@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.
Files changed (101) hide show
  1. package/dist/build-info.json +4 -4
  2. package/dist/claude-sdk-runner.d.ts +2 -3
  3. package/dist/config.d.ts +1 -1
  4. package/dist/config.js +48 -0
  5. package/dist/daemon-connection.d.ts +9 -0
  6. package/dist/daemon-connection.js +86 -0
  7. package/dist/distribution-manager.d.ts +5 -0
  8. package/dist/distribution-manager.js +79 -1
  9. package/dist/git-quick-commit.js +12 -0
  10. package/dist/message-truncator.js +9 -3
  11. package/dist/models.d.ts +39 -2
  12. package/dist/models.js +302 -9
  13. package/dist/native/darwin-arm64/wand-render +0 -0
  14. package/dist/native/darwin-arm64/wand-render.sha256 +1 -0
  15. package/dist/native/darwin-arm64/wand-render.version +1 -0
  16. package/dist/native/darwin-arm64/wand-structured-renderd +0 -0
  17. package/dist/native/darwin-arm64/wand-structured-renderd.sha256 +1 -0
  18. package/dist/native/darwin-arm64/wand-structured-renderd.version +1 -0
  19. package/dist/native/darwin-x64/wand-render +0 -0
  20. package/dist/native/darwin-x64/wand-render.sha256 +1 -0
  21. package/dist/native/darwin-x64/wand-render.version +1 -0
  22. package/dist/native/darwin-x64/wand-structured-renderd +0 -0
  23. package/dist/native/darwin-x64/wand-structured-renderd.sha256 +1 -0
  24. package/dist/native/darwin-x64/wand-structured-renderd.version +1 -0
  25. package/dist/native/linux-arm64/wand-render +0 -0
  26. package/dist/native/linux-arm64/wand-render.sha256 +1 -0
  27. package/dist/native/linux-arm64/wand-render.version +1 -0
  28. package/dist/native/linux-arm64/wand-structured-renderd +0 -0
  29. package/dist/native/linux-arm64/wand-structured-renderd.sha256 +1 -0
  30. package/dist/native/linux-arm64/wand-structured-renderd.version +1 -0
  31. package/dist/native/linux-x64/wand-render +0 -0
  32. package/dist/native/linux-x64/wand-render.sha256 +1 -0
  33. package/dist/native/linux-x64/wand-render.version +1 -0
  34. package/dist/native/linux-x64/wand-structured-renderd +0 -0
  35. package/dist/native/linux-x64/wand-structured-renderd.sha256 +1 -0
  36. package/dist/native/linux-x64/wand-structured-renderd.version +1 -0
  37. package/dist/process-manager.d.ts +12 -1
  38. package/dist/process-manager.js +88 -7
  39. package/dist/pty-terminal-state.d.ts +17 -0
  40. package/dist/pty-terminal-state.js +51 -1
  41. package/dist/render-binary.d.ts +61 -0
  42. package/dist/render-binary.js +204 -0
  43. package/dist/render-daemon-client.d.ts +202 -0
  44. package/dist/render-daemon-client.js +1164 -0
  45. package/dist/render-host.d.ts +88 -0
  46. package/dist/render-host.js +440 -0
  47. package/dist/render-protocol.d.ts +128 -0
  48. package/dist/render-protocol.js +99 -0
  49. package/dist/render-structured-client.d.ts +80 -0
  50. package/dist/render-structured-client.js +513 -0
  51. package/dist/render-structured-host.d.ts +31 -0
  52. package/dist/render-structured-host.js +218 -0
  53. package/dist/render-structured-protocol.d.ts +101 -0
  54. package/dist/render-structured-protocol.js +35 -0
  55. package/dist/server-session-routes.js +114 -12
  56. package/dist/server-task-routes.js +55 -6
  57. package/dist/server-update-routes.d.ts +2 -0
  58. package/dist/server-update-routes.js +44 -0
  59. package/dist/server.js +43 -13
  60. package/dist/session-ai-context.d.ts +1 -1
  61. package/dist/session-ai-context.js +15 -4
  62. package/dist/session-logger.d.ts +2 -0
  63. package/dist/session-logger.js +44 -1
  64. package/dist/session-transport.js +1 -1
  65. package/dist/storage.d.ts +2 -1
  66. package/dist/storage.js +14 -5
  67. package/dist/structured-client-protocol.d.ts +3 -1
  68. package/dist/structured-client-protocol.js +85 -25
  69. package/dist/structured-content.d.ts +20 -3
  70. package/dist/structured-content.js +80 -1
  71. package/dist/structured-exec-host.js +7 -1
  72. package/dist/structured-exec-pump.js +6 -2
  73. package/dist/structured-grok-adapter.js +22 -0
  74. package/dist/structured-pi-adapter.js +18 -2
  75. package/dist/structured-provider-common.d.ts +11 -9
  76. package/dist/structured-provider-common.js +60 -18
  77. package/dist/structured-session-manager.d.ts +2 -0
  78. package/dist/structured-session-manager.js +27 -3
  79. package/dist/task-types.d.ts +4 -1
  80. package/dist/terminal-daemon-client.d.ts +9 -2
  81. package/dist/terminal-daemon-client.js +119 -46
  82. package/dist/terminal-daemon-server.js +47 -8
  83. package/dist/terminal-host.d.ts +6 -0
  84. package/dist/terminal-host.js +3 -0
  85. package/dist/tui/commands.js +183 -10
  86. package/dist/tui/ipc-server.js +12 -0
  87. package/dist/types.d.ts +30 -10
  88. package/dist/unix-socket-keepalive.d.ts +3 -0
  89. package/dist/unix-socket-keepalive.js +94 -0
  90. package/dist/wand-task-sync.js +2 -1
  91. package/dist/web-ui/content/scripts.js +79 -73
  92. package/dist/web-ui/content/styles.css +1 -1
  93. package/dist/web-ui/content/tailwind.css +21 -0
  94. package/dist/web-ui/embedded-assets.js +2 -2
  95. package/dist/web-ui/thinking-efforts.d.ts +24 -0
  96. package/dist/web-ui/thinking-efforts.js +81 -0
  97. package/dist/ws-broadcast.d.ts +1 -1
  98. package/dist/ws-broadcast.js +28 -9
  99. package/package.json +8 -3
  100. package/scripts/install-render-binary.js +499 -0
  101. package/scripts/stage-render-binaries.js +626 -0
@@ -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
- export type WandTaskAgentEffort = "off" | "standard" | "deep" | "max";
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 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()))
@@ -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.socketPath, paths.tokenPath, paths.pidPath);
496
+ cleanupDaemonFiles(paths, { token, pid: process.pid });
481
497
  };
482
498
  process.once("SIGINT", shutdown);
483
499
  process.once("SIGTERM", shutdown);
484
500
  }
485
- function cleanupDaemonFiles(socketPath, tokenPath, pidPath) {
486
- for (const file of [socketPath, tokenPath, pidPath]) {
487
- if (process.platform === "win32" && file === socketPath)
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());
@@ -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
  }