ai-runtime-engine 1.3.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.
Files changed (153) hide show
  1. package/CHANGELOG.md +638 -0
  2. package/dist/agents/admit.d.ts +69 -0
  3. package/dist/agents/admit.js +129 -0
  4. package/dist/agents/definition.d.ts +36 -0
  5. package/dist/agents/definition.js +9 -0
  6. package/dist/agents/envelope.d.ts +53 -0
  7. package/dist/agents/envelope.js +68 -0
  8. package/dist/agents/finding.d.ts +79 -0
  9. package/dist/agents/finding.js +80 -0
  10. package/dist/agents/roles.d.ts +36 -0
  11. package/dist/agents/roles.js +44 -0
  12. package/dist/agents/synthesize.d.ts +44 -0
  13. package/dist/agents/synthesize.js +60 -0
  14. package/dist/agents/task.d.ts +112 -0
  15. package/dist/agents/task.js +48 -0
  16. package/dist/agents/worker.d.ts +91 -0
  17. package/dist/agents/worker.js +377 -0
  18. package/dist/capabilities/capability.d.ts +117 -0
  19. package/dist/capabilities/capability.js +66 -0
  20. package/dist/capabilities/registry.d.ts +139 -0
  21. package/dist/capabilities/registry.js +413 -0
  22. package/dist/capabilities/vocabulary.d.ts +32 -0
  23. package/dist/capabilities/vocabulary.js +34 -0
  24. package/dist/cli/cli.js +55 -4
  25. package/dist/cli/commands/cleanup.js +29 -27
  26. package/dist/cli/commands/doctor.d.ts +14 -0
  27. package/dist/cli/commands/doctor.js +38 -8
  28. package/dist/cli/commands/executions.js +34 -25
  29. package/dist/cli/commands/info.d.ts +1 -0
  30. package/dist/cli/commands/info.js +11 -9
  31. package/dist/cli/commands/init.js +19 -0
  32. package/dist/cli/commands/inspect.d.ts +40 -1
  33. package/dist/cli/commands/inspect.js +157 -2
  34. package/dist/cli/commands/mcp.d.ts +45 -0
  35. package/dist/cli/commands/mcp.js +148 -0
  36. package/dist/cli/commands/route.js +21 -0
  37. package/dist/cli/commands/run.d.ts +1 -0
  38. package/dist/cli/commands/run.js +21 -2
  39. package/dist/cli/commands/skills.d.ts +2 -0
  40. package/dist/cli/commands/skills.js +29 -7
  41. package/dist/cli/interactive/ansi.d.ts +41 -0
  42. package/dist/cli/interactive/ansi.js +43 -0
  43. package/dist/cli/interactive/complete.d.ts +10 -0
  44. package/dist/cli/interactive/complete.js +19 -0
  45. package/dist/cli/interactive/lanes.d.ts +69 -0
  46. package/dist/cli/interactive/lanes.js +181 -0
  47. package/dist/cli/interactive/repl.d.ts +3 -0
  48. package/dist/cli/interactive/repl.js +91 -13
  49. package/dist/cli/interactive/session.d.ts +8 -0
  50. package/dist/cli/interactive/session.js +73 -2
  51. package/dist/cli/render.d.ts +7 -0
  52. package/dist/cli/render.js +10 -0
  53. package/dist/cli/runtimeSession.d.ts +11 -0
  54. package/dist/cli/runtimeSession.js +17 -0
  55. package/dist/config/defaults.d.ts +3 -1
  56. package/dist/config/defaults.js +2 -0
  57. package/dist/config/schema.d.ts +1 -0
  58. package/dist/config/schema.js +2 -2
  59. package/dist/context/lossVerifier.d.ts +24 -0
  60. package/dist/context/lossVerifier.js +45 -0
  61. package/dist/context/summarize.d.ts +19 -0
  62. package/dist/context/summarize.js +53 -0
  63. package/dist/core/fallback/fallback.d.ts +5 -0
  64. package/dist/core/fallback/fallback.js +3 -1
  65. package/dist/core/router/router.d.ts +3 -0
  66. package/dist/core/router/router.js +1 -0
  67. package/dist/executions/agentTasks.d.ts +627 -0
  68. package/dist/executions/agentTasks.js +149 -0
  69. package/dist/executions/checkpoint.d.ts +5 -1
  70. package/dist/executions/checkpoint.js +13 -1
  71. package/dist/executions/execution.d.ts +36 -2
  72. package/dist/executions/store.d.ts +37 -0
  73. package/dist/executions/store.js +33 -0
  74. package/dist/generation/generateAdapter.d.ts +14 -0
  75. package/dist/generation/generateAdapter.js +38 -0
  76. package/dist/generation/generateSkill.d.ts +26 -0
  77. package/dist/generation/generateSkill.js +51 -0
  78. package/dist/index.d.ts +47 -4
  79. package/dist/index.js +33 -2
  80. package/dist/mcp/client.d.ts +70 -0
  81. package/dist/mcp/client.js +221 -0
  82. package/dist/mcp/manager.d.ts +151 -0
  83. package/dist/mcp/manager.js +493 -0
  84. package/dist/mcp/protocol.d.ts +216 -0
  85. package/dist/mcp/protocol.js +149 -0
  86. package/dist/mcp/toolAdapter.d.ts +44 -0
  87. package/dist/mcp/toolAdapter.js +94 -0
  88. package/dist/mcp/transport.d.ts +109 -0
  89. package/dist/mcp/transport.js +383 -0
  90. package/dist/memory/embedders/hash.d.ts +12 -0
  91. package/dist/memory/embedders/hash.js +31 -0
  92. package/dist/memory/embedders/http.d.ts +25 -0
  93. package/dist/memory/embedders/http.js +48 -0
  94. package/dist/memory/memory.d.ts +19 -2
  95. package/dist/memory/memory.js +75 -11
  96. package/dist/memory/semantic.d.ts +17 -0
  97. package/dist/memory/semantic.js +29 -0
  98. package/dist/orchestration/budget.d.ts +30 -0
  99. package/dist/orchestration/budget.js +40 -0
  100. package/dist/orchestration/executor.d.ts +64 -1
  101. package/dist/orchestration/executor.js +104 -7
  102. package/dist/orchestration/orchestrator.d.ts +35 -1
  103. package/dist/orchestration/orchestrator.js +106 -8
  104. package/dist/orchestration/plan.d.ts +15 -1
  105. package/dist/orchestration/plan.js +23 -4
  106. package/dist/orchestration/planner.d.ts +19 -1
  107. package/dist/orchestration/planner.js +25 -5
  108. package/dist/plugin/ai.d.ts +4 -0
  109. package/dist/plugin/ai.js +9 -0
  110. package/dist/runtime/config.js +50 -6
  111. package/dist/runtime/intent/aiClassifier.d.ts +19 -0
  112. package/dist/runtime/intent/aiClassifier.js +74 -0
  113. package/dist/runtime/models/modelProfile.d.ts +61 -0
  114. package/dist/runtime/models/modelProfile.js +139 -0
  115. package/dist/runtime/planning/deriveCapabilities.d.ts +95 -0
  116. package/dist/runtime/planning/deriveCapabilities.js +146 -0
  117. package/dist/runtime/policy.d.ts +10 -0
  118. package/dist/runtime/policy.js +9 -2
  119. package/dist/runtime/runtime.d.ts +233 -0
  120. package/dist/runtime/runtime.js +1042 -60
  121. package/dist/runtime/types.d.ts +88 -2
  122. package/dist/security/redact.js +22 -10
  123. package/dist/skills/manifest.d.ts +3 -0
  124. package/dist/skills/manifest.js +24 -0
  125. package/dist/skills/registry.d.ts +16 -1
  126. package/dist/skills/registry.js +21 -1
  127. package/dist/skills/skill.d.ts +6 -1
  128. package/dist/store/area.d.ts +15 -1
  129. package/dist/store/area.js +19 -8
  130. package/dist/store/crypto.d.ts +21 -0
  131. package/dist/store/crypto.js +49 -0
  132. package/dist/store/paths.d.ts +5 -1
  133. package/dist/store/paths.js +6 -0
  134. package/dist/store/store.d.ts +15 -3
  135. package/dist/store/store.js +28 -7
  136. package/dist/telemetry/sinks/otlp.d.ts +31 -0
  137. package/dist/telemetry/sinks/otlp.js +76 -0
  138. package/dist/tools/builtins/filesystem.js +1 -0
  139. package/dist/tools/builtins/git.js +1 -0
  140. package/dist/tools/builtins/shell.js +1 -0
  141. package/dist/tools/permissions.d.ts +28 -0
  142. package/dist/tools/permissions.js +72 -0
  143. package/dist/tools/registry.d.ts +18 -2
  144. package/dist/tools/registry.js +22 -2
  145. package/dist/tools/tool.d.ts +4 -0
  146. package/dist/types.d.ts +5 -1
  147. package/dist/util/flatten.d.ts +11 -0
  148. package/dist/util/flatten.js +18 -0
  149. package/dist/util/hash.d.ts +19 -0
  150. package/dist/util/hash.js +39 -0
  151. package/dist/util/semaphore.d.ts +19 -0
  152. package/dist/util/semaphore.js +60 -0
  153. package/package.json +24 -9
