@standardagents/code 0.1.0 → 0.1.2

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/dist/index.js CHANGED
@@ -1,12 +1,12 @@
1
1
  #!/usr/bin/env node
2
- import os4 from 'os';
3
- import fs2 from 'fs';
2
+ import os6 from 'os';
3
+ import fs4 from 'fs';
4
4
  import path3 from 'path';
5
5
  import readline2 from 'readline/promises';
6
- import { spawn } from 'child_process';
6
+ import { spawn, execFile } from 'child_process';
7
7
  import { stdout, stdin } from 'process';
8
8
  import fsp from 'fs/promises';
9
- import crypto2 from 'crypto';
9
+ import crypto from 'crypto';
10
10
  import readline from 'readline';
11
11
 
12
12
  // src/api.ts
@@ -83,10 +83,38 @@ var ApiClient = class {
83
83
  preview: t.preview || t.last_message
84
84
  })).filter((t) => requireTags.every((tag) => t.tags.includes(tag)));
85
85
  }
86
- async sendMessage(threadId, content) {
86
+ /**
87
+ * Subagent child threads of a thread, each with its current lifecycle status
88
+ * (`running`/`idle`/`terminated`). Sourced from the parent's child registry,
89
+ * which the live `/api/events` stream doesn't carry — so the CLI reads this to
90
+ * learn which subagents are *actually running* (vs idle or finished), both on
91
+ * (re)connect and whenever a subagent thread changes.
92
+ */
93
+ async listSubagents(threadId) {
94
+ const res = await this.json(
95
+ `/api/threads/${threadId}/subagents`
96
+ );
97
+ const arr = Array.isArray(res) ? res : res.subagents || [];
98
+ return arr.filter((s) => s && (s.id ?? s.reference)).map((s) => ({
99
+ id: s.id ?? s.reference,
100
+ agent_name: s.agent_name ?? s.name ?? null,
101
+ title: s.title ?? null,
102
+ threadName: s.threadName ?? s.thread_name ?? null,
103
+ status: typeof s.status === "string" ? s.status : "running"
104
+ }));
105
+ }
106
+ /**
107
+ * Send a user message, optionally with attachments (e.g. pasted images).
108
+ * Attachments use the instance's message-POST shape — base64 `data` +
109
+ * `mimeType` — which the server stores in the thread filesystem and injects
110
+ * into the LLM's vision context as real image content blocks.
111
+ */
112
+ async sendMessage(threadId, content, attachments) {
113
+ const body = { role: "user", content };
114
+ if (attachments && attachments.length > 0) body.attachments = attachments;
87
115
  await this.json(`/api/threads/${threadId}/messages`, {
88
116
  method: "POST",
89
- body: JSON.stringify({ role: "user", content })
117
+ body: JSON.stringify(body)
90
118
  });
91
119
  }
92
120
  async getMessages(threadId, limit = 50) {
@@ -122,6 +150,22 @@ var ApiClient = class {
122
150
  }
123
151
  return false;
124
152
  }
153
+ /**
154
+ * Download raw file bytes from the thread's filesystem (e.g. an
155
+ * /attachments/* asset a subagent generated). Returns null on any failure.
156
+ */
157
+ async fetchFile(threadId, fsPath) {
158
+ const clean2 = fsPath.startsWith("/") ? fsPath : `/${fsPath}`;
159
+ try {
160
+ const res = await fetch(`${this.endpoint}/api/threads/${threadId}/fs${clean2}`, {
161
+ headers: { Authorization: `Bearer ${this.token}` }
162
+ });
163
+ if (!res.ok) return null;
164
+ return Buffer.from(await res.arrayBuffer());
165
+ } catch {
166
+ return null;
167
+ }
168
+ }
125
169
  /** Read a value from the thread's durable KV store (null if absent). */
126
170
  async kvGet(threadId, key) {
127
171
  try {
@@ -144,12 +188,49 @@ var ApiClient = class {
144
188
  } catch {
145
189
  }
146
190
  }
191
+ // ── skills (instance-global Agent Skills library) ─────────────────────────
192
+ /** Installed skills — metadata only. Includes disabled skills. */
193
+ async listSkills() {
194
+ const res = await this.json(`/api/skills?all=1`);
195
+ return Array.isArray(res?.skills) ? res.skills : [];
196
+ }
197
+ /** Enable/disable an installed skill. */
198
+ async setSkillEnabled(name, enabled) {
199
+ await this.json(`/api/skills/${encodeURIComponent(name)}`, {
200
+ method: "PATCH",
201
+ body: JSON.stringify({ enabled })
202
+ });
203
+ }
204
+ /** Uninstall a skill. */
205
+ async removeSkill(name) {
206
+ await this.json(`/api/skills/${encodeURIComponent(name)}`, { method: "DELETE" });
207
+ }
208
+ /**
209
+ * Kick off background conversation compaction now. Spawns the compaction
210
+ * subagent directly server-side (so it fires even while the thread is idle).
211
+ */
212
+ async compact(threadId) {
213
+ await this.json(`/api/threads/${threadId}/compact`, { method: "POST" });
214
+ }
147
215
  async stop(threadId) {
148
216
  try {
149
217
  await this.json(`/api/threads/${threadId}/stop`, { method: "POST" });
150
218
  } catch {
151
219
  }
152
220
  }
221
+ /** The thread's current goal (set via set_goal / update_goal_step). */
222
+ async getGoal(threadId) {
223
+ try {
224
+ const r = await this.json(`/api/threads/${threadId}/goal`);
225
+ return {
226
+ summary: r?.summary ?? null,
227
+ description: r?.description ?? null,
228
+ steps: Array.isArray(r?.steps) ? r.steps : []
229
+ };
230
+ } catch {
231
+ return { summary: null, description: null, steps: [] };
232
+ }
233
+ }
153
234
  };
154
235
 
155
236
  // src/permissions.ts
@@ -202,8 +283,208 @@ function saveApprovals(api, threadId, perm) {
202
283
  void api.kvSet(threadId, KEY, payload);
203
284
  }
204
285
 
286
+ // src/heartbeat.ts
287
+ var HEARTBEAT_INTERVAL_MS = 5e3;
288
+ var CONNECTION_SILENCE_TIMEOUT_MS = 15e3;
289
+ var Heartbeat = class {
290
+ constructor(ws, onDead, options = {}) {
291
+ this.ws = ws;
292
+ this.onDead = onDead;
293
+ this.intervalMs = options.intervalMs ?? HEARTBEAT_INTERVAL_MS;
294
+ this.silenceMs = options.silenceMs ?? CONNECTION_SILENCE_TIMEOUT_MS;
295
+ }
296
+ ws;
297
+ onDead;
298
+ timer = null;
299
+ lastRecvAt = 0;
300
+ intervalMs;
301
+ silenceMs;
302
+ start() {
303
+ this.stop();
304
+ this.lastRecvAt = Date.now();
305
+ this.timer = setInterval(() => this.tick(), this.intervalMs);
306
+ }
307
+ /** Record that a frame was received — proof the connection is alive. */
308
+ markAlive() {
309
+ this.lastRecvAt = Date.now();
310
+ }
311
+ stop() {
312
+ if (this.timer) {
313
+ clearInterval(this.timer);
314
+ this.timer = null;
315
+ }
316
+ }
317
+ tick() {
318
+ if (Date.now() - this.lastRecvAt > this.silenceMs) {
319
+ this.fail();
320
+ return;
321
+ }
322
+ try {
323
+ if (this.ws.readyState === WebSocket.OPEN) this.ws.send("ping");
324
+ else this.fail();
325
+ } catch {
326
+ this.fail();
327
+ }
328
+ }
329
+ fail() {
330
+ this.stop();
331
+ try {
332
+ this.ws.close();
333
+ } catch {
334
+ }
335
+ this.onDead();
336
+ }
337
+ };
338
+
339
+ // src/render.ts
340
+ var RESET = "\x1B[0m";
341
+ var DIM = "\x1B[2m";
342
+ var ADD_BG = "\x1B[48;5;22m\x1B[38;5;254m";
343
+ var DEL_BG = "\x1B[48;5;52m\x1B[38;5;254m";
344
+ var MAX_SIDE_LINES = 4;
345
+ function clamp(s, max) {
346
+ return s.length > max ? s.slice(0, Math.max(0, max - 1)) + "\u2026" : s;
347
+ }
348
+ function cols() {
349
+ return Math.max(20, process.stdout.columns || 80);
350
+ }
351
+ function flat(line) {
352
+ return line.replace(/\t/g, " ");
353
+ }
354
+ function diffLines(oldStr, newStr) {
355
+ let oldLines = oldStr.split("\n");
356
+ let newLines = newStr.split("\n");
357
+ while (oldLines.length && newLines.length && oldLines[0] === newLines[0]) {
358
+ oldLines.shift();
359
+ newLines.shift();
360
+ }
361
+ while (oldLines.length && newLines.length && oldLines[oldLines.length - 1] === newLines[newLines.length - 1]) {
362
+ oldLines.pop();
363
+ newLines.pop();
364
+ }
365
+ const width = cols() - 2;
366
+ const out = [];
367
+ const side = (lines, bg, sign) => {
368
+ const shown = lines.slice(0, MAX_SIDE_LINES);
369
+ for (const l of shown) out.push(`${bg}${sign} ${clamp(flat(l), width)}${RESET}`);
370
+ const hidden = lines.length - shown.length;
371
+ if (hidden > 0) out.push(`${DIM} \u2026 +${hidden} more line${hidden === 1 ? "" : "s"}${RESET}`);
372
+ };
373
+ side(oldLines, DEL_BG, "-");
374
+ side(newLines, ADD_BG, "+");
375
+ return out;
376
+ }
377
+ function newFileLines(content) {
378
+ const lines = content.split("\n");
379
+ const width = cols() - 2;
380
+ const shown = lines.slice(0, MAX_SIDE_LINES);
381
+ const out = shown.map((l) => `${ADD_BG}+ ${clamp(flat(l), width)}${RESET}`);
382
+ const hidden = lines.length - shown.length;
383
+ if (hidden > 0) out.push(`${DIM} \u2026 +${hidden} more line${hidden === 1 ? "" : "s"}${RESET}`);
384
+ return out;
385
+ }
386
+ function highlightBash(cmd) {
387
+ const CYAN2 = "\x1B[36m";
388
+ const GREEN = "\x1B[32m";
389
+ const MAGENTA = "\x1B[35m";
390
+ let out = "";
391
+ let i = 0;
392
+ let expectProgram = true;
393
+ while (i < cmd.length) {
394
+ const ch = cmd[i];
395
+ if (ch === "'" || ch === '"') {
396
+ let j = i + 1;
397
+ while (j < cmd.length && cmd[j] !== ch) {
398
+ if (ch === '"' && cmd[j] === "\\") j++;
399
+ j++;
400
+ }
401
+ out += `${GREEN}${cmd.slice(i, Math.min(j + 1, cmd.length))}${RESET}`;
402
+ i = j + 1;
403
+ continue;
404
+ }
405
+ if (ch === "#") {
406
+ out += `${DIM}${cmd.slice(i)}${RESET}`;
407
+ break;
408
+ }
409
+ const op = cmd.slice(i).match(/^(\|\||&&|\||;|>>|>|<)/);
410
+ if (op) {
411
+ out += `${MAGENTA}${op[1]}${RESET}`;
412
+ i += op[1].length;
413
+ expectProgram = true;
414
+ continue;
415
+ }
416
+ const word = cmd.slice(i).match(/^[^\s'"#|;&<>]+/);
417
+ if (word) {
418
+ const w = word[0];
419
+ if (w.startsWith("-")) out += `${DIM}${w}${RESET}`;
420
+ else if (expectProgram) {
421
+ out += `${CYAN2}${w}${RESET}`;
422
+ expectProgram = false;
423
+ } else out += w;
424
+ i += w.length;
425
+ continue;
426
+ }
427
+ out += ch;
428
+ i++;
429
+ }
430
+ return out;
431
+ }
432
+ var GRAD_STOPS = [
433
+ [255, 179, 92],
434
+ // amber
435
+ [255, 92, 135],
436
+ // coral
437
+ [139, 108, 255]
438
+ // violet
439
+ ];
440
+ var GRAD_256 = [215, 210, 204, 176, 141];
441
+ function truecolor() {
442
+ const ct = process.env.COLORTERM ?? "";
443
+ if (/truecolor|24bit/i.test(ct)) return true;
444
+ return /iterm|kitty|wezterm|ghostty|alacritty/i.test(process.env.TERM_PROGRAM ?? "");
445
+ }
446
+ function gradAt(t) {
447
+ const clamped = Math.max(0, Math.min(1, t));
448
+ const seg = clamped * (GRAD_STOPS.length - 1);
449
+ const i = Math.min(GRAD_STOPS.length - 2, Math.floor(seg));
450
+ const k = seg - i;
451
+ const [a, b] = [GRAD_STOPS[i], GRAD_STOPS[i + 1]];
452
+ return [
453
+ Math.round(a[0] + (b[0] - a[0]) * k),
454
+ Math.round(a[1] + (b[1] - a[1]) * k),
455
+ Math.round(a[2] + (b[2] - a[2]) * k)
456
+ ];
457
+ }
458
+ function gradientText(text, phase = 0) {
459
+ const chars = [...text];
460
+ const visible = chars.filter((ch) => ch.trim().length > 0).length;
461
+ if (!visible) return text;
462
+ const tc = truecolor();
463
+ let seen = 0;
464
+ let out = "";
465
+ for (const ch of chars) {
466
+ if (ch.trim().length === 0) {
467
+ out += ch;
468
+ continue;
469
+ }
470
+ const t = Math.min(1, phase + seen / Math.max(1, visible - 1) * (1 - phase * 0.4));
471
+ seen++;
472
+ if (tc) {
473
+ const [r, g, b] = gradAt(t);
474
+ out += `\x1B[38;2;${r};${g};${b}m${ch}`;
475
+ } else {
476
+ out += `\x1B[38;5;${GRAD_256[Math.min(GRAD_256.length - 1, Math.floor(t * GRAD_256.length))]}m${ch}`;
477
+ }
478
+ }
479
+ return out + "\x1B[0m";
480
+ }
481
+ function gradientArt(lines) {
482
+ const n = Math.max(1, lines.length - 1);
483
+ return lines.map((line, i) => gradientText(line, i / n * 0.55));
484
+ }
485
+
205
486
  // src/bridge.ts
206
- var PATH_ARG_TOOLS = /* @__PURE__ */ new Set(["read_file", "list_dir", "grep", "glob", "write_file", "edit_file", "delete"]);
487
+ var PATH_ARG_TOOLS = /* @__PURE__ */ new Set(["read_file", "grep", "glob", "write_file", "edit_file"]);
207
488
  var Bridge = class {
208
489
  constructor(api, threadId, host, perm, hooks) {
209
490
  this.api = api;
@@ -264,7 +545,10 @@ var Bridge = class {
264
545
  this.hooks.onConnection?.(wasReconnecting ? "reconnected" : "connected", 0);
265
546
  this.resolveConnected?.();
266
547
  });
267
- ws.addEventListener("message", (ev) => this.onMessage(String(ev.data)));
548
+ ws.addEventListener("message", (ev) => {
549
+ if (this.ws === ws) this.heartbeat?.markAlive();
550
+ this.onMessage(String(ev.data));
551
+ });
268
552
  ws.addEventListener("error", () => this.handleDrop(ws));
269
553
  ws.addEventListener("close", () => this.handleDrop(ws));
270
554
  }
@@ -287,16 +571,12 @@ var Bridge = class {
287
571
  }
288
572
  startHeartbeat(ws) {
289
573
  this.stopHeartbeat();
290
- this.heartbeat = setInterval(() => {
291
- try {
292
- if (ws.readyState === WebSocket.OPEN) ws.send("ping");
293
- } catch {
294
- }
295
- }, 5e3);
574
+ this.heartbeat = new Heartbeat(ws, () => this.handleDrop(ws));
575
+ this.heartbeat.start();
296
576
  }
297
577
  stopHeartbeat() {
298
578
  if (this.heartbeat) {
299
- clearInterval(this.heartbeat);
579
+ this.heartbeat.stop();
300
580
  this.heartbeat = null;
301
581
  }
302
582
  }
@@ -361,10 +641,16 @@ var Bridge = class {
361
641
  return;
362
642
  }
363
643
  if (decision === "ask") {
364
- const choice = await this.hooks.requestApproval(req, summary, effectiveRisk);
644
+ const { choice, reason } = await this.hooks.requestApproval(req, summary, effectiveRisk);
365
645
  if (choice === "deny") {
366
- this.hooks.onActivity(`\u26D4 ${summary} \u2014 you declined`);
367
- this.respond(req, false, void 0, "The user declined to run this operation.");
646
+ const why = reason?.trim();
647
+ this.hooks.onActivity(`\u26D4 ${summary} \u2014 you declined${why ? `: ${why}` : ""}`);
648
+ this.respond(
649
+ req,
650
+ false,
651
+ void 0,
652
+ why ? `The user declined to run this operation. Their reason: ${why}` : "The user declined to run this operation."
653
+ );
368
654
  return;
369
655
  }
370
656
  if (choice === "always") this.perm.alwaysAllow.add(permKey);
@@ -373,11 +659,23 @@ var Bridge = class {
373
659
  saveApprovals(this.api, this.threadId, this.perm);
374
660
  }
375
661
  }
376
- this.hooks.onStatus?.(summary);
377
- const result = await this.host.execute(req.tool, req.args);
378
- this.hooks.onStatus?.(null);
662
+ const callKey = req.toolCallId ?? req.id ?? `${req.tool}:${summary}`;
663
+ this.hooks.onStatus?.(callKey, summary);
664
+ let result;
665
+ try {
666
+ result = await this.host.execute(req.tool, req.args);
667
+ } finally {
668
+ this.hooks.onStatus?.(callKey, null);
669
+ }
379
670
  if (result.ok) {
380
- this.hooks.onActivity(`\u2713 ${summary}${detailSuffix(req.tool, result.result)}`);
671
+ const display = req.tool === "bash" ? `bash: ${highlightBash(String(req.args.command ?? "").slice(0, 200))}` : summary;
672
+ let detail;
673
+ if (req.tool === "edit_file") {
674
+ detail = diffLines(String(req.args.old_string ?? ""), String(req.args.new_string ?? ""));
675
+ } else if (req.tool === "write_file") {
676
+ detail = newFileLines(String(req.args.content ?? ""));
677
+ }
678
+ this.hooks.onActivity(`\u2713 ${display}${detailSuffix(req.tool, result.result)}`, detail);
381
679
  this.respond(req, true, result.result ?? "");
382
680
  } else {
383
681
  this.hooks.onActivity(`\u2717 ${summary} \u2014 ${result.error}`);
@@ -406,8 +704,6 @@ function describe(req) {
406
704
  }
407
705
  case "read_file":
408
706
  return `read ${a.path}`;
409
- case "list_dir":
410
- return `list ${a.path || "."}`;
411
707
  case "grep":
412
708
  return `grep "${a.pattern}"${a.glob ? ` in ${a.glob}` : ""}`;
413
709
  case "glob":
@@ -416,8 +712,10 @@ function describe(req) {
416
712
  return `write ${a.path}`;
417
713
  case "edit_file":
418
714
  return `edit ${a.path}`;
419
- case "delete":
420
- return `delete ${a.path}`;
715
+ case "save_to_disk":
716
+ return `save ${a.source_path} \u2192 ${a.dest_path}`;
717
+ case "run_skill_script":
718
+ return `skill ${a.skill}: run ${a.entry}`;
421
719
  case "bash":
422
720
  return `bash: ${String(a.command).slice(0, 80)}`;
423
721
  default:
@@ -426,7 +724,7 @@ function describe(req) {
426
724
  }
427
725
  function detailSuffix(tool, result) {
428
726
  if (!result) return "";
429
- if (tool === "write_file" || tool === "edit_file" || tool === "delete") return "";
727
+ if (tool === "write_file" || tool === "edit_file") return "";
430
728
  if (tool === "bash") {
431
729
  const m = result.match(/\[exit code (\d+)\]\s*$/);
432
730
  return m ? ` (exit ${m[1]})` : "";
@@ -434,7 +732,7 @@ function detailSuffix(tool, result) {
434
732
  const lines = result.split("\n").length;
435
733
  return ` (${lines} line${lines === 1 ? "" : "s"})`;
436
734
  }
437
- var LOG_DIR = path3.join(os4.homedir(), ".standardagents", "process-logs");
735
+ var LOG_DIR = path3.join(os6.homedir(), ".standardagents", "process-logs");
438
736
  var KEY2 = "bg_processes";
439
737
  function isAlive(pid) {
440
738
  try {
@@ -510,11 +808,12 @@ var ProcessRegistry = class {
510
808
  }
511
809
  }
512
810
  };
513
- var DIR = path3.join(os4.homedir(), ".standardagents");
514
- var FILE = path3.join(DIR, "mcp.json");
811
+ function configFile() {
812
+ return process.env.STANDARDAGENTS_MCP_CONFIG || path3.join(os6.homedir(), ".standardagents", "mcp.json");
813
+ }
515
814
  function loadMcpConfig() {
516
815
  try {
517
- const raw = fs2.readFileSync(FILE, "utf8");
816
+ const raw = fs4.readFileSync(configFile(), "utf8");
518
817
  const parsed = JSON.parse(raw);
519
818
  if (!parsed.servers || typeof parsed.servers !== "object") parsed.servers = {};
520
819
  return parsed;
@@ -544,8 +843,9 @@ function setMcpServerEnabled(name, enabled) {
544
843
  write(cfg);
545
844
  }
546
845
  function write(cfg) {
547
- fs2.mkdirSync(DIR, { recursive: true });
548
- fs2.writeFileSync(FILE, JSON.stringify(cfg, null, 2), { mode: 384 });
846
+ const file = configFile();
847
+ fs4.mkdirSync(path3.dirname(file), { recursive: true });
848
+ fs4.writeFileSync(file, JSON.stringify(cfg, null, 2), { mode: 384 });
549
849
  }
550
850
  function parseServerSpec(spec) {
551
851
  const trimmed = spec.trim();
@@ -609,13 +909,14 @@ async function readLogTail(logPath, n) {
609
909
  }
610
910
  }
611
911
  var HostTools = class {
612
- constructor(projectDir, registry, threadId, machine, mcp, onMcpCatalogChange) {
912
+ constructor(projectDir, registry, threadId, machine, mcp, onMcpCatalogChange, api) {
613
913
  this.projectDir = projectDir;
614
914
  this.registry = registry;
615
915
  this.threadId = threadId;
616
916
  this.machine = machine;
617
917
  this.mcp = mcp;
618
918
  this.onMcpCatalogChange = onMcpCatalogChange;
919
+ this.api = api;
619
920
  }
620
921
  projectDir;
621
922
  registry;
@@ -623,6 +924,7 @@ var HostTools = class {
623
924
  machine;
624
925
  mcp;
625
926
  onMcpCatalogChange;
927
+ api;
626
928
  /** Resolve a user/model-supplied path against the project directory. */
627
929
  resolve(p) {
628
930
  if (!p || p === ".") return this.projectDir;
@@ -639,8 +941,6 @@ var HostTools = class {
639
941
  switch (tool) {
640
942
  case "read_file":
641
943
  return await this.readFile(args);
642
- case "list_dir":
643
- return await this.listDir(args);
644
944
  case "grep":
645
945
  return await this.grep(args);
646
946
  case "glob":
@@ -651,8 +951,10 @@ var HostTools = class {
651
951
  return await this.editFile(args);
652
952
  case "bash":
653
953
  return await this.bash(args);
654
- case "delete":
655
- return await this.deletePath(args);
954
+ case "save_to_disk":
955
+ return await this.saveToDisk(args);
956
+ case "run_skill_script":
957
+ return await this.runSkillScript(args);
656
958
  case "background_process": {
657
959
  const action = String(args.action || "list");
658
960
  if (action === "start") return await this.runBackground(args);
@@ -695,15 +997,6 @@ var HostTools = class {
695
997
  const numbered = slice.map((l, i) => `${offset + i} ${l}`).join("\n");
696
998
  return { ok: true, result: numbered || "(empty file)" };
697
999
  }
698
- async listDir(args) {
699
- const dir = this.resolve(args.path ? String(args.path) : void 0);
700
- const entries = await fsp.readdir(dir, { withFileTypes: true });
701
- const sorted = entries.filter((e) => e.name !== ".git" && e.name !== "node_modules").sort((a, b) => Number(b.isDirectory()) - Number(a.isDirectory()) || a.name.localeCompare(b.name));
702
- const lines = sorted.map((e) => e.isDirectory() ? `${e.name}/` : e.name);
703
- const header = `${path3.relative(this.projectDir, dir) || "."} (${lines.length} entries)`;
704
- return { ok: true, result: `${header}
705
- ${lines.join("\n")}` };
706
- }
707
1000
  async grep(args) {
708
1001
  const pattern = String(args.pattern || "");
709
1002
  if (!pattern) return { ok: false, error: "pattern is required" };
@@ -735,7 +1028,7 @@ ${lines.join("\n")}` };
735
1028
  const file = this.resolve(String(args.path || ""));
736
1029
  const content = String(args.content ?? "");
737
1030
  await fsp.mkdir(path3.dirname(file), { recursive: true });
738
- const existed = fs2.existsSync(file);
1031
+ const existed = fs4.existsSync(file);
739
1032
  await fsp.writeFile(file, content, "utf8");
740
1033
  return {
741
1034
  ok: true,
@@ -760,16 +1053,125 @@ ${lines.join("\n")}` };
760
1053
  await fsp.writeFile(file, updated, "utf8");
761
1054
  return { ok: true, result: `Edited ${path3.relative(this.projectDir, file)} (${count} replacement${count === 1 ? "" : "s"})` };
762
1055
  }
763
- async deletePath(args) {
764
- const target = this.resolve(String(args.path || ""));
765
- const recursive = args.recursive === true;
766
- const stat = await fsp.stat(target).catch(() => null);
767
- if (!stat) return { ok: false, error: `Path not found: ${args.path}` };
768
- if (stat.isDirectory() && !recursive) {
769
- return { ok: false, error: `${args.path} is a directory; set recursive to delete it.` };
1056
+ /**
1057
+ * Copy a file from the THREAD filesystem (e.g. a generated /attachments/*
1058
+ * asset) onto the project disk: download the bytes from the instance API and
1059
+ * write them at the destination. The bytes never pass through the model.
1060
+ */
1061
+ async saveToDisk(args) {
1062
+ const source = String(args.source_path || "");
1063
+ const destArg = String(args.dest_path || "");
1064
+ if (!source || !destArg) return { ok: false, error: "source_path and dest_path are required" };
1065
+ if (!this.api || !this.threadId) {
1066
+ return { ok: false, error: "No instance API available for thread file downloads." };
1067
+ }
1068
+ const bytes = await this.api.fetchFile(this.threadId, source);
1069
+ if (!bytes) return { ok: false, error: `Could not download ${source} from the thread filesystem.` };
1070
+ const dest = this.resolve(destArg);
1071
+ await fsp.mkdir(path3.dirname(dest), { recursive: true });
1072
+ const existed = fs4.existsSync(dest);
1073
+ await fsp.writeFile(dest, bytes);
1074
+ return {
1075
+ ok: true,
1076
+ result: `${existed ? "Overwrote" : "Saved"} ${path3.relative(this.projectDir, dest)} (${bytes.length} bytes) from ${source}`
1077
+ };
1078
+ }
1079
+ /**
1080
+ * Execute a SKILL script on the host. Skills live in the instance (cloud is
1081
+ * the source of truth); the server forwards the skill's files with the call
1082
+ * and we materialize them into a content-addressed temp dir — nothing about
1083
+ * a skill is stored on this machine beyond an ephemeral cache. The script
1084
+ * runs with cwd = the PROJECT (so it can operate on the user's files) and
1085
+ * SKILL_DIR pointing at the materialized folder (so it can read its own
1086
+ * references/assets).
1087
+ */
1088
+ async runSkillScript(args) {
1089
+ const skill = String(args.skill || "skill");
1090
+ const entry = String(args.entry || "");
1091
+ if (!entry) return { ok: false, error: "entry (the script path within the skill) is required" };
1092
+ let files;
1093
+ try {
1094
+ const parsed = JSON.parse(String(args.files_json || "[]"));
1095
+ if (!Array.isArray(parsed)) throw new Error("not an array");
1096
+ files = parsed.map((f) => ({ path: String(f.path), content: String(f.content ?? "") }));
1097
+ } catch {
1098
+ return { ok: false, error: "files_json must be a JSON array of {path, content}" };
1099
+ }
1100
+ if (!files.some((f) => f.path === entry)) {
1101
+ return { ok: false, error: `entry "${entry}" is not among the provided skill files` };
1102
+ }
1103
+ let scriptArgs = [];
1104
+ if (args.args_json) {
1105
+ try {
1106
+ const parsed = JSON.parse(String(args.args_json));
1107
+ if (!Array.isArray(parsed)) throw new Error("not an array");
1108
+ scriptArgs = parsed.map(String);
1109
+ } catch {
1110
+ return { ok: false, error: "args_json must be a JSON array of strings" };
1111
+ }
1112
+ }
1113
+ const hash = crypto.createHash("sha256").update(JSON.stringify(files)).digest("hex").slice(0, 12);
1114
+ const skillDir = path3.join(os6.tmpdir(), "standardcode-skills", `${skill}-${hash}`);
1115
+ for (const f of files) {
1116
+ const dest = path3.resolve(skillDir, f.path);
1117
+ if (path3.relative(skillDir, dest).startsWith("..")) {
1118
+ return { ok: false, error: `Skill file escapes its directory: ${f.path}` };
1119
+ }
1120
+ await fsp.mkdir(path3.dirname(dest), { recursive: true });
1121
+ await fsp.writeFile(dest, f.content, "utf8");
1122
+ }
1123
+ const entryPath = path3.resolve(skillDir, entry);
1124
+ const entryContent = files.find((f) => f.path === entry).content;
1125
+ let cmd;
1126
+ let argv;
1127
+ if (entryContent.startsWith("#!")) {
1128
+ await fsp.chmod(entryPath, 493);
1129
+ cmd = entryPath;
1130
+ argv = scriptArgs;
1131
+ } else {
1132
+ const ext = path3.extname(entry).toLowerCase();
1133
+ const interp = {
1134
+ ".py": ["python3"],
1135
+ ".sh": ["bash"],
1136
+ ".js": ["node"],
1137
+ ".mjs": ["node"],
1138
+ ".ts": ["npx", "tsx"]
1139
+ };
1140
+ const found = interp[ext];
1141
+ if (!found) return { ok: false, error: `No interpreter for "${ext}" \u2014 add a shebang line to the script.` };
1142
+ cmd = found[0];
1143
+ argv = [...found.slice(1), entryPath, ...scriptArgs];
770
1144
  }
771
- await fsp.rm(target, { recursive, force: false });
772
- return { ok: true, result: `Deleted ${path3.relative(this.projectDir, target) || target}` };
1145
+ const timeoutMs = typeof args.timeout_ms === "number" ? Math.min(args.timeout_ms, 3e5) : 12e4;
1146
+ return await new Promise((resolvePromise) => {
1147
+ const child = spawn(cmd, argv, {
1148
+ cwd: this.projectDir,
1149
+ env: { ...process.env, SKILL_DIR: skillDir },
1150
+ stdio: ["ignore", "pipe", "pipe"]
1151
+ });
1152
+ let out = "";
1153
+ const cap = (chunk) => {
1154
+ if (out.length < 2e5) out += chunk.toString("utf8");
1155
+ };
1156
+ child.stdout.on("data", cap);
1157
+ child.stderr.on("data", cap);
1158
+ const timer = setTimeout(() => {
1159
+ child.kill("SIGKILL");
1160
+ resolvePromise({ ok: false, error: `Skill script timed out after ${timeoutMs}ms.
1161
+ ${out.slice(-4e3)}` });
1162
+ }, timeoutMs);
1163
+ child.on("error", (err) => {
1164
+ clearTimeout(timer);
1165
+ resolvePromise({ ok: false, error: `Could not launch ${cmd}: ${err.message}` });
1166
+ });
1167
+ child.on("exit", (code) => {
1168
+ clearTimeout(timer);
1169
+ const body = out.trim() || "(no output)";
1170
+ if (code === 0) resolvePromise({ ok: true, result: body });
1171
+ else resolvePromise({ ok: false, error: `Script exited with code ${code}:
1172
+ ${body.slice(-6e3)}` });
1173
+ });
1174
+ });
773
1175
  }
774
1176
  async bash(args) {
775
1177
  const command = String(args.command || "");
@@ -797,12 +1199,12 @@ ${truncated}`
797
1199
  const command = String(args.command || "");
798
1200
  if (!command.trim()) return { ok: false, error: "command is required" };
799
1201
  const cwd = args.cwd ? this.resolve(String(args.cwd)) : this.projectDir;
800
- const id = crypto2.randomUUID().slice(0, 8);
1202
+ const id = crypto.randomUUID().slice(0, 8);
801
1203
  const logPath = path3.join(LOG_DIR, `${id}.log`);
802
1204
  let out;
803
1205
  try {
804
1206
  await fsp.mkdir(LOG_DIR, { recursive: true });
805
- out = fs2.openSync(logPath, "a");
1207
+ out = fs4.openSync(logPath, "a");
806
1208
  } catch (err) {
807
1209
  return { ok: false, error: `Could not open log file: ${err instanceof Error ? err.message : String(err)}` };
808
1210
  }
@@ -810,10 +1212,10 @@ ${truncated}`
810
1212
  try {
811
1213
  child = spawn("bash", ["-lc", command], { cwd, detached: true, stdio: ["ignore", out, out] });
812
1214
  } catch (err) {
813
- fs2.closeSync(out);
1215
+ fs4.closeSync(out);
814
1216
  return { ok: false, error: `Failed to start: ${err instanceof Error ? err.message : String(err)}` };
815
1217
  }
816
- fs2.closeSync(out);
1218
+ fs4.closeSync(out);
817
1219
  const pid = child.pid;
818
1220
  if (!pid) return { ok: false, error: "Process failed to start (no pid)." };
819
1221
  let earlyExit;
@@ -973,6 +1375,39 @@ ${tail}` : " No output was captured.")
973
1375
  }
974
1376
  return { ok: false, error: `Unknown action: ${action}` };
975
1377
  }
1378
+ /**
1379
+ * Terminate every still-running background process this machine started, so
1380
+ * detached children don't outlive the CLI when the session quits. Sends
1381
+ * SIGTERM to each process group, waits a brief grace, then SIGKILL any
1382
+ * straggler, and records them stopped. Best effort and bounded so quitting
1383
+ * stays snappy. Returns the number of running processes it signaled.
1384
+ */
1385
+ async stopAllLocalProcesses() {
1386
+ if (!this.registry) return 0;
1387
+ let running = [];
1388
+ try {
1389
+ const procs = await this.registry.list();
1390
+ running = procs.filter((p) => p.status === "running" && p.machine === (this.machine ?? ""));
1391
+ } catch {
1392
+ return 0;
1393
+ }
1394
+ if (!running.length) return 0;
1395
+ const signal = (pid, sig) => {
1396
+ try {
1397
+ process.kill(-pid, sig);
1398
+ } catch {
1399
+ try {
1400
+ process.kill(pid, sig);
1401
+ } catch {
1402
+ }
1403
+ }
1404
+ };
1405
+ for (const proc of running) signal(proc.pid, "SIGTERM");
1406
+ await new Promise((r) => setTimeout(r, 300));
1407
+ for (const proc of running) signal(proc.pid, "SIGKILL");
1408
+ await Promise.allSettled(running.map((proc) => this.registry.markStopped(proc.id)));
1409
+ return running.length;
1410
+ }
976
1411
  run(cmd, args, cwd, timeoutMs) {
977
1412
  return new Promise((resolve) => {
978
1413
  let stdout = "";
@@ -1069,6 +1504,7 @@ var MessageStream = class {
1069
1504
  hooks;
1070
1505
  ws = null;
1071
1506
  closed = false;
1507
+ heartbeat = null;
1072
1508
  reconnectAttempt = 0;
1073
1509
  reconnectTimer = null;
1074
1510
  resolveConnected = null;
@@ -1093,7 +1529,7 @@ var MessageStream = class {
1093
1529
  }
1094
1530
  openSocket() {
1095
1531
  if (this.closed) return;
1096
- const url = `${this.api.wsEndpoint}/api/threads/${this.threadId}/stream?token=${encodeURIComponent(this.api.bearer)}`;
1532
+ const url = `${this.api.wsEndpoint}/api/threads/${this.threadId}/stream?token=${encodeURIComponent(this.api.bearer)}&reasoning=1`;
1097
1533
  let ws;
1098
1534
  try {
1099
1535
  ws = new WebSocket(url);
@@ -1104,17 +1540,33 @@ var MessageStream = class {
1104
1540
  this.ws = ws;
1105
1541
  ws.addEventListener("open", () => {
1106
1542
  this.reconnectAttempt = 0;
1543
+ this.startHeartbeat(ws);
1107
1544
  this.resolveConnected?.();
1108
1545
  });
1109
- ws.addEventListener("message", (ev) => this.onMessage(String(ev.data)));
1546
+ ws.addEventListener("message", (ev) => {
1547
+ if (this.ws === ws) this.heartbeat?.markAlive();
1548
+ this.onMessage(String(ev.data));
1549
+ });
1110
1550
  ws.addEventListener("error", () => this.handleDrop(ws));
1111
1551
  ws.addEventListener("close", () => this.handleDrop(ws));
1112
1552
  }
1113
1553
  handleDrop(ws) {
1114
1554
  if (this.ws !== ws) return;
1115
1555
  this.ws = null;
1556
+ this.stopHeartbeat();
1116
1557
  this.scheduleReconnect();
1117
1558
  }
1559
+ startHeartbeat(ws) {
1560
+ this.stopHeartbeat();
1561
+ this.heartbeat = new Heartbeat(ws, () => this.handleDrop(ws));
1562
+ this.heartbeat.start();
1563
+ }
1564
+ stopHeartbeat() {
1565
+ if (this.heartbeat) {
1566
+ this.heartbeat.stop();
1567
+ this.heartbeat = null;
1568
+ }
1569
+ }
1118
1570
  scheduleReconnect() {
1119
1571
  if (this.closed || this.reconnectTimer) return;
1120
1572
  this.reconnectAttempt++;
@@ -1127,6 +1579,7 @@ var MessageStream = class {
1127
1579
  }
1128
1580
  close() {
1129
1581
  this.closed = true;
1582
+ this.stopHeartbeat();
1130
1583
  if (this.reconnectTimer) {
1131
1584
  clearTimeout(this.reconnectTimer);
1132
1585
  this.reconnectTimer = null;
@@ -1145,7 +1598,11 @@ var MessageStream = class {
1145
1598
  return;
1146
1599
  }
1147
1600
  if (msg.type === "message_chunk" && (msg.depth ?? 0) === 0) {
1148
- if (typeof msg.chunk === "string") this.hooks.onChunk(msg.chunk);
1601
+ if (typeof msg.chunk === "string") this.hooks.onChunk(msg.chunk, msg.message_id);
1602
+ return;
1603
+ }
1604
+ if (msg.type === "reasoning_chunk" && (msg.depth ?? 0) === 0) {
1605
+ if (typeof msg.chunk === "string") this.hooks.onReasoningChunk?.(msg.chunk, msg.message_id);
1149
1606
  return;
1150
1607
  }
1151
1608
  if (msg.type === "message_data" && (msg.depth ?? 0) === 0) {
@@ -1172,6 +1629,7 @@ var SystemEvents = class {
1172
1629
  hooks;
1173
1630
  ws = null;
1174
1631
  closed = false;
1632
+ heartbeat = null;
1175
1633
  reconnectAttempt = 0;
1176
1634
  reconnectTimer = null;
1177
1635
  connect() {
@@ -1190,11 +1648,27 @@ var SystemEvents = class {
1190
1648
  this.ws = ws;
1191
1649
  ws.addEventListener("open", () => {
1192
1650
  this.reconnectAttempt = 0;
1651
+ this.startHeartbeat(ws);
1652
+ this.hooks.onOpen?.();
1653
+ });
1654
+ ws.addEventListener("message", (ev) => {
1655
+ if (this.ws === ws) this.heartbeat?.markAlive();
1656
+ this.onMessage(String(ev.data));
1193
1657
  });
1194
- ws.addEventListener("message", (ev) => this.onMessage(String(ev.data)));
1195
1658
  ws.addEventListener("error", () => this.handleDrop(ws));
1196
1659
  ws.addEventListener("close", () => this.handleDrop(ws));
1197
1660
  }
1661
+ startHeartbeat(ws) {
1662
+ this.stopHeartbeat();
1663
+ this.heartbeat = new Heartbeat(ws, () => this.handleDrop(ws));
1664
+ this.heartbeat.start();
1665
+ }
1666
+ stopHeartbeat() {
1667
+ if (this.heartbeat) {
1668
+ this.heartbeat.stop();
1669
+ this.heartbeat = null;
1670
+ }
1671
+ }
1198
1672
  onMessage(raw) {
1199
1673
  let msg;
1200
1674
  try {
@@ -1211,6 +1685,7 @@ var SystemEvents = class {
1211
1685
  handleDrop(ws) {
1212
1686
  if (this.ws !== ws) return;
1213
1687
  this.ws = null;
1688
+ this.stopHeartbeat();
1214
1689
  this.scheduleReconnect();
1215
1690
  }
1216
1691
  scheduleReconnect() {
@@ -1225,6 +1700,7 @@ var SystemEvents = class {
1225
1700
  }
1226
1701
  close() {
1227
1702
  this.closed = true;
1703
+ this.stopHeartbeat();
1228
1704
  if (this.reconnectTimer) {
1229
1705
  clearTimeout(this.reconnectTimer);
1230
1706
  this.reconnectTimer = null;
@@ -1245,8 +1721,98 @@ var LEVEL_DETAIL = {
1245
1721
  function levelLabel(level) {
1246
1722
  return `auto-accept level ${level} (${LEVEL_DETAIL[level]})`;
1247
1723
  }
1724
+ var FILE_MIMES = {
1725
+ ".png": "image/png",
1726
+ ".jpg": "image/jpeg",
1727
+ ".jpeg": "image/jpeg",
1728
+ ".gif": "image/gif",
1729
+ ".webp": "image/webp"
1730
+ };
1731
+ var MAX_IMAGE_BYTES = 8 * 1024 * 1024;
1732
+ function run(cmd, args, maxBuffer = MAX_IMAGE_BYTES * 2) {
1733
+ return new Promise((resolve) => {
1734
+ execFile(cmd, args, { encoding: "buffer", maxBuffer }, (err, stdout) => {
1735
+ resolve({ ok: !err, stdout: stdout ?? Buffer.alloc(0) });
1736
+ });
1737
+ });
1738
+ }
1739
+ function fromFile(filePath) {
1740
+ const mime = FILE_MIMES[path3.extname(filePath).toLowerCase()];
1741
+ if (!mime) return null;
1742
+ try {
1743
+ const stat = fs4.statSync(filePath);
1744
+ if (!stat.isFile() || stat.size === 0 || stat.size > MAX_IMAGE_BYTES) return null;
1745
+ return { data: fs4.readFileSync(filePath).toString("base64"), mime };
1746
+ } catch {
1747
+ return null;
1748
+ }
1749
+ }
1750
+ async function readDarwin() {
1751
+ const tmp = path3.join(os6.tmpdir(), `sc-clip-${process.pid}-${Date.now()}.png`);
1752
+ const script = [
1753
+ `set d to the clipboard as \xABclass PNGf\xBB`,
1754
+ `set f to open for access POSIX file "${tmp}" with write permission`,
1755
+ `set eof f to 0`,
1756
+ `write d to f`,
1757
+ `close access f`
1758
+ ].join("\n");
1759
+ const png = await run("osascript", ["-e", script]);
1760
+ if (png.ok) {
1761
+ const img = fromFile(tmp);
1762
+ try {
1763
+ fs4.unlinkSync(tmp);
1764
+ } catch {
1765
+ }
1766
+ if (img) return img;
1767
+ }
1768
+ const furl = await run("osascript", ["-e", "POSIX path of (the clipboard as \xABclass furl\xBB)"]);
1769
+ if (furl.ok) {
1770
+ const p = furl.stdout.toString("utf8").trim();
1771
+ if (p) return fromFile(p);
1772
+ }
1773
+ return null;
1774
+ }
1775
+ async function readLinux() {
1776
+ for (const [cmd, args] of [
1777
+ ["wl-paste", ["--type", "image/png"]],
1778
+ ["xclip", ["-selection", "clipboard", "-t", "image/png", "-o"]]
1779
+ ]) {
1780
+ const res = await run(cmd, args);
1781
+ if (res.ok && res.stdout.length > 8 && res.stdout.length <= MAX_IMAGE_BYTES && res.stdout[0] === 137 && res.stdout[1] === 80) {
1782
+ return { data: res.stdout.toString("base64"), mime: "image/png" };
1783
+ }
1784
+ }
1785
+ return null;
1786
+ }
1787
+ async function readWindows() {
1788
+ const tmp = path3.join(os6.tmpdir(), `sc-clip-${process.pid}-${Date.now()}.png`);
1789
+ const ps = [
1790
+ "Add-Type -AssemblyName System.Windows.Forms;",
1791
+ "$img = [System.Windows.Forms.Clipboard]::GetImage();",
1792
+ `if ($img -ne $null) { $img.Save('${tmp.replace(/'/g, "''")}', [System.Drawing.Imaging.ImageFormat]::Png) }`
1793
+ ].join(" ");
1794
+ await run("powershell", ["-NoProfile", "-STA", "-Command", ps]);
1795
+ const img = fromFile(tmp);
1796
+ try {
1797
+ fs4.unlinkSync(tmp);
1798
+ } catch {
1799
+ }
1800
+ return img;
1801
+ }
1802
+ async function readClipboardImage() {
1803
+ try {
1804
+ if (process.platform === "darwin") return await readDarwin();
1805
+ if (process.platform === "win32") return await readWindows();
1806
+ return await readLinux();
1807
+ } catch {
1808
+ return null;
1809
+ }
1810
+ }
1248
1811
 
1249
1812
  // src/tui.ts
1813
+ function imagePlaceholder(seq) {
1814
+ return `[#Image ${seq}]`;
1815
+ }
1250
1816
  var C = {
1251
1817
  reset: "\x1B[0m",
1252
1818
  dim: "\x1B[2m",
@@ -1261,7 +1827,23 @@ var C = {
1261
1827
  teal: "\x1B[38;5;37m"
1262
1828
  };
1263
1829
  var FRAMES = ["\u28F7", "\u28EF", "\u28DF", "\u287F", "\u28BF", "\u28FB", "\u28FD", "\u28FE"];
1264
- var Tui = class {
1830
+ var SUBAGENT_COLORS = [
1831
+ "\x1B[35m",
1832
+ // magenta
1833
+ "\x1B[38;5;39m",
1834
+ // azure
1835
+ "\x1B[38;5;75m",
1836
+ // blue
1837
+ "\x1B[32m",
1838
+ // green
1839
+ "\x1B[38;5;141m",
1840
+ // violet
1841
+ "\x1B[38;5;177m"
1842
+ // orchid
1843
+ ];
1844
+ var COMPACTION_COLOR = "\x1B[38;5;208m";
1845
+ var COMPACTION_AGENT = "compaction_agent";
1846
+ var Tui = class _Tui {
1265
1847
  constructor(level = 1) {
1266
1848
  this.level = level;
1267
1849
  readline.emitKeypressEvents(process.stdin);
@@ -1270,6 +1852,7 @@ var Tui = class {
1270
1852
  process.stdin.resume();
1271
1853
  process.stdout.write("\x1B[?2004h");
1272
1854
  process.on("exit", () => process.stdout.write("\x1B[?2004l\x1B[?25h"));
1855
+ process.stdout.on("resize", () => this.renderBottom());
1273
1856
  }
1274
1857
  level;
1275
1858
  // input + indicators
@@ -1284,13 +1867,31 @@ var Tui = class {
1284
1867
  bgCount = 0;
1285
1868
  queuedCount = 0;
1286
1869
  subagents = [];
1287
- // labels of subagents currently working (one line each)
1870
+ // active subagents (one line each)
1871
+ subagentColorByID = /* @__PURE__ */ new Map();
1872
+ // subagent id → SUBAGENT_COLORS index
1288
1873
  tokensIn = 0;
1289
1874
  // cumulative input tokens
1290
1875
  tokensOut = 0;
1291
1876
  // cumulative output tokens (includes the in-progress live count)
1292
1877
  contextPct = null;
1293
1878
  // % of the model context window currently used
1879
+ // Live streaming preview, shown just above the status line while a turn runs:
1880
+ // the model's internal reasoning (dim italic) until the answer starts, then the
1881
+ // answer text (plain). Bounded to a tail; cleared when the message commits.
1882
+ streamThinking = "";
1883
+ streamResponse = "";
1884
+ streamMessageId = null;
1885
+ // the message currently previewing
1886
+ streamRedrawTimer = null;
1887
+ streamIdleTimer = null;
1888
+ // wipes a stale preview
1889
+ // Live goal checklist, fixed below the status bar. Driven by the goal_updated
1890
+ // thread event (+ an initial fetch). null = nothing to show.
1891
+ goal = null;
1892
+ // Set true the moment every step of a goal is done: the goal area is cleared
1893
+ // and the status line shows "Goal complete." until the next turn starts.
1894
+ goalComplete = false;
1294
1895
  // Inline slash-command palette: when the input starts with "/", the filtered
1295
1896
  // command list renders above the prompt and arrows/enter/tab drive it.
1296
1897
  commands = [];
@@ -1305,6 +1906,14 @@ var Tui = class {
1305
1906
  connected = true;
1306
1907
  bottomDrawn = false;
1307
1908
  started = false;
1909
+ // Resize bookkeeping: the width the region was last drawn at, and the visible
1910
+ // width of every HUD row written above the input. When the terminal is
1911
+ // resized, previously drawn rows re-wrap (a full-width ruler becomes 2+ rows
1912
+ // when narrowed), so the move-up count recorded at draw time is wrong — these
1913
+ // let moveToRegionTop recompute the region height under the NEW wrap instead
1914
+ // of leaving stale rulers behind.
1915
+ lastDrawnCols = 0;
1916
+ drawnHudWidths = [];
1308
1917
  // takeover (approval / menu) state
1309
1918
  takeoverHandler = null;
1310
1919
  bufferedPrints = [];
@@ -1315,13 +1924,28 @@ var Tui = class {
1315
1924
  // bracketed-paste state
1316
1925
  pasting = false;
1317
1926
  pasteTimer = null;
1927
+ // Images pasted into the CURRENT input (Ctrl+V). Each got a `[#Image N]`
1928
+ // placeholder at the caret; on submit only images whose placeholder is still
1929
+ // present in the text are handed to onSubmit. Cleared with the input.
1930
+ pendingImages = [];
1931
+ imagePasteBusy = false;
1932
+ // one clipboard read at a time
1933
+ // Sent-message history for ↑/↓ recall (oldest → newest). `historyIdx` is the
1934
+ // entry currently shown (null = not browsing); the in-progress draft is
1935
+ // stashed so cycling past the newest entry restores it. Any edit exits
1936
+ // browsing and keeps the recalled text as the new draft.
1937
+ history = [];
1938
+ historyIdx = null;
1939
+ historyDraft = "";
1940
+ historyDraftImages = [];
1318
1941
  // event hooks (wired by index.ts)
1319
1942
  onSubmit = () => {
1320
1943
  };
1321
1944
  onInterrupt = () => {
1322
1945
  };
1323
- onUpArrow = () => {
1324
- };
1946
+ /** Up on the top row: return true to consume it (e.g. pull a queued message)
1947
+ * before history recall gets a chance. */
1948
+ onUpArrow = () => false;
1325
1949
  onQuit = () => process.exit(0);
1326
1950
  levelListeners = [];
1327
1951
  get colors() {
@@ -1356,6 +1980,8 @@ var Tui = class {
1356
1980
  end() {
1357
1981
  if (this.quitTimer) clearTimeout(this.quitTimer);
1358
1982
  this.quitTimer = null;
1983
+ if (this.streamIdleTimer) clearTimeout(this.streamIdleTimer);
1984
+ this.streamIdleTimer = null;
1359
1985
  this.clearBottom();
1360
1986
  process.stdout.write("\x1B[?2004l\x1B[?25h");
1361
1987
  }
@@ -1368,6 +1994,11 @@ var Tui = class {
1368
1994
  }
1369
1995
  // ─── key dispatch ──────────────────────────────────────────────────────────
1370
1996
  dispatch(str, key) {
1997
+ const seq = key && key.sequence || str || "";
1998
+ if (seq === "\n" || seq === "\x1B[13;2u" || seq === "\x1B[27;2;13~") {
1999
+ this.insertAtCursor("\n");
2000
+ return;
2001
+ }
1371
2002
  if (key && key.ctrl && key.name === "c") {
1372
2003
  this.requestQuit();
1373
2004
  return;
@@ -1376,7 +2007,6 @@ var Tui = class {
1376
2007
  this.cycleLevel();
1377
2008
  return;
1378
2009
  }
1379
- const seq = key && key.sequence || str || "";
1380
2010
  if (!this.pasting && seq.includes("\x1B[200~")) {
1381
2011
  this.pasting = true;
1382
2012
  this.armPasteSafety();
@@ -1420,6 +2050,10 @@ var Tui = class {
1420
2050
  return;
1421
2051
  }
1422
2052
  if (key.name === "return" || key.name === "enter") {
2053
+ if (key.shift) {
2054
+ this.insertAtCursor("\n");
2055
+ return;
2056
+ }
1423
2057
  if (matches.length) this.runCommand(matches[cur]);
1424
2058
  return;
1425
2059
  }
@@ -1460,15 +2094,46 @@ var Tui = class {
1460
2094
  return;
1461
2095
  }
1462
2096
  if (key.name === "up") {
1463
- this.onUpArrow();
2097
+ const { caretRow } = this.inputLayout();
2098
+ if (caretRow > 0) {
2099
+ this.moveCaretVertical(-1);
2100
+ return;
2101
+ }
2102
+ if (this.onUpArrow()) return;
2103
+ this.historyPrev();
2104
+ return;
2105
+ }
2106
+ if (key.name === "down") {
2107
+ const { caretRow, rowCount } = this.inputLayout();
2108
+ if (caretRow < rowCount - 1) {
2109
+ this.moveCaretVertical(1);
2110
+ return;
2111
+ }
2112
+ this.historyNext();
1464
2113
  return;
1465
2114
  }
1466
2115
  if (key.name === "return" || key.name === "enter") {
2116
+ if (key.shift) {
2117
+ this.insertAtCursor("\n");
2118
+ return;
2119
+ }
1467
2120
  const text = this.inputBuffer;
2121
+ const images = this.pendingImages.filter((img) => text.includes(imagePlaceholder(img.seq)));
1468
2122
  this.inputBuffer = "";
1469
2123
  this.cursorPos = 0;
2124
+ this.pendingImages = [];
2125
+ this.historyIdx = null;
2126
+ this.historyDraft = "";
2127
+ this.historyDraftImages = [];
1470
2128
  this.renderBottom();
1471
- if (text.trim()) this.onSubmit(text.trim());
2129
+ if (text.trim()) {
2130
+ this.addHistoryEntry(text.trim());
2131
+ this.onSubmit(text.trim(), images);
2132
+ }
2133
+ return;
2134
+ }
2135
+ if (key.ctrl && key.name === "v") {
2136
+ void this.pasteClipboardImage();
1472
2137
  return;
1473
2138
  }
1474
2139
  if (key.name === "backspace") {
@@ -1476,6 +2141,7 @@ var Tui = class {
1476
2141
  this.inputBuffer = this.inputBuffer.slice(0, this.cursorPos - 1) + this.inputBuffer.slice(this.cursorPos);
1477
2142
  this.cursorPos--;
1478
2143
  this.slashIdx = 0;
2144
+ this.historyIdx = null;
1479
2145
  this.renderBottom();
1480
2146
  }
1481
2147
  return;
@@ -1484,6 +2150,7 @@ var Tui = class {
1484
2150
  if (this.cursorPos < this.inputBuffer.length) {
1485
2151
  this.inputBuffer = this.inputBuffer.slice(0, this.cursorPos) + this.inputBuffer.slice(this.cursorPos + 1);
1486
2152
  this.slashIdx = 0;
2153
+ this.historyIdx = null;
1487
2154
  this.renderBottom();
1488
2155
  }
1489
2156
  return;
@@ -1497,6 +2164,7 @@ var Tui = class {
1497
2164
  this.inputBuffer = this.inputBuffer.slice(0, this.cursorPos) + text + this.inputBuffer.slice(this.cursorPos);
1498
2165
  this.cursorPos += text.length;
1499
2166
  this.slashIdx = 0;
2167
+ this.historyIdx = null;
1500
2168
  this.renderBottom();
1501
2169
  }
1502
2170
  /** Insert a paste fragment at the caret; collapse newlines (single-line input). */
@@ -1506,6 +2174,7 @@ var Tui = class {
1506
2174
  if (content) {
1507
2175
  this.inputBuffer = this.inputBuffer.slice(0, this.cursorPos) + content + this.inputBuffer.slice(this.cursorPos);
1508
2176
  this.cursorPos += content.length;
2177
+ this.historyIdx = null;
1509
2178
  }
1510
2179
  if (end >= 0) {
1511
2180
  this.pasting = false;
@@ -1525,6 +2194,128 @@ var Tui = class {
1525
2194
  this.renderBottom();
1526
2195
  }, 2e3);
1527
2196
  }
2197
+ // ─── input layout + vertical caret movement ────────────────────────────────
2198
+ /**
2199
+ * The input's physical rows (same wrapping math as renderBottom: logical
2200
+ * lines split on "\n", line 0 led by the prompt prefix, each wrapping at the
2201
+ * terminal width) plus where the caret sits among them. Each row records the
2202
+ * buffer index of its first character, its character count, and the visual
2203
+ * column its first character renders at (only row 0 is offset, by the
2204
+ * prompt). Drives ↑/↓: row 0 is "the top line" (history recall territory),
2205
+ * anything below moves the caret instead.
2206
+ */
2207
+ inputLayout() {
2208
+ const cols2 = process.stdout.columns || 80;
2209
+ const pw = this.visibleWidth(this.promptPrefix());
2210
+ const lines = this.inputBuffer.split("\n");
2211
+ const rows = [];
2212
+ let offset = 0;
2213
+ for (let i = 0; i < lines.length; i++) {
2214
+ const lead = i === 0 ? pw : 0;
2215
+ const len = lines[i].length;
2216
+ const nRows = Math.max(1, Math.ceil((lead + len) / cols2));
2217
+ for (let j = 0; j < nRows; j++) {
2218
+ const charStart = Math.max(0, j * cols2 - lead);
2219
+ const charEnd = Math.min(len, (j + 1) * cols2 - lead);
2220
+ rows.push({ start: offset + charStart, len: Math.max(0, charEnd - charStart), colOffset: j === 0 ? lead : 0 });
2221
+ }
2222
+ offset += len + 1;
2223
+ }
2224
+ let pos = this.cursorPos;
2225
+ let caretLine = 0;
2226
+ while (caretLine < lines.length - 1 && pos > lines[caretLine].length) {
2227
+ pos -= lines[caretLine].length + 1;
2228
+ caretLine++;
2229
+ }
2230
+ const caretCell = (caretLine === 0 ? pw : 0) + pos;
2231
+ let caretRow = Math.floor(caretCell / cols2);
2232
+ for (let i = 0; i < caretLine; i++) {
2233
+ const lead = i === 0 ? pw : 0;
2234
+ caretRow += Math.max(1, Math.ceil((lead + lines[i].length) / cols2));
2235
+ }
2236
+ const caretCol = caretCell % cols2;
2237
+ while (caretRow >= rows.length) rows.push({ start: this.inputBuffer.length, len: 0, colOffset: 0 });
2238
+ return { rows, caretRow, caretCol, rowCount: rows.length };
2239
+ }
2240
+ /** Move the caret one visual row up/down, keeping the column when possible. */
2241
+ moveCaretVertical(delta) {
2242
+ const { rows, caretRow, caretCol } = this.inputLayout();
2243
+ const target = caretRow + delta;
2244
+ if (target < 0 || target >= rows.length) return;
2245
+ const row = rows[target];
2246
+ const within = Math.max(0, Math.min(caretCol - row.colOffset, row.len));
2247
+ this.cursorPos = Math.min(row.start + within, this.inputBuffer.length);
2248
+ this.renderBottom();
2249
+ }
2250
+ // ─── sent-message history (↑/↓ recall) ─────────────────────────────────────
2251
+ /** Seed the recall history (oldest → newest), e.g. from the on-disk file. */
2252
+ setHistory(entries) {
2253
+ this.history = entries.filter((e) => e.trim() !== "");
2254
+ }
2255
+ /** Record a sent message (skipping a consecutive duplicate). */
2256
+ addHistoryEntry(text) {
2257
+ if (this.history[this.history.length - 1] === text) return;
2258
+ this.history.push(text);
2259
+ }
2260
+ /** Recall the previous (older) history entry; stashes the draft first. */
2261
+ historyPrev() {
2262
+ if (!this.history.length) return;
2263
+ if (this.historyIdx === null) {
2264
+ this.historyDraft = this.inputBuffer;
2265
+ this.historyDraftImages = this.pendingImages;
2266
+ this.historyIdx = this.history.length - 1;
2267
+ } else if (this.historyIdx > 0) {
2268
+ this.historyIdx--;
2269
+ } else {
2270
+ return;
2271
+ }
2272
+ this.pendingImages = [];
2273
+ this.inputBuffer = this.history[this.historyIdx];
2274
+ this.cursorPos = this.inputBuffer.length;
2275
+ this.slashIdx = 0;
2276
+ this.renderBottom();
2277
+ }
2278
+ /** Step toward the newest entry; past it, restore the stashed draft. */
2279
+ historyNext() {
2280
+ if (this.historyIdx === null) return;
2281
+ if (this.historyIdx < this.history.length - 1) {
2282
+ this.historyIdx++;
2283
+ this.pendingImages = [];
2284
+ this.inputBuffer = this.history[this.historyIdx];
2285
+ } else {
2286
+ this.historyIdx = null;
2287
+ this.inputBuffer = this.historyDraft;
2288
+ this.pendingImages = this.historyDraftImages;
2289
+ this.historyDraft = "";
2290
+ this.historyDraftImages = [];
2291
+ }
2292
+ this.cursorPos = this.inputBuffer.length;
2293
+ this.slashIdx = 0;
2294
+ this.renderBottom();
2295
+ }
2296
+ // ─── clipboard image paste (Ctrl+V) ────────────────────────────────────────
2297
+ /**
2298
+ * Read an image off the system clipboard and drop an `[#Image N]`
2299
+ * placeholder at the caret. The bytes ride along with the message on submit
2300
+ * (as a real attachment) as long as the placeholder is still in the text —
2301
+ * delete the placeholder and the image is dropped too.
2302
+ */
2303
+ async pasteClipboardImage() {
2304
+ if (this.imagePasteBusy) return;
2305
+ this.imagePasteBusy = true;
2306
+ try {
2307
+ const img = await readClipboardImage();
2308
+ if (!img) {
2309
+ this.print(`${C.dim}No image on the clipboard.${C.reset}`);
2310
+ return;
2311
+ }
2312
+ const seq = this.pendingImages.reduce((m, i) => Math.max(m, i.seq), 0) + 1;
2313
+ this.pendingImages.push({ seq, data: img.data, mime: img.mime });
2314
+ this.insertAtCursor(imagePlaceholder(seq));
2315
+ } finally {
2316
+ this.imagePasteBusy = false;
2317
+ }
2318
+ }
1528
2319
  cycleLevel() {
1529
2320
  const idx = LEVELS.indexOf(this.level);
1530
2321
  this.setLevel(LEVELS[(idx + 1) % LEVELS.length]);
@@ -1571,7 +2362,8 @@ var Tui = class {
1571
2362
  let out = parts.length ? `${C.gray}${parts.join(" ")}${C.reset}` : "";
1572
2363
  if (this.contextPct != null) {
1573
2364
  const pct = this.contextPct;
1574
- const col = pct >= 85 ? C.red : pct >= 70 ? C.yellow : C.green;
2365
+ const calmGreen = "\x1B[38;5;65m";
2366
+ const col = pct >= 85 ? C.red : pct >= 70 ? C.yellow : calmGreen;
1575
2367
  const gauge = `${col}ctx ${pct}%${C.reset}`;
1576
2368
  out = out ? `${out} ${gauge}` : gauge;
1577
2369
  }
@@ -1623,13 +2415,13 @@ var Tui = class {
1623
2415
  * token totals visible (`↑in ↓out`) so they live in the summary rather than
1624
2416
  * crowding the prompt. Null when idle with nothing counted yet.
1625
2417
  */
1626
- statusLineText(cols) {
2418
+ statusLineText(cols2) {
1627
2419
  const tk = this.tokensText();
1628
2420
  if (this.working) {
1629
2421
  const el = this.formatElapsed(Date.now() - this.workingStart);
1630
2422
  const right = `${C.dim}${el}${C.reset}${tk ? " " + tk : ""}`;
1631
2423
  const head = `${this.spinnerFrame()} ${C.bold}Working${C.reset}`;
1632
- const avail = Math.max(0, cols - this.visibleWidth(head) - this.visibleWidth(right) - 2);
2424
+ const avail = Math.max(0, cols2 - this.visibleWidth(head) - this.visibleWidth(right) - 2);
1633
2425
  let stepPart = "";
1634
2426
  if (this.step && avail > 1) {
1635
2427
  let s = this.step;
@@ -1638,23 +2430,43 @@ var Tui = class {
1638
2430
  }
1639
2431
  return `${head}${stepPart} ${right}`;
1640
2432
  }
2433
+ if (this.goalComplete) {
2434
+ const done = `${C.bold}${gradientText("\u2713 Goal complete.")}${C.reset}`;
2435
+ return tk ? `${done} ${tk}` : done;
2436
+ }
1641
2437
  return tk ? tk : null;
1642
2438
  }
1643
2439
  /** The prompt line prefix (with ANSI colour) that precedes the typed text. */
1644
2440
  promptPrefix() {
1645
2441
  const q = this.queuedCount > 0 ? `${C.yellow}[\u23F3 ${this.queuedCount} queued]${C.reset} ` : "";
1646
2442
  const bg = this.bgCount > 0 ? `${C.cyan}[\u2699 ${this.bgCount} bg]${C.reset} ` : "";
1647
- return `${q}${bg}${this.levelColor()}\u203A${C.reset} `;
2443
+ return `${q}${bg}${this.levelColor()}\u276F${C.reset} `;
1648
2444
  }
1649
2445
  visibleWidth(s) {
1650
2446
  return s.replace(/\x1b\[[0-9;]*m/g, "").length;
1651
2447
  }
2448
+ /**
2449
+ * The slash-palette block rendered BELOW the input. While the palette is open
2450
+ * we reserve a fixed number of rows — one per available command — padding with
2451
+ * blank rows so the region height never changes as the filter narrows. That
2452
+ * pins the input: the region scrolls into place once when the palette opens,
2453
+ * then nothing below the input resizes, so the text you're typing never jumps.
2454
+ * Returns `[]` when the palette is closed (no reservation, input sits at the
2455
+ * bottom as usual).
2456
+ */
2457
+ paletteBlockLines(cols2) {
2458
+ if (!this.paletteOpen()) return [];
2459
+ const rows = this.paletteLines(cols2);
2460
+ const reserved = Math.max(this.commands.length, rows.length);
2461
+ while (rows.length < reserved) rows.push("");
2462
+ return rows;
2463
+ }
1652
2464
  /**
1653
2465
  * Build the slash-palette rows for the current filter. Each row is clamped to
1654
2466
  * ONE physical line (a wrapped row would desync the move-up redraw), with the
1655
2467
  * `/name` highlighted, the label dimmed, and the hint right-aligned.
1656
2468
  */
1657
- paletteLines(cols) {
2469
+ paletteLines(cols2) {
1658
2470
  if (!this.paletteOpen()) return [];
1659
2471
  const matches = this.filteredCommands();
1660
2472
  if (matches.length === 0) return [` ${C.gray}no matching command${C.reset}`];
@@ -1666,23 +2478,42 @@ var Tui = class {
1666
2478
  const hintW = hint.length;
1667
2479
  const name = `/${cmd.name}`;
1668
2480
  let visible = `${name} ${cmd.label}`;
1669
- const labelMax = Math.max(6, cols - pointerW - (hintW ? hintW + 2 : 0));
2481
+ const labelMax = Math.max(6, cols2 - pointerW - (hintW ? hintW + 2 : 0));
1670
2482
  if (visible.length > labelMax) visible = visible.slice(0, labelMax - 1) + "\u2026";
1671
2483
  const desc = visible.slice(name.length);
1672
2484
  const pointer = sel ? `${C.magenta}\u276F${C.reset} ` : " ";
1673
2485
  const nameStyled = sel ? `${C.bold}${C.cyan}${name}${C.reset}` : `${C.cyan}${name}${C.reset}`;
1674
2486
  let line = `${pointer}${nameStyled}${C.gray}${desc}${C.reset}`;
1675
2487
  if (hintW) {
1676
- const gap = Math.max(2, cols - pointerW - visible.length - hintW);
2488
+ const gap = Math.max(2, cols2 - pointerW - visible.length - hintW);
1677
2489
  line += `${" ".repeat(gap)}${C.gray}${hint}${C.reset}`;
1678
2490
  }
1679
2491
  return line;
1680
2492
  });
1681
2493
  }
1682
- /** Move the cursor to the top-left of the current bottom region. */
2494
+ /**
2495
+ * Move the cursor to the top-left of the current bottom region.
2496
+ *
2497
+ * Same width as the last draw → the caret's recorded row offset is exact.
2498
+ * Width CHANGED (terminal resized) → previously drawn rows re-wrapped, so
2499
+ * that offset is stale; recompute it under the new wrap instead: each drawn
2500
+ * HUD row of visible width w now occupies ceil(w / cols) physical rows
2501
+ * (reflowing terminals re-wrap hard lines; the cursor follows its logical
2502
+ * position in the input text, which inputLayout locates at the new width).
2503
+ */
1683
2504
  moveToRegionTop() {
1684
2505
  process.stdout.write("\r");
1685
- if (this.bottomDrawn && this.lastCursorRow > 0) process.stdout.write(`\x1B[${this.lastCursorRow}A`);
2506
+ if (!this.bottomDrawn) return;
2507
+ const cols2 = process.stdout.columns || 80;
2508
+ let up;
2509
+ if (cols2 !== this.lastDrawnCols && this.lastDrawnCols > 0) {
2510
+ let above = 0;
2511
+ for (const w of this.drawnHudWidths) above += Math.max(1, Math.ceil(Math.max(w, 1) / cols2));
2512
+ up = above + this.inputLayout().caretRow;
2513
+ } else {
2514
+ up = this.lastCursorRow;
2515
+ }
2516
+ if (up > 0) process.stdout.write(`\x1B[${up}A`);
1686
2517
  }
1687
2518
  /**
1688
2519
  * Render the bottom region: an optional step line, then the prompt + input
@@ -1692,43 +2523,77 @@ var Tui = class {
1692
2523
  */
1693
2524
  renderBottom() {
1694
2525
  if (!this.started || this.takeoverHandler) return;
1695
- const cols = process.stdout.columns || 80;
2526
+ const cols2 = process.stdout.columns || 80;
1696
2527
  this.moveToRegionTop();
1697
2528
  process.stdout.write("\x1B[J");
2529
+ const hudWidths = [];
2530
+ const writeHudRow = (line) => {
2531
+ hudWidths.push(this.visibleWidth(line));
2532
+ process.stdout.write(line + "\r\n");
2533
+ };
2534
+ const previewLines = this.streamPreviewLines(cols2);
2535
+ for (const line of previewLines) writeHudRow(line);
2536
+ const rulerRows = 1;
2537
+ writeHudRow(`${C.dim}${"\u2500".repeat(cols2)}${C.reset}`);
1698
2538
  const noticeLine = this.connected ? null : `${C.yellow}\u26A0 lost connection to the workspace \u2014 reconnecting\u2026${C.reset}`;
1699
2539
  const noticeRows = noticeLine ? 1 : 0;
1700
- if (noticeLine) process.stdout.write(noticeLine + "\r\n");
2540
+ if (noticeLine) writeHudRow(noticeLine);
1701
2541
  const quitLine = this.quitArmed ? `${C.dim}Press Control-C again to exit${C.reset}` : null;
1702
2542
  const quitRows = quitLine ? 1 : 0;
1703
- if (quitLine) process.stdout.write(quitLine + "\r\n");
2543
+ if (quitLine) writeHudRow(quitLine);
1704
2544
  const frame = FRAMES[Math.floor(Date.now() / 100) % FRAMES.length];
1705
- for (const label of this.subagents) {
1706
- const line = `${C.magenta}${frame}${C.reset} ${C.magenta}${label}${C.reset} ${C.dim}working${C.reset}`;
1707
- process.stdout.write(line + "\r\n");
2545
+ for (const sub of this.subagents) {
2546
+ const color = sub.agentName === COMPACTION_AGENT ? COMPACTION_COLOR : SUBAGENT_COLORS[this.subagentColorByID.get(sub.id) ?? 0];
2547
+ const budget = cols2 - 10;
2548
+ let label = sub.label;
2549
+ if (budget < 1) label = "";
2550
+ else if (label.length > budget) label = label.slice(0, Math.max(0, budget - 1)) + "\u2026";
2551
+ const line = `${color}${frame}${C.reset} ${color}${label}${C.reset} ${C.dim}working${C.reset}`;
2552
+ writeHudRow(line);
1708
2553
  }
1709
- const statusLine = this.statusLineText(cols);
2554
+ const statusLine = this.statusLineText(cols2);
1710
2555
  const statusRows = statusLine ? 1 : 0;
1711
- if (statusLine) process.stdout.write(statusLine + "\r\n");
1712
- const paletteLines = this.paletteLines(cols);
1713
- for (const line of paletteLines) process.stdout.write(line + "\r\n");
1714
- const aboveRows = noticeRows + quitRows + this.subagents.length + statusRows + paletteLines.length;
2556
+ if (statusLine) writeHudRow(statusLine);
2557
+ const goalLines = this.goalLines(cols2);
2558
+ for (const line of goalLines) writeHudRow(line);
2559
+ const aboveRows = previewLines.length + rulerRows + noticeRows + quitRows + this.subagents.length + statusRows + goalLines.length;
1715
2560
  const prefix = this.promptPrefix();
1716
2561
  const pw = this.visibleWidth(prefix);
1717
- const buf = this.inputBuffer;
1718
- process.stdout.write(prefix + buf);
1719
- const inputRows = Math.max(1, Math.ceil((pw + buf.length) / cols));
1720
- if (this.cursorPos < buf.length) {
1721
- const curCell = pw + this.cursorPos;
1722
- const cursorRowInInput = Math.floor(curCell / cols);
1723
- const cursorCol = curCell % cols;
2562
+ const lines = this.inputBuffer.split("\n");
2563
+ process.stdout.write(prefix + lines[0]);
2564
+ for (let i = 1; i < lines.length; i++) process.stdout.write("\r\n" + lines[i]);
2565
+ const rowsOf = (len, lead) => Math.max(1, Math.ceil((lead + len) / cols2));
2566
+ const lineRows = lines.map((l, i) => rowsOf(l.length, i === 0 ? pw : 0));
2567
+ const inputRows = lineRows.reduce((a, b) => a + b, 0);
2568
+ let pos = this.cursorPos;
2569
+ let caretLine = 0;
2570
+ while (caretLine < lines.length - 1 && pos > lines[caretLine].length) {
2571
+ pos -= lines[caretLine].length + 1;
2572
+ caretLine++;
2573
+ }
2574
+ const caretCell = (caretLine === 0 ? pw : 0) + pos;
2575
+ let caretRow = Math.floor(caretCell / cols2);
2576
+ for (let i = 0; i < caretLine; i++) caretRow += lineRows[i];
2577
+ const caretCol = caretCell % cols2;
2578
+ const paletteBlock = this.paletteBlockLines(cols2);
2579
+ for (const line of paletteBlock) process.stdout.write("\r\n" + line);
2580
+ if (paletteBlock.length > 0) {
2581
+ process.stdout.write("\r");
2582
+ const up = inputRows - 1 + paletteBlock.length - caretRow;
2583
+ if (up > 0) process.stdout.write(`\x1B[${up}A`);
2584
+ if (caretCol > 0) process.stdout.write(`\x1B[${caretCol}C`);
2585
+ this.lastCursorRow = aboveRows + caretRow;
2586
+ } else if (this.cursorPos < this.inputBuffer.length) {
1724
2587
  process.stdout.write("\r");
1725
- const up = inputRows - 1 - cursorRowInInput;
2588
+ const up = inputRows - 1 - caretRow;
1726
2589
  if (up > 0) process.stdout.write(`\x1B[${up}A`);
1727
- if (cursorCol > 0) process.stdout.write(`\x1B[${cursorCol}C`);
1728
- this.lastCursorRow = aboveRows + cursorRowInInput;
2590
+ if (caretCol > 0) process.stdout.write(`\x1B[${caretCol}C`);
2591
+ this.lastCursorRow = aboveRows + caretRow;
1729
2592
  } else {
1730
2593
  this.lastCursorRow = aboveRows + (inputRows - 1);
1731
2594
  }
2595
+ this.drawnHudWidths = hudWidths;
2596
+ this.lastDrawnCols = cols2;
1732
2597
  this.bottomDrawn = true;
1733
2598
  }
1734
2599
  clearBottom() {
@@ -1737,14 +2602,183 @@ var Tui = class {
1737
2602
  process.stdout.write("\x1B[J");
1738
2603
  this.bottomDrawn = false;
1739
2604
  }
2605
+ // ── live streaming preview ────────────────────────────────────────────────
2606
+ // An ephemeral tail of the model's output above the status line: reasoning in
2607
+ // dim italic until the answer begins, then the answer plain. clearStream()
2608
+ // wipes it right before the finished message is committed to the transcript
2609
+ // (which renders full markdown), so there's no double-render.
2610
+ static STREAM_TAIL = 20;
2611
+ // How long a preview may sit untouched before it's wiped. The model often
2612
+ // reasons and then calls a tool without ever emitting an answer, so without
2613
+ // this the reasoning tail would linger on screen until the *next* thought (or
2614
+ // message) arrives. Re-armed on every delta → fires this long after the last.
2615
+ static STREAM_IDLE_MS = 1e4;
2616
+ /** Append a fragment of streamed answer text (rendered plain). */
2617
+ streamResponseDelta(delta, messageId) {
2618
+ this.beginStreamMessage(messageId);
2619
+ this.streamResponse += delta;
2620
+ this.scheduleStreamRedraw();
2621
+ this.armStreamIdleExpiry();
2622
+ }
2623
+ /**
2624
+ * Append a fragment of streamed internal reasoning (rendered dim italic).
2625
+ * Ignored once the answer has started, since reasoning precedes the answer.
2626
+ */
2627
+ streamThinkingDelta(delta, messageId) {
2628
+ this.beginStreamMessage(messageId);
2629
+ if (this.streamResponse) return;
2630
+ this.streamThinking += delta;
2631
+ this.scheduleStreamRedraw();
2632
+ this.armStreamIdleExpiry();
2633
+ }
2634
+ /** Reset the preview when a new message starts, so two messages' output (e.g.
2635
+ * one that only reasons then calls a tool, then the next) never blend. */
2636
+ beginStreamMessage(messageId) {
2637
+ if (messageId !== void 0 && messageId !== this.streamMessageId) {
2638
+ this.streamMessageId = messageId;
2639
+ this.streamThinking = "";
2640
+ this.streamResponse = "";
2641
+ }
2642
+ }
2643
+ /** Wipe the live preview — call right before committing the final message. */
2644
+ clearStream() {
2645
+ if (this.streamRedrawTimer) {
2646
+ clearTimeout(this.streamRedrawTimer);
2647
+ this.streamRedrawTimer = null;
2648
+ }
2649
+ if (this.streamIdleTimer) {
2650
+ clearTimeout(this.streamIdleTimer);
2651
+ this.streamIdleTimer = null;
2652
+ }
2653
+ this.streamMessageId = null;
2654
+ if (!this.streamThinking && !this.streamResponse) return;
2655
+ this.streamThinking = "";
2656
+ this.streamResponse = "";
2657
+ this.renderBottom();
2658
+ }
2659
+ scheduleStreamRedraw() {
2660
+ if (this.streamRedrawTimer || this.takeoverHandler || !this.started) return;
2661
+ this.streamRedrawTimer = setTimeout(() => {
2662
+ this.streamRedrawTimer = null;
2663
+ this.renderBottom();
2664
+ }, 40);
2665
+ }
2666
+ /** Re-armed on every streamed delta: once the model goes quiet for a beat, the
2667
+ * preview is stale, so wipe it instead of letting it sit until the next
2668
+ * message. The committed message (if any) still renders in full via
2669
+ * clearStream(), so nothing is lost. */
2670
+ armStreamIdleExpiry() {
2671
+ if (this.streamIdleTimer) clearTimeout(this.streamIdleTimer);
2672
+ this.streamIdleTimer = setTimeout(() => {
2673
+ this.streamIdleTimer = null;
2674
+ if (!this.streamThinking && !this.streamResponse) return;
2675
+ this.streamThinking = "";
2676
+ this.streamResponse = "";
2677
+ this.renderBottom();
2678
+ }, _Tui.STREAM_IDLE_MS);
2679
+ }
2680
+ /**
2681
+ * The preview's physical rows: a tail of reasoning (dim italic) before the
2682
+ * answer starts, otherwise a tail of the answer (plain). Each row is clamped to
2683
+ * one terminal line so the bottom-region redraw math stays correct.
2684
+ *
2685
+ * Rows match the committed transcript formatting so a finished message doesn't
2686
+ * visibly "snap" into shape: the answer's first line carries the grey gutter
2687
+ * dot (as long as it hasn't scrolled out of the tail) and the body indents two
2688
+ * spaces beneath it; reasoning aligns at the same indent, dotless.
2689
+ */
2690
+ streamPreviewLines(cols2) {
2691
+ const thinkStyle = "\x1B[3m\x1B[38;5;240m";
2692
+ const clamp2 = (s, wrap, lead) => {
2693
+ const max = cols2 - 2;
2694
+ const t = s.length > max ? s.slice(0, Math.max(0, max - 1)) + "\u2026" : s;
2695
+ return wrap ? `${lead}${wrap}${t}${C.reset}` : `${lead}${t}`;
2696
+ };
2697
+ const realLines = (text) => text.replace(/\r/g, "").split("\n").filter((l) => l.trim() !== "");
2698
+ if (this.streamResponse) {
2699
+ const all = realLines(this.streamResponse);
2700
+ const shown = all.slice(-20);
2701
+ const firstVisible = all.length <= _Tui.STREAM_TAIL;
2702
+ return shown.map(
2703
+ (l, i) => clamp2(l, "", i === 0 && firstVisible ? `${C.gray}\u2022${C.reset} ` : " ")
2704
+ );
2705
+ }
2706
+ if (this.streamThinking) {
2707
+ return realLines(this.streamThinking).slice(-20).map((l) => clamp2(l, thinkStyle, " "));
2708
+ }
2709
+ return [];
2710
+ }
2711
+ // ── live goal checklist ───────────────────────────────────────────────────
2712
+ /**
2713
+ * Update the goal from the goal_updated event (or the initial fetch). When
2714
+ * every step is done, the goal is fully achieved: we clear the goal area and
2715
+ * flip on the "Goal complete." status badge instead of leaving a finished
2716
+ * checklist sitting there.
2717
+ */
2718
+ setGoal(goal) {
2719
+ const steps = goal?.steps;
2720
+ if (steps && Array.isArray(steps) && steps.length) {
2721
+ const allDone = steps.every((s) => s.status === "done");
2722
+ if (allDone) {
2723
+ this.goal = null;
2724
+ this.goalComplete = true;
2725
+ } else {
2726
+ this.goal = goal;
2727
+ this.goalComplete = false;
2728
+ }
2729
+ } else {
2730
+ this.goal = null;
2731
+ }
2732
+ this.renderBottom();
2733
+ }
2734
+ /**
2735
+ * The goal's physical rows: a header (the short summary + progress) then one
2736
+ * row per step, indented two spaces beneath it so the todos clearly belong to
2737
+ * the goal — ○ pending / ▸ in-progress / ✓ done — each clamped to a line.
2738
+ */
2739
+ goalLines(cols2) {
2740
+ const steps = this.goal?.steps;
2741
+ if (!steps?.length) return [];
2742
+ const oneLine = (s) => s.replace(/\s+/g, " ").trim();
2743
+ const clamp2 = (s, max) => s.length > max ? s.slice(0, Math.max(0, max - 1)) + "\u2026" : s;
2744
+ const calmGreen = "\x1B[38;5;65m";
2745
+ const out = [];
2746
+ const done = steps.filter((s) => s.status === "done").length;
2747
+ const summary = oneLine(this.goal?.summary || this.goal?.description || "Goal");
2748
+ const prefix = `Goal ${done}/${steps.length} `;
2749
+ const sum = clamp2(summary, Math.max(1, cols2 - prefix.length));
2750
+ out.push(`${C.bold}Goal${C.reset} ${C.gray}${done}/${steps.length}${C.reset} ${sum}`);
2751
+ for (const s of steps) {
2752
+ const text = clamp2(oneLine(s.step || ""), Math.max(1, cols2 - 4));
2753
+ if (s.status === "done") out.push(` ${calmGreen}\u2713${C.reset} ${C.dim}${text}${C.reset}`);
2754
+ else if (s.status === "in_progress") out.push(` ${C.cyan}\u25B8${C.reset} ${text}`);
2755
+ else out.push(` ${C.gray}\u25CB ${text}${C.reset}`);
2756
+ }
2757
+ return out;
2758
+ }
2759
+ /**
2760
+ * Transcript gutter: content starts at column 1, so the far-left column is a
2761
+ * clean strip where only status glyphs (✓ ✗ ⛔ …) land — scanning down the
2762
+ * left edge reads as a ledger of what happened. Lines already led by a
2763
+ * gutter glyph or whitespace (indented blocks, the user-message bar) pass
2764
+ * through untouched.
2765
+ */
2766
+ gutterize(text) {
2767
+ return text.split("\n").map((line) => {
2768
+ const plain = line.replace(/\x1b\[[0-9;]*m/g, "");
2769
+ if (plain === "" || /^[\s✓✗⛔⚠⚙⚡⏳↪›❯◇─•]/.test(plain)) return line;
2770
+ return " " + line;
2771
+ }).join("\n");
2772
+ }
1740
2773
  /** Print a line of transcript above the persistent input. */
1741
2774
  print(text) {
2775
+ const line = this.gutterize(text);
1742
2776
  if (this.takeoverHandler) {
1743
- this.bufferedPrints.push(text);
2777
+ this.bufferedPrints.push(line);
1744
2778
  return;
1745
2779
  }
1746
2780
  this.clearBottom();
1747
- process.stdout.write(text + "\n");
2781
+ process.stdout.write(line + "\n");
1748
2782
  this.renderBottom();
1749
2783
  }
1750
2784
  /** Multi-line convenience. */
@@ -1758,9 +2792,9 @@ var Tui = class {
1758
2792
  * above and below, and a teal `›` marks the first row.
1759
2793
  */
1760
2794
  printUserMessage(text) {
1761
- const cols = Math.max(20, process.stdout.columns || 80);
2795
+ const cols2 = Math.max(20, process.stdout.columns || 80);
1762
2796
  const bg = "\x1B[48;5;238m";
1763
- const limit = Math.max(8, cols - 6);
2797
+ const limit = Math.max(8, cols2 - 6);
1764
2798
  const words = text.replace(/\s+/g, " ").trim().split(" ");
1765
2799
  const lines = [];
1766
2800
  let cur = "";
@@ -1784,9 +2818,9 @@ var Tui = class {
1784
2818
  const innerW = 2 + Math.max(...lines.map((l) => l.length));
1785
2819
  this.print("");
1786
2820
  lines.forEach((line, i) => {
1787
- const rowText = (i === 0 ? "\u203A " : " ") + line;
2821
+ const rowText = (i === 0 ? "\u276F " : " ") + line;
1788
2822
  const padded = rowText.padEnd(innerW);
1789
- const inner = i === 0 ? `${C.teal}\u203A${C.reset}${bg}${padded.slice(1)}` : padded;
2823
+ const inner = i === 0 ? `${C.teal}\u276F${C.reset}${bg}${padded.slice(1)}` : padded;
1790
2824
  this.print(`${bg} ${inner} ${C.reset}`);
1791
2825
  });
1792
2826
  this.print("");
@@ -1796,15 +2830,30 @@ var Tui = class {
1796
2830
  if (on && !this.working) {
1797
2831
  this.working = true;
1798
2832
  this.workingStart = Date.now();
2833
+ this.goalComplete = false;
1799
2834
  } else if (!on) {
1800
2835
  this.working = false;
1801
2836
  }
1802
2837
  this.syncSpinner();
1803
2838
  this.renderBottom();
1804
2839
  }
1805
- /** Labels of subagents currently working, one persistent line each. */
1806
- setSubagents(labels) {
1807
- this.subagents = labels;
2840
+ /** The subagents currently working, one persistent line each. Each keeps a
2841
+ * stable, distinct colour for as long as it's active; the compaction agent
2842
+ * is always orange (its colour never comes from the shared pool). */
2843
+ setSubagents(subagents) {
2844
+ this.subagents = subagents;
2845
+ const active = new Set(subagents.map((s) => s.id));
2846
+ for (const id of [...this.subagentColorByID.keys()]) {
2847
+ if (!active.has(id)) this.subagentColorByID.delete(id);
2848
+ }
2849
+ for (const s of subagents) {
2850
+ if (s.agentName === COMPACTION_AGENT) continue;
2851
+ if (this.subagentColorByID.has(s.id)) continue;
2852
+ const used = new Set(this.subagentColorByID.values());
2853
+ let idx = 0;
2854
+ while (used.has(idx) && idx < SUBAGENT_COLORS.length - 1) idx++;
2855
+ this.subagentColorByID.set(s.id, idx);
2856
+ }
1808
2857
  this.syncSpinner();
1809
2858
  this.renderBottom();
1810
2859
  }
@@ -1837,9 +2886,12 @@ var Tui = class {
1837
2886
  getInput() {
1838
2887
  return this.inputBuffer;
1839
2888
  }
1840
- setInput(text) {
2889
+ /** Replace the input (and any pasted images tied to placeholders in it). */
2890
+ setInput(text, images = []) {
1841
2891
  this.inputBuffer = text;
1842
2892
  this.cursorPos = text.length;
2893
+ this.pendingImages = images;
2894
+ this.historyIdx = null;
1843
2895
  this.renderBottom();
1844
2896
  }
1845
2897
  /** Cumulative token totals shown on the prompt line (`outTokens` includes live). */
@@ -1879,7 +2931,8 @@ var Tui = class {
1879
2931
  }
1880
2932
  this.renderBottom();
1881
2933
  }
1882
- /** Approval prompt: arrow-navigable with y/a/l/n shortcuts. Pauses input. */
2934
+ /** Approval prompt: arrow-navigable with y/a/l/n shortcuts. Pauses input.
2935
+ * Tab resolves "deny_with_reason" so the caller can collect a free-text reason. */
1883
2936
  approval(question, risk) {
1884
2937
  return new Promise((resolve) => {
1885
2938
  const options = [
@@ -1891,12 +2944,16 @@ var Tui = class {
1891
2944
  let idx = 0;
1892
2945
  const riskBar = `${C.red}${"\u25CF".repeat(risk)}${C.gray}${"\u25CB".repeat(5 - risk)}${C.reset}`;
1893
2946
  this.beginTakeover();
1894
- process.stdout.write(
1895
- `
1896
- ${C.yellow}\u2503${C.reset} ${C.bold}Permission needed${C.reset} risk ${riskBar}
1897
- ${C.yellow}\u2503${C.reset} ${question}
1898
- `
1899
- );
2947
+ const cols2 = Math.max(1, process.stdout.columns || 80);
2948
+ const physRows = (line) => Math.max(1, Math.ceil(this.visibleWidth(line) / cols2));
2949
+ const headerText = `${C.yellow}\u2503${C.reset} ${C.bold}Permission needed${C.reset} risk ${riskBar}`;
2950
+ const questionLines = question.split("\n").map((line) => `${C.yellow}\u2503${C.reset} ${line}`);
2951
+ const hintLine = `${C.yellow}\u2503${C.reset} ${C.gray}\u21E5 tab \u2014 deny with a reason${C.reset}`;
2952
+ const blockLines = [headerText, ...questionLines, hintLine];
2953
+ const headerRows = 1 + blockLines.reduce((n, line) => n + physRows(line), 0);
2954
+ process.stdout.write(`
2955
+ ` + blockLines.map((line) => `${line}
2956
+ `).join(""));
1900
2957
  const renderLine = (i) => {
1901
2958
  const o = options[i];
1902
2959
  const sel = i === idx;
@@ -1910,7 +2967,14 @@ ${C.yellow}\u2503${C.reset} ${question}
1910
2967
  `);
1911
2968
  };
1912
2969
  draw(false);
2970
+ const erase = () => {
2971
+ process.stdout.write("\r");
2972
+ const up = headerRows + options.length;
2973
+ if (up > 0) process.stdout.write(`\x1B[${up}A`);
2974
+ process.stdout.write("\x1B[J");
2975
+ };
1913
2976
  const finish = (choice) => {
2977
+ erase();
1914
2978
  this.endTakeover();
1915
2979
  resolve(choice);
1916
2980
  };
@@ -1921,6 +2985,8 @@ ${C.yellow}\u2503${C.reset} ${question}
1921
2985
  } else if (key?.name === "down" || str === "j") {
1922
2986
  idx = (idx + 1) % options.length;
1923
2987
  draw(true);
2988
+ } else if (key?.name === "tab") {
2989
+ finish("deny_with_reason");
1924
2990
  } else if (key?.name === "return" || key?.name === "enter") {
1925
2991
  finish(options[idx].value);
1926
2992
  } else {
@@ -2041,11 +3107,39 @@ ${C.cyan}\u2503${C.reset} ${question}
2041
3107
  }
2042
3108
  };
2043
3109
 
3110
+ // src/history.ts
3111
+ var HISTORY_KEY = "input_history";
3112
+ var MAX_ENTRIES = 100;
3113
+ function clean(value) {
3114
+ if (!Array.isArray(value)) return [];
3115
+ return value.filter((e) => typeof e === "string" && e.trim() !== "").slice(-MAX_ENTRIES);
3116
+ }
3117
+ async function loadHistory(store, threadId, seedThreadId) {
3118
+ const own = clean(await store.kvGet(threadId, HISTORY_KEY));
3119
+ if (own.length) return own;
3120
+ if (seedThreadId && seedThreadId !== threadId) {
3121
+ const seeded = clean(await store.kvGet(seedThreadId, HISTORY_KEY));
3122
+ if (seeded.length) {
3123
+ void store.kvSet(threadId, HISTORY_KEY, seeded);
3124
+ return seeded;
3125
+ }
3126
+ }
3127
+ return [];
3128
+ }
3129
+ function appendHistory(store, threadId, history, text) {
3130
+ const t = text.trim();
3131
+ if (!t || history[history.length - 1] === t) return history;
3132
+ history.push(t);
3133
+ if (history.length > MAX_ENTRIES) history.splice(0, history.length - MAX_ENTRIES);
3134
+ void store.kvSet(threadId, HISTORY_KEY, [...history]);
3135
+ return history;
3136
+ }
3137
+
2044
3138
  // src/markdown.ts
2045
3139
  var ESC = "\x1B[";
2046
3140
  var R = ESC + "0m";
2047
3141
  var BOLD = ESC + "1m";
2048
- var DIM = ESC + "2m";
3142
+ var DIM2 = ESC + "2m";
2049
3143
  var ITAL = ESC + "3m";
2050
3144
  var UNDER = ESC + "4m";
2051
3145
  var TEAL = ESC + "38;5;37m";
@@ -2067,11 +3161,11 @@ function inline(s) {
2067
3161
  });
2068
3162
  s = s.replace(
2069
3163
  /\[([^\]]+)\]\(([^)\s]+)\)/g,
2070
- (_, text, url) => `${CYAN}${UNDER}${text}${R} ${DIM}${url}${R}`
3164
+ (_, text, url) => `${CYAN}${UNDER}${text}${R} ${DIM2}${url}${R}`
2071
3165
  );
2072
3166
  s = s.replace(/\*\*([^*]+)\*\*/g, (_, t) => `${BOLD}${t}${R}`);
2073
3167
  s = s.replace(/\*([^*\n]+)\*/g, (_, t) => `${ITAL}${t}${R}`);
2074
- s = s.replace(/~~([^~]+)~~/g, (_, t) => `${DIM}${t}${R}`);
3168
+ s = s.replace(/~~([^~]+)~~/g, (_, t) => `${DIM2}${t}${R}`);
2075
3169
  s = s.replace(/\x00(\d+)\x00/g, (_, i) => `${TEAL}${codes[+i].replace(/ /g, String.fromCharCode(160))}${R}`);
2076
3170
  return s;
2077
3171
  }
@@ -2098,8 +3192,8 @@ function wrapStyled(text, width) {
2098
3192
  if (cur !== "" || lines.length === 0) lines.push(cur);
2099
3193
  return lines;
2100
3194
  }
2101
- function wrapBlock(out, cols, leadFirst, leadRest, leadWidth, text) {
2102
- const wrapped = wrapStyled(text, Math.max(8, cols - leadWidth));
3195
+ function wrapBlock(out, cols2, leadFirst, leadRest, leadWidth, text) {
3196
+ const wrapped = wrapStyled(text, Math.max(8, cols2 - leadWidth));
2103
3197
  wrapped.forEach((ln, idx) => out.push((idx === 0 ? leadFirst : leadRest) + ln));
2104
3198
  }
2105
3199
  function tableCells(row) {
@@ -2113,16 +3207,16 @@ function isTableSeparator(line) {
2113
3207
  return SEPARATOR.test(line) && line.includes("-") && line.includes("|");
2114
3208
  }
2115
3209
  function renderTable(rows) {
2116
- const cols = Math.max(...rows.map((r) => r.length));
3210
+ const cols2 = Math.max(...rows.map((r) => r.length));
2117
3211
  const widths = [];
2118
- for (let c2 = 0; c2 < cols; c2++) {
3212
+ for (let c2 = 0; c2 < cols2; c2++) {
2119
3213
  widths[c2] = Math.max(...rows.map((r) => visibleWidth(inline(r[c2] ?? ""))));
2120
3214
  }
2121
3215
  const sep = `${GRAY} \u2502 ${R}`;
2122
3216
  const out = [];
2123
3217
  rows.forEach((r, ri) => {
2124
3218
  const cells = [];
2125
- for (let c2 = 0; c2 < cols; c2++) {
3219
+ for (let c2 = 0; c2 < cols2; c2++) {
2126
3220
  const raw = r[c2] ?? "";
2127
3221
  const styled = ri === 0 ? `${BOLD}${inline(raw)}${R}` : inline(raw);
2128
3222
  cells.push(padEndVisible(styled, widths[c2]));
@@ -2135,7 +3229,7 @@ function renderTable(rows) {
2135
3229
  });
2136
3230
  return out;
2137
3231
  }
2138
- function renderMarkdown(src, cols = 80) {
3232
+ function renderMarkdown(src, cols2 = 80) {
2139
3233
  const lines = src.replace(/\r\n/g, "\n").split("\n");
2140
3234
  const out = [];
2141
3235
  let inFence = false;
@@ -2164,7 +3258,7 @@ function renderMarkdown(src, cols = 80) {
2164
3258
  }
2165
3259
  const heading = line.match(/^(#{1,6})\s+(.*)$/);
2166
3260
  if (heading) {
2167
- for (const ln of wrapStyled(heading[2].trim(), cols)) out.push(`${BOLD}${TEAL}${ln}${R}`);
3261
+ for (const ln of wrapStyled(heading[2].trim(), cols2)) out.push(`${BOLD}${TEAL}${ln}${R}`);
2168
3262
  i++;
2169
3263
  continue;
2170
3264
  }
@@ -2175,8 +3269,8 @@ function renderMarkdown(src, cols = 80) {
2175
3269
  }
2176
3270
  const quote = line.match(/^\s*>\s?(.*)$/);
2177
3271
  if (quote) {
2178
- for (const ln of wrapStyled(inline(quote[1]), Math.max(8, cols - 2))) {
2179
- out.push(`${GRAY}\u2502${R} ${DIM}${ln}${R}`);
3272
+ for (const ln of wrapStyled(inline(quote[1]), Math.max(8, cols2 - 2))) {
3273
+ out.push(`${GRAY}\u2502${R} ${DIM2}${ln}${R}`);
2180
3274
  }
2181
3275
  i++;
2182
3276
  continue;
@@ -2184,7 +3278,7 @@ function renderMarkdown(src, cols = 80) {
2184
3278
  const bullet = line.match(/^(\s*)[-*+]\s+(.*)$/);
2185
3279
  if (bullet) {
2186
3280
  const leadWidth = bullet[1].length + 2;
2187
- wrapBlock(out, cols, `${bullet[1]}${TEAL}\u2022${R} `, " ".repeat(leadWidth), leadWidth, inline(bullet[2]));
3281
+ wrapBlock(out, cols2, `${bullet[1]}${TEAL}\u2022${R} `, " ".repeat(leadWidth), leadWidth, inline(bullet[2]));
2188
3282
  i++;
2189
3283
  continue;
2190
3284
  }
@@ -2192,11 +3286,11 @@ function renderMarkdown(src, cols = 80) {
2192
3286
  if (numbered) {
2193
3287
  const marker = `${numbered[2]}${numbered[3]}`;
2194
3288
  const leadWidth = numbered[1].length + marker.length + 1;
2195
- wrapBlock(out, cols, `${numbered[1]}${BOLD}${marker}${R} `, " ".repeat(leadWidth), leadWidth, inline(numbered[4]));
3289
+ wrapBlock(out, cols2, `${numbered[1]}${BOLD}${marker}${R} `, " ".repeat(leadWidth), leadWidth, inline(numbered[4]));
2196
3290
  i++;
2197
3291
  continue;
2198
3292
  }
2199
- if (line.trim()) wrapBlock(out, cols, "", "", 0, inline(line));
3293
+ if (line.trim()) wrapBlock(out, cols2, "", "", 0, inline(line));
2200
3294
  else out.push("");
2201
3295
  i++;
2202
3296
  }
@@ -2307,7 +3401,7 @@ ${stderrTail.trim()}` : msg;
2307
3401
  target,
2308
3402
  argsSha256: sha256(canonicalJson(args)),
2309
3403
  resultSha256: sha256(text),
2310
- nonce: crypto2.randomBytes(8).toString("hex"),
3404
+ nonce: crypto.randomBytes(8).toString("hex"),
2311
3405
  isError,
2312
3406
  at: Date.now()
2313
3407
  };
@@ -2577,10 +3671,10 @@ function sortKeys(value) {
2577
3671
  return value;
2578
3672
  }
2579
3673
  function sha256(input2) {
2580
- return crypto2.createHash("sha256").update(input2).digest("hex");
3674
+ return crypto.createHash("sha256").update(input2).digest("hex");
2581
3675
  }
2582
- var DIR2 = path3.join(os4.homedir(), ".standardagents");
2583
- var FILE2 = path3.join(DIR2, "credentials");
3676
+ var DIR = path3.join(os6.homedir(), ".standardagents");
3677
+ var FILE = path3.join(DIR, "credentials");
2584
3678
  function normalizeEndpoint(endpoint) {
2585
3679
  let e = endpoint.trim();
2586
3680
  if (!/^https?:\/\//i.test(e)) e = "http://" + e;
@@ -2588,7 +3682,7 @@ function normalizeEndpoint(endpoint) {
2588
3682
  }
2589
3683
  function loadCredentials() {
2590
3684
  try {
2591
- const raw = fs2.readFileSync(FILE2, "utf8");
3685
+ const raw = fs4.readFileSync(FILE, "utf8");
2592
3686
  const parsed = JSON.parse(raw);
2593
3687
  if (!parsed.instances) parsed.instances = {};
2594
3688
  return parsed;
@@ -2600,21 +3694,33 @@ function getCredential(endpoint) {
2600
3694
  const creds = loadCredentials();
2601
3695
  return creds.instances[normalizeEndpoint(endpoint)] ?? null;
2602
3696
  }
2603
- function saveCredential(cred) {
3697
+ function saveCredential(cred, options = {}) {
2604
3698
  const creds = loadCredentials();
2605
3699
  const endpoint = normalizeEndpoint(cred.endpoint);
2606
3700
  creds.instances[endpoint] = { ...cred, endpoint };
2607
- creds.default_endpoint = endpoint;
2608
- fs2.mkdirSync(DIR2, { recursive: true });
2609
- fs2.writeFileSync(FILE2, JSON.stringify(creds, null, 2), { mode: 384 });
3701
+ if (options.updateDefault ?? true) {
3702
+ creds.default_endpoint = endpoint;
3703
+ }
3704
+ fs4.mkdirSync(DIR, { recursive: true });
3705
+ fs4.writeFileSync(FILE, JSON.stringify(creds, null, 2), { mode: 384 });
2610
3706
  try {
2611
- fs2.chmodSync(FILE2, 384);
3707
+ fs4.chmodSync(FILE, 384);
2612
3708
  } catch {
2613
3709
  }
2614
3710
  }
2615
3711
  function defaultEndpoint() {
2616
3712
  return loadCredentials().default_endpoint ?? null;
2617
3713
  }
3714
+ function saveDefaultEndpoint(endpoint) {
3715
+ const creds = loadCredentials();
3716
+ creds.default_endpoint = normalizeEndpoint(endpoint);
3717
+ fs4.mkdirSync(DIR, { recursive: true });
3718
+ fs4.writeFileSync(FILE, JSON.stringify(creds, null, 2), { mode: 384 });
3719
+ try {
3720
+ fs4.chmodSync(FILE, 384);
3721
+ } catch {
3722
+ }
3723
+ }
2618
3724
 
2619
3725
  // src/index.ts
2620
3726
  var AGENT_ID = "standard_code_agent";
@@ -2643,13 +3749,86 @@ var LOGO_MARK = [
2643
3749
  "\u2588\u2588 \u2588\u2588\u2588",
2644
3750
  "\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588"
2645
3751
  ];
3752
+ function printUsage() {
3753
+ stdout.write(
3754
+ [
3755
+ "",
3756
+ `${c.bold}Usage${c.reset}`,
3757
+ " standardcode [options] [dir]",
3758
+ "",
3759
+ `${c.bold}Options${c.reset}`,
3760
+ " -e, --endpoint [url] Use a Standard Agents instance for this run only.",
3761
+ " If url is omitted, prompt for it.",
3762
+ " Credentials are remembered for that endpoint, but",
3763
+ " the saved default endpoint is not changed.",
3764
+ " -h, --help Show this help.",
3765
+ ""
3766
+ ].join("\n")
3767
+ );
3768
+ }
3769
+ function parseArgs2(args) {
3770
+ const parsed = { help: false, promptEndpoint: false };
3771
+ for (let i = 0; i < args.length; i++) {
3772
+ const arg = args[i];
3773
+ if (arg === "--help" || arg === "-h") {
3774
+ parsed.help = true;
3775
+ continue;
3776
+ }
3777
+ if (arg === "--endpoint" || arg === "-e") {
3778
+ const value = args[i + 1];
3779
+ if (value && !value.startsWith("-")) {
3780
+ parsed.endpoint = value;
3781
+ i++;
3782
+ } else {
3783
+ parsed.promptEndpoint = true;
3784
+ }
3785
+ continue;
3786
+ }
3787
+ if (arg.startsWith("--endpoint=")) {
3788
+ const value = arg.slice("--endpoint=".length);
3789
+ if (value) {
3790
+ parsed.endpoint = value;
3791
+ } else {
3792
+ parsed.promptEndpoint = true;
3793
+ }
3794
+ continue;
3795
+ }
3796
+ if (arg === "--") {
3797
+ if (i === 0 && args[i + 1]?.startsWith("-")) continue;
3798
+ const rest = args.slice(i + 1);
3799
+ if (rest.length > 1) throw new Error("Expected at most one project directory.");
3800
+ if (rest[0]) parsed.dir = rest[0];
3801
+ break;
3802
+ }
3803
+ if (arg.startsWith("-")) throw new Error(`Unknown option: ${arg}`);
3804
+ if (parsed.dir) throw new Error("Expected at most one project directory.");
3805
+ parsed.dir = arg;
3806
+ }
3807
+ return parsed;
3808
+ }
2646
3809
  function printAssistant(tui, text) {
2647
- const cols = Math.max(20, (process.stdout.columns || 80) - 1);
3810
+ const cols2 = Math.max(20, (process.stdout.columns || 80) - 3);
3811
+ tui.clearStream();
2648
3812
  tui.print("");
2649
- for (const line of renderMarkdown(text, cols)) tui.print(line);
3813
+ let dotted = false;
3814
+ for (const line of renderMarkdown(text, cols2)) {
3815
+ if (!dotted && line.trim()) {
3816
+ tui.print(`${c.gray}\u2022${c.reset} ${line}`);
3817
+ dotted = true;
3818
+ } else {
3819
+ tui.print(` ${line}`);
3820
+ }
3821
+ }
2650
3822
  tui.print("");
2651
3823
  }
2652
- function farewell() {
3824
+ function farewell(stoppedProcs = 0) {
3825
+ if (stoppedProcs > 0) {
3826
+ stdout.write(
3827
+ `
3828
+ ${c.cyan}\u2699${c.reset} Stopped ${stoppedProcs} background process${stoppedProcs === 1 ? "" : "es"}.
3829
+ `
3830
+ );
3831
+ }
2653
3832
  stdout.write(`
2654
3833
  ${c.teal}\u25C7${c.reset} ${c.dim}Standard Code \u2014 see you soon.${c.reset}
2655
3834
  `);
@@ -2676,29 +3855,30 @@ function relaxTlsForLocalEndpoint(endpoint) {
2676
3855
  }
2677
3856
  function readVersion() {
2678
3857
  try {
2679
- const pkg = JSON.parse(fs2.readFileSync(new URL("../package.json", import.meta.url), "utf8"));
3858
+ const pkg = JSON.parse(fs4.readFileSync(new URL("../package.json", import.meta.url), "utf8"));
2680
3859
  return typeof pkg.version === "string" ? pkg.version : "";
2681
3860
  } catch {
2682
3861
  return "";
2683
3862
  }
2684
3863
  }
2685
3864
  function printWelcome(endpoint, projectDir) {
2686
- const home = os4.homedir();
3865
+ const home = os6.homedir();
2687
3866
  const dir = projectDir.startsWith(home) ? "~" + projectDir.slice(home.length) : projectDir;
2688
3867
  const host = endpoint.replace(/^https?:\/\//, "").replace(/\/$/, "");
2689
3868
  const version = readVersion();
2690
3869
  const pad = " ";
2691
3870
  const meta = [
2692
- `${c.bold}${c.white}Standard Code${c.reset}${version ? ` ${c.dim}v${version}${c.reset}` : ""}`,
3871
+ `${c.bold}${gradientText("Standard Code")}${c.reset}${version ? ` ${c.dim}v${version}${c.reset}` : ""}`,
2693
3872
  `${c.dim}terminal coding agent${c.reset}`,
2694
3873
  `${c.teal}${host}${c.reset}`,
2695
3874
  `${c.dim}${dir}${c.reset}`
2696
3875
  ];
2697
3876
  const markWidth = Math.max(...LOGO_MARK.map((l) => [...l].length));
3877
+ const gradMark = gradientArt(LOGO_MARK);
2698
3878
  const metaTop = Math.floor((LOGO_MARK.length - meta.length) / 2);
2699
3879
  stdout.write("\n");
2700
3880
  for (let i = 0; i < LOGO_MARK.length; i++) {
2701
- const glyph = LOGO_MARK[i].padEnd(markWidth);
3881
+ const glyph = gradMark[i].padEnd(markWidth + (gradMark[i].length - LOGO_MARK[i].length));
2702
3882
  const line = meta[i - metaTop];
2703
3883
  stdout.write(`${pad}${glyph}${line ? ` ${line}` : ""}
2704
3884
  `);
@@ -2713,19 +3893,41 @@ function colorActivity(line) {
2713
3893
  const body = rest.replace(/\s(\([^()]*\))\s*$/, ` ${c.dim}$1${c.reset}`);
2714
3894
  return `${indent}${c.green}\u2713${c.reset} ${body}`;
2715
3895
  }
2716
- if (glyph === "\u2717") return `${indent}${c.red}\u2717${c.reset} ${rest}`;
3896
+ if (glyph === "\u2717") {
3897
+ const ERR_MAX_LINES = 7;
3898
+ const lines = rest.split("\n");
3899
+ const shown = lines.slice(0, ERR_MAX_LINES);
3900
+ const hidden = lines.length - shown.length;
3901
+ const body = shown.map(
3902
+ (l, i) => i === 0 ? `${indent}${c.red}\u2717 ${l}${c.reset}` : `${indent}${c.red}${c.dim}${l}${c.reset}`
3903
+ ).join("\n");
3904
+ if (hidden > 0) {
3905
+ return `${body}
3906
+ ${indent}${c.dim}\u2026 +${hidden} more line${hidden === 1 ? "" : "s"}${c.reset}`;
3907
+ }
3908
+ return body;
3909
+ }
2717
3910
  return `${indent}${c.yellow}\u26D4 ${rest}${c.reset}`;
2718
3911
  }
2719
3912
  async function main() {
2720
- const args = process.argv.slice(2);
2721
- let endpointArg;
2722
- let dirArg;
2723
- for (let i = 0; i < args.length; i++) {
2724
- if (args[i] === "--endpoint" || args[i] === "-e") endpointArg = args[++i];
2725
- else if (!args[i].startsWith("-")) dirArg = args[i];
3913
+ let cliArgs;
3914
+ try {
3915
+ cliArgs = parseArgs2(process.argv.slice(2));
3916
+ } catch (error) {
3917
+ stdout.write(`${c.red}error:${c.reset} ${error instanceof Error ? error.message : String(error)}
3918
+ `);
3919
+ printUsage();
3920
+ process.exit(1);
3921
+ }
3922
+ if (cliArgs.help) {
3923
+ printUsage();
3924
+ return;
2726
3925
  }
3926
+ const endpointArg = cliArgs.endpoint;
3927
+ const endpointOverride = cliArgs.promptEndpoint || typeof endpointArg === "string" && endpointArg.trim() !== "";
3928
+ const dirArg = cliArgs.dir;
2727
3929
  const projectDir = path3.resolve(dirArg || process.cwd());
2728
- const machine = os4.hostname();
3930
+ const machine = os6.hostname();
2729
3931
  const reader = { rl: null };
2730
3932
  let handoffClosing = false;
2731
3933
  let preflightArmed = false;
@@ -2758,13 +3960,22 @@ ${c.dim}Press Control-C again to exit${c.reset}
2758
3960
  }
2759
3961
  return reader.rl.question(question);
2760
3962
  };
3963
+ const askEndpoint = async () => {
3964
+ for (; ; ) {
3965
+ const answer = (await ask(
3966
+ `${c.cyan}Standard Agents instance URL${c.reset} (e.g. http://localhost:5178): `
3967
+ )).trim();
3968
+ if (answer) return answer;
3969
+ stdout.write(`${c.dim}An endpoint URL is required.${c.reset}
3970
+ `);
3971
+ }
3972
+ };
2761
3973
  process.on("SIGINT", onPreflightSigint);
2762
- let endpoint = endpointArg || defaultEndpoint() || "";
3974
+ let endpointPrompted = false;
3975
+ let endpoint = endpointArg || (cliArgs.promptEndpoint ? "" : defaultEndpoint() || "");
2763
3976
  if (!endpoint) {
2764
- const answer = await ask(
2765
- `${c.cyan}Standard Agents instance URL${c.reset} (e.g. http://localhost:5178): `
2766
- );
2767
- endpoint = answer.trim();
3977
+ endpoint = await askEndpoint();
3978
+ endpointPrompted = true;
2768
3979
  }
2769
3980
  endpoint = normalizeEndpoint(endpoint);
2770
3981
  const tlsRelaxed = relaxTlsForLocalEndpoint(endpoint);
@@ -2779,22 +3990,43 @@ ${c.dim}Press Control-C again to exit${c.reset}
2779
3990
  if (!api || !await api.verify()) {
2780
3991
  const host = endpoint.replace(/^https?:\/\//, "").replace(/\/$/, "");
2781
3992
  stdout.write(
2782
- `${c.bold}${c.white}Sign in${c.reset} ${c.dim}\u2014 paste an API token to connect to${c.reset} ${c.teal}${host}${c.reset}
3993
+ `${c.bold}${c.white}Sign in${c.reset} ${c.dim}\u2014 connect to${c.reset} ${c.teal}${host}${c.reset}
2783
3994
  `
2784
3995
  );
2785
- stdout.write(`${c.dim}Create one in your instance settings under API tokens.${c.reset}
3996
+ stdout.write(
3997
+ `${c.dim}Press Enter to sign in with your browser, or paste an API token.${c.reset}
2786
3998
 
2787
- `);
3999
+ `
4000
+ );
2788
4001
  for (; ; ) {
2789
- const token = (await ask(`${c.teal}\u276F${c.reset} ${c.dim}token${c.reset} `)).trim();
4002
+ const token = (await ask(`${c.teal}\u276F${c.reset} ${c.dim}token (or Enter for browser)${c.reset} `)).trim();
2790
4003
  if (!token) {
2791
- stdout.write(`${c.dim}A token is required.${c.reset}
4004
+ const got = await deviceLogin(endpoint).catch((e) => {
4005
+ stdout.write(`${c.red}\u2717${c.reset} ${c.dim}${e instanceof Error ? e.message : String(e)}${c.reset}
4006
+ `);
4007
+ return null;
4008
+ });
4009
+ if (!got) continue;
4010
+ api = new ApiClient(endpoint, got);
4011
+ if (await api.verify()) {
4012
+ saveCredential(
4013
+ { endpoint, access_token: got, token_type: "Bearer", saved_at: Date.now() },
4014
+ { updateDefault: !endpointOverride }
4015
+ );
4016
+ stdout.write(`${c.green}\u2713${c.reset} Connected to ${c.teal}${host}${c.reset}
4017
+ `);
4018
+ break;
4019
+ }
4020
+ stdout.write(`${c.red}\u2717${c.reset} ${c.dim}Browser sign-in didn't verify. Try again.${c.reset}
2792
4021
  `);
2793
4022
  continue;
2794
4023
  }
2795
4024
  api = new ApiClient(endpoint, token);
2796
4025
  if (await api.verify()) {
2797
- saveCredential({ endpoint, access_token: token, token_type: "Bearer", saved_at: Date.now() });
4026
+ saveCredential(
4027
+ { endpoint, access_token: token, token_type: "Bearer", saved_at: Date.now() },
4028
+ { updateDefault: !endpointOverride }
4029
+ );
2798
4030
  stdout.write(`${c.green}\u2713${c.reset} Connected to ${c.teal}${host}${c.reset}
2799
4031
  `);
2800
4032
  break;
@@ -2802,6 +4034,8 @@ ${c.dim}Press Control-C again to exit${c.reset}
2802
4034
  stdout.write(`${c.red}\u2717${c.reset} ${c.dim}That token didn't work. Try again.${c.reset}
2803
4035
  `);
2804
4036
  }
4037
+ } else if (endpointPrompted) {
4038
+ saveDefaultEndpoint(endpoint);
2805
4039
  }
2806
4040
  if (!api) process.exit(1);
2807
4041
  handoffClosing = true;
@@ -2816,6 +4050,7 @@ ${c.dim}Press Control-C again to exit${c.reset}
2816
4050
  const tui = new Tui(1);
2817
4051
  let threadId;
2818
4052
  let resumed = false;
4053
+ let historySeed;
2819
4054
  if (existing.length > 0) {
2820
4055
  const summaries = await summarizeThreads(api, existing.slice(0, 8));
2821
4056
  const items = summaries.map((s) => ({
@@ -2824,7 +4059,7 @@ ${c.dim}Press Control-C again to exit${c.reset}
2824
4059
  value: s.id
2825
4060
  }));
2826
4061
  items.push({ label: "\uFF0B Start a new session", value: null });
2827
- const home = os4.homedir();
4062
+ const home = os6.homedir();
2828
4063
  const tilde = projectDir.startsWith(home) ? "~" + projectDir.slice(home.length) : projectDir;
2829
4064
  const shortDir = tilde.length > 38 ? "\u2026" + tilde.slice(-37) : tilde;
2830
4065
  const picked = await tui.select(
@@ -2836,11 +4071,12 @@ ${c.dim}Press Control-C again to exit${c.reset}
2836
4071
  resumed = true;
2837
4072
  } else {
2838
4073
  threadId = await api.createThread(AGENT_ID, tags);
4074
+ historySeed = existing[0]?.id;
2839
4075
  }
2840
4076
  } else {
2841
4077
  threadId = await api.createThread(AGENT_ID, tags);
2842
4078
  }
2843
- await runInteractive(tui, api, threadId, projectDir, machine, resumed);
4079
+ await runInteractive(tui, api, threadId, projectDir, machine, resumed, historySeed);
2844
4080
  }
2845
4081
  async function summarizeThreads(api, threads) {
2846
4082
  return Promise.all(
@@ -2859,13 +4095,34 @@ async function summarizeThreads(api, threads) {
2859
4095
  })
2860
4096
  );
2861
4097
  }
2862
- function subagentLabel(t, titles) {
2863
- const agentName = (t.agent_name || "").trim();
2864
- const title = titles.get(agentName) || (agentName ? agentName.replace(/[_-]+/g, " ").replace(/\b\w/g, (c2) => c2.toUpperCase()) : "Subagent");
2865
- const nameTag = (t.tags || []).find((tag) => tag.startsWith("name:"));
2866
- const tagged = nameTag?.slice("name:".length).trim();
4098
+ function subagentLabel(s, titles) {
4099
+ const agentName = (s.agent_name || "").trim();
4100
+ const title = (s.title || "").trim() || titles.get(agentName) || (agentName ? agentName.replace(/[_-]+/g, " ").replace(/\b\w/g, (c2) => c2.toUpperCase()) : "Subagent");
4101
+ const tagged = (s.threadName || "").trim();
2867
4102
  return tagged ? `${title} \xB7 ${tagged}` : title;
2868
4103
  }
4104
+ async function deviceLogin(endpoint) {
4105
+ const start = await fetch(`${endpoint}/api/auth/device/start`, { method: "POST" });
4106
+ if (!start.ok) throw new Error(`This instance does not support browser sign-in (HTTP ${start.status}). Paste an API token instead.`);
4107
+ const info = await start.json();
4108
+ stdout.write(`${c.dim}Opening your browser to approve this sign-in\u2026${c.reset}
4109
+ `);
4110
+ stdout.write(`${c.dim}If it doesn't open, visit:${c.reset} ${c.teal}${info.verify_url}${c.reset}
4111
+ `);
4112
+ openUrl(info.verify_url);
4113
+ const deadline = Date.now() + (info.expires_in ?? 600) * 1e3;
4114
+ const interval = Math.max(2, info.interval ?? 2) * 1e3;
4115
+ while (Date.now() < deadline) {
4116
+ await new Promise((r) => setTimeout(r, interval));
4117
+ const res = await fetch(info.poll_url).catch(() => null);
4118
+ if (!res) continue;
4119
+ if (res.status === 404) throw new Error("The sign-in link expired. Try again.");
4120
+ const body = await res.json().catch(() => ({}));
4121
+ if (body.status === "approved" && body.token) return body.token;
4122
+ if (body.status === "denied") throw new Error("Sign-in was denied in the browser.");
4123
+ }
4124
+ throw new Error("Timed out waiting for browser approval. Try again.");
4125
+ }
2869
4126
  function openUrl(url) {
2870
4127
  const platform = process.platform;
2871
4128
  const cmd = platform === "darwin" ? "open" : platform === "win32" ? "cmd" : "xdg-open";
@@ -2926,14 +4183,13 @@ async function printHistory(api, threadId, tui) {
2926
4183
  if (m.role === "user") tui.printUserMessage(text);
2927
4184
  else printAssistant(tui, text);
2928
4185
  }
2929
- tui.print(`${c.dim}\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500${c.reset}`);
2930
4186
  }
2931
- async function runInteractive(tui, api, threadId, projectDir, machine, resumed) {
4187
+ async function runInteractive(tui, api, threadId, projectDir, machine, resumed, historySeedThreadId) {
2932
4188
  const registry = new ProcessRegistry(api, threadId, machine);
2933
4189
  const mcp = new McpManager(projectDir);
2934
4190
  const publishMcpCatalog = () => void api.kvSet(threadId, "mcp_catalog", mcp.catalog()).catch(() => {
2935
4191
  });
2936
- const host = new HostTools(projectDir, registry, threadId, machine, mcp, publishMcpCatalog);
4192
+ const host = new HostTools(projectDir, registry, threadId, machine, mcp, publishMcpCatalog, api);
2937
4193
  const refreshBgCount = () => {
2938
4194
  void registry.runningCount().then((n) => tui.setBackgroundCount(n)).catch(() => {
2939
4195
  });
@@ -2950,11 +4206,13 @@ async function runInteractive(tui, api, threadId, projectDir, machine, resumed)
2950
4206
  perm.level = l;
2951
4207
  saveApprovals(api, threadId, perm);
2952
4208
  });
4209
+ saveApprovals(api, threadId, perm);
2953
4210
  let busy = false;
2954
4211
  let interrupting = false;
2955
4212
  const queued = [];
2956
4213
  let editingQueued = false;
2957
4214
  const shownIds = /* @__PURE__ */ new Set();
4215
+ const pendingSent = /* @__PURE__ */ new Map();
2958
4216
  let tokensIn = 0;
2959
4217
  let tokensOut = 0;
2960
4218
  let liveOut = 0;
@@ -2967,13 +4225,19 @@ async function runInteractive(tui, api, threadId, projectDir, machine, resumed)
2967
4225
  tui.setStep(label, liveOut);
2968
4226
  };
2969
4227
  const bridge = new Bridge(api, threadId, host, perm, {
2970
- onActivity: (line) => {
4228
+ onActivity: (line, detail) => {
2971
4229
  tui.print(colorActivity(line));
4230
+ if (detail) for (const d of detail) tui.print(d);
2972
4231
  refreshBgCount();
2973
4232
  },
2974
- onStatus: () => {
4233
+ onStatus: (id, summary) => {
4234
+ if (summary) {
4235
+ if (!activeSteps.has(id)) activeSteps.set(id, summary);
4236
+ } else {
4237
+ activeSteps.delete(id);
4238
+ }
4239
+ refreshStatus();
2975
4240
  },
2976
- // the working indicator is driven by the busy poller
2977
4241
  onConnection: (state, attempt) => {
2978
4242
  if (state === "reconnecting") {
2979
4243
  if (attempt >= 4) tui.setConnected(false);
@@ -2981,15 +4245,27 @@ async function runInteractive(tui, api, threadId, projectDir, machine, resumed)
2981
4245
  tui.setConnected(true);
2982
4246
  }
2983
4247
  },
2984
- requestApproval: (req, summary, risk) => tui.approval(
2985
- `${summary}${req.requestPermission ? `
2986
- ${c.dim}why: ${req.requestPermission}${c.reset}` : ""}`,
2987
- risk
2988
- )
4248
+ requestApproval: async (req, summary, risk) => {
4249
+ const choice = await tui.approval(
4250
+ `${summary}${req.requestPermission ? `
4251
+ ${c.bold}why: ${req.requestPermission}${c.reset}` : ""}`,
4252
+ risk
4253
+ );
4254
+ if (choice === "deny_with_reason") {
4255
+ const reason = await tui.prompt(
4256
+ "Why are you denying this? (sent to the agent \u2014 enter to send, esc to skip)"
4257
+ );
4258
+ return { choice: "deny", reason: reason ?? void 0 };
4259
+ }
4260
+ return { choice };
4261
+ }
2989
4262
  });
2990
4263
  const stream = new MessageStream(api, threadId, {
2991
- onChunk: () => {
2992
- },
4264
+ // Live streaming preview: answer text and (opt-in) internal reasoning feed
4265
+ // the TUI's ephemeral preview; the committed message still renders from
4266
+ // polling, which calls tui.clearStream() first so there's no double-render.
4267
+ onChunk: (text, mid) => tui.streamResponseDelta(text, mid),
4268
+ onReasoningChunk: (text, mid) => tui.streamThinkingDelta(text, mid),
2993
4269
  onAssistantText: () => {
2994
4270
  },
2995
4271
  onEvent: (eventType, data) => {
@@ -3002,6 +4278,8 @@ ${c.dim}why: ${req.requestPermission}${c.reset}` : ""}`,
3002
4278
  } else if (eventType === "tool_call_done" && data?.id) {
3003
4279
  activeSteps.delete(data.id);
3004
4280
  refreshStatus();
4281
+ } else if (eventType === "goal_updated" && data) {
4282
+ tui.setGoal(data);
3005
4283
  }
3006
4284
  },
3007
4285
  onError: () => {
@@ -3009,33 +4287,69 @@ ${c.dim}why: ${req.requestPermission}${c.reset}` : ""}`,
3009
4287
  });
3010
4288
  const activeSubagents = /* @__PURE__ */ new Map();
3011
4289
  const agentTitles = /* @__PURE__ */ new Map();
3012
- void api.listAgents().then((list) => list.forEach((a) => agentTitles.set(a.name, a.title))).catch(() => {
4290
+ const agentTitlesReady = api.listAgents().then((list) => list.forEach((a) => agentTitles.set(a.name, a.title))).catch(() => {
3013
4291
  });
3014
- const pushSubagents = () => tui.setSubagents([...activeSubagents.values()]);
4292
+ const pushSubagents = () => tui.setSubagents(
4293
+ [...activeSubagents.entries()].map(([id, s]) => ({ id, label: s.label, agentName: s.agentName }))
4294
+ );
4295
+ const reconcileSubagents = async () => {
4296
+ try {
4297
+ await agentTitlesReady;
4298
+ const subs = await api.listSubagents(threadId);
4299
+ activeSubagents.clear();
4300
+ for (const s of subs) {
4301
+ if (s.status !== "running") continue;
4302
+ activeSubagents.set(s.id, {
4303
+ label: subagentLabel(s, agentTitles),
4304
+ agentName: s.agent_name ?? void 0
4305
+ });
4306
+ }
4307
+ pushSubagents();
4308
+ } catch {
4309
+ }
4310
+ };
4311
+ let reconcileTimer = null;
4312
+ let reconcilePending = false;
4313
+ const scheduleReconcile = () => {
4314
+ if (reconcileTimer) {
4315
+ reconcilePending = true;
4316
+ return;
4317
+ }
4318
+ reconcileTimer = setTimeout(async () => {
4319
+ reconcileTimer = null;
4320
+ await reconcileSubagents();
4321
+ if (reconcilePending) {
4322
+ reconcilePending = false;
4323
+ scheduleReconcile();
4324
+ }
4325
+ }, 150);
4326
+ };
3015
4327
  const events = new SystemEvents(api, {
4328
+ onOpen: () => scheduleReconcile(),
3016
4329
  onThreadCreated: (t) => {
3017
- if (t.parent === threadId && !t.terminated) {
3018
- activeSubagents.set(t.id, subagentLabel(t, agentTitles));
3019
- pushSubagents();
3020
- }
4330
+ if (t.parent === threadId) scheduleReconcile();
3021
4331
  },
3022
4332
  onThreadUpdated: (t) => {
3023
- if (t.parent !== threadId) return;
3024
- if (t.terminated) activeSubagents.delete(t.id);
3025
- else activeSubagents.set(t.id, subagentLabel(t, agentTitles));
3026
- pushSubagents();
4333
+ if (t.parent === threadId) scheduleReconcile();
3027
4334
  },
3028
4335
  onThreadDeleted: (id) => {
3029
- if (activeSubagents.delete(id)) pushSubagents();
4336
+ if (activeSubagents.has(id)) scheduleReconcile();
3030
4337
  }
3031
4338
  });
3032
- const quit = () => {
4339
+ const quit = async () => {
3033
4340
  tui.end();
4341
+ const stopped = api.stop(threadId).catch(() => {
4342
+ });
4343
+ const procsStopped = host.stopAllLocalProcesses().catch(() => 0);
3034
4344
  bridge.close();
3035
4345
  stream.close();
3036
4346
  events.close();
3037
4347
  mcp.closeAll();
3038
- farewell();
4348
+ const [, killed] = await Promise.race([
4349
+ Promise.all([stopped, procsStopped]),
4350
+ new Promise((r) => setTimeout(() => r([void 0, 0]), 1500))
4351
+ ]);
4352
+ farewell(killed);
3039
4353
  process.exit(0);
3040
4354
  };
3041
4355
  tui.setQuitHandler(quit);
@@ -3087,11 +4401,18 @@ ${c.dim}why: ${req.requestPermission}${c.reset}` : ""}`,
3087
4401
  },
3088
4402
  setEnabled: (name, enabled) => setMcpServerEnabled(name, enabled)
3089
4403
  };
3090
- const sendNow = async (text) => {
4404
+ const extFor = (mime) => ({ "image/png": "png", "image/jpeg": "jpg", "image/gif": "gif", "image/webp": "webp" })[mime] ?? "bin";
4405
+ const toAttachments = (images) => images.map((img) => ({ name: `image-${img.seq}.${extFor(img.mime)}`, mimeType: img.mime, data: img.data }));
4406
+ const sendNow = async (text, images = []) => {
3091
4407
  tui.printUserMessage(text);
4408
+ const key = text.trim();
4409
+ pendingSent.set(key, (pendingSent.get(key) ?? 0) + 1);
3092
4410
  try {
3093
- await api.sendMessage(threadId, text);
4411
+ await api.sendMessage(threadId, text, toAttachments(images));
3094
4412
  } catch (e) {
4413
+ const n = (pendingSent.get(key) ?? 1) - 1;
4414
+ if (n > 0) pendingSent.set(key, n);
4415
+ else pendingSent.delete(key);
3095
4416
  tui.print(`${c.dim}failed to send: ${e instanceof Error ? e.message : String(e)}${c.reset}`);
3096
4417
  return;
3097
4418
  }
@@ -3103,16 +4424,25 @@ ${c.dim}why: ${req.requestPermission}${c.reset}` : ""}`,
3103
4424
  if (!queued.length) return;
3104
4425
  const toSend = queued.splice(0);
3105
4426
  tui.setQueuedCount(0);
3106
- for (const t of toSend) await sendNow(t);
4427
+ for (const q of toSend) await sendNow(q.text, q.images);
3107
4428
  };
3108
4429
  const requestCompaction = async () => {
3109
4430
  try {
3110
- await api.kvSet(threadId, "compaction_request", { requestedAt: Date.now() });
3111
- tui.print(
3112
- `${c.cyan}\u27F3${c.reset} compacting the conversation in the background \u2014 recent messages stay live.`
3113
- );
4431
+ await api.compact(threadId);
3114
4432
  } catch (err) {
3115
- tui.print(`${c.red}\u2717${c.reset} couldn't request compaction: ${err.message}`);
4433
+ tui.print(`${c.red}\u2717${c.reset} couldn't start compaction: ${err.message}`);
4434
+ }
4435
+ };
4436
+ const skillsCtl = {
4437
+ list: () => api.listSkills(),
4438
+ setEnabled: (name, enabled) => api.setSkillEnabled(name, enabled),
4439
+ remove: (name) => api.removeSkill(name),
4440
+ // Seed the request into the main chat — the agent researches or authors
4441
+ // the skill there (research_agent + install_skill), visible in the transcript.
4442
+ requestInstall: (query) => {
4443
+ void sendNow(
4444
+ `Install a skill for me: ${query}. Find the skill's published files (or author a proper SKILL.md from your research), install it with install_skill, then tell me what it can do.`
4445
+ );
3116
4446
  }
3117
4447
  };
3118
4448
  tui.setCommands([
@@ -3141,25 +4471,34 @@ ${c.dim}why: ${req.requestPermission}${c.reset}` : ""}`,
3141
4471
  },
3142
4472
  run: () => runMcpMenu(tui, mcpCtl)
3143
4473
  },
4474
+ {
4475
+ name: "skills",
4476
+ label: "Agent skills",
4477
+ hint: "list / install / manage",
4478
+ run: () => runSkillsMenu(tui, skillsCtl)
4479
+ },
3144
4480
  { name: "background", label: "Background processes", hint: "list / stop", run: () => runProcessMenu(tui, bgMgr) },
3145
4481
  { name: "view", label: "View thread in AgentBuilder", run: () => viewThread() },
3146
4482
  { name: "keybindings", label: "Keyboard shortcuts", run: () => showKeybindings(tui) },
3147
4483
  { name: "quit", label: "Quit", run: () => quit() }
3148
4484
  ]);
3149
- tui.onSubmit = (text) => {
4485
+ const history = await loadHistory(api, threadId, historySeedThreadId);
4486
+ tui.setHistory(history);
4487
+ tui.onSubmit = (text, images) => {
4488
+ appendHistory(api, threadId, history, text);
3150
4489
  if (editingQueued) {
3151
4490
  editingQueued = false;
3152
- queued.push(text);
4491
+ queued.push({ text, images });
3153
4492
  tui.setQueuedCount(queued.length);
3154
4493
  tui.print(`${c.gray}\u23F3 queued:${c.reset} ${text}`);
3155
4494
  return;
3156
4495
  }
3157
4496
  if (busy) {
3158
- queued.push(text);
4497
+ queued.push({ text, images });
3159
4498
  tui.setQueuedCount(queued.length);
3160
4499
  tui.print(`${c.gray}\u23F3 queued:${c.reset} ${text} ${c.dim}(esc to steer now)${c.reset}`);
3161
4500
  } else {
3162
- void sendNow(text);
4501
+ void sendNow(text, images);
3163
4502
  }
3164
4503
  };
3165
4504
  tui.onInterrupt = () => {
@@ -3180,19 +4519,21 @@ ${c.dim}why: ${req.requestPermission}${c.reset}` : ""}`,
3180
4519
  }
3181
4520
  };
3182
4521
  tui.onUpArrow = () => {
3183
- if (tui.getInput().trim() || queued.length === 0) return;
3184
- const text = queued.pop();
4522
+ if (tui.getInput().trim() || queued.length === 0) return false;
4523
+ const q = queued.pop();
3185
4524
  tui.setQueuedCount(queued.length);
3186
4525
  editingQueued = true;
3187
- tui.setInput(text);
4526
+ tui.setInput(q.text, q.images);
4527
+ return true;
3188
4528
  };
3189
4529
  events.connect();
3190
4530
  await Promise.all([bridge.connect(), stream.connect()]);
4531
+ void api.getGoal(threadId).then((g) => tui.setGoal(g)).catch(() => {
4532
+ });
3191
4533
  tui.banner([
3192
4534
  `${c.bold}${c.magenta}Standard Code${c.reset} ${c.dim}\u2014 coding agent${c.reset}`,
3193
4535
  `${c.gray}project:${c.reset} ${projectDir}`,
3194
- `${c.gray}machine:${c.reset} ${machine} ${c.gray}thread:${c.reset} ${threadId.slice(0, 8)}`,
3195
- `${c.dim}type anytime \xB7 shift-tab cycles auto-accept level \xB7 / for options \xB7 esc interrupts/steers \xB7 ctrl-c quits${c.reset}`
4536
+ `${c.gray}machine:${c.reset} ${machine} ${c.gray}thread:${c.reset} ${threadId.slice(0, 8)}`
3196
4537
  ]);
3197
4538
  if (resumed) await printHistory(api, threadId, tui);
3198
4539
  try {
@@ -3232,6 +4573,15 @@ ${c.dim}why: ${req.requestPermission}${c.reset}` : ""}`,
3232
4573
  const text = messageText(m.content).trim();
3233
4574
  if (m.role === "assistant" && text) printAssistant(tui, text);
3234
4575
  else if (m.role === "system" && text) tui.print(`${c.dim}${text}${c.reset}`);
4576
+ else if (m.role === "user" && text) {
4577
+ const pending = pendingSent.get(text) ?? 0;
4578
+ if (pending > 0) {
4579
+ if (pending === 1) pendingSent.delete(text);
4580
+ else pendingSent.set(text, pending - 1);
4581
+ } else {
4582
+ tui.printUserMessage(text);
4583
+ }
4584
+ }
3235
4585
  }
3236
4586
  const polledBusy = threadBusy(msgs);
3237
4587
  if (interrupting) {
@@ -3279,6 +4629,56 @@ ${c.dim}why: ${req.requestPermission}${c.reset}` : ""}`,
3279
4629
  await new Promise(() => {
3280
4630
  });
3281
4631
  }
4632
+ async function runSkillsMenu(tui, skills) {
4633
+ let list;
4634
+ try {
4635
+ list = await skills.list();
4636
+ } catch (e) {
4637
+ tui.print(`${c.red}\u2717 couldn't load skills:${c.reset} ${c.gray}${e instanceof Error ? e.message : String(e)}${c.reset}`);
4638
+ return;
4639
+ }
4640
+ const INSTALL = "__install__";
4641
+ const items = list.map((s) => ({
4642
+ label: s.name,
4643
+ hint: `${s.enabled ? "enabled" : "disabled"} \xB7 ${s.files.length} file${s.files.length === 1 ? "" : "s"}`,
4644
+ value: s.name
4645
+ }));
4646
+ items.push({ label: "\uFF0B Install a skill\u2026", hint: "find & install", value: INSTALL });
4647
+ const picked = await tui.select(
4648
+ `${c.bold}Agent skills${c.reset} ${c.dim}(\u2191/\u2193 \xB7 enter \xB7 esc to close)${c.reset}`,
4649
+ items
4650
+ );
4651
+ if (!picked) return;
4652
+ if (picked === INSTALL) {
4653
+ const query = await tui.prompt(
4654
+ "What skill do you want to install?",
4655
+ "the anthropic pdf skill / a skill for writing conventional commits"
4656
+ );
4657
+ if (query) skills.requestInstall(query);
4658
+ return;
4659
+ }
4660
+ const skill = list.find((s) => s.name === picked);
4661
+ tui.print(`${c.cyan}${skill.name}${c.reset}${skill.version ? ` ${c.dim}v${skill.version}${c.reset}` : ""} ${c.gray}\u2014 ${skill.description}${c.reset}`);
4662
+ const action = await tui.select(`${c.bold}${picked}${c.reset}`, [
4663
+ skill.enabled ? { label: "Disable (hide from the agent)", value: "disable" } : { label: "Enable", value: "enable" },
4664
+ { label: "View files", value: "files" },
4665
+ { label: "Remove this skill", value: "remove" },
4666
+ { label: "Back", value: "back" }
4667
+ ]);
4668
+ try {
4669
+ if (action === "enable" || action === "disable") {
4670
+ await skills.setEnabled(picked, action === "enable");
4671
+ tui.print(`${c.gray}${action}d ${picked}${c.reset}`);
4672
+ } else if (action === "files") {
4673
+ for (const f of skill.files) tui.print(` ${c.gray}${f}${c.reset}`);
4674
+ } else if (action === "remove") {
4675
+ await skills.remove(picked);
4676
+ tui.print(`${c.gray}removed ${picked}${c.reset}`);
4677
+ }
4678
+ } catch (e) {
4679
+ tui.print(`${c.red}\u2717 ${e instanceof Error ? e.message : String(e)}${c.reset}`);
4680
+ }
4681
+ }
3282
4682
  async function runLevelMenu(tui, perm) {
3283
4683
  const picked = await tui.select(
3284
4684
  `${c.bold}Auto-accept level${c.reset} ${c.dim}(\u2191/\u2193 \xB7 enter \xB7 shift-tab cycles)${c.reset}`,
@@ -3297,6 +4697,8 @@ function showKeybindings(tui) {
3297
4697
  tui.print(`${c.gray}shortcuts:${c.reset}`);
3298
4698
  tui.print(`${c.gray} shift-tab${c.reset} cycle auto-accept level (1\u20135)`);
3299
4699
  tui.print(`${c.gray} /${c.reset} open the command palette (type to filter)`);
4700
+ tui.print(`${c.gray} ctrl-v${c.reset} paste an image from the clipboard ([#Image 1])`);
4701
+ tui.print(`${c.gray} \u2191 / \u2193${c.reset} cycle past messages (on the input's top line)`);
3300
4702
  tui.print(`${c.gray} ctrl-c${c.reset} quit`);
3301
4703
  }
3302
4704
  async function runProcessMenu(tui, bg) {