@kkelly-offical/kkcode 0.1.6 → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (163) hide show
  1. package/LICENSE +674 -674
  2. package/README.md +452 -387
  3. package/package.json +50 -46
  4. package/src/agent/agent.mjs +19 -2
  5. package/src/agent/custom-agent-loader.mjs +6 -3
  6. package/src/agent/generator.mjs +2 -2
  7. package/src/agent/prompt/assistant.txt +12 -0
  8. package/src/agent/prompt/bug-hunter.txt +90 -0
  9. package/src/agent/prompt/frontend-designer.txt +58 -58
  10. package/src/agent/prompt/guide.txt +1 -1
  11. package/src/agent/prompt/longagent-blueprint-agent.txt +83 -83
  12. package/src/agent/prompt/longagent-coding-agent.txt +37 -37
  13. package/src/agent/prompt/longagent-debugging-agent.txt +46 -46
  14. package/src/agent/prompt/longagent-preview-agent.txt +63 -63
  15. package/src/command/custom-commands.mjs +2 -2
  16. package/src/commands/agent.mjs +1 -1
  17. package/src/commands/background.mjs +145 -4
  18. package/src/commands/chat.mjs +117 -76
  19. package/src/commands/config.mjs +148 -1
  20. package/src/commands/doctor.mjs +30 -6
  21. package/src/commands/init.mjs +32 -6
  22. package/src/commands/longagent.mjs +117 -0
  23. package/src/commands/mcp.mjs +275 -43
  24. package/src/commands/permission.mjs +1 -1
  25. package/src/commands/session.mjs +195 -140
  26. package/src/commands/skill.mjs +63 -0
  27. package/src/commands/theme.mjs +1 -1
  28. package/src/config/defaults.mjs +280 -260
  29. package/src/config/import-config.mjs +1 -1
  30. package/src/config/load-config.mjs +61 -4
  31. package/src/config/schema.mjs +591 -574
  32. package/src/context.mjs +4 -1
  33. package/src/core/constants.mjs +97 -91
  34. package/src/core/types.mjs +1 -1
  35. package/src/github/api.mjs +78 -78
  36. package/src/github/auth.mjs +294 -286
  37. package/src/github/flow.mjs +298 -298
  38. package/src/github/workspace.mjs +225 -212
  39. package/src/index.mjs +84 -82
  40. package/src/knowledge/frontend-aesthetics.txt +38 -38
  41. package/src/mcp/client-http.mjs +139 -141
  42. package/src/mcp/client-sse.mjs +297 -288
  43. package/src/mcp/client-stdio.mjs +534 -533
  44. package/src/mcp/constants.mjs +2 -2
  45. package/src/mcp/registry.mjs +498 -479
  46. package/src/mcp/stdio-framing.mjs +135 -133
  47. package/src/mcp/tool-result.mjs +24 -24
  48. package/src/observability/edit-diagnostics.mjs +449 -0
  49. package/src/observability/index.mjs +42 -42
  50. package/src/observability/metrics.mjs +165 -137
  51. package/src/observability/tracer.mjs +137 -137
  52. package/src/onboarding.mjs +209 -0
  53. package/src/orchestration/background-manager.mjs +567 -372
  54. package/src/orchestration/background-worker.mjs +419 -305
  55. package/src/orchestration/interruption-reason.mjs +21 -0
  56. package/src/orchestration/longagent-manager.mjs +197 -171
  57. package/src/orchestration/stage-scheduler.mjs +733 -728
  58. package/src/orchestration/subagent-router.mjs +7 -1
  59. package/src/orchestration/task-scheduler.mjs +219 -7
  60. package/src/permission/engine.mjs +1 -1
  61. package/src/permission/exec-policy.mjs +370 -370
  62. package/src/permission/file-edit-policy.mjs +108 -0
  63. package/src/permission/prompt.mjs +1 -1
  64. package/src/permission/rules.mjs +116 -7
  65. package/src/plugin/builtin-hooks/post-edit-format.mjs +2 -1
  66. package/src/plugin/builtin-hooks/post-edit-typecheck.mjs +104 -40
  67. package/src/plugin/hook-bus.mjs +19 -5
  68. package/src/plugin/manifest-loader.mjs +222 -0
  69. package/src/provider/anthropic.mjs +396 -390
  70. package/src/provider/ollama.mjs +7 -1
  71. package/src/provider/openai.mjs +382 -340
  72. package/src/provider/retry-policy.mjs +74 -68
  73. package/src/provider/router.mjs +242 -241
  74. package/src/provider/sse.mjs +104 -104
  75. package/src/provider/wizard.mjs +556 -0
  76. package/src/repl/capability-facade.mjs +30 -0
  77. package/src/repl/command-surface.mjs +23 -0
  78. package/src/repl/controller-entry.mjs +40 -0
  79. package/src/repl/core-shell.mjs +208 -0
  80. package/src/repl/dialog-router.mjs +87 -0
  81. package/src/repl/input-engine.mjs +76 -0
  82. package/src/repl/keymap.mjs +7 -0
  83. package/src/repl/operator-surface.mjs +15 -0
  84. package/src/repl/permission-flow.mjs +49 -0
  85. package/src/repl/runtime-facade.mjs +36 -0
  86. package/src/repl/slash-router.mjs +62 -0
  87. package/src/repl/state-store.mjs +29 -0
  88. package/src/repl/turn-controller.mjs +58 -0
  89. package/src/repl/verification.mjs +23 -0
  90. package/src/repl.mjs +3368 -2929
  91. package/src/rules/load-rules.mjs +3 -3
  92. package/src/runtime.mjs +1 -1
  93. package/src/session/agent-transaction.mjs +86 -0
  94. package/src/session/checkpoint.mjs +302 -302
  95. package/src/session/compaction.mjs +36 -14
  96. package/src/session/engine.mjs +417 -227
  97. package/src/session/longagent-4stage.mjs +467 -460
  98. package/src/session/longagent-hybrid.mjs +1344 -1081
  99. package/src/session/longagent-plan.mjs +376 -365
  100. package/src/session/longagent-project-memory.mjs +53 -53
  101. package/src/session/longagent-scaffold.mjs +291 -291
  102. package/src/session/longagent-task-bus.mjs +138 -54
  103. package/src/session/longagent-utils.mjs +828 -472
  104. package/src/session/longagent.mjs +911 -884
  105. package/src/session/loop.mjs +1005 -905
  106. package/src/session/prompt/agent.txt +25 -0
  107. package/src/session/prompt/anthropic.txt +150 -150
  108. package/src/session/prompt/beast.txt +1 -1
  109. package/src/session/prompt/plan.txt +28 -6
  110. package/src/session/prompt/qwen.txt +46 -46
  111. package/src/session/recovery.mjs +21 -0
  112. package/src/session/rollback.mjs +197 -0
  113. package/src/session/routing-observability.mjs +72 -0
  114. package/src/session/runtime-state.mjs +47 -0
  115. package/src/session/store.mjs +523 -510
  116. package/src/session/system-prompt.mjs +56 -8
  117. package/src/session/task-validator.mjs +267 -267
  118. package/src/session/usability-gates.mjs +2 -2
  119. package/src/skill/builtin/commit.mjs +64 -64
  120. package/src/skill/builtin/design.mjs +76 -76
  121. package/src/skill/generator.mjs +18 -2
  122. package/src/skill/registry.mjs +642 -390
  123. package/src/storage/audit-store.mjs +18 -11
  124. package/src/storage/event-log.mjs +7 -1
  125. package/src/storage/ghost-commit-store.mjs +243 -245
  126. package/src/storage/paths.mjs +13 -0
  127. package/src/theme/default-theme.mjs +1 -1
  128. package/src/theme/markdown.mjs +4 -0
  129. package/src/theme/schema.mjs +1 -1
  130. package/src/theme/status-bar.mjs +162 -158
  131. package/src/tool/audit-wrapper.mjs +18 -2
  132. package/src/tool/edit-transaction.mjs +23 -0
  133. package/src/tool/executor.mjs +26 -1
  134. package/src/tool/file-read-state.mjs +65 -0
  135. package/src/tool/git-auto.mjs +526 -526
  136. package/src/tool/git-full-auto.mjs +487 -478
  137. package/src/tool/mutation-guard.mjs +54 -0
  138. package/src/tool/prompt/edit.txt +3 -3
  139. package/src/tool/prompt/multiedit.txt +1 -0
  140. package/src/tool/prompt/notebookedit.txt +2 -1
  141. package/src/tool/prompt/patch.txt +25 -24
  142. package/src/tool/prompt/read.txt +3 -3
  143. package/src/tool/prompt/sysinfo.txt +29 -0
  144. package/src/tool/prompt/task.txt +66 -4
  145. package/src/tool/prompt/write.txt +2 -2
  146. package/src/tool/question-prompt.mjs +17 -4
  147. package/src/tool/registry.mjs +1701 -1343
  148. package/src/tool/task-tool.mjs +14 -6
  149. package/src/ui/activity-renderer.mjs +667 -664
  150. package/src/ui/repl-background-panel.mjs +7 -0
  151. package/src/ui/repl-capability-panel.mjs +9 -0
  152. package/src/ui/repl-dashboard.mjs +54 -4
  153. package/src/ui/repl-help.mjs +110 -0
  154. package/src/ui/repl-operator-panel.mjs +12 -0
  155. package/src/ui/repl-route-feedback.mjs +35 -0
  156. package/src/ui/repl-status-view.mjs +76 -0
  157. package/src/ui/repl-task-panel.mjs +5 -0
  158. package/src/ui/repl-transcript-panel.mjs +56 -0
  159. package/src/ui/repl-turn-summary.mjs +135 -0
  160. package/src/usage/pricing.mjs +122 -121
  161. package/src/usage/usage-meter.mjs +1 -0
  162. package/src/util/git.mjs +562 -519
  163. package/src/util/template.mjs +6 -1
