@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.
- package/CHANGELOG.md +37 -0
- package/README.md +3 -0
- package/dist/cli-bin.d.ts +21 -1
- package/dist/cli-bin.js +141 -10
- package/dist/cli-bin.js.map +1 -1
- package/dist/cli-update-contract.d.ts +7 -0
- package/dist/cli-update-contract.js +10 -0
- package/dist/cli-update-contract.js.map +1 -0
- package/dist/client.d.ts +6 -1
- package/dist/client.js +1 -0
- package/dist/client.js.map +1 -1
- package/dist/codex-wake.d.ts +2 -0
- package/dist/codex-wake.js +2 -1
- package/dist/codex-wake.js.map +1 -1
- package/dist/commands-auth.d.ts +15 -1
- package/dist/commands-auth.js +29 -11
- package/dist/commands-auth.js.map +1 -1
- package/dist/commands-core.d.ts +9 -2
- package/dist/commands-core.js +11 -4
- package/dist/commands-core.js.map +1 -1
- package/dist/commands-devices.d.ts +10 -1
- package/dist/commands-devices.js +17 -13
- package/dist/commands-devices.js.map +1 -1
- package/dist/commands-doctor.js +93 -49
- package/dist/commands-doctor.js.map +1 -1
- package/dist/commands-hooks.js +47 -10
- package/dist/commands-hooks.js.map +1 -1
- package/dist/commands-init.d.ts +16 -0
- package/dist/commands-init.js +254 -93
- package/dist/commands-init.js.map +1 -1
- package/dist/commands-send-support.js +3 -0
- package/dist/commands-send-support.js.map +1 -1
- package/dist/commands-send.d.ts +1 -1
- package/dist/commands-send.js +121 -35
- package/dist/commands-send.js.map +1 -1
- package/dist/commands-skill.js +72 -54
- package/dist/commands-skill.js.map +1 -1
- package/dist/commands-update.d.ts +10 -0
- package/dist/commands-update.js +141 -0
- package/dist/commands-update.js.map +1 -0
- package/dist/commands.d.ts +2 -1
- package/dist/commands.js +2 -1
- package/dist/commands.js.map +1 -1
- package/dist/hook-adapter.d.ts +2 -0
- package/dist/hook-adapter.js +12 -1
- package/dist/hook-adapter.js.map +1 -1
- package/dist/hooks.d.ts +43 -2
- package/dist/hooks.js +163 -21
- package/dist/hooks.js.map +1 -1
- package/dist/invocation-context.d.ts +1 -0
- package/dist/invocation-context.js +9 -1
- package/dist/invocation-context.js.map +1 -1
- package/dist/main-run.d.ts +1 -0
- package/dist/main-run.js +42 -0
- package/dist/main-run.js.map +1 -0
- package/dist/main.js +10 -31
- package/dist/main.js.map +1 -1
- package/dist/native-skills.d.ts +13 -3
- package/dist/native-skills.js +51 -12
- package/dist/native-skills.js.map +1 -1
- package/dist/node-floor.d.ts +19 -0
- package/dist/node-floor.js +39 -0
- package/dist/node-floor.js.map +1 -0
- package/dist/orca-session-title.d.ts +5 -4
- package/dist/orca-session-title.js +32 -19
- package/dist/orca-session-title.js.map +1 -1
- package/dist/platform.d.ts +11 -17
- package/dist/platform.js +20 -23
- package/dist/platform.js.map +1 -1
- package/dist/program.d.ts +2 -1
- package/dist/program.js +22 -5
- package/dist/program.js.map +1 -1
- package/dist/question-settlement-process.d.ts +15 -0
- package/dist/question-settlement-process.js +35 -0
- package/dist/question-settlement-process.js.map +1 -0
- package/dist/readiness.d.ts +25 -1
- package/dist/readiness.js +32 -2
- package/dist/readiness.js.map +1 -1
- package/dist/release.d.ts +5 -5
- package/dist/release.js +5 -5
- package/dist/setup-destinations.d.ts +23 -0
- package/dist/setup-destinations.js +44 -0
- package/dist/setup-destinations.js.map +1 -0
- package/dist/skill-integrity.d.ts +49 -0
- package/dist/skill-integrity.js +154 -0
- package/dist/skill-integrity.js.map +1 -0
- package/dist/skill-source/manifest.json +16 -0
- package/dist/skill-source/notifai/SKILL.md +390 -0
- package/dist/skill-source/notifai/references/harness-setup.md +220 -0
- package/package.json +11 -14
- package/src/atomic-file.ts +0 -198
- package/src/claude-wake.test.ts +0 -321
- package/src/claude-wake.ts +0 -499
- package/src/cli-bin.test.ts +0 -38
- package/src/cli-bin.ts +0 -67
- package/src/cli-release.test.ts +0 -46
- package/src/cli-release.ts +0 -78
- package/src/client.test.ts +0 -390
- package/src/client.ts +0 -345
- package/src/codex-wake.test.ts +0 -254
- package/src/codex-wake.ts +0 -297
- package/src/command-session.ts +0 -38
- package/src/commands-agent-sessions.ts +0 -72
- package/src/commands-auth.ts +0 -205
- package/src/commands-config.ts +0 -336
- package/src/commands-core.ts +0 -298
- package/src/commands-devices.test.ts +0 -15
- package/src/commands-devices.ts +0 -173
- package/src/commands-doctor.ts +0 -1440
- package/src/commands-guidance.ts +0 -181
- package/src/commands-harness-context.ts +0 -207
- package/src/commands-hook-shape.ts +0 -69
- package/src/commands-hooks.ts +0 -2268
- package/src/commands-init.ts +0 -878
- package/src/commands-io.ts +0 -108
- package/src/commands-logs.ts +0 -243
- package/src/commands-project.ts +0 -47
- package/src/commands-send-support.test.ts +0 -156
- package/src/commands-send-support.ts +0 -311
- package/src/commands-send.ts +0 -769
- package/src/commands-setup-proof.test.ts +0 -72
- package/src/commands-setup-proof.ts +0 -124
- package/src/commands-skill.test.ts +0 -44
- package/src/commands-skill.ts +0 -204
- package/src/commands-sounds.test.ts +0 -106
- package/src/commands-sounds.ts +0 -49
- package/src/commands.test.ts +0 -7828
- package/src/commands.ts +0 -75
- package/src/config-schema.test.ts +0 -153
- package/src/config-schema.ts +0 -349
- package/src/config.test.ts +0 -581
- package/src/config.ts +0 -475
- package/src/credentials.test.ts +0 -403
- package/src/credentials.ts +0 -403
- package/src/file-lock-process-worker.test.ts +0 -64
- package/src/file-lock.test.ts +0 -320
- package/src/file-lock.ts +0 -393
- package/src/fixtures/hermes-0.20.6-classic-cli-local.json +0 -50
- package/src/fixtures/openclaw-2026.7.1-2-agent-local.json +0 -57
- package/src/generated-session-label.ts +0 -155
- package/src/guidance-content.ts +0 -278
- package/src/guidance-render.ts +0 -56
- package/src/guidance.test.ts +0 -327
- package/src/guidance.ts +0 -233
- package/src/harness-integration.test.ts +0 -294
- package/src/harnesses.test.ts +0 -149
- package/src/harnesses.ts +0 -164
- package/src/hook-adapter.test.ts +0 -496
- package/src/hook-adapter.ts +0 -410
- package/src/hook-input.test.ts +0 -39
- package/src/hook-input.ts +0 -72
- package/src/hooks.test.ts +0 -5549
- package/src/hooks.ts +0 -3759
- package/src/install-hooks.test.ts +0 -1614
- package/src/install-hooks.ts +0 -1501
- package/src/interactive.test.ts +0 -387
- package/src/interactive.ts +0 -858
- package/src/invocation-context.test.ts +0 -352
- package/src/invocation-context.ts +0 -197
- package/src/logging-process-worker.test.ts +0 -45
- package/src/logging.test.ts +0 -544
- package/src/logging.ts +0 -698
- package/src/main.ts +0 -36
- package/src/native-skills.test.ts +0 -115
- package/src/native-skills.ts +0 -162
- package/src/onboarding-docs.test.ts +0 -25
- package/src/openclaw-plugin.ts +0 -409
- package/src/opencode-plugin.ts +0 -298
- package/src/orca-session-title.test.ts +0 -54
- package/src/orca-session-title.ts +0 -103
- package/src/pack-feedback-slice.test.ts +0 -212
- package/src/pack-feedback-slice.ts +0 -187
- package/src/platform.test.ts +0 -152
- package/src/platform.ts +0 -173
- package/src/program.test.ts +0 -282
- package/src/program.ts +0 -818
- package/src/project-enablement.test.ts +0 -55
- package/src/project-enablement.ts +0 -99
- package/src/question-caps.test.ts +0 -49
- package/src/question-timing.ts +0 -56
- package/src/readiness.test.ts +0 -25
- package/src/readiness.ts +0 -179
- package/src/release.ts +0 -51
- package/src/send-attempts.test.ts +0 -143
- package/src/send-attempts.ts +0 -188
- package/src/send.test.ts +0 -28
- package/src/send.ts +0 -436
- package/src/session-activation.ts +0 -61
- package/src/session-end-log-lock-worker.test.ts +0 -35
- package/src/session-labels.test.ts +0 -490
- package/src/session-labels.ts +0 -474
- package/src/skill.test.ts +0 -415
- package/src/sound-ref.test.ts +0 -31
- package/src/sound-ref.ts +0 -34
- package/src/trust-policy.test.ts +0 -701
- package/src/ui/banner.ts +0 -95
- package/src/ui/config-view.test.ts +0 -90
- package/src/ui/config-view.ts +0 -138
- package/src/ui/help.test.ts +0 -16
- package/src/ui/help.ts +0 -128
- package/src/ui/theme.test.ts +0 -92
- package/src/ui/theme.ts +0 -265
- package/src/url-policy.ts +0 -399
- 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
|
-
}
|
package/src/codex-wake.test.ts
DELETED
|
@@ -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
|
-
})
|