talon-agent 2.0.1 → 3.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.
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.1",
3
+ "version": "3.0.0",
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",
@@ -388,37 +388,27 @@ export async function handleMessage(
388
388
 
389
389
  // Final authoritative usage settlement — shared by the success post-loop
390
390
  // and the terminal-failure path so failed turns account for the tokens
391
- // they burned too. Codex's `turn.completed.usage` is CUMULATIVE, so we
392
- // read the per-call `token_count` event from the rollout JSONL for an
393
- // accurate /status display, falling back silently when unavailable.
391
+ // they burned too. Codex's `turn.completed.usage` is CUMULATIVE across
392
+ // every API call in the turn — never in the per-turn units the shared
393
+ // stream state (and everything downstream: /status, the companion's
394
+ // per-message counts) speaks. The rollout JSONL's totals diffed against
395
+ // the pre-turn baseline are this turn's real usage; that is the ONLY
396
+ // authoritative source. The SDK figure is a last-resort fallback when
397
+ // the rollout can't be read, and it overstates multi-call turns.
394
398
  const settleUsageAccounting = async (): Promise<void> => {
395
- if (usage) {
396
- recordTokens(streamState, {
397
- inputTokens: usage.input_tokens,
398
- outputTokens: usage.output_tokens,
399
- cacheRead: usage.cached_input_tokens,
400
- cacheWrite: 0, // Codex doesn't report cache writes
401
- });
402
- }
403
- if (!resolvedThreadId) return;
404
- const last = await readLastRolloutSnapshot(resolvedThreadId).catch(
405
- () => null,
406
- );
407
- if (!last) return;
408
- if (last.usage) {
399
+ const last = resolvedThreadId
400
+ ? await readLastRolloutSnapshot(resolvedThreadId).catch(() => null)
401
+ : null;
402
+ if (last?.usage) {
409
403
  streamState.contextTokens = last.usage.contextTokens;
410
404
  if (last.usage.contextWindow) {
411
405
  streamState.contextWindow = last.usage.contextWindow;
412
406
  }
413
407
  }
414
- if (typeof last.numApiCalls === "number") {
408
+ if (typeof last?.numApiCalls === "number") {
415
409
  streamState.numApiCalls = last.numApiCalls;
416
410
  }
417
- // Terminator-driven turns abort the stream before `turn.completed`
418
- // fires, so the SDK-side `usage` capture above is null on almost
419
- // every Talon turn. Recover this turn's real usage by diffing the
420
- // rollout's cumulative totals against the pre-turn baseline.
421
- if (!usage && last.totals && baselineTotals) {
411
+ if (last?.totals && baselineTotals) {
422
412
  recordTokens(streamState, {
423
413
  inputTokens: last.totals.inputTokens - baselineTotals.inputTokens,
424
414
  outputTokens: last.totals.outputTokens - baselineTotals.outputTokens,
@@ -426,6 +416,13 @@ export async function handleMessage(
426
416
  last.totals.cachedInputTokens - baselineTotals.cachedInputTokens,
427
417
  cacheWrite: 0, // Codex doesn't report cache writes
428
418
  });
419
+ } else if (usage) {
420
+ recordTokens(streamState, {
421
+ inputTokens: usage.input_tokens,
422
+ outputTokens: usage.output_tokens,
423
+ cacheRead: usage.cached_input_tokens,
424
+ cacheWrite: 0, // Codex doesn't report cache writes
425
+ });
429
426
  }
430
427
  };
431
428
 
@@ -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
- }