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_bridge.py ADDED
@@ -0,0 +1,1196 @@
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
+ """PyObjC-free orchestration for one AI council discussion at a time."""
8
+
9
+ from __future__ import annotations
10
+
11
+ import json
12
+ import logging
13
+ import os
14
+ import re
15
+ import shutil
16
+ import threading
17
+ import time
18
+ from collections import deque
19
+ from collections.abc import Callable, Mapping, Sequence
20
+ from dataclasses import asdict, dataclass, field, replace
21
+ from datetime import datetime
22
+ from pathlib import Path
23
+ from typing import Literal
24
+
25
+ from discussion_cli import (
26
+ DEFAULT_TIMEOUT_SECONDS,
27
+ AgyAdapter,
28
+ ClaudeAdapter,
29
+ CLIAdapter,
30
+ CodexAdapter,
31
+ DetectionResult,
32
+ Invocation,
33
+ StreamError,
34
+ StreamFailureReason,
35
+ build_argv_invocation,
36
+ build_login_shell_invocation,
37
+ resolve_neutral_working_directory,
38
+ run_streaming,
39
+ validate_project_working_directory,
40
+ )
41
+ from discussion_session import (
42
+ ConsensusCount,
43
+ DebateStyle,
44
+ DiscussionEvent,
45
+ DiscussionSession,
46
+ Participant,
47
+ SessionStatus,
48
+ build_moderator_prompt,
49
+ build_round1_prompt,
50
+ build_round2_prompt,
51
+ truncate_guidance,
52
+ )
53
+ from discussion_usage import TurnUsage
54
+ from i18n import _t
55
+ from usage_lang import detect_lang
56
+
57
+ logger = logging.getLogger(__name__)
58
+
59
+ MAX_CONCURRENT_PROCESSES = 4
60
+ DELTA_FLUSH_CHARS = 128
61
+ DELTA_FLUSH_SECONDS = 0.05
62
+ GUIDANCE_TIMEOUT_SECONDS = 300.0
63
+ DISCUSSIONS_DIRECTORY = Path("~/.usage/discussions").expanduser()
64
+
65
+ ParticipantSource = Literal["builtin", "argv", "login_shell"]
66
+ AdapterFactory = Callable[["ParticipantSpec"], CLIAdapter]
67
+
68
+
69
+ class DiscussionBusyError(RuntimeError):
70
+ """Raised when start is called while the current session is still running."""
71
+
72
+
73
+ @dataclass(frozen=True)
74
+ class ParticipantSpec:
75
+ id: str
76
+ label: str
77
+ adapter_id: str
78
+ model: str | None = None
79
+ source: ParticipantSource = "builtin"
80
+ executable: str | None = None
81
+ args_before_prompt: tuple[str, ...] = ()
82
+ args_after_prompt: tuple[str, ...] = ()
83
+ login_shell_script: str | None = None
84
+ login_shell_opt_in: bool = False
85
+ cwd: str | None = None
86
+ read_only: bool = False
87
+ extra_read_dirs: tuple[str, ...] = ()
88
+ env_overrides: Mapping[str, str] = field(default_factory=dict)
89
+ timeout_seconds: float = DEFAULT_TIMEOUT_SECONDS
90
+ supports_token_stream: bool = False
91
+ persona_prompt: str | None = None
92
+ persona_label: str | None = None
93
+
94
+
95
+ @dataclass(frozen=True)
96
+ class _ResolvedParticipant:
97
+ spec: ParticipantSpec
98
+ adapter: CLIAdapter | None
99
+ detection: DetectionResult
100
+
101
+
102
+ @dataclass(frozen=True)
103
+ class _TurnResult:
104
+ participant: _ResolvedParticipant
105
+ turn_id: str | None
106
+ success: bool
107
+ text: str
108
+ error: str | None
109
+
110
+
111
+ class _DeltaAccumulator:
112
+ def __init__(self) -> None:
113
+ self._parts: list[str] = []
114
+ self._length = 0
115
+ self._last_flush = time.monotonic()
116
+ self._lock = threading.Lock()
117
+
118
+ def add(self, text: str) -> str:
119
+ with self._lock:
120
+ self._parts.append(text)
121
+ self._length += len(text)
122
+ now = time.monotonic()
123
+ if (
124
+ self._length < DELTA_FLUSH_CHARS
125
+ and now - self._last_flush < DELTA_FLUSH_SECONDS
126
+ ):
127
+ return ""
128
+ return self._take_locked(now)
129
+
130
+ def flush(self) -> str:
131
+ with self._lock:
132
+ return self._take_locked(time.monotonic())
133
+
134
+ def _take_locked(self, now: float) -> str:
135
+ if not self._parts:
136
+ return ""
137
+ value = "".join(self._parts)
138
+ self._parts.clear()
139
+ self._length = 0
140
+ self._last_flush = now
141
+ return value
142
+
143
+
144
+ class _CustomLineAdapter:
145
+ def __init__(self, spec: ParticipantSpec) -> None:
146
+ self.adapter_id = spec.adapter_id
147
+ self.supports_token_stream = spec.supports_token_stream
148
+ self._spec = spec
149
+
150
+ def detect(self) -> DetectionResult:
151
+ if self._spec.source == "login_shell":
152
+ shell = Path("/bin/zsh")
153
+ available = shell.is_file() and os.access(shell, os.X_OK)
154
+ return DetectionResult(
155
+ self.adapter_id,
156
+ available,
157
+ str(shell) if available else None,
158
+ "user_configured" if available else "not_found",
159
+ None if available else "/bin/zsh is missing or not executable",
160
+ )
161
+ executable = self._spec.executable
162
+ if not executable:
163
+ return DetectionResult(
164
+ self.adapter_id,
165
+ False,
166
+ None,
167
+ "not_found",
168
+ "custom executable is required",
169
+ )
170
+ path = Path(executable)
171
+ if path.is_absolute():
172
+ available = path.is_file() and os.access(path, os.X_OK)
173
+ return DetectionResult(
174
+ self.adapter_id,
175
+ available,
176
+ str(path),
177
+ "user_configured",
178
+ None if available else "custom executable is missing or not executable",
179
+ )
180
+ found = shutil.which(executable)
181
+ return DetectionResult(
182
+ self.adapter_id,
183
+ found is not None,
184
+ found,
185
+ "which" if found is not None else "not_found",
186
+ None if found is not None else f"custom executable not found: {executable}",
187
+ )
188
+
189
+ def build_invocation(self, prompt: str, model: str | None) -> Invocation:
190
+ if self._spec.read_only:
191
+ raise ValueError("read-only project mode is unavailable for custom participants")
192
+ cwd = self._spec.cwd or resolve_neutral_working_directory()
193
+ if self._spec.source == "login_shell":
194
+ script = self._spec.login_shell_script
195
+ if script is None:
196
+ raise ValueError("login-shell command requires login_shell_script")
197
+ return build_login_shell_invocation(
198
+ script,
199
+ prompt,
200
+ opt_in=self._spec.login_shell_opt_in,
201
+ cwd=cwd,
202
+ env_overrides=self._spec.env_overrides,
203
+ timeout_seconds=self._spec.timeout_seconds,
204
+ )
205
+ detection = self.detect()
206
+ if not detection.available or detection.path is None:
207
+ raise ValueError(detection.error or "custom executable is unavailable")
208
+ return build_argv_invocation(
209
+ detection.path,
210
+ self._spec.args_before_prompt,
211
+ self._spec.args_after_prompt,
212
+ prompt,
213
+ cwd=cwd,
214
+ env_overrides=self._spec.env_overrides,
215
+ timeout_seconds=self._spec.timeout_seconds,
216
+ )
217
+
218
+ def parse_stdout_line(self, line: str) -> tuple[str | None, bool]:
219
+ return (line, False) if line else (None, False)
220
+
221
+ def take_final_text(self) -> str | None:
222
+ return None
223
+
224
+ def take_usage(self) -> TurnUsage | None:
225
+ return None
226
+
227
+
228
+ class DiscussionBridge:
229
+ def __init__(self, adapter_factory: AdapterFactory | None = None) -> None:
230
+ self._adapter_factory = adapter_factory or _default_adapter_factory
231
+ self._session: DiscussionSession | None = None
232
+ self._worker: threading.Thread | None = None
233
+ self._cancel_event: threading.Event | None = None
234
+ self._guidance_event = threading.Event()
235
+ self._guidance_lock = threading.Lock()
236
+ self._guidance_text: str | None = None
237
+ self._state_lock = threading.Lock()
238
+ self._event_order_lock = threading.RLock()
239
+ self._event_lock = threading.Lock()
240
+ self._events: deque[DiscussionEvent] = deque()
241
+ self._event_listener: Callable[[], None] | None = None
242
+ self._callbacks_enabled = True
243
+ self._working_directory: str | None = None
244
+
245
+ def detect_participants(self) -> list[DetectionResult]:
246
+ return [
247
+ ClaudeAdapter().detect(),
248
+ CodexAdapter().detect(),
249
+ AgyAdapter().detect(),
250
+ ]
251
+
252
+ def start(
253
+ self,
254
+ topic: str,
255
+ participants: Sequence[ParticipantSpec],
256
+ moderator_id: str | None = None,
257
+ working_directory: str | None = None,
258
+ attachments: Sequence[str] | None = None,
259
+ total_rounds: int = 2,
260
+ include_summary: bool = True,
261
+ end_on_consensus: bool = False,
262
+ guidance_between_rounds: bool = False,
263
+ debate_style: DebateStyle = DebateStyle.CONSTRUCTIVE,
264
+ ) -> str:
265
+ normalized_topic = topic.strip()
266
+ if not normalized_topic:
267
+ raise ValueError("topic must not be blank")
268
+ specs = tuple(participants)
269
+ if not specs:
270
+ raise ValueError("at least one participant is required")
271
+ project_cwd = (
272
+ validate_project_working_directory(working_directory)
273
+ if working_directory is not None
274
+ else None
275
+ )
276
+ if project_cwd is not None:
277
+ specs = tuple(
278
+ replace(spec, cwd=project_cwd, read_only=True) for spec in specs
279
+ )
280
+ # Grant each CLI read access to the folders holding the attached
281
+ # images, so non-interactive mode can open files outside the working
282
+ # directory without an approval prompt. Only the attachment folders
283
+ # themselves — never the home directory or wider paths.
284
+ attachment_dirs = _attachment_dirs(attachments or ())
285
+ if attachment_dirs:
286
+ specs = tuple(
287
+ replace(spec, extra_read_dirs=attachment_dirs) for spec in specs
288
+ )
289
+ participant_models = [
290
+ Participant(
291
+ id=spec.id,
292
+ label=spec.label,
293
+ adapter_id=spec.adapter_id,
294
+ model=spec.model,
295
+ is_moderator=spec.id == moderator_id,
296
+ persona_label=spec.persona_label,
297
+ )
298
+ for spec in specs
299
+ ]
300
+ rounds = min(5, max(1, total_rounds))
301
+ session = DiscussionSession(
302
+ normalized_topic, participant_models, total_rounds=rounds
303
+ )
304
+ # The session keeps the user's original topic for display; only the
305
+ # prompt handed to each CLI carries the appended image paths.
306
+ effective_topic = normalized_topic + build_attachment_block(
307
+ attachments or (), detect_lang()
308
+ )
309
+ cancel_event = threading.Event()
310
+ with self._guidance_lock:
311
+ self._guidance_text = None
312
+ self._guidance_event.clear()
313
+ with self._state_lock:
314
+ if self._worker is not None and self._worker.is_alive():
315
+ raise DiscussionBusyError("a discussion session is already running")
316
+ self._session = session
317
+ self._working_directory = project_cwd
318
+ self._cancel_event = cancel_event
319
+ self._callbacks_enabled = True
320
+ with self._event_lock:
321
+ self._events.clear()
322
+ session.transition(SessionStatus.PREPARING)
323
+ worker = threading.Thread(
324
+ target=self._run_session,
325
+ args=(
326
+ session,
327
+ specs,
328
+ moderator_id,
329
+ effective_topic,
330
+ rounds,
331
+ include_summary,
332
+ end_on_consensus,
333
+ guidance_between_rounds,
334
+ debate_style,
335
+ cancel_event,
336
+ ),
337
+ name=f"discussion-session-{session.session_id}",
338
+ daemon=True,
339
+ )
340
+ self._worker = worker
341
+ worker.start()
342
+ return session.session_id
343
+
344
+ def stop(self) -> None:
345
+ with self._state_lock:
346
+ session = self._session
347
+ cancel_event = self._cancel_event
348
+ if session is None or cancel_event is None:
349
+ return
350
+ with self._event_order_lock:
351
+ if session.status not in {
352
+ SessionStatus.PREPARING,
353
+ SessionStatus.ROUND1_RUNNING,
354
+ SessionStatus.AWAITING_GUIDANCE,
355
+ SessionStatus.ROUND2_RUNNING,
356
+ SessionStatus.SUMMARIZING,
357
+ }:
358
+ return
359
+ cancel_event.set()
360
+ self._guidance_event.set()
361
+ session.transition(SessionStatus.CANCELLING)
362
+ for cancelled in session.cancel_incomplete_turns():
363
+ self._enqueue_event_locked(cancelled)
364
+ event = session.transition(SessionStatus.CANCELLED)
365
+ if event is not None:
366
+ self._enqueue_event_locked(event)
367
+
368
+ def clear(self) -> dict[str, str]:
369
+ """Drop the finished discussion so a new round can start fresh.
370
+
371
+ Refuses while the worker thread is still alive (the UI must stop
372
+ first); returns ``{"status": "busy"}`` rather than raising so a stray
373
+ call never crashes the window. On success returns ``{"status": "ok"}``
374
+ and ``snapshot()`` is empty afterwards. Never touches attachment files.
375
+ """
376
+ with self._state_lock:
377
+ if self._worker is not None and self._worker.is_alive():
378
+ return {"status": "busy"}
379
+ self._session = None
380
+ self._worker = None
381
+ self._cancel_event = None
382
+ self._working_directory = None
383
+ with self._guidance_lock:
384
+ self._guidance_text = None
385
+ self._guidance_event.clear()
386
+ with self._event_lock:
387
+ self._events.clear()
388
+ return {"status": "ok"}
389
+
390
+ def submit_guidance(self, text: str) -> None:
391
+ with self._guidance_lock:
392
+ session = self._session
393
+ if session is None or session.status is not SessionStatus.AWAITING_GUIDANCE:
394
+ return
395
+ self._guidance_text = truncate_guidance(text)
396
+ self._guidance_event.set()
397
+
398
+ def snapshot(self) -> dict[str, object]:
399
+ with self._state_lock:
400
+ session = self._session
401
+ working_directory = self._working_directory
402
+ if session is None:
403
+ return {}
404
+ snapshot = session.snapshot()
405
+ snapshot["working_directory"] = working_directory
406
+ return snapshot
407
+
408
+ def drain_events(self, max_count: int = 50) -> list[dict[str, object]]:
409
+ if max_count <= 0:
410
+ return []
411
+ drained: list[dict[str, object]] = []
412
+ with self._event_lock:
413
+ for _ in range(min(max_count, len(self._events))):
414
+ drained.append(asdict(self._events.popleft()))
415
+ return drained
416
+
417
+ def set_event_listener(self, callback: Callable[[], None] | None) -> None:
418
+ with self._event_lock:
419
+ self._event_listener = callback
420
+
421
+ def shutdown(self, timeout_seconds: float = 5.0) -> None:
422
+ self.stop()
423
+ with self._state_lock:
424
+ worker = self._worker
425
+ if worker is not None:
426
+ worker.join(timeout=max(0.0, timeout_seconds))
427
+ with self._event_order_lock, self._event_lock:
428
+ self._callbacks_enabled = False
429
+ self._event_listener = None
430
+
431
+ def _run_session(
432
+ self,
433
+ session: DiscussionSession,
434
+ specs: tuple[ParticipantSpec, ...],
435
+ moderator_id: str | None,
436
+ effective_topic: str,
437
+ total_rounds: int,
438
+ include_summary: bool,
439
+ end_on_consensus: bool,
440
+ guidance_between_rounds: bool,
441
+ debate_style: DebateStyle,
442
+ cancel_event: threading.Event,
443
+ ) -> None:
444
+ try:
445
+ anonymous_labels = {
446
+ participant.id: _anonymous_participant_label(index)
447
+ for index, participant in enumerate(session.participants)
448
+ }
449
+ resolved = self._resolve_participants(specs)
450
+ if cancel_event.is_set():
451
+ return
452
+ self._transition(session, cancel_event, SessionStatus.ROUND1_RUNNING)
453
+ round1 = self._run_round(
454
+ session,
455
+ resolved,
456
+ 1,
457
+ lambda participant: build_round1_prompt(
458
+ effective_topic,
459
+ persona=participant.spec.persona_prompt,
460
+ ),
461
+ cancel_event,
462
+ )
463
+ if cancel_event.is_set():
464
+ return
465
+ round1_survivors = [result for result in round1 if result.success]
466
+ if not round1_survivors:
467
+ self._transition(
468
+ session,
469
+ cancel_event,
470
+ SessionStatus.FAILED,
471
+ error="all participants failed in round 1",
472
+ )
473
+ return
474
+ if len(round1_survivors) < 2:
475
+ self._transition(session, cancel_event, SessionStatus.COMPLETED)
476
+ return
477
+ survivors = round1_survivors
478
+ for round_index in range(2, total_rounds + 1):
479
+ if len(survivors) < 2:
480
+ break
481
+ guidance: str | None = None
482
+ if guidance_between_rounds:
483
+ guidance = self._await_guidance(
484
+ session,
485
+ round_index,
486
+ cancel_event,
487
+ )
488
+ if cancel_event.is_set():
489
+ return
490
+ else:
491
+ self._transition(
492
+ session,
493
+ cancel_event,
494
+ SessionStatus.ROUND2_RUNNING,
495
+ round_index=round_index,
496
+ )
497
+ answers = [
498
+ (
499
+ anonymous_labels[result.participant.spec.id],
500
+ result.text,
501
+ )
502
+ for result in survivors
503
+ ]
504
+
505
+ def round_prompt(
506
+ participant: _ResolvedParticipant,
507
+ answers: list[tuple[str, str]] = answers,
508
+ prior_round: int = round_index - 1,
509
+ guidance: str | None = guidance,
510
+ ) -> str:
511
+ return build_round2_prompt(
512
+ effective_topic,
513
+ answers,
514
+ prior_round=prior_round,
515
+ persona=participant.spec.persona_prompt,
516
+ style=debate_style,
517
+ guidance=guidance,
518
+ )
519
+
520
+ round_results = self._run_round(
521
+ session,
522
+ [result.participant for result in survivors],
523
+ round_index,
524
+ round_prompt,
525
+ cancel_event,
526
+ )
527
+ consensus = self._publish_consensus_count(session, cancel_event)
528
+ survivors = [
529
+ result
530
+ for result in round_results
531
+ if result.success
532
+ ]
533
+ if end_on_consensus and _is_unanimous_consensus(
534
+ consensus, len(round_results)
535
+ ):
536
+ self._mark_consensus_reached(
537
+ session,
538
+ round_index,
539
+ cancel_event,
540
+ )
541
+ break
542
+ if cancel_event.is_set() or not survivors:
543
+ break
544
+ if cancel_event.is_set() or not survivors or not include_summary:
545
+ self._transition(session, cancel_event, SessionStatus.COMPLETED)
546
+ return
547
+
548
+ moderator = _select_moderator(survivors, moderator_id)
549
+ if moderator is None:
550
+ self._transition(session, cancel_event, SessionStatus.COMPLETED)
551
+ return
552
+ self._transition(session, cancel_event, SessionStatus.SUMMARIZING)
553
+ transcript = _build_transcript(session, anonymous_labels)
554
+ self._run_turn(
555
+ session,
556
+ moderator.participant,
557
+ total_rounds + 1,
558
+ build_moderator_prompt(transcript),
559
+ cancel_event,
560
+ text_transform=lambda text: _restore_participant_labels(
561
+ text,
562
+ session,
563
+ anonymous_labels,
564
+ ),
565
+ )
566
+ if cancel_event.is_set():
567
+ return
568
+ self._transition(session, cancel_event, SessionStatus.COMPLETED)
569
+ except Exception as exc:
570
+ if cancel_event.is_set():
571
+ return
572
+ try:
573
+ self._transition(
574
+ session,
575
+ cancel_event,
576
+ SessionStatus.FAILED,
577
+ error=str(exc),
578
+ )
579
+ except Exception:
580
+ return
581
+
582
+ def _await_guidance(
583
+ self,
584
+ session: DiscussionSession,
585
+ round_index: int,
586
+ cancel_event: threading.Event,
587
+ ) -> str | None:
588
+ with self._guidance_lock:
589
+ self._guidance_text = None
590
+ self._guidance_event.clear()
591
+ self._transition(
592
+ session,
593
+ cancel_event,
594
+ SessionStatus.AWAITING_GUIDANCE,
595
+ round_index=round_index,
596
+ )
597
+ self._guidance_event.wait(GUIDANCE_TIMEOUT_SECONDS)
598
+ with self._guidance_lock:
599
+ if cancel_event.is_set():
600
+ return None
601
+ guidance = self._guidance_text
602
+ self._transition(
603
+ session,
604
+ cancel_event,
605
+ SessionStatus.ROUND2_RUNNING,
606
+ round_index=round_index,
607
+ )
608
+ return guidance
609
+
610
+ def _resolve_participants(
611
+ self,
612
+ specs: tuple[ParticipantSpec, ...],
613
+ ) -> list[_ResolvedParticipant]:
614
+ resolved: list[_ResolvedParticipant] = []
615
+ for spec in specs:
616
+ try:
617
+ adapter = self._adapter_factory(spec)
618
+ detection = adapter.detect()
619
+ except Exception as exc:
620
+ adapter = None
621
+ detection = DetectionResult(
622
+ spec.adapter_id,
623
+ False,
624
+ None,
625
+ "not_found",
626
+ str(exc),
627
+ )
628
+ resolved.append(_ResolvedParticipant(spec, adapter, detection))
629
+ return resolved
630
+
631
+ def _run_round(
632
+ self,
633
+ session: DiscussionSession,
634
+ participants: Sequence[_ResolvedParticipant],
635
+ round_index: int,
636
+ prompt_factory: Callable[[_ResolvedParticipant], str],
637
+ cancel_event: threading.Event,
638
+ ) -> list[_TurnResult]:
639
+ results: list[_TurnResult | None] = [None] * len(participants)
640
+ results_lock = threading.Lock()
641
+ semaphore = threading.Semaphore(MAX_CONCURRENT_PROCESSES)
642
+ turn_ids = [
643
+ self._begin_turn(
644
+ session,
645
+ participant.spec.id,
646
+ round_index,
647
+ (
648
+ participant.adapter.supports_token_stream
649
+ if participant.adapter is not None
650
+ else participant.spec.supports_token_stream
651
+ ),
652
+ cancel_event,
653
+ )
654
+ for participant in participants
655
+ ]
656
+
657
+ def run_one(
658
+ index: int,
659
+ participant: _ResolvedParticipant,
660
+ turn_id: str | None,
661
+ ) -> None:
662
+ with semaphore:
663
+ if cancel_event.is_set():
664
+ return
665
+ result = self._run_turn(
666
+ session,
667
+ participant,
668
+ round_index,
669
+ prompt_factory(participant),
670
+ cancel_event,
671
+ turn_id=turn_id,
672
+ )
673
+ with results_lock:
674
+ results[index] = result
675
+
676
+ threads = [
677
+ threading.Thread(
678
+ target=run_one,
679
+ args=(index, participant, turn_ids[index]),
680
+ name=f"discussion-turn-r{round_index}-{participant.spec.id}",
681
+ daemon=True,
682
+ )
683
+ for index, participant in enumerate(participants)
684
+ ]
685
+ for thread in threads:
686
+ thread.start()
687
+ for thread in threads:
688
+ thread.join()
689
+ return [result for result in results if result is not None]
690
+
691
+ def _run_turn(
692
+ self,
693
+ session: DiscussionSession,
694
+ participant: _ResolvedParticipant,
695
+ round_index: int,
696
+ prompt: str,
697
+ cancel_event: threading.Event,
698
+ *,
699
+ turn_id: str | None = None,
700
+ text_transform: Callable[[str], str] | None = None,
701
+ ) -> _TurnResult:
702
+ adapter = participant.adapter
703
+ supports_token_stream = (
704
+ adapter.supports_token_stream
705
+ if adapter is not None
706
+ else participant.spec.supports_token_stream
707
+ )
708
+ if turn_id is None:
709
+ turn_id = self._begin_turn(
710
+ session,
711
+ participant.spec.id,
712
+ round_index,
713
+ supports_token_stream,
714
+ cancel_event,
715
+ )
716
+ if turn_id is None:
717
+ return _TurnResult(participant, None, False, "", "cancelled")
718
+ if (
719
+ adapter is None
720
+ or not participant.detection.available
721
+ or participant.detection.path is None
722
+ ):
723
+ error = participant.detection.error or f"{participant.spec.adapter_id} is unavailable"
724
+ self._fail_turn(session, turn_id, error, _DeltaAccumulator(), cancel_event)
725
+ return _TurnResult(participant, turn_id, False, "", error)
726
+
727
+ result_success = False
728
+ result_error: str | None = None
729
+ for attempt in range(2):
730
+ accumulator = _DeltaAccumulator()
731
+ terminal = threading.Event()
732
+ attempt_done = False
733
+ attempt_error: str | None = None
734
+ attempt_reason: str | None = None
735
+ attempt_text: list[str] = []
736
+ final_text: str | None = None
737
+
738
+ def on_delta(
739
+ text: str,
740
+ accumulator: _DeltaAccumulator = accumulator,
741
+ attempt_text: list[str] = attempt_text,
742
+ ) -> None:
743
+ if cancel_event.is_set():
744
+ return
745
+ attempt_text.append(text)
746
+ combined = accumulator.add(text)
747
+ if combined:
748
+ self._append_delta(session, turn_id, combined, cancel_event)
749
+
750
+ def on_done(terminal: threading.Event = terminal) -> None:
751
+ nonlocal attempt_done
752
+ attempt_done = True
753
+ terminal.set()
754
+
755
+ def on_final_text(text: str) -> None:
756
+ nonlocal final_text
757
+ final_text = text
758
+ self._replace_text(session, turn_id, text, cancel_event)
759
+
760
+ def on_usage(usage: TurnUsage) -> None:
761
+ self._set_turn_usage(session, turn_id, usage, cancel_event)
762
+
763
+ def on_error(
764
+ message: str,
765
+ terminal: threading.Event = terminal,
766
+ ) -> None:
767
+ nonlocal attempt_error, attempt_reason
768
+ attempt_error = str(message)
769
+ if isinstance(message, StreamError):
770
+ attempt_reason = message.reason
771
+ terminal.set()
772
+
773
+ def on_cancelled(terminal: threading.Event = terminal) -> None:
774
+ terminal.set()
775
+
776
+ try:
777
+ # A failed agy invocation can retain its final response. Clear
778
+ # it before every attempt so a retry cannot inherit stale text.
779
+ adapter.take_final_text()
780
+ invocation = adapter.build_invocation(prompt, participant.spec.model)
781
+ run_streaming(
782
+ adapter,
783
+ invocation,
784
+ on_delta,
785
+ on_done,
786
+ on_error,
787
+ on_cancelled,
788
+ on_final_text=on_final_text,
789
+ on_usage=on_usage,
790
+ cancel_event=cancel_event,
791
+ )
792
+ except OSError as exc:
793
+ attempt_error = str(exc)
794
+ attempt_reason = StreamFailureReason.LAUNCH
795
+ terminal.set()
796
+ except Exception as exc:
797
+ attempt_error = str(exc)
798
+ terminal.set()
799
+
800
+ if cancel_event.is_set():
801
+ break
802
+ if not terminal.is_set():
803
+ attempt_error = "stream runner returned without a terminal callback"
804
+ produced_text = final_text if final_text is not None else "".join(attempt_text)
805
+ retryable = attempt_reason == StreamFailureReason.NONZERO_EXIT
806
+ if attempt_done and attempt_error is None:
807
+ if produced_text.strip():
808
+ if text_transform is not None:
809
+ self._replace_text(
810
+ session,
811
+ turn_id,
812
+ text_transform(produced_text),
813
+ cancel_event,
814
+ )
815
+ accumulator = _DeltaAccumulator()
816
+ if final_text is not None:
817
+ accumulator = _DeltaAccumulator()
818
+ result_success = self._complete_turn(
819
+ session, turn_id, accumulator, cancel_event
820
+ )
821
+ break
822
+ attempt_error = "CLI exited with empty output"
823
+ retryable = True
824
+ if retryable and attempt == 0:
825
+ self._replace_text(session, turn_id, "", cancel_event)
826
+ continue
827
+ result_error = attempt_error or "stream runner returned without a terminal callback"
828
+ self._fail_turn(session, turn_id, result_error, accumulator, cancel_event)
829
+ break
830
+ return _TurnResult(
831
+ participant,
832
+ turn_id,
833
+ result_success,
834
+ _turn_text(session, turn_id),
835
+ result_error,
836
+ )
837
+
838
+ def _begin_turn(
839
+ self,
840
+ session: DiscussionSession,
841
+ participant_id: str,
842
+ round_index: int,
843
+ supports_token_stream: bool,
844
+ cancel_event: threading.Event,
845
+ ) -> str | None:
846
+ with self._event_order_lock:
847
+ if cancel_event.is_set():
848
+ return None
849
+ turn = session.add_turn(
850
+ participant_id,
851
+ round_index,
852
+ supports_token_stream=supports_token_stream,
853
+ )
854
+ event = session.start_turn(turn.id)
855
+ self._enqueue_event_locked(event)
856
+ return turn.id
857
+
858
+ def _append_delta(
859
+ self,
860
+ session: DiscussionSession,
861
+ turn_id: str,
862
+ text: str,
863
+ cancel_event: threading.Event,
864
+ ) -> bool:
865
+ with self._event_order_lock:
866
+ if cancel_event.is_set():
867
+ return False
868
+ event = session.append_delta(turn_id, text)
869
+ self._enqueue_event_locked(event)
870
+ return True
871
+
872
+ def _replace_text(
873
+ self,
874
+ session: DiscussionSession,
875
+ turn_id: str,
876
+ text: str,
877
+ cancel_event: threading.Event,
878
+ ) -> bool:
879
+ with self._event_order_lock:
880
+ if cancel_event.is_set():
881
+ return False
882
+ event = session.replace_text(turn_id, text)
883
+ self._enqueue_event_locked(event)
884
+ return True
885
+
886
+ def _set_turn_usage(
887
+ self,
888
+ session: DiscussionSession,
889
+ turn_id: str,
890
+ usage: TurnUsage,
891
+ cancel_event: threading.Event,
892
+ ) -> bool:
893
+ with self._event_order_lock:
894
+ if cancel_event.is_set():
895
+ return False
896
+ self._enqueue_event_locked(session.set_turn_usage(turn_id, usage))
897
+ return True
898
+
899
+ def _complete_turn(
900
+ self,
901
+ session: DiscussionSession,
902
+ turn_id: str,
903
+ accumulator: _DeltaAccumulator,
904
+ cancel_event: threading.Event,
905
+ ) -> bool:
906
+ with self._event_order_lock:
907
+ if cancel_event.is_set():
908
+ return False
909
+ remaining = accumulator.flush()
910
+ if remaining:
911
+ self._enqueue_event_locked(session.append_delta(turn_id, remaining))
912
+ self._enqueue_event_locked(session.complete_turn(turn_id))
913
+ return True
914
+
915
+ def _fail_turn(
916
+ self,
917
+ session: DiscussionSession,
918
+ turn_id: str,
919
+ error: str,
920
+ accumulator: _DeltaAccumulator,
921
+ cancel_event: threading.Event,
922
+ ) -> bool:
923
+ with self._event_order_lock:
924
+ if cancel_event.is_set():
925
+ return False
926
+ remaining = accumulator.flush()
927
+ if remaining:
928
+ self._enqueue_event_locked(session.append_delta(turn_id, remaining))
929
+ self._enqueue_event_locked(session.fail_turn(turn_id, error))
930
+ return True
931
+
932
+ def _publish_consensus_count(
933
+ self,
934
+ session: DiscussionSession,
935
+ cancel_event: threading.Event,
936
+ ) -> ConsensusCount:
937
+ with self._event_order_lock:
938
+ if cancel_event.is_set():
939
+ return ConsensusCount()
940
+ event = session.count_consensus()
941
+ self._enqueue_event_locked(event)
942
+ return ConsensusCount(**event.payload)
943
+
944
+ def _mark_consensus_reached(
945
+ self,
946
+ session: DiscussionSession,
947
+ round_index: int,
948
+ cancel_event: threading.Event,
949
+ ) -> bool:
950
+ with self._event_order_lock:
951
+ if cancel_event.is_set():
952
+ return False
953
+ self._enqueue_event_locked(session.mark_consensus_reached(round_index))
954
+ return True
955
+
956
+ def _transition(
957
+ self,
958
+ session: DiscussionSession,
959
+ cancel_event: threading.Event,
960
+ status: SessionStatus,
961
+ *,
962
+ error: str | None = None,
963
+ round_index: int | None = None,
964
+ ) -> bool:
965
+ with self._event_order_lock:
966
+ if cancel_event.is_set():
967
+ return False
968
+ if status is SessionStatus.COMPLETED:
969
+ snapshot = session.snapshot()
970
+ snapshot["status"] = status.value
971
+ with self._state_lock:
972
+ snapshot["working_directory"] = self._working_directory
973
+ self._archive_completed_session(session.session_id, snapshot)
974
+ event = session.transition(status, error=error, round_index=round_index)
975
+ if event is not None:
976
+ self._enqueue_event_locked(event)
977
+ return True
978
+
979
+ def _archive_completed_session(
980
+ self,
981
+ session_id: str,
982
+ snapshot: dict[str, object],
983
+ ) -> None:
984
+ try:
985
+ # Transcripts carry full conversation text and project paths; keep them
986
+ # owner-only like the neutral workspace in discussion_cli.
987
+ DISCUSSIONS_DIRECTORY.mkdir(mode=0o700, parents=True, exist_ok=True)
988
+ base = DISCUSSIONS_DIRECTORY / session_id
989
+ for suffix, payload in (
990
+ (".json", json.dumps(snapshot, ensure_ascii=False, indent=2)),
991
+ (".md", _render_discussion_markdown(snapshot)),
992
+ ):
993
+ path = base.with_suffix(suffix)
994
+ path.write_text(payload, encoding="utf-8")
995
+ path.chmod(0o600)
996
+ except Exception:
997
+ if os.environ.get("USAGE_DEBUG") == "1":
998
+ logger.warning("failed to archive discussion %s", session_id, exc_info=True)
999
+ return
1000
+
1001
+ def _enqueue_event_locked(self, event: DiscussionEvent) -> None:
1002
+ listener: Callable[[], None] | None = None
1003
+ with self._event_lock:
1004
+ if not self._callbacks_enabled:
1005
+ return
1006
+ was_empty = not self._events
1007
+ self._events.append(event)
1008
+ if was_empty:
1009
+ listener = self._event_listener
1010
+ if listener is not None:
1011
+ try:
1012
+ listener()
1013
+ except Exception:
1014
+ return
1015
+
1016
+
1017
+ def _default_adapter_factory(spec: ParticipantSpec) -> CLIAdapter:
1018
+ if spec.source == "argv" or spec.source == "login_shell":
1019
+ return _CustomLineAdapter(spec)
1020
+ if spec.adapter_id == "claude":
1021
+ return ClaudeAdapter(
1022
+ cwd=spec.cwd,
1023
+ read_only=spec.read_only,
1024
+ extra_read_dirs=spec.extra_read_dirs,
1025
+ env_overrides=spec.env_overrides,
1026
+ timeout_seconds=spec.timeout_seconds,
1027
+ )
1028
+ if spec.adapter_id == "codex":
1029
+ return CodexAdapter(
1030
+ cwd=spec.cwd,
1031
+ read_only=spec.read_only,
1032
+ extra_read_dirs=spec.extra_read_dirs,
1033
+ env_overrides=spec.env_overrides,
1034
+ timeout_seconds=spec.timeout_seconds,
1035
+ )
1036
+ if spec.adapter_id == "agy":
1037
+ return AgyAdapter(
1038
+ cwd=spec.cwd,
1039
+ read_only=spec.read_only,
1040
+ extra_read_dirs=spec.extra_read_dirs,
1041
+ env_overrides=spec.env_overrides,
1042
+ timeout_seconds=spec.timeout_seconds,
1043
+ )
1044
+ raise ValueError(f"unknown built-in adapter: {spec.adapter_id}")
1045
+
1046
+
1047
+ def _select_moderator(
1048
+ survivors: Sequence[_TurnResult],
1049
+ moderator_id: str | None,
1050
+ ) -> _TurnResult | None:
1051
+ if moderator_id is not None:
1052
+ for survivor in survivors:
1053
+ if survivor.participant.spec.id == moderator_id:
1054
+ return survivor
1055
+ return survivors[0] if survivors else None
1056
+
1057
+
1058
+ def _turn_text(session: DiscussionSession, turn_id: str) -> str:
1059
+ snapshot = session.snapshot()
1060
+ for turn in snapshot["turns"]:
1061
+ if turn["id"] == turn_id:
1062
+ return str(turn["text"])
1063
+ return ""
1064
+
1065
+
1066
+ def _anonymous_participant_label(index: int) -> str:
1067
+ """Return a stable A, B, … label for a zero-based participant index."""
1068
+ return f"參與者 {chr(ord('A') + index)}" if index < 26 else f"參與者 {index + 1}"
1069
+
1070
+
1071
+ def _is_unanimous_consensus(
1072
+ consensus: ConsensusCount, expected_participants: int
1073
+ ) -> bool:
1074
+ return (
1075
+ consensus.agree
1076
+ + consensus.disagree
1077
+ + consensus.alternative
1078
+ + consensus.unparsed
1079
+ == expected_participants
1080
+ and consensus.agree >= 2
1081
+ and consensus.disagree == 0
1082
+ and consensus.alternative == 0
1083
+ and consensus.unparsed == 0
1084
+ )
1085
+
1086
+
1087
+ def _render_discussion_markdown(snapshot: dict[str, object]) -> str:
1088
+ participants = snapshot["participants"]
1089
+ turns = snapshot["turns"]
1090
+ assert isinstance(participants, list)
1091
+ assert isinstance(turns, list)
1092
+ total_rounds = snapshot["total_rounds"]
1093
+ assert isinstance(total_rounds, int)
1094
+ labels = {
1095
+ str(participant["id"]): str(participant["label"])
1096
+ for participant in participants
1097
+ if isinstance(participant, dict)
1098
+ }
1099
+ lines = [
1100
+ f"# {snapshot['topic']}",
1101
+ datetime.now().astimezone().isoformat(),
1102
+ "參與者:" + "、".join(labels.values()),
1103
+ ]
1104
+ for turn in turns:
1105
+ if not isinstance(turn, dict):
1106
+ continue
1107
+ text = str(turn["text"])
1108
+ participant = labels.get(str(turn["participant_id"]), str(turn["participant_id"]))
1109
+ if int(turn["round_index"]) > total_rounds:
1110
+ lines.extend(("## 主持人總結", text))
1111
+ else:
1112
+ lines.extend((f"## 第 {turn['round_index']} 輪 · {participant}", text))
1113
+ return "\n\n".join(lines) + "\n"
1114
+
1115
+
1116
+ def _build_transcript(
1117
+ session: DiscussionSession,
1118
+ anonymous_labels: Mapping[str, str],
1119
+ ) -> str:
1120
+ snapshot = session.snapshot()
1121
+ sections: list[str] = []
1122
+ for turn in snapshot["turns"]:
1123
+ participant_id = str(turn["participant_id"])
1124
+ error = turn["error"]
1125
+ body = str(turn["text"])
1126
+ if error:
1127
+ body = f"{body}\n[此發言未完成]" if body else "[此發言未完成]"
1128
+ sections.append(
1129
+ f"<<<TURN participant={anonymous_labels.get(participant_id, participant_id)!r} "
1130
+ f"round={turn['round_index']} status={turn['status']}>>>\n"
1131
+ f"{body}\n<<<TURN_END>>>"
1132
+ )
1133
+ return "\n\n".join(sections)
1134
+
1135
+
1136
+ def _restore_participant_labels(
1137
+ text: str,
1138
+ session: DiscussionSession,
1139
+ anonymous_labels: Mapping[str, str],
1140
+ ) -> str:
1141
+ labels = {
1142
+ anonymous_labels[participant.id]: participant.label
1143
+ for participant in session.participants
1144
+ }
1145
+ return _replace_labels(text, labels)
1146
+
1147
+
1148
+ def _replace_labels(
1149
+ text: str,
1150
+ replacements: Mapping[str, str],
1151
+ ) -> str:
1152
+ labels = sorted((label for label in replacements if label), key=len, reverse=True)
1153
+ if not labels:
1154
+ return text
1155
+ pattern = re.compile(
1156
+ f"(?:{'|'.join(re.escape(label) for label in labels)})(?![A-Za-z0-9])"
1157
+ )
1158
+ return pattern.sub(lambda match: replacements[match.group(0)], text)
1159
+
1160
+
1161
+ def _attachment_dirs(attachments: Sequence[str]) -> tuple[str, ...]:
1162
+ """Distinct absolute directories holding the existing attachment files.
1163
+
1164
+ Missing paths are ignored (mirrors ``build_attachment_block``); only the
1165
+ folder each surviving file lives in is returned, deduplicated and in
1166
+ stable first-seen order.
1167
+ """
1168
+ seen: set[str] = set()
1169
+ dirs: list[str] = []
1170
+ for raw in attachments:
1171
+ path = Path(str(raw))
1172
+ if not path.is_file():
1173
+ continue
1174
+ directory = str(path.resolve().parent)
1175
+ if directory not in seen:
1176
+ seen.add(directory)
1177
+ dirs.append(directory)
1178
+ return tuple(dirs)
1179
+
1180
+
1181
+ def build_attachment_block(attachments: Sequence[str], language: str) -> str:
1182
+ """Append-only image section for the prompt sent to each CLI.
1183
+
1184
+ Returns ``""`` when none of the paths point at an existing file, so the
1185
+ discussion proceeds unchanged. Existing-file paths are resolved to absolute
1186
+ form; missing paths are skipped rather than aborting the whole session.
1187
+ """
1188
+ resolved: list[str] = []
1189
+ for raw in attachments:
1190
+ path = Path(str(raw))
1191
+ if path.is_file():
1192
+ resolved.append(str(path.resolve()))
1193
+ if not resolved:
1194
+ return ""
1195
+ header = _t(language, "discussion_prompt_attachment_header")
1196
+ return "\n\n" + header + "\n" + "\n".join(resolved)