@pugi/cli 0.1.0-alpha.10

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 (79) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +172 -0
  3. package/bin/run.js +2 -0
  4. package/dist/commands/jobs.js +245 -0
  5. package/dist/core/agents/loader.js +104 -0
  6. package/dist/core/agents/registry.js +69 -0
  7. package/dist/core/auto-open-browser.js +128 -0
  8. package/dist/core/bash-classifier.js +1001 -0
  9. package/dist/core/clipboard.js +70 -0
  10. package/dist/core/context/builder.js +114 -0
  11. package/dist/core/context/compaction-events.js +99 -0
  12. package/dist/core/context/compaction.js +602 -0
  13. package/dist/core/context/invariants.js +250 -0
  14. package/dist/core/context/markdown-loader.js +270 -0
  15. package/dist/core/credentials.js +355 -0
  16. package/dist/core/engine/adapter-runner.js +8 -0
  17. package/dist/core/engine/anvil-client.js +156 -0
  18. package/dist/core/engine/compaction-hook.js +154 -0
  19. package/dist/core/engine/index.js +12 -0
  20. package/dist/core/engine/native-pugi.js +369 -0
  21. package/dist/core/engine/noop.js +27 -0
  22. package/dist/core/engine/prompts.js +118 -0
  23. package/dist/core/engine/tool-bridge.js +313 -0
  24. package/dist/core/file-cache.js +29 -0
  25. package/dist/core/hooks.js +415 -0
  26. package/dist/core/index-store.js +260 -0
  27. package/dist/core/jobs/registry.js +462 -0
  28. package/dist/core/mcp/client.js +316 -0
  29. package/dist/core/mcp/registry.js +171 -0
  30. package/dist/core/mcp/trust.js +91 -0
  31. package/dist/core/path-security.js +63 -0
  32. package/dist/core/permission.js +309 -0
  33. package/dist/core/repl/cap-warning.js +91 -0
  34. package/dist/core/repl/clipboard-read.js +174 -0
  35. package/dist/core/repl/history-search.js +175 -0
  36. package/dist/core/repl/history.js +172 -0
  37. package/dist/core/repl/kill-ring.js +138 -0
  38. package/dist/core/repl/session.js +618 -0
  39. package/dist/core/repl/slash-commands.js +227 -0
  40. package/dist/core/repl/workspace-context.js +113 -0
  41. package/dist/core/session.js +258 -0
  42. package/dist/core/settings.js +59 -0
  43. package/dist/core/skills/loader.js +454 -0
  44. package/dist/core/skills/sources.js +480 -0
  45. package/dist/core/skills/trust.js +172 -0
  46. package/dist/core/subagents/dispatcher.js +258 -0
  47. package/dist/core/subagents/index.js +26 -0
  48. package/dist/core/subagents/spawn.js +86 -0
  49. package/dist/core/trust.js +109 -0
  50. package/dist/index.js +8 -0
  51. package/dist/runtime/cli.js +3405 -0
  52. package/dist/runtime/commands/agents.js +385 -0
  53. package/dist/runtime/commands/budget.js +192 -0
  54. package/dist/runtime/commands/config.js +231 -0
  55. package/dist/runtime/commands/privacy.js +107 -0
  56. package/dist/runtime/commands/skills.js +401 -0
  57. package/dist/runtime/commands/undo.js +329 -0
  58. package/dist/runtime/update-check.js +294 -0
  59. package/dist/tools/bash.js +660 -0
  60. package/dist/tools/file-tools.js +346 -0
  61. package/dist/tools/registry.js +25 -0
  62. package/dist/tools/web-fetch.js +535 -0
  63. package/dist/tui/agent-tree.js +66 -0
  64. package/dist/tui/conversation-pane.js +45 -0
  65. package/dist/tui/device-flow.js +142 -0
  66. package/dist/tui/input-box.js +474 -0
  67. package/dist/tui/login-picker.js +69 -0
  68. package/dist/tui/render.js +125 -0
  69. package/dist/tui/repl-render.js +240 -0
  70. package/dist/tui/repl-splash-art.js +64 -0
  71. package/dist/tui/repl-splash.js +111 -0
  72. package/dist/tui/repl.js +214 -0
  73. package/dist/tui/slash-palette.js +106 -0
  74. package/dist/tui/splash-data.js +61 -0
  75. package/dist/tui/splash.js +31 -0
  76. package/dist/tui/status-bar.js +71 -0
  77. package/dist/tui/update-banner.js +8 -0
  78. package/dist/tui/workspace-context.js +105 -0
  79. package/package.json +71 -0
