@co0ontty/wand 4.83.1 → 4.84.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (188) hide show
  1. package/README.md +4 -0
  2. package/dist/agent-dispatch.js +5 -1
  3. package/dist/ai-team-availability.d.ts +1 -1
  4. package/dist/ai-team-prompts.js +13 -4
  5. package/dist/ai-team-runner.d.ts +80 -5
  6. package/dist/ai-team-runner.js +332 -55
  7. package/dist/ai-team-types.d.ts +92 -5
  8. package/dist/ai-team-types.js +9 -2
  9. package/dist/build-info.json +3 -3
  10. package/dist/cli-api.d.ts +1 -0
  11. package/dist/cli-api.js +3 -0
  12. package/dist/cli.js +18 -1
  13. package/dist/commit-task-archive.d.ts +3 -1
  14. package/dist/commit-task-archive.js +21 -10
  15. package/dist/config.d.ts +1 -1
  16. package/dist/config.js +73 -0
  17. package/dist/conversation-mentions.d.ts +16 -0
  18. package/dist/conversation-mentions.js +32 -0
  19. package/dist/conversation-service.d.ts +90 -0
  20. package/dist/conversation-service.js +759 -0
  21. package/dist/conversation-session-preview.d.ts +9 -0
  22. package/dist/conversation-session-preview.js +47 -0
  23. package/dist/conversation-task-preview.d.ts +5 -0
  24. package/dist/conversation-task-preview.js +8 -0
  25. package/dist/conversation-types.d.ts +80 -0
  26. package/dist/conversation-types.js +7 -0
  27. package/dist/core-extension-host.d.ts +27 -0
  28. package/dist/core-extension-host.js +138 -0
  29. package/dist/core-runner.d.ts +86 -0
  30. package/dist/core-runner.js +757 -0
  31. package/dist/core-status-cli.d.ts +9 -0
  32. package/dist/core-status-cli.js +280 -0
  33. package/dist/core-turn-tracker.d.ts +12 -0
  34. package/dist/core-turn-tracker.js +36 -0
  35. package/dist/decision-runner.d.ts +6 -0
  36. package/dist/decision-runner.js +3 -1
  37. package/dist/decision-tool.d.ts +2 -0
  38. package/dist/decision-tool.js +49 -22
  39. package/dist/env-utils.d.ts +7 -3
  40. package/dist/env-utils.js +30 -5
  41. package/dist/git-quick-commit.d.ts +5 -0
  42. package/dist/git-quick-commit.js +9 -3
  43. package/dist/git-utils.js +2 -1
  44. package/dist/harness-engine.d.ts +110 -0
  45. package/dist/harness-engine.js +331 -0
  46. package/dist/mission-diff.js +2 -1
  47. package/dist/model-auto-assign.d.ts +50 -0
  48. package/dist/model-auto-assign.js +163 -0
  49. package/dist/model-group-runner.d.ts +4 -0
  50. package/dist/model-group-runner.js +72 -0
  51. package/dist/model-groups.d.ts +38 -0
  52. package/dist/model-groups.js +138 -0
  53. package/dist/models.d.ts +12 -0
  54. package/dist/models.js +102 -8
  55. package/dist/npm-update-utils.js +2 -1
  56. package/dist/openrouter-free-cli.d.ts +25 -0
  57. package/dist/openrouter-free-cli.js +109 -0
  58. package/dist/openrouter-free-models.d.ts +85 -0
  59. package/dist/openrouter-free-models.js +458 -0
  60. package/dist/openrouter-free-selection.d.ts +5 -0
  61. package/dist/openrouter-free-selection.js +7 -0
  62. package/dist/path-repair.d.ts +10 -0
  63. package/dist/path-repair.js +50 -5
  64. package/dist/pi-auto-resources.d.ts +12 -0
  65. package/dist/pi-auto-resources.js +151 -0
  66. package/dist/pi-execution-types.d.ts +48 -0
  67. package/dist/pi-execution-types.js +1 -0
  68. package/dist/pi-execution.d.ts +9 -0
  69. package/dist/pi-execution.js +187 -0
  70. package/dist/pi-extension-resources.d.ts +13 -0
  71. package/dist/pi-extension-resources.js +71 -0
  72. package/dist/pi-openrouter-free-bridge.d.ts +2 -0
  73. package/dist/pi-openrouter-free-bridge.js +80 -0
  74. package/dist/pi-resource-bridge.d.ts +2 -0
  75. package/dist/pi-resource-bridge.js +73 -0
  76. package/dist/pi-resource-catalog.d.ts +18 -0
  77. package/dist/pi-resource-catalog.js +82 -0
  78. package/dist/pi-resource-recommendation.d.ts +29 -0
  79. package/dist/pi-resource-recommendation.js +169 -0
  80. package/dist/pi-resource-run.d.ts +8 -0
  81. package/dist/pi-resource-run.js +28 -0
  82. package/dist/pi-session-settings.d.ts +157 -0
  83. package/dist/pi-session-settings.js +166 -0
  84. package/dist/platform-action-catalog.d.ts +24 -0
  85. package/dist/platform-action-catalog.js +141 -0
  86. package/dist/platform-action-service.d.ts +37 -0
  87. package/dist/platform-action-service.js +138 -0
  88. package/dist/platform-action-types.d.ts +125 -0
  89. package/dist/platform-action-types.js +7 -0
  90. package/dist/process-manager.d.ts +21 -1
  91. package/dist/process-manager.js +168 -15
  92. package/dist/provider-catalog.js +1 -1
  93. package/dist/provider-cli-updater.js +29 -8
  94. package/dist/provider-history-scanner.d.ts +9 -0
  95. package/dist/provider-history-scanner.js +107 -1
  96. package/dist/retention.d.ts +19 -7
  97. package/dist/retention.js +171 -28
  98. package/dist/server-ai-team-routes.js +6 -2
  99. package/dist/server-conversation-routes.d.ts +5 -0
  100. package/dist/server-conversation-routes.js +91 -0
  101. package/dist/server-employee-routes.d.ts +2 -0
  102. package/dist/server-employee-routes.js +85 -11
  103. package/dist/server-pi-recommendation-routes.d.ts +13 -0
  104. package/dist/server-pi-recommendation-routes.js +67 -0
  105. package/dist/server-resume-routes.js +1 -1
  106. package/dist/server-session-routes.d.ts +4 -3
  107. package/dist/server-session-routes.js +182 -37
  108. package/dist/server-settings-routes.d.ts +5 -0
  109. package/dist/server-settings-routes.js +46 -0
  110. package/dist/server-task-routes.js +49 -4
  111. package/dist/server-team-dispatch-routes.d.ts +19 -0
  112. package/dist/server-team-dispatch-routes.js +71 -0
  113. package/dist/server-update-routes.d.ts +1 -1
  114. package/dist/server-update-routes.js +10 -5
  115. package/dist/server-workspace-routes.js +44 -24
  116. package/dist/server.d.ts +1 -0
  117. package/dist/server.js +148 -30
  118. package/dist/session-ai-context.d.ts +5 -1
  119. package/dist/session-ai-context.js +9 -4
  120. package/dist/session-registry.d.ts +4 -0
  121. package/dist/session-registry.js +47 -13
  122. package/dist/session-transport.js +8 -1
  123. package/dist/settings-web-access.d.ts +15 -0
  124. package/dist/settings-web-access.js +37 -0
  125. package/dist/silicon-employee-defaults.d.ts +29 -0
  126. package/dist/silicon-employee-defaults.js +41 -0
  127. package/dist/silicon-employee-dispatch.d.ts +5 -1
  128. package/dist/silicon-employee-dispatch.js +10 -4
  129. package/dist/silicon-employee-draft.d.ts +21 -4
  130. package/dist/silicon-employee-draft.js +103 -16
  131. package/dist/storage.d.ts +73 -2
  132. package/dist/storage.js +362 -26
  133. package/dist/structured-claude-protocol.js +1 -1
  134. package/dist/structured-client-protocol.js +11 -33
  135. package/dist/structured-content.d.ts +23 -0
  136. package/dist/structured-content.js +72 -0
  137. package/dist/structured-failure.d.ts +16 -0
  138. package/dist/structured-failure.js +36 -0
  139. package/dist/structured-pi-adapter.d.ts +8 -3
  140. package/dist/structured-pi-adapter.js +131 -30
  141. package/dist/structured-provider-common.d.ts +6 -0
  142. package/dist/structured-provider-common.js +23 -1
  143. package/dist/structured-runner.d.ts +45 -0
  144. package/dist/structured-session-manager.d.ts +78 -4
  145. package/dist/structured-session-manager.js +513 -129
  146. package/dist/structured-thinking.d.ts +15 -0
  147. package/dist/structured-thinking.js +46 -0
  148. package/dist/subagent-dispatch.d.ts +56 -0
  149. package/dist/subagent-dispatch.js +102 -0
  150. package/dist/system-employee.d.ts +4 -3
  151. package/dist/system-employee.js +7 -4
  152. package/dist/task-retention.d.ts +23 -0
  153. package/dist/task-retention.js +69 -0
  154. package/dist/task-types.d.ts +4 -0
  155. package/dist/task-types.js +3 -0
  156. package/dist/team-dispatch.d.ts +101 -0
  157. package/dist/team-dispatch.js +269 -0
  158. package/dist/tool-preview.js +7 -1
  159. package/dist/tui/commands.js +50 -1
  160. package/dist/tui/ipc-protocol.d.ts +1 -1
  161. package/dist/tui/ipc-server.d.ts +4 -0
  162. package/dist/tui/ipc-server.js +23 -0
  163. package/dist/turn-heartbeat.d.ts +35 -0
  164. package/dist/turn-heartbeat.js +64 -0
  165. package/dist/types.d.ts +147 -1
  166. package/dist/user-profile.d.ts +33 -0
  167. package/dist/user-profile.js +70 -0
  168. package/dist/web-ui/chat-message-meta.d.ts +11 -0
  169. package/dist/web-ui/chat-message-meta.js +47 -0
  170. package/dist/web-ui/content/ai-teams.js +13 -13
  171. package/dist/web-ui/content/scripts.js +795 -95
  172. package/dist/web-ui/content/styles.css +1 -1
  173. package/dist/web-ui/content/tailwind.css +1 -6808
  174. package/dist/web-ui/content/vendor/qrcode/qrcode.bundle.js +1 -1
  175. package/dist/web-ui/embedded-assets.d.ts +1 -1
  176. package/dist/web-ui/embedded-assets.js +5 -5
  177. package/dist/web-ui/index.d.ts +1 -1
  178. package/dist/web-ui/index.js +3 -3
  179. package/dist/web-ui/markdown.d.ts +2 -0
  180. package/dist/web-ui/markdown.js +111 -0
  181. package/dist/web-ui/page.d.ts +4 -0
  182. package/dist/web-ui/page.js +8 -0
  183. package/dist/web-ui/react/local-preview/controller.d.ts +30 -0
  184. package/dist/web-ui/react/local-preview/controller.js +159 -0
  185. package/dist/web-ui/running-activity.d.ts +63 -0
  186. package/dist/web-ui/running-activity.js +134 -0
  187. package/dist/web-ui/styles.js +2 -11
  188. package/package.json +7 -2
