@inthedark/wb 0.0.7 → 0.0.9

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
@@ -21,34 +21,63 @@ npx @inthedark/wb
21
21
  npx @inthedark/wb connect
22
22
  ```
23
23
 
24
+ ## philosophies
25
+
26
+ - what the human says goes
27
+ - harnesses should own agent mistake prevention via mechanical tools. make mistakes impossible or difficult to walk into
28
+ - for projects sandboxes are the right approach
29
+ - allow the user to do anything in minimal clicks
30
+ - reinventing the wheel is worth it way more than in the past
31
+
24
32
  ## features
25
33
 
26
- key features:
27
- - fully customisable agent instructions with support for comments & imports
28
- - strong thread transcript UX for when you need to keep an eye on a thread (command matching + html elements for agents)
29
- - strong thread management UX for when you're managing LOTS of threads (thread status-sorted sidebar)
30
- - better shared workspace support than any other agentic ide, because a lot of work is not worktree->pr shaped; agents claim the files they need and other agents don't freak out about unclaimed dirt
31
- - near-identical agent behaviour across providers (codex, opencode 2, claude code) as if they're the same harness
32
- - agents can orchestrate subagents working in threads across providers and projects
33
- - customisable voice to text ("what if voice to text was like working in a rich text editor")
34
- - git UI for doing your own work at the same time as other agents, allowing easy commits while agents have other files claimed
35
- - quickly switch between saved profiles for harness/model/effort/window/fast mode/agent identity
36
- - automatic compaction on turn start past configurable time & token thresholds (reduce cache write costs)
37
- - agents can search repos with shell tools like they're on disk via vfs
34
+ selling points first:
35
+
36
+ #### first class shared workspace support
37
+ a lot of work is not worktree->pr shaped; agents claim the files they need and other agents don't freak out about workspace dirt, they don't *see* it. they queue up behind each other for their claims. even has a git view that helps you easily commit unclaimed dirt
38
+
39
+ #### fully customisable instructions
40
+ base instructions get improved constantly; but YOUR CONTROL is important. you can override it piecemeal or in entirety, *with* support for comments & imports
41
+
42
+ #### consolidated tool & sandbox behaviour across harnesses
43
+ codex, opencode 2, and claude code agents all have workbench tools and only the most basic of their native tools left like edit tools
44
+
45
+ #### customisable voice to text
46
+ what if voice to text was like telling someone what to do in a rich text editor
38
47
 
39
- setup/meta:
48
+ #### webdev mockups using real project components & css
49
+ configure build+css scripts to handle the agent's created tsx
50
+
51
+ #### .env secrets
52
+ store secrets in ui, reference from .env, see dedicated docs below
53
+
54
+ #### agents can search repos with comfy shell fs tools
55
+ vfs-powered, so doesn't require the repo being on disk entirely
56
+
57
+ ### ux
58
+ - thread status inbox, snooze threads while working to make them hide until done
59
+ - human readable displays for common shell commands
60
+ - near-instant loads
61
+ - smooth per-token animations, or animate in whole blocks if you prefer
62
+
63
+ ### obligatory
40
64
  - built on top of other harnesses (codex, opencode 2, claude code) so you can use subscriptions
65
+ - cross-harness subagents
66
+ - browser use
67
+ - saved profiles for harness/model/effort/window/fast mode/agent identity
68
+ - mcp servers (agents must ask to enable tools by default)
69
+
70
+ ### misc
71
+ - automatic compaction on turn start past configurable time & token thresholds (reduce cache write costs)
41
72
  - tailscale integration
42
73
  - daemon sleeps when idle, leaving only a light http wake service up
43
74
 
44
- unpolished/unfinished/broken stuff (coming soon tm!!!!!):
75
+ ### unpolished/unfinished/broken stuff (coming soon tm!!!!!)
45
76
  - soon, autocomplete/suggestions extension for vscode & similar (highly configurable, able to be FAST)
46
77
  - worktree->pr workflow is unsupported (gonna be honest i have been in a cave working solo on wb for so long that i have not needed this yet)
47
- - browse AX & UX
48
78
  - search UX
49
79
  - i'd like more git ui
50
- - many places around the app could use a UI polish pass
51
- - the codebase itself could use a polish pass, esp. the react & tailwind
80
+ - the codebase's refactors from its (very) humble beginnings are not complete
52
81
 
53
82
  ## tech
54
83
 
@@ -58,7 +87,8 @@ note: have tried to keep dependencies minimal for the most part
58
87
  `/daemon/host` — wake service
59
88
  `/daemon/server` — workbench harness
60
89
  `/daemon/voice` — vtt service (rust)
61
- `/daemon/repo` — vfs service (go)
90
+ `/daemon/repo` — vfs service (go)
91
+ `/daemon/relay` — linux sandbox loopback relay (go)
62
92
  `/integration` — integrations in third party software
63
93
  `/instructions` — instructions for agents, copied into the user's `~/.workbench` folder whenever read
64
94
  `/app/server` — server for frontend app
@@ -0,0 +1,71 @@
1
+ /*
2
+ * Exports:
3
+ * - WorkbenchArcTreeOptions: cwd, hold-own, environment, fetch, cancellation and warning boundaries for loading.
4
+ * - WorkbenchArcTreeRepository: one repository's daemon view: tree, HEAD and other threads' held paths.
5
+ * - WorkbenchArcTreeNodeEnvironmentOptions: base environment and manifest parent directory for Node children.
6
+ * - default WorkbenchArcTree: the worktree as the calling agent thread builds it, readable in process and by Node
7
+ * children; the plain filesystem outside agent threads or when the daemon view fails.
8
+ */
9
+ export interface WorkbenchArcTreeOptions {
10
+ /** Directory whose repositories to view; relative reads resolve from it. Defaults to `process.cwd()`. */
11
+ cwd?: string;
12
+ /** Hold the caller's own dirty claims back too, for a matched "before" build. */
13
+ holdOwn?: boolean;
14
+ /** Agent identity and data root come from here. Defaults to `process.env`. */
15
+ env?: NodeJS.ProcessEnv;
16
+ fetch?: typeof globalThis.fetch;
17
+ /** Aborting stops the daemon request and Git reads, and rejects instead of falling back. */
18
+ signal?: AbortSignal;
19
+ /** Fallback and dropped-entry notices. Silent by default. */
20
+ warn?: (message: string) => void;
21
+ }
22
+
23
+ export interface WorkbenchArcTreeRepository {
24
+ /** Absolute repository root. */
25
+ root: string;
26
+ head: string | null;
27
+ /** Git tree of the worktree as the caller builds it. */
28
+ tree: string;
29
+ /** Repository-relative paths each other thread holds. */
30
+ held: ReadonlyArray<{ threadId: string; paths: readonly string[] }>;
31
+ }
32
+
33
+ export interface WorkbenchArcTreeNodeEnvironmentOptions {
34
+ /** `NODE_OPTIONS` is appended to, not replaced. Defaults to `process.env`. */
35
+ env?: NodeJS.ProcessEnv;
36
+ /** Parent of the manifest directory, relative to `cwd`. Defaults to `node_modules/.cache/wb-arc-tree`. Only the first call's value applies. */
37
+ directory?: string;
38
+ }
39
+
40
+ export default class WorkbenchArcTree implements AsyncDisposable {
41
+ private constructor();
42
+ /**
43
+ * Outside agent threads, the plain filesystem without asking the daemon. A daemon or Git failure warns and also
44
+ * yields the plain filesystem, so callers still run, visibly against the live worktree. Never rejects for
45
+ * unavailability; only `signal` aborts reject.
46
+ */
47
+ static load(options?: WorkbenchArcTreeOptions): Promise<WorkbenchArcTree>;
48
+ /** A tree holding `files` (paths relative to `cwd`; null where the tree lacks them) without asking the daemon. */
49
+ static fromFiles(cwd: string, files: ReadonlyMap<string, Buffer | null>, repositories?: readonly WorkbenchArcTreeRepository[]): WorkbenchArcTree;
50
+
51
+ readonly source: "arc" | "filesystem";
52
+ readonly cwd: string;
53
+ /** Absolute held paths mapped to arc tree bytes, or null where the arc tree lacks them. Unlisted paths read from disk. */
54
+ readonly files: ReadonlyMap<string, Buffer | null>;
55
+ readonly repositories: readonly WorkbenchArcTreeRepository[];
56
+ readonly isEmpty: boolean;
57
+
58
+ /** Held bytes, ENOENT where the arc tree lacks the path, otherwise disk. */
59
+ readFile(path: string): Promise<Buffer>;
60
+ readFile(path: string, encoding: BufferEncoding): Promise<string>;
61
+ exists(path: string): boolean;
62
+ /** Absolute held paths this tree has but disk lacks; in-process reads see them, Node children cannot. */
63
+ readonly deletedOnDisk: string[];
64
+ /**
65
+ * Variables to merge into a Node child's environment so it and its Node descendants read this tree. Empty when
66
+ * nothing is held. Writes a manifest that `dispose` removes.
67
+ */
68
+ nodeEnvironment(options?: WorkbenchArcTreeNodeEnvironmentOptions): Promise<Record<string, string>>;
69
+ dispose(): Promise<void>;
70
+ [Symbol.asyncDispose](): Promise<void>;
71
+ }
@@ -0,0 +1,185 @@
1
+ /*
2
+ * Exports:
3
+ * - default WorkbenchArcTree: the worktree as the calling agent thread builds it (other threads' held paths at the
4
+ * caller's arc baseline, everything else from disk), with in-process reads and a Node child-process environment.
5
+ * Falls back to the plain filesystem outside agent threads or when the daemon view fails.
6
+ */
7
+ import { spawn } from "node:child_process";
8
+ import fs from "node:fs";
9
+ import os from "node:os";
10
+ import path from "node:path";
11
+ import { ARC_TREE_MANIFEST_ENV, arcTreePathKey, writeArcTreeManifest } from "./arc-tree-manifest.mjs";
12
+ import { daemonGitArcIdentity, requestDaemonGitArc } from "./workbench-daemon.mjs";
13
+
14
+ const TREE_SHA = /^[0-9a-f]{40}(?:[0-9a-f]{24})?$/u;
15
+
16
+ function nonEmptyString(value) {
17
+ return typeof value === "string" && value.length > 0;
18
+ }
19
+
20
+ /** Keep every well-formed repository and held entry; each malformed one drops only itself. */
21
+ function conformRepositories(body, warn) {
22
+ if (!body || typeof body !== "object" || !Array.isArray(body.repositories)) {
23
+ throw new Error("the daemon's arc tree response lacks repositories");
24
+ }
25
+ let dropped = 0;
26
+ const repositories = body.repositories.flatMap((repository) => {
27
+ if (!repository || typeof repository !== "object" || !nonEmptyString(repository.repoRoot) || !TREE_SHA.test(String(repository.tree))) {
28
+ dropped++;
29
+ return [];
30
+ }
31
+ const held = (Array.isArray(repository.held) ? repository.held : []).flatMap((entry) => {
32
+ if (!entry || typeof entry !== "object" || !nonEmptyString(entry.threadId) || !Array.isArray(entry.paths)) {
33
+ dropped++;
34
+ return [];
35
+ }
36
+ const paths = entry.paths.filter(nonEmptyString);
37
+ dropped += entry.paths.length - paths.length;
38
+ return [{ threadId: entry.threadId, paths }];
39
+ });
40
+ const head = typeof repository.head === "string" && TREE_SHA.test(repository.head) ? repository.head : null;
41
+ return [{ root: path.resolve(repository.repoRoot), head, tree: repository.tree, held }];
42
+ });
43
+ if (dropped) warn(`Workbench arc tree dropped ${dropped} malformed daemon entr${dropped === 1 ? "y" : "ies"}; their paths read from disk.`);
44
+ return repositories;
45
+ }
46
+
47
+ async function git(root, args, { input = "", signal } = {}) {
48
+ const child = spawn("git", args, { cwd: root, signal, stdio: ["pipe", "pipe", "pipe"], windowsHide: true });
49
+ const stdout = [];
50
+ const stderr = [];
51
+ child.stdout.on("data", (chunk) => stdout.push(chunk));
52
+ child.stderr.on("data", (chunk) => stderr.push(chunk));
53
+ const exit = new Promise((resolve, reject) => {
54
+ child.once("error", reject);
55
+ child.once("close", resolve);
56
+ });
57
+ child.stdin.end(input);
58
+ if (await exit !== 0) throw new Error(`git ${args[0]} failed: ${Buffer.concat(stderr).toString().trim()}`);
59
+ return Buffer.concat(stdout);
60
+ }
61
+
62
+ /**
63
+ * Each path's bytes in `tree` as a checkout would write them, or null where the tree lacks it. Batch reads skip
64
+ * checkout filters, so only the existence check is batched.
65
+ */
66
+ async function readTreeFiles(root, tree, paths, signal) {
67
+ if (paths.some((relative) => relative.includes("\n"))) throw new Error("held paths containing newlines cannot be read from Git");
68
+ const checks = paths.length
69
+ ? (await git(root, ["cat-file", "--batch-check"], { input: paths.map((relative) => `${tree}:${relative}\n`).join(""), signal })).toString().split("\n")
70
+ : [];
71
+ return await Promise.all(paths.map(async (relative, index) => {
72
+ const target = path.resolve(root, relative);
73
+ if (checks[index].endsWith(" missing")) return [target, null];
74
+ const [, type] = checks[index].split(" ");
75
+ if (type !== "blob") throw new Error(`${relative} is a ${type} in the arc tree, not a file`);
76
+ return [target, await git(root, ["cat-file", "--filters", `${tree}:${relative}`], { signal })];
77
+ }));
78
+ }
79
+
80
+ function missing(target) {
81
+ return Object.assign(new Error(`ENOENT: no such file or directory, open '${target}'`), {
82
+ code: "ENOENT", errno: -os.constants.errno.ENOENT, path: target, syscall: "open",
83
+ });
84
+ }
85
+
86
+ export default class WorkbenchArcTree {
87
+ #held;
88
+ #manifest = null;
89
+
90
+ constructor(source, cwd, files, repositories) {
91
+ this.source = source;
92
+ this.cwd = cwd;
93
+ this.files = files;
94
+ this.repositories = repositories;
95
+ this.#held = new Map([...files].map(([target, content]) => [arcTreePathKey(target), content]));
96
+ }
97
+
98
+ static async load({ cwd = process.cwd(), holdOwn = false, env = process.env, fetch, signal, warn = () => {} } = {}) {
99
+ const root = path.resolve(cwd);
100
+ signal?.throwIfAborted();
101
+ if (!daemonGitArcIdentity(env)) return new WorkbenchArcTree("filesystem", root, new Map(), []);
102
+ try {
103
+ const repositories = conformRepositories(
104
+ await requestDaemonGitArc({ action: "arcTree", cwd: root, holdOwn, paths: [] }, { env, fetch, signal }),
105
+ warn,
106
+ );
107
+ if (!repositories.length) throw new Error(`no Workbench arc covers ${root}`);
108
+ const files = new Map();
109
+ for (const repository of repositories) {
110
+ const held = [...new Set(repository.held.flatMap(({ paths }) => paths))];
111
+ for (const [target, content] of await readTreeFiles(repository.root, repository.tree, held, signal)) files.set(target, content);
112
+ }
113
+ return new WorkbenchArcTree("arc", root, files, repositories);
114
+ } catch (error) {
115
+ signal?.throwIfAborted();
116
+ warn(`Workbench arc tree unavailable (${error instanceof Error ? error.message : String(error)}); reading the filesystem, including other threads' live edits.`);
117
+ return new WorkbenchArcTree("filesystem", root, new Map(), []);
118
+ }
119
+ }
120
+
121
+ static fromFiles(cwd, files, repositories = []) {
122
+ const root = path.resolve(cwd);
123
+ return new WorkbenchArcTree("arc", root, new Map([...files].map(([relative, content]) => [path.resolve(root, relative), content])), repositories);
124
+ }
125
+
126
+ get isEmpty() {
127
+ return !this.files.size;
128
+ }
129
+
130
+ async readFile(target, encoding) {
131
+ const resolved = path.resolve(this.cwd, target);
132
+ const content = this.#held.get(arcTreePathKey(resolved));
133
+ if (content === undefined) return await fs.promises.readFile(resolved, encoding ? { encoding } : undefined);
134
+ if (content === null) throw missing(resolved);
135
+ return encoding ? content.toString(encoding) : Buffer.from(content);
136
+ }
137
+
138
+ exists(target) {
139
+ const resolved = path.resolve(this.cwd, target);
140
+ const content = this.#held.get(arcTreePathKey(resolved));
141
+ return content === undefined ? fs.existsSync(resolved) : content !== null;
142
+ }
143
+
144
+ get deletedOnDisk() {
145
+ return [...this.files].flatMap(([target, content]) => content !== null && !fs.existsSync(target) ? [target] : []);
146
+ }
147
+
148
+ async nodeEnvironment({ env = process.env, directory } = {}) {
149
+ if (this.isEmpty) return {};
150
+ this.#manifest ??= this.#writeManifest(path.resolve(this.cwd, directory ?? path.join("node_modules", ".cache", "wb-arc-tree")));
151
+ let file;
152
+ try {
153
+ ({ file } = await this.#manifest);
154
+ } catch (error) {
155
+ this.#manifest = null;
156
+ throw error;
157
+ }
158
+ const preload = `--import ${new URL("./arc-tree-preload.mjs", import.meta.url).href}`;
159
+ return { [ARC_TREE_MANIFEST_ENV]: file, NODE_OPTIONS: env.NODE_OPTIONS ? `${env.NODE_OPTIONS} ${preload}` : preload };
160
+ }
161
+
162
+ async #writeManifest(parent) {
163
+ await fs.promises.mkdir(parent, { recursive: true });
164
+ const directory = await fs.promises.mkdtemp(path.join(parent, "manifest-"));
165
+ const file = path.join(directory, "manifest.json");
166
+ try {
167
+ await writeArcTreeManifest(file, this.files);
168
+ } catch (error) {
169
+ await fs.promises.rm(directory, { force: true, recursive: true });
170
+ throw error;
171
+ }
172
+ return { directory, file };
173
+ }
174
+
175
+ async dispose() {
176
+ const manifest = this.#manifest;
177
+ this.#manifest = null;
178
+ const written = await manifest?.catch(() => null);
179
+ if (written) await fs.promises.rm(written.directory, { force: true, maxRetries: 5, recursive: true, retryDelay: 50 });
180
+ }
181
+
182
+ async [Symbol.asyncDispose]() {
183
+ await this.dispose();
184
+ }
185
+ }
@@ -1,18 +1,18 @@
1
1
  /*
2
2
  * Exports:
3
- * - WorkbenchBootstrap (default): locates or installs an editable checkout and delegates to it.
3
+ * - WorkbenchBootstrap (default): locates or installs an editable checkout and delegates to it; `run` resolves a delegated exit code.
4
4
  */
