lyra-core 0.1.0

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 (131) hide show
  1. package/README.md +24 -0
  2. package/package.json +65 -0
  3. package/src/brain/compaction.test.ts +156 -0
  4. package/src/brain/compaction.ts +131 -0
  5. package/src/brain/demo-endpoint.ts +13 -0
  6. package/src/brain/frozen-prefix.test.ts +235 -0
  7. package/src/brain/frozen-prefix.ts +320 -0
  8. package/src/brain/history-persist.test.ts +129 -0
  9. package/src/brain/history-persist.ts +154 -0
  10. package/src/brain/index.ts +44 -0
  11. package/src/brain/openai-brain.test.ts +61 -0
  12. package/src/brain/openai-brain.ts +544 -0
  13. package/src/brain/sanitize.test.ts +27 -0
  14. package/src/brain/sanitize.ts +23 -0
  15. package/src/brain/stub.ts +20 -0
  16. package/src/brain/types.ts +129 -0
  17. package/src/chain.ts +35 -0
  18. package/src/claude-plugins/discovery.test.ts +71 -0
  19. package/src/claude-plugins/discovery.ts +152 -0
  20. package/src/claude-plugins/index.ts +6 -0
  21. package/src/claude-plugins/types.ts +38 -0
  22. package/src/commands/index.ts +16 -0
  23. package/src/commands/registry.test.ts +186 -0
  24. package/src/commands/registry.ts +255 -0
  25. package/src/config.ts +201 -0
  26. package/src/economy/index.ts +6 -0
  27. package/src/events/index.ts +4 -0
  28. package/src/events/listeners.ts +37 -0
  29. package/src/events/queue.test.ts +43 -0
  30. package/src/events/queue.ts +63 -0
  31. package/src/events/router.ts +42 -0
  32. package/src/events/types.ts +28 -0
  33. package/src/format.ts +13 -0
  34. package/src/index.test.ts +6 -0
  35. package/src/index.ts +314 -0
  36. package/src/locks.test.ts +259 -0
  37. package/src/locks.ts +233 -0
  38. package/src/mcp/discovery.test.ts +97 -0
  39. package/src/mcp/discovery.ts +150 -0
  40. package/src/mcp/index.ts +10 -0
  41. package/src/mcp/manager.test.ts +97 -0
  42. package/src/mcp/manager.ts +110 -0
  43. package/src/mcp/stdio-client.ts +154 -0
  44. package/src/mcp/types.ts +44 -0
  45. package/src/memory/edit.test.ts +41 -0
  46. package/src/memory/edit.ts +53 -0
  47. package/src/memory/encryption.test.ts +68 -0
  48. package/src/memory/encryption.ts +94 -0
  49. package/src/memory/fs-util.ts +15 -0
  50. package/src/memory/index-file.ts +74 -0
  51. package/src/memory/index-sync.test.ts +81 -0
  52. package/src/memory/index-sync.ts +99 -0
  53. package/src/memory/index.ts +58 -0
  54. package/src/memory/list-tool.ts +105 -0
  55. package/src/memory/pack-blob.test.ts +90 -0
  56. package/src/memory/pack-blob.ts +120 -0
  57. package/src/memory/pack-gather.test.ts +110 -0
  58. package/src/memory/pack-gather.ts +112 -0
  59. package/src/memory/parser.test.ts +29 -0
  60. package/src/memory/parser.ts +20 -0
  61. package/src/memory/path-drift.test.ts +71 -0
  62. package/src/memory/read-tool-fallback.test.ts +54 -0
  63. package/src/memory/read-tool.ts +198 -0
  64. package/src/memory/save-tool.test.ts +193 -0
  65. package/src/memory/save-tool.ts +189 -0
  66. package/src/memory/scan.test.ts +24 -0
  67. package/src/memory/scan.ts +63 -0
  68. package/src/memory/topic.ts +32 -0
  69. package/src/memory/types.ts +49 -0
  70. package/src/migration/index.ts +6 -0
  71. package/src/migration/option3-crypto.test.ts +106 -0
  72. package/src/migration/option3-crypto.ts +143 -0
  73. package/src/pairing.test.ts +212 -0
  74. package/src/pairing.ts +285 -0
  75. package/src/paths.ts +70 -0
  76. package/src/permission/dangerous.ts +105 -0
  77. package/src/permission/env-redact.ts +54 -0
  78. package/src/permission/index.ts +16 -0
  79. package/src/permission/path-guard.ts +114 -0
  80. package/src/permission/permission.test.ts +299 -0
  81. package/src/permission/service.ts +191 -0
  82. package/src/plugins/context.ts +226 -0
  83. package/src/plugins/hooks.ts +81 -0
  84. package/src/plugins/index.ts +24 -0
  85. package/src/plugins/plugins.test.ts +196 -0
  86. package/src/plugins/tool-search.ts +49 -0
  87. package/src/public/card.test.ts +70 -0
  88. package/src/public/card.ts +67 -0
  89. package/src/runtime/activity.ts +29 -0
  90. package/src/runtime/index.ts +7 -0
  91. package/src/runtime/runtime.test.ts +55 -0
  92. package/src/runtime/runtime.ts +126 -0
  93. package/src/sandbox/credentials.ts +25 -0
  94. package/src/sandbox/docker.ts +389 -0
  95. package/src/sandbox/factory.ts +99 -0
  96. package/src/sandbox/index.ts +15 -0
  97. package/src/sandbox/linux.ts +141 -0
  98. package/src/sandbox/local.ts +19 -0
  99. package/src/sandbox/macos.ts +71 -0
  100. package/src/sandbox/sandbox.test.ts +386 -0
  101. package/src/sandbox/seatbelt-profile.ts +139 -0
  102. package/src/sandbox/types.ts +129 -0
  103. package/src/skills/index.ts +8 -0
  104. package/src/skills/scanner.test.ts +137 -0
  105. package/src/skills/scanner.ts +257 -0
  106. package/src/skills/triggers.test.ts +77 -0
  107. package/src/skills/triggers.ts +78 -0
  108. package/src/skills/types.ts +37 -0
  109. package/src/storage/encryption.test.ts +30 -0
  110. package/src/storage/encryption.ts +87 -0
  111. package/src/storage/factory.ts +31 -0
  112. package/src/storage/index.ts +11 -0
  113. package/src/storage/local-stub.ts +70 -0
  114. package/src/storage/sqlite.test.ts +29 -0
  115. package/src/storage/sqlite.ts +95 -0
  116. package/src/storage/types.ts +21 -0
  117. package/src/tools/escalation.test.ts +348 -0
  118. package/src/tools/escalation.ts +200 -0
  119. package/src/tools/index.ts +11 -0
  120. package/src/tools/registry.test.ts +70 -0
  121. package/src/tools/registry.ts +152 -0
  122. package/src/tools/types.ts +65 -0
  123. package/src/tools/zod-helpers.test.ts +63 -0
  124. package/src/tools/zod-helpers.ts +36 -0
  125. package/src/tools/zod-schema.ts +99 -0
  126. package/src/wallet/drain.test.ts +41 -0
  127. package/src/wallet/drain.ts +76 -0
  128. package/src/wallet/eoa.ts +61 -0
  129. package/src/wallet/index.ts +14 -0
  130. package/src/wallet/keystore.test.ts +17 -0
  131. package/src/wallet/keystore.ts +50 -0
