thincoder 0.12.54 → 0.12.59

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.
Files changed (129) hide show
  1. package/CHANGELOG.md +98 -0
  2. package/README.md +1 -1
  3. package/bin/thincoder.mjs +25 -3
  4. package/package.json +3 -7
  5. package/src/acp/bridge.mjs +132 -26
  6. package/src/advisor/messages.mjs +38 -3
  7. package/src/advisor/run.mjs +91 -53
  8. package/src/advisor.mjs +15 -7
  9. package/src/agent/dispatch.mjs +156 -39
  10. package/src/agent/helpers.mjs +46 -4
  11. package/src/agent/setup.mjs +102 -19
  12. package/src/agent/spawn-child.mjs +28 -1
  13. package/src/agent-tools/advisor.mjs +43 -11
  14. package/src/agent-tools/consult.mjs +37 -6
  15. package/src/agent-tools/eng.mjs +4 -1
  16. package/src/agent-tools/goal.mjs +11 -1
  17. package/src/agent-tools/read-history.mjs +160 -0
  18. package/src/agent-tools/settings.mjs +162 -0
  19. package/src/agent-tools/skill.mjs +2 -1
  20. package/src/agent-tools/subagent-actions.mjs +432 -0
  21. package/src/agent-tools/subagent-async.mjs +427 -0
  22. package/src/agent-tools/subagent-scheduler.mjs +319 -0
  23. package/src/agent-tools/subagent.mjs +565 -128
  24. package/src/agent-tools/task.mjs +4 -3
  25. package/src/agent-tools/timer.mjs +9 -4
  26. package/src/agent-tools/verify.mjs +161 -49
  27. package/src/agent-tools.mjs +1 -0
  28. package/src/agent.mjs +182 -81
  29. package/src/auto-think.mjs +14 -0
  30. package/src/cli/make-agent.mjs +27 -1
  31. package/src/cli/memory-command.mjs +28 -7
  32. package/src/cli/permission.mjs +8 -1
  33. package/src/config.mjs +125 -8
  34. package/src/context.mjs +115 -34
  35. package/src/distill.mjs +19 -1
  36. package/src/escape.mjs +82 -27
  37. package/src/log.mjs +195 -0
  38. package/src/mcp/transport-http.mjs +13 -1
  39. package/src/mcp.mjs +52 -7
  40. package/src/memory/code-sync.mjs +1 -1
  41. package/src/memory/core.mjs +204 -10
  42. package/src/memory/docs.mjs +197 -62
  43. package/src/memory.mjs +1 -1
  44. package/src/model-specs.mjs +38 -1
  45. package/src/prompts/advisor-design.md +46 -0
  46. package/src/prompts/advisor-round1.md +49 -2
  47. package/src/prompts/advisor-round2.md +47 -0
  48. package/src/prompts/advisor-round3.md +47 -0
  49. package/src/prompts/coder.md +22 -0
  50. package/src/prompts/consult-base.md +13 -0
  51. package/src/prompts/discipline.md +25 -6
  52. package/src/prompts/eng-coder.md +2 -2
  53. package/src/prompts/engineering-sub.md +23 -1
  54. package/src/prompts/engineering.md +157 -50
  55. package/src/prompts/explore.md +1 -2
  56. package/src/prompts/main.md +11 -5
  57. package/src/prompts/methodology-template.md +14 -0
  58. package/src/prompts/system.md +5 -2
  59. package/src/provider/anthropic.mjs +7 -5
  60. package/src/provider/core.mjs +104 -28
  61. package/src/provider/google.mjs +57 -24
  62. package/src/provider/normalize.mjs +1 -1
  63. package/src/provider/rate.mjs +0 -2
  64. package/src/provider/responses.mjs +8 -13
  65. package/src/provider/sse.mjs +20 -0
  66. package/src/session.mjs +15 -0
  67. package/src/tools/apply_patch.md +5 -1
  68. package/src/tools/bash.md +3 -3
  69. package/src/tools/delete.md +1 -0
  70. package/src/tools/edit-batch.mjs +92 -0
  71. package/src/tools/edit-diff.mjs +265 -0
  72. package/src/tools/edit.md +11 -6
  73. package/src/tools/execute.md +8 -8
  74. package/src/tools/execute.mjs +31 -35
  75. package/src/tools/file.mjs +26 -114
  76. package/src/tools/file_ops.md +3 -2
  77. package/src/tools/get_current_time.md +3 -1
  78. package/src/tools/git.md +1 -1
  79. package/src/tools/git.mjs +8 -16
  80. package/src/tools/hashline_edit.md +2 -0
  81. package/src/tools/index.mjs +3 -2
  82. package/src/tools/insert_after.md +2 -1
  83. package/src/tools/lint.md +3 -1
  84. package/src/tools/linter.mjs +9 -37
  85. package/src/tools/lsp.md +4 -1
  86. package/src/tools/patch.mjs +84 -13
  87. package/src/tools/pdf-parse-text.mjs +497 -0
  88. package/src/tools/pdf-parse-xref.mjs +499 -0
  89. package/src/tools/pdf.mjs +155 -0
  90. package/src/tools/question.md +2 -1
  91. package/src/tools/read.md +1 -0
  92. package/src/tools/read_pdf.md +21 -0
  93. package/src/tools/repomap.mjs +1 -1
  94. package/src/tools/shared.mjs +11 -32
  95. package/src/tools/system.mjs +6 -21
  96. package/src/tools/tree.md +2 -1
  97. package/src/tools/web.mjs +5 -3
  98. package/src/tools/websearch.md +2 -1
  99. package/src/tools/write.md +2 -0
  100. package/src/traces/trace-store.mjs +224 -0
  101. package/src/tui/agent-turn.mjs +387 -24
  102. package/src/tui/clipboard.mjs +17 -6
  103. package/src/tui/cmd-config.mjs +29 -9
  104. package/src/tui/cmd-eng.mjs +1 -0
  105. package/src/tui/cmd-extract.mjs +1 -1
  106. package/src/tui/cmd-mcp-form.mjs +197 -0
  107. package/src/tui/cmd-mcp.mjs +264 -114
  108. package/src/tui/cmd-think.mjs +1 -1
  109. package/src/tui/index.mjs +49 -95
  110. package/src/tui/interaction.mjs +41 -3
  111. package/src/tui/key-handler.mjs +105 -143
  112. package/src/tui/key-modes.mjs +215 -0
  113. package/src/tui/layout.mjs +22 -1
  114. package/src/tui/mouse.mjs +41 -1
  115. package/src/tui/pickers.mjs +73 -7
  116. package/src/tui/render-conversation.mjs +13 -161
  117. package/src/tui/render-frame.mjs +45 -20
  118. package/src/tui/render-loop.mjs +4 -1
  119. package/src/tui/render-segments.mjs +165 -0
  120. package/src/tui/render.mjs +4 -4
  121. package/src/tui/startup.mjs +40 -2
  122. package/src/tui/subagent-blocks.mjs +404 -111
  123. package/src/tui/subagent-panel.mjs +88 -13
  124. package/src/tui/tool-args.mjs +10 -2
  125. package/src/tui/tool-events.mjs +172 -95
  126. package/src/tui/update-notice.mjs +72 -0
  127. package/src/tui/wizard.mjs +36 -6
  128. package/src/agent-tools/escalate.mjs +0 -179
  129. package/src/tools/exec-prelude.mjs +0 -84
