@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.
- package/dist/build-info.json +3 -3
- package/dist/distribution-manager.d.ts +17 -0
- package/dist/distribution-manager.js +80 -17
- package/dist/server-update-routes.d.ts +5 -0
- package/dist/server-update-routes.js +15 -0
- package/dist/server.js +8 -2
- package/dist/structured-claude-adapter.d.ts +3 -1
- package/dist/structured-claude-adapter.js +48 -93
- package/dist/structured-codex-adapter.d.ts +5 -0
- package/dist/structured-codex-adapter.js +40 -78
- package/dist/structured-exec-host.d.ts +76 -0
- package/dist/structured-exec-host.js +117 -0
- package/dist/structured-exec-pump.d.ts +37 -0
- package/dist/structured-exec-pump.js +127 -0
- package/dist/structured-grok-adapter.d.ts +3 -1
- package/dist/structured-grok-adapter.js +28 -67
- package/dist/structured-opencode-adapter.d.ts +3 -1
- package/dist/structured-opencode-adapter.js +37 -79
- package/dist/structured-pi-adapter.d.ts +3 -1
- package/dist/structured-pi-adapter.js +27 -39
- package/dist/structured-qoder-adapter.d.ts +3 -1
- package/dist/structured-qoder-adapter.js +49 -87
- package/dist/structured-session-manager.d.ts +9 -1
- package/dist/structured-session-manager.js +399 -12
- package/dist/terminal-daemon-client.d.ts +15 -1
- package/dist/terminal-daemon-client.js +261 -0
- package/dist/terminal-daemon-protocol.d.ts +10 -3
- package/dist/terminal-daemon-protocol.js +1 -1
- package/dist/terminal-daemon-server.js +178 -0
- package/dist/web-ui/content/scripts.js +57 -57
- package/dist/web-ui/content/styles.css +1 -1
- package/dist/web-ui/embedded-assets.d.ts +1 -1
- package/dist/web-ui/embedded-assets.js +3 -3
- 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 =
|
|
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 =
|
|
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(() => {
|