summaryrefslogtreecommitdiffhomepage
path: root/packages/tool-shell/src/spawn.ts
diff options
context:
space:
mode:
authorAdam Malczewski <[email protected]>2026-06-25 14:06:23 +0900
committerAdam Malczewski <[email protected]>2026-06-25 14:06:23 +0900
commit1ff0eac44cd44751af979c51c746a1774c268e8a (patch)
treebf1c4563595e5b4c23f63e1d5b0782400be7e025 /packages/tool-shell/src/spawn.ts
parent54db4583e66134010375a1fa94256f36034ffdff (diff)
downloaddispatch-1ff0eac44cd44751af979c51c746a1774c268e8a.tar.gz
dispatch-1ff0eac44cd44751af979c51c746a1774c268e8a.zip
feat(ssh): wave 2 — route filesystem/shell tools behind ExecBackend
Wave 2 of transparent SSH support (4 parallel owner-agents on disjoint tool packages). The tools now resolve an ExecBackend per-call from ctx.computerId and call backend.spawn / backend.readFile / etc. instead of node:fs and node:child_process directly — so they are transport-agnostic (local now; remote over SSH later, transparent to the agent). Still LOCAL-ONLY this wave (computerId always undefined -> LocalExecBackend, behavior-identical). - tool-shell: factory takes resolveBackend; execute calls backend.spawn. spawn.ts DELETED (realSpawn was a verbatim duplicate of exec-backend's LocalExecBackend.spawn — logic moved to the sanctioned shared package). manifest dependsOn:[exec-backend]; host.getService at activation. - tool-read-file: readFile/stat/readdir -> backend.* (pure logic untouched; ENOENT .code branches kept). - tool-write-file: exists/stat/writeFile -> backend.* (pure logic untouched). - tool-edit-file: readFile/writeFile -> backend.* + forward-compatible REMOTE diagnostics skip (ctx.computerId set -> skip LSP, return empty — plan §6.1; local path byte-identical to today). LSP lookup stays lazy. - orchestrator: pre-wired @dispatch/exec-backend dep into the 4 tool package.jsons + bun install (build/config, my lane) so isolated verify resolved cleanly; agents added the ../exec-backend tsconfig ref. Verified: tsc -b EXIT 0, biome clean, 1599 vitest pass (was 1592). Refs: notes/ssh-support-plan.md (decisions §0.5/§13). No merge or push.
Diffstat (limited to 'packages/tool-shell/src/spawn.ts')
-rw-r--r--packages/tool-shell/src/spawn.ts87
1 files changed, 0 insertions, 87 deletions
diff --git a/packages/tool-shell/src/spawn.ts b/packages/tool-shell/src/spawn.ts
deleted file mode 100644
index 9b1d7e4..0000000
--- a/packages/tool-shell/src/spawn.ts
+++ /dev/null
@@ -1,87 +0,0 @@
-import { spawn as nodeSpawn } from "node:child_process";
-import type { SpawnResult, SpawnShell } from "./shell.js";
-
-export const realSpawn: SpawnShell = (params): Promise<SpawnResult> => {
- return new Promise<SpawnResult>((resolve) => {
- // detached: true puts the child in its own process group (pgid = child.pid).
- // This lets us kill the entire group (child + any grandchildren that inherit
- // the pipes) via process.kill(-pgid, "SIGKILL") on abort/timeout, so a
- // backgrounded grandchild can't keep the stdio pipes open and stall the
- // promise on child.on("close").
- const child = nodeSpawn("sh", ["-c", params.command], {
- cwd: params.cwd,
- stdio: ["ignore", "pipe", "pipe"],
- detached: true,
- });
-
- let settled = false;
- let timedOut = false;
- let timer: ReturnType<typeof setTimeout> | undefined;
-
- /** Kill the entire child process group (best-effort — group may be gone). */
- const killGroup = () => {
- if (child.pid !== undefined) {
- try {
- process.kill(-child.pid, "SIGKILL");
- } catch {
- // Process group may already be gone — ignore.
- }
- }
- };
-
- /** Remove the abort listener and clear the timeout timer (no leaks). */
- const cleanup = () => {
- if (timer !== undefined) {
- clearTimeout(timer);
- timer = undefined;
- }
- params.signal.removeEventListener("abort", onAbort);
- };
-
- /** Resolve once, then clean up so listeners/timers never leak. */
- const settle = (result: SpawnResult) => {
- if (settled) return;
- settled = true;
- cleanup();
- resolve(result);
- };
-
- const onAbort = () => {
- if (settled) return;
- killGroup();
- // Resolve immediately — do NOT wait for child.on("close"), which may
- // never fire if a grandchild holds the pipes open.
- settle({ exitCode: null, timedOut: false, aborted: true });
- };
- params.signal.addEventListener("abort", onAbort, { once: true });
-
- timer = setTimeout(() => {
- if (settled) return;
- timedOut = true;
- killGroup();
- // Resolve immediately — same reasoning as abort.
- settle({ exitCode: null, timedOut: true, aborted: false });
- }, params.timeout);
-
- child.stdout.on("data", (chunk: Buffer) => {
- params.onOutput(chunk.toString(), "stdout");
- });
-
- child.stderr.on("data", (chunk: Buffer) => {
- params.onOutput(chunk.toString(), "stderr");
- });
-
- // Normal-completion path: wait for "close" so all stdout/stderr is captured.
- // If abort/timeout already settled, this is a no-op (settled === true).
- child.on("close", (code) => {
- settle({ exitCode: code, timedOut, aborted: false });
- });
-
- // Spawn error (e.g. bad cwd, sh not found). Kill the group just in case
- // and resolve — never leave the promise pending.
- child.on("error", () => {
- killGroup();
- settle({ exitCode: 1, timedOut: false, aborted: false });
- });
- });
-};