@@ -0,0 +1,72 @@
1
+ /**
2
+ * update-notice.mjs — 后台升级提示(2026-09-03 D-S1c 自 index.mjs 拆出):
3
+ * upgradeFailureText / pendingNoticeReady(纯函数,index.mjs re-export——tui.test
4
+ * 动态 import 面零改动)+ createUpdateNotice 装配(升级提示 picker + 启动检查)。
5
+ * index.mjs 只保留装配调用(showPicker 闭包在此经 ctx 注入)。
6
+ */
7
+ import { ansi, C } from "./ansi.mjs"
8
+
9
+ /** 升级失败提示文案:附 npm 输出尾部(最多 3 行),方便定位失败原因。 */
10
+ export function upgradeFailureText(code, output) {
11
+ const tail = (output ?? "").trimEnd().split("\n").slice(-3).join("\n")
12
+ return `✗ Upgrade failed (exit ${code}). Run \`thincoder upgrade\` manually.${tail ? `\n${tail}` : ""}`
13
+ }
14
+
15
+ /** 后台更新提示可弹出的条件:无任何交互弹层(picker/permission/question)激活。 */
16
+ export function pendingNoticeReady(state) {
17
+ return Boolean(state.pendingNotice && !state.picker && !state.permission && !state.question)
18
+ }
19
+
20
+ /**
21
+ * 升级提示 + 启动检查装配。ctx: { state, showPicker, pushLine, pushLabel, render }
22
+ * 返回 { showUpdateNotice, checkUpdates }——checkUpdates 非阻塞(网络错误静默跳过);
23
+ * 有 picker 打开时提示挂到 state.pendingNotice,picker 全部关闭后由 doRender 弹出。
24
+ */
25
+ export function createUpdateNotice(ctx) {
26
+ const { state, showPicker, pushLine, pushLabel, render } = ctx
27
+
28
+ const showUpdateNotice = async (result) => {
29
+ const sel = await showPicker(`Update available: ${result.local} → ${result.latest}`, [
30
+ { type: "header", text: `thincoder ${result.latest} is available (current: ${result.local})` },
31
+ { type: "item", text: "Upgrade now", action: "upgrade" },
32
+ { type: "item", text: "Later", action: "later" },
33
+ ])
34
+ if (sel?.action !== "upgrade") return
35
+ pushLabel(`❯ Upgrade`, ansi.bold + C.tool)
36
+ pushLine(`Upgrading to ${result.latest}...`, C.tool)
37
+ const { exec } = await import("node:child_process")
38
+ const cp = exec("npm install -g thincoder@latest", { windowsHide: true })
39
+ let stdout = ""
40
+ cp.stdout?.on("data", (d) => { stdout += d })
41
+ cp.stderr?.on("data", (d) => { stdout += d })
42
+ cp.on("close", (code) => {
43
+ if (code === 0) {
44
+ pushLine(`✓ Upgraded to ${result.latest}. Restart to apply.`, C.tool)
45
+ } else {
46
+ pushLine(upgradeFailureText(code, stdout), C.error)
47
+ }
48
+ render()
49
+ })
50
+ }
51
+
52
+ const checkUpdates = async () => {
53
+ try {
54
+ const { readFileSync } = await import("node:fs")
55
+ const pkg = JSON.parse(readFileSync(new URL("../../package.json", import.meta.url), "utf8"))
56
+ const { checkForUpdate } = await import("../upgrade.mjs")
57
+ const result = await checkForUpdate(pkg.version)
58
+ if (result?.newer) {
59
+ // Defer: if wizard is still active, just show a dim line
60
+ if (state.wizard) {
61
+ pushLine(`Tip: thincoder ${result.latest} is available (run /upgrade later or restart)`, C.dim)
62
+ render()
63
+ } else {
64
+ state.pendingNotice = result
65
+ render()
66
+ }
67
+ }
68
+ } catch { /* network error or timeout — silently skip */ }
69
+ }
70
+
71
+ return { showUpdateNotice, checkUpdates }
72
+ }
@@ -1,6 +1,7 @@
1
1
  /**
2
2
  * wizard.mjs — first-launch config wizard
3
- * Extracted from index.mjs: provider select → step-by-step input (name/baseURL/model/key/embedkey) → persist → then model picker.
3
+ * Extracted from index.mjs: provider select → step-by-step input (name/baseURL/model/format/key/embedkey)
4
+ * → persist → then model picker. Custom 分支含 API format 步(D-C2,TUI.md §10.6D)。
4
5
  * Accesses shared state and UI functions from the startTUI closure via ctx object.
5
6
  * ctx: { agent, state, pushLine, pushLabel, render, persistRaw, openModelPicker }
6
7
  */
