@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/src/commands.ts CHANGED
@@ -46,6 +46,7 @@ import {
46
46
  type LogLevel,
47
47
  } from './config.js'
48
48
  import { acceptedValues, configInfo } from './config-schema.js'
49
+ import { skillsSource } from './release.js'
49
50
  import { atomicWriteFileSync } from './atomic-file.js'
50
51
  import { withTargetFileLock } from './file-lock.js'
51
52
  import {
@@ -63,20 +64,22 @@ import {
63
64
  } from './readiness.js'
64
65
  import type { Tone } from './ui/theme.js'
65
66
  import {
67
+ WAITER_CEILING_SECONDS,
66
68
  handleSessionEnd,
67
69
  handleStop,
68
70
  handleUserPromptSubmit,
69
71
  parseHookInput,
70
72
  pruneAbandonedSessions,
73
+ readLiveProjectSessionPointers,
71
74
  readMatchingProjectSessionPointer,
72
75
  readProjectSession,
73
76
  readProjectSessionPointer,
74
77
  readSessionState,
75
78
  registerQuestion,
79
+ type EscalationDeliveryRoute,
76
80
  type HookContext,
77
81
  type HookHarness,
78
82
  } from './hooks.js'
79
- import { readIdleSeconds } from './idle.js'
80
83
  import {
81
84
  LOG_EVENTS,
82
85
  activeLogPath,
@@ -91,9 +94,10 @@ import {
91
94
  type Logger,
92
95
  } from './logging.js'
93
96
  import {
97
+ BLOCKING_STOP_TIMEOUT_SECONDS,
98
+ CLAUDE_ASYNC_STOP_TIMEOUT_SECONDS,
94
99
  HARNESSES,
95
100
  applyPlan,
96
- blockingHookTimeoutSeconds,
97
101
  buildCursorHookConfig,
98
102
  buildHookConfig,
99
103
  codexLayerDir,
@@ -112,7 +116,18 @@ import {
112
116
  type Harness,
113
117
  type Installation,
114
118
  } from './install-hooks.js'
115
- import { HARNESS_CAPABILITIES, HOOK_TIMING } from './harnesses.js'
119
+ import { HARNESS_CAPABILITIES } from './harnesses.js'
120
+ import {
121
+ claudeWakeRoute,
122
+ inspectClaudeInbox,
123
+ systemClaudeWakeAdapters,
124
+ type ClaudeWakeAdapters,
125
+ } from './claude-wake.js'
126
+ import {
127
+ codexWakeRoute,
128
+ inspectCodexResume,
129
+ type CodexWakeAdapters,
130
+ } from './codex-wake.js'
116
131
  import {
117
132
  inspectHookAdapter,
118
133
  installHookAdapter,
@@ -179,8 +194,12 @@ export interface CommandDeps {
179
194
  now?: () => number
180
195
  /** Test seam for retry/backoff timing. */
181
196
  sleep?: (milliseconds: number) => Promise<void>
182
- /** Test seam for the OS idle probe; production shells out to the platform. */
183
- idleSeconds?: () => number | null
197
+ /** Test seams for Claude liveness, socket delivery, and cold resume. */
198
+ claudeWake?: ClaudeWakeAdapters
199
+ claudeSourcePid?: number
200
+ /** Test seams for the Codex thread-writer probe and cold resume. */
201
+ codexWake?: CodexWakeAdapters
202
+ codexSourcePid?: number
184
203
  /** Test seam and production adapter for the external native skills installer. */
185
204
  nativeSkills?: NativeSkills
186
205
  /**
@@ -1209,7 +1228,7 @@ export async function hookRunCommand(
1209
1228
  // would grant slow setup a second budget and let the harness kill us before
1210
1229
  // an accepted answer is journaled or written to stdout.
1211
1230
  const now = deps.now ?? Date.now
1212
- const processDeadlineAt = now() + HOOK_TIMING.totalSeconds * 1000
1231
+ const processDeadlineAt = now() + WAITER_CEILING_SECONDS * 1000
1213
1232
 
1214
1233
  const logger = log(deps)
1215
1234
  logger.bind({ cmd: `hook ${event}` })
@@ -1347,13 +1366,7 @@ export async function hookRunCommand(
1347
1366
  `Bearer nfm_${credential.machineId}.${credential.secret}`,
1348
1367
  {
1349
1368
  timeoutMs: event === 'user-prompt-submit' ? 4_000 : 20_000,
1350
- ...(event === 'stop'
1351
- ? {
1352
- deadlineAt:
1353
- processDeadlineAt - HOOK_TIMING.stdoutReserveSeconds * 1000,
1354
- now,
1355
- }
1356
- : {}),
1369
+ ...(event === 'stop' ? { deadlineAt: processDeadlineAt, now } : {}),
1357
1370
  },
1358
1371
  )
1359
1372
  const ctx: HookContext = {
@@ -1361,7 +1374,6 @@ export async function hookRunCommand(
1361
1374
  config: resolved,
1362
1375
  env: deps.env,
1363
1376
  now,
1364
- idleSeconds: deps.idleSeconds ?? (() => readIdleSeconds()),
1365
1377
  sleep: deps.sleep ?? ((ms) => new Promise((resolve) => setTimeout(resolve, ms))),
1366
1378
  waitForFirstReply: async (requestId, timeoutSeconds) => {
1367
1379
  const result = await waitForReply(client, requestId, {
@@ -1391,7 +1403,12 @@ export async function hookRunCommand(
1391
1403
  const outcome =
1392
1404
  event === 'user-prompt-submit'
1393
1405
  ? await handleUserPromptSubmit(ctx, envelope)
1394
- : await handleStop(ctx, envelope, processDeadlineAt)
1406
+ : await handleStop(
1407
+ ctx,
1408
+ envelope,
1409
+ processDeadlineAt,
1410
+ stopWakeRoute(deps, harness, envelope.session_id, cwd),
1411
+ )
1395
1412
  // Answer diagnostics are already persisted once as hook.answer. Keep every
1396
1413
  // other note in the lifecycle record without duplicating the user's text.
1397
1414
  const notes = outcome.notes.filter((note) => !/^(?:late )?answer from /.test(note))
@@ -1436,6 +1453,61 @@ export async function hookRunCommand(
1436
1453
  }
1437
1454
  }
1438
1455
 
1456
+ /**
1457
+ * The last meter for an answer this Stop hook accepted, chosen by harness.
1458
+ *
1459
+ * Both wake adapters need the harness process that invoked this hook: Claude's
1460
+ * to prove exact own-child session ownership before it posts to the inbox
1461
+ * socket, Codex's to know whether its own stdout is still a live continuation
1462
+ * channel. Without an exact session id neither can prove anything, so the
1463
+ * waiter falls back to the plain blocking Stop continuation.
1464
+ */
1465
+ function stopWakeRoute(
1466
+ deps: CommandDeps,
1467
+ harness: Harness | undefined,
1468
+ sessionId: string | undefined,
1469
+ cwd: string,
1470
+ ): EscalationDeliveryRoute | undefined {
1471
+ if (sessionId === undefined) return undefined
1472
+ if (harness === 'claude-code') {
1473
+ return claudeWakeRoute({
1474
+ sessionId,
1475
+ cwd,
1476
+ sourcePid: deps.claudeSourcePid ?? claudeSessionPid(deps.env),
1477
+ ...(deps.claudeWake === undefined ? {} : { adapters: deps.claudeWake }),
1478
+ })
1479
+ }
1480
+ if (harness === 'codex') {
1481
+ return codexWakeRoute({
1482
+ threadId: sessionId,
1483
+ cwd,
1484
+ sourcePid: deps.codexSourcePid ?? process.ppid,
1485
+ env: deps.env,
1486
+ ...(deps.codexWake === undefined ? {} : { adapters: deps.codexWake }),
1487
+ })
1488
+ }
1489
+ return undefined
1490
+ }
1491
+
1492
+ /**
1493
+ * The pid of the Claude Code session this hook belongs to.
1494
+ *
1495
+ * Claude exports `CLAUDE_PID` to its hooks, and that is the authoritative
1496
+ * answer. `process.ppid` agrees with it today — the hook command runs through
1497
+ * a shell, but the shell `exec`s, so this process's parent *is* the session
1498
+ * (probed against a live 2.1.228 session) — and it remains the fallback for a
1499
+ * harness build that stops exporting the variable.
1500
+ *
1501
+ * Preferring the explicit value costs a line and removes a silent failure: if
1502
+ * this ever resolved to a shell instead, no session descriptor would match, the
1503
+ * route could not prove own-child ownership, and every answer would degrade to
1504
+ * hold-for-next-turn with nothing reported as wrong.
1505
+ */
1506
+ function claudeSessionPid(env: NodeJS.ProcessEnv): number {
1507
+ const declared = Number(env['CLAUDE_PID'])
1508
+ return Number.isInteger(declared) && declared > 0 ? declared : process.ppid
1509
+ }
1510
+
1439
1511
  /**
1440
1512
  * What went wrong, in terms of what to do about it.
1441
1513
  *
@@ -1588,10 +1660,9 @@ function buildOneQuestion(
1588
1660
  }
1589
1661
 
1590
1662
  /**
1591
- * Registers a question for turn-end routing under the user's presence config.
1592
- * Returns immediately so the agent can ask in prose and end its turn. Presence
1593
- * does not block the shipped default; `require_idle = true` intentionally keeps
1594
- * the question local while the user is active.
1663
+ * Registers a question for turn-end routing. Returns immediately so the agent
1664
+ * can ask in prose and end its turn; the terminal keeps the question to itself
1665
+ * for `ask_grace_seconds` before it reaches any device.
1595
1666
  */
1596
1667
  export function askCommand(deps: CommandDeps, question: string | undefined, flags: AskFlags): number {
1597
1668
  // Validate before route discovery. A malformed question belongs to the
@@ -1619,7 +1690,7 @@ export function askCommand(deps: CommandDeps, question: string | undefined, flag
1619
1690
  // markers identify the active owner, while UserPromptSubmit adds the hook's
1620
1691
  // canonical id to the directory's concurrent-session index.
1621
1692
  const now = (deps.now ?? Date.now)()
1622
- const active = activeHarnessSession(deps.env)
1693
+ const { active, contested } = resolveActiveHarness(deps.env, deps.cwd, now)
1623
1694
  let sessionId: string | undefined
1624
1695
  if (active !== null) {
1625
1696
  const installations = findInstallations(deps.cwd, deps.env, deps.hookAdapterHome)
@@ -1627,7 +1698,12 @@ export function askCommand(deps: CommandDeps, question: string | undefined, flag
1627
1698
  (installation) => installation.harness === active.harness,
1628
1699
  )
1629
1700
  if (!activeInstalled) {
1630
- for (const line of diagnoseActiveHarnessSession(active, 'not-installed', installations)) {
1701
+ for (const line of diagnoseActiveHarnessSession(
1702
+ active,
1703
+ 'not-installed',
1704
+ installations,
1705
+ contested,
1706
+ )) {
1631
1707
  deps.io.err(line)
1632
1708
  }
1633
1709
  return EXIT.usage
@@ -1646,7 +1722,12 @@ export function askCommand(deps: CommandDeps, question: string | undefined, flag
1646
1722
  active.harness,
1647
1723
  )
1648
1724
  if (projectPointer === null) {
1649
- for (const line of diagnoseActiveHarnessSession(active, 'not-fired', installations)) {
1725
+ for (const line of diagnoseActiveHarnessSession(
1726
+ active,
1727
+ 'not-fired',
1728
+ installations,
1729
+ contested,
1730
+ )) {
1650
1731
  deps.io.err(line)
1651
1732
  }
1652
1733
  return EXIT.usage
@@ -1717,8 +1798,32 @@ export function askCommand(deps: CommandDeps, question: string | undefined, flag
1717
1798
  }
1718
1799
  deps.io.out(
1719
1800
  built.questions.length > 1
1720
- ? `${built.questions.length} questions registered as one form. Ask them in the conversation as usual and end your turn.`
1721
- : 'Question registered. Ask it in the conversation as usual and end your turn.',
1801
+ ? `${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.`
1802
+ : 'Question registered. Ask it in the conversation, state the concrete work you will resume when the answer arrives, then end your turn.',
1803
+ )
1804
+ deps.io.out('Before ending this turn, pre-commit in your own words to the work you will resume:')
1805
+ for (const [index, entry] of built.questions.entries()) {
1806
+ const questionPrefix = built.questions.length > 1 ? `Question ${index + 1}, ` : ''
1807
+ if (entry.choices !== undefined) {
1808
+ for (const choice of entry.choices) {
1809
+ deps.io.out(
1810
+ `- ${questionPrefix}If the answer is ${JSON.stringify(choice.label)}: state the concrete work you will resume.`,
1811
+ )
1812
+ }
1813
+ deps.io.out(
1814
+ `- ${questionPrefix}For an unexpected typed answer: state how it will determine the concrete work you resume.`,
1815
+ )
1816
+ } else {
1817
+ deps.io.out(
1818
+ `- ${questionPrefix}For the free-text answer: state how its content will determine the concrete work you resume.`,
1819
+ )
1820
+ }
1821
+ }
1822
+ deps.io.out(
1823
+ '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.',
1824
+ )
1825
+ deps.io.out(
1826
+ 'A Notifai answer cannot answer a harness permission prompt or interactive picker; leave those to the harness and user.',
1722
1827
  )
1723
1828
  return EXIT.ok
1724
1829
  }
@@ -1736,30 +1841,96 @@ interface ActiveHarnessSession {
1736
1841
  sessionId?: string
1737
1842
  }
1738
1843
 
1739
- function activeHarnessSession(env: NodeJS.ProcessEnv): ActiveHarnessSession | null {
1740
- const explicit = env['NOTIFAI_ACTIVE_HARNESS']
1741
- if (explicit === 'opencode') {
1844
+ /**
1845
+ * Every harness marker present in this environment, in declared order.
1846
+ *
1847
+ * Order here is a last resort, not an answer. A harness exports its markers
1848
+ * into every process it starts, so a nested harness inherits its parent's
1849
+ * markers alongside its own and the environment alone cannot say which of them
1850
+ * owns this shell; `resolveActiveHarness` settles that with live evidence.
1851
+ */
1852
+ function harnessEnvCandidates(env: NodeJS.ProcessEnv): ActiveHarnessSession[] {
1853
+ const candidates: ActiveHarnessSession[] = []
1854
+ if (env['NOTIFAI_ACTIVE_HARNESS'] === 'opencode') {
1742
1855
  const sessionId = env['NOTIFAI_ACTIVE_SESSION_ID']
1743
- return {
1856
+ candidates.push({
1744
1857
  harness: 'opencode',
1745
1858
  label: 'OpenCode',
1746
1859
  ...(sessionId === undefined || sessionId === '' ? {} : { sessionId }),
1747
- }
1860
+ })
1748
1861
  }
1749
1862
  if (env['CLAUDECODE'] === '1') {
1750
1863
  const sessionId = env['CLAUDE_CODE_SESSION_ID']
1751
- return {
1864
+ candidates.push({
1752
1865
  harness: 'claude-code',
1753
1866
  label: 'Claude Code',
1754
1867
  ...(sessionId === undefined || sessionId === '' ? {} : { sessionId }),
1755
- }
1868
+ })
1756
1869
  }
1757
1870
  const codexSession = env['CODEX_THREAD_ID']
1758
1871
  if (codexSession !== undefined && codexSession !== '') {
1759
- return { harness: 'codex', label: 'Codex', sessionId: codexSession }
1872
+ candidates.push({ harness: 'codex', label: 'Codex', sessionId: codexSession })
1760
1873
  }
1761
- if ((env['CURSOR_AGENT'] ?? '') !== '') return { harness: 'cursor', label: 'Cursor' }
1762
- return null
1874
+ if ((env['CURSOR_AGENT'] ?? '') !== '') candidates.push({ harness: 'cursor', label: 'Cursor' })
1875
+ return candidates
1876
+ }
1877
+
1878
+ interface ActiveHarnessResolution {
1879
+ active: ActiveHarnessSession | null
1880
+ /**
1881
+ * Markers of harnesses that could equally own this shell, present only when
1882
+ * nothing here has fired yet and declared order had to pick. Whatever is
1883
+ * reported then has to hold for every one of them.
1884
+ */
1885
+ contested: ActiveHarnessSession[]
1886
+ }
1887
+
1888
+ /**
1889
+ * Which harness session owns this shell, when several claim to.
1890
+ *
1891
+ * Nesting is ordinary: an orchestrator running inside Claude Code starts a
1892
+ * Codex session, and that Codex process inherits `CLAUDECODE` and
1893
+ * `CLAUDE_CODE_SESSION_ID` on top of its own `CODEX_THREAD_ID`. The mirror is
1894
+ * just as ordinary, so no fixed precedence between two markers can be right —
1895
+ * whichever one it favours is wrong in the opposite nesting, and the cost is
1896
+ * silent: `ask` looks up a session that is not this one, and every remedy the
1897
+ * agent is told to try addresses a harness that is not running here.
1898
+ *
1899
+ * The general rule is to prefer the most specific *live* signal over inherited
1900
+ * environment. An inherited marker is a claim about some ancestor process; a
1901
+ * session id that names an entry in this directory's live pointer index is
1902
+ * evidence that that exact session fired a hook here and its state still
1903
+ * exists. Live evidence therefore wins over declared order, and the most
1904
+ * recently active pointer wins between two live candidates: the harness whose
1905
+ * turn is running is the one that fired last, while its parent sits blocked on
1906
+ * the child it started. Declared order decides only when nothing here has
1907
+ * fired yet, and it says so — every route fails closed there anyway.
1908
+ */
1909
+ function resolveActiveHarness(
1910
+ env: NodeJS.ProcessEnv,
1911
+ cwd: string,
1912
+ now: number,
1913
+ ): ActiveHarnessResolution {
1914
+ const candidates = harnessEnvCandidates(env)
1915
+ const first = candidates[0]
1916
+ if (first === undefined) return { active: null, contested: [] }
1917
+ if (candidates.length === 1) return { active: first, contested: [] }
1918
+ for (const pointer of readLiveProjectSessionPointers(cwd, env, now)) {
1919
+ const owner = candidates.find(
1920
+ (candidate) =>
1921
+ candidate.harness === pointer.harness && candidate.sessionId === pointer.sessionId,
1922
+ )
1923
+ if (owner !== undefined) return { active: owner, contested: [] }
1924
+ }
1925
+ return { active: first, contested: candidates }
1926
+ }
1927
+
1928
+ function activeHarnessSession(
1929
+ env: NodeJS.ProcessEnv,
1930
+ cwd: string,
1931
+ now: number,
1932
+ ): ActiveHarnessSession | null {
1933
+ return resolveActiveHarness(env, cwd, now).active
1763
1934
  }
1764
1935
 
1765
1936
  /**
@@ -1807,20 +1978,7 @@ function activeQuestionRouteProblems(
1807
1978
  for (const problem of inspectHookAdapter(deps.hookAdapterHome).problems) {
1808
1979
  problems.push(problem)
1809
1980
  }
1810
- if (active.harness !== 'codex') {
1811
- const required = blockingHookTimeoutSeconds()
1812
- for (const installation of matching) {
1813
- for (const handler of installation.handlers.filter(
1814
- (entry) => handlerEvent(entry.command) === 'stop',
1815
- )) {
1816
- if (handler.timeout === undefined || handler.timeout < required) {
1817
- problems.push(
1818
- `${installation.file} gives Stop ${handler.timeout ?? 'no'}s, but the live answer owner requires ${required}s`,
1819
- )
1820
- }
1821
- }
1822
- }
1823
- }
1981
+ for (const installation of matching) problems.push(...stopShapeProblems(installation))
1824
1982
  problems.push(...codexTrustProblems(matching, deps.env))
1825
1983
  return [...new Set(problems)]
1826
1984
  }
@@ -1831,11 +1989,22 @@ function diagnoseActiveHarnessSession(
1831
1989
  active: ActiveHarnessSession,
1832
1990
  problem: ActiveHarnessProblem,
1833
1991
  installations: Installation[],
1992
+ contested: ActiveHarnessSession[] = [],
1834
1993
  ): string[] {
1994
+ // Naming one harness confidently is wrong when the environment carries the
1995
+ // markers of several and none of them has fired here: whichever one the
1996
+ // agent is told to prompt may not be the one it is running in.
1997
+ const ambiguity =
1998
+ contested.length > 1
1999
+ ? [
2000
+ `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.`,
2001
+ ]
2002
+ : []
1835
2003
  if (problem === 'not-installed') {
1836
2004
  const others = installations.map((installation) => installation.harness)
1837
2005
  return [
1838
2006
  `Could not register the question for the active ${active.label} session: Notifai ${active.label} hooks are not installed for this project.`,
2007
+ ...ambiguity,
1839
2008
  ...(others.length === 0
1840
2009
  ? []
1841
2010
  : [
@@ -1848,12 +2017,14 @@ function diagnoseActiveHarnessSession(
1848
2017
  if (problem === 'different-session') {
1849
2018
  return [
1850
2019
  `Could not register the question for the active ${active.label} session: the project pointer belongs to another ${active.label} session or harness.`,
2020
+ ...ambiguity,
1851
2021
  `Refusing to guess or cross-wire the question. Send one prompt in this ${active.label} session, then run \`notifai doctor\`.`,
1852
2022
  `Retry \`notifai ask\` only after doctor reports that this active ${active.label} session fired the hooks.`,
1853
2023
  ]
1854
2024
  }
1855
2025
  return [
1856
2026
  `Could not register the question for the active ${active.label} session: Notifai hooks are installed, but this session has not published its pointer.`,
2027
+ ...ambiguity,
1857
2028
  `Send one ${active.label} prompt, then run \`notifai doctor\`.`,
1858
2029
  `Retry \`notifai ask\` only after doctor reports that the active ${active.label} session fired the hooks.`,
1859
2030
  ]
@@ -1974,7 +2145,7 @@ export function hooksInstallCommand(deps: CommandDeps, flags: HooksInstallFlags)
1974
2145
  if (harness === 'opencode') {
1975
2146
  return installOpencodePlugin(deps, file, {
1976
2147
  adapterPath,
1977
- timeoutSeconds: blockingHookTimeoutSeconds(),
2148
+ timeoutSeconds: BLOCKING_STOP_TIMEOUT_SECONDS,
1978
2149
  })
1979
2150
  }
1980
2151
 
@@ -2041,27 +2212,11 @@ export function hooksInstallCommand(deps: CommandDeps, flags: HooksInstallFlags)
2041
2212
  deps.io.out(` removed: ${merged.removed.join(', ')} (this build no longer serves them)`)
2042
2213
  }
2043
2214
  deps.io.out('')
2044
- if (config.require_idle.value) {
2045
- deps.io.out(
2046
- `While keyboard or mouse idle time stays below ${config.away_after_seconds.value}s, ` +
2047
- `nothing is pushed. A question registered with \`notifai ask\` goes to your devices ` +
2048
- (config.ask_grace_seconds.value === 0
2049
- ? 'as soon as the machine meets that idle threshold. '
2050
- : `once its ${config.ask_grace_seconds.value}s grace window, counted from registration, has elapsed and the machine meets that idle threshold. `) +
2051
- `Run \`notifai config set require_idle false\` to be notified even while you are working.`,
2052
- )
2053
- } else {
2054
- deps.io.out(
2055
- config.ask_grace_seconds.value === 0
2056
- ? 'A question registered with `notifai ask` goes to your devices immediately when the agent turn ends, whether or not you are at this machine.'
2057
- : `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.`,
2058
- )
2059
- }
2060
- if (config.require_idle.value) {
2061
- deps.io.out(
2062
- '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.',
2063
- )
2064
- }
2215
+ deps.io.out(
2216
+ config.ask_grace_seconds.value === 0
2217
+ ? 'A question registered with `notifai ask` goes to your devices immediately when the agent turn ends, whether or not you are at this machine.'
2218
+ : `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.`,
2219
+ )
2065
2220
  if (harness === 'codex') {
2066
2221
  const layer = flags.global ? null : codexLayerDir(deps.cwd)
2067
2222
  if (layer !== null) {
@@ -2079,6 +2234,15 @@ export function hooksInstallCommand(deps: CommandDeps, flags: HooksInstallFlags)
2079
2234
  }
2080
2235
  }
2081
2236
  deps.io.out('')
2237
+ if (harness === 'claude-code') {
2238
+ // Codex deliberately gets no counterpart line: its Stop definition is
2239
+ // byte-identical to the one earlier builds wrote, so nothing about its
2240
+ // trust state changed and saying so would invent a gate that is not there.
2241
+ deps.io.out(
2242
+ `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.`,
2243
+ )
2244
+ deps.io.out('')
2245
+ }
2082
2246
  if (harness === 'claude-code' && flags.global !== true) {
2083
2247
  deps.io.out('Claude Code reloads project hook files without a restart. Send one new prompt,')
2084
2248
  deps.io.out('then check `notifai doctor` to confirm that the hook fired.')
@@ -2749,13 +2913,22 @@ export function logsCommand(deps: CommandDeps, flags: LogsFlags): number {
2749
2913
  // ---------------------------------------------------------------------------
2750
2914
 
2751
2915
  /**
2752
- * Where `npx skills add` fetches the optional agent skill from. In skills CLI
2753
- * 1.5.x, `owner/repo@name` selects a skill; a Git ref belongs after `#`.
2754
- * Keep this immutable and public because the command is printed to users.
2916
+ * Where `npx skills add` fetches the optional agent skill from, derived from
2917
+ * this build's own version so the pin cannot drift from the release it names.
2918
+ * Null when the build cannot establish its version; see `./release.js`.
2755
2919
  */
2756
- export const SKILLS_SOURCE = 'Raidiant-io/notifai#v0.4.0'
2920
+ export const SKILLS_SOURCE: string | null = skillsSource()
2921
+
2922
+ /**
2923
+ * How to refer to the pin in user-facing text.
2924
+ *
2925
+ * Only reached in a corrupted install, where naming the tag is impossible but
2926
+ * saying nothing would be worse — the sentence still has to read as English.
2927
+ */
2928
+ const SKILLS_SOURCE_LABEL = SKILLS_SOURCE ?? 'the public release tag matching this CLI'
2757
2929
 
2758
2930
  function skillSourceParts(): { source: string; ref: string } | null {
2931
+ if (SKILLS_SOURCE === null) return null
2759
2932
  const match = /^([^#]+)#(.+)$/.exec(SKILLS_SOURCE)
2760
2933
  return match === null ? null : { source: match[1]!, ref: match[2]! }
2761
2934
  }
@@ -2792,7 +2965,7 @@ async function skillReadiness(
2792
2965
  id: 'skill',
2793
2966
  title: 'Agent guidance skill',
2794
2967
  status: 'ready',
2795
- detail: `installed from ${SKILLS_SOURCE} in the ${installed.scope} scope`,
2968
+ detail: `installed from ${SKILLS_SOURCE_LABEL} in the ${installed.scope} scope`,
2796
2969
  }
2797
2970
  }
2798
2971
 
@@ -2807,7 +2980,7 @@ async function skillReadiness(
2807
2980
  detail:
2808
2981
  errors.length > 0
2809
2982
  ? `could not verify installer-managed state in ${scopeText} (${errors.join('; ')})`
2810
- : `not installed from ${SKILLS_SOURCE} in ${scopeText}`,
2983
+ : `not installed from ${SKILLS_SOURCE_LABEL} in ${scopeText}`,
2811
2984
  remedy: {
2812
2985
  by: 'cli',
2813
2986
  summary: 'install the skill agents follow when deciding to notify',
@@ -3008,6 +3181,14 @@ async function closeGap(
3008
3181
  deps.io.err('Skill installation failed — the native `npx skills` flow is unavailable.')
3009
3182
  return 'failed'
3010
3183
  }
3184
+ // Refuse rather than reach for a mutable ref: installing the skill from a
3185
+ // moving branch is the one outcome the pin exists to prevent.
3186
+ if (SKILLS_SOURCE === null) {
3187
+ deps.io.err(
3188
+ 'Skill installation failed — this build cannot determine its own version, so there is no release tag to install from.',
3189
+ )
3190
+ return 'failed'
3191
+ }
3011
3192
  const scopeText = flags.skillsScope === undefined ? 'the scope you choose' : `${flags.skillsScope} scope`
3012
3193
  deps.io.out(`Starting the native npx skills setup for the notifai agent skill (${scopeText})...`)
3013
3194
  const addOptions = {
@@ -3984,7 +4165,7 @@ function remedyLine(state: ReadinessState): string {
3984
4165
  */
3985
4166
  function hookStates(deps: CommandDeps): ReadinessState[] {
3986
4167
  const installations = findInstallations(deps.cwd, deps.env, deps.hookAdapterHome)
3987
- const active = activeHarnessSession(deps.env)
4168
+ const active = activeHarnessSession(deps.env, deps.cwd, (deps.now ?? Date.now)())
3988
4169
  const config = loadLoggedConfig(deps, { cwd: deps.cwd, env: deps.env })
3989
4170
  const settings: ReadinessState = {
3990
4171
  id: 'question-routing-settings',
@@ -3992,10 +4173,7 @@ function hookStates(deps: CommandDeps): ReadinessState[] {
3992
4173
  status: config.ask_notifications.value ? 'ready' : 'gap',
3993
4174
  detail: [
3994
4175
  `ask_notifications=${config.ask_notifications.value} (${config.ask_notifications.source})`,
3995
- `require_idle=${config.require_idle.value} (${config.require_idle.source})`,
3996
- `away_after_seconds=${config.away_after_seconds.value} (${config.away_after_seconds.source})`,
3997
4176
  `ask_grace_seconds=${config.ask_grace_seconds.value} (${config.ask_grace_seconds.source})`,
3998
- `hook_reply_timeout_seconds=${config.hook_reply_timeout_seconds.value} (${config.hook_reply_timeout_seconds.source})`,
3999
4177
  ].join(', '),
4000
4178
  ...(config.ask_notifications.value
4001
4179
  ? {}
@@ -4064,6 +4242,49 @@ function hookStates(deps: CommandDeps): ReadinessState[] {
4064
4242
  ]
4065
4243
  }
4066
4244
 
4245
+ /**
4246
+ * Whether an installed Stop handler declares the shape its harness needs.
4247
+ *
4248
+ * The three answers differ, and getting one wrong fails silently in a
4249
+ * different way each time:
4250
+ *
4251
+ * - Claude Code's handler must be `async: true`, or the waiter holds the
4252
+ * user's turn for its whole wait instead of returning at once. It must
4253
+ * also declare a `timeout`, because the harness default is 600 s and the
4254
+ * kill is silent — the backgrounded waiter vanishes and the answer the
4255
+ * user already gave is never delivered.
4256
+ * - Codex owns its Stop timeout. Declaring one changes the definition it
4257
+ * hashes into `trusted_hash`, and an untrusted handler is simply not run.
4258
+ * - Everything else blocks its turn and needs a ceiling above the wait.
4259
+ */
4260
+ function stopShapeProblems(installation: Installation): string[] {
4261
+ if (installation.harness === 'codex') return []
4262
+ const problems: string[] = []
4263
+ for (const handler of installation.handlers.filter(
4264
+ (entry) => handlerEvent(entry.command) === 'stop',
4265
+ )) {
4266
+ if (installation.harness === 'claude-code') {
4267
+ if (handler.async !== true) {
4268
+ problems.push(
4269
+ `${installation.file} declares a blocking Stop handler; the Claude Code wake route needs \`async: true\` so the turn ends while the waiter runs`,
4270
+ )
4271
+ }
4272
+ if (handler.timeout === undefined || handler.timeout < CLAUDE_ASYNC_STOP_TIMEOUT_SECONDS) {
4273
+ problems.push(
4274
+ `${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`,
4275
+ )
4276
+ }
4277
+ continue
4278
+ }
4279
+ if (handler.timeout === undefined || handler.timeout < BLOCKING_STOP_TIMEOUT_SECONDS) {
4280
+ problems.push(
4281
+ `${installation.file} gives Stop ${handler.timeout ?? 'no'}s, but the blocking answer owner requires ${BLOCKING_STOP_TIMEOUT_SECONDS}s`,
4282
+ )
4283
+ }
4284
+ }
4285
+ return problems
4286
+ }
4287
+
4067
4288
  interface HookCheck {
4068
4289
  name: string
4069
4290
  ok: boolean
@@ -4096,7 +4317,11 @@ function hookChecks(deps: CommandDeps): HookCheck[] {
4096
4317
  .join(', '),
4097
4318
  })
4098
4319
 
4099
- const active = activeHarnessSession(deps.env)
4320
+ const { active, contested } = resolveActiveHarness(
4321
+ deps.env,
4322
+ deps.cwd,
4323
+ (deps.now ?? Date.now)(),
4324
+ )
4100
4325
  const activeInstallations =
4101
4326
  active === null
4102
4327
  ? []
@@ -4207,24 +4432,19 @@ function hookChecks(deps: CommandDeps): HookCheck[] {
4207
4432
  }),
4208
4433
  })
4209
4434
 
4210
- const requiredTimeout = blockingHookTimeoutSeconds()
4211
- const shortTimeouts = installations.flatMap((installation) =>
4212
- installation.handlers
4213
- .filter(() => installation.harness !== 'codex')
4214
- .filter((handler) => handlerEvent(handler.command) === 'stop')
4215
- .filter((handler) => handler.timeout === undefined || handler.timeout < requiredTimeout)
4216
- .map(
4217
- (handler) =>
4218
- `${installation.harness} declares ${handler.timeout ?? 'no'}s, needs ${requiredTimeout}s — run \`notifai hooks install --harness ${installation.harness}${installation.global ? ' --global' : ''}\``,
4219
- ),
4435
+ const shapeProblems = installations.flatMap((installation) =>
4436
+ stopShapeProblems(installation).map(
4437
+ (problem) =>
4438
+ `${problem} — run \`notifai hooks install --harness ${installation.harness}${installation.global ? ' --global' : ''}\``,
4439
+ ),
4220
4440
  )
4221
4441
  checks.push({
4222
- name: 'hooks (timeout)',
4223
- ok: shortTimeouts.length === 0,
4442
+ name: 'hooks (stop shape)',
4443
+ ok: shapeProblems.length === 0,
4224
4444
  detail:
4225
- shortTimeouts.length === 0
4226
- ? `declared Stop timeouts match the fixed ${requiredTimeout}s process budget where required; Codex uses host defaults`
4227
- : shortTimeouts.join('; '),
4445
+ shapeProblems.length === 0
4446
+ ? `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`
4447
+ : shapeProblems.join('; '),
4228
4448
  })
4229
4449
 
4230
4450
  // Project and global definitions for one harness both fire. Stable adapter
@@ -4262,22 +4482,51 @@ function hookChecks(deps: CommandDeps): HookCheck[] {
4262
4482
  })
4263
4483
  }
4264
4484
 
4485
+ // Which route this judges, and which remedy it prints, must both belong to
4486
+ // the harness that is actually running here. A machine-global installation
4487
+ // matches every directory, so picking whichever installation matched — or
4488
+ // whichever session last wrote a pointer — reports on a harness the agent
4489
+ // cannot influence: it is told to send a prompt in a harness that is not
4490
+ // running, follows the fail-closed rule, and can never clear the check.
4265
4491
  const firedPointer =
4266
- active?.sessionId === undefined
4492
+ active === null
4267
4493
  ? readProjectSessionPointer(deps.cwd, deps.env, (deps.now ?? Date.now)())
4268
- : readMatchingProjectSessionPointer(
4269
- deps.cwd,
4270
- deps.env,
4271
- (deps.now ?? Date.now)(),
4272
- active.sessionId,
4273
- active.harness,
4274
- )
4494
+ : active.sessionId === undefined
4495
+ ? (readLiveProjectSessionPointers(deps.cwd, deps.env, (deps.now ?? Date.now)()).find(
4496
+ (pointer) => pointer.harness === active.harness,
4497
+ ) ?? null)
4498
+ : readMatchingProjectSessionPointer(
4499
+ deps.cwd,
4500
+ deps.env,
4501
+ (deps.now ?? Date.now)(),
4502
+ active.sessionId,
4503
+ active.harness,
4504
+ )
4275
4505
  const firedState = firedPointer === null ? null : readSessionState(firedPointer.sessionId, deps.env)
4276
4506
  const promptFired = firedState?.last_prompt_at !== undefined
4277
4507
  const stopFired = firedState?.last_stop_at !== undefined
4278
4508
  const fired = firedPointer !== null && promptFired && stopFired
4279
- const activationInstallations =
4280
- active !== null && activeInstallations.length > 0 ? activeInstallations : installations
4509
+ // Installations for other harnesses are irrelevant to the active one, and an
4510
+ // active harness with none of its own has nothing to activate: say that
4511
+ // instead of advising a prompt in some other harness. When the environment
4512
+ // is contested and nothing has fired, every candidate is still possible, so
4513
+ // the advice covers all of them rather than betting on one.
4514
+ const activationHarnesses = new Set(
4515
+ contested.length > 1
4516
+ ? contested.map((candidate) => candidate.harness)
4517
+ : active === null
4518
+ ? installations.map((installation) => installation.harness)
4519
+ : [active.harness],
4520
+ )
4521
+ const activationInstallations = installations.filter((installation) =>
4522
+ activationHarnesses.has(installation.harness),
4523
+ )
4524
+ const activationAdvice =
4525
+ activationInstallations.length > 0
4526
+ ? hookActivationAdvice(activationInstallations)
4527
+ : active === null
4528
+ ? hookActivationAdvice(installations)
4529
+ : `${active.label}: no ${active.label} hook installation matches this project — run \`notifai hooks install --harness ${active.harness}\`.`
4281
4530
  checks.push({
4282
4531
  name: 'hooks (fired)',
4283
4532
  ok: fired,
@@ -4285,9 +4534,19 @@ function hookChecks(deps: CommandDeps): HookCheck[] {
4285
4534
  // a missing Stop is a broken route, not missing historical evidence.
4286
4535
  informational: firedPointer === null,
4287
4536
  detail: fired
4288
- ? 'a session in this directory has run UserPromptSubmit and Stop'
4537
+ ? active === null
4538
+ ? 'a session in this directory has run UserPromptSubmit and Stop'
4539
+ : `the active ${active.label} session has run UserPromptSubmit and Stop`
4289
4540
  : firedPointer === null
4290
- ? `no session pointer from the last 24 hours — ${hookActivationAdvice(activationInstallations)}`
4541
+ ? `no ${
4542
+ contested.length > 1
4543
+ ? `session pointer for any harness whose markers are present here (${contested
4544
+ .map((candidate) => candidate.label)
4545
+ .join(', ')})`
4546
+ : active === null
4547
+ ? 'session pointer'
4548
+ : `${active.label} session pointer`
4549
+ } from the last 24 hours — ${activationAdvice}`
4291
4550
  : `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`,
4292
4551
  ...(fired
4293
4552
  ? {}
@@ -4295,7 +4554,7 @@ function hookChecks(deps: CommandDeps): HookCheck[] {
4295
4554
  remedy: {
4296
4555
  summary:
4297
4556
  firedPointer === null
4298
- ? 'send one prompt in a session here, then re-check'
4557
+ ? `send one ${active === null || contested.length > 1 ? '' : `${active.label} `}prompt in a session here, then re-check`
4299
4558
  : 'end one harmless turn, send a new prompt, then re-check',
4300
4559
  command: 'notifai doctor',
4301
4560
  },
@@ -4325,12 +4584,77 @@ function hookChecks(deps: CommandDeps): HookCheck[] {
4325
4584
  .join('; '),
4326
4585
  })
4327
4586
 
4587
+ const wakeRoute = wakeRouteCheck(deps, active, activeInstallations)
4588
+ if (wakeRoute !== null) checks.push(wakeRoute)
4589
+
4328
4590
  const stray = codexStrayWorktreeCheck(deps)
4329
4591
  if (stray !== null) checks.push(stray)
4330
4592
 
4331
4593
  return checks
4332
4594
  }
4333
4595
 
4596
+ /**
4597
+ * Whether an answer arriving after this turn ends could actually reach this
4598
+ * exact session — the question `notifai ask` really turns on, and the one no
4599
+ * other check answers.
4600
+ *
4601
+ * Read-only, and deliberately so: nothing here connects to a socket, takes a
4602
+ * lock, or sends a message. A diagnostic that wakes the agent it is diagnosing
4603
+ * would be its own bug report.
4604
+ *
4605
+ * Everything it can report negatively is a degradation rather than a failure —
4606
+ * the accepted journal still replays the answer at the session's next turn —
4607
+ * so this is never a blocker. What it buys is that the reason has a name
4608
+ * before the user notices the silence.
4609
+ *
4610
+ * It never asks for `crossSessionInbound`. The poster is the session's own
4611
+ * hook child and takes the privileged own-child path, verified to be delivered
4612
+ * even against a `bypassPermissions` receiver while an unrelated process was
4613
+ * held. Widening a user's general inbound policy to suit Notifai would be a
4614
+ * real change to their machine's posture in exchange for nothing.
4615
+ */
4616
+ function wakeRouteCheck(
4617
+ deps: CommandDeps,
4618
+ active: ActiveHarnessSession | null,
4619
+ activeInstallations: Installation[],
4620
+ ): HookCheck | null {
4621
+ if (active === null || activeInstallations.length === 0) return null
4622
+ if (active.harness === 'claude-code') {
4623
+ const readiness = inspectClaudeInbox({
4624
+ pid: deps.claudeSourcePid ?? claudeSessionPid(deps.env),
4625
+ platform: process.platform,
4626
+ readDescriptor:
4627
+ deps.claudeWake?.readDescriptor ?? systemClaudeWakeAdapters(deps.env).readDescriptor,
4628
+ socketExists: (socketPath) => existsSync(socketPath),
4629
+ })
4630
+ return {
4631
+ name: 'hooks (wake route)',
4632
+ ok: readiness.state === 'ready',
4633
+ informational: true,
4634
+ detail:
4635
+ readiness.state === 'ready'
4636
+ ? `this Claude Code ${readiness.version} session is listening on ${readiness.socketPath}, so an answer can start a turn here without you`
4637
+ : `${readiness.reason}. Answers are still delivered, at this session's next turn rather than on their own`,
4638
+ }
4639
+ }
4640
+ if (active.harness === 'codex') {
4641
+ const readiness = inspectCodexResume(deps.env, {
4642
+ platform: process.platform,
4643
+ directoryExists: (directory) => existsSync(directory),
4644
+ })
4645
+ return {
4646
+ name: 'hooks (wake route)',
4647
+ ok: readiness.state === 'ready',
4648
+ informational: true,
4649
+ detail:
4650
+ readiness.state === 'ready'
4651
+ ? `the held Codex turn continues from its own hook, and after it returns ${readiness.lockDirectory} can prove a stopped thread unowned before resuming it`
4652
+ : `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`,
4653
+ }
4654
+ }
4655
+ return null
4656
+ }
4657
+
4334
4658
  /**
4335
4659
  * A Codex hooks file sitting in a worktree, which Codex will never read.
4336
4660
  *