@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
@@ -1,6 +1,8 @@
1
- /** Keep tool payloads out of all chat snapshots and fetch them only when opened. */
1
+ /** Keep full tool payloads out of compact snapshots; retain bounded card previews. */
2
+ import { withToolPreview } from "./tool-preview.js";
2
3
  import { createHash } from "node:crypto";
3
4
  import { contentHasStructuredImage } from "./structured-content.js";
5
+ import { isDecisionToolCall } from "./decision-tool.js";
4
6
  const TRUNCATION_THRESHOLD = 200;
5
7
  const SUMMARY_LENGTH = 100;
6
8
  /**
@@ -29,21 +31,23 @@ export const MESSAGE_FIRST_PAINT_BYTES = 1024 * 1024;
29
31
  * 而不是把整段拉进首屏(窗口的目标是压住首屏载荷,吸附不能反过来把它撑大)。
30
32
  */
31
33
  const MAX_BLOCK_SNAP_BACK_BYTES = 128 * 1024;
34
+ /** Decision calls keep their raw names; only this local defaults lookup treats them as always visible. */
35
+ function toolNamesForTransport(content) {
36
+ return new Map(content.flatMap((block) => block.type === "tool_use"
37
+ ? [[block.id, isDecisionToolCall(block) ? "__wand_decision" : block.name]] : []));
38
+ }
32
39
  /** 每个块在客户端是否「默认收起」(思考块 + 默认收起的工具卡片,与传输截断同一口径)。 */
