@raidiant/notifai 0.3.0 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +19 -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/claude-wake.test.ts +320 -0
- package/src/claude-wake.ts +489 -0
- package/src/client.test.ts +12 -0
- package/src/client.ts +72 -4
- package/src/codex-wake.test.ts +253 -0
- package/src/codex-wake.ts +288 -0
- package/src/commands.test.ts +1261 -77
- package/src/commands.ts +1482 -375
- package/src/config-schema.test.ts +3 -3
- package/src/config-schema.ts +57 -44
- package/src/config.test.ts +22 -16
- package/src/config.ts +81 -66
- 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 +34 -0
- package/src/harnesses.ts +57 -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 +1985 -417
- package/src/hooks.ts +1338 -461
- package/src/install-hooks.test.ts +131 -41
- package/src/install-hooks.ts +284 -84
- package/src/interactive.ts +3 -8
- 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 +34 -0
- package/src/ui/config-view.test.ts +9 -7
- package/src/ui/config-view.ts +3 -0
- package/src/idle.test.ts +0 -32
- package/src/idle.ts +0 -41
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,
|
|
@@ -57,27 +63,44 @@ import {
|
|
|
57
63
|
} from './readiness.js'
|
|
58
64
|
import type { Tone } from './ui/theme.js'
|
|
59
65
|
import {
|
|
66
|
+
WAITER_CEILING_SECONDS,
|
|
60
67
|
handleSessionEnd,
|
|
61
68
|
handleStop,
|
|
62
69
|
handleUserPromptSubmit,
|
|
63
70
|
parseHookInput,
|
|
64
71
|
pruneAbandonedSessions,
|
|
72
|
+
readLiveProjectSessionPointers,
|
|
73
|
+
readMatchingProjectSessionPointer,
|
|
65
74
|
readProjectSession,
|
|
66
75
|
readProjectSessionPointer,
|
|
67
76
|
readSessionState,
|
|
68
77
|
registerQuestion,
|
|
78
|
+
type EscalationDeliveryRoute,
|
|
69
79
|
type HookContext,
|
|
70
|
-
type HookEnvelope,
|
|
71
80
|
type HookHarness,
|
|
72
81
|
} from './hooks.js'
|
|
73
|
-
import { readIdleSeconds } from './idle.js'
|
|
74
82
|
import {
|
|
83
|
+
LOG_EVENTS,
|
|
84
|
+
activeLogPath,
|
|
85
|
+
archiveLogPaths,
|
|
86
|
+
logConfigResolved,
|
|
87
|
+
logSettingsFrom,
|
|
88
|
+
logsDiskUsage,
|
|
89
|
+
nullLogger,
|
|
90
|
+
readLogRecords,
|
|
91
|
+
renderRecord,
|
|
92
|
+
type LogQuery,
|
|
93
|
+
type Logger,
|
|
94
|
+
} from './logging.js'
|
|
95
|
+
import {
|
|
96
|
+
BLOCKING_STOP_TIMEOUT_SECONDS,
|
|
97
|
+
CLAUDE_ASYNC_STOP_TIMEOUT_SECONDS,
|
|
75
98
|
HARNESSES,
|
|
76
99
|
applyPlan,
|
|
77
|
-
blockingHookTimeoutSeconds,
|
|
78
100
|
buildCursorHookConfig,
|
|
79
101
|
buildHookConfig,
|
|
80
102
|
codexLayerDir,
|
|
103
|
+
codexTrustProblems,
|
|
81
104
|
codexProjectRoot,
|
|
82
105
|
detectHarness,
|
|
83
106
|
findInstallations,
|
|
@@ -92,6 +115,22 @@ import {
|
|
|
92
115
|
type Harness,
|
|
93
116
|
type Installation,
|
|
94
117
|
} from './install-hooks.js'
|
|
118
|
+
import { HARNESS_CAPABILITIES } from './harnesses.js'
|
|
119
|
+
import {
|
|
120
|
+
claudeWakeRoute,
|
|
121
|
+
inspectClaudeInbox,
|
|
122
|
+
systemClaudeWakeAdapters,
|
|
123
|
+
type ClaudeWakeAdapters,
|
|
124
|
+
} from './claude-wake.js'
|
|
125
|
+
import {
|
|
126
|
+
codexWakeRoute,
|
|
127
|
+
inspectCodexResume,
|
|
128
|
+
type CodexWakeAdapters,
|
|
129
|
+
} from './codex-wake.js'
|
|
130
|
+
import {
|
|
131
|
+
inspectHookAdapter,
|
|
132
|
+
installHookAdapter,
|
|
133
|
+
} from './hook-adapter.js'
|
|
95
134
|
import {
|
|
96
135
|
isOurOpencodePlugin,
|
|
97
136
|
opencodePluginSource,
|
|
@@ -146,16 +185,44 @@ export interface CommandDeps {
|
|
|
146
185
|
store: CredentialStore
|
|
147
186
|
env: NodeJS.ProcessEnv
|
|
148
187
|
cwd: string
|
|
188
|
+
/** Test seam; production fixes the hook adapter under os.homedir(). */
|
|
189
|
+
hookAdapterHome?: string
|
|
149
190
|
/** Test seam; production uses fetch against base_url. */
|
|
150
191
|
clientFactory?: (baseUrl: string, bearer: string | null, options?: ClientOptions) => ApiClient
|
|
151
192
|
/** Test seam for bounded polling without wall-clock sleeps. */
|
|
152
193
|
now?: () => number
|
|
153
194
|
/** Test seam for retry/backoff timing. */
|
|
154
195
|
sleep?: (milliseconds: number) => Promise<void>
|
|
155
|
-
/** Test
|
|
156
|
-
|
|
196
|
+
/** Test seams for Claude liveness, socket delivery, and cold resume. */
|
|
197
|
+
claudeWake?: ClaudeWakeAdapters
|
|
198
|
+
claudeSourcePid?: number
|
|
199
|
+
/** Test seams for the Codex thread-writer probe and cold resume. */
|
|
200
|
+
codexWake?: CodexWakeAdapters
|
|
201
|
+
codexSourcePid?: number
|
|
157
202
|
/** Test seam and production adapter for the external native skills installer. */
|
|
158
203
|
nativeSkills?: NativeSkills
|
|
204
|
+
/**
|
|
205
|
+
* The local record of what this invocation did. Optional so a test fake need
|
|
206
|
+
* not carry one; `log(deps)` supplies a logger that records nothing.
|
|
207
|
+
*/
|
|
208
|
+
logger?: Logger
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/** The invocation's logger, or one that discards everything. */
|
|
212
|
+
export function log(deps: CommandDeps): Logger {
|
|
213
|
+
return deps.logger ?? nullLogger()
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
function loadLoggedConfig(
|
|
217
|
+
deps: CommandDeps,
|
|
218
|
+
options: Parameters<typeof loadConfig>[0],
|
|
219
|
+
): CliConfig {
|
|
220
|
+
const config = loadConfig(options)
|
|
221
|
+
const logger = log(deps)
|
|
222
|
+
logger.adopt(logSettingsFrom(config))
|
|
223
|
+
logger.bind({ project: config.project.value })
|
|
224
|
+
logConfigResolved(logger, config)
|
|
225
|
+
return config
|
|
159
226
|
}
|
|
160
227
|
|
|
161
228
|
export const EXIT = {
|
|
@@ -173,7 +240,7 @@ function makeClient(
|
|
|
173
240
|
bearer: string | null,
|
|
174
241
|
options?: ClientOptions,
|
|
175
242
|
): ApiClient {
|
|
176
|
-
return (deps.clientFactory ?? createClient)(baseUrl, bearer, options)
|
|
243
|
+
return (deps.clientFactory ?? createClient)(baseUrl, bearer, { ...options, logger: log(deps) })
|
|
177
244
|
}
|
|
178
245
|
|
|
179
246
|
function resolvedBaseUrl(config: CliConfig, credential: MachineCredential | null): string {
|
|
@@ -183,6 +250,10 @@ function resolvedBaseUrl(config: CliConfig, credential: MachineCredential | null
|
|
|
183
250
|
function authedClient(deps: CommandDeps, config: CliConfig): { client: ApiClient; baseUrl: string } | null {
|
|
184
251
|
const credential = deps.store.load()
|
|
185
252
|
if (!credential) {
|
|
253
|
+
// The commonest reason a command does nothing, and one that leaves no other
|
|
254
|
+
// trace: it returns before any request is made, so without this the log
|
|
255
|
+
// shows an exit code and no cause.
|
|
256
|
+
log(deps).error('cli.error', { kind: 'auth', message: 'not signed in', store: deps.store.describe() })
|
|
186
257
|
deps.io.err('Not signed in. Run `notifai login` first.')
|
|
187
258
|
return null
|
|
188
259
|
}
|
|
@@ -193,7 +264,22 @@ function authedClient(deps: CommandDeps, config: CliConfig): { client: ApiClient
|
|
|
193
264
|
}
|
|
194
265
|
}
|
|
195
266
|
|
|
196
|
-
function reportError(
|
|
267
|
+
function reportError(
|
|
268
|
+
deps: CommandDeps,
|
|
269
|
+
err: unknown,
|
|
270
|
+
context: Record<string, unknown> = {},
|
|
271
|
+
): number {
|
|
272
|
+
// Recorded before it is printed: stderr from an agent's shell call is often
|
|
273
|
+
// the one thing that does not survive into the next turn, and this is exactly
|
|
274
|
+
// the line someone comes back looking for.
|
|
275
|
+
log(deps).error('cli.error', {
|
|
276
|
+
...context,
|
|
277
|
+
...(err instanceof ApiCallError
|
|
278
|
+
? { kind: 'api', status: err.status, code: err.code, message: err.message, next_action: err.nextAction, details: err.details }
|
|
279
|
+
: err instanceof NetworkError
|
|
280
|
+
? { kind: 'network', message: err.message }
|
|
281
|
+
: { kind: 'unknown', message: String(err) }),
|
|
282
|
+
})
|
|
197
283
|
if (err instanceof ApiCallError) {
|
|
198
284
|
deps.io.err(`${err.code}: ${err.message}`)
|
|
199
285
|
if (err.nextAction) deps.io.err(`next: ${err.nextAction}`)
|
|
@@ -216,7 +302,7 @@ export async function loginCommand(
|
|
|
216
302
|
deps: CommandDeps,
|
|
217
303
|
flags: { name?: string; baseUrl?: string; open?: boolean },
|
|
218
304
|
): Promise<number> {
|
|
219
|
-
const config =
|
|
305
|
+
const config = loadLoggedConfig(deps, { cwd: deps.cwd, env: deps.env, flags: { base_url: flags.baseUrl } as FlagOverrides })
|
|
220
306
|
const baseUrl = config.base_url.value
|
|
221
307
|
const machineName = flags.name ?? os.hostname()
|
|
222
308
|
const secret = randomBytes(32).toString('base64url')
|
|
@@ -345,7 +431,7 @@ export async function accessStatusCommand(
|
|
|
345
431
|
deps: CommandDeps,
|
|
346
432
|
flags: { json?: boolean },
|
|
347
433
|
): Promise<number> {
|
|
348
|
-
const config =
|
|
434
|
+
const config = loadLoggedConfig(deps, { cwd: deps.cwd, env: deps.env })
|
|
349
435
|
const authed = authedClient(deps, config)
|
|
350
436
|
if (!authed) return EXIT.auth
|
|
351
437
|
try {
|
|
@@ -373,7 +459,7 @@ export async function accessStatusCommand(
|
|
|
373
459
|
// ---------------------------------------------------------------------------
|
|
374
460
|
|
|
375
461
|
export async function devicesCommand(deps: CommandDeps, flags: { json?: boolean }): Promise<number> {
|
|
376
|
-
const config =
|
|
462
|
+
const config = loadLoggedConfig(deps, { cwd: deps.cwd, env: deps.env })
|
|
377
463
|
const authed = authedClient(deps, config)
|
|
378
464
|
if (!authed) return EXIT.auth
|
|
379
465
|
try {
|
|
@@ -417,7 +503,7 @@ export async function devicesCommand(deps: CommandDeps, flags: { json?: boolean
|
|
|
417
503
|
*/
|
|
418
504
|
export async function deviceInventory(deps: CommandDeps): Promise<RoutableDevice[] | null> {
|
|
419
505
|
if (!deps.store.load()) return null
|
|
420
|
-
const config =
|
|
506
|
+
const config = loadLoggedConfig(deps, { cwd: deps.cwd, env: deps.env })
|
|
421
507
|
const authed = authedClient(deps, config)
|
|
422
508
|
if (!authed) return null
|
|
423
509
|
try {
|
|
@@ -436,7 +522,7 @@ export async function capabilitiesCommand(
|
|
|
436
522
|
deps: CommandDeps,
|
|
437
523
|
flags: { json?: boolean; platform?: Platform },
|
|
438
524
|
): Promise<number> {
|
|
439
|
-
const config =
|
|
525
|
+
const config = loadLoggedConfig(deps, { cwd: deps.cwd, env: deps.env })
|
|
440
526
|
const credential = deps.store.load()
|
|
441
527
|
const baseUrl = resolvedBaseUrl(config, credential)
|
|
442
528
|
const client = makeClient(deps, baseUrl, null)
|
|
@@ -519,7 +605,7 @@ export async function sendCommand(
|
|
|
519
605
|
deps.io.err(`--reply-window must be an integer from 60 to ${REPLY_MAX_WINDOW_SECONDS} seconds.`)
|
|
520
606
|
return EXIT.usage
|
|
521
607
|
}
|
|
522
|
-
const config =
|
|
608
|
+
const config = loadLoggedConfig(deps, {
|
|
523
609
|
cwd: deps.cwd,
|
|
524
610
|
env: deps.env,
|
|
525
611
|
flags: { base_url: flags.baseUrl, wait_seconds: flags.wait } as FlagOverrides,
|
|
@@ -560,38 +646,71 @@ export async function sendCommand(
|
|
|
560
646
|
}
|
|
561
647
|
const waitSeconds = flags.noWait ? 0 : config.wait_seconds.value
|
|
562
648
|
const idempotencyKey = flags.idempotencyKey ?? `cli-${randomBytes(12).toString('base64url')}`
|
|
649
|
+
let receipt: SubmissionReceipt
|
|
563
650
|
try {
|
|
564
|
-
|
|
651
|
+
receipt = await authed.client.submit(
|
|
565
652
|
{ idempotency_key: idempotencyKey, draft: build.draft },
|
|
566
653
|
waitSeconds,
|
|
567
654
|
)
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
655
|
+
} catch (err) {
|
|
656
|
+
return reportError(deps, err)
|
|
657
|
+
}
|
|
658
|
+
const receiptExit = receiptExitCode(receipt)
|
|
659
|
+
// The single most useful line in the log: it ties the local invocation to
|
|
660
|
+
// the server-side request id, which is what every later question about this
|
|
661
|
+
// notification ("did it arrive?", "which device?") is asked in terms of.
|
|
662
|
+
log(deps).info('send.submitted', {
|
|
663
|
+
request_id: receipt.request_id,
|
|
664
|
+
kind: flags.reply ? 'question' : (flags.kind ?? 'update'),
|
|
665
|
+
title: flags.title,
|
|
666
|
+
overall: receipt.overall,
|
|
667
|
+
replayed: receipt.replayed,
|
|
668
|
+
deliveries: receipt.deliveries.length,
|
|
669
|
+
wait_seconds: waitSeconds,
|
|
670
|
+
exit: receiptExit,
|
|
671
|
+
})
|
|
672
|
+
log(deps).info('send.outcome', {
|
|
673
|
+
request_id: receipt.request_id,
|
|
674
|
+
overall: receipt.overall,
|
|
675
|
+
// Per device, because "it said accepted but nothing arrived" is answered
|
|
676
|
+
// by which device reached which state and why the provider said so.
|
|
677
|
+
devices: receipt.deliveries.map((delivery) => ({
|
|
678
|
+
device: delivery.device_name,
|
|
679
|
+
state: delivery.state,
|
|
680
|
+
attempts: delivery.attempts,
|
|
681
|
+
provider_status: delivery.provider_status,
|
|
682
|
+
provider_reason: delivery.provider_reason,
|
|
683
|
+
})),
|
|
684
|
+
...(receipt.warnings.length > 0 ? { warnings: receipt.warnings } : {}),
|
|
685
|
+
})
|
|
686
|
+
if (!flags.json) deps.io.out(formatReceipt(receipt))
|
|
687
|
+
else if (flags.reply) deps.io.out(JSON.stringify({ type: 'receipt', receipt }))
|
|
571
688
|
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
}
|
|
586
|
-
return receiptExit
|
|
689
|
+
// A zero wait can no longer reach here: --reply guarantees a positive one.
|
|
690
|
+
if (!flags.reply || receiptExit !== EXIT.ok) {
|
|
691
|
+
if (flags.json) {
|
|
692
|
+
deps.io.out(
|
|
693
|
+
flags.reply
|
|
694
|
+
? JSON.stringify({
|
|
695
|
+
type: 'reply_result',
|
|
696
|
+
request_id: receipt.request_id,
|
|
697
|
+
replies: [],
|
|
698
|
+
degraded: false,
|
|
699
|
+
})
|
|
700
|
+
: JSON.stringify(receipt, null, 2),
|
|
701
|
+
)
|
|
587
702
|
}
|
|
703
|
+
return receiptExit
|
|
704
|
+
}
|
|
588
705
|
|
|
706
|
+
try {
|
|
589
707
|
const result = await waitForReply(authed.client, receipt.request_id, {
|
|
590
708
|
timeoutSeconds: replyTimeout,
|
|
591
709
|
afterSeq: 0,
|
|
592
710
|
now: deps.now,
|
|
593
711
|
sleep: deps.sleep,
|
|
594
712
|
})
|
|
713
|
+
recordReplies(deps, receipt.request_id, result.response.replies)
|
|
595
714
|
if (flags.json) {
|
|
596
715
|
deps.io.out(
|
|
597
716
|
JSON.stringify({
|
|
@@ -604,17 +723,48 @@ export async function sendCommand(
|
|
|
604
723
|
} else if (result.response.replies.length > 0) printReplies(deps, result.response.replies)
|
|
605
724
|
else printNoReply(deps, receipt.request_id, result.response.reply_expires_at)
|
|
606
725
|
if (result.degraded) {
|
|
607
|
-
deps.
|
|
726
|
+
log(deps).error('cli.error', {
|
|
727
|
+
kind: 'network',
|
|
728
|
+
operation: 'reply_wait',
|
|
729
|
+
request_id: receipt.request_id,
|
|
730
|
+
degraded: true,
|
|
731
|
+
message: 'the reply wait ended while the server was unreachable or faulting',
|
|
732
|
+
})
|
|
733
|
+
deps.io.err(degradedWaitWarning(receipt.request_id))
|
|
608
734
|
return EXIT.network
|
|
609
735
|
}
|
|
610
736
|
if (result.timedOut) {
|
|
611
737
|
deps.io.err(
|
|
612
738
|
`No reply yet. Retrieve it with \`notifai replies ${receipt.request_id}\` or retire the question with ` +
|
|
613
|
-
`\`notifai close ${receipt.request_id}
|
|
739
|
+
`\`notifai close ${receipt.request_id}\`. ` +
|
|
740
|
+
`This is a reply-wait timeout, not a Delivery or Companion Receipt failure — check with \`notifai status ${receipt.request_id}\`.`,
|
|
614
741
|
)
|
|
615
742
|
}
|
|
616
743
|
return result.timedOut ? EXIT.noReply : EXIT.ok
|
|
617
744
|
} catch (err) {
|
|
745
|
+
// Receipt already printed; a wait fault must not read as "send failed".
|
|
746
|
+
// Permanent poll errors (auth, closed window, not found) still surface, but
|
|
747
|
+
// always name the durable request and point at recovery.
|
|
748
|
+
if (err instanceof ApiCallError || err instanceof NetworkError) {
|
|
749
|
+
log(deps).error('cli.error', {
|
|
750
|
+
kind: err instanceof ApiCallError ? 'api' : 'network',
|
|
751
|
+
operation: 'reply_wait',
|
|
752
|
+
request_id: receipt.request_id,
|
|
753
|
+
...(err instanceof ApiCallError ? { status: err.status, code: err.code } : {}),
|
|
754
|
+
message: err.message,
|
|
755
|
+
})
|
|
756
|
+
deps.io.err(
|
|
757
|
+
`notifai: reply wait failed for ${receipt.request_id} (${err instanceof ApiCallError ? err.code : 'network'}: ${err.message}). ` +
|
|
758
|
+
`Delivery and Companion Receipt are independent — check with \`notifai status ${receipt.request_id}\` and retry with \`notifai replies ${receipt.request_id}\`.`,
|
|
759
|
+
)
|
|
760
|
+
if (err instanceof ApiCallError) {
|
|
761
|
+
if (err.code === 'auth_required' || err.code === 'machine_revoked') return EXIT.auth
|
|
762
|
+
return err.status >= 500 || err.status === 429 || err.status === 408
|
|
763
|
+
? EXIT.network
|
|
764
|
+
: EXIT.failed
|
|
765
|
+
}
|
|
766
|
+
return EXIT.network
|
|
767
|
+
}
|
|
618
768
|
return reportError(deps, err)
|
|
619
769
|
}
|
|
620
770
|
}
|
|
@@ -705,40 +855,49 @@ export async function repliesCommand(
|
|
|
705
855
|
return EXIT.usage
|
|
706
856
|
}
|
|
707
857
|
|
|
708
|
-
const config =
|
|
858
|
+
const config = loadLoggedConfig(deps, { cwd: deps.cwd, env: deps.env })
|
|
709
859
|
const authed = authedClient(deps, config)
|
|
710
860
|
if (!authed) return EXIT.auth
|
|
711
861
|
try {
|
|
712
862
|
let anyReplies = false
|
|
713
|
-
|
|
863
|
+
const degradedRequestIds: string[] = []
|
|
714
864
|
let allTimedOut = true
|
|
715
865
|
const jsonBodies: object[] = []
|
|
716
866
|
for (const requestId of requestIds) {
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
867
|
+
try {
|
|
868
|
+
const result = await waitForReply(authed.client, requestId, {
|
|
869
|
+
timeoutSeconds: waitSeconds,
|
|
870
|
+
afterSeq,
|
|
871
|
+
now: deps.now,
|
|
872
|
+
sleep: deps.sleep,
|
|
873
|
+
})
|
|
874
|
+
recordReplies(deps, requestId, result.response.replies)
|
|
875
|
+
if (flags.json) {
|
|
876
|
+
jsonBodies.push({ ...result.response, degraded: result.degraded })
|
|
877
|
+
} else if (result.response.replies.length > 0) {
|
|
878
|
+
if (flags.pending === true) deps.io.out(`pending request ${requestId}`)
|
|
879
|
+
printReplies(deps, result.response.replies)
|
|
880
|
+
} else {
|
|
881
|
+
printNoReply(deps, requestId, result.response.reply_expires_at)
|
|
882
|
+
}
|
|
883
|
+
anyReplies ||= result.response.replies.length > 0
|
|
884
|
+
if (result.degraded) degradedRequestIds.push(requestId)
|
|
885
|
+
allTimedOut &&= result.timedOut
|
|
886
|
+
} catch (err) {
|
|
887
|
+
recordDegradedReplyWaits(deps, degradedRequestIds)
|
|
888
|
+
return reportError(deps, err, { operation: 'reply_wait', request_id: requestId })
|
|
730
889
|
}
|
|
731
|
-
anyReplies ||= result.response.replies.length > 0
|
|
732
|
-
anyDegraded ||= result.degraded
|
|
733
|
-
allTimedOut &&= result.timedOut
|
|
734
890
|
}
|
|
735
891
|
if (flags.json) {
|
|
736
892
|
// One request keeps the response shape agents already parse; several
|
|
737
893
|
// (only possible via --pending) arrive as an array in queue order.
|
|
738
894
|
deps.io.out(JSON.stringify(jsonBodies.length === 1 ? jsonBodies[0] : jsonBodies, null, 2))
|
|
739
895
|
}
|
|
740
|
-
if (
|
|
741
|
-
deps
|
|
896
|
+
if (degradedRequestIds.length > 0) {
|
|
897
|
+
recordDegradedReplyWaits(deps, degradedRequestIds)
|
|
898
|
+
// Name a request whose polls actually degraded, not merely the first item
|
|
899
|
+
// in a multi-request queue that may have completed cleanly.
|
|
900
|
+
deps.io.err(degradedWaitWarning(degradedRequestIds[0]!))
|
|
742
901
|
return EXIT.network
|
|
743
902
|
}
|
|
744
903
|
if (anyReplies) return EXIT.ok
|
|
@@ -775,8 +934,8 @@ export async function waitForReply(
|
|
|
775
934
|
const sleep = options.sleep ?? ((milliseconds: number) => new Promise((resolve) => setTimeout(resolve, milliseconds)))
|
|
776
935
|
const deadline = now() + options.timeoutSeconds * 1000
|
|
777
936
|
let lastResponse: ListRepliesResponse | null = null
|
|
778
|
-
let
|
|
779
|
-
let
|
|
937
|
+
let lastTransientError: Error | null = null
|
|
938
|
+
let consecutiveTransientErrors = 0
|
|
780
939
|
let firstPoll = true
|
|
781
940
|
|
|
782
941
|
while (firstPoll || now() < deadline) {
|
|
@@ -789,24 +948,31 @@ export async function waitForReply(
|
|
|
789
948
|
afterSeq: options.afterSeq,
|
|
790
949
|
})
|
|
791
950
|
lastResponse = response
|
|
792
|
-
|
|
793
|
-
|
|
951
|
+
lastTransientError = null
|
|
952
|
+
consecutiveTransientErrors = 0
|
|
794
953
|
if (response.replies.length > 0) return { response, timedOut: false, degraded: false }
|
|
795
954
|
|
|
796
955
|
const pauseMs = Math.min(250, Math.max(0, deadline - now()))
|
|
797
956
|
if (pauseMs > 0) await sleep(pauseMs)
|
|
798
957
|
} catch (err) {
|
|
799
|
-
if (!(err
|
|
800
|
-
|
|
801
|
-
|
|
958
|
+
if (!isRetryableReplyPollError(err)) throw err
|
|
959
|
+
lastTransientError = err instanceof Error ? err : new Error(String(err))
|
|
960
|
+
consecutiveTransientErrors += 1
|
|
802
961
|
const remainingAfterError = Math.max(0, deadline - now())
|
|
803
962
|
if (remainingAfterError === 0) break
|
|
804
|
-
const backoffMs = Math.min(
|
|
963
|
+
const backoffMs = Math.min(
|
|
964
|
+
250 * 2 ** (consecutiveTransientErrors - 1),
|
|
965
|
+
2_000,
|
|
966
|
+
remainingAfterError,
|
|
967
|
+
)
|
|
805
968
|
await sleep(backoffMs)
|
|
806
969
|
}
|
|
807
970
|
}
|
|
808
971
|
|
|
809
|
-
|
|
972
|
+
// Never saw a successful poll: surface the fault so callers get EXIT.network
|
|
973
|
+
// rather than a fake empty silence. The durable request is still on the
|
|
974
|
+
// server; the typed recovery is `notifai replies <id>`.
|
|
975
|
+
if (!lastResponse && lastTransientError) throw lastTransientError
|
|
810
976
|
return {
|
|
811
977
|
response:
|
|
812
978
|
lastResponse ??
|
|
@@ -815,23 +981,55 @@ export async function waitForReply(
|
|
|
815
981
|
// A poll succeeded at some point, so we do not throw — but the last thing
|
|
816
982
|
// we know is that we could not reach the server. Reporting that as a plain
|
|
817
983
|
// "no reply" would let an agent treat an unseen refusal as consent.
|
|
818
|
-
degraded:
|
|
984
|
+
degraded: lastTransientError !== null,
|
|
819
985
|
}
|
|
820
986
|
}
|
|
821
987
|
|
|
988
|
+
function recordDegradedReplyWaits(deps: CommandDeps, requestIds: readonly string[]): void {
|
|
989
|
+
if (requestIds.length === 0) return
|
|
990
|
+
log(deps).error('cli.error', {
|
|
991
|
+
kind: 'network',
|
|
992
|
+
operation: 'reply_wait',
|
|
993
|
+
request_ids: requestIds,
|
|
994
|
+
degraded: true,
|
|
995
|
+
message: 'the reply wait ended while the server was unreachable or faulting',
|
|
996
|
+
})
|
|
997
|
+
}
|
|
998
|
+
|
|
822
999
|
/**
|
|
823
1000
|
* Shared by every surface that waits: "the user did not answer" and "I could
|
|
824
1001
|
* not find out" must not look the same, because agents branch on the exit code
|
|
825
1002
|
* and one of those two branches is safe to proceed from.
|
|
1003
|
+
*
|
|
1004
|
+
* Delivery / Companion Receipt / OS presentation are separate facts — a wait
|
|
1005
|
+
* fault is not evidence that the push failed.
|
|
826
1006
|
*/
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
1007
|
+
function degradedWaitWarning(requestId: string): string {
|
|
1008
|
+
return (
|
|
1009
|
+
`notifai: the wait for ${requestId} ended while the server was unreachable or faulting, ` +
|
|
1010
|
+
`so this is "could not find out", not "no answer" — the request is still durable and the ` +
|
|
1011
|
+
`reply may already be waiting (Provider Acceptance and Companion Receipt are independent). ` +
|
|
1012
|
+
`Retry with \`notifai replies ${requestId}\`.`
|
|
1013
|
+
)
|
|
1014
|
+
}
|
|
831
1015
|
function isNonNegativeInteger(value: number): boolean {
|
|
832
1016
|
return Number.isInteger(value) && value >= 0
|
|
833
1017
|
}
|
|
834
1018
|
|
|
1019
|
+
function recordReplies(deps: CommandDeps, requestId: string, replies: readonly ReplyView[]): void {
|
|
1020
|
+
const logger = log(deps)
|
|
1021
|
+
for (const reply of replies) {
|
|
1022
|
+
logger.info('reply.received', {
|
|
1023
|
+
request_id: requestId,
|
|
1024
|
+
sequence: reply.seq,
|
|
1025
|
+
device: reply.device_name,
|
|
1026
|
+
text: reply.text,
|
|
1027
|
+
answers: reply.answers,
|
|
1028
|
+
source: reply.source,
|
|
1029
|
+
})
|
|
1030
|
+
}
|
|
1031
|
+
}
|
|
1032
|
+
|
|
835
1033
|
function printReplies(deps: CommandDeps, replies: ReplyView[]): void {
|
|
836
1034
|
for (const reply of replies) deps.io.out(`reply from ${reply.device_name}: ${reply.text}`)
|
|
837
1035
|
const contradiction = contradictingAnswer(replies)
|
|
@@ -880,7 +1078,7 @@ export async function statusCommand(
|
|
|
880
1078
|
requestId: string,
|
|
881
1079
|
flags: { json?: boolean },
|
|
882
1080
|
): Promise<number> {
|
|
883
|
-
const config =
|
|
1081
|
+
const config = loadLoggedConfig(deps, { cwd: deps.cwd, env: deps.env })
|
|
884
1082
|
const authed = authedClient(deps, config)
|
|
885
1083
|
if (!authed) return EXIT.auth
|
|
886
1084
|
try {
|
|
@@ -890,6 +1088,7 @@ export async function statusCommand(
|
|
|
890
1088
|
return EXIT.ok
|
|
891
1089
|
}
|
|
892
1090
|
deps.io.out(`request ${snapshot.request_id} (${snapshot.event ?? 'no event'}) — ${snapshot.overall}`)
|
|
1091
|
+
let anyReplyReceived = false
|
|
893
1092
|
for (const d of snapshot.deliveries) {
|
|
894
1093
|
deps.io.out(` ${d.device_name}:`)
|
|
895
1094
|
deps.io.out(` Delivery: ${d.state} after ${d.attempts} attempt(s)`)
|
|
@@ -907,10 +1106,27 @@ export async function statusCommand(
|
|
|
907
1106
|
" Companion Receipt (the app's delivery confirmation): unknown — not observed; this is not a failure or proof of non-receipt",
|
|
908
1107
|
)
|
|
909
1108
|
}
|
|
1109
|
+
// Notifai never learns whether the OS painted a banner; saying so stops
|
|
1110
|
+
// a reply-wait fault from being misread as "the phone never showed it".
|
|
1111
|
+
deps.io.out(
|
|
1112
|
+
' OS presentation: not observed by Notifai — Provider Acceptance and Companion Receipt do not prove a banner was shown',
|
|
1113
|
+
)
|
|
1114
|
+
const replyEvent = d.events.find((e) => e.stage === 'reply_received')
|
|
1115
|
+
if (replyEvent) {
|
|
1116
|
+
anyReplyReceived = true
|
|
1117
|
+
deps.io.out(` Reply received: yes (first at ${replyEvent.occurred_at})`)
|
|
1118
|
+
} else {
|
|
1119
|
+
deps.io.out(' Reply received: not yet recorded on this delivery')
|
|
1120
|
+
}
|
|
910
1121
|
for (const e of d.events) {
|
|
911
1122
|
deps.io.out(` ${e.occurred_at} ${e.stage}${e.reason ? ` (${e.reason})` : ''}`)
|
|
912
1123
|
}
|
|
913
1124
|
}
|
|
1125
|
+
deps.io.out(
|
|
1126
|
+
anyReplyReceived
|
|
1127
|
+
? ` Reply wait: answers are on the server — collect with \`notifai replies ${snapshot.request_id}\` (a local wait fault does not erase them)`
|
|
1128
|
+
: ` Reply wait: no answer stored yet — a blocking wait failure is independent of Delivery above; retry with \`notifai replies ${snapshot.request_id}\``,
|
|
1129
|
+
)
|
|
914
1130
|
return EXIT.ok
|
|
915
1131
|
} catch (err) {
|
|
916
1132
|
return reportError(deps, err)
|
|
@@ -979,6 +1195,13 @@ async function uploadImage(deps: CommandDeps, client: ApiClient, source: string)
|
|
|
979
1195
|
export const HOOK_EVENTS = ['user-prompt-submit', 'stop', 'session-end'] as const
|
|
980
1196
|
export type HookEvent = (typeof HOOK_EVENTS)[number]
|
|
981
1197
|
|
|
1198
|
+
/** SessionEnd cleanup must precede every diagnostic that can wait on a file lock. */
|
|
1199
|
+
export function hookDefersDiagnosticsUntilAfterCleanup(
|
|
1200
|
+
event: unknown,
|
|
1201
|
+
): event is 'session-end' {
|
|
1202
|
+
return event === 'session-end'
|
|
1203
|
+
}
|
|
1204
|
+
|
|
982
1205
|
/**
|
|
983
1206
|
* Runs one harness hook. Contract with every harness: hook JSON arrives on
|
|
984
1207
|
* stdin, the decision (if any) goes to stdout, diagnostics go to stderr, and
|
|
@@ -998,53 +1221,129 @@ export async function hookRunCommand(
|
|
|
998
1221
|
deps.io.err(`Unknown hook event "${event}". Valid: ${HOOK_EVENTS.join(', ')}`)
|
|
999
1222
|
return EXIT.usage
|
|
1000
1223
|
}
|
|
1001
|
-
|
|
1224
|
+
|
|
1225
|
+
// One clock owns the complete Stop invocation, including stdin, config,
|
|
1226
|
+
// credentials, and client construction. Starting this inside `handleStop`
|
|
1227
|
+
// would grant slow setup a second budget and let the harness kill us before
|
|
1228
|
+
// an accepted answer is journaled or written to stdout.
|
|
1229
|
+
const now = deps.now ?? Date.now
|
|
1230
|
+
const processDeadlineAt = now() + WAITER_CEILING_SECONDS * 1000
|
|
1231
|
+
|
|
1232
|
+
const logger = log(deps)
|
|
1233
|
+
logger.bind({ cmd: `hook ${event}` })
|
|
1234
|
+
let started = false
|
|
1235
|
+
const start = (data: Record<string, unknown> = {}): void => {
|
|
1236
|
+
if (started) return
|
|
1237
|
+
started = true
|
|
1238
|
+
logger.info('hook.start', { hook: event, harness: harness ?? 'unknown', ...data })
|
|
1239
|
+
}
|
|
1240
|
+
const failureData = (err: unknown): Record<string, unknown> =>
|
|
1241
|
+
err instanceof ApiCallError
|
|
1242
|
+
? { status: err.status, code: err.code, message: err.message, details: err.details }
|
|
1243
|
+
: { message: err instanceof Error ? err.message : String(err) }
|
|
1244
|
+
|
|
1245
|
+
let raw: string
|
|
1002
1246
|
try {
|
|
1003
|
-
|
|
1004
|
-
|
|
1005
|
-
|
|
1006
|
-
|
|
1007
|
-
|
|
1008
|
-
|
|
1009
|
-
|
|
1010
|
-
|
|
1011
|
-
|
|
1247
|
+
raw = await readStdin()
|
|
1248
|
+
} catch (err) {
|
|
1249
|
+
start({ input: 'unavailable' })
|
|
1250
|
+
logger.error('hook.end', {
|
|
1251
|
+
hook: event,
|
|
1252
|
+
outcome: 'ignored',
|
|
1253
|
+
reason: 'input-read-failed',
|
|
1254
|
+
...failureData(err),
|
|
1255
|
+
})
|
|
1256
|
+
return EXIT.ok
|
|
1257
|
+
}
|
|
1258
|
+
|
|
1259
|
+
if (raw.trim() !== '') {
|
|
1260
|
+
try {
|
|
1261
|
+
const parsed: unknown = JSON.parse(raw)
|
|
1262
|
+
if (typeof parsed !== 'object' || parsed === null) throw new Error('not an object')
|
|
1263
|
+
} catch {
|
|
1264
|
+
start({ input: 'malformed' })
|
|
1265
|
+
logger.error('hook.end', { hook: event, outcome: 'ignored', reason: 'malformed-input' })
|
|
1266
|
+
deps.io.err('notifai: ignored malformed or truncated hook input; no routing action was taken')
|
|
1267
|
+
return EXIT.ok
|
|
1012
1268
|
}
|
|
1013
|
-
|
|
1014
|
-
|
|
1015
|
-
|
|
1016
|
-
|
|
1017
|
-
|
|
1018
|
-
|
|
1019
|
-
|
|
1020
|
-
|
|
1021
|
-
|
|
1022
|
-
|
|
1023
|
-
|
|
1024
|
-
|
|
1269
|
+
}
|
|
1270
|
+
|
|
1271
|
+
let envelope = parseHookInput(raw)
|
|
1272
|
+
if (harness === 'cursor') {
|
|
1273
|
+
const sessionId = envelope.session_id ?? envelope.conversation_id
|
|
1274
|
+
const cwd = envelope.cwd ?? envelope.workspace_roots?.[0]
|
|
1275
|
+
envelope = {
|
|
1276
|
+
...envelope,
|
|
1277
|
+
...(sessionId === undefined ? {} : { session_id: sessionId }),
|
|
1278
|
+
...(cwd === undefined ? {} : { cwd }),
|
|
1279
|
+
stop_hook_active:
|
|
1280
|
+
envelope.stop_hook_active ??
|
|
1281
|
+
(typeof envelope.loop_count === 'number' && envelope.loop_count > 0),
|
|
1282
|
+
}
|
|
1283
|
+
}
|
|
1284
|
+
|
|
1285
|
+
const cwd = envelope.cwd ?? deps.cwd
|
|
1286
|
+
const sessionEnd = hookDefersDiagnosticsUntilAfterCleanup(event)
|
|
1287
|
+
logger.bind({ session: envelope.session_id ?? null })
|
|
1288
|
+
let config: CliConfig | null = null
|
|
1289
|
+
let configFailure: unknown
|
|
1290
|
+
try {
|
|
1291
|
+
config = loadConfig({ cwd, env: deps.env, sessionId: envelope.session_id })
|
|
1292
|
+
// The hook's project is the session's, not this process's, and the log
|
|
1293
|
+
// settings that apply are that project's too. Keeping a mutable bootstrap
|
|
1294
|
+
// logger lets this more-specific layer turn logging back on.
|
|
1295
|
+
logger.adopt(logSettingsFrom(config))
|
|
1296
|
+
logger.bind({ project: config.project.value })
|
|
1297
|
+
if (!sessionEnd) {
|
|
1298
|
+
start({ cwd, stop_hook_active: envelope.stop_hook_active ?? null })
|
|
1299
|
+
logConfigResolved(logger, config)
|
|
1300
|
+
}
|
|
1301
|
+
} catch (err) {
|
|
1302
|
+
configFailure = err
|
|
1303
|
+
if (!sessionEnd) {
|
|
1304
|
+
start({ cwd, stop_hook_active: envelope.stop_hook_active ?? null })
|
|
1305
|
+
logger.error('hook.end', {
|
|
1306
|
+
hook: event,
|
|
1307
|
+
outcome: 'failed',
|
|
1308
|
+
reason: 'config-failed',
|
|
1309
|
+
...failureData(err),
|
|
1310
|
+
})
|
|
1311
|
+
for (const line of describeHookFailure(err)) deps.io.err(`notifai: ${line}`)
|
|
1312
|
+
return EXIT.ok
|
|
1025
1313
|
}
|
|
1026
|
-
} catch {
|
|
1027
|
-
return EXIT.ok
|
|
1028
1314
|
}
|
|
1029
1315
|
|
|
1030
|
-
// Everything below is inside one fail-open boundary.
|
|
1031
|
-
//
|
|
1032
|
-
//
|
|
1033
|
-
// user than not having installed the hook at all.
|
|
1316
|
+
// Everything below is inside one fail-open boundary. Credential loading,
|
|
1317
|
+
// client construction and hook handling can all throw, and a hook that exits
|
|
1318
|
+
// non-zero makes the harness report a failure — strictly worse than skipping.
|
|
1034
1319
|
try {
|
|
1035
|
-
if (
|
|
1320
|
+
if (sessionEnd) {
|
|
1321
|
+
// Codex gives SessionEnd one second total. Do every durable cleanup write
|
|
1322
|
+
// before lifecycle diagnostics: the log lock is deliberately allowed to
|
|
1323
|
+
// wait that long, and a busy log must never preserve ended-session state
|
|
1324
|
+
// or its inherited configuration. The resolved config above is retained
|
|
1325
|
+
// in memory so logging still uses the ending session's settings afterwards.
|
|
1036
1326
|
const outcome = handleSessionEnd(deps.env, envelope, (deps.now ?? Date.now)())
|
|
1327
|
+
start({ cwd, stop_hook_active: envelope.stop_hook_active ?? null })
|
|
1328
|
+
if (config !== null) logConfigResolved(logger, config)
|
|
1329
|
+
const data = { hook: event, decided: false, ...outcome.log }
|
|
1330
|
+
if (configFailure === undefined) logger.info('hook.end', data)
|
|
1331
|
+
else {
|
|
1332
|
+
logger.error('hook.end', {
|
|
1333
|
+
...data,
|
|
1334
|
+
reason: 'config-failed',
|
|
1335
|
+
config_error: failureData(configFailure),
|
|
1336
|
+
})
|
|
1337
|
+
}
|
|
1037
1338
|
for (const note of outcome.notes) deps.io.err(`notifai: ${note}`)
|
|
1038
1339
|
return EXIT.ok
|
|
1039
1340
|
}
|
|
1040
1341
|
|
|
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 })
|
|
1342
|
+
// Non-SessionEnd hooks cannot reach here without resolved configuration.
|
|
1343
|
+
const resolved = config!
|
|
1046
1344
|
const credential = deps.store.load()
|
|
1047
1345
|
if (!credential) {
|
|
1346
|
+
logger.error('hook.end', { hook: event, outcome: 'not-paired' })
|
|
1048
1347
|
deps.io.err('notifai: hook skipped: this machine is not paired; run `notifai login`')
|
|
1049
1348
|
return EXIT.ok
|
|
1050
1349
|
}
|
|
@@ -1052,9 +1351,9 @@ export async function hookRunCommand(
|
|
|
1052
1351
|
// repository can commit `.notifai/config.toml`, and honouring a base_url
|
|
1053
1352
|
// from it would hand this machine's bearer token to whatever host it names.
|
|
1054
1353
|
const baseUrl = credential.baseUrl
|
|
1055
|
-
if (
|
|
1354
|
+
if (resolved.base_url.source !== 'default' && resolved.base_url.value !== baseUrl) {
|
|
1056
1355
|
deps.io.err(
|
|
1057
|
-
`notifai: ignoring base_url from ${
|
|
1356
|
+
`notifai: ignoring base_url from ${resolved.base_url.source}; hooks only talk to ${baseUrl}`,
|
|
1058
1357
|
)
|
|
1059
1358
|
}
|
|
1060
1359
|
// UserPromptSubmit runs in front of the user's own prompt under a 15s
|
|
@@ -1064,15 +1363,16 @@ export async function hookRunCommand(
|
|
|
1064
1363
|
deps,
|
|
1065
1364
|
baseUrl,
|
|
1066
1365
|
`Bearer nfm_${credential.machineId}.${credential.secret}`,
|
|
1067
|
-
{
|
|
1366
|
+
{
|
|
1367
|
+
timeoutMs: event === 'user-prompt-submit' ? 4_000 : 20_000,
|
|
1368
|
+
...(event === 'stop' ? { deadlineAt: processDeadlineAt, now } : {}),
|
|
1369
|
+
},
|
|
1068
1370
|
)
|
|
1069
|
-
const now = deps.now ?? Date.now
|
|
1070
1371
|
const ctx: HookContext = {
|
|
1071
1372
|
client,
|
|
1072
|
-
config,
|
|
1373
|
+
config: resolved,
|
|
1073
1374
|
env: deps.env,
|
|
1074
1375
|
now,
|
|
1075
|
-
idleSeconds: deps.idleSeconds ?? (() => readIdleSeconds()),
|
|
1076
1376
|
sleep: deps.sleep ?? ((ms) => new Promise((resolve) => setTimeout(resolve, ms))),
|
|
1077
1377
|
waitForFirstReply: async (requestId, timeoutSeconds) => {
|
|
1078
1378
|
const result = await waitForReply(client, requestId, {
|
|
@@ -1087,24 +1387,46 @@ export async function hookRunCommand(
|
|
|
1087
1387
|
degraded: result.degraded,
|
|
1088
1388
|
}
|
|
1089
1389
|
},
|
|
1390
|
+
log: logger,
|
|
1090
1391
|
...(harness === undefined ? {} : { harness }),
|
|
1091
1392
|
}
|
|
1092
1393
|
|
|
1093
1394
|
// Real clock, deliberately, not `deps.now`. This compares against file
|
|
1094
1395
|
// mtimes, which are wall-clock facts — handing it a virtual or skewed clock
|
|
1095
1396
|
// would have it delete live session state as "abandoned".
|
|
1096
|
-
//
|
|
1097
|
-
//
|
|
1098
|
-
|
|
1397
|
+
// Daily state pruning is housekeeping, not part of the Stop delivery
|
|
1398
|
+
// contract. Its directory scan has no useful bound, so keep it on the
|
|
1399
|
+
// short prompt path and never spend the answer owner's finite budget on it.
|
|
1400
|
+
if (event !== 'stop') pruneAbandonedSessions(deps.env)
|
|
1099
1401
|
|
|
1100
1402
|
const outcome =
|
|
1101
1403
|
event === 'user-prompt-submit'
|
|
1102
1404
|
? await handleUserPromptSubmit(ctx, envelope)
|
|
1103
|
-
: await handleStop(
|
|
1405
|
+
: await handleStop(
|
|
1406
|
+
ctx,
|
|
1407
|
+
envelope,
|
|
1408
|
+
processDeadlineAt,
|
|
1409
|
+
stopWakeRoute(deps, harness, envelope.session_id, cwd),
|
|
1410
|
+
)
|
|
1411
|
+
// Answer diagnostics are already persisted once as hook.answer. Keep every
|
|
1412
|
+
// other note in the lifecycle record without duplicating the user's text.
|
|
1413
|
+
const notes = outcome.notes.filter((note) => !/^(?:late )?answer from /.test(note))
|
|
1414
|
+
logger.info('hook.end', {
|
|
1415
|
+
hook: event,
|
|
1416
|
+
// A hook that returns stdout has taken over the turn; one that does not
|
|
1417
|
+
// has handed the terminal back. That distinction is the whole contract.
|
|
1418
|
+
decided: outcome.stdout !== undefined,
|
|
1419
|
+
...(notes.length === 0 ? {} : { notes }),
|
|
1420
|
+
...outcome.log,
|
|
1421
|
+
})
|
|
1104
1422
|
for (const note of outcome.notes) deps.io.err(`notifai: ${note}`)
|
|
1105
1423
|
if (outcome.stdout !== undefined) {
|
|
1106
1424
|
let stdout = outcome.stdout
|
|
1107
|
-
if (
|
|
1425
|
+
if (
|
|
1426
|
+
harness !== undefined &&
|
|
1427
|
+
event === 'stop' &&
|
|
1428
|
+
HARNESS_CAPABILITIES[harness].stopContinuation === 'followup-message'
|
|
1429
|
+
) {
|
|
1108
1430
|
const decision = JSON.parse(outcome.stdout) as { decision?: unknown; reason?: unknown }
|
|
1109
1431
|
if (decision.decision === 'block' && typeof decision.reason === 'string') {
|
|
1110
1432
|
stdout = JSON.stringify({ followup_message: decision.reason })
|
|
@@ -1114,11 +1436,77 @@ export async function hookRunCommand(
|
|
|
1114
1436
|
}
|
|
1115
1437
|
return EXIT.ok
|
|
1116
1438
|
} catch (err) {
|
|
1439
|
+
// SessionEnd defers its start record until after cleanup; if cleanup itself
|
|
1440
|
+
// fails, begin the after-the-fact lifecycle here before recording why.
|
|
1441
|
+
if (sessionEnd) start({ cwd, stop_hook_active: envelope.stop_hook_active ?? null })
|
|
1442
|
+
// The hook still exits 0 — handing the terminal back is always right. What
|
|
1443
|
+
// this adds is that the reason survives, including the server's own words.
|
|
1444
|
+
logger.error('hook.end', {
|
|
1445
|
+
hook: event,
|
|
1446
|
+
outcome: 'failed',
|
|
1447
|
+
reason: 'execution-failed',
|
|
1448
|
+
...failureData(err),
|
|
1449
|
+
})
|
|
1117
1450
|
for (const line of describeHookFailure(err)) deps.io.err(`notifai: ${line}`)
|
|
1118
1451
|
return EXIT.ok
|
|
1119
1452
|
}
|
|
1120
1453
|
}
|
|
1121
1454
|
|
|
1455
|
+
/**
|
|
1456
|
+
* The last meter for an answer this Stop hook accepted, chosen by harness.
|
|
1457
|
+
*
|
|
1458
|
+
* Both wake adapters need the harness process that invoked this hook: Claude's
|
|
1459
|
+
* to prove exact own-child session ownership before it posts to the inbox
|
|
1460
|
+
* socket, Codex's to know whether its own stdout is still a live continuation
|
|
1461
|
+
* channel. Without an exact session id neither can prove anything, so the
|
|
1462
|
+
* waiter falls back to the plain blocking Stop continuation.
|
|
1463
|
+
*/
|
|
1464
|
+
function stopWakeRoute(
|
|
1465
|
+
deps: CommandDeps,
|
|
1466
|
+
harness: Harness | undefined,
|
|
1467
|
+
sessionId: string | undefined,
|
|
1468
|
+
cwd: string,
|
|
1469
|
+
): EscalationDeliveryRoute | undefined {
|
|
1470
|
+
if (sessionId === undefined) return undefined
|
|
1471
|
+
if (harness === 'claude-code') {
|
|
1472
|
+
return claudeWakeRoute({
|
|
1473
|
+
sessionId,
|
|
1474
|
+
cwd,
|
|
1475
|
+
sourcePid: deps.claudeSourcePid ?? claudeSessionPid(deps.env),
|
|
1476
|
+
...(deps.claudeWake === undefined ? {} : { adapters: deps.claudeWake }),
|
|
1477
|
+
})
|
|
1478
|
+
}
|
|
1479
|
+
if (harness === 'codex') {
|
|
1480
|
+
return codexWakeRoute({
|
|
1481
|
+
threadId: sessionId,
|
|
1482
|
+
cwd,
|
|
1483
|
+
sourcePid: deps.codexSourcePid ?? process.ppid,
|
|
1484
|
+
env: deps.env,
|
|
1485
|
+
...(deps.codexWake === undefined ? {} : { adapters: deps.codexWake }),
|
|
1486
|
+
})
|
|
1487
|
+
}
|
|
1488
|
+
return undefined
|
|
1489
|
+
}
|
|
1490
|
+
|
|
1491
|
+
/**
|
|
1492
|
+
* The pid of the Claude Code session this hook belongs to.
|
|
1493
|
+
*
|
|
1494
|
+
* Claude exports `CLAUDE_PID` to its hooks, and that is the authoritative
|
|
1495
|
+
* answer. `process.ppid` agrees with it today — the hook command runs through
|
|
1496
|
+
* a shell, but the shell `exec`s, so this process's parent *is* the session
|
|
1497
|
+
* (probed against a live 2.1.228 session) — and it remains the fallback for a
|
|
1498
|
+
* harness build that stops exporting the variable.
|
|
1499
|
+
*
|
|
1500
|
+
* Preferring the explicit value costs a line and removes a silent failure: if
|
|
1501
|
+
* this ever resolved to a shell instead, no session descriptor would match, the
|
|
1502
|
+
* route could not prove own-child ownership, and every answer would degrade to
|
|
1503
|
+
* hold-for-next-turn with nothing reported as wrong.
|
|
1504
|
+
*/
|
|
1505
|
+
function claudeSessionPid(env: NodeJS.ProcessEnv): number {
|
|
1506
|
+
const declared = Number(env['CLAUDE_PID'])
|
|
1507
|
+
return Number.isInteger(declared) && declared > 0 ? declared : process.ppid
|
|
1508
|
+
}
|
|
1509
|
+
|
|
1122
1510
|
/**
|
|
1123
1511
|
* What went wrong, in terms of what to do about it.
|
|
1124
1512
|
*
|
|
@@ -1271,66 +1659,105 @@ function buildOneQuestion(
|
|
|
1271
1659
|
}
|
|
1272
1660
|
|
|
1273
1661
|
/**
|
|
1274
|
-
* Registers a question for turn-end routing
|
|
1275
|
-
*
|
|
1276
|
-
*
|
|
1277
|
-
* `require_idle = false` intentionally permits a push while the user is active.
|
|
1662
|
+
* Registers a question for turn-end routing. Returns immediately so the agent
|
|
1663
|
+
* can ask in prose and end its turn; the terminal keeps the question to itself
|
|
1664
|
+
* for `ask_grace_seconds` before it reaches any device.
|
|
1278
1665
|
*/
|
|
1279
1666
|
export function askCommand(deps: CommandDeps, question: string | undefined, flags: AskFlags): number {
|
|
1667
|
+
// Validate before route discovery. A malformed question belongs to the
|
|
1668
|
+
// caller and should not be hidden behind whichever harness setup issue
|
|
1669
|
+
// happens to exist on this machine.
|
|
1670
|
+
const built = buildQuestions(flags, question)
|
|
1671
|
+
if (!built.ok) {
|
|
1672
|
+
deps.io.err(built.error)
|
|
1673
|
+
return EXIT.usage
|
|
1674
|
+
}
|
|
1675
|
+
const routingConfig = loadConfig({ cwd: deps.cwd, env: deps.env })
|
|
1676
|
+
if (!routingConfig.ask_notifications.value) {
|
|
1677
|
+
deps.io.err(
|
|
1678
|
+
'Question routing is disabled by ask_notifications=false; enable it or use a blocking `notifai send --reply` question.',
|
|
1679
|
+
)
|
|
1680
|
+
return EXIT.usage
|
|
1681
|
+
}
|
|
1682
|
+
if (deps.store.load() === null) {
|
|
1683
|
+
deps.io.err(
|
|
1684
|
+
'Question routing is not paired on this machine; run `notifai login` before registering an asynchronous question.',
|
|
1685
|
+
)
|
|
1686
|
+
return EXIT.usage
|
|
1687
|
+
}
|
|
1280
1688
|
// 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.
|
|
1689
|
+
// markers identify the active owner, while UserPromptSubmit adds the hook's
|
|
1690
|
+
// canonical id to the directory's concurrent-session index.
|
|
1287
1691
|
const now = (deps.now ?? Date.now)()
|
|
1288
|
-
const
|
|
1289
|
-
const pointer = projectPointer?.sessionId ?? null
|
|
1290
|
-
const active = activeHarnessSession(deps.env)
|
|
1692
|
+
const { active, contested } = resolveActiveHarness(deps.env, deps.cwd, now)
|
|
1291
1693
|
let sessionId: string | undefined
|
|
1292
|
-
if (
|
|
1293
|
-
|
|
1294
|
-
} else if (active !== null) {
|
|
1295
|
-
const installations = findInstallations(deps.cwd, deps.env)
|
|
1694
|
+
if (active !== null) {
|
|
1695
|
+
const installations = findInstallations(deps.cwd, deps.env, deps.hookAdapterHome)
|
|
1296
1696
|
const activeInstalled = installations.some(
|
|
1297
1697
|
(installation) => installation.harness === active.harness,
|
|
1298
1698
|
)
|
|
1299
1699
|
if (!activeInstalled) {
|
|
1300
|
-
for (const line of diagnoseActiveHarnessSession(
|
|
1700
|
+
for (const line of diagnoseActiveHarnessSession(
|
|
1701
|
+
active,
|
|
1702
|
+
'not-installed',
|
|
1703
|
+
installations,
|
|
1704
|
+
contested,
|
|
1705
|
+
)) {
|
|
1301
1706
|
deps.io.err(line)
|
|
1302
1707
|
}
|
|
1303
1708
|
return EXIT.usage
|
|
1304
1709
|
}
|
|
1305
|
-
if (
|
|
1306
|
-
for (const
|
|
1307
|
-
deps.io.err(
|
|
1710
|
+
if (active.sessionId === undefined) {
|
|
1711
|
+
for (const problem of activeQuestionRouteProblems(deps, active, installations)) {
|
|
1712
|
+
deps.io.err(`Question routing is not ready: ${problem}`)
|
|
1308
1713
|
}
|
|
1309
1714
|
return EXIT.usage
|
|
1310
1715
|
}
|
|
1311
|
-
|
|
1312
|
-
|
|
1313
|
-
|
|
1314
|
-
|
|
1315
|
-
|
|
1716
|
+
const projectPointer = readMatchingProjectSessionPointer(
|
|
1717
|
+
deps.cwd,
|
|
1718
|
+
deps.env,
|
|
1719
|
+
now,
|
|
1720
|
+
active.sessionId,
|
|
1721
|
+
active.harness,
|
|
1722
|
+
)
|
|
1723
|
+
if (projectPointer === null) {
|
|
1724
|
+
for (const line of diagnoseActiveHarnessSession(
|
|
1725
|
+
active,
|
|
1726
|
+
'not-fired',
|
|
1727
|
+
installations,
|
|
1728
|
+
contested,
|
|
1729
|
+
)) {
|
|
1316
1730
|
deps.io.err(line)
|
|
1317
1731
|
}
|
|
1318
1732
|
return EXIT.usage
|
|
1319
1733
|
}
|
|
1734
|
+
if (flags.session !== undefined && flags.session !== active.sessionId) {
|
|
1735
|
+
deps.io.err(
|
|
1736
|
+
`Question routing is not ready: --session ${flags.session} does not match the exact active ${active.label} session ${active.sessionId}; refusing cross-session routing.`,
|
|
1737
|
+
)
|
|
1738
|
+
return EXIT.usage
|
|
1739
|
+
}
|
|
1740
|
+
const routeProblems = activeQuestionRouteProblems(deps, active, installations)
|
|
1741
|
+
if (routeProblems.length > 0) {
|
|
1742
|
+
for (const problem of routeProblems) deps.io.err(`Question routing is not ready: ${problem}`)
|
|
1743
|
+
return EXIT.usage
|
|
1744
|
+
}
|
|
1745
|
+
if (readSessionState(projectPointer.sessionId, deps.env).last_stop_at === undefined) {
|
|
1746
|
+
deps.io.err(
|
|
1747
|
+
`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\`.`,
|
|
1748
|
+
)
|
|
1749
|
+
return EXIT.usage
|
|
1750
|
+
}
|
|
1320
1751
|
sessionId = projectPointer.sessionId
|
|
1321
1752
|
} else {
|
|
1322
|
-
sessionId = pointer ?? deps.env['NOTIFAI_SESSION'] ?? undefined
|
|
1323
|
-
}
|
|
1324
|
-
if (!sessionId) {
|
|
1325
1753
|
for (const line of diagnoseMissingSession(deps)) deps.io.err(line)
|
|
1754
|
+
deps.io.err(
|
|
1755
|
+
'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.',
|
|
1756
|
+
)
|
|
1326
1757
|
return EXIT.usage
|
|
1327
1758
|
}
|
|
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)
|
|
1759
|
+
if (!sessionId) {
|
|
1760
|
+
for (const line of diagnoseMissingSession(deps)) deps.io.err(line)
|
|
1334
1761
|
return EXIT.usage
|
|
1335
1762
|
}
|
|
1336
1763
|
try {
|
|
@@ -1345,9 +1772,20 @@ export function askCommand(deps: CommandDeps, question: string | undefined, flag
|
|
|
1345
1772
|
(deps.now ?? Date.now)(),
|
|
1346
1773
|
)
|
|
1347
1774
|
} catch (err) {
|
|
1775
|
+
log(deps).error('ask.registered', { ok: false, session: sessionId, message: String(err) })
|
|
1348
1776
|
deps.io.err(`Could not register the question: ${err instanceof Error ? err.message : String(err)}`)
|
|
1349
1777
|
return EXIT.failed
|
|
1350
1778
|
}
|
|
1779
|
+
// `ask` returns immediately and the push happens later inside a hook, so this
|
|
1780
|
+
// is the only local evidence that the question was ever registered — the
|
|
1781
|
+
// starting point for "the agent asked, and nothing ever reached my phone".
|
|
1782
|
+
log(deps).info('ask.registered', {
|
|
1783
|
+
ok: true,
|
|
1784
|
+
session: sessionId,
|
|
1785
|
+
questions: built.questions.length,
|
|
1786
|
+
text: built.questions[0]!.text,
|
|
1787
|
+
choices: built.questions[0]!.choices?.length ?? 0,
|
|
1788
|
+
})
|
|
1351
1789
|
for (const [index, entry] of built.questions.entries()) {
|
|
1352
1790
|
const prefix = built.questions.length > 1 ? `${index + 1}. ` : ''
|
|
1353
1791
|
if (entry.choices !== undefined) {
|
|
@@ -1359,8 +1797,32 @@ export function askCommand(deps: CommandDeps, question: string | undefined, flag
|
|
|
1359
1797
|
}
|
|
1360
1798
|
deps.io.out(
|
|
1361
1799
|
built.questions.length > 1
|
|
1362
|
-
? `${built.questions.length} questions registered as one form. Ask them in the conversation
|
|
1363
|
-
: 'Question registered. Ask it in the conversation
|
|
1800
|
+
? `${built.questions.length} questions registered as one form. Ask them in the conversation, state the concrete work you will resume for their answers, then end your turn.`
|
|
1801
|
+
: 'Question registered. Ask it in the conversation, state the concrete work you will resume when the answer arrives, then end your turn.',
|
|
1802
|
+
)
|
|
1803
|
+
deps.io.out('Before ending this turn, pre-commit in your own words to the work you will resume:')
|
|
1804
|
+
for (const [index, entry] of built.questions.entries()) {
|
|
1805
|
+
const questionPrefix = built.questions.length > 1 ? `Question ${index + 1}, ` : ''
|
|
1806
|
+
if (entry.choices !== undefined) {
|
|
1807
|
+
for (const choice of entry.choices) {
|
|
1808
|
+
deps.io.out(
|
|
1809
|
+
`- ${questionPrefix}If the answer is ${JSON.stringify(choice.label)}: state the concrete work you will resume.`,
|
|
1810
|
+
)
|
|
1811
|
+
}
|
|
1812
|
+
deps.io.out(
|
|
1813
|
+
`- ${questionPrefix}For an unexpected typed answer: state how it will determine the concrete work you resume.`,
|
|
1814
|
+
)
|
|
1815
|
+
} else {
|
|
1816
|
+
deps.io.out(
|
|
1817
|
+
`- ${questionPrefix}For the free-text answer: state how its content will determine the concrete work you resume.`,
|
|
1818
|
+
)
|
|
1819
|
+
}
|
|
1820
|
+
}
|
|
1821
|
+
deps.io.out(
|
|
1822
|
+
'When the answer arrives, resume the matching work without asking the user to confirm again. Frame this as work you will resume, not as approval you receive.',
|
|
1823
|
+
)
|
|
1824
|
+
deps.io.out(
|
|
1825
|
+
'A Notifai answer cannot answer a harness permission prompt or interactive picker; leave those to the harness and user.',
|
|
1364
1826
|
)
|
|
1365
1827
|
return EXIT.ok
|
|
1366
1828
|
}
|
|
@@ -1378,30 +1840,146 @@ interface ActiveHarnessSession {
|
|
|
1378
1840
|
sessionId?: string
|
|
1379
1841
|
}
|
|
1380
1842
|
|
|
1381
|
-
|
|
1382
|
-
|
|
1383
|
-
|
|
1843
|
+
/**
|
|
1844
|
+
* Every harness marker present in this environment, in declared order.
|
|
1845
|
+
*
|
|
1846
|
+
* Order here is a last resort, not an answer. A harness exports its markers
|
|
1847
|
+
* into every process it starts, so a nested harness inherits its parent's
|
|
1848
|
+
* markers alongside its own and the environment alone cannot say which of them
|
|
1849
|
+
* owns this shell; `resolveActiveHarness` settles that with live evidence.
|
|
1850
|
+
*/
|
|
1851
|
+
function harnessEnvCandidates(env: NodeJS.ProcessEnv): ActiveHarnessSession[] {
|
|
1852
|
+
const candidates: ActiveHarnessSession[] = []
|
|
1853
|
+
if (env['NOTIFAI_ACTIVE_HARNESS'] === 'opencode') {
|
|
1384
1854
|
const sessionId = env['NOTIFAI_ACTIVE_SESSION_ID']
|
|
1385
|
-
|
|
1855
|
+
candidates.push({
|
|
1386
1856
|
harness: 'opencode',
|
|
1387
1857
|
label: 'OpenCode',
|
|
1388
1858
|
...(sessionId === undefined || sessionId === '' ? {} : { sessionId }),
|
|
1389
|
-
}
|
|
1859
|
+
})
|
|
1390
1860
|
}
|
|
1391
1861
|
if (env['CLAUDECODE'] === '1') {
|
|
1392
1862
|
const sessionId = env['CLAUDE_CODE_SESSION_ID']
|
|
1393
|
-
|
|
1863
|
+
candidates.push({
|
|
1394
1864
|
harness: 'claude-code',
|
|
1395
1865
|
label: 'Claude Code',
|
|
1396
1866
|
...(sessionId === undefined || sessionId === '' ? {} : { sessionId }),
|
|
1397
|
-
}
|
|
1867
|
+
})
|
|
1398
1868
|
}
|
|
1399
1869
|
const codexSession = env['CODEX_THREAD_ID']
|
|
1400
1870
|
if (codexSession !== undefined && codexSession !== '') {
|
|
1401
|
-
|
|
1871
|
+
candidates.push({ harness: 'codex', label: 'Codex', sessionId: codexSession })
|
|
1402
1872
|
}
|
|
1403
|
-
if ((env['CURSOR_AGENT'] ?? '') !== '')
|
|
1404
|
-
return
|
|
1873
|
+
if ((env['CURSOR_AGENT'] ?? '') !== '') candidates.push({ harness: 'cursor', label: 'Cursor' })
|
|
1874
|
+
return candidates
|
|
1875
|
+
}
|
|
1876
|
+
|
|
1877
|
+
interface ActiveHarnessResolution {
|
|
1878
|
+
active: ActiveHarnessSession | null
|
|
1879
|
+
/**
|
|
1880
|
+
* Markers of harnesses that could equally own this shell, present only when
|
|
1881
|
+
* nothing here has fired yet and declared order had to pick. Whatever is
|
|
1882
|
+
* reported then has to hold for every one of them.
|
|
1883
|
+
*/
|
|
1884
|
+
contested: ActiveHarnessSession[]
|
|
1885
|
+
}
|
|
1886
|
+
|
|
1887
|
+
/**
|
|
1888
|
+
* Which harness session owns this shell, when several claim to.
|
|
1889
|
+
*
|
|
1890
|
+
* Nesting is ordinary: an orchestrator running inside Claude Code starts a
|
|
1891
|
+
* Codex session, and that Codex process inherits `CLAUDECODE` and
|
|
1892
|
+
* `CLAUDE_CODE_SESSION_ID` on top of its own `CODEX_THREAD_ID`. The mirror is
|
|
1893
|
+
* just as ordinary, so no fixed precedence between two markers can be right —
|
|
1894
|
+
* whichever one it favours is wrong in the opposite nesting, and the cost is
|
|
1895
|
+
* silent: `ask` looks up a session that is not this one, and every remedy the
|
|
1896
|
+
* agent is told to try addresses a harness that is not running here.
|
|
1897
|
+
*
|
|
1898
|
+
* The general rule is to prefer the most specific *live* signal over inherited
|
|
1899
|
+
* environment. An inherited marker is a claim about some ancestor process; a
|
|
1900
|
+
* session id that names an entry in this directory's live pointer index is
|
|
1901
|
+
* evidence that that exact session fired a hook here and its state still
|
|
1902
|
+
* exists. Live evidence therefore wins over declared order, and the most
|
|
1903
|
+
* recently active pointer wins between two live candidates: the harness whose
|
|
1904
|
+
* turn is running is the one that fired last, while its parent sits blocked on
|
|
1905
|
+
* the child it started. Declared order decides only when nothing here has
|
|
1906
|
+
* fired yet, and it says so — every route fails closed there anyway.
|
|
1907
|
+
*/
|
|
1908
|
+
function resolveActiveHarness(
|
|
1909
|
+
env: NodeJS.ProcessEnv,
|
|
1910
|
+
cwd: string,
|
|
1911
|
+
now: number,
|
|
1912
|
+
): ActiveHarnessResolution {
|
|
1913
|
+
const candidates = harnessEnvCandidates(env)
|
|
1914
|
+
const first = candidates[0]
|
|
1915
|
+
if (first === undefined) return { active: null, contested: [] }
|
|
1916
|
+
if (candidates.length === 1) return { active: first, contested: [] }
|
|
1917
|
+
for (const pointer of readLiveProjectSessionPointers(cwd, env, now)) {
|
|
1918
|
+
const owner = candidates.find(
|
|
1919
|
+
(candidate) =>
|
|
1920
|
+
candidate.harness === pointer.harness && candidate.sessionId === pointer.sessionId,
|
|
1921
|
+
)
|
|
1922
|
+
if (owner !== undefined) return { active: owner, contested: [] }
|
|
1923
|
+
}
|
|
1924
|
+
return { active: first, contested: candidates }
|
|
1925
|
+
}
|
|
1926
|
+
|
|
1927
|
+
function activeHarnessSession(
|
|
1928
|
+
env: NodeJS.ProcessEnv,
|
|
1929
|
+
cwd: string,
|
|
1930
|
+
now: number,
|
|
1931
|
+
): ActiveHarnessSession | null {
|
|
1932
|
+
return resolveActiveHarness(env, cwd, now).active
|
|
1933
|
+
}
|
|
1934
|
+
|
|
1935
|
+
/**
|
|
1936
|
+
* One fail-closed admission gate shared by every active harness route.
|
|
1937
|
+
* Finding a file is not readiness: the exact session, one current definition,
|
|
1938
|
+
* the stable adapter, a long enough Stop owner, and (for Codex) trust must all
|
|
1939
|
+
* be true before `ask` is allowed to create an answerable notification.
|
|
1940
|
+
*/
|
|
1941
|
+
function activeQuestionRouteProblems(
|
|
1942
|
+
deps: CommandDeps,
|
|
1943
|
+
active: ActiveHarnessSession,
|
|
1944
|
+
installations: Installation[],
|
|
1945
|
+
): string[] {
|
|
1946
|
+
const problems: string[] = []
|
|
1947
|
+
if (active.sessionId === undefined) {
|
|
1948
|
+
problems.push(
|
|
1949
|
+
`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`,
|
|
1950
|
+
)
|
|
1951
|
+
}
|
|
1952
|
+
const capability = HARNESS_CAPABILITIES[active.harness]
|
|
1953
|
+
if (capability.stopContinuation === 'unsupported') {
|
|
1954
|
+
problems.push(`${active.label}: ${capability.deliveryContract}`)
|
|
1955
|
+
}
|
|
1956
|
+
const matching = installations.filter(
|
|
1957
|
+
(installation) => installation.harness === active.harness,
|
|
1958
|
+
)
|
|
1959
|
+
if (matching.length > 1) {
|
|
1960
|
+
problems.push(
|
|
1961
|
+
`${matching.length} ${active.label} definitions are active (${matching.map((entry) => entry.file).join(', ')}); keep either project or global routing`,
|
|
1962
|
+
)
|
|
1963
|
+
}
|
|
1964
|
+
for (const installation of matching) {
|
|
1965
|
+
for (const problem of installation.problems ?? []) {
|
|
1966
|
+
problems.push(`${installation.file}: ${problem}`)
|
|
1967
|
+
}
|
|
1968
|
+
for (const handler of installation.handlers) {
|
|
1969
|
+
const event = handlerEvent(handler.command)
|
|
1970
|
+
if (event !== null && !(HOOK_EVENTS as readonly string[]).includes(event)) {
|
|
1971
|
+
problems.push(
|
|
1972
|
+
`${handler.event} in ${installation.file} names the unsupported event ${event}; reinstall the ${active.label} hooks`,
|
|
1973
|
+
)
|
|
1974
|
+
}
|
|
1975
|
+
}
|
|
1976
|
+
}
|
|
1977
|
+
for (const problem of inspectHookAdapter(deps.hookAdapterHome).problems) {
|
|
1978
|
+
problems.push(problem)
|
|
1979
|
+
}
|
|
1980
|
+
for (const installation of matching) problems.push(...stopShapeProblems(installation))
|
|
1981
|
+
problems.push(...codexTrustProblems(matching, deps.env))
|
|
1982
|
+
return [...new Set(problems)]
|
|
1405
1983
|
}
|
|
1406
1984
|
|
|
1407
1985
|
type ActiveHarnessProblem = 'not-installed' | 'not-fired' | 'different-session'
|
|
@@ -1410,11 +1988,22 @@ function diagnoseActiveHarnessSession(
|
|
|
1410
1988
|
active: ActiveHarnessSession,
|
|
1411
1989
|
problem: ActiveHarnessProblem,
|
|
1412
1990
|
installations: Installation[],
|
|
1991
|
+
contested: ActiveHarnessSession[] = [],
|
|
1413
1992
|
): string[] {
|
|
1993
|
+
// Naming one harness confidently is wrong when the environment carries the
|
|
1994
|
+
// markers of several and none of them has fired here: whichever one the
|
|
1995
|
+
// agent is told to prompt may not be the one it is running in.
|
|
1996
|
+
const ambiguity =
|
|
1997
|
+
contested.length > 1
|
|
1998
|
+
? [
|
|
1999
|
+
`Several harness markers are present here (${contested.map((candidate) => candidate.label).join(', ')}) and none names a session that has fired in this directory, so ${active.label} is a guess from the environment. Send the prompt in the harness you are actually running.`,
|
|
2000
|
+
]
|
|
2001
|
+
: []
|
|
1414
2002
|
if (problem === 'not-installed') {
|
|
1415
2003
|
const others = installations.map((installation) => installation.harness)
|
|
1416
2004
|
return [
|
|
1417
2005
|
`Could not register the question for the active ${active.label} session: Notifai ${active.label} hooks are not installed for this project.`,
|
|
2006
|
+
...ambiguity,
|
|
1418
2007
|
...(others.length === 0
|
|
1419
2008
|
? []
|
|
1420
2009
|
: [
|
|
@@ -1427,12 +2016,14 @@ function diagnoseActiveHarnessSession(
|
|
|
1427
2016
|
if (problem === 'different-session') {
|
|
1428
2017
|
return [
|
|
1429
2018
|
`Could not register the question for the active ${active.label} session: the project pointer belongs to another ${active.label} session or harness.`,
|
|
2019
|
+
...ambiguity,
|
|
1430
2020
|
`Refusing to guess or cross-wire the question. Send one prompt in this ${active.label} session, then run \`notifai doctor\`.`,
|
|
1431
2021
|
`Retry \`notifai ask\` only after doctor reports that this active ${active.label} session fired the hooks.`,
|
|
1432
2022
|
]
|
|
1433
2023
|
}
|
|
1434
2024
|
return [
|
|
1435
2025
|
`Could not register the question for the active ${active.label} session: Notifai hooks are installed, but this session has not published its pointer.`,
|
|
2026
|
+
...ambiguity,
|
|
1436
2027
|
`Send one ${active.label} prompt, then run \`notifai doctor\`.`,
|
|
1437
2028
|
`Retry \`notifai ask\` only after doctor reports that the active ${active.label} session fired the hooks.`,
|
|
1438
2029
|
]
|
|
@@ -1448,7 +2039,7 @@ function diagnoseActiveHarnessSession(
|
|
|
1448
2039
|
* checked after a prompt before assuming that a new session is required.
|
|
1449
2040
|
*/
|
|
1450
2041
|
function diagnoseMissingSession(deps: CommandDeps): string[] {
|
|
1451
|
-
const installations = findInstallations(deps.cwd, deps.env)
|
|
2042
|
+
const installations = findInstallations(deps.cwd, deps.env, deps.hookAdapterHome)
|
|
1452
2043
|
if (installations.length === 0) {
|
|
1453
2044
|
return [
|
|
1454
2045
|
'Could not tell which harness session this is: no Notifai hooks are installed for this project.',
|
|
@@ -1460,7 +2051,7 @@ function diagnoseMissingSession(deps: CommandDeps): string[] {
|
|
|
1460
2051
|
`Could not tell which harness session this is. Notifai hooks are installed (${where}),`,
|
|
1461
2052
|
'but no usable session pointer from the last 24 hours exists here.',
|
|
1462
2053
|
hookActivationAdvice(installations),
|
|
1463
|
-
'
|
|
2054
|
+
'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
2055
|
]
|
|
1465
2056
|
}
|
|
1466
2057
|
|
|
@@ -1505,7 +2096,7 @@ function hookActivationAdvice(installations: Installation[]): string {
|
|
|
1505
2096
|
}
|
|
1506
2097
|
if (harnesses.has('opencode')) {
|
|
1507
2098
|
advice.push(
|
|
1508
|
-
'OpenCode: restart it, then send one prompt; plugins load at startup,
|
|
2099
|
+
'OpenCode: restart it, then send one prompt; plugins load at startup, but non-blocking question continuation is intentionally unsupported',
|
|
1509
2100
|
)
|
|
1510
2101
|
}
|
|
1511
2102
|
return `${advice.join('. ')}.`
|
|
@@ -1513,7 +2104,7 @@ function hookActivationAdvice(installations: Installation[]): string {
|
|
|
1513
2104
|
|
|
1514
2105
|
/** Retire a question so a late answer is rejected rather than silently lost. */
|
|
1515
2106
|
export async function closeCommand(deps: CommandDeps, requestId: string): Promise<number> {
|
|
1516
|
-
const config =
|
|
2107
|
+
const config = loadLoggedConfig(deps, { cwd: deps.cwd, env: deps.env })
|
|
1517
2108
|
const authed = authedClient(deps, config)
|
|
1518
2109
|
if (!authed) return EXIT.auth
|
|
1519
2110
|
try {
|
|
@@ -1538,43 +2129,41 @@ export function hooksInstallCommand(deps: CommandDeps, flags: HooksInstallFlags)
|
|
|
1538
2129
|
if (!harness) return EXIT.usage
|
|
1539
2130
|
const execPath = flags.execPath ?? process.execPath
|
|
1540
2131
|
const scriptPath = flags.scriptPath ?? process.argv[1] ?? 'notifai'
|
|
1541
|
-
|
|
2132
|
+
let adapterPath: string
|
|
2133
|
+
try {
|
|
2134
|
+
adapterPath = installHookAdapter({ execPath, scriptPath }, deps.hookAdapterHome).path
|
|
2135
|
+
} catch (err) {
|
|
2136
|
+
deps.io.err(`Could not prepare the stable hook adapter: ${String(err)}`)
|
|
2137
|
+
return EXIT.failed
|
|
2138
|
+
}
|
|
2139
|
+
const config = loadLoggedConfig(deps, { cwd: deps.cwd, env: deps.env })
|
|
1542
2140
|
const file = settingsFile(harness, flags.global ?? false, deps.cwd, deps.env)
|
|
1543
2141
|
|
|
1544
2142
|
// OpenCode's adapter is a generated plugin module rather than a handler
|
|
1545
2143
|
// merged into a settings document, so it owns the whole file.
|
|
1546
2144
|
if (harness === 'opencode') {
|
|
1547
2145
|
return installOpencodePlugin(deps, file, {
|
|
1548
|
-
|
|
1549
|
-
|
|
1550
|
-
timeoutSeconds: blockingHookTimeoutSeconds(
|
|
1551
|
-
config.ask_grace_seconds.value,
|
|
1552
|
-
config.hook_reply_timeout_seconds.value,
|
|
1553
|
-
),
|
|
2146
|
+
adapterPath,
|
|
2147
|
+
timeoutSeconds: BLOCKING_STOP_TIMEOUT_SECONDS,
|
|
1554
2148
|
})
|
|
1555
2149
|
}
|
|
1556
2150
|
|
|
1557
2151
|
if (harness === 'cursor') {
|
|
1558
|
-
let
|
|
2152
|
+
let merged: ReturnType<typeof mergeCursorHooks>
|
|
1559
2153
|
try {
|
|
1560
|
-
|
|
1561
|
-
|
|
1562
|
-
|
|
1563
|
-
|
|
1564
|
-
|
|
1565
|
-
|
|
1566
|
-
|
|
1567
|
-
|
|
1568
|
-
|
|
1569
|
-
|
|
1570
|
-
|
|
1571
|
-
|
|
1572
|
-
|
|
1573
|
-
}),
|
|
1574
|
-
scriptPath,
|
|
1575
|
-
)
|
|
1576
|
-
try {
|
|
1577
|
-
applyPlan(file, merged.document)
|
|
2154
|
+
merged = withTargetFileLock(file, () => {
|
|
2155
|
+
const document = loadCursorSettings(file)
|
|
2156
|
+
const result = mergeCursorHooks(
|
|
2157
|
+
document,
|
|
2158
|
+
buildCursorHookConfig({
|
|
2159
|
+
adapterPath,
|
|
2160
|
+
harness: 'cursor',
|
|
2161
|
+
}),
|
|
2162
|
+
scriptPath,
|
|
2163
|
+
)
|
|
2164
|
+
applyPlan(file, result.document)
|
|
2165
|
+
return result
|
|
2166
|
+
})
|
|
1578
2167
|
} catch (err) {
|
|
1579
2168
|
deps.io.err(String(err))
|
|
1580
2169
|
return EXIT.failed
|
|
@@ -1589,31 +2178,27 @@ export function hooksInstallCommand(deps: CommandDeps, flags: HooksInstallFlags)
|
|
|
1589
2178
|
deps.io.out('Send one Cursor prompt, then check `notifai doctor`. If the hook has not fired,')
|
|
1590
2179
|
deps.io.out('start a new Cursor session and try one prompt again.')
|
|
1591
2180
|
}
|
|
1592
|
-
deps.io.out('
|
|
1593
|
-
deps.io.out('
|
|
2181
|
+
deps.io.out('Cursor hooks can emit a native follow-up, but its agent shell does not expose')
|
|
2182
|
+
deps.io.out('the exact conversation id needed to register safely. `notifai ask` therefore')
|
|
2183
|
+
deps.io.out('fails closed; use blocking `notifai send --reply` for questions.')
|
|
1594
2184
|
return EXIT.ok
|
|
1595
2185
|
}
|
|
1596
2186
|
|
|
1597
|
-
let
|
|
2187
|
+
let merged: ReturnType<typeof mergeHooks>
|
|
1598
2188
|
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)
|
|
2189
|
+
merged = withTargetFileLock(file, () => {
|
|
2190
|
+
const document = loadSettings(file)
|
|
2191
|
+
const result = mergeHooks(
|
|
2192
|
+
document,
|
|
2193
|
+
buildHookConfig({
|
|
2194
|
+
adapterPath,
|
|
2195
|
+
harness,
|
|
2196
|
+
}),
|
|
2197
|
+
scriptPath,
|
|
2198
|
+
)
|
|
2199
|
+
applyPlan(file, result.document)
|
|
2200
|
+
return result
|
|
2201
|
+
})
|
|
1617
2202
|
} catch (err) {
|
|
1618
2203
|
deps.io.err(String(err))
|
|
1619
2204
|
return EXIT.failed
|
|
@@ -1627,22 +2212,10 @@ export function hooksInstallCommand(deps: CommandDeps, flags: HooksInstallFlags)
|
|
|
1627
2212
|
}
|
|
1628
2213
|
deps.io.out('')
|
|
1629
2214
|
deps.io.out(
|
|
1630
|
-
config.
|
|
1631
|
-
? `
|
|
1632
|
-
|
|
1633
|
-
`until its ${config.ask_grace_seconds.value}s grace window, counted from registration, ` +
|
|
1634
|
-
`has elapsed; it goes to your devices only while the machine also meets the idle threshold. ` +
|
|
1635
|
-
`Run \`notifai config set require_idle false\` to be notified even while you are working.`
|
|
1636
|
-
: `A question registered with \`notifai ask\` stays in the terminal for ` +
|
|
1637
|
-
`${config.ask_grace_seconds.value}s from registration and then goes to your devices ` +
|
|
1638
|
-
`whether or not you are at this machine ` +
|
|
1639
|
-
`(\`notifai config set require_idle false\` is already in effect).`,
|
|
2215
|
+
config.ask_grace_seconds.value === 0
|
|
2216
|
+
? 'A question registered with `notifai ask` goes to your devices immediately when the agent turn ends, whether or not you are at this machine.'
|
|
2217
|
+
: `A question registered with \`notifai ask\` stays in the terminal for ${config.ask_grace_seconds.value}s from registration and then goes to your devices whether or not you are at this machine.`,
|
|
1640
2218
|
)
|
|
1641
|
-
if (config.require_idle.value) {
|
|
1642
|
-
deps.io.out(
|
|
1643
|
-
'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.',
|
|
1644
|
-
)
|
|
1645
|
-
}
|
|
1646
2219
|
if (harness === 'codex') {
|
|
1647
2220
|
const layer = flags.global ? null : codexLayerDir(deps.cwd)
|
|
1648
2221
|
if (layer !== null) {
|
|
@@ -1660,6 +2233,15 @@ export function hooksInstallCommand(deps: CommandDeps, flags: HooksInstallFlags)
|
|
|
1660
2233
|
}
|
|
1661
2234
|
}
|
|
1662
2235
|
deps.io.out('')
|
|
2236
|
+
if (harness === 'claude-code') {
|
|
2237
|
+
// Codex deliberately gets no counterpart line: its Stop definition is
|
|
2238
|
+
// byte-identical to the one earlier builds wrote, so nothing about its
|
|
2239
|
+
// trust state changed and saying so would invent a gate that is not there.
|
|
2240
|
+
deps.io.out(
|
|
2241
|
+
`The Stop handler is asynchronous: it returns at once, so your turn ends normally, and it waits for your answer out of band for up to ${CLAUDE_ASYNC_STOP_TIMEOUT_SECONDS}s before delivering it back into this session. That timeout is written explicitly because Claude Code kills a background hook at its own 600s default and reports nothing when it does.`,
|
|
2242
|
+
)
|
|
2243
|
+
deps.io.out('')
|
|
2244
|
+
}
|
|
1663
2245
|
if (harness === 'claude-code' && flags.global !== true) {
|
|
1664
2246
|
deps.io.out('Claude Code reloads project hook files without a restart. Send one new prompt,')
|
|
1665
2247
|
deps.io.out('then check `notifai doctor` to confirm that the hook fired.')
|
|
@@ -1681,18 +2263,23 @@ export function hooksInstallCommand(deps: CommandDeps, flags: HooksInstallFlags)
|
|
|
1681
2263
|
function installOpencodePlugin(
|
|
1682
2264
|
deps: CommandDeps,
|
|
1683
2265
|
file: string,
|
|
1684
|
-
options: {
|
|
2266
|
+
options: { adapterPath: string; timeoutSeconds: number },
|
|
1685
2267
|
): 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
2268
|
try {
|
|
1694
|
-
|
|
1695
|
-
|
|
2269
|
+
withTargetFileLock(file, () => {
|
|
2270
|
+
if (existsSync(file)) {
|
|
2271
|
+
assertOwnedRegularFile(file)
|
|
2272
|
+
const existing = readFileSync(file, 'utf8')
|
|
2273
|
+
if (!isOurOpencodePlugin(existing)) {
|
|
2274
|
+
throw new Error(`${file} exists and was not written by Notifai; move it aside first.`)
|
|
2275
|
+
}
|
|
2276
|
+
}
|
|
2277
|
+
atomicWriteFileSync(file, opencodePluginSource(options), {
|
|
2278
|
+
mode: 0o600,
|
|
2279
|
+
preserveMode: false,
|
|
2280
|
+
requireCurrentUserOwner: true,
|
|
2281
|
+
})
|
|
2282
|
+
})
|
|
1696
2283
|
} catch (err) {
|
|
1697
2284
|
deps.io.err(String(err))
|
|
1698
2285
|
return EXIT.failed
|
|
@@ -1702,9 +2289,9 @@ function installOpencodePlugin(
|
|
|
1702
2289
|
deps.io.out('It maps chat.message to presence, session.idle to question escalation, and')
|
|
1703
2290
|
deps.io.out('session.deleted to local cleanup through the same `notifai hook` commands')
|
|
1704
2291
|
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('
|
|
2292
|
+
deps.io.out('OpenCode does not expose a proven exactly-once continuation after session.idle,')
|
|
2293
|
+
deps.io.out('so `notifai ask` fails closed there. Use blocking `notifai send --reply` when')
|
|
2294
|
+
deps.io.out('the answer must return to the agent without another human prompt.')
|
|
1708
2295
|
deps.io.out('')
|
|
1709
2296
|
deps.io.out('Restart OpenCode: it loads plugins once at start.')
|
|
1710
2297
|
return EXIT.ok
|
|
@@ -1715,35 +2302,46 @@ export function hooksUninstallCommand(deps: CommandDeps, flags: HooksInstallFlag
|
|
|
1715
2302
|
if (!harness) return EXIT.usage
|
|
1716
2303
|
const scriptPath = flags.scriptPath ?? process.argv[1] ?? 'notifai'
|
|
1717
2304
|
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
2305
|
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
2306
|
try {
|
|
1735
|
-
|
|
2307
|
+
return withTargetFileLock(file, () => {
|
|
2308
|
+
if (!existsSync(file)) {
|
|
2309
|
+
deps.io.out(`Nothing to remove: ${file} does not exist.`)
|
|
2310
|
+
return EXIT.ok
|
|
2311
|
+
}
|
|
2312
|
+
assertOwnedRegularFile(file)
|
|
2313
|
+
// We own the whole file, but only if we wrote it.
|
|
2314
|
+
if (!isOurOpencodePlugin(readFileSync(file, 'utf8'))) {
|
|
2315
|
+
deps.io.out(`Left ${file} alone: Notifai did not write it.`)
|
|
2316
|
+
return EXIT.ok
|
|
2317
|
+
}
|
|
2318
|
+
rmSync(file, { force: true })
|
|
2319
|
+
deps.io.out(`Removed the Notifai OpenCode plugin at ${file}`)
|
|
2320
|
+
return EXIT.ok
|
|
2321
|
+
})
|
|
1736
2322
|
} catch (err) {
|
|
1737
2323
|
deps.io.err(String(err))
|
|
1738
2324
|
return EXIT.failed
|
|
1739
2325
|
}
|
|
1740
|
-
|
|
2326
|
+
}
|
|
2327
|
+
if (harness === 'cursor') {
|
|
2328
|
+
let stripped: ReturnType<typeof removeCursorHooks> | null
|
|
1741
2329
|
try {
|
|
1742
|
-
|
|
2330
|
+
stripped = withTargetFileLock(file, () => {
|
|
2331
|
+
if (!existsSync(file)) return null
|
|
2332
|
+
const document = loadCursorSettings(file)
|
|
2333
|
+
const result = removeCursorHooks(document, scriptPath)
|
|
2334
|
+
applyPlan(file, result.document)
|
|
2335
|
+
return result
|
|
2336
|
+
})
|
|
1743
2337
|
} catch (err) {
|
|
1744
2338
|
deps.io.err(String(err))
|
|
1745
2339
|
return EXIT.failed
|
|
1746
2340
|
}
|
|
2341
|
+
if (stripped === null) {
|
|
2342
|
+
deps.io.out(`Nothing to remove: ${file} does not exist.`)
|
|
2343
|
+
return EXIT.ok
|
|
2344
|
+
}
|
|
1747
2345
|
deps.io.out(
|
|
1748
2346
|
stripped.replaced.length > 0
|
|
1749
2347
|
? `Removed Notifai hooks (${stripped.replaced.join(', ')}) from ${file}`
|
|
@@ -1751,19 +2349,22 @@ export function hooksUninstallCommand(deps: CommandDeps, flags: HooksInstallFlag
|
|
|
1751
2349
|
)
|
|
1752
2350
|
return EXIT.ok
|
|
1753
2351
|
}
|
|
1754
|
-
let
|
|
2352
|
+
let stripped: ReturnType<typeof removeHooks> | null
|
|
1755
2353
|
try {
|
|
1756
|
-
|
|
2354
|
+
stripped = withTargetFileLock(file, () => {
|
|
2355
|
+
if (!existsSync(file)) return null
|
|
2356
|
+
const document = loadSettings(file)
|
|
2357
|
+
const result = removeHooks(document, scriptPath)
|
|
2358
|
+
applyPlan(file, result.document)
|
|
2359
|
+
return result
|
|
2360
|
+
})
|
|
1757
2361
|
} catch (err) {
|
|
1758
2362
|
deps.io.err(String(err))
|
|
1759
2363
|
return EXIT.failed
|
|
1760
2364
|
}
|
|
1761
|
-
|
|
1762
|
-
|
|
1763
|
-
|
|
1764
|
-
} catch (err) {
|
|
1765
|
-
deps.io.err(String(err))
|
|
1766
|
-
return EXIT.failed
|
|
2365
|
+
if (stripped === null) {
|
|
2366
|
+
deps.io.out(`Nothing to remove: ${file} does not exist.`)
|
|
2367
|
+
return EXIT.ok
|
|
1767
2368
|
}
|
|
1768
2369
|
deps.io.out(
|
|
1769
2370
|
stripped.replaced.length > 0
|
|
@@ -1773,6 +2374,17 @@ export function hooksUninstallCommand(deps: CommandDeps, flags: HooksInstallFlag
|
|
|
1773
2374
|
return EXIT.ok
|
|
1774
2375
|
}
|
|
1775
2376
|
|
|
2377
|
+
function assertOwnedRegularFile(file: string): void {
|
|
2378
|
+
const stat = lstatSync(file)
|
|
2379
|
+
if (stat.isSymbolicLink() || !stat.isFile()) {
|
|
2380
|
+
throw new Error(`${file} is not a regular file; refusing to read or replace it.`)
|
|
2381
|
+
}
|
|
2382
|
+
const uid = typeof process.getuid === 'function' ? process.getuid() : undefined
|
|
2383
|
+
if (uid !== undefined && stat.uid !== uid) {
|
|
2384
|
+
throw new Error(`${file} is owned by uid ${stat.uid}, not the current user.`)
|
|
2385
|
+
}
|
|
2386
|
+
}
|
|
2387
|
+
|
|
1776
2388
|
function resolveHarness(deps: CommandDeps, requested: string | undefined): Harness | null {
|
|
1777
2389
|
if (requested !== undefined) {
|
|
1778
2390
|
if ((HARNESSES as readonly string[]).includes(requested)) return requested as Harness
|
|
@@ -1790,14 +2402,14 @@ function resolveHarness(deps: CommandDeps, requested: string | undefined): Harne
|
|
|
1790
2402
|
}
|
|
1791
2403
|
|
|
1792
2404
|
// ---------------------------------------------------------------------------
|
|
1793
|
-
// config show / set
|
|
2405
|
+
// config show / set / unset
|
|
1794
2406
|
// ---------------------------------------------------------------------------
|
|
1795
2407
|
|
|
1796
2408
|
export function configShowCommand(
|
|
1797
2409
|
deps: CommandDeps,
|
|
1798
2410
|
flags: { json?: boolean; explain?: boolean; plain?: boolean },
|
|
1799
2411
|
): number {
|
|
1800
|
-
const config =
|
|
2412
|
+
const config = loadLoggedConfig(deps, { cwd: deps.cwd, env: deps.env })
|
|
1801
2413
|
if (flags.json) {
|
|
1802
2414
|
const output = Object.fromEntries(
|
|
1803
2415
|
CONFIG_KEYS.map((key) => [key, { value: config[key].value, source: config[key].source }]),
|
|
@@ -1821,9 +2433,10 @@ export function configShowCommand(
|
|
|
1821
2433
|
/**
|
|
1822
2434
|
* One setting, explained in full.
|
|
1823
2435
|
*
|
|
1824
|
-
* The gap this closes: `config show` prints
|
|
1825
|
-
*
|
|
1826
|
-
* TypeScript comment, read by everyone except the
|
|
2436
|
+
* The gap this closes: `config show` prints a value and provenance but that is
|
|
2437
|
+
* not enough to explain the setting's consequences. Every key already had a
|
|
2438
|
+
* careful explanation — in a TypeScript comment, read by everyone except the
|
|
2439
|
+
* person who needed it.
|
|
1827
2440
|
*/
|
|
1828
2441
|
export function configExplainCommand(
|
|
1829
2442
|
deps: CommandDeps,
|
|
@@ -1836,7 +2449,7 @@ export function configExplainCommand(
|
|
|
1836
2449
|
return EXIT.usage
|
|
1837
2450
|
}
|
|
1838
2451
|
const configKey = key as ConfigKey
|
|
1839
|
-
const config =
|
|
2452
|
+
const config = loadLoggedConfig(deps, { cwd: deps.cwd, env: deps.env })
|
|
1840
2453
|
const info = configInfo(configKey)
|
|
1841
2454
|
const entry = config[configKey]
|
|
1842
2455
|
|
|
@@ -1916,15 +2529,8 @@ export async function configSetCommand(
|
|
|
1916
2529
|
rawValue: string,
|
|
1917
2530
|
flags: { project?: boolean; local?: boolean; session?: string; yes?: boolean },
|
|
1918
2531
|
): 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
|
|
2532
|
+
const configKey = configMutationKey(deps, key)
|
|
2533
|
+
if (configKey === null) return EXIT.usage
|
|
1928
2534
|
const info = configInfo(configKey)
|
|
1929
2535
|
let value: unknown = rawValue
|
|
1930
2536
|
if (NUMERIC_CONFIG_KEYS.includes(configKey)) {
|
|
@@ -1956,6 +2562,53 @@ export async function configSetCommand(
|
|
|
1956
2562
|
}
|
|
1957
2563
|
if (key === 'devices') value = rawValue.split(',').map((s) => s.trim()).filter(Boolean)
|
|
1958
2564
|
|
|
2565
|
+
const target = await configMutationTarget(deps, flags)
|
|
2566
|
+
if (target === null) return EXIT.usage
|
|
2567
|
+
if (target.layer === 'global' && Object.is(value, configDefaultValue(configKey))) {
|
|
2568
|
+
deps.io.err(`${key} is already the shipped default (${JSON.stringify(value)}).`)
|
|
2569
|
+
deps.io.err(
|
|
2570
|
+
`Run \`notifai config unset ${key} --yes\` to remove a redundant override instead of creating one.`,
|
|
2571
|
+
)
|
|
2572
|
+
return EXIT.usage
|
|
2573
|
+
}
|
|
2574
|
+
|
|
2575
|
+
if (!flags.yes) {
|
|
2576
|
+
const confirmed = await deps.io.confirm(`Set ${key} = ${JSON.stringify(value)} in ${target.path}?`)
|
|
2577
|
+
if (!confirmed) {
|
|
2578
|
+
deps.io.err('Not confirmed. Pass --yes to skip the confirmation gate.')
|
|
2579
|
+
return EXIT.usage
|
|
2580
|
+
}
|
|
2581
|
+
}
|
|
2582
|
+
|
|
2583
|
+
const existing = existsSync(target.path)
|
|
2584
|
+
? (parseToml(readFileSync(target.path, 'utf8')) as Record<string, unknown>)
|
|
2585
|
+
: {}
|
|
2586
|
+
existing[key] = value
|
|
2587
|
+
mkdirSync(path.dirname(target.path), { recursive: true })
|
|
2588
|
+
writeFileSync(target.path, `${stringifyToml(existing)}\n`)
|
|
2589
|
+
deps.io.out(`Wrote ${key} to ${target.path}`)
|
|
2590
|
+
return EXIT.ok
|
|
2591
|
+
}
|
|
2592
|
+
|
|
2593
|
+
function configMutationKey(deps: CommandDeps, key: string): ConfigKey | null {
|
|
2594
|
+
if (!(CONFIG_KEYS as readonly string[]).includes(key)) {
|
|
2595
|
+
deps.io.err(`Unknown setting "${key}".`)
|
|
2596
|
+
const near = nearestKey(key)
|
|
2597
|
+
if (near !== null) deps.io.err(`Did you mean "${near}"?`)
|
|
2598
|
+
deps.io.err(`Valid settings: ${CONFIG_KEYS.join(', ')}`)
|
|
2599
|
+
deps.io.err('Run `notifai config explain <key>` to see what one of them does.')
|
|
2600
|
+
return null
|
|
2601
|
+
}
|
|
2602
|
+
return key as ConfigKey
|
|
2603
|
+
}
|
|
2604
|
+
|
|
2605
|
+
type ConfigMutationFlags = { project?: boolean; local?: boolean; session?: string; yes?: boolean }
|
|
2606
|
+
type ConfigMutationLayer = 'global' | 'project' | 'local' | 'session'
|
|
2607
|
+
|
|
2608
|
+
async function configMutationTarget(
|
|
2609
|
+
deps: CommandDeps,
|
|
2610
|
+
flags: ConfigMutationFlags,
|
|
2611
|
+
): Promise<{ path: string; layer: ConfigMutationLayer } | null> {
|
|
1959
2612
|
let layer = flags.local ? 'local' : flags.project ? 'project' : 'global'
|
|
1960
2613
|
if (
|
|
1961
2614
|
flags.session === undefined &&
|
|
@@ -1972,7 +2625,7 @@ export async function configSetCommand(
|
|
|
1972
2625
|
])
|
|
1973
2626
|
if (selected === null) {
|
|
1974
2627
|
deps.io.err('No configuration layer selected.')
|
|
1975
|
-
return
|
|
2628
|
+
return null
|
|
1976
2629
|
}
|
|
1977
2630
|
layer = selected
|
|
1978
2631
|
}
|
|
@@ -1984,22 +2637,273 @@ export async function configSetCommand(
|
|
|
1984
2637
|
: layer === 'project'
|
|
1985
2638
|
? (findProjectConfigPath(deps.cwd) ?? path.join(deps.cwd, '.notifai', 'config.toml'))
|
|
1986
2639
|
: globalConfigPath(deps.env)
|
|
2640
|
+
return {
|
|
2641
|
+
path: targetPath,
|
|
2642
|
+
layer: flags.session ? 'session' : (layer as Exclude<ConfigMutationLayer, 'session'>),
|
|
2643
|
+
}
|
|
2644
|
+
}
|
|
1987
2645
|
|
|
2646
|
+
export async function configUnsetCommand(
|
|
2647
|
+
deps: CommandDeps,
|
|
2648
|
+
key: string,
|
|
2649
|
+
flags: ConfigMutationFlags,
|
|
2650
|
+
): Promise<number> {
|
|
2651
|
+
const configKey = configMutationKey(deps, key)
|
|
2652
|
+
if (configKey === null) return EXIT.usage
|
|
2653
|
+
const target = await configMutationTarget(deps, flags)
|
|
2654
|
+
if (target === null) return EXIT.usage
|
|
2655
|
+
const existing = existsSync(target.path)
|
|
2656
|
+
? (parseToml(readFileSync(target.path, 'utf8')) as Record<string, unknown>)
|
|
2657
|
+
: {}
|
|
2658
|
+
if (!Object.prototype.hasOwnProperty.call(existing, configKey)) {
|
|
2659
|
+
deps.io.out(`${configKey} is not set in ${target.path}`)
|
|
2660
|
+
return EXIT.ok
|
|
2661
|
+
}
|
|
1988
2662
|
if (!flags.yes) {
|
|
1989
|
-
const confirmed = await deps.io.confirm(`
|
|
2663
|
+
const confirmed = await deps.io.confirm(`Remove ${configKey} from ${target.path}?`)
|
|
1990
2664
|
if (!confirmed) {
|
|
1991
2665
|
deps.io.err('Not confirmed. Pass --yes to skip the confirmation gate.')
|
|
1992
2666
|
return EXIT.usage
|
|
1993
2667
|
}
|
|
1994
2668
|
}
|
|
1995
2669
|
|
|
1996
|
-
|
|
1997
|
-
|
|
1998
|
-
:
|
|
1999
|
-
|
|
2000
|
-
|
|
2001
|
-
|
|
2002
|
-
deps.io.out(`
|
|
2670
|
+
delete existing[configKey]
|
|
2671
|
+
if (Object.keys(existing).length === 0) {
|
|
2672
|
+
rmSync(target.path, { force: true })
|
|
2673
|
+
} else {
|
|
2674
|
+
writeFileSync(target.path, `${stringifyToml(existing)}\n`)
|
|
2675
|
+
}
|
|
2676
|
+
deps.io.out(`Removed ${configKey} from ${target.path}; the inherited value now applies.`)
|
|
2677
|
+
return EXIT.ok
|
|
2678
|
+
}
|
|
2679
|
+
|
|
2680
|
+
// ---------------------------------------------------------------------------
|
|
2681
|
+
// logs
|
|
2682
|
+
// ---------------------------------------------------------------------------
|
|
2683
|
+
|
|
2684
|
+
export interface LogsFlags {
|
|
2685
|
+
json?: boolean
|
|
2686
|
+
limit?: number
|
|
2687
|
+
all?: boolean
|
|
2688
|
+
since?: string
|
|
2689
|
+
level?: string
|
|
2690
|
+
event?: string[]
|
|
2691
|
+
run?: string
|
|
2692
|
+
request?: string
|
|
2693
|
+
session?: string
|
|
2694
|
+
project?: string
|
|
2695
|
+
allProjects?: boolean
|
|
2696
|
+
grep?: string
|
|
2697
|
+
path?: boolean
|
|
2698
|
+
clear?: boolean
|
|
2699
|
+
}
|
|
2700
|
+
|
|
2701
|
+
const LOG_RECORD_OPTIONS: readonly (readonly [keyof LogsFlags, string])[] = [
|
|
2702
|
+
['limit', '--limit'],
|
|
2703
|
+
['all', '--all'],
|
|
2704
|
+
['since', '--since'],
|
|
2705
|
+
['level', '--level'],
|
|
2706
|
+
['event', '--event'],
|
|
2707
|
+
['run', '--run'],
|
|
2708
|
+
['request', '--request'],
|
|
2709
|
+
['session', '--session'],
|
|
2710
|
+
['project', '--project'],
|
|
2711
|
+
['allProjects', '--all-projects'],
|
|
2712
|
+
['grep', '--grep'],
|
|
2713
|
+
]
|
|
2714
|
+
|
|
2715
|
+
/**
|
|
2716
|
+
* Deliberately small. The reader is usually a model with a finite context, and
|
|
2717
|
+
* a log command whose default answer is ten thousand lines gets used once.
|
|
2718
|
+
* Everything beyond this is reachable by asking for it.
|
|
2719
|
+
*/
|
|
2720
|
+
const DEFAULT_LOG_LIMIT = 30
|
|
2721
|
+
/** Even `--all` stops somewhere; an unbounded dump is never the useful answer. */
|
|
2722
|
+
const MAX_LOG_LIMIT = 2_000
|
|
2723
|
+
|
|
2724
|
+
/** `10m`, `2h`, `1d`, or an ISO 8601 instant. */
|
|
2725
|
+
export function parseSince(raw: string, now: number): number | null {
|
|
2726
|
+
const relative = /^(\d+)([smhd])$/.exec(raw.trim())
|
|
2727
|
+
if (relative !== null) {
|
|
2728
|
+
const amount = Number(relative[1])
|
|
2729
|
+
const unit = relative[2] as 's' | 'm' | 'h' | 'd'
|
|
2730
|
+
const seconds = { s: 1, m: 60, h: 3600, d: 86400 }[unit]
|
|
2731
|
+
return now - amount * seconds * 1000
|
|
2732
|
+
}
|
|
2733
|
+
const absolute = Date.parse(raw)
|
|
2734
|
+
return Number.isNaN(absolute) ? null : absolute
|
|
2735
|
+
}
|
|
2736
|
+
|
|
2737
|
+
/**
|
|
2738
|
+
* What this machine recorded about itself.
|
|
2739
|
+
*
|
|
2740
|
+
* The retrieval half of the local log, and the half that decides whether the
|
|
2741
|
+
* log is worth having. Three things make it usable by an agent rather than
|
|
2742
|
+
* merely available to one: the default is bounded and scoped to this project,
|
|
2743
|
+
* the filters match the questions actually asked ("that run", "that
|
|
2744
|
+
* notification", "just the failures"), and `--json` gives the machine the
|
|
2745
|
+
* records untouched on stdout while every word of explanation goes to stderr.
|
|
2746
|
+
*/
|
|
2747
|
+
export function logsCommand(deps: CommandDeps, flags: LogsFlags): number {
|
|
2748
|
+
const selectedRetrievalOptions = LOG_RECORD_OPTIONS
|
|
2749
|
+
.filter(([key]) => {
|
|
2750
|
+
const value = flags[key]
|
|
2751
|
+
return Array.isArray(value) ? value.length > 0 : value !== undefined && value !== false
|
|
2752
|
+
})
|
|
2753
|
+
.map(([, name]) => name)
|
|
2754
|
+
|
|
2755
|
+
if (flags.path === true && flags.clear === true) {
|
|
2756
|
+
deps.io.err('Pass --path or --clear, not both.')
|
|
2757
|
+
return EXIT.usage
|
|
2758
|
+
}
|
|
2759
|
+
if (flags.path === true && selectedRetrievalOptions.length > 0) {
|
|
2760
|
+
deps.io.err(`--path cannot be combined with record options: ${selectedRetrievalOptions.join(', ')}.`)
|
|
2761
|
+
return EXIT.usage
|
|
2762
|
+
}
|
|
2763
|
+
if (flags.clear === true && selectedRetrievalOptions.length > 0) {
|
|
2764
|
+
deps.io.err(`--clear cannot be combined with record options: ${selectedRetrievalOptions.join(', ')}.`)
|
|
2765
|
+
return EXIT.usage
|
|
2766
|
+
}
|
|
2767
|
+
if (flags.project !== undefined && flags.allProjects === true) {
|
|
2768
|
+
deps.io.err('Pass --project or --all-projects, not both.')
|
|
2769
|
+
return EXIT.usage
|
|
2770
|
+
}
|
|
2771
|
+
if (flags.limit !== undefined && flags.all === true) {
|
|
2772
|
+
deps.io.err('Pass --limit or --all, not both.')
|
|
2773
|
+
return EXIT.usage
|
|
2774
|
+
}
|
|
2775
|
+
if (flags.limit !== undefined && (!Number.isInteger(flags.limit) || flags.limit <= 0)) {
|
|
2776
|
+
deps.io.err('--limit must be a positive integer.')
|
|
2777
|
+
return EXIT.usage
|
|
2778
|
+
}
|
|
2779
|
+
if (flags.limit !== undefined && flags.limit > MAX_LOG_LIMIT) {
|
|
2780
|
+
deps.io.err(`--limit cannot exceed ${MAX_LOG_LIMIT}; narrow the result with --since or filters.`)
|
|
2781
|
+
return EXIT.usage
|
|
2782
|
+
}
|
|
2783
|
+
|
|
2784
|
+
const config = loadLoggedConfig(deps, { cwd: deps.cwd, env: deps.env })
|
|
2785
|
+
const now = (deps.now ?? Date.now)()
|
|
2786
|
+
|
|
2787
|
+
if (flags.path === true) {
|
|
2788
|
+
const usage = logsDiskUsage(deps.env)
|
|
2789
|
+
const archives = archiveLogPaths(deps.env)
|
|
2790
|
+
if (flags.json === true) {
|
|
2791
|
+
deps.io.out(
|
|
2792
|
+
JSON.stringify(
|
|
2793
|
+
{
|
|
2794
|
+
active: activeLogPath(deps.env),
|
|
2795
|
+
archives,
|
|
2796
|
+
files: usage.files,
|
|
2797
|
+
bytes: usage.bytes,
|
|
2798
|
+
level: config.log_level.value,
|
|
2799
|
+
max_bytes: config.log_max_bytes.value,
|
|
2800
|
+
max_files: config.log_max_files.value,
|
|
2801
|
+
local_only: true,
|
|
2802
|
+
},
|
|
2803
|
+
null,
|
|
2804
|
+
2,
|
|
2805
|
+
),
|
|
2806
|
+
)
|
|
2807
|
+
return EXIT.ok
|
|
2808
|
+
}
|
|
2809
|
+
deps.io.out(activeLogPath(deps.env))
|
|
2810
|
+
for (const archive of archives) deps.io.out(archive)
|
|
2811
|
+
deps.io.err(
|
|
2812
|
+
`${usage.files} file(s), ${Math.round(usage.bytes / 1024)} KB, level ${config.log_level.value}. These stay on this machine.`,
|
|
2813
|
+
)
|
|
2814
|
+
return EXIT.ok
|
|
2815
|
+
}
|
|
2816
|
+
|
|
2817
|
+
if (flags.clear === true) {
|
|
2818
|
+
let removed = 0
|
|
2819
|
+
for (const file of [activeLogPath(deps.env), ...archiveLogPaths(deps.env)]) {
|
|
2820
|
+
try {
|
|
2821
|
+
unlinkSync(file)
|
|
2822
|
+
removed += 1
|
|
2823
|
+
} catch {
|
|
2824
|
+
// Absent, or not ours to remove; either way there is nothing to report.
|
|
2825
|
+
}
|
|
2826
|
+
}
|
|
2827
|
+
deps.io.out(
|
|
2828
|
+
flags.json === true
|
|
2829
|
+
? JSON.stringify({ cleared_files: removed })
|
|
2830
|
+
: `Cleared ${removed} log file${removed === 1 ? '' : 's'}.`,
|
|
2831
|
+
)
|
|
2832
|
+
return EXIT.ok
|
|
2833
|
+
}
|
|
2834
|
+
|
|
2835
|
+
const query: LogQuery = {
|
|
2836
|
+
limit: flags.limit ?? (flags.all === true ? MAX_LOG_LIMIT : DEFAULT_LOG_LIMIT),
|
|
2837
|
+
}
|
|
2838
|
+
|
|
2839
|
+
if (flags.since !== undefined) {
|
|
2840
|
+
const since = parseSince(flags.since, now)
|
|
2841
|
+
if (since === null) {
|
|
2842
|
+
deps.io.err(`Could not read "${flags.since}" as a time. Use 10m, 2h, 1d, or an ISO 8601 instant.`)
|
|
2843
|
+
return EXIT.usage
|
|
2844
|
+
}
|
|
2845
|
+
query.since = since
|
|
2846
|
+
}
|
|
2847
|
+
if (flags.level !== undefined) {
|
|
2848
|
+
if (!(LOG_LEVELS as readonly string[]).includes(flags.level)) {
|
|
2849
|
+
deps.io.err(`--level takes one of: ${LOG_LEVELS.join(', ')} — not "${flags.level}".`)
|
|
2850
|
+
return EXIT.usage
|
|
2851
|
+
}
|
|
2852
|
+
query.level = flags.level as LogLevel
|
|
2853
|
+
}
|
|
2854
|
+
if (flags.event !== undefined && flags.event.length > 0) {
|
|
2855
|
+
const unknown = flags.event.filter((name) => !(LOG_EVENTS as readonly string[]).includes(name))
|
|
2856
|
+
if (unknown.length > 0) {
|
|
2857
|
+
deps.io.err(`Unknown event ${unknown.join(', ')}. Valid: ${LOG_EVENTS.join(', ')}`)
|
|
2858
|
+
return EXIT.usage
|
|
2859
|
+
}
|
|
2860
|
+
query.event = flags.event
|
|
2861
|
+
}
|
|
2862
|
+
if (flags.run !== undefined) query.run = flags.run
|
|
2863
|
+
if (flags.session !== undefined) query.session = flags.session
|
|
2864
|
+
if (flags.request !== undefined) query.request = flags.request
|
|
2865
|
+
if (flags.grep !== undefined) query.contains = flags.grep
|
|
2866
|
+
|
|
2867
|
+
// Scoped to this project by default. A machine commonly runs several agents
|
|
2868
|
+
// in several worktrees at once, and the answer to "what just happened" is
|
|
2869
|
+
// useless if three other projects are interleaved through it.
|
|
2870
|
+
const scope =
|
|
2871
|
+
flags.project ?? (flags.allProjects === true ? undefined : (config.project.value ?? undefined))
|
|
2872
|
+
if (scope !== undefined) query.project = scope
|
|
2873
|
+
|
|
2874
|
+
const result = readLogRecords(deps.env, query)
|
|
2875
|
+
|
|
2876
|
+
if (flags.json === true) {
|
|
2877
|
+
for (const record of result.records) deps.io.out(JSON.stringify(record))
|
|
2878
|
+
} else {
|
|
2879
|
+
for (const record of result.records) deps.io.out(renderRecord(record))
|
|
2880
|
+
}
|
|
2881
|
+
|
|
2882
|
+
if (result.records.length === 0) {
|
|
2883
|
+
if (config.log_level.value === 'off') {
|
|
2884
|
+
deps.io.err('Nothing is being recorded: log_level is off.')
|
|
2885
|
+
deps.io.err('Turn it on with `notifai config set log_level info --yes`.')
|
|
2886
|
+
} else if (logsDiskUsage(deps.env).files === 0) {
|
|
2887
|
+
deps.io.err('No log yet. It is written the next time a notifai command or harness hook runs.')
|
|
2888
|
+
} else {
|
|
2889
|
+
deps.io.err('No records matched. Widen it with --all-projects, --since 1d, or --all.')
|
|
2890
|
+
}
|
|
2891
|
+
return EXIT.ok
|
|
2892
|
+
}
|
|
2893
|
+
|
|
2894
|
+
// Everything explanatory goes to stderr so `--json` leaves stdout as clean
|
|
2895
|
+
// JSONL for whatever is parsing it.
|
|
2896
|
+
const notes: string[] = []
|
|
2897
|
+
if (scope !== undefined) notes.push(`project ${scope} (--all-projects for every project)`)
|
|
2898
|
+
if (result.more) {
|
|
2899
|
+
notes.push(
|
|
2900
|
+
query.limit! < MAX_LOG_LIMIT
|
|
2901
|
+
? `more history behind this (-n ${Math.min(query.limit! * 4, MAX_LOG_LIMIT)} or --all)`
|
|
2902
|
+
: `more history exists beyond the ${MAX_LOG_LIMIT}-record safety cap; narrow with --since or filters`,
|
|
2903
|
+
)
|
|
2904
|
+
}
|
|
2905
|
+
if (config.log_level.value !== 'debug') notes.push('`log_level = debug` records request detail')
|
|
2906
|
+
if (notes.length > 0) deps.io.err(`— ${result.records.length} records; ${notes.join('; ')}`)
|
|
2003
2907
|
return EXIT.ok
|
|
2004
2908
|
}
|
|
2005
2909
|
|
|
@@ -2012,7 +2916,7 @@ export async function configSetCommand(
|
|
|
2012
2916
|
* 1.5.x, `owner/repo@name` selects a skill; a Git ref belongs after `#`.
|
|
2013
2917
|
* Keep this immutable and public because the command is printed to users.
|
|
2014
2918
|
*/
|
|
2015
|
-
export const SKILLS_SOURCE = 'Raidiant-io/notifai#v0.
|
|
2919
|
+
export const SKILLS_SOURCE = 'Raidiant-io/notifai#v0.5.0'
|
|
2016
2920
|
|
|
2017
2921
|
function skillSourceParts(): { source: string; ref: string } | null {
|
|
2018
2922
|
const match = /^([^#]+)#(.+)$/.exec(SKILLS_SOURCE)
|
|
@@ -2330,7 +3234,7 @@ async function waitForReadyDevice(deps: CommandDeps, state: ReadinessState): Pro
|
|
|
2330
3234
|
const remedy = state.remedy
|
|
2331
3235
|
if (deps.io.interactive !== true || remedy?.by !== 'user-elsewhere') return 'pending'
|
|
2332
3236
|
|
|
2333
|
-
const config =
|
|
3237
|
+
const config = loadLoggedConfig(deps, { cwd: deps.cwd, env: deps.env })
|
|
2334
3238
|
const authed = authedClient(deps, config)
|
|
2335
3239
|
if (!authed) {
|
|
2336
3240
|
deps.io.err('Could not start the companion-device wait: this machine is not signed in.')
|
|
@@ -2481,7 +3385,7 @@ async function submitSetupProof(
|
|
|
2481
3385
|
* the push, not that a companion process received it.
|
|
2482
3386
|
*/
|
|
2483
3387
|
async function runSetupProof(deps: CommandDeps): Promise<GapCloseResult> {
|
|
2484
|
-
const config =
|
|
3388
|
+
const config = loadLoggedConfig(deps, { cwd: deps.cwd, env: deps.env })
|
|
2485
3389
|
const authed = authedClient(deps, config)
|
|
2486
3390
|
if (!authed) return 'failed'
|
|
2487
3391
|
|
|
@@ -2849,7 +3753,7 @@ export async function assessReadiness(
|
|
|
2849
3753
|
deps: CommandDeps,
|
|
2850
3754
|
options: { skillScope?: SkillScope } = {},
|
|
2851
3755
|
): Promise<Readiness> {
|
|
2852
|
-
const config =
|
|
3756
|
+
const config = loadLoggedConfig(deps, { cwd: deps.cwd, env: deps.env })
|
|
2853
3757
|
const states: ReadinessState[] = []
|
|
2854
3758
|
let accountClient: ApiClient | null = null
|
|
2855
3759
|
let accountDevices: RoutableDevice[] | null = null
|
|
@@ -3234,30 +4138,34 @@ function remedyLine(state: ReadinessState): string {
|
|
|
3234
4138
|
*
|
|
3235
4139
|
* Not everything failed is in the way. A pointer that has never been
|
|
3236
4140
|
* 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
|
|
4141
|
+
* next prompt fixes it and no command can. Treating that as blocking would
|
|
4142
|
+
* mean `init` could only finish after a session had already run — so it
|
|
4143
|
+
* reports as something worth knowing rather than something to
|
|
3242
4144
|
* fix, and `init` walks on to the states it can actually close, delivery
|
|
3243
4145
|
* proof included. Which failures are informational, and what the true remedy
|
|
3244
4146
|
* is, is each check's own call (`HookCheck`).
|
|
3245
4147
|
*/
|
|
3246
4148
|
function hookStates(deps: CommandDeps): ReadinessState[] {
|
|
3247
|
-
const installations = findInstallations(deps.cwd, deps.env)
|
|
3248
|
-
const active = activeHarnessSession(deps.env)
|
|
3249
|
-
const config =
|
|
4149
|
+
const installations = findInstallations(deps.cwd, deps.env, deps.hookAdapterHome)
|
|
4150
|
+
const active = activeHarnessSession(deps.env, deps.cwd, (deps.now ?? Date.now)())
|
|
4151
|
+
const config = loadLoggedConfig(deps, { cwd: deps.cwd, env: deps.env })
|
|
3250
4152
|
const settings: ReadinessState = {
|
|
3251
4153
|
id: 'question-routing-settings',
|
|
3252
4154
|
title: 'Question routing settings',
|
|
3253
|
-
status: 'ready',
|
|
4155
|
+
status: config.ask_notifications.value ? 'ready' : 'gap',
|
|
3254
4156
|
detail: [
|
|
3255
4157
|
`ask_notifications=${config.ask_notifications.value} (${config.ask_notifications.source})`,
|
|
3256
|
-
`require_idle=${config.require_idle.value} (${config.require_idle.source})`,
|
|
3257
|
-
`away_after_seconds=${config.away_after_seconds.value} (${config.away_after_seconds.source})`,
|
|
3258
4158
|
`ask_grace_seconds=${config.ask_grace_seconds.value} (${config.ask_grace_seconds.source})`,
|
|
3259
|
-
`hook_reply_timeout_seconds=${config.hook_reply_timeout_seconds.value} (${config.hook_reply_timeout_seconds.source})`,
|
|
3260
4159
|
].join(', '),
|
|
4160
|
+
...(config.ask_notifications.value
|
|
4161
|
+
? {}
|
|
4162
|
+
: {
|
|
4163
|
+
remedy: {
|
|
4164
|
+
by: 'cli' as const,
|
|
4165
|
+
summary: 'enable asynchronous question routing',
|
|
4166
|
+
command: 'notifai config set ask_notifications true',
|
|
4167
|
+
},
|
|
4168
|
+
}),
|
|
3261
4169
|
}
|
|
3262
4170
|
if (installations.length === 0) {
|
|
3263
4171
|
return [
|
|
@@ -3283,7 +4191,7 @@ function hookStates(deps: CommandDeps): ReadinessState[] {
|
|
|
3283
4191
|
}
|
|
3284
4192
|
|
|
3285
4193
|
/** Real but not in the way; see the note above. */
|
|
3286
|
-
const informational = new Set(
|
|
4194
|
+
const informational = new Set<string>()
|
|
3287
4195
|
return [
|
|
3288
4196
|
...hookChecks(deps).map((check) => ({
|
|
3289
4197
|
id: check.name.replace(/[ ()]+/g, '-').replace(/-$/, ''),
|
|
@@ -3316,6 +4224,49 @@ function hookStates(deps: CommandDeps): ReadinessState[] {
|
|
|
3316
4224
|
]
|
|
3317
4225
|
}
|
|
3318
4226
|
|
|
4227
|
+
/**
|
|
4228
|
+
* Whether an installed Stop handler declares the shape its harness needs.
|
|
4229
|
+
*
|
|
4230
|
+
* The three answers differ, and getting one wrong fails silently in a
|
|
4231
|
+
* different way each time:
|
|
4232
|
+
*
|
|
4233
|
+
* - Claude Code's handler must be `async: true`, or the waiter holds the
|
|
4234
|
+
* user's turn for its whole wait instead of returning at once. It must
|
|
4235
|
+
* also declare a `timeout`, because the harness default is 600 s and the
|
|
4236
|
+
* kill is silent — the backgrounded waiter vanishes and the answer the
|
|
4237
|
+
* user already gave is never delivered.
|
|
4238
|
+
* - Codex owns its Stop timeout. Declaring one changes the definition it
|
|
4239
|
+
* hashes into `trusted_hash`, and an untrusted handler is simply not run.
|
|
4240
|
+
* - Everything else blocks its turn and needs a ceiling above the wait.
|
|
4241
|
+
*/
|
|
4242
|
+
function stopShapeProblems(installation: Installation): string[] {
|
|
4243
|
+
if (installation.harness === 'codex') return []
|
|
4244
|
+
const problems: string[] = []
|
|
4245
|
+
for (const handler of installation.handlers.filter(
|
|
4246
|
+
(entry) => handlerEvent(entry.command) === 'stop',
|
|
4247
|
+
)) {
|
|
4248
|
+
if (installation.harness === 'claude-code') {
|
|
4249
|
+
if (handler.async !== true) {
|
|
4250
|
+
problems.push(
|
|
4251
|
+
`${installation.file} declares a blocking Stop handler; the Claude Code wake route needs \`async: true\` so the turn ends while the waiter runs`,
|
|
4252
|
+
)
|
|
4253
|
+
}
|
|
4254
|
+
if (handler.timeout === undefined || handler.timeout < CLAUDE_ASYNC_STOP_TIMEOUT_SECONDS) {
|
|
4255
|
+
problems.push(
|
|
4256
|
+
`${installation.file} gives Stop ${handler.timeout ?? 'no'} declared seconds; Claude Code then kills the backgrounded waiter at its 600s default without reporting anything, so it needs an explicit ${CLAUDE_ASYNC_STOP_TIMEOUT_SECONDS}s`,
|
|
4257
|
+
)
|
|
4258
|
+
}
|
|
4259
|
+
continue
|
|
4260
|
+
}
|
|
4261
|
+
if (handler.timeout === undefined || handler.timeout < BLOCKING_STOP_TIMEOUT_SECONDS) {
|
|
4262
|
+
problems.push(
|
|
4263
|
+
`${installation.file} gives Stop ${handler.timeout ?? 'no'}s, but the blocking answer owner requires ${BLOCKING_STOP_TIMEOUT_SECONDS}s`,
|
|
4264
|
+
)
|
|
4265
|
+
}
|
|
4266
|
+
}
|
|
4267
|
+
return problems
|
|
4268
|
+
}
|
|
4269
|
+
|
|
3319
4270
|
interface HookCheck {
|
|
3320
4271
|
name: string
|
|
3321
4272
|
ok: boolean
|
|
@@ -3328,7 +4279,7 @@ interface HookCheck {
|
|
|
3328
4279
|
|
|
3329
4280
|
function hookChecks(deps: CommandDeps): HookCheck[] {
|
|
3330
4281
|
const checks: HookCheck[] = []
|
|
3331
|
-
const installations = findInstallations(deps.cwd, deps.env)
|
|
4282
|
+
const installations = findInstallations(deps.cwd, deps.env, deps.hookAdapterHome)
|
|
3332
4283
|
|
|
3333
4284
|
// Not having hooks is a setup someone chose, not a fault: `send` works
|
|
3334
4285
|
// without them. A setup that cannot work is what deserves to go red.
|
|
@@ -3348,7 +4299,11 @@ function hookChecks(deps: CommandDeps): HookCheck[] {
|
|
|
3348
4299
|
.join(', '),
|
|
3349
4300
|
})
|
|
3350
4301
|
|
|
3351
|
-
const active =
|
|
4302
|
+
const { active, contested } = resolveActiveHarness(
|
|
4303
|
+
deps.env,
|
|
4304
|
+
deps.cwd,
|
|
4305
|
+
(deps.now ?? Date.now)(),
|
|
4306
|
+
)
|
|
3352
4307
|
const activeInstallations =
|
|
3353
4308
|
active === null
|
|
3354
4309
|
? []
|
|
@@ -3371,7 +4326,16 @@ function hookChecks(deps: CommandDeps): HookCheck[] {
|
|
|
3371
4326
|
}),
|
|
3372
4327
|
})
|
|
3373
4328
|
if (activeInstallations.length > 0) {
|
|
3374
|
-
const pointer =
|
|
4329
|
+
const pointer =
|
|
4330
|
+
active.sessionId === undefined
|
|
4331
|
+
? null
|
|
4332
|
+
: readMatchingProjectSessionPointer(
|
|
4333
|
+
deps.cwd,
|
|
4334
|
+
deps.env,
|
|
4335
|
+
(deps.now ?? Date.now)(),
|
|
4336
|
+
active.sessionId,
|
|
4337
|
+
active.harness,
|
|
4338
|
+
)
|
|
3375
4339
|
if (pointer === null) {
|
|
3376
4340
|
// The normal condition of hooks installed moments ago: the pointer
|
|
3377
4341
|
// appears when the harness next fires a hook, and no command can
|
|
@@ -3388,23 +4352,10 @@ function hookChecks(deps: CommandDeps): HookCheck[] {
|
|
|
3388
4352
|
},
|
|
3389
4353
|
})
|
|
3390
4354
|
} else {
|
|
3391
|
-
const matches =
|
|
3392
|
-
pointer.harness === active.harness &&
|
|
3393
|
-
(active.sessionId === undefined || pointer.sessionId === active.sessionId)
|
|
3394
4355
|
checks.push({
|
|
3395
4356
|
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
|
-
}),
|
|
4357
|
+
ok: true,
|
|
4358
|
+
detail: `the concurrent project index contains the active ${active.label} session`,
|
|
3408
4359
|
})
|
|
3409
4360
|
}
|
|
3410
4361
|
}
|
|
@@ -3432,53 +4383,62 @@ function hookChecks(deps: CommandDeps): HookCheck[] {
|
|
|
3432
4383
|
const adapterProblems = installations.flatMap((installation) =>
|
|
3433
4384
|
(installation.problems ?? []).map((problem) => `${installation.file}: ${problem}`),
|
|
3434
4385
|
)
|
|
4386
|
+
const sharedAdapter = inspectHookAdapter(deps.hookAdapterHome)
|
|
4387
|
+
adapterProblems.push(...sharedAdapter.problems)
|
|
3435
4388
|
if (adapterProblems.length > 0) {
|
|
3436
4389
|
checks.push({
|
|
3437
4390
|
name: 'hooks (adapter)',
|
|
3438
4391
|
ok: false,
|
|
4392
|
+
// A machine-global install for a harness that is not active in this
|
|
4393
|
+
// project is useful diagnosis, but it must not block unrelated init.
|
|
4394
|
+
informational: active === null && installations.every((installation) => installation.global),
|
|
3439
4395
|
detail: adapterProblems.join('; '),
|
|
3440
4396
|
})
|
|
3441
4397
|
}
|
|
3442
4398
|
|
|
3443
|
-
const
|
|
3444
|
-
|
|
3445
|
-
|
|
3446
|
-
|
|
3447
|
-
|
|
3448
|
-
|
|
3449
|
-
|
|
3450
|
-
|
|
3451
|
-
|
|
3452
|
-
|
|
3453
|
-
|
|
3454
|
-
|
|
3455
|
-
|
|
4399
|
+
const trustProblems = codexTrustProblems(installations, deps.env)
|
|
4400
|
+
checks.push({
|
|
4401
|
+
name: 'hooks (trust)',
|
|
4402
|
+
ok: trustProblems.length === 0,
|
|
4403
|
+
detail:
|
|
4404
|
+
trustProblems.length === 0
|
|
4405
|
+
? 'best-effort check matches current persisted Codex approvals; Notifai never writes the trust store, and `/hooks` is authoritative'
|
|
4406
|
+
: `best-effort check only; Notifai never writes the trust store and \`/hooks\` is authoritative. ${trustProblems.join('; ')}`,
|
|
4407
|
+
...(trustProblems.length === 0
|
|
4408
|
+
? {}
|
|
4409
|
+
: {
|
|
4410
|
+
remedy: {
|
|
4411
|
+
summary: 'open `/hooks` in Codex and approve the changed Notifai handlers',
|
|
4412
|
+
command: '/hooks',
|
|
4413
|
+
},
|
|
4414
|
+
}),
|
|
4415
|
+
})
|
|
4416
|
+
|
|
4417
|
+
const shapeProblems = installations.flatMap((installation) =>
|
|
4418
|
+
stopShapeProblems(installation).map(
|
|
4419
|
+
(problem) =>
|
|
4420
|
+
`${problem} — run \`notifai hooks install --harness ${installation.harness}${installation.global ? ' --global' : ''}\``,
|
|
4421
|
+
),
|
|
3456
4422
|
)
|
|
3457
4423
|
checks.push({
|
|
3458
|
-
name: 'hooks (
|
|
3459
|
-
ok:
|
|
4424
|
+
name: 'hooks (stop shape)',
|
|
4425
|
+
ok: shapeProblems.length === 0,
|
|
3460
4426
|
detail:
|
|
3461
|
-
|
|
3462
|
-
? `installed Stop
|
|
3463
|
-
:
|
|
4427
|
+
shapeProblems.length === 0
|
|
4428
|
+
? `every installed Stop handler declares the shape its harness needs: Claude Code async with an explicit ${CLAUDE_ASYNC_STOP_TIMEOUT_SECONDS}s waiter budget, blocking hosts ${BLOCKING_STOP_TIMEOUT_SECONDS}s, Codex host-owned`
|
|
4429
|
+
: shapeProblems.join('; '),
|
|
3464
4430
|
})
|
|
3465
4431
|
|
|
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.
|
|
4432
|
+
// Project and global definitions for one harness both fire. Stable adapter
|
|
4433
|
+
// identity deliberately makes their command bytes equal, so comparing
|
|
4434
|
+
// command targets would now hide this duplicate rather than diagnose it.
|
|
4435
|
+
// Different harnesses remain independent: only the active one runs.
|
|
3472
4436
|
const duplicated = [...new Set(installations.map((i) => i.harness))]
|
|
3473
4437
|
.map((harness) => ({
|
|
3474
4438
|
harness,
|
|
3475
|
-
|
|
3476
|
-
installations
|
|
3477
|
-
.filter((i) => i.harness === harness)
|
|
3478
|
-
.flatMap((i) => i.handlers.map((h) => h.command.split(' hook ')[0] ?? '')),
|
|
3479
|
-
),
|
|
4439
|
+
installations: installations.filter((i) => i.harness === harness),
|
|
3480
4440
|
}))
|
|
3481
|
-
.filter((entry) => entry.
|
|
4441
|
+
.filter((entry) => entry.installations.length > 1)
|
|
3482
4442
|
if (duplicated.length > 0) {
|
|
3483
4443
|
checks.push({
|
|
3484
4444
|
name: 'hooks (duplicates)',
|
|
@@ -3486,43 +4446,128 @@ function hookChecks(deps: CommandDeps): HookCheck[] {
|
|
|
3486
4446
|
detail: duplicated
|
|
3487
4447
|
.map(
|
|
3488
4448
|
(entry) =>
|
|
3489
|
-
`${entry.harness}: ${entry.
|
|
4449
|
+
`${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
4450
|
)
|
|
3491
4451
|
.join('; '),
|
|
3492
4452
|
})
|
|
3493
4453
|
}
|
|
3494
4454
|
|
|
3495
|
-
|
|
3496
|
-
|
|
3497
|
-
|
|
3498
|
-
|
|
4455
|
+
if (active !== null && activeInstallations.length > 0) {
|
|
4456
|
+
const admissionProblems = activeQuestionRouteProblems(deps, active, installations)
|
|
4457
|
+
checks.push({
|
|
4458
|
+
name: 'hooks (question admission)',
|
|
4459
|
+
ok: admissionProblems.length === 0,
|
|
4460
|
+
detail:
|
|
4461
|
+
admissionProblems.length === 0
|
|
4462
|
+
? `the active ${active.label} route is exact, current, singular, trusted where applicable, and bounded by a live owner`
|
|
4463
|
+
: admissionProblems.join('; '),
|
|
4464
|
+
})
|
|
4465
|
+
}
|
|
4466
|
+
|
|
4467
|
+
// Which route this judges, and which remedy it prints, must both belong to
|
|
4468
|
+
// the harness that is actually running here. A machine-global installation
|
|
4469
|
+
// matches every directory, so picking whichever installation matched — or
|
|
4470
|
+
// whichever session last wrote a pointer — reports on a harness the agent
|
|
4471
|
+
// cannot influence: it is told to send a prompt in a harness that is not
|
|
4472
|
+
// running, follows the fail-closed rule, and can never clear the check.
|
|
4473
|
+
const firedPointer =
|
|
4474
|
+
active === null
|
|
4475
|
+
? readProjectSessionPointer(deps.cwd, deps.env, (deps.now ?? Date.now)())
|
|
4476
|
+
: active.sessionId === undefined
|
|
4477
|
+
? (readLiveProjectSessionPointers(deps.cwd, deps.env, (deps.now ?? Date.now)()).find(
|
|
4478
|
+
(pointer) => pointer.harness === active.harness,
|
|
4479
|
+
) ?? null)
|
|
4480
|
+
: readMatchingProjectSessionPointer(
|
|
4481
|
+
deps.cwd,
|
|
4482
|
+
deps.env,
|
|
4483
|
+
(deps.now ?? Date.now)(),
|
|
4484
|
+
active.sessionId,
|
|
4485
|
+
active.harness,
|
|
4486
|
+
)
|
|
4487
|
+
const firedState = firedPointer === null ? null : readSessionState(firedPointer.sessionId, deps.env)
|
|
4488
|
+
const promptFired = firedState?.last_prompt_at !== undefined
|
|
4489
|
+
const stopFired = firedState?.last_stop_at !== undefined
|
|
4490
|
+
const fired = firedPointer !== null && promptFired && stopFired
|
|
4491
|
+
// Installations for other harnesses are irrelevant to the active one, and an
|
|
4492
|
+
// active harness with none of its own has nothing to activate: say that
|
|
4493
|
+
// instead of advising a prompt in some other harness. When the environment
|
|
4494
|
+
// is contested and nothing has fired, every candidate is still possible, so
|
|
4495
|
+
// the advice covers all of them rather than betting on one.
|
|
4496
|
+
const activationHarnesses = new Set(
|
|
4497
|
+
contested.length > 1
|
|
4498
|
+
? contested.map((candidate) => candidate.harness)
|
|
4499
|
+
: active === null
|
|
4500
|
+
? installations.map((installation) => installation.harness)
|
|
4501
|
+
: [active.harness],
|
|
4502
|
+
)
|
|
4503
|
+
const activationInstallations = installations.filter((installation) =>
|
|
4504
|
+
activationHarnesses.has(installation.harness),
|
|
4505
|
+
)
|
|
4506
|
+
const activationAdvice =
|
|
4507
|
+
activationInstallations.length > 0
|
|
4508
|
+
? hookActivationAdvice(activationInstallations)
|
|
4509
|
+
: active === null
|
|
4510
|
+
? hookActivationAdvice(installations)
|
|
4511
|
+
: `${active.label}: no ${active.label} hook installation matches this project — run \`notifai hooks install --harness ${active.harness}\`.`
|
|
3499
4512
|
checks.push({
|
|
3500
4513
|
name: 'hooks (fired)',
|
|
3501
4514
|
ok: fired,
|
|
3502
|
-
//
|
|
3503
|
-
//
|
|
3504
|
-
informational:
|
|
4515
|
+
// A wholly fresh install is informational. Once UserPromptSubmit has fired,
|
|
4516
|
+
// a missing Stop is a broken route, not missing historical evidence.
|
|
4517
|
+
informational: firedPointer === null,
|
|
3505
4518
|
detail: fired
|
|
3506
|
-
?
|
|
3507
|
-
|
|
4519
|
+
? active === null
|
|
4520
|
+
? 'a session in this directory has run UserPromptSubmit and Stop'
|
|
4521
|
+
: `the active ${active.label} session has run UserPromptSubmit and Stop`
|
|
4522
|
+
: firedPointer === null
|
|
4523
|
+
? `no ${
|
|
4524
|
+
contested.length > 1
|
|
4525
|
+
? `session pointer for any harness whose markers are present here (${contested
|
|
4526
|
+
.map((candidate) => candidate.label)
|
|
4527
|
+
.join(', ')})`
|
|
4528
|
+
: active === null
|
|
4529
|
+
? 'session pointer'
|
|
4530
|
+
: `${active.label} session pointer`
|
|
4531
|
+
} from the last 24 hours — ${activationAdvice}`
|
|
4532
|
+
: `the routed session has fired ${promptFired ? 'UserPromptSubmit' : 'neither required event'}, but Stop has not been observed — end one harmless turn, send a new prompt, then check again`,
|
|
3508
4533
|
...(fired
|
|
3509
4534
|
? {}
|
|
3510
4535
|
: {
|
|
3511
4536
|
remedy: {
|
|
3512
|
-
summary:
|
|
4537
|
+
summary:
|
|
4538
|
+
firedPointer === null
|
|
4539
|
+
? `send one ${active === null || contested.length > 1 ? '' : `${active.label} `}prompt in a session here, then re-check`
|
|
4540
|
+
: 'end one harmless turn, send a new prompt, then re-check',
|
|
3513
4541
|
command: 'notifai doctor',
|
|
3514
4542
|
},
|
|
3515
4543
|
}),
|
|
3516
4544
|
})
|
|
3517
4545
|
|
|
3518
|
-
|
|
3519
|
-
|
|
3520
|
-
|
|
3521
|
-
|
|
3522
|
-
|
|
3523
|
-
|
|
3524
|
-
|
|
3525
|
-
|
|
4546
|
+
const continuationInstallations =
|
|
4547
|
+
active !== null ? activeInstallations : installations
|
|
4548
|
+
const continuationHarnesses = [
|
|
4549
|
+
...new Set(continuationInstallations.map((installation) => installation.harness)),
|
|
4550
|
+
]
|
|
4551
|
+
checks.push({
|
|
4552
|
+
name: 'hooks (answer continuation)',
|
|
4553
|
+
ok:
|
|
4554
|
+
continuationHarnesses.length > 0 &&
|
|
4555
|
+
continuationHarnesses.every(
|
|
4556
|
+
(harness) => HARNESS_CAPABILITIES[harness].stopContinuation !== 'unsupported',
|
|
4557
|
+
),
|
|
4558
|
+
informational: active === null,
|
|
4559
|
+
detail:
|
|
4560
|
+
continuationHarnesses.length === 0
|
|
4561
|
+
? active === null
|
|
4562
|
+
? 'no installed harness route to assess'
|
|
4563
|
+
: `the active ${active.label} session has no matching continuation adapter`
|
|
4564
|
+
: continuationHarnesses
|
|
4565
|
+
.map((harness) => `${harness}: ${HARNESS_CAPABILITIES[harness].deliveryContract}`)
|
|
4566
|
+
.join('; '),
|
|
4567
|
+
})
|
|
4568
|
+
|
|
4569
|
+
const wakeRoute = wakeRouteCheck(deps, active, activeInstallations)
|
|
4570
|
+
if (wakeRoute !== null) checks.push(wakeRoute)
|
|
3526
4571
|
|
|
3527
4572
|
const stray = codexStrayWorktreeCheck(deps)
|
|
3528
4573
|
if (stray !== null) checks.push(stray)
|
|
@@ -3530,6 +4575,68 @@ function hookChecks(deps: CommandDeps): HookCheck[] {
|
|
|
3530
4575
|
return checks
|
|
3531
4576
|
}
|
|
3532
4577
|
|
|
4578
|
+
/**
|
|
4579
|
+
* Whether an answer arriving after this turn ends could actually reach this
|
|
4580
|
+
* exact session — the question `notifai ask` really turns on, and the one no
|
|
4581
|
+
* other check answers.
|
|
4582
|
+
*
|
|
4583
|
+
* Read-only, and deliberately so: nothing here connects to a socket, takes a
|
|
4584
|
+
* lock, or sends a message. A diagnostic that wakes the agent it is diagnosing
|
|
4585
|
+
* would be its own bug report.
|
|
4586
|
+
*
|
|
4587
|
+
* Everything it can report negatively is a degradation rather than a failure —
|
|
4588
|
+
* the accepted journal still replays the answer at the session's next turn —
|
|
4589
|
+
* so this is never a blocker. What it buys is that the reason has a name
|
|
4590
|
+
* before the user notices the silence.
|
|
4591
|
+
*
|
|
4592
|
+
* It never asks for `crossSessionInbound`. The poster is the session's own
|
|
4593
|
+
* hook child and takes the privileged own-child path, verified to be delivered
|
|
4594
|
+
* even against a `bypassPermissions` receiver while an unrelated process was
|
|
4595
|
+
* held. Widening a user's general inbound policy to suit Notifai would be a
|
|
4596
|
+
* real change to their machine's posture in exchange for nothing.
|
|
4597
|
+
*/
|
|
4598
|
+
function wakeRouteCheck(
|
|
4599
|
+
deps: CommandDeps,
|
|
4600
|
+
active: ActiveHarnessSession | null,
|
|
4601
|
+
activeInstallations: Installation[],
|
|
4602
|
+
): HookCheck | null {
|
|
4603
|
+
if (active === null || activeInstallations.length === 0) return null
|
|
4604
|
+
if (active.harness === 'claude-code') {
|
|
4605
|
+
const readiness = inspectClaudeInbox({
|
|
4606
|
+
pid: deps.claudeSourcePid ?? claudeSessionPid(deps.env),
|
|
4607
|
+
platform: process.platform,
|
|
4608
|
+
readDescriptor:
|
|
4609
|
+
deps.claudeWake?.readDescriptor ?? systemClaudeWakeAdapters(deps.env).readDescriptor,
|
|
4610
|
+
socketExists: (socketPath) => existsSync(socketPath),
|
|
4611
|
+
})
|
|
4612
|
+
return {
|
|
4613
|
+
name: 'hooks (wake route)',
|
|
4614
|
+
ok: readiness.state === 'ready',
|
|
4615
|
+
informational: true,
|
|
4616
|
+
detail:
|
|
4617
|
+
readiness.state === 'ready'
|
|
4618
|
+
? `this Claude Code ${readiness.version} session is listening on ${readiness.socketPath}, so an answer can start a turn here without you`
|
|
4619
|
+
: `${readiness.reason}. Answers are still delivered, at this session's next turn rather than on their own`,
|
|
4620
|
+
}
|
|
4621
|
+
}
|
|
4622
|
+
if (active.harness === 'codex') {
|
|
4623
|
+
const readiness = inspectCodexResume(deps.env, {
|
|
4624
|
+
platform: process.platform,
|
|
4625
|
+
directoryExists: (directory) => existsSync(directory),
|
|
4626
|
+
})
|
|
4627
|
+
return {
|
|
4628
|
+
name: 'hooks (wake route)',
|
|
4629
|
+
ok: readiness.state === 'ready',
|
|
4630
|
+
informational: true,
|
|
4631
|
+
detail:
|
|
4632
|
+
readiness.state === 'ready'
|
|
4633
|
+
? `the held Codex turn continues from its own hook, and after it returns ${readiness.lockDirectory} can prove a stopped thread unowned before resuming it`
|
|
4634
|
+
: `the held Codex turn still continues from its own hook, but after it returns nothing can be resumed: ${readiness.reason}. Answers wait for the next turn`,
|
|
4635
|
+
}
|
|
4636
|
+
}
|
|
4637
|
+
return null
|
|
4638
|
+
}
|
|
4639
|
+
|
|
3533
4640
|
/**
|
|
3534
4641
|
* A Codex hooks file sitting in a worktree, which Codex will never read.
|
|
3535
4642
|
*
|