@pugi/cli 0.1.0-alpha.3 → 0.1.0-alpha.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +20 -0
- package/dist/commands/jobs.js +245 -0
- package/dist/core/agents/registry.js +69 -0
- package/dist/core/bash-classifier.js +1001 -0
- package/dist/core/context/builder.js +114 -0
- package/dist/core/context/compaction-events.js +99 -0
- package/dist/core/context/compaction.js +602 -0
- package/dist/core/context/invariants.js +250 -0
- package/dist/core/context/markdown-loader.js +270 -0
- package/dist/core/engine/compaction-hook.js +154 -0
- package/dist/core/engine/index.js +5 -0
- package/dist/core/engine/prompts.js +42 -0
- package/dist/core/engine/tool-bridge.js +159 -61
- package/dist/core/hooks.js +415 -0
- package/dist/core/jobs/registry.js +462 -0
- package/dist/core/mcp/client.js +316 -0
- package/dist/core/mcp/registry.js +171 -0
- package/dist/core/mcp/trust.js +91 -0
- package/dist/core/permission.js +221 -116
- package/dist/core/repl/cap-warning.js +91 -0
- package/dist/core/repl/session.js +399 -0
- package/dist/core/repl/slash-commands.js +116 -0
- package/dist/core/session.js +168 -0
- package/dist/core/subagents/dispatcher.js +258 -0
- package/dist/core/subagents/index.js +26 -0
- package/dist/core/subagents/spawn.js +86 -0
- package/dist/core/trust.js +109 -0
- package/dist/runtime/cli.js +158 -46
- package/dist/runtime/commands/budget.js +192 -0
- package/dist/runtime/commands/config.js +231 -0
- package/dist/runtime/commands/privacy.js +107 -0
- package/dist/runtime/commands/undo.js +329 -0
- package/dist/tools/bash.js +660 -0
- package/dist/tui/agent-tree.js +66 -0
- package/dist/tui/conversation-pane.js +45 -0
- package/dist/tui/input-box.js +91 -0
- package/dist/tui/login-picker.js +69 -0
- package/dist/tui/render.js +68 -0
- package/dist/tui/repl-render.js +218 -0
- package/dist/tui/repl.js +152 -0
- package/dist/tui/splash-data.js +61 -0
- package/dist/tui/splash.js +31 -0
- package/dist/tui/status-bar.js +58 -0
- package/package.json +11 -5
|
@@ -0,0 +1,399 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* REPL session lifecycle - Sprint α5.7 (ADR-0056 PR-PUGI-CLI-REPL-DEFAULT).
|
|
3
|
+
*
|
|
4
|
+
* Owns the state machine that the REPL UI subscribes to:
|
|
5
|
+
*
|
|
6
|
+
* 1. Open a server-side Pugi session via POST /api/pugi/sessions.
|
|
7
|
+
* The CLI keeps a sessionId; reconnect uses it.
|
|
8
|
+
* 2. Subscribe to GET /api/pugi/sessions/:id/stream (SSE). Each event
|
|
9
|
+
* pushes one of: agent.spawned, agent.step, agent.tokens,
|
|
10
|
+
* agent.completed, agent.blocked, agent.failed.
|
|
11
|
+
* 3. Dispatch a brief via POST /api/pugi/sessions/:id/brief.
|
|
12
|
+
* 4. Track active dispatches so the cap-warning gate has a number.
|
|
13
|
+
* 5. Reconnect with Last-Event-ID on transient failure (10 retries,
|
|
14
|
+
* exponential backoff capped at 5s) so the operator sees a stable
|
|
15
|
+
* stream even on flaky connections.
|
|
16
|
+
*
|
|
17
|
+
* The module is environment-agnostic: callers inject `fetch` (Node 22
|
|
18
|
+
* native or a stub from a test) and `EventSource` (a polyfill or
|
|
19
|
+
* a fake). The REPL UI passes the production singletons in
|
|
20
|
+
* `runtime/cli.ts`; tests pass in-memory stand-ins so the contract
|
|
21
|
+
* surface is exercisable without a network.
|
|
22
|
+
*
|
|
23
|
+
* Brand voice: the conversation transcript is line-based, persona-
|
|
24
|
+
* prefixed (Mira / Marcus / Hiroshi / Vera / Anika / Olivia / Diego /
|
|
25
|
+
* Sofia per @pugi/personas). Forbidden words gate applies to every
|
|
26
|
+
* line we synthesize client-side; server-side events are passed through
|
|
27
|
+
* verbatim - the brand gate on those happens at the controller.
|
|
28
|
+
*/
|
|
29
|
+
import { randomUUID } from 'node:crypto';
|
|
30
|
+
import { listRoles, getPersonaForRole } from '../agents/registry.js';
|
|
31
|
+
import { evaluateCap, describeVerdict } from './cap-warning.js';
|
|
32
|
+
import { parseSlashCommand } from './slash-commands.js';
|
|
33
|
+
const MAX_TRANSCRIPT_ROWS = 500;
|
|
34
|
+
const MAX_RECONNECT_ATTEMPTS = 10;
|
|
35
|
+
const RECONNECT_BASE_MS = 250;
|
|
36
|
+
const RECONNECT_MAX_MS = 5_000;
|
|
37
|
+
export class ReplSession {
|
|
38
|
+
options;
|
|
39
|
+
subscribers = new Set();
|
|
40
|
+
state;
|
|
41
|
+
streamHandle;
|
|
42
|
+
lastEventId;
|
|
43
|
+
reconnectAttempt = 0;
|
|
44
|
+
reconnectTimer;
|
|
45
|
+
closed = false;
|
|
46
|
+
constructor(options) {
|
|
47
|
+
this.options = options;
|
|
48
|
+
this.state = {
|
|
49
|
+
sessionId: undefined,
|
|
50
|
+
workspaceLabel: options.workspaceLabel,
|
|
51
|
+
cliVersion: options.cliVersion,
|
|
52
|
+
connection: 'connecting',
|
|
53
|
+
agents: [],
|
|
54
|
+
transcript: [],
|
|
55
|
+
tokensDownstreamTotal: 0,
|
|
56
|
+
briefStartedAtEpochMs: undefined,
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
/* ------------- subscribe / state -------------- */
|
|
60
|
+
subscribe(callback) {
|
|
61
|
+
this.subscribers.add(callback);
|
|
62
|
+
callback(this.state);
|
|
63
|
+
return () => {
|
|
64
|
+
this.subscribers.delete(callback);
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
getState() {
|
|
68
|
+
return this.state;
|
|
69
|
+
}
|
|
70
|
+
/* ------------- lifecycle -------------- */
|
|
71
|
+
/**
|
|
72
|
+
* Create the server-side session and open the SSE stream. Idempotent;
|
|
73
|
+
* a second call after `close()` resurrects the connection but
|
|
74
|
+
* issues a fresh server session id.
|
|
75
|
+
*/
|
|
76
|
+
async start() {
|
|
77
|
+
this.closed = false;
|
|
78
|
+
try {
|
|
79
|
+
const { sessionId } = await this.options.transport.createSession({
|
|
80
|
+
apiUrl: this.options.apiUrl,
|
|
81
|
+
apiKey: this.options.apiKey,
|
|
82
|
+
});
|
|
83
|
+
this.patch({ sessionId, connection: 'connecting' });
|
|
84
|
+
this.openStream();
|
|
85
|
+
}
|
|
86
|
+
catch (error) {
|
|
87
|
+
this.appendSystemLine(`Could not open Pugi session: ${this.errorMessage(error)}`);
|
|
88
|
+
this.patch({ connection: 'offline' });
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
/**
|
|
92
|
+
* Tear down the SSE stream and stop the reconnect timer. The session
|
|
93
|
+
* id stays valid server-side; `pugi resume <id>` reopens later.
|
|
94
|
+
*/
|
|
95
|
+
close() {
|
|
96
|
+
this.closed = true;
|
|
97
|
+
if (this.streamHandle) {
|
|
98
|
+
this.streamHandle.close();
|
|
99
|
+
this.streamHandle = undefined;
|
|
100
|
+
}
|
|
101
|
+
if (this.reconnectTimer) {
|
|
102
|
+
clearTimeout(this.reconnectTimer);
|
|
103
|
+
this.reconnectTimer = undefined;
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
/* ------------- input handling -------------- */
|
|
107
|
+
/**
|
|
108
|
+
* Run one line of operator input through the slash command parser
|
|
109
|
+
* and the cap gate, then forward to the transport. Returns the
|
|
110
|
+
* parser verdict so the REPL UI can clear the input box, focus
|
|
111
|
+
* pointer, etc.
|
|
112
|
+
*/
|
|
113
|
+
async handleInput(input) {
|
|
114
|
+
const verdict = parseSlashCommand(input);
|
|
115
|
+
switch (verdict.kind) {
|
|
116
|
+
case 'noop':
|
|
117
|
+
return verdict;
|
|
118
|
+
case 'help':
|
|
119
|
+
case 'roster':
|
|
120
|
+
// UI overlays - no transport interaction.
|
|
121
|
+
return verdict;
|
|
122
|
+
case 'quit':
|
|
123
|
+
this.appendSystemLine('Brief it. It ships.');
|
|
124
|
+
return verdict;
|
|
125
|
+
case 'error':
|
|
126
|
+
this.appendSystemLine(verdict.message);
|
|
127
|
+
return verdict;
|
|
128
|
+
case 'stop': {
|
|
129
|
+
await this.dispatchStop(verdict.persona);
|
|
130
|
+
return verdict;
|
|
131
|
+
}
|
|
132
|
+
case 'dispatch': {
|
|
133
|
+
await this.dispatchBrief(verdict.brief);
|
|
134
|
+
return verdict;
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
/* ------------- dispatch -------------- */
|
|
139
|
+
async dispatchBrief(brief) {
|
|
140
|
+
const sessionId = this.state.sessionId;
|
|
141
|
+
if (!sessionId) {
|
|
142
|
+
this.appendSystemLine('No server session yet - try again in a moment.');
|
|
143
|
+
return;
|
|
144
|
+
}
|
|
145
|
+
const capVerdict = evaluateCap({
|
|
146
|
+
active: this.activeAgentCount(),
|
|
147
|
+
env: this.options.env ?? process.env,
|
|
148
|
+
});
|
|
149
|
+
const capLine = describeVerdict(capVerdict);
|
|
150
|
+
if (capVerdict.kind === 'block') {
|
|
151
|
+
this.appendSystemLine(capLine);
|
|
152
|
+
return;
|
|
153
|
+
}
|
|
154
|
+
if (capLine.length > 0) {
|
|
155
|
+
this.appendSystemLine(capLine);
|
|
156
|
+
}
|
|
157
|
+
this.appendOperatorLine(brief);
|
|
158
|
+
this.patch({ briefStartedAtEpochMs: this.now() });
|
|
159
|
+
try {
|
|
160
|
+
await this.options.transport.postBrief({
|
|
161
|
+
apiUrl: this.options.apiUrl,
|
|
162
|
+
apiKey: this.options.apiKey,
|
|
163
|
+
sessionId,
|
|
164
|
+
brief,
|
|
165
|
+
});
|
|
166
|
+
}
|
|
167
|
+
catch (error) {
|
|
168
|
+
this.appendSystemLine(`Brief dispatch refused: ${this.errorMessage(error)}`);
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
async dispatchStop(persona) {
|
|
172
|
+
const sessionId = this.state.sessionId;
|
|
173
|
+
if (!sessionId) {
|
|
174
|
+
this.appendSystemLine('No server session yet - nothing to stop.');
|
|
175
|
+
return;
|
|
176
|
+
}
|
|
177
|
+
try {
|
|
178
|
+
const { stopped } = await this.options.transport.postStop({
|
|
179
|
+
apiUrl: this.options.apiUrl,
|
|
180
|
+
apiKey: this.options.apiKey,
|
|
181
|
+
sessionId,
|
|
182
|
+
persona,
|
|
183
|
+
});
|
|
184
|
+
this.appendSystemLine(stopped
|
|
185
|
+
? `Stopped persona '${persona}'.`
|
|
186
|
+
: `No active dispatch matched persona '${persona}'.`);
|
|
187
|
+
}
|
|
188
|
+
catch (error) {
|
|
189
|
+
this.appendSystemLine(`Stop refused: ${this.errorMessage(error)}`);
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
/* ------------- SSE consumer + reconnect -------------- */
|
|
193
|
+
openStream() {
|
|
194
|
+
const sessionId = this.state.sessionId;
|
|
195
|
+
if (!sessionId)
|
|
196
|
+
return;
|
|
197
|
+
if (this.streamHandle) {
|
|
198
|
+
this.streamHandle.close();
|
|
199
|
+
this.streamHandle = undefined;
|
|
200
|
+
}
|
|
201
|
+
this.streamHandle = this.options.transport.subscribe({
|
|
202
|
+
apiUrl: this.options.apiUrl,
|
|
203
|
+
apiKey: this.options.apiKey,
|
|
204
|
+
sessionId,
|
|
205
|
+
lastEventId: this.lastEventId,
|
|
206
|
+
onOpen: () => {
|
|
207
|
+
this.reconnectAttempt = 0;
|
|
208
|
+
this.patch({ connection: 'on_watch' });
|
|
209
|
+
},
|
|
210
|
+
onEvent: (event, eventId) => {
|
|
211
|
+
this.lastEventId = eventId;
|
|
212
|
+
this.handleServerEvent(event);
|
|
213
|
+
},
|
|
214
|
+
onError: (error) => {
|
|
215
|
+
if (this.closed)
|
|
216
|
+
return;
|
|
217
|
+
this.patch({ connection: 'reconnecting' });
|
|
218
|
+
this.appendSystemLine(`Stream interrupted (${this.errorMessage(error)}). Reconnecting.`);
|
|
219
|
+
this.scheduleReconnect();
|
|
220
|
+
},
|
|
221
|
+
});
|
|
222
|
+
}
|
|
223
|
+
scheduleReconnect() {
|
|
224
|
+
if (this.closed)
|
|
225
|
+
return;
|
|
226
|
+
if (this.reconnectAttempt >= MAX_RECONNECT_ATTEMPTS) {
|
|
227
|
+
this.appendSystemLine('Gave up reconnecting - type /quit and `pugi resume` to retry.');
|
|
228
|
+
this.patch({ connection: 'offline' });
|
|
229
|
+
return;
|
|
230
|
+
}
|
|
231
|
+
const delay = Math.min(RECONNECT_BASE_MS * 2 ** this.reconnectAttempt, RECONNECT_MAX_MS);
|
|
232
|
+
this.reconnectAttempt += 1;
|
|
233
|
+
this.reconnectTimer = setTimeout(() => {
|
|
234
|
+
this.reconnectTimer = undefined;
|
|
235
|
+
this.openStream();
|
|
236
|
+
}, delay);
|
|
237
|
+
}
|
|
238
|
+
/* ------------- event reducer -------------- */
|
|
239
|
+
handleServerEvent(event) {
|
|
240
|
+
switch (event.type) {
|
|
241
|
+
case 'agent.spawned': {
|
|
242
|
+
const persona = safePersonaName(event.role);
|
|
243
|
+
const node = {
|
|
244
|
+
taskId: event.taskId,
|
|
245
|
+
role: event.role,
|
|
246
|
+
personaSlug: event.personaSlug,
|
|
247
|
+
personaName: persona,
|
|
248
|
+
status: 'queued',
|
|
249
|
+
detail: 'queued for dispatch',
|
|
250
|
+
startedAtEpochMs: this.now(),
|
|
251
|
+
tokensIn: 0,
|
|
252
|
+
tokensOut: 0,
|
|
253
|
+
};
|
|
254
|
+
this.patch({ agents: [node, ...this.state.agents] });
|
|
255
|
+
// The conversation pane already prefixes persona rows with the
|
|
256
|
+
// persona name in the persona's hue colour. Skip embedding the
|
|
257
|
+
// name in the body text to avoid the `Marcus Marcus dispatched`
|
|
258
|
+
// double-print. `void persona` keeps the resolved name in scope
|
|
259
|
+
// for the agent tree node above without leaking it into the
|
|
260
|
+
// transcript body.
|
|
261
|
+
void persona;
|
|
262
|
+
this.appendPersonaLine(event.personaSlug, `dispatched (${event.role}).`);
|
|
263
|
+
return;
|
|
264
|
+
}
|
|
265
|
+
case 'agent.step': {
|
|
266
|
+
this.patch({
|
|
267
|
+
agents: this.state.agents.map((a) => a.taskId === event.taskId
|
|
268
|
+
? { ...a, status: 'thinking', detail: event.detail }
|
|
269
|
+
: a),
|
|
270
|
+
});
|
|
271
|
+
return;
|
|
272
|
+
}
|
|
273
|
+
case 'agent.tokens': {
|
|
274
|
+
const delta = event.tokensIn + event.tokensOut;
|
|
275
|
+
this.patch({
|
|
276
|
+
tokensDownstreamTotal: this.state.tokensDownstreamTotal + delta,
|
|
277
|
+
agents: this.state.agents.map((a) => a.taskId === event.taskId
|
|
278
|
+
? {
|
|
279
|
+
...a,
|
|
280
|
+
tokensIn: a.tokensIn + event.tokensIn,
|
|
281
|
+
tokensOut: a.tokensOut + event.tokensOut,
|
|
282
|
+
}
|
|
283
|
+
: a),
|
|
284
|
+
});
|
|
285
|
+
return;
|
|
286
|
+
}
|
|
287
|
+
case 'agent.completed': {
|
|
288
|
+
const target = this.state.agents.find((a) => a.taskId === event.taskId);
|
|
289
|
+
this.patch({
|
|
290
|
+
agents: this.state.agents.map((a) => a.taskId === event.taskId
|
|
291
|
+
? { ...a, status: 'shipped', detail: 'shipped' }
|
|
292
|
+
: a),
|
|
293
|
+
});
|
|
294
|
+
if (target) {
|
|
295
|
+
this.appendPersonaLine(target.personaSlug, 'shipped.');
|
|
296
|
+
}
|
|
297
|
+
return;
|
|
298
|
+
}
|
|
299
|
+
case 'agent.blocked': {
|
|
300
|
+
const target = this.state.agents.find((a) => a.taskId === event.taskId);
|
|
301
|
+
this.patch({
|
|
302
|
+
agents: this.state.agents.map((a) => a.taskId === event.taskId
|
|
303
|
+
? { ...a, status: 'blocked', detail: event.detail }
|
|
304
|
+
: a),
|
|
305
|
+
});
|
|
306
|
+
if (target) {
|
|
307
|
+
this.appendPersonaLine(target.personaSlug, `blocked: ${event.detail}`);
|
|
308
|
+
}
|
|
309
|
+
return;
|
|
310
|
+
}
|
|
311
|
+
case 'agent.failed': {
|
|
312
|
+
const target = this.state.agents.find((a) => a.taskId === event.taskId);
|
|
313
|
+
this.patch({
|
|
314
|
+
agents: this.state.agents.map((a) => a.taskId === event.taskId
|
|
315
|
+
? { ...a, status: 'failed', detail: event.error }
|
|
316
|
+
: a),
|
|
317
|
+
});
|
|
318
|
+
if (target) {
|
|
319
|
+
this.appendPersonaLine(target.personaSlug, `failed: ${event.error}`);
|
|
320
|
+
}
|
|
321
|
+
return;
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
/* ------------- transcript helpers -------------- */
|
|
326
|
+
appendOperatorLine(text) {
|
|
327
|
+
this.appendRow({ source: 'operator', text });
|
|
328
|
+
}
|
|
329
|
+
appendSystemLine(text) {
|
|
330
|
+
this.appendRow({ source: 'system', text });
|
|
331
|
+
}
|
|
332
|
+
appendPersonaLine(personaSlug, text) {
|
|
333
|
+
this.appendRow({ source: 'persona', text, personaSlug });
|
|
334
|
+
}
|
|
335
|
+
appendRow(input) {
|
|
336
|
+
if (input.text.length === 0)
|
|
337
|
+
return;
|
|
338
|
+
const row = {
|
|
339
|
+
id: randomUUID(),
|
|
340
|
+
source: input.source,
|
|
341
|
+
text: input.text,
|
|
342
|
+
personaSlug: input.personaSlug,
|
|
343
|
+
timestampEpochMs: this.now(),
|
|
344
|
+
};
|
|
345
|
+
const next = this.state.transcript.concat(row).slice(-MAX_TRANSCRIPT_ROWS);
|
|
346
|
+
this.patch({ transcript: next });
|
|
347
|
+
}
|
|
348
|
+
/* ------------- agent count + clock -------------- */
|
|
349
|
+
activeAgentCount() {
|
|
350
|
+
return this.state.agents.filter((a) => a.status === 'queued' || a.status === 'thinking').length;
|
|
351
|
+
}
|
|
352
|
+
/** Exported so the cap-warning gate can be exercised in isolation. */
|
|
353
|
+
activeRoles() {
|
|
354
|
+
return this.state.agents
|
|
355
|
+
.filter((a) => a.status === 'queued' || a.status === 'thinking')
|
|
356
|
+
.map((a) => a.role);
|
|
357
|
+
}
|
|
358
|
+
now() {
|
|
359
|
+
return (this.options.now ?? Date.now)();
|
|
360
|
+
}
|
|
361
|
+
patch(partial) {
|
|
362
|
+
this.state = { ...this.state, ...partial };
|
|
363
|
+
for (const subscriber of this.subscribers) {
|
|
364
|
+
subscriber(this.state);
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
errorMessage(error) {
|
|
368
|
+
if (error instanceof Error)
|
|
369
|
+
return error.message;
|
|
370
|
+
return String(error);
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
/* ------------------------------------------------------------------ */
|
|
374
|
+
/* Helpers */
|
|
375
|
+
/* ------------------------------------------------------------------ */
|
|
376
|
+
/**
|
|
377
|
+
* Resolve role → display name without throwing on unknown roles. The
|
|
378
|
+
* dispatcher always passes a known role, but the SSE wire format is
|
|
379
|
+
* forward-compatible - if a future server pushes a role the client
|
|
380
|
+
* does not recognise, the REPL still renders something usable rather
|
|
381
|
+
* than crashing mid-frame.
|
|
382
|
+
*/
|
|
383
|
+
function safePersonaName(role) {
|
|
384
|
+
try {
|
|
385
|
+
return getPersonaForRole(role).name;
|
|
386
|
+
}
|
|
387
|
+
catch {
|
|
388
|
+
return role;
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
/**
|
|
392
|
+
* Convenience: list the legal role slugs the operator can target with
|
|
393
|
+
* `/stop`. Surfaced in the slash command help overlay and in the
|
|
394
|
+
* cap-warning helper when the operator types an unrecognised slug.
|
|
395
|
+
*/
|
|
396
|
+
export function knownRoles() {
|
|
397
|
+
return listRoles();
|
|
398
|
+
}
|
|
399
|
+
//# sourceMappingURL=session.js.map
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* REPL slash command registry - Sprint α5.7 (ADR-0056 PR-PUGI-CLI-REPL-DEFAULT).
|
|
3
|
+
*
|
|
4
|
+
* The REPL input box surfaces a small palette of slash commands that the
|
|
5
|
+
* operator can run from inside a persistent session: dispatch a brief,
|
|
6
|
+
* inspect the agent roster, stop a running persona, open the help
|
|
7
|
+
* overlay, or quit. The registry is intentionally narrow at M1 -
|
|
8
|
+
* complementary surfaces (`/sync`, `/handoff`, `/budget`) ship as proper
|
|
9
|
+
* subcommands in α5.8+ and stay reachable from a non-REPL `pugi
|
|
10
|
+
* <command>` invocation.
|
|
11
|
+
*
|
|
12
|
+
* The registry is pure: each entry returns a `SlashCommandResult`
|
|
13
|
+
* describing what the REPL session should do next. The session module
|
|
14
|
+
* owns the side effects (network calls, dispatcher invocation, exit).
|
|
15
|
+
* Keeping the surface pure lets the unit test exercise every shape
|
|
16
|
+
* without standing up an Ink runtime or an Anvil endpoint.
|
|
17
|
+
*
|
|
18
|
+
* Brand voice (brandbook §08): power words `brief / dispatch / stop /
|
|
19
|
+
* agents / quit`. Tagline `Brief it. It ships.` reserved for `/quit`
|
|
20
|
+
* confirmation and `/help` footer - never inline.
|
|
21
|
+
*/
|
|
22
|
+
import { listRoles } from '../agents/registry.js';
|
|
23
|
+
export const SLASH_COMMAND_HELP = Object.freeze([
|
|
24
|
+
{ name: 'brief', args: '<text>', gloss: 'Dispatch a brief to the workforce' },
|
|
25
|
+
{ name: 'agents', args: '', gloss: 'List the on-watch agent roster' },
|
|
26
|
+
{ name: 'stop', args: '<persona>', gloss: 'Stop one agent by persona slug' },
|
|
27
|
+
{ name: 'help', args: '', gloss: 'Show this help overlay' },
|
|
28
|
+
{ name: 'quit', args: '', gloss: 'Exit the REPL (session resumes via pugi resume)' },
|
|
29
|
+
]);
|
|
30
|
+
/**
|
|
31
|
+
* Parse one line of input from the REPL. The contract:
|
|
32
|
+
*
|
|
33
|
+
* - Empty / whitespace-only input returns `noop` with the original
|
|
34
|
+
* text so the REPL can ignore it without printing anything.
|
|
35
|
+
* - Input that does not start with `/` is treated as an implicit
|
|
36
|
+
* `/brief <text>` - the most-common operator action.
|
|
37
|
+
* - `/<name> [args]` resolves the name against the registry; unknown
|
|
38
|
+
* names return `error` so the REPL can render a one-line tip
|
|
39
|
+
* instead of silently dropping the input.
|
|
40
|
+
*
|
|
41
|
+
* The function never throws. Bad input maps to a structured result the
|
|
42
|
+
* REPL can render - the alternative (throwing from a keystroke handler)
|
|
43
|
+
* would unmount Ink mid-frame.
|
|
44
|
+
*/
|
|
45
|
+
export function parseSlashCommand(input) {
|
|
46
|
+
const trimmed = input.trim();
|
|
47
|
+
if (trimmed.length === 0) {
|
|
48
|
+
return { kind: 'noop', text: '' };
|
|
49
|
+
}
|
|
50
|
+
if (!trimmed.startsWith('/')) {
|
|
51
|
+
return { kind: 'dispatch', brief: trimmed };
|
|
52
|
+
}
|
|
53
|
+
// `/` with no name → render help overlay so the operator discovers
|
|
54
|
+
// the command palette without typing `/help`.
|
|
55
|
+
if (trimmed === '/') {
|
|
56
|
+
return { kind: 'help' };
|
|
57
|
+
}
|
|
58
|
+
const space = trimmed.indexOf(' ');
|
|
59
|
+
const head = space === -1 ? trimmed.slice(1) : trimmed.slice(1, space);
|
|
60
|
+
const tail = space === -1 ? '' : trimmed.slice(space + 1).trim();
|
|
61
|
+
const name = head.toLowerCase();
|
|
62
|
+
switch (name) {
|
|
63
|
+
case 'brief': {
|
|
64
|
+
if (tail.length === 0) {
|
|
65
|
+
return { kind: 'error', message: 'Usage: /brief <text>' };
|
|
66
|
+
}
|
|
67
|
+
return { kind: 'dispatch', brief: tail };
|
|
68
|
+
}
|
|
69
|
+
case 'agents':
|
|
70
|
+
case 'agent':
|
|
71
|
+
case 'roster': {
|
|
72
|
+
return { kind: 'roster' };
|
|
73
|
+
}
|
|
74
|
+
case 'stop':
|
|
75
|
+
case 'kill': {
|
|
76
|
+
if (tail.length === 0) {
|
|
77
|
+
return {
|
|
78
|
+
kind: 'error',
|
|
79
|
+
message: `Usage: /stop <persona> (try one of: ${listRoles().join(', ')})`,
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
return { kind: 'stop', persona: tail.toLowerCase() };
|
|
83
|
+
}
|
|
84
|
+
case 'help':
|
|
85
|
+
case '?': {
|
|
86
|
+
return { kind: 'help' };
|
|
87
|
+
}
|
|
88
|
+
case 'quit':
|
|
89
|
+
case 'exit':
|
|
90
|
+
case 'q': {
|
|
91
|
+
return { kind: 'quit' };
|
|
92
|
+
}
|
|
93
|
+
default: {
|
|
94
|
+
return {
|
|
95
|
+
kind: 'error',
|
|
96
|
+
message: `Unknown command /${head}. Try /help for the palette.`,
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
/**
|
|
102
|
+
* Filter SLASH_COMMAND_HELP rows whose name starts with the typed
|
|
103
|
+
* prefix. The REPL input box uses this to render an inline palette
|
|
104
|
+
* after the operator types `/`.
|
|
105
|
+
*
|
|
106
|
+
* Matching is case-insensitive; the prefix is normalized to lowercase
|
|
107
|
+
* before comparison so the operator can type `/HELP` and still see
|
|
108
|
+
* suggestions.
|
|
109
|
+
*/
|
|
110
|
+
export function matchSlashPrefix(prefix) {
|
|
111
|
+
const normalized = prefix.toLowerCase().replace(/^\//, '');
|
|
112
|
+
if (normalized.length === 0)
|
|
113
|
+
return SLASH_COMMAND_HELP;
|
|
114
|
+
return SLASH_COMMAND_HELP.filter((row) => row.name.startsWith(normalized));
|
|
115
|
+
}
|
|
116
|
+
//# sourceMappingURL=slash-commands.js.map
|
package/dist/core/session.js
CHANGED
|
@@ -81,6 +81,174 @@ export function recordFileMutation(session, input) {
|
|
|
81
81
|
...input,
|
|
82
82
|
}, session.eventsPath);
|
|
83
83
|
}
|
|
84
|
+
export function recordHookInvoked(session, input) {
|
|
85
|
+
if (!session.enabled)
|
|
86
|
+
return;
|
|
87
|
+
appendEvent({
|
|
88
|
+
id: randomUUID(),
|
|
89
|
+
sessionId: session.id,
|
|
90
|
+
timestamp: now(),
|
|
91
|
+
type: 'hook.invoked',
|
|
92
|
+
event: input.event,
|
|
93
|
+
matchSummary: input.matchSummary,
|
|
94
|
+
runSummary: input.runSummary,
|
|
95
|
+
}, session.eventsPath);
|
|
96
|
+
}
|
|
97
|
+
export function recordHookResult(session, input) {
|
|
98
|
+
if (!session.enabled)
|
|
99
|
+
return;
|
|
100
|
+
appendEvent({
|
|
101
|
+
id: randomUUID(),
|
|
102
|
+
sessionId: session.id,
|
|
103
|
+
timestamp: now(),
|
|
104
|
+
type: 'hook.result',
|
|
105
|
+
event: input.event,
|
|
106
|
+
ok: input.ok,
|
|
107
|
+
exitCode: input.exitCode,
|
|
108
|
+
elapsedMs: input.elapsedMs,
|
|
109
|
+
stdoutLen: input.stdoutLen,
|
|
110
|
+
stderrLen: input.stderrLen,
|
|
111
|
+
}, session.eventsPath);
|
|
112
|
+
}
|
|
113
|
+
export function recordHookSkipped(session, input) {
|
|
114
|
+
if (!session.enabled)
|
|
115
|
+
return;
|
|
116
|
+
appendEvent({
|
|
117
|
+
id: randomUUID(),
|
|
118
|
+
sessionId: session.id,
|
|
119
|
+
timestamp: now(),
|
|
120
|
+
type: 'hook.skipped',
|
|
121
|
+
event: input.event,
|
|
122
|
+
reason: input.reason,
|
|
123
|
+
}, session.eventsPath);
|
|
124
|
+
}
|
|
125
|
+
/**
|
|
126
|
+
* Record a `subagent.spawned` event in the session audit log. Inputs
|
|
127
|
+
* arrive untyped (the dispatcher emits JSON to stay decoupled from this
|
|
128
|
+
* module); the recorder validates the role and isolation at the typed
|
|
129
|
+
* audit-event union boundary, so a malformed role would fail at the
|
|
130
|
+
* `AuditEvent` cast site rather than silently writing garbage. The cast
|
|
131
|
+
* itself is the load-bearing check: `assertSubagentRole` and
|
|
132
|
+
* `assertSubagentIsolation` narrow at runtime.
|
|
133
|
+
*/
|
|
134
|
+
export function recordSubagentSpawned(session, input) {
|
|
135
|
+
if (!session.enabled)
|
|
136
|
+
return;
|
|
137
|
+
appendEvent({
|
|
138
|
+
id: randomUUID(),
|
|
139
|
+
sessionId: session.id,
|
|
140
|
+
timestamp: now(),
|
|
141
|
+
type: 'subagent.spawned',
|
|
142
|
+
taskId: input.taskId,
|
|
143
|
+
role: assertSubagentRole(input.role),
|
|
144
|
+
personaSlug: input.personaSlug,
|
|
145
|
+
parentSessionId: input.parentSessionId,
|
|
146
|
+
isolation: assertSubagentIsolation(input.isolation),
|
|
147
|
+
}, session.eventsPath);
|
|
148
|
+
}
|
|
149
|
+
export function recordSubagentToolCall(session, input) {
|
|
150
|
+
if (!session.enabled)
|
|
151
|
+
return;
|
|
152
|
+
appendEvent({
|
|
153
|
+
id: randomUUID(),
|
|
154
|
+
sessionId: session.id,
|
|
155
|
+
timestamp: now(),
|
|
156
|
+
type: 'subagent.tool_call',
|
|
157
|
+
taskId: input.taskId,
|
|
158
|
+
role: assertSubagentRole(input.role),
|
|
159
|
+
personaSlug: input.personaSlug,
|
|
160
|
+
toolName: input.toolName,
|
|
161
|
+
toolCallId: input.toolCallId,
|
|
162
|
+
}, session.eventsPath);
|
|
163
|
+
}
|
|
164
|
+
export function recordSubagentCompleted(session, input) {
|
|
165
|
+
if (!session.enabled)
|
|
166
|
+
return;
|
|
167
|
+
appendEvent({
|
|
168
|
+
id: randomUUID(),
|
|
169
|
+
sessionId: session.id,
|
|
170
|
+
timestamp: now(),
|
|
171
|
+
type: 'subagent.completed',
|
|
172
|
+
taskId: input.taskId,
|
|
173
|
+
role: assertSubagentRole(input.role),
|
|
174
|
+
personaSlug: input.personaSlug,
|
|
175
|
+
toolCallCount: input.toolCallCount,
|
|
176
|
+
tokensIn: input.tokensIn,
|
|
177
|
+
tokensOut: input.tokensOut,
|
|
178
|
+
durationMs: input.durationMs,
|
|
179
|
+
}, session.eventsPath);
|
|
180
|
+
}
|
|
181
|
+
export function recordSubagentBlocked(session, input) {
|
|
182
|
+
if (!session.enabled)
|
|
183
|
+
return;
|
|
184
|
+
appendEvent({
|
|
185
|
+
id: randomUUID(),
|
|
186
|
+
sessionId: session.id,
|
|
187
|
+
timestamp: now(),
|
|
188
|
+
type: 'subagent.blocked',
|
|
189
|
+
taskId: input.taskId,
|
|
190
|
+
role: assertSubagentRole(input.role),
|
|
191
|
+
personaSlug: input.personaSlug,
|
|
192
|
+
reason: assertSubagentBlockedReason(input.reason),
|
|
193
|
+
detail: input.detail,
|
|
194
|
+
}, session.eventsPath);
|
|
195
|
+
}
|
|
196
|
+
export function recordSubagentFailed(session, input) {
|
|
197
|
+
if (!session.enabled)
|
|
198
|
+
return;
|
|
199
|
+
appendEvent({
|
|
200
|
+
id: randomUUID(),
|
|
201
|
+
sessionId: session.id,
|
|
202
|
+
timestamp: now(),
|
|
203
|
+
type: 'subagent.failed',
|
|
204
|
+
taskId: input.taskId,
|
|
205
|
+
role: assertSubagentRole(input.role),
|
|
206
|
+
personaSlug: input.personaSlug,
|
|
207
|
+
error: input.error,
|
|
208
|
+
}, session.eventsPath);
|
|
209
|
+
}
|
|
210
|
+
const SUBAGENT_ROLES = new Set([
|
|
211
|
+
'orchestrator',
|
|
212
|
+
'architect',
|
|
213
|
+
'coder',
|
|
214
|
+
'verifier',
|
|
215
|
+
'reviewer',
|
|
216
|
+
'researcher',
|
|
217
|
+
'release',
|
|
218
|
+
'devops',
|
|
219
|
+
'design_qa',
|
|
220
|
+
]);
|
|
221
|
+
const SUBAGENT_ISOLATIONS = new Set([
|
|
222
|
+
'prompt_only',
|
|
223
|
+
'shared_fs_readonly',
|
|
224
|
+
'shared_fs_serialized',
|
|
225
|
+
'worktree',
|
|
226
|
+
'remote_vm',
|
|
227
|
+
]);
|
|
228
|
+
const SUBAGENT_BLOCKED_REASONS = new Set([
|
|
229
|
+
'budget_exhausted',
|
|
230
|
+
'plan_mode_refused',
|
|
231
|
+
'permission_denied',
|
|
232
|
+
'tool_unavailable',
|
|
233
|
+
]);
|
|
234
|
+
function assertSubagentRole(value) {
|
|
235
|
+
if (!SUBAGENT_ROLES.has(value)) {
|
|
236
|
+
throw new Error(`recordSubagent*: unknown role '${value}'`);
|
|
237
|
+
}
|
|
238
|
+
return value;
|
|
239
|
+
}
|
|
240
|
+
function assertSubagentIsolation(value) {
|
|
241
|
+
if (!SUBAGENT_ISOLATIONS.has(value)) {
|
|
242
|
+
throw new Error(`recordSubagent*: unknown isolation '${value}'`);
|
|
243
|
+
}
|
|
244
|
+
return value;
|
|
245
|
+
}
|
|
246
|
+
function assertSubagentBlockedReason(value) {
|
|
247
|
+
if (!SUBAGENT_BLOCKED_REASONS.has(value)) {
|
|
248
|
+
throw new Error(`recordSubagent*: unknown blocked reason '${value}'`);
|
|
249
|
+
}
|
|
250
|
+
return value;
|
|
251
|
+
}
|
|
84
252
|
function appendEvent(event, eventsPath) {
|
|
85
253
|
appendFileSync(eventsPath, `${JSON.stringify(event)}\n`, { encoding: 'utf8', mode: 0o600 });
|
|
86
254
|
}
|