thincoder 0.12.58 → 0.12.60
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.md +78 -2
- package/README.md +3 -3
- package/bin/thincoder.mjs +88 -19
- package/package.json +4 -3
- package/src/acp/bridge.mjs +135 -26
- package/src/advisor/messages.mjs +57 -4
- package/src/advisor/run.mjs +119 -79
- package/src/advisor.mjs +34 -7
- package/src/agent/completion.mjs +17 -11
- package/src/agent/dispatch.mjs +182 -22
- package/src/agent/helpers.mjs +71 -4
- package/src/agent/record-results.mjs +46 -10
- package/src/agent/run-stages.mjs +227 -0
- package/src/agent/setup-reminders.mjs +62 -0
- package/src/agent/setup.mjs +107 -20
- package/src/agent/spawn-child.mjs +54 -4
- package/src/agent-tools/advisor-async.mjs +456 -0
- package/src/agent-tools/advisor.mjs +133 -109
- package/src/agent-tools/async-settle.mjs +191 -0
- package/src/agent-tools/consult.mjs +154 -104
- package/src/agent-tools/design-token.mjs +104 -0
- package/src/agent-tools/eng.mjs +26 -30
- package/src/agent-tools/escalate-async.mjs +286 -0
- package/src/agent-tools/goal.mjs +11 -1
- package/src/agent-tools/read-history.mjs +284 -0
- package/src/agent-tools/recent-changes.mjs +2 -1
- package/src/agent-tools/settings.mjs +152 -0
- package/src/agent-tools/skill.mjs +2 -1
- package/src/agent-tools/subagent-actions.mjs +470 -0
- package/src/agent-tools/subagent-async.mjs +382 -0
- package/src/agent-tools/subagent-panel.mjs +153 -0
- package/src/agent-tools/subagent-run.mjs +202 -0
- package/src/agent-tools/subagent-scheduler.mjs +343 -0
- package/src/agent-tools/subagent-spawn.mjs +406 -0
- package/src/agent-tools/subagent.mjs +203 -377
- package/src/agent-tools/task.mjs +4 -3
- package/src/agent-tools/timer.mjs +9 -4
- package/src/agent-tools/verify.mjs +198 -238
- package/src/agent-tools.mjs +1 -0
- package/src/agent.mjs +145 -242
- package/src/auto-think.mjs +14 -0
- package/src/cli/distill-command.mjs +10 -4
- package/src/cli/make-agent.mjs +4 -1
- package/src/cli/memory-command.mjs +2 -1
- package/src/cli/permission.mjs +8 -1
- package/src/cli/setup-wizard.mjs +17 -12
- package/src/config.mjs +61 -8
- package/src/context.mjs +81 -163
- package/src/crash-reports.mjs +123 -0
- package/src/distill.mjs +30 -12
- package/src/escape.mjs +6 -4
- package/src/explore-distill.mjs +155 -0
- package/src/log.mjs +195 -0
- package/src/memory/code-sync.mjs +2 -1
- package/src/memory/core.mjs +11 -72
- package/src/memory/delete.mjs +234 -0
- package/src/memory/docs.mjs +206 -87
- package/src/memory.mjs +3 -1
- package/src/model-specs.mjs +15 -1
- package/src/peer-domains.mjs +265 -0
- package/src/peer-instances.mjs +231 -0
- package/src/prompt-overlays.mjs +25 -0
- package/src/prompts/advisor-design.md +18 -39
- package/src/prompts/advisor-round1.md +20 -32
- package/src/prompts/advisor-round2.md +16 -16
- package/src/prompts/advisor-round3.md +16 -16
- package/src/prompts/coder.md +7 -28
- package/src/prompts/consult-base.md +4 -11
- package/src/prompts/discipline.md +31 -44
- package/src/prompts/eng-coder.md +9 -34
- package/src/prompts/engineering-sub.md +10 -8
- package/src/prompts/engineering.md +61 -264
- package/src/prompts/explore.md +4 -14
- package/src/prompts/main.md +18 -35
- package/src/prompts/methodology-template.md +32 -38
- package/src/prompts/plan.md +2 -9
- package/src/prompts/system.md +18 -35
- package/src/provider/core.mjs +62 -69
- package/src/provider/errors.mjs +76 -0
- package/src/provider/retry.mjs +8 -45
- package/src/session-gc.mjs +214 -0
- package/src/session-guard.mjs +47 -0
- package/src/session-rename.mjs +38 -0
- package/src/session-slots.mjs +181 -58
- package/src/session.mjs +48 -89
- package/src/token-ttl.mjs +273 -0
- package/src/tools/apply_patch.md +3 -1
- package/src/tools/bash.md +1 -1
- package/src/tools/checklist-sync.mjs +181 -0
- package/src/tools/checklist.mjs +52 -39
- package/src/tools/delete.md +1 -0
- package/src/tools/edit-batch.mjs +131 -44
- package/src/tools/edit-diff.mjs +348 -0
- package/src/tools/edit.md +20 -13
- package/src/tools/execute.md +7 -7
- package/src/tools/execute.mjs +55 -24
- package/src/tools/file.mjs +25 -70
- package/src/tools/file_ops.md +2 -1
- package/src/tools/get_current_time.md +3 -1
- package/src/tools/git.mjs +14 -6
- package/src/tools/glob-dialect.mjs +130 -0
- package/src/tools/glob.md +3 -3
- package/src/tools/grep.md +1 -1
- package/src/tools/hashline_edit.md +2 -0
- package/src/tools/index.mjs +3 -3
- package/src/tools/insert_after.md +2 -1
- package/src/tools/lint.md +2 -0
- package/src/tools/lsp.md +4 -1
- package/src/tools/ops.mjs +175 -3
- package/src/tools/patch.mjs +84 -13
- package/src/tools/question.md +5 -1
- package/src/tools/repomap.mjs +1 -1
- package/src/tools/shared.mjs +18 -25
- package/src/tools/system.mjs +50 -30
- package/src/tools/tree.md +2 -1
- package/src/tools/wait_for.md +22 -0
- package/src/tools/web.mjs +5 -3
- package/src/tools/websearch.md +2 -1
- package/src/tools/write.md +2 -0
- package/src/traces/trace-store.mjs +224 -0
- package/src/tui/agent-turn.mjs +179 -27
- package/src/tui/clipboard.mjs +15 -4
- package/src/tui/cmd-config.mjs +77 -16
- package/src/tui/cmd-eng.mjs +20 -16
- package/src/tui/cmd-extract.mjs +1 -1
- package/src/tui/cmd-mcp.mjs +17 -2
- package/src/tui/cmd-new.mjs +3 -2
- package/src/tui/cmd-session.mjs +19 -4
- package/src/tui/cmd-think.mjs +11 -11
- package/src/tui/cmd-upgrade.mjs +19 -4
- package/src/tui/config-helpers.mjs +28 -16
- package/src/tui/distill-cmd.mjs +1 -1
- package/src/tui/index.mjs +31 -96
- package/src/tui/interaction.mjs +13 -2
- package/src/tui/key-handler.mjs +105 -155
- package/src/tui/key-modes.mjs +215 -0
- package/src/tui/layout.mjs +22 -1
- package/src/tui/mouse.mjs +46 -0
- package/src/tui/pickers.mjs +51 -25
- package/src/tui/render-conversation.mjs +13 -161
- package/src/tui/render-frame.mjs +27 -10
- package/src/tui/render-loop.mjs +4 -1
- package/src/tui/render-segments.mjs +182 -0
- package/src/tui/startup.mjs +40 -0
- package/src/tui/subagent-blocks.mjs +272 -262
- package/src/tui/subagent-children.mjs +176 -0
- package/src/tui/subagent-freeze.mjs +172 -0
- package/src/tui/subagent-panel.mjs +125 -12
- package/src/tui/suspension-drive.mjs +351 -0
- package/src/tui/tool-args.mjs +10 -2
- package/src/tui/tool-display.mjs +142 -0
- package/src/tui/tool-events.mjs +127 -231
- package/src/tui/tui-lifecycle.mjs +29 -0
- package/src/tui/update-notice.mjs +76 -0
- package/src/tui/wizard.mjs +48 -12
- package/src/agent-tools/escalate.mjs +0 -179
- package/src/agent-tools/subagent-check.mjs +0 -107
- package/src/tools/exec-prelude.mjs +0 -84
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* settings tool — agent-side configuration adjustment (SETTINGS-TOOL.md, 2026-09-05).
|
|
3
|
+
* 用户裁定:全量 config.json 任意键(点分路径)+ 单工具多动作 list/get/set + 双端同批。
|
|
4
|
+
* 语义:set = 写盘(config.json——磁盘真相最小化:只写被设的键——默认值不固化)+
|
|
5
|
+
* 热应用(ctx.agent.config 内存对象立即更新——回合边界键下回合生效)。
|
|
6
|
+
* 护栏:敏感键(路径段含 apiKey/key/token/secret/password)回显/错误文本永不出现明文
|
|
7
|
+
* (••••(masked)——防密钥泄漏进会话历史/trace);已知键类型校验(类型表自动派生自
|
|
8
|
+
* config.mjs DEFAULTS——不手写防漂移);set 侧效走审批门(dispatch 动作级分类)。
|
|
9
|
+
*/
|
|
10
|
+
import { DEFAULTS, configPath, writeConfigAtomic } from "../config.mjs"
|
|
11
|
+
|
|
12
|
+
/** 敏感键段判定(完整点分路径的段级匹配——apiKey/api_key/api-key/token/secret/password 形态) */
|
|
13
|
+
const SENSITIVE_SEGMENT = /(^|[._-])(api[_-]?key|key|token|secret|password)($|[._-])/i
|
|
14
|
+
const MASKED = "••••(masked)"
|
|
15
|
+
|
|
16
|
+
function isSensitiveKey(path) {
|
|
17
|
+
return SENSITIVE_SEGMENT.test(path)
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* 类型表:递归遍历 DEFAULTS——叶子(非对象/数组值)记 `路径 → typeof 默认值`。
|
|
22
|
+
* 数组(providersList 等)不递归(下标元素无类型约束——全量域);对象节递归到叶子。
|
|
23
|
+
*/
|
|
24
|
+
function buildTypeMap(obj, prefix = "", out = {}) {
|
|
25
|
+
for (const [k, v] of Object.entries(obj)) {
|
|
26
|
+
const p = prefix ? `${prefix}.${k}` : k
|
|
27
|
+
if (v !== null && typeof v === "object" && !Array.isArray(v)) buildTypeMap(v, p, out)
|
|
28
|
+
else out[p] = Array.isArray(v) ? "array" : typeof v
|
|
29
|
+
}
|
|
30
|
+
return out
|
|
31
|
+
}
|
|
32
|
+
const TYPE_MAP = buildTypeMap(DEFAULTS)
|
|
33
|
+
|
|
34
|
+
/** 点分路径解析(含数组数字段)——逐段下钻;返回 { ok, value } 或缺失段信息 */
|
|
35
|
+
function resolvePath(obj, path) {
|
|
36
|
+
const segs = String(path).split(".")
|
|
37
|
+
let cur = obj
|
|
38
|
+
for (let i = 0; i < segs.length; i++) {
|
|
39
|
+
if (cur === null || typeof cur !== "object" || !(segs[i] in cur)) {
|
|
40
|
+
return { ok: false, reached: segs.slice(0, i).join("."), missing: segs[i], depth: i }
|
|
41
|
+
}
|
|
42
|
+
cur = cur[segs[i]]
|
|
43
|
+
}
|
|
44
|
+
return { ok: true, value: cur }
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** 点分路径写入(自动建中间对象——cmd-config 数值项同款);返回旧值 */
|
|
48
|
+
function setKeyPath(obj, path, value) {
|
|
49
|
+
const segs = String(path).split(".")
|
|
50
|
+
let cur = obj
|
|
51
|
+
for (let i = 0; i < segs.length - 1; i++) {
|
|
52
|
+
if (cur[segs[i]] === null || typeof cur[segs[i]] !== "object") cur[segs[i]] = {}
|
|
53
|
+
cur = cur[segs[i]]
|
|
54
|
+
}
|
|
55
|
+
const last = segs[segs.length - 1]
|
|
56
|
+
const old = cur[last]
|
|
57
|
+
cur[last] = value
|
|
58
|
+
return old
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** 递归展平 config 对象(数组下标段形态)→ 排序的 { path, value } 列表 */
|
|
62
|
+
function flatten(obj, prefix = "", out = []) {
|
|
63
|
+
for (const [k, v] of Object.entries(obj)) {
|
|
64
|
+
const p = prefix ? `${prefix}.${k}` : k
|
|
65
|
+
if (v !== null && typeof v === "object") flatten(v, p, out)
|
|
66
|
+
else out.push({ path: p, value: v })
|
|
67
|
+
}
|
|
68
|
+
return out.sort((a, b) => (a.path < b.path ? -1 : 1))
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** 行格式化:`key = value (type)`——敏感键值遮罩 */
|
|
72
|
+
function formatLine({ path, value }) {
|
|
73
|
+
const shown = isSensitiveKey(path) ? MASKED : value
|
|
74
|
+
return `${path} = ${shown} (${Array.isArray(value) ? "array" : typeof value})`
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function parseValue(raw) {
|
|
78
|
+
const s = String(raw)
|
|
79
|
+
try {
|
|
80
|
+
const v = JSON.parse(s)
|
|
81
|
+
// JSON 顶层标量都接受;解析失败(裸字符串)→ 字符串字面
|
|
82
|
+
if (typeof v === "number" || typeof v === "boolean" || v === null || Array.isArray(v) || typeof v === "object") return v
|
|
83
|
+
return s // "abc"(带引号传的字符串)——JSON.parse 成功但返回字符串——保持解析结果
|
|
84
|
+
} catch {
|
|
85
|
+
return s // 裸 abc → 字符串字面
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export function settingsTool(opts = {}) {
|
|
90
|
+
const cfgPath = opts.configPath ?? configPath // 测试注入 tmp 文件;默认全局 configPath
|
|
91
|
+
|
|
92
|
+
return {
|
|
93
|
+
name: "settings",
|
|
94
|
+
description:
|
|
95
|
+
"Adjust ThinCoder runtime configuration — persisted to config.json AND hot-applied to the live agent config.\n" +
|
|
96
|
+
"Actions: list (all keys + values, flattened) | get <key> | set <key> <value> — dot paths into config.json (agent.maxTurns, traces.enabled, providers.0.model, any nesting).\n" +
|
|
97
|
+
"set persists to disk (only the set key is written — defaults are never baked in) and takes effect in the running session immediately (turn-boundary keys apply next turn); the value survives restarts.\n" +
|
|
98
|
+
"Known keys are type-checked against the built-in defaults (agent.maxTurns must be a number, traces.enabled a boolean); unknown keys under a known section are stored as given. Values parse as JSON first (true/false/numbers/objects/arrays), else stay strings.\n" +
|
|
99
|
+
"SENSITIVE keys (path segment contains apiKey/key/token/secret/password) are NEVER echoed in plaintext — list/get/set replies show ••••(masked); setting a sensitive key is allowed and stored, but never echoed back.\n" +
|
|
100
|
+
"list/get are read-only (planMode ok, no approval); set is a side effect (approval gate). The /config TUI command is the human equivalent.",
|
|
101
|
+
parameters: {
|
|
102
|
+
type: "object",
|
|
103
|
+
properties: {
|
|
104
|
+
action: { type: "string", enum: ["list", "get", "set"], description: "list — full key/value inventory; get <key> — one value; set <key> <value> — persist + hot-apply" },
|
|
105
|
+
key: { type: "string", description: "Dot path into config.json, e.g. agent.maxTurns / traces.enabled / providers.0.model" },
|
|
106
|
+
value: { type: "string", description: "New value for set — parsed as JSON first (true/5/{...}/[...]), otherwise kept as string" },
|
|
107
|
+
},
|
|
108
|
+
required: ["action"],
|
|
109
|
+
},
|
|
110
|
+
readonly: false, // set 是侧效——dispatch 动作级分类放行 list/get
|
|
111
|
+
async execute(args, ctx) {
|
|
112
|
+
const action = args?.action
|
|
113
|
+
if (!["list", "get", "set"].includes(action)) throw new Error(`settings: action must be one of list/get/set — got ${JSON.stringify(action)}`)
|
|
114
|
+
const config = ctx?.agent?.config ?? null
|
|
115
|
+
if (!config) throw new Error("settings: no live agent config (ctx.agent.config missing)")
|
|
116
|
+
if (action === "list") {
|
|
117
|
+
const rows = flatten(config)
|
|
118
|
+
if (rows.length === 0) return "(空配置——无已设置键)"
|
|
119
|
+
return rows.map(formatLine).join("\n")
|
|
120
|
+
}
|
|
121
|
+
if (action === "get") {
|
|
122
|
+
if (!args.key) throw new Error("settings get: key is required (dot path, e.g. agent.maxTurns)")
|
|
123
|
+
const r = resolvePath(config, args.key)
|
|
124
|
+
if (!r.ok) {
|
|
125
|
+
const hint = r.depth === 0 ? "(config 顶层无此键——顶层可用键见 settings list)" : `(父键 ${r.reached || "顶层"} 存在——无 ${r.missing} 子键)`
|
|
126
|
+
throw new Error(`settings get: no such key "${args.key}" ${hint}`)
|
|
127
|
+
}
|
|
128
|
+
return formatLine({ path: args.key, value: r.value })
|
|
129
|
+
}
|
|
130
|
+
// set
|
|
131
|
+
if (!args.key || args.value === undefined) throw new Error("settings set: key and value are required")
|
|
132
|
+
const value = parseValue(args.value)
|
|
133
|
+
// 已知键类型校验(类型表派生自 DEFAULTS——未知键 JSON 原样)
|
|
134
|
+
const want = TYPE_MAP[String(args.key)]
|
|
135
|
+
if (want && want !== "array") {
|
|
136
|
+
const got = value === null ? "null" : typeof value
|
|
137
|
+
if (got !== want && !(value === null && want === "object")) {
|
|
138
|
+
throw new Error(`settings set: "${args.key}" expects ${want} — got ${got} (${JSON.stringify(args.value)})`)
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
// 写盘(D-F5b:磁盘真相最小化——writeConfigAtomic 磁盘新鲜读 + 只改被设键 + mtime
|
|
142
|
+
// 门控——默认不固化;冲突/畸形抛错,内存不热应用(零虚假成功))+ 热应用(内存对象)
|
|
143
|
+
const r = await writeConfigAtomic(cfgPath, (disk) => {
|
|
144
|
+
setKeyPath(disk, args.key, value)
|
|
145
|
+
})
|
|
146
|
+
if (!r.ok) throw new Error("config changed on disk concurrently — retry (settings set not applied)")
|
|
147
|
+
setKeyPath(config, args.key, value)
|
|
148
|
+
const shown = isSensitiveKey(String(args.key)) ? MASKED : value
|
|
149
|
+
return `settings set: ${args.key} = ${shown} (${Array.isArray(value) ? "array" : typeof value})${isSensitiveKey(String(args.key)) ? " — stored(值不回显)" : " — persisted + hot-applied(运行中已生效)"}`
|
|
150
|
+
},
|
|
151
|
+
}
|
|
152
|
+
}
|
|
@@ -9,7 +9,8 @@ import { escapeXml } from "../agent.mjs"
|
|
|
9
9
|
export const skillTool = {
|
|
10
10
|
name: "skill",
|
|
11
11
|
description:
|
|
12
|
-
"Load a project skill from .thincoder/skills/. Skills contain reusable instructions, workflows, or reference material. Use this when the user references a skill by name, or when a task matches a known skill's description. Call with action='list' to see available skills; call with action='load' and name=<skill> to activate one."
|
|
12
|
+
"Load a project skill from .thincoder/skills/. Skills contain reusable instructions, workflows, or reference material. Use this when the user references a skill by name, or when a task matches a known skill's description. Call with action='list' to see available skills; call with action='load' and name=<skill> to activate one. " +
|
|
13
|
+
"Returns the skill list ('list'), the load confirmation ('load' — instructions arrive in the next message), or Error: ... with the available skills.",
|
|
13
14
|
parameters: {
|
|
14
15
|
type: "object",
|
|
15
16
|
properties: {
|
|
@@ -0,0 +1,470 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* subagent-actions.mjs — subagent 动作执行器(§19/§19.5/§19.6——2026-09-05 自
|
|
3
|
+
* subagent-async.mjs 拆分——Module Split Policy §20.9——纯迁移零行为变化)。
|
|
4
|
+
* 内容:executeStatusAction(§19.5 D-M5——非阻塞池查询——touchedSummary/
|
|
5
|
+
* shortTouchedPath/statusFields 摘要助手随行)/ executeObserveAction + executeSendAction
|
|
6
|
+
* (SUBAGENT-OBSERVE-SEND——observe 只读/send 控制豁免注入)/ executeEscalateAction(§19
|
|
7
|
+
* D-M4——飞刀——touchedFilesNote 随行)。
|
|
8
|
+
* §19.8 check 删除后仅 cancel 动作执行器与共享 post-spawn 管线留在 subagent-async.mjs;
|
|
9
|
+
* §20 调度器 + 文件域组在 ./subagent-scheduler.mjs(describeBlockers 由此导入)。
|
|
10
|
+
* 2026-09-08 二次拆分:§19.6 面板段迁至 ./subagent-panel.mjs(601 > 500 硬限跨档——
|
|
11
|
+
* 尾部 re-export executePanelAction 保 subagent.mjs 既有 import 面)+ ASYNC-RESULT-
|
|
12
|
+
* CONTAINER.md D1 落地(池访问点改 getAsyncPool accessor)。
|
|
13
|
+
*/
|
|
14
|
+
import { isAbsolute, relative } from "node:path"
|
|
15
|
+
import { runAgent, createAgent, CODER_OVERLAY, DEFAULT_SUBAGENT_TURNS } from "../agent.mjs"
|
|
16
|
+
import {
|
|
17
|
+
runWithContinue, TURN_CAP_MARK, makeRelay, wrapChildCallbacks,
|
|
18
|
+
ensureChildApiKey, clampEffort,
|
|
19
|
+
} from "../agent/spawn-child.mjs"
|
|
20
|
+
import { logEvent, errText } from "../log.mjs"
|
|
21
|
+
import { describeBlockers, detectStall, STALL_NOTE } from "./subagent-scheduler.mjs"
|
|
22
|
+
import { resolveChildProvider, mergeChildMutations } from "./subagent-async.mjs"
|
|
23
|
+
import { launchEscalateAsync } from "./escalate-async.mjs"
|
|
24
|
+
// ASYNC-RESULT-CONTAINER.md D1:池 accessor(absorb 双池——advisor 独立池无队列)
|
|
25
|
+
import { getAsyncPool } from "./async-settle.mjs"
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* subagent action:"status" (§19 D-M2, new): NON-BLOCKING async-pool query —
|
|
29
|
+
* returns immediately and never consumes a result (results belong to the auto
|
|
30
|
+
* channel — §19.8: no check action any more). Source of truth = the pool
|
|
31
|
+
* (_asyncSubagents): entries moved to _pendingAsyncResults during a suspension
|
|
32
|
+
* (§17 D-S3 ② — injected at the next run start) are no longer in the pool and
|
|
33
|
+
* are NOT counted as done-waiting.
|
|
34
|
+
* - id given → { id, role, status, model?, elapsedSec?, turn?, maxTurns?,
|
|
35
|
+
* touchedFiles?/touchedMore?/touched? ... } for that entry; unknown id → error
|
|
36
|
+
* (T12 semantics — unknown async-subagent id error, same wording as the pool).
|
|
37
|
+
* - id omitted → { overview: { running: [{id, role, model, elapsedSec, turn,
|
|
38
|
+
* maxTurns, touchedFiles?/touched?}], queued: [{id, role, position, touched?}],
|
|
39
|
+
* done: [{id, role}] } } — live queue positions (index in _asyncQueue + 1).
|
|
40
|
+
* §19.5.6: running 条目带 touched files 摘要(touchedFiles 前 5 + touchedMore 超出
|
|
41
|
+
* 计数——相对查询方 cwd;0 改动 → touched 占位);queued 条目带 touched 占位
|
|
42
|
+
* "—(未启动)";done/error/取消条目无 touched 字段(round3 #9)。
|
|
43
|
+
* A settled-but-unconsumed entry (settled during a NORMAL turn) reports done
|
|
44
|
+
* with a "not yet consumed" note — the auto channel (turn-end collection / the
|
|
45
|
+
* suspension digest) still delivers it afterwards (§19.8: sole consumption path).
|
|
46
|
+
*/
|
|
47
|
+
/** §19.5 D-M5 decision-field assembly (F9): running entries report
|
|
48
|
+
* {id, role, model, elapsedSec, turn, maxTurns} — the data needed to decide
|
|
49
|
+
* WHO to cancel. Model is recorded at spawn (childProvider), startedAt at
|
|
50
|
+
* ACTUAL start (queued waits don't count), turn/maxTurns mirrored from the
|
|
51
|
+
* child's ⟦ev⟧turn events at the callbacks-wrap layer (subagent.mjs tracker).
|
|
52
|
+
* elapsedSec computed at call time from startedAt. */
|
|
53
|
+
/** §19.5.6 D-SF2/N-SF1 摘要形态:status 调用时实时读 entry.childAgent._touchedFiles
|
|
54
|
+
* (绝对路径——per-run 记账——§18.12)→ 相对查询方 cwd;cwd 之外保留绝对形态 +
|
|
55
|
+
* "../" 前缀;>80 字符截尾(不超行);前 5 个 + 独立截断字段 touchedMore(超出
|
|
56
|
+
* 计数——不混入数组——消费方按类型区分)。占位:running 0 改动 → "—(尚无改动)"
|
|
57
|
+
* (T-SF2a);queued 未启动 → "—(未启动)"(T-SF2b);done/error/取消条目不含
|
|
58
|
+
* 本摘要(round3 #9 明示——本批只做 running/queued)。 */
|
|
59
|
+
function touchedSummary(entry, cwd) {
|
|
60
|
+
if (entry.status !== "running") return { touched: "—(未启动)" }
|
|
61
|
+
const files = entry.childAgent?._touchedFiles ?? []
|
|
62
|
+
if (files.length === 0) return { touched: "—(尚无改动)" }
|
|
63
|
+
const shown = files.slice(0, 5).map((f) => shortTouchedPath(f, cwd))
|
|
64
|
+
const out = { touchedFiles: shown }
|
|
65
|
+
if (files.length > 5) out.touchedMore = files.length - 5
|
|
66
|
+
return out
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** N-SF1 单路径显示形态:cwd 内 → 相对路径;cwd 外 → "../" + 绝对路径;>80 截尾。 */
|
|
70
|
+
function shortTouchedPath(f, cwd) {
|
|
71
|
+
let p = f
|
|
72
|
+
if (cwd) {
|
|
73
|
+
const rel = relative(cwd, f)
|
|
74
|
+
p = rel && !rel.startsWith("..") && !isAbsolute(rel) ? rel : `../${f}`
|
|
75
|
+
}
|
|
76
|
+
return p.length > 80 ? `${p.slice(0, 79)}…` : p
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function statusFields(entry, cwd) {
|
|
80
|
+
const base = { id: String(entry.id), role: entry.role }
|
|
81
|
+
if (entry.status === "running") {
|
|
82
|
+
base.model = entry.model ?? null
|
|
83
|
+
base.elapsedSec = entry.startedAt ? Math.max(0, Math.floor((Date.now() - entry.startedAt) / 1000)) : 0
|
|
84
|
+
base.turn = entry.turn ?? 0
|
|
85
|
+
base.maxTurns = entry.maxTurns ?? 0
|
|
86
|
+
// §19.5.6:touched files 摘要(T-SF1..4——新字段追加——既有字段零破坏)
|
|
87
|
+
Object.assign(base, touchedSummary(entry, cwd))
|
|
88
|
+
} else if (entry.status === "queued") {
|
|
89
|
+
// §19.5.6 T-SF2b:未启动——确定性占位(不崩;无对象可读)
|
|
90
|
+
base.touched = "—(未启动)"
|
|
91
|
+
}
|
|
92
|
+
return base
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export function executeStatusAction(args, ctx) {
|
|
96
|
+
const agent = ctx.agent
|
|
97
|
+
const map = getAsyncPool(agent, "subagent") ?? new Map()
|
|
98
|
+
const advisors = getAsyncPool(agent, "advisor") ?? new Map()
|
|
99
|
+
const queue = agent._asyncQueue ?? []
|
|
100
|
+
const queuedPosition = (id) => {
|
|
101
|
+
const i = queue.findIndex((e) => String(e.id) === id)
|
|
102
|
+
return i >= 0 ? i + 1 : undefined
|
|
103
|
+
}
|
|
104
|
+
const { id } = args ?? {}
|
|
105
|
+
if (id !== undefined && id !== null && String(id) !== "") {
|
|
106
|
+
const key = String(id)
|
|
107
|
+
// §24 D-24b: by-id queries fall through to the advisor pool (shared counter —
|
|
108
|
+
// ids are unique across both pools; role identifies the kind).
|
|
109
|
+
const entry = map.get(key) ?? advisors.get(key)
|
|
110
|
+
if (!entry) {
|
|
111
|
+
// Wording kept exact — locked by subagent-async/tool tests (unknown-id error
|
|
112
|
+
// semantics); an unknown id simply names neither pool (the pools share the
|
|
113
|
+
// id counter, so the message stays unambiguous).
|
|
114
|
+
return JSON.stringify({ id: key, status: "error", error: `unknown async subagent id: ${key}` })
|
|
115
|
+
}
|
|
116
|
+
const target = statusFields(entry, agent.cwd)
|
|
117
|
+
if (entry.status === "running") return JSON.stringify({ ...target, status: "running" })
|
|
118
|
+
if (entry.status === "queued") {
|
|
119
|
+
// §20 F-SD4/D-SD3b:waiting 语义对模型可见——排队原因(冲突对象/依赖对象)随
|
|
120
|
+
// status 返回;纯槽满等位(kind slot)无 waiting 字段(position 已足够)。
|
|
121
|
+
const blk = describeBlockers(agent, entry)
|
|
122
|
+
const out = { ...target, status: "queued", position: queuedPosition(key) ?? entry.position }
|
|
123
|
+
if (blk.kind !== "slot") {
|
|
124
|
+
out.waiting = blk.kind === "depc" ? "dependency-cancelled" : "waiting-deps"
|
|
125
|
+
out.reason = blk.detail
|
|
126
|
+
}
|
|
127
|
+
// §21.1 P-SL2(D-SL2):停滞机械标记——池停滞时本任务所在闭环阻塞链随 status
|
|
128
|
+
// 返回(F-SL2——status 视图可显示停滞——模型可见——零正常路径字段变化)。
|
|
129
|
+
const stall = detectStall(agent)
|
|
130
|
+
if (stall) {
|
|
131
|
+
const chain = stall.chains.find((c) => c.task === entry)?.text
|
|
132
|
+
if (chain) out.stall = { chain, note: STALL_NOTE }
|
|
133
|
+
}
|
|
134
|
+
return JSON.stringify(out)
|
|
135
|
+
}
|
|
136
|
+
// done = settled during this turn and not yet consumed — the auto channel still
|
|
137
|
+
// delivers it (§19.8: turn-end collection / the suspension digest — no fetch action).
|
|
138
|
+
target.status = "done"
|
|
139
|
+
target.done = true
|
|
140
|
+
if (entry.error) target.error = entry.error
|
|
141
|
+
target.note = "settled, not yet consumed — delivered by the auto channel (turn-end collection or the suspension digest injects it)"
|
|
142
|
+
return JSON.stringify(target)
|
|
143
|
+
}
|
|
144
|
+
const overview = { running: [], queued: [], done: [] }
|
|
145
|
+
const mapEntries = [...map.values(), ...advisors.values()]
|
|
146
|
+
for (const entry of mapEntries) {
|
|
147
|
+
if (entry.status === "running") overview.running.push(statusFields(entry, agent.cwd))
|
|
148
|
+
else if (entry.status === "queued") {
|
|
149
|
+
// §20:queued 条目补 waiting/reason(F-SD4——依赖/冲突原因模型可见);
|
|
150
|
+
// §19.5.6 T-SF2b:未启动占位(确定性——不崩)。
|
|
151
|
+
const blk = describeBlockers(agent, entry)
|
|
152
|
+
const row = statusFields(entry, agent.cwd)
|
|
153
|
+
row.position = queuedPosition(String(entry.id)) ?? entry.position
|
|
154
|
+
if (blk.kind !== "slot") {
|
|
155
|
+
row.waiting = blk.kind === "depc" ? "dependency-cancelled" : "waiting-deps"
|
|
156
|
+
row.reason = blk.detail
|
|
157
|
+
}
|
|
158
|
+
overview.queued.push(row)
|
|
159
|
+
}
|
|
160
|
+
else if (entry.done) overview.done.push({ id: String(entry.id), role: entry.role })
|
|
161
|
+
}
|
|
162
|
+
// §21.1 P-SL2(D-SL2):停滞机械标记——overview 级标注(可显示停滞——F-SL2)——
|
|
163
|
+
// 链文本随视图返回(逐条——每 queued 条目一行——闭环可破环引导同挂)。
|
|
164
|
+
const stall = detectStall(agent)
|
|
165
|
+
if (stall) {
|
|
166
|
+
overview.stall = {
|
|
167
|
+
chains: stall.chains.map((c) => c.text),
|
|
168
|
+
note: STALL_NOTE,
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
return JSON.stringify({ overview })
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
175
|
+
// SUBAGENT-OBSERVE-SEND(docs/design/SUBAGENT-OBSERVE-SEND.md——CLI 端)
|
|
176
|
+
// observe(D1——readonly 查询)+ send(D2——控制类豁免注入引导)——动作执行器。
|
|
177
|
+
// 目标 = 父自身 spawn 的异步子代理池条目(_asyncSubagents——非 advisor/escalate——
|
|
178
|
+
// 后者共享 id 计数但非"读写子代理",错误路径明示)。
|
|
179
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
180
|
+
|
|
181
|
+
/** D1/N2 摘要形态:从子代理 _fullHistory(real 消息——pushReal 累积)倒序收最近 N 个
|
|
182
|
+
* assistant 回合的一行描述(newest-first;assistant 消息 = 一回合——带 tool_calls =
|
|
183
|
+
* 工具轮、独立 content = 回复首行)。返回纯摘要非全量(N2 隔离——不把子代理噪音灌父)。 */
|
|
184
|
+
function recentTurnLines(child, limit) {
|
|
185
|
+
const hist = child?._fullHistory
|
|
186
|
+
if (!Array.isArray(hist)) return []
|
|
187
|
+
const out = []
|
|
188
|
+
for (let i = hist.length - 1; i >= 0 && out.length < limit; i--) {
|
|
189
|
+
const m = hist[i]
|
|
190
|
+
if (m?.role !== "assistant") continue
|
|
191
|
+
const tcs = Array.isArray(m.tool_calls)
|
|
192
|
+
? m.tool_calls.filter((t) => typeof t?.function?.name === "string")
|
|
193
|
+
: []
|
|
194
|
+
if (tcs.length) out.push(`tools: ${tcs.map((t) => t.function.name).join(", ")}`)
|
|
195
|
+
else if (typeof m.content === "string" && m.content.trim()) {
|
|
196
|
+
const first = m.content.trim().split(/\r?\n/).find((l) => l.trim())
|
|
197
|
+
out.push((first ?? m.content.trim()).slice(0, 120))
|
|
198
|
+
} else out.push("(assistant — no content)")
|
|
199
|
+
}
|
|
200
|
+
return out
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
function queuedPositionOf(agent, key) {
|
|
204
|
+
const i = (agent?._asyncQueue ?? []).findIndex((e) => String(e.id) === key)
|
|
205
|
+
return i >= 0 ? i + 1 : undefined
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/** recent 参数钳制(默认 ~5 条摘要——可参数;钳到 1..20 防超长 N2 失控)。 */
|
|
209
|
+
function clampRecent(n) {
|
|
210
|
+
const v = Number.parseInt(n, 10)
|
|
211
|
+
if (!Number.isFinite(v)) return 5
|
|
212
|
+
return Math.max(1, Math.min(20, v))
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/**
|
|
216
|
+
* subagent action:"observe"(D1——READONLY 查询,父回合内):按 id 拉运行中异步子代理的
|
|
217
|
+
* 最近活动快照——从 entry.childAgent(subagent-run.mjs start() 绑定)读**已落回合**摘要
|
|
218
|
+
* (_fullHistory 最近 N 条)+ _touchedFiles + turn/maxTurns + status + **in-flight 当前
|
|
219
|
+
* 工具**(评审 #1——从 dispatch runOne 维护的 child._inflightTools 读——非只读已落
|
|
220
|
+
* history:卡在长工具调用时 history 无新回合、恰是 observe 要检测的卡死态)。返摘要非
|
|
221
|
+
* 全量(N2)。observe = readonly——running/queued/done 均可查(done 终报 / queued 占位)。
|
|
222
|
+
*/
|
|
223
|
+
export function executeObserveAction(args, ctx) {
|
|
224
|
+
if ((ctx.depth ?? 0) > 0) {
|
|
225
|
+
return JSON.stringify({ status: "error", error: "observe is only available at depth 0 — a child agent has no async pool of its own (AGENT-LOOP.md §7.2)" })
|
|
226
|
+
}
|
|
227
|
+
const agent = ctx.agent
|
|
228
|
+
const id = args?.id
|
|
229
|
+
if (id === undefined || id === null || String(id) === "") {
|
|
230
|
+
return JSON.stringify({ status: "error", error: "observe requires the id of the async subagent to inspect (from the async spawn return) — pass the id; omitting it observes nothing (SUBAGENT-OBSERVE-SEND)" })
|
|
231
|
+
}
|
|
232
|
+
const key = String(id)
|
|
233
|
+
const entry = getAsyncPool(agent, "subagent")?.get(key)
|
|
234
|
+
if (!entry) {
|
|
235
|
+
if (getAsyncPool(agent, "advisor")?.has(key)) {
|
|
236
|
+
return JSON.stringify({ status: "error", error: `id ${key} is an async ADVISOR review — observe is for async subagents (read/edit children); track an advisor with action:'status' or wait for its auto-delivered report` })
|
|
237
|
+
}
|
|
238
|
+
return JSON.stringify({ status: "error", error: `unknown async subagent id: ${key}` })
|
|
239
|
+
}
|
|
240
|
+
const child = entry.childAgent
|
|
241
|
+
const out = { id: key, role: entry.role, status: entry.status }
|
|
242
|
+
if (entry.status === "running") {
|
|
243
|
+
out.turn = entry.turn ?? 0
|
|
244
|
+
out.maxTurns = entry.maxTurns ?? 0
|
|
245
|
+
Object.assign(out, touchedSummary(entry, agent.cwd))
|
|
246
|
+
// in-flight 当前工具(评审 #1):child._inflightTools Set——dispatch runOne 在工具
|
|
247
|
+
// 执行前后维护——LLM 生成/工具间空隙为空;子代理 await 长工具调用时父回合可见它。
|
|
248
|
+
const inflight = child?._inflightTools
|
|
249
|
+
if (inflight instanceof Set && inflight.size > 0) out.currentTool = [...inflight]
|
|
250
|
+
const n = clampRecent(args?.recent)
|
|
251
|
+
out.recentTurns = recentTurnLines(child, n)
|
|
252
|
+
out.note = `observing running ${entry.role}#${key} — recentTurns newest-first (cap ${n}); currentTool shown only while a tool is executing (stuck detection); sent directions consume at the next turn boundary`
|
|
253
|
+
} else if (entry.status === "queued") {
|
|
254
|
+
out.position = queuedPositionOf(agent, key) ?? entry.position ?? null
|
|
255
|
+
out.note = "queued — not started yet; no activity to observe (starts when the slot frees / dependencies clear; action:'send' targets running only)"
|
|
256
|
+
} else {
|
|
257
|
+
// done = settled this turn, not yet consumed — final report rides the auto channel.
|
|
258
|
+
out.done = true
|
|
259
|
+
out.turn = entry.turn ?? 0
|
|
260
|
+
out.maxTurns = entry.maxTurns ?? 0
|
|
261
|
+
out.recentTurns = child ? recentTurnLines(child, clampRecent(args?.recent)) : []
|
|
262
|
+
out.note = "settled — the final report is delivered by the auto channel (turn-end collection or the suspension digest); observe returns the activity summary, not the full report (N2)"
|
|
263
|
+
}
|
|
264
|
+
return JSON.stringify(out)
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
/**
|
|
268
|
+
* subagent action:"send"(D2——控制类豁免,同 cancel/panel-freeze——父回合内显式调用即
|
|
269
|
+
* 授权):按 id 向运行中异步子代理注入一条引导消息——push 进 entry._injected(仅 running
|
|
270
|
+
* 异步可 send);子回合边界经 consumeInjected 回调消费 → pushReal 成 user 回合进子历史
|
|
271
|
+
* → 当作普通指令处理(注入不等同偏离豁免——子收敛/审计纪律不变)。settle/cancel/unknown
|
|
272
|
+
* /queued → 明确错误。send→settle 竞态:入队后子未及下回合边界即 settle → 消息未投递
|
|
273
|
+
* → settle 收尾附报告提示(不在此报错——send 返回时无法预知)。
|
|
274
|
+
*/
|
|
275
|
+
export function executeSendAction(args, ctx) {
|
|
276
|
+
if ((ctx.depth ?? 0) > 0) {
|
|
277
|
+
return JSON.stringify({ status: "error", error: "send is only available at depth 0 — a child agent has no async pool of its own (AGENT-LOOP.md §7.2)" })
|
|
278
|
+
}
|
|
279
|
+
const agent = ctx.agent
|
|
280
|
+
const id = args?.id
|
|
281
|
+
if (id === undefined || id === null || String(id) === "") {
|
|
282
|
+
return JSON.stringify({ status: "error", error: "send requires the id of the running async subagent to direct (from the async spawn return) — omitting it means an unspecified target (SUBAGENT-OBSERVE-SEND)" })
|
|
283
|
+
}
|
|
284
|
+
const message = args?.message
|
|
285
|
+
if (typeof message !== "string" || !message.trim()) {
|
|
286
|
+
return JSON.stringify({ status: "error", error: "send requires the message to inject — the direction the running subagent should treat as an ordinary user instruction at its next turn boundary" })
|
|
287
|
+
}
|
|
288
|
+
const key = String(id)
|
|
289
|
+
const entry = getAsyncPool(agent, "subagent")?.get(key)
|
|
290
|
+
if (!entry) {
|
|
291
|
+
if (getAsyncPool(agent, "advisor")?.has(key)) {
|
|
292
|
+
return JSON.stringify({ status: "error", error: `id ${key} is an async ADVISOR review — send is for async subagents; you cannot inject direction into a running review (AGENT-LOOP.md §7.2)` })
|
|
293
|
+
}
|
|
294
|
+
return JSON.stringify({ status: "error", error: `unknown async subagent id: ${key}` })
|
|
295
|
+
}
|
|
296
|
+
if (entry.done || entry.cancelled) {
|
|
297
|
+
return JSON.stringify({ status: "error", error: `async subagent #${key} has ${entry.cancelled ? "been cancelled" : "already settled"} — nothing to send (you cannot inject into a finished subagent; re-spawn with the direction instead)` })
|
|
298
|
+
}
|
|
299
|
+
if (entry.status !== "running") {
|
|
300
|
+
return JSON.stringify({ status: "error", error: `async subagent #${key} is ${entry.status} (not running) — send only targets a RUNNING async subagent; a queued one has not started its turn loop yet` })
|
|
301
|
+
}
|
|
302
|
+
entry._injected ??= []
|
|
303
|
+
entry._injected.push(String(message).trim())
|
|
304
|
+
return JSON.stringify({
|
|
305
|
+
id: key,
|
|
306
|
+
status: "delivered",
|
|
307
|
+
queued: entry._injected.length,
|
|
308
|
+
note: `message queued for ${entry.role}#${key} — consumed as an ordinary user instruction at its next turn boundary (after its current tool finishes — non-interrupting). If it settles first, its report carries an "undelivered" note.`,
|
|
309
|
+
})
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
313
|
+
// §19.6 subagent panel 检查工具(AGENT-LOOP.md §19.6——F-P1..P3/D-P1..P4)
|
|
314
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
315
|
+
// 2026-09-08 拆分(Module Split Policy——601 > 500 硬限跨档):§19.6 面板段迁至
|
|
316
|
+
// ./subagent-panel.mjs(executePanelAction/panelFreezeGate/blockKeyIn——verbatim +
|
|
317
|
+
// ASYNC-RESULT-CONTAINER D1/D2 落地);本面保留 re-export 保 subagent.mjs 既有
|
|
318
|
+
// import 面(subagent-async 尾部 re-export 先例)。
|
|
319
|
+
export { executePanelAction } from "./subagent-panel.mjs"
|
|
320
|
+
|
|
321
|
+
/**
|
|
322
|
+
* subagent action:"escalate"(§19 D-M4——退役 escalate 工具语义原样,ESCALATE.md;
|
|
323
|
+
* §25 D-R17b——R17:缺省 async——后台飞刀 + settle 三分类 digest——async:false 保
|
|
324
|
+
* 同步旧路径)。飞刀——交给 consultModels 池里更强模型(WRITE + 术后报告)。约束全
|
|
325
|
+
* 保留:depth-0 only / 工程模式拒 / consultModels 空拒 / relay 前缀 `escalate#N/`
|
|
326
|
+
* (与既有前缀同名——TUI 路由零改动)/ mutations merge 回父(async 路径在 settle 分类)。
|
|
327
|
+
*/
|
|
328
|
+
export async function executeEscalateAction(args, ctx) {
|
|
329
|
+
const parent = ctx.agent
|
|
330
|
+
if ((ctx.depth ?? 0) > 0) return "Error: escalate is only available at depth 0 (an escalate's work cannot be delegated again)"
|
|
331
|
+
if (parent?.config?.agent?.engineering) {
|
|
332
|
+
return "Error: engineering mode is ON — escalate is unavailable (it spawns a coder sub-agent, which engineering mode forbids). Use subagent with role='eng-coder' and a designToken from advisor(type='design') instead."
|
|
333
|
+
}
|
|
334
|
+
const pool = parent?.config?.agent?.consultModels ?? []
|
|
335
|
+
if (pool.length === 0) return "Error: no escalate candidates — configure at least one consult model (agent.consultModels)"
|
|
336
|
+
|
|
337
|
+
const { task, model } = args ?? {}
|
|
338
|
+
// task 机械必填(多动作 schema 的 required 只是建议——缺 task 会以晦涩 child-run 错浮现)
|
|
339
|
+
if (typeof task !== "string" || !task.trim()) {
|
|
340
|
+
return "Error: escalate requires a task — the task description with goal, constraints, entry files and acceptance criteria"
|
|
341
|
+
}
|
|
342
|
+
const label = (m) => `${m.provider}:${m.model}`
|
|
343
|
+
const wanted = typeof model === "string" ? model.replace(/\s+\([^)]*\)\s*$/, "").trim() : model
|
|
344
|
+
const pick = wanted ? pool.find((m) => label(m) === wanted) : pool[0]
|
|
345
|
+
if (!pick) {
|
|
346
|
+
return `Error: "${model}" is not a consult candidate. Available: ${pool.map(label).join(", ")}`
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
let provider
|
|
350
|
+
try {
|
|
351
|
+
provider = resolveChildProvider(parent, `${pick.provider}:${pick.model}`)
|
|
352
|
+
} catch (e) {
|
|
353
|
+
return `Error: ${e.message}`
|
|
354
|
+
}
|
|
355
|
+
if (!ensureChildApiKey(provider)) {
|
|
356
|
+
return `Error: provider "${pick.provider}" has no API key — set it in config.json before flying it in`
|
|
357
|
+
}
|
|
358
|
+
let effortNote = ""
|
|
359
|
+
if (pick.effort && !clampEffort(provider, pick.model, pick.effort)) {
|
|
360
|
+
// enum 外 effort 丢弃(preset 默认也可能对 override model 是 enum 外值)
|
|
361
|
+
effortNote = ` (effort "${pick.effort}" unsupported by ${pick.model}, dropped)`
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
const tag = label(pick)
|
|
365
|
+
|
|
366
|
+
// §25 D-R17b (R17 — 决策点 ③): escalate 缺省 async — the launch returns an ack
|
|
367
|
+
// {id, role:"escalate", status:"running"|"queued"} and the flight runs in the
|
|
368
|
+
// background (shared other pool; settle 三分类 → pending 单容器 digest——
|
|
369
|
+
// ASYNC-RESULT-CONTAINER.md D2)。
|
|
370
|
+
// `async: false` keeps the legacy synchronous flight below (backward compat).
|
|
371
|
+
if (args?.async !== false) {
|
|
372
|
+
return launchEscalateAsync(parent, ctx, { task: String(task), provider, tag, effortNote })
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
const relayPrefix = makeRelay(parent, "escalate", ctx.callbacks?.onToken, provider.model ?? tag)
|
|
376
|
+
|
|
377
|
+
// 无墙钟 watchdog——turn cap 即成本预算(2026-08-16 rationale:固定墙钟会误杀正常慢速
|
|
378
|
+
// 手术;挂起防护 = FETCH_TIMEOUT_MS + 父 signal 直传)
|
|
379
|
+
|
|
380
|
+
// 不自建 onToken(consult P2):wrapChildCallbacks 已承担前缀 relay + D7 哨兵剥除,
|
|
381
|
+
// runWithContinue 拥有 capture(stripEventTokensForCapture)——手写副本会双剥+双缓冲
|
|
382
|
+
const childCallbacks = wrapChildCallbacks(relayPrefix, ctx.callbacks ?? {})
|
|
383
|
+
|
|
384
|
+
// try 外声明:catch 也能在部分失败时 merge mutations
|
|
385
|
+
let child = null
|
|
386
|
+
let escErr = null // LOGGING outcome(string 形态返回 vs 异常——见下方事件点)
|
|
387
|
+
const escId = relayPrefix.slice(0, -1)
|
|
388
|
+
let escT0 = Date.now()
|
|
389
|
+
try {
|
|
390
|
+
// 全写路径(role "coder"):权限经父 onPermissionRequest,mutations merge 回父
|
|
391
|
+
child = createAgent({
|
|
392
|
+
provider,
|
|
393
|
+
tools: parent.tools,
|
|
394
|
+
config: parent.config,
|
|
395
|
+
cwd: parent.cwd,
|
|
396
|
+
memory: parent.memory,
|
|
397
|
+
overlay: CODER_OVERLAY,
|
|
398
|
+
role: "coder",
|
|
399
|
+
})
|
|
400
|
+
child._logId = escId // LOGGING:子内事件归属(escalate#N)
|
|
401
|
+
escT0 = Date.now()
|
|
402
|
+
logEvent("child:spawn", { role: "escalate", id: escId, kind: "escalate" })
|
|
403
|
+
const runner = ctx.runAgent ?? runAgent
|
|
404
|
+
const runOpts = {
|
|
405
|
+
depth: 1,
|
|
406
|
+
maxTurns: parent.config?.agent?.subagentTurns ?? DEFAULT_SUBAGENT_TURNS, // review #7: constant, not literal (single source with subagent)
|
|
407
|
+
signal: ctx.signal ?? null,
|
|
408
|
+
}
|
|
409
|
+
// Continue 经 runWithContinue(§7.2 D3,主会话同等 y/n 面板):resume:true 不重注入
|
|
410
|
+
// task 文本(setup 跳 input)且保留 child history + mutation 记账,刷新 turn 预算;
|
|
411
|
+
// 无权限 handler(headless)或拒绝 → 部分工作返回;continue 次数无限(每轮可拒)。
|
|
412
|
+
const report = await runWithContinue(
|
|
413
|
+
async (childAgent, input, cbs, opts) => {
|
|
414
|
+
// Merge mid-run mutations even when the run throws — the outer catch keeps
|
|
415
|
+
// handling createAgent failures; AbortError still propagates (user Stop).
|
|
416
|
+
try {
|
|
417
|
+
return await runner(childAgent, input, cbs, opts)
|
|
418
|
+
} catch (e) {
|
|
419
|
+
mergeChildMutations(parent, childAgent)
|
|
420
|
+
throw e
|
|
421
|
+
}
|
|
422
|
+
},
|
|
423
|
+
child, task, { ...childCallbacks, onPermissionRequest: parent.autoApprove ? async () => true : (ctx.onPermissionRequest ?? null) },
|
|
424
|
+
runOpts,
|
|
425
|
+
{
|
|
426
|
+
// escalate has NO permQueue: prompts go straight to the user (T-L spec).
|
|
427
|
+
askContinue: (e) => (ctx.onPermissionRequest
|
|
428
|
+
? ctx.onPermissionRequest("continue", { turns: e.turn, agent: tag })
|
|
429
|
+
: Promise.resolve(false)),
|
|
430
|
+
onDeclined: (e, output) => `escalate (${tag}) ${TURN_CAP_MARK} (${e.turn} turns) — work may be partial; review recent_changes before deciding next steps.\nPartial output: ${output.slice(0, 2000)}`,
|
|
431
|
+
},
|
|
432
|
+
).catch((e) => {
|
|
433
|
+
// 非 ContinueError 运行失败:错误文本 + partial 输出(mutations 已在 runner 包装层 merge)
|
|
434
|
+
if (ctx.signal?.aborted || e?.name === "AbortError") throw e
|
|
435
|
+
escErr = { err: e?.message ?? String(e) } // LOGGING:错误路径(返回形态——不抛)
|
|
436
|
+
return `escalate (${tag}) error: ${e?.message ?? String(e)}\nPartial output: ${(child._capturedOutput ?? "").slice(0, 2000)}`
|
|
437
|
+
})
|
|
438
|
+
// Escalate mutations are the parent's mutations: verify/advisor guards must see them
|
|
439
|
+
mergeChildMutations(parent, child)
|
|
440
|
+
if (escErr) logEvent("child:error", { role: "escalate", id: escId, ms: Date.now() - escT0, err: errText(escErr.err, 200) })
|
|
441
|
+
else logEvent("child:done", { role: "escalate", id: escId, ms: Date.now() - escT0, kind: String(report).includes(TURN_CAP_MARK) ? "partial" : "ok" })
|
|
442
|
+
// §7.2.3(round1 #2):escalate 与 spawn 同享 ctx._subagentKey——同步完成精确冻
|
|
443
|
+
// (relayPrefix 去尾 = `escalate#N`)。仅成功路径(escErr = 运行中途失败——不设
|
|
444
|
+
// key——TUI 回落 escalate 角色启发式:escalate 串行 + 角色限定,天然精确——legacy
|
|
445
|
+
// 行为不变——错误路径不触发冻结 round1 #1)。
|
|
446
|
+
if (!escErr) ctx._subagentKey = escId
|
|
447
|
+
return `escalate (${tag})${effortNote} post-op report:\n${report || (child._capturedOutput ?? "").slice(0, 4000)}${touchedFilesNote(child, parent.cwd)}`
|
|
448
|
+
} catch (e) {
|
|
449
|
+
// 仅 createAgent 失败/continue 询问抛出才到这(运行失败已在上面 catch 处理)
|
|
450
|
+
if (child) {
|
|
451
|
+
mergeChildMutations(parent, child)
|
|
452
|
+
if (!escErr && !(ctx.signal?.aborted) && e?.name !== "AbortError") {
|
|
453
|
+
logEvent("child:error", { role: "escalate", id: escId, ms: Date.now() - escT0, err: errText(e, 200) })
|
|
454
|
+
}
|
|
455
|
+
}
|
|
456
|
+
if (ctx.signal?.aborted || e?.name === "AbortError") throw e
|
|
457
|
+
return `escalate (${tag}) error: ${e?.message ?? String(e)}`
|
|
458
|
+
}
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
/** Relative touched-file list appended to every escalate return (child paths are absolute). */
|
|
462
|
+
function touchedFilesNote(child, cwd) {
|
|
463
|
+
const touched = child?._touchedFiles ?? []
|
|
464
|
+
if (touched.length === 0) return ""
|
|
465
|
+
const shown = touched.map((f) => {
|
|
466
|
+
const r = relative(cwd ?? process.cwd(), f)
|
|
467
|
+
return r && !r.startsWith("..") && !isAbsolute(r) ? r : f
|
|
468
|
+
})
|
|
469
|
+
return `\nTouched files: ${shown.join(", ")}`
|
|
470
|
+
}
|