@@ -0,0 +1,6 @@
1
+ import { expect, test } from 'bun:test'
2
+ import { VERSION } from './index'
3
+
4
+ test('core package loads and exposes VERSION', () => {
5
+ expect(VERSION).toBe('0.0.0')
6
+ })
package/src/index.ts ADDED
@@ -0,0 +1,314 @@
1
+ // lyra-core: always-on infrastructure for the lyra harness.
2
+ export const VERSION = '0.0.0'
3
+
4
+ export * from './config'
5
+ export { formatSui } from './format'
6
+ export { agentPaths, placeholderAgentId } from './paths'
7
+
8
+ export type {
9
+ LyraEvent,
10
+ EventPayload,
11
+ EventSource,
12
+ Listener,
13
+ RouterDeps,
14
+ } from './events'
15
+ export { EventQueue, newEventId, listeners, routeLoop } from './events'
16
+
17
+ export type {
18
+ ToolCall,
19
+ ToolDef,
20
+ ToolResult,
21
+ ToolSchema,
22
+ JSONSchema,
23
+ FetchEscalation,
24
+ EscalationDeps,
25
+ } from './tools'
26
+ export {
27
+ ToolRegistry,
28
+ zodToJsonSchema,
29
+ coerceBool,
30
+ coerceInt,
31
+ detectFetchEscalation,
32
+ mergeEscalationResult,
33
+ runEscalation,
34
+ } from './tools'
35
+
36
+ export type {
37
+ ApplyResult,
38
+ CommandScope,
39
+ CommandSurface,
40
+ ParsedSlash,
41
+ PermissionApi,
42
+ PermissionToggleMode,
43
+ SlashCommand,
44
+ } from './commands'
45
+ export {
46
+ COMMAND_REGISTRY,
47
+ applyPerms,
48
+ applyYolo,
49
+ commandsForSurface,
50
+ findCommand,
51
+ parseSlash,
52
+ suggestForPrefix,
53
+ } from './commands'
54
+
55
+ export type {
56
+ Brain,
57
+ BrainCompactionEvent,
58
+ BrainInferInput,
59
+ BrainTurn,
60
+ BrainMessage,
61
+ BrainProvider,
62
+ BrainProviderOpts,
63
+ CompactionOpts,
64
+ FrozenPrefix,
65
+ HistoryPersist,
66
+ FsHistoryPersistOpts,
67
+ OpenAIBrainOpts,
68
+ SummarizeFn,
69
+ } from './brain'
70
+ export {
71
+ StubBrain,
72
+ OpenAIBrain,
73
+ DEFAULT_BASE_URL,
74
+ DEMO_LLM_BASE_URL,
75
+ DEMO_LLM_TOKEN,
76
+ DEFAULT_MODEL,
77
+ DEFAULT_CHANNEL_KEY,
78
+ DEFAULT_MAX_OUTPUT_TOKENS,
79
+ previewToolArgs,
80
+ inferToolOk,
81
+ buildFrozenPrefix,
82
+ renderFrozenPrefix,
83
+ DEFAULT_SYSTEM_PROMPT,
84
+ DEFAULT_COMPACTION_OPTS,
85
+ SUMMARY_SYSTEM_PROMPT,
86
+ estimateTokens,
87
+ shouldCompact,
88
+ compactHistory,
89
+ createFsHistoryPersist,
90
+ sanitizeChannelKey,
91
+ } from './brain'
92
+
93
+ export type {
94
+ MemoryType,
95
+ MemoryPartition,
96
+ MemoryFrontmatter,
97
+ MemoryTopic,
98
+ MemoryIndexEntry,
99
+ MemoryIndex,
100
+ EditOp,
101
+ EditAction,
102
+ ThreatScanResult,
103
+ } from './memory'
104
+ export {
105
+ parseTopic,
106
+ stringifyTopic,
107
+ scanForThreats,
108
+ applyEdit,
109
+ EditError,
110
+ parseIndex,
111
+ stringifyIndex,
112
+ readIndexFile,
113
+ writeIndexFile,
114
+ addEntryLine,
115
+ removeEntryLine,
116
+ readTopic,
117
+ writeTopic,
118
+ topicPath,
119
+ makeMemorySaveTool,
120
+ type MemorySaveArgs,
121
+ makeMemoryReadTool,
122
+ type MemoryReadArgs,
123
+ makeMemoryListTool,
124
+ type MemoryListArgs,
125
+ type MemoryListAgentFile,
126
+ ensureSyntheticIndexEntries,
127
+ STANDARD_SYNTHETIC_INDEX_FILES,
128
+ type SyntheticIndexFile,
129
+ type SyntheticIndexResult,
130
+ INDEX_LINE_LIMIT,
131
+ INDEX_BYTE_LIMIT,
132
+ MEMORY_BLOB_VERSION,
133
+ deriveMemoryKey,
134
+ encryptMemoryBytes,
135
+ decryptMemoryBytes,
136
+ PACK_BLOB_VERSION,
137
+ encodePackBlob,
138
+ decodePackBlob,
139
+ isV2Envelope,
140
+ type PackBlob,
141
+ type EncodePackOpts,
142
+ gatherAgentPack,
143
+ gatherUserPack,
144
+ writeAgentPack,
145
+ writeUserPack,
146
+ type GatherResult,
147
+ } from './memory'
148
+
149
+ export type { Storage } from './storage'
150
+ export {
151
+ LocalStubStorage,
152
+ SqliteStorage,
153
+ getStorage,
154
+ downloadBlobByRoot,
155
+ encrypt as encryptBytes,
156
+ decrypt as decryptBytes,
157
+ packEnvelope,
158
+ unpackEnvelope,
159
+ type EncryptedEnvelope,
160
+ } from './storage'
161
+
162
+ export {
163
+ encryptKey,
164
+ decryptKey,
165
+ generateAgentKeypair,
166
+ generateAgentWallet,
167
+ saveKeystore,
168
+ loadKeystore,
169
+ type EncryptedKeystore,
170
+ type AgentWalletMaterial,
171
+ drainAgentEOA,
172
+ computeSweepAmount,
173
+ SWEEP_GAS_RESERVE_MIST,
174
+ type DrainAgentResult,
175
+ } from './wallet'
176
+
177
+ export {
178
+ makeSuiClient,
179
+ suiRpcUrl,
180
+ keypairFromSecret,
181
+ getSuiBalanceMist,
182
+ } from './chain'
183
+
184
+ export {
185
+ Runtime,
186
+ type RuntimeDeps,
187
+ type AgentIdentity,
188
+ type IdentityProvider,
189
+ ActivityLog,
190
+ type ActivityEntry,
191
+ } from './runtime'
192
+
193
+ export {
194
+ acquireScopedLock,
195
+ clearStaleScopedLock,
196
+ DEFAULT_LOCK_TTL_SECONDS,
197
+ type AcquireScopedLockOpts,
198
+ type AcquireScopedLockResult,
199
+ type ClearStaleScopedLockReason,
200
+ type ClearStaleScopedLockResult,
201
+ type ScopedLockHandle,
202
+ } from './locks'
203
+
204
+ export {
205
+ PairingStore,
206
+ PAIRING_ALPHABET,
207
+ PAIRING_CODE_LENGTH,
208
+ PAIRING_CODE_TTL_SECONDS,
209
+ PAIRING_RATE_LIMIT_SECONDS,
210
+ PAIRING_LOCKOUT_SECONDS,
211
+ PAIRING_MAX_PENDING_PER_PLATFORM,
212
+ PAIRING_MAX_FAILED_ATTEMPTS,
213
+ type PairingStoreOpts,
214
+ type PendingEntry,
215
+ type ApprovedEntry,
216
+ type PendingListing,
217
+ type ApprovedListing,
218
+ type ApproveResult,
219
+ } from './pairing'
220
+
221
+ export {
222
+ encryptToPubkey,
223
+ decryptWithPrivkey,
224
+ generateBootstrapKeypair,
225
+ type Option3Envelope,
226
+ } from './migration'
227
+
228
+ export {
229
+ HookBus,
230
+ type HookName,
231
+ type HookHandler,
232
+ type PreToolCallContext,
233
+ type PreToolCallResult,
234
+ type PostToolCallContext,
235
+ loadPlugins,
236
+ type PluginContext,
237
+ type NativePlugin,
238
+ type PluginLoadResult,
239
+ type PluginLoaderDeps,
240
+ type DelegateBrainFactory,
241
+ type DelegateBrainFactoryOpts,
242
+ type DelegateBrainHandle,
243
+ type DelegateBrainTurn,
244
+ type VisionInferFn,
245
+ type VisionInferInput,
246
+ type VisionInferImage,
247
+ type VisionInferResult,
248
+ makeToolSearchTool,
249
+ type ToolSearchArgs,
250
+ } from './plugins'
251
+
252
+ export {
253
+ detectDangerousCommand,
254
+ DANGEROUS_PATTERNS,
255
+ PathGuard,
256
+ type PathGuardOpts,
257
+ type PathGuardResult,
258
+ redactEnv,
259
+ type EnvRedactResult,
260
+ PermissionService,
261
+ type PermissionMode,
262
+ type PermissionDecision,
263
+ type PermissionRequest,
264
+ type PermissionPrompter,
265
+ type PermissionServiceOpts,
266
+ type DangerousMatch,
267
+ } from './permission'
268
+
269
+ export type { SkillFrontmatter, SkillRef, SkillSource } from './skills'
270
+ export {
271
+ scanSkills,
272
+ parseFrontmatter as parseSkillFrontmatter,
273
+ matchTriggers as matchSkillTriggers,
274
+ matchFilePattern,
275
+ matchBashPattern,
276
+ type SkillScannerOptions,
277
+ type SkillTriggerMatch,
278
+ } from './skills'
279
+
280
+ export {
281
+ discoverMcpServers,
282
+ McpManager,
283
+ McpStdioClient,
284
+ type McpDiscoveryOptions,
285
+ type McpServerConfig,
286
+ type McpServerStdio,
287
+ type McpServerHttp,
288
+ type McpToolMeta,
289
+ type McpDiscoveryResult,
290
+ } from './mcp'
291
+
292
+ export {
293
+ discoverClaudeExtras,
294
+ type ClaudeExtrasOptions,
295
+ type ClaudeCommand,
296
+ type ClaudeAgent,
297
+ type ClaudeExtrasDiscoveryResult,
298
+ } from './claude-plugins'
299
+
300
+ export {
301
+ LocalBackend,
302
+ MacOSSandboxExecBackend,
303
+ DockerBackend,
304
+ makeSandboxBackend,
305
+ buildSeatbeltProfile,
306
+ type SandboxBackend,
307
+ type SandboxBackendOpts,
308
+ type SandboxMode,
309
+ type SandboxSpawnRequest,
310
+ type WrappedSpawn,
311
+ type SeatbeltProfileOpts,
312
+ type MakeSandboxOpts,
313
+ type DockerBackendOpts,
314
+ } from './sandbox'
@@ -0,0 +1,259 @@
1
+ import { afterEach, beforeEach, describe, expect, it } from 'bun:test'
2
+ import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'
3
+ import { tmpdir } from 'node:os'
4
+ import { join } from 'node:path'
5
+ import {
6
+ DEFAULT_LOCK_TTL_SECONDS,
7
+ acquireScopedLock,
8
+ clearStaleScopedLock,
9
+ isZombieLinux,
10
+ } from './locks'
11
+
12
+ let testDir: string
13
+
14
+ beforeEach(() => {
15
+ testDir = mkdtempSync(join(tmpdir(), 'lyra-locks-test-'))
16
+ })
17
+
18
+ afterEach(() => {
19
+ rmSync(testDir, { recursive: true, force: true })
20
+ })
21
+
22
+ describe('acquireScopedLock', () => {
23
+ it('acquires when no prior holder exists', () => {
24
+ const r = acquireScopedLock({ scope: 'test', identity: 'token-a', rootDir: testDir })
25
+ expect(r.acquired).toBe(true)
26
+ expect(r.handle).toBeDefined()
27
+ r.handle?.releaseFn()
28
+ })
29
+
30
+ it('rejects when another live process holds it', () => {
31
+ const r1 = acquireScopedLock({ scope: 'test', identity: 'token-a', rootDir: testDir })
32
+ expect(r1.acquired).toBe(true)
33
+
34
+ const r2 = acquireScopedLock({ scope: 'test', identity: 'token-a', rootDir: testDir })
35
+ expect(r2.acquired).toBe(false)
36
+ expect(r2.existing?.pid).toBe(process.pid)
37
+
38
+ r1.handle?.releaseFn()
39
+ })
40
+
41
+ it('different identities do not collide on same scope', () => {
42
+ const a = acquireScopedLock({ scope: 'test', identity: 'token-a', rootDir: testDir })
43
+ const b = acquireScopedLock({ scope: 'test', identity: 'token-b', rootDir: testDir })
44
+ expect(a.acquired).toBe(true)
45
+ expect(b.acquired).toBe(true)
46
+ a.handle?.releaseFn()
47
+ b.handle?.releaseFn()
48
+ })
49
+
50
+ it('release allows re-acquire', () => {
51
+ const r1 = acquireScopedLock({ scope: 'test', identity: 'token-a', rootDir: testDir })
52
+ expect(r1.acquired).toBe(true)
53
+ r1.handle?.releaseFn()
54
+
55
+ const r2 = acquireScopedLock({ scope: 'test', identity: 'token-a', rootDir: testDir })
56
+ expect(r2.acquired).toBe(true)
57
+ r2.handle?.releaseFn()
58
+ })
59
+
60
+ it('release is idempotent', () => {
61
+ const r1 = acquireScopedLock({ scope: 'test', identity: 'token-a', rootDir: testDir })
62
+ r1.handle?.releaseFn()
63
+ r1.handle?.releaseFn()
64
+ const r2 = acquireScopedLock({ scope: 'test', identity: 'token-a', rootDir: testDir })
65
+ expect(r2.acquired).toBe(true)
66
+ r2.handle?.releaseFn()
67
+ })
68
+
69
+ it('reclaims a stale lock from a dead PID', () => {
70
+ // Manually plant a lock file with a fake PID that won't exist
71
+ const fakePid = 999999
72
+ const lockFile = findLockFile(testDir, 'test', 'token-a')
73
+ writeFileSync(
74
+ lockFile,
75
+ JSON.stringify({
76
+ pid: fakePid,
77
+ scope: 'test',
78
+ identityHash: 'fake',
79
+ startedAt: 0,
80
+ updatedAt: 0,
81
+ ttl: DEFAULT_LOCK_TTL_SECONDS,
82
+ }),
83
+ )
84
+
85
+ const r = acquireScopedLock({ scope: 'test', identity: 'token-a', rootDir: testDir })
86
+ expect(r.acquired).toBe(true)
87
+ r.handle?.releaseFn()
88
+ })
89
+
90
+ it('reclaims a TTL-expired lock from a live process', () => {
91
+ // Plant a lock file with a real PID but ancient updatedAt
92
+ const lockFile = findLockFile(testDir, 'test', 'token-a')
93
+ const veryOld = Math.floor(Date.now() / 1000) - 99999
94
+ writeFileSync(
95
+ lockFile,
96
+ JSON.stringify({
97
+ pid: process.pid,
98
+ scope: 'test',
99
+ identityHash: 'fake',
100
+ startedAt: veryOld,
101
+ updatedAt: veryOld,
102
+ ttl: 60,
103
+ }),
104
+ )
105
+
106
+ const r = acquireScopedLock({ scope: 'test', identity: 'token-a', rootDir: testDir })
107
+ expect(r.acquired).toBe(true)
108
+ r.handle?.releaseFn()
109
+ })
110
+
111
+ it('refreshFn returns true while owner holds the lock', () => {
112
+ const r = acquireScopedLock({ scope: 'test', identity: 'token-a', rootDir: testDir })
113
+ expect(r.handle?.refreshFn()).toBe(true)
114
+ r.handle?.releaseFn()
115
+ })
116
+
117
+ it('refreshFn returns false after release', () => {
118
+ const r = acquireScopedLock({ scope: 'test', identity: 'token-a', rootDir: testDir })
119
+ r.handle?.releaseFn()
120
+ expect(r.handle?.refreshFn()).toBe(false)
121
+ })
122
+
123
+ it('hashes the identity into the lock filename', () => {
124
+ const a = acquireScopedLock({ scope: 'test', identity: 'token-a', rootDir: testDir })
125
+ const b = acquireScopedLock({ scope: 'test', identity: 'token-different', rootDir: testDir })
126
+ expect(a.acquired).toBe(true)
127
+ expect(b.acquired).toBe(true)
128
+ a.handle?.releaseFn()
129
+ b.handle?.releaseFn()
130
+ })
131
+
132
+ it('records stable shape: pid + startedAt + updatedAt + ttl', () => {
133
+ const r = acquireScopedLock({ scope: 'test', identity: 'token-a', rootDir: testDir, ttl: 600 })
134
+ expect(r.acquired).toBe(true)
135
+ r.handle?.releaseFn()
136
+ })
137
+
138
+ it('respects custom rootDir', () => {
139
+ const r = acquireScopedLock({ scope: 'test', identity: 'token-a', rootDir: testDir })
140
+ expect(r.acquired).toBe(true)
141
+ r.handle?.releaseFn()
142
+ })
143
+ })
144
+
145
+ describe('isZombieLinux', () => {
146
+ it('returns false on non-linux platforms', () => {
147
+ if (process.platform === 'linux') return // platform-specific guard
148
+ expect(isZombieLinux(process.pid)).toBe(false)
149
+ })
150
+
151
+ it('returns false for the live current process on linux', () => {
152
+ if (process.platform !== 'linux') return
153
+ expect(isZombieLinux(process.pid)).toBe(false)
154
+ })
155
+
156
+ it('returns false when /proc/<pid>/status is unreadable', () => {
157
+ // pid 999999 vanishingly unlikely to exist; readFile will throw.
158
+ expect(isZombieLinux(999_999)).toBe(false)
159
+ })
160
+ })
161
+
162
+ describe('acquireScopedLock evicts dead-pid lock', () => {
163
+ it('reclaims when the lockfile records a pid that no longer exists', () => {
164
+ // Take the lock, write a tampered record claiming a vanishingly-unlikely
165
+ // pid is the holder, then re-acquire. The stale-detection path
166
+ // (process.kill(pid, 0) → ESRCH) should treat it as gone and reclaim.
167
+ const path = findLockFile(testDir, 'test', 'dead-pid')
168
+ writeFileSync(
169
+ path,
170
+ JSON.stringify({
171
+ pid: 999_998, // unlikely to exist on the test host
172
+ scope: 'test',
173
+ identityHash: 'whatever',
174
+ startedAt: Math.floor(Date.now() / 1000),
175
+ updatedAt: Math.floor(Date.now() / 1000),
176
+ ttl: 300,
177
+ }),
178
+ )
179
+ const r = acquireScopedLock({ scope: 'test', identity: 'dead-pid', rootDir: testDir })
180
+ expect(r.acquired).toBe(true)
181
+ r.handle?.releaseFn()
182
+ })
183
+ })
184
+
185
+ describe('clearStaleScopedLock', () => {
186
+ it('returns no-lock when nothing exists', () => {
187
+ const r = clearStaleScopedLock({ scope: 'test', identity: 'fresh', rootDir: testDir })
188
+ expect(r.cleared).toBe(false)
189
+ expect(r.reason).toBe('no-lock')
190
+ })
191
+
192
+ it('returns alive-pid when a live PID holds the lock', () => {
193
+ const a = acquireScopedLock({ scope: 'test', identity: 'live', rootDir: testDir })
194
+ expect(a.acquired).toBe(true)
195
+ const r = clearStaleScopedLock({ scope: 'test', identity: 'live', rootDir: testDir })
196
+ expect(r.cleared).toBe(false)
197
+ expect(r.reason).toBe('alive-pid')
198
+ a.handle?.releaseFn()
199
+ })
200
+
201
+ it('clears a dead-PID lock and reports cleared-stale', () => {
202
+ const path = findLockFile(testDir, 'test', 'dead')
203
+ writeFileSync(
204
+ path,
205
+ JSON.stringify({
206
+ pid: 999_997,
207
+ scope: 'test',
208
+ identityHash: 'x',
209
+ startedAt: Math.floor(Date.now() / 1000),
210
+ updatedAt: Math.floor(Date.now() / 1000),
211
+ ttl: 600,
212
+ }),
213
+ )
214
+ const r = clearStaleScopedLock({ scope: 'test', identity: 'dead', rootDir: testDir })
215
+ expect(r.cleared).toBe(true)
216
+ expect(r.reason).toBe('cleared-stale')
217
+ // Re-clear should now return no-lock.
218
+ const r2 = clearStaleScopedLock({ scope: 'test', identity: 'dead', rootDir: testDir })
219
+ expect(r2.cleared).toBe(false)
220
+ expect(r2.reason).toBe('no-lock')
221
+ })
222
+
223
+ it('clears a TTL-expired lock and reports cleared-ttl', () => {
224
+ const path = findLockFile(testDir, 'test', 'expired')
225
+ const ancient = Math.floor(Date.now() / 1000) - 9999
226
+ writeFileSync(
227
+ path,
228
+ JSON.stringify({
229
+ pid: process.pid,
230
+ scope: 'test',
231
+ identityHash: 'x',
232
+ startedAt: ancient,
233
+ updatedAt: ancient,
234
+ ttl: 60,
235
+ }),
236
+ )
237
+ const r = clearStaleScopedLock({ scope: 'test', identity: 'expired', rootDir: testDir })
238
+ expect(r.cleared).toBe(true)
239
+ expect(r.reason).toBe('cleared-ttl')
240
+ })
241
+
242
+ it('does not delete a lock owned by current process within TTL', () => {
243
+ const a = acquireScopedLock({ scope: 'test', identity: 'mine', rootDir: testDir })
244
+ expect(a.acquired).toBe(true)
245
+ const r = clearStaleScopedLock({ scope: 'test', identity: 'mine', rootDir: testDir })
246
+ expect(r.cleared).toBe(false)
247
+ expect(r.reason).toBe('alive-pid')
248
+ // Lock still acquireable by us via refresh:
249
+ expect(a.handle?.refreshFn()).toBe(true)
250
+ a.handle?.releaseFn()
251
+ })
252
+ })
253
+
254
+ function findLockFile(dir: string, scope: string, identity: string): string {
255
+ // Mirror the internal hash logic (sha256 first 16 hex chars)
256
+ const { createHash } = require('node:crypto') as typeof import('node:crypto')
257
+ const hash = createHash('sha256').update(identity).digest('hex').slice(0, 16)
258
+ return join(dir, `${scope}-${hash}.lock`)
259
+ }