ags-cli 0.1.0__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.
Files changed (156) hide show
  1. ags_cli-0.1.0.dist-info/METADATA +332 -0
  2. ags_cli-0.1.0.dist-info/RECORD +156 -0
  3. ags_cli-0.1.0.dist-info/WHEEL +5 -0
  4. ags_cli-0.1.0.dist-info/entry_points.txt +2 -0
  5. ags_cli-0.1.0.dist-info/top_level.txt +2 -0
  6. cli/__init__.py +0 -0
  7. cli/__main__.py +4 -0
  8. cli/client.py +145 -0
  9. cli/commands/__init__.py +0 -0
  10. cli/commands/adapter.py +33 -0
  11. cli/commands/artifact.py +21 -0
  12. cli/commands/ask.py +212 -0
  13. cli/commands/chat.py +649 -0
  14. cli/commands/diff.py +49 -0
  15. cli/commands/doctor.py +93 -0
  16. cli/commands/gui.py +45 -0
  17. cli/commands/init.py +20 -0
  18. cli/commands/mcp.py +50 -0
  19. cli/commands/memory.py +65 -0
  20. cli/commands/model.py +73 -0
  21. cli/commands/policy.py +52 -0
  22. cli/commands/project.py +122 -0
  23. cli/commands/quota.py +107 -0
  24. cli/commands/run.py +84 -0
  25. cli/commands/serve.py +171 -0
  26. cli/commands/service.py +236 -0
  27. cli/commands/session.py +219 -0
  28. cli/commands/stats.py +96 -0
  29. cli/commands/status.py +36 -0
  30. cli/commands/task.py +37 -0
  31. cli/commands/usage.py +86 -0
  32. cli/local.py +84 -0
  33. cli/main.py +149 -0
  34. harness/__init__.py +4 -0
  35. harness/adapters/__init__.py +26 -0
  36. harness/adapters/antigravity.py +301 -0
  37. harness/adapters/claude_code.py +522 -0
  38. harness/adapters/local_openai.py +534 -0
  39. harness/adapters/mock.py +112 -0
  40. harness/adapters/probe.py +65 -0
  41. harness/adapters/registry.py +56 -0
  42. harness/adapters/warm_pool.py +255 -0
  43. harness/api/__init__.py +0 -0
  44. harness/api/router.py +38 -0
  45. harness/api/v1/__init__.py +0 -0
  46. harness/api/v1/adapters.py +105 -0
  47. harness/api/v1/approvals.py +173 -0
  48. harness/api/v1/artifacts.py +44 -0
  49. harness/api/v1/attachments.py +48 -0
  50. harness/api/v1/doctor.py +22 -0
  51. harness/api/v1/health.py +37 -0
  52. harness/api/v1/mcp.py +131 -0
  53. harness/api/v1/memory.py +80 -0
  54. harness/api/v1/policies.py +49 -0
  55. harness/api/v1/project.py +302 -0
  56. harness/api/v1/runs.py +98 -0
  57. harness/api/v1/sessions.py +248 -0
  58. harness/api/v1/stream.py +110 -0
  59. harness/api/v1/tasks.py +30 -0
  60. harness/api/v1/usage.py +58 -0
  61. harness/bootstrap.py +216 -0
  62. harness/db.py +164 -0
  63. harness/deps.py +58 -0
  64. harness/eventbus/__init__.py +20 -0
  65. harness/eventbus/inprocess_bus.py +85 -0
  66. harness/main.py +144 -0
  67. harness/mcp/__init__.py +22 -0
  68. harness/mcp/client.py +139 -0
  69. harness/mcp/config_gen.py +77 -0
  70. harness/mcp/registry.py +156 -0
  71. harness/mcp/tools.py +81 -0
  72. harness/memory/__init__.py +13 -0
  73. harness/memory/context_budget.py +116 -0
  74. harness/memory/embed.py +217 -0
  75. harness/memory/extract.py +74 -0
  76. harness/memory/ingest.py +106 -0
  77. harness/memory/namespaces.py +57 -0
  78. harness/memory/ranking.py +31 -0
  79. harness/memory/redact.py +37 -0
  80. harness/memory/service.py +320 -0
  81. harness/memory/sqlite_vec_backend.py +266 -0
  82. harness/memory/summarize.py +68 -0
  83. harness/models/__init__.py +35 -0
  84. harness/models/adapter_health.py +27 -0
  85. harness/models/approval.py +37 -0
  86. harness/models/artifact.py +35 -0
  87. harness/models/audit_log.py +33 -0
  88. harness/models/comparison.py +33 -0
  89. harness/models/enums.py +146 -0
  90. harness/models/mcp_server_config.py +49 -0
  91. harness/models/memory.py +76 -0
  92. harness/models/policy.py +29 -0
  93. harness/models/provider_config.py +35 -0
  94. harness/models/run.py +46 -0
  95. harness/models/run_event.py +35 -0
  96. harness/models/session.py +50 -0
  97. harness/models/task.py +30 -0
  98. harness/models/types.py +63 -0
  99. harness/models/workspace.py +27 -0
  100. harness/observability/__init__.py +4 -0
  101. harness/observability/audit.py +88 -0
  102. harness/observability/logging.py +47 -0
  103. harness/observability/metrics.py +43 -0
  104. harness/observability/tracing.py +38 -0
  105. harness/orchestrator/__init__.py +19 -0
  106. harness/orchestrator/engine.py +821 -0
  107. harness/orchestrator/handoff.py +33 -0
  108. harness/orchestrator/lifecycle.py +69 -0
  109. harness/orchestrator/native_resume.py +93 -0
  110. harness/orchestrator/policies.py +101 -0
  111. harness/orchestrator/reconcile.py +39 -0
  112. harness/orchestrator/run_graph.py +62 -0
  113. harness/orchestrator/snapshot.py +49 -0
  114. harness/schemas/__init__.py +1 -0
  115. harness/schemas/adapter.py +26 -0
  116. harness/schemas/approval.py +25 -0
  117. harness/schemas/artifact.py +17 -0
  118. harness/schemas/common.py +20 -0
  119. harness/schemas/memory.py +50 -0
  120. harness/schemas/policy.py +39 -0
  121. harness/schemas/run.py +116 -0
  122. harness/schemas/session.py +93 -0
  123. harness/schemas/task.py +24 -0
  124. harness/security/__init__.py +5 -0
  125. harness/security/approval_policy.py +107 -0
  126. harness/security/auth.py +106 -0
  127. harness/security/permissions.py +59 -0
  128. harness/security/risk.py +59 -0
  129. harness/security/secrets.py +49 -0
  130. harness/services/__init__.py +1 -0
  131. harness/services/adapters_health.py +212 -0
  132. harness/services/approvals.py +84 -0
  133. harness/services/artifacts.py +78 -0
  134. harness/services/attachments.py +228 -0
  135. harness/services/comparison.py +345 -0
  136. harness/services/doctor.py +156 -0
  137. harness/services/files.py +287 -0
  138. harness/services/mcp_servers.py +179 -0
  139. harness/services/project_git.py +101 -0
  140. harness/services/retention.py +97 -0
  141. harness/services/runs.py +155 -0
  142. harness/services/runs_diff.py +201 -0
  143. harness/services/sessions.py +242 -0
  144. harness/services/ssh.py +300 -0
  145. harness/services/stats.py +132 -0
  146. harness/services/tasks.py +41 -0
  147. harness/services/workspaces.py +184 -0
  148. harness/services/worktrees.py +439 -0
  149. harness/settings.py +186 -0
  150. harness/skills/__init__.py +11 -0
  151. harness/skills/manager.py +141 -0
  152. harness/tools/__init__.py +1 -0
  153. harness/tools/fs_tools.py +295 -0
  154. harness/workers/__init__.py +0 -0
  155. harness/workers/dispatch.py +26 -0
  156. harness/workers/inprocess.py +135 -0
