@co0ontty/wand 4.72.0 → 4.72.1

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.
@@ -1,6 +1,6 @@
1
1
  {
2
- "commit": "fc5673635615d567540a2a3e636041a4a3422cad",
3
- "builtAt": "2026-09-21T22:45:17.210Z",
4
- "version": "4.72.0",
2
+ "commit": "78b791818f725d606571f7c36f245a85388b19a5",
3
+ "builtAt": "2026-09-22T01:56:32.828Z",
4
+ "version": "4.72.1",
5
5
  "channel": "stable"
6
6
  }
@@ -109,8 +109,6 @@ export declare class ProcessManager extends EventEmitter {
109
109
  sendInput(id: string, input: string, view?: "chat" | "terminal", shortcutKey?: string, trackUserInput?: boolean): SessionSnapshot;
110
110
  resize(id: string, cols: number, rows: number): SessionSnapshot;
111
111
  getTerminalState(id: string): PtyTerminalSnapshot | null;
112
- pauseOutput(id: string): void;
113
- resumeOutput(id: string): void;
114
112
  /**
115
113
  * Finalize provider-only state while leaving the owning PTY shell alive. The
116
114
  * session itself remains running so terminal input can continue normally.
@@ -1553,16 +1553,6 @@ export class ProcessManager extends EventEmitter {
1553
1553
  }
1554
1554
  return record.terminalState.snapshot();
1555
1555
  }
1556
- pauseOutput(id) {
1557
- const record = this.sessions.get(id);
1558
- if (record?.ptyProcess && record.status === "running")
1559
- record.ptyProcess.pause();
1560
- }
1561
- resumeOutput(id) {
1562
- const record = this.sessions.get(id);
1563
- if (record?.ptyProcess && record.status === "running")
1564
- record.ptyProcess.resume();
1565
- }
1566
1556
  /**
1567
1557
  * Finalize provider-only state while leaving the owning PTY shell alive. The
1568
1558
  * session itself remains running so terminal input can continue normally.
@@ -22,8 +22,11 @@ export declare class PtyTerminalState {
22
22
  private readonly serializer;
23
23
  private readonly unicode;
24
24
  private pending;
25
+ private buffered;
25
26
  private tail;
27
+ private flushTimer?;
26
28
  private checkpointTimer?;
29
+ private checkpointQueued;
27
30
  private nextId;
28
31
  private committedData;
29
32
  private committedCols;
@@ -35,5 +38,9 @@ export declare class PtyTerminalState {
35
38
  snapshot(): PtyTerminalSnapshot;
36
39
  dispose(): void;
37
40
  private addPending;
41
+ private scheduleFlush;
42
+ /** Publish buffered bytes as one pending data operation and queue one write. */
43
+ private flushBuffered;
38
44
  private scheduleCheckpoint;
45
+ private commitCheckpoint;
39
46
  }
@@ -2,6 +2,49 @@ import headless from "@xterm/headless";
2
2
  import { SerializeAddon } from "@xterm/addon-serialize";
3
3
  import { Unicode11Addon } from "@xterm/addon-unicode11";
4
4
  const { Terminal } = headless;
