@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
@@ -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
+ }