@co0ontty/wand 4.46.0 → 4.47.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.
Files changed (34) hide show
  1. package/dist/build-info.json +3 -3
  2. package/dist/distribution-manager.d.ts +17 -0
  3. package/dist/distribution-manager.js +80 -17
  4. package/dist/server-update-routes.d.ts +5 -0
  5. package/dist/server-update-routes.js +15 -0
  6. package/dist/server.js +8 -2
  7. package/dist/structured-claude-adapter.d.ts +3 -1
  8. package/dist/structured-claude-adapter.js +48 -93
  9. package/dist/structured-codex-adapter.d.ts +5 -0
  10. package/dist/structured-codex-adapter.js +40 -78
  11. package/dist/structured-exec-host.d.ts +76 -0
  12. package/dist/structured-exec-host.js +117 -0
  13. package/dist/structured-exec-pump.d.ts +37 -0
  14. package/dist/structured-exec-pump.js +127 -0
  15. package/dist/structured-grok-adapter.d.ts +3 -1
  16. package/dist/structured-grok-adapter.js +28 -67
  17. package/dist/structured-opencode-adapter.d.ts +3 -1
  18. package/dist/structured-opencode-adapter.js +37 -79
  19. package/dist/structured-pi-adapter.d.ts +3 -1
  20. package/dist/structured-pi-adapter.js +27 -39
  21. package/dist/structured-qoder-adapter.d.ts +3 -1
  22. package/dist/structured-qoder-adapter.js +49 -87
  23. package/dist/structured-session-manager.d.ts +9 -1
  24. package/dist/structured-session-manager.js +399 -12
  25. package/dist/terminal-daemon-client.d.ts +15 -1
  26. package/dist/terminal-daemon-client.js +261 -0
  27. package/dist/terminal-daemon-protocol.d.ts +10 -3
  28. package/dist/terminal-daemon-protocol.js +1 -1
  29. package/dist/terminal-daemon-server.js +178 -0
  30. package/dist/web-ui/content/scripts.js +57 -57
  31. package/dist/web-ui/content/styles.css +1 -1
  32. package/dist/web-ui/embedded-assets.d.ts +1 -1
  33. package/dist/web-ui/embedded-assets.js +3 -3
  34. package/package.json +1 -1
@@ -4,6 +4,7 @@ import { spawn } from "node:child_process";
4
4
  import process from "node:process";
5
5
  import { TERMINAL_DAEMON_PROTOCOL_VERSION, terminalDaemonPaths, } from "./terminal-daemon-protocol.js";
6
6
  import { appendTerminalChunkWindow, InProcessTerminalHost, } from "./terminal-host.js";
7
+ import { STRUCTURED_RUN_LOG_MAX_CHARS, } from "./structured-exec-host.js";
7
8
  import { appendWindow, PTY_OUTPUT_MAX_SIZE } from "./pty-text-utils.js";
8
9
  const TERMINAL_DAEMON_RECONNECT_INITIAL_MS = 500;
9
10
  const TERMINAL_DAEMON_RECONNECT_MAX_MS = 10_000;
@@ -97,6 +98,125 @@ class RemoteTerminalProcess {
97
98
  listener(event);
98
99
  }
99
100
  }
