talon-agent 1.48.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 (50) hide show
  1. package/README.md +3 -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 +12 -5
  14. package/src/cli/daemon-api.ts +39 -0
  15. package/src/cli/events.ts +133 -0
  16. package/src/cli/fs.ts +138 -0
  17. package/src/cli/index.ts +36 -2
  18. package/src/cli/tasks.ts +82 -48
  19. package/src/core/bus/bus.ts +114 -0
  20. package/src/core/bus/events.ts +64 -0
  21. package/src/core/bus/index.ts +20 -0
  22. package/src/core/engine/gateway-actions/index.ts +3 -0
  23. package/src/core/engine/gateway-actions/native.ts +151 -24
  24. package/src/core/engine/gateway-actions/vfs.ts +69 -0
  25. package/src/core/engine/gateway.ts +46 -0
  26. package/src/core/tasks/table.ts +25 -4
  27. package/src/core/tasks/types.ts +6 -2
  28. package/src/core/tools/index.ts +2 -0
  29. package/src/core/tools/native.ts +19 -6
  30. package/src/core/tools/types.ts +2 -1
  31. package/src/core/tools/vfs.ts +61 -0
  32. package/src/core/vfs/index.ts +113 -0
  33. package/src/core/vfs/mounts/files.ts +154 -0
  34. package/src/core/vfs/mounts/plugins.ts +72 -0
  35. package/src/core/vfs/mounts/proc.ts +125 -0
  36. package/src/core/vfs/types.ts +103 -0
  37. package/src/core/vfs/vfs.ts +237 -0
  38. package/src/core/weaver/weaver.ts +62 -15
  39. package/src/frontend/native/discovery.ts +3 -0
  40. package/src/frontend/native/index.ts +10 -1
  41. package/src/frontend/native/server.ts +65 -6
  42. package/src/frontend/native/tls.ts +313 -0
  43. package/src/storage/journal.ts +91 -0
  44. package/src/storage/repositories/journal-repo.ts +38 -0
  45. package/src/storage/sql/journal.sql +16 -0
  46. package/src/storage/sql/schema.sql +15 -0
  47. package/src/storage/sql/statements.generated.ts +24 -1
  48. package/src/util/config.ts +9 -0
  49. package/src/util/log.ts +2 -0
  50. package/src/util/paths.ts +2 -0
@@ -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
+ };
@@ -27,6 +27,7 @@ import {
27
27
  HUB_PATH_PREFIX,
28
28
  } from "../mcp-hub/index.js";
29
29
  import { handlePluginAction } from "../plugin/index.js";
30
+ import { bus } from "../bus/index.js";
30
31
  import { taskTable } from "../tasks/index.js";
31
32
  import type { FrontendActionHandler } from "../types.js";
32
33
  import { BOT_MESSAGE_ACTIONS, noteBotMessage } from "../soul/taps.js";
@@ -396,6 +397,51 @@ export class Gateway {
396
397
  return;
397
398
  }
398
399
 
