usage-cli 0.29.32__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.
- adapters/__init__.py +5 -0
- adapters/agy.py +68 -0
- adapters/claude.py +215 -0
- adapters/codex.py +209 -0
- adapters/rate_limits.py +76 -0
- adapters/registry.py +17 -0
- adapters/types.py +139 -0
- agy_disk_cache.py +135 -0
- agy_loader.py +416 -0
- agy_quota_probe.py +748 -0
- agy_window_keeper.py +185 -0
- analyzer/__init__.py +5 -0
- analyzer/aggregator.py +139 -0
- analyzer/blocks.py +80 -0
- analyzer/diagnoser.py +638 -0
- analyzer/insights.py +277 -0
- analyzer/persona_loader.py +199 -0
- analyzer/reporter.py +989 -0
- analyzer/subscription.py +108 -0
- burn_rate.py +75 -0
- cache_quarantine.py +50 -0
- codex_disk_cache.py +227 -0
- codex_events.py +136 -0
- codex_fork_replay.py +111 -0
- codex_loader.py +1426 -0
- codex_paths.py +20 -0
- critter_frames.py +26 -0
- discussion_bridge.py +1196 -0
- discussion_cli.py +844 -0
- discussion_session.py +622 -0
- discussion_usage.py +13 -0
- discussion_window.py +955 -0
- disk_cache_common.py +132 -0
- disk_cache_lifecycle.py +39 -0
- doctor.py +452 -0
- fsevents_watch.py +207 -0
- history_disk_cache.py +110 -0
- history_loader.py +416 -0
- i18n.py +88 -0
- jsonl_limits.py +17 -0
- jsonl_utils.py +40 -0
- login_item.py +154 -0
- main.py +387 -0
- menubar.py +1201 -0
- menubar_actions.py +204 -0
- menubar_agy.py +193 -0
- menubar_chrome.py +156 -0
- menubar_menu.py +169 -0
- menubar_notify.py +102 -0
- menubar_popover.py +233 -0
- menubar_prefs.py +118 -0
- menubar_refresh.py +285 -0
- menubar_state.py +1200 -0
- menubar_title.py +157 -0
- menubar_update.py +123 -0
- panel_window.py +78 -0
- panel_window_state.py +159 -0
- panels/__init__.py +186 -0
- panels/base.py +83 -0
- panels/dynamic_height.py +140 -0
- panels/payload.py +178 -0
- panels/web_panel.py +513 -0
- panels/window_drag.py +56 -0
- prefs.py +44 -0
- pricing.py +452 -0
- project_resolver.py +112 -0
- service_status.py +383 -0
- session_hooks.py +1154 -0
- setup_app.py +171 -0
- setup_hook.py +1011 -0
- statusline_settings.py +160 -0
- talent_market_bridge.py +243 -0
- time_utils.py +24 -0
- tui.py +288 -0
- tui_sprite.py +206 -0
- ui/__init__.py +5 -0
- ui/html_report.py +923 -0
- ui/report_scripts.py +251 -0
- ui/report_styles.py +370 -0
- ui/tables.py +888 -0
- update_checker.py +156 -0
- update_gate.py +66 -0
- update_release_notes.py +49 -0
- usage_cli-0.29.32.data/data/share/usage/i18n.json +2427 -0
- usage_cli-0.29.32.dist-info/METADATA +223 -0
- usage_cli-0.29.32.dist-info/RECORD +109 -0
- usage_cli-0.29.32.dist-info/WHEEL +5 -0
- usage_cli-0.29.32.dist-info/entry_points.txt +3 -0
- usage_cli-0.29.32.dist-info/licenses/LICENSE +663 -0
- usage_cli-0.29.32.dist-info/top_level.txt +80 -0
- usage_cli.py +827 -0
- usage_client.py +487 -0
- usage_diagnosis_snapshot.py +143 -0
- usage_dir_sweeper.py +100 -0
- usage_lang.py +79 -0
- usage_logging.py +75 -0
- usage_notifications.py +96 -0
- usage_rate.py +97 -0
- usage_session_resume.py +913 -0
- usage_statusline.py +810 -0
- usage_statusline_agy.py +397 -0
- usage_statusline_forwarder.py +88 -0
- usage_terse_mode.py +223 -0
- usage_terse_reminder.py +151 -0
- win_login_item.py +53 -0
- window_keeper.py +264 -0
- windows_watch.py +443 -0
- wintray.py +2014 -0
- wintray_menu.py +136 -0
discussion_cli.py
ADDED
|
@@ -0,0 +1,844 @@
|
|
|
1
|
+
# SPDX-License-Identifier: AGPL-3.0-only
|
|
2
|
+
# Copyright (C) 2026 lollapalooza <https://github.com/aqua5230>
|
|
3
|
+
#
|
|
4
|
+
# Part of "usage". Free software licensed under the GNU Affero General Public
|
|
5
|
+
# License v3.0 only; see the LICENSE file for full terms and the warranty disclaimer.
|
|
6
|
+
|
|
7
|
+
"""Headless CLI detection, event parsing, and process streaming."""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import json
|
|
12
|
+
import os
|
|
13
|
+
import re
|
|
14
|
+
import shutil
|
|
15
|
+
import signal
|
|
16
|
+
import subprocess
|
|
17
|
+
import sys
|
|
18
|
+
import threading
|
|
19
|
+
import time
|
|
20
|
+
from collections import deque
|
|
21
|
+
from collections.abc import Callable, Iterator, Mapping, Sequence
|
|
22
|
+
from dataclasses import dataclass
|
|
23
|
+
from pathlib import Path
|
|
24
|
+
from typing import Literal, Protocol, cast
|
|
25
|
+
|
|
26
|
+
from discussion_usage import TurnUsage
|
|
27
|
+
|
|
28
|
+
DetectionSource = Literal["which", "candidate_dir", "user_configured", "not_found"]
|
|
29
|
+
|
|
30
|
+
# Every built-in CLI defaults to a dedicated neutral cwd to block project-level
|
|
31
|
+
# instructions and repository agent rules from contaminating council answers.
|
|
32
|
+
# Claude's --bare mode is intentionally not used: it also disables OAuth/keychain
|
|
33
|
+
# authentication, which would break subscription-based users.
|
|
34
|
+
# Isolation differs by CLI: Claude combines --safe-mode with --setting-sources
|
|
35
|
+
# project for full customization isolation. Codex and Antigravity cannot isolate
|
|
36
|
+
# user-level instructions with flags, so prompts can only mitigate their influence.
|
|
37
|
+
CANDIDATE_DIRECTORIES = (
|
|
38
|
+
Path("/opt/homebrew/bin"),
|
|
39
|
+
Path("/usr/local/bin"),
|
|
40
|
+
Path("~/.local/bin"),
|
|
41
|
+
)
|
|
42
|
+
NEUTRAL_DISCUSSION_CWD = Path("~/.usage/discussion-cwd")
|
|
43
|
+
NEUTRAL_CONFIG_NAMES = frozenset(
|
|
44
|
+
{
|
|
45
|
+
".agents",
|
|
46
|
+
".claude",
|
|
47
|
+
".codex",
|
|
48
|
+
".gemini",
|
|
49
|
+
".mcp.json",
|
|
50
|
+
"agents.md",
|
|
51
|
+
"claude.md",
|
|
52
|
+
"gemini.md",
|
|
53
|
+
"settings.json",
|
|
54
|
+
"settings.local.json",
|
|
55
|
+
}
|
|
56
|
+
)
|
|
57
|
+
DEFAULT_TIMEOUT_SECONDS = 120.0
|
|
58
|
+
TERMINATION_GRACE_SECONDS = 2.0
|
|
59
|
+
STDERR_TAIL_LINES = 50
|
|
60
|
+
MAX_STREAM_OUTPUT_CHARS = 40_000
|
|
61
|
+
TRUNCATION_MARKER = "\n[內容已截斷]"
|
|
62
|
+
POLL_INTERVAL_SECONDS = 0.02
|
|
63
|
+
# Short values are too likely to occur in ordinary output and cause false redactions.
|
|
64
|
+
MIN_REDACTED_ENV_VALUE_LENGTH = 8
|
|
65
|
+
|
|
66
|
+
ANSI_ESCAPE_RE = re.compile(
|
|
67
|
+
r"\x1b(?:\[[0-?]*[ -/]*[@-~]|\][^\x07]*(?:\x07|\x1b\\))"
|
|
68
|
+
)
|
|
69
|
+
CONTROL_RE = re.compile(r"[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]")
|
|
70
|
+
SENSITIVE_ENV_NAME_RE = re.compile(
|
|
71
|
+
# PAT excludes PATH: redacting the PATH value would gut every error message.
|
|
72
|
+
r"(?:TOKEN|KEY|SECRET|PASSWORD|PASSWD|CREDENTIAL|AUTH|COOKIE|PRIVATE|PAT(?!H))",
|
|
73
|
+
re.IGNORECASE,
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
@dataclass(frozen=True)
|
|
78
|
+
class DetectionResult:
|
|
79
|
+
adapter_id: str
|
|
80
|
+
available: bool
|
|
81
|
+
path: str | None
|
|
82
|
+
source: DetectionSource
|
|
83
|
+
error: str | None = None
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
@dataclass(frozen=True)
|
|
87
|
+
class Invocation:
|
|
88
|
+
argv: tuple[str, ...]
|
|
89
|
+
cwd: str | None
|
|
90
|
+
env_overrides: Mapping[str, str]
|
|
91
|
+
timeout_seconds: float
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
class CLIAdapter(Protocol):
|
|
95
|
+
adapter_id: str
|
|
96
|
+
supports_token_stream: bool
|
|
97
|
+
|
|
98
|
+
def detect(self) -> DetectionResult: ...
|
|
99
|
+
|
|
100
|
+
def build_invocation(self, prompt: str, model: str | None) -> Invocation: ...
|
|
101
|
+
|
|
102
|
+
def parse_stdout_line(self, line: str) -> tuple[str | None, bool]: ...
|
|
103
|
+
|
|
104
|
+
def take_final_text(self) -> str | None: ...
|
|
105
|
+
|
|
106
|
+
def take_usage(self) -> TurnUsage | None: ...
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
class CLIUnavailableError(RuntimeError):
|
|
110
|
+
"""Raised when an invocation is requested for an unavailable adapter."""
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
class StreamFailureReason:
|
|
114
|
+
"""Machine-readable reasons for a failed CLI stream."""
|
|
115
|
+
|
|
116
|
+
LAUNCH = "launch"
|
|
117
|
+
TIMEOUT = "timeout"
|
|
118
|
+
NONZERO_EXIT = "nonzero_exit"
|
|
119
|
+
READER = "reader"
|
|
120
|
+
INCOMPLETE = "incomplete"
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
class StreamError(str):
|
|
124
|
+
"""A stream failure message that retains its reason for the caller."""
|
|
125
|
+
|
|
126
|
+
reason: str
|
|
127
|
+
|
|
128
|
+
def __new__(cls, message: str, reason: str) -> StreamError:
|
|
129
|
+
instance = super().__new__(cls, message)
|
|
130
|
+
instance.reason = reason
|
|
131
|
+
return instance
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
class NeutralWorkingDirectoryError(RuntimeError):
|
|
135
|
+
"""Raised when the isolated CLI working directory cannot be used safely."""
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
class _JSONAdapter:
|
|
139
|
+
adapter_id = ""
|
|
140
|
+
executable_name = ""
|
|
141
|
+
supports_token_stream = False
|
|
142
|
+
|
|
143
|
+
def __init__(
|
|
144
|
+
self,
|
|
145
|
+
user_configured_path: str | None = None,
|
|
146
|
+
*,
|
|
147
|
+
cwd: str | None = None,
|
|
148
|
+
read_only: bool = False,
|
|
149
|
+
env_overrides: Mapping[str, str] | None = None,
|
|
150
|
+
timeout_seconds: float = DEFAULT_TIMEOUT_SECONDS,
|
|
151
|
+
extra_read_dirs: Sequence[str] | None = None,
|
|
152
|
+
) -> None:
|
|
153
|
+
self._user_configured_path = user_configured_path
|
|
154
|
+
self._cwd = cwd
|
|
155
|
+
self._read_only = read_only
|
|
156
|
+
self._env_overrides = dict(env_overrides or {})
|
|
157
|
+
self._timeout_seconds = timeout_seconds
|
|
158
|
+
# Directories the CLI is allowed to read in addition to its working
|
|
159
|
+
# directory (used to grant access to attachment folders). Stored as an
|
|
160
|
+
# immutable tuple; non-existent entries are filtered out at invocation
|
|
161
|
+
# time so a missing folder never makes the CLI error out.
|
|
162
|
+
self._extra_read_dirs = tuple(extra_read_dirs or ())
|
|
163
|
+
self._parse_error_count = 0
|
|
164
|
+
self._parse_error_lock = threading.Lock()
|
|
165
|
+
self._usage: TurnUsage | None = None
|
|
166
|
+
|
|
167
|
+
@property
|
|
168
|
+
def parse_error_count(self) -> int:
|
|
169
|
+
with self._parse_error_lock:
|
|
170
|
+
return self._parse_error_count
|
|
171
|
+
|
|
172
|
+
def detect(self) -> DetectionResult:
|
|
173
|
+
configured = self._user_configured_path
|
|
174
|
+
if configured is not None:
|
|
175
|
+
configured_path = Path(configured)
|
|
176
|
+
if not configured_path.is_absolute():
|
|
177
|
+
return DetectionResult(
|
|
178
|
+
self.adapter_id,
|
|
179
|
+
False,
|
|
180
|
+
None,
|
|
181
|
+
"user_configured",
|
|
182
|
+
"configured CLI path must be absolute",
|
|
183
|
+
)
|
|
184
|
+
if _is_executable(configured_path):
|
|
185
|
+
return DetectionResult(
|
|
186
|
+
self.adapter_id,
|
|
187
|
+
True,
|
|
188
|
+
str(configured_path),
|
|
189
|
+
"user_configured",
|
|
190
|
+
)
|
|
191
|
+
return DetectionResult(
|
|
192
|
+
self.adapter_id,
|
|
193
|
+
False,
|
|
194
|
+
str(configured_path),
|
|
195
|
+
"user_configured",
|
|
196
|
+
"configured CLI path is missing or not executable",
|
|
197
|
+
)
|
|
198
|
+
|
|
199
|
+
found = shutil.which(self.executable_name)
|
|
200
|
+
if found is not None:
|
|
201
|
+
return DetectionResult(self.adapter_id, True, found, "which")
|
|
202
|
+
|
|
203
|
+
for directory in CANDIDATE_DIRECTORIES:
|
|
204
|
+
candidate = directory.expanduser() / self.executable_name
|
|
205
|
+
if _is_executable(candidate):
|
|
206
|
+
return DetectionResult(
|
|
207
|
+
self.adapter_id,
|
|
208
|
+
True,
|
|
209
|
+
str(candidate),
|
|
210
|
+
"candidate_dir",
|
|
211
|
+
)
|
|
212
|
+
return DetectionResult(self.adapter_id, False, None, "not_found")
|
|
213
|
+
|
|
214
|
+
def _require_path(self) -> str:
|
|
215
|
+
detection = self.detect()
|
|
216
|
+
if not detection.available or detection.path is None:
|
|
217
|
+
detail = f": {detection.error}" if detection.error else ""
|
|
218
|
+
raise CLIUnavailableError(f"{self.adapter_id} CLI is unavailable{detail}")
|
|
219
|
+
return detection.path
|
|
220
|
+
|
|
221
|
+
def _invocation(self, argv: Sequence[str]) -> Invocation:
|
|
222
|
+
if self._read_only:
|
|
223
|
+
if self._cwd is None:
|
|
224
|
+
raise ValueError("read-only project mode requires a working directory")
|
|
225
|
+
cwd = validate_project_working_directory(self._cwd)
|
|
226
|
+
else:
|
|
227
|
+
cwd = self._cwd or resolve_neutral_working_directory()
|
|
228
|
+
return Invocation(
|
|
229
|
+
argv=tuple(argv),
|
|
230
|
+
cwd=cwd,
|
|
231
|
+
env_overrides=dict(self._env_overrides),
|
|
232
|
+
timeout_seconds=self._timeout_seconds,
|
|
233
|
+
)
|
|
234
|
+
|
|
235
|
+
def _load_event(self, line: str) -> dict[str, object] | None:
|
|
236
|
+
try:
|
|
237
|
+
value = json.loads(line)
|
|
238
|
+
except (json.JSONDecodeError, TypeError):
|
|
239
|
+
self._record_parse_error()
|
|
240
|
+
return None
|
|
241
|
+
if not isinstance(value, dict):
|
|
242
|
+
self._record_parse_error()
|
|
243
|
+
return None
|
|
244
|
+
return cast(dict[str, object], value)
|
|
245
|
+
|
|
246
|
+
def _record_parse_error(self) -> None:
|
|
247
|
+
with self._parse_error_lock:
|
|
248
|
+
self._parse_error_count += 1
|
|
249
|
+
|
|
250
|
+
def _resolved_read_dirs(self) -> tuple[str, ...]:
|
|
251
|
+
resolved: list[str] = []
|
|
252
|
+
for raw in self._extra_read_dirs:
|
|
253
|
+
path = Path(raw).expanduser()
|
|
254
|
+
if path.is_dir():
|
|
255
|
+
resolved.append(str(path.resolve()))
|
|
256
|
+
return tuple(resolved)
|
|
257
|
+
|
|
258
|
+
def take_final_text(self) -> str | None:
|
|
259
|
+
return None
|
|
260
|
+
|
|
261
|
+
def take_usage(self) -> TurnUsage | None:
|
|
262
|
+
usage = self._usage
|
|
263
|
+
self._usage = None
|
|
264
|
+
return usage
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
class ClaudeAdapter(_JSONAdapter):
|
|
268
|
+
adapter_id = "claude"
|
|
269
|
+
executable_name = "claude"
|
|
270
|
+
supports_token_stream = True
|
|
271
|
+
|
|
272
|
+
def build_invocation(self, prompt: str, model: str | None) -> Invocation:
|
|
273
|
+
self._usage = None
|
|
274
|
+
argv = [self._require_path(), "-p"]
|
|
275
|
+
if self._read_only:
|
|
276
|
+
# `--tools` is variadic (`<tools...>`): it keeps consuming argv
|
|
277
|
+
# tokens until the next flag. It must not directly precede the
|
|
278
|
+
# trailing prompt, or claude swallows the prompt as a tool name and
|
|
279
|
+
# dies with "Input must be provided ... when using --print". Keep a
|
|
280
|
+
# non-variadic flag (`--safe-mode`) right after it.
|
|
281
|
+
argv.extend(("--tools", "Read,Grep,Glob"))
|
|
282
|
+
else:
|
|
283
|
+
# Without an attached project, participants have nothing to read
|
|
284
|
+
# and the council prompt already prohibits tool calls. Disable the
|
|
285
|
+
# built-in toolset rather than paying for its schema on every turn.
|
|
286
|
+
# `--safe-mode` below also prevents this variadic flag from
|
|
287
|
+
# consuming the trailing prompt.
|
|
288
|
+
argv.extend(("--tools", ""))
|
|
289
|
+
# `--add-dir` is variadic (`<directories...>`), the same trap as
|
|
290
|
+
# `--tools` above: it must not sit right before the trailing prompt.
|
|
291
|
+
# Emit one `--add-dir <dir>` per folder, then let the non-variadic
|
|
292
|
+
# `--safe-mode` that follows act as the stopper so the prompt survives.
|
|
293
|
+
for directory in self._resolved_read_dirs():
|
|
294
|
+
argv.extend(("--add-dir", directory))
|
|
295
|
+
argv.extend(
|
|
296
|
+
(
|
|
297
|
+
"--safe-mode",
|
|
298
|
+
"--exclude-dynamic-system-prompt-sections",
|
|
299
|
+
"--setting-sources",
|
|
300
|
+
"project",
|
|
301
|
+
"--output-format",
|
|
302
|
+
"stream-json",
|
|
303
|
+
"--include-partial-messages",
|
|
304
|
+
"--verbose",
|
|
305
|
+
)
|
|
306
|
+
)
|
|
307
|
+
if model is not None:
|
|
308
|
+
argv.extend(("--model", model))
|
|
309
|
+
argv.append(prompt)
|
|
310
|
+
return self._invocation(argv)
|
|
311
|
+
|
|
312
|
+
def parse_stdout_line(self, line: str) -> tuple[str | None, bool]:
|
|
313
|
+
event = self._load_event(line)
|
|
314
|
+
if event is None:
|
|
315
|
+
return None, False
|
|
316
|
+
event_type = event.get("type")
|
|
317
|
+
if event_type == "result":
|
|
318
|
+
self._usage = _claude_usage(event.get("usage"))
|
|
319
|
+
return None, True
|
|
320
|
+
if event_type != "stream_event":
|
|
321
|
+
return None, False
|
|
322
|
+
stream_event = event.get("event")
|
|
323
|
+
if not isinstance(stream_event, dict):
|
|
324
|
+
self._record_parse_error()
|
|
325
|
+
return None, False
|
|
326
|
+
if stream_event.get("type") == "message_stop":
|
|
327
|
+
return None, True
|
|
328
|
+
if stream_event.get("type") != "content_block_delta":
|
|
329
|
+
return None, False
|
|
330
|
+
delta = stream_event.get("delta")
|
|
331
|
+
if not isinstance(delta, dict) or delta.get("type") != "text_delta":
|
|
332
|
+
return None, False
|
|
333
|
+
text = delta.get("text")
|
|
334
|
+
if not isinstance(text, str):
|
|
335
|
+
self._record_parse_error()
|
|
336
|
+
return None, False
|
|
337
|
+
return text, False
|
|
338
|
+
|
|
339
|
+
|
|
340
|
+
class CodexAdapter(_JSONAdapter):
|
|
341
|
+
adapter_id = "codex"
|
|
342
|
+
executable_name = "codex"
|
|
343
|
+
supports_token_stream = False
|
|
344
|
+
|
|
345
|
+
def build_invocation(self, prompt: str, model: str | None) -> Invocation:
|
|
346
|
+
self._usage = None
|
|
347
|
+
argv = [
|
|
348
|
+
self._require_path(),
|
|
349
|
+
"exec",
|
|
350
|
+
"--skip-git-repo-check",
|
|
351
|
+
"--ignore-user-config",
|
|
352
|
+
"--json",
|
|
353
|
+
]
|
|
354
|
+
if self._read_only:
|
|
355
|
+
argv.extend(("-s", "read-only"))
|
|
356
|
+
# `--add-dir` takes a single `<DIR>`; repeat once per folder. Placed
|
|
357
|
+
# alongside the other flags so the prompt stays the trailing argument.
|
|
358
|
+
for directory in self._resolved_read_dirs():
|
|
359
|
+
argv.extend(("--add-dir", directory))
|
|
360
|
+
if model is not None:
|
|
361
|
+
argv.extend(("--model", model))
|
|
362
|
+
argv.append(prompt)
|
|
363
|
+
return self._invocation(argv)
|
|
364
|
+
|
|
365
|
+
def parse_stdout_line(self, line: str) -> tuple[str | None, bool]:
|
|
366
|
+
event = self._load_event(line)
|
|
367
|
+
if event is None:
|
|
368
|
+
return None, False
|
|
369
|
+
event_type = event.get("type")
|
|
370
|
+
if event_type == "turn.completed":
|
|
371
|
+
self._usage = _codex_usage(event.get("usage"))
|
|
372
|
+
return None, True
|
|
373
|
+
if event_type != "item.completed":
|
|
374
|
+
return None, False
|
|
375
|
+
item = event.get("item")
|
|
376
|
+
if not isinstance(item, dict) or item.get("type") != "agent_message":
|
|
377
|
+
return None, False
|
|
378
|
+
text = item.get("text")
|
|
379
|
+
if not isinstance(text, str):
|
|
380
|
+
self._record_parse_error()
|
|
381
|
+
return None, False
|
|
382
|
+
return text, False
|
|
383
|
+
|
|
384
|
+
|
|
385
|
+
class AgyAdapter(_JSONAdapter):
|
|
386
|
+
adapter_id = "agy"
|
|
387
|
+
executable_name = "agy"
|
|
388
|
+
supports_token_stream = True
|
|
389
|
+
_final_text: str | None = None
|
|
390
|
+
|
|
391
|
+
def build_invocation(self, prompt: str, model: str | None) -> Invocation:
|
|
392
|
+
self._final_text = None
|
|
393
|
+
self._usage = None
|
|
394
|
+
# agy has no equivalent of Claude's or Codex's user-config isolation flag.
|
|
395
|
+
# The neutral cwd blocks project-level AGENTS.md only; user settings may apply.
|
|
396
|
+
# Project mode can only change cwd; agy exposes no read-only sandbox flag.
|
|
397
|
+
argv = [self._require_path(), "--output-format", "stream-json"]
|
|
398
|
+
if model is not None:
|
|
399
|
+
argv.extend(("--model", model))
|
|
400
|
+
# `--add-dir` is repeatable; emit one per folder before `-p <prompt>`
|
|
401
|
+
# so the prompt remains the final argument.
|
|
402
|
+
for directory in self._resolved_read_dirs():
|
|
403
|
+
argv.extend(("--add-dir", directory))
|
|
404
|
+
argv.extend(("-p", prompt))
|
|
405
|
+
return self._invocation(argv)
|
|
406
|
+
|
|
407
|
+
def parse_stdout_line(self, line: str) -> tuple[str | None, bool]:
|
|
408
|
+
event = self._load_event(line)
|
|
409
|
+
if event is None:
|
|
410
|
+
return None, False
|
|
411
|
+
event_type = event.get("event")
|
|
412
|
+
if event_type == "result":
|
|
413
|
+
result = event.get("result")
|
|
414
|
+
if isinstance(result, dict):
|
|
415
|
+
response = result.get("response")
|
|
416
|
+
if isinstance(response, str) and response:
|
|
417
|
+
self._final_text = response
|
|
418
|
+
self._usage = _agy_usage(result.get("usage"))
|
|
419
|
+
return None, True
|
|
420
|
+
if event_type != "step_update":
|
|
421
|
+
return None, False
|
|
422
|
+
step_update = event.get("step_update")
|
|
423
|
+
if not isinstance(step_update, dict):
|
|
424
|
+
self._record_parse_error()
|
|
425
|
+
return None, False
|
|
426
|
+
if step_update.get("step_type") != "agent_response":
|
|
427
|
+
return None, False
|
|
428
|
+
text = step_update.get("text_delta")
|
|
429
|
+
if not isinstance(text, str):
|
|
430
|
+
self._record_parse_error()
|
|
431
|
+
return None, False
|
|
432
|
+
return text, False
|
|
433
|
+
|
|
434
|
+
def take_final_text(self) -> str | None:
|
|
435
|
+
final_text = self._final_text
|
|
436
|
+
self._final_text = None
|
|
437
|
+
return final_text
|
|
438
|
+
|
|
439
|
+
|
|
440
|
+
def build_argv_invocation(
|
|
441
|
+
executable: str,
|
|
442
|
+
args_before_prompt: Sequence[str],
|
|
443
|
+
args_after_prompt: Sequence[str],
|
|
444
|
+
prompt: str,
|
|
445
|
+
*,
|
|
446
|
+
cwd: str | None = None,
|
|
447
|
+
env_overrides: Mapping[str, str] | None = None,
|
|
448
|
+
timeout_seconds: float = DEFAULT_TIMEOUT_SECONDS,
|
|
449
|
+
) -> Invocation:
|
|
450
|
+
return Invocation(
|
|
451
|
+
argv=(executable, *args_before_prompt, prompt, *args_after_prompt),
|
|
452
|
+
cwd=cwd,
|
|
453
|
+
env_overrides=dict(env_overrides or {}),
|
|
454
|
+
timeout_seconds=timeout_seconds,
|
|
455
|
+
)
|
|
456
|
+
|
|
457
|
+
|
|
458
|
+
def build_login_shell_invocation(
|
|
459
|
+
script: str,
|
|
460
|
+
prompt: str,
|
|
461
|
+
*,
|
|
462
|
+
opt_in: bool,
|
|
463
|
+
cwd: str | None = None,
|
|
464
|
+
env_overrides: Mapping[str, str] | None = None,
|
|
465
|
+
timeout_seconds: float = DEFAULT_TIMEOUT_SECONDS,
|
|
466
|
+
) -> Invocation:
|
|
467
|
+
if not opt_in:
|
|
468
|
+
raise ValueError("login-shell mode requires explicit opt-in")
|
|
469
|
+
return Invocation(
|
|
470
|
+
argv=("/bin/zsh", "-lic", script, "usage-discussion", prompt),
|
|
471
|
+
cwd=cwd,
|
|
472
|
+
env_overrides=dict(env_overrides or {}),
|
|
473
|
+
timeout_seconds=timeout_seconds,
|
|
474
|
+
)
|
|
475
|
+
|
|
476
|
+
|
|
477
|
+
class _Completion:
|
|
478
|
+
def __init__(
|
|
479
|
+
self,
|
|
480
|
+
on_done: Callable[[], None],
|
|
481
|
+
on_error: Callable[[str], None],
|
|
482
|
+
on_cancelled: Callable[[], None],
|
|
483
|
+
) -> None:
|
|
484
|
+
self._on_done = on_done
|
|
485
|
+
self._on_error = on_error
|
|
486
|
+
self._on_cancelled = on_cancelled
|
|
487
|
+
self._committed = False
|
|
488
|
+
self._lock = threading.Lock()
|
|
489
|
+
|
|
490
|
+
def done(self) -> bool:
|
|
491
|
+
with self._lock:
|
|
492
|
+
if self._committed:
|
|
493
|
+
return False
|
|
494
|
+
self._committed = True
|
|
495
|
+
self._on_done()
|
|
496
|
+
return True
|
|
497
|
+
|
|
498
|
+
def error(self, message: str) -> bool:
|
|
499
|
+
with self._lock:
|
|
500
|
+
if self._committed:
|
|
501
|
+
return False
|
|
502
|
+
self._committed = True
|
|
503
|
+
self._on_error(message)
|
|
504
|
+
return True
|
|
505
|
+
|
|
506
|
+
def cancelled(self) -> bool:
|
|
507
|
+
with self._lock:
|
|
508
|
+
if self._committed:
|
|
509
|
+
return False
|
|
510
|
+
self._committed = True
|
|
511
|
+
self._on_cancelled()
|
|
512
|
+
return True
|
|
513
|
+
|
|
514
|
+
|
|
515
|
+
def run_streaming(
|
|
516
|
+
adapter: CLIAdapter,
|
|
517
|
+
invocation: Invocation,
|
|
518
|
+
on_delta: Callable[[str], None],
|
|
519
|
+
on_done: Callable[[], None],
|
|
520
|
+
on_error: Callable[[str], None],
|
|
521
|
+
on_cancelled: Callable[[], None],
|
|
522
|
+
on_final_text: Callable[[str], None] | None = None,
|
|
523
|
+
on_usage: Callable[[TurnUsage], None] | None = None,
|
|
524
|
+
cancel_event: threading.Event | None = None,
|
|
525
|
+
) -> None:
|
|
526
|
+
cancel_event = cancel_event or threading.Event()
|
|
527
|
+
completion = _Completion(on_done, on_error, on_cancelled)
|
|
528
|
+
merged_env = os.environ.copy()
|
|
529
|
+
merged_env.update(invocation.env_overrides)
|
|
530
|
+
try:
|
|
531
|
+
process = subprocess.Popen(
|
|
532
|
+
invocation.argv,
|
|
533
|
+
shell=False,
|
|
534
|
+
stdin=subprocess.DEVNULL,
|
|
535
|
+
stdout=subprocess.PIPE,
|
|
536
|
+
stderr=subprocess.PIPE,
|
|
537
|
+
text=True,
|
|
538
|
+
encoding="utf-8",
|
|
539
|
+
errors="replace",
|
|
540
|
+
bufsize=1,
|
|
541
|
+
start_new_session=True,
|
|
542
|
+
cwd=invocation.cwd,
|
|
543
|
+
env=merged_env,
|
|
544
|
+
)
|
|
545
|
+
except OSError as exc:
|
|
546
|
+
completion.error(
|
|
547
|
+
StreamError(
|
|
548
|
+
_redact_environment_values(str(exc), merged_env),
|
|
549
|
+
StreamFailureReason.LAUNCH,
|
|
550
|
+
)
|
|
551
|
+
)
|
|
552
|
+
return
|
|
553
|
+
|
|
554
|
+
assert process.stdout is not None
|
|
555
|
+
assert process.stderr is not None
|
|
556
|
+
stderr_tail: deque[str] = deque(maxlen=STDERR_TAIL_LINES)
|
|
557
|
+
stdout_tail: deque[str] = deque(maxlen=STDERR_TAIL_LINES)
|
|
558
|
+
reader_errors: list[str] = []
|
|
559
|
+
reader_error_lock = threading.Lock()
|
|
560
|
+
parser_reported_done = threading.Event()
|
|
561
|
+
output_state = _OutputState()
|
|
562
|
+
|
|
563
|
+
def read_stdout() -> None:
|
|
564
|
+
try:
|
|
565
|
+
for raw_line in cast(Iterator[str], process.stdout):
|
|
566
|
+
line = _clean_text(raw_line)
|
|
567
|
+
stdout_tail.append(line.rstrip("\n"))
|
|
568
|
+
delta, is_done = adapter.parse_stdout_line(line)
|
|
569
|
+
if delta is not None:
|
|
570
|
+
bounded = output_state.apply(_clean_text(delta))
|
|
571
|
+
if bounded:
|
|
572
|
+
on_delta(bounded)
|
|
573
|
+
if is_done:
|
|
574
|
+
parser_reported_done.set()
|
|
575
|
+
except (OSError, ValueError) as exc:
|
|
576
|
+
with reader_error_lock:
|
|
577
|
+
reader_errors.append(f"stdout read failed: {exc}")
|
|
578
|
+
|
|
579
|
+
def read_stderr() -> None:
|
|
580
|
+
try:
|
|
581
|
+
for raw_line in cast(Iterator[str], process.stderr):
|
|
582
|
+
stderr_tail.append(_clean_text(raw_line).rstrip("\n"))
|
|
583
|
+
except (OSError, ValueError) as exc:
|
|
584
|
+
with reader_error_lock:
|
|
585
|
+
reader_errors.append(f"stderr read failed: {exc}")
|
|
586
|
+
|
|
587
|
+
stdout_thread = threading.Thread(target=read_stdout, name="discussion-cli-stdout")
|
|
588
|
+
stderr_thread = threading.Thread(target=read_stderr, name="discussion-cli-stderr")
|
|
589
|
+
stdout_thread.start()
|
|
590
|
+
stderr_thread.start()
|
|
591
|
+
|
|
592
|
+
deadline = time.monotonic() + max(0.0, invocation.timeout_seconds)
|
|
593
|
+
termination_reason: Literal["cancelled", "timeout"] | None = None
|
|
594
|
+
while True:
|
|
595
|
+
returncode = process.poll()
|
|
596
|
+
if returncode is not None:
|
|
597
|
+
break
|
|
598
|
+
if cancel_event.is_set():
|
|
599
|
+
termination_reason = "cancelled"
|
|
600
|
+
_terminate_process_group(process)
|
|
601
|
+
break
|
|
602
|
+
remaining = deadline - time.monotonic()
|
|
603
|
+
if remaining <= 0:
|
|
604
|
+
termination_reason = "timeout"
|
|
605
|
+
_terminate_process_group(process)
|
|
606
|
+
break
|
|
607
|
+
cancel_event.wait(min(POLL_INTERVAL_SECONDS, remaining))
|
|
608
|
+
|
|
609
|
+
stdout_thread.join(timeout=TERMINATION_GRACE_SECONDS)
|
|
610
|
+
stderr_thread.join(timeout=TERMINATION_GRACE_SECONDS)
|
|
611
|
+
if stdout_thread.is_alive() or stderr_thread.is_alive():
|
|
612
|
+
with reader_error_lock:
|
|
613
|
+
reader_errors.append("stream reader did not stop")
|
|
614
|
+
|
|
615
|
+
if termination_reason == "cancelled":
|
|
616
|
+
completion.cancelled()
|
|
617
|
+
return
|
|
618
|
+
if termination_reason == "timeout":
|
|
619
|
+
completion.error(
|
|
620
|
+
StreamError(
|
|
621
|
+
f"CLI invocation timed out after {invocation.timeout_seconds:g} seconds",
|
|
622
|
+
StreamFailureReason.TIMEOUT,
|
|
623
|
+
)
|
|
624
|
+
)
|
|
625
|
+
return
|
|
626
|
+
if reader_errors:
|
|
627
|
+
message = "\n".join(reader_errors)
|
|
628
|
+
completion.error(
|
|
629
|
+
StreamError(
|
|
630
|
+
_redact_environment_values(message, merged_env),
|
|
631
|
+
StreamFailureReason.READER,
|
|
632
|
+
)
|
|
633
|
+
)
|
|
634
|
+
return
|
|
635
|
+
|
|
636
|
+
returncode = process.returncode
|
|
637
|
+
if returncode is None:
|
|
638
|
+
returncode = process.wait()
|
|
639
|
+
if returncode != 0:
|
|
640
|
+
stderr_message = "\n".join(stderr_tail).strip()
|
|
641
|
+
stdout_message = "\n".join(stdout_tail).strip()
|
|
642
|
+
message = stderr_message or stdout_message or f"CLI exited with status {returncode}"
|
|
643
|
+
completion.error(
|
|
644
|
+
StreamError(
|
|
645
|
+
_redact_environment_values(message, merged_env),
|
|
646
|
+
StreamFailureReason.NONZERO_EXIT,
|
|
647
|
+
)
|
|
648
|
+
)
|
|
649
|
+
return
|
|
650
|
+
|
|
651
|
+
if not parser_reported_done.is_set():
|
|
652
|
+
completion.error(
|
|
653
|
+
StreamError(
|
|
654
|
+
"CLI exited without a completion event; output may be incomplete",
|
|
655
|
+
StreamFailureReason.INCOMPLETE,
|
|
656
|
+
)
|
|
657
|
+
)
|
|
658
|
+
return
|
|
659
|
+
final_text = adapter.take_final_text()
|
|
660
|
+
if final_text and on_final_text is not None:
|
|
661
|
+
on_final_text(output_state.replace(_clean_text(final_text)))
|
|
662
|
+
usage = adapter.take_usage()
|
|
663
|
+
if usage is not None and on_usage is not None:
|
|
664
|
+
on_usage(usage)
|
|
665
|
+
completion.done()
|
|
666
|
+
|
|
667
|
+
|
|
668
|
+
def _usage_value(usage: object, key: str) -> int:
|
|
669
|
+
if not isinstance(usage, dict):
|
|
670
|
+
return 0
|
|
671
|
+
value = usage.get(key)
|
|
672
|
+
return value if isinstance(value, int) and not isinstance(value, bool) else 0
|
|
673
|
+
|
|
674
|
+
|
|
675
|
+
def _claude_usage(usage: object) -> TurnUsage:
|
|
676
|
+
input_tokens = _usage_value(usage, "input_tokens")
|
|
677
|
+
output_tokens = _usage_value(usage, "output_tokens")
|
|
678
|
+
total_tokens = (
|
|
679
|
+
input_tokens
|
|
680
|
+
+ _usage_value(usage, "cache_creation_input_tokens")
|
|
681
|
+
+ _usage_value(usage, "cache_read_input_tokens")
|
|
682
|
+
+ output_tokens
|
|
683
|
+
)
|
|
684
|
+
return TurnUsage(input_tokens, output_tokens, total_tokens)
|
|
685
|
+
|
|
686
|
+
|
|
687
|
+
def _codex_usage(usage: object) -> TurnUsage:
|
|
688
|
+
input_tokens = _usage_value(usage, "input_tokens")
|
|
689
|
+
output_tokens = _usage_value(usage, "output_tokens") + _usage_value(
|
|
690
|
+
usage, "reasoning_output_tokens"
|
|
691
|
+
)
|
|
692
|
+
total_tokens = (
|
|
693
|
+
input_tokens
|
|
694
|
+
+ _usage_value(usage, "cached_input_tokens")
|
|
695
|
+
+ _usage_value(usage, "cache_write_input_tokens")
|
|
696
|
+
+ output_tokens
|
|
697
|
+
)
|
|
698
|
+
return TurnUsage(input_tokens, output_tokens, total_tokens)
|
|
699
|
+
|
|
700
|
+
|
|
701
|
+
def _agy_usage(usage: object) -> TurnUsage:
|
|
702
|
+
return TurnUsage(
|
|
703
|
+
_usage_value(usage, "input_tokens"),
|
|
704
|
+
_usage_value(usage, "output_tokens"),
|
|
705
|
+
_usage_value(usage, "total_tokens"),
|
|
706
|
+
)
|
|
707
|
+
|
|
708
|
+
|
|
709
|
+
class _OutputState:
|
|
710
|
+
def __init__(self) -> None:
|
|
711
|
+
self._length = 0
|
|
712
|
+
self._truncated = False
|
|
713
|
+
self._lock = threading.Lock()
|
|
714
|
+
|
|
715
|
+
def apply(self, delta: str) -> str:
|
|
716
|
+
with self._lock:
|
|
717
|
+
if self._truncated:
|
|
718
|
+
return ""
|
|
719
|
+
remaining = MAX_STREAM_OUTPUT_CHARS - self._length
|
|
720
|
+
if len(delta) <= remaining:
|
|
721
|
+
self._length += len(delta)
|
|
722
|
+
return delta
|
|
723
|
+
self._truncated = True
|
|
724
|
+
prefix = delta[: max(0, remaining)]
|
|
725
|
+
self._length += len(prefix)
|
|
726
|
+
return prefix + TRUNCATION_MARKER
|
|
727
|
+
|
|
728
|
+
def replace(self, text: str) -> str:
|
|
729
|
+
with self._lock:
|
|
730
|
+
self._length = 0
|
|
731
|
+
self._truncated = False
|
|
732
|
+
return self.apply(text)
|
|
733
|
+
|
|
734
|
+
|
|
735
|
+
def _is_executable(path: Path) -> bool:
|
|
736
|
+
try:
|
|
737
|
+
return path.is_file() and os.access(path, os.X_OK)
|
|
738
|
+
except OSError:
|
|
739
|
+
return False
|
|
740
|
+
|
|
741
|
+
|
|
742
|
+
def validate_project_working_directory(path: str) -> str:
|
|
743
|
+
if not path.strip():
|
|
744
|
+
raise ValueError("working directory must not be blank")
|
|
745
|
+
project_path = Path(path).expanduser()
|
|
746
|
+
try:
|
|
747
|
+
if not project_path.exists():
|
|
748
|
+
raise ValueError(f"working directory does not exist: {project_path}")
|
|
749
|
+
if not project_path.is_dir():
|
|
750
|
+
raise ValueError(f"working directory is not a directory: {project_path}")
|
|
751
|
+
return str(project_path.resolve())
|
|
752
|
+
except OSError as exc:
|
|
753
|
+
raise ValueError(f"cannot access working directory {project_path}: {exc}") from exc
|
|
754
|
+
|
|
755
|
+
|
|
756
|
+
def resolve_neutral_working_directory(path: Path | None = None) -> str:
|
|
757
|
+
neutral_path = (path or NEUTRAL_DISCUSSION_CWD).expanduser()
|
|
758
|
+
try:
|
|
759
|
+
if neutral_path.is_symlink():
|
|
760
|
+
raise NeutralWorkingDirectoryError(
|
|
761
|
+
f"neutral working directory must not be a symlink: {neutral_path}"
|
|
762
|
+
)
|
|
763
|
+
neutral_path.mkdir(mode=0o700, parents=True, exist_ok=True)
|
|
764
|
+
if not neutral_path.is_dir():
|
|
765
|
+
raise NeutralWorkingDirectoryError(
|
|
766
|
+
f"neutral working directory is not a directory: {neutral_path}"
|
|
767
|
+
)
|
|
768
|
+
conflicts = sorted(
|
|
769
|
+
entry.name
|
|
770
|
+
for entry in neutral_path.iterdir()
|
|
771
|
+
if entry.name.lower() in NEUTRAL_CONFIG_NAMES
|
|
772
|
+
)
|
|
773
|
+
except NeutralWorkingDirectoryError:
|
|
774
|
+
raise
|
|
775
|
+
except OSError as exc:
|
|
776
|
+
raise NeutralWorkingDirectoryError(
|
|
777
|
+
f"cannot prepare neutral working directory {neutral_path}: {exc}"
|
|
778
|
+
) from exc
|
|
779
|
+
if conflicts:
|
|
780
|
+
names = ", ".join(conflicts)
|
|
781
|
+
raise NeutralWorkingDirectoryError(
|
|
782
|
+
f"neutral working directory contains configuration files: {names}"
|
|
783
|
+
)
|
|
784
|
+
return str(neutral_path)
|
|
785
|
+
|
|
786
|
+
|
|
787
|
+
def _terminate_process_group_posix(process: subprocess.Popen[str]) -> None:
|
|
788
|
+
try:
|
|
789
|
+
process_group = os.getpgid(process.pid) # type: ignore[attr-defined]
|
|
790
|
+
except ProcessLookupError:
|
|
791
|
+
return
|
|
792
|
+
try:
|
|
793
|
+
os.killpg(process_group, signal.SIGTERM) # type: ignore[attr-defined]
|
|
794
|
+
except ProcessLookupError:
|
|
795
|
+
return
|
|
796
|
+
try:
|
|
797
|
+
process.wait(timeout=TERMINATION_GRACE_SECONDS)
|
|
798
|
+
return
|
|
799
|
+
except subprocess.TimeoutExpired:
|
|
800
|
+
pass
|
|
801
|
+
try:
|
|
802
|
+
os.killpg(process_group, signal.SIGKILL) # type: ignore[attr-defined]
|
|
803
|
+
except ProcessLookupError:
|
|
804
|
+
return
|
|
805
|
+
try:
|
|
806
|
+
process.wait(timeout=TERMINATION_GRACE_SECONDS)
|
|
807
|
+
except subprocess.TimeoutExpired:
|
|
808
|
+
return
|
|
809
|
+
|
|
810
|
+
|
|
811
|
+
def _terminate_process_group_win(process: subprocess.Popen[str]) -> None:
|
|
812
|
+
process.terminate()
|
|
813
|
+
try:
|
|
814
|
+
process.wait(timeout=TERMINATION_GRACE_SECONDS)
|
|
815
|
+
return
|
|
816
|
+
except subprocess.TimeoutExpired:
|
|
817
|
+
pass
|
|
818
|
+
process.kill()
|
|
819
|
+
try:
|
|
820
|
+
process.wait(timeout=TERMINATION_GRACE_SECONDS)
|
|
821
|
+
except subprocess.TimeoutExpired:
|
|
822
|
+
return
|
|
823
|
+
|
|
824
|
+
|
|
825
|
+
# AI Council only runs its GUI on macOS today (see discussion_window.py), but
|
|
826
|
+
# this CLI layer is imported and type-checked on Windows CI too. `start_new_session`
|
|
827
|
+
# on the Popen call above is a POSIX-only no-op on Windows, so termination there
|
|
828
|
+
# falls back to plain terminate()/kill() instead of process-group signals.
|
|
829
|
+
_terminate_process_group = (
|
|
830
|
+
_terminate_process_group_win if sys.platform == "win32" else _terminate_process_group_posix
|
|
831
|
+
)
|
|
832
|
+
|
|
833
|
+
|
|
834
|
+
def _clean_text(text: str) -> str:
|
|
835
|
+
normalized = text.replace("\r\n", "\n").replace("\r", "\n")
|
|
836
|
+
return CONTROL_RE.sub("", ANSI_ESCAPE_RE.sub("", normalized))
|
|
837
|
+
|
|
838
|
+
|
|
839
|
+
def _redact_environment_values(message: str, environment: Mapping[str, str]) -> str:
|
|
840
|
+
redacted = message
|
|
841
|
+
for name, value in environment.items():
|
|
842
|
+
if len(value) >= MIN_REDACTED_ENV_VALUE_LENGTH and SENSITIVE_ENV_NAME_RE.search(name):
|
|
843
|
+
redacted = redacted.replace(value, "[REDACTED]")
|
|
844
|
+
return redacted
|