@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/README.md +3 -4
- package/package.json +1 -1
- 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 +375 -39
- package/src/commands.ts +410 -104
- 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/skill.test.ts +23 -0
- package/src/ui/config-view.test.ts +5 -5
- package/src/idle.test.ts +0 -32
- package/src/idle.ts +0 -41
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
|
|
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
|
|
183
|
-
|
|
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() +
|
|
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(
|
|
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
|
|
1592
|
-
*
|
|
1593
|
-
*
|
|
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 =
|
|
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(
|
|
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(
|
|
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
|
|
1721
|
-
: 'Question registered. Ask it in the conversation
|
|
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
|
-
|
|
1740
|
-
|
|
1741
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1871
|
+
candidates.push({ harness: 'codex', label: 'Codex', sessionId: codexSession })
|
|
1760
1872
|
}
|
|
1761
|
-
if ((env['CURSOR_AGENT'] ?? '') !== '')
|
|
1762
|
-
return
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
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
|
-
}
|
|
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.
|
|
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 =
|
|
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
|
|
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
|
-
),
|
|
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 (
|
|
4223
|
-
ok:
|
|
4424
|
+
name: 'hooks (stop shape)',
|
|
4425
|
+
ok: shapeProblems.length === 0,
|
|
4224
4426
|
detail:
|
|
4225
|
-
|
|
4226
|
-
? `
|
|
4227
|
-
:
|
|
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
|
|
4474
|
+
active === null
|
|
4267
4475
|
? readProjectSessionPointer(deps.cwd, deps.env, (deps.now ?? Date.now)())
|
|
4268
|
-
:
|
|
4269
|
-
|
|
4270
|
-
|
|
4271
|
-
|
|
4272
|
-
|
|
4273
|
-
|
|
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
|
-
|
|
4280
|
-
|
|
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
|
-
?
|
|
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
|
|
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
|
-
?
|
|
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
|
*
|