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.
- package/README.md +3 -0
- package/package.json +1 -1
- package/src/backend/codex/factory.ts +2 -0
- package/src/backend/codex/handler/message.ts +10 -0
- package/src/backend/kilo/factory.ts +2 -0
- package/src/backend/kilo/handler/message.ts +10 -0
- package/src/backend/openai-agents/factory.ts +2 -0
- package/src/backend/openai-agents/handler/message.ts +10 -0
- package/src/backend/opencode/factory.ts +2 -0
- package/src/backend/opencode/handler/message.ts +10 -0
- package/src/backend/shared/index.ts +4 -0
- package/src/backend/shared/turn-interrupt.ts +61 -0
- package/src/bootstrap.ts +12 -5
- package/src/cli/daemon-api.ts +39 -0
- package/src/cli/events.ts +133 -0
- package/src/cli/fs.ts +138 -0
- package/src/cli/index.ts +36 -2
- package/src/cli/tasks.ts +82 -48
- package/src/core/bus/bus.ts +114 -0
- package/src/core/bus/events.ts +64 -0
- package/src/core/bus/index.ts +20 -0
- package/src/core/engine/gateway-actions/index.ts +3 -0
- package/src/core/engine/gateway-actions/native.ts +151 -24
- package/src/core/engine/gateway-actions/vfs.ts +69 -0
- package/src/core/engine/gateway.ts +46 -0
- package/src/core/tasks/table.ts +25 -4
- package/src/core/tasks/types.ts +6 -2
- package/src/core/tools/index.ts +2 -0
- package/src/core/tools/native.ts +19 -6
- package/src/core/tools/types.ts +2 -1
- package/src/core/tools/vfs.ts +61 -0
- package/src/core/vfs/index.ts +113 -0
- package/src/core/vfs/mounts/files.ts +154 -0
- package/src/core/vfs/mounts/plugins.ts +72 -0
- package/src/core/vfs/mounts/proc.ts +125 -0
- package/src/core/vfs/types.ts +103 -0
- package/src/core/vfs/vfs.ts +237 -0
- package/src/core/weaver/weaver.ts +62 -15
- package/src/frontend/native/discovery.ts +3 -0
- package/src/frontend/native/index.ts +10 -1
- package/src/frontend/native/server.ts +65 -6
- package/src/frontend/native/tls.ts +313 -0
- package/src/storage/journal.ts +91 -0
- package/src/storage/repositories/journal-repo.ts +38 -0
- package/src/storage/sql/journal.sql +16 -0
- package/src/storage/sql/schema.sql +15 -0
- package/src/storage/sql/statements.generated.ts +24 -1
- package/src/util/config.ts +9 -0
- package/src/util/log.ts +2 -0
- 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
|
|
459
|
-
if (!
|
|
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 =
|
|
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 ${
|
|
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
|
-
`${
|
|
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: `${
|
|
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 (
|
|
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 {
|
|
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: `${
|
|
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
|
|
534
|
-
if (!
|
|
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 {
|
|
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 ${
|
|
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
|
|
557
|
-
if (!
|
|
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 {
|
|
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 ${
|
|
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 ${
|
|
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 ${
|
|
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 {
|
|
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 ${
|
|
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
|
|
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.
|
package/src/core/tasks/table.ts
CHANGED
|
@@ -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(
|
|
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
|
+
});
|
package/src/core/tasks/types.ts
CHANGED
|
@@ -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
|
-
/**
|
|
91
|
-
|
|
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`. */
|
package/src/core/tools/index.ts
CHANGED
|
@@ -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
|
/**
|
package/src/core/tools/native.ts
CHANGED
|
@@ -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
|
|
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
|
|
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
|
|
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()
|