@raidiant/notifai 0.2.1 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +20 -4
- package/dist/atomic-file.d.ts +4 -0
- package/dist/atomic-file.js +60 -10
- package/dist/atomic-file.js.map +1 -1
- package/dist/client.d.ts +25 -2
- package/dist/client.js +45 -1
- package/dist/client.js.map +1 -1
- package/dist/commands.d.ts +85 -6
- package/dist/commands.js +1133 -322
- package/dist/commands.js.map +1 -1
- package/dist/config-schema.d.ts +62 -0
- package/dist/config-schema.js +312 -0
- package/dist/config-schema.js.map +1 -0
- package/dist/config.d.ts +38 -3
- package/dist/config.js +61 -14
- package/dist/config.js.map +1 -1
- package/dist/file-lock.d.ts +26 -0
- package/dist/file-lock.js +268 -0
- package/dist/file-lock.js.map +1 -0
- package/dist/harnesses.d.ts +28 -0
- package/dist/harnesses.js +40 -0
- package/dist/harnesses.js.map +1 -0
- package/dist/hook-adapter.d.ts +27 -0
- package/dist/hook-adapter.js +188 -0
- package/dist/hook-adapter.js.map +1 -0
- package/dist/hook-input.d.ts +10 -0
- package/dist/hook-input.js +64 -0
- package/dist/hook-input.js.map +1 -0
- package/dist/hooks.d.ts +66 -10
- package/dist/hooks.js +751 -276
- package/dist/hooks.js.map +1 -1
- package/dist/install-hooks.d.ts +40 -16
- package/dist/install-hooks.js +217 -73
- package/dist/install-hooks.js.map +1 -1
- package/dist/interactive.d.ts +4 -0
- package/dist/interactive.js +604 -0
- package/dist/interactive.js.map +1 -0
- package/dist/logging.d.ts +142 -0
- package/dist/logging.js +533 -0
- package/dist/logging.js.map +1 -0
- package/dist/main.js +201 -51
- package/dist/main.js.map +1 -1
- package/dist/opencode-plugin.d.ts +7 -7
- package/dist/opencode-plugin.js +12 -13
- package/dist/opencode-plugin.js.map +1 -1
- package/dist/ui/banner.d.ts +22 -0
- package/dist/ui/banner.js +87 -0
- package/dist/ui/banner.js.map +1 -0
- package/dist/ui/config-view.d.ts +32 -0
- package/dist/ui/config-view.js +108 -0
- package/dist/ui/config-view.js.map +1 -0
- package/dist/ui/help.d.ts +46 -0
- package/dist/ui/help.js +105 -0
- package/dist/ui/help.js.map +1 -0
- package/dist/ui/theme.d.ts +108 -0
- package/dist/ui/theme.js +261 -0
- package/dist/ui/theme.js.map +1 -0
- package/package.json +11 -9
- package/src/atomic-file.ts +86 -7
- package/src/client.test.ts +12 -0
- package/src/client.ts +72 -4
- package/src/commands.test.ts +971 -44
- package/src/commands.ts +1323 -337
- package/src/config-schema.test.ts +152 -0
- package/src/config-schema.ts +363 -0
- package/src/config.test.ts +6 -5
- package/src/config.ts +85 -16
- package/src/file-lock-process-worker.test.ts +64 -0
- package/src/file-lock.test.ts +279 -0
- package/src/file-lock.ts +318 -0
- package/src/harnesses.test.ts +48 -0
- package/src/harnesses.ts +59 -0
- package/src/hook-adapter.test.ts +187 -0
- package/src/hook-adapter.ts +212 -0
- package/src/hook-input.test.ts +39 -0
- package/src/hook-input.ts +72 -0
- package/src/hooks.test.ts +1318 -240
- package/src/hooks.ts +1046 -296
- package/src/install-hooks.test.ts +90 -41
- package/src/install-hooks.ts +259 -74
- package/src/interactive.ts +692 -0
- package/src/logging-process-worker.test.ts +39 -0
- package/src/logging.test.ts +453 -0
- package/src/logging.ts +639 -0
- package/src/main.ts +220 -48
- package/src/opencode-plugin.ts +15 -16
- package/src/session-end-log-lock-worker.test.ts +35 -0
- package/src/skill.test.ts +11 -0
- package/src/ui/banner.ts +95 -0
- package/src/ui/config-view.test.ts +88 -0
- package/src/ui/config-view.ts +138 -0
- package/src/ui/help.ts +124 -0
- package/src/ui/theme.test.ts +92 -0
- package/src/ui/theme.ts +279 -0
package/dist/commands.js
CHANGED
|
@@ -1,19 +1,38 @@
|
|
|
1
1
|
import { createHash, randomBytes } from 'node:crypto';
|
|
2
2
|
import { spawn } from 'node:child_process';
|
|
3
|
-
import { existsSync, mkdirSync, readFileSync, realpathSync, rmSync, writeFileSync } from 'node:fs';
|
|
3
|
+
import { existsSync, lstatSync, mkdirSync, readFileSync, realpathSync, rmSync, unlinkSync, writeFileSync } from 'node:fs';
|
|
4
4
|
import os from 'node:os';
|
|
5
5
|
import path from 'node:path';
|
|
6
6
|
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
|
-
import { ApiCallError, NetworkError, createClient, } from './client.js';
|
|
10
|
-
import { BOOLEAN_CONFIG_KEYS, CONFIG_KEYS, NUMERIC_CONFIG_KEYS, configBounds, findProjectConfigPath, findProjectLocalConfigPath, globalConfigPath, loadConfig, sessionConfigPath, } from './config.js';
|
|
11
|
-
import {
|
|
12
|
-
import {
|
|
9
|
+
import { ApiCallError, NetworkError, createClient, isRetryableReplyPollError, } from './client.js';
|
|
10
|
+
import { BOOLEAN_CONFIG_KEYS, CONFIG_KEYS, LOG_LEVELS, NUMERIC_CONFIG_KEYS, configBounds, configDefaultValue, findProjectConfigPath, findProjectLocalConfigPath, globalConfigPath, loadConfig, sessionConfigPath, } from './config.js';
|
|
11
|
+
import { acceptedValues, configInfo } from './config-schema.js';
|
|
12
|
+
import { atomicWriteFileSync } from './atomic-file.js';
|
|
13
|
+
import { withTargetFileLock } from './file-lock.js';
|
|
14
|
+
import { renderConfigExplain, renderConfigList, renderConfigPlain, } from './ui/config-view.js';
|
|
15
|
+
import { firstBlocker, openItems, } from './readiness.js';
|
|
16
|
+
import { handleSessionEnd, handleStop, handleUserPromptSubmit, parseHookInput, pruneAbandonedSessions, readMatchingProjectSessionPointer, readProjectSession, readProjectSessionPointer, readSessionState, registerQuestion, } from './hooks.js';
|
|
13
17
|
import { readIdleSeconds } from './idle.js';
|
|
14
|
-
import {
|
|
18
|
+
import { LOG_EVENTS, activeLogPath, archiveLogPaths, logConfigResolved, logSettingsFrom, logsDiskUsage, nullLogger, readLogRecords, renderRecord, } from './logging.js';
|
|
19
|
+
import { HARNESSES, applyPlan, blockingHookTimeoutSeconds, buildCursorHookConfig, buildHookConfig, codexLayerDir, codexTrustProblems, codexProjectRoot, detectHarness, findInstallations, handlerEvent, loadCursorSettings, loadSettings, mergeCursorHooks, mergeHooks, removeCursorHooks, removeHooks, settingsFile, } from './install-hooks.js';
|
|
20
|
+
import { HARNESS_CAPABILITIES, HOOK_TIMING } from './harnesses.js';
|
|
21
|
+
import { inspectHookAdapter, installHookAdapter, } from './hook-adapter.js';
|
|
15
22
|
import { isOurOpencodePlugin, opencodePluginSource, } from './opencode-plugin.js';
|
|
16
23
|
import { CHOICE_USAGE, buildDraft, formatReceipt, parseChoices, receiptExitCode, slugify, } from './send.js';
|
|
24
|
+
/** The invocation's logger, or one that discards everything. */
|
|
25
|
+
export function log(deps) {
|
|
26
|
+
return deps.logger ?? nullLogger();
|
|
27
|
+
}
|
|
28
|
+
function loadLoggedConfig(deps, options) {
|
|
29
|
+
const config = loadConfig(options);
|
|
30
|
+
const logger = log(deps);
|
|
31
|
+
logger.adopt(logSettingsFrom(config));
|
|
32
|
+
logger.bind({ project: config.project.value });
|
|
33
|
+
logConfigResolved(logger, config);
|
|
34
|
+
return config;
|
|
35
|
+
}
|
|
17
36
|
export const EXIT = {
|
|
18
37
|
ok: 0,
|
|
19
38
|
failed: 1,
|
|
@@ -23,7 +42,7 @@ export const EXIT = {
|
|
|
23
42
|
network: 5,
|
|
24
43
|
};
|
|
25
44
|
function makeClient(deps, baseUrl, bearer, options) {
|
|
26
|
-
return (deps.clientFactory ?? createClient)(baseUrl, bearer, options);
|
|
45
|
+
return (deps.clientFactory ?? createClient)(baseUrl, bearer, { ...options, logger: log(deps) });
|
|
27
46
|
}
|
|
28
47
|
function resolvedBaseUrl(config, credential) {
|
|
29
48
|
return config.base_url.source === 'default' && credential ? credential.baseUrl : config.base_url.value;
|
|
@@ -31,6 +50,10 @@ function resolvedBaseUrl(config, credential) {
|
|
|
31
50
|
function authedClient(deps, config) {
|
|
32
51
|
const credential = deps.store.load();
|
|
33
52
|
if (!credential) {
|
|
53
|
+
// The commonest reason a command does nothing, and one that leaves no other
|
|
54
|
+
// trace: it returns before any request is made, so without this the log
|
|
55
|
+
// shows an exit code and no cause.
|
|
56
|
+
log(deps).error('cli.error', { kind: 'auth', message: 'not signed in', store: deps.store.describe() });
|
|
34
57
|
deps.io.err('Not signed in. Run `notifai login` first.');
|
|
35
58
|
return null;
|
|
36
59
|
}
|
|
@@ -40,7 +63,18 @@ function authedClient(deps, config) {
|
|
|
40
63
|
baseUrl,
|
|
41
64
|
};
|
|
42
65
|
}
|
|
43
|
-
function reportError(deps, err) {
|
|
66
|
+
function reportError(deps, err, context = {}) {
|
|
67
|
+
// Recorded before it is printed: stderr from an agent's shell call is often
|
|
68
|
+
// the one thing that does not survive into the next turn, and this is exactly
|
|
69
|
+
// the line someone comes back looking for.
|
|
70
|
+
log(deps).error('cli.error', {
|
|
71
|
+
...context,
|
|
72
|
+
...(err instanceof ApiCallError
|
|
73
|
+
? { kind: 'api', status: err.status, code: err.code, message: err.message, next_action: err.nextAction, details: err.details }
|
|
74
|
+
: err instanceof NetworkError
|
|
75
|
+
? { kind: 'network', message: err.message }
|
|
76
|
+
: { kind: 'unknown', message: String(err) }),
|
|
77
|
+
});
|
|
44
78
|
if (err instanceof ApiCallError) {
|
|
45
79
|
deps.io.err(`${err.code}: ${err.message}`);
|
|
46
80
|
if (err.nextAction)
|
|
@@ -60,7 +94,7 @@ function reportError(deps, err) {
|
|
|
60
94
|
// login / logout / auth status
|
|
61
95
|
// ---------------------------------------------------------------------------
|
|
62
96
|
export async function loginCommand(deps, flags) {
|
|
63
|
-
const config =
|
|
97
|
+
const config = loadLoggedConfig(deps, { cwd: deps.cwd, env: deps.env, flags: { base_url: flags.baseUrl } });
|
|
64
98
|
const baseUrl = config.base_url.value;
|
|
65
99
|
const machineName = flags.name ?? os.hostname();
|
|
66
100
|
const secret = randomBytes(32).toString('base64url');
|
|
@@ -178,7 +212,7 @@ export function authStatusCommand(deps, flags) {
|
|
|
178
212
|
}
|
|
179
213
|
/** Show the server's account access decision without attempting a product mutation. */
|
|
180
214
|
export async function accessStatusCommand(deps, flags) {
|
|
181
|
-
const config =
|
|
215
|
+
const config = loadLoggedConfig(deps, { cwd: deps.cwd, env: deps.env });
|
|
182
216
|
const authed = authedClient(deps, config);
|
|
183
217
|
if (!authed)
|
|
184
218
|
return EXIT.auth;
|
|
@@ -206,7 +240,7 @@ export async function accessStatusCommand(deps, flags) {
|
|
|
206
240
|
// devices / capabilities
|
|
207
241
|
// ---------------------------------------------------------------------------
|
|
208
242
|
export async function devicesCommand(deps, flags) {
|
|
209
|
-
const config =
|
|
243
|
+
const config = loadLoggedConfig(deps, { cwd: deps.cwd, env: deps.env });
|
|
210
244
|
const authed = authedClient(deps, config);
|
|
211
245
|
if (!authed)
|
|
212
246
|
return EXIT.auth;
|
|
@@ -237,8 +271,35 @@ export async function devicesCommand(deps, flags) {
|
|
|
237
271
|
return reportError(deps, err);
|
|
238
272
|
}
|
|
239
273
|
}
|
|
274
|
+
/**
|
|
275
|
+
* Devices as data, for surfaces that render them themselves.
|
|
276
|
+
*
|
|
277
|
+
* `devicesCommand` prints and returns an exit code, which is the right shape
|
|
278
|
+
* for a command and the wrong one for a menu that wants to show names beside
|
|
279
|
+
* checkboxes. Silent on every failure by design: the interactive app already
|
|
280
|
+
* shows the credential and connectivity state on its status card, so a second
|
|
281
|
+
* error line here would be reporting the same fault twice.
|
|
282
|
+
*/
|
|
283
|
+
export async function deviceInventory(deps) {
|
|
284
|
+
if (!deps.store.load())
|
|
285
|
+
return null;
|
|
286
|
+
const config = loadLoggedConfig(deps, { cwd: deps.cwd, env: deps.env });
|
|
287
|
+
const authed = authedClient(deps, config);
|
|
288
|
+
if (!authed)
|
|
289
|
+
return null;
|
|
290
|
+
try {
|
|
291
|
+
return (await authed.client.listDevices()).devices;
|
|
292
|
+
}
|
|
293
|
+
catch {
|
|
294
|
+
return null;
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
/** Whether a device is in a state where a notification would actually arrive. */
|
|
298
|
+
export function canDeviceReceive(device) {
|
|
299
|
+
return deviceCanReceive(device);
|
|
300
|
+
}
|
|
240
301
|
export async function capabilitiesCommand(deps, flags) {
|
|
241
|
-
const config =
|
|
302
|
+
const config = loadLoggedConfig(deps, { cwd: deps.cwd, env: deps.env });
|
|
242
303
|
const credential = deps.store.load();
|
|
243
304
|
const baseUrl = resolvedBaseUrl(config, credential);
|
|
244
305
|
const client = makeClient(deps, baseUrl, null);
|
|
@@ -303,7 +364,7 @@ export async function sendCommand(deps, flags) {
|
|
|
303
364
|
deps.io.err(`--reply-window must be an integer from 60 to ${REPLY_MAX_WINDOW_SECONDS} seconds.`);
|
|
304
365
|
return EXIT.usage;
|
|
305
366
|
}
|
|
306
|
-
const config =
|
|
367
|
+
const config = loadLoggedConfig(deps, {
|
|
307
368
|
cwd: deps.cwd,
|
|
308
369
|
env: deps.env,
|
|
309
370
|
flags: { base_url: flags.baseUrl, wait_seconds: flags.wait },
|
|
@@ -342,33 +403,67 @@ export async function sendCommand(deps, flags) {
|
|
|
342
403
|
}
|
|
343
404
|
const waitSeconds = flags.noWait ? 0 : config.wait_seconds.value;
|
|
344
405
|
const idempotencyKey = flags.idempotencyKey ?? `cli-${randomBytes(12).toString('base64url')}`;
|
|
406
|
+
let receipt;
|
|
345
407
|
try {
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
408
|
+
receipt = await authed.client.submit({ idempotency_key: idempotencyKey, draft: build.draft }, waitSeconds);
|
|
409
|
+
}
|
|
410
|
+
catch (err) {
|
|
411
|
+
return reportError(deps, err);
|
|
412
|
+
}
|
|
413
|
+
const receiptExit = receiptExitCode(receipt);
|
|
414
|
+
// The single most useful line in the log: it ties the local invocation to
|
|
415
|
+
// the server-side request id, which is what every later question about this
|
|
416
|
+
// notification ("did it arrive?", "which device?") is asked in terms of.
|
|
417
|
+
log(deps).info('send.submitted', {
|
|
418
|
+
request_id: receipt.request_id,
|
|
419
|
+
kind: flags.reply ? 'question' : (flags.kind ?? 'update'),
|
|
420
|
+
title: flags.title,
|
|
421
|
+
overall: receipt.overall,
|
|
422
|
+
replayed: receipt.replayed,
|
|
423
|
+
deliveries: receipt.deliveries.length,
|
|
424
|
+
wait_seconds: waitSeconds,
|
|
425
|
+
exit: receiptExit,
|
|
426
|
+
});
|
|
427
|
+
log(deps).info('send.outcome', {
|
|
428
|
+
request_id: receipt.request_id,
|
|
429
|
+
overall: receipt.overall,
|
|
430
|
+
// Per device, because "it said accepted but nothing arrived" is answered
|
|
431
|
+
// by which device reached which state and why the provider said so.
|
|
432
|
+
devices: receipt.deliveries.map((delivery) => ({
|
|
433
|
+
device: delivery.device_name,
|
|
434
|
+
state: delivery.state,
|
|
435
|
+
attempts: delivery.attempts,
|
|
436
|
+
provider_status: delivery.provider_status,
|
|
437
|
+
provider_reason: delivery.provider_reason,
|
|
438
|
+
})),
|
|
439
|
+
...(receipt.warnings.length > 0 ? { warnings: receipt.warnings } : {}),
|
|
440
|
+
});
|
|
441
|
+
if (!flags.json)
|
|
442
|
+
deps.io.out(formatReceipt(receipt));
|
|
443
|
+
else if (flags.reply)
|
|
444
|
+
deps.io.out(JSON.stringify({ type: 'receipt', receipt }));
|
|
445
|
+
// A zero wait can no longer reach here: --reply guarantees a positive one.
|
|
446
|
+
if (!flags.reply || receiptExit !== EXIT.ok) {
|
|
447
|
+
if (flags.json) {
|
|
448
|
+
deps.io.out(flags.reply
|
|
449
|
+
? JSON.stringify({
|
|
450
|
+
type: 'reply_result',
|
|
451
|
+
request_id: receipt.request_id,
|
|
452
|
+
replies: [],
|
|
453
|
+
degraded: false,
|
|
454
|
+
})
|
|
455
|
+
: JSON.stringify(receipt, null, 2));
|
|
365
456
|
}
|
|
457
|
+
return receiptExit;
|
|
458
|
+
}
|
|
459
|
+
try {
|
|
366
460
|
const result = await waitForReply(authed.client, receipt.request_id, {
|
|
367
461
|
timeoutSeconds: replyTimeout,
|
|
368
462
|
afterSeq: 0,
|
|
369
463
|
now: deps.now,
|
|
370
464
|
sleep: deps.sleep,
|
|
371
465
|
});
|
|
466
|
+
recordReplies(deps, receipt.request_id, result.response.replies);
|
|
372
467
|
if (flags.json) {
|
|
373
468
|
deps.io.out(JSON.stringify({
|
|
374
469
|
type: 'reply_result',
|
|
@@ -382,16 +477,46 @@ export async function sendCommand(deps, flags) {
|
|
|
382
477
|
else
|
|
383
478
|
printNoReply(deps, receipt.request_id, result.response.reply_expires_at);
|
|
384
479
|
if (result.degraded) {
|
|
385
|
-
deps.
|
|
480
|
+
log(deps).error('cli.error', {
|
|
481
|
+
kind: 'network',
|
|
482
|
+
operation: 'reply_wait',
|
|
483
|
+
request_id: receipt.request_id,
|
|
484
|
+
degraded: true,
|
|
485
|
+
message: 'the reply wait ended while the server was unreachable or faulting',
|
|
486
|
+
});
|
|
487
|
+
deps.io.err(degradedWaitWarning(receipt.request_id));
|
|
386
488
|
return EXIT.network;
|
|
387
489
|
}
|
|
388
490
|
if (result.timedOut) {
|
|
389
491
|
deps.io.err(`No reply yet. Retrieve it with \`notifai replies ${receipt.request_id}\` or retire the question with ` +
|
|
390
|
-
`\`notifai close ${receipt.request_id}
|
|
492
|
+
`\`notifai close ${receipt.request_id}\`. ` +
|
|
493
|
+
`This is a reply-wait timeout, not a Delivery or Companion Receipt failure — check with \`notifai status ${receipt.request_id}\`.`);
|
|
391
494
|
}
|
|
392
495
|
return result.timedOut ? EXIT.noReply : EXIT.ok;
|
|
393
496
|
}
|
|
394
497
|
catch (err) {
|
|
498
|
+
// Receipt already printed; a wait fault must not read as "send failed".
|
|
499
|
+
// Permanent poll errors (auth, closed window, not found) still surface, but
|
|
500
|
+
// always name the durable request and point at recovery.
|
|
501
|
+
if (err instanceof ApiCallError || err instanceof NetworkError) {
|
|
502
|
+
log(deps).error('cli.error', {
|
|
503
|
+
kind: err instanceof ApiCallError ? 'api' : 'network',
|
|
504
|
+
operation: 'reply_wait',
|
|
505
|
+
request_id: receipt.request_id,
|
|
506
|
+
...(err instanceof ApiCallError ? { status: err.status, code: err.code } : {}),
|
|
507
|
+
message: err.message,
|
|
508
|
+
});
|
|
509
|
+
deps.io.err(`notifai: reply wait failed for ${receipt.request_id} (${err instanceof ApiCallError ? err.code : 'network'}: ${err.message}). ` +
|
|
510
|
+
`Delivery and Companion Receipt are independent — check with \`notifai status ${receipt.request_id}\` and retry with \`notifai replies ${receipt.request_id}\`.`);
|
|
511
|
+
if (err instanceof ApiCallError) {
|
|
512
|
+
if (err.code === 'auth_required' || err.code === 'machine_revoked')
|
|
513
|
+
return EXIT.auth;
|
|
514
|
+
return err.status >= 500 || err.status === 429 || err.status === 408
|
|
515
|
+
? EXIT.network
|
|
516
|
+
: EXIT.failed;
|
|
517
|
+
}
|
|
518
|
+
return EXIT.network;
|
|
519
|
+
}
|
|
395
520
|
return reportError(deps, err);
|
|
396
521
|
}
|
|
397
522
|
}
|
|
@@ -455,44 +580,55 @@ export async function repliesCommand(deps, requestedId, flags) {
|
|
|
455
580
|
deps.io.err('Pass a request id or --pending.');
|
|
456
581
|
return EXIT.usage;
|
|
457
582
|
}
|
|
458
|
-
const config =
|
|
583
|
+
const config = loadLoggedConfig(deps, { cwd: deps.cwd, env: deps.env });
|
|
459
584
|
const authed = authedClient(deps, config);
|
|
460
585
|
if (!authed)
|
|
461
586
|
return EXIT.auth;
|
|
462
587
|
try {
|
|
463
588
|
let anyReplies = false;
|
|
464
|
-
|
|
589
|
+
const degradedRequestIds = [];
|
|
465
590
|
let allTimedOut = true;
|
|
466
591
|
const jsonBodies = [];
|
|
467
592
|
for (const requestId of requestIds) {
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
593
|
+
try {
|
|
594
|
+
const result = await waitForReply(authed.client, requestId, {
|
|
595
|
+
timeoutSeconds: waitSeconds,
|
|
596
|
+
afterSeq,
|
|
597
|
+
now: deps.now,
|
|
598
|
+
sleep: deps.sleep,
|
|
599
|
+
});
|
|
600
|
+
recordReplies(deps, requestId, result.response.replies);
|
|
601
|
+
if (flags.json) {
|
|
602
|
+
jsonBodies.push({ ...result.response, degraded: result.degraded });
|
|
603
|
+
}
|
|
604
|
+
else if (result.response.replies.length > 0) {
|
|
605
|
+
if (flags.pending === true)
|
|
606
|
+
deps.io.out(`pending request ${requestId}`);
|
|
607
|
+
printReplies(deps, result.response.replies);
|
|
608
|
+
}
|
|
609
|
+
else {
|
|
610
|
+
printNoReply(deps, requestId, result.response.reply_expires_at);
|
|
611
|
+
}
|
|
612
|
+
anyReplies ||= result.response.replies.length > 0;
|
|
613
|
+
if (result.degraded)
|
|
614
|
+
degradedRequestIds.push(requestId);
|
|
615
|
+
allTimedOut &&= result.timedOut;
|
|
481
616
|
}
|
|
482
|
-
|
|
483
|
-
|
|
617
|
+
catch (err) {
|
|
618
|
+
recordDegradedReplyWaits(deps, degradedRequestIds);
|
|
619
|
+
return reportError(deps, err, { operation: 'reply_wait', request_id: requestId });
|
|
484
620
|
}
|
|
485
|
-
anyReplies ||= result.response.replies.length > 0;
|
|
486
|
-
anyDegraded ||= result.degraded;
|
|
487
|
-
allTimedOut &&= result.timedOut;
|
|
488
621
|
}
|
|
489
622
|
if (flags.json) {
|
|
490
623
|
// One request keeps the response shape agents already parse; several
|
|
491
624
|
// (only possible via --pending) arrive as an array in queue order.
|
|
492
625
|
deps.io.out(JSON.stringify(jsonBodies.length === 1 ? jsonBodies[0] : jsonBodies, null, 2));
|
|
493
626
|
}
|
|
494
|
-
if (
|
|
495
|
-
deps
|
|
627
|
+
if (degradedRequestIds.length > 0) {
|
|
628
|
+
recordDegradedReplyWaits(deps, degradedRequestIds);
|
|
629
|
+
// Name a request whose polls actually degraded, not merely the first item
|
|
630
|
+
// in a multi-request queue that may have completed cleanly.
|
|
631
|
+
deps.io.err(degradedWaitWarning(degradedRequestIds[0]));
|
|
496
632
|
return EXIT.network;
|
|
497
633
|
}
|
|
498
634
|
if (anyReplies)
|
|
@@ -509,8 +645,8 @@ export async function waitForReply(client, requestId, options) {
|
|
|
509
645
|
const sleep = options.sleep ?? ((milliseconds) => new Promise((resolve) => setTimeout(resolve, milliseconds)));
|
|
510
646
|
const deadline = now() + options.timeoutSeconds * 1000;
|
|
511
647
|
let lastResponse = null;
|
|
512
|
-
let
|
|
513
|
-
let
|
|
648
|
+
let lastTransientError = null;
|
|
649
|
+
let consecutiveTransientErrors = 0;
|
|
514
650
|
let firstPoll = true;
|
|
515
651
|
while (firstPoll || now() < deadline) {
|
|
516
652
|
firstPoll = false;
|
|
@@ -522,8 +658,8 @@ export async function waitForReply(client, requestId, options) {
|
|
|
522
658
|
afterSeq: options.afterSeq,
|
|
523
659
|
});
|
|
524
660
|
lastResponse = response;
|
|
525
|
-
|
|
526
|
-
|
|
661
|
+
lastTransientError = null;
|
|
662
|
+
consecutiveTransientErrors = 0;
|
|
527
663
|
if (response.replies.length > 0)
|
|
528
664
|
return { response, timedOut: false, degraded: false };
|
|
529
665
|
const pauseMs = Math.min(250, Math.max(0, deadline - now()));
|
|
@@ -531,19 +667,22 @@ export async function waitForReply(client, requestId, options) {
|
|
|
531
667
|
await sleep(pauseMs);
|
|
532
668
|
}
|
|
533
669
|
catch (err) {
|
|
534
|
-
if (!(err
|
|
670
|
+
if (!isRetryableReplyPollError(err))
|
|
535
671
|
throw err;
|
|
536
|
-
|
|
537
|
-
|
|
672
|
+
lastTransientError = err instanceof Error ? err : new Error(String(err));
|
|
673
|
+
consecutiveTransientErrors += 1;
|
|
538
674
|
const remainingAfterError = Math.max(0, deadline - now());
|
|
539
675
|
if (remainingAfterError === 0)
|
|
540
676
|
break;
|
|
541
|
-
const backoffMs = Math.min(250 * 2 ** (
|
|
677
|
+
const backoffMs = Math.min(250 * 2 ** (consecutiveTransientErrors - 1), 2_000, remainingAfterError);
|
|
542
678
|
await sleep(backoffMs);
|
|
543
679
|
}
|
|
544
680
|
}
|
|
545
|
-
|
|
546
|
-
|
|
681
|
+
// Never saw a successful poll: surface the fault so callers get EXIT.network
|
|
682
|
+
// rather than a fake empty silence. The durable request is still on the
|
|
683
|
+
// server; the typed recovery is `notifai replies <id>`.
|
|
684
|
+
if (!lastResponse && lastTransientError)
|
|
685
|
+
throw lastTransientError;
|
|
547
686
|
return {
|
|
548
687
|
response: lastResponse ??
|
|
549
688
|
{ request_id: requestId, reply_expires_at: null, replies: [] },
|
|
@@ -551,19 +690,50 @@ export async function waitForReply(client, requestId, options) {
|
|
|
551
690
|
// A poll succeeded at some point, so we do not throw — but the last thing
|
|
552
691
|
// we know is that we could not reach the server. Reporting that as a plain
|
|
553
692
|
// "no reply" would let an agent treat an unseen refusal as consent.
|
|
554
|
-
degraded:
|
|
693
|
+
degraded: lastTransientError !== null,
|
|
555
694
|
};
|
|
556
695
|
}
|
|
696
|
+
function recordDegradedReplyWaits(deps, requestIds) {
|
|
697
|
+
if (requestIds.length === 0)
|
|
698
|
+
return;
|
|
699
|
+
log(deps).error('cli.error', {
|
|
700
|
+
kind: 'network',
|
|
701
|
+
operation: 'reply_wait',
|
|
702
|
+
request_ids: requestIds,
|
|
703
|
+
degraded: true,
|
|
704
|
+
message: 'the reply wait ended while the server was unreachable or faulting',
|
|
705
|
+
});
|
|
706
|
+
}
|
|
557
707
|
/**
|
|
558
708
|
* Shared by every surface that waits: "the user did not answer" and "I could
|
|
559
709
|
* not find out" must not look the same, because agents branch on the exit code
|
|
560
710
|
* and one of those two branches is safe to proceed from.
|
|
711
|
+
*
|
|
712
|
+
* Delivery / Companion Receipt / OS presentation are separate facts — a wait
|
|
713
|
+
* fault is not evidence that the push failed.
|
|
561
714
|
*/
|
|
562
|
-
|
|
563
|
-
|
|
715
|
+
function degradedWaitWarning(requestId) {
|
|
716
|
+
return (`notifai: the wait for ${requestId} ended while the server was unreachable or faulting, ` +
|
|
717
|
+
`so this is "could not find out", not "no answer" — the request is still durable and the ` +
|
|
718
|
+
`reply may already be waiting (Provider Acceptance and Companion Receipt are independent). ` +
|
|
719
|
+
`Retry with \`notifai replies ${requestId}\`.`);
|
|
720
|
+
}
|
|
564
721
|
function isNonNegativeInteger(value) {
|
|
565
722
|
return Number.isInteger(value) && value >= 0;
|
|
566
723
|
}
|
|
724
|
+
function recordReplies(deps, requestId, replies) {
|
|
725
|
+
const logger = log(deps);
|
|
726
|
+
for (const reply of replies) {
|
|
727
|
+
logger.info('reply.received', {
|
|
728
|
+
request_id: requestId,
|
|
729
|
+
sequence: reply.seq,
|
|
730
|
+
device: reply.device_name,
|
|
731
|
+
text: reply.text,
|
|
732
|
+
answers: reply.answers,
|
|
733
|
+
source: reply.source,
|
|
734
|
+
});
|
|
735
|
+
}
|
|
736
|
+
}
|
|
567
737
|
function printReplies(deps, replies) {
|
|
568
738
|
for (const reply of replies)
|
|
569
739
|
deps.io.out(`reply from ${reply.device_name}: ${reply.text}`);
|
|
@@ -604,7 +774,7 @@ function printNoReply(deps, requestId, expiresAt) {
|
|
|
604
774
|
: `no reply yet for request ${requestId}; the reply window remains open`);
|
|
605
775
|
}
|
|
606
776
|
export async function statusCommand(deps, requestId, flags) {
|
|
607
|
-
const config =
|
|
777
|
+
const config = loadLoggedConfig(deps, { cwd: deps.cwd, env: deps.env });
|
|
608
778
|
const authed = authedClient(deps, config);
|
|
609
779
|
if (!authed)
|
|
610
780
|
return EXIT.auth;
|
|
@@ -615,6 +785,7 @@ export async function statusCommand(deps, requestId, flags) {
|
|
|
615
785
|
return EXIT.ok;
|
|
616
786
|
}
|
|
617
787
|
deps.io.out(`request ${snapshot.request_id} (${snapshot.event ?? 'no event'}) — ${snapshot.overall}`);
|
|
788
|
+
let anyReplyReceived = false;
|
|
618
789
|
for (const d of snapshot.deliveries) {
|
|
619
790
|
deps.io.out(` ${d.device_name}:`);
|
|
620
791
|
deps.io.out(` Delivery: ${d.state} after ${d.attempts} attempt(s)`);
|
|
@@ -627,10 +798,24 @@ export async function statusCommand(deps, requestId, flags) {
|
|
|
627
798
|
else {
|
|
628
799
|
deps.io.out(" Companion Receipt (the app's delivery confirmation): unknown — not observed; this is not a failure or proof of non-receipt");
|
|
629
800
|
}
|
|
801
|
+
// Notifai never learns whether the OS painted a banner; saying so stops
|
|
802
|
+
// a reply-wait fault from being misread as "the phone never showed it".
|
|
803
|
+
deps.io.out(' OS presentation: not observed by Notifai — Provider Acceptance and Companion Receipt do not prove a banner was shown');
|
|
804
|
+
const replyEvent = d.events.find((e) => e.stage === 'reply_received');
|
|
805
|
+
if (replyEvent) {
|
|
806
|
+
anyReplyReceived = true;
|
|
807
|
+
deps.io.out(` Reply received: yes (first at ${replyEvent.occurred_at})`);
|
|
808
|
+
}
|
|
809
|
+
else {
|
|
810
|
+
deps.io.out(' Reply received: not yet recorded on this delivery');
|
|
811
|
+
}
|
|
630
812
|
for (const e of d.events) {
|
|
631
813
|
deps.io.out(` ${e.occurred_at} ${e.stage}${e.reason ? ` (${e.reason})` : ''}`);
|
|
632
814
|
}
|
|
633
815
|
}
|
|
816
|
+
deps.io.out(anyReplyReceived
|
|
817
|
+
? ` Reply wait: answers are on the server — collect with \`notifai replies ${snapshot.request_id}\` (a local wait fault does not erase them)`
|
|
818
|
+
: ` Reply wait: no answer stored yet — a blocking wait failure is independent of Delivery above; retry with \`notifai replies ${snapshot.request_id}\``);
|
|
634
819
|
return EXIT.ok;
|
|
635
820
|
}
|
|
636
821
|
catch (err) {
|
|
@@ -693,6 +878,10 @@ async function uploadImage(deps, client, source) {
|
|
|
693
878
|
// hook / ask / close — harness integration
|
|
694
879
|
// ---------------------------------------------------------------------------
|
|
695
880
|
export const HOOK_EVENTS = ['user-prompt-submit', 'stop', 'session-end'];
|
|
881
|
+
/** SessionEnd cleanup must precede every diagnostic that can wait on a file lock. */
|
|
882
|
+
export function hookDefersDiagnosticsUntilAfterCleanup(event) {
|
|
883
|
+
return event === 'session-end';
|
|
884
|
+
}
|
|
696
885
|
/**
|
|
697
886
|
* Runs one harness hook. Contract with every harness: hook JSON arrives on
|
|
698
887
|
* stdin, the decision (if any) goes to stdout, diagnostics go to stderr, and
|
|
@@ -707,54 +896,128 @@ export async function hookRunCommand(deps, event, readStdin, harness) {
|
|
|
707
896
|
deps.io.err(`Unknown hook event "${event}". Valid: ${HOOK_EVENTS.join(', ')}`);
|
|
708
897
|
return EXIT.usage;
|
|
709
898
|
}
|
|
710
|
-
|
|
899
|
+
// One clock owns the complete Stop invocation, including stdin, config,
|
|
900
|
+
// credentials, and client construction. Starting this inside `handleStop`
|
|
901
|
+
// would grant slow setup a second budget and let the harness kill us before
|
|
902
|
+
// an accepted answer is journaled or written to stdout.
|
|
903
|
+
const now = deps.now ?? Date.now;
|
|
904
|
+
const processDeadlineAt = now() + HOOK_TIMING.totalSeconds * 1000;
|
|
905
|
+
const logger = log(deps);
|
|
906
|
+
logger.bind({ cmd: `hook ${event}` });
|
|
907
|
+
let started = false;
|
|
908
|
+
const start = (data = {}) => {
|
|
909
|
+
if (started)
|
|
910
|
+
return;
|
|
911
|
+
started = true;
|
|
912
|
+
logger.info('hook.start', { hook: event, harness: harness ?? 'unknown', ...data });
|
|
913
|
+
};
|
|
914
|
+
const failureData = (err) => err instanceof ApiCallError
|
|
915
|
+
? { status: err.status, code: err.code, message: err.message, details: err.details }
|
|
916
|
+
: { message: err instanceof Error ? err.message : String(err) };
|
|
917
|
+
let raw;
|
|
711
918
|
try {
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
919
|
+
raw = await readStdin();
|
|
920
|
+
}
|
|
921
|
+
catch (err) {
|
|
922
|
+
start({ input: 'unavailable' });
|
|
923
|
+
logger.error('hook.end', {
|
|
924
|
+
hook: event,
|
|
925
|
+
outcome: 'ignored',
|
|
926
|
+
reason: 'input-read-failed',
|
|
927
|
+
...failureData(err),
|
|
928
|
+
});
|
|
929
|
+
return EXIT.ok;
|
|
930
|
+
}
|
|
931
|
+
if (raw.trim() !== '') {
|
|
932
|
+
try {
|
|
933
|
+
const parsed = JSON.parse(raw);
|
|
934
|
+
if (typeof parsed !== 'object' || parsed === null)
|
|
935
|
+
throw new Error('not an object');
|
|
723
936
|
}
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
...envelope,
|
|
730
|
-
...(sessionId === undefined ? {} : { session_id: sessionId }),
|
|
731
|
-
...(cwd === undefined ? {} : { cwd }),
|
|
732
|
-
stop_hook_active: envelope.stop_hook_active ??
|
|
733
|
-
(typeof envelope.loop_count === 'number' && envelope.loop_count > 0),
|
|
734
|
-
};
|
|
937
|
+
catch {
|
|
938
|
+
start({ input: 'malformed' });
|
|
939
|
+
logger.error('hook.end', { hook: event, outcome: 'ignored', reason: 'malformed-input' });
|
|
940
|
+
deps.io.err('notifai: ignored malformed or truncated hook input; no routing action was taken');
|
|
941
|
+
return EXIT.ok;
|
|
735
942
|
}
|
|
736
943
|
}
|
|
737
|
-
|
|
738
|
-
|
|
944
|
+
let envelope = parseHookInput(raw);
|
|
945
|
+
if (harness === 'cursor') {
|
|
946
|
+
const sessionId = envelope.session_id ?? envelope.conversation_id;
|
|
947
|
+
const cwd = envelope.cwd ?? envelope.workspace_roots?.[0];
|
|
948
|
+
envelope = {
|
|
949
|
+
...envelope,
|
|
950
|
+
...(sessionId === undefined ? {} : { session_id: sessionId }),
|
|
951
|
+
...(cwd === undefined ? {} : { cwd }),
|
|
952
|
+
stop_hook_active: envelope.stop_hook_active ??
|
|
953
|
+
(typeof envelope.loop_count === 'number' && envelope.loop_count > 0),
|
|
954
|
+
};
|
|
955
|
+
}
|
|
956
|
+
const cwd = envelope.cwd ?? deps.cwd;
|
|
957
|
+
const sessionEnd = hookDefersDiagnosticsUntilAfterCleanup(event);
|
|
958
|
+
logger.bind({ session: envelope.session_id ?? null });
|
|
959
|
+
let config = null;
|
|
960
|
+
let configFailure;
|
|
961
|
+
try {
|
|
962
|
+
config = loadConfig({ cwd, env: deps.env, sessionId: envelope.session_id });
|
|
963
|
+
// The hook's project is the session's, not this process's, and the log
|
|
964
|
+
// settings that apply are that project's too. Keeping a mutable bootstrap
|
|
965
|
+
// logger lets this more-specific layer turn logging back on.
|
|
966
|
+
logger.adopt(logSettingsFrom(config));
|
|
967
|
+
logger.bind({ project: config.project.value });
|
|
968
|
+
if (!sessionEnd) {
|
|
969
|
+
start({ cwd, stop_hook_active: envelope.stop_hook_active ?? null });
|
|
970
|
+
logConfigResolved(logger, config);
|
|
971
|
+
}
|
|
972
|
+
}
|
|
973
|
+
catch (err) {
|
|
974
|
+
configFailure = err;
|
|
975
|
+
if (!sessionEnd) {
|
|
976
|
+
start({ cwd, stop_hook_active: envelope.stop_hook_active ?? null });
|
|
977
|
+
logger.error('hook.end', {
|
|
978
|
+
hook: event,
|
|
979
|
+
outcome: 'failed',
|
|
980
|
+
reason: 'config-failed',
|
|
981
|
+
...failureData(err),
|
|
982
|
+
});
|
|
983
|
+
for (const line of describeHookFailure(err))
|
|
984
|
+
deps.io.err(`notifai: ${line}`);
|
|
985
|
+
return EXIT.ok;
|
|
986
|
+
}
|
|
739
987
|
}
|
|
740
|
-
// Everything below is inside one fail-open boundary.
|
|
741
|
-
//
|
|
742
|
-
//
|
|
743
|
-
// user than not having installed the hook at all.
|
|
988
|
+
// Everything below is inside one fail-open boundary. Credential loading,
|
|
989
|
+
// client construction and hook handling can all throw, and a hook that exits
|
|
990
|
+
// non-zero makes the harness report a failure — strictly worse than skipping.
|
|
744
991
|
try {
|
|
745
|
-
if (
|
|
992
|
+
if (sessionEnd) {
|
|
993
|
+
// Codex gives SessionEnd one second total. Do every durable cleanup write
|
|
994
|
+
// before lifecycle diagnostics: the log lock is deliberately allowed to
|
|
995
|
+
// wait that long, and a busy log must never preserve ended-session state
|
|
996
|
+
// or its inherited configuration. The resolved config above is retained
|
|
997
|
+
// in memory so logging still uses the ending session's settings afterwards.
|
|
746
998
|
const outcome = handleSessionEnd(deps.env, envelope, (deps.now ?? Date.now)());
|
|
999
|
+
start({ cwd, stop_hook_active: envelope.stop_hook_active ?? null });
|
|
1000
|
+
if (config !== null)
|
|
1001
|
+
logConfigResolved(logger, config);
|
|
1002
|
+
const data = { hook: event, decided: false, ...outcome.log };
|
|
1003
|
+
if (configFailure === undefined)
|
|
1004
|
+
logger.info('hook.end', data);
|
|
1005
|
+
else {
|
|
1006
|
+
logger.error('hook.end', {
|
|
1007
|
+
...data,
|
|
1008
|
+
reason: 'config-failed',
|
|
1009
|
+
config_error: failureData(configFailure),
|
|
1010
|
+
});
|
|
1011
|
+
}
|
|
747
1012
|
for (const note of outcome.notes)
|
|
748
1013
|
deps.io.err(`notifai: ${note}`);
|
|
749
1014
|
return EXIT.ok;
|
|
750
1015
|
}
|
|
751
|
-
//
|
|
752
|
-
|
|
753
|
-
// session belongs to is the harness's business, not ours.
|
|
754
|
-
const cwd = envelope.cwd ?? deps.cwd;
|
|
755
|
-
const config = loadConfig({ cwd, env: deps.env, sessionId: envelope.session_id });
|
|
1016
|
+
// Non-SessionEnd hooks cannot reach here without resolved configuration.
|
|
1017
|
+
const resolved = config;
|
|
756
1018
|
const credential = deps.store.load();
|
|
757
1019
|
if (!credential) {
|
|
1020
|
+
logger.error('hook.end', { hook: event, outcome: 'not-paired' });
|
|
758
1021
|
deps.io.err('notifai: hook skipped: this machine is not paired; run `notifai login`');
|
|
759
1022
|
return EXIT.ok;
|
|
760
1023
|
}
|
|
@@ -762,17 +1025,24 @@ export async function hookRunCommand(deps, event, readStdin, harness) {
|
|
|
762
1025
|
// repository can commit `.notifai/config.toml`, and honouring a base_url
|
|
763
1026
|
// from it would hand this machine's bearer token to whatever host it names.
|
|
764
1027
|
const baseUrl = credential.baseUrl;
|
|
765
|
-
if (
|
|
766
|
-
deps.io.err(`notifai: ignoring base_url from ${
|
|
1028
|
+
if (resolved.base_url.source !== 'default' && resolved.base_url.value !== baseUrl) {
|
|
1029
|
+
deps.io.err(`notifai: ignoring base_url from ${resolved.base_url.source}; hooks only talk to ${baseUrl}`);
|
|
767
1030
|
}
|
|
768
1031
|
// UserPromptSubmit runs in front of the user's own prompt under a 15s
|
|
769
1032
|
// harness ceiling and can make two calls, so each gets a small slice of it;
|
|
770
1033
|
// Stop is allowed to block and keeps the ordinary budget.
|
|
771
|
-
const client = makeClient(deps, baseUrl, `Bearer nfm_${credential.machineId}.${credential.secret}`, {
|
|
772
|
-
|
|
1034
|
+
const client = makeClient(deps, baseUrl, `Bearer nfm_${credential.machineId}.${credential.secret}`, {
|
|
1035
|
+
timeoutMs: event === 'user-prompt-submit' ? 4_000 : 20_000,
|
|
1036
|
+
...(event === 'stop'
|
|
1037
|
+
? {
|
|
1038
|
+
deadlineAt: processDeadlineAt - HOOK_TIMING.stdoutReserveSeconds * 1000,
|
|
1039
|
+
now,
|
|
1040
|
+
}
|
|
1041
|
+
: {}),
|
|
1042
|
+
});
|
|
773
1043
|
const ctx = {
|
|
774
1044
|
client,
|
|
775
|
-
config,
|
|
1045
|
+
config: resolved,
|
|
776
1046
|
env: deps.env,
|
|
777
1047
|
now,
|
|
778
1048
|
idleSeconds: deps.idleSeconds ?? (() => readIdleSeconds()),
|
|
@@ -790,22 +1060,38 @@ export async function hookRunCommand(deps, event, readStdin, harness) {
|
|
|
790
1060
|
degraded: result.degraded,
|
|
791
1061
|
};
|
|
792
1062
|
},
|
|
1063
|
+
log: logger,
|
|
793
1064
|
...(harness === undefined ? {} : { harness }),
|
|
794
1065
|
};
|
|
795
1066
|
// Real clock, deliberately, not `deps.now`. This compares against file
|
|
796
1067
|
// mtimes, which are wall-clock facts — handing it a virtual or skewed clock
|
|
797
1068
|
// would have it delete live session state as "abandoned".
|
|
798
|
-
//
|
|
799
|
-
//
|
|
800
|
-
|
|
1069
|
+
// Daily state pruning is housekeeping, not part of the Stop delivery
|
|
1070
|
+
// contract. Its directory scan has no useful bound, so keep it on the
|
|
1071
|
+
// short prompt path and never spend the answer owner's finite budget on it.
|
|
1072
|
+
if (event !== 'stop')
|
|
1073
|
+
pruneAbandonedSessions(deps.env);
|
|
801
1074
|
const outcome = event === 'user-prompt-submit'
|
|
802
1075
|
? await handleUserPromptSubmit(ctx, envelope)
|
|
803
|
-
: await handleStop(ctx, envelope);
|
|
1076
|
+
: await handleStop(ctx, envelope, processDeadlineAt);
|
|
1077
|
+
// Answer diagnostics are already persisted once as hook.answer. Keep every
|
|
1078
|
+
// other note in the lifecycle record without duplicating the user's text.
|
|
1079
|
+
const notes = outcome.notes.filter((note) => !/^(?:late )?answer from /.test(note));
|
|
1080
|
+
logger.info('hook.end', {
|
|
1081
|
+
hook: event,
|
|
1082
|
+
// A hook that returns stdout has taken over the turn; one that does not
|
|
1083
|
+
// has handed the terminal back. That distinction is the whole contract.
|
|
1084
|
+
decided: outcome.stdout !== undefined,
|
|
1085
|
+
...(notes.length === 0 ? {} : { notes }),
|
|
1086
|
+
...outcome.log,
|
|
1087
|
+
});
|
|
804
1088
|
for (const note of outcome.notes)
|
|
805
1089
|
deps.io.err(`notifai: ${note}`);
|
|
806
1090
|
if (outcome.stdout !== undefined) {
|
|
807
1091
|
let stdout = outcome.stdout;
|
|
808
|
-
if (harness
|
|
1092
|
+
if (harness !== undefined &&
|
|
1093
|
+
event === 'stop' &&
|
|
1094
|
+
HARNESS_CAPABILITIES[harness].stopContinuation === 'followup-message') {
|
|
809
1095
|
const decision = JSON.parse(outcome.stdout);
|
|
810
1096
|
if (decision.decision === 'block' && typeof decision.reason === 'string') {
|
|
811
1097
|
stdout = JSON.stringify({ followup_message: decision.reason });
|
|
@@ -816,6 +1102,18 @@ export async function hookRunCommand(deps, event, readStdin, harness) {
|
|
|
816
1102
|
return EXIT.ok;
|
|
817
1103
|
}
|
|
818
1104
|
catch (err) {
|
|
1105
|
+
// SessionEnd defers its start record until after cleanup; if cleanup itself
|
|
1106
|
+
// fails, begin the after-the-fact lifecycle here before recording why.
|
|
1107
|
+
if (sessionEnd)
|
|
1108
|
+
start({ cwd, stop_hook_active: envelope.stop_hook_active ?? null });
|
|
1109
|
+
// The hook still exits 0 — handing the terminal back is always right. What
|
|
1110
|
+
// this adds is that the reason survives, including the server's own words.
|
|
1111
|
+
logger.error('hook.end', {
|
|
1112
|
+
hook: event,
|
|
1113
|
+
outcome: 'failed',
|
|
1114
|
+
reason: 'execution-failed',
|
|
1115
|
+
...failureData(err),
|
|
1116
|
+
});
|
|
819
1117
|
for (const line of describeHookFailure(err))
|
|
820
1118
|
deps.io.err(`notifai: ${line}`);
|
|
821
1119
|
return EXIT.ok;
|
|
@@ -945,28 +1243,36 @@ function buildOneQuestion(text, choiceLabels, multi, index, usedIds) {
|
|
|
945
1243
|
}
|
|
946
1244
|
/**
|
|
947
1245
|
* Registers a question for turn-end routing under the user's presence config.
|
|
948
|
-
* Returns immediately so the agent can ask in prose and end its turn.
|
|
949
|
-
*
|
|
950
|
-
*
|
|
1246
|
+
* Returns immediately so the agent can ask in prose and end its turn. Presence
|
|
1247
|
+
* does not block the shipped default; `require_idle = true` intentionally keeps
|
|
1248
|
+
* the question local while the user is active.
|
|
951
1249
|
*/
|
|
952
1250
|
export function askCommand(deps, question, flags) {
|
|
1251
|
+
// Validate before route discovery. A malformed question belongs to the
|
|
1252
|
+
// caller and should not be hidden behind whichever harness setup issue
|
|
1253
|
+
// happens to exist on this machine.
|
|
1254
|
+
const built = buildQuestions(flags, question);
|
|
1255
|
+
if (!built.ok) {
|
|
1256
|
+
deps.io.err(built.error);
|
|
1257
|
+
return EXIT.usage;
|
|
1258
|
+
}
|
|
1259
|
+
const routingConfig = loadConfig({ cwd: deps.cwd, env: deps.env });
|
|
1260
|
+
if (!routingConfig.ask_notifications.value) {
|
|
1261
|
+
deps.io.err('Question routing is disabled by ask_notifications=false; enable it or use a blocking `notifai send --reply` question.');
|
|
1262
|
+
return EXIT.usage;
|
|
1263
|
+
}
|
|
1264
|
+
if (deps.store.load() === null) {
|
|
1265
|
+
deps.io.err('Question routing is not paired on this machine; run `notifai login` before registering an asynchronous question.');
|
|
1266
|
+
return EXIT.usage;
|
|
1267
|
+
}
|
|
953
1268
|
// An agent calling this gets no hook payload. Harness-native environment
|
|
954
|
-
// markers identify the active owner, while the
|
|
955
|
-
//
|
|
956
|
-
// The pointer outranks the
|
|
957
|
-
// NOTIFAI_SESSION fallback deliberately: the exported id is often a chosen
|
|
958
|
-
// label rather than the harness's own id, and the hooks key state by the
|
|
959
|
-
// latter — the env var is only trusted when no hook has spoken.
|
|
1269
|
+
// markers identify the active owner, while UserPromptSubmit adds the hook's
|
|
1270
|
+
// canonical id to the directory's concurrent-session index.
|
|
960
1271
|
const now = (deps.now ?? Date.now)();
|
|
961
|
-
const projectPointer = readProjectSessionPointer(deps.cwd, deps.env, now);
|
|
962
|
-
const pointer = projectPointer?.sessionId ?? null;
|
|
963
1272
|
const active = activeHarnessSession(deps.env);
|
|
964
1273
|
let sessionId;
|
|
965
|
-
if (
|
|
966
|
-
|
|
967
|
-
}
|
|
968
|
-
else if (active !== null) {
|
|
969
|
-
const installations = findInstallations(deps.cwd, deps.env);
|
|
1274
|
+
if (active !== null) {
|
|
1275
|
+
const installations = findInstallations(deps.cwd, deps.env, deps.hookAdapterHome);
|
|
970
1276
|
const activeInstalled = installations.some((installation) => installation.harness === active.harness);
|
|
971
1277
|
if (!activeInstalled) {
|
|
972
1278
|
for (const line of diagnoseActiveHarnessSession(active, 'not-installed', installations)) {
|
|
@@ -974,35 +1280,44 @@ export function askCommand(deps, question, flags) {
|
|
|
974
1280
|
}
|
|
975
1281
|
return EXIT.usage;
|
|
976
1282
|
}
|
|
1283
|
+
if (active.sessionId === undefined) {
|
|
1284
|
+
for (const problem of activeQuestionRouteProblems(deps, active, installations)) {
|
|
1285
|
+
deps.io.err(`Question routing is not ready: ${problem}`);
|
|
1286
|
+
}
|
|
1287
|
+
return EXIT.usage;
|
|
1288
|
+
}
|
|
1289
|
+
const projectPointer = readMatchingProjectSessionPointer(deps.cwd, deps.env, now, active.sessionId, active.harness);
|
|
977
1290
|
if (projectPointer === null) {
|
|
978
1291
|
for (const line of diagnoseActiveHarnessSession(active, 'not-fired', installations)) {
|
|
979
1292
|
deps.io.err(line);
|
|
980
1293
|
}
|
|
981
1294
|
return EXIT.usage;
|
|
982
1295
|
}
|
|
983
|
-
if (
|
|
984
|
-
(
|
|
985
|
-
|
|
986
|
-
|
|
987
|
-
|
|
1296
|
+
if (flags.session !== undefined && flags.session !== active.sessionId) {
|
|
1297
|
+
deps.io.err(`Question routing is not ready: --session ${flags.session} does not match the exact active ${active.label} session ${active.sessionId}; refusing cross-session routing.`);
|
|
1298
|
+
return EXIT.usage;
|
|
1299
|
+
}
|
|
1300
|
+
const routeProblems = activeQuestionRouteProblems(deps, active, installations);
|
|
1301
|
+
if (routeProblems.length > 0) {
|
|
1302
|
+
for (const problem of routeProblems)
|
|
1303
|
+
deps.io.err(`Question routing is not ready: ${problem}`);
|
|
1304
|
+
return EXIT.usage;
|
|
1305
|
+
}
|
|
1306
|
+
if (readSessionState(projectPointer.sessionId, deps.env).last_stop_at === undefined) {
|
|
1307
|
+
deps.io.err(`Question routing is not ready: this ${active.label} session has fired UserPromptSubmit, but its Stop hook has not been observed. End one harmless turn, send a new prompt, then run \`notifai doctor\`.`);
|
|
988
1308
|
return EXIT.usage;
|
|
989
1309
|
}
|
|
990
1310
|
sessionId = projectPointer.sessionId;
|
|
991
1311
|
}
|
|
992
1312
|
else {
|
|
993
|
-
sessionId = pointer ?? deps.env['NOTIFAI_SESSION'] ?? undefined;
|
|
994
|
-
}
|
|
995
|
-
if (!sessionId) {
|
|
996
1313
|
for (const line of diagnoseMissingSession(deps))
|
|
997
1314
|
deps.io.err(line);
|
|
1315
|
+
deps.io.err('Could not prove which live harness session owns this command, so Notifai will not register a question that could be delivered to the wrong or already-ended agent. Run it from a supported harness with exact session identity, or use a blocking `notifai send --reply` question.');
|
|
998
1316
|
return EXIT.usage;
|
|
999
1317
|
}
|
|
1000
|
-
|
|
1001
|
-
|
|
1002
|
-
|
|
1003
|
-
const built = buildQuestions(flags, question);
|
|
1004
|
-
if (!built.ok) {
|
|
1005
|
-
deps.io.err(built.error);
|
|
1318
|
+
if (!sessionId) {
|
|
1319
|
+
for (const line of diagnoseMissingSession(deps))
|
|
1320
|
+
deps.io.err(line);
|
|
1006
1321
|
return EXIT.usage;
|
|
1007
1322
|
}
|
|
1008
1323
|
try {
|
|
@@ -1013,9 +1328,20 @@ export function askCommand(deps, question, flags) {
|
|
|
1013
1328
|
}, (deps.now ?? Date.now)());
|
|
1014
1329
|
}
|
|
1015
1330
|
catch (err) {
|
|
1331
|
+
log(deps).error('ask.registered', { ok: false, session: sessionId, message: String(err) });
|
|
1016
1332
|
deps.io.err(`Could not register the question: ${err instanceof Error ? err.message : String(err)}`);
|
|
1017
1333
|
return EXIT.failed;
|
|
1018
1334
|
}
|
|
1335
|
+
// `ask` returns immediately and the push happens later inside a hook, so this
|
|
1336
|
+
// is the only local evidence that the question was ever registered — the
|
|
1337
|
+
// starting point for "the agent asked, and nothing ever reached my phone".
|
|
1338
|
+
log(deps).info('ask.registered', {
|
|
1339
|
+
ok: true,
|
|
1340
|
+
session: sessionId,
|
|
1341
|
+
questions: built.questions.length,
|
|
1342
|
+
text: built.questions[0].text,
|
|
1343
|
+
choices: built.questions[0].choices?.length ?? 0,
|
|
1344
|
+
});
|
|
1019
1345
|
for (const [index, entry] of built.questions.entries()) {
|
|
1020
1346
|
const prefix = built.questions.length > 1 ? `${index + 1}. ` : '';
|
|
1021
1347
|
if (entry.choices !== undefined) {
|
|
@@ -1057,6 +1383,52 @@ function activeHarnessSession(env) {
|
|
|
1057
1383
|
return { harness: 'cursor', label: 'Cursor' };
|
|
1058
1384
|
return null;
|
|
1059
1385
|
}
|
|
1386
|
+
/**
|
|
1387
|
+
* One fail-closed admission gate shared by every active harness route.
|
|
1388
|
+
* Finding a file is not readiness: the exact session, one current definition,
|
|
1389
|
+
* the stable adapter, a long enough Stop owner, and (for Codex) trust must all
|
|
1390
|
+
* be true before `ask` is allowed to create an answerable notification.
|
|
1391
|
+
*/
|
|
1392
|
+
function activeQuestionRouteProblems(deps, active, installations) {
|
|
1393
|
+
const problems = [];
|
|
1394
|
+
if (active.sessionId === undefined) {
|
|
1395
|
+
problems.push(`the active ${active.label} shell does not expose an exact session id; a project-level last-writer pointer can cross-wire two sessions. Use a blocking \`notifai send --reply\` question`);
|
|
1396
|
+
}
|
|
1397
|
+
const capability = HARNESS_CAPABILITIES[active.harness];
|
|
1398
|
+
if (capability.stopContinuation === 'unsupported') {
|
|
1399
|
+
problems.push(`${active.label}: ${capability.deliveryContract}`);
|
|
1400
|
+
}
|
|
1401
|
+
const matching = installations.filter((installation) => installation.harness === active.harness);
|
|
1402
|
+
if (matching.length > 1) {
|
|
1403
|
+
problems.push(`${matching.length} ${active.label} definitions are active (${matching.map((entry) => entry.file).join(', ')}); keep either project or global routing`);
|
|
1404
|
+
}
|
|
1405
|
+
for (const installation of matching) {
|
|
1406
|
+
for (const problem of installation.problems ?? []) {
|
|
1407
|
+
problems.push(`${installation.file}: ${problem}`);
|
|
1408
|
+
}
|
|
1409
|
+
for (const handler of installation.handlers) {
|
|
1410
|
+
const event = handlerEvent(handler.command);
|
|
1411
|
+
if (event !== null && !HOOK_EVENTS.includes(event)) {
|
|
1412
|
+
problems.push(`${handler.event} in ${installation.file} names the unsupported event ${event}; reinstall the ${active.label} hooks`);
|
|
1413
|
+
}
|
|
1414
|
+
}
|
|
1415
|
+
}
|
|
1416
|
+
for (const problem of inspectHookAdapter(deps.hookAdapterHome).problems) {
|
|
1417
|
+
problems.push(problem);
|
|
1418
|
+
}
|
|
1419
|
+
if (active.harness !== 'codex') {
|
|
1420
|
+
const required = blockingHookTimeoutSeconds();
|
|
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
|
+
}
|
|
1429
|
+
problems.push(...codexTrustProblems(matching, deps.env));
|
|
1430
|
+
return [...new Set(problems)];
|
|
1431
|
+
}
|
|
1060
1432
|
function diagnoseActiveHarnessSession(active, problem, installations) {
|
|
1061
1433
|
if (problem === 'not-installed') {
|
|
1062
1434
|
const others = installations.map((installation) => installation.harness);
|
|
@@ -1094,7 +1466,7 @@ function diagnoseActiveHarnessSession(active, problem, installations) {
|
|
|
1094
1466
|
* checked after a prompt before assuming that a new session is required.
|
|
1095
1467
|
*/
|
|
1096
1468
|
function diagnoseMissingSession(deps) {
|
|
1097
|
-
const installations = findInstallations(deps.cwd, deps.env);
|
|
1469
|
+
const installations = findInstallations(deps.cwd, deps.env, deps.hookAdapterHome);
|
|
1098
1470
|
if (installations.length === 0) {
|
|
1099
1471
|
return [
|
|
1100
1472
|
'Could not tell which harness session this is: no Notifai hooks are installed for this project.',
|
|
@@ -1106,7 +1478,7 @@ function diagnoseMissingSession(deps) {
|
|
|
1106
1478
|
`Could not tell which harness session this is. Notifai hooks are installed (${where}),`,
|
|
1107
1479
|
'but no usable session pointer from the last 24 hours exists here.',
|
|
1108
1480
|
hookActivationAdvice(installations),
|
|
1109
|
-
'
|
|
1481
|
+
'Do not bypass this with a guessed session id; run from the exact active harness session or use a blocking `notifai send --reply` question.',
|
|
1110
1482
|
];
|
|
1111
1483
|
}
|
|
1112
1484
|
/** The least disruptive verified way to make each installed adapter run once. */
|
|
@@ -1129,13 +1501,13 @@ function hookActivationAdvice(installations) {
|
|
|
1129
1501
|
advice.push('Codex: send one prompt, then run `notifai doctor`; start a new session only if it still has not fired');
|
|
1130
1502
|
}
|
|
1131
1503
|
if (harnesses.has('opencode')) {
|
|
1132
|
-
advice.push('OpenCode: restart it, then send one prompt; plugins load at startup,
|
|
1504
|
+
advice.push('OpenCode: restart it, then send one prompt; plugins load at startup, but non-blocking question continuation is intentionally unsupported');
|
|
1133
1505
|
}
|
|
1134
1506
|
return `${advice.join('. ')}.`;
|
|
1135
1507
|
}
|
|
1136
1508
|
/** Retire a question so a late answer is rejected rather than silently lost. */
|
|
1137
1509
|
export async function closeCommand(deps, requestId) {
|
|
1138
|
-
const config =
|
|
1510
|
+
const config = loadLoggedConfig(deps, { cwd: deps.cwd, env: deps.env });
|
|
1139
1511
|
const authed = authedClient(deps, config);
|
|
1140
1512
|
if (!authed)
|
|
1141
1513
|
return EXIT.auth;
|
|
@@ -1154,35 +1526,36 @@ export function hooksInstallCommand(deps, flags) {
|
|
|
1154
1526
|
return EXIT.usage;
|
|
1155
1527
|
const execPath = flags.execPath ?? process.execPath;
|
|
1156
1528
|
const scriptPath = flags.scriptPath ?? process.argv[1] ?? 'notifai';
|
|
1157
|
-
|
|
1529
|
+
let adapterPath;
|
|
1530
|
+
try {
|
|
1531
|
+
adapterPath = installHookAdapter({ execPath, scriptPath }, deps.hookAdapterHome).path;
|
|
1532
|
+
}
|
|
1533
|
+
catch (err) {
|
|
1534
|
+
deps.io.err(`Could not prepare the stable hook adapter: ${String(err)}`);
|
|
1535
|
+
return EXIT.failed;
|
|
1536
|
+
}
|
|
1537
|
+
const config = loadLoggedConfig(deps, { cwd: deps.cwd, env: deps.env });
|
|
1158
1538
|
const file = settingsFile(harness, flags.global ?? false, deps.cwd, deps.env);
|
|
1159
1539
|
// OpenCode's adapter is a generated plugin module rather than a handler
|
|
1160
1540
|
// merged into a settings document, so it owns the whole file.
|
|
1161
1541
|
if (harness === 'opencode') {
|
|
1162
1542
|
return installOpencodePlugin(deps, file, {
|
|
1163
|
-
|
|
1164
|
-
|
|
1165
|
-
timeoutSeconds: blockingHookTimeoutSeconds(config.ask_grace_seconds.value, config.hook_reply_timeout_seconds.value),
|
|
1543
|
+
adapterPath,
|
|
1544
|
+
timeoutSeconds: blockingHookTimeoutSeconds(),
|
|
1166
1545
|
});
|
|
1167
1546
|
}
|
|
1168
1547
|
if (harness === 'cursor') {
|
|
1169
|
-
let
|
|
1548
|
+
let merged;
|
|
1170
1549
|
try {
|
|
1171
|
-
|
|
1172
|
-
|
|
1173
|
-
|
|
1174
|
-
|
|
1175
|
-
|
|
1176
|
-
|
|
1177
|
-
|
|
1178
|
-
|
|
1179
|
-
|
|
1180
|
-
replyTimeoutSeconds: config.hook_reply_timeout_seconds.value,
|
|
1181
|
-
graceSeconds: config.ask_grace_seconds.value,
|
|
1182
|
-
harness: 'cursor',
|
|
1183
|
-
}), scriptPath);
|
|
1184
|
-
try {
|
|
1185
|
-
applyPlan(file, merged.document);
|
|
1550
|
+
merged = withTargetFileLock(file, () => {
|
|
1551
|
+
const document = loadCursorSettings(file);
|
|
1552
|
+
const result = mergeCursorHooks(document, buildCursorHookConfig({
|
|
1553
|
+
adapterPath,
|
|
1554
|
+
harness: 'cursor',
|
|
1555
|
+
}), scriptPath);
|
|
1556
|
+
applyPlan(file, result.document);
|
|
1557
|
+
return result;
|
|
1558
|
+
});
|
|
1186
1559
|
}
|
|
1187
1560
|
catch (err) {
|
|
1188
1561
|
deps.io.err(String(err));
|
|
@@ -1201,27 +1574,22 @@ export function hooksInstallCommand(deps, flags) {
|
|
|
1201
1574
|
deps.io.out('Send one Cursor prompt, then check `notifai doctor`. If the hook has not fired,');
|
|
1202
1575
|
deps.io.out('start a new Cursor session and try one prompt again.');
|
|
1203
1576
|
}
|
|
1204
|
-
deps.io.out('
|
|
1205
|
-
deps.io.out('
|
|
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.');
|
|
1206
1580
|
return EXIT.ok;
|
|
1207
1581
|
}
|
|
1208
|
-
let
|
|
1582
|
+
let merged;
|
|
1209
1583
|
try {
|
|
1210
|
-
|
|
1211
|
-
|
|
1212
|
-
|
|
1213
|
-
|
|
1214
|
-
|
|
1215
|
-
|
|
1216
|
-
|
|
1217
|
-
|
|
1218
|
-
|
|
1219
|
-
replyTimeoutSeconds: config.hook_reply_timeout_seconds.value,
|
|
1220
|
-
graceSeconds: config.ask_grace_seconds.value,
|
|
1221
|
-
harness,
|
|
1222
|
-
}), scriptPath);
|
|
1223
|
-
try {
|
|
1224
|
-
applyPlan(file, merged.document);
|
|
1584
|
+
merged = withTargetFileLock(file, () => {
|
|
1585
|
+
const document = loadSettings(file);
|
|
1586
|
+
const result = mergeHooks(document, buildHookConfig({
|
|
1587
|
+
adapterPath,
|
|
1588
|
+
harness,
|
|
1589
|
+
}), scriptPath);
|
|
1590
|
+
applyPlan(file, result.document);
|
|
1591
|
+
return result;
|
|
1592
|
+
});
|
|
1225
1593
|
}
|
|
1226
1594
|
catch (err) {
|
|
1227
1595
|
deps.io.err(String(err));
|
|
@@ -1236,16 +1604,19 @@ export function hooksInstallCommand(deps, flags) {
|
|
|
1236
1604
|
deps.io.out(` removed: ${merged.removed.join(', ')} (this build no longer serves them)`);
|
|
1237
1605
|
}
|
|
1238
1606
|
deps.io.out('');
|
|
1239
|
-
|
|
1240
|
-
|
|
1241
|
-
`nothing is pushed. A question registered with \`notifai ask\`
|
|
1242
|
-
|
|
1243
|
-
|
|
1244
|
-
|
|
1245
|
-
|
|
1246
|
-
|
|
1247
|
-
|
|
1248
|
-
|
|
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
|
+
}
|
|
1249
1620
|
if (config.require_idle.value) {
|
|
1250
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.');
|
|
1251
1622
|
}
|
|
@@ -1286,16 +1657,21 @@ export function hooksInstallCommand(deps, flags) {
|
|
|
1286
1657
|
* the same reason command hooks are.
|
|
1287
1658
|
*/
|
|
1288
1659
|
function installOpencodePlugin(deps, file, options) {
|
|
1289
|
-
if (existsSync(file)) {
|
|
1290
|
-
const existing = readFileSync(file, 'utf8');
|
|
1291
|
-
if (!isOurOpencodePlugin(existing)) {
|
|
1292
|
-
deps.io.err(`${file} exists and was not written by Notifai; move it aside first.`);
|
|
1293
|
-
return EXIT.failed;
|
|
1294
|
-
}
|
|
1295
|
-
}
|
|
1296
1660
|
try {
|
|
1297
|
-
|
|
1298
|
-
|
|
1661
|
+
withTargetFileLock(file, () => {
|
|
1662
|
+
if (existsSync(file)) {
|
|
1663
|
+
assertOwnedRegularFile(file);
|
|
1664
|
+
const existing = readFileSync(file, 'utf8');
|
|
1665
|
+
if (!isOurOpencodePlugin(existing)) {
|
|
1666
|
+
throw new Error(`${file} exists and was not written by Notifai; move it aside first.`);
|
|
1667
|
+
}
|
|
1668
|
+
}
|
|
1669
|
+
atomicWriteFileSync(file, opencodePluginSource(options), {
|
|
1670
|
+
mode: 0o600,
|
|
1671
|
+
preserveMode: false,
|
|
1672
|
+
requireCurrentUserOwner: true,
|
|
1673
|
+
});
|
|
1674
|
+
});
|
|
1299
1675
|
}
|
|
1300
1676
|
catch (err) {
|
|
1301
1677
|
deps.io.err(String(err));
|
|
@@ -1306,9 +1682,9 @@ function installOpencodePlugin(deps, file, options) {
|
|
|
1306
1682
|
deps.io.out('It maps chat.message to presence, session.idle to question escalation, and');
|
|
1307
1683
|
deps.io.out('session.deleted to local cleanup through the same `notifai hook` commands');
|
|
1308
1684
|
deps.io.out('the other harnesses run. Permission prompts stay in OpenCode.');
|
|
1309
|
-
deps.io.out('OpenCode
|
|
1310
|
-
deps.io.out('
|
|
1311
|
-
deps.io.out('
|
|
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.');
|
|
1312
1688
|
deps.io.out('');
|
|
1313
1689
|
deps.io.out('Restart OpenCode: it loads plugins once at start.');
|
|
1314
1690
|
return EXIT.ok;
|
|
@@ -1319,63 +1695,88 @@ export function hooksUninstallCommand(deps, flags) {
|
|
|
1319
1695
|
return EXIT.usage;
|
|
1320
1696
|
const scriptPath = flags.scriptPath ?? process.argv[1] ?? 'notifai';
|
|
1321
1697
|
const file = settingsFile(harness, flags.global ?? false, deps.cwd, deps.env);
|
|
1322
|
-
if (!existsSync(file)) {
|
|
1323
|
-
deps.io.out(`Nothing to remove: ${file} does not exist.`);
|
|
1324
|
-
return EXIT.ok;
|
|
1325
|
-
}
|
|
1326
1698
|
if (harness === 'opencode') {
|
|
1327
|
-
// We own the whole file, but only if we wrote it.
|
|
1328
|
-
if (!isOurOpencodePlugin(readFileSync(file, 'utf8'))) {
|
|
1329
|
-
deps.io.out(`Left ${file} alone: Notifai did not write it.`);
|
|
1330
|
-
return EXIT.ok;
|
|
1331
|
-
}
|
|
1332
|
-
rmSync(file, { force: true });
|
|
1333
|
-
deps.io.out(`Removed the Notifai OpenCode plugin at ${file}`);
|
|
1334
|
-
return EXIT.ok;
|
|
1335
|
-
}
|
|
1336
|
-
if (harness === 'cursor') {
|
|
1337
|
-
let document;
|
|
1338
1699
|
try {
|
|
1339
|
-
|
|
1700
|
+
return withTargetFileLock(file, () => {
|
|
1701
|
+
if (!existsSync(file)) {
|
|
1702
|
+
deps.io.out(`Nothing to remove: ${file} does not exist.`);
|
|
1703
|
+
return EXIT.ok;
|
|
1704
|
+
}
|
|
1705
|
+
assertOwnedRegularFile(file);
|
|
1706
|
+
// We own the whole file, but only if we wrote it.
|
|
1707
|
+
if (!isOurOpencodePlugin(readFileSync(file, 'utf8'))) {
|
|
1708
|
+
deps.io.out(`Left ${file} alone: Notifai did not write it.`);
|
|
1709
|
+
return EXIT.ok;
|
|
1710
|
+
}
|
|
1711
|
+
rmSync(file, { force: true });
|
|
1712
|
+
deps.io.out(`Removed the Notifai OpenCode plugin at ${file}`);
|
|
1713
|
+
return EXIT.ok;
|
|
1714
|
+
});
|
|
1340
1715
|
}
|
|
1341
1716
|
catch (err) {
|
|
1342
1717
|
deps.io.err(String(err));
|
|
1343
1718
|
return EXIT.failed;
|
|
1344
1719
|
}
|
|
1345
|
-
|
|
1720
|
+
}
|
|
1721
|
+
if (harness === 'cursor') {
|
|
1722
|
+
let stripped;
|
|
1346
1723
|
try {
|
|
1347
|
-
|
|
1724
|
+
stripped = withTargetFileLock(file, () => {
|
|
1725
|
+
if (!existsSync(file))
|
|
1726
|
+
return null;
|
|
1727
|
+
const document = loadCursorSettings(file);
|
|
1728
|
+
const result = removeCursorHooks(document, scriptPath);
|
|
1729
|
+
applyPlan(file, result.document);
|
|
1730
|
+
return result;
|
|
1731
|
+
});
|
|
1348
1732
|
}
|
|
1349
1733
|
catch (err) {
|
|
1350
1734
|
deps.io.err(String(err));
|
|
1351
1735
|
return EXIT.failed;
|
|
1352
1736
|
}
|
|
1737
|
+
if (stripped === null) {
|
|
1738
|
+
deps.io.out(`Nothing to remove: ${file} does not exist.`);
|
|
1739
|
+
return EXIT.ok;
|
|
1740
|
+
}
|
|
1353
1741
|
deps.io.out(stripped.replaced.length > 0
|
|
1354
1742
|
? `Removed Notifai hooks (${stripped.replaced.join(', ')}) from ${file}`
|
|
1355
1743
|
: `No Notifai hooks found in ${file}`);
|
|
1356
1744
|
return EXIT.ok;
|
|
1357
1745
|
}
|
|
1358
|
-
let
|
|
1746
|
+
let stripped;
|
|
1359
1747
|
try {
|
|
1360
|
-
|
|
1748
|
+
stripped = withTargetFileLock(file, () => {
|
|
1749
|
+
if (!existsSync(file))
|
|
1750
|
+
return null;
|
|
1751
|
+
const document = loadSettings(file);
|
|
1752
|
+
const result = removeHooks(document, scriptPath);
|
|
1753
|
+
applyPlan(file, result.document);
|
|
1754
|
+
return result;
|
|
1755
|
+
});
|
|
1361
1756
|
}
|
|
1362
1757
|
catch (err) {
|
|
1363
1758
|
deps.io.err(String(err));
|
|
1364
1759
|
return EXIT.failed;
|
|
1365
1760
|
}
|
|
1366
|
-
|
|
1367
|
-
|
|
1368
|
-
|
|
1369
|
-
}
|
|
1370
|
-
catch (err) {
|
|
1371
|
-
deps.io.err(String(err));
|
|
1372
|
-
return EXIT.failed;
|
|
1761
|
+
if (stripped === null) {
|
|
1762
|
+
deps.io.out(`Nothing to remove: ${file} does not exist.`);
|
|
1763
|
+
return EXIT.ok;
|
|
1373
1764
|
}
|
|
1374
1765
|
deps.io.out(stripped.replaced.length > 0
|
|
1375
1766
|
? `Removed Notifai hooks (${stripped.replaced.join(', ')}) from ${file}`
|
|
1376
1767
|
: `No Notifai hooks found in ${file}`);
|
|
1377
1768
|
return EXIT.ok;
|
|
1378
1769
|
}
|
|
1770
|
+
function assertOwnedRegularFile(file) {
|
|
1771
|
+
const stat = lstatSync(file);
|
|
1772
|
+
if (stat.isSymbolicLink() || !stat.isFile()) {
|
|
1773
|
+
throw new Error(`${file} is not a regular file; refusing to read or replace it.`);
|
|
1774
|
+
}
|
|
1775
|
+
const uid = typeof process.getuid === 'function' ? process.getuid() : undefined;
|
|
1776
|
+
if (uid !== undefined && stat.uid !== uid) {
|
|
1777
|
+
throw new Error(`${file} is owned by uid ${stat.uid}, not the current user.`);
|
|
1778
|
+
}
|
|
1779
|
+
}
|
|
1379
1780
|
function resolveHarness(deps, requested) {
|
|
1380
1781
|
if (requested !== undefined) {
|
|
1381
1782
|
if (HARNESSES.includes(requested))
|
|
@@ -1391,50 +1792,175 @@ function resolveHarness(deps, requested) {
|
|
|
1391
1792
|
return detected;
|
|
1392
1793
|
}
|
|
1393
1794
|
// ---------------------------------------------------------------------------
|
|
1394
|
-
// config show / set
|
|
1795
|
+
// config show / set / unset
|
|
1395
1796
|
// ---------------------------------------------------------------------------
|
|
1396
1797
|
export function configShowCommand(deps, flags) {
|
|
1397
|
-
const config =
|
|
1798
|
+
const config = loadLoggedConfig(deps, { cwd: deps.cwd, env: deps.env });
|
|
1398
1799
|
if (flags.json) {
|
|
1399
1800
|
const output = Object.fromEntries(CONFIG_KEYS.map((key) => [key, { value: config[key].value, source: config[key].source }]));
|
|
1400
1801
|
deps.io.out(JSON.stringify(output, null, 2));
|
|
1401
1802
|
return EXIT.ok;
|
|
1402
1803
|
}
|
|
1403
|
-
|
|
1404
|
-
|
|
1405
|
-
|
|
1406
|
-
|
|
1804
|
+
// Anything that is not a person at a terminal keeps the flat `key = value`
|
|
1805
|
+
// form it has always had. Scripts parse this, and a prettier layout for an
|
|
1806
|
+
// audience that cannot see it would only be a breaking change.
|
|
1807
|
+
if (deps.io.interactive !== true || flags.plain === true) {
|
|
1808
|
+
for (const line of renderConfigPlain(config, flags.explain === true))
|
|
1809
|
+
deps.io.out(line);
|
|
1810
|
+
return EXIT.ok;
|
|
1811
|
+
}
|
|
1812
|
+
for (const line of renderConfigList(config, { showAdvanced: flags.explain === true })) {
|
|
1813
|
+
deps.io.out(line);
|
|
1407
1814
|
}
|
|
1408
1815
|
return EXIT.ok;
|
|
1409
1816
|
}
|
|
1410
|
-
|
|
1817
|
+
/**
|
|
1818
|
+
* One setting, explained in full.
|
|
1819
|
+
*
|
|
1820
|
+
* The gap this closes: `config show` prints a value and provenance but that is
|
|
1821
|
+
* not enough to explain the setting's consequences. Every key already had a
|
|
1822
|
+
* careful explanation — in a TypeScript comment, read by everyone except the
|
|
1823
|
+
* person who needed it.
|
|
1824
|
+
*/
|
|
1825
|
+
export function configExplainCommand(deps, key, flags = {}) {
|
|
1411
1826
|
if (!CONFIG_KEYS.includes(key)) {
|
|
1412
|
-
deps.io.err(`Unknown
|
|
1827
|
+
deps.io.err(`Unknown setting "${key}".`);
|
|
1828
|
+
deps.io.err(`Valid settings: ${CONFIG_KEYS.join(', ')}`);
|
|
1413
1829
|
return EXIT.usage;
|
|
1414
1830
|
}
|
|
1831
|
+
const configKey = key;
|
|
1832
|
+
const config = loadLoggedConfig(deps, { cwd: deps.cwd, env: deps.env });
|
|
1833
|
+
const info = configInfo(configKey);
|
|
1834
|
+
const entry = config[configKey];
|
|
1835
|
+
if (flags.json) {
|
|
1836
|
+
deps.io.out(JSON.stringify({
|
|
1837
|
+
key: configKey,
|
|
1838
|
+
label: info.label,
|
|
1839
|
+
group: info.group,
|
|
1840
|
+
kind: info.kind,
|
|
1841
|
+
summary: info.summary,
|
|
1842
|
+
detail: info.detail,
|
|
1843
|
+
accepts: acceptedValues(configKey),
|
|
1844
|
+
...(info.choices !== undefined ? { choices: info.choices } : {}),
|
|
1845
|
+
value: entry.value,
|
|
1846
|
+
source: entry.source,
|
|
1847
|
+
}, null, 2));
|
|
1848
|
+
return EXIT.ok;
|
|
1849
|
+
}
|
|
1850
|
+
if (deps.io.interactive !== true) {
|
|
1851
|
+
deps.io.out(`${configKey} = ${JSON.stringify(entry.value)} [${entry.source}]`);
|
|
1852
|
+
deps.io.out(info.detail.replace(/\n\n/g, '\n'));
|
|
1853
|
+
deps.io.out(`accepts: ${acceptedValues(configKey)}`);
|
|
1854
|
+
return EXIT.ok;
|
|
1855
|
+
}
|
|
1856
|
+
for (const line of renderConfigExplain(configKey, config))
|
|
1857
|
+
deps.io.out(line);
|
|
1858
|
+
return EXIT.ok;
|
|
1859
|
+
}
|
|
1860
|
+
/**
|
|
1861
|
+
* Closest config key by edit distance, or null when nothing is close.
|
|
1862
|
+
*
|
|
1863
|
+
* The threshold matters more than the algorithm: suggesting a key that shares
|
|
1864
|
+
* three letters with the typo sends the reader to the wrong setting with
|
|
1865
|
+
* confidence, which is worse than listing all fourteen and letting them look.
|
|
1866
|
+
*/
|
|
1867
|
+
function nearestKey(input) {
|
|
1868
|
+
let best = null;
|
|
1869
|
+
let bestDistance = Number.POSITIVE_INFINITY;
|
|
1870
|
+
for (const candidate of CONFIG_KEYS) {
|
|
1871
|
+
const distance = editDistance(input.toLowerCase(), candidate);
|
|
1872
|
+
if (distance < bestDistance) {
|
|
1873
|
+
bestDistance = distance;
|
|
1874
|
+
best = candidate;
|
|
1875
|
+
}
|
|
1876
|
+
}
|
|
1877
|
+
return best !== null && bestDistance <= Math.max(2, Math.floor(best.length / 3)) ? best : null;
|
|
1878
|
+
}
|
|
1879
|
+
function editDistance(a, b) {
|
|
1880
|
+
const previous = Array.from({ length: b.length + 1 }, (_, i) => i);
|
|
1881
|
+
for (let i = 1; i <= a.length; i += 1) {
|
|
1882
|
+
let diagonal = previous[0];
|
|
1883
|
+
previous[0] = i;
|
|
1884
|
+
for (let j = 1; j <= b.length; j += 1) {
|
|
1885
|
+
const above = previous[j];
|
|
1886
|
+
previous[j] = Math.min(previous[j] + 1, previous[j - 1] + 1, diagonal + (a[i - 1] === b[j - 1] ? 0 : 1));
|
|
1887
|
+
diagonal = above;
|
|
1888
|
+
}
|
|
1889
|
+
}
|
|
1890
|
+
return previous[b.length];
|
|
1891
|
+
}
|
|
1892
|
+
export async function configSetCommand(deps, key, rawValue, flags) {
|
|
1893
|
+
const configKey = configMutationKey(deps, key);
|
|
1894
|
+
if (configKey === null)
|
|
1895
|
+
return EXIT.usage;
|
|
1896
|
+
const info = configInfo(configKey);
|
|
1415
1897
|
let value = rawValue;
|
|
1416
|
-
if (NUMERIC_CONFIG_KEYS.includes(
|
|
1898
|
+
if (NUMERIC_CONFIG_KEYS.includes(configKey)) {
|
|
1417
1899
|
const numeric = Number(rawValue);
|
|
1418
1900
|
if (!Number.isInteger(numeric)) {
|
|
1419
|
-
deps.io.err(
|
|
1901
|
+
deps.io.err(`${key} takes ${acceptedValues(configKey)}, not "${rawValue}".`);
|
|
1420
1902
|
return EXIT.usage;
|
|
1421
1903
|
}
|
|
1422
|
-
const bounds = configBounds(
|
|
1904
|
+
const bounds = configBounds(configKey);
|
|
1423
1905
|
if (bounds !== undefined && (numeric < bounds.min || numeric > bounds.max)) {
|
|
1424
1906
|
deps.io.err(`${key} must be between ${bounds.min} and ${bounds.max}.`);
|
|
1425
1907
|
return EXIT.usage;
|
|
1426
1908
|
}
|
|
1427
1909
|
value = numeric;
|
|
1428
1910
|
}
|
|
1429
|
-
if (BOOLEAN_CONFIG_KEYS.includes(
|
|
1911
|
+
if (BOOLEAN_CONFIG_KEYS.includes(configKey)) {
|
|
1430
1912
|
if (rawValue !== 'true' && rawValue !== 'false') {
|
|
1431
1913
|
deps.io.err(`${key} is a toggle — pass "true" or "false", not "${rawValue}".`);
|
|
1432
1914
|
return EXIT.usage;
|
|
1433
1915
|
}
|
|
1434
1916
|
value = rawValue === 'true';
|
|
1435
1917
|
}
|
|
1918
|
+
// Enum keys were accepted unchecked, so `config set sound whatever` wrote a
|
|
1919
|
+
// value the sender would later reject — a typo that only surfaces at the
|
|
1920
|
+
// moment a notification fails to carry the sound you asked for.
|
|
1921
|
+
if (info.kind === 'enum' && info.choices !== undefined && !info.choices.includes(rawValue)) {
|
|
1922
|
+
deps.io.err(`${key} takes one of: ${info.choices.join(', ')} — not "${rawValue}".`);
|
|
1923
|
+
return EXIT.usage;
|
|
1924
|
+
}
|
|
1436
1925
|
if (key === 'devices')
|
|
1437
1926
|
value = rawValue.split(',').map((s) => s.trim()).filter(Boolean);
|
|
1927
|
+
const target = await configMutationTarget(deps, flags);
|
|
1928
|
+
if (target === null)
|
|
1929
|
+
return EXIT.usage;
|
|
1930
|
+
if (target.layer === 'global' && Object.is(value, configDefaultValue(configKey))) {
|
|
1931
|
+
deps.io.err(`${key} is already the shipped default (${JSON.stringify(value)}).`);
|
|
1932
|
+
deps.io.err(`Run \`notifai config unset ${key} --yes\` to remove a redundant override instead of creating one.`);
|
|
1933
|
+
return EXIT.usage;
|
|
1934
|
+
}
|
|
1935
|
+
if (!flags.yes) {
|
|
1936
|
+
const confirmed = await deps.io.confirm(`Set ${key} = ${JSON.stringify(value)} in ${target.path}?`);
|
|
1937
|
+
if (!confirmed) {
|
|
1938
|
+
deps.io.err('Not confirmed. Pass --yes to skip the confirmation gate.');
|
|
1939
|
+
return EXIT.usage;
|
|
1940
|
+
}
|
|
1941
|
+
}
|
|
1942
|
+
const existing = existsSync(target.path)
|
|
1943
|
+
? parseToml(readFileSync(target.path, 'utf8'))
|
|
1944
|
+
: {};
|
|
1945
|
+
existing[key] = value;
|
|
1946
|
+
mkdirSync(path.dirname(target.path), { recursive: true });
|
|
1947
|
+
writeFileSync(target.path, `${stringifyToml(existing)}\n`);
|
|
1948
|
+
deps.io.out(`Wrote ${key} to ${target.path}`);
|
|
1949
|
+
return EXIT.ok;
|
|
1950
|
+
}
|
|
1951
|
+
function configMutationKey(deps, key) {
|
|
1952
|
+
if (!CONFIG_KEYS.includes(key)) {
|
|
1953
|
+
deps.io.err(`Unknown setting "${key}".`);
|
|
1954
|
+
const near = nearestKey(key);
|
|
1955
|
+
if (near !== null)
|
|
1956
|
+
deps.io.err(`Did you mean "${near}"?`);
|
|
1957
|
+
deps.io.err(`Valid settings: ${CONFIG_KEYS.join(', ')}`);
|
|
1958
|
+
deps.io.err('Run `notifai config explain <key>` to see what one of them does.');
|
|
1959
|
+
return null;
|
|
1960
|
+
}
|
|
1961
|
+
return key;
|
|
1962
|
+
}
|
|
1963
|
+
async function configMutationTarget(deps, flags) {
|
|
1438
1964
|
let layer = flags.local ? 'local' : flags.project ? 'project' : 'global';
|
|
1439
1965
|
if (flags.session === undefined &&
|
|
1440
1966
|
flags.local !== true &&
|
|
@@ -1449,7 +1975,7 @@ export async function configSetCommand(deps, key, rawValue, flags) {
|
|
|
1449
1975
|
]);
|
|
1450
1976
|
if (selected === null) {
|
|
1451
1977
|
deps.io.err('No configuration layer selected.');
|
|
1452
|
-
return
|
|
1978
|
+
return null;
|
|
1453
1979
|
}
|
|
1454
1980
|
layer = selected;
|
|
1455
1981
|
}
|
|
@@ -1460,20 +1986,236 @@ export async function configSetCommand(deps, key, rawValue, flags) {
|
|
|
1460
1986
|
: layer === 'project'
|
|
1461
1987
|
? (findProjectConfigPath(deps.cwd) ?? path.join(deps.cwd, '.notifai', 'config.toml'))
|
|
1462
1988
|
: globalConfigPath(deps.env);
|
|
1989
|
+
return {
|
|
1990
|
+
path: targetPath,
|
|
1991
|
+
layer: flags.session ? 'session' : layer,
|
|
1992
|
+
};
|
|
1993
|
+
}
|
|
1994
|
+
export async function configUnsetCommand(deps, key, flags) {
|
|
1995
|
+
const configKey = configMutationKey(deps, key);
|
|
1996
|
+
if (configKey === null)
|
|
1997
|
+
return EXIT.usage;
|
|
1998
|
+
const target = await configMutationTarget(deps, flags);
|
|
1999
|
+
if (target === null)
|
|
2000
|
+
return EXIT.usage;
|
|
2001
|
+
const existing = existsSync(target.path)
|
|
2002
|
+
? parseToml(readFileSync(target.path, 'utf8'))
|
|
2003
|
+
: {};
|
|
2004
|
+
if (!Object.prototype.hasOwnProperty.call(existing, configKey)) {
|
|
2005
|
+
deps.io.out(`${configKey} is not set in ${target.path}`);
|
|
2006
|
+
return EXIT.ok;
|
|
2007
|
+
}
|
|
1463
2008
|
if (!flags.yes) {
|
|
1464
|
-
const confirmed = await deps.io.confirm(`
|
|
2009
|
+
const confirmed = await deps.io.confirm(`Remove ${configKey} from ${target.path}?`);
|
|
1465
2010
|
if (!confirmed) {
|
|
1466
2011
|
deps.io.err('Not confirmed. Pass --yes to skip the confirmation gate.');
|
|
1467
2012
|
return EXIT.usage;
|
|
1468
2013
|
}
|
|
1469
2014
|
}
|
|
1470
|
-
|
|
1471
|
-
|
|
1472
|
-
:
|
|
1473
|
-
|
|
1474
|
-
|
|
1475
|
-
|
|
1476
|
-
|
|
2015
|
+
delete existing[configKey];
|
|
2016
|
+
if (Object.keys(existing).length === 0) {
|
|
2017
|
+
rmSync(target.path, { force: true });
|
|
2018
|
+
}
|
|
2019
|
+
else {
|
|
2020
|
+
writeFileSync(target.path, `${stringifyToml(existing)}\n`);
|
|
2021
|
+
}
|
|
2022
|
+
deps.io.out(`Removed ${configKey} from ${target.path}; the inherited value now applies.`);
|
|
2023
|
+
return EXIT.ok;
|
|
2024
|
+
}
|
|
2025
|
+
const LOG_RECORD_OPTIONS = [
|
|
2026
|
+
['limit', '--limit'],
|
|
2027
|
+
['all', '--all'],
|
|
2028
|
+
['since', '--since'],
|
|
2029
|
+
['level', '--level'],
|
|
2030
|
+
['event', '--event'],
|
|
2031
|
+
['run', '--run'],
|
|
2032
|
+
['request', '--request'],
|
|
2033
|
+
['session', '--session'],
|
|
2034
|
+
['project', '--project'],
|
|
2035
|
+
['allProjects', '--all-projects'],
|
|
2036
|
+
['grep', '--grep'],
|
|
2037
|
+
];
|
|
2038
|
+
/**
|
|
2039
|
+
* Deliberately small. The reader is usually a model with a finite context, and
|
|
2040
|
+
* a log command whose default answer is ten thousand lines gets used once.
|
|
2041
|
+
* Everything beyond this is reachable by asking for it.
|
|
2042
|
+
*/
|
|
2043
|
+
const DEFAULT_LOG_LIMIT = 30;
|
|
2044
|
+
/** Even `--all` stops somewhere; an unbounded dump is never the useful answer. */
|
|
2045
|
+
const MAX_LOG_LIMIT = 2_000;
|
|
2046
|
+
/** `10m`, `2h`, `1d`, or an ISO 8601 instant. */
|
|
2047
|
+
export function parseSince(raw, now) {
|
|
2048
|
+
const relative = /^(\d+)([smhd])$/.exec(raw.trim());
|
|
2049
|
+
if (relative !== null) {
|
|
2050
|
+
const amount = Number(relative[1]);
|
|
2051
|
+
const unit = relative[2];
|
|
2052
|
+
const seconds = { s: 1, m: 60, h: 3600, d: 86400 }[unit];
|
|
2053
|
+
return now - amount * seconds * 1000;
|
|
2054
|
+
}
|
|
2055
|
+
const absolute = Date.parse(raw);
|
|
2056
|
+
return Number.isNaN(absolute) ? null : absolute;
|
|
2057
|
+
}
|
|
2058
|
+
/**
|
|
2059
|
+
* What this machine recorded about itself.
|
|
2060
|
+
*
|
|
2061
|
+
* The retrieval half of the local log, and the half that decides whether the
|
|
2062
|
+
* log is worth having. Three things make it usable by an agent rather than
|
|
2063
|
+
* merely available to one: the default is bounded and scoped to this project,
|
|
2064
|
+
* the filters match the questions actually asked ("that run", "that
|
|
2065
|
+
* notification", "just the failures"), and `--json` gives the machine the
|
|
2066
|
+
* records untouched on stdout while every word of explanation goes to stderr.
|
|
2067
|
+
*/
|
|
2068
|
+
export function logsCommand(deps, flags) {
|
|
2069
|
+
const selectedRetrievalOptions = LOG_RECORD_OPTIONS
|
|
2070
|
+
.filter(([key]) => {
|
|
2071
|
+
const value = flags[key];
|
|
2072
|
+
return Array.isArray(value) ? value.length > 0 : value !== undefined && value !== false;
|
|
2073
|
+
})
|
|
2074
|
+
.map(([, name]) => name);
|
|
2075
|
+
if (flags.path === true && flags.clear === true) {
|
|
2076
|
+
deps.io.err('Pass --path or --clear, not both.');
|
|
2077
|
+
return EXIT.usage;
|
|
2078
|
+
}
|
|
2079
|
+
if (flags.path === true && selectedRetrievalOptions.length > 0) {
|
|
2080
|
+
deps.io.err(`--path cannot be combined with record options: ${selectedRetrievalOptions.join(', ')}.`);
|
|
2081
|
+
return EXIT.usage;
|
|
2082
|
+
}
|
|
2083
|
+
if (flags.clear === true && selectedRetrievalOptions.length > 0) {
|
|
2084
|
+
deps.io.err(`--clear cannot be combined with record options: ${selectedRetrievalOptions.join(', ')}.`);
|
|
2085
|
+
return EXIT.usage;
|
|
2086
|
+
}
|
|
2087
|
+
if (flags.project !== undefined && flags.allProjects === true) {
|
|
2088
|
+
deps.io.err('Pass --project or --all-projects, not both.');
|
|
2089
|
+
return EXIT.usage;
|
|
2090
|
+
}
|
|
2091
|
+
if (flags.limit !== undefined && flags.all === true) {
|
|
2092
|
+
deps.io.err('Pass --limit or --all, not both.');
|
|
2093
|
+
return EXIT.usage;
|
|
2094
|
+
}
|
|
2095
|
+
if (flags.limit !== undefined && (!Number.isInteger(flags.limit) || flags.limit <= 0)) {
|
|
2096
|
+
deps.io.err('--limit must be a positive integer.');
|
|
2097
|
+
return EXIT.usage;
|
|
2098
|
+
}
|
|
2099
|
+
if (flags.limit !== undefined && flags.limit > MAX_LOG_LIMIT) {
|
|
2100
|
+
deps.io.err(`--limit cannot exceed ${MAX_LOG_LIMIT}; narrow the result with --since or filters.`);
|
|
2101
|
+
return EXIT.usage;
|
|
2102
|
+
}
|
|
2103
|
+
const config = loadLoggedConfig(deps, { cwd: deps.cwd, env: deps.env });
|
|
2104
|
+
const now = (deps.now ?? Date.now)();
|
|
2105
|
+
if (flags.path === true) {
|
|
2106
|
+
const usage = logsDiskUsage(deps.env);
|
|
2107
|
+
const archives = archiveLogPaths(deps.env);
|
|
2108
|
+
if (flags.json === true) {
|
|
2109
|
+
deps.io.out(JSON.stringify({
|
|
2110
|
+
active: activeLogPath(deps.env),
|
|
2111
|
+
archives,
|
|
2112
|
+
files: usage.files,
|
|
2113
|
+
bytes: usage.bytes,
|
|
2114
|
+
level: config.log_level.value,
|
|
2115
|
+
max_bytes: config.log_max_bytes.value,
|
|
2116
|
+
max_files: config.log_max_files.value,
|
|
2117
|
+
local_only: true,
|
|
2118
|
+
}, null, 2));
|
|
2119
|
+
return EXIT.ok;
|
|
2120
|
+
}
|
|
2121
|
+
deps.io.out(activeLogPath(deps.env));
|
|
2122
|
+
for (const archive of archives)
|
|
2123
|
+
deps.io.out(archive);
|
|
2124
|
+
deps.io.err(`${usage.files} file(s), ${Math.round(usage.bytes / 1024)} KB, level ${config.log_level.value}. These stay on this machine.`);
|
|
2125
|
+
return EXIT.ok;
|
|
2126
|
+
}
|
|
2127
|
+
if (flags.clear === true) {
|
|
2128
|
+
let removed = 0;
|
|
2129
|
+
for (const file of [activeLogPath(deps.env), ...archiveLogPaths(deps.env)]) {
|
|
2130
|
+
try {
|
|
2131
|
+
unlinkSync(file);
|
|
2132
|
+
removed += 1;
|
|
2133
|
+
}
|
|
2134
|
+
catch {
|
|
2135
|
+
// Absent, or not ours to remove; either way there is nothing to report.
|
|
2136
|
+
}
|
|
2137
|
+
}
|
|
2138
|
+
deps.io.out(flags.json === true
|
|
2139
|
+
? JSON.stringify({ cleared_files: removed })
|
|
2140
|
+
: `Cleared ${removed} log file${removed === 1 ? '' : 's'}.`);
|
|
2141
|
+
return EXIT.ok;
|
|
2142
|
+
}
|
|
2143
|
+
const query = {
|
|
2144
|
+
limit: flags.limit ?? (flags.all === true ? MAX_LOG_LIMIT : DEFAULT_LOG_LIMIT),
|
|
2145
|
+
};
|
|
2146
|
+
if (flags.since !== undefined) {
|
|
2147
|
+
const since = parseSince(flags.since, now);
|
|
2148
|
+
if (since === null) {
|
|
2149
|
+
deps.io.err(`Could not read "${flags.since}" as a time. Use 10m, 2h, 1d, or an ISO 8601 instant.`);
|
|
2150
|
+
return EXIT.usage;
|
|
2151
|
+
}
|
|
2152
|
+
query.since = since;
|
|
2153
|
+
}
|
|
2154
|
+
if (flags.level !== undefined) {
|
|
2155
|
+
if (!LOG_LEVELS.includes(flags.level)) {
|
|
2156
|
+
deps.io.err(`--level takes one of: ${LOG_LEVELS.join(', ')} — not "${flags.level}".`);
|
|
2157
|
+
return EXIT.usage;
|
|
2158
|
+
}
|
|
2159
|
+
query.level = flags.level;
|
|
2160
|
+
}
|
|
2161
|
+
if (flags.event !== undefined && flags.event.length > 0) {
|
|
2162
|
+
const unknown = flags.event.filter((name) => !LOG_EVENTS.includes(name));
|
|
2163
|
+
if (unknown.length > 0) {
|
|
2164
|
+
deps.io.err(`Unknown event ${unknown.join(', ')}. Valid: ${LOG_EVENTS.join(', ')}`);
|
|
2165
|
+
return EXIT.usage;
|
|
2166
|
+
}
|
|
2167
|
+
query.event = flags.event;
|
|
2168
|
+
}
|
|
2169
|
+
if (flags.run !== undefined)
|
|
2170
|
+
query.run = flags.run;
|
|
2171
|
+
if (flags.session !== undefined)
|
|
2172
|
+
query.session = flags.session;
|
|
2173
|
+
if (flags.request !== undefined)
|
|
2174
|
+
query.request = flags.request;
|
|
2175
|
+
if (flags.grep !== undefined)
|
|
2176
|
+
query.contains = flags.grep;
|
|
2177
|
+
// Scoped to this project by default. A machine commonly runs several agents
|
|
2178
|
+
// in several worktrees at once, and the answer to "what just happened" is
|
|
2179
|
+
// useless if three other projects are interleaved through it.
|
|
2180
|
+
const scope = flags.project ?? (flags.allProjects === true ? undefined : (config.project.value ?? undefined));
|
|
2181
|
+
if (scope !== undefined)
|
|
2182
|
+
query.project = scope;
|
|
2183
|
+
const result = readLogRecords(deps.env, query);
|
|
2184
|
+
if (flags.json === true) {
|
|
2185
|
+
for (const record of result.records)
|
|
2186
|
+
deps.io.out(JSON.stringify(record));
|
|
2187
|
+
}
|
|
2188
|
+
else {
|
|
2189
|
+
for (const record of result.records)
|
|
2190
|
+
deps.io.out(renderRecord(record));
|
|
2191
|
+
}
|
|
2192
|
+
if (result.records.length === 0) {
|
|
2193
|
+
if (config.log_level.value === 'off') {
|
|
2194
|
+
deps.io.err('Nothing is being recorded: log_level is off.');
|
|
2195
|
+
deps.io.err('Turn it on with `notifai config set log_level info --yes`.');
|
|
2196
|
+
}
|
|
2197
|
+
else if (logsDiskUsage(deps.env).files === 0) {
|
|
2198
|
+
deps.io.err('No log yet. It is written the next time a notifai command or harness hook runs.');
|
|
2199
|
+
}
|
|
2200
|
+
else {
|
|
2201
|
+
deps.io.err('No records matched. Widen it with --all-projects, --since 1d, or --all.');
|
|
2202
|
+
}
|
|
2203
|
+
return EXIT.ok;
|
|
2204
|
+
}
|
|
2205
|
+
// Everything explanatory goes to stderr so `--json` leaves stdout as clean
|
|
2206
|
+
// JSONL for whatever is parsing it.
|
|
2207
|
+
const notes = [];
|
|
2208
|
+
if (scope !== undefined)
|
|
2209
|
+
notes.push(`project ${scope} (--all-projects for every project)`);
|
|
2210
|
+
if (result.more) {
|
|
2211
|
+
notes.push(query.limit < MAX_LOG_LIMIT
|
|
2212
|
+
? `more history behind this (-n ${Math.min(query.limit * 4, MAX_LOG_LIMIT)} or --all)`
|
|
2213
|
+
: `more history exists beyond the ${MAX_LOG_LIMIT}-record safety cap; narrow with --since or filters`);
|
|
2214
|
+
}
|
|
2215
|
+
if (config.log_level.value !== 'debug')
|
|
2216
|
+
notes.push('`log_level = debug` records request detail');
|
|
2217
|
+
if (notes.length > 0)
|
|
2218
|
+
deps.io.err(`— ${result.records.length} records; ${notes.join('; ')}`);
|
|
1477
2219
|
return EXIT.ok;
|
|
1478
2220
|
}
|
|
1479
2221
|
// ---------------------------------------------------------------------------
|
|
@@ -1484,7 +2226,7 @@ export async function configSetCommand(deps, key, rawValue, flags) {
|
|
|
1484
2226
|
* 1.5.x, `owner/repo@name` selects a skill; a Git ref belongs after `#`.
|
|
1485
2227
|
* Keep this immutable and public because the command is printed to users.
|
|
1486
2228
|
*/
|
|
1487
|
-
export const SKILLS_SOURCE = 'Raidiant-io/notifai#v0.
|
|
2229
|
+
export const SKILLS_SOURCE = 'Raidiant-io/notifai#v0.4.0';
|
|
1488
2230
|
function skillSourceParts() {
|
|
1489
2231
|
const match = /^([^#]+)#(.+)$/.exec(SKILLS_SOURCE);
|
|
1490
2232
|
return match === null ? null : { source: match[1], ref: match[2] };
|
|
@@ -1709,7 +2451,7 @@ async function waitForReadyDevice(deps, state) {
|
|
|
1709
2451
|
const remedy = state.remedy;
|
|
1710
2452
|
if (deps.io.interactive !== true || remedy?.by !== 'user-elsewhere')
|
|
1711
2453
|
return 'pending';
|
|
1712
|
-
const config =
|
|
2454
|
+
const config = loadLoggedConfig(deps, { cwd: deps.cwd, env: deps.env });
|
|
1713
2455
|
const authed = authedClient(deps, config);
|
|
1714
2456
|
if (!authed) {
|
|
1715
2457
|
deps.io.err('Could not start the companion-device wait: this machine is not signed in.');
|
|
@@ -1827,7 +2569,7 @@ async function submitSetupProof(deps, client, config, device) {
|
|
|
1827
2569
|
* the push, not that a companion process received it.
|
|
1828
2570
|
*/
|
|
1829
2571
|
async function runSetupProof(deps) {
|
|
1830
|
-
const config =
|
|
2572
|
+
const config = loadLoggedConfig(deps, { cwd: deps.cwd, env: deps.env });
|
|
1831
2573
|
const authed = authedClient(deps, config);
|
|
1832
2574
|
if (!authed)
|
|
1833
2575
|
return 'failed';
|
|
@@ -2160,7 +2902,7 @@ async function contractCheck(client) {
|
|
|
2160
2902
|
* looking like a broken install.
|
|
2161
2903
|
*/
|
|
2162
2904
|
export async function assessReadiness(deps, options = {}) {
|
|
2163
|
-
const config =
|
|
2905
|
+
const config = loadLoggedConfig(deps, { cwd: deps.cwd, env: deps.env });
|
|
2164
2906
|
const states = [];
|
|
2165
2907
|
let accountClient = null;
|
|
2166
2908
|
let accountDevices = null;
|
|
@@ -2449,8 +3191,13 @@ export async function doctorCommand(deps, flags) {
|
|
|
2449
3191
|
// costs nothing.
|
|
2450
3192
|
if (deps.io.interactive === true && deps.io.check) {
|
|
2451
3193
|
await deps.io.intro?.('Notifai doctor');
|
|
2452
|
-
for (const s of readiness.states)
|
|
2453
|
-
|
|
3194
|
+
for (const s of readiness.states) {
|
|
3195
|
+
// Four states, not two. Rendering `optional-gap` and `unknown` as
|
|
3196
|
+
// successes made a green report that had never checked half of what it
|
|
3197
|
+
// listed, and put a tick beside things the reader had deliberately
|
|
3198
|
+
// declined.
|
|
3199
|
+
await deps.io.check(s.status !== 'gap', line(s), doctorTone(s.status));
|
|
3200
|
+
}
|
|
2454
3201
|
await deps.io.outro?.(ok ? 'Everything looks good' : `Start with: ${remedyLine(blocker)}`);
|
|
2455
3202
|
}
|
|
2456
3203
|
else {
|
|
@@ -2469,6 +3216,19 @@ export async function doctorCommand(deps, flags) {
|
|
|
2469
3216
|
}
|
|
2470
3217
|
return ok ? EXIT.ok : EXIT.failed;
|
|
2471
3218
|
}
|
|
3219
|
+
/** Readiness status as a report tone. */
|
|
3220
|
+
function doctorTone(status) {
|
|
3221
|
+
switch (status) {
|
|
3222
|
+
case 'ready':
|
|
3223
|
+
return 'ok';
|
|
3224
|
+
case 'gap':
|
|
3225
|
+
return 'bad';
|
|
3226
|
+
case 'optional-gap':
|
|
3227
|
+
return 'warn';
|
|
3228
|
+
case 'unknown':
|
|
3229
|
+
return 'pending';
|
|
3230
|
+
}
|
|
3231
|
+
}
|
|
2472
3232
|
/** One line telling the reader what to actually do about a state. */
|
|
2473
3233
|
function remedyLine(state) {
|
|
2474
3234
|
const remedy = state.remedy;
|
|
@@ -2498,23 +3258,21 @@ function remedyLine(state) {
|
|
|
2498
3258
|
*
|
|
2499
3259
|
* Not everything failed is in the way. A pointer that has never been
|
|
2500
3260
|
* published is the normal condition of an install thirty seconds old — the
|
|
2501
|
-
* next prompt fixes it and no command can
|
|
2502
|
-
*
|
|
2503
|
-
*
|
|
2504
|
-
* finish for an OpenCode user, or could only finish after a session had
|
|
2505
|
-
* already run — so they report as things worth knowing rather than things to
|
|
3261
|
+
* next prompt fixes it and no command can. Treating that as blocking would
|
|
3262
|
+
* mean `init` could only finish after a session had already run — so it
|
|
3263
|
+
* reports as something worth knowing rather than something to
|
|
2506
3264
|
* fix, and `init` walks on to the states it can actually close, delivery
|
|
2507
3265
|
* proof included. Which failures are informational, and what the true remedy
|
|
2508
3266
|
* is, is each check's own call (`HookCheck`).
|
|
2509
3267
|
*/
|
|
2510
3268
|
function hookStates(deps) {
|
|
2511
|
-
const installations = findInstallations(deps.cwd, deps.env);
|
|
3269
|
+
const installations = findInstallations(deps.cwd, deps.env, deps.hookAdapterHome);
|
|
2512
3270
|
const active = activeHarnessSession(deps.env);
|
|
2513
|
-
const config =
|
|
3271
|
+
const config = loadLoggedConfig(deps, { cwd: deps.cwd, env: deps.env });
|
|
2514
3272
|
const settings = {
|
|
2515
3273
|
id: 'question-routing-settings',
|
|
2516
3274
|
title: 'Question routing settings',
|
|
2517
|
-
status: 'ready',
|
|
3275
|
+
status: config.ask_notifications.value ? 'ready' : 'gap',
|
|
2518
3276
|
detail: [
|
|
2519
3277
|
`ask_notifications=${config.ask_notifications.value} (${config.ask_notifications.source})`,
|
|
2520
3278
|
`require_idle=${config.require_idle.value} (${config.require_idle.source})`,
|
|
@@ -2522,6 +3280,15 @@ function hookStates(deps) {
|
|
|
2522
3280
|
`ask_grace_seconds=${config.ask_grace_seconds.value} (${config.ask_grace_seconds.source})`,
|
|
2523
3281
|
`hook_reply_timeout_seconds=${config.hook_reply_timeout_seconds.value} (${config.hook_reply_timeout_seconds.source})`,
|
|
2524
3282
|
].join(', '),
|
|
3283
|
+
...(config.ask_notifications.value
|
|
3284
|
+
? {}
|
|
3285
|
+
: {
|
|
3286
|
+
remedy: {
|
|
3287
|
+
by: 'cli',
|
|
3288
|
+
summary: 'enable asynchronous question routing',
|
|
3289
|
+
command: 'notifai config set ask_notifications true',
|
|
3290
|
+
},
|
|
3291
|
+
}),
|
|
2525
3292
|
};
|
|
2526
3293
|
if (installations.length === 0) {
|
|
2527
3294
|
return [
|
|
@@ -2544,7 +3311,7 @@ function hookStates(deps) {
|
|
|
2544
3311
|
];
|
|
2545
3312
|
}
|
|
2546
3313
|
/** Real but not in the way; see the note above. */
|
|
2547
|
-
const informational = new Set(
|
|
3314
|
+
const informational = new Set();
|
|
2548
3315
|
return [
|
|
2549
3316
|
...hookChecks(deps).map((check) => ({
|
|
2550
3317
|
id: check.name.replace(/[ ()]+/g, '-').replace(/-$/, ''),
|
|
@@ -2577,7 +3344,7 @@ function hookStates(deps) {
|
|
|
2577
3344
|
}
|
|
2578
3345
|
function hookChecks(deps) {
|
|
2579
3346
|
const checks = [];
|
|
2580
|
-
const installations = findInstallations(deps.cwd, deps.env);
|
|
3347
|
+
const installations = findInstallations(deps.cwd, deps.env, deps.hookAdapterHome);
|
|
2581
3348
|
// Not having hooks is a setup someone chose, not a fault: `send` works
|
|
2582
3349
|
// without them. A setup that cannot work is what deserves to go red.
|
|
2583
3350
|
if (installations.length === 0) {
|
|
@@ -2616,7 +3383,9 @@ function hookChecks(deps) {
|
|
|
2616
3383
|
}),
|
|
2617
3384
|
});
|
|
2618
3385
|
if (activeInstallations.length > 0) {
|
|
2619
|
-
const pointer =
|
|
3386
|
+
const pointer = active.sessionId === undefined
|
|
3387
|
+
? null
|
|
3388
|
+
: readMatchingProjectSessionPointer(deps.cwd, deps.env, (deps.now ?? Date.now)(), active.sessionId, active.harness);
|
|
2620
3389
|
if (pointer === null) {
|
|
2621
3390
|
// The normal condition of hooks installed moments ago: the pointer
|
|
2622
3391
|
// appears when the harness next fires a hook, and no command can
|
|
@@ -2634,22 +3403,10 @@ function hookChecks(deps) {
|
|
|
2634
3403
|
});
|
|
2635
3404
|
}
|
|
2636
3405
|
else {
|
|
2637
|
-
const matches = pointer.harness === active.harness &&
|
|
2638
|
-
(active.sessionId === undefined || pointer.sessionId === active.sessionId);
|
|
2639
3406
|
checks.push({
|
|
2640
3407
|
name: 'hooks (active session)',
|
|
2641
|
-
ok:
|
|
2642
|
-
detail:
|
|
2643
|
-
? `the project pointer belongs to the active ${active.label} session`
|
|
2644
|
-
: `the project pointer belongs to another ${active.label} session or harness; refusing cross-session routing`,
|
|
2645
|
-
...(matches
|
|
2646
|
-
? {}
|
|
2647
|
-
: {
|
|
2648
|
-
remedy: {
|
|
2649
|
-
summary: `send one ${active.label} prompt in this session so the pointer belongs to it`,
|
|
2650
|
-
command: 'notifai doctor',
|
|
2651
|
-
},
|
|
2652
|
-
}),
|
|
3408
|
+
ok: true,
|
|
3409
|
+
detail: `the concurrent project index contains the active ${active.label} session`,
|
|
2653
3410
|
});
|
|
2654
3411
|
}
|
|
2655
3412
|
}
|
|
@@ -2670,16 +3427,37 @@ function hookChecks(deps) {
|
|
|
2670
3427
|
: `${stale.join('; ')} — rerun \`notifai hooks install\` to drop ${stale.length === 1 ? 'it' : 'them'}`,
|
|
2671
3428
|
});
|
|
2672
3429
|
const adapterProblems = installations.flatMap((installation) => (installation.problems ?? []).map((problem) => `${installation.file}: ${problem}`));
|
|
3430
|
+
const sharedAdapter = inspectHookAdapter(deps.hookAdapterHome);
|
|
3431
|
+
adapterProblems.push(...sharedAdapter.problems);
|
|
2673
3432
|
if (adapterProblems.length > 0) {
|
|
2674
3433
|
checks.push({
|
|
2675
3434
|
name: 'hooks (adapter)',
|
|
2676
3435
|
ok: false,
|
|
3436
|
+
// A machine-global install for a harness that is not active in this
|
|
3437
|
+
// project is useful diagnosis, but it must not block unrelated init.
|
|
3438
|
+
informational: active === null && installations.every((installation) => installation.global),
|
|
2677
3439
|
detail: adapterProblems.join('; '),
|
|
2678
3440
|
});
|
|
2679
3441
|
}
|
|
2680
|
-
const
|
|
2681
|
-
|
|
3442
|
+
const trustProblems = codexTrustProblems(installations, deps.env);
|
|
3443
|
+
checks.push({
|
|
3444
|
+
name: 'hooks (trust)',
|
|
3445
|
+
ok: trustProblems.length === 0,
|
|
3446
|
+
detail: trustProblems.length === 0
|
|
3447
|
+
? 'best-effort check matches current persisted Codex approvals; Notifai never writes the trust store, and `/hooks` is authoritative'
|
|
3448
|
+
: `best-effort check only; Notifai never writes the trust store and \`/hooks\` is authoritative. ${trustProblems.join('; ')}`,
|
|
3449
|
+
...(trustProblems.length === 0
|
|
3450
|
+
? {}
|
|
3451
|
+
: {
|
|
3452
|
+
remedy: {
|
|
3453
|
+
summary: 'open `/hooks` in Codex and approve the changed Notifai handlers',
|
|
3454
|
+
command: '/hooks',
|
|
3455
|
+
},
|
|
3456
|
+
}),
|
|
3457
|
+
});
|
|
3458
|
+
const requiredTimeout = blockingHookTimeoutSeconds();
|
|
2682
3459
|
const shortTimeouts = installations.flatMap((installation) => installation.handlers
|
|
3460
|
+
.filter(() => installation.harness !== 'codex')
|
|
2683
3461
|
.filter((handler) => handlerEvent(handler.command) === 'stop')
|
|
2684
3462
|
.filter((handler) => handler.timeout === undefined || handler.timeout < requiredTimeout)
|
|
2685
3463
|
.map((handler) => `${installation.harness} declares ${handler.timeout ?? 'no'}s, needs ${requiredTimeout}s — run \`notifai hooks install --harness ${installation.harness}${installation.global ? ' --global' : ''}\``));
|
|
@@ -2687,60 +3465,85 @@ function hookChecks(deps) {
|
|
|
2687
3465
|
name: 'hooks (timeout)',
|
|
2688
3466
|
ok: shortTimeouts.length === 0,
|
|
2689
3467
|
detail: shortTimeouts.length === 0
|
|
2690
|
-
? `
|
|
3468
|
+
? `declared Stop timeouts match the fixed ${requiredTimeout}s process budget where required; Codex uses host defaults`
|
|
2691
3469
|
: shortTimeouts.join('; '),
|
|
2692
3470
|
});
|
|
2693
|
-
//
|
|
2694
|
-
//
|
|
2695
|
-
//
|
|
2696
|
-
//
|
|
2697
|
-
// session, so having Claude Code and OpenCode both set up is the ordinary
|
|
2698
|
-
// case and not a duplicate — comparing them turned a healthy machine red.
|
|
3471
|
+
// Project and global definitions for one harness both fire. Stable adapter
|
|
3472
|
+
// identity deliberately makes their command bytes equal, so comparing
|
|
3473
|
+
// command targets would now hide this duplicate rather than diagnose it.
|
|
3474
|
+
// Different harnesses remain independent: only the active one runs.
|
|
2699
3475
|
const duplicated = [...new Set(installations.map((i) => i.harness))]
|
|
2700
3476
|
.map((harness) => ({
|
|
2701
3477
|
harness,
|
|
2702
|
-
|
|
2703
|
-
.filter((i) => i.harness === harness)
|
|
2704
|
-
.flatMap((i) => i.handlers.map((h) => h.command.split(' hook ')[0] ?? ''))),
|
|
3478
|
+
installations: installations.filter((i) => i.harness === harness),
|
|
2705
3479
|
}))
|
|
2706
|
-
.filter((entry) => entry.
|
|
3480
|
+
.filter((entry) => entry.installations.length > 1);
|
|
2707
3481
|
if (duplicated.length > 0) {
|
|
2708
3482
|
checks.push({
|
|
2709
3483
|
name: 'hooks (duplicates)',
|
|
2710
3484
|
ok: false,
|
|
2711
3485
|
detail: duplicated
|
|
2712
|
-
.map((entry) => `${entry.harness}: ${entry.
|
|
3486
|
+
.map((entry) => `${entry.harness}: ${entry.installations.length} hook definitions are active, so each event will fire all of them. Keep either project or global routing and uninstall the other: ${entry.installations.map((installation) => installation.file).join(', ')}`)
|
|
2713
3487
|
.join('; '),
|
|
2714
3488
|
});
|
|
2715
3489
|
}
|
|
2716
|
-
|
|
2717
|
-
|
|
3490
|
+
if (active !== null && activeInstallations.length > 0) {
|
|
3491
|
+
const admissionProblems = activeQuestionRouteProblems(deps, active, installations);
|
|
3492
|
+
checks.push({
|
|
3493
|
+
name: 'hooks (question admission)',
|
|
3494
|
+
ok: admissionProblems.length === 0,
|
|
3495
|
+
detail: admissionProblems.length === 0
|
|
3496
|
+
? `the active ${active.label} route is exact, current, singular, trusted where applicable, and bounded by a live owner`
|
|
3497
|
+
: admissionProblems.join('; '),
|
|
3498
|
+
});
|
|
3499
|
+
}
|
|
3500
|
+
const firedPointer = active?.sessionId === undefined
|
|
3501
|
+
? readProjectSessionPointer(deps.cwd, deps.env, (deps.now ?? Date.now)())
|
|
3502
|
+
: readMatchingProjectSessionPointer(deps.cwd, deps.env, (deps.now ?? Date.now)(), active.sessionId, active.harness);
|
|
3503
|
+
const firedState = firedPointer === null ? null : readSessionState(firedPointer.sessionId, deps.env);
|
|
3504
|
+
const promptFired = firedState?.last_prompt_at !== undefined;
|
|
3505
|
+
const stopFired = firedState?.last_stop_at !== undefined;
|
|
3506
|
+
const fired = firedPointer !== null && promptFired && stopFired;
|
|
2718
3507
|
const activationInstallations = active !== null && activeInstallations.length > 0 ? activeInstallations : installations;
|
|
2719
3508
|
checks.push({
|
|
2720
3509
|
name: 'hooks (fired)',
|
|
2721
3510
|
ok: fired,
|
|
2722
|
-
//
|
|
2723
|
-
//
|
|
2724
|
-
informational:
|
|
3511
|
+
// A wholly fresh install is informational. Once UserPromptSubmit has fired,
|
|
3512
|
+
// a missing Stop is a broken route, not missing historical evidence.
|
|
3513
|
+
informational: firedPointer === null,
|
|
2725
3514
|
detail: fired
|
|
2726
|
-
? 'a session in this directory has run
|
|
2727
|
-
:
|
|
3515
|
+
? 'a session in this directory has run UserPromptSubmit and Stop'
|
|
3516
|
+
: firedPointer === null
|
|
3517
|
+
? `no session pointer from the last 24 hours — ${hookActivationAdvice(activationInstallations)}`
|
|
3518
|
+
: `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`,
|
|
2728
3519
|
...(fired
|
|
2729
3520
|
? {}
|
|
2730
3521
|
: {
|
|
2731
3522
|
remedy: {
|
|
2732
|
-
summary:
|
|
3523
|
+
summary: firedPointer === null
|
|
3524
|
+
? 'send one prompt in a session here, then re-check'
|
|
3525
|
+
: 'end one harmless turn, send a new prompt, then re-check',
|
|
2733
3526
|
command: 'notifai doctor',
|
|
2734
3527
|
},
|
|
2735
3528
|
}),
|
|
2736
3529
|
});
|
|
2737
|
-
|
|
2738
|
-
|
|
2739
|
-
|
|
2740
|
-
|
|
2741
|
-
|
|
2742
|
-
|
|
2743
|
-
|
|
3530
|
+
const continuationInstallations = active !== null ? activeInstallations : installations;
|
|
3531
|
+
const continuationHarnesses = [
|
|
3532
|
+
...new Set(continuationInstallations.map((installation) => installation.harness)),
|
|
3533
|
+
];
|
|
3534
|
+
checks.push({
|
|
3535
|
+
name: 'hooks (answer continuation)',
|
|
3536
|
+
ok: continuationHarnesses.length > 0 &&
|
|
3537
|
+
continuationHarnesses.every((harness) => HARNESS_CAPABILITIES[harness].stopContinuation !== 'unsupported'),
|
|
3538
|
+
informational: active === null,
|
|
3539
|
+
detail: continuationHarnesses.length === 0
|
|
3540
|
+
? active === null
|
|
3541
|
+
? 'no installed harness route to assess'
|
|
3542
|
+
: `the active ${active.label} session has no matching continuation adapter`
|
|
3543
|
+
: continuationHarnesses
|
|
3544
|
+
.map((harness) => `${harness}: ${HARNESS_CAPABILITIES[harness].deliveryContract}`)
|
|
3545
|
+
.join('; '),
|
|
3546
|
+
});
|
|
2744
3547
|
const stray = codexStrayWorktreeCheck(deps);
|
|
2745
3548
|
if (stray !== null)
|
|
2746
3549
|
checks.push(stray);
|
|
@@ -2853,14 +3656,22 @@ export function realIo(env = process.env) {
|
|
|
2853
3656
|
error: (next) => progress.error(next),
|
|
2854
3657
|
};
|
|
2855
3658
|
},
|
|
2856
|
-
check: async (ok, message) => {
|
|
3659
|
+
check: async (ok, message, tone) => {
|
|
2857
3660
|
if (!interactive())
|
|
2858
3661
|
return;
|
|
2859
3662
|
const { log } = await clack();
|
|
2860
|
-
|
|
2861
|
-
|
|
2862
|
-
|
|
2863
|
-
|
|
3663
|
+
switch (tone ?? (ok ? 'ok' : 'bad')) {
|
|
3664
|
+
case 'ok':
|
|
3665
|
+
return log.success(message);
|
|
3666
|
+
case 'warn':
|
|
3667
|
+
return log.warn(message);
|
|
3668
|
+
case 'pending':
|
|
3669
|
+
// Never checked. `log.info` reads as neutral, which is the honest
|
|
3670
|
+
// rendering for a state no evidence was gathered about.
|
|
3671
|
+
return log.info(message);
|
|
3672
|
+
case 'bad':
|
|
3673
|
+
return log.error(message);
|
|
3674
|
+
}
|
|
2864
3675
|
},
|
|
2865
3676
|
openUrl: (url) => {
|
|
2866
3677
|
try {
|