@raidiant/notifai 0.2.1 → 0.4.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 (94) hide show
  1. package/README.md +20 -4
  2. package/dist/atomic-file.d.ts +4 -0
  3. package/dist/atomic-file.js +60 -10
  4. package/dist/atomic-file.js.map +1 -1
  5. package/dist/client.d.ts +25 -2
  6. package/dist/client.js +45 -1
  7. package/dist/client.js.map +1 -1
  8. package/dist/commands.d.ts +85 -6
  9. package/dist/commands.js +1133 -322
  10. package/dist/commands.js.map +1 -1
  11. package/dist/config-schema.d.ts +62 -0
  12. package/dist/config-schema.js +312 -0
  13. package/dist/config-schema.js.map +1 -0
  14. package/dist/config.d.ts +38 -3
  15. package/dist/config.js +61 -14
  16. package/dist/config.js.map +1 -1
  17. package/dist/file-lock.d.ts +26 -0
  18. package/dist/file-lock.js +268 -0
  19. package/dist/file-lock.js.map +1 -0
  20. package/dist/harnesses.d.ts +28 -0
  21. package/dist/harnesses.js +40 -0
  22. package/dist/harnesses.js.map +1 -0
  23. package/dist/hook-adapter.d.ts +27 -0
  24. package/dist/hook-adapter.js +188 -0
  25. package/dist/hook-adapter.js.map +1 -0
  26. package/dist/hook-input.d.ts +10 -0
  27. package/dist/hook-input.js +64 -0
  28. package/dist/hook-input.js.map +1 -0
  29. package/dist/hooks.d.ts +66 -10
  30. package/dist/hooks.js +751 -276
  31. package/dist/hooks.js.map +1 -1
  32. package/dist/install-hooks.d.ts +40 -16
  33. package/dist/install-hooks.js +217 -73
  34. package/dist/install-hooks.js.map +1 -1
  35. package/dist/interactive.d.ts +4 -0
  36. package/dist/interactive.js +604 -0
  37. package/dist/interactive.js.map +1 -0
  38. package/dist/logging.d.ts +142 -0
  39. package/dist/logging.js +533 -0
  40. package/dist/logging.js.map +1 -0
  41. package/dist/main.js +201 -51
  42. package/dist/main.js.map +1 -1
  43. package/dist/opencode-plugin.d.ts +7 -7
  44. package/dist/opencode-plugin.js +12 -13
  45. package/dist/opencode-plugin.js.map +1 -1
  46. package/dist/ui/banner.d.ts +22 -0
  47. package/dist/ui/banner.js +87 -0
  48. package/dist/ui/banner.js.map +1 -0
  49. package/dist/ui/config-view.d.ts +32 -0
  50. package/dist/ui/config-view.js +108 -0
  51. package/dist/ui/config-view.js.map +1 -0
  52. package/dist/ui/help.d.ts +46 -0
  53. package/dist/ui/help.js +105 -0
  54. package/dist/ui/help.js.map +1 -0
  55. package/dist/ui/theme.d.ts +108 -0
  56. package/dist/ui/theme.js +261 -0
  57. package/dist/ui/theme.js.map +1 -0
  58. package/package.json +11 -9
  59. package/src/atomic-file.ts +86 -7
  60. package/src/client.test.ts +12 -0
  61. package/src/client.ts +72 -4
  62. package/src/commands.test.ts +971 -44
  63. package/src/commands.ts +1323 -337
  64. package/src/config-schema.test.ts +152 -0
  65. package/src/config-schema.ts +363 -0
  66. package/src/config.test.ts +6 -5
  67. package/src/config.ts +85 -16
  68. package/src/file-lock-process-worker.test.ts +64 -0
  69. package/src/file-lock.test.ts +279 -0
  70. package/src/file-lock.ts +318 -0
  71. package/src/harnesses.test.ts +48 -0
  72. package/src/harnesses.ts +59 -0
  73. package/src/hook-adapter.test.ts +187 -0
  74. package/src/hook-adapter.ts +212 -0
  75. package/src/hook-input.test.ts +39 -0
  76. package/src/hook-input.ts +72 -0
  77. package/src/hooks.test.ts +1318 -240
  78. package/src/hooks.ts +1046 -296
  79. package/src/install-hooks.test.ts +90 -41
  80. package/src/install-hooks.ts +259 -74
  81. package/src/interactive.ts +692 -0
  82. package/src/logging-process-worker.test.ts +39 -0
  83. package/src/logging.test.ts +453 -0
  84. package/src/logging.ts +639 -0
  85. package/src/main.ts +220 -48
  86. package/src/opencode-plugin.ts +15 -16
  87. package/src/session-end-log-lock-worker.test.ts +35 -0
  88. package/src/skill.test.ts +11 -0
  89. package/src/ui/banner.ts +95 -0
  90. package/src/ui/config-view.test.ts +88 -0
  91. package/src/ui/config-view.ts +138 -0
  92. package/src/ui/help.ts +124 -0
  93. package/src/ui/theme.test.ts +92 -0
  94. package/src/ui/theme.ts +279 -0
