ai-runtime-engine 2.7.0 → 2.9.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);
@@ -69,10 +120,15 @@ export async function runAgentTask(step, envelope, definition, deps) {
69
120
  const skillResults = [];
70
121
  const finish = (state, failure) => {
71
122
  deps.parentSignal?.removeEventListener('abort', onParentAbort);
123
+ deps.releaseAbort?.(record.agentTaskId);
72
124
  record.state = state;
73
125
  record.endedAt = deps.clock.now();
74
- record.callsUsed = calls;
75
- record.toolCallsUsed = toolCalls;
126
+ record.updatedAt = record.endedAt;
127
+ record.callsUsed = priorCalls + calls;
128
+ record.toolCallsUsed = priorToolCalls + toolCalls;
129
+ // Derived, for reporting only. The real refund is that the executor charges what was SPENT, so the
130
+ // unspent part of the reservation is simply never charged — there is no ledger to get wrong.
131
+ record.callsRefunded = Math.max(0, record.callsReserved - record.callsUsed);
76
132
  if (failure)
77
133
  record.failure = failure;
78
134
  const row = AGENT_TASK_PROJECTION[state];
@@ -88,6 +144,7 @@ export async function runAgentTask(step, envelope, definition, deps) {
88
144
  ...(state === 'completed' ? { output: `${envelope.agentId}: ${record.innerSteps.succeeded}/${record.innerSteps.total} inner step(s), ${record.findings.length} finding(s)` } : {}),
89
145
  };
90
146
  deps.emit?.({ type: 'agent.task.completed', record });
147
+ deps.onRecord?.(record);
91
148
  return { observation, record };
92
149
  };
93
150
  // (12) A parent abort is a cancel or a pause; both project to the same observation, and the record
@@ -108,13 +165,16 @@ export async function runAgentTask(step, envelope, definition, deps) {
108
165
  return preempted;
109
166
  record.state = 'running';
110
167
  record.startedAt = deps.clock.now();
168
+ record.updatedAt = record.startedAt;
169
+ deps.registerAbort?.(record.agentTaskId, () => child.abort());
111
170
  deps.emit?.({ type: 'agent.task.started', record });
171
+ deps.onRecord?.(record);
112
172
  // (6) THE TOOL SEAM - defense in depth behind narrowEnvelope. Always a structured denial, never a
113
173
  // throw: the tool contract is that a refusal is visible.
114
174
  const innerCallTool = async (id, input) => {
115
175
  if (!envelope.tools.includes(id))
116
176
  return { ok: false, error: { code: 'PERMISSION', message: `'${flattenClamp(id, 40)}' is not in this agent's catalog` } };
117
- if (toolCalls + 1 > envelope.maxToolCalls)
177
+ if (priorToolCalls + toolCalls + 1 > envelope.maxToolCalls)
118
178
  return { ok: false, error: { code: 'PERMISSION', message: `agent tool-call budget exhausted (${envelope.maxToolCalls})` } };
119
179
  if (expired())
120
180
  return { ok: false, error: { code: 'TIMEOUT', message: 'agent time budget exhausted' } };
@@ -127,20 +187,59 @@ export async function runAgentTask(step, envelope, definition, deps) {
127
187
  const innerGoal = [
128
188
  envelope.objective,
129
189
  wrapUntrusted(`agent-input:${envelope.agentId}`, flattenClamp(JSON.stringify(step.input ?? null), AGENT_INPUT_MAX)),
190
+ // The answer to THIS task's own question, routed here rather than appended to the outer goal —
191
+ // appending it there would replan the outer plan and discard every sibling agent's work.
192
+ ...(deps.resumeAnswer ? [wrapUntrusted(`agent-answer:${envelope.agentId}`, flattenClamp(deps.resumeAnswer, AGENT_INPUT_MAX))] : []),
130
193
  ].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) });
194
+ // A persisted inner plan is REUSED rather than regenerated — that is what makes a resume cheap and
195
+ // what stops completed inner work being redone. It is still re-validated against the envelope's
196
+ // catalog first: the plan came off disk, and `validatePlan` is the membership gate for every plan,
197
+ // resumed or fresh. A plan that no longer validates is discarded, not repaired.
198
+ let innerPlan;
199
+ const identical = deps.resume ? deps.resume.envelopeHash === envHash && deps.resume.agentDefHash === defHash : false;
200
+ // An ANSWER changes what the plan should be — that is the entire point of having asked — so a task
201
+ // being resumed with one always re-plans rather than replaying the plan made before the question.
202
+ if (deps.resume?.innerPlan && identical && !deps.resumeAnswer) {
203
+ const check = validatePlan(deps.resume.innerPlan, { skills: envelope.skills, tools: envelope.tools, agents: [] });
204
+ if (check.ok)
205
+ innerPlan = deps.resume.innerPlan;
139
206
  }
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) });
207
+ // The record now belongs to THIS run: an edited definition or a re-narrowed envelope invalidates the
208
+ // old plan (handled above) but not the task, so the hashes move forward with it.
209
+ record.agentDefHash = defHash;
210
+ record.envelopeHash = envHash;
211
+ if (!innerPlan) {
212
+ let planned;
213
+ try {
214
+ planned = await generatePlan({ goal: innerGoal, ai: metered, skills: deps.skills, tools: envelope.tools, agents: [], version: 1, ...(envelope.routing ? { routing: envelope.routing } : {}) });
215
+ }
216
+ catch (err) {
217
+ if (err instanceof AgentCallBudgetError)
218
+ return finish('failed', { code: 'agent-call-budget', message: err.message });
219
+ return finish('failed', { code: 'agent-failed', message: flattenClamp(err instanceof Error ? err.message : String(err), 160) });
220
+ }
221
+ if (planned.clarification) {
222
+ // The one inner wait that exists: the planner needs an answer. The task stays RESUMABLE and the
223
+ // step stays pending — this is not a failure, and it must not fail the branch.
224
+ record.pendingInner = { kind: 'clarification', question: flattenClamp(planned.clarification, 240), at: deps.clock.now() };
225
+ return finish('waiting_for_clarification', { code: 'agent-waiting', message: record.pendingInner.question });
226
+ }
227
+ if (!planned.plan) {
228
+ return finish('failed', { code: 'agent-failed', message: flattenClamp(planned.error ?? 'the agent could not form a plan', 160) });
229
+ }
230
+ innerPlan = planned.plan;
231
+ // A REGENERATED plan has new step ids. Carrying the discarded plan's completed ids into its skip
232
+ // set would mark steps `succeeded` that never ran — the dangerous direction of a resume, since the
233
+ // work is silently not done rather than merely done twice.
234
+ record.innerCompletedSteps = [];
235
+ record.innerObservations = [];
236
+ record.innerObservationsOmitted = 0;
142
237
  }
