@co0ontty/wand 4.67.0 → 4.68.0-beta.gb5f5a70

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/README.md CHANGED
@@ -322,6 +322,7 @@ wand/
322
322
  ├── android/ # Android 客户端(submodule)
323
323
  ├── ios/ # iOS 客户端(submodule)
324
324
  ├── macos/ # macOS 客户端(submodule)
325
+ ├── 架构图.md # 运行时架构与代码边界(mermaid)
325
326
  └── docs/ # 文档与截图
326
327
  ```
327
328
 
@@ -1,6 +1,6 @@
1
1
  {
2
- "commit": "19d226c7839da00873a245da7e252395f095d730",
3
- "builtAt": "2026-09-16T23:41:08.180Z",
4
- "version": "4.67.0",
5
- "channel": "stable"
2
+ "commit": "b5f5a70e88309c0a06e988480b76760251c1935b",
3
+ "builtAt": "2026-09-17T01:40:08.544Z",
4
+ "version": "4.68.0-beta.gb5f5a70",
5
+ "channel": "beta"
6
6
  }
@@ -182,7 +182,11 @@ export function registerFileRoutes(app, deps) {
182
182
  return;
183
183
  }
184
184
  try {
185
- const fileStat = await stat(resolvedPath);
185
+ const fileStat = await lstat(resolvedPath);
186
+ if (fileStat.isSymbolicLink()) {
187
+ res.status(400).json({ error: "无法保存符号链接,请打开目标文件后编辑。" });
188
+ return;
189
+ }
186
190
  if (fileStat.isDirectory()) {
187
191
  res.status(400).json({ error: "目标是目录,无法写入。" });
188
192
  return;
@@ -485,8 +485,20 @@ export function registerWorkspaceRoutes(app, storage, sessions) {
485
485
  groups.set(id, created);
486
486
  return created;
487
487
  };
488
+ const tasksByWorkspace = new Map(workspaces.map((workspace) => [
489
+ workspace.id, storage.listWorkspaceTasks(workspace.id),
490
+ ]));
491
+ const sessionsByTask = new Map();
492
+ const taskSessions = (taskId) => {
493
+ let snapshots = sessionsByTask.get(taskId);
494
+ if (!snapshots) {
495
+ snapshots = storage.listSessionsByWorkspaceTask(taskId);
496
+ sessionsByTask.set(taskId, snapshots);
497
+ }
498
+ return snapshots;
499
+ };
488
500
  const appendTask = (target, workspace, task) => {
489
- const allSessions = storage.listSessionsByWorkspaceTask(task.id);
501
+ const allSessions = taskSessions(task.id);
490
502
  const sessions = allSessions
491
503
  .slice(0, sessionLimit ?? undefined)
492
504
  .map((session) => ({
@@ -508,7 +520,7 @@ export function registerWorkspaceRoutes(app, storage, sessions) {
508
520
  if (!base)
509
521
  continue;
510
522
  const global = isGlobalWorkspace(workspace);
511
- for (const task of storage.listWorkspaceTasks(workspace.id).slice(0, taskLimit ?? undefined)) {
523
+ for (const task of tasksByWorkspace.get(workspace.id).slice(0, taskLimit ?? undefined)) {
512
524
  const taskCwd = taskRuntimeCwd(task, workspace);
513
525
  const target = global && taskCwd
514
526
  && normalizeProjectCwd(taskCwd) !== normalizeProjectCwd(workspace.cwd)
@@ -518,9 +530,10 @@ export function registerWorkspaceRoutes(app, storage, sessions) {
518
530
  }
519
531
  }
520
532
  const taskBoundSessionIds = new Set();
521
- for (const workspace of workspaces) {
522
- for (const task of storage.listWorkspaceTasks(workspace.id)) {
523
- for (const session of storage.listSessionsByWorkspaceTask(task.id))
533
+ // Membership must include tasks/sessions hidden by the response limits.
534
+ for (const tasks of tasksByWorkspace.values()) {
535
+ for (const task of tasks) {
536
+ for (const session of taskSessions(task.id))
524
537
  taskBoundSessionIds.add(session.id);
525
538
  }
526
539
  }
package/dist/server.js CHANGED
@@ -319,8 +319,8 @@ export async function startServer(config, configPath, options = {}) {
319
319
  const terminalHost = options.terminalHost ?? await createTerminalHost(configPath);
320
320
  const processes = new ProcessManager(config, storage, configDir, terminalHost);
321
321
  const structuredLogger = new SessionLogger(configDir, config.shortcutLogMaxBytes);
322
- // Only the daemon-backed host keeps structured CLI runs alive across restarts;
323
- // the in-process fallback keeps the legacy die-with-server lifecycle.
322
+ // Production startup requires the daemon-backed host so CLI runs outlive web
323
+ // restarts. In-process hosts are retained only for explicit test injection.
324
324
  const structuredExecHost = terminalHost instanceof TerminalDaemonClient ? terminalHost : undefined;
325
325
  const structuredSessions = new StructuredSessionManager(storage, config, structuredLogger, undefined, {}, structuredExecHost);
326
326
  const sessionRegistry = new SessionRegistry(processes, structuredSessions, storage);
@@ -1,9 +1,5 @@
1
1
  import { cleanupWorktreeSync } from "./git-worktree.js";
2
2
  import { inferProviderFromCommand } from "./session-provider.js";
3
- function slimSnapshot(snapshot) {
4
- const { output: _output, messages: _messages, ...slim } = snapshot;
5
- return { ...slim, output: "" };
6
- }
7
3
  /** 读取 `hidden_claude_session_ids`;坏数据 / 空值按空集合处理,不抛错。 */
8
4
  export function readHiddenSessionIds(storage) {
9
5
  const raw = storage.getConfigValue("hidden_claude_session_ids");
@@ -82,9 +78,9 @@ export class SessionRegistry {
82
78
  if (!byId.has(snapshot.id))
83
79
  byId.set(snapshot.id, snapshot);
84
80
  }
85
- for (const snapshot of this.storage.loadSessions()) {
81
+ for (const snapshot of this.storage.loadSessionsSlim()) {
86
82
  if (!byId.has(snapshot.id))
87
- byId.set(snapshot.id, slimSnapshot(snapshot));
83
+ byId.set(snapshot.id, snapshot);
88
84
  }
89
85
  return Array.from(byId.values()).sort((a, b) => b.startedAt.localeCompare(a.startedAt));
90
86
  }
package/dist/storage.d.ts CHANGED
@@ -229,6 +229,8 @@ export declare class WandStorage {
229
229
  getSession(id: string): SessionSnapshot | null;
230
230
  getLatestSessionByClaudeSessionId(claudeSessionId: string): SessionSnapshot | null;
231
231
  loadSessions(): SessionSnapshot[];
232
+ /** List durable metadata without reading or parsing output/messages payloads. */
233
+ loadSessionsSlim(): SessionSnapshot[];
232
234
  private mapSessionRow;
233
235
  deleteSession(id: string): void;
234
236
  }
package/dist/storage.js CHANGED
@@ -251,8 +251,8 @@ function mapWorktreeMergeFields(row) {
251
251
  worktreeMergeInfo: parseWorktreeMergeInfo(row.worktree_merge_info) ?? null,
252
252
  };
253
253
  }
254
- function sessionSelectFields() {
255
- return `id, session_source, automation_id, provider, session_kind, runner, command, cwd, mode, status, exit_code, started_at, ended_at, output, pty_output_seq, archived, archived_at, claude_session_id, messages, queued_messages, queued_message_skills, structured_state
254
+ function sessionSelectFields(slim = false) {
255
+ return `id, session_source, automation_id, provider, session_kind, runner, command, cwd, mode, status, exit_code, started_at, ended_at, ${slim ? "'' AS output" : "output"}, pty_output_seq, archived, archived_at, claude_session_id, ${slim ? "NULL AS messages" : "messages"}, queued_messages, queued_message_skills, structured_state
256
256
  , resumed_from_session_id, auto_recovered, worktree_enabled, worktree_info, worktree_merge_status, worktree_merge_info, title, description, session_options, workspace_id, workspace_task_id`;
257
257
  }
