talon-agent 1.48.0 → 1.50.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 +3 -0
- package/package.json +1 -1
- package/src/backend/codex/factory.ts +2 -0
- package/src/backend/codex/handler/message.ts +10 -0
- package/src/backend/kilo/factory.ts +2 -0
- package/src/backend/kilo/handler/message.ts +10 -0
- package/src/backend/openai-agents/factory.ts +2 -0
- package/src/backend/openai-agents/handler/message.ts +10 -0
- package/src/backend/opencode/factory.ts +2 -0
- package/src/backend/opencode/handler/message.ts +10 -0
- package/src/backend/shared/index.ts +4 -0
- package/src/backend/shared/turn-interrupt.ts +61 -0
- package/src/bootstrap.ts +12 -5
- package/src/cli/daemon-api.ts +39 -0
- package/src/cli/events.ts +133 -0
- package/src/cli/fs.ts +138 -0
- package/src/cli/index.ts +36 -2
- package/src/cli/tasks.ts +82 -48
- package/src/core/bus/bus.ts +114 -0
- package/src/core/bus/events.ts +64 -0
- package/src/core/bus/index.ts +20 -0
- package/src/core/engine/gateway-actions/index.ts +3 -0
- package/src/core/engine/gateway-actions/native.ts +151 -24
- package/src/core/engine/gateway-actions/vfs.ts +69 -0
- package/src/core/engine/gateway.ts +46 -0
- package/src/core/tasks/table.ts +25 -4
- package/src/core/tasks/types.ts +6 -2
- package/src/core/tools/index.ts +2 -0
- package/src/core/tools/native.ts +19 -6
- package/src/core/tools/types.ts +2 -1
- package/src/core/tools/vfs.ts +61 -0
- package/src/core/vfs/index.ts +113 -0
- package/src/core/vfs/mounts/files.ts +154 -0
- package/src/core/vfs/mounts/plugins.ts +72 -0
- package/src/core/vfs/mounts/proc.ts +125 -0
- package/src/core/vfs/types.ts +103 -0
- package/src/core/vfs/vfs.ts +237 -0
- package/src/core/weaver/weaver.ts +62 -15
- package/src/frontend/native/discovery.ts +3 -0
- package/src/frontend/native/index.ts +10 -1
- package/src/frontend/native/server.ts +65 -6
- package/src/frontend/native/tls.ts +313 -0
- package/src/storage/journal.ts +91 -0
- package/src/storage/repositories/journal-repo.ts +38 -0
- package/src/storage/sql/journal.sql +16 -0
- package/src/storage/sql/schema.sql +15 -0
- package/src/storage/sql/statements.generated.ts +24 -1
- package/src/util/config.ts +9 -0
- package/src/util/log.ts +2 -0
- package/src/util/paths.ts +2 -0
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* VFS tools — the unified `talon://` namespace.
|
|
3
|
+
*
|
|
4
|
+
* One browsable tree over everything the daemon owns: real stores (the
|
|
5
|
+
* workspace, skills, scripts, logs) and live synthetic state (`proc/` for
|
|
6
|
+
* the task table and event bus, `plugins/` for the registry) answer the
|
|
7
|
+
* same three tools. The synthetic mounts are the point — this is the only
|
|
8
|
+
* way to read live daemon state as plain files.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { z } from "zod";
|
|
12
|
+
import type { ToolDefinition } from "./types.js";
|
|
13
|
+
|
|
14
|
+
const NAMESPACE = `Namespace roots: home/ (workspace), skills/, scripts/, logs/, proc/ (live task table under proc/tasks/<id> and the event ring at proc/events), plugins/ (registry view). List "" to see the root and where each mount lives on disk. Absolute OS paths inside a mounted directory are accepted and translated.`;
|
|
15
|
+
|
|
16
|
+
export const vfsTools: ToolDefinition[] = [
|
|
17
|
+
{
|
|
18
|
+
name: "vfs_list",
|
|
19
|
+
description: `List a directory in the talon:// namespace — the unified view over Talon's own state. ${NAMESPACE} Entries show kind (d/-), writability, size, and name.`,
|
|
20
|
+
schema: {
|
|
21
|
+
path: z
|
|
22
|
+
.string()
|
|
23
|
+
.describe(
|
|
24
|
+
'Namespace path, e.g. "" (root), "proc/tasks", "skills/review-pr". Forward slashes on every platform; "talon://" prefix optional.',
|
|
25
|
+
),
|
|
26
|
+
},
|
|
27
|
+
execute: (params, bridge) => bridge("vfs_list", params),
|
|
28
|
+
tag: "vfs",
|
|
29
|
+
},
|
|
30
|
+
|
|
31
|
+
{
|
|
32
|
+
name: "vfs_read",
|
|
33
|
+
description: `Read a file from the talon:// namespace (UTF-8 text, 256KB cap). ${NAMESPACE} Reading proc/tasks/<id> returns that task as JSON; proc/events returns the event ring as JSON Lines.`,
|
|
34
|
+
schema: {
|
|
35
|
+
path: z
|
|
36
|
+
.string()
|
|
37
|
+
.describe(
|
|
38
|
+
'Namespace file path, e.g. "proc/events" or "logs/2026-07-16.md"',
|
|
39
|
+
),
|
|
40
|
+
},
|
|
41
|
+
execute: (params, bridge) => bridge("vfs_read", params),
|
|
42
|
+
tag: "vfs",
|
|
43
|
+
},
|
|
44
|
+
|
|
45
|
+
{
|
|
46
|
+
name: "vfs_write",
|
|
47
|
+
description: `Write a UTF-8 file into a writable part of the talon:// namespace (home/, skills/, scripts/). Parent directories are created; synthetic mounts (proc/, plugins/) and logs/ are read-only views.`,
|
|
48
|
+
schema: {
|
|
49
|
+
path: z
|
|
50
|
+
.string()
|
|
51
|
+
.describe(
|
|
52
|
+
'Namespace file path under a writable mount, e.g. "home/notes/ideas.md"',
|
|
53
|
+
),
|
|
54
|
+
content: z
|
|
55
|
+
.string()
|
|
56
|
+
.describe("Full file content (replaces any existing content)"),
|
|
57
|
+
},
|
|
58
|
+
execute: (params, bridge) => bridge("vfs_write", params),
|
|
59
|
+
tag: "vfs",
|
|
60
|
+
},
|
|
61
|
+
];
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* VFS — public surface and default namespace.
|
|
3
|
+
*
|
|
4
|
+
* `getVfs()` serves the process-wide namespace, built on first use rather
|
|
5
|
+
* than at module load: the file mounts capture `dirs.*` when constructed,
|
|
6
|
+
* and eager construction would freeze those paths into whichever process
|
|
7
|
+
* (or test mock) happened to import this barrel first. Mounts project
|
|
8
|
+
* live singletons, so there is nothing to inject at bootstrap:
|
|
9
|
+
*
|
|
10
|
+
* talon://
|
|
11
|
+
* home/ workspace (writable)
|
|
12
|
+
* skills/ SKILL.md bundles (writable)
|
|
13
|
+
* scripts/ agent script bodies (writable)
|
|
14
|
+
* logs/ daily logs (read-only — the daemon writes these)
|
|
15
|
+
* proc/ task table + event bus ring (synthetic, read-only)
|
|
16
|
+
* plugins/ plugin registry view (synthetic, read-only)
|
|
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`.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
import { dirs } from "../../util/paths.js";
|
|
23
|
+
import { bus } from "../bus/index.js";
|
|
24
|
+
import { registry } from "../plugin/registry.js";
|
|
25
|
+
import { taskTable } from "../tasks/index.js";
|
|
26
|
+
import { createFileMount } from "./mounts/files.js";
|
|
27
|
+
import { createPluginsMount, type PluginView } from "./mounts/plugins.js";
|
|
28
|
+
import { createProcMount } from "./mounts/proc.js";
|
|
29
|
+
import { Vfs } from "./vfs.js";
|
|
30
|
+
|
|
31
|
+
export { Vfs } from "./vfs.js";
|
|
32
|
+
export { createFileMount } from "./mounts/files.js";
|
|
33
|
+
export { createProcMount, type ProcMountDeps } from "./mounts/proc.js";
|
|
34
|
+
export { createPluginsMount, type PluginView } from "./mounts/plugins.js";
|
|
35
|
+
export type {
|
|
36
|
+
VfsErrorCode,
|
|
37
|
+
VfsMount,
|
|
38
|
+
VfsNodeKind,
|
|
39
|
+
VfsResult,
|
|
40
|
+
VfsStat,
|
|
41
|
+
} from "./types.js";
|
|
42
|
+
export { VFS_MAX_READ_BYTES, vfsError, vfsOk } from "./types.js";
|
|
43
|
+
|
|
44
|
+
function pluginViews(): PluginView[] {
|
|
45
|
+
const modules: PluginView[] = registry.all.map(({ plugin, path }) => ({
|
|
46
|
+
name: plugin.name,
|
|
47
|
+
kind: "module",
|
|
48
|
+
...(plugin.description !== undefined
|
|
49
|
+
? { description: plugin.description }
|
|
50
|
+
: {}),
|
|
51
|
+
...(plugin.version !== undefined ? { version: plugin.version } : {}),
|
|
52
|
+
source: path,
|
|
53
|
+
}));
|
|
54
|
+
const mcp: PluginView[] = registry.mcpEntries.map((entry) => ({
|
|
55
|
+
name: entry.name,
|
|
56
|
+
kind: "mcp",
|
|
57
|
+
source: `${entry.command}${entry.args ? ` ${entry.args.join(" ")}` : ""}`,
|
|
58
|
+
}));
|
|
59
|
+
return [...modules, ...mcp];
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function buildDefaultNamespace(): Vfs {
|
|
63
|
+
const vfs = new Vfs();
|
|
64
|
+
vfs.mount(
|
|
65
|
+
"home",
|
|
66
|
+
createFileMount({
|
|
67
|
+
root: dirs.workspace,
|
|
68
|
+
description: "The workspace — the agent's home directory",
|
|
69
|
+
writable: true,
|
|
70
|
+
}),
|
|
71
|
+
);
|
|
72
|
+
vfs.mount(
|
|
73
|
+
"skills",
|
|
74
|
+
createFileMount({
|
|
75
|
+
root: dirs.skills,
|
|
76
|
+
description: "SKILL.md workflow bundles",
|
|
77
|
+
writable: true,
|
|
78
|
+
}),
|
|
79
|
+
);
|
|
80
|
+
vfs.mount(
|
|
81
|
+
"scripts",
|
|
82
|
+
createFileMount({
|
|
83
|
+
root: dirs.scripts,
|
|
84
|
+
description: "Agent-authored script bodies",
|
|
85
|
+
writable: true,
|
|
86
|
+
}),
|
|
87
|
+
);
|
|
88
|
+
vfs.mount(
|
|
89
|
+
"logs",
|
|
90
|
+
createFileMount({
|
|
91
|
+
root: dirs.logs,
|
|
92
|
+
description: "Daily interaction logs (daemon-written)",
|
|
93
|
+
writable: false,
|
|
94
|
+
}),
|
|
95
|
+
);
|
|
96
|
+
vfs.mount(
|
|
97
|
+
"proc",
|
|
98
|
+
createProcMount({
|
|
99
|
+
tasks: () => taskTable.list(),
|
|
100
|
+
events: () => bus.recent(0),
|
|
101
|
+
}),
|
|
102
|
+
);
|
|
103
|
+
vfs.mount("plugins", createPluginsMount(pluginViews));
|
|
104
|
+
return vfs;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
let defaultVfs: Vfs | undefined;
|
|
108
|
+
|
|
109
|
+
/** The process-wide namespace, built on first use. */
|
|
110
|
+
export function getVfs(): Vfs {
|
|
111
|
+
defaultVfs ??= buildDefaultNamespace();
|
|
112
|
+
return defaultVfs;
|
|
113
|
+
}
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* File mount — a real directory exposed into the namespace.
|
|
3
|
+
*
|
|
4
|
+
* Backs the workspace-shaped mounts (home, skills, scripts, logs). The
|
|
5
|
+
* resolver has already rejected traversal segments; the containment check
|
|
6
|
+
* here is belt-and-braces so no future caller can reach outside the root.
|
|
7
|
+
* A missing root reads as an empty directory — mounts exist from boot,
|
|
8
|
+
* the workspace dirs appear lazily.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import {
|
|
12
|
+
existsSync,
|
|
13
|
+
mkdirSync,
|
|
14
|
+
readFileSync,
|
|
15
|
+
readdirSync,
|
|
16
|
+
statSync,
|
|
17
|
+
writeFileSync,
|
|
18
|
+
} from "node:fs";
|
|
19
|
+
import { dirname, isAbsolute, relative, resolve, sep } from "node:path";
|
|
20
|
+
import type { VfsMount, VfsResult, VfsStat } from "../types.js";
|
|
21
|
+
import { VFS_MAX_READ_BYTES, vfsError, vfsOk } from "../types.js";
|
|
22
|
+
|
|
23
|
+
export function createFileMount(options: {
|
|
24
|
+
root: string;
|
|
25
|
+
description: string;
|
|
26
|
+
writable: boolean;
|
|
27
|
+
}): VfsMount {
|
|
28
|
+
const root = resolve(options.root);
|
|
29
|
+
|
|
30
|
+
/** Absolute path for `rel`, or undefined when it would escape the root. */
|
|
31
|
+
function realPath(rel: string): string | undefined {
|
|
32
|
+
const abs = resolve(root, ...rel.split("/"));
|
|
33
|
+
const offset = relative(root, abs);
|
|
34
|
+
if (
|
|
35
|
+
offset.startsWith(`..${sep}`) ||
|
|
36
|
+
offset === ".." ||
|
|
37
|
+
isAbsolute(offset)
|
|
38
|
+
) {
|
|
39
|
+
return undefined;
|
|
40
|
+
}
|
|
41
|
+
return abs;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function statAt(rel: string, abs: string): VfsResult<VfsStat> {
|
|
45
|
+
try {
|
|
46
|
+
const stats = statSync(abs);
|
|
47
|
+
const name = rel === "" ? "" : rel.split("/").at(-1)!;
|
|
48
|
+
return vfsOk({
|
|
49
|
+
path: rel,
|
|
50
|
+
name,
|
|
51
|
+
kind: stats.isDirectory() ? ("dir" as const) : ("file" as const),
|
|
52
|
+
...(stats.isFile() ? { size: stats.size } : {}),
|
|
53
|
+
modifiedAt: Math.floor(stats.mtimeMs),
|
|
54
|
+
writable: options.writable,
|
|
55
|
+
osPath: abs,
|
|
56
|
+
});
|
|
57
|
+
} catch {
|
|
58
|
+
return vfsError("not-found");
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
return {
|
|
63
|
+
description: options.description,
|
|
64
|
+
writable: options.writable,
|
|
65
|
+
osRoot: root,
|
|
66
|
+
|
|
67
|
+
stat(rel) {
|
|
68
|
+
const abs = realPath(rel);
|
|
69
|
+
if (!abs) return vfsError("invalid-path");
|
|
70
|
+
if (rel === "" && !existsSync(abs)) {
|
|
71
|
+
return vfsOk({
|
|
72
|
+
path: "",
|
|
73
|
+
name: "",
|
|
74
|
+
kind: "dir",
|
|
75
|
+
writable: options.writable,
|
|
76
|
+
osPath: abs,
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
return statAt(rel, abs);
|
|
80
|
+
},
|
|
81
|
+
|
|
82
|
+
list(rel) {
|
|
83
|
+
const abs = realPath(rel);
|
|
84
|
+
if (!abs) return vfsError("invalid-path");
|
|
85
|
+
if (rel === "" && !existsSync(abs)) return vfsOk([]);
|
|
86
|
+
let stats;
|
|
87
|
+
try {
|
|
88
|
+
stats = statSync(abs);
|
|
89
|
+
} catch {
|
|
90
|
+
return vfsError("not-found");
|
|
91
|
+
}
|
|
92
|
+
if (!stats.isDirectory()) return vfsError("not-a-directory");
|
|
93
|
+
const entries = readdirSync(abs, { withFileTypes: true })
|
|
94
|
+
.map((entry) => {
|
|
95
|
+
const childRel = rel === "" ? entry.name : `${rel}/${entry.name}`;
|
|
96
|
+
const child = statAt(childRel, resolve(abs, entry.name));
|
|
97
|
+
return child.ok ? child.value : undefined;
|
|
98
|
+
})
|
|
99
|
+
.filter((entry): entry is VfsStat => entry !== undefined)
|
|
100
|
+
.sort(
|
|
101
|
+
(a, b) =>
|
|
102
|
+
Number(a.kind === "file") - Number(b.kind === "file") ||
|
|
103
|
+
a.name.localeCompare(b.name),
|
|
104
|
+
);
|
|
105
|
+
return vfsOk(entries);
|
|
106
|
+
},
|
|
107
|
+
|
|
108
|
+
read(rel) {
|
|
109
|
+
const abs = realPath(rel);
|
|
110
|
+
if (!abs) return vfsError("invalid-path");
|
|
111
|
+
let stats;
|
|
112
|
+
try {
|
|
113
|
+
stats = statSync(abs);
|
|
114
|
+
} catch {
|
|
115
|
+
return vfsError("not-found");
|
|
116
|
+
}
|
|
117
|
+
if (stats.isDirectory()) return vfsError("is-a-directory");
|
|
118
|
+
if (stats.size > VFS_MAX_READ_BYTES) {
|
|
119
|
+
return vfsError(
|
|
120
|
+
"too-large",
|
|
121
|
+
`${stats.size} bytes (cap ${VFS_MAX_READ_BYTES}) — on disk at ${abs}`,
|
|
122
|
+
);
|
|
123
|
+
}
|
|
124
|
+
const buffer = readFileSync(abs);
|
|
125
|
+
if (buffer.includes(0)) {
|
|
126
|
+
return vfsError("binary-file", "Content contains null bytes");
|
|
127
|
+
}
|
|
128
|
+
return vfsOk(buffer.toString("utf-8"));
|
|
129
|
+
},
|
|
130
|
+
|
|
131
|
+
...(options.writable
|
|
132
|
+
? {
|
|
133
|
+
write(rel: string, content: string): VfsResult<VfsStat> {
|
|
134
|
+
if (rel === "") return vfsError("is-a-directory");
|
|
135
|
+
const abs = realPath(rel);
|
|
136
|
+
if (!abs) return vfsError("invalid-path");
|
|
137
|
+
if (existsSync(abs) && statSync(abs).isDirectory()) {
|
|
138
|
+
return vfsError("is-a-directory");
|
|
139
|
+
}
|
|
140
|
+
try {
|
|
141
|
+
mkdirSync(dirname(abs), { recursive: true });
|
|
142
|
+
writeFileSync(abs, content, "utf-8");
|
|
143
|
+
} catch (err) {
|
|
144
|
+
return vfsError(
|
|
145
|
+
"io-error",
|
|
146
|
+
err instanceof Error ? err.message : String(err),
|
|
147
|
+
);
|
|
148
|
+
}
|
|
149
|
+
return statAt(rel, abs);
|
|
150
|
+
},
|
|
151
|
+
}
|
|
152
|
+
: {}),
|
|
153
|
+
};
|
|
154
|
+
}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Plugins mount — the plugin registry as a synthetic read-only directory:
|
|
3
|
+
* one file per loaded plugin (or registered standalone MCP entry), whose
|
|
4
|
+
* content is the registry's view of it as pretty JSON. Provider-injected
|
|
5
|
+
* like proc — the index wires the live registry, tests wire fixtures.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import type { VfsMount, VfsResult, VfsStat } from "../types.js";
|
|
9
|
+
import { vfsError, vfsOk } from "../types.js";
|
|
10
|
+
|
|
11
|
+
/** The registry facts one plugin file exposes. */
|
|
12
|
+
export interface PluginView {
|
|
13
|
+
readonly name: string;
|
|
14
|
+
readonly kind: "module" | "mcp";
|
|
15
|
+
readonly description?: string;
|
|
16
|
+
readonly version?: string;
|
|
17
|
+
/** Module path, or the MCP entry's command line. */
|
|
18
|
+
readonly source: string;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function render(view: PluginView): string {
|
|
22
|
+
return `${JSON.stringify(view, null, 2)}\n`;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function fileStat(view: PluginView): VfsStat {
|
|
26
|
+
return {
|
|
27
|
+
path: view.name,
|
|
28
|
+
name: view.name,
|
|
29
|
+
kind: "file",
|
|
30
|
+
size: Buffer.byteLength(render(view), "utf-8"),
|
|
31
|
+
writable: false,
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function createPluginsMount(
|
|
36
|
+
plugins: () => readonly PluginView[],
|
|
37
|
+
): VfsMount {
|
|
38
|
+
function byName(rel: string): PluginView | undefined {
|
|
39
|
+
return plugins().find((view) => view.name === rel);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
return {
|
|
43
|
+
description: "Loaded plugins and registered MCP servers (registry view)",
|
|
44
|
+
writable: false,
|
|
45
|
+
|
|
46
|
+
stat(rel): VfsResult<VfsStat> {
|
|
47
|
+
if (rel === "")
|
|
48
|
+
return vfsOk({ path: "", name: "", kind: "dir", writable: false });
|
|
49
|
+
const view = byName(rel);
|
|
50
|
+
return view ? vfsOk(fileStat(view)) : vfsError("not-found");
|
|
51
|
+
},
|
|
52
|
+
|
|
53
|
+
list(rel) {
|
|
54
|
+
if (rel !== "") {
|
|
55
|
+
return byName(rel)
|
|
56
|
+
? vfsError("not-a-directory")
|
|
57
|
+
: vfsError("not-found");
|
|
58
|
+
}
|
|
59
|
+
return vfsOk(
|
|
60
|
+
plugins()
|
|
61
|
+
.map(fileStat)
|
|
62
|
+
.sort((a, b) => a.name.localeCompare(b.name)),
|
|
63
|
+
);
|
|
64
|
+
},
|
|
65
|
+
|
|
66
|
+
read(rel) {
|
|
67
|
+
if (rel === "") return vfsError("is-a-directory");
|
|
68
|
+
const view = byName(rel);
|
|
69
|
+
return view ? vfsOk(render(view)) : vfsError("not-found");
|
|
70
|
+
},
|
|
71
|
+
};
|
|
72
|
+
}
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Proc mount — live daemon state as a synthetic read-only tree, in the
|
|
3
|
+
* /proc tradition:
|
|
4
|
+
*
|
|
5
|
+
* proc/
|
|
6
|
+
* tasks/<id> one task table record, pretty JSON
|
|
7
|
+
* events the event bus ring, JSON Lines (newest last)
|
|
8
|
+
*
|
|
9
|
+
* Providers are injected so the mount is a pure projection — the index
|
|
10
|
+
* wires the real task table and bus, tests wire fixtures. Sizes and
|
|
11
|
+
* timestamps are computed from the rendered content on demand; this is a
|
|
12
|
+
* view of live state, nothing is cached.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import type { PublishedEvent } from "../../bus/index.js";
|
|
16
|
+
import type { TaskRecord } from "../../tasks/index.js";
|
|
17
|
+
import type { VfsMount, VfsResult, VfsStat } from "../types.js";
|
|
18
|
+
import { vfsError, vfsOk } from "../types.js";
|
|
19
|
+
|
|
20
|
+
export interface ProcMountDeps {
|
|
21
|
+
tasks: () => readonly TaskRecord[];
|
|
22
|
+
events: () => readonly PublishedEvent[];
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const DIR = (path: string, name: string): VfsStat => ({
|
|
26
|
+
path,
|
|
27
|
+
name,
|
|
28
|
+
kind: "dir",
|
|
29
|
+
writable: false,
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
function fileStat(path: string, content: string, modifiedAt?: number): VfsStat {
|
|
33
|
+
return {
|
|
34
|
+
path,
|
|
35
|
+
name: path.split("/").at(-1)!,
|
|
36
|
+
kind: "file",
|
|
37
|
+
size: Buffer.byteLength(content, "utf-8"),
|
|
38
|
+
...(modifiedAt !== undefined ? { modifiedAt } : {}),
|
|
39
|
+
writable: false,
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function renderTask(task: TaskRecord): string {
|
|
44
|
+
return `${JSON.stringify(task, null, 2)}\n`;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function taskModifiedAt(task: TaskRecord): number {
|
|
48
|
+
return task.endedAt ?? task.startedAt ?? task.queuedAt;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function renderEvents(events: readonly PublishedEvent[]): string {
|
|
52
|
+
if (events.length === 0) return "";
|
|
53
|
+
return events.map((event) => JSON.stringify(event)).join("\n") + "\n";
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function createProcMount(deps: ProcMountDeps): VfsMount {
|
|
57
|
+
function taskById(id: string): TaskRecord | undefined {
|
|
58
|
+
if (!/^\d+$/.test(id)) return undefined;
|
|
59
|
+
return deps.tasks().find((task) => task.id === Number(id));
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function resolveNode(rel: string): VfsResult<VfsStat> {
|
|
63
|
+
if (rel === "") return vfsOk(DIR("", ""));
|
|
64
|
+
if (rel === "tasks") return vfsOk(DIR("tasks", "tasks"));
|
|
65
|
+
if (rel === "events") {
|
|
66
|
+
const events = deps.events();
|
|
67
|
+
return vfsOk(fileStat("events", renderEvents(events), events.at(-1)?.at));
|
|
68
|
+
}
|
|
69
|
+
const taskId = rel.startsWith("tasks/") ? rel.slice("tasks/".length) : null;
|
|
70
|
+
if (taskId !== null && !taskId.includes("/")) {
|
|
71
|
+
const task = taskById(taskId);
|
|
72
|
+
if (!task) return vfsError("not-found");
|
|
73
|
+
return vfsOk(
|
|
74
|
+
fileStat(`tasks/${task.id}`, renderTask(task), taskModifiedAt(task)),
|
|
75
|
+
);
|
|
76
|
+
}
|
|
77
|
+
return vfsError("not-found");
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
return {
|
|
81
|
+
description: "Live daemon state: task table and event bus ring",
|
|
82
|
+
writable: false,
|
|
83
|
+
|
|
84
|
+
stat: resolveNode,
|
|
85
|
+
|
|
86
|
+
list(rel) {
|
|
87
|
+
if (rel === "") {
|
|
88
|
+
const events = deps.events();
|
|
89
|
+
return vfsOk([
|
|
90
|
+
DIR("tasks", "tasks"),
|
|
91
|
+
fileStat("events", renderEvents(events), events.at(-1)?.at),
|
|
92
|
+
]);
|
|
93
|
+
}
|
|
94
|
+
if (rel === "tasks") {
|
|
95
|
+
return vfsOk(
|
|
96
|
+
deps
|
|
97
|
+
.tasks()
|
|
98
|
+
.map((task) =>
|
|
99
|
+
fileStat(
|
|
100
|
+
`tasks/${task.id}`,
|
|
101
|
+
renderTask(task),
|
|
102
|
+
taskModifiedAt(task),
|
|
103
|
+
),
|
|
104
|
+
)
|
|
105
|
+
.sort((a, b) => Number(a.name) - Number(b.name)),
|
|
106
|
+
);
|
|
107
|
+
}
|
|
108
|
+
const node = resolveNode(rel);
|
|
109
|
+
if (!node.ok) return node;
|
|
110
|
+
return vfsError("not-a-directory");
|
|
111
|
+
},
|
|
112
|
+
|
|
113
|
+
read(rel) {
|
|
114
|
+
if (rel === "events") return vfsOk(renderEvents(deps.events()));
|
|
115
|
+
if (rel.startsWith("tasks/")) {
|
|
116
|
+
const task = taskById(rel.slice("tasks/".length));
|
|
117
|
+
if (!task) return vfsError("not-found");
|
|
118
|
+
return vfsOk(renderTask(task));
|
|
119
|
+
}
|
|
120
|
+
const node = resolveNode(rel);
|
|
121
|
+
if (!node.ok) return node;
|
|
122
|
+
return vfsError("is-a-directory");
|
|
123
|
+
},
|
|
124
|
+
};
|
|
125
|
+
}
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* VFS vocabulary — the unified `talon://` namespace.
|
|
3
|
+
*
|
|
4
|
+
* One rooted, mountable namespace over everything the daemon owns, in the
|
|
5
|
+
* Plan 9 tradition: real-file stores (workspace, skills, scripts, logs)
|
|
6
|
+
* and synthetic /proc-style views of live state (task table, event bus,
|
|
7
|
+
* plugin registry) answer the same four operations. The namespace is for
|
|
8
|
+
* unification and introspection — mounts decide what they can do (a
|
|
9
|
+
* synthetic view has nothing meaningful to write), but the VFS is never a
|
|
10
|
+
* permission layer.
|
|
11
|
+
*
|
|
12
|
+
* An address reaches the namespace in one of three spellings, all resolved
|
|
13
|
+
* by the same grammar (see `Vfs.#parse`):
|
|
14
|
+
*
|
|
15
|
+
* - scheme: `talon://skills/review-pr/SKILL.md`
|
|
16
|
+
* - mount-relative: `skills/review-pr/SKILL.md` (`/`-separated everywhere)
|
|
17
|
+
* - OS-absolute: the disk spelling of a node inside a file-backed
|
|
18
|
+
* mount's root — the same node, other coordinate system
|
|
19
|
+
*
|
|
20
|
+
* Mounts receive mount-relative paths ("" = the mount root) and report
|
|
21
|
+
* entry paths mount-relative too — the resolver owns prefixing.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
/** What a path points at. */
|
|
25
|
+
export type VfsNodeKind = "dir" | "file";
|
|
26
|
+
|
|
27
|
+
/** Errno-style failure vocabulary — every operation fails with one of these. */
|
|
28
|
+
export type VfsErrorCode =
|
|
29
|
+
| "not-found"
|
|
30
|
+
| "invalid-path"
|
|
31
|
+
| "is-a-directory"
|
|
32
|
+
| "not-a-directory"
|
|
33
|
+
| "not-writable"
|
|
34
|
+
| "too-large"
|
|
35
|
+
| "binary-file"
|
|
36
|
+
| "io-error";
|
|
37
|
+
|
|
38
|
+
export type VfsResult<T> =
|
|
39
|
+
| { readonly ok: true; readonly value: T }
|
|
40
|
+
| {
|
|
41
|
+
readonly ok: false;
|
|
42
|
+
readonly error: VfsErrorCode;
|
|
43
|
+
readonly detail?: string;
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
export function vfsOk<T>(value: T): VfsResult<T> {
|
|
47
|
+
return { ok: true, value };
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function vfsError<T>(
|
|
51
|
+
error: VfsErrorCode,
|
|
52
|
+
detail?: string,
|
|
53
|
+
): VfsResult<T> {
|
|
54
|
+
return { ok: false, error, ...(detail !== undefined ? { detail } : {}) };
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** One node's metadata — also the shape of directory listing entries. */
|
|
58
|
+
export interface VfsStat {
|
|
59
|
+
/** Root-relative path (`skills/review-pr/SKILL.md`); "" is the root. */
|
|
60
|
+
readonly path: string;
|
|
61
|
+
/** Last path segment; the mount name for a mount root. */
|
|
62
|
+
readonly name: string;
|
|
63
|
+
readonly kind: VfsNodeKind;
|
|
64
|
+
/** Content size in bytes, when the mount can know it cheaply. */
|
|
65
|
+
readonly size?: number;
|
|
66
|
+
/** Last modification, epoch ms, when known. */
|
|
67
|
+
readonly modifiedAt?: number;
|
|
68
|
+
/** Whether write() can target this node (or nodes under this dir). */
|
|
69
|
+
readonly writable: boolean;
|
|
70
|
+
/**
|
|
71
|
+
* Where this node lives on disk, for file-backed mounts. Absent on
|
|
72
|
+
* synthetic nodes — they have no disk representation. This is what lets
|
|
73
|
+
* OS-level tools (shell, editors) reach a node the namespace named.
|
|
74
|
+
*/
|
|
75
|
+
readonly osPath?: string;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* One mounted store. Implementations receive mount-relative paths that the
|
|
80
|
+
* resolver has already normalized (no "..", no backslashes, no empty
|
|
81
|
+
* segments) and return stats with mount-relative `path`s.
|
|
82
|
+
*/
|
|
83
|
+
export interface VfsMount {
|
|
84
|
+
/** One line shown when listing the namespace root. */
|
|
85
|
+
readonly description: string;
|
|
86
|
+
/** Can write() ever succeed here? Synthetic views say false. */
|
|
87
|
+
readonly writable: boolean;
|
|
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.
|
|
93
|
+
*/
|
|
94
|
+
readonly osRoot?: string;
|
|
95
|
+
stat(rel: string): VfsResult<VfsStat>;
|
|
96
|
+
list(rel: string): VfsResult<VfsStat[]>;
|
|
97
|
+
read(rel: string): VfsResult<string>;
|
|
98
|
+
/** Optional — absent means the whole mount is read-only. */
|
|
99
|
+
write?(rel: string, content: string): VfsResult<VfsStat>;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** Read cap — a namespace read is a context payload, not a file transfer. */
|
|
103
|
+
export const VFS_MAX_READ_BYTES = 256 * 1024;
|