talon-agent 2.0.2 → 3.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 CHANGED
@@ -24,7 +24,7 @@ Multi-platform agentic AI harness. Runs on **Telegram**, **Discord**, **Microsof
24
24
  | **Triggers** | Self-authored watcher scripts (bash/python/node) that wake the bot when conditions are met |
25
25
  | **Task table** | Every unit of agent work — chat turns, heartbeat, dream, isolated cron/trigger jobs — registered live; `talon ps` / `talon kill` |
26
26
  | **Event bus** | Typed internal pub-sub spine (task + turn lifecycle events); subsystems subscribe instead of importing each other; `talon events -f` |
27
- | **VFS** | Unified `talon://` namespace over workspace, skills, scripts, logs, plus /proc-style live views of the task table, event bus, and plugin registry — a real filesystem at `~/.talon/ns` (FUSE-backed live views), so plain `ls`/`cat` and every tool just work |
27
+ | **VFS** | Unified namespace at `~/.talon/ns` over workspace, skills, scripts, logs, plus /proc-style live views of the task table, event bus, and plugin registry — a real filesystem (FUSE-backed live views), so plain `ls`/`cat` and every tool just work |
28
28
  | **Per-chat settings** | Model, effort level, and pulse toggle per conversation via inline keyboard |
29
29
  | **Model registry** | Models discovered from the active backend at startup — new models appear in all pickers automatically |
30
30
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "talon-agent",
3
- "version": "2.0.2",
3
+ "version": "3.0.1",
4
4
  "description": "Multi-frontend AI agent with full tool access, streaming, cron jobs, and plugin system",
5
5
  "author": "Dylan Neve",
6
6
  "license": "MIT",
@@ -116,6 +116,7 @@
116
116
  "liquidjs": "^10.27.0",
117
117
  "marked": "^18.0.0",
118
118
  "mem0ai": "^3.0.13",
119
+ "openai": "^6.46.0",
119
120
  "p-retry": "^8.0.0",
120
121
  "picocolors": "^1.1.1",
121
122
  "pino": "^10.3.1",
package/src/cli/config.ts CHANGED
@@ -97,9 +97,11 @@ export function isConfigured(config: Config): boolean {
97
97
  : [config.frontend];
98
98
  return fes.every((fe) => {
99
99
  if (fe === "telegram") return !!config.botToken;
100
- if (fe === "terminal") return true;
101
100
  if (fe === "teams") return !!config.teamsWebhookUrl;
102
101
  if (fe === "discord") return !!config.discord?.botToken;
103
- return false;
102
+ // terminal (stdio) and native (the client bridge) carry no
103
+ // credentials; unknown names fail closed so a typo'd frontend sends
104
+ // the user to setup instead of a daemon that can't start.
105
+ return fe === "terminal" || fe === "native";
104
106
  });
105
107
  }
