@commonlyai/cli 0.1.173 → 0.1.175

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (29) hide show
  1. package/node_modules/@commonlyai/seats/README.md +11 -0
  2. package/node_modules/@commonlyai/seats/package.json +22 -0
  3. package/node_modules/@commonlyai/seats/src/adapters/hermes.js +206 -0
  4. package/node_modules/@commonlyai/seats/src/adapters/stub.js +24 -0
  5. package/node_modules/@commonlyai/seats/src/contract.js +48 -0
  6. package/node_modules/@commonlyai/seats/src/failures.js +38 -0
  7. package/node_modules/@commonlyai/seats/src/index.js +22 -0
  8. package/node_modules/@commonlyai/seats/src/process-group.js +261 -0
  9. package/node_modules/@commonlyai/seats/src/record/format.js +635 -0
  10. package/node_modules/@commonlyai/seats/src/record/otel.js +238 -0
  11. package/node_modules/@commonlyai/seats/src/runtime-observation.js +29 -0
  12. package/node_modules/@commonlyai/seats/src/seat-model.js +17 -0
  13. package/node_modules/@commonlyai/seats/src/supervisor.js +308 -0
  14. package/node_modules/@commonlyai/seats/src/trace/events.js +45 -0
  15. package/node_modules/@commonlyai/seats/src/trace/store.js +276 -0
  16. package/node_modules/@commonlyai/seats/src/upstream-refusal.js +362 -0
  17. package/node_modules/@commonlyai/seats/src/valet/env.js +164 -0
  18. package/node_modules/@commonlyai/seats/src/valet/grant.js +118 -0
  19. package/node_modules/@commonlyai/seats/src/valet/permissions.js +213 -0
  20. package/package.json +6 -1
  21. package/src/commands/agent.js +21 -3
  22. package/src/lib/adapters/claude.js +8 -3
  23. package/src/lib/adapters/codex.js +5 -2
  24. package/src/lib/adapters/hermes.js +13 -194
  25. package/src/lib/adapters/seat-model.js +5 -12
  26. package/src/lib/adapters/stub.js +1 -24
  27. package/src/lib/runtime-observation.js +3 -27
  28. package/src/lib/seat-stop.js +96 -0
  29. package/src/lib/upstream-refusal.js +1 -354
