@matt82198/aesop 0.1.0-beta.5 → 0.1.0

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 (198) hide show
  1. package/CHANGELOG.md +158 -5
  2. package/LICENSE +66 -21
  3. package/README.md +97 -33
  4. package/aesop.config.example.json +6 -0
  5. package/bin/CLAUDE.md +12 -3
  6. package/bin/cli.js +211 -40
  7. package/daemons/CLAUDE.md +1 -1
  8. package/daemons/backup-fleet.sh +209 -51
  9. package/daemons/run-watchdog.sh +208 -62
  10. package/dash/dash-extra.mjs +73 -4
  11. package/dash/watchdog-gui.sh +41 -35
  12. package/docs/ARCHITECTURE.md +296 -0
  13. package/docs/CONCEPTS.md +254 -0
  14. package/docs/CONFIGURE.md +256 -0
  15. package/docs/FIRST-WAVE.md +276 -0
  16. package/docs/INSTALL.md +224 -0
  17. package/docs/README.md +176 -27
  18. package/docs/autonomous-swe.md +149 -0
  19. package/docs/reproduce.md +121 -0
  20. package/hooks/CLAUDE.md +28 -0
  21. package/hooks/install-waveguard.sh +68 -0
  22. package/hooks/pre-commit-waveguard.sh +26 -0
  23. package/hooks/pre-push-policy.sh +253 -15
  24. package/monitor/CLAUDE.md +3 -3
  25. package/monitor/collect-signals.mjs +133 -20
  26. package/package.json +15 -7
  27. package/scan/CLAUDE.md +30 -0
  28. package/skills/CLAUDE.md +1 -0
  29. package/state_store/ingest.py +18 -1
  30. package/state_store/projections.py +78 -4
  31. package/state_store/store.py +102 -7
  32. package/tools/CLAUDE.md +25 -18
  33. package/tools/alert_bridge.py +14 -10
  34. package/tools/bench_runner.py +438 -0
  35. package/tools/buildlog.py +4 -7
  36. package/tools/ci_merge_wait.py +215 -34
  37. package/tools/common.py +62 -0
  38. package/tools/cost_ceiling.py +191 -0
  39. package/tools/dash.js +102 -0
  40. package/tools/doctor.js +220 -0
  41. package/tools/fleet_ledger.py +189 -17
  42. package/tools/halt.py +172 -0
  43. package/tools/healthcheck.py +24 -21
  44. package/tools/heartbeat.py +4 -7
  45. package/tools/metrics_gate.py +8 -2
  46. package/tools/orchestrator_status.py +4 -9
  47. package/tools/reconcile.py +277 -0
  48. package/tools/rotate_logs.py +152 -62
  49. package/tools/scanner_selftest.py +28 -3
  50. package/tools/secret_scan.py +359 -90
  51. package/tools/self_stats.py +292 -4
  52. package/tools/status.js +187 -0
  53. package/tools/verify_agent_inspector.py +289 -0
  54. package/tools/verify_prboard.py +344 -0
  55. package/tools/watch.js +49 -0
  56. package/ui/CLAUDE.md +2 -0
  57. package/ui/agents.py +332 -33
  58. package/ui/api/tracker.py +15 -7
  59. package/ui/collectors.py +125 -32
  60. package/ui/config.py +22 -2
  61. package/ui/cost.py +98 -3
  62. package/ui/csrf.py +2 -1
  63. package/ui/handler.py +209 -21
  64. package/ui/sse.py +102 -16
  65. package/ui/wave_prs.py +230 -0
  66. package/ui/web/dist/assets/index-0qQYnvMC.js +9 -0
  67. package/ui/web/dist/assets/index-BdIlFieV.css +1 -0
  68. package/ui/web/dist/index.html +2 -2
  69. package/monitor/.signal-state.json +0 -3
  70. package/monitor/ACTIONS.log +0 -1
  71. package/monitor/BRIEF.md +0 -24
  72. package/monitor/SIGNALS.json +0 -54
  73. package/state_store/__pycache__/__init__.cpython-314.pyc +0 -0
  74. package/state_store/__pycache__/api.cpython-314.pyc +0 -0
  75. package/state_store/__pycache__/export.cpython-314.pyc +0 -0
  76. package/state_store/__pycache__/ingest.cpython-314.pyc +0 -0
  77. package/state_store/__pycache__/projections.cpython-314.pyc +0 -0
  78. package/state_store/__pycache__/store.cpython-314.pyc +0 -0
  79. package/tools/__pycache__/alert_bridge.cpython-314.pyc +0 -0
  80. package/tools/__pycache__/buildlog.cpython-314.pyc +0 -0
  81. package/tools/__pycache__/ci_merge_wait.cpython-314.pyc +0 -0
  82. package/tools/__pycache__/ensure_state.cpython-314.pyc +0 -0
  83. package/tools/__pycache__/eod_sweep.cpython-314.pyc +0 -0
  84. package/tools/__pycache__/fleet_ledger.cpython-314.pyc +0 -0
  85. package/tools/__pycache__/fleet_prompt_extractor.cpython-314.pyc +0 -0
  86. package/tools/__pycache__/healthcheck.cpython-314.pyc +0 -0
  87. package/tools/__pycache__/heartbeat.cpython-314.pyc +0 -0
  88. package/tools/__pycache__/inbox_drain.cpython-314.pyc +0 -0
  89. package/tools/__pycache__/launch_tui.cpython-314.pyc +0 -0
  90. package/tools/__pycache__/metrics_gate.cpython-314.pyc +0 -0
  91. package/tools/__pycache__/orchestrator_status.cpython-314.pyc +0 -0
  92. package/tools/__pycache__/power_selftest.cpython-314.pyc +0 -0
  93. package/tools/__pycache__/prepublish_scan.cpython-314.pyc +0 -0
  94. package/tools/__pycache__/rotate_logs.cpython-314.pyc +0 -0
  95. package/tools/__pycache__/scanner_selftest.cpython-314.pyc +0 -0
  96. package/tools/__pycache__/secret_scan.cpython-314.pyc +0 -0
  97. package/tools/__pycache__/self_stats.cpython-314.pyc +0 -0
  98. package/tools/__pycache__/session_usage_summary.cpython-314.pyc +0 -0
  99. package/tools/__pycache__/stall_check.cpython-314.pyc +0 -0
  100. package/tools/__pycache__/transcript_replay.cpython-314.pyc +0 -0
  101. package/tools/__pycache__/transcript_timeline.cpython-314.pyc +0 -0
  102. package/tools/__pycache__/verify_dash.cpython-314.pyc +0 -0
  103. package/tools/__pycache__/verify_submit_encoding.cpython-314.pyc +0 -0
  104. package/ui/__pycache__/agents.cpython-314.pyc +0 -0
  105. package/ui/__pycache__/collectors.cpython-314.pyc +0 -0
  106. package/ui/__pycache__/config.cpython-314.pyc +0 -0
  107. package/ui/__pycache__/cost.cpython-314.pyc +0 -0
  108. package/ui/__pycache__/csrf.cpython-314.pyc +0 -0
  109. package/ui/__pycache__/handler.cpython-314.pyc +0 -0
  110. package/ui/__pycache__/render.cpython-314.pyc +0 -0
  111. package/ui/__pycache__/serve.cpython-314.pyc +0 -0
  112. package/ui/__pycache__/sse.cpython-314.pyc +0 -0
  113. package/ui/api/__pycache__/__init__.cpython-314.pyc +0 -0
  114. package/ui/api/__pycache__/submit.cpython-314.pyc +0 -0
  115. package/ui/api/__pycache__/tracker.cpython-314.pyc +0 -0
  116. package/ui/web/.gitattributes +0 -13
  117. package/ui/web/dist/assets/index-2LZDQirC.js +0 -9
  118. package/ui/web/dist/assets/index-D4M1qyOv.css +0 -1
  119. package/ui/web/index.html +0 -13
  120. package/ui/web/package-lock.json +0 -2225
  121. package/ui/web/package.json +0 -26
  122. package/ui/web/src/App.test.tsx +0 -74
  123. package/ui/web/src/App.tsx +0 -142
  124. package/ui/web/src/CONTRIBUTING-UI.md +0 -49
  125. package/ui/web/src/components/AgentRow.css +0 -187
  126. package/ui/web/src/components/AgentRow.test.tsx +0 -209
  127. package/ui/web/src/components/AgentRow.tsx +0 -207
  128. package/ui/web/src/components/AgentsPanel.css +0 -108
  129. package/ui/web/src/components/AgentsPanel.test.tsx +0 -41
  130. package/ui/web/src/components/AgentsPanel.tsx +0 -58
  131. package/ui/web/src/components/AlertsPanel.css +0 -88
  132. package/ui/web/src/components/AlertsPanel.test.tsx +0 -51
  133. package/ui/web/src/components/AlertsPanel.tsx +0 -67
  134. package/ui/web/src/components/BacklogPanel.test.tsx +0 -126
  135. package/ui/web/src/components/BacklogPanel.tsx +0 -122
  136. package/ui/web/src/components/CostChart.css +0 -110
  137. package/ui/web/src/components/CostChart.test.tsx +0 -144
  138. package/ui/web/src/components/CostChart.tsx +0 -152
  139. package/ui/web/src/components/CostTable.css +0 -93
  140. package/ui/web/src/components/CostTable.test.tsx +0 -165
  141. package/ui/web/src/components/CostTable.tsx +0 -94
  142. package/ui/web/src/components/EventsFeed.css +0 -68
  143. package/ui/web/src/components/EventsFeed.test.tsx +0 -36
  144. package/ui/web/src/components/EventsFeed.tsx +0 -31
  145. package/ui/web/src/components/HealthHeader.css +0 -137
  146. package/ui/web/src/components/HealthHeader.test.tsx +0 -278
  147. package/ui/web/src/components/HealthHeader.tsx +0 -281
  148. package/ui/web/src/components/InboxForm.css +0 -135
  149. package/ui/web/src/components/InboxForm.test.tsx +0 -208
  150. package/ui/web/src/components/InboxForm.tsx +0 -116
  151. package/ui/web/src/components/MessagesTail.module.css +0 -144
  152. package/ui/web/src/components/MessagesTail.test.tsx +0 -176
  153. package/ui/web/src/components/MessagesTail.tsx +0 -94
  154. package/ui/web/src/components/ReposPanel.css +0 -90
  155. package/ui/web/src/components/ReposPanel.test.tsx +0 -45
  156. package/ui/web/src/components/ReposPanel.tsx +0 -67
  157. package/ui/web/src/components/Scorecard.css +0 -106
  158. package/ui/web/src/components/Scorecard.test.tsx +0 -117
  159. package/ui/web/src/components/Scorecard.tsx +0 -85
  160. package/ui/web/src/components/Timeline.module.css +0 -151
  161. package/ui/web/src/components/Timeline.test.tsx +0 -215
  162. package/ui/web/src/components/Timeline.tsx +0 -99
  163. package/ui/web/src/components/TrackerBoard.test.tsx +0 -121
  164. package/ui/web/src/components/TrackerBoard.tsx +0 -107
  165. package/ui/web/src/components/TrackerCard.test.tsx +0 -180
  166. package/ui/web/src/components/TrackerCard.tsx +0 -160
  167. package/ui/web/src/components/TrackerForm.test.tsx +0 -189
  168. package/ui/web/src/components/TrackerForm.tsx +0 -144
  169. package/ui/web/src/lib/api.ts +0 -218
  170. package/ui/web/src/lib/format.test.ts +0 -89
  171. package/ui/web/src/lib/format.ts +0 -103
  172. package/ui/web/src/lib/sanitizeUrl.test.ts +0 -84
  173. package/ui/web/src/lib/sanitizeUrl.ts +0 -38
  174. package/ui/web/src/lib/types.ts +0 -230
  175. package/ui/web/src/lib/useHashRoute.test.ts +0 -60
  176. package/ui/web/src/lib/useHashRoute.ts +0 -23
  177. package/ui/web/src/lib/useSSE.ts +0 -175
  178. package/ui/web/src/main.tsx +0 -10
  179. package/ui/web/src/styles/global.css +0 -179
  180. package/ui/web/src/styles/theme.css +0 -184
  181. package/ui/web/src/styles/work.css +0 -572
  182. package/ui/web/src/test/fixtures.ts +0 -385
  183. package/ui/web/src/test/setup.ts +0 -49
  184. package/ui/web/src/views/Activity.module.css +0 -43
  185. package/ui/web/src/views/Activity.test.tsx +0 -89
  186. package/ui/web/src/views/Activity.tsx +0 -31
  187. package/ui/web/src/views/Cost.css +0 -87
  188. package/ui/web/src/views/Cost.test.tsx +0 -142
  189. package/ui/web/src/views/Cost.tsx +0 -54
  190. package/ui/web/src/views/Overview.css +0 -51
  191. package/ui/web/src/views/Overview.test.tsx +0 -76
  192. package/ui/web/src/views/Overview.tsx +0 -46
  193. package/ui/web/src/views/Work.test.tsx +0 -82
  194. package/ui/web/src/views/Work.tsx +0 -79
  195. package/ui/web/src/vite-env.d.ts +0 -10
  196. package/ui/web/tsconfig.json +0 -22
  197. package/ui/web/vite.config.ts +0 -25
  198. package/ui/web/vitest.config.ts +0 -12