33
40
  export function collapsedBlockFlags(content, cardDefaults) {
34
- const toolNameById = new Map();
35
- for (const block of content) {
36
- if (block.type === "tool_use")
37
- toolNameById.set(block.id, block.name);
38
- }
41
+ const toolNameById = toolNamesForTransport(content);
39
42
  return content.map((block) => {
40
43
  switch (block.type) {
41
44
  case "thinking":
42
45
  return cardDefaults.thinking !== true;
43
46
  case "tool_use":
44
- return isToolDefaultCollapsed(block.name, cardDefaults);
47
+ return !isDecisionToolCall(block) && isToolDefaultCollapsed(block.name, cardDefaults);
45
48
  case "tool_result":
46
- return isToolDefaultCollapsed(toolNameById.get(block.tool_use_id) ?? "", cardDefaults);
49
+ return block.semantic?.kind !== "decision"
50
+ && isToolDefaultCollapsed(toolNameById.get(block.tool_use_id) ?? "", cardDefaults);
47
51
  default:
48
52
  return false;
49
53
  }
@@ -75,7 +79,8 @@ function blockTransportBytes(block, cardDefaults, toolNameById) {
75
79
  case "tool_result": {
76
80
  const result = block;
77
81
  const raw = getContentString(result.content);
78
- const collapsed = isToolDefaultCollapsed(toolNameById.get(result.tool_use_id) ?? "", cardDefaults);
82
+ const collapsed = result.semantic?.kind !== "decision"
83
+ && isToolDefaultCollapsed(toolNameById.get(result.tool_use_id) ?? "", cardDefaults);
79
84
  const truncated = collapsed && !result.is_error &&
80
85
  !contentHasStructuredImage(result.content) && raw.length > TRUNCATION_THRESHOLD;
81
86
  return truncated ? SUMMARY_LENGTH + 4 : raw.length;
@@ -89,11 +94,7 @@ function blockTransportBytes(block, cardDefaults, toolNameById) {
89
94
  * (预算判定只需要知道「超了」,不需要精确值)。
90
95
  */
91
96
  export function contentTransportBytes(content, cardDefaults, limit = Number.POSITIVE_INFINITY) {
92
- const toolNameById = new Map();
93
- for (const block of content) {
94
- if (block.type === "tool_use")
95
- toolNameById.set(block.id, block.name);
96
- }
97
+ const toolNameById = toolNamesForTransport(content);
97
98
  let total = 0;
98
99
  for (const block of content) {
99
100
  total += blockTransportBytes(block, cardDefaults, toolNameById) + 32;
@@ -128,11 +129,7 @@ function cutStartByBudget(content, cardDefaults, visibleBudget, byteBudget) {
128
129
  if (content.length === 0)
129
130
  return 0;
130
131
  const collapsed = collapsedBlockFlags(content, cardDefaults);
131
- const toolNameById = new Map();
132
- for (const block of content) {
133
- if (block.type === "tool_use")
134
- toolNameById.set(block.id, block.name);
135
- }
132
+ const toolNameById = toolNamesForTransport(content);
136
133
  let visible = 0;
137
134
  let bytes = 0;
138
135
  let cut = content.length;
@@ -413,11 +410,14 @@ export function compactToolMessagesForTransport(messages, knownToolNames) {
413
410
  const toolNameMap = new Map(knownToolNames);
414
411
  const imageToolIds = new Set();
415
412
  const independentToolIds = new Set();
413
+ const decisionToolIds = new Set();
416
414
  for (const turn of messages) {
417
415
  for (const block of turn.content) {
418
416
  if (block.type === "tool_use") {
419
417
  toolNameMap.set(block.id, block.name);
420
- if (INDEPENDENT_TOOL_NAMES.has(block.name) || block.semantic || block.__subagent
418
+ if (isDecisionToolCall(block))
419
+ decisionToolIds.add(block.id);
420
+ if (INDEPENDENT_TOOL_NAMES.has(block.name) || block.semantic || isDecisionToolCall(block) || block.__subagent
421
421
  || toolInputShowsImage(block.input ?? {}))
422
422
  independentToolIds.add(block.id);
423
423
  }
@@ -427,8 +427,11 @@ export function compactToolMessagesForTransport(messages, knownToolNames) {
427
427
  }
428
428
  }
429
429
  return messages.map((turn) => {
430
- const truncatedContent = turn.content.map((block) => {
430
+ const truncatedContent = turn.content.map(withToolPreview).map((block) => {
431
431
  if (block.type === "tool_use") {
432
+ // 决策卡携带服务端缩略投影(题数/要点/结论),压缩时不能把它降回只有 kind。
433
+ if (decisionToolIds.has(block.id))
434
+ return { ...block, semantic: { ...block.semantic, kind: "decision" }, activity: undefined };
432
435
  if (independentToolIds.has(block.id) || imageToolIds.has(block.id))
433
436
  return block;
434
437
  return {
@@ -442,6 +445,9 @@ export function compactToolMessagesForTransport(messages, knownToolNames) {
442
445
  } : undefined,
443
446
  };
444
447
  }
448
+ if (block.type === "tool_result" && (block.semantic?.kind === "decision" || decisionToolIds.has(block.tool_use_id))) {
449
+ return { ...block, semantic: { ...block.semantic, kind: "decision" } };
450
+ }
445
451
  if (block.type === "tool_result" && toolNameMap.has(block.tool_use_id)
446
452
  && !INDEPENDENT_TOOL_NAMES.has(toolNameMap.get(block.tool_use_id))
447
453
  && !independentToolIds.has(block.tool_use_id)
@@ -459,6 +465,7 @@ function getContentString(content) {
459
465
  }
460
466
  /** Existing transport behavior for clients that have not opted into compact tool cards. */
461
467
  export function truncateMessagesForTransport(messages, cardDefaults, streamingTurnIndex = -1) {
468
+ const decisionIds = new Set(messages.flatMap((turn) => turn.content.flatMap((block) => block.type === "tool_use" && isDecisionToolCall(block) ? [block.id] : [])));
462
469
  return messages.map((turn, turnIndex) => {
463
470
  if (turnIndex === streamingTurnIndex)
464
471
  return turn;
@@ -471,7 +478,7 @@ export function truncateMessagesForTransport(messages, cardDefaults, streamingTu
471
478
  const truncatedContent = turn.content.map((block) => {
472
479
  if (block.type !== "tool_result")
473
480
  return block;
474
- if (block.is_error)
481
+ if (block.is_error || block.semantic?.kind === "decision" || decisionIds.has(block.tool_use_id))
475
482
  return block;
476
483
  const toolName = toolNameMap.get(block.tool_use_id) ?? "";
477
484
  if (!isToolDefaultCollapsed(toolName, cardDefaults))
package/dist/models.d.ts CHANGED
@@ -34,6 +34,7 @@ export interface ModelRefreshOptions {
34
34
  inheritEnv?: boolean;
35
35
  env?: NodeJS.ProcessEnv;
36
36
  apiKey?: string;
37
+ baseUrl?: string;
37
38
  commandRunner?: ModelCommandRunner;
38
39
  modelsApi?: ClaudeModelsApi;
39
40
  verifyClaudeCandidates?: boolean;
package/dist/models.js CHANGED
@@ -1,5 +1,8 @@
1
1
  import { execFile } from "node:child_process";
2
2
  import { createHash } from "node:crypto";
3
+ import { existsSync, readFileSync } from "node:fs";
4
+ import { homedir } from "node:os";
5
+ import path from "node:path";
3
6
  import { promisify } from "node:util";
4
7
  import Anthropic from "@anthropic-ai/sdk";
5
8
  import { resolveChildEnv } from "./env-utils.js";
@@ -550,18 +553,42 @@ function unionModelEfforts(models) {
550
553
  }
551
554
  return levels;
552
555
  }
553
- function createOfficialModelsApi(apiKey) {
554
- const client = new Anthropic({ apiKey });
556
+ function createOfficialModelsApi(apiKey, baseURL) {
557
+ const client = new Anthropic({ apiKey, baseURL: baseURL || undefined });
555
558
  return {
556
559
  list: () => client.models.list({ limit: 100 }),
557
560
  };
558
561
  }
562
+ function resolveClaudeAuth(options, env) {
563
+ let apiKey = options.apiKey?.trim() || env.ANTHROPIC_API_KEY?.trim() || env.ANTHROPIC_AUTH_TOKEN?.trim();
564
+ let baseURL = options.baseUrl?.trim() || env.ANTHROPIC_BASE_URL?.trim();
565
+ if (!apiKey) {
566
+ try {
567
+ const settingsPath = path.join(homedir(), ".claude", "settings.json");
568
+ if (existsSync(settingsPath)) {
569
+ const raw = JSON.parse(readFileSync(settingsPath, "utf8"));
570
+ const token = raw?.env?.ANTHROPIC_AUTH_TOKEN || raw?.env?.ANTHROPIC_API_KEY;
571
+ if (typeof token === "string" && token.trim()) {
572
+ apiKey = token.trim();
573
+ }
574
+ const base = raw?.env?.ANTHROPIC_BASE_URL;
575
+ if (typeof base === "string" && base.trim() && !baseURL) {
576
+ baseURL = base.trim();
577
+ }
578
+ }
579
+ }
580
+ catch {
581
+ // ignore
582
+ }
583
+ }
584
+ return { apiKey, baseURL };
585
+ }
559
586
  async function listClaudeModelsFromApi(options, env) {
560
- const apiKey = options.apiKey?.trim() || env.ANTHROPIC_API_KEY?.trim();
587
+ const { apiKey, baseURL } = resolveClaudeAuth(options, env);
561
588
  if (!apiKey)
562
589
  return { ok: false };
563
590
  try {
564
- const api = options.modelsApi ?? createOfficialModelsApi(apiKey);
591
+ const api = options.modelsApi ?? createOfficialModelsApi(apiKey, baseURL);
565
592
  const models = [];
566
593
  for await (const model of api.list()) {
567
594
  const id = normalizeClaudeModelId(model?.id);
@@ -3,6 +3,7 @@ import { WandStorage } from "./storage.js";
3
3
  import { ExecutionMode, ProcessEventHandler, SessionProvider, SessionSnapshot, SessionSource, WandConfig } from "./types.js";
4
4
  import { type PermissionResolution } from "./claude-pty-bridge.js";
5
5
  import { type PtyTerminalSnapshot, type PtyHistoryPage } from "./pty-terminal-state.js";
6
+ import { samplePtyForegrounds } from "./pty-foreground.js";
6
7
  import { type TerminalHost } from "./terminal-host.js";
7
8
  import { type ClaudeHistorySession, type CodexHistorySession, type OpenCodeHistorySession, type QoderHistorySession } from "./provider-history-scanner.js";
8
9
  export type { ClaudeHistorySession, CodexHistorySession, OpenCodeHistorySession, QoderHistorySession, } from "./provider-history-scanner.js";
@@ -26,9 +27,18 @@ export declare class PtyInputDeliveryError extends Error {
26
27
  * Matches the bridge's own idle-probe delay so both paths make the same call.
27
28
  */
28
29
  export declare const PTY_TURN_IDLE_MS = 3000;
30
+ /**
31
+ * 采样间隔:多久向内核问一次「这个 PTY 的前台进程组还是 CLI 吗」。
32
+ * 只查运行中的 PTY 会话,一次 `ps` 拿全部会话,开销与间隔同量级(毫秒)。
33
+ */
34
+ export declare const PTY_FOREGROUND_SAMPLE_MS = 1000;
29
35
  export interface ProcessManagerOptions {
30
36
  /** Overrides the non-Claude PTY turn quiet window (tests use a short one). */
31
37
  ptyTurnIdleMs?: number;
38
+ /** Overrides the foreground-process-group sampling interval (tests use a short one). */
39
+ ptyForegroundSampleMs?: number;
40
+ /** Overrides the foreground probe itself, so tests can drive it deterministically. */
41
+ samplePtyForegrounds?: typeof samplePtyForegrounds;
32
42
  }
33
43
  export declare class ProcessManager extends EventEmitter {
34
44
  private readonly config;
@@ -38,8 +48,16 @@ export declare class ProcessManager extends EventEmitter {
38
48
  private readonly providerHistory;
39
49
  /** Quiet window for the non-Claude PTY turn tracker. */
40
50
  private readonly ptyTurnIdleMs;
51
+ /** Foreground-process-group sampling interval. */
52
+ private readonly ptyForegroundSampleMs;
53
+ /** Foreground probe; injectable so tests can drive it without a real process table. */
54
+ private readonly probePtyForegrounds;
41
55
  /** 24h archive scan timer */
42
56
  private archiveTimer;
57
+ /** 前台进程组采样定时器(见 samplePtyForegrounds) */
58
+ private ptyForegroundTimer;
59
+ /** 采样是 async 的,避免慢 ps 重叠成多个进程 */
60
+ private ptyForegroundSampling;
43
61
  /** Per-session debounce timers for throttled persist calls */
44
62
  private readonly persistDebounceTimers;
45
63
  /** Last persisted message state per session — used to skip redundant message writes */
@@ -53,9 +71,25 @@ export declare class ProcessManager extends EventEmitter {
53
71
  private disposed;
54
72
  private readonly terminalHost;
55
73
  constructor(config: WandConfig, storage: WandStorage, configDir?: string, terminalHost?: TerminalHost, options?: ProcessManagerOptions);
74
+ /**
75
+ * 采样每个运行中 PTY 会话的终端前台进程组,得出「CLI 是否还在前台」。
76
+ *
77
+ * 这是 ptyBusy 的实时资格判断,取代一次性启动标记:CLI 退出后又被重新拉起
78
+ * (自更新、手动重跑)时,前台进程组会自然回到 CLI,于是新一轮仍然能被跟踪。
79
+ * 采样不到(不支持的平台、ps 失败、pid 已消失)就保持上次结论,绝不因此清状态。
80
+ */
81
+ private samplePtyForegrounds;
56
82
  private makeRestoredRecord;
57
83
  private bindRestoredTerminal;
58
84
  private initializeClaudeBridge;
85
+ /**
86
+ * 这一轮的终端是否值得跟踪:这个会话有 provider CLI,且它现在就在前台
87
+ * (实时采样的内核事实);采样不可用时退回一次性的 providerCliActive 标记。
88
+ *
89
+ * 纯 shell 会话(没有 provider)不跟踪:用户在终端里随手敲一条命令不应该
90
+ * 把任务标成「运行中 / 刚完成」。
91
+ */
92
+ private canTrackPtyTurn;
59
93
  /**
60
94
  * Open a non-Claude PTY turn and start its quiet-window timer. Claude's bridge
61
95
  * owns ptyBusy, so bridged sessions are left untouched.
@@ -93,7 +127,7 @@ export declare class ProcessManager extends EventEmitter {
93
127
  workspaceTaskId?: string;
94
128
  interactiveShell?: boolean;
95
129
  systemPrompt?: string;
96
- }): Promise<SessionSnapshot>;
130
+ } & Pick<SessionSnapshot, "employeeId" | "employeeName" | "employeeAvatar">): Promise<SessionSnapshot>;
97
131
  list(): SessionSnapshot[];
98
132
  /** Return lightweight snapshots for the session list (no output/messages). */
99
133
  listSlim(): SessionSnapshot[];
@@ -123,6 +157,10 @@ export declare class ProcessManager extends EventEmitter {
123
157
  * the PTY so Claude Code switches on the fly.
124
158
  */
125
159
  setSessionModel(id: string, model: string | null): SessionSnapshot;
160
+ /**
161
+ * 归档 / 取消归档一个 PTY 会话:只写标记,不杀进程;保留期清理再按 archivedAt 删除。
162
+ */
163
+ setSessionArchived(id: string, archived: boolean): SessionSnapshot;
126
164
  /**
127
165
  * Set the thinking-effort level for a PTY session. Interactive Claude supports
128
166
  * this through /effort; off maps to auto, which restores the model default.
@@ -22,8 +22,10 @@ import { describePtySpawnFailure } from "./ensure-node-pty-helper.js";
22
22
  import { resolveSessionProvider, resolveSystemAiContext } from "./session-ai-context.js";
23
23
  import { resolveSessionCwd } from "./session-cwd.js";
24
24
  import { inferProviderFromCommand } from "./session-provider.js";
25
+ import { RETENTION_IDLE_MS } from "./retention.js";
25
26
  import { PtyTerminalState } from "./pty-terminal-state.js";
26
27
  import { isPtySubmitInput } from "./pty-turn-activity.js";
28
+ import { samplePtyForegrounds } from "./pty-foreground.js";
27
29
  import { buildPtyShellLaunchPlan, PtyCliExitMarker } from "./pty-shell-launch.js";
28
30
  import { InProcessTerminalHost, } from "./terminal-host.js";
29
31
  import { ProviderHistoryScanner, } from "./provider-history-scanner.js";
@@ -480,7 +482,8 @@ function restoreTerminalState(state) {
480
482
  return terminal;
481
483
  }
482
484
  const MAX_SESSIONS = 200;
483
- const ARCHIVE_AFTER_MS = 1000 * 60 * 60 * 24;
485
+ // 与会话/任务的统一保留期一致:7 天没活动才自动归档(随后由保留期扫描在 7 天后清理)。
486
+ const ARCHIVE_AFTER_MS = RETENTION_IDLE_MS;
484
487
  const CONFIRM_WINDOW_SIZE = 800;
485
488
  /**
486
489
  * Quiet window for the non-Claude PTY turn tracker: a submitted turn stays busy
@@ -488,6 +491,11 @@ const CONFIRM_WINDOW_SIZE = 800;
488
491
  * Matches the bridge's own idle-probe delay so both paths make the same call.
489
492
  */
490
493
  export const PTY_TURN_IDLE_MS = 3000;
494
+ /**
495
+ * 采样间隔:多久向内核问一次「这个 PTY 的前台进程组还是 CLI 吗」。
496
+ * 只查运行中的 PTY 会话,一次 `ps` 拿全部会话,开销与间隔同量级(毫秒)。
497
+ */
498
+ export const PTY_FOREGROUND_SAMPLE_MS = 1000;
491
499
  const UUID_V4_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
492
500
  function getClaudeProjectDir(cwd) {
493
501
  // Claude Code encodes the project dir by replacing every non-alphanumeric
@@ -546,8 +554,16 @@ export class ProcessManager extends EventEmitter {
546
554
  providerHistory = new ProviderHistoryScanner();
547
555
  /** Quiet window for the non-Claude PTY turn tracker. */
548
556
  ptyTurnIdleMs;
557
+ /** Foreground-process-group sampling interval. */
558
+ ptyForegroundSampleMs;
559
+ /** Foreground probe; injectable so tests can drive it without a real process table. */
560
+ probePtyForegrounds;
549
561
  /** 24h archive scan timer */
550
562
  archiveTimer = null;
563
+ /** 前台进程组采样定时器(见 samplePtyForegrounds) */
564
+ ptyForegroundTimer = null;
565
+ /** 采样是 async 的,避免慢 ps 重叠成多个进程 */
566
+ ptyForegroundSampling = false;
551
567
  /** Per-session debounce timers for throttled persist calls */
552
568
  persistDebounceTimers = new Map();
553
569
  /** Last persisted message state per session — used to skip redundant message writes */
@@ -565,6 +581,8 @@ export class ProcessManager extends EventEmitter {
565
581
  this.config = config;
566
582
  this.storage = storage;
567
583
  this.ptyTurnIdleMs = options.ptyTurnIdleMs ?? PTY_TURN_IDLE_MS;
584
+ this.ptyForegroundSampleMs = options.ptyForegroundSampleMs ?? PTY_FOREGROUND_SAMPLE_MS;
585
+ this.probePtyForegrounds = options.samplePtyForegrounds ?? samplePtyForegrounds;
568
586
  this.terminalHost = terminalHost ?? new InProcessTerminalHost();
569
587
  this.logger = new SessionLogger(configDir || path.join(process.env.HOME || process.cwd(), ".wand"), config.shortcutLogMaxBytes);
570
588
  let startupCodexHistory = null;
@@ -675,6 +693,49 @@ export class ProcessManager extends EventEmitter {
675
693
  }
676
694
  }, 60 * 1000);
677
695
  this.archiveTimer.unref?.();
696
+ this.ptyForegroundTimer = setInterval(() => { void this.samplePtyForegrounds(); }, this.ptyForegroundSampleMs);
697
+ this.ptyForegroundTimer.unref?.();
698
+ }
699
+ /**
700
+ * 采样每个运行中 PTY 会话的终端前台进程组,得出「CLI 是否还在前台」。
701
+ *
702
+ * 这是 ptyBusy 的实时资格判断,取代一次性启动标记:CLI 退出后又被重新拉起
703
+ * (自更新、手动重跑)时,前台进程组会自然回到 CLI,于是新一轮仍然能被跟踪。
704
+ * 采样不到(不支持的平台、ps 失败、pid 已消失)就保持上次结论,绝不因此清状态。
705
+ */
706
+ async samplePtyForegrounds() {
707
+ if (this.disposed || this.ptyForegroundSampling)
708
+ return;
709
+ const candidates = [...this.sessions.values()].filter((record) => (record.status === "running"
710
+ && record.ptyProcess !== null
711
+ && !record.ptyBridge
712
+ && typeof record.processId === "number"
713
+ && record.processId > 0));
714
+ if (candidates.length === 0)
715
+ return;
716
+ this.ptyForegroundSampling = true;
717
+ try {
718
+ const samples = await this.probePtyForegrounds(candidates.map((record) => record.processId));
719
+ if (!samples || this.disposed)
720
+ return;
721
+ for (const record of candidates) {
722
+ if (this.sessions.get(record.id) !== record)
723
+ continue;
724
+ const next = samples.get(record.processId);
725
+ if (next === undefined || record.ptyForeground === next)
726
+ continue;
727
+ record.ptyForeground = next;
728
+ // CLI 退回提示符:这一轮立刻结束,不必再等静默窗口。
729
+ if (!next)
730
+ this.closePtyTurn(record);
731
+ }
732
+ }
733
+ catch {
734
+ // 探测失败只意味着这一轮没有新信息,保留上次结论。
735
+ }
736
+ finally {
737
+ this.ptyForegroundSampling = false;
738
+ }
678
739
  }
679
740
  makeRestoredRecord(snapshot, provider, ptyProcess, terminal, history) {
680
741
  return {
@@ -756,12 +817,25 @@ export class ProcessManager extends EventEmitter {
756
817
  this.handleBridgeEvent(record, event);
757
818
  });
758
819
  }
820
+ /**
821
+ * 这一轮的终端是否值得跟踪:这个会话有 provider CLI,且它现在就在前台
822
+ * (实时采样的内核事实);采样不可用时退回一次性的 providerCliActive 标记。
823
+ *
824
+ * 纯 shell 会话(没有 provider)不跟踪:用户在终端里随手敲一条命令不应该
825
+ * 把任务标成「运行中 / 刚完成」。
826
+ */
827
+ canTrackPtyTurn(record) {
828
+ if (record.ptyForeground !== undefined) {
829
+ return record.ptyForeground && record.provider !== undefined;
830
+ }
831
+ return record.providerCliActive;
832
+ }
759
833
  /**
760
834
  * Open a non-Claude PTY turn and start its quiet-window timer. Claude's bridge
761
835
  * owns ptyBusy, so bridged sessions are left untouched.
762
836
  */
763
837
  openPtyTurn(record) {
764
- if (record.ptyBridge || !record.providerCliActive || record.status !== "running")
838
+ if (record.ptyBridge || !this.canTrackPtyTurn(record) || record.status !== "running")
765
839
  return;
766
840
  if (record.ptyBusy !== true) {
767
841
  record.ptyBusy = true;
@@ -771,7 +845,7 @@ export class ProcessManager extends EventEmitter {
771
845
  }
772
846
  /** Extend the quiet window because the CLI is still producing output. */
773
847
  refreshPtyTurn(record) {
774
- if (record.ptyBridge || !record.providerCliActive)
848
+ if (record.ptyBridge || !this.canTrackPtyTurn(record))
775
849
  return;
776
850
  if (record.ptyTurnTimer)
777
851
  clearTimeout(record.ptyTurnTimer);
@@ -937,10 +1011,16 @@ export class ProcessManager extends EventEmitter {
937
1011
  if (chunk && current.providerCliActive && current.autoApprovePermissions && !current.ptyBridge && current.provider === "claude") {
938
1012
  this.autoConfirmWithRecord(current, chunk, child);
939
1013
  }
940
- // Non-Claude CLIs have no parsed turn boundary; any output while a turn is
941
- // open keeps it open (see pty-turn-activity.ts).
942
- if (chunk && current.ptyBusy === true)
943
- this.refreshPtyTurn(current);
1014
+ // Non-Claude CLIs have no parsed turn boundary: output keeps an open turn
1015
+ // open, and output while the CLI is in the foreground opens one — the CLI may
1016
+ // have (re)started in this terminal long after the one-shot launch marker was
1017
+ // consumed (see pty-turn-activity.ts / pty-foreground.ts).
1018
+ if (chunk && this.canTrackPtyTurn(current)) {
1019
+ if (current.ptyBusy === true)
1020
+ this.refreshPtyTurn(current);
1021
+ else
1022
+ this.openPtyTurn(current);
1023
+ }
944
1024
  if (chunk)
945
1025
  onVisibleChunk?.(chunk);
946
1026
  if (boundary.exitCode !== null)
@@ -963,6 +1043,10 @@ export class ProcessManager extends EventEmitter {
963
1043
  clearInterval(this.archiveTimer);
964
1044
  this.archiveTimer = null;
965
1045
  }
1046
+ if (this.ptyForegroundTimer) {
1047
+ clearInterval(this.ptyForegroundTimer);
1048
+ this.ptyForegroundTimer = null;
1049
+ }
966
1050
  const pendingPersistIds = new Set(this.persistDebounceTimers.keys());
967
1051
  for (const timer of this.persistDebounceTimers.values())
968
1052
  clearTimeout(timer);
@@ -1061,6 +1145,7 @@ export class ProcessManager extends EventEmitter {
1061
1145
  let inheritedAutomationId;
1062
1146
  let inheritedWorkspaceId;
1063
1147
  let inheritedWorkspaceTaskId;
1148
+ let inheritedRole;
1064
1149
  if (opts?.reuseId) {
1065
1150
  const oldRecord = this.sessions.get(id);
1066
1151
  if (oldRecord) {
@@ -1069,6 +1154,7 @@ export class ProcessManager extends EventEmitter {
1069
1154
  inheritedAutomationId = oldRecord.automationId;
1070
1155
  inheritedWorkspaceId = oldRecord.workspaceId;
1071
1156
  inheritedWorkspaceTaskId = oldRecord.workspaceTaskId;
1157
+ inheritedRole = oldRecord;
1072
1158
  this.cleanupRecord(oldRecord);
1073
1159
  this.sessions.delete(id);
1074
1160
  }
@@ -1079,6 +1165,7 @@ export class ProcessManager extends EventEmitter {
1079
1165
  inheritedAutomationId = stored?.automationId;
1080
1166
  inheritedWorkspaceId = stored?.workspaceId;
1081
1167
  inheritedWorkspaceTaskId = stored?.workspaceTaskId;
1168
+ inheritedRole = stored ?? undefined;
1082
1169
  }
1083
1170
  this.terminalHost.forget(id);
1084
1171
  }
@@ -1095,7 +1182,7 @@ export class ProcessManager extends EventEmitter {
1095
1182
  const initialThinkingEffort = normalizeThinkingEffort(opts?.thinkingEffort);
1096
1183
  let processedCommand = this.processCommandForMode(command, effectiveMode, provider, selectedModel, initialThinkingEffort);
1097
1184
  // 会话级系统提示:provider 有系统提示开关就走开关(PTY 与结构化行为一致),没有就并进首条输入。
1098
- const systemPrompt = opts?.systemPrompt?.trim() || undefined;
1185
+ const systemPrompt = (opts?.systemPrompt ?? inheritedRole?.systemPrompt)?.trim() || undefined;
1099
1186
  const initialInputText = initialInput && systemPrompt && !systemPromptFlag(provider)
1100
1187
  ? composeSystemFallback(systemPrompt, initialInput)
1101
1188
  : initialInput;
@@ -1136,6 +1223,9 @@ export class ProcessManager extends EventEmitter {
1136
1223
  sessionSource: opts?.sessionSource ?? inheritedSessionSource ?? "interactive",
1137
1224
  automationId: opts?.automationId ?? inheritedAutomationId,
1138
1225
  systemPrompt: systemPrompt ?? null,
1226
+ employeeId: opts?.employeeId ?? inheritedRole?.employeeId,
1227
+ employeeName: opts?.employeeName ?? inheritedRole?.employeeName,
1228
+ employeeAvatar: opts?.employeeAvatar ?? inheritedRole?.employeeAvatar,
1139
1229
  workspaceId: opts?.workspaceId ?? inheritedWorkspaceId,
1140
1230
  workspaceTaskId: opts?.workspaceTaskId ?? inheritedWorkspaceTaskId,
1141
1231
  provider,
@@ -1549,6 +1639,17 @@ export class ProcessManager extends EventEmitter {
1549
1639
  this.emitEvent({ type: "status", sessionId: id, data: { selectedModel: normalized } });
1550
1640
  return this.snapshot(record);
1551
1641
  }
1642
+ /**
1643
+ * 归档 / 取消归档一个 PTY 会话:只写标记,不杀进程;保留期清理再按 archivedAt 删除。
1644
+ */
1645
+ setSessionArchived(id, archived) {
1646
+ const record = this.mustGet(id);
1647
+ record.archived = archived;
1648
+ record.archivedAt = archived ? new Date().toISOString() : null;
1649
+ this.persist(record);
1650
+ this.emitEvent({ type: "status", sessionId: id, data: { archived, archivedAt: record.archivedAt } });
1651
+ return this.snapshot(record);
1652
+ }
1552
1653
  /**
1553
1654
  * Set the thinking-effort level for a PTY session. Interactive Claude supports
1554
1655
  * this through /effort; off maps to auto, which restores the model default.
@@ -1972,6 +2073,9 @@ export class ProcessManager extends EventEmitter {
1972
2073
  automationId: record.automationId,
1973
2074
  // 会话级系统提示要跟着快照回库(persist 就是走 snapshot),否则重启后 resume 会丢掉角色与规则。
1974
2075
  systemPrompt: record.systemPrompt ?? null,
2076
+ employeeId: record.employeeId,
2077
+ employeeName: record.employeeName,
2078
+ employeeAvatar: record.employeeAvatar,
1975
2079
  provider: record.provider,
1976
2080
  providerCliActive: record.providerCliActive,
1977
2081
  providerCliExitCode: record.providerCliExitCode,
@@ -0,0 +1,47 @@
1
+ /**
2
+ * Provider 的唯一真源:受支持的 CLI 列表、展示名、别名表与推断规则。
3
+ *
4
+ * 这个模块必须保持"纯数据 + 纯函数",不能 import 任何 `node:*`:
5
+ * 服务端(`src/session-provider.ts`)与浏览器 bundle(`src/web-ui/provider-identity.ts`)
6
+ * 都直接复用它,避免同一个 provider 概念散落在两处各写一份。
7
+ */
8
+ /** 全部受支持的会话 provider;顺序即 UI / 派发选择器里的展示顺序。 */
9
+ export declare const SESSION_PROVIDERS: readonly ["claude", "codex", "opencode", "grok", "qoder", "pi", "gemini"];
10
+ export type SessionProvider = (typeof SESSION_PROVIDERS)[number];
11
+ /**
12
+ * 拥有原生思考档位(`provider:level`)的 provider。
13
+ * Gemini CLI 没有档位开关,所以不出现在这里:`gemini:max` 这种值不应该被当成合法档位保留。
14
+ */
15
+ export declare const NATIVE_THINKING_EFFORT_PROVIDERS: readonly ["claude", "codex", "opencode", "grok", "qoder", "pi"];
16
+ /** 原生思考档位的 `provider:level` 形状;各端必须共用同一张 provider 名单。 */
17
+ export declare const NATIVE_THINKING_EFFORT_PATTERN: string;
18
+ /** 判断字符串是否为 `provider:level` 形状的原生思考档。 */
19
+ export declare function isNativeThinkingEffort(value: unknown): boolean;
20
+ /** 运行时守卫:把配置、数据库、请求体里的任意值收敛成 SessionProvider。 */
21
+ export declare function isSessionProvider(value: unknown): value is SessionProvider;
22
+ /** provider 对应的 CLI 可执行文件名(Qoder 的 CLI 叫 qodercli)。 */
23
+ export declare function providerCliCommand(provider: SessionProvider): string;
24
+ /** provider 的展示名。缺省不认识的字符串一律回落到 "AI"。 */
25
+ export declare const PROVIDER_LABELS: Readonly<Record<SessionProvider, string>>;
26
+ /**
27
+ * provider id 别名表:既接受用户/客户端可能写出的别名(`anthropic`、`open-code`),
28
+ * 也接受历史 runner 名(`claude-cli-print`、`codex-cli-exec` …),
29
+ * 供 `normalizeProviderId` 与 `inferProviderFromRunner` 共用。
30
+ */
31
+ export declare const PROVIDER_ALIASES: Readonly<Record<string, SessionProvider>>;
32
+ /** 归一化一个已持久化的 provider id 或 runner/别名;无法识别时返回 null。 */
33
+ export declare function normalizeProviderId(value: unknown): SessionProvider | null;
34
+ /** 从 runner 名推断 provider;`pty` 等与 provider 无关的 runner 返回 undefined。 */
35
+ export declare function inferProviderFromRunner(runner: unknown): SessionProvider | undefined;
36
+ /**
37
+ * 从启动命令推断 provider;识别的是命令行里真正的可执行名。
38
+ *
39
+ * - `/opt/homebrew/bin/claude --resume abc` → claude
40
+ * - `open-code run` / `qodercli --print` → opencode / qoder
41
+ * - `npx claude` / `pnpm dlx codex` → 看包管理器后面的真实 CLI
42
+ *
43
+ * 参数与父目录名不能冒充别的 provider(`claude -p codex` 仍是 claude)。
44
+ */
45
+ export declare function inferProviderFromCommand(command: unknown): SessionProvider | undefined;
46
+ /** 每个 provider 在自由文本里可能出现的写法,用于把命令名收敛成可读标签。 */
47
+ export declare function providerDisplayName(value: unknown): string;