@raidiant/notifai 0.4.0 → 0.5.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 (62) hide show
  1. package/README.md +3 -4
  2. package/dist/claude-wake.d.ts +101 -0
  3. package/dist/claude-wake.js +397 -0
  4. package/dist/claude-wake.js.map +1 -0
  5. package/dist/codex-wake.d.ts +84 -0
  6. package/dist/codex-wake.js +238 -0
  7. package/dist/codex-wake.js.map +1 -0
  8. package/dist/commands.d.ts +15 -10
  9. package/dist/commands.js +321 -88
  10. package/dist/commands.js.map +1 -1
  11. package/dist/config-schema.d.ts +2 -2
  12. package/dist/config-schema.js +9 -35
  13. package/dist/config-schema.js.map +1 -1
  14. package/dist/config.d.ts +5 -38
  15. package/dist/config.js +11 -32
  16. package/dist/config.js.map +1 -1
  17. package/dist/harnesses.d.ts +3 -13
  18. package/dist/harnesses.js +6 -17
  19. package/dist/harnesses.js.map +1 -1
  20. package/dist/hooks.d.ts +119 -65
  21. package/dist/hooks.js +330 -285
  22. package/dist/hooks.js.map +1 -1
  23. package/dist/install-hooks.d.ts +22 -7
  24. package/dist/install-hooks.js +43 -28
  25. package/dist/install-hooks.js.map +1 -1
  26. package/dist/interactive.d.ts +1 -1
  27. package/dist/interactive.js +2 -4
  28. package/dist/interactive.js.map +1 -1
  29. package/dist/main.js +6 -13
  30. package/dist/main.js.map +1 -1
  31. package/dist/release.d.ts +31 -0
  32. package/dist/release.js +52 -0
  33. package/dist/release.js.map +1 -0
  34. package/dist/ui/theme.d.ts +1 -1
  35. package/package.json +9 -9
  36. package/src/claude-wake.test.ts +320 -0
  37. package/src/claude-wake.ts +489 -0
  38. package/src/codex-wake.test.ts +253 -0
  39. package/src/codex-wake.ts +288 -0
  40. package/src/commands.test.ts +393 -40
  41. package/src/commands.ts +433 -109
  42. package/src/config-schema.test.ts +3 -3
  43. package/src/config-schema.ts +11 -40
  44. package/src/config.test.ts +21 -16
  45. package/src/config.ts +15 -69
  46. package/src/harnesses.test.ts +7 -21
  47. package/src/harnesses.ts +17 -19
  48. package/src/hooks.test.ts +780 -290
  49. package/src/hooks.ts +466 -339
  50. package/src/install-hooks.test.ts +44 -3
  51. package/src/install-hooks.ts +43 -28
  52. package/src/interactive.ts +2 -7
  53. package/src/logging.test.ts +3 -3
  54. package/src/main.ts +6 -12
  55. package/src/release.ts +51 -0
  56. package/src/skill.test.ts +23 -0
  57. package/src/ui/config-view.test.ts +5 -5
  58. package/dist/idle.d.ts +0 -16
  59. package/dist/idle.js +0 -44
  60. package/dist/idle.js.map +0 -1
  61. package/src/idle.test.ts +0 -32
  62. package/src/idle.ts +0 -41
