ai-runtime-engine 2.7.0 → 2.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -21,8 +21,24 @@ import { flattenClamp } from '../util/flatten.js';
21
21
  import { admitFindings, contractFailed } from './admit.js';
22
22
  import { executionCoverage } from './finding.js';
23
23
  import { AGENT_TASK_PROJECTION, nextAgentTaskId } from './task.js';
24
+ import { hashOf } from '../util/hash.js';
25
+ import { validatePlan } from '../orchestration/plan.js';
24
26
  /** How much of a step input may reach the inner prompt. It is model-authored, and it is fenced. */
25
27
  export const AGENT_INPUT_MAX = 1000;
28
+ /** Inner observations persisted per task. The record lives in a JSON file that is rewritten every
29
+ * commit, so this is a durability bound, not a display one. Beyond it, the count is kept and the
30
+ * content dropped — an honest "there was more" rather than a silently short list. */
31
+ export const INNER_OBS_MAX = 20;
32
+ /** Admission rejections kept per task. They accumulate across attempts and are rewritten every commit. */
33
+ export const DIAGNOSTICS_KEPT = 50;
34
+ /**
35
+ * What makes a step THIS step. Plan step ids (`s1`, `auto1`) are model-authored and recur across
36
+ * replans, so binding a persisted record by id alone would hand one step's completed inner work to a
37
+ * different step that happens to share its id — same agent, different input, silently wrong findings.
38
+ */
39
+ export function stepIdentity(step) {
40
+ return hashOf({ agent: step.agent, description: step.description, input: step.input ?? null });
41
+ }
26
42
  /** Raised by the metered facade when an agent tries to exceed its reservation. */
