agentflowctl 0.13.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 +24 -2
- package/dist/agents/codex.js +70 -30
- package/dist/agents/gemini.js +11 -4
- package/dist/logs.js +2 -0
- package/dist/modelProbe.js +11 -4
- package/dist/proc.js +35 -2
- package/package.json +1 -1
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
|
## 開始使用
|
|
@@ -130,13 +132,33 @@ agentflowctl model mode adaptive
|
|
|
130
132
|
agentflowctl run --req-file ./requirement.md
|
|
131
133
|
```
|
|
132
134
|
|
|
133
|
-
把 `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
|
|
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` 可暫時回到原設定。
|
|
134
136
|
|
|
135
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` 內。
|
|
136
138
|
|
|
137
139
|
計畫 agent 會查閱相關程式碼,依影響範圍、技術不確定性與失敗後果為每個任務標註 `low`/`medium`/`high` 難度,取三者中最高等級,並在計畫中寫出依據;計畫審查會逐項核對。自動選模先遵守角色分配,再取階段強度與任務難度中較高者;失敗重試會提高強度。若分配到的 agent 沒有足夠強度的模型,會選它最強的模型並提示。這些強度是你對模型能力的設定,不由 CLI 自動評分。
|
|
138
140
|
|
|
139
|
-
|
|
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#模型設定與自動選模)。
|
|
140
162
|
|
|
141
163
|
### 專案設定
|
|
142
164
|
|
package/dist/agents/codex.js
CHANGED
|
@@ -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,41 +51,36 @@ import { num, str, tryJson } from "./types.js";
|
|
|
6
51
|
*/
|
|
7
52
|
export const codex = {
|
|
8
53
|
probe: () => ({ cmd: "codex", args: ["--version"] }),
|
|
9
|
-
|
|
54
|
+
// Codex 沒有 --tools 空清單;停用可執行工具,剩餘檔案工具由唯讀沙箱限制。
|
|
55
|
+
invokeModelProbe: (model, cwd) => ({
|
|
10
56
|
cmd: "codex",
|
|
11
|
-
args: ["exec", "--json", "--sandbox", "
|
|
12
|
-
|
|
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
|
-
|
|
67
|
+
// 探測也拒絕尚未完成的工具;一般 log 仍在 item.completed 時顯示結果。
|
|
68
|
+
parseModelProbe(line) {
|
|
69
|
+
const out = parse(line);
|
|
15
70
|
const ev = tryJson(line);
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
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
|
-
const cacheRead = num(usage.cached_input_tokens);
|
|
35
|
-
out.push({ kind: "usage", inputTokens: num(usage.input_tokens), outputTokens: num(usage.output_tokens),
|
|
36
|
-
...(cacheRead !== undefined && { cacheReadTokens: cacheRead }) });
|
|
37
|
-
}
|
|
38
|
-
}
|
|
39
|
-
else if (ev.type === "turn.failed" || ev.type === "error") {
|
|
40
|
-
const err = (ev.error ?? {});
|
|
41
|
-
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 });
|
|
42
74
|
}
|
|
43
75
|
return out;
|
|
44
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,
|
|
45
85
|
};
|
|
46
86
|
//# sourceMappingURL=codex.js.map
|
package/dist/agents/gemini.js
CHANGED
|
@@ -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
|
-
|
|
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", "--
|
|
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
|
-
|
|
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/logs.js
CHANGED
|
@@ -120,6 +120,8 @@ function renderEvent(ev, full, lastText) {
|
|
|
120
120
|
}
|
|
121
121
|
case "usage":
|
|
122
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,精簡模式不再重印一次
|
package/dist/modelProbe.js
CHANGED
|
@@ -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(...
|
|
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;
|
|
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 });
|