5
5
  import fs from "node:fs/promises";
6
6
  import { constants } from "node:fs";
7
7
  import os from "node:os";
8
8
  import path from "node:path";
9
- import { randomUUID } from "node:crypto";
10
9
  import { fileURLToPath } from "node:url";
11
- import { DatabaseSync } from "node:sqlite";
10
+ import WorkbenchInstallationRecord from "./WorkbenchInstallationRecord.mjs";
12
11
  import WorkbenchInstallPrompt from "./WorkbenchInstallPrompt.mjs";
13
- import WorkbenchBootstrapCommand from "./WorkbenchBootstrapCommand.mjs";
12
+ import WorkbenchBootstrapCommand, { WorkbenchChildExitError } from "./WorkbenchBootstrapCommand.mjs";
14
13
  import WorkbenchToolchain from "./WorkbenchToolchain.mjs";
15
14
  import WorkbenchPackageRegistration from "./WorkbenchPackageRegistration.mjs";
15
+ import resolveWorkbenchDataRoot from "./workbench-data-root.mjs";
16
16
 
17
17
  export default class WorkbenchBootstrap {
18
18
  constructor({
@@ -35,7 +35,9 @@ export default class WorkbenchBootstrap {
35
35
  this.toolchain = toolchain;
36
36
  this.registration = registration;
37
37
  this.write = write;
38
- this.registry = path.join(home, ".workbench", "installation.json");
38
+ this.installation = new WorkbenchInstallationRecord({
39
+ dataRoot: resolveWorkbenchDataRoot({ environment, homeDirectory: home, platform }),
40
+ });
39
41
  }
40
42
 
41
43
  async checkoutExists(root) {
@@ -63,26 +65,6 @@ export default class WorkbenchBootstrap {
63
65
  }
64
66
  }
65
67
 
66
- async readInstallation() {
67
- try {
68
- const record = JSON.parse(await fs.readFile(this.registry, "utf8"));
69
- if (record.version !== 1 || typeof record.root !== "string" || !path.isAbsolute(record.root)
70
- || !["cloning", "setup", "ready"].includes(record.phase)) {
71
- throw new Error(`Invalid Workbench installation record: ${this.registry}. Existing files were not changed.`);
72
- }
73
- return record;
74
- } catch (error) {
75
- if (error.code === "ENOENT") return null;
76
- throw error;
77
- }
78
- }
79
-
80
- async writeInstallation(record) {
81
- const candidate = `${this.registry}.${randomUUID()}.tmp`;
82
- await fs.writeFile(candidate, `${JSON.stringify(record, null, 2)}\n`, { flag: "wx", mode: 0o600 });
83
- await fs.rename(candidate, this.registry);
84
- }
85
-
86
68
  async requireTool(command, cwd) {
87
69
  try {
88
70
  await this.commands.run(command, ["--version"], {
@@ -105,7 +87,7 @@ export default class WorkbenchBootstrap {
105
87
  if (await this.checkoutExists(linked)) {
106
88
  return await this.delegate(linked, args);
107
89
  }
108
- const installed = await this.readInstallation();
90
+ const installed = await this.installation.read();
109
91
  if (installed?.phase === "ready") {
110
92
  const state = await this.checkoutState(installed.root);
111
93
  if (state === "usable") return await this.delegate(installed.root, args);
@@ -130,21 +112,10 @@ export default class WorkbenchBootstrap {
130
112
  ? path.join(this.environment.LOCALAPPDATA || path.join(this.home, "AppData", "Local"), "Programs", "inthedark", "wb")
131
113
  : path.join(this.home, ".local", "lib", "inthedark", "wb"));
132
114
  const root = path.resolve(await this.prompt.location("Install location", defaultRoot));
133
- await fs.mkdir(path.dirname(this.registry), { recursive: true });
134
- const lockPath = `${this.registry}.lock.sqlite3`;
135
- const lock = new DatabaseSync(lockPath);
136
- try {
137
- lock.exec("PRAGMA busy_timeout = 0; BEGIN EXCLUSIVE");
138
- } catch (error) {
139
- lock.close();
140
- if (error.errcode === 5 || error.errcode === 6) {
141
- throw new Error("Another Workbench installation is running. Let it finish before trying again.");
142
- }
143
- throw error;
144
- }
115
+ const lock = await this.installation.lock();
145
116
  try {
146
117
  // Another invocation may have finished while this one was accepting input.
147
- const latest = await this.readInstallation();
118
+ const latest = await this.installation.read();
148
119
  if (latest?.phase === "ready") {
149
120
  const state = await this.checkoutState(latest.root);
150
121
  if (state === "usable") return await this.delegate(latest.root, args);
@@ -158,7 +129,7 @@ export default class WorkbenchBootstrap {
158
129
  const resume = latest?.root === root && await this.checkoutExists(root);
159
130
  if (!resume) await this.requireEmptyDestination(root);
160
131
  // Recording before provisioning keeps the chosen location as the next default if anything fails.
161
- await this.writeInstallation({ version: 1, root, phase: resume ? "setup" : "cloning" });
132
+ await this.installation.write({ version: 1, root, phase: resume ? "setup" : "cloning" });
162
133
  await this.registration.prepare();
163
134
  await this.toolchain.ensure();
164
135
  await this.prompt.withHeader(async () => {
@@ -168,15 +139,13 @@ export default class WorkbenchBootstrap {
168
139
  const repository = this.environment.WORKBENCH_INSTALL_REPOSITORY || "https://github.com/ErisInTheDark/workbench.git";
169
140
  await this.commands.run("git", ["clone", "--progress", "--branch", "main", "--single-branch", repository, root]);
170
141
  if (!await this.checkoutExists(root)) throw new Error("Clone did not produce a Workbench checkout.");
171
- await this.writeInstallation({ version: 1, root, phase: "setup" });
142
+ await this.installation.write({ version: 1, root, phase: "setup" });
172
143
  }
173
144
  await this.commands.run(process.execPath, [path.join(root, "scripts", "install.mjs"), "--prepare"], { cwd: root });
174
145
  await this.registration.register(root);
175
146
  });
176
- await this.writeInstallation({ version: 1, root, phase: "ready" });
147
+ await this.installation.write({ version: 1, root, phase: "ready" });
177
148
  } finally {
178
- // Closing the connection also releases the OS lock after normal completion;
179
- // process death releases it without a stale PID or lock-file recovery path.
180
149
  lock.close();
181
150
  }
182
151
  // Persist readiness before optional platform actions: missing native artifacts
@@ -185,8 +154,15 @@ export default class WorkbenchBootstrap {
185
154
  args[0] === "connect" ? "--connect" : "--welcome"], { cwd: root, interactive: true });
186
155
  }
187
156
 
157
+ /** Resolves the checkout CLI's exit code; it already reported its own failure. */
188
158
  async delegate(root, args) {
189
- return await this.commands.run(process.execPath, [path.join(root, "cli", "dispatch.mjs"), ...args], { interactive: true });
159
+ try {
160
+ await this.commands.run(process.execPath, [path.join(root, "cli", "dispatch.mjs"), ...args], { interactive: true });
161
+ return 0;
162
+ } catch (error) {
163
+ if (error instanceof WorkbenchChildExitError) return error.exitCode;
164
+ throw error;
165
+ }
190
166
  }
191
167
 
192
168
  async requireEmptyDestination(root) {
@@ -1,11 +1,33 @@
1
1
  /*
2
2
  * Exports:
3
- * - WorkbenchBootstrapCommand (default): owns bootstrap child commands, Windows executable lookup (including Git Bash), and failures.
3
+ * - WorkbenchBootstrapCommand (default): owns bootstrap child commands, Windows executable lookup (including Git Bash), process-tree cancellation, and failures.
4
+ * - WorkbenchChildExitError: a child exited with a non-zero status after reporting its own failure; delegators propagate `exitCode` silently.
4
5
  */
5
6
  import { spawn } from "node:child_process";
6
7
  import fs from "node:fs/promises";
7
8
  import path from "node:path";
8
9
 
10
+ function stopTree(child) {
11
+ if (child.exitCode !== null || child.signalCode !== null || !child.pid) return;
12
+ if (process.platform === "win32") {
13
+ // A denied or failed tree kill still stops the direct child.
14
+ const killer = spawn("taskkill", ["/PID", String(child.pid), "/T", "/F"], { windowsHide: true, stdio: "ignore" });
15
+ killer.once("error", () => { child.kill(); });
16
+ killer.once("exit", code => { if (code !== 0) child.kill(); });
17
+ return;
18
+ }
19
+ try { process.kill(-child.pid, "SIGTERM"); }
20
+ catch (error) { if (error.code !== "ESRCH") child.kill("SIGTERM"); }
21
+ }
22
+
23
+ export class WorkbenchChildExitError extends Error {
24
+ constructor(command, exitCode) {
25
+ super(`${command} failed with status ${exitCode}.`);
26
+ this.name = "WorkbenchChildExitError";
27
+ this.exitCode = exitCode;
28
+ }
29
+ }
30
+
9
31
  export default class WorkbenchBootstrapCommand {
10
32
  constructor({ output = process.stdout, errorOutput = process.stderr, environment = process.env } = {}) {
11
33
  this.output = output;
@@ -115,19 +137,28 @@ export default class WorkbenchBootstrapCommand {
115
137
  throw new Error(`Workbench needs Git Bash on Windows.${sawLauncher ? " The bash on PATH is WSL's, which cannot run Workbench." : ""} Install Git for Windows, then retry.`);
116
138
  }
117
139
 
140
+ /** Cancelling a non-interactive run stops the child's whole process tree; wrappers like vp otherwise orphan their workers. */
118
141
  async run(command, args, { cwd, signal, onOutput, environment = {}, output = this.output, errorOutput = this.errorOutput, interactive = false } = {}) {
119
142
  signal?.throwIfAborted();
120
143
  const [executable, ...prefix] = await this.resolve(command);
121
144
  signal?.throwIfAborted();
145
+ const tree = Boolean(signal) && !interactive;
122
146
  return await new Promise((resolve, reject) => {
123
147
  const child = spawn(executable, [...prefix, ...args], {
124
148
  cwd,
125
149
  env: this.environmentFor(environment),
126
150
  windowsHide: true,
127
151
  stdio: interactive ? "inherit" : ["ignore", "pipe", "pipe"],
128
- signal,
152
+ // A POSIX process group lets one signal reach every descendant.
153
+ ...(tree ? { detached: process.platform !== "win32" } : { signal }),
129
154
  });
130
155
  let failure;
156
+ let cancelled = false;
157
+ const cancel = () => {
158
+ cancelled = true;
159
+ stopTree(child);
160
+ };
161
+ if (tree) signal.addEventListener("abort", cancel, { once: true });
131
162
  const relay = signal => { child.kill(signal); };
132
163
  const interrupt = () => relay("SIGINT");
133
164
  const terminate = () => relay("SIGTERM");
@@ -157,13 +188,17 @@ export default class WorkbenchBootstrapCommand {
157
188
  process.off("SIGTERM", terminate);
158
189
  process.off("SIGHUP", hangup);
159
190
  }
160
- if (failure?.code === "ENOENT") {
191
+ if (tree) signal.removeEventListener("abort", cancel);
192
+ if (cancelled) {
193
+ reject(Object.assign(new Error(`${command} was cancelled.`, { cause: signal.reason }), { name: "AbortError", code: "ABORT_ERR" }));
194
+ } else if (failure?.code === "ENOENT") {
161
195
  reject(Object.assign(new Error(`Cannot start ${command}: executable not found. Install it and make it available on PATH, then retry.`, { cause: failure }), {
162
196
  code: "ENOENT",
163
197
  }));
164
198
  } else if (failure) reject(failure);
165
199
  else if (code === 0) resolve();
166
- else reject(Object.assign(new Error(`${command} failed with ${exitSignal ? `signal ${exitSignal}` : `status ${code ?? "unknown"}`}.`), { exitCode: code ?? 1 }));
200
+ else if (code !== null) reject(new WorkbenchChildExitError(command, code));
201
+ else reject(Object.assign(new Error(`${command} failed with signal ${exitSignal ?? "unknown"}.`), { exitCode: 1 }));
167
202
  });
168
203
  });
169
204
  }
@@ -0,0 +1,18 @@
1
+ /*
2
+ * Exports:
3
+ * - WorkbenchInstallationPhase/WorkbenchInstallationRecordValue: one recorded installation.
4
+ * - default WorkbenchInstallationRecord: read, write and exclusively lock the data root's installation record.
5
+ */
6
+ export type WorkbenchInstallationPhase = "cloning" | "setup" | "ready";
7
+ export interface WorkbenchInstallationRecordValue {
8
+ version: 1;
9
+ root: string;
10
+ phase: WorkbenchInstallationPhase;
11
+ }
12
+ export default class WorkbenchInstallationRecord {
13
+ constructor(options: { dataRoot: string });
14
+ readonly path: string;
15
+ read(): Promise<WorkbenchInstallationRecordValue | null>;
16
+ write(record: WorkbenchInstallationRecordValue): Promise<void>;
17
+ lock(): Promise<{ close(): void }>;
18
+ }
@@ -0,0 +1,57 @@
1
+ /*
2
+ * Exports:
3
+ * - default WorkbenchInstallationRecord: read, write and exclusively lock the data root's record of which checkout is installed.
4
+ */
5
+ import fs from "node:fs/promises";
6
+ import path from "node:path";
7
+ import { randomUUID } from "node:crypto";
8
+ import { DatabaseSync } from "node:sqlite";
9
+
10
+ const PHASES = ["cloning", "setup", "ready"];
11
+
12
+ export default class WorkbenchInstallationRecord {
13
+ constructor({ dataRoot }) {
14
+ this.path = path.join(dataRoot, "installation", "installation.json");
15
+ }
16
+
17
+ /** The record, or null when none exists; malformed records reject without being changed. */
18
+ async read() {
19
+ try {
20
+ const record = JSON.parse(await fs.readFile(this.path, "utf8"));
21
+ if (record.version !== 1 || typeof record.root !== "string" || !path.isAbsolute(record.root)
22
+ || !PHASES.includes(record.phase)) {
23
+ throw new Error(`Invalid Workbench installation record: ${this.path}. Existing files were not changed.`);
24
+ }
25
+ return record;
26
+ } catch (error) {
27
+ if (error.code === "ENOENT") return null;
28
+ throw error;
29
+ }
30
+ }
31
+
32
+ async write(record) {
33
+ await fs.mkdir(path.dirname(this.path), { recursive: true });
34
+ const candidate = `${this.path}.${randomUUID()}.tmp`;
35
+ await fs.writeFile(candidate, `${JSON.stringify(record, null, 2)}\n`, { flag: "wx", mode: 0o600 });
36
+ await fs.rename(candidate, this.path);
37
+ }
38
+
39
+ /**
40
+ * Holds the installation lock until `close()`. Closing the connection, or process death, releases the OS lock
41
+ * without a stale PID or lock-file recovery path.
42
+ */
43
+ async lock() {
44
+ await fs.mkdir(path.dirname(this.path), { recursive: true });
45
+ const lock = new DatabaseSync(`${this.path}.lock.sqlite3`);
46
+ try {
47
+ lock.exec("PRAGMA busy_timeout = 0; BEGIN EXCLUSIVE");
48
+ } catch (error) {
49
+ lock.close();
50
+ if (error.errcode === 5 || error.errcode === 6) {
51
+ throw new Error("Another Workbench installation is running. Let it finish before trying again.");
52
+ }
53
+ throw error;
54
+ }
55
+ return { close: () => lock.close() };
56
+ }
57
+ }
@@ -0,0 +1,41 @@
1
+ // @ts-check
2
+ /*
3
+ * Exports:
4
+ * - ARC_TREE_MANIFEST_ENV: environment variable naming the manifest Node processes preload.
5
+ * - arcTreePathKey: comparable key for an absolute path on this platform's file system.
6
+ * - writeArcTreeManifest: write held paths and their arc tree bytes for ./arc-tree-preload.mjs.
7
+ * - readArcTreeManifest: read a manifest into path keys mapped to bytes, or null where the arc tree lacks the path.
8
+ */
9
+ import fs from "node:fs";
10
+ import path from "node:path";
11
+
12
+ export const ARC_TREE_MANIFEST_ENV = "WORKBENCH_ARC_OVERLAY_MANIFEST";
13
+ const MANIFEST_VERSION = 1;
14
+
15
+ /** @param {string} target */
16
+ export function arcTreePathKey(target) {
17
+ const resolved = path.resolve(target);
18
+ return process.platform === "win32" || process.platform === "darwin" ? resolved.toLowerCase() : resolved;
19
+ }
20
+
21
+ /** @param {string} file @param {ReadonlyMap<string, Buffer | null>} files absolute paths */
22
+ export async function writeArcTreeManifest(file, files) {
23
+ const encoded = Object.fromEntries([...files].map(([target, content]) => [target, content?.toString("base64") ?? null]));
24
+ await fs.promises.writeFile(file, JSON.stringify({ version: MANIFEST_VERSION, files: encoded }), { flag: "wx" });
25
+ }
26
+
27
+ /**
28
+ * @param {string} file
29
+ * @returns {Map<string, Buffer | null>}
30
+ */
31
+ export function readArcTreeManifest(file) {
32
+ /** @type {{ files?: unknown; version?: unknown }} */
33
+ const parsed = JSON.parse(fs.readFileSync(file, "utf8"));
34
+ if (parsed.version !== MANIFEST_VERSION || !parsed.files || typeof parsed.files !== "object") {
35
+ throw new Error(`Arc tree manifest ${file} is invalid.`);
36
+ }
37
+ return new Map(Object.entries(parsed.files).map(([target, content]) => {
38
+ if (content !== null && typeof content !== "string") throw new Error(`Arc tree manifest ${file} is invalid.`);
39
+ return [arcTreePathKey(target), content === null ? null : Buffer.from(content, "base64")];
40
+ }));
41
+ }
@@ -0,0 +1,160 @@
1
+ // @ts-check
2
+ /*
3
+ * No exports. Node `--import` preload, plain JavaScript so it loads before any TypeScript loader exists: answer reads of
4
+ * other threads' held paths from the arc tree manifest, and make paths the caller's arc tree lacks look absent.
5
+ * Node's module resolver stats files internally, so it still finds hidden files; reading them then fails as missing.
6
+ */
7
+ import fs from "node:fs";
8
+ import { syncBuiltinESMExports } from "node:module";
9
+ import os from "node:os";
10
+ import path from "node:path";
11
+ import { fileURLToPath } from "node:url";
12
+ import { ARC_TREE_MANIFEST_ENV, arcTreePathKey, readArcTreeManifest } from "./arc-tree-manifest.mjs";
13
+
14
+ /** @typedef {(error: NodeJS.ErrnoException | null, value?: unknown) => void} Callback */
15
+ /** @typedef {(...values: unknown[]) => unknown} LooseFunction */
16
+
17
+ /** @param {Map<string, Buffer | null>} overlay */
18
+ function install(overlay) {
19
+ /** @param {unknown} target */
20
+ const keyOf = (target) => {
21
+ if (typeof target === "string") return arcTreePathKey(target);
22
+ if (target instanceof URL) return target.protocol === "file:" ? arcTreePathKey(fileURLToPath(target)) : null;
23
+ if (Buffer.isBuffer(target)) return arcTreePathKey(target.toString());
24
+ return null;
25
+ };
26
+ /**
27
+ * @param {unknown} target
28
+ * @returns {Buffer | null | undefined} undefined when no other thread holds the path
29
+ */
30
+ const held = (target) => {
31
+ const key = keyOf(target);
32
+ return key === null ? undefined : overlay.get(key);
33
+ };
34
+ /** @param {string} syscall @param {unknown} target */
35
+ const missing = (syscall, target) => Object.assign(
36
+ new Error(`ENOENT: no such file or directory, ${syscall} '${String(target)}'`),
37
+ { code: "ENOENT", errno: -os.constants.errno.ENOENT, path: String(target), syscall },
38
+ );
39
+ /** @param {Buffer} content @param {unknown} options */
40
+ const encoded = (content, options) => {
41
+ const encoding = typeof options === "string"
42
+ ? /** @type {BufferEncoding} */ (options)
43
+ : /** @type {{ encoding?: BufferEncoding | null } | undefined} */ (options)?.encoding;
44
+ return encoding ? content.toString(encoding) : Buffer.from(content);
45
+ };
46
+ /** @param {unknown} directory @param {unknown[]} entries */
47
+ const visible = (directory, entries) => entries.filter((entry) => {
48
+ const dirent = entry instanceof fs.Dirent ? entry : null;
49
+ const name = dirent ? dirent.name : entry;
50
+ if (typeof name !== "string") return true;
51
+ const parent = dirent ? dirent.parentPath : directory instanceof URL ? fileURLToPath(directory) : String(directory);
52
+ return overlay.get(arcTreePathKey(path.join(parent, name))) !== null;
53
+ });
54
+ /** @param {unknown[]} args */
55
+ const callbackOf = (args) => /** @type {Callback} */ (args.at(-1));
56
+ /** @param {Callback} callback @param {NodeJS.ErrnoException | null} error @param {unknown} [value] */
57
+ const later = (callback, error, value) => process.nextTick(() => callback(error, value));
58
+
59
+ const { promises } = fs;
60
+ const original = {
61
+ access: /** @type {LooseFunction} */ (fs.access),
62
+ accessSync: fs.accessSync,
63
+ existsSync: fs.existsSync,
64
+ lstat: /** @type {LooseFunction} */ (fs.lstat),
65
+ lstatSync: /** @type {LooseFunction} */ (fs.lstatSync),
66
+ promisesAccess: promises.access,
67
+ promisesLstat: /** @type {LooseFunction} */ (promises.lstat),
68
+ promisesReadFile: /** @type {LooseFunction} */ (promises.readFile),
69
+ promisesReaddir: /** @type {LooseFunction} */ (promises.readdir),
70
+ promisesStat: /** @type {LooseFunction} */ (promises.stat),
71
+ readFile: /** @type {LooseFunction} */ (fs.readFile),
72
+ readFileSync: /** @type {LooseFunction} */ (fs.readFileSync),
73
+ readdir: /** @type {LooseFunction} */ (fs.readdir),
74
+ readdirSync: /** @type {LooseFunction} */ (fs.readdirSync),
75
+ stat: /** @type {LooseFunction} */ (fs.stat),
76
+ statSync: /** @type {LooseFunction} */ (fs.statSync),
77
+ };
78
+ /** @param {string} syscall @param {LooseFunction} sync */
79
+ const statSync = (syscall, sync) => (/** @type {unknown} */ target, /** @type {fs.StatSyncOptions | undefined} */ options) => {
80
+ if (held(target) !== null) return sync.call(fs, target, options);
81
+ if (options?.throwIfNoEntry === false) return undefined;
82
+ throw missing(syscall, target);
83
+ };
84
+ /** @param {string} syscall @param {LooseFunction} callbackStat */
85
+ const statCallback = (syscall, callbackStat) => (/** @type {unknown} */ target, /** @type {unknown[]} */ ...args) => {
86
+ if (held(target) !== null) return callbackStat.call(fs, target, ...args);
87
+ later(callbackOf(args), missing(syscall, target));
88
+ };
89
+ /** @param {string} syscall @param {LooseFunction} promiseStat */
90
+ const statPromise = (syscall, promiseStat) => async (/** @type {unknown} */ target, /** @type {unknown} */ options) => {
91
+ if (held(target) === null) throw missing(syscall, target);
92
+ return await promiseStat.call(promises, target, options);
93
+ };
94
+
95
+ Object.assign(fs, {
96
+ /** @param {unknown} target @param {unknown} [options] */
97
+ readFileSync(target, options) {
98
+ const content = held(target);
99
+ if (content === undefined) return original.readFileSync.call(fs, target, options);
100
+ if (content === null) throw missing("open", target);
101
+ return encoded(content, options);
102
+ },
103
+ /** @param {unknown} target @param {unknown[]} args */
104
+ readFile(target, ...args) {
105
+ const content = held(target);
106
+ if (content === undefined) return original.readFile.call(fs, target, ...args);
107
+ if (content === null) later(callbackOf(args), missing("open", target));
108
+ else later(callbackOf(args), null, encoded(content, args.length > 1 ? args[0] : undefined));
109
+ },
110
+ /** @param {fs.PathLike} target */
111
+ existsSync(target) {
112
+ return held(target) === null ? false : original.existsSync(target);
113
+ },
114
+ /** @param {fs.PathLike} target @param {number} [mode] */
115
+ accessSync(target, mode) {
116
+ if (held(target) === null) throw missing("access", target);
117
+ original.accessSync(target, mode);
118
+ },
119
+ access: statCallback("access", original.access),
120
+ statSync: statSync("stat", original.statSync),
121
+ lstatSync: statSync("lstat", original.lstatSync),
122
+ stat: statCallback("stat", original.stat),
123
+ lstat: statCallback("lstat", original.lstat),
124
+ /** @param {unknown} directory @param {unknown} [options] */
125
+ readdirSync(directory, options) {
126
+ return visible(directory, /** @type {unknown[]} */ (original.readdirSync.call(fs, directory, options)));
127
+ },
128
+ /** @param {unknown} directory @param {unknown[]} args */
129
+ readdir(directory, ...args) {
130
+ const callback = callbackOf(args);
131
+ original.readdir.call(fs, directory, ...args.slice(0, -1), /** @type {Callback} */ ((error, entries) => {
132
+ callback(error, error ? undefined : visible(directory, /** @type {unknown[]} */ (entries ?? [])));
133
+ }));
134
+ },
135
+ });
136
+ Object.assign(promises, {
137
+ /** @param {unknown} target @param {unknown} [options] */
138
+ async readFile(target, options) {
139
+ const content = held(target);
140
+ if (content === undefined) return await original.promisesReadFile.call(promises, target, options);
141
+ if (content === null) throw missing("open", target);
142
+ return encoded(content, options);
143
+ },
144
+ /** @param {fs.PathLike} target @param {number} [mode] */
145
+ async access(target, mode) {
146
+ if (held(target) === null) throw missing("access", target);
147
+ await original.promisesAccess(target, mode);
148
+ },
149
+ stat: statPromise("stat", original.promisesStat),
150
+ lstat: statPromise("lstat", original.promisesLstat),
151
+ /** @param {unknown} directory @param {unknown} [options] */
152
+ async readdir(directory, options) {
153
+ return visible(directory, /** @type {unknown[]} */ (await original.promisesReaddir.call(promises, directory, options)));
154
+ },
155
+ });
156
+ syncBuiltinESMExports();
157
+ }
158
+
159
+ const manifest = process.env[ARC_TREE_MANIFEST_ENV];
160
+ if (manifest) install(readArcTreeManifest(manifest));
package/index.d.ts CHANGED
@@ -1,9 +1,18 @@
1
1
  /*
2
2
  * Exports:
3
- * - WorkbenchConfigOptions: dotenv loading, mutually exclusive missing-value policies, cancellation and warning hooks.
4
- * - WorkbenchConfigOutput: parsed values after store replacement, with the first file read error.
5
- * - config: load dotenv and store values; reject missing required names before writing the environment.
3
+ * - WorkbenchConfigOptions: dotenv mapping, missing-value policies, cancellation and warning hooks.
4
+ * - WorkbenchConfigOutput: mapped values after store replacement and policy checks, with the first file read error.
5
+ * - config: load and map dotenv values; resolve stores and check required names before environment writes.
6
+ * - WorkbenchArcTree/WorkbenchArcTreeOptions/WorkbenchArcTreeRepository/WorkbenchArcTreeNodeEnvironmentOptions: arc tree types.
7
+ * - arcTree: the worktree as the calling agent thread builds it, or the plain filesystem outside agent threads.
6
8
  */
9
+ import type WorkbenchArcTree from "./WorkbenchArcTree.mjs";
10
+ import type { WorkbenchArcTreeOptions } from "./WorkbenchArcTree.mjs";
11
+
12
+ export type { WorkbenchArcTree };
13
+ export type {
14
+ WorkbenchArcTreeNodeEnvironmentOptions, WorkbenchArcTreeOptions, WorkbenchArcTreeRepository,
15
+ } from "./WorkbenchArcTree.mjs";
7
16
  export type WorkbenchConfigOptions = {
8
17
  /** One file or several; earlier files win unless `override` is set. Defaults to `.env`. */
9
18
  path?: string | readonly string[];
@@ -11,6 +20,8 @@ export type WorkbenchConfigOptions = {
11
20
  /** Replace variables already present in `processEnv`, as dotenv does. */
12
21
  override?: boolean;
13
22
  processEnv?: Record<string, string | undefined>;
23
+ /** Map merged parsed values before store resolution and policy checks. Rejection prevents environment writes. */
24
+ map?: (env: Record<string, string>) => Record<string, string> | Promise<Record<string, string>>;
14
25
  /** Aborting kills running store commands and rejects. */
15
26
  signal?: AbortSignal;
16
27
  /** Opt into bounded lookup warnings that never contain command output. Silent by default. */
@@ -36,3 +47,9 @@ export interface WorkbenchConfigOutput {
36
47
  * before any environment writes. Existing environment values win unless override is set.
37
48
  */
38
49
  export function config(options?: WorkbenchConfigOptions): Promise<WorkbenchConfigOutput>;
50
+
51
+ /**
52
+ * Other threads' held paths read at the caller's arc baseline; everything else reads from disk. Outside agent
53
+ * threads, or when the daemon view fails (reported through `warn`), the plain filesystem. Only `signal` aborts reject.
54
+ */
55
+ export function arcTree(options?: WorkbenchArcTreeOptions): Promise<WorkbenchArcTree>;
package/index.mjs CHANGED
@@ -1,11 +1,13 @@
1
1
  /*
2
2
  * Exports:
3
- * - config: load dotenv files, resolve named store references, and enforce an optional missing-variable policy.
3
+ * - config: load and map dotenv values, resolve store references, and enforce an optional missing-variable policy.
4
+ * - arcTree: load the worktree as the calling agent thread builds it, or the plain filesystem without one.
4
5
  */
5
6
  import fs from "node:fs/promises";
6
7
  import path from "node:path";
7
8
  import dotenv from "dotenv";
8
9
  import StoreCommandRunner from "./StoreCommandRunner.mjs";
10
+ import WorkbenchArcTree from "./WorkbenchArcTree.mjs";
9
11
  import {
10
12
  DEFAULT_STORE_DEFINITIONS,
11
13
  expandStoreCommand,
@@ -109,10 +111,13 @@ export async function config({
109
111
  runner = new StoreCommandRunner(),
110
112
  required,
111
113
  optional,
114
+ map,
112
115
  } = {}) {
113
116
  signal?.throwIfAborted();
114
117
  const policy = missingPolicy(required, optional);
115
- const { parsed: raw, error } = await readParsed(Array.isArray(paths) ? paths : [paths], encoding, override);
118
+ const { parsed: input, error } = await readParsed(Array.isArray(paths) ? paths : [paths], encoding, override);
119
+ const raw = map ? await map(input) : input;
120
+ signal?.throwIfAborted();
116
121
  const definitions = raw.WB_STORES?.trim() || processEnv.WB_STORES?.trim() || DEFAULT_STORE_DEFINITIONS;
117
122
  const { parsed, unresolved } = await resolveReferences(raw, definitions, { runner, signal, warn, policy });
118
123
  applyMissingPolicy(parsed, unresolved, policy, processEnv, override);
@@ -122,3 +127,7 @@ export async function config({
122
127
  dotenv.populate(processEnv, values, { override: policy ? true : override });
123
128
  return error ? { parsed, error } : { parsed };
124
129
  }
130
+
131
+ export async function arcTree(options) {
132
+ return await WorkbenchArcTree.load(options);
133
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@inthedark/wb",
3
- "version": "0.0.7",
3
+ "version": "0.0.9",
4
4
  "type": "module",
5
5
  "repository": {
6
6
  "type": "git",
@@ -25,6 +25,8 @@
25
25
  "files": [
26
26
  "wb",
27
27
  "WorkbenchBootstrap.mjs",
28
+ "WorkbenchInstallationRecord.mjs",
29
+ "WorkbenchInstallationRecord.d.mts",
28
30
  "WorkbenchBootstrapCommand.mjs",
29
31
  "WorkbenchToolchain.mjs",
30
32
  "WorkbenchPackageRegistration.mjs",
@@ -37,10 +39,16 @@
37
39
  "index.d.ts",
38
40
  "store-definitions.mjs",
39
41
  "StoreCommandRunner.mjs",
42
+ "WorkbenchArcTree.mjs",
43
+ "WorkbenchArcTree.d.mts",
44
+ "arc-tree-manifest.mjs",
45
+ "arc-tree-preload.mjs",
46
+ "workbench-daemon.mjs",
47
+ "workbench-daemon.d.mts",
40
48
  "wb.schema.json",
41
49
  "README.md"
42
50
  ],
43
51
  "dependencies": {
44
- "dotenv": "^18.0.4"
52
+ "dotenv": "18.0.4"
45
53
  }
46
54
  }
package/wb CHANGED
@@ -4,7 +4,10 @@
4
4
  */
5
5
  import WorkbenchBootstrap from "./WorkbenchBootstrap.mjs";
6
6
 
7
- try { await new WorkbenchBootstrap().run(process.argv.slice(2)); }
7
+ try {
8
+ const exitCode = await new WorkbenchBootstrap().run(process.argv.slice(2));
9
+ if (Number.isInteger(exitCode)) process.exitCode = exitCode;
10
+ }
8
11
  catch (error) {
9
12
  process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`);
10
13
  process.exitCode = Number.isInteger(error?.exitCode) ? error.exitCode : 1;
@@ -0,0 +1,38 @@
1
+ /*
2
+ * Exports:
3
+ * - WorkbenchDaemonEndpoint: validated loopback publication of the running local daemon.
4
+ * - DaemonGitArcIdentity: harness and thread a Git arc request acts as.
5
+ * - DaemonGitArcRequest: one Git arc action body, without identity.
6
+ * - DaemonGitArcRequestOptions: environment, fetch and cancellation boundaries.
7
+ * - daemonGitArcIdentity: the invoking agent thread's Git arc identity, or null outside agent threads.
8
+ * - readDaemonEndpoint: read the bounded, loopback-only local daemon publication, or null when none is published.
9
+ * - requestDaemonGitArc: send one Git arc request as the invoking thread; resolves the raw JSON body for the caller to conform.
10
+ */
11
+ export interface WorkbenchDaemonEndpoint {
12
+ version: 1;
13
+ instanceId: string;
14
+ pid: number;
15
+ origin: string;
16
+ }
17
+
18
+ export interface DaemonGitArcIdentity {
19
+ harness: string;
20
+ threadId: string;
21
+ }
22
+
23
+ export interface DaemonGitArcRequest {
24
+ action: string;
25
+ cwd: string;
26
+ [field: string]: unknown;
27
+ }
28
+
29
+ export interface DaemonGitArcRequestOptions {
30
+ env?: NodeJS.ProcessEnv;
31
+ fetch?: typeof globalThis.fetch;
32
+ signal?: AbortSignal;
33
+ }
34
+
35
+ export function daemonGitArcIdentity(env?: NodeJS.ProcessEnv): DaemonGitArcIdentity | null;
36
+ /** Rejects oversized, malformed or non-loopback publications so they never become a daemon address. */
37
+ export function readDaemonEndpoint(file: string): Promise<WorkbenchDaemonEndpoint | null>;
38
+ export function requestDaemonGitArc(request: DaemonGitArcRequest, options?: DaemonGitArcRequestOptions): Promise<unknown>;
@@ -0,0 +1,76 @@
1
+ /*
2
+ * Exports:
3
+ * - daemonGitArcIdentity: the invoking agent thread's Git arc identity, or null outside agent threads.
4
+ * - readDaemonEndpoint: read the bounded, loopback-only local daemon publication, or null when none is published.
5
+ * - requestDaemonGitArc: send one Git arc request as the invoking thread to the published local daemon.
6
+ */
7
+ import fs from "node:fs/promises";
8
+ import path from "node:path";
9
+ import resolveWorkbenchDataRoot from "./workbench-data-root.mjs";
10
+
11
+ const ENDPOINT_SIZE_LIMIT = 16_384;
12
+ const ENDPOINT_KEYS = ["instanceId", "origin", "pid", "version"];
13
+ const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/iu;
14
+
15
+ export function daemonGitArcIdentity(env = process.env) {
16
+ const threadId = env.WORKBENCH_THREAD_ID || env.CODEX_THREAD_ID;
17
+ return threadId ? { harness: env.WORKBENCH_HARNESS || "codex", threadId } : null;
18
+ }
19
+
20
+ function isLoopbackOrigin(value) {
21
+ if (typeof value !== "string") return false;
22
+ try {
23
+ const url = new URL(value);
24
+ return url.origin === value && url.protocol === "http:" && url.hostname === "127.0.0.1" && Number(url.port) > 0;
25
+ } catch {
26
+ return false;
27
+ }
28
+ }
29
+
30
+ function isEndpoint(value) {
31
+ if (!value || typeof value !== "object" || Array.isArray(value)) return false;
32
+ return Object.keys(value).sort().join() === ENDPOINT_KEYS.join()
33
+ && value.version === 1
34
+ && typeof value.instanceId === "string" && UUID.test(value.instanceId)
35
+ && Number.isInteger(value.pid) && value.pid >= 1
36
+ && isLoopbackOrigin(value.origin);
37
+ }
38
+
39
+ export async function readDaemonEndpoint(file) {
40
+ let handle;
41
+ try {
42
+ handle = await fs.open(file, "r");
43
+ } catch (error) {
44
+ if (error?.code === "ENOENT") return null;
45
+ throw new Error("Local daemon endpoint could not be opened.", { cause: error });
46
+ }
47
+ try {
48
+ const buffer = Buffer.alloc(ENDPOINT_SIZE_LIMIT + 1);
49
+ let size = 0;
50
+ while (size < buffer.length) {
51
+ const { bytesRead } = await handle.read(buffer, size, buffer.length - size, null);
52
+ if (!bytesRead) break;
53
+ size += bytesRead;
54
+ }
55
+ if (size === buffer.length) throw new Error("Local daemon endpoint exceeds its size limit.");
56
+ const parsed = JSON.parse(buffer.toString("utf8", 0, size));
57
+ if (!isEndpoint(parsed)) throw new Error("Local daemon endpoint is invalid.");
58
+ return parsed;
59
+ } finally {
60
+ await handle.close();
61
+ }
62
+ }
63
+
64
+ export async function requestDaemonGitArc(request, { env = process.env, fetch = globalThis.fetch, signal } = {}) {
65
+ const endpoint = await readDaemonEndpoint(path.join(resolveWorkbenchDataRoot({ environment: env }), "daemon", "runtime.json"));
66
+ if (!endpoint) throw new Error("The Workbench daemon has not published an endpoint.");
67
+ const response = await fetch(new URL("/daemon/git-arc", endpoint.origin), {
68
+ method: "POST",
69
+ headers: { "Content-Type": "application/json" },
70
+ body: JSON.stringify({ ...request, ...daemonGitArcIdentity(env) }),
71
+ redirect: "error",
72
+ signal,
73
+ });
74
+ if (!response.ok) throw new Error(`Git arc ${request.action} request failed (HTTP ${response.status}).`);
75
+ return await response.json();
76
+ }