@standardagents/code 0.1.0 → 0.1.1

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,155 @@ 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
+
205
433
  // src/bridge.ts
206
- var PATH_ARG_TOOLS = /* @__PURE__ */ new Set(["read_file", "list_dir", "grep", "glob", "write_file", "edit_file", "delete"]);
434
+ var PATH_ARG_TOOLS = /* @__PURE__ */ new Set(["read_file", "grep", "glob", "write_file", "edit_file"]);
207
435
  var Bridge = class {
208
436
  constructor(api, threadId, host, perm, hooks) {
209
437
  this.api = api;
@@ -264,7 +492,10 @@ var Bridge = class {
264
492
  this.hooks.onConnection?.(wasReconnecting ? "reconnected" : "connected", 0);
265
493
  this.resolveConnected?.();
266
494
  });
267
- ws.addEventListener("message", (ev) => this.onMessage(String(ev.data)));
495
+ ws.addEventListener("message", (ev) => {
496
+ if (this.ws === ws) this.heartbeat?.markAlive();
497
+ this.onMessage(String(ev.data));
498
+ });
268
499
  ws.addEventListener("error", () => this.handleDrop(ws));
269
500
  ws.addEventListener("close", () => this.handleDrop(ws));
270
501
  }
@@ -287,16 +518,12 @@ var Bridge = class {
287
518
  }
288
519
  startHeartbeat(ws) {
289
520
  this.stopHeartbeat();
290
- this.heartbeat = setInterval(() => {
291
- try {
292
- if (ws.readyState === WebSocket.OPEN) ws.send("ping");
293
- } catch {
294
- }
295
- }, 5e3);
521
+ this.heartbeat = new Heartbeat(ws, () => this.handleDrop(ws));
522
+ this.heartbeat.start();
296
523
  }
297
524
  stopHeartbeat() {
298
525
  if (this.heartbeat) {
299
- clearInterval(this.heartbeat);
526
+ this.heartbeat.stop();
300
527
  this.heartbeat = null;
301
528
  }
302
529
  }
@@ -361,10 +588,16 @@ var Bridge = class {
361
588
  return;
362
589
  }
363
590
  if (decision === "ask") {
364
- const choice = await this.hooks.requestApproval(req, summary, effectiveRisk);
591
+ const { choice, reason } = await this.hooks.requestApproval(req, summary, effectiveRisk);
365
592
  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.");
593
+ const why = reason?.trim();
594
+ this.hooks.onActivity(`\u26D4 ${summary} \u2014 you declined${why ? `: ${why}` : ""}`);
595
+ this.respond(
596
+ req,
597
+ false,
598
+ void 0,
599
+ why ? `The user declined to run this operation. Their reason: ${why}` : "The user declined to run this operation."
600
+ );
368
601
  return;
369
602
  }
370
603
  if (choice === "always") this.perm.alwaysAllow.add(permKey);
@@ -373,11 +606,23 @@ var Bridge = class {
373
606
  saveApprovals(this.api, this.threadId, this.perm);
374
607
  }
375
608
  }