@@ -0,0 +1,261 @@
1
+ /**
2
+ * One visual language for every human-facing surface: help, `config show`,
3
+ * `doctor`, and the interactive app.
4
+ *
5
+ * Two rules hold everything here together.
6
+ *
7
+ * Colour is decoration, never information. Every state that colour marks also
8
+ * carries a glyph and a word, because the output is read through pipes, on
9
+ * monochrome terminals, by screen readers, and by people who cannot separate
10
+ * red from green. `picocolors` resolves to identity functions when stdout is
11
+ * not a TTY or `NO_COLOR` is set, so the plain path is the same code, not a
12
+ * second implementation that can drift.
13
+ *
14
+ * And an agent's bytes are not ours to restyle. Machine-readable output
15
+ * (`--json`) never routes through this module, and every non-TTY invocation
16
+ * lands on the uncoloured branch by construction.
17
+ */
18
+ import pc from 'picocolors';
19
+ /** True when the terminal can render the box-drawing and status glyphs below. */
20
+ export const unicode = process.platform !== 'win32' ||
21
+ process.env['WT_SESSION'] !== undefined ||
22
+ process.env['TERM_PROGRAM'] === 'vscode' ||
23
+ process.env['ConEmuTask'] === '{cmd::Cmder}';
24
+ /** True when 24-bit colour is available, which the banner gradient needs. */
25
+ export function supportsTrueColor(env = process.env) {
26
+ if (!pc.isColorSupported)
27
+ return false;
28
+ const colorterm = env['COLORTERM'] ?? '';
29
+ if (/^(truecolor|24bit)$/i.test(colorterm))
30
+ return true;
31
+ return env['TERM_PROGRAM'] === 'iTerm.app' || env['TERM_PROGRAM'] === 'vscode';
32
+ }
33
+ export const color = pc;
34
+ /**
35
+ * Brand ramp, shared with the companion app icon: pink → coral → rose →
36
+ * magenta. Used for the wordmark and for accenting the surfaces a human sees.
37
+ */
38
+ export const BRAND_RAMP = ['#FF9EC0', '#FF816B', '#EC5A73', '#B84E93'];
39
+ function hexToRgb(hex) {
40
+ const value = Number.parseInt(hex.slice(1), 16);
41
+ return { r: (value >> 16) & 255, g: (value >> 8) & 255, b: value & 255 };
42
+ }
43
+ function mix(a, b, t) {
44
+ return {
45
+ r: Math.round(a.r + (b.r - a.r) * t),
46
+ g: Math.round(a.g + (b.g - a.g) * t),
47
+ b: Math.round(a.b + (b.b - a.b) * t),
48
+ };
49
+ }
50
+ /**
51
+ * Sample the brand ramp at `t` in [0, 1]. Multi-stop so the gradient keeps the
52
+ * icon's coral midtones instead of washing straight from pink to magenta.
53
+ */
54
+ export function rampAt(t, ramp = BRAND_RAMP) {
55
+ const clamped = Math.min(1, Math.max(0, t));
56
+ const span = ramp.length - 1;
57
+ const scaled = clamped * span;
58
+ const index = Math.min(span - 1, Math.floor(scaled));
59
+ const { r, g, b } = mix(hexToRgb(ramp[index]), hexToRgb(ramp[index + 1]), scaled - index);
60
+ return `[38;2;${r};${g};${b}m`;
61
+ }
62
+ const RESET = '';
63
+ /**
64
+ * SGR escape sequences, in the three shapes the helpers below need: stripping
65
+ * them to measure, splitting around them to slice, and recognising one on its
66
+ * own. Kept together so a change to what counts as an escape lands once.
67
+ */
68
+ const ANSI_GLOBAL = /\[[0-9;]*m/g;
69
+ const ANSI_SPLIT = /(\[[0-9;]*m)/;
70
+ const ANSI_ONLY = /^\[[0-9;]*m$/;
71
+ /**
72
+ * Paint `text` across the brand ramp, one escape per character.
73
+ *
74
+ * Falls back to a single flat brand colour without truecolor, and to the bare
75
+ * string with colour off — a gradient is the most decorative thing this CLI
76
+ * does, so it must also be the first thing to degrade quietly.
77
+ */
78
+ export function gradient(text, ramp = BRAND_RAMP) {
79
+ if (!pc.isColorSupported)
80
+ return text;
81
+ if (!supportsTrueColor())
82
+ return pc.magenta(text);
83
+ const chars = [...text];
84
+ const last = Math.max(1, chars.length - 1);
85
+ return chars.map((char, i) => (char === ' ' ? char : `${rampAt(i / last, ramp)}${char}`)).join('') + RESET;
86
+ }
87
+ /**
88
+ * Paint each line of a block so the gradient runs left-to-right across the
89
+ * whole block rather than restarting per line. The wordmark is one image, and
90
+ * per-line gradients make it read as several.
91
+ */
92
+ export function gradientBlock(lines, ramp = BRAND_RAMP) {
93
+ if (!pc.isColorSupported)
94
+ return [...lines];
95
+ const width = Math.max(1, ...lines.map((line) => [...line].length));
96
+ if (!supportsTrueColor())
97
+ return lines.map((line) => pc.magenta(line));
98
+ return lines.map((line) => [...line]
99
+ .map((char, i) => (char === ' ' ? char : `${rampAt(i / Math.max(1, width - 1), ramp)}${char}`))
100
+ .join('') + RESET);
101
+ }
102
+ // ---------------------------------------------------------------------------
103
+ // Semantic styles
104
+ // ---------------------------------------------------------------------------
105
+ export const style = {
106
+ /** A heading that opens a section. */
107
+ heading: (text) => pc.bold(text),
108
+ /** The brand voice — command names, the product name, the selected thing. */
109
+ accent: (text) => pc.magenta(text),
110
+ /** Secondary text: hints, provenance, units, defaults. */
111
+ dim: (text) => pc.dim(text),
112
+ /** Something the reader can type. */
113
+ code: (text) => pc.cyan(text),
114
+ ok: (text) => pc.green(text),
115
+ warn: (text) => pc.yellow(text),
116
+ bad: (text) => pc.red(text),
117
+ /** A value as it currently stands. */
118
+ value: (text) => pc.bold(pc.white(text)),
119
+ /** A value that is not set, or is inherited rather than chosen. */
120
+ unset: (text) => pc.dim(pc.italic(text)),
121
+ };
122
+ /**
123
+ * Status marks. The glyph and the colour say the same thing twice on purpose;
124
+ * either alone is enough to read the line.
125
+ */
126
+ export const glyph = {
127
+ ok: unicode ? '●' : '*',
128
+ warn: unicode ? '▲' : '!',
129
+ bad: unicode ? '✕' : 'x',
130
+ pending: unicode ? '○' : 'o',
131
+ bullet: unicode ? '•' : '-',
132
+ arrow: unicode ? '→' : '->',
133
+ chevron: unicode ? '›' : '>',
134
+ bar: unicode ? '│' : '|',
135
+ };
136
+ /** A coloured glyph for a tone; the caller supplies the words. */
137
+ export function mark(tone) {
138
+ switch (tone) {
139
+ case 'ok':
140
+ return style.ok(glyph.ok);
141
+ case 'warn':
142
+ return style.warn(glyph.warn);
143
+ case 'bad':
144
+ return style.bad(glyph.bad);
145
+ case 'pending':
146
+ return style.dim(glyph.pending);
147
+ }
148
+ }
149
+ /** Visible width, ignoring ANSI escapes, for alignment maths. */
150
+ export function width(text) {
151
+ return [...text.replace(ANSI_GLOBAL, '')].length;
152
+ }
153
+ /** Pad to `size` using visible width, so colour never breaks a column. */
154
+ export function pad(text, size) {
155
+ return text + ' '.repeat(Math.max(0, size - width(text)));
156
+ }
157
+ /**
158
+ * Usable terminal width: the real one, capped so paragraphs do not run to 200
159
+ * characters on a maximised window.
160
+ *
161
+ * No lower clamp. Raising a narrow terminal to a comfortable minimum sounds
162
+ * harmless and is not — every box drawn to that invented width overflows the
163
+ * real one and wraps, which looks far worse than a cramped box that fits.
164
+ */
165
+ export function terminalWidth(fallback = 80) {
166
+ const columns = process.stdout.columns;
167
+ if (typeof columns !== 'number' || columns <= 0)
168
+ return fallback;
169
+ return Math.min(100, columns);
170
+ }
171
+ /**
172
+ * A titled box around `rows`.
173
+ *
174
+ * Hand-drawn rather than delegated, because the obvious library call sizes
175
+ * itself from `process.stdout.columns` and divides by it — a pty that reports
176
+ * zero columns (some CI runners, some `script` invocations) makes the padding
177
+ * negative and throws, taking the whole screen down with it. A status card is
178
+ * decoration; it may degrade, but it must never be what crashes the app.
179
+ *
180
+ * Rows are measured with `width`, so colour inside them does not skew the
181
+ * border, and anything too wide for the terminal is clipped rather than
182
+ * wrapped — a status line that wraps stops being a status line.
183
+ */
184
+ export function card(title, rows, columns = terminalWidth()) {
185
+ const [tl, tr, bl, br, h, v] = unicode
186
+ ? ['╭', '╮', '╰', '╯', '─', '│']
187
+ : ['+', '+', '+', '+', '-', '|'];
188
+ const inner = Math.max(20, columns - 4);
189
+ const content = rows.map((row) => (width(row) > inner ? clip(row, inner) : row));
190
+ const bodyWidth = Math.max(width(title) + 4, ...content.map((row) => width(row)));
191
+ const boxWidth = Math.min(inner, bodyWidth);
192
+ const heading = `${h} ${style.heading(title)} `;
193
+ const lines = [];
194
+ lines.push(style.dim(tl) + heading + style.dim(h.repeat(Math.max(0, boxWidth - width(heading) + 2))) + style.dim(tr));
195
+ for (const row of content) {
196
+ lines.push(`${style.dim(v)} ${pad(row, boxWidth)} ${style.dim(v)}`);
197
+ }
198
+ lines.push(style.dim(bl + h.repeat(boxWidth + 2) + br));
199
+ return lines;
200
+ }
201
+ /**
202
+ * Truncate to a visible width, carrying escape sequences through untouched.
203
+ *
204
+ * Counting only printable characters is the whole point: slicing a coloured
205
+ * string by `String.length` cuts through an escape sequence and spills raw
206
+ * `[38;2` into the terminal.
207
+ */
208
+ export function clip(text, limit) {
209
+ if (width(text) <= limit)
210
+ return text;
211
+ const parts = text.split(ANSI_SPLIT);
212
+ let out = '';
213
+ let visible = 0;
214
+ for (const part of parts) {
215
+ if (part === undefined || part === '')
216
+ continue;
217
+ if (ANSI_ONLY.test(part)) {
218
+ out += part;
219
+ continue;
220
+ }
221
+ for (const char of part) {
222
+ if (visible >= limit - 1)
223
+ return `${out}\u2026${RESET}`;
224
+ out += char;
225
+ visible += 1;
226
+ }
227
+ }
228
+ return `${out}${RESET}`;
229
+ }
230
+ /**
231
+ * Wrap `text` to `columns`, returning bare lines.
232
+ *
233
+ * Indentation is the caller's, deliberately: an earlier version indented
234
+ * continuation lines itself, callers indented every line as well, and each
235
+ * wrapped paragraph stepped further right down the page.
236
+ */
237
+ export function wrap(text, columns) {
238
+ // Honour the width asked for. A floor here would silently ignore small
239
+ // values and hand back lines wider than the caller reserved room for; a
240
+ // width of zero or less is the only nonsense, and that falls back.
241
+ const limit = columns > 0 ? columns : 80;
242
+ const words = text.split(/\s+/).filter(Boolean);
243
+ if (words.length === 0)
244
+ return [''];
245
+ const lines = [];
246
+ let line = '';
247
+ for (const word of words) {
248
+ const candidate = line === '' ? word : `${line} ${word}`;
249
+ if (width(candidate) > limit && line !== '') {
250
+ lines.push(line);
251
+ line = word;
252
+ }
253
+ else {
254
+ line = candidate;
255
+ }
256
+ }
257
+ if (line !== '')
258
+ lines.push(line);
259
+ return lines;
260
+ }
261
+ //# sourceMappingURL=theme.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"theme.js","sourceRoot":"","sources":["../../src/ui/theme.ts"],"names":[],"mappings":"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"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@raidiant/notifai",
3
- "version": "0.2.1",
3
+ "version": "0.4.0",
4
4
  "type": "module",
5
5
  "description": "Send native phone notifications from agents and local programs",
6
6
  "bin": {
@@ -11,10 +11,17 @@
11
11
  "src",
12
12
  "tsconfig.json"
13
13
  ],
14
+ "scripts": {
15
+ "build": "tsc -p tsconfig.json",
16
+ "pretest": "pnpm run build",
17
+ "typecheck": "tsc -p tsconfig.json --noEmit",
18
+ "test": "vitest run"
19
+ },
14
20
  "dependencies": {
15
21
  "@clack/prompts": "^1.7.0",
16
- "@raidiant/notifai-protocol": "0.2.0",
22
+ "@raidiant/notifai-protocol": "0.3.0",
17
23
  "commander": "^14.0.0",
24
+ "picocolors": "^1.1.1",
18
25
  "smol-toml": "^1.4.0"
19
26
  },
20
27
  "devDependencies": {
@@ -47,10 +54,5 @@
47
54
  "ai",
48
55
  "claude",
49
56
  "codex"
50
- ],
51
- "scripts": {
52
- "build": "tsc -p tsconfig.json",
53
- "typecheck": "tsc -p tsconfig.json --noEmit",
54
- "test": "vitest run"
55
- }
56
- }
57
+ ]
58
+ }
@@ -1,5 +1,6 @@
1
1
  import { randomBytes } from 'node:crypto'
