@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.
- package/dist/build-info.json +3 -3
- package/dist/process-manager.d.ts +0 -2
- package/dist/process-manager.js +0 -10
- package/dist/pty-terminal-state.d.ts +7 -0
- package/dist/pty-terminal-state.js +139 -22
- package/dist/server.js +0 -2
- package/dist/structured-session-manager.d.ts +10 -0
- package/dist/structured-session-manager.js +164 -15
- package/dist/terminal-daemon-client.js +0 -36
- package/dist/terminal-daemon-server.d.ts +12 -0
- package/dist/terminal-daemon-server.js +53 -7
- package/dist/terminal-host.d.ts +0 -2
- package/dist/terminal-host.js +0 -6
- package/dist/web-ui/content/scripts.js +70 -70
- package/dist/web-ui/content/styles.css +1 -1
- package/dist/web-ui/embedded-assets.js +2 -2
- package/dist/web-ui/sidebar-layout.d.ts +0 -4
- package/dist/web-ui/sidebar-layout.js +2 -2
- package/dist/ws-broadcast.d.ts +14 -6
- package/dist/ws-broadcast.js +54 -40
- package/package.json +1 -1
package/dist/build-info.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
|
-
"commit": "
|
|
3
|
-
"builtAt": "2026-09-
|
|
4
|
-
"version": "4.72.
|
|
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.
|
package/dist/process-manager.js
CHANGED
|
@@ -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
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
this.
|
|
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
|
-
|
|
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(
|
|
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
|
|
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
|
-
|
|
73
|
-
this.pending.push(pending);
|
|
74
|
-
return pending;
|
|
125
|
+
this.pending.push({ id: this.nextId++, operation });
|
|
75
126
|
}
|
|
76
|
-
|
|
77
|
-
if (this.
|
|
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
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
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
|
-
|
|
91
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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);
|