@co0ontty/wand 4.72.2-beta.gcc5d57e → 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.
Files changed (72) hide show
  1. package/dist/build-info.json +4 -4
  2. package/dist/config.js +20 -0
  3. package/dist/daemon-connection.d.ts +9 -0
  4. package/dist/daemon-connection.js +86 -0
  5. package/dist/distribution-manager.d.ts +5 -0
  6. package/dist/distribution-manager.js +79 -1
  7. package/dist/git-quick-commit.js +12 -0
  8. package/dist/message-truncator.js +6 -0
  9. package/dist/native/darwin-arm64/wand-render +0 -0
  10. package/dist/native/darwin-arm64/wand-render.sha256 +1 -0
  11. package/dist/native/darwin-arm64/wand-render.version +1 -0
  12. package/dist/native/darwin-x64/wand-render +0 -0
  13. package/dist/native/darwin-x64/wand-render.sha256 +1 -0
  14. package/dist/native/darwin-x64/wand-render.version +1 -0
  15. package/dist/native/linux-arm64/wand-render +0 -0
  16. package/dist/native/linux-arm64/wand-render.sha256 +1 -0
  17. package/dist/native/linux-arm64/wand-render.version +1 -0
  18. package/dist/native/linux-x64/wand-render +0 -0
  19. package/dist/native/linux-x64/wand-render.sha256 +1 -0
  20. package/dist/native/linux-x64/wand-render.version +1 -0
  21. package/dist/process-manager.d.ts +10 -0
  22. package/dist/process-manager.js +73 -4
  23. package/dist/render-binary.d.ts +61 -0
  24. package/dist/render-binary.js +204 -0
  25. package/dist/render-daemon-client.d.ts +171 -0
  26. package/dist/render-daemon-client.js +1114 -0
  27. package/dist/render-host.d.ts +64 -0
  28. package/dist/render-host.js +301 -0
  29. package/dist/render-protocol.d.ts +128 -0
  30. package/dist/render-protocol.js +99 -0
  31. package/dist/render-structured-client.d.ts +80 -0
  32. package/dist/render-structured-client.js +513 -0
  33. package/dist/render-structured-host.d.ts +31 -0
  34. package/dist/render-structured-host.js +218 -0
  35. package/dist/render-structured-protocol.d.ts +101 -0
  36. package/dist/render-structured-protocol.js +35 -0
  37. package/dist/server-session-routes.js +72 -9
  38. package/dist/server-task-routes.js +52 -3
  39. package/dist/server-update-routes.d.ts +2 -0
  40. package/dist/server-update-routes.js +44 -0
  41. package/dist/server.js +31 -9
  42. package/dist/session-transport.js +1 -1
  43. package/dist/storage.d.ts +2 -1
  44. package/dist/storage.js +14 -5
  45. package/dist/structured-client-protocol.d.ts +3 -1
  46. package/dist/structured-client-protocol.js +85 -25
  47. package/dist/structured-content.d.ts +20 -3
  48. package/dist/structured-content.js +80 -1
  49. package/dist/structured-exec-host.js +7 -1
  50. package/dist/structured-exec-pump.js +6 -2
  51. package/dist/structured-pi-adapter.js +18 -2
  52. package/dist/structured-session-manager.d.ts +2 -0
  53. package/dist/structured-session-manager.js +27 -3
  54. package/dist/task-types.d.ts +2 -0
  55. package/dist/terminal-daemon-client.d.ts +9 -2
  56. package/dist/terminal-daemon-client.js +119 -46
  57. package/dist/terminal-daemon-server.js +20 -4
  58. package/dist/terminal-host.d.ts +6 -0
  59. package/dist/terminal-host.js +3 -0
  60. package/dist/tui/ipc-server.js +12 -0
  61. package/dist/types.d.ts +19 -1
  62. package/dist/unix-socket-keepalive.d.ts +3 -0
  63. package/dist/unix-socket-keepalive.js +94 -0
  64. package/dist/web-ui/content/scripts.js +68 -68
  65. package/dist/web-ui/content/styles.css +1 -1
  66. package/dist/web-ui/content/tailwind.css +12 -0
  67. package/dist/web-ui/embedded-assets.js +2 -2
  68. package/dist/ws-broadcast.d.ts +1 -1
  69. package/dist/ws-broadcast.js +17 -5
  70. package/package.json +8 -3
  71. package/scripts/install-render-binary.js +499 -0
  72. 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
- await this.resumeDetachedRun(session, state);
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);
@@ -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 readonly token;
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
- constructor(socketPath: string, token: string);
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 = Math.max(this.lastStdoutSeq, event.seq);
140
+ this.lastStdoutSeq = event.seq;
134
141
  else
135
- this.lastStderrSeq = Math.max(this.lastStderrSeq, event.seq);
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: ++this.syntheticSeq });
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: ++this.syntheticSeq });
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
- constructor(socketPath, token) {
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
- async connect() {
221
+ connect() {
210
222
  if (this.disposed)
211
- throw new Error("Terminal daemon client disposed");
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
- await new Promise((resolve, reject) => {
218
- const onConnect = () => { cleanup(); resolve(); };
219
- const onError = (error) => { cleanup(); reject(error); };
220
- const cleanup = () => {
221
- socket.off("connect", onConnect);
222
- socket.off("error", onError);
223
- };
224
- socket.once("connect", onConnect);
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) => this.consume(this.decoder.write(data)));
232
- socket.on("close", () => this.handleDisconnect());
233
- socket.on("error", (error) => {
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
- this.structuredInventory.set(runId, payload.state);
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
- return Array.isArray(result) ? result : [];
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
- handleFromState(state, reuse) {
350
- this.structuredInventory.set(state.runId, state);
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
- // Events can arrive (and the child can even exit) before this RPC returns.
362
- // Always drain the backlog onto THIS handle. If the run already finished,
363
- // also replay daemon logs and synthesize exit so a fast Codex/Pi turn is
364
- // not lost just because listeners were not attached yet.
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?.destroyed)
413
- this.socket = null;
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
- socket.removeAllListeners();
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
- if (existing && existing.status === "running")
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
- if (wantsStdin)
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()))
@@ -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;
@@ -17,6 +17,9 @@ class InProcessTerminalProcess {
17
17
  write(data) {
18
18
  this.child.write(data);
19
19
  }
20
+ async writeConfirmed(data) {
21
+ this.child.write(data);
22
+ }
20
23
  resize(cols, rows) {
21
24
  this.child.resize(cols, rows);
22
25
  }
@@ -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 自行决定。 */
@@ -0,0 +1,3 @@
1
+ import type net from "node:net";
2
+ /** Restore only a missing endpoint; existing connections and their owners stay alive. */
3
+ export declare function keepUnixSocketAlive(server: net.Server, socketPath: string, onListening: () => void, intervalMs?: number): () => void;