@raidiant/notifai 10.1.1 → 10.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.
Files changed (156) hide show
  1. package/CHANGELOG.md +20 -0
  2. package/dist/commands-core.d.ts +1 -1
  3. package/dist/commands-init.js +9 -6
  4. package/dist/commands-init.js.map +1 -1
  5. package/dist/commands-send-support.js +3 -0
  6. package/dist/commands-send-support.js.map +1 -1
  7. package/dist/commands-skill.js +72 -54
  8. package/dist/commands-skill.js.map +1 -1
  9. package/dist/invocation-context.d.ts +1 -0
  10. package/dist/invocation-context.js +9 -1
  11. package/dist/invocation-context.js.map +1 -1
  12. package/dist/native-skills.d.ts +13 -3
  13. package/dist/native-skills.js +51 -12
  14. package/dist/native-skills.js.map +1 -1
  15. package/dist/node-floor.d.ts +2 -1
  16. package/dist/node-floor.js +9 -5
  17. package/dist/node-floor.js.map +1 -1
  18. package/dist/orca-session-title.d.ts +5 -4
  19. package/dist/orca-session-title.js +32 -19
  20. package/dist/orca-session-title.js.map +1 -1
  21. package/dist/platform.d.ts +11 -17
  22. package/dist/platform.js +20 -23
  23. package/dist/platform.js.map +1 -1
  24. package/dist/release.d.ts +5 -5
  25. package/dist/release.js +5 -5
  26. package/dist/setup-destinations.js +1 -1
  27. package/dist/setup-destinations.js.map +1 -1
  28. package/dist/skill-integrity.d.ts +49 -0
  29. package/dist/skill-integrity.js +154 -0
  30. package/dist/skill-integrity.js.map +1 -0
  31. package/dist/skill-source/manifest.json +16 -0
  32. package/dist/skill-source/notifai/SKILL.md +390 -0
  33. package/dist/skill-source/notifai/references/harness-setup.md +220 -0
  34. package/package.json +11 -14
  35. package/src/atomic-file.ts +0 -198
  36. package/src/claude-wake.test.ts +0 -321
  37. package/src/claude-wake.ts +0 -499
  38. package/src/cli-bin.test.ts +0 -176
  39. package/src/cli-bin.ts +0 -233
  40. package/src/cli-release.test.ts +0 -46
  41. package/src/cli-release.ts +0 -78
  42. package/src/cli-update-contract.ts +0 -9
  43. package/src/cli-update.test.ts +0 -125
  44. package/src/client.test.ts +0 -390
  45. package/src/client.ts +0 -352
  46. package/src/codex-wake.test.ts +0 -254
  47. package/src/codex-wake.ts +0 -301
  48. package/src/command-session.ts +0 -38
  49. package/src/commands-agent-sessions.ts +0 -72
  50. package/src/commands-auth.ts +0 -238
  51. package/src/commands-config.ts +0 -336
  52. package/src/commands-core.ts +0 -312
  53. package/src/commands-devices.ts +0 -180
  54. package/src/commands-doctor.ts +0 -1501
  55. package/src/commands-guidance.ts +0 -181
  56. package/src/commands-harness-context.ts +0 -207
  57. package/src/commands-hook-shape.ts +0 -69
  58. package/src/commands-hooks.ts +0 -2318
  59. package/src/commands-init.ts +0 -1055
  60. package/src/commands-io.ts +0 -108
  61. package/src/commands-logs.ts +0 -243
  62. package/src/commands-project.ts +0 -47
  63. package/src/commands-send-support.test.ts +0 -156
  64. package/src/commands-send-support.ts +0 -311
  65. package/src/commands-send.ts +0 -875
  66. package/src/commands-setup-proof.test.ts +0 -72
  67. package/src/commands-setup-proof.ts +0 -124
  68. package/src/commands-skill.test.ts +0 -44
  69. package/src/commands-skill.ts +0 -204
  70. package/src/commands-sounds.test.ts +0 -106
  71. package/src/commands-sounds.ts +0 -49
  72. package/src/commands-update.ts +0 -180
  73. package/src/commands.test.ts +0 -8297
  74. package/src/commands.ts +0 -77
  75. package/src/config-schema.test.ts +0 -153
  76. package/src/config-schema.ts +0 -349
  77. package/src/config.test.ts +0 -581
  78. package/src/config.ts +0 -475
  79. package/src/credentials.test.ts +0 -403
  80. package/src/credentials.ts +0 -403
  81. package/src/file-lock-process-worker.test.ts +0 -64
  82. package/src/file-lock.test.ts +0 -320
  83. package/src/file-lock.ts +0 -393
  84. package/src/fixtures/hermes-0.20.6-classic-cli-local.json +0 -50
  85. package/src/fixtures/openclaw-2026.7.1-2-agent-local.json +0 -57
  86. package/src/generated-session-label.ts +0 -155
  87. package/src/guidance-content.ts +0 -278
  88. package/src/guidance-render.ts +0 -56
  89. package/src/guidance.test.ts +0 -327
  90. package/src/guidance.ts +0 -233
  91. package/src/harness-integration.test.ts +0 -294
  92. package/src/harnesses.test.ts +0 -149
  93. package/src/harnesses.ts +0 -164
  94. package/src/hook-adapter.test.ts +0 -496
  95. package/src/hook-adapter.ts +0 -424
  96. package/src/hook-input.test.ts +0 -39
  97. package/src/hook-input.ts +0 -72
  98. package/src/hooks.test.ts +0 -5824
  99. package/src/hooks.ts +0 -4011
  100. package/src/install-hooks.test.ts +0 -1614
  101. package/src/install-hooks.ts +0 -1501
  102. package/src/interactive.test.ts +0 -387
  103. package/src/interactive.ts +0 -858
  104. package/src/invocation-context.test.ts +0 -352
  105. package/src/invocation-context.ts +0 -197
  106. package/src/logging-process-worker.test.ts +0 -45
  107. package/src/logging.test.ts +0 -544
  108. package/src/logging.ts +0 -698
  109. package/src/main-run.ts +0 -45
  110. package/src/main.ts +0 -12
  111. package/src/native-skills.test.ts +0 -115
  112. package/src/native-skills.ts +0 -162
  113. package/src/node-floor.test.ts +0 -27
  114. package/src/node-floor.ts +0 -37
  115. package/src/onboarding-docs.test.ts +0 -25
  116. package/src/openclaw-plugin.ts +0 -409
  117. package/src/opencode-plugin.ts +0 -298
  118. package/src/orca-session-title.test.ts +0 -54
  119. package/src/orca-session-title.ts +0 -103
  120. package/src/pack-feedback-slice.test.ts +0 -212
  121. package/src/pack-feedback-slice.ts +0 -187
  122. package/src/platform.test.ts +0 -152
  123. package/src/platform.ts +0 -173
  124. package/src/program.test.ts +0 -317
  125. package/src/program.ts +0 -842
  126. package/src/project-enablement.test.ts +0 -55
  127. package/src/project-enablement.ts +0 -99
  128. package/src/question-caps.test.ts +0 -49
  129. package/src/question-settlement-process.ts +0 -47
  130. package/src/question-timing.ts +0 -56
  131. package/src/readiness.test.ts +0 -25
  132. package/src/readiness.ts +0 -212
  133. package/src/release.ts +0 -51
  134. package/src/send-attempts.test.ts +0 -143
  135. package/src/send-attempts.ts +0 -188
  136. package/src/send.test.ts +0 -28
  137. package/src/send.ts +0 -436
  138. package/src/session-activation.ts +0 -61
  139. package/src/session-end-log-lock-worker.test.ts +0 -35
  140. package/src/session-labels.test.ts +0 -490
  141. package/src/session-labels.ts +0 -474
  142. package/src/setup-destinations.test.ts +0 -41
  143. package/src/setup-destinations.ts +0 -50
  144. package/src/skill.test.ts +0 -426
  145. package/src/sound-ref.test.ts +0 -31
  146. package/src/sound-ref.ts +0 -34
  147. package/src/trust-policy.test.ts +0 -701
  148. package/src/ui/banner.ts +0 -95
  149. package/src/ui/config-view.test.ts +0 -90
  150. package/src/ui/config-view.ts +0 -138
  151. package/src/ui/help.test.ts +0 -16
  152. package/src/ui/help.ts +0 -128
  153. package/src/ui/theme.test.ts +0 -92
  154. package/src/ui/theme.ts +0 -265
  155. package/src/url-policy.ts +0 -399
  156. package/tsconfig.json +0 -10