@@ -23,7 +24,13 @@ export function createWizard(ctx) {
23
24
  }
24
25
  for (const [name, p] of Object.entries(PRESETS)) {
25
26
  if (!agent.providers.some((x) => x.name === name)) {
26
- items.push({ kind: "preset", name, baseURL: p.baseURL, model: p.model, label: `${name} (${p.desc})` })
27
+ items.push({
28
+ kind: "preset", name, baseURL: p.baseURL, model: p.model, label: `${name} (${p.desc})`,
29
+ // 预设自身声明的扩展字段随 preset 直达落盘(code review 🟡——与 pickers preset 路径同构;
30
+ // claude/gemini 缺 format、deepseek/glm 缺 thinking/maxTokens 会静默错配);不新增提问步。
31
+ format: p.format, thinking: p.thinking, reasoningEffort: p.reasoningEffort,
32
+ maxTokens: p.maxTokens, chatPath: p.chatPath,
33
+ })
27
34
  }
28
35
  }
29
36
  items.push({ kind: "custom", name: null, label: "Custom endpoint…" })
@@ -45,6 +52,14 @@ export function createWizard(ctx) {
45
52
  prompt: "Enter model name (e.g. gpt-4o)",
46
53
  validate: (v) => v.length > 0 || "Model name required",
47
54
  },