143
- record.innerPlanVersion = planned.plan.version;
238
+ record.innerPlan = innerPlan;
239
+ record.innerPlanVersion = innerPlan.version;
240
+ // The plan exists and nothing has run: a crash from here on resumes against a real inner plan.
241
+ record.updatedAt = deps.clock.now();
242
+ deps.onRecord?.(record);
144
243
  const aborted = abortedNow();
145
244
  if (aborted)
146
245
  return aborted;
@@ -150,7 +249,30 @@ export async function runAgentTask(step, envelope, definition, deps) {
150
249
  // a skill's own model calls, which a wave gate cannot see.
151
250
  let inner;
152
251
  try {
153
- inner = await executePlan(planned.plan, {
252
+ inner = await executePlan(innerPlan, {
253
+ skip: new Set(record.innerCompletedSteps),
254
+ // THE seam that makes inner work survive a crash. Everything between `running` and `finish()` was
255
+ // otherwise invisible to disk, so a resumed task had nothing to skip and re-ran the whole inner plan.
256
+ onProgress: (snap) => {
257
+ record.innerPlan = snap.plan;
258
+ record.innerCompletedSteps = snap.plan.steps.filter((st) => st.status === 'succeeded').map((st) => st.id);
259
+ const room = INNER_OBS_MAX - record.innerObservations.length;
260
+ if (room > 0)
261
+ record.innerObservations.push(...snap.observations.slice(0, room));
262
+ record.innerObservationsOmitted += Math.max(0, snap.observations.length - Math.max(0, room));
263
+ record.callsUsed = priorCalls + calls;
264
+ record.toolCallsUsed = priorToolCalls + toolCalls;
265
+ record.updatedAt = deps.clock.now();
266
+ record.innerSteps = { total: snap.plan.steps.length, succeeded: record.innerCompletedSteps.length };
267
+ deps.onRecord?.(record);
268
+ // One event per WAVE, plus one at the end. `wave-partition` fires BEFORE the wave runs, so on
269
+ // its own it reports the count from before — for a single-wave inner plan (the common case)
270
+ // that means the only progress event says 0/N, which is exactly what `started` already said,
271
+ // and a display would read 0/N until the task simply finished. `plan-end` fires once and is the
272
+ // only snapshot carrying the final count. Still not per-step: waves + 1 events per task.
273
+ if (snap.at === 'wave-partition' || snap.at === 'plan-end')
274
+ deps.emit?.({ type: 'agent.task.progress', record });
275
+ },
154
276
  runSkill: async (id, i) => {
155
277
  const out = await deps.runSkill(id, i, { permissions: envelope.permissions, signal: child.signal, ai: metered });
156
278
  skillResults.push({ stepId: id, result: out.result, validation: out.validation });
@@ -171,6 +293,7 @@ export async function runAgentTask(step, envelope, definition, deps) {
171
293
  return finish('failed', { code: 'agent-failed', message: flattenClamp(err instanceof Error ? err.message : String(err), 160) });
172
294
  }
173
295
  record.innerSteps = { total: inner.plan.steps.length, succeeded: inner.plan.steps.filter((s) => s.status === 'succeeded').length };
296
+ record.innerCompletedSteps = inner.plan.steps.filter((s) => s.status === 'succeeded').map((s) => s.id);
174
297
  const afterRun = abortedNow();
175
298
  if (afterRun)
176
299
  return afterRun;
@@ -239,8 +362,16 @@ export async function runAgentTask(step, envelope, definition, deps) {
239
362
  provenance: deps.provenance,
240
363
  now: deps.clock.now(),
241
364
  });
242
- record.findings = [...denials, ...admission.admitted];
243
- record.diagnostics = admission.rejected;
365
+ // MERGE, never assign. A resumed attempt that admits fewer findings than the interrupted one would
366
+ // otherwise drop the earlier ones on the floor — and observations already committed to disk reference
367
+ // them by id. Attempt >1 namespaces its ids so the two attempts can never collide.
368
+ const mint = (f) => (record.attempt > 1 ? { ...f, id: `${f.id}@a${record.attempt}` } : f);
369
+ const fresh = [...denials, ...admission.admitted].map(mint);
370
+ const seenFinding = new Set(record.findings.map((f) => f.id));
371
+ record.findings = [...record.findings, ...fresh.filter((f) => !seenFinding.has(f.id))];
372
+ // Bounded: diagnostics accumulate across attempts, and every one of them is rewritten to disk on
373
+ // every commit. Keeping the most recent is the useful half.
374
+ record.diagnostics = [...record.diagnostics, ...admission.rejected].slice(-DIAGNOSTICS_KEPT);
244
375
  if (contractFailed(admission)) {
245
376
  return finish('failed', { code: 'finding-contract', message: 'the agent did not satisfy its declared output contract' });
246
377
  }
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.9.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
+ }
@@ -13,6 +13,7 @@ import { print, printChunk, printError } from '../render.js';
13
13
  import { summarizeWorkspace } from '../../runtime/workspace/workspace.js';
14
14
  import { ReplSession, SLASH_COMMANDS } from './session.js';
15
15
  import { makeCompleter } from './complete.js';
16
+ import { LaneSet, laneLines, frameDiff, frameRows } from './lanes.js';
16
17
  import { colorEnabled, bold, cyan, dim, gray, SPINNER_FRAMES, statusLine, clearLine } from './ansi.js';
17
18
  function banner(rt, colors) {
18
19
  const ws = rt.workspaceInfo();
@@ -96,6 +97,22 @@ export async function startRepl(configPath) {
96
97
  };
97
98
  let streamedThisRun = false;
98
99
  let spinner;
100
+ // Phase 3.6: concurrent agent steps render as live lanes. The region opens on the first agent event —
101
+ // long after the spinner has been stopped by the very first event of the run — so the two never own
102
+ // the cursor at the same time. With agents disabled no agent event is ever emitted, so the region
103
+ // never opens and output is byte-identical.
104
+ const lanes = new LaneSet();
105
+ let laneRows = 0;
106
+ let laneTick = 0;
107
+ // A late event — one arriving after the run has already returned — must not open a region on top of
108
+ // the prompt and leave `laneRows` set for the NEXT run's cursor arithmetic to walk into.
109
+ let runInFlight = false;
110
+ const closeLanes = () => {
111
+ if (laneRows > 0)
112
+ printChunk(frameDiff(laneRows, [], colors));
113
+ laneRows = 0;
114
+ lanes.clear();
115
+ };
99
116
  // Live progress + token streaming. Events are already redacted; a throwing observer can't break a run.
100
117
  rt.on((e) => {
101
118
  spinner?.stop(); // any event means work has started producing output — drop the spinner first
@@ -104,6 +121,30 @@ export async function startRepl(configPath) {
104
121
  streamedThisRun = true;
105
122
  return;
106
123
  }
124
+ const lane = lanes.observe(e);
125
+ if (lane) {
126
+ if (!runInFlight)
127
+ return; // see `runInFlight`
128
+ laneTick += 1;
129
+ if (colors) {
130
+ // The region must fit the viewport: `cursorUp` saturates at row 0, so a frame taller than the
131
+ // pane can never walk back to its own top and would redraw itself downward forever.
132
+ const budget = Math.max(1, (process.stdout.rows || 24) - 2);
133
+ const all = lanes.list();
134
+ const shown = all.length > budget ? all.slice(0, budget - 1) : all;
135
+ const lines = laneLines(shown, { cols: process.stdout.columns || 80, colors, tick: laneTick });
136
+ if (all.length > shown.length)
137
+ lines.push(dim(` … ${all.length - shown.length} more agent task(s)`, colors));
138
+ printChunk(frameDiff(laneRows, lines, colors));
139
+ laneRows = frameRows(lines);
140
+ }
141
+ else if (e.type !== 'agent.task.progress') {
142
+ // Piped, NO_COLOR or a dumb terminal: cursor games would be garbage in a log file, so report
143
+ // the transitions append-only instead. Progress ticks are dropped — in a log they are noise.
144
+ print(` · agent ${lane.agentId} @ ${lane.stepId}: ${lane.state}`);
145
+ }
146
+ return;
147
+ }
107
148
  const line = progressLine(e, colors);
108
149
  if (line)
109
150
  print(line);
@@ -125,18 +166,29 @@ export async function startRepl(configPath) {
125
166
  let result;
126
167
  streamedThisRun = false;
127
168
  spinner = startSpinner(colors);
169
+ runInFlight = true;
170
+ // Readline keeps echoing keypresses while the handler awaits, and the cursor sits inside the lane
171
+ // region — so a keystroke mid-run writes into the frame and its clear-to-end-of-screen erases the
172
+ // rows below it. Pausing buffers the input instead; it is resumed on both exits below.
173
+ rl.pause();
128
174
  try {
129
175
  result = await session.handle(line);
130
176
  }
131
177
  catch (err) {
178
+ runInFlight = false;
179
+ rl.resume();
132
180
  spinner.stop();
181
+ closeLanes();
133
182
  if (streamedThisRun)
134
183
  process.stdout.write('\n');
135
184
  printError(`error: ${err instanceof Error ? err.message : String(err)}`);
136
185
  rl.prompt();
137
186
  continue;
138
187
  }
188
+ runInFlight = false;
189
+ rl.resume();
139
190
  spinner.stop();
191
+ closeLanes(); // hand the cursor back before any result line is printed
140
192
  if (streamedThisRun)
141
193
  process.stdout.write('\n'); // close the streamed line before printing result lines
142
194
  if (result.clear)
@@ -11,7 +11,10 @@ export interface HandleResult {
11
11
  clear?: boolean;
12
12
  }
13
13
  /** Top-level slash commands, for REPL tab-completion (Phase 21b). Kept in sync with the `handle` dispatch. */
14
- export declare const SLASH_COMMANDS: readonly ["help", "status", "info", "doctor", "cleanup", "mode", "compare", "models", "config", "providers", "tools", "capabilities", "mcp", "skills", "memory", "conversations", "executions", "resume", "resume-execution", "pause", "cancel", "approve", "deny", "learning", "feedback", "permissions", "budget", "stream", "dry-run", "clear", "exit", "quit"];
14
+ export declare const SLASH_COMMANDS: readonly ["help", "status", "info", "doctor", "cleanup", "mode", "compare", "models", "config", "providers", "tools", "capabilities", "mcp", "skills", "memory", "conversations", "executions", "agents", "resume", "resume-execution", "pause", "cancel", "approve", "deny", "learning", "feedback", "permissions", "budget", "stream", "dry-run", "clear", "exit", "quit"];
15
+ /** Exported so a test can prove every reachable command is documented — the three touch points below
16
+ * are synced by hand, and `/agents` shipped tab-completable but absent from this list. */
17
+ export declare const HELP: string[];
15
18
  export declare class ReplSession {
16
19
  private readonly runtime;
17
20
  private mode;
@@ -36,6 +39,10 @@ export declare class ReplSession {
36
39
  private learning;
37
40
  private permissions;
38
41
  private conversationsList;
42
+ /** Agent tasks across this project's executions, newest first. */
43
+ private agentsList;
44
+ /** Stop one agent task. Every outcome is reported — a stop that looks like nothing happened is a bug. */
45
+ private agentStop;
39
46
  private executionsList;
40
47
  private resumeExecution;
41
48
  private resume;