@kkelly-offical/kkcode 0.1.2

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 (196) hide show
  1. package/LICENSE +674 -0
  2. package/README.md +445 -0
  3. package/package.json +46 -0
  4. package/src/agent/agent.mjs +170 -0
  5. package/src/agent/custom-agent-loader.mjs +158 -0
  6. package/src/agent/generator.mjs +115 -0
  7. package/src/agent/prompt/architect.txt +36 -0
  8. package/src/agent/prompt/build-fixer.txt +71 -0
  9. package/src/agent/prompt/build.txt +101 -0
  10. package/src/agent/prompt/compaction.txt +12 -0
  11. package/src/agent/prompt/explore.txt +29 -0
  12. package/src/agent/prompt/guide.txt +40 -0
  13. package/src/agent/prompt/longagent.txt +178 -0
  14. package/src/agent/prompt/plan.txt +50 -0
  15. package/src/agent/prompt/researcher.txt +23 -0
  16. package/src/agent/prompt/reviewer.txt +44 -0
  17. package/src/agent/prompt/security-reviewer.txt +62 -0
  18. package/src/agent/prompt/tdd-guide.txt +84 -0
  19. package/src/agent/prompt/title.txt +8 -0
  20. package/src/command/custom-commands.mjs +57 -0
  21. package/src/commands/agent.mjs +71 -0
  22. package/src/commands/audit.mjs +77 -0
  23. package/src/commands/background.mjs +86 -0
  24. package/src/commands/chat.mjs +114 -0
  25. package/src/commands/command.mjs +41 -0
  26. package/src/commands/config.mjs +44 -0
  27. package/src/commands/doctor.mjs +148 -0
  28. package/src/commands/hook.mjs +29 -0
  29. package/src/commands/init.mjs +141 -0
  30. package/src/commands/longagent.mjs +100 -0
  31. package/src/commands/mcp.mjs +89 -0
  32. package/src/commands/permission.mjs +36 -0
  33. package/src/commands/prompt.mjs +42 -0
  34. package/src/commands/review.mjs +266 -0
  35. package/src/commands/rule.mjs +34 -0
  36. package/src/commands/session.mjs +235 -0
  37. package/src/commands/theme.mjs +98 -0
  38. package/src/commands/usage.mjs +91 -0
  39. package/src/config/defaults.mjs +195 -0
  40. package/src/config/import-config.mjs +76 -0
  41. package/src/config/load-config.mjs +76 -0
  42. package/src/config/schema.mjs +509 -0
  43. package/src/context.mjs +40 -0
  44. package/src/core/constants.mjs +46 -0
  45. package/src/core/errors.mjs +57 -0
  46. package/src/core/events.mjs +29 -0
  47. package/src/core/types.mjs +57 -0
  48. package/src/github/api.mjs +78 -0
  49. package/src/github/auth.mjs +286 -0
  50. package/src/github/flow.mjs +298 -0
  51. package/src/github/workspace.mjs +212 -0
  52. package/src/index.mjs +82 -0
  53. package/src/knowledge/api-design.txt +9 -0
  54. package/src/knowledge/cpp.txt +10 -0
  55. package/src/knowledge/docker.txt +10 -0
  56. package/src/knowledge/dotnet.txt +9 -0
  57. package/src/knowledge/electron.txt +10 -0
  58. package/src/knowledge/flutter.txt +10 -0
  59. package/src/knowledge/go.txt +9 -0
  60. package/src/knowledge/graphql.txt +10 -0
  61. package/src/knowledge/java.txt +9 -0
  62. package/src/knowledge/kotlin.txt +10 -0
  63. package/src/knowledge/loader.mjs +125 -0
  64. package/src/knowledge/next.txt +8 -0
  65. package/src/knowledge/node.txt +8 -0
  66. package/src/knowledge/nuxt.txt +9 -0
  67. package/src/knowledge/php.txt +10 -0
  68. package/src/knowledge/python.txt +10 -0
  69. package/src/knowledge/react-native.txt +10 -0
  70. package/src/knowledge/react.txt +9 -0
  71. package/src/knowledge/ruby.txt +11 -0
  72. package/src/knowledge/rust.txt +9 -0
  73. package/src/knowledge/svelte.txt +9 -0
  74. package/src/knowledge/swift.txt +10 -0
  75. package/src/knowledge/tailwind.txt +10 -0
  76. package/src/knowledge/testing.txt +8 -0
  77. package/src/knowledge/typescript.txt +8 -0
  78. package/src/knowledge/vue.txt +9 -0
  79. package/src/mcp/client-http.mjs +157 -0
  80. package/src/mcp/client-sse.mjs +286 -0
  81. package/src/mcp/client-stdio.mjs +451 -0
  82. package/src/mcp/registry.mjs +394 -0
  83. package/src/mcp/stdio-framing.mjs +127 -0
  84. package/src/orchestration/background-manager.mjs +358 -0
  85. package/src/orchestration/background-worker.mjs +245 -0
  86. package/src/orchestration/longagent-manager.mjs +116 -0
  87. package/src/orchestration/stage-scheduler.mjs +489 -0
  88. package/src/orchestration/subagent-router.mjs +62 -0
  89. package/src/orchestration/task-scheduler.mjs +74 -0
  90. package/src/permission/engine.mjs +92 -0
  91. package/src/permission/exec-policy.mjs +372 -0
  92. package/src/permission/prompt.mjs +39 -0
  93. package/src/permission/rules.mjs +120 -0
  94. package/src/permission/workspace-trust.mjs +44 -0
  95. package/src/plugin/builtin-hooks/console-warn.mjs +41 -0
  96. package/src/plugin/builtin-hooks/extract-patterns.mjs +75 -0
  97. package/src/plugin/builtin-hooks/post-edit-format.mjs +57 -0
  98. package/src/plugin/builtin-hooks/post-edit-typecheck.mjs +61 -0
  99. package/src/plugin/builtin-hooks/strategic-compaction.mjs +38 -0
  100. package/src/plugin/hook-bus.mjs +154 -0
  101. package/src/provider/anthropic.mjs +389 -0
  102. package/src/provider/ollama.mjs +236 -0
  103. package/src/provider/openai-compatible.mjs +1 -0
  104. package/src/provider/openai.mjs +339 -0
  105. package/src/provider/retry-policy.mjs +68 -0
  106. package/src/provider/router.mjs +228 -0
  107. package/src/provider/sse.mjs +91 -0
  108. package/src/repl.mjs +2929 -0
  109. package/src/review/diff-parser.mjs +36 -0
  110. package/src/review/rejection-queue.mjs +62 -0
  111. package/src/review/review-store.mjs +21 -0
  112. package/src/review/risk-score.mjs +61 -0
  113. package/src/rules/load-rules.mjs +64 -0
  114. package/src/runtime.mjs +1 -0
  115. package/src/session/checkpoint.mjs +239 -0
  116. package/src/session/compaction.mjs +276 -0
  117. package/src/session/engine.mjs +225 -0
  118. package/src/session/instinct-manager.mjs +172 -0
  119. package/src/session/instruction-loader.mjs +25 -0
  120. package/src/session/longagent-plan.mjs +329 -0
  121. package/src/session/longagent-scaffold.mjs +100 -0
  122. package/src/session/longagent.mjs +1462 -0
  123. package/src/session/loop.mjs +905 -0
  124. package/src/session/memory-loader.mjs +75 -0
  125. package/src/session/project-context.mjs +367 -0
  126. package/src/session/prompt/anthropic.txt +151 -0
  127. package/src/session/prompt/beast.txt +37 -0
  128. package/src/session/prompt/max-steps.txt +6 -0
  129. package/src/session/prompt/plan.txt +9 -0
  130. package/src/session/prompt/qwen.txt +46 -0
  131. package/src/session/prompt-loader.mjs +18 -0
  132. package/src/session/recovery.mjs +52 -0
  133. package/src/session/store.mjs +503 -0
  134. package/src/session/system-prompt.mjs +260 -0
  135. package/src/session/task-validator.mjs +266 -0
  136. package/src/session/usability-gates.mjs +379 -0
  137. package/src/skill/builtin/backend-patterns.mjs +123 -0
  138. package/src/skill/builtin/commit.mjs +64 -0
  139. package/src/skill/builtin/debug.mjs +45 -0
  140. package/src/skill/builtin/frontend-patterns.mjs +120 -0
  141. package/src/skill/builtin/frontend.mjs +188 -0
  142. package/src/skill/builtin/init.mjs +220 -0
  143. package/src/skill/builtin/review.mjs +49 -0
  144. package/src/skill/builtin/security-checklist.mjs +80 -0
  145. package/src/skill/builtin/tdd.mjs +54 -0
  146. package/src/skill/generator.mjs +113 -0
  147. package/src/skill/registry.mjs +336 -0
  148. package/src/storage/audit-store.mjs +83 -0
  149. package/src/storage/event-log.mjs +82 -0
  150. package/src/storage/ghost-commit-store.mjs +235 -0
  151. package/src/storage/json-store.mjs +53 -0
  152. package/src/storage/paths.mjs +148 -0
  153. package/src/theme/color.mjs +64 -0
  154. package/src/theme/default-theme.mjs +29 -0
  155. package/src/theme/load-theme.mjs +71 -0
  156. package/src/theme/markdown.mjs +135 -0
  157. package/src/theme/schema.mjs +45 -0
  158. package/src/theme/status-bar.mjs +158 -0
  159. package/src/tool/audit-wrapper.mjs +38 -0
  160. package/src/tool/edit-transaction.mjs +126 -0
  161. package/src/tool/executor.mjs +109 -0
  162. package/src/tool/file-lock-manager.mjs +85 -0
  163. package/src/tool/git-auto.mjs +545 -0
  164. package/src/tool/git-full-auto.mjs +478 -0
  165. package/src/tool/image-util.mjs +276 -0
  166. package/src/tool/prompt/background_cancel.txt +1 -0
  167. package/src/tool/prompt/background_output.txt +1 -0
  168. package/src/tool/prompt/bash.txt +71 -0
  169. package/src/tool/prompt/codesearch.txt +18 -0
  170. package/src/tool/prompt/edit.txt +27 -0
  171. package/src/tool/prompt/enter_plan.txt +74 -0
  172. package/src/tool/prompt/exit_plan.txt +62 -0
  173. package/src/tool/prompt/glob.txt +33 -0
  174. package/src/tool/prompt/grep.txt +43 -0
  175. package/src/tool/prompt/list.txt +8 -0
  176. package/src/tool/prompt/multiedit.txt +20 -0
  177. package/src/tool/prompt/notebookedit.txt +21 -0
  178. package/src/tool/prompt/patch.txt +24 -0
  179. package/src/tool/prompt/question.txt +44 -0
  180. package/src/tool/prompt/read.txt +40 -0
  181. package/src/tool/prompt/task.txt +83 -0
  182. package/src/tool/prompt/todowrite.txt +117 -0
  183. package/src/tool/prompt/webfetch.txt +38 -0
  184. package/src/tool/prompt/websearch.txt +43 -0
  185. package/src/tool/prompt/write.txt +38 -0
  186. package/src/tool/prompt-loader.mjs +18 -0
  187. package/src/tool/question-prompt.mjs +86 -0
  188. package/src/tool/registry.mjs +1309 -0
  189. package/src/tool/task-tool.mjs +28 -0
  190. package/src/ui/activity-renderer.mjs +410 -0
  191. package/src/ui/repl-dashboard.mjs +357 -0
  192. package/src/usage/pricing.mjs +121 -0
  193. package/src/usage/usage-meter.mjs +113 -0
  194. package/src/util/git.mjs +496 -0
  195. package/src/util/template.mjs +10 -0
  196. package/src/util/yaml.mjs +100 -0
