@50arms/platform-mcp 0.0.0-stage → 0.1.437
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 +114 -2
- package/dist/access-request.js +101 -0
- package/dist/agent-gate.js +69 -0
- package/dist/board-id.js +27 -0
- package/dist/bounded-retry.js +25 -0
- package/dist/checkout-docs-root.js +54 -0
- package/dist/clear-control.js +77 -0
- package/dist/compaction-control.js +142 -0
- package/dist/credential.js +104 -0
- package/dist/danxbot-api-examples.js +97 -0
- package/dist/dashboard-identity.js +14 -0
- package/dist/design-screenshot.js +138 -0
- package/dist/digest-hold.js +67 -0
- package/dist/docs-tools.js +140 -0
- package/dist/durable-write.js +56 -0
- package/dist/entrypoint.js +35 -0
- package/dist/forbidden-hint.js +23 -0
- package/dist/guarded-calls.js +21 -0
- package/dist/handlers.js +385 -0
- package/dist/heartbeat-reporter.js +156 -0
- package/dist/http-client.js +190 -0
- package/dist/index.js +1128 -0
- package/dist/json-body.js +12 -0
- package/dist/key-revoked-halt.js +30 -0
- package/dist/lifecycle.js +113 -0
- package/dist/listen.js +999 -0
- package/dist/listener-reminder.js +119 -0
- package/dist/machine-daemon/autostart.js +160 -0
- package/dist/machine-daemon/beat.js +77 -0
- package/dist/machine-daemon/cli.js +42 -0
- package/dist/machine-daemon/install.js +168 -0
- package/dist/machine-daemon/state.js +64 -0
- package/dist/machine-daemon/version.js +13 -0
- package/dist/machine-daemon.js +553 -0
- package/dist/machine-toolkit.js +460 -0
- package/dist/mini-app-build.js +159 -0
- package/dist/mini-app-checkout.js +215 -0
- package/dist/mini-app-output.js +111 -0
- package/dist/mini-app-publish.js +154 -0
- package/dist/mini-app-restore.js +175 -0
- package/dist/one-line.js +25 -0
- package/dist/pacing-relay.js +16 -0
- package/dist/permission-request.js +134 -0
- package/dist/permission-watch.js +145 -0
- package/dist/plan-events-relay.js +572 -0
- package/dist/plan-events-tool.js +9 -0
- package/dist/plugin-publish.js +38 -0
- package/dist/plugin-screenshot.js +81 -0
- package/dist/request-bound.js +21 -0
- package/dist/restart-notice.js +271 -0
- package/dist/result-limit.js +31 -0
- package/dist/role-instruction-file.js +36 -0
- package/dist/route-board-scope.generated.js +95 -0
- package/dist/route-board-scope.js +71 -0
- package/dist/session-access.js +322 -0
- package/dist/session-connection.js +264 -0
- package/dist/session-control-file.js +58 -0
- package/dist/session-key.js +125 -0
- package/dist/session-options.js +60 -0
- package/dist/session-stats.js +235 -0
- package/dist/session-stream.js +345 -0
- package/dist/session-title.js +129 -0
- package/dist/shutdown.js +38 -0
- package/dist/stop-fixes.js +50 -0
- package/dist/subagents-live.js +162 -0
- package/dist/terms-halt.js +39 -0
- package/dist/text-limit.js +24 -0
- package/dist/tool-access.js +114 -0
- package/dist/tool-defs.js +58 -0
- package/dist/tool-surface.js +59 -0
- package/dist/transcript-lineage.js +105 -0
- package/dist/types.js +45 -0
- package/dist/urgent-records.js +47 -0
- package/dist/version-floor.js +146 -0
- package/package.json +51 -4
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* DX-4390 (PLAN-17 section 96) - the dashboard key a user approved for ONE agent session, kept on
|
|
3
|
+
* this machine so the session's MCP server and its event relay both authenticate with it.
|
|
4
|
+
*
|
|
5
|
+
* One file per dashboard and per session:
|
|
6
|
+
* `~/.config/danxbot/session-keys/<sha12(dashboard url)>/<session id>.json`, mode 0600. The key
|
|
7
|
+
* lapses on the dashboard after 24 hours without use (it slides on every authenticated call), so
|
|
8
|
+
* this file is a cache of a revocable, short-lived credential, not a standing secret: a 401 on it
|
|
9
|
+
* deletes it (`SessionAccess`), and files nothing has touched for a week are pruned on the next
|
|
10
|
+
* write (`pruneStaleSessionFiles`, shared with the session connection record). A live server
|
|
11
|
+
* rewrites its file at least hourly (`SessionAccess.guard`), so only an unused session's file
|
|
12
|
+
* ages out. The session id picks the file but never selects a key on the dashboard.
|
|
13
|
+
*
|
|
14
|
+
* Written write-then-rename, like the session connection record, so a relay reading
|
|
15
|
+
* concurrently sees the old file or the new one, never a torn one.
|
|
16
|
+
*/
|
|
17
|
+
import { createHash } from "node:crypto";
|
|
18
|
+
import { existsSync, mkdirSync, readdirSync, readFileSync, rmSync } from "node:fs";
|
|
19
|
+
import { writeFileDurable } from "./durable-write.js";
|
|
20
|
+
import { homedir } from "node:os";
|
|
21
|
+
import { join } from "node:path";
|
|
22
|
+
import { assertSessionIdSegment, pruneStaleSessionFiles } from "./session-connection.js";
|
|
23
|
+
/** One canonical shape (CLAUDE.md Core Principle 1): a file of any other version is refused. */
|
|
24
|
+
export const SESSION_KEY_SCHEMA_VERSION = 1;
|
|
25
|
+
const dashboardHash = (dashboardUrl) => createHash("sha256").update(dashboardUrl.replace(/\/+$/, "")).digest("hex").slice(0, 12);
|
|
26
|
+
export function sessionKeyRoot(home = homedir()) {
|
|
27
|
+
return join(home, ".config", "danxbot", "session-keys");
|
|
28
|
+
}
|
|
29
|
+
export function sessionKeyDir(dashboardUrl, home = homedir()) {
|
|
30
|
+
return join(sessionKeyRoot(home), dashboardHash(dashboardUrl));
|
|
31
|
+
}
|
|
32
|
+
export function sessionKeyPath(dashboardUrl, sessionId, home = homedir()) {
|
|
33
|
+
assertSessionIdSegment(sessionId);
|
|
34
|
+
return join(sessionKeyDir(dashboardUrl, home), `${sessionId}.json`);
|
|
35
|
+
}
|
|
36
|
+
/** Store a freshly claimed key for this session; returns the file's path. */
|
|
37
|
+
export function writeSessionKey(input, options = {}) {
|
|
38
|
+
const home = options.home ?? homedir();
|
|
39
|
+
const now = options.now ?? Date.now();
|
|
40
|
+
const file = sessionKeyPath(input.dashboardUrl, input.sessionId, home);
|
|
41
|
+
const dir = sessionKeyDir(input.dashboardUrl, home);
|
|
42
|
+
mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
43
|
+
pruneStaleSessionFiles(dir, now);
|
|
44
|
+
const record = {
|
|
45
|
+
schemaVersion: SESSION_KEY_SCHEMA_VERSION,
|
|
46
|
+
dashboardUrl: input.dashboardUrl.replace(/\/+$/, ""),
|
|
47
|
+
sessionId: input.sessionId,
|
|
48
|
+
apiKey: input.apiKey,
|
|
49
|
+
savedAt: new Date(now).toISOString(),
|
|
50
|
+
};
|
|
51
|
+
writeFileDurable(file, JSON.stringify(record), { mode: 0o600 });
|
|
52
|
+
return file;
|
|
53
|
+
}
|
|
54
|
+
/** Why a key file cannot be used, or `null`. */
|
|
55
|
+
function invalidKeyReason(value) {
|
|
56
|
+
if (typeof value !== "object" || value === null)
|
|
57
|
+
return "it is not an object";
|
|
58
|
+
const r = value;
|
|
59
|
+
if (r.schemaVersion !== SESSION_KEY_SCHEMA_VERSION) {
|
|
60
|
+
return `schemaVersion is ${JSON.stringify(r.schemaVersion)}, not ${SESSION_KEY_SCHEMA_VERSION}`;
|
|
61
|
+
}
|
|
62
|
+
for (const field of ["dashboardUrl", "sessionId", "apiKey", "savedAt"]) {
|
|
63
|
+
if (typeof r[field] !== "string" || r[field] === "")
|
|
64
|
+
return `${field} is not a non-empty string`;
|
|
65
|
+
}
|
|
66
|
+
return null;
|
|
67
|
+
}
|
|
68
|
+
/** The thrown shape of an absent file, so a caller can tell "no key" from "a key I cannot read". */
|
|
69
|
+
export class SessionKeyAbsentError extends Error {
|
|
70
|
+
constructor(path) {
|
|
71
|
+
super(`there is no session key at ${path}`);
|
|
72
|
+
this.name = "SessionKeyAbsentError";
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
/** A key file that is there but unreadable or of another shape. */
|
|
76
|
+
export class SessionKeyUnusableError extends Error {
|
|
77
|
+
path;
|
|
78
|
+
constructor(path, why) {
|
|
79
|
+
super(`the session key file ${path} is unusable: ${why}`);
|
|
80
|
+
this.path = path;
|
|
81
|
+
this.name = "SessionKeyUnusableError";
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
/** The record in `path`. Absent and unusable are different errors: only the first means "never signed in". */
|
|
85
|
+
function readSessionKeyRecord(path) {
|
|
86
|
+
let raw;
|
|
87
|
+
try {
|
|
88
|
+
raw = readFileSync(path, "utf-8");
|
|
89
|
+
}
|
|
90
|
+
catch (err) {
|
|
91
|
+
if (err.code === "ENOENT")
|
|
92
|
+
throw new SessionKeyAbsentError(path);
|
|
93
|
+
throw new SessionKeyUnusableError(path, err.message);
|
|
94
|
+
}
|
|
95
|
+
let parsed;
|
|
96
|
+
try {
|
|
97
|
+
parsed = JSON.parse(raw);
|
|
98
|
+
}
|
|
99
|
+
catch (err) {
|
|
100
|
+
throw new SessionKeyUnusableError(path, `not JSON (${err.message})`);
|
|
101
|
+
}
|
|
102
|
+
const invalid = invalidKeyReason(parsed);
|
|
103
|
+
if (invalid !== null)
|
|
104
|
+
throw new SessionKeyUnusableError(path, invalid);
|
|
105
|
+
return parsed;
|
|
106
|
+
}
|
|
107
|
+
/** The key in `path`. */
|
|
108
|
+
export function readSessionKeyFile(path) {
|
|
109
|
+
return readSessionKeyRecord(path).apiKey;
|
|
110
|
+
}
|
|
111
|
+
/** DX-6607 - every dashboard this session holds a key on, as its key files name them (the folder name is a hash of the URL, so only the file can say). A file that is there but unusable throws. */
|
|
112
|
+
export function sessionKeyDashboards(sessionId, home = homedir()) {
|
|
113
|
+
assertSessionIdSegment(sessionId);
|
|
114
|
+
const root = sessionKeyRoot(home);
|
|
115
|
+
if (!existsSync(root))
|
|
116
|
+
return [];
|
|
117
|
+
return readdirSync(root).flatMap((dashboard) => {
|
|
118
|
+
const file = join(root, dashboard, `${sessionId}.json`);
|
|
119
|
+
return existsSync(file) ? [readSessionKeyRecord(file).dashboardUrl] : [];
|
|
120
|
+
});
|
|
121
|
+
}
|
|
122
|
+
/** Delete a key file (a revoked or lapsed key). Never throws for an absent file. */
|
|
123
|
+
export function deleteSessionKey(path) {
|
|
124
|
+
rmSync(path, { force: true });
|
|
125
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* DX-2862 / DX-4233 - how `restart_notice` (`restart-notice.ts`, this file's only caller) learns which dashboard to call and
|
|
3
|
+
* as whom when it asks about an earlier session. It reads the connection record that session's own MCP server wrote when it
|
|
4
|
+
* connected a plan (`session-connection.ts`), resolves the credential the record names through the same resolver the server
|
|
5
|
+
* used, and refuses unless it fingerprints identically. The in-process relay behind `plan_events_wait` needs none of this: it
|
|
6
|
+
* lives inside the server that holds the credential.
|
|
7
|
+
*/
|
|
8
|
+
import { homedir } from "node:os";
|
|
9
|
+
import { credentialFingerprint, describeCredentialSource, readCredential } from "./credential.js";
|
|
10
|
+
import { readSessionConnection } from "./session-connection.js";
|
|
11
|
+
import { fixForStop } from "./stop-fixes.js";
|
|
12
|
+
export const SESSION_ID_HEADER = "x-danx-session-id";
|
|
13
|
+
/** DX-5033 / DX-5076 - marks a request as this process's own background work (not the agent acting): the dashboard ends no agent pause on it. */
|
|
14
|
+
export const PLUGIN_BACKGROUND_HEADER = "x-danx-plugin-background";
|
|
15
|
+
/** A start this process cannot make, named the way the session will be told about it. */
|
|
16
|
+
export class SessionStartError extends Error {
|
|
17
|
+
// `ListenStopReason`, not `string`: a start-time failure is always one of `no_connection_record` / `credential_unavailable`
|
|
18
|
+
// / `credential_mismatch` (there is no stream yet).
|
|
19
|
+
reason;
|
|
20
|
+
fix;
|
|
21
|
+
constructor(reason, detail, fix) {
|
|
22
|
+
super(detail);
|
|
23
|
+
this.name = "SessionStartError";
|
|
24
|
+
this.reason = reason;
|
|
25
|
+
this.fix = fix;
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* The session's connection, as its own MCP server recorded it, and the proof that this process holds the SAME credential that
|
|
30
|
+
* server does. Never falls back to an ambient env credential: that fallback is precisely DX-2862.
|
|
31
|
+
*/
|
|
32
|
+
export function resolveDashboardAuth(sessionId, env, options = {}) {
|
|
33
|
+
const home = options.home ?? homedir();
|
|
34
|
+
let record;
|
|
35
|
+
try {
|
|
36
|
+
record = readSessionConnection(sessionId, { home });
|
|
37
|
+
}
|
|
38
|
+
catch (err) {
|
|
39
|
+
throw new SessionStartError("no_connection_record", err.message, fixForStop("no_connection_record"));
|
|
40
|
+
}
|
|
41
|
+
if (record === null) {
|
|
42
|
+
throw new SessionStartError("no_connection_record", `session ${sessionId} has no dashboard connection record — no platform MCP server has connected it to a plan`, fixForStop("no_connection_record"));
|
|
43
|
+
}
|
|
44
|
+
let token;
|
|
45
|
+
try {
|
|
46
|
+
token = readCredential(record.source, env);
|
|
47
|
+
}
|
|
48
|
+
catch (err) {
|
|
49
|
+
const credentialError = err;
|
|
50
|
+
throw new SessionStartError("credential_unavailable", `the credential this session's platform MCP server declared cannot be resolved here: ${credentialError.message}`,
|
|
51
|
+
// CredentialError.fix is a required string, never absent — no fallback needed.
|
|
52
|
+
credentialError.fix);
|
|
53
|
+
}
|
|
54
|
+
const fingerprint = credentialFingerprint(token);
|
|
55
|
+
if (fingerprint !== record.fingerprint) {
|
|
56
|
+
throw new SessionStartError("credential_mismatch", `${describeCredentialSource(record.source)} now yields credential ${fingerprint}, but this session's ` +
|
|
57
|
+
`platform MCP server is using ${record.fingerprint} — a lookup would authenticate as somebody else`, fixForStop("credential_mismatch"));
|
|
58
|
+
}
|
|
59
|
+
return { dashboardUrl: record.dashboardUrl.replace(/\/+$/, ""), token, sessionId };
|
|
60
|
+
}
|
|
@@ -0,0 +1,235 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* DX-2954 — an operator session's live stats, collected by the relay and sent
|
|
3
|
+
* in its DX-2952 heartbeat.
|
|
4
|
+
*
|
|
5
|
+
* Workers and operator sessions report the same stats the same way (epic
|
|
6
|
+
* DX-2958), so this module owns NO counting or cursor logic of its own: it runs
|
|
7
|
+
* the ONE collector from the shared package (`DispatchUsage`, the shared ledger
|
|
8
|
+
* counter + transcript cursors + running-set queue), fed by the ONE transcript
|
|
9
|
+
* tailer (`SessionLogWatcher`) and the ONE transcript-side running-set producer
|
|
10
|
+
* (`createActivityObserver`) — the exact trio the worker runs in
|
|
11
|
+
* `attach-monitoring-stack.ts`. The server side is the ONE writer too:
|
|
12
|
+
* `POST /api/plan-sessions/me/heartbeat` applies the `stats` field through the
|
|
13
|
+
* same `applyStatsInTx` the worker's `/stats` route uses.
|
|
14
|
+
*
|
|
15
|
+
* ## Process start: the first heartbeat's answer, never the mint (round 22)
|
|
16
|
+
*
|
|
17
|
+
* A restarted session's relay (a resume or a restarted MCP server) may fail its mint and
|
|
18
|
+
* still heartbeat (DX-2952), so the heartbeat is the one call that always
|
|
19
|
+
* answers. Until this process holds that answer it sends ONLY its connection
|
|
20
|
+
* state: no ledger entries, no cursors — the transcript is not even opened. The
|
|
21
|
+
* answer's `sourceCursors` are where each source resumes (start-order rule 1: an
|
|
22
|
+
* operator dispatch is one per session id, so its own stored `acked` is the
|
|
23
|
+
* whole answer; a source with none starts at 0 — rule 3, so a session connected
|
|
24
|
+
* mid-way, or a fork, has its full transcript counted). There is no predecessor
|
|
25
|
+
* and never an end-of-file skip for an operator session.
|
|
26
|
+
*
|
|
27
|
+
* An in-process re-mint (lease expiry, a refused ticket, transient backoff)
|
|
28
|
+
* never reaches this module: it lives for the whole process, so its in-memory
|
|
29
|
+
* state is untouched by anything the mint loop does.
|
|
30
|
+
*
|
|
31
|
+
* ## The one path that leaves the machine
|
|
32
|
+
*
|
|
33
|
+
* `transcriptPath` comes from the Claude Code hook input `transcript_path` (the
|
|
34
|
+
* plugin hands it to this process as {@link TRANSCRIPT_PATH_ENV}). It is sent
|
|
35
|
+
* once, in the first stats-bearing heartbeat, and recorded on the dispatch's
|
|
36
|
+
* `jsonl_path`; the strict stats parser refuses every other path field.
|
|
37
|
+
*/
|
|
38
|
+
import { basename, dirname, isAbsolute } from "node:path";
|
|
39
|
+
import { DispatchUsage, SessionLogWatcher, createActivityObserver, readGitHead, } from "@50arms/session-telemetry";
|
|
40
|
+
/**
|
|
41
|
+
* DX-4233 - the one rule for a transcript path, wherever it comes from (the `transcript_path` argument of
|
|
42
|
+
* `plan_events_wait`): an absolute `.jsonl` path, or a loud refusal naming `source`.
|
|
43
|
+
*/
|
|
44
|
+
export function checkedTranscriptPath(path, source) {
|
|
45
|
+
if (!isAbsolute(path) || !path.endsWith(".jsonl")) {
|
|
46
|
+
throw new Error(`${source} must be an absolute path to a session .jsonl transcript (got "${path}")`);
|
|
47
|
+
}
|
|
48
|
+
return path;
|
|
49
|
+
}
|
|
50
|
+
const realWatcher = (options) => new SessionLogWatcher(options);
|
|
51
|
+
/** The collector's dispatch-id argument; the heartbeat is addressed by session, not dispatch. */
|
|
52
|
+
const OPERATOR_DISPATCH_REF = "operator-session";
|
|
53
|
+
/**
|
|
54
|
+
* DX-3962 - the relay's collector. `"operator-bridge"` (the telemetry package's published mode value, kept under its old name) is the ONE mode that credits a sub-agent's replies to the card
|
|
55
|
+
* it claimed (from its own transcript's pickup); a worker builds its collector with attribution off.
|
|
56
|
+
*/
|
|
57
|
+
function newCollector() {
|
|
58
|
+
return new DispatchUsage("operator-bridge");
|
|
59
|
+
}
|
|
60
|
+
export class OperatorSessionStats {
|
|
61
|
+
transcriptPath;
|
|
62
|
+
makeWatcher;
|
|
63
|
+
usage = newCollector();
|
|
64
|
+
watcher = null;
|
|
65
|
+
stopped = false;
|
|
66
|
+
/** DX-6367: the last landed answer said the dashboard accepts `statsHeld`. */
|
|
67
|
+
dashboardAcceptsStatsHeld = false;
|
|
68
|
+
constructor(transcriptPath, makeWatcher = realWatcher) {
|
|
69
|
+
this.transcriptPath = transcriptPath;
|
|
70
|
+
this.makeWatcher = makeWatcher;
|
|
71
|
+
}
|
|
72
|
+
/** Whether this process holds the stored stats (and is therefore collecting). */
|
|
73
|
+
collecting() {
|
|
74
|
+
return this.watcher !== null;
|
|
75
|
+
}
|
|
76
|
+
/**
|
|
77
|
+
* One heartbeat tick. Before the process holds the stored stats: a plain
|
|
78
|
+
* connection-state heartbeat, whose answer starts collection. After: the
|
|
79
|
+
* collector's flush, carried by the heartbeat (a flush with nothing new
|
|
80
|
+
* sends one plain heartbeat instead, so an idle session still heartbeats).
|
|
81
|
+
* Returns whether a heartbeat landed, for the relay's silent-failure count.
|
|
82
|
+
*/
|
|
83
|
+
async tick(rawSend) {
|
|
84
|
+
const send = this.tracked(rawSend);
|
|
85
|
+
if (this.watcher === null) {
|
|
86
|
+
const answer = await send(undefined);
|
|
87
|
+
if (answer.stats !== null)
|
|
88
|
+
this.begin(answer.stats);
|
|
89
|
+
return answer.landed;
|
|
90
|
+
}
|
|
91
|
+
const { carried, landed, planless } = await this.flushVia(send);
|
|
92
|
+
if (planless) {
|
|
93
|
+
// DX-4892: re-queueing would resend this batch on every tick for as long as the session stays planless. Drop the collector instead:
|
|
94
|
+
// the plain heartbeats that follow start collection again from the stored cursors once an answer carries stats (the session is bound).
|
|
95
|
+
this.watcher?.stop();
|
|
96
|
+
this.watcher = null;
|
|
97
|
+
this.usage = newCollector();
|
|
98
|
+
return landed;
|
|
99
|
+
}
|
|
100
|
+
if (carried)
|
|
101
|
+
return landed;
|
|
102
|
+
// DX-6367: while collecting, this process holds the stored stats, so the plain heartbeat asks for none.
|
|
103
|
+
return (await send(undefined, this.dashboardAcceptsStatsHeld)).landed;
|
|
104
|
+
}
|
|
105
|
+
/** `send`, remembering whether the dashboard accepts `statsHeld`. */
|
|
106
|
+
tracked(send) {
|
|
107
|
+
return async (stats, statsHeld) => {
|
|
108
|
+
const answer = await send(stats, statsHeld);
|
|
109
|
+
if (answer.landed)
|
|
110
|
+
this.dashboardAcceptsStatsHeld = answer.acceptsStatsHeld;
|
|
111
|
+
return answer;
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
/** One collector flush carried by `send`; a refused or unlanded batch throws inside the collector, which re-queues it. */
|
|
115
|
+
async flushVia(send) {
|
|
116
|
+
const outcome = { carried: false, landed: true, planless: false };
|
|
117
|
+
const flushed = await this.usage.flush({
|
|
118
|
+
applyStats: async (_dispatchId, payload) => {
|
|
119
|
+
outcome.carried = true;
|
|
120
|
+
const answer = await send(payload);
|
|
121
|
+
if (!answer.landed) {
|
|
122
|
+
outcome.landed = false;
|
|
123
|
+
throw new Error("stats heartbeat did not reach the dashboard");
|
|
124
|
+
}
|
|
125
|
+
if (answer.statsNotStored === "no_plan") {
|
|
126
|
+
outcome.planless = true;
|
|
127
|
+
throw new Error("stats heartbeat refused: the session is on no plan, so the dashboard stores no stats for it");
|
|
128
|
+
}
|
|
129
|
+
if (answer.stats === null) {
|
|
130
|
+
throw new Error(`stats heartbeat answered HTTP ${answer.status} without stats`);
|
|
131
|
+
}
|
|
132
|
+
return answer.stats;
|
|
133
|
+
},
|
|
134
|
+
}, OPERATOR_DISPATCH_REF);
|
|
135
|
+
return { ...outcome, outcome: flushed };
|
|
136
|
+
}
|
|
137
|
+
/**
|
|
138
|
+
* Stop tailing. Idempotent; a stopped collector never starts again.
|
|
139
|
+
*
|
|
140
|
+
* DX-5415: a sub-agent's final report is held until its end signal is read, and confirmed only by a LATER poll (DX-5413), so a bare
|
|
141
|
+
* `watcher.stop()` lost any final still held or whose signal was read in the last poll. A collecting watcher is therefore settled first:
|
|
142
|
+
* `drainFinal()`, then one flush carried by `send`, and only then stopped. The bound only stops WAITING: when it fires, `stop` returns
|
|
143
|
+
* and stops the watcher, but the drain and the flush already under way keep running to their own end (each dashboard request is
|
|
144
|
+
* bounded by its own request timeout), so shutdown never hangs and a cut-off settle can still land late. A drain that throws, a flush
|
|
145
|
+
* that fails or is refused, or a settle cut off costs only what it had not yet delivered, said on stderr.
|
|
146
|
+
*/
|
|
147
|
+
async stop(send, bound) {
|
|
148
|
+
if (this.stopped)
|
|
149
|
+
return;
|
|
150
|
+
this.stopped = true;
|
|
151
|
+
const watcher = this.watcher;
|
|
152
|
+
if (watcher === null)
|
|
153
|
+
return;
|
|
154
|
+
const cutOff = new Promise((resolve) => {
|
|
155
|
+
if (bound.aborted)
|
|
156
|
+
resolve("cut");
|
|
157
|
+
else
|
|
158
|
+
bound.addEventListener("abort", () => resolve("cut"), { once: true });
|
|
159
|
+
});
|
|
160
|
+
try {
|
|
161
|
+
const settled = (async () => {
|
|
162
|
+
await watcher.drainFinal();
|
|
163
|
+
// DX-5415: a failed or refused flush re-queues nothing for a collector that is stopping, so it says what it costs.
|
|
164
|
+
const flushed = (await this.flushVia(send)).outcome;
|
|
165
|
+
if (flushed !== "ok")
|
|
166
|
+
process.stderr.write(`operator session stats: the final flush was ${flushed}; held sub-agent finals may be lost
|
|
167
|
+
`);
|
|
168
|
+
return "settled";
|
|
169
|
+
})();
|
|
170
|
+
settled.catch(() => undefined); // a cut-off settle's late failure is already reported by the race below
|
|
171
|
+
if ((await Promise.race([settled, cutOff])) === "cut") {
|
|
172
|
+
process.stderr.write("operator session stats: the final settle was cut off by its bound; held sub-agent finals may be lost\n");
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
catch (err) {
|
|
176
|
+
process.stderr.write(`operator session stats: final settle failed: ${err instanceof Error ? err.message : String(err)}\n`);
|
|
177
|
+
}
|
|
178
|
+
finally {
|
|
179
|
+
watcher.stop();
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
begin(stored) {
|
|
183
|
+
// `this.watcher !== null`: the relay fires `tick` without awaiting it, so
|
|
184
|
+
// two ticks can both be waiting on a pre-collection heartbeat. The first
|
|
185
|
+
// answer starts collection; a second must not start (and leak) another
|
|
186
|
+
// tailer over the same transcript.
|
|
187
|
+
if (this.transcriptPath === null || this.stopped || this.watcher !== null)
|
|
188
|
+
return;
|
|
189
|
+
const path = this.transcriptPath;
|
|
190
|
+
const activities = createActivityObserver({
|
|
191
|
+
recordActivities: (changes) => this.usage.recordActivities(changes),
|
|
192
|
+
// DX-5333: each sub-agent's brief, follow-ups and reports, the problems it raised, and the commit it started from.
|
|
193
|
+
recordMessages: (messages) => this.usage.recordMessages(messages),
|
|
194
|
+
recordProblemLinks: (links) => this.usage.recordProblemLinks(links),
|
|
195
|
+
readHeadSha: readGitHead,
|
|
196
|
+
});
|
|
197
|
+
const watcher = this.makeWatcher({
|
|
198
|
+
cwd: dirname(path),
|
|
199
|
+
sessionDir: dirname(path),
|
|
200
|
+
sessionId: basename(path, ".jsonl"),
|
|
201
|
+
startCursors: stored.sourceCursors,
|
|
202
|
+
onCursorPastEof: (info) => this.usage.recordPastEof(info),
|
|
203
|
+
onSourceDiscovered: (info) => activities.onSourceDiscovered(info),
|
|
204
|
+
onPollComplete: () => activities.onPollComplete(),
|
|
205
|
+
});
|
|
206
|
+
this.usage.attachCursors(() => watcher.cursorState(), stored.sourceCursors);
|
|
207
|
+
this.usage.setTranscriptPath(path);
|
|
208
|
+
// Replays are credited (the ledger is idempotent) and marked so the
|
|
209
|
+
// collector's own estimate never counts them twice; the relay has no
|
|
210
|
+
// side-effecting transcript subscriber to skip them for.
|
|
211
|
+
watcher.onUsageLine((raw, meta) => this.usage.observeRawLine(raw, meta));
|
|
212
|
+
// DX-4498 — the raw line feed: the brief and Claude Code's task notifications are not entries.
|
|
213
|
+
watcher.onUsageLine(activities.onLine);
|
|
214
|
+
this.watcher = watcher;
|
|
215
|
+
void watcher
|
|
216
|
+
.start()
|
|
217
|
+
.then(async () => {
|
|
218
|
+
// The watcher's discovery gives up after a bounded wait when the
|
|
219
|
+
// transcript file does not exist yet (Claude Code may create it lazily,
|
|
220
|
+
// on the session's first turn). Collecting "forever" from a watcher that
|
|
221
|
+
// adopted no file would read nothing, so drop it: the next heartbeat's
|
|
222
|
+
// answer starts collection again from the stored cursors.
|
|
223
|
+
if (this.watcher !== watcher || this.stopped)
|
|
224
|
+
return;
|
|
225
|
+
if ((await watcher.resolveSessionFileNow()) !== null)
|
|
226
|
+
return;
|
|
227
|
+
watcher.stop();
|
|
228
|
+
this.watcher = null;
|
|
229
|
+
this.usage = newCollector();
|
|
230
|
+
})
|
|
231
|
+
.catch((err) => {
|
|
232
|
+
process.stderr.write(`operator session stats: transcript watcher failed: ${err.message}\n`);
|
|
233
|
+
});
|
|
234
|
+
}
|
|
235
|
+
}
|