talon-agent 1.53.0 → 2.0.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.
@@ -0,0 +1,170 @@
1
+ /**
2
+ * talon:// → real path translation — the seam that makes the namespace
3
+ * seamless for OS-level tools.
4
+ *
5
+ * Because the namespace exists on disk (~/.talon/ns/, see nsdir.ts and
6
+ * fusefs.ts), translating an address is pure prefix substitution — no
7
+ * tokenizing, no quoting rules, correct anywhere in a command string:
8
+ *
9
+ * FUSE mounted talon:// → ~/.talon/ns/ (total)
10
+ * fuseless talon://home/x → <workspace>/x (per mount)
11
+ * talon:// → ~/.talon/ns (symlink farm)
12
+ * talon://proc/x → refused — live views exist only
13
+ * while the FUSE layer is mounted
14
+ *
15
+ * Two consumers: `resolveNamespacePath` for a single address parameter
16
+ * (read/write/edit/glob/search), `rewriteNamespaceRefs` for a whole
17
+ * shell command (bash). Both return real host paths that ordinary fs
18
+ * code and child processes use with no further special-casing.
19
+ */
20
+
21
+ import { dirs } from "../../util/paths.js";
22
+ import type { Vfs } from "./vfs.js";
23
+
24
+ const SCHEME = "talon://";
25
+
26
+ /** Chars that can continue a mount name — used as a replace boundary. */
27
+ const MOUNT_NAME = /^[a-z0-9-]+/;
28
+
29
+ export type PathResolution =
30
+ { ok: true; path: string } | { ok: false; error: string };
31
+
32
+ export type CommandRewrite =
33
+ | { ok: true; command: string; mappings: string[] }
34
+ | { ok: false; error: string };
35
+
36
+ function lockedError(name: string): string {
37
+ return (
38
+ `talon://${name} is a live view that only exists while the FUSE layer is ` +
39
+ `mounted, and it isn't mounted on this host (config \`fuse: "off"\`, ` +
40
+ `missing talon-fusefs addon, or no FUSE support). File-backed mounts ` +
41
+ `under ${dirs.ns} keep working.`
42
+ );
43
+ }
44
+
45
+ function unknownMountError(name: string, vfs: Vfs): string {
46
+ const mounts = vfs
47
+ .describeMounts()
48
+ .map((mount) => mount.name)
49
+ .join(", ");
50
+ return `No mount "${name}" in the talon:// namespace — mounts: ${mounts}`;
51
+ }
52
+
53
+ function isSynthetic(name: string, vfs: Vfs): boolean {
54
+ return vfs
55
+ .describeMounts()
56
+ .some((mount) => mount.name === name && mount.osRoot === undefined);
57
+ }
58
+
59
+ /**
60
+ * Resolve one talon:// address to the real host path it names. The
61
+ * caller has already checked the scheme prefix. With FUSE mounted every
62
+ * address maps into the mountpoint; fuseless, file-backed mounts map to
63
+ * their disk roots and live views are refused (they don't exist).
64
+ */
65
+ export function resolveNamespacePath(
66
+ address: string,
67
+ vfs: Vfs,
68
+ fuseMounted: boolean,
69
+ nsRoot: string = dirs.ns,
70
+ ): PathResolution {
71
+ const rest = address.slice(SCHEME.length).replace(/^\/+/, "");
72
+ if (rest === "") return { ok: true, path: nsRoot };
73
+ if (fuseMounted) return { ok: true, path: `${nsRoot}/${rest}` };
74
+
75
+ const name = MOUNT_NAME.exec(rest)?.[0] ?? "";
76
+ if (isSynthetic(name, vfs)) return { ok: false, error: lockedError(name) };
77
+
78
+ const located = vfs.locate(address);
79
+ if (!located.ok) {
80
+ return {
81
+ ok: false,
82
+ error: `Cannot resolve ${address}: ${located.error}${located.detail ? ` — ${located.detail}` : ""}`,
83
+ };
84
+ }
85
+ if (located.value === undefined) {
86
+ // A mount the table knows but has no disk root and isn't flagged
87
+ // synthetic can't exist; treat as locked for the same honest reason.
88
+ return { ok: false, error: lockedError(name) };
89
+ }
90
+ return { ok: true, path: located.value };
91
+ }
92
+
93
+ /**
94
+ * Translate every talon:// reference in a shell command to its real
95
+ * path, so `ls talon://home | head` runs untouched by any namespace
96
+ * plumbing. Returns the applied mappings for the tool result, so the
97
+ * translation is visible rather than silent.
98
+ */
99
+ export function rewriteNamespaceRefs(
100
+ command: string,
101
+ vfs: Vfs,
102
+ fuseMounted: boolean,
103
+ nsRoot: string = dirs.ns,
104
+ ): CommandRewrite {
105
+ if (!command.includes("talon:")) {
106
+ return { ok: true, command, mappings: [] };
107
+ }
108
+ // Near-miss schemes that are unambiguously typos get corrected rather
109
+ // than letting the shell chase a path that never existed: the
110
+ // single-slash form (talon:/x), and talon:<name> when <name> is a real
111
+ // mount. Anything else containing "talon:" (a grep pattern, a log tag)
112
+ // passes through untouched.
113
+ const nearMiss =
114
+ /talon:\/(?!\/)([a-z0-9-][^\s'"`]*)/.exec(command) ??
115
+ /talon:(?!\/)([a-z0-9-]+)/.exec(command);
116
+ if (nearMiss) {
117
+ const rest = nearMiss[1]!;
118
+ const name = MOUNT_NAME.exec(rest)?.[0] ?? "";
119
+ const known = vfs.describeMounts().some((mount) => mount.name === name);
120
+ if (nearMiss[0].startsWith("talon:/") || known) {
121
+ return {
122
+ ok: false,
123
+ error: `"${nearMiss[0]}" is not an address — spell it ${SCHEME}${rest}`,
124
+ };
125
+ }
126
+ }
127
+ if (!command.includes(SCHEME)) {
128
+ return { ok: true, command, mappings: [] };
129
+ }
130
+
131
+ if (fuseMounted) {
132
+ return {
133
+ ok: true,
134
+ command: command.split(SCHEME).join(`${nsRoot}/`),
135
+ mappings: [`talon:// → ${nsRoot}/`],
136
+ };
137
+ }
138
+
139
+ let rewritten = command;
140
+ const mappings: string[] = [];
141
+ const mounts = vfs
142
+ .describeMounts()
143
+ .filter((mount) => mount.osRoot !== undefined)
144
+ .sort((a, b) => b.name.length - a.name.length);
145
+ for (const mount of mounts) {
146
+ const ref = new RegExp(`talon://${mount.name}(?![a-z0-9-])`, "g");
147
+ if (ref.test(rewritten)) {
148
+ rewritten = rewritten.replace(ref, mount.osRoot!);
149
+ mappings.push(`talon://${mount.name} → ${mount.osRoot}`);
150
+ }
151
+ }
152
+
153
+ const residual = /talon:\/\/([a-z0-9-]+)/.exec(rewritten);
154
+ if (residual) {
155
+ const name = residual[1]!;
156
+ return {
157
+ ok: false,
158
+ error: isSynthetic(name, vfs)
159
+ ? lockedError(name)
160
+ : unknownMountError(name, vfs),
161
+ };
162
+ }
163
+
164
+ // Bare talon:// (the namespace root) — the symlink farm is always there.
165
+ if (rewritten.includes(SCHEME)) {
166
+ rewritten = rewritten.split(SCHEME).join(`${nsRoot}/`);
167
+ mappings.push(`talon:// → ${nsRoot}/`);
168
+ }
169
+ return { ok: true, command: rewritten, mappings };
170
+ }
@@ -6,18 +6,18 @@
6
6
  * re-prefixing the mount-relative stats that come back. Mounts therefore
7
7
  * only ever see clean relative paths.
8
8
  *
9
- * An address is one of three spellings of the same node:
9
+ * An address is one of exactly two spellings of the same node:
10
10
  *
11
11
  * talon://home/notes.md scheme form — always namespace-interpreted
12
- * home/notes.md mount-relative form
13
12
  * <workspace>/notes.md OS-absolute form — routed through the mount
14
13
  * table by containment, exactly like a kernel
15
14
  * resolving a path through its mounts
16
15
  *
17
- * The grammar is total: an unschemed address starting with a separator or
18
- * drive prefix is always an OS path, never mount-relative so an OS path
19
- * can't silently misroute into a like-named mount, and a namespace path
20
- * can't be mistaken for disk.
16
+ * Nothing else is an address. Bare relative paths and `~` spellings are
17
+ * refused with the correction rather than guessed atthe old
18
+ * mount-relative form (`home/notes.md`) was ambiguous with a relative OS
19
+ * path and is gone. The empty string (and bare separators) still name the
20
+ * namespace root for internal callers.
21
21
  */
22
22
 
23
23
  import { isAbsolute, relative, resolve, sep } from "node:path";
@@ -121,31 +121,46 @@ export class Vfs {
121
121
  );
122
122
  }
123
123
 
124
- /** Mount names + descriptions, for docs/tool output. */
125
- describeMounts(): { name: string; description: string; writable: boolean }[] {
124
+ /**
125
+ * The mount table as data: name, description, writability, and for
126
+ * file-backed mounts — the disk root. Consumed by the namespace dir
127
+ * builder (symlink farm), the command rewriter, and docs/tool output.
128
+ */
129
+ describeMounts(): {
130
+ name: string;
131
+ description: string;
132
+ writable: boolean;
133
+ osRoot?: string;
134
+ }[] {
126
135
  return [...this.#mounts.entries()].map(([name, mount]) => ({
127
136
  name,
128
137
  description: mount.description,
129
138
  writable: mount.writable,
139
+ ...(mount.osRoot !== undefined ? { osRoot: mount.osRoot } : {}),
130
140
  }));
131
141
  }
