agentflowctl 0.12.0 → 0.13.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -2,6 +2,8 @@
2
2
 
3
3
  讓 Claude Code、Codex、Gemini CLI 等 agent 在同一個專案裡分工:整理需求、規劃、寫測試與程式、交叉審查,最後建立 PR。agentflowctl 負責推進流程,並用檔案、測試和檢查結果決定能否進到下一步。
4
4
 
5
+ 目前內建支援三種 LLM CLI:**Claude Code、Codex、Gemini CLI**。未來可擴充自定義 LLM adapter,讓其他模型供應商加入流程。目前若要串接其他 CLI,可使用 `command` adapter,自行提供執行命令;需要模型驗證時,也須提供探測命令。
6
+
5
7
  每次執行都會建立獨立的 git worktree 與 `flow/<id>` 分支,不會直接修改你目前的工作目錄。兩個 agent 就能運作;若只有一個,也能執行,但無法做到跨 agent 審查。
6
8
 
7
9
  ## 開始使用
@@ -46,11 +48,14 @@ agentflowctl list # 列出 run
46
48
  agentflowctl status f-xxxx # 看進度、結果與下一步
47
49
  agentflowctl logs f-xxxx # 列出各步驟的 log
48
50
  agentflowctl logs f-xxxx --latest # 看最新一份 log
51
+ agentflowctl stats f-xxxx # 各步驟耗時、執行與失敗次數
49
52
  agentflowctl resume f-xxxx # 從暫停、中斷或失敗處接續
50
53
  ```
51
54
 
52
55
  `status` 會列出目前階段、未結的交接事項與下一步指令;失敗或暫停時也會顯示原因。要看某一步的詳細輸出,可用 `logs <id> <編號>`;加 `--full` 看完整工具內容,或加 `--raw` 看原始輸出。
53
56
 
57
+ `stats` 依 log 的開始與結束時間統計每個步驟的執行次數、失敗次數、總耗時與最長一次,並分開列出 agent 與專案指令(install、測試、checks)各占多少時間,最耗時的步驟排在最前面。沒有結束紀錄的 log 列為未完成,不計入耗時;總經過時間包含暫停與等待核准。
58
+
54
59
  執行紀錄在 `.agentflowctl/runs/<id>/`,工作分支在 `.agentflowctl/worktrees/<id>/`。不再需要某次 run 時,可用 `agentflowctl clean <id>` 清除 worktree 與紀錄;`flow/<id>` 分支會保留。
55
60
 
56
61
  ## 執行停下來時怎麼做
@@ -127,13 +132,33 @@ agentflowctl model mode adaptive
127
132
  agentflowctl run --req-file ./requirement.md
128
133
  ```
129
134
 
130
- 把 `MODEL_NAME` 換成該 CLI 目前可呼叫的別名或完整 ID。`model add` 會用目前登入的帳號送出短請求,可能耗用少量 token;成功才寫入設定。需要重驗時執行 `model check`。用 `model set claude MODEL_NAME --strength medium` 改強度、`model remove claude MODEL_NAME` 移除模型,或用 `model stage taskReview high` 調整階段最低強度;`model stage` 不帶強度時列出各階段實際生效的強度。終端機每次呼叫會顯示送給 CLI 的模型名稱,Claude Code 與 Gemini CLI 回報的實際模型不同時也會顯示;`status <id>` 會按階段、任務、模型與步驟顯示用量。`run --model-mode balanced` 可暫時回到原設定。
135
+ 把 `MODEL_NAME` 換成該 CLI 目前可呼叫的別名或完整 ID。`model add` 會用目前登入的帳號送出短請求,可能耗用少量 token;成功才寫入設定。需要重驗時執行 `model check`。用 `model set claude MODEL_NAME --strength medium` 改強度、`model remove claude MODEL_NAME` 移除模型,或用 `model stage taskReview high` 調整階段最低強度;`model stage` 不帶強度時列出各階段實際生效的強度。終端機每次呼叫會顯示送給 CLI 的模型名稱;CLI 回報的實際模型不同時也會顯示(Claude Code 與 Gemini CLI 每次回報,Codex 只在模型被改派時回報);`status <id>` 會按階段、任務、模型與步驟顯示用量。`run --model-mode balanced` 可暫時回到原設定。
131
136
 
132
- `status <id>` 的用量以每次 LLM 呼叫為一筆,失敗、額度用完及代打也會計入呼叫次數。只有 CLI 同時回報輸入與輸出 token,才把兩者納入合計與模型強度占比;明確回報的 0 仍算已回報。缺少任一數字列為「未回報」;舊紀錄無法分辨真實 0 與預設補值,列為「舊紀錄不明」,原始數字只供查閱。各 agent、階段、任務、模型與步驟、模型強度是同一批呼叫的不同分組,不應跨組相加。`model add/check` 的探測請求可能耗用 token,但不屬於 run,因此不在 `status` 內。
137
+ `status <id>` 的用量以每次 LLM 呼叫為一筆,失敗、額度用完及代打也會計入呼叫次數。只有 CLI 同時回報輸入與輸出 token,才把兩者納入合計與模型強度占比;明確回報的 0 仍算已回報。缺少任一數字列為「未回報」;舊紀錄無法分辨真實 0 與預設補值,列為「舊紀錄不明」,原始數字只供查閱。各 agent、階段、任務、模型與步驟、模型強度是同一批呼叫的不同分組,不應跨組相加。輸入 token 一律包含 cache 讀取與寫入:Claude Code 回報的 `input_tokens` 不含 cache,agentflowctl 會把 cache 讀寫加回去;Codex 的 `input_tokens` 本來就包含 cache。有回報 cache 時,`status` 與 `logs` 會另外標出其中讀取與寫入 cache 各多少。`model add/check` 的探測請求可能耗用 token,但不屬於 run,因此不在 `status` 內。
133
138
 
