@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.
- package/README.md +3 -4
- package/dist/claude-wake.d.ts +101 -0
- package/dist/claude-wake.js +397 -0
- package/dist/claude-wake.js.map +1 -0
- package/dist/codex-wake.d.ts +84 -0
- package/dist/codex-wake.js +238 -0
- package/dist/codex-wake.js.map +1 -0
- package/dist/commands.d.ts +15 -10
- package/dist/commands.js +321 -88
- package/dist/commands.js.map +1 -1
- package/dist/config-schema.d.ts +2 -2
- package/dist/config-schema.js +9 -35
- package/dist/config-schema.js.map +1 -1
- package/dist/config.d.ts +5 -38
- package/dist/config.js +11 -32
- package/dist/config.js.map +1 -1
- package/dist/harnesses.d.ts +3 -13
- package/dist/harnesses.js +6 -17
- package/dist/harnesses.js.map +1 -1
- package/dist/hooks.d.ts +119 -65
- package/dist/hooks.js +330 -285
- package/dist/hooks.js.map +1 -1
- package/dist/install-hooks.d.ts +22 -7
- package/dist/install-hooks.js +43 -28
- package/dist/install-hooks.js.map +1 -1
- package/dist/interactive.d.ts +1 -1
- package/dist/interactive.js +2 -4
- package/dist/interactive.js.map +1 -1
- package/dist/main.js +6 -13
- package/dist/main.js.map +1 -1
- package/dist/release.d.ts +31 -0
- package/dist/release.js +52 -0
- package/dist/release.js.map +1 -0
- package/dist/ui/theme.d.ts +1 -1
- package/package.json +9 -9
- package/src/claude-wake.test.ts +320 -0
- package/src/claude-wake.ts +489 -0
- package/src/codex-wake.test.ts +253 -0
- package/src/codex-wake.ts +288 -0
- package/src/commands.test.ts +393 -40
- package/src/commands.ts +433 -109
- package/src/config-schema.test.ts +3 -3
- package/src/config-schema.ts +11 -40
- package/src/config.test.ts +21 -16
- package/src/config.ts +15 -69
- package/src/harnesses.test.ts +7 -21
- package/src/harnesses.ts +17 -19
- package/src/hooks.test.ts +780 -290
- package/src/hooks.ts +466 -339
- package/src/install-hooks.test.ts +44 -3
- package/src/install-hooks.ts +43 -28
- package/src/interactive.ts +2 -7
- package/src/logging.test.ts +3 -3
- package/src/main.ts +6 -12
- package/src/release.ts +51 -0
- package/src/skill.test.ts +23 -0
- package/src/ui/config-view.test.ts +5 -5
- package/dist/idle.d.ts +0 -16
- package/dist/idle.js +0 -44
- package/dist/idle.js.map +0 -1
- package/src/idle.test.ts +0 -32
- 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
|
|
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
|
|
183
|
-
|
|
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() +
|
|
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(
|
|
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
|
|
1592
|
-
*
|
|
1593
|
-
*
|
|
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 =
|
|
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(
|
|
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(
|
|
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
|
|
1721
|
-
: 'Question registered. Ask it in the conversation
|
|
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
|
-
|
|
1740
|
-
|
|
1741
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1872
|
+
candidates.push({ harness: 'codex', label: 'Codex', sessionId: codexSession })
|
|
1760
1873
|
}
|
|
1761
|
-
if ((env['CURSOR_AGENT'] ?? '') !== '')
|
|
1762
|
-
return
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
2045
|
-
|
|
2046
|
-
`
|
|
2047
|
-
|
|
2048
|
-
|
|
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
|
|
2753
|
-
*
|
|
2754
|
-
*
|
|
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 =
|
|
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 ${
|
|
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 ${
|
|
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 =
|
|
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
|
|
4211
|
-
|
|
4212
|
-
|
|
4213
|
-
|
|
4214
|
-
|
|
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 (
|
|
4223
|
-
ok:
|
|
4442
|
+
name: 'hooks (stop shape)',
|
|
4443
|
+
ok: shapeProblems.length === 0,
|
|
4224
4444
|
detail:
|
|
4225
|
-
|
|
4226
|
-
? `
|
|
4227
|
-
:
|
|
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
|
|
4492
|
+
active === null
|
|
4267
4493
|
? readProjectSessionPointer(deps.cwd, deps.env, (deps.now ?? Date.now)())
|
|
4268
|
-
:
|
|
4269
|
-
|
|
4270
|
-
|
|
4271
|
-
|
|
4272
|
-
|
|
4273
|
-
|
|
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
|
-
|
|
4280
|
-
|
|
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
|
-
?
|
|
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
|
|
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
|
-
?
|
|
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
|
*
|