talon-agent 2.0.2 → 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 +1 -1
- package/package.json +1 -1
- package/src/core/engine/gateway-actions/native.ts +19 -101
- package/src/core/tools/native.ts +20 -14
- package/src/core/vfs/index.ts +5 -8
- package/src/core/vfs/types.ts +4 -4
- package/src/core/vfs/vfs.ts +1 -20
- package/src/core/vfs/rewrite.ts +0 -170
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 `
|
|
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
|
@@ -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
|
-
|
|
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
|
-
// ──
|
|
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:
|
|
535
|
-
*
|
|
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
|
-
):
|
|
541
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
package/src/core/tools/native.ts
CHANGED
|
@@ -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
|
-
*
|
|
14
|
-
*
|
|
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
|
-
"
|
|
20
|
-
"
|
|
21
|
-
"
|
|
22
|
-
"
|
|
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}
|
|
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).
|
|
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
|
|
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.
|
|
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
|
|
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
|
|
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(
|
|
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(
|
|
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()
|
package/src/core/vfs/index.ts
CHANGED
|
@@ -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
|
|
22
|
-
*
|
|
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,
|
package/src/core/vfs/types.ts
CHANGED
|
@@ -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
|
|
90
|
-
* addresses
|
|
91
|
-
* the mount
|
|
92
|
-
*
|
|
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>;
|
package/src/core/vfs/vfs.ts
CHANGED
|
@@ -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
|
|
108
|
+
* builder (symlink farm), the FUSE layer, and docs/tool output.
|
|
128
109
|
*/
|
|
129
110
|
describeMounts(): {
|
|
130
111
|
name: string;
|
package/src/core/vfs/rewrite.ts
DELETED
|
@@ -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
|
-
}
|