tamfis-code 0.2.3__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,985 @@
1
+ """Interactive terminal composer with multiline history, explicit chat/audit/
2
+ plan/agent modes, durable saved-plan execution, streamed tools and approvals,
3
+ session recovery, diffs/revert, model routing, context checkpoints, and queue
4
+ management.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import re
10
+ from pathlib import Path
11
+ from typing import Optional
12
+
13
+ from prompt_toolkit import PromptSession
14
+ from prompt_toolkit.history import FileHistory
15
+ from prompt_toolkit.key_binding import KeyBindings
16
+ from rich.console import Console
17
+ from rich.markdown import Markdown
18
+ from rich.table import Table
19
+
20
+ from . import state as local_state
21
+ from .api_client import AuthRequiredError, RemoteAPIClient, RemoteAPIError
22
+ from .config import APPROVAL_MODES, CONFIG_DIR, Config, MODE_ALIASES
23
+ from .doctor import run_doctor
24
+ from .render import StreamRenderer, print_banner, print_error, print_recent_thread, print_unified_diff
25
+ from .runner import resolve_approval_decision, retry_task_and_stream, run_ai_task_and_stream, run_shell_command
26
+ from .runner_local import run_local_agent_turn, run_local_shell_command
27
+ from .safety import revert_mutation as local_revert_mutation
28
+ from .tasks import find_recent_task
29
+ from .workspace import (
30
+ WorkspaceContext, blocking_dirty_files, context_from_session, discover_local_repository,
31
+ find_resumable_session, resolve_local_workspace,
32
+ )
33
+
34
+ HELP_TEXT = """\
35
+ Natural-language text submits a full coding-agent task (mode: coding).
36
+ $ <command> explicit shell command
37
+ /run <command> explicit shell command
38
+ /shell <command> explicit shell command
39
+ /chat <question> conversational/read-only coding assistance
40
+ /audit <objective> AI audit mode (read-only)
41
+ /plan <objective> create and save an executable plan (no changes)
42
+ /plans list saved plans for this session
43
+ /plan show [plan_id] show a saved plan (latest/active if omitted)
44
+ /execute-plan [plan_id] execute a saved plan (latest/active if omitted)
45
+ /agent <objective> full coding-agent mode (inspect + edit + verify)
46
+ /execute <objective> AI execute mode (tools + approval policy)
47
+
48
+ /help show this help
49
+ /status show session/workspace/approval status
50
+ /context show cached repository/task context
51
+ /reports show the repository report index
52
+ /queue show queued instructions
53
+ /queue <instruction> append a follow-up instruction
54
+ /cwd show the current workspace root
55
+ /doctor run connectivity/auth checks
56
+ /resume [session_id] switch to another session (most recent if omitted)
57
+ /retry [task_id] retry a failed task (most recent failure if omitted)
58
+ /agents list sessions and their latest task status
59
+ /delegate <a> | <b> run objectives a, b, ... as concurrent delegated sub-tasks
60
+ (requires enable_subagent_delegation = true in config.toml,
61
+ or TAMFIS_CODE_ENABLE_SUBAGENT_DELEGATION=1)
62
+ /diffs [n] show the last n file mutations in this session (default 10)
63
+ /diff [mutation_id] show a semantic unified diff (latest if omitted)
64
+ /revert <mutation_id> restore a file to its content before that mutation (or
65
+ delete it, if that mutation created the file)
66
+ /detach exit without cancelling anything server-side (same as /exit --
67
+ nothing in this REPL ties a task's lifetime to this process; see
68
+ `tamfis-code attach <session_id>` in another terminal to reconnect)
69
+ /clear clear the screen
70
+ /compact save a durable checkpoint of the current task context
71
+ /permissions show approval policy and immutable server safeguards
72
+ /mode show the active approval mode and available modes
73
+ /mode <name> switch mode: manual | accept-edits | auto | plan
74
+ /model show the active model route
75
+ /model list [route] list coding models (route: hf or openrouter)
76
+ /model auto restore Hugging Face -> OpenRouter automatic routing
77
+ /model <route> [id] pin a provider, optionally with a catalog model id
78
+ /tools show the tools exposed to tamfis-code tasks
79
+ /pty start [command] start a persistent background terminal (default: bash)
80
+ /pty list list this session's background terminals
81
+ /pty send <id> <text> send input to a background terminal (a trailing \\n
82
+ submits the line; omit it to send raw keystrokes)
83
+ /pty read <id> show output produced since the last /pty read
84
+ /pty kill <id> terminate a background terminal
85
+ /exit quit (also: /quit, Ctrl+D, Ctrl+C)
86
+
87
+ Not yet implemented in this pass: /notifications.
88
+ """
89
+
90
+
91
+ class Intent:
92
+ def __init__(self, kind: str, *, command: str = "", objective: str = "", mode: str = "coding"):
93
+ self.kind = kind
94
+ self.command = command
95
+ self.objective = objective
96
+ self.mode = mode
97
+
98
+
99
+ def contextualize_short_reply(raw: str, *, has_context: bool) -> str:
100
+ """Turn terse conversational controls into explicit contextual intents.
101
+
102
+ Keep truly new-session input untouched; a bare ``1`` only means "step
103
+ one" when there is an active/prior task to refer to.
104
+ """
105
+ text = raw.strip()
106
+ if not has_context:
107
+ return text
108
+ lowered = text.lower().rstrip(".! ")
109
+ if lowered in {"ok", "okay", "yes", "y", "sure", "go", "proceed"}:
110
+ return "Yes. Proceed with the action or next step you just proposed."
111
+ if lowered in {"no", "n", "stop"}:
112
+ return "No. Do not proceed with the action you just proposed."
113
+ if lowered.isdigit():
114
+ return f"Proceed with step {int(lowered)} from your immediately preceding plan."
115
+ match = re.fullmatch(r"(?:step\s*)?(\d+)", lowered)
116
+ if match:
117
+ return f"Proceed with step {int(match.group(1))} from your immediately preceding plan."
118
+ return text
119
+
120
+
121
+ def parse_intent(raw: str) -> Intent:
122
+ text = raw.strip()
123
+ if text.startswith("$ "):
124
+ return Intent("shell", command=text[2:].strip())
125
+ if text.startswith("/run "):
126
+ return Intent("shell", command=text[5:].strip())
127
+ if text.startswith("/shell "):
128
+ return Intent("shell", command=text[7:].strip())
129
+ if text.startswith("/audit "):
130
+ return Intent("ai", objective=text[7:].strip(), mode="audit")
131
+ if text.startswith("/chat "):
132
+ return Intent("ai", objective=text[6:].strip(), mode="chat")
133
+ if text.startswith("/plan "):
134
+ return Intent("ai", objective=text[6:].strip(), mode="plan")
135
+ if text == "/execute-plan" or text.startswith("/execute-plan "):
136
+ return Intent("saved_plan", command=text[len("/execute-plan"):].strip())
137
+ if text.startswith("/agent "):
138
+ return Intent("ai", objective=text[7:].strip(), mode="execute")
139
+ if text.startswith("/execute "):
140
+ return Intent("ai", objective=text[9:].strip(), mode="execute")
141
+ if text.startswith("/ask "):
142
+ return Intent("ai", objective=text[5:].strip(), mode="coding")
143
+ return Intent("ai", objective=text, mode="coding")
144
+
145
+
146
+ async def run_interactive(
147
+ client: Optional[RemoteAPIClient], config: Config, workspace: WorkspaceContext,
148
+ *, provider: str = "auto", model: Optional[str] = None,
149
+ ) -> None:
150
+ """Drives the interactive REPL. `client=None` means standalone mode: no
151
+ TamfisGPT Remote Workspace backend, no login -- AI turns/shell commands
152
+ run through runner_local.py's local agent loop calling `provider`
153
+ directly instead. A handful of features that are inherently remote-server
154
+ concepts (background PTY terminals, a remote model catalog) have no
155
+ local equivalent yet and degrade to a clear message rather than crashing;
156
+ everything else (diffs/revert, resume, agents listing, retry, delegate,
157
+ doctor) has a real local implementation in this mode.
158
+ """
159
+ standalone = client is None
160
+ provider_manager = None
161
+ provider_type = None
162
+ last_turn: Optional[tuple[str, str]] = None # (objective, mode) -- standalone /retry target
163
+
164
+ if standalone:
165
+ from .local_chat import resolve_provider_type
166
+ from .providers import ProviderManager
167
+
168
+ console = Console(no_color=not config.colour)
169
+ try:
170
+ provider_type = resolve_provider_type(provider)
171
+ except ValueError as exc:
172
+ print_error(console, str(exc))
173
+ return
174
+ provider_manager = ProviderManager()
175
+ else:
176
+ console = Console(no_color=not config.colour)
177
+
178
+ print_banner(
179
+ console, host=(f"local:{provider}" if standalone else config.api_base),
180
+ workspace_root=workspace.workspace_root, mode="interactive", approval_policy=config.approval_policy,
181
+ )
182
+ console.print("[dim]Type /help for commands. Paste up to 1,000,000 characters; Alt+Enter adds a newline. Ctrl+D or Ctrl+C exits.[/dim]\n")
183
+
184
+ CONFIG_DIR.mkdir(parents=True, exist_ok=True)
185
+ history_path = CONFIG_DIR / "history"
186
+ bindings = KeyBindings()
187
+ # Local only: which byte offset this REPL has already displayed per PTY
188
+ # id, so /pty read shows only new output. The server (RemotePtySession/
189
+ # pty_broker's ring buffer) is the durable source of truth -- losing
190
+ # this dict on restart just means the next /pty read re-shows output
191
+ # already seen, never data loss. (Remote mode only -- standalone has no
192
+ # PTY backend at all yet, see the /pty handler below.)
193
+ pty_offsets: dict[str, int] = {}
194
+
195
+ @bindings.add("enter")
196
+ def _submit(event) -> None:
197
+ event.current_buffer.validate_and_handle()
198
+
199
+ @bindings.add("escape", "enter")
200
+ def _newline(event) -> None:
201
+ event.current_buffer.insert_text("\n")
202
+
203
+ # multiline=True is essential for bracketed terminal paste: embedded
204
+ # newlines remain part of one objective instead of submitting the first
205
+ # line and treating the remainder as accidental follow-up commands. The
206
+ # bindings retain familiar Enter-to-submit behaviour.
207
+ session: PromptSession = PromptSession(
208
+ history=FileHistory(str(history_path)), multiline=True, key_bindings=bindings,
209
+ )
210
+
211
+ while True:
212
+ queued_item = next((item for item in local_state.get_session_state(workspace.session_id).queued_user_instructions
213
+ if item.get("status") == "queued"), None)
214
+ if queued_item and queued_item.get("classification") not in {"pause", "cancel"}:
215
+ local_state.update_instruction(workspace.session_id, str(queued_item.get("id")), "running")
216
+ text = str(queued_item.get("text") or "")
217
+ console.print(f"[cyan]◆ Queued instruction[/cyan] {queued_item.get('id')}: {text}")
218
+ elif queued_item:
219
+ local_state.update_instruction(workspace.session_id, str(queued_item.get("id")), "completed")
220
+ console.print(f"[yellow]◆ {queued_item.get('classification')} boundary reached[/yellow]")
221
+ continue
222
+ else:
223
+ try:
224
+ text = await session.prompt_async("tamfis-code> ")
225
+ except KeyboardInterrupt:
226
+ # NOTE: this used to just `continue`, silently redrawing the
227
+ # prompt -- Ctrl+C appeared to do nothing at all, with no
228
+ # documented way to exit except Ctrl+D or typing /exit. Ctrl+C
229
+ # while an AI task/command is actively streaming is a separate,
230
+ # already-correct code path (runner.py's _install_sigint_watcher
231
+ # cancels just that task, not the whole process) -- this only
232
+ # covers the idle prompt, where Ctrl+C exiting is the standard
233
+ # expectation.
234
+ break
235
+ except EOFError:
236
+ break
237
+
238
+ text = text.strip()
239
+ if not text:
240
+ continue
241
+ if len(text) > 1_000_000:
242
+ print_error(console, "Objective exceeds the 1,000,000 character safety limit.")
243
+ continue
244
+ if text in ("/exit", "/quit", "/detach"):
245
+ # No task submitted through this REPL outlives this process's
246
+ # lifetime any differently based on which of these three the
247
+ # user types -- background durability comes from `--bg` /
248
+ # `attach` in a fresh invocation, not from how THIS process
249
+ # exits. /detach exists as a documented, discoverable alias
250
+ # matching the Phase 19 slash-command surface, not because it
251
+ # behaves differently from /exit today.
252
+ break
253
+ # A bare "/" fell all the way through to parse_intent() before, which
254
+ # doesn't treat "/" as a prefix of anything (every command check
255
+ # requires an exact match or a trailing space with content) -- it
256
+ # was submitted to the AI as a one-character objective instead of
257
+ # showing the command list the way typing "/" alone is expected to.
258
+ if text in ("/help", "/"):
259
+ console.print(HELP_TEXT)
260
+ if standalone:
261
+ console.print(
262
+ "[dim]Standalone mode: /pty and /model list have no local equivalent yet (they need a "
263
+ "persistent server / remote model catalog) -- everything else above (diffs/revert, resume, "
264
+ "agents, retry, delegate, doctor) runs fully locally, no TamfisGPT backend involved.[/dim]"
265
+ )
266
+ continue
267
+ if text == "/cwd":
268
+ console.print(workspace.workspace_root)
269
+ continue
270
+ if text == "/status":
271
+ state = local_state.get_session_state(workspace.session_id)
272
+ identity_line = (
273
+ f"session_id={workspace.session_id} (standalone, local session)"
274
+ if standalone else f"session_id={workspace.session_id} server_id={workspace.server_id}"
275
+ )
276
+ backend_line = (
277
+ f"approval_policy={config.approval_policy} provider={provider_type.value if provider_type else provider}"
278
+ if standalone else f"approval_policy={config.approval_policy} api_base={config.api_base}"
279
+ )
280
+ console.print(
281
+ f"{identity_line}\n"
282
+ f"workspace_root={workspace.workspace_root}\n"
283
+ f"repository_root={state.repository_root or '-'} branch={state.active_branch or '-'}\n"
284
+ f"phase={state.current_phase} execution={state.execution_status}\n"
285
+ f"queue={sum(1 for item in state.queued_user_instructions if item.get('status') == 'queued')} "
286
+ f"validations={len(state.validation_results)} issues={len(state.unresolved_issues)}\n"
287
+ f"saved_plans={len(state.saved_plans)} active_plan={state.active_plan_id or '-'}\n"
288
+ f"{backend_line}\n"
289
+ f"model={state.selected_model} route={state.selected_provider or 'auto'}"
290
+ )
291
+ continue
292
+ if text == "/context":
293
+ context = discover_local_repository(workspace.session_id, Path(workspace.workspace_root))
294
+ state = local_state.get_session_state(workspace.session_id)
295
+ console.print(f"repository={context.get('repository_root')} branch={context.get('branch') or '-'} dirty={context.get('dirty')}")
296
+ console.print(f"cwd={context.get('working_directory')} indexed_files={context.get('indexed_file_count')}")
297
+ console.print(f"task={(state.active_task or {}).get('objective') or state.conversation_summary or '-'}")
298
+ for path in context.get("instruction_files", []):
299
+ console.print(f" instruction: {path}")
300
+ continue
301
+ if text == "/reports":
302
+ discover_local_repository(workspace.session_id, Path(workspace.workspace_root))
303
+ reports = local_state.get_session_state(workspace.session_id).discovered_reports
304
+ if not reports:
305
+ console.print("[dim]No matching reports discovered in this repository.[/dim]")
306
+ for report in reports:
307
+ console.print(f" {str(report.get('modified_at', ''))[:10]} {report.get('verification')} {report.get('path')}")
308
+ continue
309
+ if text == "/plans":
310
+ state = local_state.get_session_state(workspace.session_id)
311
+ if not state.saved_plans:
312
+ console.print("[dim]No saved plans yet. Create one with /plan <objective>.[/dim]")
313
+ continue
314
+ table = Table(show_header=True, header_style="bold")
315
+ for column in ("ID", "STATUS", "OBJECTIVE", "CREATED"):
316
+ table.add_column(column)
317
+ for item in reversed(state.saved_plans):
318
+ marker = " *" if item.get("id") == state.active_plan_id else ""
319
+ table.add_row(
320
+ f"{item.get('id')}{marker}", str(item.get("status") or "ready"),
321
+ str(item.get("objective") or "")[:80], str(item.get("created_at") or "")[:19],
322
+ )
323
+ console.print(table)
324
+ continue
325
+ if text == "/plan" or text == "/plan show" or text.startswith("/plan show "):
326
+ plan_id = text[len("/plan show"):].strip() if text.startswith("/plan show") else ""
327
+ plan = local_state.get_plan(workspace.session_id, plan_id or None)
328
+ if plan is None:
329
+ print_error(console, "Plan not found. Use /plans to list saved plans.")
330
+ continue
331
+ console.print(
332
+ f"[bold]{plan.get('id')}[/bold] · {plan.get('status', 'ready')}\n"
333
+ f"[dim]Objective:[/dim] {plan.get('objective', '')}"
334
+ )
335
+ console.print(Markdown(str(plan.get("content") or "")))
336
+ continue
337
+ if text == "/queue" or text.startswith("/queue "):
338
+ arg = text[len("/queue"):].strip()
339
+ if arg:
340
+ item = local_state.enqueue_instruction(workspace.session_id, arg)
341
+ console.print(f"[cyan]Queued[/cyan] {item.id}")
342
+ for item in local_state.get_session_state(workspace.session_id).queued_user_instructions:
343
+ console.print(f" {item.get('id')} {item.get('status')} {item.get('classification')} {item.get('text')}")
344
+ continue
345
+ if text == "/model" or text.startswith("/model "):
346
+ arg = text[len("/model"):].strip()
347
+ state = local_state.get_session_state(workspace.session_id)
348
+ if not arg:
349
+ console.print(
350
+ f"model={state.selected_model} "
351
+ f"provider={state.selected_provider or 'auto (hf -> openrouter)'}"
352
+ )
353
+ continue
354
+ parts = arg.split()
355
+
356
+ if standalone:
357
+ from .local_chat import resolve_provider_type as _resolve_provider_type
358
+ from .providers import ProviderManager as _ProviderManager, ProviderType
359
+
360
+ if parts[0].lower() == "list":
361
+ route_arg = parts[1].lower() if len(parts) > 1 else None
362
+ manager = _ProviderManager()
363
+ routes = [route_arg] if route_arg else [p.value for p in manager.PROVIDERS]
364
+ table = Table(show_header=True, header_style="bold")
365
+ for column in ("PROVIDER", "MODELS"):
366
+ table.add_column(column)
367
+ shown = False
368
+ for route_name in routes:
369
+ try:
370
+ route_type = _resolve_provider_type(route_name)
371
+ except ValueError:
372
+ continue
373
+ pcfg = manager.PROVIDERS.get(route_type)
374
+ if pcfg:
375
+ table.add_row(pcfg.name, ", ".join(pcfg.models) or pcfg.default_model)
376
+ shown = True
377
+ console.print(table if shown else "[dim]Unknown provider. Use hf, nvidia, openrouter, or ollama.[/dim]")
378
+ continue
379
+ if parts[0].lower() == "auto":
380
+ provider_type = ProviderType.AUTO
381
+ local_state.save_session_state(workspace.session_id, selected_model="auto", selected_provider=None)
382
+ console.print("[green]Provider routing set to automatic.[/green]")
383
+ continue
384
+ try:
385
+ provider_type = _resolve_provider_type(parts[0])
386
+ except ValueError as exc:
387
+ print_error(console, f"{exc} Usage: /model auto | /model <hf|nvidia|openrouter|ollama> [model-id]")
388
+ continue
389
+ model_id = parts[1] if len(parts) > 1 else "auto"
390
+ if len(parts) > 2:
391
+ print_error(console, "Model ids cannot contain spaces.")
392
+ continue
393
+ model = None if model_id == "auto" else model_id
394
+ local_state.save_session_state(
395
+ workspace.session_id, selected_model=model_id, selected_provider=parts[0].lower(),
396
+ )
397
+ console.print(f"[green]Pinned {parts[0].lower()} route[/green] model={model_id}")
398
+ continue
399
+
400
+ if parts[0].lower() == "list":
401
+ route = parts[1].lower() if len(parts) > 1 else None
402
+ if route not in (None, "hf", "openrouter"):
403
+ print_error(console, "Usage: /model list [hf|openrouter]")
404
+ continue
405
+ api_provider = "huggingface" if route == "hf" else route
406
+ try:
407
+ result = await client.list_models(api_provider)
408
+ except (AuthRequiredError, RemoteAPIError) as e:
409
+ print_error(console, str(e))
410
+ continue
411
+ rows = [m for m in (result.get("models") or []) if "coding" in [str(c).lower() for c in (m.get("categories") or [])]]
412
+ table = Table(show_header=True, header_style="bold")
413
+ for column in ("ID", "PROVIDER", "REASONING", "MAX TOKENS"):
414
+ table.add_column(column)
415
+ for item in rows[:40]:
416
+ table.add_row(
417
+ str(item.get("id")), str(item.get("provider") or item.get("backend") or ""),
418
+ str(item.get("reasoning") or "-"), str(item.get("maxTokens") or "-"),
419
+ )
420
+ console.print(table if rows else "[dim]No coding models found for that route.[/dim]")
421
+ continue
422
+ if parts[0].lower() == "auto":
423
+ local_state.save_session_state(
424
+ workspace.session_id, selected_model="auto", selected_provider=None,
425
+ )
426
+ console.print("[green]Model routing set to automatic: Hugging Face, then OpenRouter.[/green]")
427
+ continue
428
+ route = parts[0].lower()
429
+ if route not in ("hf", "openrouter"):
430
+ print_error(console, "Usage: /model auto | /model <hf|openrouter> [catalog-model-id]")
431
+ continue
432
+ model_id = parts[1] if len(parts) > 1 else "auto"
433
+ if len(parts) > 2:
434
+ print_error(console, "Model ids cannot contain spaces.")
435
+ continue
436
+ if model_id != "auto":
437
+ api_provider = "huggingface" if route == "hf" else route
438
+ try:
439
+ available = await client.list_models(api_provider)
440
+ except (AuthRequiredError, RemoteAPIError) as e:
441
+ print_error(console, str(e))
442
+ continue
443
+ ids = {str(item.get("id")) for item in (available.get("models") or [])}
444
+ if model_id not in ids:
445
+ print_error(console, f"Unknown {route} model '{model_id}'. Use /model list {route}.")
446
+ continue
447
+ local_state.save_session_state(
448
+ workspace.session_id, selected_model=model_id, selected_provider=route,
449
+ )
450
+ console.print(f"[green]Pinned {route} route[/green] model={model_id}")
451
+ continue
452
+ if text == "/tools":
453
+ table = Table(show_header=True, header_style="bold")
454
+ table.add_column("Tool")
455
+ table.add_column("Purpose")
456
+ table.add_column("Safeguard")
457
+ if standalone:
458
+ table.add_row("read_file", "Read a file's contents", "Read-only")
459
+ table.add_row("list_directory", "List a directory's contents", "Read-only")
460
+ table.add_row("search_code", "ripgrep-backed content search", "Read-only")
461
+ table.add_row("get_git_info", "Branch/HEAD/status for a repo path", "Read-only")
462
+ table.add_row("edit_file", "Exact, uniqueness-checked replacement", "Local risk classifier + approval + mutation ledger")
463
+ table.add_row("write_file", "Create or fully replace a file", "Workspace-boundary check + approval + mutation ledger")
464
+ table.add_row("execute_command", "Run a shell command", "Local risk classifier + approval (no sandboxing)")
465
+ table.add_row("browser", "Public Chromium navigation and screenshots", "Only if a monorepo browser tool is co-located")
466
+ console.print(table)
467
+ console.print(
468
+ "[dim]This is the real local tool set (mcp.py) the standalone agent loop uses -- "
469
+ "see safety.py for how risk is classified and see /diffs for the mutation ledger.[/dim]"
470
+ )
471
+ continue
472
+ table.add_row("read_file", "Bounded, line-numbered file reads", "Read-only + CWD confinement")
473
+ table.add_row("glob_files", "Find files by filename glob, not full paths", "Read-only + CWD confinement")
474
+ table.add_row("grep_files", "Search repository contents", "Read-only + CWD confinement")
475
+ table.add_row("list_symbols", "List definitions in a source file", "Read-only + CWD confinement")
476
+ table.add_row("find_dependencies", "Inspect imports and require targets", "Read-only + CWD confinement")
477
+ table.add_row("edit_file", "Exact, uniqueness-checked replacement", "Approval + mutation ledger")
478
+ table.add_row("write_file", "Create or fully replace a file", "Approval + mutation ledger")
479
+ table.add_row("extract/repackage_archive", "Inspect and rebuild repository archives", "CWD confinement + approval for writes")
480
+ table.add_row("remote_exec", "Tests, builds, Git and shell operations", "CWD confinement + risk approval")
481
+ table.add_row("browser", "Public Chromium navigation and screenshots", "Clean session + SSRF protection")
482
+ table.add_row("web_search", "Current external research when needed", "Read-only; provider/network policy")
483
+ table.add_row("codex_apps.*", "Optional organization-operated connector gateway", "Unavailable unless explicitly configured")
484
+ table.add_row("claude_apps.*", "Optional organization-operated connector gateway", "Unavailable unless explicitly configured")
485
+ console.print(table)
486
+ console.print("[dim]Use glob_files with patterns like '*.tsx' or 'src/**/*.tsx'. Do not paste a full filesystem path into pattern; use path for the search root instead.[/dim]")
487
+ console.print("[dim]Native coding tools above do not require Codex Apps, Claude Apps, or MCP. Optional connector tools appear only when a real gateway is enabled and successfully discovered.[/dim]")
488
+ continue
489
+ if text == "/pty" or text.startswith("/pty "):
490
+ if standalone:
491
+ print_error(
492
+ console,
493
+ "Background terminals require --remote (a persistent server to host them) -- "
494
+ "there's no standalone equivalent yet. Use $ <command> or /run for one-off local commands.",
495
+ )
496
+ continue
497
+ arg = text[len("/pty"):].strip()
498
+ parts = arg.split(maxsplit=1)
499
+ sub = parts[0].lower() if parts else "list"
500
+ rest = parts[1] if len(parts) > 1 else ""
501
+
502
+ if sub == "start":
503
+ shell_command = rest.strip() or "bash"
504
+ try:
505
+ pty = await client.start_pty(workspace.session_id, shell_command=shell_command)
506
+ decision = resolve_approval_decision(
507
+ console, f"[background terminal] {shell_command}", str(pty.get("safety_tier", "medium")),
508
+ config.approval_policy, interactive=True,
509
+ )
510
+ if decision != "approve_once":
511
+ await client.deny_pty(pty["id"])
512
+ console.print("[dim]Terminal not started.[/dim]")
513
+ continue
514
+ pty = await client.approve_pty(pty["id"])
515
+ except (AuthRequiredError, RemoteAPIError) as e:
516
+ print_error(console, str(e))
517
+ continue
518
+ pty_offsets[pty["id"]] = 0
519
+ console.print(f"[green]Started background terminal[/green] {pty['id']} pid={pty.get('pid')}")
520
+ console.print(f"[dim]/pty send {pty['id']} <text> /pty read {pty['id']} /pty kill {pty['id']}[/dim]")
521
+ continue
522
+
523
+ if sub == "list":
524
+ try:
525
+ sessions_list = await client.list_pty(workspace.session_id)
526
+ except (AuthRequiredError, RemoteAPIError) as e:
527
+ print_error(console, str(e))
528
+ continue
529
+ if not sessions_list:
530
+ console.print("[dim]No background terminals in this session. Use /pty start.[/dim]")
531
+ continue
532
+ table = Table(show_header=True, header_style="bold")
533
+ for column in ("ID", "STATUS", "COMMAND", "PID", "CREATED"):
534
+ table.add_column(column)
535
+ for item in sessions_list:
536
+ table.add_row(
537
+ str(item.get("id"))[:8], str(item.get("status")), str(item.get("shell_command"))[:40],
538
+ str(item.get("pid") or "-"), str(item.get("created_at") or "")[:19],
539
+ )
540
+ console.print(table)
541
+ continue
542
+
543
+ # Remaining subcommands (send/read/kill) take a target id as
544
+ # their first token; accept either the full UUID or the
545
+ # 8-character prefix /pty list shows.
546
+ id_parts = rest.split(maxsplit=1)
547
+ if not id_parts:
548
+ print_error(console, "Usage: /pty <start|list|send|read|kill> ...")
549
+ continue
550
+ prefix = id_parts[0]
551
+ payload = id_parts[1] if len(id_parts) > 1 else ""
552
+ matches = [pid for pid in pty_offsets if pid == prefix or pid.startswith(prefix)]
553
+ target_id = matches[0] if len(matches) == 1 else prefix
554
+
555
+ if sub == "send":
556
+ if not payload:
557
+ print_error(console, "Usage: /pty send <id> <text>")
558
+ continue
559
+ try:
560
+ await client.write_pty(target_id, payload)
561
+ except (AuthRequiredError, RemoteAPIError) as e:
562
+ print_error(console, str(e))
563
+ continue
564
+
565
+ if sub == "read":
566
+ try:
567
+ result = await client.read_pty(target_id, since=pty_offsets.get(target_id, 0))
568
+ except (AuthRequiredError, RemoteAPIError) as e:
569
+ print_error(console, str(e))
570
+ continue
571
+ pty_offsets[target_id] = int(result.get("offset", 0))
572
+ if result.get("data"):
573
+ console.print(result["data"], end="")
574
+ if not result.get("alive", True):
575
+ console.print(f"\n[yellow]Terminal {target_id[:8]} is {result.get('status', 'exited')}.[/yellow]")
576
+ continue
577
+
578
+ if sub == "kill":
579
+ try:
580
+ result = await client.kill_pty(target_id)
581
+ except (AuthRequiredError, RemoteAPIError) as e:
582
+ print_error(console, str(e))
583
+ continue
584
+ pty_offsets.pop(target_id, None)
585
+ console.print(f"[yellow]Terminal {target_id[:8]} {result.get('status', 'killed')}.[/yellow]")
586
+ continue
587
+
588
+ print_error(console, "Usage: /pty <start|list|send|read|kill> ...")
589
+ continue
590
+ if text == "/permissions":
591
+ console.print(f"approval_policy={config.approval_policy}")
592
+ console.print("[dim]Server safeguards always enforce workspace scope, command risk classification, ownership, and approval state; client policy cannot widen them.[/dim]")
593
+ continue
594
+ if text == "/mode" or text.startswith("/mode "):
595
+ arg = text[len("/mode"):].strip().lower()
596
+ if not arg:
597
+ console.print(f"Current mode: [bold]{config.approval_policy}[/bold]")
598
+ console.print(
599
+ "[dim]/mode manual prompt for every risky action (the default)\n"
600
+ "/mode accept-edits auto-approve safe/medium-risk actions, still prompt for dangerous ones\n"
601
+ "/mode auto never prompt (server safeguards still apply)\n"
602
+ "/mode plan read-only: propose without executing[/dim]"
603
+ )
604
+ continue
605
+ resolved = MODE_ALIASES.get(arg, arg if arg in APPROVAL_MODES else None)
606
+ if resolved is None:
607
+ print_error(console, f"Unknown mode '{arg}'. Use manual, accept-edits, auto, or plan.")
608
+ continue
609
+ config.approval_policy = resolved
610
+ console.print(f"[green]Mode set to[/green] {arg} [dim]({resolved})[/dim]")
611
+ continue
612
+ if text == "/compact":
613
+ state = local_state.get_session_state(workspace.session_id)
614
+ summary = (state.active_task or {}).get("objective") or state.conversation_summary or "Session checkpoint"
615
+ local_state.checkpoint(workspace.session_id, reason="user_compact", summary=summary)
616
+ console.print("[green]Context checkpoint saved.[/green]")
617
+ continue
618
+ if text == "/doctor":
619
+ if standalone:
620
+ from .providers import get_provider_status as _get_provider_status
621
+
622
+ status = _get_provider_status()
623
+ table = Table(show_header=True, header_style="bold")
624
+ for column in ("PROVIDER", "CONFIGURED", "KEY"):
625
+ table.add_column(column)
626
+ for name, info in status["config"].items():
627
+ configured = "[green]yes[/green]" if info["api_key_set"] or name == "ollama" else "[dim]no[/dim]"
628
+ table.add_row(name, configured, info["key_preview"])
629
+ console.print(table)
630
+ console.print(
631
+ f"[dim]Currently selected: {provider_type.value if provider_type else provider} "
632
+ f"· auto would pick: {status['default']}[/dim]"
633
+ )
634
+ continue
635
+ await run_doctor(config, console, Path(workspace.workspace_root), session_id=workspace.session_id)
636
+ continue
637
+ if text == "/agents":
638
+ if standalone:
639
+ for sid in local_state.all_known_session_ids():
640
+ sess_state = local_state.get_session_state(sid)
641
+ marker = " *" if sid == workspace.session_id else ""
642
+ console.print(f" {sid} {sess_state.workspace_root or sess_state.primary_workspace}{marker}")
643
+ continue
644
+ try:
645
+ sessions_list = await client.list_sessions()
646
+ except (AuthRequiredError, RemoteAPIError) as e:
647
+ print_error(console, str(e))
648
+ continue
649
+ for sess in sessions_list:
650
+ marker = " *" if sess.get("id") == workspace.session_id else ""
651
+ console.print(f" {sess.get('id')} {sess.get('status')} {sess.get('working_directory') or ''}{marker}")
652
+ continue
653
+ if text == "/delegate" or text.startswith("/delegate "):
654
+ if not config.enable_subagent_delegation:
655
+ print_error(
656
+ console,
657
+ "Subagent delegation is disabled. Enable it with enable_subagent_delegation = true "
658
+ "in config.toml, or TAMFIS_CODE_ENABLE_SUBAGENT_DELEGATION=1.",
659
+ )
660
+ continue
661
+ arg = text[len("/delegate"):].strip()
662
+ descriptions = [part.strip() for part in arg.split("|") if part.strip()]
663
+ if not descriptions:
664
+ print_error(console, "Usage: /delegate <objective 1> | <objective 2> | ...")
665
+ continue
666
+ # Delegation always runs through the standalone local loop
667
+ # (agents.py's DelegatedCodingAgent) regardless of whether this
668
+ # REPL session itself is standalone or --remote -- there's only
669
+ # one implementation now (it was fully converted, not dual-mode).
670
+ from .agents import AgentManager
671
+ manager = AgentManager()
672
+ delegate_manager = provider_manager
673
+ delegate_provider = provider_type
674
+ if delegate_manager is None:
675
+ from .local_chat import resolve_provider_type as _resolve_provider_type
676
+ from .providers import ProviderManager as _ProviderManager
677
+
678
+ delegate_manager = _ProviderManager()
679
+ delegate_provider = _resolve_provider_type("auto")
680
+ results = await manager.execute_tasks(
681
+ descriptions, manager=delegate_manager, provider=delegate_provider, model=model,
682
+ console=console, workspace_root=workspace.workspace_root,
683
+ approval_policy=config.approval_policy,
684
+ )
685
+ for r in results:
686
+ marker = "✅" if r["status"] == "completed" else "❌"
687
+ console.print(f"{marker} {r['description']}")
688
+ summary = (r.get("result") or {}).get("summary") or (r.get("result") or {}).get("error")
689
+ if summary:
690
+ console.print(f" {summary}")
691
+ continue
692
+ if text == "/diffs" or text.startswith("/diffs "):
693
+ arg = text[len("/diffs"):].strip()
694
+ try:
695
+ limit = int(arg) if arg else 10
696
+ except ValueError:
697
+ print_error(console, f"'{arg}' is not a valid number.")
698
+ continue
699
+ if standalone:
700
+ mutations = local_state.get_session_state(workspace.session_id).modified_files[-limit:]
701
+ mutations = list(reversed(mutations))
702
+ else:
703
+ try:
704
+ result = await client.list_file_mutations(workspace.session_id, limit=limit)
705
+ except (AuthRequiredError, RemoteAPIError) as e:
706
+ print_error(console, str(e))
707
+ continue
708
+ mutations = result.get("mutations") or []
709
+ if not mutations:
710
+ console.print("[dim]No file mutations recorded yet in this session.[/dim]")
711
+ for m in mutations:
712
+ mutation_id = m.get("mutation_id") if standalone else m.get("id")
713
+ status = m.get("revert_status")
714
+ marker = " [reverted]" if status == "reverted" else (" [revert failed]" if status == "revert_failed" else "")
715
+ console.print(
716
+ f" {mutation_id} {m.get('operation')} {m.get('path')} "
717
+ f"+{m.get('lines_added')}/-{m.get('lines_removed')}{marker}"
718
+ )
719
+ continue
720
+ if text == "/diff" or text.startswith("/diff "):
721
+ mutation_id = text[len("/diff"):].strip()
722
+ if standalone:
723
+ mutations = local_state.get_session_state(workspace.session_id).modified_files
724
+ selected = next((item for item in mutations if item.get("mutation_id") == mutation_id), None) if mutation_id else (mutations[-1] if mutations else None)
725
+ if selected is None:
726
+ print_error(console, "Mutation not found." if mutation_id else "No file mutations recorded yet.")
727
+ else:
728
+ print_unified_diff(console, str(selected.get("unified_diff") or ""), title=str(selected.get("path") or "Changes"))
729
+ continue
730
+ try:
731
+ result = await client.list_file_mutations(workspace.session_id, limit=200 if mutation_id else 1)
732
+ except (AuthRequiredError, RemoteAPIError) as e:
733
+ print_error(console, str(e))
734
+ continue
735
+ mutations = result.get("mutations") or []
736
+ selected = next((item for item in mutations if str(item.get("id")) == mutation_id), None) if mutation_id else (mutations[0] if mutations else None)
737
+ if selected is None:
738
+ print_error(console, "Mutation not found." if mutation_id else "No file mutations recorded yet.")
739
+ else:
740
+ print_unified_diff(console, str(selected.get("unified_diff") or ""), title=str(selected.get("path") or "Changes"))
741
+ continue
742
+ if text == "/revert" or text.startswith("/revert "):
743
+ arg = text[len("/revert"):].strip()
744
+ if not arg:
745
+ print_error(console, "Usage: /revert <mutation_id> -- see /diffs for recent mutation ids.")
746
+ continue
747
+ if standalone:
748
+ try:
749
+ result = local_revert_mutation(workspace.session_id, arg)
750
+ except ValueError as e:
751
+ print_error(console, str(e))
752
+ continue
753
+ console.print(f"[green]Reverted[/green] {result.get('path')}")
754
+ continue
755
+ try:
756
+ result = await client.revert_file_mutation(workspace.session_id, arg)
757
+ except (AuthRequiredError, RemoteAPIError) as e:
758
+ print_error(console, str(e))
759
+ continue
760
+ console.print(f"[green]Reverted[/green] {result.get('path')}")
761
+ continue
762
+ if text == "/clear":
763
+ console.clear()
764
+ continue
765
+ if text == "/resume" or text.startswith("/resume "):
766
+ arg = text[len("/resume"):].strip()
767
+ if standalone:
768
+ known = local_state.all_known_session_ids()
769
+ if arg:
770
+ try:
771
+ target_id = int(arg)
772
+ except ValueError:
773
+ print_error(console, f"'{arg}' is not a valid session id.")
774
+ continue
775
+ if target_id not in known:
776
+ print_error(console, f"No known local session {target_id}. Use /agents to list known sessions.")
777
+ continue
778
+ else:
779
+ candidates = [sid for sid in reversed(known) if sid != workspace.session_id]
780
+ if not candidates:
781
+ console.print("[dim]No other sessions to resume.[/dim]")
782
+ continue
783
+ target_id = candidates[0]
784
+ target_state = local_state.get_session_state(target_id)
785
+ workspace = WorkspaceContext(
786
+ session_id=target_id,
787
+ workspace_root=target_state.workspace_root or target_state.primary_workspace,
788
+ )
789
+ console.print(f"[green]Resumed session {workspace.session_id}[/green] workspace_root={workspace.workspace_root}")
790
+ if target_state.conversation_summary:
791
+ console.print(f"[dim]{target_state.conversation_summary[-1000:]}[/dim]")
792
+ continue
793
+ try:
794
+ if arg:
795
+ try:
796
+ target_id = int(arg)
797
+ except ValueError:
798
+ print_error(console, f"'{arg}' is not a valid session id.")
799
+ continue
800
+ workspace = await context_from_session(client, target_id)
801
+ else:
802
+ target = await find_resumable_session(client, exclude_session_id=workspace.session_id)
803
+ if target is None:
804
+ console.print("[dim]No other sessions to resume.[/dim]")
805
+ continue
806
+ workspace = await context_from_session(client, target["id"])
807
+ except (AuthRequiredError, RemoteAPIError) as e:
808
+ print_error(console, str(e))
809
+ continue
810
+ console.print(f"[green]Resumed session {workspace.session_id}[/green] workspace_root={workspace.workspace_root}")
811
+ try:
812
+ thread = await client.get_thread(workspace.session_id)
813
+ print_recent_thread(console, thread.get("messages") or [])
814
+ except (AuthRequiredError, RemoteAPIError):
815
+ pass
816
+ continue
817
+ if text == "/retry" or text.startswith("/retry "):
818
+ if standalone:
819
+ if last_turn is None:
820
+ console.print("[dim]No previous turn in this session to retry.[/dim]")
821
+ continue
822
+ objective, mode = last_turn
823
+ repo_state = local_state.get_session_state(workspace.session_id).repository_context
824
+ if mode in {"coding", "agent", "execute"} and blocking_dirty_files(repo_state.get("dirty_files") or []):
825
+ print_error(console, "Existing uncommitted changes detected; retry is blocked to preserve user edits.")
826
+ continue
827
+ renderer = StreamRenderer(console)
828
+ outcome = await run_local_agent_turn(
829
+ provider_manager, provider_type, model, [{"role": "user", "content": objective}],
830
+ console, renderer,
831
+ workspace_root=workspace.workspace_root, session_id=workspace.session_id,
832
+ approval_policy=config.approval_policy, interactive=True,
833
+ read_only=mode in {"chat", "audit", "plan"},
834
+ )
835
+ renderer.finish()
836
+ if outcome.status == "completed" and outcome.summary and not renderer.streamed_final_text:
837
+ console.print(Markdown(outcome.summary))
838
+ if outcome.status != "completed":
839
+ print_error(console, outcome.error or f"task {outcome.status}")
840
+ console.print()
841
+ continue
842
+ arg = text[len("/retry"):].strip()
843
+ try:
844
+ if arg:
845
+ task_id = arg
846
+ else:
847
+ failed = await find_recent_task(client, workspace.session_id, only_status={"failed", "cancelled"})
848
+ if failed is None:
849
+ console.print("[dim]No recent failed task to retry.[/dim]")
850
+ continue
851
+ task_id = failed["id"]
852
+ renderer = StreamRenderer(console)
853
+ outcome = await retry_task_and_stream(
854
+ client, renderer, console,
855
+ session_id=workspace.session_id, task_id=task_id, mode=None,
856
+ approval_policy=config.approval_policy, interactive=True,
857
+ )
858
+ if outcome.status == "completed" and outcome.summary and not renderer.streamed_final_text:
859
+ console.print(Markdown(outcome.summary))
860
+ if outcome.status != "completed":
861
+ print_error(console, outcome.error or f"task {outcome.status}")
862
+ except (AuthRequiredError, RemoteAPIError) as e:
863
+ print_error(console, str(e))
864
+ console.print()
865
+ continue
866
+
867
+ # Short replies are meaningful in a stateful coding conversation.
868
+ # Expand them only when this session has prior task context so the
869
+ # server/model receives the intended reference instead of a bare
870
+ # token (and never reject them merely for being short).
871
+ reply_state = local_state.get_session_state(workspace.session_id)
872
+ text = contextualize_short_reply(
873
+ text,
874
+ has_context=bool(reply_state.last_task_id or reply_state.conversation_summary or reply_state.active_plan_id),
875
+ )
876
+ intent = parse_intent(text)
877
+ try:
878
+ if intent.kind == "shell":
879
+ if not intent.command:
880
+ continue
881
+ if standalone:
882
+ outcome = await run_local_shell_command(
883
+ console, workspace_root=workspace.workspace_root, session_id=workspace.session_id,
884
+ command=intent.command, approval_policy=config.approval_policy, interactive=True,
885
+ )
886
+ else:
887
+ outcome = await run_shell_command(
888
+ client, console,
889
+ session_id=workspace.session_id, command=intent.command,
890
+ approval_policy=config.approval_policy, interactive=True,
891
+ )
892
+ elif intent.kind == "saved_plan":
893
+ plan = local_state.get_plan(workspace.session_id, intent.command or None)
894
+ if plan is None:
895
+ print_error(console, "Plan not found. Use /plans to list saved plans.")
896
+ continue
897
+ repo_state = local_state.get_session_state(workspace.session_id).repository_context
898
+ if blocking_dirty_files(repo_state.get("dirty_files") or []):
899
+ print_error(console, "Existing uncommitted changes detected; plan execution is blocked to preserve user edits.")
900
+ continue
901
+ plan_id = str(plan["id"])
902
+ local_state.update_plan(workspace.session_id, plan_id, status="executing")
903
+ renderer = StreamRenderer(console)
904
+ model_state = local_state.get_session_state(workspace.session_id)
905
+ plan_objective = local_state.plan_execution_objective(plan)
906
+ if standalone:
907
+ outcome = await run_local_agent_turn(
908
+ provider_manager, provider_type, model, [{"role": "user", "content": plan_objective}],
909
+ console, renderer,
910
+ workspace_root=workspace.workspace_root, session_id=workspace.session_id,
911
+ approval_policy=config.approval_policy, interactive=True,
912
+ )
913
+ last_turn = (plan_objective, "execute")
914
+ else:
915
+ outcome = await run_ai_task_and_stream(
916
+ client, renderer, console,
917
+ session_id=workspace.session_id,
918
+ objective=plan_objective, mode="execute",
919
+ approval_policy=config.approval_policy, interactive=True,
920
+ model=model_state.selected_model, provider=model_state.selected_provider,
921
+ )
922
+ if standalone:
923
+ renderer.finish()
924
+ local_state.update_plan(
925
+ workspace.session_id, plan_id,
926
+ status="completed" if outcome.status == "completed" else "failed",
927
+ execution_task_id=local_state.get_session_state(workspace.session_id).last_task_id,
928
+ )
929
+ if outcome.status == "completed" and outcome.summary and not renderer.streamed_final_text:
930
+ console.print(Markdown(outcome.summary))
931
+ else:
932
+ if not intent.objective:
933
+ continue
934
+ if intent.mode == "execute":
935
+ repo_state = local_state.get_session_state(workspace.session_id).repository_context
936
+ if blocking_dirty_files(repo_state.get("dirty_files") or []):
937
+ print_error(console, "Existing uncommitted changes detected; execute mode is blocked to preserve user edits. Use /audit or /plan, or clean the worktree yourself.")
938
+ continue
939
+ renderer = StreamRenderer(console, objective=intent.objective)
940
+ model_state = local_state.get_session_state(workspace.session_id)
941
+ if standalone:
942
+ outcome = await run_local_agent_turn(
943
+ provider_manager, provider_type, model, [{"role": "user", "content": intent.objective}],
944
+ console, renderer,
945
+ workspace_root=workspace.workspace_root, session_id=workspace.session_id,
946
+ approval_policy=config.approval_policy, interactive=True,
947
+ read_only=intent.mode in {"chat", "audit", "plan"},
948
+ )
949
+ renderer.finish()
950
+ last_turn = (intent.objective, intent.mode)
951
+ if intent.mode == "plan" and outcome.status == "completed" and outcome.summary:
952
+ saved = local_state.save_plan(workspace.session_id, objective=intent.objective, content=outcome.summary)
953
+ console.print(f"[green]Plan saved[/green] · {saved.id} · run /execute-plan {saved.id}")
954
+ else:
955
+ outcome = await run_ai_task_and_stream(
956
+ client, renderer, console,
957
+ session_id=workspace.session_id, objective=intent.objective, mode=intent.mode,
958
+ approval_policy=config.approval_policy, interactive=True,
959
+ model=model_state.selected_model,
960
+ provider=model_state.selected_provider,
961
+ )
962
+ if outcome.status == "completed" and outcome.summary and not renderer.streamed_final_text:
963
+ console.print(Markdown(outcome.summary))
964
+ except AuthRequiredError:
965
+ if queued_item:
966
+ local_state.update_instruction(workspace.session_id, str(queued_item.get("id")), "failed")
967
+ print_error(console, "Not authenticated -- run `tamfis-code login` in another terminal, then retry.")
968
+ continue
969
+ except RemoteAPIError as e:
970
+ if queued_item:
971
+ local_state.update_instruction(workspace.session_id, str(queued_item.get("id")), "failed")
972
+ print_error(console, str(e))
973
+ continue
974
+ except Exception as e:
975
+ if queued_item:
976
+ local_state.update_instruction(workspace.session_id, str(queued_item.get("id")), "failed")
977
+ print_error(console, str(e))
978
+ continue
979
+
980
+ if queued_item:
981
+ local_state.update_instruction(workspace.session_id, str(queued_item.get("id")), "completed")
982
+
983
+ if outcome.status not in ("completed",):
984
+ print_error(console, outcome.error or f"task {outcome.status}")
985
+ console.print()