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