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.
Files changed (109) hide show
  1. adapters/__init__.py +5 -0
  2. adapters/agy.py +68 -0
  3. adapters/claude.py +215 -0
  4. adapters/codex.py +209 -0
  5. adapters/rate_limits.py +76 -0
  6. adapters/registry.py +17 -0
  7. adapters/types.py +139 -0
  8. agy_disk_cache.py +135 -0
  9. agy_loader.py +416 -0
  10. agy_quota_probe.py +748 -0
  11. agy_window_keeper.py +185 -0
  12. analyzer/__init__.py +5 -0
  13. analyzer/aggregator.py +139 -0
  14. analyzer/blocks.py +80 -0
  15. analyzer/diagnoser.py +638 -0
  16. analyzer/insights.py +277 -0
  17. analyzer/persona_loader.py +199 -0
  18. analyzer/reporter.py +989 -0
  19. analyzer/subscription.py +108 -0
  20. burn_rate.py +75 -0
  21. cache_quarantine.py +50 -0
  22. codex_disk_cache.py +227 -0
  23. codex_events.py +136 -0
  24. codex_fork_replay.py +111 -0
  25. codex_loader.py +1426 -0
  26. codex_paths.py +20 -0
  27. critter_frames.py +26 -0
  28. discussion_bridge.py +1196 -0
  29. discussion_cli.py +844 -0
  30. discussion_session.py +622 -0
  31. discussion_usage.py +13 -0
  32. discussion_window.py +955 -0
  33. disk_cache_common.py +132 -0
  34. disk_cache_lifecycle.py +39 -0
  35. doctor.py +452 -0
  36. fsevents_watch.py +207 -0
  37. history_disk_cache.py +110 -0
  38. history_loader.py +416 -0
  39. i18n.py +88 -0
  40. jsonl_limits.py +17 -0
  41. jsonl_utils.py +40 -0
  42. login_item.py +154 -0
  43. main.py +387 -0
  44. menubar.py +1201 -0
  45. menubar_actions.py +204 -0
  46. menubar_agy.py +193 -0
  47. menubar_chrome.py +156 -0
  48. menubar_menu.py +169 -0
  49. menubar_notify.py +102 -0
  50. menubar_popover.py +233 -0
  51. menubar_prefs.py +118 -0
  52. menubar_refresh.py +285 -0
  53. menubar_state.py +1200 -0
  54. menubar_title.py +157 -0
  55. menubar_update.py +123 -0
  56. panel_window.py +78 -0
  57. panel_window_state.py +159 -0
  58. panels/__init__.py +186 -0
  59. panels/base.py +83 -0
  60. panels/dynamic_height.py +140 -0
  61. panels/payload.py +178 -0
  62. panels/web_panel.py +513 -0
  63. panels/window_drag.py +56 -0
  64. prefs.py +44 -0
  65. pricing.py +452 -0
  66. project_resolver.py +112 -0
  67. service_status.py +383 -0
  68. session_hooks.py +1154 -0
  69. setup_app.py +171 -0
  70. setup_hook.py +1011 -0
  71. statusline_settings.py +160 -0
  72. talent_market_bridge.py +243 -0
  73. time_utils.py +24 -0
  74. tui.py +288 -0
  75. tui_sprite.py +206 -0
  76. ui/__init__.py +5 -0
  77. ui/html_report.py +923 -0
  78. ui/report_scripts.py +251 -0
  79. ui/report_styles.py +370 -0
  80. ui/tables.py +888 -0
  81. update_checker.py +156 -0
  82. update_gate.py +66 -0
  83. update_release_notes.py +49 -0
  84. usage_cli-0.29.32.data/data/share/usage/i18n.json +2427 -0
  85. usage_cli-0.29.32.dist-info/METADATA +223 -0
  86. usage_cli-0.29.32.dist-info/RECORD +109 -0
  87. usage_cli-0.29.32.dist-info/WHEEL +5 -0
  88. usage_cli-0.29.32.dist-info/entry_points.txt +3 -0
  89. usage_cli-0.29.32.dist-info/licenses/LICENSE +663 -0
  90. usage_cli-0.29.32.dist-info/top_level.txt +80 -0
  91. usage_cli.py +827 -0
  92. usage_client.py +487 -0
  93. usage_diagnosis_snapshot.py +143 -0
  94. usage_dir_sweeper.py +100 -0
  95. usage_lang.py +79 -0
  96. usage_logging.py +75 -0
  97. usage_notifications.py +96 -0
  98. usage_rate.py +97 -0
  99. usage_session_resume.py +913 -0
  100. usage_statusline.py +810 -0
  101. usage_statusline_agy.py +397 -0
  102. usage_statusline_forwarder.py +88 -0
  103. usage_terse_mode.py +223 -0
  104. usage_terse_reminder.py +151 -0
  105. win_login_item.py +53 -0
  106. window_keeper.py +264 -0
  107. windows_watch.py +443 -0
  108. wintray.py +2014 -0
  109. 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