@raidiant/notifai 0.5.0 → 1.0.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/CHANGELOG.md +165 -0
- package/README.md +11 -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 +31 -11
- package/dist/commands.js +679 -232
- package/dist/commands.js.map +1 -1
- package/dist/config-schema.d.ts +3 -3
- package/dist/config-schema.js +11 -49
- package/dist/config-schema.js.map +1 -1
- package/dist/config.d.ts +23 -45
- package/dist/config.js +61 -39
- package/dist/config.js.map +1 -1
- package/dist/harnesses.d.ts +4 -13
- package/dist/harnesses.js +12 -17
- package/dist/harnesses.js.map +1 -1
- package/dist/hook-adapter.d.ts +10 -1
- package/dist/hook-adapter.js +79 -10
- package/dist/hook-adapter.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 +76 -24
- package/dist/install-hooks.js +301 -125
- package/dist/install-hooks.js.map +1 -1
- package/dist/interactive.d.ts +32 -1
- package/dist/interactive.js +165 -51
- package/dist/interactive.js.map +1 -1
- package/dist/main.js +14 -21
- package/dist/main.js.map +1 -1
- package/dist/native-skills.d.ts +1 -1
- package/dist/native-skills.js +23 -58
- package/dist/native-skills.js.map +1 -1
- package/dist/pack-feedback-slice.d.ts +35 -0
- package/dist/pack-feedback-slice.js +126 -0
- package/dist/pack-feedback-slice.js.map +1 -0
- package/dist/readiness.d.ts +20 -0
- package/dist/readiness.js +24 -0
- package/dist/readiness.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 +11 -10
- package/src/commands.test.ts +386 -36
- package/src/commands.ts +473 -182
- package/src/config-schema.ts +2 -17
- package/src/config.test.ts +29 -4
- package/src/config.ts +67 -11
- package/src/harnesses.ts +7 -0
- package/src/hook-adapter.test.ts +43 -1
- package/src/hook-adapter.ts +83 -11
- package/src/hooks.test.ts +4 -5
- package/src/install-hooks.test.ts +154 -2
- package/src/install-hooks.ts +231 -44
- package/src/interactive.test.ts +338 -0
- package/src/interactive.ts +221 -49
- package/src/main.ts +14 -20
- package/src/native-skills.test.ts +75 -0
- package/src/native-skills.ts +31 -75
- package/src/pack-feedback-slice.test.ts +212 -0
- package/src/pack-feedback-slice.ts +187 -0
- package/src/readiness.test.ts +25 -0
- package/src/readiness.ts +38 -0
- package/src/release.ts +51 -0
- package/src/skill.test.ts +43 -0
- package/src/ui/config-view.test.ts +5 -4
- 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
|
@@ -7,18 +7,20 @@ import { parse as parseToml, stringify as stringifyToml } from 'smol-toml';
|
|
|
7
7
|
import { CAPABILITIES_V1, QUESTION_TEXT_MAX_LENGTH, REPLY_MAX_QUESTIONS, REPLY_MAX_WINDOW_SECONDS, validateDraft, } from '@raidiant/notifai-protocol';
|
|
8
8
|
import { sha256Hex } from '@raidiant/notifai-protocol/node';
|
|
9
9
|
import { ApiCallError, NetworkError, createClient, isRetryableReplyPollError, } from './client.js';
|
|
10
|
-
import { BOOLEAN_CONFIG_KEYS, CONFIG_KEYS, LOG_LEVELS, NUMERIC_CONFIG_KEYS, configBounds, configDefaultValue, findProjectConfigPath,
|
|
10
|
+
import { BOOLEAN_CONFIG_KEYS, CONFIG_KEYS, LOG_LEVELS, NUMERIC_CONFIG_KEYS, configBounds, configDefaultValue, findProjectConfigPath, personalProjectConfigPath, globalConfigPath, loadConfig, sessionConfigPath, } from './config.js';
|
|
11
11
|
import { acceptedValues, configInfo } from './config-schema.js';
|
|
12
|
+
import { packageVersion, 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
|
-
import { firstBlocker,
|
|
16
|
-
import { handleSessionEnd, handleStop, handleUserPromptSubmit, parseHookInput, pruneAbandonedSessions, readMatchingProjectSessionPointer, readProjectSession, readProjectSessionPointer, readSessionState, registerQuestion, } from './hooks.js';
|
|
17
|
-
import { readIdleSeconds } from './idle.js';
|
|
16
|
+
import { firstBlocker, } from './readiness.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,
|
|
21
|
-
import {
|
|
19
|
+
import { BLOCKING_STOP_TIMEOUT_SECONDS, CLAUDE_ASYNC_STOP_TIMEOUT_SECONDS, HARNESSES, applyPlan, buildCursorHookConfig, buildHookConfig, collapseUnusedCodexRepresentation, codexLayerDir, codexLayerPaths, codexRepresentationProblems, codexTrustProblems, codexProjectRoot, detectHarness, detectedHarnesses, findInstallations, handlerEvent, hookDefinitionFiles, inspectCodexLayer, loadCursorSettings, loadSettings, mergeCursorHooks, mergeHooks, removeCursorHooks, removeHooks, settingsFile, } from './install-hooks.js';
|
|
20
|
+
import { HARNESS_CAPABILITIES, HARNESS_LABELS } from './harnesses.js';
|
|
21
|
+
import { claudeWakeRoute, inspectClaudeInbox, systemClaudeWakeAdapters, } from './claude-wake.js';
|
|
22
|
+
import { codexWakeRoute, inspectCodexResume, } from './codex-wake.js';
|
|
23
|
+
import { inspectHookAdapter, installHookAdapter, isNpxAdapterTarget, } 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';
|
|
24
26
|
/** The invocation's logger, or one that discards everything. */
|
|
@@ -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
|
];
|
|
@@ -1520,34 +1628,85 @@ export async function closeCommand(deps, requestId) {
|
|
|
1520
1628
|
return reportError(deps, err);
|
|
1521
1629
|
}
|
|
1522
1630
|
}
|
|
1631
|
+
/** True when this process is `npx` / `npm exec`, not a global or linked install. */
|
|
1632
|
+
export function runningViaNpx(env, scriptPath) {
|
|
1633
|
+
if (env['npm_command'] === 'exec')
|
|
1634
|
+
return true;
|
|
1635
|
+
return scriptPath.includes(`${path.sep}_npx${path.sep}`);
|
|
1636
|
+
}
|
|
1637
|
+
function fileHookInstallTarget(target) {
|
|
1638
|
+
if (target === undefined || isNpxAdapterTarget(target))
|
|
1639
|
+
return undefined;
|
|
1640
|
+
return target;
|
|
1641
|
+
}
|
|
1642
|
+
function resolveHookAdapterTarget(deps, flags) {
|
|
1643
|
+
if (deps.hookInstallTarget !== undefined && isNpxAdapterTarget(deps.hookInstallTarget)) {
|
|
1644
|
+
return deps.hookInstallTarget;
|
|
1645
|
+
}
|
|
1646
|
+
const fileTarget = fileHookInstallTarget(deps.hookInstallTarget);
|
|
1647
|
+
const execPath = flags.execPath ?? fileTarget?.execPath ?? process.execPath;
|
|
1648
|
+
const scriptPath = flags.scriptPath ?? fileTarget?.scriptPath ?? process.argv[1] ?? 'notifai';
|
|
1649
|
+
if (runningViaNpx(deps.env, scriptPath)) {
|
|
1650
|
+
const version = packageVersion();
|
|
1651
|
+
const npmCli = deps.env['npm_execpath'];
|
|
1652
|
+
if (version === null) {
|
|
1653
|
+
throw new Error('Could not read this CLI version, so an npx hook target cannot be pinned. Install `@raidiant/notifai` globally and rerun `notifai hooks install`.');
|
|
1654
|
+
}
|
|
1655
|
+
if (typeof npmCli !== 'string' || npmCli === '') {
|
|
1656
|
+
throw new Error('This process looks like npx but npm_execpath is missing. Install `@raidiant/notifai` globally and rerun `notifai hooks install`.');
|
|
1657
|
+
}
|
|
1658
|
+
return { kind: 'npx', execPath, npmCli, spec: `@raidiant/notifai@${version}` };
|
|
1659
|
+
}
|
|
1660
|
+
return { execPath, scriptPath };
|
|
1661
|
+
}
|
|
1662
|
+
function printHooksInstallClose(deps, harness, file) {
|
|
1663
|
+
const label = HARNESS_LABELS[harness];
|
|
1664
|
+
if (deps.io.interactive === true && deps.io.note) {
|
|
1665
|
+
void deps.io.note(`${file}\nSend one ${label} prompt, then run \`notifai doctor\`.`, `${label} hooks installed`);
|
|
1666
|
+
return;
|
|
1667
|
+
}
|
|
1668
|
+
deps.io.out(`Installed ${harness} hooks in ${file}`);
|
|
1669
|
+
deps.io.out(`Send one ${label} prompt, then check \`notifai doctor\`.`);
|
|
1670
|
+
}
|
|
1523
1671
|
export function hooksInstallCommand(deps, flags) {
|
|
1672
|
+
if (flags.harness === undefined) {
|
|
1673
|
+
const detected = detectedHarnesses(deps.cwd, deps.env);
|
|
1674
|
+
if (detected.length === 0) {
|
|
1675
|
+
deps.io.err(`Could not tell which harness to install for — pass --harness <${HARNESSES.join('|')}>.`);
|
|
1676
|
+
return EXIT.usage;
|
|
1677
|
+
}
|
|
1678
|
+
let ok = true;
|
|
1679
|
+
for (const harness of detected) {
|
|
1680
|
+
if (hooksInstallCommand(deps, { ...flags, harness }) !== EXIT.ok)
|
|
1681
|
+
ok = false;
|
|
1682
|
+
}
|
|
1683
|
+
return ok ? EXIT.ok : EXIT.failed;
|
|
1684
|
+
}
|
|
1524
1685
|
const harness = resolveHarness(deps, flags.harness);
|
|
1525
1686
|
if (!harness)
|
|
1526
1687
|
return EXIT.usage;
|
|
1527
|
-
const
|
|
1528
|
-
const scriptPath = flags.scriptPath ?? process.argv[1] ?? 'notifai';
|
|
1688
|
+
const adapterTarget = resolveHookAdapterTarget(deps, flags);
|
|
1689
|
+
const scriptPath = flags.scriptPath ?? fileHookInstallTarget(adapterTarget)?.scriptPath ?? process.argv[1] ?? 'notifai';
|
|
1529
1690
|
let adapterPath;
|
|
1530
1691
|
try {
|
|
1531
|
-
adapterPath = installHookAdapter(
|
|
1692
|
+
adapterPath = installHookAdapter(adapterTarget, deps.hookAdapterHome).path;
|
|
1532
1693
|
}
|
|
1533
1694
|
catch (err) {
|
|
1534
1695
|
deps.io.err(`Could not prepare the stable hook adapter: ${String(err)}`);
|
|
1535
1696
|
return EXIT.failed;
|
|
1536
1697
|
}
|
|
1537
|
-
const config = loadLoggedConfig(deps, { cwd: deps.cwd, env: deps.env });
|
|
1538
1698
|
const file = settingsFile(harness, flags.global ?? false, deps.cwd, deps.env);
|
|
1539
1699
|
// OpenCode's adapter is a generated plugin module rather than a handler
|
|
1540
1700
|
// merged into a settings document, so it owns the whole file.
|
|
1541
1701
|
if (harness === 'opencode') {
|
|
1542
1702
|
return installOpencodePlugin(deps, file, {
|
|
1543
1703
|
adapterPath,
|
|
1544
|
-
timeoutSeconds:
|
|
1704
|
+
timeoutSeconds: BLOCKING_STOP_TIMEOUT_SECONDS,
|
|
1545
1705
|
});
|
|
1546
1706
|
}
|
|
1547
1707
|
if (harness === 'cursor') {
|
|
1548
|
-
let merged;
|
|
1549
1708
|
try {
|
|
1550
|
-
|
|
1709
|
+
withTargetFileLock(file, () => {
|
|
1551
1710
|
const document = loadCursorSettings(file);
|
|
1552
1711
|
const result = mergeCursorHooks(document, buildCursorHookConfig({
|
|
1553
1712
|
adapterPath,
|
|
@@ -1561,28 +1720,15 @@ export function hooksInstallCommand(deps, flags) {
|
|
|
1561
1720
|
deps.io.err(String(err));
|
|
1562
1721
|
return EXIT.failed;
|
|
1563
1722
|
}
|
|
1564
|
-
deps
|
|
1565
|
-
if (merged.replaced.length > 0)
|
|
1566
|
-
deps.io.out(` replaced: ${merged.replaced.join(', ')}`);
|
|
1567
|
-
if (merged.added.length > 0)
|
|
1568
|
-
deps.io.out(` added: ${merged.added.join(', ')}`);
|
|
1569
|
-
if (flags.global) {
|
|
1570
|
-
deps.io.out('Send one Cursor prompt, then check `notifai doctor`. If the hook has not fired,');
|
|
1571
|
-
deps.io.out('start a new Cursor session and try one prompt again.');
|
|
1572
|
-
}
|
|
1573
|
-
else {
|
|
1574
|
-
deps.io.out('Send one Cursor prompt, then check `notifai doctor`. If the hook has not fired,');
|
|
1575
|
-
deps.io.out('start a new Cursor session and try one prompt again.');
|
|
1576
|
-
}
|
|
1577
|
-
deps.io.out('Cursor hooks can emit a native follow-up, but its agent shell does not expose');
|
|
1578
|
-
deps.io.out('the exact conversation id needed to register safely. `notifai ask` therefore');
|
|
1579
|
-
deps.io.out('fails closed; use blocking `notifai send --reply` for questions.');
|
|
1723
|
+
printHooksInstallClose(deps, harness, file);
|
|
1580
1724
|
return EXIT.ok;
|
|
1581
1725
|
}
|
|
1582
|
-
let
|
|
1726
|
+
let foreignStopCount = 0;
|
|
1727
|
+
let collapsedFrom = null;
|
|
1583
1728
|
try {
|
|
1584
|
-
|
|
1729
|
+
withTargetFileLock(file, () => {
|
|
1585
1730
|
const document = loadSettings(file);
|
|
1731
|
+
foreignStopCount = foreignStopHandlers(document).length;
|
|
1586
1732
|
const result = mergeHooks(document, buildHookConfig({
|
|
1587
1733
|
adapterPath,
|
|
1588
1734
|
harness,
|
|
@@ -1590,67 +1736,41 @@ export function hooksInstallCommand(deps, flags) {
|
|
|
1590
1736
|
applyPlan(file, result.document);
|
|
1591
1737
|
return result;
|
|
1592
1738
|
});
|
|
1739
|
+
if (harness === 'codex') {
|
|
1740
|
+
collapsedFrom = collapseUnusedCodexRepresentation(file, scriptPath, codexLayerPaths(flags.global ?? false, deps.cwd, deps.env));
|
|
1741
|
+
}
|
|
1593
1742
|
}
|
|
1594
1743
|
catch (err) {
|
|
1595
1744
|
deps.io.err(String(err));
|
|
1596
1745
|
return EXIT.failed;
|
|
1597
1746
|
}
|
|
1598
|
-
deps.io.out(`Installed ${harness} hooks in ${file}`);
|
|
1599
|
-
if (merged.replaced.length > 0)
|
|
1600
|
-
deps.io.out(` replaced: ${merged.replaced.join(', ')}`);
|
|
1601
|
-
if (merged.added.length > 0)
|
|
1602
|
-
deps.io.out(` added: ${merged.added.join(', ')}`);
|
|
1603
|
-
if (merged.removed.length > 0) {
|
|
1604
|
-
deps.io.out(` removed: ${merged.removed.join(', ')} (this build no longer serves them)`);
|
|
1605
|
-
}
|
|
1606
|
-
deps.io.out('');
|
|
1607
|
-
if (config.require_idle.value) {
|
|
1608
|
-
deps.io.out(`While keyboard or mouse idle time stays below ${config.away_after_seconds.value}s, ` +
|
|
1609
|
-
`nothing is pushed. A question registered with \`notifai ask\` goes to your devices ` +
|
|
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
|
-
}
|
|
1623
1747
|
if (harness === 'codex') {
|
|
1624
1748
|
const layer = flags.global ? null : codexLayerDir(deps.cwd);
|
|
1625
|
-
if (layer !== null)
|
|
1626
|
-
// Codex reads project hooks from the main repository but only looks when
|
|
1627
|
-
// a `.codex` directory sits at or above cwd, so a worktree install has to
|
|
1628
|
-
// write one file and create one directory in two different places. Doing
|
|
1629
|
-
// it silently would leave the next person deriving this the hard way
|
|
1630
|
-
//.
|
|
1749
|
+
if (layer !== null)
|
|
1631
1750
|
mkdirSync(layer, { recursive: true });
|
|
1632
|
-
deps.io.out('');
|
|
1633
|
-
deps.io.out('You are in a worktree. Codex reads project hooks from the main repository,');
|
|
1634
|
-
deps.io.out(`so they were written to ${file}. ${layer} was created so this`);
|
|
1635
|
-
deps.io.out('worktree discovers that file. Each other worktree needs its own `.codex`');
|
|
1636
|
-
deps.io.out('directory; rerun this installer from that worktree to create it.');
|
|
1637
|
-
}
|
|
1638
1751
|
}
|
|
1639
|
-
deps
|
|
1640
|
-
if (
|
|
1641
|
-
deps.io.out(
|
|
1642
|
-
deps.io.out('then check `notifai doctor` to confirm that the hook fired.');
|
|
1752
|
+
printHooksInstallClose(deps, harness, file);
|
|
1753
|
+
if (collapsedFrom !== null) {
|
|
1754
|
+
deps.io.out(`Collapsed ${collapsedFrom} onto ${file} so this layer has one representation.`);
|
|
1643
1755
|
}
|
|
1644
|
-
|
|
1645
|
-
deps.io.out(
|
|
1646
|
-
deps.io.out('not fired, start a new Claude Code session and try one prompt again.');
|
|
1756
|
+
if (foreignStopCount > 0) {
|
|
1757
|
+
deps.io.out("This layer already has a Stop handler. Codex runs every matching handler, so Notifai's Stop and the existing one will both fire.");
|
|
1647
1758
|
}
|
|
1648
|
-
|
|
1649
|
-
|
|
1650
|
-
|
|
1759
|
+
if (harness === 'codex') {
|
|
1760
|
+
for (const problem of codexRepresentationProblems(deps.cwd, deps.env)) {
|
|
1761
|
+
deps.io.out(problem);
|
|
1762
|
+
}
|
|
1651
1763
|
}
|
|
1652
1764
|
return EXIT.ok;
|
|
1653
1765
|
}
|
|
1766
|
+
function foreignStopHandlers(document) {
|
|
1767
|
+
const groups = document.hooks?.['Stop'];
|
|
1768
|
+
if (!Array.isArray(groups))
|
|
1769
|
+
return [];
|
|
1770
|
+
return groups
|
|
1771
|
+
.flatMap((group) => group.hooks ?? [])
|
|
1772
|
+
.filter((handler) => !/ hook (user-prompt-submit|stop|session-end)\b/.test(handler.command));
|
|
1773
|
+
}
|
|
1654
1774
|
/**
|
|
1655
1775
|
* Writes the OpenCode plugin, replacing any Notifai plugin already there —
|
|
1656
1776
|
* including one a different checkout wrote, matched on the managed marker for
|
|
@@ -1677,16 +1797,7 @@ function installOpencodePlugin(deps, file, options) {
|
|
|
1677
1797
|
deps.io.err(String(err));
|
|
1678
1798
|
return EXIT.failed;
|
|
1679
1799
|
}
|
|
1680
|
-
deps
|
|
1681
|
-
deps.io.out('');
|
|
1682
|
-
deps.io.out('It maps chat.message to presence, session.idle to question escalation, and');
|
|
1683
|
-
deps.io.out('session.deleted to local cleanup through the same `notifai hook` commands');
|
|
1684
|
-
deps.io.out('the other harnesses run. Permission prompts stay in OpenCode.');
|
|
1685
|
-
deps.io.out('OpenCode does not expose a proven exactly-once continuation after session.idle,');
|
|
1686
|
-
deps.io.out('so `notifai ask` fails closed there. Use blocking `notifai send --reply` when');
|
|
1687
|
-
deps.io.out('the answer must return to the agent without another human prompt.');
|
|
1688
|
-
deps.io.out('');
|
|
1689
|
-
deps.io.out('Restart OpenCode: it loads plugins once at start.');
|
|
1800
|
+
printHooksInstallClose(deps, 'opencode', file);
|
|
1690
1801
|
return EXIT.ok;
|
|
1691
1802
|
}
|
|
1692
1803
|
export function hooksUninstallCommand(deps, flags) {
|
|
@@ -1743,28 +1854,34 @@ export function hooksUninstallCommand(deps, flags) {
|
|
|
1743
1854
|
: `No Notifai hooks found in ${file}`);
|
|
1744
1855
|
return EXIT.ok;
|
|
1745
1856
|
}
|
|
1746
|
-
|
|
1857
|
+
const files = hookDefinitionFiles(harness, flags.global ?? false, deps.cwd, deps.env);
|
|
1858
|
+
const existing = files.filter((candidate) => existsSync(candidate));
|
|
1859
|
+
if (existing.length === 0) {
|
|
1860
|
+
deps.io.out(`Nothing to remove: ${file} does not exist.`);
|
|
1861
|
+
return EXIT.ok;
|
|
1862
|
+
}
|
|
1863
|
+
let removedAny = false;
|
|
1747
1864
|
try {
|
|
1748
|
-
|
|
1749
|
-
|
|
1750
|
-
|
|
1751
|
-
|
|
1752
|
-
|
|
1753
|
-
|
|
1754
|
-
|
|
1755
|
-
|
|
1865
|
+
for (const candidate of existing) {
|
|
1866
|
+
const stripped = withTargetFileLock(candidate, () => {
|
|
1867
|
+
const document = loadSettings(candidate);
|
|
1868
|
+
const result = removeHooks(document, scriptPath);
|
|
1869
|
+
applyPlan(candidate, result.document);
|
|
1870
|
+
return result;
|
|
1871
|
+
});
|
|
1872
|
+
if (stripped.replaced.length > 0) {
|
|
1873
|
+
removedAny = true;
|
|
1874
|
+
deps.io.out(`Removed Notifai hooks (${stripped.replaced.join(', ')}) from ${candidate}`);
|
|
1875
|
+
}
|
|
1876
|
+
}
|
|
1756
1877
|
}
|
|
1757
1878
|
catch (err) {
|
|
1758
1879
|
deps.io.err(String(err));
|
|
1759
1880
|
return EXIT.failed;
|
|
1760
1881
|
}
|
|
1761
|
-
if (
|
|
1762
|
-
deps.io.out(`
|
|
1763
|
-
return EXIT.ok;
|
|
1882
|
+
if (!removedAny) {
|
|
1883
|
+
deps.io.out(`No Notifai hooks found in ${existing.join(', ')}`);
|
|
1764
1884
|
}
|
|
1765
|
-
deps.io.out(stripped.replaced.length > 0
|
|
1766
|
-
? `Removed Notifai hooks (${stripped.replaced.join(', ')}) from ${file}`
|
|
1767
|
-
: `No Notifai hooks found in ${file}`);
|
|
1768
1885
|
return EXIT.ok;
|
|
1769
1886
|
}
|
|
1770
1887
|
function assertOwnedRegularFile(file) {
|
|
@@ -1777,6 +1894,12 @@ function assertOwnedRegularFile(file) {
|
|
|
1777
1894
|
throw new Error(`${file} is owned by uid ${stat.uid}, not the current user.`);
|
|
1778
1895
|
}
|
|
1779
1896
|
}
|
|
1897
|
+
const HARNESS_LABEL = {
|
|
1898
|
+
'claude-code': 'Claude Code',
|
|
1899
|
+
codex: 'Codex',
|
|
1900
|
+
cursor: 'Cursor',
|
|
1901
|
+
opencode: 'OpenCode',
|
|
1902
|
+
};
|
|
1780
1903
|
function resolveHarness(deps, requested) {
|
|
1781
1904
|
if (requested !== undefined) {
|
|
1782
1905
|
if (HARNESSES.includes(requested))
|
|
@@ -1784,20 +1907,61 @@ function resolveHarness(deps, requested) {
|
|
|
1784
1907
|
deps.io.err(`Unknown harness "${requested}". Supported: ${HARNESSES.join(', ')}.`);
|
|
1785
1908
|
return null;
|
|
1786
1909
|
}
|
|
1787
|
-
const detected = detectHarness(deps.cwd);
|
|
1910
|
+
const detected = detectHarness(deps.cwd, deps.env);
|
|
1788
1911
|
if (!detected) {
|
|
1789
1912
|
deps.io.err(`Could not tell which harness to install for — pass --harness <${HARNESSES.join('|')}>.`);
|
|
1790
1913
|
return null;
|
|
1791
1914
|
}
|
|
1792
1915
|
return detected;
|
|
1793
1916
|
}
|
|
1917
|
+
/**
|
|
1918
|
+
* Which harnesses to wire. An explicit `--harness` still wins as a singleton.
|
|
1919
|
+
* Otherwise: every detected harness, or a human picker when detection is empty
|
|
1920
|
+
* or names more than one.
|
|
1921
|
+
*/
|
|
1922
|
+
async function pickHarnessesToInstall(deps, requested) {
|
|
1923
|
+
if (requested !== undefined) {
|
|
1924
|
+
const harness = resolveHarness(deps, requested);
|
|
1925
|
+
return harness === null ? null : [harness];
|
|
1926
|
+
}
|
|
1927
|
+
const detected = detectedHarnesses(deps.cwd, deps.env);
|
|
1928
|
+
if (detected.length === 1)
|
|
1929
|
+
return detected;
|
|
1930
|
+
if (deps.io.interactive === true && deps.io.multiselect) {
|
|
1931
|
+
const picked = await deps.io.multiselect('Which agent harnesses should Notifai wire here?', HARNESSES.map((name) => ({
|
|
1932
|
+
value: name,
|
|
1933
|
+
label: HARNESS_LABEL[name],
|
|
1934
|
+
...(detected.includes(name) ? { hint: 'detected on this machine' } : {}),
|
|
1935
|
+
})), detected);
|
|
1936
|
+
if (picked === null)
|
|
1937
|
+
return null;
|
|
1938
|
+
const unknown = picked.filter((name) => !HARNESSES.includes(name));
|
|
1939
|
+
if (unknown.length > 0) {
|
|
1940
|
+
deps.io.err(`Unknown harness "${unknown[0]}". Supported: ${HARNESSES.join(', ')}.`);
|
|
1941
|
+
return null;
|
|
1942
|
+
}
|
|
1943
|
+
return picked;
|
|
1944
|
+
}
|
|
1945
|
+
if (detected.length === 0) {
|
|
1946
|
+
deps.io.err(`Could not tell which harness to wire. Run: notifai hooks install --harness <${HARNESSES.join('|')}>`);
|
|
1947
|
+
return null;
|
|
1948
|
+
}
|
|
1949
|
+
return detected;
|
|
1950
|
+
}
|
|
1794
1951
|
// ---------------------------------------------------------------------------
|
|
1795
1952
|
// config show / set / unset
|
|
1796
1953
|
// ---------------------------------------------------------------------------
|
|
1797
1954
|
export function configShowCommand(deps, flags) {
|
|
1798
1955
|
const config = loadLoggedConfig(deps, { cwd: deps.cwd, env: deps.env });
|
|
1799
1956
|
if (flags.json) {
|
|
1800
|
-
const output = Object.fromEntries(CONFIG_KEYS.map((key) => [
|
|
1957
|
+
const output = Object.fromEntries(CONFIG_KEYS.map((key) => [
|
|
1958
|
+
key,
|
|
1959
|
+
{
|
|
1960
|
+
value: config[key].value,
|
|
1961
|
+
source: config[key].source,
|
|
1962
|
+
summary: configInfo(key).summary,
|
|
1963
|
+
},
|
|
1964
|
+
]));
|
|
1801
1965
|
deps.io.out(JSON.stringify(output, null, 2));
|
|
1802
1966
|
return EXIT.ok;
|
|
1803
1967
|
}
|
|
@@ -1971,7 +2135,7 @@ async function configMutationTarget(deps, flags) {
|
|
|
1971
2135
|
const selected = await deps.io.select('Where should this setting live?', [
|
|
1972
2136
|
{ value: 'global', label: 'This machine', hint: 'applies across projects' },
|
|
1973
2137
|
{ value: 'project', label: 'This project (shared)', hint: '.notifai/config.toml' },
|
|
1974
|
-
{ value: 'local', label: 'This project (personal)', hint: '
|
|
2138
|
+
{ value: 'local', label: 'This project (personal)', hint: 'stored on this machine, not in the repo' },
|
|
1975
2139
|
]);
|
|
1976
2140
|
if (selected === null) {
|
|
1977
2141
|
deps.io.err('No configuration layer selected.');
|
|
@@ -1982,7 +2146,7 @@ async function configMutationTarget(deps, flags) {
|
|
|
1982
2146
|
const targetPath = flags.session
|
|
1983
2147
|
? sessionConfigPath(flags.session, deps.env)
|
|
1984
2148
|
: layer === 'local'
|
|
1985
|
-
? (
|
|
2149
|
+
? personalProjectConfigPath(deps.cwd, deps.env)
|
|
1986
2150
|
: layer === 'project'
|
|
1987
2151
|
? (findProjectConfigPath(deps.cwd) ?? path.join(deps.cwd, '.notifai', 'config.toml'))
|
|
1988
2152
|
: globalConfigPath(deps.env);
|
|
@@ -2222,12 +2386,21 @@ export function logsCommand(deps, flags) {
|
|
|
2222
2386
|
// init
|
|
2223
2387
|
// ---------------------------------------------------------------------------
|
|
2224
2388
|
/**
|
|
2225
|
-
* Where `npx skills add` fetches the optional agent skill from
|
|
2226
|
-
*
|
|
2227
|
-
*
|
|
2389
|
+
* Where `npx skills add` fetches the optional agent skill from, derived from
|
|
2390
|
+
* this build's own version so the pin cannot drift from the release it names.
|
|
2391
|
+
* Null when the build cannot establish its version; see `./release.js`.
|
|
2392
|
+
*/
|
|
2393
|
+
export const SKILLS_SOURCE = skillsSource();
|
|
2394
|
+
/**
|
|
2395
|
+
* How to refer to the pin in user-facing text.
|
|
2396
|
+
*
|
|
2397
|
+
* Only reached in a corrupted install, where naming the tag is impossible but
|
|
2398
|
+
* saying nothing would be worse — the sentence still has to read as English.
|
|
2228
2399
|
*/
|
|
2229
|
-
|
|
2400
|
+
const SKILLS_SOURCE_LABEL = SKILLS_SOURCE ?? 'the public release tag matching this CLI';
|
|
2230
2401
|
function skillSourceParts() {
|
|
2402
|
+
if (SKILLS_SOURCE === null)
|
|
2403
|
+
return null;
|
|
2231
2404
|
const match = /^([^#]+)#(.+)$/.exec(SKILLS_SOURCE);
|
|
2232
2405
|
return match === null ? null : { source: match[1], ref: match[2] };
|
|
2233
2406
|
}
|
|
@@ -2257,7 +2430,7 @@ async function skillReadiness(deps, selectedScope) {
|
|
|
2257
2430
|
id: 'skill',
|
|
2258
2431
|
title: 'Agent guidance skill',
|
|
2259
2432
|
status: 'ready',
|
|
2260
|
-
detail: `installed from ${
|
|
2433
|
+
detail: `installed from ${SKILLS_SOURCE_LABEL} in the ${installed.scope} scope`,
|
|
2261
2434
|
};
|
|
2262
2435
|
}
|
|
2263
2436
|
const errors = results
|
|
@@ -2270,7 +2443,7 @@ async function skillReadiness(deps, selectedScope) {
|
|
|
2270
2443
|
status: 'optional-gap',
|
|
2271
2444
|
detail: errors.length > 0
|
|
2272
2445
|
? `could not verify installer-managed state in ${scopeText} (${errors.join('; ')})`
|
|
2273
|
-
: `not installed from ${
|
|
2446
|
+
: `not installed from ${SKILLS_SOURCE_LABEL} in ${scopeText}`,
|
|
2274
2447
|
remedy: {
|
|
2275
2448
|
by: 'cli',
|
|
2276
2449
|
summary: 'install the skill agents follow when deciding to notify',
|
|
@@ -2380,23 +2553,27 @@ async function closeGap(deps, state, flags) {
|
|
|
2380
2553
|
return 'closed';
|
|
2381
2554
|
}
|
|
2382
2555
|
if (state.id === 'hooks') {
|
|
2383
|
-
|
|
2384
|
-
if (
|
|
2385
|
-
const picked = await deps.io.select('Which agent harness do you use here?', HARNESSES.map((name) => ({ value: name, label: name })));
|
|
2386
|
-
if (picked !== null)
|
|
2387
|
-
harness = picked;
|
|
2388
|
-
}
|
|
2389
|
-
if (harness === null) {
|
|
2390
|
-
deps.io.err(`Could not tell which harness to wire. Run: notifai hooks install --harness <${HARNESSES.join('|')}>`);
|
|
2556
|
+
const harnesses = await pickHarnessesToInstall(deps);
|
|
2557
|
+
if (harnesses === null || harnesses.length === 0)
|
|
2391
2558
|
return 'failed';
|
|
2559
|
+
let ok = true;
|
|
2560
|
+
for (const harness of harnesses) {
|
|
2561
|
+
if (hooksInstallCommand(deps, { harness }) !== EXIT.ok)
|
|
2562
|
+
ok = false;
|
|
2392
2563
|
}
|
|
2393
|
-
return
|
|
2564
|
+
return ok ? 'closed' : 'failed';
|
|
2394
2565
|
}
|
|
2395
2566
|
if (state.id === 'skill') {
|
|
2396
2567
|
if (deps.nativeSkills === undefined) {
|
|
2397
2568
|
deps.io.err('Skill installation failed — the native `npx skills` flow is unavailable.');
|
|
2398
2569
|
return 'failed';
|
|
2399
2570
|
}
|
|
2571
|
+
// Refuse rather than reach for a mutable ref: installing the skill from a
|
|
2572
|
+
// moving branch is the one outcome the pin exists to prevent.
|
|
2573
|
+
if (SKILLS_SOURCE === null) {
|
|
2574
|
+
deps.io.err('Skill installation failed — this build cannot determine its own version, so there is no release tag to install from.');
|
|
2575
|
+
return 'failed';
|
|
2576
|
+
}
|
|
2400
2577
|
const scopeText = flags.skillsScope === undefined ? 'the scope you choose' : `${flags.skillsScope} scope`;
|
|
2401
2578
|
deps.io.out(`Starting the native npx skills setup for the notifai agent skill (${scopeText})...`);
|
|
2402
2579
|
const addOptions = {
|
|
@@ -2673,6 +2850,12 @@ async function runSetupProof(deps) {
|
|
|
2673
2850
|
'Proof may still arrive — re-run `notifai init` and it will re-check this same notification.');
|
|
2674
2851
|
return 'pending';
|
|
2675
2852
|
}
|
|
2853
|
+
/** Closing a local gap cannot have changed the service, the keychain, or devices. */
|
|
2854
|
+
function refreshAfterClose(id) {
|
|
2855
|
+
return id === 'project' || id === 'hooks' || id === 'skill' || id === 'question-routing-settings'
|
|
2856
|
+
? ['local']
|
|
2857
|
+
: undefined;
|
|
2858
|
+
}
|
|
2676
2859
|
/** Whether an optional gap should be closed, given flags and who is watching. */
|
|
2677
2860
|
function wantsOptional(deps, state, flags) {
|
|
2678
2861
|
// Naming the project is init's whole reason to touch the filesystem, costs
|
|
@@ -2726,8 +2909,12 @@ export async function initCommand(deps, flags) {
|
|
|
2726
2909
|
return EXIT.usage;
|
|
2727
2910
|
}
|
|
2728
2911
|
await deps.io.intro?.('Notifai setup');
|
|
2729
|
-
const
|
|
2730
|
-
let readiness = await
|
|
2912
|
+
const skillOpts = flags.skillsScope === undefined ? {} : { skillScope: flags.skillsScope };
|
|
2913
|
+
let readiness = await assessReadiness(deps, skillOpts);
|
|
2914
|
+
const reassess = (refresh) => assessReadiness(deps, {
|
|
2915
|
+
...skillOpts,
|
|
2916
|
+
...(refresh === undefined ? {} : { previous: readiness, refresh }),
|
|
2917
|
+
});
|
|
2731
2918
|
let failed = false;
|
|
2732
2919
|
const attempted = new Set();
|
|
2733
2920
|
// Re-assess after every successful action. This is how a browser approval or
|
|
@@ -2759,7 +2946,7 @@ export async function initCommand(deps, flags) {
|
|
|
2759
2946
|
if (result === 'failed')
|
|
2760
2947
|
failed = true;
|
|
2761
2948
|
if (result === 'failed' && state.status === 'optional-gap') {
|
|
2762
|
-
readiness = await reassess();
|
|
2949
|
+
readiness = await reassess(refreshAfterClose(state.id));
|
|
2763
2950
|
advanced = true;
|
|
2764
2951
|
break;
|
|
2765
2952
|
}
|
|
@@ -2767,7 +2954,7 @@ export async function initCommand(deps, flags) {
|
|
|
2767
2954
|
stop = true;
|
|
2768
2955
|
break;
|
|
2769
2956
|
}
|
|
2770
|
-
readiness = await reassess();
|
|
2957
|
+
readiness = await reassess(refreshAfterClose(state.id));
|
|
2771
2958
|
advanced = true;
|
|
2772
2959
|
break;
|
|
2773
2960
|
}
|
|
@@ -2780,7 +2967,7 @@ export async function initCommand(deps, flags) {
|
|
|
2780
2967
|
stop = true;
|
|
2781
2968
|
break;
|
|
2782
2969
|
}
|
|
2783
|
-
readiness = await reassess();
|
|
2970
|
+
readiness = await reassess(refreshAfterClose(state.id));
|
|
2784
2971
|
advanced = true;
|
|
2785
2972
|
break;
|
|
2786
2973
|
}
|
|
@@ -2822,34 +3009,74 @@ export async function initCommand(deps, flags) {
|
|
|
2822
3009
|
if (stop || !advanced)
|
|
2823
3010
|
break;
|
|
2824
3011
|
}
|
|
2825
|
-
|
|
2826
|
-
|
|
2827
|
-
|
|
3012
|
+
await printInitClose(deps, readiness, flags);
|
|
3013
|
+
const blocker = firstBlocker(readiness);
|
|
3014
|
+
if (blocker === null)
|
|
3015
|
+
return failed ? EXIT.failed : EXIT.ok;
|
|
3016
|
+
return failed || deps.io.interactive !== true ? EXIT.failed : EXIT.ok;
|
|
3017
|
+
}
|
|
3018
|
+
function isHookSubstate(id) {
|
|
3019
|
+
return id === 'hooks' || id.startsWith('hooks-') || id === 'question-routing-settings';
|
|
3020
|
+
}
|
|
3021
|
+
function leftoverOptionals(readiness, flags) {
|
|
3022
|
+
return readiness.states.filter((state) => {
|
|
3023
|
+
if (state.status !== 'optional-gap')
|
|
3024
|
+
return false;
|
|
3025
|
+
if (isHookSubstate(state.id))
|
|
3026
|
+
return false;
|
|
3027
|
+
if (state.id === 'skill' && flags.skills === false)
|
|
3028
|
+
return false;
|
|
3029
|
+
return true;
|
|
3030
|
+
});
|
|
3031
|
+
}
|
|
3032
|
+
function printOptionalLeftovers(deps, leftovers) {
|
|
3033
|
+
for (const state of leftovers) {
|
|
3034
|
+
deps.io.out(`Optional, not set up — ${remedyLine(state)}`);
|
|
2828
3035
|
}
|
|
3036
|
+
}
|
|
3037
|
+
function questionsWillRoute(readiness) {
|
|
3038
|
+
const hooks = readiness.states.find((state) => state.id === 'hooks');
|
|
3039
|
+
const settings = readiness.states.find((state) => state.id === 'question-routing-settings');
|
|
3040
|
+
return hooks?.status === 'ready' && settings?.status !== 'gap';
|
|
3041
|
+
}
|
|
3042
|
+
async function printInitClose(deps, readiness, flags) {
|
|
2829
3043
|
const blocker = firstBlocker(readiness);
|
|
3044
|
+
const canSend = readiness.states.find((state) => state.id === 'devices')?.status === 'ready';
|
|
3045
|
+
const questions = questionsWillRoute(readiness);
|
|
3046
|
+
const leftovers = leftoverOptionals(readiness, flags);
|
|
3047
|
+
if (deps.io.interactive === true) {
|
|
3048
|
+
if (blocker === null) {
|
|
3049
|
+
const lines = [
|
|
3050
|
+
canSend ? 'You can send notifications.' : 'You are signed in.',
|
|
3051
|
+
questions
|
|
3052
|
+
? 'Questions will reach your devices.'
|
|
3053
|
+
: 'Questions stay in the terminal until hooks are installed.',
|
|
3054
|
+
];
|
|
3055
|
+
await deps.io.note?.(lines.join('\n'), 'Ready');
|
|
3056
|
+
printOptionalLeftovers(deps, leftovers);
|
|
3057
|
+
deps.io.out('All set. Agents in this project can notify you and ask questions.');
|
|
3058
|
+
await deps.io.outro?.('All set ✨');
|
|
3059
|
+
return;
|
|
3060
|
+
}
|
|
3061
|
+
await deps.io.note?.(`${blocker.title} — ${blocker.detail}\n${remedyLine(blocker)}`, 'Next');
|
|
3062
|
+
deps.io.out(`Next: ${blocker.title} — ${blocker.detail}`);
|
|
3063
|
+
deps.io.out(` ${remedyLine(blocker)}`);
|
|
3064
|
+
if (blocker.remedy?.by === 'user-elsewhere' || blocker.remedy?.by === 'user-here') {
|
|
3065
|
+
deps.io.out(' Then re-run `notifai init` and it will pick up from here.');
|
|
3066
|
+
}
|
|
3067
|
+
await deps.io.outro?.('One step remains (above)');
|
|
3068
|
+
return;
|
|
3069
|
+
}
|
|
2830
3070
|
if (blocker === null) {
|
|
2831
|
-
|
|
2832
|
-
for (const state of skipped)
|
|
2833
|
-
deps.io.out(`Optional, not set up — ${remedyLine(state)}`);
|
|
3071
|
+
printOptionalLeftovers(deps, leftovers);
|
|
2834
3072
|
deps.io.out('All set. Agents in this project can notify you and ask questions.');
|
|
2835
|
-
|
|
2836
|
-
return failed ? EXIT.failed : EXIT.ok;
|
|
3073
|
+
return;
|
|
2837
3074
|
}
|
|
2838
|
-
// Exactly one. Everything else waits until this is done, because the next
|
|
2839
|
-
// gap is frequently a consequence of this one and naming it now would send
|
|
2840
|
-
// the reader off to fix something that is not actually wrong.
|
|
2841
|
-
deps.io.out('');
|
|
2842
3075
|
deps.io.out(`Next: ${blocker.title} — ${blocker.detail}`);
|
|
2843
3076
|
deps.io.out(` ${remedyLine(blocker)}`);
|
|
2844
|
-
// Both "do it here" and "do it elsewhere" leave setup mid-journey; re-run is
|
|
2845
|
-
// how the rest is recovered either way.
|
|
2846
3077
|
if (blocker.remedy?.by === 'user-elsewhere' || blocker.remedy?.by === 'user-here') {
|
|
2847
3078
|
deps.io.out(' Then re-run `notifai init` and it will pick up from here.');
|
|
2848
3079
|
}
|
|
2849
|
-
await deps.io.outro?.('One step remains (above)');
|
|
2850
|
-
// An agent must be able to branch on setup being blocked without parsing
|
|
2851
|
-
// prose. A present human may deliberately leave and resume later.
|
|
2852
|
-
return failed || deps.io.interactive !== true ? EXIT.failed : EXIT.ok;
|
|
2853
3080
|
}
|
|
2854
3081
|
// ---------------------------------------------------------------------------
|
|
2855
3082
|
// doctor
|
|
@@ -2892,23 +3119,10 @@ async function contractCheck(client) {
|
|
|
2892
3119
|
};
|
|
2893
3120
|
}
|
|
2894
3121
|
}
|
|
2895
|
-
|
|
2896
|
-
* Read the whole setup once, in dependency order.
|
|
2897
|
-
*
|
|
2898
|
-
* Descent stops where a prerequisite is missing: without a credential there is
|
|
2899
|
-
* nothing to ask the server with, and without a reachable server a contract
|
|
2900
|
-
* mismatch is unknowable rather than absent. Those downstream states report
|
|
2901
|
-
* `unknown`, which is the honest answer and keeps a network outage from
|
|
2902
|
-
* looking like a broken install.
|
|
2903
|
-
*/
|
|
2904
|
-
export async function assessReadiness(deps, options = {}) {
|
|
2905
|
-
const config = loadLoggedConfig(deps, { cwd: deps.cwd, env: deps.env });
|
|
2906
|
-
const states = [];
|
|
2907
|
-
let accountClient = null;
|
|
2908
|
-
let accountDevices = null;
|
|
3122
|
+
function projectReadiness(deps, config) {
|
|
2909
3123
|
const configPath = path.join(deps.cwd, '.notifai', 'config.toml');
|
|
2910
3124
|
const projectSlug = config.project.value;
|
|
2911
|
-
|
|
3125
|
+
return projectSlug !== null
|
|
2912
3126
|
? {
|
|
2913
3127
|
id: 'project',
|
|
2914
3128
|
title: 'Project identity',
|
|
@@ -2928,7 +3142,73 @@ export async function assessReadiness(deps, options = {}) {
|
|
|
2928
3142
|
summary: 'name this project after its directory',
|
|
2929
3143
|
command: 'notifai init',
|
|
2930
3144
|
},
|
|
2931
|
-
}
|
|
3145
|
+
};
|
|
3146
|
+
}
|
|
3147
|
+
function remoteStatesFrom(previous) {
|
|
3148
|
+
const pick = (id) => previous.states.find((state) => state.id === id);
|
|
3149
|
+
const credential = pick('credential');
|
|
3150
|
+
const server = pick('server');
|
|
3151
|
+
const contract = pick('contract');
|
|
3152
|
+
const auth = pick('auth');
|
|
3153
|
+
const devices = pick('devices');
|
|
3154
|
+
const proof = pick('proof');
|
|
3155
|
+
if (credential === undefined ||
|
|
3156
|
+
server === undefined ||
|
|
3157
|
+
contract === undefined ||
|
|
3158
|
+
auth === undefined ||
|
|
3159
|
+
devices === undefined ||
|
|
3160
|
+
proof === undefined) {
|
|
3161
|
+
return null;
|
|
3162
|
+
}
|
|
3163
|
+
return { credential, server, contract, auth, devices, proof };
|
|
3164
|
+
}
|
|
3165
|
+
function remoteInvalidatedByConfig(previous, config) {
|
|
3166
|
+
if (config.base_url.source === 'default')
|
|
3167
|
+
return false;
|
|
3168
|
+
const server = previous.states.find((state) => state.id === 'server');
|
|
3169
|
+
return server === undefined || !server.detail.includes(config.base_url.value);
|
|
3170
|
+
}
|
|
3171
|
+
/**
|
|
3172
|
+
* Read the whole setup once, in dependency order.
|
|
3173
|
+
*
|
|
3174
|
+
* Descent stops where a prerequisite is missing: without a credential there is
|
|
3175
|
+
* nothing to ask the server with, and without a reachable server a contract
|
|
3176
|
+
* mismatch is unknowable rather than absent. Those downstream states report
|
|
3177
|
+
* `unknown`, which is the honest answer and keeps a network outage from
|
|
3178
|
+
* looking like a broken install.
|
|
3179
|
+
*/
|
|
3180
|
+
export async function assessReadiness(deps, options = {}) {
|
|
3181
|
+
const config = loadLoggedConfig(deps, { cwd: deps.cwd, env: deps.env });
|
|
3182
|
+
const previous = options.previous;
|
|
3183
|
+
const refresh = options.refresh;
|
|
3184
|
+
if (previous !== undefined && refresh !== undefined && refresh.length === 0)
|
|
3185
|
+
return previous;
|
|
3186
|
+
const reuseRemote = previous !== undefined &&
|
|
3187
|
+
refresh !== undefined &&
|
|
3188
|
+
!refresh.includes('remote') &&
|
|
3189
|
+
!remoteInvalidatedByConfig(previous, config);
|
|
3190
|
+
if (reuseRemote) {
|
|
3191
|
+
const reused = remoteStatesFrom(previous);
|
|
3192
|
+
if (reused !== null) {
|
|
3193
|
+
return {
|
|
3194
|
+
states: [
|
|
3195
|
+
projectReadiness(deps, config),
|
|
3196
|
+
reused.credential,
|
|
3197
|
+
reused.server,
|
|
3198
|
+
reused.contract,
|
|
3199
|
+
reused.auth,
|
|
3200
|
+
...hookStates(deps),
|
|
3201
|
+
await skillReadiness(deps, options.skillScope),
|
|
3202
|
+
reused.devices,
|
|
3203
|
+
reused.proof,
|
|
3204
|
+
],
|
|
3205
|
+
};
|
|
3206
|
+
}
|
|
3207
|
+
}
|
|
3208
|
+
const states = [];
|
|
3209
|
+
let accountClient = null;
|
|
3210
|
+
let accountDevices = null;
|
|
3211
|
+
states.push(projectReadiness(deps, config));
|
|
2932
3212
|
const credential = deps.store.load();
|
|
2933
3213
|
states.push(credential
|
|
2934
3214
|
? {
|
|
@@ -2967,10 +3247,10 @@ export async function assessReadiness(deps, options = {}) {
|
|
|
2967
3247
|
id: 'server',
|
|
2968
3248
|
title: 'Service',
|
|
2969
3249
|
status: 'gap',
|
|
2970
|
-
detail: `cannot reach ${baseUrl}
|
|
3250
|
+
detail: `cannot reach ${baseUrl}`,
|
|
2971
3251
|
remedy: {
|
|
2972
3252
|
by: 'user-here',
|
|
2973
|
-
summary: 'check your network
|
|
3253
|
+
summary: 'check your network',
|
|
2974
3254
|
command: 'notifai doctor',
|
|
2975
3255
|
},
|
|
2976
3256
|
});
|
|
@@ -3176,8 +3456,8 @@ async function setupProofState(deps, config, client, devices) {
|
|
|
3176
3456
|
};
|
|
3177
3457
|
}
|
|
3178
3458
|
}
|
|
3179
|
-
export async function doctorCommand(deps, flags) {
|
|
3180
|
-
const readiness = await assessReadiness(deps);
|
|
3459
|
+
export async function doctorCommand(deps, flags, options = {}) {
|
|
3460
|
+
const readiness = options.readiness ?? (await assessReadiness(deps));
|
|
3181
3461
|
const blocker = firstBlocker(readiness);
|
|
3182
3462
|
const ok = blocker === null;
|
|
3183
3463
|
if (flags.json) {
|
|
@@ -3267,7 +3547,7 @@ function remedyLine(state) {
|
|
|
3267
3547
|
*/
|
|
3268
3548
|
function hookStates(deps) {
|
|
3269
3549
|
const installations = findInstallations(deps.cwd, deps.env, deps.hookAdapterHome);
|
|
3270
|
-
const active = activeHarnessSession(deps.env);
|
|
3550
|
+
const active = activeHarnessSession(deps.env, deps.cwd, (deps.now ?? Date.now)());
|
|
3271
3551
|
const config = loadLoggedConfig(deps, { cwd: deps.cwd, env: deps.env });
|
|
3272
3552
|
const settings = {
|
|
3273
3553
|
id: 'question-routing-settings',
|
|
@@ -3275,10 +3555,7 @@ function hookStates(deps) {
|
|
|
3275
3555
|
status: config.ask_notifications.value ? 'ready' : 'gap',
|
|
3276
3556
|
detail: [
|
|
3277
3557
|
`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
3558
|
`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
3559
|
].join(', '),
|
|
3283
3560
|
...(config.ask_notifications.value
|
|
3284
3561
|
? {}
|
|
@@ -3342,6 +3619,41 @@ function hookStates(deps) {
|
|
|
3342
3619
|
settings,
|
|
3343
3620
|
];
|
|
3344
3621
|
}
|
|
3622
|
+
/**
|
|
3623
|
+
* Whether an installed Stop handler declares the shape its harness needs.
|
|
3624
|
+
*
|
|
3625
|
+
* The three answers differ, and getting one wrong fails silently in a
|
|
3626
|
+
* different way each time:
|
|
3627
|
+
*
|
|
3628
|
+
* - Claude Code's handler must be `async: true`, or the waiter holds the
|
|
3629
|
+
* user's turn for its whole wait instead of returning at once. It must
|
|
3630
|
+
* also declare a `timeout`, because the harness default is 600 s and the
|
|
3631
|
+
* kill is silent — the backgrounded waiter vanishes and the answer the
|
|
3632
|
+
* user already gave is never delivered.
|
|
3633
|
+
* - Codex owns its Stop timeout. Declaring one changes the definition it
|
|
3634
|
+
* hashes into `trusted_hash`, and an untrusted handler is simply not run.
|
|
3635
|
+
* - Everything else blocks its turn and needs a ceiling above the wait.
|
|
3636
|
+
*/
|
|
3637
|
+
function stopShapeProblems(installation) {
|
|
3638
|
+
if (installation.harness === 'codex')
|
|
3639
|
+
return [];
|
|
3640
|
+
const problems = [];
|
|
3641
|
+
for (const handler of installation.handlers.filter((entry) => handlerEvent(entry.command) === 'stop')) {
|
|
3642
|
+
if (installation.harness === 'claude-code') {
|
|
3643
|
+
if (handler.async !== true) {
|
|
3644
|
+
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`);
|
|
3645
|
+
}
|
|
3646
|
+
if (handler.timeout === undefined || handler.timeout < CLAUDE_ASYNC_STOP_TIMEOUT_SECONDS) {
|
|
3647
|
+
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`);
|
|
3648
|
+
}
|
|
3649
|
+
continue;
|
|
3650
|
+
}
|
|
3651
|
+
if (handler.timeout === undefined || handler.timeout < BLOCKING_STOP_TIMEOUT_SECONDS) {
|
|
3652
|
+
problems.push(`${installation.file} gives Stop ${handler.timeout ?? 'no'}s, but the blocking answer owner requires ${BLOCKING_STOP_TIMEOUT_SECONDS}s`);
|
|
3653
|
+
}
|
|
3654
|
+
}
|
|
3655
|
+
return problems;
|
|
3656
|
+
}
|
|
3345
3657
|
function hookChecks(deps) {
|
|
3346
3658
|
const checks = [];
|
|
3347
3659
|
const installations = findInstallations(deps.cwd, deps.env, deps.hookAdapterHome);
|
|
@@ -3362,7 +3674,21 @@ function hookChecks(deps) {
|
|
|
3362
3674
|
.map((i) => `${i.harness} ${i.global ? 'global' : 'project'} (${i.file})`)
|
|
3363
3675
|
.join(', '),
|
|
3364
3676
|
});
|
|
3365
|
-
const
|
|
3677
|
+
const wired = new Set(installations.map((installation) => installation.harness));
|
|
3678
|
+
const unwired = detectedHarnesses(deps.cwd, deps.env).filter((harness) => !wired.has(harness));
|
|
3679
|
+
if (unwired.length > 0) {
|
|
3680
|
+
checks.push({
|
|
3681
|
+
name: 'hooks (detected)',
|
|
3682
|
+
ok: false,
|
|
3683
|
+
informational: true,
|
|
3684
|
+
detail: `${unwired.map((harness) => HARNESS_LABEL[harness]).join(', ')} detected on this machine but not wired`,
|
|
3685
|
+
remedy: {
|
|
3686
|
+
summary: 'install hooks for every detected harness',
|
|
3687
|
+
command: 'notifai hooks install',
|
|
3688
|
+
},
|
|
3689
|
+
});
|
|
3690
|
+
}
|
|
3691
|
+
const { active, contested } = resolveActiveHarness(deps.env, deps.cwd, (deps.now ?? Date.now)());
|
|
3366
3692
|
const activeInstallations = active === null
|
|
3367
3693
|
? []
|
|
3368
3694
|
: installations.filter((installation) => installation.harness === active.harness);
|
|
@@ -3455,18 +3781,13 @@ function hookChecks(deps) {
|
|
|
3455
3781
|
},
|
|
3456
3782
|
}),
|
|
3457
3783
|
});
|
|
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' : ''}\``));
|
|
3784
|
+
const shapeProblems = installations.flatMap((installation) => stopShapeProblems(installation).map((problem) => `${problem} — run \`notifai hooks install --harness ${installation.harness}${installation.global ? ' --global' : ''}\``));
|
|
3464
3785
|
checks.push({
|
|
3465
|
-
name: 'hooks (
|
|
3466
|
-
ok:
|
|
3467
|
-
detail:
|
|
3468
|
-
? `
|
|
3469
|
-
:
|
|
3786
|
+
name: 'hooks (stop shape)',
|
|
3787
|
+
ok: shapeProblems.length === 0,
|
|
3788
|
+
detail: shapeProblems.length === 0
|
|
3789
|
+
? `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`
|
|
3790
|
+
: shapeProblems.join('; '),
|
|
3470
3791
|
});
|
|
3471
3792
|
// Project and global definitions for one harness both fire. Stable adapter
|
|
3472
3793
|
// identity deliberately makes their command bytes equal, so comparing
|
|
@@ -3477,7 +3798,8 @@ function hookChecks(deps) {
|
|
|
3477
3798
|
harness,
|
|
3478
3799
|
installations: installations.filter((i) => i.harness === harness),
|
|
3479
3800
|
}))
|
|
3480
|
-
.filter((entry) => entry.installations.
|
|
3801
|
+
.filter((entry) => entry.installations.some((installation) => installation.global) &&
|
|
3802
|
+
entry.installations.some((installation) => !installation.global));
|
|
3481
3803
|
if (duplicated.length > 0) {
|
|
3482
3804
|
checks.push({
|
|
3483
3805
|
name: 'hooks (duplicates)',
|
|
@@ -3487,6 +3809,25 @@ function hookChecks(deps) {
|
|
|
3487
3809
|
.join('; '),
|
|
3488
3810
|
});
|
|
3489
3811
|
}
|
|
3812
|
+
const representationProblems = codexRepresentationProblems(deps.cwd, deps.env);
|
|
3813
|
+
if (representationProblems.length > 0) {
|
|
3814
|
+
const collapseCommand = representationProblems.some((problem) => problem.includes('--global'))
|
|
3815
|
+
? 'notifai hooks install --harness codex --global'
|
|
3816
|
+
: 'notifai hooks install --harness codex';
|
|
3817
|
+
checks.push({
|
|
3818
|
+
name: 'hooks (codex representation)',
|
|
3819
|
+
ok: false,
|
|
3820
|
+
detail: representationProblems.join('; '),
|
|
3821
|
+
...(representationProblems.some((problem) => problem.includes('to collapse onto'))
|
|
3822
|
+
? {
|
|
3823
|
+
remedy: {
|
|
3824
|
+
summary: 'collapse this Codex layer onto a single hook file',
|
|
3825
|
+
command: collapseCommand,
|
|
3826
|
+
},
|
|
3827
|
+
}
|
|
3828
|
+
: {}),
|
|
3829
|
+
});
|
|
3830
|
+
}
|
|
3490
3831
|
if (active !== null && activeInstallations.length > 0) {
|
|
3491
3832
|
const admissionProblems = activeQuestionRouteProblems(deps, active, installations);
|
|
3492
3833
|
checks.push({
|
|
@@ -3497,14 +3838,37 @@ function hookChecks(deps) {
|
|
|
3497
3838
|
: admissionProblems.join('; '),
|
|
3498
3839
|
});
|
|
3499
3840
|
}
|
|
3500
|
-
|
|
3841
|
+
// Which route this judges, and which remedy it prints, must both belong to
|
|
3842
|
+
// the harness that is actually running here. A machine-global installation
|
|
3843
|
+
// matches every directory, so picking whichever installation matched — or
|
|
3844
|
+
// whichever session last wrote a pointer — reports on a harness the agent
|
|
3845
|
+
// cannot influence: it is told to send a prompt in a harness that is not
|
|
3846
|
+
// running, follows the fail-closed rule, and can never clear the check.
|
|
3847
|
+
const firedPointer = active === null
|
|
3501
3848
|
? readProjectSessionPointer(deps.cwd, deps.env, (deps.now ?? Date.now)())
|
|
3502
|
-
:
|
|
3849
|
+
: active.sessionId === undefined
|
|
3850
|
+
? (readLiveProjectSessionPointers(deps.cwd, deps.env, (deps.now ?? Date.now)()).find((pointer) => pointer.harness === active.harness) ?? null)
|
|
3851
|
+
: readMatchingProjectSessionPointer(deps.cwd, deps.env, (deps.now ?? Date.now)(), active.sessionId, active.harness);
|
|
3503
3852
|
const firedState = firedPointer === null ? null : readSessionState(firedPointer.sessionId, deps.env);
|
|
3504
3853
|
const promptFired = firedState?.last_prompt_at !== undefined;
|
|
3505
3854
|
const stopFired = firedState?.last_stop_at !== undefined;
|
|
3506
3855
|
const fired = firedPointer !== null && promptFired && stopFired;
|
|
3507
|
-
|
|
3856
|
+
// Installations for other harnesses are irrelevant to the active one, and an
|
|
3857
|
+
// active harness with none of its own has nothing to activate: say that
|
|
3858
|
+
// instead of advising a prompt in some other harness. When the environment
|
|
3859
|
+
// is contested and nothing has fired, every candidate is still possible, so
|
|
3860
|
+
// the advice covers all of them rather than betting on one.
|
|
3861
|
+
const activationHarnesses = new Set(contested.length > 1
|
|
3862
|
+
? contested.map((candidate) => candidate.harness)
|
|
3863
|
+
: active === null
|
|
3864
|
+
? installations.map((installation) => installation.harness)
|
|
3865
|
+
: [active.harness]);
|
|
3866
|
+
const activationInstallations = installations.filter((installation) => activationHarnesses.has(installation.harness));
|
|
3867
|
+
const activationAdvice = activationInstallations.length > 0
|
|
3868
|
+
? hookActivationAdvice(activationInstallations)
|
|
3869
|
+
: active === null
|
|
3870
|
+
? hookActivationAdvice(installations)
|
|
3871
|
+
: `${active.label}: no ${active.label} hook installation matches this project — run \`notifai hooks install --harness ${active.harness}\`.`;
|
|
3508
3872
|
checks.push({
|
|
3509
3873
|
name: 'hooks (fired)',
|
|
3510
3874
|
ok: fired,
|
|
@@ -3512,16 +3876,24 @@ function hookChecks(deps) {
|
|
|
3512
3876
|
// a missing Stop is a broken route, not missing historical evidence.
|
|
3513
3877
|
informational: firedPointer === null,
|
|
3514
3878
|
detail: fired
|
|
3515
|
-
?
|
|
3879
|
+
? active === null
|
|
3880
|
+
? 'a session in this directory has run UserPromptSubmit and Stop'
|
|
3881
|
+
: `the active ${active.label} session has run UserPromptSubmit and Stop`
|
|
3516
3882
|
: firedPointer === null
|
|
3517
|
-
? `no
|
|
3883
|
+
? `no ${contested.length > 1
|
|
3884
|
+
? `session pointer for any harness whose markers are present here (${contested
|
|
3885
|
+
.map((candidate) => candidate.label)
|
|
3886
|
+
.join(', ')})`
|
|
3887
|
+
: active === null
|
|
3888
|
+
? 'session pointer'
|
|
3889
|
+
: `${active.label} session pointer`} from the last 24 hours — ${activationAdvice}`
|
|
3518
3890
|
: `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
3891
|
...(fired
|
|
3520
3892
|
? {}
|
|
3521
3893
|
: {
|
|
3522
3894
|
remedy: {
|
|
3523
3895
|
summary: firedPointer === null
|
|
3524
|
-
?
|
|
3896
|
+
? `send one ${active === null || contested.length > 1 ? '' : `${active.label} `}prompt in a session here, then re-check`
|
|
3525
3897
|
: 'end one harmless turn, send a new prompt, then re-check',
|
|
3526
3898
|
command: 'notifai doctor',
|
|
3527
3899
|
},
|
|
@@ -3544,11 +3916,69 @@ function hookChecks(deps) {
|
|
|
3544
3916
|
.map((harness) => `${harness}: ${HARNESS_CAPABILITIES[harness].deliveryContract}`)
|
|
3545
3917
|
.join('; '),
|
|
3546
3918
|
});
|
|
3919
|
+
const wakeRoute = wakeRouteCheck(deps, active, activeInstallations);
|
|
3920
|
+
if (wakeRoute !== null)
|
|
3921
|
+
checks.push(wakeRoute);
|
|
3547
3922
|
const stray = codexStrayWorktreeCheck(deps);
|
|
3548
3923
|
if (stray !== null)
|
|
3549
3924
|
checks.push(stray);
|
|
3550
3925
|
return checks;
|
|
3551
3926
|
}
|
|
3927
|
+
/**
|
|
3928
|
+
* Whether an answer arriving after this turn ends could actually reach this
|
|
3929
|
+
* exact session — the question `notifai ask` really turns on, and the one no
|
|
3930
|
+
* other check answers.
|
|
3931
|
+
*
|
|
3932
|
+
* Read-only, and deliberately so: nothing here connects to a socket, takes a
|
|
3933
|
+
* lock, or sends a message. A diagnostic that wakes the agent it is diagnosing
|
|
3934
|
+
* would be its own bug report.
|
|
3935
|
+
*
|
|
3936
|
+
* Everything it can report negatively is a degradation rather than a failure —
|
|
3937
|
+
* the accepted journal still replays the answer at the session's next turn —
|
|
3938
|
+
* so this is never a blocker. What it buys is that the reason has a name
|
|
3939
|
+
* before the user notices the silence.
|
|
3940
|
+
*
|
|
3941
|
+
* It never asks for `crossSessionInbound`. The poster is the session's own
|
|
3942
|
+
* hook child and takes the privileged own-child path, verified to be delivered
|
|
3943
|
+
* even against a `bypassPermissions` receiver while an unrelated process was
|
|
3944
|
+
* held. Widening a user's general inbound policy to suit Notifai would be a
|
|
3945
|
+
* real change to their machine's posture in exchange for nothing.
|
|
3946
|
+
*/
|
|
3947
|
+
function wakeRouteCheck(deps, active, activeInstallations) {
|
|
3948
|
+
if (active === null || activeInstallations.length === 0)
|
|
3949
|
+
return null;
|
|
3950
|
+
if (active.harness === 'claude-code') {
|
|
3951
|
+
const readiness = inspectClaudeInbox({
|
|
3952
|
+
pid: deps.claudeSourcePid ?? claudeSessionPid(deps.env),
|
|
3953
|
+
platform: process.platform,
|
|
3954
|
+
readDescriptor: deps.claudeWake?.readDescriptor ?? systemClaudeWakeAdapters(deps.env).readDescriptor,
|
|
3955
|
+
socketExists: (socketPath) => existsSync(socketPath),
|
|
3956
|
+
});
|
|
3957
|
+
return {
|
|
3958
|
+
name: 'hooks (wake route)',
|
|
3959
|
+
ok: readiness.state === 'ready',
|
|
3960
|
+
informational: true,
|
|
3961
|
+
detail: readiness.state === 'ready'
|
|
3962
|
+
? `this Claude Code ${readiness.version} session is listening on ${readiness.socketPath}, so an answer can start a turn here without you`
|
|
3963
|
+
: `${readiness.reason}. Answers are still delivered, at this session's next turn rather than on their own`,
|
|
3964
|
+
};
|
|
3965
|
+
}
|
|
3966
|
+
if (active.harness === 'codex') {
|
|
3967
|
+
const readiness = inspectCodexResume(deps.env, {
|
|
3968
|
+
platform: process.platform,
|
|
3969
|
+
directoryExists: (directory) => existsSync(directory),
|
|
3970
|
+
});
|
|
3971
|
+
return {
|
|
3972
|
+
name: 'hooks (wake route)',
|
|
3973
|
+
ok: readiness.state === 'ready',
|
|
3974
|
+
informational: true,
|
|
3975
|
+
detail: readiness.state === 'ready'
|
|
3976
|
+
? `the held Codex turn continues from its own hook, and after it returns ${readiness.lockDirectory} can prove a stopped thread unowned before resuming it`
|
|
3977
|
+
: `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`,
|
|
3978
|
+
};
|
|
3979
|
+
}
|
|
3980
|
+
return null;
|
|
3981
|
+
}
|
|
3552
3982
|
/**
|
|
3553
3983
|
* A Codex hooks file sitting in a worktree, which Codex will never read.
|
|
3554
3984
|
*
|
|
@@ -3562,15 +3992,20 @@ function codexStrayWorktreeCheck(deps) {
|
|
|
3562
3992
|
if (layer === null)
|
|
3563
3993
|
return null;
|
|
3564
3994
|
const root = codexProjectRoot(deps.cwd);
|
|
3565
|
-
const
|
|
3566
|
-
if (
|
|
3995
|
+
const project = inspectCodexLayer(codexLayerPaths(false, deps.cwd, deps.env));
|
|
3996
|
+
if (project.jsonEvents.length === 0 && project.tomlEvents.length === 0)
|
|
3567
3997
|
return null;
|
|
3998
|
+
const strayJson = path.join(path.dirname(layer), '.codex', 'hooks.json');
|
|
3999
|
+
const strayToml = path.join(path.dirname(layer), '.codex', 'config.toml');
|
|
3568
4000
|
const problems = [];
|
|
3569
4001
|
if (!existsSync(layer)) {
|
|
3570
4002
|
problems.push(`${layer} is missing, so Codex never looks for project hooks here`);
|
|
3571
4003
|
}
|
|
3572
|
-
if (existsSync(
|
|
3573
|
-
problems.push(`${
|
|
4004
|
+
if (existsSync(strayJson) && path.resolve(strayJson) !== path.resolve(project.paths.hooksJson)) {
|
|
4005
|
+
problems.push(`${strayJson} is never read — Codex reads ${project.writeTarget} instead`);
|
|
4006
|
+
}
|
|
4007
|
+
if (existsSync(strayToml) && path.resolve(strayToml) !== path.resolve(project.paths.configToml)) {
|
|
4008
|
+
problems.push(`${strayToml} is never read — Codex reads ${project.writeTarget} instead`);
|
|
3574
4009
|
}
|
|
3575
4010
|
return {
|
|
3576
4011
|
name: 'hooks (codex worktree)',
|
|
@@ -3630,6 +4065,18 @@ export function realIo(env = process.env) {
|
|
|
3630
4065
|
const answer = await p.select({ message, options });
|
|
3631
4066
|
return p.isCancel(answer) ? null : answer;
|
|
3632
4067
|
},
|
|
4068
|
+
multiselect: async (message, options, initial) => {
|
|
4069
|
+
if (!interactive())
|
|
4070
|
+
return null;
|
|
4071
|
+
const p = await clack();
|
|
4072
|
+
const answer = await p.multiselect({
|
|
4073
|
+
message,
|
|
4074
|
+
options,
|
|
4075
|
+
required: true,
|
|
4076
|
+
...(initial !== undefined && initial.length > 0 ? { initialValues: initial } : {}),
|
|
4077
|
+
});
|
|
4078
|
+
return p.isCancel(answer) ? null : answer;
|
|
4079
|
+
},
|
|
3633
4080
|
intro: async (title) => {
|
|
3634
4081
|
if (!interactive())
|
|
3635
4082
|
return;
|