5
+ /**
6
+ * Incoming PTY chunks are coalesced into one xterm write per flush. xterm's
7
+ * write callback costs a timer turn per call, so one write per node-pty chunk
8
+ * caps a session at roughly 1 MB/s of mirrored output; batching removes that
9
+ * ceiling without changing what a snapshot contains (the delay below is far
10
+ * shorter than the client's own output debounce).
11
+ */
12
+ const WRITE_BATCH_MAX_CHARS = 64 * 1024;
13
+ const WRITE_BATCH_DELAY_MS = 16;
14
+ /**
15
+ * Re-serializing the screen costs ~20ms and allocates a few hundred KB, so a
16
+ * checkpoint must not run on a fixed cadence while a session streams. Writes
17
+ * pause between turns, which is when a fresh baseline is actually useful; the
18
+ * size bounds only exist so a session that never pauses stays bounded.
19
+ */
20
+ const CHECKPOINT_QUIET_MS = 100;
21
+ const CHECKPOINT_PENDING_MAX_CHARS = 256 * 1024;
22
+ const CHECKPOINT_PENDING_MAX_OPS = 1024;
23
+ function pendingChars(pending) {
24
+ let chars = 0;
25
+ for (const entry of pending) {
26
+ if (entry.operation.type === "data")
27
+ chars += entry.operation.data.length;
28
+ }
29
+ return chars;
30
+ }
31
+ /**
32
+ * Merge adjacent data operations so a snapshot exposes as few replay steps as
33
+ * possible: clients write pending operations one awaited call at a time, and a
34
+ * session that streamed without pausing can hold hundreds of them.
35
+ */
36
+ function coalesceOperations(pending) {
37
+ const operations = [];
38
+ for (const { operation } of pending) {
39
+ const last = operations.at(-1);
40
+ if (operation.type === "data" && last?.type === "data") {
41
+ operations[operations.length - 1] = { type: "data", data: last.data + operation.data };
42
+ continue;
43
+ }
44
+ operations.push({ ...operation });
45
+ }
46
+ return operations;
47
+ }
5
48
  /**
6
49
  * Server-side xterm screen model used only for reconnect snapshots. Live PTY
7
50
  * bytes still travel unchanged from node-pty to the browser terminal.
@@ -11,8 +54,11 @@ export class PtyTerminalState {
11
54
  serializer = new SerializeAddon();
12
55
  unicode = new Unicode11Addon();
13
56
  pending = [];
57
+ buffered = "";
14
58
  tail = Promise.resolve();
59
+ flushTimer;
15
60
  checkpointTimer;
61
+ checkpointQueued = false;
16
62
  nextId = 1;
17
63
  committedData = "";
18
64
  committedCols;
@@ -31,63 +77,134 @@ export class PtyTerminalState {
31
77
  write(data) {
32
78
  if (this.disposed || !data)
33
79
  return;
34
- const pending = this.addPending({ type: "data", data });
35
- this.tail = this.tail.then(() => new Promise((resolve) => {
36
- if (this.disposed)
37
- return resolve();
38
- this.terminal.write(data, () => resolve());
39
- }));
40
- this.scheduleCheckpoint(pending.id);
80
+ this.buffered += data;
81
+ if (this.buffered.length >= WRITE_BATCH_MAX_CHARS)
82
+ this.flushBuffered();
83
+ else
84
+ this.scheduleFlush();
41
85
  }
42
86
  resize(cols, rows) {
43
87
  if (this.disposed)
44
88
  return;
45
- const pending = this.addPending({ type: "resize", cols, rows });
89
+ // Bytes that arrived before the resize must land before it.
90
+ this.flushBuffered();
91
+ this.addPending({ type: "resize", cols, rows });
46
92
  this.tail = this.tail.then(() => {
47
93
  if (!this.disposed)
48
94
  this.terminal.resize(cols, rows);
49
95
  });
50
- this.scheduleCheckpoint(pending.id);
96
+ this.scheduleCheckpoint();
51
97
  }
52
98
  snapshot() {
99
+ // Unflushed bytes would be invisible to a snapshot, so publish them as
100
+ // pending operations the client replays after `data`.
101
+ this.flushBuffered();
53
102
  return {
54
103
  version: 1,
55
104
  data: this.committedData,
56
105
  cols: this.committedCols,
57
106
  rows: this.committedRows,
58
- pending: this.pending.map(({ operation }) => ({ ...operation })),
107
+ pending: coalesceOperations(this.pending),
59
108
  };
60
109
  }
61
110
  dispose() {
62
111
  if (this.disposed)
63
112
  return;
64
113
  this.disposed = true;
114
+ if (this.flushTimer)
115
+ clearTimeout(this.flushTimer);
116
+ this.flushTimer = undefined;
65
117
  if (this.checkpointTimer)
66
118
  clearTimeout(this.checkpointTimer);
67
119
  this.checkpointTimer = undefined;
120
+ this.buffered = "";
68
121
  this.pending = [];
69
122
  this.terminal.dispose();
70
123
  }
71
124
  addPending(operation) {
72
- const pending = { id: this.nextId++, operation };
73
- this.pending.push(pending);
74
- return pending;
125
+ this.pending.push({ id: this.nextId++, operation });
75
126
  }
76
- scheduleCheckpoint(_operationId) {
77
- if (this.checkpointTimer || this.disposed)
127
+ scheduleFlush() {
128
+ if (this.flushTimer || this.disposed)
78
129
  return;
130
+ this.flushTimer = setTimeout(() => {
131
+ this.flushTimer = undefined;
132
+ this.flushBuffered();
133
+ }, WRITE_BATCH_DELAY_MS);
134
+ this.flushTimer.unref?.();
135
+ }
136
+ /** Publish buffered bytes as one pending data operation and queue one write. */
137
+ flushBuffered() {
138
+ if (this.flushTimer) {
139
+ clearTimeout(this.flushTimer);
140
+ this.flushTimer = undefined;
141
+ }
142
+ const data = this.buffered;
143
+ if (!data)
144
+ return;
145
+ this.buffered = "";
146
+ this.addPending({ type: "data", data });
147
+ this.tail = this.tail.then(() => new Promise((resolve) => {
148
+ if (this.disposed)
149
+ return resolve();
150
+ try {
151
+ this.terminal.write(data, () => resolve());
152
+ }
153
+ catch {
154
+ // A throwing write must not poison the ordering chain: every later
155
+ // chunk would then be dropped without any signal.
156
+ resolve();
157
+ }
158
+ }));
159
+ this.scheduleCheckpoint();
160
+ }
161
+ scheduleCheckpoint() {
162
+ if (this.disposed || this.checkpointQueued)
163
+ return;
164
+ if (pendingChars(this.pending) >= CHECKPOINT_PENDING_MAX_CHARS
165
+ || this.pending.length >= CHECKPOINT_PENDING_MAX_OPS) {
166
+ this.commitCheckpoint();
167
+ return;
168
+ }
169
+ // Quiet-period trigger: one checkpoint after a streaming burst ends,
170
+ // instead of one every 100ms while it runs.
171
+ if (this.checkpointTimer)
172
+ clearTimeout(this.checkpointTimer);
79
173
  this.checkpointTimer = setTimeout(() => {
80
174
  this.checkpointTimer = undefined;
81
- const cutoff = this.pending.at(-1)?.id ?? 0;
82
- this.tail = this.tail.then(() => {
83
- if (this.disposed)
84
- return;
175
+ this.commitCheckpoint();
176
+ }, CHECKPOINT_QUIET_MS);
177
+ this.checkpointTimer.unref?.();
178
+ }
179
+ commitCheckpoint() {
180
+ if (this.disposed || this.checkpointQueued)
181
+ return;
182
+ if (this.checkpointTimer) {
183
+ clearTimeout(this.checkpointTimer);
184
+ this.checkpointTimer = undefined;
185
+ }
186
+ const cutoff = this.pending.at(-1)?.id ?? 0;
187
+ if (!cutoff)
188
+ return;
189
+ this.checkpointQueued = true;
190
+ this.tail = this.tail.then(() => {
191
+ if (this.disposed)
192
+ return;
193
+ try {
85
194
  this.committedData = this.serializer.serialize({ scrollback: 5000 });
86
195
  this.committedCols = this.terminal.cols;
87
196
  this.committedRows = this.terminal.rows;
88
197
  this.pending = this.pending.filter((entry) => entry.id > cutoff);
89
- });
90
- }, 100);
91
- this.checkpointTimer.unref?.();
198
+ }
199
+ catch {
200
+ // Keep the mirror alive; the next checkpoint re-serializes.
201
+ }
202
+ }).then(() => {
203
+ this.checkpointQueued = false;
204
+ // Operations that arrived while this checkpoint drained still need a
205
+ // baseline of their own once the writer goes quiet again.
206
+ if (this.pending.length > 0)
207
+ this.scheduleCheckpoint();
208
+ });
92
209
  }
