@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.
- package/LICENSE +674 -0
- package/README.md +445 -0
- package/package.json +46 -0
- package/src/agent/agent.mjs +170 -0
- package/src/agent/custom-agent-loader.mjs +158 -0
- package/src/agent/generator.mjs +115 -0
- package/src/agent/prompt/architect.txt +36 -0
- package/src/agent/prompt/build-fixer.txt +71 -0
- package/src/agent/prompt/build.txt +101 -0
- package/src/agent/prompt/compaction.txt +12 -0
- package/src/agent/prompt/explore.txt +29 -0
- package/src/agent/prompt/guide.txt +40 -0
- package/src/agent/prompt/longagent.txt +178 -0
- package/src/agent/prompt/plan.txt +50 -0
- package/src/agent/prompt/researcher.txt +23 -0
- package/src/agent/prompt/reviewer.txt +44 -0
- package/src/agent/prompt/security-reviewer.txt +62 -0
- package/src/agent/prompt/tdd-guide.txt +84 -0
- package/src/agent/prompt/title.txt +8 -0
- package/src/command/custom-commands.mjs +57 -0
- package/src/commands/agent.mjs +71 -0
- package/src/commands/audit.mjs +77 -0
- package/src/commands/background.mjs +86 -0
- package/src/commands/chat.mjs +114 -0
- package/src/commands/command.mjs +41 -0
- package/src/commands/config.mjs +44 -0
- package/src/commands/doctor.mjs +148 -0
- package/src/commands/hook.mjs +29 -0
- package/src/commands/init.mjs +141 -0
- package/src/commands/longagent.mjs +100 -0
- package/src/commands/mcp.mjs +89 -0
- package/src/commands/permission.mjs +36 -0
- package/src/commands/prompt.mjs +42 -0
- package/src/commands/review.mjs +266 -0
- package/src/commands/rule.mjs +34 -0
- package/src/commands/session.mjs +235 -0
- package/src/commands/theme.mjs +98 -0
- package/src/commands/usage.mjs +91 -0
- package/src/config/defaults.mjs +195 -0
- package/src/config/import-config.mjs +76 -0
- package/src/config/load-config.mjs +76 -0
- package/src/config/schema.mjs +509 -0
- package/src/context.mjs +40 -0
- package/src/core/constants.mjs +46 -0
- package/src/core/errors.mjs +57 -0
- package/src/core/events.mjs +29 -0
- package/src/core/types.mjs +57 -0
- package/src/github/api.mjs +78 -0
- package/src/github/auth.mjs +286 -0
- package/src/github/flow.mjs +298 -0
- package/src/github/workspace.mjs +212 -0
- package/src/index.mjs +82 -0
- package/src/knowledge/api-design.txt +9 -0
- package/src/knowledge/cpp.txt +10 -0
- package/src/knowledge/docker.txt +10 -0
- package/src/knowledge/dotnet.txt +9 -0
- package/src/knowledge/electron.txt +10 -0
- package/src/knowledge/flutter.txt +10 -0
- package/src/knowledge/go.txt +9 -0
- package/src/knowledge/graphql.txt +10 -0
- package/src/knowledge/java.txt +9 -0
- package/src/knowledge/kotlin.txt +10 -0
- package/src/knowledge/loader.mjs +125 -0
- package/src/knowledge/next.txt +8 -0
- package/src/knowledge/node.txt +8 -0
- package/src/knowledge/nuxt.txt +9 -0
- package/src/knowledge/php.txt +10 -0
- package/src/knowledge/python.txt +10 -0
- package/src/knowledge/react-native.txt +10 -0
- package/src/knowledge/react.txt +9 -0
- package/src/knowledge/ruby.txt +11 -0
- package/src/knowledge/rust.txt +9 -0
- package/src/knowledge/svelte.txt +9 -0
- package/src/knowledge/swift.txt +10 -0
- package/src/knowledge/tailwind.txt +10 -0
- package/src/knowledge/testing.txt +8 -0
- package/src/knowledge/typescript.txt +8 -0
- package/src/knowledge/vue.txt +9 -0
- package/src/mcp/client-http.mjs +157 -0
- package/src/mcp/client-sse.mjs +286 -0
- package/src/mcp/client-stdio.mjs +451 -0
- package/src/mcp/registry.mjs +394 -0
- package/src/mcp/stdio-framing.mjs +127 -0
- package/src/orchestration/background-manager.mjs +358 -0
- package/src/orchestration/background-worker.mjs +245 -0
- package/src/orchestration/longagent-manager.mjs +116 -0
- package/src/orchestration/stage-scheduler.mjs +489 -0
- package/src/orchestration/subagent-router.mjs +62 -0
- package/src/orchestration/task-scheduler.mjs +74 -0
- package/src/permission/engine.mjs +92 -0
- package/src/permission/exec-policy.mjs +372 -0
- package/src/permission/prompt.mjs +39 -0
- package/src/permission/rules.mjs +120 -0
- package/src/permission/workspace-trust.mjs +44 -0
- package/src/plugin/builtin-hooks/console-warn.mjs +41 -0
- package/src/plugin/builtin-hooks/extract-patterns.mjs +75 -0
- package/src/plugin/builtin-hooks/post-edit-format.mjs +57 -0
- package/src/plugin/builtin-hooks/post-edit-typecheck.mjs +61 -0
- package/src/plugin/builtin-hooks/strategic-compaction.mjs +38 -0
- package/src/plugin/hook-bus.mjs +154 -0
- package/src/provider/anthropic.mjs +389 -0
- package/src/provider/ollama.mjs +236 -0
- package/src/provider/openai-compatible.mjs +1 -0
- package/src/provider/openai.mjs +339 -0
- package/src/provider/retry-policy.mjs +68 -0
- package/src/provider/router.mjs +228 -0
- package/src/provider/sse.mjs +91 -0
- package/src/repl.mjs +2929 -0
- package/src/review/diff-parser.mjs +36 -0
- package/src/review/rejection-queue.mjs +62 -0
- package/src/review/review-store.mjs +21 -0
- package/src/review/risk-score.mjs +61 -0
- package/src/rules/load-rules.mjs +64 -0
- package/src/runtime.mjs +1 -0
- package/src/session/checkpoint.mjs +239 -0
- package/src/session/compaction.mjs +276 -0
- package/src/session/engine.mjs +225 -0
- package/src/session/instinct-manager.mjs +172 -0
- package/src/session/instruction-loader.mjs +25 -0
- package/src/session/longagent-plan.mjs +329 -0
- package/src/session/longagent-scaffold.mjs +100 -0
- package/src/session/longagent.mjs +1462 -0
- package/src/session/loop.mjs +905 -0
- package/src/session/memory-loader.mjs +75 -0
- package/src/session/project-context.mjs +367 -0
- package/src/session/prompt/anthropic.txt +151 -0
- package/src/session/prompt/beast.txt +37 -0
- package/src/session/prompt/max-steps.txt +6 -0
- package/src/session/prompt/plan.txt +9 -0
- package/src/session/prompt/qwen.txt +46 -0
- package/src/session/prompt-loader.mjs +18 -0
- package/src/session/recovery.mjs +52 -0
- package/src/session/store.mjs +503 -0
- package/src/session/system-prompt.mjs +260 -0
- package/src/session/task-validator.mjs +266 -0
- package/src/session/usability-gates.mjs +379 -0
- package/src/skill/builtin/backend-patterns.mjs +123 -0
- package/src/skill/builtin/commit.mjs +64 -0
- package/src/skill/builtin/debug.mjs +45 -0
- package/src/skill/builtin/frontend-patterns.mjs +120 -0
- package/src/skill/builtin/frontend.mjs +188 -0
- package/src/skill/builtin/init.mjs +220 -0
- package/src/skill/builtin/review.mjs +49 -0
- package/src/skill/builtin/security-checklist.mjs +80 -0
- package/src/skill/builtin/tdd.mjs +54 -0
- package/src/skill/generator.mjs +113 -0
- package/src/skill/registry.mjs +336 -0
- package/src/storage/audit-store.mjs +83 -0
- package/src/storage/event-log.mjs +82 -0
- package/src/storage/ghost-commit-store.mjs +235 -0
- package/src/storage/json-store.mjs +53 -0
- package/src/storage/paths.mjs +148 -0
- package/src/theme/color.mjs +64 -0
- package/src/theme/default-theme.mjs +29 -0
- package/src/theme/load-theme.mjs +71 -0
- package/src/theme/markdown.mjs +135 -0
- package/src/theme/schema.mjs +45 -0
- package/src/theme/status-bar.mjs +158 -0
- package/src/tool/audit-wrapper.mjs +38 -0
- package/src/tool/edit-transaction.mjs +126 -0
- package/src/tool/executor.mjs +109 -0
- package/src/tool/file-lock-manager.mjs +85 -0
- package/src/tool/git-auto.mjs +545 -0
- package/src/tool/git-full-auto.mjs +478 -0
- package/src/tool/image-util.mjs +276 -0
- package/src/tool/prompt/background_cancel.txt +1 -0
- package/src/tool/prompt/background_output.txt +1 -0
- package/src/tool/prompt/bash.txt +71 -0
- package/src/tool/prompt/codesearch.txt +18 -0
- package/src/tool/prompt/edit.txt +27 -0
- package/src/tool/prompt/enter_plan.txt +74 -0
- package/src/tool/prompt/exit_plan.txt +62 -0
- package/src/tool/prompt/glob.txt +33 -0
- package/src/tool/prompt/grep.txt +43 -0
- package/src/tool/prompt/list.txt +8 -0
- package/src/tool/prompt/multiedit.txt +20 -0
- package/src/tool/prompt/notebookedit.txt +21 -0
- package/src/tool/prompt/patch.txt +24 -0
- package/src/tool/prompt/question.txt +44 -0
- package/src/tool/prompt/read.txt +40 -0
- package/src/tool/prompt/task.txt +83 -0
- package/src/tool/prompt/todowrite.txt +117 -0
- package/src/tool/prompt/webfetch.txt +38 -0
- package/src/tool/prompt/websearch.txt +43 -0
- package/src/tool/prompt/write.txt +38 -0
- package/src/tool/prompt-loader.mjs +18 -0
- package/src/tool/question-prompt.mjs +86 -0
- package/src/tool/registry.mjs +1309 -0
- package/src/tool/task-tool.mjs +28 -0
- package/src/ui/activity-renderer.mjs +410 -0
- package/src/ui/repl-dashboard.mjs +357 -0
- package/src/usage/pricing.mjs +121 -0
- package/src/usage/usage-meter.mjs +113 -0
- package/src/util/git.mjs +496 -0
- package/src/util/template.mjs +10 -0
- 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
|
+
}
|