talon-agent 1.53.0 → 2.0.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.
@@ -0,0 +1,269 @@
1
+ /**
2
+ * FUSE layer lifecycle — mounts the talon:// namespace at ~/.talon/ns
3
+ * while the daemon runs.
4
+ *
5
+ * The mount serves symlinks for file-backed mounts (kernel-followed, so
6
+ * heavy I/O never crosses FUSE) and answers synthetic subtrees (proc/,
7
+ * plugins/) live from the JS Vfs over the addon's callback bridge. When
8
+ * anything is missing — config off, addon absent, no /dev/fuse, mount
9
+ * or sanity check fails — the layer degrades to the plain symlink farm
10
+ * and records why: fuseless hosts get the identical experience minus
11
+ * live views, never an error at boot.
12
+ *
13
+ * Deadlock rule: the daemon must never do SYNCHRONOUS fs I/O under
14
+ * ~/.talon/ns — a sync call blocks the one JS thread that answers the
15
+ * bridge, wedging both sides. Async fs is safe (libuv worker blocks,
16
+ * the event loop answers), and child/external processes are always
17
+ * safe. The bridge handler itself only touches in-memory synthetic
18
+ * mounts, keeping replies non-blocking by construction.
19
+ */
20
+
21
+ import { spawnSync } from "node:child_process";
22
+ import { existsSync } from "node:fs";
23
+ import { readdir, stat } from "node:fs/promises";
24
+ import { log, logWarn } from "../../util/log.js";
25
+ import { dirs } from "../../util/paths.js";
26
+ import type { NativeFuseFs } from "../../native/fusefs.js";
27
+ import { nativeFuseFs } from "../../native/fusefs.js";
28
+ import { syncNamespaceDir } from "./nsdir.js";
29
+ import type { VfsErrorCode } from "./types.js";
30
+ import type { Vfs } from "./vfs.js";
31
+
32
+ export type FuseStatus = {
33
+ readonly mounted: boolean;
34
+ /** Why the layer is off — set exactly when `mounted` is false. */
35
+ readonly reason?: string;
36
+ };
37
+
38
+ /** How long a post-mount sanity probe may take before we bail out. */
39
+ const SANITY_TIMEOUT_MS = 3_000;
40
+
41
+ let status: FuseStatus = { mounted: false, reason: "not started" };
42
+ let activeAddon: NativeFuseFs | null = null;
43
+
44
+ /** Is the FUSE layer live? Consulted by the address/command resolvers. */
45
+ export function isNamespaceFsMounted(): boolean {
46
+ return status.mounted;
47
+ }
48
+
49
+ /** Current layer status, for doctor/status surfaces. */
50
+ export function namespaceFsStatus(): FuseStatus {
51
+ return status;
52
+ }
53
+
54
+ /** Test seam — force a status without a real mount. */
55
+ export function _setNamespaceFsStatusForTesting(next: FuseStatus): void {
56
+ status = next;
57
+ }
58
+
59
+ // ── Bridge: FUSE thread → JS Vfs ─────────────────────────────────────────────
60
+
61
+ const ERRNO: Record<VfsErrorCode, string> = {
62
+ "not-found": "ENOENT",
63
+ "invalid-path": "ENOENT",
64
+ "is-a-directory": "EISDIR",
65
+ "not-a-directory": "ENOTDIR",
66
+ "not-writable": "EROFS",
67
+ "too-large": "EFBIG",
68
+ "binary-file": "EIO",
69
+ "io-error": "EIO",
70
+ };
71
+
72
+ /**
73
+ * Answer one bridge request. Pure Vfs → JSON: `path` is
74
+ * namespace-relative ("proc/tasks/7"); ops are stat / list / read.
75
+ * Never throws — every failure becomes an errno reply, because an
76
+ * unanswered request strands a kernel caller until the addon times it
77
+ * out.
78
+ */
79
+ export function serveNamespaceRequest(
80
+ vfs: Vfs,
81
+ op: string,
82
+ path: string,
83
+ ): string {
84
+ try {
85
+ const address = `talon://${path}`;
86
+ if (op === "stat") {
87
+ const result = vfs.stat(address);
88
+ if (!result.ok) return errno(result.error);
89
+ return JSON.stringify({
90
+ ok: true,
91
+ kind: result.value.kind,
92
+ size: result.value.size ?? 0,
93
+ mtimeMs: result.value.modifiedAt ?? 0,
94
+ });
95
+ }
96
+ if (op === "list") {
97
+ const result = vfs.list(address);
98
+ if (!result.ok) return errno(result.error);
99
+ return JSON.stringify({
100
+ ok: true,
101
+ entries: result.value.map((entry) => ({
102
+ name: entry.name,
103
+ kind: entry.kind,
104
+ })),
105
+ });
106
+ }
107
+ if (op === "read") {
108
+ const result = vfs.read(address);
109
+ if (!result.ok) return errno(result.error);
110
+ return JSON.stringify({
111
+ ok: true,
112
+ data: Buffer.from(result.value, "utf-8").toString("base64"),
113
+ });
114
+ }
115
+ return errno("io-error");
116
+ } catch {
117
+ return JSON.stringify({ ok: false, errno: "EIO" });
118
+ }
119
+ }
120
+
121
+ function errno(code: VfsErrorCode): string {
122
+ return JSON.stringify({ ok: false, errno: ERRNO[code] });
123
+ }
124
+
125
+ // ── Mount lifecycle ──────────────────────────────────────────────────────────
126
+
127
+ export interface MountOptions {
128
+ /** `config.fuse` — "auto" mounts when possible, "off" never mounts. */
129
+ mode: "auto" | "off";
130
+ vfs: Vfs;
131
+ /** Test seams; production callers omit them. */
132
+ nsRoot?: string;
133
+ addon?: NativeFuseFs | null;
134
+ }
135
+
136
+ /**
137
+ * Bring the FUSE layer up if this host can. Never throws: every failure
138
+ * path lands in `{ mounted: false, reason }` with the symlink farm
139
+ * already synced, so the fuseless experience is intact regardless.
140
+ */
141
+ export async function mountNamespaceFs(
142
+ options: MountOptions,
143
+ ): Promise<FuseStatus> {
144
+ const nsRoot = options.nsRoot ?? dirs.ns;
145
+ await recoverStaleMount(nsRoot);
146
+ try {
147
+ syncNamespaceDir(options.vfs, nsRoot);
148
+ } catch (err) {
149
+ return down(`namespace dir sync failed: ${message(err)}`);
150
+ }
151
+
152
+ if (options.mode === "off") return down('disabled (config fuse: "off")');
153
+ if (process.platform !== "linux") {
154
+ return down("FUSE layer is Linux-only for now");
155
+ }
156
+ if (!existsSync("/dev/fuse")) return down("/dev/fuse not present");
157
+
158
+ const addon = options.addon !== undefined ? options.addon : nativeFuseFs();
159
+ if (!addon) return down("talon-fusefs addon not available");
160
+
161
+ const synthetic = options.vfs
162
+ .describeMounts()
163
+ .filter((mount) => mount.osRoot === undefined)
164
+ .map((mount) => mount.name);
165
+ const symlinks = options.vfs
166
+ .describeMounts()
167
+ .filter((mount) => mount.osRoot !== undefined)
168
+ .map((mount) => ({ name: mount.name, target: mount.osRoot! }));
169
+
170
+ try {
171
+ addon.mount(nsRoot, symlinks, synthetic, (id, op, path) => {
172
+ addon.reply(id, serveNamespaceRequest(options.vfs, op, path));
173
+ });
174
+ } catch (err) {
175
+ return down(`mount failed: ${message(err)}`);
176
+ }
177
+ activeAddon = addon;
178
+
179
+ // Sanity: the mount must actually answer before we advertise it.
180
+ // Async fs only — see the deadlock rule in the module doc.
181
+ const probe = synthetic[0];
182
+ const healthy = await withTimeout(
183
+ (async () => {
184
+ const entries = await readdir(nsRoot);
185
+ if (probe !== undefined && !entries.includes(probe)) return false;
186
+ if (probe !== undefined) {
187
+ if (!(await stat(`${nsRoot}/${probe}`)).isDirectory()) return false;
188
+ }
189
+ return true;
190
+ })(),
191
+ SANITY_TIMEOUT_MS,
192
+ );
193
+ if (healthy !== true) {
194
+ await unmountNamespaceFs();
195
+ // The failed mount may have shadowed the symlink farm — restore it.
196
+ try {
197
+ syncNamespaceDir(options.vfs, nsRoot);
198
+ } catch {
199
+ // best effort; the boot log already carries the real failure
200
+ }
201
+ return down(
202
+ healthy === false
203
+ ? "mount sanity check failed (synthetic mounts not visible)"
204
+ : `mount sanity check timed out after ${SANITY_TIMEOUT_MS}ms`,
205
+ );
206
+ }
207
+
208
+ status = { mounted: true };
209
+ log("fusefs", `talon:// namespace mounted at ${nsRoot}`);
210
+ return status;
211
+ }
212
+
213
+ /** Tear the layer down (daemon shutdown). Never throws, idempotent. */
214
+ export async function unmountNamespaceFs(): Promise<void> {
215
+ const addon = activeAddon;
216
+ activeAddon = null;
217
+ if (status.mounted) status = { mounted: false, reason: "unmounted" };
218
+ if (!addon) return;
219
+ try {
220
+ addon.unmount();
221
+ } catch (err) {
222
+ logWarn("fusefs", `unmount failed: ${message(err)}`);
223
+ }
224
+ }
225
+
226
+ /**
227
+ * A daemon that died without unmounting leaves the mountpoint wedged —
228
+ * every syscall answers ENOTCONN ("transport endpoint is not
229
+ * connected") until someone detaches it. A predecessor that is alive
230
+ * but not answering is worse: its mount HANGS syscalls instead, so the
231
+ * probe itself carries a timeout and a hang counts as stale. Detect
232
+ * and lazy-unmount before touching the directory.
233
+ */
234
+ async function recoverStaleMount(nsRoot: string): Promise<void> {
235
+ const probe = await withTimeout(
236
+ stat(nsRoot).then(
237
+ () => "ok" as const,
238
+ (err) => (err as NodeJS.ErrnoException).code ?? "error",
239
+ ),
240
+ SANITY_TIMEOUT_MS,
241
+ );
242
+ if (probe !== "ENOTCONN" && probe !== "timeout") return;
243
+ logWarn("fusefs", `stale mount at ${nsRoot} — detaching`);
244
+ for (const bin of ["fusermount3", "fusermount"]) {
245
+ const result = spawnSync(bin, ["-uz", nsRoot], { stdio: "ignore" });
246
+ if (result.status === 0) return;
247
+ }
248
+ spawnSync("umount", ["-l", nsRoot], { stdio: "ignore" });
249
+ }
250
+
251
+ function down(reason: string): FuseStatus {
252
+ status = { mounted: false, reason };
253
+ log("fusefs", `FUSE layer off — ${reason}`);
254
+ return status;
255
+ }
256
+
257
+ function message(err: unknown): string {
258
+ return err instanceof Error ? err.message : String(err);
259
+ }
260
+
261
+ function withTimeout<T>(work: Promise<T>, ms: number): Promise<T | "timeout"> {
262
+ return Promise.race([
263
+ work,
264
+ new Promise<"timeout">((resolve) => {
265
+ const timer = setTimeout(() => resolve("timeout"), ms);
266
+ timer.unref();
267
+ }),
268
+ ]);
269
+ }
@@ -15,8 +15,11 @@
15
15
  * proc/ task table + event bus ring (synthetic, read-only)
