@raidiant/notifai 0.3.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (80) hide show
  1. package/README.md +20 -4
  2. package/dist/atomic-file.d.ts +4 -0
  3. package/dist/atomic-file.js +60 -10
  4. package/dist/atomic-file.js.map +1 -1
  5. package/dist/client.d.ts +25 -2
  6. package/dist/client.js +45 -1
  7. package/dist/client.js.map +1 -1
  8. package/dist/commands.d.ts +56 -7
  9. package/dist/commands.js +980 -318
  10. package/dist/commands.js.map +1 -1
  11. package/dist/config-schema.d.ts +1 -1
  12. package/dist/config-schema.js +46 -8
  13. package/dist/config-schema.js.map +1 -1
  14. package/dist/config.d.ts +38 -3
  15. package/dist/config.js +61 -14
  16. package/dist/config.js.map +1 -1
  17. package/dist/file-lock.d.ts +26 -0
  18. package/dist/file-lock.js +268 -0
  19. package/dist/file-lock.js.map +1 -0
  20. package/dist/harnesses.d.ts +28 -0
  21. package/dist/harnesses.js +40 -0
  22. package/dist/harnesses.js.map +1 -0
  23. package/dist/hook-adapter.d.ts +27 -0
  24. package/dist/hook-adapter.js +188 -0
  25. package/dist/hook-adapter.js.map +1 -0
  26. package/dist/hook-input.d.ts +10 -0
  27. package/dist/hook-input.js +64 -0
  28. package/dist/hook-input.js.map +1 -0
  29. package/dist/hooks.d.ts +66 -10
  30. package/dist/hooks.js +751 -276
  31. package/dist/hooks.js.map +1 -1
  32. package/dist/install-hooks.d.ts +40 -16
  33. package/dist/install-hooks.js +217 -73
  34. package/dist/install-hooks.js.map +1 -1
  35. package/dist/interactive.d.ts +1 -1
  36. package/dist/interactive.js +3 -3
  37. package/dist/interactive.js.map +1 -1
  38. package/dist/logging.d.ts +142 -0
  39. package/dist/logging.js +533 -0
  40. package/dist/logging.js.map +1 -0
  41. package/dist/main.js +97 -36
  42. package/dist/main.js.map +1 -1
  43. package/dist/opencode-plugin.d.ts +7 -7
  44. package/dist/opencode-plugin.js +12 -13
  45. package/dist/opencode-plugin.js.map +1 -1
  46. package/dist/ui/config-view.js +3 -0
  47. package/dist/ui/config-view.js.map +1 -1
  48. package/dist/ui/theme.d.ts +1 -1
  49. package/package.json +3 -2
  50. package/src/atomic-file.ts +86 -7
  51. package/src/client.test.ts +12 -0
  52. package/src/client.ts +72 -4
  53. package/src/commands.test.ts +891 -43
  54. package/src/commands.ts +1133 -332
  55. package/src/config-schema.ts +51 -9
  56. package/src/config.test.ts +6 -5
  57. package/src/config.ts +85 -16
  58. package/src/file-lock-process-worker.test.ts +64 -0
  59. package/src/file-lock.test.ts +279 -0
  60. package/src/file-lock.ts +318 -0
  61. package/src/harnesses.test.ts +48 -0
  62. package/src/harnesses.ts +59 -0
  63. package/src/hook-adapter.test.ts +187 -0
  64. package/src/hook-adapter.ts +212 -0
  65. package/src/hook-input.test.ts +39 -0
  66. package/src/hook-input.ts +72 -0
  67. package/src/hooks.test.ts +1318 -240
  68. package/src/hooks.ts +1046 -296
  69. package/src/install-hooks.test.ts +90 -41
  70. package/src/install-hooks.ts +259 -74
  71. package/src/interactive.ts +3 -3
  72. package/src/logging-process-worker.test.ts +39 -0
  73. package/src/logging.test.ts +453 -0
  74. package/src/logging.ts +639 -0
  75. package/src/main.ts +113 -32
  76. package/src/opencode-plugin.ts +15 -16
  77. package/src/session-end-log-lock-worker.test.ts +35 -0
  78. package/src/skill.test.ts +11 -0
  79. package/src/ui/config-view.test.ts +4 -2
  80. package/src/ui/config-view.ts +3 -0
package/dist/commands.js CHANGED
@@ -1,21 +1,38 @@
1
1
  import { createHash, randomBytes } from 'node:crypto';
2
2
  import { spawn } from 'node:child_process';
3
- import { existsSync, mkdirSync, readFileSync, realpathSync, rmSync, writeFileSync } from 'node:fs';
3
+ import { existsSync, lstatSync, mkdirSync, readFileSync, realpathSync, rmSync, unlinkSync, writeFileSync } from 'node:fs';
4
4
  import os from 'node:os';
5
5
  import path from 'node:path';
6
6
  import { parse as parseToml, stringify as stringifyToml } from 'smol-toml';
7
7
  import { CAPABILITIES_V1, QUESTION_TEXT_MAX_LENGTH, REPLY_MAX_QUESTIONS, REPLY_MAX_WINDOW_SECONDS, validateDraft, } from '@raidiant/notifai-protocol';
8
8
  import { sha256Hex } from '@raidiant/notifai-protocol/node';
9
- import { ApiCallError, NetworkError, createClient, } from './client.js';
10
- import { BOOLEAN_CONFIG_KEYS, CONFIG_KEYS, NUMERIC_CONFIG_KEYS, configBounds, findProjectConfigPath, findProjectLocalConfigPath, globalConfigPath, loadConfig, sessionConfigPath, } from './config.js';
9
+ import { ApiCallError, NetworkError, createClient, isRetryableReplyPollError, } from './client.js';
10
+ import { BOOLEAN_CONFIG_KEYS, CONFIG_KEYS, LOG_LEVELS, NUMERIC_CONFIG_KEYS, configBounds, configDefaultValue, findProjectConfigPath, findProjectLocalConfigPath, globalConfigPath, loadConfig, sessionConfigPath, } from './config.js';
11
11
  import { acceptedValues, configInfo } from './config-schema.js';
12
+ import { atomicWriteFileSync } from './atomic-file.js';
13
+ import { withTargetFileLock } from './file-lock.js';
12
14
  import { renderConfigExplain, renderConfigList, renderConfigPlain, } from './ui/config-view.js';
13
15
  import { firstBlocker, openItems, } from './readiness.js';
14
- import { handleSessionEnd, handleStop, handleUserPromptSubmit, parseHookInput, pruneAbandonedSessions, readProjectSession, readProjectSessionPointer, readSessionState, registerQuestion, } from './hooks.js';
16
+ import { handleSessionEnd, handleStop, handleUserPromptSubmit, parseHookInput, pruneAbandonedSessions, readMatchingProjectSessionPointer, readProjectSession, readProjectSessionPointer, readSessionState, registerQuestion, } from './hooks.js';
15
17
  import { readIdleSeconds } from './idle.js';
16
- import { HARNESSES, applyPlan, blockingHookTimeoutSeconds, buildCursorHookConfig, buildHookConfig, codexLayerDir, codexProjectRoot, detectHarness, findInstallations, handlerEvent, loadCursorSettings, loadSettings, mergeCursorHooks, mergeHooks, removeCursorHooks, removeHooks, settingsFile, } from './install-hooks.js';
18
+ import { LOG_EVENTS, activeLogPath, archiveLogPaths, logConfigResolved, logSettingsFrom, logsDiskUsage, nullLogger, readLogRecords, renderRecord, } from './logging.js';
19
+ import { HARNESSES, applyPlan, blockingHookTimeoutSeconds, buildCursorHookConfig, buildHookConfig, codexLayerDir, codexTrustProblems, codexProjectRoot, detectHarness, findInstallations, handlerEvent, loadCursorSettings, loadSettings, mergeCursorHooks, mergeHooks, removeCursorHooks, removeHooks, settingsFile, } from './install-hooks.js';
20
+ import { HARNESS_CAPABILITIES, HOOK_TIMING } from './harnesses.js';
21
+ import { inspectHookAdapter, installHookAdapter, } from './hook-adapter.js';
17
22
  import { isOurOpencodePlugin, opencodePluginSource, } from './opencode-plugin.js';
18
23
  import { CHOICE_USAGE, buildDraft, formatReceipt, parseChoices, receiptExitCode, slugify, } from './send.js';
