@raidiant/notifai 0.4.0 → 0.5.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.
package/src/commands.ts CHANGED
@@ -63,20 +63,22 @@ import {
63
63
  } from './readiness.js'
64
64
  import type { Tone } from './ui/theme.js'
65
65
  import {
66
+ WAITER_CEILING_SECONDS,
66
67
  handleSessionEnd,
67
68
  handleStop,
68
69
  handleUserPromptSubmit,
69
70
  parseHookInput,
70
71
  pruneAbandonedSessions,
72
+ readLiveProjectSessionPointers,
71
73
  readMatchingProjectSessionPointer,
72
74
  readProjectSession,
73
75
  readProjectSessionPointer,
74
76
  readSessionState,
75
77
  registerQuestion,
78
+ type EscalationDeliveryRoute,
76
79
  type HookContext,
77
80
  type HookHarness,
78
81
  } from './hooks.js'
79
- import { readIdleSeconds } from './idle.js'
80
82
  import {
81
83
  LOG_EVENTS,
82
84
  activeLogPath,
@@ -91,9 +93,10 @@ import {
91
93
  type Logger,
92
94
  } from './logging.js'
93
95
  import {
96
+ BLOCKING_STOP_TIMEOUT_SECONDS,
97
+ CLAUDE_ASYNC_STOP_TIMEOUT_SECONDS,
94
98
  HARNESSES,
95
99
  applyPlan,
96
- blockingHookTimeoutSeconds,
97
100
  buildCursorHookConfig,
98
101
  buildHookConfig,
99
102
  codexLayerDir,
@@ -112,7 +115,18 @@ import {
112
115
  type Harness,
113
116
  type Installation,
114
117
  } from './install-hooks.js'
115
- import { HARNESS_CAPABILITIES, HOOK_TIMING } from './harnesses.js'
118
+ import { HARNESS_CAPABILITIES } from './harnesses.js'
119
+ import {
120
+ claudeWakeRoute,
121
+ inspectClaudeInbox,
122
+ systemClaudeWakeAdapters,
123
+ type ClaudeWakeAdapters,
124
+ } from './claude-wake.js'
125
+ import {
126
+ codexWakeRoute,
127
+ inspectCodexResume,
128
+ type CodexWakeAdapters,
129
+ } from './codex-wake.js'
116
130
  import {
117
131
  inspectHookAdapter,
118
132
  installHookAdapter,
@@ -179,8 +193,12 @@ export interface CommandDeps {
179
193
  now?: () => number
180
194
  /** Test seam for retry/backoff timing. */
181
195
  sleep?: (milliseconds: number) => Promise<void>
182
- /** Test seam for the OS idle probe; production shells out to the platform. */
183
- idleSeconds?: () => number | null
196
+ /** Test seams for Claude liveness, socket delivery, and cold resume. */
197
+ claudeWake?: ClaudeWakeAdapters
198
+ claudeSourcePid?: number
199
+ /** Test seams for the Codex thread-writer probe and cold resume. */
200
+ codexWake?: CodexWakeAdapters
201
+ codexSourcePid?: number
184
202
  /** Test seam and production adapter for the external native skills installer. */
185
203
  nativeSkills?: NativeSkills
186
204
  /**
@@ -1209,7 +1227,7 @@ export async function hookRunCommand(
1209
1227
  // would grant slow setup a second budget and let the harness kill us before
1210
1228
  // an accepted answer is journaled or written to stdout.
1211
1229
  const now = deps.now ?? Date.now
1212
- const processDeadlineAt = now() + HOOK_TIMING.totalSeconds * 1000
1230
+ const processDeadlineAt = now() + WAITER_CEILING_SECONDS * 1000
1213
1231
 
1214
1232
  const logger = log(deps)
1215
1233
  logger.bind({ cmd: `hook ${event}` })
@@ -1347,13 +1365,7 @@ export async function hookRunCommand(
1347
1365
  `Bearer nfm_${credential.machineId}.${credential.secret}`,
1348
1366
  {
1349
1367
  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
- : {}),
1368
+ ...(event === 'stop' ? { deadlineAt: processDeadlineAt, now } : {}),
1357
1369
  },
1358
1370
  )
1359
1371
  const ctx: HookContext = {
@@ -1361,7 +1373,6 @@ export async function hookRunCommand(
1361
1373
  config: resolved,
1362
1374
  env: deps.env,
1363
1375
  now,
1364
- idleSeconds: deps.idleSeconds ?? (() => readIdleSeconds()),
1365
1376
  sleep: deps.sleep ?? ((ms) => new Promise((resolve) => setTimeout(resolve, ms))),
1366
1377
  waitForFirstReply: async (requestId, timeoutSeconds) => {
1367
1378
  const result = await waitForReply(client, requestId, {
@@ -1391,7 +1402,12 @@ export async function hookRunCommand(
1391
1402
  const outcome =
1392
1403
  event === 'user-prompt-submit'
1393
1404
  ? await handleUserPromptSubmit(ctx, envelope)
1394
- : await handleStop(ctx, envelope, processDeadlineAt)
1405
+ : await handleStop(
1406
+ ctx,
1407
+ envelope,
1408
+ processDeadlineAt,
1409
+ stopWakeRoute(deps, harness, envelope.session_id, cwd),
1410
+ )
1395
1411
  // Answer diagnostics are already persisted once as hook.answer. Keep every
1396
1412
  // other note in the lifecycle record without duplicating the user's text.
1397
1413
  const notes = outcome.notes.filter((note) => !/^(?:late )?answer from /.test(note))
@@ -1436,6 +1452,61 @@ export async function hookRunCommand(
1436
1452
  }
1437
1453
  }
1438
1454
 
1455
+ /**
1456
+ * The last meter for an answer this Stop hook accepted, chosen by harness.
1457
+ *
1458
+ * Both wake adapters need the harness process that invoked this hook: Claude's
1459
+ * to prove exact own-child session ownership before it posts to the inbox
1460
+ * socket, Codex's to know whether its own stdout is still a live continuation
1461
+ * channel. Without an exact session id neither can prove anything, so the
1462
+ * waiter falls back to the plain blocking Stop continuation.
1463
+ */
1464
+ function stopWakeRoute(
1465
+ deps: CommandDeps,
1466
+ harness: Harness | undefined,
1467
+ sessionId: string | undefined,
1468
+ cwd: string,
1469
+ ): EscalationDeliveryRoute | undefined {
1470
+ if (sessionId === undefined) return undefined
1471
+ if (harness === 'claude-code') {
1472
+ return claudeWakeRoute({
1473
+ sessionId,
1474
+ cwd,
1475
+ sourcePid: deps.claudeSourcePid ?? claudeSessionPid(deps.env),
1476
+ ...(deps.claudeWake === undefined ? {} : { adapters: deps.claudeWake }),
1477
+ })
1478
+ }
1479
+ if (harness === 'codex') {
1480
+ return codexWakeRoute({
1481
+ threadId: sessionId,
1482
+ cwd,
1483
+ sourcePid: deps.codexSourcePid ?? process.ppid,
1484
+ env: deps.env,
1485
+ ...(deps.codexWake === undefined ? {} : { adapters: deps.codexWake }),
1486
+ })
1487
+ }
1488
+ return undefined
1489
+ }
1490
+
1491
+ /**
1492
+ * The pid of the Claude Code session this hook belongs to.
1493
+ *
1494
+ * Claude exports `CLAUDE_PID` to its hooks, and that is the authoritative
1495
+ * answer. `process.ppid` agrees with it today — the hook command runs through
1496
+ * a shell, but the shell `exec`s, so this process's parent *is* the session
1497
+ * (probed against a live 2.1.228 session) — and it remains the fallback for a
1498
+ * harness build that stops exporting the variable.
1499
+ *
1500
+ * Preferring the explicit value costs a line and removes a silent failure: if
1501
+ * this ever resolved to a shell instead, no session descriptor would match, the
1502
+ * route could not prove own-child ownership, and every answer would degrade to
1503
+ * hold-for-next-turn with nothing reported as wrong.
1504
+ */
1505
+ function claudeSessionPid(env: NodeJS.ProcessEnv): number {
1506
+ const declared = Number(env['CLAUDE_PID'])
1507
+ return Number.isInteger(declared) && declared > 0 ? declared : process.ppid
1508
+ }
1509
+
1439
1510
  /**
1440
1511
  * What went wrong, in terms of what to do about it.
1441
1512
  *
@@ -1588,10 +1659,9 @@ function buildOneQuestion(
1588
1659
  }
1589
1660
 
1590
1661
  /**
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.
1662
+ * Registers a question for turn-end routing. Returns immediately so the agent
1663
+ * can ask in prose and end its turn; the terminal keeps the question to itself
1664
+ * for `ask_grace_seconds` before it reaches any device.
1595
1665
  */
1596
1666
  export function askCommand(deps: CommandDeps, question: string | undefined, flags: AskFlags): number {
1597
1667
  // Validate before route discovery. A malformed question belongs to the
@@ -1619,7 +1689,7 @@ export function askCommand(deps: CommandDeps, question: string | undefined, flag
1619
1689
  // markers identify the active owner, while UserPromptSubmit adds the hook's
1620
1690
  // canonical id to the directory's concurrent-session index.
1621
1691
  const now = (deps.now ?? Date.now)()
1622
- const active = activeHarnessSession(deps.env)
1692
+ const { active, contested } = resolveActiveHarness(deps.env, deps.cwd, now)
1623
1693
  let sessionId: string | undefined
1624
1694
  if (active !== null) {
1625
1695
  const installations = findInstallations(deps.cwd, deps.env, deps.hookAdapterHome)
@@ -1627,7 +1697,12 @@ export function askCommand(deps: CommandDeps, question: string | undefined, flag
1627
1697
  (installation) => installation.harness === active.harness,
1628
1698
  )
1629
1699
  if (!activeInstalled) {
1630
- for (const line of diagnoseActiveHarnessSession(active, 'not-installed', installations)) {
1700
+ for (const line of diagnoseActiveHarnessSession(
1701
+ active,
1702
+ 'not-installed',
1703
+ installations,
1704
+ contested,
1705
+ )) {
1631
1706
  deps.io.err(line)
1632
1707
  }
1633
1708
  return EXIT.usage
@@ -1646,7 +1721,12 @@ export function askCommand(deps: CommandDeps, question: string | undefined, flag
1646
1721
  active.harness,
1647
1722
  )
1648
1723
  if (projectPointer === null) {
1649
- for (const line of diagnoseActiveHarnessSession(active, 'not-fired', installations)) {
1724
+ for (const line of diagnoseActiveHarnessSession(
1725
+ active,
1726
+ 'not-fired',
1727
+ installations,
1728
+ contested,
1729
+ )) {
1650
1730
  deps.io.err(line)
1651
1731
  }
1652
1732
  return EXIT.usage
@@ -1717,8 +1797,32 @@ export function askCommand(deps: CommandDeps, question: string | undefined, flag
1717
1797
  }
1718
1798
  deps.io.out(
1719
1799
  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.',
1800
+ ? `${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.`
1801
+ : 'Question registered. Ask it in the conversation, state the concrete work you will resume when the answer arrives, then end your turn.',
1802
+ )
1803
+ deps.io.out('Before ending this turn, pre-commit in your own words to the work you will resume:')
1804
+ for (const [index, entry] of built.questions.entries()) {
1805
+ const questionPrefix = built.questions.length > 1 ? `Question ${index + 1}, ` : ''
1806
+ if (entry.choices !== undefined) {
1807
+ for (const choice of entry.choices) {
1808
+ deps.io.out(
1809
+ `- ${questionPrefix}If the answer is ${JSON.stringify(choice.label)}: state the concrete work you will resume.`,
1810
+ )
1811
+ }
1812
+ deps.io.out(
1813
+ `- ${questionPrefix}For an unexpected typed answer: state how it will determine the concrete work you resume.`,
1814
+ )
1815
+ } else {
1816
+ deps.io.out(
1817
+ `- ${questionPrefix}For the free-text answer: state how its content will determine the concrete work you resume.`,
1818
+ )
1819
+ }
1820
+ }
1821
+ deps.io.out(
1822
+ '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.',
1823
+ )
1824
+ deps.io.out(
1825
+ 'A Notifai answer cannot answer a harness permission prompt or interactive picker; leave those to the harness and user.',
1722
1826
  )
1723
1827
  return EXIT.ok
1724
1828
  }
@@ -1736,30 +1840,96 @@ interface ActiveHarnessSession {
1736
1840
  sessionId?: string
1737
1841
  }
1738
1842
 
1739
- function activeHarnessSession(env: NodeJS.ProcessEnv): ActiveHarnessSession | null {
1740
- const explicit = env['NOTIFAI_ACTIVE_HARNESS']
1741
- if (explicit === 'opencode') {
1843
+ /**
1844
+ * Every harness marker present in this environment, in declared order.
1845
+ *
1846
+ * Order here is a last resort, not an answer. A harness exports its markers
1847
+ * into every process it starts, so a nested harness inherits its parent's
1848
+ * markers alongside its own and the environment alone cannot say which of them
1849
+ * owns this shell; `resolveActiveHarness` settles that with live evidence.
1850
+ */
1851
+ function harnessEnvCandidates(env: NodeJS.ProcessEnv): ActiveHarnessSession[] {
1852
+ const candidates: ActiveHarnessSession[] = []
1853
+ if (env['NOTIFAI_ACTIVE_HARNESS'] === 'opencode') {
1742
1854
  const sessionId = env['NOTIFAI_ACTIVE_SESSION_ID']
1743
- return {
1855
+ candidates.push({
1744
1856
  harness: 'opencode',
1745
1857
  label: 'OpenCode',
1746
1858
  ...(sessionId === undefined || sessionId === '' ? {} : { sessionId }),
1747
- }
1859
+ })
1748
1860
  }
1749
1861
  if (env['CLAUDECODE'] === '1') {
1750
1862
  const sessionId = env['CLAUDE_CODE_SESSION_ID']
1751
- return {
1863
+ candidates.push({
1752
1864
  harness: 'claude-code',
1753
1865
  label: 'Claude Code',
1754
1866
  ...(sessionId === undefined || sessionId === '' ? {} : { sessionId }),
1755
- }
1867
+ })
1756
1868
  }
1757
1869
  const codexSession = env['CODEX_THREAD_ID']
1758
1870
  if (codexSession !== undefined && codexSession !== '') {
1759
- return { harness: 'codex', label: 'Codex', sessionId: codexSession }
1871
+ candidates.push({ harness: 'codex', label: 'Codex', sessionId: codexSession })
1760
1872
  }
1761
- if ((env['CURSOR_AGENT'] ?? '') !== '') return { harness: 'cursor', label: 'Cursor' }
1762
- return null
1873
+ if ((env['CURSOR_AGENT'] ?? '') !== '') candidates.push({ harness: 'cursor', label: 'Cursor' })
1874
+ return candidates
1875
+ }
1876
+
1877
+ interface ActiveHarnessResolution {
1878
+ active: ActiveHarnessSession | null
1879
+ /**
1880
+ * Markers of harnesses that could equally own this shell, present only when
1881
+ * nothing here has fired yet and declared order had to pick. Whatever is
1882
+ * reported then has to hold for every one of them.
1883
+ */
1884
+ contested: ActiveHarnessSession[]
1885
+ }
1886
+
1887
+ /**
1888
+ * Which harness session owns this shell, when several claim to.
1889
+ *
1890
+ * Nesting is ordinary: an orchestrator running inside Claude Code starts a
1891
+ * Codex session, and that Codex process inherits `CLAUDECODE` and
1892
+ * `CLAUDE_CODE_SESSION_ID` on top of its own `CODEX_THREAD_ID`. The mirror is
1893
+ * just as ordinary, so no fixed precedence between two markers can be right —
1894
+ * whichever one it favours is wrong in the opposite nesting, and the cost is
1895
+ * silent: `ask` looks up a session that is not this one, and every remedy the
1896
+ * agent is told to try addresses a harness that is not running here.
1897
+ *
1898
+ * The general rule is to prefer the most specific *live* signal over inherited
1899
+ * environment. An inherited marker is a claim about some ancestor process; a
1900
+ * session id that names an entry in this directory's live pointer index is
1901
+ * evidence that that exact session fired a hook here and its state still
1902
+ * exists. Live evidence therefore wins over declared order, and the most
1903
+ * recently active pointer wins between two live candidates: the harness whose
1904
+ * turn is running is the one that fired last, while its parent sits blocked on
1905
+ * the child it started. Declared order decides only when nothing here has
1906
+ * fired yet, and it says so — every route fails closed there anyway.
1907
+ */
1908
+ function resolveActiveHarness(
1909
+ env: NodeJS.ProcessEnv,
1910
+ cwd: string,
1911
+ now: number,
1912
+ ): ActiveHarnessResolution {
1913
+ const candidates = harnessEnvCandidates(env)
1914
+ const first = candidates[0]
1915
+ if (first === undefined) return { active: null, contested: [] }
1916
+ if (candidates.length === 1) return { active: first, contested: [] }
1917
+ for (const pointer of readLiveProjectSessionPointers(cwd, env, now)) {
1918
+ const owner = candidates.find(
1919
+ (candidate) =>
1920
+ candidate.harness === pointer.harness && candidate.sessionId === pointer.sessionId,
1921
+ )
1922
+ if (owner !== undefined) return { active: owner, contested: [] }
1923
+ }
1924
+ return { active: first, contested: candidates }
1925
+ }
1926
+
1927
+ function activeHarnessSession(
1928
+ env: NodeJS.ProcessEnv,
1929
+ cwd: string,
1930
+ now: number,
1931
+ ): ActiveHarnessSession | null {
1932
+ return resolveActiveHarness(env, cwd, now).active
1763
1933
  }
1764
1934
 
1765
1935
  /**
@@ -1807,20 +1977,7 @@ function activeQuestionRouteProblems(
1807
1977
  for (const problem of inspectHookAdapter(deps.hookAdapterHome).problems) {
1808
1978
  problems.push(problem)
1809
1979
  }
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
- }
1980
+ for (const installation of matching) problems.push(...stopShapeProblems(installation))
1824
1981
  problems.push(...codexTrustProblems(matching, deps.env))
1825
1982
  return [...new Set(problems)]
1826
1983
  }
@@ -1831,11 +1988,22 @@ function diagnoseActiveHarnessSession(
1831
1988
  active: ActiveHarnessSession,
1832
1989
  problem: ActiveHarnessProblem,
1833
1990
  installations: Installation[],
1991
+ contested: ActiveHarnessSession[] = [],
1834
1992
  ): string[] {
1993
+ // Naming one harness confidently is wrong when the environment carries the
1994
+ // markers of several and none of them has fired here: whichever one the
1995
+ // agent is told to prompt may not be the one it is running in.
1996
+ const ambiguity =
1997
+ contested.length > 1
1998
+ ? [
1999
+ `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.`,
2000
+ ]
2001
+ : []
1835
2002
  if (problem === 'not-installed') {
1836
2003
  const others = installations.map((installation) => installation.harness)
1837
2004
  return [
1838
2005
  `Could not register the question for the active ${active.label} session: Notifai ${active.label} hooks are not installed for this project.`,
2006
+ ...ambiguity,
1839
2007
  ...(others.length === 0
1840
2008
  ? []
1841
2009
  : [
@@ -1848,12 +2016,14 @@ function diagnoseActiveHarnessSession(
1848
2016
  if (problem === 'different-session') {
1849
2017
  return [
1850
2018
  `Could not register the question for the active ${active.label} session: the project pointer belongs to another ${active.label} session or harness.`,
2019
+ ...ambiguity,
1851
2020
  `Refusing to guess or cross-wire the question. Send one prompt in this ${active.label} session, then run \`notifai doctor\`.`,
1852
2021
  `Retry \`notifai ask\` only after doctor reports that this active ${active.label} session fired the hooks.`,
1853
2022
  ]
1854
2023
  }
1855
2024
  return [
1856
2025
  `Could not register the question for the active ${active.label} session: Notifai hooks are installed, but this session has not published its pointer.`,
2026
+ ...ambiguity,
1857
2027
  `Send one ${active.label} prompt, then run \`notifai doctor\`.`,
1858
2028
  `Retry \`notifai ask\` only after doctor reports that the active ${active.label} session fired the hooks.`,
1859
2029
  ]
@@ -1974,7 +2144,7 @@ export function hooksInstallCommand(deps: CommandDeps, flags: HooksInstallFlags)
1974
2144
  if (harness === 'opencode') {
1975
2145
  return installOpencodePlugin(deps, file, {
1976
2146
  adapterPath,
1977
- timeoutSeconds: blockingHookTimeoutSeconds(),
2147
+ timeoutSeconds: BLOCKING_STOP_TIMEOUT_SECONDS,
1978
2148
  })
1979
2149
  }
1980
2150
 
@@ -2041,27 +2211,11 @@ export function hooksInstallCommand(deps: CommandDeps, flags: HooksInstallFlags)
2041
2211
  deps.io.out(` removed: ${merged.removed.join(', ')} (this build no longer serves them)`)
2042
2212
  }
2043
2213
  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
- }
2214
+ deps.io.out(
2215
+ config.ask_grace_seconds.value === 0
2216
+ ? 'A question registered with `notifai ask` goes to your devices immediately when the agent turn ends, whether or not you are at this machine.'
2217
+ : `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.`,
2218
+ )
2065
2219
  if (harness === 'codex') {
2066
2220
  const layer = flags.global ? null : codexLayerDir(deps.cwd)
2067
2221
  if (layer !== null) {
@@ -2079,6 +2233,15 @@ export function hooksInstallCommand(deps: CommandDeps, flags: HooksInstallFlags)
2079
2233
  }
2080
2234
  }
2081
2235
  deps.io.out('')
2236
+ if (harness === 'claude-code') {
2237
+ // Codex deliberately gets no counterpart line: its Stop definition is
2238
+ // byte-identical to the one earlier builds wrote, so nothing about its
2239
+ // trust state changed and saying so would invent a gate that is not there.
2240
+ deps.io.out(
2241
+ `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.`,
2242
+ )
2243
+ deps.io.out('')
2244
+ }
2082
2245
  if (harness === 'claude-code' && flags.global !== true) {
2083
2246
  deps.io.out('Claude Code reloads project hook files without a restart. Send one new prompt,')
2084
2247
  deps.io.out('then check `notifai doctor` to confirm that the hook fired.')
@@ -2753,7 +2916,7 @@ export function logsCommand(deps: CommandDeps, flags: LogsFlags): number {
2753
2916
  * 1.5.x, `owner/repo@name` selects a skill; a Git ref belongs after `#`.
2754
2917
  * Keep this immutable and public because the command is printed to users.
2755
2918
  */
2756
- export const SKILLS_SOURCE = 'Raidiant-io/notifai#v0.4.0'
2919
+ export const SKILLS_SOURCE = 'Raidiant-io/notifai#v0.5.0'
2757
2920
 
2758
2921
  function skillSourceParts(): { source: string; ref: string } | null {
2759
2922
  const match = /^([^#]+)#(.+)$/.exec(SKILLS_SOURCE)
@@ -3984,7 +4147,7 @@ function remedyLine(state: ReadinessState): string {
3984
4147
  */
3985
4148
  function hookStates(deps: CommandDeps): ReadinessState[] {
3986
4149
  const installations = findInstallations(deps.cwd, deps.env, deps.hookAdapterHome)
3987
- const active = activeHarnessSession(deps.env)
4150
+ const active = activeHarnessSession(deps.env, deps.cwd, (deps.now ?? Date.now)())
3988
4151
  const config = loadLoggedConfig(deps, { cwd: deps.cwd, env: deps.env })
3989
4152
  const settings: ReadinessState = {
3990
4153
  id: 'question-routing-settings',
@@ -3992,10 +4155,7 @@ function hookStates(deps: CommandDeps): ReadinessState[] {
3992
4155
  status: config.ask_notifications.value ? 'ready' : 'gap',
3993
4156
  detail: [
3994
4157
  `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
4158
  `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
4159
  ].join(', '),
4000
4160
  ...(config.ask_notifications.value
4001
4161
  ? {}
@@ -4064,6 +4224,49 @@ function hookStates(deps: CommandDeps): ReadinessState[] {
4064
4224
  ]
4065
4225
  }
4066
4226
 
4227
+ /**
4228
+ * Whether an installed Stop handler declares the shape its harness needs.
4229
+ *
4230
+ * The three answers differ, and getting one wrong fails silently in a
4231
+ * different way each time:
4232
+ *
4233
+ * - Claude Code's handler must be `async: true`, or the waiter holds the
4234
+ * user's turn for its whole wait instead of returning at once. It must
4235
+ * also declare a `timeout`, because the harness default is 600 s and the
4236
+ * kill is silent — the backgrounded waiter vanishes and the answer the
4237
+ * user already gave is never delivered.
4238
+ * - Codex owns its Stop timeout. Declaring one changes the definition it
4239
+ * hashes into `trusted_hash`, and an untrusted handler is simply not run.
4240
+ * - Everything else blocks its turn and needs a ceiling above the wait.
4241
+ */
4242
+ function stopShapeProblems(installation: Installation): string[] {
4243
+ if (installation.harness === 'codex') return []
4244
+ const problems: string[] = []
4245
+ for (const handler of installation.handlers.filter(
4246
+ (entry) => handlerEvent(entry.command) === 'stop',
4247
+ )) {
4248
+ if (installation.harness === 'claude-code') {
4249
+ if (handler.async !== true) {
4250
+ problems.push(
4251
+ `${installation.file} declares a blocking Stop handler; the Claude Code wake route needs \`async: true\` so the turn ends while the waiter runs`,
4252
+ )
4253
+ }
4254
+ if (handler.timeout === undefined || handler.timeout < CLAUDE_ASYNC_STOP_TIMEOUT_SECONDS) {
4255
+ problems.push(
4256
+ `${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`,
4257
+ )
4258
+ }
4259
+ continue
4260
+ }
4261
+ if (handler.timeout === undefined || handler.timeout < BLOCKING_STOP_TIMEOUT_SECONDS) {
4262
+ problems.push(
4263
+ `${installation.file} gives Stop ${handler.timeout ?? 'no'}s, but the blocking answer owner requires ${BLOCKING_STOP_TIMEOUT_SECONDS}s`,
4264
+ )
4265
+ }
4266
+ }
4267
+ return problems
4268
+ }
4269
+
4067
4270
  interface HookCheck {
4068
4271
  name: string
4069
4272
  ok: boolean
@@ -4096,7 +4299,11 @@ function hookChecks(deps: CommandDeps): HookCheck[] {
4096
4299
  .join(', '),
4097
4300
  })
4098
4301
 
4099
- const active = activeHarnessSession(deps.env)
4302
+ const { active, contested } = resolveActiveHarness(
4303
+ deps.env,
4304
+ deps.cwd,
4305
+ (deps.now ?? Date.now)(),
4306
+ )
4100
4307
  const activeInstallations =
4101
4308
  active === null
4102
4309
  ? []
@@ -4207,24 +4414,19 @@ function hookChecks(deps: CommandDeps): HookCheck[] {
4207
4414
  }),
4208
4415
  })
4209
4416
 
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
- ),
4417
+ const shapeProblems = installations.flatMap((installation) =>
4418
+ stopShapeProblems(installation).map(
4419
+ (problem) =>
4420
+ `${problem} — run \`notifai hooks install --harness ${installation.harness}${installation.global ? ' --global' : ''}\``,
4421
+ ),
4220
4422
  )
4221
4423
  checks.push({
4222
- name: 'hooks (timeout)',
4223
- ok: shortTimeouts.length === 0,
4424
+ name: 'hooks (stop shape)',
4425
+ ok: shapeProblems.length === 0,
4224
4426
  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('; '),
4427
+ shapeProblems.length === 0
4428
+ ? `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`
4429
+ : shapeProblems.join('; '),
4228
4430
  })
4229
4431
 
4230
4432
  // Project and global definitions for one harness both fire. Stable adapter
@@ -4262,22 +4464,51 @@ function hookChecks(deps: CommandDeps): HookCheck[] {
4262
4464
  })
4263
4465
  }
4264
4466
 
4467
+ // Which route this judges, and which remedy it prints, must both belong to
4468
+ // the harness that is actually running here. A machine-global installation
4469
+ // matches every directory, so picking whichever installation matched — or
4470
+ // whichever session last wrote a pointer — reports on a harness the agent
4471
+ // cannot influence: it is told to send a prompt in a harness that is not
4472
+ // running, follows the fail-closed rule, and can never clear the check.
4265
4473
  const firedPointer =
4266
- active?.sessionId === undefined
4474
+ active === null
4267
4475
  ? 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
- )
4476
+ : active.sessionId === undefined
4477
+ ? (readLiveProjectSessionPointers(deps.cwd, deps.env, (deps.now ?? Date.now)()).find(
4478
+ (pointer) => pointer.harness === active.harness,
4479
+ ) ?? null)
4480
+ : readMatchingProjectSessionPointer(
4481
+ deps.cwd,
4482
+ deps.env,
4483
+ (deps.now ?? Date.now)(),
4484
+ active.sessionId,
4485
+ active.harness,
4486
+ )
4275
4487
  const firedState = firedPointer === null ? null : readSessionState(firedPointer.sessionId, deps.env)
4276
4488
  const promptFired = firedState?.last_prompt_at !== undefined
4277
4489
  const stopFired = firedState?.last_stop_at !== undefined
4278
4490
  const fired = firedPointer !== null && promptFired && stopFired
4279
- const activationInstallations =
4280
- active !== null && activeInstallations.length > 0 ? activeInstallations : installations
4491
+ // Installations for other harnesses are irrelevant to the active one, and an
4492
+ // active harness with none of its own has nothing to activate: say that
4493
+ // instead of advising a prompt in some other harness. When the environment
4494
+ // is contested and nothing has fired, every candidate is still possible, so
4495
+ // the advice covers all of them rather than betting on one.
4496
+ const activationHarnesses = new Set(
4497
+ contested.length > 1
4498
+ ? contested.map((candidate) => candidate.harness)
4499
+ : active === null
4500
+ ? installations.map((installation) => installation.harness)
4501
+ : [active.harness],
4502
+ )
4503
+ const activationInstallations = installations.filter((installation) =>
4504
+ activationHarnesses.has(installation.harness),
4505
+ )
4506
+ const activationAdvice =
4507
+ activationInstallations.length > 0
4508
+ ? hookActivationAdvice(activationInstallations)
4509
+ : active === null
4510
+ ? hookActivationAdvice(installations)
4511
+ : `${active.label}: no ${active.label} hook installation matches this project — run \`notifai hooks install --harness ${active.harness}\`.`
4281
4512
  checks.push({
4282
4513
  name: 'hooks (fired)',
4283
4514
  ok: fired,
@@ -4285,9 +4516,19 @@ function hookChecks(deps: CommandDeps): HookCheck[] {
4285
4516
  // a missing Stop is a broken route, not missing historical evidence.
4286
4517
  informational: firedPointer === null,
4287
4518
  detail: fired
4288
- ? 'a session in this directory has run UserPromptSubmit and Stop'
4519
+ ? active === null
4520
+ ? 'a session in this directory has run UserPromptSubmit and Stop'
4521
+ : `the active ${active.label} session has run UserPromptSubmit and Stop`
4289
4522
  : firedPointer === null
4290
- ? `no session pointer from the last 24 hours — ${hookActivationAdvice(activationInstallations)}`
4523
+ ? `no ${
4524
+ contested.length > 1
4525
+ ? `session pointer for any harness whose markers are present here (${contested
4526
+ .map((candidate) => candidate.label)
4527
+ .join(', ')})`
4528
+ : active === null
4529
+ ? 'session pointer'
4530
+ : `${active.label} session pointer`
4531
+ } from the last 24 hours — ${activationAdvice}`
4291
4532
  : `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
4533
  ...(fired
4293
4534
  ? {}
@@ -4295,7 +4536,7 @@ function hookChecks(deps: CommandDeps): HookCheck[] {
4295
4536
  remedy: {
4296
4537
  summary:
4297
4538
  firedPointer === null
4298
- ? 'send one prompt in a session here, then re-check'
4539
+ ? `send one ${active === null || contested.length > 1 ? '' : `${active.label} `}prompt in a session here, then re-check`
4299
4540
  : 'end one harmless turn, send a new prompt, then re-check',
4300
4541
  command: 'notifai doctor',
4301
4542
  },
@@ -4325,12 +4566,77 @@ function hookChecks(deps: CommandDeps): HookCheck[] {
4325
4566
  .join('; '),
4326
4567
  })
4327
4568
 
4569
+ const wakeRoute = wakeRouteCheck(deps, active, activeInstallations)
4570
+ if (wakeRoute !== null) checks.push(wakeRoute)
4571
+
4328
4572
  const stray = codexStrayWorktreeCheck(deps)
4329
4573
  if (stray !== null) checks.push(stray)
4330
4574
 
4331
4575
  return checks
4332
4576
  }
4333
4577
 
4578
+ /**
4579
+ * Whether an answer arriving after this turn ends could actually reach this
4580
+ * exact session — the question `notifai ask` really turns on, and the one no
4581
+ * other check answers.
4582
+ *
4583
+ * Read-only, and deliberately so: nothing here connects to a socket, takes a
4584
+ * lock, or sends a message. A diagnostic that wakes the agent it is diagnosing
4585
+ * would be its own bug report.
4586
+ *
4587
+ * Everything it can report negatively is a degradation rather than a failure —
4588
+ * the accepted journal still replays the answer at the session's next turn —
4589
+ * so this is never a blocker. What it buys is that the reason has a name
4590
+ * before the user notices the silence.
4591
+ *
4592
+ * It never asks for `crossSessionInbound`. The poster is the session's own
4593
+ * hook child and takes the privileged own-child path, verified to be delivered
4594
+ * even against a `bypassPermissions` receiver while an unrelated process was
4595
+ * held. Widening a user's general inbound policy to suit Notifai would be a
4596
+ * real change to their machine's posture in exchange for nothing.
4597
+ */
4598
+ function wakeRouteCheck(
4599
+ deps: CommandDeps,
4600
+ active: ActiveHarnessSession | null,
4601
+ activeInstallations: Installation[],
4602
+ ): HookCheck | null {
4603
+ if (active === null || activeInstallations.length === 0) return null
4604
+ if (active.harness === 'claude-code') {
4605
+ const readiness = inspectClaudeInbox({
4606
+ pid: deps.claudeSourcePid ?? claudeSessionPid(deps.env),
4607
+ platform: process.platform,
4608
+ readDescriptor:
4609
+ deps.claudeWake?.readDescriptor ?? systemClaudeWakeAdapters(deps.env).readDescriptor,
4610
+ socketExists: (socketPath) => existsSync(socketPath),
4611
+ })
4612
+ return {
4613
+ name: 'hooks (wake route)',
4614
+ ok: readiness.state === 'ready',
4615
+ informational: true,
4616
+ detail:
4617
+ readiness.state === 'ready'
4618
+ ? `this Claude Code ${readiness.version} session is listening on ${readiness.socketPath}, so an answer can start a turn here without you`
4619
+ : `${readiness.reason}. Answers are still delivered, at this session's next turn rather than on their own`,
4620
+ }
4621
+ }
4622
+ if (active.harness === 'codex') {
4623
+ const readiness = inspectCodexResume(deps.env, {
4624
+ platform: process.platform,
4625
+ directoryExists: (directory) => existsSync(directory),
4626
+ })
4627
+ return {
4628
+ name: 'hooks (wake route)',
4629
+ ok: readiness.state === 'ready',
4630
+ informational: true,
4631
+ detail:
4632
+ readiness.state === 'ready'
4633
+ ? `the held Codex turn continues from its own hook, and after it returns ${readiness.lockDirectory} can prove a stopped thread unowned before resuming it`
4634
+ : `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`,
4635
+ }
4636
+ }
4637
+ return null
4638
+ }
4639
+
4334
4640
  /**
4335
4641
  * A Codex hooks file sitting in a worktree, which Codex will never read.
4336
4642
  *