@demicodes/host-local 0.1.0 → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.d.mts CHANGED
@@ -1,5 +1,50 @@
1
- import { Host, HostFileSystem, HostProcess, HostStore } from "@demicodes/shell";
2
-
1
+ import { AgentHarness, AgentServer, AgentServerSessionOptions } from "@demicodes/agent";
2
+ import { resolveDemiHome } from "@demicodes/provider/credentials-pool";
3
+ import { BashEnvironmentOptions, Host, HostFileSystem, HostProcess, HostStore } from "@demicodes/shell";
4
+ import { Provider } from "@demicodes/provider";
5
+ //#region src/command-bridge.d.ts
6
+ /**
7
+ * Dispatch script shared by every generated command-name symlink.
8
+ * Reads the invoked name from the symlink path, applies the stdin grace
9
+ * contract from docs/command-bridge.md, and POSTs /run on the UDS socket.
10
+ */
11
+ declare const COMMAND_BRIDGE_SHIM_SOURCE = "#!/usr/bin/env node\nconst { request } = require('node:http')\nconst { basename } = require('node:path')\n\n// Stdin policy (docs/command-bridge.md): if no byte arrives within the grace\n// window, proceed with empty stdin. Once any byte arrives, clear the timer\n// and read until EOF with no time cap. Late data after empty-stdin dispatch\n// is reported on stderr and not pretended to have been delivered.\nconst STDIN_GRACE_MS = 300\n\nfunction readStdin() {\n if (process.stdin.isTTY) return Promise.resolve('')\n return new Promise((resolve, reject) => {\n const chunks = []\n let resolved = false\n let timedOut = false\n let timer\n const cleanup = () => {\n clearTimeout(timer)\n process.stdin.removeListener('data', onData)\n process.stdin.removeListener('end', onEnd)\n process.stdin.removeListener('error', onError)\n }\n const onData = (chunk) => {\n if (timedOut) {\n process.stderr.write(\n 'command bridge: ' + chunk.length + ' byte(s) of stdin arrived after the ' + STDIN_GRACE_MS +\n 'ms grace period elapsed; the command already ran with empty stdin\\n',\n )\n cleanup()\n return\n }\n clearTimeout(timer)\n chunks.push(chunk)\n }\n const onEnd = () => {\n if (timedOut) {\n cleanup()\n return\n }\n resolved = true\n cleanup()\n resolve(Buffer.concat(chunks).toString('utf8'))\n }\n const onError = (error) => {\n if (resolved || timedOut) return\n resolved = true\n cleanup()\n reject(error)\n }\n timer = setTimeout(() => {\n timedOut = true\n resolved = true\n resolve('')\n }, STDIN_GRACE_MS)\n process.stdin.on('data', onData)\n process.stdin.on('end', onEnd)\n process.stdin.on('error', onError)\n })\n}\n\nfunction postRun(socketPath, body) {\n return new Promise((resolve, reject) => {\n const req = request(\n { socketPath, path: '/run', method: 'POST', headers: { 'content-type': 'application/json' } },\n (res) => {\n const chunks = []\n res.on('data', (chunk) => chunks.push(chunk))\n res.on('end', () => resolve({ statusCode: res.statusCode || 0, body: Buffer.concat(chunks).toString('utf8') }))\n },\n )\n req.on('error', reject)\n req.end(body)\n })\n}\n\nasync function main() {\n const socketPath = process.env.DEMI_COMMAND_BRIDGE_SOCK\n // Shell sessions export DEMI_SESSION_ID as the command scope id (the agent\n // session id for agent-owned shells).\n const commandScopeId = process.env.DEMI_SESSION_ID\n if (!socketPath || !commandScopeId) {\n process.stderr.write('command bridge: DEMI_COMMAND_BRIDGE_SOCK / session id not set in this shell\\n')\n process.exit(1)\n }\n const name = basename(process.argv[1] || '')\n const args = process.argv.slice(2)\n const stdin = await readStdin()\n const body = JSON.stringify({ commandScopeId, name, args, cwd: process.cwd(), stdin })\n\n const response = await postRun(socketPath, body)\n if (response.statusCode !== 200) {\n let message = response.body\n try {\n message = JSON.parse(response.body).error || message\n } catch {}\n process.stderr.write('command bridge: ' + message + '\\n')\n process.exit(1)\n }\n const result = JSON.parse(response.body)\n if (result.stdout) {\n // Binary final streams arrive base64-encoded; write raw bytes so external\n // pipes (demi read a.png | ffmpeg -i - ...) stay byte-clean.\n process.stdout.write(result.stdoutEncoding === 'base64' ? Buffer.from(result.stdout, 'base64') : result.stdout)\n }\n if (result.stderr) process.stderr.write(result.stderr)\n process.exit(result.exitCode)\n}\n\nmain().catch((error) => {\n process.stderr.write('command bridge: ' + (error && error.message ? error.message : String(error)) + '\\n')\n process.exit(1)\n})\n";
12
+ interface CommandBridgeOptions {
13
+ socketPath: string;
14
+ }
15
+ interface CommandBridgeHandle {
16
+ close(): Promise<void>;
17
+ }
18
+ /**
19
+ * Starts the process-wide UDS listener for command-bridge shims.
20
+ * Each POST /run is dispatched to `AgentServer.runCommandLine`.
21
+ */
22
+ declare function startCommandBridge(server: AgentServer, options: CommandBridgeOptions): CommandBridgeHandle;
23
+ //#endregion
24
+ //#region src/command-bridge-shim.d.ts
25
+ interface MaterializeCommandBridgeShimsOptions {
26
+ host: Host;
27
+ agentSessionId: string;
28
+ commandNames: readonly string[];
29
+ shimSource: string;
30
+ /**
31
+ * Absolute Demi state root (`~/.demi` or `$DEMI_HOME` / `stateDir`).
32
+ * Shims always go to `<stateDir>/bridge-bin/<agentSessionId>/`.
33
+ */
34
+ stateDir: string;
35
+ }
36
+ /** Fixed relative layout under stateDir. */
37
+ declare function bridgeBinDirFor(stateDir: string): string;
38
+ /**
39
+ * Writes the command bridge shim directory for one session and returns its
40
+ * resolved absolute path for PATH injection. Idempotent: safe on every open().
41
+ */
42
+ declare function materializeCommandBridgeShims(options: MaterializeCommandBridgeShimsOptions): Promise<string>;
43
+ //#endregion
44
+ //#region src/demi-home.d.ts
45
+ /** Fixed layout: `<stateDir>/bridges/<id>.sock` (short id for macOS AF_UNIX limits). */
46
+ declare function defaultBridgeSocketPath(stateDir: string, serverId?: string): string;
47
+ //#endregion
3
48
  //#region src/local-host.d.ts
