@raidiant/notifai 0.3.0 → 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 +56 -7
- package/dist/commands.js +980 -318
- package/dist/commands.js.map +1 -1
- package/dist/config-schema.d.ts +1 -1
- package/dist/config-schema.js +46 -8
- package/dist/config-schema.js.map +1 -1
- 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 +1 -1
- package/dist/interactive.js +3 -3
- package/dist/interactive.js.map +1 -1
- package/dist/logging.d.ts +142 -0
- package/dist/logging.js +533 -0
- package/dist/logging.js.map +1 -0
- package/dist/main.js +97 -36
- 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/config-view.js +3 -0
- package/dist/ui/config-view.js.map +1 -1
- package/dist/ui/theme.d.ts +1 -1
- package/package.json +3 -2
- 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 +891 -43
- package/src/commands.ts +1133 -332
- package/src/config-schema.ts +51 -9
- 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 +3 -3
- 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 +113 -32
- 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/config-view.test.ts +4 -2
- package/src/ui/config-view.ts +3 -0
package/src/commands.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
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'
|
|
@@ -24,14 +24,17 @@ import {
|
|
|
24
24
|
ApiCallError,
|
|
25
25
|
NetworkError,
|
|
26
26
|
createClient,
|
|
27
|
+
isRetryableReplyPollError,
|
|
27
28
|
type ApiClient,
|
|
28
29
|
type ClientOptions,
|
|
29
30
|
} from './client.js'
|
|
30
31
|
import {
|
|
31
32
|
BOOLEAN_CONFIG_KEYS,
|
|
32
33
|
CONFIG_KEYS,
|
|
34
|
+
LOG_LEVELS,
|
|
33
35
|
NUMERIC_CONFIG_KEYS,
|
|
34
36
|
configBounds,
|
|
37
|
+
configDefaultValue,
|
|
35
38
|
findProjectConfigPath,
|
|
36
39
|
findProjectLocalConfigPath,
|
|
37
40
|
globalConfigPath,
|
|
@@ -40,8 +43,11 @@ import {
|
|
|
40
43
|
type CliConfig,
|
|
41
44
|
type ConfigKey,
|
|
42
45
|
type FlagOverrides,
|
|
46
|
+
type LogLevel,
|
|
43
47
|
} from './config.js'
|
|
44
48
|
import { acceptedValues, configInfo } from './config-schema.js'
|
|
49
|
+
import { atomicWriteFileSync } from './atomic-file.js'
|
|
50
|
+
import { withTargetFileLock } from './file-lock.js'
|
|
45
51
|
import {
|
|
46
52
|
renderConfigExplain,
|
|
47
53
|
renderConfigList,
|
|
@@ -62,15 +68,28 @@ import {
|
|
|
62
68
|
handleUserPromptSubmit,
|
|
63
69
|
parseHookInput,
|
|
64
70
|
pruneAbandonedSessions,
|
|
71
|
+
readMatchingProjectSessionPointer,
|
|
65
72
|
readProjectSession,
|
|
66
73
|
readProjectSessionPointer,
|
|
67
74
|
readSessionState,
|
|
68
75
|
registerQuestion,
|
|
69
76
|
type HookContext,
|
|
70
|
-
type HookEnvelope,
|
|
71
77
|
type HookHarness,
|
|
72
78
|
} from './hooks.js'
|
|
73
79
|
import { readIdleSeconds } from './idle.js'
|
|
80
|
+
import {
|
|
81
|
+
LOG_EVENTS,
|
|
82
|
+
activeLogPath,
|
|
83
|
+
archiveLogPaths,
|
|
84
|
+
logConfigResolved,
|
|
85
|
+
logSettingsFrom,
|
|
86
|
+
logsDiskUsage,
|
|
87
|
+
nullLogger,
|
|
88
|
+
readLogRecords,
|
|
89
|
+
renderRecord,
|
|
90
|
+
type LogQuery,
|
|
91
|
+
type Logger,
|
|
92
|
+
} from './logging.js'
|
|
74
93
|
import {
|
|
75
94
|
HARNESSES,
|
|
76
95
|
applyPlan,
|
|
@@ -78,6 +97,7 @@ import {
|
|
|
78
97
|
buildCursorHookConfig,
|
|
79
98
|
buildHookConfig,
|
|
80
99
|
codexLayerDir,
|
|
100
|
+
codexTrustProblems,
|
|
81
101
|
codexProjectRoot,
|
|
82
102
|
detectHarness,
|
|
83
103
|
findInstallations,
|
|
@@ -92,6 +112,11 @@ import {
|
|
|
92
112
|
type Harness,
|
|
93
113
|
type Installation,
|
|
94
114
|
} from './install-hooks.js'
|
|
115
|
+
import { HARNESS_CAPABILITIES, HOOK_TIMING } from './harnesses.js'
|
|
116
|
+
import {
|
|
117
|
+
inspectHookAdapter,
|
|
118
|
+
installHookAdapter,
|
|
119
|
+
} from './hook-adapter.js'
|
|
95
120
|
import {
|
|
96
121
|
isOurOpencodePlugin,
|
|
97
122
|
opencodePluginSource,
|
|
@@ -146,6 +171,8 @@ export interface CommandDeps {
|
|
|
146
171
|
store: CredentialStore
|
|
147
172
|
env: NodeJS.ProcessEnv
|
|
148
173
|
cwd: string
|
|
174
|
+
/** Test seam; production fixes the hook adapter under os.homedir(). */
|
|
175
|
+
hookAdapterHome?: string
|
|
149
176
|
/** Test seam; production uses fetch against base_url. */
|
|
150
177
|
clientFactory?: (baseUrl: string, bearer: string | null, options?: ClientOptions) => ApiClient
|
|
151
178
|
/** Test seam for bounded polling without wall-clock sleeps. */
|
|
@@ -156,6 +183,28 @@ export interface CommandDeps {
|
|
|
156
183
|
idleSeconds?: () => number | null
|
|
157
184
|
/** Test seam and production adapter for the external native skills installer. */
|
|
158
185
|
nativeSkills?: NativeSkills
|
|
186
|
+
/**
|
|
187
|
+
* The local record of what this invocation did. Optional so a test fake need
|
|
188
|
+
* not carry one; `log(deps)` supplies a logger that records nothing.
|
|
189
|
+
*/
|
|
190
|
+
logger?: Logger
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/** The invocation's logger, or one that discards everything. */
|
|
194
|
+
export function log(deps: CommandDeps): Logger {
|
|
195
|
+
return deps.logger ?? nullLogger()
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
function loadLoggedConfig(
|
|
199
|
+
deps: CommandDeps,
|
|
200
|
+
options: Parameters<typeof loadConfig>[0],
|
|
201
|
+
): CliConfig {
|
|
202
|
+
const config = loadConfig(options)
|
|
203
|
+
const logger = log(deps)
|
|
204
|
+
logger.adopt(logSettingsFrom(config))
|
|
205
|
+
logger.bind({ project: config.project.value })
|
|
206
|
+
logConfigResolved(logger, config)
|
|
207
|
+
return config
|
|
159
208
|
}
|
|
160
209
|
|
|
161
210
|
export const EXIT = {
|
|
@@ -173,7 +222,7 @@ function makeClient(
|
|
|
173
222
|
bearer: string | null,
|
|
174
223
|
options?: ClientOptions,
|
|
175
224
|
): ApiClient {
|
|
176
|
-
return (deps.clientFactory ?? createClient)(baseUrl, bearer, options)
|
|
225
|
+
return (deps.clientFactory ?? createClient)(baseUrl, bearer, { ...options, logger: log(deps) })
|
|
177
226
|
}
|
|
178
227
|
|
|
179
228
|
function resolvedBaseUrl(config: CliConfig, credential: MachineCredential | null): string {
|
|
@@ -183,6 +232,10 @@ function resolvedBaseUrl(config: CliConfig, credential: MachineCredential | null
|
|
|
183
232
|
function authedClient(deps: CommandDeps, config: CliConfig): { client: ApiClient; baseUrl: string } | null {
|
|
184
233
|
const credential = deps.store.load()
|
|
185
234
|
if (!credential) {
|
|
235
|
+
// The commonest reason a command does nothing, and one that leaves no other
|
|
236
|
+
// trace: it returns before any request is made, so without this the log
|
|
237
|
+
// shows an exit code and no cause.
|
|
238
|
+
log(deps).error('cli.error', { kind: 'auth', message: 'not signed in', store: deps.store.describe() })
|
|
186
239
|
deps.io.err('Not signed in. Run `notifai login` first.')
|
|
187
240
|
return null
|
|
188
241
|
}
|
|
@@ -193,7 +246,22 @@ function authedClient(deps: CommandDeps, config: CliConfig): { client: ApiClient
|
|
|
193
246
|
}
|
|
194
247
|
}
|
|
195
248
|
|
|
196
|
-
function reportError(
|
|
249
|
+
function reportError(
|
|
250
|
+
deps: CommandDeps,
|
|
251
|
+
err: unknown,
|
|
252
|
+
context: Record<string, unknown> = {},
|
|
253
|
+
): number {
|
|
254
|
+
// Recorded before it is printed: stderr from an agent's shell call is often
|
|
255
|
+
// the one thing that does not survive into the next turn, and this is exactly
|
|
256
|
+
// the line someone comes back looking for.
|
|
257
|
+
log(deps).error('cli.error', {
|
|
258
|
+
...context,
|
|
259
|
+
...(err instanceof ApiCallError
|
|
260
|
+
? { kind: 'api', status: err.status, code: err.code, message: err.message, next_action: err.nextAction, details: err.details }
|
|
261
|
+
: err instanceof NetworkError
|
|
262
|
+
? { kind: 'network', message: err.message }
|
|
263
|
+
: { kind: 'unknown', message: String(err) }),
|
|
264
|
+
})
|
|
197
265
|
if (err instanceof ApiCallError) {
|
|
198
266
|
deps.io.err(`${err.code}: ${err.message}`)
|
|
199
267
|
if (err.nextAction) deps.io.err(`next: ${err.nextAction}`)
|
|
@@ -216,7 +284,7 @@ export async function loginCommand(
|
|
|
216
284
|
deps: CommandDeps,
|
|
217
285
|
flags: { name?: string; baseUrl?: string; open?: boolean },
|
|
218
286
|
): Promise<number> {
|
|
219
|
-
const config =
|
|
287
|
+
const config = loadLoggedConfig(deps, { cwd: deps.cwd, env: deps.env, flags: { base_url: flags.baseUrl } as FlagOverrides })
|
|
220
288
|
const baseUrl = config.base_url.value
|
|
221
289
|
const machineName = flags.name ?? os.hostname()
|
|
222
290
|
const secret = randomBytes(32).toString('base64url')
|
|
@@ -345,7 +413,7 @@ export async function accessStatusCommand(
|
|
|
345
413
|
deps: CommandDeps,
|
|
346
414
|
flags: { json?: boolean },
|
|
347
415
|
): Promise<number> {
|
|
348
|
-
const config =
|
|
416
|
+
const config = loadLoggedConfig(deps, { cwd: deps.cwd, env: deps.env })
|
|
349
417
|
const authed = authedClient(deps, config)
|
|
350
418
|
if (!authed) return EXIT.auth
|
|
351
419
|
try {
|
|
@@ -373,7 +441,7 @@ export async function accessStatusCommand(
|
|
|
373
441
|
// ---------------------------------------------------------------------------
|
|
374
442
|
|
|
375
443
|
export async function devicesCommand(deps: CommandDeps, flags: { json?: boolean }): Promise<number> {
|
|
376
|
-
const config =
|
|
444
|
+
const config = loadLoggedConfig(deps, { cwd: deps.cwd, env: deps.env })
|
|
377
445
|
const authed = authedClient(deps, config)
|
|
378
446
|
if (!authed) return EXIT.auth
|
|
379
447
|
try {
|
|
@@ -417,7 +485,7 @@ export async function devicesCommand(deps: CommandDeps, flags: { json?: boolean
|
|
|
417
485
|
*/
|
|
418
486
|
export async function deviceInventory(deps: CommandDeps): Promise<RoutableDevice[] | null> {
|
|
419
487
|
if (!deps.store.load()) return null
|
|
420
|
-
const config =
|
|
488
|
+
const config = loadLoggedConfig(deps, { cwd: deps.cwd, env: deps.env })
|
|
421
489
|
const authed = authedClient(deps, config)
|
|
422
490
|
if (!authed) return null
|
|
423
491
|
try {
|
|
@@ -436,7 +504,7 @@ export async function capabilitiesCommand(
|
|
|
436
504
|
deps: CommandDeps,
|
|
437
505
|
flags: { json?: boolean; platform?: Platform },
|
|
438
506
|
): Promise<number> {
|
|
439
|
-
const config =
|
|
507
|
+
const config = loadLoggedConfig(deps, { cwd: deps.cwd, env: deps.env })
|
|
440
508
|
const credential = deps.store.load()
|
|
441
509
|
const baseUrl = resolvedBaseUrl(config, credential)
|
|
442
510
|
const client = makeClient(deps, baseUrl, null)
|
|
@@ -519,7 +587,7 @@ export async function sendCommand(
|
|
|
519
587
|
deps.io.err(`--reply-window must be an integer from 60 to ${REPLY_MAX_WINDOW_SECONDS} seconds.`)
|
|
520
588
|
return EXIT.usage
|
|
521
589
|
}
|
|
522
|
-
const config =
|
|
590
|
+
const config = loadLoggedConfig(deps, {
|
|
523
591
|
cwd: deps.cwd,
|
|
524
592
|
env: deps.env,
|
|
525
593
|
flags: { base_url: flags.baseUrl, wait_seconds: flags.wait } as FlagOverrides,
|
|
@@ -560,38 +628,71 @@ export async function sendCommand(
|
|
|
560
628
|
}
|
|
561
629
|
const waitSeconds = flags.noWait ? 0 : config.wait_seconds.value
|
|
562
630
|
const idempotencyKey = flags.idempotencyKey ?? `cli-${randomBytes(12).toString('base64url')}`
|
|
631
|
+
let receipt: SubmissionReceipt
|
|
563
632
|
try {
|
|
564
|
-
|
|
633
|
+
receipt = await authed.client.submit(
|
|
565
634
|
{ idempotency_key: idempotencyKey, draft: build.draft },
|
|
566
635
|
waitSeconds,
|
|
567
636
|
)
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
637
|
+
} catch (err) {
|
|
638
|
+
return reportError(deps, err)
|
|
639
|
+
}
|
|
640
|
+
const receiptExit = receiptExitCode(receipt)
|
|
641
|
+
// The single most useful line in the log: it ties the local invocation to
|
|
642
|
+
// the server-side request id, which is what every later question about this
|
|
643
|
+
// notification ("did it arrive?", "which device?") is asked in terms of.
|
|
644
|
+
log(deps).info('send.submitted', {
|
|
645
|
+
request_id: receipt.request_id,
|
|
646
|
+
kind: flags.reply ? 'question' : (flags.kind ?? 'update'),
|
|
647
|
+
title: flags.title,
|
|
648
|
+
overall: receipt.overall,
|
|
649
|
+
replayed: receipt.replayed,
|
|
650
|
+
deliveries: receipt.deliveries.length,
|
|
651
|
+
wait_seconds: waitSeconds,
|
|
652
|
+
exit: receiptExit,
|
|
653
|
+
})
|
|
654
|
+
log(deps).info('send.outcome', {
|
|
655
|
+
request_id: receipt.request_id,
|
|
656
|
+
overall: receipt.overall,
|
|
657
|
+
// Per device, because "it said accepted but nothing arrived" is answered
|
|
658
|
+
// by which device reached which state and why the provider said so.
|
|
659
|
+
devices: receipt.deliveries.map((delivery) => ({
|
|
660
|
+
device: delivery.device_name,
|
|
661
|
+
state: delivery.state,
|
|
662
|
+
attempts: delivery.attempts,
|
|
663
|
+
provider_status: delivery.provider_status,
|
|
664
|
+
provider_reason: delivery.provider_reason,
|
|
665
|
+
})),
|
|
666
|
+
...(receipt.warnings.length > 0 ? { warnings: receipt.warnings } : {}),
|
|
667
|
+
})
|
|
668
|
+
if (!flags.json) deps.io.out(formatReceipt(receipt))
|
|
669
|
+
else if (flags.reply) deps.io.out(JSON.stringify({ type: 'receipt', receipt }))
|
|
571
670
|
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
}
|
|
586
|
-
return receiptExit
|
|
671
|
+
// A zero wait can no longer reach here: --reply guarantees a positive one.
|
|
672
|
+
if (!flags.reply || receiptExit !== EXIT.ok) {
|
|
673
|
+
if (flags.json) {
|
|
674
|
+
deps.io.out(
|
|
675
|
+
flags.reply
|
|
676
|
+
? JSON.stringify({
|
|
677
|
+
type: 'reply_result',
|
|
678
|
+
request_id: receipt.request_id,
|
|
679
|
+
replies: [],
|
|
680
|
+
degraded: false,
|
|
681
|
+
})
|
|
682
|
+
: JSON.stringify(receipt, null, 2),
|
|
683
|
+
)
|
|
587
684
|
}
|
|
685
|
+
return receiptExit
|
|
686
|
+
}
|
|
588
687
|
|
|
688
|
+
try {
|
|
589
689
|
const result = await waitForReply(authed.client, receipt.request_id, {
|
|
590
690
|
timeoutSeconds: replyTimeout,
|
|
591
691
|
afterSeq: 0,
|
|
592
692
|
now: deps.now,
|
|
593
693
|
sleep: deps.sleep,
|
|
594
694
|
})
|
|
695
|
+
recordReplies(deps, receipt.request_id, result.response.replies)
|
|
595
696
|
if (flags.json) {
|
|
596
697
|
deps.io.out(
|
|
597
698
|
JSON.stringify({
|
|
@@ -604,17 +705,48 @@ export async function sendCommand(
|
|
|
604
705
|
} else if (result.response.replies.length > 0) printReplies(deps, result.response.replies)
|
|
605
706
|
else printNoReply(deps, receipt.request_id, result.response.reply_expires_at)
|
|
606
707
|
if (result.degraded) {
|
|
607
|
-
deps.
|
|
708
|
+
log(deps).error('cli.error', {
|
|
709
|
+
kind: 'network',
|
|
710
|
+
operation: 'reply_wait',
|
|
711
|
+
request_id: receipt.request_id,
|
|
712
|
+
degraded: true,
|
|
713
|
+
message: 'the reply wait ended while the server was unreachable or faulting',
|
|
714
|
+
})
|
|
715
|
+
deps.io.err(degradedWaitWarning(receipt.request_id))
|
|
608
716
|
return EXIT.network
|
|
609
717
|
}
|
|
610
718
|
if (result.timedOut) {
|
|
611
719
|
deps.io.err(
|
|
612
720
|
`No reply yet. Retrieve it with \`notifai replies ${receipt.request_id}\` or retire the question with ` +
|
|
613
|
-
`\`notifai close ${receipt.request_id}
|
|
721
|
+
`\`notifai close ${receipt.request_id}\`. ` +
|
|
722
|
+
`This is a reply-wait timeout, not a Delivery or Companion Receipt failure — check with \`notifai status ${receipt.request_id}\`.`,
|
|
614
723
|
)
|
|
615
724
|
}
|
|
616
725
|
return result.timedOut ? EXIT.noReply : EXIT.ok
|
|
617
726
|
} catch (err) {
|
|
727
|
+
// Receipt already printed; a wait fault must not read as "send failed".
|
|
728
|
+
// Permanent poll errors (auth, closed window, not found) still surface, but
|
|
729
|
+
// always name the durable request and point at recovery.
|
|
730
|
+
if (err instanceof ApiCallError || err instanceof NetworkError) {
|
|
731
|
+
log(deps).error('cli.error', {
|
|
732
|
+
kind: err instanceof ApiCallError ? 'api' : 'network',
|
|
733
|
+
operation: 'reply_wait',
|
|
734
|
+
request_id: receipt.request_id,
|
|
735
|
+
...(err instanceof ApiCallError ? { status: err.status, code: err.code } : {}),
|
|
736
|
+
message: err.message,
|
|
737
|
+
})
|
|
738
|
+
deps.io.err(
|
|
739
|
+
`notifai: reply wait failed for ${receipt.request_id} (${err instanceof ApiCallError ? err.code : 'network'}: ${err.message}). ` +
|
|
740
|
+
`Delivery and Companion Receipt are independent — check with \`notifai status ${receipt.request_id}\` and retry with \`notifai replies ${receipt.request_id}\`.`,
|
|
741
|
+
)
|
|
742
|
+
if (err instanceof ApiCallError) {
|
|
743
|
+
if (err.code === 'auth_required' || err.code === 'machine_revoked') return EXIT.auth
|
|
744
|
+
return err.status >= 500 || err.status === 429 || err.status === 408
|
|
745
|
+
? EXIT.network
|
|
746
|
+
: EXIT.failed
|
|
747
|
+
}
|
|
748
|
+
return EXIT.network
|
|
749
|
+
}
|
|
618
750
|
return reportError(deps, err)
|
|
619
751
|
}
|
|
620
752
|
}
|
|
@@ -705,40 +837,49 @@ export async function repliesCommand(
|
|
|
705
837
|
return EXIT.usage
|
|
706
838
|
}
|
|
707
839
|
|
|
708
|
-
const config =
|
|
840
|
+
const config = loadLoggedConfig(deps, { cwd: deps.cwd, env: deps.env })
|
|
709
841
|
const authed = authedClient(deps, config)
|
|
710
842
|
if (!authed) return EXIT.auth
|
|
711
843
|
try {
|
|
712
844
|
let anyReplies = false
|
|
713
|
-
|
|
845
|
+
const degradedRequestIds: string[] = []
|
|
714
846
|
let allTimedOut = true
|
|
715
847
|
const jsonBodies: object[] = []
|
|
716
848
|
for (const requestId of requestIds) {
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
849
|
+
try {
|
|
850
|
+
const result = await waitForReply(authed.client, requestId, {
|
|
851
|
+
timeoutSeconds: waitSeconds,
|
|
852
|
+
afterSeq,
|
|
853
|
+
now: deps.now,
|
|
854
|
+
sleep: deps.sleep,
|
|
855
|
+
})
|
|
856
|
+
recordReplies(deps, requestId, result.response.replies)
|
|
857
|
+
if (flags.json) {
|
|
858
|
+
jsonBodies.push({ ...result.response, degraded: result.degraded })
|
|
859
|
+
} else if (result.response.replies.length > 0) {
|
|
860
|
+
if (flags.pending === true) deps.io.out(`pending request ${requestId}`)
|
|
861
|
+
printReplies(deps, result.response.replies)
|
|
862
|
+
} else {
|
|
863
|
+
printNoReply(deps, requestId, result.response.reply_expires_at)
|
|
864
|
+
}
|
|
865
|
+
anyReplies ||= result.response.replies.length > 0
|
|
866
|
+
if (result.degraded) degradedRequestIds.push(requestId)
|
|
867
|
+
allTimedOut &&= result.timedOut
|
|
868
|
+
} catch (err) {
|
|
869
|
+
recordDegradedReplyWaits(deps, degradedRequestIds)
|
|
870
|
+
return reportError(deps, err, { operation: 'reply_wait', request_id: requestId })
|
|
730
871
|
}
|
|
731
|
-
anyReplies ||= result.response.replies.length > 0
|
|
732
|
-
anyDegraded ||= result.degraded
|
|
733
|
-
allTimedOut &&= result.timedOut
|
|
734
872
|
}
|
|
735
873
|
if (flags.json) {
|
|
736
874
|
// One request keeps the response shape agents already parse; several
|
|
737
875
|
// (only possible via --pending) arrive as an array in queue order.
|
|
738
876
|
deps.io.out(JSON.stringify(jsonBodies.length === 1 ? jsonBodies[0] : jsonBodies, null, 2))
|
|
739
877
|
}
|
|
740
|
-
if (
|
|
741
|
-
deps
|
|
878
|
+
if (degradedRequestIds.length > 0) {
|
|
879
|
+
recordDegradedReplyWaits(deps, degradedRequestIds)
|
|
880
|
+
// Name a request whose polls actually degraded, not merely the first item
|
|
881
|
+
// in a multi-request queue that may have completed cleanly.
|
|
882
|
+
deps.io.err(degradedWaitWarning(degradedRequestIds[0]!))
|
|
742
883
|
return EXIT.network
|
|
743
884
|
}
|
|
744
885
|
if (anyReplies) return EXIT.ok
|
|
@@ -775,8 +916,8 @@ export async function waitForReply(
|
|
|
775
916
|
const sleep = options.sleep ?? ((milliseconds: number) => new Promise((resolve) => setTimeout(resolve, milliseconds)))
|
|
776
917
|
const deadline = now() + options.timeoutSeconds * 1000
|
|
777
918
|
let lastResponse: ListRepliesResponse | null = null
|
|
778
|
-
let
|
|
779
|
-
let
|
|
919
|
+
let lastTransientError: Error | null = null
|
|
920
|
+
let consecutiveTransientErrors = 0
|
|
780
921
|
let firstPoll = true
|
|
781
922
|
|
|
782
923
|
while (firstPoll || now() < deadline) {
|
|
@@ -789,24 +930,31 @@ export async function waitForReply(
|
|
|
789
930
|
afterSeq: options.afterSeq,
|
|
790
931
|
})
|
|
791
932
|
lastResponse = response
|
|
792
|
-
|
|
793
|
-
|
|
933
|
+
lastTransientError = null
|
|
934
|
+
consecutiveTransientErrors = 0
|
|
794
935
|
if (response.replies.length > 0) return { response, timedOut: false, degraded: false }
|
|
795
936
|
|
|
796
937
|
const pauseMs = Math.min(250, Math.max(0, deadline - now()))
|
|
797
938
|
if (pauseMs > 0) await sleep(pauseMs)
|
|
798
939
|
} catch (err) {
|
|
799
|
-
if (!(err
|
|
800
|
-
|
|
801
|
-
|
|
940
|
+
if (!isRetryableReplyPollError(err)) throw err
|
|
941
|
+
lastTransientError = err instanceof Error ? err : new Error(String(err))
|
|
942
|
+
consecutiveTransientErrors += 1
|
|
802
943
|
const remainingAfterError = Math.max(0, deadline - now())
|
|
803
944
|
if (remainingAfterError === 0) break
|
|
804
|
-
const backoffMs = Math.min(
|
|
945
|
+
const backoffMs = Math.min(
|
|
946
|
+
250 * 2 ** (consecutiveTransientErrors - 1),
|
|
947
|
+
2_000,
|
|
948
|
+
remainingAfterError,
|
|
949
|
+
)
|
|
805
950
|
await sleep(backoffMs)
|
|
806
951
|
}
|
|
807
952
|
}
|
|
808
953
|
|
|
809
|
-
|
|
954
|
+
// Never saw a successful poll: surface the fault so callers get EXIT.network
|
|
955
|
+
// rather than a fake empty silence. The durable request is still on the
|
|
956
|
+
// server; the typed recovery is `notifai replies <id>`.
|
|
957
|
+
if (!lastResponse && lastTransientError) throw lastTransientError
|
|
810
958
|
return {
|
|
811
959
|
response:
|
|
812
960
|
lastResponse ??
|
|
@@ -815,23 +963,55 @@ export async function waitForReply(
|
|
|
815
963
|
// A poll succeeded at some point, so we do not throw — but the last thing
|
|
816
964
|
// we know is that we could not reach the server. Reporting that as a plain
|
|
817
965
|
// "no reply" would let an agent treat an unseen refusal as consent.
|
|
818
|
-
degraded:
|
|
966
|
+
degraded: lastTransientError !== null,
|
|
819
967
|
}
|
|
820
968
|
}
|
|
821
969
|
|
|
970
|
+
function recordDegradedReplyWaits(deps: CommandDeps, requestIds: readonly string[]): void {
|
|
971
|
+
if (requestIds.length === 0) return
|
|
972
|
+
log(deps).error('cli.error', {
|
|
973
|
+
kind: 'network',
|
|
974
|
+
operation: 'reply_wait',
|
|
975
|
+
request_ids: requestIds,
|
|
976
|
+
degraded: true,
|
|
977
|
+
message: 'the reply wait ended while the server was unreachable or faulting',
|
|
978
|
+
})
|
|
979
|
+
}
|
|
980
|
+
|
|
822
981
|
/**
|
|
823
982
|
* Shared by every surface that waits: "the user did not answer" and "I could
|
|
824
983
|
* not find out" must not look the same, because agents branch on the exit code
|
|
825
984
|
* and one of those two branches is safe to proceed from.
|
|
985
|
+
*
|
|
986
|
+
* Delivery / Companion Receipt / OS presentation are separate facts — a wait
|
|
987
|
+
* fault is not evidence that the push failed.
|
|
826
988
|
*/
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
989
|
+
function degradedWaitWarning(requestId: string): string {
|
|
990
|
+
return (
|
|
991
|
+
`notifai: the wait for ${requestId} ended while the server was unreachable or faulting, ` +
|
|
992
|
+
`so this is "could not find out", not "no answer" — the request is still durable and the ` +
|
|
993
|
+
`reply may already be waiting (Provider Acceptance and Companion Receipt are independent). ` +
|
|
994
|
+
`Retry with \`notifai replies ${requestId}\`.`
|
|
995
|
+
)
|
|
996
|
+
}
|
|
831
997
|
function isNonNegativeInteger(value: number): boolean {
|
|
832
998
|
return Number.isInteger(value) && value >= 0
|
|
833
999
|
}
|
|
834
1000
|
|
|
1001
|
+
function recordReplies(deps: CommandDeps, requestId: string, replies: readonly ReplyView[]): void {
|
|
1002
|
+
const logger = log(deps)
|
|
1003
|
+
for (const reply of replies) {
|
|
1004
|
+
logger.info('reply.received', {
|
|
1005
|
+
request_id: requestId,
|
|
1006
|
+
sequence: reply.seq,
|
|
1007
|
+
device: reply.device_name,
|
|
1008
|
+
text: reply.text,
|
|
1009
|
+
answers: reply.answers,
|
|
1010
|
+
source: reply.source,
|
|
1011
|
+
})
|
|
1012
|
+
}
|
|
1013
|
+
}
|
|
1014
|
+
|
|
835
1015
|
function printReplies(deps: CommandDeps, replies: ReplyView[]): void {
|
|
836
1016
|
for (const reply of replies) deps.io.out(`reply from ${reply.device_name}: ${reply.text}`)
|
|
837
1017
|
const contradiction = contradictingAnswer(replies)
|
|
@@ -880,7 +1060,7 @@ export async function statusCommand(
|
|
|
880
1060
|
requestId: string,
|
|
881
1061
|
flags: { json?: boolean },
|
|
882
1062
|
): Promise<number> {
|
|
883
|
-
const config =
|
|
1063
|
+
const config = loadLoggedConfig(deps, { cwd: deps.cwd, env: deps.env })
|
|
884
1064
|
const authed = authedClient(deps, config)
|
|
885
1065
|
if (!authed) return EXIT.auth
|
|
886
1066
|
try {
|
|
@@ -890,6 +1070,7 @@ export async function statusCommand(
|
|
|
890
1070
|
return EXIT.ok
|
|
891
1071
|
}
|
|
892
1072
|
deps.io.out(`request ${snapshot.request_id} (${snapshot.event ?? 'no event'}) — ${snapshot.overall}`)
|
|
1073
|
+
let anyReplyReceived = false
|
|
893
1074
|
for (const d of snapshot.deliveries) {
|
|
894
1075
|
deps.io.out(` ${d.device_name}:`)
|
|
895
1076
|
deps.io.out(` Delivery: ${d.state} after ${d.attempts} attempt(s)`)
|
|
@@ -907,10 +1088,27 @@ export async function statusCommand(
|
|
|
907
1088
|
" Companion Receipt (the app's delivery confirmation): unknown — not observed; this is not a failure or proof of non-receipt",
|
|
908
1089
|
)
|
|
909
1090
|
}
|
|
1091
|
+
// Notifai never learns whether the OS painted a banner; saying so stops
|
|
1092
|
+
// a reply-wait fault from being misread as "the phone never showed it".
|
|
1093
|
+
deps.io.out(
|
|
1094
|
+
' OS presentation: not observed by Notifai — Provider Acceptance and Companion Receipt do not prove a banner was shown',
|
|
1095
|
+
)
|
|
1096
|
+
const replyEvent = d.events.find((e) => e.stage === 'reply_received')
|
|
1097
|
+
if (replyEvent) {
|
|
1098
|
+
anyReplyReceived = true
|
|
1099
|
+
deps.io.out(` Reply received: yes (first at ${replyEvent.occurred_at})`)
|
|
1100
|
+
} else {
|
|
1101
|
+
deps.io.out(' Reply received: not yet recorded on this delivery')
|
|
1102
|
+
}
|
|
910
1103
|
for (const e of d.events) {
|
|
911
1104
|
deps.io.out(` ${e.occurred_at} ${e.stage}${e.reason ? ` (${e.reason})` : ''}`)
|
|
912
1105
|
}
|
|
913
1106
|
}
|
|
1107
|
+
deps.io.out(
|
|
1108
|
+
anyReplyReceived
|
|
1109
|
+
? ` Reply wait: answers are on the server — collect with \`notifai replies ${snapshot.request_id}\` (a local wait fault does not erase them)`
|
|
1110
|
+
: ` Reply wait: no answer stored yet — a blocking wait failure is independent of Delivery above; retry with \`notifai replies ${snapshot.request_id}\``,
|
|
1111
|
+
)
|
|
914
1112
|
return EXIT.ok
|
|
915
1113
|
} catch (err) {
|
|
916
1114
|
return reportError(deps, err)
|
|
@@ -979,6 +1177,13 @@ async function uploadImage(deps: CommandDeps, client: ApiClient, source: string)
|
|
|
979
1177
|
export const HOOK_EVENTS = ['user-prompt-submit', 'stop', 'session-end'] as const
|
|
980
1178
|
export type HookEvent = (typeof HOOK_EVENTS)[number]
|
|
981
1179
|
|
|
1180
|
+
/** SessionEnd cleanup must precede every diagnostic that can wait on a file lock. */
|
|
1181
|
+
export function hookDefersDiagnosticsUntilAfterCleanup(
|
|
1182
|
+
event: unknown,
|
|
1183
|
+
): event is 'session-end' {
|
|
1184
|
+
return event === 'session-end'
|
|
1185
|
+
}
|
|
1186
|
+
|
|
982
1187
|
/**
|
|
983
1188
|
* Runs one harness hook. Contract with every harness: hook JSON arrives on
|
|
984
1189
|
* stdin, the decision (if any) goes to stdout, diagnostics go to stderr, and
|
|
@@ -998,53 +1203,129 @@ export async function hookRunCommand(
|
|
|
998
1203
|
deps.io.err(`Unknown hook event "${event}". Valid: ${HOOK_EVENTS.join(', ')}`)
|
|
999
1204
|
return EXIT.usage
|
|
1000
1205
|
}
|
|
1001
|
-
|
|
1206
|
+
|
|
1207
|
+
// One clock owns the complete Stop invocation, including stdin, config,
|
|
1208
|
+
// credentials, and client construction. Starting this inside `handleStop`
|
|
1209
|
+
// would grant slow setup a second budget and let the harness kill us before
|
|
1210
|
+
// an accepted answer is journaled or written to stdout.
|
|
1211
|
+
const now = deps.now ?? Date.now
|
|
1212
|
+
const processDeadlineAt = now() + HOOK_TIMING.totalSeconds * 1000
|
|
1213
|
+
|
|
1214
|
+
const logger = log(deps)
|
|
1215
|
+
logger.bind({ cmd: `hook ${event}` })
|
|
1216
|
+
let started = false
|
|
1217
|
+
const start = (data: Record<string, unknown> = {}): void => {
|
|
1218
|
+
if (started) return
|
|
1219
|
+
started = true
|
|
1220
|
+
logger.info('hook.start', { hook: event, harness: harness ?? 'unknown', ...data })
|
|
1221
|
+
}
|
|
1222
|
+
const failureData = (err: unknown): Record<string, unknown> =>
|
|
1223
|
+
err instanceof ApiCallError
|
|
1224
|
+
? { status: err.status, code: err.code, message: err.message, details: err.details }
|
|
1225
|
+
: { message: err instanceof Error ? err.message : String(err) }
|
|
1226
|
+
|
|
1227
|
+
let raw: string
|
|
1002
1228
|
try {
|
|
1003
|
-
|
|
1004
|
-
|
|
1005
|
-
|
|
1006
|
-
|
|
1007
|
-
|
|
1008
|
-
|
|
1009
|
-
|
|
1010
|
-
|
|
1011
|
-
|
|
1229
|
+
raw = await readStdin()
|
|
1230
|
+
} catch (err) {
|
|
1231
|
+
start({ input: 'unavailable' })
|
|
1232
|
+
logger.error('hook.end', {
|
|
1233
|
+
hook: event,
|
|
1234
|
+
outcome: 'ignored',
|
|
1235
|
+
reason: 'input-read-failed',
|
|
1236
|
+
...failureData(err),
|
|
1237
|
+
})
|
|
1238
|
+
return EXIT.ok
|
|
1239
|
+
}
|
|
1240
|
+
|
|
1241
|
+
if (raw.trim() !== '') {
|
|
1242
|
+
try {
|
|
1243
|
+
const parsed: unknown = JSON.parse(raw)
|
|
1244
|
+
if (typeof parsed !== 'object' || parsed === null) throw new Error('not an object')
|
|
1245
|
+
} catch {
|
|
1246
|
+
start({ input: 'malformed' })
|
|
1247
|
+
logger.error('hook.end', { hook: event, outcome: 'ignored', reason: 'malformed-input' })
|
|
1248
|
+
deps.io.err('notifai: ignored malformed or truncated hook input; no routing action was taken')
|
|
1249
|
+
return EXIT.ok
|
|
1012
1250
|
}
|
|
1013
|
-
|
|
1014
|
-
|
|
1015
|
-
|
|
1016
|
-
|
|
1017
|
-
|
|
1018
|
-
|
|
1019
|
-
|
|
1020
|
-
|
|
1021
|
-
|
|
1022
|
-
|
|
1023
|
-
|
|
1024
|
-
|
|
1251
|
+
}
|
|
1252
|
+
|
|
1253
|
+
let envelope = parseHookInput(raw)
|
|
1254
|
+
if (harness === 'cursor') {
|
|
1255
|
+
const sessionId = envelope.session_id ?? envelope.conversation_id
|
|
1256
|
+
const cwd = envelope.cwd ?? envelope.workspace_roots?.[0]
|
|
1257
|
+
envelope = {
|
|
1258
|
+
...envelope,
|
|
1259
|
+
...(sessionId === undefined ? {} : { session_id: sessionId }),
|
|
1260
|
+
...(cwd === undefined ? {} : { cwd }),
|
|
1261
|
+
stop_hook_active:
|
|
1262
|
+
envelope.stop_hook_active ??
|
|
1263
|
+
(typeof envelope.loop_count === 'number' && envelope.loop_count > 0),
|
|
1025
1264
|
}
|
|
1026
|
-
} catch {
|
|
1027
|
-
return EXIT.ok
|
|
1028
1265
|
}
|
|
1029
1266
|
|
|
1030
|
-
|
|
1031
|
-
|
|
1032
|
-
|
|
1033
|
-
|
|
1267
|
+
const cwd = envelope.cwd ?? deps.cwd
|
|
1268
|
+
const sessionEnd = hookDefersDiagnosticsUntilAfterCleanup(event)
|
|
1269
|
+
logger.bind({ session: envelope.session_id ?? null })
|
|
1270
|
+
let config: CliConfig | null = null
|
|
1271
|
+
let configFailure: unknown
|
|
1034
1272
|
try {
|
|
1035
|
-
|
|
1273
|
+
config = loadConfig({ cwd, env: deps.env, sessionId: envelope.session_id })
|
|
1274
|
+
// The hook's project is the session's, not this process's, and the log
|
|
1275
|
+
// settings that apply are that project's too. Keeping a mutable bootstrap
|
|
1276
|
+
// logger lets this more-specific layer turn logging back on.
|
|
1277
|
+
logger.adopt(logSettingsFrom(config))
|
|
1278
|
+
logger.bind({ project: config.project.value })
|
|
1279
|
+
if (!sessionEnd) {
|
|
1280
|
+
start({ cwd, stop_hook_active: envelope.stop_hook_active ?? null })
|
|
1281
|
+
logConfigResolved(logger, config)
|
|
1282
|
+
}
|
|
1283
|
+
} catch (err) {
|
|
1284
|
+
configFailure = err
|
|
1285
|
+
if (!sessionEnd) {
|
|
1286
|
+
start({ cwd, stop_hook_active: envelope.stop_hook_active ?? null })
|
|
1287
|
+
logger.error('hook.end', {
|
|
1288
|
+
hook: event,
|
|
1289
|
+
outcome: 'failed',
|
|
1290
|
+
reason: 'config-failed',
|
|
1291
|
+
...failureData(err),
|
|
1292
|
+
})
|
|
1293
|
+
for (const line of describeHookFailure(err)) deps.io.err(`notifai: ${line}`)
|
|
1294
|
+
return EXIT.ok
|
|
1295
|
+
}
|
|
1296
|
+
}
|
|
1297
|
+
|
|
1298
|
+
// Everything below is inside one fail-open boundary. Credential loading,
|
|
1299
|
+
// client construction and hook handling can all throw, and a hook that exits
|
|
1300
|
+
// non-zero makes the harness report a failure — strictly worse than skipping.
|
|
1301
|
+
try {
|
|
1302
|
+
if (sessionEnd) {
|
|
1303
|
+
// Codex gives SessionEnd one second total. Do every durable cleanup write
|
|
1304
|
+
// before lifecycle diagnostics: the log lock is deliberately allowed to
|
|
1305
|
+
// wait that long, and a busy log must never preserve ended-session state
|
|
1306
|
+
// or its inherited configuration. The resolved config above is retained
|
|
1307
|
+
// in memory so logging still uses the ending session's settings afterwards.
|
|
1036
1308
|
const outcome = handleSessionEnd(deps.env, envelope, (deps.now ?? Date.now)())
|
|
1309
|
+
start({ cwd, stop_hook_active: envelope.stop_hook_active ?? null })
|
|
1310
|
+
if (config !== null) logConfigResolved(logger, config)
|
|
1311
|
+
const data = { hook: event, decided: false, ...outcome.log }
|
|
1312
|
+
if (configFailure === undefined) logger.info('hook.end', data)
|
|
1313
|
+
else {
|
|
1314
|
+
logger.error('hook.end', {
|
|
1315
|
+
...data,
|
|
1316
|
+
reason: 'config-failed',
|
|
1317
|
+
config_error: failureData(configFailure),
|
|
1318
|
+
})
|
|
1319
|
+
}
|
|
1037
1320
|
for (const note of outcome.notes) deps.io.err(`notifai: ${note}`)
|
|
1038
1321
|
return EXIT.ok
|
|
1039
1322
|
}
|
|
1040
1323
|
|
|
1041
|
-
//
|
|
1042
|
-
|
|
1043
|
-
// session belongs to is the harness's business, not ours.
|
|
1044
|
-
const cwd = envelope.cwd ?? deps.cwd
|
|
1045
|
-
const config = loadConfig({ cwd, env: deps.env, sessionId: envelope.session_id })
|
|
1324
|
+
// Non-SessionEnd hooks cannot reach here without resolved configuration.
|
|
1325
|
+
const resolved = config!
|
|
1046
1326
|
const credential = deps.store.load()
|
|
1047
1327
|
if (!credential) {
|
|
1328
|
+
logger.error('hook.end', { hook: event, outcome: 'not-paired' })
|
|
1048
1329
|
deps.io.err('notifai: hook skipped: this machine is not paired; run `notifai login`')
|
|
1049
1330
|
return EXIT.ok
|
|
1050
1331
|
}
|
|
@@ -1052,9 +1333,9 @@ export async function hookRunCommand(
|
|
|
1052
1333
|
// repository can commit `.notifai/config.toml`, and honouring a base_url
|
|
1053
1334
|
// from it would hand this machine's bearer token to whatever host it names.
|
|
1054
1335
|
const baseUrl = credential.baseUrl
|
|
1055
|
-
if (
|
|
1336
|
+
if (resolved.base_url.source !== 'default' && resolved.base_url.value !== baseUrl) {
|
|
1056
1337
|
deps.io.err(
|
|
1057
|
-
`notifai: ignoring base_url from ${
|
|
1338
|
+
`notifai: ignoring base_url from ${resolved.base_url.source}; hooks only talk to ${baseUrl}`,
|
|
1058
1339
|
)
|
|
1059
1340
|
}
|
|
1060
1341
|
// UserPromptSubmit runs in front of the user's own prompt under a 15s
|
|
@@ -1064,12 +1345,20 @@ export async function hookRunCommand(
|
|
|
1064
1345
|
deps,
|
|
1065
1346
|
baseUrl,
|
|
1066
1347
|
`Bearer nfm_${credential.machineId}.${credential.secret}`,
|
|
1067
|
-
{
|
|
1348
|
+
{
|
|
1349
|
+
timeoutMs: event === 'user-prompt-submit' ? 4_000 : 20_000,
|
|
1350
|
+
...(event === 'stop'
|
|
1351
|
+
? {
|
|
1352
|
+
deadlineAt:
|
|
1353
|
+
processDeadlineAt - HOOK_TIMING.stdoutReserveSeconds * 1000,
|
|
1354
|
+
now,
|
|
1355
|
+
}
|
|
1356
|
+
: {}),
|
|
1357
|
+
},
|
|
1068
1358
|
)
|
|
1069
|
-
const now = deps.now ?? Date.now
|
|
1070
1359
|
const ctx: HookContext = {
|
|
1071
1360
|
client,
|
|
1072
|
-
config,
|
|
1361
|
+
config: resolved,
|
|
1073
1362
|
env: deps.env,
|
|
1074
1363
|
now,
|
|
1075
1364
|
idleSeconds: deps.idleSeconds ?? (() => readIdleSeconds()),
|
|
@@ -1087,24 +1376,41 @@ export async function hookRunCommand(
|
|
|
1087
1376
|
degraded: result.degraded,
|
|
1088
1377
|
}
|
|
1089
1378
|
},
|
|
1379
|
+
log: logger,
|
|
1090
1380
|
...(harness === undefined ? {} : { harness }),
|
|
1091
1381
|
}
|
|
1092
1382
|
|
|
1093
1383
|
// Real clock, deliberately, not `deps.now`. This compares against file
|
|
1094
1384
|
// mtimes, which are wall-clock facts — handing it a virtual or skewed clock
|
|
1095
1385
|
// would have it delete live session state as "abandoned".
|
|
1096
|
-
//
|
|
1097
|
-
//
|
|
1098
|
-
|
|
1386
|
+
// Daily state pruning is housekeeping, not part of the Stop delivery
|
|
1387
|
+
// contract. Its directory scan has no useful bound, so keep it on the
|
|
1388
|
+
// short prompt path and never spend the answer owner's finite budget on it.
|
|
1389
|
+
if (event !== 'stop') pruneAbandonedSessions(deps.env)
|
|
1099
1390
|
|
|
1100
1391
|
const outcome =
|
|
1101
1392
|
event === 'user-prompt-submit'
|
|
1102
1393
|
? await handleUserPromptSubmit(ctx, envelope)
|
|
1103
|
-
: await handleStop(ctx, envelope)
|
|
1394
|
+
: await handleStop(ctx, envelope, processDeadlineAt)
|
|
1395
|
+
// Answer diagnostics are already persisted once as hook.answer. Keep every
|
|
1396
|
+
// other note in the lifecycle record without duplicating the user's text.
|
|
1397
|
+
const notes = outcome.notes.filter((note) => !/^(?:late )?answer from /.test(note))
|
|
1398
|
+
logger.info('hook.end', {
|
|
1399
|
+
hook: event,
|
|
1400
|
+
// A hook that returns stdout has taken over the turn; one that does not
|
|
1401
|
+
// has handed the terminal back. That distinction is the whole contract.
|
|
1402
|
+
decided: outcome.stdout !== undefined,
|
|
1403
|
+
...(notes.length === 0 ? {} : { notes }),
|
|
1404
|
+
...outcome.log,
|
|
1405
|
+
})
|
|
1104
1406
|
for (const note of outcome.notes) deps.io.err(`notifai: ${note}`)
|
|
1105
1407
|
if (outcome.stdout !== undefined) {
|
|
1106
1408
|
let stdout = outcome.stdout
|
|
1107
|
-
if (
|
|
1409
|
+
if (
|
|
1410
|
+
harness !== undefined &&
|
|
1411
|
+
event === 'stop' &&
|
|
1412
|
+
HARNESS_CAPABILITIES[harness].stopContinuation === 'followup-message'
|
|
1413
|
+
) {
|
|
1108
1414
|
const decision = JSON.parse(outcome.stdout) as { decision?: unknown; reason?: unknown }
|
|
1109
1415
|
if (decision.decision === 'block' && typeof decision.reason === 'string') {
|
|
1110
1416
|
stdout = JSON.stringify({ followup_message: decision.reason })
|
|
@@ -1114,6 +1420,17 @@ export async function hookRunCommand(
|
|
|
1114
1420
|
}
|
|
1115
1421
|
return EXIT.ok
|
|
1116
1422
|
} catch (err) {
|
|
1423
|
+
// SessionEnd defers its start record until after cleanup; if cleanup itself
|
|
1424
|
+
// fails, begin the after-the-fact lifecycle here before recording why.
|
|
1425
|
+
if (sessionEnd) start({ cwd, stop_hook_active: envelope.stop_hook_active ?? null })
|
|
1426
|
+
// The hook still exits 0 — handing the terminal back is always right. What
|
|
1427
|
+
// this adds is that the reason survives, including the server's own words.
|
|
1428
|
+
logger.error('hook.end', {
|
|
1429
|
+
hook: event,
|
|
1430
|
+
outcome: 'failed',
|
|
1431
|
+
reason: 'execution-failed',
|
|
1432
|
+
...failureData(err),
|
|
1433
|
+
})
|
|
1117
1434
|
for (const line of describeHookFailure(err)) deps.io.err(`notifai: ${line}`)
|
|
1118
1435
|
return EXIT.ok
|
|
1119
1436
|
}
|
|
@@ -1272,27 +1589,40 @@ function buildOneQuestion(
|
|
|
1272
1589
|
|
|
1273
1590
|
/**
|
|
1274
1591
|
* Registers a question for turn-end routing under the user's presence config.
|
|
1275
|
-
* Returns immediately so the agent can ask in prose and end its turn.
|
|
1276
|
-
*
|
|
1277
|
-
*
|
|
1592
|
+
* Returns immediately so the agent can ask in prose and end its turn. Presence
|
|
1593
|
+
* does not block the shipped default; `require_idle = true` intentionally keeps
|
|
1594
|
+
* the question local while the user is active.
|
|
1278
1595
|
*/
|
|
1279
1596
|
export function askCommand(deps: CommandDeps, question: string | undefined, flags: AskFlags): number {
|
|
1597
|
+
// Validate before route discovery. A malformed question belongs to the
|
|
1598
|
+
// caller and should not be hidden behind whichever harness setup issue
|
|
1599
|
+
// happens to exist on this machine.
|
|
1600
|
+
const built = buildQuestions(flags, question)
|
|
1601
|
+
if (!built.ok) {
|
|
1602
|
+
deps.io.err(built.error)
|
|
1603
|
+
return EXIT.usage
|
|
1604
|
+
}
|
|
1605
|
+
const routingConfig = loadConfig({ cwd: deps.cwd, env: deps.env })
|
|
1606
|
+
if (!routingConfig.ask_notifications.value) {
|
|
1607
|
+
deps.io.err(
|
|
1608
|
+
'Question routing is disabled by ask_notifications=false; enable it or use a blocking `notifai send --reply` question.',
|
|
1609
|
+
)
|
|
1610
|
+
return EXIT.usage
|
|
1611
|
+
}
|
|
1612
|
+
if (deps.store.load() === null) {
|
|
1613
|
+
deps.io.err(
|
|
1614
|
+
'Question routing is not paired on this machine; run `notifai login` before registering an asynchronous question.',
|
|
1615
|
+
)
|
|
1616
|
+
return EXIT.usage
|
|
1617
|
+
}
|
|
1280
1618
|
// An agent calling this gets no hook payload. Harness-native environment
|
|
1281
|
-
// markers identify the active owner, while the
|
|
1282
|
-
//
|
|
1283
|
-
// The pointer outranks the
|
|
1284
|
-
// NOTIFAI_SESSION fallback deliberately: the exported id is often a chosen
|
|
1285
|
-
// label rather than the harness's own id, and the hooks key state by the
|
|
1286
|
-
// latter — the env var is only trusted when no hook has spoken.
|
|
1619
|
+
// markers identify the active owner, while UserPromptSubmit adds the hook's
|
|
1620
|
+
// canonical id to the directory's concurrent-session index.
|
|
1287
1621
|
const now = (deps.now ?? Date.now)()
|
|
1288
|
-
const projectPointer = readProjectSessionPointer(deps.cwd, deps.env, now)
|
|
1289
|
-
const pointer = projectPointer?.sessionId ?? null
|
|
1290
1622
|
const active = activeHarnessSession(deps.env)
|
|
1291
1623
|
let sessionId: string | undefined
|
|
1292
|
-
if (
|
|
1293
|
-
|
|
1294
|
-
} else if (active !== null) {
|
|
1295
|
-
const installations = findInstallations(deps.cwd, deps.env)
|
|
1624
|
+
if (active !== null) {
|
|
1625
|
+
const installations = findInstallations(deps.cwd, deps.env, deps.hookAdapterHome)
|
|
1296
1626
|
const activeInstalled = installations.some(
|
|
1297
1627
|
(installation) => installation.harness === active.harness,
|
|
1298
1628
|
)
|
|
@@ -1302,35 +1632,52 @@ export function askCommand(deps: CommandDeps, question: string | undefined, flag
|
|
|
1302
1632
|
}
|
|
1303
1633
|
return EXIT.usage
|
|
1304
1634
|
}
|
|
1635
|
+
if (active.sessionId === undefined) {
|
|
1636
|
+
for (const problem of activeQuestionRouteProblems(deps, active, installations)) {
|
|
1637
|
+
deps.io.err(`Question routing is not ready: ${problem}`)
|
|
1638
|
+
}
|
|
1639
|
+
return EXIT.usage
|
|
1640
|
+
}
|
|
1641
|
+
const projectPointer = readMatchingProjectSessionPointer(
|
|
1642
|
+
deps.cwd,
|
|
1643
|
+
deps.env,
|
|
1644
|
+
now,
|
|
1645
|
+
active.sessionId,
|
|
1646
|
+
active.harness,
|
|
1647
|
+
)
|
|
1305
1648
|
if (projectPointer === null) {
|
|
1306
1649
|
for (const line of diagnoseActiveHarnessSession(active, 'not-fired', installations)) {
|
|
1307
1650
|
deps.io.err(line)
|
|
1308
1651
|
}
|
|
1309
1652
|
return EXIT.usage
|
|
1310
1653
|
}
|
|
1311
|
-
if (
|
|
1312
|
-
|
|
1313
|
-
|
|
1314
|
-
|
|
1315
|
-
|
|
1316
|
-
|
|
1317
|
-
|
|
1654
|
+
if (flags.session !== undefined && flags.session !== active.sessionId) {
|
|
1655
|
+
deps.io.err(
|
|
1656
|
+
`Question routing is not ready: --session ${flags.session} does not match the exact active ${active.label} session ${active.sessionId}; refusing cross-session routing.`,
|
|
1657
|
+
)
|
|
1658
|
+
return EXIT.usage
|
|
1659
|
+
}
|
|
1660
|
+
const routeProblems = activeQuestionRouteProblems(deps, active, installations)
|
|
1661
|
+
if (routeProblems.length > 0) {
|
|
1662
|
+
for (const problem of routeProblems) deps.io.err(`Question routing is not ready: ${problem}`)
|
|
1663
|
+
return EXIT.usage
|
|
1664
|
+
}
|
|
1665
|
+
if (readSessionState(projectPointer.sessionId, deps.env).last_stop_at === undefined) {
|
|
1666
|
+
deps.io.err(
|
|
1667
|
+
`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\`.`,
|
|
1668
|
+
)
|
|
1318
1669
|
return EXIT.usage
|
|
1319
1670
|
}
|
|
1320
1671
|
sessionId = projectPointer.sessionId
|
|
1321
1672
|
} else {
|
|
1322
|
-
sessionId = pointer ?? deps.env['NOTIFAI_SESSION'] ?? undefined
|
|
1323
|
-
}
|
|
1324
|
-
if (!sessionId) {
|
|
1325
1673
|
for (const line of diagnoseMissingSession(deps)) deps.io.err(line)
|
|
1674
|
+
deps.io.err(
|
|
1675
|
+
'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.',
|
|
1676
|
+
)
|
|
1326
1677
|
return EXIT.usage
|
|
1327
1678
|
}
|
|
1328
|
-
|
|
1329
|
-
|
|
1330
|
-
// question set would look like it registered fine and then silently never ask.
|
|
1331
|
-
const built = buildQuestions(flags, question)
|
|
1332
|
-
if (!built.ok) {
|
|
1333
|
-
deps.io.err(built.error)
|
|
1679
|
+
if (!sessionId) {
|
|
1680
|
+
for (const line of diagnoseMissingSession(deps)) deps.io.err(line)
|
|
1334
1681
|
return EXIT.usage
|
|
1335
1682
|
}
|
|
1336
1683
|
try {
|
|
@@ -1345,9 +1692,20 @@ export function askCommand(deps: CommandDeps, question: string | undefined, flag
|
|
|
1345
1692
|
(deps.now ?? Date.now)(),
|
|
1346
1693
|
)
|
|
1347
1694
|
} catch (err) {
|
|
1695
|
+
log(deps).error('ask.registered', { ok: false, session: sessionId, message: String(err) })
|
|
1348
1696
|
deps.io.err(`Could not register the question: ${err instanceof Error ? err.message : String(err)}`)
|
|
1349
1697
|
return EXIT.failed
|
|
1350
1698
|
}
|
|
1699
|
+
// `ask` returns immediately and the push happens later inside a hook, so this
|
|
1700
|
+
// is the only local evidence that the question was ever registered — the
|
|
1701
|
+
// starting point for "the agent asked, and nothing ever reached my phone".
|
|
1702
|
+
log(deps).info('ask.registered', {
|
|
1703
|
+
ok: true,
|
|
1704
|
+
session: sessionId,
|
|
1705
|
+
questions: built.questions.length,
|
|
1706
|
+
text: built.questions[0]!.text,
|
|
1707
|
+
choices: built.questions[0]!.choices?.length ?? 0,
|
|
1708
|
+
})
|
|
1351
1709
|
for (const [index, entry] of built.questions.entries()) {
|
|
1352
1710
|
const prefix = built.questions.length > 1 ? `${index + 1}. ` : ''
|
|
1353
1711
|
if (entry.choices !== undefined) {
|
|
@@ -1404,6 +1762,69 @@ function activeHarnessSession(env: NodeJS.ProcessEnv): ActiveHarnessSession | nu
|
|
|
1404
1762
|
return null
|
|
1405
1763
|
}
|
|
1406
1764
|
|
|
1765
|
+
/**
|
|
1766
|
+
* One fail-closed admission gate shared by every active harness route.
|
|
1767
|
+
* Finding a file is not readiness: the exact session, one current definition,
|
|
1768
|
+
* the stable adapter, a long enough Stop owner, and (for Codex) trust must all
|
|
1769
|
+
* be true before `ask` is allowed to create an answerable notification.
|
|
1770
|
+
*/
|
|
1771
|
+
function activeQuestionRouteProblems(
|
|
1772
|
+
deps: CommandDeps,
|
|
1773
|
+
active: ActiveHarnessSession,
|
|
1774
|
+
installations: Installation[],
|
|
1775
|
+
): string[] {
|
|
1776
|
+
const problems: string[] = []
|
|
1777
|
+
if (active.sessionId === undefined) {
|
|
1778
|
+
problems.push(
|
|
1779
|
+
`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`,
|
|
1780
|
+
)
|
|
1781
|
+
}
|
|
1782
|
+
const capability = HARNESS_CAPABILITIES[active.harness]
|
|
1783
|
+
if (capability.stopContinuation === 'unsupported') {
|
|
1784
|
+
problems.push(`${active.label}: ${capability.deliveryContract}`)
|
|
1785
|
+
}
|
|
1786
|
+
const matching = installations.filter(
|
|
1787
|
+
(installation) => installation.harness === active.harness,
|
|
1788
|
+
)
|
|
1789
|
+
if (matching.length > 1) {
|
|
1790
|
+
problems.push(
|
|
1791
|
+
`${matching.length} ${active.label} definitions are active (${matching.map((entry) => entry.file).join(', ')}); keep either project or global routing`,
|
|
1792
|
+
)
|
|
1793
|
+
}
|
|
1794
|
+
for (const installation of matching) {
|
|
1795
|
+
for (const problem of installation.problems ?? []) {
|
|
1796
|
+
problems.push(`${installation.file}: ${problem}`)
|
|
1797
|
+
}
|
|
1798
|
+
for (const handler of installation.handlers) {
|
|
1799
|
+
const event = handlerEvent(handler.command)
|
|
1800
|
+
if (event !== null && !(HOOK_EVENTS as readonly string[]).includes(event)) {
|
|
1801
|
+
problems.push(
|
|
1802
|
+
`${handler.event} in ${installation.file} names the unsupported event ${event}; reinstall the ${active.label} hooks`,
|
|
1803
|
+
)
|
|
1804
|
+
}
|
|
1805
|
+
}
|
|
1806
|
+
}
|
|
1807
|
+
for (const problem of inspectHookAdapter(deps.hookAdapterHome).problems) {
|
|
1808
|
+
problems.push(problem)
|
|
1809
|
+
}
|
|
1810
|
+
if (active.harness !== 'codex') {
|
|
1811
|
+
const required = blockingHookTimeoutSeconds()
|
|
1812
|
+
for (const installation of matching) {
|
|
1813
|
+
for (const handler of installation.handlers.filter(
|
|
1814
|
+
(entry) => handlerEvent(entry.command) === 'stop',
|
|
1815
|
+
)) {
|
|
1816
|
+
if (handler.timeout === undefined || handler.timeout < required) {
|
|
1817
|
+
problems.push(
|
|
1818
|
+
`${installation.file} gives Stop ${handler.timeout ?? 'no'}s, but the live answer owner requires ${required}s`,
|
|
1819
|
+
)
|
|
1820
|
+
}
|
|
1821
|
+
}
|
|
1822
|
+
}
|
|
1823
|
+
}
|
|
1824
|
+
problems.push(...codexTrustProblems(matching, deps.env))
|
|
1825
|
+
return [...new Set(problems)]
|
|
1826
|
+
}
|
|
1827
|
+
|
|
1407
1828
|
type ActiveHarnessProblem = 'not-installed' | 'not-fired' | 'different-session'
|
|
1408
1829
|
|
|
1409
1830
|
function diagnoseActiveHarnessSession(
|
|
@@ -1448,7 +1869,7 @@ function diagnoseActiveHarnessSession(
|
|
|
1448
1869
|
* checked after a prompt before assuming that a new session is required.
|
|
1449
1870
|
*/
|
|
1450
1871
|
function diagnoseMissingSession(deps: CommandDeps): string[] {
|
|
1451
|
-
const installations = findInstallations(deps.cwd, deps.env)
|
|
1872
|
+
const installations = findInstallations(deps.cwd, deps.env, deps.hookAdapterHome)
|
|
1452
1873
|
if (installations.length === 0) {
|
|
1453
1874
|
return [
|
|
1454
1875
|
'Could not tell which harness session this is: no Notifai hooks are installed for this project.',
|
|
@@ -1460,7 +1881,7 @@ function diagnoseMissingSession(deps: CommandDeps): string[] {
|
|
|
1460
1881
|
`Could not tell which harness session this is. Notifai hooks are installed (${where}),`,
|
|
1461
1882
|
'but no usable session pointer from the last 24 hours exists here.',
|
|
1462
1883
|
hookActivationAdvice(installations),
|
|
1463
|
-
'
|
|
1884
|
+
'Do not bypass this with a guessed session id; run from the exact active harness session or use a blocking `notifai send --reply` question.',
|
|
1464
1885
|
]
|
|
1465
1886
|
}
|
|
1466
1887
|
|
|
@@ -1505,7 +1926,7 @@ function hookActivationAdvice(installations: Installation[]): string {
|
|
|
1505
1926
|
}
|
|
1506
1927
|
if (harnesses.has('opencode')) {
|
|
1507
1928
|
advice.push(
|
|
1508
|
-
'OpenCode: restart it, then send one prompt; plugins load at startup,
|
|
1929
|
+
'OpenCode: restart it, then send one prompt; plugins load at startup, but non-blocking question continuation is intentionally unsupported',
|
|
1509
1930
|
)
|
|
1510
1931
|
}
|
|
1511
1932
|
return `${advice.join('. ')}.`
|
|
@@ -1513,7 +1934,7 @@ function hookActivationAdvice(installations: Installation[]): string {
|
|
|
1513
1934
|
|
|
1514
1935
|
/** Retire a question so a late answer is rejected rather than silently lost. */
|
|
1515
1936
|
export async function closeCommand(deps: CommandDeps, requestId: string): Promise<number> {
|
|
1516
|
-
const config =
|
|
1937
|
+
const config = loadLoggedConfig(deps, { cwd: deps.cwd, env: deps.env })
|
|
1517
1938
|
const authed = authedClient(deps, config)
|
|
1518
1939
|
if (!authed) return EXIT.auth
|
|
1519
1940
|
try {
|
|
@@ -1538,43 +1959,41 @@ export function hooksInstallCommand(deps: CommandDeps, flags: HooksInstallFlags)
|
|
|
1538
1959
|
if (!harness) return EXIT.usage
|
|
1539
1960
|
const execPath = flags.execPath ?? process.execPath
|
|
1540
1961
|
const scriptPath = flags.scriptPath ?? process.argv[1] ?? 'notifai'
|
|
1541
|
-
|
|
1962
|
+
let adapterPath: string
|
|
1963
|
+
try {
|
|
1964
|
+
adapterPath = installHookAdapter({ execPath, scriptPath }, deps.hookAdapterHome).path
|
|
1965
|
+
} catch (err) {
|
|
1966
|
+
deps.io.err(`Could not prepare the stable hook adapter: ${String(err)}`)
|
|
1967
|
+
return EXIT.failed
|
|
1968
|
+
}
|
|
1969
|
+
const config = loadLoggedConfig(deps, { cwd: deps.cwd, env: deps.env })
|
|
1542
1970
|
const file = settingsFile(harness, flags.global ?? false, deps.cwd, deps.env)
|
|
1543
1971
|
|
|
1544
1972
|
// OpenCode's adapter is a generated plugin module rather than a handler
|
|
1545
1973
|
// merged into a settings document, so it owns the whole file.
|
|
1546
1974
|
if (harness === 'opencode') {
|
|
1547
1975
|
return installOpencodePlugin(deps, file, {
|
|
1548
|
-
|
|
1549
|
-
|
|
1550
|
-
timeoutSeconds: blockingHookTimeoutSeconds(
|
|
1551
|
-
config.ask_grace_seconds.value,
|
|
1552
|
-
config.hook_reply_timeout_seconds.value,
|
|
1553
|
-
),
|
|
1976
|
+
adapterPath,
|
|
1977
|
+
timeoutSeconds: blockingHookTimeoutSeconds(),
|
|
1554
1978
|
})
|
|
1555
1979
|
}
|
|
1556
1980
|
|
|
1557
1981
|
if (harness === 'cursor') {
|
|
1558
|
-
let
|
|
1559
|
-
try {
|
|
1560
|
-
document = loadCursorSettings(file)
|
|
1561
|
-
} catch (err) {
|
|
1562
|
-
deps.io.err(String(err))
|
|
1563
|
-
return EXIT.failed
|
|
1564
|
-
}
|
|
1565
|
-
const merged = mergeCursorHooks(
|
|
1566
|
-
document,
|
|
1567
|
-
buildCursorHookConfig({
|
|
1568
|
-
execPath,
|
|
1569
|
-
scriptPath,
|
|
1570
|
-
replyTimeoutSeconds: config.hook_reply_timeout_seconds.value,
|
|
1571
|
-
graceSeconds: config.ask_grace_seconds.value,
|
|
1572
|
-
harness: 'cursor',
|
|
1573
|
-
}),
|
|
1574
|
-
scriptPath,
|
|
1575
|
-
)
|
|
1982
|
+
let merged: ReturnType<typeof mergeCursorHooks>
|
|
1576
1983
|
try {
|
|
1577
|
-
|
|
1984
|
+
merged = withTargetFileLock(file, () => {
|
|
1985
|
+
const document = loadCursorSettings(file)
|
|
1986
|
+
const result = mergeCursorHooks(
|
|
1987
|
+
document,
|
|
1988
|
+
buildCursorHookConfig({
|
|
1989
|
+
adapterPath,
|
|
1990
|
+
harness: 'cursor',
|
|
1991
|
+
}),
|
|
1992
|
+
scriptPath,
|
|
1993
|
+
)
|
|
1994
|
+
applyPlan(file, result.document)
|
|
1995
|
+
return result
|
|
1996
|
+
})
|
|
1578
1997
|
} catch (err) {
|
|
1579
1998
|
deps.io.err(String(err))
|
|
1580
1999
|
return EXIT.failed
|
|
@@ -1589,31 +2008,27 @@ export function hooksInstallCommand(deps: CommandDeps, flags: HooksInstallFlags)
|
|
|
1589
2008
|
deps.io.out('Send one Cursor prompt, then check `notifai doctor`. If the hook has not fired,')
|
|
1590
2009
|
deps.io.out('start a new Cursor session and try one prompt again.')
|
|
1591
2010
|
}
|
|
1592
|
-
deps.io.out('
|
|
1593
|
-
deps.io.out('
|
|
2011
|
+
deps.io.out('Cursor hooks can emit a native follow-up, but its agent shell does not expose')
|
|
2012
|
+
deps.io.out('the exact conversation id needed to register safely. `notifai ask` therefore')
|
|
2013
|
+
deps.io.out('fails closed; use blocking `notifai send --reply` for questions.')
|
|
1594
2014
|
return EXIT.ok
|
|
1595
2015
|
}
|
|
1596
2016
|
|
|
1597
|
-
let
|
|
2017
|
+
let merged: ReturnType<typeof mergeHooks>
|
|
1598
2018
|
try {
|
|
1599
|
-
|
|
1600
|
-
|
|
1601
|
-
|
|
1602
|
-
|
|
1603
|
-
|
|
1604
|
-
|
|
1605
|
-
|
|
1606
|
-
|
|
1607
|
-
|
|
1608
|
-
|
|
1609
|
-
|
|
1610
|
-
|
|
1611
|
-
|
|
1612
|
-
}),
|
|
1613
|
-
scriptPath,
|
|
1614
|
-
)
|
|
1615
|
-
try {
|
|
1616
|
-
applyPlan(file, merged.document)
|
|
2019
|
+
merged = withTargetFileLock(file, () => {
|
|
2020
|
+
const document = loadSettings(file)
|
|
2021
|
+
const result = mergeHooks(
|
|
2022
|
+
document,
|
|
2023
|
+
buildHookConfig({
|
|
2024
|
+
adapterPath,
|
|
2025
|
+
harness,
|
|
2026
|
+
}),
|
|
2027
|
+
scriptPath,
|
|
2028
|
+
)
|
|
2029
|
+
applyPlan(file, result.document)
|
|
2030
|
+
return result
|
|
2031
|
+
})
|
|
1617
2032
|
} catch (err) {
|
|
1618
2033
|
deps.io.err(String(err))
|
|
1619
2034
|
return EXIT.failed
|
|
@@ -1626,18 +2041,22 @@ export function hooksInstallCommand(deps: CommandDeps, flags: HooksInstallFlags)
|
|
|
1626
2041
|
deps.io.out(` removed: ${merged.removed.join(', ')} (this build no longer serves them)`)
|
|
1627
2042
|
}
|
|
1628
2043
|
deps.io.out('')
|
|
1629
|
-
|
|
1630
|
-
|
|
1631
|
-
|
|
1632
|
-
|
|
1633
|
-
|
|
1634
|
-
|
|
1635
|
-
`
|
|
1636
|
-
|
|
1637
|
-
|
|
1638
|
-
|
|
1639
|
-
|
|
1640
|
-
|
|
2044
|
+
if (config.require_idle.value) {
|
|
2045
|
+
deps.io.out(
|
|
2046
|
+
`While keyboard or mouse idle time stays below ${config.away_after_seconds.value}s, ` +
|
|
2047
|
+
`nothing is pushed. A question registered with \`notifai ask\` goes to your devices ` +
|
|
2048
|
+
(config.ask_grace_seconds.value === 0
|
|
2049
|
+
? 'as soon as the machine meets that idle threshold. '
|
|
2050
|
+
: `once its ${config.ask_grace_seconds.value}s grace window, counted from registration, has elapsed and the machine meets that idle threshold. `) +
|
|
2051
|
+
`Run \`notifai config set require_idle false\` to be notified even while you are working.`,
|
|
2052
|
+
)
|
|
2053
|
+
} else {
|
|
2054
|
+
deps.io.out(
|
|
2055
|
+
config.ask_grace_seconds.value === 0
|
|
2056
|
+
? 'A question registered with `notifai ask` goes to your devices immediately when the agent turn ends, whether or not you are at this machine.'
|
|
2057
|
+
: `A question registered with \`notifai ask\` stays in the terminal for ${config.ask_grace_seconds.value}s from registration and then goes to your devices whether or not you are at this machine.`,
|
|
2058
|
+
)
|
|
2059
|
+
}
|
|
1641
2060
|
if (config.require_idle.value) {
|
|
1642
2061
|
deps.io.out(
|
|
1643
2062
|
'If this OS exposes no keyboard/mouse idle signal, the hook falls back to prompt silence and skips the blocking grace once it decides you are away.',
|
|
@@ -1681,18 +2100,23 @@ export function hooksInstallCommand(deps: CommandDeps, flags: HooksInstallFlags)
|
|
|
1681
2100
|
function installOpencodePlugin(
|
|
1682
2101
|
deps: CommandDeps,
|
|
1683
2102
|
file: string,
|
|
1684
|
-
options: {
|
|
2103
|
+
options: { adapterPath: string; timeoutSeconds: number },
|
|
1685
2104
|
): number {
|
|
1686
|
-
if (existsSync(file)) {
|
|
1687
|
-
const existing = readFileSync(file, 'utf8')
|
|
1688
|
-
if (!isOurOpencodePlugin(existing)) {
|
|
1689
|
-
deps.io.err(`${file} exists and was not written by Notifai; move it aside first.`)
|
|
1690
|
-
return EXIT.failed
|
|
1691
|
-
}
|
|
1692
|
-
}
|
|
1693
2105
|
try {
|
|
1694
|
-
|
|
1695
|
-
|
|
2106
|
+
withTargetFileLock(file, () => {
|
|
2107
|
+
if (existsSync(file)) {
|
|
2108
|
+
assertOwnedRegularFile(file)
|
|
2109
|
+
const existing = readFileSync(file, 'utf8')
|
|
2110
|
+
if (!isOurOpencodePlugin(existing)) {
|
|
2111
|
+
throw new Error(`${file} exists and was not written by Notifai; move it aside first.`)
|
|
2112
|
+
}
|
|
2113
|
+
}
|
|
2114
|
+
atomicWriteFileSync(file, opencodePluginSource(options), {
|
|
2115
|
+
mode: 0o600,
|
|
2116
|
+
preserveMode: false,
|
|
2117
|
+
requireCurrentUserOwner: true,
|
|
2118
|
+
})
|
|
2119
|
+
})
|
|
1696
2120
|
} catch (err) {
|
|
1697
2121
|
deps.io.err(String(err))
|
|
1698
2122
|
return EXIT.failed
|
|
@@ -1702,9 +2126,9 @@ function installOpencodePlugin(
|
|
|
1702
2126
|
deps.io.out('It maps chat.message to presence, session.idle to question escalation, and')
|
|
1703
2127
|
deps.io.out('session.deleted to local cleanup through the same `notifai hook` commands')
|
|
1704
2128
|
deps.io.out('the other harnesses run. Permission prompts stay in OpenCode.')
|
|
1705
|
-
deps.io.out('OpenCode
|
|
1706
|
-
deps.io.out('
|
|
1707
|
-
deps.io.out('
|
|
2129
|
+
deps.io.out('OpenCode does not expose a proven exactly-once continuation after session.idle,')
|
|
2130
|
+
deps.io.out('so `notifai ask` fails closed there. Use blocking `notifai send --reply` when')
|
|
2131
|
+
deps.io.out('the answer must return to the agent without another human prompt.')
|
|
1708
2132
|
deps.io.out('')
|
|
1709
2133
|
deps.io.out('Restart OpenCode: it loads plugins once at start.')
|
|
1710
2134
|
return EXIT.ok
|
|
@@ -1715,35 +2139,46 @@ export function hooksUninstallCommand(deps: CommandDeps, flags: HooksInstallFlag
|
|
|
1715
2139
|
if (!harness) return EXIT.usage
|
|
1716
2140
|
const scriptPath = flags.scriptPath ?? process.argv[1] ?? 'notifai'
|
|
1717
2141
|
const file = settingsFile(harness, flags.global ?? false, deps.cwd, deps.env)
|
|
1718
|
-
if (!existsSync(file)) {
|
|
1719
|
-
deps.io.out(`Nothing to remove: ${file} does not exist.`)
|
|
1720
|
-
return EXIT.ok
|
|
1721
|
-
}
|
|
1722
2142
|
if (harness === 'opencode') {
|
|
1723
|
-
// We own the whole file, but only if we wrote it.
|
|
1724
|
-
if (!isOurOpencodePlugin(readFileSync(file, 'utf8'))) {
|
|
1725
|
-
deps.io.out(`Left ${file} alone: Notifai did not write it.`)
|
|
1726
|
-
return EXIT.ok
|
|
1727
|
-
}
|
|
1728
|
-
rmSync(file, { force: true })
|
|
1729
|
-
deps.io.out(`Removed the Notifai OpenCode plugin at ${file}`)
|
|
1730
|
-
return EXIT.ok
|
|
1731
|
-
}
|
|
1732
|
-
if (harness === 'cursor') {
|
|
1733
|
-
let document
|
|
1734
2143
|
try {
|
|
1735
|
-
|
|
2144
|
+
return withTargetFileLock(file, () => {
|
|
2145
|
+
if (!existsSync(file)) {
|
|
2146
|
+
deps.io.out(`Nothing to remove: ${file} does not exist.`)
|
|
2147
|
+
return EXIT.ok
|
|
2148
|
+
}
|
|
2149
|
+
assertOwnedRegularFile(file)
|
|
2150
|
+
// We own the whole file, but only if we wrote it.
|
|
2151
|
+
if (!isOurOpencodePlugin(readFileSync(file, 'utf8'))) {
|
|
2152
|
+
deps.io.out(`Left ${file} alone: Notifai did not write it.`)
|
|
2153
|
+
return EXIT.ok
|
|
2154
|
+
}
|
|
2155
|
+
rmSync(file, { force: true })
|
|
2156
|
+
deps.io.out(`Removed the Notifai OpenCode plugin at ${file}`)
|
|
2157
|
+
return EXIT.ok
|
|
2158
|
+
})
|
|
1736
2159
|
} catch (err) {
|
|
1737
2160
|
deps.io.err(String(err))
|
|
1738
2161
|
return EXIT.failed
|
|
1739
2162
|
}
|
|
1740
|
-
|
|
2163
|
+
}
|
|
2164
|
+
if (harness === 'cursor') {
|
|
2165
|
+
let stripped: ReturnType<typeof removeCursorHooks> | null
|
|
1741
2166
|
try {
|
|
1742
|
-
|
|
2167
|
+
stripped = withTargetFileLock(file, () => {
|
|
2168
|
+
if (!existsSync(file)) return null
|
|
2169
|
+
const document = loadCursorSettings(file)
|
|
2170
|
+
const result = removeCursorHooks(document, scriptPath)
|
|
2171
|
+
applyPlan(file, result.document)
|
|
2172
|
+
return result
|
|
2173
|
+
})
|
|
1743
2174
|
} catch (err) {
|
|
1744
2175
|
deps.io.err(String(err))
|
|
1745
2176
|
return EXIT.failed
|
|
1746
2177
|
}
|
|
2178
|
+
if (stripped === null) {
|
|
2179
|
+
deps.io.out(`Nothing to remove: ${file} does not exist.`)
|
|
2180
|
+
return EXIT.ok
|
|
2181
|
+
}
|
|
1747
2182
|
deps.io.out(
|
|
1748
2183
|
stripped.replaced.length > 0
|
|
1749
2184
|
? `Removed Notifai hooks (${stripped.replaced.join(', ')}) from ${file}`
|
|
@@ -1751,19 +2186,22 @@ export function hooksUninstallCommand(deps: CommandDeps, flags: HooksInstallFlag
|
|
|
1751
2186
|
)
|
|
1752
2187
|
return EXIT.ok
|
|
1753
2188
|
}
|
|
1754
|
-
let
|
|
2189
|
+
let stripped: ReturnType<typeof removeHooks> | null
|
|
1755
2190
|
try {
|
|
1756
|
-
|
|
2191
|
+
stripped = withTargetFileLock(file, () => {
|
|
2192
|
+
if (!existsSync(file)) return null
|
|
2193
|
+
const document = loadSettings(file)
|
|
2194
|
+
const result = removeHooks(document, scriptPath)
|
|
2195
|
+
applyPlan(file, result.document)
|
|
2196
|
+
return result
|
|
2197
|
+
})
|
|
1757
2198
|
} catch (err) {
|
|
1758
2199
|
deps.io.err(String(err))
|
|
1759
2200
|
return EXIT.failed
|
|
1760
2201
|
}
|
|
1761
|
-
|
|
1762
|
-
|
|
1763
|
-
|
|
1764
|
-
} catch (err) {
|
|
1765
|
-
deps.io.err(String(err))
|
|
1766
|
-
return EXIT.failed
|
|
2202
|
+
if (stripped === null) {
|
|
2203
|
+
deps.io.out(`Nothing to remove: ${file} does not exist.`)
|
|
2204
|
+
return EXIT.ok
|
|
1767
2205
|
}
|
|
1768
2206
|
deps.io.out(
|
|
1769
2207
|
stripped.replaced.length > 0
|
|
@@ -1773,6 +2211,17 @@ export function hooksUninstallCommand(deps: CommandDeps, flags: HooksInstallFlag
|
|
|
1773
2211
|
return EXIT.ok
|
|
1774
2212
|
}
|
|
1775
2213
|
|
|
2214
|
+
function assertOwnedRegularFile(file: string): void {
|
|
2215
|
+
const stat = lstatSync(file)
|
|
2216
|
+
if (stat.isSymbolicLink() || !stat.isFile()) {
|
|
2217
|
+
throw new Error(`${file} is not a regular file; refusing to read or replace it.`)
|
|
2218
|
+
}
|
|
2219
|
+
const uid = typeof process.getuid === 'function' ? process.getuid() : undefined
|
|
2220
|
+
if (uid !== undefined && stat.uid !== uid) {
|
|
2221
|
+
throw new Error(`${file} is owned by uid ${stat.uid}, not the current user.`)
|
|
2222
|
+
}
|
|
2223
|
+
}
|
|
2224
|
+
|
|
1776
2225
|
function resolveHarness(deps: CommandDeps, requested: string | undefined): Harness | null {
|
|
1777
2226
|
if (requested !== undefined) {
|
|
1778
2227
|
if ((HARNESSES as readonly string[]).includes(requested)) return requested as Harness
|
|
@@ -1790,14 +2239,14 @@ function resolveHarness(deps: CommandDeps, requested: string | undefined): Harne
|
|
|
1790
2239
|
}
|
|
1791
2240
|
|
|
1792
2241
|
// ---------------------------------------------------------------------------
|
|
1793
|
-
// config show / set
|
|
2242
|
+
// config show / set / unset
|
|
1794
2243
|
// ---------------------------------------------------------------------------
|
|
1795
2244
|
|
|
1796
2245
|
export function configShowCommand(
|
|
1797
2246
|
deps: CommandDeps,
|
|
1798
2247
|
flags: { json?: boolean; explain?: boolean; plain?: boolean },
|
|
1799
2248
|
): number {
|
|
1800
|
-
const config =
|
|
2249
|
+
const config = loadLoggedConfig(deps, { cwd: deps.cwd, env: deps.env })
|
|
1801
2250
|
if (flags.json) {
|
|
1802
2251
|
const output = Object.fromEntries(
|
|
1803
2252
|
CONFIG_KEYS.map((key) => [key, { value: config[key].value, source: config[key].source }]),
|
|
@@ -1821,9 +2270,10 @@ export function configShowCommand(
|
|
|
1821
2270
|
/**
|
|
1822
2271
|
* One setting, explained in full.
|
|
1823
2272
|
*
|
|
1824
|
-
* The gap this closes: `config show` prints
|
|
1825
|
-
*
|
|
1826
|
-
* TypeScript comment, read by everyone except the
|
|
2273
|
+
* The gap this closes: `config show` prints a value and provenance but that is
|
|
2274
|
+
* not enough to explain the setting's consequences. Every key already had a
|
|
2275
|
+
* careful explanation — in a TypeScript comment, read by everyone except the
|
|
2276
|
+
* person who needed it.
|
|
1827
2277
|
*/
|
|
1828
2278
|
export function configExplainCommand(
|
|
1829
2279
|
deps: CommandDeps,
|
|
@@ -1836,7 +2286,7 @@ export function configExplainCommand(
|
|
|
1836
2286
|
return EXIT.usage
|
|
1837
2287
|
}
|
|
1838
2288
|
const configKey = key as ConfigKey
|
|
1839
|
-
const config =
|
|
2289
|
+
const config = loadLoggedConfig(deps, { cwd: deps.cwd, env: deps.env })
|
|
1840
2290
|
const info = configInfo(configKey)
|
|
1841
2291
|
const entry = config[configKey]
|
|
1842
2292
|
|
|
@@ -1916,15 +2366,8 @@ export async function configSetCommand(
|
|
|
1916
2366
|
rawValue: string,
|
|
1917
2367
|
flags: { project?: boolean; local?: boolean; session?: string; yes?: boolean },
|
|
1918
2368
|
): Promise<number> {
|
|
1919
|
-
|
|
1920
|
-
|
|
1921
|
-
const near = nearestKey(key)
|
|
1922
|
-
if (near !== null) deps.io.err(`Did you mean "${near}"?`)
|
|
1923
|
-
deps.io.err(`Valid settings: ${CONFIG_KEYS.join(', ')}`)
|
|
1924
|
-
deps.io.err('Run `notifai config explain <key>` to see what one of them does.')
|
|
1925
|
-
return EXIT.usage
|
|
1926
|
-
}
|
|
1927
|
-
const configKey = key as ConfigKey
|
|
2369
|
+
const configKey = configMutationKey(deps, key)
|
|
2370
|
+
if (configKey === null) return EXIT.usage
|
|
1928
2371
|
const info = configInfo(configKey)
|
|
1929
2372
|
let value: unknown = rawValue
|
|
1930
2373
|
if (NUMERIC_CONFIG_KEYS.includes(configKey)) {
|
|
@@ -1956,6 +2399,53 @@ export async function configSetCommand(
|
|
|
1956
2399
|
}
|
|
1957
2400
|
if (key === 'devices') value = rawValue.split(',').map((s) => s.trim()).filter(Boolean)
|
|
1958
2401
|
|
|
2402
|
+
const target = await configMutationTarget(deps, flags)
|
|
2403
|
+
if (target === null) return EXIT.usage
|
|
2404
|
+
if (target.layer === 'global' && Object.is(value, configDefaultValue(configKey))) {
|
|
2405
|
+
deps.io.err(`${key} is already the shipped default (${JSON.stringify(value)}).`)
|
|
2406
|
+
deps.io.err(
|
|
2407
|
+
`Run \`notifai config unset ${key} --yes\` to remove a redundant override instead of creating one.`,
|
|
2408
|
+
)
|
|
2409
|
+
return EXIT.usage
|
|
2410
|
+
}
|
|
2411
|
+
|
|
2412
|
+
if (!flags.yes) {
|
|
2413
|
+
const confirmed = await deps.io.confirm(`Set ${key} = ${JSON.stringify(value)} in ${target.path}?`)
|
|
2414
|
+
if (!confirmed) {
|
|
2415
|
+
deps.io.err('Not confirmed. Pass --yes to skip the confirmation gate.')
|
|
2416
|
+
return EXIT.usage
|
|
2417
|
+
}
|
|
2418
|
+
}
|
|
2419
|
+
|
|
2420
|
+
const existing = existsSync(target.path)
|
|
2421
|
+
? (parseToml(readFileSync(target.path, 'utf8')) as Record<string, unknown>)
|
|
2422
|
+
: {}
|
|
2423
|
+
existing[key] = value
|
|
2424
|
+
mkdirSync(path.dirname(target.path), { recursive: true })
|
|
2425
|
+
writeFileSync(target.path, `${stringifyToml(existing)}\n`)
|
|
2426
|
+
deps.io.out(`Wrote ${key} to ${target.path}`)
|
|
2427
|
+
return EXIT.ok
|
|
2428
|
+
}
|
|
2429
|
+
|
|
2430
|
+
function configMutationKey(deps: CommandDeps, key: string): ConfigKey | null {
|
|
2431
|
+
if (!(CONFIG_KEYS as readonly string[]).includes(key)) {
|
|
2432
|
+
deps.io.err(`Unknown setting "${key}".`)
|
|
2433
|
+
const near = nearestKey(key)
|
|
2434
|
+
if (near !== null) deps.io.err(`Did you mean "${near}"?`)
|
|
2435
|
+
deps.io.err(`Valid settings: ${CONFIG_KEYS.join(', ')}`)
|
|
2436
|
+
deps.io.err('Run `notifai config explain <key>` to see what one of them does.')
|
|
2437
|
+
return null
|
|
2438
|
+
}
|
|
2439
|
+
return key as ConfigKey
|
|
2440
|
+
}
|
|
2441
|
+
|
|
2442
|
+
type ConfigMutationFlags = { project?: boolean; local?: boolean; session?: string; yes?: boolean }
|
|
2443
|
+
type ConfigMutationLayer = 'global' | 'project' | 'local' | 'session'
|
|
2444
|
+
|
|
2445
|
+
async function configMutationTarget(
|
|
2446
|
+
deps: CommandDeps,
|
|
2447
|
+
flags: ConfigMutationFlags,
|
|
2448
|
+
): Promise<{ path: string; layer: ConfigMutationLayer } | null> {
|
|
1959
2449
|
let layer = flags.local ? 'local' : flags.project ? 'project' : 'global'
|
|
1960
2450
|
if (
|
|
1961
2451
|
flags.session === undefined &&
|
|
@@ -1972,7 +2462,7 @@ export async function configSetCommand(
|
|
|
1972
2462
|
])
|
|
1973
2463
|
if (selected === null) {
|
|
1974
2464
|
deps.io.err('No configuration layer selected.')
|
|
1975
|
-
return
|
|
2465
|
+
return null
|
|
1976
2466
|
}
|
|
1977
2467
|
layer = selected
|
|
1978
2468
|
}
|
|
@@ -1984,22 +2474,273 @@ export async function configSetCommand(
|
|
|
1984
2474
|
: layer === 'project'
|
|
1985
2475
|
? (findProjectConfigPath(deps.cwd) ?? path.join(deps.cwd, '.notifai', 'config.toml'))
|
|
1986
2476
|
: globalConfigPath(deps.env)
|
|
2477
|
+
return {
|
|
2478
|
+
path: targetPath,
|
|
2479
|
+
layer: flags.session ? 'session' : (layer as Exclude<ConfigMutationLayer, 'session'>),
|
|
2480
|
+
}
|
|
2481
|
+
}
|
|
1987
2482
|
|
|
2483
|
+
export async function configUnsetCommand(
|
|
2484
|
+
deps: CommandDeps,
|
|
2485
|
+
key: string,
|
|
2486
|
+
flags: ConfigMutationFlags,
|
|
2487
|
+
): Promise<number> {
|
|
2488
|
+
const configKey = configMutationKey(deps, key)
|
|
2489
|
+
if (configKey === null) return EXIT.usage
|
|
2490
|
+
const target = await configMutationTarget(deps, flags)
|
|
2491
|
+
if (target === null) return EXIT.usage
|
|
2492
|
+
const existing = existsSync(target.path)
|
|
2493
|
+
? (parseToml(readFileSync(target.path, 'utf8')) as Record<string, unknown>)
|
|
2494
|
+
: {}
|
|
2495
|
+
if (!Object.prototype.hasOwnProperty.call(existing, configKey)) {
|
|
2496
|
+
deps.io.out(`${configKey} is not set in ${target.path}`)
|
|
2497
|
+
return EXIT.ok
|
|
2498
|
+
}
|
|
1988
2499
|
if (!flags.yes) {
|
|
1989
|
-
const confirmed = await deps.io.confirm(`
|
|
2500
|
+
const confirmed = await deps.io.confirm(`Remove ${configKey} from ${target.path}?`)
|
|
1990
2501
|
if (!confirmed) {
|
|
1991
2502
|
deps.io.err('Not confirmed. Pass --yes to skip the confirmation gate.')
|
|
1992
2503
|
return EXIT.usage
|
|
1993
2504
|
}
|
|
1994
2505
|
}
|
|
1995
2506
|
|
|
1996
|
-
|
|
1997
|
-
|
|
1998
|
-
:
|
|
1999
|
-
|
|
2000
|
-
|
|
2001
|
-
|
|
2002
|
-
deps.io.out(`
|
|
2507
|
+
delete existing[configKey]
|
|
2508
|
+
if (Object.keys(existing).length === 0) {
|
|
2509
|
+
rmSync(target.path, { force: true })
|
|
2510
|
+
} else {
|
|
2511
|
+
writeFileSync(target.path, `${stringifyToml(existing)}\n`)
|
|
2512
|
+
}
|
|
2513
|
+
deps.io.out(`Removed ${configKey} from ${target.path}; the inherited value now applies.`)
|
|
2514
|
+
return EXIT.ok
|
|
2515
|
+
}
|
|
2516
|
+
|
|
2517
|
+
// ---------------------------------------------------------------------------
|
|
2518
|
+
// logs
|
|
2519
|
+
// ---------------------------------------------------------------------------
|
|
2520
|
+
|
|
2521
|
+
export interface LogsFlags {
|
|
2522
|
+
json?: boolean
|
|
2523
|
+
limit?: number
|
|
2524
|
+
all?: boolean
|
|
2525
|
+
since?: string
|
|
2526
|
+
level?: string
|
|
2527
|
+
event?: string[]
|
|
2528
|
+
run?: string
|
|
2529
|
+
request?: string
|
|
2530
|
+
session?: string
|
|
2531
|
+
project?: string
|
|
2532
|
+
allProjects?: boolean
|
|
2533
|
+
grep?: string
|
|
2534
|
+
path?: boolean
|
|
2535
|
+
clear?: boolean
|
|
2536
|
+
}
|
|
2537
|
+
|
|
2538
|
+
const LOG_RECORD_OPTIONS: readonly (readonly [keyof LogsFlags, string])[] = [
|
|
2539
|
+
['limit', '--limit'],
|
|
2540
|
+
['all', '--all'],
|
|
2541
|
+
['since', '--since'],
|
|
2542
|
+
['level', '--level'],
|
|
2543
|
+
['event', '--event'],
|
|
2544
|
+
['run', '--run'],
|
|
2545
|
+
['request', '--request'],
|
|
2546
|
+
['session', '--session'],
|
|
2547
|
+
['project', '--project'],
|
|
2548
|
+
['allProjects', '--all-projects'],
|
|
2549
|
+
['grep', '--grep'],
|
|
2550
|
+
]
|
|
2551
|
+
|
|
2552
|
+
/**
|
|
2553
|
+
* Deliberately small. The reader is usually a model with a finite context, and
|
|
2554
|
+
* a log command whose default answer is ten thousand lines gets used once.
|
|
2555
|
+
* Everything beyond this is reachable by asking for it.
|
|
2556
|
+
*/
|
|
2557
|
+
const DEFAULT_LOG_LIMIT = 30
|
|
2558
|
+
/** Even `--all` stops somewhere; an unbounded dump is never the useful answer. */
|
|
2559
|
+
const MAX_LOG_LIMIT = 2_000
|
|
2560
|
+
|
|
2561
|
+
/** `10m`, `2h`, `1d`, or an ISO 8601 instant. */
|
|
2562
|
+
export function parseSince(raw: string, now: number): number | null {
|
|
2563
|
+
const relative = /^(\d+)([smhd])$/.exec(raw.trim())
|
|
2564
|
+
if (relative !== null) {
|
|
2565
|
+
const amount = Number(relative[1])
|
|
2566
|
+
const unit = relative[2] as 's' | 'm' | 'h' | 'd'
|
|
2567
|
+
const seconds = { s: 1, m: 60, h: 3600, d: 86400 }[unit]
|
|
2568
|
+
return now - amount * seconds * 1000
|
|
2569
|
+
}
|
|
2570
|
+
const absolute = Date.parse(raw)
|
|
2571
|
+
return Number.isNaN(absolute) ? null : absolute
|
|
2572
|
+
}
|
|
2573
|
+
|
|
2574
|
+
/**
|
|
2575
|
+
* What this machine recorded about itself.
|
|
2576
|
+
*
|
|
2577
|
+
* The retrieval half of the local log, and the half that decides whether the
|
|
2578
|
+
* log is worth having. Three things make it usable by an agent rather than
|
|
2579
|
+
* merely available to one: the default is bounded and scoped to this project,
|
|
2580
|
+
* the filters match the questions actually asked ("that run", "that
|
|
2581
|
+
* notification", "just the failures"), and `--json` gives the machine the
|
|
2582
|
+
* records untouched on stdout while every word of explanation goes to stderr.
|
|
2583
|
+
*/
|
|
2584
|
+
export function logsCommand(deps: CommandDeps, flags: LogsFlags): number {
|
|
2585
|
+
const selectedRetrievalOptions = LOG_RECORD_OPTIONS
|
|
2586
|
+
.filter(([key]) => {
|
|
2587
|
+
const value = flags[key]
|
|
2588
|
+
return Array.isArray(value) ? value.length > 0 : value !== undefined && value !== false
|
|
2589
|
+
})
|
|
2590
|
+
.map(([, name]) => name)
|
|
2591
|
+
|
|
2592
|
+
if (flags.path === true && flags.clear === true) {
|
|
2593
|
+
deps.io.err('Pass --path or --clear, not both.')
|
|
2594
|
+
return EXIT.usage
|
|
2595
|
+
}
|
|
2596
|
+
if (flags.path === true && selectedRetrievalOptions.length > 0) {
|
|
2597
|
+
deps.io.err(`--path cannot be combined with record options: ${selectedRetrievalOptions.join(', ')}.`)
|
|
2598
|
+
return EXIT.usage
|
|
2599
|
+
}
|
|
2600
|
+
if (flags.clear === true && selectedRetrievalOptions.length > 0) {
|
|
2601
|
+
deps.io.err(`--clear cannot be combined with record options: ${selectedRetrievalOptions.join(', ')}.`)
|
|
2602
|
+
return EXIT.usage
|
|
2603
|
+
}
|
|
2604
|
+
if (flags.project !== undefined && flags.allProjects === true) {
|
|
2605
|
+
deps.io.err('Pass --project or --all-projects, not both.')
|
|
2606
|
+
return EXIT.usage
|
|
2607
|
+
}
|
|
2608
|
+
if (flags.limit !== undefined && flags.all === true) {
|
|
2609
|
+
deps.io.err('Pass --limit or --all, not both.')
|
|
2610
|
+
return EXIT.usage
|
|
2611
|
+
}
|
|
2612
|
+
if (flags.limit !== undefined && (!Number.isInteger(flags.limit) || flags.limit <= 0)) {
|
|
2613
|
+
deps.io.err('--limit must be a positive integer.')
|
|
2614
|
+
return EXIT.usage
|
|
2615
|
+
}
|
|
2616
|
+
if (flags.limit !== undefined && flags.limit > MAX_LOG_LIMIT) {
|
|
2617
|
+
deps.io.err(`--limit cannot exceed ${MAX_LOG_LIMIT}; narrow the result with --since or filters.`)
|
|
2618
|
+
return EXIT.usage
|
|
2619
|
+
}
|
|
2620
|
+
|
|
2621
|
+
const config = loadLoggedConfig(deps, { cwd: deps.cwd, env: deps.env })
|
|
2622
|
+
const now = (deps.now ?? Date.now)()
|
|
2623
|
+
|
|
2624
|
+
if (flags.path === true) {
|
|
2625
|
+
const usage = logsDiskUsage(deps.env)
|
|
2626
|
+
const archives = archiveLogPaths(deps.env)
|
|
2627
|
+
if (flags.json === true) {
|
|
2628
|
+
deps.io.out(
|
|
2629
|
+
JSON.stringify(
|
|
2630
|
+
{
|
|
2631
|
+
active: activeLogPath(deps.env),
|
|
2632
|
+
archives,
|
|
2633
|
+
files: usage.files,
|
|
2634
|
+
bytes: usage.bytes,
|
|
2635
|
+
level: config.log_level.value,
|
|
2636
|
+
max_bytes: config.log_max_bytes.value,
|
|
2637
|
+
max_files: config.log_max_files.value,
|
|
2638
|
+
local_only: true,
|
|
2639
|
+
},
|
|
2640
|
+
null,
|
|
2641
|
+
2,
|
|
2642
|
+
),
|
|
2643
|
+
)
|
|
2644
|
+
return EXIT.ok
|
|
2645
|
+
}
|
|
2646
|
+
deps.io.out(activeLogPath(deps.env))
|
|
2647
|
+
for (const archive of archives) deps.io.out(archive)
|
|
2648
|
+
deps.io.err(
|
|
2649
|
+
`${usage.files} file(s), ${Math.round(usage.bytes / 1024)} KB, level ${config.log_level.value}. These stay on this machine.`,
|
|
2650
|
+
)
|
|
2651
|
+
return EXIT.ok
|
|
2652
|
+
}
|
|
2653
|
+
|
|
2654
|
+
if (flags.clear === true) {
|
|
2655
|
+
let removed = 0
|
|
2656
|
+
for (const file of [activeLogPath(deps.env), ...archiveLogPaths(deps.env)]) {
|
|
2657
|
+
try {
|
|
2658
|
+
unlinkSync(file)
|
|
2659
|
+
removed += 1
|
|
2660
|
+
} catch {
|
|
2661
|
+
// Absent, or not ours to remove; either way there is nothing to report.
|
|
2662
|
+
}
|
|
2663
|
+
}
|
|
2664
|
+
deps.io.out(
|
|
2665
|
+
flags.json === true
|
|
2666
|
+
? JSON.stringify({ cleared_files: removed })
|
|
2667
|
+
: `Cleared ${removed} log file${removed === 1 ? '' : 's'}.`,
|
|
2668
|
+
)
|
|
2669
|
+
return EXIT.ok
|
|
2670
|
+
}
|
|
2671
|
+
|
|
2672
|
+
const query: LogQuery = {
|
|
2673
|
+
limit: flags.limit ?? (flags.all === true ? MAX_LOG_LIMIT : DEFAULT_LOG_LIMIT),
|
|
2674
|
+
}
|
|
2675
|
+
|
|
2676
|
+
if (flags.since !== undefined) {
|
|
2677
|
+
const since = parseSince(flags.since, now)
|
|
2678
|
+
if (since === null) {
|
|
2679
|
+
deps.io.err(`Could not read "${flags.since}" as a time. Use 10m, 2h, 1d, or an ISO 8601 instant.`)
|
|
2680
|
+
return EXIT.usage
|
|
2681
|
+
}
|
|
2682
|
+
query.since = since
|
|
2683
|
+
}
|
|
2684
|
+
if (flags.level !== undefined) {
|
|
2685
|
+
if (!(LOG_LEVELS as readonly string[]).includes(flags.level)) {
|
|
2686
|
+
deps.io.err(`--level takes one of: ${LOG_LEVELS.join(', ')} — not "${flags.level}".`)
|
|
2687
|
+
return EXIT.usage
|
|
2688
|
+
}
|
|
2689
|
+
query.level = flags.level as LogLevel
|
|
2690
|
+
}
|
|
2691
|
+
if (flags.event !== undefined && flags.event.length > 0) {
|
|
2692
|
+
const unknown = flags.event.filter((name) => !(LOG_EVENTS as readonly string[]).includes(name))
|
|
2693
|
+
if (unknown.length > 0) {
|
|
2694
|
+
deps.io.err(`Unknown event ${unknown.join(', ')}. Valid: ${LOG_EVENTS.join(', ')}`)
|
|
2695
|
+
return EXIT.usage
|
|
2696
|
+
}
|
|
2697
|
+
query.event = flags.event
|
|
2698
|
+
}
|
|
2699
|
+
if (flags.run !== undefined) query.run = flags.run
|
|
2700
|
+
if (flags.session !== undefined) query.session = flags.session
|
|
2701
|
+
if (flags.request !== undefined) query.request = flags.request
|
|
2702
|
+
if (flags.grep !== undefined) query.contains = flags.grep
|
|
2703
|
+
|
|
2704
|
+
// Scoped to this project by default. A machine commonly runs several agents
|
|
2705
|
+
// in several worktrees at once, and the answer to "what just happened" is
|
|
2706
|
+
// useless if three other projects are interleaved through it.
|
|
2707
|
+
const scope =
|
|
2708
|
+
flags.project ?? (flags.allProjects === true ? undefined : (config.project.value ?? undefined))
|
|
2709
|
+
if (scope !== undefined) query.project = scope
|
|
2710
|
+
|
|
2711
|
+
const result = readLogRecords(deps.env, query)
|
|
2712
|
+
|
|
2713
|
+
if (flags.json === true) {
|
|
2714
|
+
for (const record of result.records) deps.io.out(JSON.stringify(record))
|
|
2715
|
+
} else {
|
|
2716
|
+
for (const record of result.records) deps.io.out(renderRecord(record))
|
|
2717
|
+
}
|
|
2718
|
+
|
|
2719
|
+
if (result.records.length === 0) {
|
|
2720
|
+
if (config.log_level.value === 'off') {
|
|
2721
|
+
deps.io.err('Nothing is being recorded: log_level is off.')
|
|
2722
|
+
deps.io.err('Turn it on with `notifai config set log_level info --yes`.')
|
|
2723
|
+
} else if (logsDiskUsage(deps.env).files === 0) {
|
|
2724
|
+
deps.io.err('No log yet. It is written the next time a notifai command or harness hook runs.')
|
|
2725
|
+
} else {
|
|
2726
|
+
deps.io.err('No records matched. Widen it with --all-projects, --since 1d, or --all.')
|
|
2727
|
+
}
|
|
2728
|
+
return EXIT.ok
|
|
2729
|
+
}
|
|
2730
|
+
|
|
2731
|
+
// Everything explanatory goes to stderr so `--json` leaves stdout as clean
|
|
2732
|
+
// JSONL for whatever is parsing it.
|
|
2733
|
+
const notes: string[] = []
|
|
2734
|
+
if (scope !== undefined) notes.push(`project ${scope} (--all-projects for every project)`)
|
|
2735
|
+
if (result.more) {
|
|
2736
|
+
notes.push(
|
|
2737
|
+
query.limit! < MAX_LOG_LIMIT
|
|
2738
|
+
? `more history behind this (-n ${Math.min(query.limit! * 4, MAX_LOG_LIMIT)} or --all)`
|
|
2739
|
+
: `more history exists beyond the ${MAX_LOG_LIMIT}-record safety cap; narrow with --since or filters`,
|
|
2740
|
+
)
|
|
2741
|
+
}
|
|
2742
|
+
if (config.log_level.value !== 'debug') notes.push('`log_level = debug` records request detail')
|
|
2743
|
+
if (notes.length > 0) deps.io.err(`— ${result.records.length} records; ${notes.join('; ')}`)
|
|
2003
2744
|
return EXIT.ok
|
|
2004
2745
|
}
|
|
2005
2746
|
|
|
@@ -2012,7 +2753,7 @@ export async function configSetCommand(
|
|
|
2012
2753
|
* 1.5.x, `owner/repo@name` selects a skill; a Git ref belongs after `#`.
|
|
2013
2754
|
* Keep this immutable and public because the command is printed to users.
|
|
2014
2755
|
*/
|
|
2015
|
-
export const SKILLS_SOURCE = 'Raidiant-io/notifai#v0.
|
|
2756
|
+
export const SKILLS_SOURCE = 'Raidiant-io/notifai#v0.4.0'
|
|
2016
2757
|
|
|
2017
2758
|
function skillSourceParts(): { source: string; ref: string } | null {
|
|
2018
2759
|
const match = /^([^#]+)#(.+)$/.exec(SKILLS_SOURCE)
|
|
@@ -2330,7 +3071,7 @@ async function waitForReadyDevice(deps: CommandDeps, state: ReadinessState): Pro
|
|
|
2330
3071
|
const remedy = state.remedy
|
|
2331
3072
|
if (deps.io.interactive !== true || remedy?.by !== 'user-elsewhere') return 'pending'
|
|
2332
3073
|
|
|
2333
|
-
const config =
|
|
3074
|
+
const config = loadLoggedConfig(deps, { cwd: deps.cwd, env: deps.env })
|
|
2334
3075
|
const authed = authedClient(deps, config)
|
|
2335
3076
|
if (!authed) {
|
|
2336
3077
|
deps.io.err('Could not start the companion-device wait: this machine is not signed in.')
|
|
@@ -2481,7 +3222,7 @@ async function submitSetupProof(
|
|
|
2481
3222
|
* the push, not that a companion process received it.
|
|
2482
3223
|
*/
|
|
2483
3224
|
async function runSetupProof(deps: CommandDeps): Promise<GapCloseResult> {
|
|
2484
|
-
const config =
|
|
3225
|
+
const config = loadLoggedConfig(deps, { cwd: deps.cwd, env: deps.env })
|
|
2485
3226
|
const authed = authedClient(deps, config)
|
|
2486
3227
|
if (!authed) return 'failed'
|
|
2487
3228
|
|
|
@@ -2849,7 +3590,7 @@ export async function assessReadiness(
|
|
|
2849
3590
|
deps: CommandDeps,
|
|
2850
3591
|
options: { skillScope?: SkillScope } = {},
|
|
2851
3592
|
): Promise<Readiness> {
|
|
2852
|
-
const config =
|
|
3593
|
+
const config = loadLoggedConfig(deps, { cwd: deps.cwd, env: deps.env })
|
|
2853
3594
|
const states: ReadinessState[] = []
|
|
2854
3595
|
let accountClient: ApiClient | null = null
|
|
2855
3596
|
let accountDevices: RoutableDevice[] | null = null
|
|
@@ -3234,23 +3975,21 @@ function remedyLine(state: ReadinessState): string {
|
|
|
3234
3975
|
*
|
|
3235
3976
|
* Not everything failed is in the way. A pointer that has never been
|
|
3236
3977
|
* published is the normal condition of an install thirty seconds old — the
|
|
3237
|
-
* next prompt fixes it and no command can
|
|
3238
|
-
*
|
|
3239
|
-
*
|
|
3240
|
-
* finish for an OpenCode user, or could only finish after a session had
|
|
3241
|
-
* already run — so they report as things worth knowing rather than things to
|
|
3978
|
+
* next prompt fixes it and no command can. Treating that as blocking would
|
|
3979
|
+
* mean `init` could only finish after a session had already run — so it
|
|
3980
|
+
* reports as something worth knowing rather than something to
|
|
3242
3981
|
* fix, and `init` walks on to the states it can actually close, delivery
|
|
3243
3982
|
* proof included. Which failures are informational, and what the true remedy
|
|
3244
3983
|
* is, is each check's own call (`HookCheck`).
|
|
3245
3984
|
*/
|
|
3246
3985
|
function hookStates(deps: CommandDeps): ReadinessState[] {
|
|
3247
|
-
const installations = findInstallations(deps.cwd, deps.env)
|
|
3986
|
+
const installations = findInstallations(deps.cwd, deps.env, deps.hookAdapterHome)
|
|
3248
3987
|
const active = activeHarnessSession(deps.env)
|
|
3249
|
-
const config =
|
|
3988
|
+
const config = loadLoggedConfig(deps, { cwd: deps.cwd, env: deps.env })
|
|
3250
3989
|
const settings: ReadinessState = {
|
|
3251
3990
|
id: 'question-routing-settings',
|
|
3252
3991
|
title: 'Question routing settings',
|
|
3253
|
-
status: 'ready',
|
|
3992
|
+
status: config.ask_notifications.value ? 'ready' : 'gap',
|
|
3254
3993
|
detail: [
|
|
3255
3994
|
`ask_notifications=${config.ask_notifications.value} (${config.ask_notifications.source})`,
|
|
3256
3995
|
`require_idle=${config.require_idle.value} (${config.require_idle.source})`,
|
|
@@ -3258,6 +3997,15 @@ function hookStates(deps: CommandDeps): ReadinessState[] {
|
|
|
3258
3997
|
`ask_grace_seconds=${config.ask_grace_seconds.value} (${config.ask_grace_seconds.source})`,
|
|
3259
3998
|
`hook_reply_timeout_seconds=${config.hook_reply_timeout_seconds.value} (${config.hook_reply_timeout_seconds.source})`,
|
|
3260
3999
|
].join(', '),
|
|
4000
|
+
...(config.ask_notifications.value
|
|
4001
|
+
? {}
|
|
4002
|
+
: {
|
|
4003
|
+
remedy: {
|
|
4004
|
+
by: 'cli' as const,
|
|
4005
|
+
summary: 'enable asynchronous question routing',
|
|
4006
|
+
command: 'notifai config set ask_notifications true',
|
|
4007
|
+
},
|
|
4008
|
+
}),
|
|
3261
4009
|
}
|
|
3262
4010
|
if (installations.length === 0) {
|
|
3263
4011
|
return [
|
|
@@ -3283,7 +4031,7 @@ function hookStates(deps: CommandDeps): ReadinessState[] {
|
|
|
3283
4031
|
}
|
|
3284
4032
|
|
|
3285
4033
|
/** Real but not in the way; see the note above. */
|
|
3286
|
-
const informational = new Set(
|
|
4034
|
+
const informational = new Set<string>()
|
|
3287
4035
|
return [
|
|
3288
4036
|
...hookChecks(deps).map((check) => ({
|
|
3289
4037
|
id: check.name.replace(/[ ()]+/g, '-').replace(/-$/, ''),
|
|
@@ -3328,7 +4076,7 @@ interface HookCheck {
|
|
|
3328
4076
|
|
|
3329
4077
|
function hookChecks(deps: CommandDeps): HookCheck[] {
|
|
3330
4078
|
const checks: HookCheck[] = []
|
|
3331
|
-
const installations = findInstallations(deps.cwd, deps.env)
|
|
4079
|
+
const installations = findInstallations(deps.cwd, deps.env, deps.hookAdapterHome)
|
|
3332
4080
|
|
|
3333
4081
|
// Not having hooks is a setup someone chose, not a fault: `send` works
|
|
3334
4082
|
// without them. A setup that cannot work is what deserves to go red.
|
|
@@ -3371,7 +4119,16 @@ function hookChecks(deps: CommandDeps): HookCheck[] {
|
|
|
3371
4119
|
}),
|
|
3372
4120
|
})
|
|
3373
4121
|
if (activeInstallations.length > 0) {
|
|
3374
|
-
const pointer =
|
|
4122
|
+
const pointer =
|
|
4123
|
+
active.sessionId === undefined
|
|
4124
|
+
? null
|
|
4125
|
+
: readMatchingProjectSessionPointer(
|
|
4126
|
+
deps.cwd,
|
|
4127
|
+
deps.env,
|
|
4128
|
+
(deps.now ?? Date.now)(),
|
|
4129
|
+
active.sessionId,
|
|
4130
|
+
active.harness,
|
|
4131
|
+
)
|
|
3375
4132
|
if (pointer === null) {
|
|
3376
4133
|
// The normal condition of hooks installed moments ago: the pointer
|
|
3377
4134
|
// appears when the harness next fires a hook, and no command can
|
|
@@ -3388,23 +4145,10 @@ function hookChecks(deps: CommandDeps): HookCheck[] {
|
|
|
3388
4145
|
},
|
|
3389
4146
|
})
|
|
3390
4147
|
} else {
|
|
3391
|
-
const matches =
|
|
3392
|
-
pointer.harness === active.harness &&
|
|
3393
|
-
(active.sessionId === undefined || pointer.sessionId === active.sessionId)
|
|
3394
4148
|
checks.push({
|
|
3395
4149
|
name: 'hooks (active session)',
|
|
3396
|
-
ok:
|
|
3397
|
-
detail:
|
|
3398
|
-
? `the project pointer belongs to the active ${active.label} session`
|
|
3399
|
-
: `the project pointer belongs to another ${active.label} session or harness; refusing cross-session routing`,
|
|
3400
|
-
...(matches
|
|
3401
|
-
? {}
|
|
3402
|
-
: {
|
|
3403
|
-
remedy: {
|
|
3404
|
-
summary: `send one ${active.label} prompt in this session so the pointer belongs to it`,
|
|
3405
|
-
command: 'notifai doctor',
|
|
3406
|
-
},
|
|
3407
|
-
}),
|
|
4150
|
+
ok: true,
|
|
4151
|
+
detail: `the concurrent project index contains the active ${active.label} session`,
|
|
3408
4152
|
})
|
|
3409
4153
|
}
|
|
3410
4154
|
}
|
|
@@ -3432,21 +4176,41 @@ function hookChecks(deps: CommandDeps): HookCheck[] {
|
|
|
3432
4176
|
const adapterProblems = installations.flatMap((installation) =>
|
|
3433
4177
|
(installation.problems ?? []).map((problem) => `${installation.file}: ${problem}`),
|
|
3434
4178
|
)
|
|
4179
|
+
const sharedAdapter = inspectHookAdapter(deps.hookAdapterHome)
|
|
4180
|
+
adapterProblems.push(...sharedAdapter.problems)
|
|
3435
4181
|
if (adapterProblems.length > 0) {
|
|
3436
4182
|
checks.push({
|
|
3437
4183
|
name: 'hooks (adapter)',
|
|
3438
4184
|
ok: false,
|
|
4185
|
+
// A machine-global install for a harness that is not active in this
|
|
4186
|
+
// project is useful diagnosis, but it must not block unrelated init.
|
|
4187
|
+
informational: active === null && installations.every((installation) => installation.global),
|
|
3439
4188
|
detail: adapterProblems.join('; '),
|
|
3440
4189
|
})
|
|
3441
4190
|
}
|
|
3442
4191
|
|
|
3443
|
-
const
|
|
3444
|
-
|
|
3445
|
-
|
|
3446
|
-
|
|
3447
|
-
|
|
4192
|
+
const trustProblems = codexTrustProblems(installations, deps.env)
|
|
4193
|
+
checks.push({
|
|
4194
|
+
name: 'hooks (trust)',
|
|
4195
|
+
ok: trustProblems.length === 0,
|
|
4196
|
+
detail:
|
|
4197
|
+
trustProblems.length === 0
|
|
4198
|
+
? 'best-effort check matches current persisted Codex approvals; Notifai never writes the trust store, and `/hooks` is authoritative'
|
|
4199
|
+
: `best-effort check only; Notifai never writes the trust store and \`/hooks\` is authoritative. ${trustProblems.join('; ')}`,
|
|
4200
|
+
...(trustProblems.length === 0
|
|
4201
|
+
? {}
|
|
4202
|
+
: {
|
|
4203
|
+
remedy: {
|
|
4204
|
+
summary: 'open `/hooks` in Codex and approve the changed Notifai handlers',
|
|
4205
|
+
command: '/hooks',
|
|
4206
|
+
},
|
|
4207
|
+
}),
|
|
4208
|
+
})
|
|
4209
|
+
|
|
4210
|
+
const requiredTimeout = blockingHookTimeoutSeconds()
|
|
3448
4211
|
const shortTimeouts = installations.flatMap((installation) =>
|
|
3449
4212
|
installation.handlers
|
|
4213
|
+
.filter(() => installation.harness !== 'codex')
|
|
3450
4214
|
.filter((handler) => handlerEvent(handler.command) === 'stop')
|
|
3451
4215
|
.filter((handler) => handler.timeout === undefined || handler.timeout < requiredTimeout)
|
|
3452
4216
|
.map(
|
|
@@ -3459,26 +4223,20 @@ function hookChecks(deps: CommandDeps): HookCheck[] {
|
|
|
3459
4223
|
ok: shortTimeouts.length === 0,
|
|
3460
4224
|
detail:
|
|
3461
4225
|
shortTimeouts.length === 0
|
|
3462
|
-
? `
|
|
4226
|
+
? `declared Stop timeouts match the fixed ${requiredTimeout}s process budget where required; Codex uses host defaults`
|
|
3463
4227
|
: shortTimeouts.join('; '),
|
|
3464
4228
|
})
|
|
3465
4229
|
|
|
3466
|
-
//
|
|
3467
|
-
//
|
|
3468
|
-
//
|
|
3469
|
-
//
|
|
3470
|
-
// session, so having Claude Code and OpenCode both set up is the ordinary
|
|
3471
|
-
// case and not a duplicate — comparing them turned a healthy machine red.
|
|
4230
|
+
// Project and global definitions for one harness both fire. Stable adapter
|
|
4231
|
+
// identity deliberately makes their command bytes equal, so comparing
|
|
4232
|
+
// command targets would now hide this duplicate rather than diagnose it.
|
|
4233
|
+
// Different harnesses remain independent: only the active one runs.
|
|
3472
4234
|
const duplicated = [...new Set(installations.map((i) => i.harness))]
|
|
3473
4235
|
.map((harness) => ({
|
|
3474
4236
|
harness,
|
|
3475
|
-
|
|
3476
|
-
installations
|
|
3477
|
-
.filter((i) => i.harness === harness)
|
|
3478
|
-
.flatMap((i) => i.handlers.map((h) => h.command.split(' hook ')[0] ?? '')),
|
|
3479
|
-
),
|
|
4237
|
+
installations: installations.filter((i) => i.harness === harness),
|
|
3480
4238
|
}))
|
|
3481
|
-
.filter((entry) => entry.
|
|
4239
|
+
.filter((entry) => entry.installations.length > 1)
|
|
3482
4240
|
if (duplicated.length > 0) {
|
|
3483
4241
|
checks.push({
|
|
3484
4242
|
name: 'hooks (duplicates)',
|
|
@@ -3486,43 +4244,86 @@ function hookChecks(deps: CommandDeps): HookCheck[] {
|
|
|
3486
4244
|
detail: duplicated
|
|
3487
4245
|
.map(
|
|
3488
4246
|
(entry) =>
|
|
3489
|
-
`${entry.harness}: ${entry.
|
|
4247
|
+
`${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(', ')}`,
|
|
3490
4248
|
)
|
|
3491
4249
|
.join('; '),
|
|
3492
4250
|
})
|
|
3493
4251
|
}
|
|
3494
4252
|
|
|
3495
|
-
|
|
3496
|
-
|
|
4253
|
+
if (active !== null && activeInstallations.length > 0) {
|
|
4254
|
+
const admissionProblems = activeQuestionRouteProblems(deps, active, installations)
|
|
4255
|
+
checks.push({
|
|
4256
|
+
name: 'hooks (question admission)',
|
|
4257
|
+
ok: admissionProblems.length === 0,
|
|
4258
|
+
detail:
|
|
4259
|
+
admissionProblems.length === 0
|
|
4260
|
+
? `the active ${active.label} route is exact, current, singular, trusted where applicable, and bounded by a live owner`
|
|
4261
|
+
: admissionProblems.join('; '),
|
|
4262
|
+
})
|
|
4263
|
+
}
|
|
4264
|
+
|
|
4265
|
+
const firedPointer =
|
|
4266
|
+
active?.sessionId === undefined
|
|
4267
|
+
? readProjectSessionPointer(deps.cwd, deps.env, (deps.now ?? Date.now)())
|
|
4268
|
+
: readMatchingProjectSessionPointer(
|
|
4269
|
+
deps.cwd,
|
|
4270
|
+
deps.env,
|
|
4271
|
+
(deps.now ?? Date.now)(),
|
|
4272
|
+
active.sessionId,
|
|
4273
|
+
active.harness,
|
|
4274
|
+
)
|
|
4275
|
+
const firedState = firedPointer === null ? null : readSessionState(firedPointer.sessionId, deps.env)
|
|
4276
|
+
const promptFired = firedState?.last_prompt_at !== undefined
|
|
4277
|
+
const stopFired = firedState?.last_stop_at !== undefined
|
|
4278
|
+
const fired = firedPointer !== null && promptFired && stopFired
|
|
3497
4279
|
const activationInstallations =
|
|
3498
4280
|
active !== null && activeInstallations.length > 0 ? activeInstallations : installations
|
|
3499
4281
|
checks.push({
|
|
3500
4282
|
name: 'hooks (fired)',
|
|
3501
4283
|
ok: fired,
|
|
3502
|
-
//
|
|
3503
|
-
//
|
|
3504
|
-
informational:
|
|
4284
|
+
// A wholly fresh install is informational. Once UserPromptSubmit has fired,
|
|
4285
|
+
// a missing Stop is a broken route, not missing historical evidence.
|
|
4286
|
+
informational: firedPointer === null,
|
|
3505
4287
|
detail: fired
|
|
3506
|
-
? 'a session in this directory has run
|
|
3507
|
-
:
|
|
4288
|
+
? 'a session in this directory has run UserPromptSubmit and Stop'
|
|
4289
|
+
: firedPointer === null
|
|
4290
|
+
? `no session pointer from the last 24 hours — ${hookActivationAdvice(activationInstallations)}`
|
|
4291
|
+
: `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`,
|
|
3508
4292
|
...(fired
|
|
3509
4293
|
? {}
|
|
3510
4294
|
: {
|
|
3511
4295
|
remedy: {
|
|
3512
|
-
summary:
|
|
4296
|
+
summary:
|
|
4297
|
+
firedPointer === null
|
|
4298
|
+
? 'send one prompt in a session here, then re-check'
|
|
4299
|
+
: 'end one harmless turn, send a new prompt, then re-check',
|
|
3513
4300
|
command: 'notifai doctor',
|
|
3514
4301
|
},
|
|
3515
4302
|
}),
|
|
3516
4303
|
})
|
|
3517
4304
|
|
|
3518
|
-
|
|
3519
|
-
|
|
3520
|
-
|
|
3521
|
-
|
|
3522
|
-
|
|
3523
|
-
|
|
3524
|
-
|
|
3525
|
-
|
|
4305
|
+
const continuationInstallations =
|
|
4306
|
+
active !== null ? activeInstallations : installations
|
|
4307
|
+
const continuationHarnesses = [
|
|
4308
|
+
...new Set(continuationInstallations.map((installation) => installation.harness)),
|
|
4309
|
+
]
|
|
4310
|
+
checks.push({
|
|
4311
|
+
name: 'hooks (answer continuation)',
|
|
4312
|
+
ok:
|
|
4313
|
+
continuationHarnesses.length > 0 &&
|
|
4314
|
+
continuationHarnesses.every(
|
|
4315
|
+
(harness) => HARNESS_CAPABILITIES[harness].stopContinuation !== 'unsupported',
|
|
4316
|
+
),
|
|
4317
|
+
informational: active === null,
|
|
4318
|
+
detail:
|
|
4319
|
+
continuationHarnesses.length === 0
|
|
4320
|
+
? active === null
|
|
4321
|
+
? 'no installed harness route to assess'
|
|
4322
|
+
: `the active ${active.label} session has no matching continuation adapter`
|
|
4323
|
+
: continuationHarnesses
|
|
4324
|
+
.map((harness) => `${harness}: ${HARNESS_CAPABILITIES[harness].deliveryContract}`)
|
|
4325
|
+
.join('; '),
|
|
4326
|
+
})
|
|
3526
4327
|
|
|
3527
4328
|
const stray = codexStrayWorktreeCheck(deps)
|
|
3528
4329
|
if (stray !== null) checks.push(stray)
|