@raidiant/notifai 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (54) hide show
  1. package/LICENSE +202 -0
  2. package/README.md +76 -0
  3. package/dist/client.d.ts +40 -0
  4. package/dist/client.js +118 -0
  5. package/dist/client.js.map +1 -0
  6. package/dist/commands.d.ts +225 -0
  7. package/dist/commands.js +1855 -0
  8. package/dist/commands.js.map +1 -0
  9. package/dist/config.d.ts +138 -0
  10. package/dist/config.js +224 -0
  11. package/dist/config.js.map +1 -0
  12. package/dist/credentials.d.ts +31 -0
  13. package/dist/credentials.js +94 -0
  14. package/dist/credentials.js.map +1 -0
  15. package/dist/hooks.d.ts +250 -0
  16. package/dist/hooks.js +792 -0
  17. package/dist/hooks.js.map +1 -0
  18. package/dist/idle.d.ts +16 -0
  19. package/dist/idle.js +44 -0
  20. package/dist/idle.js.map +1 -0
  21. package/dist/install-hooks.d.ts +194 -0
  22. package/dist/install-hooks.js +579 -0
  23. package/dist/install-hooks.js.map +1 -0
  24. package/dist/main.d.ts +2 -0
  25. package/dist/main.js +259 -0
  26. package/dist/main.js.map +1 -0
  27. package/dist/opencode-plugin.d.ts +65 -0
  28. package/dist/opencode-plugin.js +181 -0
  29. package/dist/opencode-plugin.js.map +1 -0
  30. package/dist/readiness.d.ts +87 -0
  31. package/dist/readiness.js +40 -0
  32. package/dist/readiness.js.map +1 -0
  33. package/dist/send.d.ts +86 -0
  34. package/dist/send.js +202 -0
  35. package/dist/send.js.map +1 -0
  36. package/package.json +56 -0
  37. package/src/client.test.ts +83 -0
  38. package/src/client.ts +183 -0
  39. package/src/commands.test.ts +1199 -0
  40. package/src/commands.ts +2201 -0
  41. package/src/config.test.ts +243 -0
  42. package/src/config.ts +347 -0
  43. package/src/credentials.ts +119 -0
  44. package/src/hooks.test.ts +1030 -0
  45. package/src/hooks.ts +1099 -0
  46. package/src/idle.test.ts +32 -0
  47. package/src/idle.ts +41 -0
  48. package/src/install-hooks.test.ts +500 -0
  49. package/src/install-hooks.ts +708 -0
  50. package/src/main.ts +315 -0
  51. package/src/opencode-plugin.ts +203 -0
  52. package/src/readiness.ts +88 -0
  53. package/src/send.ts +272 -0
  54. package/tsconfig.json +10 -0
