@kkelly-offical/kkcode 0.1.7 → 0.2.3-preview.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 (166) hide show
  1. package/LICENSE +674 -674
  2. package/README.md +474 -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/commands/update.mjs +32 -0
  29. package/src/config/defaults.mjs +289 -260
  30. package/src/config/import-config.mjs +1 -1
  31. package/src/config/load-config.mjs +61 -4
  32. package/src/config/schema.mjs +604 -574
  33. package/src/context.mjs +4 -1
  34. package/src/core/constants.mjs +97 -91
  35. package/src/core/types.mjs +1 -1
  36. package/src/github/api.mjs +78 -78
  37. package/src/github/auth.mjs +294 -286
  38. package/src/github/flow.mjs +298 -298
  39. package/src/github/workspace.mjs +225 -212
  40. package/src/index.mjs +87 -82
  41. package/src/knowledge/frontend-aesthetics.txt +38 -38
  42. package/src/mcp/client-http.mjs +139 -141
  43. package/src/mcp/client-sse.mjs +297 -288
  44. package/src/mcp/client-stdio.mjs +534 -533
  45. package/src/mcp/constants.mjs +4 -2
  46. package/src/mcp/registry.mjs +498 -479
  47. package/src/mcp/stdio-framing.mjs +135 -133
  48. package/src/mcp/tool-result.mjs +24 -24
  49. package/src/observability/edit-diagnostics.mjs +449 -0
  50. package/src/observability/index.mjs +42 -42
  51. package/src/observability/metrics.mjs +165 -137
  52. package/src/observability/tracer.mjs +137 -137
  53. package/src/onboarding.mjs +209 -0
  54. package/src/orchestration/background-manager.mjs +567 -372
  55. package/src/orchestration/background-worker.mjs +419 -305
  56. package/src/orchestration/interruption-reason.mjs +21 -0
  57. package/src/orchestration/longagent-manager.mjs +197 -171
  58. package/src/orchestration/stage-scheduler.mjs +733 -728
  59. package/src/orchestration/subagent-router.mjs +7 -1
  60. package/src/orchestration/task-scheduler.mjs +219 -7
  61. package/src/permission/engine.mjs +1 -1
  62. package/src/permission/exec-policy.mjs +370 -370
  63. package/src/permission/file-edit-policy.mjs +108 -0
  64. package/src/permission/prompt.mjs +1 -1
  65. package/src/permission/rules.mjs +116 -7
  66. package/src/plugin/builtin-hooks/post-edit-format.mjs +2 -1
  67. package/src/plugin/builtin-hooks/post-edit-typecheck.mjs +104 -40
  68. package/src/plugin/hook-bus.mjs +19 -5
  69. package/src/plugin/manifest-loader.mjs +222 -0
  70. package/src/provider/anthropic.mjs +396 -390
  71. package/src/provider/ollama.mjs +7 -1
  72. package/src/provider/openai.mjs +382 -340
  73. package/src/provider/retry-policy.mjs +74 -68
  74. package/src/provider/router.mjs +242 -241
  75. package/src/provider/sse.mjs +104 -104
  76. package/src/provider/wizard.mjs +556 -0
  77. package/src/repl/capability-facade.mjs +30 -0
  78. package/src/repl/command-surface.mjs +23 -0
  79. package/src/repl/controller-entry.mjs +40 -0
  80. package/src/repl/core-shell.mjs +208 -0
  81. package/src/repl/dialog-router.mjs +87 -0
  82. package/src/repl/input-engine.mjs +76 -0
  83. package/src/repl/keymap.mjs +7 -0
  84. package/src/repl/operator-surface.mjs +15 -0
  85. package/src/repl/permission-flow.mjs +49 -0
  86. package/src/repl/runtime-facade.mjs +36 -0
  87. package/src/repl/slash-router.mjs +62 -0
  88. package/src/repl/state-store.mjs +29 -0
  89. package/src/repl/turn-controller.mjs +58 -0
  90. package/src/repl/verification.mjs +23 -0
  91. package/src/repl.mjs +3371 -2981
  92. package/src/rules/load-rules.mjs +3 -3
  93. package/src/runtime.mjs +1 -1
  94. package/src/session/agent-transaction.mjs +86 -0
  95. package/src/session/checkpoint.mjs +302 -302
  96. package/src/session/compaction.mjs +298 -298
  97. package/src/session/engine.mjs +417 -232
  98. package/src/session/longagent-4stage.mjs +467 -460
  99. package/src/session/longagent-hybrid.mjs +1344 -1097
  100. package/src/session/longagent-plan.mjs +376 -365
  101. package/src/session/longagent-project-memory.mjs +53 -53
  102. package/src/session/longagent-scaffold.mjs +291 -291
  103. package/src/session/longagent-task-bus.mjs +138 -54
  104. package/src/session/longagent-utils.mjs +828 -472
  105. package/src/session/longagent.mjs +911 -900
  106. package/src/session/loop.mjs +1005 -930
  107. package/src/session/prompt/agent.txt +25 -25
  108. package/src/session/prompt/anthropic.txt +150 -150
  109. package/src/session/prompt/beast.txt +1 -1
  110. package/src/session/prompt/plan.txt +31 -31
  111. package/src/session/prompt/qwen.txt +46 -46
  112. package/src/session/recovery.mjs +21 -0
  113. package/src/session/rollback.mjs +196 -195
  114. package/src/session/routing-observability.mjs +72 -0
  115. package/src/session/runtime-state.mjs +47 -0
  116. package/src/session/store.mjs +523 -519
  117. package/src/session/system-prompt.mjs +308 -273
  118. package/src/session/task-validator.mjs +267 -267
  119. package/src/session/usability-gates.mjs +2 -2
  120. package/src/skill/builtin/commit.mjs +64 -64
  121. package/src/skill/builtin/design.mjs +76 -76
  122. package/src/skill/generator.mjs +18 -2
  123. package/src/skill/registry.mjs +642 -390
  124. package/src/storage/audit-store.mjs +18 -11
  125. package/src/storage/event-log.mjs +7 -1
  126. package/src/storage/ghost-commit-store.mjs +243 -245
  127. package/src/storage/paths.mjs +17 -0
  128. package/src/theme/default-theme.mjs +1 -1
  129. package/src/theme/markdown.mjs +4 -0
  130. package/src/theme/schema.mjs +1 -1
  131. package/src/theme/status-bar.mjs +162 -158
  132. package/src/tool/audit-wrapper.mjs +18 -2
  133. package/src/tool/edit-transaction.mjs +23 -0
  134. package/src/tool/executor.mjs +26 -1
  135. package/src/tool/file-read-state.mjs +65 -0
  136. package/src/tool/git-auto.mjs +526 -526
  137. package/src/tool/git-full-auto.mjs +487 -478
  138. package/src/tool/mutation-guard.mjs +54 -0
  139. package/src/tool/prompt/edit.txt +3 -3
  140. package/src/tool/prompt/multiedit.txt +1 -0
  141. package/src/tool/prompt/notebookedit.txt +2 -1
  142. package/src/tool/prompt/patch.txt +25 -24
  143. package/src/tool/prompt/read.txt +3 -3
  144. package/src/tool/prompt/sysinfo.txt +29 -0
  145. package/src/tool/prompt/task.txt +66 -4
  146. package/src/tool/prompt/write.txt +2 -2
  147. package/src/tool/question-prompt.mjs +99 -93
  148. package/src/tool/registry.mjs +1701 -1343
  149. package/src/tool/task-tool.mjs +14 -6
  150. package/src/ui/activity-renderer.mjs +667 -664
  151. package/src/ui/repl-background-panel.mjs +7 -0
  152. package/src/ui/repl-capability-panel.mjs +9 -0
  153. package/src/ui/repl-dashboard.mjs +54 -4
  154. package/src/ui/repl-help.mjs +110 -0
  155. package/src/ui/repl-operator-panel.mjs +12 -0
  156. package/src/ui/repl-route-feedback.mjs +35 -0
  157. package/src/ui/repl-status-view.mjs +76 -0
  158. package/src/ui/repl-task-panel.mjs +5 -0
  159. package/src/ui/repl-transcript-panel.mjs +56 -0
  160. package/src/ui/repl-turn-summary.mjs +135 -0
  161. package/src/update/checker.mjs +184 -0
  162. package/src/usage/pricing.mjs +122 -121
  163. package/src/usage/usage-meter.mjs +1 -0
  164. package/src/util/git.mjs +562 -519
  165. package/src/util/template.mjs +6 -1
  166. package/src/version.mjs +3 -0
