@co0ontty/wand 4.82.0 → 4.83.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (91) hide show
  1. package/README.md +47 -9
  2. package/dist/agent-dispatch.d.ts +3 -2
  3. package/dist/agent-dispatch.js +19 -3
  4. package/dist/ai-team-delivery-types.d.ts +40 -0
  5. package/dist/ai-team-delivery-types.js +1 -0
  6. package/dist/ai-team-delivery.d.ts +13 -0
  7. package/dist/ai-team-delivery.js +89 -0
  8. package/dist/ai-team-employee-binding.d.ts +16 -0
  9. package/dist/ai-team-employee-binding.js +77 -0
  10. package/dist/ai-team-prompts.js +5 -0
  11. package/dist/ai-team-runner.d.ts +9 -2
  12. package/dist/ai-team-runner.js +52 -7
  13. package/dist/ai-team-types.d.ts +17 -3
  14. package/dist/ai-team-types.js +43 -3
  15. package/dist/build-info.json +3 -3
  16. package/dist/cli.js +36 -0
  17. package/dist/config.d.ts +18 -10
  18. package/dist/config.js +63 -105
  19. package/dist/decision-client.d.ts +4 -0
  20. package/dist/decision-client.js +91 -0
  21. package/dist/decision-runner.d.ts +9 -0
  22. package/dist/decision-runner.js +36 -0
  23. package/dist/decision-service.d.ts +46 -0
  24. package/dist/decision-service.js +230 -0
  25. package/dist/decision-skill.d.ts +6 -0
  26. package/dist/decision-skill.js +70 -0
  27. package/dist/decision-tool.d.ts +21 -0
  28. package/dist/decision-tool.js +346 -0
  29. package/dist/decision-types.d.ts +36 -0
  30. package/dist/decision-types.js +123 -0
  31. package/dist/decision-worker.py +86 -0
  32. package/dist/default-employee.d.ts +4 -1
  33. package/dist/default-employee.js +11 -1
  34. package/dist/env-utils.js +3 -0
  35. package/dist/git-quick-commit.js +7 -1
  36. package/dist/message-truncator.d.ts +1 -1
  37. package/dist/message-truncator.js +29 -22
  38. package/dist/models.d.ts +1 -0
  39. package/dist/models.js +31 -4
  40. package/dist/process-manager.d.ts +39 -1
  41. package/dist/process-manager.js +112 -8
  42. package/dist/provider-catalog.d.ts +47 -0
  43. package/dist/provider-catalog.js +138 -0
  44. package/dist/provider-cli-updater.d.ts +1 -1
  45. package/dist/pty-foreground.d.ts +37 -0
  46. package/dist/pty-foreground.js +56 -0
  47. package/dist/pty-turn-activity.d.ts +6 -5
  48. package/dist/pty-turn-activity.js +6 -5
  49. package/dist/retention.d.ts +29 -0
  50. package/dist/retention.js +142 -0
  51. package/dist/server-ai-team-routes.d.ts +2 -1
  52. package/dist/server-ai-team-routes.js +27 -9
  53. package/dist/server-decision-routes.d.ts +10 -0
  54. package/dist/server-decision-routes.js +52 -0
  55. package/dist/server-employee-routes.js +9 -1
  56. package/dist/server-session-routes.d.ts +2 -2
  57. package/dist/server-session-routes.js +97 -1
  58. package/dist/server-settings-routes.js +10 -36
  59. package/dist/server-update-routes.js +2 -1
  60. package/dist/server-workspace-routes.js +8 -0
  61. package/dist/server.js +37 -5
  62. package/dist/session-completion-state.d.ts +26 -0
  63. package/dist/session-completion-state.js +30 -0
  64. package/dist/session-completion.d.ts +12 -0
  65. package/dist/session-completion.js +62 -0
  66. package/dist/session-provider.d.ts +1 -15
  67. package/dist/session-provider.js +4 -54
  68. package/dist/session-registry.d.ts +5 -0
  69. package/dist/session-registry.js +19 -1
  70. package/dist/session-transport.js +2 -0
  71. package/dist/storage.d.ts +12 -0
  72. package/dist/storage.js +119 -16
  73. package/dist/structured-client-protocol.js +36 -1
  74. package/dist/structured-provider-common.js +3 -3
  75. package/dist/structured-session-manager.d.ts +13 -1
  76. package/dist/structured-session-manager.js +196 -27
  77. package/dist/system-employee.js +1 -0
  78. package/dist/task-types.d.ts +4 -2
  79. package/dist/tool-preview.d.ts +8 -0
  80. package/dist/tool-preview.js +158 -0
  81. package/dist/types.d.ts +35 -2
  82. package/dist/web-ui/content/ai-teams.js +13 -13
  83. package/dist/web-ui/content/scripts.js +66 -64
  84. package/dist/web-ui/content/styles.css +1 -1
  85. package/dist/web-ui/content/tailwind.css +3 -0
  86. package/dist/web-ui/embedded-assets.js +3 -3
  87. package/dist/web-ui/provider-identity.d.ts +7 -13
  88. package/dist/web-ui/provider-identity.js +8 -71
  89. package/package.json +3 -2
  90. package/skills/wand-decision/SKILL.md +61 -0
  91. package/skills/wand-decision/scripts/decide.mjs +16 -0
@@ -7,6 +7,7 @@ import { getErrorMessage } from "./error-utils.js";
7
7
  import { getDefaultModelForProvider } from "./config.js";
8
8
  import { recordIterationPrompt } from "./iteration-log.js";
9
9
  import { startEmployeeKnowledgeRunner } from "./employee-knowledge.js";