258
258
  function mapWorkspaceKind(raw) {
@@ -1149,11 +1149,11 @@ export class WandStorage {
1149
1149
  COALESCE(MAX(ended_at), '') AS ended,
1150
1150
  COALESCE(MAX(title), '') AS title
1151
1151
  FROM command_sessions`).get();
1152
- const tasks = this.db.prepare(`SELECT COUNT(*) AS count,
1153
- COALESCE(MAX(created_at), '') AS created,
1154
- COALESCE(MAX(last_opened_at), '') AS opened,
1155
- COALESCE(MAX(layout_revision), 0) AS revision
1156
- FROM workspace_tasks`).get();
1152
+ // Per-task metadata catches edits even when another task owns the maximum
1153
+ // timestamp/revision. Layout payloads are represented by their own revision.
1154
+ const tasks = this.db.prepare(`SELECT id, workspace_id, name, status, cwd, worktree_json, milestone_id,
1155
+ created_at, last_opened_at, layout_revision
1156
+ FROM workspace_tasks ORDER BY id`).all();
1157
1157
  const workspaces = this.db.prepare(`SELECT COUNT(*) AS count, COALESCE(MAX(last_opened_at), '') AS opened FROM workspaces`).get();
1158
1158
  // 名称也进指纹:改名(含工作区/项目名与目录自定义名)后客户端才肯重拉任务列表。
1159
1159
  const names = this.db.prepare(`SELECT
@@ -1725,6 +1725,11 @@ export class WandStorage {
1725
1725
  .all();
1726
1726
  return rows.map((row) => this.mapSessionRow(row));
1727
1727
  }
1728
+ /** List durable metadata without reading or parsing output/messages payloads. */
1729
+ loadSessionsSlim() {
1730
+ const rows = this.db.prepare(`SELECT ${sessionSelectFields(true)} FROM command_sessions ORDER BY started_at DESC`).all();
1731
+ return rows.map((row) => this.mapSessionRow(row));
1732
+ }
1728
1733
  mapSessionRow(row) {
1729
1734
  return mapSessionCore(row);
1730
1735
  }
@@ -95,7 +95,11 @@ export function applyPiEvent(state, event) {
95
95
  // created when an assistant message is saved, so this ID is not resumable yet.
96
96
  if (event.type === "session" && typeof event.id === "string")
97
97
  state.pendingSessionId = event.id;
98
+ if (event.type === "turn_start" || event.type === "message_start" || event.type === "tool_execution_start") {
99
+ state.phase = "responding";
100
+ }
98
101
  if (event.type === "message_update") {
102
+ state.phase = "responding";
99
103
  const update = asRecord(event.assistantMessageEvent);
100
104
  const delta = typeof update?.delta === "string" ? update.delta : "";
101
105
  if (update?.type === "text_delta" && delta) {
@@ -144,7 +148,14 @@ export function applyPiEvent(state, event) {
144
148
  if (message?.role === "assistant") {
145
149
  if (state.pendingSessionId)
146
150
  state.sessionId = state.pendingSessionId;
147
- return applyPiAssistantMessage(state, message);
151
+ const error = applyPiAssistantMessage(state, message);
152
+ if (event.type === "turn_end") {
153
+ const content = Array.isArray(message.content) ? message.content : [];
154
+ const hasToolCall = content.some((part) => asRecord(part)?.type === "toolCall");
155
+ if (!hasToolCall && message.stopReason !== "toolUse")
156
+ state.phase = "background";
157
+ }
158
+ return error;
148
159
  }
149
160
  }
150
161
  if (event.type === "agent_end" && Array.isArray(event.messages)) {
@@ -170,7 +181,13 @@ export class PiRunner {
170
181
  }
171
182
  start(context, observer) {
172
183
  const args = buildPiArgs(context.session, context.prompt);
173
- const state = { blocks: [], result: "", sessionId: context.session.claudeSessionId, model: context.session.selectedModel ?? undefined };
184
+ const state = {
185
+ blocks: [],
186
+ result: "",
187
+ sessionId: context.session.claudeSessionId,
188
+ model: context.session.selectedModel ?? undefined,
189
+ phase: "responding",
190
+ };
174
191
  let primaryError = null;
175
192
  return startStructuredCli({
176
193
  sessionId: context.session.id,
@@ -5,6 +5,8 @@ export interface StructuredRunnerTurnState {
5
5
  sessionId: string | null;
6
6
  model?: string;
7
7
  usage?: ConversationTurn["usage"];
8
+ /** Provider turn phase when the CLI stays alive after its visible reply. */
9
+ phase?: "responding" | "background";
8
10
  }
9
11
  export interface StructuredRunnerContext {
10
12
  session: SessionSnapshot;
@@ -101,14 +101,19 @@ export declare class StructuredSessionManager {
101
101
  private readonly piRunner;
102
102
  /** Structured CLI runs that were mid-flight when the previous web process died. */
103
103
  private pendingRecoveryIds;
104
+ private detachedRecoveryPromise;
105
+ private detachedRecoveryRetryTimer;
106
+ private detachedRecoveryFailureLogged;
104
107
  private disposed;
105
108
  constructor(storage: WandStorage, config: WandConfig, logger?: SessionLogger | null, sdkQueryFactory?: typeof sdkQuery, runners?: StructuredSessionManagerRunners, execHost?: StructuredExecHost | undefined);
106
109
  private archiveExpiredSessions;
107
110
  setEventEmitter(emitEvent: (event: ProcessEvent) => void): void;
108
111
  /** Stop every runner and flush terminal state before storage is closed. */
109
112
  dispose(): void;
110
- /** Called once after startup wiring; safe to skip when no host or candidates. */
113
+ /** Called once after startup wiring; transient daemon failures retry automatically. */
111
114
  recoverDetachedRuns(): Promise<void>;
115
+ private recoverDetachedRunsOnce;
116
+ private scheduleDetachedRecovery;
112
117
  private resumeDetachedRun;
113
118
  private finalizeRecoveredRun;
114
119
  private trackStreamEmitTimer;
@@ -145,6 +145,7 @@ const STREAM_EMIT_DEBOUNCE_MS = 16;
145
145
  // checkpoint keeps recovery useful without rewriting megabytes five times a
146
146
  // second on the event loop.
147
147
  const STREAM_SAVE_THROTTLE_MS = 1_000;
148
+ const DETACHED_RECOVERY_RETRY_MS = 500;
148
149
  const ARCHIVE_AFTER_MS = 1000 * 60 * 60 * 24;
149
150
  /**
150
151
  * 找出最后一条 assistant turn 中尚未配对 tool_result 的 AskUserQuestion tool_use。
@@ -348,6 +349,9 @@ export class StructuredSessionManager {
348
349
  piRunner;
349
350
  /** Structured CLI runs that were mid-flight when the previous web process died. */
350
351
  pendingRecoveryIds = [];
352
+ detachedRecoveryPromise = null;
353
+ detachedRecoveryRetryTimer = null;
354
+ detachedRecoveryFailureLogged = false;
351
355
  disposed = false;
352
356
  constructor(storage, config, logger = null, sdkQueryFactory = sdkQuery, runners = {}, execHost) {
353
357
  this.storage = storage;
@@ -373,9 +377,11 @@ export class StructuredSessionManager {
373
377
  const runner = isStructuredRunnerForProvider(provider, storedRunner)
374
378
  ? storedRunner
375
379
  : defaultStructuredRunner(provider, this.config.structuredRunner);
376
- if (snapshot.status === "running" && runner !== "claude-sdk") {
380
+ const recoverableDetachedRun = snapshot.status === "running"
381
+ && runner !== "claude-sdk"
382
+ && this.execHost?.persistent === true;
383
+ if (recoverableDetachedRun)
377
384
  this.pendingRecoveryIds.push(snapshot.id);
378
- }
379
385
  const restored = {
380
386
  ...snapshot,
381
387
  sessionKind: "structured",
@@ -383,7 +389,7 @@ export class StructuredSessionManager {
383
389
  automationId: snapshot.automationId,
384
390
  provider,
385
391
  runner,
386
- status: restoredStatus,
392
+ status: recoverableDetachedRun ? "running" : restoredStatus,
387
393
  autoApprovePermissions: snapshot.autoApprovePermissions ?? shouldAutoApproveForMode(snapshot.mode),
388
394
  approvalStats: snapshot.approvalStats ?? { tool: 0, command: 0, file: 0, total: 0 },
389
395
  queuedMessages: snapshot.queuedMessages ?? [],
@@ -395,16 +401,22 @@ export class StructuredSessionManager {
395
401
  runner,
396
402
  model: snapshot.structuredState?.model ?? snapshot.selectedModel ?? undefined,
397
403
  lastError: snapshot.status === "running"
398
- ? "服务重启,上一轮已中断。"
404
+ ? recoverableDetachedRun ? null : "服务重启,上一轮已中断。"
399
405
  : snapshot.structuredState?.lastError ?? null,
400
- inFlight: false,
401
- activeRequestId: null,
406
+ inFlight: recoverableDetachedRun,
407
+ activeRequestId: recoverableDetachedRun
408
+ ? snapshot.structuredState?.activeRequestId ?? `recover-pending-${snapshot.id}`
409
+ : null,
402
410
  },
403
411
  selectedModel: snapshot.selectedModel ?? null,
404
412
  titleGenerating: false,
405
413
  };
406
414
  this.sessions.set(restored.id, restored);
407
- this.storage.saveSession(restored);
415
+ // Keep the durable running marker until terminald inventory has been
416
+ // reconciled. If web is restarted again during startup, the next owner
417
+ // must still know there is a daemon run to adopt.
418
+ if (!recoverableDetachedRun)
419
+ this.storage.saveSession(restored);
408
420
  }
409
421
  this.archiveExpiredSessions();
410
422
  this.archiveTimer = setInterval(() => {
@@ -445,6 +457,10 @@ export class StructuredSessionManager {
445
457
  clearInterval(this.archiveTimer);
446
458
  this.archiveTimer = null;
447
459
  }
460
+ if (this.detachedRecoveryRetryTimer) {
461
+ clearTimeout(this.detachedRecoveryRetryTimer);
462
+ this.detachedRecoveryRetryTimer = null;
463
+ }
448
464
  for (const timer of this.streamEmitTimers)
449
465
  clearTimeout(timer);
450
466
  this.streamEmitTimers.clear();
@@ -518,33 +534,90 @@ export class StructuredSessionManager {
518
534
  // Detached-run recovery: re-attach CLI runs that kept going inside terminald
519
535
  // while the previous web process was down.
520
536
  // ---------------------------------------------------------------------------
521
- /** Called once after startup wiring; safe to skip when no host or candidates. */
522
- async recoverDetachedRuns() {
523
- if (this.disposed || this.execHost?.persistent !== true || this.pendingRecoveryIds.length === 0)
537
+ /** Called once after startup wiring; transient daemon failures retry automatically. */
538
+ recoverDetachedRuns() {
539
+ if (this.disposed || this.execHost?.persistent !== true || this.pendingRecoveryIds.length === 0) {
540
+ return Promise.resolve();
541
+ }
542
+ if (this.detachedRecoveryPromise)
543
+ return this.detachedRecoveryPromise;
544
+ if (this.detachedRecoveryRetryTimer) {
545
+ clearTimeout(this.detachedRecoveryRetryTimer);
546
+ this.detachedRecoveryRetryTimer = null;
547
+ }
548
+ const recovery = this.recoverDetachedRunsOnce();
549
+ this.detachedRecoveryPromise = recovery;
550
+ const clearRecovery = () => {
551
+ if (this.detachedRecoveryPromise === recovery)
552
+ this.detachedRecoveryPromise = null;
553
+ };
554
+ void recovery.then(clearRecovery, clearRecovery);
555
+ return recovery;
556
+ }
557
+ async recoverDetachedRunsOnce() {
558
+ if (this.disposed || !this.execHost)
524
559
  return;
525
- const ids = this.pendingRecoveryIds;
526
- this.pendingRecoveryIds = [];
527
560
  let runs;
528
561
  try {
529
562
  runs = await this.execHost.listRuns();
563
+ this.detachedRecoveryFailureLogged = false;
530
564
  }
531
565
  catch (error) {
532
- process.stderr.write(`[wand] structured run recovery skipped: ${getErrorMessage(error)}\n`);
566
+ if (!this.detachedRecoveryFailureLogged) {
567
+ this.detachedRecoveryFailureLogged = true;
568
+ process.stderr.write(`[wand] structured run recovery waiting for terminal daemon: ${getErrorMessage(error)}\n`);
569
+ }
570
+ this.scheduleDetachedRecovery();
533
571
  return;
534
572
  }
573
+ const ids = this.pendingRecoveryIds;
574
+ this.pendingRecoveryIds = [];
535
575
  const byRunId = new Map(runs.map((run) => [run.runId, run]));
536
576
  for (const sessionId of ids) {
537
- const state = byRunId.get(structuredRunId(sessionId));
538
577
  const session = this.sessions.get(sessionId);
539
- if (!state || !session)
578
+ if (!session)
579
+ continue;
580
+ const state = byRunId.get(structuredRunId(sessionId));
581
+ if (!state) {
582
+ // Inventory was read successfully, so this is a genuinely lost run,
583
+ // not a temporary control-plane outage. Commit the interrupted view.
584
+ const interrupted = {
585
+ ...session,
586
+ status: "idle",
587
+ exitCode: null,
588
+ endedAt: null,
589
+ structuredState: {
590
+ ...session.structuredState,
591
+ inFlight: false,
592
+ activeRequestId: null,
593
+ lastError: "服务重启,上一轮已中断。",
594
+ },
595
+ };
596
+ this.sessions.set(sessionId, interrupted);
597
+ this.saveAuthoritativeSession(interrupted);
598
+ this.emitStructuredSnapshot(interrupted);
540
599
  continue;
600
+ }
541
601
  try {
542
602
  await this.resumeDetachedRun(session, state);
543
603
  }
544
604
  catch (error) {
545
605
  console.error(`[WAND] structured run recovery failed for ${sessionId}:`, error);
606
+ if (!this.disposed && !this.pendingRecoveryIds.includes(sessionId)) {
607
+ this.pendingRecoveryIds.push(sessionId);
608
+ }
546
609
  }
547
610
  }
611
+ this.scheduleDetachedRecovery();
612
+ }
613
+ scheduleDetachedRecovery() {
614
+ if (this.disposed || this.pendingRecoveryIds.length === 0 || this.detachedRecoveryRetryTimer)
615
+ return;
616
+ this.detachedRecoveryRetryTimer = setTimeout(() => {
617
+ this.detachedRecoveryRetryTimer = null;
618
+ void this.recoverDetachedRuns();
619
+ }, DETACHED_RECOVERY_RETRY_MS);
620
+ this.detachedRecoveryRetryTimer.unref?.();
548
621
  }
549
622
  async resumeDetachedRun(snapshot, initialState) {
550
623
  const sessionId = snapshot.id;
@@ -589,6 +662,7 @@ export class StructuredSessionManager {
589
662
  structuredState: {
590
663
  ...current.structuredState,
591
664
  model: turnState.model ?? current.structuredState?.model,
665
+ phase: turnState.phase ?? current.structuredState?.phase,
592
666
  },
593
667
  };
594
668
  this.sessions.set(sessionId, patched);
@@ -617,24 +691,12 @@ export class StructuredSessionManager {
617
691
  }
618
692
  };
619
693
  let runningHandle = null;
620
- let fedChars = 0;
621
- const snapshotChars = initialState.stdoutLog.length;
694
+ let lastStdoutSeq = initialState.stdoutSeq;
695
+ let lastStderrSeq = initialState.stderrSeq;
622
696
  const feedLine = (line) => {
623
697
  onApplied(processor.feed(line));
624
698
  };
625
699
  const feedDelta = (text) => {
626
- // Skip the portion already covered by the attach snapshot to avoid
627
- // double-feeding overlapping events buffered during adoption.
628
- if (fedChars < snapshotChars) {
629
- const remaining = snapshotChars - fedChars;
630
- if (text.length <= remaining) {
631
- fedChars += text.length;
632
- return;
633
- }
634
- text = text.slice(remaining);
635
- fedChars = snapshotChars;
636
- }
637
- fedChars += text.length;
638
700
  carry += text;
639
701
  const lines = carry.split("\n");
640
702
  carry = lines.pop() ?? "";
@@ -669,7 +731,6 @@ export class StructuredSessionManager {
669
731
  carry = lines.pop() ?? "";
670
732
  for (const line of lines)
671
733
  feedLine(line);
672
- fedChars = initialState.stdoutLog.length - carry.length;
673
734
  }
674
735
  runningHandle = await this.execHost.adoptRun(structuredRunId(sessionId));
675
736
  if (!runningHandle) {
@@ -685,10 +746,20 @@ export class StructuredSessionManager {
685
746
  return;
686
747
  }
687
748
  runningHandle.onStream((event) => {
688
- if (event.stream === "stdout")
749
+ // Events may be buffered while structuredAttach is in flight. Sequence
750
+ // watermarks distinguish overlap with the snapshot from genuinely new
751
+ // output; character-count skipping corrupts a new short event.
752
+ if (event.stream === "stdout") {
753
+ if (event.seq <= lastStdoutSeq)
754
+ return;
755
+ lastStdoutSeq = event.seq;
689
756
  feedDelta(event.data);
690
- else
691
- processor.stderr += event.data;
757
+ return;
758
+ }
759
+ if (event.seq <= lastStderrSeq)
760
+ return;
761
+ lastStderrSeq = event.seq;
762
+ processor.stderr += event.data;
692
763
  });
693
764
  runningHandle.onExit((event) => {
694
765
  if (carry.trim())
@@ -750,6 +821,7 @@ export class StructuredSessionManager {
750
821
  inFlight: false,
751
822
  activeRequestId: null,
752
823
  lastError: null,
824
+ phase: undefined,
753
825
  },
754
826
  };
755
827
  this.sessions.set(sessionId, finished);
@@ -2317,6 +2389,7 @@ export class StructuredSessionManager {
2317
2389
  structuredState: {
2318
2390
  ...current.structuredState,
2319
2391
  model: turnState.model ?? current.structuredState?.model,
2392
+ phase: turnState.phase ?? current.structuredState?.phase,
2320
2393
  },
2321
2394
  };
2322
2395
  this.sessions.set(sessionId, patched);
@@ -2441,6 +2514,7 @@ export class StructuredSessionManager {
2441
2514
  inFlight: false,
2442
2515
  activeRequestId: null,
2443
2516
  lastError: null,
2517
+ phase: undefined,
2444
2518
  },
2445
2519
  };
2446
2520
  this.sessions.set(sessionId, finished);
@@ -3072,6 +3146,7 @@ export class StructuredSessionManager {
3072
3146
  inFlight: false,
3073
3147
  activeRequestId: null,
3074
3148
  lastError: errorText,
3149
+ phase: undefined,
3075
3150
  },
3076
3151
  };
3077
3152
  }
@@ -6,7 +6,7 @@ export declare class TerminalDaemonClient implements TerminalHost, StructuredExe
6
6
  private readonly token;
7
7
  readonly persistent = true;
8
8
  private socket;
9
- private buffer;
9
+ private frameParts;
10
10
  /** Decodes daemon NDJSON frames without splitting multi-byte chars. */
11
11
  private decoder;
12
12
  private nextRequestId;
@@ -223,7 +223,7 @@ export class TerminalDaemonClient {
223
223
  token;
224
224
  persistent = true;
225
225
  socket = null;
226
- buffer = "";
226
+ frameParts = [];
227
227
  /** Decodes daemon NDJSON frames without splitting multi-byte chars. */
228
228
  decoder = new StringDecoder("utf8");
229
229
  nextRequestId = 1;
@@ -263,6 +263,7 @@ export class TerminalDaemonClient {
263
263
  // Each socket decodes from a clean slate; a half-written character from a
264
264
  // previous connection must not leak into the next frame.
265
265
  this.decoder = new StringDecoder("utf8");
266
+ this.frameParts = [];
266
267
  socket.on("data", (data) => this.consume(this.decoder.write(data)));
267
268
  socket.on("close", () => this.handleDisconnect());
268
269
  socket.on("error", (error) => {
@@ -563,13 +564,20 @@ export class TerminalDaemonClient {
563
564
  };
564
565
  }
565
566
  consume(chunk) {
566
- this.buffer += chunk;
567
- while (true) {
568
- const newline = this.buffer.indexOf("\n");
569
- if (newline < 0)
567
+ // Replay inventories can span tens of MB. Scan only the incoming chunk;
568
+ // concatenating and rescanning the partial frame on every socket read is
569
+ // quadratic and can starve unrelated create/attach requests until timeout.
570
+ let offset = 0;
571
+ while (offset < chunk.length) {
572
+ const newline = chunk.indexOf("\n", offset);
573
+ if (newline < 0) {
574
+ this.frameParts.push(chunk.slice(offset));
570
575
  return;
571
- const line = this.buffer.slice(0, newline);
572
- this.buffer = this.buffer.slice(newline + 1);
576
+ }
577
+ this.frameParts.push(chunk.slice(offset, newline));
578
+ const line = this.frameParts.join("");
579
+ this.frameParts = [];
580
+ offset = newline + 1;
573
581
  if (!line)
574
582
  continue;
575
583
  let message;
@@ -694,8 +702,7 @@ export async function createTerminalHost(configPath) {
694
702
  const starting = await waitForTerminalDaemon(paths.socketPath, paths.tokenPath, 5_000);
695
703
  if (starting)
696
704
  return starting;
697
- process.stderr.write("[wand] Existing terminal daemon rejected adoption; using non-persistent PTYs without replacing it.\n");
698
- return new InProcessTerminalHost();
705
+ throw new Error("Existing terminal daemon rejected adoption; refusing non-persistent fallback because web restarts must not stop running tasks.");
699
706
  }
700
707
  }
701
708
  // Service managers can start terminald and the web process at nearly the
@@ -705,8 +712,7 @@ export async function createTerminalHost(configPath) {
705
712
  const adopted = await waitForTerminalDaemon(paths.socketPath, paths.tokenPath, 5_000);
706
713
  if (adopted)
707
714
  return adopted;
708
- process.stderr.write("[wand] Terminal daemon process is alive but its socket is unavailable; using non-persistent PTYs without replacing it.\n");
709
- return new InProcessTerminalHost();
715
+ throw new Error("Terminal daemon process is alive but its socket is unavailable; refusing non-persistent fallback because web restarts must not stop running tasks.");
710
716
  }
711
717
  try {
712
718
  startDetachedDaemon(configPath);
@@ -717,8 +723,7 @@ export async function createTerminalHost(configPath) {
717
723
  catch (error) {
718
724
  process.stderr.write(`[wand] Failed to start terminal daemon: ${error instanceof Error ? error.message : String(error)}\n`);
719
725
  }
720
- process.stderr.write("[wand] Terminal daemon unavailable; falling back to in-process PTYs.\n");
721
- return new InProcessTerminalHost();
726
+ throw new Error("Terminal daemon unavailable; refusing non-persistent fallback because web restarts must not stop running tasks.");
722
727
  }
723
728
  async function waitForTerminalDaemon(socketPath, tokenPath, timeoutMs) {
724
729
  const deadline = Date.now() + timeoutMs;
package/dist/types.d.ts CHANGED
@@ -519,6 +519,8 @@ export interface StructuredSessionState {
519
519
  lastError: string | null;
520
520
  inFlight: boolean;
521
521
  activeRequestId: string | null;
522
+ /** Pi 已结束前台回复、但 CLI 仍在等待异步子任务收尾。 */
523
+ phase?: "responding" | "background";
522
524
  }
523
525
  export interface SessionSnapshot {
524
526
  id: string;