134
139
  計畫 agent 會查閱相關程式碼,依影響範圍、技術不確定性與失敗後果為每個任務標註 `low`/`medium`/`high` 難度,取三者中最高等級,並在計畫中寫出依據;計畫審查會逐項核對。自動選模先遵守角色分配,再取階段強度與任務難度中較高者;失敗重試會提高強度。若分配到的 agent 沒有足夠強度的模型,會選它最強的模型並提示。這些強度是你對模型能力的設定,不由 CLI 自動評分。
135
140
 
136
- 模型探測必須能禁止工具。這版 Codex CLI 沒有可確認的無工具探測參數,因此 `model add` 無法驗證並登記 Codex 模型;手動寫入 `models` 仍可執行,但不代表已驗證可用。`balanced` 仍可照原方式使用。自訂 `command` adapter 另需提供會實際呼叫模型的探測命令;設定方式與限制見[詳細參考](docs/reference.md#模型設定與自動選模)。
141
+ #### 模型驗證
142
+
143
+ Claude Code、Codex 與 Gemini CLI 都使用專用探測流程:以 `--model` 指定模型,在獨立暫存目錄要求「只回答 OK」,不沿用正常工作時的 `extraArgs`。
144
+
145
+ | CLI | 探測時的工具限制 |
146
+ |---|---|
147
+ | Claude Code | `--tools ""` 停用內建工具,`--strict-mcp-config` 不載入外部 MCP,`--disable-slash-commands` 停用 slash commands |
148
+ | Codex | 唯讀沙箱與 `approval_policy="never"` 禁止寫入及權限升級;停用 shell、外部工具與 hooks,忽略使用者設定及 rules。仍可能有模型內建檔案工具,由唯讀沙箱限制 |
149
+ | Gemini CLI | admin/user policy 禁止所有工具(合法優先級 `999`),停用 extensions、MCP 與 hooks;政策載入問題使驗證失敗 |
150
+
151
+ 三家共用相同的通過條件:CLI 結束碼為 0、有非空文字與成功完成事件,且沒有工具嘗試或失敗事件。即使後來回覆成功,先前的失敗也不會被忽略。
152
+
153
+ 探測上限為 30 秒,結束後清除暫存目錄;macOS/Linux 逾時或按 Ctrl-C 中斷時會停止整個程序群組,包含 CLI 啟動的子程序。`model add` 成功才新增模型,失敗保持設定原狀;`model check` 只重驗、不修改設定。CLI 不支援探測參數(版本過舊或參數已改名)時直接失敗並提示更新 CLI,不會改用更寬鬆的權限重試。
154
+
155
+ Codex 另有幾點差異:
156
+ - 只寫在 stderr 的工具拒絕(例如唯讀沙箱擋下 patch)同樣算失敗。
157
+ - Codex 的 `error` item 是非致命通知,例如找不到模型 metadata 改用預設值、設定警告、棄用提示,不影響探測與 run 結果;`logs` 以 ⚠️ 顯示,不列入錯誤段落。真正的失敗是 `turn.failed` 與頂層 `error` 事件。
158
+ - Codex 只在模型被改派時回報實際模型,其餘情況只確認指定名稱可呼叫,不推測別名對應。
159
+ - `logs` 會顯示 Codex 每回合的完成事件,跨回合的 shell 指令分開整理。
160
+
161
+ 自訂 `command` adapter 另需提供會實際呼叫模型的探測命令;設定方式與各 CLI 已查核版本見[詳細參考](docs/reference.md#模型設定與自動選模)。
137
162
 
138
163
  ### 專案設定
139
164
 
@@ -59,8 +59,18 @@ export const claude = {
59
59
  }
60
60
  else if (ev.type === "result") {
61
61
  const usage = (ev.usage ?? {});
62
- if (num(usage.input_tokens) !== undefined || num(usage.output_tokens) !== undefined) {
63
- out.push({ kind: "usage", inputTokens: num(usage.input_tokens), outputTokens: num(usage.output_tokens) });
62
+ // Claude 的 input_tokens 不含 cache,要加回來才是實際送入量
63
+ const input = num(usage.input_tokens);
64
+ const cacheRead = num(usage.cache_read_input_tokens);
65
+ const cacheWrite = num(usage.cache_creation_input_tokens);
66
+ if (input !== undefined || num(usage.output_tokens) !== undefined) {
67
+ out.push({
68
+ kind: "usage",
69
+ inputTokens: input === undefined ? undefined : input + (cacheRead ?? 0) + (cacheWrite ?? 0),
70
+ outputTokens: num(usage.output_tokens),
71
+ ...(cacheRead !== undefined && { cacheReadTokens: cacheRead }),
72
+ ...(cacheWrite !== undefined && { cacheWriteTokens: cacheWrite }),
73
+ });
64
74
  }
65
75
  out.push({ kind: "done", ok: ev.is_error !== true, summary: str(ev.result) });
66
76
  }
@@ -1,4 +1,49 @@
1
1
  import { num, str, tryJson } from "./types.js";
2
+ /** 不是工具呼叫的 item;其餘 item(shell、MCP、搜尋、todo……)都當成工具 */
3
+ const NON_TOOL_ITEMS = new Set(["agent_message", "reasoning", "error"]);
4
+ function parse(line) {
5
+ const ev = tryJson(line);
6
+ if (!ev)
7
+ return [];
8
+ const out = [];
9
+ if (ev.type === "item.completed") {
10
+ const item = (ev.item ?? {});
11
+ if (item.type === "agent_message" && str(item.text)?.trim())
12
+ out.push({ kind: "text", text: str(item.text) });
13
+ else if (item.type === "command_execution")
14
+ out.push({ kind: "tool", name: "shell", detail: str(item.command) });
15
+ else if (item.type === "file_change") {
16
+ const changes = Array.isArray(item.changes) ? item.changes : [];
17
+ const paths = changes.map((c) => str(c.path)).filter(Boolean);
18
+ out.push({ kind: "tool", name: "edit", detail: paths.length ? paths.join(", ") : undefined });
19
+ }
20
+ else if (item.type === "error") {
21
+ // error item 是非致命通知(設定警告、棄用提示、模型改派);真正的失敗走 turn.failed 或頂層 error
22
+ const message = str(item.message) ?? "Codex 回報錯誤";
23
+ const rerouted = /^model rerouted: \S+ -> (\S+)/.exec(message)?.[1];
24
+ if (rerouted)
25
+ out.push({ kind: "model", id: rerouted });
26
+ out.push({ kind: "warning", message });
27
+ }
28
+ else if (typeof item.type === "string" && !NON_TOOL_ITEMS.has(item.type)) {
29
+ out.push({ kind: "tool", name: item.type, detail: str(item.tool) ?? str(item.query) });
30
+ }
31
+ }
32
+ else if (ev.type === "turn.completed") {
33
+ const usage = (ev.usage ?? {});
34
+ if (num(usage.input_tokens) !== undefined || num(usage.output_tokens) !== undefined) {
35
+ const cacheRead = num(usage.cached_input_tokens);
36
+ out.push({ kind: "usage", inputTokens: num(usage.input_tokens), outputTokens: num(usage.output_tokens),
37
+ ...(cacheRead !== undefined && { cacheReadTokens: cacheRead }) });
38
+ }
39
+ out.push({ kind: "done", ok: true });
40
+ }
41
+ else if (ev.type === "turn.failed" || ev.type === "error") {
42
+ const err = (ev.error ?? {});
43
+ out.push({ kind: "done", ok: false, summary: str(err.message) ?? str(ev.message) ?? "Codex 執行失敗" });
44
+ }
45
+ return out;
46
+ }
2
47
  /**
3
48
  * OpenAI Codex CLI:`codex exec --json`,prompt 由 stdin 傳入(`-`)。
4
49
  * workspace-write 沙箱只允許修改工作目錄,且預設不能連網,
@@ -6,39 +51,36 @@ import { num, str, tryJson } from "./types.js";
6
51
  */
7
52
  export const codex = {
8
53
  probe: () => ({ cmd: "codex", args: ["--version"] }),
9
- invoke: (o) => ({
54
+ // Codex 沒有 --tools 空清單;停用可執行工具,剩餘檔案工具由唯讀沙箱限制。
55
+ invokeModelProbe: (model, cwd) => ({
10
56
  cmd: "codex",
11
- args: ["exec", "--json", "--sandbox", "workspace-write", "-C", o.cwd, ...(o.model ? ["-m", o.model] : []), ...o.extraArgs, "-"],
12
- input: o.prompt,
57
+ args: ["exec", "--json", "--sandbox", "read-only", "--skip-git-repo-check", "--ephemeral",
58
+ "--ignore-user-config", "--ignore-rules", "--strict-config", "-C", cwd, "-m", model,
59
+ "-c", 'approval_policy="never"', "-c", 'web_search="disabled"', "-c", "mcp_servers={}",
60
+ "-c", "project_doc_max_bytes=0",
61
+ "-c", "tools.experimental_request_user_input.enabled=false", "-c", "tools.update_plan.enabled=false",
62
+ ...["shell_tool", "hooks", "apps", "plugins", "tool_suggest", "multi_agent", "multi_agent_v2",
63
+ "browser_use", "computer_use", "image_generation", "view_image", "code_mode", "code_mode_host",
64
+ "goals", "memories", "sleep_tool", "unbounded_connection_retries"].flatMap((feature) => ["--disable", feature]), "-"],
65
+ input: "只回答 OK,不要呼叫任何工具。",
13
66
  }),
14
- parse(line) {
67
+ // 探測也拒絕尚未完成的工具;一般 log 仍在 item.completed 時顯示結果。
68
+ parseModelProbe(line) {
69
+ const out = parse(line);
15
70
  const ev = tryJson(line);
16
- if (!ev)
17
- return [];
18
- const out = [];
19
- if (ev.type === "item.completed") {
20
- const item = (ev.item ?? {});
21
- if (item.type === "agent_message" && str(item.text)?.trim())
22
- out.push({ kind: "text", text: str(item.text) });
23
- else if (item.type === "command_execution")
24
- out.push({ kind: "tool", name: "shell", detail: str(item.command) });
25
- else if (item.type === "file_change") {
26
- const changes = Array.isArray(item.changes) ? item.changes : [];
27
- const paths = changes.map((c) => str(c.path)).filter(Boolean);
28
- out.push({ kind: "tool", name: "edit", detail: paths.length ? paths.join(", ") : undefined });
29
- }
30
- }
31
- else if (ev.type === "turn.completed") {
32
- const usage = (ev.usage ?? {});
33
- if (num(usage.input_tokens) !== undefined || num(usage.output_tokens) !== undefined) {
34
- out.push({ kind: "usage", inputTokens: num(usage.input_tokens), outputTokens: num(usage.output_tokens) });
35
- }
36
- }
37
- else if (ev.type === "turn.failed" || ev.type === "error") {
38
- const err = (ev.error ?? {});
39
- out.push({ kind: "done", ok: false, summary: str(err.message) ?? str(ev.message) ?? "Codex 執行失敗" });
71
+ const item = ev?.item;
72
+ if (ev?.type === "item.started" && typeof item?.type === "string" && !NON_TOOL_ITEMS.has(item.type)) {
73
+ out.push({ kind: "tool", name: item.type });
40
74
  }
41
75
  return out;
42
76
  },
77
+ // 拒絕 apply_patch 時可能只寫 stderr,沒有 file_change 事件。
78
+ modelProbeFailure: (stderr) => /patch rejected|tools::router.*error=/i.test(stderr) ? `模型檢查期間嘗試呼叫工具:${stderr.trim()}` : undefined,
79
+ invoke: (o) => ({
80
+ cmd: "codex",
81
+ args: ["exec", "--json", "--sandbox", "workspace-write", "-C", o.cwd, ...(o.model ? ["-m", o.model] : []), ...o.extraArgs, "-"],
82
+ input: o.prompt,
83
+ }),
84
+ parse,
43
85
  };
44
86
  //# sourceMappingURL=codex.js.map
@@ -1,4 +1,4 @@
1
- import { writeFileSync } from "node:fs";
1
+ import { mkdirSync, writeFileSync } from "node:fs";
2
2
  import { join } from "node:path";
3
3
  import { num, str, toolDetail, tryJson } from "./types.js";
4
4
  /**
@@ -11,11 +11,17 @@ export const gemini = {
11
11
  probe: () => ({ cmd: "gemini", args: ["--version"] }),
12
12
  invokeModelProbe: (model, cwd) => {
13
13
  const policy = join(cwd, "deny-tools.toml");
14
- writeFileSync(policy, '[[rule]]\ntoolName = "*"\ndecision = "deny"\npriority = 10000\n');
14
+ mkdirSync(join(cwd, ".gemini"), { recursive: true });
15
+ writeFileSync(join(cwd, ".gemini", "settings.json"), JSON.stringify({ hooksConfig: { enabled: false } }));
16
+ // priority 是 tier 內的 0–999;不能用 10000 嘗試跨 tier。
17
+ writeFileSync(policy, '[[rule]]\ntoolName = "*"\ndecision = "deny"\npriority = 999\n');
15
18
  return { cmd: "gemini", args: ["-p", "只回答 OK", "--model", model, "--output-format", "stream-json",
16
- "--approval-mode", "default", "--extensions", "none", "--policy", policy],
19
+ "--approval-mode", "default", "--extensions", "none", "--allowed-mcp-server-names", "",
20
+ "--admin-policy", policy, "--policy", policy],
17
21
  env: { GEMINI_CLI_TRUST_WORKSPACE: "true" } };
18
22
  },
23
+ modelProbeFailure: (stderr) => /ignoring --admin-policy|policy file (error|warning)|error loading policy|invalid policy|failed to load.*polic/i.test(stderr)
24
+ ? `CLI 未完整載入模型探測的工具限制:${stderr.trim()}` : undefined,
19
25
  invoke: (o) => ({
20
26
  cmd: "gemini",
21
27
  args: ["-p", o.prompt, "--output-format", "stream-json", "--approval-mode", "yolo", ...(o.model ? ["-m", o.model] : []), ...o.extraArgs],
@@ -41,7 +47,8 @@ export const gemini = {
41
47
  const outputTokens = num(stats.output_tokens) ?? num(stats.outputTokens);
42
48
  if (inputTokens !== undefined || outputTokens !== undefined)
43
49
  out.push({ kind: "usage", inputTokens, outputTokens });
44
- out.push({ kind: "done", ok: ev.status !== "error", summary: str(ev.response) });
50
+ const error = (ev.error ?? {});
51
+ out.push({ kind: "done", ok: ev.status !== "error", summary: str(error.message) ?? str(ev.response) });
45
52
  }
46
53
  else if (ev.type === "error") {
47
54
  out.push({ kind: "done", ok: false, summary: str(ev.message) ?? "Gemini 執行失敗" });
package/dist/cli.js CHANGED
@@ -13,6 +13,7 @@ import { describeDetected, detectProjectDefaults } from "./detect.js";
13
13
  import { CMD_AGENT, listLogs, localTime, logMark, nextLogFile, renderLog } from "./logs.js";
14
14
  import { flowDir, logDir, projectRoot, worktreeDir } from "./paths.js";
15
15
  import { ModelStage, ModelStrength, TaskList } from "./schemas.js";
16
+ import { computeStats, formatDuration } from "./stats.js";
16
17
  import { agentRuns, getRun, listRuns, listSubstitutions, saveRun, usageByAgent, usageByModelStage, usageByStage, usageByStrength, usageByTask } from "./store.js";
17
18
  import { readJsonFile } from "./util.js";
18
19
  import { openActions, readHandoff } from "./handoff.js";
@@ -22,12 +23,13 @@ import { addAgent, readRawConfig, removeAgent, setAgent, setCycle, writeRawConfi
22
23
  import { addModel, removeModel, setModelMode, setModelStrength, setStageStrength } from "./modelConfig.js";
23
24
  import { DEFAULT_STAGE_STRENGTH, effectiveStageStrengths, validateAdaptiveConfig } from "./modelSelection.js";
24
25
  import { probeModel } from "./modelProbe.js";
26
+ const cacheNote = (c) => c.cacheReadTokens || c.cacheWriteTokens ? `(含 cache 讀 ${c.cacheReadTokens}、寫 ${c.cacheWriteTokens})` : "";
25
27
  function printUsage(title, rows) {
26
28
  if (!rows.length)
27
29
  return;
28
30
  console.log(`\n${title}`);
29
31
  for (const [key, c] of rows) {
30
- const value = c.reportedRuns ? `輸入 ${c.inputTokens}、輸出 ${c.outputTokens}、合計 ${c.tokens} tokens` : "未回報或回報狀態不明";
32
+ const value = c.reportedRuns ? `輸入 ${c.inputTokens}${cacheNote(c)}、輸出 ${c.outputTokens}、合計 ${c.tokens} tokens` : "未回報或回報狀態不明";
31
33
  console.log(` ${key}: ${value};${c.runs} 次(未回報 ${c.unreportedRuns}、舊紀錄不明 ${c.legacyRuns})`);
32
34
  }
33
35
  }
@@ -236,7 +238,7 @@ program
236
238
  if (Object.keys(byAgent).length) {
237
239
  console.log("\n各 agent 用量");
238
240
  for (const [agent, c] of Object.entries(byAgent)) {
239
- console.log(` ${agent.padEnd(10)} ${String(c.runs).padStart(3)} 次 ${String(c.tokens).padStart(9)} 已回報 tokens(未回報 ${c.unreportedRuns}、舊紀錄不明 ${c.legacyRuns}${c.legacyTokens ? `,原始數字 ${c.legacyTokens} tokens` : ""})`);
241
+ console.log(` ${agent.padEnd(10)} ${String(c.runs).padStart(3)} 次 ${String(c.tokens).padStart(9)} 已回報 tokens${cacheNote(c)}(未回報 ${c.unreportedRuns}、舊紀錄不明 ${c.legacyRuns}${c.legacyTokens ? `,原始數字 ${c.legacyTokens} tokens` : ""})`);
240
242
  }
241
243
  }
242
244
  const byStage = usageByStage(id);
@@ -537,6 +539,27 @@ program
537
539
  const text = readFileSync(entry.file, "utf8");
538
540
  console.log(opts.raw ? text : renderLog(text, entry.file, { full: opts.full }));
539
541
  });
542
+ program
543
+ .command("stats <id>")
544
+ .description("依步驟統計耗時、執行次數與失敗次數,找出最花時間與最常重試的地方")
545
+ .action((id) => {
546
+ mustGetRun(id);
547
+ const stats = computeStats(listLogs(logDir(id)));
548
+ if (!stats.steps.length)
549
+ return console.log("還沒有 log");
550
+ const total = stats.agentMs + stats.cmdMs;
551
+ const share = (ms) => (total ? `${(ms / total * 100).toFixed(0)}%` : "-");
552
+ console.log(`總經過時間 ${formatDuration(stats.wallMs)}(含暫停與等待核准)`);
553
+ console.log(` agent ${formatDuration(stats.agentMs).padStart(7)} ${share(stats.agentMs)}`);
554
+ console.log(` 專案指令 ${formatDuration(stats.cmdMs).padStart(7)} ${share(stats.cmdMs)}`);
555
+ if (stats.unfinished)
556
+ console.log(` 未完成 ${stats.unfinished} 份(沒有結束紀錄,不計入耗時)`);
557
+ console.log("\n 步驟 類型 次數 失敗 總耗時 最長 占比");
558
+ for (const s of stats.steps) {
559
+ console.log(` ${s.step.padEnd(19)} ${s.kind === "cmd" ? "指令 " : "agent"} ${String(s.runs).padStart(4)} ${String(s.failed).padStart(4)} ${formatDuration(s.totalMs).padStart(7)} ${formatDuration(s.maxMs).padStart(7)} ${share(s.totalMs).padStart(5)}${s.unfinished ? ` (未完成 ${s.unfinished})` : ""}`);
560
+ }
561
+ console.log(`\n次數多或失敗多的步驟可用 agentflowctl logs ${id} 找出編號查看原因;token 用量見 agentflowctl status ${id}`);
562
+ });
540
563
  program.parseAsync().catch((err) => {
541
564
  console.error(`錯誤:${err.message}`);
542
565
  process.exit(1);
package/dist/engine.js CHANGED
@@ -70,7 +70,7 @@ async function agentStep(run, planned, step, prompt, mode) {
70
70
  info(run, ` ↳ CLI 回報實際模型:${r.resolvedModel}`);
71
71
  addUsage(run.id, { stage: step, agent, model: selected.name, resolvedModel: r.resolvedModel,
72
72
  strength: selected.strength, targetStrength: selected.targetStrength, usageReported: r.usageReported,
73
- inputTokens: r.inputTokens, outputTokens: r.outputTokens });
73
+ inputTokens: r.inputTokens, outputTokens: r.outputTokens, cacheReadTokens: r.cacheReadTokens, cacheWriteTokens: r.cacheWriteTokens });
74
74
  if (!r.quotaExhausted) {
75
75
  reportMeta(run, agent, r);
76
76
  return { r, agent, step, callKey };
package/dist/logs.js CHANGED
@@ -119,7 +119,9 @@ function renderEvent(ev, full, lastText) {
119
119
  return `🔧 ${ev.name}: ${full ? indent(detail) : compactDetail(detail)}`;
120
120
  }
121
121
  case "usage":
122
- return `📊 用量 input ${ev.inputTokens ?? "?"} / output ${ev.outputTokens ?? "?"} tokens`;
122
+ return `📊 用量 input ${ev.inputTokens ?? "?"} / output ${ev.outputTokens ?? "?"} tokens${ev.cacheReadTokens !== undefined || ev.cacheWriteTokens !== undefined ? `(input 含 cache 讀 ${ev.cacheReadTokens ?? 0}、寫 ${ev.cacheWriteTokens ?? 0})` : ""}`;
123
+ case "warning":
124
+ return `⚠️ ${indent(ev.message.trim())}`;
123
125
  case "done": {
124
126
  const summary = ev.summary?.trim();
125
127
  // 最後一則回覆通常就是 summary,精簡模式不再重印一次
@@ -4,6 +4,8 @@ import { join } from "node:path";
4
4
  import { ADAPTERS } from "./agents/index.js";
5
5
  import { exec } from "./proc.js";
6
6
  export function probeFailureReason(message) {
7
+ if (/unknown (option|argument|feature)|unexpected argument|unrecognized (option|argument)/i.test(message))
8
+ return `CLI 不支援模型探測參數(版本過舊或參數已改名),請更新 CLI 或回報:${message}`;
7
9
  if (/\b429\b|quota|rate.?limit|usage limit/i.test(message))
8
10
  return `額度或速率限制:${message}`;
9
11
  if (/invalid model|model.*(not found|unknown|unavailable)|unknown model/i.test(message))
@@ -14,7 +16,7 @@ export function probeFailureReason(message) {
14
16
  return `網路連線失敗:${message}`;
15
17
  return message;
16
18
  }
17
- /** 在獨立暫存目錄對指定模型送出最短請求;安全能力不足時直接拒絕。 */
19
+ /** 在獨立暫存目錄對指定模型送出最短請求;停用工具或限制成唯讀,工具事件一律不通過。 */
18
20
  export async function probeModel(def, model, timeoutMs = 30000) {
19
21
  const dir = mkdtempSync(join(tmpdir(), "agentflowctl-model-"));
20
22
  try {
@@ -33,13 +35,17 @@ export async function probeModel(def, model, timeoutMs = 30000) {
33
35
  inv = adapter.invokeModelProbe(model, dir);
34
36
  }
35
37
  const events = [];
38
+ const parseLine = adapter.parseModelProbe ?? adapter.parse;
36
39
  const result = await exec(inv.cmd, inv.args, {
37
40
  cwd: dir, env: inv.env, input: inv.input, timeoutMs,
38
41
  onStdoutLine: (line) => { if (def.adapter !== "command")
39
- events.push(...adapter.parse(line)); },
42
+ events.push(...parseLine(line)); },
40
43
  });
41
44
  if (result.code !== 0)
42
45
  return { status: "failed", reason: result.code === 124 ? "模型檢查逾時" : probeFailureReason(result.stderr.trim() || result.stdout.trim() || `結束碼 ${result.code}`) };
46
+ const diagnostic = adapter.modelProbeFailure?.(result.stderr);
47
+ if (diagnostic)
48
+ return { status: "failed", reason: diagnostic };
43
49
  if (def.adapter === "command") {
44
50
  try {
45
51
  const data = JSON.parse(result.stdout.trim());
@@ -54,11 +60,12 @@ export async function probeModel(def, model, timeoutMs = 30000) {
54
60
  }
55
61
  if (events.some((e) => e.kind === "tool"))
56
62
  return { status: "failed", reason: "模型檢查期間出現工具呼叫,未通過無工具驗證" };
63
+ const failed = events.find((e) => e.kind === "done" && !e.ok);
64
+ if (failed?.kind === "done")
65
+ return { status: "failed", reason: probeFailureReason(failed.summary ?? "CLI 回報失敗") };
57
66
  const done = events.filter((e) => e.kind === "done").at(-1);
58
67
  if (done?.kind !== "done")
59
68
  return { status: "failed", reason: "CLI 沒有回報完成" };
60
- if (!done.ok)
61
- return { status: "failed", reason: done.summary ?? "CLI 回報失敗" };
62
69
  if (!events.some((e) => e.kind === "text" && e.text.trim()))
63
70
  return { status: "failed", reason: "CLI 沒有回傳文字內容" };
64
71
  const resolved = events.find((e) => e.kind === "model");
package/dist/proc.js CHANGED
@@ -1,13 +1,45 @@
1
1
  import { spawn } from "node:child_process";
2
2
  export function exec(cmd, args, opts = {}) {
3
3
  return new Promise((resolve, reject) => {
4
+ const processGroup = Boolean(opts.timeoutMs) && process.platform !== "win32";
4
5
  const child = spawn(cmd, args, {
5
6
  cwd: opts.cwd,
6
7
  env: { ...process.env, ...opts.env },
7
8
  shell: opts.shell ?? false,
9
+ detached: processGroup,
8
10
  });
11
+ // npm 安裝的 CLI 常再啟動原生執行檔;只殺啟動器會留下持續呼叫模型的程序。
12
+ const killTree = () => {
13
+ if (processGroup && child.pid) {
14
+ try {
15
+ process.kill(-child.pid, "SIGKILL");
16
+ }
17
+ catch {
18
+ child.kill("SIGKILL");
19
+ }
20
+ }
21
+ else
22
+ child.kill("SIGKILL");
23
+ };
24
+ // 獨立程序群組收不到終端機的 Ctrl-C,要自己轉達;處理完再把訊號交回原本的處理方式。
25
+ const onSignal = (signal) => {
26
+ killTree();
27
+ detachSignals();
28
+ if (!process.listenerCount(signal))
29
+ process.kill(process.pid, signal);
30
+ };
31
+ const detachSignals = () => {
32
+ process.off("SIGINT", onSignal);
33
+ process.off("SIGTERM", onSignal);
34
+ process.off("exit", killTree);
35
+ };
36
+ if (processGroup) {
37
+ process.once("SIGINT", onSignal);
38
+ process.once("SIGTERM", onSignal);
39
+ process.once("exit", killTree);
40
+ }
9
41
  let timedOut = false;
10
- const timer = opts.timeoutMs ? setTimeout(() => { timedOut = true; child.kill("SIGKILL"); }, opts.timeoutMs) : undefined;
42
+ const timer = opts.timeoutMs ? setTimeout(() => { timedOut = true; killTree(); }, opts.timeoutMs) : undefined;
11
43
  let stdout = "";
12
44
  let stderr = "";
13
45
  let pending = "";
@@ -25,10 +57,11 @@ export function exec(cmd, args, opts = {}) {
25
57
  stderr += d.toString();
26
58
  });
27
59
  child.on("error", (error) => { if (timer)
28
- clearTimeout(timer); reject(error); });
60
+ clearTimeout(timer); detachSignals(); reject(error); });
29
61
  child.on("close", (code) => {
30
62
  if (timer)
31
63
  clearTimeout(timer);
64
+ detachSignals();
32
65
  if (opts.onStdoutLine && pending)
33
66
  opts.onStdoutLine(pending);
34
67
  resolve({ code: timedOut ? 124 : code ?? 1, stdout, stderr: timedOut ? `${stderr}\n執行逾時` : stderr });
package/dist/runner.js CHANGED
@@ -70,6 +70,8 @@ export async function runAgent(name, def, t, prompt) {
70
70
  let outputTokens = 0;
71
71
  let inputReported = false;
72
72
  let outputReported = false;
73
+ let cacheReadTokens;
74
+ let cacheWriteTokens;
73
75
  let resolvedModel;
74
76
  const r = await exec(inv.cmd, inv.args, {
75
77
  cwd: t.cwd,
@@ -98,6 +100,10 @@ export async function runAgent(name, def, t, prompt) {
98
100
  outputReported = true;
99
101
  outputTokens += ev.outputTokens;
100
102
  }
103
+ if (ev.cacheReadTokens !== undefined)
104
+ cacheReadTokens = (cacheReadTokens ?? 0) + ev.cacheReadTokens;
105
+ if (ev.cacheWriteTokens !== undefined)
106
+ cacheWriteTokens = (cacheWriteTokens ?? 0) + ev.cacheWriteTokens;
101
107
  }
102
108
  else if (ev.kind === "model") {
103
109
  resolvedModel = ev.id;
@@ -118,7 +124,7 @@ export async function runAgent(name, def, t, prompt) {
118
124
  const quotaExhausted = !ok && isQuotaError(`${summary}\n${done?.summary ?? ""}\n${r.stderr}\n${tail(r.stdout, 4000)}`);
119
125
  const meta = parseResultMeta(summary) ?? parseResultMeta(lastText);
120
126
  return { ok, quotaExhausted, summary, meta, usageReported: inputReported && outputReported,
121
- inputTokens: inputReported ? inputTokens : undefined, outputTokens: outputReported ? outputTokens : undefined, resolvedModel };
127
+ inputTokens: inputReported ? inputTokens : undefined, outputTokens: outputReported ? outputTokens : undefined, cacheReadTokens, cacheWriteTokens, resolvedModel };
122
128
  }
123
129
  /**
124
130
  * 在 worktree 內執行專案指令(安裝、測試、建置)。
package/dist/stats.js ADDED
@@ -0,0 +1,53 @@
1
+ import { CMD_AGENT } from "./logs.js";
2
+ const time = (iso) => (iso ? new Date(iso).getTime() : NaN);
3
+ /** 從 log 的檔頭與檔尾算出每個步驟的次數、失敗與耗時,不讀 log 內容 */
4
+ export function computeStats(entries) {
5
+ const byKey = new Map();
6
+ let agentMs = 0;
7
+ let cmdMs = 0;
8
+ let unfinished = 0;
9
+ let first = Infinity;
10
+ let last = -Infinity;
11
+ for (const { header, footer } of entries) {
12
+ if (!header)
13
+ continue;
14
+ const kind = header.agent === CMD_AGENT ? "cmd" : "agent";
15
+ const key = `${kind}:${header.step}`;
16
+ const s = byKey.get(key) ?? { step: header.step, kind, runs: 0, failed: 0, unfinished: 0, totalMs: 0, maxMs: 0 };
17
+ byKey.set(key, s);
18
+ s.runs += 1;
19
+ const start = time(header.startedAt);
20
+ const end = time(footer?.endedAt);
21
+ if (!footer || Number.isNaN(start) || Number.isNaN(end)) {
22
+ s.unfinished += 1;
23
+ unfinished += 1;
24
+ continue;
25
+ }
26
+ if (!footer.ok)
27
+ s.failed += 1;
28
+ const ms = Math.max(0, end - start);
29
+ s.totalMs += ms;
30
+ s.maxMs = Math.max(s.maxMs, ms);
31
+ if (kind === "cmd")
32
+ cmdMs += ms;
33
+ else
34
+ agentMs += ms;
35
+ first = Math.min(first, start);
36
+ last = Math.max(last, end);
37
+ }
38
+ const steps = [...byKey.values()].sort((a, b) => b.totalMs - a.totalMs);
39
+ return { steps, agentMs, cmdMs, wallMs: last > first ? last - first : 0, unfinished };
40
+ }
41
+ /** 毫秒轉成 1h02m、3m05s、12s */
42
+ export function formatDuration(ms) {
43
+ const sec = Math.round(ms / 1000);
44
+ const h = Math.floor(sec / 3600);
45
+ const m = Math.floor((sec % 3600) / 60);
46
+ const s = sec % 60;
47
+ if (h)
48
+ return `${h}h${String(m).padStart(2, "0")}m`;
49
+ if (m)
50
+ return `${m}m${String(s).padStart(2, "0")}s`;
51
+ return `${s}s`;
52
+ }
53
+ //# sourceMappingURL=stats.js.map
package/dist/store.js CHANGED
@@ -35,7 +35,7 @@ export function addUsage(id, entry) {
35
35
  mkdirSync(runDir(id), { recursive: true });
36
36
  appendFileSync(usagePath(id), `${JSON.stringify({ at: new Date().toISOString(), ...entry })}\n`);
37
37
  }
38
- const emptySummary = () => ({ tokens: 0, inputTokens: 0, outputTokens: 0, runs: 0, reportedRuns: 0, unreportedRuns: 0, legacyRuns: 0, legacyTokens: 0 });
38
+ const emptySummary = () => ({ tokens: 0, inputTokens: 0, outputTokens: 0, cacheReadTokens: 0, cacheWriteTokens: 0, runs: 0, reportedRuns: 0, unreportedRuns: 0, legacyRuns: 0, legacyTokens: 0 });
39
39
  export function listUsage(id) {
40
40
  const p = usagePath(id);
41
41
  return existsSync(p) ? readFileSync(p, "utf8").split("\n").filter(Boolean).map((line) => JSON.parse(line)) : [];
@@ -47,6 +47,8 @@ function addSummary(acc, e) {
47
47
  acc.inputTokens += e.inputTokens ?? 0;
48
48
  acc.outputTokens += e.outputTokens ?? 0;
49
49
  acc.tokens += (e.inputTokens ?? 0) + (e.outputTokens ?? 0);
50
+ acc.cacheReadTokens += e.cacheReadTokens ?? 0;
51
+ acc.cacheWriteTokens += e.cacheWriteTokens ?? 0;
50
52
  }
51
53
  else if (e.usageReported === false || e.usageReported === true)
52
54
  acc.unreportedRuns += 1;
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "agentflowctl",
3
3
  "license": "MIT",
4
- "version": "0.12.0",
4
+ "version": "0.13.1",
5
5
  "description": "跨廠商 AI 開發 harness:Claude Code、Codex、Gemini 輪流實作、審查、修正",
6
6
  "keywords": [
7
7
  "ai",