@@ -0,0 +1,21 @@
1
+ export const INTERRUPTION_REASONS = Object.freeze({
2
+ USER_CANCEL: "user_cancel",
3
+ INTERRUPT: "interrupt",
4
+ TIMEOUT: "timeout",
5
+ PERMISSION_CANCEL: "permission_cancel",
6
+ ORPHANED: "orphaned",
7
+ REMOTE_CANCEL: "remote_cancel"
8
+ })
9
+
10
+ export function normalizeInterruptionReason(input, fallback = INTERRUPTION_REASONS.INTERRUPT) {
11
+ const text = String(input || "").trim().toLowerCase()
12
+ if (!text) return fallback
13
+ if (text.includes("remote cancel")) return INTERRUPTION_REASONS.REMOTE_CANCEL
14
+ if (text.includes("permission") && (text.includes("cancel") || text.includes("denied"))) return INTERRUPTION_REASONS.PERMISSION_CANCEL
15
+ if (text.includes("orphan") || text.includes("parent process exited")) return INTERRUPTION_REASONS.ORPHANED
16
+ if (text.includes("timeout")) return INTERRUPTION_REASONS.TIMEOUT
17
+ if (text.includes("cancelled by user") || text.includes("canceled by user") || text.includes("user cancelled") || text.includes("user canceled")) {
18
+ return INTERRUPTION_REASONS.USER_CANCEL
19
+ }
20
+ return fallback
21
+ }
@@ -1,171 +1,197 @@
1
- import path from "node:path"
2
- import { mkdir, writeFile, readFile, 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_STALE_MS = LOCK_TIMEOUT_MS * 0.8 // 4000ms — detect stale before timeout
20
- const LOCK_RETRY_INIT_MS = 50
21
- const LOCK_RETRY_MAX_MS = 500
22
-
23
- function isProcessAlive(pid) {
24
- try {
25
- process.kill(pid, 0)
26
- return true
27
- } catch {
28
- return false
29
- }
30
- }
31
-
32
- async function tryRemoveStaleLock(file, staleMs = LOCK_STALE_MS) {
33
- try {
34
- const content = await readFile(file, "utf-8")
35
- const [pidStr] = content.split(":")
36
- const pid = Number(pidStr)
37
- // If PID is valid and process is dead, remove immediately
38
- if (pid > 0 && !isProcessAlive(pid)) {
39
- await unlink(file).catch(() => {})
40
- return true
41
- }
42
- // Otherwise check mtime-based staleness
43
- const info = await stat(file)
44
- if (Date.now() - info.mtimeMs > staleMs) {
45
- await unlink(file).catch(() => {})
46
- return true
47
- }
48
- } catch {
49
- // lock disappeared or unreadable — retry
50
- return true
51
- }
52
- return false
53
- }
54
-
55
- async function acquireLock(cwd, lockTimeoutMs = LOCK_TIMEOUT_MS) {
56
- await ensure(cwd)
57
- const file = lockPath(cwd)
58
- const staleMs = lockTimeoutMs * 0.8
59
- const deadline = Date.now() + lockTimeoutMs
60
- let retryMs = LOCK_RETRY_INIT_MS
61
-
62
- while (Date.now() < deadline) {
63
- try {
64
- await writeFile(file, `${process.pid}:${Date.now()}`, { flag: "wx" })
65
- return true
66
- } catch (err) {
67
- if (err.code !== "EEXIST") throw err
68
- const removed = await tryRemoveStaleLock(file, staleMs)
69
- if (removed) continue
70
- // Exponential backoff: 50 100 → 200 → 400 → 500 (capped)
71
- await new Promise((r) => setTimeout(r, retryMs))
72
- retryMs = Math.min(retryMs * 2, LOCK_RETRY_MAX_MS)
73
- }
74
- }
75
-
76
- // Final attempt after timeout
77
- const removed = await tryRemoveStaleLock(file, staleMs)
78
- if (removed) {
79
- try {
80
- await writeFile(file, `${process.pid}:${Date.now()}`, { flag: "wx" })
81
- return true
82
- } catch { /* another process grabbed it */ }
83
- }
84
- throw new Error(`Failed to acquire lock after ${lockTimeoutMs}ms: ${file}`)
85
- }
86
-
87
- async function releaseLock(cwd) {
88
- await unlink(lockPath(cwd)).catch(() => {})
89
- }
90
-
91
- async function read(cwd = process.cwd()) {
92
- await ensure(cwd)
93
- return readJson(statePath(cwd), { sessions: {} })
94
- }
95
-
96
- async function write(data, cwd = process.cwd()) {
97
- await ensure(cwd)
98
- await writeJson(statePath(cwd), data)
99
- }
100
-
101
- export const LongAgentManager = {
102
- async update(sessionId, patch, cwd = process.cwd(), config = null) {
103
- const lockMs = Number(config?.agent?.longagent?.lock_timeout_ms || LOCK_TIMEOUT_MS)
104
- await acquireLock(cwd, lockMs)
105
- try {
106
- const state = await read(cwd)
107
- const current = state.sessions[sessionId] || {
108
- sessionId,
109
- status: "idle",
110
- phase: "L0",
111
- gateStatus: {},
112
- currentGate: "execution",
113
- recoveryCount: 0,
114
- planFrozen: false,
115
- currentStageId: null,
116
- stageIndex: 0,
117
- stageCount: 0,
118
- stageStatus: null,
119
- taskProgress: {},
120
- remainingFiles: [],
121
- remainingFilesCount: 0,
122
- lastGateFailures: [],
123
- createdAt: Date.now(),
124
- updatedAt: Date.now(),
125
- heartbeatAt: null,
126
- iterations: 0,
127
- lastMessage: ""
128
- }
129
- state.sessions[sessionId] = {
130
- ...current,
131
- ...patch,
132
- updatedAt: Date.now()
133
- }
134
- await write(state, cwd)
135
- return state.sessions[sessionId]
136
- } finally {
137
- await releaseLock(cwd)
138
- }
139
- },
140
- async get(sessionId, cwd = process.cwd()) {
141
- const state = await read(cwd)
142
- return state.sessions[sessionId] || null
143
- },
144
- async list(cwd = process.cwd()) {
145
- const state = await read(cwd)
146
- return Object.values(state.sessions).sort((a, b) => b.updatedAt - a.updatedAt)
147
- },
148
- async stop(sessionId, cwd = process.cwd()) {
149
- const existing = await this.get(sessionId, cwd)
150
- if (!existing) return null
151
- return this.update(sessionId, { stopRequested: true }, cwd)
152
- },
153
- async clearStop(sessionId, cwd = process.cwd()) {
154
- const existing = await this.get(sessionId, cwd)
155
- if (!existing) return null
156
- return this.update(sessionId, { stopRequested: false }, cwd)
157
- },
158
- /**
159
- * Execute `fn` while holding the state lock.
160
- * Prevents TOCTOU races (e.g. read-status git-merge).
161
- */
162
- async withLock(fn, cwd = process.cwd(), config = null) {
163
- const lockMs = Number(config?.agent?.longagent?.lock_timeout_ms || LOCK_TIMEOUT_MS)
164
- await acquireLock(cwd, lockMs)
165
- try {
166
- return await fn()
167
- } finally {
168
- await releaseLock(cwd)
169
- }
170
- }
171
- }
1
+ import path from "node:path"
2
+ import { mkdir, writeFile, readFile, unlink, stat } from "node:fs/promises"
3
+ import { readJson, writeJson } from "../storage/json-store.mjs"
4
+ import { projectRootDir } from "../storage/paths.mjs"
5
+ import { EventBus } from "../core/events.mjs"
6
+ import { EVENT_TYPES } from "../core/constants.mjs"
7
+
8
+ function statePath(cwd = process.cwd()) {
9
+ return path.join(projectRootDir(cwd), "longagent-state.json")
10
+ }
11
+
12
+ function lockPath(cwd = process.cwd()) {
13
+ return statePath(cwd) + ".lock"
14
+ }
15
+
16
+ async function ensure(cwd = process.cwd()) {
17
+ await mkdir(projectRootDir(cwd), { recursive: true })
18
+ }
19
+
20
+ const LOCK_TIMEOUT_MS = 5000
21
+ const LOCK_STALE_MS = LOCK_TIMEOUT_MS * 0.8 // 4000ms — detect stale before timeout
22
+ const LOCK_RETRY_INIT_MS = 50
23
+ const LOCK_RETRY_MAX_MS = 500
24
+
25
+ function isProcessAlive(pid) {
26
+ try {
27
+ process.kill(pid, 0)
28
+ return true
29
+ } catch {
30
+ return false
31
+ }
32
+ }
33
+
34
+ async function tryRemoveStaleLock(file, staleMs = LOCK_STALE_MS) {
35
+ try {
36
+ const content = await readFile(file, "utf-8")
37
+ const [pidStr] = content.split(":")
38
+ const pid = Number(pidStr)
39
+ // If PID is valid and process is dead, remove immediately
40
+ if (pid > 0 && !isProcessAlive(pid)) {
41
+ await unlink(file).catch(() => {})
42
+ return true
43
+ }
44
+ // Otherwise check mtime-based staleness
45
+ const info = await stat(file)
46
+ if (Date.now() - info.mtimeMs > staleMs) {
47
+ await unlink(file).catch(() => {})
48
+ return true
49
+ }
50
+ } catch {
51
+ // lock disappeared or unreadable — retry
52
+ return true
53
+ }
54
+ return false
55
+ }
56
+
57
+ async function acquireLock(cwd, lockTimeoutMs = LOCK_TIMEOUT_MS) {
58
+ await ensure(cwd)
59
+ const file = lockPath(cwd)
60
+ const staleMs = lockTimeoutMs * 0.8
61
+ const deadline = Date.now() + lockTimeoutMs
62
+ let retryMs = LOCK_RETRY_INIT_MS
63
+
64
+ while (Date.now() < deadline) {
65
+ try {
66
+ await writeFile(file, `${process.pid}:${Date.now()}`, { flag: "wx" })
67
+ return true
68
+ } catch (err) {
69
+ if (err.code !== "EEXIST") throw err
70
+ const removed = await tryRemoveStaleLock(file, staleMs)
71
+ if (removed) continue
72
+ // Exponential backoff: 50 100 → 200 → 400 → 500 (capped)
73
+ await new Promise((r) => setTimeout(r, retryMs))
74
+ retryMs = Math.min(retryMs * 2, LOCK_RETRY_MAX_MS)
75
+ }
76
+ }
77
+
78
+ // Final attempt after timeout
79
+ const removed = await tryRemoveStaleLock(file, staleMs)
80
+ if (removed) {
81
+ try {
82
+ await writeFile(file, `${process.pid}:${Date.now()}`, { flag: "wx" })
83
+ return true
84
+ } catch { /* another process grabbed it */ }
85
+ }
86
+ throw new Error(`Failed to acquire lock after ${lockTimeoutMs}ms: ${file}`)
87
+ }
88
+
89
+ async function releaseLock(cwd) {
90
+ await unlink(lockPath(cwd)).catch(() => {})
91
+ }
92
+
93
+ async function read(cwd = process.cwd()) {
94
+ await ensure(cwd)
95
+ return readJson(statePath(cwd), { sessions: {} })
96
+ }
97
+
98
+ async function write(data, cwd = process.cwd()) {
99
+ await ensure(cwd)
100
+ await writeJson(statePath(cwd), data)
101
+ }
102
+
103
+ async function updateSessionState(sessionId, patch, cwd = process.cwd()) {
104
+ const state = await read(cwd)
105
+ const current = state.sessions[sessionId] || {
106
+ sessionId,
107
+ status: "idle",
108
+ phase: "L0",
109
+ gateStatus: {},
110
+ currentGate: "execution",
111
+ recoveryCount: 0,
112
+ planFrozen: false,
113
+ currentStageId: null,
114
+ stageIndex: 0,
115
+ stageCount: 0,
116
+ stageStatus: null,
117
+ taskProgress: {},
118
+ remainingFiles: [],
119
+ remainingFilesCount: 0,
120
+ lastGateFailures: [],
121
+ createdAt: Date.now(),
122
+ updatedAt: Date.now(),
123
+ heartbeatAt: null,
124
+ iterations: 0,
125
+ lastMessage: ""
126
+ }
127
+ state.sessions[sessionId] = {
128
+ ...current,
129
+ ...patch,
130
+ updatedAt: Date.now()
131
+ }
132
+ await write(state, cwd)
133
+ return state.sessions[sessionId]
134
+ }
135
+
136
+ export const LongAgentManager = {
137
+ async update(sessionId, patch, cwd = process.cwd(), config = null) {
138
+ const lockMs = Number(config?.agent?.longagent?.lock_timeout_ms || LOCK_TIMEOUT_MS)
139
+ await acquireLock(cwd, lockMs)
140
+ try {
141
+ return await updateSessionState(sessionId, patch, cwd)
142
+ } finally {
143
+ await releaseLock(cwd)
144
+ }
145
+ },
146
+ async get(sessionId, cwd = process.cwd()) {
147
+ const state = await read(cwd)
148
+ return state.sessions[sessionId] || null
149
+ },
150
+ async list(cwd = process.cwd()) {
151
+ const state = await read(cwd)
152
+ return Object.values(state.sessions).sort((a, b) => b.updatedAt - a.updatedAt)
153
+ },
154
+ async stop(sessionId, cwd = process.cwd()) {
155
+ await acquireLock(cwd)
156
+ try {
157
+ const existing = await this.get(sessionId, cwd)
158
+ if (!existing) return null
159
+ const result = await updateSessionState(sessionId, { stopRequested: true }, cwd)
160
+ await EventBus.emit({ type: EVENT_TYPES.LONGAGENT_STOP_REQUESTED, sessionId, payload: { sessionId } }).catch(() => {})
161
+ return result
162
+ } finally {
163
+ await releaseLock(cwd)
164
+ }
165
+ },
166
+ async clearStop(sessionId, cwd = process.cwd()) {
167
+ await acquireLock(cwd)
168
+ try {
169
+ const existing = await this.get(sessionId, cwd)
170
+ if (!existing) return null
171
+ return await updateSessionState(sessionId, { stopRequested: false }, cwd)
172
+ } finally {
173
+ await releaseLock(cwd)
174
+ }
175
+ },
176
+ /**
177
+ * Execute `fn` while holding the state lock.
178
+ * Prevents TOCTOU races (e.g. read-status → git-merge).
179
+ * Includes heartbeat to prevent stale detection during long operations.
180
+ */
181
+ async withLock(fn, cwd = process.cwd(), config = null) {
182
+ const lockMs = Number(config?.agent?.longagent?.lock_timeout_ms || LOCK_TIMEOUT_MS)
183
+ await acquireLock(cwd, lockMs)
184
+ // Heartbeat: touch lock file periodically to prevent stale detection
185
+ const heartbeatMs = Math.max(Math.floor(lockMs * 0.3), 1000)
186
+ const file = lockPath(cwd)
187
+ const heartbeat = setInterval(() => {
188
+ writeFile(file, `${process.pid}:${Date.now()}`).catch(() => {})
189
+ }, heartbeatMs)
190
+ try {
191
+ return await fn()
192
+ } finally {
193
+ clearInterval(heartbeat)
194
+ await releaseLock(cwd)
195
+ }
196
+ }
197
+ }