4
49
  interface LocalHostOptions {
5
50
  storeRoot?: string;
@@ -12,4 +57,50 @@ declare class LocalHost implements Host {
12
57
  constructor(defaultCwd: string, options?: LocalHostOptions);
13
58
  }
14
59
  //#endregion
15
- export { LocalHost, LocalHostOptions };
60
+ //#region src/local-agent-server.d.ts
61
+ interface CreateLocalAgentServerOptions {
62
+ /** Same LocalHost instance the harness was built with. */
63
+ host: LocalHost;
64
+ agent: AgentHarness<unknown>;
65
+ providers: Provider[];
66
+ shell?: Omit<BashEnvironmentOptions, 'host' | 'commands'>;
67
+ session?: AgentServerSessionOptions;
68
+ /**
69
+ * Command bridge for real OS subprocesses. **Default true** (open box).
70
+ * Pass `false` only to disable.
71
+ */
72
+ commandBridge?: boolean;
73
+ /**
74
+ * Demi state root (default `$DEMI_HOME` or `~/.demi`).
75
+ * Bridge socket and `bridge-bin/` always live under this tree — never under workspace cwd.
76
+ * Layout is fixed: `bridges/*.sock`, `bridge-bin/<sessionId>/`.
77
+ */
78
+ stateDir?: string;
79
+ /** Override UDS path when bridge is enabled (default under `stateDir/bridges/`). */
80
+ commandBridgeSocketPath?: string;
81
+ }
82
+ interface LocalAgentServerHandle {
83
+ server: AgentServer;
84
+ host: LocalHost;
85
+ /** Resolved state root used for bridge artifacts when bridge is enabled. */
86
+ stateDir: string | null;
87
+ /** Stops the bridge listener (if any) then closes the server. */
88
+ close(): Promise<void>;
89
+ }
90
+ /**
91
+ * Open-box local agent assembly: `LocalHost` + `AgentServer` with command
92
+ * bridge **on by default**.
93
+ *
94
+ * Owns the Node-only bridge transport (UDS + PATH shims under `stateDir`).
95
+ * AgentServer only receives a generic `prepareSessionShell` hook and exposes
96
+ * `runCommandLine` — it never sees bin dirs or sockets.
97
+ *
98
+ * Default layout under `stateDir` (`~/.demi` or `$DEMI_HOME`):
99
+ * ```
100
+ * bridges/<id>.sock
101
+ * bridge-bin/<sessionId>/…
102
+ * ```
103
+ */
104
+ declare function createLocalAgentServer(options: CreateLocalAgentServerOptions): LocalAgentServerHandle;
105
+ //#endregion
106
+ export { COMMAND_BRIDGE_SHIM_SOURCE, CommandBridgeHandle, CommandBridgeOptions, CreateLocalAgentServerOptions, LocalAgentServerHandle, LocalHost, LocalHostOptions, MaterializeCommandBridgeShimsOptions, bridgeBinDirFor, createLocalAgentServer, defaultBridgeSocketPath, materializeCommandBridgeShims, resolveDemiHome, startCommandBridge };
package/dist/index.mjs CHANGED
@@ -1,9 +1,273 @@
1
+ import { existsSync, mkdirSync, unlinkSync } from "node:fs";
2
+ import { dirname, isAbsolute, join, relative, resolve } from "node:path";
3
+ import { createServer } from "node:http";
4
+ import { AgentServer, RunCommandLineCommandNotRegisteredError, RunCommandLineSessionNotFoundError, RunCommandLineTimeoutError } from "@demicodes/agent";
5
+ import { encodeUtf8, errorMessage, isFileNotFoundError, parsePortableJson, stringifyPortableJson } from "@demicodes/utils";
6
+ import { createHash, randomUUID } from "node:crypto";
7
+ import { resolveDemiHome } from "@demicodes/provider/credentials-pool";
1
8
  import { spawn } from "node:child_process";
2
- import { createHash } from "node:crypto";
3
9
  import { homedir } from "node:os";
4
- import { dirname, isAbsolute, join, relative, resolve } from "node:path";
5
10
  import { appendFile, chmod, cp, link, lstat, mkdir, readFile, readdir, readlink, realpath, rename, rm, stat, symlink, utimes, writeFile } from "node:fs/promises";
6
- import { isFileNotFoundError } from "@demicodes/utils";
11
+ //#region src/command-bridge.ts
12
+ /**
13
+ * Dispatch script shared by every generated command-name symlink.
14
+ * Reads the invoked name from the symlink path, applies the stdin grace
15
+ * contract from docs/command-bridge.md, and POSTs /run on the UDS socket.
16
+ */
17
+ const COMMAND_BRIDGE_SHIM_SOURCE = `#!/usr/bin/env node
18
+ const { request } = require('node:http')
19
+ const { basename } = require('node:path')
20
+
21
+ // Stdin policy (docs/command-bridge.md): if no byte arrives within the grace
22
+ // window, proceed with empty stdin. Once any byte arrives, clear the timer
23
+ // and read until EOF with no time cap. Late data after empty-stdin dispatch
24
+ // is reported on stderr and not pretended to have been delivered.
25
+ const STDIN_GRACE_MS = 300
26
+
27
+ function readStdin() {
28
+ if (process.stdin.isTTY) return Promise.resolve('')
29
+ return new Promise((resolve, reject) => {
30
+ const chunks = []
31
+ let resolved = false
32
+ let timedOut = false
33
+ let timer
34
+ const cleanup = () => {
35
+ clearTimeout(timer)
36
+ process.stdin.removeListener('data', onData)
37
+ process.stdin.removeListener('end', onEnd)
38
+ process.stdin.removeListener('error', onError)
39
+ }
40
+ const onData = (chunk) => {
41
+ if (timedOut) {
42
+ process.stderr.write(
43
+ 'command bridge: ' + chunk.length + ' byte(s) of stdin arrived after the ' + STDIN_GRACE_MS +
44
+ 'ms grace period elapsed; the command already ran with empty stdin\\n',
45
+ )
46
+ cleanup()
47
+ return
48
+ }
49
+ clearTimeout(timer)
50
+ chunks.push(chunk)
51
+ }
52
+ const onEnd = () => {
53
+ if (timedOut) {
54
+ cleanup()
55
+ return
56
+ }
57
+ resolved = true
58
+ cleanup()
59
+ resolve(Buffer.concat(chunks).toString('utf8'))
60
+ }
61
+ const onError = (error) => {
62
+ if (resolved || timedOut) return
63
+ resolved = true
64
+ cleanup()
65
+ reject(error)
66
+ }
67
+ timer = setTimeout(() => {
68
+ timedOut = true
69
+ resolved = true
70
+ resolve('')
71
+ }, STDIN_GRACE_MS)
72
+ process.stdin.on('data', onData)
73
+ process.stdin.on('end', onEnd)
74
+ process.stdin.on('error', onError)
75
+ })
76
+ }
77
+
78
+ function postRun(socketPath, body) {
79
+ return new Promise((resolve, reject) => {
80
+ const req = request(
81
+ { socketPath, path: '/run', method: 'POST', headers: { 'content-type': 'application/json' } },
82
+ (res) => {
83
+ const chunks = []
84
+ res.on('data', (chunk) => chunks.push(chunk))
85
+ res.on('end', () => resolve({ statusCode: res.statusCode || 0, body: Buffer.concat(chunks).toString('utf8') }))
86
+ },
87
+ )
88
+ req.on('error', reject)
89
+ req.end(body)
90
+ })
91
+ }
92
+
93
+ async function main() {
94
+ const socketPath = process.env.DEMI_COMMAND_BRIDGE_SOCK
95
+ // Shell sessions export DEMI_SESSION_ID as the command scope id (the agent
96
+ // session id for agent-owned shells).
97
+ const commandScopeId = process.env.DEMI_SESSION_ID
98
+ if (!socketPath || !commandScopeId) {
99
+ process.stderr.write('command bridge: DEMI_COMMAND_BRIDGE_SOCK / session id not set in this shell\\n')
100
+ process.exit(1)
101
+ }
102
+ const name = basename(process.argv[1] || '')
103
+ const args = process.argv.slice(2)
104
+ const stdin = await readStdin()
105
+ const body = JSON.stringify({ commandScopeId, name, args, cwd: process.cwd(), stdin })
106
+
107
+ const response = await postRun(socketPath, body)
108
+ if (response.statusCode !== 200) {
109
+ let message = response.body
110
+ try {
111
+ message = JSON.parse(response.body).error || message
112
+ } catch {}
113
+ process.stderr.write('command bridge: ' + message + '\\n')
114
+ process.exit(1)
115
+ }
116
+ const result = JSON.parse(response.body)
117
+ if (result.stdout) {
118
+ // Binary final streams arrive base64-encoded; write raw bytes so external
119
+ // pipes (demi read a.png | ffmpeg -i - ...) stay byte-clean.
120
+ process.stdout.write(result.stdoutEncoding === 'base64' ? Buffer.from(result.stdout, 'base64') : result.stdout)
121
+ }
122
+ if (result.stderr) process.stderr.write(result.stderr)
123
+ process.exit(result.exitCode)
124
+ }
125
+
126
+ main().catch((error) => {
127
+ process.stderr.write('command bridge: ' + (error && error.message ? error.message : String(error)) + '\\n')
128
+ process.exit(1)
129
+ })
130
+ `;
131
+ /**
132
+ * Starts the process-wide UDS listener for command-bridge shims.
133
+ * Each POST /run is dispatched to `AgentServer.runCommandLine`.
134
+ */
135
+ function startCommandBridge(server, options) {
136
+ mkdirSync(dirname(options.socketPath), { recursive: true });
137
+ if (existsSync(options.socketPath)) unlinkSync(options.socketPath);
138
+ const httpServer = createServer((req, res) => {
139
+ handleRun(server, req, res);
140
+ });
141
+ httpServer.listen(options.socketPath);
142
+ return { close: () => closeServer(httpServer, options.socketPath) };
143
+ }
144
+ async function handleRun(server, req, res) {
145
+ if (req.method !== "POST" || req.url !== "/run") {
146
+ sendJson(res, 404, { error: "not found" });
147
+ return;
148
+ }
149
+ let body;
150
+ try {
151
+ body = await readJsonBody(req);
152
+ } catch (error) {
153
+ sendJson(res, 400, { error: `bad request: ${errorMessage(error)}` });
154
+ return;
155
+ }
156
+ if (!isRunRequestBody(body)) {
157
+ sendJson(res, 400, { error: "bad request: expected { commandScopeId, name, args, cwd, stdin }" });
158
+ return;
159
+ }
160
+ const controller = new AbortController();
161
+ let responded = false;
162
+ const onClosedEarly = () => {
163
+ if (!responded) controller.abort();
164
+ };
165
+ req.on("close", onClosedEarly);
166
+ try {
167
+ const result = await server.runCommandLine(body.commandScopeId, body.name, body.args, {
168
+ cwd: body.cwd,
169
+ stdin: body.stdin,
170
+ signal: controller.signal
171
+ });
172
+ responded = true;
173
+ sendJson(res, 200, result);
174
+ } catch (error) {
175
+ responded = true;
176
+ sendJson(res, statusForError(error), { error: errorMessage(error) });
177
+ } finally {
178
+ req.off("close", onClosedEarly);
179
+ }
180
+ }
181
+ function statusForError(error) {
182
+ if (error instanceof RunCommandLineSessionNotFoundError) return 404;
183
+ if (error instanceof RunCommandLineCommandNotRegisteredError) return 404;
184
+ if (error instanceof RunCommandLineTimeoutError) return 504;
185
+ return 500;
186
+ }
187
+ function isRunRequestBody(value) {
188
+ if (value === null || typeof value !== "object") return false;
189
+ const record = value;
190
+ return typeof record.commandScopeId === "string" && typeof record.name === "string" && Array.isArray(record.args) && record.args.every((arg) => typeof arg === "string") && typeof record.cwd === "string" && typeof record.stdin === "string";
191
+ }
192
+ function readJsonBody(req) {
193
+ return new Promise((resolve, reject) => {
194
+ const chunks = [];
195
+ req.on("data", (chunk) => chunks.push(chunk));
196
+ req.on("end", () => {
197
+ try {
198
+ resolve(JSON.parse(Buffer.concat(chunks).toString("utf8")));
199
+ } catch (error) {
200
+ reject(error);
201
+ }
202
+ });
203
+ req.on("error", reject);
204
+ });
205
+ }
206
+ function sendJson(res, status, body) {
207
+ const text = JSON.stringify(body);
208
+ res.writeHead(status, { "content-type": "application/json; charset=utf-8" });
209
+ res.end(text);
210
+ }
211
+ function closeServer(httpServer, socketPath) {
212
+ return new Promise((resolve) => {
213
+ httpServer.close(() => {
214
+ try {
215
+ if (existsSync(socketPath)) unlinkSync(socketPath);
216
+ } catch {}
217
+ resolve();
218
+ });
219
+ });
220
+ }
221
+ //#endregion
222
+ //#region src/command-bridge-shim.ts
223
+ const BRIDGE_BIN = "bridge-bin";
224
+ const DISPATCH_FILE = ".dispatch";
225
+ const DISPATCH_PACKAGE_JSON = "{\"type\":\"commonjs\"}\n";
226
+ /**
227
+ * Rejects an `agentSessionId` that cannot be used as one filesystem path
228
+ * segment. `agentSessionId` is client-supplied and becomes a real path
229
+ * component under Host.fs, so it is validated here rather than trusted.
230
+ */
231
+ function assertPathSafeSessionId(agentSessionId) {
232
+ if (agentSessionId.length === 0 || agentSessionId.includes("\0") || agentSessionId.includes("/") || agentSessionId.includes("\\") || agentSessionId === "." || agentSessionId === "..") throw new Error(`Command bridge: agentSessionId "${agentSessionId}" is not safe to use as a path segment`);
233
+ }
234
+ /** Fixed relative layout under stateDir. */
235
+ function bridgeBinDirFor(stateDir) {
236
+ return `${stateDir.replace(/\/+$/, "")}/${BRIDGE_BIN}`;
237
+ }
238
+ /**
239
+ * Writes the command bridge shim directory for one session and returns its
240
+ * resolved absolute path for PATH injection. Idempotent: safe on every open().
241
+ */
242
+ async function materializeCommandBridgeShims(options) {
243
+ const { host, agentSessionId, commandNames, shimSource, stateDir } = options;
244
+ assertPathSafeSessionId(agentSessionId);
245
+ if (!stateDir || stateDir.includes("\0")) throw new Error("Command bridge: stateDir must be a non-empty absolute path");
246
+ const dir = `${bridgeBinDirFor(stateDir)}/${agentSessionId}`;
247
+ await host.fs.mkdir(dir, { recursive: true });
248
+ await host.fs.writeFile(`${dir}/package.json`, encodeUtf8(DISPATCH_PACKAGE_JSON));
249
+ await host.fs.writeFile(`${dir}/${DISPATCH_FILE}`, encodeUtf8(shimSource));
250
+ await host.fs.chmod(`${dir}/${DISPATCH_FILE}`, 493);
251
+ const wanted = new Set(commandNames);
252
+ const existing = await host.fs.readdir(dir);
253
+ for (const entry of existing) {
254
+ if (entry === DISPATCH_FILE || entry === "package.json" || wanted.has(entry)) continue;
255
+ await host.fs.rm(`${dir}/${entry}`, { force: true });
256
+ }
257
+ for (const name of wanted) {
258
+ const linkPath = `${dir}/${name}`;
259
+ await host.fs.rm(linkPath, { force: true });
260
+ await host.fs.symlink(DISPATCH_FILE, linkPath);
261
+ }
262
+ return host.fs.realpath(dir);
263
+ }
264
+ //#endregion
265
+ //#region src/demi-home.ts
266
+ /** Fixed layout: `<stateDir>/bridges/<id>.sock` (short id for macOS AF_UNIX limits). */
267
+ function defaultBridgeSocketPath(stateDir, serverId = randomUUID().replace(/-/g, "").slice(0, 12)) {
268
+ return join(stateDir, "bridges", `${serverId}.sock`);
269
+ }
270
+ //#endregion
7
271
  //#region src/local-store.ts
8
272
  var LocalHostStore = class {
9
273
  root;
@@ -12,8 +276,7 @@ var LocalHostStore = class {
12
276
  }
13
277
  async readJson(key) {
14
278
  try {
15
- const content = await readFile(this.pathForKey(key), "utf8");
16
- return JSON.parse(content);
279
+ return parsePortableJson(await readFile(this.pathForKey(key), "utf8"));
17
280
  } catch (error) {
18
281
  if (isFileNotFoundError(error)) return null;
19
282
  throw error;
@@ -22,7 +285,7 @@ var LocalHostStore = class {
22
285
  async writeJson(key, value) {
23
286
  const path = this.pathForKey(key);
24
287
  await mkdir(dirname(path), { recursive: true });
25
- await writeFile(path, JSON.stringify(value, null, 2), "utf8");
288
+ await writeFile(path, stringifyPortableJson(value, 2), "utf8");
26
289
  }
27
290
  async delete(key) {
28
291
  await rm(this.pathForKey(key), {
@@ -235,16 +498,16 @@ var LocalHostFileSystem = class {
235
498
  return readdir(target);
236
499
  }
237
500
  async mkdir(path, options) {
238
- await mkdir(this.resolvePath(path, options?.cwd), { recursive: options?.recursive });
501
+ await mkdir(this.resolvePath(path, options?.cwd), { recursive: options?.recursive === true });
239
502
  }
240
503
  async rm(path, options) {
241
504
  await rm(this.resolvePath(path, options?.cwd), {
242
- recursive: options?.recursive,
243
- force: options?.force
505
+ recursive: options?.recursive === true,
506
+ force: options?.force === true
244
507
  });
245
508
  }
246
509
  async cp(path, destination, options) {
247
- await cp(this.resolvePath(path, options?.cwd), this.resolvePath(destination, options?.cwd), { recursive: options?.recursive });
510
+ await cp(this.resolvePath(path, options?.cwd), this.resolvePath(destination, options?.cwd), { recursive: options?.recursive === true });
248
511
  }
249
512
  async mv(path, destination, options) {
250
513
  await rename(this.resolvePath(path, options?.cwd), this.resolvePath(destination, options?.cwd));
@@ -310,4 +573,79 @@ async function* streamBytes(stream) {
310
573
  else yield Buffer.from(String(chunk));
311
574
  }
312
575
  //#endregion
313
- export { LocalHost };
576
+ //#region src/local-agent-server.ts
577
+ /**
578
+ * Open-box local agent assembly: `LocalHost` + `AgentServer` with command
579
+ * bridge **on by default**.
580
+ *
581
+ * Owns the Node-only bridge transport (UDS + PATH shims under `stateDir`).
582
+ * AgentServer only receives a generic `prepareSessionShell` hook and exposes
583
+ * `runCommandLine` — it never sees bin dirs or sockets.
584
+ *
585
+ * Default layout under `stateDir` (`~/.demi` or `$DEMI_HOME`):
586
+ * ```
587
+ * bridges/<id>.sock
588
+ * bridge-bin/<sessionId>/…
589
+ * ```
590
+ */
591
+ function createLocalAgentServer(options) {
592
+ const bridgeEnabled = options.commandBridge !== false;
593
+ const stateDir = bridgeEnabled ? resolveDemiHome(options.stateDir) : null;
594
+ let socketPath = null;
595
+ if (bridgeEnabled && stateDir) {
596
+ socketPath = options.commandBridgeSocketPath ?? defaultBridgeSocketPath(stateDir);
597
+ mkdirSync(dirname(socketPath), { recursive: true });
598
+ }
599
+ const initialEnv = {
600
+ PATH: process.env.PATH ?? "",
601
+ ...options.shell?.initialEnv
602
+ };
603
+ const serverOptions = {
604
+ agent: options.agent,
605
+ providers: options.providers,
606
+ session: options.session,
607
+ shell: {
608
+ ...options.shell,
609
+ initialEnv
610
+ }
611
+ };
612
+ if (bridgeEnabled && socketPath && stateDir) {
613
+ const bridgeStateDir = stateDir;
614
+ const bridgeSocketPath = socketPath;
615
+ serverOptions.prepareSessionShell = async ({ host, agentSessionId, commandNames, shell }) => {
616
+ const shimDir = await materializeCommandBridgeShims({
617
+ host,
618
+ agentSessionId,
619
+ commandNames,
620
+ shimSource: COMMAND_BRIDGE_SHIM_SOURCE,
621
+ stateDir: bridgeStateDir
622
+ });
623
+ const existingPath = shell.initialEnv?.PATH;
624
+ return {
625
+ ...shell,
626
+ initialEnv: {
627
+ ...shell.initialEnv,
628
+ DEMI_COMMAND_BRIDGE_SOCK: bridgeSocketPath,
629
+ PATH: existingPath ? `${shimDir}:${existingPath}` : shimDir
630
+ }
631
+ };
632
+ };
633
+ }
634
+ const server = new AgentServer(serverOptions);
635
+ let bridge = null;
636
+ if (bridgeEnabled && socketPath) bridge = startCommandBridge(server, { socketPath });
637
+ return {
638
+ server,
639
+ host: options.host,
640
+ stateDir,
641
+ async close() {
642
+ if (bridge) {
643
+ await bridge.close();
644
+ bridge = null;
645
+ }
646
+ await server.close();
647
+ }
648
+ };
649
+ }
650
+ //#endregion
651
+ export { COMMAND_BRIDGE_SHIM_SOURCE, LocalHost, bridgeBinDirFor, createLocalAgentServer, defaultBridgeSocketPath, materializeCommandBridgeShims, resolveDemiHome, startCommandBridge };
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@demicodes/host-local",
3
- "description": "Node Host adapter for the Demi shell.",
4
- "version": "0.1.0",
3
+ "description": "Node LocalHost and open-box local AgentServer assembly (command bridge on by default).",
4
+ "version": "0.2.1",
5
5
  "private": false,
6
6
  "type": "module",
7
7
  "exports": {
@@ -12,8 +12,10 @@
12
12
  }
13
13
  },
14
14
  "dependencies": {
15
- "@demicodes/shell": "workspace:*",
16
- "@demicodes/utils": "workspace:*"
15
+ "@demicodes/agent": "^0.2.1",
16
+ "@demicodes/provider": "^0.2.1",
17
+ "@demicodes/shell": "^0.2.1",
18
+ "@demicodes/utils": "^0.2.1"
17
19
  },
18
20
  "license": "Apache-2.0",
19
21
  "main": "./dist/index.mjs",