24
+ /** The invocation's logger, or one that discards everything. */
25
+ export function log(deps) {
26
+ return deps.logger ?? nullLogger();
27
+ }
28
+ function loadLoggedConfig(deps, options) {
29
+ const config = loadConfig(options);
30
+ const logger = log(deps);
31
+ logger.adopt(logSettingsFrom(config));
32
+ logger.bind({ project: config.project.value });
33
+ logConfigResolved(logger, config);
34
+ return config;
35
+ }
19
36
  export const EXIT = {
20
37
  ok: 0,
21
38
  failed: 1,
@@ -25,7 +42,7 @@ export const EXIT = {
25
42
  network: 5,
26
43
  };
27
44
  function makeClient(deps, baseUrl, bearer, options) {
28
- return (deps.clientFactory ?? createClient)(baseUrl, bearer, options);
45
+ return (deps.clientFactory ?? createClient)(baseUrl, bearer, { ...options, logger: log(deps) });
29
46
  }
30
47
  function resolvedBaseUrl(config, credential) {
31
48
  return config.base_url.source === 'default' && credential ? credential.baseUrl : config.base_url.value;
@@ -33,6 +50,10 @@ function resolvedBaseUrl(config, credential) {
33
50
  function authedClient(deps, config) {
34
51
  const credential = deps.store.load();
35
52
  if (!credential) {
53
+ // The commonest reason a command does nothing, and one that leaves no other
54
+ // trace: it returns before any request is made, so without this the log
55
+ // shows an exit code and no cause.
56
+ log(deps).error('cli.error', { kind: 'auth', message: 'not signed in', store: deps.store.describe() });
36
57
  deps.io.err('Not signed in. Run `notifai login` first.');
37
58
  return null;
38
59
  }
@@ -42,7 +63,18 @@ function authedClient(deps, config) {
42
63
  baseUrl,
43
64
  };
44
65
  }
45
- function reportError(deps, err) {
66
+ function reportError(deps, err, context = {}) {
67
+ // Recorded before it is printed: stderr from an agent's shell call is often
68
+ // the one thing that does not survive into the next turn, and this is exactly
69
+ // the line someone comes back looking for.
70
+ log(deps).error('cli.error', {
71
+ ...context,
72
+ ...(err instanceof ApiCallError
73
+ ? { kind: 'api', status: err.status, code: err.code, message: err.message, next_action: err.nextAction, details: err.details }
74
+ : err instanceof NetworkError
75
+ ? { kind: 'network', message: err.message }
76
+ : { kind: 'unknown', message: String(err) }),
77
+ });
46
78
  if (err instanceof ApiCallError) {
47
79
  deps.io.err(`${err.code}: ${err.message}`);
48
80
  if (err.nextAction)
@@ -62,7 +94,7 @@ function reportError(deps, err) {
62
94
  // login / logout / auth status
63
95
  // ---------------------------------------------------------------------------
64
96
  export async function loginCommand(deps, flags) {
65
- const config = loadConfig({ cwd: deps.cwd, env: deps.env, flags: { base_url: flags.baseUrl } });
97
+ const config = loadLoggedConfig(deps, { cwd: deps.cwd, env: deps.env, flags: { base_url: flags.baseUrl } });
66
98
  const baseUrl = config.base_url.value;
67
99
  const machineName = flags.name ?? os.hostname();
68
100
  const secret = randomBytes(32).toString('base64url');
@@ -180,7 +212,7 @@ export function authStatusCommand(deps, flags) {
180
212
  }
181
213
  /** Show the server's account access decision without attempting a product mutation. */
182
214
  export async function accessStatusCommand(deps, flags) {
183
- const config = loadConfig({ cwd: deps.cwd, env: deps.env });
215
+ const config = loadLoggedConfig(deps, { cwd: deps.cwd, env: deps.env });
184
216
  const authed = authedClient(deps, config);
185
217
  if (!authed)
186
218
  return EXIT.auth;
@@ -208,7 +240,7 @@ export async function accessStatusCommand(deps, flags) {
208
240
  // devices / capabilities
209
241
  // ---------------------------------------------------------------------------
210
242
  export async function devicesCommand(deps, flags) {
211
- const config = loadConfig({ cwd: deps.cwd, env: deps.env });
243
+ const config = loadLoggedConfig(deps, { cwd: deps.cwd, env: deps.env });
212
244
  const authed = authedClient(deps, config);
213
245
  if (!authed)
214
246
  return EXIT.auth;
@@ -251,7 +283,7 @@ export async function devicesCommand(deps, flags) {
251
283
  export async function deviceInventory(deps) {
252
284
  if (!deps.store.load())
253
285
  return null;
254
- const config = loadConfig({ cwd: deps.cwd, env: deps.env });
286
+ const config = loadLoggedConfig(deps, { cwd: deps.cwd, env: deps.env });
255
287
  const authed = authedClient(deps, config);
256
288
  if (!authed)
257
289
  return null;
@@ -267,7 +299,7 @@ export function canDeviceReceive(device) {
267
299
  return deviceCanReceive(device);
268
300
  }
269
301
  export async function capabilitiesCommand(deps, flags) {
270
- const config = loadConfig({ cwd: deps.cwd, env: deps.env });
302
+ const config = loadLoggedConfig(deps, { cwd: deps.cwd, env: deps.env });
271
303
  const credential = deps.store.load();
272
304
  const baseUrl = resolvedBaseUrl(config, credential);
273
305
  const client = makeClient(deps, baseUrl, null);
@@ -332,7 +364,7 @@ export async function sendCommand(deps, flags) {
332
364
  deps.io.err(`--reply-window must be an integer from 60 to ${REPLY_MAX_WINDOW_SECONDS} seconds.`);
333
365
  return EXIT.usage;
334
366
  }
335
- const config = loadConfig({
367
+ const config = loadLoggedConfig(deps, {
336
368
  cwd: deps.cwd,
337
369
  env: deps.env,
338
370
  flags: { base_url: flags.baseUrl, wait_seconds: flags.wait },
@@ -371,33 +403,67 @@ export async function sendCommand(deps, flags) {
371
403
  }
372
404
  const waitSeconds = flags.noWait ? 0 : config.wait_seconds.value;
373
405
  const idempotencyKey = flags.idempotencyKey ?? `cli-${randomBytes(12).toString('base64url')}`;
406
+ let receipt;
374
407
  try {
375
- const receipt = await authed.client.submit({ idempotency_key: idempotencyKey, draft: build.draft }, waitSeconds);
376
- const receiptExit = receiptExitCode(receipt);
377
- if (!flags.json)
378
- deps.io.out(formatReceipt(receipt));
379
- else if (flags.reply)
380
- deps.io.out(JSON.stringify({ type: 'receipt', receipt }));
381
- // A zero wait can no longer reach here: --reply guarantees a positive one.
382
- if (!flags.reply || receiptExit !== EXIT.ok) {
383
- if (flags.json) {
384
- deps.io.out(flags.reply
385
- ? JSON.stringify({
386
- type: 'reply_result',
387
- request_id: receipt.request_id,
388
- replies: [],
389
- degraded: false,
390
- })
391
- : JSON.stringify(receipt, null, 2));
392
- }
393
- return receiptExit;
408
+ receipt = await authed.client.submit({ idempotency_key: idempotencyKey, draft: build.draft }, waitSeconds);
409
+ }
410
+ catch (err) {
411
+ return reportError(deps, err);
412
+ }
413
+ const receiptExit = receiptExitCode(receipt);
414
+ // The single most useful line in the log: it ties the local invocation to
415
+ // the server-side request id, which is what every later question about this
416
+ // notification ("did it arrive?", "which device?") is asked in terms of.
417
+ log(deps).info('send.submitted', {
418
+ request_id: receipt.request_id,
419
+ kind: flags.reply ? 'question' : (flags.kind ?? 'update'),
420
+ title: flags.title,
421
+ overall: receipt.overall,
422
+ replayed: receipt.replayed,
423
+ deliveries: receipt.deliveries.length,
424
+ wait_seconds: waitSeconds,
425
+ exit: receiptExit,
426
+ });
427
+ log(deps).info('send.outcome', {
428
+ request_id: receipt.request_id,
429
+ overall: receipt.overall,
430
+ // Per device, because "it said accepted but nothing arrived" is answered
431
+ // by which device reached which state and why the provider said so.
432
+ devices: receipt.deliveries.map((delivery) => ({
433
+ device: delivery.device_name,
434
+ state: delivery.state,
435
+ attempts: delivery.attempts,
436
+ provider_status: delivery.provider_status,
437
+ provider_reason: delivery.provider_reason,
438
+ })),
439
+ ...(receipt.warnings.length > 0 ? { warnings: receipt.warnings } : {}),
440
+ });
441
+ if (!flags.json)
442
+ deps.io.out(formatReceipt(receipt));
443
+ else if (flags.reply)
444
+ deps.io.out(JSON.stringify({ type: 'receipt', receipt }));
445
+ // A zero wait can no longer reach here: --reply guarantees a positive one.
446
+ if (!flags.reply || receiptExit !== EXIT.ok) {
447
+ if (flags.json) {
448
+ deps.io.out(flags.reply
449
+ ? JSON.stringify({
450
+ type: 'reply_result',
451
+ request_id: receipt.request_id,
452
+ replies: [],
453
+ degraded: false,
454
+ })
455
+ : JSON.stringify(receipt, null, 2));
394
456
  }
457
+ return receiptExit;
458
+ }
459
+ try {
395
460
  const result = await waitForReply(authed.client, receipt.request_id, {
396
461
  timeoutSeconds: replyTimeout,
397
462
  afterSeq: 0,
398
463
  now: deps.now,
399
464
  sleep: deps.sleep,
400
465
  });
466
+ recordReplies(deps, receipt.request_id, result.response.replies);
401
467
  if (flags.json) {
402
468
  deps.io.out(JSON.stringify({
403
469
  type: 'reply_result',
@@ -411,16 +477,46 @@ export async function sendCommand(deps, flags) {
411
477
  else
412
478
  printNoReply(deps, receipt.request_id, result.response.reply_expires_at);
413
479
  if (result.degraded) {
414
- deps.io.err(DEGRADED_WAIT_WARNING);
480
+ log(deps).error('cli.error', {
481
+ kind: 'network',
482
+ operation: 'reply_wait',
483
+ request_id: receipt.request_id,
484
+ degraded: true,
485
+ message: 'the reply wait ended while the server was unreachable or faulting',
486
+ });
487
+ deps.io.err(degradedWaitWarning(receipt.request_id));
415
488
  return EXIT.network;
416
489
  }
417
490
  if (result.timedOut) {
418
491
  deps.io.err(`No reply yet. Retrieve it with \`notifai replies ${receipt.request_id}\` or retire the question with ` +
419
- `\`notifai close ${receipt.request_id}\`.`);
492
+ `\`notifai close ${receipt.request_id}\`. ` +
493
+ `This is a reply-wait timeout, not a Delivery or Companion Receipt failure — check with \`notifai status ${receipt.request_id}\`.`);
420
494
  }
421
495
  return result.timedOut ? EXIT.noReply : EXIT.ok;
422
496
  }
423
497
  catch (err) {
498
+ // Receipt already printed; a wait fault must not read as "send failed".
499
+ // Permanent poll errors (auth, closed window, not found) still surface, but
500
+ // always name the durable request and point at recovery.
501
+ if (err instanceof ApiCallError || err instanceof NetworkError) {
502
+ log(deps).error('cli.error', {
503
+ kind: err instanceof ApiCallError ? 'api' : 'network',
504
+ operation: 'reply_wait',
505
+ request_id: receipt.request_id,
506
+ ...(err instanceof ApiCallError ? { status: err.status, code: err.code } : {}),
507
+ message: err.message,
508
+ });
509
+ deps.io.err(`notifai: reply wait failed for ${receipt.request_id} (${err instanceof ApiCallError ? err.code : 'network'}: ${err.message}). ` +
510
+ `Delivery and Companion Receipt are independent — check with \`notifai status ${receipt.request_id}\` and retry with \`notifai replies ${receipt.request_id}\`.`);
511
+ if (err instanceof ApiCallError) {
512
+ if (err.code === 'auth_required' || err.code === 'machine_revoked')
513
+ return EXIT.auth;
514
+ return err.status >= 500 || err.status === 429 || err.status === 408
515
+ ? EXIT.network
516
+ : EXIT.failed;
517
+ }
518
+ return EXIT.network;
519
+ }
424
520
  return reportError(deps, err);
425
521
  }
426
522
  }
@@ -484,44 +580,55 @@ export async function repliesCommand(deps, requestedId, flags) {
484
580
  deps.io.err('Pass a request id or --pending.');
485
581
  return EXIT.usage;
486
582
  }
487
- const config = loadConfig({ cwd: deps.cwd, env: deps.env });
583
+ const config = loadLoggedConfig(deps, { cwd: deps.cwd, env: deps.env });
488
584
  const authed = authedClient(deps, config);
489
585
  if (!authed)
490
586
  return EXIT.auth;
491
587
  try {
492
588
  let anyReplies = false;
493
- let anyDegraded = false;
589
+ const degradedRequestIds = [];
494
590
  let allTimedOut = true;
495
591
  const jsonBodies = [];
496
592
  for (const requestId of requestIds) {
497
- const result = await waitForReply(authed.client, requestId, {
498
- timeoutSeconds: waitSeconds,
499
- afterSeq,
500
- now: deps.now,
501
- sleep: deps.sleep,
502
- });
503
- if (flags.json) {
504
- jsonBodies.push({ ...result.response, degraded: result.degraded });
505
- }
506
- else if (result.response.replies.length > 0) {
507
- if (flags.pending === true)
508
- deps.io.out(`pending request ${requestId}`);
509
- printReplies(deps, result.response.replies);
593
+ try {
594
+ const result = await waitForReply(authed.client, requestId, {
595
+ timeoutSeconds: waitSeconds,
596
+ afterSeq,
597
+ now: deps.now,
598
+ sleep: deps.sleep,
599
+ });
600
+ recordReplies(deps, requestId, result.response.replies);
601
+ if (flags.json) {
602
+ jsonBodies.push({ ...result.response, degraded: result.degraded });
603
+ }
604
+ else if (result.response.replies.length > 0) {
605
+ if (flags.pending === true)
606
+ deps.io.out(`pending request ${requestId}`);
607
+ printReplies(deps, result.response.replies);
608
+ }
609
+ else {
610
+ printNoReply(deps, requestId, result.response.reply_expires_at);
611
+ }
612
+ anyReplies ||= result.response.replies.length > 0;
613
+ if (result.degraded)
614
+ degradedRequestIds.push(requestId);
615
+ allTimedOut &&= result.timedOut;
510
616
  }
511
- else {
512
- printNoReply(deps, requestId, result.response.reply_expires_at);
617
+ catch (err) {
618
+ recordDegradedReplyWaits(deps, degradedRequestIds);
619
+ return reportError(deps, err, { operation: 'reply_wait', request_id: requestId });
513
620
  }
514
- anyReplies ||= result.response.replies.length > 0;
515
- anyDegraded ||= result.degraded;
516
- allTimedOut &&= result.timedOut;
517
621
  }
518
622
  if (flags.json) {
519
623
  // One request keeps the response shape agents already parse; several
520
624
  // (only possible via --pending) arrive as an array in queue order.
521
625
  deps.io.out(JSON.stringify(jsonBodies.length === 1 ? jsonBodies[0] : jsonBodies, null, 2));
522
626
  }
523
- if (anyDegraded) {
524
- deps.io.err(DEGRADED_WAIT_WARNING);
627
+ if (degradedRequestIds.length > 0) {
628
+ recordDegradedReplyWaits(deps, degradedRequestIds);
629
+ // Name a request whose polls actually degraded, not merely the first item
630
+ // in a multi-request queue that may have completed cleanly.
631
+ deps.io.err(degradedWaitWarning(degradedRequestIds[0]));
525
632
  return EXIT.network;
526
633
  }
527
634
  if (anyReplies)
@@ -538,8 +645,8 @@ export async function waitForReply(client, requestId, options) {
538
645
  const sleep = options.sleep ?? ((milliseconds) => new Promise((resolve) => setTimeout(resolve, milliseconds)));
539
646
  const deadline = now() + options.timeoutSeconds * 1000;
540
647
  let lastResponse = null;
541
- let lastNetworkError = null;
542
- let consecutiveNetworkErrors = 0;
648
+ let lastTransientError = null;
649
+ let consecutiveTransientErrors = 0;
543
650
  let firstPoll = true;
544
651
  while (firstPoll || now() < deadline) {
545
652
  firstPoll = false;
@@ -551,8 +658,8 @@ export async function waitForReply(client, requestId, options) {
551
658
  afterSeq: options.afterSeq,
552
659
  });
553
660
  lastResponse = response;
554
- lastNetworkError = null;
555
- consecutiveNetworkErrors = 0;
661
+ lastTransientError = null;
662
+ consecutiveTransientErrors = 0;
556
663
  if (response.replies.length > 0)
557
664
  return { response, timedOut: false, degraded: false };
558
665
  const pauseMs = Math.min(250, Math.max(0, deadline - now()));
@@ -560,19 +667,22 @@ export async function waitForReply(client, requestId, options) {
560
667
  await sleep(pauseMs);
561
668
  }
562
669
  catch (err) {
563
- if (!(err instanceof NetworkError))
670
+ if (!isRetryableReplyPollError(err))
564
671
  throw err;
565
- lastNetworkError = err;
566
- consecutiveNetworkErrors += 1;
672
+ lastTransientError = err instanceof Error ? err : new Error(String(err));
673
+ consecutiveTransientErrors += 1;
567
674
  const remainingAfterError = Math.max(0, deadline - now());
568
675
  if (remainingAfterError === 0)
569
676
  break;
570
- const backoffMs = Math.min(250 * 2 ** (consecutiveNetworkErrors - 1), 2_000, remainingAfterError);
677
+ const backoffMs = Math.min(250 * 2 ** (consecutiveTransientErrors - 1), 2_000, remainingAfterError);
571
678
  await sleep(backoffMs);
572
679
  }
573
680
  }
574
- if (!lastResponse && lastNetworkError)
575
- throw lastNetworkError;
681
+ // Never saw a successful poll: surface the fault so callers get EXIT.network
682
+ // rather than a fake empty silence. The durable request is still on the
683
+ // server; the typed recovery is `notifai replies <id>`.
684
+ if (!lastResponse && lastTransientError)
685
+ throw lastTransientError;
576
686
  return {
577
687
  response: lastResponse ??
578
688
  { request_id: requestId, reply_expires_at: null, replies: [] },
@@ -580,19 +690,50 @@ export async function waitForReply(client, requestId, options) {
580
690
  // A poll succeeded at some point, so we do not throw — but the last thing
581
691
  // we know is that we could not reach the server. Reporting that as a plain
582
692
  // "no reply" would let an agent treat an unseen refusal as consent.
583
- degraded: lastNetworkError !== null,
693
+ degraded: lastTransientError !== null,
584
694
  };
585
695
  }
696
+ function recordDegradedReplyWaits(deps, requestIds) {
697
+ if (requestIds.length === 0)
698
+ return;
699
+ log(deps).error('cli.error', {
700
+ kind: 'network',
701
+ operation: 'reply_wait',
702
+ request_ids: requestIds,
703
+ degraded: true,
704
+ message: 'the reply wait ended while the server was unreachable or faulting',
705
+ });
706
+ }
586
707
  /**
587
708
  * Shared by every surface that waits: "the user did not answer" and "I could
588
709
  * not find out" must not look the same, because agents branch on the exit code
589
710
  * and one of those two branches is safe to proceed from.
711
+ *
712
+ * Delivery / Companion Receipt / OS presentation are separate facts — a wait
713
+ * fault is not evidence that the push failed.
590
714
  */
591
- const DEGRADED_WAIT_WARNING = 'notifai: the wait ended during a network outage, so this is "could not find out", ' +
592
- 'not "no answer" — the reply may already be waiting. Retry with `notifai replies <id>`.';
715
+ function degradedWaitWarning(requestId) {
716
+ return (`notifai: the wait for ${requestId} ended while the server was unreachable or faulting, ` +
717
+ `so this is "could not find out", not "no answer" — the request is still durable and the ` +
718
+ `reply may already be waiting (Provider Acceptance and Companion Receipt are independent). ` +
719
+ `Retry with \`notifai replies ${requestId}\`.`);
720
+ }
593
721
  function isNonNegativeInteger(value) {
594
722
  return Number.isInteger(value) && value >= 0;
595
723
  }
724
+ function recordReplies(deps, requestId, replies) {
725
+ const logger = log(deps);
726
+ for (const reply of replies) {
727
+ logger.info('reply.received', {
728
+ request_id: requestId,
729
+ sequence: reply.seq,
730
+ device: reply.device_name,
731
+ text: reply.text,
732
+ answers: reply.answers,
733
+ source: reply.source,
734
+ });
735
+ }
736
+ }
596
737
  function printReplies(deps, replies) {
597
738
  for (const reply of replies)
598
739
  deps.io.out(`reply from ${reply.device_name}: ${reply.text}`);
@@ -633,7 +774,7 @@ function printNoReply(deps, requestId, expiresAt) {
633
774
  : `no reply yet for request ${requestId}; the reply window remains open`);
634
775
  }
635
776
  export async function statusCommand(deps, requestId, flags) {
636
- const config = loadConfig({ cwd: deps.cwd, env: deps.env });
777
+ const config = loadLoggedConfig(deps, { cwd: deps.cwd, env: deps.env });
637
778
  const authed = authedClient(deps, config);
638
779
  if (!authed)
639
780
  return EXIT.auth;
@@ -644,6 +785,7 @@ export async function statusCommand(deps, requestId, flags) {
644
785
  return EXIT.ok;
645
786
  }
646
787
  deps.io.out(`request ${snapshot.request_id} (${snapshot.event ?? 'no event'}) — ${snapshot.overall}`);
788
+ let anyReplyReceived = false;
647
789
  for (const d of snapshot.deliveries) {
648
790
  deps.io.out(` ${d.device_name}:`);
649
791
  deps.io.out(` Delivery: ${d.state} after ${d.attempts} attempt(s)`);
@@ -656,10 +798,24 @@ export async function statusCommand(deps, requestId, flags) {
656
798
  else {
657
799
  deps.io.out(" Companion Receipt (the app's delivery confirmation): unknown — not observed; this is not a failure or proof of non-receipt");
658
800
  }
801
+ // Notifai never learns whether the OS painted a banner; saying so stops
802
+ // a reply-wait fault from being misread as "the phone never showed it".
803
+ deps.io.out(' OS presentation: not observed by Notifai — Provider Acceptance and Companion Receipt do not prove a banner was shown');
804
+ const replyEvent = d.events.find((e) => e.stage === 'reply_received');
805
+ if (replyEvent) {
806
+ anyReplyReceived = true;
807
+ deps.io.out(` Reply received: yes (first at ${replyEvent.occurred_at})`);
808
+ }
809
+ else {
810
+ deps.io.out(' Reply received: not yet recorded on this delivery');
811
+ }
659
812
  for (const e of d.events) {
660
813
  deps.io.out(` ${e.occurred_at} ${e.stage}${e.reason ? ` (${e.reason})` : ''}`);
661
814
  }
662
815
  }
816
+ deps.io.out(anyReplyReceived
817
+ ? ` Reply wait: answers are on the server — collect with \`notifai replies ${snapshot.request_id}\` (a local wait fault does not erase them)`
818
+ : ` Reply wait: no answer stored yet — a blocking wait failure is independent of Delivery above; retry with \`notifai replies ${snapshot.request_id}\``);
663
819
  return EXIT.ok;
664
820
  }
665
821
  catch (err) {
@@ -722,6 +878,10 @@ async function uploadImage(deps, client, source) {
722
878
  // hook / ask / close — harness integration
723
879
  // ---------------------------------------------------------------------------
724
880
  export const HOOK_EVENTS = ['user-prompt-submit', 'stop', 'session-end'];
881
+ /** SessionEnd cleanup must precede every diagnostic that can wait on a file lock. */
882
+ export function hookDefersDiagnosticsUntilAfterCleanup(event) {
883
+ return event === 'session-end';
884
+ }
725
885
  /**
726
886
  * Runs one harness hook. Contract with every harness: hook JSON arrives on
727
887
  * stdin, the decision (if any) goes to stdout, diagnostics go to stderr, and
@@ -736,54 +896,128 @@ export async function hookRunCommand(deps, event, readStdin, harness) {
736
896
  deps.io.err(`Unknown hook event "${event}". Valid: ${HOOK_EVENTS.join(', ')}`);
737
897
  return EXIT.usage;
738
898
  }
739
- let envelope;
899
+ // One clock owns the complete Stop invocation, including stdin, config,
900
+ // credentials, and client construction. Starting this inside `handleStop`
901
+ // would grant slow setup a second budget and let the harness kill us before
902
+ // an accepted answer is journaled or written to stdout.
903
+ const now = deps.now ?? Date.now;
904
+ const processDeadlineAt = now() + HOOK_TIMING.totalSeconds * 1000;
905
+ const logger = log(deps);
906
+ logger.bind({ cmd: `hook ${event}` });
907
+ let started = false;
908
+ const start = (data = {}) => {
909
+ if (started)
910
+ return;
911
+ started = true;
912
+ logger.info('hook.start', { hook: event, harness: harness ?? 'unknown', ...data });
913
+ };
914
+ const failureData = (err) => err instanceof ApiCallError
915
+ ? { status: err.status, code: err.code, message: err.message, details: err.details }
916
+ : { message: err instanceof Error ? err.message : String(err) };
917
+ let raw;
740
918
  try {
741
- const raw = await readStdin();
742
- if (raw.trim() !== '') {
743
- try {
744
- const parsed = JSON.parse(raw);
745
- if (typeof parsed !== 'object' || parsed === null)
746
- throw new Error('not an object');
747
- }
748
- catch {
749
- deps.io.err('notifai: ignored malformed or truncated hook input; no routing action was taken');
750
- return EXIT.ok;
751
- }
919
+ raw = await readStdin();
920
+ }
921
+ catch (err) {
922
+ start({ input: 'unavailable' });
923
+ logger.error('hook.end', {
924
+ hook: event,
925
+ outcome: 'ignored',
926
+ reason: 'input-read-failed',
927
+ ...failureData(err),
928
+ });
929
+ return EXIT.ok;
930
+ }
931
+ if (raw.trim() !== '') {
932
+ try {
933
+ const parsed = JSON.parse(raw);
934
+ if (typeof parsed !== 'object' || parsed === null)
935
+ throw new Error('not an object');
752
936
  }
753
- envelope = parseHookInput(raw);
754
- if (harness === 'cursor') {
755
- const sessionId = envelope.session_id ?? envelope.conversation_id;
756
- const cwd = envelope.cwd ?? envelope.workspace_roots?.[0];
757
- envelope = {
758
- ...envelope,
759
- ...(sessionId === undefined ? {} : { session_id: sessionId }),
760
- ...(cwd === undefined ? {} : { cwd }),
761
- stop_hook_active: envelope.stop_hook_active ??
762
- (typeof envelope.loop_count === 'number' && envelope.loop_count > 0),
763
- };
937
+ catch {
938
+ start({ input: 'malformed' });
939
+ logger.error('hook.end', { hook: event, outcome: 'ignored', reason: 'malformed-input' });
940
+ deps.io.err('notifai: ignored malformed or truncated hook input; no routing action was taken');
941
+ return EXIT.ok;
764
942
  }
765
943
  }
766
- catch {
767
- return EXIT.ok;
944
+ let envelope = parseHookInput(raw);
945
+ if (harness === 'cursor') {
946
+ const sessionId = envelope.session_id ?? envelope.conversation_id;
947
+ const cwd = envelope.cwd ?? envelope.workspace_roots?.[0];
948
+ envelope = {
949
+ ...envelope,
950
+ ...(sessionId === undefined ? {} : { session_id: sessionId }),
951
+ ...(cwd === undefined ? {} : { cwd }),
952
+ stop_hook_active: envelope.stop_hook_active ??
953
+ (typeof envelope.loop_count === 'number' && envelope.loop_count > 0),
954
+ };
768
955
  }
769
- // Everything below is inside one fail-open boundary. Config parsing,
770
- // credential loading and client construction can all throw, and a hook that
771
- // exits non-zero makes the harness report a failure — strictly worse for the
772
- // user than not having installed the hook at all.
956
+ const cwd = envelope.cwd ?? deps.cwd;
957
+ const sessionEnd = hookDefersDiagnosticsUntilAfterCleanup(event);
958
+ logger.bind({ session: envelope.session_id ?? null });
959
+ let config = null;
960
+ let configFailure;
773
961
  try {
774
- if (event === 'session-end') {
962
+ config = loadConfig({ cwd, env: deps.env, sessionId: envelope.session_id });
963
+ // The hook's project is the session's, not this process's, and the log
964
+ // settings that apply are that project's too. Keeping a mutable bootstrap
965
+ // logger lets this more-specific layer turn logging back on.
966
+ logger.adopt(logSettingsFrom(config));
967
+ logger.bind({ project: config.project.value });
968
+ if (!sessionEnd) {
969
+ start({ cwd, stop_hook_active: envelope.stop_hook_active ?? null });
970
+ logConfigResolved(logger, config);
971
+ }
972
+ }
973
+ catch (err) {
974
+ configFailure = err;
975
+ if (!sessionEnd) {
976
+ start({ cwd, stop_hook_active: envelope.stop_hook_active ?? null });
977
+ logger.error('hook.end', {
978
+ hook: event,
979
+ outcome: 'failed',
980
+ reason: 'config-failed',
981
+ ...failureData(err),
982
+ });
983
+ for (const line of describeHookFailure(err))
984
+ deps.io.err(`notifai: ${line}`);
985
+ return EXIT.ok;
986
+ }
987
+ }
988
+ // Everything below is inside one fail-open boundary. Credential loading,
989
+ // client construction and hook handling can all throw, and a hook that exits
990
+ // non-zero makes the harness report a failure — strictly worse than skipping.
991
+ try {
992
+ if (sessionEnd) {
993
+ // Codex gives SessionEnd one second total. Do every durable cleanup write
994
+ // before lifecycle diagnostics: the log lock is deliberately allowed to
995
+ // wait that long, and a busy log must never preserve ended-session state
996
+ // or its inherited configuration. The resolved config above is retained
997
+ // in memory so logging still uses the ending session's settings afterwards.
775
998
  const outcome = handleSessionEnd(deps.env, envelope, (deps.now ?? Date.now)());
999
+ start({ cwd, stop_hook_active: envelope.stop_hook_active ?? null });
1000
+ if (config !== null)
1001
+ logConfigResolved(logger, config);
1002
+ const data = { hook: event, decided: false, ...outcome.log };
1003
+ if (configFailure === undefined)
1004
+ logger.info('hook.end', data);
1005
+ else {
1006
+ logger.error('hook.end', {
1007
+ ...data,
1008
+ reason: 'config-failed',
1009
+ config_error: failureData(configFailure),
1010
+ });
1011
+ }
776
1012
  for (const note of outcome.notes)
777
1013
  deps.io.err(`notifai: ${note}`);
778
1014
  return EXIT.ok;
779
1015
  }
780
- // Resolve config against the session's project rather than our own working
781
- // directory. `cwd` is in the payload precisely because which project a
782
- // session belongs to is the harness's business, not ours.
783
- const cwd = envelope.cwd ?? deps.cwd;
784
- const config = loadConfig({ cwd, env: deps.env, sessionId: envelope.session_id });
1016
+ // Non-SessionEnd hooks cannot reach here without resolved configuration.
1017
+ const resolved = config;
785
1018
  const credential = deps.store.load();
786
1019
  if (!credential) {
1020
+ logger.error('hook.end', { hook: event, outcome: 'not-paired' });
787
1021
  deps.io.err('notifai: hook skipped: this machine is not paired; run `notifai login`');
788
1022
  return EXIT.ok;
789
1023
  }
@@ -791,17 +1025,24 @@ export async function hookRunCommand(deps, event, readStdin, harness) {
791
1025
  // repository can commit `.notifai/config.toml`, and honouring a base_url
792
1026
  // from it would hand this machine's bearer token to whatever host it names.
793
1027
  const baseUrl = credential.baseUrl;
794
- if (config.base_url.source !== 'default' && config.base_url.value !== baseUrl) {
795
- deps.io.err(`notifai: ignoring base_url from ${config.base_url.source}; hooks only talk to ${baseUrl}`);
1028
+ if (resolved.base_url.source !== 'default' && resolved.base_url.value !== baseUrl) {
1029
+ deps.io.err(`notifai: ignoring base_url from ${resolved.base_url.source}; hooks only talk to ${baseUrl}`);
796
1030
  }
797
1031
  // UserPromptSubmit runs in front of the user's own prompt under a 15s
798
1032
  // harness ceiling and can make two calls, so each gets a small slice of it;
799
1033
  // Stop is allowed to block and keeps the ordinary budget.
800
- const client = makeClient(deps, baseUrl, `Bearer nfm_${credential.machineId}.${credential.secret}`, { timeoutMs: event === 'user-prompt-submit' ? 4_000 : 20_000 });
801
- const now = deps.now ?? Date.now;
1034
+ const client = makeClient(deps, baseUrl, `Bearer nfm_${credential.machineId}.${credential.secret}`, {
1035
+ timeoutMs: event === 'user-prompt-submit' ? 4_000 : 20_000,
1036
+ ...(event === 'stop'
1037
+ ? {
1038
+ deadlineAt: processDeadlineAt - HOOK_TIMING.stdoutReserveSeconds * 1000,
1039
+ now,
1040
+ }
1041
+ : {}),
1042
+ });
802
1043
  const ctx = {
803
1044
  client,
804
- config,
1045
+ config: resolved,
805
1046
  env: deps.env,
806
1047
  now,
807
1048
  idleSeconds: deps.idleSeconds ?? (() => readIdleSeconds()),
@@ -819,22 +1060,38 @@ export async function hookRunCommand(deps, event, readStdin, harness) {
819
1060
  degraded: result.degraded,
820
1061
  };
821
1062
  },
1063
+ log: logger,
822
1064
  ...(harness === undefined ? {} : { harness }),
823
1065
  };
824
1066
  // Real clock, deliberately, not `deps.now`. This compares against file
825
1067
  // mtimes, which are wall-clock facts — handing it a virtual or skewed clock
826
1068
  // would have it delete live session state as "abandoned".
827
- // Rate-limited to once a day by its own stamp file, so the common cost is
828
- // one stat on a hook that sits on the critical path of every turn.
829
- pruneAbandonedSessions(deps.env);
1069
+ // Daily state pruning is housekeeping, not part of the Stop delivery
1070
+ // contract. Its directory scan has no useful bound, so keep it on the
1071
+ // short prompt path and never spend the answer owner's finite budget on it.
1072
+ if (event !== 'stop')
1073
+ pruneAbandonedSessions(deps.env);
830
1074
  const outcome = event === 'user-prompt-submit'
831
1075
  ? await handleUserPromptSubmit(ctx, envelope)
832
- : await handleStop(ctx, envelope);
1076
+ : await handleStop(ctx, envelope, processDeadlineAt);
1077
+ // Answer diagnostics are already persisted once as hook.answer. Keep every
1078
+ // other note in the lifecycle record without duplicating the user's text.
1079
+ const notes = outcome.notes.filter((note) => !/^(?:late )?answer from /.test(note));
1080
+ logger.info('hook.end', {
1081
+ hook: event,
1082
+ // A hook that returns stdout has taken over the turn; one that does not
1083
+ // has handed the terminal back. That distinction is the whole contract.
1084
+ decided: outcome.stdout !== undefined,
1085
+ ...(notes.length === 0 ? {} : { notes }),
1086
+ ...outcome.log,
1087
+ });
833
1088
  for (const note of outcome.notes)
834
1089
  deps.io.err(`notifai: ${note}`);
835
1090
  if (outcome.stdout !== undefined) {
836
1091
  let stdout = outcome.stdout;
837
- if (harness === 'cursor' && event === 'stop') {
1092
+ if (harness !== undefined &&
1093
+ event === 'stop' &&
1094
+ HARNESS_CAPABILITIES[harness].stopContinuation === 'followup-message') {
838
1095
  const decision = JSON.parse(outcome.stdout);
839
1096
  if (decision.decision === 'block' && typeof decision.reason === 'string') {
840
1097
  stdout = JSON.stringify({ followup_message: decision.reason });
@@ -845,6 +1102,18 @@ export async function hookRunCommand(deps, event, readStdin, harness) {
845
1102
  return EXIT.ok;
846
1103
  }
847
1104
  catch (err) {
1105
+ // SessionEnd defers its start record until after cleanup; if cleanup itself
1106
+ // fails, begin the after-the-fact lifecycle here before recording why.
1107
+ if (sessionEnd)
1108
+ start({ cwd, stop_hook_active: envelope.stop_hook_active ?? null });
1109
+ // The hook still exits 0 — handing the terminal back is always right. What
1110
+ // this adds is that the reason survives, including the server's own words.
1111
+ logger.error('hook.end', {
1112
+ hook: event,
1113
+ outcome: 'failed',
1114
+ reason: 'execution-failed',
1115
+ ...failureData(err),
1116
+ });
848
1117
  for (const line of describeHookFailure(err))
849
1118
  deps.io.err(`notifai: ${line}`);
850
1119
  return EXIT.ok;
@@ -974,28 +1243,36 @@ function buildOneQuestion(text, choiceLabels, multi, index, usedIds) {
974
1243
  }
975
1244
  /**
976
1245
  * Registers a question for turn-end routing under the user's presence config.
977
- * Returns immediately so the agent can ask in prose and end its turn. With the
978
- * default presence gate, recent keyboard or mouse activity keeps it local;
979
- * `require_idle = false` intentionally permits a push while the user is active.
1246
+ * Returns immediately so the agent can ask in prose and end its turn. Presence
1247
+ * does not block the shipped default; `require_idle = true` intentionally keeps
1248
+ * the question local while the user is active.
980
1249
  */
981
1250
  export function askCommand(deps, question, flags) {
1251
+ // Validate before route discovery. A malformed question belongs to the
1252
+ // caller and should not be hidden behind whichever harness setup issue
1253
+ // happens to exist on this machine.
1254
+ const built = buildQuestions(flags, question);
1255
+ if (!built.ok) {
1256
+ deps.io.err(built.error);
1257
+ return EXIT.usage;
1258
+ }
1259
+ const routingConfig = loadConfig({ cwd: deps.cwd, env: deps.env });
1260
+ if (!routingConfig.ask_notifications.value) {
1261
+ deps.io.err('Question routing is disabled by ask_notifications=false; enable it or use a blocking `notifai send --reply` question.');
1262
+ return EXIT.usage;
1263
+ }
1264
+ if (deps.store.load() === null) {
1265
+ deps.io.err('Question routing is not paired on this machine; run `notifai login` before registering an asynchronous question.');
1266
+ return EXIT.usage;
1267
+ }
982
1268
  // An agent calling this gets no hook payload. Harness-native environment
983
- // markers identify the active owner, while the UserPromptSubmit hook leaves
984
- // a pointer keyed on the project directory with the hook's canonical id.
985
- // The pointer outranks the
986
- // NOTIFAI_SESSION fallback deliberately: the exported id is often a chosen
987
- // label rather than the harness's own id, and the hooks key state by the
988
- // latter — the env var is only trusted when no hook has spoken.
1269
+ // markers identify the active owner, while UserPromptSubmit adds the hook's
1270
+ // canonical id to the directory's concurrent-session index.
989
1271
  const now = (deps.now ?? Date.now)();
990
- const projectPointer = readProjectSessionPointer(deps.cwd, deps.env, now);
991
- const pointer = projectPointer?.sessionId ?? null;
992
1272
  const active = activeHarnessSession(deps.env);
993
1273
  let sessionId;
994
- if (flags.session !== undefined) {
995
- sessionId = flags.session;
996
- }
997
- else if (active !== null) {
998
- const installations = findInstallations(deps.cwd, deps.env);
1274
+ if (active !== null) {
1275
+ const installations = findInstallations(deps.cwd, deps.env, deps.hookAdapterHome);
999
1276
  const activeInstalled = installations.some((installation) => installation.harness === active.harness);
1000
1277
  if (!activeInstalled) {
1001
1278
  for (const line of diagnoseActiveHarnessSession(active, 'not-installed', installations)) {
@@ -1003,35 +1280,44 @@ export function askCommand(deps, question, flags) {
1003
1280
  }
1004
1281
  return EXIT.usage;
1005
1282
  }
1283
+ if (active.sessionId === undefined) {
1284
+ for (const problem of activeQuestionRouteProblems(deps, active, installations)) {
1285
+ deps.io.err(`Question routing is not ready: ${problem}`);
1286
+ }
1287
+ return EXIT.usage;
1288
+ }
1289
+ const projectPointer = readMatchingProjectSessionPointer(deps.cwd, deps.env, now, active.sessionId, active.harness);
1006
1290
  if (projectPointer === null) {
1007
1291
  for (const line of diagnoseActiveHarnessSession(active, 'not-fired', installations)) {
1008
1292
  deps.io.err(line);
1009
1293
  }
1010
1294
  return EXIT.usage;
1011
1295
  }
1012
- if (projectPointer.harness !== active.harness ||
1013
- (active.sessionId !== undefined && projectPointer.sessionId !== active.sessionId)) {
1014
- for (const line of diagnoseActiveHarnessSession(active, 'different-session', installations)) {
1015
- deps.io.err(line);
1016
- }
1296
+ if (flags.session !== undefined && flags.session !== active.sessionId) {
1297
+ deps.io.err(`Question routing is not ready: --session ${flags.session} does not match the exact active ${active.label} session ${active.sessionId}; refusing cross-session routing.`);
1298
+ return EXIT.usage;
1299
+ }
1300
+ const routeProblems = activeQuestionRouteProblems(deps, active, installations);
1301
+ if (routeProblems.length > 0) {
1302
+ for (const problem of routeProblems)
1303
+ deps.io.err(`Question routing is not ready: ${problem}`);
1304
+ return EXIT.usage;
1305
+ }
1306
+ if (readSessionState(projectPointer.sessionId, deps.env).last_stop_at === undefined) {
1307
+ 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\`.`);
1017
1308
  return EXIT.usage;
1018
1309
  }
1019
1310
  sessionId = projectPointer.sessionId;
1020
1311
  }
1021
1312
  else {
1022
- sessionId = pointer ?? deps.env['NOTIFAI_SESSION'] ?? undefined;
1023
- }
1024
- if (!sessionId) {
1025
1313
  for (const line of diagnoseMissingSession(deps))
1026
1314
  deps.io.err(line);
1315
+ 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.');
1027
1316
  return EXIT.usage;
1028
1317
  }
1029
- // Validate here, not at push time. The push happens inside a hook, where a
1030
- // rejection becomes a stderr note the agent never reads — so a malformed
1031
- // question set would look like it registered fine and then silently never ask.
1032
- const built = buildQuestions(flags, question);
1033
- if (!built.ok) {
1034
- deps.io.err(built.error);
1318
+ if (!sessionId) {
1319
+ for (const line of diagnoseMissingSession(deps))
1320
+ deps.io.err(line);
1035
1321
  return EXIT.usage;
1036
1322
  }
1037
1323
  try {
@@ -1042,9 +1328,20 @@ export function askCommand(deps, question, flags) {
1042
1328
  }, (deps.now ?? Date.now)());
1043
1329
  }
1044
1330
  catch (err) {
1331
+ log(deps).error('ask.registered', { ok: false, session: sessionId, message: String(err) });
1045
1332
  deps.io.err(`Could not register the question: ${err instanceof Error ? err.message : String(err)}`);
1046
1333
  return EXIT.failed;
1047
1334
  }
1335
+ // `ask` returns immediately and the push happens later inside a hook, so this
1336
+ // is the only local evidence that the question was ever registered — the
1337
+ // starting point for "the agent asked, and nothing ever reached my phone".
1338
+ log(deps).info('ask.registered', {
1339
+ ok: true,
1340
+ session: sessionId,
1341
+ questions: built.questions.length,
1342
+ text: built.questions[0].text,
1343
+ choices: built.questions[0].choices?.length ?? 0,
1344
+ });
1048
1345
  for (const [index, entry] of built.questions.entries()) {
1049
1346
  const prefix = built.questions.length > 1 ? `${index + 1}. ` : '';
1050
1347
  if (entry.choices !== undefined) {
@@ -1086,6 +1383,52 @@ function activeHarnessSession(env) {
1086
1383
  return { harness: 'cursor', label: 'Cursor' };
1087
1384
  return null;
1088
1385
  }
1386
+ /**
1387
+ * One fail-closed admission gate shared by every active harness route.
1388
+ * Finding a file is not readiness: the exact session, one current definition,
1389
+ * the stable adapter, a long enough Stop owner, and (for Codex) trust must all
1390
+ * be true before `ask` is allowed to create an answerable notification.
1391
+ */
1392
+ function activeQuestionRouteProblems(deps, active, installations) {
1393
+ const problems = [];
1394
+ if (active.sessionId === undefined) {
1395
+ 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`);
1396
+ }
1397
+ const capability = HARNESS_CAPABILITIES[active.harness];
1398
+ if (capability.stopContinuation === 'unsupported') {
1399
+ problems.push(`${active.label}: ${capability.deliveryContract}`);
1400
+ }
1401
+ const matching = installations.filter((installation) => installation.harness === active.harness);
1402
+ if (matching.length > 1) {
1403
+ problems.push(`${matching.length} ${active.label} definitions are active (${matching.map((entry) => entry.file).join(', ')}); keep either project or global routing`);
1404
+ }
1405
+ for (const installation of matching) {
1406
+ for (const problem of installation.problems ?? []) {
1407
+ problems.push(`${installation.file}: ${problem}`);
1408
+ }
1409
+ for (const handler of installation.handlers) {
1410
+ const event = handlerEvent(handler.command);
1411
+ if (event !== null && !HOOK_EVENTS.includes(event)) {
1412
+ problems.push(`${handler.event} in ${installation.file} names the unsupported event ${event}; reinstall the ${active.label} hooks`);
1413
+ }
1414
+ }
1415
+ }
1416
+ for (const problem of inspectHookAdapter(deps.hookAdapterHome).problems) {
1417
+ problems.push(problem);
1418
+ }
1419
+ if (active.harness !== 'codex') {
1420
+ const required = blockingHookTimeoutSeconds();
1421
+ for (const installation of matching) {
1422
+ for (const handler of installation.handlers.filter((entry) => handlerEvent(entry.command) === 'stop')) {
1423
+ if (handler.timeout === undefined || handler.timeout < required) {
1424
+ problems.push(`${installation.file} gives Stop ${handler.timeout ?? 'no'}s, but the live answer owner requires ${required}s`);
1425
+ }
1426
+ }
1427
+ }
1428
+ }
1429
+ problems.push(...codexTrustProblems(matching, deps.env));
1430
+ return [...new Set(problems)];
1431
+ }
1089
1432
  function diagnoseActiveHarnessSession(active, problem, installations) {
1090
1433
  if (problem === 'not-installed') {
1091
1434
  const others = installations.map((installation) => installation.harness);
@@ -1123,7 +1466,7 @@ function diagnoseActiveHarnessSession(active, problem, installations) {
1123
1466
  * checked after a prompt before assuming that a new session is required.
1124
1467
  */
1125
1468
  function diagnoseMissingSession(deps) {
1126
- const installations = findInstallations(deps.cwd, deps.env);
1469
+ const installations = findInstallations(deps.cwd, deps.env, deps.hookAdapterHome);
1127
1470
  if (installations.length === 0) {
1128
1471
  return [
1129
1472
  'Could not tell which harness session this is: no Notifai hooks are installed for this project.',
@@ -1135,7 +1478,7 @@ function diagnoseMissingSession(deps) {
1135
1478
  `Could not tell which harness session this is. Notifai hooks are installed (${where}),`,
1136
1479
  'but no usable session pointer from the last 24 hours exists here.',
1137
1480
  hookActivationAdvice(installations),
1138
- 'To ask from this session anyway, pass --session <id>.',
1481
+ 'Do not bypass this with a guessed session id; run from the exact active harness session or use a blocking `notifai send --reply` question.',
1139
1482
  ];
1140
1483
  }
1141
1484
  /** The least disruptive verified way to make each installed adapter run once. */
@@ -1158,13 +1501,13 @@ function hookActivationAdvice(installations) {
1158
1501
  advice.push('Codex: send one prompt, then run `notifai doctor`; start a new session only if it still has not fired');
1159
1502
  }
1160
1503
  if (harnesses.has('opencode')) {
1161
- advice.push('OpenCode: restart it, then send one prompt; plugins load at startup, and a device answer is delivered on the next prompt');
1504
+ advice.push('OpenCode: restart it, then send one prompt; plugins load at startup, but non-blocking question continuation is intentionally unsupported');
1162
1505
  }
1163
1506
  return `${advice.join('. ')}.`;
1164
1507
  }
1165
1508
  /** Retire a question so a late answer is rejected rather than silently lost. */
1166
1509
  export async function closeCommand(deps, requestId) {
1167
- const config = loadConfig({ cwd: deps.cwd, env: deps.env });
1510
+ const config = loadLoggedConfig(deps, { cwd: deps.cwd, env: deps.env });
1168
1511
  const authed = authedClient(deps, config);
1169
1512
  if (!authed)
1170
1513
  return EXIT.auth;
@@ -1183,35 +1526,36 @@ export function hooksInstallCommand(deps, flags) {
1183
1526
  return EXIT.usage;
1184
1527
  const execPath = flags.execPath ?? process.execPath;
1185
1528
  const scriptPath = flags.scriptPath ?? process.argv[1] ?? 'notifai';
1186
- const config = loadConfig({ cwd: deps.cwd, env: deps.env });
1529
+ let adapterPath;
1530
+ try {
1531
+ adapterPath = installHookAdapter({ execPath, scriptPath }, deps.hookAdapterHome).path;
1532
+ }
1533
+ catch (err) {
1534
+ deps.io.err(`Could not prepare the stable hook adapter: ${String(err)}`);
1535
+ return EXIT.failed;
1536
+ }
1537
+ const config = loadLoggedConfig(deps, { cwd: deps.cwd, env: deps.env });
1187
1538
  const file = settingsFile(harness, flags.global ?? false, deps.cwd, deps.env);
1188
1539
  // OpenCode's adapter is a generated plugin module rather than a handler
1189
1540
  // merged into a settings document, so it owns the whole file.
1190
1541
  if (harness === 'opencode') {
1191
1542
  return installOpencodePlugin(deps, file, {
1192
- execPath,
1193
- scriptPath,
1194
- timeoutSeconds: blockingHookTimeoutSeconds(config.ask_grace_seconds.value, config.hook_reply_timeout_seconds.value),
1543
+ adapterPath,
1544
+ timeoutSeconds: blockingHookTimeoutSeconds(),
1195
1545
  });
1196
1546
  }
1197
1547
  if (harness === 'cursor') {
1198
- let document;
1199
- try {
1200
- document = loadCursorSettings(file);
1201
- }
1202
- catch (err) {
1203
- deps.io.err(String(err));
1204
- return EXIT.failed;
1205
- }
1206
- const merged = mergeCursorHooks(document, buildCursorHookConfig({
1207
- execPath,
1208
- scriptPath,
1209
- replyTimeoutSeconds: config.hook_reply_timeout_seconds.value,
1210
- graceSeconds: config.ask_grace_seconds.value,
1211
- harness: 'cursor',
1212
- }), scriptPath);
1548
+ let merged;
1213
1549
  try {
1214
- applyPlan(file, merged.document);
1550
+ merged = withTargetFileLock(file, () => {
1551
+ const document = loadCursorSettings(file);
1552
+ const result = mergeCursorHooks(document, buildCursorHookConfig({
1553
+ adapterPath,
1554
+ harness: 'cursor',
1555
+ }), scriptPath);
1556
+ applyPlan(file, result.document);
1557
+ return result;
1558
+ });
1215
1559
  }
1216
1560
  catch (err) {
1217
1561
  deps.io.err(String(err));
@@ -1230,27 +1574,22 @@ export function hooksInstallCommand(deps, flags) {
1230
1574
  deps.io.out('Send one Cursor prompt, then check `notifai doctor`. If the hook has not fired,');
1231
1575
  deps.io.out('start a new Cursor session and try one prompt again.');
1232
1576
  }
1233
- deps.io.out('A companion-device answer is submitted as one follow-up user message.');
1234
- deps.io.out('loop_limit = 3 permits bounded chained questions without an unbounded loop.');
1577
+ deps.io.out('Cursor hooks can emit a native follow-up, but its agent shell does not expose');
1578
+ deps.io.out('the exact conversation id needed to register safely. `notifai ask` therefore');
1579
+ deps.io.out('fails closed; use blocking `notifai send --reply` for questions.');
1235
1580
  return EXIT.ok;
1236
1581
  }
1237
- let document;
1582
+ let merged;
1238
1583
  try {
1239
- document = loadSettings(file);
1240
- }
1241
- catch (err) {
1242
- deps.io.err(String(err));
1243
- return EXIT.failed;
1244
- }
1245
- const merged = mergeHooks(document, buildHookConfig({
1246
- execPath,
1247
- scriptPath,
1248
- replyTimeoutSeconds: config.hook_reply_timeout_seconds.value,
1249
- graceSeconds: config.ask_grace_seconds.value,
1250
- harness,
1251
- }), scriptPath);
1252
- try {
1253
- applyPlan(file, merged.document);
1584
+ merged = withTargetFileLock(file, () => {
1585
+ const document = loadSettings(file);
1586
+ const result = mergeHooks(document, buildHookConfig({
1587
+ adapterPath,
1588
+ harness,
1589
+ }), scriptPath);
1590
+ applyPlan(file, result.document);
1591
+ return result;
1592
+ });
1254
1593
  }
1255
1594
  catch (err) {
1256
1595
  deps.io.err(String(err));
@@ -1265,16 +1604,19 @@ export function hooksInstallCommand(deps, flags) {
1265
1604
  deps.io.out(` removed: ${merged.removed.join(', ')} (this build no longer serves them)`);
1266
1605
  }
1267
1606
  deps.io.out('');
1268
- deps.io.out(config.require_idle.value
1269
- ? `While keyboard or mouse idle time stays below ${config.away_after_seconds.value}s, ` +
1270
- `nothing is pushed. A question registered with \`notifai ask\` stays in the terminal ` +
1271
- `until its ${config.ask_grace_seconds.value}s grace window, counted from registration, ` +
1272
- `has elapsed; it goes to your devices only while the machine also meets the idle threshold. ` +
1273
- `Run \`notifai config set require_idle false\` to be notified even while you are working.`
1274
- : `A question registered with \`notifai ask\` stays in the terminal for ` +
1275
- `${config.ask_grace_seconds.value}s from registration and then goes to your devices ` +
1276
- `whether or not you are at this machine ` +
1277
- `(\`notifai config set require_idle false\` is already in effect).`);
1607
+ if (config.require_idle.value) {
1608
+ deps.io.out(`While keyboard or mouse idle time stays below ${config.away_after_seconds.value}s, ` +
1609
+ `nothing is pushed. A question registered with \`notifai ask\` goes to your devices ` +
1610
+ (config.ask_grace_seconds.value === 0
1611
+ ? 'as soon as the machine meets that idle threshold. '
1612
+ : `once its ${config.ask_grace_seconds.value}s grace window, counted from registration, has elapsed and the machine meets that idle threshold. `) +
1613
+ `Run \`notifai config set require_idle false\` to be notified even while you are working.`);
1614
+ }
1615
+ else {
1616
+ deps.io.out(config.ask_grace_seconds.value === 0
1617
+ ? 'A question registered with `notifai ask` goes to your devices immediately when the agent turn ends, whether or not you are at this machine.'
1618
+ : `A question registered with \`notifai ask\` stays in the terminal for ${config.ask_grace_seconds.value}s from registration and then goes to your devices whether or not you are at this machine.`);
1619
+ }
1278
1620
  if (config.require_idle.value) {
1279
1621
  deps.io.out('If this OS exposes no keyboard/mouse idle signal, the hook falls back to prompt silence and skips the blocking grace once it decides you are away.');
1280
1622
  }
@@ -1315,16 +1657,21 @@ export function hooksInstallCommand(deps, flags) {
1315
1657
  * the same reason command hooks are.
1316
1658
  */
1317
1659
  function installOpencodePlugin(deps, file, options) {
1318
- if (existsSync(file)) {
1319
- const existing = readFileSync(file, 'utf8');
1320
- if (!isOurOpencodePlugin(existing)) {
1321
- deps.io.err(`${file} exists and was not written by Notifai; move it aside first.`);
1322
- return EXIT.failed;
1323
- }
1324
- }
1325
1660
  try {
1326
- mkdirSync(path.dirname(file), { recursive: true });
1327
- writeFileSync(file, opencodePluginSource(options), { mode: 0o644 });
1661
+ withTargetFileLock(file, () => {
1662
+ if (existsSync(file)) {
1663
+ assertOwnedRegularFile(file);
1664
+ const existing = readFileSync(file, 'utf8');
1665
+ if (!isOurOpencodePlugin(existing)) {
1666
+ throw new Error(`${file} exists and was not written by Notifai; move it aside first.`);
1667
+ }
1668
+ }
1669
+ atomicWriteFileSync(file, opencodePluginSource(options), {
1670
+ mode: 0o600,
1671
+ preserveMode: false,
1672
+ requireCurrentUserOwner: true,
1673
+ });
1674
+ });
1328
1675
  }
1329
1676
  catch (err) {
1330
1677
  deps.io.err(String(err));
@@ -1335,9 +1682,9 @@ function installOpencodePlugin(deps, file, options) {
1335
1682
  deps.io.out('It maps chat.message to presence, session.idle to question escalation, and');
1336
1683
  deps.io.out('session.deleted to local cleanup through the same `notifai hook` commands');
1337
1684
  deps.io.out('the other harnesses run. Permission prompts stay in OpenCode.');
1338
- deps.io.out('OpenCode pushes without holding its idle event open; the next user prompt');
1339
- deps.io.out('collects a device answer and adds it to the agent context. Use `notifai');
1340
- deps.io.out('send --reply` only when the answer must return before another prompt.');
1685
+ deps.io.out('OpenCode does not expose a proven exactly-once continuation after session.idle,');
1686
+ deps.io.out('so `notifai ask` fails closed there. Use blocking `notifai send --reply` when');
1687
+ deps.io.out('the answer must return to the agent without another human prompt.');
1341
1688
  deps.io.out('');
1342
1689
  deps.io.out('Restart OpenCode: it loads plugins once at start.');
1343
1690
  return EXIT.ok;
@@ -1348,63 +1695,88 @@ export function hooksUninstallCommand(deps, flags) {
1348
1695
  return EXIT.usage;
1349
1696
  const scriptPath = flags.scriptPath ?? process.argv[1] ?? 'notifai';
1350
1697
  const file = settingsFile(harness, flags.global ?? false, deps.cwd, deps.env);
1351
- if (!existsSync(file)) {
1352
- deps.io.out(`Nothing to remove: ${file} does not exist.`);
1353
- return EXIT.ok;
1354
- }
1355
1698
  if (harness === 'opencode') {
1356
- // We own the whole file, but only if we wrote it.
1357
- if (!isOurOpencodePlugin(readFileSync(file, 'utf8'))) {
1358
- deps.io.out(`Left ${file} alone: Notifai did not write it.`);
1359
- return EXIT.ok;
1360
- }
1361
- rmSync(file, { force: true });
1362
- deps.io.out(`Removed the Notifai OpenCode plugin at ${file}`);
1363
- return EXIT.ok;
1364
- }
1365
- if (harness === 'cursor') {
1366
- let document;
1367
1699
  try {
1368
- document = loadCursorSettings(file);
1700
+ return withTargetFileLock(file, () => {
1701
+ if (!existsSync(file)) {
1702
+ deps.io.out(`Nothing to remove: ${file} does not exist.`);
1703
+ return EXIT.ok;
1704
+ }
1705
+ assertOwnedRegularFile(file);
1706
+ // We own the whole file, but only if we wrote it.
1707
+ if (!isOurOpencodePlugin(readFileSync(file, 'utf8'))) {
1708
+ deps.io.out(`Left ${file} alone: Notifai did not write it.`);
1709
+ return EXIT.ok;
1710
+ }
1711
+ rmSync(file, { force: true });
1712
+ deps.io.out(`Removed the Notifai OpenCode plugin at ${file}`);
1713
+ return EXIT.ok;
1714
+ });
1369
1715
  }
1370
1716
  catch (err) {
1371
1717
  deps.io.err(String(err));
1372
1718
  return EXIT.failed;
1373
1719
  }
1374
- const stripped = removeCursorHooks(document, scriptPath);
1720
+ }
1721
+ if (harness === 'cursor') {
1722
+ let stripped;
1375
1723
  try {
1376
- applyPlan(file, stripped.document);
1724
+ stripped = withTargetFileLock(file, () => {
1725
+ if (!existsSync(file))
1726
+ return null;
1727
+ const document = loadCursorSettings(file);
1728
+ const result = removeCursorHooks(document, scriptPath);
1729
+ applyPlan(file, result.document);
1730
+ return result;
1731
+ });
1377
1732
  }
1378
1733
  catch (err) {
1379
1734
  deps.io.err(String(err));
1380
1735
  return EXIT.failed;
1381
1736
  }
1737
+ if (stripped === null) {
1738
+ deps.io.out(`Nothing to remove: ${file} does not exist.`);
1739
+ return EXIT.ok;
1740
+ }
1382
1741
  deps.io.out(stripped.replaced.length > 0
1383
1742
  ? `Removed Notifai hooks (${stripped.replaced.join(', ')}) from ${file}`
1384
1743
  : `No Notifai hooks found in ${file}`);
1385
1744
  return EXIT.ok;
1386
1745
  }
1387
- let document;
1746
+ let stripped;
1388
1747
  try {
1389
- document = loadSettings(file);
1748
+ stripped = withTargetFileLock(file, () => {
1749
+ if (!existsSync(file))
1750
+ return null;
1751
+ const document = loadSettings(file);
1752
+ const result = removeHooks(document, scriptPath);
1753
+ applyPlan(file, result.document);
1754
+ return result;
1755
+ });
1390
1756
  }
1391
1757
  catch (err) {
1392
1758
  deps.io.err(String(err));
1393
1759
  return EXIT.failed;
1394
1760
  }
1395
- const stripped = removeHooks(document, scriptPath);
1396
- try {
1397
- applyPlan(file, stripped.document);
1398
- }
1399
- catch (err) {
1400
- deps.io.err(String(err));
1401
- return EXIT.failed;
1761
+ if (stripped === null) {
1762
+ deps.io.out(`Nothing to remove: ${file} does not exist.`);
1763
+ return EXIT.ok;
1402
1764
  }
1403
1765
  deps.io.out(stripped.replaced.length > 0
1404
1766
  ? `Removed Notifai hooks (${stripped.replaced.join(', ')}) from ${file}`
1405
1767
  : `No Notifai hooks found in ${file}`);
1406
1768
  return EXIT.ok;
1407
1769
  }
1770
+ function assertOwnedRegularFile(file) {
1771
+ const stat = lstatSync(file);
1772
+ if (stat.isSymbolicLink() || !stat.isFile()) {
1773
+ throw new Error(`${file} is not a regular file; refusing to read or replace it.`);
1774
+ }
1775
+ const uid = typeof process.getuid === 'function' ? process.getuid() : undefined;
1776
+ if (uid !== undefined && stat.uid !== uid) {
1777
+ throw new Error(`${file} is owned by uid ${stat.uid}, not the current user.`);
1778
+ }
1779
+ }
1408
1780
  function resolveHarness(deps, requested) {
1409
1781
  if (requested !== undefined) {
1410
1782
  if (HARNESSES.includes(requested))
@@ -1420,10 +1792,10 @@ function resolveHarness(deps, requested) {
1420
1792
  return detected;
1421
1793
  }
1422
1794
  // ---------------------------------------------------------------------------
1423
- // config show / set
1795
+ // config show / set / unset
1424
1796
  // ---------------------------------------------------------------------------
1425
1797
  export function configShowCommand(deps, flags) {
1426
- const config = loadConfig({ cwd: deps.cwd, env: deps.env });
1798
+ const config = loadLoggedConfig(deps, { cwd: deps.cwd, env: deps.env });
1427
1799
  if (flags.json) {
1428
1800
  const output = Object.fromEntries(CONFIG_KEYS.map((key) => [key, { value: config[key].value, source: config[key].source }]));
1429
1801
  deps.io.out(JSON.stringify(output, null, 2));
@@ -1445,9 +1817,10 @@ export function configShowCommand(deps, flags) {
1445
1817
  /**
1446
1818
  * One setting, explained in full.
1447
1819
  *
1448
- * The gap this closes: `config show` prints `require_idle = true` and there was
1449
- * nowhere to go from there. Every key already had a careful explanation — in a
1450
- * TypeScript comment, read by everyone except the person who needed it.
1820
+ * The gap this closes: `config show` prints a value and provenance but that is
1821
+ * not enough to explain the setting's consequences. Every key already had a
1822
+ * careful explanation — in a TypeScript comment, read by everyone except the
1823
+ * person who needed it.
1451
1824
  */
1452
1825
  export function configExplainCommand(deps, key, flags = {}) {
1453
1826
  if (!CONFIG_KEYS.includes(key)) {
@@ -1456,7 +1829,7 @@ export function configExplainCommand(deps, key, flags = {}) {
1456
1829
  return EXIT.usage;
1457
1830
  }
1458
1831
  const configKey = key;
1459
- const config = loadConfig({ cwd: deps.cwd, env: deps.env });
1832
+ const config = loadLoggedConfig(deps, { cwd: deps.cwd, env: deps.env });
1460
1833
  const info = configInfo(configKey);
1461
1834
  const entry = config[configKey];
1462
1835
  if (flags.json) {
@@ -1517,16 +1890,9 @@ function editDistance(a, b) {
1517
1890
  return previous[b.length];
1518
1891
  }
1519
1892
  export async function configSetCommand(deps, key, rawValue, flags) {
1520
- if (!CONFIG_KEYS.includes(key)) {
1521
- deps.io.err(`Unknown setting "${key}".`);
1522
- const near = nearestKey(key);
1523
- if (near !== null)
1524
- deps.io.err(`Did you mean "${near}"?`);
1525
- deps.io.err(`Valid settings: ${CONFIG_KEYS.join(', ')}`);
1526
- deps.io.err('Run `notifai config explain <key>` to see what one of them does.');
1893
+ const configKey = configMutationKey(deps, key);
1894
+ if (configKey === null)
1527
1895
  return EXIT.usage;
1528
- }
1529
- const configKey = key;
1530
1896
  const info = configInfo(configKey);
1531
1897
  let value = rawValue;
1532
1898
  if (NUMERIC_CONFIG_KEYS.includes(configKey)) {
@@ -1558,6 +1924,43 @@ export async function configSetCommand(deps, key, rawValue, flags) {
1558
1924
  }
1559
1925
  if (key === 'devices')
1560
1926
  value = rawValue.split(',').map((s) => s.trim()).filter(Boolean);
1927
+ const target = await configMutationTarget(deps, flags);
1928
+ if (target === null)
1929
+ return EXIT.usage;
1930
+ if (target.layer === 'global' && Object.is(value, configDefaultValue(configKey))) {
1931
+ deps.io.err(`${key} is already the shipped default (${JSON.stringify(value)}).`);
1932
+ deps.io.err(`Run \`notifai config unset ${key} --yes\` to remove a redundant override instead of creating one.`);
1933
+ return EXIT.usage;
1934
+ }
1935
+ if (!flags.yes) {
1936
+ const confirmed = await deps.io.confirm(`Set ${key} = ${JSON.stringify(value)} in ${target.path}?`);
1937
+ if (!confirmed) {
1938
+ deps.io.err('Not confirmed. Pass --yes to skip the confirmation gate.');
1939
+ return EXIT.usage;
1940
+ }
1941
+ }
1942
+ const existing = existsSync(target.path)
1943
+ ? parseToml(readFileSync(target.path, 'utf8'))
1944
+ : {};
1945
+ existing[key] = value;
1946
+ mkdirSync(path.dirname(target.path), { recursive: true });
1947
+ writeFileSync(target.path, `${stringifyToml(existing)}\n`);
1948
+ deps.io.out(`Wrote ${key} to ${target.path}`);
1949
+ return EXIT.ok;
1950
+ }
1951
+ function configMutationKey(deps, key) {
1952
+ if (!CONFIG_KEYS.includes(key)) {
1953
+ deps.io.err(`Unknown setting "${key}".`);
1954
+ const near = nearestKey(key);
1955
+ if (near !== null)
1956
+ deps.io.err(`Did you mean "${near}"?`);
1957
+ deps.io.err(`Valid settings: ${CONFIG_KEYS.join(', ')}`);
1958
+ deps.io.err('Run `notifai config explain <key>` to see what one of them does.');
1959
+ return null;
1960
+ }
1961
+ return key;
1962
+ }
1963
+ async function configMutationTarget(deps, flags) {
1561
1964
  let layer = flags.local ? 'local' : flags.project ? 'project' : 'global';
1562
1965
  if (flags.session === undefined &&
1563
1966
  flags.local !== true &&
@@ -1572,7 +1975,7 @@ export async function configSetCommand(deps, key, rawValue, flags) {
1572
1975
  ]);
1573
1976
  if (selected === null) {
1574
1977
  deps.io.err('No configuration layer selected.');
1575
- return EXIT.usage;
1978
+ return null;
1576
1979
  }
1577
1980
  layer = selected;
1578
1981
  }
@@ -1583,20 +1986,236 @@ export async function configSetCommand(deps, key, rawValue, flags) {
1583
1986
  : layer === 'project'
1584
1987
  ? (findProjectConfigPath(deps.cwd) ?? path.join(deps.cwd, '.notifai', 'config.toml'))
1585
1988
  : globalConfigPath(deps.env);
1989
+ return {
1990
+ path: targetPath,
1991
+ layer: flags.session ? 'session' : layer,
1992
+ };
1993
+ }
1994
+ export async function configUnsetCommand(deps, key, flags) {
1995
+ const configKey = configMutationKey(deps, key);
1996
+ if (configKey === null)
1997
+ return EXIT.usage;
1998
+ const target = await configMutationTarget(deps, flags);
1999
+ if (target === null)
2000
+ return EXIT.usage;
2001
+ const existing = existsSync(target.path)
2002
+ ? parseToml(readFileSync(target.path, 'utf8'))
2003
+ : {};
2004
+ if (!Object.prototype.hasOwnProperty.call(existing, configKey)) {
2005
+ deps.io.out(`${configKey} is not set in ${target.path}`);
2006
+ return EXIT.ok;
2007
+ }
1586
2008
  if (!flags.yes) {
1587
- const confirmed = await deps.io.confirm(`Set ${key} = ${JSON.stringify(value)} in ${targetPath}?`);
2009
+ const confirmed = await deps.io.confirm(`Remove ${configKey} from ${target.path}?`);
1588
2010
  if (!confirmed) {
1589
2011
  deps.io.err('Not confirmed. Pass --yes to skip the confirmation gate.');
1590
2012
  return EXIT.usage;
1591
2013
  }
1592
2014
  }
1593
- const existing = existsSync(targetPath)
1594
- ? parseToml(readFileSync(targetPath, 'utf8'))
1595
- : {};
1596
- existing[key] = value;
1597
- mkdirSync(path.dirname(targetPath), { recursive: true });
1598
- writeFileSync(targetPath, `${stringifyToml(existing)}\n`);
1599
- deps.io.out(`Wrote ${key} to ${targetPath}`);
2015
+ delete existing[configKey];
2016
+ if (Object.keys(existing).length === 0) {
2017
+ rmSync(target.path, { force: true });
2018
+ }
2019
+ else {
2020
+ writeFileSync(target.path, `${stringifyToml(existing)}\n`);
2021
+ }
2022
+ deps.io.out(`Removed ${configKey} from ${target.path}; the inherited value now applies.`);
2023
+ return EXIT.ok;
2024
+ }
2025
+ const LOG_RECORD_OPTIONS = [
2026
+ ['limit', '--limit'],
2027
+ ['all', '--all'],
2028
+ ['since', '--since'],
2029
+ ['level', '--level'],
2030
+ ['event', '--event'],
2031
+ ['run', '--run'],
2032
+ ['request', '--request'],
2033
+ ['session', '--session'],
2034
+ ['project', '--project'],
2035
+ ['allProjects', '--all-projects'],
2036
+ ['grep', '--grep'],
2037
+ ];
2038
+ /**
2039
+ * Deliberately small. The reader is usually a model with a finite context, and
2040
+ * a log command whose default answer is ten thousand lines gets used once.
2041
+ * Everything beyond this is reachable by asking for it.
2042
+ */
2043
+ const DEFAULT_LOG_LIMIT = 30;
2044
+ /** Even `--all` stops somewhere; an unbounded dump is never the useful answer. */
2045
+ const MAX_LOG_LIMIT = 2_000;
2046
+ /** `10m`, `2h`, `1d`, or an ISO 8601 instant. */
2047
+ export function parseSince(raw, now) {
2048
+ const relative = /^(\d+)([smhd])$/.exec(raw.trim());
2049
+ if (relative !== null) {
2050
+ const amount = Number(relative[1]);
2051
+ const unit = relative[2];
2052
+ const seconds = { s: 1, m: 60, h: 3600, d: 86400 }[unit];
2053
+ return now - amount * seconds * 1000;
2054
+ }
2055
+ const absolute = Date.parse(raw);
2056
+ return Number.isNaN(absolute) ? null : absolute;
2057
+ }
2058
+ /**
2059
+ * What this machine recorded about itself.
2060
+ *
2061
+ * The retrieval half of the local log, and the half that decides whether the
2062
+ * log is worth having. Three things make it usable by an agent rather than
2063
+ * merely available to one: the default is bounded and scoped to this project,
2064
+ * the filters match the questions actually asked ("that run", "that
2065
+ * notification", "just the failures"), and `--json` gives the machine the
2066
+ * records untouched on stdout while every word of explanation goes to stderr.
2067
+ */
2068
+ export function logsCommand(deps, flags) {
2069
+ const selectedRetrievalOptions = LOG_RECORD_OPTIONS
2070
+ .filter(([key]) => {
2071
+ const value = flags[key];
2072
+ return Array.isArray(value) ? value.length > 0 : value !== undefined && value !== false;
2073
+ })
2074
+ .map(([, name]) => name);
2075
+ if (flags.path === true && flags.clear === true) {
2076
+ deps.io.err('Pass --path or --clear, not both.');
2077
+ return EXIT.usage;
2078
+ }
2079
+ if (flags.path === true && selectedRetrievalOptions.length > 0) {
2080
+ deps.io.err(`--path cannot be combined with record options: ${selectedRetrievalOptions.join(', ')}.`);
2081
+ return EXIT.usage;
2082
+ }
2083
+ if (flags.clear === true && selectedRetrievalOptions.length > 0) {
2084
+ deps.io.err(`--clear cannot be combined with record options: ${selectedRetrievalOptions.join(', ')}.`);
2085
+ return EXIT.usage;
2086
+ }
2087
+ if (flags.project !== undefined && flags.allProjects === true) {
2088
+ deps.io.err('Pass --project or --all-projects, not both.');
2089
+ return EXIT.usage;
2090
+ }
2091
+ if (flags.limit !== undefined && flags.all === true) {
2092
+ deps.io.err('Pass --limit or --all, not both.');
2093
+ return EXIT.usage;
2094
+ }
2095
+ if (flags.limit !== undefined && (!Number.isInteger(flags.limit) || flags.limit <= 0)) {
2096
+ deps.io.err('--limit must be a positive integer.');
2097
+ return EXIT.usage;
2098
+ }
2099
+ if (flags.limit !== undefined && flags.limit > MAX_LOG_LIMIT) {
2100
+ deps.io.err(`--limit cannot exceed ${MAX_LOG_LIMIT}; narrow the result with --since or filters.`);
2101
+ return EXIT.usage;
2102
+ }
2103
+ const config = loadLoggedConfig(deps, { cwd: deps.cwd, env: deps.env });
2104
+ const now = (deps.now ?? Date.now)();
2105
+ if (flags.path === true) {
2106
+ const usage = logsDiskUsage(deps.env);
2107
+ const archives = archiveLogPaths(deps.env);
2108
+ if (flags.json === true) {
2109
+ deps.io.out(JSON.stringify({
2110
+ active: activeLogPath(deps.env),
2111
+ archives,
2112
+ files: usage.files,
2113
+ bytes: usage.bytes,
2114
+ level: config.log_level.value,
2115
+ max_bytes: config.log_max_bytes.value,
2116
+ max_files: config.log_max_files.value,
2117
+ local_only: true,
2118
+ }, null, 2));
2119
+ return EXIT.ok;
2120
+ }
2121
+ deps.io.out(activeLogPath(deps.env));
2122
+ for (const archive of archives)
2123
+ deps.io.out(archive);
2124
+ deps.io.err(`${usage.files} file(s), ${Math.round(usage.bytes / 1024)} KB, level ${config.log_level.value}. These stay on this machine.`);
2125
+ return EXIT.ok;
2126
+ }
2127
+ if (flags.clear === true) {
2128
+ let removed = 0;
2129
+ for (const file of [activeLogPath(deps.env), ...archiveLogPaths(deps.env)]) {
2130
+ try {
2131
+ unlinkSync(file);
2132
+ removed += 1;
2133
+ }
2134
+ catch {
2135
+ // Absent, or not ours to remove; either way there is nothing to report.
2136
+ }
2137
+ }
2138
+ deps.io.out(flags.json === true
2139
+ ? JSON.stringify({ cleared_files: removed })
2140
+ : `Cleared ${removed} log file${removed === 1 ? '' : 's'}.`);
2141
+ return EXIT.ok;
2142
+ }
2143
+ const query = {
2144
+ limit: flags.limit ?? (flags.all === true ? MAX_LOG_LIMIT : DEFAULT_LOG_LIMIT),
2145
+ };
2146
+ if (flags.since !== undefined) {
2147
+ const since = parseSince(flags.since, now);
2148
+ if (since === null) {
2149
+ deps.io.err(`Could not read "${flags.since}" as a time. Use 10m, 2h, 1d, or an ISO 8601 instant.`);
2150
+ return EXIT.usage;
2151
+ }
2152
+ query.since = since;
2153
+ }
2154
+ if (flags.level !== undefined) {
2155
+ if (!LOG_LEVELS.includes(flags.level)) {
2156
+ deps.io.err(`--level takes one of: ${LOG_LEVELS.join(', ')} — not "${flags.level}".`);
2157
+ return EXIT.usage;
2158
+ }
2159
+ query.level = flags.level;
2160
+ }
2161
+ if (flags.event !== undefined && flags.event.length > 0) {
2162
+ const unknown = flags.event.filter((name) => !LOG_EVENTS.includes(name));
2163
+ if (unknown.length > 0) {
2164
+ deps.io.err(`Unknown event ${unknown.join(', ')}. Valid: ${LOG_EVENTS.join(', ')}`);
2165
+ return EXIT.usage;
2166
+ }
2167
+ query.event = flags.event;
2168
+ }
2169
+ if (flags.run !== undefined)
2170
+ query.run = flags.run;
2171
+ if (flags.session !== undefined)
2172
+ query.session = flags.session;
2173
+ if (flags.request !== undefined)
2174
+ query.request = flags.request;
2175
+ if (flags.grep !== undefined)
2176
+ query.contains = flags.grep;
2177
+ // Scoped to this project by default. A machine commonly runs several agents
2178
+ // in several worktrees at once, and the answer to "what just happened" is
2179
+ // useless if three other projects are interleaved through it.
2180
+ const scope = flags.project ?? (flags.allProjects === true ? undefined : (config.project.value ?? undefined));
2181
+ if (scope !== undefined)
2182
+ query.project = scope;
2183
+ const result = readLogRecords(deps.env, query);
2184
+ if (flags.json === true) {
2185
+ for (const record of result.records)
2186
+ deps.io.out(JSON.stringify(record));
2187
+ }
2188
+ else {
2189
+ for (const record of result.records)
2190
+ deps.io.out(renderRecord(record));
2191
+ }
2192
+ if (result.records.length === 0) {
2193
+ if (config.log_level.value === 'off') {
2194
+ deps.io.err('Nothing is being recorded: log_level is off.');
2195
+ deps.io.err('Turn it on with `notifai config set log_level info --yes`.');
2196
+ }
2197
+ else if (logsDiskUsage(deps.env).files === 0) {
2198
+ deps.io.err('No log yet. It is written the next time a notifai command or harness hook runs.');
2199
+ }
2200
+ else {
2201
+ deps.io.err('No records matched. Widen it with --all-projects, --since 1d, or --all.');
2202
+ }
2203
+ return EXIT.ok;
2204
+ }
2205
+ // Everything explanatory goes to stderr so `--json` leaves stdout as clean
2206
+ // JSONL for whatever is parsing it.
2207
+ const notes = [];
2208
+ if (scope !== undefined)
2209
+ notes.push(`project ${scope} (--all-projects for every project)`);
2210
+ if (result.more) {
2211
+ notes.push(query.limit < MAX_LOG_LIMIT
2212
+ ? `more history behind this (-n ${Math.min(query.limit * 4, MAX_LOG_LIMIT)} or --all)`
2213
+ : `more history exists beyond the ${MAX_LOG_LIMIT}-record safety cap; narrow with --since or filters`);
2214
+ }
2215
+ if (config.log_level.value !== 'debug')
2216
+ notes.push('`log_level = debug` records request detail');
2217
+ if (notes.length > 0)
2218
+ deps.io.err(`— ${result.records.length} records; ${notes.join('; ')}`);
1600
2219
  return EXIT.ok;
1601
2220
  }
1602
2221
  // ---------------------------------------------------------------------------
@@ -1607,7 +2226,7 @@ export async function configSetCommand(deps, key, rawValue, flags) {
1607
2226
  * 1.5.x, `owner/repo@name` selects a skill; a Git ref belongs after `#`.
1608
2227
  * Keep this immutable and public because the command is printed to users.
1609
2228
  */
1610
- export const SKILLS_SOURCE = 'Raidiant-io/notifai#v0.3.0';
2229
+ export const SKILLS_SOURCE = 'Raidiant-io/notifai#v0.4.0';
1611
2230
  function skillSourceParts() {
1612
2231
  const match = /^([^#]+)#(.+)$/.exec(SKILLS_SOURCE);
1613
2232
  return match === null ? null : { source: match[1], ref: match[2] };
@@ -1832,7 +2451,7 @@ async function waitForReadyDevice(deps, state) {
1832
2451
  const remedy = state.remedy;
1833
2452
  if (deps.io.interactive !== true || remedy?.by !== 'user-elsewhere')
1834
2453
  return 'pending';
1835
- const config = loadConfig({ cwd: deps.cwd, env: deps.env });
2454
+ const config = loadLoggedConfig(deps, { cwd: deps.cwd, env: deps.env });
1836
2455
  const authed = authedClient(deps, config);
1837
2456
  if (!authed) {
1838
2457
  deps.io.err('Could not start the companion-device wait: this machine is not signed in.');
@@ -1950,7 +2569,7 @@ async function submitSetupProof(deps, client, config, device) {
1950
2569
  * the push, not that a companion process received it.
1951
2570
  */
1952
2571
  async function runSetupProof(deps) {
1953
- const config = loadConfig({ cwd: deps.cwd, env: deps.env });
2572
+ const config = loadLoggedConfig(deps, { cwd: deps.cwd, env: deps.env });
1954
2573
  const authed = authedClient(deps, config);
1955
2574
  if (!authed)
1956
2575
  return 'failed';
@@ -2283,7 +2902,7 @@ async function contractCheck(client) {
2283
2902
  * looking like a broken install.
2284
2903
  */
2285
2904
  export async function assessReadiness(deps, options = {}) {
2286
- const config = loadConfig({ cwd: deps.cwd, env: deps.env });
2905
+ const config = loadLoggedConfig(deps, { cwd: deps.cwd, env: deps.env });
2287
2906
  const states = [];
2288
2907
  let accountClient = null;
2289
2908
  let accountDevices = null;
@@ -2639,23 +3258,21 @@ function remedyLine(state) {
2639
3258
  *
2640
3259
  * Not everything failed is in the way. A pointer that has never been
2641
3260
  * published is the normal condition of an install thirty seconds old — the
2642
- * next prompt fixes it and no command can — and OpenCode's inability to
2643
- * resume an idle turn is a property of that harness rather than a fault in
2644
- * this setup. Treating those as blocking would mean `init` could never
2645
- * finish for an OpenCode user, or could only finish after a session had
2646
- * already run — so they report as things worth knowing rather than things to
3261
+ * next prompt fixes it and no command can. Treating that as blocking would
3262
+ * mean `init` could only finish after a session had already run — so it
3263
+ * reports as something worth knowing rather than something to
2647
3264
  * fix, and `init` walks on to the states it can actually close, delivery
2648
3265
  * proof included. Which failures are informational, and what the true remedy
2649
3266
  * is, is each check's own call (`HookCheck`).
2650
3267
  */
2651
3268
  function hookStates(deps) {
2652
- const installations = findInstallations(deps.cwd, deps.env);
3269
+ const installations = findInstallations(deps.cwd, deps.env, deps.hookAdapterHome);
2653
3270
  const active = activeHarnessSession(deps.env);
2654
- const config = loadConfig({ cwd: deps.cwd, env: deps.env });
3271
+ const config = loadLoggedConfig(deps, { cwd: deps.cwd, env: deps.env });
2655
3272
  const settings = {
2656
3273
  id: 'question-routing-settings',
2657
3274
  title: 'Question routing settings',
2658
- status: 'ready',
3275
+ status: config.ask_notifications.value ? 'ready' : 'gap',
2659
3276
  detail: [
2660
3277
  `ask_notifications=${config.ask_notifications.value} (${config.ask_notifications.source})`,
2661
3278
  `require_idle=${config.require_idle.value} (${config.require_idle.source})`,
@@ -2663,6 +3280,15 @@ function hookStates(deps) {
2663
3280
  `ask_grace_seconds=${config.ask_grace_seconds.value} (${config.ask_grace_seconds.source})`,
2664
3281
  `hook_reply_timeout_seconds=${config.hook_reply_timeout_seconds.value} (${config.hook_reply_timeout_seconds.source})`,
2665
3282
  ].join(', '),
3283
+ ...(config.ask_notifications.value
3284
+ ? {}
3285
+ : {
3286
+ remedy: {
3287
+ by: 'cli',
3288
+ summary: 'enable asynchronous question routing',
3289
+ command: 'notifai config set ask_notifications true',
3290
+ },
3291
+ }),
2666
3292
  };
2667
3293
  if (installations.length === 0) {
2668
3294
  return [
@@ -2685,7 +3311,7 @@ function hookStates(deps) {
2685
3311
  ];
2686
3312
  }
2687
3313
  /** Real but not in the way; see the note above. */
2688
- const informational = new Set(['hooks (opencode continuation)']);
3314
+ const informational = new Set();
2689
3315
  return [
2690
3316
  ...hookChecks(deps).map((check) => ({
2691
3317
  id: check.name.replace(/[ ()]+/g, '-').replace(/-$/, ''),
@@ -2718,7 +3344,7 @@ function hookStates(deps) {
2718
3344
  }
2719
3345
  function hookChecks(deps) {
2720
3346
  const checks = [];
2721
- const installations = findInstallations(deps.cwd, deps.env);
3347
+ const installations = findInstallations(deps.cwd, deps.env, deps.hookAdapterHome);
2722
3348
  // Not having hooks is a setup someone chose, not a fault: `send` works
2723
3349
  // without them. A setup that cannot work is what deserves to go red.
2724
3350
  if (installations.length === 0) {
@@ -2757,7 +3383,9 @@ function hookChecks(deps) {
2757
3383
  }),
2758
3384
  });
2759
3385
  if (activeInstallations.length > 0) {
2760
- const pointer = readProjectSessionPointer(deps.cwd, deps.env, (deps.now ?? Date.now)());
3386
+ const pointer = active.sessionId === undefined
3387
+ ? null
3388
+ : readMatchingProjectSessionPointer(deps.cwd, deps.env, (deps.now ?? Date.now)(), active.sessionId, active.harness);
2761
3389
  if (pointer === null) {
2762
3390
  // The normal condition of hooks installed moments ago: the pointer
2763
3391
  // appears when the harness next fires a hook, and no command can
@@ -2775,22 +3403,10 @@ function hookChecks(deps) {
2775
3403
  });
2776
3404
  }
2777
3405
  else {
2778
- const matches = pointer.harness === active.harness &&
2779
- (active.sessionId === undefined || pointer.sessionId === active.sessionId);
2780
3406
  checks.push({
2781
3407
  name: 'hooks (active session)',
2782
- ok: matches,
2783
- detail: matches
2784
- ? `the project pointer belongs to the active ${active.label} session`
2785
- : `the project pointer belongs to another ${active.label} session or harness; refusing cross-session routing`,
2786
- ...(matches
2787
- ? {}
2788
- : {
2789
- remedy: {
2790
- summary: `send one ${active.label} prompt in this session so the pointer belongs to it`,
2791
- command: 'notifai doctor',
2792
- },
2793
- }),
3408
+ ok: true,
3409
+ detail: `the concurrent project index contains the active ${active.label} session`,
2794
3410
  });
2795
3411
  }
2796
3412
  }
@@ -2811,16 +3427,37 @@ function hookChecks(deps) {
2811
3427
  : `${stale.join('; ')} — rerun \`notifai hooks install\` to drop ${stale.length === 1 ? 'it' : 'them'}`,
2812
3428
  });
2813
3429
  const adapterProblems = installations.flatMap((installation) => (installation.problems ?? []).map((problem) => `${installation.file}: ${problem}`));
3430
+ const sharedAdapter = inspectHookAdapter(deps.hookAdapterHome);
3431
+ adapterProblems.push(...sharedAdapter.problems);
2814
3432
  if (adapterProblems.length > 0) {
2815
3433
  checks.push({
2816
3434
  name: 'hooks (adapter)',
2817
3435
  ok: false,
3436
+ // A machine-global install for a harness that is not active in this
3437
+ // project is useful diagnosis, but it must not block unrelated init.
3438
+ informational: active === null && installations.every((installation) => installation.global),
2818
3439
  detail: adapterProblems.join('; '),
2819
3440
  });
2820
3441
  }
2821
- const config = loadConfig({ cwd: deps.cwd, env: deps.env });
2822
- const requiredTimeout = blockingHookTimeoutSeconds(config.ask_grace_seconds.value, config.hook_reply_timeout_seconds.value);
3442
+ const trustProblems = codexTrustProblems(installations, deps.env);
3443
+ checks.push({
3444
+ name: 'hooks (trust)',
3445
+ ok: trustProblems.length === 0,
3446
+ detail: trustProblems.length === 0
3447
+ ? 'best-effort check matches current persisted Codex approvals; Notifai never writes the trust store, and `/hooks` is authoritative'
3448
+ : `best-effort check only; Notifai never writes the trust store and \`/hooks\` is authoritative. ${trustProblems.join('; ')}`,
3449
+ ...(trustProblems.length === 0
3450
+ ? {}
3451
+ : {
3452
+ remedy: {
3453
+ summary: 'open `/hooks` in Codex and approve the changed Notifai handlers',
3454
+ command: '/hooks',
3455
+ },
3456
+ }),
3457
+ });
3458
+ const requiredTimeout = blockingHookTimeoutSeconds();
2823
3459
  const shortTimeouts = installations.flatMap((installation) => installation.handlers
3460
+ .filter(() => installation.harness !== 'codex')
2824
3461
  .filter((handler) => handlerEvent(handler.command) === 'stop')
2825
3462
  .filter((handler) => handler.timeout === undefined || handler.timeout < requiredTimeout)
2826
3463
  .map((handler) => `${installation.harness} declares ${handler.timeout ?? 'no'}s, needs ${requiredTimeout}s — run \`notifai hooks install --harness ${installation.harness}${installation.global ? ' --global' : ''}\``));
@@ -2828,60 +3465,85 @@ function hookChecks(deps) {
2828
3465
  name: 'hooks (timeout)',
2829
3466
  ok: shortTimeouts.length === 0,
2830
3467
  detail: shortTimeouts.length === 0
2831
- ? `installed Stop timeouts cover the current ${requiredTimeout}s grace/reply budget`
3468
+ ? `declared Stop timeouts match the fixed ${requiredTimeout}s process budget where required; Codex uses host defaults`
2832
3469
  : shortTimeouts.join('; '),
2833
3470
  });
2834
- // Two checkouts each installing hooks means both fire for the same event, and
2835
- // the user gets every question twice.
2836
- //
2837
- // Compared *within* a harness, not across. Only one harness runs a given
2838
- // session, so having Claude Code and OpenCode both set up is the ordinary
2839
- // case and not a duplicate — comparing them turned a healthy machine red.
3471
+ // Project and global definitions for one harness both fire. Stable adapter
3472
+ // identity deliberately makes their command bytes equal, so comparing
3473
+ // command targets would now hide this duplicate rather than diagnose it.
3474
+ // Different harnesses remain independent: only the active one runs.
2840
3475
  const duplicated = [...new Set(installations.map((i) => i.harness))]
2841
3476
  .map((harness) => ({
2842
3477
  harness,
2843
- scripts: new Set(installations
2844
- .filter((i) => i.harness === harness)
2845
- .flatMap((i) => i.handlers.map((h) => h.command.split(' hook ')[0] ?? ''))),
3478
+ installations: installations.filter((i) => i.harness === harness),
2846
3479
  }))
2847
- .filter((entry) => entry.scripts.size > 1);
3480
+ .filter((entry) => entry.installations.length > 1);
2848
3481
  if (duplicated.length > 0) {
2849
3482
  checks.push({
2850
3483
  name: 'hooks (duplicates)',
2851
3484
  ok: false,
2852
3485
  detail: duplicated
2853
- .map((entry) => `${entry.harness}: ${entry.scripts.size} different Notifai builds are installed, so each event will fire all of them. Uninstall the ones you do not want: ${[...entry.scripts].join(', ')}`)
3486
+ .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(', ')}`)
2854
3487
  .join('; '),
2855
3488
  });
2856
3489
  }
2857
- const firedPointer = readProjectSessionPointer(deps.cwd, deps.env, (deps.now ?? Date.now)());
2858
- const fired = firedPointer !== null;
3490
+ if (active !== null && activeInstallations.length > 0) {
3491
+ const admissionProblems = activeQuestionRouteProblems(deps, active, installations);
3492
+ checks.push({
3493
+ name: 'hooks (question admission)',
3494
+ ok: admissionProblems.length === 0,
3495
+ detail: admissionProblems.length === 0
3496
+ ? `the active ${active.label} route is exact, current, singular, trusted where applicable, and bounded by a live owner`
3497
+ : admissionProblems.join('; '),
3498
+ });
3499
+ }
3500
+ const firedPointer = active?.sessionId === undefined
3501
+ ? readProjectSessionPointer(deps.cwd, deps.env, (deps.now ?? Date.now)())
3502
+ : readMatchingProjectSessionPointer(deps.cwd, deps.env, (deps.now ?? Date.now)(), active.sessionId, active.harness);
3503
+ const firedState = firedPointer === null ? null : readSessionState(firedPointer.sessionId, deps.env);
3504
+ const promptFired = firedState?.last_prompt_at !== undefined;
3505
+ const stopFired = firedState?.last_stop_at !== undefined;
3506
+ const fired = firedPointer !== null && promptFired && stopFired;
2859
3507
  const activationInstallations = active !== null && activeInstallations.length > 0 ? activeInstallations : installations;
2860
3508
  checks.push({
2861
3509
  name: 'hooks (fired)',
2862
3510
  ok: fired,
2863
- // Never-fired is evidence about the past, not a fault in the setup, and
2864
- // the cure is a prompt no command can send.
2865
- informational: true,
3511
+ // A wholly fresh install is informational. Once UserPromptSubmit has fired,
3512
+ // a missing Stop is a broken route, not missing historical evidence.
3513
+ informational: firedPointer === null,
2866
3514
  detail: fired
2867
- ? 'a session in this directory has run them'
2868
- : `no session pointer from the last 24 hours — ${hookActivationAdvice(activationInstallations)}`,
3515
+ ? 'a session in this directory has run UserPromptSubmit and Stop'
3516
+ : firedPointer === null
3517
+ ? `no session pointer from the last 24 hours — ${hookActivationAdvice(activationInstallations)}`
3518
+ : `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`,
2869
3519
  ...(fired
2870
3520
  ? {}
2871
3521
  : {
2872
3522
  remedy: {
2873
- summary: 'send one prompt in a session here, then re-check',
3523
+ summary: firedPointer === null
3524
+ ? 'send one prompt in a session here, then re-check'
3525
+ : 'end one harmless turn, send a new prompt, then re-check',
2874
3526
  command: 'notifai doctor',
2875
3527
  },
2876
3528
  }),
2877
3529
  });
2878
- if (installations.some((installation) => installation.harness === 'opencode')) {
2879
- checks.push({
2880
- name: 'hooks (opencode continuation)',
2881
- ok: false,
2882
- detail: 'question routing is installed; OpenCode cannot resume an idle turn automatically, but its next user prompt collects the answer',
2883
- });
2884
- }
3530
+ const continuationInstallations = active !== null ? activeInstallations : installations;
3531
+ const continuationHarnesses = [
3532
+ ...new Set(continuationInstallations.map((installation) => installation.harness)),
3533
+ ];
3534
+ checks.push({
3535
+ name: 'hooks (answer continuation)',
3536
+ ok: continuationHarnesses.length > 0 &&
3537
+ continuationHarnesses.every((harness) => HARNESS_CAPABILITIES[harness].stopContinuation !== 'unsupported'),
3538
+ informational: active === null,
3539
+ detail: continuationHarnesses.length === 0
3540
+ ? active === null
3541
+ ? 'no installed harness route to assess'
3542
+ : `the active ${active.label} session has no matching continuation adapter`
3543
+ : continuationHarnesses
3544
+ .map((harness) => `${harness}: ${HARNESS_CAPABILITIES[harness].deliveryContract}`)
3545
+ .join('; '),
3546
+ });
2885
3547
  const stray = codexStrayWorktreeCheck(deps);
2886
3548
  if (stray !== null)
2887
3549
  checks.push(stray);