tianshu-mcp 0.3.1 → 0.3.2
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 +58 -1
- package/CHANGELOG.md +47 -1
- package/dist/agents/adapter.d.ts +15 -1
- package/dist/agents/builtin.js +6 -0
- package/dist/agents/codex/cdp.d.ts +2 -0
- package/dist/agents/codex/cdp.js +11 -4
- package/dist/agents/codex/liveness.d.ts +8 -2
- package/dist/agents/codex/liveness.js +43 -8
- package/dist/agents/codex/run.js +128 -57
- package/dist/agents/codex/selectors.d.ts +1 -1
- package/dist/agents/codex/selectors.js +17 -2
- package/dist/agents/traework/run.js +2 -0
- package/dist/agents/zcode/run.js +2 -0
- package/dist/config/schema.d.ts +48 -0
- package/dist/config/schema.js +8 -0
- package/dist/index.js +2 -1
- package/dist/loop/fix-loop.d.ts +2 -0
- package/dist/loop/fix-loop.js +10 -2
- package/dist/mcp/context.js +2 -0
- package/dist/mcp/handlers.js +5 -1
- package/dist/mcp/tools.js +2 -2
- package/dist/tasks/task-manager.d.ts +13 -2
- package/dist/tasks/task-manager.js +72 -16
- package/dist/tasks/task.d.ts +3 -1
- package/dist/version.generated.d.ts +1 -1
- package/dist/version.generated.js +1 -1
- package/package.json +85 -85
- package/skills/tianshu-mcp/SKILL.md +5 -3
- package/skills/tianshu-mcp/usage-examples.md +53 -2
package/CHANGELOG.en.md
CHANGED
|
@@ -16,6 +16,62 @@ 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.2] — 2026-09-12
|
|
25
|
+
|
|
26
|
+
Fixes for issues #5 / #6: **the MCP task model was disconnected from the state of the turn inside
|
|
27
|
+
the Codex GUI**. The former is the completion-detection deadlock that kept reporting `running`
|
|
28
|
+
while Codex waited for user confirmation; the latter is `cancel_task` only aborting the MCP-side
|
|
29
|
+
wait loop, never stopping the in-GUI run, with a misleading description. Both share the same root
|
|
30
|
+
cause and are resolved together.
|
|
31
|
+
|
|
32
|
+
### Fixed
|
|
33
|
+
|
|
34
|
+
- **Waiting-for-user detection (issue #5)**:
|
|
35
|
+
- `judgeCodexPoll` gains a stall fallback: while the stop button stays visible and the
|
|
36
|
+
conversation hash is unchanged for `gui.stallTimeoutMs` (default 5 minutes), the task
|
|
37
|
+
transitions to `needs_user` (`needsUserKind=user_confirmation`) instead of deadlocking in
|
|
38
|
+
`running` until the overall timeout; the stall timer resets as soon as content changes again.
|
|
39
|
+
- New configurable UI detection `gui.selectors.userGate` (e.g. the embedded-checkout page or
|
|
40
|
+
approval cards): when configured and matched, transitions to `needs_user` immediately.
|
|
41
|
+
Unset by default (disabled) — no unverified selectors are built in.
|
|
42
|
+
- Tightened the `stopButton` selector: removed the `aria-label*="取消"` over-match (the Cancel
|
|
43
|
+
button on waiting-for-user screens used to be mistaken for a running signal).
|
|
44
|
+
- **Recovery path (issue #5 fallout)**: `continue_task` now supports codex —
|
|
45
|
+
- `user_confirmation`: after the user completes the action in the Codex window, resume by
|
|
46
|
+
re-attaching as an observer of the in-GUI run (no message is sent); if the turn already
|
|
47
|
+
finished before resuming, the task is still judged `succeeded` correctly;
|
|
48
|
+
- `login_required`: after login, re-checks the environment and re-dispatches the task brief
|
|
49
|
+
(fresh session + project binding + full initial prompt);
|
|
50
|
+
- zcode recovery behaviour is unchanged; other agents are rejected explicitly.
|
|
51
|
+
- **Cancel actually stops the GUI (issue #6)**:
|
|
52
|
+
- `cancel_task` no longer succeeds on request alone for GUI agents: it first best-effort clicks
|
|
53
|
+
the in-app stop button over CDP, then waits (bounded by `gui.cancelWaitMs`, default 15s) for
|
|
54
|
+
the GUI to become idle before settling `cancelled`; if the stop could not be confirmed the
|
|
55
|
+
terminal message states "GUI 内运行未确认停止…" (the in-GUI run may still be going);
|
|
56
|
+
- process-tree termination for CLI agents is unchanged;
|
|
57
|
+
- cancelling from `needs_user` now notes that a pending session may remain in the GUI
|
|
58
|
+
(no CDP connection exists at that point — documented limitation).
|
|
59
|
+
- **Re-dispatch anti-overlap guard (issue #6 chain risk)**: when dispatching, if the managed
|
|
60
|
+
instance still has an unstopped run, MCP first tries to stop it; if it cannot, the dispatch
|
|
61
|
+
fails hard with `instance_busy`, preventing old and new turns from overlapping inside the same
|
|
62
|
+
app (observed in the wild when re-dispatching right after a cancel).
|
|
63
|
+
- The startup log no longer hardcodes the tool count as `8`; it reports the actual registry size
|
|
64
|
+
(`TOOL_DEFS.length`, currently 9).
|
|
65
|
+
|
|
66
|
+
### Changed
|
|
67
|
+
|
|
68
|
+
- Tool descriptions match actual semantics: `cancel_task` distinguishes CLI (kill process tree)
|
|
69
|
+
from GUI (best-effort stop click + bounded wait); `continue_task` no longer claims to be
|
|
70
|
+
ZCode-only.
|
|
71
|
+
- `GuiProfile` gains two configurable options, `stallTimeoutMs` (default 300000) and
|
|
72
|
+
`cancelWaitMs` (default 15000), both overridable via agent-profiles.json.
|
|
73
|
+
- Skill docs (SKILL.md §4/§5, usage-examples.md §7) document the codex `user_confirmation` /
|
|
74
|
+
`login_required` recovery flows, GUI cancel semantics and the new options.
|
|
19
75
|
|
|
20
76
|
---
|
|
21
77
|
|
|
@@ -438,7 +494,8 @@ project → pick model and reasoning level → send instructions → run detecti
|
|
|
438
494
|
|
|
439
495
|
---
|
|
440
496
|
|
|
441
|
-
[Unreleased]: https://github.com/lanlan0811/tianshu-mcp/compare/v0.3.
|
|
497
|
+
[Unreleased]: https://github.com/lanlan0811/tianshu-mcp/compare/v0.3.2...HEAD
|
|
498
|
+
[0.3.2]: https://github.com/lanlan0811/tianshu-mcp/compare/v0.3.1...v0.3.2
|
|
442
499
|
[0.3.1]: https://github.com/lanlan0811/tianshu-mcp/compare/v0.3.0...v0.3.1
|
|
443
500
|
[0.3.0]: https://github.com/lanlan0811/tianshu-mcp/compare/v0.2.0...v0.3.0
|
|
444
501
|
[0.2.0]: https://github.com/lanlan0811/tianshu-mcp/compare/v0.1.10...v0.2.0
|
package/CHANGELOG.md
CHANGED
|
@@ -15,6 +15,51 @@
|
|
|
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.2] — 2026-09-12
|
|
23
|
+
|
|
24
|
+
修复 issue #5 / #6:**MCP 任务模型与 Codex GUI 内 turn 的状态脱节**。
|
|
25
|
+
前者是"等用户确认时恒报 running"的完成判定死锁,后者是 `cancel_task` 只停 MCP 侧等待、
|
|
26
|
+
不停 GUI 内运行且描述失实。同根问题一并收敛。
|
|
27
|
+
|
|
28
|
+
### 修复
|
|
29
|
+
|
|
30
|
+
- **等待用户检测(issue #5)**:
|
|
31
|
+
- `judgeCodexPoll` 新增 stall 兜底:停止按钮持续可见且对话哈希 `gui.stallTimeoutMs`
|
|
32
|
+
(默认 5 分钟)不变 → 判定等待用户,任务转 `needs_user`(`needsUserKind=user_confirmation`),
|
|
33
|
+
不再死锁在 `running` 直到总超时;对话内容恢复变化会重置 stall 计时。
|
|
34
|
+
- 新增可配置界面检测 `gui.selectors.userGate`(如结账页 `embedded-checkout`、确认卡):
|
|
35
|
+
命中即快速转 `needs_user`;默认未配置 = 禁用,不内置未真机验证的选择器。
|
|
36
|
+
- 收紧 `stopButton` 选择器:移除 `aria-label*="取消"` 过匹配(等待用户界面的"取消"
|
|
37
|
+
按钮曾被误判为运行信号)。
|
|
38
|
+
- **恢复通道(issue #5 牵连)**:`continue_task` 扩展支持 codex——
|
|
39
|
+
- `user_confirmation`:用户在 Codex 窗口处理完后恢复,MCP 仅重新接入观察 GUI 内运行
|
|
40
|
+
(不发送消息);恢复前 turn 已完成也能正确判 `succeeded`;
|
|
41
|
+
- `login_required`:复检环境后重新派发任务书(新会话 + 项目绑定 + 完整初始指令);
|
|
42
|
+
- zcode 原有恢复行为不变;其他 agent 明确拒绝。
|
|
43
|
+
- **取消真停 GUI(issue #6)**:
|
|
44
|
+
- `cancel_task` 对 GUI agent 不再"请求即成功":先经 CDP 尽力点击界面停止按钮,
|
|
45
|
+
并在 `gui.cancelWaitMs`(默认 15 秒)内有界等待 GUI 真正空闲后落 `cancelled`;
|
|
46
|
+
未确认停止时终态文案明示"GUI 内运行未确认停止,Codex 窗口中的任务可能仍在继续";
|
|
47
|
+
- CLI agent 的进程树终止语义不变;
|
|
48
|
+
- `needs_user` 状态取消的文案提示"GUI 内可能仍有等待中的会话"(此时 MCP 侧无 CDP 连接,
|
|
49
|
+
列为已知边界)。
|
|
50
|
+
- **重派防交叠护栏(issue #6 连锁)**:派发前发现受管实例上仍有未停止的运行时,
|
|
51
|
+
先尽力停止;仍不空闲则以 `instance_busy` 硬失败拒绝派发,杜绝新旧 turn 在同一
|
|
52
|
+
应用内交叠(取消后立刻重派曾实测踩中)。
|
|
53
|
+
- 启动日志的工具数由硬编码 `8` 改为按注册表实际数量(`TOOL_DEFS.length`,当前为 9)输出。
|
|
54
|
+
|
|
55
|
+
### 变更
|
|
56
|
+
|
|
57
|
+
- 工具描述对齐实际语义:`cancel_task` 区分 CLI(kill 进程树)与 GUI(尽力点击停止 +
|
|
58
|
+
有界等待);`continue_task` 去掉"只用于 ZCode"限定。
|
|
59
|
+
- `GuiProfile` 新增 `stallTimeoutMs`(默认 300000)与 `cancelWaitMs`(默认 15000)配置项,
|
|
60
|
+
均可由 agent-profiles.json 覆盖。
|
|
61
|
+
- 技能文档(SKILL.md §4/§5、usage-examples.md §7)补齐 codex `user_confirmation` /
|
|
62
|
+
`login_required` 恢复方式、GUI 取消语义与相关配置示例。
|
|
18
63
|
|
|
19
64
|
---
|
|
20
65
|
|
|
@@ -386,7 +431,8 @@ Codex 桌面端改为 **GUI 驱动**:新增 `codex-gui` adapter,通过 MSIX
|
|
|
386
431
|
|
|
387
432
|
---
|
|
388
433
|
|
|
389
|
-
[未发布]: https://github.com/lanlan0811/tianshu-mcp/compare/v0.3.
|
|
434
|
+
[未发布]: https://github.com/lanlan0811/tianshu-mcp/compare/v0.3.2...HEAD
|
|
435
|
+
[0.3.2]: https://github.com/lanlan0811/tianshu-mcp/compare/v0.3.1...v0.3.2
|
|
390
436
|
[0.3.1]: https://github.com/lanlan0811/tianshu-mcp/compare/v0.3.0...v0.3.1
|
|
391
437
|
[0.3.0]: https://github.com/lanlan0811/tianshu-mcp/compare/v0.2.0...v0.3.0
|
|
392
438
|
[0.2.0]: https://github.com/lanlan0811/tianshu-mcp/compare/v0.1.10...v0.2.0
|
package/dist/agents/adapter.d.ts
CHANGED
|
@@ -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;
|
package/dist/agents/builtin.js
CHANGED
|
@@ -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,
|
|
@@ -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,
|
|
@@ -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;
|
package/dist/agents/codex/cdp.js
CHANGED
|
@@ -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){
|
|
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.
|
|
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
|
-
|
|
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 {
|
|
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 {
|
|
44
|
-
|
|
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
|
}
|
package/dist/agents/codex/run.js
CHANGED
|
@@ -51,6 +51,8 @@ export function codexGuiOf(resolved) {
|
|
|
51
51
|
pollIntervalMs: g?.pollIntervalMs ?? 3_000,
|
|
52
52
|
stableRounds: g?.stableRounds ?? 4,
|
|
53
53
|
idleTimeoutMs: g?.idleTimeoutMs ?? 10 * 60_000,
|
|
54
|
+
stallTimeoutMs: g?.stallTimeoutMs ?? 5 * 60_000,
|
|
55
|
+
cancelWaitMs: g?.cancelWaitMs ?? 15_000,
|
|
54
56
|
cdpSendTimeoutMs: g?.cdpSendTimeoutMs ?? 15_000,
|
|
55
57
|
progressIntervalMs: g?.progressIntervalMs ?? 30_000,
|
|
56
58
|
modelSwitch: g?.modelSwitch ?? true,
|
|
@@ -189,8 +191,28 @@ export async function runCodexTask(args) {
|
|
|
189
191
|
session: { boundProjectPath: ctx.projectPath, model: spec.model, permissionMode: gui.defaultPermissionMode },
|
|
190
192
|
});
|
|
191
193
|
await cdp.dismissMenus();
|
|
192
|
-
const
|
|
193
|
-
|
|
194
|
+
const resumeKind = ctx.resume?.kind;
|
|
195
|
+
// 重观察恢复(user_confirmation):用户在 GUI 处理完等待项后 turn 自行继续,
|
|
196
|
+
// 本轮不发送任何消息,只重连 CDP 观察 GUI 内运行至终态。
|
|
197
|
+
const isReobserve = resumeKind === "continue" && ctx.resume?.reobserve === true;
|
|
198
|
+
const isContinueConfirm = resumeKind === "continue" && !ctx.resume?.sendMessage;
|
|
199
|
+
// continue-confirm 仅出现在 login_required 恢复:登录前任务尚未发送、项目尚未绑定,
|
|
200
|
+
// 须走全新派发(新会话 + 绑定项目 + 发送初始任务书);其余恢复轮复用当前会话。
|
|
201
|
+
const resuming = isReobserve || (resumeKind !== undefined && !isContinueConfirm);
|
|
202
|
+
// 重派护栏(issue #6 连锁风险):受管实例上仍有未停止的运行时先尽力停止;
|
|
203
|
+
// 仍不空闲则拒绝派发——宁可 fail-closed,也不让新旧 turn 在同一应用内交叠。
|
|
204
|
+
// 重观察轮例外:停止按钮可见正是被观察 turn 暂停的表现。
|
|
205
|
+
if (!isReobserve) {
|
|
206
|
+
const busyStop = await stopGuiTurn(cdp, gui, deps, logger);
|
|
207
|
+
if (busyStop.clicked)
|
|
208
|
+
logger.warn("[codex] 派发前发现实例仍有运行,已点击停止并恢复空闲");
|
|
209
|
+
if (!busyStop.idle)
|
|
210
|
+
return result({
|
|
211
|
+
hardFailure: true,
|
|
212
|
+
endReason: "instance_busy",
|
|
213
|
+
error: "受管 Codex 实例上存在未停止的运行(已尝试点击停止未果);请在 Codex 窗口人工处理后重试,避免新旧任务交叠",
|
|
214
|
+
});
|
|
215
|
+
}
|
|
194
216
|
// ---- 步骤 3:创建会话 + 绑定/新建项目 ----
|
|
195
217
|
if (!resuming) {
|
|
196
218
|
if (gui.freshSession && !(await cdp.click("newChat")))
|
|
@@ -245,66 +267,80 @@ export async function runCodexTask(args) {
|
|
|
245
267
|
const permOk = await ensurePermission(cdp, gui, deps, logger);
|
|
246
268
|
if (!permOk.ok)
|
|
247
269
|
return result({ hardFailure: true, error: permOk.error, endReason: "permission_unknown" });
|
|
248
|
-
// ---- 步骤 4/5:输入指令 +
|
|
249
|
-
if (
|
|
250
|
-
logger.info("[codex]
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
270
|
+
// ---- 步骤 4/5:输入指令 + 发送(重观察轮跳过:不发送任何消息)----
|
|
271
|
+
if (isReobserve) {
|
|
272
|
+
logger.info("[codex] 重观察恢复(user_confirmation):跳过输入与发送,直接观察 GUI 内运行至终态");
|
|
273
|
+
}
|
|
274
|
+
else {
|
|
275
|
+
if (isContinueConfirm)
|
|
276
|
+
logger.info("[codex] 用户确认文本不发送给模型;环境复检通过后发送原始任务书");
|
|
277
|
+
// 返修轮:ctx.feedback 即 fix-loop 生成的修复指令(引用 codex-fix-r<N>.md);
|
|
278
|
+
// 续答轮:ctx.resume.message 是用户答复。两者都不应退回重发原始任务书。
|
|
279
|
+
const message = resuming
|
|
280
|
+
? ctx.resume?.sendMessage
|
|
281
|
+
? (ctx.resume.message?.trim() || ctx.feedback?.trim() || ctx.task)
|
|
282
|
+
: ctx.task
|
|
283
|
+
: buildInitialPrompt({
|
|
284
|
+
task: ctx.task,
|
|
285
|
+
context: ctx.context,
|
|
286
|
+
planDoc: ctx.planDoc,
|
|
287
|
+
designSystem: ctx.designSystem,
|
|
288
|
+
refs: safeRefs(ctx),
|
|
289
|
+
});
|
|
290
|
+
const attempt = ctx.resume?.kind === "continue"
|
|
291
|
+
? `continue:${createHash("sha256").update(ctx.resume.message ?? "confirmed").digest("hex").slice(0, 8)}`
|
|
292
|
+
: (ctx.resume?.kind ?? "initial");
|
|
293
|
+
const marker = `【tianshu:${ctx.taskId}:r${ctx.round}:${attempt}】`;
|
|
294
|
+
const beforeText = (await cdp.poll()).conversationText;
|
|
295
|
+
await cdp.typeText(marker + message);
|
|
296
|
+
const typed = await cdp.inputText();
|
|
297
|
+
if (!typed.includes(marker))
|
|
298
|
+
return result({ hardFailure: true, error: "Codex 输入框回读不一致,未发送", endReason: "input_mismatch" });
|
|
299
|
+
await cdp.sendMessage();
|
|
300
|
+
// 发送确认:任一直接证据成立即认定已提交——
|
|
301
|
+
// a) 输入框不再含标记(文本已离开输入框)
|
|
302
|
+
// b) 对话区出现标记
|
|
303
|
+
// c) 出现运行信号(停止按钮)
|
|
304
|
+
// 注意:无论确认与否都**不会重发**(重发风险高于误判),故宁可放宽确认条件;
|
|
305
|
+
// 真机教训:对话区选择器曾命中空壳 main,导致明明发送成功却报 send_unknown。
|
|
306
|
+
let seenMessage = beforeText.includes(marker);
|
|
307
|
+
let seenCleared = false;
|
|
308
|
+
let seenRunning = false;
|
|
309
|
+
const confirmAttempts = Math.ceil(Math.min(60_000, Math.max(5_000, ctx.taskTimeoutMs)) / 250);
|
|
310
|
+
const confirmed = () => seenCleared || seenMessage || seenRunning;
|
|
311
|
+
for (let i = 0; i < confirmAttempts && !confirmed(); i++) {
|
|
312
|
+
// eslint-disable-next-line no-await-in-loop
|
|
313
|
+
await deps.sleep(250);
|
|
314
|
+
// eslint-disable-next-line no-await-in-loop
|
|
315
|
+
const [poll, input] = await Promise.all([cdp.poll(), cdp.inputText()]);
|
|
316
|
+
seenMessage ||= poll.conversationText.includes(marker);
|
|
317
|
+
seenCleared ||= !input.includes(marker);
|
|
318
|
+
seenRunning ||= poll.stopVisible;
|
|
319
|
+
}
|
|
320
|
+
if (!confirmed())
|
|
321
|
+
return result({
|
|
322
|
+
hardFailure: true,
|
|
323
|
+
error: `Codex 发送结果无法确认(用户消息=${seenMessage},输入清空=${seenCleared},运行信号=${seenRunning});不重复发送`,
|
|
324
|
+
endReason: "send_unknown",
|
|
325
|
+
});
|
|
326
|
+
logger.info(`[codex] 指令已确认发送(对话区=${seenMessage},输入清空=${seenCleared},运行信号=${seenRunning})`);
|
|
293
327
|
}
|
|
294
|
-
if (!confirmed())
|
|
295
|
-
return result({
|
|
296
|
-
hardFailure: true,
|
|
297
|
-
error: `Codex 发送结果无法确认(用户消息=${seenMessage},输入清空=${seenCleared},运行信号=${seenRunning});不重复发送`,
|
|
298
|
-
endReason: "send_unknown",
|
|
299
|
-
});
|
|
300
|
-
logger.info(`[codex] 指令已确认发送(对话区=${seenMessage},输入清空=${seenCleared},运行信号=${seenRunning})`);
|
|
301
328
|
// ---- 步骤 6:运行检测 ----
|
|
302
329
|
const deadline = started + ctx.taskTimeoutMs;
|
|
303
330
|
let state = initialCodexState();
|
|
331
|
+
if (isReobserve) {
|
|
332
|
+
// 重观察轮:被观察的 turn 此前已确认在运行。种子 sawRunning 规避
|
|
333
|
+
// 「turn 在恢复前已完成 → 从未见运行信号 → 判不了 finished → 误落 idle_timeout」。
|
|
334
|
+
state.sawRunning = true;
|
|
335
|
+
}
|
|
304
336
|
let lastProgress = 0;
|
|
305
337
|
for (;;) {
|
|
306
|
-
|
|
307
|
-
|
|
338
|
+
// 取消(issue #6):不止退出 MCP 等待循环,还要尽力点击 GUI 停止按钮并等待空闲,
|
|
339
|
+
// 结果经 guiStop 上报,由编排方在终态文案中如实反映。
|
|
340
|
+
if (opts.signal?.aborted) {
|
|
341
|
+
const guiStop = await stopGuiTurn(cdp, gui, deps, logger);
|
|
342
|
+
return result({ killed: true, endReason: "aborted", guiStop });
|
|
343
|
+
}
|
|
308
344
|
if (Date.now() >= deadline)
|
|
309
345
|
return result({
|
|
310
346
|
timeout: true,
|
|
@@ -315,7 +351,7 @@ export async function runCodexTask(args) {
|
|
|
315
351
|
await deps.sleep(gui.pollIntervalMs);
|
|
316
352
|
// eslint-disable-next-line no-await-in-loop
|
|
317
353
|
const poll = await cdp.poll();
|
|
318
|
-
const verdict = judgeCodexPoll(poll, state, gui.stableRounds, gui.idleTimeoutMs);
|
|
354
|
+
const verdict = judgeCodexPoll(poll, state, gui.stableRounds, gui.idleTimeoutMs, Date.now(), gui.stallTimeoutMs);
|
|
319
355
|
state = verdict.state;
|
|
320
356
|
if (Date.now() - lastProgress >= gui.progressIntervalMs) {
|
|
321
357
|
const note = `Codex 进度:${verdict.kind};运行证据=${verdict.evidence};对话哈希=${state.hash};稳定轮=${state.stable}`;
|
|
@@ -330,6 +366,13 @@ export async function runCodexTask(args) {
|
|
|
330
366
|
pendingQuestion: "Codex 需要登录,请在窗口中完成登录后调用 continue_task 确认。",
|
|
331
367
|
session: { boundProjectPath: ctx.projectPath, model: spec.model, permissionMode: gui.defaultPermissionMode },
|
|
332
368
|
});
|
|
369
|
+
if (verdict.kind === "needs_user")
|
|
370
|
+
return result({
|
|
371
|
+
endReason: "needs_user",
|
|
372
|
+
needsUserKind: "user_confirmation",
|
|
373
|
+
pendingQuestion: "Codex 停止按钮持续可见且对话内容长时间未变化,疑似在等待用户确认(方案确认/订阅确认等)。请在 Codex 窗口完成处理后调用 continue_task(taskId, message=已处理说明) 恢复;恢复后仅重新接入观察,不会发送消息。",
|
|
374
|
+
session: { boundProjectPath: ctx.projectPath, model: spec.model, permissionMode: gui.defaultPermissionMode },
|
|
375
|
+
});
|
|
333
376
|
if (verdict.kind === "idle_timeout")
|
|
334
377
|
return result({ endReason: "idle_timeout", error: "Codex 空闲超时;已停止 MCP 等待并保留现场" });
|
|
335
378
|
if (verdict.kind === "finished")
|
|
@@ -368,6 +411,34 @@ function safeRefs(ctx) {
|
|
|
368
411
|
return [];
|
|
369
412
|
}
|
|
370
413
|
}
|
|
414
|
+
/**
|
|
415
|
+
* 尽力停止 GUI 内正在运行的 turn(issue #6):
|
|
416
|
+
* 点击界面停止按钮(trusted 点击),并在 cancelWaitMs 内轮询确认停止按钮消失(GUI 空闲)。
|
|
417
|
+
* 不抛异常:CDP 不可用等情况下返回 clicked=false/idle=false,由调用方如实落文案。
|
|
418
|
+
* idle=false 时 GUI 内运行可能仍在继续——终态文案必须明示,不得谎报已停止。
|
|
419
|
+
*/
|
|
420
|
+
async function stopGuiTurn(cdp, gui, deps, logger) {
|
|
421
|
+
try {
|
|
422
|
+
const first = await cdp.poll();
|
|
423
|
+
if (!first.stopVisible)
|
|
424
|
+
return { clicked: false, idle: true };
|
|
425
|
+
const clicked = await cdp.click("stopButton");
|
|
426
|
+
logger.info(`[codex] 取消:${clicked ? "已点击" : "未能点击"} GUI 停止按钮,等待界面空闲(≤${gui.cancelWaitMs}ms)`);
|
|
427
|
+
const deadline = Date.now() + gui.cancelWaitMs;
|
|
428
|
+
for (;;) {
|
|
429
|
+
await deps.sleep(500);
|
|
430
|
+
if (Date.now() >= deadline)
|
|
431
|
+
return { clicked, idle: false };
|
|
432
|
+
const poll = await cdp.poll();
|
|
433
|
+
if (!poll.stopVisible)
|
|
434
|
+
return { clicked, idle: true };
|
|
435
|
+
}
|
|
436
|
+
}
|
|
437
|
+
catch (e) {
|
|
438
|
+
logger.warn(`[codex] 取消时停止 GUI 运行失败:${e instanceof Error ? e.message : String(e)}`);
|
|
439
|
+
return { clicked: false, idle: false };
|
|
440
|
+
}
|
|
441
|
+
}
|
|
371
442
|
/**
|
|
372
443
|
* 新建项目流程(截图 2→5):
|
|
373
444
|
* 打开项目选择层 → 新建项目 → 点源文件夹中心空白区 → 原生对话框键盘填路径 →
|
|
@@ -32,7 +32,7 @@ export interface CodexSelectorSpec {
|
|
|
32
32
|
verifiedVersion: string;
|
|
33
33
|
note: string;
|
|
34
34
|
}
|
|
35
|
-
export type CodexSelectorKey = "chatInput" | "sendButton" | "stopButton" | "newChat" | "projectSection" | "projectPickerTrigger" | "addProject" | "projectItem" | "newProjectMenuItem" | "sourceFolderArea" | "createProjectButton" | "modelTrigger" | "reasoningSlider" | "modelMenuItem" | "menuItem" | "permissionTrigger" | "permissionOption" | "loginIndicator" | "messageArea";
|
|
35
|
+
export type CodexSelectorKey = "chatInput" | "sendButton" | "stopButton" | "newChat" | "projectSection" | "projectPickerTrigger" | "addProject" | "projectItem" | "newProjectMenuItem" | "sourceFolderArea" | "createProjectButton" | "modelTrigger" | "reasoningSlider" | "modelMenuItem" | "menuItem" | "permissionTrigger" | "permissionOption" | "loginIndicator" | "userGate" | "messageArea";
|
|
36
36
|
export declare const CODEX_SELECTORS: Record<CodexSelectorKey, CodexSelectorSpec>;
|
|
37
37
|
/** 合并 profile 覆盖后的 CSS 候选(去重,覆盖优先) */
|
|
38
38
|
export declare function cssCandidates(key: CodexSelectorKey, overrides?: Record<string, string>): string[];
|
|
@@ -25,10 +25,25 @@ export const CODEX_SELECTORS = {
|
|
|
25
25
|
},
|
|
26
26
|
stopButton: {
|
|
27
27
|
primary: 'button[aria-label*="停止"]',
|
|
28
|
-
fallbacks: ['button[aria-label*="Stop" i]'
|
|
28
|
+
fallbacks: ['button[aria-label*="Stop" i]'],
|
|
29
29
|
ariaLabels: ["停止", "Stop"],
|
|
30
30
|
verifiedVersion: "26.903.x",
|
|
31
|
-
|
|
31
|
+
// issue #5 实测教训:fallback 曾含 button[aria-label*="取消"],但「等待用户确认」
|
|
32
|
+
// (方案确认卡/订阅结账页)界面自带「取消」按钮,会把 turn 暂停误判为运行中,
|
|
33
|
+
// 造成完成判定死锁——已移除该过匹配。若某版本停止按钮文案漂移,用
|
|
34
|
+
// gui.selectors.stopButton 热修复。
|
|
35
|
+
note: "权威运行信号;生成期间替代发送按钮。不要加入「取消」等宽泛文案(等待用户界面会误命中)",
|
|
36
|
+
},
|
|
37
|
+
userGate: {
|
|
38
|
+
// issue #5:等待用户界面检测(结账页/方案确认卡等)。默认候选为空 = 禁用;
|
|
39
|
+
// 仅当 agent-profiles.json 配置 gui.selectors.userGate 后启用(覆盖优先)。
|
|
40
|
+
// 参考值(2026-09 实测版式,未内置):[class*="embedded-checkout"]、
|
|
41
|
+
// [class*="approval"]、[role="dialog"][class*="confirm"]——配置前请真机核对,
|
|
42
|
+
// 误配会把正常运行误判为 needs_user(可恢复,非终态)。
|
|
43
|
+
primary: "",
|
|
44
|
+
fallbacks: [],
|
|
45
|
+
verifiedVersion: "未内置(按配置启用)",
|
|
46
|
+
note: "「等待用户」界面检测;默认禁用,配置 gui.selectors.userGate 后 poll().userGateVisible 才可能为真",
|
|
32
47
|
},
|
|
33
48
|
newChat: {
|
|
34
49
|
primary: 'button.sidebar-item',
|