tianshu-mcp 0.3.1 → 0.3.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.en.md CHANGED
@@ -16,6 +16,82 @@ Chinese version: [CHANGELOG.md](CHANGELOG.md)
16
16
  - TraeWork executable discovery and native-dialog driving on macOS (currently fail-closed).
17
17
  - Optional project-level skill seeding (by default nothing is written into target repos).
18
18
  - macOS hardware verification for the Codex GUI adapter (built-in status remains `research`).
19
+ - Best-effort stop of a GUI-side pending session (via a temporary CDP connection) when cancelling
20
+ a task in the `needs_user` state.
21
+
22
+ ---
23
+
24
+ ## [0.3.3] — 2026-09-12
25
+
26
+ Fixes issues #4 / #7 by adapting the ZCode GUI driver to the 3.11.2 model-menu and project-binding semantics, while making acceptance fail closed for zero-test and zero-change outcomes.
27
+
28
+ ### Fixed
29
+
30
+ - ZCode provider selectors now support both `group-provider` and the 3.11.2 `group-family` prefix. Model selection tries the visible model first and only expands a provider/family group as a fallback; failures include visible text and `data-testid` candidates.
31
+ - New-project import dismisses the stale workspace menu before opening Add Project and uses a bounded three-round dismiss/click/verify loop, preventing the first click from being consumed as an outside click.
32
+ - Project binding prioritizes the composer menu's `menuitemcheckbox`; the legacy sidebar item remains a compatibility fallback. Display-name matching normalizes NFKC, whitespace, and case.
33
+ - Binding read-back combines the composer trigger text with the full-path fast path, rejects known unbound placeholders, and retries the idempotent bind operation for up to two rounds.
34
+ - Built-in ZCode and TraeWork discovery paths use `{PROGRAMFILES}` / `{PROGRAMFILES(X86)}`. Environment placeholders in custom profiles are expanded case-insensitively while unknown placeholders remain unchanged.
35
+ - A mandatory test check is failed when it exits with code 0 but its output reports zero executed tests.
36
+ - Git projects now require a change relative to the pre-work baseline by default. Pure question/analysis tasks can explicitly opt out with `"requireChanges": false` in `.tianshu-mcp/acceptance.json`.
37
+
38
+ ### Tests
39
+
40
+ - Regression coverage now models ZCode 3.11.2 flat family layouts, legacy provider fallback, testid diagnostics, swallowed project clicks, composer binding and read-back retries, plus zero-test/zero-change acceptance gates.
41
+
42
+ ---
43
+
44
+ ## [0.3.2] — 2026-09-12
45
+
46
+ Fixes for issues #5 / #6: **the MCP task model was disconnected from the state of the turn inside
47
+ the Codex GUI**. The former is the completion-detection deadlock that kept reporting `running`
48
+ while Codex waited for user confirmation; the latter is `cancel_task` only aborting the MCP-side
49
+ wait loop, never stopping the in-GUI run, with a misleading description. Both share the same root
50
+ cause and are resolved together.
51
+
52
+ ### Fixed
53
+
54
+ - **Waiting-for-user detection (issue #5)**:
55
+ - `judgeCodexPoll` gains a stall fallback: while the stop button stays visible and the
56
+ conversation hash is unchanged for `gui.stallTimeoutMs` (default 5 minutes), the task
57
+ transitions to `needs_user` (`needsUserKind=user_confirmation`) instead of deadlocking in
58
+ `running` until the overall timeout; the stall timer resets as soon as content changes again.
59
+ - New configurable UI detection `gui.selectors.userGate` (e.g. the embedded-checkout page or
60
+ approval cards): when configured and matched, transitions to `needs_user` immediately.
61
+ Unset by default (disabled) — no unverified selectors are built in.
62
+ - Tightened the `stopButton` selector: removed the `aria-label*="取消"` over-match (the Cancel
63
+ button on waiting-for-user screens used to be mistaken for a running signal).
64
+ - **Recovery path (issue #5 fallout)**: `continue_task` now supports codex —
65
+ - `user_confirmation`: after the user completes the action in the Codex window, resume by
66
+ re-attaching as an observer of the in-GUI run (no message is sent); if the turn already
67
+ finished before resuming, the task is still judged `succeeded` correctly;
68
+ - `login_required`: after login, re-checks the environment and re-dispatches the task brief
69
+ (fresh session + project binding + full initial prompt);
70
+ - zcode recovery behaviour is unchanged; other agents are rejected explicitly.
71
+ - **Cancel actually stops the GUI (issue #6)**:
72
+ - `cancel_task` no longer succeeds on request alone for GUI agents: it first best-effort clicks
73
+ the in-app stop button over CDP, then waits (bounded by `gui.cancelWaitMs`, default 15s) for
74
+ the GUI to become idle before settling `cancelled`; if the stop could not be confirmed the
75
+ terminal message states "GUI 内运行未确认停止…" (the in-GUI run may still be going);
76
+ - process-tree termination for CLI agents is unchanged;
77
+ - cancelling from `needs_user` now notes that a pending session may remain in the GUI
78
+ (no CDP connection exists at that point — documented limitation).
79
+ - **Re-dispatch anti-overlap guard (issue #6 chain risk)**: when dispatching, if the managed
80
+ instance still has an unstopped run, MCP first tries to stop it; if it cannot, the dispatch
81
+ fails hard with `instance_busy`, preventing old and new turns from overlapping inside the same
82
+ app (observed in the wild when re-dispatching right after a cancel).
83
+ - The startup log no longer hardcodes the tool count as `8`; it reports the actual registry size
84
+ (`TOOL_DEFS.length`, currently 9).
85
+
86
+ ### Changed
87
+
88
+ - Tool descriptions match actual semantics: `cancel_task` distinguishes CLI (kill process tree)
89
+ from GUI (best-effort stop click + bounded wait); `continue_task` no longer claims to be
90
+ ZCode-only.
91
+ - `GuiProfile` gains two configurable options, `stallTimeoutMs` (default 300000) and
92
+ `cancelWaitMs` (default 15000), both overridable via agent-profiles.json.
93
+ - Skill docs (SKILL.md §4/§5, usage-examples.md §7) document the codex `user_confirmation` /
94
+ `login_required` recovery flows, GUI cancel semantics and the new options.
19
95
 
20
96
  ---
21
97
 
@@ -438,7 +514,9 @@ project → pick model and reasoning level → send instructions → run detecti
438
514
 
439
515
  ---
440
516
 
441
- [Unreleased]: https://github.com/lanlan0811/tianshu-mcp/compare/v0.3.1...HEAD
517
+ [Unreleased]: https://github.com/lanlan0811/tianshu-mcp/compare/v0.3.3...HEAD
518
+ [0.3.3]: https://github.com/lanlan0811/tianshu-mcp/compare/v0.3.2...v0.3.3
519
+ [0.3.2]: https://github.com/lanlan0811/tianshu-mcp/compare/v0.3.1...v0.3.2
442
520
  [0.3.1]: https://github.com/lanlan0811/tianshu-mcp/compare/v0.3.0...v0.3.1
443
521
  [0.3.0]: https://github.com/lanlan0811/tianshu-mcp/compare/v0.2.0...v0.3.0
444
522
  [0.2.0]: https://github.com/lanlan0811/tianshu-mcp/compare/v0.1.10...v0.2.0
package/CHANGELOG.md CHANGED
@@ -15,6 +15,71 @@
15
15
  - TraeWork 在 macOS 下的可执行探测与原生对话框驱动(当前 macOS 分支 fail-closed)。
16
16
  - 可选的项目级技能播种(默认不写入目标项目仓库)。
17
17
  - Codex GUI 的 macOS 真机验证(当前内置状态为 `research`)。
18
+ - needs_user 状态下取消任务时经临时 CDP 连接尽力停止 GUI 内等待中的会话。
19
+
20
+ ---
21
+
22
+ ## [0.3.3] — 2026-09-12
23
+
24
+ 修复 issue #4 / #7:适配 ZCode 3.11.2 的模型菜单与项目绑定语义,并将验收引擎收紧为 fail-closed,防止零用例、零变更任务假绿。
25
+
26
+ ### 修复
27
+
28
+ - ZCode 供应商分组选择器同时兼容 `group-provider` 与 3.11.2 `group-family`;模型流程改为直选优先、供应商分组展开兜底,并在失败信息中附带可见文本与 `data-testid` 候选。
29
+ - 添加新项目前收起残留工作区菜单,以最多三轮“收起—点击—验证”闭环消除首次点击被吞。
30
+ - 项目选择优先点击 composer 菜单的 `menuitemcheckbox`,旧版侧栏项仅作回退;项目名称按 NFKC、空白和大小写归一化。
31
+ - 项目绑定回读同时校验 composer 触发器文本与完整路径,未绑定占位词不再误判;绑定失败最多两轮幂等重试。
32
+ - ZCode/TraeWork 内置发现目录统一为 `{PROGRAMFILES}` / `{PROGRAMFILES(X86)}`;自定义 profile 的环境占位符按大小写不敏感方式展开,未知占位符保留原样。
33
+ - 非 optional 测试检查在退出码为 0 但输出显示零用例时改判失败。
34
+ - Git 项目默认要求相对动工前基线产生变更;纯问答/分析任务可在 `.tianshu-mcp/acceptance.json` 设置 `"requireChanges": false` 显式关闭。
35
+
36
+ ### 测试
37
+
38
+ - 回归覆盖 ZCode 3.11.2 family 平铺模型、旧版分组兜底、testid 诊断、项目点击被吞、composer 绑定与回读重试,以及零用例/零变更门禁。
39
+
40
+ ---
41
+
42
+ ## [0.3.2] — 2026-09-12
43
+
44
+ 修复 issue #5 / #6:**MCP 任务模型与 Codex GUI 内 turn 的状态脱节**。
45
+ 前者是"等用户确认时恒报 running"的完成判定死锁,后者是 `cancel_task` 只停 MCP 侧等待、
46
+ 不停 GUI 内运行且描述失实。同根问题一并收敛。
47
+
48
+ ### 修复
49
+
50
+ - **等待用户检测(issue #5)**:
51
+ - `judgeCodexPoll` 新增 stall 兜底:停止按钮持续可见且对话哈希 `gui.stallTimeoutMs`
52
+ (默认 5 分钟)不变 → 判定等待用户,任务转 `needs_user`(`needsUserKind=user_confirmation`),
53
+ 不再死锁在 `running` 直到总超时;对话内容恢复变化会重置 stall 计时。
54
+ - 新增可配置界面检测 `gui.selectors.userGate`(如结账页 `embedded-checkout`、确认卡):
55
+ 命中即快速转 `needs_user`;默认未配置 = 禁用,不内置未真机验证的选择器。
56
+ - 收紧 `stopButton` 选择器:移除 `aria-label*="取消"` 过匹配(等待用户界面的"取消"
57
+ 按钮曾被误判为运行信号)。
58
+ - **恢复通道(issue #5 牵连)**:`continue_task` 扩展支持 codex——
59
+ - `user_confirmation`:用户在 Codex 窗口处理完后恢复,MCP 仅重新接入观察 GUI 内运行
60
+ (不发送消息);恢复前 turn 已完成也能正确判 `succeeded`;
61
+ - `login_required`:复检环境后重新派发任务书(新会话 + 项目绑定 + 完整初始指令);
62
+ - zcode 原有恢复行为不变;其他 agent 明确拒绝。
63
+ - **取消真停 GUI(issue #6)**:
64
+ - `cancel_task` 对 GUI agent 不再"请求即成功":先经 CDP 尽力点击界面停止按钮,
65
+ 并在 `gui.cancelWaitMs`(默认 15 秒)内有界等待 GUI 真正空闲后落 `cancelled`;
66
+ 未确认停止时终态文案明示"GUI 内运行未确认停止,Codex 窗口中的任务可能仍在继续";
67
+ - CLI agent 的进程树终止语义不变;
68
+ - `needs_user` 状态取消的文案提示"GUI 内可能仍有等待中的会话"(此时 MCP 侧无 CDP 连接,
69
+ 列为已知边界)。
70
+ - **重派防交叠护栏(issue #6 连锁)**:派发前发现受管实例上仍有未停止的运行时,
71
+ 先尽力停止;仍不空闲则以 `instance_busy` 硬失败拒绝派发,杜绝新旧 turn 在同一
72
+ 应用内交叠(取消后立刻重派曾实测踩中)。
73
+ - 启动日志的工具数由硬编码 `8` 改为按注册表实际数量(`TOOL_DEFS.length`,当前为 9)输出。
74
+
75
+ ### 变更
76
+
77
+ - 工具描述对齐实际语义:`cancel_task` 区分 CLI(kill 进程树)与 GUI(尽力点击停止 +
78
+ 有界等待);`continue_task` 去掉"只用于 ZCode"限定。
79
+ - `GuiProfile` 新增 `stallTimeoutMs`(默认 300000)与 `cancelWaitMs`(默认 15000)配置项,
80
+ 均可由 agent-profiles.json 覆盖。
81
+ - 技能文档(SKILL.md §4/§5、usage-examples.md §7)补齐 codex `user_confirmation` /
82
+ `login_required` 恢复方式、GUI 取消语义与相关配置示例。
18
83
 
19
84
  ---
20
85
 
@@ -386,7 +451,9 @@ Codex 桌面端改为 **GUI 驱动**:新增 `codex-gui` adapter,通过 MSIX
386
451
 
387
452
  ---
388
453
 
389
- [未发布]: https://github.com/lanlan0811/tianshu-mcp/compare/v0.3.1...HEAD
454
+ [未发布]: https://github.com/lanlan0811/tianshu-mcp/compare/v0.3.3...HEAD
455
+ [0.3.3]: https://github.com/lanlan0811/tianshu-mcp/compare/v0.3.2...v0.3.3
456
+ [0.3.2]: https://github.com/lanlan0811/tianshu-mcp/compare/v0.3.1...v0.3.2
390
457
  [0.3.1]: https://github.com/lanlan0811/tianshu-mcp/compare/v0.3.0...v0.3.1
391
458
  [0.3.0]: https://github.com/lanlan0811/tianshu-mcp/compare/v0.2.0...v0.3.0
392
459
  [0.2.0]: https://github.com/lanlan0811/tianshu-mcp/compare/v0.1.10...v0.2.0
@@ -47,6 +47,11 @@ export interface TaskContext {
47
47
  kind: "continue" | "rework";
48
48
  message?: string;
49
49
  sendMessage: boolean;
50
+ /**
51
+ * 重新接入观察(不发送任何消息):codex user_confirmation 恢复专用。
52
+ * 用户在 GUI 处理完等待项后 turn 自行继续,MCP 只需重连 CDP 观察到终态。
53
+ */
54
+ reobserve?: boolean;
50
55
  sessionId?: string;
51
56
  sessionTitle?: string;
52
57
  boundProjectPath?: string;
@@ -75,8 +80,17 @@ export interface AgentRunResult {
75
80
  endReason?: string;
76
81
  /** GUI 实例是否因任务未真正完成而被保留 */
77
82
  keptInstance?: boolean;
78
- needsUserKind?: "agent_question" | "close_existing_instance" | "login_required" | "system_permission";
83
+ needsUserKind?: "agent_question" | "close_existing_instance" | "login_required" | "system_permission" | "user_confirmation";
79
84
  pendingQuestion?: string;
85
+ /**
86
+ * 取消路径的 GUI 侧停止结果(GUI agent 专用):
87
+ * clicked=是否点击了界面停止按钮;idle=等待窗口内 GUI 是否真正空闲。
88
+ * idle=false 时编排方必须在终态文案中明示「GUI 内运行未停止」。
89
+ */
90
+ guiStop?: {
91
+ clicked: boolean;
92
+ idle: boolean;
93
+ };
80
94
  session?: {
81
95
  id?: string;
82
96
  title?: string;
@@ -37,6 +37,8 @@ export const BUILTIN_PROFILES = {
37
37
  pollIntervalMs: 3_000,
38
38
  stableRounds: 4,
39
39
  idleTimeoutMs: 10 * 60_000,
40
+ stallTimeoutMs: 5 * 60_000,
41
+ cancelWaitMs: 15_000,
40
42
  cdpSendTimeoutMs: 15_000,
41
43
  progressIntervalMs: 30_000,
42
44
  modelSwitch: true,
@@ -71,8 +73,8 @@ export const BUILTIN_PROFILES = {
71
73
  authNote: "复用本机 ZCode 登录态",
72
74
  executableDiscovery: {
73
75
  dirs: [
74
- "{ProgramFiles}/ZCode",
75
- "{ProgramFiles(x86)}/ZCode",
76
+ "{PROGRAMFILES}/ZCode",
77
+ "{PROGRAMFILES(X86)}/ZCode",
76
78
  "{LOCALAPPDATA}/Programs/ZCode",
77
79
  "{LOCALAPPDATA}/ZCode",
78
80
  "/Applications/ZCode.app/Contents/MacOS",
@@ -94,6 +96,8 @@ export const BUILTIN_PROFILES = {
94
96
  pollIntervalMs: 3_000,
95
97
  stableRounds: 4,
96
98
  idleTimeoutMs: 10 * 60_000,
99
+ stallTimeoutMs: 5 * 60_000,
100
+ cancelWaitMs: 15_000,
97
101
  cdpSendTimeoutMs: 15_000,
98
102
  progressIntervalMs: 30_000,
99
103
  modelSwitch: true,
@@ -125,8 +129,8 @@ export const BUILTIN_PROFILES = {
125
129
  executableDiscovery: {
126
130
  dirs: [
127
131
  "D:/TRAE Work CN",
128
- "{ProgramFiles}/TRAE WORK CN",
129
- "{ProgramFiles(x86)}/TRAE WORK CN",
132
+ "{PROGRAMFILES}/TRAE WORK CN",
133
+ "{PROGRAMFILES(X86)}/TRAE WORK CN",
130
134
  "{LOCALAPPDATA}/Programs/TRAE WORK CN",
131
135
  "{LOCALAPPDATA}/TRAE WORK CN",
132
136
  "{APPDATA}/TRAE SOLO CN",
@@ -150,6 +154,8 @@ export const BUILTIN_PROFILES = {
150
154
  pollIntervalMs: 3_000,
151
155
  stableRounds: 12,
152
156
  idleTimeoutMs: 10 * 60_000,
157
+ stallTimeoutMs: 5 * 60_000,
158
+ cancelWaitMs: 15_000,
153
159
  cdpSendTimeoutMs: 15_000,
154
160
  progressIntervalMs: 30_000,
155
161
  modelSwitch: true,
@@ -26,6 +26,8 @@ export interface CodexPoll {
26
26
  conversationText: string;
27
27
  /** 登录/引导页可见 */
28
28
  loginVisible: boolean;
29
+ /** 「等待用户」界面检测命中(gui.selectors.userGate 配置后启用) */
30
+ userGateVisible: boolean;
29
31
  }
30
32
  export declare class CodexCdpClient {
31
33
  private readonly selectors;
@@ -258,11 +258,16 @@ export class CodexCdpClient {
258
258
  const els=__codexResolve(${specArgs("modelMenuItem", this.selectors)}).filter(vis);
259
259
  const available=els.map((e)=>(e.innerText||'').replace(/\\s+/g,' ').trim()).filter(Boolean);
260
260
  const hits=els.filter((e)=>norm(e.innerText)===target);
261
- if(hits.length===1){const e=hits[0];const r=e.getBoundingClientRect();return {count:1,available,point:{x:Math.round(r.left+r.width/2),y:Math.round(r.top+r.height/2)}}}
261
+ if(hits.length===1){
262
+ // 真机实测(26.903.9818.0):模型 radio 对 trusted 鼠标点击只收起菜单、不切换选中态,
263
+ // DOM .click() 才会真正生效(aria-checked 翻转)且菜单保持打开(后续调滑块依赖它)。
264
+ // 与「项目选择触发器需要 trusted 才能展开」相反——菜单项选择用 DOM click。
265
+ hits[0].click();
266
+ return {count:1,available,clicked:true};
267
+ }
262
268
  return {count:hits.length,available};`));
263
- if (!found.point)
269
+ if (!found.clicked)
264
270
  return { clicked: false, count: found.count, available: found.available };
265
- await this.clickAt(found.point.x, found.point.y);
266
271
  return { clicked: true, count: found.count, available: found.available };
267
272
  }
268
273
  /** 读取当前模型/思考等级触发器文本(限定输入框作用域,排除菜单栏与模式切换器) */
@@ -336,6 +341,7 @@ export class CodexCdpClient {
336
341
  sendButton: specArgs("sendButton", this.selectors),
337
342
  chatInput: specArgs("chatInput", this.selectors),
338
343
  loginIndicator: specArgs("loginIndicator", this.selectors),
344
+ userGate: specArgs("userGate", this.selectors),
339
345
  messageArea: specArgs("messageArea", this.selectors),
340
346
  })};${this.visibleFilter}
341
347
  const resolve=(k)=>__codexResolve(JSON.parse(sels[k]));
@@ -346,7 +352,8 @@ export class CodexCdpClient {
346
352
  sendVisible:visAny('sendButton'),
347
353
  composerText:(textOf('chatInput')||'').trim(),
348
354
  conversationText:(textOf('messageArea')||'').trim().slice(0,20000),
349
- loginVisible:visAny('loginIndicator')
355
+ loginVisible:visAny('loginIndicator'),
356
+ userGateVisible:visAny('userGate')
350
357
  };`));
351
358
  }
352
359
  }
@@ -9,6 +9,8 @@ export interface CodexPoll {
9
9
  loginVisible: boolean;
10
10
  /** 发送按钮当前是否可见(输入非空时) */
11
11
  sendVisible?: boolean;
12
+ /** 「等待用户」界面检测命中(gui.selectors.userGate 配置后启用) */
13
+ userGateVisible?: boolean;
12
14
  }
13
15
  export interface CodexPollState {
14
16
  hash: string;
@@ -16,12 +18,16 @@ export interface CodexPollState {
16
18
  idleSince: number;
17
19
  /** 本轮是否曾观测到权威运行信号 */
18
20
  sawRunning: boolean;
21
+ /** 停止按钮可见且对话哈希不变的起始时刻(0 = 未在计时) */
22
+ stallSince: number;
19
23
  }
20
24
  export type CodexVerdict = {
21
- kind: "running" | "pending" | "finished" | "needs_login" | "idle_timeout";
25
+ kind: "running" | "pending" | "finished" | "needs_login" | "needs_user" | "idle_timeout";
22
26
  state: CodexPollState;
23
27
  evidence: string;
24
28
  };
29
+ /** 与 GuiProfileSchema.stallTimeoutMs 默认一致;仅供直调兜底,生产路径由 codexGuiOf 传入 */
30
+ export declare const DEFAULT_STALL_TIMEOUT_MS = 300000;
25
31
  export declare function hashText(text: string): string;
26
32
  export declare const initialCodexState: () => CodexPollState;
27
- export declare function judgeCodexPoll(poll: CodexPoll, previous: CodexPollState, stableRounds: number, idleTimeoutMs: number, now?: number): CodexVerdict;
33
+ export declare function judgeCodexPoll(poll: CodexPoll, previous: CodexPollState, stableRounds: number, idleTimeoutMs: number, now?: number, stallTimeoutMs?: number): CodexVerdict;
@@ -1,5 +1,5 @@
1
1
  /**
2
- * Codex 任务运行判定(开发计划决策 7/8)。
2
+ * Codex 任务运行判定(开发计划决策 7/8;issue #5 修复后含 stall 判定)。
3
3
  *
4
4
  * 设计(吸取 TraeWork 误判完成的教训):
5
5
  * 1. 停止按钮为**权威运行信号**:出现即 running,绝不在此时判完成。
@@ -7,8 +7,17 @@
7
7
  * 把「其实还在生成、只是 DOM 恰好静止」误判为完成。
8
8
  * 3. 若始终未观测到运行信号(选择器未命中)→ 不判完成,转入空闲计时,最终 idle_timeout:
9
9
  * 结束本轮但**不终止实例**(失败开放,不比现状更糟)。
10
+ * 4. 等待用户判定(issue #5):「方案确认卡/订阅结账页」等场景下 turn 是暂停而非结束,
11
+ * 停止按钮恒可见 → 上面的规则会死锁。两条出路:
12
+ * - userGateVisible:profile.gui.selectors.userGate 配置的界面检测(快速路径);
13
+ * - stall 兜底:停止按钮持续可见且对话哈希 stallTimeoutMs 不变 → needs_user。
14
+ * 它无法区分「等用户确认」与「长时间静默的合法长命令」,误判后果是转
15
+ * needs_user(非终态、可 continue 恢复),远好于死等总超时;长命令任务
16
+ * 应调大 gui.stallTimeoutMs。
10
17
  */
11
18
  import { createHash } from "node:crypto";
19
+ /** 与 GuiProfileSchema.stallTimeoutMs 默认一致;仅供直调兜底,生产路径由 codexGuiOf 传入 */
20
+ export const DEFAULT_STALL_TIMEOUT_MS = 300_000;
12
21
  export function hashText(text) {
13
22
  return createHash("sha256").update(text).digest("hex").slice(0, 12);
14
23
  }
@@ -17,29 +26,55 @@ export const initialCodexState = () => ({
17
26
  stable: 0,
18
27
  idleSince: 0,
19
28
  sawRunning: false,
29
+ stallSince: 0,
20
30
  });
21
- export function judgeCodexPoll(poll, previous, stableRounds, idleTimeoutMs, now = Date.now()) {
31
+ export function judgeCodexPoll(poll, previous, stableRounds, idleTimeoutMs, now = Date.now(), stallTimeoutMs = DEFAULT_STALL_TIMEOUT_MS) {
22
32
  const hash = hashText(poll.conversationText);
23
33
  if (poll.loginVisible)
24
34
  return {
25
35
  kind: "needs_login",
26
- state: { hash, stable: 0, idleSince: 0, sawRunning: previous.sawRunning },
36
+ state: { hash, stable: 0, idleSince: 0, sawRunning: previous.sawRunning, stallSince: 0 },
27
37
  evidence: "login_indicator",
28
38
  };
29
- if (poll.stopVisible)
39
+ // 等待用户快速路径:界面检测命中(如结账页/确认卡,选择器可配置)
40
+ if (poll.userGateVisible)
41
+ return {
42
+ kind: "needs_user",
43
+ state: { hash, stable: 0, idleSince: 0, sawRunning: previous.sawRunning, stallSince: 0 },
44
+ evidence: "user_gate",
45
+ };
46
+ if (poll.stopVisible) {
47
+ // stall 兜底(issue #5):停止按钮恒可见时对话哈希长时间不变 → 判定等待用户。
48
+ const hashUnchanged = hash === previous.hash;
49
+ const stallSince = hashUnchanged ? previous.stallSince || now : now;
50
+ if (hashUnchanged && previous.stallSince && now - previous.stallSince >= stallTimeoutMs)
51
+ return {
52
+ kind: "needs_user",
53
+ state: { hash, stable: 0, idleSince: 0, sawRunning: true, stallSince },
54
+ evidence: "stop_button+stall",
55
+ };
30
56
  return {
31
57
  kind: "running",
32
- state: { hash, stable: 0, idleSince: 0, sawRunning: true },
58
+ state: { hash, stable: 0, idleSince: 0, sawRunning: true, stallSince },
33
59
  evidence: "stop_button",
34
60
  };
61
+ }
35
62
  const sawRunning = previous.sawRunning;
36
63
  const stable = hash === previous.hash && poll.conversationText.trim() ? previous.stable + 1 : 0;
37
64
  const idleSince = stable >= stableRounds ? previous.idleSince || now : 0;
38
65
  // 权威完成:曾运行 → 停止钮消失 → 文本稳定
39
66
  if (sawRunning && stable >= stableRounds && poll.conversationText.trim())
40
- return { kind: "finished", state: { hash, stable, idleSince, sawRunning }, evidence: "stop_button_gone+text_stable" };
67
+ return {
68
+ kind: "finished",
69
+ state: { hash, stable, idleSince, sawRunning, stallSince: 0 },
70
+ evidence: "stop_button_gone+text_stable",
71
+ };
41
72
  // 失败开放:始终无运行信号 → 空闲超时(结束本轮、保留实例)
42
73
  if (idleSince && now - idleSince >= idleTimeoutMs)
43
- return { kind: "idle_timeout", state: { hash, stable, idleSince, sawRunning }, evidence: "no_running_signal+idle" };
44
- return { kind: "pending", state: { hash, stable, idleSince, sawRunning }, evidence: "none" };
74
+ return {
75
+ kind: "idle_timeout",
76
+ state: { hash, stable, idleSince, sawRunning, stallSince: 0 },
77
+ evidence: "no_running_signal+idle",
78
+ };
79
+ return { kind: "pending", state: { hash, stable, idleSince, sawRunning, stallSince: 0 }, evidence: "none" };
45
80
  }