@@ -1,3 +1,8 @@
1
+ import { defaultModelGroupSelector, isAutoAssignSelector, resolveModelGroupModels } from "./model-groups.js";
2
+ import { resolveAutoAssign } from "./model-auto-assign.js";
3
+ import { withModelGroups } from "./model-group-runner.js";
4
+ import { classifyStructuredFailure, hasStructuredExecutionProgress } from "./structured-failure.js";
5
+ import { withAutomaticPiResources } from "./pi-auto-resources.js";
1
6
  import { randomUUID } from "node:crypto";
2
7
  import { prepareSessionWorktree } from "./git-worktree.js";
3
8
  import { truncateMessagesForTransport } from "./message-truncator.js";
@@ -7,6 +12,10 @@ import { getErrorMessage } from "./error-utils.js";
7
12
  import { getDefaultModelForProvider } from "./config.js";
8
13
  import { recordIterationPrompt } from "./iteration-log.js";
9
14
  import { startEmployeeKnowledgeRunner } from "./employee-knowledge.js";
15
+ import { CoreRunner } from "./core-runner.js";
16
+ import { CoreTurnTracker } from "./core-turn-tracker.js";
17
+ import { coreHarnessAgentDir, resolveHarnessEngineSync } from "./harness-engine.js";
18
+ import { cliPiSettingsRejection, defaultPiCliSessionSettings, defaultPiSessionSettings, patchPiSessionSettings } from "./pi-session-settings.js";
10
19
  import { withDecisionAccess } from "./decision-runner.js";
11
20
  import { signalNameFromNumber } from "./signal-utils.js";
12
21
  import { provisionalSessionTopic, SessionNativeTitleTracker, SessionTopicCoordinator, sessionTopicBlocklistForSnapshot, shouldAcceptGeneratedSessionTitle, shouldGenerateSessionTopicFromInput, } from "./session-topic.js";
@@ -26,6 +35,7 @@ import { PiRunner, applyPiEvent, isMissingPiSession } from "./structured-pi-adap
26
35
  import { GeminiRunner, applyGeminiEvent, isMissingGeminiSession } from "./structured-gemini-adapter.js";
27
36
  import { structuredRunId, } from "./structured-exec-host.js";
28
37
  import { defaultStructuredRunner, defaultStructuredState, isStructuredRunnerForProvider, normalizeThinkingEffort, resolveStructuredRunner, } from "./structured-provider-common.js";
38
+ import { TurnQuietHeartbeat } from "./turn-heartbeat.js";
29
39
  import { enrichStructuredMessages, WAND_PROTOCOL_VERSION } from "./structured-client-protocol.js";
30
40
  import { RETENTION_IDLE_MS } from "./retention.js";
31
41
  class PersistedStructuredRunnerError extends Error {
@@ -34,12 +44,17 @@ class PersistedStructuredRunnerError extends Error {
34
44
  this.name = "PersistedStructuredRunnerError";
35
45
  }
36
46
  }