package/src/cli/menu.ts CHANGED
@@ -21,7 +21,7 @@ export async function mainMenu(): Promise<void> {
21
21
  if (!existsSync(CONFIG_FILE) || !isConfigured(loadConfig())) {
22
22
  p.intro(pc.inverse(" Welcome to Talon "));
23
23
  p.note(
24
- "Talon is an agentic AI harness.\nSupports Telegram and Terminal.\nLet's get you set up.",
24
+ "Talon is an agentic AI harness.\nRuns on Telegram, Discord, Teams, Terminal,\nand the native client bridge.\nLet's get you set up.",
25
25
  "First time?",
26
26
  );
27
27
  await runSetup();
@@ -44,7 +44,9 @@ export async function mainMenu(): Promise<void> {
44
44
  ? "Teams"
45
45
  : f === "discord"
46
46
  ? "Discord"
47
- : "Terminal",
47
+ : f === "native"
48
+ ? "Native"
49
+ : "Terminal",
48
50
  )
49
51
  .join(" + ");
50
52
 
@@ -12,6 +12,7 @@
12
12
  */
13
13
 
14
14
  import { existsSync } from "node:fs";
15
+ import { stat } from "node:fs/promises";
15
16
  import { execFileSync } from "node:child_process";
16
17
  import { NATIVE_MODULES } from "../native/registry.js";
17
18
  import { dirs } from "../util/paths.js";
@@ -103,20 +104,107 @@ export async function checkNativeModules(): Promise<NativeModuleCheck[]> {
103
104
  return results;
104
105
  }
105
106
 
106
- /** Whether every configured frontend has its required credentials. */
107
- function frontendConfigured(config: DoctorConfigSlice): boolean {
107
+ /**
108
+ * The configured frontends that are missing their required credentials.
109
+ * `terminal` (stdio) and `native` (the client bridge) carry none, so they
110
+ * are always configured; a name doctor doesn't recognize stays a failure
111
+ * (fail-closed) so a typo'd frontend is surfaced instead of blessed —
112
+ * but it is *named* in the report either way, never a bare "not fully
113
+ * configured" that leaves the operator guessing which entry is at fault.
114
+ */
115
+ function unconfiguredFrontends(config: DoctorConfigSlice): string[] {
108
116
  const fes = Array.isArray(config.frontend)
109
117
  ? config.frontend
110
118
  : [config.frontend];
111
- return fes.every((fe) => {
112
- if (fe === "telegram") return Boolean(config.botToken);
113
- if (fe === "terminal") return true;
114
- if (fe === "teams") return Boolean(config.teamsWebhookUrl);
115
- if (fe === "discord") return Boolean(config.discord?.botToken);
116
- return false;
119
+ return fes.filter((fe) => {
120
+ if (fe === "telegram") return !config.botToken;
121
+ if (fe === "teams") return !config.teamsWebhookUrl;
122
+ if (fe === "discord") return !config.discord?.botToken;
123
+ return fe !== "terminal" && fe !== "native";
117
124
  });
118
125
  }
119
126
 
127
+ /** How long a namespace probe may take before the mount counts as wedged. */
128
+ const NS_PROBE_TIMEOUT_MS = 3_000;
129
+
130
+ /**
131
+ * Probe one path without trusting it to answer: a healthy dir stats in
132
+ * microseconds, a stale FUSE mount from a dead daemon answers ENOTCONN,
133
+ * and a live-but-stuck daemon's mount HANGS the syscall — so the probe
134
+ * races a timeout and a hang counts as wedged rather than wedging
135
+ * doctor itself.
136
+ */
137
+ async function probeNsPath(
138
+ path: string,
139
+ ): Promise<"ok" | "missing" | "hang" | string> {
140
+ const result = await Promise.race([
141
+ stat(path).then(
142
+ () => "ok" as const,
143
+ (err) => {
144
+ const code = (err as NodeJS.ErrnoException).code;
145
+ return code === "ENOENT" ? ("missing" as const) : (code ?? "error");
146
+ },
147
+ ),
148
+ new Promise<"hang">((resolve) => {
149
+ setTimeout(() => resolve("hang"), NS_PROBE_TIMEOUT_MS).unref();
150
+ }),
151
+ ]);
152
+ return result;
153
+ }
154
+
155
+ /**
156
+ * The namespace mountpoint (~/.talon/ns) from the outside. Doctor runs
157
+ * in its own process, so it can't read the daemon's in-memory FUSE
158
+ * status — but every failure mode shows up in the filesystem itself:
159
+ * missing means no daemon has booted yet, ENOTCONN or a hanging syscall
160
+ * means a FUSE mount wedged by a dead or stuck daemon (every consumer
161
+ * of the namespace is broken until it's detached), and a healthy root
162
+ * either has live views (proc/ answers) or is the plain symlink farm.
163
+ */
164
+ async function checkNamespaceDir(): Promise<DoctorCheck> {
165
+ const root = await probeNsPath(dirs.ns);
166
+ if (root === "missing") {
167
+ return {
168
+ label: "Namespace dir not built yet",
169
+ status: "info",
170
+ detail: `${dirs.ns} appears at first daemon boot`,
171
+ };
172
+ }
173
+ if (root !== "ok") {
174
+ return {
175
+ label:
176
+ root === "hang"
177
+ ? "Namespace dir wedged (syscalls hang)"
178
+ : `Namespace dir wedged (${root})`,
179
+ status: "fail",
180
+ detail: `stale FUSE mount at ${dirs.ns} — detach with: fusermount3 -uz ${dirs.ns} (or: umount -l ${dirs.ns})`,
181
+ };
182
+ }
183
+ // Root answers. proc/ is served over the FUSE bridge, so it gets its
184
+ // own hang-guarded probe: present = live views, absent = symlink farm.
185
+ const proc = await probeNsPath(`${dirs.ns}/proc`);
186
+ if (proc === "ok") {
187
+ return {
188
+ label: `Namespace dir: ${dirs.ns}`,
189
+ status: "ok",
190
+ detail: "live views mounted (proc/ answering)",
191
+ };
192
+ }
193
+ if (proc === "missing") {
194
+ return {
195
+ label: `Namespace dir: ${dirs.ns}`,
196
+ status: "ok",
197
+ detail: "symlink farm (no live views — daemon off or FUSE not mounted)",
198
+ };
199
+ }
200
+ return {
201
+ label: `Namespace live views unhealthy (proc/ ${proc === "hang" ? "hangs" : proc})`,
202
+ status: "warn",
203
+ detail: `file mounts still work; detach the mount with: fusermount3 -uz ${dirs.ns}`,
204
+ issue: true,
205
+ };
206
+ }
207
+
120
208
  function binaryOnPath(name: string): boolean {
121
209
  try {
122
210
  const lookupCmd = process.platform === "win32" ? "where" : "which";
@@ -327,14 +415,19 @@ export async function collectDoctorReport(opts: {
327
415
  const fes = Array.isArray(opts.config.frontend)
328
416
  ? opts.config.frontend
329
417
  : [opts.config.frontend];
418
+ const missing = unconfiguredFrontends(opts.config);
330
419
  checks.push(
331
- frontendConfigured(opts.config)
420
+ missing.length === 0
332
421
  ? {
333
422
  label: `Frontend: ${fes.join(", ")}`,
334
423
  status: "ok",
335
424
  detail: "configured",
336
425
  }
337
- : { label: "Frontend not fully configured", status: "fail" },
426
+ : {
427
+ label: `Frontend not fully configured: ${missing.join(", ")}`,
428
+ status: "fail",
429
+ detail: "missing credentials in talon.json (or unknown frontend)",
430
+ },
338
431
  );
339
432
  } else {
340
433
  checks.push({ label: "No config file", status: "fail" });
@@ -346,6 +439,8 @@ export async function collectDoctorReport(opts: {
346
439
  : { label: "Workspace missing", status: "warn" },
347
440
  );
348
441
 
442
+ checks.push(await checkNamespaceDir());
443
+
349
444
  // The warden is optional by design (built per-arch, absent on plain
350
445
  // npm installs), so a missing binary is informational — triggers run
351
446
  // on the in-process TS path. When present, report the live version.
@@ -33,12 +33,6 @@ import {
33
33
  setTeleport,
34
34
  setTeleportCwd,
35
35
  } from "../../mesh/teleport.js";
36
- import {
37
- getVfs,
38
- isNamespaceFsMounted,
39
- resolveNamespacePath,
40
- rewriteNamespaceRefs,
41
- } from "../../vfs/index.js";
42
36
  import {
43
37
  clampExecOutput,
44
38
  createOutputCapture,
@@ -169,39 +163,13 @@ async function bash(
169
163
  timeoutSec: unknown,
170
164
  background?: unknown,
171
165
  ): Promise<Result> {
172
- let cmd = typeof command === "string" ? command : "";
166
+ const cmd = typeof command === "string" ? command : "";
173
167
  if (!cmd.trim()) return { ok: false, text: "No command given." };
174
168
  const timeoutMs = clampTimeout(timeoutSec);
175
169
  const active = await getTeleport(chatId);
176
170
 
177
- // talon:// references in the command translate to real paths before the
178
- // shell ever sees them — teleported they can't (wrong address space).
179
- let mappings: string[] = [];
180
- if (cmd.includes("talon:") && !active) {
181
- const rewritten = rewriteNamespaceRefs(
182
- cmd,
183
- getVfs(),
184
- isNamespaceFsMounted(),
185
- );
186
- if (!rewritten.ok) return { ok: false, text: rewritten.error };
187
- cmd = rewritten.command;
188
- mappings = rewritten.mappings;
189
- }
190
- if (cmd.includes(NAMESPACE_SCHEME) && active) {
191
- return {
192
- ok: false,
193
- text:
194
- `talon:// names the daemon's namespace, but native tools are ` +
195
- `teleported onto ${active.deviceName} — teleport_back first, or use a device path.`,
196
- };
197
- }
198
-
199
171
  let dir = typeof cwd === "string" && cwd.trim() ? cwd.trim() : undefined;
200
- if (dir !== undefined && !active) {
201
- const resolved = resolvePathParam(dir, undefined);
202
- if ("error" in resolved) return { ok: false, text: resolved.error };
203
- dir = resolved.path;
204
- }
172
+ if (dir !== undefined && !active) dir = resolvePathParam(dir, undefined);
205
173
 
206
174
  const result = await (async () => {
207
175
  if (background === true) {
@@ -219,10 +187,6 @@ async function bash(
219
187
  if (active) return bashTeleported(chatId, active.deviceId, cmd, timeoutMs);
220
188
  return bashLocal(cmd, dir, timeoutMs);
221
189
  })();
222
- // Surface the translation so it's visible, never silent.
223
- if (mappings.length > 0) {
224
- return { ...result, text: `↪ ${mappings.join(" ")}\n${result.text}` };
225
- }
226
190
  return result;
227
191
  }
228
192
 
@@ -490,38 +454,7 @@ async function bashTeleported(
490
454
  };
491
455
  }
492
456
 
493
- // ── talon:// address resolution ─────────────────────────────────────────────
494
-
495
- const NAMESPACE_SCHEME = "talon://";
496
-
497
- /**
498
- * talon:// addresses translate to real host paths (core/vfs/rewrite.ts)
499
- * and then flow through the exact same fs code as any other path — no
500
- * virtual nodes, no special branches. With the FUSE layer mounted even
501
- * proc/ and plugins/ are ordinary files; without it they are refused at
502
- * resolution. Teleport can't cross address spaces: talon:// names
503
- * daemon state, and a teleported chat's paths belong to the device, so
504
- * the combination is refused rather than resolved against the wrong
505
- * filesystem.
506
- */
507
- function resolveAddress(
508
- address: string,
509
- teleportedTo: string | undefined,
510
- ): { path: string } | { error: string } {
511
- if (teleportedTo !== undefined) {
512
- return {
513
- error:
514
- `${address} names the daemon's namespace, but native tools are ` +
515
- `teleported onto ${teleportedTo} — teleport_back first, or use a device path.`,
516
- };
517
- }
518
- const resolved = resolveNamespacePath(
519
- address,
520
- getVfs(),
521
- isNamespaceFsMounted(),
522
- );
523
- return resolved.ok ? { path: resolved.path } : { error: resolved.error };
524
- }
457
+ // ── path parameter resolution ───────────────────────────────────────────────
525
458
 
526
459
  /** Expand a leading `~` — local runs only; a device's home is not ours. */
527
460
  function expandHome(path: string): string {
@@ -531,26 +464,21 @@ function expandHome(path: string): string {
531
464
  }
532
465
 
533
466
  /**
534
- * One rule for every path parameter: talon:// translates, `~` expands
535
- * (locally), everything else passes through untouched.
467
+ * One rule for every path parameter: `~` expands (local runs only),
468
+ * everything else passes through untouched.
469
+ *
470
+ * The namespace has no tool-facing address scheme — its nodes are reached
471
+ * by their real paths (`~/.talon/ns/…`, kept real by the symlink farm in
472
+ * nsdir.ts and the FUSE layer in fusefs.ts). Real paths need no
473
+ * translation and behave identically here, in a bare shell, in another
474
+ * backend's built-in shell (Codex), and in any spawned child process.
475
+ * Teleported, paths belong to the device and pass through verbatim.
536
476
  */
537
477
  function resolvePathParam(
538
478
  path: string,
539
479
  teleportedTo: string | undefined,
540
- ): { path: string } | { error: string } {
541
- if (path.startsWith(NAMESPACE_SCHEME)) {
542
- return resolveAddress(path, teleportedTo);
543
- }
544
- return { path: teleportedTo !== undefined ? path : expandHome(path) };
545
- }
546
-
547
- /** glob/search root — same rule, defaulting to the working directory. */
548
- function resolveSearchRoot(
549
- path: string | undefined,
550
- teleportedTo: string | undefined,
551
- ): { root: string } | { error: string } {
552
- const resolved = resolvePathParam(path ?? ".", teleportedTo);
553
- return "error" in resolved ? resolved : { root: resolved.path };
480
+ ): string {
481
+ return teleportedTo !== undefined ? path : expandHome(path);
554
482
  }
555
483
 
556
484
  // ── read / write / edit ─────────────────────────────────────────────────────
@@ -566,9 +494,7 @@ async function read(
566
494
  const active = await getTeleport(chatId);
567
495
  const where = active ? active.deviceName : "local";
568
496
 
569
- const resolved = resolvePathParam(address, active?.deviceName);
570
- if ("error" in resolved) return { ok: false, text: resolved.error };
571
- const p = resolved.path;
497
+ const p = resolvePathParam(address, active?.deviceName);
572
498
  const shown = p === address ? p : `${address} → ${p}`;
573
499
 
574
500
  // Image files: return a viewable image block, not the raw bytes decoded as
@@ -648,9 +574,7 @@ async function write(
648
574
  if (!address) return { ok: false, text: "A file path is required." };
649
575
  const body = typeof content === "string" ? content : "";
650
576
  const active = await getTeleport(chatId);
651
- const resolved = resolvePathParam(address, active?.deviceName);
652
- if ("error" in resolved) return { ok: false, text: resolved.error };
653
- const p = resolved.path;
577
+ const p = resolvePathParam(address, active?.deviceName);
654
578
  const shown = p === address ? p : `${address} → ${p}`;
655
579
  if (active) {
656
580
  return getMeshService().writeFileToDevice(active.deviceId, p, body);
@@ -681,9 +605,7 @@ async function edit(
681
605
  if (from === to)
682
606
  return { ok: false, text: "old_string and new_string are identical." };
683
607
  const active = await getTeleport(chatId);
684
- const resolved = resolvePathParam(address, active?.deviceName);
685
- if ("error" in resolved) return { ok: false, text: resolved.error };
686
- const p = resolved.path;
608
+ const p = resolvePathParam(address, active?.deviceName);
687
609
  const shown = p === address ? p : `${address} → ${p}`;
688
610
  const svc = getMeshService();
689
611
 
@@ -754,9 +676,7 @@ async function glob(
754
676
  const pat = str(pattern);
755
677
  if (!pat) return { ok: false, text: "A glob pattern is required." };
756
678
  const active = await getTeleport(chatId);
757
- const rooted = resolveSearchRoot(str(path), active?.deviceName);
758
- if ("error" in rooted) return { ok: false, text: rooted.error };
759
- const root = rooted.root;
679
+ const root = resolvePathParam(str(path) ?? ".", active?.deviceName);
760
680
  if (active) {
761
681
  // Prefer rg on the device; fall back to find (basename patterns via
762
682
  // -name, path patterns via -path). `command -v` gates the choice so a
@@ -803,9 +723,7 @@ async function search(
803
723
  const pat = str(pattern);
804
724
  if (!pat) return { ok: false, text: "A search pattern is required." };
805
725
  const active = await getTeleport(chatId);
806
- const rooted = resolveSearchRoot(str(path), active?.deviceName);
807
- if ("error" in rooted) return { ok: false, text: rooted.error };
808
- const root = rooted.root;
726
+ const root = resolvePathParam(str(path) ?? ".", active?.deviceName);
809
727
  const g = str(globPat);
810
728
  const ci = caseInsensitive === true;
811
729
  // `-e` keeps a pattern that starts with "-" from being parsed as a flag
@@ -10,16 +10,18 @@ import type { ToolDefinition } from "./types.js";
10
10
  * Their extra power over the built-ins: every one honours the active
11
11
  * `teleport` target, so with a teleport engaged they run ON a companion
12
12
  * device (via the mesh exec/fs channel) instead of on the daemon host.
13
- * And every one speaks talon:// — Talon's own namespace, which is a real
14
- * filesystem location, so addresses translate to legitimate paths.
13
+ * Talon's namespace (~/.talon/ns) is a real filesystem location — its
14
+ * files are reached by their ordinary real paths, with no address scheme
15
+ * to translate, so the same path works here, in a bare shell, and in any
16
+ * spawned process.
15
17
  */
16
18
 
17
19
  /** One shared description of the namespace, stated once. */
18
20
  const NAMESPACE_DOC =
19
- "talon:// is Talon's namespace, on disk at ~/.talon/ns: home/ (the workspace), " +
20
- "skills/, scripts/, logs/, and while the FUSE layer is mounted the live views " +
21
- "proc/ (task table at proc/tasks/<id>, event ring at proc/events, both JSON) and " +
22
- "plugins/ (registry).";
21
+ "Talon's namespace lives on disk at ~/.talon/ns home/ (the workspace), skills/, " +
22
+ "scripts/, logs/, and, while the FUSE layer is mounted, the live views proc/ (task " +
23
+ "table at proc/tasks/<id>, event ring at proc/events, both JSON) and plugins/ " +
24
+ "(registry). These are real paths: address them directly as ~/.talon/ns/<mount>/….";
23
25
  export const nativeTools: ToolDefinition[] = [
24
26
  {
25
27
  name: "teleport",
@@ -49,14 +51,14 @@ export const nativeTools: ToolDefinition[] = [
49
51
  description:
50
52
  "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). " +
51
53
  "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. " +
52
- `${NAMESPACE_DOC} talon:// references in the command are translated to real paths before the shell runs (local only), so \`ls talon://home\` or \`cat talon://proc/events | jq\` just work.`,
54
+ `${NAMESPACE_DOC} Reach them by real path, e.g. \`ls ~/.talon/ns/home\` or \`cat ~/.talon/ns/proc/events | jq\` (proc/ and plugins/ need the FUSE layer mounted).`,
53
55
  schema: {
54
56
  command: z.string().describe("The shell command to run."),
55
57
  cwd: z
56
58
  .string()
57
59
  .optional()
58
60
  .describe(
59
- "Working directory (local runs only; teleport tracks its own cwd). talon:// accepted.",
61
+ "Working directory (local runs only; teleport tracks its own cwd). A real path, e.g. ~/.talon/ns/home.",
60
62
  ),
61
63
  timeout_sec: z
62
64
  .number()
@@ -81,7 +83,7 @@ export const nativeTools: ToolDefinition[] = [
81
83
  path: z
82
84
  .string()
83
85
  .describe(
84
- "Absolute file path, ~ path, or a talon:// address (translated on the daemon host).",
86
+ "Absolute file path or ~ path (namespace files live under ~/.talon/ns, e.g. ~/.talon/ns/home/notes.md).",
85
87
  ),
86
88
  offset: z.number().optional().describe("0-based line to start from."),
87
89
  limit: z
@@ -95,12 +97,12 @@ export const nativeTools: ToolDefinition[] = [
95
97
  {
96
98
  name: "write",
97
99
  description:
98
- "Write (create/overwrite) a file with the given content. Runs on the daemon host or the active teleport device. Writable talon:// mounts: home/, skills/, scripts/ (live views are read-only).",
100
+ "Write (create/overwrite) a file with the given content. Runs on the daemon host or the active teleport device. Under ~/.talon/ns the writable mounts are home/, skills/, scripts/ (logs/, proc/, plugins/ are read-only).",
99
101
  schema: {
100
102
  path: z
101
103
  .string()
102
104
  .describe(
103
- "Absolute file path, ~ path, or a talon:// address (translated on the daemon host).",
105
+ "Absolute file path or ~ path (namespace files live under ~/.talon/ns, e.g. ~/.talon/ns/home/notes.md).",
104
106
  ),
105
107
  content: z.string().describe("Full file content to write."),
106
108
  },
@@ -115,7 +117,7 @@ export const nativeTools: ToolDefinition[] = [
115
117
  path: z
116
118
  .string()
117
119
  .describe(
118
- "Absolute file path, ~ path, or a talon:// address (translated on the daemon host).",
120
+ "Absolute file path or ~ path (namespace files live under ~/.talon/ns, e.g. ~/.talon/ns/home/notes.md).",
119
121
  ),
120
122
  old_string: z.string().describe("Exact text to replace."),
121
123
  new_string: z.string().describe("Replacement text."),
@@ -136,7 +138,9 @@ export const nativeTools: ToolDefinition[] = [
136
138
  path: z
137
139
  .string()
138
140
  .optional()
139
- .describe("Root directory to search (default cwd; talon:// accepted)."),
141
+ .describe(
142
+ "Root directory to search (default cwd; a real path e.g. ~/.talon/ns/home).",
143
+ ),
140
144
  },
141
145
  execute: (params, bridge) => bridge("native_glob", params),
142
146
  tag: "native",
@@ -150,7 +154,9 @@ export const nativeTools: ToolDefinition[] = [
150
154
  path: z
151
155
  .string()
152
156
  .optional()
153
- .describe("Root directory or file (default cwd; talon:// accepted)."),
157
+ .describe(
158
+ "Root directory or file (default cwd; a real path e.g. ~/.talon/ns/home).",
159
+ ),
154
160
  glob: z
155
161
  .string()
156
162
  .optional()
@@ -18,8 +18,11 @@
18
18
  * The namespace IS the filesystem: it lives at ~/.talon/ns (symlink
19
19
  * farm via nsdir.ts, live synthetic mounts via fusefs.ts when FUSE is
20
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.
21
+ * user's own terminal, other backends' shells (Codex), spawned
22
+ * processes reaches it through the same real paths. There is no
23
+ * tool-facing address scheme to translate: `~/.talon/ns/home/…` is the
24
+ * address. (`talon://` survives only as the VFS's internal mount
25
+ * grammar in vfs.ts / fusefs.ts; consumers never see it.)
23
26
  */
24
27
 
25
28
  import { dirs } from "../../util/paths.js";
@@ -36,12 +39,6 @@ export { createFileMount } from "./mounts/files.js";
36
39
  export { createProcMount, type ProcMountDeps } from "./mounts/proc.js";
37
40
  export { createPluginsMount, type PluginView } from "./mounts/plugins.js";
38
41
  export { syncNamespaceDir, type NsDirSync } from "./nsdir.js";
39
- export {
40
- resolveNamespacePath,
41
- rewriteNamespaceRefs,
42
- type CommandRewrite,
43
- type PathResolution,
44
- } from "./rewrite.js";
45
42
  export {
46
43
  isNamespaceFsMounted,
47
44
  mountNamespaceFs,
@@ -86,10 +86,10 @@ export interface VfsMount {
86
86
  /** Can write() ever succeed here? Synthetic views say false. */
87
87
  readonly writable: boolean;
88
88
  /**
89
- * Absolute disk root, for file-backed mounts. The single fact that makes
90
- * addresses bidirectional: the resolver routes OS-absolute addresses into
91
- * the mount by containment, and `Vfs.locate` maps namespace addresses
92
- * back to disk. Synthetic mounts omit it.
89
+ * Absolute disk root, for file-backed mounts. The resolver routes
90
+ * OS-absolute addresses into the mount by containment, and the symlink
91
+ * farm / FUSE layer expose the mount at its real path on disk.
92
+ * Synthetic mounts omit it.
93
93
  */
94
94
  readonly osRoot?: string;
95
95
  stat(rel: string): VfsResult<VfsStat>;
@@ -102,29 +102,10 @@ export class Vfs {
102
102
  return result.ok ? vfsOk(withPrefix(result.value, prefix)) : result;
103
103
  }
104
104
 
105
- /**
106
- * Where an address lives on disk: the absolute path for nodes of
107
- * file-backed mounts (whether or not the node exists yet), `undefined`
108
- * for addresses with no disk representation (the root, synthetic
109
- * mounts). This is the namespace→OS direction of the address grammar —
110
- * OS-level tools call it to run real file operations on a talon://
111
- * address.
112
- */
113
- locate(path: string): VfsResult<string | undefined> {
114
- const parsed = this.#parse(path);
115
- if (!parsed.ok) return parsed;
116
- if (parsed.value === null) return vfsOk(undefined);
117
- const { mount, rel } = parsed.value;
118
- if (mount.osRoot === undefined) return vfsOk(undefined);
119
- return vfsOk(
120
- rel === "" ? mount.osRoot : resolve(mount.osRoot, ...rel.split("/")),
121
- );
122
- }
123
-
124
105
  /**
125
106
  * The mount table as data: name, description, writability, and — for
126
107
  * file-backed mounts — the disk root. Consumed by the namespace dir
127
- * builder (symlink farm), the command rewriter, and docs/tool output.
108
+ * builder (symlink farm), the FUSE layer, and docs/tool output.
128
109
  */
129
110
  describeMounts(): {
130
111
  name: string;
@@ -1,170 +0,0 @@
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
- }