package/ui/CLAUDE.md CHANGED
@@ -14,6 +14,7 @@
14
14
  - **render.py** — Renders `ui/web/dist/index.html` (wave-14 U9 cutover: no fallback), substituting the CSRF token via a unique sentinel (the sentinel persists through Vite's build). Requires `template_path` parameter; legacy fallback removed.
15
15
  - **handler.py** — `DashboardHandler` (HTTP routing + all GET/POST endpoints incl. /api/tracker) and `run_server()`. Wave-14 additions: static serving (`GET /assets/*` from `ui/web/dist/assets/`), `/api/state` consolidated snapshot, `/api/session` for Vite dev server, `/api/cost` scorecard. Reads `config.X` / `csrf.SESSION_TOKEN` at call time.
16
16
  - **cost.py** — Parser for `state/ledger/OUTCOMES-LEDGER.md` (markdown table); returns per-model and per-day aggregates, verdict scorecards, and optional dollar estimates (only if `aesop.config.json` supplies `pricing` map).
17
+ - **wave_prs.py** — Wave PR board collector: `get_wave_prs()` gathers open PRs (`gh pr list`) + PR-less `feat/*` branches (`git for-each-ref`), rolls check runs into passing/failing/pending/none, derives the top blocker, caches ~5s. Read-only; subprocess reads use `encoding='utf-8', errors='replace'`; degrades to `{available:false, error}` when gh is missing/un-authed. `AESOP_GH_BIN` overrides the gh binary.
17
18
 
18
19
  ## State Store Integration (Wave-15)
19
20
 
@@ -90,6 +91,7 @@ Configuration is resolved in this order (first match wins):
90
91
  - `GET /api/state` — Consolidated first-paint snapshot: `{data, backlog, agents, tracker, status, cost}` in one round trip (reuses latest snapshots).
91
92
  - `GET /api/session` — Returns `{token}` for Vite dev server; Origin-checked fail-closed (only local origins).
92
93
  - `GET /api/cost` — Cost/scorecard summary from `state/ledger/OUTCOMES-LEDGER.md` (per-model, per-day, verdicts, optional pricing estimates).
94
+ - `GET /api/wave/prs` — Wave PR board: open PRs (`gh pr list`) + PR-less `feat/*` branches (`git for-each-ref`), each with CI rollup / mergeable / age / top blocker. Cached ~5s; degrades to `{available:false, error}` when gh is missing/un-authenticated (never a 500). `wave_prs.py` collector; polled by the frontend (not an SSE section — `gh` is too slow for the collector tick). Set `AESOP_GH_BIN` to override the gh binary path.
93
95
  - `GET /events` — Server-Sent Events stream (read-only, no CSRF).
94
96
 
95
97
  **Mutations (CSRF-gated)**:
package/ui/agents.py CHANGED
@@ -9,6 +9,53 @@ from pathlib import Path
9
9
  import config
10
10
 
11
11
 
12
+ def _path_is_contained(child, root):
13
+ """Check if child path is contained within root path (no traversal).
14
+
15
+ Returns True if child is under root, False if it escapes (e.g., via ..).
16
+ Uses Path.is_relative_to (Python 3.9+) with a fallback for older runtimes.
17
+
18
+ Args:
19
+ child: Path object (typically resolved)
20
+ root: Path object (typically resolved)
21
+
22
+ Returns:
23
+ bool: True if child is contained within root, False otherwise
24
+ """
25
+ try:
26
+ return child.is_relative_to(root.resolve())
27
+ except AttributeError:
28
+ # Path.is_relative_to requires Python 3.9+; fall back for older runtimes.
29
+ try:
30
+ child.relative_to(root.resolve())
31
+ return True
32
+ except ValueError:
33
+ return False
34
+
35
+
36
+ def sanitize_agents_for_broadcast(agents):
37
+ """Strip large prompt fields from agents before SSE broadcast.
38
+
39
+ Wave-19 fix: Remove promptFull, dispatch_prompt, and other multi-KB fields
40
+ from agents snapshots before sending via SSE. The React app never reads
41
+ these full prompts (it lazy-fetches them via /agent?id= endpoint), so
42
+ stripping them saves bandwidth on every tick. Keeps summary fields only.
43
+ """
44
+ sanitized = []
45
+ for agent in agents:
46
+ if not isinstance(agent, dict):
47
+ sanitized.append(agent)
48
+ continue
49
+ # Drop only the multi-KB prompt fields; keep everything else the
50
+ # frontend Agent contract reads (ui/web/src/lib/types.ts) — id,
51
+ # project, status, age_s, hint, startedAt, lastActivity,
52
+ # runtimeSeconds, tokensUsed, taskLabel.
53
+ strip_fields = {"promptFull", "dispatch_prompt"}
54
+ summary = {k: v for k, v in agent.items() if k not in strip_fields}
55
+ sanitized.append(summary)
56
+ return sanitized
57
+
58
+
12
59
  def get_fleet_agents():
13
60
  """Detect running subagents by calling dash-extra.mjs --json.
14
61
 
@@ -30,6 +77,8 @@ def get_fleet_agents():
30
77
  ["node", str(dash_extra_path), "--json"],
31
78
  capture_output=True,
32
79
  text=True,
80
+ encoding='utf-8',
81
+ errors='replace',
33
82
  timeout=5
34
83
  )
35
84
  if result.returncode == 0 and result.stdout:
@@ -53,6 +102,272 @@ def get_fleet_agents():
53
102
 
54
103
  _AGENT_ID_FORBIDDEN = re.compile(r'\.\.|[/\\*?\[\]]')
55
104
 
105
+ # Transcript-tail bounds (defense against loading a whole multi-MB transcript
106
+ # into memory and against emitting an unbounded payload to the browser).
107
+ TRANSCRIPT_TAIL_LINES = 40 # last N NDJSON lines rendered in the drawer
108
+ TRANSCRIPT_TAIL_MAX_BYTES = 256 * 1024 # only ever seek-read this much from EOF
109
+ TRANSCRIPT_TAIL_ENTRY_MAXLEN = 2000 # per-entry text cap (chars)
110
+
111
+ # Best-effort credential redaction for the transcript tail. These are the
112
+ # high-confidence, unambiguous secret FORMATS (mirroring the fatal rules in
113
+ # tools/secret_scan.py) plus bearer/authorization values. This is defense in
114
+ # depth, NOT a guarantee: a novel or bespoke credential format can still slip
115
+ # through, so the tail is a read-only convenience, never an audited-clean feed.
116
+ # tools/secret_scan.py is deliberately NOT imported (it is not on the UI import
117
+ # path and pulling it in would couple the dashboard to the push-gate tool); the
118
+ # patterns are duplicated here in a tiny, stdlib-only form.
119
+ _REDACTION_PATTERNS = [
120
+ re.compile(r'-----BEGIN[^\n]*PRIVATE KEY-----', re.IGNORECASE),
121
+ re.compile(r'AKIA[0-9A-Z]{16}'),
122
+ re.compile(r'(?:ghp_|gho_|ghu_|ghs_|ghr_|github_pat_)[A-Za-z0-9_]{20,}'),
123
+ re.compile(r'xox[baprs]-[A-Za-z0-9-]{10,}'),
124
+ re.compile(r'sk-[A-Za-z0-9_\-]{20,}'),
125
+ re.compile(r'(?i:bearer)\s+[A-Za-z0-9._\-]{20,}'),
126
+ # key/secret/password/token = <value> (quoted or bare) — mask the value.
127
+ re.compile(
128
+ r'(?i)((?:api[_-]?key|secret|password|passwd|token|authorization)'
129
+ r'\s*[:=]\s*)([^\s"\',}]{12,})'
130
+ ),
131
+ ]
132
+
133
+
134
+ def _redact_secrets(text):
135
+ """Best-effort masking of high-confidence credential formats in free text.
136
+
137
+ Returns text with any matched secret replaced by a short prefix + a
138
+ REDACTED marker (so an operator can still eyeball that *something* was
139
+ there). Never raises. See _REDACTION_PATTERNS for the (intentionally
140
+ limited) coverage and its documented limits.
141
+ """
142
+ if not text:
143
+ return text
144
+
145
+ def _mask(match):
146
+ groups = match.groups()
147
+ if len(groups) == 2:
148
+ # key/value form: keep the "key = " prefix, mask only the value.
149
+ return f"{groups[0]}***REDACTED***"
150
+ token = match.group(0)
151
+ prefix = token[:4] if len(token) > 8 else ""
152
+ return f"{prefix}***REDACTED***"
153
+
154
+ for pat in _REDACTION_PATTERNS:
155
+ try:
156
+ text = pat.sub(_mask, text)
157
+ except re.error:
158
+ continue
159
+ return text
160
+
161
+
162
+ def _resolve_transcript_path(agent_id):
163
+ """Resolve an agent id to its on-disk transcript file, path-traversal-safe.
164
+
165
+ Shared by extract_agent_dispatch_prompt() and get_agent_detail() so the
166
+ security contract (reject `.. / \\ * ? [ ]` before any glob, then verify the
167
+ resolved match stays inside config.TRANSCRIPTS_ROOT) can never drift between
168
+ the two endpoints.
169
+
170
+ Returns (Path, None) on a clean match, or (None, error_dict) otherwise.
171
+ error_dict carries "invalid": True for a rejected input (caller maps to
172
+ HTTP 400); its absence means a well-formed id that simply had no transcript
173
+ (caller maps to HTTP 404). Never raises for the expected failure modes.
174
+ """
175
+ if not agent_id or _AGENT_ID_FORBIDDEN.search(agent_id):
176
+ return None, {"error": "invalid agent id", "invalid": True}
177
+
178
+ if not config.TRANSCRIPTS_ROOT.exists():
179
+ return None, {"error": f"transcripts root not found at {config.TRANSCRIPTS_ROOT}"}
180
+
181
+ matches = sorted(
182
+ config.TRANSCRIPTS_ROOT.glob(f"**/agent-{agent_id}*.jsonl"),
183
+ key=lambda p: p.stat().st_mtime, reverse=True,
184
+ )
185
+ if not matches:
186
+ return None, {"error": f"transcript not found for {agent_id}"}
187
+
188
+ output_file = matches[0]
189
+ if not _path_is_contained(output_file.resolve(), config.TRANSCRIPTS_ROOT):
190
+ return None, {"error": "resolved path outside transcripts root", "invalid": True}
191
+
192
+ return output_file, None
193
+
194
+
195
+ def _read_tail_lines(path, max_lines=TRANSCRIPT_TAIL_LINES,
196
+ max_bytes=TRANSCRIPT_TAIL_MAX_BYTES):
197
+ """Return roughly the last `max_lines` text lines of a file, bounded.
198
+
199
+ Seeks to at most `max_bytes` from EOF so a huge transcript is never fully
200
+ read into memory. Decodes utf-8 with errors='replace' (wave-27 lesson) so
201
+ undecodable bytes never raise. When the read started mid-file the first
202
+ (probably partial) line is dropped. Returns (lines, truncated) where
203
+ truncated is True if the file was larger than the window we read.
204
+ """
205
+ try:
206
+ size = path.stat().st_size
207
+ except OSError:
208
+ return [], False
209
+ to_read = min(size, max_bytes)
210
+ try:
211
+ with open(path, 'rb') as f:
212
+ if size > to_read:
213
+ f.seek(size - to_read)
214
+ raw = f.read()
215
+ except OSError:
216
+ return [], False
217
+ text = raw.decode('utf-8', errors='replace')
218
+ lines = text.splitlines()
219
+ windowed = size > to_read
220
+ if windowed and len(lines) > 1:
221
+ lines = lines[1:] # drop the partial leading line
222
+ truncated = windowed or len(lines) > max_lines
223
+ return lines[-max_lines:], truncated
224
+
225
+
226
+ def _extract_line_text(obj):
227
+ """Pull a readable text summary out of one parsed transcript record.
228
+
229
+ Handles the shapes Claude Code emits: message.content as a plain string, or
230
+ as a list of content blocks (text / tool_use / tool_result / thinking).
231
+ Returns a plain string (never HTML); the frontend renders it as text.
232
+ """
233
+ msg = obj.get("message")
234
+ content = msg.get("content") if isinstance(msg, dict) else None
235
+ if content is None:
236
+ content = obj.get("content")
237
+
238
+ if isinstance(content, str):
239
+ return content
240
+ if isinstance(content, list):
241
+ parts = []
242
+ for block in content:
243
+ if isinstance(block, str):
244
+ parts.append(block)
245
+ elif isinstance(block, dict):
246
+ btype = block.get("type")
247
+ if btype == "text" and isinstance(block.get("text"), str):
248
+ parts.append(block["text"])
249
+ elif btype == "thinking" and isinstance(block.get("thinking"), str):
250
+ parts.append(f"[thinking] {block['thinking']}")
251
+ elif btype == "tool_use":
252
+ parts.append(f"[tool_use: {block.get('name', '?')}]")
253
+ elif btype == "tool_result":
254
+ parts.append("[tool_result]")
255
+ return "\n".join(p for p in parts if p)
256
+ return ""
257
+
258
+
259
+ def _summarize_transcript_line(raw_line):
260
+ """Turn one NDJSON line into a {"type", "text"} tail entry, or None to skip.
261
+
262
+ Robust to non-JSON / non-dict lines (returned as a "raw" entry). Text is
263
+ secret-redacted and length-capped so the payload stays bounded and XSS-safe
264
+ (plain strings only — the client escapes on render).
265
+ """
266
+ raw_line = raw_line.strip()
267
+ if not raw_line:
268
+ return None
269
+ try:
270
+ obj = json.loads(raw_line)
271
+ except (json.JSONDecodeError, ValueError):
272
+ return {"type": "raw", "text": _redact_secrets(raw_line[:TRANSCRIPT_TAIL_ENTRY_MAXLEN])}
273
+ if not isinstance(obj, dict):
274
+ return {"type": "raw", "text": _redact_secrets(str(obj)[:TRANSCRIPT_TAIL_ENTRY_MAXLEN])}
275
+ entry_type = str(obj.get("type", "unknown"))
276
+ text = _extract_line_text(obj)
277
+ text = _redact_secrets(text[:TRANSCRIPT_TAIL_ENTRY_MAXLEN])
278
+ return {"type": entry_type, "text": text}
279
+
280
+
281
+ def get_agent_detail(agent_id, tail_lines=TRANSCRIPT_TAIL_LINES):
282
+ """Agent detail for the inspector drawer: dispatch prompt + bounded tail.
283
+
284
+ Read-only. Reuses _resolve_transcript_path() for the same path-traversal /
285
+ glob-injection protection as extract_agent_dispatch_prompt(), then reads
286
+ ONLY the last ~tail_lines lines of the transcript (never the whole file) and
287
+ returns them as a list of {type, text} entries with best-effort credential
288
+ redaction applied.
289
+
290
+ Success shape:
291
+ {id, dispatch_prompt, dispatcher, model, message_count, first_seen,
292
+ last_activity, transcript_tail: [{type, text}, ...], tail_truncated}
293
+
294
+ Error shape (same convention as extract_agent_dispatch_prompt):
295
+ {"error": str} -> caller returns 404
296
+ {"error": str, "invalid": True} -> caller returns 400
297
+ Never raises: an unexpected failure degrades to a generic error dict.
298
+ """
299
+ try:
300
+ output_file, err = _resolve_transcript_path(agent_id)
301
+ if err is not None:
302
+ return err
303
+
304
+ stat = output_file.stat()
305
+ first_seen = int(stat.st_mtime)
306
+ last_activity = int(stat.st_mtime)
307
+
308
+ dispatch_prompt = None
309
+ model = None
310
+ parent_uuid = None
311
+ message_count = 0
312
+
313
+ # Header parse: first line = dispatch prompt; scan a few for the model.
314
+ # Bounded read of just the head lines (the tail is read separately).
315
+ try:
316
+ with open(output_file, 'r', encoding='utf-8', errors='replace') as f:
317
+ head = []
318
+ for i, line in enumerate(f):
319
+ if i < 20:
320
+ head.append(line)
321
+ message_count += 1
322
+ if head:
323
+ try:
324
+ first = json.loads(head[0])
325
+ if first.get('type') == 'user':
326
+ msg = first.get('message', {})
327
+ dispatch_prompt = msg.get('content', '')
328
+ parent_uuid = first.get('parentUuid')
329
+ except (json.JSONDecodeError, KeyError):
330
+ pass
331
+ for line in head[1:]:
332
+ try:
333
+ obj = json.loads(line)
334
+ if obj.get('type') == 'assistant' and not model and 'model' in obj:
335
+ model = obj.get('model')
336
+ except (json.JSONDecodeError, KeyError):
337
+ pass
338
+ except OSError:
339
+ pass
340
+
341
+ # dispatch_prompt may be a content-block list; normalise to a string.
342
+ if isinstance(dispatch_prompt, list):
343
+ dispatch_prompt = _extract_line_text({"content": dispatch_prompt})
344
+ dispatch_prompt = _redact_secrets(dispatch_prompt or "")
345
+
346
+ tail_raw, truncated = _read_tail_lines(output_file, max_lines=tail_lines)
347
+ transcript_tail = []
348
+ for line in tail_raw:
349
+ entry = _summarize_transcript_line(line)
350
+ if entry is not None:
351
+ transcript_tail.append(entry)
352
+
353
+ dispatcher = "main thread" if parent_uuid is None else "parent agent"
354
+
355
+ return {
356
+ "id": agent_id,
357
+ "dispatch_prompt": dispatch_prompt,
358
+ "dispatcher": dispatcher,
359
+ "model": model or "unknown",
360
+ "message_count": message_count,
361
+ "first_seen": first_seen,
362
+ "last_activity": last_activity,
363
+ "transcript_tail": transcript_tail,
364
+ "tail_truncated": truncated,
365
+ }
366
+ except Exception as e:
367
+ print(f"[get_agent_detail] Uncaught exception: {e}", file=sys.stderr)
368
+ return {"error": "Failed to load agent detail"}
369
+
370
+
56
371
  def extract_agent_dispatch_prompt(agent_id):
57
372
  """
58
373
  Extract dispatch prompt and metadata from agent jsonl transcript.
@@ -70,37 +385,12 @@ def extract_agent_dispatch_prompt(agent_id):
70
385
  the same file format that the dashboard actually finds.
71
386
  """
72
387
  try:
73
- if not agent_id or _AGENT_ID_FORBIDDEN.search(agent_id):
74
- return {"error": "invalid agent id", "invalid": True}
75
-
76
- # Prefix-match: search in config.TRANSCRIPTS_ROOT for files matching agent-agent_id*.jsonl
77
- # The dashboard (dash-extra.mjs) scans for agent-*.jsonl files and emits
78
- # a 13-char truncated ID; we must match against the same .jsonl files.
79
- if not config.TRANSCRIPTS_ROOT.exists():
80
- return {"error": f"transcripts root not found at {config.TRANSCRIPTS_ROOT}"}
81
-
82
- # Glob for matching files (prefix-match handles truncated IDs)
83
- matches = sorted(
84
- config.TRANSCRIPTS_ROOT.glob(f"**/agent-{agent_id}*.jsonl"),
85
- key=lambda p: p.stat().st_mtime, reverse=True,
86
- )
87
- if not matches:
88
- return {"error": f"transcript not found for {agent_id}"}
89
- output_file = matches[0]
90
-
91
- # Containment check: the resolved match must stay inside config.TRANSCRIPTS_ROOT.
92
- # Belt-and-suspenders alongside the input rejection above.
93
- try:
94
- is_contained = output_file.resolve().is_relative_to(config.TRANSCRIPTS_ROOT.resolve())
95
- except AttributeError:
96
- # Path.is_relative_to requires Python 3.9+; fall back for older runtimes.
97
- try:
98
- output_file.resolve().relative_to(config.TRANSCRIPTS_ROOT.resolve())
99
- is_contained = True
100
- except ValueError:
101
- is_contained = False
102
- if not is_contained:
103
- return {"error": "resolved path outside transcripts root", "invalid": True}
388
+ # Path-traversal / glob-injection protection + prefix-match resolution
389
+ # live in the shared _resolve_transcript_path() so this endpoint and the
390
+ # inspector's get_agent_detail() can never drift apart on security.
391
+ output_file, err = _resolve_transcript_path(agent_id)
392
+ if err is not None:
393
+ return err
104
394
 
105
395
  dispatch_prompt = None
106
396
  message_count = 0
@@ -110,7 +400,7 @@ def extract_agent_dispatch_prompt(agent_id):
110
400
  last_activity = None
111
401
 
112
402
  # Parse NDJSON (one JSON per line)
113
- with open(output_file, 'r', encoding='utf-8', errors='ignore') as f:
403
+ with open(output_file, 'r', encoding='utf-8', errors='replace') as f:
114
404
  lines = f.readlines()
115
405
  message_count = len(lines)
116
406
 
@@ -143,6 +433,14 @@ def extract_agent_dispatch_prompt(agent_id):
143
433
  if not dispatch_prompt:
144
434
  return {"error": f"no dispatch prompt found"}
145
435
 
436
+ # dispatch_prompt may be a content-block list; normalise to a string,
437
+ # then redact credentials before this leaves the process (same
438
+ # contract as get_agent_detail() — wave-32 fix, this endpoint had
439
+ # drifted and was returning secrets unredacted).
440
+ if isinstance(dispatch_prompt, list):
441
+ dispatch_prompt = _extract_line_text({"content": dispatch_prompt})
442
+ dispatch_prompt = _redact_secrets(dispatch_prompt or "")
443
+
146
444
  # Infer dispatcher: if parentUuid is null, it's main thread; otherwise parent agent
147
445
  dispatcher = "main thread" if parent_uuid is None else "parent agent"
148
446
 
@@ -156,7 +454,8 @@ def extract_agent_dispatch_prompt(agent_id):
156
454
  "last_activity": last_activity,
157
455
  }
158
456
  except Exception as e:
159
- return {"error": str(e)}
457
+ print(f"[extract_agent_dispatch_prompt] Uncaught exception: {e}", file=sys.stderr)
458
+ return {"error": "Failed to extract dispatch prompt"}
160
459
 
161
460
  def _transcripts_fingerprint():
162
461
  """Cheap fs-stat-only fingerprint of the transcripts tree.
package/ui/api/tracker.py CHANGED
@@ -33,7 +33,9 @@ def list_items(status=None, priority=None):
33
33
  items = get_tracker_items(status=status, priority=priority)
34
34
  return 200, items
35
35
  except Exception as e:
36
- return 500, {"error": str(e)}
36
+ import sys
37
+ print(f"[tracker.list_items] Uncaught exception: {e}", file=sys.stderr)
38
+ return 500, {"error": "Internal server error"}
37
39
 
38
40
 
39
41
  def create(headers, body_bytes):
@@ -59,7 +61,9 @@ def create(headers, body_bytes):
59
61
  except json.JSONDecodeError:
60
62
  return 400, {"error": "Invalid JSON"}
61
63
  except Exception as e:
62
- return 500, {"error": str(e)}
64
+ import sys
65
+ print(f"[tracker.create] Uncaught exception: {e}", file=sys.stderr)
66
+ return 500, {"error": "Internal server error"}
63
67
 
64
68
 
65
69
  def update(item_id, body_bytes):
@@ -83,14 +87,16 @@ def update(item_id, body_bytes):
83
87
  preserves the pre-split handler's behavior).
84
88
  """
85
89
  try:
86
- body_str = (body_bytes or b'').decode('utf-8', errors='ignore')
90
+ body_str = (body_bytes or b'').decode('utf-8', errors='replace')
87
91
  update_data = json.loads(body_str)
88
92
  item = update_tracker_item(item_id, update_data)
89
93
  return 200, item
90
94
  except Exception as e:
95
+ import sys
91
96
  if "404" in str(e):
92
- return 404, {"error": str(e)}
93
- return 500, {"error": str(e)}
97
+ return 404, {"error": "404 Item not found"}
98
+ print(f"[tracker.update] Uncaught exception: {e}", file=sys.stderr)
99
+ return 500, {"error": "Internal server error"}
94
100
 
95
101
 
96
102
  def delete(item_id):
@@ -111,6 +117,8 @@ def delete(item_id):
111
117
  item = delete_tracker_item(item_id)
112
118
  return 200, item
113
119
  except Exception as e:
120
+ import sys
114
121
  if "404" in str(e):
115
- return 404, {"error": str(e)}
116
- return 500, {"error": str(e)}
122
+ return 404, {"error": "404 Item not found"}
123
+ print(f"[tracker.delete] Uncaught exception: {e}", file=sys.stderr)
124
+ return 500, {"error": "Internal server error"}
package/ui/collectors.py CHANGED
@@ -233,7 +233,7 @@ def get_main_thread_messages():
233
233
  return messages
234
234
 
235
235
  newest = jsonl_files[0]
236
- with open(newest, 'r', encoding='utf-8', errors='ignore') as f:
236
+ with open(newest, 'r', encoding='utf-8', errors='replace') as f:
237
237
  lines = f.readlines()
238
238
  # Get last 30 lines to extract ~12 message turns
239
239
  for line in lines[-30:]:
@@ -639,6 +639,85 @@ def _snapshot_orchestrator_status():
639
639
  print(f"[status] Snapshot error: {e}", file=sys.stderr)
640
640
  return {"orchestrators": []}
641
641
 
642
+ def _recover_stranded_inbox_files():
643
+ """Defect 3 fix: Recovery sweep for .tracker-inbox.jsonl.processing-* files.
644
+
645
+ If drain_tracker_inbox crashes mid-process, it leaves a .processing-* file.
646
+ This sweep re-ingests those files on the next drain call, preventing silent
647
+ data loss. Returns list of items recovered.
648
+ """
649
+ recovered = []
650
+ try:
651
+ for processing_file in sorted(config.STATE_DIR.glob(".tracker-inbox.jsonl.processing-*")):
652
+ if not processing_file.exists():
653
+ continue
654
+ try:
655
+ content = processing_file.read_text(encoding='utf-8')
656
+ if not content.strip():
657
+ try:
658
+ processing_file.unlink()
659
+ except Exception:
660
+ pass
661
+ continue
662
+
663
+ # Build dedup hash set from both tracker.json and event store
664
+ existing_hashes = set()
665
+
666
+ # Check rendered tracker.json
667
+ tracker_json = load_tracker()
668
+ for item in tracker_json.get("items", []):
669
+ source = item.get("source", "")
670
+ title = item.get("title", "")
671
+ h = hashlib.sha256((source + ":" + title).encode()).hexdigest()
672
+ existing_hashes.add(h)
673
+
674
+ # Check event store projection
675
+ try:
676
+ api = _tracker_api()
677
+ projected = api.project("tracker")
678
+ for item in projected.get("items", []):
679
+ source = item.get("source", "")
680
+ title = item.get("title", "")
681
+ h = hashlib.sha256((source + ":" + title).encode()).hexdigest()
682
+ existing_hashes.add(h)
683
+ except Exception as e:
684
+ print(f"[inbox] Recovery: failed to project tracker state: {e}", file=sys.stderr)
685
+
686
+ # Reprocess lines from the recovered file
687
+ lines = content.strip().splitlines()
688
+ for line in lines:
689
+ line = line.strip()
690
+ if not line:
691
+ continue
692
+ try:
693
+ entry = json.loads(line)
694
+ if not isinstance(entry, dict):
695
+ continue
696
+ source = entry.get("source", "")
697
+ title = entry.get("title", "")
698
+ h = hashlib.sha256((source + ":" + title).encode()).hexdigest()
699
+
700
+ if h not in existing_hashes:
701
+ item = create_tracker_item(entry)
702
+ recovered.append(item)
703
+ existing_hashes.add(h)
704
+ except (json.JSONDecodeError, Exception):
705
+ pass
706
+
707
+ # Only delete after successful re-ingest
708
+ try:
709
+ processing_file.unlink()
710
+ except Exception as e:
711
+ print(f"[inbox] Recovery: failed to unlink {processing_file}: {e}", file=sys.stderr)
712
+
713
+ except Exception as e:
714
+ print(f"[inbox] Recovery sweep error on {processing_file}: {e}", file=sys.stderr)
715
+ except Exception as e:
716
+ print(f"[inbox] Recovery sweep failed: {e}", file=sys.stderr)
717
+
718
+ return recovered
719
+
720
+
642
721
  def drain_tracker_inbox():
643
722
  """Drain .tracker-inbox.jsonl, create items idempotently.
644
723
 
@@ -646,31 +725,40 @@ def drain_tracker_inbox():
646
725
  to ensure only one caller processes it under concurrent access. Strengthen
647
726
  dedup to check both tracker.json AND api.project("tracker") so items
648
727
  in the event store but not yet rendered are also excluded.
728
+
729
+ Defect 3 fix: Before processing the current inbox, perform a recovery sweep
730
+ to re-ingest leftover .tracker-inbox.jsonl.processing-* files from any
731
+ previous crashes. This prevents silent data loss if a crash happens
732
+ mid-drain: stranded files are recovered on the next drain call.
649
733
  """
650
734
  inbox_file = config.STATE_DIR / ".tracker-inbox.jsonl"
735
+
736
+ # Defect 3: Recovery sweep for stranded .processing-* files from prior crashes.
737
+ created = _recover_stranded_inbox_files()
738
+
739
+ # Now process the current inbox if it exists
651
740
  if not inbox_file.exists():
652
- return []
741
+ return created
653
742
 
654
- created = []
743
+ # Defect 1: Atomically rename inbox to unique processing name first.
744
+ # This ensures only one caller wins; others see no file.
745
+ processing_file = inbox_file.with_name(
746
+ f".tracker-inbox.jsonl.processing-{secrets.token_hex(8)}"
747
+ )
655
748
  try:
656
- # Defect 1: Atomically rename inbox to unique processing name first.
657
- # This ensures only one caller wins; others see no file.
658
- processing_file = inbox_file.with_name(
659
- f".tracker-inbox.jsonl.processing-{secrets.token_hex(8)}"
660
- )
661
- try:
662
- os.replace(str(inbox_file), str(processing_file))
663
- except FileNotFoundError:
664
- # Another caller already renamed it; nothing to process
665
- return []
666
- except Exception as e:
667
- print(f"[inbox] Failed to rename inbox: {e}", file=sys.stderr)
668
- return []
749
+ os.replace(str(inbox_file), str(processing_file))
750
+ except FileNotFoundError:
751
+ # Another caller already renamed it; nothing to process
752
+ return created
753
+ except Exception as e:
754
+ print(f"[inbox] Failed to rename inbox: {e}", file=sys.stderr)
755
+ return created
669
756
 
757
+ try:
670
758
  content = processing_file.read_text(encoding='utf-8')
671
759
  if not content.strip():
672
760
  processing_file.unlink()
673
- return []
761
+ return created
674
762
 
675
763
  lines = content.strip().splitlines()
676
764
 
@@ -707,29 +795,34 @@ def drain_tracker_inbox():
707
795
  continue
708
796
  try:
709
797
  entry = json.loads(line)
710
- if not isinstance(entry, dict):
711
- rejects.append(line)
712
- continue
713
-
714
- source = entry.get("source", "")
715
- title = entry.get("title", "")
716
- h = hashlib.sha256((source + ":" + title).encode()).hexdigest()
717
-
718
- if h not in existing_hashes:
719
- item = create_tracker_item(entry)
720
- created.append(item)
721
- existing_hashes.add(h)
722
798
  except json.JSONDecodeError:
723
799
  rejects.append(line)
724
- except Exception as e:
725
- rejects.append(line + " # " + str(e))
800
+ continue
801
+
802
+ if not isinstance(entry, dict):
803
+ rejects.append(line)
804
+ continue
805
+
806
+ source = entry.get("source", "")
807
+ title = entry.get("title", "")
808
+ h = hashlib.sha256((source + ":" + title).encode()).hexdigest()
809
+
810
+ if h not in existing_hashes:
811
+ # create_tracker_item can raise real errors (not malformed JSON)
812
+ # let those bubble up rather than silently adding to rejects
813
+ item = create_tracker_item(entry)
814
+ created.append(item)
815
+ existing_hashes.add(h)
726
816
 
727
817
  if rejects:
728
818
  rejects_file = inbox_file.with_name(".tracker-inbox.rejects")
729
819
  rejects_file.write_text("\n".join(rejects) + "\n", encoding='utf-8')
730
820
 
821
+ # Only delete the processing file after successful completion.
822
+ # If an exception occurs, the file is left behind for recovery.
731
823
  processing_file.unlink()
732
824
  except Exception as e:
733
- print(f"[inbox] Drain error: {e}", file=sys.stderr)
825
+ print(f"[inbox] Drain error: {e}; processing file {processing_file.name} left for recovery", file=sys.stderr)
826
+ # Don't delete the file on error; let recovery sweep handle it next time
734
827
 
735
828
  return created