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