@@ -0,0 +1,358 @@
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
+ return Math.max(1000, Number(payload.workerTimeoutMs || config.background?.worker_timeout_ms || 900000))
23
+ }
24
+
25
+ function resolveMaxParallel(config = {}) {
26
+ return Math.max(1, Number(config.background?.max_parallel || 2))
27
+ }
28
+
29
+ function isProcessAlive(pid) {
30
+ if (!Number.isInteger(pid) || pid <= 0) return false
31
+ try {
32
+ process.kill(pid, 0)
33
+ return true
34
+ } catch {
35
+ return false
36
+ }
37
+ }
38
+
39
+ async function loadTask(id) {
40
+ return readJson(backgroundTaskCheckpointPath(id), null)
41
+ }
42
+
43
+ async function saveTask(task) {
44
+ await ensureBackgroundTaskRuntimeDir()
45
+ await writeJson(backgroundTaskCheckpointPath(task.id), task)
46
+ return task
47
+ }
48
+
49
+ async function patchTask(id, updater, { maxRetries = 3 } = {}) {
50
+ for (let attempt = 0; attempt <= maxRetries; attempt++) {
51
+ const current = await loadTask(id)
52
+ if (!current) return null
53
+ const next = {
54
+ ...current,
55
+ ...updater(current),
56
+ _version: (current._version || 0) + 1,
57
+ updatedAt: now()
58
+ }
59
+ // Optimistic lock: re-read and verify version before write
60
+ const check = await loadTask(id)
61
+ if (check && (check._version || 0) !== (current._version || 0)) {
62
+ if (attempt < maxRetries) continue // version changed, retry
63
+ // Last attempt: write anyway to avoid deadlock
64
+ }
65
+ await saveTask(next)
66
+ return next
67
+ }
68
+ return null
69
+ }
70
+
71
+ async function listTaskIds() {
72
+ await ensureBackgroundTaskRuntimeDir()
73
+ const entries = await readdir(backgroundTaskRuntimeDir(), { withFileTypes: true }).catch(() => [])
74
+ return entries
75
+ .filter((entry) => entry.isFile() && entry.name.endsWith(".json"))
76
+ .map((entry) => path.basename(entry.name, ".json"))
77
+ }
78
+
79
+ async function readAllTasks() {
80
+ const ids = await listTaskIds()
81
+ const out = []
82
+ for (const id of ids) {
83
+ const task = await loadTask(id)
84
+ if (task) out.push(task)
85
+ }
86
+ return out.sort((a, b) => b.updatedAt - a.updatedAt)
87
+ }
88
+
89
+ function spawnWorker(taskId) {
90
+ const logFile = backgroundTaskLogPath(taskId)
91
+ let stderrFd = null
92
+ try {
93
+ stderrFd = openSync(logFile, "a")
94
+ } catch {
95
+ // directory may not exist yet or permission issue — fall back to ignore
96
+ }
97
+ const child = spawn(process.execPath, [WORKER_ENTRY, "--task-id", taskId], {
98
+ detached: true,
99
+ windowsHide: true,
100
+ stdio: ["ignore", "ignore", stderrFd !== null ? stderrFd : "ignore"],
101
+ env: {
102
+ ...process.env,
103
+ KKCODE_BACKGROUND_TASK_ID: taskId
104
+ }
105
+ })
106
+ child.on("exit", (code) => {
107
+ if (stderrFd !== null) {
108
+ try { closeSync(stderrFd) } catch { /* already closed */ }
109
+ }
110
+ if (code && code !== 0) {
111
+ patchTask(taskId, (current) => {
112
+ if (current.status === "running") {
113
+ return {
114
+ status: "error",
115
+ error: `worker process exited with code ${code}`,
116
+ endedAt: now()
117
+ }
118
+ }
119
+ return {}
120
+ }).catch(() => {})
121
+ }
122
+ })
123
+ child.unref()
124
+ return child.pid
125
+ }
126
+
127
+ async function markStaleRunningTasks(config = {}) {
128
+ const tasks = await readAllTasks()
129
+ const timeoutDefault = Math.max(1000, Number(config.background?.worker_timeout_ms || 900000))
130
+ let interrupted = 0
131
+
132
+ for (const task of tasks) {
133
+ if (task.status !== "running") continue
134
+ const heartbeatAt = Number(task.lastHeartbeatAt || 0)
135
+ const timeoutMs = resolveWorkerTimeoutMs(config, task.payload || {})
136
+ const staleByHeartbeat = heartbeatAt > 0 && now() - heartbeatAt > timeoutMs + 5000
137
+ const deadPid = task.workerPid ? !isProcessAlive(task.workerPid) : false
138
+ const staleNoHeartbeat = heartbeatAt === 0 && now() - Number(task.startedAt || task.createdAt || now()) > timeoutDefault + 5000
139
+
140
+ if (staleByHeartbeat || deadPid || staleNoHeartbeat) {
141
+ await patchTask(task.id, () => ({
142
+ status: "interrupted",
143
+ endedAt: now(),
144
+ error: deadPid
145
+ ? "background worker exited unexpectedly"
146
+ : staleByHeartbeat
147
+ ? "background worker heartbeat timeout"
148
+ : "background worker no heartbeat",
149
+ workerPid: null
150
+ }))
151
+ interrupted += 1
152
+ }
153
+ }
154
+
155
+ return interrupted
156
+ }
157
+
158
+ async function startPendingTasks(config = {}) {
159
+ const maxParallel = resolveMaxParallel(config)
160
+ const tasks = await readAllTasks()
161
+ const running = tasks.filter((task) => task.status === "running").length
162
+ let remainingSlots = Math.max(0, maxParallel - running)
163
+ if (remainingSlots <= 0) return 0
164
+
165
+ let started = 0
166
+ const pending = tasks
167
+ .filter((task) => task.status === "pending" && task.backgroundMode === "worker_process")
168
+ .sort((a, b) => a.createdAt - b.createdAt)
169
+
170
+ for (const task of pending) {
171
+ if (remainingSlots <= 0) break
172
+ let pid
173
+ try {
174
+ pid = spawnWorker(task.id)
175
+ } catch (err) {
176
+ await patchTask(task.id, () => ({
177
+ status: "error",
178
+ error: `spawn failed: ${err.message}`,
179
+ endedAt: now()
180
+ }))
181
+ continue
182
+ }
183
+ const timeoutMs = resolveWorkerTimeoutMs(config, task.payload || {})
184
+ await patchTask(task.id, (current) => ({
185
+ status: "running",
186
+ workerPid: pid,
187
+ lastHeartbeatAt: now(),
188
+ startedAt: current.startedAt || now(),
189
+ payload: {
190
+ ...(current.payload || {}),
191
+ workerTimeoutMs: timeoutMs
192
+ }
193
+ }))
194
+ remainingSlots -= 1
195
+ started += 1
196
+ }
197
+
198
+ return started
199
+ }
200
+
201
+ async function runInline(task, run) {
202
+ await patchTask(task.id, () => ({ status: "running", startedAt: now() }))
203
+ try {
204
+ const result = await run({
205
+ taskId: task.id,
206
+ isCancelled: async () => {
207
+ const latest = await loadTask(task.id)
208
+ return Boolean(latest?.cancelled)
209
+ },
210
+ log: async (line) => {
211
+ await patchTask(task.id, (current) => ({
212
+ logs: [...(current.logs || []), String(line)].slice(-300),
213
+ lastHeartbeatAt: now()
214
+ }))
215
+ }
216
+ })
217
+ const latest = await loadTask(task.id)
218
+ if (latest?.cancelled) {
219
+ await patchTask(task.id, () => ({ status: "cancelled", endedAt: now() }))
220
+ return
221
+ }
222
+ await patchTask(task.id, () => ({ status: "completed", result, endedAt: now() }))
223
+ } catch (error) {
224
+ const latest = await loadTask(task.id)
225
+ await patchTask(task.id, () => ({
226
+ status: latest?.cancelled ? "cancelled" : "error",
227
+ error: error.message,
228
+ endedAt: now()
229
+ }))
230
+ }
231
+ }
232
+
233
+ export const BackgroundManager = {
234
+ async launch({ description, payload, run = null, config = {} }) {
235
+ await ensureBackgroundTaskRuntimeDir()
236
+ const id = `bg_${Math.random().toString(36).slice(2, 14)}`
237
+ const timeoutMs = resolveWorkerTimeoutMs(config, payload || {})
238
+ const task = {
239
+ id,
240
+ description,
241
+ payload: {
242
+ ...(payload || {}),
243
+ workerTimeoutMs: timeoutMs
244
+ },
245
+ status: "pending",
246
+ createdAt: now(),
247
+ updatedAt: now(),
248
+ startedAt: null,
249
+ endedAt: null,
250
+ logs: [],
251
+ result: null,
252
+ error: null,
253
+ cancelled: false,
254
+ backgroundMode: run ? "inline" : (config.background?.mode || "worker_process"),
255
+ workerPid: null,
256
+ lastHeartbeatAt: null,
257
+ attempt: Number(payload?.attempt || 1),
258
+ resumeToken: payload?.resumeToken || `resume_${Date.now()}`
259
+ }
260
+ await saveTask(task)
261
+
262
+ if (run) {
263
+ queueMicrotask(() => {
264
+ runInline(task, run).catch((err) => {
265
+ patchTask(task.id, () => ({
266
+ status: "error",
267
+ error: `inline task failed: ${err?.message || String(err)}`,
268
+ endedAt: now()
269
+ })).catch(() => {})
270
+ })
271
+ })
272
+ return task
273
+ }
274
+
275
+ await this.tick(config)
276
+ return (await loadTask(id)) || task
277
+ },
278
+
279
+ async launchDelegateTask({ description, payload, config = {} }) {
280
+ return this.launch({
281
+ description,
282
+ payload: {
283
+ ...payload,
284
+ workerType: "delegate_task",
285
+ attempt: Number(payload.attempt || 1),
286
+ resumeToken: payload.resumeToken || `resume_${Date.now()}`
287
+ },
288
+ run: null,
289
+ config
290
+ })
291
+ },
292
+
293
+ async get(id) {
294
+ await ensureBackgroundTaskRuntimeDir()
295
+ return loadTask(id)
296
+ },
297
+
298
+ async list() {
299
+ await ensureBackgroundTaskRuntimeDir()
300
+ return readAllTasks()
301
+ },
302
+
303
+ async cancel(id) {
304
+ const task = await loadTask(id)
305
+ if (!task) return false
306
+ await patchTask(id, (current) => ({
307
+ cancelled: true,
308
+ status: current.status === "pending" ? "cancelled" : current.status
309
+ }))
310
+ return true
311
+ },
312
+
313
+ async retry(id, config = {}) {
314
+ const task = await loadTask(id)
315
+ if (!task) return null
316
+ if (!["error", "interrupted"].includes(task.status)) return null
317
+
318
+ const nextAttempt = Number(task.attempt || 1) + 1
319
+ const nextResumeToken = `resume_${Date.now()}`
320
+ await patchTask(id, () => ({
321
+ status: "pending",
322
+ error: null,
323
+ cancelled: false,
324
+ endedAt: null,
325
+ workerPid: null,
326
+ lastHeartbeatAt: null,
327
+ attempt: nextAttempt,
328
+ resumeToken: nextResumeToken,
329
+ payload: {
330
+ ...(task.payload || {}),
331
+ attempt: nextAttempt,
332
+ resumeToken: nextResumeToken
333
+ }
334
+ }))
335
+
336
+ await this.tick(config)
337
+ return loadTask(id)
338
+ },
339
+
340
+ async clean({ maxAge = 7 * 24 * 60 * 60 * 1000 } = {}) {
341
+ const tasks = await readAllTasks()
342
+ const cutoff = now() - maxAge
343
+ const removed = []
344
+ for (const task of tasks) {
345
+ if (!TERMINAL_STATES.has(task.status)) continue
346
+ if (task.updatedAt > cutoff) continue
347
+ await unlink(backgroundTaskCheckpointPath(task.id)).catch(() => {})
348
+ await unlink(backgroundTaskLogPath(task.id)).catch(() => {})
349
+ removed.push(task.id)
350
+ }
351
+ return removed
352
+ },
353
+
354
+ async tick(config = {}) {
355
+ await markStaleRunningTasks(config)
356
+ await startPendingTasks(config)
357
+ }
358
+ }
@@ -0,0 +1,245 @@
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
+ async function appendTaskLog(taskId, line) {
45
+ await appendFile(backgroundTaskLogPath(taskId), `${line}\n`, "utf8")
46
+ await patchTask(taskId, (current) => ({
47
+ logs: [...(current.logs || []), String(line)].slice(-300),
48
+ lastHeartbeatAt: now()
49
+ }))
50
+ }
51
+
52
+ async function runDelegateTask(task, signal) {
53
+ const payload = task.payload || {}
54
+ const cwd = payload.cwd || process.cwd()
55
+ process.chdir(cwd)
56
+
57
+ const ctx = await buildContext({ cwd })
58
+ await ToolRegistry.initialize({
59
+ config: ctx.configState.config,
60
+ cwd
61
+ })
62
+ const { CustomAgentRegistry } = await import("../agent/custom-agent-loader.mjs")
63
+ await CustomAgentRegistry.initialize(cwd)
64
+
65
+ const providerType = payload.providerType || ctx.configState.config.provider.default
66
+ const providerDefault = ctx.configState.config.provider[providerType]
67
+ const model = payload.model || providerDefault?.default_model
68
+
69
+ const out = await executeTurn({
70
+ prompt: String(payload.prompt || ""),
71
+ mode: "agent",
72
+ model,
73
+ providerType,
74
+ sessionId: payload.subSessionId,
75
+ configState: ctx.configState,
76
+ signal,
77
+ allowQuestion: payload.allowQuestion !== true ? false : true,
78
+ toolContext: {
79
+ taskId: task.id,
80
+ stageId: payload.stageId || null,
81
+ logicalTaskId: payload.logicalTaskId || null
82
+ }
83
+ })
84
+
85
+ const plannedFiles = Array.isArray(payload.plannedFiles)
86
+ ? payload.plannedFiles.map((item) => String(item || "").trim()).filter(Boolean)
87
+ : []
88
+ const completedFilesFromTools = out.toolEvents
89
+ .filter((event) => ["write", "edit"].includes(event.name) && event.status === "completed")
90
+ .map((event) => {
91
+ const p = event.args?.path
92
+ return p ? String(p).trim() : ""
93
+ })
94
+ .filter(Boolean)
95
+
96
+ const fileChanges = out.toolEvents
97
+ .flatMap((event) => Array.isArray(event?.metadata?.fileChanges) ? event.metadata.fileChanges : [])
98
+ .map((item) => ({
99
+ path: String(item?.path || "").trim(),
100
+ addedLines: Math.max(0, Number(item?.addedLines || 0)),
101
+ removedLines: Math.max(0, Number(item?.removedLines || 0)),
102
+ stageId: item?.stageId ? String(item.stageId) : (payload.stageId || ""),
103
+ taskId: item?.taskId ? String(item.taskId) : (payload.logicalTaskId || "")
104
+ }))
105
+ .filter((item) => item.path)
106
+
107
+ const completedFileSet = new Set(
108
+ completedFilesFromTools.filter((file) => plannedFiles.length === 0 || plannedFiles.includes(file))
109
+ )
110
+ const completedFiles = [...completedFileSet]
111
+ const remainingFiles = plannedFiles.filter((file) => !completedFileSet.has(file))
112
+
113
+ return {
114
+ session_id: payload.subSessionId,
115
+ parent_session_id: payload.parentSessionId || null,
116
+ subagent: payload.subagent || null,
117
+ reply: out.reply,
118
+ tool_events: out.toolEvents?.length || 0,
119
+ completed_files: completedFiles,
120
+ remaining_files: remainingFiles,
121
+ file_changes: fileChanges,
122
+ cost: out.cost,
123
+ budget_warnings: out.budgetWarnings || []
124
+ }
125
+ }
126
+
127
+ async function main() {
128
+ const taskId = argValue("--task-id") || process.env.KKCODE_BACKGROUND_TASK_ID || null
129
+ if (!taskId) {
130
+ process.exit(1)
131
+ return
132
+ }
133
+
134
+ await ensureBackgroundTaskRuntimeDir()
135
+ const task = await readTask(taskId)
136
+ if (!task) {
137
+ process.exit(1)
138
+ return
139
+ }
140
+
141
+ if (task.cancelled) {
142
+ await patchTask(taskId, () => ({
143
+ status: "cancelled",
144
+ endedAt: now()
145
+ }))
146
+ process.exit(0)
147
+ return
148
+ }
149
+
150
+ await patchTask(taskId, () => ({
151
+ status: "running",
152
+ workerPid: process.pid,
153
+ startedAt: now(),
154
+ lastHeartbeatAt: now()
155
+ }))
156
+
157
+ const abortController = new AbortController()
158
+ const parentPid = process.ppid
159
+ const heartbeatTimer = setInterval(() => {
160
+ patchTask(taskId, () => ({ lastHeartbeatAt: now() })).catch(() => {})
161
+ }, 2000)
162
+
163
+ const cancelPoll = setInterval(() => {
164
+ // Orphan detection: if parent process died, self-terminate
165
+ try { process.kill(parentPid, 0) } catch {
166
+ if (!abortController.signal.aborted) {
167
+ abortController.abort(makeAbortError("parent process exited, worker orphaned"))
168
+ }
169
+ return
170
+ }
171
+ readTask(taskId).then((latest) => {
172
+ if (latest?.cancelled && !abortController.signal.aborted) {
173
+ abortController.abort(makeAbortError("cancelled by user"))
174
+ }
175
+ }).catch(() => {})
176
+ }, 1500)
177
+
178
+ const timeoutMs = Math.max(1000, Number(task.payload?.workerTimeoutMs || 900000))
179
+ const timeoutTimer = setTimeout(() => {
180
+ if (!abortController.signal.aborted) {
181
+ abortController.abort(makeAbortError(`worker timeout after ${timeoutMs}ms`))
182
+ }
183
+ }, timeoutMs)
184
+
185
+ try {
186
+ await appendTaskLog(taskId, `task started (worker pid=${process.pid})`)
187
+
188
+ const latest = await readTask(taskId)
189
+ if (!latest?.payload?.workerType || latest.payload.workerType !== "delegate_task") {
190
+ throw new Error(`unsupported workerType: ${latest?.payload?.workerType || "unknown"}`)
191
+ }
192
+
193
+ const result = await runDelegateTask(latest, abortController.signal)
194
+ await appendTaskLog(taskId, "task completed")
195
+ await patchTask(taskId, () => ({
196
+ status: "completed",
197
+ result,
198
+ error: null,
199
+ endedAt: now(),
200
+ lastHeartbeatAt: now()
201
+ }))
202
+ process.exit(0)
203
+ } catch (error) {
204
+ const latest = await readTask(taskId)
205
+ const cancelled = latest?.cancelled
206
+ const aborted = isAbortError(error)
207
+ if (cancelled) {
208
+ await appendTaskLog(taskId, "task cancelled")
209
+ await patchTask(taskId, () => ({
210
+ status: "cancelled",
211
+ endedAt: now(),
212
+ error: null
213
+ }))
214
+ process.exit(0)
215
+ return
216
+ }
217
+
218
+ if (aborted) {
219
+ await appendTaskLog(taskId, `task interrupted: ${error.message}`)
220
+ await patchTask(taskId, () => ({
221
+ status: "interrupted",
222
+ error: error.message,
223
+ endedAt: now()
224
+ }))
225
+ process.exit(2)
226
+ return
227
+ }
228
+
229
+ await appendTaskLog(taskId, `task error: ${error.message}`)
230
+ await patchTask(taskId, () => ({
231
+ status: "error",
232
+ error: error.message,
233
+ endedAt: now()
234
+ }))
235
+ process.exit(1)
236
+ } finally {
237
+ clearInterval(heartbeatTimer)
238
+ clearInterval(cancelPoll)
239
+ clearTimeout(timeoutTimer)
240
+ }
241
+ }
242
+
243
+ main().catch(() => {
244
+ process.exit(1)
245
+ })
@@ -0,0 +1,116 @@
1
+ import path from "node:path"
2
+ import { mkdir, writeFile, unlink, stat } from "node:fs/promises"
3
+ import { readJson, writeJson } from "../storage/json-store.mjs"
4
+ import { projectRootDir } from "../storage/paths.mjs"
5
+
6
+ function statePath(cwd = process.cwd()) {
7
+ return path.join(projectRootDir(cwd), "longagent-state.json")
8
+ }
9
+
10
+ function lockPath(cwd = process.cwd()) {
11
+ return statePath(cwd) + ".lock"
12
+ }
13
+
14
+ async function ensure(cwd = process.cwd()) {
15
+ await mkdir(projectRootDir(cwd), { recursive: true })
16
+ }
17
+
18
+ const LOCK_TIMEOUT_MS = 5000
19
+ const LOCK_RETRY_MS = 50
20
+
21
+ async function acquireLock(cwd) {
22
+ const file = lockPath(cwd)
23
+ const deadline = Date.now() + LOCK_TIMEOUT_MS
24
+ while (Date.now() < deadline) {
25
+ try {
26
+ await writeFile(file, String(process.pid), { flag: "wx" })
27
+ return true
28
+ } catch (err) {
29
+ if (err.code !== "EEXIST") throw err
30
+ // Stale lock detection: if lock file is older than timeout, remove it
31
+ try {
32
+ const info = await stat(file)
33
+ if (Date.now() - info.mtimeMs > LOCK_TIMEOUT_MS) {
34
+ await unlink(file).catch(() => {})
35
+ continue
36
+ }
37
+ } catch { /* lock disappeared, retry */ continue }
38
+ await new Promise((r) => setTimeout(r, LOCK_RETRY_MS))
39
+ }
40
+ }
41
+ // Timeout: force-remove stale lock and proceed
42
+ await unlink(file).catch(() => {})
43
+ return false
44
+ }
45
+
46
+ async function releaseLock(cwd) {
47
+ await unlink(lockPath(cwd)).catch(() => {})
48
+ }
49
+
50
+ async function read(cwd = process.cwd()) {
51
+ await ensure(cwd)
52
+ return readJson(statePath(cwd), { sessions: {} })
53
+ }
54
+
55
+ async function write(data, cwd = process.cwd()) {
56
+ await ensure(cwd)
57
+ await writeJson(statePath(cwd), data)
58
+ }
59
+
60
+ export const LongAgentManager = {
61
+ async update(sessionId, patch, cwd = process.cwd()) {
62
+ await acquireLock(cwd)
63
+ try {
64
+ const state = await read(cwd)
65
+ const current = state.sessions[sessionId] || {
66
+ sessionId,
67
+ status: "idle",
68
+ phase: "L0",
69
+ gateStatus: {},
70
+ currentGate: "execution",
71
+ recoveryCount: 0,
72
+ planFrozen: false,
73
+ currentStageId: null,
74
+ stageIndex: 0,
75
+ stageCount: 0,
76
+ stageStatus: null,
77
+ taskProgress: {},
78
+ remainingFiles: [],
79
+ remainingFilesCount: 0,
80
+ lastGateFailures: [],
81
+ createdAt: Date.now(),
82
+ updatedAt: Date.now(),
83
+ heartbeatAt: null,
84
+ iterations: 0,
85
+ lastMessage: ""
86
+ }
87
+ state.sessions[sessionId] = {
88
+ ...current,
89
+ ...patch,
90
+ updatedAt: Date.now()
91
+ }
92
+ await write(state, cwd)
93
+ return state.sessions[sessionId]
94
+ } finally {
95
+ await releaseLock(cwd)
96
+ }
97
+ },
98
+ async get(sessionId, cwd = process.cwd()) {
99
+ const state = await read(cwd)
100
+ return state.sessions[sessionId] || null
101
+ },
102
+ async list(cwd = process.cwd()) {
103
+ const state = await read(cwd)
104
+ return Object.values(state.sessions).sort((a, b) => b.updatedAt - a.updatedAt)
105
+ },
106
+ async stop(sessionId, cwd = process.cwd()) {
107
+ const existing = await this.get(sessionId, cwd)
108
+ if (!existing) return null
109
+ return this.update(sessionId, { stopRequested: true }, cwd)
110
+ },
111
+ async clearStop(sessionId, cwd = process.cwd()) {
112
+ const existing = await this.get(sessionId, cwd)
113
+ if (!existing) return null
114
+ return this.update(sessionId, { stopRequested: false }, cwd)
115
+ }
116
+ }