talon-agent 1.49.0 → 1.50.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (46) hide show
  1. package/README.md +2 -0
  2. package/package.json +1 -1
  3. package/src/backend/codex/factory.ts +2 -0
  4. package/src/backend/codex/handler/message.ts +10 -0
  5. package/src/backend/kilo/factory.ts +2 -0
  6. package/src/backend/kilo/handler/message.ts +10 -0
  7. package/src/backend/openai-agents/factory.ts +2 -0
  8. package/src/backend/openai-agents/handler/message.ts +10 -0
  9. package/src/backend/opencode/factory.ts +2 -0
  10. package/src/backend/opencode/handler/message.ts +10 -0
  11. package/src/backend/shared/index.ts +4 -0
  12. package/src/backend/shared/turn-interrupt.ts +61 -0
  13. package/src/bootstrap.ts +5 -0
  14. package/src/cli/events.ts +47 -12
  15. package/src/cli/fs.ts +138 -0
  16. package/src/cli/index.ts +31 -7
  17. package/src/cli/tasks.ts +85 -20
  18. package/src/core/engine/gateway-actions/index.ts +3 -0
  19. package/src/core/engine/gateway-actions/native.ts +151 -24
  20. package/src/core/engine/gateway-actions/vfs.ts +69 -0
  21. package/src/core/engine/gateway.ts +28 -0
  22. package/src/core/tasks/table.ts +1 -1
  23. package/src/core/tasks/types.ts +6 -2
  24. package/src/core/tools/index.ts +2 -0
  25. package/src/core/tools/native.ts +19 -6
  26. package/src/core/tools/types.ts +2 -1
  27. package/src/core/tools/vfs.ts +61 -0
  28. package/src/core/vfs/index.ts +113 -0
  29. package/src/core/vfs/mounts/files.ts +154 -0
  30. package/src/core/vfs/mounts/plugins.ts +72 -0
  31. package/src/core/vfs/mounts/proc.ts +125 -0
  32. package/src/core/vfs/types.ts +103 -0
  33. package/src/core/vfs/vfs.ts +237 -0
  34. package/src/core/weaver/weaver.ts +40 -3
  35. package/src/frontend/native/discovery.ts +3 -0
  36. package/src/frontend/native/index.ts +10 -1
  37. package/src/frontend/native/server.ts +65 -6
  38. package/src/frontend/native/tls.ts +313 -0
  39. package/src/storage/journal.ts +91 -0
  40. package/src/storage/repositories/journal-repo.ts +38 -0
  41. package/src/storage/sql/journal.sql +16 -0
  42. package/src/storage/sql/schema.sql +15 -0
  43. package/src/storage/sql/statements.generated.ts +24 -1
  44. package/src/util/config.ts +9 -0
  45. package/src/util/log.ts +1 -0
  46. package/src/util/paths.ts +2 -0
package/src/cli/tasks.ts CHANGED
@@ -2,12 +2,14 @@
2
2
  * `talon ps` / `talon kill` — the task table from the outside.
3
3
  *
4
4
  * Data comes from the running daemon's gateway (`GET /tasks`,
5
- * `POST /tasks/kill`); discovery finds the instance the same way
6
- * `talon status` does. This module only rendersthe table itself
7
- * lives in core/tasks/.
5
+ * `POST /tasks/kill`); `--all` also folds in settled runs from the
6
+ * durable journal in talon.db, which answers across restartswith or
7
+ * without a daemon. This module only renders — the table lives in
8
+ * core/tasks/, the journal in storage/journal.ts.
8
9
  */
9
10
 
10
11
  import pc from "picocolors";
12
+ import { findRunningInstance } from "../core/daemon/discovery.js";
11
13
  import { fetchGateway, requireGatewayPort } from "./daemon-api.js";