@@ -0,0 +1,618 @@
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
+ import { webFetchTool } from '../../tools/web-fetch.js';
34
+ import { loadSettings } from '../settings.js';
35
+ import { getJobRegistry } from '../jobs/registry.js';
36
+ import { existsSync, readdirSync, statSync } from 'node:fs';
37
+ import { resolve as resolvePath } from 'node:path';
38
+ const MAX_TRANSCRIPT_ROWS = 500;
39
+ const MAX_RECONNECT_ATTEMPTS = 10;
40
+ const RECONNECT_BASE_MS = 250;
41
+ const RECONNECT_MAX_MS = 5_000;
42
+ export class ReplSession {
43
+ options;
44
+ subscribers = new Set();
45
+ state;
46
+ streamHandle;
47
+ lastEventId;
48
+ reconnectAttempt = 0;
49
+ reconnectTimer;
50
+ closed = false;
51
+ /**
52
+ * Last non-trivial step.detail recorded per taskId. The server streams
53
+ * the persona reply incrementally via `agent.step` events whose
54
+ * `detail` field carries the cumulative model output. `agent.completed`
55
+ * arrives last and previously overwrote the visible detail to the
56
+ * literal string `'shipped'` while the transcript line said only
57
+ * `shipped.` — the actual reply text was lost. By caching the last
58
+ * non-trivial detail here, we can flush it into the transcript when
59
+ * the agent completes so the operator sees what the persona actually
60
+ * said. CEO wave-2 fix 2026-05-25.
61
+ */
62
+ lastStepDetail = new Map();
63
+ constructor(options) {
64
+ this.options = options;
65
+ this.state = {
66
+ sessionId: undefined,
67
+ workspaceLabel: options.workspaceLabel,
68
+ cliVersion: options.cliVersion,
69
+ connection: 'connecting',
70
+ agents: [],
71
+ transcript: [],
72
+ tokensDownstreamTotal: 0,
73
+ briefStartedAtEpochMs: undefined,
74
+ };
75
+ }
76
+ /* ------------- subscribe / state -------------- */
77
+ subscribe(callback) {
78
+ this.subscribers.add(callback);
79
+ callback(this.state);
80
+ return () => {
81
+ this.subscribers.delete(callback);
82
+ };
83
+ }
84
+ getState() {
85
+ return this.state;
86
+ }
87
+ /* ------------- lifecycle -------------- */
88
+ /**
89
+ * Create the server-side session and open the SSE stream. Idempotent;
90
+ * a second call after `close()` resurrects the connection but
91
+ * issues a fresh server session id.
92
+ */
93
+ async start() {
94
+ this.closed = false;
95
+ try {
96
+ const { sessionId } = await this.options.transport.createSession({
97
+ apiUrl: this.options.apiUrl,
98
+ apiKey: this.options.apiKey,
99
+ workspace: this.options.workspace,
100
+ });
101
+ this.patch({ sessionId, connection: 'connecting' });
102
+ this.openStream();
103
+ }
104
+ catch (error) {
105
+ this.appendSystemLine(`Could not open Pugi session: ${this.errorMessage(error)}`);
106
+ this.patch({ connection: 'offline' });
107
+ }
108
+ }
109
+ /**
110
+ * Tear down the SSE stream and stop the reconnect timer. The session
111
+ * id stays valid server-side; `pugi resume <id>` reopens later.
112
+ */
113
+ close() {
114
+ this.closed = true;
115
+ if (this.streamHandle) {
116
+ this.streamHandle.close();
117
+ this.streamHandle = undefined;
118
+ }
119
+ if (this.reconnectTimer) {
120
+ clearTimeout(this.reconnectTimer);
121
+ this.reconnectTimer = undefined;
122
+ }
123
+ }
124
+ /* ------------- input handling -------------- */
125
+ /**
126
+ * Run one line of operator input through the slash command parser
127
+ * and the cap gate, then forward to the transport. Returns the
128
+ * parser verdict so the REPL UI can clear the input box, focus
129
+ * pointer, etc.
130
+ */
131
+ async handleInput(input) {
132
+ const verdict = parseSlashCommand(input);
133
+ switch (verdict.kind) {
134
+ case 'noop':
135
+ return verdict;
136
+ case 'help':
137
+ case 'roster':
138
+ // UI overlays - no transport interaction.
139
+ return verdict;
140
+ case 'quit':
141
+ // UI Designer audit 2026-05-25: "Brief it. It ships." is reserved
142
+ // for identity intro + landing per wave-4 prompt rule. Drop the
143
+ // tagline drift here; tell the operator what happened and how to
144
+ // resume.
145
+ this.appendSystemLine('On watch ended. pugi resume to come back.');
146
+ return verdict;
147
+ case 'error':
148
+ this.appendSystemLine(verdict.message);
149
+ return verdict;
150
+ case 'stop': {
151
+ await this.dispatchStop(verdict.persona);
152
+ return verdict;
153
+ }
154
+ case 'dispatch': {
155
+ await this.dispatchBrief(verdict.brief);
156
+ return verdict;
157
+ }
158
+ case 'web': {
159
+ await this.dispatchWebFetch(verdict.url);
160
+ return verdict;
161
+ }
162
+ case 'clear': {
163
+ this.clearTranscript();
164
+ return verdict;
165
+ }
166
+ case 'version': {
167
+ this.appendSystemLine(`pugi ${this.options.cliVersion}`);
168
+ return verdict;
169
+ }
170
+ case 'jobs': {
171
+ await this.dispatchJobs();
172
+ return verdict;
173
+ }
174
+ case 'diff': {
175
+ this.dispatchDiff();
176
+ return verdict;
177
+ }
178
+ case 'cost': {
179
+ this.dispatchCost();
180
+ return verdict;
181
+ }
182
+ case 'status': {
183
+ this.dispatchStatus();
184
+ return verdict;
185
+ }
186
+ case 'stub': {
187
+ this.appendSystemLine(verdict.message);
188
+ return verdict;
189
+ }
190
+ }
191
+ }
192
+ /**
193
+ * Reset the conversation transcript. The agent registry stays intact
194
+ * so the operator can `/clear` to declutter the chat pane without
195
+ * losing visibility into running dispatches.
196
+ */
197
+ clearTranscript() {
198
+ this.patch({ transcript: [] });
199
+ }
200
+ /* ------------- Tier 1 / Tier 2 wired handlers -------------- */
201
+ async dispatchJobs() {
202
+ try {
203
+ const registry = getJobRegistry();
204
+ const entries = await registry.list();
205
+ if (entries.length === 0) {
206
+ this.appendSystemLine('No background jobs tracked.');
207
+ return;
208
+ }
209
+ this.appendSystemLine(`Background jobs (${entries.length}):`);
210
+ for (const entry of entries) {
211
+ const id = entry.id.replace(/^pj-/, '').slice(0, 8);
212
+ const status = entry.status;
213
+ const cmd = entry.command.length > 48 ? `${entry.command.slice(0, 47)}…` : entry.command;
214
+ this.appendSystemLine(` ${id} ${status.padEnd(10)} ${cmd}`);
215
+ }
216
+ }
217
+ catch (error) {
218
+ this.appendSystemLine(`/jobs failed: ${this.errorMessage(error)}`);
219
+ }
220
+ }
221
+ dispatchDiff() {
222
+ try {
223
+ const artifactsRoot = resolvePath(process.cwd(), '.pugi', 'artifacts');
224
+ if (!existsSync(artifactsRoot)) {
225
+ this.appendSystemLine('No pending diffs (.pugi/artifacts/ not found).');
226
+ return;
227
+ }
228
+ const subdirs = readdirSync(artifactsRoot, { withFileTypes: true })
229
+ .filter((d) => d.isDirectory())
230
+ .map((d) => d.name);
231
+ const diffs = [];
232
+ for (const name of subdirs) {
233
+ const candidate = resolvePath(artifactsRoot, name, 'diff.patch');
234
+ if (existsSync(candidate)) {
235
+ const size = statSync(candidate).size;
236
+ diffs.push(` ${name}/diff.patch (${size} bytes)`);
237
+ }
238
+ }
239
+ if (diffs.length === 0) {
240
+ this.appendSystemLine('No pending diffs.');
241
+ return;
242
+ }
243
+ this.appendSystemLine(`Pending diffs (${diffs.length}):`);
244
+ for (const line of diffs)
245
+ this.appendSystemLine(line);
246
+ }
247
+ catch (error) {
248
+ this.appendSystemLine(`/diff failed: ${this.errorMessage(error)}`);
249
+ }
250
+ }
251
+ dispatchCost() {
252
+ const { tokensDownstreamTotal, agents } = this.state;
253
+ const active = agents.filter((a) => a.status === 'queued' || a.status === 'thinking').length;
254
+ const lineTokens = `Tokens this session: ${tokensDownstreamTotal.toLocaleString()} (in+out).`;
255
+ const lineAgents = `Active dispatches: ${active} of cap.`;
256
+ this.appendSystemLine(lineTokens);
257
+ this.appendSystemLine(lineAgents);
258
+ this.appendSystemLine('Full per-persona budget breakdown lands in α6.5.');
259
+ }
260
+ dispatchStatus() {
261
+ const sessionId = this.state.sessionId ?? '(unbound)';
262
+ const reach = this.state.connection;
263
+ this.appendSystemLine(`Backend: ${this.options.apiUrl} (${reach}).`);
264
+ this.appendSystemLine(`Session: ${sessionId}.`);
265
+ this.appendSystemLine(`Workspace: ${this.state.workspaceLabel}.`);
266
+ this.appendSystemLine(`CLI: pugi ${this.state.cliVersion}.`);
267
+ }
268
+ /**
269
+ * Fetch one URL via the web_fetch tool and inject the resulting
270
+ * Markdown into the transcript as an operator-attributed brief. The
271
+ * `<untrusted-content>` sentinel travels with the body so the Mira
272
+ * system prompt can refuse to follow instructions inside it.
273
+ *
274
+ * Gating: the dispatcher reads PugiSettings from disk on every
275
+ * call so the operator can flip `web.fetch.enabled` mid-session
276
+ * without restarting the REPL. The CLI's bare `--allow-fetch` flag
277
+ * is honored by the runtime entry point and propagates through
278
+ * env to keep the session module transport-free.
279
+ */
280
+ async dispatchWebFetch(url) {
281
+ // Malformed `.pugi/settings.json` must not crash the REPL —
282
+ // surface the error to the operator and treat fetch as disabled
283
+ // (fail-safe). The session keeps running.
284
+ let settings;
285
+ try {
286
+ settings = loadSettings(process.cwd());
287
+ }
288
+ catch (error) {
289
+ const msg = error instanceof Error ? error.message : String(error);
290
+ this.appendSystemLine(`web_fetch refused: failed to load .pugi/settings.json (${msg}).`);
291
+ return;
292
+ }
293
+ const allowFetch = (this.options.env ?? process.env).PUGI_ALLOW_FETCH === '1';
294
+ const result = await webFetchTool({ url }, { settings, allowFetch });
295
+ if (!result.ok) {
296
+ this.appendSystemLine(`web_fetch refused: ${result.error}`);
297
+ return;
298
+ }
299
+ this.appendOperatorLine(`/web ${result.url}`);
300
+ this.appendSystemLine(`Fetched ${result.title} (${result.fetched_at}).`);
301
+ // Surface the Markdown body so the operator sees what landed in
302
+ // the brief; downstream the body is the actual dispatch payload.
303
+ this.appendSystemLine(result.content_md);
304
+ await this.dispatchBrief(`Brief from fetched page:\n\n${result.content_md}`);
305
+ }
306
+ /* ------------- dispatch -------------- */
307
+ async dispatchBrief(brief) {
308
+ const sessionId = this.state.sessionId;
309
+ if (!sessionId) {
310
+ this.appendSystemLine('No server session yet - try again in a moment.');
311
+ return;
312
+ }
313
+ const capVerdict = evaluateCap({
314
+ active: this.activeAgentCount(),
315
+ env: this.options.env ?? process.env,
316
+ });
317
+ const capLine = describeVerdict(capVerdict);
318
+ if (capVerdict.kind === 'block') {
319
+ this.appendSystemLine(capLine);
320
+ return;
321
+ }
322
+ if (capLine.length > 0) {
323
+ this.appendSystemLine(capLine);
324
+ }
325
+ this.appendOperatorLine(brief);
326
+ this.patch({ briefStartedAtEpochMs: this.now() });
327
+ try {
328
+ await this.options.transport.postBrief({
329
+ apiUrl: this.options.apiUrl,
330
+ apiKey: this.options.apiKey,
331
+ sessionId,
332
+ brief,
333
+ });
334
+ }
335
+ catch (error) {
336
+ this.appendSystemLine(`Brief dispatch refused: ${this.errorMessage(error)}`);
337
+ }
338
+ }
339
+ async dispatchStop(persona) {
340
+ const sessionId = this.state.sessionId;
341
+ if (!sessionId) {
342
+ this.appendSystemLine('No server session yet - nothing to stop.');
343
+ return;
344
+ }
345
+ try {
346
+ const { stopped } = await this.options.transport.postStop({
347
+ apiUrl: this.options.apiUrl,
348
+ apiKey: this.options.apiKey,
349
+ sessionId,
350
+ persona,
351
+ });
352
+ this.appendSystemLine(stopped
353
+ ? `Stopped persona '${persona}'.`
354
+ : `No active dispatch matched persona '${persona}'.`);
355
+ }
356
+ catch (error) {
357
+ this.appendSystemLine(`Stop refused: ${this.errorMessage(error)}`);
358
+ }
359
+ }
360
+ /* ------------- SSE consumer + reconnect -------------- */
361
+ openStream() {
362
+ const sessionId = this.state.sessionId;
363
+ if (!sessionId)
364
+ return;
365
+ if (this.streamHandle) {
366
+ this.streamHandle.close();
367
+ this.streamHandle = undefined;
368
+ }
369
+ this.streamHandle = this.options.transport.subscribe({
370
+ apiUrl: this.options.apiUrl,
371
+ apiKey: this.options.apiKey,
372
+ sessionId,
373
+ lastEventId: this.lastEventId,
374
+ onOpen: () => {
375
+ this.reconnectAttempt = 0;
376
+ this.patch({ connection: 'on_watch' });
377
+ },
378
+ onEvent: (event, eventId) => {
379
+ this.lastEventId = eventId;
380
+ this.handleServerEvent(event);
381
+ },
382
+ onError: (error) => {
383
+ if (this.closed)
384
+ return;
385
+ this.patch({ connection: 'reconnecting' });
386
+ this.appendSystemLine(`Stream interrupted (${this.errorMessage(error)}). Reconnecting.`);
387
+ this.scheduleReconnect();
388
+ },
389
+ });
390
+ }
391
+ scheduleReconnect() {
392
+ if (this.closed)
393
+ return;
394
+ if (this.reconnectAttempt >= MAX_RECONNECT_ATTEMPTS) {
395
+ this.appendSystemLine('Gave up reconnecting - type /quit and `pugi resume` to retry.');
396
+ this.patch({ connection: 'offline' });
397
+ return;
398
+ }
399
+ const delay = Math.min(RECONNECT_BASE_MS * 2 ** this.reconnectAttempt, RECONNECT_MAX_MS);
400
+ this.reconnectAttempt += 1;
401
+ this.reconnectTimer = setTimeout(() => {
402
+ this.reconnectTimer = undefined;
403
+ this.openStream();
404
+ }, delay);
405
+ }
406
+ /* ------------- event reducer -------------- */
407
+ handleServerEvent(event) {
408
+ switch (event.type) {
409
+ case 'agent.spawned': {
410
+ const persona = safePersonaName(event.role);
411
+ // Wave 4 fix 2026-05-25: the roster collapses to one row per
412
+ // persona slug. The α5.7 reducer pushed a fresh row on every
413
+ // spawn, so after three turns the bottom panel stacked
414
+ // "Mira orchestrator shipped" three times. The new contract:
415
+ // - If a row already exists for this personaSlug, REUSE it.
416
+ // Replace its taskId, reset status to 'queued', clear the
417
+ // detail line, restart the duration clock, zero the token
418
+ // counters. The persona name + slug + role stay stable
419
+ // (they are the row identity).
420
+ // - If no row exists yet, push a new one.
421
+ // Per-task lifecycle (step/tokens/completed/blocked/failed) is
422
+ // keyed off `taskId` everywhere, so the reused row still folds
423
+ // the latest task's events correctly.
424
+ const existing = this.state.agents.find((a) => a.personaSlug === event.personaSlug);
425
+ const node = {
426
+ taskId: event.taskId,
427
+ role: event.role,
428
+ personaSlug: event.personaSlug,
429
+ personaName: persona,
430
+ status: 'queued',
431
+ detail: 'queued for dispatch',
432
+ startedAtEpochMs: this.now(),
433
+ tokensIn: 0,
434
+ tokensOut: 0,
435
+ };
436
+ if (existing) {
437
+ this.patch({
438
+ agents: this.state.agents.map((a) => a.personaSlug === event.personaSlug ? node : a),
439
+ });
440
+ }
441
+ else {
442
+ this.patch({ agents: [node, ...this.state.agents] });
443
+ }
444
+ // The conversation pane already prefixes persona rows with the
445
+ // persona name in the persona's hue colour. Skip embedding the
446
+ // name in the body text to avoid the `Marcus Marcus dispatched`
447
+ // double-print. `void persona` keeps the resolved name in scope
448
+ // for the agent tree node above without leaking it into the
449
+ // transcript body.
450
+ void persona;
451
+ this.appendPersonaLine(event.personaSlug, `dispatched (${event.role}).`);
452
+ return;
453
+ }
454
+ case 'agent.step': {
455
+ // Cache the running detail per task so we can surface the
456
+ // model's actual reply when agent.completed lands (otherwise
457
+ // the reply disappears under the literal 'shipped' patch).
458
+ if (event.detail && event.detail.trim().length > 0) {
459
+ this.lastStepDetail.set(event.taskId, event.detail);
460
+ }
461
+ this.patch({
462
+ agents: this.state.agents.map((a) => a.taskId === event.taskId
463
+ ? { ...a, status: 'thinking', detail: event.detail }
464
+ : a),
465
+ });
466
+ return;
467
+ }
468
+ case 'agent.tokens': {
469
+ const delta = event.tokensIn + event.tokensOut;
470
+ this.patch({
471
+ tokensDownstreamTotal: this.state.tokensDownstreamTotal + delta,
472
+ agents: this.state.agents.map((a) => a.taskId === event.taskId
473
+ ? {
474
+ ...a,
475
+ tokensIn: a.tokensIn + event.tokensIn,
476
+ tokensOut: a.tokensOut + event.tokensOut,
477
+ }
478
+ : a),
479
+ });
480
+ return;
481
+ }
482
+ case 'agent.completed': {
483
+ const target = this.state.agents.find((a) => a.taskId === event.taskId);
484
+ const finalDetail = this.lastStepDetail.get(event.taskId);
485
+ this.lastStepDetail.delete(event.taskId);
486
+ this.patch({
487
+ agents: this.state.agents.map((a) => a.taskId === event.taskId
488
+ ? { ...a, status: 'shipped', detail: 'shipped' }
489
+ : a),
490
+ });
491
+ if (target) {
492
+ // If the persona actually produced a reply via incremental
493
+ // agent.step events, render that reply in the transcript so
494
+ // the operator sees the full answer. The threshold (>4 chars
495
+ // + not the queued placeholder) filters out the no-op
496
+ // "shipped"/"queued for dispatch" placeholders the dispatcher
497
+ // emits before the model speaks. Multi-line replies are
498
+ // emitted line by line so the conversation pane wraps each
499
+ // sentence on its own row.
500
+ if (finalDetail
501
+ && finalDetail !== 'queued for dispatch'
502
+ && finalDetail.trim().length > 4) {
503
+ for (const line of finalDetail.split('\n')) {
504
+ const trimmed = line.trim();
505
+ if (trimmed.length > 0) {
506
+ this.appendPersonaLine(target.personaSlug, trimmed);
507
+ }
508
+ }
509
+ }
510
+ else {
511
+ this.appendPersonaLine(target.personaSlug, 'shipped.');
512
+ }
513
+ }
514
+ return;
515
+ }
516
+ case 'agent.blocked': {
517
+ const target = this.state.agents.find((a) => a.taskId === event.taskId);
518
+ this.lastStepDetail.delete(event.taskId);
519
+ this.patch({
520
+ agents: this.state.agents.map((a) => a.taskId === event.taskId
521
+ ? { ...a, status: 'blocked', detail: event.detail }
522
+ : a),
523
+ });
524
+ if (target) {
525
+ this.appendPersonaLine(target.personaSlug, `blocked: ${event.detail}`);
526
+ }
527
+ return;
528
+ }
529
+ case 'agent.failed': {
530
+ const target = this.state.agents.find((a) => a.taskId === event.taskId);
531
+ this.lastStepDetail.delete(event.taskId);
532
+ this.patch({
533
+ agents: this.state.agents.map((a) => a.taskId === event.taskId
534
+ ? { ...a, status: 'failed', detail: event.error }
535
+ : a),
536
+ });
537
+ if (target) {
538
+ this.appendPersonaLine(target.personaSlug, `failed: ${event.error}`);
539
+ }
540
+ return;
541
+ }
542
+ }
543
+ }
544
+ /* ------------- transcript helpers -------------- */
545
+ appendOperatorLine(text) {
546
+ this.appendRow({ source: 'operator', text });
547
+ }
548
+ appendSystemLine(text) {
549
+ this.appendRow({ source: 'system', text });
550
+ }
551
+ appendPersonaLine(personaSlug, text) {
552
+ this.appendRow({ source: 'persona', text, personaSlug });
553
+ }
554
+ appendRow(input) {
555
+ if (input.text.length === 0)
556
+ return;
557
+ const row = {
558
+ id: randomUUID(),
559
+ source: input.source,
560
+ text: input.text,
561
+ personaSlug: input.personaSlug,
562
+ timestampEpochMs: this.now(),
563
+ };
564
+ const next = this.state.transcript.concat(row).slice(-MAX_TRANSCRIPT_ROWS);
565
+ this.patch({ transcript: next });
566
+ }
567
+ /* ------------- agent count + clock -------------- */
568
+ activeAgentCount() {
569
+ return this.state.agents.filter((a) => a.status === 'queued' || a.status === 'thinking').length;
570
+ }
571
+ /** Exported so the cap-warning gate can be exercised in isolation. */
572
+ activeRoles() {
573
+ return this.state.agents
574
+ .filter((a) => a.status === 'queued' || a.status === 'thinking')
575
+ .map((a) => a.role);
576
+ }
577
+ now() {
578
+ return (this.options.now ?? Date.now)();
579
+ }
580
+ patch(partial) {
581
+ this.state = { ...this.state, ...partial };
582
+ for (const subscriber of this.subscribers) {
583
+ subscriber(this.state);
584
+ }
585
+ }
586
+ errorMessage(error) {
587
+ if (error instanceof Error)
588
+ return error.message;
589
+ return String(error);
590
+ }
591
+ }
592
+ /* ------------------------------------------------------------------ */
593
+ /* Helpers */
594
+ /* ------------------------------------------------------------------ */
595
+ /**
596
+ * Resolve role → display name without throwing on unknown roles. The
597
+ * dispatcher always passes a known role, but the SSE wire format is
598
+ * forward-compatible - if a future server pushes a role the client
599
+ * does not recognise, the REPL still renders something usable rather
600
+ * than crashing mid-frame.
601
+ */
602
+ function safePersonaName(role) {
603
+ try {
604
+ return getPersonaForRole(role).name;
605
+ }
606
+ catch {
607
+ return role;
608
+ }
609
+ }
610
+ /**
611
+ * Convenience: list the legal role slugs the operator can target with
612
+ * `/stop`. Surfaced in the slash command help overlay and in the
613
+ * cap-warning helper when the operator types an unrecognised slug.
614
+ */
615
+ export function knownRoles() {
616
+ return listRoles();
617
+ }
618
+ //# sourceMappingURL=session.js.map