@50arms/session-telemetry 0.0.0-stage → 0.1.66
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.
- package/README.md +68 -2
- package/dist/.source-hash +1 -0
- package/dist/activity-observer.d.ts +186 -0
- package/dist/activity-observer.js +917 -0
- package/dist/api-tool-names.d.ts +12 -0
- package/dist/api-tool-names.js +19 -0
- package/dist/browser.d.ts +29 -0
- package/dist/browser.js +10 -0
- package/dist/catalog-prices.d.ts +27 -0
- package/dist/catalog-prices.js +31 -0
- package/dist/claim-tracker.d.ts +53 -0
- package/dist/claim-tracker.js +213 -0
- package/dist/dispatch-usage.d.ts +321 -0
- package/dist/dispatch-usage.js +657 -0
- package/dist/git-head.d.ts +1 -0
- package/dist/git-head.js +19 -0
- package/dist/index.d.ts +35 -0
- package/dist/index.js +49 -0
- package/dist/internal-logger.d.ts +16 -0
- package/dist/internal-logger.js +27 -0
- package/dist/ledger-timing.d.ts +26 -0
- package/dist/ledger-timing.js +31 -0
- package/dist/pricing.d.ts +93 -0
- package/dist/pricing.js +77 -0
- package/dist/real-work.d.ts +13 -0
- package/dist/real-work.js +57 -0
- package/dist/session-log-watcher.d.ts +616 -0
- package/dist/session-log-watcher.js +1285 -0
- package/dist/session-title-marks.d.ts +2 -0
- package/dist/session-title-marks.js +16 -0
- package/dist/stats-wire.d.ts +345 -0
- package/dist/stats-wire.js +636 -0
- package/dist/subagent-live.d.ts +36 -0
- package/dist/subagent-live.js +153 -0
- package/dist/tool-summary.d.ts +19 -0
- package/dist/tool-summary.js +58 -0
- package/dist/transcript-timing.d.ts +100 -0
- package/dist/transcript-timing.js +231 -0
- package/dist/types.d.ts +25 -0
- package/dist/types.js +1 -0
- package/dist/usage-ledger.d.ts +256 -0
- package/dist/usage-ledger.js +581 -0
- package/dist/write-queue.d.ts +9 -0
- package/dist/write-queue.js +16 -0
- package/package.json +43 -4
|
@@ -0,0 +1,917 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* DX-2954 — the TRANSCRIPT-side producer of a dispatch's running set.
|
|
3
|
+
*
|
|
4
|
+
* `dispatch_activities` answers "what is this dispatch running RIGHT NOW" — one
|
|
5
|
+
* row per sub-agent or background script, with `finished_at` NULL while it is
|
|
6
|
+
* still going. This module produces the sub-agent half of that set from the one
|
|
7
|
+
* signal danxbot owns end-to-end: the session-log watcher discovering a
|
|
8
|
+
* sub-agent's transcript file and then tailing it.
|
|
9
|
+
*
|
|
10
|
+
* ## Why the transcript, and where the hooks come in
|
|
11
|
+
*
|
|
12
|
+
* Claude Code's `SubagentStart` / `SubagentStop` hooks carry an `agent_type`
|
|
13
|
+
* before the sub-agent has written anything and a FINISH for every sub-agent,
|
|
14
|
+
* foreground or background. They ship from the 50arms plugin in the
|
|
15
|
+
* claude-plugins repo (DX-3284; DX-4235 moved them into the plugin's hooks module), which calls
|
|
16
|
+
* `50arms_api`, and reach the server
|
|
17
|
+
* through `POST /api/plan-sessions/me/activity` — which writes the SAME
|
|
18
|
+
* `dispatch_activities` rows through the same one server writer.
|
|
19
|
+
*
|
|
20
|
+
* The transcript stays the producer because it needs no hook to be installed: a
|
|
21
|
+
* session with no plugin, and every WORKER dispatch, still gets a row for each
|
|
22
|
+
* sub-agent from here. The hooks are an ENRICHMENT layered on the same rows:
|
|
23
|
+
*
|
|
24
|
+
* | Signal | Sub-agent start | Sub-agent finish | Background script |
|
|
25
|
+
* |---|---|---|---|
|
|
26
|
+
* | Transcript (here) | file discovered | a foreground sub-agent's own `tool_result` in the spawner's transcript (DX-5576); a background sub-agent's task notification (DX-4498); else the dispatch's terminal write (`finishOpenActivities`) as the fallback | not visible |
|
|
27
|
+
* | Hooks (DX-3284, operator sessions) | `SubagentStart` | `SubagentStop` | `PostToolUse` start; end at dispatch terminal (no documented completion event) |
|
|
28
|
+
*
|
|
29
|
+
* ## What it reports about each sub-agent (DX-4498)
|
|
30
|
+
*
|
|
31
|
+
* All of it read from what Claude Code itself writes, with no cooperation from
|
|
32
|
+
* the sub-agent:
|
|
33
|
+
*
|
|
34
|
+
* | Field | Read from |
|
|
35
|
+
* |---|---|
|
|
36
|
+
* | `agentType`, `description`, `parentActivityId` | the sub-agent's `meta.json` sidecar (via `SourceDiscoveredInfo`); the description through {@link redactLabel} |
|
|
37
|
+
* | `lastActivityAt` | the timestamp of its latest `assistant` / `user` line |
|
|
38
|
+
* | `effort` | its latest `assistant` line's top-level `effort` |
|
|
39
|
+
* | `currentActivity` | its latest `tool_use` block, through {@link describeToolCall} |
|
|
40
|
+
* | `briefProfile` | DX-5898: the leading word of its brief (the root `user` line) when shaped like a profile name, else null; the server matches it against the team's profiles |
|
|
41
|
+
* | `briefIssueId` | the card its spawning `Agent` call names: the first card id in its `description` (redacted), else the first anywhere in its brief (the root `user` line) — see {@link firstIssueId} (DX-4508) |
|
|
42
|
+
* | `finishedAt`, `endStatus` | what Claude Code writes into the SPAWNER's transcript: a foreground spawn's `tool_result` (DX-5576) or a background spawn's task notification (DX-4498) |
|
|
43
|
+
*
|
|
44
|
+
* Model, tokens, cost, tool-call count and the claimed card are NOT here: the
|
|
45
|
+
* usage ledger already records them per reply, keyed by the same source, and
|
|
46
|
+
* the read derives them from it (two places recording one number is how they
|
|
47
|
+
* drift).
|
|
48
|
+
*
|
|
49
|
+
* ## Why the two producers cannot double-count
|
|
50
|
+
*
|
|
51
|
+
* Every row is an upsert keyed `(dispatch_id, kind, activity_id)`, and BOTH
|
|
52
|
+
* producers derive `activity_id` from the same thing: the sub-agent's transcript
|
|
53
|
+
* file basename, `agent-<agent_id>`. The watcher has it directly; a hook has the
|
|
54
|
+
* `agent_id` and builds the same basename from it: `agent-` plus the id with one leading `agent-` stripped (the hooks docs
|
|
55
|
+
* show the prefix on some `agent_id`s), so both forms reach this key. That rule lives in the 50arms plugin's hooks module
|
|
56
|
+
* (claude-plugins repo). A hook's `transcript_path` is NOT the
|
|
57
|
+
* sub-agent's file — on these events it is the MAIN session's transcript, and only
|
|
58
|
+
* `SubagentStop` also carries the sub-agent's own as `agent_transcript_path`,
|
|
59
|
+
* which the plugin uses to refuse a key that disagrees. That basename is also
|
|
60
|
+
* the key the ledger credits the sub-agent's tokens under, which is how
|
|
61
|
+
* per-sub-agent tokens join to the running set without this table carrying a
|
|
62
|
+
* token column. A task notification names the sub-agent by its bare id
|
|
63
|
+
* (`<task-id>`), which is mapped onto the same `agent-<id>` key.
|
|
64
|
+
*
|
|
65
|
+
* ## A finish is reported only when Claude Code says so
|
|
66
|
+
*
|
|
67
|
+
* A sub-agent's OWN transcript gives no "this sub-agent is done" signal — a
|
|
68
|
+
* finished sub-agent's file simply stops growing, which is indistinguishable
|
|
69
|
+
* from a slow one. Guessing a finish from an idle gap would mark a
|
|
70
|
+
* long-thinking sub-agent as complete, so this module never does. The finishes it
|
|
71
|
+
* reports are EXPLICIT, and a row ends one of three ways:
|
|
72
|
+
*
|
|
73
|
+
* 1. A FOREGROUND spawn's `tool_result` in the spawner's transcript (DX-5576), written when the sub-agent is done: its status is
|
|
74
|
+
* `completed`, `failed` for an errored result, `stopped` for an interrupted or rejected one ({@link foregroundEndStatus}).
|
|
75
|
+
* 2. A BACKGROUND spawn's `<task-notification>` (DX-4498), which Claude Code delivers to the spawner when the task ends, carrying
|
|
76
|
+
* its `<status>` (`completed`, `failed`, `killed`, `stopped`). A foreground spawn gets no notification.
|
|
77
|
+
* 3. The fallback, for a row neither signal reached: the `SubagentStop` hook (operator sessions) or the dispatch's own terminal
|
|
78
|
+
* write, which sets `finishedAt` but names no status.
|
|
79
|
+
*
|
|
80
|
+
* Whichever lands first sets `finishedAt`; a later signal only fills the status, unless it is a later end (DX-6815: a sub-agent resumed after its finish ends again), which replaces the finish.
|
|
81
|
+
*/
|
|
82
|
+
import { ACTIVITY_MESSAGE_MAX_CHARS, CURRENT_ACTIVITY_MAX_CHARS, GIT_OBJECT_ID, PROFILE_WORD_MAX_LENGTH, } from "./stats-wire.js";
|
|
83
|
+
import { DASHBOARD_API_TOOL_NAMES, ISSUE_ID_MAX_LENGTH, ISSUE_ID_PATTERN } from "./claim-tracker.js";
|
|
84
|
+
import { SUBAGENT_TOOL_NAMES } from "./real-work.js";
|
|
85
|
+
/** The prefix a sub-agent's source key (its transcript basename) carries before its bare agent id. */
|
|
86
|
+
const SUBAGENT_SOURCE_PREFIX = "agent-";
|
|
87
|
+
/**
|
|
88
|
+
* DX-4498 — how many task notifications for a source not yet discovered are held. A notification normally names a
|
|
89
|
+
* sub-agent whose file was discovered long before it ended; one that arrives first waits here. Background SHELL tasks
|
|
90
|
+
* notify the same way and are never discovered, so the map is bounded: the oldest entry is dropped beyond this.
|
|
91
|
+
*/
|
|
92
|
+
const MAX_PENDING_ENDS = 500;
|
|
93
|
+
/** DX-5333 - how many entries each map of spawn calls, spawn results and reported ends keeps; the oldest is dropped beyond this. */
|
|
94
|
+
const MAX_PENDING_SPAWNS = 500;
|
|
95
|
+
/** Insert `key` as the newest entry (a repeated key is renewed, not left in its old place) and drop the oldest beyond `cap`. */
|
|
96
|
+
function setCapped(map, key, value, cap) {
|
|
97
|
+
map.delete(key);
|
|
98
|
+
map.set(key, value);
|
|
99
|
+
if (map.size > cap)
|
|
100
|
+
map.delete(map.keys().next().value);
|
|
101
|
+
}
|
|
102
|
+
/** DX-4498 — Claude Code's notification statuses, as the end status the running set stores. */
|
|
103
|
+
const NOTIFICATION_END_STATUS = {
|
|
104
|
+
completed: "completed",
|
|
105
|
+
failed: "failed",
|
|
106
|
+
killed: "stopped",
|
|
107
|
+
stopped: "stopped",
|
|
108
|
+
};
|
|
109
|
+
/**
|
|
110
|
+
* DX-5576 - the texts Claude Code writes as an errored `tool_result` when the user stops a call, anchored to the start so a real error that
|
|
111
|
+
* merely quotes them stays `failed`: `[Request interrupted by user for tool use]` (an interrupt) and `The user doesn't want to proceed with this
|
|
112
|
+
* tool use. The tool use was rejected` / `The user doesn't want to take this action right now. STOP what you are doing...` (a rejection),
|
|
113
|
+
* the last read from a real local transcript.
|
|
114
|
+
*/
|
|
115
|
+
const FOREGROUND_STOPPED = /^\s*(?:\[Request interrupted by user|The user doesn't want to (?:proceed|take this action))/i;
|
|
116
|
+
const NOTIFICATION = /^\s*<task-notification>\s*<task-id>([^<]+)<\/task-id>[\s\S]*?<status>([^<]+)<\/status>/;
|
|
117
|
+
/**
|
|
118
|
+
* DX-4498 — a task notification's `(task id, end status)`, or null for a line that is not one, or one whose status is
|
|
119
|
+
* not an end (`running`). Only the two delivery shapes Claude Code itself writes are read: a `user` line whose
|
|
120
|
+
* `origin.kind` is `task-notification`, and a `queued_command` attachment with that `commandMode`. A sub-agent's
|
|
121
|
+
* reply that merely QUOTES a notification carries neither marker, so it can never end anything.
|
|
122
|
+
*/
|
|
123
|
+
export function parseTaskNotification(raw) {
|
|
124
|
+
let text;
|
|
125
|
+
if (raw.type === "user") {
|
|
126
|
+
const origin = raw.origin;
|
|
127
|
+
if (origin?.kind !== "task-notification")
|
|
128
|
+
return null;
|
|
129
|
+
text = raw.message?.content;
|
|
130
|
+
}
|
|
131
|
+
else if (raw.type === "attachment") {
|
|
132
|
+
const attachment = raw.attachment;
|
|
133
|
+
if (attachment?.type !== "queued_command" || attachment.commandMode !== "task-notification")
|
|
134
|
+
return null;
|
|
135
|
+
text = attachment.prompt;
|
|
136
|
+
}
|
|
137
|
+
else {
|
|
138
|
+
return null;
|
|
139
|
+
}
|
|
140
|
+
if (typeof text !== "string")
|
|
141
|
+
return null;
|
|
142
|
+
const match = NOTIFICATION.exec(text);
|
|
143
|
+
if (match === null)
|
|
144
|
+
return null;
|
|
145
|
+
const endStatus = NOTIFICATION_END_STATUS[match[2].trim()];
|
|
146
|
+
return endStatus === undefined ? null : { taskId: match[1].trim(), endStatus };
|
|
147
|
+
}
|
|
148
|
+
/** DX-4498 — what {@link redactLabel} puts in place of a path-shaped token. */
|
|
149
|
+
const REDACTED_PATH = "[path]";
|
|
150
|
+
/** DX-4498 — what {@link redactLabel} puts in place of a secret-shaped token, or an assignment's value. */
|
|
151
|
+
const REDACTED_SECRET = "[redacted]";
|
|
152
|
+
/** An `ENV_NAME=value` token: its value may be a credential. */
|
|
153
|
+
/** DX-5333: a value that is only `=` padding (`<base64>==`) is not an assignment value; the whole token is then classified as a bare run. */
|
|
154
|
+
const ASSIGNMENT_TOKEN = /^([A-Za-z_][A-Za-z0-9_]*)=(?!=*$).+$/;
|
|
155
|
+
/** The characters a key, token, hash or id is written in (a base64 key may also carry `/`, `+` and trailing `=`). */
|
|
156
|
+
const OPAQUE_TOKEN = /^[A-Za-z0-9_+.-]+={0,2}$/;
|
|
157
|
+
/** An AWS secret access key is exactly this long. */
|
|
158
|
+
const AWS_SECRET_CHARS = 40;
|
|
159
|
+
const BASE64_KEY = /^[A-Za-z0-9/+]+={0,2}$/;
|
|
160
|
+
/** The shortest opaque run of letters and digits treated as a key, token, hash or id (a card id or short SHA is far shorter). */
|
|
161
|
+
const OPAQUE_TOKEN_MIN_CHARS = 20;
|
|
162
|
+
/** Punctuation a token may be wrapped in, which never stops it from being read as opaque. */
|
|
163
|
+
export const WRAPPING_PUNCTUATION = /^[("'`[]+|[)"'`\],.:;!?]+$/g;
|
|
164
|
+
/** The names an `ENV_NAME=value` assignment, a JSON key or a command-line flag carries a credential under. */
|
|
165
|
+
// DX-5333: `pass` counts only after `_` or `-` (`DB_PASS`), so `bypass` and `compass` are not credential names.
|
|
166
|
+
const SECRET_NAME = "(?:secret|token|passw(?:or)?d|passwd|passphrase|pwd|(?<=[_-])pass(?![a-z])|api[_-]?key|credential|private[_-]?key|signature)";
|
|
167
|
+
/**
|
|
168
|
+
* Token shapes that are secrets by their prefix alone, even with no digit in the body (`ghp_...`, `sk_live_...`): GitHub, Stripe,
|
|
169
|
+
* Anthropic/OpenAI-style, Slack, npm and AWS access-key ids. A trailing `=` (base64 padding) is allowed.
|
|
170
|
+
*/
|
|
171
|
+
const KNOWN_SECRET_TOKEN = /^(?:gh[pousr]_|github_pat_|sk_(?:live|test)_|rk_(?:live|test)_|sk-|xox[abprs]-|npm_|hf_|glpat-)[A-Za-z0-9_-]{16,}={0,2}$|^AKIA[A-Z0-9]{16}$/;
|
|
172
|
+
/** DX-5333 - a UUID: an id a brief names, never a credential. */
|
|
173
|
+
const UUID_TOKEN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
174
|
+
/** DX-5333 - a `claude-...` model id (a dated model name). */
|
|
175
|
+
const MODEL_ID_TOKEN = /^claude-[a-z0-9.-]+$/;
|
|
176
|
+
/** DX-5333 - a bare file name with an extension (`1792303009664-120f12_x.ts`); a path is already out of the opaque shape. */
|
|
177
|
+
const FILE_NAME_TOKEN = /^[A-Za-z0-9_.-]+\.[A-Za-z][A-Za-z0-9]{0,7}$/;
|
|
178
|
+
/**
|
|
179
|
+
* DX-5333 — the ONE test for "this bare token is a key, token or hash": 20+ characters holding a letter and a digit, written
|
|
180
|
+
* either as a plain opaque run or as a base64 key (which may hold `/` and `+` and end in `=`; an AWS secret is one). A base64
|
|
181
|
+
* run must also mix upper and lower case. DX-5333 - a known secret shape (see {@link KNOWN_SECRET_TOKEN}) is masked first; a UUID, a
|
|
182
|
+
* `claude-*` model id and a file name with an extension are kept, since a brief that names them is worthless without them.
|
|
183
|
+
*/
|
|
184
|
+
function isOpaqueSecret(bare) {
|
|
185
|
+
if (KNOWN_SECRET_TOKEN.test(bare))
|
|
186
|
+
return true;
|
|
187
|
+
if (UUID_TOKEN.test(bare) || MODEL_ID_TOKEN.test(bare) || FILE_NAME_TOKEN.test(bare))
|
|
188
|
+
return false;
|
|
189
|
+
if (bare.length < OPAQUE_TOKEN_MIN_CHARS || !/[0-9]/.test(bare) || !/[A-Za-z]/.test(bare))
|
|
190
|
+
return false;
|
|
191
|
+
if (OPAQUE_TOKEN.test(bare))
|
|
192
|
+
return true;
|
|
193
|
+
if (!BASE64_KEY.test(bare) || !/[a-z]/.test(bare) || !/[A-Z]/.test(bare))
|
|
194
|
+
return false;
|
|
195
|
+
// `=` padding marks base64 outright; a `/` or `+` alone could be a path, so only the 40-character AWS secret shape counts.
|
|
196
|
+
return bare.endsWith("=") || bare.length === AWS_SECRET_CHARS;
|
|
197
|
+
}
|
|
198
|
+
/** Both redactors' token classifier: `options` says what each redactor leaves alone (paths, non-credential assignments, git ids) before the shared secret test. */
|
|
199
|
+
function redactTokenWith(token, options) {
|
|
200
|
+
if (options.pathsRead && /[\/\\]/.test(token))
|
|
201
|
+
return REDACTED_PATH;
|
|
202
|
+
// DX-5333: a URL query pair (`?sid=...`, `&sig=...`) is an assignment behind its `?` or `&`.
|
|
203
|
+
const lead = /^[?&]*/.exec(token)[0];
|
|
204
|
+
const assignment = ASSIGNMENT_TOKEN.exec(token.slice(lead.length));
|
|
205
|
+
if (assignment !== null) {
|
|
206
|
+
if (options.assignmentNames === null || options.assignmentNames.test(assignment[1]))
|
|
207
|
+
return `${lead}${assignment[1]}=${REDACTED_SECRET}`;
|
|
208
|
+
// DX-5333: a secret VALUE is masked whatever the name it sits under (`FOO=ghp_...`, `sid=...`, `Cookie: session=...`).
|
|
209
|
+
const value = token.slice(lead.length + assignment[1].length + 1).replace(WRAPPING_PUNCTUATION, "");
|
|
210
|
+
return isOpaqueSecret(value) ? `${lead}${assignment[1]}=${REDACTED_SECRET}` : token;
|
|
211
|
+
}
|
|
212
|
+
const bare = token.replace(WRAPPING_PUNCTUATION, "");
|
|
213
|
+
// A git commit id is not a secret, and a brief that names a commit loses its meaning without it.
|
|
214
|
+
if (options.keepGitIds && GIT_OBJECT_ID.test(bare))
|
|
215
|
+
return token;
|
|
216
|
+
return isOpaqueSecret(bare) ? REDACTED_SECRET : token;
|
|
217
|
+
}
|
|
218
|
+
const redactToken = (token) => redactTokenWith(token, { pathsRead: true, assignmentNames: null, keepGitIds: false });
|
|
219
|
+
/**
|
|
220
|
+
* DX-4498 — an agent-written label as the stats wire may carry it: on one line, with every path-shaped token (any token
|
|
221
|
+
* holding a `/` or `\`) read as `[path]`, and every secret-shaped token (an `ENV_NAME=value` value, or an opaque run of
|
|
222
|
+
* 20+ letters and digits such as a key, token or full SHA) read as `[redacted]`. A label is free text the agent wrote
|
|
223
|
+
* for a person to read (real shell descriptions name absolute paths), and it reaches every reader of the team, so this
|
|
224
|
+
* is the one way any label leaves a transcript. Empty for a blank label.
|
|
225
|
+
*/
|
|
226
|
+
export function redactLabel(label) {
|
|
227
|
+
return label
|
|
228
|
+
.split(/\s+/)
|
|
229
|
+
.filter((token) => token !== "")
|
|
230
|
+
.map(redactToken)
|
|
231
|
+
.join(" ");
|
|
232
|
+
}
|
|
233
|
+
/**
|
|
234
|
+
* DX-5333 round 5 - the run scanner: the last pass over {@link redactSecrets}'s text. A "run" is a stretch of the characters a key,
|
|
235
|
+
* token, hash, path or file name is written in; every other character (whitespace, quotes, brackets, `,;:*<>&?|\` and the like) is a
|
|
236
|
+
* boundary kept as it was, so JSON, markup and prose around a secret keep their shape. A run is masked when it is itself a secret
|
|
237
|
+
* ({@link isOpaqueSecret}); else it is cut at `=` and each side judged (`--flag=ghp_...`), then at `/` and each part judged with the
|
|
238
|
+
* stricter {@link isPathPartSecret} (`path/ghp_...`, while a hyphenated branch or directory name in a path survives).
|
|
239
|
+
*/
|
|
240
|
+
const RUN = /[A-Za-z0-9_+\/.=-]+/g;
|
|
241
|
+
// DX-5333 round 6: index loops, not a lazy regex (`a` + 100k dots + `a` was quadratic).
|
|
242
|
+
function trimRun(run) {
|
|
243
|
+
let start = 0;
|
|
244
|
+
let end = run.length;
|
|
245
|
+
while (start < end && run[start] === ".")
|
|
246
|
+
start++;
|
|
247
|
+
while (end > start && run[end - 1] === ".")
|
|
248
|
+
end--;
|
|
249
|
+
return [run.slice(0, start), run.slice(start, end), run.slice(end)];
|
|
250
|
+
}
|
|
251
|
+
/** A path part is a secret when it is a known secret shape, or a long unbroken letters-and-digits run (a hash, a key); hyphens and dots mark a name. */
|
|
252
|
+
function isPathPartSecret(part) {
|
|
253
|
+
if (GIT_OBJECT_ID.test(part))
|
|
254
|
+
return false;
|
|
255
|
+
if (KNOWN_SECRET_TOKEN.test(part))
|
|
256
|
+
return true;
|
|
257
|
+
return part.length >= OPAQUE_TOKEN_MIN_CHARS && /^[A-Za-z0-9_]+$/.test(part) && /[0-9]/.test(part) && /[A-Za-z]/.test(part) && !UUID_TOKEN.test(part);
|
|
258
|
+
}
|
|
259
|
+
function maskRun(run) {
|
|
260
|
+
const [lead, core, tail] = trimRun(run);
|
|
261
|
+
if (core === "")
|
|
262
|
+
return run;
|
|
263
|
+
// A git commit id is not a secret, and a brief that names a commit loses its meaning without it.
|
|
264
|
+
if (GIT_OBJECT_ID.test(core))
|
|
265
|
+
return run;
|
|
266
|
+
if (isOpaqueSecret(core))
|
|
267
|
+
return `${lead}${REDACTED_SECRET}${tail}`;
|
|
268
|
+
if (core.includes("="))
|
|
269
|
+
return `${lead}${core.split("=").map(maskRun).join("=")}${tail}`;
|
|
270
|
+
if (core.includes("/"))
|
|
271
|
+
return `${lead}${core.split("/").map((part) => (isPathPartSecret(part) ? REDACTED_SECRET : part)).join("/")}${tail}`;
|
|
272
|
+
return run;
|
|
273
|
+
}
|
|
274
|
+
/** A URL's `user:password@` part: the password is read as a secret wherever the URL sits (bare, or after `DATABASE_URL=`). */
|
|
275
|
+
// DX-5333 round 7: the password is everything up to the LAST `@` of the authority (the part before the first `/`), so a `#` or `?` inside it
|
|
276
|
+
// is masked (`u:abc#def@host`) and `?email=a@b.com` after the path is not swallowed. When no `@` precedes the first `/`, the password may hold a `/`
|
|
277
|
+
// (`u:pa/ss@host`) but then stops at `?` or `#`. DX-5333: `:<digits>/` is a port followed by a path (`localhost:5567/@vite/client`), never a password.
|
|
278
|
+
const URL_CREDENTIALS = /((?<![A-Za-z0-9+.-])[A-Za-z][A-Za-z0-9+.-]{0,31}:\/\/[^\s:/@]{0,128}:)(?!\d+\/)(?:[^\s/]{1,256}|[^\s?#]{1,256})(@)/g;
|
|
279
|
+
/** A URL's `token@` userinfo with no colon (`https://ghp_...@github.com`): masked only when the userinfo is itself a secret, so `ssh://git@host` stays. */
|
|
280
|
+
const URL_USERINFO_TOKEN = /((?<![A-Za-z0-9+.-])[A-Za-z][A-Za-z0-9+.-]{0,31}:\/\/)([^\s:/@]{1,256})(@)/g;
|
|
281
|
+
/** A PEM private key block, BEGIN line to END line (to the end of the text when the END line was cut off). */
|
|
282
|
+
const PEM_PRIVATE_KEY = /-----BEGIN [A-Z ]*PRIVATE KEY(?: BLOCK)?-----[\s\S]*?(?:-----END [A-Z ]*PRIVATE KEY(?: BLOCK)?-----|$)/g;
|
|
283
|
+
/** An `Authorization` header or assignment: the scheme word stays, the credential after it goes. */
|
|
284
|
+
const AUTHORIZATION_VALUE = /(\bauthorization["']?\s*[:=]\s*["']?(?:(?:bearer|basic|token)\s+)?)[^\s"']+/gi;
|
|
285
|
+
/** A bearer credential written outside a header. */
|
|
286
|
+
const BEARER_VALUE = /(\bbearer\s+)(?!\[redacted\])[^\s"']+/gi;
|
|
287
|
+
/**
|
|
288
|
+
* A credential-named key (plain, or quoted as JSON) followed by `:` or `=`, with its value (quoted, or unquoted).
|
|
289
|
+
* DX-5333 round 7: an unquoted value runs to whitespace, a quote, a comma or a semicolon, so `password=pa)ss` is masked whole. It also
|
|
290
|
+
* stops at a closer (`) } ] >`) only when the key is directly enclosed by an opener (`{password: x}`, `(token=x)`, `[password=x]`), so the
|
|
291
|
+
* closer stays. An existing `[redacted]` is kept only as the WHOLE value; `[redacted]hunter2` is masked entirely, enclosed too (the enclosed
|
|
292
|
+
* fallback takes a leading `[redacted]` whole, or its `]` would end the value and leave `hunter2` in clear).
|
|
293
|
+
*/
|
|
294
|
+
const NAMED_KEY = String.raw `["']?[\w-]{0,64}${SECRET_NAME}[\w-]{0,64}["']?\s*[:=]\s*`;
|
|
295
|
+
const NAMED_VALUE = new RegExp(String.raw `(?<=[({\[<]\s{0,16})(${NAMED_KEY})(\[redacted\](?![^\s"',;)}\]>])|"[^"]*"|'[^']*'|(?:\[redacted\])?[^\s"',;)}\]>]+)|(${NAMED_KEY})(\[redacted\](?![^\s"',;])|"[^"]*"|'[^']*'|[^\s"',;]+)`, "gi");
|
|
296
|
+
/** DX-5333: a passphrase is several words (`passphrase: correct horse`): its unquoted value runs to the end of the line, bounded. */
|
|
297
|
+
const PASSPHRASE_VALUE = /(\bpassphrase["']?\s*[:=][ \t]*)(?![ \t])(?!\[redacted\])[^\n"']{1,200}/gi;
|
|
298
|
+
/** A masked value keeps the quotes it was written in, so a JSON body stays JSON. */
|
|
299
|
+
const maskedValue = (_match, enclosedName, enclosedValue, name2, value2) => {
|
|
300
|
+
const name = enclosedName ?? name2 ?? "";
|
|
301
|
+
const value = enclosedValue ?? value2 ?? "";
|
|
302
|
+
const quote = value[0] === '"' || value[0] === "'" ? value[0] : "";
|
|
303
|
+
return `${name}${quote}${REDACTED_SECRET}${quote}`;
|
|
304
|
+
};
|
|
305
|
+
/** A credential-named command-line flag followed by its value after a space (`--password x`). */
|
|
306
|
+
const FLAG_VALUE = new RegExp(String.raw `(--[\w-]{0,64}${SECRET_NAME}[\w-]{0,64}\s+)(?!-)[^\s]+`, "gi");
|
|
307
|
+
/**
|
|
308
|
+
* DX-5333 — a message as the stats wire may carry it: every credential read as `[redacted]` — a URL's password, an
|
|
309
|
+
* `Authorization`/bearer value, the value of a credential-named assignment, JSON key or `--flag` (`password: x`,
|
|
310
|
+
* `"password": "x"`, `--password x`), and every opaque run of 20+ characters (a key, a token, a base64 secret that may hold
|
|
311
|
+
* `/` or end in `=`; a git commit id is kept) — with the text's own whitespace, line breaks, paths and prose kept: a brief is
|
|
312
|
+
* read to replay the work, and its paths are the work. NUL characters are removed (Postgres refuses them, which would wedge
|
|
313
|
+
* the write that carries the message). Cut to {@link ACTIVITY_MESSAGE_MAX_CHARS}. The server applies the same function again
|
|
314
|
+
* on receipt.
|
|
315
|
+
*
|
|
316
|
+
* The order of the chain matters: NUL goes first, the PEM block next (its body lines would otherwise be read token by token), then
|
|
317
|
+
* the structured patterns (URL credentials, headers, named values, flags), and the per-token classifier last, over what is
|
|
318
|
+
* left. The server re-runs the whole chain on text already masked here, so every step must leave `[redacted]` alone (idempotent).
|
|
319
|
+
*/
|
|
320
|
+
export function redactSecrets(text) {
|
|
321
|
+
// DX-5333: cut the raw text first so the passes below never run over more than the wire carries.
|
|
322
|
+
const redacted = (text.length > ACTIVITY_MESSAGE_MAX_CHARS ? text.slice(0, ACTIVITY_MESSAGE_MAX_CHARS) : text)
|
|
323
|
+
.replace(/\u0000/g, "")
|
|
324
|
+
.replace(PEM_PRIVATE_KEY, REDACTED_SECRET)
|
|
325
|
+
.replace(URL_CREDENTIALS, `$1${REDACTED_SECRET}$2`)
|
|
326
|
+
.replace(URL_USERINFO_TOKEN, (match, scheme, user, at) => (isOpaqueSecret(user) ? `${scheme}${REDACTED_SECRET}${at}` : match))
|
|
327
|
+
.replace(AUTHORIZATION_VALUE, `$1${REDACTED_SECRET}`)
|
|
328
|
+
.replace(BEARER_VALUE, `$1${REDACTED_SECRET}`)
|
|
329
|
+
.replace(PASSPHRASE_VALUE, `$1${REDACTED_SECRET}`)
|
|
330
|
+
.replace(NAMED_VALUE, maskedValue)
|
|
331
|
+
.replace(FLAG_VALUE, `$1${REDACTED_SECRET}`)
|
|
332
|
+
.replace(RUN, maskRun);
|
|
333
|
+
return redacted.length > ACTIVITY_MESSAGE_MAX_CHARS ? redacted.slice(0, ACTIVITY_MESSAGE_MAX_CHARS) : redacted;
|
|
334
|
+
}
|
|
335
|
+
/** DX-4498 — a transcript value read as a label: {@link redactLabel}'s text, or null for a non-string or blank one. */
|
|
336
|
+
export function labelOf(value) {
|
|
337
|
+
if (typeof value !== "string")
|
|
338
|
+
return null;
|
|
339
|
+
const label = redactLabel(value);
|
|
340
|
+
return label === "" ? null : label;
|
|
341
|
+
}
|
|
342
|
+
/**
|
|
343
|
+
* DX-4498 — a sub-agent's current activity, from one `tool_use` block: the tool's name, plus a label only where the
|
|
344
|
+
* agent wrote one AS a label (a shell call's `description`, an `Agent` call's `description`, a `Skill` call's skill
|
|
345
|
+
* name), through {@link labelOf}. Never a command, a file path, a secret or a prompt: the stats wire carries ids,
|
|
346
|
+
* numbers and redacted labels, never free-form transcript content.
|
|
347
|
+
*/
|
|
348
|
+
export function describeToolCall(name, input) {
|
|
349
|
+
let detail = null;
|
|
350
|
+
switch (name) {
|
|
351
|
+
case "Bash":
|
|
352
|
+
case "PowerShell":
|
|
353
|
+
case "Agent":
|
|
354
|
+
case "Task":
|
|
355
|
+
detail = labelOf(input.description);
|
|
356
|
+
break;
|
|
357
|
+
case "Skill":
|
|
358
|
+
detail = labelOf(input.skill);
|
|
359
|
+
break;
|
|
360
|
+
}
|
|
361
|
+
const text = detail === null ? name : `${name}: ${detail}`;
|
|
362
|
+
return text.length > CURRENT_ACTIVITY_MAX_CHARS ? `${text.slice(0, CURRENT_ACTIVITY_MAX_CHARS - 3)}...` : text;
|
|
363
|
+
}
|
|
364
|
+
/**
|
|
365
|
+
* DX-4508 — the first card-id-shaped token in a text (`DX-4498`, also `DX-4498:`, `(DX-4498)`), or null when it has
|
|
366
|
+
* none. A sub-agent's card is the one its spawning `Agent` call names: its `description` ("Review DX-4498 code gates")
|
|
367
|
+
* first, else its brief ("Quality gate for card DX-4421 ..."), where the first-word rule DX-4498 shipped missed most
|
|
368
|
+
* real briefs. Only the SHAPE is checked: the server keeps the id only when it is a real card of the team, so a plan ref
|
|
369
|
+
* (`PLAN-2`) or rule ref (`R-45`) never reaches a reader.
|
|
370
|
+
*/
|
|
371
|
+
export function firstIssueId(text) {
|
|
372
|
+
for (const token of text.split(/\s+/)) {
|
|
373
|
+
const bare = token.replace(WRAPPING_PUNCTUATION, "");
|
|
374
|
+
if (bare.length <= ISSUE_ID_MAX_LENGTH && ISSUE_ID_PATTERN.test(bare))
|
|
375
|
+
return bare;
|
|
376
|
+
}
|
|
377
|
+
return null;
|
|
378
|
+
}
|
|
379
|
+
/** DX-5898: the shape of an agent profile's name (`gate-designer`); anything else leading a brief is prose, never stored. */
|
|
380
|
+
const PROFILE_WORD_PATTERN = /^[a-z][a-z0-9-]*$/;
|
|
381
|
+
/**
|
|
382
|
+
* DX-5898 - the leading word of a sub-agent's brief when it has a profile name's shape (`gate-designer DX-5875` -> `gate-designer`), else
|
|
383
|
+
* null. An orchestrator briefs a profile sub-agent as `<profile> <CARD-ID>` (DX-5899); the collector cannot know the team's profiles, so it
|
|
384
|
+
* keeps only the raw word and the server matches it against the profiles the team may see when it reads the row. A brief that opens with an
|
|
385
|
+
* ordinary word equal to a profile's name therefore gives a false match: the shape check and that server match are the only filters.
|
|
386
|
+
*/
|
|
387
|
+
export function leadingProfileWord(text) {
|
|
388
|
+
const first = text.trim().split(/\s+/)[0] ?? "";
|
|
389
|
+
const bare = first.replace(WRAPPING_PUNCTUATION, "");
|
|
390
|
+
return bare.length <= PROFILE_WORD_MAX_LENGTH && PROFILE_WORD_PATTERN.test(bare) ? bare : null;
|
|
391
|
+
}
|
|
392
|
+
/**
|
|
393
|
+
* DX-5333 — whether a `user` line is a message the coordinator (the parent session) sent: Claude Code writes a `SendMessage`
|
|
394
|
+
* follow-up to a running sub-agent as `isMeta: true` with `origin.kind: "coordinator"`. Any other meta line is Claude Code's own.
|
|
395
|
+
*/
|
|
396
|
+
function isCoordinatorLine(raw) {
|
|
397
|
+
return raw.origin?.kind === "coordinator";
|
|
398
|
+
}
|
|
399
|
+
/** The text of a `user` line's content: the string itself, or its first text block. */
|
|
400
|
+
function userText(raw) {
|
|
401
|
+
const content = raw.message?.content;
|
|
402
|
+
if (typeof content === "string")
|
|
403
|
+
return content;
|
|
404
|
+
if (!Array.isArray(content))
|
|
405
|
+
return null;
|
|
406
|
+
const block = content.find((b) => b?.type === "text");
|
|
407
|
+
return typeof block?.text === "string" ? block.text : null;
|
|
408
|
+
}
|
|
409
|
+
/** The line's own timestamp in epoch ms, or null when it carries none that parses. */
|
|
410
|
+
function lineTime(raw) {
|
|
411
|
+
if (typeof raw.timestamp !== "string")
|
|
412
|
+
return null;
|
|
413
|
+
const ms = Date.parse(raw.timestamp);
|
|
414
|
+
return Number.isNaN(ms) ? null : ms;
|
|
415
|
+
}
|
|
416
|
+
/** The text blocks of an `assistant` line's content, joined; empty when it has none. */
|
|
417
|
+
function assistantText(raw) {
|
|
418
|
+
const content = raw.message?.content;
|
|
419
|
+
if (!Array.isArray(content))
|
|
420
|
+
return "";
|
|
421
|
+
return content
|
|
422
|
+
.filter((b) => b?.type === "text" && typeof b.text === "string")
|
|
423
|
+
.map((b) => b.text)
|
|
424
|
+
.join("\n\n")
|
|
425
|
+
.trim();
|
|
426
|
+
}
|
|
427
|
+
/** DX-5333 — whether an assistant line carries a tool call. */
|
|
428
|
+
function hasToolUse(raw) {
|
|
429
|
+
const content = raw.message?.content;
|
|
430
|
+
return Array.isArray(content) && content.some((b) => b?.type === "tool_use");
|
|
431
|
+
}
|
|
432
|
+
/** DX-5333 — the API message id an assistant line belongs to (Claude Code writes each content block as its own line). */
|
|
433
|
+
function assistantMessageId(raw) {
|
|
434
|
+
return raw.message?.id;
|
|
435
|
+
}
|
|
436
|
+
/** A follow-up message delivered to a sub-agent as a queued command (Claude Code's `SendMessage`), or null for any other line. */
|
|
437
|
+
function queuedCommandText(raw) {
|
|
438
|
+
if (raw.type !== "attachment")
|
|
439
|
+
return null;
|
|
440
|
+
const attachment = raw.attachment;
|
|
441
|
+
if (attachment?.type !== "queued_command" || attachment.commandMode === "task-notification")
|
|
442
|
+
return null;
|
|
443
|
+
return typeof attachment.prompt === "string" ? attachment.prompt : null;
|
|
444
|
+
}
|
|
445
|
+
/** DX-5333 — the `50arms_api` call that creates a card problem: `POST /api/issues/<id>/problems`. */
|
|
446
|
+
const PROBLEM_CREATE_PATH = /^\/api\/issues\/[^/?#]+\/problems\/?(?:[?#].*)?$/;
|
|
447
|
+
/** The `tool_use` blocks of an `assistant` line's content that are problem-create calls, as their ids. */
|
|
448
|
+
function problemCreateCallIds(raw) {
|
|
449
|
+
const content = raw.message?.content;
|
|
450
|
+
if (!Array.isArray(content))
|
|
451
|
+
return [];
|
|
452
|
+
const ids = [];
|
|
453
|
+
for (const block of content) {
|
|
454
|
+
if (block?.type !== "tool_use" || typeof block.id !== "string" || typeof block.name !== "string")
|
|
455
|
+
continue;
|
|
456
|
+
if (!DASHBOARD_API_TOOL_NAMES.has(block.name))
|
|
457
|
+
continue;
|
|
458
|
+
const input = block.input;
|
|
459
|
+
if (typeof input?.method === "string" && input.method.toUpperCase() === "POST" && typeof input.path === "string" && PROBLEM_CREATE_PATH.test(input.path)) {
|
|
460
|
+
ids.push(block.id);
|
|
461
|
+
}
|
|
462
|
+
}
|
|
463
|
+
return ids;
|
|
464
|
+
}
|
|
465
|
+
/** DX-5333 - the ids of the Agent / Task `tool_use` blocks of an `assistant` line. */
|
|
466
|
+
function spawnCallIdsOf(raw) {
|
|
467
|
+
const content = raw.message?.content;
|
|
468
|
+
if (!Array.isArray(content))
|
|
469
|
+
return [];
|
|
470
|
+
const ids = [];
|
|
471
|
+
for (const block of content) {
|
|
472
|
+
if (block?.type === "tool_use" && typeof block.id === "string" && typeof block.name === "string" && SUBAGENT_TOOL_NAMES.has(block.name))
|
|
473
|
+
ids.push(block.id);
|
|
474
|
+
}
|
|
475
|
+
return ids;
|
|
476
|
+
}
|
|
477
|
+
/** The `tool_result` blocks of a `user` line's content, as `(tool_use_id, text)`. */
|
|
478
|
+
function toolResults(raw) {
|
|
479
|
+
const content = raw.message?.content;
|
|
480
|
+
if (!Array.isArray(content))
|
|
481
|
+
return [];
|
|
482
|
+
const out = [];
|
|
483
|
+
for (const block of content) {
|
|
484
|
+
if (block?.type !== "tool_result" || typeof block.tool_use_id !== "string")
|
|
485
|
+
continue;
|
|
486
|
+
const body = block.content;
|
|
487
|
+
const text = typeof body === "string"
|
|
488
|
+
? body
|
|
489
|
+
: Array.isArray(body)
|
|
490
|
+
? body.map((b) => (b?.type === "text" ? String(b.text) : "")).join("")
|
|
491
|
+
: "";
|
|
492
|
+
out.push({ toolUseId: block.tool_use_id, text, isError: block.is_error === true });
|
|
493
|
+
}
|
|
494
|
+
return out;
|
|
495
|
+
}
|
|
496
|
+
/**
|
|
497
|
+
* DX-5576 - how a foreground spawn ended, from its own `tool_result` (a foreground spawn gets no task notification): an errored result
|
|
498
|
+
* is `failed`, or `stopped` when the user interrupted or rejected the call, any other result is `completed`.
|
|
499
|
+
*/
|
|
500
|
+
function foregroundEndStatus(result) {
|
|
501
|
+
if (!result.isError)
|
|
502
|
+
return "completed";
|
|
503
|
+
return FOREGROUND_STOPPED.test(result.text) ? "stopped" : "failed";
|
|
504
|
+
}
|
|
505
|
+
/** DX-5333 - whether a spawn's `tool_result` is the notice of a background launch (`toolUseResult.status` `async_launched`; a nested spawner's line has no `toolUseResult`, so its text is read), not a completion. */
|
|
506
|
+
function isAsyncLaunch(raw, resultText) {
|
|
507
|
+
if (raw.toolUseResult?.status === "async_launched")
|
|
508
|
+
return true;
|
|
509
|
+
return /^\s*async agent launched/i.test(resultText);
|
|
510
|
+
}
|
|
511
|
+
/** The number of the problem a successful `50arms_api` problem-create answer names (`{ok, status: 201, body: {ref_num}}`, DX-5516: the n of its `PBLM-n`, never its row id), or null. */
|
|
512
|
+
function createdProblemRefNum(resultText) {
|
|
513
|
+
try {
|
|
514
|
+
const answer = JSON.parse(resultText);
|
|
515
|
+
const refNum = answer.body?.ref_num;
|
|
516
|
+
return answer.ok === true && answer.status === 201 && typeof refNum === "number" && Number.isInteger(refNum) && refNum > 0 ? refNum : null;
|
|
517
|
+
}
|
|
518
|
+
catch {
|
|
519
|
+
return null;
|
|
520
|
+
}
|
|
521
|
+
}
|
|
522
|
+
/** How many problem-create calls awaiting their answer are remembered; the oldest is dropped beyond this. */
|
|
523
|
+
const MAX_PENDING_PROBLEM_CALLS = 200;
|
|
524
|
+
/** DX-5333 - a transcript line's text field with any NUL removed (the wire parser refuses one, and a refused write is re-sent forever). */
|
|
525
|
+
const withoutNul = (value) => value.replace(/\u0000/g, "");
|
|
526
|
+
function optionalLineText(value) {
|
|
527
|
+
if (typeof value !== "string")
|
|
528
|
+
return null;
|
|
529
|
+
const text = withoutNul(value);
|
|
530
|
+
return text === "" ? null : text;
|
|
531
|
+
}
|
|
532
|
+
export function createActivityObserver(deps) {
|
|
533
|
+
// No clock of its own, deliberately. Every stamp this module reports comes from
|
|
534
|
+
// the event that caused it — the watcher's `discoveredAt` for a start (its `lastWriteAt` for the activity stamp), the
|
|
535
|
+
// line's own `timestamp` for an advance or an end — so there is no wall-clock
|
|
536
|
+
// reading anywhere that a test would need to freeze or that a replay could skew.
|
|
537
|
+
/** Every sub-agent this observer has reported, by its ledger source key. */
|
|
538
|
+
const started = new Map();
|
|
539
|
+
/**
|
|
540
|
+
* DX-4498 — per source, the newest line timestamp read so far. The effort and tool call come from the NEWEST line,
|
|
541
|
+
* which `lastActivityAt` cannot tell: it starts at the watcher's discovery time, after every line a reattached
|
|
542
|
+
* collector re-reads.
|
|
543
|
+
*/
|
|
544
|
+
const newestLineAt = new Map();
|
|
545
|
+
/** DX-4498 — ends notified for a source not yet discovered (see {@link MAX_PENDING_ENDS}). */
|
|
546
|
+
const pendingEnds = new Map();
|
|
547
|
+
const report = (change) => deps.recordActivities([{ ...change }]);
|
|
548
|
+
/**
|
|
549
|
+
* DX-5333 — sub-agents this observer saw START (not one already running when the collector attached): only their first
|
|
550
|
+
* line's checkout is still the commit they started from. Maps the source to the working directories already tried.
|
|
551
|
+
*/
|
|
552
|
+
const awaitingStartSha = new Map();
|
|
553
|
+
/** DX-5333 — problem-create calls not yet answered, by their `tool_use` id, with the sub-agent that made them. */
|
|
554
|
+
const pendingProblemCalls = new Map();
|
|
555
|
+
/**
|
|
556
|
+
* DX-5333 — a text-only assistant line is the sub-agent's report only if nothing follows it in the same turn. Claude Code
|
|
557
|
+
* does not always fill `stop_reason` on that line (a short reply stays `null`), and writes a message's text and tool call
|
|
558
|
+
* as separate lines, so the line is held here until the next line decides it: a tool call of the same message withdraws it,
|
|
559
|
+
* any other line (or the sub-agent's reported end) confirms it. Keyed by activity; one candidate per sub-agent.
|
|
560
|
+
*/
|
|
561
|
+
const pendingReports = new Map();
|
|
562
|
+
/**
|
|
563
|
+
* DX-5333 — foreground sub-agents by the `tool_use` id of the spawn call. The spawner's `tool_result` for that call is written
|
|
564
|
+
* when the sub-agent is done, so it ENDS the row (DX-5576: `finishedAt` and the status {@link foregroundEndStatus} reads from the
|
|
565
|
+
* result) and confirms a held report that no later line of the sub-agent's own transcript will.
|
|
566
|
+
*/
|
|
567
|
+
const foregroundSpawns = new Map();
|
|
568
|
+
/** DX-5333 - the `tool_use` ids of Agent / Task calls, so an early result is buffered for a spawn and never for another tool. */
|
|
569
|
+
const spawnCallIds = new Map();
|
|
570
|
+
/**
|
|
571
|
+
* DX-5333 - a spawn's result read before its sub-agent was discovered (`meta.json` lands after the `.jsonl`), by the call id, with the
|
|
572
|
+
* result line's time and the end status it gives (DX-5576), applied when the sub-agent is registered. Capped like {@link MAX_PENDING_ENDS}.
|
|
573
|
+
*/
|
|
574
|
+
const earlyResults = new Map();
|
|
575
|
+
/**
|
|
576
|
+
* DX-5333 - activities whose end was reported (a foreground spawn's result, or a task-notification), with the reporting line's
|
|
577
|
+
* time. The spawner's file is read before the sub-agent's, so at the poll's end every line up to it has been read and the held
|
|
578
|
+
* reply is the sub-agent's final ({@link ActivityObserver.onPollComplete}); the first sub-agent line dated AFTER it (a
|
|
579
|
+
* `SendMessage` resume) settles it too. Either way the entry is removed, so a resumed turn holds and withdraws its text like any other.
|
|
580
|
+
* DX-5413: a nested spawner can be read AFTER its child in the same poll (discovery order), so a signal read this poll proves nothing about
|
|
581
|
+
* the child's lines; only a signal recorded in an EARLIER poll confirms (`settlesNext` on its {@link endedAwaitingPoll} entry), costing up to one poll of delay.
|
|
582
|
+
*/
|
|
583
|
+
// DX-5413: `settlesNext` lives in the entry, so the one cap bounds both (a separate set outgrew it on a long-lived collector).
|
|
584
|
+
// The one-poll delay is why `SessionLogWatcher.drainFinal` drains twice: change one, change the other.
|
|
585
|
+
const endedAwaitingPoll = new Map();
|
|
586
|
+
const recordEnd = (activityId, at) => {
|
|
587
|
+
setCapped(endedAwaitingPoll, activityId, { at, settlesNext: false }, MAX_PENDING_SPAWNS);
|
|
588
|
+
};
|
|
589
|
+
/**
|
|
590
|
+
* DX-5576: the one end for a started row, whichever signal brought it (a foreground result or a task notification): confirm the held
|
|
591
|
+
* reply at the next poll's end (the sub-agent's last lines may not be read yet, DX-5333 / DX-5413), apply the end, and report only a change.
|
|
592
|
+
*/
|
|
593
|
+
const endActivity = (change, end) => {
|
|
594
|
+
recordEnd(change.activityId, end.at);
|
|
595
|
+
// DX-6815: an end older than the one held (a replay of a first notification after the second) is nothing new, and so is the same end again.
|
|
596
|
+
if (change.finishedAt !== null && (end.at < change.finishedAt || (end.at === change.finishedAt && change.endStatus === end.endStatus)))
|
|
597
|
+
return;
|
|
598
|
+
applyEnd(change, { finishedAt: end.at, endStatus: end.endStatus });
|
|
599
|
+
report(change);
|
|
600
|
+
};
|
|
601
|
+
const registerForeground = (toolUseId, activityId) => {
|
|
602
|
+
const returned = earlyResults.get(toolUseId);
|
|
603
|
+
if (returned === undefined) {
|
|
604
|
+
setCapped(foregroundSpawns, toolUseId, activityId, MAX_PENDING_SPAWNS);
|
|
605
|
+
return;
|
|
606
|
+
}
|
|
607
|
+
earlyResults.delete(toolUseId);
|
|
608
|
+
const change = started.get(activityId);
|
|
609
|
+
if (change !== undefined)
|
|
610
|
+
endActivity(change, returned);
|
|
611
|
+
};
|
|
612
|
+
const confirmReport = (change) => {
|
|
613
|
+
const pending = pendingReports.get(change.activityId);
|
|
614
|
+
if (pending === undefined)
|
|
615
|
+
return;
|
|
616
|
+
pendingReports.delete(change.activityId);
|
|
617
|
+
message(pending.raw, change, "final", pending.text, pending.at);
|
|
618
|
+
};
|
|
619
|
+
const message = (raw, change, kind, text, at) => {
|
|
620
|
+
if (deps.recordMessages === undefined || typeof raw.uuid !== "string")
|
|
621
|
+
return;
|
|
622
|
+
const messageKey = withoutNul(raw.uuid);
|
|
623
|
+
if (messageKey === "")
|
|
624
|
+
return;
|
|
625
|
+
const redacted = redactSecrets(text);
|
|
626
|
+
if (redacted.trim() === "")
|
|
627
|
+
return;
|
|
628
|
+
deps.recordMessages([
|
|
629
|
+
{ activityId: change.activityId, kind, messageKey, text: redacted, at, cwd: optionalLineText(raw.cwd), gitBranch: optionalLineText(raw.gitBranch) },
|
|
630
|
+
]);
|
|
631
|
+
};
|
|
632
|
+
/**
|
|
633
|
+
* Stamp `startSha` from the first line whose `cwd` is a git checkout (a sub-agent that starts in a non-git directory tries
|
|
634
|
+
* again on each new `cwd`). The read is asynchronous, so the stamp is reported when git answers, never blocking the line feed.
|
|
635
|
+
*/
|
|
636
|
+
const stampStartSha = (raw, change) => {
|
|
637
|
+
const tried = awaitingStartSha.get(change.activityId);
|
|
638
|
+
const cwd = optionalLineText(raw.cwd);
|
|
639
|
+
if (tried === undefined || cwd === null || tried.has(cwd) || deps.readHeadSha === undefined)
|
|
640
|
+
return;
|
|
641
|
+
tried.add(cwd);
|
|
642
|
+
void deps
|
|
643
|
+
.readHeadSha(cwd)
|
|
644
|
+
.catch(() => null)
|
|
645
|
+
.then((sha) => {
|
|
646
|
+
// Another line's answer may have stamped it first; the first answer stands.
|
|
647
|
+
if (sha === null || !awaitingStartSha.has(change.activityId))
|
|
648
|
+
return;
|
|
649
|
+
awaitingStartSha.delete(change.activityId);
|
|
650
|
+
change.startSha = sha;
|
|
651
|
+
report(change);
|
|
652
|
+
});
|
|
653
|
+
};
|
|
654
|
+
const applyEnd = (change, end) => {
|
|
655
|
+
// DX-6815: the LATEST finish stands (the server's own merge, `mergedFinish`), so a sub-agent resumed after its first finish ends again at its
|
|
656
|
+
// second; the LATEST status wins with it.
|
|
657
|
+
change.finishedAt = end.finishedAt;
|
|
658
|
+
change.endStatus = end.endStatus;
|
|
659
|
+
};
|
|
660
|
+
/** DX-5333 — what a line does to the held report: a tool call of the same message withdraws it, any other line confirms it. */
|
|
661
|
+
const settlePendingReport = (raw, change) => {
|
|
662
|
+
const pending = pendingReports.get(change.activityId);
|
|
663
|
+
if (pending === undefined)
|
|
664
|
+
return;
|
|
665
|
+
if (raw.type === "assistant" && assistantMessageId(raw) === pending.messageId) {
|
|
666
|
+
// The same message's next block: more text replaces the held text (set by the caller), a tool call withdraws it.
|
|
667
|
+
if (hasToolUse(raw))
|
|
668
|
+
pendingReports.delete(change.activityId);
|
|
669
|
+
return;
|
|
670
|
+
}
|
|
671
|
+
confirmReport(change);
|
|
672
|
+
};
|
|
673
|
+
const onNotification = (taskId, endStatus, at) => {
|
|
674
|
+
const source = `${SUBAGENT_SOURCE_PREFIX}${taskId}`;
|
|
675
|
+
const change = started.get(source);
|
|
676
|
+
if (change === undefined) {
|
|
677
|
+
setCapped(pendingEnds, source, { finishedAt: at, endStatus }, MAX_PENDING_ENDS);
|
|
678
|
+
return;
|
|
679
|
+
}
|
|
680
|
+
endActivity(change, { at, endStatus });
|
|
681
|
+
};
|
|
682
|
+
return {
|
|
683
|
+
onSourceDiscovered(info) {
|
|
684
|
+
// Not this dispatch's sub-agent (see `ignorePreexisting`). Never added to
|
|
685
|
+
// `started` either, so its later lines do not stamp activity on a row
|
|
686
|
+
// that does not exist.
|
|
687
|
+
if (deps.ignorePreexisting === true && info.preexisting)
|
|
688
|
+
return;
|
|
689
|
+
const existing = started.get(info.source);
|
|
690
|
+
if (existing) {
|
|
691
|
+
// A second discovery of a source already reported can only ADD what the
|
|
692
|
+
// first pass could not read yet (its `meta.json` sidecar was not written).
|
|
693
|
+
// Never re-stamp `startedAt`: the server takes LEAST(startedAt) anyway, but
|
|
694
|
+
// reporting a later start for a sub-agent that has been running since
|
|
695
|
+
// before this watcher attached would be wrong in the payload itself.
|
|
696
|
+
let filled = false;
|
|
697
|
+
if (existing.agentType === null && info.agentType !== null) {
|
|
698
|
+
existing.agentType = info.agentType;
|
|
699
|
+
filled = true;
|
|
700
|
+
}
|
|
701
|
+
const description = labelOf(info.description);
|
|
702
|
+
if (existing.description === null && description !== null) {
|
|
703
|
+
existing.description = description;
|
|
704
|
+
filled = true;
|
|
705
|
+
// DX-4508 — the description outranks the brief: a late sidecar naming a card replaces a brief-derived id.
|
|
706
|
+
const named = firstIssueId(description);
|
|
707
|
+
if (named !== null && named !== existing.briefIssueId)
|
|
708
|
+
existing.briefIssueId = named;
|
|
709
|
+
}
|
|
710
|
+
if (info.foregroundToolUseId !== null)
|
|
711
|
+
registerForeground(info.foregroundToolUseId, existing.activityId);
|
|
712
|
+
if (existing.parentActivityId === null && info.parentSource !== null) {
|
|
713
|
+
existing.parentActivityId = info.parentSource;
|
|
714
|
+
filled = true;
|
|
715
|
+
}
|
|
716
|
+
if (filled)
|
|
717
|
+
report(existing);
|
|
718
|
+
return;
|
|
719
|
+
}
|
|
720
|
+
// DX-4498 — the label its spawning `Agent` call gave it, from the sidecar, redacted like every label.
|
|
721
|
+
const description = labelOf(info.description);
|
|
722
|
+
const change = {
|
|
723
|
+
kind: "agent",
|
|
724
|
+
activityId: info.source,
|
|
725
|
+
agentType: info.agentType,
|
|
726
|
+
description,
|
|
727
|
+
startedAt: info.discoveredAt,
|
|
728
|
+
// DX-5110: the file's own last write, not the discovery time: a restarted collector re-discovers every finished
|
|
729
|
+
// sub-agent, and the server's GREATEST merge would otherwise revive each as live for the liveness window.
|
|
730
|
+
lastActivityAt: info.lastWriteAt,
|
|
731
|
+
finishedAt: null,
|
|
732
|
+
parentActivityId: info.parentSource,
|
|
733
|
+
effort: null,
|
|
734
|
+
currentActivity: null,
|
|
735
|
+
endStatus: null,
|
|
736
|
+
// DX-4508 — the card the description names (the REDACTED label: the one way a label leaves a transcript); the
|
|
737
|
+
// brief fills it in only while this is still null.
|
|
738
|
+
briefIssueId: description === null ? null : firstIssueId(description),
|
|
739
|
+
};
|
|
740
|
+
const pending = pendingEnds.get(info.source);
|
|
741
|
+
if (pending !== undefined) {
|
|
742
|
+
pendingEnds.delete(info.source);
|
|
743
|
+
applyEnd(change, pending);
|
|
744
|
+
recordEnd(info.source, pending.finishedAt);
|
|
745
|
+
}
|
|
746
|
+
started.set(info.source, change);
|
|
747
|
+
// DX-5576: after `started.set`, so a result already read ends the row it was just registered for.
|
|
748
|
+
if (info.foregroundToolUseId !== null)
|
|
749
|
+
registerForeground(info.foregroundToolUseId, info.source);
|
|
750
|
+
if (!info.preexisting && deps.readHeadSha !== undefined) {
|
|
751
|
+
change.startSha = null;
|
|
752
|
+
awaitingStartSha.set(info.source, new Set());
|
|
753
|
+
}
|
|
754
|
+
report(change);
|
|
755
|
+
},
|
|
756
|
+
onPollComplete() {
|
|
757
|
+
// DX-5413: only an end signal recorded in an earlier poll confirms; one read this poll waits for the next.
|
|
758
|
+
for (const [activityId, ended] of [...endedAwaitingPoll]) {
|
|
759
|
+
if (!ended.settlesNext) {
|
|
760
|
+
ended.settlesNext = true;
|
|
761
|
+
continue;
|
|
762
|
+
}
|
|
763
|
+
const change = started.get(activityId);
|
|
764
|
+
if (change !== undefined)
|
|
765
|
+
confirmReport(change);
|
|
766
|
+
endedAwaitingPoll.delete(activityId);
|
|
767
|
+
}
|
|
768
|
+
},
|
|
769
|
+
onLine(raw, meta) {
|
|
770
|
+
const at = lineTime(raw);
|
|
771
|
+
// A line with no timestamp moves nothing: every stamp here is the line's own, never a wall clock.
|
|
772
|
+
if (at === null)
|
|
773
|
+
return;
|
|
774
|
+
// DX-4498 — a notification ends the sub-agent it names, wherever it is read: the main transcript for one the
|
|
775
|
+
// session spawned, the spawning sub-agent's own transcript for a nested one.
|
|
776
|
+
const notification = parseTaskNotification(raw);
|
|
777
|
+
if (notification !== null) {
|
|
778
|
+
onNotification(notification.taskId, notification.endStatus, at);
|
|
779
|
+
return;
|
|
780
|
+
}
|
|
781
|
+
// DX-5333 — the spawner's result for a foreground spawn ends that sub-agent; a nested spawner's line has no
|
|
782
|
+
// `toolUseResult`, so the match is on the call id alone. Read wherever it is found, like a notification.
|
|
783
|
+
if (raw.type === "user") {
|
|
784
|
+
for (const result of toolResults(raw)) {
|
|
785
|
+
// A spawn moved to the background answers at once with a launch notice; that is not its completion.
|
|
786
|
+
if (isAsyncLaunch(raw, result.text))
|
|
787
|
+
continue;
|
|
788
|
+
const spawned = foregroundSpawns.get(result.toolUseId);
|
|
789
|
+
if (spawned === undefined) {
|
|
790
|
+
if (spawnCallIds.has(result.toolUseId))
|
|
791
|
+
setCapped(earlyResults, result.toolUseId, { at, endStatus: foregroundEndStatus(result) }, MAX_PENDING_SPAWNS);
|
|
792
|
+
continue;
|
|
793
|
+
}
|
|
794
|
+
foregroundSpawns.delete(result.toolUseId);
|
|
795
|
+
const spawnedChange = started.get(spawned);
|
|
796
|
+
if (spawnedChange !== undefined)
|
|
797
|
+
endActivity(spawnedChange, { at, endStatus: foregroundEndStatus(result) });
|
|
798
|
+
}
|
|
799
|
+
}
|
|
800
|
+
if (raw.type === "assistant")
|
|
801
|
+
for (const id of spawnCallIdsOf(raw))
|
|
802
|
+
setCapped(spawnCallIds, id, true, MAX_PENDING_SPAWNS);
|
|
803
|
+
const change = started.get(meta.source);
|
|
804
|
+
if (!change)
|
|
805
|
+
return;
|
|
806
|
+
// DX-5333 — a follow-up delivered as a queued command.
|
|
807
|
+
const queued = queuedCommandText(raw);
|
|
808
|
+
if (queued !== null)
|
|
809
|
+
message(raw, change, "followup", queued, at);
|
|
810
|
+
// DX-5333: a line dated after the spawn's result is a resumed turn; what was held before it was the final of the turn that returned.
|
|
811
|
+
const ended = endedAwaitingPoll.get(change.activityId);
|
|
812
|
+
if (ended !== undefined && at > ended.at)
|
|
813
|
+
endedAwaitingPoll.delete(change.activityId);
|
|
814
|
+
settlePendingReport(raw, change);
|
|
815
|
+
if (raw.type !== "assistant" && raw.type !== "user")
|
|
816
|
+
return;
|
|
817
|
+
let changed = false;
|
|
818
|
+
// DX-2954 — a REPLAY counts as activity here, unlike the stall detector's
|
|
819
|
+
// liveness signal. The two ask different questions: the stall detector asks
|
|
820
|
+
// "is the agent alive NOW" (a re-read proves nothing), while this stamp
|
|
821
|
+
// answers "when did this sub-agent last actually say something", which a
|
|
822
|
+
// re-read line genuinely reports.
|
|
823
|
+
//
|
|
824
|
+
// Which is exactly why the stamp is the LINE's OWN timestamp and never a
|
|
825
|
+
// wall clock. Stamping `Date.now()` would make a reattach reset every
|
|
826
|
+
// pre-existing sub-agent's last activity to the moment the collector
|
|
827
|
+
// restarted: the whole replay window would report as fresh, and a sub-agent
|
|
828
|
+
// silent for half an hour would come out of it looking live, so
|
|
829
|
+
// `RUNNING_ACTIVITY_SILENT_AFTER_MS` could never fire for it. That is the
|
|
830
|
+
// one signal this feature has for "ended or stuck" (DX-3246 fixed the same
|
|
831
|
+
// wall-clock-for-entry-time confusion in the boot mark). Even with no
|
|
832
|
+
// replay involved, a wall clock records when the 5s poll NOTICED the line,
|
|
833
|
+
// not when the agent wrote it.
|
|
834
|
+
//
|
|
835
|
+
// GREATEST, mirroring the server: a line read out of order, or an older
|
|
836
|
+
// line inside a replay window, never drags liveness backwards — nor the
|
|
837
|
+
// effort and tool call that ride it.
|
|
838
|
+
const newest = at >= (newestLineAt.get(meta.source) ?? 0);
|
|
839
|
+
if (newest)
|
|
840
|
+
newestLineAt.set(meta.source, at);
|
|
841
|
+
if (at > change.lastActivityAt) {
|
|
842
|
+
change.lastActivityAt = at;
|
|
843
|
+
changed = true;
|
|
844
|
+
}
|
|
845
|
+
// DX-5333 — the checkout the sub-agent started in, read from its first line that names a git checkout.
|
|
846
|
+
stampStartSha(raw, change);
|
|
847
|
+
if (raw.type === "user") {
|
|
848
|
+
// DX-5333 — the brief (the chain's root line) and every later message sent to it; a tool result is neither.
|
|
849
|
+
const text = userText(raw);
|
|
850
|
+
if (text !== null && (raw.isMeta !== true || isCoordinatorLine(raw)))
|
|
851
|
+
message(raw, change, raw.parentUuid === null ? "brief" : "followup", text, at);
|
|
852
|
+
// DX-5333 — the answer to a problem-create call this sub-agent made.
|
|
853
|
+
for (const result of toolResults(raw)) {
|
|
854
|
+
const source = pendingProblemCalls.get(result.toolUseId);
|
|
855
|
+
if (source === undefined)
|
|
856
|
+
continue;
|
|
857
|
+
pendingProblemCalls.delete(result.toolUseId);
|
|
858
|
+
const problemRefNum = createdProblemRefNum(result.text);
|
|
859
|
+
if (problemRefNum !== null)
|
|
860
|
+
deps.recordProblemLinks?.([{ activityId: source, problemRefNum }]);
|
|
861
|
+
}
|
|
862
|
+
// DX-4498 — the brief is the chain's root line (`parentUuid: null`), so a collector that resumes mid-file
|
|
863
|
+
// never mistakes a later prompt for it. DX-4508 — read only while no card is known: a description's id wins.
|
|
864
|
+
// DX-5898 - the profile word is read from the same root line, once.
|
|
865
|
+
if (change.briefProfile === undefined && raw.parentUuid === null && text !== null) {
|
|
866
|
+
change.briefProfile = leadingProfileWord(text);
|
|
867
|
+
changed = true;
|
|
868
|
+
}
|
|
869
|
+
if (change.briefIssueId === null && raw.parentUuid === null) {
|
|
870
|
+
const id = text === null ? null : firstIssueId(text);
|
|
871
|
+
if (id !== null) {
|
|
872
|
+
change.briefIssueId = id;
|
|
873
|
+
changed = true;
|
|
874
|
+
}
|
|
875
|
+
}
|
|
876
|
+
}
|
|
877
|
+
else {
|
|
878
|
+
// DX-5333 — a reply that ended the sub-agent's turn is its report; a problem-create call is remembered until answered.
|
|
879
|
+
const text = assistantText(raw);
|
|
880
|
+
if (text !== "") {
|
|
881
|
+
if (raw.message?.stop_reason === "end_turn") {
|
|
882
|
+
pendingReports.delete(change.activityId);
|
|
883
|
+
message(raw, change, "final", text, at);
|
|
884
|
+
}
|
|
885
|
+
else if (!hasToolUse(raw) && raw.isApiErrorMessage !== true) {
|
|
886
|
+
// DX-5333 — never a synthetic API-error line (`isApiErrorMessage`, model `<synthetic>`): Claude Code's notice, not a reply.
|
|
887
|
+
pendingReports.set(change.activityId, { raw, text, at, messageId: assistantMessageId(raw) });
|
|
888
|
+
}
|
|
889
|
+
}
|
|
890
|
+
for (const id of problemCreateCallIds(raw)) {
|
|
891
|
+
setCapped(pendingProblemCalls, id, change.activityId, MAX_PENDING_PROBLEM_CALLS);
|
|
892
|
+
}
|
|
893
|
+
}
|
|
894
|
+
if (raw.type === "assistant" && newest) {
|
|
895
|
+
if (typeof raw.effort === "string" && raw.effort !== "" && raw.effort !== change.effort) {
|
|
896
|
+
change.effort = raw.effort;
|
|
897
|
+
changed = true;
|
|
898
|
+
}
|
|
899
|
+
const content = raw.message?.content;
|
|
900
|
+
const toolUses = Array.isArray(content)
|
|
901
|
+
? content.filter((b) => b?.type === "tool_use" && typeof b.name === "string")
|
|
902
|
+
: [];
|
|
903
|
+
const last = toolUses.at(-1);
|
|
904
|
+
if (last !== undefined) {
|
|
905
|
+
const input = last.input !== null && typeof last.input === "object" ? last.input : {};
|
|
906
|
+
const activity = describeToolCall(last.name, input);
|
|
907
|
+
if (activity !== change.currentActivity) {
|
|
908
|
+
change.currentActivity = activity;
|
|
909
|
+
changed = true;
|
|
910
|
+
}
|
|
911
|
+
}
|
|
912
|
+
}
|
|
913
|
+
if (changed)
|
|
914
|
+
report(change);
|
|
915
|
+
},
|
|
916
|
+
};
|
|
917
|
+
}
|