@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
@@ -0,0 +1,1855 @@
1
+ import { randomBytes } from 'node:crypto';
2
+ import { spawn } from 'node:child_process';
3
+ import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
4
+ import os from 'node:os';
5
+ import path from 'node:path';
6
+ import { parse as parseToml, stringify as stringifyToml } from 'smol-toml';
7
+ import { CAPABILITIES_V1, REPLY_MAX_WINDOW_SECONDS, validateDraft, } from '@raidiant/notifai-protocol';
8
+ import { sha256Hex } from '@raidiant/notifai-protocol/node';
9
+ import { ApiCallError, NetworkError, createClient, } from './client.js';
10
+ import { BOOLEAN_CONFIG_KEYS, CONFIG_KEYS, NUMERIC_CONFIG_KEYS, configBounds, findProjectConfigPath, findProjectLocalConfigPath, globalConfigPath, loadConfig, sessionConfigPath, } from './config.js';
11
+ import { firstBlocker, openItems } from './readiness.js';
12
+ import { handleSessionEnd, handleStop, handleUserPromptSubmit, parseHookInput, pruneAbandonedSessions, readProjectSession, registerQuestion, } from './hooks.js';
13
+ import { readIdleSeconds } from './idle.js';
14
+ import { HARNESSES, applyPlan, blockingHookTimeoutSeconds, buildCursorHookConfig, buildHookConfig, codexLayerDir, codexProjectRoot, detectHarness, findInstallations, handlerEvent, loadCursorSettings, loadSettings, mergeCursorHooks, mergeHooks, removeCursorHooks, removeHooks, settingsFile, } from './install-hooks.js';
15
+ import { isOurOpencodePlugin, opencodePluginSource, } from './opencode-plugin.js';
16
+ import { CHOICE_USAGE, ambiguousChoiceSplit, buildDraft, formatReceipt, parseChoices, receiptExitCode, } from './send.js';
17
+ export const EXIT = {
18
+ ok: 0,
19
+ failed: 1,
20
+ usage: 2,
21
+ noReply: 3,
22
+ auth: 4,
23
+ network: 5,
24
+ };
25
+ function makeClient(deps, baseUrl, bearer, options) {
26
+ return (deps.clientFactory ?? createClient)(baseUrl, bearer, options);
27
+ }
28
+ function resolvedBaseUrl(config, credential) {
29
+ return config.base_url.source === 'default' && credential ? credential.baseUrl : config.base_url.value;
30
+ }
31
+ function authedClient(deps, config) {
32
+ const credential = deps.store.load();
33
+ if (!credential) {
34
+ deps.io.err('Not signed in. Run `notifai login` first.');
35
+ return null;
36
+ }
37
+ const baseUrl = resolvedBaseUrl(config, credential);
38
+ return {
39
+ client: makeClient(deps, baseUrl, `Bearer nfm_${credential.machineId}.${credential.secret}`),
40
+ baseUrl,
41
+ };
42
+ }
43
+ function reportError(deps, err) {
44
+ if (err instanceof ApiCallError) {
45
+ deps.io.err(`${err.code}: ${err.message}`);
46
+ if (err.nextAction)
47
+ deps.io.err(`next: ${err.nextAction}`);
48
+ if (err.code === 'auth_required' || err.code === 'machine_revoked')
49
+ return EXIT.auth;
50
+ return err.status >= 500 ? EXIT.network : EXIT.failed;
51
+ }
52
+ if (err instanceof NetworkError) {
53
+ deps.io.err(err.message);
54
+ return EXIT.network;
55
+ }
56
+ deps.io.err(String(err));
57
+ return EXIT.failed;
58
+ }
59
+ // ---------------------------------------------------------------------------
60
+ // login / logout / auth status
61
+ // ---------------------------------------------------------------------------
62
+ export async function loginCommand(deps, flags) {
63
+ const config = loadConfig({ cwd: deps.cwd, env: deps.env, flags: { base_url: flags.baseUrl } });
64
+ const baseUrl = config.base_url.value;
65
+ const machineName = flags.name ?? os.hostname();
66
+ const secret = randomBytes(32).toString('base64url');
67
+ const pollVerifier = randomBytes(24).toString('base64url');
68
+ const client = makeClient(deps, baseUrl, null);
69
+ let begin;
70
+ try {
71
+ begin = await client.beginPairing({
72
+ machine_name: machineName,
73
+ credential_hash: sha256Hex(secret),
74
+ poll_verifier_hash: sha256Hex(pollVerifier),
75
+ });
76
+ }
77
+ catch (err) {
78
+ return reportError(deps, err);
79
+ }
80
+ const interactive = deps.io.interactive === true;
81
+ if (interactive) {
82
+ await deps.io.intro?.('NotifAI sign in');
83
+ await deps.io.note?.(`Code: ${begin.code}\n${begin.approve_url}`, 'Approve this machine');
84
+ }
85
+ else {
86
+ deps.io.out(`Pairing code: ${begin.code}`);
87
+ deps.io.out(`Approve this machine at: ${begin.approve_url}`);
88
+ deps.io.out('Waiting for approval…');
89
+ }
90
+ if (flags.open !== false)
91
+ deps.io.openUrl(begin.approve_url);
92
+ const expiresAt = new Date(begin.expires_at).getTime();
93
+ const intervalMs = Math.max(begin.poll_interval_seconds, 1) * 1000;
94
+ const now = deps.now ?? Date.now;
95
+ const sleep = deps.sleep ?? ((milliseconds) => new Promise((resolve) => setTimeout(resolve, milliseconds)));
96
+ const spinner = interactive ? await deps.io.spinner?.('Waiting for approval…') : null;
97
+ while (now() < expiresAt) {
98
+ await sleep(intervalMs);
99
+ let poll;
100
+ try {
101
+ poll = await client.pollPairing(begin.pairing_id, pollVerifier);
102
+ }
103
+ catch (err) {
104
+ if (err instanceof NetworkError) {
105
+ spinner?.message('Connection lost — retrying…');
106
+ continue;
107
+ }
108
+ spinner?.error('Pairing failed');
109
+ return reportError(deps, err);
110
+ }
111
+ if (poll.status === 'approved' && poll.machine_id) {
112
+ deps.store.save({ machineId: poll.machine_id, secret, baseUrl, machineName });
113
+ if (interactive) {
114
+ spinner?.stop(`Machine "${machineName}" approved`);
115
+ await deps.io.outro?.(`Credential stored in ${deps.store.describe()}`);
116
+ }
117
+ else {
118
+ deps.io.out(`Machine "${machineName}" approved. Credential stored in ${deps.store.describe()}.`);
119
+ }
120
+ return EXIT.ok;
121
+ }
122
+ if (poll.status === 'denied') {
123
+ spinner?.error('Pairing denied');
124
+ deps.io.err('Pairing was denied from the dashboard.');
125
+ return EXIT.auth;
126
+ }
127
+ if (poll.status === 'expired')
128
+ break;
129
+ spinner?.message('Waiting for approval…');
130
+ }
131
+ spinner?.error('Pairing expired');
132
+ deps.io.err('Pairing expired before it was approved. Run `notifai login` again.');
133
+ return EXIT.auth;
134
+ }
135
+ export function logoutCommand(deps) {
136
+ deps.store.clear();
137
+ deps.io.out('Machine credential removed. Revoke it in the dashboard too if the machine is untrusted.');
138
+ return EXIT.ok;
139
+ }
140
+ export function authStatusCommand(deps, flags) {
141
+ const credential = deps.store.load();
142
+ if (flags.json) {
143
+ deps.io.out(JSON.stringify(credential
144
+ ? {
145
+ signed_in: true,
146
+ machine_id: credential.machineId,
147
+ machine_name: credential.machineName,
148
+ base_url: credential.baseUrl,
149
+ store: deps.store.describe(),
150
+ }
151
+ : { signed_in: false }, null, 2));
152
+ return credential ? EXIT.ok : EXIT.auth;
153
+ }
154
+ if (!credential) {
155
+ deps.io.err('Not signed in. Run `notifai login`.');
156
+ return EXIT.auth;
157
+ }
158
+ deps.io.out(`Signed in as machine "${credential.machineName}" (${credential.machineId})`);
159
+ deps.io.out(`Server: ${credential.baseUrl}`);
160
+ deps.io.out(`Credential store: ${deps.store.describe()}`);
161
+ return EXIT.ok;
162
+ }
163
+ // ---------------------------------------------------------------------------
164
+ // devices / capabilities
165
+ // ---------------------------------------------------------------------------
166
+ export async function devicesCommand(deps, flags) {
167
+ const config = loadConfig({ cwd: deps.cwd, env: deps.env });
168
+ const authed = authedClient(deps, config);
169
+ if (!authed)
170
+ return EXIT.auth;
171
+ try {
172
+ const result = await authed.client.listDevices();
173
+ if (flags.json) {
174
+ deps.io.out(JSON.stringify(result, null, 2));
175
+ return EXIT.ok;
176
+ }
177
+ if (result.devices.length === 0) {
178
+ deps.io.out('No devices. Install a NotifAI companion app on a device and sign in.');
179
+ return EXIT.ok;
180
+ }
181
+ for (const d of result.devices) {
182
+ deps.io.out(`${d.device_id} ${d.display_name} ${d.platform} ${d.registration_healthy ? 'ready' : 'not ready'} (permission: ${d.permission_status})`);
183
+ }
184
+ return EXIT.ok;
185
+ }
186
+ catch (err) {
187
+ return reportError(deps, err);
188
+ }
189
+ }
190
+ export async function capabilitiesCommand(deps, flags) {
191
+ const config = loadConfig({ cwd: deps.cwd, env: deps.env });
192
+ const credential = deps.store.load();
193
+ const baseUrl = resolvedBaseUrl(config, credential);
194
+ const client = makeClient(deps, baseUrl, null);
195
+ try {
196
+ const doc = await client.capabilities(flags.platform ?? 'ios');
197
+ if (flags.json) {
198
+ deps.io.out(JSON.stringify(doc, null, 2));
199
+ return EXIT.ok;
200
+ }
201
+ deps.io.out(`${doc.platform} capability contract v${doc.schema_version} (payload limit ${doc.payload_limit_bytes} bytes)`);
202
+ for (const field of doc.fields) {
203
+ deps.io.out(` ${field.path}: ${field.status}${field.reason ? ` — ${field.reason}` : ''}`);
204
+ }
205
+ return EXIT.ok;
206
+ }
207
+ catch (err) {
208
+ return reportError(deps, err);
209
+ }
210
+ }
211
+ // ---------------------------------------------------------------------------
212
+ // send / status
213
+ // ---------------------------------------------------------------------------
214
+ export async function sendCommand(deps, flags) {
215
+ const hasReplyChoice = Array.isArray(flags.replyChoice)
216
+ ? flags.replyChoice.length > 0
217
+ : flags.replyChoice !== undefined;
218
+ if (!flags.reply &&
219
+ (flags.replyTimeout !== undefined || flags.replyWindow !== undefined || flags.noBlock || hasReplyChoice)) {
220
+ deps.io.err('Use --reply with --reply-timeout, --reply-window, --reply-choice, or --no-block.');
221
+ return EXIT.usage;
222
+ }
223
+ const replyTimeout = flags.replyTimeout ?? 900;
224
+ if (flags.reply && !isNonNegativeInteger(replyTimeout)) {
225
+ deps.io.err('--reply-timeout must be a non-negative integer number of seconds.');
226
+ return EXIT.usage;
227
+ }
228
+ // Asking while declaring that nothing will wait for the answer. The reply is
229
+ // captured server-side and then unreachable: only a blocking send waits for
230
+ // it, and the hook path drains questions registered by `ask`, never a send's
231
+ // request id. So the user gets a real button, taps it, and nothing happens —
232
+ // worse than a banner that never asked, because it spends their attention
233
+ // and their trust in the channel.
234
+ //
235
+ // Both spellings of "do not wait" are rejected, because the defect is the
236
+ // zero wait and not the flag that produced it.
237
+ if (flags.reply && (flags.noBlock || replyTimeout === 0)) {
238
+ deps.io.err('A question needs someone to hear the answer, so --reply cannot be combined ' +
239
+ 'with --no-block or --reply-timeout 0.\n' +
240
+ 'To ask and end the turn, use `notifai ask` — the turn-end hook returns the answer.\n' +
241
+ 'To announce finished work, drop --reply and its choices.');
242
+ return EXIT.usage;
243
+ }
244
+ if (flags.reply &&
245
+ flags.replyWindow !== undefined &&
246
+ (!Number.isInteger(flags.replyWindow) ||
247
+ flags.replyWindow < 60 ||
248
+ flags.replyWindow > REPLY_MAX_WINDOW_SECONDS)) {
249
+ deps.io.err(`--reply-window must be an integer from 60 to ${REPLY_MAX_WINDOW_SECONDS} seconds.`);
250
+ return EXIT.usage;
251
+ }
252
+ const config = loadConfig({
253
+ cwd: deps.cwd,
254
+ env: deps.env,
255
+ flags: { base_url: flags.baseUrl, wait_seconds: flags.wait },
256
+ });
257
+ const authed = authedClient(deps, config);
258
+ if (!authed)
259
+ return EXIT.auth;
260
+ if (flags.image !== undefined && !flags.image.startsWith('med_')) {
261
+ const uploaded = await uploadImage(deps, authed.client, flags.image);
262
+ if (!uploaded.ok) {
263
+ deps.io.err(uploaded.error);
264
+ return uploaded.exit;
265
+ }
266
+ flags = { ...flags, image: uploaded.mediaId };
267
+ }
268
+ const build = buildDraft(config, flags);
269
+ if (!build.ok) {
270
+ deps.io.err(build.error);
271
+ return EXIT.usage;
272
+ }
273
+ const capabilities = CAPABILITIES_V1.describe(build.platform);
274
+ if (!capabilities) {
275
+ deps.io.err(`No capability contract is available for ${build.platform}.`);
276
+ return EXIT.usage;
277
+ }
278
+ const validation = validateDraft(build.draft, capabilities);
279
+ if (!validation.ok) {
280
+ for (const issue of validation.errors)
281
+ deps.io.err(`${issue.path}: ${issue.message}`);
282
+ return EXIT.usage;
283
+ }
284
+ if (!flags.reply &&
285
+ (flags.title.trim().endsWith('?') || flags.body.trim().endsWith('?'))) {
286
+ deps.io.err('Heads up: this notification ends with a question but has no reply action. Add --reply (and optionally --reply-choice) so it can be answered from the notification.');
287
+ }
288
+ const waitSeconds = flags.noWait ? 0 : config.wait_seconds.value;
289
+ const idempotencyKey = flags.idempotencyKey ?? `cli-${randomBytes(12).toString('base64url')}`;
290
+ try {
291
+ const receipt = await authed.client.submit({ idempotency_key: idempotencyKey, draft: build.draft }, waitSeconds);
292
+ const receiptExit = receiptExitCode(receipt);
293
+ if (!flags.json)
294
+ deps.io.out(formatReceipt(receipt));
295
+ // A zero wait can no longer reach here: --reply guarantees a positive one.
296
+ if (!flags.reply || receiptExit !== EXIT.ok) {
297
+ if (flags.json) {
298
+ deps.io.out(JSON.stringify(flags.reply ? { receipt, replies: [] } : receipt, null, 2));
299
+ }
300
+ return receiptExit;
301
+ }
302
+ const result = await waitForReply(authed.client, receipt.request_id, {
303
+ timeoutSeconds: replyTimeout,
304
+ afterSeq: 0,
305
+ now: deps.now,
306
+ sleep: deps.sleep,
307
+ });
308
+ if (flags.json) {
309
+ deps.io.out(JSON.stringify({ receipt, replies: result.response.replies, degraded: result.degraded }, null, 2));
310
+ }
311
+ else if (result.response.replies.length > 0)
312
+ printReplies(deps, result.response.replies);
313
+ else
314
+ printNoReply(deps, receipt.request_id, result.response.reply_expires_at);
315
+ if (result.degraded) {
316
+ deps.io.err(DEGRADED_WAIT_WARNING);
317
+ return EXIT.network;
318
+ }
319
+ return result.timedOut ? EXIT.noReply : EXIT.ok;
320
+ }
321
+ catch (err) {
322
+ return reportError(deps, err);
323
+ }
324
+ }
325
+ export async function repliesCommand(deps, requestId, flags) {
326
+ const waitSeconds = flags.wait ?? 0;
327
+ const afterSeq = flags.after ?? 0;
328
+ if (!isNonNegativeInteger(waitSeconds)) {
329
+ deps.io.err('--wait must be a non-negative integer number of seconds.');
330
+ return EXIT.usage;
331
+ }
332
+ if (!isNonNegativeInteger(afterSeq)) {
333
+ deps.io.err('--after must be a non-negative integer sequence number.');
334
+ return EXIT.usage;
335
+ }
336
+ const config = loadConfig({ cwd: deps.cwd, env: deps.env });
337
+ const authed = authedClient(deps, config);
338
+ if (!authed)
339
+ return EXIT.auth;
340
+ try {
341
+ const result = await waitForReply(authed.client, requestId, {
342
+ timeoutSeconds: waitSeconds,
343
+ afterSeq,
344
+ now: deps.now,
345
+ sleep: deps.sleep,
346
+ });
347
+ if (flags.json) {
348
+ deps.io.out(JSON.stringify({ ...result.response, degraded: result.degraded }, null, 2));
349
+ }
350
+ else if (result.response.replies.length > 0)
351
+ printReplies(deps, result.response.replies);
352
+ else
353
+ printNoReply(deps, requestId, result.response.reply_expires_at);
354
+ if (result.degraded) {
355
+ deps.io.err(DEGRADED_WAIT_WARNING);
356
+ return EXIT.network;
357
+ }
358
+ return result.timedOut ? EXIT.noReply : EXIT.ok;
359
+ }
360
+ catch (err) {
361
+ return reportError(deps, err);
362
+ }
363
+ }
364
+ /** Loop over server-capped long polls until a reply arrives or the caller's deadline passes. */
365
+ export async function waitForReply(client, requestId, options) {
366
+ const now = options.now ?? Date.now;
367
+ const sleep = options.sleep ?? ((milliseconds) => new Promise((resolve) => setTimeout(resolve, milliseconds)));
368
+ const deadline = now() + options.timeoutSeconds * 1000;
369
+ let lastResponse = null;
370
+ let lastNetworkError = null;
371
+ let consecutiveNetworkErrors = 0;
372
+ let firstPoll = true;
373
+ while (firstPoll || now() < deadline) {
374
+ firstPoll = false;
375
+ const remainingMs = Math.max(0, deadline - now());
376
+ const waitSeconds = Math.min(25, Math.floor(remainingMs / 1000));
377
+ try {
378
+ const response = await client.replies(requestId, {
379
+ waitSeconds,
380
+ afterSeq: options.afterSeq,
381
+ });
382
+ lastResponse = response;
383
+ lastNetworkError = null;
384
+ consecutiveNetworkErrors = 0;
385
+ if (response.replies.length > 0)
386
+ return { response, timedOut: false, degraded: false };
387
+ const pauseMs = Math.min(250, Math.max(0, deadline - now()));
388
+ if (pauseMs > 0)
389
+ await sleep(pauseMs);
390
+ }
391
+ catch (err) {
392
+ if (!(err instanceof NetworkError))
393
+ throw err;
394
+ lastNetworkError = err;
395
+ consecutiveNetworkErrors += 1;
396
+ const remainingAfterError = Math.max(0, deadline - now());
397
+ if (remainingAfterError === 0)
398
+ break;
399
+ const backoffMs = Math.min(250 * 2 ** (consecutiveNetworkErrors - 1), 2_000, remainingAfterError);
400
+ await sleep(backoffMs);
401
+ }
402
+ }
403
+ if (!lastResponse && lastNetworkError)
404
+ throw lastNetworkError;
405
+ return {
406
+ response: lastResponse ??
407
+ { request_id: requestId, reply_expires_at: null, replies: [] },
408
+ timedOut: true,
409
+ // A poll succeeded at some point, so we do not throw — but the last thing
410
+ // we know is that we could not reach the server. Reporting that as a plain
411
+ // "no reply" would let an agent treat an unseen refusal as consent.
412
+ degraded: lastNetworkError !== null,
413
+ };
414
+ }
415
+ /**
416
+ * Shared by every surface that waits: "the user did not answer" and "I could
417
+ * not find out" must not look the same, because agents branch on the exit code
418
+ * and one of those two branches is safe to proceed from.
419
+ */
420
+ const DEGRADED_WAIT_WARNING = 'notifai: the wait ended during a network outage, so this is "could not find out", ' +
421
+ 'not "no answer" — the reply may already be waiting. Retry with `notifai replies <id>`.';
422
+ function isNonNegativeInteger(value) {
423
+ return Number.isInteger(value) && value >= 0;
424
+ }
425
+ function printReplies(deps, replies) {
426
+ for (const reply of replies)
427
+ deps.io.out(`reply from ${reply.device_name}: ${reply.text}`);
428
+ const contradiction = contradictingAnswer(replies);
429
+ if (contradiction !== null)
430
+ deps.io.err(contradiction);
431
+ }
432
+ /**
433
+ * The other half of first-reply-wins.
434
+ *
435
+ * A blocking send returns the first non-empty batch and acts on it, so a later
436
+ * answer that disagrees is silently ignored. That is the right default — an
437
+ * agent wants one answer, not a quorum — but silently is the wrong way to do
438
+ * it. The server now retires the question on the other devices the moment one
439
+ * answers, which makes this rare; it is still reachable, because a device can
440
+ * answer between the first reply landing and its retirement arriving.
441
+ *
442
+ * Ordered by seq, so `replies[0]` is the one that won.
443
+ */
444
+ export function contradictingAnswer(replies) {
445
+ const winner = replies[0];
446
+ if (winner === undefined || replies.length < 2)
447
+ return null;
448
+ const disagreeing = replies
449
+ .slice(1)
450
+ .filter((reply) => (reply.choice_id ?? reply.text) !== (winner.choice_id ?? winner.text));
451
+ if (disagreeing.length === 0)
452
+ return null;
453
+ const names = [...new Set(disagreeing.map((reply) => reply.device_name))].join(', ');
454
+ return (`note: "${winner.text}" from ${winner.device_name} is the answer that counts — it arrived first. ` +
455
+ `${names} answered differently afterwards and that answer was not used.`);
456
+ }
457
+ function printNoReply(deps, requestId, expiresAt) {
458
+ // A harness hook may have retired this question, in which case promising an
459
+ // open window would send the caller back to wait for an answer the server
460
+ // will now refuse.
461
+ const closed = expiresAt != null && Date.parse(expiresAt) <= Date.now();
462
+ deps.io.out(closed
463
+ ? `no reply for request ${requestId}; the reply window has closed`
464
+ : `no reply yet for request ${requestId}; the reply window remains open`);
465
+ }
466
+ export async function statusCommand(deps, requestId, flags) {
467
+ const config = loadConfig({ cwd: deps.cwd, env: deps.env });
468
+ const authed = authedClient(deps, config);
469
+ if (!authed)
470
+ return EXIT.auth;
471
+ try {
472
+ const snapshot = await authed.client.evidence(requestId);
473
+ if (flags.json) {
474
+ deps.io.out(JSON.stringify(snapshot, null, 2));
475
+ return EXIT.ok;
476
+ }
477
+ deps.io.out(`request ${snapshot.request_id} (${snapshot.event ?? 'no event'}) — ${snapshot.overall}`);
478
+ for (const d of snapshot.deliveries) {
479
+ deps.io.out(` ${d.device_name}: ${d.state} after ${d.attempts} attempt(s)`);
480
+ for (const e of d.events) {
481
+ deps.io.out(` ${e.occurred_at} ${e.stage}${e.reason ? ` (${e.reason})` : ''}`);
482
+ }
483
+ }
484
+ return EXIT.ok;
485
+ }
486
+ catch (err) {
487
+ return reportError(deps, err);
488
+ }
489
+ }
490
+ const MEDIA_TYPES = {
491
+ '.jpg': 'image/jpeg',
492
+ '.jpeg': 'image/jpeg',
493
+ '.png': 'image/png',
494
+ '.gif': 'image/gif',
495
+ };
496
+ /** `--image` accepts a media id, a local file path, or an http(s) URL. */
497
+ async function uploadImage(deps, client, source) {
498
+ let bytes;
499
+ let mediaType;
500
+ if (/^https?:\/\//.test(source)) {
501
+ try {
502
+ const response = await fetch(source);
503
+ if (!response.ok)
504
+ return { ok: false, error: `Could not fetch ${source} (${response.status}).`, exit: EXIT.usage };
505
+ const contentType = response.headers.get('content-type')?.split(';')[0]?.trim() ?? '';
506
+ mediaType = ['image/jpeg', 'image/png', 'image/gif'].find((t) => t === contentType);
507
+ bytes = new Uint8Array(await response.arrayBuffer());
508
+ }
509
+ catch (err) {
510
+ return { ok: false, error: `Could not fetch ${source}: ${String(err)}`, exit: EXIT.network };
511
+ }
512
+ }
513
+ else {
514
+ if (!existsSync(source)) {
515
+ return { ok: false, error: `--image: "${source}" is not a media id, file, or URL.`, exit: EXIT.usage };
516
+ }
517
+ bytes = new Uint8Array(readFileSync(source));
518
+ mediaType = MEDIA_TYPES[path.extname(source).toLowerCase()];
519
+ }
520
+ if (!mediaType) {
521
+ return { ok: false, error: 'Images must be JPEG, PNG, or GIF.', exit: EXIT.usage };
522
+ }
523
+ try {
524
+ const grant = await client.createMediaUpload({
525
+ media_type: mediaType,
526
+ size_bytes: bytes.byteLength,
527
+ sha256: sha256Hex(bytes),
528
+ });
529
+ await client.uploadMedia(grant, bytes);
530
+ return { ok: true, mediaId: grant.media_id };
531
+ }
532
+ catch (err) {
533
+ return { ok: false, error: err instanceof Error ? err.message : String(err), exit: EXIT.network };
534
+ }
535
+ }
536
+ // ---------------------------------------------------------------------------
537
+ // hook / ask / close — harness integration
538
+ // ---------------------------------------------------------------------------
539
+ export const HOOK_EVENTS = ['user-prompt-submit', 'stop', 'session-end'];
540
+ /**
541
+ * Runs one harness hook. Contract with every harness: hook JSON arrives on
542
+ * stdin, the decision (if any) goes to stdout, diagnostics go to stderr, and
543
+ * exit 0 with no stdout means "no decision — carry on as normal".
544
+ *
545
+ * Every failure path in here must reach that no-decision state. A hook that
546
+ * throws, or that blocks past the harness's timeout, degrades the agent for a
547
+ * feature the user only asked to make it more convenient.
548
+ */
549
+ export async function hookRunCommand(deps, event, readStdin, harness) {
550
+ if (!HOOK_EVENTS.includes(event)) {
551
+ deps.io.err(`Unknown hook event "${event}". Valid: ${HOOK_EVENTS.join(', ')}`);
552
+ return EXIT.usage;
553
+ }
554
+ let envelope;
555
+ try {
556
+ envelope = parseHookInput(await readStdin());
557
+ if (harness === 'cursor') {
558
+ const sessionId = envelope.session_id ?? envelope.conversation_id;
559
+ const cwd = envelope.cwd ?? envelope.workspace_roots?.[0];
560
+ envelope = {
561
+ ...envelope,
562
+ ...(sessionId === undefined ? {} : { session_id: sessionId }),
563
+ ...(cwd === undefined ? {} : { cwd }),
564
+ stop_hook_active: envelope.stop_hook_active ??
565
+ (typeof envelope.loop_count === 'number' && envelope.loop_count > 0),
566
+ };
567
+ }
568
+ }
569
+ catch {
570
+ return EXIT.ok;
571
+ }
572
+ // Everything below is inside one fail-open boundary. Config parsing,
573
+ // credential loading and client construction can all throw, and a hook that
574
+ // exits non-zero makes the harness report a failure — strictly worse for the
575
+ // user than not having installed the hook at all.
576
+ try {
577
+ if (event === 'session-end') {
578
+ const outcome = handleSessionEnd(deps.env, envelope, (deps.now ?? Date.now)());
579
+ for (const note of outcome.notes)
580
+ deps.io.err(`notifai: ${note}`);
581
+ return EXIT.ok;
582
+ }
583
+ // Resolve config against the session's project rather than our own working
584
+ // directory. `cwd` is in the payload precisely because which project a
585
+ // session belongs to is the harness's business, not ours.
586
+ const cwd = envelope.cwd ?? deps.cwd;
587
+ const config = loadConfig({ cwd, env: deps.env, sessionId: envelope.session_id });
588
+ const credential = deps.store.load();
589
+ if (!credential)
590
+ return EXIT.ok;
591
+ // Pin authenticated traffic to the origin the credential was issued for. A
592
+ // repository can commit `.notifai/config.toml`, and honouring a base_url
593
+ // from it would hand this machine's bearer token to whatever host it names.
594
+ const baseUrl = credential.baseUrl;
595
+ if (config.base_url.source !== 'default' && config.base_url.value !== baseUrl) {
596
+ deps.io.err(`notifai: ignoring base_url from ${config.base_url.source}; hooks only talk to ${baseUrl}`);
597
+ }
598
+ // UserPromptSubmit runs in front of the user's own prompt under a 15s
599
+ // harness ceiling and can make two calls, so each gets a small slice of it;
600
+ // Stop is allowed to block and keeps the ordinary budget.
601
+ const client = makeClient(deps, baseUrl, `Bearer nfm_${credential.machineId}.${credential.secret}`, { timeoutMs: event === 'user-prompt-submit' ? 4_000 : 20_000 });
602
+ const now = deps.now ?? Date.now;
603
+ const ctx = {
604
+ client,
605
+ config,
606
+ env: deps.env,
607
+ now,
608
+ idleSeconds: deps.idleSeconds ?? (() => readIdleSeconds()),
609
+ sleep: deps.sleep ?? ((ms) => new Promise((resolve) => setTimeout(resolve, ms))),
610
+ waitForFirstReply: async (requestId, timeoutSeconds) => {
611
+ const result = await waitForReply(client, requestId, {
612
+ timeoutSeconds,
613
+ afterSeq: 0,
614
+ now: deps.now,
615
+ sleep: deps.sleep,
616
+ });
617
+ return {
618
+ replies: result.response.replies,
619
+ timedOut: result.timedOut,
620
+ degraded: result.degraded,
621
+ };
622
+ },
623
+ };
624
+ // Real clock, deliberately, not `deps.now`. This compares against file
625
+ // mtimes, which are wall-clock facts — handing it a virtual or skewed clock
626
+ // would have it delete live session state as "abandoned".
627
+ // Rate-limited to once a day by its own stamp file, so the common cost is
628
+ // one stat on a hook that sits on the critical path of every turn.
629
+ pruneAbandonedSessions(deps.env);
630
+ const outcome = event === 'user-prompt-submit'
631
+ ? await handleUserPromptSubmit(ctx, envelope)
632
+ : await handleStop(ctx, envelope);
633
+ for (const note of outcome.notes)
634
+ deps.io.err(`notifai: ${note}`);
635
+ if (outcome.stdout !== undefined) {
636
+ let stdout = outcome.stdout;
637
+ if (harness === 'cursor' && event === 'stop') {
638
+ const decision = JSON.parse(outcome.stdout);
639
+ if (decision.decision === 'block' && typeof decision.reason === 'string') {
640
+ stdout = JSON.stringify({ followup_message: decision.reason });
641
+ }
642
+ }
643
+ deps.io.out(stdout);
644
+ }
645
+ return EXIT.ok;
646
+ }
647
+ catch (err) {
648
+ for (const line of describeHookFailure(err))
649
+ deps.io.err(`notifai: ${line}`);
650
+ return EXIT.ok;
651
+ }
652
+ }
653
+ /**
654
+ * What went wrong, in terms of what to do about it.
655
+ *
656
+ * On 2026-08-03 a contract change shipped without the server deploy that goes
657
+ * with it. The CLI stamped `lifecycle` on every question draft, the deployed
658
+ * server rejected the unknown field, and escalation stopped working entirely —
659
+ * announced as "hook failed, deferring to the terminal", which reads like a
660
+ * flaky network. The information needed to diagnose it in one second was
661
+ * already in hand: a 422 whose details name the offending path. It was being
662
+ * thrown away by `String(err)`.
663
+ *
664
+ * A hook still exits 0 whatever this says. Handing the terminal back is always
665
+ * right; the only question is whether the user is told anything they can use.
666
+ */
667
+ export function describeHookFailure(err) {
668
+ if (!(err instanceof ApiCallError)) {
669
+ return [`hook failed, deferring to the terminal (${String(err)})`];
670
+ }
671
+ const lines = [`hook failed, deferring to the terminal (${err.code}: ${err.message})`];
672
+ const paths = rejectedPaths(err.details);
673
+ if (paths.length > 0)
674
+ lines.push(`the server rejected: ${paths.join(', ')}`);
675
+ // A 422 on a draft this CLI built is not a user error — this CLI's own
676
+ // contract produced it. Either the server is behind, or the two disagree.
677
+ if (err.status === 422) {
678
+ lines.push('this build sent a field the server did not accept, which usually means the server ' +
679
+ 'is older than this CLI — check with `notifai doctor`');
680
+ }
681
+ return lines;
682
+ }
683
+ function rejectedPaths(details) {
684
+ if (!Array.isArray(details))
685
+ return [];
686
+ return details
687
+ .map((issue) => typeof issue === 'object' && issue !== null && typeof issue.path === 'string'
688
+ ? issue.path
689
+ : null)
690
+ .filter((path) => path !== null && path !== '')
691
+ .slice(0, 5);
692
+ }
693
+ /**
694
+ * Registers a question for turn-end routing under the user's presence config.
695
+ * Returns immediately so the agent can ask in prose and end its turn. With the
696
+ * default presence gate, recent keyboard or mouse activity keeps it local;
697
+ * `require_idle = false` intentionally permits a push while the user is active.
698
+ */
699
+ export function askCommand(deps, question, flags) {
700
+ // An agent calling this gets no hook payload and no harness exports its
701
+ // session id, so the UserPromptSubmit hook leaves a pointer keyed on the
702
+ // project directory and we read it back here. The pointer outranks the
703
+ // NOTIFAI_SESSION fallback deliberately: the exported id is often a chosen
704
+ // label rather than the harness's own id, and the hooks key state by the
705
+ // latter — the env var is only trusted when no hook has spoken.
706
+ const now = (deps.now ?? Date.now)();
707
+ const sessionId = flags.session ??
708
+ readProjectSession(deps.cwd, deps.env, now) ??
709
+ deps.env['NOTIFAI_SESSION'] ??
710
+ undefined;
711
+ if (!sessionId) {
712
+ for (const line of diagnoseMissingSession(deps))
713
+ deps.io.err(line);
714
+ return EXIT.usage;
715
+ }
716
+ if (question.trim() === '') {
717
+ deps.io.err('The question cannot be empty.');
718
+ return EXIT.usage;
719
+ }
720
+ // Validate here, not at push time. The push happens inside a hook, where a
721
+ // rejection becomes a stderr note the agent never reads — so a malformed
722
+ // choice set would look like it registered fine and then silently never ask.
723
+ const choices = parseChoices(flags.choice);
724
+ if (choices === 'invalid') {
725
+ deps.io.err(CHOICE_USAGE);
726
+ return EXIT.usage;
727
+ }
728
+ registerQuestion(sessionId, deps.env, {
729
+ question: question.trim(),
730
+ ...(choices !== null ? { choices: choices.map((choice) => choice.label) } : {}),
731
+ }, (deps.now ?? Date.now)());
732
+ if (choices !== null) {
733
+ deps.io.out(`Answers offered: ${choices.map((choice) => choice.label).join(' / ')}`);
734
+ }
735
+ // On stdout, not stderr: the agent reads the former and this is only useful
736
+ // if it is read while there is still time to fix the question.
737
+ const ambiguous = ambiguousChoiceSplit(flags.choice);
738
+ if (ambiguous !== null)
739
+ deps.io.out(ambiguous);
740
+ deps.io.out('Question registered. Ask it in the conversation as usual and end your turn.');
741
+ return EXIT.ok;
742
+ }
743
+ /**
744
+ * Why `ask` cannot see a session, in terms of what to do about it.
745
+ *
746
+ * Only a UserPromptSubmit hook firing produces the pointer this reads, and the
747
+ * old message answered every cause with "run `notifai hooks install` and send
748
+ * one prompt". The useful next action depends on the harness: some reload
749
+ * project hook files, OpenCode loads its plugin at startup, and Codex should be
750
+ * checked after a prompt before assuming that a new session is required.
751
+ */
752
+ function diagnoseMissingSession(deps) {
753
+ const installations = findInstallations(deps.cwd, deps.env);
754
+ if (installations.length === 0) {
755
+ return [
756
+ 'Could not tell which harness session this is: no NotifAI hooks are installed for this project.',
757
+ 'Run `notifai hooks install`, then follow the activation instruction it prints.',
758
+ ];
759
+ }
760
+ const where = installations.map((i) => `${i.harness} in ${i.file}`).join(', ');
761
+ return [
762
+ `Could not tell which harness session this is. NotifAI hooks are installed (${where}),`,
763
+ 'but no usable session pointer from the last 24 hours exists here.',
764
+ hookActivationAdvice(installations),
765
+ 'To ask from this session anyway, pass --session <id>.',
766
+ ];
767
+ }
768
+ /** The least disruptive verified way to make each installed adapter run once. */
769
+ function hookActivationAdvice(installations) {
770
+ const harnesses = new Set(installations.map((installation) => installation.harness));
771
+ const advice = [];
772
+ if (installations.some((installation) => installation.harness === 'claude-code' && !installation.global)) {
773
+ advice.push('Claude Code: send one new prompt; project hook files reload without a restart');
774
+ }
775
+ if (installations.some((installation) => installation.harness === 'claude-code' && installation.global)) {
776
+ advice.push('Claude Code global hooks: send one prompt; start a new session only if it does not fire');
777
+ }
778
+ if (installations.some((installation) => installation.harness === 'cursor' && !installation.global)) {
779
+ advice.push('Cursor: send one prompt, then run `notifai doctor`; start a new session only if it still has not fired');
780
+ }
781
+ if (installations.some((installation) => installation.harness === 'cursor' && installation.global)) {
782
+ advice.push('Cursor global hooks: send one prompt; start a new session only if it does not fire');
783
+ }
784
+ if (harnesses.has('codex')) {
785
+ advice.push('Codex: send one prompt, then run `notifai doctor`; start a new session only if it still has not fired');
786
+ }
787
+ if (harnesses.has('opencode')) {
788
+ advice.push('OpenCode: restart it, then send one prompt; plugins load at startup');
789
+ }
790
+ return `${advice.join('. ')}.`;
791
+ }
792
+ /** Retire a question so a late answer is rejected rather than silently lost. */
793
+ export async function closeCommand(deps, requestId) {
794
+ const config = loadConfig({ cwd: deps.cwd, env: deps.env });
795
+ const authed = authedClient(deps, config);
796
+ if (!authed)
797
+ return EXIT.auth;
798
+ try {
799
+ await authed.client.closeReplies(requestId);
800
+ deps.io.out(`Closed the reply window for ${requestId}.`);
801
+ return EXIT.ok;
802
+ }
803
+ catch (err) {
804
+ return reportError(deps, err);
805
+ }
806
+ }
807
+ export function hooksInstallCommand(deps, flags) {
808
+ const harness = resolveHarness(deps, flags.harness);
809
+ if (!harness)
810
+ return EXIT.usage;
811
+ const execPath = flags.execPath ?? process.execPath;
812
+ const scriptPath = flags.scriptPath ?? process.argv[1] ?? 'notifai';
813
+ const config = loadConfig({ cwd: deps.cwd, env: deps.env });
814
+ const file = settingsFile(harness, flags.global ?? false, deps.cwd, deps.env);
815
+ // OpenCode's adapter is a generated plugin module rather than a handler
816
+ // merged into a settings document, so it owns the whole file.
817
+ if (harness === 'opencode') {
818
+ return installOpencodePlugin(deps, file, {
819
+ execPath,
820
+ scriptPath,
821
+ timeoutSeconds: blockingHookTimeoutSeconds(config.ask_grace_seconds.value, config.hook_reply_timeout_seconds.value),
822
+ });
823
+ }
824
+ if (harness === 'cursor') {
825
+ let document;
826
+ try {
827
+ document = loadCursorSettings(file);
828
+ }
829
+ catch (err) {
830
+ deps.io.err(String(err));
831
+ return EXIT.failed;
832
+ }
833
+ const merged = mergeCursorHooks(document, buildCursorHookConfig({
834
+ execPath,
835
+ scriptPath,
836
+ replyTimeoutSeconds: config.hook_reply_timeout_seconds.value,
837
+ graceSeconds: config.ask_grace_seconds.value,
838
+ }), scriptPath);
839
+ try {
840
+ applyPlan(file, merged.document);
841
+ }
842
+ catch (err) {
843
+ deps.io.err(String(err));
844
+ return EXIT.failed;
845
+ }
846
+ deps.io.out(`Installed ${harness} hooks in ${file}`);
847
+ if (merged.replaced.length > 0)
848
+ deps.io.out(` replaced: ${merged.replaced.join(', ')}`);
849
+ if (merged.added.length > 0)
850
+ deps.io.out(` added: ${merged.added.join(', ')}`);
851
+ if (flags.global) {
852
+ deps.io.out('Send one Cursor prompt, then check `notifai doctor`. If the hook has not fired,');
853
+ deps.io.out('start a new Cursor session and try one prompt again.');
854
+ }
855
+ else {
856
+ deps.io.out('Send one Cursor prompt, then check `notifai doctor`. If the hook has not fired,');
857
+ deps.io.out('start a new Cursor session and try one prompt again.');
858
+ }
859
+ deps.io.out('A companion-device answer is submitted as one');
860
+ deps.io.out('follow-up user message; loop_limit = 1 prevents repeated answer turns.');
861
+ return EXIT.ok;
862
+ }
863
+ let document;
864
+ try {
865
+ document = loadSettings(file);
866
+ }
867
+ catch (err) {
868
+ deps.io.err(String(err));
869
+ return EXIT.failed;
870
+ }
871
+ const merged = mergeHooks(document, buildHookConfig({
872
+ execPath,
873
+ scriptPath,
874
+ replyTimeoutSeconds: config.hook_reply_timeout_seconds.value,
875
+ graceSeconds: config.ask_grace_seconds.value,
876
+ }), scriptPath);
877
+ try {
878
+ applyPlan(file, merged.document);
879
+ }
880
+ catch (err) {
881
+ deps.io.err(String(err));
882
+ return EXIT.failed;
883
+ }
884
+ deps.io.out(`Installed ${harness} hooks in ${file}`);
885
+ if (merged.replaced.length > 0)
886
+ deps.io.out(` replaced: ${merged.replaced.join(', ')}`);
887
+ if (merged.added.length > 0)
888
+ deps.io.out(` added: ${merged.added.join(', ')}`);
889
+ if (merged.removed.length > 0) {
890
+ deps.io.out(` removed: ${merged.removed.join(', ')} (this build no longer serves them)`);
891
+ }
892
+ deps.io.out('');
893
+ deps.io.out(config.require_idle.value
894
+ ? `While keyboard or mouse idle time stays below ${config.away_after_seconds.value}s, ` +
895
+ `nothing is pushed. A question registered with \`notifai ask\` stays in the terminal ` +
896
+ `until its ${config.ask_grace_seconds.value}s grace window, counted from registration, ` +
897
+ `has elapsed; it goes to your devices only while the machine also meets the idle threshold. ` +
898
+ `Set \`require_idle = false\` to be notified even while you are working.`
899
+ : `A question registered with \`notifai ask\` stays in the terminal for ` +
900
+ `${config.ask_grace_seconds.value}s from registration and then goes to your devices ` +
901
+ `whether or not you are at this machine ` +
902
+ `(\`require_idle = false\`).`);
903
+ if (config.require_idle.value) {
904
+ deps.io.out('If this OS exposes no keyboard/mouse idle signal, the hook falls back to prompt silence and skips the blocking grace once it decides you are away.');
905
+ }
906
+ if (harness === 'codex') {
907
+ const layer = flags.global ? null : codexLayerDir(deps.cwd);
908
+ if (layer !== null) {
909
+ // Codex reads project hooks from the main repository but only looks when
910
+ // a `.codex` directory sits at or above cwd, so a worktree install has to
911
+ // write one file and create one directory in two different places. Doing
912
+ // it silently would leave the next person deriving this the hard way
913
+ //.
914
+ mkdirSync(layer, { recursive: true });
915
+ deps.io.out('');
916
+ deps.io.out('You are in a worktree. Codex reads project hooks from the main repository,');
917
+ deps.io.out(`so they were written to ${file}. ${layer} was created so this`);
918
+ deps.io.out('worktree discovers that file. Each other worktree needs its own `.codex`');
919
+ deps.io.out('directory; rerun this installer from that worktree to create it.');
920
+ }
921
+ }
922
+ deps.io.out('');
923
+ if (harness === 'claude-code' && flags.global !== true) {
924
+ deps.io.out('Claude Code reloads project hook files without a restart. Send one new prompt,');
925
+ deps.io.out('then check `notifai doctor` to confirm that the hook fired.');
926
+ }
927
+ else if (harness === 'claude-code') {
928
+ deps.io.out('Send one new Claude Code prompt, then check `notifai doctor`. If the hook has');
929
+ deps.io.out('not fired, start a new Claude Code session and try one prompt again.');
930
+ }
931
+ else {
932
+ deps.io.out('Send one Codex prompt, then check `notifai doctor`. If the hook has not fired,');
933
+ deps.io.out('start a new Codex session and try one prompt again.');
934
+ }
935
+ return EXIT.ok;
936
+ }
937
+ /**
938
+ * Writes the OpenCode plugin, replacing any NotifAI plugin already there —
939
+ * including one a different checkout wrote, matched on the managed marker for
940
+ * the same reason command hooks are.
941
+ */
942
+ function installOpencodePlugin(deps, file, options) {
943
+ if (existsSync(file)) {
944
+ const existing = readFileSync(file, 'utf8');
945
+ if (!isOurOpencodePlugin(existing)) {
946
+ deps.io.err(`${file} exists and was not written by NotifAI; move it aside first.`);
947
+ return EXIT.failed;
948
+ }
949
+ }
950
+ try {
951
+ mkdirSync(path.dirname(file), { recursive: true });
952
+ writeFileSync(file, opencodePluginSource(options), { mode: 0o644 });
953
+ }
954
+ catch (err) {
955
+ deps.io.err(String(err));
956
+ return EXIT.failed;
957
+ }
958
+ deps.io.out(`Installed the OpenCode plugin at ${file}`);
959
+ deps.io.out('');
960
+ deps.io.out('It maps chat.message to presence, session.idle to question escalation, and');
961
+ deps.io.out('session.deleted to local cleanup through the same `notifai hook` commands');
962
+ deps.io.out('the other harnesses run. Permission prompts stay in OpenCode.');
963
+ deps.io.out('OpenCode cannot reliably resume an idle agent turn with a device answer; use');
964
+ deps.io.out('`notifai send --reply` when the answer must return to the agent.');
965
+ deps.io.out('');
966
+ deps.io.out('Restart OpenCode: it loads plugins once at start.');
967
+ return EXIT.ok;
968
+ }
969
+ export function hooksUninstallCommand(deps, flags) {
970
+ const harness = resolveHarness(deps, flags.harness);
971
+ if (!harness)
972
+ return EXIT.usage;
973
+ const scriptPath = flags.scriptPath ?? process.argv[1] ?? 'notifai';
974
+ const file = settingsFile(harness, flags.global ?? false, deps.cwd, deps.env);
975
+ if (!existsSync(file)) {
976
+ deps.io.out(`Nothing to remove: ${file} does not exist.`);
977
+ return EXIT.ok;
978
+ }
979
+ if (harness === 'opencode') {
980
+ // We own the whole file, but only if we wrote it.
981
+ if (!isOurOpencodePlugin(readFileSync(file, 'utf8'))) {
982
+ deps.io.out(`Left ${file} alone: NotifAI did not write it.`);
983
+ return EXIT.ok;
984
+ }
985
+ rmSync(file, { force: true });
986
+ deps.io.out(`Removed the NotifAI OpenCode plugin at ${file}`);
987
+ return EXIT.ok;
988
+ }
989
+ if (harness === 'cursor') {
990
+ let document;
991
+ try {
992
+ document = loadCursorSettings(file);
993
+ }
994
+ catch (err) {
995
+ deps.io.err(String(err));
996
+ return EXIT.failed;
997
+ }
998
+ const stripped = removeCursorHooks(document, scriptPath);
999
+ try {
1000
+ applyPlan(file, stripped.document);
1001
+ }
1002
+ catch (err) {
1003
+ deps.io.err(String(err));
1004
+ return EXIT.failed;
1005
+ }
1006
+ deps.io.out(stripped.replaced.length > 0
1007
+ ? `Removed NotifAI hooks (${stripped.replaced.join(', ')}) from ${file}`
1008
+ : `No NotifAI hooks found in ${file}`);
1009
+ return EXIT.ok;
1010
+ }
1011
+ let document;
1012
+ try {
1013
+ document = loadSettings(file);
1014
+ }
1015
+ catch (err) {
1016
+ deps.io.err(String(err));
1017
+ return EXIT.failed;
1018
+ }
1019
+ const stripped = removeHooks(document, scriptPath);
1020
+ try {
1021
+ applyPlan(file, stripped.document);
1022
+ }
1023
+ catch (err) {
1024
+ deps.io.err(String(err));
1025
+ return EXIT.failed;
1026
+ }
1027
+ deps.io.out(stripped.replaced.length > 0
1028
+ ? `Removed NotifAI hooks (${stripped.replaced.join(', ')}) from ${file}`
1029
+ : `No NotifAI hooks found in ${file}`);
1030
+ return EXIT.ok;
1031
+ }
1032
+ function resolveHarness(deps, requested) {
1033
+ if (requested !== undefined) {
1034
+ if (HARNESSES.includes(requested))
1035
+ return requested;
1036
+ deps.io.err(`Unknown harness "${requested}". Supported: ${HARNESSES.join(', ')}.`);
1037
+ return null;
1038
+ }
1039
+ const detected = detectHarness(deps.cwd);
1040
+ if (!detected) {
1041
+ deps.io.err(`Could not tell which harness to install for — pass --harness <${HARNESSES.join('|')}>.`);
1042
+ return null;
1043
+ }
1044
+ return detected;
1045
+ }
1046
+ // ---------------------------------------------------------------------------
1047
+ // config show / set
1048
+ // ---------------------------------------------------------------------------
1049
+ export function configShowCommand(deps, flags) {
1050
+ const config = loadConfig({ cwd: deps.cwd, env: deps.env });
1051
+ if (flags.json) {
1052
+ const output = Object.fromEntries(CONFIG_KEYS.map((key) => [key, { value: config[key].value, source: config[key].source }]));
1053
+ deps.io.out(JSON.stringify(output, null, 2));
1054
+ return EXIT.ok;
1055
+ }
1056
+ for (const key of CONFIG_KEYS) {
1057
+ const entry = config[key];
1058
+ const provenance = flags.explain ? ` [${entry.source}]` : '';
1059
+ deps.io.out(`${key} = ${JSON.stringify(entry.value)}${provenance}`);
1060
+ }
1061
+ return EXIT.ok;
1062
+ }
1063
+ export async function configSetCommand(deps, key, rawValue, flags) {
1064
+ if (!CONFIG_KEYS.includes(key)) {
1065
+ deps.io.err(`Unknown key "${key}". Valid keys: ${CONFIG_KEYS.join(', ')}`);
1066
+ return EXIT.usage;
1067
+ }
1068
+ let value = rawValue;
1069
+ if (NUMERIC_CONFIG_KEYS.includes(key)) {
1070
+ const numeric = Number(rawValue);
1071
+ if (!Number.isInteger(numeric)) {
1072
+ deps.io.err(`"${rawValue}" is not an integer.`);
1073
+ return EXIT.usage;
1074
+ }
1075
+ const bounds = configBounds(key);
1076
+ if (bounds !== undefined && (numeric < bounds.min || numeric > bounds.max)) {
1077
+ deps.io.err(`${key} must be between ${bounds.min} and ${bounds.max}.`);
1078
+ return EXIT.usage;
1079
+ }
1080
+ value = numeric;
1081
+ }
1082
+ if (BOOLEAN_CONFIG_KEYS.includes(key)) {
1083
+ if (rawValue !== 'true' && rawValue !== 'false') {
1084
+ deps.io.err(`${key} is a toggle — pass "true" or "false", not "${rawValue}".`);
1085
+ return EXIT.usage;
1086
+ }
1087
+ value = rawValue === 'true';
1088
+ }
1089
+ if (key === 'devices')
1090
+ value = rawValue.split(',').map((s) => s.trim()).filter(Boolean);
1091
+ let layer = flags.local ? 'local' : flags.project ? 'project' : 'global';
1092
+ if (flags.session === undefined &&
1093
+ flags.local !== true &&
1094
+ flags.project !== true &&
1095
+ flags.yes !== true &&
1096
+ deps.io.interactive === true &&
1097
+ deps.io.select) {
1098
+ const selected = await deps.io.select('Where should this setting live?', [
1099
+ { value: 'global', label: 'This machine', hint: 'applies across projects' },
1100
+ { value: 'project', label: 'This project (shared)', hint: '.notifai/config.toml' },
1101
+ { value: 'local', label: 'This project (personal)', hint: 'keep config.local.toml gitignored' },
1102
+ ]);
1103
+ if (selected === null) {
1104
+ deps.io.err('No configuration layer selected.');
1105
+ return EXIT.usage;
1106
+ }
1107
+ layer = selected;
1108
+ }
1109
+ const targetPath = flags.session
1110
+ ? sessionConfigPath(flags.session, deps.env)
1111
+ : layer === 'local'
1112
+ ? (findProjectLocalConfigPath(deps.cwd) ?? path.join(deps.cwd, '.notifai', 'config.local.toml'))
1113
+ : layer === 'project'
1114
+ ? (findProjectConfigPath(deps.cwd) ?? path.join(deps.cwd, '.notifai', 'config.toml'))
1115
+ : globalConfigPath(deps.env);
1116
+ if (!flags.yes) {
1117
+ const confirmed = await deps.io.confirm(`Set ${key} = ${JSON.stringify(value)} in ${targetPath}?`);
1118
+ if (!confirmed) {
1119
+ deps.io.err('Not confirmed. Pass --yes to skip the confirmation gate.');
1120
+ return EXIT.usage;
1121
+ }
1122
+ }
1123
+ const existing = existsSync(targetPath)
1124
+ ? parseToml(readFileSync(targetPath, 'utf8'))
1125
+ : {};
1126
+ existing[key] = value;
1127
+ mkdirSync(path.dirname(targetPath), { recursive: true });
1128
+ writeFileSync(targetPath, `${stringifyToml(existing)}\n`);
1129
+ deps.io.out(`Wrote ${key} to ${targetPath}`);
1130
+ return EXIT.ok;
1131
+ }
1132
+ // ---------------------------------------------------------------------------
1133
+ // init
1134
+ // ---------------------------------------------------------------------------
1135
+ /**
1136
+ * Where `npx skills add` fetches the optional agent skill from. The skill's
1137
+ * canonical home is the public repository (RafaelVidaurre/notifai), which has
1138
+ * nothing pushed to it yet, so no source can resolve today; the guard below
1139
+ * keeps `--skills` from installing anything until a tagged release exists to
1140
+ * pin this to. Never point this at the private repository — the installer
1141
+ * command is printed to users, and only public sources belong in it.
1142
+ */
1143
+ const SKILLS_SOURCE = '';
1144
+ /** Derive a contract-valid project slug from a directory name. */
1145
+ export function projectSlugFrom(name) {
1146
+ const slug = name
1147
+ .toLowerCase()
1148
+ .replaceAll(/[^a-z0-9._-]+/g, '-')
1149
+ .replaceAll(/^[^a-z0-9]+|[^a-z0-9._-]+$/g, '')
1150
+ .slice(0, 64);
1151
+ return slug.length > 0 && /^[a-z0-9]/.test(slug) ? slug : 'project';
1152
+ }
1153
+ /**
1154
+ * The setup coordinator that observes each prerequisite and advances the ones
1155
+ * this build can perform.
1156
+ *
1157
+ * Idempotent by construction: every step first observes, then acts only on the
1158
+ * gap, so re-running is how you check the setup as much as how you create it.
1159
+ * With a human at a terminal it walks them through the missing pieces; run by
1160
+ * an agent it never prompts — each optional step is answered by a flag, and
1161
+ * whatever only the user can do (signing in, pairing a companion device) is printed as
1162
+ * the exact command to hand back to them.
1163
+ */
1164
+ /**
1165
+ * Close a gap the CLI is allowed to close on its own, without asking.
1166
+ *
1167
+ * Only reached for `by: 'cli'` remedies, which by definition need no human, so
1168
+ * this stays silent about what it did — the re-assessment that follows reports
1169
+ * the new state, and narrating both is how a setup log becomes unreadable.
1170
+ *
1171
+ * Returns false when the attempt failed, which is different from declining:
1172
+ * a decline leaves an optional gap open and is fine, a failure is not.
1173
+ */
1174
+ async function closeGap(deps, state, flags) {
1175
+ if (state.id === 'project') {
1176
+ const configPath = path.join(deps.cwd, '.notifai', 'config.toml');
1177
+ const existing = existsSync(configPath)
1178
+ ? parseToml(readFileSync(configPath, 'utf8'))
1179
+ : {};
1180
+ existing['project'] = projectSlugFrom(flags.projectId ?? path.basename(deps.cwd));
1181
+ mkdirSync(path.dirname(configPath), { recursive: true });
1182
+ writeFileSync(configPath, `${stringifyToml(existing)}\n`);
1183
+ return true;
1184
+ }
1185
+ if (state.id === 'hooks') {
1186
+ let harness = detectHarness(deps.cwd);
1187
+ if (harness === null && deps.io.interactive === true && deps.io.select) {
1188
+ const picked = await deps.io.select('Which agent harness do you use here?', HARNESSES.map((name) => ({ value: name, label: name })));
1189
+ if (picked !== null)
1190
+ harness = picked;
1191
+ }
1192
+ if (harness === null) {
1193
+ deps.io.err(`Could not tell which harness to wire. Run: notifai hooks install --harness <${HARNESSES.join('|')}>`);
1194
+ return false;
1195
+ }
1196
+ return hooksInstallCommand(deps, { harness }) === EXIT.ok;
1197
+ }
1198
+ if (state.id === 'skill') {
1199
+ deps.io.out(`Installing the notifai agent skill (npx skills add ${SKILLS_SOURCE})...`);
1200
+ const code = await new Promise((resolve) => {
1201
+ const child = spawn('npx', ['-y', 'skills', 'add', SKILLS_SOURCE, '--skill', 'notifai'], {
1202
+ cwd: deps.cwd,
1203
+ stdio: 'inherit',
1204
+ });
1205
+ child.on('error', () => resolve(1));
1206
+ child.on('exit', (exitCode) => resolve(exitCode ?? 1));
1207
+ });
1208
+ if (code !== 0) {
1209
+ deps.io.err('Skill installation failed — run it manually with:');
1210
+ deps.io.err(` npx skills add ${SKILLS_SOURCE} --skill notifai`);
1211
+ }
1212
+ return code === 0;
1213
+ }
1214
+ return false;
1215
+ }
1216
+ /** Whether an optional gap should be closed, given flags and who is watching. */
1217
+ function wantsOptional(deps, state, flags) {
1218
+ // Naming the project is init's whole reason to touch the filesystem, costs
1219
+ // nothing, and is undone by editing one line — so it is done rather than
1220
+ // asked about, for a human and an agent alike.
1221
+ if (state.id === 'project')
1222
+ return Promise.resolve(true);
1223
+ const explicit = state.id === 'hooks' ? flags.hooks : state.id === 'skill' ? flags.skills : undefined;
1224
+ if (explicit !== undefined)
1225
+ return Promise.resolve(explicit);
1226
+ // An agent is never asked, and never assumed into a change it did not
1227
+ // request: silence means no, and the summary says what was skipped.
1228
+ if (deps.io.interactive !== true)
1229
+ return Promise.resolve(false);
1230
+ const question = state.id === 'hooks'
1231
+ ? 'Install harness hooks, so questions reach your devices when you are away?'
1232
+ : 'Install/update the agent guidance skill in this repo?';
1233
+ return deps.io.confirm(question, true);
1234
+ }
1235
+ /**
1236
+ * Setup as one step at a time.
1237
+ *
1238
+ * The old version ran five steps in a fixed order and ended with a list of
1239
+ * everything still outstanding. That is a report, and a report is the wrong
1240
+ * output here: someone handed five things to do does none of them, and the
1241
+ * order was the script's rather than the dependency graph's — it offered to
1242
+ * install hooks after a sign-in that had just failed.
1243
+ *
1244
+ * So this closes what it can, then surfaces exactly one thing, the first that
1245
+ * stands in the way. Re-running advances by one. Idempotence stops being a
1246
+ * property to preserve and becomes the mechanism: every decision is derived
1247
+ * from observed state, so a partial run, a second project, a fresh worktree
1248
+ * and a revoked credential are the same code path arriving at different
1249
+ * states rather than four branches to enumerate.
1250
+ */
1251
+ export async function initCommand(deps, flags) {
1252
+ await deps.io.intro?.('NotifAI setup');
1253
+ if (SKILLS_SOURCE === '' && flags.skills === true) {
1254
+ deps.io.err('The optional agent skill is not published yet; this build has no skill source configured.');
1255
+ return EXIT.failed;
1256
+ }
1257
+ let readiness = await assessReadiness(deps);
1258
+ let failed = false;
1259
+ // One pass is enough: closing a gap never opens an earlier one, and the
1260
+ // states that remain are exactly those needing someone who is not the CLI.
1261
+ for (const state of readiness.states) {
1262
+ const remedy = state.remedy;
1263
+ if (remedy === undefined)
1264
+ continue;
1265
+ // Its to launch, theirs to complete. Offering to start the sign-in is the
1266
+ // single most useful thing this command does for a person, so it is worth
1267
+ // the extra branch — but only when someone is actually watching, since an
1268
+ // agent that reached a browser prompt would simply hang.
1269
+ if (remedy.by === 'user-here' && remedy.interactive === true) {
1270
+ if (state.status !== 'gap' || deps.io.interactive !== true)
1271
+ continue;
1272
+ if (!(await deps.io.confirm('Sign in now? (opens your browser)', true)))
1273
+ continue;
1274
+ if ((await loginCommand(deps, {})) !== EXIT.ok)
1275
+ failed = true;
1276
+ continue;
1277
+ }
1278
+ if (remedy.by !== 'cli')
1279
+ continue;
1280
+ if (state.status === 'gap') {
1281
+ if (!(await closeGap(deps, state, flags)))
1282
+ failed = true;
1283
+ }
1284
+ else if (state.status === 'optional-gap' && (await wantsOptional(deps, state, flags))) {
1285
+ if (!(await closeGap(deps, state, flags)))
1286
+ failed = true;
1287
+ }
1288
+ }
1289
+ readiness = await assessReadiness(deps);
1290
+ for (const state of readiness.states.filter((s) => s.status === 'ready')) {
1291
+ deps.io.out(`${state.title}: ${state.detail}`);
1292
+ }
1293
+ const blocker = firstBlocker(readiness);
1294
+ if (blocker === null) {
1295
+ const skipped = openItems(readiness);
1296
+ for (const state of skipped)
1297
+ deps.io.out(`Optional, not set up — ${remedyLine(state)}`);
1298
+ deps.io.out('All set. Agents in this project can notify you and ask questions.');
1299
+ await deps.io.outro?.('All set ✨');
1300
+ return failed ? EXIT.failed : EXIT.ok;
1301
+ }
1302
+ // Exactly one. Everything else waits until this is done, because the next
1303
+ // gap is frequently a consequence of this one and naming it now would send
1304
+ // the reader off to fix something that is not actually wrong.
1305
+ deps.io.out('');
1306
+ deps.io.out(`Next: ${blocker.title} — ${blocker.detail}`);
1307
+ deps.io.out(` ${remedyLine(blocker)}`);
1308
+ if (blocker.remedy?.by === 'user-elsewhere') {
1309
+ deps.io.out(' Then re-run `notifai init` and it will pick up from here.');
1310
+ }
1311
+ await deps.io.outro?.('One step remains (above)');
1312
+ return failed ? EXIT.failed : EXIT.ok;
1313
+ }
1314
+ // ---------------------------------------------------------------------------
1315
+ // doctor
1316
+ // ---------------------------------------------------------------------------
1317
+ /**
1318
+ * Whether the deployed server understands the contract this build speaks
1319
+ *.
1320
+ *
1321
+ * Every test in the suite runs the CLI and the server from the same commit, so
1322
+ * a client sending a field the deployed server has not learned yet is
1323
+ * structurally invisible to it. That gap shipped a silent production outage on
1324
+ * 2026-08-03: questions stopped reaching devices entirely and nothing said so.
1325
+ *
1326
+ * The capability document already carries a schema version and is served
1327
+ * unauthenticated, so a single GET answers the question. This does not claim to
1328
+ * catch every skew — an additive field inside the same schema version would
1329
+ * still pass — but it catches the one that has actually happened, and it names
1330
+ * the remedy rather than leaving someone to derive it.
1331
+ */
1332
+ async function contractCheck(client) {
1333
+ const local = CAPABILITIES_V1.describe('ios')?.schema_version;
1334
+ try {
1335
+ const remote = (await client.capabilities('ios')).schema_version;
1336
+ if (local === remote) {
1337
+ return { name: 'contract', ok: true, detail: `server and CLI both speak schema v${remote}` };
1338
+ }
1339
+ return {
1340
+ name: 'contract',
1341
+ ok: false,
1342
+ detail: local !== undefined && remote < local
1343
+ ? `server speaks schema v${remote}, this CLI speaks v${local} — the server needs deploying, ` +
1344
+ 'until then sends carrying newer fields are rejected'
1345
+ : `server speaks schema v${remote}, this CLI speaks v${local} — update the CLI`,
1346
+ };
1347
+ }
1348
+ catch (err) {
1349
+ return {
1350
+ name: 'contract',
1351
+ ok: false,
1352
+ detail: `could not read the server capability document (${err instanceof ApiCallError ? err.code : String(err)})`,
1353
+ };
1354
+ }
1355
+ }
1356
+ /**
1357
+ * Read the whole setup once, in dependency order.
1358
+ *
1359
+ * Descent stops where a prerequisite is missing: without a credential there is
1360
+ * nothing to ask the server with, and without a reachable server a contract
1361
+ * mismatch is unknowable rather than absent. Those downstream states report
1362
+ * `unknown`, which is the honest answer and keeps a network outage from
1363
+ * looking like a broken install.
1364
+ */
1365
+ export async function assessReadiness(deps) {
1366
+ const config = loadConfig({ cwd: deps.cwd, env: deps.env });
1367
+ const states = [];
1368
+ const configPath = path.join(deps.cwd, '.notifai', 'config.toml');
1369
+ const projectSlug = config.project.value;
1370
+ states.push(projectSlug !== null
1371
+ ? {
1372
+ id: 'project',
1373
+ title: 'Project identity',
1374
+ status: 'ready',
1375
+ detail: `"${projectSlug}" (${config.project.source})`,
1376
+ }
1377
+ : {
1378
+ id: 'project',
1379
+ title: 'Project identity',
1380
+ // Not a blocker: a send without a project simply carries no project
1381
+ // identity. init always sets one because it is free and reversible,
1382
+ // but an unlabelled setup works, so this must not go red.
1383
+ status: 'optional-gap',
1384
+ detail: `not set in ${configPath} — sends from here carry no project identity`,
1385
+ remedy: {
1386
+ by: 'cli',
1387
+ summary: 'name this project after its directory',
1388
+ command: 'notifai init',
1389
+ },
1390
+ });
1391
+ const credential = deps.store.load();
1392
+ states.push(credential
1393
+ ? {
1394
+ id: 'credential',
1395
+ title: 'This machine',
1396
+ status: 'ready',
1397
+ detail: `paired as "${credential.machineName}" (${deps.store.describe()})`,
1398
+ }
1399
+ : {
1400
+ id: 'credential',
1401
+ title: 'This machine',
1402
+ status: 'gap',
1403
+ detail: 'not paired with your account',
1404
+ remedy: {
1405
+ by: 'user-here',
1406
+ summary: 'sign in — this opens your browser to approve the machine',
1407
+ command: 'notifai login',
1408
+ interactive: true,
1409
+ },
1410
+ });
1411
+ const baseUrl = resolvedBaseUrl(config, credential);
1412
+ const anon = makeClient(deps, baseUrl, null);
1413
+ // A probe that throws is unreachable, not a crash: this runs against a
1414
+ // half-configured machine by definition, which is where a client that
1415
+ // cannot even be constructed properly shows up.
1416
+ let reachable = false;
1417
+ try {
1418
+ reachable = await anon.health();
1419
+ }
1420
+ catch {
1421
+ reachable = false;
1422
+ }
1423
+ states.push(reachable
1424
+ ? { id: 'server', title: 'Service', status: 'ready', detail: `${baseUrl} reachable` }
1425
+ : {
1426
+ id: 'server',
1427
+ title: 'Service',
1428
+ status: 'gap',
1429
+ detail: `cannot reach ${baseUrl} (${config.base_url.source})`,
1430
+ remedy: {
1431
+ by: 'user-here',
1432
+ summary: 'check your network, or the base_url shown above',
1433
+ command: 'notifai doctor',
1434
+ },
1435
+ });
1436
+ if (!reachable) {
1437
+ states.push({
1438
+ id: 'contract',
1439
+ title: 'Protocol version',
1440
+ status: 'unknown',
1441
+ detail: 'not checked — the server is unreachable',
1442
+ });
1443
+ }
1444
+ else {
1445
+ const contract = await contractCheck(anon);
1446
+ states.push(contract.ok
1447
+ ? { id: 'contract', title: 'Protocol version', status: 'ready', detail: contract.detail }
1448
+ : {
1449
+ id: 'contract',
1450
+ title: 'Protocol version',
1451
+ status: 'gap',
1452
+ detail: contract.detail,
1453
+ remedy: {
1454
+ by: 'user-here',
1455
+ summary: 'the CLI and server disagree; the detail above says which to move',
1456
+ command: 'notifai doctor',
1457
+ },
1458
+ });
1459
+ }
1460
+ if (!credential || !reachable) {
1461
+ const why = !credential ? 'this machine is not paired' : 'the server is unreachable';
1462
+ states.push({ id: 'auth', title: 'Account', status: 'unknown', detail: `not checked — ${why}` });
1463
+ states.push({ id: 'devices', title: 'Your devices', status: 'unknown', detail: `not checked — ${why}` });
1464
+ }
1465
+ else {
1466
+ const client = makeClient(deps, baseUrl, `Bearer nfm_${credential.machineId}.${credential.secret}`);
1467
+ try {
1468
+ const { devices } = await client.listDevices();
1469
+ const ready = devices.filter((d) => d.registration_healthy);
1470
+ states.push({
1471
+ id: 'auth',
1472
+ title: 'Account',
1473
+ status: 'ready',
1474
+ detail: `machine ${credential.machineId} accepted`,
1475
+ });
1476
+ states.push(ready.length > 0
1477
+ ? {
1478
+ id: 'devices',
1479
+ title: 'Your devices',
1480
+ status: 'ready',
1481
+ detail: `${ready.map((d) => d.display_name).join(', ')} ready to receive`,
1482
+ }
1483
+ : {
1484
+ id: 'devices',
1485
+ title: 'Your devices',
1486
+ status: 'gap',
1487
+ // The one gap that cannot be closed from this terminal, and the
1488
+ // likeliest place a first setup is abandoned. Naming which of
1489
+ // the three sub-states it is matters: "install the app" is
1490
+ // useless advice to someone who installed it and denied the
1491
+ // permission prompt.
1492
+ detail: devices.length === 0
1493
+ ? 'nothing registered yet'
1494
+ : `${devices.map((d) => `${d.display_name} (${d.permission_status})`).join(', ')} — registered but not able to receive`,
1495
+ remedy: {
1496
+ by: 'user-elsewhere',
1497
+ summary: devices.length === 0
1498
+ ? 'install the companion app on your phone, sign in with the same account, and allow notifications'
1499
+ : 'allow notifications for NotifAI in that device’s system settings',
1500
+ },
1501
+ });
1502
+ }
1503
+ catch (err) {
1504
+ // A credential the server rejects is revocation, not absence, and the
1505
+ // remedy is the same sign-in either way.
1506
+ states.push({
1507
+ id: 'auth',
1508
+ title: 'Account',
1509
+ status: 'gap',
1510
+ detail: err instanceof ApiCallError ? `${err.code}: ${err.message}` : String(err),
1511
+ remedy: {
1512
+ by: 'user-here',
1513
+ summary: 'this machine is no longer recognised; pair it again',
1514
+ command: 'notifai login',
1515
+ },
1516
+ });
1517
+ states.push({ id: 'devices', title: 'Your devices', status: 'unknown', detail: 'not checked — sign-in failed' });
1518
+ }
1519
+ }
1520
+ states.push(...hookStates(deps));
1521
+ states.push(SKILLS_SOURCE === ''
1522
+ ? {
1523
+ id: 'skill',
1524
+ title: 'Agent guidance skill',
1525
+ // Nothing outstanding, because nothing can be done: this build has
1526
+ // no published skill source configured. Silence here would read as
1527
+ // an omission to anyone who had seen it mentioned in the docs.
1528
+ status: 'ready',
1529
+ detail: 'not applicable — this build has no published skill source configured',
1530
+ }
1531
+ : {
1532
+ id: 'skill',
1533
+ title: 'Agent guidance skill',
1534
+ status: 'optional-gap',
1535
+ detail: 'not installed in this project',
1536
+ remedy: {
1537
+ by: 'cli',
1538
+ summary: 'install the skill agents follow when deciding to notify',
1539
+ command: 'notifai init --skills',
1540
+ },
1541
+ });
1542
+ return { states };
1543
+ }
1544
+ export async function doctorCommand(deps, flags) {
1545
+ const readiness = await assessReadiness(deps);
1546
+ const blocker = firstBlocker(readiness);
1547
+ const ok = blocker === null;
1548
+ if (flags.json) {
1549
+ deps.io.out(JSON.stringify({ ok, states: readiness.states }, null, 2));
1550
+ return ok ? EXIT.ok : EXIT.failed;
1551
+ }
1552
+ const line = (s) => `${s.title}: ${s.detail}`;
1553
+ // Doctor reports everything — that is the difference from init, which acts
1554
+ // on one thing. It still names where to start, because a list of five
1555
+ // problems in dependency order has an obvious first move and saying so
1556
+ // costs nothing.
1557
+ if (deps.io.interactive === true && deps.io.check) {
1558
+ await deps.io.intro?.('NotifAI doctor');
1559
+ for (const s of readiness.states)
1560
+ await deps.io.check(s.status !== 'gap', line(s));
1561
+ await deps.io.outro?.(ok ? 'Everything looks good' : `Start with: ${remedyLine(blocker)}`);
1562
+ }
1563
+ else {
1564
+ for (const s of readiness.states) {
1565
+ const mark = s.status === 'gap' ? 'FAIL' : s.status === 'unknown' ? ' ? ' : 'ok ';
1566
+ deps.io.out(`${mark} ${line(s)}`);
1567
+ }
1568
+ if (!ok)
1569
+ deps.io.out(`\nStart with: ${remedyLine(blocker)}`);
1570
+ }
1571
+ return ok ? EXIT.ok : EXIT.failed;
1572
+ }
1573
+ /** One line telling the reader what to actually do about a state. */
1574
+ function remedyLine(state) {
1575
+ const remedy = state.remedy;
1576
+ if (!remedy)
1577
+ return state.detail;
1578
+ if (remedy.by === 'user-elsewhere')
1579
+ return remedy.summary;
1580
+ return remedy.command === undefined
1581
+ ? remedy.summary
1582
+ : `${remedy.summary} — run \`${remedy.command}\``;
1583
+ }
1584
+ /**
1585
+ * Whether the hook installation is internally ready, plus evidence that a
1586
+ * project session has fired it before. This cannot prove future execution or
1587
+ * end-to-end notification delivery without a live harness and device test.
1588
+ *
1589
+ * Every failure mode here was found the expensive way, by spawning a session
1590
+ * and watching nothing happen: hooks not installed, installed but never fired,
1591
+ * or left behind by an older build that named events this one does not serve.
1592
+ */
1593
+ /**
1594
+ * Hook diagnostics as readiness states.
1595
+ *
1596
+ * A thin adapter over `hookChecks`, whose every branch was found the expensive
1597
+ * way and is not worth re-deriving. The judgment added here is which failures
1598
+ * actually stand in the way.
1599
+ *
1600
+ * Two do not. A hook that has never fired is the normal condition of an
1601
+ * install thirty seconds old, and OpenCode's inability to resume an idle turn
1602
+ * is a property of that harness rather than a fault in this setup. Treating
1603
+ * either as blocking would mean `init` could never finish for an OpenCode
1604
+ * user, or could only finish after a session had already run — so both report
1605
+ * as things worth knowing rather than things to fix.
1606
+ */
1607
+ function hookStates(deps) {
1608
+ const installations = findInstallations(deps.cwd, deps.env);
1609
+ if (installations.length === 0) {
1610
+ return [
1611
+ {
1612
+ id: 'hooks',
1613
+ title: 'Question routing',
1614
+ status: 'optional-gap',
1615
+ detail: 'hooks not installed, so questions stay in the terminal',
1616
+ remedy: {
1617
+ by: 'cli',
1618
+ summary: 'install harness hooks so questions reach your devices when you are away',
1619
+ command: 'notifai hooks install',
1620
+ },
1621
+ },
1622
+ ];
1623
+ }
1624
+ /** Real but not in the way; see the note above. */
1625
+ const informational = new Set(['hooks (fired)', 'hooks (opencode continuation)']);
1626
+ return hookChecks(deps).map((check) => ({
1627
+ id: check.name.replace(/[ ()]+/g, '-').replace(/-$/, ''),
1628
+ title: check.name === 'hooks' ? 'Question routing' : check.name,
1629
+ status: check.ok ? 'ready' : informational.has(check.name) ? 'optional-gap' : 'gap',
1630
+ detail: check.detail,
1631
+ ...(check.ok
1632
+ ? {}
1633
+ : {
1634
+ remedy: {
1635
+ by: 'user-here',
1636
+ summary: 'the detail above names what to change',
1637
+ command: 'notifai hooks install',
1638
+ },
1639
+ }),
1640
+ }));
1641
+ }
1642
+ function hookChecks(deps) {
1643
+ const checks = [];
1644
+ const installations = findInstallations(deps.cwd, deps.env);
1645
+ // Not having hooks is a setup someone chose, not a fault: `send` works
1646
+ // without them. A setup that cannot work is what deserves to go red.
1647
+ if (installations.length === 0) {
1648
+ checks.push({
1649
+ name: 'hooks',
1650
+ ok: true,
1651
+ detail: 'not installed (optional) — `notifai hooks install` adds question routing',
1652
+ });
1653
+ return checks;
1654
+ }
1655
+ checks.push({
1656
+ name: 'hooks',
1657
+ ok: true,
1658
+ detail: installations
1659
+ .map((i) => `${i.harness} ${i.global ? 'global' : 'project'} (${i.file})`)
1660
+ .join(', '),
1661
+ });
1662
+ // A handler naming an event this build dropped exits 2 every time the harness
1663
+ // fires it, which the harness reports as a hook failure.
1664
+ const stale = installations.flatMap((i) => i.handlers
1665
+ .filter((h) => {
1666
+ const event = handlerEvent(h.command);
1667
+ return event !== null && !HOOK_EVENTS.includes(event);
1668
+ })
1669
+ .map((h) => `${h.event} -> ${handlerEvent(h.command)} in ${i.file}`));
1670
+ checks.push({
1671
+ name: 'hooks (stale)',
1672
+ ok: stale.length === 0,
1673
+ detail: stale.length === 0
1674
+ ? 'every installed handler names an event this build serves'
1675
+ : `${stale.join('; ')} — rerun \`notifai hooks install\` to drop ${stale.length === 1 ? 'it' : 'them'}`,
1676
+ });
1677
+ const adapterProblems = installations.flatMap((installation) => (installation.problems ?? []).map((problem) => `${installation.file}: ${problem}`));
1678
+ if (adapterProblems.length > 0) {
1679
+ checks.push({
1680
+ name: 'hooks (adapter)',
1681
+ ok: false,
1682
+ detail: adapterProblems.join('; '),
1683
+ });
1684
+ }
1685
+ // Two checkouts each installing hooks means both fire for the same event, and
1686
+ // the user gets every question twice.
1687
+ //
1688
+ // Compared *within* a harness, not across. Only one harness runs a given
1689
+ // session, so having Claude Code and OpenCode both set up is the ordinary
1690
+ // case and not a duplicate — comparing them turned a healthy machine red.
1691
+ const duplicated = [...new Set(installations.map((i) => i.harness))]
1692
+ .map((harness) => ({
1693
+ harness,
1694
+ scripts: new Set(installations
1695
+ .filter((i) => i.harness === harness)
1696
+ .flatMap((i) => i.handlers.map((h) => h.command.split(' hook ')[0] ?? ''))),
1697
+ }))
1698
+ .filter((entry) => entry.scripts.size > 1);
1699
+ if (duplicated.length > 0) {
1700
+ checks.push({
1701
+ name: 'hooks (duplicates)',
1702
+ ok: false,
1703
+ detail: duplicated
1704
+ .map((entry) => `${entry.harness}: ${entry.scripts.size} different NotifAI builds are installed, so each event will fire all of them. Uninstall the ones you do not want: ${[...entry.scripts].join(', ')}`)
1705
+ .join('; '),
1706
+ });
1707
+ }
1708
+ const fired = readProjectSession(deps.cwd, deps.env, (deps.now ?? Date.now)()) !== null;
1709
+ checks.push({
1710
+ name: 'hooks (fired)',
1711
+ ok: fired,
1712
+ detail: fired
1713
+ ? 'a session in this directory has run them'
1714
+ : `no session pointer from the last 24 hours — ${hookActivationAdvice(installations)}`,
1715
+ });
1716
+ if (installations.some((installation) => installation.harness === 'opencode')) {
1717
+ checks.push({
1718
+ name: 'hooks (opencode continuation)',
1719
+ ok: false,
1720
+ detail: 'question routing is installed, but OpenCode cannot reliably resume an idle agent turn with the answer; use `notifai send --reply` for decisions',
1721
+ });
1722
+ }
1723
+ const stray = codexStrayWorktreeCheck(deps);
1724
+ if (stray !== null)
1725
+ checks.push(stray);
1726
+ return checks;
1727
+ }
1728
+ /**
1729
+ * A Codex hooks file sitting in a worktree, which Codex will never read.
1730
+ *
1731
+ * `settingsFile` now writes to the main repository, so this only fires for a
1732
+ * file an older build left behind — but that file is indistinguishable from a
1733
+ * working install if you go looking, and it is exactly what made this bug take
1734
+ * a day to find. Omitted entirely when there is nothing to say.
1735
+ */
1736
+ function codexStrayWorktreeCheck(deps) {
1737
+ const layer = codexLayerDir(deps.cwd);
1738
+ if (layer === null)
1739
+ return null;
1740
+ const root = codexProjectRoot(deps.cwd);
1741
+ const stray = path.join(path.dirname(layer), '.codex', 'hooks.json');
1742
+ if (!existsSync(path.join(root, '.codex', 'hooks.json')))
1743
+ return null;
1744
+ const problems = [];
1745
+ if (!existsSync(layer)) {
1746
+ problems.push(`${layer} is missing, so Codex never looks for project hooks here`);
1747
+ }
1748
+ if (existsSync(stray)) {
1749
+ problems.push(`${stray} is never read — Codex reads ${root}/.codex/hooks.json instead`);
1750
+ }
1751
+ return {
1752
+ name: 'hooks (codex worktree)',
1753
+ ok: problems.length === 0,
1754
+ detail: problems.length === 0
1755
+ ? `worktree wired to the main repository at ${root}`
1756
+ : `${problems.join('; ')}. Re-run \`notifai hooks install\` to fix.`,
1757
+ };
1758
+ }
1759
+ // ---------------------------------------------------------------------------
1760
+ // production IO
1761
+ // ---------------------------------------------------------------------------
1762
+ /**
1763
+ * Whether a human is driving this terminal.
1764
+ *
1765
+ * A TTY alone is NOT that evidence: agent harnesses frequently allocate a PTY
1766
+ * for the commands they run, and a prompt shown to an agent does not fail — it
1767
+ * hangs, because every prompt library waits on stdin rather than erroring. So
1768
+ * this also honours `CI` and an explicit `NOTIFAI_NO_INPUT=1` escape hatch,
1769
+ * and every interactive affordance stays strictly optional: anything `init`
1770
+ * can ask, a flag can answer.
1771
+ */
1772
+ function isHumanTerminal(env) {
1773
+ return (process.stdin.isTTY === true &&
1774
+ process.stdout.isTTY === true &&
1775
+ (env['CI'] ?? '') === '' &&
1776
+ (env['NOTIFAI_NO_INPUT'] ?? '') === '');
1777
+ }
1778
+ /**
1779
+ * Lazy on purpose: the hook path runs in front of every prompt the user types,
1780
+ * and must not pay for a prompt library it will never show.
1781
+ */
1782
+ async function clack() {
1783
+ return await import('@clack/prompts');
1784
+ }
1785
+ export function realIo(env = process.env) {
1786
+ const interactive = () => isHumanTerminal(env);
1787
+ return {
1788
+ out: (line) => console.log(line),
1789
+ err: (line) => console.error(line),
1790
+ get interactive() {
1791
+ return interactive();
1792
+ },
1793
+ confirm: async (question, fallback = false) => {
1794
+ if (!interactive())
1795
+ return fallback;
1796
+ const p = await clack();
1797
+ const answer = await p.confirm({ message: question, initialValue: fallback });
1798
+ // Ctrl-C mid-prompt arrives as a cancel symbol, not a SIGINT; treat it
1799
+ // as the safe answer rather than letting a Symbol escape into logic.
1800
+ return p.isCancel(answer) ? false : answer;
1801
+ },
1802
+ select: async (message, options) => {
1803
+ if (!interactive())
1804
+ return null;
1805
+ const p = await clack();
1806
+ const answer = await p.select({ message, options });
1807
+ return p.isCancel(answer) ? null : answer;
1808
+ },
1809
+ intro: async (title) => {
1810
+ if (!interactive())
1811
+ return;
1812
+ (await clack()).intro(title);
1813
+ },
1814
+ outro: async (message) => {
1815
+ if (!interactive())
1816
+ return;
1817
+ (await clack()).outro(message);
1818
+ },
1819
+ note: async (message, title) => {
1820
+ if (!interactive())
1821
+ return;
1822
+ (await clack()).note(message, title);
1823
+ },
1824
+ spinner: async (message) => {
1825
+ if (!interactive())
1826
+ return null;
1827
+ const progress = (await clack()).spinner();
1828
+ progress.start(message);
1829
+ return {
1830
+ message: (next) => progress.message(next),
1831
+ stop: (next) => progress.stop(next),
1832
+ error: (next) => progress.error(next),
1833
+ };
1834
+ },
1835
+ check: async (ok, message) => {
1836
+ if (!interactive())
1837
+ return;
1838
+ const { log } = await clack();
1839
+ if (ok)
1840
+ log.success(message);
1841
+ else
1842
+ log.error(message);
1843
+ },
1844
+ openUrl: (url) => {
1845
+ const command = process.platform === 'darwin' ? 'open' : process.platform === 'win32' ? 'start' : 'xdg-open';
1846
+ try {
1847
+ spawn(command, [url], { stdio: 'ignore', detached: true }).unref();
1848
+ }
1849
+ catch {
1850
+ // Browser opening is best-effort; the URL is printed anyway.
1851
+ }
1852
+ },
1853
+ };
1854
+ }
1855
+ //# sourceMappingURL=commands.js.map