Files
dikte/dikte/assistant.py
T
yusufipek 22a73bd939 Merge master into the OpenCode Go branch
Master grew Google AI Studio and Antigravity as providers, a doctor that
names each provider's own key, and settings that fetch every hosted
model list as the window opens. OpenCode Go is folded into each: its
name joins SERVICES and the doctor's key table, its key row sits beside
Google's, its Fetch button follows the per-provider pattern Google's
uses, and _load_hosted_models fetches its catalog at open when a key is
on file, filling the cleanup and agent boxes alike.
2026-08-27 16:27:57 +03:00

731 lines
29 KiB
Python

"""Handing a dictation to an agent as a command, and pasting back its answer.
Five of them, because not everyone has the same one installed:
Claude Code `claude -p`, the session you would have opened yourself
Codex `codex exec`, the same idea from the other shop
Antigravity `agy -p`, Google's, with a browser of its own attached
OpenRouter a plain chat request, over the key that is already configured
OpenCode Go a plain chat request, over a subscription to open coding models
The first three are the whole machine: they run commands, read files, and reach
whatever skills and services you have connected, which is what makes "put that
in my calendar on Thursday" a thing you can say. The two chat requests cannot
touch any of that, and are there so that a question still gets an answer on a
machine with no CLI installed at all.
What each of the three is allowed to do without asking is settled where that
program keeps its own permissions, not here. Dikte hands Claude Code the mode
chosen in Settings because it has a flag for one; Codex gets a sandbox for the
same reason; Antigravity has neither, and reads its own allow-rules instead.
Whichever it is, the reply is pasted exactly where the transcript would have
been, and the conversation carries across dictations so that "and move that to
Friday" knows what "that" is.
The three CLIs are read as they stream rather than waited out. A command that
reaches for the calendar or the web takes long enough that a still indicator is
indistinguishable from a hang, so every tool they pick up is named in the corner
while they work.
"""
import json
import os
import re
import shutil
import signal
import subprocess
import tempfile
import threading
import time
from . import api
from . import config as cfg
from . import paths
from .i18n import t
SESSION_FILE = cfg.DATA_DIR / "assistant.json"
PROVIDERS = ("claude", "codex", "agy", "openrouter", "opencode")
# What each one is called where a person reads it: the tray, the corner of
# the screen, and the line an error is written in.
SERVICES = {"claude": "Claude", "codex": "Codex", "agy": "Antigravity",
"openrouter": "OpenRouter", "opencode": "OpenCode Go"}
# How many messages of a chat provider's conversation are carried forward. The
# two CLIs keep their own history and need no such number; here every turn is
# resent in full, so the window has to end somewhere.
MAX_HISTORY = 24
# What to say in the indicator for a tool, keyed by the name the CLI uses.
# Anything unlisted is named as it comes, which beats a generic "working" for
# tools arriving from an MCP server nobody wrote this table for.
CLAUDE_TOOLS = {
"Bash": "Running a command…",
"BashOutput": "Running a command…",
"Read": "Reading…",
"Glob": "Looking through files…",
"Grep": "Searching the files…",
"Edit": "Editing a file…",
"Write": "Writing a file…",
"NotebookEdit": "Editing a file…",
"WebSearch": "Searching the web…",
"WebFetch": "Reading a web page…",
"Task": "Handing it to a subagent…",
"TodoWrite": "Planning…",
}
CODEX_ITEMS = {
"command_execution": "Running a command…",
"reasoning": "Thinking…",
"web_search": "Searching the web…",
"file_change": "Editing a file…",
"patch_apply": "Editing a file…",
"todo_list": "Planning…",
}
# Antigravity carries a browser around with it, so the handful of names below
# stand in for the couple of dozen browser_* tools it can pick up; being told
# which mouse button moved is not what the corner of the screen is for.
AGY_TOOLS = {
"run_command": "Running a command…",
"command_status": "Running a command…",
"send_command_input": "Running a command…",
"view_file": "Reading…",
"read_url_content": "Reading a web page…",
"list_dir": "Looking through files…",
"find_by_name": "Looking through files…",
"grep_search": "Searching the files…",
"search_web": "Searching the web…",
"replace_file_content": "Editing a file…",
"multi_replace_file_content": "Editing a file…",
"sed_file": "Editing a file…",
"notebook_edit": "Editing a file…",
"write_to_file": "Writing a file…",
"generate_image": "Drawing…",
"manage_task": "Planning…",
"invoke_subagent": "Handing it to a subagent…",
"browser_subagent": "Handing it to a subagent…",
}
# How hard to think, in each provider's own vocabulary. The setting is one
# scale, offered once, because "think harder" is one thing to want; what differs
# is only which rungs a provider has. A level it does not have lands on the
# nearest one it does rather than being dropped.
CLAUDE_EFFORT = {"none": "low", "minimal": "low", "low": "low",
"medium": "medium", "high": "high", "xhigh": "xhigh",
"max": "max"}
# "minimal" was Codex's bottom rung until the newer models replaced it with
# "none", and each of them rejects the other's word for it with a 400. "low" is
# the one every model has, so the two lowest rungs land there instead.
CODEX_EFFORT = {"none": "low", "minimal": "low", "low": "low",
"medium": "medium", "high": "high", "xhigh": "high",
"max": "high"}
# agy has three rungs and no word for off, so the bottom of the ladder lands on
# "low" and the top two on "high". Shared with cleanup, which runs the same
# program for the smaller job.
AGY_EFFORT = {"none": "low", "minimal": "low", "low": "low",
"medium": "medium", "high": "high", "xhigh": "high",
"max": "high"}
class AssistantError(Exception):
pass
class Cancelled(Exception):
pass
def provider(conf):
chosen = conf["assistant_provider"]
return chosen if chosen in PROVIDERS else "claude"
def executable(name):
"""The CLI a provider runs, or "" when it needs none."""
return {"claude": "claude", "codex": "codex", "agy": "agy"}.get(name, "")
def model(conf):
"""Which model answered, for the history to record.
Each provider keeps its own setting, and the one a CLI is left on has no id
to report, only a name — the same arrangement cleanup.model() makes.
"""
name = provider(conf)
if name == "codex":
return conf["assistant_codex_model"].strip() or "codex"
if name == "agy":
return conf["assistant_agy_model"].strip() or "agy"
if name == "openrouter":
return conf["assistant_openrouter_model"]
if name == "opencode":
return conf["assistant_opencode_model"]
return conf["assistant_model"]
def display_name(conf):
"""What to call the thing being asked, in the tray and in the corner."""
return SERVICES.get(provider(conf), "OpenRouter")
# --- the conversation -----------------------------------------------------
#
# One conversation is kept across dictations, so "and move that to tomorrow"
# means something. It is dropped once it has sat unused for long enough: an hour
# later the next command is almost certainly a new subject, and dragging the old
# one along costs tokens and invites an answer to the wrong question. Switching
# provider drops it too, since none of them can pick up another's thread.
def _read_session():
"""The stored conversation row, or {} however the file fails to read."""
try:
with open(SESSION_FILE, encoding="utf-8") as fh:
row = json.load(fh)
except (OSError, json.JSONDecodeError, ValueError):
return {}
return row if isinstance(row, dict) else {}
def _read_row(name, max_age_seconds):
row = _read_session()
if row.get("provider") != name:
return {}
if max_age_seconds and time.time() - row.get("ts", 0) > max_age_seconds:
return {}
return row
def read_session(name, max_age_seconds):
"""The id to resume, for the providers that keep their own history."""
return str(_read_row(name, max_age_seconds).get("session", ""))
def read_messages(name, max_age_seconds):
"""The conversation so far, for the provider that does not."""
messages = _read_row(name, max_age_seconds).get("messages")
return messages if isinstance(messages, list) else []
def write_session(name, session="", messages=None):
row = {"provider": name, "session": session, "ts": time.time()}
if messages is not None:
row["messages"] = messages[-MAX_HISTORY:]
try:
cfg.DATA_DIR.mkdir(parents=True, exist_ok=True)
with open(SESSION_FILE, "w", encoding="utf-8") as fh:
json.dump(row, fh, ensure_ascii=False)
except OSError:
pass
def clear_session():
try:
SESSION_FILE.unlink(missing_ok=True)
except OSError:
pass
def stored_provider():
"""Whose conversation is on disk, whatever the setting says now."""
return str(_read_session().get("provider", ""))
def session_age():
"""Seconds since the stored conversation was last used, or None."""
row = _read_session()
if not (row.get("session") or row.get("messages")):
return None
return time.time() - row.get("ts", 0)
# --- the call -------------------------------------------------------------
def working_dir(conf):
wanted = conf["assistant_dir"].strip()
if wanted and os.path.isdir(os.path.expanduser(wanted)):
return os.path.expanduser(wanted)
return os.path.expanduser("~")
def ask(prompt, conf, on_stage=None, should_stop=None):
"""Run the prompt through the configured agent. Returns (answer, warning).
`warning` is set when the answer arrived but something about the run should
be seen anyway, a denied tool above all: the reply still reads like a normal
one, and only the denial explains why it did not do what it was asked to.
"""
name = provider(conf)
if name in ("openrouter", "opencode"):
return _ask_chat(name, SERVICES[name], prompt, conf, on_stage)
binary = executable(name)
if not shutil.which(binary):
raise AssistantError(t(
"{binary} not found. Install it, or pick another provider under "
"Settings → Agent.", binary=binary,
))
run = {"claude": _ask_claude, "codex": _ask_codex, "agy": _ask_agy}[name]
session = read_session(name, conf["assistant_session_minutes"] * 60)
try:
return run(prompt, conf, session, on_stage, should_stop)
except _SessionGone:
# The conversation it pointed at is not there any more: the history was
# cleared, or it was started somewhere else. Say nothing and start over,
# because from the outside this is just the first command of the day.
clear_session()
return run(prompt, conf, "", on_stage, should_stop)
class _SessionGone(Exception):
pass
# --- Claude Code ----------------------------------------------------------
def _ask_claude(prompt, conf, session, on_stage, should_stop):
cmd = [
"claude", "-p", prompt,
"--output-format", "stream-json", "--verbose",
"--model", conf["assistant_model"],
"--permission-mode", conf["assistant_permission_mode"],
"--append-system-prompt", conf.assistant_prompt(),
]
effort = CLAUDE_EFFORT.get(conf["assistant_reasoning"], "")
if effort:
cmd += ["--effort", effort]
if session:
cmd += ["--resume", session]
found = {"answer": "", "warning": "", "session": "", "failure": ""}
def on_event(event):
kind = event.get("type")
if kind == "system" and event.get("subtype") == "init":
found["session"] = event.get("session_id") or found["session"]
elif kind == "assistant" and on_stage:
for block in event.get("message", {}).get("content", []) or []:
if isinstance(block, dict) and block.get("type") == "tool_use":
on_stage(_claude_label(block))
elif kind == "result":
found["session"] = event.get("session_id") or found["session"]
answer = (event.get("result") or "").strip()
if event.get("is_error"):
found["failure"] = answer or t("Claude ended with an error.")
else:
found["answer"] = answer
found["warning"] = _denial_warning(event)
code, stderr = _stream(cmd, conf, on_event, should_stop)
return _conclude(found, code, stderr, session, "claude")
def _claude_label(block):
name = block.get("name", "")
if name in CLAUDE_TOOLS:
return t(CLAUDE_TOOLS[name])
if name == "Skill":
return t("Using {name}…", name=(block.get("input") or {}).get("skill") or "a skill")
if name.startswith("mcp__"):
parts = name.split("__")
return t("Using {name}…", name=parts[1] if len(parts) > 1 else name)
return t("Using {name}…", name=name or "a tool")
def _denial_warning(event):
denials = event.get("permission_denials") or []
names = []
for denial in denials:
name = denial.get("tool_name") if isinstance(denial, dict) else str(denial)
if name and name not in names:
names.append(name)
return t("It was not allowed to use: {tools}", tools=", ".join(names)) if names else ""
# --- Codex ----------------------------------------------------------------
def _ask_codex(prompt, conf, session, on_stage, should_stop):
# Codex takes no system prompt of its own, so the instruction rides along in
# front of the command, kept apart from it so the two are not read as one.
body = f"{conf.assistant_prompt()}\n\n---\n\n{prompt}"
settings = [
"-c", f'sandbox_mode="{conf["assistant_codex_sandbox"]}"',
"-c", 'approval_policy="never"', # there is nobody here to approve
"--skip-git-repo-check",
"--json",
]
if conf["assistant_codex_model"].strip():
settings += ["-m", conf["assistant_codex_model"].strip()]
effort = CODEX_EFFORT.get(conf["assistant_reasoning"], "")
if effort:
settings += ["-c", f'model_reasoning_effort="{effort}"']
cmd = (["codex", "exec", "resume", session] if session else ["codex", "exec"])
cmd += settings + [body]
found = {"answer": "", "warning": "", "session": "", "failure": ""}
def on_event(event):
kind = event.get("type")
if kind == "thread.started":
found["session"] = event.get("thread_id") or found["session"]
elif kind in ("item.started", "item.completed"):
item = event.get("item") or {}
item_type = item.get("type")
# Every message is kept rather than only the last: a run can say
# something, use a tool and speak again, and the closing one is the
# answer.
if item_type == "agent_message" and kind == "item.completed":
found["answer"] = (item.get("text") or "").strip() or found["answer"]
elif on_stage and kind == "item.started":
on_stage(_codex_label(item))
elif kind in ("turn.failed", "error"):
error = event.get("error") or {}
found["failure"] = (error.get("message") if isinstance(error, dict)
else str(error)) or t("Codex ended with an error.")
code, stderr = _stream(cmd, conf, on_event, should_stop)
return _conclude(found, code, stderr, session, "codex")
def _codex_label(item):
item_type = item.get("type", "")
if item_type in CODEX_ITEMS:
return t(CODEX_ITEMS[item_type])
if item_type == "mcp_tool_call":
return t("Using {name}…", name=item.get("server") or item.get("tool") or "a tool")
return t("Using {name}…", name=item_type or "a tool")
def codex_models():
"""The models Codex itself would offer right now, best first.
`codex debug models` prints the catalog the CLI's own model picker reads,
fetched from OpenAI and cached beside Codex's config, so the list is as
current as the installed Codex and there is no second list to keep up to
date here. Entries the picker hides are internal and stay hidden. A machine
without Codex, or one too old to have the command, answers with nothing and
the caller keeps its built-in list.
"""
if not shutil.which("codex"):
return []
try:
proc = subprocess.run(["codex", "debug", "models"],
capture_output=True, text=True, timeout=30)
catalog = json.loads(proc.stdout or "null")
except (OSError, subprocess.SubprocessError, ValueError):
return []
if not isinstance(catalog, dict):
return []
rows = [row for row in catalog.get("models") or []
if isinstance(row, dict) and row.get("slug")
and row.get("visibility") != "hide"]
rows.sort(key=lambda row: row.get("priority") or 0)
return [row["slug"] for row in rows]
# --- Antigravity ----------------------------------------------------------
def _ask_agy(prompt, conf, session, on_stage, should_stop):
# Antigravity takes no system prompt of its own either, so the instruction
# rides in front of the command, kept apart from it so the two are not read
# as one.
body = f"{conf.assistant_prompt()}\n\n---\n\n{prompt}"
cmd = [
"agy", "-p", body,
"--output-format", "stream-json",
# agy stops after five minutes unless it is told otherwise, which is
# shorter than the timeout this setting offers.
"--print-timeout", f"{conf['assistant_timeout']}s",
]
# One or the other, always: left with neither, agy picks up whichever
# project it was last in and works in that project's directory rather than
# the one _stream is about to start it in.
cmd += ["--conversation", session] if session else ["--new-project"]
if conf["assistant_agy_model"].strip():
cmd += ["--model", conf["assistant_agy_model"].strip()]
effort = AGY_EFFORT.get(conf["assistant_reasoning"], "")
if effort:
# Most of agy's own model ids carry the effort in their suffix already;
# this is for the ones that do not.
cmd += ["--effort", effort]
found = {"answer": "", "warning": "", "session": "", "failure": ""}
def on_event(event):
kind = event.get("event")
if kind == "init":
found["session"] = event.get("conversation_id") or found["session"]
elif kind == "step_update":
step = event.get("step_update") or {}
# A tool is reported twice, once when it starts and once when it is
# done; the corner wants the first of those.
if (on_stage and step.get("step_type") == "tool"
and step.get("state") == "ACTIVE"):
on_stage(_agy_label(step))
elif kind == "result":
result = event.get("result") or {}
found["session"] = result.get("conversation_id") or found["session"]
answer = (result.get("response") or "").strip()
if result.get("status") == "SUCCESS":
found["answer"] = answer
else:
found["failure"] = answer or t("{service} ended with an error.",
service="Antigravity")
code, stderr = _stream(cmd, conf, on_event, should_stop)
return _conclude(found, code, stderr, session, "agy")
def _agy_label(step):
name = step.get("tool_name", "")
if name in AGY_TOOLS:
return t(AGY_TOOLS[name])
if name.startswith("browser_") or name.startswith("capture_browser"):
return t("Working in the browser…")
if name == "call_mcp_tool":
server = (step.get("tool_info") or {}).get("parameters") or {}
return t("Using {name}…", name=server.get("server") or "a tool")
return t("Using {name}…", name=name or "a tool")
def agy_models():
"""The models Antigravity itself would offer right now, in its own order.
`agy models` prints one `id<TAB>display name` line per model, so the list
is as current as the account behind the CLI. Unlike Codex it asks Google
rather than a cache on disk, a couple of seconds the caller spends off the
interface thread. A machine without agy, or a call that fails, answers
with nothing and the caller keeps its built-in list.
"""
if not shutil.which("agy"):
return []
try:
proc = subprocess.run(["agy", "models"],
capture_output=True, text=True, timeout=30)
except (OSError, subprocess.SubprocessError):
return []
if proc.returncode != 0:
return []
ids = []
for line in (proc.stdout or "").splitlines():
model_id, tab, _ = line.partition("\t")
if tab and model_id.strip():
ids.append(model_id.strip())
return ids
# --- OpenRouter and OpenCode Go -------------------------------------------
def _ask_chat(name, service, prompt, conf, on_stage):
"""A plain question and answer, over a chat provider's key.
No tools, no files, no calendar. It is the fallback for a machine with
neither CLI on it, so it says what it knows and nothing else. The
conversation is ours to keep here, since there is no session on the other
end to resume.
"""
if on_stage:
on_stage(t("Thinking…"))
history = read_messages(name, conf["assistant_session_minutes"] * 60)
messages = history + [{"role": "user", "content": prompt}]
model = (conf["assistant_openrouter_model"] if name == "openrouter"
else conf["assistant_opencode_model"])
base_url = (conf["openrouter_base_url"] if name == "openrouter"
else conf["opencode_base_url"])
key = conf.openrouter_key() if name == "openrouter" else conf.opencode_key()
try:
answer = api.chat(
messages, key, model, conf.assistant_prompt(),
reasoning=conf["assistant_reasoning"], base_url=base_url,
timeout=conf["assistant_timeout"], provider=name, service=service,
)
except api.ApiError as exc:
raise AssistantError(str(exc)) from exc
write_session(name,
messages=messages + [{"role": "assistant", "content": answer}])
return answer, ""
# --- running a CLI --------------------------------------------------------
def _stream(cmd, conf, on_event, should_stop):
"""Run cmd, hand every JSON line it prints to on_event.
Returns (exit code, stderr). Raises Cancelled when the stop was asked for,
and AssistantError when the clock ran out.
"""
# stderr lands in a file rather than a pipe: nobody drains it while stdout
# is being read, and a CLI chatty enough on stderr would fill the pipe's
# buffer and wedge both of us. A file has no such limit, and is read once
# at the end, which is the only moment stderr matters.
stderr_file = tempfile.TemporaryFile()
# On POSIX the run gets its own session, so that ending it can take down
# every subprocess it started, not just the CLI itself.
grouped = {"start_new_session": True} if os.name == "posix" else {}
try:
proc = subprocess.Popen(
cmd, cwd=working_dir(conf), stdin=subprocess.DEVNULL,
stdout=subprocess.PIPE, stderr=stderr_file,
text=True, encoding="utf-8", errors="replace", bufsize=1,
creationflags=paths.NO_WINDOW,
**grouped,
)
except OSError as exc:
stderr_file.close()
raise AssistantError(t("Could not run {binary}: {error}",
binary=cmd[0], error=exc)) from exc
# Reading the stream blocks between lines, and a model that thinks for a
# minute sends none. So the clock and the stop button are watched from the
# side, and they end the run by killing the process: that closes the stream
# and the loop below falls out of its own accord.
ended = {"cancelled": False, "timed_out": False}
watchdog = threading.Thread(
target=_watch,
args=(proc, time.monotonic() + conf["assistant_timeout"], should_stop, ended),
daemon=True,
)
watchdog.start()
try:
for line in proc.stdout:
line = line.strip()
# Both CLIs print the odd unstructured line among the JSON.
if not line.startswith("{"):
continue
try:
event = json.loads(line)
except (json.JSONDecodeError, ValueError):
continue
if isinstance(event, dict):
on_event(event)
finally:
stderr = _finish(proc, stderr_file)
watchdog.join(timeout=1)
if ended["cancelled"]:
raise Cancelled()
if ended["timed_out"]:
raise AssistantError(t("It did not finish within {seconds} seconds.",
seconds=conf["assistant_timeout"]))
return proc.returncode, stderr
# Failures that a fresh session cannot cure: an exhausted quota, a signed-out
# CLI, a network that is down. A resumed run that dies with one of these is
# reported as what it is, not retried without the session, because the retry
# would fail the same way after making the user wait through a second run.
_API_TROUBLE = re.compile(
r"(?i)rate.?limit|quota|overloaded|too many requests|credit|billing|"
r"insufficient|unauthorized|forbidden|authentication|invalid.{0,8}key|"
r"log ?in|logged.?out|network|connection|ECONN|ENOTFOUND|ETIMEDOUT|"
r"\b(401|403|429|5\d\d)\b")
def _conclude(found, code, stderr, session, name):
"""Turn what the stream said into an answer, or into the reason there is none."""
service = SERVICES.get(name, name)
if code != 0 and not found["answer"]:
# A resumed run that died with nothing to show is treated as the
# session being gone, whatever the wording: this code used to look for
# "session ... not found" in stderr, but a CLI update or another
# language rewords that and the recovery stops working. Retrying costs
# one clean start, and cannot loop because the retry resumes nothing.
# Recognised API trouble is the exception: it is not the session's
# fault, and the retry would only repeat it.
blame = last_line(stderr) or found["failure"] or ""
if session and not _API_TROUBLE.search(blame):
raise _SessionGone()
raise AssistantError(blame or t(
"{service} exited with code {code}.", service=service, code=code))
if found["failure"] and not found["answer"]:
raise AssistantError(found["failure"])
if not found["answer"]:
raise AssistantError(t("{service} answered with nothing.", service=service))
if found["session"]:
write_session(name, found["session"])
return found["answer"], found["warning"]
def _watch(proc, deadline, should_stop, ended):
while proc.poll() is None:
if should_stop is not None and should_stop():
ended["cancelled"] = True
break
if time.monotonic() > deadline:
ended["timed_out"] = True
break
time.sleep(0.25)
if ended["cancelled"] or ended["timed_out"]:
kill_tree(proc)
def kill_tree(proc):
"""End the process and everything it started.
A CLI runs tools as subprocesses of its own, and ending only the CLI would
leave those behind, still working on a question nobody is waiting for.
Shared with cleanup, which runs the same two programs. Every failure here
is swallowed: the process being already gone is the outcome being asked for.
"""
if os.name == "nt":
# There is no process group to signal on Windows; taskkill walks the
# tree instead. The wait after it is best-effort, so a tree that will
# not die does not hang the caller on top of everything else.
subprocess.run(
["taskkill", "/T", "/F", "/PID", str(proc.pid)],
capture_output=True,
creationflags=paths.NO_WINDOW,
)
try:
proc.wait(timeout=3)
except (subprocess.TimeoutExpired, OSError):
pass
return
# The Popen was started with start_new_session=True, so the pid names a
# whole session to signal. SIGTERM first for a clean exit, SIGKILL for a
# tree that ignored it.
try:
os.killpg(proc.pid, signal.SIGTERM)
except (ProcessLookupError, PermissionError, OSError):
return
try:
proc.wait(timeout=3)
except subprocess.TimeoutExpired:
try:
os.killpg(proc.pid, signal.SIGKILL)
except (ProcessLookupError, PermissionError, OSError):
pass
def _finish(proc, stderr_file):
try:
proc.wait(timeout=5)
except subprocess.TimeoutExpired:
kill_tree(proc)
# Read back what the CLI wrote to its stderr file, decoded leniently: a
# dying CLI is exactly the one likely to print something half-encoded.
try:
stderr_file.seek(0)
stderr = stderr_file.read().decode("utf-8", "replace")
except (OSError, ValueError):
stderr = ""
for stream in (proc.stdout, stderr_file):
try:
stream.close()
except OSError:
pass
return stderr
def last_line(text):
"""The line worth showing out of a CLI's stderr: the last one it wrote.
Shared with cleanup, which runs the same two programs for a different job
and fails the same way when they are unhappy.
"""
lines = [line for line in (text or "").splitlines() if line.strip()]
return lines[-1].strip() if lines else ""