10
+ import { withDecisionAccess } from "./decision-runner.js";
10
11
  import { signalNameFromNumber } from "./signal-utils.js";
11
12
  import { provisionalSessionTopic, SessionNativeTitleTracker, SessionTopicCoordinator, sessionTopicBlocklistForSnapshot, shouldAcceptGeneratedSessionTitle, shouldGenerateSessionTopicFromInput, } from "./session-topic.js";
12
13
  import { resolveSessionCwd } from "./session-cwd.js";
@@ -26,6 +27,7 @@ import { GeminiRunner, applyGeminiEvent, isMissingGeminiSession } from "./struct
26
27
  import { structuredRunId, } from "./structured-exec-host.js";
27
28
  import { defaultStructuredRunner, defaultStructuredState, isStructuredRunnerForProvider, normalizeThinkingEffort, resolveStructuredRunner, } from "./structured-provider-common.js";
28
29
  import { enrichStructuredMessages, WAND_PROTOCOL_VERSION } from "./structured-client-protocol.js";
30
+ import { RETENTION_IDLE_MS } from "./retention.js";
29
31
  class PersistedStructuredRunnerError extends Error {
30
32
  constructor(message) {
31
33
  super(message);
@@ -158,7 +160,7 @@ const STREAM_EMIT_DEBOUNCE_MS = 16;
158
160
  // second on the event loop.
159
161
  const STREAM_SAVE_THROTTLE_MS = 1_000;
160
162
  const DETACHED_RECOVERY_RETRY_MS = 500;
161
- const ARCHIVE_AFTER_MS = 1000 * 60 * 60 * 24;
163
+ const ARCHIVE_AFTER_MS = RETENTION_IDLE_MS;
162
164
  /**
163
165
  * 找出最后一条 assistant turn 中尚未配对 tool_result 的 AskUserQuestion tool_use。
164
166
  * 用来识别"刚被 SIGTERM 中断、正在等用户提交答案"的状态。
@@ -400,7 +402,11 @@ export class StructuredSessionManager {
400
402
  logger;
401
403
  execHost;
402
404
  sessions = new Map();
405
+ // Both freshly started runners and adopted daemon runs own the same input gate.
403
406
  pendingRunnerExecutions = new Map();
407
+ // Server-only, one-shot execution UUIDs. Actual request lifecycle clears them; metadata updates do not.
408
+ // Never persisted or included in session DTOs.
409
+ unacceptedTeamStarts = new Map();
404
410
  interruptedWith = new Map();
405
411
  preserveQueueOnInterrupt = new Set();
406
412
  /** Last wall-clock time (ms) a streaming checkpoint reached SQLite. */
@@ -434,18 +440,19 @@ export class StructuredSessionManager {
434
440
  detachedRecoveryRetryTimer = null;
435
441
  detachedRecoveryFailureLogged = false;
436
442
  disposed = false;
437
- constructor(storage, config, logger = null, runners = {}, execHost) {
443
+ constructor(storage, config, logger = null, runners = {}, execHost, decisionRuntime = () => null) {
438
444
  this.storage = storage;
439
445
  this.config = config;
440
446
  this.logger = logger;
441
447
  this.execHost = execHost;
442
- this.claudeCliRunner = runners.claudeCli ?? new ClaudeCliRunner({ language: () => this.config.language }, this.execHost);
443
- this.codexRunner = runners.codex ?? new CodexRunner(undefined, this.execHost);
444
- this.openCodeRunner = runners.opencode ?? new OpenCodeRunner(undefined, this.execHost);
445
- this.grokRunner = runners.grok ?? new GrokRunner(undefined, this.execHost);
446
- this.qoderRunner = runners.qoder ?? new QoderRunner(undefined, this.execHost);
447
- this.piRunner = runners.pi ?? new PiRunner(undefined, this.execHost);
448
- this.geminiRunner = runners.gemini ?? new GeminiRunner(undefined, this.execHost);
448
+ const wrap = (runner) => withDecisionAccess(runner, storage, decisionRuntime);
449
+ this.claudeCliRunner = wrap(runners.claudeCli ?? new ClaudeCliRunner({ language: () => this.config.language }, this.execHost));
450
+ this.codexRunner = wrap(runners.codex ?? new CodexRunner(undefined, this.execHost));
451
+ this.openCodeRunner = wrap(runners.opencode ?? new OpenCodeRunner(undefined, this.execHost));
452
+ this.grokRunner = wrap(runners.grok ?? new GrokRunner(undefined, this.execHost));
453
+ this.qoderRunner = wrap(runners.qoder ?? new QoderRunner(undefined, this.execHost));
454
+ this.piRunner = wrap(runners.pi ?? new PiRunner(undefined, this.execHost));
455
+ this.geminiRunner = wrap(runners.gemini ?? new GeminiRunner(undefined, this.execHost));
449
456
  for (const snapshot of this.storage.loadSessions()) {
450
457
  if ((snapshot.sessionKind ?? "pty") !== "structured")
451
458
  continue;
@@ -532,6 +539,7 @@ export class StructuredSessionManager {
532
539
  if (this.disposed)
533
540
  return;
534
541
  this.disposed = true;
542
+ this.unacceptedTeamStarts.clear();
535
543
  if (this.archiveTimer) {
536
544
  clearInterval(this.archiveTimer);
537
545
  this.archiveTimer = null;
@@ -635,13 +643,16 @@ export class StructuredSessionManager {
635
643
  this.scheduleDetachedRecovery();
636
644
  return;
637
645
  }
638
- const ids = this.pendingRecoveryIds;
639
- this.pendingRecoveryIds = [];
646
+ if (this.disposed)
647
+ return;
648
+ const ids = [...this.pendingRecoveryIds];
640
649
  const byRunId = new Map(runs.map((run) => [run.runId, run]));
641
650
  for (const sessionId of ids) {
642
651
  const session = this.sessions.get(sessionId);
643
- if (!session)
652
+ if (!session) {
653
+ this.pendingRecoveryIds = this.pendingRecoveryIds.filter((id) => id !== sessionId);
644
654
  continue;
655
+ }
645
656
  const state = byRunId.get(structuredRunId(sessionId));
646
657
  if (!state) {
647
658
  // Inventory was read successfully, so this is a genuinely lost run,
@@ -661,16 +672,20 @@ export class StructuredSessionManager {
661
672
  this.sessions.set(sessionId, interrupted);
662
673
  this.saveAuthoritativeSession(interrupted);
663
674
  this.emitStructuredSnapshot(interrupted);
675
+ this.pendingRecoveryIds = this.pendingRecoveryIds.filter((id) => id !== sessionId);
664
676
  continue;
665
677
  }
666
678
  try {
667
679
  // listRuns is metadata-only on Render v2. Fetch the authoritative
668
680
  // paged replay from the owner before feeding any provider reducer.
669
681
  const attached = await this.execHost.attachRun(state.runId);
682
+ if (this.disposed)
683
+ return;
670
684
  if (!attached || attached.incarnationId !== state.incarnationId) {
671
685
  throw new Error("Structured run changed owner between inventory and replay");
672
686
  }
673
- await this.resumeDetachedRun(session, attached);
687
+ await this.resumeDetachedRun(this.requireSession(sessionId), attached);
688
+ this.pendingRecoveryIds = this.pendingRecoveryIds.filter((id) => id !== sessionId);
674
689
  }
675
690
  catch (error) {
676
691
  console.error(`[WAND] structured run recovery failed for ${sessionId}:`, error);
@@ -692,9 +707,20 @@ export class StructuredSessionManager {
692
707
  }
693
708
  async resumeDetachedRun(snapshot, initialState) {
694
709
  const sessionId = snapshot.id;
695
- if (!this.execHost || !snapshot.structuredState)
710
+ if (this.disposed || !this.execHost || !snapshot.structuredState)
696
711
  return;
697
712
  const requestId = `recover-${initialState.incarnationId}`;
713
+ let runningHandle = null;
714
+ let interruptRequested = false;
715
+ const execution = {
716
+ interrupt: () => {
717
+ interruptRequested = true;
718
+ runningHandle?.interrupt();
719
+ },
720
+ };
721
+ // Reserve ownership before publishing inFlight or awaiting adoption. Inputs
722
+ // arriving during replay/attach must queue just like inputs to a fresh runner.
723
+ this.pendingRunnerExecutions.set(sessionId, execution);
698
724
  // Re-arm the in-flight marker so UI and request guards treat the resumed
699
725
  // turn like any other streaming turn.
700
726
  const resumed = {
@@ -800,7 +826,6 @@ export class StructuredSessionManager {
800
826
  runningHandle.interrupt();
801
827
  }
802
828
  };
803
- let runningHandle = null;
804
829
  let lastStdoutSeq = initialState.stdoutSeq;
805
830
  let lastStderrSeq = initialState.stderrSeq;
806
831
  const feedLine = (line) => {
@@ -843,7 +868,22 @@ export class StructuredSessionManager {
843
868
  for (const line of lines)
844
869
  feedLine(line);
845
870
  }
846
- runningHandle = await this.execHost.adoptRun(structuredRunId(sessionId));
871
+ try {
872
+ runningHandle = await this.execHost.adoptRun(structuredRunId(sessionId));
873
+ }
874
+ catch (error) {
875
+ this.releasePendingRunnerExecution(sessionId, execution, false);
876
+ throw error;
877
+ }
878
+ // Server shutdown detaches persistent runs; only an explicit stop/delete
879
+ // should cancel a handle that arrived after its request was invalidated.
880
+ if (this.disposed)
881
+ return;
882
+ if (!this.isCurrentRequest(sessionId, requestId)) {
883
+ runningHandle?.interrupt();
884
+ this.releasePendingRunnerExecution(sessionId, execution);
885
+ return;
886
+ }
847
887
  if (!runningHandle) {
848
888
  // Run vanished between listing and adoption; fall back to failure notes.
849
889
  this.finalizeRecoveredRun(sessionId, requestId, processor, replayView, {
@@ -891,11 +931,18 @@ export class StructuredSessionManager {
891
931
  });
892
932
  flushEmit();
893
933
  });
934
+ // Install exit listeners before delivering an interrupt that arrived during adopt.
935
+ if (interruptRequested)
936
+ runningHandle.interrupt();
894
937
  }
895
938
  finalizeRecoveredRun(sessionId, requestId, processor, replayView, outcome) {
896
- this.execHost?.forgetRun(structuredRunId(sessionId));
897
939
  if (!this.isCurrentRequest(sessionId, requestId))
898
940
  return;
941
+ const execution = this.pendingRunnerExecutions.get(sessionId);
942
+ if (execution)
943
+ this.releasePendingRunnerExecution(sessionId, execution);
944
+ else
945
+ this.execHost?.forgetRun(structuredRunId(sessionId));
899
946
  const current = this.sessions.get(sessionId);
900
947
  if (!current)
901
948
  return;
@@ -907,7 +954,8 @@ export class StructuredSessionManager {
907
954
  const failedExit = outcome.lost
908
955
  || (outcome.exitCode !== null && outcome.exitCode !== 0)
909
956
  || outcome.signal !== null;
910
- if ((processor.primaryError || failedExit) && !interruptedForQuestion) {
957
+ const interruptPrompt = this.interruptedWith.get(sessionId);
958
+ if ((processor.primaryError || failedExit) && !interruptedForQuestion && !interruptPrompt) {
911
959
  const errorText = outcome.lost
912
960
  ? "服务重启后运行进程已丢失,本轮未能完成。"
913
961
  : this.formatStructuredExitError(commandLabel, outcome.exitCode, outcome.signal, {
@@ -923,7 +971,7 @@ export class StructuredSessionManager {
923
971
  this.emitStructuredSnapshot(failed, "ended");
924
972
  return;
925
973
  }
926
- const keepRunning = interruptedForQuestion;
974
+ const keepRunning = interruptedForQuestion || !!interruptPrompt;
927
975
  const messages = replayTruncated
928
976
  ? this.mergeTruncatedReplayTurn(current, processor, replayView, !keepRunning)
929
977
  : this.buildCompletedAssistantMessages(current, processor.state, false);
@@ -936,6 +984,7 @@ export class StructuredSessionManager {
936
984
  output: replayTruncated ? current.output || processor.state.result : processor.state.result,
937
985
  claudeSessionId: processor.state.sessionId ?? current.claudeSessionId,
938
986
  messages,
987
+ queuedMessages: this.resolveQueuedMessagesAfterInterrupt(sessionId, current, interruptPrompt),
939
988
  pendingEscalation: null,
940
989
  permissionBlocked: false,
941
990
  structuredState: {
@@ -952,7 +1001,16 @@ export class StructuredSessionManager {
952
1001
  this.emitStructuredSnapshot(finished);
953
1002
  if (!keepRunning)
954
1003
  this.emitStructuredSnapshot(finished, "ended");
955
- if ((finished.queuedMessages?.length ?? 0) > 0) {
1004
+ if (interruptPrompt) {
1005
+ this.interruptedWith.delete(sessionId);
1006
+ this.preserveQueueOnInterrupt.delete(sessionId);
1007
+ setImmediate(() => {
1008
+ this.sendMessage(sessionId, interruptPrompt).catch((error) => {
1009
+ console.error("[WAND] recovered interrupt-and-send failed:", error);
1010
+ });
1011
+ });
1012
+ }
1013
+ else if ((finished.queuedMessages?.length ?? 0) > 0) {
956
1014
  setImmediate(() => { void this.flushNextQueuedMessage(sessionId); });
957
1015
  }
958
1016
  }
@@ -1067,6 +1125,20 @@ export class StructuredSessionManager {
1067
1125
  this.emitStructuredSnapshot(updated);
1068
1126
  return updated;
1069
1127
  }
1128
+ /** 归档 / 取消归档:只写标记并广播,不中断运行中的 run,也不删历史。 */
1129
+ setSessionArchived(id, archived) {
1130
+ const current = this.requireSession(id);
1131
+ const archivedAt = archived ? new Date().toISOString() : null;
1132
+ const updated = { ...current, archived, archivedAt };
1133
+ this.sessions.set(id, updated);
1134
+ this.storage.updateSessionRuntimeMetadata(updated);
1135
+ this.emit({
1136
+ type: "status",
1137
+ sessionId: id,
1138
+ data: { archived, archivedAt, sessionKind: "structured" },
1139
+ });
1140
+ return updated;
1141
+ }
1070
1142
  setSessionTopicGenerating(id, titleGenerating) {
1071
1143
  const current = this.sessions.get(id);
1072
1144
  if (!current || current.titleGenerating === titleGenerating)
@@ -1256,12 +1328,39 @@ export class StructuredSessionManager {
1256
1328
  this.emitStructuredSnapshot(titled);
1257
1329
  return titled;
1258
1330
  }
1331
+ /** Server-only generation anchor, including a failed unaccepted turn whose activeRequestId is cleared. */
1332
+ teamRequestId(id) {
1333
+ if (this.disposed)
1334
+ return null;
1335
+ const session = this.sessions.get(id);
1336
+ if (!session?.automationId?.startsWith("ai-team:"))
1337
+ return null;
1338
+ const failed = this.unacceptedTeamStarts.get(id);
1339
+ return session.structuredState?.activeRequestId
1340
+ ?? (session.status === "failed" && !session.structuredState?.inFlight ? failed?.requestId ?? null : null);
1341
+ }
1342
+ /** Consume only the exact failed request observed by the team's dispatch, never error-text inference. */
1343
+ consumeUnacceptedTeamStartup(id, requestId) {
1344
+ const fact = this.unacceptedTeamStarts.get(id);
1345
+ if (!fact || fact.requestId !== requestId)
1346
+ return false;
1347
+ this.unacceptedTeamStarts.delete(id);
1348
+ const session = this.sessions.get(id);
1349
+ return !this.disposed && session?.status === "failed"
1350
+ && !session.structuredState?.activeRequestId && !session.structuredState?.inFlight;
1351
+ }
1259
1352
  /** Only an unstarted employee conversation may change CLI after a failed first turn. */
1260
1353
  retryEmployeeCandidate(id, original, prompt, error) {
1354
+ // Team runs own candidate scheduling; keep the snapshot without a second retry loop.
1355
+ if (original.automationId?.startsWith("ai-team:"))
1356
+ return null;
1261
1357
  const candidates = original.employeeCandidates ?? [];
1262
1358
  const nextIndex = (original.employeeCandidateIndex ?? 0) + 1;
1263
1359
  if (!original.employeeId || (original.messages?.length ?? 0) !== 0 || nextIndex >= candidates.length)
1264
1360
  return null;
1361
+ // 手动选择候选链外的工具时,不把明确选择又换回员工的另一条候选。
1362
+ if (original.provider !== candidates[original.employeeCandidateIndex ?? 0]?.provider)
1363
+ return null;
1265
1364
  const current = this.sessions.get(id);
1266
1365
  if (!current || (current.status !== "running" && current.status !== "failed"))
1267
1366
  return null;
@@ -1359,10 +1458,8 @@ export class StructuredSessionManager {
1359
1458
  const runnerExecution = this.pendingRunnerExecutions.get(id);
1360
1459
  // interrupt() only requests cancellation; completion can settle later.
1361
1460
  // Treat runner-map ownership as the authoritative in-flight state.
1362
- const childActive = Boolean(runnerExecution);
1461
+ const childActive = Boolean(runnerExecution) || this.pendingRecoveryIds.includes(id);
1363
1462
  if (!childActive) {
1364
- if (runnerExecution)
1365
- this.releasePendingRunnerExecution(id, runnerExecution);
1366
1463
  const recovered = {
1367
1464
  ...session,
1368
1465
  status: "idle",
@@ -1378,6 +1475,7 @@ export class StructuredSessionManager {
1378
1475
  session = recovered;
1379
1476
  }
1380
1477
  else if (opts?.interrupt) {
1478
+ this.requireRecoveryControl(id);
1381
1479
  this.interruptedWith.set(id, prompt);
1382
1480
  if (opts.preserveQueue) {
1383
1481
  this.preserveQueueOnInterrupt.add(id);
@@ -1448,6 +1546,7 @@ export class StructuredSessionManager {
1448
1546
  content: [{ type: "text", text: prompt }],
1449
1547
  };
1450
1548
  const requestId = randomUUID();
1549
+ this.unacceptedTeamStarts.delete(id);
1451
1550
  if ((session.provider === "pi" && isMissingPiSession(session.structuredState?.lastError, session.claudeSessionId))
1452
1551
  || (session.provider === "gemini" && isMissingGeminiSession(session.structuredState?.lastError))) {
1453
1552
  session = { ...session, claudeSessionId: null };
@@ -1466,6 +1565,8 @@ export class StructuredSessionManager {
1466
1565
  },
1467
1566
  };
1468
1567
  this.sessions.set(id, updated);
1568
+ if (updated.automationId?.startsWith("ai-team:"))
1569
+ opts?.teamRequestStarted?.(id, requestId);
1469
1570
  this.checkpointSessionMessages(updated);
1470
1571
  this.emitStructuredSnapshot(updated);
1471
1572
  this.emit({
@@ -1569,6 +1670,14 @@ export class StructuredSessionManager {
1569
1670
  };
1570
1671
  this.sessions.set(id, failed);
1571
1672
  this.saveAuthoritativeSession(failed);
1673
+ if (!this.disposed && error instanceof UnacceptedStructuredSpawnError
1674
+ && current.automationId?.startsWith("ai-team:")) {
1675
+ // Available before status/ended listeners run. A later turn/stop/delete invalidates this fact.
1676
+ if (this.unacceptedTeamStarts.size >= 1024) {
1677
+ this.unacceptedTeamStarts.delete(this.unacceptedTeamStarts.keys().next().value);
1678
+ }
1679
+ this.unacceptedTeamStarts.set(id, { requestId });
1680
+ }
1572
1681
  this.emit({
1573
1682
  type: "status",
1574
1683
  sessionId: id,
@@ -1654,6 +1763,7 @@ export class StructuredSessionManager {
1654
1763
  */
1655
1764
  async promoteQueuedMessage(sessionId, index, expectedText, idempotencyKey) {
1656
1765
  const session = this.requireSession(sessionId);
1766
+ this.requireRecoveryControl(sessionId);
1657
1767
  if (idempotencyKey && this.seenIdempotencyKeys.has(`${sessionId}:${idempotencyKey}`)) {
1658
1768
  return session;
1659
1769
  }
@@ -1700,6 +1810,56 @@ export class StructuredSessionManager {
1700
1810
  this.emitStructuredSnapshot(updated);
1701
1811
  return updated;
1702
1812
  }
1813
+ /** 新建空白对话可原位换 CLI;接受过输入、恢复会话和自动化不跨 provider 搬历史。 */
1814
+ setSessionProvider(sessionId, provider) {
1815
+ const session = this.requireSession(sessionId);
1816
+ if (!isSessionProvider(provider))
1817
+ throw new Error("请选择有效的 CLI 工具。");
1818
+ if (session.status !== "idle" || session.archived || session.structuredState?.inFlight ||
1819
+ this.pendingRunnerExecutions.has(sessionId) || session.claudeSessionId ||
1820
+ (session.messages?.length ?? 0) > 0 || (session.queuedMessages?.length ?? 0) > 0 ||
1821
+ session.automationId || (session.sessionSource && session.sessionSource !== "interactive") ||
1822
+ this.relayHandlerFor(session)) {
1823
+ throw new Error("只有尚未发送消息的新建空白对话可以更换工具。");
1824
+ }
1825
+ if (session.provider === provider)
1826
+ return session;
1827
+ const candidates = session.employeeCandidates ?? [];
1828
+ const candidateIndex = candidates.findIndex((candidate) => candidate.provider === provider);
1829
+ const candidate = candidateIndex >= 0 ? candidates[candidateIndex] : undefined;
1830
+ const runner = defaultStructuredRunner(provider);
1831
+ const model = candidate && candidate.model !== "default" ? candidate.model
1832
+ : getDefaultModelForProvider(this.config, provider) || null;
1833
+ const effort = normalizeThinkingEffort(candidate?.thinkingEffort ?? this.config.defaultThinkingEffort);
1834
+ const thinkingEffort = effort?.includes(":") && !effort.startsWith(`${provider}:`) ? "off" : effort;
1835
+ const requestedMode = candidate?.mode ?? session.mode;
1836
+ const unsupportedMode = (requestedMode === "native" && provider !== "claude") ||
1837
+ (requestedMode === "auto-edit" && ["opencode", "grok", "pi"].includes(provider));
1838
+ const mode = provider === "codex" ? "full-access"
1839
+ : unsupportedMode ? "default" : requestedMode;
1840
+ const updated = {
1841
+ ...session,
1842
+ provider,
1843
+ runner,
1844
+ command: recoveredCommandLabel(runner),
1845
+ mode,
1846
+ autoApprovePermissions: shouldAutoApproveForMode(mode),
1847
+ selectedModel: model,
1848
+ thinkingEffort,
1849
+ employeeCandidateIndex: candidateIndex >= 0 ? candidateIndex : undefined,
1850
+ structuredState: { ...defaultStructuredState(provider, runner), model: model ?? undefined },
1851
+ };
1852
+ // 身份、知识归属、任务/目录和候选快照不变;只更新本会话的执行参数。
1853
+ this.storage.saveSession(updated);
1854
+ this.sessions.set(sessionId, updated);
1855
+ this.emit({
1856
+ type: "status",
1857
+ sessionId,
1858
+ data: { ...buildStructuredOutputPayload(updated), command: updated.command,
1859
+ mode, autoApprovePermissions: updated.autoApprovePermissions },
1860
+ });
1861
+ return updated;
1862
+ }
1703
1863
  /** Update the selected model for a structured session. Takes effect on the next spawn. */
1704
1864
  setSessionModel(sessionId, model) {
1705
1865
  const session = this.requireSession(sessionId);
@@ -1840,8 +2000,15 @@ export class StructuredSessionManager {
1840
2000
  });
1841
2001
  return updated;
1842
2002
  }
2003
+ requireRecoveryControl(id) {
2004
+ if (this.pendingRecoveryIds.includes(id) && !this.pendingRunnerExecutions.has(id)) {
2005
+ throw new Error("正在恢复运行中的任务,请稍后重试控制操作;新消息仍可排队。");
2006
+ }
2007
+ }
1843
2008
  stop(id) {
2009
+ this.unacceptedTeamStarts.delete(id);
1844
2010
  const session = this.requireSession(id);
2011
+ this.requireRecoveryControl(id);
1845
2012
  this.interruptedWith.delete(id);
1846
2013
  this.preserveQueueOnInterrupt.delete(id);
1847
2014
  // Clearing activeRequestId is the generation barrier: late data/close callbacks
@@ -1874,6 +2041,8 @@ export class StructuredSessionManager {
1874
2041
  return cancelled;
1875
2042
  }
1876
2043
  delete(id) {
2044
+ this.unacceptedTeamStarts.delete(id);
2045
+ this.requireRecoveryControl(id);
1877
2046
  const runnerExecution = this.pendingRunnerExecutions.get(id);
1878
2047
  // Invalidate callback ownership before signalling the runner. Cancellation
1879
2048
  // can synchronously wake listeners in some adapter implementations.
@@ -1908,13 +2077,13 @@ export class StructuredSessionManager {
1908
2077
  return this.sessions.get(sessionId) ?? null;
1909
2078
  }
1910
2079
  /** Delete a handle only if it still belongs to the execution doing cleanup. */
1911
- releasePendingRunnerExecution(sessionId, execution) {
2080
+ releasePendingRunnerExecution(sessionId, execution, forgetRun = true) {
1912
2081
  if (this.pendingRunnerExecutions.get(sessionId) !== execution)
1913
2082
  return false;
1914
2083
  this.pendingRunnerExecutions.delete(sessionId);
1915
- // Drop the daemon-side record once the manager has taken over completion;
1916
- // no-op for the in-process fallback host.
1917
- this.execHost?.forgetRun(structuredRunId(sessionId));
2084
+ // A failed recovery attach must retain the daemon record for the next retry.
2085
+ if (forgetRun)
2086
+ this.execHost?.forgetRun(structuredRunId(sessionId));
1918
2087
  return true;
1919
2088
  }
1920
2089
  emitStructuredSnapshot(session, eventType = "output") {
@@ -52,6 +52,7 @@ export function systemEmployeeDefinition(agents, now, existing) {
52
52
  avatar: "",
53
53
  agents: agents.length ? agents : systemEmployeeSeedAgents(),
54
54
  systemKey: SYSTEM_EMPLOYEE_KEY,
55
+ tags: [SYSTEM_EMPLOYEE_TAG],
55
56
  createdAt: existing?.createdAt ?? now,
56
57
  updatedAt: now,
57
58
  };
@@ -4,8 +4,8 @@ export declare function isClosedWandTaskStatus(status: WandTaskStatus): boolean;
4
4
  export type WandTaskPriority = "none" | "low" | "medium" | "high" | "urgent";
5
5
  /** 新建任务的默认优先级:用户没挑就是「低」,不再落成「无优先级」。 */
6
6
  export declare const DEFAULT_WAND_TASK_PRIORITY: WandTaskPriority;
7
- /** 任务派发时选择的 CLI 工具;空串表示尚未指定。 */
8
- export type WandTaskAgentProvider = "claude" | "codex" | "opencode" | "grok" | "qoder" | "pi" | "gemini";
7
+ /** 任务派发时选择的 CLI 工具;空串表示尚未指定。沿用 provider 的唯一真源。 */
8
+ export type WandTaskAgentProvider = import("./provider-catalog.js").SessionProvider;
9
9
  export type WandTaskAgentModel = string;
10
10
  /** 与会话思考档位同一套:旧四档,或 `provider:level` 原生档。 */
11
11
  export type WandTaskAgentEffort = "off" | "standard" | "deep" | "max" | `${WandTaskAgentProvider}:${string}`;
@@ -147,6 +147,8 @@ export interface WandTask {
147
147
  /** 该任务绑定的执行 Agent;null 表示还没指定。 */
148
148
  agent: WandTaskAgent | null;
149
149
  executionSubject?: TaskExecutionSubject | null;
150
+ /** 进入归档的时间;未归档为 null。保留期清理按它起算。 */
151
+ archivedAt: string | null;
150
152
  createdAt: string;
151
153
  updatedAt: string;
152
154
  }
@@ -0,0 +1,8 @@
1
+ import type { ContentBlock, ToolResultBlock, ToolUseBlock } from "./types.js";
2
+ export declare const TOOL_PREVIEW_LIMIT = 180;
3
+ /** A small, literal excerpt for a collapsed card, never generated by a model. */
4
+ export declare function compactPreviewText(value: string): string;
5
+ export declare function toolInputPreview(use: Pick<ToolUseBlock, "name" | "input" | "description" | "preview">): string;
6
+ export declare function toolResultPreview(result: Pick<ToolResultBlock, "content" | "is_error" | "preview" | "_truncated">): string;
7
+ /** Called at transport boundaries; provider history and full detail remain untouched. */
8
+ export declare function withToolPreview(block: ContentBlock): ContentBlock;
@@ -0,0 +1,158 @@
1
+ export const TOOL_PREVIEW_LIMIT = 180;
2
+ /** A small, literal excerpt for a collapsed card, never generated by a model. */
3
+ export function compactPreviewText(value) {
4
+ const text = value.slice(0, 8_192)
5
+ .replace(/\x1b\[[0-9;]*[A-Za-z]/g, "")
6
+ .replace(/(?:Bearer\s+)[\w.+/=-]+/gi, "Bearer [隐藏]")
7
+ .replace(/((?:api[_-]?key|access[_-]?token|token|password|secret)\s*[=:]\s*)[^\s,;]+/gi, "$1[隐藏]")
8
+ .replace(/\s+/g, " ").trim();
9
+ return text.length > TOOL_PREVIEW_LIMIT ? text.slice(0, TOOL_PREVIEW_LIMIT - 1) + "…" : text;
10
+ }
11
+ function stringField(input, ...keys) {
12
+ for (const key of keys) {
13
+ if (typeof input[key] === "string" && input[key].trim())
14
+ return input[key];
15
+ }
16
+ return "";
17
+ }
18
+ function shortPath(path) {
19
+ return path.replace(/\\/g, "/").split("/").filter(Boolean).slice(-2).join("/");
20
+ }
21
+ function lineCount(text) {
22
+ return text ? text.split("\n").length - (text.endsWith("\n") ? 1 : 0) : 0;
23
+ }
24
+ export function toolInputPreview(use) {
25
+ if (use.preview)
26
+ return compactPreviewText(use.preview);
27
+ const input = use.input ?? {};
28
+ const name = use.name.toLowerCase().split(/\/|__/).pop() ?? "";
29
+ const path = shortPath(stringField(input, "file_path", "path", "filename", "file", "notebook_path"));
30
+ const command = stringField(input, "command", "cmd", "script");
31
+ let parts = [];
32
+ if (command) {
33
+ parts = [command];
34
+ }
35
+ else if (Array.isArray(input.tool_uses)) {
36
+ const calls = input.tool_uses.filter((value) => !!value && typeof value === "object");
37
+ parts = [`并行 ${calls.length} 个工具`, calls.slice(0, 3).map(call => stringField(call, "recipient_name", "name")).filter(Boolean).join("、")];
38
+ }
39
+ else if (typeof input.code === "string") {
40
+ const calls = [...new Set(Array.from(input.code.slice(0, 16_384).matchAll(/tools\.(\w+)\s*\(/g), match => match[1]))];
41
+ parts = [calls.length ? `脚本调用 ${calls.slice(0, 4).join("、")}` : "执行代码", use.description || ""];
42
+ }
43
+ else if (Array.isArray(input.urls)) {
44
+ const urls = input.urls.filter((value) => typeof value === "string");
45
+ parts = [`读取 ${urls.length} 个网址`, ...urls.slice(0, 2).map(value => {
46
+ try {
47
+ const url = new URL(value);
48
+ return url.hostname + url.pathname;
49
+ }
50
+ catch {
51
+ return "网址";
52
+ }
53
+ })];
54
+ }
55
+ else if (/edit|write|replace|patch|file_change/.test(name)) {
56
+ parts = [path];
57
+ if (Array.isArray(input.edits))
58
+ parts.push(`${input.edits.length} 处替换`);
59
+ else if (typeof input.old_string === "string" && typeof input.new_string === "string") {
60
+ parts.push(`替换 ${lineCount(input.old_string)} 行 → ${lineCount(input.new_string)} 行`);
61
+ }
62
+ else if (typeof input.content === "string")
63
+ parts.push(`写入 ${lineCount(input.content)} 行`);
64
+ const patch = stringField(input, "patch", "unified_diff");
65
+ if (patch) {
66
+ const paths = Array.from(patch.matchAll(/^(?:\*\*\* (?:Update|Add|Delete) File: |\+\+\+ b\/)(.+)$/gm), match => shortPath(match[1]));
67
+ if (paths.length)
68
+ parts.push([...new Set(paths)].slice(0, 3).join("、"));
69
+ }
70
+ }
71
+ else if (/grep|glob|search|find/.test(name)) {
72
+ const query = stringField(input, "pattern", "query", "search", "regex");
73
+ const queries = Array.isArray(input.queries) ? input.queries.filter((v) => typeof v === "string") : [];
74
+ parts = [query || queries.slice(0, 2).join(";"), path ? `范围 ${path}` : "", stringField(input, "glob", "include")];
75
+ }
76
+ else if (path) {
77
+ parts = [path];
78
+ const offset = input.offset ?? input.start_line;
79
+ const limit = input.limit ?? input.end_line;
80
+ if (typeof offset === "number")
81
+ parts.push(`起始行 ${offset}`);
82
+ if (typeof limit === "number")
83
+ parts.push(`${input.end_line !== undefined ? "结束行" : "最多"} ${limit}${input.end_line !== undefined ? "" : " 行"}`);
84
+ }
85
+ else if (typeof input.url === "string") {
86
+ try {
87
+ const url = new URL(input.url);
88
+ parts = [url.hostname + url.pathname];
89
+ }
90
+ catch {
91
+ parts = [input.url];
92
+ }
93
+ }
94
+ else if (Array.isArray(input.todos)) {
95
+ const todos = input.todos.filter((v) => !!v && typeof v === "object");
96
+ const done = todos.filter(todo => todo.status === "completed").length;
97
+ const active = todos.find(todo => todo.status === "in_progress");
98
+ parts = [`${done}/${todos.length} 项完成`, active ? stringField(active, "activeForm", "content", "subject") : ""];
99
+ }
100
+ else {
101
+ parts = [stringField(input, "action", "agent"), stringField(input, "subject", "title", "description", "task", "prompt", "query", "name") || use.description || ""];
102
+ if (!parts.some(Boolean)) {
103
+ parts = Object.entries(input).filter(([key, value]) => !/token|password|secret|key|content|body|data|code/i.test(key) && ["string", "number", "boolean"].includes(typeof value))
104
+ .slice(0, 2).map(([key, value]) => `${key}: ${String(value)}`);
105
+ }
106
+ }
107
+ return compactPreviewText(parts.filter(Boolean).join(" · "));
108
+ }
109
+ export function toolResultPreview(result) {
110
+ if (result.preview)
111
+ return compactPreviewText(result.preview);
112
+ const raw = typeof result.content === "string" ? result.content : result.content
113
+ .filter(part => part.type === "text" && typeof part.text === "string")
114
+ .slice(0, 3).map(part => part.text).join("\n");
115
+ if (!raw.trim())
116
+ return "";
117
+ let text = raw;
118
+ let exit = "";
119
+ if (raw.length <= 65_536 && /^\s*[{[]/.test(raw)) {
120
+ try {
121
+ const parsed = JSON.parse(raw);
122
+ if (Array.isArray(parsed))
123
+ return `返回 ${parsed.length} 项`;
124
+ if (parsed && typeof parsed === "object") {
125
+ const code = parsed.exit_code ?? parsed.exitCode;
126
+ if (typeof code === "number")
127
+ exit = `退出码 ${code}`;
128
+ text = stringField(parsed, "error", "message", "summary", "output", "stdout", "result");
129
+ if (!text) {
130
+ const rows = parsed.results ?? parsed.matches ?? parsed.items;
131
+ if (Array.isArray(rows))
132
+ text = `返回 ${rows.length} 项`;
133
+ else
134
+ text = Object.entries(parsed).filter(([key, value]) => /^(?:status|count|total|success|passed|failed|duration|wall_time_seconds)$/.test(key) &&
135
+ ["string", "number", "boolean"].includes(typeof value)).map(([key, value]) => `${key}: ${value}`).join(" · ");
136
+ }
137
+ }
138
+ }
139
+ catch { /* Plain tool output may begin with a bracket. */ }
140
+ }
141
+ const sample = text.length > 8_192 ? text.slice(0, 2_048) + "\n…\n" + text.slice(-6_144) : text;
142
+ const lines = sample.split(/\r?\n/).map(line => line.trim()).filter(Boolean);
143
+ const facts = lines.filter(line => /(?:error|failed|failure|passed|\b(?:pass|fail|tests?)\b|exit(?:ed)?(?: code)?|BUILD (?:SUCCESS|FAIL)|错误|失败|通过|命中|找到)/i.test(line));
144
+ const selected = (facts.length ? facts.slice(-2) : result.is_error ? lines.slice(-2) : lines.slice(0, 2)).join(" · ");
145
+ return compactPreviewText([exit, selected].filter(Boolean).join(" · "));
146
+ }
147
+ /** Called at transport boundaries; provider history and full detail remain untouched. */
148
+ export function withToolPreview(block) {
149
+ if (block.type === "tool_use") {
150
+ const preview = toolInputPreview(block);
151
+ return preview ? { ...block, preview } : block;
152
+ }
153
+ if (block.type === "tool_result") {
154
+ const preview = toolResultPreview(block);
155
+ return preview ? { ...block, preview } : block;
156
+ }
157
+ return block;
158
+ }