@@ -1,305 +1,419 @@
1
- import { appendFile } from "node:fs/promises"
2
- import { readJson, writeJson } from "../storage/json-store.mjs"
3
- import { ensureBackgroundTaskRuntimeDir, backgroundTaskCheckpointPath, backgroundTaskLogPath } from "../storage/paths.mjs"
4
- import { buildContext } from "../context.mjs"
5
- import { ToolRegistry } from "../tool/registry.mjs"
6
- import { executeTurn } from "../session/engine.mjs"
7
-
8
- function now() {
9
- return Date.now()
10
- }
11
-
12
- function argValue(flag) {
13
- const idx = process.argv.indexOf(flag)
14
- if (idx < 0) return null
15
- return process.argv[idx + 1] || null
16
- }
17
-
18
- function makeAbortError(reason = "aborted") {
19
- const err = new Error(reason)
20
- err.code = "ABORT_ERR"
21
- return err
22
- }
23
-
24
- function isAbortError(error) {
25
- return error?.code === "ABORT_ERR" || error?.name === "AbortError"
26
- }
27
-
28
- async function readTask(taskId) {
29
- return readJson(backgroundTaskCheckpointPath(taskId), null)
30
- }
31
-
32
- async function patchTask(taskId, updater) {
33
- const current = await readTask(taskId)
34
- if (!current) return null
35
- const next = {
36
- ...current,
37
- ...updater(current),
38
- updatedAt: now()
39
- }
40
- await writeJson(backgroundTaskCheckpointPath(taskId), next)
41
- return next
42
- }
43
-
44
- let _maxLogLines = 300
45
-
46
- async function appendTaskLog(taskId, line) {
47
- await appendFile(backgroundTaskLogPath(taskId), `${line}\n`, "utf8")
48
- await patchTask(taskId, (current) => ({
49
- logs: [...(current.logs || []), String(line)].slice(-_maxLogLines),
50
- lastHeartbeatAt: now()
51
- }))
52
- }
53
-
54
- async function runDelegateTask(task, signal) {
55
- const payload = task.payload || {}
56
- const cwd = payload.cwd || process.cwd()
57
- process.chdir(cwd)
58
-
59
- const ctx = await buildContext({ cwd })
60
- _maxLogLines = Number(ctx.configState.config?.background?.max_log_lines || 300)
61
- await ToolRegistry.initialize({
62
- config: ctx.configState.config,
63
- cwd
64
- })
65
- const { CustomAgentRegistry } = await import("../agent/custom-agent-loader.mjs")
66
- await CustomAgentRegistry.initialize(cwd)
67
-
68
- const providerType = payload.providerType || ctx.configState.config.provider.default
69
- const providerDefault = ctx.configState.config.provider[providerType]
70
- const model = payload.model || providerDefault?.default_model
71
-
72
- const out = await executeTurn({
73
- prompt: String(payload.prompt || ""),
74
- mode: "agent",
75
- model,
76
- providerType,
77
- sessionId: payload.subSessionId,
78
- configState: ctx.configState,
79
- signal,
80
- allowQuestion: payload.allowQuestion !== true ? false : true,
81
- toolContext: {
82
- taskId: task.id,
83
- stageId: payload.stageId || null,
84
- logicalTaskId: payload.logicalTaskId || null
85
- }
86
- })
87
-
88
- const plannedFiles = Array.isArray(payload.plannedFiles)
89
- ? payload.plannedFiles.map((item) => String(item || "").trim()).filter(Boolean)
90
- : []
91
- const completedFilesFromTools = out.toolEvents
92
- .filter((event) => ["write", "edit"].includes(event.name) && event.status === "completed")
93
- .map((event) => {
94
- const p = event.args?.path
95
- return p ? String(p).trim() : ""
96
- })
97
- .filter(Boolean)
98
-
99
- const fileChanges = out.toolEvents
100
- .flatMap((event) => Array.isArray(event?.metadata?.fileChanges) ? event.metadata.fileChanges : [])
101
- .map((item) => ({
102
- path: String(item?.path || "").trim(),
103
- addedLines: Math.max(0, Number(item?.addedLines || 0)),
104
- removedLines: Math.max(0, Number(item?.removedLines || 0)),
105
- stageId: item?.stageId ? String(item.stageId) : (payload.stageId || ""),
106
- taskId: item?.taskId ? String(item.taskId) : (payload.logicalTaskId || "")
107
- }))
108
- .filter((item) => item.path)
109
-
110
- const completedFileSet = new Set(
111
- completedFilesFromTools.filter((file) => plannedFiles.length === 0 || plannedFiles.includes(file))
112
- )
113
- const completedFiles = [...completedFileSet]
114
- const remainingFiles = plannedFiles.filter((file) => !completedFileSet.has(file))
115
-
116
- return {
117
- session_id: payload.subSessionId,
118
- parent_session_id: payload.parentSessionId || null,
119
- subagent: payload.subagent || null,
120
- reply: out.reply,
121
- tool_events: out.toolEvents?.length || 0,
122
- completed_files: completedFiles,
123
- remaining_files: remainingFiles,
124
- file_changes: fileChanges,
125
- cost: out.cost,
126
- budget_warnings: out.budgetWarnings || []
127
- }
128
- }
129
-
130
- const SILENT_ERROR_PATTERNS = [
131
- /provider[\s._-]*error/i,
132
- /api[\s._-]*timeout/i,
133
- /rate[\s._-]?limit/i,
134
- /\b(429|503|502|500)\b/,
135
- /missing api key/i,
136
- /stream idle timeout/i,
137
- /\b(econnreset|econnrefused|etimedout)\b/i,
138
- /budget exceeded/i
139
- ]
140
-
141
- function detectSilentError(result, payload) {
142
- const reply = String(result?.reply || "")
143
- const toolEvents = Number(result?.tool_events || 0)
144
- const plannedFiles = Array.isArray(payload?.plannedFiles) ? payload.plannedFiles : []
145
- const completedFiles = Array.isArray(result?.completed_files) ? result.completed_files : []
146
- const remainingFiles = Array.isArray(result?.remaining_files) ? result.remaining_files : []
147
-
148
- // Guard: tasks without plannedFiles (review/analysis) skip all detection
149
- if (plannedFiles.length === 0) return { hasError: false, errorMessage: "" }
150
-
151
- // Guard: [TASK_COMPLETE] marker present trust the agent's self-report
152
- if (reply.toLowerCase().includes("[task_complete]")) return { hasError: false, errorMessage: "" }
153
-
154
- // Guard: has tool activity and substantial reply — likely real work done
155
- if (toolEvents > 0 && reply.length >= 200) return { hasError: false, errorMessage: "" }
156
-
157
- // Pattern matching: known provider error signatures in reply
158
- for (const pattern of SILENT_ERROR_PATTERNS) {
159
- if (pattern.test(reply)) {
160
- return { hasError: true, errorMessage: `silent provider error detected: ${reply.slice(0, 200)}` }
161
- }
162
- }
163
-
164
- // Heuristic: planned files exist but none completed, low activity
165
- if (completedFiles.length === 0
166
- && remainingFiles.length === plannedFiles.length
167
- && (reply.length < 200 || toolEvents === 0)) {
168
- return { hasError: true, errorMessage: `heuristic: no files completed, no tool activity (reply ${reply.length} chars, ${toolEvents} tool events)` }
169
- }
170
-
171
- return { hasError: false, errorMessage: "" }
172
- }
173
-
174
- async function main() {
175
- const taskId = argValue("--task-id") || process.env.KKCODE_BACKGROUND_TASK_ID || null
176
- if (!taskId) {
177
- process.exit(1)
178
- return
179
- }
180
-
181
- await ensureBackgroundTaskRuntimeDir()
182
- const task = await readTask(taskId)
183
- if (!task) {
184
- process.exit(1)
185
- return
186
- }
187
-
188
- if (task.cancelled) {
189
- await patchTask(taskId, () => ({
190
- status: "cancelled",
191
- endedAt: now()
192
- }))
193
- process.exit(0)
194
- return
195
- }
196
-
197
- await patchTask(taskId, () => ({
198
- status: "running",
199
- workerPid: process.pid,
200
- startedAt: now(),
201
- lastHeartbeatAt: now()
202
- }))
203
-
204
- const abortController = new AbortController()
205
- const parentPid = process.ppid
206
- const heartbeatTimer = setInterval(() => {
207
- patchTask(taskId, () => ({ lastHeartbeatAt: now() })).catch(() => {})
208
- }, 2000)
209
-
210
- const cancelPoll = setInterval(() => {
211
- // Orphan detection: if parent process died, self-terminate
212
- try { process.kill(parentPid, 0) } catch {
213
- if (!abortController.signal.aborted) {
214
- abortController.abort(makeAbortError("parent process exited, worker orphaned"))
215
- }
216
- return
217
- }
218
- readTask(taskId).then((latest) => {
219
- if (latest?.cancelled && !abortController.signal.aborted) {
220
- abortController.abort(makeAbortError("cancelled by user"))
221
- }
222
- }).catch(() => {})
223
- }, 1500)
224
-
225
- const timeoutMs = Math.max(1000, Number(task.payload?.workerTimeoutMs || 900000))
226
- const timeoutTimer = setTimeout(() => {
227
- if (!abortController.signal.aborted) {
228
- abortController.abort(makeAbortError(`worker timeout after ${timeoutMs}ms`))
229
- }
230
- }, timeoutMs)
231
-
232
- try {
233
- await appendTaskLog(taskId, `task started (worker pid=${process.pid})`)
234
-
235
- const latest = await readTask(taskId)
236
- if (!latest?.payload?.workerType || latest.payload.workerType !== "delegate_task") {
237
- throw new Error(`unsupported workerType: ${latest?.payload?.workerType || "unknown"}`)
238
- }
239
-
240
- const result = await runDelegateTask(latest, abortController.signal)
241
- const silentCheck = detectSilentError(result, latest.payload)
242
- if (silentCheck.hasError) {
243
- await appendTaskLog(taskId, `silent error detected: ${silentCheck.errorMessage}`)
244
- await patchTask(taskId, () => ({
245
- status: "error",
246
- result,
247
- error: silentCheck.errorMessage,
248
- endedAt: now(),
249
- lastHeartbeatAt: now()
250
- }))
251
- process.exit(1)
252
- } else {
253
- await appendTaskLog(taskId, "task completed")
254
- await patchTask(taskId, () => ({
255
- status: "completed",
256
- result,
257
- error: null,
258
- endedAt: now(),
259
- lastHeartbeatAt: now()
260
- }))
261
- process.exit(0)
262
- }
263
- } catch (error) {
264
- const latest = await readTask(taskId)
265
- const cancelled = latest?.cancelled
266
- const aborted = isAbortError(error)
267
- if (cancelled) {
268
- await appendTaskLog(taskId, "task cancelled")
269
- await patchTask(taskId, () => ({
270
- status: "cancelled",
271
- endedAt: now(),
272
- error: null
273
- }))
274
- process.exit(0)
275
- return
276
- }
277
-
278
- if (aborted) {
279
- await appendTaskLog(taskId, `task interrupted: ${error.message}`)
280
- await patchTask(taskId, () => ({
281
- status: "interrupted",
282
- error: error.message,
283
- endedAt: now()
284
- }))
285
- process.exit(2)
286
- return
287
- }
288
-
289
- await appendTaskLog(taskId, `task error: ${error.message}`)
290
- await patchTask(taskId, () => ({
291
- status: "error",
292
- error: error.message,
293
- endedAt: now()
294
- }))
295
- process.exit(1)
296
- } finally {
297
- clearInterval(heartbeatTimer)
298
- clearInterval(cancelPoll)
299
- clearTimeout(timeoutTimer)
300
- }
301
- }
302
-
303
- main().catch(() => {
304
- process.exit(1)
305
- })
1
+ import { appendFile, access, copyFile, mkdir } from "node:fs/promises"
2
+ import path from "node:path"
3
+ import { readJson, writeJson } from "../storage/json-store.mjs"
4
+ import { ensureBackgroundTaskRuntimeDir, backgroundTaskCheckpointPath, backgroundTaskLogPath } from "../storage/paths.mjs"
5
+ import { buildContext } from "../context.mjs"
6
+ import { ToolRegistry } from "../tool/registry.mjs"
7
+ import { executeTurn } from "../session/engine.mjs"
8
+ import { flushNow, forkSession, getSession } from "../session/store.mjs"
9
+ import { extractEditFeedbackFromToolEvents } from "../observability/edit-diagnostics.mjs"
10
+ import { INTERRUPTION_REASONS, normalizeInterruptionReason } from "./interruption-reason.mjs"
11
+ import * as git from "../util/git.mjs"
12
+
13
+ function now() {
14
+ return Date.now()
15
+ }
16
+
17
+ function argValue(flag) {
18
+ const idx = process.argv.indexOf(flag)
19
+ if (idx < 0) return null
20
+ return process.argv[idx + 1] || null
21
+ }
22
+
23
+ function makeAbortError(reason = "aborted") {
24
+ const err = new Error(reason)
25
+ err.code = "ABORT_ERR"
26
+ return err
27
+ }
28
+
29
+ function isAbortError(error) {
30
+ return error?.code === "ABORT_ERR" || error?.name === "AbortError"
31
+ }
32
+
33
+ async function copyWorkspaceConfigFiles(sourceRoot, targetRoot) {
34
+ const candidates = [
35
+ "kkcode.config.json",
36
+ "kkcode.config.yaml",
37
+ ".kkcode/config.json",
38
+ ".kkcode/config.yaml"
39
+ ]
40
+ for (const rel of candidates) {
41
+ const from = path.join(sourceRoot, rel)
42
+ try {
43
+ await access(from)
44
+ } catch {
45
+ continue
46
+ }
47
+ const to = path.join(targetRoot, rel)
48
+ await mkdir(path.dirname(to), { recursive: true })
49
+ await copyFile(from, to)
50
+ }
51
+ }
52
+
53
+ async function readTask(taskId) {
54
+ return readJson(backgroundTaskCheckpointPath(taskId), null)
55
+ }
56
+
57
+ async function patchTask(taskId, updater) {
58
+ const current = await readTask(taskId)
59
+ if (!current) return null
60
+ const next = {
61
+ ...current,
62
+ ...updater(current),
63
+ updatedAt: now()
64
+ }
65
+ await writeJson(backgroundTaskCheckpointPath(taskId), next)
66
+ return next
67
+ }
68
+
69
+ let _maxLogLines = 300
70
+
71
+ let _logBuffer = []
72
+ let _logFlushTimer = null
73
+ const LOG_FLUSH_INTERVAL_MS = 3000
74
+
75
+ async function flushLogBuffer(taskId) {
76
+ if (!_logBuffer.length) return
77
+ const lines = _logBuffer.splice(0)
78
+ await patchTask(taskId, (current) => ({
79
+ logs: [...(current.logs || []), ...lines].slice(-_maxLogLines),
80
+ lastHeartbeatAt: now()
81
+ }))
82
+ }
83
+
84
+ async function appendTaskLog(taskId, line) {
85
+ await appendFile(backgroundTaskLogPath(taskId), `${line}\n`, "utf8")
86
+ _logBuffer.push(String(line))
87
+ if (!_logFlushTimer) {
88
+ _logFlushTimer = setTimeout(async () => {
89
+ _logFlushTimer = null
90
+ await flushLogBuffer(taskId).catch(() => {})
91
+ }, LOG_FLUSH_INTERVAL_MS)
92
+ }
93
+ }
94
+
95
+ async function ensureDelegatedSession({ executionMode, parentSessionId, subSessionId }) {
96
+ if (executionMode !== "fork_context") return
97
+ if (!parentSessionId) throw new Error("fork_context requires a parent session")
98
+
99
+ const existing = await getSession(subSessionId)
100
+ if (existing) return
101
+
102
+ const forked = await forkSession({
103
+ sessionId: parentSessionId,
104
+ newSessionId: subSessionId,
105
+ title: `fork:${subSessionId}`
106
+ })
107
+
108
+ if (!forked) {
109
+ throw new Error(`fork_context parent session not found: ${parentSessionId}`)
110
+ }
111
+
112
+ await flushNow()
113
+ }
114
+
115
+ async function runDelegateTask(task, signal) {
116
+ const payload = task.payload || {}
117
+ const repoCwd = payload.cwd || process.cwd()
118
+ let effectiveCwd = repoCwd
119
+ let worktree = null
120
+
121
+ if (String(payload.isolation || "default").trim().toLowerCase() === "worktree") {
122
+ const created = await git.createDetachedWorktree(repoCwd, task.id)
123
+ if (!created.ok) {
124
+ throw new Error(`worktree setup failed: ${created.error}`)
125
+ }
126
+ worktree = created
127
+ effectiveCwd = created.path
128
+ await copyWorkspaceConfigFiles(repoCwd, effectiveCwd)
129
+ }
130
+
131
+ process.chdir(effectiveCwd)
132
+
133
+ const ctx = await buildContext({ cwd: effectiveCwd })
134
+ _maxLogLines = Number(ctx.configState.config?.background?.max_log_lines || 300)
135
+ await ToolRegistry.initialize({
136
+ config: ctx.configState.config,
137
+ cwd: effectiveCwd
138
+ })
139
+ const { CustomAgentRegistry } = await import("../agent/custom-agent-loader.mjs")
140
+ await CustomAgentRegistry.initialize(effectiveCwd)
141
+
142
+ const providerType = payload.providerType || ctx.configState.config.provider.default
143
+ const providerDefault = ctx.configState.config.provider[providerType]
144
+ const model = payload.model || providerDefault?.default_model
145
+ const executionMode = String(payload.executionMode || "fresh_agent").trim().toLowerCase() || "fresh_agent"
146
+
147
+ if (!["fresh_agent", "fork_context"].includes(executionMode)) {
148
+ throw new Error(`unsupported task.execution_mode: ${payload.executionMode}`)
149
+ }
150
+ if (payload.allowQuestion === true) {
151
+ throw new Error("background delegated tasks cannot set allow_question=true")
152
+ }
153
+
154
+ await ensureDelegatedSession({
155
+ executionMode,
156
+ parentSessionId: payload.parentSessionId || null,
157
+ subSessionId: payload.subSessionId
158
+ })
159
+
160
+ let out
161
+ try {
162
+ out = await executeTurn({
163
+ prompt: String(payload.prompt || ""),
164
+ mode: "agent",
165
+ model,
166
+ providerType,
167
+ sessionId: payload.subSessionId,
168
+ configState: ctx.configState,
169
+ signal,
170
+ allowQuestion: false,
171
+ toolContext: {
172
+ taskId: task.id,
173
+ stageId: payload.stageId || null,
174
+ logicalTaskId: payload.logicalTaskId || null
175
+ }
176
+ })
177
+ await flushNow()
178
+ } catch (error) {
179
+ if (worktree) {
180
+ const clean = await git.isClean(worktree.path).catch(() => false)
181
+ if (clean) {
182
+ await git.removeWorktree(worktree.path, repoCwd).catch(() => {})
183
+ }
184
+ }
185
+ throw error
186
+ }
187
+
188
+ const plannedFiles = Array.isArray(payload.plannedFiles)
189
+ ? payload.plannedFiles.map((item) => String(item || "").trim()).filter(Boolean)
190
+ : []
191
+ const completedFilesFromTools = out.toolEvents
192
+ .filter((event) => ["write", "edit"].includes(event.name) && event.status === "completed")
193
+ .map((event) => {
194
+ const p = event.args?.path
195
+ return p ? String(p).trim() : ""
196
+ })
197
+ .filter(Boolean)
198
+
199
+ const fileChanges = out.toolEvents
200
+ .flatMap((event) => Array.isArray(event?.metadata?.fileChanges) ? event.metadata.fileChanges : [])
201
+ .map((item) => ({
202
+ path: String(item?.path || "").trim(),
203
+ addedLines: Math.max(0, Number(item?.addedLines || 0)),
204
+ removedLines: Math.max(0, Number(item?.removedLines || 0)),
205
+ stageId: item?.stageId ? String(item.stageId) : (payload.stageId || ""),
206
+ taskId: item?.taskId ? String(item.taskId) : (payload.logicalTaskId || "")
207
+ }))
208
+ .filter((item) => item.path)
209
+ const editFeedback = extractEditFeedbackFromToolEvents(out.toolEvents || [])
210
+
211
+ const completedFileSet = new Set(
212
+ completedFilesFromTools.filter((file) => plannedFiles.length === 0 || plannedFiles.includes(file))
213
+ )
214
+ const completedFiles = [...completedFileSet]
215
+ const remainingFiles = plannedFiles.filter((file) => !completedFileSet.has(file))
216
+ const worktreePreserved = Boolean(worktree && (fileChanges.length > 0 || completedFiles.length > 0))
217
+
218
+ if (worktree && !worktreePreserved) {
219
+ await git.removeWorktree(worktree.path, repoCwd).catch(() => {})
220
+ }
221
+
222
+ return {
223
+ session_id: payload.subSessionId,
224
+ parent_session_id: payload.parentSessionId || null,
225
+ subagent: payload.subagent || null,
226
+ execution_mode: executionMode,
227
+ reply: out.reply,
228
+ tool_events: out.toolEvents?.length || 0,
229
+ completed_files: completedFiles,
230
+ remaining_files: remainingFiles,
231
+ file_changes: fileChanges,
232
+ edit_feedback: editFeedback,
233
+ cost: out.cost,
234
+ budget_warnings: out.budgetWarnings || [],
235
+ isolation: String(payload.isolation || "default"),
236
+ worktree_path: worktreePreserved ? worktree.path : null,
237
+ worktree_preserved: worktreePreserved
238
+ }
239
+ }
240
+
241
+ const SILENT_ERROR_PATTERNS = [
242
+ /provider[\s._-]*error/i,
243
+ /api[\s._-]*timeout/i,
244
+ /rate[\s._-]?limit/i,
245
+ /\b(429|503|502|500)\b/,
246
+ /missing api key/i,
247
+ /stream idle timeout/i,
248
+ /\b(econnreset|econnrefused|etimedout)\b/i,
249
+ /budget exceeded/i
250
+ ]
251
+
252
+ function detectSilentError(result, payload) {
253
+ const reply = String(result?.reply || "")
254
+ const toolEvents = Number(result?.tool_events || 0)
255
+ const plannedFiles = Array.isArray(payload?.plannedFiles) ? payload.plannedFiles : []
256
+ const completedFiles = Array.isArray(result?.completed_files) ? result.completed_files : []
257
+ const remainingFiles = Array.isArray(result?.remaining_files) ? result.remaining_files : []
258
+
259
+ // Guard: tasks without plannedFiles (review/analysis) skip all detection
260
+ if (plannedFiles.length === 0) return { hasError: false, errorMessage: "" }
261
+
262
+ // Guard: [TASK_COMPLETE] marker present — trust the agent's self-report
263
+ if (reply.toLowerCase().includes("[task_complete]")) return { hasError: false, errorMessage: "" }
264
+
265
+ // Guard: has tool activity and substantial reply — likely real work done
266
+ if (toolEvents > 0 && reply.length >= 200) return { hasError: false, errorMessage: "" }
267
+
268
+ // Pattern matching: known provider error signatures in reply
269
+ for (const pattern of SILENT_ERROR_PATTERNS) {
270
+ if (pattern.test(reply)) {
271
+ return { hasError: true, errorMessage: `silent provider error detected: ${reply.slice(0, 200)}` }
272
+ }
273
+ }
274
+
275
+ // Heuristic: planned files exist but none completed, low activity
276
+ if (completedFiles.length === 0
277
+ && remainingFiles.length === plannedFiles.length
278
+ && (reply.length < 200 || toolEvents === 0)) {
279
+ return { hasError: true, errorMessage: `heuristic: no files completed, no tool activity (reply ${reply.length} chars, ${toolEvents} tool events)` }
280
+ }
281
+
282
+ return { hasError: false, errorMessage: "" }
283
+ }
284
+
285
+ async function main() {
286
+ const taskId = argValue("--task-id") || process.env.KKCODE_BACKGROUND_TASK_ID || null
287
+ if (!taskId) {
288
+ process.exit(1)
289
+ return
290
+ }
291
+
292
+ await ensureBackgroundTaskRuntimeDir()
293
+ const task = await readTask(taskId)
294
+ if (!task) {
295
+ process.exit(1)
296
+ return
297
+ }
298
+
299
+ if (task.cancelled) {
300
+ await patchTask(taskId, () => ({
301
+ status: "cancelled",
302
+ interruptionReason: INTERRUPTION_REASONS.USER_CANCEL,
303
+ endedAt: now()
304
+ }))
305
+ process.exit(0)
306
+ return
307
+ }
308
+
309
+ await patchTask(taskId, () => ({
310
+ status: "running",
311
+ workerPid: process.pid,
312
+ startedAt: now(),
313
+ lastHeartbeatAt: now()
314
+ }))
315
+
316
+ const abortController = new AbortController()
317
+ const parentPid = process.ppid
318
+ const heartbeatTimer = setInterval(() => {
319
+ patchTask(taskId, () => ({ lastHeartbeatAt: now() })).catch(() => {})
320
+ }, 2000)
321
+
322
+ const cancelPoll = setInterval(() => {
323
+ // Orphan detection: if parent process died, self-terminate
324
+ try { process.kill(parentPid, 0) } catch {
325
+ if (!abortController.signal.aborted) {
326
+ abortController.abort(makeAbortError("parent process exited, worker orphaned"))
327
+ }
328
+ return
329
+ }
330
+ readTask(taskId).then((latest) => {
331
+ if (latest?.cancelled && !abortController.signal.aborted) {
332
+ abortController.abort(makeAbortError("cancelled by user"))
333
+ }
334
+ }).catch(() => {})
335
+ }, 1500)
336
+
337
+ const timeoutMs = Math.max(1000, Number(task.payload?.workerTimeoutMs || 900000))
338
+ const timeoutTimer = setTimeout(() => {
339
+ if (!abortController.signal.aborted) {
340
+ abortController.abort(makeAbortError(`worker timeout after ${timeoutMs}ms`))
341
+ }
342
+ }, timeoutMs)
343
+
344
+ try {
345
+ await appendTaskLog(taskId, `task started (worker pid=${process.pid})`)
346
+
347
+ const latest = await readTask(taskId)
348
+ if (!latest?.payload?.workerType || latest.payload.workerType !== "delegate_task") {
349
+ throw new Error(`unsupported workerType: ${latest?.payload?.workerType || "unknown"}`)
350
+ }
351
+
352
+ const result = await runDelegateTask(latest, abortController.signal)
353
+ const silentCheck = detectSilentError(result, latest.payload)
354
+ if (silentCheck.hasError) {
355
+ await appendTaskLog(taskId, `silent error detected: ${silentCheck.errorMessage}`)
356
+ await patchTask(taskId, () => ({
357
+ status: "error",
358
+ result,
359
+ error: silentCheck.errorMessage,
360
+ endedAt: now(),
361
+ lastHeartbeatAt: now()
362
+ }))
363
+ process.exit(1)
364
+ } else {
365
+ await appendTaskLog(taskId, "task completed")
366
+ await patchTask(taskId, () => ({
367
+ status: "completed",
368
+ result,
369
+ error: null,
370
+ endedAt: now(),
371
+ lastHeartbeatAt: now()
372
+ }))
373
+ process.exit(0)
374
+ }
375
+ } catch (error) {
376
+ const latest = await readTask(taskId)
377
+ const cancelled = latest?.cancelled
378
+ const aborted = isAbortError(error)
379
+ if (cancelled) {
380
+ await appendTaskLog(taskId, "task cancelled")
381
+ await patchTask(taskId, () => ({
382
+ status: "cancelled",
383
+ interruptionReason: INTERRUPTION_REASONS.USER_CANCEL,
384
+ endedAt: now(),
385
+ error: null
386
+ }))
387
+ process.exit(0)
388
+ return
389
+ }
390
+
391
+ if (aborted) {
392
+ await appendTaskLog(taskId, `task interrupted: ${error.message}`)
393
+ await patchTask(taskId, () => ({
394
+ status: "interrupted",
395
+ interruptionReason: normalizeInterruptionReason(error.message),
396
+ error: error.message,
397
+ endedAt: now()
398
+ }))
399
+ process.exit(2)
400
+ return
401
+ }
402
+
403
+ await appendTaskLog(taskId, `task error: ${error.message}`)
404
+ await patchTask(taskId, () => ({
405
+ status: "error",
406
+ error: error.message,
407
+ endedAt: now()
408
+ }))
409
+ process.exit(1)
410
+ } finally {
411
+ clearInterval(heartbeatTimer)
412
+ clearInterval(cancelPoll)
413
+ clearTimeout(timeoutTimer)
414
+ }
415
+ }
416
+
417
+ main().catch(() => {
418
+ process.exit(1)
419
+ })