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.
- package/README.md +1 -1
- package/package.json +3 -2
- package/src/app.ts +11 -0
- package/src/cli/index.ts +0 -15
- package/src/core/doctor.ts +20 -0
- package/src/core/engine/gateway-actions/index.ts +0 -3
- package/src/core/engine/gateway-actions/native.ts +108 -100
- package/src/core/engine/gateway.ts +0 -28
- package/src/core/tools/index.ts +0 -2
- package/src/core/tools/native.ts +16 -8
- package/src/core/tools/types.ts +1 -2
- package/src/core/vfs/fusefs.ts +438 -0
- package/src/core/vfs/index.ts +21 -2
- package/src/core/vfs/nsdir.ts +95 -0
- package/src/core/vfs/rewrite.ts +170 -0
- package/src/core/vfs/vfs.ts +34 -19
- package/src/native/fusefs.ts +97 -0
- package/src/util/config.ts +8 -0
- package/src/util/log.ts +1 -0
- package/src/util/paths.ts +7 -0
- package/src/cli/fs.ts +0 -138
- package/src/core/engine/gateway-actions/vfs.ts +0 -69
- package/src/core/tools/vfs.ts +0 -61
|
@@ -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
|
+
}
|
package/src/core/vfs/vfs.ts
CHANGED
|
@@ -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
|
|
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
|
-
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
16
|
+
* Nothing else is an address. Bare relative paths and `~` spellings are
|
|
17
|
+
* refused with the correction rather than guessed at — the 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
|
-
/**
|
|
125
|
-
|
|
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
|
-
|
|
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
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
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
|
+
}
|
package/src/util/config.ts
CHANGED
|
@@ -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
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
|
-
};
|
package/src/core/tools/vfs.ts
DELETED
|
@@ -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
|
-
];
|