@@ -0,0 +1,253 @@
1
+ import { closeSync, mkdirSync, mkdtempSync, openSync, rmSync, writeFileSync } from 'node:fs'
2
+ import os from 'node:os'
3
+ import path from 'node:path'
4
+ import { afterAll, describe, expect, it, vi } from 'vitest'
5
+ import {
6
+ CODEX_THREAD_LOCK_DIR,
7
+ codexThreadLockPath,
8
+ codexWakeRoute,
9
+ observeCodexThread,
10
+ systemCodexWakeAdapters,
11
+ type CodexWakeAdapters,
12
+ type CodexWakeObservation,
13
+ } from './codex-wake.js'
14
+
15
+ const THREAD_ID = '019ff69d-a07f-7161-ab6e-bd06b3b93c8e'
16
+
17
+ const event = {
18
+ context:
19
+ 'Notifai — question_id rollout-option, question "Which rollout option?"; the user answered "BETA".',
20
+ answers: 1,
21
+ remaining: 0,
22
+ request_ids: ['req_test'],
23
+ journal_recorded_at: 1_800_000_000_000,
24
+ }
25
+
26
+ const temporaries: string[] = []
27
+
28
+ function codexHome(): string {
29
+ const home = mkdtempSync(path.join(os.tmpdir(), 'notifai-codex-wake-'))
30
+ temporaries.push(home)
31
+ mkdirSync(path.join(home, CODEX_THREAD_LOCK_DIR), { recursive: true })
32
+ return home
33
+ }
34
+
35
+ afterAll(() => {
36
+ for (const directory of temporaries) rmSync(directory, { recursive: true, force: true })
37
+ })
38
+
39
+ function adapters(
40
+ options: {
41
+ sourceAlive?: boolean
42
+ probes?: CodexWakeObservation[]
43
+ probe?: CodexWakeObservation
44
+ } = {},
45
+ ): CodexWakeAdapters & { resumed: Array<{ threadId: string; cwd: string; context: string }>; probed: string[] } {
46
+ const resumed: Array<{ threadId: string; cwd: string; context: string }> = []
47
+ const probed: string[] = []
48
+ const sequence = [...(options.probes ?? [])]
49
+ return {
50
+ resumed,
51
+ probed,
52
+ probeThreadWriter(lockPath) {
53
+ probed.push(lockPath)
54
+ return sequence.shift() ?? options.probe ?? { state: 'stopped' }
55
+ },
56
+ sourceAlive() {
57
+ return options.sourceAlive ?? false
58
+ },
59
+ async resume(threadId, cwd, context) {
60
+ resumed.push({ threadId, cwd, context })
61
+ },
62
+ }
63
+ }
64
+
65
+ function route(
66
+ wake: CodexWakeAdapters,
67
+ overrides: { threadId?: string; env?: NodeJS.ProcessEnv } = {},
68
+ ): ReturnType<typeof codexWakeRoute> {
69
+ return codexWakeRoute({
70
+ threadId: overrides.threadId ?? THREAD_ID,
71
+ cwd: '/tmp/notifai-codex-wake',
72
+ sourcePid: 4242,
73
+ env: overrides.env ?? { CODEX_HOME: '/tmp/notifai-codex-home' },
74
+ adapters: wake,
75
+ })
76
+ }
77
+
78
+ describe('Codex thread ownership', () => {
79
+ it('names the lock file Codex itself keys by thread id', () => {
80
+ expect(codexThreadLockPath(THREAD_ID, { CODEX_HOME: '/tmp/cx' })).toBe(
81
+ `/tmp/cx/${CODEX_THREAD_LOCK_DIR}/${THREAD_ID}.lock`,
82
+ )
83
+ })
84
+
85
+ it('refuses to derive a lock path from a session id that is not a thread id', () => {
86
+ const wake = adapters()
87
+
88
+ expect(observeCodexThread('../../etc/passwd', {}, wake)).toEqual({
89
+ state: 'unknown',
90
+ reason: 'the Codex session id is not a thread id',
91
+ })
92
+ expect(wake.probed).toEqual([])
93
+ })
94
+
95
+ it('reports a probe that throws as unknown rather than as an unowned thread', () => {
96
+ const observation = observeCodexThread(THREAD_ID, { CODEX_HOME: '/tmp/cx' }, {
97
+ probeThreadWriter() {
98
+ throw new Error('permission denied')
99
+ },
100
+ })
101
+
102
+ expect(observation).toEqual({
103
+ state: 'unknown',
104
+ reason: 'Codex thread-writer lock probe failed: permission denied',
105
+ })
106
+ })
107
+ })
108
+
109
+ describe('Codex writer-lock probe', () => {
110
+ const platform = process.platform
111
+ const supported = platform === 'darwin' || platform === 'freebsd' || platform === 'openbsd'
112
+
113
+ it.runIf(supported)('reads a kernel-held lock as a live writer and its release as stopped', () => {
114
+ const home = codexHome()
115
+ const lock = codexThreadLockPath(THREAD_ID, { CODEX_HOME: home })
116
+ writeFileSync(lock, '')
117
+ const probe = systemCodexWakeAdapters({ CODEX_HOME: home }).probeThreadWriter
118
+
119
+ // flock is held per open file description, so this process contends with
120
+ // itself exactly as a separate Codex process would.
121
+ const held = openSync(lock, 0x20 | 0x4)
122
+ try {
123
+ expect(probe(lock)).toEqual({ state: 'live' })
124
+ } finally {
125
+ closeSync(held)
126
+ }
127
+
128
+ expect(probe(lock)).toEqual({ state: 'stopped' })
129
+ })
130
+
131
+ it.runIf(supported)('treats a swept lock file inside a real lock directory as stopped', () => {
132
+ const home = codexHome()
133
+ const probe = systemCodexWakeAdapters({ CODEX_HOME: home }).probeThreadWriter
134
+
135
+ expect(probe(codexThreadLockPath(THREAD_ID, { CODEX_HOME: home }))).toEqual({
136
+ state: 'stopped',
137
+ })
138
+ })
139
+
140
+ it.runIf(supported)('never claims a thread is unowned when there is no lock directory', () => {
141
+ const home = mkdtempSync(path.join(os.tmpdir(), 'notifai-codex-nohome-'))
142
+ temporaries.push(home)
143
+ const probe = systemCodexWakeAdapters({ CODEX_HOME: home }).probeThreadWriter
144
+ const lock = codexThreadLockPath(THREAD_ID, { CODEX_HOME: home })
145
+
146
+ expect(probe(lock)).toMatchObject({ state: 'unknown' })
147
+ })
148
+
149
+ it.runIf(!supported)('fails closed where no non-blocking lock probe exists', () => {
150
+ const probe = systemCodexWakeAdapters({ CODEX_HOME: '/tmp/cx' }).probeThreadWriter
151
+
152
+ expect(probe(`/tmp/cx/${CODEX_THREAD_LOCK_DIR}/${THREAD_ID}.lock`)).toMatchObject({
153
+ state: 'unknown',
154
+ })
155
+ })
156
+ })
157
+
158
+ describe('Codex wake delivery', () => {
159
+ it('continues the live session through its own Stop hook without probing anything', async () => {
160
+ const wake = adapters({ sourceAlive: true })
161
+
162
+ const outcome = await route(wake).deliver(event)
163
+
164
+ expect(JSON.parse(outcome.stdout!)).toEqual({ decision: 'block', reason: event.context })
165
+ expect(wake.probed).toEqual([])
166
+ expect(wake.resumed).toEqual([])
167
+ })
168
+
169
+ it('cold-resumes a stopped thread only after two probes both find no writer', async () => {
170
+ const wake = adapters({ probes: [{ state: 'stopped' }, { state: 'stopped' }] })
171
+
172
+ const outcome = await route(wake).deliver(event)
173
+
174
+ expect(wake.probed).toEqual([
175
+ `/tmp/notifai-codex-home/${CODEX_THREAD_LOCK_DIR}/${THREAD_ID}.lock`,
176
+ `/tmp/notifai-codex-home/${CODEX_THREAD_LOCK_DIR}/${THREAD_ID}.lock`,
177
+ ])
178
+ expect(wake.resumed).toEqual([
179
+ { threadId: THREAD_ID, cwd: '/tmp/notifai-codex-wake', context: event.context },
180
+ ])
181
+ expect(outcome.stdout).toBeUndefined()
182
+ expect(outcome.log).toEqual({ route: 'cold-resume', stage: 'delivered' })
183
+ })
184
+
185
+ it('never resumes a thread a live writer owns', async () => {
186
+ const wake = adapters({ probe: { state: 'live' } })
187
+
188
+ const outcome = await route(wake).deliver(event)
189
+
190
+ expect(wake.resumed).toEqual([])
191
+ expect(outcome.stdout).toBeUndefined()
192
+ expect(outcome.log).toEqual({
193
+ route: 'hold-for-next-turn',
194
+ stage: 'queued',
195
+ reason: 'a live writer owns the Codex thread and this hook can no longer continue it',
196
+ })
197
+ expect(outcome.notes.join('\n')).toContain('holding the accepted answer for the next turn')
198
+ })
199
+
200
+ it('never resumes a thread whose writer took the lock between the two probes', async () => {
201
+ const wake = adapters({ probes: [{ state: 'stopped' }, { state: 'live' }] })
202
+
203
+ const outcome = await route(wake).deliver(event)
204
+
205
+ expect(wake.probed).toHaveLength(2)
206
+ expect(wake.resumed).toEqual([])
207
+ expect(outcome.log).toMatchObject({ route: 'hold-for-next-turn', stage: 'queued' })
208
+ })
209
+
210
+ it('journals the answer when ownership cannot be probed at all', async () => {
211
+ const wake = adapters({ probe: { state: 'unknown', reason: 'no lock directory' } })
212
+
213
+ const outcome = await route(wake).deliver(event)
214
+
215
+ expect(wake.resumed).toEqual([])
216
+ expect(outcome.log).toEqual({
217
+ route: 'hold-for-next-turn',
218
+ stage: 'queued',
219
+ reason: 'no lock directory',
220
+ })
221
+ })
222
+
223
+ it('journals the answer when the session id cannot name a thread lock', async () => {
224
+ const wake = adapters()
225
+
226
+ const outcome = await route(wake, { threadId: 'codex-session-7' }).deliver(event)
227
+
228
+ expect(wake.probed).toEqual([])
229
+ expect(wake.resumed).toEqual([])
230
+ expect(outcome.log).toMatchObject({
231
+ route: 'hold-for-next-turn',
232
+ reason: 'the Codex session id is not a thread id',
233
+ })
234
+ })
235
+
236
+ it('does not report delivery when the cold resume fails', async () => {
237
+ const wake = adapters()
238
+ wake.resume = vi.fn(async () => {
239
+ throw new Error('codex exec exited 1')
240
+ })
241
+
242
+ await expect(route(wake).deliver(event)).rejects.toThrow('codex exec exited 1')
243
+ })
244
+ })
245
+
246
+ describe('Codex system adapters', () => {
247
+ it('reads this process as a live source and PID 0 as no source at all', () => {
248
+ const wake = systemCodexWakeAdapters({})
249
+
250
+ expect(wake.sourceAlive(process.pid)).toBe(true)
251
+ expect(wake.sourceAlive(0)).toBe(false)
252
+ })
253
+ })
@@ -0,0 +1,288 @@
1
+ import { spawn } from 'node:child_process'
2
+ import { closeSync, constants, existsSync, openSync } from 'node:fs'
3
+ import path from 'node:path'
4
+ import { configHome } from './install-hooks.js'
5
+ import {
6
+ hookContinuationRoute,
7
+ type ContinuationEvent,
8
+ type DeliveryOutcome,
9
+ type EscalationDeliveryRoute,
10
+ } from './hooks.js'
11
+
12
+ /** Codex's per-thread writer lock directory, relative to `$CODEX_HOME`. */
13
+ export const CODEX_THREAD_LOCK_DIR = 'thread-writer-locks'
14
+
15
+ /**
16
+ * BSD `open(2)` lock flags. Node does not surface them in `fs.constants`, and
17
+ * they are the only way to take a non-blocking `flock` from Node without a
18
+ * native addon: the kernel takes the lock at open and drops it at close.
19
+ */
20
+ const O_EXLOCK = 0x20
21
+ const O_NONBLOCK = 0x4
22
+
23
+ /** Platforms whose `open(2)` implements `O_EXLOCK`. Everything else fails closed. */
24
+ const LOCK_PROBE_PLATFORMS: ReadonlySet<string> = new Set(['darwin', 'freebsd', 'openbsd'])
25
+
26
+ /** Codex thread ids are UUIDs, and the lock file is named by the exact id. */
27
+ const THREAD_ID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i
28
+
29
+ export type CodexWakeObservation =
30
+ /** A live writer owns this thread. Resuming it would create a divergent ghost. */
31
+ | { state: 'live' }
32
+ /** No process holds the thread's writer lock; a cold resume can own it. */
33
+ | { state: 'stopped' }
34
+ | { state: 'unknown'; reason: string }
35
+
36
+ export interface CodexWakeAdapters {
37
+ /** Non-blocking writer-lock probe. Never holds the lock beyond the probe. */
38
+ probeThreadWriter(lockPath: string): CodexWakeObservation
39
+ /** Is the harness process that will read this hook's stdout still running? */
40
+ sourceAlive(pid: number): boolean
41
+ resume(threadId: string, cwd: string, context: string): Promise<void>
42
+ }
43
+
44
+ /**
45
+ * The one cross-process oracle for "who owns this Codex thread".
46
+ *
47
+ * Codex's thread store takes a non-blocking `flock(LOCK_EX)` on a zero-byte
48
+ * file per thread and holds it for as long as that process owns the thread —
49
+ * verified against both a hosted app-server thread sitting idle and an
50
+ * in-process `codex exec` run (2026-08-12). Because the kernel holds it, a
51
+ * crash, SIGKILL, or reboot releases it instantly, and the leftover file proves
52
+ * nothing: only the lock does. Codex fails closed itself with `-32600` when a
53
+ * second writer contends, but that is the backstop; this is the gate.
54
+ */
55
+ export function observeCodexThread(
56
+ threadId: string,
57
+ env: NodeJS.ProcessEnv,
58
+ adapters: Pick<CodexWakeAdapters, 'probeThreadWriter'>,
59
+ ): CodexWakeObservation {
60
+ if (!THREAD_ID.test(threadId)) {
61
+ return { state: 'unknown', reason: 'the Codex session id is not a thread id' }
62
+ }
63
+ try {
64
+ return adapters.probeThreadWriter(codexThreadLockPath(threadId, env))
65
+ } catch (err) {
66
+ return {
67
+ state: 'unknown',
68
+ reason: `Codex thread-writer lock probe failed: ${err instanceof Error ? err.message : String(err)}`,
69
+ }
70
+ }
71
+ }
72
+
73
+ export function codexThreadLockPath(threadId: string, env: NodeJS.ProcessEnv): string {
74
+ return path.join(codexThreadLockDirectory(env), `${threadId}.lock`)
75
+ }
76
+
77
+ export function codexThreadLockDirectory(env: NodeJS.ProcessEnv): string {
78
+ return path.join(configHome(env, 'CODEX_HOME', '.codex'), CODEX_THREAD_LOCK_DIR)
79
+ }
80
+
81
+ export type CodexResumeReadiness =
82
+ | { state: 'ready'; lockDirectory: string }
83
+ | { state: 'unavailable'; reason: string }
84
+
85
+ /**
86
+ * Whether a Codex thread whose Stop hook has already returned could still be
87
+ * cold-resumed, decided without taking any lock or spawning anything.
88
+ *
89
+ * Two things have to hold: this platform must implement the non-blocking
90
+ * `flock` the probe is built on, and `$CODEX_HOME` must actually have a
91
+ * thread-writer-lock directory — its absence is not evidence that a thread is
92
+ * unowned, so the gate fails closed and the answer waits for the next turn.
93
+ * Neither affects the ordinary path, where the hook's own stdout continues the
94
+ * held turn.
95
+ */
96
+ export function inspectCodexResume(
97
+ env: NodeJS.ProcessEnv,
98
+ options: { platform: NodeJS.Platform; directoryExists: (directory: string) => boolean },
99
+ ): CodexResumeReadiness {
100
+ const lockDirectory = codexThreadLockDirectory(env)
101
+ if (!LOCK_PROBE_PLATFORMS.has(options.platform)) {
102
+ return {
103
+ state: 'unavailable',
104
+ reason: `no non-blocking thread-writer lock probe exists on ${options.platform}`,
105
+ }
106
+ }
107
+ if (!options.directoryExists(lockDirectory)) {
108
+ return {
109
+ state: 'unavailable',
110
+ reason: `no thread-writer-lock directory at ${lockDirectory}, so no thread can be proven unowned`,
111
+ }
112
+ }
113
+ return { state: 'ready', lockDirectory }
114
+ }
115
+
116
+ /**
117
+ * Codex's answer-delivery route: the blocking Stop hook, then nothing else.
118
+ *
119
+ * The default is the hook continuation itself. While the Codex process that
120
+ * invoked this hook is alive it is reading this process's stdout, and
121
+ * `{"decision":"block"}` there creates a real new user turn — no launch
122
+ * ownership, no control plane, no probe required. That path must never depend
123
+ * on the lock probe: printing to a dead pipe cannot ghost-write anything, so
124
+ * an unreadable `$CODEX_HOME` is not a reason to withhold a continuation.
125
+ *
126
+ * Only when that process is gone does the answer need another last meter, and
127
+ * then exactly one is safe: a cold `codex exec resume` of a thread no live
128
+ * writer owns. Anything else — a live writer, an unprobeable platform, a thread
129
+ * id we cannot name — is journaled and replayed at the session's next Stop.
130
+ * That accepted gap is the honest floor of the Codex adapter: a session that
131
+ * went idle after its Stop returned, which Notifai did not launch, cannot be
132
+ * woken in place.
133
+ */
134
+ export function codexWakeRoute(options: {
135
+ threadId: string
136
+ cwd: string
137
+ sourcePid: number
138
+ env?: NodeJS.ProcessEnv
139
+ adapters?: CodexWakeAdapters
140
+ }): EscalationDeliveryRoute {
141
+ const env = options.env ?? process.env
142
+ const adapters = options.adapters ?? systemCodexWakeAdapters(env)
143
+ const continuation = hookContinuationRoute()
144
+ return {
145
+ kind: 'hook-continuation',
146
+ async deliver(event: ContinuationEvent): Promise<DeliveryOutcome> {
147
+ if (adapters.sourceAlive(options.sourcePid)) return continuation.deliver(event)
148
+
149
+ // Probe immediately before the spawn, twice, and never trust the earlier
150
+ // answer: ownership is a kernel fact that can change between two syscalls,
151
+ // and resuming a thread someone else owns is the disqualifying failure.
152
+ const observed = observeCodexThread(options.threadId, env, adapters)
153
+ const held = holdForNextTurn(observed)
154
+ if (held !== null) return held
155
+ const confirmed = observeCodexThread(options.threadId, env, adapters)
156
+ const raced = holdForNextTurn(confirmed)
157
+ if (raced !== null) return raced
158
+
159
+ await adapters.resume(options.threadId, options.cwd, event.context)
160
+ return {
161
+ notes: ['cold-resumed the stopped Codex thread with its accepted answer'],
162
+ log: { route: 'cold-resume', stage: 'delivered' },
163
+ // The resume ran: the thread holds the answer, and no later hook of this
164
+ // session will report on a turn that started elsewhere.
165
+ acknowledgement: 'delivered',
166
+ }
167
+ },
168
+ }
169
+ }
170
+
171
+ /** The journal, with the exact reason the thread could not be resumed. */
172
+ function holdForNextTurn(observation: CodexWakeObservation): DeliveryOutcome | null {
173
+ if (observation.state === 'stopped') return null
174
+ const reason =
175
+ observation.state === 'live'
176
+ ? 'a live writer owns the Codex thread and this hook can no longer continue it'
177
+ : observation.reason
178
+ return {
179
+ notes: [`holding the accepted answer for the next turn: ${reason}`],
180
+ log: { route: 'hold-for-next-turn', stage: 'queued', reason },
181
+ acknowledgement: 'held',
182
+ }
183
+ }
184
+
185
+ /**
186
+ * Take and release the writer lock in one open/close pair.
187
+ *
188
+ * The probe momentarily owns the lock when it is free, so it is held for
189
+ * microseconds and never across a spawn — a probe racing a legitimate resume
190
+ * must not be what makes that resume fail.
191
+ */
192
+ function probeThreadWriterLock(lockPath: string): CodexWakeObservation {
193
+ if (!LOCK_PROBE_PLATFORMS.has(process.platform)) {
194
+ return {
195
+ state: 'unknown',
196
+ reason: `no non-blocking thread-writer lock probe exists on ${process.platform}`,
197
+ }
198
+ }
199
+ const directory = path.dirname(lockPath)
200
+ if (!existsSync(directory)) {
201
+ // No lock directory means this is not a Codex home that has ever opened a
202
+ // thread, so its silence is not evidence that nothing owns this one.
203
+ return { state: 'unknown', reason: `no Codex thread-writer lock directory at ${directory}` }
204
+ }
205
+ let handle: number
206
+ try {
207
+ handle = openSync(lockPath, constants.O_RDONLY | O_EXLOCK | O_NONBLOCK)
208
+ } catch (err) {
209
+ const code = (err as NodeJS.ErrnoException).code
210
+ if (code === 'EAGAIN' || code === 'EWOULDBLOCK') return { state: 'live' }
211
+ // Codex unlinks the lock file when its writer drops cleanly, so a missing
212
+ // file inside a real lock directory means the thread has no owner.
213
+ if (code === 'ENOENT') return { state: 'stopped' }
214
+ return {
215
+ state: 'unknown',
216
+ reason: `Codex thread-writer lock probe failed: ${err instanceof Error ? err.message : String(err)}`,
217
+ }
218
+ }
219
+ closeSync(handle)
220
+ return { state: 'stopped' }
221
+ }
222
+
223
+ function runCodex(
224
+ args: string[],
225
+ options: { cwd: string; env: NodeJS.ProcessEnv },
226
+ ): Promise<string> {
227
+ return new Promise((resolve, reject) => {
228
+ const child = spawn('codex', args, {
229
+ cwd: options.cwd,
230
+ env: options.env,
231
+ stdio: ['ignore', 'pipe', 'pipe'],
232
+ })
233
+ const stdout: Buffer[] = []
234
+ const stderr: Buffer[] = []
235
+ child.stdout?.on('data', (chunk: Buffer | string) => stdout.push(Buffer.from(chunk)))
236
+ child.stderr?.on('data', (chunk: Buffer | string) => stderr.push(Buffer.from(chunk)))
237
+ child.once('error', reject)
238
+ child.once('exit', (code, signal) => {
239
+ if (code === 0) {
240
+ resolve(Buffer.concat(stdout).toString())
241
+ return
242
+ }
243
+ reject(
244
+ new Error(
245
+ `codex ${args[0] ?? ''} exited ${code === null ? `on ${String(signal)}` : String(code)}: ${Buffer.concat(stderr).toString().trim()}`,
246
+ ),
247
+ )
248
+ })
249
+ })
250
+ }
251
+
252
+ /**
253
+ * The resumed process must not inherit the ended session's identity, or it
254
+ * reports itself as that thread to every hook it fires. `CODEX_HOME` is the one
255
+ * exception: it names the thread store the resume has to open.
256
+ */
257
+ function coldResumeEnvironment(env: NodeJS.ProcessEnv): NodeJS.ProcessEnv {
258
+ const next = { ...env }
259
+ for (const key of Object.keys(next)) {
260
+ if (key.startsWith('CODEX_') && key !== 'CODEX_HOME') delete next[key]
261
+ }
262
+ return next
263
+ }
264
+
265
+ export function systemCodexWakeAdapters(
266
+ env: NodeJS.ProcessEnv = process.env,
267
+ ): CodexWakeAdapters {
268
+ return {
269
+ probeThreadWriter: probeThreadWriterLock,
270
+ sourceAlive(pid) {
271
+ if (!Number.isInteger(pid) || pid <= 1) return false
272
+ try {
273
+ process.kill(pid, 0)
274
+ return true
275
+ } catch (err) {
276
+ // Signalling a process we may not touch still proves it exists.
277
+ return (err as NodeJS.ErrnoException).code === 'EPERM'
278
+ }
279
+ },
280
+ async resume(threadId, cwd, context) {
281
+ if (!existsSync(cwd)) throw new Error(`Codex thread cwd no longer exists: ${cwd}`)
282
+ await runCodex(['exec', 'resume', threadId, context], {
283
+ cwd,
284
+ env: coldResumeEnvironment(env),
285
+ })
286
+ },
287
+ }
288
+ }