@kkelly-offical/kkcode 0.1.7 → 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 +228 -220
  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 +89 -89
  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 -2981
  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 +298 -298
  96. package/src/session/engine.mjs +417 -232
  97. package/src/session/longagent-4stage.mjs +467 -460
  98. package/src/session/longagent-hybrid.mjs +1344 -1097
  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 -900
  105. package/src/session/loop.mjs +1005 -930
  106. package/src/session/prompt/agent.txt +25 -25
  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 +31 -31
  110. package/src/session/prompt/qwen.txt +46 -46
  111. package/src/session/recovery.mjs +21 -0
  112. package/src/session/rollback.mjs +196 -195
  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 -519
  116. package/src/session/system-prompt.mjs +308 -273
  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 +99 -93
  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,372 +1,567 @@
1
- import path from "node:path"
2
- import { spawn } from "node:child_process"
3
- import { openSync, closeSync } from "node:fs"
4
- import { readdir, unlink } from "node:fs/promises"
5
- import { fileURLToPath } from "node:url"
6
- import { readJson, writeJson } from "../storage/json-store.mjs"
7
- import {
8
- ensureBackgroundTaskRuntimeDir,
9
- backgroundTaskCheckpointPath,
10
- backgroundTaskLogPath,
11
- backgroundTaskRuntimeDir
12
- } from "../storage/paths.mjs"
13
-
14
- const WORKER_ENTRY = fileURLToPath(new URL("./background-worker.mjs", import.meta.url))
15
- const TERMINAL_STATES = new Set(["completed", "cancelled", "error", "interrupted"])
16
-
17
- function now() {
18
- return Date.now()
19
- }
20
-
21
- function resolveWorkerTimeoutMs(config = {}, payload = {}) {
22
- const raw = Number(payload.workerTimeoutMs || config.background?.worker_timeout_ms || 900000)
23
- return Number.isFinite(raw) ? Math.max(1000, raw) : 900000
24
- }
25
-
26
- function resolveMaxParallel(config = {}) {
27
- const raw = Number(config.background?.max_parallel || 2)
28
- return Number.isFinite(raw) ? Math.max(1, raw) : 2
29
- }
30
-
31
- function isProcessAlive(pid) {
32
- if (!Number.isInteger(pid) || pid <= 0) return false
33
- try {
34
- process.kill(pid, 0)
35
- return true
36
- } catch {
37
- return false
38
- }
39
- }
40
-
41
- async function loadTask(id) {
42
- return readJson(backgroundTaskCheckpointPath(id), null)
43
- }
44
-
45
- async function saveTask(task) {
46
- await ensureBackgroundTaskRuntimeDir()
47
- await writeJson(backgroundTaskCheckpointPath(task.id), task)
48
- return task
49
- }
50
-
51
- async function patchTask(id, updater, { maxRetries = 3 } = {}) {
52
- for (let attempt = 0; attempt <= maxRetries; attempt++) {
53
- const current = await loadTask(id)
54
- if (!current) return null
55
- const next = {
56
- ...current,
57
- ...updater(current),
58
- _version: (current._version || 0) + 1,
59
- updatedAt: now()
60
- }
61
- // Optimistic lock: re-read and verify version before write
62
- const check = await loadTask(id)
63
- if (check && (check._version || 0) !== (current._version || 0)) {
64
- if (attempt < maxRetries) continue // version changed, retry
65
- // Last attempt: log warning and fail instead of silent overwrite
66
- const err = new Error(`patchTask(${id}): version conflict after ${maxRetries} retries (expected ${current._version}, got ${check._version})`)
67
- err.code = "VERSION_CONFLICT"
68
- throw err
69
- }
70
- await saveTask(next)
71
- return next
72
- }
73
- return null
74
- }
75
-
76
- async function listTaskIds() {
77
- await ensureBackgroundTaskRuntimeDir()
78
- const entries = await readdir(backgroundTaskRuntimeDir(), { withFileTypes: true }).catch(() => [])
79
- return entries
80
- .filter((entry) => entry.isFile() && entry.name.endsWith(".json"))
81
- .map((entry) => path.basename(entry.name, ".json"))
82
- }
83
-
84
- async function readAllTasks() {
85
- const ids = await listTaskIds()
86
- const out = []
87
- for (const id of ids) {
88
- const task = await loadTask(id)
89
- if (task) out.push(task)
90
- }
91
- return out.sort((a, b) => b.updatedAt - a.updatedAt)
92
- }
93
-
94
- function spawnWorker(taskId) {
95
- const logFile = backgroundTaskLogPath(taskId)
96
- let stderrFd = null
97
- try {
98
- stderrFd = openSync(logFile, "a")
99
- } catch {
100
- // directory may not exist yet or permission issue — fall back to ignore
101
- }
102
- let child
103
- try {
104
- child = spawn(process.execPath, [WORKER_ENTRY, "--task-id", taskId], {
105
- detached: true,
106
- windowsHide: true,
107
- stdio: ["ignore", "ignore", stderrFd !== null ? stderrFd : "ignore"],
108
- env: {
109
- ...process.env,
110
- KKCODE_BACKGROUND_TASK_ID: taskId
111
- }
112
- })
113
- } catch (err) {
114
- // Close fd to prevent leak if spawn fails
115
- if (stderrFd !== null) {
116
- try { closeSync(stderrFd) } catch { /* ignore */ }
117
- }
118
- throw err
119
- }
120
- child.on("exit", (code) => {
121
- if (stderrFd !== null) {
122
- try { closeSync(stderrFd) } catch { /* already closed */ }
123
- }
124
- if (code && code !== 0) {
125
- patchTask(taskId, (current) => {
126
- if (current.status === "running") {
127
- return {
128
- status: "error",
129
- error: `worker process exited with code ${code}`,
130
- endedAt: now()
131
- }
132
- }
133
- return {}
134
- }).catch(() => {})
135
- }
136
- })
137
- child.unref()
138
- return child.pid
139
- }
140
-
141
- async function markStaleRunningTasks(config = {}) {
142
- const tasks = await readAllTasks()
143
- const timeoutDefault = Math.max(1000, Number(config.background?.worker_timeout_ms || 900000))
144
- let interrupted = 0
145
-
146
- for (const task of tasks) {
147
- if (task.status !== "running") continue
148
- const heartbeatAt = Number(task.lastHeartbeatAt || 0)
149
- const timeoutMs = resolveWorkerTimeoutMs(config, task.payload || {})
150
- const staleByHeartbeat = heartbeatAt > 0 && now() - heartbeatAt > timeoutMs + 5000
151
- const deadPid = task.workerPid ? !isProcessAlive(task.workerPid) : false
152
- const staleNoHeartbeat = heartbeatAt === 0 && now() - Number(task.startedAt || task.createdAt || now()) > timeoutDefault + 5000
153
-
154
- if (staleByHeartbeat || deadPid || staleNoHeartbeat) {
155
- await patchTask(task.id, () => ({
156
- status: "interrupted",
157
- endedAt: now(),
158
- error: deadPid
159
- ? "background worker exited unexpectedly"
160
- : staleByHeartbeat
161
- ? "background worker heartbeat timeout"
162
- : "background worker no heartbeat",
163
- workerPid: null
164
- }))
165
- interrupted += 1
166
- }
167
- }
168
-
169
- return interrupted
170
- }
171
-
172
- async function startPendingTasks(config = {}) {
173
- const maxParallel = resolveMaxParallel(config)
174
- const tasks = await readAllTasks()
175
- const running = tasks.filter((task) => task.status === "running").length
176
- let remainingSlots = Math.max(0, maxParallel - running)
177
- if (remainingSlots <= 0) return 0
178
-
179
- let started = 0
180
- const pending = tasks
181
- .filter((task) => task.status === "pending" && task.backgroundMode === "worker_process")
182
- .sort((a, b) => a.createdAt - b.createdAt)
183
-
184
- for (const task of pending) {
185
- if (remainingSlots <= 0) break
186
- let pid
187
- try {
188
- pid = spawnWorker(task.id)
189
- } catch (err) {
190
- await patchTask(task.id, () => ({
191
- status: "error",
192
- error: `spawn failed: ${err.message}`,
193
- endedAt: now()
194
- }))
195
- continue
196
- }
197
- const timeoutMs = resolveWorkerTimeoutMs(config, task.payload || {})
198
- await patchTask(task.id, (current) => ({
199
- status: "running",
200
- workerPid: pid,
201
- lastHeartbeatAt: now(),
202
- startedAt: current.startedAt || now(),
203
- payload: {
204
- ...(current.payload || {}),
205
- workerTimeoutMs: timeoutMs
206
- }
207
- }))
208
- remainingSlots -= 1
209
- started += 1
210
- }
211
-
212
- return started
213
- }
214
-
215
- async function runInline(task, run) {
216
- await patchTask(task.id, () => ({ status: "running", startedAt: now() }))
217
- try {
218
- const result = await run({
219
- taskId: task.id,
220
- isCancelled: async () => {
221
- const latest = await loadTask(task.id)
222
- return Boolean(latest?.cancelled)
223
- },
224
- log: async (line) => {
225
- await patchTask(task.id, (current) => ({
226
- logs: [...(current.logs || []), String(line)].slice(-300),
227
- lastHeartbeatAt: now()
228
- }))
229
- }
230
- })
231
- const latest = await loadTask(task.id)
232
- if (latest?.cancelled) {
233
- await patchTask(task.id, () => ({ status: "cancelled", endedAt: now() }))
234
- return
235
- }
236
- await patchTask(task.id, () => ({ status: "completed", result, endedAt: now() }))
237
- } catch (error) {
238
- const latest = await loadTask(task.id)
239
- await patchTask(task.id, () => ({
240
- status: latest?.cancelled ? "cancelled" : "error",
241
- error: error.message,
242
- endedAt: now()
243
- }))
244
- }
245
- }
246
-
247
- export const BackgroundManager = {
248
- async launch({ description, payload, run = null, config = {} }) {
249
- await ensureBackgroundTaskRuntimeDir()
250
- const id = `bg_${Math.random().toString(36).slice(2, 14)}`
251
- const timeoutMs = resolveWorkerTimeoutMs(config, payload || {})
252
- const task = {
253
- id,
254
- description,
255
- payload: {
256
- ...(payload || {}),
257
- workerTimeoutMs: timeoutMs
258
- },
259
- status: "pending",
260
- createdAt: now(),
261
- updatedAt: now(),
262
- startedAt: null,
263
- endedAt: null,
264
- logs: [],
265
- result: null,
266
- error: null,
267
- cancelled: false,
268
- backgroundMode: run ? "inline" : (config.background?.mode || "worker_process"),
269
- workerPid: null,
270
- lastHeartbeatAt: null,
271
- attempt: Number(payload?.attempt || 1),
272
- resumeToken: payload?.resumeToken || `resume_${Date.now()}`
273
- }
274
- await saveTask(task)
275
-
276
- if (run) {
277
- queueMicrotask(() => {
278
- runInline(task, run).catch((err) => {
279
- patchTask(task.id, () => ({
280
- status: "error",
281
- error: `inline task failed: ${err?.message || String(err)}`,
282
- endedAt: now()
283
- })).catch(() => {})
284
- })
285
- })
286
- return task
287
- }
288
-
289
- await this.tick(config)
290
- return (await loadTask(id)) || task
291
- },
292
-
293
- async launchDelegateTask({ description, payload, config = {} }) {
294
- return this.launch({
295
- description,
296
- payload: {
297
- ...payload,
298
- workerType: "delegate_task",
299
- attempt: Number(payload.attempt || 1),
300
- resumeToken: payload.resumeToken || `resume_${Date.now()}`
301
- },
302
- run: null,
303
- config
304
- })
305
- },
306
-
307
- async get(id) {
308
- await ensureBackgroundTaskRuntimeDir()
309
- return loadTask(id)
310
- },
311
-
312
- async list() {
313
- await ensureBackgroundTaskRuntimeDir()
314
- return readAllTasks()
315
- },
316
-
317
- async cancel(id) {
318
- const task = await loadTask(id)
319
- if (!task) return false
320
- await patchTask(id, (current) => ({
321
- cancelled: true,
322
- status: current.status === "pending" ? "cancelled" : current.status
323
- }))
324
- return true
325
- },
326
-
327
- async retry(id, config = {}) {
328
- const task = await loadTask(id)
329
- if (!task) return null
330
- if (!["error", "interrupted"].includes(task.status)) return null
331
-
332
- const nextAttempt = Number(task.attempt || 1) + 1
333
- const nextResumeToken = `resume_${Date.now()}`
334
- await patchTask(id, () => ({
335
- status: "pending",
336
- error: null,
337
- cancelled: false,
338
- endedAt: null,
339
- workerPid: null,
340
- lastHeartbeatAt: null,
341
- attempt: nextAttempt,
342
- resumeToken: nextResumeToken,
343
- payload: {
344
- ...(task.payload || {}),
345
- attempt: nextAttempt,
346
- resumeToken: nextResumeToken
347
- }
348
- }))
349
-
350
- await this.tick(config)
351
- return loadTask(id)
352
- },
353
-
354
- async clean({ maxAge = 7 * 24 * 60 * 60 * 1000 } = {}) {
355
- const tasks = await readAllTasks()
356
- const cutoff = now() - maxAge
357
- const removed = []
358
- for (const task of tasks) {
359
- if (!TERMINAL_STATES.has(task.status)) continue
360
- if (task.updatedAt > cutoff) continue
361
- await unlink(backgroundTaskCheckpointPath(task.id)).catch(() => {})
362
- await unlink(backgroundTaskLogPath(task.id)).catch(() => {})
363
- removed.push(task.id)
364
- }
365
- return removed
366
- },
367
-
368
- async tick(config = {}) {
369
- await markStaleRunningTasks(config)
370
- await startPendingTasks(config)
371
- }
372
- }
1
+ import path from "node:path"
2
+ import { spawn } from "node:child_process"
3
+ import { openSync, closeSync } from "node:fs"
4
+ import { readdir, unlink } from "node:fs/promises"
5
+ import { fileURLToPath } from "node:url"
6
+ import { EventEmitter } from "node:events"
7
+ import { readJson, writeJson } from "../storage/json-store.mjs"
8
+ import { INTERRUPTION_REASONS } from "./interruption-reason.mjs"
9
+ import {
10
+ ensureBackgroundTaskRuntimeDir,
11
+ backgroundTaskCheckpointPath,
12
+ backgroundTaskLogPath,
13
+ backgroundTaskRuntimeDir
14
+ } from "../storage/paths.mjs"
15
+
16
+ // Internal emitter for task settlement notifications
17
+ const settledEmitter = new EventEmitter()
18
+ settledEmitter.setMaxListeners(50)
19
+
20
+ const WORKER_ENTRY = fileURLToPath(new URL("./background-worker.mjs", import.meta.url))
21
+ const TERMINAL_STATES = new Set(["completed", "cancelled", "error", "interrupted"])
22
+
23
+ function now() {
24
+ return Date.now()
25
+ }
26
+
27
+ function clipText(text, max = 160) {
28
+ const value = String(text || "").trim().replace(/\s+/g, " ")
29
+ if (value.length <= max) return value
30
+ return `${value.slice(0, Math.max(0, max - 1))}…`
31
+ }
32
+
33
+ function extractTaskResultPreview(task) {
34
+ if (task?.status === "completed") {
35
+ const reply = String(task?.result?.reply || task?.result?.summary || "").trim()
36
+ if (reply) return clipText(reply, 180)
37
+ return "completed successfully"
38
+ }
39
+ if (task?.error) return clipText(task.error, 180)
40
+ if (task?.interruptionReason) return clipText(task.interruptionReason, 120)
41
+ return ""
42
+ }
43
+
44
+ function nextActionForTask(task) {
45
+ switch (task?.status) {
46
+ case "pending":
47
+ return "wait for the worker to start or inspect later with background show/background_output"
48
+ case "running":
49
+ return "wait for completion or inspect logs with background show/background_output"
50
+ case "completed":
51
+ return "read the final result and file changes via background_output"
52
+ case "error":
53
+ return "inspect the error/log tail and use background retry if the task is safe to rerun"
54
+ case "interrupted":
55
+ return "inspect the interruption reason and use background retry when appropriate"
56
+ case "cancelled":
57
+ return "rerun the task if you still need the sidecar result"
58
+ default:
59
+ return "inspect the task record for more detail"
60
+ }
61
+ }
62
+
63
+ function summarizeTask(task) {
64
+ if (!task) return null
65
+ return {
66
+ id: task.id,
67
+ description: task.description,
68
+ status: task.status,
69
+ attempt: Number(task.attempt || 1),
70
+ background_mode: task.backgroundMode || null,
71
+ subagent: task.payload?.subagent || task.payload?.subagentType || null,
72
+ execution_mode: task.payload?.executionMode || null,
73
+ session_id: task.payload?.subSessionId || null,
74
+ parent_session_id: task.payload?.parentSessionId || null,
75
+ stage_id: task.payload?.stageId || null,
76
+ logical_task_id: task.payload?.logicalTaskId || null,
77
+ created_at: task.createdAt || null,
78
+ started_at: task.startedAt || null,
79
+ ended_at: task.endedAt || null,
80
+ interruption_reason: task.interruptionReason || null,
81
+ next_action: nextActionForTask(task),
82
+ log_lines: Array.isArray(task.logs) ? task.logs.length : 0,
83
+ log_tail: Array.isArray(task.logs) ? task.logs.slice(-10) : [],
84
+ result_preview: extractTaskResultPreview(task)
85
+ }
86
+ }
87
+
88
+ function summarizeTaskList(tasks = []) {
89
+ const counts = {
90
+ pending: 0,
91
+ running: 0,
92
+ completed: 0,
93
+ cancelled: 0,
94
+ error: 0,
95
+ interrupted: 0
96
+ }
97
+ for (const task of tasks) {
98
+ if (counts[task.status] !== undefined) counts[task.status] += 1
99
+ }
100
+ return {
101
+ total: tasks.length,
102
+ active: counts.pending + counts.running,
103
+ counts,
104
+ recent_terminal: tasks
105
+ .filter((task) => TERMINAL_STATES.has(task.status))
106
+ .slice(0, 3)
107
+ .map((task) => summarizeTask(task))
108
+ }
109
+ }
110
+
111
+ function resolveWorkerTimeoutMs(config = {}, payload = {}) {
112
+ const raw = Number(payload.workerTimeoutMs || config.background?.worker_timeout_ms || 900000)
113
+ return Number.isFinite(raw) ? Math.max(1000, raw) : 900000
114
+ }
115
+
116
+ function resolveMaxParallel(config = {}) {
117
+ const raw = Number(config.background?.max_parallel || 2)
118
+ return Number.isFinite(raw) ? Math.max(1, raw) : 2
119
+ }
120
+
121
+ function isProcessAlive(pid) {
122
+ if (!Number.isInteger(pid) || pid <= 0) return false
123
+ try {
124
+ process.kill(pid, 0)
125
+ return true
126
+ } catch {
127
+ return false
128
+ }
129
+ }
130
+
131
+ async function loadTask(id) {
132
+ return readJson(backgroundTaskCheckpointPath(id), null)
133
+ }
134
+
135
+ async function saveTask(task) {
136
+ await ensureBackgroundTaskRuntimeDir()
137
+ await writeJson(backgroundTaskCheckpointPath(task.id), task)
138
+ return task
139
+ }
140
+
141
+ // Process-level mutex to serialize patchTask calls (prevents same-process TOCTOU)
142
+ let patchLock = Promise.resolve()
143
+
144
+ async function patchTask(id, updater, { maxRetries = 3 } = {}) {
145
+ const run = async () => {
146
+ for (let attempt = 0; attempt <= maxRetries; attempt++) {
147
+ const current = await loadTask(id)
148
+ if (!current) return null
149
+ const next = {
150
+ ...current,
151
+ ...updater(current),
152
+ _version: (current._version || 0) + 1,
153
+ updatedAt: now()
154
+ }
155
+ // Optimistic lock: re-read and verify version before write
156
+ const check = await loadTask(id)
157
+ if (check && (check._version || 0) !== (current._version || 0)) {
158
+ if (attempt < maxRetries) continue // version changed, retry
159
+ const err = new Error(`patchTask(${id}): version conflict after ${maxRetries} retries (expected ${current._version}, got ${check._version})`)
160
+ err.code = "VERSION_CONFLICT"
161
+ throw err
162
+ }
163
+ await saveTask(next)
164
+ // Emit settlement notification when task reaches a terminal state
165
+ if (TERMINAL_STATES.has(next.status) && !TERMINAL_STATES.has(current.status)) {
166
+ settledEmitter.emit("task-settled", { id: next.id, status: next.status })
167
+ }
168
+ return next
169
+ }
170
+ return null
171
+ }
172
+ const result = patchLock.then(run, run)
173
+ patchLock = result.then(() => undefined, () => undefined)
174
+ return result
175
+ }
176
+
177
+ async function listTaskIds() {
178
+ await ensureBackgroundTaskRuntimeDir()
179
+ const entries = await readdir(backgroundTaskRuntimeDir(), { withFileTypes: true }).catch(() => [])
180
+ return entries
181
+ .filter((entry) => entry.isFile() && entry.name.endsWith(".json"))
182
+ .map((entry) => path.basename(entry.name, ".json"))
183
+ }
184
+
185
+ async function readAllTasks() {
186
+ const ids = await listTaskIds()
187
+ const out = []
188
+ for (const id of ids) {
189
+ const task = await loadTask(id)
190
+ if (task) out.push(task)
191
+ }
192
+ return out.sort((a, b) => b.updatedAt - a.updatedAt)
193
+ }
194
+
195
+ function spawnWorker(taskId) {
196
+ const logFile = backgroundTaskLogPath(taskId)
197
+ let stderrFd = null
198
+ try {
199
+ stderrFd = openSync(logFile, "a")
200
+ } catch {
201
+ // directory may not exist yet or permission issue — fall back to ignore
202
+ }
203
+ let child
204
+ try {
205
+ child = spawn(process.execPath, [WORKER_ENTRY, "--task-id", taskId], {
206
+ detached: true,
207
+ windowsHide: true,
208
+ stdio: ["ignore", "ignore", stderrFd !== null ? stderrFd : "ignore"],
209
+ env: {
210
+ ...process.env,
211
+ KKCODE_BACKGROUND_TASK_ID: taskId
212
+ }
213
+ })
214
+ } catch (err) {
215
+ // Close fd to prevent leak if spawn fails
216
+ if (stderrFd !== null) {
217
+ try { closeSync(stderrFd) } catch { /* ignore */ }
218
+ }
219
+ throw err
220
+ }
221
+ child.on("exit", (code) => {
222
+ if (stderrFd !== null) {
223
+ try { closeSync(stderrFd) } catch { /* already closed */ }
224
+ }
225
+ if (code && code !== 0) {
226
+ patchTask(taskId, (current) => {
227
+ if (current.status === "running") {
228
+ return {
229
+ status: "error",
230
+ error: `worker process exited with code ${code}`,
231
+ endedAt: now()
232
+ }
233
+ }
234
+ return
235
+ }).catch((err) => {
236
+ console.warn(`[kkcode] patchTask failed for exited worker ${taskId}: ${err?.message || err}`)
237
+ })
238
+ } else {
239
+ // Worker exited cleanly (code 0) notify waiters so they re-check status
240
+ settledEmitter.emit("task-settled", { id: taskId, status: "exited", code: 0 })
241
+ }
242
+ })
243
+ child.unref()
244
+ return child.pid
245
+ }
246
+
247
+ async function markStaleRunningTasks(config = {}) {
248
+ const tasks = await readAllTasks()
249
+ const timeoutDefault = Math.max(1000, Number(config.background?.worker_timeout_ms || 900000))
250
+ let interrupted = 0
251
+
252
+ for (const task of tasks) {
253
+ if (task.status !== "running") continue
254
+ const heartbeatAt = Number(task.lastHeartbeatAt || 0)
255
+ const timeoutMs = resolveWorkerTimeoutMs(config, task.payload || {})
256
+ const staleByHeartbeat = heartbeatAt > 0 && now() - heartbeatAt > timeoutMs + 5000
257
+ const deadPid = task.workerPid ? !isProcessAlive(task.workerPid) : false
258
+ const staleNoHeartbeat = heartbeatAt === 0 && now() - Number(task.startedAt || task.createdAt || now()) > timeoutDefault + 5000
259
+
260
+ if (staleByHeartbeat || deadPid || staleNoHeartbeat) {
261
+ await patchTask(task.id, () => ({
262
+ status: "interrupted",
263
+ endedAt: now(),
264
+ interruptionReason: deadPid ? INTERRUPTION_REASONS.INTERRUPT : INTERRUPTION_REASONS.TIMEOUT,
265
+ error: deadPid
266
+ ? "background worker exited unexpectedly"
267
+ : staleByHeartbeat
268
+ ? "background worker heartbeat timeout"
269
+ : "background worker no heartbeat",
270
+ workerPid: null
271
+ }))
272
+ interrupted += 1
273
+ }
274
+ }
275
+
276
+ return interrupted
277
+ }
278
+
279
+ async function startPendingTasks(config = {}) {
280
+ const maxParallel = resolveMaxParallel(config)
281
+ const tasks = await readAllTasks()
282
+ const running = tasks.filter((task) => task.status === "running").length
283
+ let remainingSlots = Math.max(0, maxParallel - running)
284
+ if (remainingSlots <= 0) return 0
285
+
286
+ let started = 0
287
+ const pending = tasks
288
+ .filter((task) => task.status === "pending" && task.backgroundMode === "worker_process")
289
+ .sort((a, b) => a.createdAt - b.createdAt)
290
+
291
+ for (const task of pending) {
292
+ if (remainingSlots <= 0) break
293
+ let pid
294
+ try {
295
+ pid = spawnWorker(task.id)
296
+ } catch (err) {
297
+ await patchTask(task.id, () => ({
298
+ status: "error",
299
+ error: `spawn failed: ${err.message}`,
300
+ endedAt: now()
301
+ }))
302
+ continue
303
+ }
304
+ const timeoutMs = resolveWorkerTimeoutMs(config, task.payload || {})
305
+ await patchTask(task.id, (current) => ({
306
+ status: "running",
307
+ workerPid: pid,
308
+ lastHeartbeatAt: now(),
309
+ startedAt: current.startedAt || now(),
310
+ payload: {
311
+ ...(current.payload || {}),
312
+ workerTimeoutMs: timeoutMs
313
+ }
314
+ }))
315
+ remainingSlots -= 1
316
+ started += 1
317
+ }
318
+
319
+ return started
320
+ }
321
+
322
+ async function runInline(task, run) {
323
+ await patchTask(task.id, () => ({ status: "running", startedAt: now() }))
324
+ try {
325
+ const result = await run({
326
+ taskId: task.id,
327
+ isCancelled: async () => {
328
+ const latest = await loadTask(task.id)
329
+ return Boolean(latest?.cancelled)
330
+ },
331
+ log: async (line) => {
332
+ await patchTask(task.id, (current) => ({
333
+ logs: [...(current.logs || []), String(line)].slice(-300),
334
+ lastHeartbeatAt: now()
335
+ }))
336
+ }
337
+ })
338
+ const latest = await loadTask(task.id)
339
+ if (latest?.cancelled) {
340
+ await patchTask(task.id, () => ({
341
+ status: "cancelled",
342
+ endedAt: now(),
343
+ interruptionReason: INTERRUPTION_REASONS.USER_CANCEL
344
+ }))
345
+ return
346
+ }
347
+ await patchTask(task.id, () => ({ status: "completed", result, endedAt: now() }))
348
+ } catch (error) {
349
+ const latest = await loadTask(task.id)
350
+ await patchTask(task.id, () => ({
351
+ status: latest?.cancelled ? "cancelled" : "error",
352
+ error: error.message,
353
+ interruptionReason: latest?.cancelled ? INTERRUPTION_REASONS.USER_CANCEL : null,
354
+ endedAt: now()
355
+ }))
356
+ }
357
+ }
358
+
359
+ export const BackgroundManager = {
360
+ async launch({ description, payload, run = null, config = {} }) {
361
+ await ensureBackgroundTaskRuntimeDir()
362
+ const id = `bg_${Math.random().toString(36).slice(2, 14)}`
363
+ const timeoutMs = resolveWorkerTimeoutMs(config, payload || {})
364
+ const task = {
365
+ id,
366
+ description,
367
+ payload: {
368
+ ...(payload || {}),
369
+ workerTimeoutMs: timeoutMs
370
+ },
371
+ status: "pending",
372
+ createdAt: now(),
373
+ updatedAt: now(),
374
+ startedAt: null,
375
+ endedAt: null,
376
+ logs: [],
377
+ result: null,
378
+ error: null,
379
+ interruptionReason: null,
380
+ cancelled: false,
381
+ backgroundMode: run ? "inline" : (config.background?.mode || "worker_process"),
382
+ workerPid: null,
383
+ lastHeartbeatAt: null,
384
+ attempt: Number(payload?.attempt || 1),
385
+ resumeToken: payload?.resumeToken || `resume_${Date.now()}`
386
+ }
387
+ await saveTask(task)
388
+
389
+ if (run) {
390
+ queueMicrotask(() => {
391
+ runInline(task, run).catch((err) => {
392
+ patchTask(task.id, () => ({
393
+ status: "error",
394
+ error: `inline task failed: ${err?.message || String(err)}`,
395
+ endedAt: now()
396
+ })).catch(() => {})
397
+ })
398
+ })
399
+ return task
400
+ }
401
+
402
+ await this.tick(config)
403
+ return (await loadTask(id)) || task
404
+ },
405
+
406
+ async launchDelegateTask({ description, payload, config = {} }) {
407
+ return this.launch({
408
+ description,
409
+ payload: {
410
+ ...payload,
411
+ workerType: "delegate_task",
412
+ attempt: Number(payload.attempt || 1),
413
+ resumeToken: payload.resumeToken || `resume_${Date.now()}`
414
+ },
415
+ run: null,
416
+ config
417
+ })
418
+ },
419
+
420
+ async get(id) {
421
+ await ensureBackgroundTaskRuntimeDir()
422
+ return loadTask(id)
423
+ },
424
+
425
+ summarize(task) {
426
+ return summarizeTask(task)
427
+ },
428
+
429
+ summarizeList(tasks) {
430
+ return summarizeTaskList(tasks)
431
+ },
432
+
433
+ async list() {
434
+ await ensureBackgroundTaskRuntimeDir()
435
+ return readAllTasks()
436
+ },
437
+
438
+ async summary() {
439
+ await ensureBackgroundTaskRuntimeDir()
440
+ return summarizeTaskList(await readAllTasks())
441
+ },
442
+
443
+ async cancel(id) {
444
+ const task = await loadTask(id)
445
+ if (!task) return false
446
+ await patchTask(id, (current) => ({
447
+ cancelled: true,
448
+ status: current.status === "pending" ? "cancelled" : current.status,
449
+ interruptionReason: INTERRUPTION_REASONS.USER_CANCEL
450
+ }))
451
+ return true
452
+ },
453
+
454
+ async retry(id, config = {}) {
455
+ const task = await loadTask(id)
456
+ if (!task) return null
457
+ if (!["error", "interrupted"].includes(task.status)) return null
458
+
459
+ const nextAttempt = Number(task.attempt || 1) + 1
460
+ const nextResumeToken = `resume_${Date.now()}`
461
+ await patchTask(id, () => ({
462
+ status: "pending",
463
+ error: null,
464
+ interruptionReason: null,
465
+ cancelled: false,
466
+ endedAt: null,
467
+ workerPid: null,
468
+ lastHeartbeatAt: null,
469
+ attempt: nextAttempt,
470
+ resumeToken: nextResumeToken,
471
+ payload: {
472
+ ...(task.payload || {}),
473
+ attempt: nextAttempt,
474
+ resumeToken: nextResumeToken
475
+ }
476
+ }))
477
+
478
+ await this.tick(config)
479
+ return loadTask(id)
480
+ },
481
+
482
+ async clean({ maxAge = 7 * 24 * 60 * 60 * 1000 } = {}) {
483
+ const tasks = await readAllTasks()
484
+ const cutoff = now() - maxAge
485
+ const removed = []
486
+ for (const task of tasks) {
487
+ if (!TERMINAL_STATES.has(task.status)) continue
488
+ if (task.updatedAt > cutoff) continue
489
+ await unlink(backgroundTaskCheckpointPath(task.id)).catch(() => {})
490
+ await unlink(backgroundTaskLogPath(task.id)).catch(() => {})
491
+ removed.push(task.id)
492
+ }
493
+ return removed
494
+ },
495
+
496
+ /**
497
+ * Wait for any task to reach a terminal state, or timeout.
498
+ * Returns immediately if a settlement event fires before the deadline.
499
+ */
500
+ waitForSettled(timeoutMs = 300) {
501
+ return new Promise((resolve) => {
502
+ const timer = setTimeout(() => {
503
+ settledEmitter.removeListener("task-settled", onSettled)
504
+ resolve()
505
+ }, timeoutMs)
506
+ function onSettled() {
507
+ clearTimeout(timer)
508
+ settledEmitter.removeListener("task-settled", onSettled)
509
+ resolve()
510
+ }
511
+ settledEmitter.once("task-settled", onSettled)
512
+ })
513
+ },
514
+
515
+ /**
516
+ * Wait for any of the specified tasks to settle, or timeout.
517
+ * Unlike waitForSettled(), this filters by task ID — unrelated task
518
+ * settlements won't cause a spurious wakeup.
519
+ * @param {string[]} taskIds - IDs to watch
520
+ * @param {number} timeoutMs - max wait before resolving anyway
521
+ * @returns {Promise<{id:string,status:string}|null>} settled task info, or null on timeout
522
+ */
523
+ waitForAny(taskIds, timeoutMs = 300) {
524
+ if (!taskIds || !taskIds.length) {
525
+ return this.waitForSettled(timeoutMs)
526
+ }
527
+ const idSet = new Set(taskIds)
528
+ return new Promise((resolve) => {
529
+ let done = false
530
+ const timer = setTimeout(() => {
531
+ done = true
532
+ settledEmitter.removeListener("task-settled", onSettled)
533
+ resolve(null)
534
+ }, timeoutMs)
535
+ function onSettled(event) {
536
+ if (done) return
537
+ if (idSet.has(event.id)) {
538
+ done = true
539
+ clearTimeout(timer)
540
+ settledEmitter.removeListener("task-settled", onSettled)
541
+ resolve(event)
542
+ }
543
+ // unrelated event — .once() already removed us, re-register
544
+ if (!done) settledEmitter.once("task-settled", onSettled)
545
+ }
546
+ settledEmitter.once("task-settled", onSettled)
547
+ })
548
+ },
549
+
550
+ async waitForTask(id, { timeoutMs = 30000, tickMs = 250, config = {} } = {}) {
551
+ const deadline = Date.now() + Math.max(100, Number(timeoutMs || 30000))
552
+ while (Date.now() < deadline) {
553
+ await this.tick(config)
554
+ const task = await loadTask(id)
555
+ if (!task) return null
556
+ if (TERMINAL_STATES.has(task.status)) return task
557
+ const remaining = Math.max(1, deadline - Date.now())
558
+ await this.waitForAny([id], Math.min(Number(tickMs || 250), remaining))
559
+ }
560
+ return loadTask(id)
561
+ },
562
+
563
+ async tick(config = {}) {
564
+ await markStaleRunningTasks(config)
565
+ await startPendingTasks(config)
566
+ }
567
+ }