@raidiant/notifai 0.4.0 → 0.5.1

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 (62) hide show
  1. package/README.md +3 -4
  2. package/dist/claude-wake.d.ts +101 -0
  3. package/dist/claude-wake.js +397 -0
  4. package/dist/claude-wake.js.map +1 -0
  5. package/dist/codex-wake.d.ts +84 -0
  6. package/dist/codex-wake.js +238 -0
  7. package/dist/codex-wake.js.map +1 -0
  8. package/dist/commands.d.ts +15 -10
  9. package/dist/commands.js +321 -88
  10. package/dist/commands.js.map +1 -1
  11. package/dist/config-schema.d.ts +2 -2
  12. package/dist/config-schema.js +9 -35
  13. package/dist/config-schema.js.map +1 -1
  14. package/dist/config.d.ts +5 -38
  15. package/dist/config.js +11 -32
  16. package/dist/config.js.map +1 -1
  17. package/dist/harnesses.d.ts +3 -13
  18. package/dist/harnesses.js +6 -17
  19. package/dist/harnesses.js.map +1 -1
  20. package/dist/hooks.d.ts +119 -65
  21. package/dist/hooks.js +330 -285
  22. package/dist/hooks.js.map +1 -1
  23. package/dist/install-hooks.d.ts +22 -7
  24. package/dist/install-hooks.js +43 -28
  25. package/dist/install-hooks.js.map +1 -1
  26. package/dist/interactive.d.ts +1 -1
  27. package/dist/interactive.js +2 -4
  28. package/dist/interactive.js.map +1 -1
  29. package/dist/main.js +6 -13
  30. package/dist/main.js.map +1 -1
  31. package/dist/release.d.ts +31 -0
  32. package/dist/release.js +52 -0
  33. package/dist/release.js.map +1 -0
  34. package/dist/ui/theme.d.ts +1 -1
  35. package/package.json +9 -9
  36. package/src/claude-wake.test.ts +320 -0
  37. package/src/claude-wake.ts +489 -0
  38. package/src/codex-wake.test.ts +253 -0
  39. package/src/codex-wake.ts +288 -0
  40. package/src/commands.test.ts +393 -40
  41. package/src/commands.ts +433 -109
  42. package/src/config-schema.test.ts +3 -3
  43. package/src/config-schema.ts +11 -40
  44. package/src/config.test.ts +21 -16
  45. package/src/config.ts +15 -69
  46. package/src/harnesses.test.ts +7 -21
  47. package/src/harnesses.ts +17 -19
  48. package/src/hooks.test.ts +780 -290
  49. package/src/hooks.ts +466 -339
  50. package/src/install-hooks.test.ts +44 -3
  51. package/src/install-hooks.ts +43 -28
  52. package/src/interactive.ts +2 -7
  53. package/src/logging.test.ts +3 -3
  54. package/src/main.ts +6 -12
  55. package/src/release.ts +51 -0
  56. package/src/skill.test.ts +23 -0
  57. package/src/ui/config-view.test.ts +5 -5
  58. package/dist/idle.d.ts +0 -16
  59. package/dist/idle.js +0 -44
  60. package/dist/idle.js.map +0 -1
  61. package/src/idle.test.ts +0 -32
  62. package/src/idle.ts +0 -41
package/dist/commands.js CHANGED
@@ -9,15 +9,17 @@ import { sha256Hex } from '@raidiant/notifai-protocol/node';
9
9
  import { ApiCallError, NetworkError, createClient, isRetryableReplyPollError, } from './client.js';
10
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 { skillsSource } from './release.js';
12
13
  import { atomicWriteFileSync } from './atomic-file.js';
13
14
  import { withTargetFileLock } from './file-lock.js';
14
15
  import { renderConfigExplain, renderConfigList, renderConfigPlain, } from './ui/config-view.js';
15
16
  import { firstBlocker, openItems, } from './readiness.js';
16
- import { handleSessionEnd, handleStop, handleUserPromptSubmit, parseHookInput, pruneAbandonedSessions, readMatchingProjectSessionPointer, readProjectSession, readProjectSessionPointer, readSessionState, registerQuestion, } from './hooks.js';
17
- import { readIdleSeconds } from './idle.js';
17
+ import { WAITER_CEILING_SECONDS, handleSessionEnd, handleStop, handleUserPromptSubmit, parseHookInput, pruneAbandonedSessions, readLiveProjectSessionPointers, readMatchingProjectSessionPointer, readProjectSession, readProjectSessionPointer, readSessionState, registerQuestion, } from './hooks.js';
18
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';
19
+ import { BLOCKING_STOP_TIMEOUT_SECONDS, CLAUDE_ASYNC_STOP_TIMEOUT_SECONDS, HARNESSES, applyPlan, buildCursorHookConfig, buildHookConfig, codexLayerDir, codexTrustProblems, codexProjectRoot, detectHarness, findInstallations, handlerEvent, loadCursorSettings, loadSettings, mergeCursorHooks, mergeHooks, removeCursorHooks, removeHooks, settingsFile, } from './install-hooks.js';
20
+ import { HARNESS_CAPABILITIES } from './harnesses.js';
21
+ import { claudeWakeRoute, inspectClaudeInbox, systemClaudeWakeAdapters, } from './claude-wake.js';
22
+ import { codexWakeRoute, inspectCodexResume, } from './codex-wake.js';
21
23
  import { inspectHookAdapter, installHookAdapter, } from './hook-adapter.js';