101
+ /** Client-side handle for a daemon-owned structured CLI run. */
102
+ class RemoteStructuredProcess {
103
+ runId;
104
+ incarnationId;
105
+ pid;
106
+ client;
107
+ streamListeners = new Set();
108
+ exitListeners = new Set();
109
+ paused = false;
110
+ pausedEvents = [];
111
+ undeliveredEvents = [];
112
+ pendingExit = null;
113
+ lastStdoutSeq = 0;
114
+ lastStderrSeq = 0;
115
+ constructor(runId, incarnationId, pid, client) {
116
+ this.runId = runId;
117
+ this.incarnationId = incarnationId;
118
+ this.pid = pid;
119
+ this.client = client;
120
+ }
121
+ interrupt(signal) {
122
+ void this.client.request("structuredKill", { runId: this.runId, signal }).catch((error) => this.client.reportOperationError(this.runId, error));
123
+ }
124
+ onStream(listener) {
125
+ this.streamListeners.add(listener);
126
+ if (this.undeliveredEvents.length > 0) {
127
+ const pending = this.undeliveredEvents;
128
+ this.undeliveredEvents = [];
129
+ queueMicrotask(() => {
130
+ if (!this.streamListeners.has(listener))
131
+ return;
132
+ for (const event of pending)
133
+ listener(event);
134
+ });
135
+ }
136
+ return { dispose: () => this.streamListeners.delete(listener) };
137
+ }
138
+ onExit(listener) {
139
+ this.exitListeners.add(listener);
140
+ const pending = this.pendingExit;
141
+ if (pending) {
142
+ queueMicrotask(() => {
143
+ if (this.exitListeners.has(listener))
144
+ listener(pending);
145
+ });
146
+ }
147
+ return { dispose: () => this.exitListeners.delete(listener) };
148
+ }
149
+ acceptStream(event) {
150
+ this.deliveredChars[event.stream] += event.data.length;
151
+ if (event.stream === "stdout")
152
+ this.lastStdoutSeq = Math.max(this.lastStdoutSeq, event.seq);
153
+ else
154
+ this.lastStderrSeq = Math.max(this.lastStderrSeq, event.seq);
155
+ if (this.paused) {
156
+ this.pausedEvents.push(event);
157
+ return;
158
+ }
159
+ if (this.streamListeners.size === 0) {
160
+ this.undeliveredEvents.push(event);
161
+ if (this.undeliveredEvents.length > 4096)
162
+ this.undeliveredEvents.shift();
163
+ return;
164
+ }
165
+ this.emitStream(event);
166
+ }
167
+ acceptExit(event) {
168
+ this.pendingExit = event;
169
+ for (const listener of Array.from(this.exitListeners))
170
+ listener(event);
171
+ }
172
+ /** Gap-free catch-up from the daemon's full replay logs after a reconnect. */
173
+ replayFromLogs(state) {
174
+ const stdoutDelta = alignedDelta(state.stdoutLog, state.stdoutTruncated, this.deliveredChars.stdout);
175
+ if (stdoutDelta === null) {
176
+ process.stderr.write(`[wand] structured run ${this.runId} stdout log no longer aligns with delivered output; skipping catch-up\n`);
177
+ }
178
+ else if (stdoutDelta) {
179
+ this.acceptStream({ stream: "stdout", data: stdoutDelta, seq: ++this.syntheticSeq });
180
+ }
181
+ const stderrDelta = alignedDelta(state.stderrLog, state.stderrTruncated, this.deliveredChars.stderr);
182
+ if (stderrDelta === null) {
183
+ process.stderr.write(`[wand] structured run ${this.runId} stderr log no longer aligns with delivered output; skipping catch-up\n`);
184
+ }
185
+ else if (stderrDelta) {
186
+ this.acceptStream({ stream: "stderr", data: stderrDelta, seq: ++this.syntheticSeq });
187
+ }
188
+ }
189
+ pause() {
190
+ this.paused = true;
191
+ }
192
+ resume() {
193
+ if (!this.paused)
194
+ return;
195
+ this.paused = false;
196
+ const pending = this.pausedEvents;
197
+ this.pausedEvents = [];
198
+ for (const event of pending)
199
+ this.emitStream(event);
200
+ }
201
+ emitStream(event) {
202
+ for (const listener of Array.from(this.streamListeners))
203
+ listener(event);
204
+ }
205
+ deliveredChars = { stdout: 0, stderr: 0 };
206
+ syntheticSeq = 1_000_000_000_000;
207
+ }
208
+ /**
209
+ * Deliver the suffix of the daemon's replay log beyond what this handle has
210
+ * already seen. Returns null when alignment is impossible (head-truncated log
211
+ * or watermark beyond the log), in which case callers skip catch-up.
212
+ */
213
+ function alignedDelta(log, truncated, deliveredChars) {
214
+ if (truncated)
215
+ return null;
216
+ if (log.length < deliveredChars)
217
+ return null;
218
+ return log.slice(deliveredChars);
219
+ }
100
220
  export class TerminalDaemonClient {
101
221
  socketPath;
102
222
  token;
@@ -108,6 +228,9 @@ export class TerminalDaemonClient {
108
228
  inventory = new Map();
109
229
  handles = new Map();
110
230
  pendingEvents = new Map();
231
+ structuredInventory = new Map();
232
+ structuredHandles = new Map();
233
+ pendingStructuredEvents = new Map();
111
234
  disposed = false;
112
235
  reconnectTimer = null;
113
236
  reconnectDelayMs = TERMINAL_DAEMON_RECONNECT_INITIAL_MS;
@@ -151,6 +274,7 @@ export class TerminalDaemonClient {
151
274
  this.reconcileAfterReconnect(previous);
152
275
  process.stderr.write("[wand] Reconnected to terminal daemon; reconciled PTY inventory.\n");
153
276
  }
277
+ await this.refreshStructuredAfterReconnect();
154
278
  }
155
279
  catch (error) {
156
280
  if (!this.disposed) {
@@ -183,6 +307,91 @@ export class TerminalDaemonClient {
183
307
  this.pendingEvents.delete(sessionId);
184
308
  void this.request("forget", { sessionId }).catch((error) => this.reportOperationError(sessionId, error));
185
309
  }
310
+ // ---------------------------------------------------------------------------
311
+ // StructuredExecHost: daemon-owned CLI runs that survive web restarts.
312
+ // ---------------------------------------------------------------------------
313
+ async spawnStructured(request) {
314
+ const existingHandle = this.structuredHandles.get(request.runId);
315
+ const existing = this.structuredInventory.get(request.runId);
316
+ if (existingHandle && existing?.status === "running" && existing.incarnationId === existingHandle.incarnationId) {
317
+ return existingHandle;
318
+ }
319
+ this.structuredHandles.delete(request.runId);
320
+ const state = await this.request("structuredSpawn", { ...request });
321
+ return this.handleFromState(state, existingHandle ?? null);
322
+ }
323
+ async attachRun(runId) {
324
+ const payload = await this.request("structuredAttach", { runId });
325
+ if (!payload.state)
326
+ return null;
327
+ this.structuredInventory.set(runId, payload.state);
328
+ return payload.state;
329
+ }
330
+ async adoptRun(runId) {
331
+ const state = await this.attachRun(runId);
332
+ if (!state || state.status !== "running")
333
+ return null;
334
+ const existing = this.structuredHandles.get(runId);
335
+ if (existing && existing.incarnationId === state.incarnationId)
336
+ return existing;
337
+ return this.handleFromState(state, existing ?? null);
338
+ }
339
+ async listRuns() {
340
+ const result = await this.request("structuredList");
341
+ // Defensive against protocol skew with older daemons that answer unknown
342
+ // methods with a generic { ok: true }.
343
+ return Array.isArray(result) ? result : [];
344
+ }
345
+ forgetRun(runId) {
346
+ this.structuredInventory.delete(runId);
347
+ this.structuredHandles.delete(runId);
348
+ this.pendingStructuredEvents.delete(runId);
349
+ void this.request("structuredForget", { runId }).catch((error) => this.reportOperationError(runId, error));
350
+ }
351
+ /** Refresh the structured inventory and catch live handles up after a reconnect. */
352
+ async refreshStructuredAfterReconnect() {
353
+ const previous = new Map(this.structuredInventory);
354
+ let runs;
355
+ try {
356
+ runs = await this.listRuns();
357
+ }
358
+ catch {
359
+ return; // socket died again mid-refresh; next reconnect retries
360
+ }
361
+ this.structuredInventory.clear();
362
+ for (const run of runs)
363
+ this.structuredInventory.set(run.runId, run);
364
+ for (const [runId, oldState] of previous) {
365
+ const current = this.structuredInventory.get(runId);
366
+ const handle = this.structuredHandles.get(runId);
367
+ if (!handle || handle.incarnationId !== oldState.incarnationId)
368
+ continue;
369
+ if (current && current.status === "running") {
370
+ handle.replayFromLogs(current);
371
+ continue;
372
+ }
373
+ // Run exited while we were disconnected.
374
+ this.structuredHandles.delete(runId);
375
+ handle.acceptExit({ exitCode: current?.exitCode ?? -1, signal: current?.signal ?? null });
376
+ }
377
+ }
378
+ handleFromState(state, reuse) {
379
+ this.structuredInventory.set(state.runId, state);
380
+ if (state.status !== "running") {
381
+ this.structuredHandles.delete(state.runId);
382
+ return reuse ?? new RemoteStructuredProcess(state.runId, state.incarnationId, state.pid, this);
383
+ }
384
+ let process = this.structuredHandles.get(state.runId) ?? null;
385
+ if (!process || process.incarnationId !== state.incarnationId) {
386
+ process = new RemoteStructuredProcess(state.runId, state.incarnationId, state.pid, this);
387
+ this.structuredHandles.set(state.runId, process);
388
+ const pending = this.pendingStructuredEvents.get(state.runId) ?? [];
389
+ this.pendingStructuredEvents.delete(state.runId);
390
+ for (const event of pending)
391
+ this.routeStructuredEvent(event);
392
+ }
393
+ return process;
394
+ }
186
395
  disconnect() {
187
396
  this.disposed = true;
188
397
  if (this.reconnectTimer) {
@@ -196,6 +405,8 @@ export class TerminalDaemonClient {
196
405
  this.rejectPending(new Error("Terminal daemon client disposed"));
197
406
  this.handles.clear();
198
407
  this.pendingEvents.clear();
408
+ this.structuredHandles.clear();
409
+ this.pendingStructuredEvents.clear();
199
410
  }
200
411
  /**
201
412
  * Socket teardown path. Without a reconnect, a daemon restart would leave
@@ -355,6 +566,10 @@ export class TerminalDaemonClient {
355
566
  }
356
567
  }
357
568
  routeEvent(event) {
569
+ if (event.event === "sdata" || event.event === "sexit") {
570
+ this.routeStructuredEvent(event);
571
+ return;
572
+ }
358
573
  const state = this.inventory.get(event.sessionId);
359
574
  if (state && state.incarnationId === event.incarnationId) {
360
575
  if (event.event === "data" && typeof event.data === "string" && typeof event.seq === "number") {
@@ -382,6 +597,52 @@ export class TerminalDaemonClient {
382
597
  this.handles.delete(event.sessionId);
383
598
  }
384
599
  }
600
+ /** Structured run events update the inventory and feed live handles. */
601
+ routeStructuredEvent(event) {
602
+ const state = this.structuredInventory.get(event.sessionId);
603
+ if (state && state.incarnationId === event.incarnationId) {
604
+ if (event.event === "sdata" && typeof event.data === "string" && typeof event.seq === "number") {
605
+ const stream = event.stream === "stderr" ? "stderr" : "stdout";
606
+ if (stream === "stdout")
607
+ state.stdoutSeq = Math.max(state.stdoutSeq, event.seq);
608
+ else
609
+ state.stderrSeq = Math.max(state.stderrSeq, event.seq);
610
+ if (stream === "stdout") {
611
+ state.stdoutLog += event.data;
612
+ if (state.stdoutLog.length > STRUCTURED_RUN_LOG_MAX_CHARS) {
613
+ state.stdoutLog = state.stdoutLog.slice(-STRUCTURED_RUN_LOG_MAX_CHARS);
614
+ state.stdoutTruncated = true;
615
+ }
616
+ }
617
+ else {
618
+ state.stderrLog += event.data;
619
+ if (state.stderrLog.length > STRUCTURED_RUN_LOG_MAX_CHARS) {
620
+ state.stderrLog = state.stderrLog.slice(-STRUCTURED_RUN_LOG_MAX_CHARS);
621
+ state.stderrTruncated = true;
622
+ }
623
+ }
624
+ }
625
+ else if (event.event === "sexit") {
626
+ state.status = "exited";
627
+ state.exitCode = event.exitCode ?? null;
628
+ state.signal = event.signal ?? null;
629
+ }
630
+ }
631
+ const handle = this.structuredHandles.get(event.sessionId);
632
+ if (!handle || handle.incarnationId !== event.incarnationId) {
633
+ const pending = this.pendingStructuredEvents.get(event.sessionId) ?? [];
634
+ pending.push(event);
635
+ this.pendingStructuredEvents.set(event.sessionId, pending.slice(-2048));
636
+ return;
637
+ }
638
+ if (event.event === "sdata" && typeof event.data === "string" && typeof event.seq === "number") {
639
+ handle.acceptStream({ stream: event.stream === "stderr" ? "stderr" : "stdout", data: event.data, seq: event.seq });
640
+ }
641
+ else if (event.event === "sexit") {
642
+ handle.acceptExit({ exitCode: event.exitCode ?? null, signal: event.signal ?? null });
643
+ this.structuredHandles.delete(event.sessionId);
644
+ }
645
+ }
385
646
  rejectPending(error) {
386
647
  for (const pending of this.pending.values()) {
387
648
  clearTimeout(pending.timer);
@@ -1,5 +1,6 @@
1
+ import type { StructuredRunState, StructuredSpawnRequest } from "./structured-exec-host.js";
1
2
  import type { TerminalSessionState, TerminalSpawnRequest } from "./terminal-host.js";
2
- export declare const TERMINAL_DAEMON_PROTOCOL_VERSION = 1;
3
+ export declare const TERMINAL_DAEMON_PROTOCOL_VERSION = 2;
3
4
  export interface TerminalDaemonPaths {
4
5
  socketPath: string;
5
6
  tokenPath: string;
@@ -10,7 +11,7 @@ export type TerminalDaemonRequest = {
10
11
  id: number;
11
12
  token: string;
12
13
  protocolVersion: number;
13
- method: "hello" | "list" | "createOrAttach" | "write" | "resize" | "kill" | "forget";
14
+ method: "hello" | "list" | "createOrAttach" | "write" | "resize" | "kill" | "forget" | "structuredSpawn" | "structuredAttach" | "structuredList" | "structuredKill" | "structuredForget";
14
15
  params?: Record<string, unknown>;
15
16
  };
16
17
  export type TerminalDaemonResponse = {
@@ -26,14 +27,20 @@ export type TerminalDaemonResponse = {
26
27
  };
27
28
  export type TerminalDaemonEvent = {
28
29
  kind: "event";
29
- event: "data" | "exit";
30
+ event: "data" | "exit" | "sdata" | "sexit";
30
31
  sessionId: string;
31
32
  incarnationId: string;
32
33
  data?: string;
33
34
  seq?: number;
34
35
  exitCode?: number;
35
36
  signal?: number;
37
+ stream?: "stdout" | "stderr";
36
38
  };
39
+ export interface TerminalDaemonStructuredAttachPayload {
40
+ state: StructuredRunState | null;
41
+ }
42
+ export interface TerminalDaemonStructuredSpawnParams extends StructuredSpawnRequest {
43
+ }
37
44
  export interface TerminalDaemonCreateParams extends TerminalSpawnRequest {
38
45
  afterSeq?: number;
39
46
  }
@@ -1,7 +1,7 @@
1
1
  import { createHash } from "node:crypto";
2
2
  import os from "node:os";
3
3
  import path from "node:path";
4
- export const TERMINAL_DAEMON_PROTOCOL_VERSION = 1;
4
+ export const TERMINAL_DAEMON_PROTOCOL_VERSION = 2;
5
5
  export function terminalDaemonPaths(configPath) {
6
6
  const resolved = path.resolve(configPath);
7
7
  const suffix = createHash("sha256").update(resolved).digest("hex").slice(0, 12);
@@ -1,14 +1,17 @@
1
1
  import { randomBytes, randomUUID } from "node:crypto";
2
+ import { spawn as nodeSpawn } from "node:child_process";
2
3
  import { chmodSync, existsSync, mkdirSync, readFileSync, unlinkSync, writeFileSync } from "node:fs";
3
4
  import net from "node:net";
4
5
  import path from "node:path";
5
6
  import pty from "node-pty";
6
7
  import { ensureNodePtyHelperExecutable } from "./ensure-node-pty-helper.js";
8
+ import { STRUCTURED_RUN_LOG_MAX_CHARS, } from "./structured-exec-host.js";
7
9
  import { TERMINAL_DAEMON_PROTOCOL_VERSION, terminalDaemonPaths, } from "./terminal-daemon-protocol.js";
8
10
  import { appendTerminalChunkWindow, } from "./terminal-host.js";
9
11
  import { PtyTerminalState } from "./pty-terminal-state.js";
10
12
  import { PtyCliExitMarker } from "./pty-shell-launch.js";
11
13
  import { appendWindow, PTY_OUTPUT_MAX_SIZE } from "./pty-text-utils.js";
14
+ const MAX_STRUCTURED_RUNS = 256;
12
15
  const MAX_REQUEST_BUFFER = 4 * 1024 * 1024;
13
16
  export async function runTerminalDaemon(configPath) {
14
17
  const paths = terminalDaemonPaths(configPath);
@@ -34,6 +37,7 @@ export async function runTerminalDaemon(configPath) {
34
37
  const token = randomBytes(32).toString("hex");
35
38
  const sessions = new Map();
36
39
  const clients = new Set();
40
+ const structuredRuns = new Map();
37
41
  const send = (socket, message) => {
38
42
  if (!socket.destroyed && socket.writable)
39
43
  socket.write(`${JSON.stringify(message)}\n`);
@@ -137,6 +141,142 @@ export async function runTerminalDaemon(configPath) {
137
141
  }
138
142
  session.terminalState.dispose();
139
143
  };
144
+ // ---------------------------------------------------------------------------
145
+ // Structured CLI runs: plain child_process ownership with full replay logs.
146
+ // ---------------------------------------------------------------------------
147
+ const appendRunLog = (run, stream, data) => {
148
+ const seq = stream === "stdout" ? ++run.stdoutSeq : ++run.stderrSeq;
149
+ if (stream === "stdout") {
150
+ run.stdoutLog += data;
151
+ if (run.stdoutLog.length > STRUCTURED_RUN_LOG_MAX_CHARS) {
152
+ run.stdoutLog = run.stdoutLog.slice(-STRUCTURED_RUN_LOG_MAX_CHARS);
153
+ run.stdoutTruncated = true;
154
+ }
155
+ }
156
+ else {
157
+ run.stderrLog += data;
158
+ if (run.stderrLog.length > STRUCTURED_RUN_LOG_MAX_CHARS) {
159
+ run.stderrLog = run.stderrLog.slice(-STRUCTURED_RUN_LOG_MAX_CHARS);
160
+ run.stderrTruncated = true;
161
+ }
162
+ }
163
+ return seq;
164
+ };
165
+ const serializeStructuredRun = (run) => ({
166
+ runId: run.request.runId,
167
+ incarnationId: run.incarnationId,
168
+ pid: run.pid,
169
+ status: run.status,
170
+ exitCode: run.exitCode,
171
+ signal: run.signal,
172
+ stdoutSeq: run.stdoutSeq,
173
+ stderrSeq: run.stderrSeq,
174
+ stdoutLog: run.stdoutLog,
175
+ stderrLog: run.stderrLog,
176
+ stdoutTruncated: run.stdoutTruncated,
177
+ stderrTruncated: run.stderrTruncated,
178
+ });
179
+ /** Evict oldest exited records once the map outgrows its bound. */
180
+ const evictStaleStructuredRuns = () => {
181
+ if (structuredRuns.size <= MAX_STRUCTURED_RUNS)
182
+ return;
183
+ for (const [runId, run] of structuredRuns) {
184
+ if (structuredRuns.size <= MAX_STRUCTURED_RUNS)
185
+ break;
186
+ if (run.status === "exited")
187
+ structuredRuns.delete(runId);
188
+ }
189
+ };
190
+ const structuredSpawn = (request) => {
191
+ const existing = structuredRuns.get(request.runId);
192
+ if (existing && existing.status === "running")
193
+ return serializeStructuredRun(existing);
194
+ const wantsStdin = typeof request.stdinData === "string";
195
+ const child = nodeSpawn(request.file, request.args, {
196
+ cwd: request.cwd,
197
+ env: request.env,
198
+ stdio: wantsStdin ? ["pipe", "pipe", "pipe"] : ["ignore", "pipe", "pipe"],
199
+ });
200
+ if (wantsStdin)
201
+ child.stdin?.end(request.stdinData);
202
+ const run = {
203
+ request,
204
+ incarnationId: randomUUID(),
205
+ child,
206
+ pid: child.pid ?? -1,
207
+ status: "running",
208
+ exitCode: null,
209
+ signal: null,
210
+ stdoutSeq: 0,
211
+ stderrSeq: 0,
212
+ stdoutLog: "",
213
+ stderrLog: "",
214
+ stdoutTruncated: false,
215
+ stderrTruncated: false,
216
+ };
217
+ structuredRuns.set(request.runId, run);
218
+ evictStaleStructuredRuns();
219
+ child.stdout?.on("data", (chunk) => {
220
+ const text = chunk.toString();
221
+ const seq = appendRunLog(run, "stdout", text);
222
+ broadcast({
223
+ kind: "event",
224
+ event: "sdata",
225
+ sessionId: run.request.runId,
226
+ incarnationId: run.incarnationId,
227
+ stream: "stdout",
228
+ data: text,
229
+ seq,
230
+ });
231
+ });
232
+ child.stderr?.on("data", (chunk) => {
233
+ const text = chunk.toString();
234
+ const seq = appendRunLog(run, "stderr", text);
235
+ broadcast({
236
+ kind: "event",
237
+ event: "sdata",
238
+ sessionId: run.request.runId,
239
+ incarnationId: run.incarnationId,
240
+ stream: "stderr",
241
+ data: text,
242
+ seq,
243
+ });
244
+ });
245
+ child.on("error", () => {
246
+ // Spawn failures surface via close with no exit code; keep the record so
247
+ // attach still reports a terminal state to late adopters.
248
+ });
249
+ child.on("close", (code, signalName) => {
250
+ if (structuredRuns.get(run.request.runId) !== run || run.child !== child)
251
+ return;
252
+ run.child = null;
253
+ run.status = "exited";
254
+ run.exitCode = code;
255
+ run.signal = signalName === null || signalName === undefined ? null : Number(signalName) || signalNumberFromName(signalName);
256
+ broadcast({
257
+ kind: "event",
258
+ event: "sexit",
259
+ sessionId: run.request.runId,
260
+ incarnationId: run.incarnationId,
261
+ exitCode: run.exitCode ?? undefined,
262
+ signal: run.signal ?? undefined,
263
+ });
264
+ });
265
+ return serializeStructuredRun(run);
266
+ };
267
+ const structuredForget = (runId) => {
268
+ const run = structuredRuns.get(runId);
269
+ if (!run)
270
+ return;
271
+ structuredRuns.delete(runId);
272
+ if (run.child) {
273
+ try {
274
+ run.child.kill("SIGTERM");
275
+ }
276
+ catch { /* best-effort explicit deletion */ }
277
+ run.child = null;
278
+ }
279
+ };
140
280
  const handleRequest = (client, request) => {
141
281
  if (request.token !== token) {
142
282
  send(client.socket, { kind: "response", id: request.id, ok: false, error: "Unauthorized" });
@@ -200,6 +340,33 @@ export async function runTerminalDaemon(configPath) {
200
340
  forget(String(params.sessionId ?? ""));
201
341
  result = { ok: true };
202
342
  break;
343
+ case "structuredSpawn":
344
+ result = structuredSpawn(params);
345
+ break;
346
+ case "structuredAttach": {
347
+ const run = structuredRuns.get(String(params.runId ?? ""));
348
+ result = { state: run ? serializeStructuredRun(run) : null };
349
+ break;
350
+ }
351
+ case "structuredList":
352
+ result = Array.from(structuredRuns.values(), serializeStructuredRun);
353
+ break;
354
+ case "structuredKill": {
355
+ const run = structuredRuns.get(String(params.runId ?? ""));
356
+ const signal = typeof params.signal === "string" ? params.signal : "SIGTERM";
357
+ if (run?.child) {
358
+ try {
359
+ run.child.kill(signal);
360
+ }
361
+ catch { /* best-effort interruption */ }
362
+ }
363
+ result = { ok: true };
364
+ break;
365
+ }
366
+ case "structuredForget":
367
+ structuredForget(String(params.runId ?? ""));
368
+ result = { ok: true };
369
+ break;
203
370
  }
204
371
  send(client.socket, { kind: "response", id: request.id, ok: true, result });
205
372
  }
@@ -259,6 +426,8 @@ export async function runTerminalDaemon(configPath) {
259
426
  const shutdown = () => {
260
427
  for (const sessionId of Array.from(sessions.keys()))
261
428
  forget(sessionId);
429
+ for (const runId of Array.from(structuredRuns.keys()))
430
+ structuredForget(runId);
262
431
  for (const client of clients)
263
432
  client.socket.destroy();
264
433
  server.close(() => process.exit(0));
@@ -295,6 +464,15 @@ function isProcessAlive(pid) {
295
464
  return false;
296
465
  }
297
466
  }
467
+ function signalNumberFromName(signalName) {
468
+ const table = {
469
+ SIGHUP: 1, SIGINT: 2, SIGQUIT: 3, SIGILL: 4, SIGTRAP: 5, SIGABRT: 6,
470
+ SIGBUS: 7, SIGFPE: 8, SIGKILL: 9, SIGUSR1: 10, SIGSEGV: 11, SIGUSR2: 12,
471
+ SIGPIPE: 13, SIGALRM: 14, SIGTERM: 15, SIGCHLD: 17, SIGCONT: 18,
472
+ SIGSTOP: 19, SIGTSTP: 20,
473
+ };
474
+ return table[signalName] ?? 0;
475
+ }
298
476
  function waitForProcessExit(pid) {
299
477
  return new Promise((resolve) => {
300
478
  const timer = setInterval(() => {