@co0ontty/wand 4.72.2 → 4.73.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/config.js +20 -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 +6 -0
- 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-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/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-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/process-manager.d.ts +10 -0
- package/dist/process-manager.js +73 -4
- package/dist/render-binary.d.ts +61 -0
- package/dist/render-binary.js +204 -0
- package/dist/render-daemon-client.d.ts +171 -0
- package/dist/render-daemon-client.js +1114 -0
- package/dist/render-host.d.ts +64 -0
- package/dist/render-host.js +301 -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 +72 -9
- package/dist/server-task-routes.js +52 -3
- package/dist/server-update-routes.d.ts +2 -0
- package/dist/server-update-routes.js +44 -0
- package/dist/server.js +31 -9
- 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-pi-adapter.js +18 -2
- package/dist/structured-session-manager.d.ts +2 -0
- package/dist/structured-session-manager.js +27 -3
- package/dist/task-types.d.ts +2 -0
- package/dist/terminal-daemon-client.d.ts +9 -2
- package/dist/terminal-daemon-client.js +119 -46
- package/dist/terminal-daemon-server.js +20 -4
- package/dist/terminal-host.d.ts +6 -0
- package/dist/terminal-host.js +3 -0
- package/dist/tui/ipc-server.js +12 -0
- package/dist/types.d.ts +19 -1
- package/dist/unix-socket-keepalive.d.ts +3 -0
- package/dist/unix-socket-keepalive.js +94 -0
- package/dist/web-ui/content/scripts.js +68 -68
- package/dist/web-ui/content/styles.css +1 -1
- package/dist/web-ui/content/tailwind.css +12 -0
- package/dist/web-ui/embedded-assets.js +2 -2
- package/dist/ws-broadcast.d.ts +1 -1
- package/dist/ws-broadcast.js +17 -5
- package/package.json +8 -3
- package/scripts/install-render-binary.js +499 -0
- package/scripts/stage-render-binaries.js +626 -0
|
@@ -207,7 +207,7 @@ function buildStructuredOutputPayload(snapshot) {
|
|
|
207
207
|
return {
|
|
208
208
|
wandProtocolVersion: WAND_PROTOCOL_VERSION,
|
|
209
209
|
output: snapshot.output,
|
|
210
|
-
messages: snapshot.messages ? enrichStructuredMessages(snapshot.messages) : undefined,
|
|
210
|
+
messages: snapshot.messages ? enrichStructuredMessages(snapshot.messages, snapshot.id) : undefined,
|
|
211
211
|
queuedMessages: snapshot.queuedMessages,
|
|
212
212
|
queuedMessageSkills: snapshot.queuedMessageSkills,
|
|
213
213
|
sessionKind: "structured",
|
|
@@ -259,7 +259,7 @@ function buildIncrementalStructuredPayload(snapshot, cardDefaults) {
|
|
|
259
259
|
const messages = snapshot.messages ?? [];
|
|
260
260
|
// Derive semantics from the complete current turn before taking the
|
|
261
261
|
// incremental tail; TaskCreate results can live in an earlier provider frame.
|
|
262
|
-
const clientMessages = enrichStructuredMessages(messages);
|
|
262
|
+
const clientMessages = enrichStructuredMessages(messages, snapshot.id);
|
|
263
263
|
const lastTurn = clientMessages.length > 0 ? clientMessages[clientMessages.length - 1] : undefined;
|
|
264
264
|
// Streaming turn (index 0 here) is preserved verbatim; truncation only kicks
|
|
265
265
|
// in if the live response is already bigger than the transport threshold,
|
|
@@ -678,7 +678,13 @@ export class StructuredSessionManager {
|
|
|
678
678
|
continue;
|
|
679
679
|
}
|
|
680
680
|
try {
|
|
681
|
-
|
|
681
|
+
// listRuns is metadata-only on Render v2. Fetch the authoritative
|
|
682
|
+
// paged replay from the owner before feeding any provider reducer.
|
|
683
|
+
const attached = await this.execHost.attachRun(state.runId);
|
|
684
|
+
if (!attached || attached.incarnationId !== state.incarnationId) {
|
|
685
|
+
throw new Error("Structured run changed owner between inventory and replay");
|
|
686
|
+
}
|
|
687
|
+
await this.resumeDetachedRun(session, attached);
|
|
682
688
|
}
|
|
683
689
|
catch (error) {
|
|
684
690
|
console.error(`[WAND] structured run recovery failed for ${sessionId}:`, error);
|
|
@@ -1497,6 +1503,24 @@ export class StructuredSessionManager {
|
|
|
1497
1503
|
this.emitStructuredSnapshot(updated);
|
|
1498
1504
|
return updated;
|
|
1499
1505
|
}
|
|
1506
|
+
/** Edit only the pending item still identified by both index and original text. */
|
|
1507
|
+
editQueuedMessage(sessionId, index, expectedText, text) {
|
|
1508
|
+
const session = this.requireSession(sessionId);
|
|
1509
|
+
const queue = session.queuedMessages ?? [];
|
|
1510
|
+
if (!Number.isInteger(index) || index < 0 || index >= queue.length || queue[index] !== expectedText) {
|
|
1511
|
+
throw new Error("排队消息已变化,请刷新后重试。");
|
|
1512
|
+
}
|
|
1513
|
+
const trimmed = text.trim();
|
|
1514
|
+
if (!trimmed)
|
|
1515
|
+
throw new Error("排队消息不能为空。");
|
|
1516
|
+
const next = queue.slice();
|
|
1517
|
+
next[index] = trimmed;
|
|
1518
|
+
const updated = { ...session, queuedMessages: next };
|
|
1519
|
+
this.sessions.set(sessionId, updated);
|
|
1520
|
+
this.storage.updateSessionRuntimeMetadata(updated);
|
|
1521
|
+
this.emitStructuredSnapshot(updated);
|
|
1522
|
+
return updated;
|
|
1523
|
+
}
|
|
1500
1524
|
/** Remove a single queued message only while index and text still identify the same item. */
|
|
1501
1525
|
deleteQueuedMessage(sessionId, index, expectedText) {
|
|
1502
1526
|
const session = this.requireSession(sessionId);
|
package/dist/task-types.d.ts
CHANGED
|
@@ -120,6 +120,8 @@ export interface WandTask {
|
|
|
120
120
|
workspaceId: string | null;
|
|
121
121
|
/** 关联的侧栏工作任务;归档/新建时用来对账,不随 WorkspaceTask 删除而消失。 */
|
|
122
122
|
workspaceTaskId: string | null;
|
|
123
|
+
/** 认领到处理中的父任务;子任务仍有独立 Agent 会话。 */
|
|
124
|
+
parentTaskId: string | null;
|
|
123
125
|
identifier: string;
|
|
124
126
|
title: string;
|
|
125
127
|
/** '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()))
|
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;
|
package/dist/terminal-host.js
CHANGED
package/dist/tui/ipc-server.js
CHANGED
|
@@ -8,6 +8,7 @@ import { chmodSync, chownSync, existsSync, statSync, unlinkSync } from "node:fs"
|
|
|
8
8
|
import { StringDecoder } from "node:string_decoder";
|
|
9
9
|
import path from "node:path";
|
|
10
10
|
import { getErrorMessage } from "../error-utils.js";
|
|
11
|
+
import { keepUnixSocketAlive } from "../unix-socket-keepalive.js";
|
|
11
12
|
export function startIpcServer(deps) {
|
|
12
13
|
if (!deps.socketPath)
|
|
13
14
|
return null;
|
|
@@ -86,11 +87,22 @@ export function startIpcServer(deps) {
|
|
|
86
87
|
// 不要崩进程;attach 模式不可用也不影响主功能。
|
|
87
88
|
process.stderr.write(`[wand] IPC server error: ${err.message}\n`);
|
|
88
89
|
});
|
|
90
|
+
let stopSocketKeepalive = () => { };
|
|
91
|
+
let closed = false;
|
|
89
92
|
server.listen(deps.socketPath, () => {
|
|
93
|
+
if (closed) {
|
|
94
|
+
server.close();
|
|
95
|
+
return;
|
|
96
|
+
}
|
|
90
97
|
applySocketOwnership(deps.socketPath);
|
|
98
|
+
stopSocketKeepalive = keepUnixSocketAlive(server, deps.socketPath, () => {
|
|
99
|
+
applySocketOwnership(deps.socketPath);
|
|
100
|
+
});
|
|
91
101
|
});
|
|
92
102
|
return {
|
|
93
103
|
close: async () => {
|
|
104
|
+
closed = true;
|
|
105
|
+
stopSocketKeepalive();
|
|
94
106
|
await new Promise((resolve) => {
|
|
95
107
|
server.close(() => resolve());
|
|
96
108
|
// 兜底:1s 内强制 resolve
|
package/dist/types.d.ts
CHANGED
|
@@ -12,7 +12,7 @@ type EscalationResolution = "approve_once" | "approve_turn" | "deny" | "fallback
|
|
|
12
12
|
type EscalationSource = "tool_permission_request" | "sandbox_hard_block" | "workspace_policy_limit" | "cli_capability_limit" | "unknown";
|
|
13
13
|
/** WebSocket / ProcessManager event envelope used throughout the app. */
|
|
14
14
|
export interface ProcessEvent {
|
|
15
|
-
type: "output" | "status" | "started" | "ended" | "usage" | "task" | "notification";
|
|
15
|
+
type: "output" | "status" | "started" | "ended" | "usage" | "task" | "notification" | "resync";
|
|
16
16
|
sessionId: string;
|
|
17
17
|
data?: unknown;
|
|
18
18
|
/** Monotonic per-session sequence stamped by the WS broadcast layer for
|
|
@@ -65,6 +65,20 @@ export interface AndroidApkConfig {
|
|
|
65
65
|
apkDir?: string;
|
|
66
66
|
currentApkFile?: string;
|
|
67
67
|
}
|
|
68
|
+
/**
|
|
69
|
+
* PTY 归属引擎。`auto` 优先 Rust Render(找到了二进制才用),`rust` 强制 Rust,
|
|
70
|
+
* `legacy` 保留现有 terminald 行为。仅切换所有权,DB/客户端契约不变。
|
|
71
|
+
*/
|
|
72
|
+
export type RenderEngine = "auto" | "rust" | "legacy";
|
|
73
|
+
export interface RenderConfig {
|
|
74
|
+
engine: RenderEngine;
|
|
75
|
+
/** 显式指定 wand-render 可执行文件;留空则按约定顺序自动查找。 */
|
|
76
|
+
binaryPath?: string;
|
|
77
|
+
}
|
|
78
|
+
/** Deployment-only owner for new structured CLI runs. Existing runs follow daemon inventory. */
|
|
79
|
+
export interface StructuredProcessConfig {
|
|
80
|
+
processHost: "legacy" | "rust";
|
|
81
|
+
}
|
|
68
82
|
export interface MacosDmgConfig {
|
|
69
83
|
enabled?: boolean;
|
|
70
84
|
dmgDir?: string;
|
|
@@ -123,6 +137,10 @@ export interface WandConfig {
|
|
|
123
137
|
android?: AndroidApkConfig;
|
|
124
138
|
macos?: MacosDmgConfig;
|
|
125
139
|
ios?: IosIpaConfig;
|
|
140
|
+
/** Render(常驻 PTY 持有者)引擎选择。默认 auto。 */
|
|
141
|
+
render?: RenderConfig;
|
|
142
|
+
/** Structured CLI process owner for new runs; default legacy. SDK stays in Node. */
|
|
143
|
+
structured?: StructuredProcessConfig;
|
|
126
144
|
/** Default expand/collapse state for card types in structured chat view */
|
|
127
145
|
cardDefaults?: CardExpandDefaults;
|
|
128
146
|
/** 新建会话时默认使用的 Claude 模型(别名或完整 ID)。留空则不传 --model,由 claude 自行决定。 */
|