@@ -0,0 +1,238 @@
1
+ /**
2
+ * Export run records as OTLP/JSON traces (an ExportTraceServiceRequest), so
3
+ * a tracing backend can show who let which agent do what beside its own
4
+ * spans. docs/record-v1.md §5 is the mapping; the gen_ai.* names follow the
5
+ * OpenTelemetry GenAI conventions, which are Development status upstream.
6
+ *
7
+ * Ids are derived, never random: the same entries export to the same bytes.
8
+ */
9
+ import { createHash } from 'node:crypto';
10
+ import { KINDS, RECORD_SCHEMA, RECORD_VERSION } from './format.js';
11
+
12
+ const SPAN_KIND_INTERNAL = 1;
13
+ const STATUS_UNSET = 0;
14
+ const STATUS_ERROR = 2;
15
+ const FAILED_OUTCOMES = new Set(['failed', 'stuck', 'cancelled']);
16
+ // Content stays out of the export, as upstream makes content capture opt-in.
17
+ const CONTENT_KINDS = new Set(['agent.message_chunk', 'agent.thought_chunk']);
18
+
19
+ const digest = (text, bytes) => {
20
+ const hex = createHash('sha256').update(text, 'utf8').digest('hex').slice(0, bytes * 2);
21
+ // An all-zero id is invalid in OTLP; vanishingly unlikely, cheap to exclude.
22
+ return /^0+$/.test(hex) ? digest(`${text}#`, bytes) : hex;
23
+ };
24
+
25
+ /** 16-byte trace id per task (task ids repeat across pods, so the pod is in it), else per run. */
26
+ export const traceIdOf = (entry) => digest(entry.taskId
27
+ ? `seats.record/trace/task/${entry.podId}/${entry.taskId}`
28
+ : `seats.record/trace/run/${entry.runId}`, 16);
29
+ /** 8-byte span id for the span an entry opens; the run span uses seq 0. */
30
+ export const spanIdOf = (runId, seq) => digest(`seats.record/span/${runId}/${seq}`, 8);
31
+
32
+ /** RFC 3339 → Unix nanoseconds as a decimal string, keeping sub-millisecond digits. */
33
+ const nanos = (ts) => {
34
+ const match = /^(.*T\d{2}:\d{2}:\d{2})(?:\.(\d{1,9}))?Z$/.exec(ts);
35
+ if (!match) return '0';
36
+ return String(BigInt(Date.parse(`${match[1]}Z`)) * 1000000n + BigInt((match[2] || '').padEnd(9, '0')));
37
+ };
38
+
39
+ const snake = (key) => key.replace(/([a-z0-9])([A-Z])/g, '$1_$2').toLowerCase();
40
+
41
+ const anyValue = (value) => {
42
+ if (typeof value === 'string') return { stringValue: value };
43
+ if (typeof value === 'boolean') return { boolValue: value };
44
+ if (Number.isInteger(value)) return { intValue: String(value) };
45
+ if (typeof value === 'number') return { doubleValue: value };
46
+ if (Array.isArray(value)) return { arrayValue: { values: value.filter((v) => v !== null && v !== undefined).map(anyValue) } };
47
+ return {
48
+ kvlistValue: {
49
+ values: Object.keys(value).filter((k) => value[k] !== null && value[k] !== undefined).sort()
50
+ .map((k) => ({ key: snake(k), value: anyValue(value[k]) })),
51
+ },
52
+ };
53
+ };
54
+ const attr = (key, value) => ({ key, value: anyValue(value) });
55
+ const attrs = (pairs) => pairs.filter(([, value]) => value !== null && value !== undefined).map(([key, value]) => attr(key, value));
56
+
57
+ /** Nested data → `seats.<path>` attributes, keys sorted for a stable export. */
58
+ const flatten = (data, prefix = 'seats') => Object.keys(data || {}).sort().flatMap((key) => {
59
+ const value = data[key];
60
+ const name = `${prefix}.${snake(key)}`;
61
+ if (value === null || value === undefined) return [];
62
+ if (typeof value === 'object' && !Array.isArray(value)) return flatten(value, name);
63
+ return [attr(name, value)];
64
+ });
65
+
66
+ const eventOf = (entry) => ({
67
+ timeUnixNano: nanos(entry.ts),
68
+ name: entry.kind,
69
+ attributes: [
70
+ attr('seats.seq', entry.seq),
71
+ ...(entry.actor ? attrs([
72
+ ['seats.actor.type', entry.actor.type], ['seats.actor.id', entry.actor.id],
73
+ ['seats.actor.owner', entry.actor.owner], ['seats.actor.name', entry.actor.name],
74
+ ['seats.actor.on_behalf_of.id', entry.actor.onBehalfOf?.id], ['seats.actor.on_behalf_of.role', entry.actor.onBehalfOf?.role],
75
+ ]) : []),
76
+ ...flatten(entry.data),
77
+ ],
78
+ });
79
+
80
+ const fail = (span, errorType) => {
81
+ span.status = { code: STATUS_ERROR, message: errorType };
82
+ span.attributes.push(attr('error.type', errorType));
83
+ };
84
+
85
+ /** One run's entries, in seq order → its spans. */
86
+ const spansOfRun = (entries) => {
87
+ const head = entries[0];
88
+ const traceId = traceIdOf(head);
89
+ const last = entries[entries.length - 1];
90
+ const span = ({ seq, parent, name, attributes, start }) => ({
91
+ traceId,
92
+ spanId: spanIdOf(head.runId, seq),
93
+ ...(parent ? { parentSpanId: parent.spanId } : {}),
94
+ name,
95
+ kind: SPAN_KIND_INTERNAL,
96
+ startTimeUnixNano: nanos(start),
97
+ endTimeUnixNano: null,
98
+ attributes,
99
+ events: [],
100
+ status: { code: STATUS_UNSET },
101
+ });
102
+ const run = span({
103
+ seq: 0,
104
+ name: 'seats.run',
105
+ start: head.ts,
106
+ attributes: attrs([['seats.run.id', head.runId], ['seats.task.id', head.taskId], ['seats.pod.id', head.podId]]),
107
+ });
108
+ const spans = [run];
109
+ let attempt = null; // { span, number, tools: Map<toolCallId, span>, usage, context, sessionId }
110
+ const cards = new Map(); // decisionCardId → the span its request sits on
111
+ const end = (s, ts) => { if (s && s.endTimeUnixNano === null) s.endTimeUnixNano = nanos(ts); };
112
+ const closeAttempt = (ts) => {
113
+ if (!attempt) return;
114
+ for (const tool of attempt.tools.values()) end(tool, ts);
115
+ if (attempt.usage) {
116
+ attempt.span.attributes.push(...attrs([
117
+ ['gen_ai.usage.input_tokens', attempt.usage.inputTokens], ['gen_ai.usage.output_tokens', attempt.usage.outputTokens],
118
+ ]));
119
+ }
120
+ attempt.span.attributes.push(...attrs([
121
+ ['seats.usage.context_tokens', attempt.context.tokens], ['seats.usage.context_size', attempt.context.size],
122
+ ]));
123
+ // The session an attempt opened names the conversation when attempt.started did not.
124
+ if (attempt.sessionId && !attempt.span.attributes.some((a) => a.key === 'gen_ai.conversation.id')) {
125
+ attempt.span.attributes.push(attr('gen_ai.conversation.id', attempt.sessionId));
126
+ }
127
+ end(attempt.span, ts);
128
+ attempt = null;
129
+ };
130
+ const current = () => attempt?.span || run;
131
+
132
+ for (const entry of entries) {
133
+ const { kind, data = {} } = entry;
134
+ if (!Object.hasOwn(KINDS, kind) || CONTENT_KINDS.has(kind)) continue; // unknown kinds are skipped
135
+ if (kind === 'attempt.started') {
136
+ closeAttempt(entry.ts);
137
+ const agentName = entry.actor?.name || entry.actor?.id;
138
+ const s = span({
139
+ seq: entry.seq,
140
+ parent: run,
141
+ name: agentName ? `invoke_agent ${agentName}` : 'invoke_agent',
142
+ start: entry.ts,
143
+ attributes: attrs([
144
+ ['gen_ai.operation.name', 'invoke_agent'],
145
+ ['gen_ai.agent.id', entry.actor?.id], ['gen_ai.agent.name', agentName],
146
+ ['gen_ai.conversation.id', data.sessionId],
147
+ ['seats.agent.owner', entry.actor?.owner],
148
+ ['seats.agent.on_behalf_of.id', entry.actor?.onBehalfOf?.id], ['seats.agent.on_behalf_of.role', entry.actor?.onBehalfOf?.role],
149
+ ['seats.attempt.number', data.attempt], ['seats.adapter', data.adapter],
150
+ ['seats.cli.name', data.cli?.name], ['seats.cli.version', data.cli?.version],
151
+ ]),
152
+ });
153
+ spans.push(s);
154
+ attempt = {
155
+ span: s, number: data.attempt, agentName, tools: new Map(), usage: null, context: { tokens: null, size: null }, sessionId: null,
156
+ };
157
+ } else if (kind === 'attempt.settled' && attempt && attempt.number === data.attempt) {
158
+ attempt.span.attributes.push(attr('seats.attempt.outcome', data.outcome));
159
+ if (FAILED_OUTCOMES.has(data.outcome)) fail(attempt.span, data.failure?.kind || data.outcome);
160
+ closeAttempt(entry.ts);
161
+ } else if (kind === 'run.settled') {
162
+ run.attributes.push(...attrs([['seats.run.outcome', data.outcome], ['seats.run.attempts', data.attempts]]));
163
+ if (FAILED_OUTCOMES.has(data.outcome)) {
164
+ const errorType = data.failure?.kind || data.outcome;
165
+ fail(run, errorType);
166
+ // The run's outcome is the last attempt's when nothing settled it.
167
+ if (attempt) fail(attempt.span, errorType);
168
+ }
169
+ closeAttempt(entry.ts);
170
+ end(run, entry.ts);
171
+ } else if (kind === 'tool.call') {
172
+ const toolName = data.name || data.title || 'tool';
173
+ const s = span({
174
+ seq: entry.seq,
175
+ parent: current(),
176
+ name: `execute_tool ${toolName}`,
177
+ start: entry.ts,
178
+ attributes: attrs([
179
+ ['gen_ai.operation.name', 'execute_tool'],
180
+ ['gen_ai.tool.name', toolName], ['gen_ai.tool.call.id', data.toolCallId],
181
+ ['gen_ai.agent.name', attempt?.agentName],
182
+ ['seats.actor.on_behalf_of.id', entry.actor?.onBehalfOf?.id], ['seats.actor.on_behalf_of.role', entry.actor?.onBehalfOf?.role],
183
+ ['seats.tool.kind', data.kind], ['seats.tool.surface', data.surface], ['seats.tool.args_digest', data.argsDigest],
184
+ ]),
185
+ });
186
+ spans.push(s);
187
+ if (attempt && data.toolCallId) attempt.tools.set(data.toolCallId, s);
188
+ } else if (kind === 'tool.update') {
189
+ const tool = attempt?.tools.get(data.toolCallId);
190
+ (tool || current()).events.push(eventOf(entry));
191
+ if (tool && data.status === 'failed') fail(tool, 'tool_failed');
192
+ if (tool && (data.status === 'completed' || data.status === 'failed')) end(tool, entry.ts);
193
+ } else if (kind === 'usage' && attempt) {
194
+ attempt.usage = data;
195
+ // Context fill comes from the stream's usage updates; a later entry without it keeps the last known.
196
+ if (Number.isInteger(data.contextTokens)) attempt.context.tokens = data.contextTokens;
197
+ if (Number.isInteger(data.contextSize)) attempt.context.size = data.contextSize;
198
+ attempt.span.events.push(eventOf(entry));
199
+ } else if (kind === 'permission.requested' || kind === 'permission.decided') {
200
+ const target = attempt?.tools.get(data.toolCallId) || current();
201
+ target.events.push(eventOf(entry));
202
+ if (data.decisionCardId && !cards.has(data.decisionCardId)) cards.set(data.decisionCardId, target);
203
+ } else if (kind === 'session.opened') {
204
+ if (attempt && typeof data.sessionId === 'string') attempt.sessionId = data.sessionId;
205
+ current().events.push(eventOf(entry));
206
+ } else if (kind === 'decision.ruled') {
207
+ // A ruling on a card that a request rode on joins that request; any other is an event where it happened.
208
+ (cards.get(data.decisionCardId) || current()).events.push(eventOf(entry));
209
+ } else {
210
+ current().events.push(eventOf(entry));
211
+ }
212
+ }
213
+ closeAttempt(last.ts);
214
+ end(run, last.ts);
215
+ return spans;
216
+ };
217
+
218
+ /**
219
+ * toOtlpJson(entries, { serviceName? }) → ExportTraceServiceRequest (OTLP/JSON)
220
+ *
221
+ * `entries` are record entries (readRun's output, or parsed lines) from one
222
+ * or more runs. Entries of another schema or a newer version are left out.
223
+ */
224
+ export const toOtlpJson = (entries, { serviceName = 'seats' } = {}) => {
225
+ const runs = new Map();
226
+ for (const entry of entries || []) {
227
+ if (!entry || entry.schema !== RECORD_SCHEMA || !(entry.v <= RECORD_VERSION)) continue;
228
+ if (!runs.has(entry.runId)) runs.set(entry.runId, []);
229
+ runs.get(entry.runId).push(entry);
230
+ }
231
+ const spans = [...runs.values()].flatMap((run) => spansOfRun([...run].sort((a, b) => a.seq - b.seq)));
232
+ return {
233
+ resourceSpans: [{
234
+ resource: { attributes: [attr('service.name', serviceName)] },
235
+ scopeSpans: [{ scope: { name: RECORD_SCHEMA, version: String(RECORD_VERSION) }, spans }],
236
+ }],
237
+ };
238
+ };
@@ -0,0 +1,29 @@
1
+ // Read only adapter-owned control arguments. Callers exclude prompt/stdin and
2
+ // provide every value-taking option, so a flag-shaped model/session value is
3
+ // never reinterpreted as a second option. Raw arguments never leave the runner.
4
+ export const controlsFromArguments = (args, { modelFlag, effortFlag, valueFlags = [], codex = false }) => {
5
+ const controls = { model: null, effort: null };
6
+ const flags = new Set([modelFlag, effortFlag, ...valueFlags].filter(Boolean));
7
+ for (let index = 0; index < args.length; index += 1) {
8
+ const flag = args[index];
9
+ if (!flags.has(flag)) continue;
10
+ const value = args[++index];
11
+ if (flag === modelFlag) controls.model = value || null;
12
+ if (flag === effortFlag) controls.effort = value || null;
13
+ if (codex && flag === '-c' && value?.startsWith('model_reasoning_effort=')) {
14
+ try { controls.effort = JSON.parse(value.slice('model_reasoning_effort='.length)); } catch { /* unknown encoding */ }
15
+ }
16
+ }
17
+ return controls;
18
+ };
19
+
20
+ // Called at the child's successful OS spawn event, not when a requested
21
+ // configuration is saved or when a turn is merely queued. Never send argv.
22
+ // The child rides along as a second argument so a supervisor can read its
23
+ // output as a liveness signal and kill it; a reporter reads only the first.
24
+ export const observeSpawn = (child, onSpawn, controls) => {
25
+ if (typeof onSpawn !== 'function') return;
26
+ child.once('spawn', () => {
27
+ try { onSpawn({ model: controls.model || null, effort: controls.effort || null, at: new Date().toISOString() }, child); } catch { /* diagnostic only */ }
28
+ });
29
+ };
@@ -0,0 +1,17 @@
1
+ /**
2
+ * The model a seat runs on is a declared field (`environment.model`), never a
3
+ * CLI default the operator did not choose. Whether a missing model is an error
4
+ * is the host's policy, passed as `required`.
5
+ */
6
+ export const requireSeatModel = (environment, adapterName, required = false) => {
7
+ const model = environment?.model;
8
+ if (typeof model !== 'string' || !model.trim()) {
9
+ if (!required) return undefined;
10
+ const error = new Error(
11
+ `${adapterName} requires environment.model; set the seat model with commonly agent config <name> --model <model> before running`,
12
+ );
13
+ error.code = 'seat_model_missing';
14
+ throw error;
15
+ }
16
+ return model.trim();
17
+ };
@@ -0,0 +1,308 @@
1
+ /**
2
+ * The run supervisor: one prompt, one traced run, and a guarantee that the run
3
+ * settles. A turn that hangs is named `stuck` in the trace, cancelled, and if
4
+ * the adapter will not let go, abandoned and settled anyway. Nothing this
5
+ * module starts is allowed to sit in "running" forever.
6
+ *
7
+ * Liveness is read from progress signals, not from the reply: any trace event
8
+ * the adapter emits, and any byte on the stdout/stderr of a child the adapter
9
+ * reports through `ctx.onSpawn`. A run that is busy but quiet past the window
10
+ * is indistinguishable from a hung one, which is why the window is long.
11
+ *
12
+ * The adapter is told (ctx.ownProcessGroup) that it may start its child as the
13
+ * leader of a new process group; a child it marks as one is killed as a group,
14
+ * so the CLI's own children (MCP servers, a native binary behind a launcher)
15
+ * die with it. See process-group.js, which also kills those groups when the
16
+ * wrapper itself is signalled or exits.
17
+ */
18
+ import { RunFailure, runFailure, failureKindOf, FAILURE_KINDS } from './failures.js';
19
+ import { redactKnownCredentials, scrubAdapterFailure, spawnCredentials } from './upstream-refusal.js';
20
+ import { killProcessTree, trackSupervisedGroup, OWN_PROCESS_GROUP } from './process-group.js';
21
+
22
+ export const SUPERVISOR_DEFAULTS = Object.freeze({
23
+ // Longer than the quiet stretches a healthy turn has: a long-context first
24
+ // token, or a tool (an install, a test suite) whose output the CLI buffers.
25
+ stallMs: 120_000,
26
+ // Covers an adapter's own SIGTERM -> 5 s -> SIGKILL ladder with room to spare.
27
+ cancelGraceMs: 10_000,
28
+ // The turn budget the run loop already enforces (COMMONLY_AGENT_RUN_TIMEOUT_MS's
29
+ // fallback), so supervising a turn does not shorten or lengthen it.
30
+ timeoutMs: 15 * 60 * 1000,
31
+ });
32
+
33
+ export const DEFAULT_RETRY_ON = Object.freeze(['stuck', 'exited', 'timeout']);
34
+
35
+ // setTimeout overflows past this and fires immediately.
36
+ const MAX_TIMER_MS = 2_147_483_647;
37
+ // The supervisor's own events; an adapter writing them would forge the run's outcome.
38
+ const SUPERVISOR_EVENTS = new Set(['run.started', 'run.settled']);
39
+
40
+ const windowOf = (value, fallback) => {
41
+ if (value === undefined || value === null) return fallback;
42
+ const number = Number(value);
43
+ if (number === 0 || number === Infinity) return null; // explicitly off
44
+ if (!Number.isFinite(number) || number < 0) throw new Error(`supervisor window must be a non-negative number, got ${value}`);
45
+ return Math.min(number, MAX_TIMER_MS);
46
+ };
47
+
48
+ const resolvePolicy = (policy = {}) => {
49
+ const retry = policy.retry || {};
50
+ return {
51
+ stallMs: windowOf(policy.stallMs, SUPERVISOR_DEFAULTS.stallMs),
52
+ cancelGraceMs: windowOf(policy.cancelGraceMs, SUPERVISOR_DEFAULTS.cancelGraceMs) ?? 0,
53
+ timeoutMs: windowOf(policy.timeoutMs, SUPERVISOR_DEFAULTS.timeoutMs),
54
+ retryMax: Math.max(0, Math.floor(Number(retry.max) || 0)),
55
+ retryOn: Array.isArray(retry.on) ? retry.on : DEFAULT_RETRY_ON,
56
+ onStalled: typeof policy.onStalled === 'function' ? policy.onStalled : null,
57
+ };
58
+ };
59
+
60
+ const isChild = (value) => !!value && typeof value.kill === 'function' && typeof value.once === 'function';
61
+ const isAlive = (child) => child.exitCode === null && child.signalCode === null;
62
+
63
+ // The retryable flag in FAILURE_KINDS outranks the caller's list: a refusal
64
+ // listed in `on` is still never re-sent.
65
+ const shouldRetry = (failure, attempt, settings) => {
66
+ const kind = failureKindOf(failure);
67
+ return attempt <= settings.retryMax
68
+ && !!kind && FAILURE_KINDS[kind].retryable
69
+ && settings.retryOn.includes(kind)
70
+ && !failure.adapterIgnoredCancel;
71
+ };
72
+
73
+ const outcomeOf = (failure) => {
74
+ if (!failure) return 'ok';
75
+ const kind = failureKindOf(failure);
76
+ if (kind === 'stuck') return 'stuck';
77
+ if (kind === 'cancelled') return 'cancelled';
78
+ return 'failed';
79
+ };
80
+
81
+ // An adapter that threw a bare Error broke the RunFailure rule; its text is
82
+ // still scrubbed before it reaches the trace, and it carries no kind rather
83
+ // than a guessed one. A RunFailure's detail keeps its shape but loses any
84
+ // credential this spawn was handed, whoever built it.
85
+ const failureData = (failure, credentials) => (failure instanceof RunFailure
86
+ ? { kind: failure.kind, detail: redactKnownCredentials(failure.detail, credentials) }
87
+ : { kind: null, detail: scrubAdapterFailure(String(failure?.message ?? failure ?? ''), { credentials }).detail });
88
+
89
+ const sessionIdOf = (failure) => {
90
+ const id = failure?.sessionId ?? failure?.newSessionId;
91
+ return typeof id === 'string' && id ? id : null;
92
+ };
93
+
94
+ const ignoredCancel = (reason, graceMs) => {
95
+ const failure = new RunFailure(reason.kind, `${reason.detail}; the adapter did not settle within ${graceMs}ms of cancellation`);
96
+ failure.adapterIgnoredCancel = true;
97
+ return failure;
98
+ };
99
+
100
+ const callerCancel = (signal) => (failureKindOf(signal.reason) === 'cancelled'
101
+ ? signal.reason : runFailure('cancelled', 'cancelled by the caller'));
102
+
103
+ /** One attempt. Resolves { result } or { failure }; never rejects, never stays pending. */
104
+ const runAttempt = ({ adapter, prompt, ctx, run, settings, attempt, sessionId, external }) => new Promise((resolve) => {
105
+ const controller = new AbortController();
106
+ const children = new Set();
107
+ let live = true;
108
+ let abortedBy = null;
109
+ let lastSignal = Date.now();
110
+ let stallTimer = null;
111
+ let wallTimer = null;
112
+ let graceTimer = null;
113
+
114
+ const touch = () => { if (live) lastSignal = Date.now(); };
115
+
116
+ const killChildren = () => {
117
+ let killed = 0;
118
+ for (const child of children) {
119
+ if (!isAlive(child)) continue;
120
+ if (killProcessTree(child, 'SIGKILL')) killed += 1;
121
+ }
122
+ return killed;
123
+ };
124
+
125
+ const onExternal = () => cancel(callerCancel(external), { recovery: false });
126
+
127
+ const finish = (outcome) => {
128
+ if (!live) return;
129
+ live = false;
130
+ clearTimeout(stallTimer);
131
+ clearTimeout(wallTimer);
132
+ clearTimeout(graceTimer);
133
+ external?.removeEventListener?.('abort', onExternal);
134
+ // An adapter that honoured the abort can still leave its child behind.
135
+ if (abortedBy && !outcome.failure?.adapterIgnoredCancel && killChildren() > 0) {
136
+ run.emit('liveness.recovery', { action: 'kill', attempt });
137
+ }
138
+ resolve(outcome);
139
+ };
140
+
141
+ function cancel(reason, { recovery = true } = {}) {
142
+ if (abortedBy || !live) return;
143
+ abortedBy = reason;
144
+ clearTimeout(stallTimer);
145
+ clearTimeout(wallTimer);
146
+ if (recovery) run.emit('liveness.recovery', { action: 'cancel', attempt });
147
+ controller.abort(reason);
148
+ graceTimer = setTimeout(() => {
149
+ if (!live) return;
150
+ run.emit('liveness.recovery', { action: 'kill', attempt });
151
+ killChildren();
152
+ finish({ failure: ignoredCancel(reason, settings.cancelGraceMs) });
153
+ }, settings.cancelGraceMs);
154
+ }
155
+
156
+ const checkStall = () => {
157
+ if (!live || abortedBy) return;
158
+ const idleMs = Date.now() - lastSignal;
159
+ if (idleMs < settings.stallMs) {
160
+ stallTimer = setTimeout(checkStall, settings.stallMs - idleMs);
161
+ return;
162
+ }
163
+ const windowMs = settings.stallMs;
164
+ run.emit('liveness.stalled', { idleMs, windowMs });
165
+ if (settings.onStalled) {
166
+ // A host's notifier failing must not keep the run from recovering.
167
+ try {
168
+ Promise.resolve(settings.onStalled({ runId: run.runId, adapter: adapter.name, attempt, idleMs, windowMs })).catch(() => {});
169
+ } catch { /* notifier only */ }
170
+ }
171
+ cancel(runFailure('stuck', `no progress signal for ${idleMs}ms (window ${windowMs}ms)`));
172
+ };
173
+
174
+ // Counts as progress only while this attempt is live, so a cancelled
175
+ // attempt's late output cannot keep the next one alive or reach the trace.
176
+ // The emit options (the entry's actor) pass through unchanged. `redact` is
177
+ // offered only when the run has one, so prepareRunEnv cannot hand its
178
+ // values to a no-op; it is not gated on `live`, since adding a value to
179
+ // scrub is never unsafe.
180
+ const trace = {
181
+ emit(type, data, options) {
182
+ if (!live || SUPERVISOR_EVENTS.has(type)) return null;
183
+ touch();
184
+ return run.emit(type, data, options);
185
+ },
186
+ ...(typeof run.redact === 'function' ? { redact: (values) => run.redact(values) } : {}),
187
+ };
188
+
189
+ // Wraps the caller's onSpawn: every argument reaches it unchanged.
190
+ // A group-leading child is tracked for the wrapper's exit even when it
191
+ // arrives after this attempt ended: it is outside the seat's own group, so
192
+ // nothing else would kill it.
193
+ const onSpawn = (...args) => {
194
+ for (const child of args.filter(isChild)) trackSupervisedGroup(child);
195
+ if (live) {
196
+ touch();
197
+ for (const child of args.filter(isChild)) {
198
+ children.add(child);
199
+ child.stdout?.on?.('data', touch);
200
+ child.stderr?.on?.('data', touch);
201
+ }
202
+ }
203
+ return typeof ctx.onSpawn === 'function' ? ctx.onSpawn(...args) : undefined;
204
+ };
205
+
206
+ if (external?.aborted) {
207
+ abortedBy = callerCancel(external);
208
+ finish({ failure: abortedBy });
209
+ return;
210
+ }
211
+ external?.addEventListener?.('abort', onExternal, { once: true });
212
+ if (settings.stallMs) stallTimer = setTimeout(checkStall, settings.stallMs);
213
+ if (settings.timeoutMs) {
214
+ wallTimer = setTimeout(() => cancel(runFailure('timeout', `exceeded the ${settings.timeoutMs}ms wall-clock budget`)), settings.timeoutMs);
215
+ }
216
+
217
+ let pending;
218
+ try {
219
+ pending = Promise.resolve(adapter.spawn(prompt, {
220
+ ...ctx, ...(sessionId ? { sessionId } : {}), trace, signal: controller.signal, onSpawn, [OWN_PROCESS_GROUP]: true,
221
+ }));
222
+ } catch (error) {
223
+ pending = Promise.reject(error);
224
+ }
225
+ // Once the supervisor has aborted, the abort's reason is the outcome, even
226
+ // if the adapter answers inside the grace window: the trace already says why.
227
+ pending.then(
228
+ (result) => finish(abortedBy ? { failure: abortedBy } : { result }),
229
+ (error) => finish({ failure: abortedBy || error }),
230
+ );
231
+ });
232
+
233
+ /**
234
+ * Run one prompt under liveness supervision.
235
+ *
236
+ * @param {object} args
237
+ * @param {import('./contract.js').Adapter} args.adapter
238
+ * @param {string} args.prompt
239
+ * @param {object} [args.ctx] passed to adapter.spawn; `trace`, `signal` and `onSpawn` are wrapped,
240
+ * and `ownProcessGroup` is set to true.
241
+ * `ctx.signal`, when given, cancels the run (outcome `cancelled`).
242
+ * @param {ReturnType<import('./trace/store.js').createTraceStore>} args.store
243
+ * @param {object} [args.policy]
244
+ * @param {number} [args.policy.stallMs] no-progress window; 0 turns it off
245
+ * @param {number} [args.policy.cancelGraceMs] how long a cancelled adapter has to settle
246
+ * @param {number} [args.policy.timeoutMs] wall-clock budget per attempt; 0 turns it off
247
+ * @param {{max: number, on?: string[]}} [args.policy.retry] off unless max > 0
248
+ * @param {(info: {runId, adapter, attempt, idleMs, windowMs}) => void} [args.policy.onStalled]
249
+ * @param {string} [args.runId]
250
+ * @param {string} [args.label]
251
+ * @param {string} [args.taskId] the board task this run works; needs `podId`
252
+ * @param {string} [args.podId]
253
+ * @param {object} [args.actor] the run's own party, {type, id, owner?, onBehalfOf?};
254
+ * entries the run writes without naming an actor (run.started, tool calls,
255
+ * permission requests) record this one instead of null
256
+ * @returns {Promise<{ runId: string, outcome: string, attempts: number, result?: object, failure?: Error }>}
257
+ */
258
+ export const superviseRun = async ({
259
+ adapter, prompt, ctx = {}, store, policy = {}, runId, label, taskId, podId, actor,
260
+ } = {}) => {
261
+ if (!adapter || typeof adapter.spawn !== 'function') throw new Error('superviseRun needs an adapter with spawn()');
262
+ if (!store || typeof store.startRun !== 'function') throw new Error('superviseRun needs a trace store');
263
+ const settings = resolvePolicy(policy);
264
+ const run = store.startRun({
265
+ runId, adapter: adapter.name, label, taskId, podId, actor,
266
+ });
267
+ // The runtime token rides in ctx, not through the valet, so the store would
268
+ // not otherwise know to scrub it from agent text.
269
+ if (typeof run.redact === 'function' && typeof ctx.runtimeToken === 'string' && ctx.runtimeToken) {
270
+ run.redact([ctx.runtimeToken]);
271
+ }
272
+ const startedAt = Date.now();
273
+ const external = ctx.signal;
274
+ let attempt = 0;
275
+ let sessionId = ctx.sessionId || null;
276
+ let last = { failure: null };
277
+
278
+ try {
279
+ for (;;) {
280
+ attempt += 1;
281
+ run.emit('run.started', { adapter: adapter.name, ...(sessionId ? { sessionId } : {}), attempt });
282
+ last = await runAttempt({ adapter, prompt, ctx, run, settings, attempt, sessionId, external });
283
+ if (!last.failure || external?.aborted || !shouldRetry(last.failure, attempt, settings)) break;
284
+ sessionId = sessionIdOf(last.failure) || sessionId;
285
+ run.emit('liveness.recovery', { action: 'retry', attempt: attempt + 1 });
286
+ }
287
+ } catch (error) {
288
+ // The supervisor's own fault (a trace write failing, say) still settles the run.
289
+ last = { failure: error };
290
+ }
291
+
292
+ const outcome = outcomeOf(last.failure);
293
+ const credentials = spawnCredentials(ctx, [adapter.providerKeyEnv].filter(Boolean));
294
+ try {
295
+ run.emit('run.settled', {
296
+ outcome,
297
+ ...(last.failure ? { failure: failureData(last.failure, credentials) } : {}),
298
+ durationMs: Date.now() - startedAt,
299
+ attempts: attempt,
300
+ });
301
+ } catch { /* the outcome is still returned */ }
302
+ return {
303
+ runId: run.runId,
304
+ outcome,
305
+ attempts: attempt,
306
+ ...(last.failure ? { failure: last.failure } : { result: last.result }),
307
+ };
308
+ };
@@ -0,0 +1,45 @@
1
+ /**
2
+ * Trace event vocabulary. Names follow ACP's session/update kinds where one
3
+ * exists, so an ACP agent's stream maps across without a translation table.
4
+ *
5
+ * Event: { runId, seq, ts, type, data }, plus the record envelope (kind, v,
6
+ * actor, prev, taskId?, podId?) when read back from the store; the on-disk
7
+ * format is docs/record-v1.md. `seq` is per run, starting at 1; `ts` is
8
+ * an ISO string. Events never carry prompt text, credentials or
9
+ * environment values — only names, ids, sizes and the agent's own output.
10
+ */
11
+ export const EVENT_TYPES = Object.freeze([
12
+ 'run.started', // { adapter, cli: {name, version}?, sessionId?, attempt }
13
+ 'run.settled', // { outcome: 'ok'|'failed'|'stuck'|'cancelled', failure?: {kind, detail}, durationMs, attempts }
14
+ 'turn.prompt_sent', // { chars }
15
+ 'agent.message_chunk', // { text }
16
+ 'agent.thought_chunk', // { chars } thoughts are counted, not stored
17
+ 'agent.retry', // { state: 'scheduled'|'started', attempt?, nextRetryAt?, status?, errorType? } the agent retrying its provider; never the provider's text
18
+ 'tool.call', // { toolCallId, title, kind?, status?, name?, surface?, argsDigest? }
19
+ 'tool.update', // { toolCallId, status?, title? }
20
+ 'plan', // { entries: [{content, status}] }
21
+ 'permission.requested', // { toolCallId?, options: [{optionId, kind}], question, askedOf: {id, role}, grantId?, decisionCardId?, argsDigest? }
22
+ 'permission.decided', // { outcome: 'selected'|'cancelled', optionId?, kind?, toolCallId?, rule?: {list, pattern}, approval? }
23
+ 'usage', // { inputTokens?, outputTokens?, totalTokens?, cost? } null-not-zero when unknown
24
+ 'liveness.stalled', // { idleMs, windowMs }
25
+ 'liveness.recovery', // { action: 'cancel'|'kill'|'retry', attempt }
26
+ 'credential.granted', // { names: [...] } names only, never values
27
+ 'credential.denied', // { name, reason?: 'not_granted'|'unresolved'|'too_short' }
28
+ 'process.exited', // { code, signal }
29
+ // Record kinds with no earlier trace name (docs/record-v1.md has the full data).
30
+ 'task.claimed', // { leaseUntil? } mirrors the board's claim; the board stays the source of truth
31
+ 'task.released', // { reason? }
32
+ 'task.done', // { doneBy? } written for the board; ends grants bound to the task
33
+ 'grant.issued', // { grantId, parentGrantId?, scope: {connectionId?, tools: [names], writeMode?, budget?, audience}, target?, issuedBy?, expiresAt?, binding? }
34
+ 'grant.revoked', // { grantIds: [...], reason: 'task.done'|'expired'|'cascade'|'manual' }
35
+ 'attempt.settled', // { attempt, outcome, failure?: {kind, detail} }
36
+ 'decision.ruled', // { decisionCardId, value, decidedBy, decidedByRole } a card ruling not tied to a parked tool call
37
+ 'result.reported', // { status: 'done'|'partial'|'blocked'|'failed', refs?: [{type, id}] }
38
+ ]);
39
+
40
+ const KNOWN = new Set(EVENT_TYPES);
41
+ export const isEventType = (type) => KNOWN.has(type);
42
+
43
+ /** The trace hook an adapter receives as ctx.trace. Absent hook → no-op. */
44
+ export const NULL_TRACE = Object.freeze({ emit() {} });
45
+ export const traceOf = (ctx) => (ctx && ctx.trace && typeof ctx.trace.emit === 'function' ? ctx.trace : NULL_TRACE);