2
2
  import {
3
+ chmodSync,
3
4
  closeSync,
4
5
  fsyncSync,
5
6
  lstatSync,
@@ -14,6 +15,10 @@ import path from 'node:path'
14
15
  export interface AtomicWriteOptions {
15
16
  /** Mode for a newly created file. Existing regular files keep their mode. */
16
17
  mode?: number
18
+ /** False when a managed executable must keep the requested mode on repair. */
19
+ preserveMode?: boolean
20
+ /** Reject a target owned by another user instead of replacing it. */
21
+ requireCurrentUserOwner?: boolean
17
22
  }
18
23
 
19
24
  /**
@@ -28,19 +33,29 @@ export function atomicWriteFileSync(
28
33
  contents: string,
29
34
  options: AtomicWriteOptions = {},
30
35
  ): void {
31
- mkdirSync(path.dirname(file), { recursive: true })
32
- const mode = targetMode(file, options.mode ?? 0o600)
36
+ const directory = path.dirname(file)
37
+ mkdirSync(directory, { recursive: true })
38
+ const parent = safeDirectory(directory, options.requireCurrentUserOwner ?? false)
39
+ const target = targetMetadata(
40
+ file,
41
+ options.mode ?? 0o600,
42
+ options.preserveMode ?? true,
43
+ options.requireCurrentUserOwner ?? false,
44
+ )
33
45
  const temp = path.join(
34
- path.dirname(file),
46
+ directory,
35
47
  `.${path.basename(file)}.notifai-${process.pid}-${randomBytes(6).toString('hex')}.tmp`,
36
48
  )
37
49
  let handle: number | undefined
38
50
  try {
39
- handle = openSync(temp, 'wx', mode)
51
+ handle = openSync(temp, 'wx', target.mode)
40
52
  writeFileSync(handle, contents)
41
53
  fsyncSync(handle)
42
54
  closeSync(handle)
43
55
  handle = undefined
56
+ chmodSync(temp, target.mode)
57
+ assertSameDirectory(directory, parent)
58
+ assertUnchangedTarget(file, target)
44
59
  renameSync(temp, file)
45
60
  } catch (err) {
46
61
  if (handle !== undefined) closeSync(handle)
@@ -54,12 +69,24 @@ export function atomicWriteFileSync(
54
69
  }
55
70
 
56
71
  /** Mode to preserve, or the mode for a new file. Refuses a non-regular target. */
57
- function targetMode(file: string, fallback: number): number {
72
+ interface TargetMetadata {
73
+ mode: number
74
+ identity: { dev: number; ino: number } | null
75
+ }
76
+
77
+ function targetMetadata(
78
+ file: string,
79
+ fallback: number,
80
+ preserveMode: boolean,
81
+ requireCurrentUserOwner: boolean,
82
+ ): TargetMetadata {
58
83
  let stat
59
84
  try {
60
85
  stat = lstatSync(file)
61
86
  } catch (err) {
62
- if ((err as NodeJS.ErrnoException).code === 'ENOENT') return fallback
87
+ if ((err as NodeJS.ErrnoException).code === 'ENOENT') {
88
+ return { mode: fallback, identity: null }
89
+ }
63
90
  throw err
64
91
  }
65
92
  if (stat.isSymbolicLink()) {
@@ -69,5 +96,57 @@ function targetMode(file: string, fallback: number): number {
69
96
  )
70
97
  }
71
98
  if (!stat.isFile()) throw new Error(`${file} is not a regular file; refusing to replace it.`)
72
- return stat.mode & 0o777
99
+ if (requireCurrentUserOwner) assertCurrentUserOwns(file, stat.uid)
100
+ return {
101
+ mode: preserveMode ? stat.mode & 0o777 : fallback,
102
+ identity: { dev: stat.dev, ino: stat.ino },
103
+ }
104
+ }
105
+
106
+ function assertUnchangedTarget(file: string, target: TargetMetadata): void {
107
+ let current
108
+ try {
109
+ current = lstatSync(file)
110
+ } catch (err) {
111
+ if ((err as NodeJS.ErrnoException).code === 'ENOENT' && target.identity === null) return
112
+ throw new Error(`${file} changed during the atomic write; refusing to replace it.`)
113
+ }
114
+ if (
115
+ target.identity === null ||
116
+ current.dev !== target.identity.dev ||
117
+ current.ino !== target.identity.ino
118
+ ) {
119
+ throw new Error(`${file} changed during the atomic write; refusing to replace it.`)
120
+ }
121
+ }
122
+
123
+ function safeDirectory(
124
+ directory: string,
125
+ requireCurrentUserOwner: boolean,
126
+ ): { dev: number; ino: number } {
127
+ const stat = lstatSync(directory)
128
+ if (stat.isSymbolicLink() || !stat.isDirectory()) {
129
+ throw new Error(`${directory} is not a regular directory; refusing to write inside it.`)
130
+ }
131
+ if (requireCurrentUserOwner) assertCurrentUserOwns(directory, stat.uid)
132
+ return { dev: stat.dev, ino: stat.ino }
133
+ }
134
+
135
+ function assertSameDirectory(directory: string, expected: { dev: number; ino: number }): void {
136
+ const current = lstatSync(directory)
137
+ if (
138
+ current.isSymbolicLink() ||
139
+ !current.isDirectory() ||
140
+ current.dev !== expected.dev ||
141
+ current.ino !== expected.ino
142
+ ) {
143
+ throw new Error(`${directory} changed during the write; refusing to publish the file.`)
144
+ }
145
+ }
146
+
147
+ function assertCurrentUserOwns(file: string, owner: number): void {
148
+ const uid = typeof process.getuid === 'function' ? process.getuid() : undefined
149
+ if (uid !== undefined && owner !== uid) {
150
+ throw new Error(`${file} is owned by uid ${owner}, not the current user; refusing to replace it.`)
151
+ }
73
152
  }
@@ -51,6 +51,18 @@ describe('a server that never answers', () => {
51
51
  await expect(client.listDevices()).rejects.toThrow(/did not respond within/)
52
52
  })
53
53
 
54
+ it('clamps a late request to the caller whole-operation deadline', async () => {
55
+ const baseUrl = await serving(() => {})
56
+ const started = Date.now()
57
+ const client = createClient(baseUrl, null, {
58
+ timeoutMs: 5_000,
59
+ deadlineAt: started + 200,
60
+ })
61
+
62
+ await expect(client.listDevices()).rejects.toBeInstanceOf(NetworkError)
63
+ expect(Date.now() - started).toBeLessThan(2_000)
64
+ })
65
+
54
66
  it('allows for the long poll it asked the server to hold', async () => {
55
67
  // The deadline is per request, so a 25s long poll must not be cut off by
56
68
  // the ordinary budget — otherwise waiting for a reply could never work.
package/src/client.ts CHANGED
@@ -13,6 +13,7 @@ import type {
13
13
  SubmissionReceipt,
14
14
  SubmitNotificationRequestT,
15
15
  } from '@raidiant/notifai-protocol'
16
+ import type { Logger } from './logging.js'
16
17
 
17
18
  export class ApiCallError extends Error {
18
19
  constructor(
@@ -28,6 +29,18 @@ export class ApiCallError extends Error {
28
29
 
29
30
  export class NetworkError extends Error {}
30
31
 
32
+ /**
33
+ * Whether a replies-poll failure is temporary while the durable request stays
34
+ * live. Transport faults, throttling, request timeouts, and server failures
35
+ * can recover on a later poll; client errors cannot be repaired by retrying
36
+ * the same authenticated request.
37
+ */
38
+ export function isRetryableReplyPollError(err: unknown): boolean {
39
+ if (err instanceof NetworkError) return true
40
+ if (!(err instanceof ApiCallError)) return false
41
+ return err.status >= 500 || err.status === 429 || err.status === 408
42
+ }
43
+
31
44
  /**
32
45
  * A deadline that fired reads as an abort, which says nothing useful on its
33
46
  * own. Naming the timeout is what lets someone tell a hung server apart from a
@@ -57,8 +70,8 @@ export interface ApiClient {
57
70
  requestId: string,
58
71
  options: { waitSeconds: number; afterSeq: number },
59
72
  ): Promise<ListRepliesResponse>
60
- /** Retire a question so a late device answer is rejected rather than lost. */
61
- closeReplies(requestId: string): Promise<void>
73
+ /** Retire a question and return the replies committed before the close fence. */
74
+ closeReplies(requestId: string): Promise<ListRepliesResponse>
62
75
  createMediaUpload(body: CreateMediaUploadRequestT): Promise<CreateMediaUploadResponse>
63
76
  uploadMedia(grant: CreateMediaUploadResponse, bytes: Uint8Array): Promise<void>
64
77
  health(): Promise<boolean>
@@ -71,6 +84,21 @@ export interface ClientOptions {
71
84
  * whole network path stays inside the budget the harness allows them.
72
85
  */
73
86
  timeoutMs?: number
87
+ /**
88
+ * Absolute wall-clock ceiling for the caller's whole operation. This is
89
+ * intentionally separate from `timeoutMs`: a late long poll must not get a
90
+ * fresh per-request allowance after its owning harness hook is nearly done.
91
+ */
92
+ deadlineAt?: number
93
+ /** Test seam for `deadlineAt`; production uses the wall clock. */
94
+ now?: () => number
95
+ /**
96
+ * Records each call locally. Worth having because the two failures that cost
97
+ * the most time — a server older than this CLI, and a network path that only
98
+ * fails inside a hook — both look identical from outside and are told apart
99
+ * instantly by a status code and a duration.
100
+ */
101
+ logger?: Pick<Logger, 'debug' | 'error'>
74
102
  }
75
103
 
76
104
  /** Generous enough for a slow link, short enough that nothing hangs for ever. */
@@ -83,6 +111,8 @@ export function createClient(
83
111
  ): ApiClient {
84
112
  const root = baseUrl.replace(/\/$/, '')
85
113
  const budgetMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS
114
+ const now = options.now ?? Date.now
115
+ const logger = options.logger
86
116
 
87
117
  async function call<T>(
88
118
  method: string,
@@ -90,12 +120,47 @@ export function createClient(
90
120
  body?: unknown,
91
121
  /** Seconds the server was asked to hold the connection open. */
92
122
  serverWaitSeconds = 0,
123
+ ): Promise<T> {
124
+ const startedAt = Date.now()
125
+ try {
126
+ const result = await callOnce<T>(method, apiPath, body, serverWaitSeconds)
127
+ logger?.debug('http.call', {
128
+ method,
129
+ path: apiPath,
130
+ ok: true,
131
+ duration_ms: Date.now() - startedAt,
132
+ })
133
+ return result
134
+ } catch (err) {
135
+ logger?.error('http.call', {
136
+ method,
137
+ path: apiPath,
138
+ ok: false,
139
+ duration_ms: Date.now() - startedAt,
140
+ ...(err instanceof ApiCallError
141
+ ? { status: err.status, code: err.code, message: err.message, details: err.details }
142
+ : { message: String(err) }),
143
+ })
144
+ throw err
145
+ }
146
+ }
147
+
148
+ async function callOnce<T>(
149
+ method: string,
150
+ apiPath: string,
151
+ body?: unknown,
152
+ serverWaitSeconds = 0,
93
153
  ): Promise<T> {
94
154
  // Without this, a server that accepts the connection and then never answers
95
155
  // hangs until the harness kills the whole hook — and the reply-poll deadline
96
156
  // cannot interrupt an individual fetch. The signal covers the
97
157
  // body read too, not just the response headers.
98
- const limitMs = budgetMs + serverWaitSeconds * 1000
158
+ const requestBudgetMs = budgetMs + serverWaitSeconds * 1000
159
+ const remainingMs =
160
+ options.deadlineAt === undefined
161
+ ? requestBudgetMs
162
+ : Math.max(1, options.deadlineAt - now())
163
+ const limitMs = Math.max(1, Math.min(requestBudgetMs, remainingMs))
99
164
  const signal = AbortSignal.timeout(limitMs)
100
165
  let response: Response
101
166
  try {
@@ -154,7 +219,10 @@ export function createClient(
154
219
  waitSeconds,
155
220
  ),
156
221
  closeReplies: (requestId) =>
157
- call('POST', `/api/v1/notifications/${encodeURIComponent(requestId)}/replies/close`),
222
+ call<ListRepliesResponse>(
223
+ 'POST',
224
+ `/api/v1/notifications/${encodeURIComponent(requestId)}/replies/close`,
225
+ ),
158
226
  createMediaUpload: (body) => call('POST', '/api/v1/media', body),
159
227
  uploadMedia: async (grant, bytes) => {
160
228
  let response: Response