wavework 0.0.0-stage → 0.1.1
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/CHANGELOG.md +34 -0
- package/LICENSE +21 -0
- package/README.md +144 -2
- package/bin/wavework.mjs +7 -0
- package/examples/shipir.wavework.json +54 -0
- package/package.json +35 -4
- package/src/cli/args.mjs +28 -0
- package/src/cli/dashboard.mjs +41 -0
- package/src/cli/diff-stat.mjs +41 -0
- package/src/cli/hooks.mjs +109 -0
- package/src/cli/init.mjs +104 -0
- package/src/cli/safe-path.mjs +63 -0
- package/src/cli/sync.mjs +302 -0
- package/src/cli/update.mjs +24 -0
- package/src/cli.mjs +78 -0
- package/src/config.mjs +313 -0
- package/src/context.mjs +133 -0
- package/src/render.mjs +148 -0
- package/src/targets.mjs +100 -0
- package/templates/agents/implementer.md +16 -0
- package/templates/dashboard/README.md +96 -0
- package/templates/dashboard/hooks/session-start.mjs +305 -0
- package/templates/dashboard/hooks/subagent-stop.mjs +129 -0
- package/templates/dashboard/lib/ci.mjs +52 -0
- package/templates/dashboard/lib/derive.mjs +105 -0
- package/templates/dashboard/lib/settings.mjs +34 -0
- package/templates/dashboard/lib/tokens.mjs +21 -0
- package/templates/dashboard/pricing.json +11 -0
- package/templates/dashboard/public/app.css +134 -0
- package/templates/dashboard/public/app.js +260 -0
- package/templates/dashboard/public/default-theme.css +86 -0
- package/templates/dashboard/public/format.mjs +138 -0
- package/templates/dashboard/public/index.html +20 -0
- package/templates/dashboard/record.mjs +144 -0
- package/templates/dashboard/server.mjs +254 -0
- package/templates/dashboard-config.json +1 -0
- package/templates/scripts/red-green-check.sh +287 -0
- package/templates/scripts/red-green-check.test.sh +151 -0
- package/templates/settings/hooks.json +31 -0
- package/templates/skills/next-task/SKILL.md +77 -0
- package/templates/skills/next-task-batch/SKILL.md +27 -0
- package/templates/skills/next-task-beads/SKILL.md +29 -0
- package/templates/skills/next-task-branch/SKILL.md +19 -0
- package/templates/skills/next-task-code/SKILL.md +22 -0
- package/templates/skills/next-task-deps/SKILL.md +31 -0
- package/templates/skills/next-task-e2e/SKILL.md +35 -0
- package/templates/skills/next-task-findings/SKILL.md +65 -0
- package/templates/skills/next-task-finish/SKILL.md +43 -0
- package/templates/skills/next-task-implement/SKILL.md +34 -0
- package/templates/skills/next-task-plan/SKILL.md +52 -0
- package/templates/skills/next-task-review/SKILL.md +61 -0
- package/templates/skills/next-task-test/SKILL.md +30 -0
- package/templates/skills/next-task-write-tests/SKILL.md +34 -0
- package/templates/skills/text-to-task/SKILL.md +78 -0
- package/templates/skills/watch-ci/SKILL.md +42 -0
package/src/targets.mjs
ADDED
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Which template becomes which file in a target repository. Pure: no file system access.
|
|
3
|
+
*
|
|
4
|
+
* Contract (consumed by the init/update CLI):
|
|
5
|
+
*
|
|
6
|
+
* templateTargets(config) -> [{ template, target, vars, mode }]
|
|
7
|
+
* template path below `templates/` (always `/`-separated), e.g. `skills/next-task/SKILL.md`
|
|
8
|
+
* target path relative to the repository root, e.g. `.claude/skills/next-task/SKILL.md`
|
|
9
|
+
* vars extra template variables, merged OVER buildContext(config, options); `{}` for most templates
|
|
10
|
+
* mode file mode: 0o644 for text, 0o755 for scripts
|
|
11
|
+
* verbatim optional, `true`: copy the template byte for byte instead of rendering it (JavaScript/CSS
|
|
12
|
+
* that may contain braces). Only the dashboard runtime uses it.
|
|
13
|
+
*
|
|
14
|
+
* One entry per file to write, for ALL templates (skills, agents, scripts). The agent template
|
|
15
|
+
* `agents/implementer.md` appears once per implementer model (the keys of `models.implementers.rules`
|
|
16
|
+
* plus `models.implementers.default`), each with `vars.agent = { model, name, description, rule, isDefault, defaultName }` (`description` is a quoted YAML scalar).
|
|
17
|
+
* With `dashboard.enabled` there is one more entry per file of `templates/dashboard/` (DASHBOARD_FILES, verbatim,
|
|
18
|
+
* target `.claude/wavework/dashboard/<path>`) plus `.claude/wavework/dashboard/config.json` rendered from
|
|
19
|
+
* `templates/dashboard-config.json` (port, tokensFile). Disabled: none of them, so `update` removes them.
|
|
20
|
+
* Targets are unique. A model name that is not safe as a file name throws.
|
|
21
|
+
*
|
|
22
|
+
* renderTargets(config, options) -> [{ target, content, mode }]
|
|
23
|
+
* Renders every entry with buildContext(config, options) (same `options` as buildContext) plus `vars`.
|
|
24
|
+
*/
|
|
25
|
+
import { readFileSync } from 'node:fs';
|
|
26
|
+
import { fileURLToPath } from 'node:url';
|
|
27
|
+
import { buildContext, implementerAgents } from './context.mjs';
|
|
28
|
+
import { render } from './render.mjs';
|
|
29
|
+
|
|
30
|
+
export const SKILLS = [
|
|
31
|
+
'next-task', 'next-task-batch', 'next-task-beads', 'next-task-branch', 'next-task-code',
|
|
32
|
+
'next-task-deps', 'next-task-e2e', 'next-task-findings', 'next-task-finish', 'next-task-implement',
|
|
33
|
+
'next-task-plan', 'next-task-review', 'next-task-test', 'next-task-write-tests',
|
|
34
|
+
'text-to-task', 'watch-ci',
|
|
35
|
+
];
|
|
36
|
+
|
|
37
|
+
/** Where the dashboard runtime lives in a repository. Skills and hooks call it by this path (tests keep them in sync). */
|
|
38
|
+
export const DASHBOARD_DIR = '.claude/wavework/dashboard';
|
|
39
|
+
/** Files of templates/dashboard/ (test/targets.test.mjs checks the list against the directory). */
|
|
40
|
+
export const DASHBOARD_FILES = [
|
|
41
|
+
'README.md', 'pricing.json', 'record.mjs', 'server.mjs',
|
|
42
|
+
'hooks/session-start.mjs', 'hooks/subagent-stop.mjs',
|
|
43
|
+
'lib/ci.mjs', 'lib/derive.mjs', 'lib/settings.mjs', 'lib/tokens.mjs',
|
|
44
|
+
'public/app.css', 'public/app.js', 'public/default-theme.css', 'public/format.mjs', 'public/index.html',
|
|
45
|
+
];
|
|
46
|
+
|
|
47
|
+
const SCRIPTS = ['red-green-check.sh', 'red-green-check.test.sh'];
|
|
48
|
+
const AGENT_TEMPLATE = 'agents/implementer.md';
|
|
49
|
+
const SAFE_MODEL = /^[A-Za-z0-9][A-Za-z0-9._-]*$/;
|
|
50
|
+
|
|
51
|
+
/** A JSON string is a valid YAML double-quoted scalar, so rule texts with ": ", quotes or "#" cannot break the frontmatter. */
|
|
52
|
+
function agentDescription(rule) {
|
|
53
|
+
return JSON.stringify(rule
|
|
54
|
+
? `Implements a planned beads task. Scope: ${rule} Pass the bead ID.`
|
|
55
|
+
: 'Implements a planned beads task. Default implementer for work that no other model rule covers. Pass the bead ID.');
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function templateTargets(config) {
|
|
59
|
+
const entries = SKILLS.map((name) => ({
|
|
60
|
+
template: `skills/${name}/SKILL.md`,
|
|
61
|
+
target: `.claude/skills/${name}/SKILL.md`,
|
|
62
|
+
vars: {},
|
|
63
|
+
mode: 0o644,
|
|
64
|
+
}));
|
|
65
|
+
const agents = implementerAgents(config);
|
|
66
|
+
const defaultName = `implementer-${config.models.implementers.default}`;
|
|
67
|
+
for (const { model, rule, isDefault } of agents) {
|
|
68
|
+
if (!SAFE_MODEL.test(model)) throw new Error(`implementer model "${model}" cannot be used as an agent file name (allowed: letters, digits, ".", "_", "-")`);
|
|
69
|
+
entries.push({
|
|
70
|
+
template: AGENT_TEMPLATE,
|
|
71
|
+
target: `.claude/agents/implementer-${model}.md`,
|
|
72
|
+
vars: { agent: { model, name: `implementer-${model}`, description: agentDescription(rule), rule: rule ?? '', isDefault, defaultName } },
|
|
73
|
+
mode: 0o644,
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
for (const name of SCRIPTS) {
|
|
77
|
+
entries.push({ template: `scripts/${name}`, target: `.claude/scripts/${name}`, vars: {}, mode: 0o755 });
|
|
78
|
+
}
|
|
79
|
+
if (config.dashboard?.enabled) {
|
|
80
|
+
for (const name of DASHBOARD_FILES) {
|
|
81
|
+
entries.push({ template: `dashboard/${name}`, target: `${DASHBOARD_DIR}/${name}`, vars: {}, mode: 0o644, verbatim: true });
|
|
82
|
+
}
|
|
83
|
+
const settings = { port: config.dashboard.port, tokensFile: config.dashboard.tokensFile ?? null };
|
|
84
|
+
entries.push({
|
|
85
|
+
template: 'dashboard-config.json',
|
|
86
|
+
target: `${DASHBOARD_DIR}/config.json`,
|
|
87
|
+
vars: { dashboardConfigJson: JSON.stringify(settings, null, 2) },
|
|
88
|
+
mode: 0o644,
|
|
89
|
+
});
|
|
90
|
+
}
|
|
91
|
+
return entries;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export function renderTargets(config, options = {}) {
|
|
95
|
+
const context = buildContext(config, options);
|
|
96
|
+
return templateTargets(config).map(({ template, target, vars, mode, verbatim }) => {
|
|
97
|
+
const text = readFileSync(fileURLToPath(new URL(`../templates/${template}`, import.meta.url)), 'utf8');
|
|
98
|
+
return { target, content: verbatim === true ? text : render(text, { ...context, ...vars }, template), mode };
|
|
99
|
+
});
|
|
100
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: {{agent.name}}
|
|
3
|
+
description: {{agent.description}}
|
|
4
|
+
model: {{agent.model}}
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
You implement exactly one planned beads task in this codebase. You do not plan or redesign.
|
|
8
|
+
|
|
9
|
+
Follow `.claude/skills/next-task-implement/SKILL.md` step by step: check you are in the worktree and on the branch your prompt names (never switch branches), claim the bead, {{#if codeMap}}grep `{{codeMap.path}}` to find files (only when needed), {{/if}}write tests first, implement, e2e where a setup exists, run focused tests via `.claude/skills/next-task-test/SKILL.md`, {{#if codeMap}}update your `{{codeMap.path}}` lines, {{/if}}report out-of-scope findings.
|
|
10
|
+
|
|
11
|
+
Do not commit, push or close the bead: whoever delegated the task verifies your work, commits and closes it. Report back as the skill describes.
|
|
12
|
+
{{#if agent.isDefault}}
|
|
13
|
+
{{else}}
|
|
14
|
+
|
|
15
|
+
If the task turns out larger or riskier than described, or outside the scope in your description, stop and report back so it can go to {{agent.defaultName}}.
|
|
16
|
+
{{/if}}
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
# Pipeline dashboard
|
|
2
|
+
|
|
3
|
+
Installed by `wavework init` / `wavework update` into `.claude/wavework/dashboard/` (managed by the lock file: do not edit, change `dashboard` in `.claude/wavework.json` instead). Dev tooling (Node >= 22, built-ins only, no node_modules) that records and shows `/next-task*` runs. This folder holds the recorder (`record.mjs`), the SubagentStop and SessionStart hooks (`hooks/`), the server (`server.mjs`, `lib/`), the UI (`public/`) and `config.json` (port and tokensFile, written from the config).
|
|
4
|
+
|
|
5
|
+
## Store
|
|
6
|
+
|
|
7
|
+
`$(git rev-parse --git-common-dir)/pipeline-runs/<run-id>/`, shared by every worktree of the repo (never committed, lives inside `.git`). Override with `WAVEWORK_PIPELINE_STORE=<dir>` (used by tests).
|
|
8
|
+
|
|
9
|
+
- `run.json`: summary, replaced atomically (write temp file, rename).
|
|
10
|
+
- `events.jsonl`: append-only, one JSON object per line, written with `O_APPEND` so parallel sub-agents do not interleave lines.
|
|
11
|
+
|
|
12
|
+
`run.json` fields: `id`, `kind` (`next-task|beads|batch`), `units` (epic/bead ids), `parent` (run id of the batch, for batch members), `branch`, `worktree`, `status` (`running|done|blocked|failed`), `started`, `ended` (ISO timestamps, `ended` is null while running).
|
|
13
|
+
|
|
14
|
+
## CLI
|
|
15
|
+
|
|
16
|
+
```
|
|
17
|
+
node .claude/wavework/dashboard/record.mjs <event> [--run <id>] [--key value ...]
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
Without `--run` the run is found by cwd: the running run whose `worktree` contains the current directory (deepest worktree wins). `--units a,b` is a list, known numeric keys are stored as numbers, `--some-key` becomes `someKey`. Run ids match `[A-Za-z0-9][A-Za-z0-9._-]*` and are at most 128 characters. `readRun` applies the same check and returns null for anything else, so URL params can be passed in safely.
|
|
21
|
+
|
|
22
|
+
Recording never blocks the pipeline: any error prints one `pipeline-record: ...` line to stderr and the exit code is 0.
|
|
23
|
+
|
|
24
|
+
## Events
|
|
25
|
+
|
|
26
|
+
Every event has `ts` (ISO), `type` and `run`, plus:
|
|
27
|
+
|
|
28
|
+
| type | fields |
|
|
29
|
+
| --- | --- |
|
|
30
|
+
| `run-start` | `kind`, `units`, `parent`, `branch`, `worktree` (creates `run.json`) |
|
|
31
|
+
| `run-end` | `status` (`done` default, `blocked`, `failed`; updates `run.json`) |
|
|
32
|
+
| `step-start`, `step-end` | `step` (`deps`, `plan`, `wave`, `review`, `finish`, `push`), `wave` (on `wave` steps; start and end are paired by step + wave), `sha` (on `step-end --step push`) |
|
|
33
|
+
| `child-start`, `child-end` | `bead`, `model`, `wave`, `result`, `redBeforeGreen` (evidence) |
|
|
34
|
+
| `review` | `verdict`, `mustFix` (count), `round` |
|
|
35
|
+
| `tests` | `scope`, `passed`, `failed`, `duration` |
|
|
36
|
+
| `finding` | `bead` (id of the finding bead) |
|
|
37
|
+
| `agent` | `agentId`, `agentType`, `bead` (from the first user prompt, after the word "bead"; absent if none), `model`, `durationMs`, `tokensIn`, `tokensOut`, `tokensCacheRead`, `tokensCacheWrite`, `cost` (estimate); written by the SubagentStop hook |
|
|
38
|
+
|
|
39
|
+
Other keys passed on the command line are stored as given, except `ts`, `type` and `run`, which are always set by the recorder.
|
|
40
|
+
|
|
41
|
+
`run-start` for an existing id rewrites `run.json` from scratch (status back to `running`); events stay appended. `run-end` without a prior `run-start` creates a minimal `run.json` (`started` null).
|
|
42
|
+
|
|
43
|
+
## Agent events and pricing
|
|
44
|
+
|
|
45
|
+
The `agent` events come from the SubagentStop hook (`hooks/subagent-stop.mjs`, registered in `.claude/settings.json` by wavework). Claude Code passes `cwd`, `agent_id`, `agent_type` and `agent_transcript_path` on stdin. The hook reads the sub-agent transcript: usage is taken from assistant messages (`message.usage`, `message.model`), deduplicated by message id (streamed content blocks repeat the id; the max per field wins), duration is last minus first timestamp. The run is found by cwd (falling back to the cwd recorded in the transcript); no running run means nothing is written. The hook always exits 0 and prints nothing to stdout.
|
|
46
|
+
|
|
47
|
+
`pricing.json` holds USD per million tokens (`input`, `output`, `cacheRead`, `cacheWrite`) per model id. The cost is the sum over messages, each priced with its own model. If any message's model is missing from the table, the event has no `cost` field (never 0). Prices are estimates: cache write is the 5-minute rate (1.25x input), and Haiku uses the rate card for prompts up to 100K tokens.
|
|
48
|
+
|
|
49
|
+
## Importing
|
|
50
|
+
|
|
51
|
+
`record.mjs` exports `resolveStoreDir`, `findRunByCwd`, `recordEvent`, `readRun`, `listRuns`, `parseArgs`, `main`, `EVENT_TYPES`, `RUN_ID`, so the server and hooks reuse store resolution and lookup.
|
|
52
|
+
|
|
53
|
+
## SessionStart hook
|
|
54
|
+
|
|
55
|
+
`hooks/session-start.mjs` is registered in `.claude/settings.json` by wavework. At session start it checks `GET /api/health` on the dashboard port (`PIPELINE_DASHBOARD_PORT`, else `config.json`, else 4690) (300 ms timeout):
|
|
56
|
+
|
|
57
|
+
- Health answers `{"ok":true, "root":"<dir>"}` and `root` exists: prints nothing.
|
|
58
|
+
- Health answers but its `root` (the checkout `server.mjs` runs from) no longer exists, e.g. a deleted worktree: stops that server via `<store>/server.pid` and starts a fresh one. The pid file is `{"pid","port","root"}`. The hook kills the recorded pid only when port and root match the answering server and `lsof` lists that pid as the single listener on the port while `ps` shows that record's own `<root>/server.mjs` (so another repo's dashboard is never touched) (a recorded pid alone is not proof, pids get reused). If the record does not match or either check cannot run, it prints one warning and kills nothing.
|
|
59
|
+
- Nothing listens: starts `server.mjs` detached (`stdio` to `<store>/server.log`, pid file `<store>/server.pid`) and prints `{"systemMessage":"Pipeline dashboard: http://127.0.0.1:<port>"}` once it answers. The server runs from the main checkout's `.claude/wavework/dashboard/server.mjs` (main checkout = parent of `git rev-parse --path-format=absolute --git-common-dir`) when that exists, else from the copy the hook belongs to. So the long-lived server does not depend on a worktree that `/next-task-finish` later deletes.
|
|
60
|
+
- Another process holds the port: prints one `systemMessage` warning and starts nothing.
|
|
61
|
+
- Nothing answers within the start wait (2 s) after the spawn: prints `{"systemMessage":"Pipeline dashboard: could not start, see <store>/server.log"}`. The spawned pid is recorded at spawn, so a server that is still starting stays stoppable.
|
|
62
|
+
|
|
63
|
+
`PIPELINE_DASHBOARD_START_WAIT_MS` overrides the start wait. It is a test-only knob (tests set it near zero to force the "gave up waiting" path); do not set it in normal use.
|
|
64
|
+
|
|
65
|
+
A pid record is not overwritten while it names a live server on the same port, and a spawned child clears the record only if it exits without ever answering health.
|
|
66
|
+
|
|
67
|
+
Two sessions starting together start one server: an exclusive `<store>/server.starting` lock lets only one hook spawn; the other waits for the server. A lock older than 5 s (crashed hook) is taken over by renaming it to a unique name first (rename is atomic, so only one hook moves a given lock); a fresh lock moved by mistake is linked back.
|
|
68
|
+
|
|
69
|
+
Timing: the hook always exits 0 and has a worst case of about 1.5 s on the stale-server replace path (probe 300 ms, `lsof`/`ps` checks with a 500 ms timeout each, stop wait 250 ms, start wait 600 ms, probes of 150 ms, plus node start-up). Output format: `systemMessage` on stdout JSON, per the Claude Code hooks docs. The 5 s `timeout` in `.claude/settings.json` is only a hard kill.
|
|
70
|
+
|
|
71
|
+
## Server/API
|
|
72
|
+
|
|
73
|
+
`wavework dashboard` (or `node .claude/wavework/dashboard/server.mjs`) binds `127.0.0.1` only, port `PIPELINE_DASHBOARD_PORT`, else `config.json` (`dashboard.port`), else 4690, reads the store via `resolveStoreDir`. `createServer({storeDir, port, publicDir, tokensPath, project, ghRunner, ciRefreshMs, watch, pollMs})` is the factory used by tests (`port: 0`, stubbed `ghRunner`; `await server.listen()`, `server.port`, `await server.close()`).
|
|
74
|
+
|
|
75
|
+
- `GET /api/health`: `{ok:true, root}`; `root` is the directory `server.mjs` runs from (the SessionStart hook uses it to spot a server whose checkout is gone).
|
|
76
|
+
- `GET /api/runs`: run summaries (`run.json` fields + `totals`), newest `started` first; batch members (runs whose `parent` is another run) are nested in the batch's `children`.
|
|
77
|
+
- `GET /api/runs/:id`: `{run, events, timeline, ci}`. `timeline` = `steps` (paired step-start/end with `durationMs`, `sha` on push), `children` (per bead: model, wave, result, `redBeforeGreen`, `durationMs`, `agents` summed from `agent` events by `bead`, else by the child's time window), `reviews`, `tests`, `totals` (tokens, `cost`, `agents`, `durationMs`; `cost` is null when there are no agents or any agent has no cost, never 0 or a partial sum), `sha`. Invalid id (not the run-id pattern, `../`, encoded `%2e%2e%2f`, `%2F`, NUL) is 400, unknown id 404.
|
|
78
|
+
- A batch that is still `running` but whose members have all ended is reported as ended (`blocked` if any member is blocked/failed, else `done`, `ended` = latest member end) in both endpoints.
|
|
79
|
+
- Requests need a `Host` of `127.0.0.1:<port>` or `localhost:<port>`, else 403 (DNS-rebinding guard).
|
|
80
|
+
- `GET /api/events`: Server-Sent Events, `event: run-changed` with `data: {"id": "<run id>"}` when a store file changes (`fs.watch` recursive as trigger, polling fallback, also if the store does not exist yet).
|
|
81
|
+
- `GET /api/info`: `{project}`, the repo directory name (the main checkout), shown in the UI.
|
|
82
|
+
- `/tokens.css` is the project's own tokens file (`dashboard.tokensFile`, resolved inside the repo, symlinks leaving it refused) or an empty stylesheet when none is configured; everything else is served from `public/` only (resolved, symlinks and traversal refused).
|
|
83
|
+
|
|
84
|
+
**CI status:** the pushed SHA is the `sha` field of the last `step-end` event with `step=push` (the skill emits `record.mjs step-end --step push --sha <sha>`). For such runs, `ci` is `{sha, state, runs[]}` from `gh run list --commit <sha> --json status,conclusion,url,name` (execFile with an argument list, SHA must be 7-40 hex chars). `state` is `success|failure|pending|none|unknown`; missing/failing `gh` gives `unknown`. Results are cached per SHA; only `success` is cached for good, everything else (pending, failure because CI may be re-run, none, unknown) is refreshed after `ciRefreshMs` (15 s). `ci` is null without a pushed SHA.
|
|
85
|
+
|
|
86
|
+
## UI
|
|
87
|
+
|
|
88
|
+
Open `http://127.0.0.1:4690/` (your port) after starting the server. Plain HTML/CSS/JS in `public/` (no build): `index.html`, `default-theme.css` (neutral tokens, light and dark), `app.css` (design tokens only, `prefers-reduced-motion`; the project's `/tokens.css` loads after the default theme and may override any token), `app.js` (hash routing, SSE) and `format.mjs` (pure helpers).
|
|
89
|
+
|
|
90
|
+
- `#/` lists all runs: running first, then newest; batch members are nested under their batch. It refreshes on every `run-changed` SSE event, no reload. The header shows `Live` or `Reconnecting`.
|
|
91
|
+
- `#/runs/<id>` is a deep link to a run: header stats, step timeline, children (model, wave, duration, tokens, cost, result, review verdict and rounds, red-before-green), reviews, tests, findings filed and CI links.
|
|
92
|
+
- Costs are estimates (marked as such); an unknown cost is shown as `—`, never `$0.00`.
|
|
93
|
+
- The list view fetches `/api/runs/:id` per run to get the CI state (the list endpoint has no `ci`).
|
|
94
|
+
- Untrusted strings are rendered with `textContent`; CI links must be http(s).
|
|
95
|
+
|
|
96
|
+
Tests live in the wavework package (`test/dashboard/`), not in the target repository.
|
|
@@ -0,0 +1,305 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// SessionStart hook: makes sure the pipeline dashboard server runs and tells the user its URL.
|
|
3
|
+
// Health answers from a live checkout -> silent. Port free -> start server.mjs detached (log in <store>/server.log)
|
|
4
|
+
// and print the URL. Port held by something else, or by a server whose checkout was deleted and is not the
|
|
5
|
+
// recorded one -> one warning. Always exits 0.
|
|
6
|
+
// Time budget (hard kill is the 5 s `timeout` in settings.json), worst case = stale-server replace path:
|
|
7
|
+
// probe 300 ms + lsof 800 + ps 800 + stop wait 250 + start wait 2000 + probes 150 + node start-up ~100 = about 4.4 s.
|
|
8
|
+
// Keep the sum under 5 s when changing any of these.
|
|
9
|
+
// Output format: https://code.claude.com/docs/en/hooks.md (systemMessage on stdout JSON; exit 0).
|
|
10
|
+
import { execFile, execFileSync, spawn } from 'node:child_process';
|
|
11
|
+
import { randomUUID } from 'node:crypto';
|
|
12
|
+
import { closeSync, existsSync, linkSync, mkdirSync, openSync, readFileSync, realpathSync, renameSync, rmSync, statSync, writeFileSync } from 'node:fs';
|
|
13
|
+
import http from 'node:http';
|
|
14
|
+
import path from 'node:path';
|
|
15
|
+
import { fileURLToPath } from 'node:url';
|
|
16
|
+
import { resolveStoreDir } from '../record.mjs';
|
|
17
|
+
import { resolvePort } from '../lib/settings.mjs';
|
|
18
|
+
|
|
19
|
+
const HERE = path.dirname(fileURLToPath(import.meta.url));
|
|
20
|
+
const OWN_SERVER = path.join(HERE, '..', 'server.mjs');
|
|
21
|
+
// Where the dashboard lives in a checkout (wavework init puts it there).
|
|
22
|
+
const DASHBOARD_REL = ['.claude', 'wavework', 'dashboard'];
|
|
23
|
+
const PROBE_MS = 300; // first health probe
|
|
24
|
+
const LOOP_PROBE_MS = 150; // probes while waiting for a started server
|
|
25
|
+
// How long a started server gets to answer. The env override is for tests (a near-zero wait forces "gave up waiting").
|
|
26
|
+
const WAIT_MS = Number(process.env.PIPELINE_DASHBOARD_START_WAIT_MS) > 0 ? Number(process.env.PIPELINE_DASHBOARD_START_WAIT_MS) : 2000;
|
|
27
|
+
const EXEC_MS = 800; // lsof / ps each (a loaded machine can be slow; see the budget above)
|
|
28
|
+
const STOP_WAIT_MS = 250; // how long a stopped stale server gets to release the port
|
|
29
|
+
const STALE_LOCK_MS = 5000; // a start lock this old belongs to a crashed hook
|
|
30
|
+
|
|
31
|
+
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
32
|
+
|
|
33
|
+
function emit(systemMessage) {
|
|
34
|
+
process.stdout.write(JSON.stringify({ systemMessage }) + '\n');
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const inUse = (port) => `Pipeline dashboard: port ${port} is in use by another process (set dashboard.port in .claude/wavework.json and run wavework update to change it)`;
|
|
38
|
+
const staleInUse = (port) => `Pipeline dashboard: port ${port} is in use by a server from a missing checkout that is not the recorded one; stop it to start the dashboard`;
|
|
39
|
+
const couldNotStart = (logFile) => `Pipeline dashboard: could not start, see ${logFile}`;
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Health probe. state: 'ok' (our health answers {"ok":true}), 'free' (connection refused),
|
|
43
|
+
* 'foreign' (something else answers, or accepts the connection but does not answer in time).
|
|
44
|
+
* root: the checkout directory the server reports, when it reports one.
|
|
45
|
+
*/
|
|
46
|
+
function probe(port, timeout) {
|
|
47
|
+
return new Promise((resolve) => {
|
|
48
|
+
const req = http.get({ host: '127.0.0.1', port, path: '/api/health', timeout, agent: false }, (res) => {
|
|
49
|
+
let body = '';
|
|
50
|
+
res.setEncoding('utf8');
|
|
51
|
+
res.on('data', (d) => { body += d; });
|
|
52
|
+
res.on('end', () => {
|
|
53
|
+
try {
|
|
54
|
+
const json = JSON.parse(body);
|
|
55
|
+
if (res.statusCode === 200 && json.ok === true) {
|
|
56
|
+
resolve({ state: 'ok', root: typeof json.root === 'string' ? json.root : null });
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
59
|
+
} catch { /* not our health answer */ }
|
|
60
|
+
resolve({ state: 'foreign', root: null });
|
|
61
|
+
});
|
|
62
|
+
res.on('error', () => resolve({ state: 'foreign', root: null }));
|
|
63
|
+
});
|
|
64
|
+
req.on('timeout', () => { req.destroy(); resolve({ state: 'foreign', root: null }); });
|
|
65
|
+
req.on('error', (e) => resolve({ state: e.code === 'ECONNREFUSED' ? 'free' : 'foreign', root: null }));
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const isMissingCheckout = (root) => typeof root === 'string' && !existsSync(root);
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
/** Runs a fixed program with an argument list (no shell). Resolves to stdout, or null when it cannot run or fails. */
|
|
73
|
+
function run(file, args) {
|
|
74
|
+
return new Promise((resolve) => {
|
|
75
|
+
execFile(file, args, { encoding: 'utf8', timeout: EXEC_MS }, (err, stdout) => resolve(err ? null : stdout));
|
|
76
|
+
});
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* True only when `pid` is the single listener on `port` and its command line is the dashboard server. A pid that is
|
|
81
|
+
* merely recorded (pids get reused) is not enough. Anything that cannot be checked (no lsof or ps, no listener) counts
|
|
82
|
+
* as not proven, so nothing is killed.
|
|
83
|
+
*/
|
|
84
|
+
/** True when `command` names `serverFile` as a whole argument (not merely as the tail of a longer path). */
|
|
85
|
+
export function commandNamesServer(command, serverFile) {
|
|
86
|
+
const escaped = serverFile.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
87
|
+
return new RegExp(`(^|\\s)${escaped}(\\s|$)`).test(command.trim());
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
async function isDashboardListener(pid, port, serverFile) {
|
|
91
|
+
const listing = await run('lsof', ['-nP', `-iTCP:${port}`, '-sTCP:LISTEN', '-t']);
|
|
92
|
+
if (listing === null) return false;
|
|
93
|
+
const pids = listing.split('\n').filter(Boolean).map(Number);
|
|
94
|
+
if (pids.length !== 1 || pids[0] !== pid) return false;
|
|
95
|
+
const command = await run('ps', ['-p', String(pid), '-o', 'command=']);
|
|
96
|
+
return command !== null && commandNamesServer(command, serverFile);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** The main checkout's directory (the common git dir's parent), or null outside git. */
|
|
100
|
+
function mainCheckout() {
|
|
101
|
+
try {
|
|
102
|
+
const common = execFileSync('git', ['rev-parse', '--path-format=absolute', '--git-common-dir'], {
|
|
103
|
+
cwd: process.cwd(), encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'],
|
|
104
|
+
}).trim();
|
|
105
|
+
return path.dirname(common);
|
|
106
|
+
} catch {
|
|
107
|
+
return null;
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Which server.mjs to run: the main checkout's when it has one, so the long-lived server does not depend on a
|
|
113
|
+
* worktree that a later /next-task-finish deletes; otherwise the copy this hook belongs to.
|
|
114
|
+
*/
|
|
115
|
+
function serverLocation() {
|
|
116
|
+
const main = mainCheckout();
|
|
117
|
+
const mainServer = main ? path.join(main, ...DASHBOARD_REL, 'server.mjs') : null;
|
|
118
|
+
if (mainServer && existsSync(mainServer)) return { server: mainServer, cwd: main, root: path.dirname(mainServer) };
|
|
119
|
+
return { server: OWN_SERVER, cwd: process.cwd(), root: path.dirname(OWN_SERVER) };
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** Exclusive create of the start lock. A lock older than STALE_LOCK_MS (crashed hook) is taken over. */
|
|
123
|
+
function acquireLock(lockFile) {
|
|
124
|
+
for (let attempt = 0; attempt < 2; attempt++) {
|
|
125
|
+
try {
|
|
126
|
+
return openSync(lockFile, 'wx');
|
|
127
|
+
} catch (e) {
|
|
128
|
+
if (e.code !== 'EEXIST') throw e;
|
|
129
|
+
if (!takeOverStaleLock(lockFile)) return null;
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
return null;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Removes a stale lock without racing other hooks: rename is atomic, so the lock file is moved to a unique name
|
|
137
|
+
* and only the hook that moved it inspects it. A fresh lock that was moved by mistake is linked back (link does
|
|
138
|
+
* not overwrite, so a lock someone else created meanwhile is never replaced). Returns true if a stale lock was removed.
|
|
139
|
+
*/
|
|
140
|
+
function takeOverStaleLock(lockFile) {
|
|
141
|
+
const parked = `${lockFile}.${process.pid}.${randomUUID()}`;
|
|
142
|
+
try {
|
|
143
|
+
renameSync(lockFile, parked);
|
|
144
|
+
} catch {
|
|
145
|
+
return false; // gone, or moved by another hook
|
|
146
|
+
}
|
|
147
|
+
let stale = false;
|
|
148
|
+
try {
|
|
149
|
+
stale = Date.now() - statSync(parked).mtimeMs >= STALE_LOCK_MS;
|
|
150
|
+
} catch { /* unreadable: treat as not ours */ }
|
|
151
|
+
if (!stale) {
|
|
152
|
+
try { linkSync(parked, lockFile); } catch { /* someone else created a lock meanwhile */ }
|
|
153
|
+
}
|
|
154
|
+
rmSync(parked, { force: true });
|
|
155
|
+
return stale;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
const readRecord = (pidFile) => {
|
|
159
|
+
try {
|
|
160
|
+
return JSON.parse(readFileSync(pidFile, 'utf8'));
|
|
161
|
+
} catch {
|
|
162
|
+
return null;
|
|
163
|
+
}
|
|
164
|
+
};
|
|
165
|
+
|
|
166
|
+
const isAlive = (pid) => {
|
|
167
|
+
try {
|
|
168
|
+
process.kill(pid, 0);
|
|
169
|
+
return true;
|
|
170
|
+
} catch (e) {
|
|
171
|
+
return e.code === 'EPERM';
|
|
172
|
+
}
|
|
173
|
+
};
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* Records the pid of a freshly spawned server, unless <store>/server.pid already names a live server on the same
|
|
177
|
+
* port (then that server stays recorded). Returns true when the record was written.
|
|
178
|
+
*/
|
|
179
|
+
export async function recordSpawnedPid(pidFile, record) {
|
|
180
|
+
const current = readRecord(pidFile);
|
|
181
|
+
if (current && current.port === record.port && Number.isInteger(current.pid) && isAlive(current.pid)
|
|
182
|
+
&& (await probe(record.port, LOOP_PROBE_MS)).state === 'ok') {
|
|
183
|
+
return false;
|
|
184
|
+
}
|
|
185
|
+
writeFileSync(pidFile, JSON.stringify(record));
|
|
186
|
+
return true;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* Spawns the detached server and records its pid at once, before any health wait: a server that is still starting
|
|
191
|
+
* when the hook stops waiting must stay stoppable (the next session or a test cleanup reads this record).
|
|
192
|
+
*/
|
|
193
|
+
async function startServer({ server, cwd, root }, { logFile, pidFile, port }) {
|
|
194
|
+
const logFd = openSync(logFile, 'a');
|
|
195
|
+
let child;
|
|
196
|
+
try {
|
|
197
|
+
child = spawn(process.execPath, [server], { cwd, env: { ...process.env, PIPELINE_DASHBOARD_PORT: String(port) }, detached: true, stdio: ['ignore', logFd, logFd] });
|
|
198
|
+
} finally {
|
|
199
|
+
closeSync(logFd);
|
|
200
|
+
}
|
|
201
|
+
const spawned = { child, exited: false, answered: false };
|
|
202
|
+
if (child.pid) await recordSpawnedPid(pidFile, { pid: child.pid, port, root });
|
|
203
|
+
// A child that exits clears only its own record, and only if it never answered health: a process that served
|
|
204
|
+
// is a real server, and its record is not this hook's to drop.
|
|
205
|
+
const forget = () => {
|
|
206
|
+
spawned.exited = true;
|
|
207
|
+
if (spawned.answered) return;
|
|
208
|
+
if (readRecord(pidFile)?.pid === child.pid) rmSync(pidFile, { force: true });
|
|
209
|
+
};
|
|
210
|
+
child.once('exit', forget);
|
|
211
|
+
child.once('error', forget);
|
|
212
|
+
child.unref();
|
|
213
|
+
return spawned;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/** Probes until the server answers ok, the spawned child exits, or WAIT_MS passes. Returns the last probe. */
|
|
217
|
+
async function waitForServer(port, spawned) {
|
|
218
|
+
const deadline = Date.now() + WAIT_MS;
|
|
219
|
+
for (;;) {
|
|
220
|
+
const last = await probe(port, LOOP_PROBE_MS);
|
|
221
|
+
if (last.state === 'ok') {
|
|
222
|
+
if (spawned) spawned.answered = true;
|
|
223
|
+
return { state: 'ok', last };
|
|
224
|
+
}
|
|
225
|
+
if (spawned?.exited || Date.now() >= deadline) return { state: 'failed', last };
|
|
226
|
+
await sleep(100);
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/**
|
|
231
|
+
* Stops the server recorded in <store>/server.pid, but only when the record matches the answering server exactly
|
|
232
|
+
* (same port and same checkout root). Returns true once the port is free.
|
|
233
|
+
*/
|
|
234
|
+
async function stopRecordedServer(pidFile, port, root) {
|
|
235
|
+
const record = readRecord(pidFile);
|
|
236
|
+
if (!record || record.port !== port || record.root !== root || !Number.isInteger(record.pid) || record.pid < 1) return false;
|
|
237
|
+
// The command line must name this record's own server file (<root>/server.mjs): another repo's dashboard, or
|
|
238
|
+
// a developer's own, never matches, so one repo's hook can only ever stop the server it started itself.
|
|
239
|
+
if (!(await isDashboardListener(record.pid, port, path.join(String(record.root), 'server.mjs')))) return false;
|
|
240
|
+
try {
|
|
241
|
+
process.kill(record.pid, 'SIGTERM');
|
|
242
|
+
} catch {
|
|
243
|
+
return false;
|
|
244
|
+
}
|
|
245
|
+
const deadline = Date.now() + STOP_WAIT_MS;
|
|
246
|
+
while (Date.now() < deadline) {
|
|
247
|
+
if ((await probe(port, LOOP_PROBE_MS)).state === 'free') {
|
|
248
|
+
rmSync(pidFile, { force: true });
|
|
249
|
+
return true;
|
|
250
|
+
}
|
|
251
|
+
await sleep(50);
|
|
252
|
+
}
|
|
253
|
+
return false;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
async function main() {
|
|
257
|
+
const port = resolvePort(path.join(HERE, '..'));
|
|
258
|
+
if (!Number.isInteger(port) || port < 1 || port > 65535) return;
|
|
259
|
+
const url = `http://127.0.0.1:${port}`;
|
|
260
|
+
|
|
261
|
+
const first = await probe(port, PROBE_MS);
|
|
262
|
+
if (first.state === 'foreign') return emit(inUse(port));
|
|
263
|
+
if (first.state === 'ok' && !isMissingCheckout(first.root)) return;
|
|
264
|
+
|
|
265
|
+
const storeDir = resolveStoreDir({ cwd: process.cwd() });
|
|
266
|
+
mkdirSync(storeDir, { recursive: true });
|
|
267
|
+
const pidFile = path.join(storeDir, 'server.pid');
|
|
268
|
+
const logFile = path.join(storeDir, 'server.log');
|
|
269
|
+
if (first.state === 'ok') {
|
|
270
|
+
// Healthy server whose checkout was deleted: replace it, but only the process we recorded for this port and root.
|
|
271
|
+
if (!(await stopRecordedServer(pidFile, port, first.root))) return emit(staleInUse(port));
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
// Only the session holding the lock spawns; a concurrent session waits for that server instead.
|
|
275
|
+
const lockFile = path.join(storeDir, 'server.starting');
|
|
276
|
+
const lock = acquireLock(lockFile);
|
|
277
|
+
if (lock === null) {
|
|
278
|
+
const r = await waitForServer(port, null);
|
|
279
|
+
if (r.state === 'ok') return emit(`Pipeline dashboard: ${url}`);
|
|
280
|
+
return emit(r.last.state === 'foreign' ? inUse(port) : couldNotStart(logFile));
|
|
281
|
+
}
|
|
282
|
+
let r;
|
|
283
|
+
try {
|
|
284
|
+
const spawned = await startServer(serverLocation(), { logFile, pidFile, port });
|
|
285
|
+
r = await waitForServer(port, spawned);
|
|
286
|
+
if (r.state === 'ok') return emit(`Pipeline dashboard: ${url}`);
|
|
287
|
+
} finally {
|
|
288
|
+
closeSync(lock);
|
|
289
|
+
rmSync(lockFile, { force: true });
|
|
290
|
+
}
|
|
291
|
+
emit(r.last.state === 'foreign' ? inUse(port) : couldNotStart(logFile));
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
// Runs only as the CLI entry (tests import the helpers). Let the process end on its own (no process.exit, so stdout
|
|
295
|
+
// is never cut short); nothing here keeps the loop alive.
|
|
296
|
+
const isEntry = (() => {
|
|
297
|
+
try {
|
|
298
|
+
return realpathSync(process.argv[1]) === realpathSync(fileURLToPath(import.meta.url));
|
|
299
|
+
} catch {
|
|
300
|
+
return false;
|
|
301
|
+
}
|
|
302
|
+
})();
|
|
303
|
+
if (isEntry) {
|
|
304
|
+
main().catch(() => { /* never fail the session */ }).finally(() => { process.exitCode = 0; });
|
|
305
|
+
}
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// SubagentStop hook: appends one `agent` event (duration, tokens, estimated cost) to the active pipeline run.
|
|
3
|
+
// Input (stdin JSON, Claude Code hooks docs): cwd, agent_id, agent_type, agent_transcript_path.
|
|
4
|
+
// Never blocks or fails the session: exit 0 always, nothing on stdout.
|
|
5
|
+
import { readFileSync } from 'node:fs';
|
|
6
|
+
import path from 'node:path';
|
|
7
|
+
import { fileURLToPath } from 'node:url';
|
|
8
|
+
import { findRunByCwd, recordEvent, resolveStoreDir } from '../record.mjs';
|
|
9
|
+
|
|
10
|
+
const PRICING_FILE = path.join(path.dirname(fileURLToPath(import.meta.url)), '..', 'pricing.json');
|
|
11
|
+
const TOKEN_FIELDS = {
|
|
12
|
+
input: 'input_tokens', output: 'output_tokens',
|
|
13
|
+
cacheRead: 'cache_read_input_tokens', cacheWrite: 'cache_creation_input_tokens',
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
export function loadPricing(file = PRICING_FILE) {
|
|
17
|
+
try { return JSON.parse(readFileSync(file, 'utf8')).models ?? {}; } catch { return {}; }
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const BEAD = /\bbead\b\s*:?\s*([a-z][a-z0-9]*-[a-z0-9]+(?:\.[0-9]+)*)\b/i;
|
|
21
|
+
|
|
22
|
+
function promptText(content) {
|
|
23
|
+
if (typeof content === 'string') return content;
|
|
24
|
+
if (Array.isArray(content)) return content.map((c) => (typeof c?.text === 'string' ? c.text : '')).join('\n');
|
|
25
|
+
return '';
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const num = (v) => (Number.isFinite(v) && v >= 0 ? v : 0);
|
|
29
|
+
|
|
30
|
+
/** Parses a sub-agent transcript (JSONL). Bad lines are skipped. Returns the event fields and the transcript cwd. */
|
|
31
|
+
export function summarizeTranscript(text, pricing = loadPricing()) {
|
|
32
|
+
const messages = new Map(); // message id -> { model, usage } (streamed lines repeat the id: keep the max per field)
|
|
33
|
+
let first = null;
|
|
34
|
+
let last = null;
|
|
35
|
+
let cwd = null;
|
|
36
|
+
let anon = 0;
|
|
37
|
+
let bead = null;
|
|
38
|
+
let seenPrompt = false;
|
|
39
|
+
for (const line of text.split('\n')) {
|
|
40
|
+
if (!line.trim()) continue;
|
|
41
|
+
let o;
|
|
42
|
+
try { o = JSON.parse(line); } catch { continue; }
|
|
43
|
+
if (!o || typeof o !== 'object') continue;
|
|
44
|
+
const t = Date.parse(o.timestamp);
|
|
45
|
+
if (Number.isFinite(t)) {
|
|
46
|
+
if (first === null || t < first) first = t;
|
|
47
|
+
if (last === null || t > last) last = t;
|
|
48
|
+
}
|
|
49
|
+
if (!cwd && typeof o.cwd === 'string') cwd = o.cwd;
|
|
50
|
+
const m = o.message;
|
|
51
|
+
if (o.type === 'user' && !seenPrompt && m) {
|
|
52
|
+
seenPrompt = true; // only the first user prompt names the bead
|
|
53
|
+
bead = BEAD.exec(promptText(m.content))?.[1] ?? null;
|
|
54
|
+
}
|
|
55
|
+
if (o.type !== 'assistant' || !m || typeof m.usage !== 'object' || m.usage === null) continue;
|
|
56
|
+
const id = typeof m.id === 'string' ? m.id : `anon-${anon++}`;
|
|
57
|
+
const cur = messages.get(id) ?? { model: null, usage: {} };
|
|
58
|
+
if (typeof m.model === 'string' && m.model && m.model !== '<synthetic>') cur.model = m.model;
|
|
59
|
+
for (const f of Object.values(TOKEN_FIELDS)) cur.usage[f] = Math.max(num(cur.usage[f]), num(m.usage[f]));
|
|
60
|
+
messages.set(id, cur);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const fields = {};
|
|
64
|
+
if (first !== null) fields.durationMs = last - first;
|
|
65
|
+
if (bead) fields.bead = bead;
|
|
66
|
+
if (messages.size === 0) return { fields, cwd };
|
|
67
|
+
|
|
68
|
+
const totals = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 };
|
|
69
|
+
let cost = 0;
|
|
70
|
+
let priced = true;
|
|
71
|
+
let model = null;
|
|
72
|
+
for (const msg of messages.values()) {
|
|
73
|
+
for (const [k, f] of Object.entries(TOKEN_FIELDS)) totals[k] += msg.usage[f] ?? 0;
|
|
74
|
+
if (msg.model) model = msg.model;
|
|
75
|
+
if (Object.values(msg.usage).every((v) => v === 0)) continue; // zero usage costs nothing, whatever its model
|
|
76
|
+
const p = msg.model ? pricing[msg.model] : null;
|
|
77
|
+
if (!p) { priced = false; continue; }
|
|
78
|
+
for (const k of Object.keys(totals)) cost += ((msg.usage[TOKEN_FIELDS[k]] ?? 0) * num(p[k])) / 1e6;
|
|
79
|
+
}
|
|
80
|
+
if (model) fields.model = model;
|
|
81
|
+
Object.assign(fields, {
|
|
82
|
+
tokensIn: totals.input, tokensOut: totals.output,
|
|
83
|
+
tokensCacheRead: totals.cacheRead, tokensCacheWrite: totals.cacheWrite,
|
|
84
|
+
});
|
|
85
|
+
if (priced) fields.cost = Number(cost.toFixed(6)); // unknown model: no cost field, never 0
|
|
86
|
+
return { fields, cwd };
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** Handles one hook input. Returns the recorded event or null (no-op). May throw; main() swallows. */
|
|
90
|
+
export function handle(input, { env = process.env, pricing = loadPricing() } = {}) {
|
|
91
|
+
if (!input || typeof input !== 'object' || Array.isArray(input)) return null;
|
|
92
|
+
const file = input.agent_transcript_path;
|
|
93
|
+
if (typeof file !== 'string' || !file) return null;
|
|
94
|
+
const { fields, cwd: transcriptCwd } = summarizeTranscript(readFileSync(file, 'utf8'), pricing);
|
|
95
|
+
|
|
96
|
+
let run = null;
|
|
97
|
+
let storeDir = null;
|
|
98
|
+
for (const cwd of [input.cwd, transcriptCwd]) {
|
|
99
|
+
if (typeof cwd !== 'string' || !cwd) continue;
|
|
100
|
+
try {
|
|
101
|
+
storeDir = resolveStoreDir({ cwd, env });
|
|
102
|
+
run = findRunByCwd(cwd, { storeDir });
|
|
103
|
+
} catch { run = null; }
|
|
104
|
+
if (run) break;
|
|
105
|
+
}
|
|
106
|
+
if (!run) return null;
|
|
107
|
+
|
|
108
|
+
const event = {};
|
|
109
|
+
if (typeof input.agent_id === 'string' && input.agent_id) event.agentId = input.agent_id;
|
|
110
|
+
if (typeof input.agent_type === 'string' && input.agent_type) event.agentType = input.agent_type;
|
|
111
|
+
return recordEvent(storeDir, run.id, 'agent', { ...event, ...fields });
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function readStdin() {
|
|
115
|
+
try { return readFileSync(0, 'utf8'); } catch { return ''; }
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
export function main() {
|
|
119
|
+
try {
|
|
120
|
+
handle(JSON.parse(readStdin()));
|
|
121
|
+
} catch (err) {
|
|
122
|
+
console.error(`pipeline-subagent-stop: ${err?.message ?? err}`);
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
|
|
127
|
+
main();
|
|
128
|
+
process.exitCode = 0;
|
|
129
|
+
}
|