@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.
- package/LICENSE +21 -0
- package/README.md +172 -0
- package/bin/run.js +2 -0
- package/dist/commands/jobs.js +245 -0
- package/dist/core/agents/loader.js +104 -0
- package/dist/core/agents/registry.js +69 -0
- package/dist/core/auto-open-browser.js +128 -0
- package/dist/core/bash-classifier.js +1001 -0
- package/dist/core/clipboard.js +70 -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/credentials.js +355 -0
- package/dist/core/engine/adapter-runner.js +8 -0
- package/dist/core/engine/anvil-client.js +156 -0
- package/dist/core/engine/compaction-hook.js +154 -0
- package/dist/core/engine/index.js +12 -0
- package/dist/core/engine/native-pugi.js +369 -0
- package/dist/core/engine/noop.js +27 -0
- package/dist/core/engine/prompts.js +118 -0
- package/dist/core/engine/tool-bridge.js +313 -0
- package/dist/core/file-cache.js +29 -0
- package/dist/core/hooks.js +415 -0
- package/dist/core/index-store.js +260 -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/path-security.js +63 -0
- package/dist/core/permission.js +309 -0
- package/dist/core/repl/cap-warning.js +91 -0
- package/dist/core/repl/clipboard-read.js +174 -0
- package/dist/core/repl/history-search.js +175 -0
- package/dist/core/repl/history.js +172 -0
- package/dist/core/repl/kill-ring.js +138 -0
- package/dist/core/repl/session.js +618 -0
- package/dist/core/repl/slash-commands.js +227 -0
- package/dist/core/repl/workspace-context.js +113 -0
- package/dist/core/session.js +258 -0
- package/dist/core/settings.js +59 -0
- package/dist/core/skills/loader.js +454 -0
- package/dist/core/skills/sources.js +480 -0
- package/dist/core/skills/trust.js +172 -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/index.js +8 -0
- package/dist/runtime/cli.js +3405 -0
- package/dist/runtime/commands/agents.js +385 -0
- 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/skills.js +401 -0
- package/dist/runtime/commands/undo.js +329 -0
- package/dist/runtime/update-check.js +294 -0
- package/dist/tools/bash.js +660 -0
- package/dist/tools/file-tools.js +346 -0
- package/dist/tools/registry.js +25 -0
- package/dist/tools/web-fetch.js +535 -0
- package/dist/tui/agent-tree.js +66 -0
- package/dist/tui/conversation-pane.js +45 -0
- package/dist/tui/device-flow.js +142 -0
- package/dist/tui/input-box.js +474 -0
- package/dist/tui/login-picker.js +69 -0
- package/dist/tui/render.js +125 -0
- package/dist/tui/repl-render.js +240 -0
- package/dist/tui/repl-splash-art.js +64 -0
- package/dist/tui/repl-splash.js +111 -0
- package/dist/tui/repl.js +214 -0
- package/dist/tui/slash-palette.js +106 -0
- package/dist/tui/splash-data.js +61 -0
- package/dist/tui/splash.js +31 -0
- package/dist/tui/status-bar.js +71 -0
- package/dist/tui/update-banner.js +8 -0
- package/dist/tui/workspace-context.js +105 -0
- 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
|