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/session.py
ADDED
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import typer
|
|
4
|
+
from rich.console import Console
|
|
5
|
+
from rich.table import Table
|
|
6
|
+
|
|
7
|
+
from cli.client import HarnessClient
|
|
8
|
+
from cli.commands.ask import _state_dir
|
|
9
|
+
|
|
10
|
+
app = typer.Typer(help="Inspect, resume, branch, and clear sessions.")
|
|
11
|
+
console = Console()
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@app.command("list")
|
|
15
|
+
def list_sessions(limit: int = typer.Option(20, "--limit")) -> None:
|
|
16
|
+
client = HarnessClient()
|
|
17
|
+
rows = client.get("/sessions", limit=limit)
|
|
18
|
+
table = Table("id", "status", "objective")
|
|
19
|
+
for r in rows:
|
|
20
|
+
table.add_row(r["id"], r["status"], r["canonical_objective"][:60])
|
|
21
|
+
console.print(table)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@app.command("show")
|
|
25
|
+
def show(session_id: str = typer.Argument(...)) -> None:
|
|
26
|
+
client = HarnessClient()
|
|
27
|
+
data = client.get(f"/sessions/{session_id}")
|
|
28
|
+
console.print_json(data=data)
|
|
29
|
+
runs = client.get(f"/sessions/{session_id}/runs")
|
|
30
|
+
table = Table("run_id", "provider", "role", "status")
|
|
31
|
+
for r in runs:
|
|
32
|
+
table.add_row(r["id"], r["adapter_kind"], r["role"], r["status"])
|
|
33
|
+
console.print(table)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@app.command("resume")
|
|
37
|
+
def resume(
|
|
38
|
+
session_id: str = typer.Argument(...),
|
|
39
|
+
policy: str = typer.Option("single", "--policy", "-p"),
|
|
40
|
+
providers: str = typer.Option(None, "--providers"),
|
|
41
|
+
inline: bool = typer.Option(False, "--inline"),
|
|
42
|
+
) -> None:
|
|
43
|
+
"""Continue a session from its canonical summary."""
|
|
44
|
+
client = HarnessClient()
|
|
45
|
+
data = client.post(
|
|
46
|
+
f"/sessions/{session_id}/resume",
|
|
47
|
+
json={"policy": policy, "providers": providers.split(",") if providers else None},
|
|
48
|
+
inline=inline,
|
|
49
|
+
)
|
|
50
|
+
console.print(f"[green]resumed[/green] session {data['session_id']}")
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
@app.command("branch")
|
|
54
|
+
def branch(session_id: str = typer.Argument(...)) -> None:
|
|
55
|
+
"""Branch a new session from canonical state."""
|
|
56
|
+
client = HarnessClient()
|
|
57
|
+
data = client.post(f"/sessions/{session_id}/branch", json={})
|
|
58
|
+
console.print(f"[green]branched[/green] -> {data['id']}")
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
@app.command("workspace")
|
|
62
|
+
def workspace(session_id: str = typer.Argument(...)) -> None:
|
|
63
|
+
"""Show a session's private-workspace (worktree) state."""
|
|
64
|
+
client = HarnessClient()
|
|
65
|
+
st = client.get(f"/sessions/{session_id}/workspace") or {}
|
|
66
|
+
if st.get("workspace_mode") != "worktree":
|
|
67
|
+
console.print("[dim]session works directly in the main project folder[/dim]")
|
|
68
|
+
if st.get("fallback_reason"):
|
|
69
|
+
console.print(f"[yellow]({st['fallback_reason']})[/yellow]")
|
|
70
|
+
return
|
|
71
|
+
n = len(st.get("changed_files") or [])
|
|
72
|
+
console.print(f"[dim]branch[/dim] {st.get('branch', '?')} (base: {st.get('base', '?')})")
|
|
73
|
+
console.print(f"[dim]path[/dim] {st.get('path', '?')}")
|
|
74
|
+
console.print(
|
|
75
|
+
f"[dim]state[/dim] {n} changed file{'s' if n != 1 else ''}, "
|
|
76
|
+
f"{st.get('ahead', 0)} commit(s) ahead"
|
|
77
|
+
+ (", dirty" if st.get("dirty") else "")
|
|
78
|
+
)
|
|
79
|
+
for f in st.get("changed_files") or []:
|
|
80
|
+
console.print(f" · {f}")
|
|
81
|
+
if st.get("merged_at"):
|
|
82
|
+
console.print(f"[green]merged at {st['merged_at']}[/green]")
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
@app.command("merge")
|
|
86
|
+
def merge(
|
|
87
|
+
session_id: str = typer.Argument(...),
|
|
88
|
+
squash: bool = typer.Option(False, "--squash", help="Squash-merge to one commit."),
|
|
89
|
+
delete_worktree: bool = typer.Option(
|
|
90
|
+
False, "--delete-worktree", help="Remove the worktree + branch after merging."
|
|
91
|
+
),
|
|
92
|
+
yes: bool = typer.Option(False, "--yes", "-y", help="Skip the confirmation prompt."),
|
|
93
|
+
) -> None:
|
|
94
|
+
"""Merge a session's private branch back into its base branch."""
|
|
95
|
+
client = HarnessClient()
|
|
96
|
+
if not yes:
|
|
97
|
+
st = client.get(f"/sessions/{session_id}/workspace") or {}
|
|
98
|
+
n = len(st.get("changed_files") or [])
|
|
99
|
+
console.print(
|
|
100
|
+
f"About to merge branch [bold]{st.get('branch', '?')}[/bold] "
|
|
101
|
+
f"({n} changed file(s)) into [bold]{st.get('base', '?')}[/bold]."
|
|
102
|
+
)
|
|
103
|
+
if not typer.confirm("Proceed?"):
|
|
104
|
+
raise typer.Exit(0)
|
|
105
|
+
out = client.post(
|
|
106
|
+
f"/sessions/{session_id}/merge",
|
|
107
|
+
json={"squash": squash, "delete_worktree": delete_worktree},
|
|
108
|
+
) or {}
|
|
109
|
+
if out.get("merged"):
|
|
110
|
+
console.print(f"[green]✓ merged (commit {str(out.get('commit', ''))[:12]})[/green]")
|
|
111
|
+
elif out.get("nothing_to_merge"):
|
|
112
|
+
console.print("[cyan]nothing to merge — no new changes[/cyan]")
|
|
113
|
+
elif out.get("conflicts"):
|
|
114
|
+
console.print("[bold red]✗ merge conflicts (aborted):[/bold red]")
|
|
115
|
+
for f in out["conflicts"]:
|
|
116
|
+
console.print(f" · {f}")
|
|
117
|
+
else:
|
|
118
|
+
console.print(f"[yellow]merge did not complete: {out.get('reason') or out}[/yellow]")
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
@app.command("prune-worktrees")
|
|
122
|
+
def prune_worktrees() -> None:
|
|
123
|
+
"""Remove worktree directories for sessions that no longer exist."""
|
|
124
|
+
client = HarnessClient()
|
|
125
|
+
out = client.post("/sessions/prune-worktrees", json={}) or {}
|
|
126
|
+
console.print(f"[green]removed {out.get('removed', 0)} orphan worktree(s)[/green]")
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
@app.command("compact")
|
|
130
|
+
def compact(
|
|
131
|
+
older_than: int = typer.Option(..., "--older-than", min=1, help="Compact sessions older than N days."),
|
|
132
|
+
dry_run: bool = typer.Option(False, "--dry-run", help="Preview without deleting."),
|
|
133
|
+
yes: bool = typer.Option(False, "--yes", "-y", help="Skip the confirmation prompt."),
|
|
134
|
+
) -> None:
|
|
135
|
+
"""Prune bulky token/tool_result events from old terminal sessions.
|
|
136
|
+
|
|
137
|
+
Keeps the essential audit trail (message, status, cost, adapter_meta,
|
|
138
|
+
tool_call, error, artifact). Shows a preview and asks for confirmation
|
|
139
|
+
before deleting. Use --dry-run to preview without committing."""
|
|
140
|
+
client = HarnessClient()
|
|
141
|
+
preview = client.post("/sessions/compact", json={"older_than_days": older_than, "dry_run": True})
|
|
142
|
+
sessions = preview["sessions"]
|
|
143
|
+
events = preview["events_deleted"]
|
|
144
|
+
if sessions == 0:
|
|
145
|
+
console.print("[green]Nothing to compact[/green] — no qualifying sessions found.")
|
|
146
|
+
return
|
|
147
|
+
|
|
148
|
+
console.print(
|
|
149
|
+
f"[bold]{sessions}[/bold] session(s) matched; "
|
|
150
|
+
f"[bold red]{events}[/bold red] event(s) will be deleted (token + tool_result)."
|
|
151
|
+
)
|
|
152
|
+
if dry_run:
|
|
153
|
+
console.print("[yellow]Dry run — no changes made.[/yellow]")
|
|
154
|
+
return
|
|
155
|
+
|
|
156
|
+
if not yes and not typer.confirm("Compact these sessions?"):
|
|
157
|
+
console.print("Aborted.")
|
|
158
|
+
return
|
|
159
|
+
|
|
160
|
+
result = client.post("/sessions/compact", json={"older_than_days": older_than, "dry_run": False})
|
|
161
|
+
console.print(
|
|
162
|
+
f"[green]Compacted {result['sessions']} session(s),[/green] "
|
|
163
|
+
f"deleted {result['events_deleted']} event(s)."
|
|
164
|
+
)
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
@app.command("clear")
|
|
168
|
+
def clear(
|
|
169
|
+
ids: list[str] = typer.Argument(None, help="Specific session ids to clear."),
|
|
170
|
+
all_: bool = typer.Option(False, "--all", help="Clear ALL sessions."),
|
|
171
|
+
status: str = typer.Option(None, "--status", help="Only this status (e.g. failed)."),
|
|
172
|
+
older_than: int = typer.Option(None, "--older-than", min=1, help="Only sessions older than N days."),
|
|
173
|
+
workspace: str = typer.Option(None, "--workspace", "-w", help="Scope to a workspace slug."),
|
|
174
|
+
yes: bool = typer.Option(False, "--yes", "-y", help="Skip the confirmation prompt."),
|
|
175
|
+
) -> None:
|
|
176
|
+
"""Permanently delete unwanted sessions (and their runs/events/memory) from the DB.
|
|
177
|
+
|
|
178
|
+
Combine filters (AND): --all, --status, --older-than, --workspace, or session ids.
|
|
179
|
+
Shows a preview and asks for confirmation before deleting."""
|
|
180
|
+
if not (all_ or status or older_than or workspace or ids):
|
|
181
|
+
console.print(
|
|
182
|
+
"[yellow]Nothing selected.[/yellow] Pick what to clear: --all, "
|
|
183
|
+
"--status <s>, --older-than <days>, --workspace <slug>, or session ids."
|
|
184
|
+
)
|
|
185
|
+
raise typer.Exit(1)
|
|
186
|
+
|
|
187
|
+
client = HarnessClient()
|
|
188
|
+
body = {
|
|
189
|
+
"all": all_, "status": status, "older_than_days": older_than,
|
|
190
|
+
"workspace": workspace, "ids": list(ids) if ids else None,
|
|
191
|
+
}
|
|
192
|
+
matches = client.post("/sessions/clear", json={**body, "dry_run": True})["sessions"]
|
|
193
|
+
if not matches:
|
|
194
|
+
console.print("[green]Nothing to clear[/green] — no sessions match.")
|
|
195
|
+
return
|
|
196
|
+
|
|
197
|
+
table = Table("id", "status", "objective")
|
|
198
|
+
for s in matches:
|
|
199
|
+
table.add_row(s["id"], s["status"], (s.get("canonical_objective") or "")[:60])
|
|
200
|
+
console.print(table)
|
|
201
|
+
console.print(f"[bold red]{len(matches)}[/bold red] session(s) will be permanently deleted.")
|
|
202
|
+
if not yes and not typer.confirm("Delete these sessions?"):
|
|
203
|
+
console.print("Aborted.")
|
|
204
|
+
return
|
|
205
|
+
|
|
206
|
+
result = client.post("/sessions/clear", json={**body, "dry_run": False})
|
|
207
|
+
deleted_ids = {s["id"] for s in result["sessions"]}
|
|
208
|
+
# Drop local active-session pointers that referenced a now-deleted session, so the
|
|
209
|
+
# next `ags chat` starts fresh instead of trying to resume a gone session.
|
|
210
|
+
cleared = 0
|
|
211
|
+
for f in _state_dir().glob("*.session"):
|
|
212
|
+
try:
|
|
213
|
+
if f.read_text().strip() in deleted_ids:
|
|
214
|
+
f.unlink()
|
|
215
|
+
cleared += 1
|
|
216
|
+
except OSError:
|
|
217
|
+
pass
|
|
218
|
+
note = f" (cleared {cleared} local pointer(s))" if cleared else ""
|
|
219
|
+
console.print(f"[green]Deleted {result['count']} session(s).[/green]{note}")
|
cli/commands/stats.py
ADDED
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
"""``ags stats`` — per-turn latency and token breakdown.
|
|
2
|
+
|
|
3
|
+
Shows queue time, first-event latency, total time, and token usage for each
|
|
4
|
+
run in a session. Defaults to the current workspace's session.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import json
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
|
|
12
|
+
import typer
|
|
13
|
+
from rich.console import Console
|
|
14
|
+
from rich.table import Table
|
|
15
|
+
|
|
16
|
+
from cli.client import HarnessClient
|
|
17
|
+
from cli.commands.ask import _session_file
|
|
18
|
+
|
|
19
|
+
console = Console()
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def stats(
|
|
23
|
+
session_id: str | None = typer.Argument(
|
|
24
|
+
None, help="Session ID to fetch stats for. Defaults to current workspace session."
|
|
25
|
+
),
|
|
26
|
+
workspace: str = typer.Option("default", "--workspace", "-w", envvar="HARNESS_WORKSPACE"),
|
|
27
|
+
json_out: bool = typer.Option(False, "--json", help="Emit stats as JSON and exit."),
|
|
28
|
+
) -> None:
|
|
29
|
+
"""Show per-run latency and token breakdown for a session."""
|
|
30
|
+
# Resolve session_id: use argument, or fallback to workspace's current session
|
|
31
|
+
if session_id is None:
|
|
32
|
+
session_file = _session_file(workspace)
|
|
33
|
+
if not session_file.exists():
|
|
34
|
+
console.print(
|
|
35
|
+
f"[yellow]No session found for workspace '{workspace}'.\n"
|
|
36
|
+
f"Start one with: [bold]ags ask \"...\"[/bold][/yellow]"
|
|
37
|
+
)
|
|
38
|
+
raise typer.Exit(1)
|
|
39
|
+
session_id = session_file.read_text().strip()
|
|
40
|
+
if not session_id:
|
|
41
|
+
console.print(
|
|
42
|
+
f"[yellow]Session file is empty for workspace '{workspace}'.[/yellow]"
|
|
43
|
+
)
|
|
44
|
+
raise typer.Exit(1)
|
|
45
|
+
|
|
46
|
+
client = HarnessClient()
|
|
47
|
+
try:
|
|
48
|
+
stats_data = client.get(f"/sessions/{session_id}/stats")
|
|
49
|
+
except Exception as exc:
|
|
50
|
+
console.print(f"[red]Error fetching stats: {exc}[/red]")
|
|
51
|
+
raise typer.Exit(1)
|
|
52
|
+
|
|
53
|
+
if json_out:
|
|
54
|
+
console.print_json(data=stats_data)
|
|
55
|
+
return
|
|
56
|
+
|
|
57
|
+
# Build a Rich table
|
|
58
|
+
table = Table(title=f"Run latencies and tokens (session {session_id[:8]})", title_justify="left")
|
|
59
|
+
table.add_column("run id", style="dim")
|
|
60
|
+
table.add_column("provider")
|
|
61
|
+
table.add_column("queue (ms)", justify="right")
|
|
62
|
+
table.add_column("first event (ms)", justify="right")
|
|
63
|
+
table.add_column("total (ms)", justify="right")
|
|
64
|
+
table.add_column("prompt tokens", justify="right")
|
|
65
|
+
table.add_column("completion tokens", justify="right")
|
|
66
|
+
table.add_column("resume")
|
|
67
|
+
|
|
68
|
+
# Track totals
|
|
69
|
+
total_prompt = 0
|
|
70
|
+
total_completion = 0
|
|
71
|
+
|
|
72
|
+
for run in stats_data:
|
|
73
|
+
run_id = run["run_id"][:8]
|
|
74
|
+
provider = run["provider"]
|
|
75
|
+
queue_ms = str(run["queue_ms"]) if run["queue_ms"] is not None else "—"
|
|
76
|
+
first_event_ms = str(run["first_event_ms"]) if run["first_event_ms"] is not None else "—"
|
|
77
|
+
total_ms = str(run["total_ms"]) if run["total_ms"] is not None else "—"
|
|
78
|
+
prompt_tokens = run["tokens"]["prompt"]
|
|
79
|
+
completion_tokens = run["tokens"]["completion"]
|
|
80
|
+
resume = run["resume"] or "—"
|
|
81
|
+
|
|
82
|
+
table.add_row(
|
|
83
|
+
run_id, provider, queue_ms, first_event_ms, total_ms,
|
|
84
|
+
str(prompt_tokens), str(completion_tokens), resume,
|
|
85
|
+
)
|
|
86
|
+
|
|
87
|
+
total_prompt += int(run["tokens"]["prompt"] or 0)
|
|
88
|
+
total_completion += int(run["tokens"]["completion"] or 0)
|
|
89
|
+
|
|
90
|
+
# Add totals row
|
|
91
|
+
table.add_row(
|
|
92
|
+
"[bold]totals[/bold]", "", "", "", "",
|
|
93
|
+
f"[bold]{total_prompt}[/bold]", f"[bold]{total_completion}[/bold]", "",
|
|
94
|
+
)
|
|
95
|
+
|
|
96
|
+
console.print(table)
|
cli/commands/status.py
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
"""``ags status`` — what am I pointed at right now?
|
|
2
|
+
|
|
3
|
+
Answers the everyday questions: which project directory are the agents operating
|
|
4
|
+
in, are they in read-only (plan) or write mode, which workspace, and which agent is
|
|
5
|
+
the current default. Project dir and mode come from the running API (it is launched
|
|
6
|
+
rooted in the project, with ``HARNESS_WRITE`` controlling the mode)."""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import typer
|
|
11
|
+
from rich.console import Console
|
|
12
|
+
|
|
13
|
+
from cli.client import APIError, HarnessClient
|
|
14
|
+
from cli.commands.ask import _resolve_provider
|
|
15
|
+
|
|
16
|
+
console = Console()
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def status(
|
|
20
|
+
workspace: str = typer.Option("default", "--workspace", "-w", envvar="HARNESS_WORKSPACE"),
|
|
21
|
+
) -> None:
|
|
22
|
+
"""Show the active project, mode (plan/write), workspace, and default agent."""
|
|
23
|
+
client = HarnessClient()
|
|
24
|
+
provider = _resolve_provider(None, workspace)
|
|
25
|
+
try:
|
|
26
|
+
st = client.get("/status", workspace=workspace)
|
|
27
|
+
except APIError as exc:
|
|
28
|
+
console.print(f"[red]✗ {exc}[/red]")
|
|
29
|
+
raise typer.Exit(1) from exc
|
|
30
|
+
console.print(f"[bold]workspace[/bold] {workspace}")
|
|
31
|
+
console.print(
|
|
32
|
+
f"[bold]agent[/bold] {provider} "
|
|
33
|
+
f"[dim](default; override per-turn with -P or `ags use`)[/dim]"
|
|
34
|
+
)
|
|
35
|
+
console.print(f"[bold]project[/bold] {st.get('project_dir', '?')}")
|
|
36
|
+
console.print(f"[bold]mode[/bold] {st.get('mode', '?')}")
|
cli/commands/task.py
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import typer
|
|
4
|
+
from rich.console import Console
|
|
5
|
+
from rich.table import Table
|
|
6
|
+
|
|
7
|
+
from cli.client import HarnessClient
|
|
8
|
+
|
|
9
|
+
app = typer.Typer(help="Create and inspect tasks.")
|
|
10
|
+
console = Console()
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@app.command("create")
|
|
14
|
+
def create(
|
|
15
|
+
objective: str = typer.Option(..., "--objective", "-o", help="What to accomplish."),
|
|
16
|
+
title: str = typer.Option("", "--title", "-t"),
|
|
17
|
+
workspace: str = typer.Option("default", "--workspace", "-w"),
|
|
18
|
+
) -> None:
|
|
19
|
+
"""Create a task."""
|
|
20
|
+
client = HarnessClient()
|
|
21
|
+
data = client.post(
|
|
22
|
+
"/tasks",
|
|
23
|
+
json={"objective": objective, "title": title or objective[:60], "workspace": workspace},
|
|
24
|
+
)
|
|
25
|
+
console.print(f"[green]Created task[/green] {data['id']}")
|
|
26
|
+
console.print_json(data=data)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@app.command("list")
|
|
30
|
+
def list_tasks(limit: int = typer.Option(20, "--limit")) -> None:
|
|
31
|
+
"""List recent tasks."""
|
|
32
|
+
client = HarnessClient()
|
|
33
|
+
rows = client.get("/tasks", limit=limit)
|
|
34
|
+
table = Table("id", "title", "status")
|
|
35
|
+
for r in rows:
|
|
36
|
+
table.add_row(r["id"], r["title"], r["status"])
|
|
37
|
+
console.print(table)
|
cli/commands/usage.py
ADDED
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
"""``ags usage`` — all-providers usage table from the run ledger.
|
|
2
|
+
|
|
3
|
+
Shows a table of runs, tokens, and cost for **every** provider recorded in
|
|
4
|
+
the current workspace. For the per-provider view that also includes provider
|
|
5
|
+
plan limits (e.g. 5-hour / weekly quota), use ``ags quota [provider]``.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import typer
|
|
11
|
+
from rich.console import Console
|
|
12
|
+
from rich.table import Table
|
|
13
|
+
|
|
14
|
+
from cli.client import HarnessClient
|
|
15
|
+
|
|
16
|
+
console = Console()
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def usage(
|
|
20
|
+
workspace: str = typer.Option(
|
|
21
|
+
"default", "--workspace", "-w", envvar="HARNESS_WORKSPACE",
|
|
22
|
+
help="Workspace to query.",
|
|
23
|
+
),
|
|
24
|
+
json_out: bool = typer.Option(False, "--json", help="Emit raw usage list as JSON and exit."),
|
|
25
|
+
) -> None:
|
|
26
|
+
"""Show all-providers usage totals from the run ledger.
|
|
27
|
+
|
|
28
|
+
Displays runs, tokens, and cost for every provider recorded in the
|
|
29
|
+
workspace. For the provider-scoped view that also includes plan limits,
|
|
30
|
+
use: [bold]ags quota[/bold]
|
|
31
|
+
"""
|
|
32
|
+
client = HarnessClient()
|
|
33
|
+
rows = client.get("/usage", workspace=workspace)
|
|
34
|
+
|
|
35
|
+
if json_out:
|
|
36
|
+
console.print_json(data=rows)
|
|
37
|
+
return
|
|
38
|
+
|
|
39
|
+
if not rows:
|
|
40
|
+
console.print("[dim]No usage recorded yet.[/dim]")
|
|
41
|
+
return
|
|
42
|
+
|
|
43
|
+
table = Table(
|
|
44
|
+
title=f"AgentS usage — all providers (workspace: {workspace})",
|
|
45
|
+
title_justify="left",
|
|
46
|
+
)
|
|
47
|
+
for col in ("provider", "kind", "runs", "prompt tok", "completion tok", "total tok", "cost (USD)"):
|
|
48
|
+
table.add_column(col)
|
|
49
|
+
|
|
50
|
+
total_runs = 0
|
|
51
|
+
total_prompt = 0
|
|
52
|
+
total_completion = 0
|
|
53
|
+
total_tokens = 0
|
|
54
|
+
total_usd = 0.0
|
|
55
|
+
|
|
56
|
+
for row in rows:
|
|
57
|
+
table.add_row(
|
|
58
|
+
row["provider"],
|
|
59
|
+
row.get("kind", ""),
|
|
60
|
+
str(row["runs"]),
|
|
61
|
+
f"{row['prompt_tokens']:,}",
|
|
62
|
+
f"{row['completion_tokens']:,}",
|
|
63
|
+
f"{row['total_tokens']:,}",
|
|
64
|
+
f"${row['usd']:.4f}",
|
|
65
|
+
)
|
|
66
|
+
total_runs += int(row["runs"])
|
|
67
|
+
total_prompt += int(row["prompt_tokens"])
|
|
68
|
+
total_completion += int(row["completion_tokens"])
|
|
69
|
+
total_tokens += int(row["total_tokens"])
|
|
70
|
+
total_usd += float(row["usd"])
|
|
71
|
+
|
|
72
|
+
# Totals row
|
|
73
|
+
table.add_row(
|
|
74
|
+
"[bold]totals[/bold]", "",
|
|
75
|
+
f"[bold]{total_runs}[/bold]",
|
|
76
|
+
f"[bold]{total_prompt:,}[/bold]",
|
|
77
|
+
f"[bold]{total_completion:,}[/bold]",
|
|
78
|
+
f"[bold]{total_tokens:,}[/bold]",
|
|
79
|
+
f"[bold]${total_usd:.4f}[/bold]",
|
|
80
|
+
)
|
|
81
|
+
|
|
82
|
+
console.print(table)
|
|
83
|
+
console.print(
|
|
84
|
+
"[dim]AgentS's own accounting from the run ledger — "
|
|
85
|
+
"use [bold]ags quota[/bold] to see a single provider's plan limits.[/dim]\n"
|
|
86
|
+
)
|
cli/local.py
ADDED
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
"""Daemonless server management for the CLI.
|
|
2
|
+
|
|
3
|
+
A single ``ags serve`` process backed by SQLite. The CLI auto-spawns it on first use and
|
|
4
|
+
records its URL so every later command (and every terminal) finds it."""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import os
|
|
9
|
+
import shutil
|
|
10
|
+
import subprocess
|
|
11
|
+
import sys
|
|
12
|
+
import time
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
|
|
15
|
+
import httpx
|
|
16
|
+
|
|
17
|
+
LOCAL_PORT = 8770
|
|
18
|
+
LOCAL_BASE_URL = f"http://localhost:{LOCAL_PORT}"
|
|
19
|
+
API_URL_FILE = Path.home() / ".ags" / "api_url"
|
|
20
|
+
PID_FILE = Path.home() / ".ags" / "server.pid"
|
|
21
|
+
LOG_FILE = Path.home() / ".ags" / "server.log"
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def is_up(base_url: str) -> bool:
|
|
25
|
+
try:
|
|
26
|
+
r = httpx.get(f"{base_url}/api/v1/healthz", timeout=1.5)
|
|
27
|
+
return r.status_code == 200
|
|
28
|
+
except httpx.HTTPError:
|
|
29
|
+
return False
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _write_api_url(base_url: str) -> None:
|
|
33
|
+
API_URL_FILE.parent.mkdir(parents=True, exist_ok=True)
|
|
34
|
+
API_URL_FILE.write_text(base_url)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _serve_command() -> list[str]:
|
|
38
|
+
"""Command to launch the server using the INSTALLED package, never CWD code.
|
|
39
|
+
|
|
40
|
+
Prefer the ``ags`` console script (its ``sys.path`` is the bin dir, not the
|
|
41
|
+
current directory, so it can't be shadowed by a repo's own ``cli``/``harness``).
|
|
42
|
+
Fall back to ``python -P -m cli.main`` (``-P`` keeps CWD off ``sys.path``)."""
|
|
43
|
+
exe = shutil.which("ags") or (os.path.basename(sys.argv[0]) == "ags" and sys.argv[0])
|
|
44
|
+
if exe:
|
|
45
|
+
return [exe, "serve", "--port", str(LOCAL_PORT)]
|
|
46
|
+
return [sys.executable, "-P", "-m", "cli.main", "serve", "--port", str(LOCAL_PORT)]
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def spawn_server(base_url: str = LOCAL_BASE_URL, *, wait_s: float = 30.0) -> bool:
|
|
50
|
+
"""Start a detached ``ags serve`` and wait until it answers health checks."""
|
|
51
|
+
PID_FILE.parent.mkdir(parents=True, exist_ok=True)
|
|
52
|
+
LOG_FILE.parent.mkdir(parents=True, exist_ok=True)
|
|
53
|
+
with open(LOG_FILE, "ab") as logf:
|
|
54
|
+
proc = subprocess.Popen(
|
|
55
|
+
_serve_command(),
|
|
56
|
+
stdout=logf, stderr=logf, stdin=subprocess.DEVNULL,
|
|
57
|
+
start_new_session=True, # detach: survives this CLI process exiting
|
|
58
|
+
)
|
|
59
|
+
PID_FILE.write_text(str(proc.pid))
|
|
60
|
+
deadline = time.monotonic() + wait_s
|
|
61
|
+
while time.monotonic() < deadline:
|
|
62
|
+
if is_up(base_url):
|
|
63
|
+
_write_api_url(base_url)
|
|
64
|
+
return True
|
|
65
|
+
if proc.poll() is not None: # server died on startup
|
|
66
|
+
return False
|
|
67
|
+
time.sleep(0.4)
|
|
68
|
+
return False
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def ensure_local_server() -> str | None:
|
|
72
|
+
"""Return a reachable API base URL, auto-spawning the server if needed.
|
|
73
|
+
|
|
74
|
+
Returns None when nothing could be reached (the caller's connect-error message
|
|
75
|
+
then guides the user). Honors an explicit ``HARNESS_API_BASE_URL``."""
|
|
76
|
+
if env := os.environ.get("HARNESS_API_BASE_URL"):
|
|
77
|
+
return env
|
|
78
|
+
# Prefer a previously recorded URL if it's live.
|
|
79
|
+
if API_URL_FILE.exists() and (url := API_URL_FILE.read_text().strip()) and is_up(url):
|
|
80
|
+
return url
|
|
81
|
+
if is_up(LOCAL_BASE_URL):
|
|
82
|
+
_write_api_url(LOCAL_BASE_URL)
|
|
83
|
+
return LOCAL_BASE_URL
|
|
84
|
+
return LOCAL_BASE_URL if spawn_server() else None
|