27
43
  class AgentCallBudgetError extends Error {
28
44
  constructor() {
@@ -32,19 +48,49 @@ class AgentCallBudgetError extends Error {
32
48
  }
33
49
  export async function runAgentTask(step, envelope, definition, deps) {
34
50
  const now = deps.clock.now();
35
- const record = {
51
+ // Computed from what is TRUE NOW, so a persisted record can be compared against the run it is about
52
+ // to be reused in. Reading them off the resumed record instead would compare a value with itself.
53
+ const defHash = hashOf(definition);
54
+ const envHash = hashOf(envelope);
55
+ const record = deps.resume ?? {
56
+ v: 1,
36
57
  agentTaskId: nextAgentTaskId(now),
37
58
  agentId: envelope.agentId,
38
59
  stepId: step.id,
39
60
  state: 'created',
40
61
  createdAt: now,
62
+ updatedAt: now,
63
+ provenance: deps.provenance,
64
+ // The three identity hashes. They are computed HERE, at the only place a task is minted, so a
65
+ // resumed record can be compared against the run it is about to be reused in (Phase 3.5).
66
+ agentDefHash: defHash,
67
+ envelopeHash: envHash,
68
+ stepInputHash: stepIdentity(step),
69
+ attempt: 1,
70
+ innerCompletedSteps: [],
71
+ innerObservations: [],
72
+ innerObservationsOmitted: 0,
41
73
  innerSteps: { total: 0, succeeded: 0 },
74
+ callsReserved: envelope.reservation,
42
75
  callsUsed: 0,
76
+ callsRefunded: 0,
43
77
  toolCallsUsed: 0,
44
78
  findings: [],
45
79
  diagnostics: [],
46
80
  };
81
+ if (deps.resume) {
82
+ // A resumed record starts a NEW attempt: findings it mints are namespaced by the attempt number, so
83
+ // they can never collide with findings already on the record from the attempt that was interrupted.
84
+ record.attempt += 1;
85
+ // The stamp must describe the LATEST interruption, so it is cleared on promotion, not left to age.
86
+ delete record.interruption;
87
+ // The question has been answered (or the task is being retried); either way it is no longer pending,
88
+ // and leaving it set would re-elect this task as the run's wait on the very next commit.
89
+ delete record.pendingInner;
90
+ }
47
91
  record.state = 'queued';
92
+ record.updatedAt = deps.clock.now();
93
+ deps.onRecord?.(record);
48
94
  // (2) A child controller, so a parent abort reaches the inner run and nothing else does.
49
95
  const child = new AbortController();
50
96
  const onParentAbort = () => child.abort();
@@ -56,10 +102,15 @@ export async function runAgentTask(step, envelope, definition, deps) {
56
102
  const expired = () => deps.clock.now() > deadline;
57
103
  // (3) THE METERED FACADE. Prototype-delegating, so it IS an AI (assignable to SkillContext.ai) while
58
104
  // owning `run`. The count increments BEFORE the await, so a failed inner call is still charged.
105
+ // Prior spend carries across a resume: the reservation is the ceiling for the TASK, not per attempt,
106
+ // so a task that already burned 2 of 3 calls before a crash gets 1 more, not 3.
107
+ const priorCalls = deps.resume?.callsUsed ?? 0;
108
+ const priorToolCalls = deps.resume?.toolCallsUsed ?? 0;
59
109
  let calls = 0;
110
+ const spent = () => priorCalls + calls;
60
111
  const metered = Object.create(deps.ai);
61
112
  metered.run = async (req) => {
62
- if (calls >= envelope.reservation)
113
+ if (spent() >= envelope.reservation)
63
114
  throw new AgentCallBudgetError();
64
115
  calls += 1;
65
116
  return deps.ai.run(req);
@@ -71,8 +122,12 @@ export async function runAgentTask(step, envelope, definition, deps) {
71
122
  deps.parentSignal?.removeEventListener('abort', onParentAbort);
72
123
  record.state = state;
73
124
  record.endedAt = deps.clock.now();
74
- record.callsUsed = calls;
75
- record.toolCallsUsed = toolCalls;
125
+ record.updatedAt = record.endedAt;
126
+ record.callsUsed = priorCalls + calls;
127
+ record.toolCallsUsed = priorToolCalls + toolCalls;
128
+ // Derived, for reporting only. The real refund is that the executor charges what was SPENT, so the
129
+ // unspent part of the reservation is simply never charged — there is no ledger to get wrong.
130
+ record.callsRefunded = Math.max(0, record.callsReserved - record.callsUsed);
76
131
  if (failure)
77
132
  record.failure = failure;
78
133
  const row = AGENT_TASK_PROJECTION[state];
@@ -88,6 +143,7 @@ export async function runAgentTask(step, envelope, definition, deps) {
88
143
  ...(state === 'completed' ? { output: `${envelope.agentId}: ${record.innerSteps.succeeded}/${record.innerSteps.total} inner step(s), ${record.findings.length} finding(s)` } : {}),
89
144
  };
90
145
  deps.emit?.({ type: 'agent.task.completed', record });
146
+ deps.onRecord?.(record);
91
147
  return { observation, record };
92
148
  };
93
149
  // (12) A parent abort is a cancel or a pause; both project to the same observation, and the record
@@ -108,13 +164,15 @@ export async function runAgentTask(step, envelope, definition, deps) {
108
164
  return preempted;
109
165
  record.state = 'running';
110
166
  record.startedAt = deps.clock.now();
167
+ record.updatedAt = record.startedAt;
111
168
  deps.emit?.({ type: 'agent.task.started', record });
169
+ deps.onRecord?.(record);
112
170
  // (6) THE TOOL SEAM - defense in depth behind narrowEnvelope. Always a structured denial, never a
113
171
  // throw: the tool contract is that a refusal is visible.
114
172
  const innerCallTool = async (id, input) => {
115
173
  if (!envelope.tools.includes(id))
116
174
  return { ok: false, error: { code: 'PERMISSION', message: `'${flattenClamp(id, 40)}' is not in this agent's catalog` } };
117
- if (toolCalls + 1 > envelope.maxToolCalls)
175
+ if (priorToolCalls + toolCalls + 1 > envelope.maxToolCalls)
118
176
  return { ok: false, error: { code: 'PERMISSION', message: `agent tool-call budget exhausted (${envelope.maxToolCalls})` } };
119
177
  if (expired())
120
178
  return { ok: false, error: { code: 'TIMEOUT', message: 'agent time budget exhausted' } };
@@ -127,20 +185,59 @@ export async function runAgentTask(step, envelope, definition, deps) {
127
185
  const innerGoal = [
128
186
  envelope.objective,
129
187
  wrapUntrusted(`agent-input:${envelope.agentId}`, flattenClamp(JSON.stringify(step.input ?? null), AGENT_INPUT_MAX)),
188
+ // The answer to THIS task's own question, routed here rather than appended to the outer goal —
189
+ // appending it there would replan the outer plan and discard every sibling agent's work.
190
+ ...(deps.resumeAnswer ? [wrapUntrusted(`agent-answer:${envelope.agentId}`, flattenClamp(deps.resumeAnswer, AGENT_INPUT_MAX))] : []),
130
191
  ].join('\n\n');
131
- let planned;
132
- try {
133
- planned = await generatePlan({ goal: innerGoal, ai: metered, skills: deps.skills, tools: envelope.tools, agents: [], version: 1, ...(envelope.routing ? { routing: envelope.routing } : {}) });
134
- }
135
- catch (err) {
136
- if (err instanceof AgentCallBudgetError)
137
- return finish('failed', { code: 'agent-call-budget', message: err.message });
138
- return finish('failed', { code: 'agent-failed', message: flattenClamp(err instanceof Error ? err.message : String(err), 160) });
192
+ // A persisted inner plan is REUSED rather than regenerated — that is what makes a resume cheap and
193
+ // what stops completed inner work being redone. It is still re-validated against the envelope's
194
+ // catalog first: the plan came off disk, and `validatePlan` is the membership gate for every plan,
195
+ // resumed or fresh. A plan that no longer validates is discarded, not repaired.
196
+ let innerPlan;
197
+ const identical = deps.resume ? deps.resume.envelopeHash === envHash && deps.resume.agentDefHash === defHash : false;
198
+ // An ANSWER changes what the plan should be — that is the entire point of having asked — so a task
199
+ // being resumed with one always re-plans rather than replaying the plan made before the question.
200
+ if (deps.resume?.innerPlan && identical && !deps.resumeAnswer) {
201
+ const check = validatePlan(deps.resume.innerPlan, { skills: envelope.skills, tools: envelope.tools, agents: [] });
202
+ if (check.ok)
203
+ innerPlan = deps.resume.innerPlan;
139
204
  }
140
- if (!planned.plan) {
141
- return finish('failed', { code: 'agent-failed', message: flattenClamp(planned.error ?? planned.clarification ?? 'the agent could not form a plan', 160) });
205
+ // The record now belongs to THIS run: an edited definition or a re-narrowed envelope invalidates the
206
+ // old plan (handled above) but not the task, so the hashes move forward with it.
207
+ record.agentDefHash = defHash;
208
+ record.envelopeHash = envHash;
209
+ if (!innerPlan) {
210
+ let planned;
211
+ try {
212
+ planned = await generatePlan({ goal: innerGoal, ai: metered, skills: deps.skills, tools: envelope.tools, agents: [], version: 1, ...(envelope.routing ? { routing: envelope.routing } : {}) });
213
+ }
214
+ catch (err) {
215
+ if (err instanceof AgentCallBudgetError)
216
+ return finish('failed', { code: 'agent-call-budget', message: err.message });
217
+ return finish('failed', { code: 'agent-failed', message: flattenClamp(err instanceof Error ? err.message : String(err), 160) });
218
+ }
219
+ if (planned.clarification) {
220
+ // The one inner wait that exists: the planner needs an answer. The task stays RESUMABLE and the
221
+ // step stays pending — this is not a failure, and it must not fail the branch.
222
+ record.pendingInner = { kind: 'clarification', question: flattenClamp(planned.clarification, 240), at: deps.clock.now() };
223
+ return finish('waiting_for_clarification', { code: 'agent-waiting', message: record.pendingInner.question });
224
+ }
225
+ if (!planned.plan) {
226
+ return finish('failed', { code: 'agent-failed', message: flattenClamp(planned.error ?? 'the agent could not form a plan', 160) });
227
+ }
228
+ innerPlan = planned.plan;
229
+ // A REGENERATED plan has new step ids. Carrying the discarded plan's completed ids into its skip
230
+ // set would mark steps `succeeded` that never ran — the dangerous direction of a resume, since the
231
+ // work is silently not done rather than merely done twice.
232
+ record.innerCompletedSteps = [];
233
+ record.innerObservations = [];
234
+ record.innerObservationsOmitted = 0;
142
235
  }
143
- record.innerPlanVersion = planned.plan.version;
236
+ record.innerPlan = innerPlan;
237
+ record.innerPlanVersion = innerPlan.version;
238
+ // The plan exists and nothing has run: a crash from here on resumes against a real inner plan.
239
+ record.updatedAt = deps.clock.now();
240
+ deps.onRecord?.(record);
144
241
  const aborted = abortedNow();
145
242
  if (aborted)
146
243
  return aborted;
@@ -150,7 +247,22 @@ export async function runAgentTask(step, envelope, definition, deps) {
150
247
  // a skill's own model calls, which a wave gate cannot see.
151
248
  let inner;
152
249
  try {
153
- inner = await executePlan(planned.plan, {
250
+ inner = await executePlan(innerPlan, {
251
+ skip: new Set(record.innerCompletedSteps),
252
+ // THE seam that makes inner work survive a crash. Everything between `running` and `finish()` was
253
+ // otherwise invisible to disk, so a resumed task had nothing to skip and re-ran the whole inner plan.
254
+ onProgress: (snap) => {
255
+ record.innerPlan = snap.plan;
256
+ record.innerCompletedSteps = snap.plan.steps.filter((st) => st.status === 'succeeded').map((st) => st.id);
257
+ const room = INNER_OBS_MAX - record.innerObservations.length;
258
+ if (room > 0)
259
+ record.innerObservations.push(...snap.observations.slice(0, room));
260
+ record.innerObservationsOmitted += Math.max(0, snap.observations.length - Math.max(0, room));
261
+ record.callsUsed = priorCalls + calls;
262
+ record.toolCallsUsed = priorToolCalls + toolCalls;
263
+ record.updatedAt = deps.clock.now();
264
+ deps.onRecord?.(record);
265
+ },
154
266
  runSkill: async (id, i) => {
155
267
  const out = await deps.runSkill(id, i, { permissions: envelope.permissions, signal: child.signal, ai: metered });
156
268
  skillResults.push({ stepId: id, result: out.result, validation: out.validation });
@@ -171,6 +283,7 @@ export async function runAgentTask(step, envelope, definition, deps) {
171
283
  return finish('failed', { code: 'agent-failed', message: flattenClamp(err instanceof Error ? err.message : String(err), 160) });
172
284
  }
173
285
  record.innerSteps = { total: inner.plan.steps.length, succeeded: inner.plan.steps.filter((s) => s.status === 'succeeded').length };
286
+ record.innerCompletedSteps = inner.plan.steps.filter((s) => s.status === 'succeeded').map((s) => s.id);
174
287
  const afterRun = abortedNow();
175
288
  if (afterRun)
176
289
  return afterRun;
@@ -239,8 +352,16 @@ export async function runAgentTask(step, envelope, definition, deps) {
239
352
  provenance: deps.provenance,
240
353
  now: deps.clock.now(),
241
354
  });
242
- record.findings = [...denials, ...admission.admitted];
243
- record.diagnostics = admission.rejected;
355
+ // MERGE, never assign. A resumed attempt that admits fewer findings than the interrupted one would
356
+ // otherwise drop the earlier ones on the floor — and observations already committed to disk reference
357
+ // them by id. Attempt >1 namespaces its ids so the two attempts can never collide.
358
+ const mint = (f) => (record.attempt > 1 ? { ...f, id: `${f.id}@a${record.attempt}` } : f);
359
+ const fresh = [...denials, ...admission.admitted].map(mint);
360
+ const seenFinding = new Set(record.findings.map((f) => f.id));
361
+ record.findings = [...record.findings, ...fresh.filter((f) => !seenFinding.has(f.id))];
362
+ // Bounded: diagnostics accumulate across attempts, and every one of them is rewritten to disk on
363
+ // every commit. Keeping the most recent is the useful half.
364
+ record.diagnostics = [...record.diagnostics, ...admission.rejected].slice(-DIAGNOSTICS_KEPT);
244
365
  if (contractFailed(admission)) {
245
366
  return finish('failed', { code: 'finding-contract', message: 'the agent did not satisfy its declared output contract' });
246
367
  }
package/dist/cli/cli.js CHANGED
@@ -21,7 +21,7 @@ import { mcpCommand, mcpAddCommand, mcpRemoveCommand, mcpEnableCommand, mcpTestC
21
21
  import { startRepl } from './interactive/repl.js';
22
22
  import { printError } from './render.js';
23
23
  const program = new Command();
24
- program.name('ai-runtime').description('Universal, provider-agnostic AI Runtime & Orchestration Platform').version('2.7.0');
24
+ program.name('ai-runtime').description('Universal, provider-agnostic AI Runtime & Orchestration Platform').version('2.8.0');
25
25
  const configOpt = ['-c, --config <path>', 'path to an ai-runtime config file'];
26
26
  // Bare `ai-runtime` (no subcommand) opens the interactive terminal. `allowExcessArguments(false)` keeps
27
27
  // a mistyped subcommand (e.g. `ai-runtime porviders`) failing fast instead of silently opening the REPL.
@@ -0,0 +1,69 @@
1
+ /**
2
+ * Multi-lane agent display (Phase 3.6) — concurrent agent steps as live rows in the REPL.
3
+ *
4
+ * PURE BY CONSTRUCTION. Nothing here reads `process.stdout`, a clock, or an environment variable: the
5
+ * terminal width, whether colour is on, and every value rendered are parameters. That is what makes a
6
+ * terminal feature testable offline and deterministically, and it is the rule to keep — the moment this
7
+ * module reads the real terminal, its tests start describing the machine they ran on.
8
+ *
9
+ * WRAPPING IS MADE IMPOSSIBLE RATHER THAN HANDLED. In-place rewriting works by moving the cursor up N
10
+ * rows, which is only correct while one logical line occupies exactly one physical row. So every line is
11
+ * clamped to `cols - 1`: no line ever reaches the final column, nothing wraps, and the arithmetic is
12
+ * exact. Lines are built as plain text, clamped, and styled last, so the clamp never has to count
13
+ * invisible escape bytes.
14
+ */
15
+ import type { RuntimeEvent } from '../../runtime/events.js';
16
+ export interface AgentLane {
17
+ agentTaskId: string;
18
+ agentId: string;
19
+ stepId: string;
20
+ /** `running` covers started + progress; the terminal states come from the completion event. */
21
+ state: 'running' | 'completed' | 'failed' | 'cancelled' | 'waiting';
22
+ succeeded: number;
23
+ total: number;
24
+ callsUsed: number;
25
+ findings: number;
26
+ }
27
+ /** Display width in terminal columns, not code units. */
28
+ export declare function displayWidth(text: string): number;
29
+ /** Truncate to `cols` display columns, never splitting a surrogate pair or half a wide glyph. */
30
+ export declare function sliceColumns(text: string, cols: number): string;
31
+ /**
32
+ * Accumulates agent lanes from the event stream. Insertion-ordered, so lanes do not jump around between
33
+ * frames — a display that reorders itself is unreadable even when every value in it is right.
34
+ */
35
+ export declare class LaneSet {
36
+ private readonly lanes;
37
+ /**
38
+ * Fold one event in. Returns the lane it touched, or `undefined` for an event that is not an agent
39
+ * event — a lane rather than a boolean, so the caller neither re-narrows the event nor looks the lane
40
+ * up a second time.
41
+ */
42
+ observe(event: RuntimeEvent): AgentLane | undefined;
43
+ list(): AgentLane[];
44
+ /** True once at least one lane exists and none is still in flight. */
45
+ settled(): boolean;
46
+ clear(): void;
47
+ }
48
+ /**
49
+ * Render the lanes as one line each. `tick` animates the running marker; it is a parameter rather than a
50
+ * timer so a test can render frame 3 without waiting for it.
51
+ */
52
+ export declare function laneLines(lanes: AgentLane[], opts: {
53
+ cols: number;
54
+ colors: boolean;
55
+ tick: number;
56
+ }): string[];
57
+ /**
58
+ * Rewrite an owned region of the terminal in place, returning the bytes to write.
59
+ *
60
+ * `prevRows === 0` opens the region at the cursor's current line. Otherwise the cursor is assumed to be
61
+ * on the region's LAST row (which is where this function always leaves it) and walks up from there.
62
+ *
63
+ * The cursor never ends on a fresh line below the frame: a trailing newline on the terminal's last row
64
+ * scrolls the viewport, and the next frame's `cursorUp` would then land one row high and duplicate the
65
+ * region on every tick, forever.
66
+ */
67
+ export declare function frameDiff(prevRows: number, lines: string[], enabled: boolean): string;
68
+ /** How many rows `frameDiff` will have left behind — the `prevRows` for the next call. */
69
+ export declare function frameRows(lines: string[]): number;
@@ -0,0 +1,181 @@
1
+ /**
2
+ * Multi-lane agent display (Phase 3.6) — concurrent agent steps as live rows in the REPL.
3
+ *
4
+ * PURE BY CONSTRUCTION. Nothing here reads `process.stdout`, a clock, or an environment variable: the
5
+ * terminal width, whether colour is on, and every value rendered are parameters. That is what makes a
6
+ * terminal feature testable offline and deterministically, and it is the rule to keep — the moment this
7
+ * module reads the real terminal, its tests start describing the machine they ran on.
8
+ *
9
+ * WRAPPING IS MADE IMPOSSIBLE RATHER THAN HANDLED. In-place rewriting works by moving the cursor up N
10
+ * rows, which is only correct while one logical line occupies exactly one physical row. So every line is
11
+ * clamped to `cols - 1`: no line ever reaches the final column, nothing wraps, and the arithmetic is
12
+ * exact. Lines are built as plain text, clamped, and styled last, so the clamp never has to count
13
+ * invisible escape bytes.
14
+ */
15
+ import { style, SPINNER_FRAMES } from './ansi.js';
16
+ import { flattenClamp } from '../../util/flatten.js';
17
+ const CLEAR_LINE = '\x1b[2K';
18
+ /**
19
+ * East Asian Wide and Fullwidth code points, which a terminal draws in TWO columns. The no-wrap
20
+ * invariant is stated in COLUMNS, so counting UTF-16 code units would let a CJK step id — and step ids
21
+ * are model-authored — produce a line that measures 79 and draws 95, wrapping and breaking every
22
+ * cursor-up that follows it.
23
+ */
24
+ function isWide(cp) {
25
+ return ((cp >= 0x1100 && cp <= 0x115f) || // Hangul Jamo
26
+ (cp >= 0x2e80 && cp <= 0x303e) || // CJK radicals, Kangxi, punctuation
27
+ (cp >= 0x3041 && cp <= 0x33ff) || // Hiragana .. CJK compatibility
28
+ (cp >= 0x3400 && cp <= 0x4dbf) ||
29
+ (cp >= 0x4e00 && cp <= 0x9fff) || // CJK unified ideographs
30
+ (cp >= 0xa000 && cp <= 0xa4cf) || // Yi
31
+ (cp >= 0xac00 && cp <= 0xd7a3) || // Hangul syllables
32
+ (cp >= 0xf900 && cp <= 0xfaff) ||
33
+ (cp >= 0xfe30 && cp <= 0xfe6f) ||
34
+ (cp >= 0xff00 && cp <= 0xff60) || // Fullwidth forms
35
+ (cp >= 0xffe0 && cp <= 0xffe6) ||
36
+ (cp >= 0x1f300 && cp <= 0x1f64f) || // emoji
37
+ (cp >= 0x1f900 && cp <= 0x1f9ff) ||
38
+ (cp >= 0x20000 && cp <= 0x3fffd));
39
+ }
40
+ /** Display width in terminal columns, not code units. */
41
+ export function displayWidth(text) {
42
+ let n = 0;
43
+ for (const ch of text)
44
+ n += isWide(ch.codePointAt(0)) ? 2 : 1;
45
+ return n;
46
+ }
47
+ /** Truncate to `cols` display columns, never splitting a surrogate pair or half a wide glyph. */
48
+ export function sliceColumns(text, cols) {
49
+ let out = '';
50
+ let used = 0;
51
+ for (const ch of text) {
52
+ const w = isWide(ch.codePointAt(0)) ? 2 : 1;
53
+ if (used + w > cols)
54
+ break;
55
+ out += ch;
56
+ used += w;
57
+ }
58
+ return out;
59
+ }
60
+ const cursorUp = (n) => (n > 0 ? `\x1b[${n}A` : '');
61
+ /** Which lane state an agent-task state projects onto. Anything unrecognised reads as still running. */
62
+ function laneState(state) {
63
+ if (state === 'completed' || state === 'failed' || state === 'cancelled')
64
+ return state;
65
+ if (state.startsWith('waiting'))
66
+ return 'waiting';
67
+ return 'running';
68
+ }
69
+ /**
70
+ * Accumulates agent lanes from the event stream. Insertion-ordered, so lanes do not jump around between
71
+ * frames — a display that reorders itself is unreadable even when every value in it is right.
72
+ */
73
+ export class LaneSet {
74
+ lanes = new Map();
75
+ /**
76
+ * Fold one event in. Returns the lane it touched, or `undefined` for an event that is not an agent
77
+ * event — a lane rather than a boolean, so the caller neither re-narrows the event nor looks the lane
78
+ * up a second time.
79
+ */
80
+ observe(event) {
81
+ if (!event.type.startsWith('agent.task.'))
82
+ return undefined;
83
+ const e = event;
84
+ const existing = this.lanes.get(e.agentTaskId);
85
+ const lane = existing ?? {
86
+ agentTaskId: e.agentTaskId,
87
+ agentId: e.agentId,
88
+ stepId: e.stepId,
89
+ state: 'running',
90
+ succeeded: 0,
91
+ total: 0,
92
+ callsUsed: 0,
93
+ findings: 0,
94
+ };
95
+ if ('innerSteps' in e) {
96
+ lane.total = e.innerSteps.total;
97
+ lane.succeeded = e.innerSteps.succeeded;
98
+ }
99
+ if ('callsUsed' in e)
100
+ lane.callsUsed = e.callsUsed;
101
+ if ('findings' in e)
102
+ lane.findings = e.findings;
103
+ if ('state' in e)
104
+ lane.state = laneState(e.state);
105
+ this.lanes.set(e.agentTaskId, lane);
106
+ return lane;
107
+ }
108
+ list() {
109
+ return [...this.lanes.values()];
110
+ }
111
+ /** True once at least one lane exists and none is still in flight. */
112
+ settled() {
113
+ const all = this.list();
114
+ return all.length > 0 && all.every((l) => l.state !== 'running');
115
+ }
116
+ clear() {
117
+ this.lanes.clear();
118
+ }
119
+ }
120
+ const MARK = { running: '', completed: '✓', failed: '✗', cancelled: '−', waiting: '?' };
121
+ const TINT = {
122
+ running: 'cyan',
123
+ completed: 'green',
124
+ failed: 'red',
125
+ cancelled: 'gray',
126
+ waiting: 'yellow',
127
+ };
128
+ /**
129
+ * Render the lanes as one line each. `tick` animates the running marker; it is a parameter rather than a
130
+ * timer so a test can render frame 3 without waiting for it.
131
+ */
132
+ export function laneLines(lanes, opts) {
133
+ // `|| 80` not `?? 80`: a pty can report ZERO columns, and zero is not nullish. And the floor is 1,
134
+ // not 20 — raising the width for a narrow terminal MAKES it wrap, which is the failure this clamp
135
+ // exists to prevent.
136
+ const width = Math.max(1, (opts.cols || 80) - 1);
137
+ return lanes.map((l) => {
138
+ const mark = l.state === 'running' ? SPINNER_FRAMES[opts.tick % SPINNER_FRAMES.length] : MARK[l.state];
139
+ const progress = l.total > 0 ? `${l.succeeded}/${l.total}` : '…';
140
+ const found = l.findings > 0 ? `, ${l.findings} finding(s)` : '';
141
+ // Built plain, clamped, THEN styled — so the clamp never counts escape bytes.
142
+ const plain = ` ${mark} ${flattenClamp(l.agentId, 24)} @ ${flattenClamp(l.stepId, 16)} ${progress} step(s), ${l.callsUsed} call(s)${found}`;
143
+ return style(sliceColumns(plain, width), TINT[l.state], opts.colors);
144
+ });
145
+ }
146
+ /**
147
+ * Rewrite an owned region of the terminal in place, returning the bytes to write.
148
+ *
149
+ * `prevRows === 0` opens the region at the cursor's current line. Otherwise the cursor is assumed to be
150
+ * on the region's LAST row (which is where this function always leaves it) and walks up from there.
151
+ *
152
+ * The cursor never ends on a fresh line below the frame: a trailing newline on the terminal's last row
153
+ * scrolls the viewport, and the next frame's `cursorUp` would then land one row high and duplicate the
154
+ * region on every tick, forever.
155
+ */
156
+ export function frameDiff(prevRows, lines, enabled) {
157
+ if (!enabled)
158
+ return '';
159
+ const rows = Math.max(prevRows, lines.length);
160
+ if (rows === 0)
161
+ return '';
162
+ let out = '\r';
163
+ if (prevRows > 0)
164
+ out += cursorUp(prevRows - 1);
165
+ for (let i = 0; i < rows; i += 1) {
166
+ out += CLEAR_LINE + (lines[i] ?? '');
167
+ if (i < rows - 1)
168
+ out += '\n';
169
+ }
170
+ // Drew fewer rows than last time: the cursor sits on a cleared row below the frame, so walk back up
171
+ // to the real last line, keeping the "cursor is on the last row" invariant true for the next call.
172
+ // CLOSING the region (`lines` empty) has no last line, so the target is the region's TOP row — which
173
+ // is one higher than the shrink case, and the reason this is `max(1, …)` rather than `lines.length`.
174
+ if (rows > lines.length)
175
+ out += `\r${cursorUp(rows - Math.max(1, lines.length))}`;
176
+ return out;
177
+ }
178
+ /** How many rows `frameDiff` will have left behind — the `prevRows` for the next call. */
179
+ export function frameRows(lines) {
180
+ return lines.length;
181
+ }