package/dist/send.d.ts ADDED
@@ -0,0 +1,86 @@
1
+ import { type LifecycleT, type NotificationDraftT, type Platform, type SubmissionReceipt } from '@raidiant/notifai-protocol';
2
+ import type { CliConfig } from './config.js';
3
+ export interface SendFlags {
4
+ title: string;
5
+ body: string;
6
+ subtitle?: string;
7
+ /** Long-form markdown for the app's detail view; never on the banner. */
8
+ detail?: string;
9
+ event?: string;
10
+ /** What this notification is: update (default), done, or question. */
11
+ kind?: string;
12
+ project?: string;
13
+ session?: string;
14
+ device?: string[];
15
+ all?: boolean;
16
+ ttl?: number;
17
+ collapseKey?: string;
18
+ sound?: string;
19
+ badge?: number;
20
+ threadId?: string;
21
+ level?: string;
22
+ relevance?: number;
23
+ targetContentId?: string;
24
+ data?: string[];
25
+ image?: string;
26
+ /** Enable the inline reply action for this Notification Request. */
27
+ reply?: boolean;
28
+ /** How long the server accepts a reply after submission. */
29
+ replyWindow?: number;
30
+ /**
31
+ * Answer labels; turns the reply into a closed question. One value splits on
32
+ * commas; repeating the flag passes each label through verbatim.
33
+ */
34
+ replyChoice?: string | string[];
35
+ /** Platform whose optional notification fields these flags configure. */
36
+ platform?: string;
37
+ /**
38
+ * Lifecycle position of this send; not a user-facing flag. Hooks set it so
39
+ * questions and their retirements ride the wire as what they are.
40
+ */
41
+ lifecycle?: LifecycleT;
42
+ }
43
+ export type DraftBuild = {
44
+ ok: true;
45
+ draft: NotificationDraftT;
46
+ platform: Platform;
47
+ } | {
48
+ ok: false;
49
+ error: string;
50
+ };
51
+ /** Merge flags over resolved config into a Notification Request draft. */
52
+ export declare function buildDraft(config: CliConfig, flags: SendFlags): DraftBuild;
53
+ /** Human one-screen receipt summary; agents use --json instead. */
54
+ export declare function formatReceipt(receipt: SubmissionReceipt): string;
55
+ /** Stable exit codes: 0 ok/pending, 1 rejected everywhere. */
56
+ export declare function receiptExitCode(receipt: SubmissionReceipt): number;
57
+ /**
58
+ * `"Staging,Production"` -> `[{id:'staging',label:'Staging'}, ...]`.
59
+ *
60
+ * A single occurrence splits on commas, which keeps the common one-liner
61
+ * short. Repeating the flag is the escape hatch, and the only way to write a
62
+ * label that itself contains a comma: passing `"Yes, ship it,No"` as one value
63
+ * silently yields three choices, and nothing downstream can detect that the
64
+ * agent meant two.
65
+ */
66
+ export declare function parseChoices(value: string | string[] | undefined): {
67
+ id: string;
68
+ label: string;
69
+ }[] | null | 'invalid';
70
+ /** Message shared by every surface that accepts choice labels. */
71
+ export declare const CHOICE_USAGE: string;
72
+ /**
73
+ * Catches the split that looked like it worked.
74
+ *
75
+ * `--choice "macOS parity,Long-form detail,Nothing, I'll review"` produced four
76
+ * buttons rather than three, because the third label contained a comma. Nothing
77
+ * downstream can detect it — the labels are all well-formed — and the CLI
78
+ * cheerfully echoed the wrong list back, so the user saw buttons the agent
79
+ * never intended.
80
+ *
81
+ * The tell is a comma followed by a space. A delimiter list is written
82
+ * "Yes,No"; prose is written "Nothing, I'll review". That is a heuristic and it
83
+ * will occasionally be wrong, which is exactly why this warns and names the
84
+ * escape hatch rather than rejecting: the split may well have been intended.
85
+ */
86
+ export declare function ambiguousChoiceSplit(value: string | string[] | undefined): string | null;
package/dist/send.js ADDED
@@ -0,0 +1,202 @@
1
+ import { CLI_SOUNDS, INTERRUPTION_LEVELS, NOTIFICATION_SCHEMA_VERSION, NOTIFICATION_KINDS, PLATFORMS, } from '@raidiant/notifai-protocol';
2
+ /** Merge flags over resolved config into a Notification Request draft. */
3
+ export function buildDraft(config, flags) {
4
+ if (!flags.title || !flags.body)
5
+ return { ok: false, error: 'Both --title and --body are required.' };
6
+ const platform = resolvePlatform(flags.platform);
7
+ if (!platform) {
8
+ return { ok: false, error: `Unknown platform "${flags.platform}" — use ${PLATFORMS.join(' or ')}.` };
9
+ }
10
+ const deviceIds = flags.all ? null : (flags.device?.length ? flags.device : config.devices.value);
11
+ const targets = deviceIds && deviceIds.length > 0 ? { mode: 'selected', device_ids: deviceIds } : { mode: 'all' };
12
+ const sound = flags.sound ?? config.sound.value;
13
+ if (sound !== null && sound !== undefined && !CLI_SOUNDS.includes(sound)) {
14
+ return {
15
+ ok: false,
16
+ error: `Unknown sound "${sound}" — supported: ${CLI_SOUNDS.map((value) => `"${value}"`).join(', ')}.`,
17
+ };
18
+ }
19
+ const level = flags.level ?? config.interruption_level.value;
20
+ if (level !== null &&
21
+ level !== undefined &&
22
+ !INTERRUPTION_LEVELS.includes(level)) {
23
+ return { ok: false, error: `Unknown interruption level "${level}".` };
24
+ }
25
+ const options = {};
26
+ if (sound === 'none')
27
+ options.sound = null;
28
+ else if (sound !== null && sound !== undefined) {
29
+ options.sound = sound;
30
+ }
31
+ if (flags.badge !== undefined)
32
+ options.badge = flags.badge;
33
+ if (flags.threadId !== undefined)
34
+ options.thread_id = flags.threadId;
35
+ if (level !== null && level !== undefined) {
36
+ options.interruption_level = level;
37
+ }
38
+ if (flags.relevance !== undefined)
39
+ options.relevance_score = flags.relevance;
40
+ if (flags.targetContentId !== undefined)
41
+ options.target_content_id = flags.targetContentId;
42
+ if (flags.data?.length) {
43
+ const data = {};
44
+ for (const pair of flags.data) {
45
+ const eq = pair.indexOf('=');
46
+ if (eq <= 0)
47
+ return { ok: false, error: `--data expects key=value, got "${pair}".` };
48
+ data[pair.slice(0, eq)] = pair.slice(eq + 1);
49
+ }
50
+ options.custom_data = data;
51
+ }
52
+ if (flags.kind !== undefined &&
53
+ !NOTIFICATION_KINDS.includes(flags.kind)) {
54
+ return {
55
+ ok: false,
56
+ error: `Unknown kind "${flags.kind}" — supported: ${NOTIFICATION_KINDS.map((kind) => `"${kind}"`).join(', ')}.`,
57
+ };
58
+ }
59
+ const ttl = flags.ttl ?? config.ttl_seconds.value;
60
+ const collapse = flags.collapseKey ?? config.collapse_key.value;
61
+ const project = flags.project ?? config.project.value;
62
+ // Harnesses can export NOTIFAI_SESSION once instead of passing the flag
63
+ // on every send.
64
+ const session = flags.session ?? process.env['NOTIFAI_SESSION'];
65
+ // Ids are derived from labels so an agent writing a one-liner does not have
66
+ // to invent them, but they stay the stable thing it branches on afterwards.
67
+ const choices = parseChoices(flags.replyChoice);
68
+ if (choices === 'invalid') {
69
+ return {
70
+ ok: false,
71
+ error: CHOICE_USAGE,
72
+ };
73
+ }
74
+ const draft = {
75
+ schema_version: NOTIFICATION_SCHEMA_VERSION,
76
+ ...(flags.event !== undefined ? { event: flags.event } : {}),
77
+ ...(flags.kind !== undefined
78
+ ? { kind: flags.kind }
79
+ : {}),
80
+ ...(flags.lifecycle !== undefined ? { lifecycle: flags.lifecycle } : {}),
81
+ ...(project !== null && project !== undefined ? { project } : {}),
82
+ ...(session !== undefined && session !== '' ? { session } : {}),
83
+ presentation: {
84
+ title: flags.title,
85
+ body: flags.body,
86
+ ...(flags.subtitle !== undefined ? { subtitle: flags.subtitle } : {}),
87
+ ...(flags.detail !== undefined && flags.detail.trim() !== ''
88
+ ? { detail: flags.detail }
89
+ : {}),
90
+ ...(flags.image !== undefined ? { image: { media_id: flags.image } } : {}),
91
+ },
92
+ targets,
93
+ delivery: { ttl_seconds: ttl, collapse_key: collapse },
94
+ ...(flags.reply
95
+ ? {
96
+ reply: {
97
+ expires_in_seconds: flags.replyWindow ?? 86400,
98
+ ...(choices !== null ? { kind: 'choice', choices } : {}),
99
+ },
100
+ }
101
+ : {}),
102
+ ...(Object.keys(options).length > 0
103
+ ? platform === 'ios'
104
+ ? { platform: { ios: options } }
105
+ : { platform: { macos: options } }
106
+ : {}),
107
+ };
108
+ return { ok: true, draft, platform };
109
+ }
110
+ function resolvePlatform(value) {
111
+ if (value === undefined)
112
+ return 'ios';
113
+ return PLATFORMS.includes(value) ? value : null;
114
+ }
115
+ /** Human one-screen receipt summary; agents use --json instead. */
116
+ export function formatReceipt(receipt) {
117
+ const lines = [];
118
+ const overall = receipt.overall === 'provider_accepted_all'
119
+ ? 'accepted by the push provider for every device'
120
+ : receipt.overall === 'provider_accepted_partial'
121
+ ? 'accepted for some devices'
122
+ : receipt.overall === 'provider_rejected_all'
123
+ ? 'rejected for every device'
124
+ : 'still in flight';
125
+ lines.push(`request ${receipt.request_id}${receipt.replayed ? ' (replayed)' : ''}: ${overall}`);
126
+ for (const d of receipt.deliveries) {
127
+ const detail = d.provider_reason ? ` (${d.provider_reason})` : '';
128
+ lines.push(` ${d.device_name}: ${d.state}${detail}`);
129
+ }
130
+ for (const w of receipt.warnings)
131
+ lines.push(` warning ${w.path}: ${w.message}`);
132
+ return lines.join('\n');
133
+ }
134
+ /** Stable exit codes: 0 ok/pending, 1 rejected everywhere. */
135
+ export function receiptExitCode(receipt) {
136
+ return receipt.overall === 'provider_rejected_all' ? 1 : 0;
137
+ }
138
+ /**
139
+ * `"Staging,Production"` -> `[{id:'staging',label:'Staging'}, ...]`.
140
+ *
141
+ * A single occurrence splits on commas, which keeps the common one-liner
142
+ * short. Repeating the flag is the escape hatch, and the only way to write a
143
+ * label that itself contains a comma: passing `"Yes, ship it,No"` as one value
144
+ * silently yields three choices, and nothing downstream can detect that the
145
+ * agent meant two.
146
+ */
147
+ export function parseChoices(value) {
148
+ if (value === undefined)
149
+ return null;
150
+ const raw = Array.isArray(value) ? value : [value];
151
+ if (raw.length === 0)
152
+ return null;
153
+ const labels = (raw.length === 1 ? (raw[0] ?? '').split(',') : raw)
154
+ .map((part) => part.trim())
155
+ .filter((part) => part.length > 0);
156
+ if (labels.length < 2 || labels.length > 6)
157
+ return 'invalid';
158
+ const choices = labels.map((label) => ({ id: slugify(label), label }));
159
+ if (choices.some((choice) => choice.id === ''))
160
+ return 'invalid';
161
+ if (new Set(choices.map((choice) => choice.id)).size !== choices.length)
162
+ return 'invalid';
163
+ return choices;
164
+ }
165
+ /** Message shared by every surface that accepts choice labels. */
166
+ export const CHOICE_USAGE = 'Choices must be 2-6 non-empty labels, unique once slugified. One value splits on ' +
167
+ 'commas ("Staging,Production"); repeat the flag when a label contains a comma.';
168
+ /**
169
+ * Catches the split that looked like it worked.
170
+ *
171
+ * `--choice "macOS parity,Long-form detail,Nothing, I'll review"` produced four
172
+ * buttons rather than three, because the third label contained a comma. Nothing
173
+ * downstream can detect it — the labels are all well-formed — and the CLI
174
+ * cheerfully echoed the wrong list back, so the user saw buttons the agent
175
+ * never intended.
176
+ *
177
+ * The tell is a comma followed by a space. A delimiter list is written
178
+ * "Yes,No"; prose is written "Nothing, I'll review". That is a heuristic and it
179
+ * will occasionally be wrong, which is exactly why this warns and names the
180
+ * escape hatch rather than rejecting: the split may well have been intended.
181
+ */
182
+ export function ambiguousChoiceSplit(value) {
183
+ if (value === undefined || Array.isArray(value))
184
+ return null;
185
+ if (!/,\s/.test(value))
186
+ return null;
187
+ const parts = value
188
+ .split(',')
189
+ .map((part) => part.trim())
190
+ .filter((part) => part.length > 0);
191
+ return (`Heads up: "${value}" was split on commas into ${parts.length} answers ` +
192
+ `(${parts.join(' / ')}). If one of those labels was meant to contain a comma, ` +
193
+ 'pass --choice once per answer instead.');
194
+ }
195
+ function slugify(label) {
196
+ return label
197
+ .toLowerCase()
198
+ .replace(/[^a-z0-9]+/g, '-')
199
+ .replace(/^-+|-+$/g, '')
200
+ .slice(0, 32);
201
+ }
202
+ //# sourceMappingURL=send.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"send.js","sourceRoot":"","sources":["../src/send.ts"],"names":[],"mappings":"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"}
package/package.json ADDED
@@ -0,0 +1,56 @@
1
+ {
2
+ "name": "@raidiant/notifai",
3
+ "version": "0.1.0",
4
+ "type": "module",
5
+ "description": "Send native phone notifications from agents and local programs",
6
+ "bin": {
7
+ "notifai": "dist/main.js"
8
+ },
9
+ "files": [
10
+ "dist",
11
+ "src",
12
+ "tsconfig.json"
13
+ ],
14
+ "dependencies": {
15
+ "@clack/prompts": "^1.7.0",
16
+ "commander": "^14.0.0",
17
+ "smol-toml": "^1.4.0",
18
+ "@raidiant/notifai-protocol": "0.1.0"
19
+ },
20
+ "devDependencies": {
21
+ "typescript": "^5.8.0",
22
+ "vitest": "^3.2.0"
23
+ },
24
+ "engines": {
25
+ "node": ">=20"
26
+ },
27
+ "license": "Apache-2.0",
28
+ "author": "Rafael Vidaurre",
29
+ "homepage": "https://github.com/RafaelVidaurre/notifai#readme",
30
+ "bugs": {
31
+ "url": "https://github.com/RafaelVidaurre/notifai/issues"
32
+ },
33
+ "publishConfig": {
34
+ "access": "public"
35
+ },
36
+ "repository": {
37
+ "type": "git",
38
+ "url": "git+https://github.com/RafaelVidaurre/notifai.git",
39
+ "directory": "apps/cli"
40
+ },
41
+ "keywords": [
42
+ "notifications",
43
+ "cli",
44
+ "agents",
45
+ "apns",
46
+ "push",
47
+ "ai",
48
+ "claude",
49
+ "codex"
50
+ ],
51
+ "scripts": {
52
+ "build": "tsc -p tsconfig.json",
53
+ "typecheck": "tsc -p tsconfig.json --noEmit",
54
+ "test": "vitest run"
55
+ }
56
+ }
@@ -0,0 +1,83 @@
1
+ import { createServer, type Server } from 'node:http'
2
+ import type { AddressInfo } from 'node:net'
3
+ import { afterEach, describe, expect, it } from 'vitest'
4
+ import { NetworkError, createClient } from './client.js'
5
+
6
+ /**
7
+ * These run against a real socket rather than a stubbed fetch, because the
8
+ * failure being fixed is a property of the transport: a server that accepts the
9
+ * connection and then says nothing. A mock that resolves or rejects cannot
10
+ * reproduce it.
11
+ */
12
+ let server: Server | undefined
13
+
14
+ afterEach(async () => {
15
+ if (server) {
16
+ const closing = server
17
+ server = undefined
18
+ await new Promise<void>((resolve) => closing.close(() => resolve()))
19
+ }
20
+ })
21
+
22
+ /** Starts a server with the given behaviour and returns its base URL. */
23
+ async function serving(handler: Parameters<typeof createServer>[1]): Promise<string> {
24
+ server = createServer(handler)
25
+ await new Promise<void>((resolve) => server?.listen(0, '127.0.0.1', resolve))
26
+ const { port } = server.address() as AddressInfo
27
+ return `http://127.0.0.1:${port}`
28
+ }
29
+
30
+ describe('a server that never answers', () => {
31
+ it('gives up instead of hanging until the harness kills the hook', async () => {
32
+ // Accepts the request, holds the socket open, writes nothing — ever.
33
+ const held: unknown[] = []
34
+ const baseUrl = await serving((_request, response) => {
35
+ held.push(response)
36
+ })
37
+ const client = createClient(baseUrl, null, { timeoutMs: 300 })
38
+
39
+ const started = Date.now()
40
+ await expect(client.listDevices()).rejects.toBeInstanceOf(NetworkError)
41
+ // The point is that it returns at all; the bound is loose so a busy CI box
42
+ // does not make this flaky.
43
+ expect(Date.now() - started).toBeLessThan(5_000)
44
+ expect(held).toHaveLength(1)
45
+ })
46
+
47
+ it('says the server went quiet rather than reporting a bare abort', async () => {
48
+ const baseUrl = await serving(() => {})
49
+ const client = createClient(baseUrl, null, { timeoutMs: 300 })
50
+
51
+ await expect(client.listDevices()).rejects.toThrow(/did not respond within/)
52
+ })
53
+
54
+ it('allows for the long poll it asked the server to hold', async () => {
55
+ // The deadline is per request, so a 25s long poll must not be cut off by
56
+ // the ordinary budget — otherwise waiting for a reply could never work.
57
+ let seen: string | undefined
58
+ const baseUrl = await serving((request, response) => {
59
+ seen = request.url
60
+ response.setHeader('content-type', 'application/json')
61
+ response.end(JSON.stringify({ request_id: 'req_1', reply_expires_at: null, replies: [] }))
62
+ })
63
+ const client = createClient(baseUrl, null, { timeoutMs: 300 })
64
+
65
+ const result = await client.replies('req_1', { waitSeconds: 25, afterSeq: 0 })
66
+
67
+ expect(seen).toContain('wait_seconds=25')
68
+ expect(result.replies).toEqual([])
69
+ })
70
+
71
+ it('treats a truncated body as a transport failure, not a crash', async () => {
72
+ // A body that stops mid-JSON used to escape as a raw parse error outside
73
+ // the retry path, so nothing backed off and retried it.
74
+ const baseUrl = await serving((_request, response) => {
75
+ response.setHeader('content-type', 'application/json')
76
+ response.write('{"devices": [')
77
+ response.destroy()
78
+ })
79
+ const client = createClient(baseUrl, null, { timeoutMs: 500 })
80
+
81
+ await expect(client.listDevices()).rejects.toBeInstanceOf(NetworkError)
82
+ })
83
+ })
package/src/client.ts ADDED
@@ -0,0 +1,183 @@
1
+ import type {
2
+ ApiErrorBody,
3
+ BeginPairingResponse,
4
+ CapabilityDocument,
5
+ CreateMediaUploadRequestT,
6
+ CreateMediaUploadResponse,
7
+ EvidenceSnapshot,
8
+ ListDevicesResponse,
9
+ ListRepliesResponse,
10
+ Platform,
11
+ PollPairingResponse,
12
+ SubmissionReceipt,
13
+ SubmitNotificationRequestT,
14
+ } from '@raidiant/notifai-protocol'
15
+
16
+ export class ApiCallError extends Error {
17
+ constructor(
18
+ public status: number,
19
+ public code: string,
20
+ message: string,
21
+ public nextAction: string | null = null,
22
+ public details: unknown = null,
23
+ ) {
24
+ super(message)
25
+ }
26
+ }
27
+
28
+ export class NetworkError extends Error {}
29
+
30
+ /**
31
+ * A deadline that fired reads as an abort, which says nothing useful on its
32
+ * own. Naming the timeout is what lets someone tell a hung server apart from a
33
+ * refused connection.
34
+ */
35
+ function networkFailure(err: unknown, root: string, limitMs: number): NetworkError {
36
+ const name = err instanceof Error ? err.name : ''
37
+ if (name === 'TimeoutError' || name === 'AbortError') {
38
+ return new NetworkError(`${root} did not respond within ${Math.round(limitMs / 1000)}s`)
39
+ }
40
+ return new NetworkError(`Could not reach ${root}: ${String(err)}`)
41
+ }
42
+
43
+ export interface ApiClient {
44
+ beginPairing(body: {
45
+ machine_name: string
46
+ credential_hash: string
47
+ poll_verifier_hash: string
48
+ }): Promise<BeginPairingResponse>
49
+ pollPairing(pairingId: string, pollVerifier: string): Promise<PollPairingResponse>
50
+ listDevices(): Promise<ListDevicesResponse>
51
+ capabilities(platform?: Platform): Promise<CapabilityDocument>
52
+ submit(body: SubmitNotificationRequestT, waitSeconds: number): Promise<SubmissionReceipt>
53
+ evidence(requestId: string): Promise<EvidenceSnapshot>
54
+ replies(
55
+ requestId: string,
56
+ options: { waitSeconds: number; afterSeq: number },
57
+ ): Promise<ListRepliesResponse>
58
+ /** Retire a question so a late device answer is rejected rather than lost. */
59
+ closeReplies(requestId: string): Promise<void>
60
+ createMediaUpload(body: CreateMediaUploadRequestT): Promise<CreateMediaUploadResponse>
61
+ uploadMedia(grant: CreateMediaUploadResponse, bytes: Uint8Array): Promise<void>
62
+ health(): Promise<boolean>
63
+ }
64
+
65
+ export interface ClientOptions {
66
+ /**
67
+ * Ceiling for a single request, on top of any long poll the server has been
68
+ * asked to hold. Callers running inside a harness hook shrink this so their
69
+ * whole network path stays inside the budget the harness allows them.
70
+ */
71
+ timeoutMs?: number
72
+ }
73
+
74
+ /** Generous enough for a slow link, short enough that nothing hangs for ever. */
75
+ const DEFAULT_TIMEOUT_MS = 20_000
76
+
77
+ export function createClient(
78
+ baseUrl: string,
79
+ bearer: string | null,
80
+ options: ClientOptions = {},
81
+ ): ApiClient {
82
+ const root = baseUrl.replace(/\/$/, '')
83
+ const budgetMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS
84
+
85
+ async function call<T>(
86
+ method: string,
87
+ apiPath: string,
88
+ body?: unknown,
89
+ /** Seconds the server was asked to hold the connection open. */
90
+ serverWaitSeconds = 0,
91
+ ): Promise<T> {
92
+ // Without this, a server that accepts the connection and then never answers
93
+ // hangs until the harness kills the whole hook — and the reply-poll deadline
94
+ // cannot interrupt an individual fetch. The signal covers the
95
+ // body read too, not just the response headers.
96
+ const limitMs = budgetMs + serverWaitSeconds * 1000
97
+ const signal = AbortSignal.timeout(limitMs)
98
+ let response: Response
99
+ try {
100
+ response = await fetch(`${root}${apiPath}`, {
101
+ method,
102
+ headers: {
103
+ ...(body !== undefined ? { 'content-type': 'application/json' } : {}),
104
+ ...(bearer ? { authorization: bearer } : {}),
105
+ },
106
+ signal,
107
+ ...(body !== undefined ? { body: JSON.stringify(body) } : {}),
108
+ })
109
+ } catch (err) {
110
+ throw networkFailure(err, root, limitMs)
111
+ }
112
+ if (!response.ok) {
113
+ let parsed: ApiErrorBody | null = null
114
+ try {
115
+ parsed = (await response.json()) as ApiErrorBody
116
+ } catch {
117
+ // non-JSON body
118
+ }
119
+ throw new ApiCallError(
120
+ response.status,
121
+ parsed?.error.code ?? 'internal_error',
122
+ parsed?.error.message ?? `Request failed with status ${response.status}`,
123
+ parsed?.error.next_action ?? null,
124
+ parsed?.error.details ?? null,
125
+ )
126
+ }
127
+ if (response.status === 204) return undefined as T
128
+ try {
129
+ return (await response.json()) as T
130
+ } catch (err) {
131
+ // A truncated or abandoned body is a transport failure, not a protocol
132
+ // one: it must be retryable like any other, not surface as a raw abort.
133
+ throw networkFailure(err, root, limitMs)
134
+ }
135
+ }
136
+
137
+ return {
138
+ beginPairing: (body) => call('POST', '/api/v1/pairings', body),
139
+ pollPairing: (pairingId, pollVerifier) =>
140
+ call('POST', `/api/v1/pairings/${pairingId}/poll`, { poll_verifier: pollVerifier }),
141
+ listDevices: () => call('GET', '/api/v1/devices'),
142
+ capabilities: (platform = 'ios') => call('GET', `/api/v1/capabilities/${platform}`),
143
+ submit: (body, waitSeconds) =>
144
+ call('POST', `/api/v1/notifications?wait_seconds=${waitSeconds}`, body, waitSeconds),
145
+ evidence: (requestId) => call('GET', `/api/v1/notifications/${requestId}`),
146
+ replies: (requestId, { waitSeconds, afterSeq }) =>
147
+ call(
148
+ 'GET',
149
+ `/api/v1/notifications/${encodeURIComponent(requestId)}/replies?wait_seconds=${waitSeconds}&after_seq=${afterSeq}`,
150
+ undefined,
151
+ waitSeconds,
152
+ ),
153
+ closeReplies: (requestId) =>
154
+ call('POST', `/api/v1/notifications/${encodeURIComponent(requestId)}/replies/close`),
155
+ createMediaUpload: (body) => call('POST', '/api/v1/media', body),
156
+ uploadMedia: async (grant, bytes) => {
157
+ let response: Response
158
+ try {
159
+ response = await fetch(grant.upload_url, {
160
+ method: 'PUT',
161
+ headers: grant.upload_headers,
162
+ body: bytes,
163
+ // Media can be large, so this gets its own allowance rather than the
164
+ // per-request budget — but still a finite one.
165
+ signal: AbortSignal.timeout(Math.max(budgetMs, 60_000)),
166
+ })
167
+ } catch (err) {
168
+ throw new NetworkError(`Upload failed: ${String(err)}`)
169
+ }
170
+ if (!response.ok) {
171
+ throw new NetworkError(`Upload rejected with status ${response.status}`)
172
+ }
173
+ },
174
+ health: async () => {
175
+ try {
176
+ await call('GET', '/healthz')
177
+ return true
178
+ } catch {
179
+ return false
180
+ }
181
+ },
182
+ }
183
+ }