@@ -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
+ }
@@ -1,6 +1,9 @@
1
1
  /**
2
2
  * The interactive REPL — a thin node:readline wrapper around ReplSession. All output goes through the
3
3
  * redacted `print`. Progress is shown by subscribing to the runtime's lifecycle events. Zero new deps.
4
+ * Phase 21b adds (gated on an interactive TTY): a colored banner/prompt, slash-command tab-completion, a
5
+ * best-effort persistent history file, and an in-place spinner while a run is in flight. Non-TTY/CI output
6
+ * stays plain, and `ReplSession.handle` (the tested surface) is untouched.
4
7
  */
5
8
  /** Start the interactive session. Resolves when the user exits (or stdin closes). */
6
9
  export declare function startRepl(configPath?: string): Promise<void>;
@@ -1,66 +1,142 @@
1
1
  /**
2
2
  * The interactive REPL — a thin node:readline wrapper around ReplSession. All output goes through the
3
3
  * redacted `print`. Progress is shown by subscribing to the runtime's lifecycle events. Zero new deps.
4
+ * Phase 21b adds (gated on an interactive TTY): a colored banner/prompt, slash-command tab-completion, a
5
+ * best-effort persistent history file, and an in-place spinner while a run is in flight. Non-TTY/CI output
6
+ * stays plain, and `ReplSession.handle` (the tested surface) is untouched.
4
7
  */
