tianshu-mcp 0.6.2 → 0.6.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 +24 -0
- package/CHANGELOG.md +24 -0
- package/README.en.md +2 -1
- package/README.md +2 -1
- package/dist/agents/agent-events.js +50 -0
- package/dist/agents/codex/run.js +44 -6
- package/dist/agents/traework/run.js +27 -1
- package/dist/config/schema.js +8 -0
- package/dist/loop/fix-loop.js +12 -0
- package/dist/mcp/handlers.js +10 -1
- package/dist/mcp/tools.js +5 -1
- package/dist/tasks/task-manager.js +5 -6
- package/dist/tasks/task-store.js +28 -1
- package/dist/util/fs.js +45 -0
- package/dist/version.generated.js +1 -1
- package/docs/event-stream.en.md +96 -0
- package/docs/event-stream.md +85 -0
- package/package.json +3 -1
package/CHANGELOG.en.md
CHANGED
|
@@ -8,6 +8,30 @@ Chinese version: [CHANGELOG.md](CHANGELOG.md)
|
|
|
8
8
|
|
|
9
9
|
---
|
|
10
10
|
|
|
11
|
+
## [0.6.3] - 2026-09-24
|
|
12
|
+
|
|
13
|
+
### Added
|
|
14
|
+
|
|
15
|
+
- **Fine-grained event stream** ([issue #18](https://github.com/lanlan0811/tianshu-mcp/issues/18)): adapters can proactively report semantic events at key nodes, and `query_task` returns the most recent N of them, so long tasks can be told apart as "working normally" versus "stuck on a dialog waiting for a human". Five event kinds: `task_dispatched` / `confirmation_dialog_detected` / `awaiting_user_authorization` / `file_modification_started` / `rework_triggered`. See the [event stream doc](docs/event-stream.en.md).
|
|
16
|
+
- **New optional `query_task` input `eventLimit`** (integer 1..50, **default 10**): events appear both in the meta block's `recentEvents` array and in a "recent events" section of the text area.
|
|
17
|
+
- **Two built-in GUI adapters actually report events**: codex and traework (four emission points each); `rework_triggered` is emitted engine-side (automatic rework `mode:"auto"`, manual `rework_task` `mode:"manual"`).
|
|
18
|
+
|
|
19
|
+
### Changed
|
|
20
|
+
|
|
21
|
+
- **Manual `rework_task` now emits a typed `rework_triggered` event instead of an anonymous `note`** (visible in `task.jsonl`). The semantics and purpose of the existing `note` event are unchanged; `progressSummary` / `lastRunSignal` are still carried by `note`.
|
|
22
|
+
- **The `TaskEventName` union gained five members**, sourced from `AGENT_EVENT_NAMES` so the vocabulary cannot drift between two places.
|
|
23
|
+
|
|
24
|
+
### Compatibility
|
|
25
|
+
|
|
26
|
+
- **No tool contract, data model or MCP annotation changes.** `recentEvents` is a new optional field: adapters that do not implement event reporting (including all CLI adapters) return an empty array with no event section in the text area, and **every other field is exactly as in v0.6.2**.
|
|
27
|
+
- Events are written into the existing `task.jsonl` (**no parallel event file is created**); the read side only reads a 64 KiB tail window, so memory use is decoupled from total file size.
|
|
28
|
+
|
|
29
|
+
### Notes (disclosed honestly)
|
|
30
|
+
|
|
31
|
+
- **`file_modification_started` is a heuristic.** The codex / traework adapters do not observe the filesystem directly; they can only infer that execution started from the UI's "running" signal (stop button). Its detail always reads "stop button appeared, execution started (files may be modified)" and **does not claim files were actually changed**. For hard evidence of file changes, read `changedFiles` / `diffstat` from the acceptance report.
|
|
32
|
+
- **Event reporting is an optional capability.** The hook lives on `AgentRunOptions.onEvent`, not the agent profile (`agent-profiles.json` is plain JSON and cannot hold a function); adapters that don't implement it need not change a single byte. Adapters report through `makeEmitter` — a no-op when no hook is provided, swallowing reporting exceptions so that **a failed report never affects the task itself**.
|
|
33
|
+
- **Events are not delivery-guaranteed.** This is an observability capability, not a delivery guarantee; `query_task` reflects only "the last event that was persisted".
|
|
34
|
+
|
|
11
35
|
## [0.6.2] - 2026-09-23
|
|
12
36
|
|
|
13
37
|
### Fixed
|
package/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,30 @@
|
|
|
7
7
|
|
|
8
8
|
---
|
|
9
9
|
|
|
10
|
+
## [0.6.3] - 2026-09-24
|
|
11
|
+
|
|
12
|
+
### 新增
|
|
13
|
+
|
|
14
|
+
- **细粒度事件流**([issue #18](https://github.com/lanlan0811/tianshu-mcp/issues/18)):适配器可在关键节点主动上报语义事件,`query_task` 回传最近 N 条,长任务下可区分「正常执行」与「卡在弹窗等人」。词表 5 类:`task_dispatched` / `confirmation_dialog_detected` / `awaiting_user_authorization` / `file_modification_started` / `rework_triggered`。详见 [事件流文档](docs/event-stream.md)。
|
|
15
|
+
- **`query_task` 新增可选入参 `eventLimit`**(整数 1..50,**缺省 10**):事件同时出现在 meta 块的 `recentEvents` 数组与文本区的「最近事件」段落。
|
|
16
|
+
- **两个内置 GUI 适配器落地上报**:codex 与 traework(各 4 个发射点);`rework_triggered` 由引擎侧统一上报(自动返修 `mode:"auto"`、手动 `rework_task` `mode:"manual"`)。
|
|
17
|
+
|
|
18
|
+
### 变更
|
|
19
|
+
|
|
20
|
+
- **手动 `rework_task` 的事件由匿名 `note` 改为类型化 `rework_triggered`**(`task.jsonl` 可见)。既有 `note` 事件的语义与用途不变,`progressSummary` / `lastRunSignal` 仍由 `note` 承载。
|
|
21
|
+
- **`TaskEventName` 联合类型新增 5 个成员**,与 `AGENT_EVENT_NAMES` 同源(避免两处词表漂移)。
|
|
22
|
+
|
|
23
|
+
### 兼容性
|
|
24
|
+
|
|
25
|
+
- **无工具契约、数据模型或 MCP 注解变更**。`recentEvents` 是新增可选字段:未实现事件上报的适配器(含全部 CLI 适配器)返回空数组、文本区不出现事件段落,**其余字段与 v0.6.2 完全一致**。
|
|
26
|
+
- 事件写入既有的 `task.jsonl`(**不新建并行事件文件**);读取侧只读尾部 64 KiB 窗口,内存占用与文件总大小解耦。
|
|
27
|
+
|
|
28
|
+
### 说明(如实披露)
|
|
29
|
+
|
|
30
|
+
- **`file_modification_started` 是启发式推断**:codex / traework 适配器并不直接观测文件系统,只能从界面「运行中」信号(停止按钮)推断执行已开始。其 detail 一律写「停止按钮出现,开始执行(可能开始改动文件)」,**不声称文件确已改动**;确切的文件改动证据请看验收报告的 `changedFiles` / `diffstat`。
|
|
31
|
+
- **事件上报是可选能力**:钩子挂在 `AgentRunOptions.onEvent` 而非 agent profile(`agent-profiles.json` 是纯 JSON,装不下函数);未实现的适配器一个字节都不用改。适配器侧统一经 `makeEmitter` 上报 —— 未提供钩子时空操作,且吞掉上报异常,**上报失败绝不影响任务本体**。
|
|
32
|
+
- **事件不保证送达**:属观测能力而非交付保证;`query_task` 只反映「最后一次落盘的事件」。
|
|
33
|
+
|
|
10
34
|
## [0.6.2] - 2026-09-23
|
|
11
35
|
|
|
12
36
|
### 修复
|
package/README.en.md
CHANGED
|
@@ -39,6 +39,7 @@ Tianshu plays the role of the overall commander; this MCP server is the **schedu
|
|
|
39
39
|
|
|
40
40
|
- **11 MCP tools**: `run_task / continue_task / query_task / list_tasks / get_task_report / cancel_task / verify_task / rework_task / get_profiles`, plus `prepare_visual_baseline / approve_visual_baseline` for visual acceptance
|
|
41
41
|
- **Async contract**: `run_task` returns a `taskId` immediately; long-running work is polled via `query_task` (never blocks `tools/call`).
|
|
42
|
+
- **Long-task observability (issue #18)**: adapters report fine-grained events at key nodes (`task_dispatched` / `confirmation_dialog_detected` / `awaiting_user_authorization` / `file_modification_started` / `rework_triggered`), and `query_task` returns the most recent N via `eventLimit` (default 10) — **so you can tell "the agent is working" apart from "stuck on a dialog waiting for a human"**. Event reporting is an optional capability: adapters that don't implement it behave unchanged. codex and traework report in this version. See [event stream](docs/event-stream.en.md).
|
|
42
43
|
- **Objective acceptance**: automated command checks (typecheck/lint/test/build — skipped when absent, plus tech-stack derivation) + programmatic code analysis (changed-file list / diffstat / suspicious signals such as TODO, debugger, secret-like patterns), all relative to a **git baseline**; never auto-commits or stashes. The acceptance engine is **fail-closed**: a test check fails when its output reports zero executed tests even if the exit code is 0; git projects must produce changes relative to the pre-work baseline by default (pure analysis tasks can opt out with `"requireChanges": false` in `.tianshu-mcp/acceptance.json`).
|
|
43
44
|
- **Acceptance parallelism**: command checks run **bounded-parallel** by default (`verifyConcurrency`, default 2, range 1–4). When checks depend on an order (a later check reading build output, `--fix`, shared cache dirs), set it to `1` for fully serial behaviour; a project can override it in `.tianshu-mcp/acceptance.json`, and the server level lives in `config.json`. Report and log formats are unchanged (results are returned in declaration order).
|
|
44
45
|
- **Rework loop**: automatic rework (`autoFixRounds`) + manual `rework_task`; on verification failure a repair-plan file is generated and fed back to the agent; when rounds run out → `needs_attention` awaiting Tianshu's verdict.
|
|
@@ -188,7 +189,7 @@ run_task(projectPath=D:/xxx/my-app, agentId=qoder, planDoc=./plans/development.m
|
|
|
188
189
|
|---|---|---|
|
|
189
190
|
| `run_task` | write + approval | Dispatch work (optional auto-verify / auto-rework); returns `taskId` asynchronously. Optional `idempotencyKey`: a retry with the same key returns the original `taskId` instead of creating a task |
|
|
190
191
|
| `continue_task` | write + approval | Resume the session behind `needs_user` (ZCode resumes the recorded session; Codex re-observes for `user_confirmation` / re-dispatches for `login_required`; Kimi Code resumes the recorded session and distinguishes question answering / re-observation / full re-dispatch) |
|
|
191
|
-
| `query_task` | read | Poll status / progress / log tail |
|
|
192
|
+
| `query_task` | read | Poll status / progress / log tail / recent fine-grained events. The optional `eventLimit` (1..50, default 10) controls how many `recentEvents` the meta block carries, so long tasks can be told apart as "working normally" versus "stuck on a dialog waiting for a human" |
|
|
192
193
|
| `list_tasks` | read | Filtered history of tasks |
|
|
193
194
|
| `get_task_report` | read | Full text of a verification round's report (`report.md`) |
|
|
194
195
|
| `cancel_task` | write + approval | Cancel a running task: CLI agents kill the process tree; GUI agents click the in-app stop control over CDP and bounded-wait (`gui.cancelWaitMs`, default 15s) for the GUI to go idle, stating so explicitly in the final message when the stop is unconfirmed. For a terminal GUI task this call doubles as the manual acknowledgement entry point — after verifying the window holds no residual run, it clears the `guiStopUnconfirmed` marker |
|
package/README.md
CHANGED
|
@@ -39,6 +39,7 @@
|
|
|
39
39
|
|
|
40
40
|
- **11 个 MCP 工具**:`run_task / continue_task / query_task / list_tasks / get_task_report / cancel_task / verify_task / rework_task / get_profiles`,外加视觉验收的 `prepare_visual_baseline / approve_visual_baseline`
|
|
41
41
|
- **异步契约**:`run_task` 秒回 `taskId`,长任务用 `query_task` 轮询(长任务不卡 `tools/call`)。
|
|
42
|
+
- **长任务可观测(issue #18)**:适配器在关键节点上报细粒度事件(`task_dispatched` / `confirmation_dialog_detected` / `awaiting_user_authorization` / `file_modification_started` / `rework_triggered`),`query_task` 经 `eventLimit`(默认 10)回传最近 N 条——**能区分「agent 正在干活」与「卡在弹窗等人工介入」**。事件上报是可选能力:未实现的适配器行为不变。本版 codex 与 traework 已落地上报。详见 [事件流](docs/event-stream.md)。
|
|
42
43
|
- **客观验收**:自动命令检查(typecheck/lint/test/build,缺则跳过 + 技术栈推导)+ 程序化代码分析(变更清单/diffstat/TODO·debugger·密钥形态等可疑标记),全部相对 **git 基线**,不自动 commit/stash。验收引擎 **fail-closed**:测试命令退出码为 0 但输出显示零用例时判失败;git 项目默认要求相对动工前基线产生变更(纯分析任务可在 `.tianshu-mcp/acceptance.json` 设 `"requireChanges": false` 显式关闭)。
|
|
43
44
|
- **验收并行度**:命令检查默认**有界并行**(`verifyConcurrency`,默认 2、范围 1–4)。检查项之间有顺序依赖时(后续检查读取 build 产物、带 `--fix`、共享缓存目录)请设 `1` 完全退化为串行;项目级 `.tianshu-mcp/acceptance.json` 可覆盖,server 级在 `config.json`。报告与日志格式不变(结果按声明顺序返回)。
|
|
44
45
|
- **失败返修闭环**:自动返修(`autoFixRounds`)+ 手动 `rework_task`;验收失败时自动生成修复计划文件并回填给 agent;轮次用尽 → `needs_attention` 等天枢裁决。
|
|
@@ -184,7 +185,7 @@ run_task(projectPath=D:/xxx/my-app, agentId=qoder, planDoc=./plans/development.m
|
|
|
184
185
|
|---|---|---|
|
|
185
186
|
| `run_task` | write + 审批 | 派活(可带自动验收/自动返修),异步返回 `taskId`;可选 `idempotencyKey`:同键重试恒返回原 `taskId`,不新建任务 |
|
|
186
187
|
| `continue_task` | write + 审批 | 恢复 `needs_user` 的原会话(ZCode 恢复原会话;Codex 按 `user_confirmation` 重新观察 / `login_required` 重派;Kimi Code 恢复原会话并区分提问续答 / 重新观察 / 补发任务书) |
|
|
187
|
-
| `query_task` | read | 轮询状态 / 进度 / 日志尾 |
|
|
188
|
+
| `query_task` | read | 轮询状态 / 进度 / 日志尾 / 最近细粒度事件。可选 `eventLimit`(1..50,默认 10)控制 meta 的 `recentEvents` 条数,长任务下可区分「正常执行」与「卡在弹窗等人」 |
|
|
188
189
|
| `list_tasks` | read | 历史任务过滤列表 |
|
|
189
190
|
| `get_task_report` | read | 某轮验收报告全文(`report.md`) |
|
|
190
191
|
| `cancel_task` | write + 审批 | 取消运行中任务:CLI agent kill 进程树;GUI agent 经 CDP 点击停止并在 `gui.cancelWaitMs`(默认 15s)内有界等待 GUI 空闲,未确认停止时终态明示。对已终态的 GUI 任务,本调用兼任人工确认入口——核实窗口无残留运行后调用可清除 `guiStopUnconfirmed` 待确认标记 |
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* agent 细粒度事件词表(issue #18)。
|
|
3
|
+
*
|
|
4
|
+
* 事件上报是**可选能力**:适配器实现 `AgentRunOptions.onEvent` 即在关键节点主动上报,
|
|
5
|
+
* 未实现的适配器一个字节都不用改(调用侧一律走 `opts.onEvent?.(...)` 可选链)。
|
|
6
|
+
*
|
|
7
|
+
* 本模块刻意**零依赖**:`adapter.ts`、`tasks/task.ts`、`tasks/task-store.ts` 都要引用它,
|
|
8
|
+
* 若它反向引用这些模块会形成循环。因此这里只有类型与常量,不 import 任何东西。
|
|
9
|
+
*
|
|
10
|
+
* 与既有 `note` 事件的关系:`note` 仍是进度/审计通道(承载 progressSummary、
|
|
11
|
+
* lastRunSignal 等自由文本),本词表只表达**语义化节点**,两者同写 task.jsonl、共用时序。
|
|
12
|
+
*/
|
|
13
|
+
export const AGENT_EVENT_NAMES = [
|
|
14
|
+
/** 指令已确认送达 agent(进入其执行队列) */
|
|
15
|
+
"task_dispatched",
|
|
16
|
+
/** 检测到需要人工处理的确认类对话框(含原生文件夹选择框、残留弹窗清理) */
|
|
17
|
+
"confirmation_dialog_detected",
|
|
18
|
+
/** 等待用户授权/登录/确认,任务已卡在人工介入上 */
|
|
19
|
+
"awaiting_user_authorization",
|
|
20
|
+
/**
|
|
21
|
+
* agent 开始执行(GUI 侧观测到「运行中」信号首次出现)。
|
|
22
|
+
* 注意:这是**启发式**推断——适配器并不直接观测文件系统,
|
|
23
|
+
* 因此 detail 文案一律如实写「可能开始改动文件」,不声称已改动。
|
|
24
|
+
*/
|
|
25
|
+
"file_modification_started",
|
|
26
|
+
/** 验收失败后进入返修(自动或 manual) */
|
|
27
|
+
"rework_triggered",
|
|
28
|
+
];
|
|
29
|
+
/** 判断某个事件名是否属于本词表(供读取侧过滤使用) */
|
|
30
|
+
export function isAgentEventName(name) {
|
|
31
|
+
return AGENT_EVENT_NAMES.includes(name);
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* 把可选的 `onEvent` 钩子包成「永不抛错」的上报函数。
|
|
35
|
+
*
|
|
36
|
+
* 这是 issue #18「事件上报为可选能力,不影响原有逻辑」的落点:上报失败(例如落盘 IO 出错)
|
|
37
|
+
* 绝不能中断正在进行的 GUI 任务,因此这里吞掉异常。未提供钩子时是空操作。
|
|
38
|
+
*/
|
|
39
|
+
export function makeEmitter(onEvent) {
|
|
40
|
+
return async (kind, detail, data) => {
|
|
41
|
+
if (!onEvent)
|
|
42
|
+
return;
|
|
43
|
+
try {
|
|
44
|
+
await onEvent({ kind, detail, data });
|
|
45
|
+
}
|
|
46
|
+
catch {
|
|
47
|
+
// 有意吞掉:事件上报属观测能力,不得影响任务本体
|
|
48
|
+
}
|
|
49
|
+
};
|
|
50
|
+
}
|
package/dist/agents/codex/run.js
CHANGED
|
@@ -14,6 +14,7 @@ import { ZCODE_SETUP_DEFAULTS } from "../../config/schema.js";
|
|
|
14
14
|
import fs from "node:fs";
|
|
15
15
|
import path from "node:path";
|
|
16
16
|
import { createHash } from "node:crypto";
|
|
17
|
+
import { makeEmitter } from "../agent-events.js";
|
|
17
18
|
import { mkdirp } from "../../util/fs.js";
|
|
18
19
|
import { parseCodexModel, exactUiName, parseTriggerValue } from "./model.js";
|
|
19
20
|
import { matchCodexProject, projectBasename } from "./project.js";
|
|
@@ -143,6 +144,8 @@ export async function runCodexTask(args) {
|
|
|
143
144
|
const deps = { ...DEFAULT_DEPS, ...args.deps };
|
|
144
145
|
const gui = codexGuiOf(resolved);
|
|
145
146
|
const { logger, close } = fileLogger(logFile, opts.logger);
|
|
147
|
+
// 细粒度事件上报(issue #18):未提供钩子时为空操作,失败不影响任务本体。
|
|
148
|
+
const emit = makeEmitter(opts.onEvent);
|
|
146
149
|
let cdp;
|
|
147
150
|
const result = (extra) => ({
|
|
148
151
|
ok: false,
|
|
@@ -193,13 +196,17 @@ export async function runCodexTask(args) {
|
|
|
193
196
|
cdp?.disconnect();
|
|
194
197
|
cdp = next;
|
|
195
198
|
};
|
|
196
|
-
if (await cdp.exists("loginIndicator"))
|
|
199
|
+
if (await cdp.exists("loginIndicator")) {
|
|
200
|
+
await emit("awaiting_user_authorization", "Codex 登录指示可见,等待用户完成登录或引导", {
|
|
201
|
+
needsUserKind: "login_required",
|
|
202
|
+
});
|
|
197
203
|
return result({
|
|
198
204
|
endReason: "needs_user",
|
|
199
205
|
needsUserKind: "login_required",
|
|
200
206
|
pendingQuestion: "请在 Codex 窗口中完成登录或引导,然后调用 continue_task 确认。",
|
|
201
207
|
session: { boundProjectPath: ctx.projectPath, model: spec.model, permissionMode: gui.defaultPermissionMode },
|
|
202
208
|
});
|
|
209
|
+
}
|
|
203
210
|
await cdp.dismissMenus();
|
|
204
211
|
const resumeKind = ctx.resume?.kind;
|
|
205
212
|
// 重观察恢复(user_confirmation):用户在 GUI 处理完等待项后 turn 自行继续,
|
|
@@ -250,7 +257,7 @@ export async function runCodexTask(args) {
|
|
|
250
257
|
logger.info(`[codex] 已选择既有项目:${matched.item.name}`);
|
|
251
258
|
}
|
|
252
259
|
else {
|
|
253
|
-
const created = await createProject(cdp, ctx.projectPath, aumid, gui, deps, logger);
|
|
260
|
+
const created = await createProject(cdp, ctx.projectPath, aumid, gui, deps, logger, emit);
|
|
254
261
|
if (!created.ok)
|
|
255
262
|
return result({
|
|
256
263
|
hardFailure: true,
|
|
@@ -353,6 +360,12 @@ export async function runCodexTask(args) {
|
|
|
353
360
|
endReason: "send_unknown",
|
|
354
361
|
});
|
|
355
362
|
logger.info(`[codex] 指令已确认发送(对话区=${seenMessage},输入清空=${seenCleared},运行信号=${seenRunning})`);
|
|
363
|
+
await emit("task_dispatched", `第 ${ctx.round} 轮指令已确认送达 Codex`, {
|
|
364
|
+
round: ctx.round,
|
|
365
|
+
seenMessage,
|
|
366
|
+
seenCleared,
|
|
367
|
+
seenRunning,
|
|
368
|
+
});
|
|
356
369
|
}
|
|
357
370
|
// ---- 步骤 6:运行检测 ----
|
|
358
371
|
const deadline = started + ctx.taskTimeoutMs;
|
|
@@ -364,6 +377,8 @@ export async function runCodexTask(args) {
|
|
|
364
377
|
}
|
|
365
378
|
let cdpFailures = 0;
|
|
366
379
|
let lastProgress = 0;
|
|
380
|
+
// file_modification_started 每次进程运行只发一次(首次观测到运行信号时)
|
|
381
|
+
let emittedRunning = false;
|
|
367
382
|
for (;;) {
|
|
368
383
|
// 取消(issue #6):不止退出 MCP 等待循环,还要尽力点击 GUI 停止按钮并等待空闲,
|
|
369
384
|
// 结果经 guiStop 上报,由编排方在终态文案中如实反映。
|
|
@@ -402,28 +417,44 @@ export async function runCodexTask(args) {
|
|
|
402
417
|
}
|
|
403
418
|
continue;
|
|
404
419
|
}
|
|
420
|
+
// 判定前先记下上一轮的运行信号:判定的输出含本轮状态,二者比较才能识别「首次开始运行」
|
|
421
|
+
const wasRunning = state.sawRunning;
|
|
405
422
|
const verdict = judgeCodexPoll(poll, state, gui.stableRounds, gui.idleTimeoutMs, Date.now(), gui.stallTimeoutMs);
|
|
406
423
|
state = verdict.state;
|
|
424
|
+
if (!wasRunning && state.sawRunning && !emittedRunning) {
|
|
425
|
+
emittedRunning = true;
|
|
426
|
+
// 文案如实保留:Codex 适配器并不直接观测文件系统,无法声称文件确已改动。
|
|
427
|
+
await emit("file_modification_started", "停止按钮出现,Codex 开始执行(可能开始改动文件)", {
|
|
428
|
+
round: ctx.round,
|
|
429
|
+
evidence: verdict.evidence,
|
|
430
|
+
});
|
|
431
|
+
}
|
|
407
432
|
if (Date.now() - lastProgress >= gui.progressIntervalMs) {
|
|
408
433
|
const note = `Codex 进度:${verdict.kind};运行证据=${verdict.evidence};对话哈希=${state.hash};稳定轮=${state.stable}`;
|
|
409
434
|
await Promise.resolve(opts.onProgress?.(note)).catch(() => { });
|
|
410
435
|
logger.info(note);
|
|
411
436
|
lastProgress = Date.now();
|
|
412
437
|
}
|
|
413
|
-
if (verdict.kind === "needs_login")
|
|
438
|
+
if (verdict.kind === "needs_login") {
|
|
439
|
+
await emit("awaiting_user_authorization", "Codex 需要登录,等待用户完成登录", {
|
|
440
|
+
needsUserKind: "login_required",
|
|
441
|
+
});
|
|
414
442
|
return result({
|
|
415
443
|
endReason: "needs_user",
|
|
416
444
|
needsUserKind: "login_required",
|
|
417
445
|
pendingQuestion: "Codex 需要登录,请在窗口中完成登录后调用 continue_task 确认。",
|
|
418
446
|
session: { boundProjectPath: ctx.projectPath, model: spec.model, permissionMode: gui.defaultPermissionMode },
|
|
419
447
|
});
|
|
420
|
-
|
|
448
|
+
}
|
|
449
|
+
if (verdict.kind === "needs_user") {
|
|
450
|
+
await emit("awaiting_user_authorization", "停止按钮持续可见且对话长时间未变化,疑似等待用户确认(方案确认/订阅确认等)", { needsUserKind: "user_confirmation" });
|
|
421
451
|
return result({
|
|
422
452
|
endReason: "needs_user",
|
|
423
453
|
needsUserKind: "user_confirmation",
|
|
424
454
|
pendingQuestion: "Codex 停止按钮持续可见且对话内容长时间未变化,疑似在等待用户确认(方案确认/订阅确认等)。请在 Codex 窗口完成处理后调用 continue_task(taskId, message=已处理说明) 恢复;恢复后仅重新接入观察,不会发送消息。",
|
|
425
455
|
session: { boundProjectPath: ctx.projectPath, model: spec.model, permissionMode: gui.defaultPermissionMode },
|
|
426
456
|
});
|
|
457
|
+
}
|
|
427
458
|
if (verdict.kind === "idle_timeout")
|
|
428
459
|
return result({ endReason: "idle_timeout", error: "Codex 空闲超时;已停止 MCP 等待并保留现场" });
|
|
429
460
|
if (verdict.kind === "finished")
|
|
@@ -495,7 +526,7 @@ async function stopGuiTurn(cdp, gui, deps, logger) {
|
|
|
495
526
|
* 打开项目选择层 → 新建项目 → 点源文件夹中心空白区 → 原生对话框键盘填路径 →
|
|
496
527
|
* 确认源文件夹 → 点创建项目。任一步无法唯一定位即 fail-closed。
|
|
497
528
|
*/
|
|
498
|
-
async function createProject(cdp, projectPath, aumid, gui, deps, logger) {
|
|
529
|
+
async function createProject(cdp, projectPath, aumid, gui, deps, logger, emit) {
|
|
499
530
|
// 新建会话后输入框会重渲染,触发器可能短暂缺席 —— 先等它出现再点。
|
|
500
531
|
if (!(await waitFor(cdp, "projectPickerTrigger", deps, 12_000))) {
|
|
501
532
|
// issue #23 诊断机制:解析失败时把页面可见候选一并给出,使用者一步定位文案漂移。
|
|
@@ -529,8 +560,12 @@ async function createProject(cdp, projectPath, aumid, gui, deps, logger) {
|
|
|
529
560
|
.map((p) => p.pid);
|
|
530
561
|
// 先清理残留原生对话框(上一轮失败可能留下,遮挡界面且会让本轮误判「无新对话框」)
|
|
531
562
|
const closed = await deps.closeDialogs(pids);
|
|
532
|
-
if (closed)
|
|
563
|
+
if (closed) {
|
|
533
564
|
logger.warn(`[codex] 已清理 ${closed} 个残留原生对话框`);
|
|
565
|
+
await emit("confirmation_dialog_detected", `清理残留原生对话框 ${closed} 个`, {
|
|
566
|
+
nativeDialogsClosed: closed,
|
|
567
|
+
});
|
|
568
|
+
}
|
|
534
569
|
// 原生文件夹选择器只在应用窗口处于前台时弹出;无人值守下先用 COM 激活把窗口带到前台
|
|
535
570
|
// (SetForegroundWindow 会被前台锁拒绝,应用模型激活不会)。
|
|
536
571
|
const focused = aumid ? await deps.focusApp(aumid) : false;
|
|
@@ -541,6 +576,9 @@ async function createProject(cdp, projectPath, aumid, gui, deps, logger) {
|
|
|
541
576
|
const baseline = await deps.listDialogs(pids);
|
|
542
577
|
if (!(await cdp.clickTrusted("sourceFolderArea")))
|
|
543
578
|
return { ok: false, error: "无法触发「源文件夹」点击(元素不可见或落点被遮挡)" };
|
|
579
|
+
await emit("confirmation_dialog_detected", "已唤起原生「选择文件夹」对话框", {
|
|
580
|
+
dialog: "source_folder",
|
|
581
|
+
});
|
|
544
582
|
await deps.sleep(1500);
|
|
545
583
|
const selected = await deps.selectFolder(projectPath, pids, baseline);
|
|
546
584
|
if (!selected.ok)
|
|
@@ -11,6 +11,7 @@ import { ZCODE_SETUP_DEFAULTS } from "../../config/schema.js";
|
|
|
11
11
|
import fs from "node:fs";
|
|
12
12
|
import path from "node:path";
|
|
13
13
|
import { mkdirp } from "../../util/fs.js";
|
|
14
|
+
import { makeEmitter } from "../agent-events.js";
|
|
14
15
|
import { CdpDisconnectedError, CdpUnavailableError, interpretLiveness, TraeworkCdpClient, } from "./cdp/client.js";
|
|
15
16
|
import { launchInstance, probeReady, releaseInstance, resolvePort, waitReady, diagnosePortFailure, } from "./launcher.js";
|
|
16
17
|
import { bindProject, ensureMode, matchProjectItem, projectBasename, readBoundProject, readMode, resolveMode, startNewSession, } from "./ui/session.js";
|
|
@@ -178,6 +179,8 @@ export async function runTraeworkTask(args) {
|
|
|
178
179
|
const deps = { ...DEFAULT_DEPS, ...args.deps };
|
|
179
180
|
const gui = guiOf(resolved);
|
|
180
181
|
const { logger, close: closeLog } = makeFileLogger(logFile, args.logger);
|
|
182
|
+
// 细粒度事件上报(issue #18):未提供钩子时为空操作,失败不影响任务本体。
|
|
183
|
+
const emit = makeEmitter(opts.onEvent);
|
|
181
184
|
let endReason;
|
|
182
185
|
let keptInstance = false;
|
|
183
186
|
const fail = (error, extra = {}) => ({
|
|
@@ -264,6 +267,12 @@ export async function runTraeworkTask(args) {
|
|
|
264
267
|
sleep: deps.sleep,
|
|
265
268
|
dialogWaitTimeoutMs: deps.dialogWaitTimeoutMs,
|
|
266
269
|
});
|
|
270
|
+
if (bound.method === "native-dialog") {
|
|
271
|
+
// 走原生「选择文件夹」对话框绑定:这正是无人值守下最容易卡住的确认类交互
|
|
272
|
+
await emit("confirmation_dialog_detected", bound.bound
|
|
273
|
+
? `项目绑定经原生「选择文件夹」对话框完成:${bound.message}`
|
|
274
|
+
: `原生「选择文件夹」对话框驱动失败:${bound.message}`, { dialog: "source_folder", bound: bound.bound });
|
|
275
|
+
}
|
|
267
276
|
if (!bound.bound) {
|
|
268
277
|
return stop("setup_failed", `项目文件夹绑定失败(${bound.method}):${bound.message}`, {
|
|
269
278
|
hardFailure: true,
|
|
@@ -298,6 +307,10 @@ export async function runTraeworkTask(args) {
|
|
|
298
307
|
const promptText = buildPromptText(ctx.task, ctx.context, ctx.feedback);
|
|
299
308
|
const marker = makeMarker();
|
|
300
309
|
await typeAndSend(cdp, marker + promptText, { selectors: gui.selectors, logger, sleep: deps.sleep });
|
|
310
|
+
await emit("task_dispatched", `第 ${ctx.round} 轮任务书已发送至 TraeWork`, {
|
|
311
|
+
round: ctx.round,
|
|
312
|
+
chars: promptText.length,
|
|
313
|
+
});
|
|
301
314
|
// ---- 7. 轮询到完成 ----
|
|
302
315
|
const base = await cdp.text("messageContainer", gui.selectors);
|
|
303
316
|
let state = { prev: "", stable: 0, idleSince: 0 };
|
|
@@ -340,8 +353,16 @@ export async function runTraeworkTask(args) {
|
|
|
340
353
|
const live = interpretLiveness(liveness);
|
|
341
354
|
const now = Date.now();
|
|
342
355
|
if (live.running) {
|
|
343
|
-
if (runningSince === 0)
|
|
356
|
+
if (runningSince === 0) {
|
|
344
357
|
runningSince = now;
|
|
358
|
+
// 首次由静止转为运行:会话确实开始干活了。文案如实保留 —— 适配器并不直接
|
|
359
|
+
// 观测文件系统,无法声称文件确已改动。
|
|
360
|
+
// eslint-disable-next-line no-await-in-loop
|
|
361
|
+
await emit("file_modification_started", "运行信号首次出现,TraeWork 开始执行(可能开始改动文件)", {
|
|
362
|
+
round: ctx.round,
|
|
363
|
+
evidence: live.evidence,
|
|
364
|
+
});
|
|
365
|
+
}
|
|
345
366
|
if (!runningWarned && now - runningSince >= gui.idleTimeoutMs) {
|
|
346
367
|
logger.warn(`[traework] 运行信号已持续 ${Math.round((now - runningSince) / 1000)}s(${live.evidence}),仅记录诊断,继续等待`);
|
|
347
368
|
runningWarned = true;
|
|
@@ -375,6 +396,11 @@ export async function runTraeworkTask(args) {
|
|
|
375
396
|
endReason = "ask_user";
|
|
376
397
|
logger.warn("[traework] 模型发起原生提问(ask_user),会话被阻塞,按本轮结束处理");
|
|
377
398
|
replyText = verdict.added;
|
|
399
|
+
// 这是「卡在人工介入」的典型形态,必须让调用方能从事件流直接看出来
|
|
400
|
+
// eslint-disable-next-line no-await-in-loop
|
|
401
|
+
await emit("awaiting_user_authorization", "模型发起原生提问(ask_user),会话被阻塞等待用户回答", {
|
|
402
|
+
endReason: "ask_user",
|
|
403
|
+
});
|
|
378
404
|
await cdp.pressEscape().catch(() => undefined);
|
|
379
405
|
break;
|
|
380
406
|
}
|
package/dist/config/schema.js
CHANGED
|
@@ -100,9 +100,17 @@ export const RunTaskParamsSchema = z.object({
|
|
|
100
100
|
*/
|
|
101
101
|
idempotencyKey: IdempotencyKeySchema.optional(),
|
|
102
102
|
});
|
|
103
|
+
/** query_task 返回的细粒度事件条数默认值(issue #18) */
|
|
104
|
+
export const QUERY_TASK_EVENT_LIMIT_DEFAULT = 10;
|
|
103
105
|
export const QueryTaskParamsSchema = z.object({
|
|
104
106
|
taskId: z.string().min(1),
|
|
105
107
|
tailLines: z.number().int().positive().optional(),
|
|
108
|
+
/**
|
|
109
|
+
* 返回最近 N 条细粒度 agent 事件(issue #18):task_dispatched /
|
|
110
|
+
* confirmation_dialog_detected / awaiting_user_authorization / file_modification_started /
|
|
111
|
+
* rework_triggered。缺省 10,上限 50。未实现事件上报的适配器返回空数组。
|
|
112
|
+
*/
|
|
113
|
+
eventLimit: z.number().int().min(1).max(50).optional(),
|
|
106
114
|
});
|
|
107
115
|
export const ListTasksParamsSchema = z.object({
|
|
108
116
|
projectPath: AbsPath.optional(),
|
package/dist/loop/fix-loop.js
CHANGED
|
@@ -224,6 +224,12 @@ export class TaskOrchestrator {
|
|
|
224
224
|
if (maxRounds > round) {
|
|
225
225
|
await store.updateStatus(meta, "fixing", `第 ${round} 轮验收失败,进入第 ${round + 1} 轮返修`);
|
|
226
226
|
round += 1;
|
|
227
|
+
// 事件流(issue #18):返修是与适配器无关的引擎侧节点,由编排器直接上报。
|
|
228
|
+
await store.appendEvent(meta.taskId, "rework_triggered", "fixing", `第 ${round - 1} 轮验收失败,进入第 ${round} 轮自动返修`, {
|
|
229
|
+
round,
|
|
230
|
+
mode: "auto",
|
|
231
|
+
failedChecks: verdict.report.checks.filter((c) => !c.passed && !c.skipped).map((c) => c.name),
|
|
232
|
+
});
|
|
227
233
|
if (meta.agentId === "codex") {
|
|
228
234
|
// 决策 11/12:Codex 的修复计划由 MCP 自动生成,落在**项目内** .zcode/plans/
|
|
229
235
|
// (文件名含轮次号 codex-fix-r<N>.md,不覆盖历史);因文件名发送前已知,
|
|
@@ -417,6 +423,12 @@ export class TaskOrchestrator {
|
|
|
417
423
|
await this.deps.store.appendEvent(ctx.taskId, "note", this.meta.status, note);
|
|
418
424
|
await this.deps.store.writeSnapshot(this.meta);
|
|
419
425
|
},
|
|
426
|
+
// 细粒度事件(issue #18):适配器主动上报,编排器只负责落盘,不做任何解释或推断。
|
|
427
|
+
onEvent: async (ev) => {
|
|
428
|
+
this.meta.updatedAt = new Date().toISOString();
|
|
429
|
+
await this.deps.store.appendEvent(ctx.taskId, ev.kind, this.meta.status, ev.detail, ev.data);
|
|
430
|
+
await this.deps.store.writeSnapshot(this.meta);
|
|
431
|
+
},
|
|
420
432
|
});
|
|
421
433
|
}
|
|
422
434
|
await this.deps.registry.prepareInvocation(ctx.agentId, ctx, resolved);
|
package/dist/mcp/handlers.js
CHANGED
|
@@ -8,6 +8,7 @@ import { normalizeLevel } from "../agents/qoder/model.js";
|
|
|
8
8
|
import { prepareBaseline, approveBaseline, PrepareBaselineSchema, ApproveBaselineSchema, } from "../visual/baselines.js";
|
|
9
9
|
import { assertSafeProjectDir, normPath, resolveProjectDir } from "../util/path.js";
|
|
10
10
|
import { execFileAsync } from "../verify/exec.js";
|
|
11
|
+
import { QUERY_TASK_EVENT_LIMIT_DEFAULT } from "../config/schema.js";
|
|
11
12
|
import { toAcceptanceDef } from "../config/store.js";
|
|
12
13
|
import { isDefaultWorkspace, isTerminal, } from "../tasks/task.js";
|
|
13
14
|
import { canonicalDigest, IdempotencyIndex, keyDigest, } from "../tasks/idempotency.js";
|
|
@@ -504,14 +505,22 @@ function queryTaskHandler(ctx) {
|
|
|
504
505
|
logTail = await readLogTail(logFile, tailLines);
|
|
505
506
|
}
|
|
506
507
|
const statusLine = describeStatus(meta);
|
|
508
|
+
const eventLimit = args.eventLimit ?? QUERY_TASK_EVENT_LIMIT_DEFAULT;
|
|
509
|
+
const recentEvents = await store.readRecentAgentEvents(meta.taskId, eventLimit);
|
|
507
510
|
const lines = [
|
|
508
511
|
statusLine,
|
|
509
512
|
meta.lastMessage ? `最近消息: ${meta.lastMessage}` : "",
|
|
510
513
|
logTail
|
|
511
514
|
? `--- agent 日志尾部(${logTail.split("\n").length} 行)---\n${logTail}`
|
|
512
515
|
: "(暂无 agent 日志)",
|
|
516
|
+
// 细粒度事件(issue #18):时间正序,便于直接看出「卡在哪个节点」
|
|
517
|
+
recentEvents.length
|
|
518
|
+
? `--- 最近事件(${recentEvents.length} 条,旧 → 新)---\n${recentEvents
|
|
519
|
+
.map((e) => `[${e.ts}] ${e.event}${e.detail ? ` — ${e.detail}` : ""}`)
|
|
520
|
+
.join("\n")}`
|
|
521
|
+
: "",
|
|
513
522
|
].filter((s) => s !== "");
|
|
514
|
-
return formatToolResult(lines.join("\n"), metaFromTask(meta));
|
|
523
|
+
return formatToolResult(lines.join("\n"), metaFromTask(meta, { recentEvents }));
|
|
515
524
|
};
|
|
516
525
|
}
|
|
517
526
|
async function existsFile(p) {
|
package/dist/mcp/tools.js
CHANGED
|
@@ -41,7 +41,11 @@ export const TOOL_DEFS = [
|
|
|
41
41
|
},
|
|
42
42
|
{
|
|
43
43
|
name: "query_task",
|
|
44
|
-
description: "查询任务状态 / 进度 / 最近日志尾部(默认 agent.log 末 40
|
|
44
|
+
description: "查询任务状态 / 进度 / 最近日志尾部(默认 agent.log 末 40 行)/ 最近细粒度事件。返回任务 meta 与日志片段。" +
|
|
45
|
+
"meta.recentEvents 为最近 N 条 agent 事件(eventLimit 缺省 10、上限 50)," +
|
|
46
|
+
"取值 task_dispatched / confirmation_dialog_detected / awaiting_user_authorization / " +
|
|
47
|
+
"file_modification_started / rework_triggered —— 长任务下可据此区分「正常执行」与「卡在弹窗等人」。" +
|
|
48
|
+
"未实现事件上报的适配器该数组为空,其余字段不变。",
|
|
45
49
|
inputSchema: QueryTaskParamsSchema,
|
|
46
50
|
capability: "read",
|
|
47
51
|
requireApproval: false,
|
|
@@ -219,12 +219,11 @@ export class TaskManager {
|
|
|
219
219
|
meta.updatedAt = nowIso();
|
|
220
220
|
meta.finishedAt = undefined;
|
|
221
221
|
meta.reworkFeedback = feedback?.trim() || undefined;
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
}
|
|
222
|
+
// 类型化事件(issue #18):手动返修是引擎侧节点,事件名与自动返修统一为 rework_triggered,
|
|
223
|
+
// 便于调用方用同一条规则观察「返修是否被触发」;mode 区分人工 / 自动。
|
|
224
|
+
await this.store.appendEvent(meta.taskId, "rework_triggered", "queued", meta.reworkFeedback
|
|
225
|
+
? `rework 请求,追加指示: ${meta.reworkFeedback.slice(0, 200)}`
|
|
226
|
+
: "rework 请求(无追加指示)", { mode: "manual" });
|
|
228
227
|
await this.store.writeSnapshot(meta);
|
|
229
228
|
this.tasks.set(taskId, meta);
|
|
230
229
|
this.enqueue(meta);
|
package/dist/tasks/task-store.js
CHANGED
|
@@ -5,7 +5,8 @@
|
|
|
5
5
|
*/
|
|
6
6
|
import path from "node:path";
|
|
7
7
|
import { TERMINAL_STATUSES, ACTIVE_STATUSES, } from "./task.js";
|
|
8
|
-
import { appendLine, exists, mkdirp, readDirSafe, readJsonSafe, readTextSafe, writeJsonAtomic, writeTextAtomic, } from "../util/fs.js";
|
|
8
|
+
import { appendLine, exists, mkdirp, readDirSafe, readJsonSafe, readTextSafe, readTextTail, writeJsonAtomic, writeTextAtomic, } from "../util/fs.js";
|
|
9
|
+
import { isAgentEventName } from "../agents/agent-events.js";
|
|
9
10
|
import { nowIso } from "../util/id.js";
|
|
10
11
|
import { reportToJsonable, reportToMd } from "../verify/report.js";
|
|
11
12
|
import { visualHtml } from "../visual/report.js";
|
|
@@ -81,6 +82,32 @@ export class TaskStore {
|
|
|
81
82
|
}
|
|
82
83
|
return out;
|
|
83
84
|
}
|
|
85
|
+
/**
|
|
86
|
+
* 读取最近的细粒度 agent 事件(issue #18)。
|
|
87
|
+
*
|
|
88
|
+
* 长任务的 task.jsonl 会无限增长,因此**只读尾部窗口**(默认 64KiB)而不是全文:
|
|
89
|
+
* 内存占用与文件总大小解耦,这是 issue 提到的「避免长时间运行任务内存膨胀」的落点。
|
|
90
|
+
* 返回最后 `limit` 条属于 AGENT_EVENT_NAMES 的事件(按写入顺序,即时间正序)。
|
|
91
|
+
*/
|
|
92
|
+
async readRecentAgentEvents(taskId, limit, maxBytes = 64 * 1024) {
|
|
93
|
+
const text = await readTextTail(this.jsonlPath(taskId), maxBytes);
|
|
94
|
+
if (!text)
|
|
95
|
+
return [];
|
|
96
|
+
const matched = [];
|
|
97
|
+
for (const line of text.split("\n")) {
|
|
98
|
+
if (!line.trim())
|
|
99
|
+
continue;
|
|
100
|
+
try {
|
|
101
|
+
const ev = JSON.parse(line);
|
|
102
|
+
if (isAgentEventName(ev.event))
|
|
103
|
+
matched.push(ev);
|
|
104
|
+
}
|
|
105
|
+
catch {
|
|
106
|
+
// 跳过坏行
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
return matched.slice(-limit);
|
|
110
|
+
}
|
|
84
111
|
/* ---------- 快照 ---------- */
|
|
85
112
|
async writeSnapshot(meta) {
|
|
86
113
|
await mkdirp(this.dir(meta.taskId));
|
package/dist/util/fs.js
CHANGED
|
@@ -56,6 +56,51 @@ export async function readTextSafe(p) {
|
|
|
56
56
|
return null;
|
|
57
57
|
}
|
|
58
58
|
}
|
|
59
|
+
/**
|
|
60
|
+
* 读取文件**尾部最多 maxBytes 字节**(issue #18)。
|
|
61
|
+
*
|
|
62
|
+
* 用途:`task.jsonl` 会随长任务无限增长,`readTextSafe` 全文读入会在长任务上膨胀内存。
|
|
63
|
+
* 本函数只读尾部窗口,把内存占用与文件总大小解耦。
|
|
64
|
+
*
|
|
65
|
+
* 语义:返回的文本保证**从完整行开始**——若截断点落在行中间,首个残行被丢弃,
|
|
66
|
+
* 避免把半个 JSON 行交给调用方。文件不存在或读失败返回 null(与 readTextSafe 一致)。
|
|
67
|
+
* 文件总长不超过 maxBytes 时返回全文(不做任何丢弃)。
|
|
68
|
+
*/
|
|
69
|
+
export async function readTextTail(p, maxBytes) {
|
|
70
|
+
let handle;
|
|
71
|
+
try {
|
|
72
|
+
handle = await fsp.open(p, "r");
|
|
73
|
+
}
|
|
74
|
+
catch {
|
|
75
|
+
return null;
|
|
76
|
+
}
|
|
77
|
+
try {
|
|
78
|
+
const stat = await handle.stat();
|
|
79
|
+
if (stat.size <= maxBytes)
|
|
80
|
+
return await handle.readFile({ encoding: "utf8" });
|
|
81
|
+
// 多读 1 字节:用于判断截断点是否恰好落在行首
|
|
82
|
+
const readFrom = stat.size - maxBytes - 1;
|
|
83
|
+
const len = stat.size - readFrom;
|
|
84
|
+
const buf = Buffer.allocUnsafe(len);
|
|
85
|
+
const { bytesRead } = await handle.read(buf, 0, len, readFrom);
|
|
86
|
+
let text = buf.subarray(0, bytesRead).toString("utf8");
|
|
87
|
+
if (text[0] === "\n") {
|
|
88
|
+
// readFrom 处正是换行符 → 其后的内容天然从行首开始,无需丢弃
|
|
89
|
+
text = text.slice(1);
|
|
90
|
+
}
|
|
91
|
+
else {
|
|
92
|
+
const nl = text.indexOf("\n");
|
|
93
|
+
text = nl === -1 ? "" : text.slice(nl + 1);
|
|
94
|
+
}
|
|
95
|
+
return text;
|
|
96
|
+
}
|
|
97
|
+
catch {
|
|
98
|
+
return null;
|
|
99
|
+
}
|
|
100
|
+
finally {
|
|
101
|
+
await handle.close();
|
|
102
|
+
}
|
|
103
|
+
}
|
|
59
104
|
export async function readJsonSafe(p) {
|
|
60
105
|
const text = await readTextSafe(p);
|
|
61
106
|
if (text == null)
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
# Fine-grained event stream (issue #18)
|
|
2
|
+
|
|
3
|
+
Chinese version: [event-stream.md](event-stream.md)
|
|
4
|
+
|
|
5
|
+
For long tasks — especially GUI agents stuck on confirmation dialogs, file pickers or authorization
|
|
6
|
+
prompts — `query_task` used to return only `running`. Callers could not tell "the agent is working
|
|
7
|
+
normally" apart from "the agent is stuck waiting for a human", so they had to either wait blindly or
|
|
8
|
+
kill the task on timeout, wasting scheduling time and triggering unnecessary rework rounds.
|
|
9
|
+
|
|
10
|
+
This capability lets adapters **proactively report semantic events** at key nodes; `query_task`
|
|
11
|
+
returns the most recent N of them.
|
|
12
|
+
|
|
13
|
+
## 1. Event vocabulary
|
|
14
|
+
|
|
15
|
+
| Event | Meaning | Emitted when |
|
|
16
|
+
|---|---|---|
|
|
17
|
+
| `task_dispatched` | Instruction confirmed delivered to the agent | codex: after the send-confirmation loop passes; traework: after `typeAndSend` returns |
|
|
18
|
+
| `confirmation_dialog_detected` | A confirmation dialog was detected | codex: clearing stale native dialogs, or raising the native "select folder" dialog; traework: project binding went through the native "select folder" dialog |
|
|
19
|
+
| `awaiting_user_authorization` | Waiting for the user to authorize / log in / confirm | codex: `loginIndicator` visible, `needs_login`, `needs_user` verdict; traework: `ask_user` suspension |
|
|
20
|
+
| `file_modification_started` | The agent started executing | codex / traework: the **first** time the running signal (stop button) appears; reported once per round |
|
|
21
|
+
| `rework_triggered` | Entering rework after acceptance failed | Emitted engine-side: automatic rework `mode:"auto"`, manual `rework_task` `mode:"manual"` |
|
|
22
|
+
|
|
23
|
+
> **`file_modification_started` is a heuristic and its wording says so.** The codex / traework
|
|
24
|
+
> adapters do **not** observe the filesystem directly; they can only infer that execution started
|
|
25
|
+
> from the UI's "running" signal. Its detail therefore always reads "stop button appeared, execution
|
|
26
|
+
> started (files may be modified)" — it **does not claim files were actually changed**. For hard
|
|
27
|
+
> evidence of file changes, read `changedFiles` / `diffstat` from the acceptance report.
|
|
28
|
+
|
|
29
|
+
## 2. Where events live: the same `task.jsonl`
|
|
30
|
+
|
|
31
|
+
New events are written into the **same task event stream** as existing events
|
|
32
|
+
(`<data home>/tasks/<taskId>/task.jsonl`), sharing one timeline:
|
|
33
|
+
|
|
34
|
+
- Why not an in-memory ring buffer: during long GUI tasks the MCP host may restart, and a purely
|
|
35
|
+
in-memory queue would lose **exactly the scene you most need**. A second parallel stream would also
|
|
36
|
+
create a second source of truth with no shared ordering.
|
|
37
|
+
- Why it can't grow memory without bound: the risk is on the **read** side. `readRecentAgentEvents()`
|
|
38
|
+
reads only a **tail window** of the file (64 KiB by default, via `readTextTail()`), so memory use is
|
|
39
|
+
decoupled from total file size.
|
|
40
|
+
|
|
41
|
+
The existing `note` event is **unchanged** in semantics — it remains the progress / audit channel
|
|
42
|
+
(carrying free text such as `progressSummary` / `lastRunSignal`). `recentEvents` in `query_task`
|
|
43
|
+
**only** filters the five semantic kinds above and never mixes `note` in.
|
|
44
|
+
|
|
45
|
+
## 3. How `query_task` exposes it
|
|
46
|
+
|
|
47
|
+
New optional input `eventLimit` (integer, 1..50, **default 10**). The result is readable in two places:
|
|
48
|
+
|
|
49
|
+
**① The meta block** (a Tianshu host can regex out the JSON wrapped in `---tianshu-mcp-meta---`):
|
|
50
|
+
|
|
51
|
+
```json
|
|
52
|
+
"recentEvents": [
|
|
53
|
+
{ "ts": "2026-09-24T11:06:26.056Z", "event": "task_dispatched", "detail": "第 0 轮指令已确认送达 Codex", "data": { "round": 0 } },
|
|
54
|
+
{ "ts": "2026-09-24T11:06:41.201Z", "event": "file_modification_started", "detail": "停止按钮出现,Codex 开始执行(可能开始改动文件)" }
|
|
55
|
+
]
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
**② The text area** (for humans, no meta parsing needed):
|
|
59
|
+
|
|
60
|
+
```
|
|
61
|
+
--- 最近事件(2 条,旧 → 新)---
|
|
62
|
+
[2026-09-24T11:06:26.056Z] task_dispatched — 第 0 轮指令已确认送达 Codex
|
|
63
|
+
[2026-09-24T11:06:41.201Z] file_modification_started — 停止按钮出现,Codex 开始执行(可能开始改动文件)
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
**Backward compatible**: `recentEvents` is a new optional field. Adapters that do not implement event
|
|
67
|
+
reporting (and all CLI adapters) return an **empty array**, no event section appears in the text area,
|
|
68
|
+
and **every other field is exactly as it was before this capability existed**.
|
|
69
|
+
|
|
70
|
+
## 4. For adapter authors: how to report events from a new adapter
|
|
71
|
+
|
|
72
|
+
Reporting is an **optional capability**; whether to implement it is the adapter's own decision:
|
|
73
|
+
|
|
74
|
+
```ts
|
|
75
|
+
import { makeEmitter } from "../agent-events.js";
|
|
76
|
+
|
|
77
|
+
async function runXxxTask(args: RunXxxArgs): Promise<AgentRunResult> {
|
|
78
|
+
const emit = makeEmitter(args.opts.onEvent);
|
|
79
|
+
// …
|
|
80
|
+
await emit("task_dispatched", "instruction delivered", { round: ctx.round });
|
|
81
|
+
// …
|
|
82
|
+
}
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
Key points:
|
|
86
|
+
|
|
87
|
+
1. The hook lives on `AgentRunOptions.onEvent` (**not** the agent profile — `agent-profiles.json` is
|
|
88
|
+
plain JSON and cannot hold a function; forcing one in would break schema parsing and hot reload).
|
|
89
|
+
2. **Always report through `makeEmitter`.** It turns "no hook provided" into a no-op and swallows
|
|
90
|
+
exceptions raised while reporting — event reporting is an observability concern and **must never
|
|
91
|
+
affect the task itself**.
|
|
92
|
+
3. No change to `AgentProfileSchema` is needed. "Optional" is expressed by `opts.onEvent?.(…)` on the
|
|
93
|
+
calling side together with `makeEmitter`; adapters that don't implement it **need not change a
|
|
94
|
+
single byte**.
|
|
95
|
+
4. Built-in adapters that actually report events today: **codex** and **traework** (the scope of
|
|
96
|
+
issue #18). zcode / kimicode / qoder and all CLI adapters keep the interface but do not report yet.
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
# 细粒度事件流(issue #18)
|
|
2
|
+
|
|
3
|
+
英文版:[event-stream.en.md](event-stream.en.md)
|
|
4
|
+
|
|
5
|
+
长任务(尤其是 GUI agent 卡在确认弹窗、文件选择对话框、授权提示上)下,`query_task` 原先只能返回
|
|
6
|
+
`running`。调用方无法区分「agent 正在正常工作」与「agent 已卡死等待人工干预」,只能盲等或超时后强行终止,
|
|
7
|
+
既浪费调度时间,也容易触发不必要的返修。
|
|
8
|
+
|
|
9
|
+
本能力让适配器在**关键节点主动上报语义化事件**,`query_task` 回传最近 N 条。
|
|
10
|
+
|
|
11
|
+
## 一、事件词表
|
|
12
|
+
|
|
13
|
+
| 事件 | 含义 | 何时上报 |
|
|
14
|
+
|---|---|---|
|
|
15
|
+
| `task_dispatched` | 指令已确认送达 agent | codex:发送确认循环通过后;traework:`typeAndSend` 返回后 |
|
|
16
|
+
| `confirmation_dialog_detected` | 检测到确认类对话框 | codex:清理残留原生弹窗、唤起原生「选择文件夹」时;traework:项目绑定经原生「选择文件夹」对话框时 |
|
|
17
|
+
| `awaiting_user_authorization` | 等待用户授权 / 登录 / 确认 | codex:`loginIndicator` 可见、`needs_login`、`needs_user` 判定;traework:`ask_user` 挂起 |
|
|
18
|
+
| `file_modification_started` | agent 开始执行 | codex / traework:运行信号(停止按钮)**首次**出现时,每轮只报一次 |
|
|
19
|
+
| `rework_triggered` | 验收失败后进入返修 | 引擎侧统一上报:自动返修 `mode:"auto"`,手动 `rework_task` `mode:"manual"` |
|
|
20
|
+
|
|
21
|
+
> **`file_modification_started` 是启发式推断,文案如实保留。** codex / traework 适配器**并不直接观测文件系统**,
|
|
22
|
+
> 只能从界面上的「运行中」信号推断执行已开始。因此其 detail 一律写「停止按钮出现,开始执行(可能开始改动文件)」——
|
|
23
|
+
> **不声称文件确已改动**。需要确切的文件改动证据请看验收报告的 `changedFiles` / `diffstat`。
|
|
24
|
+
|
|
25
|
+
## 二、事件存哪:同一个 `task.jsonl`
|
|
26
|
+
|
|
27
|
+
新事件与既有事件**同写一个任务事件流**(`<数据目录>/tasks/<taskId>/task.jsonl`),共用时序:
|
|
28
|
+
|
|
29
|
+
- 为什么不用内存环形缓冲:GUI 长任务中 MCP 宿主可能重启,纯内存队列会丢掉**正是最需要的那段现场**;
|
|
30
|
+
另建并行流还会出现第二个事实来源、排序不统一。
|
|
31
|
+
- 为什么不会「内存膨胀」:膨胀风险在**读取侧**。`readRecentAgentEvents()` 只读文件的**尾部窗口**
|
|
32
|
+
(默认 64 KiB,`readTextTail()` 实现),内存占用与文件总大小解耦。
|
|
33
|
+
|
|
34
|
+
既有 `note` 事件**语义不变**,仍是进度 / 审计通道(承载 `progressSummary`、`lastRunSignal` 等自由文本)。
|
|
35
|
+
`query_task` 的 `recentEvents` **只**过滤出上表 5 类语义事件,不会把 `note` 混进来。
|
|
36
|
+
|
|
37
|
+
## 三、`query_task` 怎么暴露
|
|
38
|
+
|
|
39
|
+
新增可选入参 `eventLimit`(整数,1..50,**缺省 10**)。返回有两处可读:
|
|
40
|
+
|
|
41
|
+
**① meta 块**(天枢可正则抽取 `---tianshu-mcp-meta---` 包裹的 JSON):
|
|
42
|
+
|
|
43
|
+
```json
|
|
44
|
+
"recentEvents": [
|
|
45
|
+
{ "ts": "2026-09-24T11:06:26.056Z", "event": "task_dispatched", "detail": "第 0 轮指令已确认送达 Codex", "data": { "round": 0 } },
|
|
46
|
+
{ "ts": "2026-09-24T11:06:41.201Z", "event": "file_modification_started", "detail": "停止按钮出现,Codex 开始执行(可能开始改动文件)" }
|
|
47
|
+
]
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
**② 文本区**(人直接看,不必解析 meta 块):
|
|
51
|
+
|
|
52
|
+
```
|
|
53
|
+
--- 最近事件(2 条,旧 → 新)---
|
|
54
|
+
[2026-09-24T11:06:26.056Z] task_dispatched — 第 0 轮指令已确认送达 Codex
|
|
55
|
+
[2026-09-24T11:06:41.201Z] file_modification_started — 停止按钮出现,Codex 开始执行(可能开始改动文件)
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
**向后兼容**:`recentEvents` 是新增的可选字段。未实现事件上报的适配器(以及所有 CLI 适配器)返回**空数组**,
|
|
59
|
+
文本区不出现事件段落,**其余字段与引入本能力之前完全一致**。
|
|
60
|
+
|
|
61
|
+
## 四、对适配器作者:如何让新适配器上报事件
|
|
62
|
+
|
|
63
|
+
上报是**可选能力**,实现与否由适配器自行决定:
|
|
64
|
+
|
|
65
|
+
```ts
|
|
66
|
+
import { makeEmitter } from "../agent-events.js";
|
|
67
|
+
|
|
68
|
+
async function runXxxTask(args: RunXxxArgs): Promise<AgentRunResult> {
|
|
69
|
+
const emit = makeEmitter(args.opts.onEvent);
|
|
70
|
+
// …
|
|
71
|
+
await emit("task_dispatched", "指令已送达", { round: ctx.round });
|
|
72
|
+
// …
|
|
73
|
+
}
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
要点:
|
|
77
|
+
|
|
78
|
+
1. 钩子挂在 `AgentRunOptions.onEvent`(**不是** agent profile —— `agent-profiles.json` 是纯 JSON,装不下函数,
|
|
79
|
+
硬塞进去会破坏 schema 解析与热重载)。
|
|
80
|
+
2. **一律经 `makeEmitter` 上报**。它把「未提供钩子」变成空操作,并吞掉上报过程中的异常 ——
|
|
81
|
+
事件上报属观测能力,**绝不能影响任务本体**。
|
|
82
|
+
3. 无需修改 `AgentProfileSchema`。「可选」由调用侧 `opts.onEvent?.(…)` 与 `makeEmitter` 共同表达,
|
|
83
|
+
未实现的适配器**一个字节都不用改**。
|
|
84
|
+
4. 当前真正上报事件的内置适配器:**codex**、**traework**(issue #18 的验收范围)。
|
|
85
|
+
zcode / kimicode / qoder 与全部 CLI 适配器保留接口、暂不上报。
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "tianshu-mcp",
|
|
3
|
-
"version": "0.6.
|
|
3
|
+
"version": "0.6.3",
|
|
4
4
|
"description": "天枢 × AI-Agent 编排 MCP server —— 驱动 Codex、TraeWork、ZCode、Kimi Code 与 Qoder CN 完成项目开发、验收、失败返修与再验收闭环。",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "Apache-2.0",
|
|
@@ -19,6 +19,8 @@
|
|
|
19
19
|
"dist",
|
|
20
20
|
"skills",
|
|
21
21
|
"assets",
|
|
22
|
+
"docs/event-stream.md",
|
|
23
|
+
"docs/event-stream.en.md",
|
|
22
24
|
"docs/visual-acceptance.md",
|
|
23
25
|
"docs/visual-acceptance.en.md",
|
|
24
26
|
"docs/visual-validation.md",
|