thincoder 0.12.59 → 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 +38 -3
- package/README.md +2 -2
- package/bin/thincoder.mjs +80 -19
- package/package.json +4 -3
- package/src/acp/bridge.mjs +7 -4
- package/src/advisor/messages.mjs +24 -4
- package/src/advisor/run.mjs +35 -33
- package/src/advisor.mjs +25 -6
- package/src/agent/completion.mjs +17 -11
- package/src/agent/dispatch.mjs +102 -19
- package/src/agent/helpers.mjs +36 -0
- 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 +18 -2
- package/src/agent/spawn-child.mjs +29 -4
- package/src/agent-tools/advisor-async.mjs +456 -0
- package/src/agent-tools/advisor.mjs +110 -108
- package/src/agent-tools/async-settle.mjs +191 -0
- package/src/agent-tools/consult.mjs +121 -102
- package/src/agent-tools/design-token.mjs +104 -0
- package/src/agent-tools/eng.mjs +24 -29
- package/src/agent-tools/escalate-async.mjs +286 -0
- package/src/agent-tools/read-history.mjs +155 -31
- package/src/agent-tools/recent-changes.mjs +2 -1
- package/src/agent-tools/settings.mjs +7 -17
- package/src/agent-tools/subagent-actions.mjs +168 -130
- package/src/agent-tools/subagent-async.mjs +129 -174
- 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 +45 -21
- package/src/agent-tools/subagent-spawn.mjs +406 -0
- package/src/agent-tools/subagent.mjs +107 -555
- package/src/agent-tools/verify.mjs +118 -270
- package/src/agent.mjs +57 -190
- package/src/cli/distill-command.mjs +10 -4
- package/src/cli/make-agent.mjs +3 -1
- package/src/cli/memory-command.mjs +2 -1
- package/src/cli/permission.mjs +2 -2
- package/src/cli/setup-wizard.mjs +17 -12
- package/src/config.mjs +56 -8
- package/src/context.mjs +5 -147
- package/src/crash-reports.mjs +123 -0
- package/src/distill.mjs +11 -11
- package/src/explore-distill.mjs +155 -0
- package/src/memory/code-sync.mjs +2 -1
- package/src/memory/core.mjs +6 -193
- package/src/memory/delete.mjs +234 -0
- package/src/memory/docs.mjs +58 -48
- package/src/memory.mjs +3 -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 +9 -76
- package/src/prompts/advisor-round1.md +9 -68
- package/src/prompts/advisor-round2.md +7 -54
- package/src/prompts/advisor-round3.md +7 -54
- package/src/prompts/coder.md +7 -50
- package/src/prompts/consult-base.md +4 -24
- package/src/prompts/discipline.md +26 -44
- package/src/prompts/eng-coder.md +7 -32
- package/src/prompts/engineering-sub.md +3 -23
- package/src/prompts/engineering.md +53 -306
- package/src/prompts/explore.md +3 -12
- package/src/prompts/main.md +10 -32
- package/src/prompts/methodology-template.md +28 -48
- package/src/prompts/plan.md +2 -9
- package/src/prompts/system.md +16 -35
- package/src/provider/core.mjs +6 -67
- 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/checklist-sync.mjs +181 -0
- package/src/tools/checklist.mjs +52 -39
- package/src/tools/edit-batch.mjs +109 -10
- package/src/tools/edit-diff.mjs +110 -27
- package/src/tools/edit.md +17 -12
- package/src/tools/execute.mjs +31 -4
- package/src/tools/file.mjs +11 -6
- 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/index.mjs +5 -6
- package/src/tools/ops.mjs +175 -3
- package/src/tools/patch.mjs +3 -3
- package/src/tools/question.md +3 -0
- package/src/tools/read.md +0 -1
- package/src/tools/shared.mjs +14 -13
- package/src/tools/system.mjs +44 -9
- package/src/tools/wait_for.md +22 -0
- package/src/tui/agent-turn.mjs +17 -228
- package/src/tui/cmd-config.mjs +48 -7
- package/src/tui/cmd-eng.mjs +20 -16
- package/src/tui/cmd-mcp.mjs +8 -2
- package/src/tui/cmd-new.mjs +3 -2
- package/src/tui/cmd-session.mjs +19 -4
- package/src/tui/cmd-think.mjs +10 -10
- 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 +3 -2
- package/src/tui/interaction.mjs +3 -3
- package/src/tui/mouse.mjs +7 -1
- package/src/tui/pickers.mjs +40 -22
- package/src/tui/render-segments.mjs +27 -10
- package/src/tui/startup.mjs +4 -0
- package/src/tui/subagent-blocks.mjs +95 -263
- package/src/tui/subagent-children.mjs +176 -0
- package/src/tui/subagent-freeze.mjs +172 -0
- package/src/tui/subagent-panel.mjs +61 -23
- package/src/tui/suspension-drive.mjs +351 -0
- package/src/tui/tool-args.mjs +3 -3
- package/src/tui/tool-display.mjs +142 -0
- package/src/tui/tool-events.mjs +37 -173
- package/src/tui/tui-lifecycle.mjs +29 -0
- package/src/tui/update-notice.mjs +4 -0
- package/src/tui/wizard.mjs +12 -6
- package/src/tools/pdf-parse-text.mjs +0 -497
- package/src/tools/pdf-parse-xref.mjs +0 -499
- package/src/tools/pdf.mjs +0 -155
- package/src/tools/read_pdf.md +0 -21
package/src/tools/system.mjs
CHANGED
|
@@ -7,6 +7,8 @@ import {
|
|
|
7
7
|
IGNORED_DIRS,
|
|
8
8
|
resolveInCwd,
|
|
9
9
|
globToRegex,
|
|
10
|
+
splitGlobPatterns,
|
|
11
|
+
compileGlobMatchers,
|
|
10
12
|
normalizeEOL,
|
|
11
13
|
} from "./shared.mjs";
|
|
12
14
|
import { spawn, execFileSync } from "node:child_process";
|
|
@@ -145,6 +147,11 @@ function runBash(command, cwd, { timeout, signal, onOutput, shell }) {
|
|
|
145
147
|
detached: process.platform !== "win32",
|
|
146
148
|
stdio: ["ignore", "pipe", "pipe"],
|
|
147
149
|
env: buildBashEnv(),
|
|
150
|
+
// 双保险(2026-09-05——裸 spawn 无 signal 教训:abort 只靠 killTree 手动杀——taskkill
|
|
151
|
+
// best-effort 可能失败/竞态)——Node signal option = 第一道(abort 时自动杀**直接**子
|
|
152
|
+
// 进程——cmd——语义保证);killTree 仍是必需兜底(孙进程握管道使 close 不触发——见
|
|
153
|
+
// L191 注释——spawn 级 kill 不达孙进程)
|
|
154
|
+
...(signal ? { signal } : {}),
|
|
148
155
|
})
|
|
149
156
|
|
|
150
157
|
const killTree = () => killProcessTree(child)
|
|
@@ -181,10 +188,18 @@ function runBash(command, cwd, { timeout, signal, onOutput, shell }) {
|
|
|
181
188
|
settled = true
|
|
182
189
|
clearTimeout(timer)
|
|
183
190
|
clearTimeout(graceTimer)
|
|
191
|
+
// 2026-09-05(advisor 🟡#2):收尾移除 abort 监听器——ctx.signal 为长生命周期对象,
|
|
192
|
+
// 残留 once 监听器每次 bash 调用累积(闭包持有 child/输出缓冲直到 abort 才释放)
|
|
193
|
+
if (signal) signal.removeEventListener("abort", killTree)
|
|
184
194
|
resolve(result)
|
|
185
195
|
}
|
|
186
196
|
|
|
187
197
|
child.on("error", (error) => {
|
|
198
|
+
// signal 双保险(2026-09-05):abort 时 Node signal option 杀直接子进程 → 本事件
|
|
199
|
+
// 以 AbortError 先触发——**跳过 finish**(不 settled)——exit 事件随后必到(实证
|
|
200
|
+
// 2ms 内)走 L195 分支带已收集输出收尾(user interrupted——不丢 partial);
|
|
201
|
+
// 非 abort 的真 spawn 错误(command not found 等)保持原分支
|
|
202
|
+
if (error.name === "AbortError") return
|
|
188
203
|
finish(truncate(`Command failed: ${error.message}\n[stdout]:\n${outBuf || "(empty)"}`))
|
|
189
204
|
})
|
|
190
205
|
|
|
@@ -274,7 +289,7 @@ export const globTool = {
|
|
|
274
289
|
parameters: {
|
|
275
290
|
type: "object",
|
|
276
291
|
properties: {
|
|
277
|
-
pattern: { type: "string", description: "Glob pattern" },
|
|
292
|
+
pattern: { type: "string", description: "Glob pattern — supports **, *, ?, [..], {a,b} braces, and space-separated exclusion (\"**/*.js !test/**\")" },
|
|
278
293
|
path: { type: "string", description: "Directory to search in (default cwd)" },
|
|
279
294
|
},
|
|
280
295
|
required: ["pattern"],
|
|
@@ -282,10 +297,19 @@ export const globTool = {
|
|
|
282
297
|
readonly: true,
|
|
283
298
|
async execute(args, ctx) {
|
|
284
299
|
const base = resolveInCwd(ctx, args.path ?? ".")
|
|
285
|
-
|
|
300
|
+
// §17 调用侧拆分(评审 #5 职责分层):空格分隔 include !exclude 多模式在这里拆;
|
|
301
|
+
// globToRegex 只收单个模式(数组由 compileGlobMatchers 收)。
|
|
302
|
+
let match
|
|
303
|
+
try {
|
|
304
|
+
const parts = splitGlobPatterns(args.pattern)
|
|
305
|
+
if (parts.length === 0) return "Error: pattern is required"
|
|
306
|
+
match = compileGlobMatchers(parts).test
|
|
307
|
+
} catch (e) {
|
|
308
|
+
return `glob error: invalid pattern "${args.pattern}": ${e.message}`
|
|
309
|
+
}
|
|
286
310
|
const results = []
|
|
287
311
|
for await (const relPath of walkFiles(base)) {
|
|
288
|
-
if (
|
|
312
|
+
if (match(relPath)) {
|
|
289
313
|
results.push(relPath)
|
|
290
314
|
if (results.length >= 1000) break
|
|
291
315
|
}
|
|
@@ -328,7 +352,7 @@ export const grepTool = {
|
|
|
328
352
|
properties: {
|
|
329
353
|
pattern: { type: "string", description: "Regular expression, or a literal string when literal=true" },
|
|
330
354
|
path: { type: "string", description: "Directory or file to search (default cwd)" },
|
|
331
|
-
glob: { type: "string", description: "Only search files matching this glob (
|
|
355
|
+
glob: { type: "string", description: "Only search files matching this glob — supports **, *, ?, [..], {a,b} braces and space-separated exclusion (\"**/*.js !test/**\")" },
|
|
332
356
|
ignoreCase: { type: "boolean", description: "Case-insensitive match (default false)" },
|
|
333
357
|
literal: { type: "boolean", description: "Literal string match — no regex interpretation (default false)" },
|
|
334
358
|
before: { type: "integer", description: "Lines of context to show before each match (grep -B). Default 0" },
|
|
@@ -346,7 +370,16 @@ export const grepTool = {
|
|
|
346
370
|
} catch (e) {
|
|
347
371
|
throw new Error(`grep pattern /${args.pattern}/ is not a valid regex: ${e.message}`, { cause: e })
|
|
348
372
|
}
|
|
349
|
-
|
|
373
|
+
// §17 调用侧拆分:glob 参数支持空格分隔 include !exclude 多模式(include/exclude 求交)。
|
|
374
|
+
let fileTest = null
|
|
375
|
+
if (args.glob) {
|
|
376
|
+
try {
|
|
377
|
+
const parts = splitGlobPatterns(args.glob)
|
|
378
|
+
if (parts.length > 0) fileTest = compileGlobMatchers(parts).test
|
|
379
|
+
} catch (e) {
|
|
380
|
+
throw new Error(`grep glob /${args.glob}/ is invalid: ${e.message}`, { cause: e })
|
|
381
|
+
}
|
|
382
|
+
}
|
|
350
383
|
const before = Math.max(0, Math.floor(args.before ?? 0))
|
|
351
384
|
const after = Math.max(0, Math.floor(args.after ?? 0))
|
|
352
385
|
const wantCtx = before > 0 || after > 0
|
|
@@ -373,13 +406,15 @@ export const grepTool = {
|
|
|
373
406
|
}
|
|
374
407
|
}
|
|
375
408
|
|
|
376
|
-
async function walk(target) {
|
|
409
|
+
async function walk(target, rel) {
|
|
377
410
|
if (hits.length >= 200) return
|
|
378
411
|
// Use lstat to avoid following symlinks — prevents ./evil → /etc from making grep scan the entire system
|
|
379
412
|
let s
|
|
380
413
|
try { s = await lstat(target) } catch { return }
|
|
381
414
|
if (!s.isDirectory()) {
|
|
382
|
-
|
|
415
|
+
// rel 为相对搜索基的路径(排除前缀如 !test/** 必须按目录路径作用——不能用裸文件名);
|
|
416
|
+
// 单文件目标(path 指向文件)时 rel 为空 → 退化为按文件名过滤(既有语义)。
|
|
417
|
+
if (!fileTest || fileTest(rel || target.split(/[\\/]/).pop())) await search(target)
|
|
383
418
|
return
|
|
384
419
|
}
|
|
385
420
|
let entries
|
|
@@ -390,11 +425,11 @@ export const grepTool = {
|
|
|
390
425
|
}
|
|
391
426
|
for (const e of entries) {
|
|
392
427
|
if (e.isDirectory() && IGNORED_DIRS.has(e.name)) continue
|
|
393
|
-
await walk(join(target, e.name))
|
|
428
|
+
await walk(join(target, e.name), rel ? `${rel}/${e.name}` : e.name)
|
|
394
429
|
}
|
|
395
430
|
}
|
|
396
431
|
|
|
397
|
-
await walk(base)
|
|
432
|
+
await walk(base, "")
|
|
398
433
|
if (hits.length === 0) return "(no matches)"
|
|
399
434
|
|
|
400
435
|
// No context: keep original path:line: content format
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
Wait until a condition becomes true — polls the condition every interval_ms and returns as soon as it holds or the timeout_ms ceiling passes. Use it ONLY for genuinely asynchronous waits: an async subagent/consult still settling, a file appearing, a port opening. Synchronous tools (advisor, blocking subagent spawn) return when done — waiting after them is NOT needed and wastes time.
|
|
2
|
+
|
|
3
|
+
Parameters:
|
|
4
|
+
- condition (required): a semantic condition expression:
|
|
5
|
+
- `advisor settled` — an in-flight advisor review session has finished (async advisor-role children settled)
|
|
6
|
+
- `subagent id:N done` — async subagent N (the id your async spawn ack returned) has settled
|
|
7
|
+
- `consult done` — every consult_start session has drained
|
|
8
|
+
- `file exists:path` — the file at path exists (path relative to cwd)
|
|
9
|
+
- `port open:N` — something is listening on 127.0.0.1 port N
|
|
10
|
+
- The agent-internal conditions (advisor / subagent / consult) apply to ASYNC sessions only — a synchronous call already completed before it returned and has nothing to wait for. An unknown condition is an explicit error (`wait_for: unsupported condition "..."` — the supported forms are listed above), never a silent wait.
|
|
11
|
+
- interval_ms: poll interval (default 1000, floor 100 — polling never busy-spins)
|
|
12
|
+
- timeout_ms: overall ceiling (default 30000; config.json `agent.waitForTimeoutMs` overrides the default; hard cap 600000 like the execute tool)
|
|
13
|
+
|
|
14
|
+
Returns:
|
|
15
|
+
- `wait_for: condition satisfied after Nms (N checks): "<condition>"` — condition held
|
|
16
|
+
- `wait_for: timed out after Nms waiting for "<condition>" ...` — ceiling passed with the condition still false (never burns beyond the ceiling)
|
|
17
|
+
- interrupts (Ctrl+C / cancel) exit immediately; malformed conditions and unsupported syntax error out explicitly
|
|
18
|
+
|
|
19
|
+
Notes:
|
|
20
|
+
- Read-only and non-destructive — it only observes (agent pools, the filesystem, a local port probe).
|
|
21
|
+
- Blocking by design — call it ALONE in a turn, not batched with calls that depend on its result.
|
|
22
|
+
- Do not use it to wait after synchronous tools (advisor / blocking subagent spawn / verify return only when done).
|
package/src/tui/agent-turn.mjs
CHANGED
|
@@ -1,6 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* agent-turn.mjs — runAgentTurn:一个用户回合的驱动器(submit /
|
|
3
|
-
* + §17 挂起会话(suspensionSession:async 子代理后台运行期间主会话可继续对话)。
|
|
2
|
+
* agent-turn.mjs — runAgentTurn:一个用户回合的驱动器(submit / 队列递归入口)。
|
|
4
3
|
*
|
|
5
4
|
* 2026-08-30 拆分(回回 500 行硬限):回合生命周期(状态复位 → runAgent 循环 →
|
|
6
5
|
* 错误/Continue/中断处理 → finally 收尾 → 队列)留在这里;工具事件 → TUI 状态的
|
|
@@ -9,37 +8,26 @@
|
|
|
9
8
|
* (routeSub* / finishSubTask / freeze*SubTasks)在 subagent-blocks.mjs;标题生成
|
|
10
9
|
* 在 generate-title.mjs ensureSessionTitle。
|
|
11
10
|
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
11
|
+
* 2026-09-05 module-split:§17 挂起会话段(suspensionSession/digestTurn/poolLive/
|
|
12
|
+
* poolCounts 等——agent-turn.mjs 535 > 500 硬限)verbatim 迁至 suspension-drive.mjs——
|
|
13
|
+
* 本文件回合尾经 suspensionSession 进入驱动器;驱动器内 digest/用户回合经 runAgentTurn
|
|
14
|
+
* 递归回本文件(函数级静态环——模块求值期无顶层调用,环安全——session-slots ↔
|
|
15
|
+
* session.mjs 同款先例)。
|
|
16
16
|
*/
|
|
17
17
|
import { runAgent, ContinueError } from "../agent.mjs"
|
|
18
18
|
import { saveSession } from "../session.mjs"
|
|
19
19
|
import { ansi, C } from "./ansi.mjs"
|
|
20
20
|
import { buildToolCallbacks, sweepToolBlocks } from "./tool-events.mjs"
|
|
21
|
-
import { freezeAllSubTasks
|
|
21
|
+
import { freezeAllSubTasks } from "./subagent-blocks.mjs" // freezeReclaimDigestedBlocks 随 §17 段迁 suspension-drive.mjs
|
|
22
22
|
import { ensureSessionTitle } from "../generate-title.mjs"
|
|
23
23
|
import { logEvent, errText } from "../log.mjs"
|
|
24
|
+
import { suspensionSession, poolLive, planQueuedInput } from "./suspension-drive.mjs"
|
|
24
25
|
|
|
25
26
|
/** Exit-flush bound for the async end-of-run distillation (SEND-STALL-DISTILL §2.5):
|
|
26
27
|
* wait at most this long for the in-flight distill before the final session save —
|
|
27
28
|
* never let shutdown hang on the background summary call. */
|
|
28
29
|
const DISTILL_FLUSH_TIMEOUT_MS = 5000
|
|
29
30
|
|
|
30
|
-
/** 后台池计数(LOGGING susp/digest 事件字段——pendingN/poolN)。
|
|
31
|
-
* poolN = _asyncSubagents map 大小(queued 条目同样在 map 内——2026-09-03 code
|
|
32
|
-
* review #2:不再 +queue.length 双计;与 vscode suspension poolCounts 口径一致)。 */
|
|
33
|
-
function poolCounts(agent) {
|
|
34
|
-
const map = agent?._asyncSubagents
|
|
35
|
-
const running = map ? [...map.values()].filter((e) => e.status === "running").length : 0
|
|
36
|
-
return {
|
|
37
|
-
poolN: map?.size ?? 0,
|
|
38
|
-
pendingN: agent?._pendingAsyncResults?.length ?? 0,
|
|
39
|
-
runningN: running,
|
|
40
|
-
}
|
|
41
|
-
}
|
|
42
|
-
|
|
43
31
|
/**
|
|
44
32
|
* LOGGING(docs/design/LOGGING.md)包装:回合骨架事件(turn:start/turn:end——kind
|
|
45
33
|
* user/auto;result ok/stopped/error)。内层经 _logOutcome 载具回传终止原因(中止/
|
|
@@ -302,17 +290,20 @@ async function runAgentTurnInner(ctx, text, opts) {
|
|
|
302
290
|
state.queue.push(...state.pendingInput.splice(0).map((t) => ({ text: String(t) })))
|
|
303
291
|
}
|
|
304
292
|
|
|
305
|
-
// Queued messages: auto-process next one
|
|
293
|
+
// Queued messages: auto-process next one(§24 D-24c/R15:攒批合并——合并仅限
|
|
294
|
+
// 连续文本——/cmd 逐条保序即时——单条超长直发;每条 runAgentTurn 回合后余项
|
|
295
|
+
// 由递归层的本循环续取——边界幂等不丢)
|
|
306
296
|
while (state.queue.length > 0 && !state.processing) {
|
|
307
|
-
const
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
297
|
+
const head = planQueuedInput(state.queue.map((q) => q.text))[0]
|
|
298
|
+
state.queue.splice(0, head.count)
|
|
299
|
+
// Queued slash commands execute directly — order-preserved, never merged
|
|
300
|
+
if (head.kind === "slash") {
|
|
301
|
+
await handleSlash(head.text)
|
|
311
302
|
render()
|
|
312
303
|
continue
|
|
313
304
|
}
|
|
314
305
|
pushLabel(`❯ You: (from queue)`, ansi.bold + C.user)
|
|
315
|
-
await runAgentTurn(ctx,
|
|
306
|
+
await runAgentTurn(ctx, head.text)
|
|
316
307
|
return
|
|
317
308
|
}
|
|
318
309
|
|
|
@@ -331,205 +322,3 @@ async function runAgentTurnInner(ctx, text, opts) {
|
|
|
331
322
|
state._suspAborted = false
|
|
332
323
|
}
|
|
333
324
|
}
|
|
334
|
-
|
|
335
|
-
// ─── §17 挂起会话(AGENT-LOOP.md §17 D-S2/D-S9 状态机行表)────────────────
|
|
336
|
-
|
|
337
|
-
/** 后台池存活判据(D-S2/F5 口径):running/queued 子代理,或已 settle 未注入结果
|
|
338
|
-
* (_pendingAsyncResults 非空 = D-S3 "未注入")。回合尾与每次轮末都用它评估退出。 */
|
|
339
|
-
function poolLive(agent) {
|
|
340
|
-
const map = agent._asyncSubagents
|
|
341
|
-
return (map && map.size > 0) || (agent._pendingAsyncResults?.length ?? 0) > 0
|
|
342
|
-
}
|
|
343
|
-
|
|
344
|
-
/** D-S3 ③ 记账清扫:回合边界竞态落下的已 settle 项(settle 回调未及移交——发生在
|
|
345
|
-
* 回合刚结束、_suspended 尚未置位的窗口)补入 pending。幂等:回调已移交的条目已
|
|
346
|
-
* 从 map 删除并带 _inPending 标记,不会重复入列。 */
|
|
347
|
-
function sweepSettledToPending(agent) {
|
|
348
|
-
const map = agent._asyncSubagents
|
|
349
|
-
if (!map || map.size === 0) return
|
|
350
|
-
agent._pendingAsyncResults ??= []
|
|
351
|
-
for (const e of [...map.values()]) {
|
|
352
|
-
if (e.done && !e._inPending) {
|
|
353
|
-
e._inPending = true
|
|
354
|
-
agent._pendingAsyncResults.push(e)
|
|
355
|
-
map.delete(String(e.id))
|
|
356
|
-
}
|
|
357
|
-
}
|
|
358
|
-
}
|
|
359
|
-
|
|
360
|
-
/** 等待下一次 settle(running 子代理 promise 完成)或用户唤醒(Enter 入队 / Ctrl+C /
|
|
361
|
-
* 会话 abort)。唤醒器经 state._suspWake 单槽注入;abort 监听兜底。 */
|
|
362
|
-
function waitForSettleOrWake(agent, state) {
|
|
363
|
-
return new Promise((resolve) => {
|
|
364
|
-
let finished = false
|
|
365
|
-
const cleanup = () => {
|
|
366
|
-
state._suspWake = null
|
|
367
|
-
const i = (agent._asyncWaiters ?? []).indexOf(w)
|
|
368
|
-
if (i >= 0) agent._asyncWaiters.splice(i, 1)
|
|
369
|
-
agent._sessionAbort?.signal.removeEventListener("abort", onAbort)
|
|
370
|
-
}
|
|
371
|
-
const finish = (why) => {
|
|
372
|
-
if (finished) return
|
|
373
|
-
finished = true
|
|
374
|
-
cleanup()
|
|
375
|
-
resolve(why)
|
|
376
|
-
}
|
|
377
|
-
const w = () => finish("settle")
|
|
378
|
-
const wake = () => finish("wake")
|
|
379
|
-
const onAbort = () => finish("aborted")
|
|
380
|
-
;(agent._asyncWaiters ??= []).push(w)
|
|
381
|
-
state._suspWake = wake
|
|
382
|
-
if (agent._sessionAbort?.signal.aborted) { onAbort(); return }
|
|
383
|
-
agent._sessionAbort?.signal.addEventListener("abort", onAbort, { once: true })
|
|
384
|
-
})
|
|
385
|
-
}
|
|
386
|
-
|
|
387
|
-
/** 后台模式状态行文本(D-S8;17.5.4 #6 顺手对齐):"后台 N 子代理运行中 · M 完成待消化"
|
|
388
|
-
* ——"运行中" = running + queued;"完成待消化" = pending 移交项 + §17.5 回合尾留池的
|
|
389
|
-
* settled 未消费项(挂起会话 sweep 前的可见窗口)。 */
|
|
390
|
-
function backgroundStatusText(agent) {
|
|
391
|
-
const map = agent._asyncSubagents
|
|
392
|
-
const running = map ? [...map.values()].filter((e) => e.status === "running").length : 0
|
|
393
|
-
const queued = agent._asyncQueue?.length ?? 0
|
|
394
|
-
const pending = agent._pendingAsyncResults?.length ?? 0
|
|
395
|
-
const doneInPool = map ? [...map.values()].filter((e) => e.done).length : 0 // §17.5 留池未消费
|
|
396
|
-
const awaiting = pending + doneInPool
|
|
397
|
-
const active = running + queued
|
|
398
|
-
return active > 0 || awaiting > 0
|
|
399
|
-
? `后台 ${active} 子代理运行中${awaiting ? ` · ${awaiting} 完成待消化` : ""}`
|
|
400
|
-
: "后台子代理收尾…"
|
|
401
|
-
}
|
|
402
|
-
|
|
403
|
-
/** 消化轮:系统驱动的 auto-turn(D-S6)。手动档不传权限/问答 handler(D-S7 装配
|
|
404
|
-
* 契约——denied 不弹面板、不悬挂);AUTO 档沿用普通回调(autoApprove 短路自动
|
|
405
|
-
* 执行)。_suspended 保持 true:消化中 settle 延迟冻结 + 移交 pending。 */
|
|
406
|
-
async function digestTurn(ctx) {
|
|
407
|
-
const { agent, pushLine } = ctx
|
|
408
|
-
const manual = !agent.autoApprove
|
|
409
|
-
pushLine(manual
|
|
410
|
-
? "[auto-turn: digesting finished subagent reports…]"
|
|
411
|
-
: "[auto-turn: continuing background work…]", C.dim)
|
|
412
|
-
const digestCtx = manual
|
|
413
|
-
? { ...ctx, askPermission: null, askBatchPermission: null, askQuestion: null }
|
|
414
|
-
: ctx
|
|
415
|
-
// LOGGING:digest:* 事件(D-S9 消化轮边界——LOGGING.md F-L4 挂起态覆盖)
|
|
416
|
-
const d0 = Date.now()
|
|
417
|
-
const pend0 = agent?._pendingAsyncResults?.length ?? 0
|
|
418
|
-
logEvent("digest:start", { pendingN: pend0 })
|
|
419
|
-
await runAgentTurn(digestCtx, "", { autoTurn: true, skipSession: true })
|
|
420
|
-
logEvent("digest:end", { pendingN: agent?._pendingAsyncResults?.length ?? 0, ms: Date.now() - d0 })
|
|
421
|
-
}
|
|
422
|
-
|
|
423
|
-
/**
|
|
424
|
-
* §17 挂起会话驱动(D-S9 行表;由 runAgentTurn 回合尾进入,池空自然退出):
|
|
425
|
-
* - suspension:池项 settle → 入 pending → 开 auto-turn(合并消化近邻 settle);
|
|
426
|
-
* 用户 Enter → pendingInput(digest 运行中排队,D-S5)——用户输入优先于 digest;
|
|
427
|
-
* - auto-turn:消化中 settle 不并发开新轮(单 runAgent 循环),轮末按 pending/池态
|
|
428
|
-
* 续开合并消化轮或回挂起;pendingInput 非空 → 以该消息开新回合(不触发新 digest);
|
|
429
|
-
* - §17.5.5:每次消化/会话内用户回合消费 pending 后 → freezeReclaimDigestedBlocks
|
|
430
|
-
* 逐条冻结回收(消化完成块不滞留面板——不等池空;settle 锚点 splice——digest 总览
|
|
431
|
-
* 文本之前——round1 #1 裁定);
|
|
432
|
-
* - 退出:池空 + pending 空 + 无待处理输入 → freezeAllSubTasks 补发冻结(仅兜底
|
|
433
|
-
* 未消化残项——17.5.5 块回收与池空解耦)→ idle。
|
|
434
|
-
* _suspended 翻转:会话期 true(settle 回调据此延迟冻结 + 移交 pending);会话内
|
|
435
|
-
* 用户回合执行期翻 false(普通回合语义:settle 即冻结 + 回合尾直注入 ①)。
|
|
436
|
-
*/
|
|
437
|
-
async function suspensionSession(ctx) {
|
|
438
|
-
const { agent, state, render, pushLine } = ctx
|
|
439
|
-
state.pendingInput ??= []
|
|
440
|
-
state._suspAborted = false
|
|
441
|
-
agent._suspended = true
|
|
442
|
-
agent._sessionSignal = agent._sessionAbort.signal // 会话内 spawn 的 children 共享(subagent.mjs)
|
|
443
|
-
state.suspended = true
|
|
444
|
-
state._suspPending = false // 偏差 #1:进入真正挂起态——标志只在释放窗口期有效(此后由 state.suspended 分流)
|
|
445
|
-
const suspTick = setInterval(() => {
|
|
446
|
-
if (state.suspended && !state.processing) {
|
|
447
|
-
state.status = backgroundStatusText(agent)
|
|
448
|
-
render()
|
|
449
|
-
}
|
|
450
|
-
}, 1000)
|
|
451
|
-
state.status = backgroundStatusText(agent)
|
|
452
|
-
render()
|
|
453
|
-
// LOGGING:susp:* 事件(挂起态进入/退出——F-L4;挂起期输入事件 v1 不记——refinement #1)
|
|
454
|
-
const s0 = Date.now()
|
|
455
|
-
logEvent("susp:enter", poolCounts(agent))
|
|
456
|
-
try {
|
|
457
|
-
while (!state._suspAborted && !agent._sessionAbort.signal.aborted) {
|
|
458
|
-
sweepSettledToPending(agent)
|
|
459
|
-
// 1. 用户输入优先(D-S5):pendingInput 队列 + 消化期排队的 slash 命令
|
|
460
|
-
const queuedText = state.pendingInput.length > 0 ? state.pendingInput.shift()
|
|
461
|
-
: state.queue.length > 0 ? state.queue.shift().text : null
|
|
462
|
-
if (queuedText) {
|
|
463
|
-
if (String(queuedText).startsWith("/")) {
|
|
464
|
-
await ctx.handleSlash?.(queuedText)
|
|
465
|
-
render()
|
|
466
|
-
continue
|
|
467
|
-
}
|
|
468
|
-
agent._suspended = false // 用户回合 = 普通回合语义(① 直注入 + settle 即冻结)
|
|
469
|
-
await runAgentTurn(ctx, String(queuedText), { skipSession: true })
|
|
470
|
-
agent._suspended = true
|
|
471
|
-
// §17.5.5:该回合消化完 pending(run 首行注入)→ 逐条冻结回收驻留块
|
|
472
|
-
// (不等池空——settle 锚点 splice——digest 总览文本之前;与 digest 回收同规则)
|
|
473
|
-
freezeReclaimDigestedBlocks(state, agent._pendingAsyncResults ?? [])
|
|
474
|
-
state.status = backgroundStatusText(agent)
|
|
475
|
-
continue
|
|
476
|
-
}
|
|
477
|
-
// 2. pending 非空 → 合并消化轮(注入由 runAgent 首行统一完成——D-S3 单注入点)
|
|
478
|
-
if ((agent._pendingAsyncResults?.length ?? 0) > 0) {
|
|
479
|
-
await digestTurn(ctx)
|
|
480
|
-
// §17.5.5 实测修订(2026-09-03):digest 消化完成(pending 条目已注入)→ 逐条补发
|
|
481
|
-
// done 冻结回收——不等池空——块从面板移除进流(settle 锚点 splice 落位——digest
|
|
482
|
-
// 总览文本之前——round1 #1 裁定);池空 freeze-out 仅兜底未消化残项(挂起会话
|
|
483
|
-
// 结束统一清场)——块回收与池空解耦(T-H7/AC-H5)。
|
|
484
|
-
// 归属不变式:会话内任何 run 开始前 pinned 块(awaitingDigest)的条目必在 pending
|
|
485
|
-
// ——run 消费后不在 pending 的 pinned 块即本 run 消化者(无需快照即精确归属)。
|
|
486
|
-
freezeReclaimDigestedBlocks(state, agent._pendingAsyncResults ?? [])
|
|
487
|
-
state.status = backgroundStatusText(agent)
|
|
488
|
-
continue
|
|
489
|
-
}
|
|
490
|
-
// 3. 池空(无 running/queued/未注入)→ 自然退出回 idle(补发冻结在 finally)
|
|
491
|
-
if (!poolLive(agent)) break
|
|
492
|
-
// 4. 等下一 settle / 用户唤醒(Enter 入队、Ctrl+C)
|
|
493
|
-
await waitForSettleOrWake(agent, state)
|
|
494
|
-
}
|
|
495
|
-
} finally {
|
|
496
|
-
clearInterval(suspTick)
|
|
497
|
-
const aborted = state._suspAborted || agent._sessionAbort.signal.aborted
|
|
498
|
-
if (aborted && (agent._asyncSubagents?.size ?? 0) > 0) logEvent("ev:stopped", { poolN: agent._asyncSubagents?.size ?? 0, where: "suspension-abort" })
|
|
499
|
-
logEvent("susp:exit", { ...poolCounts(agent), ms: Date.now() - s0, reason: aborted ? "aborted" : "idle" })
|
|
500
|
-
agent._suspended = false
|
|
501
|
-
agent._sessionSignal = null
|
|
502
|
-
agent._sessionAbort = null
|
|
503
|
-
agent._sessionAbortAll = null // 偏差 #3:会话期 controller 集合随句柄一并释放
|
|
504
|
-
state.suspended = false
|
|
505
|
-
state._suspWake = null
|
|
506
|
-
state.suspAbortArmed = false // round2 偏差 #4:会话退出即解除挂起中止武装(防跨会话粘滞)
|
|
507
|
-
if (aborted) {
|
|
508
|
-
// §15 abort 语义:清池不注入(用户显式停——不注入陈旧错误)
|
|
509
|
-
agent._asyncSubagents?.clear()
|
|
510
|
-
agent._asyncQueue = []
|
|
511
|
-
agent._pendingAsyncResults = []
|
|
512
|
-
// §17 round2 偏差 #2-CLI(code review round2 #2-CLI):中止时不静默丢弃挂起期
|
|
513
|
-
// 排队的用户消息——Enter 已清空输入框并入 pendingInput(用户视为已发送),
|
|
514
|
-
// 残余转回 state.queue({text} 条目,下个普通回合的队列循环续发——零丢失)
|
|
515
|
-
// + 提示行明示去向(不静默丢)。
|
|
516
|
-
const queuedN = state.pendingInput?.length ?? 0
|
|
517
|
-
if (queuedN > 0) {
|
|
518
|
-
state.queue.push(...state.pendingInput.splice(0).map((t) => ({ text: String(t) })))
|
|
519
|
-
pushLine(`[background work stopped — ${queuedN} queued message${queuedN > 1 ? "s" : ""} will run as a normal turn]`, C.warn)
|
|
520
|
-
}
|
|
521
|
-
} else {
|
|
522
|
-
// D-S3 ③ 兜底:退出前残余(极端竞态)直注入再退——结果零丢失(AC-S2)
|
|
523
|
-
const residual = agent._pendingAsyncResults
|
|
524
|
-
if (residual?.length) {
|
|
525
|
-
const { injectAsyncResult } = await import("../agent-tools/subagent.mjs")
|
|
526
|
-
for (const e of residual.splice(0)) await injectAsyncResult(agent, e)
|
|
527
|
-
}
|
|
528
|
-
}
|
|
529
|
-
// 补发 done 冻结:驻留面板的 awaiting-digest 块随池空冻结进流(T-S14)
|
|
530
|
-
freezeAllSubTasks(state)
|
|
531
|
-
sweepToolBlocks(state)
|
|
532
|
-
state.status = "Ready"
|
|
533
|
-
render()
|
|
534
|
-
}
|
|
535
|
-
}
|
package/src/tui/cmd-config.mjs
CHANGED
|
@@ -1,4 +1,3 @@
|
|
|
1
|
-
import { existsSync, readFileSync } from "node:fs"
|
|
2
1
|
import { ansi, C } from "./ansi.mjs"
|
|
3
2
|
/** Merge an embedding-key save into the raw config, backfilling baseURL/model from defaults.
|
|
4
3
|
* Keeps existing custom values (Ollama/local embedding); defaults are the single source
|
|
@@ -31,9 +30,10 @@ export async function handleConfigCommand(ctx, args = []) {
|
|
|
31
30
|
async function setEmbedKey() {
|
|
32
31
|
const embKey = await askQuestion("Enter embedding API key (default: SiliconFlow bge-m3):")
|
|
33
32
|
if (!embKey) return false
|
|
33
|
+
// D-F5b 语义先盘后存(embeddingPatch 在磁盘 fresh raw 上合并——冲突放弃不留 ghost)
|
|
34
|
+
await persistRaw((raw) => { raw.embedding = embeddingPatch(raw, embKey, DEFAULTS.embedding) })
|
|
34
35
|
agent.config.embedding ??= {}
|
|
35
36
|
agent.config.embedding.apiKey = embKey
|
|
36
|
-
await persistRaw((raw) => { raw.embedding = embeddingPatch(raw, embKey, DEFAULTS.embedding) })
|
|
37
37
|
if (agent.memory) {
|
|
38
38
|
const { createEmbedder } = await import("../embedding.mjs")
|
|
39
39
|
agent.memory.embedder = createEmbedder(agent.config.embedding)
|
|
@@ -78,13 +78,15 @@ export async function handleConfigCommand(ctx, args = []) {
|
|
|
78
78
|
}
|
|
79
79
|
}
|
|
80
80
|
|
|
81
|
-
/** 保存 config(mutate 改 raw)→ reloadConfig(provider
|
|
81
|
+
/** 保存 config(mutate 改 raw)→ reloadConfig(provider 代理无需重启即生效)。
|
|
82
|
+
* D-F5b:磁盘新鲜读 → mutate → 写前 mtime 门控(writeConfigAtomic——冲突放弃 +
|
|
83
|
+
* .bak 留现场);冲突时先 reloadConfig 采纳磁盘新值再 throw——调用方 try/catch
|
|
84
|
+
* 统一展示 "Save failed: config changed on disk concurrently — retry"。 */
|
|
82
85
|
async function saveProxy(mutate) {
|
|
83
|
-
const
|
|
84
|
-
mutate
|
|
85
|
-
const { saveConfig } = await import("../config.mjs")
|
|
86
|
-
saveConfig(raw)
|
|
86
|
+
const { writeConfigAtomic, configPath } = await import("../config.mjs")
|
|
87
|
+
const r = writeConfigAtomic(configPath, mutate)
|
|
87
88
|
await reloadConfig()
|
|
89
|
+
if (!r.ok) throw new Error("config changed on disk concurrently — retry")
|
|
88
90
|
}
|
|
89
91
|
|
|
90
92
|
// ── Proxy sub-menu loop:每轮重建 entries 显示最新状态,defaultIndex 记住上次位置 ──
|
|
@@ -229,6 +231,36 @@ export async function handleConfigCommand(ctx, args = []) {
|
|
|
229
231
|
}
|
|
230
232
|
}
|
|
231
233
|
|
|
234
|
+
// ── 并发池子菜单(§24 D-24a/R14——agent.poolLimits——读/改两域;保存经 saveProxy
|
|
235
|
+
// 落盘 + reloadConfig 热应用——下个 spawn 生效)──
|
|
236
|
+
async function poolMenu() {
|
|
237
|
+
let poolIdx = 0
|
|
238
|
+
for (;;) {
|
|
239
|
+
const pl = agent.config?.agent?.poolLimits ?? {}
|
|
240
|
+
// 显示回退与默认同源(DEFAULTS.agent.poolLimits——配置 DEFAULTS 与运行时回退常量
|
|
241
|
+
// ASYNC_POOL_LIMITS 的耦合由 T-24a4 锚定断言锁住——防默认值单侧漂移)
|
|
242
|
+
const cur = (k) => (Number.isInteger(pl[k]) && pl[k] >= 1 ? pl[k] : DEFAULTS.agent.poolLimits[k])
|
|
243
|
+
const entries = [
|
|
244
|
+
{ type: "header", text: `Async pools: eng-coder ${cur("engCoder")} / other ${cur("other")}(默认 4/4——分域互不阻塞——超限排队)` },
|
|
245
|
+
{ type: "item", text: `eng-coder 池上限 = ${cur("engCoder")}`, action: "engCoder" },
|
|
246
|
+
{ type: "item", text: `其他角色池上限 = ${cur("other")}`, action: "other" },
|
|
247
|
+
]
|
|
248
|
+
const c = await showPicker("并发池(agent.poolLimits)", entries, { defaultIndex: poolIdx })
|
|
249
|
+
if (!c) return // Esc 返回主菜单
|
|
250
|
+
poolIdx = Math.max(0, entries.filter((e) => e.type === "item").indexOf(c))
|
|
251
|
+
const val = await askQuestion(`${c.action} pool limit (current: ${cur(c.action)} — positive integer ≥1):`)
|
|
252
|
+
if (!val) continue // 空输入不改动
|
|
253
|
+
const num = Number(val)
|
|
254
|
+
if (!Number.isInteger(num) || num < 1) { pushLine("Pool limit must be a positive integer (≥1)", C.error); continue }
|
|
255
|
+
const next = { engCoder: cur("engCoder"), other: cur("other"), [c.action]: num }
|
|
256
|
+
try {
|
|
257
|
+
await saveProxy((raw) => { raw.agent ??= {}; raw.agent.poolLimits = { engCoder: next.engCoder, other: next.other } })
|
|
258
|
+
pushLabel("❯ Config", ansi.bold + C.tool)
|
|
259
|
+
pushLine(`agent.poolLimits = { engCoder: ${next.engCoder}, other: ${next.other} }(下个 spawn 生效——分域互不阻塞)`, C.tool)
|
|
260
|
+
} catch (error) { pushLine(`Save failed: ${error.message}`, C.error) }
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
|
|
232
264
|
// ── Main config loop ──
|
|
233
265
|
let running = true
|
|
234
266
|
let mainIdx = 0 // 记住上次选中位置,改完一项回主菜单时恢复
|
|
@@ -238,10 +270,13 @@ export async function handleConfigCommand(ctx, args = []) {
|
|
|
238
270
|
ec = agent.config?.embedding ?? {}
|
|
239
271
|
tc = agent.config?.traces ?? {}
|
|
240
272
|
const consultCount = (ac.consultModels ?? []).length
|
|
273
|
+
const pl = ac.poolLimits ?? {}
|
|
274
|
+
const cur = (k) => (Number.isInteger(pl[k]) && pl[k] >= 1 ? pl[k] : DEFAULTS.agent.poolLimits[k])
|
|
241
275
|
const mainEntries = [
|
|
242
276
|
{ type: "header", text: `proxy=${proxySummary()} | maxTurns=${ac.maxTurns ?? 200} | compactThreshold=${ac.compactThreshold ?? 100000} | verifyGuard=${ac.verifyGuard === true ? "on" : "off"} | consult=${consultCount} model(s) | embedding=${agent.memory?.embedder ? "on" : "off"} | traces=${tc.enabled === false ? "off" : "on"}` },
|
|
243
277
|
{ type: "item", text: `agent.maxTurns = ${ac.maxTurns ?? 200}`, action: "agent.maxTurns" },
|
|
244
278
|
{ type: "item", text: `agent.subagentTurns = ${ac.subagentTurns ?? 100}`, action: "agent.subagentTurns" },
|
|
279
|
+
{ type: "item", text: `并发池 agent.poolLimits = engCoder ${cur("engCoder")} / other ${cur("other")}(async 分域上限)`, action: "pool" },
|
|
245
280
|
{ type: "item", text: `agent.compactThreshold = ${ac.compactThreshold ?? 100000}${agent.config?.agent?.compactThresholdAuto ? " (auto)" : ""}`, action: "agent.compactThreshold" },
|
|
246
281
|
{ type: "item", text: `agent.verifyGuard = ${ac.verifyGuard === true ? "on" : "off"}`, action: "agent.verifyGuard" },
|
|
247
282
|
{ type: "item", text: `traces.enabled = ${tc.enabled === false ? "off" : "on"}(轨迹存档——发布默认关——隐私)`, action: "traces.enabled" },
|
|
@@ -269,6 +304,7 @@ export async function handleConfigCommand(ctx, args = []) {
|
|
|
269
304
|
pushLine(`traces.enabled: ${tc.enabled === false ? "off" : "on"}(默认 off——发布隐私——本地分析可开)`, C.dim)
|
|
270
305
|
pushLine(`traces.retentionHours: ${tc.retentionHours ?? 24}(超期文件启动清理——D-TR10)`, C.dim)
|
|
271
306
|
pushLine(`agent.consultModels: ${(ac.consultModels ?? []).map((m) => `${m.provider}:${m.model}${m.effort ? ` (${m.effort})` : ""}`).join(", ") || "(none)"}`, C.dim)
|
|
307
|
+
pushLine(`agent.poolLimits: { engCoder: ${cur("engCoder")}, other: ${cur("other")} }(async 分域上限——默认 4/4——agent.poolLimits 可配)`, C.dim)
|
|
272
308
|
pushLine(`agent.consultTurns: ${ac.consultTurns ?? 40}`, C.dim)
|
|
273
309
|
pushLine(`agent.consultTimeoutMs: ${Math.round((ac.consultTimeoutMs ?? 600000) / 60000)} min`, C.dim)
|
|
274
310
|
pushLine(`embedding: ${agent.memory?.embedder ? `enabled (${ec.model ?? ""})` : "disabled (FTS only)"}`, C.dim)
|
|
@@ -287,6 +323,11 @@ export async function handleConfigCommand(ctx, args = []) {
|
|
|
287
323
|
continue
|
|
288
324
|
}
|
|
289
325
|
|
|
326
|
+
if (choice.action === "pool") {
|
|
327
|
+
await poolMenu()
|
|
328
|
+
continue
|
|
329
|
+
}
|
|
330
|
+
|
|
290
331
|
if (choice.action === "embedkey") {
|
|
291
332
|
await setEmbedKey() // 保存成功/取消都回主菜单(Esc 退出)
|
|
292
333
|
continue
|
package/src/tui/cmd-eng.mjs
CHANGED
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
/** /eng command: toggle engineering mode.
|
|
2
2
|
* Requires METHODOLOGY.md in project root. Offers to create one if missing.
|
|
3
|
-
* ctx: { agent, pushLine, pushLabel,
|
|
3
|
+
* ctx: { agent, pushLine, pushLabel, showPicker } */
|
|
4
4
|
import { existsSync, copyFileSync, readFileSync, writeFileSync, mkdirSync, renameSync, unlinkSync } from "node:fs"
|
|
5
5
|
import { join, dirname } from "node:path"
|
|
6
6
|
import { fileURLToPath } from "node:url"
|
|
7
7
|
import { ansi, C } from "./ansi.mjs"
|
|
8
8
|
import { activeSlot, slotPath } from "../session.mjs"
|
|
9
|
+
import { purgeExpiredDesignTokens } from "../token-ttl.mjs"
|
|
9
10
|
|
|
10
11
|
const templateDir = join(fileURLToPath(import.meta.url), "..", "..", "prompts")
|
|
11
12
|
import { ENG_OFF_REMINDER } from "../agent.mjs"
|
|
@@ -25,7 +26,7 @@ export function writeSessionFile(p, data) {
|
|
|
25
26
|
}
|
|
26
27
|
|
|
27
28
|
export async function handleEngCommand(ctx) {
|
|
28
|
-
const { agent, pushLine, pushLabel,
|
|
29
|
+
const { agent, pushLine, pushLabel, showPicker } = ctx
|
|
29
30
|
agent.config.agent ??= {}
|
|
30
31
|
const methodologyPath = join(agent.cwd, "METHODOLOGY.md")
|
|
31
32
|
|
|
@@ -47,31 +48,40 @@ export async function handleEngCommand(ctx) {
|
|
|
47
48
|
}
|
|
48
49
|
|
|
49
50
|
agent.config.agent.engineering = !agent.config.agent.engineering
|
|
51
|
+
// §24 D-24b: per-review instances die with the mode (fresh convergence cycles
|
|
52
|
+
// on the next toggle).
|
|
53
|
+
agent._advisorRuns = new Map()
|
|
50
54
|
if (!agent.config.agent.engineering) {
|
|
51
|
-
|
|
52
|
-
|
|
55
|
+
// R16 (2026-09-06 F-R16a): OFF 不清 token——有效 token 跨模式存活(仅 TTL 过期
|
|
56
|
+
// 在三清理时机删:恢复过滤 / 开模式清过期 / spawn 门禁拒时删槽)。
|
|
53
57
|
// OFF must reach the model too (2026-08-25): /auto pushes a reminder on toggle — the
|
|
54
58
|
// mode flip is invisible to the agent otherwise. (ON needs none here: the injector
|
|
55
59
|
// in agent.mjs already announces ON transitions on the next turn.)
|
|
56
60
|
agent._pendingReminders = agent._pendingReminders ?? []
|
|
57
61
|
agent._pendingReminders.push(ENG_OFF_REMINDER)
|
|
58
62
|
}
|
|
59
|
-
|
|
63
|
+
// 开工程模式(真实 OFF→ON 转换)→ 清过期 token(有效保留——用户裁定"打开工程
|
|
64
|
+
// 模式时应该清理"——F-R16b ②——与 eng tool enter 同语义)。
|
|
65
|
+
const clearedExpired = agent.config.agent.engineering ? purgeExpiredDesignTokens(agent) : 0
|
|
66
|
+
await persistEngineering(agent)
|
|
60
67
|
pushLabel("❯ Eng", ansi.bold + C.tool)
|
|
61
68
|
pushLine(`Engineering mode: ${agent.config.agent.engineering ? "ON" : "OFF"} (session)`, C.tool)
|
|
62
69
|
if (agent.config.agent.engineering) {
|
|
63
70
|
pushLine(` → strictly following ${methodologyPath}`, C.dim)
|
|
71
|
+
if (clearedExpired > 0) {
|
|
72
|
+
pushLine(` → cleared ${clearedExpired} expired design token${clearedExpired === 1 ? "" : "s"}; valid tokens from prior reviews stay usable`, C.dim)
|
|
73
|
+
}
|
|
64
74
|
}
|
|
65
75
|
}
|
|
66
76
|
|
|
67
77
|
/**
|
|
68
|
-
*
|
|
69
|
-
* the CURRENT session slot
|
|
70
|
-
*
|
|
78
|
+
* Slot-only persistence (2026-09-08 — ENG-SESSION-PROVIDER-CLEANUP D1.1): the flipped flag
|
|
79
|
+
* goes into the CURRENT session slot only — the slot is the sole authority (shared with
|
|
80
|
+
* VS Code, per-session; config.json is just the initial default, no mirror write).
|
|
71
81
|
* The in-memory agent.config.agent.engineering (already flipped) stays the live authority for
|
|
72
82
|
* this process; saveSession also round-trips it on every turn-end write.
|
|
73
83
|
*/
|
|
74
|
-
async function persistEngineering(
|
|
84
|
+
async function persistEngineering(agent) {
|
|
75
85
|
const slot = activeSlot(agent.cwd)
|
|
76
86
|
try {
|
|
77
87
|
const p = slotPath(agent.cwd, slot)
|
|
@@ -80,11 +90,5 @@ async function persistEngineering(ctx, agent) {
|
|
|
80
90
|
data.engineering = agent.config.agent.engineering
|
|
81
91
|
writeSessionFile(p, data)
|
|
82
92
|
}
|
|
83
|
-
} catch { /* slot missing/unreadable —
|
|
84
|
-
if (ctx.persistRaw) {
|
|
85
|
-
await ctx.persistRaw((raw) => {
|
|
86
|
-
raw.agent ??= {}
|
|
87
|
-
raw.agent.engineering = agent.config.agent.engineering
|
|
88
|
-
})
|
|
89
|
-
}
|
|
93
|
+
} catch { /* slot missing/unreadable — in-memory flag already flipped; saveSession persists at turn end */ }
|
|
90
94
|
}
|
package/src/tui/cmd-mcp.mjs
CHANGED
|
@@ -82,9 +82,15 @@ export async function handleMcpCommand(ctx, args = []) {
|
|
|
82
82
|
}
|
|
83
83
|
|
|
84
84
|
async function removeServer(name) {
|
|
85
|
+
// D-F5a(#7)先盘后存:磁盘 fresh raw.mcp.servers 上只删目标条目——不整节写回内存
|
|
86
|
+
// agent.config.mcp.servers(内存含 reloadMcpFromDisk 的 keptConnected 尾巴——
|
|
87
|
+
// 整节写回会把对端磁盘上其他 server 的新改动一起抹掉);冲突放弃不留内存 ghost
|
|
88
|
+
await persistRaw((raw) => {
|
|
89
|
+
raw.mcp ??= { servers: [] }
|
|
90
|
+
if (!Array.isArray(raw.mcp.servers)) return
|
|
91
|
+
raw.mcp.servers = raw.mcp.servers.filter((s) => s?.name !== name)
|
|
92
|
+
})
|
|
85
93
|
agent.config.mcp.servers = getServers().filter((s) => s.name !== name)
|
|
86
|
-
// 评审 #1:磁盘无 mcp 段时(T23 场景——mcp 段被整体删除而连接保留)raw.mcp 为 undefined → 先建段再写
|
|
87
|
-
await persistRaw((raw) => { raw.mcp ??= { servers: [] }; raw.mcp.servers = agent.config.mcp.servers })
|
|
88
94
|
// Remove from tool list
|
|
89
95
|
const { removeMcpTools } = await import("../mcp.mjs")
|
|
90
96
|
removeMcpTools(agent, name)
|