@co0ontty/wand 1.3.6 → 1.5.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.
@@ -0,0 +1,730 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { spawn } from "node:child_process";
3
+ const STREAM_EMIT_DEBOUNCE_MS = 16;
4
+ function isRunningAsRoot() {
5
+ return process.getuid?.() === 0 || process.geteuid?.() === 0;
6
+ }
7
+ /** Should we auto-approve permissions for this mode? */
8
+ function shouldAutoApproveForMode(mode) {
9
+ return mode === "full-access" || mode === "managed" || mode === "auto-edit";
10
+ }
11
+ export class StructuredSessionManager {
12
+ storage;
13
+ config;
14
+ sessions = new Map();
15
+ pendingChildren = new Map();
16
+ emitEvent = null;
17
+ constructor(storage, config) {
18
+ this.storage = storage;
19
+ this.config = config;
20
+ for (const snapshot of this.storage.loadSessions()) {
21
+ if ((snapshot.sessionKind ?? "pty") !== "structured")
22
+ continue;
23
+ const restoredStatus = snapshot.status === "running" ? "stopped" : snapshot.status;
24
+ const restored = {
25
+ ...snapshot,
26
+ sessionKind: "structured",
27
+ runner: snapshot.runner ?? "claude-cli-print",
28
+ status: restoredStatus,
29
+ autoApprovePermissions: snapshot.autoApprovePermissions ?? shouldAutoApproveForMode(snapshot.mode),
30
+ approvalStats: snapshot.approvalStats ?? { tool: 0, command: 0, file: 0, total: 0 },
31
+ pendingEscalation: null,
32
+ permissionBlocked: false,
33
+ structuredState: {
34
+ runner: snapshot.runner ?? "claude-cli-print",
35
+ model: snapshot.structuredState?.model,
36
+ lastError: snapshot.structuredState?.lastError ?? null,
37
+ inFlight: false,
38
+ activeRequestId: null,
39
+ },
40
+ };
41
+ this.sessions.set(restored.id, restored);
42
+ this.storage.saveSession(restored);
43
+ }
44
+ }
45
+ setEventEmitter(emitEvent) {
46
+ this.emitEvent = emitEvent;
47
+ }
48
+ list() {
49
+ return Array.from(this.sessions.values()).sort((a, b) => b.startedAt.localeCompare(a.startedAt));
50
+ }
51
+ get(id) {
52
+ return this.sessions.get(id) ?? null;
53
+ }
54
+ createSession(options) {
55
+ const id = randomUUID();
56
+ const startedAt = new Date().toISOString();
57
+ const prompt = options.prompt?.trim();
58
+ const snapshot = {
59
+ id,
60
+ sessionKind: "structured",
61
+ runner: options.runner ?? "claude-cli-print",
62
+ command: "claude -p --output-format stream-json",
63
+ cwd: options.cwd,
64
+ mode: options.mode,
65
+ status: prompt ? "running" : "stopped",
66
+ exitCode: null,
67
+ startedAt,
68
+ endedAt: prompt ? null : startedAt,
69
+ output: "",
70
+ archived: false,
71
+ archivedAt: null,
72
+ claudeSessionId: null,
73
+ messages: [],
74
+ structuredState: {
75
+ runner: options.runner ?? "claude-cli-print",
76
+ inFlight: false,
77
+ activeRequestId: null,
78
+ lastError: null,
79
+ },
80
+ autoRecovered: false,
81
+ autoApprovePermissions: shouldAutoApproveForMode(options.mode),
82
+ approvalStats: { tool: 0, command: 0, file: 0, total: 0 },
83
+ };
84
+ this.sessions.set(id, snapshot);
85
+ this.storage.saveSession(snapshot);
86
+ this.emit({ type: "started", sessionId: id, data: { sessionKind: "structured" } });
87
+ if (prompt) {
88
+ void this.sendMessage(id, prompt);
89
+ }
90
+ return snapshot;
91
+ }
92
+ async sendMessage(id, input) {
93
+ let session = this.requireSession(id);
94
+ const prompt = input.trim();
95
+ if (!prompt)
96
+ return session;
97
+ console.log("[WAND] StructuredSessionManager.sendMessage id:", id, "inFlight:", session.structuredState?.inFlight, "hasPendingChild:", this.pendingChildren.has(id), "status:", session.status);
98
+ if (session.structuredState?.inFlight) {
99
+ const child = this.pendingChildren.get(id);
100
+ const childAlive = child && !child.killed && child.exitCode === null;
101
+ if (!childAlive) {
102
+ // Auto-recover: inFlight is stuck but child process is gone or dead
103
+ if (child)
104
+ this.pendingChildren.delete(id);
105
+ const recovered = {
106
+ ...session,
107
+ status: "stopped",
108
+ endedAt: session.endedAt ?? new Date().toISOString(),
109
+ structuredState: {
110
+ ...session.structuredState,
111
+ inFlight: false,
112
+ activeRequestId: null,
113
+ },
114
+ };
115
+ this.sessions.set(id, recovered);
116
+ this.storage.saveSession(recovered);
117
+ session = recovered;
118
+ }
119
+ else {
120
+ throw new Error("结构化会话正在处理中,请稍后再试。");
121
+ }
122
+ }
123
+ const userTurn = {
124
+ role: "user",
125
+ content: [{ type: "text", text: prompt }],
126
+ };
127
+ const requestId = randomUUID();
128
+ const updated = {
129
+ ...session,
130
+ status: "running",
131
+ exitCode: null,
132
+ endedAt: null,
133
+ messages: [...(session.messages ?? []), userTurn],
134
+ structuredState: {
135
+ ...(session.structuredState ?? { runner: "claude-cli-print", lastError: null, inFlight: false, activeRequestId: null }),
136
+ inFlight: true,
137
+ activeRequestId: requestId,
138
+ lastError: null,
139
+ },
140
+ };
141
+ this.sessions.set(id, updated);
142
+ this.storage.saveSession(updated);
143
+ this.emit({
144
+ type: "output",
145
+ sessionId: id,
146
+ data: { output: updated.output, messages: updated.messages, sessionKind: "structured" },
147
+ });
148
+ this.emit({
149
+ type: "status",
150
+ sessionId: id,
151
+ data: { status: "running", sessionKind: "structured" },
152
+ });
153
+ try {
154
+ await this.runClaudeStreaming(id, updated, prompt);
155
+ const finished = this.requireSession(id);
156
+ return finished;
157
+ }
158
+ catch (error) {
159
+ const message = error instanceof Error ? error.message : String(error);
160
+ const current = this.sessions.get(id);
161
+ if (!current)
162
+ throw error;
163
+ const failed = {
164
+ ...current,
165
+ status: "failed",
166
+ exitCode: 1,
167
+ endedAt: new Date().toISOString(),
168
+ structuredState: {
169
+ ...current.structuredState,
170
+ inFlight: false,
171
+ activeRequestId: null,
172
+ lastError: message,
173
+ },
174
+ };
175
+ this.sessions.set(id, failed);
176
+ this.storage.saveSession(failed);
177
+ this.emit({
178
+ type: "status",
179
+ sessionId: id,
180
+ data: { status: failed.status, error: message, sessionKind: "structured" },
181
+ });
182
+ this.emit({
183
+ type: "ended",
184
+ sessionId: id,
185
+ data: { status: failed.status, exitCode: 1, messages: failed.messages, sessionKind: "structured", structuredState: failed.structuredState },
186
+ });
187
+ throw error;
188
+ }
189
+ }
190
+ // ---------------------------------------------------------------------------
191
+ // Permission resolution (called from server routes)
192
+ // ---------------------------------------------------------------------------
193
+ /** Approve a pending permission request. */
194
+ approvePermission(sessionId) {
195
+ return this.resolvePermission(sessionId, true);
196
+ }
197
+ /** Deny a pending permission request. */
198
+ denyPermission(sessionId) {
199
+ return this.resolvePermission(sessionId, false);
200
+ }
201
+ /** Toggle auto-approve for the session. */
202
+ toggleAutoApprove(sessionId) {
203
+ const session = this.requireSession(sessionId);
204
+ const newVal = !session.autoApprovePermissions;
205
+ const updated = { ...session, autoApprovePermissions: newVal };
206
+ this.sessions.set(sessionId, updated);
207
+ this.storage.saveSession(updated);
208
+ return updated;
209
+ }
210
+ /** Resolve a specific escalation by requestId. */
211
+ resolveEscalation(sessionId, requestId, resolution) {
212
+ const approved = resolution !== "deny";
213
+ const session = this.requireSession(sessionId);
214
+ const scope = session.pendingEscalation?.scope;
215
+ if (approved && scope) {
216
+ this.incrementApprovalStats(session, scope);
217
+ }
218
+ const updated = {
219
+ ...session,
220
+ pendingEscalation: null,
221
+ permissionBlocked: false,
222
+ lastEscalationResult: session.pendingEscalation ? {
223
+ requestId: session.pendingEscalation.requestId,
224
+ resolution: approved ? "approve_once" : "deny",
225
+ reason: approved ? "user_approved" : "user_denied",
226
+ } : session.lastEscalationResult ?? null,
227
+ };
228
+ this.sessions.set(sessionId, updated);
229
+ this.storage.saveSession(updated);
230
+ this.emit({
231
+ type: "status",
232
+ sessionId,
233
+ data: { permissionBlocked: false, approvalStats: updated.approvalStats, sessionKind: "structured" },
234
+ });
235
+ return updated;
236
+ }
237
+ stop(id) {
238
+ const session = this.requireSession(id);
239
+ const child = this.pendingChildren.get(id);
240
+ if (child) {
241
+ child.kill();
242
+ this.pendingChildren.delete(id);
243
+ }
244
+ const stopped = {
245
+ ...session,
246
+ status: "stopped",
247
+ endedAt: new Date().toISOString(),
248
+ pendingEscalation: null,
249
+ permissionBlocked: false,
250
+ structuredState: {
251
+ ...(session.structuredState ?? { runner: "claude-cli-print", lastError: null, inFlight: false, activeRequestId: null }),
252
+ inFlight: false,
253
+ activeRequestId: null,
254
+ },
255
+ };
256
+ this.sessions.set(id, stopped);
257
+ this.storage.saveSession(stopped);
258
+ this.emit({ type: "ended", sessionId: id, data: { status: stopped.status, exitCode: null, messages: stopped.messages, sessionKind: "structured" } });
259
+ return stopped;
260
+ }
261
+ delete(id) {
262
+ const child = this.pendingChildren.get(id);
263
+ if (child) {
264
+ child.kill();
265
+ this.pendingChildren.delete(id);
266
+ }
267
+ this.sessions.delete(id);
268
+ this.storage.deleteSession(id);
269
+ }
270
+ // ---------------------------------------------------------------------------
271
+ // Private helpers
272
+ // ---------------------------------------------------------------------------
273
+ requireSession(id) {
274
+ const session = this.sessions.get(id);
275
+ if (!session) {
276
+ throw new Error("未找到该结构化会话。");
277
+ }
278
+ return session;
279
+ }
280
+ emit(event) {
281
+ if (this.emitEvent) {
282
+ this.emitEvent(event);
283
+ }
284
+ }
285
+ resolvePermission(sessionId, approved) {
286
+ const session = this.requireSession(sessionId);
287
+ const scope = session.pendingEscalation?.scope;
288
+ if (approved && scope) {
289
+ this.incrementApprovalStats(session, scope);
290
+ }
291
+ const updated = {
292
+ ...session,
293
+ pendingEscalation: null,
294
+ permissionBlocked: false,
295
+ lastEscalationResult: session.pendingEscalation ? {
296
+ requestId: session.pendingEscalation.requestId,
297
+ resolution: approved ? "approve_once" : "deny",
298
+ reason: approved ? "user_approved" : "user_denied",
299
+ } : session.lastEscalationResult ?? null,
300
+ };
301
+ this.sessions.set(sessionId, updated);
302
+ this.storage.saveSession(updated);
303
+ this.emit({
304
+ type: "status",
305
+ sessionId,
306
+ data: { permissionBlocked: false, approvalStats: updated.approvalStats, sessionKind: "structured" },
307
+ });
308
+ return updated;
309
+ }
310
+ incrementApprovalStats(session, scope) {
311
+ const prev = session.approvalStats ?? { tool: 0, command: 0, file: 0, total: 0 };
312
+ const stats = { ...prev };
313
+ if (scope === "run_command" || scope === "dangerous_shell") {
314
+ stats.command++;
315
+ }
316
+ else if (scope === "write_file") {
317
+ stats.file++;
318
+ }
319
+ else {
320
+ stats.tool++;
321
+ }
322
+ stats.total++;
323
+ session.approvalStats = stats;
324
+ }
325
+ // ---------------------------------------------------------------------------
326
+ // CLI argument construction
327
+ // ---------------------------------------------------------------------------
328
+ buildPermissionArgs(mode) {
329
+ if (!isRunningAsRoot()) {
330
+ if (mode === "full-access" || mode === "managed") {
331
+ return ["--permission-mode", "bypassPermissions"];
332
+ }
333
+ if (mode === "auto-edit") {
334
+ return ["--permission-mode", "acceptEdits"];
335
+ }
336
+ return [];
337
+ }
338
+ // Root: Claude CLI refuses bypassPermissions.
339
+ // acceptEdits auto-approves within CWD; --allowedTools extends to all paths.
340
+ if (shouldAutoApproveForMode(mode)) {
341
+ return [
342
+ "--permission-mode", "acceptEdits",
343
+ "--allowedTools", "Bash", "Edit", "Write", "Read", "Glob", "Grep",
344
+ "NotebookEdit", "WebFetch", "WebSearch",
345
+ ];
346
+ }
347
+ return [];
348
+ }
349
+ // ---------------------------------------------------------------------------
350
+ // Streaming claude -p execution
351
+ // ---------------------------------------------------------------------------
352
+ /**
353
+ * Spawn `claude -p --output-format stream-json` and parse NDJSON lines as
354
+ * they arrive, emitting incremental WebSocket events so the UI can render
355
+ * text / thinking / tool_use blocks in real-time.
356
+ *
357
+ * Permission handling:
358
+ * - Non-root + full-access/managed: --permission-mode bypassPermissions
359
+ * - Non-root + auto-edit: --permission-mode acceptEdits
360
+ * - Root: --permission-mode acceptEdits + --allowedTools (extends approval
361
+ * outside CWD). stdin is always "ignore" — no ACP bidirectional control.
362
+ */
363
+ runClaudeStreaming(sessionId, session, prompt) {
364
+ return new Promise((resolve, reject) => {
365
+ const args = ["-p", "--verbose", "--output-format", "stream-json"];
366
+ console.log("[WAND] runClaudeStreaming sessionId:", sessionId, "mode:", session.mode, "claudeSessionId:", session.claudeSessionId);
367
+ // Add permission args based on mode
368
+ const permArgs = this.buildPermissionArgs(session.mode);
369
+ args.push(...permArgs);
370
+ // In managed mode, append autonomous system prompt
371
+ if (session.mode === "managed") {
372
+ args.push("--append-system-prompt", "You are running in a fully managed, autonomous mode. The user may not be available to respond to questions or confirmations in a timely manner. You MUST make all decisions independently — choose the best approach yourself instead of asking the user for preferences, confirmations, or clarifications. If multiple approaches are viable, pick the one you judge most appropriate and proceed. Never block on user input unless the task is fundamentally ambiguous and cannot be reasonably inferred. Be decisive and self-directed.");
373
+ }
374
+ // Append language preference if configured
375
+ const language = this.config.language?.trim();
376
+ if (language) {
377
+ args.push("--append-system-prompt", `Please respond in ${language}. Use ${language} for all your explanations, comments, and conversational text.`);
378
+ }
379
+ if (session.claudeSessionId) {
380
+ args.push("--resume", session.claudeSessionId);
381
+ }
382
+ args.push(prompt);
383
+ const child = spawn("claude", args, {
384
+ cwd: session.cwd,
385
+ env: process.env,
386
+ stdio: ["ignore", "pipe", "pipe"],
387
+ });
388
+ console.log("[WAND] spawned claude -p pid:", child.pid, "args:", args.filter(a => a !== prompt).join(" "));
389
+ this.pendingChildren.set(sessionId, child);
390
+ const turnState = {
391
+ blocks: [],
392
+ result: "",
393
+ sessionId: null,
394
+ model: undefined,
395
+ usage: undefined,
396
+ };
397
+ // Line buffer for NDJSON: chunks from stdout may split mid-line.
398
+ let lineBuf = "";
399
+ // Debounce output events to avoid flooding the WebSocket.
400
+ let emitTimer = null;
401
+ const flushEmit = () => {
402
+ if (emitTimer) {
403
+ clearTimeout(emitTimer);
404
+ emitTimer = null;
405
+ }
406
+ const current = this.sessions.get(sessionId);
407
+ if (!current)
408
+ return;
409
+ this.emit({
410
+ type: "output",
411
+ sessionId,
412
+ data: { output: current.output, messages: current.messages, sessionKind: "structured" },
413
+ });
414
+ };
415
+ const scheduleEmit = () => {
416
+ if (!emitTimer) {
417
+ emitTimer = setTimeout(flushEmit, STREAM_EMIT_DEBOUNCE_MS);
418
+ }
419
+ };
420
+ /** Update the session snapshot with the current in-progress assistant turn. */
421
+ const syncSnapshot = () => {
422
+ const current = this.sessions.get(sessionId);
423
+ if (!current)
424
+ return;
425
+ const inProgressTurn = {
426
+ role: "assistant",
427
+ content: this.compactContentBlocks([...turnState.blocks], turnState.result),
428
+ usage: turnState.usage,
429
+ };
430
+ // Replace or append the in-progress assistant turn at the end of messages.
431
+ const msgs = [...(current.messages ?? [])];
432
+ const lastMsg = msgs[msgs.length - 1];
433
+ if (lastMsg && lastMsg.role === "assistant") {
434
+ msgs[msgs.length - 1] = inProgressTurn;
435
+ }
436
+ else {
437
+ msgs.push(inProgressTurn);
438
+ }
439
+ const patched = {
440
+ ...current,
441
+ claudeSessionId: turnState.sessionId ?? current.claudeSessionId,
442
+ messages: msgs,
443
+ structuredState: {
444
+ ...current.structuredState,
445
+ model: turnState.model ?? current.structuredState?.model,
446
+ },
447
+ };
448
+ this.sessions.set(sessionId, patched);
449
+ };
450
+ const processLine = (line) => {
451
+ const trimmed = line.trim();
452
+ if (!trimmed)
453
+ return;
454
+ let parsed;
455
+ try {
456
+ parsed = JSON.parse(trimmed);
457
+ }
458
+ catch {
459
+ return;
460
+ }
461
+ if (parsed && parsed.type === "assistant" && parsed.message) {
462
+ const extracted = this.extractAssistantMessage(parsed.message);
463
+ if (extracted.content.length > 0) {
464
+ turnState.blocks.push(...extracted.content);
465
+ }
466
+ // NOTE: usage from streaming "assistant" events contains partial/incremental
467
+ // token counts (e.g. output_tokens=1 during streaming) and is NOT accurate.
468
+ // We only use the authoritative usage from the final "result" event.
469
+ syncSnapshot();
470
+ scheduleEmit();
471
+ return;
472
+ }
473
+ if (parsed && parsed.type === "user" && parsed.message && Array.isArray(parsed.message.content)) {
474
+ for (const block of parsed.message.content) {
475
+ if (block && block.type === "tool_result") {
476
+ turnState.blocks.push({
477
+ type: "tool_result",
478
+ tool_use_id: typeof block.tool_use_id === "string" ? block.tool_use_id : "",
479
+ content: this.normalizeToolResultContent(block.content),
480
+ is_error: block.is_error === true,
481
+ });
482
+ }
483
+ }
484
+ syncSnapshot();
485
+ scheduleEmit();
486
+ return;
487
+ }
488
+ if (parsed && parsed.type === "result") {
489
+ if (typeof parsed.result === "string") {
490
+ turnState.result = parsed.result.trim();
491
+ }
492
+ if (typeof parsed.session_id === "string") {
493
+ turnState.sessionId = parsed.session_id;
494
+ }
495
+ turnState.model = this.extractModelName(parsed.modelUsage) ?? turnState.model;
496
+ turnState.usage = this.extractUsage(parsed) ?? turnState.usage;
497
+ syncSnapshot();
498
+ scheduleEmit();
499
+ }
500
+ };
501
+ let stderr = "";
502
+ child.stdout?.on("data", (chunk) => {
503
+ lineBuf += chunk.toString();
504
+ const lines = lineBuf.split("\n");
505
+ // Keep the last (possibly incomplete) segment in the buffer.
506
+ lineBuf = lines.pop() ?? "";
507
+ for (const line of lines) {
508
+ processLine(line);
509
+ }
510
+ });
511
+ child.stderr?.on("data", (chunk) => {
512
+ stderr += chunk.toString();
513
+ });
514
+ child.on("error", (error) => {
515
+ console.log("[WAND] claude -p child error:", error.message);
516
+ this.pendingChildren.delete(sessionId);
517
+ if (emitTimer)
518
+ clearTimeout(emitTimer);
519
+ reject(error);
520
+ });
521
+ child.on("close", (code) => {
522
+ console.log("[WAND] claude -p child close code:", code, "stderr:", stderr.substring(0, 200));
523
+ this.pendingChildren.delete(sessionId);
524
+ // Process any remaining data in the line buffer.
525
+ if (lineBuf.trim()) {
526
+ processLine(lineBuf);
527
+ lineBuf = "";
528
+ }
529
+ // Flush any pending debounced emit before finalizing.
530
+ flushEmit();
531
+ // Finalize the session snapshot.
532
+ const current = this.sessions.get(sessionId);
533
+ if (!current) {
534
+ reject(new Error("Session removed during execution."));
535
+ return;
536
+ }
537
+ if (code !== 0 && code !== null) {
538
+ const errorText = stderr.trim() || `claude -p exited with code ${code}`;
539
+ const failureTurn = {
540
+ role: "assistant",
541
+ content: [{ type: "text", text: `结构化会话执行失败:${errorText}` }],
542
+ };
543
+ const msgs = [...(current.messages ?? [])];
544
+ const lastMsg = msgs[msgs.length - 1];
545
+ if (lastMsg && lastMsg.role === "assistant") {
546
+ msgs[msgs.length - 1] = failureTurn;
547
+ }
548
+ else {
549
+ msgs.push(failureTurn);
550
+ }
551
+ const failed = {
552
+ ...current,
553
+ status: "failed",
554
+ exitCode: code,
555
+ endedAt: new Date().toISOString(),
556
+ output: errorText,
557
+ claudeSessionId: turnState.sessionId ?? current.claudeSessionId,
558
+ messages: msgs,
559
+ pendingEscalation: null,
560
+ permissionBlocked: false,
561
+ structuredState: {
562
+ ...current.structuredState,
563
+ model: turnState.model ?? current.structuredState?.model,
564
+ inFlight: false,
565
+ activeRequestId: null,
566
+ lastError: errorText,
567
+ },
568
+ };
569
+ this.sessions.set(sessionId, failed);
570
+ this.storage.saveSession(failed);
571
+ this.emit({
572
+ type: "output",
573
+ sessionId,
574
+ data: { output: failed.output, messages: failed.messages, sessionKind: "structured" },
575
+ });
576
+ this.emit({
577
+ type: "ended",
578
+ sessionId,
579
+ data: { status: failed.status, exitCode: failed.exitCode, messages: failed.messages, sessionKind: "structured", structuredState: failed.structuredState },
580
+ });
581
+ reject(new Error(errorText));
582
+ return;
583
+ }
584
+ // Build the final assistant turn.
585
+ const finalContent = this.compactContentBlocks([...turnState.blocks], turnState.result);
586
+ const assistantTurn = {
587
+ role: "assistant",
588
+ content: finalContent,
589
+ usage: turnState.usage,
590
+ };
591
+ // Ensure the final messages list has the completed assistant turn.
592
+ const msgs = [...(current.messages ?? [])];
593
+ const lastMsg = msgs[msgs.length - 1];
594
+ if (lastMsg && lastMsg.role === "assistant") {
595
+ msgs[msgs.length - 1] = assistantTurn;
596
+ }
597
+ else {
598
+ msgs.push(assistantTurn);
599
+ }
600
+ const finished = {
601
+ ...current,
602
+ status: "stopped",
603
+ exitCode: 0,
604
+ endedAt: new Date().toISOString(),
605
+ output: turnState.result,
606
+ claudeSessionId: turnState.sessionId ?? current.claudeSessionId,
607
+ messages: msgs,
608
+ pendingEscalation: null,
609
+ permissionBlocked: false,
610
+ structuredState: {
611
+ ...current.structuredState,
612
+ model: turnState.model ?? current.structuredState?.model,
613
+ inFlight: false,
614
+ activeRequestId: null,
615
+ lastError: null,
616
+ },
617
+ };
618
+ this.sessions.set(sessionId, finished);
619
+ this.storage.saveSession(finished);
620
+ this.emit({
621
+ type: "output",
622
+ sessionId,
623
+ data: { output: finished.output, messages: finished.messages, sessionKind: "structured" },
624
+ });
625
+ this.emit({
626
+ type: "ended",
627
+ sessionId,
628
+ data: { status: finished.status, exitCode: 0, messages: finished.messages, sessionKind: "structured", structuredState: finished.structuredState },
629
+ });
630
+ resolve();
631
+ });
632
+ });
633
+ }
634
+ // ---------------------------------------------------------------------------
635
+ // Parsing helpers (unchanged logic, extracted from previous implementation)
636
+ // ---------------------------------------------------------------------------
637
+ extractAssistantMessage(message) {
638
+ const rawContent = Array.isArray(message.content) ? message.content : [];
639
+ const content = [];
640
+ for (const block of rawContent) {
641
+ if (!block || typeof block !== "object")
642
+ continue;
643
+ const typedBlock = block;
644
+ if (typedBlock.type === "text" && typeof typedBlock.text === "string") {
645
+ content.push({ type: "text", text: typedBlock.text });
646
+ continue;
647
+ }
648
+ if (typedBlock.type === "thinking" && typeof typedBlock.thinking === "string") {
649
+ content.push({ type: "thinking", thinking: typedBlock.thinking });
650
+ continue;
651
+ }
652
+ if (typedBlock.type === "tool_use" && typeof typedBlock.id === "string" && typeof typedBlock.name === "string") {
653
+ content.push({
654
+ type: "tool_use",
655
+ id: typedBlock.id,
656
+ name: typedBlock.name,
657
+ description: typeof typedBlock.description === "string" ? typedBlock.description : undefined,
658
+ input: this.normalizeToolInput(typedBlock.input),
659
+ });
660
+ }
661
+ }
662
+ return {
663
+ content,
664
+ usage: this.extractUsage({ usage: message.usage }),
665
+ };
666
+ }
667
+ compactContentBlocks(blocks, fallbackResult) {
668
+ const compacted = [];
669
+ for (const block of blocks) {
670
+ const previous = compacted[compacted.length - 1];
671
+ if (previous
672
+ && previous.type === "text"
673
+ && block.type === "text") {
674
+ previous.text = `${previous.text}${block.text}`;
675
+ continue;
676
+ }
677
+ compacted.push(block);
678
+ }
679
+ if (compacted.length === 0) {
680
+ return [{ type: "text", text: fallbackResult || "(无输出)" }];
681
+ }
682
+ const hasVisibleText = compacted.some((block) => block.type === "text" && block.text.trim().length > 0);
683
+ if (!hasVisibleText && fallbackResult) {
684
+ compacted.push({ type: "text", text: fallbackResult });
685
+ }
686
+ return compacted;
687
+ }
688
+ normalizeToolInput(input) {
689
+ if (!input || typeof input !== "object" || Array.isArray(input)) {
690
+ return {};
691
+ }
692
+ return input;
693
+ }
694
+ normalizeToolResultContent(content) {
695
+ if (typeof content === "string") {
696
+ return content;
697
+ }
698
+ if (Array.isArray(content)) {
699
+ return content.filter((item) => !!item && typeof item === "object" && typeof item.type === "string");
700
+ }
701
+ return typeof content === "undefined" || content === null ? "" : String(content);
702
+ }
703
+ extractModelName(modelUsage) {
704
+ if (!modelUsage)
705
+ return undefined;
706
+ const names = Object.keys(modelUsage);
707
+ return names.length > 0 ? names[0] : undefined;
708
+ }
709
+ extractUsage(source) {
710
+ if (!source || !source.usage || typeof source.usage !== "object") {
711
+ return undefined;
712
+ }
713
+ const usage = source.usage;
714
+ const value = {
715
+ inputTokens: typeof usage.input_tokens === "number" ? usage.input_tokens : undefined,
716
+ outputTokens: typeof usage.output_tokens === "number" ? usage.output_tokens : undefined,
717
+ cacheReadInputTokens: typeof usage.cache_read_input_tokens === "number" ? usage.cache_read_input_tokens : undefined,
718
+ cacheCreationInputTokens: typeof usage.cache_creation_input_tokens === "number" ? usage.cache_creation_input_tokens : undefined,
719
+ totalCostUsd: typeof source.total_cost_usd === "number" ? source.total_cost_usd : undefined,
720
+ };
721
+ if (value.inputTokens === undefined
722
+ && value.outputTokens === undefined
723
+ && value.cacheReadInputTokens === undefined
724
+ && value.cacheCreationInputTokens === undefined
725
+ && value.totalCostUsd === undefined) {
726
+ return undefined;
727
+ }
728
+ return value;
729
+ }
730
+ }