5
8
  import { createInterface } from 'node:readline';
9
+ import { readFileSync, appendFileSync, mkdirSync } from 'node:fs';
10
+ import { join, dirname } from 'node:path';
6
11
  import { Runtime } from '../../runtime/runtime.js';
7
12
  import { print, printChunk, printError } from '../render.js';
8
13
  import { summarizeWorkspace } from '../../runtime/workspace/workspace.js';
9
- import { ReplSession } from './session.js';
10
- function banner(rt) {
14
+ import { ReplSession, SLASH_COMMANDS } from './session.js';
15
+ import { makeCompleter } from './complete.js';
16
+ import { colorEnabled, bold, cyan, dim, gray, SPINNER_FRAMES, statusLine, clearLine } from './ansi.js';
17
+ function banner(rt, colors) {
11
18
  const ws = rt.workspaceInfo();
12
- print('ai-runtime');
19
+ print(bold('ai-runtime', colors));
13
20
  if (ws)
14
- print(summarizeWorkspace(ws));
15
- print(`mode: auto · ${rt.ai.providers().length} providers · type /help or a request, /exit to quit`);
21
+ print(dim(summarizeWorkspace(ws), colors));
22
+ print(gray(`mode: auto · ${rt.ai.providers().length} providers · type /help or a request, /exit to quit`, colors));
16
23
  print('');
17
24
  }
18
25
  /** A concise, redacted progress line for a lifecycle event — the REPL's live feedback during a run. */
19
- function progressLine(e) {
26
+ function progressLine(e, colors) {
20
27
  switch (e.type) {
21
28
  case 'mode.selected':
22
- return e.source === 'auto' ? ` · mode: ${e.selected} (auto, ${Math.round(e.confidence * 100)}%)` : ` · mode: ${e.selected}`;
29
+ return dim(e.source === 'auto' ? ` · mode: ${e.selected} (auto, ${Math.round(e.confidence * 100)}%)` : ` · mode: ${e.selected}`, colors);
23
30
  case 'provider.selected':
24
- return ` · routed → ${e.providerId}/${e.model}`;
31
+ return dim(` · routed → ${e.providerId}/${e.model}`, colors);
25
32
  case 'provider.failed':
26
- return ` · ${e.providerId}/${e.model} failed${e.category ? ` (${e.category})` : ''} — falling back`;
33
+ return dim(` · ${e.providerId}/${e.model} failed${e.category ? ` (${e.category})` : ''} — falling back`, colors);
27
34
  default:
28
35
  return undefined; // runtime.started / clarification.requested / run.completed are covered by the result block
29
36
  }
30
37
  }
38
+ /** An in-place spinner (TTY only). `stop()` clears the line and is idempotent. */
39
+ function startSpinner(colors) {
40
+ if (!colors)
41
+ return { stop: () => { } };
42
+ let i = 0;
43
+ const tick = () => {
44
+ try {
45
+ process.stdout.write(statusLine(`${cyan(SPINNER_FRAMES[i % SPINNER_FRAMES.length], colors)} working…`, colors));
46
+ }
47
+ catch {
48
+ /* never let a draw break the REPL */
49
+ }
50
+ i += 1;
51
+ };
52
+ tick();
53
+ const handle = setInterval(tick, 90);
54
+ if (typeof handle.unref === 'function')
55
+ handle.unref();
56
+ let stopped = false;
57
+ return {
58
+ stop: () => {
59
+ if (stopped)
60
+ return;
61
+ stopped = true;
62
+ clearInterval(handle);
63
+ try {
64
+ process.stdout.write(clearLine(colors));
65
+ }
66
+ catch {
67
+ /* ignore */
68
+ }
69
+ },
70
+ };
71
+ }
31
72
  /** Start the interactive session. Resolves when the user exits (or stdin closes). */
32
73
  export async function startRepl(configPath) {
33
74
  const rt = await Runtime.load({ ...(configPath ? { config: configPath } : {}) });
75
+ const colors = colorEnabled({ isTTY: !!process.stdout.isTTY, env: process.env });
34
76
  const session = new ReplSession(rt, { streaming: true });
35
- // Tracks whether the CURRENT line produced any streamed output, so we can close the line cleanly.
77
+ // Best-effort persistent history under the project store's cache dir.
78
+ const historyPath = join(rt.store.paths().project, 'cache', 'repl_history');
79
+ let history = [];
80
+ try {
81
+ history = readFileSync(historyPath, 'utf8').split('\n').filter(Boolean).slice(-500).reverse(); // readline wants newest-first
82
+ }
83
+ catch {
84
+ /* no history yet */
85
+ }
86
+ const remember = (line) => {
87
+ if (!line.trim())
88
+ return;
89
+ try {
90
+ mkdirSync(dirname(historyPath), { recursive: true });
91
+ appendFileSync(historyPath, `${line}\n`);
92
+ }
93
+ catch {
94
+ /* history is a convenience; a write failure must never break the REPL */
95
+ }
96
+ };
36
97
  let streamedThisRun = false;
98
+ let spinner;
37
99
  // Live progress + token streaming. Events are already redacted; a throwing observer can't break a run.
38
100
  rt.on((e) => {
101
+ spinner?.stop(); // any event means work has started producing output — drop the spinner first
39
102
  if (e.type === 'response.delta') {
40
103
  printChunk(e.text);
41
104
  streamedThisRun = true;
42
105
  return;
43
106
  }
44
- const line = progressLine(e);
107
+ const line = progressLine(e, colors);
45
108
  if (line)
46
109
  print(line);
47
110
  });
48
- banner(rt);
49
- const rl = createInterface({ input: process.stdin, output: process.stdout, prompt: '> ' });
111
+ banner(rt, colors);
112
+ // The REPL is long-lived and interactive, so MCP connects EAGERLY here (unlike one-shot commands):
113
+ // the user should see their servers' tools in the very first request, and pay the cost once.
114
+ if (rt.hasMcpServers()) {
115
+ const servers = await rt.connectMcp();
116
+ const up = servers.filter((sv) => sv.state === 'connected' || sv.state === 'degraded').length;
117
+ print(gray(`mcp: ${up}/${servers.length} server(s) connected`, colors));
118
+ for (const w of rt.mcpWarningsList())
119
+ printError(`! ${w}`);
120
+ }
121
+ const rl = createInterface({ input: process.stdin, output: process.stdout, prompt: cyan('> ', colors), completer: makeCompleter(SLASH_COMMANDS), history, historySize: 500 });
50
122
  rl.prompt();
51
123
  for await (const line of rl) {
124
+ remember(line);
52
125
  let result;
53
126
  streamedThisRun = false;
127
+ spinner = startSpinner(colors);
54
128
  try {
55
129
  result = await session.handle(line);
56
130
  }
57
131
  catch (err) {
132
+ spinner.stop();
58
133
  if (streamedThisRun)
59
134
  process.stdout.write('\n');
60
135
  printError(`error: ${err instanceof Error ? err.message : String(err)}`);
61
136
  rl.prompt();
62
137
  continue;
63
138
  }
139
+ spinner.stop();
64
140
  if (streamedThisRun)
65
141
  process.stdout.write('\n'); // close the streamed line before printing result lines
66
142
  if (result.clear)
@@ -69,8 +145,10 @@ export async function startRepl(configPath) {
69
145
  print(l);
70
146
  if (result.exit) {
71
147
  rl.close();
148
+ await rt.close(); // release MCP stdio children on a clean exit
72
149
  return;
73
150
  }
74
151
  rl.prompt();
75
152
  }
153
+ await rt.close(); // stdin closed (piped input) — same release path
76
154
  }
@@ -10,6 +10,8 @@ export interface HandleResult {
10
10
  exit?: boolean;
11
11
  clear?: boolean;
12
12
  }
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"];
13
15
  export declare class ReplSession {
14
16
  private readonly runtime;
15
17
  private mode;
@@ -22,6 +24,12 @@ export declare class ReplSession {
22
24
  });
23
25
  currentMode(): RuntimeMode;
24
26
  private views;
27
+ /**
28
+ * MCP servers in the REPL. The session's Runtime is already connected (the REPL connects eagerly at
29
+ * start), so this NEVER opens a second Runtime — that would spawn duplicate stdio children and give
30
+ * `close()` the wrong one to tear down. It reuses the exact formatters the `mcp` command prints.
31
+ */
32
+ private mcpView;
25
33
  handle(raw: string): Promise<HandleResult>;
26
34
  private runInput;
27
35
  private memory;
@@ -9,6 +9,16 @@ import { SETUP_CANDIDATES } from '../commands/setup.js';
9
9
  import { gatherInfo, renderInfo } from '../commands/info.js';
10
10
  import { gatherDoctor, renderDoctor } from '../commands/doctor.js';
11
11
  import { planCleanup } from '../commands/cleanup.js';
12
+ import { actionCapabilityRows, renderActionCapabilities, renderCapabilityGaps } from '../commands/inspect.js';
13
+ import { renderMcpList, renderMcpDetail } from '../commands/mcp.js';
14
+ import { candidatesFrom, deriveCapabilitiesOffline } from '../../runtime/planning/deriveCapabilities.js';
15
+ import { displaySafe } from '../render.js';
16
+ /** Top-level slash commands, for REPL tab-completion (Phase 21b). Kept in sync with the `handle` dispatch. */
17
+ export const SLASH_COMMANDS = [
18
+ 'help', 'status', 'info', 'doctor', 'cleanup', 'mode', 'compare', 'models', 'config', 'providers', 'tools', 'capabilities', 'mcp',
19
+ 'skills', 'memory', 'conversations', 'executions', 'resume', 'resume-execution', 'pause', 'cancel', 'approve',
20
+ 'deny', 'learning', 'feedback', 'permissions', 'budget', 'stream', 'dry-run', 'clear', 'exit', 'quit',
21
+ ];
12
22
  const HELP = [
13
23
  'Commands:',
14
24
  ' /help show this help',
@@ -24,6 +34,10 @@ const HELP = [
24
34
  ' /providers/<id> [models|capabilities|health] inspect one provider',
25
35
  ' /models list all known models',
26
36
  ' /tools list registered tools',
37
+ ' /capabilities list action capabilities (what the runtime can DO) + providers',
38
+ ' /capabilities <goal> which capabilities a goal needs, and what is missing (offline)',
39
+ ' /mcp list MCP servers (state, tools, grant)',
40
+ ' /mcp/<id> inspect one MCP server (protocol, tools, diagnostics)',
27
41
  ' /skills [discover] list available skills; discover scans the workspace',
28
42
  ' /permissions show the current tool permissions',
29
43
  ' /feedback <text> record feedback on the last result (e.g. "that worked")',
@@ -43,6 +57,7 @@ const HELP = [
43
57
  ' /config show the resolved configuration',
44
58
  ' /dry-run toggle dry-run (plan only, no changes)',
45
59
  ' /stream toggle token-by-token streaming of answers',
60
+ ' /budget show the call/cost budget (AI_MAX_CALLS / AI_MAX_COST_USD)',
46
61
  ' /clear clear the screen',
47
62
  ' /exit leave the session',
48
63
  '',
@@ -67,6 +82,27 @@ export class ReplSession {
67
82
  this.viewCache = new ProviderViewCache(this.runtime.ai);
68
83
  return this.viewCache;
69
84
  }
85
+ /**
86
+ * MCP servers in the REPL. The session's Runtime is already connected (the REPL connects eagerly at
87
+ * start), so this NEVER opens a second Runtime — that would spawn duplicate stdio children and give
88
+ * `close()` the wrong one to tear down. It reuses the exact formatters the `mcp` command prints.
89
+ */
90
+ mcpView(target) {
91
+ if (!this.runtime.hasMcpServers())
92
+ return { lines: renderMcpList({}, []) };
93
+ const grants = this.runtime.permissions().mcp.servers;
94
+ if (!target) {
95
+ const lines = renderMcpList(grants, this.runtime.mcp().list());
96
+ const warnings = this.runtime.mcpWarningsList();
97
+ if (warnings.length)
98
+ lines.push('', 'Warnings:', ...warnings.map((w) => ` ! ${w}`));
99
+ return { lines };
100
+ }
101
+ const status = this.runtime.mcp().status(target);
102
+ if (!status)
103
+ return { lines: [`no MCP server '${displaySafe(target, 40)}' — /mcp lists them`] };
104
+ return { lines: renderMcpDetail(grants, status, this.runtime.mcp().diagnostics(target), this.runtime.mcp().tools(target)) };
105
+ }
70
106
  async handle(raw) {
71
107
  const line = raw.trim();
72
108
  if (!line)
@@ -109,6 +145,24 @@ export class ReplSession {
109
145
  return this.config();
110
146
  case 'providers':
111
147
  return this.providers(parts[1], args[0]);
148
+ case 'capabilities': {
149
+ // `capabilities --actions` connects (inspect.ts); without this the same question reported
150
+ // different MCP availability on the two surfaces. connectMcp is idempotent.
151
+ if (this.runtime.hasMcpServers())
152
+ await this.runtime.connectMcp();
153
+ // Both forms, and never a truncated goal: `/capabilities run the tests` and
154
+ // `/capabilities/run the tests` must mean the same thing.
155
+ const goal = [...parts.slice(1), ...args].join(' ').trim();
156
+ if (!goal)
157
+ return { lines: renderActionCapabilities(actionCapabilityRows(this.runtime)) };
158
+ const required = deriveCapabilitiesOffline(goal, candidatesFrom(this.runtime.capabilities().list()));
159
+ if (!required.length)
160
+ return { lines: [`no action capability matched "${displaySafe(goal, 80)}" — /capabilities lists everything this runtime can do.`] };
161
+ return { lines: renderCapabilityGaps(this.runtime.capabilityReport(required)) };
162
+ }
163
+ case 'mcp':
164
+ // `/mcp/<id>` and `/mcp <id>` both address one server.
165
+ return this.mcpView(parts[1] ?? args[0]);
112
166
  case 'tools':
113
167
  return { lines: ['Tools:', ...this.runtime.tools().map((t) => ` ${t.id.padEnd(14)} ${t.description}`)] };
114
168
  case 'skills': {
@@ -139,6 +193,14 @@ export class ReplSession {
139
193
  case 'stream':
140
194
  this.streaming = !this.streaming;
141
195
  return { lines: [`streaming ${this.streaming ? 'ON — answers render token-by-token' : 'OFF'}`] };
196
+ case 'budget':
197
+ return {
198
+ lines: [
199
+ `call budget (AI_MAX_CALLS): ${process.env.AI_MAX_CALLS ?? '(unset — no limit)'}`,
200
+ `cost budget (AI_MAX_COST_USD): ${process.env.AI_MAX_COST_USD ?? '(unset — no limit)'}`,
201
+ 'over budget: notify-and-wait by default; add --partial (one-shot) to run the phases that fit and pause.',
202
+ ],
203
+ };
142
204
  case 'memory':
143
205
  return this.memory(args);
144
206
  case 'conversations':
@@ -182,17 +244,25 @@ export class ReplSession {
182
244
  const execId = result.execution?.id;
183
245
  lines.push(execId ? `(approval required — /approve ${execId} to proceed, or /deny ${execId})` : '(approval required — approve the execution via /executions then /approve <id>)');
184
246
  }
247
+ if (result.status === 'waiting_for_budget') {
248
+ const execId = result.execution?.id;
249
+ lines.push(execId ? `(over budget — raise AI_MAX_CALLS and /resume-execution ${execId}, or re-run with more budget)` : '(over budget — raise the call budget and re-run, or add --partial)');
250
+ }
185
251
  if (result.status === 'failed')
186
252
  lines.push('(did not complete)');
187
253
  if (result.clarification)
188
254
  lines.push(`? ${result.clarification.question}`);
189
255
  if (result.memory?.captured)
190
256
  lines.push('(remembered)');
257
+ // Phase 3.3: the ONE gap renderer, shared with `ai-runtime run` and both capabilities surfaces.
258
+ // Appended AFTER the existing advisory tail so every positional assertion above is untouched.
259
+ if (result.capabilityGaps)
260
+ lines.push(...renderCapabilityGaps(result.capabilityGaps));
191
261
  if (this.conversationId && result.response?.text)
192
262
  this.runtime.conversations.append(this.conversationId, 'assistant', result.response.text, result.runId);
193
263
  return { lines };
194
264
  }
195
- memory(args) {
265
+ async memory(args) {
196
266
  if (!this.runtime.memory.enabled)
197
267
  return { lines: ['memory is disabled (stateless mode).'] };
198
268
  const sub = args[0];
@@ -200,7 +270,8 @@ export class ReplSession {
200
270
  const q = args.slice(1).join(' ');
201
271
  if (!q)
202
272
  return { lines: ['usage: /memory search <query>'] };
203
- const hits = this.runtime.memory.search(q, { limit: 10 });
273
+ // Semantic ranking when an embedder is configured; degrades to BM25 internally.
274
+ const hits = await this.runtime.memory.searchSemantic(q, { limit: 10 });
204
275
  return { lines: hits.length ? hits.map((r) => ` ${r.id} [${r.scope}] ${r.text}`) : ['no matches.'] };
205
276
  }
206
277
  if (sub === 'delete') {
@@ -11,3 +11,10 @@ export declare function print(line: string): void;
11
11
  */
12
12
  export declare function printChunk(chunk: string): void;
13
13
  export declare function printError(line: string): void;
14
+ /**
15
+ * Clamp a source- or MODEL-controlled string before it reaches the terminal — the terminal twin of
16
+ * `promptSafe`. LOAD-BEARING: `print` redacts secrets but strips no control characters and bounds no
17
+ * length, and a gap's `capabilityId` originates in the model's plan JSON — `sanitizeSegment` restricts
18
+ * its charset but sets no maximum, and the `register` gap hint interpolates it.
19
+ */
20
+ export declare function displaySafe(raw: string, max?: number): string;
@@ -4,6 +4,7 @@
4
4
  * directly from a command.
5
5
  */
6
6
  import { redactString } from '../security/redact.js';
7
+ import { flattenClamp } from '../util/flatten.js';
7
8
  export function print(line) {
8
9
  // eslint-disable-next-line no-console
9
10
  console.log(redactString(line));
@@ -20,3 +21,12 @@ export function printError(line) {
20
21
  // eslint-disable-next-line no-console
21
22
  console.error(redactString(line));
22
23
  }
24
+ /**
25
+ * Clamp a source- or MODEL-controlled string before it reaches the terminal — the terminal twin of
26
+ * `promptSafe`. LOAD-BEARING: `print` redacts secrets but strips no control characters and bounds no
27
+ * length, and a gap's `capabilityId` originates in the model's plan JSON — `sanitizeSegment` restricts
28
+ * its charset but sets no maximum, and the `register` gap hint interpolates it.
29
+ */
30
+ export function displaySafe(raw, max = 80) {
31
+ return flattenClamp(raw, max);
32
+ }
@@ -0,0 +1,11 @@
1
+ /**
2
+ * One place where a CLI command borrows a Runtime and gives it back.
3
+ *
4
+ * A `Runtime` can own OS resources — today MCP stdio child processes. A one-shot command that returns
5
+ * without releasing them does not merely leak: a live child keeps the event loop alive, so the CLI never
6
+ * exits. `withRuntime` guarantees the release on every path, including a thrown error.
7
+ */
8
+ import { Runtime } from '../runtime/runtime.js';
9
+ export declare function withRuntime<T>(opts: {
10
+ config?: string;
11
+ }, fn: (rt: Runtime) => Promise<T>): Promise<T>;
@@ -0,0 +1,17 @@
1
+ /**
2
+ * One place where a CLI command borrows a Runtime and gives it back.
3
+ *
4
+ * A `Runtime` can own OS resources — today MCP stdio child processes. A one-shot command that returns
5
+ * without releasing them does not merely leak: a live child keeps the event loop alive, so the CLI never
6
+ * exits. `withRuntime` guarantees the release on every path, including a thrown error.
7
+ */
8
+ import { Runtime } from '../runtime/runtime.js';
9
+ export async function withRuntime(opts, fn) {
10
+ const rt = await Runtime.load({ ...(opts.config ? { config: opts.config } : {}) });
11
+ try {
12
+ return await fn(rt);
13
+ }
14
+ finally {
15
+ await rt.close();
16
+ }
17
+ }