93
210
  }
package/dist/server.js CHANGED
@@ -815,8 +815,6 @@ export async function startServer(config, configPath, options = {}) {
815
815
  resizePty: (id, cols, rows) => {
816
816
  processes.resize(id, cols, rows);
817
817
  },
818
- pausePtyOutput: (id) => processes.pauseOutput(id),
819
- resumePtyOutput: (id) => processes.resumeOutput(id),
820
818
  });
821
819
  disconnectAuthenticatedSockets = () => wsManager.disconnectAll();
822
820
  wss.on("error", (err) => {
@@ -231,6 +231,16 @@ export declare class StructuredSessionManager {
231
231
  private runClaudeSdkStreaming;
232
232
  private compactContentBlocks;
233
233
  private buildCompletedAssistantMessages;
234
+ /**
235
+ * Rebuild the trailing assistant turn when the replayed log was truncated.
236
+ *
237
+ * 重启后在 daemon 里继续跑的 CLI,它的 stdout 只保留最后
238
+ * STRUCTURED_RUN_LOG_MAX_CHARS 字符,所以 replay 出来的 turn 是「半截尾巴」:
239
+ * 直接覆盖会丢掉重启前用户已经看到的全部输出。这里用与流式期间同一个
240
+ * TruncatedReplayView(本地已存 turn + replay 新增段),保证收尾视图和前端
241
+ * 中途看到的完全一致。
242
+ */
243
+ private mergeTruncatedReplayTurn;
234
244
  private resolveQueuedMessagesAfterInterrupt;
235
245
  private resolveQueuedMessageSkillsAfterInterrupt;
236
246
  private normalizeToolResultContent;
@@ -279,6 +279,84 @@ function buildIncrementalStructuredPayload(snapshot, cardDefaults) {
279
279
  function isoNow() {
280
280
  return new Date().toISOString();
281
281
  }
282
+ /**
283
+ * Identity of one content block inside an assistant turn. Tool ids are copied
284
+ * verbatim from the CLI stream and never mutate, so they are the anchors used to
285
+ * align a replayed turn with the transcript we already stored.
286
+ */
287
+ function replayBlockAnchor(block) {
288
+ switch (block.type) {
289
+ case "tool_use": return `tool_use:${block.id}`;
290
+ case "tool_result": return `tool_result:${block.tool_use_id}`;
291
+ case "thinking": return `thinking:${block.thinking}`;
292
+ case "text": return `text:${block.text}`;
293
+ default: return null;
294
+ }
295
+ }
296
+ /**
297
+ * Index of the last replayed block the stored turn already contains, or -1 when
298
+ * no overlap can be proven.
299
+ *
300
+ * Needed because a daemon replay log can start mid-stream
301
+ * (STRUCTURED_RUN_LOG_MAX_CHARS), so the first replayed blocks usually repeat the
302
+ * tail of the turn we accumulated live before the restart.
303
+ */
304
+ function lastStoredAnchorIndex(stored, replayed) {
305
+ if (!stored || stored.length === 0)
306
+ return -1;
307
+ const known = new Set();
308
+ for (const block of stored) {
309
+ const anchor = replayBlockAnchor(block);
310
+ if (anchor !== null)
311
+ known.add(anchor);
312
+ }
313
+ let cut = -1;
314
+ for (let index = 0; index < replayed.length; index += 1) {
315
+ const anchor = replayBlockAnchor(replayed[index]);
316
+ if (anchor !== null && known.has(anchor))
317
+ cut = index;
318
+ }
319
+ return cut;
320
+ }
321
+ /**
322
+ * 截断重放期间「本地已存 turn + replay 新增段」的合并视图。
323
+ *
324
+ * 重启后在 terminald 里继续跑的 CLI 只保留最后 STRUCTURED_RUN_LOG_MAX_CHARS 字符的
325
+ * stdout,replay 出来的是「半截尾巴」:直接用它覆盖会删掉重启前用户已经看到的输出,
326
+ * 完全不用它又会让前端冻结到本轮结束。所以把 checkpoint 里的 turn 当固定底,只把
327
+ * replay 里能证明是新增的那段拼在后面。cut 只算一次,视图随 replay 增长而增长
328
+ * (保持流式),且重复调用幂等(不会重复追加、也不会丢底)。
329
+ */
330
+ class TruncatedReplayView {
331
+ base;
332
+ cut = -1;
333
+ primed = false;
334
+ constructor(messages) {
335
+ const last = messages && messages.length > 0 ? messages[messages.length - 1] : undefined;
336
+ this.base = last && last.role === "assistant" ? last.content : null;
337
+ }
338
+ get ready() {
339
+ return this.primed;
340
+ }
341
+ /** 必须在 replay 首轮 feed 完成后再定锚点:feed 途中的半截 blocks 会把重叠判错。 */
342
+ prime(replayed) {
343
+ this.cut = lastStoredAnchorIndex(this.base, replayed);
344
+ this.primed = true;
345
+ }
346
+ content(replayed) {
347
+ if (!this.base || this.base.length === 0)
348
+ return replayed;
349
+ // 证明不了重叠就只信本地已存内容(宁可暂不追加,也不要重复整段)。
350
+ if (!this.primed || this.cut === -1)
351
+ return this.base;
352
+ const extra = replayed.slice(this.cut + 1);
353
+ return extra.length === 0 ? this.base : [...this.base, ...extra];
354
+ }
355
+ }
356
+ /** Append a standalone notice turn without touching the previous assistant turn. */
357
+ function appendNoticeTurn(messages, turn) {
358
+ return [...(messages ?? []), { ...turn, createdAt: isoNow(), completedAt: isoNow() }];
359
+ }
282
360
  function upsertAssistantMessage(messages, turn, complete = false) {
283
361
  const msgs = [...(messages ?? [])];
284
362
  const last = msgs[msgs.length - 1];
@@ -643,12 +721,54 @@ export class StructuredSessionManager {
643
721
  this.saveAuthoritativeSession(resumed);
644
722
  this.emitStructuredSnapshot(resumed);
645
723
  process.stderr.write(`[wand] resuming structured run for session ${sessionId} (daemon pid ${initialState.pid})\n`);
724
+ this.logger?.appendStructuredSpawn(sessionId, {
725
+ kind: `${resumed.runner ?? "structured"}-recovered`,
726
+ provider: resumed.provider,
727
+ pid: initialState.pid,
728
+ cwd: resumed.cwd,
729
+ status: initialState.status,
730
+ exitCode: initialState.exitCode,
731
+ stdoutTruncated: initialState.stdoutTruncated,
732
+ recoveredAt: new Date().toISOString(),
733
+ });
646
734
  const processor = buildReplayProcessor(resumed);
647
735
  let emitTimer = null;
736
+ // 日志被截断时 reducer 只看到半截历史,重建出的 turn 远比本地已存的少;
737
+ // 不能用它覆盖 messages/output,也不能就此冻结前端。
738
+ const replayTruncated = initialState.stdoutTruncated;
739
+ const replayView = new TruncatedReplayView(resumed.messages);
648
740
  const syncTurn = (turnState) => {
649
741
  const current = this.currentSessionForRequest(sessionId, requestId);
650
742
  if (!current)
651
743
  return;
744
+ const structuredState = {
745
+ ...current.structuredState,
746
+ model: turnState.model ?? current.structuredState?.model,
747
+ phase: turnState.phase ?? current.structuredState?.phase,
748
+ };
749
+ if (replayTruncated) {
750
+ // 锚点未定前(首轮 feed 中)只有半截 blocks,先只落元数据。
751
+ if (!replayView.ready) {
752
+ const patched = { ...current, structuredState };
753
+ this.sessions.set(sessionId, patched);
754
+ this.saveStreamingSnapshot(patched, { metadata: true });
755
+ return;
756
+ }
757
+ // 已存底 + replay 新增段:前端继续流式拿到增长中的 turn,库里也不会丢底。
758
+ const merged = {
759
+ role: "assistant",
760
+ content: this.compactContentBlocks(replayView.content([...turnState.blocks]), turnState.result),
761
+ usage: turnState.usage,
762
+ };
763
+ const patched = {
764
+ ...current,
765
+ messages: upsertAssistantMessage(current.messages, merged),
766
+ structuredState,
767
+ };
768
+ this.sessions.set(sessionId, patched);
769
+ this.saveStreamingSnapshot(patched);
770
+ return;
771
+ }
652
772
  const turn = {
653
773
  role: "assistant",
654
774
  content: this.compactContentBlocks([...turnState.blocks], turnState.result),
@@ -660,11 +780,7 @@ export class StructuredSessionManager {
660
780
  claudeSessionId: turnState.sessionId ?? current.claudeSessionId,
661
781
  messages,
662
782
  output: turnState.result || current.output,
663
- structuredState: {
664
- ...current.structuredState,
665
- model: turnState.model ?? current.structuredState?.model,
666
- phase: turnState.phase ?? current.structuredState?.phase,
667
- },
783
+ structuredState,
668
784
  };
669
785
  this.sessions.set(sessionId, patched);
670
786
  this.saveStreamingSnapshot(patched);
@@ -716,7 +832,8 @@ export class StructuredSessionManager {
716
832
  };
717
833
  if (initialState.status === "exited") {
718
834
  feedFullLog();
719
- this.finalizeRecoveredRun(sessionId, requestId, processor, {
835
+ replayView.prime([...processor.state.blocks]);
836
+ this.finalizeRecoveredRun(sessionId, requestId, processor, replayView, {
720
837
  exitCode: initialState.exitCode,
721
838
  signal: initialState.signal === null ? null : signalNameFromNumber(initialState.signal),
722
839
  stderr: initialState.stderrLog,
@@ -736,7 +853,7 @@ export class StructuredSessionManager {
736
853
  runningHandle = await this.execHost.adoptRun(structuredRunId(sessionId));
737
854
  if (!runningHandle) {
738
855
  // Run vanished between listing and adoption; fall back to failure notes.
739
- this.finalizeRecoveredRun(sessionId, requestId, processor, {
856
+ this.finalizeRecoveredRun(sessionId, requestId, processor, replayView, {
740
857
  exitCode: null,
741
858
  signal: null,
742
859
  stderr: initialState.stderrLog,
@@ -746,6 +863,8 @@ export class StructuredSessionManager {
746
863
  flushEmit();
747
864
  return;
748
865
  }
866
+ // 首轮 feed + attach 完成后再定锚点,之后 onStream 的事件都走合并视图。
867
+ replayView.prime([...processor.state.blocks]);
749
868
  runningHandle.onStream((event) => {
750
869
  // Events may be buffered while structuredAttach is in flight. Sequence
751
870
  // watermarks distinguish overlap with the snapshot from genuinely new
@@ -754,19 +873,23 @@ export class StructuredSessionManager {
754
873
  if (event.seq <= lastStdoutSeq)
755
874
  return;
756
875
  lastStdoutSeq = event.seq;
876
+ // 重启后 daemon 继续吐出的输出也要落回会话制品,否则会话日志在重启点断掉,
877
+ // 事后无从核对这一轮到底产出了什么。
878
+ this.logger?.appendStructuredStdout(sessionId, event.data);
757
879
  feedDelta(event.data);
758
880
  return;
759
881
  }
760
882
  if (event.seq <= lastStderrSeq)
761
883
  return;
762
884
  lastStderrSeq = event.seq;
885
+ this.logger?.appendStructuredStderr(sessionId, event.data);
763
886
  processor.stderr += event.data;
764
887
  });
765
888
  runningHandle.onExit((event) => {
766
889
  if (carry.trim())
767
890
  feedLine(carry);
768
891
  carry = "";
769
- this.finalizeRecoveredRun(sessionId, requestId, processor, {
892
+ this.finalizeRecoveredRun(sessionId, requestId, processor, replayView, {
770
893
  exitCode: event.exitCode,
771
894
  signal: event.signal === null ? null : signalNameFromNumber(event.signal),
772
895
  stderr: processor.stderr,
@@ -775,7 +898,7 @@ export class StructuredSessionManager {
775
898
  flushEmit();
776
899
  });
777
900
  }
778
- finalizeRecoveredRun(sessionId, requestId, processor, outcome) {
901
+ finalizeRecoveredRun(sessionId, requestId, processor, replayView, outcome) {
779
902
  this.execHost?.forgetRun(structuredRunId(sessionId));
780
903
  if (!this.isCurrentRequest(sessionId, requestId))
781
904
  return;
@@ -784,8 +907,10 @@ export class StructuredSessionManager {
784
907
  return;
785
908
  const commandLabel = recoveredCommandLabel(current.runner);
786
909
  const interruptedForQuestion = processor.stopReason === "ask-user-question";
910
+ // 退出码/信号是 CLI 的权威结论:daemon 的 replay 日志被截断只说明我们重建不出
911
+ // 完整过程记录,不代表这一轮失败(否则 exit 0 会被报成 "exited with code 0")。
912
+ const replayTruncated = outcome.stdoutTruncated;
787
913
  const failedExit = outcome.lost
788
- || outcome.stdoutTruncated
789
914
  || (outcome.exitCode !== null && outcome.exitCode !== 0)
790
915
  || outcome.signal !== null;
791
916
  if ((processor.primaryError || failedExit) && !interruptedForQuestion) {
@@ -797,21 +922,24 @@ export class StructuredSessionManager {
797
922
  extras: processor.errors,
798
923
  stdoutTail: processor.stdoutTail,
799
924
  });
800
- const failed = this.finishStructuredFailure(current, typeof outcome.exitCode === "number" ? outcome.exitCode : 1, errorText, processor.state);
925
+ const failed = this.finishStructuredFailure(current, typeof outcome.exitCode === "number" ? outcome.exitCode : 1, errorText, processor.state, { keepTranscript: replayTruncated });
801
926
  this.sessions.set(sessionId, failed);
802
927
  this.saveAuthoritativeSession(failed);
803
928
  this.emitStructuredSnapshot(failed);
804
929
  this.emitStructuredSnapshot(failed, "ended");
805
930
  return;
806
931
  }
807
- const messages = this.buildCompletedAssistantMessages(current, processor.state);
808
932
  const keepRunning = interruptedForQuestion;
933
+ const messages = replayTruncated
934
+ ? this.mergeTruncatedReplayTurn(current, processor, replayView, !keepRunning)
935
+ : this.buildCompletedAssistantMessages(current, processor.state);
809
936
  const finished = {
810
937
  ...current,
811
938
  status: keepRunning ? "running" : "idle",
812
939
  exitCode: keepRunning ? null : 0,
813
940
  endedAt: keepRunning ? null : new Date().toISOString(),
814
- output: processor.state.result,
941
+ // 日志被截断时 output 保留重启前的版本:replay 只覆盖尾部,用它反而丢掉前半段。
942
+ output: replayTruncated ? current.output || processor.state.result : processor.state.result,
815
943
  claudeSessionId: processor.state.sessionId ?? current.claudeSessionId,
816
944
  messages,
817
945
  pendingEscalation: null,
@@ -3090,6 +3218,23 @@ export class StructuredSessionManager {
3090
3218
  };
3091
3219
  return upsertAssistantMessage(current.messages, assistantTurn, true);
3092
3220
  }
3221
+ /**
3222
+ * Rebuild the trailing assistant turn when the replayed log was truncated.
3223
+ *
3224
+ * 重启后在 daemon 里继续跑的 CLI,它的 stdout 只保留最后
3225
+ * STRUCTURED_RUN_LOG_MAX_CHARS 字符,所以 replay 出来的 turn 是「半截尾巴」:
3226
+ * 直接覆盖会丢掉重启前用户已经看到的全部输出。这里用与流式期间同一个
3227
+ * TruncatedReplayView(本地已存 turn + replay 新增段),保证收尾视图和前端
3228
+ * 中途看到的完全一致。
3229
+ */
3230
+ mergeTruncatedReplayTurn(current, processor, view, complete) {
3231
+ const turn = {
3232
+ role: "assistant",
3233
+ content: this.compactContentBlocks(view.content([...processor.state.blocks]), processor.state.result),
3234
+ usage: processor.state.usage,
3235
+ };
3236
+ return upsertAssistantMessage([...(current.messages ?? [])], turn, complete);
3237
+ }
3093
3238
  resolveQueuedMessagesAfterInterrupt(sessionId, current, interruptPrompt) {
3094
3239
  if (interruptPrompt && !this.preserveQueueOnInterrupt.has(sessionId))
3095
3240
  return [];
@@ -3134,12 +3279,16 @@ export class StructuredSessionManager {
3134
3279
  const trimmed = detail.length > 2048 ? `...${detail.slice(-2048)}` : detail;
3135
3280
  return `${head}\n${trimmed}`;
3136
3281
  }
3137
- finishStructuredFailure(current, code, errorText, turnState) {
3282
+ finishStructuredFailure(current, code, errorText, turnState, options = {}) {
3138
3283
  const failureTurn = {
3139
3284
  role: "assistant",
3140
3285
  content: [{ type: "text", text: `结构化会话执行失败:${errorText}` }],
3141
3286
  };
3142
- const msgs = upsertAssistantMessage(current.messages, failureTurn, true);
3287
+ // 日志被截断时本地已存的 turn 是重启前唯一完整的记录:失败提示追加成独立一轮,
3288
+ // 不能把整段 turn 换成错误文本(那会把用户看到过的输出全删掉)。
3289
+ const msgs = options.keepTranscript
3290
+ ? appendNoticeTurn(current.messages, failureTurn)
3291
+ : upsertAssistantMessage(current.messages, failureTurn, true);
3143
3292
  return {
3144
3293
  ...current,
3145
3294
  status: "failed",
@@ -16,8 +16,6 @@ class RemoteTerminalProcess {
16
16
  client;
17
17
  dataListeners = new Set();
18
18
  exitListeners = new Set();
19
- paused = false;
20
- pausedEvents = [];
21
19
  undeliveredEvents = [];
22
20
  pendingExit = null;
23
21
  constructor(sessionId, incarnationId, pid, client) {
@@ -41,18 +39,6 @@ class RemoteTerminalProcess {
41
39
  this.client.reportOperationError(this.sessionId, error);
42
40
  });
43
41
  }
44
- pause() {
45
- this.paused = true;
46
- }
47
- resume() {
48
- if (!this.paused)
49
- return;
50
- this.paused = false;
51
- const pending = this.pausedEvents;
52
- this.pausedEvents = [];
53
- for (const event of pending)
54
- this.emitData(event);
55
- }
56
42
  onData(listener) {
57
43
  this.dataListeners.add(listener);
58
44
  if (this.undeliveredEvents.length > 0) {
@@ -79,10 +65,6 @@ class RemoteTerminalProcess {
79
65
  return { dispose: () => this.exitListeners.delete(listener) };
80
66
  }
81
67
  acceptData(event) {
82
- if (this.paused) {
83
- this.pausedEvents = appendTerminalChunkWindow(this.pausedEvents, event);
84
- return;
85
- }
86
68
  if (this.dataListeners.size === 0) {
87
69
  this.undeliveredEvents = appendTerminalChunkWindow(this.undeliveredEvents, event);
88
70
  return;
@@ -107,8 +89,6 @@ class RemoteStructuredProcess {
107
89
  client;
108
90
  streamListeners = new Set();
109
91
  exitListeners = new Set();
110
- paused = false;
111
- pausedEvents = [];
112
92
  undeliveredEvents = [];
113
93
  pendingExit = null;
114
94
  lastStdoutSeq = 0;
@@ -153,10 +133,6 @@ class RemoteStructuredProcess {
153
133
  this.lastStdoutSeq = Math.max(this.lastStdoutSeq, event.seq);
154
134
  else
155
135
  this.lastStderrSeq = Math.max(this.lastStderrSeq, event.seq);
156
- if (this.paused) {
157
- this.pausedEvents.push(event);
158
- return;
159
- }
160
136
  if (this.streamListeners.size === 0) {
161
137
  this.undeliveredEvents.push(event);
162
138
  if (this.undeliveredEvents.length > 4096)
@@ -187,18 +163,6 @@ class RemoteStructuredProcess {
187
163
  this.acceptStream({ stream: "stderr", data: stderrDelta, seq: ++this.syntheticSeq });
188
164
  }
189
165
  }
190
- pause() {
191
- this.paused = true;
192
- }
193
- resume() {
194
- if (!this.paused)
195
- return;
196
- this.paused = false;
197
- const pending = this.pausedEvents;
198
- this.pausedEvents = [];
199
- for (const event of pending)
200
- this.emitStream(event);
201
- }
202
166
  emitStream(event) {
203
167
  for (const listener of Array.from(this.streamListeners))
204
168
  listener(event);