16
16
  * plugins/ plugin registry view (synthetic, read-only)
17
17
  *
18
- * Read surfaces: gateway `GET /vfs/list` / `GET /vfs/read` and the
19
- * vfs_* MCP tools (via gateway actions); CLI `talon ls` / `talon cat`.
18
+ * The namespace IS the filesystem: it lives at ~/.talon/ns (symlink
19
+ * farm via nsdir.ts, live synthetic mounts via fusefs.ts when FUSE is
20
+ * available), and every consumer — native tools, shell commands, the
21
+ * user's own terminal — reaches it through real paths. Address
22
+ * translation for tools lives in rewrite.ts.
20
23
  */
21
24
 
22
25
  import { dirs } from "../../util/paths.js";
@@ -32,6 +35,22 @@ export { Vfs } from "./vfs.js";
32
35
  export { createFileMount } from "./mounts/files.js";
33
36
  export { createProcMount, type ProcMountDeps } from "./mounts/proc.js";
34
37
  export { createPluginsMount, type PluginView } from "./mounts/plugins.js";
38
+ export { syncNamespaceDir, type NsDirSync } from "./nsdir.js";
39
+ export {
40
+ resolveNamespacePath,
41
+ rewriteNamespaceRefs,
42
+ type CommandRewrite,
43
+ type PathResolution,
44
+ } from "./rewrite.js";
45
+ export {
46
+ isNamespaceFsMounted,
47
+ mountNamespaceFs,
48
+ namespaceFsStatus,
49
+ serveNamespaceRequest,
50
+ unmountNamespaceFs,
51
+ type FuseStatus,
52
+ type MountOptions,
53
+ } from "./fusefs.js";
35
54
  export type {
36
55
  VfsErrorCode,
37
56
  VfsMount,
@@ -0,0 +1,85 @@
1
+ /**
2
+ * The namespace on disk — ~/.talon/ns/, the OS spelling of talon://.
3
+ *
4
+ * One symlink per file-backed mount (home → workspace/, skills/, …), so
5
+ * `talon://` ↔ `~/.talon/ns/` is a pure prefix substitution: any shell
6
+ * command, editor, or external tool reaches namespace nodes through
7
+ * ordinary paths. Synthetic mounts (proc/, plugins/) are NOT represented
8
+ * here — they exist only while the FUSE layer (core/vfs/fusefs.ts) is
9
+ * mounted over this directory, exactly like /proc appearing at boot.
10
+ *
11
+ * The sync is idempotent and owns only symlinks: stale or retargeted
12
+ * links it created are replaced, anything that isn't a symlink is left
13
+ * untouched and reported rather than deleted.
14
+ */
15
+
16
+ import {
17
+ lstatSync,
18
+ mkdirSync,
19
+ readdirSync,
20
+ readlinkSync,
21
+ rmSync,
22
+ symlinkSync,
23
+ } from "node:fs";
24
+ import { dirs } from "../../util/paths.js";
25
+ import type { Vfs } from "./vfs.js";
26
+
27
+ export interface NsDirSync {
28
+ /** Symlinks created or retargeted this pass (mount names). */
29
+ readonly linked: string[];
30
+ /** Stale symlinks removed (no longer a mount, or wrong target). */
31
+ readonly pruned: string[];
32
+ /** Entries skipped because they aren't symlinks we own. */
33
+ readonly foreign: string[];
34
+ }
35
+
36
+ /**
37
+ * Bring ~/.talon/ns/ (or `nsRoot`) in line with the mount table. Safe to
38
+ * call repeatedly; called at daemon boot before the FUSE layer mounts
39
+ * over the directory. Mount targets are created if missing so the links
40
+ * are never dangling — workspace directories appear lazily elsewhere too.
41
+ */
42
+ export function syncNamespaceDir(
43
+ vfs: Vfs,
44
+ nsRoot: string = dirs.ns,
45
+ ): NsDirSync {
46
+ mkdirSync(nsRoot, { recursive: true });
47
+ const desired = new Map(
48
+ vfs
49
+ .describeMounts()
50
+ .filter((mount) => mount.osRoot !== undefined)
51
+ .map((mount) => [mount.name, mount.osRoot!]),
52
+ );
53
+
54
+ const linked: string[] = [];
55
+ const pruned: string[] = [];
56
+ const foreign: string[] = [];
57
+
58
+ for (const entry of readdirSync(nsRoot)) {
59
+ const path = `${nsRoot}/${entry}`;
60
+ if (!lstatSync(path).isSymbolicLink()) {
61
+ foreign.push(entry);
62
+ // A foreign entry shadowing a mount name wins — we never delete
63
+ // what we didn't create, so don't try to link over it either.
64
+ desired.delete(entry);
65
+ continue;
66
+ }
67
+ const target = desired.get(entry);
68
+ if (target !== undefined && readlinkSync(path) === target) {
69
+ desired.delete(entry); // already correct
70
+ continue;
71
+ }
72
+ rmSync(path);
73
+ if (target === undefined) pruned.push(entry);
74
+ }
75
+
76
+ for (const [name, target] of desired) {
77
+ mkdirSync(target, { recursive: true });
78
+ // "dir" matters only on Windows (junction-style resolution); ignored
79
+ // elsewhere.
80
+ symlinkSync(target, `${nsRoot}/${name}`, "dir");
81
+ linked.push(name);
82
+ }
83
+
84
+ return { linked, pruned, foreign };
85
+ }
@@ -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(