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.
- ags_cli-0.1.0.dist-info/METADATA +332 -0
- ags_cli-0.1.0.dist-info/RECORD +156 -0
- ags_cli-0.1.0.dist-info/WHEEL +5 -0
- ags_cli-0.1.0.dist-info/entry_points.txt +2 -0
- ags_cli-0.1.0.dist-info/top_level.txt +2 -0
- cli/__init__.py +0 -0
- cli/__main__.py +4 -0
- cli/client.py +145 -0
- cli/commands/__init__.py +0 -0
- cli/commands/adapter.py +33 -0
- cli/commands/artifact.py +21 -0
- cli/commands/ask.py +212 -0
- cli/commands/chat.py +649 -0
- cli/commands/diff.py +49 -0
- cli/commands/doctor.py +93 -0
- cli/commands/gui.py +45 -0
- cli/commands/init.py +20 -0
- cli/commands/mcp.py +50 -0
- cli/commands/memory.py +65 -0
- cli/commands/model.py +73 -0
- cli/commands/policy.py +52 -0
- cli/commands/project.py +122 -0
- cli/commands/quota.py +107 -0
- cli/commands/run.py +84 -0
- cli/commands/serve.py +171 -0
- cli/commands/service.py +236 -0
- cli/commands/session.py +219 -0
- cli/commands/stats.py +96 -0
- cli/commands/status.py +36 -0
- cli/commands/task.py +37 -0
- cli/commands/usage.py +86 -0
- cli/local.py +84 -0
- cli/main.py +149 -0
- harness/__init__.py +4 -0
- harness/adapters/__init__.py +26 -0
- harness/adapters/antigravity.py +301 -0
- harness/adapters/claude_code.py +522 -0
- harness/adapters/local_openai.py +534 -0
- harness/adapters/mock.py +112 -0
- harness/adapters/probe.py +65 -0
- harness/adapters/registry.py +56 -0
- harness/adapters/warm_pool.py +255 -0
- harness/api/__init__.py +0 -0
- harness/api/router.py +38 -0
- harness/api/v1/__init__.py +0 -0
- harness/api/v1/adapters.py +105 -0
- harness/api/v1/approvals.py +173 -0
- harness/api/v1/artifacts.py +44 -0
- harness/api/v1/attachments.py +48 -0
- harness/api/v1/doctor.py +22 -0
- harness/api/v1/health.py +37 -0
- harness/api/v1/mcp.py +131 -0
- harness/api/v1/memory.py +80 -0
- harness/api/v1/policies.py +49 -0
- harness/api/v1/project.py +302 -0
- harness/api/v1/runs.py +98 -0
- harness/api/v1/sessions.py +248 -0
- harness/api/v1/stream.py +110 -0
- harness/api/v1/tasks.py +30 -0
- harness/api/v1/usage.py +58 -0
- harness/bootstrap.py +216 -0
- harness/db.py +164 -0
- harness/deps.py +58 -0
- harness/eventbus/__init__.py +20 -0
- harness/eventbus/inprocess_bus.py +85 -0
- harness/main.py +144 -0
- harness/mcp/__init__.py +22 -0
- harness/mcp/client.py +139 -0
- harness/mcp/config_gen.py +77 -0
- harness/mcp/registry.py +156 -0
- harness/mcp/tools.py +81 -0
- harness/memory/__init__.py +13 -0
- harness/memory/context_budget.py +116 -0
- harness/memory/embed.py +217 -0
- harness/memory/extract.py +74 -0
- harness/memory/ingest.py +106 -0
- harness/memory/namespaces.py +57 -0
- harness/memory/ranking.py +31 -0
- harness/memory/redact.py +37 -0
- harness/memory/service.py +320 -0
- harness/memory/sqlite_vec_backend.py +266 -0
- harness/memory/summarize.py +68 -0
- harness/models/__init__.py +35 -0
- harness/models/adapter_health.py +27 -0
- harness/models/approval.py +37 -0
- harness/models/artifact.py +35 -0
- harness/models/audit_log.py +33 -0
- harness/models/comparison.py +33 -0
- harness/models/enums.py +146 -0
- harness/models/mcp_server_config.py +49 -0
- harness/models/memory.py +76 -0
- harness/models/policy.py +29 -0
- harness/models/provider_config.py +35 -0
- harness/models/run.py +46 -0
- harness/models/run_event.py +35 -0
- harness/models/session.py +50 -0
- harness/models/task.py +30 -0
- harness/models/types.py +63 -0
- harness/models/workspace.py +27 -0
- harness/observability/__init__.py +4 -0
- harness/observability/audit.py +88 -0
- harness/observability/logging.py +47 -0
- harness/observability/metrics.py +43 -0
- harness/observability/tracing.py +38 -0
- harness/orchestrator/__init__.py +19 -0
- harness/orchestrator/engine.py +821 -0
- harness/orchestrator/handoff.py +33 -0
- harness/orchestrator/lifecycle.py +69 -0
- harness/orchestrator/native_resume.py +93 -0
- harness/orchestrator/policies.py +101 -0
- harness/orchestrator/reconcile.py +39 -0
- harness/orchestrator/run_graph.py +62 -0
- harness/orchestrator/snapshot.py +49 -0
- harness/schemas/__init__.py +1 -0
- harness/schemas/adapter.py +26 -0
- harness/schemas/approval.py +25 -0
- harness/schemas/artifact.py +17 -0
- harness/schemas/common.py +20 -0
- harness/schemas/memory.py +50 -0
- harness/schemas/policy.py +39 -0
- harness/schemas/run.py +116 -0
- harness/schemas/session.py +93 -0
- harness/schemas/task.py +24 -0
- harness/security/__init__.py +5 -0
- harness/security/approval_policy.py +107 -0
- harness/security/auth.py +106 -0
- harness/security/permissions.py +59 -0
- harness/security/risk.py +59 -0
- harness/security/secrets.py +49 -0
- harness/services/__init__.py +1 -0
- harness/services/adapters_health.py +212 -0
- harness/services/approvals.py +84 -0
- harness/services/artifacts.py +78 -0
- harness/services/attachments.py +228 -0
- harness/services/comparison.py +345 -0
- harness/services/doctor.py +156 -0
- harness/services/files.py +287 -0
- harness/services/mcp_servers.py +179 -0
- harness/services/project_git.py +101 -0
- harness/services/retention.py +97 -0
- harness/services/runs.py +155 -0
- harness/services/runs_diff.py +201 -0
- harness/services/sessions.py +242 -0
- harness/services/ssh.py +300 -0
- harness/services/stats.py +132 -0
- harness/services/tasks.py +41 -0
- harness/services/workspaces.py +184 -0
- harness/services/worktrees.py +439 -0
- harness/settings.py +186 -0
- harness/skills/__init__.py +11 -0
- harness/skills/manager.py +141 -0
- harness/tools/__init__.py +1 -0
- harness/tools/fs_tools.py +295 -0
- harness/workers/__init__.py +0 -0
- harness/workers/dispatch.py +26 -0
- 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
|