55
+ // D-C2(TUI.md §10.6D):Custom 分支的 API format 步(endpoint 后 key 前——与 Add Provider
56
+ // picker 路径两入口一致;默认 openai)。空输入 = openai 直过(Enter 即默认——同 D-C1 index=0);
57
+ // Esc 在该步沿用 wizard 既有“Esc 随时跳过”语义(取消整个向导——无半配置落盘)。
58
+ format: {
59
+ prompt: "API format [openai/anthropic/google] (Enter = openai default)",
60
+ // 大小写不敏感(旧手输口径——输入统一 toLowerCase 后落盘);空输入 = openai 默认
61
+ validate: (v) => !v || /^(openai|anthropic|google)$/i.test(v) || "API format must be one of: openai, anthropic, google",
62
+ },
48
63
  key: {
49
64
  prompt: "Enter API key",
50
65
  validate: (v) => v.length > 0 || "Key must not be empty",
@@ -54,7 +69,7 @@ export function createWizard(ctx) {
54
69
  validate: () => true, // skippable
55
70
  },
56
71
  }
57
- const WIZARD_NEXT = { name: "baseURL", baseURL: "model", model: "key", key: "embedkey", embedkey: null }
72
+ const WIZARD_NEXT = { name: "baseURL", baseURL: "model", model: "format", format: "key", key: "embedkey", embedkey: null }
58
73
 
