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.
tamfis_code/runner.py ADDED
@@ -0,0 +1,635 @@
1
+ """Task/command execution + streaming loop, shared by interactive and
2
+ non-interactive commands. Supports multiple providers: Ollama, HF, NVIDIA,
3
+ OpenRouter, and auto-fallback.
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ import asyncio
9
+ import json
10
+ import os
11
+ import signal
12
+ import traceback
13
+ from dataclasses import dataclass, field
14
+ from typing import Any, Optional, AsyncGenerator
15
+
16
+ from rich.console import Console
17
+ from rich.panel import Panel
18
+
19
+ from .api_client import AuthRequiredError, RemoteAPIClient, RemoteAPIError
20
+ from .render import StreamRenderer
21
+ from . import state as local_state
22
+ from .providers import ProviderManager, ProviderType
23
+
24
+ COMMAND_POLL_INTERVAL_SECONDS = 0.2
25
+ TERMINAL_COMMAND_STATUSES = {"completed", "failed", "denied", "cancelled"}
26
+ APPROVAL_PREVIEW_CHARS = 8_000
27
+ ACTIVE_TASK_STATUSES = {"running", "pending", "queued", "pending_approval"}
28
+ try:
29
+ MAX_TASK_STREAM_RECONNECTS = min(
30
+ 30, max(3, int(os.getenv("TAMFIS_CODE_STREAM_RECONNECTS", "12")))
31
+ )
32
+ except (TypeError, ValueError):
33
+ MAX_TASK_STREAM_RECONNECTS = 12
34
+
35
+ # Allowed providers - expanded from just hf/openrouter
36
+ ALLOWED_PROVIDERS = ["hf", "huggingface", "or", "openrouter", "ollama", "nvidia", "nvidia_nim", "gemini", "apiframe", "auto", None]
37
+ PROVIDER_ALIASES = {"hf": "huggingface", "nvidia": "nvidia_nim", "or": "openrouter"}
38
+
39
+ # Provider name mapping
40
+ PROVIDER_NAME_MAP = {
41
+ "hf": "Hugging Face",
42
+ "huggingface": "Hugging Face",
43
+ "openrouter": "OpenRouter",
44
+ "ollama": "Ollama (Local)",
45
+ "nvidia": "NVIDIA NIM",
46
+ "nvidia_nim": "NVIDIA NIM",
47
+ "gemini": "Google Gemini",
48
+ "apiframe": "APIFRAME",
49
+ "auto": "Auto (server-selected)",
50
+ }
51
+
52
+
53
+ def normalize_provider(provider: Optional[str]) -> str:
54
+ if provider not in ALLOWED_PROVIDERS:
55
+ raise ValueError(f"Unsupported provider: {provider}")
56
+ return PROVIDER_ALIASES.get(provider, provider) or "auto"
57
+
58
+
59
+ @dataclass
60
+ class TaskOutcome:
61
+ status: str
62
+ summary: Optional[str] = None
63
+ error: Optional[str] = None
64
+ plan_id: Optional[str] = None
65
+ plan_items: list[dict[str, Any]] = field(default_factory=list)
66
+
67
+
68
+ def resolve_approval_decision(
69
+ console: Console, command_text: str, risk: str, approval_policy: str, interactive: bool,
70
+ *, display_preview: bool = True,
71
+ ) -> str:
72
+ risk = (risk or "medium").lower()
73
+
74
+ if approval_policy in {"auto", "full-auto"}:
75
+ return "approve_once"
76
+ if approval_policy in {"safe", "workspace", "accept-edits"}:
77
+ return "approve_once" if risk != "dangerous" else ("deny" if not interactive else _prompt(console, command_text, risk, display_preview))
78
+ if approval_policy in {"read-only", "plan-only", "suggest", "never"}:
79
+ return "deny"
80
+ if not interactive:
81
+ return "deny"
82
+ return _prompt(console, command_text, risk, display_preview)
83
+
84
+
85
+ def approval_command_preview(command_text: str, limit: int = APPROVAL_PREVIEW_CHARS) -> str:
86
+ if len(command_text) <= limit:
87
+ return command_text
88
+ head = int(limit * 0.7)
89
+ tail = limit - head
90
+ omitted = len(command_text) - limit
91
+ return (
92
+ command_text[:head]
93
+ + f"\n\n… {omitted:,} characters omitted from approval preview …\n\n"
94
+ + command_text[-tail:]
95
+ )
96
+
97
+
98
+ def _install_sigint_watcher() -> tuple[asyncio.Event, "Any"]:
99
+ loop = asyncio.get_running_loop()
100
+ event = asyncio.Event()
101
+ installed = False
102
+ try:
103
+ loop.add_signal_handler(signal.SIGINT, event.set)
104
+ installed = True
105
+ except (NotImplementedError, RuntimeError):
106
+ pass
107
+
108
+ def uninstall() -> None:
109
+ if installed:
110
+ try:
111
+ loop.remove_signal_handler(signal.SIGINT)
112
+ except (NotImplementedError, RuntimeError):
113
+ pass
114
+
115
+ return event, uninstall
116
+
117
+
118
+ def _prompt(console: Console, command_text: str, risk: str, display_preview: bool = True) -> str:
119
+ if display_preview:
120
+ console.print(Panel(approval_command_preview(command_text), title=f"Approval required — risk: {risk}", border_style="magenta", expand=False))
121
+ while True:
122
+ answer = console.input("[bold]Approve? (y)es / (n)o / (a)lways this session: [/bold]").strip().lower()
123
+ if answer in ("y", "yes"):
124
+ return "approve_once"
125
+ if answer in ("a", "always"):
126
+ return "approve_session"
127
+ if answer in ("n", "no", ""):
128
+ return "deny"
129
+
130
+
131
+ async def submit_ai_task_background(
132
+ client: RemoteAPIClient, *, session_id: int, objective: str, mode: str,
133
+ model: str = "auto", provider: Optional[str] = None,
134
+ attachments: Optional[list[dict[str, Any]]] = None,
135
+ ) -> dict[str, Any]:
136
+ provider = normalize_provider(provider)
137
+ task = await client.run_ai_task(
138
+ session_id, objective, mode, model=model, provider=provider, attachments=attachments,
139
+ )
140
+ task_id = str(task["task_id"])
141
+ action = local_state.start_action(
142
+ session_id, action_type="ai_task", purpose=objective,
143
+ risk="read_only" if mode in {"chat", "audit", "plan"} else "workspace_write",
144
+ detail=f"mode={mode}; background=true",
145
+ )
146
+ # finish_action() unconditionally resets execution_status to "idle"/
147
+ # "running" as a side effect of closing out its own local bookkeeping --
148
+ # it must run before the save_session_state() call below sets the real
149
+ # status, not after, or "backgrounded" gets clobbered back to "idle"
150
+ # immediately even though a real task is now running server-side.
151
+ local_state.finish_action(session_id, action.id, status="completed", summary=f"submitted task {task_id}")
152
+ local_state.save_session_state(
153
+ session_id, last_task_id=task_id, active_task={"id": task_id, "objective": objective, "mode": mode},
154
+ current_phase="queued", execution_status="backgrounded",
155
+ )
156
+ local_state.checkpoint(session_id, reason="background_task_submitted", summary=objective)
157
+ return task
158
+
159
+
160
+ async def _print_command_budget_if_notable(client: RemoteAPIClient, console: Console, task_id: str) -> None:
161
+ """Surface command-budget usage once a task finishes, but only when it's
162
+ actually informative -- most short tasks use a handful of commands out
163
+ of a budget in the hundreds, and printing that every time would just be
164
+ noise. Silently does nothing if the task/budget fields aren't available
165
+ (older server, or the request itself fails) -- this is purely
166
+ informational and must never affect the task's real outcome."""
167
+ try:
168
+ task = await client.get_task(task_id)
169
+ except (AuthRequiredError, RemoteAPIError):
170
+ return
171
+ budget = task.get("command_budget")
172
+ count = task.get("command_count")
173
+ if not budget or count is None:
174
+ return
175
+ if count >= budget * 0.8:
176
+ style = "red" if count >= budget else "yellow"
177
+ console.print(f"[{style}]Commands used: {count}/{budget} for this task.[/{style}]")
178
+
179
+
180
+ async def run_ai_task_and_stream(
181
+ client: RemoteAPIClient,
182
+ renderer: StreamRenderer,
183
+ console: Console,
184
+ *,
185
+ session_id: int,
186
+ objective: str,
187
+ mode: str,
188
+ approval_policy: str,
189
+ interactive: bool,
190
+ model: str = "auto",
191
+ provider: Optional[str] = None,
192
+ attachments: Optional[list[dict[str, Any]]] = None,
193
+ ) -> TaskOutcome:
194
+ # Normalize aliases at the boundary. Explicit routes are never silently
195
+ # replaced with auto or a different provider.
196
+ provider = normalize_provider(provider)
197
+
198
+ provider_name = PROVIDER_NAME_MAP.get(provider or "auto", provider or "auto")
199
+ console.print(f"[dim]Using provider: {provider_name}[/dim]")
200
+
201
+ # Preserve auto selection for Tier IV. The CLI must not silently pin
202
+ # automatic requests to Ollama or OpenRouter; the orchestration layer
203
+ # has provider health, capability and workspace-tier information.
204
+ if provider == "auto":
205
+ provider = "auto"
206
+
207
+ task = await client.run_ai_task(
208
+ session_id, objective, mode, model=model, provider=provider, attachments=attachments,
209
+ )
210
+ task_id = str(task["task_id"])
211
+ action = local_state.start_action(
212
+ session_id, action_type="ai_task", purpose=objective,
213
+ risk="read_only" if mode in {"chat", "audit", "plan"} else "workspace_write", detail=f"mode={mode}",
214
+ )
215
+ local_state.save_session_state(
216
+ session_id, last_task_id=task_id, active_task={"id": task_id, "objective": objective, "mode": mode},
217
+ current_phase="understand", execution_status="running",
218
+ )
219
+ try:
220
+ # Resume from the durable session cursor. Replaying a long-lived
221
+ # workspace from event 1 on every new task delays visible output and
222
+ # can make a healthy task look hung while hundreds of irrelevant old
223
+ # events are filtered client-side.
224
+ stream_cursor = local_state.get_session_state(session_id).last_event_id
225
+ outcome = await _stream_task(
226
+ client, renderer, console, session_id=session_id, task_id=task_id,
227
+ approval_policy=approval_policy, interactive=interactive,
228
+ from_event_id=stream_cursor,
229
+ )
230
+ except BaseException:
231
+ local_state.finish_action(session_id, action.id, status="failed", summary="stream failed")
232
+ raise
233
+ # A live-branched instruction may have continued this task under a new
234
+ # task_id (see _stream_task's task_continued handling) -- last_task_id
235
+ # tracks whichever one actually finished.
236
+ final_task_id = local_state.get_session_state(session_id).last_task_id or task_id
237
+ await _print_command_budget_if_notable(client, console, final_task_id)
238
+ local_state.finish_action(session_id, action.id, status=outcome.status, summary=outcome.summary or outcome.error or "")
239
+ if mode == "plan" and outcome.status == "completed" and outcome.summary:
240
+ saved = local_state.save_plan(
241
+ session_id, objective=objective, content=outcome.summary, source_task_id=task_id,
242
+ steps=outcome.plan_items,
243
+ )
244
+ outcome.plan_id = saved.id
245
+ console.print(
246
+ f"[green]Plan saved[/green] · {saved.id} · run `/execute-plan {saved.id}` "
247
+ f"in the REPL or `tamfis-code execute-plan {saved.id}` from the shell"
248
+ )
249
+ local_state.save_session_state(session_id, active_task=None, current_phase="report", execution_status=outcome.status)
250
+ local_state.checkpoint(session_id, reason=f"task_{outcome.status}", summary=outcome.summary or outcome.error or "")
251
+ return outcome
252
+
253
+
254
+ async def retry_task_and_stream(
255
+ client: RemoteAPIClient,
256
+ renderer: StreamRenderer,
257
+ console: Console,
258
+ *,
259
+ session_id: int,
260
+ task_id: str,
261
+ mode: Optional[str],
262
+ approval_policy: str,
263
+ interactive: bool,
264
+ ) -> TaskOutcome:
265
+ retried = await client.retry_task(task_id, mode)
266
+ new_task_id = str(retried["task_id"])
267
+ console.print(f"[dim]Retrying as task {new_task_id}[/dim]")
268
+ local_state.save_session_state(session_id, last_task_id=new_task_id)
269
+ stream_cursor = local_state.get_session_state(session_id).last_event_id
270
+ outcome = await _stream_task(
271
+ client, renderer, console,
272
+ session_id=session_id, task_id=new_task_id,
273
+ approval_policy=approval_policy, interactive=interactive,
274
+ from_event_id=stream_cursor,
275
+ )
276
+ await _print_command_budget_if_notable(client, console, new_task_id)
277
+ return outcome
278
+
279
+
280
+ async def attach_and_stream(
281
+ client: RemoteAPIClient,
282
+ renderer: StreamRenderer,
283
+ console: Console,
284
+ *,
285
+ session_id: int,
286
+ task_id: str,
287
+ approval_policy: str,
288
+ interactive: bool,
289
+ ) -> TaskOutcome:
290
+ from_event_id = local_state.get_session_state(session_id).last_event_id
291
+ return await _stream_task(
292
+ client, renderer, console,
293
+ session_id=session_id, task_id=task_id,
294
+ approval_policy=approval_policy, interactive=interactive,
295
+ from_event_id=from_event_id, on_interrupt="detach",
296
+ )
297
+
298
+
299
+ async def _stream_task(
300
+ client: RemoteAPIClient,
301
+ renderer: StreamRenderer,
302
+ console: Console,
303
+ *,
304
+ session_id: int,
305
+ task_id: str,
306
+ approval_policy: str,
307
+ interactive: bool,
308
+ from_event_id: int = 0,
309
+ on_interrupt: str = "cancel",
310
+ reconnect_attempt: int = 0,
311
+ ) -> TaskOutcome:
312
+ outcome: Optional[TaskOutcome] = None
313
+ last_assistant_content: Optional[str] = None
314
+ last_plan_items: list[dict[str, Any]] = []
315
+ prompted_command_ids: set[int] = set()
316
+ interrupted, uninstall_sigint = _install_sigint_watcher()
317
+ queued_interrupt: Optional[dict[str, Any]] = None
318
+
319
+ async def watch_instruction_queue() -> None:
320
+ nonlocal queued_interrupt
321
+ while not interrupted.is_set():
322
+ state = local_state.get_session_state(session_id)
323
+ queued_interrupt = next((
324
+ item for item in state.queued_user_instructions
325
+ if item.get("status") == "queued"
326
+ and item.get("classification") in {"cancel", "replace", "reprioritise"}
327
+ ), None)
328
+ if queued_interrupt is not None:
329
+ interrupted.set()
330
+ return
331
+ await asyncio.sleep(0.25)
332
+
333
+ async def consume() -> None:
334
+ nonlocal outcome, last_assistant_content, last_plan_items, task_id
335
+ async for event in client.stream_session(session_id, from_event_id):
336
+ sequence = event.get("stream_sequence")
337
+ if sequence is not None:
338
+ local_state.save_session_state(session_id, last_event_id=int(sequence))
339
+
340
+ event_task_id = event.get("task_id")
341
+ event_type = event.get("event_type") or event.get("event")
342
+ payload = event.get("payload") or {}
343
+
344
+ # A live-branched instruction (see LiveInstructionIn/
345
+ # add_task_instruction) starts a real continuation task under a
346
+ # NEW task_id once the current turn finishes at a safe boundary
347
+ # -- this is the SAME session-level stream, so its events are
348
+ # already arriving here. Follow it instead of stopping: from the
349
+ # user's point of view this is still one running task that just
350
+ # incorporated their new guidance and revised its plan.
351
+ if str(event_task_id) == task_id and event_type == "task_continued":
352
+ new_task_id = str(payload.get("continuation_task_id") or "")
353
+ if new_task_id:
354
+ console.print(f"[cyan]◆ Continuing with your instruction[/cyan] (task {new_task_id})")
355
+ task_id = new_task_id
356
+ local_state.save_session_state(
357
+ session_id, last_task_id=task_id,
358
+ active_task={"id": task_id, "objective": str(payload.get("instruction") or ""), "mode": "coding"},
359
+ )
360
+ continue
361
+
362
+ if str(event_task_id) != task_id:
363
+ continue
364
+
365
+ phase_by_event = {
366
+ "plan_created": "plan", "tool_call_requested": "execute",
367
+ "command_started": "execute", "file_mutation": "execute",
368
+ "approval_required": "waiting_for_approval", "task_diagnostics": "validate",
369
+ "ai_task_completed": "report", "ai_task_failed": "report",
370
+ }
371
+ if event_type in phase_by_event:
372
+ local_state.save_session_state(session_id, current_phase=phase_by_event[event_type])
373
+
374
+ if event_type == "plan_created":
375
+ items = payload.get("items") if isinstance(payload.get("items"), list) else []
376
+ last_plan_items = items
377
+ # If a saved plan is already active (e.g. re-planning mid
378
+ # `execute-plan`), keep its persisted step list current too --
379
+ # for a brand-new `plan` mode task there's no saved plan yet
380
+ # (that only happens once the task completes, below), so
381
+ # `last_plan_items` is what carries this forward in that case.
382
+ state = local_state.get_session_state(session_id)
383
+ if state.active_plan_id:
384
+ local_state.update_plan_steps(session_id, state.active_plan_id, items)
385
+
386
+ if event_type == "file_mutation":
387
+ state = local_state.get_session_state(session_id)
388
+ mutation_id = str(payload.get("mutation_id") or "")
389
+ existing = {str(item.get("mutation_id")): item for item in state.modified_files}
390
+ existing[mutation_id] = {
391
+ "mutation_id": mutation_id, "path": payload.get("path"),
392
+ "original_hash": payload.get("hash_before"), "current_hash": payload.get("hash_after"),
393
+ "pre_existing_changes": bool(state.repository_context.get("dirty")),
394
+ "modified_by_action_ids": [state.running_action.get("id")] if state.running_action else [],
395
+ "validation_status": "pending", "revert_status": "none",
396
+ }
397
+ local_state.save_session_state(session_id, modified_files=list(existing.values()))
398
+
399
+ if event_type == "file_mutation_reverted":
400
+ state = local_state.get_session_state(session_id)
401
+ for item in state.modified_files:
402
+ if str(item.get("mutation_id")) == str(payload.get("mutation_id")):
403
+ item["revert_status"] = "reverted"
404
+ local_state.save_session_state(session_id, modified_files=state.modified_files)
405
+
406
+ if event_type == "task_diagnostics":
407
+ state = local_state.get_session_state(session_id)
408
+ result = {
409
+ "task_id": task_id, "status": payload.get("completion_status"),
410
+ "provider": payload.get("provider"), "model": payload.get("model"),
411
+ "tool_calls": len(payload.get("tool_calls") or []),
412
+ "failed_tool_calls": sum(1 for call in payload.get("tool_calls") or [] if call.get("success") is False),
413
+ }
414
+ state.validation_results = (state.validation_results + [result])[-100:]
415
+ local_state.save_session_state(session_id, validation_results=state.validation_results)
416
+
417
+ if event_type == "tool_output":
418
+ result_envelope = payload.get("result") if isinstance(payload.get("result"), dict) else payload
419
+ evidence = payload.get("evidence") or result_envelope.get("evidence") or []
420
+ file_reads = [
421
+ item for item in evidence
422
+ if isinstance(item, dict) and item.get("type") == "file_read" and item.get("path")
423
+ ]
424
+ if file_reads:
425
+ state = local_state.get_session_state(session_id)
426
+ inspected = dict(state.inspected_files)
427
+ for item in file_reads:
428
+ inspected[str(item["path"])] = {
429
+ "path": item["path"],
430
+ "sha256": item.get("sha256"),
431
+ "last_read_at": item.get("timestamp"),
432
+ "tool_call_id": item.get("tool_call_id"),
433
+ }
434
+ local_state.save_session_state(session_id, inspected_files=inspected)
435
+
436
+ if event_type == "assistant_message":
437
+ last_assistant_content = str(payload.get("visible_content", ""))
438
+
439
+ if event_type == "approval_required":
440
+ # Render the full command/cwd/reason/risk card before asking
441
+ # for a decision. The prompt then stays compact and does not
442
+ # duplicate a less-informative second approval panel.
443
+ renderer.handle_event(event)
444
+ command_id = payload.get("command_id")
445
+ if command_id is not None and command_id not in prompted_command_ids:
446
+ prompted_command_ids.add(command_id)
447
+ decision = resolve_approval_decision(
448
+ console,
449
+ str(payload.get("command", "")),
450
+ str(payload.get("risk_level", "medium")),
451
+ approval_policy,
452
+ interactive,
453
+ display_preview=False,
454
+ )
455
+ try:
456
+ await client.approve_command(command_id, decision)
457
+ except RemoteAPIError:
458
+ pass
459
+
460
+ if event_type != "approval_required":
461
+ renderer.handle_event(event)
462
+
463
+ if event_type == "ai_task_completed":
464
+ outcome = TaskOutcome(status="completed", summary=last_assistant_content or "", plan_items=last_plan_items)
465
+ if last_assistant_content:
466
+ local_state.save_session_state(
467
+ session_id, conversation_summary=last_assistant_content[-4000:],
468
+ )
469
+ return
470
+ if event_type == "ai_task_failed":
471
+ outcome = TaskOutcome(status="failed", error=str(payload.get("error", "unknown error")))
472
+ return
473
+
474
+ consume_task = asyncio.ensure_future(consume())
475
+ interrupt_task = asyncio.ensure_future(interrupted.wait())
476
+ queue_task = asyncio.ensure_future(watch_instruction_queue())
477
+ try:
478
+ done, _pending = await asyncio.wait({consume_task, interrupt_task}, return_when=asyncio.FIRST_COMPLETED)
479
+ if consume_task not in done:
480
+ consume_task.cancel()
481
+ try:
482
+ await consume_task
483
+ except asyncio.CancelledError:
484
+ pass
485
+ if on_interrupt == "detach":
486
+ outcome = TaskOutcome(status="detached", summary=task_id)
487
+ else:
488
+ await client.cancel_task(task_id)
489
+ if queued_interrupt and queued_interrupt.get("classification") == "cancel":
490
+ local_state.update_instruction(session_id, str(queued_interrupt.get("id")), "completed")
491
+ reason = "Reprioritised by queued instruction" if queued_interrupt else "Interrupted by user"
492
+ outcome = TaskOutcome(status="cancelled", error=reason)
493
+ else:
494
+ await consume_task
495
+ finally:
496
+ interrupt_task.cancel()
497
+ queue_task.cancel()
498
+ uninstall_sigint()
499
+ renderer.finish()
500
+
501
+ if outcome is None:
502
+ task_status = await client.get_task(task_id)
503
+ status = str(task_status.get("status", "failed"))
504
+ if status == "completed":
505
+ outcome = TaskOutcome(status="completed", summary=task_status.get("final_answer") or "")
506
+ elif status in ACTIVE_TASK_STATUSES:
507
+ if reconnect_attempt < MAX_TASK_STREAM_RECONNECTS:
508
+ delay = min(8.0, 0.5 * (2 ** reconnect_attempt))
509
+ console.print(
510
+ f"[dim]· Event stream closed; reconnecting from the last checkpoint "
511
+ f"({reconnect_attempt + 1}/{MAX_TASK_STREAM_RECONNECTS})...[/dim]"
512
+ )
513
+ await asyncio.sleep(delay)
514
+ cursor = local_state.get_session_state(session_id).last_event_id
515
+ return await _stream_task(
516
+ client, renderer, console,
517
+ session_id=session_id, task_id=task_id,
518
+ approval_policy=approval_policy, interactive=interactive,
519
+ from_event_id=cursor, on_interrupt=on_interrupt,
520
+ reconnect_attempt=reconnect_attempt + 1,
521
+ )
522
+ outcome = TaskOutcome(status="detached", summary=task_id)
523
+ else:
524
+ outcome = TaskOutcome(status=status, error=task_status.get("error"))
525
+ return outcome
526
+
527
+
528
+ async def run_shell_command(
529
+ client: RemoteAPIClient,
530
+ console: Console,
531
+ *,
532
+ session_id: int,
533
+ command: str,
534
+ approval_policy: str,
535
+ interactive: bool,
536
+ ) -> TaskOutcome:
537
+ action = local_state.start_action(
538
+ session_id, action_type="shell_command", purpose="Run an explicit Remote command",
539
+ risk="policy_classified", detail=command,
540
+ )
541
+ cmd = await client.submit_command(session_id, command)
542
+ command_id = cmd["id"]
543
+ console.print(f"[bold]$[/bold] {command}")
544
+
545
+ prompted = False
546
+ status = str(cmd.get("status", ""))
547
+ interrupted, uninstall_sigint = _install_sigint_watcher()
548
+ try:
549
+ # Approval is durable server state, not a countdown. Keep polling
550
+ # until the user decides or cancels with Ctrl+C; command execution
551
+ # still has its own bounded server-side timeout after approval.
552
+ while True:
553
+ if interrupted.is_set():
554
+ cmd = await client.cancel_command(command_id)
555
+ status = str(cmd.get("status", "cancelled"))
556
+ console.print("[dim]Cancelled.[/dim]")
557
+ break
558
+ cmd = await client.get_command(command_id)
559
+ status = str(cmd.get("status", ""))
560
+ if status == "pending_approval" and not prompted:
561
+ prompted = True
562
+ decision = resolve_approval_decision(
563
+ console, str(cmd.get("command_text", command)), str(cmd.get("safety_tier", "medium")),
564
+ approval_policy, interactive,
565
+ )
566
+ await client.approve_command(command_id, decision)
567
+ if status in TERMINAL_COMMAND_STATUSES:
568
+ break
569
+ try:
570
+ await asyncio.wait_for(interrupted.wait(), timeout=COMMAND_POLL_INTERVAL_SECONDS)
571
+ except asyncio.TimeoutError:
572
+ pass
573
+ finally:
574
+ uninstall_sigint()
575
+
576
+ stdout = str(cmd.get("stdout") or "")
577
+ stderr = str(cmd.get("stderr") or "")
578
+ exit_code = cmd.get("exit_code")
579
+ body = stdout.strip()
580
+ if stderr.strip():
581
+ body = (body + "\n" + stderr.strip()).strip()
582
+ ok = status == "completed" and exit_code == 0
583
+ if not body:
584
+ if ok:
585
+ body = "Command completed successfully with no output"
586
+ elif status == "denied":
587
+ body = "Command was rejected by the user"
588
+ elif status == "cancelled":
589
+ body = "Command was cancelled"
590
+ else:
591
+ body = f"Command failed with exit code {exit_code}"
592
+ console.print(Panel(body, title=f"{status} · exit {exit_code}", border_style="green" if ok else "red"))
593
+
594
+ if ok:
595
+ outcome = TaskOutcome(status="completed", summary=stdout)
596
+ else:
597
+ outcome = TaskOutcome(status=status or "failed", error=stderr or f"exit code {exit_code}")
598
+ local_state.finish_action(session_id, action.id, status=outcome.status, summary=f"exit={exit_code}")
599
+ local_state.checkpoint(session_id, reason=f"command_{outcome.status}", summary=f"exit={exit_code}")
600
+ return outcome
601
+
602
+
603
+ async def follow_session_logs(
604
+ client: RemoteAPIClient,
605
+ renderer: StreamRenderer,
606
+ console: Console,
607
+ *,
608
+ session_id: int,
609
+ from_event_id: int = 0,
610
+ ) -> None:
611
+ interrupted, uninstall_sigint = _install_sigint_watcher()
612
+
613
+ async def consume() -> None:
614
+ async for event in client.stream_session(session_id, from_event_id):
615
+ sequence = event.get("stream_sequence")
616
+ if sequence is not None:
617
+ local_state.save_session_state(session_id, last_event_id=int(sequence))
618
+ renderer.handle_event(event)
619
+
620
+ consume_task = asyncio.ensure_future(consume())
621
+ interrupt_task = asyncio.ensure_future(interrupted.wait())
622
+ try:
623
+ done, _pending = await asyncio.wait({consume_task, interrupt_task}, return_when=asyncio.FIRST_COMPLETED)
624
+ if consume_task not in done:
625
+ consume_task.cancel()
626
+ try:
627
+ await consume_task
628
+ except asyncio.CancelledError:
629
+ pass
630
+ else:
631
+ await consume_task
632
+ finally:
633
+ interrupt_task.cancel()
634
+ uninstall_sigint()
635
+ renderer.finish()