@agentprojectcontext/apx 1.54.0 → 1.55.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.
@@ -1,862 +0,0 @@
1
- import readline from "node:readline";
2
- import { http } from "../http.js";
3
- import { resolveProjectId } from "./project.js";
4
- import { readConfig } from "#core/config/index.js";
5
- import { readIdentity } from "#core/identity/index.js";
6
- import { CHANNELS } from "#core/constants/channels.js";
7
- import {
8
- C,
9
- MODES,
10
- readPackageVersion,
11
- renderTerminalChat,
12
- titlecase,
13
- } from "../terminal-chat/renderer.js";
14
- import { existsSync } from "node:fs";
15
- import { fileURLToPath } from "node:url";
16
- import { dirname, resolve } from "node:path";
17
- import { spawnSync } from "node:child_process";
18
-
19
- const __dirname = dirname(fileURLToPath(import.meta.url));
20
- const TUI_SRC = resolve(__dirname, "../../tui/run.ts");
21
-
22
- const MAIN_PALETTE_OPTIONS = ["Switch model", "Switch agent", "Connect provider", "Open editor", "Exit"];
23
-
24
- // Message Actions overlay options for a queued message
25
- const MSG_ACTION_SEND = "Send now (interrupt current)";
26
- const MSG_ACTION_COPY = "Copy message text";
27
- const MSG_ACTION_QUESTION = "Ask about this...";
28
- const MSG_ACTION_REMOVE = "Remove from queue";
29
-
30
- export async function cmdSys(args) {
31
- const pid = await resolveProjectId(args?.flags?.project);
32
- const cfg = readConfig();
33
- const id = readIdentity();
34
-
35
- // Optional --agent <slug>: route chat to a project agent instead of the APX
36
- // default. Empty / missing flag means default ("super-agent" mode).
37
- const agentFlag = typeof args?.flags?.agent === "string" ? args.flags.agent.trim() : "";
38
- const routedAgentSlug = agentFlag || null;
39
- const defaultAgentLabel = id?.agent_name || cfg.super_agent?.name || "APX";
40
-
41
- // Launch new Solid.js TUI via bun (runs TS source directly — no esbuild bundle needed)
42
- if (existsSync(TUI_SRC)) {
43
- const bunBin = process.env.BUN_PATH || "bun";
44
- // bun must resolve node_modules/tsconfig from the apx package root, so the
45
- // spawn cwd stays there — but we pass the user's actual working directory
46
- // (where they ran `apx code`) via --cwd so the TUI shows the real project
47
- // path + git branch instead of apx/src.
48
- const userCwd = process.cwd();
49
- spawnSync(bunBin, [
50
- "--preload", "@opentui/solid/preload",
51
- TUI_SRC,
52
- "--pid", pid,
53
- "--agent", routedAgentSlug || defaultAgentLabel,
54
- "--model", cfg.super_agent?.model || "claude-3-5-sonnet",
55
- "--cwd", userCwd,
56
- ], { stdio: "inherit", cwd: resolve(__dirname, "../../..") });
57
- return;
58
- }
59
-
60
-
61
- const state = {
62
- currentModeIdx: 0,
63
- inputText: "",
64
- cursorIndex: 0,
65
- inCommandPalette: false,
66
- paletteSelection: 0,
67
- paletteState: "main",
68
- paletteOptions: [...MAIN_PALETTE_OPTIONS],
69
- activeAgent: titlecase(routedAgentSlug || defaultAgentLabel || "APX"),
70
- activeAgentSlug: routedAgentSlug, // null => super-agent route; string => /agents/:slug/chat
71
- activeAgentConversationId: null,
72
- defaultAgentLabel: defaultAgentLabel || "APX",
73
- activeModel: cfg.super_agent?.model || "Claude 3.5 Sonnet",
74
- version: readPackageVersion(),
75
- hasStarted: false,
76
- sessionTitle: "",
77
- usage: { input: 0, output: 0, percent: 0 },
78
- chatScrollOffset: 0,
79
- transcript: [],
80
- // Message Actions overlay state
81
- inMsgActions: false,
82
- msgActionsTarget: null, // { text } of the targeted message
83
- msgActionsSelection: 0,
84
- msgActionsOptions: [MSG_ACTION_SEND, MSG_ACTION_COPY, MSG_ACTION_QUESTION, MSG_ACTION_REMOVE],
85
- };
86
-
87
- const previousMessages = [];
88
- const pendingPrompts = [];
89
- let restored = false;
90
- let isRequesting = false;
91
- // AbortController for the current in-flight LLM request
92
- let currentAbortCtrl = null;
93
-
94
- function restoreTerminal() {
95
- if (restored) return;
96
- restored = true;
97
- // Disable mouse tracking before exit
98
- process.stdout.write("\x1b[?1000l\x1b[?1015l\x1b[?1006l");
99
- if (process.stdin.isTTY) process.stdin.setRawMode(false);
100
- process.stdout.write(C.reset + C.showCursor + C.resetBg + C.altOff);
101
- }
102
-
103
- function renderScreen() {
104
- state.sessionTitle = state.transcript.find((item) => item.type === "user")?.text || "";
105
- renderTerminalChat(state);
106
- }
107
-
108
- function resetPalette() {
109
- state.paletteState = "main";
110
- state.paletteOptions = [...MAIN_PALETTE_OPTIONS];
111
- state.paletteSelection = 0;
112
- }
113
-
114
- function close() {
115
- restoreTerminal();
116
- process.exit(0);
117
- }
118
-
119
- readline.emitKeypressEvents(process.stdin);
120
- if (process.stdin.isTTY) process.stdin.setRawMode(true);
121
- process.stdout.write(C.altOn + C.setBgBlack + C.showCursor + C.bg);
122
- // Enable xterm mouse button tracking (X10 + SGR extended for wide terminals)
123
- process.stdout.write("\x1b[?1000h\x1b[?1015h\x1b[?1006h");
124
- process.once("exit", restoreTerminal);
125
- process.once("SIGINT", close);
126
- process.once("SIGTERM", close);
127
- process.stdout.on?.("resize", renderScreen);
128
- process.on("SIGWINCH", renderScreen);
129
-
130
- renderScreen();
131
-
132
- // Handle raw mouse tracking bytes before readline keypress
133
- process.stdin.on("data", (chunk) => {
134
- const raw = typeof chunk === "string" ? chunk : chunk.toString("binary");
135
- // SGR mouse: ESC [ < Pb ; Px ; Py M/m
136
- const sgrMatch = raw.match(/\x1b\[<(\d+);(\d+);(\d+)([Mm])/);
137
- if (sgrMatch) {
138
- const btn = parseInt(sgrMatch[1], 10);
139
- const col = parseInt(sgrMatch[2], 10) - 1;
140
- const row = parseInt(sgrMatch[3], 10) - 1;
141
- const press = sgrMatch[4] === "M";
142
- if (press && btn === 0) {
143
- handleMouseClick(col, row, state, pendingPrompts, renderScreen, () => {
144
- // interrupt callback: abort current request then flush queue
145
- if (currentAbortCtrl) currentAbortCtrl.abort();
146
- });
147
- }
148
- return;
149
- }
150
- });
151
-
152
- process.stdin.on("keypress", async (str, key) => {
153
- if (key.ctrl && key.name === "c") {
154
- // If a request is running, interrupt it first; second Ctrl-C exits
155
- if (isRequesting && currentAbortCtrl) {
156
- currentAbortCtrl.abort();
157
- return;
158
- }
159
- close();
160
- }
161
-
162
- // Ctrl+I = interrupt current request and immediately send first queued prompt
163
- if (key.ctrl && key.name === "i" && isRequesting) {
164
- if (currentAbortCtrl) currentAbortCtrl.abort();
165
- return;
166
- }
167
-
168
- if (key.ctrl && key.name === "p") {
169
- state.inCommandPalette = !state.inCommandPalette;
170
- state.inMsgActions = false;
171
- resetPalette();
172
- renderScreen();
173
- return;
174
- }
175
-
176
- if (key.name === "escape") {
177
- if (state.inMsgActions) {
178
- state.inMsgActions = false;
179
- state.msgActionsTarget = null;
180
- renderScreen();
181
- return;
182
- }
183
- if (state.inCommandPalette) {
184
- if (state.paletteState !== "main") {
185
- resetPalette();
186
- } else {
187
- state.inCommandPalette = false;
188
- }
189
- renderScreen();
190
- return;
191
- }
192
- }
193
-
194
- if (state.inMsgActions) {
195
- await handleMsgActionsKey(key, state, pendingPrompts, renderScreen, () => {
196
- if (currentAbortCtrl) currentAbortCtrl.abort();
197
- });
198
- return;
199
- }
200
-
201
- if (state.inCommandPalette) {
202
- await handlePaletteKey(key, pid, cfg, state, renderScreen, close);
203
- return;
204
- }
205
-
206
- if (handleScrollKey(key, state, renderScreen)) return;
207
-
208
- if (isReturnKey(key)) {
209
- if (isExitCommand(state.inputText)) {
210
- close();
211
- return;
212
- }
213
-
214
- if (isRequesting) {
215
- queuePrompt(state, pendingPrompts, renderScreen);
216
- return;
217
- }
218
-
219
- isRequesting = true;
220
- await submitPromptQueue(
221
- pid, state, previousMessages, pendingPrompts, renderScreen, close,
222
- (ctrl) => { currentAbortCtrl = ctrl; }
223
- );
224
- isRequesting = false;
225
- currentAbortCtrl = null;
226
- return;
227
- }
228
-
229
- if (handleEditingKey(str, key, state, renderScreen)) return;
230
- });
231
- }
232
-
233
- // ---------------------------------------------------------------------------
234
- // Mouse click → Message Actions overlay
235
- // ---------------------------------------------------------------------------
236
-
237
- /**
238
- * Determine if a click at (col, row) lands on a queued user message bubble,
239
- * and if so open the Message Actions overlay for it.
240
- */
241
- function handleMouseClick(col, row, state, pendingPrompts, renderScreen, onInterrupt) {
242
- if (!state.hasStarted) return;
243
-
244
- // Find the message bubble that was clicked.
245
- // We look for both queued and regular messages in the transcript.
246
- // Transcript is rendered from bottom to top in terms of logic,
247
- // but we'll use a simple heuristic for now.
248
- const allUserMessages = state.transcript.filter(t => t.type === "user");
249
- if (allUserMessages.length === 0) return;
250
-
251
- const { width } = { width: process.stdout.columns || 80 };
252
- if (col > Math.floor(width * 0.8)) return;
253
-
254
- // For now, just pick the most recent one if clicked in the main area.
255
- // In a real app we'd map row to transcript index precisely.
256
- state.inMsgActions = true;
257
- state.msgActionsTarget = allUserMessages[allUserMessages.length - 1];
258
- state.msgActionsSelection = 0;
259
-
260
- // Filter options: "Send now" only for queued items
261
- const isQueued = state.msgActionsTarget.meta === "queued";
262
- state.msgActionsOptions = isQueued
263
- ? [MSG_ACTION_SEND, MSG_ACTION_COPY, MSG_ACTION_QUESTION, MSG_ACTION_REMOVE]
264
- : [MSG_ACTION_COPY, MSG_ACTION_QUESTION];
265
-
266
- renderScreen();
267
- }
268
-
269
- /** Keyboard nav inside the Message Actions overlay */
270
- async function handleMsgActionsKey(key, state, pendingPrompts, renderScreen, onInterrupt) {
271
- if (key.name === "up") {
272
- state.msgActionsSelection = Math.max(0, state.msgActionsSelection - 1);
273
- renderScreen();
274
- return;
275
- }
276
- if (key.name === "down") {
277
- state.msgActionsSelection = Math.min(
278
- state.msgActionsOptions.length - 1,
279
- state.msgActionsSelection + 1
280
- );
281
- renderScreen();
282
- return;
283
- }
284
- if (key.name !== "return") {
285
- renderScreen();
286
- return;
287
- }
288
-
289
- const selected = state.msgActionsOptions[state.msgActionsSelection];
290
- const target = state.msgActionsTarget;
291
-
292
- // Close overlay first
293
- state.inMsgActions = false;
294
- state.msgActionsTarget = null;
295
-
296
- if (selected === MSG_ACTION_REMOVE) {
297
- // Remove from pendingPrompts and transcript
298
- const idx = pendingPrompts.findIndex((p) => p.text === target?.text);
299
- if (idx >= 0) pendingPrompts.splice(idx, 1);
300
- const tidx = state.transcript.indexOf(target);
301
- if (tidx >= 0) state.transcript.splice(tidx, 1);
302
- renderScreen();
303
- return;
304
- }
305
-
306
- if (selected === MSG_ACTION_COPY) {
307
- // Best-effort clipboard via pbcopy (macOS) / xclip (Linux)
308
- try {
309
- const { execSync } = await import("node:child_process");
310
- const cmd = process.platform === "darwin" ? "pbcopy" : "xclip -selection clipboard";
311
- execSync(cmd, { input: target?.text || "", stdio: ["pipe", "ignore", "ignore"] });
312
- } catch {}
313
- state.transcript.push({ type: "status", text: "Copied to clipboard" });
314
- renderScreen();
315
- return;
316
- }
317
-
318
- if (selected === MSG_ACTION_QUESTION) {
319
- const text = target?.text || "";
320
- state.inputText = `Pregunta sobre esto: "${text.slice(0, 50)}${text.length > 50 ? "..." : ""}"\n\n`;
321
- state.cursorIndex = state.inputText.length;
322
- renderScreen();
323
- return;
324
- }
325
-
326
- if (selected === MSG_ACTION_SEND) {
327
- // Promote the queued item to front of queue, then interrupt current request
328
- const idx = pendingPrompts.findIndex((p) => p.text === target?.text);
329
- if (idx > 0) {
330
- const [entry] = pendingPrompts.splice(idx, 1);
331
- pendingPrompts.unshift(entry);
332
- }
333
- // Signal the interrupt — the running submitPromptQueue will pick it up
334
- onInterrupt();
335
- renderScreen();
336
- return;
337
- }
338
-
339
- renderScreen();
340
- }
341
-
342
- export function isReturnKey(key) {
343
- return key?.name === "return" || key?.name === "enter";
344
- }
345
-
346
- export function isExitCommand(text) {
347
- return /^(exit|quit)$/i.test(String(text || "").trim());
348
- }
349
-
350
- export function handleScrollKey(key, state, renderScreen) {
351
- if (!state.hasStarted || !key) return false;
352
- const pageSize = key.name === "pageup" || key.name === "pagedown" ? 8 : 3;
353
-
354
- if (key.name === "pageup" || key.name === "up" || (key.ctrl && key.name === "up")) {
355
- state.chatScrollOffset = Math.min(100000, (state.chatScrollOffset || 0) + pageSize);
356
- renderScreen();
357
- return true;
358
- }
359
-
360
- if (key.name === "pagedown" || key.name === "down" || (key.ctrl && key.name === "down")) {
361
- state.chatScrollOffset = Math.max(0, (state.chatScrollOffset || 0) - pageSize);
362
- renderScreen();
363
- return true;
364
- }
365
-
366
- if (key.meta && key.name === "up") {
367
- state.chatScrollOffset = Math.min(100000, (state.chatScrollOffset || 0) + 20);
368
- renderScreen();
369
- return true;
370
- }
371
-
372
- if (key.meta && key.name === "down") {
373
- state.chatScrollOffset = 0;
374
- renderScreen();
375
- return true;
376
- }
377
-
378
- return false;
379
- }
380
-
381
- async function handlePaletteKey(key, pid, cfg, state, renderScreen, close) {
382
- if (key.name === "up") {
383
- state.paletteSelection = Math.max(0, state.paletteSelection - 1);
384
- renderScreen();
385
- return;
386
- }
387
-
388
- if (key.name === "down") {
389
- state.paletteSelection = Math.min(state.paletteOptions.length - 1, state.paletteSelection + 1);
390
- renderScreen();
391
- return;
392
- }
393
-
394
- if (key.name !== "return") {
395
- renderScreen();
396
- return;
397
- }
398
-
399
- const selected = state.paletteOptions[state.paletteSelection];
400
-
401
- if (state.paletteState === "main") {
402
- if (selected === "Exit") { close(); return; }
403
-
404
- if (selected === "Switch model") {
405
- state.paletteState = "switch_model";
406
- state.paletteOptions = ["Loading models..."];
407
- state.paletteSelection = 0;
408
- renderScreen();
409
- loadModelOptions(pid, cfg, state, renderScreen);
410
- return;
411
- }
412
-
413
- if (selected === "Switch agent") {
414
- state.paletteState = "switch_agent";
415
- state.paletteOptions = ["Loading agents..."];
416
- state.paletteSelection = 0;
417
- renderScreen();
418
- loadAgentOptions(pid, state, renderScreen);
419
- return;
420
- }
421
-
422
- state.inCommandPalette = false;
423
- state.transcript.push({ type: "status", text: `Command: ${selected} (not implemented yet)` });
424
- renderScreen();
425
- return;
426
- }
427
-
428
- if (
429
- state.paletteState === "switch_model" &&
430
- !selected.startsWith("Loading") &&
431
- !selected.startsWith("Failed") &&
432
- !selected.startsWith("No ")
433
- ) {
434
- state.activeModel = selected;
435
- const configModule = await import("#core/config/index.js");
436
- const currentCfg = configModule.readConfig();
437
- if (!currentCfg.super_agent) currentCfg.super_agent = {};
438
- currentCfg.super_agent.model = selected;
439
- configModule.writeConfig(currentCfg);
440
-
441
- // Tell the daemon to re-read ~/.apx/config.json so the next chat picks
442
- // up the new model without needing a full restart.
443
- try { await http.post("/admin/reload", {}); } catch {}
444
-
445
- state.inCommandPalette = false;
446
- state.transcript.push({ type: "status", text: `Model → ${selected}` });
447
- renderScreen();
448
- return;
449
- }
450
-
451
- if (
452
- state.paletteState === "switch_agent" &&
453
- !selected.startsWith("Loading") &&
454
- !selected.startsWith("Failed") &&
455
- !selected.startsWith("No ")
456
- ) {
457
- // The "APX (default)" option resets routing back to the APX default
458
- // (a.k.a. super-agent mode); any other selection routes chat to that
459
- // project agent's /agents/:slug/chat endpoint.
460
- if (selected === "APX (default)") {
461
- state.activeAgentSlug = null;
462
- state.activeAgent = titlecase(state.defaultAgentLabel || "APX");
463
- state.activeAgentConversationId = null;
464
- } else {
465
- state.activeAgentSlug = selected;
466
- state.activeAgent = titlecase(selected);
467
- state.activeAgentConversationId = null;
468
- }
469
- state.inCommandPalette = false;
470
- state.transcript.push({ type: "status", text: `Agent → ${state.activeAgent}` });
471
- renderScreen();
472
- return;
473
- }
474
-
475
- renderScreen();
476
- }
477
-
478
- function loadModelOptions(pid, cfg, state, renderScreen) {
479
- // Load engines from APX daemon first, then fall back to Ollama tags
480
- const apxEnginesPromise = pid
481
- ? http.get("/engines").then((d) => d?.engines || []).catch(() => [])
482
- : Promise.resolve([]);
483
-
484
- const ollamaBaseUrl = cfg.engines?.ollama?.base_url || "http://127.0.0.1:11434";
485
- const ollamaPromise = fetch(`${ollamaBaseUrl}/api/tags`)
486
- .then((r) => r.json())
487
- .then((d) => (d.models || []).map((m) => "ollama:" + m.name))
488
- .catch(() => []);
489
-
490
- Promise.all([apxEnginesPromise, ollamaPromise])
491
- .then(([apxEngines, ollamaModels]) => {
492
- const all = [
493
- ...apxEngines.filter((e) => typeof e === "string"),
494
- ...ollamaModels,
495
- ];
496
- state.paletteOptions = all.length ? all : ["No models found"];
497
- if (state.paletteState === "switch_model") renderScreen();
498
- })
499
- .catch(() => {
500
- state.paletteOptions = ["Failed to load models"];
501
- if (state.paletteState === "switch_model") renderScreen();
502
- });
503
- }
504
-
505
- function loadAgentOptions(pid, state, renderScreen) {
506
- if (!pid) {
507
- state.paletteOptions = ["No project selected"];
508
- renderScreen();
509
- return;
510
- }
511
- http.get(`/projects/${pid}/agents`)
512
- .then((agents) => {
513
- const list = Array.isArray(agents)
514
- ? agents.map((a) => a.slug || a.name || String(a)).filter(Boolean)
515
- : [];
516
- // Always offer a way back to the APX default agent ("super-agent" mode).
517
- state.paletteOptions = ["APX (default)", ...list];
518
- if (state.paletteState === "switch_agent") renderScreen();
519
- })
520
- .catch(() => {
521
- state.paletteOptions = ["APX (default)", "Failed to load agents"];
522
- if (state.paletteState === "switch_agent") renderScreen();
523
- });
524
- }
525
-
526
- export function handleEditingKey(str, key, state, renderScreen) {
527
- if (key.name === "tab") {
528
- state.currentModeIdx = (state.currentModeIdx + 1) % MODES.length;
529
- renderScreen();
530
- return true;
531
- }
532
-
533
- if (key.ctrl && key.name === "a") {
534
- state.cursorIndex = 0;
535
- renderScreen();
536
- return true;
537
- }
538
-
539
- if (key.ctrl && key.name === "e") {
540
- state.cursorIndex = state.inputText.length;
541
- renderScreen();
542
- return true;
543
- }
544
-
545
- if (key.ctrl && key.name === "u") {
546
- state.inputText = state.inputText.slice(state.cursorIndex);
547
- state.cursorIndex = 0;
548
- renderScreen();
549
- return true;
550
- }
551
-
552
- if (key.ctrl && key.name === "w") {
553
- const before = state.inputText.slice(0, state.cursorIndex).replace(/\s*\S+\s*$/, "");
554
- state.inputText = before + state.inputText.slice(state.cursorIndex);
555
- state.cursorIndex = before.length;
556
- renderScreen();
557
- return true;
558
- }
559
-
560
- if (key.name === "left") {
561
- state.cursorIndex = Math.max(0, state.cursorIndex - 1);
562
- renderScreen();
563
- return true;
564
- }
565
-
566
- if (key.name === "right") {
567
- state.cursorIndex = Math.min(state.inputText.length, state.cursorIndex + 1);
568
- renderScreen();
569
- return true;
570
- }
571
-
572
- if (key.name === "home") {
573
- state.cursorIndex = 0;
574
- renderScreen();
575
- return true;
576
- }
577
-
578
- if (key.name === "end") {
579
- state.cursorIndex = state.inputText.length;
580
- renderScreen();
581
- return true;
582
- }
583
-
584
- if (key.name === "delete") {
585
- state.inputText = state.inputText.slice(0, state.cursorIndex) + state.inputText.slice(state.cursorIndex + 1);
586
- renderScreen();
587
- return true;
588
- }
589
-
590
- if (key.name === "backspace") {
591
- if (state.cursorIndex === 0) return true;
592
- state.inputText = state.inputText.slice(0, state.cursorIndex - 1) + state.inputText.slice(state.cursorIndex);
593
- state.cursorIndex -= 1;
594
- renderScreen();
595
- return true;
596
- }
597
-
598
- if (str && str.length === 1 && !key.ctrl && !key.meta && str >= " ") {
599
- state.inputText = state.inputText.slice(0, state.cursorIndex) + str + state.inputText.slice(state.cursorIndex);
600
- state.cursorIndex += str.length;
601
- renderScreen();
602
- return true;
603
- }
604
-
605
- return false;
606
- }
607
-
608
- function queuePrompt(state, pendingPrompts, renderScreen) {
609
- const text = state.inputText.trim();
610
- if (!text) return;
611
-
612
- state.hasStarted = true;
613
- state.inputText = "";
614
- state.cursorIndex = 0;
615
- state.chatScrollOffset = 0;
616
-
617
- const item = { type: "user", text, meta: "queued" };
618
- pendingPrompts.push({ text, item });
619
- state.transcript.push(item);
620
- renderScreen();
621
- }
622
-
623
- async function submitPromptQueue(
624
- pid, state, previousMessages, pendingPrompts, renderScreen, close,
625
- setAbortCtrl = () => {}
626
- ) {
627
- const firstText = state.inputText.trim();
628
- if (!firstText) return;
629
- if (isExitCommand(firstText)) {
630
- close();
631
- return;
632
- }
633
-
634
- state.hasStarted = true;
635
- state.inputText = "";
636
- state.cursorIndex = 0;
637
- state.chatScrollOffset = 0;
638
-
639
- const firstItem = { type: "user", text: firstText };
640
- state.transcript.push(firstItem);
641
- await runPrompt(
642
- pid, state, previousMessages, renderScreen, firstText, firstItem, setAbortCtrl
643
- );
644
-
645
- while (pendingPrompts.length > 0) {
646
- const queued = pendingPrompts.shift();
647
- delete queued.item.meta;
648
- await runPrompt(
649
- pid, state, previousMessages, renderScreen, queued.text, queued.item, setAbortCtrl
650
- );
651
- }
652
- }
653
-
654
- async function runPrompt(
655
- pid, state, previousMessages, renderScreen, text, userItem,
656
- setAbortCtrl = () => {}
657
- ) {
658
- appendLiveItem(state, { type: "status", text: "Thinking...", active: true });
659
- renderScreen();
660
-
661
- const startTime = Date.now();
662
- const abortCtrl = http.createAbortController();
663
- setAbortCtrl(abortCtrl);
664
-
665
- try {
666
- const cwd = process.cwd();
667
-
668
- // --- Project-agent routing ------------------------------------------
669
- // When --agent <slug> was passed (or the user picked one from the
670
- // palette), bypass the super-agent stream and POST to the agent's chat
671
- // endpoint. /agents/:slug/chat is non-streaming today — the spinner
672
- // stays "Thinking..." until the full reply lands.
673
- if (state.activeAgentSlug) {
674
- const agentBody = {
675
- prompt: `[Mode: ${MODES[state.currentModeIdx]}]\n${text}`,
676
- conversation_id: state.activeAgentConversationId || undefined,
677
- model: state.activeModel,
678
- };
679
- let agentInterrupted = false;
680
- let agentResult;
681
- try {
682
- agentResult = await http.post(
683
- `/projects/${pid}/agents/${encodeURIComponent(state.activeAgentSlug)}/chat`,
684
- agentBody,
685
- { signal: abortCtrl.signal }
686
- );
687
- } catch (e) {
688
- if (abortCtrl.signal.aborted) {
689
- agentInterrupted = true;
690
- removeStatus(state);
691
- appendLiveItem(state, {
692
- type: "status",
693
- text: `⚡ Interrupted — ${text.slice(0, 60)}${text.length > 60 ? "…" : ""}`,
694
- });
695
- } else {
696
- throw e;
697
- }
698
- }
699
- if (!agentInterrupted && agentResult) {
700
- if (agentResult.conversation_id) {
701
- state.activeAgentConversationId = agentResult.conversation_id;
702
- }
703
- completeSuperAgentResult(agentResult, text, startTime, state, previousMessages);
704
- }
705
- if (userItem) delete userItem.meta;
706
- setAbortCtrl(null);
707
- renderScreen();
708
- return;
709
- }
710
-
711
- const currentMode = MODES[state.currentModeIdx];
712
- const body = {
713
- prompt: `[Mode: ${currentMode}]\n${text}`,
714
- channel: CHANNELS.CODE,
715
- channelMeta: { cwd },
716
- previousMessages,
717
- model: state.activeModel,
718
- };
719
- // Coding modes mirror the web Code module: high iteration ceiling + a real
720
- // output budget so multi-step tasks run to completion. Build additionally
721
- // turns on the completion contract (the model keeps calling tools until it
722
- // calls `finish` — no early "I'll do X" stops). Zen stays lightweight chat.
723
- if (currentMode === "Build" || currentMode === "Plan") {
724
- body.maxIters = 100;
725
- body.maxTokens = 8192;
726
- }
727
- if (currentMode === "Build") {
728
- body.completionContract = true;
729
- }
730
-
731
- let result;
732
- let interrupted = false;
733
- try {
734
- result = await http.streamPost(
735
- `/projects/${pid}/super-agent/chat/stream`,
736
- body,
737
- (event) => handleProgressEvent(event, state, renderScreen),
738
- { signal: abortCtrl.signal }
739
- );
740
- } catch (e) {
741
- if (abortCtrl.signal.aborted) {
742
- // Interrupted by user — show notice and continue to next queued prompt
743
- interrupted = true;
744
- removeStatus(state);
745
- appendLiveItem(state, {
746
- type: "status",
747
- text: `\u26a1 Interrupted — ${text.slice(0, 60)}${text.length > 60 ? "\u2026" : ""}`,
748
- });
749
- } else if (e.status !== 404) {
750
- throw e;
751
- } else {
752
- result = await http.post(
753
- `/projects/${pid}/super-agent/chat`, body,
754
- { signal: abortCtrl.signal }
755
- );
756
- removeStatus(state);
757
- for (const trace of result.trace || []) {
758
- appendLiveItem(state, { type: "tool", trace });
759
- }
760
- }
761
- }
762
-
763
- if (!interrupted && result) {
764
- completeSuperAgentResult(result, text, startTime, state, previousMessages);
765
- }
766
- } catch (e) {
767
- if (!abortCtrl.signal.aborted) {
768
- removeStatus(state);
769
- appendLiveItem(state, { type: "error", text: e.message });
770
- }
771
- }
772
-
773
- if (userItem) delete userItem.meta;
774
- setAbortCtrl(null);
775
- renderScreen();
776
- }
777
-
778
- function removeStatus(state) {
779
- for (let i = state.transcript.length - 1; i >= 0; i--) {
780
- if (state.transcript[i]?.type === "status" && state.transcript[i]?.active) {
781
- state.transcript.splice(i, 1);
782
- return;
783
- }
784
- }
785
- }
786
-
787
- function appendLiveItem(state, item) {
788
- const queuedIndex = state.transcript.findIndex(
789
- (entry) => entry?.type === "user" && entry?.meta === "queued"
790
- );
791
- if (queuedIndex >= 0) state.transcript.splice(queuedIndex, 0, item);
792
- else state.transcript.push(item);
793
- }
794
-
795
- function handleProgressEvent(event, state, renderScreen) {
796
- if (event.type === "model_start") {
797
- const status = [...state.transcript].reverse().find((item) => item?.type === "status" && item?.active);
798
- if (status) {
799
- status.text = event.iteration > 1 ? `Thinking... step ${event.iteration}` : "Thinking...";
800
- renderScreen();
801
- }
802
- return;
803
- }
804
-
805
- if (event.type === "model_retry") {
806
- const status = [...state.transcript].reverse().find((item) => item?.type === "status" && item?.active);
807
- if (status) status.text = "Retrying with tool fallback...";
808
- else appendLiveItem(state, { type: "status", text: "Retrying with tool fallback...", active: true });
809
- renderScreen();
810
- return;
811
- }
812
-
813
- if (event.type === "assistant_text" && event.text) {
814
- removeStatus(state);
815
- appendLiveItem(state, {
816
- type: "assistant",
817
- name: state.activeAgent,
818
- text: event.text,
819
- meta: "intermediate",
820
- });
821
- renderScreen();
822
- return;
823
- }
824
-
825
- if (event.type === "tool_start" && event.trace) {
826
- removeStatus(state);
827
- appendLiveItem(state, { type: "tool", trace: event.trace });
828
- renderScreen();
829
- return;
830
- }
831
-
832
- if (event.type === "tool_result" && event.trace) {
833
- removeStatus(state);
834
- const idx = state.transcript.findIndex(
835
- (item) => item.type === "tool" && item.trace?.id && item.trace.id === event.trace.id
836
- );
837
- if (idx >= 0) state.transcript[idx] = { type: "tool", trace: event.trace };
838
- else appendLiveItem(state, { type: "tool", trace: event.trace });
839
- renderScreen();
840
- }
841
- }
842
-
843
- function completeSuperAgentResult(result, userText, startTime, state, previousMessages) {
844
- removeStatus(state);
845
- if (!result) throw new Error("super-agent stream ended without final result");
846
-
847
- previousMessages.push({ role: "user", content: userText });
848
- previousMessages.push({ role: "assistant", content: result.text });
849
- if (previousMessages.length > 20) previousMessages.splice(0, previousMessages.length - 20);
850
-
851
- const elapsed = ((Date.now() - startTime) / 1000).toFixed(1);
852
- state.usage.input += result.usage?.input_tokens || 0;
853
- state.usage.output += result.usage?.output_tokens || 0;
854
- state.usage.percent = Math.min(99, Math.round((state.usage.input / 200000) * 100));
855
-
856
- appendLiveItem(state, {
857
- type: "assistant",
858
- name: state.activeAgent,
859
- text: result.text,
860
- meta: `${elapsed}s · In: ${result.usage?.input_tokens || 0} Out: ${result.usage?.output_tokens || 0}`,
861
- });
862
- }