@raidiant/notifai 5.0.0 → 7.0.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 (126) hide show
  1. package/CHANGELOG.md +51 -0
  2. package/README.md +2 -2
  3. package/dist/atomic-file.js +13 -0
  4. package/dist/atomic-file.js.map +1 -1
  5. package/dist/client.d.ts +8 -3
  6. package/dist/client.js +24 -3
  7. package/dist/client.js.map +1 -1
  8. package/dist/commands-auth.d.ts +14 -0
  9. package/dist/commands-auth.js +154 -0
  10. package/dist/commands-auth.js.map +1 -0
  11. package/dist/commands-config.d.ts +31 -0
  12. package/dist/commands-config.js +246 -0
  13. package/dist/commands-config.js.map +1 -0
  14. package/dist/commands-core.d.ts +102 -0
  15. package/dist/commands-core.js +135 -0
  16. package/dist/commands-core.js.map +1 -0
  17. package/dist/commands-devices.d.ts +31 -0
  18. package/dist/commands-devices.js +124 -0
  19. package/dist/commands-devices.js.map +1 -0
  20. package/dist/commands-doctor.d.ts +24 -0
  21. package/dist/commands-doctor.js +1011 -0
  22. package/dist/commands-doctor.js.map +1 -0
  23. package/dist/commands-harness-context.d.ts +65 -0
  24. package/dist/commands-harness-context.js +97 -0
  25. package/dist/commands-harness-context.js.map +1 -0
  26. package/dist/commands-hook-shape.d.ts +17 -0
  27. package/dist/commands-hook-shape.js +37 -0
  28. package/dist/commands-hook-shape.js.map +1 -0
  29. package/dist/commands-hooks.d.ts +115 -0
  30. package/dist/commands-hooks.js +1225 -0
  31. package/dist/commands-hooks.js.map +1 -0
  32. package/dist/commands-init.d.ts +35 -0
  33. package/dist/commands-init.js +580 -0
  34. package/dist/commands-init.js.map +1 -0
  35. package/dist/commands-io.d.ts +2 -0
  36. package/dist/commands-io.js +110 -0
  37. package/dist/commands-io.js.map +1 -0
  38. package/dist/commands-logs.d.ts +30 -0
  39. package/dist/commands-logs.js +201 -0
  40. package/dist/commands-logs.js.map +1 -0
  41. package/dist/commands-send-support.d.ts +44 -0
  42. package/dist/commands-send-support.js +183 -0
  43. package/dist/commands-send-support.js.map +1 -0
  44. package/dist/commands-send.d.ts +33 -0
  45. package/dist/commands-send.js +519 -0
  46. package/dist/commands-send.js.map +1 -0
  47. package/dist/commands-setup-proof.d.ts +15 -0
  48. package/dist/commands-setup-proof.js +53 -0
  49. package/dist/commands-setup-proof.js.map +1 -0
  50. package/dist/commands-skill.d.ts +10 -0
  51. package/dist/commands-skill.js +71 -0
  52. package/dist/commands-skill.js.map +1 -0
  53. package/dist/commands.d.ts +12 -387
  54. package/dist/commands.js +12 -4536
  55. package/dist/commands.js.map +1 -1
  56. package/dist/config-schema.js +1 -1
  57. package/dist/config-schema.js.map +1 -1
  58. package/dist/config.d.ts +4 -4
  59. package/dist/config.js +6 -4
  60. package/dist/config.js.map +1 -1
  61. package/dist/credentials.js +13 -3
  62. package/dist/credentials.js.map +1 -1
  63. package/dist/generated-session-label.d.ts +2 -0
  64. package/dist/generated-session-label.js +152 -0
  65. package/dist/generated-session-label.js.map +1 -0
  66. package/dist/hooks.d.ts +2 -2
  67. package/dist/hooks.js +127 -99
  68. package/dist/hooks.js.map +1 -1
  69. package/dist/interactive.js +4 -4
  70. package/dist/interactive.js.map +1 -1
  71. package/dist/invocation-context.d.ts +3 -0
  72. package/dist/invocation-context.js +23 -6
  73. package/dist/invocation-context.js.map +1 -1
  74. package/dist/logging.js +1 -0
  75. package/dist/logging.js.map +1 -1
  76. package/dist/main.js +3 -3
  77. package/dist/main.js.map +1 -1
  78. package/dist/opencode-plugin.js +18 -3
  79. package/dist/opencode-plugin.js.map +1 -1
  80. package/dist/readiness.d.ts +3 -1
  81. package/dist/readiness.js.map +1 -1
  82. package/dist/session-labels.d.ts +30 -0
  83. package/dist/session-labels.js +278 -0
  84. package/dist/session-labels.js.map +1 -0
  85. package/package.json +2 -2
  86. package/src/atomic-file.ts +12 -0
  87. package/src/client.test.ts +90 -1
  88. package/src/client.ts +35 -2
  89. package/src/commands-auth.ts +174 -0
  90. package/src/commands-config.ts +307 -0
  91. package/src/commands-core.ts +240 -0
  92. package/src/commands-devices.ts +150 -0
  93. package/src/commands-doctor.ts +1209 -0
  94. package/src/commands-harness-context.ts +134 -0
  95. package/src/commands-hook-shape.ts +53 -0
  96. package/src/commands-hooks.ts +1586 -0
  97. package/src/commands-init.ts +721 -0
  98. package/src/commands-io.ts +108 -0
  99. package/src/commands-logs.ts +243 -0
  100. package/src/commands-send-support.ts +227 -0
  101. package/src/commands-send.ts +623 -0
  102. package/src/commands-setup-proof.ts +71 -0
  103. package/src/commands-skill.ts +84 -0
  104. package/src/commands.test.ts +567 -42
  105. package/src/commands.ts +58 -5614
  106. package/src/config-schema.ts +1 -1
  107. package/src/config.test.ts +9 -0
  108. package/src/config.ts +10 -5
  109. package/src/credentials.test.ts +25 -1
  110. package/src/credentials.ts +13 -3
  111. package/src/generated-session-label.ts +155 -0
  112. package/src/hooks.test.ts +355 -16
  113. package/src/hooks.ts +145 -97
  114. package/src/install-hooks.test.ts +84 -1
  115. package/src/interactive.test.ts +19 -1
  116. package/src/interactive.ts +4 -4
  117. package/src/invocation-context.test.ts +115 -16
  118. package/src/invocation-context.ts +32 -11
  119. package/src/logging.test.ts +15 -2
  120. package/src/logging.ts +1 -0
  121. package/src/main.ts +9 -3
  122. package/src/opencode-plugin.ts +18 -3
  123. package/src/readiness.ts +3 -1
  124. package/src/session-labels.test.ts +303 -0
  125. package/src/session-labels.ts +360 -0
  126. package/src/skill.test.ts +13 -0