132
142
 
133
143
  /** null = the namespace root. */
134
144
  #parse(raw: string): VfsResult<Resolved | null> {
135
145
  let path = raw.trim();
136
- const schemed = path.startsWith(SCHEME);
137
- if (schemed) {
146
+ if (path.startsWith(SCHEME)) {
138
147
  path = path.slice(SCHEME.length);
148
+ } else if (path === "" || /^[\\/]+$/.test(path)) {
149
+ // Internal callers address the namespace root as "" or bare separators.
150
+ return vfsOk(null);
151
+ } else if (OS_ABSOLUTE.test(path)) {
152
+ return this.#parseOsPath(path);
153
+ } else if (path.startsWith("talon:")) {
154
+ // A near-miss scheme (talon:/x, talon:x) is always a typo, never a path.
155
+ return vfsError(
156
+ "invalid-path",
157
+ `Did you mean "${SCHEME}${path.replace(/^talon:\/{0,2}/, "")}"?`,
158
+ );
139
159
  } else {
140
- // Bare separators address the namespace root ("talon ls /").
141
- if (/^[\\/]+$/.test(path)) return vfsOk(null);
142
- if (OS_ABSOLUTE.test(path)) return this.#parseOsPath(path);
143
- if (path.startsWith("~")) {
144
- return vfsError(
145
- "invalid-path",
146
- '"~" is not expanded — use an absolute path or a talon:// address',
147
- );
148
- }
160
+ return vfsError(
161
+ "invalid-path",
162
+ `Not an address — use ${SCHEME}<mount>/… or an absolute OS path`,
163
+ );
149
164
  }
150
165
  if (path.includes("\\")) {
151
166
  return vfsError(
@@ -0,0 +1,97 @@
1
+ /**
2
+ * talon-fusefs — TypeScript boundary over the FUSE addon
3
+ * (native/talon-fusefs, `bin/talon-fusefs.node`).
4
+ *
5
+ * The addon mounts the talon:// namespace at ~/.talon/ns while the
6
+ * daemon runs: file-backed mounts are served as symlinks (the kernel
7
+ * follows them — heavy file I/O never crosses the FUSE boundary) and
8
+ * synthetic mounts (proc/, plugins/) are served live through a
9
+ * threadsafe callback bridge into the JS Vfs.
10
+ *
11
+ * Per-arch artifact like blake3-napi: present on binary-channel and
12
+ * source installs, absent otherwise — absence simply means the FUSE
13
+ * layer is off and the namespace falls back to the symlink farm.
14
+ * `TALON_FUSEFS_NODE` overrides the location, `TALON_NO_FUSEFS=1`
15
+ * disables it. Linux-only: fuser's pure-Rust mount path (no libfuse
16
+ * link) needs only /dev/fuse + fusermount3 at runtime.
17
+ */
18
+
19
+ import { createRequire } from "node:module";
20
+ import { fileURLToPath } from "node:url";
21
+
22
+ /** One root symlink entry the mount serves (a file-backed mount). */
23
+ export interface FuseSymlinkSpec {
24
+ name: string;
25
+ target: string;
26
+ }
27
+
28
+ /**
29
+ * A bridge request from the FUSE thread. Answer by calling `reply(id,
30
+ * json)` — every request MUST be answered or the kernel caller waits
31
+ * out the addon's internal timeout and gets EIO. `path` is
32
+ * namespace-relative ("proc/tasks/7"). The reply JSON shapes are
33
+ * defined by `serveNamespaceRequest` in core/vfs/fusefs.ts.
34
+ */
35
+ export type FuseBridgeHandler = (id: number, op: string, path: string) => void;
36
+
37
+ /** The N-API surface exported by native/talon-fusefs. */
38
+ export interface NativeFuseFs {
39
+ version(): string;
40
+ /**
41
+ * Mount at `mountpoint` and return once the kernel accepted the
42
+ * mount. Throws when mounting fails (no /dev/fuse, no fusermount,
43
+ * mountpoint busy). The FUSE session runs on its own thread until
44
+ * `unmount()`.
45
+ */
46
+ mount(
47
+ mountpoint: string,
48
+ symlinks: FuseSymlinkSpec[],
49
+ synthetic: string[],
50
+ onRequest: FuseBridgeHandler,
51
+ ): void;
52
+ /** Answer a bridge request. Unknown/expired ids are ignored. */
53
+ reply(id: number, json: string): void;
54
+ /** Tear the mount down. Idempotent. */
55
+ unmount(): void;
56
+ }
57
+
58
+ let addon: NativeFuseFs | null | undefined;
59
+
60
+ /** Resolve the addon, or null when the FUSE layer must stay off. */
61
+ export function nativeFuseFs(): NativeFuseFs | null {
62
+ if (addon === undefined) addon = loadNativeFuseFs();
63
+ return addon;
64
+ }
65
+
66
+ function loadNativeFuseFs(): NativeFuseFs | null {
67
+ if (process.env.TALON_NO_FUSEFS === "1") return null;
68
+
69
+ let candidate = process.env.TALON_FUSEFS_NODE;
70
+ if (!candidate) {
71
+ try {
72
+ // Beside bin/talon.js — where build:fusefs and packaging put it.
73
+ // Throws under bun single-binary builds (no real fs URL): those
74
+ // ship through channels that set TALON_FUSEFS_NODE, or fall back.
75
+ candidate = fileURLToPath(
76
+ new URL("../../bin/talon-fusefs.node", import.meta.url),
77
+ );
78
+ } catch {
79
+ return null;
80
+ }
81
+ }
82
+ try {
83
+ const requireAddon = createRequire(import.meta.url);
84
+ const loaded = requireAddon(candidate) as NativeFuseFs;
85
+ // Trust nothing that can't state its version — a truncated or
86
+ // wrong-arch artifact fails here and the FUSE layer stays off.
87
+ if (typeof loaded.version() !== "string") return null;
88
+ return loaded;
89
+ } catch {
90
+ return null;
91
+ }
92
+ }
93
+
94
+ /** Tests swap addons via TALON_FUSEFS_NODE and need the memo dropped. */
95
+ export function _resetNativeFuseFsForTesting(): void {
96
+ addon = undefined;
97
+ }
@@ -479,6 +479,14 @@ const configSchema = z.object({
479
479
  // and restart), with an instant rollback by flipping back to false.
480
480
  nativeTools: z.boolean().default(false),
481
481
 
482
+ // FUSE layer for the talon:// namespace (~/.talon/ns). "auto" mounts the
483
+ // live views (proc/, plugins/) when the host can (Linux, /dev/fuse, the
484
+ // talon-fusefs addon present) and degrades to the plain symlink farm with a
485
+ // logged reason when it can't — fuseless hosts get the identical namespace
486
+ // minus live views. "off" never mounts. There is no "force": a mount that
487
+ // can't come up healthy is always rolled back rather than half-served.
488
+ fuse: z.enum(["auto", "off"]).default("auto"),
489
+
482
490
  // Display name shown in terminal UI (defaults to "Talon")
483
491
  botDisplayName: z.string().default("Talon"),
484
492
 
package/src/util/log.ts CHANGED
@@ -48,6 +48,7 @@ export type LogComponent =
48
48
  | "heartbeat"
49
49
  | "dispatcher"
50
50
  | "gateway"
51
+ | "fusefs"
51
52
  | "plugin"
52
53
  | "teams"
53
54
  | "discord"
package/src/util/paths.ts CHANGED
@@ -72,6 +72,13 @@ export const dirs = {
72
72
  skills: resolve(TALON_ROOT, "workspace", "skills"),
73
73
  /** Key material (bridge TLS identity, release keys): ~/.talon/keys/ */
74
74
  keys: resolve(TALON_ROOT, "keys"),
75
+ /**
76
+ * The talon:// namespace on disk: ~/.talon/ns/. Holds one symlink per
77
+ * file-backed mount (home → workspace/, skills/, …); while the daemon
78
+ * runs with FUSE the synthetic mounts (proc/, plugins/) appear here too.
79
+ * `talon://x` and `~/.talon/ns/x` are the same address in two spellings.
80
+ */
81
+ ns: resolve(TALON_ROOT, "ns"),
75
82
  /** CLI-installed plugins (`talon plugin install`): ~/.talon/plugins/ */
76
83
  plugins: resolve(TALON_ROOT, "plugins"),
77
84
  } as const;
package/src/cli/fs.ts DELETED
@@ -1,138 +0,0 @@
1
- /**
2
- * `talon ls` / `talon cat` — the talon:// namespace from the outside.
3
- *
4
- * A running daemon answers through the gateway (`GET /vfs/list|read`) so
5
- * the synthetic mounts (proc, plugins) show live state. With no daemon,
6
- * the same namespace is served in-process — file mounts work identically,
7
- * the synthetic mounts are simply empty — so browsing the workspace never
8
- * requires Talon to be up.
9
- */
10
-
11
- import pc from "picocolors";
12
- import { findRunningInstance } from "../core/daemon/discovery.js";
13
- import type { VfsErrorCode, VfsResult, VfsStat } from "../core/vfs/index.js";
14
- import { fetchGateway } from "./daemon-api.js";
15
-
16
- /**
17
- * Ask the daemon first (live proc/plugins state), fall back to serving the
18
- * namespace in-process. `live` records which one answered.
19
- */
20
- async function query(
21
- op: "list" | "read",
22
- path: string,
23
- ): Promise<{ result: VfsResult<VfsStat[] | string>; live: boolean }> {
24
- const instance = await findRunningInstance();
25
- if (instance?.port) {
26
- const body = (await fetchGateway(
27
- instance.port,
28
- `/vfs/${op}?path=${encodeURIComponent(path)}`,
29
- )) as {
30
- ok: boolean;
31
- entries?: VfsStat[];
32
- content?: string;
33
- error?: string;
34
- detail?: string;
35
- };
36
- const result: VfsResult<VfsStat[] | string> = body.ok
37
- ? { ok: true, value: op === "list" ? body.entries! : body.content! }
38
- : {
39
- ok: false,
40
- error: (body.error ?? "io-error") as VfsErrorCode,
41
- ...(body.detail !== undefined ? { detail: body.detail } : {}),
42
- };
43
- return { result, live: true };
44
- }
45
-
46
- const { getVfs } = await import("../core/vfs/index.js");
47
- const vfs = getVfs();
48
- return {
49
- result: op === "list" ? vfs.list(path) : vfs.read(path),
50
- live: false,
51
- };
52
- }
53
-
54
- function printError(
55
- path: string,
56
- result: { error: string; detail?: string },
57
- ): void {
58
- console.error(
59
- ` ${pc.red("✖")} talon://${path}: ${result.error}${result.detail ? ` — ${result.detail}` : ""}`,
60
- );
61
- process.exitCode = 1;
62
- }
63
-
64
- function formatModified(entry: VfsStat): string {
65
- if (entry.modifiedAt === undefined) return "-";
66
- return new Date(entry.modifiedAt)
67
- .toISOString()
68
- .slice(0, 16)
69
- .replace("T", " ");
70
- }
71
-
72
- export async function showLs(rawPath: string | undefined): Promise<void> {
73
- const path = (rawPath ?? "").trim();
74
- console.log();
75
- const { result, live } = await query("list", path);
76
- if (!result.ok) {
77
- printError(path, result);
78
- return;
79
- }
80
- const entries = result.value as VfsStat[];
81
-
82
- if (!live) {
83
- console.log(
84
- ` ${pc.dim("Talon is not running — file mounts only; proc/ and plugins/ are empty.")}`,
85
- );
86
- }
87
- if (entries.length === 0) {
88
- console.log(` ${pc.dim("(empty)")}\n`);
89
- return;
90
- }
91
-
92
- const header = ["KIND", "SIZE", "MODIFIED", "NAME"];
93
- const cells = entries.map((entry) => [
94
- entry.kind === "dir" ? "dir" : "file",
95
- entry.kind === "dir" ? "-" : String(entry.size ?? "?"),
96
- formatModified(entry),
97
- entry.kind === "dir" ? `${entry.name}/` : entry.name,
98
- ]);
99
- const widths = header.map((h, col) =>
100
- Math.max(h.length, ...cells.map((row) => row[col]!.length)),
101
- );
102
- console.log(
103
- ` ${pc.dim(header.map((h, col) => h.padEnd(widths[col]!)).join(" "))}`,
104
- );
105
- cells.forEach((row, i) => {
106
- const padded = row.map((cell, col) => cell.padEnd(widths[col]!));
107
- const name = entries[i]!.kind === "dir" ? pc.cyan(padded[3]!) : padded[3]!;
108
- // Root entries carry the mount's disk location — show the mapping, it's
109
- // what shell commands (which can't read talon://) need.
110
- const entry = entries[i]!;
111
- const mapping =
112
- entry.osPath !== undefined && !entry.path.includes("/")
113
- ? pc.dim(` → ${entry.osPath}`)
114
- : "";
115
- console.log(
116
- ` ${padded[0]} ${padded[1]} ${padded[2]} ${name}${mapping}`,
117
- );
118
- });
119
- console.log();
120
- }
121
-
122
- export async function showCat(rawPath: string | undefined): Promise<void> {
123
- if (!rawPath || !rawPath.trim()) {
124
- console.error(
125
- ` ${pc.red("✖")} Usage: ${pc.cyan("talon cat <path>")} — e.g. ${pc.cyan("talon cat proc/events")}`,
126
- );
127
- process.exitCode = 1;
128
- return;
129
- }
130
- const path = rawPath.trim();
131
- const { result } = await query("read", path);
132
- if (!result.ok) {
133
- printError(path, result);
134
- return;
135
- }
136
- // Raw content, no decoration — `talon cat x | jq` must work.
137
- process.stdout.write(result.value as string);
138
- }
@@ -1,69 +0,0 @@
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
- };
@@ -1,61 +0,0 @@
1
- /**
2
- * VFS tools — the unified `talon://` namespace.
3
- *
4
- * One browsable tree over everything the daemon owns: real stores (the
5
- * workspace, skills, scripts, logs) and live synthetic state (`proc/` for
6
- * the task table and event bus, `plugins/` for the registry) answer the
7
- * same three tools. The synthetic mounts are the point — this is the only
8
- * way to read live daemon state as plain files.
9
- */
10
-
11
- import { z } from "zod";
12
- import type { ToolDefinition } from "./types.js";
13
-
14
- const NAMESPACE = `Namespace roots: home/ (workspace), skills/, scripts/, logs/, proc/ (live task table under proc/tasks/<id> and the event ring at proc/events), plugins/ (registry view). List "" to see the root and where each mount lives on disk. Absolute OS paths inside a mounted directory are accepted and translated.`;
15
-
16
- export const vfsTools: ToolDefinition[] = [
17
- {
18
- name: "vfs_list",
19
- description: `List a directory in the talon:// namespace — the unified view over Talon's own state. ${NAMESPACE} Entries show kind (d/-), writability, size, and name.`,
20
- schema: {
21
- path: z
22
- .string()
23
- .describe(
24
- 'Namespace path, e.g. "" (root), "proc/tasks", "skills/review-pr". Forward slashes on every platform; "talon://" prefix optional.',
25
- ),
26
- },
27
- execute: (params, bridge) => bridge("vfs_list", params),
28
- tag: "vfs",
29
- },
30
-
31
- {
32
- name: "vfs_read",
33
- description: `Read a file from the talon:// namespace (UTF-8 text, 256KB cap). ${NAMESPACE} Reading proc/tasks/<id> returns that task as JSON; proc/events returns the event ring as JSON Lines.`,
34
- schema: {
35
- path: z
36
- .string()
37
- .describe(
38
- 'Namespace file path, e.g. "proc/events" or "logs/2026-07-16.md"',
39
- ),
40
- },
41
- execute: (params, bridge) => bridge("vfs_read", params),
42
- tag: "vfs",
43
- },
44
-
45
- {
46
- name: "vfs_write",
47
- description: `Write a UTF-8 file into a writable part of the talon:// namespace (home/, skills/, scripts/). Parent directories are created; synthetic mounts (proc/, plugins/) and logs/ are read-only views.`,
48
- schema: {
49
- path: z
50
- .string()
51
- .describe(
52
- 'Namespace file path under a writable mount, e.g. "home/notes/ideas.md"',
53
- ),
54
- content: z
55
- .string()
56
- .describe("Full file content (replaces any existing content)"),
57
- },
58
- execute: (params, bridge) => bridge("vfs_write", params),
59
- tag: "vfs",
60
- },
61
- ];