12
14
  import type {
13
15
  KillOutcome,
@@ -64,7 +66,12 @@ function displayOrder(tasks: TaskRecord[]): TaskRecord[] {
64
66
  (t) => t.state !== "running" && t.state !== "queued",
65
67
  );
66
68
  live.sort((a, b) => a.id - b.id);
67
- settled.sort((a, b) => b.id - a.id);
69
+ // By time, not id: journal history spans daemon restarts, and per-process
70
+ // ids restart with the daemon.
71
+ settled.sort(
72
+ (a, b) =>
73
+ (b.endedAt ?? b.queuedAt) - (a.endedAt ?? a.queuedAt) || b.id - a.id,
74
+ );
68
75
  return [...live, ...settled];
69
76
  }
70
77
 
@@ -105,27 +112,85 @@ function renderTable(tasks: TaskRecord[], now: number): void {
105
112
  console.log();
106
113
  }
107
114
 
108
- export async function showTasks(): Promise<void> {
115
+ /** Settled runs from the journal, minus any already in the live table. */
116
+ async function journalTasks(
117
+ liveTasks: readonly TaskRecord[],
118
+ limit: number,
119
+ ): Promise<TaskRecord[]> {
120
+ const { readJournal } = await import("../storage/journal.js");
121
+ const seen = new Set(liveTasks.map((t) => `${t.id}:${t.queuedAt}`));
122
+ const historical: TaskRecord[] = [];
123
+ for (const entry of readJournal({ type: "task.settled", limit })) {
124
+ if (entry.event.type !== "task.settled") continue;
125
+ const task = entry.event.task;
126
+ // (id, queuedAt) identifies a run across surfaces — per-process ids
127
+ // repeat between daemon runs, enqueue times don't.
128
+ if (seen.has(`${task.id}:${task.queuedAt}`)) continue;
129
+ seen.add(`${task.id}:${task.queuedAt}`);
130
+ historical.push(task);
131
+ }
132
+ return historical;
133
+ }
134
+
135
+ export async function showTasks(all = false): Promise<void> {
109
136
  console.log();
110
- const port = await requireGatewayPort();
111
- if (port === null) return;
112
137
 
113
- let tasks: TaskRecord[];
114
- try {
115
- const body = (await fetchGateway(port, "/tasks")) as {
116
- tasks?: TaskRecord[];
117
- };
118
- tasks = body.tasks ?? [];
119
- } catch (err) {
120
- console.log(
121
- ` ${pc.red("✖")} Could not read the task table: ${err instanceof Error ? err.message : err}\n`,
122
- );
123
- return;
138
+ let tasks: TaskRecord[] = [];
139
+ let daemonUp = true;
140
+ if (all) {
141
+ // History works without a daemon — fold in the live table only when
142
+ // one is actually running.
143
+ const instance = await findRunningInstance();
144
+ if (instance?.port) {
145
+ try {
146
+ const body = (await fetchGateway(instance.port, "/tasks")) as {
147
+ tasks?: TaskRecord[];
148
+ };
149
+ tasks = body.tasks ?? [];
150
+ } catch {
151
+ daemonUp = false;
152
+ }
153
+ } else {
154
+ daemonUp = false;
155
+ }
156
+ } else {
157
+ const port = await requireGatewayPort();
158
+ if (port === null) return;
159
+ try {
160
+ const body = (await fetchGateway(port, "/tasks")) as {
161
+ tasks?: TaskRecord[];
162
+ };
163
+ tasks = body.tasks ?? [];
164
+ } catch (err) {
165
+ console.log(
166
+ ` ${pc.red("✖")} Could not read the task table: ${err instanceof Error ? err.message : err}\n`,
167
+ );
168
+ return;
169
+ }
170
+ }
171
+
172
+ if (all) {
173
+ if (!daemonUp) {
174
+ console.log(
175
+ ` ${pc.dim("Talon is not running — journal history only.")}`,
176
+ );
177
+ }
178
+ try {
179
+ tasks = [...tasks, ...(await journalTasks(tasks, 200))];
180
+ } catch (err) {
181
+ console.log(
182
+ ` ${pc.red("✖")} Could not read the journal: ${err instanceof Error ? err.message : err}\n`,
183
+ );
184
+ process.exitCode = 1;
185
+ return;
186
+ }
124
187
  }
125
188
 
126
189
  if (tasks.length === 0) {
127
190
  console.log(
128
- ` ${pc.dim("No tasks — the table starts empty on each daemon start.")}\n`,
191
+ all
192
+ ? ` ${pc.dim("No tasks — nothing live and the journal is empty.")}\n`
193
+ : ` ${pc.dim("No tasks — the table starts empty on each daemon start. Older runs: talon ps --all")}\n`,
129
194
  );
130
195
  return;
131
196
  }
@@ -174,7 +239,7 @@ export async function killTask(rawId: string | undefined): Promise<void> {
174
239
  return;
175
240
  case "not-killable":
176
241
  console.log(
177
- ` ${pc.yellow("!")} Task ${id} has no abort hook (chat turns run to completion) it cannot be killed.\n`,
242
+ ` ${pc.yellow("!")} Task ${id} has no abort hook its backend can't interrupt a running turn.\n`,
178
243
  );
179
244
  return;
180
245
  }
@@ -17,6 +17,7 @@
17
17
  * - `plugins` — plugin hot-reload
18
18
  * - `models` — model / backend discovery
19
19
  * - `mesh` — companion device mesh (presence + location)
20
+ * - `vfs` — the unified talon:// namespace (list/read/write)
20
21
  */
21
22
 
22
23
  import type { ActionResult } from "../../types.js";
@@ -33,6 +34,7 @@ import { pluginHandlers } from "./plugins.js";
33
34
  import { modelHandlers } from "./models.js";
34
35
  import { meshHandlers } from "./mesh.js";
35
36
  import { nativeHandlers } from "./native.js";
37
+ import { vfsHandlers } from "./vfs.js";
36
38
 
37
39
  // Null-prototype so a request `action` of "toString" / "constructor" / etc.
38
40
  // can't resolve an inherited Object.prototype method — `handlers[action]` only
@@ -49,6 +51,7 @@ const handlers: SharedActionHandlers = Object.assign(Object.create(null), {
49
51
  ...modelHandlers,
50
52
  ...meshHandlers,
51
53
  ...nativeHandlers,
54
+ ...vfsHandlers,
52
55
  });
53
56
 
54
57
  export async function handleSharedAction(
@@ -33,6 +33,7 @@ import {
33
33
  setTeleport,
34
34
  setTeleportCwd,
35
35
  } from "../../mesh/teleport.js";
36
+ import { getVfs } from "../../vfs/index.js";
36
37
  import {
37
38
  clampExecOutput,
38
39
  createOutputCapture,
@@ -447,6 +448,65 @@ async function bashTeleported(
447
448
  };
448
449
  }
449
450
 
451
+ // ── talon:// address resolution ─────────────────────────────────────────────
452
+
453
+ const NAMESPACE_SCHEME = "talon://";
454
+
455
+ type AddressResolution =
456
+ | { kind: "disk"; path: string }
457
+ | { kind: "virtual" }
458
+ | { kind: "error"; text: string };
459
+
460
+ /**
461
+ * Native fs tools accept talon:// addresses: `Vfs.locate` maps them to the
462
+ * disk location the address names, and the tool then operates on the real
463
+ * file. Synthetic nodes (proc/, plugins/) have no disk location — reads are
464
+ * served straight from the namespace, mutation refuses honestly. Teleport
465
+ * can't cross address spaces: talon:// names daemon state, and a teleported
466
+ * chat's paths belong to the device, so the combination is refused rather
467
+ * than resolved against the wrong filesystem.
468
+ */
469
+ function resolveNamespaceAddress(
470
+ address: string,
471
+ teleportedTo: string | undefined,
472
+ ): AddressResolution {
473
+ if (teleportedTo !== undefined) {
474
+ return {
475
+ kind: "error",
476
+ text:
477
+ `${address} names the daemon's namespace, but native tools are ` +
478
+ `teleported onto ${teleportedTo} — teleport_back first, or use a device path.`,
479
+ };
480
+ }
481
+ const located = getVfs().locate(address);
482
+ if (!located.ok) {
483
+ return {
484
+ kind: "error",
485
+ text: `Cannot resolve ${address}: ${located.error}${located.detail ? ` — ${located.detail}` : ""}`,
486
+ };
487
+ }
488
+ return located.value === undefined
489
+ ? { kind: "virtual" }
490
+ : { kind: "disk", path: located.value };
491
+ }
492
+
493
+ /** glob/search root: a talon:// address must name a disk-backed directory. */
494
+ function resolveSearchRoot(
495
+ path: string | undefined,
496
+ teleportedTo: string | undefined,
497
+ ): { root: string } | { error: string } {
498
+ const root = path ?? ".";
499
+ if (!root.startsWith(NAMESPACE_SCHEME)) return { root };
500
+ const resolved = resolveNamespaceAddress(root, teleportedTo);
501
+ if (resolved.kind === "error") return { error: resolved.text };
502
+ if (resolved.kind === "virtual") {
503
+ return {
504
+ error: `${root} is a synthetic view with no files on disk — browse it with vfs_list instead.`,
505
+ };
506
+ }
507
+ return { root: resolved.path };
508
+ }
509
+
450
510
  // ── read / write / edit ─────────────────────────────────────────────────────
451
511
 
452
512
  async function read(
@@ -455,15 +515,38 @@ async function read(
455
515
  offset: unknown,
456
516
  limit: unknown,
457
517
  ): Promise<Result> {
458
- const p = str(path);
459
- if (!p) return { ok: false, text: "A file path is required." };
518
+ const address = str(path);
519
+ if (!address) return { ok: false, text: "A file path is required." };
460
520
  const active = await getTeleport(chatId);
461
521
  const where = active ? active.deviceName : "local";
462
522
 
523
+ let p = address;
524
+ let virtualContent: string | undefined;
525
+ if (address.startsWith(NAMESPACE_SCHEME)) {
526
+ const resolved = resolveNamespaceAddress(address, active?.deviceName);
527
+ if (resolved.kind === "error") return { ok: false, text: resolved.text };
528
+ if (resolved.kind === "disk") {
529
+ p = resolved.path;
530
+ } else {
531
+ const served = getVfs().read(address);
532
+ if (!served.ok) {
533
+ return {
534
+ ok: false,
535
+ text: `Cannot read ${address}: ${served.error}${served.detail ? ` — ${served.detail}` : ""}`,
536
+ };
537
+ }
538
+ virtualContent = served.value;
539
+ }
540
+ }
541
+ const shown = p === address ? p : `${address} → ${p}`;
542
+
463
543
  // Image files: return a viewable image block, not the raw bytes decoded as
464
544
  // UTF-8. Without this, `read`ing a photo/screenshot/design hands the model
465
545
  // mojibake and it can't see the picture at all.
466
- const mime = IMAGE_MIME[extname(p).toLowerCase()];
546
+ const mime =
547
+ virtualContent === undefined
548
+ ? IMAGE_MIME[extname(p).toLowerCase()]
549
+ : undefined;
467
550
  if (mime) {
468
551
  let bytes: Buffer;
469
552
  if (active) {
@@ -476,7 +559,7 @@ async function read(
476
559
  } catch (err) {
477
560
  return {
478
561
  ok: false,
479
- text: `Cannot read ${p}: ${(err as Error).message}`,
562
+ text: `Cannot read ${shown}: ${(err as Error).message}`,
480
563
  };
481
564
  }
482
565
  }
@@ -484,14 +567,14 @@ async function read(
484
567
  return {
485
568
  ok: false,
486
569
  text:
487
- `${p} [${where}] is ${(bytes.length / 1_048_576).toFixed(1)}MB — over the ` +
570
+ `${shown} [${where}] is ${(bytes.length / 1_048_576).toFixed(1)}MB — over the ` +
488
571
  `${MAX_IMAGE_BYTES / 1_048_576}MB image limit. Downscale it first ` +
489
572
  `(e.g. \`convert '${p}' -resize 1568x /tmp/small.jpg\`) and read that.`,
490
573
  };
491
574
  }
492
575
  return {
493
576
  ok: true,
494
- text: `${p} [${where}] — image (${mime}, ${bytes.length} bytes)`,
577
+ text: `${shown} [${where}] — image (${mime}, ${bytes.length} bytes)`,
495
578
  image: { data: bytes.toString("base64"), mimeType: mime },
496
579
  };
497
580
  }
@@ -499,7 +582,9 @@ async function read(
499
582
  const start = num(offset) ?? 0;
500
583
  const max = Math.min(num(limit) ?? MAX_READ_LINES, MAX_READ_LINES);
501
584
  let content: string;
502
- if (active) {
585
+ if (virtualContent !== undefined) {
586
+ content = virtualContent;
587
+ } else if (active) {
503
588
  const res = await getMeshService().readFileBytes(active.deviceId, p);
504
589
  if ("error" in res) return { ok: false, text: res.error };
505
590
  content = res.data.toString("utf8");
@@ -507,7 +592,10 @@ async function read(
507
592
  try {
508
593
  content = await readFile(p, "utf8");
509
594
  } catch (err) {
510
- return { ok: false, text: `Cannot read ${p}: ${(err as Error).message}` };
595
+ return {
596
+ ok: false,
597
+ text: `Cannot read ${shown}: ${(err as Error).message}`,
598
+ };
511
599
  }
512
600
  }
513
601
  const lines = content.split("\n");
@@ -521,7 +609,7 @@ async function read(
521
609
  : "";
522
610
  return {
523
611
  ok: true,
524
- text: `${p} [${where}] — ${lines.length} lines\n${numbered}${more}`,
612
+ text: `${shown} [${where}] — ${lines.length} lines\n${numbered}${more}`,
525
613
  };
526
614
  }
527
615
 
@@ -530,10 +618,23 @@ async function write(
530
618
  path: unknown,
531
619
  content: unknown,
532
620
  ): Promise<Result> {
533
- const p = str(path);
534
- if (!p) return { ok: false, text: "A file path is required." };
621
+ const address = str(path);
622
+ if (!address) return { ok: false, text: "A file path is required." };
535
623
  const body = typeof content === "string" ? content : "";
536
624
  const active = await getTeleport(chatId);
625
+ let p = address;
626
+ if (address.startsWith(NAMESPACE_SCHEME)) {
627
+ const resolved = resolveNamespaceAddress(address, active?.deviceName);
628
+ if (resolved.kind === "error") return { ok: false, text: resolved.text };
629
+ if (resolved.kind === "virtual") {
630
+ return {
631
+ ok: false,
632
+ text: `${address} is a synthetic view — there is no file on disk to write. Writable mounts: vfs_list "" shows where each lives.`,
633
+ };
634
+ }
635
+ p = resolved.path;
636
+ }
637
+ const shown = p === address ? p : `${address} → ${p}`;
537
638
  if (active) {
538
639
  return getMeshService().writeFileToDevice(active.deviceId, p, body);
539
640
  }
@@ -541,9 +642,12 @@ async function write(
541
642
  await mkdir(dirname(p), { recursive: true });
542
643
  await writeFile(p, body);
543
644
  } catch (err) {
544
- return { ok: false, text: `Cannot write ${p}: ${(err as Error).message}` };
645
+ return {
646
+ ok: false,
647
+ text: `Cannot write ${shown}: ${(err as Error).message}`,
648
+ };
545
649
  }
546
- return { ok: true, text: `Wrote ${body.length} bytes to ${p} [local].` };
650
+ return { ok: true, text: `Wrote ${body.length} bytes to ${shown} [local].` };
547
651
  }
548
652
 
549
653
  async function edit(
@@ -553,13 +657,26 @@ async function edit(
553
657
  newString: unknown,
554
658
  replaceAll: unknown,
555
659
  ): Promise<Result> {
556
- const p = str(path);
557
- if (!p) return { ok: false, text: "A file path is required." };
660
+ const address = str(path);
661
+ if (!address) return { ok: false, text: "A file path is required." };
558
662
  const from = typeof oldString === "string" ? oldString : "";
559
663
  const to = typeof newString === "string" ? newString : "";
560
664
  if (from === to)
561
665
  return { ok: false, text: "old_string and new_string are identical." };
562
666
  const active = await getTeleport(chatId);
667
+ let p = address;
668
+ if (address.startsWith(NAMESPACE_SCHEME)) {
669
+ const resolved = resolveNamespaceAddress(address, active?.deviceName);
670
+ if (resolved.kind === "error") return { ok: false, text: resolved.text };
671
+ if (resolved.kind === "virtual") {
672
+ return {
673
+ ok: false,
674
+ text: `${address} is a synthetic view — there is no file on disk to edit. Read it with vfs_read instead.`,
675
+ };
676
+ }
677
+ p = resolved.path;
678
+ }
679
+ const shown = p === address ? p : `${address} → ${p}`;
563
680
  const svc = getMeshService();
564
681
 
565
682
  let content: string;
@@ -571,7 +688,10 @@ async function edit(
571
688
  try {
572
689
  content = await readFile(p, "utf8");
573
690
  } catch (err) {
574
- return { ok: false, text: `Cannot read ${p}: ${(err as Error).message}` };
691
+ return {
692
+ ok: false,
693
+ text: `Cannot read ${shown}: ${(err as Error).message}`,
694
+ };
575
695
  }
576
696
  }
577
697
 
@@ -579,13 +699,13 @@ async function edit(
579
699
  if (count === 0) {
580
700
  return {
581
701
  ok: false,
582
- text: `old_string not found in ${p}.${nearMissHint(content, from)}`,
702
+ text: `old_string not found in ${shown}.${nearMissHint(content, from)}`,
583
703
  };
584
704
  }
585
705
  if (count > 1 && replaceAll !== true) {
586
706
  return {
587
707
  ok: false,
588
- text: `old_string appears ${count}× in ${p}; pass replace_all or make it unique.`,
708
+ text: `old_string appears ${count}× in ${shown}; pass replace_all or make it unique.`,
589
709
  };
590
710
  }
591
711
  const updated =
@@ -598,18 +718,21 @@ async function edit(
598
718
  return res.ok
599
719
  ? {
600
720
  ok: true,
601
- text: `Edited ${p} [${active.deviceName}] (${count} replacement${count === 1 ? "" : "s"}).`,
721
+ text: `Edited ${shown} [${active.deviceName}] (${count} replacement${count === 1 ? "" : "s"}).`,
602
722
  }
603
723
  : res;
604
724
  }
605
725
  try {
606
726
  await writeFile(p, updated);
607
727
  } catch (err) {
608
- return { ok: false, text: `Cannot write ${p}: ${(err as Error).message}` };
728
+ return {
729
+ ok: false,
730
+ text: `Cannot write ${shown}: ${(err as Error).message}`,
731
+ };
609
732
  }
610
733
  return {
611
734
  ok: true,
612
- text: `Edited ${p} [local] (${count} replacement${count === 1 ? "" : "s"}).`,
735
+ text: `Edited ${shown} [local] (${count} replacement${count === 1 ? "" : "s"}).`,
613
736
  };
614
737
  }
615
738
 
@@ -622,8 +745,10 @@ async function glob(
622
745
  ): Promise<Result> {
623
746
  const pat = str(pattern);
624
747
  if (!pat) return { ok: false, text: "A glob pattern is required." };
625
- const root = str(path) ?? ".";
626
748
  const active = await getTeleport(chatId);
749
+ const rooted = resolveSearchRoot(str(path), active?.deviceName);
750
+ if ("error" in rooted) return { ok: false, text: rooted.error };
751
+ const root = rooted.root;
627
752
  if (active) {
628
753
  // Prefer rg on the device; fall back to find (basename patterns via
629
754
  // -name, path patterns via -path). `command -v` gates the choice so a
@@ -669,7 +794,10 @@ async function search(
669
794
  ): Promise<Result> {
670
795
  const pat = str(pattern);
671
796
  if (!pat) return { ok: false, text: "A search pattern is required." };
672
- const root = str(path) ?? ".";
797
+ const active = await getTeleport(chatId);
798
+ const rooted = resolveSearchRoot(str(path), active?.deviceName);
799
+ if ("error" in rooted) return { ok: false, text: rooted.error };
800
+ const root = rooted.root;
673
801
  const g = str(globPat);
674
802
  const ci = caseInsensitive === true;
675
803
  // `-e` keeps a pattern that starts with "-" from being parsed as a flag
@@ -680,7 +808,6 @@ async function search(
680
808
  ...(ci ? ["-i"] : []),
681
809
  ...(g ? ["-g", g] : []),
682
810
  ];
683
- const active = await getTeleport(chatId);
684
811
  if (active) {
685
812
  // Prefer rg on the device, fall back to grep (Android toybox has grep
686
813
  // but rarely rg). --include is grep's closest analogue of -g.
@@ -0,0 +1,69 @@
1
+ /**
2
+ * VFS — the `talon://` namespace over the gateway: list / read / write.
3
+ * Formatting only; the namespace itself lives in core/vfs.
4
+ */
5
+
6
+ import { getVfs, type VfsResult, type VfsStat } from "../../vfs/index.js";
7
+ import { log } from "../../../util/log.js";
8
+ import type { SharedActionHandlers } from "./types.js";
9
+
10
+ function describeError(
11
+ path: string,
12
+ result: { error: string; detail?: string },
13
+ ): string {
14
+ return `talon://${path}: ${result.error}${result.detail ? ` — ${result.detail}` : ""}`;
15
+ }
16
+
17
+ function formatSize(entry: VfsStat): string {
18
+ return entry.kind === "dir" ? "-" : String(entry.size ?? "?");
19
+ }
20
+
21
+ function formatEntry(entry: VfsStat): string {
22
+ const kind = entry.kind === "dir" ? "d" : "-";
23
+ const write = entry.writable ? "w" : "-";
24
+ const suffix = entry.kind === "dir" ? "/" : "";
25
+ // Root entries (single-segment paths) show where the mount lives on
26
+ // disk — the bridge for OS-level tools, which can't read talon://.
27
+ const mapping =
28
+ entry.osPath !== undefined && !entry.path.includes("/")
29
+ ? ` → ${entry.osPath}`
30
+ : "";
31
+ return `${kind}${write} ${formatSize(entry).padStart(9)} ${entry.name}${suffix}${mapping}`;
32
+ }
33
+
34
+ function normalize(body: Record<string, unknown>): string {
35
+ return String(body.path ?? "").trim();
36
+ }
37
+
38
+ export const vfsHandlers: SharedActionHandlers = {
39
+ vfs_list: (body) => {
40
+ const path = normalize(body);
41
+ const result = getVfs().list(path);
42
+ if (!result.ok) return { ok: false, error: describeError(path, result) };
43
+ const header = `talon://${path.replace(/\/+$/, "")}${path ? "/" : ""} (${result.value.length} entries)`;
44
+ const lines = result.value.map(formatEntry);
45
+ return {
46
+ ok: true,
47
+ text: [header, ...(lines.length > 0 ? lines : ["(empty)"])].join("\n"),
48
+ };
49
+ },
50
+
51
+ vfs_read: (body) => {
52
+ const path = normalize(body);
53
+ const result = getVfs().read(path);
54
+ if (!result.ok) return { ok: false, error: describeError(path, result) };
55
+ return { ok: true, text: result.value };
56
+ },
57
+
58
+ vfs_write: (body) => {
59
+ const path = normalize(body);
60
+ const content = String(body.content ?? "");
61
+ const result: VfsResult<VfsStat> = getVfs().write(path, content);
62
+ if (!result.ok) return { ok: false, error: describeError(path, result) };
63
+ log("gateway", `vfs_write: talon://${result.value.path}`);
64
+ return {
65
+ ok: true,
66
+ text: `Wrote ${result.value.size ?? content.length} bytes to talon://${result.value.path}`,
67
+ };
68
+ },
69
+ };
@@ -414,6 +414,34 @@ export class Gateway {
414
414
  return;
415
415
  }
416
416
 
417
+ if (
418
+ req.method === "GET" &&
419
+ (req.url?.startsWith("/vfs/list") || req.url?.startsWith("/vfs/read"))
420
+ ) {
421
+ // The talon:// namespace — the transport for `talon ls` /
422
+ // `talon cat`, which need the daemon's live synthetic mounts
423
+ // (proc, plugins). Same 127.0.0.1 trust boundary as /action.
424
+ const url = new URL(req.url, "http://gateway");
425
+ const path = url.searchParams.get("path") ?? "";
426
+ const { getVfs } = await import("../vfs/index.js");
427
+ const vfs = getVfs();
428
+ let result: Record<string, unknown>;
429
+ if (url.pathname === "/vfs/list") {
430
+ const listed = vfs.list(path);
431
+ result = listed.ok
432
+ ? { ok: true, entries: listed.value }
433
+ : { ok: false, error: listed.error, detail: listed.detail };
434
+ } else {
435
+ const content = vfs.read(path);
436
+ result = content.ok
437
+ ? { ok: true, content: content.value }
438
+ : { ok: false, error: content.error, detail: content.detail };
439
+ }
440
+ res.writeHead(200, { "Content-Type": "application/json" });
441
+ res.end(JSON.stringify(result));
442
+ return;
443
+ }
444
+
417
445
  if (req.method === "GET" && req.url === "/tasks") {
418
446
  // The task table — every live/recent unit of agent work. Read by
419
447
  // `talon ps`. Same 127.0.0.1 trust boundary as /action.
@@ -92,7 +92,7 @@ export class TaskTable {
92
92
  start: () => this.start(id),
93
93
  bind: (binding) => this.bind(id, binding),
94
94
  succeed: (usage) => this.settle(id, "done", undefined, usage),
95
- fail: (error) => this.settle(id, "failed", error),
95
+ fail: (error, usage) => this.settle(id, "failed", error, usage),
96
96
  };
97
97
  }
98
98
 
@@ -87,8 +87,12 @@ export interface TaskHandle {
87
87
  bind(binding: TaskBinding): void;
88
88
  /** Settle as `done`, optionally recording token usage. */
89
89
  succeed(usage?: TaskUsage): void;
90
- /** Settle as `failed` — or `killed` when a kill was requested first. */
91
- fail(error: unknown): void;
90
+ /**
91
+ * Settle as `failed` — or `killed` when a kill was requested first.
92
+ * Usage is recorded when the owner knows what the run burned before it
93
+ * died (an interrupted turn still spent real tokens).
94
+ */
95
+ fail(error: unknown, usage?: TaskUsage): void;
92
96
  }
93
97
 
94
98
  /** Result of `TaskTable.kill`. */
@@ -23,6 +23,7 @@ import { adminTools } from "./admin.js";
23
23
  import { modelTools } from "./models.js";
24
24
  import { meshTools } from "./mesh.js";
25
25
  import { nativeTools } from "./native.js";
26
+ import { vfsTools } from "./vfs.js";
26
27
 
27
28
  /** All built-in tool definitions. */
28
29
  export const ALL_TOOLS: readonly ToolDefinition[] = [
@@ -41,6 +42,7 @@ export const ALL_TOOLS: readonly ToolDefinition[] = [
41
42
  ...adminTools,
42
43
  ...modelTools,
43
44
  ...meshTools,
45
+ ...vfsTools,
44
46
  ];
45
47
 
46
48
  /**