package/dist/commands.js CHANGED
@@ -1,4537 +1,13 @@
1
- import { createHash, randomBytes } from 'node:crypto';
2
- import { existsSync, lstatSync, mkdirSync, readFileSync, realpathSync, rmSync, unlinkSync, writeFileSync } from 'node:fs';
3
- import os from 'node:os';
4
- import path from 'node:path';
5
- import { parse as parseToml, stringify as stringifyToml } from 'smol-toml';
6
- import { AGENT_ACKNOWLEDGEMENT_MAX_LENGTH, CAPABILITIES_V1, PLATFORMS, QUESTION_TEXT_MAX_LENGTH, REPLY_MAX_QUESTIONS, REPLY_MAX_WINDOW_SECONDS, validateDraft, } from '@raidiant/notifai-protocol';
7
- import { sha256Hex } from '@raidiant/notifai-protocol/node';
8
- import { ApiCallError, NetworkError, createClient, isRetryableReplyPollError, } from './client.js';
9
- import { BOOLEAN_CONFIG_KEYS, CONFIG_KEYS, NUMERIC_CONFIG_KEYS, configBounds, configDefaultValue, findProjectConfigPath, personalProjectConfigPath, globalConfigPath, loadConfig, sessionConfigPath, stateDir, } from './config.js';
10
- import { acceptedValues, configInfo } from './config-schema.js';
11
- import { packageVersion, skillsSource } from './release.js';
12
- import { atomicWriteFileSync } from './atomic-file.js';
13
- import { withTargetFileLock } from './file-lock.js';
14
- import { renderConfigExplain, renderConfigList, renderConfigPlain, } from './ui/config-view.js';
15
- import { firstBlocker, } from './readiness.js';
16
- import { waiterCeilingSeconds, clearAcknowledgementObligation, handleSessionEnd, handleStop, handleUserPromptSubmit, parseHookInput, pruneAbandonedSessions, readLiveProjectSessionPointers, readMatchingProjectSessionPointer, readProjectSession, readProjectSessionPointer, readSessionState, registerQuestion, MIN_REPLY_WINDOW_SECONDS, } from './hooks.js';
17
- import { LOG_EVENTS, activeLogPath, archiveLogPaths, logConfigResolved, logSettingsFrom, logsDiskUsage, nullLogger, readLogRecords, renderRecord, RECORD_LEVELS, } from './logging.js';
18
- import { BLOCKING_STOP_TIMEOUT_SECONDS, stopHandlerIsDetached, CLAUDE_ASYNC_STOP_TIMEOUT_SECONDS, HARNESSES, applyPlan, buildCursorHookConfig, buildHookConfig, codexLayerDir, codexLayerPaths, codexCoexistenceNotes, codexHomeNote, codexRepresentationProblems, codexTrustProblems, codexProjectRoot, detectHarness, detectedHarnesses, findInstallations, handlerEvent, hookDefinitionFiles, inspectCodexLayer, loadCursorSettings, loadSettings, mergeCursorHooks, mergeHooks, removeCursorHooks, removeHooks, settingsFile, withCodexLayerTransaction, } from './install-hooks.js';
19
- import { HARNESS_CAPABILITIES, HARNESS_LABELS } from './harnesses.js';
20
- import { claudeWakeRoute, inspectClaudeInbox, systemClaudeWakeAdapters, } from './claude-wake.js';
21
- import { codexWakeRoute, inspectCodexResume, } from './codex-wake.js';
22
- import { inspectHookAdapter, installHookAdapter, isNpxAdapterTarget, } from './hook-adapter.js';
23
- import { isOurOpencodePlugin, opencodePluginSource, } from './opencode-plugin.js';
24
- import { CHOICE_USAGE, buildDraft, formatReceipt, parseChoices, receiptExitCode, slugify, validateMediaInputs, } from './send.js';
25
- import { buildSourceContext, inferInvocationContext, projectSlugFrom as inferredProjectSlugFrom, } from './invocation-context.js';
26
- import { openUrl } from './platform.js';
27
- /** The invocation's logger, or one that discards everything. */
28
- export function log(deps) {
29
- return deps.logger ?? nullLogger();
30
- }
31
- function loadLoggedConfig(deps, options) {
32
- const config = loadConfig(options);
33
- const logger = log(deps);
34
- logger.adopt(logSettingsFrom(config));
35
- logger.bind({ project: config.project.value });
36
- logConfigResolved(logger, config);
37
- return config;
38
- }
39
- export const EXIT = {
40
- ok: 0,
41
- failed: 1,
42
- usage: 2,
43
- noReply: 3,
44
- auth: 4,
45
- network: 5,
46
- };
47
- function makeClient(deps, baseUrl, bearer, options) {
48
- return (deps.clientFactory ?? createClient)(baseUrl, bearer, { ...options, logger: log(deps) });
49
- }
50
- function resolvedBaseUrl(config, credential) {
51
- return config.base_url.source === 'default' && credential ? credential.baseUrl : config.base_url.value;
52
- }
53
- function authedClient(deps, config) {
54
- const credential = deps.store.load();
55
- if (!credential) {
56
- // The commonest reason a command does nothing, and one that leaves no other
57
- // trace: it returns before any request is made, so without this the log
58
- // shows an exit code and no cause.
59
- log(deps).error('cli.error', { kind: 'auth', message: 'not signed in', store: deps.store.describe() });
60
- deps.io.err('Not signed in. Run `notifai login` first.');
61
- return null;
62
- }
63
- const baseUrl = resolvedBaseUrl(config, credential);
64
- return {
65
- client: makeClient(deps, baseUrl, `Bearer nfm_${credential.machineId}.${credential.secret}`),
66
- baseUrl,
67
- };
68
- }
69
- function reportError(deps, err, context = {}) {
70
- // Recorded before it is printed: stderr from an agent's shell call is often
71
- // the one thing that does not survive into the next turn, and this is exactly
72
- // the line someone comes back looking for.
73
- log(deps).error('cli.error', {
74
- ...context,
75
- ...(err instanceof ApiCallError
76
- ? { kind: 'api', status: err.status, code: err.code, message: err.message, next_action: err.nextAction, details: err.details }
77
- : err instanceof NetworkError
78
- ? { kind: 'network', message: err.message }
79
- : { kind: 'unknown', message: String(err) }),
80
- });
81
- if (err instanceof ApiCallError) {
82
- deps.io.err(`${err.code}: ${err.message}`);
83
- // The server's field-level details were already being logged above, but
84
- // stderr is the one surface an agent's next turn actually reads — so the
85
- // diagnosis has to be on it, the same way the hook path prints it.
86
- const paths = rejectedPaths(err.details);
87
- if (paths.length > 0)
88
- deps.io.err(`the server rejected: ${paths.join(', ')}`);
89
- // A 422 on a request this CLI built is not a user error: the two sides
90
- // disagree about the contract, in whichever direction. `doctor` says which.
91
- // Codes that name a policy the account chose are the exception — the
92
- // contract held, the body simply did not satisfy that account.
93
- if (err.status === 422 && err.code !== 'acknowledgement_text_required') {
94
- deps.io.err('this build sent a field the server did not accept — the CLI and server ' +
95
- 'disagree about the contract; check with `notifai doctor`');
96
- }
97
- if (err.nextAction)
98
- deps.io.err(`next: ${err.nextAction}`);
99
- if (err.code === 'auth_required' || err.code === 'machine_revoked')
100
- return EXIT.auth;
101
- return err.status >= 500 ? EXIT.network : EXIT.failed;
102
- }
103
- if (err instanceof NetworkError) {
104
- deps.io.err(err.message);
105
- return EXIT.network;
106
- }
107
- deps.io.err(String(err));
108
- return EXIT.failed;
109
- }
110
- // ---------------------------------------------------------------------------
111
- // login / logout / auth status
112
- // ---------------------------------------------------------------------------
113
- export async function loginCommand(deps, flags) {
114
- const config = loadLoggedConfig(deps, { cwd: deps.cwd, env: deps.env, flags: { base_url: flags.baseUrl } });
115
- const baseUrl = config.base_url.value;
116
- const machineName = flags.name ?? os.hostname();
117
- const secret = randomBytes(32).toString('base64url');
118
- const pollVerifier = randomBytes(24).toString('base64url');
119
- const client = makeClient(deps, baseUrl, null);
120
- let begin;
121
- try {
122
- begin = await client.beginPairing({
123
- machine_name: machineName,
124
- credential_hash: sha256Hex(secret),
125
- poll_verifier_hash: sha256Hex(pollVerifier),
126
- });
127
- }
128
- catch (err) {
129
- return reportError(deps, err);
130
- }
131
- const interactive = deps.io.interactive === true;
132
- if (interactive) {
133
- await deps.io.intro?.('Notifai sign in');
134
- await deps.io.note?.(`Code: ${begin.code}\n${begin.approve_url}`, 'Approve this machine');
135
- }
136
- else {
137
- deps.io.out(`Pairing code: ${begin.code}`);
138
- deps.io.out(`Approve this machine at: ${begin.approve_url}`);
139
- deps.io.out('Waiting for approval…');
140
- }
141
- if (flags.open !== false)
142
- deps.io.openUrl(begin.approve_url);
143
- const expiresAt = new Date(begin.expires_at).getTime();
144
- const intervalMs = Math.max(begin.poll_interval_seconds, 1) * 1000;
145
- const now = deps.now ?? Date.now;
146
- const sleep = deps.sleep ?? ((milliseconds) => new Promise((resolve) => setTimeout(resolve, milliseconds)));
147
- const approvalWaitMessage = () => {
148
- const remainingSec = Math.max(0, Math.ceil((expiresAt - now()) / 1000));
149
- const minutes = Math.floor(remainingSec / 60);
150
- const seconds = remainingSec % 60;
151
- const remaining = minutes > 0 ? `${minutes}m ${seconds.toString().padStart(2, '0')}s` : `${seconds}s`;
152
- return `Waiting for approval… code ${begin.code} · ${remaining} left`;
153
- };
154
- const spinner = interactive ? await deps.io.spinner?.(approvalWaitMessage()) : null;
155
- while (now() < expiresAt) {
156
- await sleep(intervalMs);
157
- let poll;
158
- try {
159
- poll = await client.pollPairing(begin.pairing_id, pollVerifier);
160
- }
161
- catch (err) {
162
- if (err instanceof NetworkError) {
163
- spinner?.message(`Connection lost — retrying… code ${begin.code}`);
164
- continue;
165
- }
166
- spinner?.error('Pairing failed');
167
- return reportError(deps, err);
168
- }
169
- if (poll.status === 'approved' && poll.machine_id) {
170
- deps.store.save({ machineId: poll.machine_id, secret, baseUrl, machineName });
171
- if (interactive) {
172
- spinner?.stop(`Machine "${machineName}" approved`);
173
- await deps.io.outro?.(`Credential stored in ${deps.store.describe()}`);
174
- }
175
- else {
176
- deps.io.out(`Machine "${machineName}" approved. Credential stored in ${deps.store.describe()}.`);
177
- }
178
- return EXIT.ok;
179
- }
180
- if (poll.status === 'denied') {
181
- spinner?.error('Pairing denied');
182
- deps.io.err('Pairing was denied from the dashboard.');
183
- return EXIT.auth;
184
- }
185
- if (poll.status === 'no_active_plan') {
186
- const next = poll.next_action ??
187
- `Open ${baseUrl.replace(/\/$/, '')}/support to request Alpha access, then retry.`;
188
- spinner?.error('Account has no Alpha access');
189
- deps.io.err('This account has no active plan or temporary Alpha access.');
190
- deps.io.err(`next: ${next}`);
191
- deps.io.err('After access is granted, run `notifai login` again.');
192
- return EXIT.auth;
193
- }
194
- if (poll.status === 'expired')
195
- break;
196
- spinner?.message(approvalWaitMessage());
197
- }
198
- spinner?.error('Pairing expired');
199
- deps.io.err('Pairing expired before it was approved. Run `notifai login` again.');
200
- return EXIT.auth;
201
- }
202
- export function logoutCommand(deps) {
203
- deps.store.clear();
204
- deps.io.out('Machine credential removed. Revoke it in the dashboard too if the machine is untrusted.');
205
- return EXIT.ok;
206
- }
207
- export function authStatusCommand(deps, flags) {
208
- const credential = deps.store.load();
209
- if (flags.json) {
210
- deps.io.out(JSON.stringify(credential
211
- ? {
212
- signed_in: true,
213
- machine_id: credential.machineId,
214
- machine_name: credential.machineName,
215
- base_url: credential.baseUrl,
216
- store: deps.store.describe(),
217
- }
218
- : { signed_in: false }, null, 2));
219
- return credential ? EXIT.ok : EXIT.auth;
220
- }
221
- if (!credential) {
222
- deps.io.err('Not signed in. Run `notifai login`.');
223
- return EXIT.auth;
224
- }
225
- deps.io.out(`Signed in as machine "${credential.machineName}" (${credential.machineId})`);
226
- deps.io.out(`Server: ${credential.baseUrl}`);
227
- deps.io.out(`Credential store: ${deps.store.describe()}`);
228
- return EXIT.ok;
229
- }
230
- /** Show the server's account access decision without attempting a product mutation. */
231
- export async function accessStatusCommand(deps, flags) {
232
- const config = loadLoggedConfig(deps, { cwd: deps.cwd, env: deps.env });
233
- const authed = authedClient(deps, config);
234
- if (!authed)
235
- return EXIT.auth;
236
- try {
237
- const access = await authed.client.accessStatus();
238
- if (flags.json) {
239
- deps.io.out(JSON.stringify(access, null, 2));
240
- return access.status === 'active' ? EXIT.ok : EXIT.failed;
241
- }
242
- if (access.status === 'no_active_plan') {
243
- const supportUrl = `${authed.baseUrl.replace(/\/$/, '')}/support`;
244
- deps.io.out('No active plan or temporary Alpha access for this account.');
245
- deps.io.out(`next: Open ${supportUrl} to request Alpha access, then retry.`);
246
- return EXIT.failed;
247
- }
248
- const expiry = access.expires_at ? ` until ${access.expires_at}` : '';
249
- deps.io.out(`Access active (${access.reason})${expiry}`);
250
- return EXIT.ok;
251
- }
252
- catch (err) {
253
- return reportError(deps, err);
254
- }
255
- }
256
- // ---------------------------------------------------------------------------
257
- // devices / capabilities
258
- // ---------------------------------------------------------------------------
259
- export async function devicesCommand(deps, flags) {
260
- const config = loadLoggedConfig(deps, { cwd: deps.cwd, env: deps.env });
261
- const authed = authedClient(deps, config);
262
- if (!authed)
263
- return EXIT.auth;
264
- try {
265
- const result = await authed.client.listDevices();
266
- if (flags.json) {
267
- deps.io.out(JSON.stringify(result, null, 2));
268
- return EXIT.ok;
269
- }
270
- if (result.devices.length === 0) {
271
- const supportUrl = supportPageUrl(authed.baseUrl);
272
- let email = null;
273
- try {
274
- email = (await authed.client.accessStatus()).email;
275
- }
276
- catch {
277
- // Best-effort: the empty-state copy still points at /support without it.
278
- }
279
- deps.io.out(`No devices yet. Install Notifai from ${supportUrl}, ${sameAccountSignInLine(email)}, and allow notifications.`);
280
- return EXIT.ok;
281
- }
282
- for (const d of result.devices) {
283
- deps.io.out(`${d.device_id} ${d.display_name} ${d.platform} ${d.registration_healthy ? 'ready' : 'not ready'} (permission: ${d.permission_status})`);
284
- }
285
- return EXIT.ok;
286
- }
287
- catch (err) {
288
- return reportError(deps, err);
289
- }
290
- }
291
- /**
292
- * Devices as data, for surfaces that render them themselves.
293
- *
294
- * `devicesCommand` prints and returns an exit code, which is the right shape
295
- * for a command and the wrong one for a menu that wants to show names beside
296
- * checkboxes. Silent on every failure by design: the interactive app already
297
- * shows the credential and connectivity state on its status card, so a second
298
- * error line here would be reporting the same fault twice.
299
- */
300
- export async function deviceInventory(deps) {
301
- if (!deps.store.load())
302
- return null;
303
- const config = loadLoggedConfig(deps, { cwd: deps.cwd, env: deps.env });
304
- const authed = authedClient(deps, config);
305
- if (!authed)
306
- return null;
307
- try {
308
- return (await authed.client.listDevices()).devices;
309
- }
310
- catch {
311
- return null;
312
- }
313
- }
314
- /** Whether a device is in a state where a notification would actually arrive. */
315
- export function canDeviceReceive(device) {
316
- return deviceCanReceive(device);
317
- }
318
- export async function capabilitiesCommand(deps, flags) {
319
- // Locally, and with the same message `send` gives. Spending a round trip to
320
- // have the server answer "Request validation failed" told the caller neither
321
- // which flag was wrong nor what it accepts.
322
- if (flags.platform !== undefined && !PLATFORMS.includes(flags.platform)) {
323
- deps.io.err(`Unknown platform "${flags.platform}" — use ${PLATFORMS.join(' or ')}.`);
324
- return EXIT.usage;
325
- }
326
- const config = loadLoggedConfig(deps, { cwd: deps.cwd, env: deps.env });
327
- const credential = deps.store.load();
328
- const baseUrl = resolvedBaseUrl(config, credential);
329
- const client = makeClient(deps, baseUrl, null);
330
- try {
331
- const doc = await client.capabilities(flags.platform ?? 'ios');
332
- if (flags.json) {
333
- deps.io.out(JSON.stringify(doc, null, 2));
334
- return EXIT.ok;
335
- }
336
- deps.io.out(`${doc.platform} capability contract v${doc.schema_version} (payload limit ${doc.payload_limit_bytes} bytes)`);
337
- for (const field of doc.fields) {
338
- deps.io.out(` ${field.path}: ${field.status}${field.reason ? ` — ${field.reason}` : ''}`);
339
- }
340
- return EXIT.ok;
341
- }
342
- catch (err) {
343
- return reportError(deps, err);
344
- }
345
- }
346
- // ---------------------------------------------------------------------------
347
- // send / status
348
- // ---------------------------------------------------------------------------
349
- function resolveDraftInvocation(deps, flags, active) {
350
- const inferred = inferInvocationContext(deps.cwd);
351
- const source = buildSourceContext({
352
- env: deps.env,
353
- invocation: inferred,
354
- ...(flags.sessionId !== undefined ? { sessionId: flags.sessionId } : {}),
355
- ...(flags.sessionLabel !== undefined ? { sessionLabel: flags.sessionLabel } : {}),
356
- ...(active === null
357
- ? {}
358
- : {
359
- activeHarness: {
360
- harness: active.harness,
361
- ...(active.sessionId === undefined ? {} : { sessionId: active.sessionId }),
362
- },
363
- }),
364
- });
365
- if (!source.ok)
366
- return source;
367
- return {
368
- ok: true,
369
- invocation: {
370
- inferredProject: inferred.project,
371
- ...(source.source === undefined ? {} : { source: source.source }),
372
- },
373
- };
374
- }
375
- export async function sendCommand(deps, flags) {
376
- const hasReplyChoice = Array.isArray(flags.replyChoice)
377
- ? flags.replyChoice.length > 0
378
- : flags.replyChoice !== undefined;
379
- if (flags.reply && flags.kind !== undefined && flags.kind !== 'question') {
380
- deps.io.err(`--kind ${flags.kind} cannot be combined with --reply; a reply request is a question.`);
381
- return EXIT.usage;
382
- }
383
- // Kind now decides the sound a notification arrives with, so it is asked for
384
- // at the boundary rather than defaulted silently: an unlabelled send would
385
- // reach the user as ordinary news whatever actually happened.
386
- if (!flags.reply && flags.kind === undefined) {
387
- deps.io.err('--kind is required: say what this notification is. ' +
388
- 'update (news) · done (finished) · failed (terminal failure) · blocked (cannot proceed). ' +
389
- '--reply makes it a question without --kind.');
390
- return EXIT.usage;
391
- }
392
- if (!flags.reply && flags.kind === 'question') {
393
- deps.io.err('--kind question requires --reply so the question can be answered.');
394
- return EXIT.usage;
395
- }
396
- if (!flags.reply &&
397
- (flags.replyTimeout !== undefined || flags.replyWindow !== undefined || flags.noBlock || hasReplyChoice)) {
398
- deps.io.err('Use --reply with --reply-timeout, --reply-window, --reply-choice, or --no-block.');
399
- return EXIT.usage;
400
- }
401
- const replyTimeout = flags.replyTimeout ?? 900;
402
- if (flags.reply && !isNonNegativeInteger(replyTimeout)) {
403
- deps.io.err('--reply-timeout must be a non-negative integer number of seconds.');
404
- return EXIT.usage;
405
- }
406
- // Asking while declaring that nothing will wait for the answer. The reply is
407
- // captured server-side and then unreachable: only a blocking send waits for
408
- // it, and the hook path drains questions registered by `ask`, never a send's
409
- // request id. So the user gets a real button, taps it, and nothing happens —
410
- // worse than a banner that never asked, because it spends their attention
411
- // and their trust in the channel.
412
- //
413
- // Both spellings of "do not wait" are rejected, because the defect is the
414
- // zero wait and not the flag that produced it.
415
- if (flags.reply && (flags.noBlock || replyTimeout === 0)) {
416
- deps.io.err('A question needs someone to hear the answer, so --reply cannot be combined ' +
417
- 'with --no-block or --reply-timeout 0.\n' +
418
- 'To ask and end the turn, use `notifai ask` — the turn-end hook returns the answer.\n' +
419
- 'To announce finished work, drop --reply and its choices.');
420
- return EXIT.usage;
421
- }
422
- if (flags.reply &&
423
- flags.replyWindow !== undefined &&
424
- (!Number.isInteger(flags.replyWindow) ||
425
- flags.replyWindow < MIN_REPLY_WINDOW_SECONDS ||
426
- flags.replyWindow > REPLY_MAX_WINDOW_SECONDS)) {
427
- deps.io.err(`--reply-window must be an integer from ${MIN_REPLY_WINDOW_SECONDS} to ${REPLY_MAX_WINDOW_SECONDS} seconds.`);
428
- return EXIT.usage;
429
- }
430
- const mediaInputError = validateMediaInputs(flags.image, flags.imageAlt);
431
- if (mediaInputError !== null) {
432
- deps.io.err(mediaInputError);
433
- return EXIT.usage;
434
- }
435
- const config = loadLoggedConfig(deps, {
436
- cwd: deps.cwd,
437
- env: deps.env,
438
- flags: { base_url: flags.baseUrl, wait_seconds: flags.wait },
439
- });
440
- const source = resolveDraftInvocation(deps, flags, activeHarnessSession(deps.env, deps.cwd, (deps.now ?? Date.now)()));
441
- if (!source.ok) {
442
- deps.io.err(source.error);
443
- return EXIT.usage;
444
- }
445
- const authed = authedClient(deps, config);
446
- if (!authed)
447
- return EXIT.auth;
448
- const mediaIds = [];
449
- for (const image of flags.image ?? []) {
450
- if (image.startsWith('med_')) {
451
- mediaIds.push(image);
452
- continue;
453
- }
454
- const uploaded = await uploadImage(deps, authed.client, image);
455
- if (!uploaded.ok) {
456
- if (uploaded.error !== null)
457
- deps.io.err(uploaded.error);
458
- return uploaded.exit;
459
- }
460
- mediaIds.push(uploaded.mediaId);
461
- }
462
- flags = { ...flags, image: mediaIds };
463
- const build = buildDraft(config, flags, source.invocation);
464
- if (!build.ok) {
465
- deps.io.err(build.error);
466
- return EXIT.usage;
467
- }
468
- const capabilities = CAPABILITIES_V1.describe(build.platform);
469
- if (!capabilities) {
470
- deps.io.err(`No capability contract is available for ${build.platform}.`);
471
- return EXIT.usage;
472
- }
473
- const validation = validateDraft(build.draft, capabilities);
474
- if (!validation.ok) {
475
- for (const issue of validation.errors)
476
- deps.io.err(`${issue.path}: ${issue.message}`);
477
- return EXIT.usage;
478
- }
479
- emitSendWarnings(deps, flags, config);
480
- if (!flags.reply &&
481
- (flags.title.trim().endsWith('?') || flags.body.trim().endsWith('?'))) {
482
- 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.');
483
- }
484
- const waitSeconds = flags.noWait ? 0 : config.wait_seconds.value;
485
- const idempotencyKey = flags.idempotencyKey ?? `cli-${randomBytes(12).toString('base64url')}`;
486
- let receipt;
487
- try {
488
- receipt = await authed.client.submit({ idempotency_key: idempotencyKey, draft: build.draft }, waitSeconds);
489
- }
490
- catch (err) {
491
- return reportError(deps, err);
492
- }
493
- const receiptExit = receiptExitCode(receipt);
494
- // The single most useful line in the log: it ties the local invocation to
495
- // the server-side request id, which is what every later question about this
496
- // notification ("did it arrive?", "which device?") is asked in terms of.
497
- log(deps).info('send.submitted', {
498
- request_id: receipt.request_id,
499
- kind: flags.reply ? 'question' : (flags.kind ?? 'update'),
500
- title: flags.title,
501
- overall: receipt.overall,
502
- replayed: receipt.replayed,
503
- agent_acknowledgement_required: receipt.agent_acknowledgement_required,
504
- deliveries: receipt.deliveries.length,
505
- wait_seconds: waitSeconds,
506
- exit: receiptExit,
507
- });
508
- log(deps).info('send.outcome', {
509
- request_id: receipt.request_id,
510
- overall: receipt.overall,
511
- // Per device, because "it said accepted but nothing arrived" is answered
512
- // by which device reached which state and why the provider said so.
513
- devices: receipt.deliveries.map((delivery) => ({
514
- device: delivery.device_name,
515
- state: delivery.state,
516
- attempts: delivery.attempts,
517
- provider_status: delivery.provider_status,
518
- provider_reason: delivery.provider_reason,
519
- })),
520
- ...(receipt.warnings.length > 0 ? { warnings: receipt.warnings } : {}),
521
- });
522
- if (!flags.json)
523
- deps.io.out(formatReceipt(receipt));
524
- else if (flags.reply)
525
- deps.io.out(JSON.stringify({ type: 'receipt', receipt }));
526
- // A zero wait can no longer reach here: --reply guarantees a positive one.
527
- if (!flags.reply || receiptExit !== EXIT.ok) {
528
- if (flags.json) {
529
- deps.io.out(flags.reply
530
- ? JSON.stringify({
531
- type: 'reply_result',
532
- request_id: receipt.request_id,
533
- replies: [],
534
- agent_acknowledgement_required: receipt.agent_acknowledgement_required,
535
- agent_acknowledgement_text_required: receipt.agent_acknowledgement_text_required,
536
- agent_acknowledgement: null,
537
- acknowledgement_command: null,
538
- degraded: false,
539
- })
540
- : JSON.stringify(receipt, null, 2));
541
- }
542
- return receiptExit;
543
- }
544
- try {
545
- const result = await waitForReply(authed.client, receipt.request_id, {
546
- timeoutSeconds: replyTimeout,
547
- afterSeq: 0,
548
- now: deps.now,
549
- sleep: deps.sleep,
550
- });
551
- recordReplies(deps, receipt.request_id, result.response.replies);
552
- if (flags.json) {
553
- deps.io.out(JSON.stringify(replyResultJson(result.response, result.degraded)));
554
- }
555
- else if (result.response.replies.length > 0) {
556
- printReplies(deps, result.response.replies);
557
- printAcknowledgementStatus(deps, result.response);
558
- }
559
- else {
560
- printNoReply(deps, receipt.request_id, result.response.reply_expires_at);
561
- printAcknowledgementStatus(deps, result.response);
562
- }
563
- if (result.degraded) {
564
- log(deps).error('cli.error', {
565
- kind: 'network',
566
- operation: 'reply_wait',
567
- request_id: receipt.request_id,
568
- degraded: true,
569
- message: 'the reply wait ended while the server was unreachable or faulting',
570
- });
571
- deps.io.err(degradedWaitWarning(receipt.request_id));
572
- return EXIT.network;
573
- }
574
- if (result.timedOut) {
575
- deps.io.err(`No reply yet. Retrieve it with \`notifai replies ${receipt.request_id}\` or retire the question with ` +
576
- `\`notifai close ${receipt.request_id}\`. ` +
577
- `This is a reply-wait timeout, not a Delivery or Companion Receipt failure — check with \`notifai status ${receipt.request_id}\`.`);
578
- }
579
- return result.timedOut ? EXIT.noReply : EXIT.ok;
580
- }
581
- catch (err) {
582
- // Receipt already printed; a wait fault must not read as "send failed".
583
- // Permanent poll errors (auth, closed window, not found) still surface, but
584
- // always name the durable request and point at recovery.
585
- if (err instanceof ApiCallError || err instanceof NetworkError) {
586
- log(deps).error('cli.error', {
587
- kind: err instanceof ApiCallError ? 'api' : 'network',
588
- operation: 'reply_wait',
589
- request_id: receipt.request_id,
590
- ...(err instanceof ApiCallError ? { status: err.status, code: err.code } : {}),
591
- message: err.message,
592
- });
593
- deps.io.err(`notifai: reply wait failed for ${receipt.request_id} (${err instanceof ApiCallError ? err.code : 'network'}: ${err.message}). ` +
594
- `Delivery and Companion Receipt are independent — check with \`notifai status ${receipt.request_id}\` and retry with \`notifai replies ${receipt.request_id}\`.`);
595
- if (err instanceof ApiCallError) {
596
- if (err.code === 'auth_required' || err.code === 'machine_revoked')
597
- return EXIT.auth;
598
- return err.status >= 500 || err.status === 429 || err.status === 408
599
- ? EXIT.network
600
- : EXIT.failed;
601
- }
602
- return EXIT.network;
603
- }
604
- return reportError(deps, err);
605
- }
606
- }
607
- function emitSendWarnings(deps, flags, config) {
608
- if (flags.title.length > 40) {
609
- deps.io.err(`Heads up: this title is ${flags.title.length} characters; notification titles work best around 40 characters or fewer.`);
610
- }
611
- if (/^(?:update|done|question|failed|blocked)\s*(?:[·:—-]|$)/i.test(flags.title.trim())) {
612
- deps.io.err('Heads up: keep the title to the specific substance. Put notification type in --kind; project identity is inferred separately.');
613
- }
614
- if (flags.collapseKey === undefined &&
615
- config.collapse_key.value !== null &&
616
- config.collapse_key.source.startsWith('global:')) {
617
- deps.io.err('Heads up: collapse_key comes from machine-global config, so unrelated notifications may replace each other. Prefer a project or command-specific --collapse-key.');
618
- }
619
- if (flags.ttl !== undefined && flags.ttl > 259_200) {
620
- deps.io.err('Heads up: this explicit --ttl is longer than 72 hours; stale notifications may arrive after they are useful.');
621
- }
622
- }
623
- export async function repliesCommand(deps, requestedId, flags) {
624
- const waitSeconds = flags.wait ?? 0;
625
- const afterSeq = flags.after ?? 0;
626
- if (!isNonNegativeInteger(waitSeconds)) {
627
- deps.io.err('--wait must be a non-negative integer number of seconds.');
628
- return EXIT.usage;
629
- }
630
- if (!isNonNegativeInteger(afterSeq)) {
631
- deps.io.err('--after must be a non-negative integer sequence number.');
632
- return EXIT.usage;
633
- }
634
- if (flags.pending === true && requestedId !== undefined) {
635
- deps.io.err('Pass a request id or --pending, not both.');
636
- return EXIT.usage;
637
- }
638
- let requestIds = requestedId === undefined ? [] : [requestedId];
639
- if (flags.pending === true) {
640
- const sessionId = readProjectSession(deps.cwd, deps.env, (deps.now ?? Date.now)());
641
- if (sessionId === null) {
642
- deps.io.err('No active session pointer is available in this directory.');
643
- return EXIT.noReply;
644
- }
645
- // Every delivered question in the session's queue, in registration order —
646
- // an agent may have several outstanding at once.
647
- const state = readSessionState(sessionId, deps.env);
648
- requestIds = (Array.isArray(state.pending) ? state.pending : [])
649
- .map((entry) => entry.request_id)
650
- .filter((id) => id !== undefined);
651
- if (requestIds.length === 0) {
652
- // `--json` is a promise about stdout, and an empty result is still a
653
- // result: a caller that asked for machine-readable output must not have
654
- // to parse an English sentence to learn there was nothing pending.
655
- if (flags.json === true) {
656
- deps.io.out(JSON.stringify({ session_id: sessionId, pending: [], replies: [] }, null, 2));
657
- }
658
- else {
659
- deps.io.err(`Session ${sessionId} has no pushed question pending.`);
660
- }
661
- return EXIT.noReply;
662
- }
663
- }
664
- if (requestIds.length === 0) {
665
- deps.io.err('Pass a request id or --pending.');
666
- return EXIT.usage;
667
- }
668
- const config = loadLoggedConfig(deps, { cwd: deps.cwd, env: deps.env });
669
- const authed = authedClient(deps, config);
670
- if (!authed)
671
- return EXIT.auth;
672
- try {
673
- let anyReplies = false;
674
- const degradedRequestIds = [];
675
- let allTimedOut = true;
676
- const jsonBodies = [];
677
- for (const requestId of requestIds) {
678
- try {
679
- const result = await waitForReply(authed.client, requestId, {
680
- timeoutSeconds: waitSeconds,
681
- afterSeq,
682
- now: deps.now,
683
- sleep: deps.sleep,
684
- });
685
- recordReplies(deps, requestId, result.response.replies);
686
- if (flags.json) {
687
- jsonBodies.push(replyResultJson(result.response, result.degraded));
688
- }
689
- else if (result.response.replies.length > 0) {
690
- if (flags.pending === true)
691
- deps.io.out(`pending request ${requestId}`);
692
- printReplies(deps, result.response.replies);
693
- printAcknowledgementStatus(deps, result.response);
694
- }
695
- else {
696
- printNoReply(deps, requestId, result.response.reply_expires_at);
697
- printAcknowledgementStatus(deps, result.response);
698
- }
699
- anyReplies ||= result.response.replies.length > 0;
700
- if (result.degraded)
701
- degradedRequestIds.push(requestId);
702
- allTimedOut &&= result.timedOut;
703
- }
704
- catch (err) {
705
- recordDegradedReplyWaits(deps, degradedRequestIds);
706
- return reportError(deps, err, { operation: 'reply_wait', request_id: requestId });
707
- }
708
- }
709
- if (flags.json) {
710
- // One request keeps the response shape agents already parse; several
711
- // (only possible via --pending) arrive as an array in queue order.
712
- deps.io.out(JSON.stringify(jsonBodies.length === 1 ? jsonBodies[0] : jsonBodies, null, 2));
713
- }
714
- if (degradedRequestIds.length > 0) {
715
- recordDegradedReplyWaits(deps, degradedRequestIds);
716
- // Name a request whose polls actually degraded, not merely the first item
717
- // in a multi-request queue that may have completed cleanly.
718
- deps.io.err(degradedWaitWarning(degradedRequestIds[0]));
719
- return EXIT.network;
720
- }
721
- if (anyReplies)
722
- return EXIT.ok;
723
- return allTimedOut ? EXIT.noReply : EXIT.ok;
724
- }
725
- catch (err) {
726
- return reportError(deps, err);
727
- }
728
- }
729
- /** Loop over server-capped long polls until a reply arrives or the caller's deadline passes. */
730
- export async function waitForReply(client, requestId, options) {
731
- const now = options.now ?? Date.now;
732
- const sleep = options.sleep ?? ((milliseconds) => new Promise((resolve) => setTimeout(resolve, milliseconds)));
733
- const deadline = now() + options.timeoutSeconds * 1000;
734
- let lastResponse = null;
735
- let lastTransientError = null;
736
- let consecutiveTransientErrors = 0;
737
- let firstPoll = true;
738
- while (firstPoll || now() < deadline) {
739
- firstPoll = false;
740
- const remainingMs = Math.max(0, deadline - now());
741
- const waitSeconds = Math.min(25, Math.floor(remainingMs / 1000));
742
- try {
743
- const response = await client.replies(requestId, {
744
- waitSeconds,
745
- afterSeq: options.afterSeq,
746
- });
747
- lastResponse = response;
748
- lastTransientError = null;
749
- consecutiveTransientErrors = 0;
750
- if (response.replies.length > 0)
751
- return { response, timedOut: false, degraded: false };
752
- const pauseMs = Math.min(250, Math.max(0, deadline - now()));
753
- if (pauseMs > 0)
754
- await sleep(pauseMs);
755
- }
756
- catch (err) {
757
- if (!isRetryableReplyPollError(err))
758
- throw err;
759
- lastTransientError = err instanceof Error ? err : new Error(String(err));
760
- consecutiveTransientErrors += 1;
761
- const remainingAfterError = Math.max(0, deadline - now());
762
- if (remainingAfterError === 0)
763
- break;
764
- const backoffMs = Math.min(250 * 2 ** (consecutiveTransientErrors - 1), 2_000, remainingAfterError);
765
- await sleep(backoffMs);
766
- }
767
- }
768
- // Never saw a successful poll: surface the fault so callers get EXIT.network
769
- // rather than a fake empty silence. The durable request is still on the
770
- // server; the typed recovery is `notifai replies <id>`.
771
- if (!lastResponse && lastTransientError)
772
- throw lastTransientError;
773
- return {
774
- response: lastResponse ??
775
- {
776
- request_id: requestId,
777
- reply_expires_at: null,
778
- agent_acknowledgement_required: false,
779
- agent_acknowledgement_text_required: false,
780
- agent_acknowledgement: null,
781
- replies: [],
782
- },
783
- timedOut: true,
784
- // A poll succeeded at some point, so we do not throw — but the last thing
785
- // we know is that we could not reach the server. Reporting that as a plain
786
- // "no reply" would let an agent treat an unseen refusal as consent.
787
- degraded: lastTransientError !== null,
788
- };
789
- }
790
- function recordDegradedReplyWaits(deps, requestIds) {
791
- if (requestIds.length === 0)
792
- return;
793
- log(deps).error('cli.error', {
794
- kind: 'network',
795
- operation: 'reply_wait',
796
- request_ids: requestIds,
797
- degraded: true,
798
- message: 'the reply wait ended while the server was unreachable or faulting',
799
- });
800
- }
801
- /**
802
- * Shared by every surface that waits: "the user did not answer" and "I could
803
- * not find out" must not look the same, because agents branch on the exit code
804
- * and one of those two branches is safe to proceed from.
805
- *
806
- * Delivery / Companion Receipt / OS presentation are separate facts — a wait
807
- * fault is not evidence that the push failed.
808
- */
809
- function degradedWaitWarning(requestId) {
810
- return (`notifai: the wait for ${requestId} ended while the server was unreachable or faulting, ` +
811
- `so this is "could not find out", not "no answer" — the request is still durable and the ` +
812
- `reply may already be waiting (Provider Acceptance and Companion Receipt are independent). ` +
813
- `Retry with \`notifai replies ${requestId}\`.`);
814
- }
815
- function isNonNegativeInteger(value) {
816
- return Number.isInteger(value) && value >= 0;
817
- }
818
- function acknowledgeInvocation(requestId, textRequired) {
819
- return textRequired
820
- ? `notifai acknowledge ${requestId} --text <text>`
821
- : `notifai acknowledge ${requestId}`;
822
- }
823
- function acknowledgementCommand(requestId, required, textRequired, acknowledgement, hasReply = true) {
824
- return required && acknowledgement === null && hasReply
825
- ? acknowledgeInvocation(requestId, textRequired)
826
- : null;
827
- }
828
- function replyResultJson(response, degraded) {
829
- return {
830
- type: 'reply_result',
831
- request_id: response.request_id,
832
- reply_expires_at: response.reply_expires_at,
833
- replies: response.replies,
834
- agent_acknowledgement_required: response.agent_acknowledgement_required,
835
- agent_acknowledgement: response.agent_acknowledgement,
836
- agent_acknowledgement_text_required: response.agent_acknowledgement_text_required,
837
- acknowledgement_command: acknowledgementCommand(response.request_id, response.agent_acknowledgement_required, response.agent_acknowledgement_text_required, response.agent_acknowledgement, response.replies.length > 0),
838
- degraded,
839
- };
840
- }
841
- function printAcknowledgementStatus(deps, response) {
842
- if (!response.agent_acknowledgement_required) {
843
- deps.io.out('Agent Acknowledgement: not required for this request.');
844
- return;
845
- }
846
- const textRequired = response.agent_acknowledgement_text_required;
847
- if (response.agent_acknowledgement !== null) {
848
- const recorded = `Agent Acknowledgement: recorded at ${response.agent_acknowledgement.created_at}`;
849
- deps.io.out(response.agent_acknowledgement.text.length > 0
850
- ? `${recorded}: ${response.agent_acknowledgement.text}`
851
- : `${recorded}.`);
852
- return;
853
- }
854
- if (response.replies.length > 0) {
855
- deps.io.out('Agent Acknowledgement required.');
856
- deps.io.out(textRequired
857
- ? `next: Run \`${acknowledgeInvocation(response.request_id, true)}\` with concrete text saying what you will do because of the reply.`
858
- : `next: Run \`${acknowledgeInvocation(response.request_id, false)}\` so the user sees you read the reply; this account turned acknowledgement text off.`);
859
- return;
860
- }
861
- deps.io.out('Agent Acknowledgement: required after a user reply; no reply is recorded yet.');
862
- }
863
- function recordReplies(deps, requestId, replies) {
864
- const logger = log(deps);
865
- for (const reply of replies) {
866
- logger.info('reply.received', {
867
- request_id: requestId,
868
- sequence: reply.seq,
869
- device: reply.device_name,
870
- text: reply.text,
871
- answers: reply.answers,
872
- source: reply.source,
873
- });
874
- }
875
- }
876
- function printReplies(deps, replies) {
877
- for (const reply of replies)
878
- deps.io.out(`reply from ${reply.device_name}: ${reply.text}`);
879
- const contradiction = contradictingAnswer(replies);
880
- if (contradiction !== null)
881
- deps.io.err(contradiction);
882
- }
883
- /**
884
- * The correction note for a wait that saw more than one answer.
885
- *
886
- * A later reply that conflicts with an earlier one is a correction, so the
887
- * latest answer is the one that counts. The server retires the question on
888
- * the other devices the moment one answers, which makes this rare; it is
889
- * still reachable, because a device can answer between the first reply
890
- * landing and its retirement arriving.
891
- *
892
- * Ordered by seq, so `replies.at(-1)` is the answer to act on.
893
- */
894
- export function contradictingAnswer(replies) {
895
- const winner = replies.at(-1);
896
- if (winner === undefined || replies.length < 2)
897
- return null;
898
- const key = (reply) => reply.answers.length > 0 ? JSON.stringify(reply.answers) : reply.text;
899
- const corrected = replies.slice(0, -1).filter((reply) => key(reply) !== key(winner));
900
- if (corrected.length === 0)
901
- return null;
902
- const names = [...new Set(corrected.map((reply) => reply.device_name))].join(', ');
903
- return (`note: "${winner.text}" from ${winner.device_name} is the answer that counts — a later ` +
904
- `answer corrects an earlier one. Earlier differing answers from ${names} were superseded.`);
905
- }
906
- function printNoReply(deps, requestId, expiresAt) {
907
- // A harness hook may have retired this question, in which case promising an
908
- // open window would send the caller back to wait for an answer the server
909
- // will now refuse.
910
- const closed = expiresAt != null && Date.parse(expiresAt) <= Date.now();
911
- deps.io.out(closed
912
- ? `no reply for request ${requestId}; the reply window has closed`
913
- : `no reply yet for request ${requestId}; the reply window remains open`);
914
- }
915
- export async function statusCommand(deps, requestId, flags) {
916
- const config = loadLoggedConfig(deps, { cwd: deps.cwd, env: deps.env });
917
- const authed = authedClient(deps, config);
918
- if (!authed)
919
- return EXIT.auth;
920
- try {
921
- const snapshot = await authed.client.evidence(requestId);
922
- if (flags.json) {
923
- deps.io.out(JSON.stringify(snapshot, null, 2));
924
- return EXIT.ok;
925
- }
926
- deps.io.out(`request ${snapshot.request_id} (${snapshot.event ?? 'no event'}) — ${snapshot.overall}`);
927
- let anyReplyReceived = false;
928
- for (const d of snapshot.deliveries) {
929
- deps.io.out(` ${d.device_name}:`);
930
- deps.io.out(` Delivery: ${d.state} after ${d.attempts} attempt(s)`);
931
- deps.io.out(` Provider Acceptance: ${d.state === 'provider_accepted' ? 'accepted' : 'not recorded'}`);
932
- if (d.companion_receipt.state === 'observed') {
933
- const latency = d.companion_receipt.latency_ms;
934
- deps.io.out(` Companion Receipt (the app's delivery confirmation): observed at ${d.companion_receipt.observed_at}` +
935
- (latency === null ? '' : ` (${formatElapsed(latency)} after Provider Acceptance)`));
936
- }
937
- else {
938
- deps.io.out(" Companion Receipt (the app's delivery confirmation): unknown — not observed; this is not a failure or proof of non-receipt");
939
- }
940
- // Notifai never learns whether the OS painted a banner; saying so stops
941
- // a reply-wait fault from being misread as "the phone never showed it".
942
- deps.io.out(' OS presentation: not observed by Notifai — Provider Acceptance and Companion Receipt do not prove a banner was shown');
943
- const replyEvent = d.events.find((e) => e.stage === 'reply_received');
944
- if (replyEvent) {
945
- anyReplyReceived = true;
946
- deps.io.out(` Reply received: yes (first at ${replyEvent.occurred_at})`);
947
- }
948
- else {
949
- deps.io.out(' Reply received: not yet recorded on this delivery');
950
- }
951
- for (const e of d.events) {
952
- deps.io.out(` ${e.occurred_at} ${e.stage}${e.reason ? ` (${e.reason})` : ''}`);
953
- }
954
- }
955
- deps.io.out(anyReplyReceived
956
- ? ` Reply wait: answers are on the server — collect with \`notifai replies ${snapshot.request_id}\` (a local wait fault does not erase them)`
957
- : ` Reply wait: no answer stored yet — a blocking wait failure is independent of Delivery above; retry with \`notifai replies ${snapshot.request_id}\``);
958
- return EXIT.ok;
959
- }
960
- catch (err) {
961
- return reportError(deps, err);
962
- }
963
- }
964
- function formatElapsed(milliseconds) {
965
- const totalSeconds = Math.floor(milliseconds / 1_000);
966
- const minutes = Math.floor(totalSeconds / 60);
967
- const seconds = totalSeconds % 60;
968
- return minutes === 0 ? `${seconds}s` : `${minutes}m ${seconds}s`;
969
- }
970
- const MEDIA_TYPES = {
971
- '.jpg': 'image/jpeg',
972
- '.jpeg': 'image/jpeg',
973
- '.png': 'image/png',
974
- '.gif': 'image/gif',
975
- };
976
- /** `--image` accepts a media id, a local file path, or an http(s) URL. */
977
- async function uploadImage(deps, client, source) {
978
- let bytes;
979
- let mediaType;
980
- if (/^https?:\/\//.test(source)) {
981
- try {
982
- const response = await fetch(source);
983
- if (!response.ok)
984
- return { ok: false, error: `Could not fetch ${source} (${response.status}).`, exit: EXIT.usage };
985
- const contentType = response.headers.get('content-type')?.split(';')[0]?.trim() ?? '';
986
- mediaType = ['image/jpeg', 'image/png', 'image/gif'].find((t) => t === contentType);
987
- bytes = new Uint8Array(await response.arrayBuffer());
988
- }
989
- catch (err) {
990
- return { ok: false, error: `Could not fetch ${source}: ${String(err)}`, exit: EXIT.network };
991
- }
992
- }
993
- else {
994
- if (!existsSync(source)) {
995
- return { ok: false, error: `--image: "${source}" is not a media id, file, or URL.`, exit: EXIT.usage };
996
- }
997
- bytes = new Uint8Array(readFileSync(source));
998
- mediaType = MEDIA_TYPES[path.extname(source).toLowerCase()];
999
- }
1000
- if (!mediaType) {
1001
- return { ok: false, error: 'Images must be JPEG, PNG, or GIF.', exit: EXIT.usage };
1002
- }
1003
- try {
1004
- const grant = await client.createMediaUpload({
1005
- media_type: mediaType,
1006
- size_bytes: bytes.byteLength,
1007
- sha256: sha256Hex(bytes),
1008
- });
1009
- await client.uploadMedia(grant, bytes);
1010
- return { ok: true, mediaId: grant.media_id };
1011
- }
1012
- catch (err) {
1013
- // Every other API failure in this file goes through `reportError`, which
1014
- // maps a revoked credential to the auth code, a server fault to the network
1015
- // one and everything else to plain failure, and records it in the local
1016
- // log with whatever next step the server named. This used to answer
1017
- // `network` to all of them, so an image too large for the account exited
1018
- // the same way an unreachable server did, and nothing about it was logged.
1019
- return { ok: false, error: null, exit: reportError(deps, err) };
1020
- }
1021
- }
1022
- // ---------------------------------------------------------------------------
1023
- // hook / ask / close — harness integration
1024
- // ---------------------------------------------------------------------------
1025
- export const HOOK_EVENTS = ['user-prompt-submit', 'stop', 'session-end'];
1026
- /** SessionEnd cleanup must precede every diagnostic that can wait on a file lock. */
1027
- export function hookDefersDiagnosticsUntilAfterCleanup(event) {
1028
- return event === 'session-end';
1029
- }
1030
- /**
1031
- * Runs one harness hook. Contract with every harness: hook JSON arrives on
1032
- * stdin, the decision (if any) goes to stdout, diagnostics go to stderr, and
1033
- * exit 0 with no stdout means "no decision — carry on as normal".
1034
- *
1035
- * Every failure path in here must reach that no-decision state. A hook that
1036
- * throws, or that blocks past the harness's timeout, degrades the agent for a
1037
- * feature the user only asked to make it more convenient.
1038
- */
1039
- export async function hookRunCommand(deps, event, readStdin, harness) {
1040
- if (!HOOK_EVENTS.includes(event)) {
1041
- deps.io.err(`Unknown hook event "${event}". Valid: ${HOOK_EVENTS.join(', ')}`);
1042
- return EXIT.usage;
1043
- }
1044
- // One clock owns the complete Stop invocation, including stdin, config,
1045
- // credentials, and client construction. Starting this inside `handleStop`
1046
- // would grant slow setup a second budget and let the harness kill us before
1047
- // an accepted answer is journaled or written to stdout.
1048
- const now = deps.now ?? Date.now;
1049
- // The waiter may spend a long wall clock exactly when no turn is held open
1050
- // for it, which is the same condition the installer used to declare
1051
- // `async: true`. One predicate decides it for both.
1052
- const detachedWaiter = stopHandlerIsDetached(harness, deps.hookPlatform ?? process.platform);
1053
- const processDeadlineAt = now() + waiterCeilingSeconds(detachedWaiter) * 1000;
1054
- const logger = log(deps);
1055
- logger.bind({ cmd: `hook ${event}` });
1056
- let started = false;
1057
- const start = (data = {}) => {
1058
- if (started)
1059
- return;
1060
- started = true;
1061
- logger.info('hook.start', { hook: event, harness: harness ?? 'unknown', ...data });
1062
- };
1063
- const failureData = (err) => err instanceof ApiCallError
1064
- ? { status: err.status, code: err.code, message: err.message, details: err.details }
1065
- : { message: err instanceof Error ? err.message : String(err) };
1066
- let raw;
1067
- try {
1068
- raw = await readStdin();
1069
- }
1070
- catch (err) {
1071
- start({ input: 'unavailable' });
1072
- logger.error('hook.end', {
1073
- hook: event,
1074
- outcome: 'ignored',
1075
- reason: 'input-read-failed',
1076
- ...failureData(err),
1077
- });
1078
- return EXIT.ok;
1079
- }
1080
- if (raw.trim() !== '') {
1081
- try {
1082
- const parsed = JSON.parse(raw);
1083
- if (typeof parsed !== 'object' || parsed === null)
1084
- throw new Error('not an object');
1085
- }
1086
- catch {
1087
- start({ input: 'malformed' });
1088
- logger.error('hook.end', { hook: event, outcome: 'ignored', reason: 'malformed-input' });
1089
- deps.io.err('notifai: ignored malformed or truncated hook input; no routing action was taken');
1090
- return EXIT.ok;
1091
- }
1092
- }
1093
- let envelope = parseHookInput(raw);
1094
- if (harness === 'cursor') {
1095
- const sessionId = envelope.session_id ?? envelope.conversation_id;
1096
- const cwd = envelope.cwd ?? envelope.workspace_roots?.[0];
1097
- envelope = {
1098
- ...envelope,
1099
- ...(sessionId === undefined ? {} : { session_id: sessionId }),
1100
- ...(cwd === undefined ? {} : { cwd }),
1101
- stop_hook_active: envelope.stop_hook_active ??
1102
- (typeof envelope.loop_count === 'number' && envelope.loop_count > 0),
1103
- };
1104
- }
1105
- const cwd = envelope.cwd ?? deps.cwd;
1106
- const sessionEnd = hookDefersDiagnosticsUntilAfterCleanup(event);
1107
- logger.bind({ session: envelope.session_id ?? null });
1108
- let config = null;
1109
- let configFailure;
1110
- try {
1111
- config = loadConfig({ cwd, env: deps.env, sessionId: envelope.session_id });
1112
- // The hook's project is the session's, not this process's, and the log
1113
- // settings that apply are that project's too. Keeping a mutable bootstrap
1114
- // logger lets this more-specific layer turn logging back on.
1115
- logger.adopt(logSettingsFrom(config));
1116
- logger.bind({ project: config.project.value });
1117
- if (!sessionEnd) {
1118
- start({ cwd, stop_hook_active: envelope.stop_hook_active ?? null });
1119
- logConfigResolved(logger, config);
1120
- }
1121
- }
1122
- catch (err) {
1123
- configFailure = err;
1124
- if (!sessionEnd) {
1125
- start({ cwd, stop_hook_active: envelope.stop_hook_active ?? null });
1126
- logger.error('hook.end', {
1127
- hook: event,
1128
- outcome: 'failed',
1129
- reason: 'config-failed',
1130
- ...failureData(err),
1131
- });
1132
- for (const line of describeHookFailure(err))
1133
- deps.io.err(`notifai: ${line}`);
1134
- return EXIT.ok;
1135
- }
1136
- }
1137
- // Everything below is inside one fail-open boundary. Credential loading,
1138
- // client construction and hook handling can all throw, and a hook that exits
1139
- // non-zero makes the harness report a failure — strictly worse than skipping.
1140
- try {
1141
- if (sessionEnd) {
1142
- // Codex gives SessionEnd one second total. Do every durable cleanup write
1143
- // before lifecycle diagnostics: the log lock is deliberately allowed to
1144
- // wait that long, and a busy log must never preserve ended-session state
1145
- // or its inherited configuration. The resolved config above is retained
1146
- // in memory so logging still uses the ending session's settings afterwards.
1147
- const outcome = handleSessionEnd(deps.env, envelope, (deps.now ?? Date.now)());
1148
- start({ cwd, stop_hook_active: envelope.stop_hook_active ?? null });
1149
- if (config !== null)
1150
- logConfigResolved(logger, config);
1151
- const data = { hook: event, decided: false, ...outcome.log };
1152
- if (configFailure === undefined)
1153
- logger.info('hook.end', data);
1154
- else {
1155
- logger.error('hook.end', {
1156
- ...data,
1157
- reason: 'config-failed',
1158
- config_error: failureData(configFailure),
1159
- });
1160
- }
1161
- for (const note of outcome.notes)
1162
- deps.io.err(`notifai: ${note}`);
1163
- return EXIT.ok;
1164
- }
1165
- // Non-SessionEnd hooks cannot reach here without resolved configuration.
1166
- const resolved = config;
1167
- const credential = deps.store.load();
1168
- if (!credential) {
1169
- logger.error('hook.end', { hook: event, outcome: 'not-paired' });
1170
- deps.io.err('notifai: hook skipped: this machine is not paired; run `notifai login`');
1171
- return EXIT.ok;
1172
- }
1173
- // Pin authenticated traffic to the origin the credential was issued for. A
1174
- // repository can commit `.notifai/config.toml`, and honouring a base_url
1175
- // from it would hand this machine's bearer token to whatever host it names.
1176
- const baseUrl = credential.baseUrl;
1177
- if (resolved.base_url.source !== 'default' && resolved.base_url.value !== baseUrl) {
1178
- deps.io.err(`notifai: ignoring base_url from ${resolved.base_url.source}; hooks only talk to ${baseUrl}`);
1179
- }
1180
- // UserPromptSubmit runs in front of the user's own prompt under a 15s
1181
- // harness ceiling and can make two calls, so each gets a small slice of it;
1182
- // Stop is allowed to block and keeps the ordinary budget.
1183
- const client = makeClient(deps, baseUrl, `Bearer nfm_${credential.machineId}.${credential.secret}`, {
1184
- timeoutMs: event === 'user-prompt-submit' ? 4_000 : 20_000,
1185
- ...(event === 'stop' ? { deadlineAt: processDeadlineAt, now } : {}),
1186
- });
1187
- const ctx = {
1188
- client,
1189
- config: resolved,
1190
- env: deps.env,
1191
- now,
1192
- sleep: deps.sleep ?? ((ms) => new Promise((resolve) => setTimeout(resolve, ms))),
1193
- waitForFirstReply: async (requestId, timeoutSeconds) => {
1194
- const result = await waitForReply(client, requestId, {
1195
- timeoutSeconds,
1196
- afterSeq: 0,
1197
- now: deps.now,
1198
- sleep: deps.sleep,
1199
- });
1200
- return {
1201
- replies: result.response.replies,
1202
- timedOut: result.timedOut,
1203
- degraded: result.degraded,
1204
- };
1205
- },
1206
- log: logger,
1207
- ...(harness === undefined ? {} : { harness }),
1208
- };
1209
- // Real clock, deliberately, not `deps.now`. This compares against file
1210
- // mtimes, which are wall-clock facts — handing it a virtual or skewed clock
1211
- // would have it delete live session state as "abandoned".
1212
- // Daily state pruning is housekeeping, not part of the Stop delivery
1213
- // contract. Its directory scan has no useful bound, so keep it on the
1214
- // short prompt path and never spend the answer owner's finite budget on it.
1215
- if (event !== 'stop')
1216
- pruneAbandonedSessions(deps.env);
1217
- const outcome = event === 'user-prompt-submit'
1218
- ? await handleUserPromptSubmit(ctx, envelope)
1219
- : await handleStop(ctx, envelope, processDeadlineAt, stopWakeRoute(deps, harness, envelope.session_id, cwd));
1220
- // Answer diagnostics are already persisted once as hook.answer. Keep every
1221
- // other note in the lifecycle record without duplicating the user's text.
1222
- const notes = outcome.notes.filter((note) => !/^(?:late )?answer from /.test(note));
1223
- logger.info('hook.end', {
1224
- hook: event,
1225
- // A hook that returns stdout has taken over the turn; one that does not
1226
- // has handed the terminal back. That distinction is the whole contract.
1227
- decided: outcome.stdout !== undefined,
1228
- ...(notes.length === 0 ? {} : { notes }),
1229
- ...outcome.log,
1230
- });
1231
- for (const note of outcome.notes)
1232
- deps.io.err(`notifai: ${note}`);
1233
- if (outcome.stdout !== undefined)
1234
- deps.io.out(outcome.stdout);
1235
- return EXIT.ok;
1236
- }
1237
- catch (err) {
1238
- // SessionEnd defers its start record until after cleanup; if cleanup itself
1239
- // fails, begin the after-the-fact lifecycle here before recording why.
1240
- if (sessionEnd)
1241
- start({ cwd, stop_hook_active: envelope.stop_hook_active ?? null });
1242
- // The hook still exits 0 — handing the terminal back is always right. What
1243
- // this adds is that the reason survives, including the server's own words.
1244
- logger.error('hook.end', {
1245
- hook: event,
1246
- outcome: 'failed',
1247
- reason: 'execution-failed',
1248
- ...failureData(err),
1249
- });
1250
- for (const line of describeHookFailure(err))
1251
- deps.io.err(`notifai: ${line}`);
1252
- return EXIT.ok;
1253
- }
1254
- }
1255
- /**
1256
- * The last meter for an answer this Stop hook accepted, chosen by harness.
1257
- *
1258
- * Both wake adapters need the harness process that invoked this hook: Claude's
1259
- * to prove exact own-child session ownership before it posts to the inbox
1260
- * socket, Codex's to know whether its own stdout is still a live continuation
1261
- * channel. Without an exact session id neither can prove anything, so the
1262
- * waiter falls back to the plain blocking Stop continuation.
1263
- */
1264
- function stopWakeRoute(deps, harness, sessionId, cwd) {
1265
- if (sessionId === undefined)
1266
- return undefined;
1267
- if (harness === 'claude-code') {
1268
- if ((deps.hookPlatform ?? process.platform) === 'win32')
1269
- return undefined;
1270
- return claudeWakeRoute({
1271
- sessionId,
1272
- cwd,
1273
- sourcePid: deps.claudeSourcePid ?? claudeSessionPid(deps.env),
1274
- ...(deps.claudeWake === undefined ? {} : { adapters: deps.claudeWake }),
1275
- });
1276
- }
1277
- if (harness === 'codex') {
1278
- return codexWakeRoute({
1279
- threadId: sessionId,
1280
- cwd,
1281
- sourcePid: deps.codexSourcePid ?? process.ppid,
1282
- env: deps.env,
1283
- ...(deps.codexWake === undefined ? {} : { adapters: deps.codexWake }),
1284
- });
1285
- }
1286
- return undefined;
1287
- }
1288
- /**
1289
- * The pid of the Claude Code session this hook belongs to.
1290
- *
1291
- * Claude exports `CLAUDE_PID` to its hooks, and that is the authoritative
1292
- * answer. `process.ppid` agrees with it today — the hook command runs through
1293
- * a shell, but the shell `exec`s, so this process's parent *is* the session
1294
- * (probed against a live 2.1.228 session) — and it remains the fallback for a
1295
- * harness build that stops exporting the variable.
1296
- *
1297
- * Preferring the explicit value costs a line and removes a silent failure: if
1298
- * this ever resolved to a shell instead, no session descriptor would match, the
1299
- * route could not prove own-child ownership, and every answer would degrade to
1300
- * hold-for-next-turn with nothing reported as wrong.
1301
- */
1302
- function claudeSessionPid(env) {
1303
- const declared = Number(env['CLAUDE_PID']);
1304
- return Number.isInteger(declared) && declared > 0 ? declared : process.ppid;
1305
- }
1306
- /**
1307
- * What went wrong, in terms of what to do about it.
1308
- *
1309
- * On 2026-08-03 a contract change shipped without the server deploy that goes
1310
- * with it. The CLI stamped `lifecycle` on every question draft, the deployed
1311
- * server rejected the unknown field, and escalation stopped working entirely —
1312
- * announced as "hook failed, deferring to the terminal", which reads like a
1313
- * flaky network. The information needed to diagnose it in one second was
1314
- * already in hand: a 422 whose details name the offending path. It was being
1315
- * thrown away by `String(err)`.
1316
- *
1317
- * A hook still exits 0 whatever this says. Handing the terminal back is always
1318
- * right; the only question is whether the user is told anything they can use.
1319
- */
1320
- export function describeHookFailure(err) {
1321
- if (!(err instanceof ApiCallError)) {
1322
- return [`hook failed, deferring to the terminal (${String(err)})`];
1323
- }
1324
- const lines = [`hook failed, deferring to the terminal (${err.code}: ${err.message})`];
1325
- const paths = rejectedPaths(err.details);
1326
- if (paths.length > 0)
1327
- lines.push(`the server rejected: ${paths.join(', ')}`);
1328
- // A 422 on a draft this CLI built is not a user error — this CLI's own
1329
- // contract produced it. Either the server is behind, or the two disagree.
1330
- if (err.status === 422) {
1331
- lines.push('this build sent a field the server did not accept, which usually means the server ' +
1332
- 'is older than this CLI — check with `notifai doctor`');
1333
- }
1334
- return lines;
1335
- }
1336
- function rejectedPaths(details) {
1337
- if (!Array.isArray(details))
1338
- return [];
1339
- return details
1340
- .map((issue) => typeof issue === 'object' && issue !== null && typeof issue.path === 'string'
1341
- ? issue.path
1342
- : null)
1343
- .filter((path) => path !== null && path !== '')
1344
- .slice(0, 5);
1345
- }
1346
- /**
1347
- * Turn ask input into questions plus their canonical body. Everything is
1348
- * validated at registration because a later hook failure is easy to miss.
1349
- */
1350
- export function buildQuestions(flags, question) {
1351
- if (flags.form !== undefined) {
1352
- if (question !== undefined || flags.choice?.length || flags.multi) {
1353
- return { ok: false, error: '--form replaces the positional question, --choice, and --multi.' };
1354
- }
1355
- let parsed;
1356
- try {
1357
- parsed = JSON.parse(flags.form);
1358
- }
1359
- catch {
1360
- return {
1361
- ok: false,
1362
- error: '--form must be JSON: {"questions": [{"text", "choices"?, "multi"?}], "body"?}.',
1363
- };
1364
- }
1365
- if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
1366
- return { ok: false, error: `--form needs a "questions" array (1-${REPLY_MAX_QUESTIONS} entries).` };
1367
- }
1368
- const record = parsed;
1369
- const unknownKeys = Object.keys(record).filter((key) => key !== 'questions' && key !== 'body');
1370
- if (unknownKeys.length > 0) {
1371
- return {
1372
- ok: false,
1373
- error: `Unknown --form ${unknownKeys.length === 1 ? 'key' : 'keys'}: ${unknownKeys.join(', ')}. Use "body" for Markdown context.`,
1374
- };
1375
- }
1376
- if (!Array.isArray(record['questions'])) {
1377
- return { ok: false, error: `--form needs a "questions" array (1-${REPLY_MAX_QUESTIONS} entries).` };
1378
- }
1379
- const formQuestions = record['questions'];
1380
- if (formQuestions.length < 1 || formQuestions.length > REPLY_MAX_QUESTIONS) {
1381
- return {
1382
- ok: false,
1383
- error: `A form asks 1-${REPLY_MAX_QUESTIONS} questions; this one has ${formQuestions.length}.`,
1384
- };
1385
- }
1386
- if (record['body'] !== undefined && typeof record['body'] !== 'string') {
1387
- return { ok: false, error: '"body" must be a Markdown string.' };
1388
- }
1389
- if (flags.body !== undefined && record['body'] !== undefined) {
1390
- return { ok: false, error: 'Pass form context in either --body or the form "body" key, not both.' };
1391
- }
1392
- const questions = [];
1393
- const usedIds = new Set();
1394
- for (const [index, entry] of formQuestions.entries()) {
1395
- if (typeof entry !== 'object' || entry === null || typeof entry.text !== 'string') {
1396
- return { ok: false, error: `Question ${index + 1} needs a "text" string.` };
1397
- }
1398
- const spec = entry;
1399
- const built = buildOneQuestion(spec.text, spec.choices, spec.multi === true, index, usedIds);
1400
- if ('error' in built)
1401
- return { ok: false, error: `Question ${index + 1}: ${built.error}` };
1402
- questions.push(built.question);
1403
- }
1404
- const context = flags.body ?? record['body'];
1405
- const questionBlock = questions.map((entry, index) => `${index + 1}. ${entry.text}`).join('\n');
1406
- return {
1407
- ok: true,
1408
- questions,
1409
- body: context !== undefined && context.trim() !== ''
1410
- ? `${questionBlock}\n\n${context}`
1411
- : questionBlock,
1412
- };
1413
- }
1414
- if (question === undefined || question.trim() === '') {
1415
- return { ok: false, error: 'The question cannot be empty.' };
1416
- }
1417
- const built = buildOneQuestion(question, flags.choice, flags.multi === true, 0, new Set());
1418
- if ('error' in built)
1419
- return { ok: false, error: built.error };
1420
- const context = flags.body;
1421
- return {
1422
- ok: true,
1423
- questions: [built.question],
1424
- body: context !== undefined && context.trim() !== ''
1425
- ? `${built.question.text}\n\n${context}`
1426
- : built.question.text,
1427
- };
1428
- }
1429
- function buildOneQuestion(text, choiceLabels, multi, index, usedIds) {
1430
- const trimmed = text.trim();
1431
- if (trimmed === '')
1432
- return { error: 'the question text cannot be empty.' };
1433
- if (trimmed.length > QUESTION_TEXT_MAX_LENGTH) {
1434
- return {
1435
- error: `a question must be readable where it is answered: keep it within ` +
1436
- `${QUESTION_TEXT_MAX_LENGTH} characters and put the longer context in --body.`,
1437
- };
1438
- }
1439
- const choices = parseChoices(choiceLabels);
1440
- if (choices === 'invalid')
1441
- return { error: CHOICE_USAGE };
1442
- if (multi && choices === null) {
1443
- return { error: '--multi needs answers to select between; add --choice.' };
1444
- }
1445
- let id = slugify(trimmed);
1446
- if (id === '' || usedIds.has(id))
1447
- id = `q${index + 1}`;
1448
- usedIds.add(id);
1449
- return {
1450
- question: {
1451
- id,
1452
- text: trimmed,
1453
- ...(choices !== null ? { choices } : {}),
1454
- ...(multi ? { multi: true } : {}),
1455
- },
1456
- };
1457
- }
1458
- function buildAskDraft(config, built, flags, invocation, mediaIds) {
1459
- const result = buildDraft(config, {
1460
- title: built.questions[0].text,
1461
- body: built.body,
1462
- ...(flags.project !== undefined ? { project: flags.project } : {}),
1463
- ...(mediaIds.length > 0 ? { image: mediaIds } : {}),
1464
- ...(flags.imageAlt !== undefined ? { imageAlt: flags.imageAlt } : {}),
1465
- reply: true,
1466
- questions: built.questions,
1467
- }, invocation);
1468
- if (!result.ok)
1469
- return result;
1470
- const capabilities = CAPABILITIES_V1.describe(result.platform);
1471
- if (capabilities === null)
1472
- return { ok: false, error: 'No iOS capability contract is available.' };
1473
- const validation = validateDraft(result.draft, capabilities);
1474
- if (!validation.ok) {
1475
- return {
1476
- ok: false,
1477
- error: validation.errors.map((issue) => `${issue.path}: ${issue.message}`).join('\n'),
1478
- };
1479
- }
1480
- return { ok: true, draft: result.draft };
1481
- }
1482
- function recordRegisteredQuestion(deps, sessionId, built, draft, json = false) {
1483
- try {
1484
- registerQuestion(sessionId, deps.env, {
1485
- question: built.questions[0].text,
1486
- questions: built.questions,
1487
- body: draft.presentation.body,
1488
- ...(draft.project !== undefined ? { project: draft.project } : {}),
1489
- ...(draft.source !== undefined ? { source: draft.source } : {}),
1490
- ...(draft.presentation.media !== undefined ? { media: draft.presentation.media } : {}),
1491
- }, (deps.now ?? Date.now)());
1492
- }
1493
- catch (err) {
1494
- log(deps).error('ask.registered', { ok: false, session: sessionId, message: String(err) });
1495
- deps.io.err(`Could not register the question: ${err instanceof Error ? err.message : String(err)}`);
1496
- return EXIT.failed;
1497
- }
1498
- log(deps).info('ask.registered', {
1499
- ok: true,
1500
- session: sessionId,
1501
- questions: built.questions.length,
1502
- text: built.questions[0].text,
1503
- choices: built.questions[0].choices?.length ?? 0,
1504
- media: draft.presentation.media?.length ?? 0,
1505
- });
1506
- // The block below is the densest guidance this CLI prints, and until now it
1507
- // was prose only: an agent could not read back the choice ids it must branch
1508
- // on without asking the server for them. The JSON form carries the same
1509
- // obligation as data.
1510
- if (json) {
1511
- deps.io.out(JSON.stringify({
1512
- registered: true,
1513
- questions: built.questions.map((entry) => ({
1514
- id: entry.id,
1515
- text: entry.text,
1516
- ...(entry.choices === undefined ? {} : { choices: entry.choices }),
1517
- ...(entry.multi === true ? { multi: true } : {}),
1518
- })),
1519
- next: {
1520
- end_turn: true,
1521
- in_this_turn: 'Ask the question in the conversation and say what concrete work each possible answer will make you resume, then end the turn.',
1522
- route_neutral: 'Never say where the answer must arrive; it returns by whatever route the harness supports.',
1523
- on_answer: 'Acknowledge it, then resume the committed work without asking the user to confirm again.',
1524
- },
1525
- }, null, 2));
1526
- return EXIT.ok;
1527
- }
1528
- for (const [index, entry] of built.questions.entries()) {
1529
- const prefix = built.questions.length > 1 ? `${index + 1}. ` : '';
1530
- if (entry.choices !== undefined) {
1531
- const kind = entry.multi === true ? 'answers offered (several may be chosen)' : 'answers offered';
1532
- deps.io.out(`${prefix}${entry.text} — ${kind}: ${entry.choices.map((choice) => choice.label).join(' / ')}`);
1533
- }
1534
- else if (built.questions.length > 1) {
1535
- deps.io.out(`${prefix}${entry.text} — free text`);
1536
- }
1537
- }
1538
- deps.io.out(built.questions.length > 1
1539
- ? `${built.questions.length} questions registered as one form. Ask them in the conversation, state the concrete work you will resume for their answers, then end your turn.`
1540
- : 'Question registered. Ask it in the conversation, state the concrete work you will resume when the answer arrives, then end your turn.');
1541
- deps.io.out('Before ending this turn, pre-commit in your own words to the work you will resume:');
1542
- for (const [index, entry] of built.questions.entries()) {
1543
- const questionPrefix = built.questions.length > 1 ? `Question ${index + 1}, ` : '';
1544
- if (entry.choices !== undefined) {
1545
- for (const choice of entry.choices) {
1546
- deps.io.out(`- ${questionPrefix}If the answer is ${JSON.stringify(choice.label)}: state the concrete work you will resume.`);
1547
- }
1548
- deps.io.out(`- ${questionPrefix}For an unexpected typed answer: state how it will determine the concrete work you resume.`);
1549
- }
1550
- else {
1551
- deps.io.out(`- ${questionPrefix}For the free-text answer: state how its content will determine the concrete work you resume.`);
1552
- }
1553
- }
1554
- deps.io.out('When the answer arrives, resume the matching work without asking the user to confirm again. Frame this as work you will resume, not as approval you receive.');
1555
- deps.io.out('A Notifai answer cannot answer a harness permission prompt or interactive picker; leave those to the harness and user.');
1556
- return EXIT.ok;
1557
- }
1558
- async function uploadAskMedia(deps, config, sessionId, built, flags, invocation) {
1559
- const authed = authedClient(deps, config);
1560
- if (!authed)
1561
- return EXIT.auth;
1562
- const mediaIds = [];
1563
- for (const image of flags.image ?? []) {
1564
- if (image.startsWith('med_')) {
1565
- mediaIds.push(image);
1566
- continue;
1567
- }
1568
- const uploaded = await uploadImage(deps, authed.client, image);
1569
- if (!uploaded.ok) {
1570
- if (uploaded.error !== null)
1571
- deps.io.err(uploaded.error);
1572
- return uploaded.exit;
1573
- }
1574
- mediaIds.push(uploaded.mediaId);
1575
- }
1576
- const ready = buildAskDraft(config, built, flags, invocation, mediaIds);
1577
- if (!ready.ok) {
1578
- deps.io.err(ready.error);
1579
- return EXIT.usage;
1580
- }
1581
- return recordRegisteredQuestion(deps, sessionId, built, ready.draft, flags.json === true);
1582
- }
1583
- /**
1584
- * Registers a question for turn-end routing. Returns immediately so the agent
1585
- * can ask in prose and end its turn; the terminal keeps the question to itself
1586
- * for `ask_grace_seconds` before it reaches any device.
1587
- */
1588
- export function askCommand(deps, question, flags) {
1589
- // Validate before route discovery. A malformed question belongs to the
1590
- // caller and should not be hidden behind whichever harness setup issue
1591
- // happens to exist on this machine.
1592
- const built = buildQuestions(flags, question);
1593
- if (!built.ok) {
1594
- deps.io.err(built.error);
1595
- return EXIT.usage;
1596
- }
1597
- const mediaInputError = validateMediaInputs(flags.image, flags.imageAlt);
1598
- if (mediaInputError !== null) {
1599
- deps.io.err(mediaInputError);
1600
- return EXIT.usage;
1601
- }
1602
- const routingConfig = loadConfig({ cwd: deps.cwd, env: deps.env });
1603
- if (!routingConfig.ask_notifications.value) {
1604
- deps.io.err('Question routing is disabled by ask_notifications=false; enable it or use a blocking `notifai send --reply` question.');
1605
- return EXIT.usage;
1606
- }
1607
- if (deps.store.load() === null) {
1608
- deps.io.err('Question routing is not paired on this machine; run `notifai login` before registering an asynchronous question.');
1609
- return EXIT.usage;
1610
- }
1611
- // An agent calling this gets no hook payload. Harness-native environment
1612
- // markers identify the active owner, while UserPromptSubmit adds the hook's
1613
- // canonical id to the directory's concurrent-session index.
1614
- const now = (deps.now ?? Date.now)();
1615
- const { active, contested } = resolveActiveHarness(deps.env, deps.cwd, now);
1616
- let sessionId;
1617
- if (active !== null) {
1618
- const installations = findInstallations(deps.cwd, deps.env, deps.hookAdapterHome, deps.hookPlatform);
1619
- const activeInstalled = installations.some((installation) => installation.harness === active.harness);
1620
- if (!activeInstalled) {
1621
- for (const line of diagnoseActiveHarnessSession(active, 'not-installed', installations, contested)) {
1622
- deps.io.err(line);
1623
- }
1624
- return EXIT.usage;
1625
- }
1626
- if (active.sessionId === undefined) {
1627
- for (const problem of activeQuestionRouteProblems(deps, active, installations)) {
1628
- deps.io.err(`Question routing is not ready: ${problem}`);
1629
- }
1630
- return EXIT.usage;
1631
- }
1632
- const projectPointer = readMatchingProjectSessionPointer(deps.cwd, deps.env, now, active.sessionId, active.harness);
1633
- if (projectPointer === null) {
1634
- for (const line of diagnoseActiveHarnessSession(active, 'not-fired', installations, contested)) {
1635
- deps.io.err(line);
1636
- }
1637
- return EXIT.usage;
1638
- }
1639
- const routeProblems = activeQuestionRouteProblems(deps, active, installations);
1640
- if (routeProblems.length > 0) {
1641
- for (const problem of routeProblems)
1642
- deps.io.err(`Question routing is not ready: ${problem}`);
1643
- return EXIT.usage;
1644
- }
1645
- if (readSessionState(projectPointer.sessionId, deps.env).last_stop_at === undefined) {
1646
- deps.io.err(`Question routing is not ready: this ${active.label} session has fired UserPromptSubmit, but its Stop hook has not been observed. End one harmless turn, send a new prompt, then run \`notifai doctor\`.`);
1647
- return EXIT.usage;
1648
- }
1649
- sessionId = projectPointer.sessionId;
1650
- }
1651
- else {
1652
- for (const line of diagnoseMissingSession(deps))
1653
- deps.io.err(line);
1654
- deps.io.err('Could not prove which live harness session owns this command, so Notifai will not register a question that could be delivered to the wrong or already-ended agent. Run it from a supported harness with exact session identity, or use a blocking `notifai send --reply` question.');
1655
- return EXIT.usage;
1656
- }
1657
- if (!sessionId) {
1658
- for (const line of diagnoseMissingSession(deps))
1659
- deps.io.err(line);
1660
- return EXIT.usage;
1661
- }
1662
- const source = resolveDraftInvocation(deps, flags, active);
1663
- if (!source.ok) {
1664
- deps.io.err(source.error);
1665
- return EXIT.usage;
1666
- }
1667
- if (source.invocation.source?.session_id !== sessionId) {
1668
- deps.io.err(`Question routing is not ready: --session-id or NOTIFAI_SESSION_ID does not match the exact active ${active.label} session; refusing cross-session routing.`);
1669
- return EXIT.usage;
1670
- }
1671
- // Placeholders let every body, source, project, media, and payload limit fail
1672
- // before an upload starts. The real ids replace them only after this passes.
1673
- const placeholders = (flags.image ?? []).map((_, index) => `med_pending_${index + 1}`);
1674
- const preflight = buildAskDraft(routingConfig, built, flags, source.invocation, placeholders);
1675
- if (!preflight.ok) {
1676
- deps.io.err(preflight.error);
1677
- return EXIT.usage;
1678
- }
1679
- if ((flags.image?.length ?? 0) > 0) {
1680
- return uploadAskMedia(deps, routingConfig, sessionId, built, flags, source.invocation);
1681
- }
1682
- return recordRegisteredQuestion(deps, sessionId, built, preflight.draft, flags.json === true);
1683
- }
1684
- /**
1685
- * Every harness marker present in this environment, in declared order.
1686
- *
1687
- * Order here is a last resort, not an answer. A harness exports its markers
1688
- * into every process it starts, so a nested harness inherits its parent's
1689
- * markers alongside its own and the environment alone cannot say which of them
1690
- * owns this shell; `resolveActiveHarness` settles that with live evidence.
1691
- */
1692
- function harnessEnvCandidates(env) {
1693
- const candidates = [];
1694
- if (env['NOTIFAI_ACTIVE_HARNESS'] === 'opencode') {
1695
- const sessionId = env['NOTIFAI_ACTIVE_SESSION_ID'];
1696
- candidates.push({
1697
- harness: 'opencode',
1698
- label: 'OpenCode',
1699
- ...(sessionId === undefined || sessionId === '' ? {} : { sessionId }),
1700
- });
1701
- }
1702
- if (env['CLAUDECODE'] === '1') {
1703
- const sessionId = env['CLAUDE_CODE_SESSION_ID'];
1704
- candidates.push({
1705
- harness: 'claude-code',
1706
- label: 'Claude Code',
1707
- ...(sessionId === undefined || sessionId === '' ? {} : { sessionId }),
1708
- });
1709
- }
1710
- const codexSession = env['CODEX_THREAD_ID'];
1711
- if (codexSession !== undefined && codexSession !== '') {
1712
- candidates.push({ harness: 'codex', label: 'Codex', sessionId: codexSession });
1713
- }
1714
- if ((env['CURSOR_AGENT'] ?? '') !== '')
1715
- candidates.push({ harness: 'cursor', label: 'Cursor' });
1716
- return candidates;
1717
- }
1718
- /**
1719
- * Which harness session owns this shell, when several claim to.
1720
- *
1721
- * Nesting is ordinary: an orchestrator running inside Claude Code starts a
1722
- * Codex session, and that Codex process inherits `CLAUDECODE` and
1723
- * `CLAUDE_CODE_SESSION_ID` on top of its own `CODEX_THREAD_ID`. The mirror is
1724
- * just as ordinary, so no fixed precedence between two markers can be right —
1725
- * whichever one it favours is wrong in the opposite nesting, and the cost is
1726
- * silent: `ask` looks up a session that is not this one, and every remedy the
1727
- * agent is told to try addresses a harness that is not running here.
1728
- *
1729
- * The general rule is to prefer the most specific *live* signal over inherited
1730
- * environment. An inherited marker is a claim about some ancestor process; a
1731
- * session id that names an entry in this directory's live pointer index is
1732
- * evidence that that exact session fired a hook here and its state still
1733
- * exists. Live evidence therefore wins over declared order, and the most
1734
- * recently active pointer wins between two live candidates: the harness whose
1735
- * turn is running is the one that fired last, while its parent sits blocked on
1736
- * the child it started. Declared order decides only when nothing here has
1737
- * fired yet, and it says so — every route fails closed there anyway.
1738
- */
1739
- function resolveActiveHarness(env, cwd, now) {
1740
- const candidates = harnessEnvCandidates(env);
1741
- const first = candidates[0];
1742
- if (first === undefined)
1743
- return { active: null, contested: [] };
1744
- if (candidates.length === 1)
1745
- return { active: first, contested: [] };
1746
- for (const pointer of readLiveProjectSessionPointers(cwd, env, now)) {
1747
- const owner = candidates.find((candidate) => candidate.harness === pointer.harness && candidate.sessionId === pointer.sessionId);
1748
- if (owner !== undefined)
1749
- return { active: owner, contested: [] };
1750
- }
1751
- return { active: first, contested: candidates };
1752
- }
1753
- function activeHarnessSession(env, cwd, now) {
1754
- return resolveActiveHarness(env, cwd, now).active;
1755
- }
1756
- /**
1757
- * One fail-closed admission gate shared by every active harness route.
1758
- * Finding a file is not readiness: the exact session, one current definition,
1759
- * the stable adapter, a long enough Stop owner, and (for Codex) trust must all
1760
- * be true before `ask` is allowed to create an answerable notification.
1761
- */
1762
- function activeQuestionRouteProblems(deps, active, installations) {
1763
- const problems = [];
1764
- if (active.sessionId === undefined) {
1765
- problems.push(`the active ${active.label} shell does not expose an exact session id; a project-level last-writer pointer can cross-wire two sessions. Use a blocking \`notifai send --reply\` question`);
1766
- }
1767
- const capability = HARNESS_CAPABILITIES[active.harness];
1768
- if (capability.stopContinuation === 'unsupported') {
1769
- problems.push(`${active.label}: ${capability.deliveryContract}`);
1770
- }
1771
- const matching = installations.filter((installation) => installation.harness === active.harness);
1772
- if (matching.length > 1) {
1773
- problems.push(`${matching.length} ${active.label} definitions are active (${matching.map((entry) => entry.file).join(', ')}); keep either project or global routing`);
1774
- }
1775
- for (const installation of matching) {
1776
- for (const problem of installation.problems ?? []) {
1777
- problems.push(`${installation.file}: ${problem}`);
1778
- }
1779
- for (const handler of installation.handlers) {
1780
- const event = handlerEvent(handler.command);
1781
- if (event !== null && !HOOK_EVENTS.includes(event)) {
1782
- problems.push(`${handler.event} in ${installation.file} names the unsupported event ${event}; reinstall the ${active.label} hooks`);
1783
- }
1784
- }
1785
- }
1786
- for (const problem of inspectHookAdapter(deps.hookAdapterHome, deps.hookPlatform).problems) {
1787
- problems.push(problem);
1788
- }
1789
- for (const installation of matching)
1790
- problems.push(...stopShapeProblems(installation, deps.hookPlatform));
1791
- problems.push(...codexTrustProblems(matching, deps.env));
1792
- return [...new Set(problems)];
1793
- }
1794
- function diagnoseActiveHarnessSession(active, problem, installations, contested = []) {
1795
- // Naming one harness confidently is wrong when the environment carries the
1796
- // markers of several and none of them has fired here: whichever one the
1797
- // agent is told to prompt may not be the one it is running in.
1798
- const ambiguity = contested.length > 1
1799
- ? [
1800
- `Several harness markers are present here (${contested.map((candidate) => candidate.label).join(', ')}) and none names a session that has fired in this directory, so ${active.label} is a guess from the environment. Send the prompt in the harness you are actually running.`,
1801
- ]
1802
- : [];
1803
- if (problem === 'not-installed') {
1804
- const others = installations.map((installation) => installation.harness);
1805
- return [
1806
- `Could not register the question for the active ${active.label} session: Notifai ${active.label} hooks are not installed for this project.`,
1807
- ...ambiguity,
1808
- ...(others.length === 0
1809
- ? []
1810
- : [
1811
- `Hooks installed for ${[...new Set(others)].join(', ')} do not route an active ${active.label} session.`,
1812
- ]),
1813
- `Run \`notifai hooks install --harness ${active.harness}\`, then send one ${active.label} prompt and run \`notifai doctor\`.`,
1814
- `Retry \`notifai ask\` only after doctor reports that the ${active.label} hooks fired.`,
1815
- ];
1816
- }
1817
- if (problem === 'different-session') {
1818
- return [
1819
- `Could not register the question for the active ${active.label} session: the project pointer belongs to another ${active.label} session or harness.`,
1820
- ...ambiguity,
1821
- `Refusing to guess or cross-wire the question. Send one prompt in this ${active.label} session, then run \`notifai doctor\`.`,
1822
- `Retry \`notifai ask\` only after doctor reports that this active ${active.label} session fired the hooks.`,
1823
- ];
1824
- }
1825
- return [
1826
- `Could not register the question for the active ${active.label} session: Notifai hooks are installed, but this session has not published its pointer.`,
1827
- ...ambiguity,
1828
- `Send one ${active.label} prompt, then run \`notifai doctor\`.`,
1829
- `Retry \`notifai ask\` only after doctor reports that the active ${active.label} session fired the hooks.`,
1830
- ];
1831
- }
1832
- /**
1833
- * Why `ask` cannot see a session, in terms of what to do about it.
1834
- *
1835
- * Only a UserPromptSubmit hook firing produces the pointer this reads, and the
1836
- * old message answered every cause with "run `notifai hooks install` and send
1837
- * one prompt". The useful next action depends on the harness: some reload
1838
- * project hook files, OpenCode loads its plugin at startup, and Codex should be
1839
- * checked after a prompt before assuming that a new session is required.
1840
- */
1841
- function diagnoseMissingSession(deps) {
1842
- const installations = findInstallations(deps.cwd, deps.env, deps.hookAdapterHome, deps.hookPlatform);
1843
- if (installations.length === 0) {
1844
- return [
1845
- 'Could not tell which harness session this is: no Notifai hooks are installed for this project.',
1846
- 'Run `notifai hooks install`, then follow the activation instruction it prints.',
1847
- ];
1848
- }
1849
- const where = installations.map((i) => `${i.harness} in ${i.file}`).join(', ');
1850
- return [
1851
- `Could not tell which harness session this is. Notifai hooks are installed (${where}),`,
1852
- 'but no usable session pointer from the last 24 hours exists here.',
1853
- hookActivationAdvice(installations),
1854
- 'Do not bypass this with a guessed session id; run from the exact active harness session or use a blocking `notifai send --reply` question.',
1855
- ];
1856
- }
1857
- /** The least disruptive verified way to make each installed adapter run once. */
1858
- function hookActivationAdvice(installations) {
1859
- const harnesses = new Set(installations.map((installation) => installation.harness));
1860
- const advice = [];
1861
- if (installations.some((installation) => installation.harness === 'claude-code' && !installation.global)) {
1862
- advice.push('Claude Code: send one new prompt; project hook files reload without a restart');
1863
- }
1864
- if (installations.some((installation) => installation.harness === 'claude-code' && installation.global)) {
1865
- advice.push('Claude Code global hooks: send one prompt; start a new session only if it does not fire');
1866
- }
1867
- if (installations.some((installation) => installation.harness === 'cursor' && !installation.global)) {
1868
- advice.push('Cursor: send one prompt, then run `notifai doctor`; start a new session only if it still has not fired');
1869
- }
1870
- if (installations.some((installation) => installation.harness === 'cursor' && installation.global)) {
1871
- advice.push('Cursor global hooks: send one prompt; start a new session only if it does not fire');
1872
- }
1873
- if (harnesses.has('codex')) {
1874
- advice.push('Codex: send one prompt, then run `notifai doctor`; start a new session only if it still has not fired');
1875
- }
1876
- if (harnesses.has('opencode')) {
1877
- advice.push('OpenCode: restart it, then send one prompt; plugins load at startup, but non-blocking question continuation is intentionally unsupported');
1878
- }
1879
- return `${advice.join('. ')}.`;
1880
- }
1881
- /**
1882
- * Record the one Agent Acknowledgement associated with a replied-to request.
1883
- *
1884
- * `--text` is optional here because the acknowledgement is not: an account may
1885
- * turn the agent's written reply off, and the receipt must still be recorded so
1886
- * the user sees that an agent read the answer. The service holds the account's
1887
- * snapshot, so it — not this process — decides whether text was owed.
1888
- */
1889
- export async function acknowledgeCommand(deps, requestId, flags) {
1890
- const text = flags.text?.trim() ?? '';
1891
- if (flags.text !== undefined && text.length === 0) {
1892
- deps.io.err('--text must contain non-whitespace text. Drop it to acknowledge without text.');
1893
- return EXIT.usage;
1894
- }
1895
- if (text.length > AGENT_ACKNOWLEDGEMENT_MAX_LENGTH) {
1896
- deps.io.err(`--text must be at most ${AGENT_ACKNOWLEDGEMENT_MAX_LENGTH} characters after trimming. Shorten it: an acknowledgement is a receipt, not a report.`);
1897
- return EXIT.usage;
1898
- }
1899
- const logger = log(deps);
1900
- const config = loadLoggedConfig(deps, { cwd: deps.cwd, env: deps.env });
1901
- logger.info('acknowledgement.attempted', {
1902
- request_id: requestId,
1903
- text,
1904
- characters: text.length,
1905
- });
1906
- const authed = authedClient(deps, config);
1907
- if (!authed)
1908
- return EXIT.auth;
1909
- try {
1910
- const result = await authed.client.putAgentAcknowledgement(requestId, text.length > 0 ? { text } : {});
1911
- const output = {
1912
- request_id: requestId,
1913
- outcome: result.status,
1914
- acknowledgement: result.agent_acknowledgement,
1915
- agent_acknowledgement_required: true,
1916
- };
1917
- logger.info('acknowledgement.outcome', {
1918
- request_id: requestId,
1919
- outcome: result.status,
1920
- text: result.agent_acknowledgement.text,
1921
- created_at: result.agent_acknowledgement.created_at,
1922
- agent_acknowledgement_required: true,
1923
- });
1924
- const sessionId = readProjectSession(deps.cwd, deps.env, (deps.now ?? Date.now)());
1925
- if (sessionId !== null) {
1926
- clearAcknowledgementObligation(sessionId, deps.env, requestId);
1927
- }
1928
- if (flags.json)
1929
- deps.io.out(JSON.stringify(output, null, 2));
1930
- else {
1931
- deps.io.out(`Agent Acknowledgement ${result.status} for ${requestId} at ${result.agent_acknowledgement.created_at}.`);
1932
- }
1933
- return EXIT.ok;
1934
- }
1935
- catch (err) {
1936
- return reportError(deps, err, { operation: 'agent_acknowledgement', request_id: requestId });
1937
- }
1938
- }
1939
- /** Retire a question so a late answer is rejected rather than silently lost. */
1940
- export async function closeCommand(deps, requestId, flags = {}) {
1941
- const config = loadLoggedConfig(deps, { cwd: deps.cwd, env: deps.env });
1942
- const authed = authedClient(deps, config);
1943
- if (!authed)
1944
- return EXIT.auth;
1945
- try {
1946
- const response = await authed.client.closeReplies(requestId);
1947
- if (flags.json) {
1948
- deps.io.out(JSON.stringify({
1949
- ...response,
1950
- acknowledgement_command: acknowledgementCommand(response.request_id, response.agent_acknowledgement_required, response.agent_acknowledgement_text_required, response.agent_acknowledgement, response.replies.length > 0),
1951
- }, null, 2));
1952
- }
1953
- else {
1954
- deps.io.out(`Closed the reply window for ${requestId}.`);
1955
- if (response.replies.length > 0)
1956
- printAcknowledgementStatus(deps, response);
1957
- }
1958
- return EXIT.ok;
1959
- }
1960
- catch (err) {
1961
- return reportError(deps, err);
1962
- }
1963
- }
1964
- /** True when this process is `npx` / `npm exec`, not a global or linked install. */
1965
- export function runningViaNpx(env, scriptPath) {
1966
- if (env['npm_command'] === 'exec')
1967
- return true;
1968
- return scriptPath.includes(`${path.sep}_npx${path.sep}`);
1969
- }
1970
- function fileHookInstallTarget(target) {
1971
- if (target === undefined || isNpxAdapterTarget(target))
1972
- return undefined;
1973
- return target;
1974
- }
1975
- function resolveHookAdapterTarget(deps, flags) {
1976
- if (deps.hookInstallTarget !== undefined && isNpxAdapterTarget(deps.hookInstallTarget)) {
1977
- return deps.hookInstallTarget;
1978
- }
1979
- const fileTarget = fileHookInstallTarget(deps.hookInstallTarget);
1980
- const execPath = flags.execPath ?? fileTarget?.execPath ?? process.execPath;
1981
- const scriptPath = flags.scriptPath ?? fileTarget?.scriptPath ?? process.argv[1] ?? 'notifai';
1982
- if (runningViaNpx(deps.env, scriptPath)) {
1983
- const version = packageVersion();
1984
- const npmCli = deps.env['npm_execpath'];
1985
- if (version === null) {
1986
- throw new Error('Could not read this CLI version, so an npx hook target cannot be pinned. Install `@raidiant/notifai` globally and rerun `notifai hooks install`.');
1987
- }
1988
- if (typeof npmCli !== 'string' || npmCli === '') {
1989
- throw new Error('This process looks like npx but npm_execpath is missing. Install `@raidiant/notifai` globally and rerun `notifai hooks install`.');
1990
- }
1991
- return { kind: 'npx', execPath, npmCli, spec: `@raidiant/notifai@${version}` };
1992
- }
1993
- return { execPath, scriptPath };
1994
- }
1995
- function printHooksInstallClose(deps, harness, file) {
1996
- const label = HARNESS_LABELS[harness];
1997
- if (deps.io.interactive === true && deps.io.note) {
1998
- void deps.io.note(`${file}\nSend one ${label} prompt, then run \`notifai doctor\`.`, `${label} hooks installed`);
1999
- return;
2000
- }
2001
- deps.io.out(`Installed ${harness} hooks in ${file}`);
2002
- deps.io.out(`Send one ${label} prompt, then check \`notifai doctor\`.`);
2003
- }
2004
- export function hooksInstallCommand(deps, flags) {
2005
- if (flags.harness === undefined) {
2006
- const detected = detectedHarnesses(deps.cwd, deps.env);
2007
- if (detected.length === 0) {
2008
- deps.io.err(`Could not tell which harness you mean — pass --harness <${HARNESSES.join('|')}>.`);
2009
- return EXIT.usage;
2010
- }
2011
- let ok = true;
2012
- for (const harness of detected) {
2013
- if (hooksInstallCommand(deps, { ...flags, harness }) !== EXIT.ok)
2014
- ok = false;
2015
- }
2016
- return ok ? EXIT.ok : EXIT.failed;
2017
- }
2018
- const harness = resolveHarness(deps, flags.harness);
2019
- if (!harness)
2020
- return EXIT.usage;
2021
- const adapterTarget = resolveHookAdapterTarget(deps, flags);
2022
- const scriptPath = flags.scriptPath ?? fileHookInstallTarget(adapterTarget)?.scriptPath ?? process.argv[1] ?? 'notifai';
2023
- const hookPlatform = deps.hookPlatform ?? process.platform;
2024
- const nodePath = adapterTarget.execPath;
2025
- let adapterPath;
2026
- try {
2027
- adapterPath = installHookAdapter(adapterTarget, deps.hookAdapterHome, hookPlatform).path;
2028
- }
2029
- catch (err) {
2030
- deps.io.err(`Could not prepare the stable hook adapter: ${String(err)}`);
2031
- return EXIT.failed;
2032
- }
2033
- const wantGlobal = flags.global === true;
2034
- const existing = findInstallations(deps.cwd, deps.env, deps.hookAdapterHome, deps.hookPlatform).filter((installation) => installation.harness === harness);
2035
- const otherScope = existing.filter((installation) => installation.global !== wantGlobal);
2036
- if (!wantGlobal && otherScope.some((installation) => installation.global)) {
2037
- const globalFile = otherScope.find((installation) => installation.global)?.file;
2038
- deps.io.out(`${HARNESS_LABELS[harness]} hooks already cover this machine (${globalFile}). This project does not need its own copy. To wire only this project: notifai hooks uninstall --harness ${harness} --global && notifai hooks install --harness ${harness}`);
2039
- return EXIT.ok;
2040
- }
2041
- if (wantGlobal && otherScope.some((installation) => !installation.global)) {
2042
- if (hooksUninstallCommand(deps, { ...flags, global: false, harness }) !== EXIT.ok) {
2043
- return EXIT.failed;
2044
- }
2045
- }
2046
- const codexPaths = harness === 'codex'
2047
- ? codexLayerPaths(wantGlobal, deps.cwd, deps.env, hookPlatform)
2048
- : null;
2049
- const settingsTarget = codexPaths?.configToml ?? settingsFile(harness, wantGlobal, deps.cwd, deps.env, hookPlatform);
2050
- // OpenCode's adapter is a generated plugin module rather than a handler
2051
- // merged into a settings document, so it owns the whole file.
2052
- if (harness === 'opencode') {
2053
- return installOpencodePlugin(deps, settingsTarget, {
2054
- adapterPath,
2055
- timeoutSeconds: BLOCKING_STOP_TIMEOUT_SECONDS,
2056
- platform: hookPlatform,
2057
- nodePath,
2058
- });
2059
- }
2060
- if (harness === 'cursor') {
2061
- try {
2062
- withTargetFileLock(settingsTarget, () => {
2063
- const document = loadCursorSettings(settingsTarget);
2064
- const result = mergeCursorHooks(document, buildCursorHookConfig({
2065
- adapterPath,
2066
- harness: 'cursor',
2067
- platform: hookPlatform,
2068
- nodePath,
2069
- }), scriptPath);
2070
- applyPlan(settingsTarget, result.document);
2071
- return result;
2072
- });
2073
- }
2074
- catch (err) {
2075
- deps.io.err(String(err));
2076
- return EXIT.failed;
2077
- }
2078
- printHooksInstallClose(deps, harness, settingsTarget);
2079
- return EXIT.ok;
2080
- }
2081
- const installInto = (file) => {
2082
- const document = loadSettings(file);
2083
- const foreignStopCount = foreignStopHandlers(document).length;
2084
- const result = mergeHooks(document, buildHookConfig({
2085
- adapterPath,
2086
- harness,
2087
- platform: hookPlatform,
2088
- nodePath,
2089
- }), scriptPath);
2090
- applyPlan(file, result.document);
2091
- return { file, foreignStopCount };
2092
- };
2093
- let installed;
2094
- try {
2095
- installed =
2096
- codexPaths === null
2097
- ? withTargetFileLock(settingsTarget, () => installInto(settingsTarget))
2098
- : withCodexLayerTransaction(codexPaths, (inspection) => installInto(inspection.writeTarget));
2099
- }
2100
- catch (err) {
2101
- deps.io.err(String(err));
2102
- return EXIT.failed;
2103
- }
2104
- if (harness === 'codex') {
2105
- const layer = flags.global ? null : codexLayerDir(deps.cwd);
2106
- if (layer !== null)
2107
- mkdirSync(layer, { recursive: true });
2108
- }
2109
- printHooksInstallClose(deps, harness, installed.file);
2110
- if (installed.foreignStopCount > 0) {
2111
- deps.io.out("This layer already has a Stop handler. Codex runs every matching handler, so Notifai's Stop and the existing one will both fire.");
2112
- }
2113
- if (harness === 'codex') {
2114
- for (const problem of codexRepresentationProblems(deps.cwd, deps.env, hookPlatform)) {
2115
- deps.io.out(problem);
2116
- }
2117
- for (const note of codexCoexistenceNotes(deps.cwd, deps.env, hookPlatform)) {
2118
- deps.io.out(note);
2119
- }
2120
- if (flags.global) {
2121
- const home = codexHomeNote(deps.env, hookPlatform);
2122
- if (home !== null)
2123
- deps.io.out(home);
2124
- }
2125
- }
2126
- return EXIT.ok;
2127
- }
2128
- function foreignStopHandlers(document) {
2129
- const groups = document.hooks?.['Stop'];
2130
- if (!Array.isArray(groups))
2131
- return [];
2132
- return groups
2133
- .flatMap((group) => group.hooks ?? [])
2134
- .filter((handler) => !/ hook (user-prompt-submit|stop|session-end)\b/.test(handler.command));
2135
- }
2136
- /**
2137
- * Writes the OpenCode plugin, replacing any Notifai plugin already there —
2138
- * including one a different checkout wrote, matched on the managed marker for
2139
- * the same reason command hooks are.
2140
- */
2141
- function installOpencodePlugin(deps, file, options) {
2142
- try {
2143
- withTargetFileLock(file, () => {
2144
- if (existsSync(file)) {
2145
- assertOwnedRegularFile(file);
2146
- const existing = readFileSync(file, 'utf8');
2147
- if (!isOurOpencodePlugin(existing)) {
2148
- throw new Error(`${file} exists and was not written by Notifai; move it aside first.`);
2149
- }
2150
- }
2151
- atomicWriteFileSync(file, opencodePluginSource(options), {
2152
- mode: 0o600,
2153
- preserveMode: false,
2154
- requireCurrentUserOwner: true,
2155
- });
2156
- });
2157
- }
2158
- catch (err) {
2159
- deps.io.err(String(err));
2160
- return EXIT.failed;
2161
- }
2162
- printHooksInstallClose(deps, 'opencode', file);
2163
- return EXIT.ok;
2164
- }
2165
- export function hooksUninstallCommand(deps, flags) {
2166
- const harness = resolveHarness(deps, flags.harness);
2167
- if (!harness)
2168
- return EXIT.usage;
2169
- const scriptPath = flags.scriptPath ?? process.argv[1] ?? 'notifai';
2170
- const global = flags.global ?? false;
2171
- const codexPaths = harness === 'codex'
2172
- ? codexLayerPaths(global, deps.cwd, deps.env, deps.hookPlatform)
2173
- : null;
2174
- const file = codexPaths?.configToml ?? settingsFile(harness, global, deps.cwd, deps.env, deps.hookPlatform);
2175
- if (harness === 'opencode') {
2176
- try {
2177
- return withTargetFileLock(file, () => {
2178
- if (!existsSync(file)) {
2179
- deps.io.out(`Nothing to remove: ${file} does not exist.`);
2180
- return EXIT.ok;
2181
- }
2182
- assertOwnedRegularFile(file);
2183
- // We own the whole file, but only if we wrote it.
2184
- if (!isOurOpencodePlugin(readFileSync(file, 'utf8'))) {
2185
- deps.io.out(`Left ${file} alone: Notifai did not write it.`);
2186
- return EXIT.ok;
2187
- }
2188
- rmSync(file, { force: true });
2189
- deps.io.out(`Removed the Notifai OpenCode plugin at ${file}`);
2190
- return EXIT.ok;
2191
- });
2192
- }
2193
- catch (err) {
2194
- deps.io.err(String(err));
2195
- return EXIT.failed;
2196
- }
2197
- }
2198
- if (harness === 'cursor') {
2199
- let stripped;
2200
- try {
2201
- stripped = withTargetFileLock(file, () => {
2202
- if (!existsSync(file))
2203
- return null;
2204
- const document = loadCursorSettings(file);
2205
- const result = removeCursorHooks(document, scriptPath);
2206
- applyPlan(file, result.document);
2207
- return result;
2208
- });
2209
- }
2210
- catch (err) {
2211
- deps.io.err(String(err));
2212
- return EXIT.failed;
2213
- }
2214
- if (stripped === null) {
2215
- deps.io.out(`Nothing to remove: ${file} does not exist.`);
2216
- return EXIT.ok;
2217
- }
2218
- deps.io.out(stripped.replaced.length > 0
2219
- ? `Removed Notifai hooks (${stripped.replaced.join(', ')}) from ${file}`
2220
- : `No Notifai hooks found in ${file}`);
2221
- return EXIT.ok;
2222
- }
2223
- const removeInstalledHooks = () => {
2224
- const files = codexPaths === null
2225
- ? hookDefinitionFiles(harness, global, deps.cwd, deps.env, deps.hookPlatform)
2226
- : [codexPaths.hooksJson, codexPaths.configToml];
2227
- const existing = files.filter((candidate) => existsSync(candidate));
2228
- let removedAny = false;
2229
- for (const candidate of existing) {
2230
- const removeFromCandidate = () => {
2231
- const document = loadSettings(candidate);
2232
- const result = removeHooks(document, scriptPath);
2233
- if (result.replaced.length > 0)
2234
- applyPlan(candidate, result.document);
2235
- return result;
2236
- };
2237
- const stripped = codexPaths === null
2238
- ? withTargetFileLock(candidate, removeFromCandidate)
2239
- : removeFromCandidate();
2240
- if (stripped.replaced.length > 0) {
2241
- removedAny = true;
2242
- deps.io.out(`Removed Notifai hooks (${stripped.replaced.join(', ')}) from ${candidate}`);
2243
- }
2244
- }
2245
- return { existing, removedAny };
2246
- };
2247
- let result;
2248
- try {
2249
- result =
2250
- codexPaths === null
2251
- ? removeInstalledHooks()
2252
- : withCodexLayerTransaction(codexPaths, removeInstalledHooks);
2253
- }
2254
- catch (err) {
2255
- deps.io.err(String(err));
2256
- return EXIT.failed;
2257
- }
2258
- if (result.existing.length === 0) {
2259
- deps.io.out(`Nothing to remove: ${file} does not exist.`);
2260
- return EXIT.ok;
2261
- }
2262
- if (!result.removedAny) {
2263
- deps.io.out(`No Notifai hooks found in ${result.existing.join(', ')}`);
2264
- }
2265
- return EXIT.ok;
2266
- }
2267
- function assertOwnedRegularFile(file) {
2268
- const stat = lstatSync(file);
2269
- if (stat.isSymbolicLink() || !stat.isFile()) {
2270
- throw new Error(`${file} is not a regular file; refusing to read or replace it.`);
2271
- }
2272
- const uid = typeof process.getuid === 'function' ? process.getuid() : undefined;
2273
- if (uid !== undefined && stat.uid !== uid) {
2274
- throw new Error(`${file} is owned by uid ${stat.uid}, not the current user.`);
2275
- }
2276
- }
2277
- function resolveHarness(deps, requested) {
2278
- if (requested !== undefined) {
2279
- if (HARNESSES.includes(requested))
2280
- return requested;
2281
- deps.io.err(`Unknown harness "${requested}". Supported: ${HARNESSES.join(', ')}.`);
2282
- return null;
2283
- }
2284
- const detected = detectHarness(deps.cwd, deps.env);
2285
- if (!detected) {
2286
- deps.io.err(`Could not tell which harness to install for — pass --harness <${HARNESSES.join('|')}>.`);
2287
- return null;
2288
- }
2289
- return detected;
2290
- }
2291
- /**
2292
- * Which harnesses to wire. An explicit `--harness` still wins as a singleton.
2293
- * Otherwise: every detected harness, or a human picker when detection is empty
2294
- * or names more than one.
2295
- */
2296
- async function pickHarnessesToInstall(deps, requested) {
2297
- if (requested !== undefined) {
2298
- const harness = resolveHarness(deps, requested);
2299
- return harness === null ? null : [harness];
2300
- }
2301
- const detected = detectedHarnesses(deps.cwd, deps.env);
2302
- if (detected.length === 1)
2303
- return detected;
2304
- if (deps.io.interactive === true && deps.io.multiselect) {
2305
- const picked = await deps.io.multiselect('Which agent harnesses should Notifai wire here?', HARNESSES.map((name) => ({
2306
- value: name,
2307
- label: HARNESS_LABELS[name],
2308
- ...(detected.includes(name) ? { hint: 'detected on this machine' } : {}),
2309
- })), detected);
2310
- if (picked === null)
2311
- return null;
2312
- const unknown = picked.filter((name) => !HARNESSES.includes(name));
2313
- if (unknown.length > 0) {
2314
- deps.io.err(`Unknown harness "${unknown[0]}". Supported: ${HARNESSES.join(', ')}.`);
2315
- return null;
2316
- }
2317
- return picked;
2318
- }
2319
- if (detected.length === 0) {
2320
- deps.io.err(`Could not tell which harness to wire. Run: notifai hooks install --harness <${HARNESSES.join('|')}>`);
2321
- return null;
2322
- }
2323
- return detected;
2324
- }
2325
- // ---------------------------------------------------------------------------
2326
- // config show / set / unset
2327
- // ---------------------------------------------------------------------------
2328
- export function configShowCommand(deps, flags) {
2329
- const config = loadLoggedConfig(deps, { cwd: deps.cwd, env: deps.env });
2330
- if (flags.json) {
2331
- const output = Object.fromEntries(CONFIG_KEYS.map((key) => [
2332
- key,
2333
- {
2334
- value: config[key].value,
2335
- source: config[key].source,
2336
- summary: configInfo(key).summary,
2337
- },
2338
- ]));
2339
- deps.io.out(JSON.stringify(output, null, 2));
2340
- return EXIT.ok;
2341
- }
2342
- // Anything that is not a person at a terminal keeps the flat `key = value`
2343
- // form it has always had. Scripts parse this, and a prettier layout for an
2344
- // audience that cannot see it would only be a breaking change.
2345
- if (deps.io.interactive !== true || flags.plain === true) {
2346
- for (const line of renderConfigPlain(config, flags.explain === true))
2347
- deps.io.out(line);
2348
- return EXIT.ok;
2349
- }
2350
- for (const line of renderConfigList(config, { showAdvanced: flags.explain === true })) {
2351
- deps.io.out(line);
2352
- }
2353
- return EXIT.ok;
2354
- }
2355
- /**
2356
- * One setting, explained in full.
2357
- *
2358
- * The gap this closes: `config show` prints a value and provenance but that is
2359
- * not enough to explain the setting's consequences. Every key already had a
2360
- * careful explanation — in a TypeScript comment, read by everyone except the
2361
- * person who needed it.
2362
- */
2363
- export function configExplainCommand(deps, key, flags = {}) {
2364
- if (!CONFIG_KEYS.includes(key)) {
2365
- deps.io.err(`Unknown setting "${key}".`);
2366
- deps.io.err(`Valid settings: ${CONFIG_KEYS.join(', ')}`);
2367
- return EXIT.usage;
2368
- }
2369
- const configKey = key;
2370
- const config = loadLoggedConfig(deps, { cwd: deps.cwd, env: deps.env });
2371
- const info = configInfo(configKey);
2372
- const entry = config[configKey];
2373
- if (flags.json) {
2374
- deps.io.out(JSON.stringify({
2375
- key: configKey,
2376
- label: info.label,
2377
- group: info.group,
2378
- kind: info.kind,
2379
- summary: info.summary,
2380
- detail: info.detail,
2381
- accepts: acceptedValues(configKey),
2382
- ...(info.choices !== undefined ? { choices: info.choices } : {}),
2383
- value: entry.value,
2384
- source: entry.source,
2385
- }, null, 2));
2386
- return EXIT.ok;
2387
- }
2388
- if (deps.io.interactive !== true) {
2389
- deps.io.out(`${configKey} = ${JSON.stringify(entry.value)} [${entry.source}]`);
2390
- deps.io.out(info.detail.replace(/\n\n/g, '\n'));
2391
- deps.io.out(`accepts: ${acceptedValues(configKey)}`);
2392
- return EXIT.ok;
2393
- }
2394
- for (const line of renderConfigExplain(configKey, config))
2395
- deps.io.out(line);
2396
- return EXIT.ok;
2397
- }
2398
- /**
2399
- * Closest config key by edit distance, or null when nothing is close.
2400
- *
2401
- * The threshold matters more than the algorithm: suggesting a key that shares
2402
- * three letters with the typo sends the reader to the wrong setting with
2403
- * confidence, which is worse than listing all fourteen and letting them look.
2404
- */
2405
- function nearestKey(input) {
2406
- let best = null;
2407
- let bestDistance = Number.POSITIVE_INFINITY;
2408
- for (const candidate of CONFIG_KEYS) {
2409
- const distance = editDistance(input.toLowerCase(), candidate);
2410
- if (distance < bestDistance) {
2411
- bestDistance = distance;
2412
- best = candidate;
2413
- }
2414
- }
2415
- return best !== null && bestDistance <= Math.max(2, Math.floor(best.length / 3)) ? best : null;
2416
- }
2417
- function editDistance(a, b) {
2418
- const previous = Array.from({ length: b.length + 1 }, (_, i) => i);
2419
- for (let i = 1; i <= a.length; i += 1) {
2420
- let diagonal = previous[0];
2421
- previous[0] = i;
2422
- for (let j = 1; j <= b.length; j += 1) {
2423
- const above = previous[j];
2424
- previous[j] = Math.min(previous[j] + 1, previous[j - 1] + 1, diagonal + (a[i - 1] === b[j - 1] ? 0 : 1));
2425
- diagonal = above;
2426
- }
2427
- }
2428
- return previous[b.length];
2429
- }
2430
- export async function configSetCommand(deps, key, rawValue, flags) {
2431
- const configKey = configMutationKey(deps, key);
2432
- if (configKey === null)
2433
- return EXIT.usage;
2434
- const info = configInfo(configKey);
2435
- let value = rawValue;
2436
- if (NUMERIC_CONFIG_KEYS.includes(configKey)) {
2437
- const numeric = Number(rawValue);
2438
- if (!Number.isInteger(numeric)) {
2439
- deps.io.err(`${key} takes ${acceptedValues(configKey)}, not "${rawValue}".`);
2440
- return EXIT.usage;
2441
- }
2442
- const bounds = configBounds(configKey);
2443
- if (bounds !== undefined && (numeric < bounds.min || numeric > bounds.max)) {
2444
- deps.io.err(`${key} must be between ${bounds.min} and ${bounds.max}.`);
2445
- return EXIT.usage;
2446
- }
2447
- value = numeric;
2448
- }
2449
- if (BOOLEAN_CONFIG_KEYS.includes(configKey)) {
2450
- if (rawValue !== 'true' && rawValue !== 'false') {
2451
- deps.io.err(`${key} is a toggle — pass "true" or "false", not "${rawValue}".`);
2452
- return EXIT.usage;
2453
- }
2454
- value = rawValue === 'true';
2455
- }
2456
- // Enum keys were accepted unchecked, so `config set sound whatever` wrote a
2457
- // value the sender would later reject — a typo that only surfaces at the
2458
- // moment a notification fails to carry the sound you asked for.
2459
- if (info.kind === 'enum' && info.choices !== undefined && !info.choices.includes(rawValue)) {
2460
- deps.io.err(`${key} takes one of: ${info.choices.join(', ')} — not "${rawValue}".`);
2461
- return EXIT.usage;
2462
- }
2463
- if (key === 'devices')
2464
- value = rawValue.split(',').map((s) => s.trim()).filter(Boolean);
2465
- const target = await configMutationTarget(deps, flags);
2466
- if (target === null)
2467
- return EXIT.usage;
2468
- if (target.layer === 'global' && Object.is(value, configDefaultValue(configKey))) {
2469
- deps.io.err(`${key} is already the shipped default (${JSON.stringify(value)}).`);
2470
- deps.io.err(`Run \`notifai config unset ${key} --yes\` to remove a redundant override instead of creating one.`);
2471
- return EXIT.usage;
2472
- }
2473
- if (!flags.yes) {
2474
- const confirmed = await deps.io.confirm(`Set ${key} = ${JSON.stringify(value)} in ${target.path}?`);
2475
- if (!confirmed) {
2476
- deps.io.err('Not confirmed. Pass --yes to skip the confirmation gate.');
2477
- return EXIT.usage;
2478
- }
2479
- }
2480
- const existing = existsSync(target.path)
2481
- ? parseToml(readFileSync(target.path, 'utf8'))
2482
- : {};
2483
- existing[key] = value;
2484
- mkdirSync(path.dirname(target.path), { recursive: true });
2485
- writeFileSync(target.path, `${stringifyToml(existing)}\n`);
2486
- deps.io.out(`Wrote ${key} to ${target.path}`);
2487
- return EXIT.ok;
2488
- }
2489
- function configMutationKey(deps, key) {
2490
- if (!CONFIG_KEYS.includes(key)) {
2491
- deps.io.err(`Unknown setting "${key}".`);
2492
- const near = nearestKey(key);
2493
- if (near !== null)
2494
- deps.io.err(`Did you mean "${near}"?`);
2495
- deps.io.err(`Valid settings: ${CONFIG_KEYS.join(', ')}`);
2496
- deps.io.err('Run `notifai config explain <key>` to see what one of them does.');
2497
- return null;
2498
- }
2499
- return key;
2500
- }
2501
- async function configMutationTarget(deps, flags) {
2502
- let layer = flags.local ? 'local' : flags.project ? 'project' : 'global';
2503
- if (flags.session === undefined &&
2504
- flags.local !== true &&
2505
- flags.project !== true &&
2506
- flags.yes !== true &&
2507
- deps.io.interactive === true &&
2508
- deps.io.select) {
2509
- const selected = await deps.io.select('Where should this setting live?', [
2510
- { value: 'global', label: 'This machine', hint: 'applies across projects' },
2511
- { value: 'project', label: 'This project (shared)', hint: '.notifai/config.toml' },
2512
- { value: 'local', label: 'This project (personal)', hint: 'stored on this machine, not in the repo' },
2513
- ]);
2514
- if (selected === null) {
2515
- deps.io.err('No configuration layer selected.');
2516
- return null;
2517
- }
2518
- layer = selected;
2519
- }
2520
- const targetPath = flags.session
2521
- ? sessionConfigPath(flags.session, deps.env)
2522
- : layer === 'local'
2523
- ? personalProjectConfigPath(deps.cwd, deps.env)
2524
- : layer === 'project'
2525
- ? (findProjectConfigPath(deps.cwd) ?? path.join(deps.cwd, '.notifai', 'config.toml'))
2526
- : globalConfigPath(deps.env);
2527
- return {
2528
- path: targetPath,
2529
- layer: flags.session ? 'session' : layer,
2530
- };
2531
- }
2532
- export async function configUnsetCommand(deps, key, flags) {
2533
- const configKey = configMutationKey(deps, key);
2534
- if (configKey === null)
2535
- return EXIT.usage;
2536
- const target = await configMutationTarget(deps, flags);
2537
- if (target === null)
2538
- return EXIT.usage;
2539
- const existing = existsSync(target.path)
2540
- ? parseToml(readFileSync(target.path, 'utf8'))
2541
- : {};
2542
- if (!Object.prototype.hasOwnProperty.call(existing, configKey)) {
2543
- deps.io.out(`${configKey} is not set in ${target.path}`);
2544
- return EXIT.ok;
2545
- }
2546
- if (!flags.yes) {
2547
- const confirmed = await deps.io.confirm(`Remove ${configKey} from ${target.path}?`);
2548
- if (!confirmed) {
2549
- deps.io.err('Not confirmed. Pass --yes to skip the confirmation gate.');
2550
- return EXIT.usage;
2551
- }
2552
- }
2553
- delete existing[configKey];
2554
- if (Object.keys(existing).length === 0) {
2555
- rmSync(target.path, { force: true });
2556
- }
2557
- else {
2558
- writeFileSync(target.path, `${stringifyToml(existing)}\n`);
2559
- }
2560
- deps.io.out(`Removed ${configKey} from ${target.path}; the inherited value now applies.`);
2561
- return EXIT.ok;
2562
- }
2563
- const LOG_RECORD_OPTIONS = [
2564
- ['limit', '--limit'],
2565
- ['all', '--all'],
2566
- ['since', '--since'],
2567
- ['level', '--level'],
2568
- ['event', '--event'],
2569
- ['run', '--run'],
2570
- ['request', '--request'],
2571
- ['session', '--session'],
2572
- ['project', '--project'],
2573
- ['allProjects', '--all-projects'],
2574
- ['grep', '--grep'],
2575
- ];
2576
- /**
2577
- * Deliberately small. The reader is usually a model with a finite context, and
2578
- * a log command whose default answer is ten thousand lines gets used once.
2579
- * Everything beyond this is reachable by asking for it.
2580
- */
2581
- const DEFAULT_LOG_LIMIT = 30;
2582
- /** Even `--all` stops somewhere; an unbounded dump is never the useful answer. */
2583
- const MAX_LOG_LIMIT = 2_000;
2584
- /** `10m`, `2h`, `1d`, or an ISO 8601 instant. */
2585
- export function parseSince(raw, now) {
2586
- const relative = /^(\d+)([smhd])$/.exec(raw.trim());
2587
- if (relative !== null) {
2588
- const amount = Number(relative[1]);
2589
- const unit = relative[2];
2590
- const seconds = { s: 1, m: 60, h: 3600, d: 86400 }[unit];
2591
- return now - amount * seconds * 1000;
2592
- }
2593
- const absolute = Date.parse(raw);
2594
- return Number.isNaN(absolute) ? null : absolute;
2595
- }
2596
- /**
2597
- * What this machine recorded about itself.
2598
- *
2599
- * The retrieval half of the local log, and the half that decides whether the
2600
- * log is worth having. Three things make it usable by an agent rather than
2601
- * merely available to one: the default is bounded and scoped to this project,
2602
- * the filters match the questions actually asked ("that run", "that
2603
- * notification", "just the failures"), and `--json` gives the machine the
2604
- * records untouched on stdout while every word of explanation goes to stderr.
2605
- */
2606
- export function logsCommand(deps, flags) {
2607
- const selectedRetrievalOptions = LOG_RECORD_OPTIONS
2608
- .filter(([key]) => {
2609
- const value = flags[key];
2610
- return Array.isArray(value) ? value.length > 0 : value !== undefined && value !== false;
2611
- })
2612
- .map(([, name]) => name);
2613
- if (flags.path === true && flags.clear === true) {
2614
- deps.io.err('Pass --path or --clear, not both.');
2615
- return EXIT.usage;
2616
- }
2617
- if (flags.path === true && selectedRetrievalOptions.length > 0) {
2618
- deps.io.err(`--path cannot be combined with record options: ${selectedRetrievalOptions.join(', ')}.`);
2619
- return EXIT.usage;
2620
- }
2621
- if (flags.clear === true && selectedRetrievalOptions.length > 0) {
2622
- deps.io.err(`--clear cannot be combined with record options: ${selectedRetrievalOptions.join(', ')}.`);
2623
- return EXIT.usage;
2624
- }
2625
- if (flags.project !== undefined && flags.allProjects === true) {
2626
- deps.io.err('Pass --project or --all-projects, not both.');
2627
- return EXIT.usage;
2628
- }
2629
- if (flags.limit !== undefined && flags.all === true) {
2630
- deps.io.err('Pass --limit or --all, not both.');
2631
- return EXIT.usage;
2632
- }
2633
- if (flags.limit !== undefined && (!Number.isInteger(flags.limit) || flags.limit <= 0)) {
2634
- deps.io.err('--limit must be a positive integer.');
2635
- return EXIT.usage;
2636
- }
2637
- if (flags.limit !== undefined && flags.limit > MAX_LOG_LIMIT) {
2638
- deps.io.err(`--limit cannot exceed ${MAX_LOG_LIMIT}; narrow the result with --since or filters.`);
2639
- return EXIT.usage;
2640
- }
2641
- const config = loadLoggedConfig(deps, { cwd: deps.cwd, env: deps.env });
2642
- const now = (deps.now ?? Date.now)();
2643
- if (flags.path === true) {
2644
- const usage = logsDiskUsage(deps.env);
2645
- const archives = archiveLogPaths(deps.env);
2646
- if (flags.json === true) {
2647
- deps.io.out(JSON.stringify({
2648
- active: activeLogPath(deps.env),
2649
- archives,
2650
- files: usage.files,
2651
- bytes: usage.bytes,
2652
- level: config.log_level.value,
2653
- max_bytes: config.log_max_bytes.value,
2654
- max_files: config.log_max_files.value,
2655
- local_only: true,
2656
- }, null, 2));
2657
- return EXIT.ok;
2658
- }
2659
- deps.io.out(activeLogPath(deps.env));
2660
- for (const archive of archives)
2661
- deps.io.out(archive);
2662
- deps.io.err(`${usage.files} file(s), ${Math.round(usage.bytes / 1024)} KB, level ${config.log_level.value}. These stay on this machine.`);
2663
- return EXIT.ok;
2664
- }
2665
- if (flags.clear === true) {
2666
- let removed = 0;
2667
- for (const file of [activeLogPath(deps.env), ...archiveLogPaths(deps.env)]) {
2668
- try {
2669
- unlinkSync(file);
2670
- removed += 1;
2671
- }
2672
- catch {
2673
- // Absent, or not ours to remove; either way there is nothing to report.
2674
- }
2675
- }
2676
- deps.io.out(flags.json === true
2677
- ? JSON.stringify({ cleared_files: removed })
2678
- : `Cleared ${removed} log file${removed === 1 ? '' : 's'}.`);
2679
- return EXIT.ok;
2680
- }
2681
- const query = {
2682
- limit: flags.limit ?? (flags.all === true ? MAX_LOG_LIMIT : DEFAULT_LOG_LIMIT),
2683
- };
2684
- if (flags.since !== undefined) {
2685
- const since = parseSince(flags.since, now);
2686
- if (since === null) {
2687
- deps.io.err(`Could not read "${flags.since}" as a time. Use 10m, 2h, 1d, or an ISO 8601 instant.`);
2688
- return EXIT.usage;
2689
- }
2690
- query.since = since;
2691
- }
2692
- if (flags.level !== undefined) {
2693
- if (!RECORD_LEVELS.includes(flags.level)) {
2694
- deps.io.err(`--level takes one of: ${RECORD_LEVELS.join(', ')} — not "${flags.level}".`);
2695
- return EXIT.usage;
2696
- }
2697
- query.level = flags.level;
2698
- }
2699
- if (flags.event !== undefined && flags.event.length > 0) {
2700
- const unknown = flags.event.filter((name) => !LOG_EVENTS.includes(name));
2701
- if (unknown.length > 0) {
2702
- deps.io.err(`Unknown event ${unknown.join(', ')}. Valid: ${LOG_EVENTS.join(', ')}`);
2703
- return EXIT.usage;
2704
- }
2705
- query.event = flags.event;
2706
- }
2707
- if (flags.run !== undefined)
2708
- query.run = flags.run;
2709
- if (flags.session !== undefined)
2710
- query.session = flags.session;
2711
- if (flags.request !== undefined)
2712
- query.request = flags.request;
2713
- if (flags.grep !== undefined)
2714
- query.contains = flags.grep;
2715
- // Scoped to this project by default. A machine commonly runs several agents
2716
- // in several worktrees at once, and the answer to "what just happened" is
2717
- // useless if three other projects are interleaved through it.
2718
- const scope = flags.project ?? (flags.allProjects === true ? undefined : (config.project.value ?? undefined));
2719
- if (scope !== undefined)
2720
- query.project = scope;
2721
- const result = readLogRecords(deps.env, query);
2722
- if (flags.json === true) {
2723
- for (const record of result.records)
2724
- deps.io.out(JSON.stringify(record));
2725
- }
2726
- else {
2727
- for (const record of result.records)
2728
- deps.io.out(renderRecord(record));
2729
- }
2730
- if (result.records.length === 0) {
2731
- if (config.log_level.value === 'off') {
2732
- deps.io.err('Nothing is being recorded: log_level is off.');
2733
- deps.io.err('Turn it on with `notifai config set log_level info --yes`.');
2734
- }
2735
- else if (logsDiskUsage(deps.env).files === 0) {
2736
- deps.io.err('No log yet. It is written the next time a notifai command or harness hook runs.');
2737
- }
2738
- else {
2739
- deps.io.err('No records matched. Widen it with --all-projects, --since 1d, or --all.');
2740
- }
2741
- return EXIT.ok;
2742
- }
2743
- // Everything explanatory goes to stderr so `--json` leaves stdout as clean
2744
- // JSONL for whatever is parsing it.
2745
- const notes = [];
2746
- if (scope !== undefined)
2747
- notes.push(`project ${scope} (--all-projects for every project)`);
2748
- if (result.more) {
2749
- notes.push(query.limit < MAX_LOG_LIMIT
2750
- ? `more history behind this (-n ${Math.min(query.limit * 4, MAX_LOG_LIMIT)} or --all)`
2751
- : `more history exists beyond the ${MAX_LOG_LIMIT}-record safety cap; narrow with --since or filters`);
2752
- }
2753
- if (config.log_level.value !== 'debug')
2754
- notes.push('`log_level = debug` records request detail');
2755
- if (notes.length > 0)
2756
- deps.io.err(`— ${result.records.length} records; ${notes.join('; ')}`);
2757
- return EXIT.ok;
2758
- }
2759
- // ---------------------------------------------------------------------------
2760
- // init
2761
- // ---------------------------------------------------------------------------
2762
- /**
2763
- * Where `npx skills add` fetches the optional agent skill from, derived from
2764
- * this build's own version so the pin cannot drift from the release it names.
2765
- * Null when the build cannot establish its version; see `./release.js`.
2766
- */
2767
- export const SKILLS_SOURCE = skillsSource();
2768
- /**
2769
- * How to refer to the pin in user-facing text.
2770
- *
2771
- * Only reached in a corrupted install, where naming the tag is impossible but
2772
- * saying nothing would be worse — the sentence still has to read as English.
2773
- */
2774
- const SKILLS_SOURCE_LABEL = SKILLS_SOURCE ?? 'the public release tag matching this CLI';
2775
- function skillSourceParts() {
2776
- if (SKILLS_SOURCE === null)
2777
- return null;
2778
- const match = /^([^#]+)#(.+)$/.exec(SKILLS_SOURCE);
2779
- return match === null ? null : { source: match[1], ref: match[2] };
2780
- }
2781
- function expectedSkill(skill) {
2782
- const expected = skillSourceParts();
2783
- return (expected !== null &&
2784
- skill.name === 'notifai' &&
2785
- skill.source === expected.source &&
2786
- skill.sourceType === 'github' &&
2787
- skill.ref === expected.ref);
2788
- }
2789
- async function skillReadiness(deps, selectedScope) {
2790
- const scopes = selectedScope === undefined ? ['project', 'global'] : [selectedScope];
2791
- const results = await Promise.all(scopes.map(async (scope) => {
2792
- if (deps.nativeSkills === undefined)
2793
- return { scope, skills: [] };
2794
- try {
2795
- return { scope, ...(await deps.nativeSkills.list(scope, deps.cwd, deps.env)) };
2796
- }
2797
- catch (err) {
2798
- return { scope, skills: [], error: String(err) };
2799
- }
2800
- }));
2801
- const installed = results.flatMap(({ skills }) => skills).find(expectedSkill);
2802
- if (installed !== undefined) {
2803
- return {
2804
- id: 'skill',
2805
- title: 'Agent guidance skill',
2806
- status: 'ready',
2807
- detail: `installed from ${SKILLS_SOURCE_LABEL} in the ${installed.scope} scope`,
2808
- };
2809
- }
2810
- const errors = results
2811
- .filter((result) => result.error !== undefined)
2812
- .map((result) => `${result.scope}: ${result.error}`);
2813
- const scopeText = selectedScope === undefined ? 'project or machine-global scope' : `${selectedScope} scope`;
2814
- return {
2815
- id: 'skill',
2816
- title: 'Agent guidance skill',
2817
- status: 'optional-gap',
2818
- detail: errors.length > 0
2819
- ? `could not verify installer-managed state in ${scopeText} (${errors.join('; ')})`
2820
- : `not installed from ${SKILLS_SOURCE_LABEL} in ${scopeText}`,
2821
- remedy: {
2822
- by: 'cli',
2823
- summary: 'install the skill agents follow when deciding to notify',
2824
- command: selectedScope === undefined
2825
- ? 'notifai init --skills'
2826
- : `notifai init --skills --skills-scope ${selectedScope}`,
2827
- },
2828
- };
2829
- }
2830
- /** Derive a contract-valid project slug; init alone needs a non-empty fallback. */
2831
- export function projectSlugFrom(name) {
2832
- return inferredProjectSlugFrom(name) ?? 'project';
2833
- }
2834
- /** Long enough for a first TestFlight install; keep-waiting extends another budget. */
2835
- const DEVICE_BRIDGE_TIMEOUT_MS = 10 * 60 * 1000;
2836
- const DEVICE_BRIDGE_POLL_MS = 2_000;
2837
- const PROOF_TIMEOUT_MS = 30_000;
2838
- const PROOF_POLL_MS = 1_000;
2839
- function supportPageUrl(baseUrl) {
2840
- return `${baseUrl.replace(/\/$/, '')}/support`;
2841
- }
2842
- function formatWaitBudget(milliseconds) {
2843
- const minutes = Math.round(milliseconds / 60_000);
2844
- return minutes === 1 ? '1 minute' : `${minutes} minutes`;
2845
- }
2846
- /** Same-account line for the device hop; prefers the real email when known. */
2847
- function sameAccountSignInLine(email) {
2848
- return email
2849
- ? `sign in with the same email as this account (${email})`
2850
- : 'sign in with the same email as this account';
2851
- }
2852
- function deviceInstallRemedy(options) {
2853
- const support = supportPageUrl(options.baseUrl);
2854
- const sameEmail = sameAccountSignInLine(options.email);
2855
- if (options.devices.length === 0) {
2856
- return (`open the install steps at ${support} on that device (or your phone’s browser), ` +
2857
- `install Notifai, ${sameEmail}, and allow notifications`);
2858
- }
2859
- if (options.devices.some((d) => d.permission_status === 'denied')) {
2860
- return 'allow notifications for Notifai in that device’s system settings';
2861
- }
2862
- return 'open Notifai on that device and allow its notification prompt';
2863
- }
2864
- function setupProofPath(deps) {
2865
- let projectDir = path.resolve(deps.cwd);
2866
- try {
2867
- projectDir = realpathSync(projectDir);
2868
- }
2869
- catch {
2870
- // A deleted or not-yet-created cwd cannot collide with a real directory:
2871
- // the resolved absolute path is still a stable local identity for it.
2872
- }
2873
- const digest = createHash('sha256').update(projectDir).digest('hex').slice(0, 32);
2874
- return path.join(stateDir(deps.env), 'setup-proofs', `${digest}.json`);
2875
- }
2876
- function readSetupProof(deps) {
2877
- const file = setupProofPath(deps);
2878
- if (!existsSync(file))
2879
- return null;
2880
- try {
2881
- const parsed = JSON.parse(readFileSync(file, 'utf8'));
2882
- return typeof parsed.request_id === 'string' &&
2883
- typeof parsed.device_id === 'string' &&
2884
- (typeof parsed.project === 'string' || parsed.project === null) &&
2885
- typeof parsed.started_at === 'string'
2886
- ? parsed
2887
- : null;
2888
- }
2889
- catch {
2890
- // Corrupt local evidence is not readiness. A fresh proof replaces it.
2891
- return null;
2892
- }
2893
- }
2894
- function writeSetupProof(deps, proof) {
2895
- const file = setupProofPath(deps);
2896
- try {
2897
- mkdirSync(path.dirname(file), { recursive: true });
2898
- writeFileSync(file, `${JSON.stringify(proof, null, 2)}\n`, { mode: 0o600 });
2899
- return true;
2900
- }
2901
- catch (err) {
2902
- deps.io.err(`Could not save setup proof ${proof.request_id} at ${file}: ${String(err)}`);
2903
- return false;
2904
- }
2905
- }
2906
- function observedCompanionReceipt(snapshot, deviceId) {
2907
- const delivery = snapshot.deliveries.find((candidate) => candidate.device_id === deviceId);
2908
- const receipt = delivery?.events.find((event) => event.stage === 'companion_received');
2909
- return delivery && receipt ? { delivery, observedAt: receipt.occurred_at } : null;
2910
- }
2911
- async function closeGap(deps, state, flags) {
2912
- if (state.id === 'project') {
2913
- const configPath = path.join(deps.cwd, '.notifai', 'config.toml');
2914
- const existing = existsSync(configPath)
2915
- ? parseToml(readFileSync(configPath, 'utf8'))
2916
- : {};
2917
- existing['project'] = projectSlugFrom(flags.projectId ?? path.basename(deps.cwd));
2918
- mkdirSync(path.dirname(configPath), { recursive: true });
2919
- writeFileSync(configPath, `${stringifyToml(existing)}\n`);
2920
- return 'closed';
2921
- }
2922
- if (state.id === 'hooks') {
2923
- const harnesses = await pickHarnessesToInstall(deps);
2924
- if (harnesses === null || harnesses.length === 0)
2925
- return 'failed';
2926
- let ok = true;
2927
- for (const harness of harnesses) {
2928
- if (hooksInstallCommand(deps, { harness }) !== EXIT.ok)
2929
- ok = false;
2930
- }
2931
- return ok ? 'closed' : 'failed';
2932
- }
2933
- if (state.id === 'skill') {
2934
- if (deps.nativeSkills === undefined) {
2935
- deps.io.err('Skill installation failed — the native `npx skills` flow is unavailable.');
2936
- return 'failed';
2937
- }
2938
- // Refuse rather than reach for a mutable ref: installing the skill from a
2939
- // moving branch is the one outcome the pin exists to prevent.
2940
- if (SKILLS_SOURCE === null) {
2941
- deps.io.err('Skill installation failed — this build cannot determine its own version, so there is no release tag to install from.');
2942
- return 'failed';
2943
- }
2944
- const scopeText = flags.skillsScope === undefined ? 'the scope you choose' : `${flags.skillsScope} scope`;
2945
- deps.io.out(`Starting the native npx skills setup for the notifai agent skill (${scopeText})...`);
2946
- const addOptions = {
2947
- source: SKILLS_SOURCE,
2948
- skill: 'notifai',
2949
- cwd: deps.cwd,
2950
- env: deps.env,
2951
- ...(flags.skillsScope === undefined ? {} : { scope: flags.skillsScope }),
2952
- };
2953
- const code = await deps.nativeSkills.add(addOptions);
2954
- if (code !== 0) {
2955
- deps.io.err('Skill installation failed — run it manually with:');
2956
- deps.io.err(` npx skills add ${SKILLS_SOURCE} --skill notifai${flags.skillsScope === 'global' ? ' --global' : ''}${flags.skillsScope === undefined ? '' : ' --yes'}`);
2957
- }
2958
- return code === 0 ? 'closed' : 'failed';
2959
- }
2960
- if (state.id === 'proof')
2961
- return await runSetupProof(deps);
2962
- return 'failed';
2963
- }
2964
- function deviceCanReceive(device) {
2965
- return (device.registration_healthy &&
2966
- (device.permission_status === 'authorized' || device.permission_status === 'provisional'));
2967
- }
2968
- function readyIosDevices(devices) {
2969
- return devices.filter((device) => device.platform === 'ios' && deviceCanReceive(device));
2970
- }
2971
- function deviceBridgeMessage(devices) {
2972
- if (devices.length === 0) {
2973
- return 'Waiting for the companion app to sign in and register…';
2974
- }
2975
- const denied = devices.find((device) => device.permission_status === 'denied');
2976
- if (denied)
2977
- return `Waiting for notifications to be allowed on ${denied.display_name}…`;
2978
- const undecided = devices.find((device) => device.permission_status === 'not_determined');
2979
- if (undecided)
2980
- return `Waiting for ${undecided.display_name} to allow the notification prompt…`;
2981
- return 'Waiting for a companion device to become ready…';
2982
- }
2983
- /**
2984
- * Observe the supported Device Installation path while the user finishes the
2985
- * app-side work. The live bridge is the dashboard `/support` page (TestFlight
2986
- * steps). Interactive runs offer to open it so the user never types the URL;
2987
- * non-interactive/agent paths only print plain text and never wait on a prompt.
2988
- *
2989
- * The wait budget is stated up front. On expiry we say the *timer* expired —
2990
- * not the setup — and offer another budget when a human is present. Agents
2991
- * never hang: this whole path is gated on `io.interactive`, and keep-waiting
2992
- * uses `confirm` which resolves the safe default when not interactive.
2993
- */
2994
- async function waitForReadyDevice(deps, state) {
2995
- const remedy = state.remedy;
2996
- if (deps.io.interactive !== true || remedy?.by !== 'user-elsewhere')
2997
- return 'pending';
2998
- const config = loadLoggedConfig(deps, { cwd: deps.cwd, env: deps.env });
2999
- const authed = authedClient(deps, config);
3000
- if (!authed) {
3001
- deps.io.err('Could not start the companion-device wait: this machine is not signed in.');
3002
- return 'failed';
3003
- }
3004
- const budgetLabel = formatWaitBudget(DEVICE_BRIDGE_TIMEOUT_MS);
3005
- const supportUrl = supportPageUrl(authed.baseUrl);
3006
- await deps.io.note?.([
3007
- state.detail,
3008
- remedy.summary,
3009
- `Install steps (no typing): ${supportUrl}`,
3010
- `I will wait up to ${budgetLabel} for a companion device to become ready.`,
3011
- ].join('\n'), 'Finish setup on your companion device');
3012
- // Open the real support page so the user never has to type the URL. Decline
3013
- // is fine — the URL remains in the note and in the Next: line if they leave.
3014
- if (await deps.io.confirm('Open install instructions in your browser?', true)) {
3015
- deps.io.openUrl(supportUrl);
3016
- }
3017
- if (!(await deps.io.confirm('Wait here while you finish that on your device?', true))) {
3018
- deps.io.out(`OK — finish device setup when you can (install steps: ${supportUrl}), then re-run \`notifai init\`.`);
3019
- return 'pending';
3020
- }
3021
- const now = deps.now ?? Date.now;
3022
- const sleep = deps.sleep ??
3023
- ((milliseconds) => new Promise((resolve) => setTimeout(resolve, milliseconds)));
3024
- const spinner = await deps.io.spinner?.(`Waiting up to ${budgetLabel} for a companion device…`);
3025
- let lastDevices = [];
3026
- let deadline = now() + DEVICE_BRIDGE_TIMEOUT_MS;
3027
- for (;;) {
3028
- while (now() < deadline) {
3029
- try {
3030
- const response = await authed.client.listDevices();
3031
- lastDevices = response.devices;
3032
- const ready = response.devices.find(deviceCanReceive);
3033
- if (ready) {
3034
- spinner?.stop(`${ready.display_name} is ready to receive`);
3035
- return 'closed';
3036
- }
3037
- spinner?.message(deviceBridgeMessage(response.devices));
3038
- }
3039
- catch (err) {
3040
- if (!(err instanceof NetworkError)) {
3041
- spinner?.error('Could not check companion readiness');
3042
- reportError(deps, err);
3043
- return 'failed';
3044
- }
3045
- spinner?.message('Connection lost — still watching…');
3046
- }
3047
- await sleep(Math.min(DEVICE_BRIDGE_POLL_MS, Math.max(0, deadline - now())));
3048
- }
3049
- // Timer expired — setup did not. Offer another budget only when a human
3050
- // can answer; confirm() already returns the fallback for agents, so this
3051
- // never hangs unattended even if interactive were mis-set.
3052
- spinner?.error(`The ${budgetLabel} wait timer expired`);
3053
- deps.io.err(`The ${budgetLabel} wait timer expired — setup is not finished, only this wait.`);
3054
- deps.io.err(deviceBridgeMessage(lastDevices).replace(/…$/, '.'));
3055
- deps.io.err(`Re-run \`notifai init\` later and it will pick up from here (install steps: ${supportUrl}).`);
3056
- const keepWaiting = await deps.io.confirm(`Keep waiting for another ${budgetLabel}?`, false);
3057
- if (!keepWaiting) {
3058
- deps.io.out('Stopping the wait. Device setup can continue; re-run `notifai init` when ready.');
3059
- return 'pending';
3060
- }
3061
- spinner?.message(`Waiting another ${budgetLabel} for a companion device…`);
3062
- deadline = now() + DEVICE_BRIDGE_TIMEOUT_MS;
3063
- }
3064
- }
3065
- function setupProofDraft(config, device) {
3066
- const project = config.project.value;
3067
- return buildDraft(config, {
3068
- title: 'Notifai is ready',
3069
- body: project === null
3070
- ? 'This real notification completed setup verification.'
3071
- : `This real notification completed setup verification for ${project}.`,
3072
- event: 'setup_verified',
3073
- kind: 'update',
3074
- platform: 'ios',
3075
- device: [device.device_id],
3076
- sound: 'none',
3077
- level: 'passive',
3078
- collapseKey: 'notifai-setup-verification',
3079
- });
3080
- }
3081
- async function submitSetupProof(deps, client, config, device) {
3082
- const build = setupProofDraft(config, device);
3083
- if (!build.ok) {
3084
- deps.io.err(`Could not build the setup verification notification: ${build.error}`);
3085
- return null;
3086
- }
3087
- const capabilities = CAPABILITIES_V1.describe(build.platform);
3088
- if (!capabilities) {
3089
- deps.io.err(`No capability contract is available for ${build.platform}.`);
3090
- return null;
3091
- }
3092
- const validation = validateDraft(build.draft, capabilities);
3093
- if (!validation.ok) {
3094
- for (const issue of validation.errors) {
3095
- deps.io.err(`Setup verification ${issue.path}: ${issue.message}`);
3096
- }
3097
- return null;
3098
- }
3099
- try {
3100
- return await client.submit({
3101
- idempotency_key: `init-${randomBytes(12).toString('base64url')}`,
3102
- draft: build.draft,
3103
- }, config.wait_seconds.value);
3104
- }
3105
- catch (err) {
3106
- reportError(deps, err);
3107
- return null;
3108
- }
3109
- }
3110
- /**
3111
- * Send or resume one real setup probe, then wait for a Companion Receipt.
3112
- * Provider Acceptance is intentionally insufficient: it proves APNs accepted
3113
- * the push, not that a companion process received it.
3114
- */
3115
- async function runSetupProof(deps) {
3116
- const config = loadLoggedConfig(deps, { cwd: deps.cwd, env: deps.env });
3117
- const authed = authedClient(deps, config);
3118
- if (!authed)
3119
- return 'failed';
3120
- let devices;
3121
- try {
3122
- devices = (await authed.client.listDevices()).devices;
3123
- }
3124
- catch (err) {
3125
- reportError(deps, err);
3126
- return 'failed';
3127
- }
3128
- const candidates = readyIosDevices(devices);
3129
- const existing = readSetupProof(deps);
3130
- const target = candidates.find((device) => device.device_id === existing?.device_id && existing.project === config.project.value) ?? candidates[0];
3131
- if (!target) {
3132
- deps.io.err("Setup proof needs a receipt-capable iPhone. The current macOS notification path does not emit Companion Receipts (the app's delivery confirmation).");
3133
- return 'pending';
3134
- }
3135
- let proof = existing?.device_id === target.device_id && existing.project === config.project.value
3136
- ? existing
3137
- : null;
3138
- if (proof === null) {
3139
- const receipt = await submitSetupProof(deps, authed.client, config, target);
3140
- if (receipt === null)
3141
- return 'failed';
3142
- if (receipt.overall === 'provider_rejected_all') {
3143
- deps.io.err(formatReceipt(receipt));
3144
- return 'failed';
3145
- }
3146
- proof = {
3147
- request_id: receipt.request_id,
3148
- device_id: target.device_id,
3149
- project: config.project.value,
3150
- started_at: new Date((deps.now ?? Date.now)()).toISOString(),
3151
- };
3152
- if (!writeSetupProof(deps, proof))
3153
- return 'failed';
3154
- deps.io.out(`Verification notification sent to ${target.display_name} (${proof.request_id}).`);
3155
- }
3156
- else {
3157
- deps.io.out(`Checking verification notification ${proof.request_id} again.`);
3158
- }
3159
- const now = deps.now ?? Date.now;
3160
- const sleep = deps.sleep ?? ((milliseconds) => new Promise((resolve) => setTimeout(resolve, milliseconds)));
3161
- const deadline = now() + PROOF_TIMEOUT_MS;
3162
- const spinner = deps.io.interactive === true
3163
- ? await deps.io.spinner?.("Waiting for a Companion Receipt (the app's delivery confirmation)…")
3164
- : null;
3165
- let lastError = null;
3166
- let replacedMissingProof = false;
3167
- for (;;) {
3168
- try {
3169
- const snapshot = await authed.client.evidence(proof.request_id);
3170
- const observed = observedCompanionReceipt(snapshot, proof.device_id);
3171
- if (observed) {
3172
- spinner?.stop(`Receipt observed from ${observed.delivery.device_name}`);
3173
- deps.io.out(`Companion Receipt (the app's delivery confirmation) observed from ${observed.delivery.device_name}.`);
3174
- return 'closed';
3175
- }
3176
- lastError = null;
3177
- }
3178
- catch (err) {
3179
- lastError = err;
3180
- if (err instanceof ApiCallError &&
3181
- err.code === 'not_found' &&
3182
- !replacedMissingProof) {
3183
- const receipt = await submitSetupProof(deps, authed.client, config, target);
3184
- if (receipt === null)
3185
- return 'failed';
3186
- if (receipt.overall === 'provider_rejected_all') {
3187
- deps.io.err(formatReceipt(receipt));
3188
- return 'failed';
3189
- }
3190
- proof = {
3191
- request_id: receipt.request_id,
3192
- device_id: target.device_id,
3193
- project: config.project.value,
3194
- started_at: new Date(now()).toISOString(),
3195
- };
3196
- if (!writeSetupProof(deps, proof))
3197
- return 'failed';
3198
- replacedMissingProof = true;
3199
- lastError = null;
3200
- deps.io.out(`The saved proof had expired; sent replacement ${proof.request_id}.`);
3201
- continue;
3202
- }
3203
- if (!(err instanceof NetworkError)) {
3204
- spinner?.error('Could not read Companion Receipt evidence');
3205
- reportError(deps, err);
3206
- return 'failed';
3207
- }
3208
- spinner?.message('Connection lost — still checking the same request…');
3209
- }
3210
- if (now() >= deadline)
3211
- break;
3212
- await sleep(Math.min(PROOF_POLL_MS, Math.max(0, deadline - now())));
3213
- }
3214
- spinner?.stop('Delivery confirmation not observed yet');
3215
- if (lastError instanceof NetworkError)
3216
- deps.io.err(lastError.message);
3217
- deps.io.out(`Provider accepted the notification; Companion Receipt (the app's delivery confirmation) for ${proof.request_id} was not observed within ${PROOF_TIMEOUT_MS / 1000}s. ` +
3218
- 'Proof may still arrive — re-run `notifai init` and it will re-check this same notification.');
3219
- return 'pending';
3220
- }
3221
- /** Closing a local gap cannot have changed the service, the keychain, or devices. */
3222
- function refreshAfterClose(id) {
3223
- return id === 'project' || id === 'hooks' || id === 'skill' || id === 'question-routing-settings'
3224
- ? ['local']
3225
- : undefined;
3226
- }
3227
- /** Whether an optional gap should be closed, given flags and who is watching. */
3228
- function wantsOptional(deps, state, flags) {
3229
- // Naming the project is init's whole reason to touch the filesystem, costs
3230
- // nothing, and is undone by editing one line — so it is done rather than
3231
- // asked about, for a human and an agent alike.
3232
- if (state.id === 'project')
3233
- return Promise.resolve(true);
3234
- const explicit = state.id === 'hooks' ? flags.hooks : state.id === 'skill' ? flags.skills : undefined;
3235
- if (explicit !== undefined)
3236
- return Promise.resolve(explicit);
3237
- // An agent is never asked, and never assumed into a change it did not
3238
- // request: silence means no, and the summary says what was skipped.
3239
- if (deps.io.interactive !== true)
3240
- return Promise.resolve(false);
3241
- const question = state.id === 'hooks'
3242
- ? 'Install harness hooks, so questions reach your devices when you are away?'
3243
- : 'Install/update the agent guidance skill through the native npx skills flow?';
3244
- return deps.io.confirm(question, true);
3245
- }
3246
- /**
3247
- * Setup as one step at a time.
3248
- *
3249
- * The old version ran five steps in a fixed order and ended with a list of
3250
- * everything still outstanding. That is a report, and a report is the wrong
3251
- * output here: someone handed five things to do does none of them, and the
3252
- * order was the script's rather than the dependency graph's — it offered to
3253
- * install hooks after a sign-in that had just failed.
3254
- *
3255
- * So this closes what it can, then surfaces exactly one thing, the first that
3256
- * stands in the way. Re-running advances by one. Idempotence stops being a
3257
- * property to preserve and becomes the mechanism: every decision is derived
3258
- * from observed state, so a partial run, a second project, a fresh worktree
3259
- * and a revoked credential are the same code path arriving at different
3260
- * states rather than four branches to enumerate.
3261
- */
3262
- export async function initCommand(deps, flags) {
3263
- if (flags.skillsScope !== undefined &&
3264
- flags.skillsScope !== 'project' &&
3265
- flags.skillsScope !== 'global') {
3266
- deps.io.err('Invalid skill scope. Choose `project` or `global`.');
3267
- return EXIT.usage;
3268
- }
3269
- if (flags.skillsScope !== undefined && flags.skills !== true) {
3270
- deps.io.err('`--skills-scope` requires `--skills`. Choose project or global in the native installer.');
3271
- return EXIT.usage;
3272
- }
3273
- if (flags.skills === true &&
3274
- deps.io.interactive !== true &&
3275
- flags.skillsScope === undefined) {
3276
- deps.io.err('Unattended skill setup requires an explicit scope: `notifai init --skills --skills-scope project` or `... global`.');
3277
- return EXIT.usage;
3278
- }
3279
- await deps.io.intro?.('Notifai setup');
3280
- const skillOpts = flags.skillsScope === undefined ? {} : { skillScope: flags.skillsScope };
3281
- let readiness = await assessReadiness(deps, skillOpts);
3282
- const reassess = (refresh) => assessReadiness(deps, {
3283
- ...skillOpts,
3284
- ...(refresh === undefined ? {} : { previous: readiness, refresh }),
3285
- });
3286
- let failed = false;
3287
- const attempted = new Set();
3288
- // Re-assess after every successful action. This is how a browser approval or
3289
- // companion registration can unlock the next state while the user is still
3290
- // here, without copying the dependency graph into a second setup script.
3291
- for (;;) {
3292
- let advanced = false;
3293
- let stop = false;
3294
- for (const state of readiness.states) {
3295
- if (state.status === 'ready')
3296
- continue;
3297
- if (state.status === 'unknown') {
3298
- stop = true;
3299
- break;
3300
- }
3301
- const remedy = state.remedy;
3302
- if (remedy === undefined || attempted.has(state.id)) {
3303
- if (state.status === 'gap')
3304
- stop = true;
3305
- if (stop)
3306
- break;
3307
- continue;
3308
- }
3309
- if (state.status === 'optional-gap') {
3310
- if (remedy.by !== 'cli' || !(await wantsOptional(deps, state, flags)))
3311
- continue;
3312
- attempted.add(state.id);
3313
- const result = await closeGap(deps, state, flags);
3314
- if (result === 'failed')
3315
- failed = true;
3316
- if (result === 'failed' && state.status === 'optional-gap') {
3317
- readiness = await reassess(refreshAfterClose(state.id));
3318
- advanced = true;
3319
- break;
3320
- }
3321
- if (result !== 'closed') {
3322
- stop = true;
3323
- break;
3324
- }
3325
- readiness = await reassess(refreshAfterClose(state.id));
3326
- advanced = true;
3327
- break;
3328
- }
3329
- if (remedy.by === 'cli') {
3330
- attempted.add(state.id);
3331
- const result = await closeGap(deps, state, flags);
3332
- if (result === 'failed')
3333
- failed = true;
3334
- if (result !== 'closed') {
3335
- stop = true;
3336
- break;
3337
- }
3338
- readiness = await reassess(refreshAfterClose(state.id));
3339
- advanced = true;
3340
- break;
3341
- }
3342
- // Its to launch, theirs to complete. Running `init` is the consent; announce
3343
- // and open rather than re-asking. An agent never reaches this path.
3344
- if (remedy.by === 'user-here' &&
3345
- remedy.interactive === true &&
3346
- deps.io.interactive === true) {
3347
- attempted.add(state.id);
3348
- deps.io.out('Opening your browser to approve this machine — Ctrl-C to stop.');
3349
- if ((await loginCommand(deps, {})) !== EXIT.ok) {
3350
- failed = true;
3351
- stop = true;
3352
- break;
3353
- }
3354
- readiness = await reassess();
3355
- advanced = true;
3356
- break;
3357
- }
3358
- if (state.id === 'devices' &&
3359
- remedy.by === 'user-elsewhere' &&
3360
- deps.io.interactive === true) {
3361
- attempted.add(state.id);
3362
- const result = await waitForReadyDevice(deps, state);
3363
- if (result === 'failed')
3364
- failed = true;
3365
- if (result !== 'closed') {
3366
- stop = true;
3367
- break;
3368
- }
3369
- readiness = await reassess();
3370
- advanced = true;
3371
- break;
3372
- }
3373
- // A human-only remedy is the first blocker for an unattended agent.
3374
- stop = true;
3375
- break;
3376
- }
3377
- if (stop || !advanced)
3378
- break;
3379
- }
3380
- await printInitClose(deps, readiness, flags);
3381
- const blocker = firstBlocker(readiness);
3382
- if (blocker === null)
3383
- return failed ? EXIT.failed : EXIT.ok;
3384
- return failed || deps.io.interactive !== true ? EXIT.failed : EXIT.ok;
3385
- }
3386
- function isHookSubstate(id) {
3387
- return id === 'hooks' || id.startsWith('hooks-') || id === 'question-routing-settings';
3388
- }
3389
- function leftoverOptionals(readiness, flags) {
3390
- return readiness.states.filter((state) => {
3391
- if (state.status !== 'optional-gap')
3392
- return false;
3393
- if (isHookSubstate(state.id))
3394
- return false;
3395
- if (state.id === 'skill' && flags.skills === false)
3396
- return false;
3397
- return true;
3398
- });
3399
- }
3400
- function printOptionalLeftovers(deps, leftovers) {
3401
- for (const state of leftovers) {
3402
- deps.io.out(`Optional, not set up — ${remedyLine(state)}`);
3403
- }
3404
- }
3405
- function questionsWillRoute(readiness) {
3406
- const hooks = readiness.states.find((state) => state.id === 'hooks');
3407
- const settings = readiness.states.find((state) => state.id === 'question-routing-settings');
3408
- return hooks?.status === 'ready' && settings?.status !== 'gap';
3409
- }
3410
- async function printInitClose(deps, readiness, flags) {
3411
- const blocker = firstBlocker(readiness);
3412
- const canSend = readiness.states.find((state) => state.id === 'devices')?.status === 'ready';
3413
- const questions = questionsWillRoute(readiness);
3414
- const leftovers = leftoverOptionals(readiness, flags);
3415
- if (deps.io.interactive === true) {
3416
- if (blocker === null) {
3417
- const lines = [
3418
- canSend ? 'You can send notifications.' : 'You are signed in.',
3419
- questions
3420
- ? 'Questions will reach your devices.'
3421
- : 'Questions stay in the terminal until hooks are installed.',
3422
- ];
3423
- await deps.io.note?.(lines.join('\n'), 'Ready');
3424
- printOptionalLeftovers(deps, leftovers);
3425
- deps.io.out(questions
3426
- ? 'All set. Agents in this project can notify you and ask you questions.'
3427
- : 'All set. Agents in this project can notify you. Questions stay in the terminal until hooks are installed.');
3428
- await deps.io.outro?.('All set ✨');
3429
- return;
3430
- }
3431
- await deps.io.note?.(`${blocker.title} — ${blocker.detail}\n${remedyLine(blocker)}`, 'Next');
3432
- deps.io.out(`Next: ${blocker.title} — ${blocker.detail}`);
3433
- deps.io.out(` ${remedyLine(blocker)}`);
3434
- if (blocker.remedy?.by === 'user-elsewhere' || blocker.remedy?.by === 'user-here') {
3435
- deps.io.out(' Then re-run `notifai init` and it will pick up from here.');
3436
- }
3437
- await deps.io.outro?.('One step remains (above)');
3438
- return;
3439
- }
3440
- if (blocker === null) {
3441
- printOptionalLeftovers(deps, leftovers);
3442
- deps.io.out(questions
3443
- ? 'All set. Agents in this project can notify you and ask you questions.'
3444
- : 'All set. Agents in this project can notify you. Questions stay in the terminal until hooks are installed.');
3445
- return;
3446
- }
3447
- deps.io.out(`Next: ${blocker.title} — ${blocker.detail}`);
3448
- deps.io.out(` ${remedyLine(blocker)}`);
3449
- if (blocker.remedy?.by === 'user-elsewhere' || blocker.remedy?.by === 'user-here') {
3450
- deps.io.out(' Then re-run `notifai init` and it will pick up from here.');
3451
- }
3452
- }
3453
- // ---------------------------------------------------------------------------
3454
- // doctor
3455
- // ---------------------------------------------------------------------------
3456
- /**
3457
- * Whether the deployed server understands the contract this build speaks
3458
- *.
3459
- *
3460
- * Every test in the suite runs the CLI and the server from the same commit, so
3461
- * a client sending a field the deployed server has not learned yet is
3462
- * structurally invisible to it. That gap shipped a silent production outage on
3463
- * 2026-08-03: questions stopped reaching devices entirely and nothing said so.
3464
- *
3465
- * The capability document already carries a schema version and is served
3466
- * unauthenticated, so a single GET answers the question. This does not claim to
3467
- * catch every skew — an additive field inside the same schema version would
3468
- * still pass — but it catches the one that has actually happened, and it names
3469
- * the remedy rather than leaving someone to derive it.
3470
- */
3471
- async function contractCheck(client) {
3472
- const local = CAPABILITIES_V1.describe('ios')?.schema_version;
3473
- try {
3474
- const remote = (await client.capabilities('ios')).schema_version;
3475
- if (local === remote) {
3476
- return { name: 'contract', ok: true, detail: `server and CLI both speak schema v${remote}` };
3477
- }
3478
- return {
3479
- name: 'contract',
3480
- ok: false,
3481
- detail: local !== undefined && remote < local
3482
- ? `server speaks schema v${remote}, this CLI speaks v${local} — the service is being updated; try again later`
3483
- : `server speaks schema v${remote}, this CLI speaks v${local} — update the CLI: npm install -g @raidiant/notifai`,
3484
- };
3485
- }
3486
- catch (err) {
3487
- return {
3488
- name: 'contract',
3489
- ok: false,
3490
- detail: `could not read the server capability document (${err instanceof ApiCallError ? err.code : String(err)})`,
3491
- };
3492
- }
3493
- }
3494
- function projectReadiness(deps, config) {
3495
- const configured = config.project.value;
3496
- if (configured !== null) {
3497
- return {
3498
- id: 'project',
3499
- title: 'Project identity',
3500
- status: 'ready',
3501
- detail: `"${configured}" (${config.project.source})`,
3502
- };
3503
- }
3504
- const inferred = inferInvocationContext(deps.cwd).project;
3505
- if (inferred !== null) {
3506
- return {
3507
- id: 'project',
3508
- title: 'Project identity',
3509
- status: 'optional-gap',
3510
- detail: `"${inferred}" is inferred for each send; init can stamp it into shared config`,
3511
- remedy: {
3512
- by: 'cli',
3513
- summary: 'make the inferred Project identity explicit for every checkout',
3514
- command: 'notifai init',
3515
- },
3516
- };
3517
- }
3518
- return {
3519
- id: 'project',
3520
- title: 'Project identity',
3521
- status: 'optional-gap',
3522
- detail: 'the directory name has no characters a Project identifier can use',
3523
- remedy: {
3524
- by: 'cli',
3525
- summary: 'choose an explicit Project identifier',
3526
- command: 'notifai init --project-id my-project',
3527
- },
3528
- };
3529
- }
3530
- function remoteStatesFrom(previous) {
3531
- const pick = (id) => previous.states.find((state) => state.id === id);
3532
- const credential = pick('credential');
3533
- const server = pick('server');
3534
- const contract = pick('contract');
3535
- const auth = pick('auth');
3536
- const devices = pick('devices');
3537
- const proof = pick('proof');
3538
- if (credential === undefined ||
3539
- server === undefined ||
3540
- contract === undefined ||
3541
- auth === undefined ||
3542
- devices === undefined ||
3543
- proof === undefined) {
3544
- return null;
3545
- }
3546
- return { credential, server, contract, auth, devices, proof };
3547
- }
3548
- function remoteInvalidatedByConfig(previous, config) {
3549
- if (config.base_url.source === 'default')
3550
- return false;
3551
- const server = previous.states.find((state) => state.id === 'server');
3552
- return server === undefined || !server.detail.includes(config.base_url.value);
3553
- }
3554
- /**
3555
- * Read the whole setup once, in dependency order.
3556
- *
3557
- * Descent stops where a prerequisite is missing: without a credential there is
3558
- * nothing to ask the server with, and without a reachable server a contract
3559
- * mismatch is unknowable rather than absent. Those downstream states report
3560
- * `unknown`, which is the honest answer and keeps a network outage from
3561
- * looking like a broken install.
3562
- */
3563
- export async function assessReadiness(deps, options = {}) {
3564
- const config = loadLoggedConfig(deps, { cwd: deps.cwd, env: deps.env });
3565
- const previous = options.previous;
3566
- const refresh = options.refresh;
3567
- if (previous !== undefined && refresh !== undefined && refresh.length === 0)
3568
- return previous;
3569
- const reuseRemote = previous !== undefined &&
3570
- refresh !== undefined &&
3571
- !refresh.includes('remote') &&
3572
- !remoteInvalidatedByConfig(previous, config);
3573
- if (reuseRemote) {
3574
- const reused = remoteStatesFrom(previous);
3575
- if (reused !== null) {
3576
- return {
3577
- states: [
3578
- projectReadiness(deps, config),
3579
- reused.credential,
3580
- reused.server,
3581
- reused.contract,
3582
- reused.auth,
3583
- ...hookStates(deps),
3584
- await skillReadiness(deps, options.skillScope),
3585
- reused.devices,
3586
- reused.proof,
3587
- ],
3588
- };
3589
- }
3590
- }
3591
- const states = [];
3592
- let accountClient = null;
3593
- let accountDevices = null;
3594
- states.push(projectReadiness(deps, config));
3595
- const credential = deps.store.load();
3596
- states.push(credential
3597
- ? {
3598
- id: 'credential',
3599
- title: 'This machine',
3600
- status: 'ready',
3601
- detail: `paired as "${credential.machineName}" (${deps.store.describe()})`,
3602
- }
3603
- : {
3604
- id: 'credential',
3605
- title: 'This machine',
3606
- status: 'gap',
3607
- detail: 'not paired with your account',
3608
- remedy: {
3609
- by: 'user-here',
3610
- summary: 'sign in — this opens your browser to approve the machine',
3611
- command: 'notifai login',
3612
- interactive: true,
3613
- },
3614
- });
3615
- const baseUrl = resolvedBaseUrl(config, credential);
3616
- const anon = makeClient(deps, baseUrl, null);
3617
- // A probe that throws is unreachable, not a crash: this runs against a
3618
- // half-configured machine by definition, which is where a client that
3619
- // cannot even be constructed properly shows up.
3620
- let reachable = false;
3621
- try {
3622
- reachable = await anon.health();
3623
- }
3624
- catch {
3625
- reachable = false;
3626
- }
3627
- states.push(reachable
3628
- ? { id: 'server', title: 'Service', status: 'ready', detail: `${baseUrl} reachable` }
3629
- : {
3630
- id: 'server',
3631
- title: 'Service',
3632
- status: 'gap',
3633
- detail: `cannot reach ${baseUrl}`,
3634
- remedy: {
3635
- by: 'user-here',
3636
- summary: 'check your network',
3637
- command: 'notifai doctor',
3638
- },
3639
- });
3640
- if (!reachable) {
3641
- states.push({
3642
- id: 'contract',
3643
- title: 'Protocol version',
3644
- status: 'unknown',
3645
- detail: 'not checked — the server is unreachable',
3646
- });
3647
- }
3648
- else {
3649
- const contract = await contractCheck(anon);
3650
- states.push(contract.ok
3651
- ? { id: 'contract', title: 'Protocol version', status: 'ready', detail: contract.detail }
3652
- : {
3653
- id: 'contract',
3654
- title: 'Protocol version',
3655
- status: 'gap',
3656
- detail: contract.detail,
3657
- remedy: {
3658
- by: 'user-here',
3659
- summary: 'the CLI and server disagree; the detail above says which to move',
3660
- command: 'notifai doctor',
3661
- },
3662
- });
3663
- }
3664
- let accountEmail = null;
3665
- let accountLookupFailed = false;
3666
- if (!credential || !reachable) {
3667
- const why = !credential ? 'this machine is not paired' : 'the server is unreachable';
3668
- states.push({ id: 'auth', title: 'Account', status: 'unknown', detail: `not checked — ${why}` });
3669
- }
3670
- else {
3671
- const client = makeClient(deps, baseUrl, `Bearer nfm_${credential.machineId}.${credential.secret}`);
3672
- accountClient = client;
3673
- try {
3674
- const [{ devices }, email] = await Promise.all([
3675
- client.listDevices(),
3676
- Promise.resolve()
3677
- .then(async () => (await client.accessStatus()).email)
3678
- .catch(() => null),
3679
- ]);
3680
- accountDevices = devices;
3681
- accountEmail = email;
3682
- states.push({
3683
- id: 'auth',
3684
- title: 'Account',
3685
- status: 'ready',
3686
- detail: accountEmail
3687
- ? `machine ${credential.machineId} accepted (${accountEmail})`
3688
- : `machine ${credential.machineId} accepted`,
3689
- });
3690
- }
3691
- catch (err) {
3692
- // A credential the server rejects is revocation, not absence, and the
3693
- // remedy is the same sign-in either way.
3694
- accountLookupFailed = true;
3695
- states.push({
3696
- id: 'auth',
3697
- title: 'Account',
3698
- status: 'gap',
3699
- detail: err instanceof ApiCallError ? `${err.code}: ${err.message}` : String(err),
3700
- remedy: {
3701
- by: 'user-here',
3702
- summary: 'this machine is no longer recognised; pair it again',
3703
- command: 'notifai login',
3704
- },
3705
- });
3706
- }
3707
- }
3708
- // Optional setup that works without a companion device must appear before the
3709
- // device gap: init stops at the first user-elsewhere blocker, and hooks/skill
3710
- // are reachable without a phone.
3711
- states.push(...hookStates(deps));
3712
- states.push(await skillReadiness(deps, options.skillScope));
3713
- if (!credential || !reachable) {
3714
- const why = !credential ? 'this machine is not paired' : 'the server is unreachable';
3715
- states.push({ id: 'devices', title: 'Your devices', status: 'unknown', detail: `not checked — ${why}` });
3716
- }
3717
- else if (accountLookupFailed || accountDevices === null) {
3718
- states.push({ id: 'devices', title: 'Your devices', status: 'unknown', detail: 'not checked — sign-in failed' });
3719
- }
3720
- else {
3721
- const devices = accountDevices;
3722
- const ready = devices.filter(deviceCanReceive);
3723
- states.push(ready.length > 0
3724
- ? {
3725
- id: 'devices',
3726
- title: 'Your devices',
3727
- status: 'ready',
3728
- detail: `${ready.map((d) => d.display_name).join(', ')} ready to receive`,
3729
- }
3730
- : {
3731
- id: 'devices',
3732
- title: 'Your devices',
3733
- status: 'gap',
3734
- // The one gap that cannot be closed from this terminal, and the
3735
- // likeliest place a first setup is abandoned. Naming which of
3736
- // the three sub-states it is matters: "install the app" is
3737
- // useless advice to someone who installed it and denied the
3738
- // permission prompt. The live bridge is /support on the
3739
- // dashboard origin — not a placeholder, and not typed by hand.
3740
- detail: devices.length === 0
3741
- ? `nothing registered yet; install Notifai on iPhone or Mac via ${supportPageUrl(baseUrl)}`
3742
- : `${devices.map((d) => `${d.display_name} (${d.permission_status})`).join(', ')} — registered but not able to receive`,
3743
- remedy: {
3744
- by: 'user-elsewhere',
3745
- summary: deviceInstallRemedy({
3746
- baseUrl,
3747
- email: accountEmail,
3748
- devices,
3749
- }),
3750
- },
3751
- });
3752
- }
3753
- states.push(await setupProofState(deps, config, accountClient, accountDevices));
3754
- return { states };
3755
- }
3756
- async function setupProofState(deps, config, client, devices) {
3757
- if (client === null || devices === null) {
3758
- return {
3759
- id: 'proof',
3760
- title: 'Delivery proof',
3761
- status: 'unknown',
3762
- detail: 'not checked — account and device readiness must be established first',
3763
- };
3764
- }
3765
- const ready = devices.filter(deviceCanReceive);
3766
- if (ready.length === 0) {
3767
- return {
3768
- id: 'proof',
3769
- title: 'Delivery proof',
3770
- status: 'unknown',
3771
- detail: 'not checked — no companion device is ready',
3772
- };
3773
- }
3774
- const ios = readyIosDevices(devices);
3775
- if (ios.length === 0) {
3776
- // Honest non-blocking caveat: notifications can reach the Mac; only the
3777
- // receipt proof path is unavailable in this release. Never emit a Next:
3778
- // step the user cannot satisfy, and never claim unobserved proof.
3779
- return {
3780
- id: 'proof',
3781
- title: 'Delivery proof',
3782
- status: 'optional-gap',
3783
- detail: "unprovable in this release — notifications can reach your Mac, but the macOS path does not emit Companion Receipts (the app's delivery confirmation); receipt proof needs an iPhone",
3784
- remedy: {
3785
- by: 'user-elsewhere',
3786
- summary: 'receipt proof needs an iPhone in this release (notifications still reach this Mac)',
3787
- },
3788
- };
3789
- }
3790
- const proof = readSetupProof(deps);
3791
- const target = proof === null ? null : ios.find((device) => device.device_id === proof.device_id);
3792
- if (proof === null || proof.project !== config.project.value || target === undefined) {
3793
- return {
3794
- id: 'proof',
3795
- title: 'Delivery proof',
3796
- status: 'gap',
3797
- detail: "no Companion Receipt (the app's delivery confirmation) has proven this project on this machine yet",
3798
- remedy: {
3799
- by: 'cli',
3800
- summary: "send one real verification notification and wait for its Companion Receipt (the app's delivery confirmation)",
3801
- command: 'notifai init',
3802
- },
3803
- };
3804
- }
3805
- try {
3806
- const snapshot = await client.evidence(proof.request_id);
3807
- const observed = observedCompanionReceipt(snapshot, proof.device_id);
3808
- if (observed) {
3809
- return {
3810
- id: 'proof',
3811
- title: 'Delivery proof',
3812
- status: 'ready',
3813
- detail: `Companion Receipt (the app's delivery confirmation) observed from ${observed.delivery.device_name} at ${observed.observedAt} (${proof.request_id})`,
3814
- };
3815
- }
3816
- return {
3817
- id: 'proof',
3818
- title: 'Delivery proof',
3819
- status: 'gap',
3820
- detail: `${proof.request_id} was sent, but its Companion Receipt (the app's delivery confirmation) is still unknown`,
3821
- remedy: {
3822
- by: 'cli',
3823
- summary: 'check the same verification notification again',
3824
- command: 'notifai init',
3825
- },
3826
- };
3827
- }
3828
- catch (err) {
3829
- return {
3830
- id: 'proof',
3831
- title: 'Delivery proof',
3832
- status: 'gap',
3833
- detail: `could not read ${proof.request_id} evidence (${err instanceof ApiCallError ? err.code : String(err)})`,
3834
- remedy: {
3835
- by: 'cli',
3836
- summary: 'retry the existing verification evidence check',
3837
- command: 'notifai init',
3838
- },
3839
- };
3840
- }
3841
- }
3842
- export async function doctorCommand(deps, flags, options = {}) {
3843
- const readiness = options.readiness ?? (await assessReadiness(deps));
3844
- const blocker = firstBlocker(readiness);
3845
- const ok = blocker === null;
3846
- if (flags.json) {
3847
- deps.io.out(JSON.stringify({ ok, exit_code: ok ? EXIT.ok : EXIT.failed, states: readiness.states }, null, 2));
3848
- return ok ? EXIT.ok : EXIT.failed;
3849
- }
3850
- const line = (s) => `${s.title}: ${s.detail}`;
3851
- // Doctor reports everything — that is the difference from init, which acts
3852
- // on one thing. It still names where to start, because a list of five
3853
- // problems in dependency order has an obvious first move and saying so
3854
- // costs nothing.
3855
- if (deps.io.interactive === true && deps.io.check) {
3856
- await deps.io.intro?.('Notifai doctor');
3857
- for (const s of readiness.states) {
3858
- // Four states, not two. Rendering `optional-gap` and `unknown` as
3859
- // successes made a green report that had never checked half of what it
3860
- // listed, and put a tick beside things the reader had deliberately
3861
- // declined.
3862
- await deps.io.check(s.status !== 'gap', line(s), doctorTone(s.status));
3863
- }
3864
- await deps.io.outro?.(ok ? 'Everything looks good' : `Start with: ${remedyLine(blocker)}`);
3865
- }
3866
- else {
3867
- for (const s of readiness.states) {
3868
- const mark = s.status === 'gap'
3869
- ? 'FAIL'
3870
- : s.status === 'unknown'
3871
- ? ' ? '
3872
- : s.status === 'optional-gap'
3873
- ? ' --'
3874
- : 'ok ';
3875
- deps.io.out(`${mark} ${line(s)}`);
3876
- }
3877
- if (!ok)
3878
- deps.io.out(`\nStart with: ${remedyLine(blocker)}`);
3879
- }
3880
- return ok ? EXIT.ok : EXIT.failed;
3881
- }
3882
- /** Readiness status as a report tone. */
3883
- function doctorTone(status) {
3884
- switch (status) {
3885
- case 'ready':
3886
- return 'ok';
3887
- case 'gap':
3888
- return 'bad';
3889
- case 'optional-gap':
3890
- return 'warn';
3891
- case 'unknown':
3892
- return 'pending';
3893
- }
3894
- }
3895
- /** One line telling the reader what to actually do about a state. */
3896
- function remedyLine(state) {
3897
- const remedy = state.remedy;
3898
- if (!remedy)
3899
- return state.detail;
3900
- if (remedy.by === 'user-elsewhere')
3901
- return remedy.summary;
3902
- return remedy.command === undefined
3903
- ? remedy.summary
3904
- : `${remedy.summary} — run \`${remedy.command}\``;
3905
- }
3906
- /**
3907
- * Whether the hook installation is internally ready, plus evidence that a
3908
- * project session has fired it before. This cannot prove future execution or
3909
- * end-to-end notification delivery without a live harness and device test.
3910
- *
3911
- * Every failure mode here was found the expensive way, by spawning a session
3912
- * and watching nothing happen: hooks not installed, installed but never fired,
3913
- * or left behind by an older build that named events this one does not serve.
3914
- */
3915
- /**
3916
- * Hook diagnostics as readiness states.
3917
- *
3918
- * A thin adapter over `hookChecks`, whose every branch was found the expensive
3919
- * way and is not worth re-deriving. The judgment added here is which failures
3920
- * actually stand in the way.
3921
- *
3922
- * Not everything failed is in the way. A pointer that has never been
3923
- * published is the normal condition of an install thirty seconds old — the
3924
- * next prompt fixes it and no command can. Treating that as blocking would
3925
- * mean `init` could only finish after a session had already run — so it
3926
- * reports as something worth knowing rather than something to
3927
- * fix, and `init` walks on to the states it can actually close, delivery
3928
- * proof included. Which failures are informational, and what the true remedy
3929
- * is, is each check's own call (`HookCheck`).
3930
- */
3931
- function hookStates(deps) {
3932
- const installations = findInstallations(deps.cwd, deps.env, deps.hookAdapterHome, deps.hookPlatform);
3933
- const active = activeHarnessSession(deps.env, deps.cwd, (deps.now ?? Date.now)());
3934
- const config = loadLoggedConfig(deps, { cwd: deps.cwd, env: deps.env });
3935
- const settings = {
3936
- id: 'question-routing-settings',
3937
- title: 'Question routing settings',
3938
- status: config.ask_notifications.value ? 'ready' : 'gap',
3939
- detail: [
3940
- `ask_notifications=${config.ask_notifications.value} (${config.ask_notifications.source})`,
3941
- `ask_grace_seconds=${config.ask_grace_seconds.value} (${config.ask_grace_seconds.source})`,
3942
- ].join(', '),
3943
- ...(config.ask_notifications.value
3944
- ? {}
3945
- : {
3946
- remedy: {
3947
- by: 'cli',
3948
- summary: 'enable asynchronous question routing',
3949
- command: 'notifai config set ask_notifications true',
3950
- },
3951
- }),
3952
- };
3953
- if (installations.length === 0) {
3954
- return [
3955
- {
3956
- id: 'hooks',
3957
- title: 'Question routing',
3958
- status: active === null ? 'optional-gap' : 'gap',
3959
- detail: active === null
3960
- ? 'hooks not installed, so questions stay in the terminal'
3961
- : `active ${active.label} session detected, but ${active.label} hooks are not installed; \`notifai ask\` cannot route this session`,
3962
- remedy: {
3963
- by: 'cli',
3964
- summary: 'install harness hooks so questions reach your devices when you are away',
3965
- command: active === null
3966
- ? 'notifai hooks install'
3967
- : `notifai hooks install --harness ${active.harness}`,
3968
- },
3969
- },
3970
- settings,
3971
- ];
3972
- }
3973
- /**
3974
- * A human title per check.
3975
- *
3976
- * Three checks used to collapse onto "Question routing", so a reader could not
3977
- * tell which of them had failed, and the rest fell through to their internal
3978
- * name — `hooks (stale)` beside `Delivery proof`. The `id` stays the stable
3979
- * thing to branch on; this is only what a person reads.
3980
- */
3981
- const CHECK_TITLES = {
3982
- hooks: 'Question routing',
3983
- 'hooks (detected)': 'Harnesses detected',
3984
- 'hooks (active harness)': 'Routing for this harness',
3985
- 'hooks (active session)': 'Routing for this session',
3986
- 'hooks (stale)': 'Hook definitions current',
3987
- 'hooks (adapter)': 'Hook adapter',
3988
- 'hooks (trust)': 'Codex hook trust',
3989
- 'hooks (stop shape)': 'Turn-end hook shape',
3990
- 'hooks (duplicates)': 'Duplicate hook installs',
3991
- 'hooks (codex representation)': 'Codex hook representation',
3992
- 'hooks (question admission)': 'Question admission',
3993
- 'hooks (fired)': 'Hooks have run here',
3994
- 'hooks (answer continuation)': 'How an answer returns',
3995
- 'hooks (wake route)': 'Direct wake route',
3996
- };
3997
- function checkTitle(name) {
3998
- return CHECK_TITLES[name] ?? name;
3999
- }
4000
- /** Real but not in the way; see the note above. */
4001
- const informational = new Set();
4002
- return [
4003
- ...hookChecks(deps).map((check) => ({
4004
- id: check.name.replace(/[ ()]+/g, '-').replace(/-$/, ''),
4005
- title: checkTitle(check.name),
4006
- status: check.ok
4007
- ? 'ready'
4008
- : check.informational === true || informational.has(check.name)
4009
- ? 'optional-gap'
4010
- : 'gap',
4011
- detail: check.detail,
4012
- ...(check.ok
4013
- ? {}
4014
- : {
4015
- remedy: {
4016
- by: 'user-here',
4017
- // The check's own remedy when it has one; the generic reinstall
4018
- // line was wrong exactly where it mattered (an unfired pointer
4019
- // needs a prompt, not `hooks install`).
4020
- ...(check.remedy ?? {
4021
- summary: 'the detail above names what to change',
4022
- command: 'notifai hooks install',
4023
- }),
4024
- },
4025
- }),
4026
- })),
4027
- settings,
4028
- ];
4029
- }
4030
- /**
4031
- * Whether an installed Stop handler declares the shape its harness needs.
4032
- *
4033
- * The three answers differ, and getting one wrong fails silently in a
4034
- * different way each time:
4035
- *
4036
- * - Claude Code's handler must be `async: true`, or the waiter holds the
4037
- * user's turn for its whole wait instead of returning at once. It must
4038
- * also declare a `timeout`, because the harness default is 600 s and the
4039
- * kill is silent — the backgrounded waiter vanishes and the answer the
4040
- * user already gave is never delivered.
4041
- * - Codex owns its Stop timeout. Declaring one changes the definition it
4042
- * hashes into `trusted_hash`, and an untrusted handler is simply not run.
4043
- * - Everything else blocks its turn and needs a ceiling above the wait.
4044
- */
4045
- function stopShapeProblems(installation, platform = process.platform) {
4046
- if (installation.harness === 'codex')
4047
- return [];
4048
- const problems = [];
4049
- for (const handler of installation.handlers.filter((entry) => handlerEvent(entry.command) === 'stop')) {
4050
- if (stopHandlerIsDetached(installation.harness, platform)) {
4051
- if (handler.async !== true) {
4052
- problems.push(`${installation.file} declares a blocking Stop handler; the Claude Code wake route needs \`async: true\` so the turn ends while the waiter runs`);
4053
- }
4054
- if (handler.timeout === undefined || handler.timeout < CLAUDE_ASYNC_STOP_TIMEOUT_SECONDS) {
4055
- problems.push(`${installation.file} gives Stop ${handler.timeout ?? 'no'} declared seconds; Claude Code then kills the backgrounded waiter at its 600s default without reporting anything, so it needs an explicit ${CLAUDE_ASYNC_STOP_TIMEOUT_SECONDS}s`);
4056
- }
4057
- continue;
4058
- }
4059
- if (handler.timeout === undefined || handler.timeout < BLOCKING_STOP_TIMEOUT_SECONDS) {
4060
- problems.push(`${installation.file} gives Stop ${handler.timeout ?? 'no'}s, but the blocking answer owner requires ${BLOCKING_STOP_TIMEOUT_SECONDS}s`);
4061
- }
4062
- }
4063
- return problems;
4064
- }
4065
- function hookChecks(deps) {
4066
- const checks = [];
4067
- const installations = findInstallations(deps.cwd, deps.env, deps.hookAdapterHome, deps.hookPlatform);
4068
- // Not having hooks is a setup someone chose, not a fault: `send` works
4069
- // without them. A setup that cannot work is what deserves to go red.
4070
- if (installations.length === 0) {
4071
- checks.push({
4072
- name: 'hooks',
4073
- ok: true,
4074
- detail: 'not installed (optional) — `notifai hooks install` adds question routing',
4075
- });
4076
- return checks;
4077
- }
4078
- checks.push({
4079
- name: 'hooks',
4080
- ok: true,
4081
- detail: installations
4082
- .map((i) => `${i.harness} ${i.global ? 'global' : 'project'} (${i.file})`)
4083
- .join(', '),
4084
- });
4085
- const wired = new Set(installations.map((installation) => installation.harness));
4086
- const unwired = detectedHarnesses(deps.cwd, deps.env).filter((harness) => !wired.has(harness));
4087
- if (unwired.length > 0) {
4088
- checks.push({
4089
- name: 'hooks (detected)',
4090
- ok: false,
4091
- informational: true,
4092
- detail: `${unwired.map((harness) => HARNESS_LABELS[harness]).join(', ')} detected on this machine but not wired`,
4093
- remedy: {
4094
- summary: 'install hooks for every detected harness',
4095
- command: 'notifai hooks install',
4096
- },
4097
- });
4098
- }
4099
- const { active, contested } = resolveActiveHarness(deps.env, deps.cwd, (deps.now ?? Date.now)());
4100
- const activeInstallations = active === null
4101
- ? []
4102
- : installations.filter((installation) => installation.harness === active.harness);
4103
- if (active !== null) {
4104
- checks.push({
4105
- name: 'hooks (active harness)',
4106
- ok: activeInstallations.length > 0,
4107
- detail: activeInstallations.length > 0
4108
- ? `active ${active.label} session has a matching hook installation`
4109
- : `active ${active.label} session has no matching hook installation — run \`notifai hooks install --harness ${active.harness}\``,
4110
- ...(activeInstallations.length > 0
4111
- ? {}
4112
- : {
4113
- remedy: {
4114
- summary: `install hooks for the active ${active.label} session`,
4115
- command: `notifai hooks install --harness ${active.harness}`,
4116
- },
4117
- }),
4118
- });
4119
- if (activeInstallations.length > 0) {
4120
- const pointer = active.sessionId === undefined
4121
- ? null
4122
- : readMatchingProjectSessionPointer(deps.cwd, deps.env, (deps.now ?? Date.now)(), active.sessionId, active.harness);
4123
- if (pointer === null) {
4124
- // The normal condition of hooks installed moments ago: the pointer
4125
- // appears when the harness next fires a hook, and no command can
4126
- // force that. Informational, so `init` walks on to the states it can
4127
- // actually prove instead of exiting over evidence only time produces.
4128
- checks.push({
4129
- name: 'hooks (active session)',
4130
- ok: false,
4131
- informational: true,
4132
- detail: `active ${active.label} session has not published a live pointer — send one ${active.label} prompt, then check again`,
4133
- remedy: {
4134
- summary: `send one ${active.label} prompt — its hook publishes the routing pointer`,
4135
- command: 'notifai doctor',
4136
- },
4137
- });
4138
- }
4139
- else {
4140
- checks.push({
4141
- name: 'hooks (active session)',
4142
- ok: true,
4143
- detail: `the concurrent project index contains the active ${active.label} session`,
4144
- });
4145
- }
4146
- }
4147
- }
4148
- // A handler naming an event this build dropped exits 2 every time the harness
4149
- // fires it, which the harness reports as a hook failure.
4150
- const stale = installations.flatMap((i) => i.handlers
4151
- .filter((h) => {
4152
- const event = handlerEvent(h.command);
4153
- return event !== null && !HOOK_EVENTS.includes(event);
4154
- })
4155
- .map((h) => `${h.event} -> ${handlerEvent(h.command)} in ${i.file}`));
4156
- checks.push({
4157
- name: 'hooks (stale)',
4158
- ok: stale.length === 0,
4159
- detail: stale.length === 0
4160
- ? 'every installed handler names an event this build serves'
4161
- : `${stale.join('; ')} — rerun \`notifai hooks install\` to drop ${stale.length === 1 ? 'it' : 'them'}`,
4162
- });
4163
- const adapterProblems = installations.flatMap((installation) => (installation.problems ?? []).map((problem) => `${installation.file}: ${problem}`));
4164
- const sharedAdapter = inspectHookAdapter(deps.hookAdapterHome, deps.hookPlatform);
4165
- adapterProblems.push(...sharedAdapter.problems);
4166
- if (adapterProblems.length > 0) {
4167
- checks.push({
4168
- name: 'hooks (adapter)',
4169
- ok: false,
4170
- // A machine-global install for a harness that is not active in this
4171
- // project is useful diagnosis, but it must not block unrelated init.
4172
- informational: active === null && installations.every((installation) => installation.global),
4173
- detail: adapterProblems.join('; '),
4174
- });
4175
- }
4176
- const trustProblems = codexTrustProblems(installations, deps.env);
4177
- checks.push({
4178
- name: 'hooks (trust)',
4179
- ok: trustProblems.length === 0,
4180
- detail: trustProblems.length === 0
4181
- ? 'best-effort check matches current persisted Codex approvals; Notifai never writes the trust store, and `/hooks` is authoritative'
4182
- : `best-effort check only; Notifai never writes the trust store and \`/hooks\` is authoritative. ${trustProblems.join('; ')}`,
4183
- ...(trustProblems.length === 0
4184
- ? {}
4185
- : {
4186
- remedy: {
4187
- summary: 'open `/hooks` in Codex and approve the changed Notifai handlers',
4188
- command: '/hooks',
4189
- },
4190
- }),
4191
- });
4192
- const shapeProblems = installations.flatMap((installation) => stopShapeProblems(installation, deps.hookPlatform).map((problem) => `${problem} — run \`notifai hooks install --harness ${installation.harness}${installation.global ? ' --global' : ''}\``));
4193
- checks.push({
4194
- name: 'hooks (stop shape)',
4195
- ok: shapeProblems.length === 0,
4196
- detail: shapeProblems.length === 0
4197
- ? `every installed Stop handler declares the shape its harness needs: Claude Code async with an explicit ${CLAUDE_ASYNC_STOP_TIMEOUT_SECONDS}s waiter budget, blocking hosts ${BLOCKING_STOP_TIMEOUT_SECONDS}s, Codex host-owned`
4198
- : shapeProblems.join('; '),
4199
- });
4200
- // Project and global definitions for one harness both fire. Stable adapter
4201
- // identity deliberately makes their command bytes equal, so comparing
4202
- // command targets would now hide this duplicate rather than diagnose it.
4203
- // Different harnesses remain independent: only the active one runs.
4204
- const duplicated = [...new Set(installations.map((i) => i.harness))]
4205
- .map((harness) => ({
4206
- harness,
4207
- installations: installations.filter((i) => i.harness === harness),
4208
- }))
4209
- .filter((entry) => entry.installations.some((installation) => installation.global) &&
4210
- entry.installations.some((installation) => !installation.global));
4211
- if (duplicated.length > 0) {
4212
- checks.push({
4213
- name: 'hooks (duplicates)',
4214
- ok: false,
4215
- detail: duplicated
4216
- .map((entry) => `${entry.harness}: ${entry.installations.length} hook definitions are active, so each event will fire all of them. Keep either project or global routing and uninstall the other: ${entry.installations.map((installation) => installation.file).join(', ')}`)
4217
- .join('; '),
4218
- });
4219
- }
4220
- const representationProblems = codexRepresentationProblems(deps.cwd, deps.env, deps.hookPlatform);
4221
- if (representationProblems.length > 0) {
4222
- checks.push({
4223
- name: 'hooks (codex representation)',
4224
- ok: false,
4225
- detail: representationProblems.join('; '),
4226
- });
4227
- }
4228
- else {
4229
- const coexistence = codexCoexistenceNotes(deps.cwd, deps.env, deps.hookPlatform);
4230
- if (coexistence.length > 0) {
4231
- checks.push({
4232
- name: 'hooks (codex representation)',
4233
- ok: true,
4234
- detail: coexistence.join('; '),
4235
- });
4236
- }
4237
- }
4238
- const codexHome = codexHomeNote(deps.env, deps.hookPlatform);
4239
- if (codexHome !== null) {
4240
- checks.push({ name: 'hooks (codex home)', ok: true, detail: codexHome });
4241
- }
4242
- if (active !== null && activeInstallations.length > 0) {
4243
- const admissionProblems = activeQuestionRouteProblems(deps, active, installations);
4244
- checks.push({
4245
- name: 'hooks (question admission)',
4246
- ok: admissionProblems.length === 0,
4247
- detail: admissionProblems.length === 0
4248
- ? `the active ${active.label} route is exact, current, singular, trusted where applicable, and bounded by a live owner`
4249
- : admissionProblems.join('; '),
4250
- });
4251
- }
4252
- // Which route this judges, and which remedy it prints, must both belong to
4253
- // the harness that is actually running here. A machine-global installation
4254
- // matches every directory, so picking whichever installation matched — or
4255
- // whichever session last wrote a pointer — reports on a harness the agent
4256
- // cannot influence: it is told to send a prompt in a harness that is not
4257
- // running, follows the fail-closed rule, and can never clear the check.
4258
- const firedPointer = active === null
4259
- ? readProjectSessionPointer(deps.cwd, deps.env, (deps.now ?? Date.now)())
4260
- : active.sessionId === undefined
4261
- ? (readLiveProjectSessionPointers(deps.cwd, deps.env, (deps.now ?? Date.now)()).find((pointer) => pointer.harness === active.harness) ?? null)
4262
- : readMatchingProjectSessionPointer(deps.cwd, deps.env, (deps.now ?? Date.now)(), active.sessionId, active.harness);
4263
- const firedState = firedPointer === null ? null : readSessionState(firedPointer.sessionId, deps.env);
4264
- const promptFired = firedState?.last_prompt_at !== undefined;
4265
- const stopFired = firedState?.last_stop_at !== undefined;
4266
- const fired = firedPointer !== null && promptFired && stopFired;
4267
- // Installations for other harnesses are irrelevant to the active one, and an
4268
- // active harness with none of its own has nothing to activate: say that
4269
- // instead of advising a prompt in some other harness. When the environment
4270
- // is contested and nothing has fired, every candidate is still possible, so
4271
- // the advice covers all of them rather than betting on one.
4272
- const activationHarnesses = new Set(contested.length > 1
4273
- ? contested.map((candidate) => candidate.harness)
4274
- : active === null
4275
- ? installations.map((installation) => installation.harness)
4276
- : [active.harness]);
4277
- const activationInstallations = installations.filter((installation) => activationHarnesses.has(installation.harness));
4278
- const activationAdvice = activationInstallations.length > 0
4279
- ? hookActivationAdvice(activationInstallations)
4280
- : active === null
4281
- ? hookActivationAdvice(installations)
4282
- : `${active.label}: no ${active.label} hook installation matches this project — run \`notifai hooks install --harness ${active.harness}\`.`;
4283
- checks.push({
4284
- name: 'hooks (fired)',
4285
- ok: fired,
4286
- // A wholly fresh install is informational. Once UserPromptSubmit has fired,
4287
- // a missing Stop is a broken route, not missing historical evidence.
4288
- informational: firedPointer === null,
4289
- detail: fired
4290
- ? active === null
4291
- ? 'a session in this directory has run UserPromptSubmit and Stop'
4292
- : `the active ${active.label} session has run UserPromptSubmit and Stop`
4293
- : firedPointer === null
4294
- ? `no ${contested.length > 1
4295
- ? `session pointer for any harness whose markers are present here (${contested
4296
- .map((candidate) => candidate.label)
4297
- .join(', ')})`
4298
- : active === null
4299
- ? 'session pointer'
4300
- : `${active.label} session pointer`} from the last 24 hours — ${activationAdvice}`
4301
- : `the routed session has fired ${promptFired ? 'UserPromptSubmit' : 'neither required event'}, but Stop has not been observed — end one harmless turn, send a new prompt, then check again`,
4302
- ...(fired
4303
- ? {}
4304
- : {
4305
- remedy: {
4306
- summary: firedPointer === null
4307
- ? `send one ${active === null || contested.length > 1 ? '' : `${active.label} `}prompt in a session here, then re-check`
4308
- : 'end one harmless turn, send a new prompt, then re-check',
4309
- command: 'notifai doctor',
4310
- },
4311
- }),
4312
- });
4313
- const continuationInstallations = active !== null ? activeInstallations : installations;
4314
- const continuationHarnesses = [
4315
- ...new Set(continuationInstallations.map((installation) => installation.harness)),
4316
- ];
4317
- checks.push({
4318
- name: 'hooks (answer continuation)',
4319
- ok: continuationHarnesses.length > 0 &&
4320
- continuationHarnesses.every((harness) => HARNESS_CAPABILITIES[harness].stopContinuation !== 'unsupported'),
4321
- informational: active === null,
4322
- detail: continuationHarnesses.length === 0
4323
- ? active === null
4324
- ? 'no installed harness route to assess'
4325
- : `the active ${active.label} session has no matching continuation adapter`
4326
- : continuationHarnesses
4327
- .map((harness) => `${harness}: ${HARNESS_CAPABILITIES[harness].deliveryContract}`)
4328
- .join('; '),
4329
- });
4330
- const wakeRoute = wakeRouteCheck(deps, active, activeInstallations);
4331
- if (wakeRoute !== null)
4332
- checks.push(wakeRoute);
4333
- const stray = codexStrayWorktreeCheck(deps);
4334
- if (stray !== null)
4335
- checks.push(stray);
4336
- return checks;
4337
- }
4338
- /**
4339
- * Whether an answer arriving after this turn ends could actually reach this
4340
- * exact session — the question `notifai ask` really turns on, and the one no
4341
- * other check answers.
4342
- *
4343
- * Read-only, and deliberately so: nothing here connects to a socket, takes a
4344
- * lock, or sends a message. A diagnostic that wakes the agent it is diagnosing
4345
- * would be its own bug report.
4346
- *
4347
- * Everything it can report negatively is a degradation rather than a failure —
4348
- * the accepted journal still replays the answer at the session's next turn —
4349
- * so this is never a blocker. What it buys is that the reason has a name
4350
- * before the user notices the silence.
4351
- *
4352
- * It never asks for `crossSessionInbound`. The poster is the session's own
4353
- * hook child and takes the privileged own-child path, verified to be delivered
4354
- * even against a `bypassPermissions` receiver while an unrelated process was
4355
- * held. Widening a user's general inbound policy to suit Notifai would be a
4356
- * real change to their machine's posture in exchange for nothing.
4357
- */
4358
- function wakeRouteCheck(deps, active, activeInstallations) {
4359
- if (active === null || activeInstallations.length === 0)
4360
- return null;
4361
- if (active.harness === 'claude-code') {
4362
- const readiness = inspectClaudeInbox({
4363
- pid: deps.claudeSourcePid ?? claudeSessionPid(deps.env),
4364
- platform: deps.hookPlatform ?? process.platform,
4365
- readDescriptor: deps.claudeWake?.readDescriptor ?? systemClaudeWakeAdapters(deps.env).readDescriptor,
4366
- socketExists: (socketPath) => existsSync(socketPath),
4367
- });
4368
- return {
4369
- name: 'hooks (wake route)',
4370
- ok: readiness.state === 'ready',
4371
- informational: true,
4372
- detail: readiness.state === 'ready'
4373
- ? `this Claude Code ${readiness.version} session is listening on ${readiness.socketPath}, so an answer can start a turn here without you`
4374
- : `${readiness.reason}. Answers are still delivered, at this session's next turn rather than on their own`,
4375
- };
4376
- }
4377
- if (active.harness === 'codex') {
4378
- const readiness = inspectCodexResume(deps.env, {
4379
- platform: deps.hookPlatform ?? process.platform,
4380
- directoryExists: (directory) => existsSync(directory),
4381
- });
4382
- return {
4383
- name: 'hooks (wake route)',
4384
- ok: readiness.state === 'ready',
4385
- informational: true,
4386
- detail: readiness.state === 'ready'
4387
- ? `the held Codex turn continues from its own hook, and after it returns ${readiness.lockDirectory} can prove a stopped thread unowned before resuming it`
4388
- : `the held Codex turn still continues from its own hook, but after it returns nothing can be resumed: ${readiness.reason}. Answers wait for the next turn`,
4389
- };
4390
- }
4391
- return null;
4392
- }
4393
- /**
4394
- * A Codex hooks file sitting in a worktree, which Codex will never read.
4395
- *
4396
- * `settingsFile` now writes to the main repository, so this only fires for a
4397
- * file an older build left behind — but that file is indistinguishable from a
4398
- * working install if you go looking, and it is exactly what made this bug take
4399
- * a day to find. Omitted entirely when there is nothing to say.
4400
- */
4401
- function codexStrayWorktreeCheck(deps) {
4402
- const layer = codexLayerDir(deps.cwd);
4403
- if (layer === null)
4404
- return null;
4405
- const root = codexProjectRoot(deps.cwd);
4406
- const project = inspectCodexLayer(codexLayerPaths(false, deps.cwd, deps.env));
4407
- if (project.jsonEvents.length === 0 && project.tomlEvents.length === 0)
4408
- return null;
4409
- const strayJson = path.join(path.dirname(layer), '.codex', 'hooks.json');
4410
- const strayToml = path.join(path.dirname(layer), '.codex', 'config.toml');
4411
- const problems = [];
4412
- if (!existsSync(layer)) {
4413
- problems.push(`${layer} is missing, so Codex never looks for project hooks here`);
4414
- }
4415
- if (existsSync(strayJson) && path.resolve(strayJson) !== path.resolve(project.paths.hooksJson)) {
4416
- problems.push(`${strayJson} is never read — Codex reads ${project.writeTarget} instead`);
4417
- }
4418
- if (existsSync(strayToml) && path.resolve(strayToml) !== path.resolve(project.paths.configToml)) {
4419
- problems.push(`${strayToml} is never read — Codex reads ${project.writeTarget} instead`);
4420
- }
4421
- return {
4422
- name: 'hooks (codex worktree)',
4423
- ok: problems.length === 0,
4424
- detail: problems.length === 0
4425
- ? `worktree wired to the main repository at ${root}`
4426
- : `${problems.join('; ')}. Re-run \`notifai hooks install\` to fix.`,
4427
- };
4428
- }
4429
- // ---------------------------------------------------------------------------
4430
- // production IO
4431
- // ---------------------------------------------------------------------------
4432
- /**
4433
- * Whether a human is driving this terminal.
4434
- *
4435
- * A TTY alone is NOT that evidence: agent harnesses frequently allocate a PTY
4436
- * for the commands they run, and a prompt shown to an agent does not fail — it
4437
- * hangs, because every prompt library waits on stdin rather than erroring. So
4438
- * this also honours `CI` and an explicit `NOTIFAI_NO_INPUT=1` escape hatch,
4439
- * and every interactive affordance stays strictly optional: anything `init`
4440
- * can ask, a flag can answer.
4441
- */
4442
- function isHumanTerminal(env) {
4443
- return (process.stdin.isTTY === true &&
4444
- process.stdout.isTTY === true &&
4445
- (env['CI'] ?? '') === '' &&
4446
- (env['NOTIFAI_NO_INPUT'] ?? '') === '');
4447
- }
4448
- /**
4449
- * Lazy on purpose: the hook path runs in front of every prompt the user types,
4450
- * and must not pay for a prompt library it will never show.
4451
- */
4452
- async function clack() {
4453
- return await import('@clack/prompts');
4454
- }
4455
- export function realIo(env = process.env) {
4456
- const interactive = () => isHumanTerminal(env);
4457
- return {
4458
- out: (line) => console.log(line),
4459
- err: (line) => console.error(line),
4460
- get interactive() {
4461
- return interactive();
4462
- },
4463
- confirm: async (question, fallback = false) => {
4464
- if (!interactive())
4465
- return fallback;
4466
- const p = await clack();
4467
- const answer = await p.confirm({ message: question, initialValue: fallback });
4468
- // Ctrl-C mid-prompt arrives as a cancel symbol, not a SIGINT; treat it
4469
- // as the safe answer rather than letting a Symbol escape into logic.
4470
- return p.isCancel(answer) ? false : answer;
4471
- },
4472
- select: async (message, options) => {
4473
- if (!interactive())
4474
- return null;
4475
- const p = await clack();
4476
- const answer = await p.select({ message, options });
4477
- return p.isCancel(answer) ? null : answer;
4478
- },
4479
- multiselect: async (message, options, initial) => {
4480
- if (!interactive())
4481
- return null;
4482
- const p = await clack();
4483
- const answer = await p.multiselect({
4484
- message,
4485
- options,
4486
- required: true,
4487
- ...(initial !== undefined && initial.length > 0 ? { initialValues: initial } : {}),
4488
- });
4489
- return p.isCancel(answer) ? null : answer;
4490
- },
4491
- intro: async (title) => {
4492
- if (!interactive())
4493
- return;
4494
- (await clack()).intro(title);
4495
- },
4496
- outro: async (message) => {
4497
- if (!interactive())
4498
- return;
4499
- (await clack()).outro(message);
4500
- },
4501
- note: async (message, title) => {
4502
- if (!interactive())
4503
- return;
4504
- (await clack()).note(message, title);
4505
- },
4506
- spinner: async (message) => {
4507
- if (!interactive())
4508
- return null;
4509
- const progress = (await clack()).spinner();
4510
- progress.start(message);
4511
- return {
4512
- message: (next) => progress.message(next),
4513
- stop: (next) => progress.stop(next),
4514
- error: (next) => progress.error(next),
4515
- };
4516
- },
4517
- check: async (ok, message, tone) => {
4518
- if (!interactive())
4519
- return;
4520
- const { log } = await clack();
4521
- switch (tone ?? (ok ? 'ok' : 'bad')) {
4522
- case 'ok':
4523
- return log.success(message);
4524
- case 'warn':
4525
- return log.warn(message);
4526
- case 'pending':
4527
- // Never checked. `log.info` reads as neutral, which is the honest
4528
- // rendering for a state no evidence was gathered about.
4529
- return log.info(message);
4530
- case 'bad':
4531
- return log.error(message);
4532
- }
4533
- },
4534
- openUrl,
4535
- };
4536
- }
1
+ export { EXIT, log, } from './commands-core.js';
2
+ export { accessStatusCommand, authStatusCommand, loginCommand, logoutCommand, } from './commands-auth.js';
3
+ export { canDeviceReceive, capabilitiesCommand, deviceInventory, devicesCommand, } from './commands-devices.js';
4
+ export { contradictingAnswer, repliesCommand, sendCommand, statusCommand, } from './commands-send.js';
5
+ export { waitForReply } from './commands-send-support.js';
6
+ export { HOOK_EVENTS, acknowledgeCommand, askCommand, buildQuestions, closeCommand, describeHookFailure, hookDefersDiagnosticsUntilAfterCleanup, hookRunCommand, hooksInstallCommand, hooksUninstallCommand, runningViaNpx, } from './commands-hooks.js';
7
+ export { configExplainCommand, configSetCommand, configShowCommand, configUnsetCommand, } from './commands-config.js';
8
+ export { logsCommand, parseSince } from './commands-logs.js';
9
+ export { SKILLS_SOURCE } from './commands-skill.js';
10
+ export { initCommand, projectSlugFrom, } from './commands-init.js';
11
+ export { assessReadiness, doctorCommand } from './commands-doctor.js';
12
+ export { realIo } from './commands-io.js';
4537
13
  //# sourceMappingURL=commands.js.map