59
74
  function startWizard() {
60
75
  state.wizard = { step: "provider", index: 0, scroll: 0, selectedLine: 0, fields: {}, error: null, lines: [] }
@@ -94,6 +109,11 @@ export function createWizard(ctx) {
94
109
  w.step = "name"
95
110
  } else {
96
111
  w.fields = { name: item.name, baseURL: item.baseURL, model: item.model }
112
+ // preset 直达:预设声明的扩展字段(format/thinking/maxTokens/chatPath…)直接带进 fields——
113
+ // 无 format 提问步(T-C4)但落盘不丢字段(code review 🟡——picker preset 路径同款复制)。
114
+ for (const k of ["format", "thinking", "reasoningEffort", "maxTokens", "chatPath"]) {
115
+ if (item[k]) w.fields[k] = item[k]
116
+ }
97
117
  w.step = "key"
98
118
  }
99
119
  renderWizard()
@@ -111,7 +131,9 @@ export function createWizard(ctx) {
111
131
  w.error = null
112
132
  state.input = []
113
133
  state.cursor = 0
114
- w.fields[w.step === "key" ? "key" : w.step] = w.step === "baseURL" ? value.replace(/\/+$/, "") : value
134
+ w.fields[w.step === "key" ? "key" : w.step] = w.step === "baseURL" ? value.replace(/\/+$/, "")
135
+ : w.step === "format" ? (value.toLowerCase() || "openai") // 空输入 = openai 默认(D-C2)
136
+ : value
115
137
  const next = WIZARD_NEXT[w.step]
116
138
  if (next) {
117
139
  w.step = next
@@ -131,9 +153,17 @@ export function createWizard(ctx) {
131
153
  async function finishWizard() {
132
154
  const f = state.wizard.fields
133
155
  state.wizard = null
156
+ // D-C2:format 非默认(anthropic/google)时落盘;openai = 默认省略(与 D-C1 picker 路径同构)
157
+ const providerRec = { name: f.name, baseURL: f.baseURL, model: f.model, apiKey: f.key }
158
+ if (f.format && f.format !== "openai") providerRec.format = f.format
159
+ // code review 🟡:preset 直达带来的扩展字段一并落盘(truthy 语义与 pickers preset 分支一致——
160
+ // thinking: null 不落;Custom 路径无这些字段不受影响)
161
+ for (const k of ["thinking", "reasoningEffort", "maxTokens", "chatPath"]) {
162
+ if (f[k]) providerRec[k] = f[k]
163
+ }
134
164
  const existing = agent.providers.find((p) => p.name === f.name)
135
- if (existing) Object.assign(existing, { baseURL: f.baseURL, model: f.model, apiKey: f.key })
136
- else agent.providers.push({ name: f.name, baseURL: f.baseURL, model: f.model, apiKey: f.key })
165
+ if (existing) Object.assign(existing, providerRec)
166
+ else agent.providers.push(providerRec)
137
167
  agent.activeProvider = f.name
138
168
  agent.activeModel = null
139
169
  agent.provider = { ...agent.providers.find((p) => p.name === f.name) }
@@ -1,179 +0,0 @@
1
- /**
2
- * escalate.mjs — 飞刀 (the "flying knife", docs/design/ESCALATE.md). CLI port.
3
- *
4
- * Hand an implementation task to a STRONGER model — like a hospital flying in an
5
- * outside expert (飞刀): the expert arrives, operates personally (WRITE access),
6
- * hands back the post-op report, leaves. Complementary to consult (parallel
7
- * READ-ONLY opinions for judgment calls).
8
- *
9
- * Candidate pool = all consultModels rows. The tool is only registered when the
10
- * pool is non-empty (setup.mjs).
11
- *
12
- * CLI adaptation (vs the VS Code plugin): the child runner is CLI's runAgent
13
- * (runAgent(child, input, callbacks, opts) — an agent object, not provider+cwd);
14
- * the child is createAgent({ role: "coder", CODER_OVERLAY }); activity streams to
15
- * the parent TUI via the relay prefix `escalate#<id>/`; mutations merge via the
16
- * CLI mergeChildMutations(parent, child) (agent object, not a state sink).
17
- */
18
- import { isAbsolute, relative } from "node:path"
19
- import { createAgent, runAgent, CODER_OVERLAY, DEFAULT_SUBAGENT_TURNS } from "../agent.mjs"
20
- import { resolveChildProvider, mergeChildMutations } from "./subagent.mjs"
21
- import { makeRelay, wrapChildCallbacks, runWithContinue, ensureChildApiKey, clampEffort, TURN_CAP_MARK } from "../agent/spawn-child.mjs"
22
-
23
- const label = (m) => `${m.provider}:${m.model}`
24
-
25
- export const escalateTool = {
26
- name: "escalate",
27
- sideEffectExempt: true, // the child's mutations are tracked and reviewed, like subagent
28
- description:
29
- "TERMINOLOGY (one word for one thing): 'escalate' is the ONLY name — the tool, and the " +
30
- "role of the expert sub-agent it spawns, are both called 'escalate'; 飞刀 is the Chinese " +
31
- "alias. When the user says 飞刀 / escalate / 'fly in <model>', call THIS tool directly — " +
32
- "never via a script importing this module. " +
33
- "Hand an implementation task to a stronger model (飞刀 — a flown-in expert). " +
34
- "It gets WRITE access and does the work itself — reads, edits, runs tests — then returns " +
35
- "a post-op report (what changed, why, verification). You review the report and report to " +
36
- "the user. Use it when YOU judge the task calls for stronger hands (complex multi-file " +
37
- "refactoring, an intractable bug, intricate algorithm work — or work beyond your " +
38
- "comfortable ability). Early or late, your judgment; the cost is one expert run, " +
39
- "comparable to doing it yourself. For parallel READ-ONLY opinions use consult_start instead. " +
40
- "Not available in engineering mode (implementation goes through eng-coder subagents there).\n" +
41
- "Parameters:\n" +
42
- "- task (required): the task description — goal, constraints, entry files, acceptance criteria\n" +
43
- "- model (optional): pick a specific consultant as 'provider:model'; default = the first consult model",
44
- parameters: {
45
- type: "object",
46
- properties: {
47
- task: { type: "string", description: "Task description with acceptance criteria" },
48
- model: { type: "string", description: "Candidate 'provider:model' from the consult models (optional)" },
49
- },
50
- required: ["task"],
51
- },
52
- async execute({ task, model }, ctx) {
53
- const parent = ctx.agent
54
- if ((ctx.depth ?? 0) > 0) return "Error: escalate is only available at depth 0 (an escalate's work cannot be delegated again)"
55
- if (parent?.config?.agent?.engineering) {
56
- 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."
57
- }
58
- const pool = parent?.config?.agent?.consultModels ?? []
59
- if (pool.length === 0) return "Error: no escalate candidates — configure at least one consult model (agent.consultModels)"
60
-
61
- const wanted = typeof model === "string" ? model.replace(/\s+\([^)]*\)\s*$/, "").trim() : model
62
- const pick = wanted ? pool.find((m) => label(m) === wanted) : pool[0]
63
- if (!pick) {
64
- return `Error: "${model}" is not a consult candidate. Available: ${pool.map(label).join(", ")}`
65
- }
66
-
67
- let provider
68
- try {
69
- provider = resolveChildProvider(parent, `${pick.provider}:${pick.model}`)
70
- } catch (e) {
71
- return `Error: ${e.message}`
72
- }
73
- if (!ensureChildApiKey(provider)) {
74
- return `Error: provider "${pick.provider}" has no API key — set it in config.json before flying it in`
75
- }
76
- let effortNote = ""
77
- if (pick.effort && !clampEffort(provider, pick.model, pick.effort)) {
78
- // Out-of-enum effort dropped (see clampEffort): the provider preset default may ALSO be
79
- // out-of-enum for this override model — e.g. qwenplan preset default "high" is
80
- // invalid for qwen3.8-max, enum xhigh/medium/low.
81
- effortNote = ` (effort "${pick.effort}" unsupported by ${pick.model}, dropped)`
82
- }
83
-
84
- const tag = label(pick)
85
- const relayPrefix = makeRelay(parent, "escalate", ctx.callbacks?.onToken, provider.model ?? tag)
86
- // Report the escalated model to the display layer (it may differ from the parent's)
87
- // — makeRelay already emitted the `[model]` metadata token above.
88
-
89
- // No wall-clock watchdog — turn cap only, exactly like subagent (the verified write
90
- // path). Rationale (2026-08-16): a fixed wall-clock aborts NORMAL-but-slow surgery —
91
- // two max-effort consultants hit a 10min wall just READING files. Hang protection is
92
- // already covered by FETCH_TIMEOUT_MS (per LLM call) and the user's Stop (parent
93
- // signal propagates directly below). maxTurns is the cost budget; hitting it asks
94
- // the user whether to continue (main-agent parity), falling back to partial work.
95
-
96
- // No custom onToken here (consult P2, 2026-08-30): wrapChildCallbacks already
97
- // applies the prefixed relay + D7 sentinel strip for the display path, and
98
- // runWithContinue owns the capture (stripEventTokensForCapture) for the
99
- // partial-output return — a hand-rolled duplicate ran the strip twice and
100
- // maintained a second output buffer.
101
- const childCallbacks = wrapChildCallbacks(relayPrefix, ctx.callbacks ?? {})
102
-
103
- // Declared outside try so the catch can merge mutations even on a partial failure.
104
- let child = null
105
- try {
106
- // Full write path (role "coder"): permission gate via the parent's onPermissionRequest,
107
- // recent-changes tracking, mutations merge into the parent below.
108
- child = createAgent({
109
- provider,
110
- tools: parent.tools,
111
- config: parent.config,
112
- cwd: parent.cwd,
113
- memory: parent.memory,
114
- overlay: CODER_OVERLAY,
115
- role: "coder",
116
- })
117
- const runner = ctx.runAgent ?? runAgent
118
- const runOpts = {
119
- depth: 1,
120
- maxTurns: parent.config?.agent?.subagentTurns ?? DEFAULT_SUBAGENT_TURNS, // review #7: constant, not literal (single source with subagent.mjs)
121
- signal: ctx.signal ?? null,
122
- }
123
- // Turn-cap continue via runWithContinue (§7.2 D3), main-agent parity (tui/agent-turn.mjs):
124
- // when the child hits ContinueError, ask the user through the SAME channel as child
125
- // write approval (ctx.onPermissionRequest). The name "continue" renders the TUI's
126
- // dedicated y/n Continue panel. The resumed run passes resume:true, so runAgent does
127
- // NOT re-inject the task text (setup.mjs skips input on resume) and keeps the child's
128
- // history + mutation bookkeeping, with a fresh maxTurns budget per run. No permission
129
- // handler (headless) or a declined prompt falls through to the partial-work return.
130
- // Continues are UNLIMITED — the user can decline at any prompt.
131
- const report = await runWithContinue(
132
- async (childAgent, input, cbs, opts) => {
133
- // Merge mid-run mutations even when the run throws — the outer catch keeps
134
- // handling createAgent failures; AbortError still propagates (user Stop).
135
- try {
136
- return await runner(childAgent, input, cbs, opts)
137
- } catch (e) {
138
- mergeChildMutations(parent, childAgent)
139
- throw e
140
- }
141
- },
142
- child, task, { ...childCallbacks, onPermissionRequest: parent.autoApprove ? async () => true : (ctx.onPermissionRequest ?? null) },
143
- runOpts,
144
- {
145
- // escalate has NO permQueue: prompts go straight to the user (T-L spec).
146
- askContinue: (e) => (ctx.onPermissionRequest
147
- ? ctx.onPermissionRequest("continue", { turns: e.turn, agent: tag })
148
- : Promise.resolve(false)),
149
- 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)}`,
150
- },
151
- ).catch((e) => {
152
- // Generic run failure (not ContinueError): match the original loop's return shape —
153
- // error text + partial output, mutations already merged in the runner wrapper.
154
- if (ctx.signal?.aborted || e?.name === "AbortError") throw e
155
- return `escalate (${tag}) error: ${e?.message ?? String(e)}\nPartial output: ${(child._capturedOutput ?? "").slice(0, 2000)}`
156
- })
157
- // Escalate mutations are the parent's mutations: verify/advisor guards must see them
158
- mergeChildMutations(parent, child)
159
- return `escalate (${tag})${effortNote} post-op report:\n${report || (child._capturedOutput ?? "").slice(0, 4000)}${touchedFilesNote(child, parent.cwd)}`
160
- } catch (e) {
161
- // Reached only when createAgent itself fails or the continue prompt throws —
162
- // run failures are handled above (mutations merge inside the runner wrapper).
163
- if (child) mergeChildMutations(parent, child)
164
- if (ctx.signal?.aborted || e?.name === "AbortError") throw e
165
- return `escalate (${tag}) error: ${e?.message ?? String(e)}`
166
- }
167
- },
168
- }
169
-
170
- /** Relative touched-file list appended to every return (child paths are absolute). */
171
- function touchedFilesNote(child, cwd) {
172
- const touched = child?._touchedFiles ?? []
173
- if (touched.length === 0) return ""
174
- const shown = touched.map((f) => {
175
- const r = relative(cwd ?? process.cwd(), f)
176
- return r && !r.startsWith("..") && !isAbsolute(r) ? r : f
177
- })
178
- return `\nTouched files: ${shown.join(", ")}`
179
- }
@@ -1,84 +0,0 @@
1
- /**
2
- * exec-prelude.mjs — sandbox API injected into `execute`'s child process.
3
- *
4
- * The `execute` tool spawns `node --input-type=module --eval` and `import()`-s
5
- * this file first, so the user's code gets readFile/writeFile/glob/grep/log/require
6
- * without hand-writing fs boilerplate. Confines file paths to the workspace root
7
- * (THINCODER_EXEC_ROOT, default cwd) — an orthopedic guard, NOT a sandbox:
8
- * require()/process/import()/fetch() are full Node, the same boundary as bash
9
- * (project philosophy: no fake sandbox; transparency + audit).
10
- */
11
- import { createRequire } from "node:module"
12
- import { readFileSync, writeFileSync, existsSync, statSync, mkdirSync, readdirSync } from "node:fs"
13
- import { resolve, relative, dirname, join, isAbsolute, sep } from "node:path"
14
-
15
- const require = createRequire(join(process.cwd(), "__exec__.js"))
16
- const root = process.env.THINCODER_EXEC_ROOT || process.cwd()
17
-
18
- /** Resolve a path against the working dir, asserting it stays within the workspace root. */
19
- function safe(p) {
20
- if (typeof p !== "string") throw new Error(`Path must be a string, got ${typeof p}`)
21
- const abs = resolve(process.cwd(), p)
22
- const rel = relative(root, abs)
23
- // isAbsolute(rel) covers cross-drive (relative() returns an absolute path then)
24
- if (isAbsolute(rel) || rel === ".." || rel.startsWith(".." + sep)) {
25
- throw new Error(`Path traversal denied: ${p}`)
26
- }
27
- return abs
28
- }
29
-
30
- function globToRegex(pattern) {
31
- const DS = "\u0001", DP = "\u0002"
32
- const escaped = pattern
33
- .replace(/\*\*\//g, DS).replace(/\*\*/g, DP)
34
- .replace(/[.+^${}()|[\]\\]/g, "\\$&")
35
- .replace(/\*/g, "[^/]*").replace(/\?/g, "[^/]")
36
- .replace(new RegExp(DS, "g"), "(?:.+/)?").replace(new RegExp(DP, "g"), ".*")
37
- return new RegExp(`^${escaped}$`)
38
- }
39
-
40
- globalThis.require = require
41
- globalThis.readFile = (p) => {
42
- const abs = safe(p)
43
- if (!existsSync(abs)) throw new Error(`File not found: ${p}`)
44
- if (statSync(abs).size > 5_000_000) throw new Error(`File too large: ${p}`)
45
- return readFileSync(abs, "utf8").replace(/\r\n/g, "\n")
46
- }
47
- globalThis.writeFile = (p, content) => {
48
- const abs = safe(p)
49
- mkdirSync(dirname(abs), { recursive: true })
50
- writeFileSync(abs, String(content), "utf8")
51
- }
52
- globalThis.glob = (pattern) => {
53
- if (typeof pattern !== "string") throw new Error("glob pattern must be a string")
54
- const re = globToRegex(pattern)
55
- const out = []
56
- function walk(dir, rel) {
57
- let entries
58
- try { entries = readdirSync(dir, { withFileTypes: true }) } catch { return }
59
- for (const e of entries) {
60
- if (e.name.startsWith(".") || e.name === "node_modules") continue
61
- const rp = rel ? `${rel}/${e.name}` : e.name
62
- if (e.isDirectory()) walk(join(dir, e.name), rp)
63
- else if (re.test(rp)) out.push(rp)
64
- }
65
- }
66
- walk(process.cwd(), "")
67
- const capped = out.slice(0, 200)
68
- if (out.length > 200) capped.push(`... (${out.length - 200} more)`)
69
- return capped
70
- }
71
- globalThis.grep = (pattern, file) => {
72
- if (typeof pattern !== "string") throw new Error("grep pattern must be a string")
73
- if (typeof file !== "string") throw new Error("grep file must be a string")
74
- const abs = safe(file)
75
- if (!existsSync(abs)) throw new Error(`File not found: ${file}`)
76
- const re = new RegExp(pattern)
77
- const lines = readFileSync(abs, "utf8").replace(/\r\n/g, "\n").split("\n")
78
- const m = []
79
- for (let i = 0; i < lines.length; i++) if (re.test(lines[i])) m.push(`${i + 1}: ${lines[i].slice(0, 200)}`)
80
- const capped = m.slice(0, 100)
81
- if (m.length > 100) capped.push(`... (${m.length - 100} more)`)
82
- return capped
83
- }
84
- globalThis.log = (...a) => console.log(a.map((x) => (x && typeof x === "object" ? JSON.stringify(x) : String(x))).join(" "))