400
+ if (req.method === "GET" && req.url?.startsWith("/events/recent")) {
401
+ // Bus tail — recent events, optionally after a cursor. Read by
402
+ // `talon events`. Same 127.0.0.1 trust boundary as /action.
403
+ const url = new URL(req.url, "http://gateway");
404
+ const since = Number(url.searchParams.get("since") ?? "0");
405
+ res.writeHead(200, { "Content-Type": "application/json" });
406
+ res.end(
407
+ JSON.stringify({
408
+ ok: true,
409
+ events: bus.recent(
410
+ Number.isInteger(since) && since > 0 ? since : 0,
411
+ ),
412
+ }),
413
+ );
414
+ return;
415
+ }
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
+
399
445
  if (req.method === "GET" && req.url === "/tasks") {
400
446
  // The task table — every live/recent unit of agent work. Read by
401
447
  // `talon ps`. Same 127.0.0.1 trust boundary as /action.
@@ -12,6 +12,8 @@
12
12
  * work that no longer exists.
13
13
  */
14
14
 
15
+ import { bus } from "../bus/index.js";
16
+ import type { TaskSettledEvent, TaskStartedEvent } from "../bus/events.js";
15
17
  import type {
16
18
  KillOutcome,
17
19
  TaskBinding,
@@ -25,6 +27,17 @@ import type {
25
27
  /** Settled tasks kept for `list()` after they leave the live map. */
26
28
  const DEFAULT_HISTORY_LIMIT = 50;
27
29
 
30
+ export interface TaskTableOptions {
31
+ /** Settled tasks kept for `list()` (default 50). */
32
+ readonly historyLimit?: number;
33
+ /**
34
+ * Sink for `task.*` lifecycle events — the singleton wires the bus here.
35
+ * Injected (rather than imported at the emit sites) so unit-constructed
36
+ * tables stay silent.
37
+ */
38
+ readonly publish?: (event: TaskStartedEvent | TaskSettledEvent) => void;
39
+ }
40
+
28
41
  type MutableTaskRecord = {
29
42
  -readonly [K in keyof TaskRecord]: TaskRecord[K];
30
43
  };
@@ -39,10 +52,14 @@ export class TaskTable {
39
52
  private readonly live = new Map<number, LiveTask>();
40
53
  private readonly history: TaskRecord[] = [];
41
54
  private readonly historyLimit: number;
55
+ private readonly publish?: (
56
+ event: TaskStartedEvent | TaskSettledEvent,
57
+ ) => void;
42
58
  private nextId = 1;
43
59
 
44
- constructor(historyLimit = DEFAULT_HISTORY_LIMIT) {
45
- this.historyLimit = historyLimit;
60
+ constructor(options: TaskTableOptions = {}) {
61
+ this.historyLimit = options.historyLimit ?? DEFAULT_HISTORY_LIMIT;
62
+ if (options.publish) this.publish = options.publish;
46
63
  }
47
64
 
48
65
  /** Register a run that starts immediately. */
@@ -75,7 +92,7 @@ export class TaskTable {
75
92
  start: () => this.start(id),
76
93
  bind: (binding) => this.bind(id, binding),
77
94
  succeed: (usage) => this.settle(id, "done", undefined, usage),
78
- fail: (error) => this.settle(id, "failed", error),
95
+ fail: (error, usage) => this.settle(id, "failed", error, usage),
79
96
  };
80
97
  }
81
98
 
@@ -122,6 +139,7 @@ export class TaskTable {
122
139
  if (!task || task.record.state !== "queued") return;
123
140
  task.record.state = "running";
124
141
  task.record.startedAt = Date.now();
142
+ this.publish?.({ type: "task.started", task: { ...task.record } });
125
143
  }
126
144
 
127
145
  private bind(id: number, binding: TaskBinding): void {
@@ -154,8 +172,11 @@ export class TaskTable {
154
172
  if (this.history.length > this.historyLimit) {
155
173
  this.history.splice(0, this.history.length - this.historyLimit);
156
174
  }
175
+ this.publish?.({ type: "task.settled", task: { ...record } });
157
176
  }
158
177
  }
159
178
 
160
179
  /** The daemon-wide table. Tests needing isolation construct their own. */
161
- export const taskTable = new TaskTable();
180
+ export const taskTable = new TaskTable({
181
+ publish: (event) => bus.publish(event),
182
+ });
@@ -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
  /**
@@ -39,7 +39,8 @@ export const nativeTools: ToolDefinition[] = [
39
39
  name: "bash",
40
40
  description:
41
41
  "Run a shell command. Runs on the daemon host, or ON the active teleport device if one is engaged. On a teleported device, `cd` persists across calls (a real working-directory session). " +
42
- "Foreground runs are killed at timeout_sec — for streaming/never-ending commands (adb logcat, tail -f, dev servers, watchers) set background:true instead: the command is launched detached in its own process group with stdout+stderr captured to a log file, and the tool returns immediately with the pid + log path so you can poll the log and kill it when done.",
42
+ "Foreground runs are killed at timeout_sec — for streaming/never-ending commands (adb logcat, tail -f, dev servers, watchers) set background:true instead: the command is launched detached in its own process group with stdout+stderr captured to a log file, and the tool returns immediately with the pid + log path so you can poll the log and kill it when done. " +
43
+ "talon:// addresses are not OS paths — the shell needs the disk locations the namespace root listing shows (vfs_list).",
43
44
  schema: {
44
45
  command: z.string().describe("The shell command to run."),
45
46
  cwd: z
@@ -69,7 +70,11 @@ export const nativeTools: ToolDefinition[] = [
69
70
  description:
70
71
  "Read a file (with line numbers). Runs on the daemon host or the active teleport device. Supports offset/limit for large files.",
71
72
  schema: {
72
- path: z.string().describe("Absolute file path."),
73
+ path: z
74
+ .string()
75
+ .describe(
76
+ "Absolute file path, or a talon:// namespace address (resolved on the daemon host).",
77
+ ),
73
78
  offset: z.number().optional().describe("0-based line to start from."),
74
79
  limit: z
75
80
  .number()
@@ -84,7 +89,11 @@ export const nativeTools: ToolDefinition[] = [
84
89
  description:
85
90
  "Write (create/overwrite) a file with the given content. Runs on the daemon host or the active teleport device.",
86
91
  schema: {
87
- path: z.string().describe("Absolute file path."),
92
+ path: z
93
+ .string()
94
+ .describe(
95
+ "Absolute file path, or a talon:// namespace address (resolved on the daemon host).",
96
+ ),
88
97
  content: z.string().describe("Full file content to write."),
89
98
  },
90
99
  execute: (params, bridge) => bridge("native_write", params),
@@ -95,7 +104,11 @@ export const nativeTools: ToolDefinition[] = [
95
104
  description:
96
105
  "Exact-string replacement in a file. old_string must be unique unless replace_all is set. Runs on the daemon host or the active teleport device.",
97
106
  schema: {
98
- path: z.string().describe("Absolute file path."),
107
+ path: z
108
+ .string()
109
+ .describe(
110
+ "Absolute file path, or a talon:// namespace address (resolved on the daemon host).",
111
+ ),
99
112
  old_string: z.string().describe("Exact text to replace."),
100
113
  new_string: z.string().describe("Replacement text."),
101
114
  replace_all: z
@@ -115,7 +128,7 @@ export const nativeTools: ToolDefinition[] = [
115
128
  path: z
116
129
  .string()
117
130
  .optional()
118
- .describe("Root directory to search (default cwd)."),
131
+ .describe("Root directory to search (default cwd; talon:// accepted)."),
119
132
  },
120
133
  execute: (params, bridge) => bridge("native_glob", params),
121
134
  tag: "native",
@@ -129,7 +142,7 @@ export const nativeTools: ToolDefinition[] = [
129
142
  path: z
130
143
  .string()
131
144
  .optional()
132
- .describe("Root directory or file (default cwd)."),
145
+ .describe("Root directory or file (default cwd; talon:// accepted)."),
133
146
  glob: z
134
147
  .string()
135
148
  .optional()
@@ -28,7 +28,8 @@ export type ToolTag =
28
28
  | "admin"
29
29
  | "models"
30
30
  | "mesh"
31
- | "native";
31
+ | "native"
32
+ | "vfs";
32
33
 
33
34
  /** The bridge caller signature — injected into execute(). */
34
35
  export type BridgeFunction = (