37
- /** The child process never started, so retrying the same input with another CLI cannot duplicate work. */
38
- class UnacceptedStructuredSpawnError extends Error {
39
- constructor(message) {
47
+ /** Runner-owned refusal facts, never inferred by schedulers from a displayed error. */
48
+ class UnacceptedStructuredInputError extends Error {
49
+ failure;
50
+ #state;
51
+ constructor(message, failure, state) {
40
52
  super(message);
41
- this.name = "UnacceptedStructuredSpawnError";
53
+ this.failure = failure;
54
+ this.name = "UnacceptedStructuredInputError";
55
+ this.#state = state;
42
56
  }
57
+ get state() { return this.#state; }
43
58
  }
44
59
  function buildReplayProcessor(session) {
45
60
  const runner = session.runner;
@@ -113,7 +128,7 @@ function buildReplayProcessor(session) {
113
128
  state,
114
129
  stderr: "",
115
130
  primaryError: null,
116
- feed: (line) => {
131
+ feed: (line, observedAt) => {
117
132
  const trimmed = line.trim();
118
133
  if (!trimmed)
119
134
  return false;
@@ -130,7 +145,7 @@ function buildReplayProcessor(session) {
130
145
  ? applyOpenCodeEvent(state, event)
131
146
  : runner === "gemini-cli-json"
132
147
  ? applyGeminiEvent(state, event)
133
- : applyPiEvent(state, event);
148
+ : applyPiEvent(state, event, observedAt);
134
149
  if (error)
135
150
  processor.primaryError = error;
136
151
  return true;
@@ -376,6 +391,23 @@ class TruncatedReplayView {
376
391
  function appendNoticeTurn(messages, turn) {
377
392
  return [...(messages ?? []), { ...turn, createdAt: isoNow(), completedAt: isoNow() }];
378
393
  }
394
+ /** core 引擎的压缩通知 → 可持久化的会话上下文(历史不删,只记切点与摘要)。 */
395
+ function compactionToSessionContext(notice) {
396
+ return {
397
+ summary: notice.summary,
398
+ fromTurnIndex: notice.fromTurnIndex,
399
+ tokensBefore: notice.tokensBefore,
400
+ compactions: notice.compactions,
401
+ };
402
+ }
403
+ function cancelPendingResourceSelection(messages) {
404
+ const last = messages?.at(-1);
405
+ if (!messages || last?.role !== "assistant" || last.resourceSelection?.status !== "selecting")
406
+ return messages;
407
+ return [...messages.slice(0, -1), { ...last, completedAt: isoNow(), resourceSelection: {
408
+ ...last.resourceSelection, status: "cancelled", label: "本轮自动选择已取消",
409
+ } }];
410
+ }
379
411
  function upsertAssistantMessage(messages, turn, complete = false, stampNewTools = true) {
380
412
  const msgs = [...(messages ?? [])];
381
413
  const last = msgs[msgs.length - 1];
@@ -383,6 +415,8 @@ function upsertAssistantMessage(messages, turn, complete = false, stampNewTools
383
415
  const createdAt = (last?.role === "assistant" ? last.createdAt : undefined) ?? turn.createdAt ?? observedAt;
384
416
  const next = {
385
417
  ...turn,
418
+ ...(turn.resourceSelection ? { resourceSelection: turn.resourceSelection }
419
+ : last?.role === "assistant" && last.resourceSelection ? { resourceSelection: last.resourceSelection } : {}),
386
420
  createdAt,
387
421
  content: stampNewToolUseTimes(turn.content, last?.role === "assistant" ? last.content : undefined, observedAt, stampNewTools),
388
422
  };
@@ -401,6 +435,8 @@ export class StructuredSessionManager {
401
435
  config;
402
436
  logger;
403
437
  execHost;
438
+ decisionRuntime;
439
+ autoAssignEvaluate;
404
440
  sessions = new Map();
405
441
  // Both freshly started runners and adopted daemon runs own the same input gate.
406
442
  pendingRunnerExecutions = new Map();
@@ -408,6 +444,11 @@ export class StructuredSessionManager {
408
444
  // Never persisted or included in session DTOs.
409
445
  unacceptedTeamStarts = new Map();
410
446
  interruptedWith = new Map();
447
+ /**
448
+ * 「智能分配」正在结算的会话:这段窗口内还没有 runner 归属,新输入必须排队而不是另起一轮。
449
+ * token.cancelled 由 stop / delete 置位,结算回来后不得再启动。
450
+ */
451
+ autoAssignPending = new Map();
411
452
  preserveQueueOnInterrupt = new Set();
412
453
  /** Last wall-clock time (ms) a streaming checkpoint reached SQLite. */
413
454
  lastStreamSaveAt = new Map();
@@ -424,6 +465,11 @@ export class StructuredSessionManager {
424
465
  relayHandlers = new Map();
425
466
  emitEvent = null;
426
467
  archiveTimer = null;
468
+ /**
469
+ * 静默期心跳:回合在飞且连续 TURN_QUIET_HEARTBEAT_MS 没有任何事件时,用现有的
470
+ * status 事件重发一次权威快照(含最新 turn 锚点)。timer 只在 inFlight 期间存在。
471
+ */
472
+ turnHeartbeat = new TurnQuietHeartbeat((sessionId) => this.publishQuietHeartbeat(sessionId));
427
473
  topicCoordinator = new SessionTopicCoordinator();
428
474
  nativeTitles = new SessionNativeTitleTracker();
429
475
  streamEmitTimers = new Set();
@@ -433,6 +479,12 @@ export class StructuredSessionManager {
433
479
  grokRunner;
434
480
  qoderRunner;
435
481
  piRunner;
482
+ /** 进程内 core 引擎供员工候选显式启用;普通 Pi 主功能不使用它。 */
483
+ coreRunner;
484
+ /** 宿主是否显式注入了 pi 的 CLI runner / core runner,供系统级引擎状态使用。 */
485
+ piRunnerSupplied;
486
+ coreRunnerSupplied;
487
+ coreRestartDrains = 0;
436
488
  geminiRunner;
437
489
  /** Structured CLI runs that were mid-flight when the previous web process died. */
438
490
  pendingRecoveryIds = [];
@@ -440,19 +492,28 @@ export class StructuredSessionManager {
440
492
  detachedRecoveryRetryTimer = null;
441
493
  detachedRecoveryFailureLogged = false;
442
494
  disposed = false;
443
- constructor(storage, config, logger = null, runners = {}, execHost, decisionRuntime = () => null) {
495
+ constructor(storage, config, logger = null, runners = {}, execHost, decisionRuntime = () => null, openRouter,
496
+ /** 「智能分配」专用的本地决策入口:只有真正能跑时才注入,没注入则用系统员工回退。 */
497
+ autoAssignEvaluate = () => undefined) {
444
498
  this.storage = storage;
445
499
  this.config = config;
446
500
  this.logger = logger;
447
501
  this.execHost = execHost;
448
- const wrap = (runner) => withDecisionAccess(runner, storage, decisionRuntime);
502
+ this.decisionRuntime = decisionRuntime;
503
+ this.autoAssignEvaluate = autoAssignEvaluate;
504
+ const wrap = (runner) => withModelGroups(withDecisionAccess(runner, storage, decisionRuntime), config);
449
505
  this.claudeCliRunner = wrap(runners.claudeCli ?? new ClaudeCliRunner({ language: () => this.config.language }, this.execHost));
450
506
  this.codexRunner = wrap(runners.codex ?? new CodexRunner(undefined, this.execHost));
451
507
  this.openCodeRunner = wrap(runners.opencode ?? new OpenCodeRunner(undefined, this.execHost));
452
508
  this.grokRunner = wrap(runners.grok ?? new GrokRunner(undefined, this.execHost));
453
509
  this.qoderRunner = wrap(runners.qoder ?? new QoderRunner(undefined, this.execHost));
454
- this.piRunner = wrap(runners.pi ?? new PiRunner(undefined, this.execHost));
510
+ this.piRunner = wrap(withAutomaticPiResources(runners.pi ?? new PiRunner(undefined, this.execHost, coreHarnessAgentDir(config.harness), openRouter ? { storage, service: openRouter } : undefined), config, { evaluate: () => this.decisionRuntime()?.evaluate }));
511
+ this.coreRunner = withModelGroups(runners.core ?? new CoreRunner({ config: this.config,
512
+ decisionAccess: decisionRuntime, openRouter }), config);
513
+ this.piRunnerSupplied = runners.pi !== undefined;
514
+ this.coreRunnerSupplied = runners.core !== undefined;
455
515
  this.geminiRunner = wrap(runners.gemini ?? new GeminiRunner(undefined, this.execHost));
516
+ // 能力探测放到 server 启动处 await(warmCoreHarness),这里不再另起预热。
456
517
  for (const snapshot of this.storage.loadSessions()) {
457
518
  if ((snapshot.sessionKind ?? "pty") !== "structured")
458
519
  continue;
@@ -466,7 +527,9 @@ export class StructuredSessionManager {
466
527
  ? storedRunner
467
528
  : defaultStructuredRunner(provider);
468
529
  const recoverableDetachedRun = snapshot.status === "running"
469
- && this.execHost?.persistent === true;
530
+ && this.execHost?.persistent === true
531
+ // 进程内 core 回合不会比服务活得更久;重启后只能算中断,不能去 daemon 里领养。
532
+ && snapshot.structuredState?.engine !== "core";
470
533
  if (recoverableDetachedRun)
471
534
  this.pendingRecoveryIds.push(snapshot.id);
472
535
  const restored = {
@@ -483,6 +546,8 @@ export class StructuredSessionManager {
483
546
  pendingEscalation: null,
484
547
  permissionBlocked: false,
485
548
  structuredState: {
549
+ // 保留既有可读投影(engine / contextUsage / compactions / phase),只覆盖受管字段。
550
+ ...(snapshot.structuredState ?? {}),
486
551
  provider,
487
552
  runner,
488
553
  model: snapshot.structuredState?.model ?? snapshot.selectedModel ?? undefined,
@@ -493,6 +558,11 @@ export class StructuredSessionManager {
493
558
  activeRequestId: recoverableDetachedRun
494
559
  ? snapshot.structuredState?.activeRequestId ?? `recover-pending-${snapshot.id}`
495
560
  : null,
561
+ // 锚点跟着真实运行事实走:还能被 terminald 领养的回合保留重启前那一轮的起点
562
+ // (attach 成功后由 adoptTurn 重新挂静默心跳);服务重启导致中断的回合降级为
563
+ // idle,锚点一并清 null,不伪造「还在跑」。
564
+ turnStartedAt: recoverableDetachedRun ? snapshot.structuredState?.turnStartedAt ?? null : null,
565
+ lastActivityAt: recoverableDetachedRun ? snapshot.structuredState?.lastActivityAt ?? null : null,
496
566
  },
497
567
  selectedModel: snapshot.selectedModel ?? null,
498
568
  titleGenerating: false,
@@ -534,12 +604,51 @@ export class StructuredSessionManager {
534
604
  return;
535
605
  this.emitEvent = emitEvent;
536
606
  }
607
+ getCoreTurnStatus() {
608
+ const ids = new Set(CoreTurnTracker.getActiveTurnIds());
609
+ // Include manager-owned executions through their final durable checkpoint.
610
+ for (const [id] of this.pendingRunnerExecutions) {
611
+ if (this.sessions.get(id)?.structuredState?.engine === "core")
612
+ ids.add(id);
613
+ }
614
+ const activeTurnCount = Math.max(CoreTurnTracker.getActiveTurnCount(), ids.size);
615
+ return { hasActiveTurns: activeTurnCount > 0, activeTurnCount, activeTurnIds: [...ids] };
616
+ }
617
+ /** Keep current turns running; new Pi inputs stay in the durable input queue. */
618
+ beginCoreRestartDrain() {
619
+ if (this.disposed)
620
+ throw new Error("StructuredSessionManager has been disposed.");
621
+ this.coreRestartDrains += 1;
622
+ let released = false;
623
+ return () => {
624
+ if (released)
625
+ return;
626
+ released = true;
627
+ this.coreRestartDrains -= 1;
628
+ if (this.coreRestartDrains === 0)
629
+ this.resumeQueuedPiMessages();
630
+ };
631
+ }
632
+ /** Resume accepted inputs after a cancelled drain or after a successful restart. */
633
+ resumeQueuedPiMessages() {
634
+ if (this.disposed || this.coreRestartDrains > 0)
635
+ return;
636
+ for (const session of this.sessions.values()) {
637
+ if (session.provider === "pi" && session.status === "idle"
638
+ && !session.structuredState?.inFlight && !session.structuredState?.lastError
639
+ && (session.queuedMessages?.length ?? 0) > 0) {
640
+ setImmediate(() => { void this.flushNextQueuedMessage(session.id); });
641
+ }
642
+ }
643
+ }
537
644
  /** Stop every runner and flush terminal state before storage is closed. */
538
645
  dispose() {
539
646
  if (this.disposed)
540
647
  return;
541
648
  this.disposed = true;
542
649
  this.unacceptedTeamStarts.clear();
650
+ // 静默心跳不跨服务关闭存活(会话级 timer 不允许泄漏)。
651
+ this.turnHeartbeat.dispose();
543
652
  if (this.archiveTimer) {
544
653
  clearInterval(this.archiveTimer);
545
654
  this.archiveTimer = null;
@@ -568,13 +677,14 @@ export class StructuredSessionManager {
568
677
  continue;
569
678
  const cancelled = {
570
679
  ...session,
680
+ messages: cancelPendingResourceSelection(session.messages),
571
681
  status: "idle",
572
682
  exitCode: null,
573
683
  endedAt: null,
574
684
  pendingEscalation: null,
575
685
  permissionBlocked: false,
576
686
  structuredState: {
577
- ...(session.structuredState ?? defaultStructuredState(session.provider ?? "claude", session.runner)),
687
+ ...this.settleTurn(id, session.structuredState ?? defaultStructuredState(session.provider ?? "claude", session.runner)),
578
688
  inFlight: false,
579
689
  activeRequestId: null,
580
690
  lastError: null,
@@ -594,6 +704,9 @@ export class StructuredSessionManager {
594
704
  this.pendingRunnerExecutions.clear();
595
705
  this.interruptedWith.clear();
596
706
  this.preserveQueueOnInterrupt.clear();
707
+ for (const token of this.autoAssignPending.values())
708
+ token.cancelled = true;
709
+ this.autoAssignPending.clear();
597
710
  for (const timer of this.streamCheckpointTimers.values())
598
711
  clearTimeout(timer);
599
712
  this.streamCheckpointTimers.clear();
@@ -663,7 +776,7 @@ export class StructuredSessionManager {
663
776
  exitCode: null,
664
777
  endedAt: null,
665
778
  structuredState: {
666
- ...session.structuredState,
779
+ ...this.settleTurn(sessionId, session.structuredState),
667
780
  inFlight: false,
668
781
  activeRequestId: null,
669
782
  lastError: "服务重启,上一轮已中断。",
@@ -728,12 +841,12 @@ export class StructuredSessionManager {
728
841
  status: "running",
729
842
  exitCode: null,
730
843
  endedAt: null,
731
- structuredState: {
844
+ structuredState: this.adoptTurn(sessionId, {
732
845
  ...snapshot.structuredState,
733
846
  inFlight: true,
734
847
  activeRequestId: requestId,
735
848
  lastError: null,
736
- },
849
+ }),
737
850
  };
738
851
  this.sessions.set(sessionId, resumed);
739
852
  this.saveAuthoritativeSession(resumed);
@@ -760,11 +873,11 @@ export class StructuredSessionManager {
760
873
  const current = this.currentSessionForRequest(sessionId, requestId);
761
874
  if (!current)
762
875
  return;
763
- const structuredState = {
876
+ const structuredState = this.noteTurnActivity(sessionId, {
764
877
  ...current.structuredState,
765
878
  model: turnState.model ?? current.structuredState?.model,
766
879
  phase: turnState.phase ?? current.structuredState?.phase,
767
- };
880
+ });
768
881
  if (replayTruncated) {
769
882
  // 锚点未定前(首轮 feed 中)只有半截 blocks,先只落元数据。
770
883
  if (!replayView.ready) {
@@ -829,7 +942,7 @@ export class StructuredSessionManager {
829
942
  let lastStdoutSeq = initialState.stdoutSeq;
830
943
  let lastStderrSeq = initialState.stderrSeq;
831
944
  const feedLine = (line) => {
832
- onApplied(processor.feed(line));
945
+ onApplied(processor.feed(line, replayingHistory ? undefined : new Date().toISOString()));
833
946
  };
834
947
  const feedDelta = (text) => {
835
948
  carry += text;
@@ -988,7 +1101,7 @@ export class StructuredSessionManager {
988
1101
  pendingEscalation: null,
989
1102
  permissionBlocked: false,
990
1103
  structuredState: {
991
- ...current.structuredState,
1104
+ ...this.settleTurn(sessionId, current.structuredState),
992
1105
  model: processor.state.model ?? current.structuredState?.model,
993
1106
  inFlight: false,
994
1107
  activeRequestId: null,
@@ -1125,9 +1238,11 @@ export class StructuredSessionManager {
1125
1238
  this.emitStructuredSnapshot(updated);
1126
1239
  return updated;
1127
1240
  }
1128
- /** 归档 / 取消归档:只写标记并广播,不中断运行中的 run,也不删历史。 */
1241
+ /** 归档会先停掉还在跑的回合,会话 ID 和 provider session id 都留着。 */
1129
1242
  setSessionArchived(id, archived) {
1130
- const current = this.requireSession(id);
1243
+ const current = archived && this.requireSession(id).status === "running"
1244
+ ? this.stop(id)
1245
+ : this.requireSession(id);
1131
1246
  const archivedAt = archived ? new Date().toISOString() : null;
1132
1247
  const updated = { ...current, archived, archivedAt };
1133
1248
  this.sessions.set(id, updated);
@@ -1246,10 +1361,22 @@ export class StructuredSessionManager {
1246
1361
  : null;
1247
1362
  const selectedModel = options.model?.trim() || null;
1248
1363
  const initialThinkingEffort = normalizeThinkingEffort(options.thinkingEffort);
1364
+ const autoCompaction = this.config.harness?.compaction?.enabled ?? true;
1365
+ // 员工 SDK 会话不静默继承用户的全局扩展,也不继承 CLI 专属的资源/CodeMode 默认。
1366
+ const sdkEmployeeSession = provider === "pi"
1367
+ && options.employeeCandidates?.[options.employeeCandidateIndex ?? 0]?.engine === "sdk";
1368
+ // CLI Pi 会话从「上次设置」起步:用户在任一 Pi 会话改过的自动选择 / CodeMode / 资源选择
1369
+ // 就是新会话的默认,不需要每开一个会话重配一次。autoCompaction 始终取当前配置。
1370
+ const rememberedPiSettings = provider === "pi" && !sdkEmployeeSession ? this.storage.getPiSessionDefaults() : null;
1249
1371
  const snapshot = {
1250
1372
  id,
1373
+ // Pi 会话从创建起就带着自己的工具/功能安排:面板显示的设置与首轮真实启动参数必须一致。
1374
+ ...(provider === "pi" ? { piSettings: sdkEmployeeSession ? defaultPiSessionSettings(autoCompaction)
1375
+ : rememberedPiSettings ? { ...rememberedPiSettings, autoCompaction }
1376
+ : defaultPiCliSessionSettings(autoCompaction) } : {}),
1251
1377
  sessionKind: "structured",
1252
1378
  sessionSource: options.sessionSource ?? "interactive",
1379
+ title: options.title?.trim() || undefined,
1253
1380
  automationId: options.automationId,
1254
1381
  employeeId: options.employeeId,
1255
1382
  employeeName: options.employeeName,
@@ -1297,6 +1424,9 @@ export class StructuredSessionManager {
1297
1424
  inFlight: false,
1298
1425
  activeRequestId: null,
1299
1426
  lastError: null,
1427
+ // 新会话还没有回合在飞:锚点显式为 null,客户端不会看到「已运行 N 分钟」。
1428
+ turnStartedAt: null,
1429
+ lastActivityAt: null,
1300
1430
  },
1301
1431
  autoRecovered: false,
1302
1432
  autoApprovePermissions: shouldAutoApproveForMode(options.mode),
@@ -1339,40 +1469,54 @@ export class StructuredSessionManager {
1339
1469
  return session.structuredState?.activeRequestId
1340
1470
  ?? (session.status === "failed" && !session.structuredState?.inFlight ? failed?.requestId ?? null : null);
1341
1471
  }
1342
- /** Consume only the exact failed request observed by the team's dispatch, never error-text inference. */
1472
+ /** Compatibility boolean API; typed consumers preserve the reason without parsing user-facing text. */
1343
1473
  consumeUnacceptedTeamStartup(id, requestId) {
1474
+ return this.consumeUnacceptedTeamFailure(id, requestId) !== null;
1475
+ }
1476
+ /** Consume only the exact failed request observed by dispatch, once, before a newer request can replace it. */
1477
+ consumeUnacceptedTeamFailure(id, requestId) {
1344
1478
  const fact = this.unacceptedTeamStarts.get(id);
1345
1479
  if (!fact || fact.requestId !== requestId)
1346
- return false;
1480
+ return null;
1347
1481
  this.unacceptedTeamStarts.delete(id);
1348
1482
  const session = this.sessions.get(id);
1349
1483
  return !this.disposed && session?.status === "failed"
1350
- && !session.structuredState?.activeRequestId && !session.structuredState?.inFlight;
1484
+ && !session.structuredState?.activeRequestId && !session.structuredState?.inFlight ? fact.failure : null;
1485
+ }
1486
+ employeeCandidateModel(agent) {
1487
+ return agent.model === "default"
1488
+ ? defaultModelGroupSelector(this.config.modelGroups, agent.provider, getDefaultModelForProvider(this.config, agent.provider))
1489
+ || getDefaultModelForProvider(this.config, agent.provider) || null : agent.model;
1351
1490
  }
1352
- /** Only an unstarted employee conversation may change CLI after a failed first turn. */
1491
+ /** Only an unstarted employee conversation may replay its explicitly rejected first input. */
1353
1492
  retryEmployeeCandidate(id, original, prompt, error) {
1354
1493
  // Team runs own candidate scheduling; keep the snapshot without a second retry loop.
1355
- if (original.automationId?.startsWith("ai-team:"))
1494
+ if (original.automationId?.startsWith("ai-team:") || !(error instanceof UnacceptedStructuredInputError))
1356
1495
  return null;
1357
1496
  const candidates = original.employeeCandidates ?? [];
1358
- const nextIndex = (original.employeeCandidateIndex ?? 0) + 1;
1359
- if (!original.employeeId || (original.messages?.length ?? 0) !== 0 || nextIndex >= candidates.length)
1497
+ const index = original.employeeCandidateIndex ?? 0;
1498
+ const nextIndex = index + 1;
1499
+ if (!original.employeeId || (original.messages?.length ?? 0) !== 0 || original.claudeSessionId
1500
+ || !error.failure.retryable || nextIndex >= candidates.length)
1360
1501
  return null;
1361
- // 手动选择候选链外的工具时,不把明确选择又换回员工的另一条候选。
1362
- if (original.provider !== candidates[original.employeeCandidateIndex ?? 0]?.provider)
1502
+ const selected = candidates[index];
1503
+ // Explicit tool/model changes, including changes during the pending request, remain authoritative.
1504
+ if (!selected || original.provider !== selected.provider
1505
+ || (original.selectedModel && original.selectedModel !== this.employeeCandidateModel(selected)))
1363
1506
  return null;
1364
1507
  const current = this.sessions.get(id);
1365
- if (!current || (current.status !== "running" && current.status !== "failed"))
1366
- return null;
1367
- if ((current.messages ?? []).length !== 1 || current.messages?.[0]?.role !== "user")
1368
- return null;
1369
- if (!(error instanceof UnacceptedStructuredSpawnError))
1508
+ if (!current || current.status !== "running" || current.provider !== original.provider
1509
+ || current.selectedModel !== original.selectedModel || current.mode !== original.mode
1510
+ || current.piSettings !== original.piSettings)
1370
1511
  return null;
1371
1512
  const next = candidates[nextIndex];
1372
1513
  const runner = resolveStructuredRunner(next.provider, undefined);
1373
- const model = next.model === "default"
1374
- ? getDefaultModelForProvider(this.config, next.provider) || null
1375
- : next.model;
1514
+ const model = this.employeeCandidateModel(next);
1515
+ const sameTool = next.provider === original.provider && (next.engine ?? "cli") === (selected.engine ?? "cli");
1516
+ const piSettings = next.provider !== "pi" ? undefined : sameTool ? current.piSettings
1517
+ : next.engine === "sdk" ? defaultPiSessionSettings(this.config.harness?.compaction?.enabled ?? true)
1518
+ : { ...(this.storage.getPiSessionDefaults() ?? defaultPiCliSessionSettings()),
1519
+ autoCompaction: this.config.harness?.compaction?.enabled ?? true };
1376
1520
  const retry = {
1377
1521
  ...current,
1378
1522
  provider: next.provider,
@@ -1383,8 +1527,12 @@ export class StructuredSessionManager {
1383
1527
  selectedModel: model,
1384
1528
  thinkingEffort: next.thinkingEffort,
1385
1529
  employeeCandidateIndex: nextIndex,
1530
+ piSettings,
1386
1531
  claudeSessionId: null,
1532
+ harnessContext: undefined,
1533
+ harnessExtensionState: undefined,
1387
1534
  messages: [],
1535
+ output: "",
1388
1536
  status: "idle",
1389
1537
  exitCode: null,
1390
1538
  endedAt: null,
@@ -1393,11 +1541,24 @@ export class StructuredSessionManager {
1393
1541
  model: model ?? undefined,
1394
1542
  },
1395
1543
  };
1544
+ this.logger?.appendStructuredSpawn(id, { kind: "candidate-fallback", failureKind: error.failure.kind,
1545
+ fromProvider: original.provider, toProvider: next.provider, candidateIndex: nextIndex });
1396
1546
  this.sessions.set(id, retry);
1397
1547
  this.saveAuthoritativeSession(retry);
1398
- this.emitStructuredSnapshot(retry);
1548
+ // sendMessage publishes the new running snapshot synchronously; do not expose a transient idle slot.
1399
1549
  return this.sendMessage(id, prompt);
1400
1550
  }
1551
+ throwUnacceptedInput(sessionId, result, command, missingHint) {
1552
+ const failure = result.failure ?? classifyStructuredFailure(result);
1553
+ if (!failure?.retryable || this.interruptedWith.has(sessionId))
1554
+ return;
1555
+ const message = result.spawnError
1556
+ ? `${command} 启动失败:${result.spawnError.message}${result.spawnError.code === "ENOENT" ? missingHint : ""}`
1557
+ : result.primaryError?.trim() || `${command} 在执行前拒绝了输入。`;
1558
+ this.logger?.appendStructuredSpawn(sessionId, { kind: "structured-input-rejected", command,
1559
+ exitCode: result.exitCode, failureKind: failure.kind });
1560
+ throw new UnacceptedStructuredInputError(message, failure, result.state);
1561
+ }
1401
1562
  /** 往转发会话里追加别的参与者的发言。 */
1402
1563
  appendRelayTurns(id, turns) {
1403
1564
  const session = this.sessions.get(id);
@@ -1453,7 +1614,11 @@ export class StructuredSessionManager {
1453
1614
  }
1454
1615
  }
1455
1616
  }
1617
+ // Pi 主功能现在固定走 CLI;core SDK 重启 drain 不影响 CLI 会话入场。
1456
1618
  this.maybeGenerateSessionTopic(id, prompt);
1619
+ // 「智能分配」结算窗口:还没有 runner 归属,新输入先排队,不能另起一轮。
1620
+ if (this.autoAssignPending.has(id))
1621
+ return this.enqueueInput(session, prompt);
1457
1622
  if (session.structuredState?.inFlight) {
1458
1623
  const runnerExecution = this.pendingRunnerExecutions.get(id);
1459
1624
  // interrupt() only requests cancellation; completion can settle later.
@@ -1465,7 +1630,7 @@ export class StructuredSessionManager {
1465
1630
  status: "idle",
1466
1631
  endedAt: session.endedAt ?? new Date().toISOString(),
1467
1632
  structuredState: {
1468
- ...session.structuredState,
1633
+ ...this.settleTurn(id, session.structuredState),
1469
1634
  inFlight: false,
1470
1635
  activeRequestId: null,
1471
1636
  },
@@ -1503,23 +1668,60 @@ export class StructuredSessionManager {
1503
1668
  return session;
1504
1669
  }
1505
1670
  else {
1506
- const queue = [...(session.queuedMessages ?? [])];
1507
- if (isDuplicateStructuredQueueInput(session, prompt)) {
1508
- const err = new Error("与上一条消息相同,已忽略,不会加入排队。");
1509
- err.code = "duplicate_queued_message";
1510
- throw err;
1511
- }
1512
- if (queue.length >= 10) {
1513
- throw new Error("排队消息已满(最多 10 条),请等待当前消息处理完成。");
1514
- }
1515
- const queued = {
1671
+ return this.enqueueInput(session, prompt);
1672
+ }
1673
+ }
1674
+ // 「智能分配」:只有真拿到这一轮提示词才能结算成本次使用的分组。结算后钉在会话上,
1675
+ // 后续轮次用同一个分组(不在会话中途换模型);客户端选择器同步显示实际分组。
1676
+ if (isAutoAssignSelector(session.selectedModel)) {
1677
+ const token = { cancelled: false };
1678
+ this.autoAssignPending.set(id, token);
1679
+ let assigned;
1680
+ try {
1681
+ assigned = await resolveAutoAssign({
1682
+ provider: session.provider ?? "claude",
1683
+ prompt,
1684
+ groups: this.config.modelGroups,
1685
+ evaluate: this.autoAssignEvaluate(),
1686
+ ai: resolveSystemAiContext(session, this.config, this.storage.getSystemSiliconEmployee()),
1687
+ cwd: session.cwd,
1688
+ language: this.config.language,
1689
+ });
1690
+ }
1691
+ catch (error) {
1692
+ // 结算本身绝不阻断发送:拿不到结论就按默认分组继续。
1693
+ console.warn("[AutoAssign] 分配分组失败,改用默认分组:", getErrorMessage(error));
1694
+ assigned = { selector: "", group: null, strategy: "default", calls: 0 };
1695
+ }
1696
+ finally {
1697
+ if (this.autoAssignPending.get(id) === token)
1698
+ this.autoAssignPending.delete(id);
1699
+ }
1700
+ const latest = this.sessions.get(id);
1701
+ if (!latest || token.cancelled || this.disposed)
1702
+ return latest ?? session;
1703
+ session = latest;
1704
+ // 结算期间用户可能自己换了模型;那是更强的意愿,不覆盖。
1705
+ if (isAutoAssignSelector(session.selectedModel)) {
1706
+ const resolved = {
1516
1707
  ...session,
1517
- queuedMessages: [...queue, prompt],
1708
+ selectedModel: assigned.selector || null,
1709
+ structuredState: {
1710
+ ...(session.structuredState ?? defaultStructuredState(session.provider ?? "claude", session.runner)),
1711
+ ...(assigned.selector ? { model: assigned.selector } : {}),
1712
+ },
1518
1713
  };
1519
- this.sessions.set(id, queued);
1520
- this.storage.updateSessionRuntimeMetadata(queued);
1521
- this.emitStructuredSnapshot(queued);
1522
- return queued;
1714
+ this.sessions.set(id, resolved);
1715
+ this.storage.updateSessionRuntimeMetadata(resolved);
1716
+ this.emit({
1717
+ type: "status",
1718
+ sessionId: id,
1719
+ data: { sessionKind: "structured", selectedModel: resolved.selectedModel, structuredState: resolved.structuredState },
1720
+ });
1721
+ session = resolved;
1722
+ if (assigned.group) {
1723
+ console.info(`[AutoAssign] ${id} → 分组「${assigned.group.name}」(${assigned.strategy}${assigned.probability === undefined ? "" : `, p=${assigned.probability.toFixed(2)}`})`);
1724
+ }
1523
1725
  }
1524
1726
  }
1525
1727
  // 检测上一轮 assistant 是否有未配对的 AskUserQuestion tool_use(说明前一次
@@ -1557,12 +1759,12 @@ export class StructuredSessionManager {
1557
1759
  exitCode: null,
1558
1760
  endedAt: null,
1559
1761
  messages: [...(session.messages ?? []), userTurn],
1560
- structuredState: {
1762
+ structuredState: this.beginTurn(id, {
1561
1763
  ...(session.structuredState ?? defaultStructuredState(session.provider ?? "claude", session.runner)),
1562
1764
  inFlight: true,
1563
1765
  activeRequestId: requestId,
1564
1766
  lastError: null,
1565
- },
1767
+ }),
1566
1768
  };
1567
1769
  this.sessions.set(id, updated);
1568
1770
  if (updated.automationId?.startsWith("ai-team:"))
@@ -1609,13 +1811,40 @@ export class StructuredSessionManager {
1609
1811
  });
1610
1812
  }
1611
1813
  else if (provider === "pi") {
1612
- await this.runClaudeStreaming(id, updated, prompt, requestId, {
1613
- runner: this.piRunner,
1614
- provider: "pi",
1615
- commandLabel: "pi --mode json --print",
1616
- logKind: "pi-json",
1617
- installHint: "请安装 @earendil-works/pi-coding-agent(或兼容的 Pi CLI),或重跑 `wand service:install` 刷新服务的 PATH",
1618
- });
1814
+ const employeeAgent = updated.employeeCandidates?.[updated.employeeCandidateIndex ?? 0];
1815
+ const sdkRequested = employeeAgent?.engine === "sdk";
1816
+ // 对话框默认固定走 CLI;只有员工候选明确配置 SDK 时,员工会话才启用 core。
1817
+ const resolution = sdkRequested
1818
+ ? resolveHarnessEngineSync({ ...this.config.harness, engine: "core" }, "pi", {
1819
+ coreRunnerSupplied: this.coreRunnerSupplied,
1820
+ })
1821
+ : { engine: "cli", reason: "Pi 主功能固定使用 CLI JSON" };
1822
+ const enginePatched = {
1823
+ ...updated,
1824
+ structuredState: {
1825
+ ...updated.structuredState,
1826
+ engine: resolution.engine,
1827
+ engineReason: resolution.reason,
1828
+ },
1829
+ };
1830
+ this.sessions.set(id, enginePatched);
1831
+ if (resolution.engine === "core") {
1832
+ await this.runClaudeStreaming(id, enginePatched, prompt, requestId, {
1833
+ runner: this.coreRunner,
1834
+ provider: "pi",
1835
+ commandLabel: "pi core(员工 SDK)",
1836
+ logKind: "pi-core",
1837
+ });
1838
+ }
1839
+ else {
1840
+ await this.runClaudeStreaming(id, enginePatched, prompt, requestId, {
1841
+ runner: this.piRunner,
1842
+ provider: "pi",
1843
+ commandLabel: "pi --mode json --print",
1844
+ logKind: "pi-json",
1845
+ installHint: "请安装 @earendil-works/pi-coding-agent(或兼容的 Pi CLI),或重跑 `wand service:install` 刷新服务的 PATH",
1846
+ });
1847
+ }
1619
1848
  }
1620
1849
  else if (provider === "gemini") {
1621
1850
  await this.runClaudeStreaming(id, updated, prompt, requestId, {
@@ -1637,12 +1866,8 @@ export class StructuredSessionManager {
1637
1866
  // Close handlers use this tagged error after they have already persisted
1638
1867
  // the detailed failure. Re-throw even if an ended-event listener removed
1639
1868
  // the session synchronously; there is no request-id marker to leak.
1640
- if (error instanceof PersistedStructuredRunnerError) {
1641
- const retry = this.retryEmployeeCandidate(id, session, prompt, error);
1642
- if (retry)
1643
- return await retry;
1869
+ if (error instanceof PersistedStructuredRunnerError)
1644
1870
  throw error;
1645
- }
1646
1871
  const current = this.sessions.get(id);
1647
1872
  if (!current)
1648
1873
  throw error;
@@ -1654,29 +1879,31 @@ export class StructuredSessionManager {
1654
1879
  const retry = this.retryEmployeeCandidate(id, session, prompt, error);
1655
1880
  if (retry)
1656
1881
  return await retry;
1657
- const failed = {
1658
- ...current,
1659
- status: "failed",
1660
- exitCode: 1,
1661
- endedAt: new Date().toISOString(),
1662
- pendingEscalation: null,
1663
- permissionBlocked: false,
1664
- structuredState: {
1665
- ...current.structuredState,
1666
- inFlight: false,
1667
- activeRequestId: null,
1668
- lastError: message,
1669
- },
1670
- };
1882
+ const failed = error instanceof UnacceptedStructuredInputError
1883
+ ? this.finishStructuredFailure(current, 1, message, error.state)
1884
+ : {
1885
+ ...current,
1886
+ status: "failed",
1887
+ exitCode: 1,
1888
+ endedAt: new Date().toISOString(),
1889
+ pendingEscalation: null,
1890
+ permissionBlocked: false,
1891
+ structuredState: {
1892
+ ...this.settleTurn(id, current.structuredState),
1893
+ inFlight: false,
1894
+ activeRequestId: null,
1895
+ lastError: message,
1896
+ },
1897
+ };
1671
1898
  this.sessions.set(id, failed);
1672
1899
  this.saveAuthoritativeSession(failed);
1673
- if (!this.disposed && error instanceof UnacceptedStructuredSpawnError
1900
+ if (!this.disposed && error instanceof UnacceptedStructuredInputError
1674
1901
  && current.automationId?.startsWith("ai-team:")) {
1675
1902
  // Available before status/ended listeners run. A later turn/stop/delete invalidates this fact.
1676
1903
  if (this.unacceptedTeamStarts.size >= 1024) {
1677
1904
  this.unacceptedTeamStarts.delete(this.unacceptedTeamStarts.keys().next().value);
1678
1905
  }
1679
- this.unacceptedTeamStarts.set(id, { requestId });
1906
+ this.unacceptedTeamStarts.set(id, { requestId, failure: error.failure });
1680
1907
  }
1681
1908
  this.emit({
1682
1909
  type: "status",
@@ -1829,7 +2056,7 @@ export class StructuredSessionManager {
1829
2056
  const candidate = candidateIndex >= 0 ? candidates[candidateIndex] : undefined;
1830
2057
  const runner = defaultStructuredRunner(provider);
1831
2058
  const model = candidate && candidate.model !== "default" ? candidate.model
1832
- : getDefaultModelForProvider(this.config, provider) || null;
2059
+ : defaultModelGroupSelector(this.config.modelGroups, provider, getDefaultModelForProvider(this.config, provider)) || getDefaultModelForProvider(this.config, provider) || null;
1833
2060
  const effort = normalizeThinkingEffort(candidate?.thinkingEffort ?? this.config.defaultThinkingEffort);
1834
2061
  const thinkingEffort = effort?.includes(":") && !effort.startsWith(`${provider}:`) ? "off" : effort;
1835
2062
  const requestedMode = candidate?.mode ?? session.mode;
@@ -1841,6 +2068,11 @@ export class StructuredSessionManager {
1841
2068
  ...session,
1842
2069
  provider,
1843
2070
  runner,
2071
+ // 从别的 provider 切到 Pi 的空白会话与新建 Pi 会话同等:以「上次设置」起步。
2072
+ ...(provider === "pi" && !session.piSettings ? { piSettings: candidate?.engine === "sdk"
2073
+ ? defaultPiSessionSettings(this.config.harness?.compaction?.enabled ?? true)
2074
+ : { ...(this.storage.getPiSessionDefaults() ?? defaultPiCliSessionSettings(this.config.harness?.compaction?.enabled ?? true)),
2075
+ autoCompaction: this.config.harness?.compaction?.enabled ?? true } } : {}),
1844
2076
  command: recoveredCommandLabel(runner),
1845
2077
  mode,
1846
2078
  autoApprovePermissions: shouldAutoApproveForMode(mode),
@@ -1860,10 +2092,74 @@ export class StructuredSessionManager {
1860
2092
  });
1861
2093
  return updated;
1862
2094
  }
2095
+ getPiSettings(sessionId) {
2096
+ const session = this.requireSession(sessionId);
2097
+ if (session.provider !== "pi")
2098
+ throw new Error("这些设置仅适用于 Pi 结构化会话。");
2099
+ const employeeAgent = session.employeeCandidates?.[session.employeeCandidateIndex ?? 0];
2100
+ const sdkRequested = employeeAgent?.engine === "sdk";
2101
+ let resolution;
2102
+ if (sdkRequested) {
2103
+ try {
2104
+ resolution = resolveHarnessEngineSync({ ...this.config.harness, engine: "core" }, "pi", {
2105
+ coreRunnerSupplied: this.coreRunnerSupplied,
2106
+ });
2107
+ }
2108
+ catch (error) {
2109
+ resolution = { engine: "core", reason: getErrorMessage(error) };
2110
+ }
2111
+ }
2112
+ else {
2113
+ resolution = { engine: "cli", reason: "Pi 主功能固定使用 CLI JSON" };
2114
+ }
2115
+ const defaults = resolution.engine === "cli"
2116
+ ? defaultPiCliSessionSettings(this.config.harness?.compaction?.enabled ?? true)
2117
+ : defaultPiSessionSettings(this.config.harness?.compaction?.enabled ?? true);
2118
+ // Legacy sessions really use Pi discovery. Do not report a new empty selection as applied.
2119
+ if (!session.piSettings)
2120
+ delete defaults.resources;
2121
+ return { settings: session.piSettings ?? defaults, resolution };
2122
+ }
2123
+ /** Metadata-only, synchronous merge: active runs retain their captured settings, queued rounds see these. */
2124
+ setPiSettings(sessionId, patch) {
2125
+ const session = this.requireSession(sessionId);
2126
+ if (session.archived)
2127
+ throw new Error("请先恢复已归档的会话。");
2128
+ const { settings, resolution } = this.getPiSettings(sessionId);
2129
+ const updatedSettings = patchPiSessionSettings(settings, patch);
2130
+ if (resolution.engine === "core" && (patch.resources !== undefined
2131
+ || patch.codemodeOverride !== undefined
2132
+ || patch.autoResources !== undefined
2133
+ || patch.lockedSkills !== undefined)) {
2134
+ throw new Error("当前 SDK 候选尚不支持此会话级设置;没有改用全局资源或 CodeMode 配置。");
2135
+ }
2136
+ if (resolution.engine !== "core") {
2137
+ // 写入裁决与启动参数用同一套能力边界:表达不出的字段/取值不落库。
2138
+ const rejection = cliPiSettingsRejection(patch, updatedSettings);
2139
+ if (rejection)
2140
+ throw new Error(rejection);
2141
+ }
2142
+ // Automatic selection or skill locks need an explicit on/off boundary, not legacy discovery.
2143
+ if ((updatedSettings.autoResources || updatedSettings.lockedSkills?.length) && !updatedSettings.resources) {
2144
+ updatedSettings.resources = { skills: [], mcpServers: [] };
2145
+ }
2146
+ const updated = { ...session, piSettings: updatedSettings };
2147
+ // Do not publish a setting that did not reach durable storage.
2148
+ this.storage.updateSessionRuntimeMetadata(updated);
2149
+ // 记住这次设置:新建 Pi CLI 会话以它起步。SDK 会话表达不出资源/CodeMode,不写回默认。
2150
+ if (resolution.engine === "cli")
2151
+ this.storage.setPiSessionDefaults(updatedSettings);
2152
+ this.sessions.set(sessionId, updated);
2153
+ this.emit({ type: "status", sessionId, data: { sessionKind: "structured", piSettings: updated.piSettings } });
2154
+ return updated;
2155
+ }
1863
2156
  /** Update the selected model for a structured session. Takes effect on the next spawn. */
1864
2157
  setSessionModel(sessionId, model) {
1865
2158
  const session = this.requireSession(sessionId);
1866
2159
  const normalized = model?.trim() || null;
2160
+ resolveModelGroupModels(this.config.modelGroups, session.provider ?? "claude", normalized, {
2161
+ preferDefault: !normalized || normalized === "default",
2162
+ });
1867
2163
  const updated = {
1868
2164
  ...session,
1869
2165
  selectedModel: normalized,
@@ -2009,6 +2305,12 @@ export class StructuredSessionManager {
2009
2305
  this.unacceptedTeamStarts.delete(id);
2010
2306
  const session = this.requireSession(id);
2011
2307
  this.requireRecoveryControl(id);
2308
+ // 结算中的智能分配还没有 runner:置位取消,结算回来后不得再启动。
2309
+ const pendingAssign = this.autoAssignPending.get(id);
2310
+ if (pendingAssign) {
2311
+ pendingAssign.cancelled = true;
2312
+ this.autoAssignPending.delete(id);
2313
+ }
2012
2314
  this.interruptedWith.delete(id);
2013
2315
  this.preserveQueueOnInterrupt.delete(id);
2014
2316
  // Clearing activeRequestId is the generation barrier: late data/close callbacks
@@ -2017,13 +2319,14 @@ export class StructuredSessionManager {
2017
2319
  // 这样前端不会进入"会话已结束/恢复会话"终止态,输入框保持可用,直接展示历史内容。
2018
2320
  const cancelled = {
2019
2321
  ...session,
2322
+ messages: cancelPendingResourceSelection(session.messages),
2020
2323
  status: "idle",
2021
2324
  exitCode: null,
2022
2325
  endedAt: null,
2023
2326
  pendingEscalation: null,
2024
2327
  permissionBlocked: false,
2025
2328
  structuredState: {
2026
- ...(session.structuredState ?? defaultStructuredState(session.provider ?? "claude", session.runner)),
2329
+ ...this.settleTurn(id, session.structuredState ?? defaultStructuredState(session.provider ?? "claude", session.runner)),
2027
2330
  inFlight: false,
2028
2331
  activeRequestId: null,
2029
2332
  lastError: null,
@@ -2043,6 +2346,11 @@ export class StructuredSessionManager {
2043
2346
  delete(id) {
2044
2347
  this.unacceptedTeamStarts.delete(id);
2045
2348
  this.requireRecoveryControl(id);
2349
+ const pendingAssign = this.autoAssignPending.get(id);
2350
+ if (pendingAssign) {
2351
+ pendingAssign.cancelled = true;
2352
+ this.autoAssignPending.delete(id);
2353
+ }
2046
2354
  const runnerExecution = this.pendingRunnerExecutions.get(id);
2047
2355
  // Invalidate callback ownership before signalling the runner. Cancellation
2048
2356
  // can synchronously wake listeners in some adapter implementations.
@@ -2052,6 +2360,8 @@ export class StructuredSessionManager {
2052
2360
  this.releasePendingRunnerExecution(id, runnerExecution);
2053
2361
  }
2054
2362
  this.clearStreamingCheckpoint(id);
2363
+ // 会话被删除后迟到的回合回调必须失效,静默心跳也不能再给一个不存在的会话发事件。
2364
+ this.turnHeartbeat.cancel(id);
2055
2365
  this.interruptedWith.delete(id);
2056
2366
  this.preserveQueueOnInterrupt.delete(id);
2057
2367
  this.storage.deleteSession(id);
@@ -2069,7 +2379,7 @@ export class StructuredSessionManager {
2069
2379
  }
2070
2380
  /** True only while this exact turn still owns the session's mutable state. */
2071
2381
  isCurrentRequest(sessionId, requestId) {
2072
- return this.sessions.get(sessionId)?.structuredState?.activeRequestId === requestId;
2382
+ return !this.disposed && this.sessions.get(sessionId)?.structuredState?.activeRequestId === requestId;
2073
2383
  }
2074
2384
  currentSessionForRequest(sessionId, requestId) {
2075
2385
  if (!this.isCurrentRequest(sessionId, requestId))
@@ -2086,6 +2396,67 @@ export class StructuredSessionManager {
2086
2396
  this.execHost?.forgetRun(structuredRunId(sessionId));
2087
2397
  return true;
2088
2398
  }
2399
+ /**
2400
+ * 本轮开始:turnStartedAt / lastActivityAt 同时落下,并给这一轮挂上静默心跳。
2401
+ * 只在新回合(新 requestId)起点未知时调用;daemon 领养走 adoptTurn。
2402
+ */
2403
+ beginTurn(sessionId, state) {
2404
+ const at = isoNow();
2405
+ this.turnHeartbeat.observe(sessionId);
2406
+ return { ...state, turnStartedAt: at, lastActivityAt: at };
2407
+ }
2408
+ /**
2409
+ * 领养 daemon 里仍在飞的回合:起点是重启前那一轮的真实时刻,不能改写成重连时刻;
2410
+ * 锚点缺失(改动之前落库的历史数据)才退回现在。
2411
+ */
2412
+ adoptTurn(sessionId, state) {
2413
+ const at = isoNow();
2414
+ this.turnHeartbeat.observe(sessionId);
2415
+ return { ...state, turnStartedAt: state.turnStartedAt ?? at, lastActivityAt: at };
2416
+ }
2417
+ /**
2418
+ * 观测到本轮活动(输出 chunk / 状态变化):只刷新 lastActivityAt 并重新计算静默窗口;
2419
+ * turnStartedAt 保持不变,客户端才算是「这一轮已经跑了多久」而不是「距离上一条输出多久」。
2420
+ */
2421
+ noteTurnActivity(sessionId, state) {
2422
+ this.turnHeartbeat.observe(sessionId);
2423
+ return { ...state, lastActivityAt: isoNow() };
2424
+ }
2425
+ /**
2426
+ * 本轮收敛:`inFlight` 清 false 的一切路径(结束 / 失败 / 停止 / 中断 / 重启降级 /
2427
+ * 候选降级)都必须走这里——两个锚点一起回 null,静默心跳立即 cancel。
2428
+ * 收敛后不存在任何「还在跑」的读数,客户端不需要再猜上一轮的锚点属于哪一轮。
2429
+ */
2430
+ settleTurn(sessionId, state) {
2431
+ this.turnHeartbeat.cancel(sessionId);
2432
+ return { ...state, turnStartedAt: null, lastActivityAt: null };
2433
+ }
2434
+ /**
2435
+ * 静默窗口到期:确认这一轮仍在飞才补发一次,payload 与一条普通 status 快照完全一致
2436
+ * (不带新字段、不开新事件类型)。返回值决定要不要再挂下一个窗口。
2437
+ */
2438
+ publishQuietHeartbeat(sessionId) {
2439
+ if (this.disposed)
2440
+ return false;
2441
+ const session = this.sessions.get(sessionId);
2442
+ if (session?.structuredState?.inFlight !== true)
2443
+ return false;
2444
+ // 停在权限 / 升级裁决上的回合不是在「安静地干活」:那一屏已经有 pendingEscalation
2445
+ // 这个显式指示,再广播「仍在运行」是错的。保留窗口(返回 true),但不发事件,
2446
+ // 等用户裁决后 runner 继续产出时会自然重新计时。
2447
+ if (session.pendingEscalation || session.permissionBlocked)
2448
+ return true;
2449
+ this.emit({
2450
+ type: "status",
2451
+ sessionId,
2452
+ data: {
2453
+ ...buildStructuredOutputPayload(session),
2454
+ status: session.status,
2455
+ exitCode: session.exitCode,
2456
+ },
2457
+ });
2458
+ return true;
2459
+ }
2089
2460
  emitStructuredSnapshot(session, eventType = "output") {
2090
2461
  // 排队消息只通过 payload.queuedMessages 单独下发,由各端在消息卡片外的「排队条」
2091
2462
  // 里纵向渲染——绝不再把它们当成 __queued 占位 turn 混进 messages 消息流里,否则会
@@ -2102,6 +2473,21 @@ export class StructuredSessionManager {
2102
2473
  data,
2103
2474
  });
2104
2475
  }
2476
+ enqueueInput(session, prompt) {
2477
+ if (isDuplicateStructuredQueueInput(session, prompt)) {
2478
+ const err = new Error("与上一条消息相同,已忽略,不会加入排队。");
2479
+ err.code = "duplicate_queued_message";
2480
+ throw err;
2481
+ }
2482
+ const queue = session.queuedMessages ?? [];
2483
+ if (queue.length >= 10)
2484
+ throw new Error("排队消息已满(最多 10 条),请等待当前消息处理完成。");
2485
+ const queued = { ...session, queuedMessages: [...queue, prompt] };
2486
+ this.sessions.set(session.id, queued);
2487
+ this.storage.updateSessionRuntimeMetadata(queued);
2488
+ this.emitStructuredSnapshot(queued);
2489
+ return queued;
2490
+ }
2105
2491
  async flushNextQueuedMessage(sessionId) {
2106
2492
  if (this.disposed)
2107
2493
  return;
@@ -2181,10 +2567,10 @@ export class StructuredSessionManager {
2181
2567
  claudeSessionId: turnState.sessionId ?? current.claudeSessionId,
2182
2568
  messages,
2183
2569
  output: turnState.result || current.output,
2184
- structuredState: {
2570
+ structuredState: this.noteTurnActivity(sessionId, {
2185
2571
  ...current.structuredState,
2186
2572
  model: turnState.model ?? current.structuredState?.model,
2187
- },
2573
+ }),
2188
2574
  };
2189
2575
  this.sessions.set(sessionId, patched);
2190
2576
  this.saveStreamingSnapshot(patched);
@@ -2248,12 +2634,7 @@ export class StructuredSessionManager {
2248
2634
  return;
2249
2635
  }
2250
2636
  flushEmit();
2251
- if (result.spawnError) {
2252
- const hint = result.spawnError.code === "ENOENT"
2253
- ? "(PATH 中找不到 codex 可执行文件;请确认 codex 已安装,或重跑 `wand service:install` 刷新服务的 PATH)"
2254
- : "";
2255
- throw new UnacceptedStructuredSpawnError(`codex exec 启动失败:${result.spawnError.message}${hint}`);
2256
- }
2637
+ this.throwUnacceptedInput(sessionId, result, "codex exec", "(PATH 中找不到 codex 可执行文件;请确认 codex 已安装,或重跑 `wand service:install` 刷新服务的 PATH)");
2257
2638
  this.logger?.appendStructuredSpawn(sessionId, {
2258
2639
  kind: "codex-exec-close",
2259
2640
  pid: execution.pid,
@@ -2292,7 +2673,7 @@ export class StructuredSessionManager {
2292
2673
  pendingEscalation: null,
2293
2674
  permissionBlocked: false,
2294
2675
  structuredState: {
2295
- ...current.structuredState,
2676
+ ...this.settleTurn(sessionId, current.structuredState),
2296
2677
  model: result.state.model ?? current.structuredState?.model,
2297
2678
  inFlight: false,
2298
2679
  activeRequestId: null,
@@ -2333,6 +2714,8 @@ export class StructuredSessionManager {
2333
2714
  claudeSessionId: turnState.sessionId ?? current.claudeSessionId,
2334
2715
  messages,
2335
2716
  output: turnState.result || current.output,
2717
+ // 每个 provider 帧都是本轮还在活动的证据:刷新 lastActivityAt 并重挂静默窗口。
2718
+ structuredState: this.noteTurnActivity(sessionId, current.structuredState),
2336
2719
  };
2337
2720
  this.sessions.set(sessionId, patched);
2338
2721
  this.saveStreamingSnapshot(patched);
@@ -2391,12 +2774,7 @@ export class StructuredSessionManager {
2391
2774
  return;
2392
2775
  }
2393
2776
  flushEmit();
2394
- if (result.spawnError) {
2395
- const hint = result.spawnError.code === "ENOENT"
2396
- ? "(PATH 中找不到 grok;请安装 Grok Build CLI,或重跑 `wand service:install` 刷新服务 PATH)"
2397
- : "";
2398
- throw new UnacceptedStructuredSpawnError(`grok 启动失败:${result.spawnError.message}${hint}`);
2399
- }
2777
+ this.throwUnacceptedInput(sessionId, result, "grok", "(PATH 中找不到 grok;请安装 Grok Build CLI,或重跑 `wand service:install` 刷新服务 PATH)");
2400
2778
  this.logger?.appendStructuredSpawn(sessionId, {
2401
2779
  kind: "grok-headless-close",
2402
2780
  pid: execution.pid,
@@ -2434,7 +2812,7 @@ export class StructuredSessionManager {
2434
2812
  pendingEscalation: null,
2435
2813
  permissionBlocked: false,
2436
2814
  structuredState: {
2437
- ...current.structuredState,
2815
+ ...this.settleTurn(sessionId, current.structuredState),
2438
2816
  model: result.state.model ?? current.structuredState?.model,
2439
2817
  inFlight: false,
2440
2818
  activeRequestId: null,
@@ -2473,6 +2851,8 @@ export class StructuredSessionManager {
2473
2851
  claudeSessionId: turnState.sessionId ?? current.claudeSessionId,
2474
2852
  messages,
2475
2853
  output: turnState.result || current.output,
2854
+ // 每个 provider 帧都是本轮还在活动的证据:刷新 lastActivityAt 并重挂静默窗口。
2855
+ structuredState: this.noteTurnActivity(sessionId, current.structuredState),
2476
2856
  };
2477
2857
  this.sessions.set(sessionId, patched);
2478
2858
  this.saveStreamingSnapshot(patched);
@@ -2536,12 +2916,7 @@ export class StructuredSessionManager {
2536
2916
  return;
2537
2917
  }
2538
2918
  flushEmit();
2539
- if (result.spawnError) {
2540
- const hint = result.spawnError.code === "ENOENT"
2541
- ? "(PATH 中找不到 opencode;请安装 opencode-ai,或重跑 `wand service:install` 刷新服务 PATH)"
2542
- : "";
2543
- throw new UnacceptedStructuredSpawnError(`opencode run 启动失败:${result.spawnError.message}${hint}`);
2544
- }
2919
+ this.throwUnacceptedInput(sessionId, result, "opencode run", "(PATH 中找不到 opencode;请安装 opencode-ai,或重跑 `wand service:install` 刷新服务 PATH)");
2545
2920
  this.logger?.appendStructuredSpawn(sessionId, {
2546
2921
  kind: "opencode-run-close",
2547
2922
  pid: execution.pid,
@@ -2582,7 +2957,7 @@ export class StructuredSessionManager {
2582
2957
  pendingEscalation: null,
2583
2958
  permissionBlocked: false,
2584
2959
  structuredState: {
2585
- ...current.structuredState,
2960
+ ...this.settleTurn(sessionId, current.structuredState),
2586
2961
  model: result.state.model ?? current.structuredState?.model,
2587
2962
  inFlight: false,
2588
2963
  activeRequestId: null,
@@ -2626,13 +3001,14 @@ export class StructuredSessionManager {
2626
3001
  const current = this.currentSessionForRequest(sessionId, requestId);
2627
3002
  if (!current)
2628
3003
  return;
2629
- const hasAssistantContent = turnState.blocks.length > 0 || !!turnState.result;
3004
+ const hasAssistantContent = turnState.blocks.length > 0 || !!turnState.result || !!turnState.resourceSelection;
2630
3005
  let messages = [...(current.messages ?? [])];
2631
3006
  if (hasAssistantContent) {
2632
3007
  const turn = {
2633
3008
  role: "assistant",
2634
3009
  content: this.compactContentBlocks([...turnState.blocks], turnState.result),
2635
3010
  usage: turnState.usage,
3011
+ ...(turnState.resourceSelection ? { resourceSelection: turnState.resourceSelection } : {}),
2636
3012
  };
2637
3013
  messages = upsertAssistantMessage(current.messages, turn);
2638
3014
  }
@@ -2641,11 +3017,11 @@ export class StructuredSessionManager {
2641
3017
  claudeSessionId: turnState.sessionId ?? current.claudeSessionId,
2642
3018
  messages,
2643
3019
  output: turnState.result || current.output,
2644
- structuredState: {
3020
+ structuredState: this.noteTurnActivity(sessionId, {
2645
3021
  ...current.structuredState,
2646
3022
  model: turnState.model ?? current.structuredState?.model,
2647
3023
  phase: turnState.phase ?? current.structuredState?.phase,
2648
- },
3024
+ }),
2649
3025
  };
2650
3026
  this.sessions.set(sessionId, patched);
2651
3027
  this.saveStreamingSnapshot(patched, hasAssistantContent ? undefined : { metadata: true });
@@ -2712,12 +3088,7 @@ export class StructuredSessionManager {
2712
3088
  return;
2713
3089
  }
2714
3090
  flushEmit();
2715
- if (result.spawnError) {
2716
- const hint = result.spawnError.code === "ENOENT"
2717
- ? `(PATH 中找不到 ${provider === "qoder" ? "qodercli" : provider === "gemini" ? "gemini" : provider === "pi" ? "pi" : "claude"} 可执行文件;${options.installHint ?? "请确认 claude 已安装,或重跑 `wand service:install` 刷新服务的 PATH"})`
2718
- : "";
2719
- throw new UnacceptedStructuredSpawnError(`${commandLabel} 启动失败:${result.spawnError.message}${hint}`);
2720
- }
3091
+ this.throwUnacceptedInput(sessionId, result, commandLabel, `(PATH 中找不到 ${provider === "qoder" ? "qodercli" : provider === "gemini" ? "gemini" : provider === "pi" ? "pi" : "claude"} 可执行文件;${options.installHint ?? "请确认 claude 已安装,或重跑 `wand service:install` 刷新服务的 PATH"})`);
2721
3092
  this.logger?.appendStructuredSpawn(sessionId, {
2722
3093
  kind: `${logKind}-close`,
2723
3094
  pid: execution.pid,
@@ -2732,11 +3103,11 @@ export class StructuredSessionManager {
2732
3103
  const interruptedByUser = this.interruptedWith.has(sessionId);
2733
3104
  const interruptedForQuestion = result.stopReason === "ask-user-question";
2734
3105
  const failedExit = (result.exitCode !== null && result.exitCode !== 0) || result.signal !== null;
2735
- // Pi 等 CLI 在 provider 报错(额度用尽、鉴权失败)时仍以 0 退出;这一轮没有任何产出时
2736
- // 必须按失败落盘,否则会被当成一次空回复,用户和团队调度都看不到真正的错误。
2737
- const erroredEmptyTurn = !failedExit && !!result.primaryError && !result.state.blocks.some((block) => block.type === "tool_use" || (block.type === "text" && block.text.trim() !== ""));
2738
- if ((failedExit || erroredEmptyTurn) && !interruptedByUser && !interruptedForQuestion) {
2739
- const errorText = erroredEmptyTurn
3106
+ // A protocol failure remains a failure even if the CLI exits 0 or already produced work.
3107
+ // Earlier work is preserved below and must never be replayed on another candidate.
3108
+ const failure = result.failure ?? classifyStructuredFailure(result);
3109
+ if (failure && !interruptedByUser && !interruptedForQuestion) {
3110
+ const errorText = !failedExit && result.primaryError
2740
3111
  ? result.primaryError.trim()
2741
3112
  : this.formatStructuredExitError(commandLabel, result.exitCode, result.signal, {
2742
3113
  stderr: result.stderr,
@@ -2771,13 +3142,20 @@ export class StructuredSessionManager {
2771
3142
  pendingEscalation: null,
2772
3143
  permissionBlocked: false,
2773
3144
  structuredState: {
2774
- ...current.structuredState,
3145
+ ...this.settleTurn(sessionId, current.structuredState),
2775
3146
  model: result.state.model ?? current.structuredState?.model,
2776
3147
  inFlight: false,
2777
3148
  activeRequestId: null,
2778
3149
  lastError: null,
2779
3150
  phase: undefined,
3151
+ ...(result.state.contextUsage ? { contextUsage: result.state.contextUsage } : {}),
3152
+ // core 压缩状态:历史不删,只记录切点与摘要,下一轮按它重建模型上下文。
3153
+ ...(result.state.compaction
3154
+ ? { compactions: result.state.compaction.compactions, harnessContext: compactionToSessionContext(result.state.compaction) }
3155
+ : {}),
2780
3156
  },
3157
+ ...(result.state.compaction ? { harnessContext: compactionToSessionContext(result.state.compaction) } : {}),
3158
+ ...(result.state.harnessExtensionState ? { harnessExtensionState: result.state.harnessExtensionState } : {}),
2781
3159
  };
2782
3160
  this.sessions.set(sessionId, finished);
2783
3161
  this.saveAuthoritativeSession(finished);
@@ -2849,6 +3227,7 @@ export class StructuredSessionManager {
2849
3227
  role: "assistant",
2850
3228
  content: this.compactContentBlocks([...turnState.blocks], turnState.result),
2851
3229
  usage: turnState.usage,
3230
+ ...(turnState.resourceSelection ? { resourceSelection: turnState.resourceSelection } : {}),
2852
3231
  };
2853
3232
  return upsertAssistantMessage(current.messages, assistantTurn, true, stampNewTools);
2854
3233
  }
@@ -2911,12 +3290,15 @@ export class StructuredSessionManager {
2911
3290
  const failureTurn = {
2912
3291
  role: "assistant",
2913
3292
  content: [{ type: "text", text: `结构化会话执行失败:${errorText}` }],
3293
+ ...(turnState.resourceSelection ? { resourceSelection: turnState.resourceSelection } : {}),
2914
3294
  };
2915
- // 日志被截断时本地已存的 turn 是重启前唯一完整的记录:失败提示追加成独立一轮,
2916
- // 不能把整段 turn 换成错误文本(那会把用户看到过的输出全删掉)。
3295
+ // Keep actual work and append the failure, including protocol errors with process exit 0.
3296
+ // A truncated replay must retain its complete local checkpoint rather than rebuild from a tail.
2917
3297
  const msgs = options.keepTranscript
2918
3298
  ? appendNoticeTurn(current.messages, failureTurn)
2919
- : upsertAssistantMessage(current.messages, failureTurn, true);
3299
+ : hasStructuredExecutionProgress(turnState)
3300
+ ? appendNoticeTurn(this.buildCompletedAssistantMessages(current, turnState), failureTurn)
3301
+ : upsertAssistantMessage(current.messages, failureTurn, true);
2920
3302
  return {
2921
3303
  ...current,
2922
3304
  status: "failed",
@@ -2927,8 +3309,10 @@ export class StructuredSessionManager {
2927
3309
  messages: msgs,
2928
3310
  pendingEscalation: null,
2929
3311
  permissionBlocked: false,
3312
+ ...(turnState.compaction ? { harnessContext: compactionToSessionContext(turnState.compaction) } : {}),
3313
+ ...(turnState.harnessExtensionState ? { harnessExtensionState: turnState.harnessExtensionState } : {}),
2930
3314
  structuredState: {
2931
- ...current.structuredState,
3315
+ ...this.settleTurn(current.id, current.structuredState),
2932
3316
  model: turnState.model ?? current.structuredState?.model,
2933
3317
  inFlight: false,
2934
3318
  activeRequestId: null,