package/src/client.ts DELETED
@@ -1,352 +0,0 @@
1
- import type {
2
- ApiErrorBody,
3
- AccountAccessRequestResponse,
4
- AccountAccessResponse,
5
- BeginPairingResponse,
6
- CapabilityDocument,
7
- CreateMediaUploadRequestT,
8
- CreateMediaUploadResponse,
9
- FinalizeMediaUploadResponse,
10
- EvidenceSnapshot,
11
- GetAgentAcknowledgementResponse,
12
- ListDevicesResponse,
13
- ListSoundsResponse,
14
- ClientCapability,
15
- CompatibilityResponse,
16
- RecoveryAction,
17
- ListRepliesResponse,
18
- Platform,
19
- PollPairingResponse,
20
- PutAgentAcknowledgementRequestT,
21
- PutAgentAcknowledgementResponse,
22
- PutAgentSessionLabelRequestT,
23
- AgentSessionView,
24
- SubmissionReceipt,
25
- SubmitNotificationRequestT,
26
- } from '@raidiant/notifai-protocol'
27
- import {
28
- CAPABILITIES_HEADER,
29
- CLI_VERSION_HEADER,
30
- } from '@raidiant/notifai-protocol'
31
- import type { Logger } from './logging.js'
32
-
33
- export class ApiCallError extends Error {
34
- constructor(
35
- public status: number,
36
- public code: string,
37
- message: string,
38
- public nextAction: string | null = null,
39
- public details: unknown = null,
40
- public recoveryAction: RecoveryAction | null = null,
41
- ) {
42
- super(message)
43
- }
44
- }
45
-
46
- export class NetworkError extends Error {}
47
-
48
- /**
49
- * Whether a replies-poll failure is temporary while the durable request stays
50
- * live. Transport faults, throttling, request timeouts, and server failures
51
- * can recover on a later poll; client errors cannot be repaired by retrying
52
- * the same authenticated request.
53
- */
54
- export function isRetryableReplyPollError(err: unknown): boolean {
55
- if (err instanceof NetworkError) return true
56
- if (!(err instanceof ApiCallError)) return false
57
- return err.status >= 500 || err.status === 429 || err.status === 408
58
- }
59
-
60
- /**
61
- * A deadline that fired reads as an abort, which says nothing useful on its
62
- * own. Naming the timeout is what lets someone tell a hung server apart from a
63
- * refused connection.
64
- */
65
- function networkFailure(err: unknown, root: string, limitMs: number): NetworkError {
66
- const name = err instanceof Error ? err.name : ''
67
- if (name === 'TimeoutError' || name === 'AbortError') {
68
- return new NetworkError(`${root} did not respond within ${Math.round(limitMs / 1000)}s`)
69
- }
70
- return new NetworkError(`Could not reach ${root}: ${String(err)}`)
71
- }
72
-
73
- export interface ApiClient {
74
- beginPairing(body: {
75
- machine_name: string
76
- credential_hash: string
77
- poll_verifier_hash: string
78
- confirmation_hash: string
79
- }): Promise<BeginPairingResponse>
80
- pollPairing(pairingId: string, pollVerifier: string): Promise<PollPairingResponse>
81
- accessStatus(): Promise<AccountAccessResponse>
82
- /**
83
- * Read-only. There is no client-side write: filing this errand is the
84
- * User's decision, and the server refuses it from a machine credential.
85
- */
86
- accessRequest(): Promise<AccountAccessRequestResponse>
87
- listDevices(): Promise<ListDevicesResponse>
88
- listSounds(): Promise<ListSoundsResponse>
89
- capabilities(
90
- platform?: Platform,
91
- appVersion?: string,
92
- appBuild?: string,
93
- ): Promise<CapabilityDocument>
94
- compatibility(): Promise<CompatibilityResponse>
95
- submit(body: SubmitNotificationRequestT, waitSeconds: number): Promise<SubmissionReceipt>
96
- evidence(requestId: string): Promise<EvidenceSnapshot>
97
- replies(
98
- requestId: string,
99
- options: { waitSeconds: number; afterSeq: number },
100
- ): Promise<ListRepliesResponse>
101
- /** Retire a question and return the replies committed before the close fence. */
102
- closeReplies(requestId: string): Promise<ListRepliesResponse>
103
- putAgentAcknowledgement(
104
- requestId: string,
105
- body: PutAgentAcknowledgementRequestT,
106
- ): Promise<PutAgentAcknowledgementResponse>
107
- putAgentSessionLabel(body: PutAgentSessionLabelRequestT): Promise<AgentSessionView>
108
- agentAcknowledgement(
109
- requestId: string,
110
- options: { waitSeconds: number },
111
- ): Promise<GetAgentAcknowledgementResponse>
112
- createMediaUpload(body: CreateMediaUploadRequestT): Promise<CreateMediaUploadResponse>
113
- finalizeMediaUpload(mediaId: string): Promise<FinalizeMediaUploadResponse>
114
- uploadMedia(grant: CreateMediaUploadResponse, bytes: Uint8Array): Promise<void>
115
- health(): Promise<boolean>
116
- }
117
-
118
- export interface ClientOptions {
119
- /**
120
- * Ceiling for a single request, on top of any long poll the server has been
121
- * asked to hold. Callers running inside a harness hook shrink this so their
122
- * whole network path stays inside the budget the harness allows them.
123
- */
124
- timeoutMs?: number
125
- /**
126
- * Absolute wall-clock ceiling for the caller's whole operation. This is
127
- * intentionally separate from `timeoutMs`: a late long poll must not get a
128
- * fresh per-request allowance after its owning harness hook is nearly done.
129
- */
130
- deadlineAt?: number
131
- /** Test seam for `deadlineAt`; production uses the wall clock. */
132
- now?: () => number
133
- /**
134
- * Records each call locally. Worth having because the two failures that cost
135
- * the most time — a server older than this CLI, and a network path that only
136
- * fails inside a hook — both look identical from outside and are told apart
137
- * instantly by a status code and a duration.
138
- */
139
- logger?: Pick<Logger, 'debug' | 'error'>
140
- /** Artifact identity and named jobs advertised only on authenticated traffic. */
141
- cliVersion?: string | null
142
- capabilities?: readonly ClientCapability[]
143
- }
144
-
145
- /** Generous enough for a slow link, short enough that nothing hangs for ever. */
146
- const DEFAULT_TIMEOUT_MS = 20_000
147
-
148
- export function createClient(
149
- baseUrl: string,
150
- bearer: string | null,
151
- options: ClientOptions = {},
152
- ): ApiClient {
153
- const root = baseUrl.replace(/\/$/, '')
154
- const budgetMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS
155
- const now = options.now ?? Date.now
156
- const logger = options.logger
157
- const clientHeaders: Record<string, string> = bearer
158
- ? {
159
- ...(options.cliVersion ? { [CLI_VERSION_HEADER]: options.cliVersion } : {}),
160
- ...(options.capabilities
161
- ? { [CAPABILITIES_HEADER]: options.capabilities.join(',') }
162
- : {}),
163
- }
164
- : {}
165
-
166
- async function call<T>(
167
- method: string,
168
- apiPath: string,
169
- body?: unknown,
170
- /** Seconds the server was asked to hold the connection open. */
171
- serverWaitSeconds = 0,
172
- ): Promise<T> {
173
- const startedAt = Date.now()
174
- try {
175
- const result = await callOnce<T>(method, apiPath, body, serverWaitSeconds)
176
- logger?.debug('http.call', {
177
- method,
178
- path: apiPath,
179
- ok: true,
180
- duration_ms: Date.now() - startedAt,
181
- })
182
- return result
183
- } catch (err) {
184
- logger?.error('http.call', {
185
- method,
186
- path: apiPath,
187
- ok: false,
188
- duration_ms: Date.now() - startedAt,
189
- ...(err instanceof ApiCallError
190
- ? { status: err.status, code: err.code, message: err.message, details: err.details }
191
- : { message: String(err) }),
192
- })
193
- throw err
194
- }
195
- }
196
-
197
- async function callOnce<T>(
198
- method: string,
199
- apiPath: string,
200
- body?: unknown,
201
- serverWaitSeconds = 0,
202
- ): Promise<T> {
203
- // Without this, a server that accepts the connection and then never answers
204
- // hangs until the harness kills the whole hook — and the reply-poll deadline
205
- // cannot interrupt an individual fetch. The signal covers the
206
- // body read too, not just the response headers.
207
- const requestBudgetMs = budgetMs + serverWaitSeconds * 1000
208
- const remainingMs =
209
- options.deadlineAt === undefined
210
- ? requestBudgetMs
211
- : Math.max(1, options.deadlineAt - now())
212
- const limitMs = Math.max(1, Math.min(requestBudgetMs, remainingMs))
213
- const signal = AbortSignal.timeout(limitMs)
214
- let response: Response
215
- try {
216
- response = await fetch(`${root}${apiPath}`, {
217
- method,
218
- headers: {
219
- ...(body !== undefined ? { 'content-type': 'application/json' } : {}),
220
- ...(bearer ? { authorization: bearer } : {}),
221
- ...clientHeaders,
222
- },
223
- signal,
224
- ...(bearer ? { redirect: 'error' as const } : {}),
225
- ...(body !== undefined ? { body: JSON.stringify(body) } : {}),
226
- })
227
- } catch (err) {
228
- throw networkFailure(err, root, limitMs)
229
- }
230
- if (!response.ok) {
231
- let parsed: ApiErrorBody | null = null
232
- try {
233
- parsed = (await response.json()) as ApiErrorBody
234
- } catch {
235
- // non-JSON body
236
- }
237
- throw new ApiCallError(
238
- response.status,
239
- parsed?.error.code ?? 'internal_error',
240
- parsed?.error.message ?? `Request failed with status ${response.status}`,
241
- parsed?.error.next_action ?? null,
242
- parsed?.error.details ?? null,
243
- parsed?.error.recovery_action ?? null,
244
- )
245
- }
246
- if (response.status === 204) return undefined as T
247
- try {
248
- return (await response.json()) as T
249
- } catch (err) {
250
- // A truncated or abandoned body is a transport failure, not a protocol
251
- // one: it must be retryable like any other, not surface as a raw abort.
252
- throw networkFailure(err, root, limitMs)
253
- }
254
- }
255
-
256
- return {
257
- beginPairing: (body) => call('POST', '/api/v1/pairings', body),
258
- pollPairing: (pairingId, pollVerifier) =>
259
- call('POST', `/api/v1/pairings/${encodeURIComponent(pairingId)}/poll`, {
260
- poll_verifier: pollVerifier,
261
- }),
262
- accessStatus: () => call('GET', '/api/v1/account/access'),
263
- accessRequest: () => call('GET', '/api/v1/account/alpha-access-request'),
264
- listDevices: () => call('GET', '/api/v1/devices'),
265
- listSounds: () => call('GET', '/api/v1/sounds'),
266
- capabilities: (platform = 'ios', appVersion, appBuild) => {
267
- const query = new URLSearchParams()
268
- if (appVersion !== undefined) query.set('app_version', appVersion)
269
- if (appBuild !== undefined) query.set('app_build', appBuild)
270
- const suffix = query.size > 0 ? `?${query.toString()}` : ''
271
- return call('GET', `/api/v1/capabilities/${encodeURIComponent(platform)}${suffix}`)
272
- },
273
- compatibility: () => call('GET', '/api/v1/compatibility'),
274
- submit: (body, waitSeconds) =>
275
- call('POST', `/api/v1/notifications?wait_seconds=${waitSeconds}`, body, waitSeconds),
276
- evidence: (requestId) => call('GET', `/api/v1/notifications/${encodeURIComponent(requestId)}`),
277
- replies: (requestId, { waitSeconds, afterSeq }) =>
278
- call(
279
- 'GET',
280
- `/api/v1/notifications/${encodeURIComponent(requestId)}/replies?wait_seconds=${waitSeconds}&after_seq=${afterSeq}`,
281
- undefined,
282
- waitSeconds,
283
- ),
284
- closeReplies: (requestId) =>
285
- call<ListRepliesResponse>(
286
- 'POST',
287
- `/api/v1/notifications/${encodeURIComponent(requestId)}/replies/close`,
288
- ),
289
- putAgentAcknowledgement: (requestId, body) =>
290
- call<PutAgentAcknowledgementResponse>(
291
- 'PUT',
292
- `/api/v1/notifications/${encodeURIComponent(requestId)}/agent-acknowledgement`,
293
- body,
294
- ),
295
- putAgentSessionLabel: (body) =>
296
- call<AgentSessionView>('PUT', '/api/v1/agent-sessions/label', body),
297
- agentAcknowledgement: (requestId, { waitSeconds }) =>
298
- call<GetAgentAcknowledgementResponse>(
299
- 'GET',
300
- `/api/v1/notifications/${encodeURIComponent(requestId)}/agent-acknowledgement?wait_seconds=${waitSeconds}`,
301
- undefined,
302
- waitSeconds,
303
- ),
304
- createMediaUpload: (body) => call('POST', '/api/v1/media', body),
305
- // Storage-observed bytes become authoritative before a send can reference
306
- // the upload; the caller's declared size is only a request.
307
- finalizeMediaUpload: (mediaId) =>
308
- call('POST', `/api/v1/media/${encodeURIComponent(mediaId)}/finalize`),
309
- uploadMedia: async (grant, bytes) => {
310
- let response: Response
311
- try {
312
- response = await fetch(grant.upload_url, {
313
- method: 'PUT',
314
- headers: grant.upload_headers,
315
- body: bytes,
316
- // Media can be large, so this gets its own allowance rather than the
317
- // per-request budget — but still a finite one.
318
- signal: AbortSignal.timeout(Math.max(budgetMs, 60_000)),
319
- // The grant names one exact destination and carries signed headers.
320
- // A redirect would replay both against an origin the server never
321
- // granted, so it is refused rather than followed.
322
- redirect: 'manual',
323
- })
324
- } catch (err) {
325
- throw new NetworkError(`Upload failed: ${String(err)}`)
326
- }
327
- if (response.status >= 300 && response.status < 400) {
328
- throw new NetworkError(
329
- `Upload redirect is refused, not followed: the grant's headers are signed for one exact destination`,
330
- )
331
- }
332
- if (!response.ok) {
333
- throw new NetworkError(`Upload rejected with status ${response.status}`)
334
- }
335
- // A successful storage PUT is not authorization to reference the media.
336
- // The idempotent finalize call makes the server inspect provider metadata
337
- // and commit authoritative quota before the command can use media_id.
338
- await call<FinalizeMediaUploadResponse>(
339
- 'POST',
340
- `/api/v1/media/${encodeURIComponent(grant.media_id)}/finalize`,
341
- )
342
- },
343
- health: async () => {
344
- try {
345
- await call('GET', '/healthz')
346
- return true
347
- } catch {
348
- return false
349
- }
350
- },
351
- }
352
- }
@@ -1,254 +0,0 @@
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
- commitDelivery: () => true,
25
- }
26
-
27
- const temporaries: string[] = []
28
-
29
- function codexHome(): string {
30
- const home = mkdtempSync(path.join(os.tmpdir(), 'notifai-codex-wake-'))
31
- temporaries.push(home)
32
- mkdirSync(path.join(home, CODEX_THREAD_LOCK_DIR), { recursive: true })
33
- return home
34
- }
35
-
36
- afterAll(() => {
37
- for (const directory of temporaries) rmSync(directory, { recursive: true, force: true })
38
- })
39
-
40
- function adapters(
41
- options: {
42
- sourceAlive?: boolean
43
- probes?: CodexWakeObservation[]
44
- probe?: CodexWakeObservation
45
- } = {},
46
- ): CodexWakeAdapters & { resumed: Array<{ threadId: string; cwd: string; context: string }>; probed: string[] } {
47
- const resumed: Array<{ threadId: string; cwd: string; context: string }> = []
48
- const probed: string[] = []
49
- const sequence = [...(options.probes ?? [])]
50
- return {
51
- resumed,
52
- probed,
53
- probeThreadWriter(lockPath) {
54
- probed.push(lockPath)
55
- return sequence.shift() ?? options.probe ?? { state: 'stopped' }
56
- },
57
- sourceAlive() {
58
- return options.sourceAlive ?? false
59
- },
60
- async resume(threadId, cwd, context) {
61
- resumed.push({ threadId, cwd, context })
62
- },
63
- }
64
- }
65
-
66
- function route(
67
- wake: CodexWakeAdapters,
68
- overrides: { threadId?: string; env?: NodeJS.ProcessEnv } = {},
69
- ): ReturnType<typeof codexWakeRoute> {
70
- return codexWakeRoute({
71
- threadId: overrides.threadId ?? THREAD_ID,
72
- cwd: '/tmp/notifai-codex-wake',
73
- sourcePid: 4242,
74
- env: overrides.env ?? { CODEX_HOME: '/tmp/notifai-codex-home' },
75
- adapters: wake,
76
- })
77
- }
78
-
79
- describe('Codex thread ownership', () => {
80
- it('names the lock file Codex itself keys by thread id', () => {
81
- expect(codexThreadLockPath(THREAD_ID, { CODEX_HOME: '/tmp/cx' })).toBe(
82
- `/tmp/cx/${CODEX_THREAD_LOCK_DIR}/${THREAD_ID}.lock`,
83
- )
84
- })
85
-
86
- it('refuses to derive a lock path from a session id that is not a thread id', () => {
87
- const wake = adapters()
88
-
89
- expect(observeCodexThread('../../etc/passwd', {}, wake)).toEqual({
90
- state: 'unknown',
91
- reason: 'the Codex session id is not a thread id',
92
- })
93
- expect(wake.probed).toEqual([])
94
- })
95
-
96
- it('reports a probe that throws as unknown rather than as an unowned thread', () => {
97
- const observation = observeCodexThread(THREAD_ID, { CODEX_HOME: '/tmp/cx' }, {
98
- probeThreadWriter() {
99
- throw new Error('permission denied')
100
- },
101
- })
102
-
103
- expect(observation).toEqual({
104
- state: 'unknown',
105
- reason: 'Codex thread-writer lock probe failed: permission denied',
106
- })
107
- })
108
- })
109
-
110
- describe('Codex writer-lock probe', () => {
111
- const platform = process.platform
112
- const supported = platform === 'darwin' || platform === 'freebsd' || platform === 'openbsd'
113
-
114
- it.runIf(supported)('reads a kernel-held lock as a live writer and its release as stopped', () => {
115
- const home = codexHome()
116
- const lock = codexThreadLockPath(THREAD_ID, { CODEX_HOME: home })
117
- writeFileSync(lock, '')
118
- const probe = systemCodexWakeAdapters({ CODEX_HOME: home }).probeThreadWriter
119
-
120
- // flock is held per open file description, so this process contends with
121
- // itself exactly as a separate Codex process would.
122
- const held = openSync(lock, 0x20 | 0x4)
123
- try {
124
- expect(probe(lock)).toEqual({ state: 'live' })
125
- } finally {
126
- closeSync(held)
127
- }
128
-
129
- expect(probe(lock)).toEqual({ state: 'stopped' })
130
- })
131
-
132
- it.runIf(supported)('treats a swept lock file inside a real lock directory as stopped', () => {
133
- const home = codexHome()
134
- const probe = systemCodexWakeAdapters({ CODEX_HOME: home }).probeThreadWriter
135
-
136
- expect(probe(codexThreadLockPath(THREAD_ID, { CODEX_HOME: home }))).toEqual({
137
- state: 'stopped',
138
- })
139
- })
140
-
141
- it.runIf(supported)('never claims a thread is unowned when there is no lock directory', () => {
142
- const home = mkdtempSync(path.join(os.tmpdir(), 'notifai-codex-nohome-'))
143
- temporaries.push(home)
144
- const probe = systemCodexWakeAdapters({ CODEX_HOME: home }).probeThreadWriter
145
- const lock = codexThreadLockPath(THREAD_ID, { CODEX_HOME: home })
146
-
147
- expect(probe(lock)).toMatchObject({ state: 'unknown' })
148
- })
149
-
150
- it.runIf(!supported)('fails closed where no non-blocking lock probe exists', () => {
151
- const probe = systemCodexWakeAdapters({ CODEX_HOME: '/tmp/cx' }).probeThreadWriter
152
-
153
- expect(probe(`/tmp/cx/${CODEX_THREAD_LOCK_DIR}/${THREAD_ID}.lock`)).toMatchObject({
154
- state: 'unknown',
155
- })
156
- })
157
- })
158
-
159
- describe('Codex wake delivery', () => {
160
- it('continues the live session through its own Stop hook without probing anything', async () => {
161
- const wake = adapters({ sourceAlive: true })
162
-
163
- const outcome = await route(wake).deliver(event)
164
-
165
- expect(JSON.parse(outcome.stdout!)).toEqual({ decision: 'block', reason: event.context })
166
- expect(wake.probed).toEqual([])
167
- expect(wake.resumed).toEqual([])
168
- })
169
-
170
- it('cold-resumes a stopped thread only after two probes both find no writer', async () => {
171
- const wake = adapters({ probes: [{ state: 'stopped' }, { state: 'stopped' }] })
172
-
173
- const outcome = await route(wake).deliver(event)
174
-
175
- expect(wake.probed).toEqual([
176
- `/tmp/notifai-codex-home/${CODEX_THREAD_LOCK_DIR}/${THREAD_ID}.lock`,
177
- `/tmp/notifai-codex-home/${CODEX_THREAD_LOCK_DIR}/${THREAD_ID}.lock`,
178
- ])
179
- expect(wake.resumed).toEqual([
180
- { threadId: THREAD_ID, cwd: '/tmp/notifai-codex-wake', context: event.context },
181
- ])
182
- expect(outcome.stdout).toBeUndefined()
183
- expect(outcome.log).toEqual({ route: 'cold-resume', stage: 'delivered' })
184
- })
185
-
186
- it('never resumes a thread a live writer owns', async () => {
187
- const wake = adapters({ probe: { state: 'live' } })
188
-
189
- const outcome = await route(wake).deliver(event)
190
-
191
- expect(wake.resumed).toEqual([])
192
- expect(outcome.stdout).toBeUndefined()
193
- expect(outcome.log).toEqual({
194
- route: 'hold-for-next-turn',
195
- stage: 'queued',
196
- reason: 'a live writer owns the Codex thread and this hook can no longer continue it',
197
- })
198
- expect(outcome.notes.join('\n')).toContain('holding the accepted answer for the next turn')
199
- })
200
-
201
- it('never resumes a thread whose writer took the lock between the two probes', async () => {
202
- const wake = adapters({ probes: [{ state: 'stopped' }, { state: 'live' }] })
203
-
204
- const outcome = await route(wake).deliver(event)
205
-
206
- expect(wake.probed).toHaveLength(2)
207
- expect(wake.resumed).toEqual([])
208
- expect(outcome.log).toMatchObject({ route: 'hold-for-next-turn', stage: 'queued' })
209
- })
210
-
211
- it('journals the answer when ownership cannot be probed at all', async () => {
212
- const wake = adapters({ probe: { state: 'unknown', reason: 'no lock directory' } })
213
-
214
- const outcome = await route(wake).deliver(event)
215
-
216
- expect(wake.resumed).toEqual([])
217
- expect(outcome.log).toEqual({
218
- route: 'hold-for-next-turn',
219
- stage: 'queued',
220
- reason: 'no lock directory',
221
- })
222
- })
223
-
224
- it('journals the answer when the session id cannot name a thread lock', async () => {
225
- const wake = adapters()
226
-
227
- const outcome = await route(wake, { threadId: 'codex-session-7' }).deliver(event)
228
-
229
- expect(wake.probed).toEqual([])
230
- expect(wake.resumed).toEqual([])
231
- expect(outcome.log).toMatchObject({
232
- route: 'hold-for-next-turn',
233
- reason: 'the Codex session id is not a thread id',
234
- })
235
- })
236
-
237
- it('does not report delivery when the cold resume fails', async () => {
238
- const wake = adapters()
239
- wake.resume = vi.fn(async () => {
240
- throw new Error('codex exec exited 1')
241
- })
242
-
243
- await expect(route(wake).deliver(event)).rejects.toThrow('codex exec exited 1')
244
- })
245
- })
246
-
247
- describe('Codex system adapters', () => {
248
- it('reads this process as a live source and PID 0 as no source at all', () => {
249
- const wake = systemCodexWakeAdapters({})
250
-
251
- expect(wake.sourceAlive(process.pid)).toBe(true)
252
- expect(wake.sourceAlive(0)).toBe(false)
253
- })
254
- })