cli/commands/chat.py ADDED
@@ -0,0 +1,649 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import sys
5
+
6
+ import typer
7
+ from rich.console import Console
8
+ from rich.markup import escape
9
+ from rich.prompt import Prompt
10
+
11
+ from cli.client import APIError, HarnessClient
12
+ from cli.commands.ask import _resolve_provider, _session_file, get_valid_providers, _canon
13
+
14
+ console = Console()
15
+
16
+
17
+ class _ArrowPrompt(Prompt):
18
+ """Chat input prompt that ends in `> ` instead of Rich's default `: `."""
19
+
20
+ prompt_suffix = " "
21
+
22
+ _HELP = (
23
+ "[dim]commands:[/dim] /use <agent> /model <name> /mode write|read-only|inherit "
24
+ "/merge [--squash] /handoff <agent> /compare <p1>,<p2> <prompt> /thinking on|off "
25
+ "/context /status /new /help /exit"
26
+ )
27
+
28
+ # Valid values for /mode (mirrors project.py)
29
+ _READONLY = {"read-only", "readonly", "ro", "r", "plan"}
30
+ _WRITE = {"write", "rw", "w"}
31
+ _INHERIT = {"inherit", "default", "clear"} # drop the session override
32
+
33
+
34
+ def _summarize_args(args: dict) -> str:
35
+ """Compact one-line view of tool-call arguments for the chat UI: show short string
36
+ values; replace long ones (file contents, etc.) with their size; cap the whole thing."""
37
+ if not isinstance(args, dict):
38
+ return ""
39
+ parts: list[str] = []
40
+ for v in args.values():
41
+ s = v if isinstance(v, str) else json.dumps(v, default=str)
42
+ parts.append(f"<{len(s)} chars>" if len(s) > 60 else s)
43
+ if len(parts) >= 2: # one or two key fields are enough to convey the action
44
+ break
45
+ out = " ".join(parts)
46
+ return out[:80] + "…" if len(out) > 80 else out
47
+
48
+
49
+ def _print_status(
50
+ client: HarnessClient, provider: str, workspace: str, session_id: str | None
51
+ ) -> None:
52
+ """Show what the user is actually talking to: active agent, workspace, the
53
+ project directory the agents operate in, and read-only vs write mode."""
54
+ try:
55
+ st = client.get("/status", workspace=workspace)
56
+ except APIError as exc:
57
+ st = {"project_dir": f"(API unavailable: {exc})", "mode": "?"}
58
+ mode = str(st.get("mode", "?"))
59
+ if session_id:
60
+ # The session may override the workspace mode — show the effective value.
61
+ try:
62
+ sm = client.get(f"/sessions/{session_id}/settings") or {}
63
+ mode = str(sm.get("mode", mode))
64
+ if sm.get("write_mode") is not None:
65
+ mode += " [session override]"
66
+ except APIError:
67
+ pass
68
+ console.print(
69
+ f"[dim]agent[/dim] [cyan]{escape(provider)}[/cyan] "
70
+ f"[dim]workspace[/dim] {escape(workspace)} "
71
+ f"[dim]mode[/dim] {escape(mode)}"
72
+ )
73
+ console.print(f"[dim]project[/dim] {escape(str(st.get('project_dir', '?')))}")
74
+ console.print(f"[dim]session[/dim] {escape(session_id or '(new — starts on first message)')}")
75
+ if session_id:
76
+ try:
77
+ wt = client.get(f"/sessions/{session_id}/workspace") or {}
78
+ if wt.get("workspace_mode") == "worktree":
79
+ n = len(wt.get("changed_files") or [])
80
+ console.print(
81
+ f"[dim]workspace[/dim] private "
82
+ f"(branch {escape(str(wt.get('branch', '?')))}, {n} changed "
83
+ f"file{'s' if n != 1 else ''} — /merge to apply)"
84
+ )
85
+ elif wt.get("fallback_reason"):
86
+ console.print(
87
+ f"[dim]workspace[/dim] main project "
88
+ f"({escape(str(wt['fallback_reason']))})"
89
+ )
90
+ except APIError:
91
+ pass
92
+
93
+
94
+ def chat(
95
+ provider: str | None = typer.Option(
96
+ None, "--provider", "-P", help="Override default provider knob for this chat."
97
+ ),
98
+ model: str | None = typer.Option(
99
+ None, "--model", "-m", help="One-off model override for this chat."
100
+ ),
101
+ workspace: str = typer.Option("default", "--workspace", "-w", envvar="HARNESS_WORKSPACE"),
102
+ policy: str = typer.Option("single", "--policy", "-p"),
103
+ show_thinking: bool = typer.Option(
104
+ False, "--show-thinking",
105
+ help="Stream the model's full reasoning text instead of a compact one-line summary.",
106
+ ),
107
+ isolated: bool | None = typer.Option(
108
+ None, "--isolated/--no-isolated",
109
+ help="Run NEW sessions in a private git worktree (own branch, merge back "
110
+ "with /merge) instead of directly in the project folder. Unset: the "
111
+ "workspace's default (private worktree unless configured otherwise).",
112
+ ),
113
+ ) -> None:
114
+ """Launch an interactive multi-turn chat session with the default or specified provider."""
115
+ client = HarnessClient()
116
+ resolved_provider = _resolve_provider(provider, workspace)
117
+ session_file = _session_file(workspace)
118
+ session_id = session_file.read_text().strip() if session_file.exists() else None
119
+
120
+ console.print("[bold]ags chat[/bold]")
121
+ _print_status(client, resolved_provider, workspace, session_id)
122
+ if model:
123
+ console.print(f"[dim]model override:[/dim] {model}")
124
+ console.print(_HELP + "\n")
125
+
126
+ session_tokens = 0 # running token total across the chat (providers that report usage)
127
+ while True:
128
+ try:
129
+ prompt_label = f"[bold green]{escape(resolved_provider)} ❯[/bold green]"
130
+ message = _ArrowPrompt.ask(prompt_label).strip()
131
+ if not message:
132
+ continue
133
+ low = message.lower()
134
+
135
+ # --- slash commands: handled locally, never sent to the agent --------
136
+ if low in ("/exit", "/quit", "exit", "quit"):
137
+ break
138
+ if low in ("/help", "/?", "/h"):
139
+ console.print(_HELP)
140
+ continue
141
+ if low == "/status":
142
+ _print_status(client, resolved_provider, workspace, session_id)
143
+ continue
144
+ if low.startswith("/thinking"):
145
+ parts = message.split(None, 1)
146
+ arg = parts[1].strip().lower() if len(parts) > 1 else ""
147
+ if arg in ("on", "full", "show"):
148
+ show_thinking = True
149
+ elif arg in ("off", "hide", "compact"):
150
+ show_thinking = False
151
+ elif arg in ("", "toggle"):
152
+ show_thinking = not show_thinking
153
+ else:
154
+ console.print("[yellow]Usage: /thinking on|off[/yellow]")
155
+ continue
156
+ state = "full reasoning" if show_thinking else "compact one-line"
157
+ console.print(f"[cyan]→ thinking display: {state}[/cyan]")
158
+ continue
159
+ if low == "/new":
160
+ session_id = None
161
+ if session_file.exists():
162
+ session_file.unlink()
163
+ console.print("[yellow]Session cleared. Next message starts fresh.[/yellow]")
164
+ continue
165
+ if low == "/context":
166
+ if not session_id:
167
+ console.print("[dim]no session yet — send a message first[/dim]")
168
+ else:
169
+ try:
170
+ runs = client.get(f"/sessions/{session_id}/runs")
171
+ if not runs:
172
+ console.print("[dim]no runs yet[/dim]")
173
+ else:
174
+ # Newest run: last in list (endpoint returns oldest-first);
175
+ # fall back to max by created_at if present.
176
+ newest = runs[-1]
177
+ if any("created_at" in r for r in runs):
178
+ newest = max(runs, key=lambda r: r.get("created_at", ""))
179
+ meta = newest.get("context_meta") or {}
180
+ if not meta:
181
+ console.print("[dim]context_meta not available for this run[/dim]")
182
+ else:
183
+ console.print(
184
+ f"[bold]Context transparency[/bold] "
185
+ f"(run [dim]{newest.get('id', '?')}[/dim])"
186
+ )
187
+ console.print(
188
+ f" resume mode : [cyan]{escape(str(meta.get('resume', '?')))}[/cyan]"
189
+ )
190
+ console.print(
191
+ f" memory chars : {meta.get('memory_chars', 0):,}"
192
+ )
193
+ console.print(
194
+ f" transcript blks: {meta.get('transcript_blocks', '?')}"
195
+ )
196
+ console.print(
197
+ f" context window : {meta.get('window', '?'):,}"
198
+ )
199
+ except APIError as exc:
200
+ console.print(f"[bold red]✗ {exc}[/bold red]")
201
+ continue
202
+ if low.startswith("/use ") or low.startswith("/switch "):
203
+ requested_provider = message.split(" ", 1)[1].strip()
204
+ canonical_provider = _canon(requested_provider)
205
+ valid_providers = get_valid_providers(client)
206
+
207
+ if canonical_provider not in valid_providers:
208
+ console.print(
209
+ f"[yellow]Unknown or disabled provider '{escape(requested_provider)}'.\n"
210
+ f"Valid providers are: {', '.join(valid_providers)}[/yellow]"
211
+ )
212
+ else:
213
+ resolved_provider = canonical_provider
214
+ console.print(f"[cyan]→ now talking to {escape(resolved_provider)}[/cyan]")
215
+ continue
216
+ if low.startswith("/model"):
217
+ parts = message.split(None, 1)
218
+ if len(parts) < 2 or not parts[1].strip():
219
+ console.print(
220
+ "[yellow]Usage: /model <name> — set the active model for the current provider[/yellow]"
221
+ )
222
+ continue
223
+ model_name = parts[1].strip()
224
+ valid_providers = get_valid_providers(client)
225
+ if resolved_provider not in valid_providers:
226
+ console.print(
227
+ f"[yellow]Unknown or disabled provider '{escape(resolved_provider)}'.\n"
228
+ f"Valid providers are: {', '.join(valid_providers)}[/yellow]"
229
+ )
230
+ continue
231
+ try:
232
+ result = client.post(
233
+ f"/adapters/{resolved_provider}/model",
234
+ json={"model": model_name},
235
+ )
236
+ confirmed = (result or {}).get("model", model_name)
237
+ console.print(
238
+ f"[cyan]→ model set to {escape(confirmed)} "
239
+ f"(provider: {escape(resolved_provider)})[/cyan]"
240
+ )
241
+ except APIError as exc:
242
+ console.print(f"[bold red]✗ {exc}[/bold red]")
243
+ continue
244
+ if low.startswith("/mode"):
245
+ parts = message.split(None, 1)
246
+ if len(parts) < 2 or not parts[1].strip():
247
+ console.print(
248
+ "[yellow]Usage: /mode write|read-only|inherit[/yellow]"
249
+ )
250
+ continue
251
+ mode_arg = parts[1].strip().lower()
252
+ write_flag: bool | None
253
+ if mode_arg in _WRITE:
254
+ write_flag = True
255
+ elif mode_arg in _READONLY:
256
+ write_flag = False
257
+ elif mode_arg in _INHERIT:
258
+ write_flag = None
259
+ else:
260
+ console.print(
261
+ "[yellow]Invalid mode. Use 'write', 'read-only' or 'inherit'.[/yellow]"
262
+ )
263
+ continue
264
+ try:
265
+ if session_id:
266
+ # Session-scoped: only THIS session changes mode; other
267
+ # sessions in the workspace keep their own / the default.
268
+ result = client.put(
269
+ f"/sessions/{session_id}/settings",
270
+ json={"write_mode": write_flag},
271
+ )
272
+ elif write_flag is None:
273
+ console.print(
274
+ "[yellow]/mode inherit needs an active session "
275
+ "(it clears a session's override).[/yellow]"
276
+ )
277
+ continue
278
+ else:
279
+ # No session yet — set the workspace default instead.
280
+ result = client.put(
281
+ "/project",
282
+ json={"write_mode": write_flag},
283
+ workspace=workspace,
284
+ )
285
+ fallback = {True: "write", False: "read-only", None: "inherit"}[write_flag]
286
+ mode_label = (result or {}).get("mode", fallback)
287
+ scope = "session" if session_id else "workspace"
288
+ console.print(
289
+ f"[cyan]→ {scope} mode set to {escape(str(mode_label))}[/cyan]"
290
+ )
291
+ except APIError as exc:
292
+ console.print(f"[bold red]✗ {exc}[/bold red]")
293
+ continue
294
+ if low == "/merge" or low.startswith("/merge "):
295
+ if not session_id:
296
+ console.print(
297
+ "[yellow]/merge needs an active session "
298
+ "(it merges the session's private branch back).[/yellow]"
299
+ )
300
+ continue
301
+ squash = "--squash" in message.split()[1:]
302
+ try:
303
+ result = client.post(
304
+ f"/sessions/{session_id}/merge",
305
+ json={"squash": squash},
306
+ ) or {}
307
+ if result.get("merged"):
308
+ console.print(
309
+ f"[green]✓ merged into base branch "
310
+ f"(commit {escape(str(result.get('commit', ''))[:12])})[/green]"
311
+ )
312
+ elif result.get("nothing_to_merge"):
313
+ console.print("[cyan]nothing to merge — no new changes[/cyan]")
314
+ elif result.get("conflicts"):
315
+ console.print(
316
+ "[bold red]✗ merge conflicts (aborted):[/bold red] "
317
+ + escape(", ".join(result["conflicts"]))
318
+ )
319
+ else:
320
+ console.print(
321
+ f"[yellow]merge did not complete: "
322
+ f"{escape(str(result.get('reason') or result))}[/yellow]"
323
+ )
324
+ except APIError as exc:
325
+ console.print(f"[bold red]✗ {exc}[/bold red]")
326
+ continue
327
+ if low.startswith("/handoff ") or low.startswith("/handoff\t"):
328
+ requested_provider = message.split(None, 1)[1].strip()
329
+ canonical_provider = _canon(requested_provider)
330
+ valid_providers = get_valid_providers(client)
331
+
332
+ if canonical_provider not in valid_providers:
333
+ console.print(
334
+ f"[yellow]Unknown or disabled provider '{escape(requested_provider)}'.\n"
335
+ f"Valid providers are: {', '.join(valid_providers)}[/yellow]"
336
+ )
337
+ else:
338
+ resolved_provider = canonical_provider
339
+ console.print(f"[cyan]→ now talking to {escape(resolved_provider)}[/cyan]")
340
+ console.print(
341
+ f"[dim]handoff: next turn will inject a structured handoff block "
342
+ f"for {escape(resolved_provider)}[/dim]"
343
+ )
344
+ continue
345
+ if low.startswith("/compare"):
346
+ # /compare <p1>,<p2> <prompt text>
347
+ parts = message.split(None, 2)
348
+ if len(parts) < 3:
349
+ console.print(
350
+ "[yellow]Usage: /compare <p1>,<p2> <prompt> — "
351
+ "run the prompt against two providers and compare[/yellow]"
352
+ )
353
+ continue
354
+ providers_arg = parts[1]
355
+ compare_prompt = parts[2].strip()
356
+ if not compare_prompt:
357
+ console.print(
358
+ "[yellow]Usage: /compare <p1>,<p2> <prompt> — "
359
+ "a non-empty prompt is required[/yellow]"
360
+ )
361
+ continue
362
+
363
+ raw_providers = [p.strip() for p in providers_arg.split(",") if p.strip()]
364
+ canonical_providers = [_canon(p) for p in raw_providers]
365
+ valid_providers = get_valid_providers(client)
366
+
367
+ bad = [p for p in canonical_providers if p not in valid_providers]
368
+ if bad:
369
+ console.print(
370
+ f"[yellow]Unknown or disabled provider(s): {', '.join(bad)}.\n"
371
+ f"Valid providers are: {', '.join(valid_providers)}[/yellow]"
372
+ )
373
+ continue
374
+
375
+ try:
376
+ if session_id:
377
+ cmp_data = client.post(
378
+ f"/sessions/{session_id}/resume",
379
+ json={
380
+ "policy": "fanout",
381
+ "providers": canonical_providers,
382
+ "additional_objective": compare_prompt,
383
+ "model": model,
384
+ },
385
+ inline=False,
386
+ )
387
+ else:
388
+ cmp_data = client.post(
389
+ "/runs/start",
390
+ json={
391
+ "objective": compare_prompt,
392
+ "workspace": workspace,
393
+ "policy": "fanout",
394
+ "providers": canonical_providers,
395
+ "model": model,
396
+ # Throwaway fan-out session: never provision a
397
+ # private worktree nobody can merge or discard.
398
+ "workspace_mode": "main",
399
+ },
400
+ inline=False,
401
+ )
402
+ # compare is a one-off fan-out; it must not hijack the sticky session pointer
403
+ except APIError as exc:
404
+ console.print(f"[bold red]✗ {exc}[/bold red]")
405
+ continue
406
+
407
+ all_runs = cmp_data.get("runs", [])
408
+ for prov in canonical_providers:
409
+ prov_runs = [
410
+ r for r in all_runs
411
+ if (r.get("graph_node_id") or "").startswith(prov)
412
+ ]
413
+ run_to_stream = (prov_runs or [None])[-1]
414
+ if run_to_stream is None:
415
+ console.print(f"[yellow]── {escape(prov)} — no run dispatched[/yellow]")
416
+ continue
417
+ console.print()
418
+ console.print(f"[bold cyan]── {escape(prov)} ──[/bold cyan]")
419
+ from rich.live import Live
420
+ from rich.markdown import Markdown
421
+
422
+ cmp_buf = ""
423
+ try:
424
+ with Live(Markdown(cmp_buf), console=console, refresh_per_second=15) as live:
425
+ for frame in client.stream(run_to_stream["id"]):
426
+ try:
427
+ ev = json.loads(frame)
428
+ except json.JSONDecodeError:
429
+ continue
430
+ if ev.get("type") == "token" and ev.get("role") != "user":
431
+ cmp_buf += ev.get("text", "")
432
+ live.update(Markdown(cmp_buf))
433
+ elif ev.get("type") == "message" and not cmp_buf:
434
+ cmp_buf += ev.get("text", "")
435
+ live.update(Markdown(cmp_buf))
436
+ except KeyboardInterrupt:
437
+ console.print("\n[yellow]— Interrupted.[/yellow]")
438
+ console.print()
439
+ # active provider is UNCHANGED after /compare
440
+ continue
441
+ if low.startswith("/"):
442
+ # Unknown command — DON'T forward a stray "/" to the agent (which
443
+ # would otherwise treat it as a prompt and go off exploring).
444
+ console.print(f"[yellow]Unknown command '{escape(message)}'. Try /help.[/yellow]")
445
+ continue
446
+
447
+ # --- a real message: start or resume the session ---------------------
448
+ try:
449
+ if session_id:
450
+ data = client.post(
451
+ f"/sessions/{session_id}/resume",
452
+ json={
453
+ "policy": policy,
454
+ "providers": [resolved_provider],
455
+ "additional_objective": message,
456
+ "model": model,
457
+ },
458
+ inline=False, # async so we can stream it
459
+ )
460
+ else:
461
+ data = client.post(
462
+ "/runs/start",
463
+ json={
464
+ "objective": message,
465
+ "workspace": workspace,
466
+ "policy": policy,
467
+ "providers": [resolved_provider],
468
+ "model": model,
469
+ # None → omit: the server applies the workspace default.
470
+ "workspace_mode": (
471
+ None if isolated is None
472
+ else ("worktree" if isolated else "main")
473
+ ),
474
+ },
475
+ inline=False,
476
+ )
477
+ session_id = data["session_id"]
478
+ session_file.write_text(session_id)
479
+ except APIError as exc:
480
+ if "404" in str(exc) and session_id:
481
+ console.print(
482
+ "[yellow]Session no longer exists on the server. "
483
+ "Starting a new session...[/yellow]"
484
+ )
485
+ session_id = None
486
+ if session_file.exists():
487
+ session_file.unlink()
488
+ continue
489
+ console.print(f"[bold red]✗ {exc}[/bold red]")
490
+ continue
491
+
492
+ # The API returns ALL of the session's runs oldest-first, so on a resume
493
+ # stream *this* turn's run — the newest one for the active provider.
494
+ if not data.get("runs"):
495
+ console.print("[yellow]No runs dispatched.[/yellow]")
496
+ continue
497
+ mine = [r for r in data["runs"]
498
+ if (r.get("graph_node_id") or "").startswith(resolved_provider)]
499
+ run_id = (mine or data["runs"])[-1]["id"]
500
+
501
+ # Label the reply with the agent in use — symmetric to the `you:` prompt.
502
+ # Uses the canonical provider name (matches /use and /status); `end=""`
503
+ # keeps the streamed response on the same line: "ags (local): <text>".
504
+ console.print()
505
+
506
+ # Label the turn
507
+ console.print(f"[bold cyan]ags ({escape(resolved_provider)})[/bold cyan] >")
508
+
509
+ streamed_tokens = False
510
+ turn_tokens: int | None = None
511
+
512
+ from rich.live import Live
513
+ from rich.markdown import Markdown
514
+ from rich.panel import Panel
515
+ from rich.console import Group
516
+
517
+ content_buffer = ""
518
+ thinking_buffer = "" # accumulated reasoning ("thinking") tokens
519
+ thinking_done = False # flips once the first content token arrives
520
+ active_tool_calls: list[str] = []
521
+ paused_for_approval = False
522
+
523
+ def _thinking_line():
524
+ """Reasoning display. Compact mode (default): a single dim line showing a
525
+ spinner, running size, and the tail of the latest thought, trimmed to the
526
+ terminal width — so hundreds of reasoning tokens never flood scrollback.
527
+ `--show-thinking` / `/thinking on` streams the full text instead."""
528
+ if not thinking_buffer:
529
+ return None
530
+ n = len(thinking_buffer)
531
+ if show_thinking:
532
+ return f"[dim italic]💭 {escape(thinking_buffer)}[/dim italic]"
533
+ if thinking_done:
534
+ return f"[dim]💭 thought · {n:,} chars[/dim]"
535
+ tail = " ".join(thinking_buffer.split())
536
+ avail = max(20, console.width - 28)
537
+ if len(tail) > avail:
538
+ tail = "…" + tail[-avail:]
539
+ return f"[dim]💭 thinking… ({n:,} chars) {escape(tail)}[/dim]"
540
+
541
+ def _render_layout():
542
+ items = []
543
+ tl = _thinking_line()
544
+ if tl is not None:
545
+ items.append(tl)
546
+ if content_buffer:
547
+ items.append(Markdown(content_buffer))
548
+ for tc in active_tool_calls:
549
+ items.append(tc)
550
+ return Group(*items)
551
+
552
+ try:
553
+ with Live(_render_layout(), console=console, refresh_per_second=15) as live:
554
+ for frame in client.stream(run_id):
555
+ try:
556
+ ev = json.loads(frame)
557
+ except json.JSONDecodeError:
558
+ continue
559
+ etype = ev.get("type")
560
+
561
+ if etype == "token":
562
+ if ev.get("role") == "user":
563
+ continue
564
+ if ev.get("reasoning"):
565
+ # Route thinking into its own buffer — never the answer.
566
+ thinking_buffer += ev.get("text", "")
567
+ live.update(_render_layout())
568
+ continue
569
+ # First content token → the model is done thinking.
570
+ thinking_done = True
571
+ content_buffer += ev.get("text", "")
572
+ live.update(_render_layout())
573
+ streamed_tokens = True
574
+ elif etype == "message":
575
+ if ev.get("role") == "user" or streamed_tokens:
576
+ continue
577
+ content_buffer += ev.get("text", "")
578
+ live.update(_render_layout())
579
+ elif etype == "tool_call":
580
+ name = ev.get("name", "tool")
581
+ summary = _summarize_args(ev.get("arguments", {}))
582
+ summary_text = f" {summary}" if summary else ""
583
+ label = f"[dim]🛠 {escape(name)}{escape(summary_text)}[/dim]"
584
+ active_tool_calls.append(label)
585
+ live.update(_render_layout())
586
+ elif etype == "tool_result":
587
+ # Replace the last tool call line with the result
588
+ if active_tool_calls:
589
+ last_tc = active_tool_calls.pop()
590
+ if ev.get("is_error"):
591
+ msg = (ev.get("output", "").strip().splitlines() or [""])[0][:100]
592
+ active_tool_calls.append(f"{last_tc}\n[bold red]↳ error: {escape(msg)}[/bold red]")
593
+ else:
594
+ active_tool_calls.append(f"{last_tc}\n[bold green]↳ ok[/bold green]")
595
+ live.update(_render_layout())
596
+ elif etype == "error":
597
+ active_tool_calls.append(f"[bold red]Error: {escape(ev.get('message', 'unknown'))}[/bold red]")
598
+ live.update(_render_layout())
599
+ elif etype == "status" and ev.get("status") == "needs_approval":
600
+ paused_for_approval = True
601
+ elif etype == "cost":
602
+ pt = int(ev.get("prompt_tokens") or 0)
603
+ ct = int(ev.get("completion_tokens") or 0)
604
+ turn_tokens = int(ev.get("total_tokens") or 0) or (pt + ct)
605
+ except KeyboardInterrupt:
606
+ console.print("\n[yellow]— Interrupted. Cancelling run...[/yellow]")
607
+ try:
608
+ client.post(f"/runs/{run_id}/cancel")
609
+ except APIError:
610
+ pass
611
+ except EOFError:
612
+ console.print("\n[yellow]Goodbye![/yellow]")
613
+ break
614
+
615
+ if paused_for_approval:
616
+ approval_id = None
617
+ try:
618
+ for a in client.get("/approvals") or []:
619
+ if a.get("run_id") == run_id:
620
+ approval_id = a.get("id")
621
+ break
622
+ except APIError:
623
+ pass
624
+ hint = (
625
+ f"ags policy decide {approval_id} --status approved"
626
+ if approval_id else "ags policy approvals"
627
+ )
628
+ console.print(
629
+ f"\n[bold yellow]⏸ Run paused — approval required.[/bold yellow] "
630
+ f"Run '{hint}' to continue."
631
+ )
632
+
633
+ # End the response line, then a dim per-turn + running-session token count
634
+ # (only when the provider reported usage — e.g. agy reports none).
635
+ console.print()
636
+ if turn_tokens is not None:
637
+ session_tokens += turn_tokens
638
+ console.print(
639
+ f"[dim]· {turn_tokens:,} tokens (session: {session_tokens:,})[/dim]"
640
+ )
641
+ console.print()
642
+
643
+ except EOFError:
644
+ console.print("\n[yellow]Goodbye![/yellow]")
645
+ break
646
+ except KeyboardInterrupt:
647
+ # Handle Ctrl+C at the "you >" prompt
648
+ console.print("\n[yellow]Goodbye![/yellow]")
649
+ break