@co0ontty/wand 4.72.2 → 4.75.0-beta.g1d477a2
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 +4 -4
- package/dist/claude-sdk-runner.d.ts +2 -3
- package/dist/config.d.ts +1 -1
- package/dist/config.js +48 -0
- package/dist/daemon-connection.d.ts +9 -0
- package/dist/daemon-connection.js +86 -0
- package/dist/distribution-manager.d.ts +5 -0
- package/dist/distribution-manager.js +79 -1
- package/dist/git-quick-commit.js +12 -0
- package/dist/message-truncator.js +9 -3
- package/dist/models.d.ts +39 -2
- package/dist/models.js +302 -9
- package/dist/native/darwin-arm64/wand-render +0 -0
- package/dist/native/darwin-arm64/wand-render.sha256 +1 -0
- package/dist/native/darwin-arm64/wand-render.version +1 -0
- package/dist/native/darwin-arm64/wand-structured-renderd +0 -0
- package/dist/native/darwin-arm64/wand-structured-renderd.sha256 +1 -0
- package/dist/native/darwin-arm64/wand-structured-renderd.version +1 -0
- package/dist/native/darwin-x64/wand-render +0 -0
- package/dist/native/darwin-x64/wand-render.sha256 +1 -0
- package/dist/native/darwin-x64/wand-render.version +1 -0
- package/dist/native/darwin-x64/wand-structured-renderd +0 -0
- package/dist/native/darwin-x64/wand-structured-renderd.sha256 +1 -0
- package/dist/native/darwin-x64/wand-structured-renderd.version +1 -0
- package/dist/native/linux-arm64/wand-render +0 -0
- package/dist/native/linux-arm64/wand-render.sha256 +1 -0
- package/dist/native/linux-arm64/wand-render.version +1 -0
- package/dist/native/linux-arm64/wand-structured-renderd +0 -0
- package/dist/native/linux-arm64/wand-structured-renderd.sha256 +1 -0
- package/dist/native/linux-arm64/wand-structured-renderd.version +1 -0
- package/dist/native/linux-x64/wand-render +0 -0
- package/dist/native/linux-x64/wand-render.sha256 +1 -0
- package/dist/native/linux-x64/wand-render.version +1 -0
- package/dist/native/linux-x64/wand-structured-renderd +0 -0
- package/dist/native/linux-x64/wand-structured-renderd.sha256 +1 -0
- package/dist/native/linux-x64/wand-structured-renderd.version +1 -0
- package/dist/process-manager.d.ts +12 -1
- package/dist/process-manager.js +88 -7
- package/dist/pty-terminal-state.d.ts +17 -0
- package/dist/pty-terminal-state.js +51 -1
- package/dist/render-binary.d.ts +61 -0
- package/dist/render-binary.js +204 -0
- package/dist/render-daemon-client.d.ts +202 -0
- package/dist/render-daemon-client.js +1164 -0
- package/dist/render-host.d.ts +88 -0
- package/dist/render-host.js +440 -0
- package/dist/render-protocol.d.ts +128 -0
- package/dist/render-protocol.js +99 -0
- package/dist/render-structured-client.d.ts +80 -0
- package/dist/render-structured-client.js +513 -0
- package/dist/render-structured-host.d.ts +31 -0
- package/dist/render-structured-host.js +218 -0
- package/dist/render-structured-protocol.d.ts +101 -0
- package/dist/render-structured-protocol.js +35 -0
- package/dist/server-session-routes.js +114 -12
- package/dist/server-task-routes.js +55 -6
- package/dist/server-update-routes.d.ts +2 -0
- package/dist/server-update-routes.js +44 -0
- package/dist/server.js +43 -13
- package/dist/session-ai-context.d.ts +1 -1
- package/dist/session-ai-context.js +15 -4
- package/dist/session-logger.d.ts +2 -0
- package/dist/session-logger.js +44 -1
- package/dist/session-transport.js +1 -1
- package/dist/storage.d.ts +2 -1
- package/dist/storage.js +14 -5
- package/dist/structured-client-protocol.d.ts +3 -1
- package/dist/structured-client-protocol.js +85 -25
- package/dist/structured-content.d.ts +20 -3
- package/dist/structured-content.js +80 -1
- package/dist/structured-exec-host.js +7 -1
- package/dist/structured-exec-pump.js +6 -2
- package/dist/structured-grok-adapter.js +22 -0
- package/dist/structured-pi-adapter.js +18 -2
- package/dist/structured-provider-common.d.ts +11 -9
- package/dist/structured-provider-common.js +60 -18
- package/dist/structured-session-manager.d.ts +2 -0
- package/dist/structured-session-manager.js +27 -3
- package/dist/task-types.d.ts +4 -1
- package/dist/terminal-daemon-client.d.ts +9 -2
- package/dist/terminal-daemon-client.js +119 -46
- package/dist/terminal-daemon-server.js +47 -8
- package/dist/terminal-host.d.ts +6 -0
- package/dist/terminal-host.js +3 -0
- package/dist/tui/commands.js +183 -10
- package/dist/tui/ipc-server.js +12 -0
- package/dist/types.d.ts +30 -10
- package/dist/unix-socket-keepalive.d.ts +3 -0
- package/dist/unix-socket-keepalive.js +94 -0
- package/dist/wand-task-sync.js +2 -1
- package/dist/web-ui/content/scripts.js +79 -73
- package/dist/web-ui/content/styles.css +1 -1
- package/dist/web-ui/content/tailwind.css +21 -0
- package/dist/web-ui/embedded-assets.js +2 -2
- package/dist/web-ui/thinking-efforts.d.ts +24 -0
- package/dist/web-ui/thinking-efforts.js +81 -0
- package/dist/ws-broadcast.d.ts +1 -1
- package/dist/ws-broadcast.js +28 -9
- package/package.json +8 -3
- package/scripts/install-render-binary.js +499 -0
- package/scripts/stage-render-binaries.js +626 -0
package/dist/task-types.d.ts
CHANGED
|
@@ -7,7 +7,8 @@ export declare const DEFAULT_WAND_TASK_PRIORITY: WandTaskPriority;
|
|
|
7
7
|
/** 任务派发时选择的 CLI 工具;空串表示尚未指定。 */
|
|
8
8
|
export type WandTaskAgentProvider = "claude" | "codex" | "opencode" | "grok" | "qoder" | "pi";
|
|
9
9
|
export type WandTaskAgentModel = string;
|
|
10
|
-
|
|
10
|
+
/** 与会话思考档位同一套:旧四档,或 `provider:level` 原生档。 */
|
|
11
|
+
export type WandTaskAgentEffort = "off" | "standard" | "deep" | "max" | `${WandTaskAgentProvider}:${string}`;
|
|
11
12
|
/**
|
|
12
13
|
* 派发时的执行模式,只暴露三种:
|
|
13
14
|
* - managed:托管,自动确认全部权限并使用完全自主提示词;
|
|
@@ -120,6 +121,8 @@ export interface WandTask {
|
|
|
120
121
|
workspaceId: string | null;
|
|
121
122
|
/** 关联的侧栏工作任务;归档/新建时用来对账,不随 WorkspaceTask 删除而消失。 */
|
|
122
123
|
workspaceTaskId: string | null;
|
|
124
|
+
/** 认领到处理中的父任务;子任务仍有独立 Agent 会话。 */
|
|
125
|
+
parentTaskId: string | null;
|
|
123
126
|
identifier: string;
|
|
124
127
|
title: string;
|
|
125
128
|
/** 'user' = 用户自己填的标题;'auto' = 用户留空后按描述/会话内容自动生成。 */
|
|
@@ -3,7 +3,8 @@ import { type TerminalAttachResult, type TerminalHost, type TerminalSpawnRequest
|
|
|
3
3
|
import { type StructuredExecHost, type StructuredExecProcess, type StructuredSpawnRequest, type StructuredRunState } from "./structured-exec-host.js";
|
|
4
4
|
export declare class TerminalDaemonClient implements TerminalHost, StructuredExecHost {
|
|
5
5
|
private readonly socketPath;
|
|
6
|
-
private
|
|
6
|
+
private token;
|
|
7
|
+
private readonly tokenPath;
|
|
7
8
|
readonly persistent = true;
|
|
8
9
|
private socket;
|
|
9
10
|
private frameParts;
|
|
@@ -15,14 +16,19 @@ export declare class TerminalDaemonClient implements TerminalHost, StructuredExe
|
|
|
15
16
|
private readonly handles;
|
|
16
17
|
private readonly pendingEvents;
|
|
17
18
|
private readonly structuredInventory;
|
|
19
|
+
/** Immutable read cursors; live sdata may mutate inventory but not the caller's last snapshot. */
|
|
20
|
+
private readonly structuredObserved;
|
|
18
21
|
private readonly structuredHandles;
|
|
19
22
|
private readonly pendingStructuredEvents;
|
|
20
23
|
private disposed;
|
|
21
24
|
private reconnectTimer;
|
|
22
25
|
private reconnectDelayMs;
|
|
23
26
|
private reconnectFailureLogged;
|
|
24
|
-
|
|
27
|
+
private connecting;
|
|
28
|
+
private stopHeartbeat;
|
|
29
|
+
constructor(socketPath: string, token: string, tokenPath?: string | null);
|
|
25
30
|
connect(): Promise<void>;
|
|
31
|
+
private open;
|
|
26
32
|
attach(sessionId: string, afterSeq?: number): TerminalAttachResult | null;
|
|
27
33
|
createOrAttach(request: TerminalSpawnRequest, afterSeq?: number): Promise<TerminalAttachResult>;
|
|
28
34
|
forget(sessionId: string): void;
|
|
@@ -33,6 +39,7 @@ export declare class TerminalDaemonClient implements TerminalHost, StructuredExe
|
|
|
33
39
|
forgetRun(runId: string): void;
|
|
34
40
|
/** Refresh the structured inventory and catch live handles up after a reconnect. */
|
|
35
41
|
private refreshStructuredAfterReconnect;
|
|
42
|
+
private rememberStructuredSnapshot;
|
|
36
43
|
private handleFromState;
|
|
37
44
|
private deliverStructuredEvent;
|
|
38
45
|
disconnect(): void;
|
|
@@ -6,6 +6,7 @@ import { StringDecoder } from "node:string_decoder";
|
|
|
6
6
|
import { TERMINAL_DAEMON_PROTOCOL_VERSION, terminalDaemonPaths, } from "./terminal-daemon-protocol.js";
|
|
7
7
|
import { appendTerminalChunkWindow, InProcessTerminalHost, } from "./terminal-host.js";
|
|
8
8
|
import { STRUCTURED_RUN_LOG_MAX_CHARS, } from "./structured-exec-host.js";
|
|
9
|
+
import { startDaemonHeartbeat, waitForDaemonSocket } from "./daemon-connection.js";
|
|
9
10
|
import { appendWindow, PTY_OUTPUT_MAX_SIZE } from "./pty-text-utils.js";
|
|
10
11
|
const TERMINAL_DAEMON_RECONNECT_INITIAL_MS = 500;
|
|
11
12
|
const TERMINAL_DAEMON_RECONNECT_MAX_MS = 10_000;
|
|
@@ -29,6 +30,9 @@ class RemoteTerminalProcess {
|
|
|
29
30
|
this.client.reportOperationError(this.sessionId, error);
|
|
30
31
|
});
|
|
31
32
|
}
|
|
33
|
+
async writeConfirmed(data) {
|
|
34
|
+
await this.client.request("write", { sessionId: this.sessionId, data });
|
|
35
|
+
}
|
|
32
36
|
resize(cols, rows) {
|
|
33
37
|
void this.client.request("resize", { sessionId: this.sessionId, cols, rows }).catch((error) => {
|
|
34
38
|
this.client.reportOperationError(this.sessionId, error);
|
|
@@ -128,11 +132,14 @@ class RemoteStructuredProcess {
|
|
|
128
132
|
return { dispose: () => this.exitListeners.delete(listener) };
|
|
129
133
|
}
|
|
130
134
|
acceptStream(event) {
|
|
135
|
+
const previousSeq = event.stream === "stdout" ? this.lastStdoutSeq : this.lastStderrSeq;
|
|
136
|
+
if (event.seq <= previousSeq)
|
|
137
|
+
return;
|
|
131
138
|
this.deliveredChars[event.stream] += event.data.length;
|
|
132
139
|
if (event.stream === "stdout")
|
|
133
|
-
this.lastStdoutSeq =
|
|
140
|
+
this.lastStdoutSeq = event.seq;
|
|
134
141
|
else
|
|
135
|
-
this.lastStderrSeq =
|
|
142
|
+
this.lastStderrSeq = event.seq;
|
|
136
143
|
if (this.streamListeners.size === 0) {
|
|
137
144
|
this.undeliveredEvents.push(event);
|
|
138
145
|
if (this.undeliveredEvents.length > 4096)
|
|
@@ -153,14 +160,14 @@ class RemoteStructuredProcess {
|
|
|
153
160
|
process.stderr.write(`[wand] structured run ${this.runId} stdout log no longer aligns with delivered output; skipping catch-up\n`);
|
|
154
161
|
}
|
|
155
162
|
else if (stdoutDelta) {
|
|
156
|
-
this.acceptStream({ stream: "stdout", data: stdoutDelta, seq:
|
|
163
|
+
this.acceptStream({ stream: "stdout", data: stdoutDelta, seq: state.stdoutSeq });
|
|
157
164
|
}
|
|
158
165
|
const stderrDelta = alignedDelta(state.stderrLog, state.stderrTruncated, this.deliveredChars.stderr);
|
|
159
166
|
if (stderrDelta === null) {
|
|
160
167
|
process.stderr.write(`[wand] structured run ${this.runId} stderr log no longer aligns with delivered output; skipping catch-up\n`);
|
|
161
168
|
}
|
|
162
169
|
else if (stderrDelta) {
|
|
163
|
-
this.acceptStream({ stream: "stderr", data: stderrDelta, seq:
|
|
170
|
+
this.acceptStream({ stream: "stderr", data: stderrDelta, seq: state.stderrSeq });
|
|
164
171
|
}
|
|
165
172
|
}
|
|
166
173
|
emitStream(event) {
|
|
@@ -168,7 +175,6 @@ class RemoteStructuredProcess {
|
|
|
168
175
|
listener(event);
|
|
169
176
|
}
|
|
170
177
|
deliveredChars = { stdout: 0, stderr: 0 };
|
|
171
|
-
syntheticSeq = 1_000_000_000_000;
|
|
172
178
|
}
|
|
173
179
|
/**
|
|
174
180
|
* Deliver the suffix of the daemon's replay log beyond what this handle has
|
|
@@ -185,6 +191,7 @@ function alignedDelta(log, truncated, deliveredChars) {
|
|
|
185
191
|
export class TerminalDaemonClient {
|
|
186
192
|
socketPath;
|
|
187
193
|
token;
|
|
194
|
+
tokenPath;
|
|
188
195
|
persistent = true;
|
|
189
196
|
socket = null;
|
|
190
197
|
frameParts = [];
|
|
@@ -196,44 +203,60 @@ export class TerminalDaemonClient {
|
|
|
196
203
|
handles = new Map();
|
|
197
204
|
pendingEvents = new Map();
|
|
198
205
|
structuredInventory = new Map();
|
|
206
|
+
/** Immutable read cursors; live sdata may mutate inventory but not the caller's last snapshot. */
|
|
207
|
+
structuredObserved = new Map();
|
|
199
208
|
structuredHandles = new Map();
|
|
200
209
|
pendingStructuredEvents = new Map();
|
|
201
210
|
disposed = false;
|
|
202
211
|
reconnectTimer = null;
|
|
203
212
|
reconnectDelayMs = TERMINAL_DAEMON_RECONNECT_INITIAL_MS;
|
|
204
213
|
reconnectFailureLogged = false;
|
|
205
|
-
|
|
214
|
+
connecting = null;
|
|
215
|
+
stopHeartbeat = null;
|
|
216
|
+
constructor(socketPath, token, tokenPath = null) {
|
|
206
217
|
this.socketPath = socketPath;
|
|
207
218
|
this.token = token;
|
|
219
|
+
this.tokenPath = tokenPath;
|
|
208
220
|
}
|
|
209
|
-
|
|
221
|
+
connect() {
|
|
210
222
|
if (this.disposed)
|
|
211
|
-
|
|
223
|
+
return Promise.reject(new Error("Terminal daemon client disposed"));
|
|
224
|
+
if (this.connecting)
|
|
225
|
+
return this.connecting;
|
|
212
226
|
if (this.socket && !this.socket.destroyed)
|
|
213
|
-
return;
|
|
227
|
+
return Promise.resolve();
|
|
228
|
+
const connecting = this.open();
|
|
229
|
+
this.connecting = connecting;
|
|
230
|
+
void connecting.finally(() => {
|
|
231
|
+
if (this.connecting === connecting)
|
|
232
|
+
this.connecting = null;
|
|
233
|
+
}).catch(() => { });
|
|
234
|
+
return connecting;
|
|
235
|
+
}
|
|
236
|
+
async open() {
|
|
237
|
+
if (this.tokenPath)
|
|
238
|
+
this.token = readFileSync(this.tokenPath, "utf8").trim();
|
|
214
239
|
const socket = net.createConnection(this.socketPath);
|
|
215
240
|
socket.setNoDelay(true);
|
|
216
241
|
this.socket = socket;
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
socket
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
socket.once("error", onError);
|
|
226
|
-
});
|
|
242
|
+
try {
|
|
243
|
+
await waitForDaemonSocket(socket);
|
|
244
|
+
}
|
|
245
|
+
catch (error) {
|
|
246
|
+
if (this.socket === socket)
|
|
247
|
+
this.socket = null;
|
|
248
|
+
throw error;
|
|
249
|
+
}
|
|
227
250
|
// Each socket decodes from a clean slate; a half-written character from a
|
|
228
251
|
// previous connection must not leak into the next frame.
|
|
229
252
|
this.decoder = new StringDecoder("utf8");
|
|
230
253
|
this.frameParts = [];
|
|
231
|
-
socket.on("data", (data) =>
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
this.rejectPending(error instanceof Error ? error : new Error(String(error)));
|
|
235
|
-
this.handleDisconnect();
|
|
254
|
+
socket.on("data", (data) => {
|
|
255
|
+
if (this.socket === socket)
|
|
256
|
+
this.consume(this.decoder.write(data));
|
|
236
257
|
});
|
|
258
|
+
socket.on("close", () => this.handleDisconnect(socket));
|
|
259
|
+
socket.on("error", () => this.handleDisconnect(socket));
|
|
237
260
|
try {
|
|
238
261
|
await this.request("hello");
|
|
239
262
|
const previous = new Map(this.inventory);
|
|
@@ -246,6 +269,10 @@ export class TerminalDaemonClient {
|
|
|
246
269
|
process.stderr.write("[wand] Reconnected to terminal daemon; reconciled PTY inventory.\n");
|
|
247
270
|
}
|
|
248
271
|
await this.refreshStructuredAfterReconnect();
|
|
272
|
+
if (this.disposed || this.socket !== socket)
|
|
273
|
+
throw new Error("Terminal daemon disconnected during adoption");
|
|
274
|
+
this.stopHeartbeat?.();
|
|
275
|
+
this.stopHeartbeat = startDaemonHeartbeat(socket, () => this.request("hello"), { socketPath: this.socketPath });
|
|
249
276
|
}
|
|
250
277
|
catch (error) {
|
|
251
278
|
if (!this.disposed) {
|
|
@@ -253,7 +280,7 @@ export class TerminalDaemonClient {
|
|
|
253
280
|
socket.destroy();
|
|
254
281
|
}
|
|
255
282
|
catch { /* best-effort cleanup */ }
|
|
256
|
-
this.handleDisconnect();
|
|
283
|
+
this.handleDisconnect(socket);
|
|
257
284
|
}
|
|
258
285
|
throw error;
|
|
259
286
|
}
|
|
@@ -279,6 +306,8 @@ export class TerminalDaemonClient {
|
|
|
279
306
|
void this.request("forget", { sessionId }).catch((error) => this.reportOperationError(sessionId, error));
|
|
280
307
|
}
|
|
281
308
|
// ---------------------------------------------------------------------------
|
|
309
|
+
// MIGRATION-COMPAT: keep this legacy StructuredExecHost adapter for owned
|
|
310
|
+
// runs and rollback after Rust adoption; remove only on explicit request.
|
|
282
311
|
// StructuredExecHost: daemon-owned CLI runs that survive web restarts.
|
|
283
312
|
// ---------------------------------------------------------------------------
|
|
284
313
|
async spawnStructured(request) {
|
|
@@ -289,32 +318,44 @@ export class TerminalDaemonClient {
|
|
|
289
318
|
}
|
|
290
319
|
this.structuredHandles.delete(request.runId);
|
|
291
320
|
const state = await this.request("structuredSpawn", { ...request });
|
|
292
|
-
return this.handleFromState(state, existingHandle ?? null);
|
|
321
|
+
return this.handleFromState(state, existingHandle ?? null, true);
|
|
293
322
|
}
|
|
294
323
|
async attachRun(runId) {
|
|
295
324
|
const payload = await this.request("structuredAttach", { runId });
|
|
296
|
-
if (!payload.state)
|
|
325
|
+
if (!payload.state) {
|
|
326
|
+
this.structuredInventory.delete(runId);
|
|
327
|
+
this.structuredObserved.delete(runId);
|
|
297
328
|
return null;
|
|
298
|
-
|
|
329
|
+
}
|
|
330
|
+
this.rememberStructuredSnapshot(payload.state);
|
|
299
331
|
return payload.state;
|
|
300
332
|
}
|
|
301
333
|
async adoptRun(runId) {
|
|
334
|
+
// The caller may have already replayed a list/attach snapshot. Keep its
|
|
335
|
+
// watermarks: events arriving during the attach RPC still need delivery.
|
|
336
|
+
const observed = this.structuredObserved.get(runId);
|
|
302
337
|
const state = await this.attachRun(runId);
|
|
303
338
|
if (!state || state.status !== "running")
|
|
304
339
|
return null;
|
|
305
340
|
const existing = this.structuredHandles.get(runId);
|
|
306
341
|
if (existing && existing.incarnationId === state.incarnationId)
|
|
307
342
|
return existing;
|
|
308
|
-
return this.handleFromState(state, existing ?? null);
|
|
343
|
+
return this.handleFromState(state, existing ?? null, false, observed?.incarnationId === state.incarnationId ? observed : state);
|
|
309
344
|
}
|
|
310
345
|
async listRuns() {
|
|
311
346
|
const result = await this.request("structuredList");
|
|
312
347
|
// Defensive against protocol skew with older daemons that answer unknown
|
|
313
348
|
// methods with a generic { ok: true }.
|
|
314
|
-
|
|
349
|
+
if (!Array.isArray(result))
|
|
350
|
+
return [];
|
|
351
|
+
const runs = result;
|
|
352
|
+
for (const run of runs)
|
|
353
|
+
this.rememberStructuredSnapshot(run);
|
|
354
|
+
return runs;
|
|
315
355
|
}
|
|
316
356
|
forgetRun(runId) {
|
|
317
357
|
this.structuredInventory.delete(runId);
|
|
358
|
+
this.structuredObserved.delete(runId);
|
|
318
359
|
this.structuredHandles.delete(runId);
|
|
319
360
|
this.pendingStructuredEvents.delete(runId);
|
|
320
361
|
void this.request("structuredForget", { runId }).catch((error) => this.reportOperationError(runId, error));
|
|
@@ -331,7 +372,7 @@ export class TerminalDaemonClient {
|
|
|
331
372
|
}
|
|
332
373
|
this.structuredInventory.clear();
|
|
333
374
|
for (const run of runs)
|
|
334
|
-
this.structuredInventory.set(run.runId, run);
|
|
375
|
+
this.structuredInventory.set(run.runId, { ...run });
|
|
335
376
|
for (const [runId, oldState] of previous) {
|
|
336
377
|
const current = this.structuredInventory.get(runId);
|
|
337
378
|
const handle = this.structuredHandles.get(runId);
|
|
@@ -346,8 +387,19 @@ export class TerminalDaemonClient {
|
|
|
346
387
|
handle.acceptExit({ exitCode: current?.exitCode ?? -1, signal: current?.signal ?? null });
|
|
347
388
|
}
|
|
348
389
|
}
|
|
349
|
-
|
|
350
|
-
|
|
390
|
+
rememberStructuredSnapshot(state) {
|
|
391
|
+
// A reducer may be feeding the returned list/attach object while events
|
|
392
|
+
// keep arriving. Never let routeStructuredEvent mutate that object or the
|
|
393
|
+
// watermarks used to bridge list -> attach -> live adoption.
|
|
394
|
+
this.structuredInventory.set(state.runId, { ...state });
|
|
395
|
+
this.structuredObserved.set(state.runId, {
|
|
396
|
+
incarnationId: state.incarnationId,
|
|
397
|
+
stdoutSeq: state.stdoutSeq,
|
|
398
|
+
stderrSeq: state.stderrSeq,
|
|
399
|
+
});
|
|
400
|
+
}
|
|
401
|
+
handleFromState(state, reuse, replaySnapshot = false, observed = state) {
|
|
402
|
+
this.rememberStructuredSnapshot(state);
|
|
351
403
|
let process = this.structuredHandles.get(state.runId) ?? null;
|
|
352
404
|
if (!process || process.incarnationId !== state.incarnationId) {
|
|
353
405
|
process = reuse && reuse.incarnationId === state.incarnationId
|
|
@@ -358,18 +410,29 @@ export class TerminalDaemonClient {
|
|
|
358
410
|
this.structuredHandles.set(state.runId, process);
|
|
359
411
|
else
|
|
360
412
|
this.structuredHandles.delete(state.runId);
|
|
361
|
-
//
|
|
362
|
-
//
|
|
363
|
-
//
|
|
364
|
-
// not
|
|
413
|
+
// A fresh spawn must replay the RPC snapshot. For adoption, the caller may
|
|
414
|
+
// have replayed an earlier inventory/attach state, so retain that earlier
|
|
415
|
+
// watermark while draining the events received during the attach RPC.
|
|
416
|
+
// Do not redeliver events already present in the caller's snapshot.
|
|
417
|
+
if (replaySnapshot || state.status !== "running")
|
|
418
|
+
process.replayFromLogs(state);
|
|
365
419
|
const pending = this.pendingStructuredEvents.get(state.runId) ?? [];
|
|
366
420
|
this.pendingStructuredEvents.delete(state.runId);
|
|
367
|
-
for (const event of pending)
|
|
421
|
+
for (const event of pending) {
|
|
422
|
+
if (event.incarnationId !== state.incarnationId)
|
|
423
|
+
continue;
|
|
424
|
+
if (event.event === "sdata" && typeof event.seq === "number") {
|
|
425
|
+
const known = replaySnapshot ? state : observed;
|
|
426
|
+
const knownSeq = event.stream === "stderr" ? known.stderrSeq : known.stdoutSeq;
|
|
427
|
+
if (event.seq <= knownSeq)
|
|
428
|
+
continue;
|
|
429
|
+
}
|
|
430
|
+
if (event.event === "sexit" && state.status === "exited")
|
|
431
|
+
continue;
|
|
368
432
|
this.deliverStructuredEvent(process, event);
|
|
369
|
-
if (state.status !== "running") {
|
|
370
|
-
process.replayFromLogs(state);
|
|
371
|
-
process.acceptExit({ exitCode: state.exitCode, signal: state.signal });
|
|
372
433
|
}
|
|
434
|
+
if (state.status !== "running")
|
|
435
|
+
process.acceptExit({ exitCode: state.exitCode, signal: state.signal });
|
|
373
436
|
return process;
|
|
374
437
|
}
|
|
375
438
|
deliverStructuredEvent(handle, event) {
|
|
@@ -389,6 +452,8 @@ export class TerminalDaemonClient {
|
|
|
389
452
|
}
|
|
390
453
|
disconnect() {
|
|
391
454
|
this.disposed = true;
|
|
455
|
+
this.stopHeartbeat?.();
|
|
456
|
+
this.stopHeartbeat = null;
|
|
392
457
|
if (this.reconnectTimer) {
|
|
393
458
|
clearTimeout(this.reconnectTimer);
|
|
394
459
|
this.reconnectTimer = null;
|
|
@@ -408,9 +473,13 @@ export class TerminalDaemonClient {
|
|
|
408
473
|
* every RemoteTerminalProcess silently dead while ProcessManager keeps the
|
|
409
474
|
* session status at "running" forever.
|
|
410
475
|
*/
|
|
411
|
-
handleDisconnect() {
|
|
412
|
-
if (this.socket
|
|
413
|
-
|
|
476
|
+
handleDisconnect(socket) {
|
|
477
|
+
if (this.socket !== socket)
|
|
478
|
+
return;
|
|
479
|
+
this.socket = null;
|
|
480
|
+
this.stopHeartbeat?.();
|
|
481
|
+
this.stopHeartbeat = null;
|
|
482
|
+
socket.destroy();
|
|
414
483
|
this.rejectPending(new Error("Terminal daemon disconnected"));
|
|
415
484
|
if (this.disposed)
|
|
416
485
|
return;
|
|
@@ -727,7 +796,7 @@ async function tryConnect(socketPath, tokenPath) {
|
|
|
727
796
|
}
|
|
728
797
|
if (!token)
|
|
729
798
|
return null;
|
|
730
|
-
const client = new TerminalDaemonClient(socketPath, token);
|
|
799
|
+
const client = new TerminalDaemonClient(socketPath, token, tokenPath);
|
|
731
800
|
try {
|
|
732
801
|
await client.connect();
|
|
733
802
|
return client;
|
|
@@ -745,8 +814,12 @@ export async function connectExistingTerminalHost(configPath) {
|
|
|
745
814
|
function probeSocket(socketPath) {
|
|
746
815
|
return new Promise((resolve) => {
|
|
747
816
|
const socket = net.createConnection(socketPath);
|
|
817
|
+
let settled = false;
|
|
748
818
|
const finish = (alive) => {
|
|
749
|
-
|
|
819
|
+
if (settled)
|
|
820
|
+
return;
|
|
821
|
+
settled = true;
|
|
822
|
+
clearTimeout(timer);
|
|
750
823
|
socket.destroy();
|
|
751
824
|
resolve(alive);
|
|
752
825
|
};
|
|
@@ -5,6 +5,7 @@ import net from "node:net";
|
|
|
5
5
|
import path from "node:path";
|
|
6
6
|
import { StringDecoder } from "node:string_decoder";
|
|
7
7
|
import pty from "node-pty";
|
|
8
|
+
import { keepUnixSocketAlive } from "./unix-socket-keepalive.js";
|
|
8
9
|
import { ensureNodePtyHelperExecutable } from "./ensure-node-pty-helper.js";
|
|
9
10
|
import { signalNumberFromName } from "./signal-utils.js";
|
|
10
11
|
import { STRUCTURED_RUN_LOG_MAX_CHARS, createUtf8TextDecoder, } from "./structured-exec-host.js";
|
|
@@ -184,6 +185,9 @@ export async function runTerminalDaemon(configPath) {
|
|
|
184
185
|
session.terminalState.dispose();
|
|
185
186
|
};
|
|
186
187
|
// ---------------------------------------------------------------------------
|
|
188
|
+
// MIGRATION-COMPAT: Node-owned structured CLI runs must remain available
|
|
189
|
+
// throughout the Rust migration for existing owners and explicit rollback.
|
|
190
|
+
// Do not remove until the user separately requests legacy cleanup.
|
|
187
191
|
// Structured CLI runs: plain child_process ownership with full replay logs.
|
|
188
192
|
// ---------------------------------------------------------------------------
|
|
189
193
|
const appendRunLog = (run, stream, data) => {
|
|
@@ -234,7 +238,9 @@ export async function runTerminalDaemon(configPath) {
|
|
|
234
238
|
};
|
|
235
239
|
const structuredSpawn = (request) => {
|
|
236
240
|
const existing = structuredRuns.get(request.runId);
|
|
237
|
-
|
|
241
|
+
// A lost spawn response must never re-execute the input, even if the
|
|
242
|
+
// child exited before the caller could retry. Only forget permits reuse.
|
|
243
|
+
if (existing)
|
|
238
244
|
return serializeStructuredRun(existing);
|
|
239
245
|
const wantsStdin = typeof request.stdinData === "string";
|
|
240
246
|
const child = nodeSpawn(request.file, request.args, {
|
|
@@ -242,8 +248,7 @@ export async function runTerminalDaemon(configPath) {
|
|
|
242
248
|
env: request.env,
|
|
243
249
|
stdio: wantsStdin ? ["pipe", "pipe", "pipe"] : ["ignore", "pipe", "pipe"],
|
|
244
250
|
});
|
|
245
|
-
|
|
246
|
-
child.stdin?.end(request.stdinData);
|
|
251
|
+
let stdinFailed = false;
|
|
247
252
|
const run = {
|
|
248
253
|
request,
|
|
249
254
|
incarnationId: randomUUID(),
|
|
@@ -295,7 +300,7 @@ export async function runTerminalDaemon(configPath) {
|
|
|
295
300
|
emitDecoded("stderr", run.stderrDecoder.end());
|
|
296
301
|
run.child = null;
|
|
297
302
|
run.status = "exited";
|
|
298
|
-
run.exitCode = code;
|
|
303
|
+
run.exitCode = stdinFailed ? -1 : code;
|
|
299
304
|
run.exitedAt = Date.now();
|
|
300
305
|
run.signal = signalName === null || signalName === undefined ? null : Number(signalName) || signalNumberFromName(signalName);
|
|
301
306
|
broadcast({
|
|
@@ -308,6 +313,13 @@ export async function runTerminalDaemon(configPath) {
|
|
|
308
313
|
});
|
|
309
314
|
evictStaleStructuredRuns();
|
|
310
315
|
});
|
|
316
|
+
child.stdin?.on("error", () => {
|
|
317
|
+
// Stream errors are independent of the child's error/close events.
|
|
318
|
+
stdinFailed = true;
|
|
319
|
+
child.kill("SIGTERM");
|
|
320
|
+
});
|
|
321
|
+
if (wantsStdin)
|
|
322
|
+
child.stdin?.end(request.stdinData);
|
|
311
323
|
return serializeStructuredRun(run);
|
|
312
324
|
};
|
|
313
325
|
const structuredForget = (runId) => {
|
|
@@ -469,7 +481,11 @@ export async function runTerminalDaemon(configPath) {
|
|
|
469
481
|
resolve();
|
|
470
482
|
});
|
|
471
483
|
});
|
|
484
|
+
const stopSocketKeepalive = keepUnixSocketAlive(server, paths.socketPath, () => {
|
|
485
|
+
chmodSync(paths.socketPath, 0o600);
|
|
486
|
+
});
|
|
472
487
|
const shutdown = () => {
|
|
488
|
+
stopSocketKeepalive();
|
|
473
489
|
for (const sessionId of Array.from(sessions.keys()))
|
|
474
490
|
forget(sessionId);
|
|
475
491
|
for (const runId of Array.from(structuredRuns.keys()))
|
|
@@ -477,14 +493,28 @@ export async function runTerminalDaemon(configPath) {
|
|
|
477
493
|
for (const client of clients)
|
|
478
494
|
client.socket.destroy();
|
|
479
495
|
server.close(() => process.exit(0));
|
|
480
|
-
cleanupDaemonFiles(paths
|
|
496
|
+
cleanupDaemonFiles(paths, { token, pid: process.pid });
|
|
481
497
|
};
|
|
482
498
|
process.once("SIGINT", shutdown);
|
|
483
499
|
process.once("SIGTERM", shutdown);
|
|
484
500
|
}
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
501
|
+
/**
|
|
502
|
+
* 只清理**自己拥有**的 daemon 文件。
|
|
503
|
+
*
|
|
504
|
+
* 升级期/僵尸期可能有两个 daemon 指向同一个 config(老进程端点已被清理、新进程刚接上):
|
|
505
|
+
* 老进程退出时无条件 unlink,会把新进程正在使用的 token/pid 一起删掉 —— 新 daemon 随即变成
|
|
506
|
+
* 「进程活着但凭据不见了」,所有终端永久断联。所以凭据按**内容**判归属,不是自己写的就不动。
|
|
507
|
+
* socket 仍然照删:它属于本进程的监听(真正被占时 bind 会 EADDRINUSE)。
|
|
508
|
+
*/
|
|
509
|
+
function cleanupDaemonFiles(paths, owner) {
|
|
510
|
+
if (process.platform !== "win32") {
|
|
511
|
+
try {
|
|
512
|
+
unlinkSync(paths.socketPath);
|
|
513
|
+
}
|
|
514
|
+
catch { /* already absent */ }
|
|
515
|
+
}
|
|
516
|
+
for (const file of [paths.tokenPath, paths.pidPath]) {
|
|
517
|
+
if (!fileHoldsValue(file, file === paths.tokenPath ? owner.token : String(owner.pid)))
|
|
488
518
|
continue;
|
|
489
519
|
try {
|
|
490
520
|
unlinkSync(file);
|
|
@@ -492,6 +522,15 @@ function cleanupDaemonFiles(socketPath, tokenPath, pidPath) {
|
|
|
492
522
|
catch { /* already absent */ }
|
|
493
523
|
}
|
|
494
524
|
}
|
|
525
|
+
/** 文件内容是否还是本进程写进去的值(读不到/已被别人改写都算「不是自己的」)。 */
|
|
526
|
+
function fileHoldsValue(filePath, expected) {
|
|
527
|
+
try {
|
|
528
|
+
return readFileSync(filePath, "utf8").trim() === expected;
|
|
529
|
+
}
|
|
530
|
+
catch {
|
|
531
|
+
return false;
|
|
532
|
+
}
|
|
533
|
+
}
|
|
495
534
|
export function readTerminalDaemonPid(configPath) {
|
|
496
535
|
try {
|
|
497
536
|
const value = Number(readFileSync(terminalDaemonPaths(configPath).pidPath, "utf8").trim());
|
package/dist/terminal-host.d.ts
CHANGED
|
@@ -13,6 +13,8 @@ export interface TerminalProcess {
|
|
|
13
13
|
readonly incarnationId: string;
|
|
14
14
|
readonly pid: number;
|
|
15
15
|
write(data: string): void;
|
|
16
|
+
/** Resolve only after a persistent host has acknowledged an interactive write. */
|
|
17
|
+
writeConfirmed?(data: string): Promise<void>;
|
|
16
18
|
resize(cols: number, rows: number): void;
|
|
17
19
|
kill(signal?: string): void;
|
|
18
20
|
onData(listener: (event: TerminalDataEvent) => void): {
|
|
@@ -21,6 +23,10 @@ export interface TerminalProcess {
|
|
|
21
23
|
onExit(listener: (event: TerminalExitEvent) => void): {
|
|
22
24
|
dispose(): void;
|
|
23
25
|
};
|
|
26
|
+
/** Persistent hosts call this when bounded replay cannot bridge a disconnect. */
|
|
27
|
+
onResync?(listener: (state: TerminalSessionState) => void): {
|
|
28
|
+
dispose(): void;
|
|
29
|
+
};
|
|
24
30
|
}
|
|
25
31
|
export interface TerminalSessionState {
|
|
26
32
|
sessionId: string;
|