376
- this.hooks.onStatus?.(summary);
377
- const result = await this.host.execute(req.tool, req.args);
378
- this.hooks.onStatus?.(null);
609
+ const callKey = req.toolCallId ?? req.id ?? `${req.tool}:${summary}`;
610
+ this.hooks.onStatus?.(callKey, summary);
611
+ let result;
612
+ try {
613
+ result = await this.host.execute(req.tool, req.args);
614
+ } finally {
615
+ this.hooks.onStatus?.(callKey, null);
616
+ }
379
617
  if (result.ok) {
380
- this.hooks.onActivity(`\u2713 ${summary}${detailSuffix(req.tool, result.result)}`);
618
+ const display = req.tool === "bash" ? `bash: ${highlightBash(String(req.args.command ?? "").slice(0, 200))}` : summary;
619
+ let detail;
620
+ if (req.tool === "edit_file") {
621
+ detail = diffLines(String(req.args.old_string ?? ""), String(req.args.new_string ?? ""));
622
+ } else if (req.tool === "write_file") {
623
+ detail = newFileLines(String(req.args.content ?? ""));
624
+ }
625
+ this.hooks.onActivity(`\u2713 ${display}${detailSuffix(req.tool, result.result)}`, detail);
381
626
  this.respond(req, true, result.result ?? "");
382
627
  } else {
383
628
  this.hooks.onActivity(`\u2717 ${summary} \u2014 ${result.error}`);
@@ -406,8 +651,6 @@ function describe(req) {
406
651
  }
407
652
  case "read_file":
408
653
  return `read ${a.path}`;
409
- case "list_dir":
410
- return `list ${a.path || "."}`;
411
654
  case "grep":
412
655
  return `grep "${a.pattern}"${a.glob ? ` in ${a.glob}` : ""}`;
413
656
  case "glob":
@@ -416,8 +659,10 @@ function describe(req) {
416
659
  return `write ${a.path}`;
417
660
  case "edit_file":
418
661
  return `edit ${a.path}`;
419
- case "delete":
420
- return `delete ${a.path}`;
662
+ case "save_to_disk":
663
+ return `save ${a.source_path} \u2192 ${a.dest_path}`;
664
+ case "run_skill_script":
665
+ return `skill ${a.skill}: run ${a.entry}`;
421
666
  case "bash":
422
667
  return `bash: ${String(a.command).slice(0, 80)}`;
423
668
  default:
@@ -426,7 +671,7 @@ function describe(req) {
426
671
  }
427
672
  function detailSuffix(tool, result) {
428
673
  if (!result) return "";
429
- if (tool === "write_file" || tool === "edit_file" || tool === "delete") return "";
674
+ if (tool === "write_file" || tool === "edit_file") return "";
430
675
  if (tool === "bash") {
431
676
  const m = result.match(/\[exit code (\d+)\]\s*$/);
432
677
  return m ? ` (exit ${m[1]})` : "";
@@ -434,7 +679,7 @@ function detailSuffix(tool, result) {
434
679
  const lines = result.split("\n").length;
435
680
  return ` (${lines} line${lines === 1 ? "" : "s"})`;
436
681
  }
437
- var LOG_DIR = path3.join(os4.homedir(), ".standardagents", "process-logs");
682
+ var LOG_DIR = path3.join(os6.homedir(), ".standardagents", "process-logs");
438
683
  var KEY2 = "bg_processes";
439
684
  function isAlive(pid) {
440
685
  try {
@@ -510,11 +755,12 @@ var ProcessRegistry = class {
510
755
  }
511
756
  }
512
757
  };
513
- var DIR = path3.join(os4.homedir(), ".standardagents");
514
- var FILE = path3.join(DIR, "mcp.json");
758
+ function configFile() {
759
+ return process.env.STANDARDAGENTS_MCP_CONFIG || path3.join(os6.homedir(), ".standardagents", "mcp.json");
760
+ }
515
761
  function loadMcpConfig() {
516
762
  try {
517
- const raw = fs2.readFileSync(FILE, "utf8");
763
+ const raw = fs4.readFileSync(configFile(), "utf8");
518
764
  const parsed = JSON.parse(raw);
519
765
  if (!parsed.servers || typeof parsed.servers !== "object") parsed.servers = {};
520
766
  return parsed;
@@ -544,8 +790,9 @@ function setMcpServerEnabled(name, enabled) {
544
790
  write(cfg);
545
791
  }
546
792
  function write(cfg) {
547
- fs2.mkdirSync(DIR, { recursive: true });
548
- fs2.writeFileSync(FILE, JSON.stringify(cfg, null, 2), { mode: 384 });
793
+ const file = configFile();
794
+ fs4.mkdirSync(path3.dirname(file), { recursive: true });
795
+ fs4.writeFileSync(file, JSON.stringify(cfg, null, 2), { mode: 384 });
549
796
  }
550
797
  function parseServerSpec(spec) {
551
798
  const trimmed = spec.trim();
@@ -609,13 +856,14 @@ async function readLogTail(logPath, n) {
609
856
  }
610
857
  }
611
858
  var HostTools = class {
612
- constructor(projectDir, registry, threadId, machine, mcp, onMcpCatalogChange) {
859
+ constructor(projectDir, registry, threadId, machine, mcp, onMcpCatalogChange, api) {
613
860
  this.projectDir = projectDir;
614
861
  this.registry = registry;
615
862
  this.threadId = threadId;
616
863
  this.machine = machine;
617
864
  this.mcp = mcp;
618
865
  this.onMcpCatalogChange = onMcpCatalogChange;
866
+ this.api = api;
619
867
  }
620
868
  projectDir;
621
869
  registry;
@@ -623,6 +871,7 @@ var HostTools = class {
623
871
  machine;
624
872
  mcp;
625
873
  onMcpCatalogChange;
874
+ api;
626
875
  /** Resolve a user/model-supplied path against the project directory. */
627
876
  resolve(p) {
628
877
  if (!p || p === ".") return this.projectDir;
@@ -639,8 +888,6 @@ var HostTools = class {
639
888
  switch (tool) {
640
889
  case "read_file":
641
890
  return await this.readFile(args);
642
- case "list_dir":
643
- return await this.listDir(args);
644
891
  case "grep":
645
892
  return await this.grep(args);
646
893
  case "glob":
@@ -651,8 +898,10 @@ var HostTools = class {
651
898
  return await this.editFile(args);
652
899
  case "bash":
653
900
  return await this.bash(args);
654
- case "delete":
655
- return await this.deletePath(args);
901
+ case "save_to_disk":
902
+ return await this.saveToDisk(args);
903
+ case "run_skill_script":
904
+ return await this.runSkillScript(args);
656
905
  case "background_process": {
657
906
  const action = String(args.action || "list");
658
907
  if (action === "start") return await this.runBackground(args);
@@ -695,15 +944,6 @@ var HostTools = class {
695
944
  const numbered = slice.map((l, i) => `${offset + i} ${l}`).join("\n");
696
945
  return { ok: true, result: numbered || "(empty file)" };
697
946
  }
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
947
  async grep(args) {
708
948
  const pattern = String(args.pattern || "");
709
949
  if (!pattern) return { ok: false, error: "pattern is required" };
@@ -735,7 +975,7 @@ ${lines.join("\n")}` };
735
975
  const file = this.resolve(String(args.path || ""));
736
976
  const content = String(args.content ?? "");
737
977
  await fsp.mkdir(path3.dirname(file), { recursive: true });
738
- const existed = fs2.existsSync(file);
978
+ const existed = fs4.existsSync(file);
739
979
  await fsp.writeFile(file, content, "utf8");
740
980
  return {
741
981
  ok: true,
@@ -760,16 +1000,125 @@ ${lines.join("\n")}` };
760
1000
  await fsp.writeFile(file, updated, "utf8");
761
1001
  return { ok: true, result: `Edited ${path3.relative(this.projectDir, file)} (${count} replacement${count === 1 ? "" : "s"})` };
762
1002
  }
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.` };
1003
+ /**
1004
+ * Copy a file from the THREAD filesystem (e.g. a generated /attachments/*
1005
+ * asset) onto the project disk: download the bytes from the instance API and
1006
+ * write them at the destination. The bytes never pass through the model.
1007
+ */
1008
+ async saveToDisk(args) {
1009
+ const source = String(args.source_path || "");
1010
+ const destArg = String(args.dest_path || "");
1011
+ if (!source || !destArg) return { ok: false, error: "source_path and dest_path are required" };
1012
+ if (!this.api || !this.threadId) {
1013
+ return { ok: false, error: "No instance API available for thread file downloads." };
770
1014
  }
771
- await fsp.rm(target, { recursive, force: false });
772
- return { ok: true, result: `Deleted ${path3.relative(this.projectDir, target) || target}` };
1015
+ const bytes = await this.api.fetchFile(this.threadId, source);
1016
+ if (!bytes) return { ok: false, error: `Could not download ${source} from the thread filesystem.` };
1017
+ const dest = this.resolve(destArg);
1018
+ await fsp.mkdir(path3.dirname(dest), { recursive: true });
1019
+ const existed = fs4.existsSync(dest);
1020
+ await fsp.writeFile(dest, bytes);
1021
+ return {
1022
+ ok: true,
1023
+ result: `${existed ? "Overwrote" : "Saved"} ${path3.relative(this.projectDir, dest)} (${bytes.length} bytes) from ${source}`
1024
+ };
1025
+ }
1026
+ /**
1027
+ * Execute a SKILL script on the host. Skills live in the instance (cloud is
1028
+ * the source of truth); the server forwards the skill's files with the call
1029
+ * and we materialize them into a content-addressed temp dir — nothing about
1030
+ * a skill is stored on this machine beyond an ephemeral cache. The script
1031
+ * runs with cwd = the PROJECT (so it can operate on the user's files) and
1032
+ * SKILL_DIR pointing at the materialized folder (so it can read its own
1033
+ * references/assets).
1034
+ */
1035
+ async runSkillScript(args) {
1036
+ const skill = String(args.skill || "skill");
1037
+ const entry = String(args.entry || "");
1038
+ if (!entry) return { ok: false, error: "entry (the script path within the skill) is required" };
1039
+ let files;
1040
+ try {
1041
+ const parsed = JSON.parse(String(args.files_json || "[]"));
1042
+ if (!Array.isArray(parsed)) throw new Error("not an array");
1043
+ files = parsed.map((f) => ({ path: String(f.path), content: String(f.content ?? "") }));
1044
+ } catch {
1045
+ return { ok: false, error: "files_json must be a JSON array of {path, content}" };
1046
+ }
1047
+ if (!files.some((f) => f.path === entry)) {
1048
+ return { ok: false, error: `entry "${entry}" is not among the provided skill files` };
1049
+ }
1050
+ let scriptArgs = [];
1051
+ if (args.args_json) {
1052
+ try {
1053
+ const parsed = JSON.parse(String(args.args_json));
1054
+ if (!Array.isArray(parsed)) throw new Error("not an array");
1055
+ scriptArgs = parsed.map(String);
1056
+ } catch {
1057
+ return { ok: false, error: "args_json must be a JSON array of strings" };
1058
+ }
1059
+ }
1060
+ const hash = crypto.createHash("sha256").update(JSON.stringify(files)).digest("hex").slice(0, 12);
1061
+ const skillDir = path3.join(os6.tmpdir(), "standardcode-skills", `${skill}-${hash}`);
1062
+ for (const f of files) {
1063
+ const dest = path3.resolve(skillDir, f.path);
1064
+ if (path3.relative(skillDir, dest).startsWith("..")) {
1065
+ return { ok: false, error: `Skill file escapes its directory: ${f.path}` };
1066
+ }
1067
+ await fsp.mkdir(path3.dirname(dest), { recursive: true });
1068
+ await fsp.writeFile(dest, f.content, "utf8");
1069
+ }
1070
+ const entryPath = path3.resolve(skillDir, entry);
1071
+ const entryContent = files.find((f) => f.path === entry).content;
1072
+ let cmd;
1073
+ let argv;
1074
+ if (entryContent.startsWith("#!")) {
1075
+ await fsp.chmod(entryPath, 493);
1076
+ cmd = entryPath;
1077
+ argv = scriptArgs;
1078
+ } else {
1079
+ const ext = path3.extname(entry).toLowerCase();
1080
+ const interp = {
1081
+ ".py": ["python3"],
1082
+ ".sh": ["bash"],
1083
+ ".js": ["node"],
1084
+ ".mjs": ["node"],
1085
+ ".ts": ["npx", "tsx"]
1086
+ };
1087
+ const found = interp[ext];
1088
+ if (!found) return { ok: false, error: `No interpreter for "${ext}" \u2014 add a shebang line to the script.` };
1089
+ cmd = found[0];
1090
+ argv = [...found.slice(1), entryPath, ...scriptArgs];
1091
+ }
1092
+ const timeoutMs = typeof args.timeout_ms === "number" ? Math.min(args.timeout_ms, 3e5) : 12e4;
1093
+ return await new Promise((resolvePromise) => {
1094
+ const child = spawn(cmd, argv, {
1095
+ cwd: this.projectDir,
1096
+ env: { ...process.env, SKILL_DIR: skillDir },
1097
+ stdio: ["ignore", "pipe", "pipe"]
1098
+ });
1099
+ let out = "";
1100
+ const cap = (chunk) => {
1101
+ if (out.length < 2e5) out += chunk.toString("utf8");
1102
+ };
1103
+ child.stdout.on("data", cap);
1104
+ child.stderr.on("data", cap);
1105
+ const timer = setTimeout(() => {
1106
+ child.kill("SIGKILL");
1107
+ resolvePromise({ ok: false, error: `Skill script timed out after ${timeoutMs}ms.
1108
+ ${out.slice(-4e3)}` });
1109
+ }, timeoutMs);
1110
+ child.on("error", (err) => {
1111
+ clearTimeout(timer);
1112
+ resolvePromise({ ok: false, error: `Could not launch ${cmd}: ${err.message}` });
1113
+ });
1114
+ child.on("exit", (code) => {
1115
+ clearTimeout(timer);
1116
+ const body = out.trim() || "(no output)";
1117
+ if (code === 0) resolvePromise({ ok: true, result: body });
1118
+ else resolvePromise({ ok: false, error: `Script exited with code ${code}:
1119
+ ${body.slice(-6e3)}` });
1120
+ });
1121
+ });
773
1122
  }
774
1123
  async bash(args) {
775
1124
  const command = String(args.command || "");
@@ -797,12 +1146,12 @@ ${truncated}`
797
1146
  const command = String(args.command || "");
798
1147
  if (!command.trim()) return { ok: false, error: "command is required" };
799
1148
  const cwd = args.cwd ? this.resolve(String(args.cwd)) : this.projectDir;
800
- const id = crypto2.randomUUID().slice(0, 8);
1149
+ const id = crypto.randomUUID().slice(0, 8);
801
1150
  const logPath = path3.join(LOG_DIR, `${id}.log`);
802
1151
  let out;
803
1152
  try {
804
1153
  await fsp.mkdir(LOG_DIR, { recursive: true });
805
- out = fs2.openSync(logPath, "a");
1154
+ out = fs4.openSync(logPath, "a");
806
1155
  } catch (err) {
807
1156
  return { ok: false, error: `Could not open log file: ${err instanceof Error ? err.message : String(err)}` };
808
1157
  }
@@ -810,10 +1159,10 @@ ${truncated}`
810
1159
  try {
811
1160
  child = spawn("bash", ["-lc", command], { cwd, detached: true, stdio: ["ignore", out, out] });
812
1161
  } catch (err) {
813
- fs2.closeSync(out);
1162
+ fs4.closeSync(out);
814
1163
  return { ok: false, error: `Failed to start: ${err instanceof Error ? err.message : String(err)}` };
815
1164
  }
816
- fs2.closeSync(out);
1165
+ fs4.closeSync(out);
817
1166
  const pid = child.pid;
818
1167
  if (!pid) return { ok: false, error: "Process failed to start (no pid)." };
819
1168
  let earlyExit;
@@ -973,6 +1322,39 @@ ${tail}` : " No output was captured.")
973
1322
  }
974
1323
  return { ok: false, error: `Unknown action: ${action}` };
975
1324
  }
1325
+ /**
1326
+ * Terminate every still-running background process this machine started, so
1327
+ * detached children don't outlive the CLI when the session quits. Sends
1328
+ * SIGTERM to each process group, waits a brief grace, then SIGKILL any
1329
+ * straggler, and records them stopped. Best effort and bounded so quitting
1330
+ * stays snappy. Returns the number of running processes it signaled.
1331
+ */
1332
+ async stopAllLocalProcesses() {
1333
+ if (!this.registry) return 0;
1334
+ let running = [];
1335
+ try {
1336
+ const procs = await this.registry.list();
1337
+ running = procs.filter((p) => p.status === "running" && p.machine === (this.machine ?? ""));
1338
+ } catch {
1339
+ return 0;
1340
+ }
1341
+ if (!running.length) return 0;
1342
+ const signal = (pid, sig) => {
1343
+ try {
1344
+ process.kill(-pid, sig);
1345
+ } catch {
1346
+ try {
1347
+ process.kill(pid, sig);
1348
+ } catch {
1349
+ }
1350
+ }
1351
+ };
1352
+ for (const proc of running) signal(proc.pid, "SIGTERM");
1353
+ await new Promise((r) => setTimeout(r, 300));
1354
+ for (const proc of running) signal(proc.pid, "SIGKILL");
1355
+ await Promise.allSettled(running.map((proc) => this.registry.markStopped(proc.id)));
1356
+ return running.length;
1357
+ }
976
1358
  run(cmd, args, cwd, timeoutMs) {
977
1359
  return new Promise((resolve) => {
978
1360
  let stdout = "";
@@ -1069,6 +1451,7 @@ var MessageStream = class {
1069
1451
  hooks;
1070
1452
  ws = null;
1071
1453
  closed = false;
1454
+ heartbeat = null;
1072
1455
  reconnectAttempt = 0;
1073
1456
  reconnectTimer = null;
1074
1457
  resolveConnected = null;
@@ -1093,7 +1476,7 @@ var MessageStream = class {
1093
1476
  }
1094
1477
  openSocket() {
1095
1478
  if (this.closed) return;
1096
- const url = `${this.api.wsEndpoint}/api/threads/${this.threadId}/stream?token=${encodeURIComponent(this.api.bearer)}`;
1479
+ const url = `${this.api.wsEndpoint}/api/threads/${this.threadId}/stream?token=${encodeURIComponent(this.api.bearer)}&reasoning=1`;
1097
1480
  let ws;
1098
1481
  try {
1099
1482
  ws = new WebSocket(url);
@@ -1104,17 +1487,33 @@ var MessageStream = class {
1104
1487
  this.ws = ws;
1105
1488
  ws.addEventListener("open", () => {
1106
1489
  this.reconnectAttempt = 0;
1490
+ this.startHeartbeat(ws);
1107
1491
  this.resolveConnected?.();
1108
1492
  });
1109
- ws.addEventListener("message", (ev) => this.onMessage(String(ev.data)));
1493
+ ws.addEventListener("message", (ev) => {
1494
+ if (this.ws === ws) this.heartbeat?.markAlive();
1495
+ this.onMessage(String(ev.data));
1496
+ });
1110
1497
  ws.addEventListener("error", () => this.handleDrop(ws));
1111
1498
  ws.addEventListener("close", () => this.handleDrop(ws));
1112
1499
  }
1113
1500
  handleDrop(ws) {
1114
1501
  if (this.ws !== ws) return;
1115
1502
  this.ws = null;
1503
+ this.stopHeartbeat();
1116
1504
  this.scheduleReconnect();
1117
1505
  }
1506
+ startHeartbeat(ws) {
1507
+ this.stopHeartbeat();
1508
+ this.heartbeat = new Heartbeat(ws, () => this.handleDrop(ws));
1509
+ this.heartbeat.start();
1510
+ }
1511
+ stopHeartbeat() {
1512
+ if (this.heartbeat) {
1513
+ this.heartbeat.stop();
1514
+ this.heartbeat = null;
1515
+ }
1516
+ }
1118
1517
  scheduleReconnect() {
1119
1518
  if (this.closed || this.reconnectTimer) return;
1120
1519
  this.reconnectAttempt++;
@@ -1127,6 +1526,7 @@ var MessageStream = class {
1127
1526
  }
1128
1527
  close() {
1129
1528
  this.closed = true;
1529
+ this.stopHeartbeat();
1130
1530
  if (this.reconnectTimer) {
1131
1531
  clearTimeout(this.reconnectTimer);
1132
1532
  this.reconnectTimer = null;
@@ -1145,7 +1545,11 @@ var MessageStream = class {
1145
1545
  return;
1146
1546
  }
1147
1547
  if (msg.type === "message_chunk" && (msg.depth ?? 0) === 0) {
1148
- if (typeof msg.chunk === "string") this.hooks.onChunk(msg.chunk);
1548
+ if (typeof msg.chunk === "string") this.hooks.onChunk(msg.chunk, msg.message_id);
1549
+ return;
1550
+ }
1551
+ if (msg.type === "reasoning_chunk" && (msg.depth ?? 0) === 0) {
1552
+ if (typeof msg.chunk === "string") this.hooks.onReasoningChunk?.(msg.chunk, msg.message_id);
1149
1553
  return;
1150
1554
  }
1151
1555
  if (msg.type === "message_data" && (msg.depth ?? 0) === 0) {
@@ -1172,6 +1576,7 @@ var SystemEvents = class {
1172
1576
  hooks;
1173
1577
  ws = null;
1174
1578
  closed = false;
1579
+ heartbeat = null;
1175
1580
  reconnectAttempt = 0;
1176
1581
  reconnectTimer = null;
1177
1582
  connect() {
@@ -1190,11 +1595,27 @@ var SystemEvents = class {
1190
1595
  this.ws = ws;
1191
1596
  ws.addEventListener("open", () => {
1192
1597
  this.reconnectAttempt = 0;
1598
+ this.startHeartbeat(ws);
1599
+ this.hooks.onOpen?.();
1600
+ });
1601
+ ws.addEventListener("message", (ev) => {
1602
+ if (this.ws === ws) this.heartbeat?.markAlive();
1603
+ this.onMessage(String(ev.data));
1193
1604
  });
1194
- ws.addEventListener("message", (ev) => this.onMessage(String(ev.data)));
1195
1605
  ws.addEventListener("error", () => this.handleDrop(ws));
1196
1606
  ws.addEventListener("close", () => this.handleDrop(ws));
1197
1607
  }
1608
+ startHeartbeat(ws) {
1609
+ this.stopHeartbeat();
1610
+ this.heartbeat = new Heartbeat(ws, () => this.handleDrop(ws));
1611
+ this.heartbeat.start();
1612
+ }
1613
+ stopHeartbeat() {
1614
+ if (this.heartbeat) {
1615
+ this.heartbeat.stop();
1616
+ this.heartbeat = null;
1617
+ }
1618
+ }
1198
1619
  onMessage(raw) {
1199
1620
  let msg;
1200
1621
  try {
@@ -1211,6 +1632,7 @@ var SystemEvents = class {
1211
1632
  handleDrop(ws) {
1212
1633
  if (this.ws !== ws) return;
1213
1634
  this.ws = null;
1635
+ this.stopHeartbeat();
1214
1636
  this.scheduleReconnect();
1215
1637
  }
1216
1638
  scheduleReconnect() {
@@ -1225,6 +1647,7 @@ var SystemEvents = class {
1225
1647
  }
1226
1648
  close() {
1227
1649
  this.closed = true;
1650
+ this.stopHeartbeat();
1228
1651
  if (this.reconnectTimer) {
1229
1652
  clearTimeout(this.reconnectTimer);
1230
1653
  this.reconnectTimer = null;
@@ -1245,8 +1668,98 @@ var LEVEL_DETAIL = {
1245
1668
  function levelLabel(level) {
1246
1669
  return `auto-accept level ${level} (${LEVEL_DETAIL[level]})`;
1247
1670
  }
1671
+ var FILE_MIMES = {
1672
+ ".png": "image/png",
1673
+ ".jpg": "image/jpeg",
1674
+ ".jpeg": "image/jpeg",
1675
+ ".gif": "image/gif",
1676
+ ".webp": "image/webp"
1677
+ };
1678
+ var MAX_IMAGE_BYTES = 8 * 1024 * 1024;
1679
+ function run(cmd, args, maxBuffer = MAX_IMAGE_BYTES * 2) {
1680
+ return new Promise((resolve) => {
1681
+ execFile(cmd, args, { encoding: "buffer", maxBuffer }, (err, stdout) => {
1682
+ resolve({ ok: !err, stdout: stdout ?? Buffer.alloc(0) });
1683
+ });
1684
+ });
1685
+ }
1686
+ function fromFile(filePath) {
1687
+ const mime = FILE_MIMES[path3.extname(filePath).toLowerCase()];
1688
+ if (!mime) return null;
1689
+ try {
1690
+ const stat = fs4.statSync(filePath);
1691
+ if (!stat.isFile() || stat.size === 0 || stat.size > MAX_IMAGE_BYTES) return null;
1692
+ return { data: fs4.readFileSync(filePath).toString("base64"), mime };
1693
+ } catch {
1694
+ return null;
1695
+ }
1696
+ }
1697
+ async function readDarwin() {
1698
+ const tmp = path3.join(os6.tmpdir(), `sc-clip-${process.pid}-${Date.now()}.png`);
1699
+ const script = [
1700
+ `set d to the clipboard as \xABclass PNGf\xBB`,
1701
+ `set f to open for access POSIX file "${tmp}" with write permission`,
1702
+ `set eof f to 0`,
1703
+ `write d to f`,
1704
+ `close access f`
1705
+ ].join("\n");
1706
+ const png = await run("osascript", ["-e", script]);
1707
+ if (png.ok) {
1708
+ const img = fromFile(tmp);
1709
+ try {
1710
+ fs4.unlinkSync(tmp);
1711
+ } catch {
1712
+ }
1713
+ if (img) return img;
1714
+ }
1715
+ const furl = await run("osascript", ["-e", "POSIX path of (the clipboard as \xABclass furl\xBB)"]);
1716
+ if (furl.ok) {
1717
+ const p = furl.stdout.toString("utf8").trim();
1718
+ if (p) return fromFile(p);
1719
+ }
1720
+ return null;
1721
+ }
1722
+ async function readLinux() {
1723
+ for (const [cmd, args] of [
1724
+ ["wl-paste", ["--type", "image/png"]],
1725
+ ["xclip", ["-selection", "clipboard", "-t", "image/png", "-o"]]
1726
+ ]) {
1727
+ const res = await run(cmd, args);
1728
+ if (res.ok && res.stdout.length > 8 && res.stdout.length <= MAX_IMAGE_BYTES && res.stdout[0] === 137 && res.stdout[1] === 80) {
1729
+ return { data: res.stdout.toString("base64"), mime: "image/png" };
1730
+ }
1731
+ }
1732
+ return null;
1733
+ }
1734
+ async function readWindows() {
1735
+ const tmp = path3.join(os6.tmpdir(), `sc-clip-${process.pid}-${Date.now()}.png`);
1736
+ const ps = [
1737
+ "Add-Type -AssemblyName System.Windows.Forms;",
1738
+ "$img = [System.Windows.Forms.Clipboard]::GetImage();",
1739
+ `if ($img -ne $null) { $img.Save('${tmp.replace(/'/g, "''")}', [System.Drawing.Imaging.ImageFormat]::Png) }`
1740
+ ].join(" ");
1741
+ await run("powershell", ["-NoProfile", "-STA", "-Command", ps]);
1742
+ const img = fromFile(tmp);
1743
+ try {
1744
+ fs4.unlinkSync(tmp);
1745
+ } catch {
1746
+ }
1747
+ return img;
1748
+ }
1749
+ async function readClipboardImage() {
1750
+ try {
1751
+ if (process.platform === "darwin") return await readDarwin();
1752
+ if (process.platform === "win32") return await readWindows();
1753
+ return await readLinux();
1754
+ } catch {
1755
+ return null;
1756
+ }
1757
+ }
1248
1758
 
1249
1759
  // src/tui.ts
1760
+ function imagePlaceholder(seq) {
1761
+ return `[#Image ${seq}]`;
1762
+ }
1250
1763
  var C = {
1251
1764
  reset: "\x1B[0m",
1252
1765
  dim: "\x1B[2m",
@@ -1261,7 +1774,23 @@ var C = {
1261
1774
  teal: "\x1B[38;5;37m"
1262
1775
  };
1263
1776
  var FRAMES = ["\u28F7", "\u28EF", "\u28DF", "\u287F", "\u28BF", "\u28FB", "\u28FD", "\u28FE"];
1264
- var Tui = class {
1777
+ var SUBAGENT_COLORS = [
1778
+ "\x1B[35m",
1779
+ // magenta
1780
+ "\x1B[38;5;39m",
1781
+ // azure
1782
+ "\x1B[38;5;75m",
1783
+ // blue
1784
+ "\x1B[32m",
1785
+ // green
1786
+ "\x1B[38;5;141m",
1787
+ // violet
1788
+ "\x1B[38;5;177m"
1789
+ // orchid
1790
+ ];
1791
+ var COMPACTION_COLOR = "\x1B[38;5;208m";
1792
+ var COMPACTION_AGENT = "compaction_agent";
1793
+ var Tui = class _Tui {
1265
1794
  constructor(level = 1) {
1266
1795
  this.level = level;
1267
1796
  readline.emitKeypressEvents(process.stdin);
@@ -1270,6 +1799,7 @@ var Tui = class {
1270
1799
  process.stdin.resume();
1271
1800
  process.stdout.write("\x1B[?2004h");
1272
1801
  process.on("exit", () => process.stdout.write("\x1B[?2004l\x1B[?25h"));
1802
+ process.stdout.on("resize", () => this.renderBottom());
1273
1803
  }
1274
1804
  level;
1275
1805
  // input + indicators
@@ -1284,13 +1814,31 @@ var Tui = class {
1284
1814
  bgCount = 0;
1285
1815
  queuedCount = 0;
1286
1816
  subagents = [];
1287
- // labels of subagents currently working (one line each)
1817
+ // active subagents (one line each)
1818
+ subagentColorByID = /* @__PURE__ */ new Map();
1819
+ // subagent id → SUBAGENT_COLORS index
1288
1820
  tokensIn = 0;
1289
1821
  // cumulative input tokens
1290
1822
  tokensOut = 0;
1291
1823
  // cumulative output tokens (includes the in-progress live count)
1292
1824
  contextPct = null;
1293
1825
  // % of the model context window currently used
1826
+ // Live streaming preview, shown just above the status line while a turn runs:
1827
+ // the model's internal reasoning (dim italic) until the answer starts, then the
1828
+ // answer text (plain). Bounded to a tail; cleared when the message commits.
1829
+ streamThinking = "";
1830
+ streamResponse = "";
1831
+ streamMessageId = null;
1832
+ // the message currently previewing
1833
+ streamRedrawTimer = null;
1834
+ streamIdleTimer = null;
1835
+ // wipes a stale preview
1836
+ // Live goal checklist, fixed below the status bar. Driven by the goal_updated
1837
+ // thread event (+ an initial fetch). null = nothing to show.
1838
+ goal = null;
1839
+ // Set true the moment every step of a goal is done: the goal area is cleared
1840
+ // and the status line shows "Goal complete." until the next turn starts.
1841
+ goalComplete = false;
1294
1842
  // Inline slash-command palette: when the input starts with "/", the filtered
1295
1843
  // command list renders above the prompt and arrows/enter/tab drive it.
1296
1844
  commands = [];
@@ -1305,6 +1853,14 @@ var Tui = class {
1305
1853
  connected = true;
1306
1854
  bottomDrawn = false;
1307
1855
  started = false;
1856
+ // Resize bookkeeping: the width the region was last drawn at, and the visible
1857
+ // width of every HUD row written above the input. When the terminal is
1858
+ // resized, previously drawn rows re-wrap (a full-width ruler becomes 2+ rows
1859
+ // when narrowed), so the move-up count recorded at draw time is wrong — these
1860
+ // let moveToRegionTop recompute the region height under the NEW wrap instead
1861
+ // of leaving stale rulers behind.
1862
+ lastDrawnCols = 0;
1863
+ drawnHudWidths = [];
1308
1864
  // takeover (approval / menu) state
1309
1865
  takeoverHandler = null;
1310
1866
  bufferedPrints = [];
@@ -1315,13 +1871,28 @@ var Tui = class {
1315
1871
  // bracketed-paste state
1316
1872
  pasting = false;
1317
1873
  pasteTimer = null;
1874
+ // Images pasted into the CURRENT input (Ctrl+V). Each got a `[#Image N]`
1875
+ // placeholder at the caret; on submit only images whose placeholder is still
1876
+ // present in the text are handed to onSubmit. Cleared with the input.
1877
+ pendingImages = [];
1878
+ imagePasteBusy = false;
1879
+ // one clipboard read at a time
1880
+ // Sent-message history for ↑/↓ recall (oldest → newest). `historyIdx` is the
1881
+ // entry currently shown (null = not browsing); the in-progress draft is
1882
+ // stashed so cycling past the newest entry restores it. Any edit exits
1883
+ // browsing and keeps the recalled text as the new draft.
1884
+ history = [];
1885
+ historyIdx = null;
1886
+ historyDraft = "";
1887
+ historyDraftImages = [];
1318
1888
  // event hooks (wired by index.ts)
1319
1889
  onSubmit = () => {
1320
1890
  };
1321
1891
  onInterrupt = () => {
1322
1892
  };
1323
- onUpArrow = () => {
1324
- };
1893
+ /** Up on the top row: return true to consume it (e.g. pull a queued message)
1894
+ * before history recall gets a chance. */
1895
+ onUpArrow = () => false;
1325
1896
  onQuit = () => process.exit(0);
1326
1897
  levelListeners = [];
1327
1898
  get colors() {
@@ -1356,6 +1927,8 @@ var Tui = class {
1356
1927
  end() {
1357
1928
  if (this.quitTimer) clearTimeout(this.quitTimer);
1358
1929
  this.quitTimer = null;
1930
+ if (this.streamIdleTimer) clearTimeout(this.streamIdleTimer);
1931
+ this.streamIdleTimer = null;
1359
1932
  this.clearBottom();
1360
1933
  process.stdout.write("\x1B[?2004l\x1B[?25h");
1361
1934
  }
@@ -1368,6 +1941,11 @@ var Tui = class {
1368
1941
  }
1369
1942
  // ─── key dispatch ──────────────────────────────────────────────────────────
1370
1943
  dispatch(str, key) {
1944
+ const seq = key && key.sequence || str || "";
1945
+ if (seq === "\n" || seq === "\x1B[13;2u" || seq === "\x1B[27;2;13~") {
1946
+ this.insertAtCursor("\n");
1947
+ return;
1948
+ }
1371
1949
  if (key && key.ctrl && key.name === "c") {
1372
1950
  this.requestQuit();
1373
1951
  return;
@@ -1376,7 +1954,6 @@ var Tui = class {
1376
1954
  this.cycleLevel();
1377
1955
  return;
1378
1956
  }
1379
- const seq = key && key.sequence || str || "";
1380
1957
  if (!this.pasting && seq.includes("\x1B[200~")) {
1381
1958
  this.pasting = true;
1382
1959
  this.armPasteSafety();
@@ -1420,6 +1997,10 @@ var Tui = class {
1420
1997
  return;
1421
1998
  }
1422
1999
  if (key.name === "return" || key.name === "enter") {
2000
+ if (key.shift) {
2001
+ this.insertAtCursor("\n");
2002
+ return;
2003
+ }
1423
2004
  if (matches.length) this.runCommand(matches[cur]);
1424
2005
  return;
1425
2006
  }
@@ -1460,15 +2041,46 @@ var Tui = class {
1460
2041
  return;
1461
2042
  }
1462
2043
  if (key.name === "up") {
1463
- this.onUpArrow();
2044
+ const { caretRow } = this.inputLayout();
2045
+ if (caretRow > 0) {
2046
+ this.moveCaretVertical(-1);
2047
+ return;
2048
+ }
2049
+ if (this.onUpArrow()) return;
2050
+ this.historyPrev();
2051
+ return;
2052
+ }
2053
+ if (key.name === "down") {
2054
+ const { caretRow, rowCount } = this.inputLayout();
2055
+ if (caretRow < rowCount - 1) {
2056
+ this.moveCaretVertical(1);
2057
+ return;
2058
+ }
2059
+ this.historyNext();
1464
2060
  return;
1465
2061
  }
1466
2062
  if (key.name === "return" || key.name === "enter") {
2063
+ if (key.shift) {
2064
+ this.insertAtCursor("\n");
2065
+ return;
2066
+ }
1467
2067
  const text = this.inputBuffer;
2068
+ const images = this.pendingImages.filter((img) => text.includes(imagePlaceholder(img.seq)));
1468
2069
  this.inputBuffer = "";
1469
2070
  this.cursorPos = 0;
2071
+ this.pendingImages = [];
2072
+ this.historyIdx = null;
2073
+ this.historyDraft = "";
2074
+ this.historyDraftImages = [];
1470
2075
  this.renderBottom();
1471
- if (text.trim()) this.onSubmit(text.trim());
2076
+ if (text.trim()) {
2077
+ this.addHistoryEntry(text.trim());
2078
+ this.onSubmit(text.trim(), images);
2079
+ }
2080
+ return;
2081
+ }
2082
+ if (key.ctrl && key.name === "v") {
2083
+ void this.pasteClipboardImage();
1472
2084
  return;
1473
2085
  }
1474
2086
  if (key.name === "backspace") {
@@ -1476,6 +2088,7 @@ var Tui = class {
1476
2088
  this.inputBuffer = this.inputBuffer.slice(0, this.cursorPos - 1) + this.inputBuffer.slice(this.cursorPos);
1477
2089
  this.cursorPos--;
1478
2090
  this.slashIdx = 0;
2091
+ this.historyIdx = null;
1479
2092
  this.renderBottom();
1480
2093
  }
1481
2094
  return;
@@ -1484,6 +2097,7 @@ var Tui = class {
1484
2097
  if (this.cursorPos < this.inputBuffer.length) {
1485
2098
  this.inputBuffer = this.inputBuffer.slice(0, this.cursorPos) + this.inputBuffer.slice(this.cursorPos + 1);
1486
2099
  this.slashIdx = 0;
2100
+ this.historyIdx = null;
1487
2101
  this.renderBottom();
1488
2102
  }
1489
2103
  return;
@@ -1497,6 +2111,7 @@ var Tui = class {
1497
2111
  this.inputBuffer = this.inputBuffer.slice(0, this.cursorPos) + text + this.inputBuffer.slice(this.cursorPos);
1498
2112
  this.cursorPos += text.length;
1499
2113
  this.slashIdx = 0;
2114
+ this.historyIdx = null;
1500
2115
  this.renderBottom();
1501
2116
  }
1502
2117
  /** Insert a paste fragment at the caret; collapse newlines (single-line input). */
@@ -1506,6 +2121,7 @@ var Tui = class {
1506
2121
  if (content) {
1507
2122
  this.inputBuffer = this.inputBuffer.slice(0, this.cursorPos) + content + this.inputBuffer.slice(this.cursorPos);
1508
2123
  this.cursorPos += content.length;
2124
+ this.historyIdx = null;
1509
2125
  }
1510
2126
  if (end >= 0) {
1511
2127
  this.pasting = false;
@@ -1525,6 +2141,128 @@ var Tui = class {
1525
2141
  this.renderBottom();
1526
2142
  }, 2e3);
1527
2143
  }
2144
+ // ─── input layout + vertical caret movement ────────────────────────────────
2145
+ /**
2146
+ * The input's physical rows (same wrapping math as renderBottom: logical
2147
+ * lines split on "\n", line 0 led by the prompt prefix, each wrapping at the
2148
+ * terminal width) plus where the caret sits among them. Each row records the
2149
+ * buffer index of its first character, its character count, and the visual
2150
+ * column its first character renders at (only row 0 is offset, by the
2151
+ * prompt). Drives ↑/↓: row 0 is "the top line" (history recall territory),
2152
+ * anything below moves the caret instead.
2153
+ */
2154
+ inputLayout() {
2155
+ const cols2 = process.stdout.columns || 80;
2156
+ const pw = this.visibleWidth(this.promptPrefix());
2157
+ const lines = this.inputBuffer.split("\n");
2158
+ const rows = [];
2159
+ let offset = 0;
2160
+ for (let i = 0; i < lines.length; i++) {
2161
+ const lead = i === 0 ? pw : 0;
2162
+ const len = lines[i].length;
2163
+ const nRows = Math.max(1, Math.ceil((lead + len) / cols2));
2164
+ for (let j = 0; j < nRows; j++) {
2165
+ const charStart = Math.max(0, j * cols2 - lead);
2166
+ const charEnd = Math.min(len, (j + 1) * cols2 - lead);
2167
+ rows.push({ start: offset + charStart, len: Math.max(0, charEnd - charStart), colOffset: j === 0 ? lead : 0 });
2168
+ }
2169
+ offset += len + 1;
2170
+ }
2171
+ let pos = this.cursorPos;
2172
+ let caretLine = 0;
2173
+ while (caretLine < lines.length - 1 && pos > lines[caretLine].length) {
2174
+ pos -= lines[caretLine].length + 1;
2175
+ caretLine++;
2176
+ }
2177
+ const caretCell = (caretLine === 0 ? pw : 0) + pos;
2178
+ let caretRow = Math.floor(caretCell / cols2);
2179
+ for (let i = 0; i < caretLine; i++) {
2180
+ const lead = i === 0 ? pw : 0;
2181
+ caretRow += Math.max(1, Math.ceil((lead + lines[i].length) / cols2));
2182
+ }
2183
+ const caretCol = caretCell % cols2;
2184
+ while (caretRow >= rows.length) rows.push({ start: this.inputBuffer.length, len: 0, colOffset: 0 });
2185
+ return { rows, caretRow, caretCol, rowCount: rows.length };
2186
+ }
2187
+ /** Move the caret one visual row up/down, keeping the column when possible. */
2188
+ moveCaretVertical(delta) {
2189
+ const { rows, caretRow, caretCol } = this.inputLayout();
2190
+ const target = caretRow + delta;
2191
+ if (target < 0 || target >= rows.length) return;
2192
+ const row = rows[target];
2193
+ const within = Math.max(0, Math.min(caretCol - row.colOffset, row.len));
2194
+ this.cursorPos = Math.min(row.start + within, this.inputBuffer.length);
2195
+ this.renderBottom();
2196
+ }
2197
+ // ─── sent-message history (↑/↓ recall) ─────────────────────────────────────
2198
+ /** Seed the recall history (oldest → newest), e.g. from the on-disk file. */
2199
+ setHistory(entries) {
2200
+ this.history = entries.filter((e) => e.trim() !== "");
2201
+ }
2202
+ /** Record a sent message (skipping a consecutive duplicate). */
2203
+ addHistoryEntry(text) {
2204
+ if (this.history[this.history.length - 1] === text) return;
2205
+ this.history.push(text);
2206
+ }
2207
+ /** Recall the previous (older) history entry; stashes the draft first. */
2208
+ historyPrev() {
2209
+ if (!this.history.length) return;
2210
+ if (this.historyIdx === null) {
2211
+ this.historyDraft = this.inputBuffer;
2212
+ this.historyDraftImages = this.pendingImages;
2213
+ this.historyIdx = this.history.length - 1;
2214
+ } else if (this.historyIdx > 0) {
2215
+ this.historyIdx--;
2216
+ } else {
2217
+ return;
2218
+ }
2219
+ this.pendingImages = [];
2220
+ this.inputBuffer = this.history[this.historyIdx];
2221
+ this.cursorPos = this.inputBuffer.length;
2222
+ this.slashIdx = 0;
2223
+ this.renderBottom();
2224
+ }
2225
+ /** Step toward the newest entry; past it, restore the stashed draft. */
2226
+ historyNext() {
2227
+ if (this.historyIdx === null) return;
2228
+ if (this.historyIdx < this.history.length - 1) {
2229
+ this.historyIdx++;
2230
+ this.pendingImages = [];
2231
+ this.inputBuffer = this.history[this.historyIdx];
2232
+ } else {
2233
+ this.historyIdx = null;
2234
+ this.inputBuffer = this.historyDraft;
2235
+ this.pendingImages = this.historyDraftImages;
2236
+ this.historyDraft = "";
2237
+ this.historyDraftImages = [];
2238
+ }
2239
+ this.cursorPos = this.inputBuffer.length;
2240
+ this.slashIdx = 0;
2241
+ this.renderBottom();
2242
+ }
2243
+ // ─── clipboard image paste (Ctrl+V) ────────────────────────────────────────
2244
+ /**
2245
+ * Read an image off the system clipboard and drop an `[#Image N]`
2246
+ * placeholder at the caret. The bytes ride along with the message on submit
2247
+ * (as a real attachment) as long as the placeholder is still in the text —
2248
+ * delete the placeholder and the image is dropped too.
2249
+ */
2250
+ async pasteClipboardImage() {
2251
+ if (this.imagePasteBusy) return;
2252
+ this.imagePasteBusy = true;
2253
+ try {
2254
+ const img = await readClipboardImage();
2255
+ if (!img) {
2256
+ this.print(`${C.dim}No image on the clipboard.${C.reset}`);
2257
+ return;
2258
+ }
2259
+ const seq = this.pendingImages.reduce((m, i) => Math.max(m, i.seq), 0) + 1;
2260
+ this.pendingImages.push({ seq, data: img.data, mime: img.mime });
2261
+ this.insertAtCursor(imagePlaceholder(seq));
2262
+ } finally {
2263
+ this.imagePasteBusy = false;
2264
+ }
2265
+ }
1528
2266
  cycleLevel() {
1529
2267
  const idx = LEVELS.indexOf(this.level);
1530
2268
  this.setLevel(LEVELS[(idx + 1) % LEVELS.length]);
@@ -1571,7 +2309,8 @@ var Tui = class {
1571
2309
  let out = parts.length ? `${C.gray}${parts.join(" ")}${C.reset}` : "";
1572
2310
  if (this.contextPct != null) {
1573
2311
  const pct = this.contextPct;
1574
- const col = pct >= 85 ? C.red : pct >= 70 ? C.yellow : C.green;
2312
+ const calmGreen = "\x1B[38;5;65m";
2313
+ const col = pct >= 85 ? C.red : pct >= 70 ? C.yellow : calmGreen;
1575
2314
  const gauge = `${col}ctx ${pct}%${C.reset}`;
1576
2315
  out = out ? `${out} ${gauge}` : gauge;
1577
2316
  }
@@ -1623,13 +2362,13 @@ var Tui = class {
1623
2362
  * token totals visible (`↑in ↓out`) so they live in the summary rather than
1624
2363
  * crowding the prompt. Null when idle with nothing counted yet.
1625
2364
  */
1626
- statusLineText(cols) {
2365
+ statusLineText(cols2) {
1627
2366
  const tk = this.tokensText();
1628
2367
  if (this.working) {
1629
2368
  const el = this.formatElapsed(Date.now() - this.workingStart);
1630
2369
  const right = `${C.dim}${el}${C.reset}${tk ? " " + tk : ""}`;
1631
2370
  const head = `${this.spinnerFrame()} ${C.bold}Working${C.reset}`;
1632
- const avail = Math.max(0, cols - this.visibleWidth(head) - this.visibleWidth(right) - 2);
2371
+ const avail = Math.max(0, cols2 - this.visibleWidth(head) - this.visibleWidth(right) - 2);
1633
2372
  let stepPart = "";
1634
2373
  if (this.step && avail > 1) {
1635
2374
  let s = this.step;
@@ -1638,23 +2377,43 @@ var Tui = class {
1638
2377
  }
1639
2378
  return `${head}${stepPart} ${right}`;
1640
2379
  }
2380
+ if (this.goalComplete) {
2381
+ const done = `${C.bold}${C.green}\u2713 Goal complete.${C.reset}`;
2382
+ return tk ? `${done} ${tk}` : done;
2383
+ }
1641
2384
  return tk ? tk : null;
1642
2385
  }
1643
2386
  /** The prompt line prefix (with ANSI colour) that precedes the typed text. */
1644
2387
  promptPrefix() {
1645
2388
  const q = this.queuedCount > 0 ? `${C.yellow}[\u23F3 ${this.queuedCount} queued]${C.reset} ` : "";
1646
2389
  const bg = this.bgCount > 0 ? `${C.cyan}[\u2699 ${this.bgCount} bg]${C.reset} ` : "";
1647
- return `${q}${bg}${this.levelColor()}\u203A${C.reset} `;
2390
+ return `${q}${bg}${this.levelColor()}\u276F${C.reset} `;
1648
2391
  }
1649
2392
  visibleWidth(s) {
1650
2393
  return s.replace(/\x1b\[[0-9;]*m/g, "").length;
1651
2394
  }
2395
+ /**
2396
+ * The slash-palette block rendered BELOW the input. While the palette is open
2397
+ * we reserve a fixed number of rows — one per available command — padding with
2398
+ * blank rows so the region height never changes as the filter narrows. That
2399
+ * pins the input: the region scrolls into place once when the palette opens,
2400
+ * then nothing below the input resizes, so the text you're typing never jumps.
2401
+ * Returns `[]` when the palette is closed (no reservation, input sits at the
2402
+ * bottom as usual).
2403
+ */
2404
+ paletteBlockLines(cols2) {
2405
+ if (!this.paletteOpen()) return [];
2406
+ const rows = this.paletteLines(cols2);
2407
+ const reserved = Math.max(this.commands.length, rows.length);
2408
+ while (rows.length < reserved) rows.push("");
2409
+ return rows;
2410
+ }
1652
2411
  /**
1653
2412
  * Build the slash-palette rows for the current filter. Each row is clamped to
1654
2413
  * ONE physical line (a wrapped row would desync the move-up redraw), with the
1655
2414
  * `/name` highlighted, the label dimmed, and the hint right-aligned.
1656
2415
  */
1657
- paletteLines(cols) {
2416
+ paletteLines(cols2) {
1658
2417
  if (!this.paletteOpen()) return [];
1659
2418
  const matches = this.filteredCommands();
1660
2419
  if (matches.length === 0) return [` ${C.gray}no matching command${C.reset}`];
@@ -1666,23 +2425,42 @@ var Tui = class {
1666
2425
  const hintW = hint.length;
1667
2426
  const name = `/${cmd.name}`;
1668
2427
  let visible = `${name} ${cmd.label}`;
1669
- const labelMax = Math.max(6, cols - pointerW - (hintW ? hintW + 2 : 0));
2428
+ const labelMax = Math.max(6, cols2 - pointerW - (hintW ? hintW + 2 : 0));
1670
2429
  if (visible.length > labelMax) visible = visible.slice(0, labelMax - 1) + "\u2026";
1671
2430
  const desc = visible.slice(name.length);
1672
2431
  const pointer = sel ? `${C.magenta}\u276F${C.reset} ` : " ";
1673
2432
  const nameStyled = sel ? `${C.bold}${C.cyan}${name}${C.reset}` : `${C.cyan}${name}${C.reset}`;
1674
2433
  let line = `${pointer}${nameStyled}${C.gray}${desc}${C.reset}`;
1675
2434
  if (hintW) {
1676
- const gap = Math.max(2, cols - pointerW - visible.length - hintW);
2435
+ const gap = Math.max(2, cols2 - pointerW - visible.length - hintW);
1677
2436
  line += `${" ".repeat(gap)}${C.gray}${hint}${C.reset}`;
1678
2437
  }
1679
2438
  return line;
1680
2439
  });
1681
2440
  }
1682
- /** Move the cursor to the top-left of the current bottom region. */
2441
+ /**
2442
+ * Move the cursor to the top-left of the current bottom region.
2443
+ *
2444
+ * Same width as the last draw → the caret's recorded row offset is exact.
2445
+ * Width CHANGED (terminal resized) → previously drawn rows re-wrapped, so
2446
+ * that offset is stale; recompute it under the new wrap instead: each drawn
2447
+ * HUD row of visible width w now occupies ceil(w / cols) physical rows
2448
+ * (reflowing terminals re-wrap hard lines; the cursor follows its logical
2449
+ * position in the input text, which inputLayout locates at the new width).
2450
+ */
1683
2451
  moveToRegionTop() {
1684
2452
  process.stdout.write("\r");
1685
- if (this.bottomDrawn && this.lastCursorRow > 0) process.stdout.write(`\x1B[${this.lastCursorRow}A`);
2453
+ if (!this.bottomDrawn) return;
2454
+ const cols2 = process.stdout.columns || 80;
2455
+ let up;
2456
+ if (cols2 !== this.lastDrawnCols && this.lastDrawnCols > 0) {
2457
+ let above = 0;
2458
+ for (const w of this.drawnHudWidths) above += Math.max(1, Math.ceil(Math.max(w, 1) / cols2));
2459
+ up = above + this.inputLayout().caretRow;
2460
+ } else {
2461
+ up = this.lastCursorRow;
2462
+ }
2463
+ if (up > 0) process.stdout.write(`\x1B[${up}A`);
1686
2464
  }
1687
2465
  /**
1688
2466
  * Render the bottom region: an optional step line, then the prompt + input
@@ -1692,43 +2470,77 @@ var Tui = class {
1692
2470
  */
1693
2471
  renderBottom() {
1694
2472
  if (!this.started || this.takeoverHandler) return;
1695
- const cols = process.stdout.columns || 80;
2473
+ const cols2 = process.stdout.columns || 80;
1696
2474
  this.moveToRegionTop();
1697
2475
  process.stdout.write("\x1B[J");
2476
+ const hudWidths = [];
2477
+ const writeHudRow = (line) => {
2478
+ hudWidths.push(this.visibleWidth(line));
2479
+ process.stdout.write(line + "\r\n");
2480
+ };
2481
+ const previewLines = this.streamPreviewLines(cols2);
2482
+ for (const line of previewLines) writeHudRow(line);
2483
+ const rulerRows = 1;
2484
+ writeHudRow(`${C.dim}${"\u2500".repeat(cols2)}${C.reset}`);
1698
2485
  const noticeLine = this.connected ? null : `${C.yellow}\u26A0 lost connection to the workspace \u2014 reconnecting\u2026${C.reset}`;
1699
2486
  const noticeRows = noticeLine ? 1 : 0;
1700
- if (noticeLine) process.stdout.write(noticeLine + "\r\n");
2487
+ if (noticeLine) writeHudRow(noticeLine);
1701
2488
  const quitLine = this.quitArmed ? `${C.dim}Press Control-C again to exit${C.reset}` : null;
1702
2489
  const quitRows = quitLine ? 1 : 0;
1703
- if (quitLine) process.stdout.write(quitLine + "\r\n");
2490
+ if (quitLine) writeHudRow(quitLine);
1704
2491
  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");
2492
+ for (const sub of this.subagents) {
2493
+ const color = sub.agentName === COMPACTION_AGENT ? COMPACTION_COLOR : SUBAGENT_COLORS[this.subagentColorByID.get(sub.id) ?? 0];
2494
+ const budget = cols2 - 10;
2495
+ let label = sub.label;
2496
+ if (budget < 1) label = "";
2497
+ else if (label.length > budget) label = label.slice(0, Math.max(0, budget - 1)) + "\u2026";
2498
+ const line = `${color}${frame}${C.reset} ${color}${label}${C.reset} ${C.dim}working${C.reset}`;
2499
+ writeHudRow(line);
1708
2500
  }
1709
- const statusLine = this.statusLineText(cols);
2501
+ const statusLine = this.statusLineText(cols2);
1710
2502
  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;
2503
+ if (statusLine) writeHudRow(statusLine);
2504
+ const goalLines = this.goalLines(cols2);
2505
+ for (const line of goalLines) writeHudRow(line);
2506
+ const aboveRows = previewLines.length + rulerRows + noticeRows + quitRows + this.subagents.length + statusRows + goalLines.length;
1715
2507
  const prefix = this.promptPrefix();
1716
2508
  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;
2509
+ const lines = this.inputBuffer.split("\n");
2510
+ process.stdout.write(prefix + lines[0]);
2511
+ for (let i = 1; i < lines.length; i++) process.stdout.write("\r\n" + lines[i]);
2512
+ const rowsOf = (len, lead) => Math.max(1, Math.ceil((lead + len) / cols2));
2513
+ const lineRows = lines.map((l, i) => rowsOf(l.length, i === 0 ? pw : 0));
2514
+ const inputRows = lineRows.reduce((a, b) => a + b, 0);
2515
+ let pos = this.cursorPos;
2516
+ let caretLine = 0;
2517
+ while (caretLine < lines.length - 1 && pos > lines[caretLine].length) {
2518
+ pos -= lines[caretLine].length + 1;
2519
+ caretLine++;
2520
+ }
2521
+ const caretCell = (caretLine === 0 ? pw : 0) + pos;
2522
+ let caretRow = Math.floor(caretCell / cols2);
2523
+ for (let i = 0; i < caretLine; i++) caretRow += lineRows[i];
2524
+ const caretCol = caretCell % cols2;
2525
+ const paletteBlock = this.paletteBlockLines(cols2);
2526
+ for (const line of paletteBlock) process.stdout.write("\r\n" + line);
2527
+ if (paletteBlock.length > 0) {
2528
+ process.stdout.write("\r");
2529
+ const up = inputRows - 1 + paletteBlock.length - caretRow;
2530
+ if (up > 0) process.stdout.write(`\x1B[${up}A`);
2531
+ if (caretCol > 0) process.stdout.write(`\x1B[${caretCol}C`);
2532
+ this.lastCursorRow = aboveRows + caretRow;
2533
+ } else if (this.cursorPos < this.inputBuffer.length) {
1724
2534
  process.stdout.write("\r");
1725
- const up = inputRows - 1 - cursorRowInInput;
2535
+ const up = inputRows - 1 - caretRow;
1726
2536
  if (up > 0) process.stdout.write(`\x1B[${up}A`);
1727
- if (cursorCol > 0) process.stdout.write(`\x1B[${cursorCol}C`);
1728
- this.lastCursorRow = aboveRows + cursorRowInInput;
2537
+ if (caretCol > 0) process.stdout.write(`\x1B[${caretCol}C`);
2538
+ this.lastCursorRow = aboveRows + caretRow;
1729
2539
  } else {
1730
2540
  this.lastCursorRow = aboveRows + (inputRows - 1);
1731
2541
  }
2542
+ this.drawnHudWidths = hudWidths;
2543
+ this.lastDrawnCols = cols2;
1732
2544
  this.bottomDrawn = true;
1733
2545
  }
1734
2546
  clearBottom() {
@@ -1737,14 +2549,183 @@ var Tui = class {
1737
2549
  process.stdout.write("\x1B[J");
1738
2550
  this.bottomDrawn = false;
1739
2551
  }
2552
+ // ── live streaming preview ────────────────────────────────────────────────
2553
+ // An ephemeral tail of the model's output above the status line: reasoning in
2554
+ // dim italic until the answer begins, then the answer plain. clearStream()
2555
+ // wipes it right before the finished message is committed to the transcript
2556
+ // (which renders full markdown), so there's no double-render.
2557
+ static STREAM_TAIL = 20;
2558
+ // How long a preview may sit untouched before it's wiped. The model often
2559
+ // reasons and then calls a tool without ever emitting an answer, so without
2560
+ // this the reasoning tail would linger on screen until the *next* thought (or
2561
+ // message) arrives. Re-armed on every delta → fires this long after the last.
2562
+ static STREAM_IDLE_MS = 1e4;
2563
+ /** Append a fragment of streamed answer text (rendered plain). */
2564
+ streamResponseDelta(delta, messageId) {
2565
+ this.beginStreamMessage(messageId);
2566
+ this.streamResponse += delta;
2567
+ this.scheduleStreamRedraw();
2568
+ this.armStreamIdleExpiry();
2569
+ }
2570
+ /**
2571
+ * Append a fragment of streamed internal reasoning (rendered dim italic).
2572
+ * Ignored once the answer has started, since reasoning precedes the answer.
2573
+ */
2574
+ streamThinkingDelta(delta, messageId) {
2575
+ this.beginStreamMessage(messageId);
2576
+ if (this.streamResponse) return;
2577
+ this.streamThinking += delta;
2578
+ this.scheduleStreamRedraw();
2579
+ this.armStreamIdleExpiry();
2580
+ }
2581
+ /** Reset the preview when a new message starts, so two messages' output (e.g.
2582
+ * one that only reasons then calls a tool, then the next) never blend. */
2583
+ beginStreamMessage(messageId) {
2584
+ if (messageId !== void 0 && messageId !== this.streamMessageId) {
2585
+ this.streamMessageId = messageId;
2586
+ this.streamThinking = "";
2587
+ this.streamResponse = "";
2588
+ }
2589
+ }
2590
+ /** Wipe the live preview — call right before committing the final message. */
2591
+ clearStream() {
2592
+ if (this.streamRedrawTimer) {
2593
+ clearTimeout(this.streamRedrawTimer);
2594
+ this.streamRedrawTimer = null;
2595
+ }
2596
+ if (this.streamIdleTimer) {
2597
+ clearTimeout(this.streamIdleTimer);
2598
+ this.streamIdleTimer = null;
2599
+ }
2600
+ this.streamMessageId = null;
2601
+ if (!this.streamThinking && !this.streamResponse) return;
2602
+ this.streamThinking = "";
2603
+ this.streamResponse = "";
2604
+ this.renderBottom();
2605
+ }
2606
+ scheduleStreamRedraw() {
2607
+ if (this.streamRedrawTimer || this.takeoverHandler || !this.started) return;
2608
+ this.streamRedrawTimer = setTimeout(() => {
2609
+ this.streamRedrawTimer = null;
2610
+ this.renderBottom();
2611
+ }, 40);
2612
+ }
2613
+ /** Re-armed on every streamed delta: once the model goes quiet for a beat, the
2614
+ * preview is stale, so wipe it instead of letting it sit until the next
2615
+ * message. The committed message (if any) still renders in full via
2616
+ * clearStream(), so nothing is lost. */
2617
+ armStreamIdleExpiry() {
2618
+ if (this.streamIdleTimer) clearTimeout(this.streamIdleTimer);
2619
+ this.streamIdleTimer = setTimeout(() => {
2620
+ this.streamIdleTimer = null;
2621
+ if (!this.streamThinking && !this.streamResponse) return;
2622
+ this.streamThinking = "";
2623
+ this.streamResponse = "";
2624
+ this.renderBottom();
2625
+ }, _Tui.STREAM_IDLE_MS);
2626
+ }
2627
+ /**
2628
+ * The preview's physical rows: a tail of reasoning (dim italic) before the
2629
+ * answer starts, otherwise a tail of the answer (plain). Each row is clamped to
2630
+ * one terminal line so the bottom-region redraw math stays correct.
2631
+ *
2632
+ * Rows match the committed transcript formatting so a finished message doesn't
2633
+ * visibly "snap" into shape: the answer's first line carries the grey gutter
2634
+ * dot (as long as it hasn't scrolled out of the tail) and the body indents two
2635
+ * spaces beneath it; reasoning aligns at the same indent, dotless.
2636
+ */
2637
+ streamPreviewLines(cols2) {
2638
+ const thinkStyle = "\x1B[3m\x1B[38;5;240m";
2639
+ const clamp2 = (s, wrap, lead) => {
2640
+ const max = cols2 - 2;
2641
+ const t = s.length > max ? s.slice(0, Math.max(0, max - 1)) + "\u2026" : s;
2642
+ return wrap ? `${lead}${wrap}${t}${C.reset}` : `${lead}${t}`;
2643
+ };
2644
+ const realLines = (text) => text.replace(/\r/g, "").split("\n").filter((l) => l.trim() !== "");
2645
+ if (this.streamResponse) {
2646
+ const all = realLines(this.streamResponse);
2647
+ const shown = all.slice(-20);
2648
+ const firstVisible = all.length <= _Tui.STREAM_TAIL;
2649
+ return shown.map(
2650
+ (l, i) => clamp2(l, "", i === 0 && firstVisible ? `${C.gray}\u2022${C.reset} ` : " ")
2651
+ );
2652
+ }
2653
+ if (this.streamThinking) {
2654
+ return realLines(this.streamThinking).slice(-20).map((l) => clamp2(l, thinkStyle, " "));
2655
+ }
2656
+ return [];
2657
+ }
2658
+ // ── live goal checklist ───────────────────────────────────────────────────
2659
+ /**
2660
+ * Update the goal from the goal_updated event (or the initial fetch). When
2661
+ * every step is done, the goal is fully achieved: we clear the goal area and
2662
+ * flip on the "Goal complete." status badge instead of leaving a finished
2663
+ * checklist sitting there.
2664
+ */
2665
+ setGoal(goal) {
2666
+ const steps = goal?.steps;
2667
+ if (steps && Array.isArray(steps) && steps.length) {
2668
+ const allDone = steps.every((s) => s.status === "done");
2669
+ if (allDone) {
2670
+ this.goal = null;
2671
+ this.goalComplete = true;
2672
+ } else {
2673
+ this.goal = goal;
2674
+ this.goalComplete = false;
2675
+ }
2676
+ } else {
2677
+ this.goal = null;
2678
+ }
2679
+ this.renderBottom();
2680
+ }
2681
+ /**
2682
+ * The goal's physical rows: a header (the short summary + progress) then one
2683
+ * row per step, indented two spaces beneath it so the todos clearly belong to
2684
+ * the goal — ○ pending / ▸ in-progress / ✓ done — each clamped to a line.
2685
+ */
2686
+ goalLines(cols2) {
2687
+ const steps = this.goal?.steps;
2688
+ if (!steps?.length) return [];
2689
+ const oneLine = (s) => s.replace(/\s+/g, " ").trim();
2690
+ const clamp2 = (s, max) => s.length > max ? s.slice(0, Math.max(0, max - 1)) + "\u2026" : s;
2691
+ const calmGreen = "\x1B[38;5;65m";
2692
+ const out = [];
2693
+ const done = steps.filter((s) => s.status === "done").length;
2694
+ const summary = oneLine(this.goal?.summary || this.goal?.description || "Goal");
2695
+ const prefix = `Goal ${done}/${steps.length} `;
2696
+ const sum = clamp2(summary, Math.max(1, cols2 - prefix.length));
2697
+ out.push(`${C.bold}Goal${C.reset} ${C.gray}${done}/${steps.length}${C.reset} ${sum}`);
2698
+ for (const s of steps) {
2699
+ const text = clamp2(oneLine(s.step || ""), Math.max(1, cols2 - 4));
2700
+ if (s.status === "done") out.push(` ${calmGreen}\u2713${C.reset} ${C.dim}${text}${C.reset}`);
2701
+ else if (s.status === "in_progress") out.push(` ${C.cyan}\u25B8${C.reset} ${text}`);
2702
+ else out.push(` ${C.gray}\u25CB ${text}${C.reset}`);
2703
+ }
2704
+ return out;
2705
+ }
2706
+ /**
2707
+ * Transcript gutter: content starts at column 1, so the far-left column is a
2708
+ * clean strip where only status glyphs (✓ ✗ ⛔ …) land — scanning down the
2709
+ * left edge reads as a ledger of what happened. Lines already led by a
2710
+ * gutter glyph or whitespace (indented blocks, the user-message bar) pass
2711
+ * through untouched.
2712
+ */
2713
+ gutterize(text) {
2714
+ return text.split("\n").map((line) => {
2715
+ const plain = line.replace(/\x1b\[[0-9;]*m/g, "");
2716
+ if (plain === "" || /^[\s✓✗⛔⚠⚙⚡⏳↪›❯◇─•]/.test(plain)) return line;
2717
+ return " " + line;
2718
+ }).join("\n");
2719
+ }
1740
2720
  /** Print a line of transcript above the persistent input. */
1741
2721
  print(text) {
2722
+ const line = this.gutterize(text);
1742
2723
  if (this.takeoverHandler) {
1743
- this.bufferedPrints.push(text);
2724
+ this.bufferedPrints.push(line);
1744
2725
  return;
1745
2726
  }
1746
2727
  this.clearBottom();
1747
- process.stdout.write(text + "\n");
2728
+ process.stdout.write(line + "\n");
1748
2729
  this.renderBottom();
1749
2730
  }
1750
2731
  /** Multi-line convenience. */
@@ -1758,9 +2739,9 @@ var Tui = class {
1758
2739
  * above and below, and a teal `›` marks the first row.
1759
2740
  */
1760
2741
  printUserMessage(text) {
1761
- const cols = Math.max(20, process.stdout.columns || 80);
2742
+ const cols2 = Math.max(20, process.stdout.columns || 80);
1762
2743
  const bg = "\x1B[48;5;238m";
1763
- const limit = Math.max(8, cols - 6);
2744
+ const limit = Math.max(8, cols2 - 6);
1764
2745
  const words = text.replace(/\s+/g, " ").trim().split(" ");
1765
2746
  const lines = [];
1766
2747
  let cur = "";
@@ -1784,9 +2765,9 @@ var Tui = class {
1784
2765
  const innerW = 2 + Math.max(...lines.map((l) => l.length));
1785
2766
  this.print("");
1786
2767
  lines.forEach((line, i) => {
1787
- const rowText = (i === 0 ? "\u203A " : " ") + line;
2768
+ const rowText = (i === 0 ? "\u276F " : " ") + line;
1788
2769
  const padded = rowText.padEnd(innerW);
1789
- const inner = i === 0 ? `${C.teal}\u203A${C.reset}${bg}${padded.slice(1)}` : padded;
2770
+ const inner = i === 0 ? `${C.teal}\u276F${C.reset}${bg}${padded.slice(1)}` : padded;
1790
2771
  this.print(`${bg} ${inner} ${C.reset}`);
1791
2772
  });
1792
2773
  this.print("");
@@ -1796,15 +2777,30 @@ var Tui = class {
1796
2777
  if (on && !this.working) {
1797
2778
  this.working = true;
1798
2779
  this.workingStart = Date.now();
2780
+ this.goalComplete = false;
1799
2781
  } else if (!on) {
1800
2782
  this.working = false;
1801
2783
  }
1802
2784
  this.syncSpinner();
1803
2785
  this.renderBottom();
1804
2786
  }
1805
- /** Labels of subagents currently working, one persistent line each. */
1806
- setSubagents(labels) {
1807
- this.subagents = labels;
2787
+ /** The subagents currently working, one persistent line each. Each keeps a
2788
+ * stable, distinct colour for as long as it's active; the compaction agent
2789
+ * is always orange (its colour never comes from the shared pool). */
2790
+ setSubagents(subagents) {
2791
+ this.subagents = subagents;
2792
+ const active = new Set(subagents.map((s) => s.id));
2793
+ for (const id of [...this.subagentColorByID.keys()]) {
2794
+ if (!active.has(id)) this.subagentColorByID.delete(id);
2795
+ }
2796
+ for (const s of subagents) {
2797
+ if (s.agentName === COMPACTION_AGENT) continue;
2798
+ if (this.subagentColorByID.has(s.id)) continue;
2799
+ const used = new Set(this.subagentColorByID.values());
2800
+ let idx = 0;
2801
+ while (used.has(idx) && idx < SUBAGENT_COLORS.length - 1) idx++;
2802
+ this.subagentColorByID.set(s.id, idx);
2803
+ }
1808
2804
  this.syncSpinner();
1809
2805
  this.renderBottom();
1810
2806
  }
@@ -1837,9 +2833,12 @@ var Tui = class {
1837
2833
  getInput() {
1838
2834
  return this.inputBuffer;
1839
2835
  }
1840
- setInput(text) {
2836
+ /** Replace the input (and any pasted images tied to placeholders in it). */
2837
+ setInput(text, images = []) {
1841
2838
  this.inputBuffer = text;
1842
2839
  this.cursorPos = text.length;
2840
+ this.pendingImages = images;
2841
+ this.historyIdx = null;
1843
2842
  this.renderBottom();
1844
2843
  }
1845
2844
  /** Cumulative token totals shown on the prompt line (`outTokens` includes live). */
@@ -1879,7 +2878,8 @@ var Tui = class {
1879
2878
  }
1880
2879
  this.renderBottom();
1881
2880
  }
1882
- /** Approval prompt: arrow-navigable with y/a/l/n shortcuts. Pauses input. */
2881
+ /** Approval prompt: arrow-navigable with y/a/l/n shortcuts. Pauses input.
2882
+ * Tab resolves "deny_with_reason" so the caller can collect a free-text reason. */
1883
2883
  approval(question, risk) {
1884
2884
  return new Promise((resolve) => {
1885
2885
  const options = [
@@ -1891,12 +2891,16 @@ var Tui = class {
1891
2891
  let idx = 0;
1892
2892
  const riskBar = `${C.red}${"\u25CF".repeat(risk)}${C.gray}${"\u25CB".repeat(5 - risk)}${C.reset}`;
1893
2893
  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
- );
2894
+ const cols2 = Math.max(1, process.stdout.columns || 80);
2895
+ const physRows = (line) => Math.max(1, Math.ceil(this.visibleWidth(line) / cols2));
2896
+ const headerText = `${C.yellow}\u2503${C.reset} ${C.bold}Permission needed${C.reset} risk ${riskBar}`;
2897
+ const questionLines = question.split("\n").map((line) => `${C.yellow}\u2503${C.reset} ${line}`);
2898
+ const hintLine = `${C.yellow}\u2503${C.reset} ${C.gray}\u21E5 tab \u2014 deny with a reason${C.reset}`;
2899
+ const blockLines = [headerText, ...questionLines, hintLine];
2900
+ const headerRows = 1 + blockLines.reduce((n, line) => n + physRows(line), 0);
2901
+ process.stdout.write(`
2902
+ ` + blockLines.map((line) => `${line}
2903
+ `).join(""));
1900
2904
  const renderLine = (i) => {
1901
2905
  const o = options[i];
1902
2906
  const sel = i === idx;
@@ -1910,7 +2914,14 @@ ${C.yellow}\u2503${C.reset} ${question}
1910
2914
  `);
1911
2915
  };
1912
2916
  draw(false);
2917
+ const erase = () => {
2918
+ process.stdout.write("\r");
2919
+ const up = headerRows + options.length;
2920
+ if (up > 0) process.stdout.write(`\x1B[${up}A`);
2921
+ process.stdout.write("\x1B[J");
2922
+ };
1913
2923
  const finish = (choice) => {
2924
+ erase();
1914
2925
  this.endTakeover();
1915
2926
  resolve(choice);
1916
2927
  };
@@ -1921,6 +2932,8 @@ ${C.yellow}\u2503${C.reset} ${question}
1921
2932
  } else if (key?.name === "down" || str === "j") {
1922
2933
  idx = (idx + 1) % options.length;
1923
2934
  draw(true);
2935
+ } else if (key?.name === "tab") {
2936
+ finish("deny_with_reason");
1924
2937
  } else if (key?.name === "return" || key?.name === "enter") {
1925
2938
  finish(options[idx].value);
1926
2939
  } else {
@@ -2041,11 +3054,39 @@ ${C.cyan}\u2503${C.reset} ${question}
2041
3054
  }
2042
3055
  };
2043
3056
 
3057
+ // src/history.ts
3058
+ var HISTORY_KEY = "input_history";
3059
+ var MAX_ENTRIES = 100;
3060
+ function clean(value) {
3061
+ if (!Array.isArray(value)) return [];
3062
+ return value.filter((e) => typeof e === "string" && e.trim() !== "").slice(-MAX_ENTRIES);
3063
+ }
3064
+ async function loadHistory(store, threadId, seedThreadId) {
3065
+ const own = clean(await store.kvGet(threadId, HISTORY_KEY));
3066
+ if (own.length) return own;
3067
+ if (seedThreadId && seedThreadId !== threadId) {
3068
+ const seeded = clean(await store.kvGet(seedThreadId, HISTORY_KEY));
3069
+ if (seeded.length) {
3070
+ void store.kvSet(threadId, HISTORY_KEY, seeded);
3071
+ return seeded;
3072
+ }
3073
+ }
3074
+ return [];
3075
+ }
3076
+ function appendHistory(store, threadId, history, text) {
3077
+ const t = text.trim();
3078
+ if (!t || history[history.length - 1] === t) return history;
3079
+ history.push(t);
3080
+ if (history.length > MAX_ENTRIES) history.splice(0, history.length - MAX_ENTRIES);
3081
+ void store.kvSet(threadId, HISTORY_KEY, [...history]);
3082
+ return history;
3083
+ }
3084
+
2044
3085
  // src/markdown.ts
2045
3086
  var ESC = "\x1B[";
2046
3087
  var R = ESC + "0m";
2047
3088
  var BOLD = ESC + "1m";
2048
- var DIM = ESC + "2m";
3089
+ var DIM2 = ESC + "2m";
2049
3090
  var ITAL = ESC + "3m";
2050
3091
  var UNDER = ESC + "4m";
2051
3092
  var TEAL = ESC + "38;5;37m";
@@ -2067,11 +3108,11 @@ function inline(s) {
2067
3108
  });
2068
3109
  s = s.replace(
2069
3110
  /\[([^\]]+)\]\(([^)\s]+)\)/g,
2070
- (_, text, url) => `${CYAN}${UNDER}${text}${R} ${DIM}${url}${R}`
3111
+ (_, text, url) => `${CYAN}${UNDER}${text}${R} ${DIM2}${url}${R}`
2071
3112
  );
2072
3113
  s = s.replace(/\*\*([^*]+)\*\*/g, (_, t) => `${BOLD}${t}${R}`);
2073
3114
  s = s.replace(/\*([^*\n]+)\*/g, (_, t) => `${ITAL}${t}${R}`);
2074
- s = s.replace(/~~([^~]+)~~/g, (_, t) => `${DIM}${t}${R}`);
3115
+ s = s.replace(/~~([^~]+)~~/g, (_, t) => `${DIM2}${t}${R}`);
2075
3116
  s = s.replace(/\x00(\d+)\x00/g, (_, i) => `${TEAL}${codes[+i].replace(/ /g, String.fromCharCode(160))}${R}`);
2076
3117
  return s;
2077
3118
  }
@@ -2098,8 +3139,8 @@ function wrapStyled(text, width) {
2098
3139
  if (cur !== "" || lines.length === 0) lines.push(cur);
2099
3140
  return lines;
2100
3141
  }
2101
- function wrapBlock(out, cols, leadFirst, leadRest, leadWidth, text) {
2102
- const wrapped = wrapStyled(text, Math.max(8, cols - leadWidth));
3142
+ function wrapBlock(out, cols2, leadFirst, leadRest, leadWidth, text) {
3143
+ const wrapped = wrapStyled(text, Math.max(8, cols2 - leadWidth));
2103
3144
  wrapped.forEach((ln, idx) => out.push((idx === 0 ? leadFirst : leadRest) + ln));
2104
3145
  }
2105
3146
  function tableCells(row) {
@@ -2113,16 +3154,16 @@ function isTableSeparator(line) {
2113
3154
  return SEPARATOR.test(line) && line.includes("-") && line.includes("|");
2114
3155
  }
2115
3156
  function renderTable(rows) {
2116
- const cols = Math.max(...rows.map((r) => r.length));
3157
+ const cols2 = Math.max(...rows.map((r) => r.length));
2117
3158
  const widths = [];
2118
- for (let c2 = 0; c2 < cols; c2++) {
3159
+ for (let c2 = 0; c2 < cols2; c2++) {
2119
3160
  widths[c2] = Math.max(...rows.map((r) => visibleWidth(inline(r[c2] ?? ""))));
2120
3161
  }
2121
3162
  const sep = `${GRAY} \u2502 ${R}`;
2122
3163
  const out = [];
2123
3164
  rows.forEach((r, ri) => {
2124
3165
  const cells = [];
2125
- for (let c2 = 0; c2 < cols; c2++) {
3166
+ for (let c2 = 0; c2 < cols2; c2++) {
2126
3167
  const raw = r[c2] ?? "";
2127
3168
  const styled = ri === 0 ? `${BOLD}${inline(raw)}${R}` : inline(raw);
2128
3169
  cells.push(padEndVisible(styled, widths[c2]));
@@ -2135,7 +3176,7 @@ function renderTable(rows) {
2135
3176
  });
2136
3177
  return out;
2137
3178
  }
2138
- function renderMarkdown(src, cols = 80) {
3179
+ function renderMarkdown(src, cols2 = 80) {
2139
3180
  const lines = src.replace(/\r\n/g, "\n").split("\n");
2140
3181
  const out = [];
2141
3182
  let inFence = false;
@@ -2164,7 +3205,7 @@ function renderMarkdown(src, cols = 80) {
2164
3205
  }
2165
3206
  const heading = line.match(/^(#{1,6})\s+(.*)$/);
2166
3207
  if (heading) {
2167
- for (const ln of wrapStyled(heading[2].trim(), cols)) out.push(`${BOLD}${TEAL}${ln}${R}`);
3208
+ for (const ln of wrapStyled(heading[2].trim(), cols2)) out.push(`${BOLD}${TEAL}${ln}${R}`);
2168
3209
  i++;
2169
3210
  continue;
2170
3211
  }
@@ -2175,8 +3216,8 @@ function renderMarkdown(src, cols = 80) {
2175
3216
  }
2176
3217
  const quote = line.match(/^\s*>\s?(.*)$/);
2177
3218
  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}`);
3219
+ for (const ln of wrapStyled(inline(quote[1]), Math.max(8, cols2 - 2))) {
3220
+ out.push(`${GRAY}\u2502${R} ${DIM2}${ln}${R}`);
2180
3221
  }
2181
3222
  i++;
2182
3223
  continue;
@@ -2184,7 +3225,7 @@ function renderMarkdown(src, cols = 80) {
2184
3225
  const bullet = line.match(/^(\s*)[-*+]\s+(.*)$/);
2185
3226
  if (bullet) {
2186
3227
  const leadWidth = bullet[1].length + 2;
2187
- wrapBlock(out, cols, `${bullet[1]}${TEAL}\u2022${R} `, " ".repeat(leadWidth), leadWidth, inline(bullet[2]));
3228
+ wrapBlock(out, cols2, `${bullet[1]}${TEAL}\u2022${R} `, " ".repeat(leadWidth), leadWidth, inline(bullet[2]));
2188
3229
  i++;
2189
3230
  continue;
2190
3231
  }
@@ -2192,11 +3233,11 @@ function renderMarkdown(src, cols = 80) {
2192
3233
  if (numbered) {
2193
3234
  const marker = `${numbered[2]}${numbered[3]}`;
2194
3235
  const leadWidth = numbered[1].length + marker.length + 1;
2195
- wrapBlock(out, cols, `${numbered[1]}${BOLD}${marker}${R} `, " ".repeat(leadWidth), leadWidth, inline(numbered[4]));
3236
+ wrapBlock(out, cols2, `${numbered[1]}${BOLD}${marker}${R} `, " ".repeat(leadWidth), leadWidth, inline(numbered[4]));
2196
3237
  i++;
2197
3238
  continue;
2198
3239
  }
2199
- if (line.trim()) wrapBlock(out, cols, "", "", 0, inline(line));
3240
+ if (line.trim()) wrapBlock(out, cols2, "", "", 0, inline(line));
2200
3241
  else out.push("");
2201
3242
  i++;
2202
3243
  }
@@ -2307,7 +3348,7 @@ ${stderrTail.trim()}` : msg;
2307
3348
  target,
2308
3349
  argsSha256: sha256(canonicalJson(args)),
2309
3350
  resultSha256: sha256(text),
2310
- nonce: crypto2.randomBytes(8).toString("hex"),
3351
+ nonce: crypto.randomBytes(8).toString("hex"),
2311
3352
  isError,
2312
3353
  at: Date.now()
2313
3354
  };
@@ -2577,10 +3618,10 @@ function sortKeys(value) {
2577
3618
  return value;
2578
3619
  }
2579
3620
  function sha256(input2) {
2580
- return crypto2.createHash("sha256").update(input2).digest("hex");
3621
+ return crypto.createHash("sha256").update(input2).digest("hex");
2581
3622
  }
2582
- var DIR2 = path3.join(os4.homedir(), ".standardagents");
2583
- var FILE2 = path3.join(DIR2, "credentials");
3623
+ var DIR = path3.join(os6.homedir(), ".standardagents");
3624
+ var FILE = path3.join(DIR, "credentials");
2584
3625
  function normalizeEndpoint(endpoint) {
2585
3626
  let e = endpoint.trim();
2586
3627
  if (!/^https?:\/\//i.test(e)) e = "http://" + e;
@@ -2588,7 +3629,7 @@ function normalizeEndpoint(endpoint) {
2588
3629
  }
2589
3630
  function loadCredentials() {
2590
3631
  try {
2591
- const raw = fs2.readFileSync(FILE2, "utf8");
3632
+ const raw = fs4.readFileSync(FILE, "utf8");
2592
3633
  const parsed = JSON.parse(raw);
2593
3634
  if (!parsed.instances) parsed.instances = {};
2594
3635
  return parsed;
@@ -2600,21 +3641,33 @@ function getCredential(endpoint) {
2600
3641
  const creds = loadCredentials();
2601
3642
  return creds.instances[normalizeEndpoint(endpoint)] ?? null;
2602
3643
  }
2603
- function saveCredential(cred) {
3644
+ function saveCredential(cred, options = {}) {
2604
3645
  const creds = loadCredentials();
2605
3646
  const endpoint = normalizeEndpoint(cred.endpoint);
2606
3647
  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 });
3648
+ if (options.updateDefault ?? true) {
3649
+ creds.default_endpoint = endpoint;
3650
+ }
3651
+ fs4.mkdirSync(DIR, { recursive: true });
3652
+ fs4.writeFileSync(FILE, JSON.stringify(creds, null, 2), { mode: 384 });
2610
3653
  try {
2611
- fs2.chmodSync(FILE2, 384);
3654
+ fs4.chmodSync(FILE, 384);
2612
3655
  } catch {
2613
3656
  }
2614
3657
  }
2615
3658
  function defaultEndpoint() {
2616
3659
  return loadCredentials().default_endpoint ?? null;
2617
3660
  }
3661
+ function saveDefaultEndpoint(endpoint) {
3662
+ const creds = loadCredentials();
3663
+ creds.default_endpoint = normalizeEndpoint(endpoint);
3664
+ fs4.mkdirSync(DIR, { recursive: true });
3665
+ fs4.writeFileSync(FILE, JSON.stringify(creds, null, 2), { mode: 384 });
3666
+ try {
3667
+ fs4.chmodSync(FILE, 384);
3668
+ } catch {
3669
+ }
3670
+ }
2618
3671
 
2619
3672
  // src/index.ts
2620
3673
  var AGENT_ID = "standard_code_agent";
@@ -2643,13 +3696,86 @@ var LOGO_MARK = [
2643
3696
  "\u2588\u2588 \u2588\u2588\u2588",
2644
3697
  "\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588"
2645
3698
  ];
3699
+ function printUsage() {
3700
+ stdout.write(
3701
+ [
3702
+ "",
3703
+ `${c.bold}Usage${c.reset}`,
3704
+ " standardcode [options] [dir]",
3705
+ "",
3706
+ `${c.bold}Options${c.reset}`,
3707
+ " -e, --endpoint [url] Use a Standard Agents instance for this run only.",
3708
+ " If url is omitted, prompt for it.",
3709
+ " Credentials are remembered for that endpoint, but",
3710
+ " the saved default endpoint is not changed.",
3711
+ " -h, --help Show this help.",
3712
+ ""
3713
+ ].join("\n")
3714
+ );
3715
+ }
3716
+ function parseArgs2(args) {
3717
+ const parsed = { help: false, promptEndpoint: false };
3718
+ for (let i = 0; i < args.length; i++) {
3719
+ const arg = args[i];
3720
+ if (arg === "--help" || arg === "-h") {
3721
+ parsed.help = true;
3722
+ continue;
3723
+ }
3724
+ if (arg === "--endpoint" || arg === "-e") {
3725
+ const value = args[i + 1];
3726
+ if (value && !value.startsWith("-")) {
3727
+ parsed.endpoint = value;
3728
+ i++;
3729
+ } else {
3730
+ parsed.promptEndpoint = true;
3731
+ }
3732
+ continue;
3733
+ }
3734
+ if (arg.startsWith("--endpoint=")) {
3735
+ const value = arg.slice("--endpoint=".length);
3736
+ if (value) {
3737
+ parsed.endpoint = value;
3738
+ } else {
3739
+ parsed.promptEndpoint = true;
3740
+ }
3741
+ continue;
3742
+ }
3743
+ if (arg === "--") {
3744
+ if (i === 0 && args[i + 1]?.startsWith("-")) continue;
3745
+ const rest = args.slice(i + 1);
3746
+ if (rest.length > 1) throw new Error("Expected at most one project directory.");
3747
+ if (rest[0]) parsed.dir = rest[0];
3748
+ break;
3749
+ }
3750
+ if (arg.startsWith("-")) throw new Error(`Unknown option: ${arg}`);
3751
+ if (parsed.dir) throw new Error("Expected at most one project directory.");
3752
+ parsed.dir = arg;
3753
+ }
3754
+ return parsed;
3755
+ }
2646
3756
  function printAssistant(tui, text) {
2647
- const cols = Math.max(20, (process.stdout.columns || 80) - 1);
3757
+ const cols2 = Math.max(20, (process.stdout.columns || 80) - 3);
3758
+ tui.clearStream();
2648
3759
  tui.print("");
2649
- for (const line of renderMarkdown(text, cols)) tui.print(line);
3760
+ let dotted = false;
3761
+ for (const line of renderMarkdown(text, cols2)) {
3762
+ if (!dotted && line.trim()) {
3763
+ tui.print(`${c.gray}\u2022${c.reset} ${line}`);
3764
+ dotted = true;
3765
+ } else {
3766
+ tui.print(` ${line}`);
3767
+ }
3768
+ }
2650
3769
  tui.print("");
2651
3770
  }
2652
- function farewell() {
3771
+ function farewell(stoppedProcs = 0) {
3772
+ if (stoppedProcs > 0) {
3773
+ stdout.write(
3774
+ `
3775
+ ${c.cyan}\u2699${c.reset} Stopped ${stoppedProcs} background process${stoppedProcs === 1 ? "" : "es"}.
3776
+ `
3777
+ );
3778
+ }
2653
3779
  stdout.write(`
2654
3780
  ${c.teal}\u25C7${c.reset} ${c.dim}Standard Code \u2014 see you soon.${c.reset}
2655
3781
  `);
@@ -2676,14 +3802,14 @@ function relaxTlsForLocalEndpoint(endpoint) {
2676
3802
  }
2677
3803
  function readVersion() {
2678
3804
  try {
2679
- const pkg = JSON.parse(fs2.readFileSync(new URL("../package.json", import.meta.url), "utf8"));
3805
+ const pkg = JSON.parse(fs4.readFileSync(new URL("../package.json", import.meta.url), "utf8"));
2680
3806
  return typeof pkg.version === "string" ? pkg.version : "";
2681
3807
  } catch {
2682
3808
  return "";
2683
3809
  }
2684
3810
  }
2685
3811
  function printWelcome(endpoint, projectDir) {
2686
- const home = os4.homedir();
3812
+ const home = os6.homedir();
2687
3813
  const dir = projectDir.startsWith(home) ? "~" + projectDir.slice(home.length) : projectDir;
2688
3814
  const host = endpoint.replace(/^https?:\/\//, "").replace(/\/$/, "");
2689
3815
  const version = readVersion();
@@ -2713,19 +3839,41 @@ function colorActivity(line) {
2713
3839
  const body = rest.replace(/\s(\([^()]*\))\s*$/, ` ${c.dim}$1${c.reset}`);
2714
3840
  return `${indent}${c.green}\u2713${c.reset} ${body}`;
2715
3841
  }
2716
- if (glyph === "\u2717") return `${indent}${c.red}\u2717${c.reset} ${rest}`;
3842
+ if (glyph === "\u2717") {
3843
+ const ERR_MAX_LINES = 7;
3844
+ const lines = rest.split("\n");
3845
+ const shown = lines.slice(0, ERR_MAX_LINES);
3846
+ const hidden = lines.length - shown.length;
3847
+ const body = shown.map(
3848
+ (l, i) => i === 0 ? `${indent}${c.red}\u2717 ${l}${c.reset}` : `${indent}${c.red}${c.dim}${l}${c.reset}`
3849
+ ).join("\n");
3850
+ if (hidden > 0) {
3851
+ return `${body}
3852
+ ${indent}${c.dim}\u2026 +${hidden} more line${hidden === 1 ? "" : "s"}${c.reset}`;
3853
+ }
3854
+ return body;
3855
+ }
2717
3856
  return `${indent}${c.yellow}\u26D4 ${rest}${c.reset}`;
2718
3857
  }
2719
3858
  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];
3859
+ let cliArgs;
3860
+ try {
3861
+ cliArgs = parseArgs2(process.argv.slice(2));
3862
+ } catch (error) {
3863
+ stdout.write(`${c.red}error:${c.reset} ${error instanceof Error ? error.message : String(error)}
3864
+ `);
3865
+ printUsage();
3866
+ process.exit(1);
2726
3867
  }
3868
+ if (cliArgs.help) {
3869
+ printUsage();
3870
+ return;
3871
+ }
3872
+ const endpointArg = cliArgs.endpoint;
3873
+ const endpointOverride = cliArgs.promptEndpoint || typeof endpointArg === "string" && endpointArg.trim() !== "";
3874
+ const dirArg = cliArgs.dir;
2727
3875
  const projectDir = path3.resolve(dirArg || process.cwd());
2728
- const machine = os4.hostname();
3876
+ const machine = os6.hostname();
2729
3877
  const reader = { rl: null };
2730
3878
  let handoffClosing = false;
2731
3879
  let preflightArmed = false;
@@ -2758,13 +3906,22 @@ ${c.dim}Press Control-C again to exit${c.reset}
2758
3906
  }
2759
3907
  return reader.rl.question(question);
2760
3908
  };
3909
+ const askEndpoint = async () => {
3910
+ for (; ; ) {
3911
+ const answer = (await ask(
3912
+ `${c.cyan}Standard Agents instance URL${c.reset} (e.g. http://localhost:5178): `
3913
+ )).trim();
3914
+ if (answer) return answer;
3915
+ stdout.write(`${c.dim}An endpoint URL is required.${c.reset}
3916
+ `);
3917
+ }
3918
+ };
2761
3919
  process.on("SIGINT", onPreflightSigint);
2762
- let endpoint = endpointArg || defaultEndpoint() || "";
3920
+ let endpointPrompted = false;
3921
+ let endpoint = endpointArg || (cliArgs.promptEndpoint ? "" : defaultEndpoint() || "");
2763
3922
  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();
3923
+ endpoint = await askEndpoint();
3924
+ endpointPrompted = true;
2768
3925
  }
2769
3926
  endpoint = normalizeEndpoint(endpoint);
2770
3927
  const tlsRelaxed = relaxTlsForLocalEndpoint(endpoint);
@@ -2779,22 +3936,43 @@ ${c.dim}Press Control-C again to exit${c.reset}
2779
3936
  if (!api || !await api.verify()) {
2780
3937
  const host = endpoint.replace(/^https?:\/\//, "").replace(/\/$/, "");
2781
3938
  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}
3939
+ `${c.bold}${c.white}Sign in${c.reset} ${c.dim}\u2014 connect to${c.reset} ${c.teal}${host}${c.reset}
2783
3940
  `
2784
3941
  );
2785
- stdout.write(`${c.dim}Create one in your instance settings under API tokens.${c.reset}
3942
+ stdout.write(
3943
+ `${c.dim}Press Enter to sign in with your browser, or paste an API token.${c.reset}
2786
3944
 
2787
- `);
3945
+ `
3946
+ );
2788
3947
  for (; ; ) {
2789
- const token = (await ask(`${c.teal}\u276F${c.reset} ${c.dim}token${c.reset} `)).trim();
3948
+ const token = (await ask(`${c.teal}\u276F${c.reset} ${c.dim}token (or Enter for browser)${c.reset} `)).trim();
2790
3949
  if (!token) {
2791
- stdout.write(`${c.dim}A token is required.${c.reset}
3950
+ const got = await deviceLogin(endpoint).catch((e) => {
3951
+ stdout.write(`${c.red}\u2717${c.reset} ${c.dim}${e instanceof Error ? e.message : String(e)}${c.reset}
3952
+ `);
3953
+ return null;
3954
+ });
3955
+ if (!got) continue;
3956
+ api = new ApiClient(endpoint, got);
3957
+ if (await api.verify()) {
3958
+ saveCredential(
3959
+ { endpoint, access_token: got, token_type: "Bearer", saved_at: Date.now() },
3960
+ { updateDefault: !endpointOverride }
3961
+ );
3962
+ stdout.write(`${c.green}\u2713${c.reset} Connected to ${c.teal}${host}${c.reset}
3963
+ `);
3964
+ break;
3965
+ }
3966
+ stdout.write(`${c.red}\u2717${c.reset} ${c.dim}Browser sign-in didn't verify. Try again.${c.reset}
2792
3967
  `);
2793
3968
  continue;
2794
3969
  }
2795
3970
  api = new ApiClient(endpoint, token);
2796
3971
  if (await api.verify()) {
2797
- saveCredential({ endpoint, access_token: token, token_type: "Bearer", saved_at: Date.now() });
3972
+ saveCredential(
3973
+ { endpoint, access_token: token, token_type: "Bearer", saved_at: Date.now() },
3974
+ { updateDefault: !endpointOverride }
3975
+ );
2798
3976
  stdout.write(`${c.green}\u2713${c.reset} Connected to ${c.teal}${host}${c.reset}
2799
3977
  `);
2800
3978
  break;
@@ -2802,6 +3980,8 @@ ${c.dim}Press Control-C again to exit${c.reset}
2802
3980
  stdout.write(`${c.red}\u2717${c.reset} ${c.dim}That token didn't work. Try again.${c.reset}
2803
3981
  `);
2804
3982
  }
3983
+ } else if (endpointPrompted) {
3984
+ saveDefaultEndpoint(endpoint);
2805
3985
  }
2806
3986
  if (!api) process.exit(1);
2807
3987
  handoffClosing = true;
@@ -2816,6 +3996,7 @@ ${c.dim}Press Control-C again to exit${c.reset}
2816
3996
  const tui = new Tui(1);
2817
3997
  let threadId;
2818
3998
  let resumed = false;
3999
+ let historySeed;
2819
4000
  if (existing.length > 0) {
2820
4001
  const summaries = await summarizeThreads(api, existing.slice(0, 8));
2821
4002
  const items = summaries.map((s) => ({
@@ -2824,7 +4005,7 @@ ${c.dim}Press Control-C again to exit${c.reset}
2824
4005
  value: s.id
2825
4006
  }));
2826
4007
  items.push({ label: "\uFF0B Start a new session", value: null });
2827
- const home = os4.homedir();
4008
+ const home = os6.homedir();
2828
4009
  const tilde = projectDir.startsWith(home) ? "~" + projectDir.slice(home.length) : projectDir;
2829
4010
  const shortDir = tilde.length > 38 ? "\u2026" + tilde.slice(-37) : tilde;
2830
4011
  const picked = await tui.select(
@@ -2836,11 +4017,12 @@ ${c.dim}Press Control-C again to exit${c.reset}
2836
4017
  resumed = true;
2837
4018
  } else {
2838
4019
  threadId = await api.createThread(AGENT_ID, tags);
4020
+ historySeed = existing[0]?.id;
2839
4021
  }
2840
4022
  } else {
2841
4023
  threadId = await api.createThread(AGENT_ID, tags);
2842
4024
  }
2843
- await runInteractive(tui, api, threadId, projectDir, machine, resumed);
4025
+ await runInteractive(tui, api, threadId, projectDir, machine, resumed, historySeed);
2844
4026
  }
2845
4027
  async function summarizeThreads(api, threads) {
2846
4028
  return Promise.all(
@@ -2859,13 +4041,34 @@ async function summarizeThreads(api, threads) {
2859
4041
  })
2860
4042
  );
2861
4043
  }
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();
4044
+ function subagentLabel(s, titles) {
4045
+ const agentName = (s.agent_name || "").trim();
4046
+ const title = (s.title || "").trim() || titles.get(agentName) || (agentName ? agentName.replace(/[_-]+/g, " ").replace(/\b\w/g, (c2) => c2.toUpperCase()) : "Subagent");
4047
+ const tagged = (s.threadName || "").trim();
2867
4048
  return tagged ? `${title} \xB7 ${tagged}` : title;
2868
4049
  }
4050
+ async function deviceLogin(endpoint) {
4051
+ const start = await fetch(`${endpoint}/api/auth/device/start`, { method: "POST" });
4052
+ if (!start.ok) throw new Error(`This instance does not support browser sign-in (HTTP ${start.status}). Paste an API token instead.`);
4053
+ const info = await start.json();
4054
+ stdout.write(`${c.dim}Opening your browser to approve this sign-in\u2026${c.reset}
4055
+ `);
4056
+ stdout.write(`${c.dim}If it doesn't open, visit:${c.reset} ${c.teal}${info.verify_url}${c.reset}
4057
+ `);
4058
+ openUrl(info.verify_url);
4059
+ const deadline = Date.now() + (info.expires_in ?? 600) * 1e3;
4060
+ const interval = Math.max(2, info.interval ?? 2) * 1e3;
4061
+ while (Date.now() < deadline) {
4062
+ await new Promise((r) => setTimeout(r, interval));
4063
+ const res = await fetch(info.poll_url).catch(() => null);
4064
+ if (!res) continue;
4065
+ if (res.status === 404) throw new Error("The sign-in link expired. Try again.");
4066
+ const body = await res.json().catch(() => ({}));
4067
+ if (body.status === "approved" && body.token) return body.token;
4068
+ if (body.status === "denied") throw new Error("Sign-in was denied in the browser.");
4069
+ }
4070
+ throw new Error("Timed out waiting for browser approval. Try again.");
4071
+ }
2869
4072
  function openUrl(url) {
2870
4073
  const platform = process.platform;
2871
4074
  const cmd = platform === "darwin" ? "open" : platform === "win32" ? "cmd" : "xdg-open";
@@ -2926,14 +4129,13 @@ async function printHistory(api, threadId, tui) {
2926
4129
  if (m.role === "user") tui.printUserMessage(text);
2927
4130
  else printAssistant(tui, text);
2928
4131
  }
2929
- tui.print(`${c.dim}\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500${c.reset}`);
2930
4132
  }
2931
- async function runInteractive(tui, api, threadId, projectDir, machine, resumed) {
4133
+ async function runInteractive(tui, api, threadId, projectDir, machine, resumed, historySeedThreadId) {
2932
4134
  const registry = new ProcessRegistry(api, threadId, machine);
2933
4135
  const mcp = new McpManager(projectDir);
2934
4136
  const publishMcpCatalog = () => void api.kvSet(threadId, "mcp_catalog", mcp.catalog()).catch(() => {
2935
4137
  });
2936
- const host = new HostTools(projectDir, registry, threadId, machine, mcp, publishMcpCatalog);
4138
+ const host = new HostTools(projectDir, registry, threadId, machine, mcp, publishMcpCatalog, api);
2937
4139
  const refreshBgCount = () => {
2938
4140
  void registry.runningCount().then((n) => tui.setBackgroundCount(n)).catch(() => {
2939
4141
  });
@@ -2950,11 +4152,13 @@ async function runInteractive(tui, api, threadId, projectDir, machine, resumed)
2950
4152
  perm.level = l;
2951
4153
  saveApprovals(api, threadId, perm);
2952
4154
  });
4155
+ saveApprovals(api, threadId, perm);
2953
4156
  let busy = false;
2954
4157
  let interrupting = false;
2955
4158
  const queued = [];
2956
4159
  let editingQueued = false;
2957
4160
  const shownIds = /* @__PURE__ */ new Set();
4161
+ const pendingSent = /* @__PURE__ */ new Map();
2958
4162
  let tokensIn = 0;
2959
4163
  let tokensOut = 0;
2960
4164
  let liveOut = 0;
@@ -2967,13 +4171,19 @@ async function runInteractive(tui, api, threadId, projectDir, machine, resumed)
2967
4171
  tui.setStep(label, liveOut);
2968
4172
  };
2969
4173
  const bridge = new Bridge(api, threadId, host, perm, {
2970
- onActivity: (line) => {
4174
+ onActivity: (line, detail) => {
2971
4175
  tui.print(colorActivity(line));
4176
+ if (detail) for (const d of detail) tui.print(d);
2972
4177
  refreshBgCount();
2973
4178
  },
2974
- onStatus: () => {
4179
+ onStatus: (id, summary) => {
4180
+ if (summary) {
4181
+ if (!activeSteps.has(id)) activeSteps.set(id, summary);
4182
+ } else {
4183
+ activeSteps.delete(id);
4184
+ }
4185
+ refreshStatus();
2975
4186
  },
2976
- // the working indicator is driven by the busy poller
2977
4187
  onConnection: (state, attempt) => {
2978
4188
  if (state === "reconnecting") {
2979
4189
  if (attempt >= 4) tui.setConnected(false);
@@ -2981,15 +4191,27 @@ async function runInteractive(tui, api, threadId, projectDir, machine, resumed)
2981
4191
  tui.setConnected(true);
2982
4192
  }
2983
4193
  },
2984
- requestApproval: (req, summary, risk) => tui.approval(
2985
- `${summary}${req.requestPermission ? `
2986
- ${c.dim}why: ${req.requestPermission}${c.reset}` : ""}`,
2987
- risk
2988
- )
4194
+ requestApproval: async (req, summary, risk) => {
4195
+ const choice = await tui.approval(
4196
+ `${summary}${req.requestPermission ? `
4197
+ ${c.bold}why: ${req.requestPermission}${c.reset}` : ""}`,
4198
+ risk
4199
+ );
4200
+ if (choice === "deny_with_reason") {
4201
+ const reason = await tui.prompt(
4202
+ "Why are you denying this? (sent to the agent \u2014 enter to send, esc to skip)"
4203
+ );
4204
+ return { choice: "deny", reason: reason ?? void 0 };
4205
+ }
4206
+ return { choice };
4207
+ }
2989
4208
  });
2990
4209
  const stream = new MessageStream(api, threadId, {
2991
- onChunk: () => {
2992
- },
4210
+ // Live streaming preview: answer text and (opt-in) internal reasoning feed
4211
+ // the TUI's ephemeral preview; the committed message still renders from
4212
+ // polling, which calls tui.clearStream() first so there's no double-render.
4213
+ onChunk: (text, mid) => tui.streamResponseDelta(text, mid),
4214
+ onReasoningChunk: (text, mid) => tui.streamThinkingDelta(text, mid),
2993
4215
  onAssistantText: () => {
2994
4216
  },
2995
4217
  onEvent: (eventType, data) => {
@@ -3002,6 +4224,8 @@ ${c.dim}why: ${req.requestPermission}${c.reset}` : ""}`,
3002
4224
  } else if (eventType === "tool_call_done" && data?.id) {
3003
4225
  activeSteps.delete(data.id);
3004
4226
  refreshStatus();
4227
+ } else if (eventType === "goal_updated" && data) {
4228
+ tui.setGoal(data);
3005
4229
  }
3006
4230
  },
3007
4231
  onError: () => {
@@ -3009,33 +4233,69 @@ ${c.dim}why: ${req.requestPermission}${c.reset}` : ""}`,
3009
4233
  });
3010
4234
  const activeSubagents = /* @__PURE__ */ new Map();
3011
4235
  const agentTitles = /* @__PURE__ */ new Map();
3012
- void api.listAgents().then((list) => list.forEach((a) => agentTitles.set(a.name, a.title))).catch(() => {
4236
+ const agentTitlesReady = api.listAgents().then((list) => list.forEach((a) => agentTitles.set(a.name, a.title))).catch(() => {
3013
4237
  });
3014
- const pushSubagents = () => tui.setSubagents([...activeSubagents.values()]);
4238
+ const pushSubagents = () => tui.setSubagents(
4239
+ [...activeSubagents.entries()].map(([id, s]) => ({ id, label: s.label, agentName: s.agentName }))
4240
+ );
4241
+ const reconcileSubagents = async () => {
4242
+ try {
4243
+ await agentTitlesReady;
4244
+ const subs = await api.listSubagents(threadId);
4245
+ activeSubagents.clear();
4246
+ for (const s of subs) {
4247
+ if (s.status !== "running") continue;
4248
+ activeSubagents.set(s.id, {
4249
+ label: subagentLabel(s, agentTitles),
4250
+ agentName: s.agent_name ?? void 0
4251
+ });
4252
+ }
4253
+ pushSubagents();
4254
+ } catch {
4255
+ }
4256
+ };
4257
+ let reconcileTimer = null;
4258
+ let reconcilePending = false;
4259
+ const scheduleReconcile = () => {
4260
+ if (reconcileTimer) {
4261
+ reconcilePending = true;
4262
+ return;
4263
+ }
4264
+ reconcileTimer = setTimeout(async () => {
4265
+ reconcileTimer = null;
4266
+ await reconcileSubagents();
4267
+ if (reconcilePending) {
4268
+ reconcilePending = false;
4269
+ scheduleReconcile();
4270
+ }
4271
+ }, 150);
4272
+ };
3015
4273
  const events = new SystemEvents(api, {
4274
+ onOpen: () => scheduleReconcile(),
3016
4275
  onThreadCreated: (t) => {
3017
- if (t.parent === threadId && !t.terminated) {
3018
- activeSubagents.set(t.id, subagentLabel(t, agentTitles));
3019
- pushSubagents();
3020
- }
4276
+ if (t.parent === threadId) scheduleReconcile();
3021
4277
  },
3022
4278
  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();
4279
+ if (t.parent === threadId) scheduleReconcile();
3027
4280
  },
3028
4281
  onThreadDeleted: (id) => {
3029
- if (activeSubagents.delete(id)) pushSubagents();
4282
+ if (activeSubagents.has(id)) scheduleReconcile();
3030
4283
  }
3031
4284
  });
3032
- const quit = () => {
4285
+ const quit = async () => {
3033
4286
  tui.end();
4287
+ const stopped = api.stop(threadId).catch(() => {
4288
+ });
4289
+ const procsStopped = host.stopAllLocalProcesses().catch(() => 0);
3034
4290
  bridge.close();
3035
4291
  stream.close();
3036
4292
  events.close();
3037
4293
  mcp.closeAll();
3038
- farewell();
4294
+ const [, killed] = await Promise.race([
4295
+ Promise.all([stopped, procsStopped]),
4296
+ new Promise((r) => setTimeout(() => r([void 0, 0]), 1500))
4297
+ ]);
4298
+ farewell(killed);
3039
4299
  process.exit(0);
3040
4300
  };
3041
4301
  tui.setQuitHandler(quit);
@@ -3087,11 +4347,18 @@ ${c.dim}why: ${req.requestPermission}${c.reset}` : ""}`,
3087
4347
  },
3088
4348
  setEnabled: (name, enabled) => setMcpServerEnabled(name, enabled)
3089
4349
  };
3090
- const sendNow = async (text) => {
4350
+ const extFor = (mime) => ({ "image/png": "png", "image/jpeg": "jpg", "image/gif": "gif", "image/webp": "webp" })[mime] ?? "bin";
4351
+ const toAttachments = (images) => images.map((img) => ({ name: `image-${img.seq}.${extFor(img.mime)}`, mimeType: img.mime, data: img.data }));
4352
+ const sendNow = async (text, images = []) => {
3091
4353
  tui.printUserMessage(text);
4354
+ const key = text.trim();
4355
+ pendingSent.set(key, (pendingSent.get(key) ?? 0) + 1);
3092
4356
  try {
3093
- await api.sendMessage(threadId, text);
4357
+ await api.sendMessage(threadId, text, toAttachments(images));
3094
4358
  } catch (e) {
4359
+ const n = (pendingSent.get(key) ?? 1) - 1;
4360
+ if (n > 0) pendingSent.set(key, n);
4361
+ else pendingSent.delete(key);
3095
4362
  tui.print(`${c.dim}failed to send: ${e instanceof Error ? e.message : String(e)}${c.reset}`);
3096
4363
  return;
3097
4364
  }
@@ -3103,16 +4370,25 @@ ${c.dim}why: ${req.requestPermission}${c.reset}` : ""}`,
3103
4370
  if (!queued.length) return;
3104
4371
  const toSend = queued.splice(0);
3105
4372
  tui.setQueuedCount(0);
3106
- for (const t of toSend) await sendNow(t);
4373
+ for (const q of toSend) await sendNow(q.text, q.images);
3107
4374
  };
3108
4375
  const requestCompaction = async () => {
3109
4376
  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
- );
4377
+ await api.compact(threadId);
3114
4378
  } catch (err) {
3115
- tui.print(`${c.red}\u2717${c.reset} couldn't request compaction: ${err.message}`);
4379
+ tui.print(`${c.red}\u2717${c.reset} couldn't start compaction: ${err.message}`);
4380
+ }
4381
+ };
4382
+ const skillsCtl = {
4383
+ list: () => api.listSkills(),
4384
+ setEnabled: (name, enabled) => api.setSkillEnabled(name, enabled),
4385
+ remove: (name) => api.removeSkill(name),
4386
+ // Seed the request into the main chat — the agent researches or authors
4387
+ // the skill there (research_agent + install_skill), visible in the transcript.
4388
+ requestInstall: (query) => {
4389
+ void sendNow(
4390
+ `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.`
4391
+ );
3116
4392
  }
3117
4393
  };
3118
4394
  tui.setCommands([
@@ -3141,25 +4417,34 @@ ${c.dim}why: ${req.requestPermission}${c.reset}` : ""}`,
3141
4417
  },
3142
4418
  run: () => runMcpMenu(tui, mcpCtl)
3143
4419
  },
4420
+ {
4421
+ name: "skills",
4422
+ label: "Agent skills",
4423
+ hint: "list / install / manage",
4424
+ run: () => runSkillsMenu(tui, skillsCtl)
4425
+ },
3144
4426
  { name: "background", label: "Background processes", hint: "list / stop", run: () => runProcessMenu(tui, bgMgr) },
3145
4427
  { name: "view", label: "View thread in AgentBuilder", run: () => viewThread() },
3146
4428
  { name: "keybindings", label: "Keyboard shortcuts", run: () => showKeybindings(tui) },
3147
4429
  { name: "quit", label: "Quit", run: () => quit() }
3148
4430
  ]);
3149
- tui.onSubmit = (text) => {
4431
+ const history = await loadHistory(api, threadId, historySeedThreadId);
4432
+ tui.setHistory(history);
4433
+ tui.onSubmit = (text, images) => {
4434
+ appendHistory(api, threadId, history, text);
3150
4435
  if (editingQueued) {
3151
4436
  editingQueued = false;
3152
- queued.push(text);
4437
+ queued.push({ text, images });
3153
4438
  tui.setQueuedCount(queued.length);
3154
4439
  tui.print(`${c.gray}\u23F3 queued:${c.reset} ${text}`);
3155
4440
  return;
3156
4441
  }
3157
4442
  if (busy) {
3158
- queued.push(text);
4443
+ queued.push({ text, images });
3159
4444
  tui.setQueuedCount(queued.length);
3160
4445
  tui.print(`${c.gray}\u23F3 queued:${c.reset} ${text} ${c.dim}(esc to steer now)${c.reset}`);
3161
4446
  } else {
3162
- void sendNow(text);
4447
+ void sendNow(text, images);
3163
4448
  }
3164
4449
  };
3165
4450
  tui.onInterrupt = () => {
@@ -3180,19 +4465,21 @@ ${c.dim}why: ${req.requestPermission}${c.reset}` : ""}`,
3180
4465
  }
3181
4466
  };
3182
4467
  tui.onUpArrow = () => {
3183
- if (tui.getInput().trim() || queued.length === 0) return;
3184
- const text = queued.pop();
4468
+ if (tui.getInput().trim() || queued.length === 0) return false;
4469
+ const q = queued.pop();
3185
4470
  tui.setQueuedCount(queued.length);
3186
4471
  editingQueued = true;
3187
- tui.setInput(text);
4472
+ tui.setInput(q.text, q.images);
4473
+ return true;
3188
4474
  };
3189
4475
  events.connect();
3190
4476
  await Promise.all([bridge.connect(), stream.connect()]);
4477
+ void api.getGoal(threadId).then((g) => tui.setGoal(g)).catch(() => {
4478
+ });
3191
4479
  tui.banner([
3192
4480
  `${c.bold}${c.magenta}Standard Code${c.reset} ${c.dim}\u2014 coding agent${c.reset}`,
3193
4481
  `${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}`
4482
+ `${c.gray}machine:${c.reset} ${machine} ${c.gray}thread:${c.reset} ${threadId.slice(0, 8)}`
3196
4483
  ]);
3197
4484
  if (resumed) await printHistory(api, threadId, tui);
3198
4485
  try {
@@ -3232,6 +4519,15 @@ ${c.dim}why: ${req.requestPermission}${c.reset}` : ""}`,
3232
4519
  const text = messageText(m.content).trim();
3233
4520
  if (m.role === "assistant" && text) printAssistant(tui, text);
3234
4521
  else if (m.role === "system" && text) tui.print(`${c.dim}${text}${c.reset}`);
4522
+ else if (m.role === "user" && text) {
4523
+ const pending = pendingSent.get(text) ?? 0;
4524
+ if (pending > 0) {
4525
+ if (pending === 1) pendingSent.delete(text);
4526
+ else pendingSent.set(text, pending - 1);
4527
+ } else {
4528
+ tui.printUserMessage(text);
4529
+ }
4530
+ }
3235
4531
  }
3236
4532
  const polledBusy = threadBusy(msgs);
3237
4533
  if (interrupting) {
@@ -3279,6 +4575,56 @@ ${c.dim}why: ${req.requestPermission}${c.reset}` : ""}`,
3279
4575
  await new Promise(() => {
3280
4576
  });
3281
4577
  }
4578
+ async function runSkillsMenu(tui, skills) {
4579
+ let list;
4580
+ try {
4581
+ list = await skills.list();
4582
+ } catch (e) {
4583
+ tui.print(`${c.red}\u2717 couldn't load skills:${c.reset} ${c.gray}${e instanceof Error ? e.message : String(e)}${c.reset}`);
4584
+ return;
4585
+ }
4586
+ const INSTALL = "__install__";
4587
+ const items = list.map((s) => ({
4588
+ label: s.name,
4589
+ hint: `${s.enabled ? "enabled" : "disabled"} \xB7 ${s.files.length} file${s.files.length === 1 ? "" : "s"}`,
4590
+ value: s.name
4591
+ }));
4592
+ items.push({ label: "\uFF0B Install a skill\u2026", hint: "find & install", value: INSTALL });
4593
+ const picked = await tui.select(
4594
+ `${c.bold}Agent skills${c.reset} ${c.dim}(\u2191/\u2193 \xB7 enter \xB7 esc to close)${c.reset}`,
4595
+ items
4596
+ );
4597
+ if (!picked) return;
4598
+ if (picked === INSTALL) {
4599
+ const query = await tui.prompt(
4600
+ "What skill do you want to install?",
4601
+ "the anthropic pdf skill / a skill for writing conventional commits"
4602
+ );
4603
+ if (query) skills.requestInstall(query);
4604
+ return;
4605
+ }
4606
+ const skill = list.find((s) => s.name === picked);
4607
+ tui.print(`${c.cyan}${skill.name}${c.reset}${skill.version ? ` ${c.dim}v${skill.version}${c.reset}` : ""} ${c.gray}\u2014 ${skill.description}${c.reset}`);
4608
+ const action = await tui.select(`${c.bold}${picked}${c.reset}`, [
4609
+ skill.enabled ? { label: "Disable (hide from the agent)", value: "disable" } : { label: "Enable", value: "enable" },
4610
+ { label: "View files", value: "files" },
4611
+ { label: "Remove this skill", value: "remove" },
4612
+ { label: "Back", value: "back" }
4613
+ ]);
4614
+ try {
4615
+ if (action === "enable" || action === "disable") {
4616
+ await skills.setEnabled(picked, action === "enable");
4617
+ tui.print(`${c.gray}${action}d ${picked}${c.reset}`);
4618
+ } else if (action === "files") {
4619
+ for (const f of skill.files) tui.print(` ${c.gray}${f}${c.reset}`);
4620
+ } else if (action === "remove") {
4621
+ await skills.remove(picked);
4622
+ tui.print(`${c.gray}removed ${picked}${c.reset}`);
4623
+ }
4624
+ } catch (e) {
4625
+ tui.print(`${c.red}\u2717 ${e instanceof Error ? e.message : String(e)}${c.reset}`);
4626
+ }
4627
+ }
3282
4628
  async function runLevelMenu(tui, perm) {
3283
4629
  const picked = await tui.select(
3284
4630
  `${c.bold}Auto-accept level${c.reset} ${c.dim}(\u2191/\u2193 \xB7 enter \xB7 shift-tab cycles)${c.reset}`,
@@ -3297,6 +4643,8 @@ function showKeybindings(tui) {
3297
4643
  tui.print(`${c.gray}shortcuts:${c.reset}`);
3298
4644
  tui.print(`${c.gray} shift-tab${c.reset} cycle auto-accept level (1\u20135)`);
3299
4645
  tui.print(`${c.gray} /${c.reset} open the command palette (type to filter)`);
4646
+ tui.print(`${c.gray} ctrl-v${c.reset} paste an image from the clipboard ([#Image 1])`);
4647
+ tui.print(`${c.gray} \u2191 / \u2193${c.reset} cycle past messages (on the input's top line)`);
3300
4648
  tui.print(`${c.gray} ctrl-c${c.reset} quit`);
3301
4649
  }
3302
4650
  async function runProcessMenu(tui, bg) {