22
24
  import { isOurOpencodePlugin, opencodePluginSource, } from './opencode-plugin.js';
23
25
  import { CHOICE_USAGE, buildDraft, formatReceipt, parseChoices, receiptExitCode, slugify, } from './send.js';
@@ -901,7 +903,7 @@ export async function hookRunCommand(deps, event, readStdin, harness) {
901
903
  // would grant slow setup a second budget and let the harness kill us before
902
904
  // an accepted answer is journaled or written to stdout.
903
905
  const now = deps.now ?? Date.now;
904
- const processDeadlineAt = now() + HOOK_TIMING.totalSeconds * 1000;
906
+ const processDeadlineAt = now() + WAITER_CEILING_SECONDS * 1000;
905
907
  const logger = log(deps);
906
908
  logger.bind({ cmd: `hook ${event}` });
907
909
  let started = false;
@@ -1033,19 +1035,13 @@ export async function hookRunCommand(deps, event, readStdin, harness) {
1033
1035
  // Stop is allowed to block and keeps the ordinary budget.
1034
1036
  const client = makeClient(deps, baseUrl, `Bearer nfm_${credential.machineId}.${credential.secret}`, {
1035
1037
  timeoutMs: event === 'user-prompt-submit' ? 4_000 : 20_000,
1036
- ...(event === 'stop'
1037
- ? {
1038
- deadlineAt: processDeadlineAt - HOOK_TIMING.stdoutReserveSeconds * 1000,
1039
- now,
1040
- }
1041
- : {}),
1038
+ ...(event === 'stop' ? { deadlineAt: processDeadlineAt, now } : {}),
1042
1039
  });
1043
1040
  const ctx = {
1044
1041
  client,
1045
1042
  config: resolved,
1046
1043
  env: deps.env,
1047
1044
  now,
1048
- idleSeconds: deps.idleSeconds ?? (() => readIdleSeconds()),
1049
1045
  sleep: deps.sleep ?? ((ms) => new Promise((resolve) => setTimeout(resolve, ms))),
1050
1046
  waitForFirstReply: async (requestId, timeoutSeconds) => {
1051
1047
  const result = await waitForReply(client, requestId, {
@@ -1073,7 +1069,7 @@ export async function hookRunCommand(deps, event, readStdin, harness) {
1073
1069
  pruneAbandonedSessions(deps.env);
1074
1070
  const outcome = event === 'user-prompt-submit'
1075
1071
  ? await handleUserPromptSubmit(ctx, envelope)
1076
- : await handleStop(ctx, envelope, processDeadlineAt);
1072
+ : await handleStop(ctx, envelope, processDeadlineAt, stopWakeRoute(deps, harness, envelope.session_id, cwd));
1077
1073
  // Answer diagnostics are already persisted once as hook.answer. Keep every
1078
1074
  // other note in the lifecycle record without duplicating the user's text.
1079
1075
  const notes = outcome.notes.filter((note) => !/^(?:late )?answer from /.test(note));
@@ -1119,6 +1115,55 @@ export async function hookRunCommand(deps, event, readStdin, harness) {
1119
1115
  return EXIT.ok;
1120
1116
  }
1121
1117
  }
1118
+ /**
1119
+ * The last meter for an answer this Stop hook accepted, chosen by harness.
1120
+ *
1121
+ * Both wake adapters need the harness process that invoked this hook: Claude's
1122
+ * to prove exact own-child session ownership before it posts to the inbox
1123
+ * socket, Codex's to know whether its own stdout is still a live continuation
1124
+ * channel. Without an exact session id neither can prove anything, so the
1125
+ * waiter falls back to the plain blocking Stop continuation.
1126
+ */
1127
+ function stopWakeRoute(deps, harness, sessionId, cwd) {
1128
+ if (sessionId === undefined)
1129
+ return undefined;
1130
+ if (harness === 'claude-code') {
1131
+ return claudeWakeRoute({
1132
+ sessionId,
1133
+ cwd,
1134
+ sourcePid: deps.claudeSourcePid ?? claudeSessionPid(deps.env),
1135
+ ...(deps.claudeWake === undefined ? {} : { adapters: deps.claudeWake }),
1136
+ });
1137
+ }
1138
+ if (harness === 'codex') {
1139
+ return codexWakeRoute({
1140
+ threadId: sessionId,
1141
+ cwd,
1142
+ sourcePid: deps.codexSourcePid ?? process.ppid,
1143
+ env: deps.env,
1144
+ ...(deps.codexWake === undefined ? {} : { adapters: deps.codexWake }),
1145
+ });
1146
+ }
1147
+ return undefined;
1148
+ }
1149
+ /**
1150
+ * The pid of the Claude Code session this hook belongs to.
1151
+ *
1152
+ * Claude exports `CLAUDE_PID` to its hooks, and that is the authoritative
1153
+ * answer. `process.ppid` agrees with it today — the hook command runs through
1154
+ * a shell, but the shell `exec`s, so this process's parent *is* the session
1155
+ * (probed against a live 2.1.228 session) — and it remains the fallback for a
1156
+ * harness build that stops exporting the variable.
1157
+ *
1158
+ * Preferring the explicit value costs a line and removes a silent failure: if
1159
+ * this ever resolved to a shell instead, no session descriptor would match, the
1160
+ * route could not prove own-child ownership, and every answer would degrade to
1161
+ * hold-for-next-turn with nothing reported as wrong.
1162
+ */
1163
+ function claudeSessionPid(env) {
1164
+ const declared = Number(env['CLAUDE_PID']);
1165
+ return Number.isInteger(declared) && declared > 0 ? declared : process.ppid;
1166
+ }
1122
1167
  /**
1123
1168
  * What went wrong, in terms of what to do about it.
1124
1169
  *
@@ -1242,10 +1287,9 @@ function buildOneQuestion(text, choiceLabels, multi, index, usedIds) {
1242
1287
  };
1243
1288
  }
1244
1289
  /**
1245
- * Registers a question for turn-end routing under the user's presence config.
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.
1290
+ * Registers a question for turn-end routing. Returns immediately so the agent
1291
+ * can ask in prose and end its turn; the terminal keeps the question to itself
1292
+ * for `ask_grace_seconds` before it reaches any device.
1249
1293
  */
1250
1294
  export function askCommand(deps, question, flags) {
1251
1295
  // Validate before route discovery. A malformed question belongs to the
@@ -1269,13 +1313,13 @@ export function askCommand(deps, question, flags) {
1269
1313
  // markers identify the active owner, while UserPromptSubmit adds the hook's
1270
1314
  // canonical id to the directory's concurrent-session index.
1271
1315
  const now = (deps.now ?? Date.now)();
1272
- const active = activeHarnessSession(deps.env);
1316
+ const { active, contested } = resolveActiveHarness(deps.env, deps.cwd, now);
1273
1317
  let sessionId;
1274
1318
  if (active !== null) {
1275
1319
  const installations = findInstallations(deps.cwd, deps.env, deps.hookAdapterHome);
1276
1320
  const activeInstalled = installations.some((installation) => installation.harness === active.harness);
1277
1321
  if (!activeInstalled) {
1278
- for (const line of diagnoseActiveHarnessSession(active, 'not-installed', installations)) {
1322
+ for (const line of diagnoseActiveHarnessSession(active, 'not-installed', installations, contested)) {
1279
1323
  deps.io.err(line);
1280
1324
  }
1281
1325
  return EXIT.usage;
@@ -1288,7 +1332,7 @@ export function askCommand(deps, question, flags) {
1288
1332
  }
1289
1333
  const projectPointer = readMatchingProjectSessionPointer(deps.cwd, deps.env, now, active.sessionId, active.harness);
1290
1334
  if (projectPointer === null) {
1291
- for (const line of diagnoseActiveHarnessSession(active, 'not-fired', installations)) {
1335
+ for (const line of diagnoseActiveHarnessSession(active, 'not-fired', installations, contested)) {
1292
1336
  deps.io.err(line);
1293
1337
  }
1294
1338
  return EXIT.usage;
@@ -1353,35 +1397,96 @@ export function askCommand(deps, question, flags) {
1353
1397
  }
1354
1398
  }
1355
1399
  deps.io.out(built.questions.length > 1
1356
- ? `${built.questions.length} questions registered as one form. Ask them in the conversation as usual and end your turn.`
1357
- : 'Question registered. Ask it in the conversation as usual and end your turn.');
1400
+ ? `${built.questions.length} questions registered as one form. Ask them in the conversation, state the concrete work you will resume for their answers, then end your turn.`
1401
+ : 'Question registered. Ask it in the conversation, state the concrete work you will resume when the answer arrives, then end your turn.');
1402
+ deps.io.out('Before ending this turn, pre-commit in your own words to the work you will resume:');
1403
+ for (const [index, entry] of built.questions.entries()) {
1404
+ const questionPrefix = built.questions.length > 1 ? `Question ${index + 1}, ` : '';
1405
+ if (entry.choices !== undefined) {
1406
+ for (const choice of entry.choices) {
1407
+ deps.io.out(`- ${questionPrefix}If the answer is ${JSON.stringify(choice.label)}: state the concrete work you will resume.`);
1408
+ }
1409
+ deps.io.out(`- ${questionPrefix}For an unexpected typed answer: state how it will determine the concrete work you resume.`);
1410
+ }
1411
+ else {
1412
+ deps.io.out(`- ${questionPrefix}For the free-text answer: state how its content will determine the concrete work you resume.`);
1413
+ }
1414
+ }
1415
+ deps.io.out('When the answer arrives, resume the matching work without asking the user to confirm again. Frame this as work you will resume, not as approval you receive.');
1416
+ deps.io.out('A Notifai answer cannot answer a harness permission prompt or interactive picker; leave those to the harness and user.');
1358
1417
  return EXIT.ok;
1359
1418
  }
1360
- function activeHarnessSession(env) {
1361
- const explicit = env['NOTIFAI_ACTIVE_HARNESS'];
1362
- if (explicit === 'opencode') {
1419
+ /**
1420
+ * Every harness marker present in this environment, in declared order.
1421
+ *
1422
+ * Order here is a last resort, not an answer. A harness exports its markers
1423
+ * into every process it starts, so a nested harness inherits its parent's
1424
+ * markers alongside its own and the environment alone cannot say which of them
1425
+ * owns this shell; `resolveActiveHarness` settles that with live evidence.
1426
+ */
1427
+ function harnessEnvCandidates(env) {
1428
+ const candidates = [];
1429
+ if (env['NOTIFAI_ACTIVE_HARNESS'] === 'opencode') {
1363
1430
  const sessionId = env['NOTIFAI_ACTIVE_SESSION_ID'];
1364
- return {
1431
+ candidates.push({
1365
1432
  harness: 'opencode',
1366
1433
  label: 'OpenCode',
1367
1434
  ...(sessionId === undefined || sessionId === '' ? {} : { sessionId }),
1368
- };
1435
+ });
1369
1436
  }
1370
1437
  if (env['CLAUDECODE'] === '1') {
1371
1438
  const sessionId = env['CLAUDE_CODE_SESSION_ID'];
1372
- return {
1439
+ candidates.push({
1373
1440
  harness: 'claude-code',
1374
1441
  label: 'Claude Code',
1375
1442
  ...(sessionId === undefined || sessionId === '' ? {} : { sessionId }),
1376
- };
1443
+ });
1377
1444
  }
1378
1445
  const codexSession = env['CODEX_THREAD_ID'];
1379
1446
  if (codexSession !== undefined && codexSession !== '') {
1380
- return { harness: 'codex', label: 'Codex', sessionId: codexSession };
1447
+ candidates.push({ harness: 'codex', label: 'Codex', sessionId: codexSession });
1381
1448
  }
1382
1449
  if ((env['CURSOR_AGENT'] ?? '') !== '')
1383
- return { harness: 'cursor', label: 'Cursor' };
1384
- return null;
1450
+ candidates.push({ harness: 'cursor', label: 'Cursor' });
1451
+ return candidates;
1452
+ }
1453
+ /**
1454
+ * Which harness session owns this shell, when several claim to.
1455
+ *
1456
+ * Nesting is ordinary: an orchestrator running inside Claude Code starts a
1457
+ * Codex session, and that Codex process inherits `CLAUDECODE` and
1458
+ * `CLAUDE_CODE_SESSION_ID` on top of its own `CODEX_THREAD_ID`. The mirror is
1459
+ * just as ordinary, so no fixed precedence between two markers can be right —
1460
+ * whichever one it favours is wrong in the opposite nesting, and the cost is
1461
+ * silent: `ask` looks up a session that is not this one, and every remedy the
1462
+ * agent is told to try addresses a harness that is not running here.
1463
+ *
1464
+ * The general rule is to prefer the most specific *live* signal over inherited
1465
+ * environment. An inherited marker is a claim about some ancestor process; a
1466
+ * session id that names an entry in this directory's live pointer index is
1467
+ * evidence that that exact session fired a hook here and its state still
1468
+ * exists. Live evidence therefore wins over declared order, and the most
1469
+ * recently active pointer wins between two live candidates: the harness whose
1470
+ * turn is running is the one that fired last, while its parent sits blocked on
1471
+ * the child it started. Declared order decides only when nothing here has
1472
+ * fired yet, and it says so — every route fails closed there anyway.
1473
+ */
1474
+ function resolveActiveHarness(env, cwd, now) {
1475
+ const candidates = harnessEnvCandidates(env);
1476
+ const first = candidates[0];
1477
+ if (first === undefined)
1478
+ return { active: null, contested: [] };
1479
+ if (candidates.length === 1)
1480
+ return { active: first, contested: [] };
1481
+ for (const pointer of readLiveProjectSessionPointers(cwd, env, now)) {
1482
+ const owner = candidates.find((candidate) => candidate.harness === pointer.harness && candidate.sessionId === pointer.sessionId);
1483
+ if (owner !== undefined)
1484
+ return { active: owner, contested: [] };
1485
+ }
1486
+ return { active: first, contested: candidates };
1487
+ }
1488
+ function activeHarnessSession(env, cwd, now) {
1489
+ return resolveActiveHarness(env, cwd, now).active;
1385
1490
  }
1386
1491
  /**
1387
1492
  * One fail-closed admission gate shared by every active harness route.
@@ -1416,24 +1521,25 @@ function activeQuestionRouteProblems(deps, active, installations) {
1416
1521
  for (const problem of inspectHookAdapter(deps.hookAdapterHome).problems) {
1417
1522
  problems.push(problem);
1418
1523
  }
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
- }
1524
+ for (const installation of matching)
1525
+ problems.push(...stopShapeProblems(installation));
1429
1526
  problems.push(...codexTrustProblems(matching, deps.env));
1430
1527
  return [...new Set(problems)];
1431
1528
  }
1432
- function diagnoseActiveHarnessSession(active, problem, installations) {
1529
+ function diagnoseActiveHarnessSession(active, problem, installations, contested = []) {
1530
+ // Naming one harness confidently is wrong when the environment carries the
1531
+ // markers of several and none of them has fired here: whichever one the
1532
+ // agent is told to prompt may not be the one it is running in.
1533
+ const ambiguity = contested.length > 1
1534
+ ? [
1535
+ `Several harness markers are present here (${contested.map((candidate) => candidate.label).join(', ')}) and none names a session that has fired in this directory, so ${active.label} is a guess from the environment. Send the prompt in the harness you are actually running.`,
1536
+ ]
1537
+ : [];
1433
1538
  if (problem === 'not-installed') {
1434
1539
  const others = installations.map((installation) => installation.harness);
1435
1540
  return [
1436
1541
  `Could not register the question for the active ${active.label} session: Notifai ${active.label} hooks are not installed for this project.`,
1542
+ ...ambiguity,
1437
1543
  ...(others.length === 0
1438
1544
  ? []
1439
1545
  : [
@@ -1446,12 +1552,14 @@ function diagnoseActiveHarnessSession(active, problem, installations) {
1446
1552
  if (problem === 'different-session') {
1447
1553
  return [
1448
1554
  `Could not register the question for the active ${active.label} session: the project pointer belongs to another ${active.label} session or harness.`,
1555
+ ...ambiguity,
1449
1556
  `Refusing to guess or cross-wire the question. Send one prompt in this ${active.label} session, then run \`notifai doctor\`.`,
1450
1557
  `Retry \`notifai ask\` only after doctor reports that this active ${active.label} session fired the hooks.`,
1451
1558
  ];
1452
1559
  }
1453
1560
  return [
1454
1561
  `Could not register the question for the active ${active.label} session: Notifai hooks are installed, but this session has not published its pointer.`,
1562
+ ...ambiguity,
1455
1563
  `Send one ${active.label} prompt, then run \`notifai doctor\`.`,
1456
1564
  `Retry \`notifai ask\` only after doctor reports that the active ${active.label} session fired the hooks.`,
1457
1565
  ];
@@ -1541,7 +1649,7 @@ export function hooksInstallCommand(deps, flags) {
1541
1649
  if (harness === 'opencode') {
1542
1650
  return installOpencodePlugin(deps, file, {
1543
1651
  adapterPath,
1544
- timeoutSeconds: blockingHookTimeoutSeconds(),
1652
+ timeoutSeconds: BLOCKING_STOP_TIMEOUT_SECONDS,
1545
1653
  });
1546
1654
  }
1547
1655
  if (harness === 'cursor') {
@@ -1604,22 +1712,9 @@ export function hooksInstallCommand(deps, flags) {
1604
1712
  deps.io.out(` removed: ${merged.removed.join(', ')} (this build no longer serves them)`);
1605
1713
  }
1606
1714
  deps.io.out('');
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
- }
1620
- if (config.require_idle.value) {
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.');
1622
- }
1715
+ deps.io.out(config.ask_grace_seconds.value === 0
1716
+ ? 'A question registered with `notifai ask` goes to your devices immediately when the agent turn ends, whether or not you are at this machine.'
1717
+ : `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.`);
1623
1718
  if (harness === 'codex') {
1624
1719
  const layer = flags.global ? null : codexLayerDir(deps.cwd);
1625
1720
  if (layer !== null) {
@@ -1637,6 +1732,13 @@ export function hooksInstallCommand(deps, flags) {
1637
1732
  }
1638
1733
  }
1639
1734
  deps.io.out('');
1735
+ if (harness === 'claude-code') {
1736
+ // Codex deliberately gets no counterpart line: its Stop definition is
1737
+ // byte-identical to the one earlier builds wrote, so nothing about its
1738
+ // trust state changed and saying so would invent a gate that is not there.
1739
+ deps.io.out(`The Stop handler is asynchronous: it returns at once, so your turn ends normally, and it waits for your answer out of band for up to ${CLAUDE_ASYNC_STOP_TIMEOUT_SECONDS}s before delivering it back into this session. That timeout is written explicitly because Claude Code kills a background hook at its own 600s default and reports nothing when it does.`);
1740
+ deps.io.out('');
1741
+ }
1640
1742
  if (harness === 'claude-code' && flags.global !== true) {
1641
1743
  deps.io.out('Claude Code reloads project hook files without a restart. Send one new prompt,');
1642
1744
  deps.io.out('then check `notifai doctor` to confirm that the hook fired.');
@@ -2222,12 +2324,21 @@ export function logsCommand(deps, flags) {
2222
2324
  // init
2223
2325
  // ---------------------------------------------------------------------------
2224
2326
  /**
2225
- * Where `npx skills add` fetches the optional agent skill from. In skills CLI
2226
- * 1.5.x, `owner/repo@name` selects a skill; a Git ref belongs after `#`.
2227
- * Keep this immutable and public because the command is printed to users.
2327
+ * Where `npx skills add` fetches the optional agent skill from, derived from
2328
+ * this build's own version so the pin cannot drift from the release it names.
2329
+ * Null when the build cannot establish its version; see `./release.js`.
2228
2330
  */
2229
- export const SKILLS_SOURCE = 'Raidiant-io/notifai#v0.4.0';
2331
+ export const SKILLS_SOURCE = skillsSource();
2332
+ /**
2333
+ * How to refer to the pin in user-facing text.
2334
+ *
2335
+ * Only reached in a corrupted install, where naming the tag is impossible but
2336
+ * saying nothing would be worse — the sentence still has to read as English.
2337
+ */
2338
+ const SKILLS_SOURCE_LABEL = SKILLS_SOURCE ?? 'the public release tag matching this CLI';
2230
2339
  function skillSourceParts() {
2340
+ if (SKILLS_SOURCE === null)
2341
+ return null;
2231
2342
  const match = /^([^#]+)#(.+)$/.exec(SKILLS_SOURCE);
2232
2343
  return match === null ? null : { source: match[1], ref: match[2] };
2233
2344
  }
@@ -2257,7 +2368,7 @@ async function skillReadiness(deps, selectedScope) {
2257
2368
  id: 'skill',
2258
2369
  title: 'Agent guidance skill',
2259
2370
  status: 'ready',
2260
- detail: `installed from ${SKILLS_SOURCE} in the ${installed.scope} scope`,
2371
+ detail: `installed from ${SKILLS_SOURCE_LABEL} in the ${installed.scope} scope`,
2261
2372
  };
2262
2373
  }
2263
2374
  const errors = results
@@ -2270,7 +2381,7 @@ async function skillReadiness(deps, selectedScope) {
2270
2381
  status: 'optional-gap',
2271
2382
  detail: errors.length > 0
2272
2383
  ? `could not verify installer-managed state in ${scopeText} (${errors.join('; ')})`
2273
- : `not installed from ${SKILLS_SOURCE} in ${scopeText}`,
2384
+ : `not installed from ${SKILLS_SOURCE_LABEL} in ${scopeText}`,
2274
2385
  remedy: {
2275
2386
  by: 'cli',
2276
2387
  summary: 'install the skill agents follow when deciding to notify',
@@ -2397,6 +2508,12 @@ async function closeGap(deps, state, flags) {
2397
2508
  deps.io.err('Skill installation failed — the native `npx skills` flow is unavailable.');
2398
2509
  return 'failed';
2399
2510
  }
2511
+ // Refuse rather than reach for a mutable ref: installing the skill from a
2512
+ // moving branch is the one outcome the pin exists to prevent.
2513
+ if (SKILLS_SOURCE === null) {
2514
+ deps.io.err('Skill installation failed — this build cannot determine its own version, so there is no release tag to install from.');
2515
+ return 'failed';
2516
+ }
2400
2517
  const scopeText = flags.skillsScope === undefined ? 'the scope you choose' : `${flags.skillsScope} scope`;
2401
2518
  deps.io.out(`Starting the native npx skills setup for the notifai agent skill (${scopeText})...`);
2402
2519
  const addOptions = {
@@ -3267,7 +3384,7 @@ function remedyLine(state) {
3267
3384
  */
3268
3385
  function hookStates(deps) {
3269
3386
  const installations = findInstallations(deps.cwd, deps.env, deps.hookAdapterHome);
3270
- const active = activeHarnessSession(deps.env);
3387
+ const active = activeHarnessSession(deps.env, deps.cwd, (deps.now ?? Date.now)());
3271
3388
  const config = loadLoggedConfig(deps, { cwd: deps.cwd, env: deps.env });
3272
3389
  const settings = {
3273
3390
  id: 'question-routing-settings',
@@ -3275,10 +3392,7 @@ function hookStates(deps) {
3275
3392
  status: config.ask_notifications.value ? 'ready' : 'gap',
3276
3393
  detail: [
3277
3394
  `ask_notifications=${config.ask_notifications.value} (${config.ask_notifications.source})`,
3278
- `require_idle=${config.require_idle.value} (${config.require_idle.source})`,
3279
- `away_after_seconds=${config.away_after_seconds.value} (${config.away_after_seconds.source})`,
3280
3395
  `ask_grace_seconds=${config.ask_grace_seconds.value} (${config.ask_grace_seconds.source})`,
3281
- `hook_reply_timeout_seconds=${config.hook_reply_timeout_seconds.value} (${config.hook_reply_timeout_seconds.source})`,
3282
3396
  ].join(', '),
3283
3397
  ...(config.ask_notifications.value
3284
3398
  ? {}
@@ -3342,6 +3456,41 @@ function hookStates(deps) {
3342
3456
  settings,
3343
3457
  ];
3344
3458
  }
3459
+ /**
3460
+ * Whether an installed Stop handler declares the shape its harness needs.
3461
+ *
3462
+ * The three answers differ, and getting one wrong fails silently in a
3463
+ * different way each time:
3464
+ *
3465
+ * - Claude Code's handler must be `async: true`, or the waiter holds the
3466
+ * user's turn for its whole wait instead of returning at once. It must
3467
+ * also declare a `timeout`, because the harness default is 600 s and the
3468
+ * kill is silent — the backgrounded waiter vanishes and the answer the
3469
+ * user already gave is never delivered.
3470
+ * - Codex owns its Stop timeout. Declaring one changes the definition it
3471
+ * hashes into `trusted_hash`, and an untrusted handler is simply not run.
3472
+ * - Everything else blocks its turn and needs a ceiling above the wait.
3473
+ */
3474
+ function stopShapeProblems(installation) {
3475
+ if (installation.harness === 'codex')
3476
+ return [];
3477
+ const problems = [];
3478
+ for (const handler of installation.handlers.filter((entry) => handlerEvent(entry.command) === 'stop')) {
3479
+ if (installation.harness === 'claude-code') {
3480
+ if (handler.async !== true) {
3481
+ problems.push(`${installation.file} declares a blocking Stop handler; the Claude Code wake route needs \`async: true\` so the turn ends while the waiter runs`);
3482
+ }
3483
+ if (handler.timeout === undefined || handler.timeout < CLAUDE_ASYNC_STOP_TIMEOUT_SECONDS) {
3484
+ problems.push(`${installation.file} gives Stop ${handler.timeout ?? 'no'} declared seconds; Claude Code then kills the backgrounded waiter at its 600s default without reporting anything, so it needs an explicit ${CLAUDE_ASYNC_STOP_TIMEOUT_SECONDS}s`);
3485
+ }
3486
+ continue;
3487
+ }
3488
+ if (handler.timeout === undefined || handler.timeout < BLOCKING_STOP_TIMEOUT_SECONDS) {
3489
+ problems.push(`${installation.file} gives Stop ${handler.timeout ?? 'no'}s, but the blocking answer owner requires ${BLOCKING_STOP_TIMEOUT_SECONDS}s`);
3490
+ }
3491
+ }
3492
+ return problems;
3493
+ }
3345
3494
  function hookChecks(deps) {
3346
3495
  const checks = [];
3347
3496
  const installations = findInstallations(deps.cwd, deps.env, deps.hookAdapterHome);
@@ -3362,7 +3511,7 @@ function hookChecks(deps) {
3362
3511
  .map((i) => `${i.harness} ${i.global ? 'global' : 'project'} (${i.file})`)
3363
3512
  .join(', '),
3364
3513
  });
3365
- const active = activeHarnessSession(deps.env);
3514
+ const { active, contested } = resolveActiveHarness(deps.env, deps.cwd, (deps.now ?? Date.now)());
3366
3515
  const activeInstallations = active === null
3367
3516
  ? []
3368
3517
  : installations.filter((installation) => installation.harness === active.harness);
@@ -3455,18 +3604,13 @@ function hookChecks(deps) {
3455
3604
  },
3456
3605
  }),
3457
3606
  });
3458
- const requiredTimeout = blockingHookTimeoutSeconds();
3459
- const shortTimeouts = installations.flatMap((installation) => installation.handlers
3460
- .filter(() => installation.harness !== 'codex')
3461
- .filter((handler) => handlerEvent(handler.command) === 'stop')
3462
- .filter((handler) => handler.timeout === undefined || handler.timeout < requiredTimeout)
3463
- .map((handler) => `${installation.harness} declares ${handler.timeout ?? 'no'}s, needs ${requiredTimeout}s — run \`notifai hooks install --harness ${installation.harness}${installation.global ? ' --global' : ''}\``));
3607
+ const shapeProblems = installations.flatMap((installation) => stopShapeProblems(installation).map((problem) => `${problem} — run \`notifai hooks install --harness ${installation.harness}${installation.global ? ' --global' : ''}\``));
3464
3608
  checks.push({
3465
- name: 'hooks (timeout)',
3466
- ok: shortTimeouts.length === 0,
3467
- detail: shortTimeouts.length === 0
3468
- ? `declared Stop timeouts match the fixed ${requiredTimeout}s process budget where required; Codex uses host defaults`
3469
- : shortTimeouts.join('; '),
3609
+ name: 'hooks (stop shape)',
3610
+ ok: shapeProblems.length === 0,
3611
+ detail: shapeProblems.length === 0
3612
+ ? `every installed Stop handler declares the shape its harness needs: Claude Code async with an explicit ${CLAUDE_ASYNC_STOP_TIMEOUT_SECONDS}s waiter budget, blocking hosts ${BLOCKING_STOP_TIMEOUT_SECONDS}s, Codex host-owned`
3613
+ : shapeProblems.join('; '),
3470
3614
  });
3471
3615
  // Project and global definitions for one harness both fire. Stable adapter
3472
3616
  // identity deliberately makes their command bytes equal, so comparing
@@ -3497,14 +3641,37 @@ function hookChecks(deps) {
3497
3641
  : admissionProblems.join('; '),
3498
3642
  });
3499
3643
  }
3500
- const firedPointer = active?.sessionId === undefined
3644
+ // Which route this judges, and which remedy it prints, must both belong to
3645
+ // the harness that is actually running here. A machine-global installation
3646
+ // matches every directory, so picking whichever installation matched — or
3647
+ // whichever session last wrote a pointer — reports on a harness the agent
3648
+ // cannot influence: it is told to send a prompt in a harness that is not
3649
+ // running, follows the fail-closed rule, and can never clear the check.
3650
+ const firedPointer = active === null
3501
3651
  ? readProjectSessionPointer(deps.cwd, deps.env, (deps.now ?? Date.now)())
3502
- : readMatchingProjectSessionPointer(deps.cwd, deps.env, (deps.now ?? Date.now)(), active.sessionId, active.harness);
3652
+ : active.sessionId === undefined
3653
+ ? (readLiveProjectSessionPointers(deps.cwd, deps.env, (deps.now ?? Date.now)()).find((pointer) => pointer.harness === active.harness) ?? null)
3654
+ : readMatchingProjectSessionPointer(deps.cwd, deps.env, (deps.now ?? Date.now)(), active.sessionId, active.harness);
3503
3655
  const firedState = firedPointer === null ? null : readSessionState(firedPointer.sessionId, deps.env);
3504
3656
  const promptFired = firedState?.last_prompt_at !== undefined;
3505
3657
  const stopFired = firedState?.last_stop_at !== undefined;
3506
3658
  const fired = firedPointer !== null && promptFired && stopFired;
3507
- const activationInstallations = active !== null && activeInstallations.length > 0 ? activeInstallations : installations;
3659
+ // Installations for other harnesses are irrelevant to the active one, and an
3660
+ // active harness with none of its own has nothing to activate: say that
3661
+ // instead of advising a prompt in some other harness. When the environment
3662
+ // is contested and nothing has fired, every candidate is still possible, so
3663
+ // the advice covers all of them rather than betting on one.
3664
+ const activationHarnesses = new Set(contested.length > 1
3665
+ ? contested.map((candidate) => candidate.harness)
3666
+ : active === null
3667
+ ? installations.map((installation) => installation.harness)
3668
+ : [active.harness]);
3669
+ const activationInstallations = installations.filter((installation) => activationHarnesses.has(installation.harness));
3670
+ const activationAdvice = activationInstallations.length > 0
3671
+ ? hookActivationAdvice(activationInstallations)
3672
+ : active === null
3673
+ ? hookActivationAdvice(installations)
3674
+ : `${active.label}: no ${active.label} hook installation matches this project — run \`notifai hooks install --harness ${active.harness}\`.`;
3508
3675
  checks.push({
3509
3676
  name: 'hooks (fired)',
3510
3677
  ok: fired,
@@ -3512,16 +3679,24 @@ function hookChecks(deps) {
3512
3679
  // a missing Stop is a broken route, not missing historical evidence.
3513
3680
  informational: firedPointer === null,
3514
3681
  detail: fired
3515
- ? 'a session in this directory has run UserPromptSubmit and Stop'
3682
+ ? active === null
3683
+ ? 'a session in this directory has run UserPromptSubmit and Stop'
3684
+ : `the active ${active.label} session has run UserPromptSubmit and Stop`
3516
3685
  : firedPointer === null
3517
- ? `no session pointer from the last 24 hours — ${hookActivationAdvice(activationInstallations)}`
3686
+ ? `no ${contested.length > 1
3687
+ ? `session pointer for any harness whose markers are present here (${contested
3688
+ .map((candidate) => candidate.label)
3689
+ .join(', ')})`
3690
+ : active === null
3691
+ ? 'session pointer'
3692
+ : `${active.label} session pointer`} from the last 24 hours — ${activationAdvice}`
3518
3693
  : `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`,
3519
3694
  ...(fired
3520
3695
  ? {}
3521
3696
  : {
3522
3697
  remedy: {
3523
3698
  summary: firedPointer === null
3524
- ? 'send one prompt in a session here, then re-check'
3699
+ ? `send one ${active === null || contested.length > 1 ? '' : `${active.label} `}prompt in a session here, then re-check`
3525
3700
  : 'end one harmless turn, send a new prompt, then re-check',
3526
3701
  command: 'notifai doctor',
3527
3702
  },
@@ -3544,11 +3719,69 @@ function hookChecks(deps) {
3544
3719
  .map((harness) => `${harness}: ${HARNESS_CAPABILITIES[harness].deliveryContract}`)
3545
3720
  .join('; '),
3546
3721
  });
3722
+ const wakeRoute = wakeRouteCheck(deps, active, activeInstallations);
3723
+ if (wakeRoute !== null)
3724
+ checks.push(wakeRoute);
3547
3725
  const stray = codexStrayWorktreeCheck(deps);
3548
3726
  if (stray !== null)
3549
3727
  checks.push(stray);
3550
3728
  return checks;
3551
3729
  }
3730
+ /**
3731
+ * Whether an answer arriving after this turn ends could actually reach this
3732
+ * exact session — the question `notifai ask` really turns on, and the one no
3733
+ * other check answers.
3734
+ *
3735
+ * Read-only, and deliberately so: nothing here connects to a socket, takes a
3736
+ * lock, or sends a message. A diagnostic that wakes the agent it is diagnosing
3737
+ * would be its own bug report.
3738
+ *
3739
+ * Everything it can report negatively is a degradation rather than a failure —
3740
+ * the accepted journal still replays the answer at the session's next turn —
3741
+ * so this is never a blocker. What it buys is that the reason has a name
3742
+ * before the user notices the silence.
3743
+ *
3744
+ * It never asks for `crossSessionInbound`. The poster is the session's own
3745
+ * hook child and takes the privileged own-child path, verified to be delivered
3746
+ * even against a `bypassPermissions` receiver while an unrelated process was
3747
+ * held. Widening a user's general inbound policy to suit Notifai would be a
3748
+ * real change to their machine's posture in exchange for nothing.
3749
+ */
3750
+ function wakeRouteCheck(deps, active, activeInstallations) {
3751
+ if (active === null || activeInstallations.length === 0)
3752
+ return null;
3753
+ if (active.harness === 'claude-code') {
3754
+ const readiness = inspectClaudeInbox({
3755
+ pid: deps.claudeSourcePid ?? claudeSessionPid(deps.env),
3756
+ platform: process.platform,
3757
+ readDescriptor: deps.claudeWake?.readDescriptor ?? systemClaudeWakeAdapters(deps.env).readDescriptor,
3758
+ socketExists: (socketPath) => existsSync(socketPath),
3759
+ });
3760
+ return {
3761
+ name: 'hooks (wake route)',
3762
+ ok: readiness.state === 'ready',
3763
+ informational: true,
3764
+ detail: readiness.state === 'ready'
3765
+ ? `this Claude Code ${readiness.version} session is listening on ${readiness.socketPath}, so an answer can start a turn here without you`
3766
+ : `${readiness.reason}. Answers are still delivered, at this session's next turn rather than on their own`,
3767
+ };
3768
+ }
3769
+ if (active.harness === 'codex') {
3770
+ const readiness = inspectCodexResume(deps.env, {
3771
+ platform: process.platform,
3772
+ directoryExists: (directory) => existsSync(directory),
3773
+ });
3774
+ return {
3775
+ name: 'hooks (wake route)',
3776
+ ok: readiness.state === 'ready',
3777
+ informational: true,
3778
+ detail: readiness.state === 'ready'
3779
+ ? `the held Codex turn continues from its own hook, and after it returns ${readiness.lockDirectory} can prove a stopped thread unowned before resuming it`
3780
+ : `the held Codex turn still continues from its own hook, but after it returns nothing can be resumed: ${readiness.reason}. Answers wait for the next turn`,
3781
+ };
3782
+ }
3783
+ return null;
3784
+ }
3552
3785
  /**
3553
3786
  * A Codex hooks file sitting in a worktree, which Codex will never read.
3554
3787
  *