@raidiant/notifai 0.2.1 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +20 -4
- package/dist/atomic-file.d.ts +4 -0
- package/dist/atomic-file.js +60 -10
- package/dist/atomic-file.js.map +1 -1
- package/dist/client.d.ts +25 -2
- package/dist/client.js +45 -1
- package/dist/client.js.map +1 -1
- package/dist/commands.d.ts +85 -6
- package/dist/commands.js +1133 -322
- package/dist/commands.js.map +1 -1
- package/dist/config-schema.d.ts +62 -0
- package/dist/config-schema.js +312 -0
- package/dist/config-schema.js.map +1 -0
- package/dist/config.d.ts +38 -3
- package/dist/config.js +61 -14
- package/dist/config.js.map +1 -1
- package/dist/file-lock.d.ts +26 -0
- package/dist/file-lock.js +268 -0
- package/dist/file-lock.js.map +1 -0
- package/dist/harnesses.d.ts +28 -0
- package/dist/harnesses.js +40 -0
- package/dist/harnesses.js.map +1 -0
- package/dist/hook-adapter.d.ts +27 -0
- package/dist/hook-adapter.js +188 -0
- package/dist/hook-adapter.js.map +1 -0
- package/dist/hook-input.d.ts +10 -0
- package/dist/hook-input.js +64 -0
- package/dist/hook-input.js.map +1 -0
- package/dist/hooks.d.ts +66 -10
- package/dist/hooks.js +751 -276
- package/dist/hooks.js.map +1 -1
- package/dist/install-hooks.d.ts +40 -16
- package/dist/install-hooks.js +217 -73
- package/dist/install-hooks.js.map +1 -1
- package/dist/interactive.d.ts +4 -0
- package/dist/interactive.js +604 -0
- package/dist/interactive.js.map +1 -0
- package/dist/logging.d.ts +142 -0
- package/dist/logging.js +533 -0
- package/dist/logging.js.map +1 -0
- package/dist/main.js +201 -51
- package/dist/main.js.map +1 -1
- package/dist/opencode-plugin.d.ts +7 -7
- package/dist/opencode-plugin.js +12 -13
- package/dist/opencode-plugin.js.map +1 -1
- package/dist/ui/banner.d.ts +22 -0
- package/dist/ui/banner.js +87 -0
- package/dist/ui/banner.js.map +1 -0
- package/dist/ui/config-view.d.ts +32 -0
- package/dist/ui/config-view.js +108 -0
- package/dist/ui/config-view.js.map +1 -0
- package/dist/ui/help.d.ts +46 -0
- package/dist/ui/help.js +105 -0
- package/dist/ui/help.js.map +1 -0
- package/dist/ui/theme.d.ts +108 -0
- package/dist/ui/theme.js +261 -0
- package/dist/ui/theme.js.map +1 -0
- package/package.json +11 -9
- package/src/atomic-file.ts +86 -7
- package/src/client.test.ts +12 -0
- package/src/client.ts +72 -4
- package/src/commands.test.ts +971 -44
- package/src/commands.ts +1323 -337
- package/src/config-schema.test.ts +152 -0
- package/src/config-schema.ts +363 -0
- package/src/config.test.ts +6 -5
- package/src/config.ts +85 -16
- package/src/file-lock-process-worker.test.ts +64 -0
- package/src/file-lock.test.ts +279 -0
- package/src/file-lock.ts +318 -0
- package/src/harnesses.test.ts +48 -0
- package/src/harnesses.ts +59 -0
- package/src/hook-adapter.test.ts +187 -0
- package/src/hook-adapter.ts +212 -0
- package/src/hook-input.test.ts +39 -0
- package/src/hook-input.ts +72 -0
- package/src/hooks.test.ts +1318 -240
- package/src/hooks.ts +1046 -296
- package/src/install-hooks.test.ts +90 -41
- package/src/install-hooks.ts +259 -74
- package/src/interactive.ts +692 -0
- package/src/logging-process-worker.test.ts +39 -0
- package/src/logging.test.ts +453 -0
- package/src/logging.ts +639 -0
- package/src/main.ts +220 -48
- package/src/opencode-plugin.ts +15 -16
- package/src/session-end-log-lock-worker.test.ts +35 -0
- package/src/skill.test.ts +11 -0
- package/src/ui/banner.ts +95 -0
- package/src/ui/config-view.test.ts +88 -0
- package/src/ui/config-view.ts +138 -0
- package/src/ui/help.ts +124 -0
- package/src/ui/theme.test.ts +92 -0
- package/src/ui/theme.ts +279 -0
package/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,24 +43,53 @@ import {
|
|
|
40
43
|
type CliConfig,
|
|
41
44
|
type ConfigKey,
|
|
42
45
|
type FlagOverrides,
|
|
46
|
+
type LogLevel,
|
|
43
47
|
} from './config.js'
|
|
48
|
+
import { acceptedValues, configInfo } from './config-schema.js'
|
|
49
|
+
import { atomicWriteFileSync } from './atomic-file.js'
|
|
50
|
+
import { withTargetFileLock } from './file-lock.js'
|
|
51
|
+
import {
|
|
52
|
+
renderConfigExplain,
|
|
53
|
+
renderConfigList,
|
|
54
|
+
renderConfigPlain,
|
|
55
|
+
} from './ui/config-view.js'
|
|
44
56
|
import type { CredentialStore, MachineCredential } from './credentials.js'
|
|
45
|
-
import {
|
|
57
|
+
import {
|
|
58
|
+
firstBlocker,
|
|
59
|
+
openItems,
|
|
60
|
+
type Readiness,
|
|
61
|
+
type ReadinessState,
|
|
62
|
+
type StateStatus,
|
|
63
|
+
} from './readiness.js'
|
|
64
|
+
import type { Tone } from './ui/theme.js'
|
|
46
65
|
import {
|
|
47
66
|
handleSessionEnd,
|
|
48
67
|
handleStop,
|
|
49
68
|
handleUserPromptSubmit,
|
|
50
69
|
parseHookInput,
|
|
51
70
|
pruneAbandonedSessions,
|
|
71
|
+
readMatchingProjectSessionPointer,
|
|
52
72
|
readProjectSession,
|
|
53
73
|
readProjectSessionPointer,
|
|
54
74
|
readSessionState,
|
|
55
75
|
registerQuestion,
|
|
56
76
|
type HookContext,
|
|
57
|
-
type HookEnvelope,
|
|
58
77
|
type HookHarness,
|
|
59
78
|
} from './hooks.js'
|
|
60
79
|
import { readIdleSeconds } from './idle.js'
|
|
80
|
+
import {
|
|
81
|
+
LOG_EVENTS,
|
|
82
|
+
activeLogPath,
|
|
83
|
+
archiveLogPaths,
|
|
84
|
+
logConfigResolved,
|
|
85
|
+
logSettingsFrom,
|
|
86
|
+
logsDiskUsage,
|
|
87
|
+
nullLogger,
|
|
88
|
+
readLogRecords,
|
|
89
|
+
renderRecord,
|
|
90
|
+
type LogQuery,
|
|
91
|
+
type Logger,
|
|
92
|
+
} from './logging.js'
|
|
61
93
|
import {
|
|
62
94
|
HARNESSES,
|
|
63
95
|
applyPlan,
|
|
@@ -65,6 +97,7 @@ import {
|
|
|
65
97
|
buildCursorHookConfig,
|
|
66
98
|
buildHookConfig,
|
|
67
99
|
codexLayerDir,
|
|
100
|
+
codexTrustProblems,
|
|
68
101
|
codexProjectRoot,
|
|
69
102
|
detectHarness,
|
|
70
103
|
findInstallations,
|
|
@@ -79,6 +112,11 @@ import {
|
|
|
79
112
|
type Harness,
|
|
80
113
|
type Installation,
|
|
81
114
|
} from './install-hooks.js'
|
|
115
|
+
import { HARNESS_CAPABILITIES, HOOK_TIMING } from './harnesses.js'
|
|
116
|
+
import {
|
|
117
|
+
inspectHookAdapter,
|
|
118
|
+
installHookAdapter,
|
|
119
|
+
} from './hook-adapter.js'
|
|
82
120
|
import {
|
|
83
121
|
isOurOpencodePlugin,
|
|
84
122
|
opencodePluginSource,
|
|
@@ -113,7 +151,13 @@ export interface CommandIo {
|
|
|
113
151
|
outro?(message: string): Promise<void>
|
|
114
152
|
note?(message: string, title?: string): Promise<void>
|
|
115
153
|
spinner?(message: string): Promise<CommandSpinner | null>
|
|
116
|
-
|
|
154
|
+
/**
|
|
155
|
+
* A report line. `tone` distinguishes the two states that are neither pass
|
|
156
|
+
* nor fail — something optional the user declined, and something that was
|
|
157
|
+
* never evaluated — which a boolean has to round to one or the other.
|
|
158
|
+
* Optional so existing implementations keep type-checking.
|
|
159
|
+
*/
|
|
160
|
+
check?(ok: boolean, message: string, tone?: Tone): Promise<void>
|
|
117
161
|
}
|
|
118
162
|
|
|
119
163
|
export interface CommandSpinner {
|
|
@@ -127,6 +171,8 @@ export interface CommandDeps {
|
|
|
127
171
|
store: CredentialStore
|
|
128
172
|
env: NodeJS.ProcessEnv
|
|
129
173
|
cwd: string
|
|
174
|
+
/** Test seam; production fixes the hook adapter under os.homedir(). */
|
|
175
|
+
hookAdapterHome?: string
|
|
130
176
|
/** Test seam; production uses fetch against base_url. */
|
|
131
177
|
clientFactory?: (baseUrl: string, bearer: string | null, options?: ClientOptions) => ApiClient
|
|
132
178
|
/** Test seam for bounded polling without wall-clock sleeps. */
|
|
@@ -137,6 +183,28 @@ export interface CommandDeps {
|
|
|
137
183
|
idleSeconds?: () => number | null
|
|
138
184
|
/** Test seam and production adapter for the external native skills installer. */
|
|
139
185
|
nativeSkills?: NativeSkills
|
|
186
|
+
/**
|
|
187
|
+
* The local record of what this invocation did. Optional so a test fake need
|
|
188
|
+
* not carry one; `log(deps)` supplies a logger that records nothing.
|
|
189
|
+
*/
|
|
190
|
+
logger?: Logger
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/** The invocation's logger, or one that discards everything. */
|
|
194
|
+
export function log(deps: CommandDeps): Logger {
|
|
195
|
+
return deps.logger ?? nullLogger()
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
function loadLoggedConfig(
|
|
199
|
+
deps: CommandDeps,
|
|
200
|
+
options: Parameters<typeof loadConfig>[0],
|
|
201
|
+
): CliConfig {
|
|
202
|
+
const config = loadConfig(options)
|
|
203
|
+
const logger = log(deps)
|
|
204
|
+
logger.adopt(logSettingsFrom(config))
|
|
205
|
+
logger.bind({ project: config.project.value })
|
|
206
|
+
logConfigResolved(logger, config)
|
|
207
|
+
return config
|
|
140
208
|
}
|
|
141
209
|
|
|
142
210
|
export const EXIT = {
|
|
@@ -154,7 +222,7 @@ function makeClient(
|
|
|
154
222
|
bearer: string | null,
|
|
155
223
|
options?: ClientOptions,
|
|
156
224
|
): ApiClient {
|
|
157
|
-
return (deps.clientFactory ?? createClient)(baseUrl, bearer, options)
|
|
225
|
+
return (deps.clientFactory ?? createClient)(baseUrl, bearer, { ...options, logger: log(deps) })
|
|
158
226
|
}
|
|
159
227
|
|
|
160
228
|
function resolvedBaseUrl(config: CliConfig, credential: MachineCredential | null): string {
|
|
@@ -164,6 +232,10 @@ function resolvedBaseUrl(config: CliConfig, credential: MachineCredential | null
|
|
|
164
232
|
function authedClient(deps: CommandDeps, config: CliConfig): { client: ApiClient; baseUrl: string } | null {
|
|
165
233
|
const credential = deps.store.load()
|
|
166
234
|
if (!credential) {
|
|
235
|
+
// The commonest reason a command does nothing, and one that leaves no other
|
|
236
|
+
// trace: it returns before any request is made, so without this the log
|
|
237
|
+
// shows an exit code and no cause.
|
|
238
|
+
log(deps).error('cli.error', { kind: 'auth', message: 'not signed in', store: deps.store.describe() })
|
|
167
239
|
deps.io.err('Not signed in. Run `notifai login` first.')
|
|
168
240
|
return null
|
|
169
241
|
}
|
|
@@ -174,7 +246,22 @@ function authedClient(deps: CommandDeps, config: CliConfig): { client: ApiClient
|
|
|
174
246
|
}
|
|
175
247
|
}
|
|
176
248
|
|
|
177
|
-
function reportError(
|
|
249
|
+
function reportError(
|
|
250
|
+
deps: CommandDeps,
|
|
251
|
+
err: unknown,
|
|
252
|
+
context: Record<string, unknown> = {},
|
|
253
|
+
): number {
|
|
254
|
+
// Recorded before it is printed: stderr from an agent's shell call is often
|
|
255
|
+
// the one thing that does not survive into the next turn, and this is exactly
|
|
256
|
+
// the line someone comes back looking for.
|
|
257
|
+
log(deps).error('cli.error', {
|
|
258
|
+
...context,
|
|
259
|
+
...(err instanceof ApiCallError
|
|
260
|
+
? { kind: 'api', status: err.status, code: err.code, message: err.message, next_action: err.nextAction, details: err.details }
|
|
261
|
+
: err instanceof NetworkError
|
|
262
|
+
? { kind: 'network', message: err.message }
|
|
263
|
+
: { kind: 'unknown', message: String(err) }),
|
|
264
|
+
})
|
|
178
265
|
if (err instanceof ApiCallError) {
|
|
179
266
|
deps.io.err(`${err.code}: ${err.message}`)
|
|
180
267
|
if (err.nextAction) deps.io.err(`next: ${err.nextAction}`)
|
|
@@ -197,7 +284,7 @@ export async function loginCommand(
|
|
|
197
284
|
deps: CommandDeps,
|
|
198
285
|
flags: { name?: string; baseUrl?: string; open?: boolean },
|
|
199
286
|
): Promise<number> {
|
|
200
|
-
const config =
|
|
287
|
+
const config = loadLoggedConfig(deps, { cwd: deps.cwd, env: deps.env, flags: { base_url: flags.baseUrl } as FlagOverrides })
|
|
201
288
|
const baseUrl = config.base_url.value
|
|
202
289
|
const machineName = flags.name ?? os.hostname()
|
|
203
290
|
const secret = randomBytes(32).toString('base64url')
|
|
@@ -326,7 +413,7 @@ export async function accessStatusCommand(
|
|
|
326
413
|
deps: CommandDeps,
|
|
327
414
|
flags: { json?: boolean },
|
|
328
415
|
): Promise<number> {
|
|
329
|
-
const config =
|
|
416
|
+
const config = loadLoggedConfig(deps, { cwd: deps.cwd, env: deps.env })
|
|
330
417
|
const authed = authedClient(deps, config)
|
|
331
418
|
if (!authed) return EXIT.auth
|
|
332
419
|
try {
|
|
@@ -354,7 +441,7 @@ export async function accessStatusCommand(
|
|
|
354
441
|
// ---------------------------------------------------------------------------
|
|
355
442
|
|
|
356
443
|
export async function devicesCommand(deps: CommandDeps, flags: { json?: boolean }): Promise<number> {
|
|
357
|
-
const config =
|
|
444
|
+
const config = loadLoggedConfig(deps, { cwd: deps.cwd, env: deps.env })
|
|
358
445
|
const authed = authedClient(deps, config)
|
|
359
446
|
if (!authed) return EXIT.auth
|
|
360
447
|
try {
|
|
@@ -387,11 +474,37 @@ export async function devicesCommand(deps: CommandDeps, flags: { json?: boolean
|
|
|
387
474
|
}
|
|
388
475
|
}
|
|
389
476
|
|
|
477
|
+
/**
|
|
478
|
+
* Devices as data, for surfaces that render them themselves.
|
|
479
|
+
*
|
|
480
|
+
* `devicesCommand` prints and returns an exit code, which is the right shape
|
|
481
|
+
* for a command and the wrong one for a menu that wants to show names beside
|
|
482
|
+
* checkboxes. Silent on every failure by design: the interactive app already
|
|
483
|
+
* shows the credential and connectivity state on its status card, so a second
|
|
484
|
+
* error line here would be reporting the same fault twice.
|
|
485
|
+
*/
|
|
486
|
+
export async function deviceInventory(deps: CommandDeps): Promise<RoutableDevice[] | null> {
|
|
487
|
+
if (!deps.store.load()) return null
|
|
488
|
+
const config = loadLoggedConfig(deps, { cwd: deps.cwd, env: deps.env })
|
|
489
|
+
const authed = authedClient(deps, config)
|
|
490
|
+
if (!authed) return null
|
|
491
|
+
try {
|
|
492
|
+
return (await authed.client.listDevices()).devices
|
|
493
|
+
} catch {
|
|
494
|
+
return null
|
|
495
|
+
}
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
/** Whether a device is in a state where a notification would actually arrive. */
|
|
499
|
+
export function canDeviceReceive(device: RoutableDevice): boolean {
|
|
500
|
+
return deviceCanReceive(device)
|
|
501
|
+
}
|
|
502
|
+
|
|
390
503
|
export async function capabilitiesCommand(
|
|
391
504
|
deps: CommandDeps,
|
|
392
505
|
flags: { json?: boolean; platform?: Platform },
|
|
393
506
|
): Promise<number> {
|
|
394
|
-
const config =
|
|
507
|
+
const config = loadLoggedConfig(deps, { cwd: deps.cwd, env: deps.env })
|
|
395
508
|
const credential = deps.store.load()
|
|
396
509
|
const baseUrl = resolvedBaseUrl(config, credential)
|
|
397
510
|
const client = makeClient(deps, baseUrl, null)
|
|
@@ -474,7 +587,7 @@ export async function sendCommand(
|
|
|
474
587
|
deps.io.err(`--reply-window must be an integer from 60 to ${REPLY_MAX_WINDOW_SECONDS} seconds.`)
|
|
475
588
|
return EXIT.usage
|
|
476
589
|
}
|
|
477
|
-
const config =
|
|
590
|
+
const config = loadLoggedConfig(deps, {
|
|
478
591
|
cwd: deps.cwd,
|
|
479
592
|
env: deps.env,
|
|
480
593
|
flags: { base_url: flags.baseUrl, wait_seconds: flags.wait } as FlagOverrides,
|
|
@@ -515,38 +628,71 @@ export async function sendCommand(
|
|
|
515
628
|
}
|
|
516
629
|
const waitSeconds = flags.noWait ? 0 : config.wait_seconds.value
|
|
517
630
|
const idempotencyKey = flags.idempotencyKey ?? `cli-${randomBytes(12).toString('base64url')}`
|
|
631
|
+
let receipt: SubmissionReceipt
|
|
518
632
|
try {
|
|
519
|
-
|
|
633
|
+
receipt = await authed.client.submit(
|
|
520
634
|
{ idempotency_key: idempotencyKey, draft: build.draft },
|
|
521
635
|
waitSeconds,
|
|
522
636
|
)
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
637
|
+
} catch (err) {
|
|
638
|
+
return reportError(deps, err)
|
|
639
|
+
}
|
|
640
|
+
const receiptExit = receiptExitCode(receipt)
|
|
641
|
+
// The single most useful line in the log: it ties the local invocation to
|
|
642
|
+
// the server-side request id, which is what every later question about this
|
|
643
|
+
// notification ("did it arrive?", "which device?") is asked in terms of.
|
|
644
|
+
log(deps).info('send.submitted', {
|
|
645
|
+
request_id: receipt.request_id,
|
|
646
|
+
kind: flags.reply ? 'question' : (flags.kind ?? 'update'),
|
|
647
|
+
title: flags.title,
|
|
648
|
+
overall: receipt.overall,
|
|
649
|
+
replayed: receipt.replayed,
|
|
650
|
+
deliveries: receipt.deliveries.length,
|
|
651
|
+
wait_seconds: waitSeconds,
|
|
652
|
+
exit: receiptExit,
|
|
653
|
+
})
|
|
654
|
+
log(deps).info('send.outcome', {
|
|
655
|
+
request_id: receipt.request_id,
|
|
656
|
+
overall: receipt.overall,
|
|
657
|
+
// Per device, because "it said accepted but nothing arrived" is answered
|
|
658
|
+
// by which device reached which state and why the provider said so.
|
|
659
|
+
devices: receipt.deliveries.map((delivery) => ({
|
|
660
|
+
device: delivery.device_name,
|
|
661
|
+
state: delivery.state,
|
|
662
|
+
attempts: delivery.attempts,
|
|
663
|
+
provider_status: delivery.provider_status,
|
|
664
|
+
provider_reason: delivery.provider_reason,
|
|
665
|
+
})),
|
|
666
|
+
...(receipt.warnings.length > 0 ? { warnings: receipt.warnings } : {}),
|
|
667
|
+
})
|
|
668
|
+
if (!flags.json) deps.io.out(formatReceipt(receipt))
|
|
669
|
+
else if (flags.reply) deps.io.out(JSON.stringify({ type: 'receipt', receipt }))
|
|
526
670
|
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
}
|
|
541
|
-
return receiptExit
|
|
671
|
+
// A zero wait can no longer reach here: --reply guarantees a positive one.
|
|
672
|
+
if (!flags.reply || receiptExit !== EXIT.ok) {
|
|
673
|
+
if (flags.json) {
|
|
674
|
+
deps.io.out(
|
|
675
|
+
flags.reply
|
|
676
|
+
? JSON.stringify({
|
|
677
|
+
type: 'reply_result',
|
|
678
|
+
request_id: receipt.request_id,
|
|
679
|
+
replies: [],
|
|
680
|
+
degraded: false,
|
|
681
|
+
})
|
|
682
|
+
: JSON.stringify(receipt, null, 2),
|
|
683
|
+
)
|
|
542
684
|
}
|
|
685
|
+
return receiptExit
|
|
686
|
+
}
|
|
543
687
|
|
|
688
|
+
try {
|
|
544
689
|
const result = await waitForReply(authed.client, receipt.request_id, {
|
|
545
690
|
timeoutSeconds: replyTimeout,
|
|
546
691
|
afterSeq: 0,
|
|
547
692
|
now: deps.now,
|
|
548
693
|
sleep: deps.sleep,
|
|
549
694
|
})
|
|
695
|
+
recordReplies(deps, receipt.request_id, result.response.replies)
|
|
550
696
|
if (flags.json) {
|
|
551
697
|
deps.io.out(
|
|
552
698
|
JSON.stringify({
|
|
@@ -559,17 +705,48 @@ export async function sendCommand(
|
|
|
559
705
|
} else if (result.response.replies.length > 0) printReplies(deps, result.response.replies)
|
|
560
706
|
else printNoReply(deps, receipt.request_id, result.response.reply_expires_at)
|
|
561
707
|
if (result.degraded) {
|
|
562
|
-
deps.
|
|
708
|
+
log(deps).error('cli.error', {
|
|
709
|
+
kind: 'network',
|
|
710
|
+
operation: 'reply_wait',
|
|
711
|
+
request_id: receipt.request_id,
|
|
712
|
+
degraded: true,
|
|
713
|
+
message: 'the reply wait ended while the server was unreachable or faulting',
|
|
714
|
+
})
|
|
715
|
+
deps.io.err(degradedWaitWarning(receipt.request_id))
|
|
563
716
|
return EXIT.network
|
|
564
717
|
}
|
|
565
718
|
if (result.timedOut) {
|
|
566
719
|
deps.io.err(
|
|
567
720
|
`No reply yet. Retrieve it with \`notifai replies ${receipt.request_id}\` or retire the question with ` +
|
|
568
|
-
`\`notifai close ${receipt.request_id}
|
|
721
|
+
`\`notifai close ${receipt.request_id}\`. ` +
|
|
722
|
+
`This is a reply-wait timeout, not a Delivery or Companion Receipt failure — check with \`notifai status ${receipt.request_id}\`.`,
|
|
569
723
|
)
|
|
570
724
|
}
|
|
571
725
|
return result.timedOut ? EXIT.noReply : EXIT.ok
|
|
572
726
|
} catch (err) {
|
|
727
|
+
// Receipt already printed; a wait fault must not read as "send failed".
|
|
728
|
+
// Permanent poll errors (auth, closed window, not found) still surface, but
|
|
729
|
+
// always name the durable request and point at recovery.
|
|
730
|
+
if (err instanceof ApiCallError || err instanceof NetworkError) {
|
|
731
|
+
log(deps).error('cli.error', {
|
|
732
|
+
kind: err instanceof ApiCallError ? 'api' : 'network',
|
|
733
|
+
operation: 'reply_wait',
|
|
734
|
+
request_id: receipt.request_id,
|
|
735
|
+
...(err instanceof ApiCallError ? { status: err.status, code: err.code } : {}),
|
|
736
|
+
message: err.message,
|
|
737
|
+
})
|
|
738
|
+
deps.io.err(
|
|
739
|
+
`notifai: reply wait failed for ${receipt.request_id} (${err instanceof ApiCallError ? err.code : 'network'}: ${err.message}). ` +
|
|
740
|
+
`Delivery and Companion Receipt are independent — check with \`notifai status ${receipt.request_id}\` and retry with \`notifai replies ${receipt.request_id}\`.`,
|
|
741
|
+
)
|
|
742
|
+
if (err instanceof ApiCallError) {
|
|
743
|
+
if (err.code === 'auth_required' || err.code === 'machine_revoked') return EXIT.auth
|
|
744
|
+
return err.status >= 500 || err.status === 429 || err.status === 408
|
|
745
|
+
? EXIT.network
|
|
746
|
+
: EXIT.failed
|
|
747
|
+
}
|
|
748
|
+
return EXIT.network
|
|
749
|
+
}
|
|
573
750
|
return reportError(deps, err)
|
|
574
751
|
}
|
|
575
752
|
}
|
|
@@ -660,40 +837,49 @@ export async function repliesCommand(
|
|
|
660
837
|
return EXIT.usage
|
|
661
838
|
}
|
|
662
839
|
|
|
663
|
-
const config =
|
|
840
|
+
const config = loadLoggedConfig(deps, { cwd: deps.cwd, env: deps.env })
|
|
664
841
|
const authed = authedClient(deps, config)
|
|
665
842
|
if (!authed) return EXIT.auth
|
|
666
843
|
try {
|
|
667
844
|
let anyReplies = false
|
|
668
|
-
|
|
845
|
+
const degradedRequestIds: string[] = []
|
|
669
846
|
let allTimedOut = true
|
|
670
847
|
const jsonBodies: object[] = []
|
|
671
848
|
for (const requestId of requestIds) {
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
849
|
+
try {
|
|
850
|
+
const result = await waitForReply(authed.client, requestId, {
|
|
851
|
+
timeoutSeconds: waitSeconds,
|
|
852
|
+
afterSeq,
|
|
853
|
+
now: deps.now,
|
|
854
|
+
sleep: deps.sleep,
|
|
855
|
+
})
|
|
856
|
+
recordReplies(deps, requestId, result.response.replies)
|
|
857
|
+
if (flags.json) {
|
|
858
|
+
jsonBodies.push({ ...result.response, degraded: result.degraded })
|
|
859
|
+
} else if (result.response.replies.length > 0) {
|
|
860
|
+
if (flags.pending === true) deps.io.out(`pending request ${requestId}`)
|
|
861
|
+
printReplies(deps, result.response.replies)
|
|
862
|
+
} else {
|
|
863
|
+
printNoReply(deps, requestId, result.response.reply_expires_at)
|
|
864
|
+
}
|
|
865
|
+
anyReplies ||= result.response.replies.length > 0
|
|
866
|
+
if (result.degraded) degradedRequestIds.push(requestId)
|
|
867
|
+
allTimedOut &&= result.timedOut
|
|
868
|
+
} catch (err) {
|
|
869
|
+
recordDegradedReplyWaits(deps, degradedRequestIds)
|
|
870
|
+
return reportError(deps, err, { operation: 'reply_wait', request_id: requestId })
|
|
685
871
|
}
|
|
686
|
-
anyReplies ||= result.response.replies.length > 0
|
|
687
|
-
anyDegraded ||= result.degraded
|
|
688
|
-
allTimedOut &&= result.timedOut
|
|
689
872
|
}
|
|
690
873
|
if (flags.json) {
|
|
691
874
|
// One request keeps the response shape agents already parse; several
|
|
692
875
|
// (only possible via --pending) arrive as an array in queue order.
|
|
693
876
|
deps.io.out(JSON.stringify(jsonBodies.length === 1 ? jsonBodies[0] : jsonBodies, null, 2))
|
|
694
877
|
}
|
|
695
|
-
if (
|
|
696
|
-
deps
|
|
878
|
+
if (degradedRequestIds.length > 0) {
|
|
879
|
+
recordDegradedReplyWaits(deps, degradedRequestIds)
|
|
880
|
+
// Name a request whose polls actually degraded, not merely the first item
|
|
881
|
+
// in a multi-request queue that may have completed cleanly.
|
|
882
|
+
deps.io.err(degradedWaitWarning(degradedRequestIds[0]!))
|
|
697
883
|
return EXIT.network
|
|
698
884
|
}
|
|
699
885
|
if (anyReplies) return EXIT.ok
|
|
@@ -730,8 +916,8 @@ export async function waitForReply(
|
|
|
730
916
|
const sleep = options.sleep ?? ((milliseconds: number) => new Promise((resolve) => setTimeout(resolve, milliseconds)))
|
|
731
917
|
const deadline = now() + options.timeoutSeconds * 1000
|
|
732
918
|
let lastResponse: ListRepliesResponse | null = null
|
|
733
|
-
let
|
|
734
|
-
let
|
|
919
|
+
let lastTransientError: Error | null = null
|
|
920
|
+
let consecutiveTransientErrors = 0
|
|
735
921
|
let firstPoll = true
|
|
736
922
|
|
|
737
923
|
while (firstPoll || now() < deadline) {
|
|
@@ -744,24 +930,31 @@ export async function waitForReply(
|
|
|
744
930
|
afterSeq: options.afterSeq,
|
|
745
931
|
})
|
|
746
932
|
lastResponse = response
|
|
747
|
-
|
|
748
|
-
|
|
933
|
+
lastTransientError = null
|
|
934
|
+
consecutiveTransientErrors = 0
|
|
749
935
|
if (response.replies.length > 0) return { response, timedOut: false, degraded: false }
|
|
750
936
|
|
|
751
937
|
const pauseMs = Math.min(250, Math.max(0, deadline - now()))
|
|
752
938
|
if (pauseMs > 0) await sleep(pauseMs)
|
|
753
939
|
} catch (err) {
|
|
754
|
-
if (!(err
|
|
755
|
-
|
|
756
|
-
|
|
940
|
+
if (!isRetryableReplyPollError(err)) throw err
|
|
941
|
+
lastTransientError = err instanceof Error ? err : new Error(String(err))
|
|
942
|
+
consecutiveTransientErrors += 1
|
|
757
943
|
const remainingAfterError = Math.max(0, deadline - now())
|
|
758
944
|
if (remainingAfterError === 0) break
|
|
759
|
-
const backoffMs = Math.min(
|
|
945
|
+
const backoffMs = Math.min(
|
|
946
|
+
250 * 2 ** (consecutiveTransientErrors - 1),
|
|
947
|
+
2_000,
|
|
948
|
+
remainingAfterError,
|
|
949
|
+
)
|
|
760
950
|
await sleep(backoffMs)
|
|
761
951
|
}
|
|
762
952
|
}
|
|
763
953
|
|
|
764
|
-
|
|
954
|
+
// Never saw a successful poll: surface the fault so callers get EXIT.network
|
|
955
|
+
// rather than a fake empty silence. The durable request is still on the
|
|
956
|
+
// server; the typed recovery is `notifai replies <id>`.
|
|
957
|
+
if (!lastResponse && lastTransientError) throw lastTransientError
|
|
765
958
|
return {
|
|
766
959
|
response:
|
|
767
960
|
lastResponse ??
|
|
@@ -770,23 +963,55 @@ export async function waitForReply(
|
|
|
770
963
|
// A poll succeeded at some point, so we do not throw — but the last thing
|
|
771
964
|
// we know is that we could not reach the server. Reporting that as a plain
|
|
772
965
|
// "no reply" would let an agent treat an unseen refusal as consent.
|
|
773
|
-
degraded:
|
|
966
|
+
degraded: lastTransientError !== null,
|
|
774
967
|
}
|
|
775
968
|
}
|
|
776
969
|
|
|
970
|
+
function recordDegradedReplyWaits(deps: CommandDeps, requestIds: readonly string[]): void {
|
|
971
|
+
if (requestIds.length === 0) return
|
|
972
|
+
log(deps).error('cli.error', {
|
|
973
|
+
kind: 'network',
|
|
974
|
+
operation: 'reply_wait',
|
|
975
|
+
request_ids: requestIds,
|
|
976
|
+
degraded: true,
|
|
977
|
+
message: 'the reply wait ended while the server was unreachable or faulting',
|
|
978
|
+
})
|
|
979
|
+
}
|
|
980
|
+
|
|
777
981
|
/**
|
|
778
982
|
* Shared by every surface that waits: "the user did not answer" and "I could
|
|
779
983
|
* not find out" must not look the same, because agents branch on the exit code
|
|
780
984
|
* and one of those two branches is safe to proceed from.
|
|
985
|
+
*
|
|
986
|
+
* Delivery / Companion Receipt / OS presentation are separate facts — a wait
|
|
987
|
+
* fault is not evidence that the push failed.
|
|
781
988
|
*/
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
989
|
+
function degradedWaitWarning(requestId: string): string {
|
|
990
|
+
return (
|
|
991
|
+
`notifai: the wait for ${requestId} ended while the server was unreachable or faulting, ` +
|
|
992
|
+
`so this is "could not find out", not "no answer" — the request is still durable and the ` +
|
|
993
|
+
`reply may already be waiting (Provider Acceptance and Companion Receipt are independent). ` +
|
|
994
|
+
`Retry with \`notifai replies ${requestId}\`.`
|
|
995
|
+
)
|
|
996
|
+
}
|
|
786
997
|
function isNonNegativeInteger(value: number): boolean {
|
|
787
998
|
return Number.isInteger(value) && value >= 0
|
|
788
999
|
}
|
|
789
1000
|
|
|
1001
|
+
function recordReplies(deps: CommandDeps, requestId: string, replies: readonly ReplyView[]): void {
|
|
1002
|
+
const logger = log(deps)
|
|
1003
|
+
for (const reply of replies) {
|
|
1004
|
+
logger.info('reply.received', {
|
|
1005
|
+
request_id: requestId,
|
|
1006
|
+
sequence: reply.seq,
|
|
1007
|
+
device: reply.device_name,
|
|
1008
|
+
text: reply.text,
|
|
1009
|
+
answers: reply.answers,
|
|
1010
|
+
source: reply.source,
|
|
1011
|
+
})
|
|
1012
|
+
}
|
|
1013
|
+
}
|
|
1014
|
+
|
|
790
1015
|
function printReplies(deps: CommandDeps, replies: ReplyView[]): void {
|
|
791
1016
|
for (const reply of replies) deps.io.out(`reply from ${reply.device_name}: ${reply.text}`)
|
|
792
1017
|
const contradiction = contradictingAnswer(replies)
|
|
@@ -835,7 +1060,7 @@ export async function statusCommand(
|
|
|
835
1060
|
requestId: string,
|
|
836
1061
|
flags: { json?: boolean },
|
|
837
1062
|
): Promise<number> {
|
|
838
|
-
const config =
|
|
1063
|
+
const config = loadLoggedConfig(deps, { cwd: deps.cwd, env: deps.env })
|
|
839
1064
|
const authed = authedClient(deps, config)
|
|
840
1065
|
if (!authed) return EXIT.auth
|
|
841
1066
|
try {
|
|
@@ -845,6 +1070,7 @@ export async function statusCommand(
|
|
|
845
1070
|
return EXIT.ok
|
|
846
1071
|
}
|
|
847
1072
|
deps.io.out(`request ${snapshot.request_id} (${snapshot.event ?? 'no event'}) — ${snapshot.overall}`)
|
|
1073
|
+
let anyReplyReceived = false
|
|
848
1074
|
for (const d of snapshot.deliveries) {
|
|
849
1075
|
deps.io.out(` ${d.device_name}:`)
|
|
850
1076
|
deps.io.out(` Delivery: ${d.state} after ${d.attempts} attempt(s)`)
|
|
@@ -862,10 +1088,27 @@ export async function statusCommand(
|
|
|
862
1088
|
" Companion Receipt (the app's delivery confirmation): unknown — not observed; this is not a failure or proof of non-receipt",
|
|
863
1089
|
)
|
|
864
1090
|
}
|
|
1091
|
+
// Notifai never learns whether the OS painted a banner; saying so stops
|
|
1092
|
+
// a reply-wait fault from being misread as "the phone never showed it".
|
|
1093
|
+
deps.io.out(
|
|
1094
|
+
' OS presentation: not observed by Notifai — Provider Acceptance and Companion Receipt do not prove a banner was shown',
|
|
1095
|
+
)
|
|
1096
|
+
const replyEvent = d.events.find((e) => e.stage === 'reply_received')
|
|
1097
|
+
if (replyEvent) {
|
|
1098
|
+
anyReplyReceived = true
|
|
1099
|
+
deps.io.out(` Reply received: yes (first at ${replyEvent.occurred_at})`)
|
|
1100
|
+
} else {
|
|
1101
|
+
deps.io.out(' Reply received: not yet recorded on this delivery')
|
|
1102
|
+
}
|
|
865
1103
|
for (const e of d.events) {
|
|
866
1104
|
deps.io.out(` ${e.occurred_at} ${e.stage}${e.reason ? ` (${e.reason})` : ''}`)
|
|
867
1105
|
}
|
|
868
1106
|
}
|
|
1107
|
+
deps.io.out(
|
|
1108
|
+
anyReplyReceived
|
|
1109
|
+
? ` Reply wait: answers are on the server — collect with \`notifai replies ${snapshot.request_id}\` (a local wait fault does not erase them)`
|
|
1110
|
+
: ` Reply wait: no answer stored yet — a blocking wait failure is independent of Delivery above; retry with \`notifai replies ${snapshot.request_id}\``,
|
|
1111
|
+
)
|
|
869
1112
|
return EXIT.ok
|
|
870
1113
|
} catch (err) {
|
|
871
1114
|
return reportError(deps, err)
|
|
@@ -934,6 +1177,13 @@ async function uploadImage(deps: CommandDeps, client: ApiClient, source: string)
|
|
|
934
1177
|
export const HOOK_EVENTS = ['user-prompt-submit', 'stop', 'session-end'] as const
|
|
935
1178
|
export type HookEvent = (typeof HOOK_EVENTS)[number]
|
|
936
1179
|
|
|
1180
|
+
/** SessionEnd cleanup must precede every diagnostic that can wait on a file lock. */
|
|
1181
|
+
export function hookDefersDiagnosticsUntilAfterCleanup(
|
|
1182
|
+
event: unknown,
|
|
1183
|
+
): event is 'session-end' {
|
|
1184
|
+
return event === 'session-end'
|
|
1185
|
+
}
|
|
1186
|
+
|
|
937
1187
|
/**
|
|
938
1188
|
* Runs one harness hook. Contract with every harness: hook JSON arrives on
|
|
939
1189
|
* stdin, the decision (if any) goes to stdout, diagnostics go to stderr, and
|
|
@@ -953,53 +1203,129 @@ export async function hookRunCommand(
|
|
|
953
1203
|
deps.io.err(`Unknown hook event "${event}". Valid: ${HOOK_EVENTS.join(', ')}`)
|
|
954
1204
|
return EXIT.usage
|
|
955
1205
|
}
|
|
956
|
-
|
|
1206
|
+
|
|
1207
|
+
// One clock owns the complete Stop invocation, including stdin, config,
|
|
1208
|
+
// credentials, and client construction. Starting this inside `handleStop`
|
|
1209
|
+
// would grant slow setup a second budget and let the harness kill us before
|
|
1210
|
+
// an accepted answer is journaled or written to stdout.
|
|
1211
|
+
const now = deps.now ?? Date.now
|
|
1212
|
+
const processDeadlineAt = now() + HOOK_TIMING.totalSeconds * 1000
|
|
1213
|
+
|
|
1214
|
+
const logger = log(deps)
|
|
1215
|
+
logger.bind({ cmd: `hook ${event}` })
|
|
1216
|
+
let started = false
|
|
1217
|
+
const start = (data: Record<string, unknown> = {}): void => {
|
|
1218
|
+
if (started) return
|
|
1219
|
+
started = true
|
|
1220
|
+
logger.info('hook.start', { hook: event, harness: harness ?? 'unknown', ...data })
|
|
1221
|
+
}
|
|
1222
|
+
const failureData = (err: unknown): Record<string, unknown> =>
|
|
1223
|
+
err instanceof ApiCallError
|
|
1224
|
+
? { status: err.status, code: err.code, message: err.message, details: err.details }
|
|
1225
|
+
: { message: err instanceof Error ? err.message : String(err) }
|
|
1226
|
+
|
|
1227
|
+
let raw: string
|
|
957
1228
|
try {
|
|
958
|
-
|
|
959
|
-
|
|
960
|
-
|
|
961
|
-
|
|
962
|
-
|
|
963
|
-
|
|
964
|
-
|
|
965
|
-
|
|
966
|
-
|
|
1229
|
+
raw = await readStdin()
|
|
1230
|
+
} catch (err) {
|
|
1231
|
+
start({ input: 'unavailable' })
|
|
1232
|
+
logger.error('hook.end', {
|
|
1233
|
+
hook: event,
|
|
1234
|
+
outcome: 'ignored',
|
|
1235
|
+
reason: 'input-read-failed',
|
|
1236
|
+
...failureData(err),
|
|
1237
|
+
})
|
|
1238
|
+
return EXIT.ok
|
|
1239
|
+
}
|
|
1240
|
+
|
|
1241
|
+
if (raw.trim() !== '') {
|
|
1242
|
+
try {
|
|
1243
|
+
const parsed: unknown = JSON.parse(raw)
|
|
1244
|
+
if (typeof parsed !== 'object' || parsed === null) throw new Error('not an object')
|
|
1245
|
+
} catch {
|
|
1246
|
+
start({ input: 'malformed' })
|
|
1247
|
+
logger.error('hook.end', { hook: event, outcome: 'ignored', reason: 'malformed-input' })
|
|
1248
|
+
deps.io.err('notifai: ignored malformed or truncated hook input; no routing action was taken')
|
|
1249
|
+
return EXIT.ok
|
|
967
1250
|
}
|
|
968
|
-
|
|
969
|
-
|
|
970
|
-
|
|
971
|
-
|
|
972
|
-
|
|
973
|
-
|
|
974
|
-
|
|
975
|
-
|
|
976
|
-
|
|
977
|
-
|
|
978
|
-
|
|
979
|
-
|
|
1251
|
+
}
|
|
1252
|
+
|
|
1253
|
+
let envelope = parseHookInput(raw)
|
|
1254
|
+
if (harness === 'cursor') {
|
|
1255
|
+
const sessionId = envelope.session_id ?? envelope.conversation_id
|
|
1256
|
+
const cwd = envelope.cwd ?? envelope.workspace_roots?.[0]
|
|
1257
|
+
envelope = {
|
|
1258
|
+
...envelope,
|
|
1259
|
+
...(sessionId === undefined ? {} : { session_id: sessionId }),
|
|
1260
|
+
...(cwd === undefined ? {} : { cwd }),
|
|
1261
|
+
stop_hook_active:
|
|
1262
|
+
envelope.stop_hook_active ??
|
|
1263
|
+
(typeof envelope.loop_count === 'number' && envelope.loop_count > 0),
|
|
1264
|
+
}
|
|
1265
|
+
}
|
|
1266
|
+
|
|
1267
|
+
const cwd = envelope.cwd ?? deps.cwd
|
|
1268
|
+
const sessionEnd = hookDefersDiagnosticsUntilAfterCleanup(event)
|
|
1269
|
+
logger.bind({ session: envelope.session_id ?? null })
|
|
1270
|
+
let config: CliConfig | null = null
|
|
1271
|
+
let configFailure: unknown
|
|
1272
|
+
try {
|
|
1273
|
+
config = loadConfig({ cwd, env: deps.env, sessionId: envelope.session_id })
|
|
1274
|
+
// The hook's project is the session's, not this process's, and the log
|
|
1275
|
+
// settings that apply are that project's too. Keeping a mutable bootstrap
|
|
1276
|
+
// logger lets this more-specific layer turn logging back on.
|
|
1277
|
+
logger.adopt(logSettingsFrom(config))
|
|
1278
|
+
logger.bind({ project: config.project.value })
|
|
1279
|
+
if (!sessionEnd) {
|
|
1280
|
+
start({ cwd, stop_hook_active: envelope.stop_hook_active ?? null })
|
|
1281
|
+
logConfigResolved(logger, config)
|
|
1282
|
+
}
|
|
1283
|
+
} catch (err) {
|
|
1284
|
+
configFailure = err
|
|
1285
|
+
if (!sessionEnd) {
|
|
1286
|
+
start({ cwd, stop_hook_active: envelope.stop_hook_active ?? null })
|
|
1287
|
+
logger.error('hook.end', {
|
|
1288
|
+
hook: event,
|
|
1289
|
+
outcome: 'failed',
|
|
1290
|
+
reason: 'config-failed',
|
|
1291
|
+
...failureData(err),
|
|
1292
|
+
})
|
|
1293
|
+
for (const line of describeHookFailure(err)) deps.io.err(`notifai: ${line}`)
|
|
1294
|
+
return EXIT.ok
|
|
980
1295
|
}
|
|
981
|
-
} catch {
|
|
982
|
-
return EXIT.ok
|
|
983
1296
|
}
|
|
984
1297
|
|
|
985
|
-
// Everything below is inside one fail-open boundary.
|
|
986
|
-
//
|
|
987
|
-
//
|
|
988
|
-
// user than not having installed the hook at all.
|
|
1298
|
+
// Everything below is inside one fail-open boundary. Credential loading,
|
|
1299
|
+
// client construction and hook handling can all throw, and a hook that exits
|
|
1300
|
+
// non-zero makes the harness report a failure — strictly worse than skipping.
|
|
989
1301
|
try {
|
|
990
|
-
if (
|
|
1302
|
+
if (sessionEnd) {
|
|
1303
|
+
// Codex gives SessionEnd one second total. Do every durable cleanup write
|
|
1304
|
+
// before lifecycle diagnostics: the log lock is deliberately allowed to
|
|
1305
|
+
// wait that long, and a busy log must never preserve ended-session state
|
|
1306
|
+
// or its inherited configuration. The resolved config above is retained
|
|
1307
|
+
// in memory so logging still uses the ending session's settings afterwards.
|
|
991
1308
|
const outcome = handleSessionEnd(deps.env, envelope, (deps.now ?? Date.now)())
|
|
1309
|
+
start({ cwd, stop_hook_active: envelope.stop_hook_active ?? null })
|
|
1310
|
+
if (config !== null) logConfigResolved(logger, config)
|
|
1311
|
+
const data = { hook: event, decided: false, ...outcome.log }
|
|
1312
|
+
if (configFailure === undefined) logger.info('hook.end', data)
|
|
1313
|
+
else {
|
|
1314
|
+
logger.error('hook.end', {
|
|
1315
|
+
...data,
|
|
1316
|
+
reason: 'config-failed',
|
|
1317
|
+
config_error: failureData(configFailure),
|
|
1318
|
+
})
|
|
1319
|
+
}
|
|
992
1320
|
for (const note of outcome.notes) deps.io.err(`notifai: ${note}`)
|
|
993
1321
|
return EXIT.ok
|
|
994
1322
|
}
|
|
995
1323
|
|
|
996
|
-
//
|
|
997
|
-
|
|
998
|
-
// session belongs to is the harness's business, not ours.
|
|
999
|
-
const cwd = envelope.cwd ?? deps.cwd
|
|
1000
|
-
const config = loadConfig({ cwd, env: deps.env, sessionId: envelope.session_id })
|
|
1324
|
+
// Non-SessionEnd hooks cannot reach here without resolved configuration.
|
|
1325
|
+
const resolved = config!
|
|
1001
1326
|
const credential = deps.store.load()
|
|
1002
1327
|
if (!credential) {
|
|
1328
|
+
logger.error('hook.end', { hook: event, outcome: 'not-paired' })
|
|
1003
1329
|
deps.io.err('notifai: hook skipped: this machine is not paired; run `notifai login`')
|
|
1004
1330
|
return EXIT.ok
|
|
1005
1331
|
}
|
|
@@ -1007,9 +1333,9 @@ export async function hookRunCommand(
|
|
|
1007
1333
|
// repository can commit `.notifai/config.toml`, and honouring a base_url
|
|
1008
1334
|
// from it would hand this machine's bearer token to whatever host it names.
|
|
1009
1335
|
const baseUrl = credential.baseUrl
|
|
1010
|
-
if (
|
|
1336
|
+
if (resolved.base_url.source !== 'default' && resolved.base_url.value !== baseUrl) {
|
|
1011
1337
|
deps.io.err(
|
|
1012
|
-
`notifai: ignoring base_url from ${
|
|
1338
|
+
`notifai: ignoring base_url from ${resolved.base_url.source}; hooks only talk to ${baseUrl}`,
|
|
1013
1339
|
)
|
|
1014
1340
|
}
|
|
1015
1341
|
// UserPromptSubmit runs in front of the user's own prompt under a 15s
|
|
@@ -1019,12 +1345,20 @@ export async function hookRunCommand(
|
|
|
1019
1345
|
deps,
|
|
1020
1346
|
baseUrl,
|
|
1021
1347
|
`Bearer nfm_${credential.machineId}.${credential.secret}`,
|
|
1022
|
-
{
|
|
1348
|
+
{
|
|
1349
|
+
timeoutMs: event === 'user-prompt-submit' ? 4_000 : 20_000,
|
|
1350
|
+
...(event === 'stop'
|
|
1351
|
+
? {
|
|
1352
|
+
deadlineAt:
|
|
1353
|
+
processDeadlineAt - HOOK_TIMING.stdoutReserveSeconds * 1000,
|
|
1354
|
+
now,
|
|
1355
|
+
}
|
|
1356
|
+
: {}),
|
|
1357
|
+
},
|
|
1023
1358
|
)
|
|
1024
|
-
const now = deps.now ?? Date.now
|
|
1025
1359
|
const ctx: HookContext = {
|
|
1026
1360
|
client,
|
|
1027
|
-
config,
|
|
1361
|
+
config: resolved,
|
|
1028
1362
|
env: deps.env,
|
|
1029
1363
|
now,
|
|
1030
1364
|
idleSeconds: deps.idleSeconds ?? (() => readIdleSeconds()),
|
|
@@ -1042,24 +1376,41 @@ export async function hookRunCommand(
|
|
|
1042
1376
|
degraded: result.degraded,
|
|
1043
1377
|
}
|
|
1044
1378
|
},
|
|
1379
|
+
log: logger,
|
|
1045
1380
|
...(harness === undefined ? {} : { harness }),
|
|
1046
1381
|
}
|
|
1047
1382
|
|
|
1048
1383
|
// Real clock, deliberately, not `deps.now`. This compares against file
|
|
1049
1384
|
// mtimes, which are wall-clock facts — handing it a virtual or skewed clock
|
|
1050
1385
|
// would have it delete live session state as "abandoned".
|
|
1051
|
-
//
|
|
1052
|
-
//
|
|
1053
|
-
|
|
1386
|
+
// Daily state pruning is housekeeping, not part of the Stop delivery
|
|
1387
|
+
// contract. Its directory scan has no useful bound, so keep it on the
|
|
1388
|
+
// short prompt path and never spend the answer owner's finite budget on it.
|
|
1389
|
+
if (event !== 'stop') pruneAbandonedSessions(deps.env)
|
|
1054
1390
|
|
|
1055
1391
|
const outcome =
|
|
1056
1392
|
event === 'user-prompt-submit'
|
|
1057
1393
|
? await handleUserPromptSubmit(ctx, envelope)
|
|
1058
|
-
: await handleStop(ctx, envelope)
|
|
1394
|
+
: await handleStop(ctx, envelope, processDeadlineAt)
|
|
1395
|
+
// Answer diagnostics are already persisted once as hook.answer. Keep every
|
|
1396
|
+
// other note in the lifecycle record without duplicating the user's text.
|
|
1397
|
+
const notes = outcome.notes.filter((note) => !/^(?:late )?answer from /.test(note))
|
|
1398
|
+
logger.info('hook.end', {
|
|
1399
|
+
hook: event,
|
|
1400
|
+
// A hook that returns stdout has taken over the turn; one that does not
|
|
1401
|
+
// has handed the terminal back. That distinction is the whole contract.
|
|
1402
|
+
decided: outcome.stdout !== undefined,
|
|
1403
|
+
...(notes.length === 0 ? {} : { notes }),
|
|
1404
|
+
...outcome.log,
|
|
1405
|
+
})
|
|
1059
1406
|
for (const note of outcome.notes) deps.io.err(`notifai: ${note}`)
|
|
1060
1407
|
if (outcome.stdout !== undefined) {
|
|
1061
1408
|
let stdout = outcome.stdout
|
|
1062
|
-
if (
|
|
1409
|
+
if (
|
|
1410
|
+
harness !== undefined &&
|
|
1411
|
+
event === 'stop' &&
|
|
1412
|
+
HARNESS_CAPABILITIES[harness].stopContinuation === 'followup-message'
|
|
1413
|
+
) {
|
|
1063
1414
|
const decision = JSON.parse(outcome.stdout) as { decision?: unknown; reason?: unknown }
|
|
1064
1415
|
if (decision.decision === 'block' && typeof decision.reason === 'string') {
|
|
1065
1416
|
stdout = JSON.stringify({ followup_message: decision.reason })
|
|
@@ -1069,6 +1420,17 @@ export async function hookRunCommand(
|
|
|
1069
1420
|
}
|
|
1070
1421
|
return EXIT.ok
|
|
1071
1422
|
} catch (err) {
|
|
1423
|
+
// SessionEnd defers its start record until after cleanup; if cleanup itself
|
|
1424
|
+
// fails, begin the after-the-fact lifecycle here before recording why.
|
|
1425
|
+
if (sessionEnd) start({ cwd, stop_hook_active: envelope.stop_hook_active ?? null })
|
|
1426
|
+
// The hook still exits 0 — handing the terminal back is always right. What
|
|
1427
|
+
// this adds is that the reason survives, including the server's own words.
|
|
1428
|
+
logger.error('hook.end', {
|
|
1429
|
+
hook: event,
|
|
1430
|
+
outcome: 'failed',
|
|
1431
|
+
reason: 'execution-failed',
|
|
1432
|
+
...failureData(err),
|
|
1433
|
+
})
|
|
1072
1434
|
for (const line of describeHookFailure(err)) deps.io.err(`notifai: ${line}`)
|
|
1073
1435
|
return EXIT.ok
|
|
1074
1436
|
}
|
|
@@ -1227,27 +1589,40 @@ function buildOneQuestion(
|
|
|
1227
1589
|
|
|
1228
1590
|
/**
|
|
1229
1591
|
* Registers a question for turn-end routing under the user's presence config.
|
|
1230
|
-
* Returns immediately so the agent can ask in prose and end its turn.
|
|
1231
|
-
*
|
|
1232
|
-
*
|
|
1592
|
+
* Returns immediately so the agent can ask in prose and end its turn. Presence
|
|
1593
|
+
* does not block the shipped default; `require_idle = true` intentionally keeps
|
|
1594
|
+
* the question local while the user is active.
|
|
1233
1595
|
*/
|
|
1234
1596
|
export function askCommand(deps: CommandDeps, question: string | undefined, flags: AskFlags): number {
|
|
1597
|
+
// Validate before route discovery. A malformed question belongs to the
|
|
1598
|
+
// caller and should not be hidden behind whichever harness setup issue
|
|
1599
|
+
// happens to exist on this machine.
|
|
1600
|
+
const built = buildQuestions(flags, question)
|
|
1601
|
+
if (!built.ok) {
|
|
1602
|
+
deps.io.err(built.error)
|
|
1603
|
+
return EXIT.usage
|
|
1604
|
+
}
|
|
1605
|
+
const routingConfig = loadConfig({ cwd: deps.cwd, env: deps.env })
|
|
1606
|
+
if (!routingConfig.ask_notifications.value) {
|
|
1607
|
+
deps.io.err(
|
|
1608
|
+
'Question routing is disabled by ask_notifications=false; enable it or use a blocking `notifai send --reply` question.',
|
|
1609
|
+
)
|
|
1610
|
+
return EXIT.usage
|
|
1611
|
+
}
|
|
1612
|
+
if (deps.store.load() === null) {
|
|
1613
|
+
deps.io.err(
|
|
1614
|
+
'Question routing is not paired on this machine; run `notifai login` before registering an asynchronous question.',
|
|
1615
|
+
)
|
|
1616
|
+
return EXIT.usage
|
|
1617
|
+
}
|
|
1235
1618
|
// An agent calling this gets no hook payload. Harness-native environment
|
|
1236
|
-
// markers identify the active owner, while the
|
|
1237
|
-
//
|
|
1238
|
-
// The pointer outranks the
|
|
1239
|
-
// NOTIFAI_SESSION fallback deliberately: the exported id is often a chosen
|
|
1240
|
-
// label rather than the harness's own id, and the hooks key state by the
|
|
1241
|
-
// latter — the env var is only trusted when no hook has spoken.
|
|
1619
|
+
// markers identify the active owner, while UserPromptSubmit adds the hook's
|
|
1620
|
+
// canonical id to the directory's concurrent-session index.
|
|
1242
1621
|
const now = (deps.now ?? Date.now)()
|
|
1243
|
-
const projectPointer = readProjectSessionPointer(deps.cwd, deps.env, now)
|
|
1244
|
-
const pointer = projectPointer?.sessionId ?? null
|
|
1245
1622
|
const active = activeHarnessSession(deps.env)
|
|
1246
1623
|
let sessionId: string | undefined
|
|
1247
|
-
if (
|
|
1248
|
-
|
|
1249
|
-
} else if (active !== null) {
|
|
1250
|
-
const installations = findInstallations(deps.cwd, deps.env)
|
|
1624
|
+
if (active !== null) {
|
|
1625
|
+
const installations = findInstallations(deps.cwd, deps.env, deps.hookAdapterHome)
|
|
1251
1626
|
const activeInstalled = installations.some(
|
|
1252
1627
|
(installation) => installation.harness === active.harness,
|
|
1253
1628
|
)
|
|
@@ -1257,35 +1632,52 @@ export function askCommand(deps: CommandDeps, question: string | undefined, flag
|
|
|
1257
1632
|
}
|
|
1258
1633
|
return EXIT.usage
|
|
1259
1634
|
}
|
|
1635
|
+
if (active.sessionId === undefined) {
|
|
1636
|
+
for (const problem of activeQuestionRouteProblems(deps, active, installations)) {
|
|
1637
|
+
deps.io.err(`Question routing is not ready: ${problem}`)
|
|
1638
|
+
}
|
|
1639
|
+
return EXIT.usage
|
|
1640
|
+
}
|
|
1641
|
+
const projectPointer = readMatchingProjectSessionPointer(
|
|
1642
|
+
deps.cwd,
|
|
1643
|
+
deps.env,
|
|
1644
|
+
now,
|
|
1645
|
+
active.sessionId,
|
|
1646
|
+
active.harness,
|
|
1647
|
+
)
|
|
1260
1648
|
if (projectPointer === null) {
|
|
1261
1649
|
for (const line of diagnoseActiveHarnessSession(active, 'not-fired', installations)) {
|
|
1262
1650
|
deps.io.err(line)
|
|
1263
1651
|
}
|
|
1264
1652
|
return EXIT.usage
|
|
1265
1653
|
}
|
|
1266
|
-
if (
|
|
1267
|
-
|
|
1268
|
-
|
|
1269
|
-
|
|
1270
|
-
|
|
1271
|
-
|
|
1272
|
-
|
|
1654
|
+
if (flags.session !== undefined && flags.session !== active.sessionId) {
|
|
1655
|
+
deps.io.err(
|
|
1656
|
+
`Question routing is not ready: --session ${flags.session} does not match the exact active ${active.label} session ${active.sessionId}; refusing cross-session routing.`,
|
|
1657
|
+
)
|
|
1658
|
+
return EXIT.usage
|
|
1659
|
+
}
|
|
1660
|
+
const routeProblems = activeQuestionRouteProblems(deps, active, installations)
|
|
1661
|
+
if (routeProblems.length > 0) {
|
|
1662
|
+
for (const problem of routeProblems) deps.io.err(`Question routing is not ready: ${problem}`)
|
|
1663
|
+
return EXIT.usage
|
|
1664
|
+
}
|
|
1665
|
+
if (readSessionState(projectPointer.sessionId, deps.env).last_stop_at === undefined) {
|
|
1666
|
+
deps.io.err(
|
|
1667
|
+
`Question routing is not ready: this ${active.label} session has fired UserPromptSubmit, but its Stop hook has not been observed. End one harmless turn, send a new prompt, then run \`notifai doctor\`.`,
|
|
1668
|
+
)
|
|
1273
1669
|
return EXIT.usage
|
|
1274
1670
|
}
|
|
1275
1671
|
sessionId = projectPointer.sessionId
|
|
1276
1672
|
} else {
|
|
1277
|
-
sessionId = pointer ?? deps.env['NOTIFAI_SESSION'] ?? undefined
|
|
1278
|
-
}
|
|
1279
|
-
if (!sessionId) {
|
|
1280
1673
|
for (const line of diagnoseMissingSession(deps)) deps.io.err(line)
|
|
1674
|
+
deps.io.err(
|
|
1675
|
+
'Could not prove which live harness session owns this command, so Notifai will not register a question that could be delivered to the wrong or already-ended agent. Run it from a supported harness with exact session identity, or use a blocking `notifai send --reply` question.',
|
|
1676
|
+
)
|
|
1281
1677
|
return EXIT.usage
|
|
1282
1678
|
}
|
|
1283
|
-
|
|
1284
|
-
|
|
1285
|
-
// question set would look like it registered fine and then silently never ask.
|
|
1286
|
-
const built = buildQuestions(flags, question)
|
|
1287
|
-
if (!built.ok) {
|
|
1288
|
-
deps.io.err(built.error)
|
|
1679
|
+
if (!sessionId) {
|
|
1680
|
+
for (const line of diagnoseMissingSession(deps)) deps.io.err(line)
|
|
1289
1681
|
return EXIT.usage
|
|
1290
1682
|
}
|
|
1291
1683
|
try {
|
|
@@ -1300,9 +1692,20 @@ export function askCommand(deps: CommandDeps, question: string | undefined, flag
|
|
|
1300
1692
|
(deps.now ?? Date.now)(),
|
|
1301
1693
|
)
|
|
1302
1694
|
} catch (err) {
|
|
1695
|
+
log(deps).error('ask.registered', { ok: false, session: sessionId, message: String(err) })
|
|
1303
1696
|
deps.io.err(`Could not register the question: ${err instanceof Error ? err.message : String(err)}`)
|
|
1304
1697
|
return EXIT.failed
|
|
1305
1698
|
}
|
|
1699
|
+
// `ask` returns immediately and the push happens later inside a hook, so this
|
|
1700
|
+
// is the only local evidence that the question was ever registered — the
|
|
1701
|
+
// starting point for "the agent asked, and nothing ever reached my phone".
|
|
1702
|
+
log(deps).info('ask.registered', {
|
|
1703
|
+
ok: true,
|
|
1704
|
+
session: sessionId,
|
|
1705
|
+
questions: built.questions.length,
|
|
1706
|
+
text: built.questions[0]!.text,
|
|
1707
|
+
choices: built.questions[0]!.choices?.length ?? 0,
|
|
1708
|
+
})
|
|
1306
1709
|
for (const [index, entry] of built.questions.entries()) {
|
|
1307
1710
|
const prefix = built.questions.length > 1 ? `${index + 1}. ` : ''
|
|
1308
1711
|
if (entry.choices !== undefined) {
|
|
@@ -1359,6 +1762,69 @@ function activeHarnessSession(env: NodeJS.ProcessEnv): ActiveHarnessSession | nu
|
|
|
1359
1762
|
return null
|
|
1360
1763
|
}
|
|
1361
1764
|
|
|
1765
|
+
/**
|
|
1766
|
+
* One fail-closed admission gate shared by every active harness route.
|
|
1767
|
+
* Finding a file is not readiness: the exact session, one current definition,
|
|
1768
|
+
* the stable adapter, a long enough Stop owner, and (for Codex) trust must all
|
|
1769
|
+
* be true before `ask` is allowed to create an answerable notification.
|
|
1770
|
+
*/
|
|
1771
|
+
function activeQuestionRouteProblems(
|
|
1772
|
+
deps: CommandDeps,
|
|
1773
|
+
active: ActiveHarnessSession,
|
|
1774
|
+
installations: Installation[],
|
|
1775
|
+
): string[] {
|
|
1776
|
+
const problems: string[] = []
|
|
1777
|
+
if (active.sessionId === undefined) {
|
|
1778
|
+
problems.push(
|
|
1779
|
+
`the active ${active.label} shell does not expose an exact session id; a project-level last-writer pointer can cross-wire two sessions. Use a blocking \`notifai send --reply\` question`,
|
|
1780
|
+
)
|
|
1781
|
+
}
|
|
1782
|
+
const capability = HARNESS_CAPABILITIES[active.harness]
|
|
1783
|
+
if (capability.stopContinuation === 'unsupported') {
|
|
1784
|
+
problems.push(`${active.label}: ${capability.deliveryContract}`)
|
|
1785
|
+
}
|
|
1786
|
+
const matching = installations.filter(
|
|
1787
|
+
(installation) => installation.harness === active.harness,
|
|
1788
|
+
)
|
|
1789
|
+
if (matching.length > 1) {
|
|
1790
|
+
problems.push(
|
|
1791
|
+
`${matching.length} ${active.label} definitions are active (${matching.map((entry) => entry.file).join(', ')}); keep either project or global routing`,
|
|
1792
|
+
)
|
|
1793
|
+
}
|
|
1794
|
+
for (const installation of matching) {
|
|
1795
|
+
for (const problem of installation.problems ?? []) {
|
|
1796
|
+
problems.push(`${installation.file}: ${problem}`)
|
|
1797
|
+
}
|
|
1798
|
+
for (const handler of installation.handlers) {
|
|
1799
|
+
const event = handlerEvent(handler.command)
|
|
1800
|
+
if (event !== null && !(HOOK_EVENTS as readonly string[]).includes(event)) {
|
|
1801
|
+
problems.push(
|
|
1802
|
+
`${handler.event} in ${installation.file} names the unsupported event ${event}; reinstall the ${active.label} hooks`,
|
|
1803
|
+
)
|
|
1804
|
+
}
|
|
1805
|
+
}
|
|
1806
|
+
}
|
|
1807
|
+
for (const problem of inspectHookAdapter(deps.hookAdapterHome).problems) {
|
|
1808
|
+
problems.push(problem)
|
|
1809
|
+
}
|
|
1810
|
+
if (active.harness !== 'codex') {
|
|
1811
|
+
const required = blockingHookTimeoutSeconds()
|
|
1812
|
+
for (const installation of matching) {
|
|
1813
|
+
for (const handler of installation.handlers.filter(
|
|
1814
|
+
(entry) => handlerEvent(entry.command) === 'stop',
|
|
1815
|
+
)) {
|
|
1816
|
+
if (handler.timeout === undefined || handler.timeout < required) {
|
|
1817
|
+
problems.push(
|
|
1818
|
+
`${installation.file} gives Stop ${handler.timeout ?? 'no'}s, but the live answer owner requires ${required}s`,
|
|
1819
|
+
)
|
|
1820
|
+
}
|
|
1821
|
+
}
|
|
1822
|
+
}
|
|
1823
|
+
}
|
|
1824
|
+
problems.push(...codexTrustProblems(matching, deps.env))
|
|
1825
|
+
return [...new Set(problems)]
|
|
1826
|
+
}
|
|
1827
|
+
|
|
1362
1828
|
type ActiveHarnessProblem = 'not-installed' | 'not-fired' | 'different-session'
|
|
1363
1829
|
|
|
1364
1830
|
function diagnoseActiveHarnessSession(
|
|
@@ -1403,7 +1869,7 @@ function diagnoseActiveHarnessSession(
|
|
|
1403
1869
|
* checked after a prompt before assuming that a new session is required.
|
|
1404
1870
|
*/
|
|
1405
1871
|
function diagnoseMissingSession(deps: CommandDeps): string[] {
|
|
1406
|
-
const installations = findInstallations(deps.cwd, deps.env)
|
|
1872
|
+
const installations = findInstallations(deps.cwd, deps.env, deps.hookAdapterHome)
|
|
1407
1873
|
if (installations.length === 0) {
|
|
1408
1874
|
return [
|
|
1409
1875
|
'Could not tell which harness session this is: no Notifai hooks are installed for this project.',
|
|
@@ -1415,7 +1881,7 @@ function diagnoseMissingSession(deps: CommandDeps): string[] {
|
|
|
1415
1881
|
`Could not tell which harness session this is. Notifai hooks are installed (${where}),`,
|
|
1416
1882
|
'but no usable session pointer from the last 24 hours exists here.',
|
|
1417
1883
|
hookActivationAdvice(installations),
|
|
1418
|
-
'
|
|
1884
|
+
'Do not bypass this with a guessed session id; run from the exact active harness session or use a blocking `notifai send --reply` question.',
|
|
1419
1885
|
]
|
|
1420
1886
|
}
|
|
1421
1887
|
|
|
@@ -1460,7 +1926,7 @@ function hookActivationAdvice(installations: Installation[]): string {
|
|
|
1460
1926
|
}
|
|
1461
1927
|
if (harnesses.has('opencode')) {
|
|
1462
1928
|
advice.push(
|
|
1463
|
-
'OpenCode: restart it, then send one prompt; plugins load at startup,
|
|
1929
|
+
'OpenCode: restart it, then send one prompt; plugins load at startup, but non-blocking question continuation is intentionally unsupported',
|
|
1464
1930
|
)
|
|
1465
1931
|
}
|
|
1466
1932
|
return `${advice.join('. ')}.`
|
|
@@ -1468,7 +1934,7 @@ function hookActivationAdvice(installations: Installation[]): string {
|
|
|
1468
1934
|
|
|
1469
1935
|
/** Retire a question so a late answer is rejected rather than silently lost. */
|
|
1470
1936
|
export async function closeCommand(deps: CommandDeps, requestId: string): Promise<number> {
|
|
1471
|
-
const config =
|
|
1937
|
+
const config = loadLoggedConfig(deps, { cwd: deps.cwd, env: deps.env })
|
|
1472
1938
|
const authed = authedClient(deps, config)
|
|
1473
1939
|
if (!authed) return EXIT.auth
|
|
1474
1940
|
try {
|
|
@@ -1493,43 +1959,41 @@ export function hooksInstallCommand(deps: CommandDeps, flags: HooksInstallFlags)
|
|
|
1493
1959
|
if (!harness) return EXIT.usage
|
|
1494
1960
|
const execPath = flags.execPath ?? process.execPath
|
|
1495
1961
|
const scriptPath = flags.scriptPath ?? process.argv[1] ?? 'notifai'
|
|
1496
|
-
|
|
1962
|
+
let adapterPath: string
|
|
1963
|
+
try {
|
|
1964
|
+
adapterPath = installHookAdapter({ execPath, scriptPath }, deps.hookAdapterHome).path
|
|
1965
|
+
} catch (err) {
|
|
1966
|
+
deps.io.err(`Could not prepare the stable hook adapter: ${String(err)}`)
|
|
1967
|
+
return EXIT.failed
|
|
1968
|
+
}
|
|
1969
|
+
const config = loadLoggedConfig(deps, { cwd: deps.cwd, env: deps.env })
|
|
1497
1970
|
const file = settingsFile(harness, flags.global ?? false, deps.cwd, deps.env)
|
|
1498
1971
|
|
|
1499
1972
|
// OpenCode's adapter is a generated plugin module rather than a handler
|
|
1500
1973
|
// merged into a settings document, so it owns the whole file.
|
|
1501
1974
|
if (harness === 'opencode') {
|
|
1502
1975
|
return installOpencodePlugin(deps, file, {
|
|
1503
|
-
|
|
1504
|
-
|
|
1505
|
-
timeoutSeconds: blockingHookTimeoutSeconds(
|
|
1506
|
-
config.ask_grace_seconds.value,
|
|
1507
|
-
config.hook_reply_timeout_seconds.value,
|
|
1508
|
-
),
|
|
1976
|
+
adapterPath,
|
|
1977
|
+
timeoutSeconds: blockingHookTimeoutSeconds(),
|
|
1509
1978
|
})
|
|
1510
1979
|
}
|
|
1511
1980
|
|
|
1512
1981
|
if (harness === 'cursor') {
|
|
1513
|
-
let
|
|
1982
|
+
let merged: ReturnType<typeof mergeCursorHooks>
|
|
1514
1983
|
try {
|
|
1515
|
-
|
|
1516
|
-
|
|
1517
|
-
|
|
1518
|
-
|
|
1519
|
-
|
|
1520
|
-
|
|
1521
|
-
|
|
1522
|
-
|
|
1523
|
-
|
|
1524
|
-
|
|
1525
|
-
|
|
1526
|
-
|
|
1527
|
-
|
|
1528
|
-
}),
|
|
1529
|
-
scriptPath,
|
|
1530
|
-
)
|
|
1531
|
-
try {
|
|
1532
|
-
applyPlan(file, merged.document)
|
|
1984
|
+
merged = withTargetFileLock(file, () => {
|
|
1985
|
+
const document = loadCursorSettings(file)
|
|
1986
|
+
const result = mergeCursorHooks(
|
|
1987
|
+
document,
|
|
1988
|
+
buildCursorHookConfig({
|
|
1989
|
+
adapterPath,
|
|
1990
|
+
harness: 'cursor',
|
|
1991
|
+
}),
|
|
1992
|
+
scriptPath,
|
|
1993
|
+
)
|
|
1994
|
+
applyPlan(file, result.document)
|
|
1995
|
+
return result
|
|
1996
|
+
})
|
|
1533
1997
|
} catch (err) {
|
|
1534
1998
|
deps.io.err(String(err))
|
|
1535
1999
|
return EXIT.failed
|
|
@@ -1544,31 +2008,27 @@ export function hooksInstallCommand(deps: CommandDeps, flags: HooksInstallFlags)
|
|
|
1544
2008
|
deps.io.out('Send one Cursor prompt, then check `notifai doctor`. If the hook has not fired,')
|
|
1545
2009
|
deps.io.out('start a new Cursor session and try one prompt again.')
|
|
1546
2010
|
}
|
|
1547
|
-
deps.io.out('
|
|
1548
|
-
deps.io.out('
|
|
2011
|
+
deps.io.out('Cursor hooks can emit a native follow-up, but its agent shell does not expose')
|
|
2012
|
+
deps.io.out('the exact conversation id needed to register safely. `notifai ask` therefore')
|
|
2013
|
+
deps.io.out('fails closed; use blocking `notifai send --reply` for questions.')
|
|
1549
2014
|
return EXIT.ok
|
|
1550
2015
|
}
|
|
1551
2016
|
|
|
1552
|
-
let
|
|
2017
|
+
let merged: ReturnType<typeof mergeHooks>
|
|
1553
2018
|
try {
|
|
1554
|
-
|
|
1555
|
-
|
|
1556
|
-
|
|
1557
|
-
|
|
1558
|
-
|
|
1559
|
-
|
|
1560
|
-
|
|
1561
|
-
|
|
1562
|
-
|
|
1563
|
-
|
|
1564
|
-
|
|
1565
|
-
|
|
1566
|
-
|
|
1567
|
-
}),
|
|
1568
|
-
scriptPath,
|
|
1569
|
-
)
|
|
1570
|
-
try {
|
|
1571
|
-
applyPlan(file, merged.document)
|
|
2019
|
+
merged = withTargetFileLock(file, () => {
|
|
2020
|
+
const document = loadSettings(file)
|
|
2021
|
+
const result = mergeHooks(
|
|
2022
|
+
document,
|
|
2023
|
+
buildHookConfig({
|
|
2024
|
+
adapterPath,
|
|
2025
|
+
harness,
|
|
2026
|
+
}),
|
|
2027
|
+
scriptPath,
|
|
2028
|
+
)
|
|
2029
|
+
applyPlan(file, result.document)
|
|
2030
|
+
return result
|
|
2031
|
+
})
|
|
1572
2032
|
} catch (err) {
|
|
1573
2033
|
deps.io.err(String(err))
|
|
1574
2034
|
return EXIT.failed
|
|
@@ -1581,18 +2041,22 @@ export function hooksInstallCommand(deps: CommandDeps, flags: HooksInstallFlags)
|
|
|
1581
2041
|
deps.io.out(` removed: ${merged.removed.join(', ')} (this build no longer serves them)`)
|
|
1582
2042
|
}
|
|
1583
2043
|
deps.io.out('')
|
|
1584
|
-
|
|
1585
|
-
|
|
1586
|
-
|
|
1587
|
-
|
|
1588
|
-
|
|
1589
|
-
|
|
1590
|
-
`
|
|
1591
|
-
|
|
1592
|
-
|
|
1593
|
-
|
|
1594
|
-
|
|
1595
|
-
|
|
2044
|
+
if (config.require_idle.value) {
|
|
2045
|
+
deps.io.out(
|
|
2046
|
+
`While keyboard or mouse idle time stays below ${config.away_after_seconds.value}s, ` +
|
|
2047
|
+
`nothing is pushed. A question registered with \`notifai ask\` goes to your devices ` +
|
|
2048
|
+
(config.ask_grace_seconds.value === 0
|
|
2049
|
+
? 'as soon as the machine meets that idle threshold. '
|
|
2050
|
+
: `once its ${config.ask_grace_seconds.value}s grace window, counted from registration, has elapsed and the machine meets that idle threshold. `) +
|
|
2051
|
+
`Run \`notifai config set require_idle false\` to be notified even while you are working.`,
|
|
2052
|
+
)
|
|
2053
|
+
} else {
|
|
2054
|
+
deps.io.out(
|
|
2055
|
+
config.ask_grace_seconds.value === 0
|
|
2056
|
+
? 'A question registered with `notifai ask` goes to your devices immediately when the agent turn ends, whether or not you are at this machine.'
|
|
2057
|
+
: `A question registered with \`notifai ask\` stays in the terminal for ${config.ask_grace_seconds.value}s from registration and then goes to your devices whether or not you are at this machine.`,
|
|
2058
|
+
)
|
|
2059
|
+
}
|
|
1596
2060
|
if (config.require_idle.value) {
|
|
1597
2061
|
deps.io.out(
|
|
1598
2062
|
'If this OS exposes no keyboard/mouse idle signal, the hook falls back to prompt silence and skips the blocking grace once it decides you are away.',
|
|
@@ -1636,18 +2100,23 @@ export function hooksInstallCommand(deps: CommandDeps, flags: HooksInstallFlags)
|
|
|
1636
2100
|
function installOpencodePlugin(
|
|
1637
2101
|
deps: CommandDeps,
|
|
1638
2102
|
file: string,
|
|
1639
|
-
options: {
|
|
2103
|
+
options: { adapterPath: string; timeoutSeconds: number },
|
|
1640
2104
|
): number {
|
|
1641
|
-
if (existsSync(file)) {
|
|
1642
|
-
const existing = readFileSync(file, 'utf8')
|
|
1643
|
-
if (!isOurOpencodePlugin(existing)) {
|
|
1644
|
-
deps.io.err(`${file} exists and was not written by Notifai; move it aside first.`)
|
|
1645
|
-
return EXIT.failed
|
|
1646
|
-
}
|
|
1647
|
-
}
|
|
1648
2105
|
try {
|
|
1649
|
-
|
|
1650
|
-
|
|
2106
|
+
withTargetFileLock(file, () => {
|
|
2107
|
+
if (existsSync(file)) {
|
|
2108
|
+
assertOwnedRegularFile(file)
|
|
2109
|
+
const existing = readFileSync(file, 'utf8')
|
|
2110
|
+
if (!isOurOpencodePlugin(existing)) {
|
|
2111
|
+
throw new Error(`${file} exists and was not written by Notifai; move it aside first.`)
|
|
2112
|
+
}
|
|
2113
|
+
}
|
|
2114
|
+
atomicWriteFileSync(file, opencodePluginSource(options), {
|
|
2115
|
+
mode: 0o600,
|
|
2116
|
+
preserveMode: false,
|
|
2117
|
+
requireCurrentUserOwner: true,
|
|
2118
|
+
})
|
|
2119
|
+
})
|
|
1651
2120
|
} catch (err) {
|
|
1652
2121
|
deps.io.err(String(err))
|
|
1653
2122
|
return EXIT.failed
|
|
@@ -1657,9 +2126,9 @@ function installOpencodePlugin(
|
|
|
1657
2126
|
deps.io.out('It maps chat.message to presence, session.idle to question escalation, and')
|
|
1658
2127
|
deps.io.out('session.deleted to local cleanup through the same `notifai hook` commands')
|
|
1659
2128
|
deps.io.out('the other harnesses run. Permission prompts stay in OpenCode.')
|
|
1660
|
-
deps.io.out('OpenCode
|
|
1661
|
-
deps.io.out('
|
|
1662
|
-
deps.io.out('
|
|
2129
|
+
deps.io.out('OpenCode does not expose a proven exactly-once continuation after session.idle,')
|
|
2130
|
+
deps.io.out('so `notifai ask` fails closed there. Use blocking `notifai send --reply` when')
|
|
2131
|
+
deps.io.out('the answer must return to the agent without another human prompt.')
|
|
1663
2132
|
deps.io.out('')
|
|
1664
2133
|
deps.io.out('Restart OpenCode: it loads plugins once at start.')
|
|
1665
2134
|
return EXIT.ok
|
|
@@ -1670,35 +2139,46 @@ export function hooksUninstallCommand(deps: CommandDeps, flags: HooksInstallFlag
|
|
|
1670
2139
|
if (!harness) return EXIT.usage
|
|
1671
2140
|
const scriptPath = flags.scriptPath ?? process.argv[1] ?? 'notifai'
|
|
1672
2141
|
const file = settingsFile(harness, flags.global ?? false, deps.cwd, deps.env)
|
|
1673
|
-
if (!existsSync(file)) {
|
|
1674
|
-
deps.io.out(`Nothing to remove: ${file} does not exist.`)
|
|
1675
|
-
return EXIT.ok
|
|
1676
|
-
}
|
|
1677
2142
|
if (harness === 'opencode') {
|
|
1678
|
-
// We own the whole file, but only if we wrote it.
|
|
1679
|
-
if (!isOurOpencodePlugin(readFileSync(file, 'utf8'))) {
|
|
1680
|
-
deps.io.out(`Left ${file} alone: Notifai did not write it.`)
|
|
1681
|
-
return EXIT.ok
|
|
1682
|
-
}
|
|
1683
|
-
rmSync(file, { force: true })
|
|
1684
|
-
deps.io.out(`Removed the Notifai OpenCode plugin at ${file}`)
|
|
1685
|
-
return EXIT.ok
|
|
1686
|
-
}
|
|
1687
|
-
if (harness === 'cursor') {
|
|
1688
|
-
let document
|
|
1689
2143
|
try {
|
|
1690
|
-
|
|
2144
|
+
return withTargetFileLock(file, () => {
|
|
2145
|
+
if (!existsSync(file)) {
|
|
2146
|
+
deps.io.out(`Nothing to remove: ${file} does not exist.`)
|
|
2147
|
+
return EXIT.ok
|
|
2148
|
+
}
|
|
2149
|
+
assertOwnedRegularFile(file)
|
|
2150
|
+
// We own the whole file, but only if we wrote it.
|
|
2151
|
+
if (!isOurOpencodePlugin(readFileSync(file, 'utf8'))) {
|
|
2152
|
+
deps.io.out(`Left ${file} alone: Notifai did not write it.`)
|
|
2153
|
+
return EXIT.ok
|
|
2154
|
+
}
|
|
2155
|
+
rmSync(file, { force: true })
|
|
2156
|
+
deps.io.out(`Removed the Notifai OpenCode plugin at ${file}`)
|
|
2157
|
+
return EXIT.ok
|
|
2158
|
+
})
|
|
1691
2159
|
} catch (err) {
|
|
1692
2160
|
deps.io.err(String(err))
|
|
1693
2161
|
return EXIT.failed
|
|
1694
2162
|
}
|
|
1695
|
-
|
|
2163
|
+
}
|
|
2164
|
+
if (harness === 'cursor') {
|
|
2165
|
+
let stripped: ReturnType<typeof removeCursorHooks> | null
|
|
1696
2166
|
try {
|
|
1697
|
-
|
|
2167
|
+
stripped = withTargetFileLock(file, () => {
|
|
2168
|
+
if (!existsSync(file)) return null
|
|
2169
|
+
const document = loadCursorSettings(file)
|
|
2170
|
+
const result = removeCursorHooks(document, scriptPath)
|
|
2171
|
+
applyPlan(file, result.document)
|
|
2172
|
+
return result
|
|
2173
|
+
})
|
|
1698
2174
|
} catch (err) {
|
|
1699
2175
|
deps.io.err(String(err))
|
|
1700
2176
|
return EXIT.failed
|
|
1701
2177
|
}
|
|
2178
|
+
if (stripped === null) {
|
|
2179
|
+
deps.io.out(`Nothing to remove: ${file} does not exist.`)
|
|
2180
|
+
return EXIT.ok
|
|
2181
|
+
}
|
|
1702
2182
|
deps.io.out(
|
|
1703
2183
|
stripped.replaced.length > 0
|
|
1704
2184
|
? `Removed Notifai hooks (${stripped.replaced.join(', ')}) from ${file}`
|
|
@@ -1706,19 +2186,22 @@ export function hooksUninstallCommand(deps: CommandDeps, flags: HooksInstallFlag
|
|
|
1706
2186
|
)
|
|
1707
2187
|
return EXIT.ok
|
|
1708
2188
|
}
|
|
1709
|
-
let
|
|
2189
|
+
let stripped: ReturnType<typeof removeHooks> | null
|
|
1710
2190
|
try {
|
|
1711
|
-
|
|
2191
|
+
stripped = withTargetFileLock(file, () => {
|
|
2192
|
+
if (!existsSync(file)) return null
|
|
2193
|
+
const document = loadSettings(file)
|
|
2194
|
+
const result = removeHooks(document, scriptPath)
|
|
2195
|
+
applyPlan(file, result.document)
|
|
2196
|
+
return result
|
|
2197
|
+
})
|
|
1712
2198
|
} catch (err) {
|
|
1713
2199
|
deps.io.err(String(err))
|
|
1714
2200
|
return EXIT.failed
|
|
1715
2201
|
}
|
|
1716
|
-
|
|
1717
|
-
|
|
1718
|
-
|
|
1719
|
-
} catch (err) {
|
|
1720
|
-
deps.io.err(String(err))
|
|
1721
|
-
return EXIT.failed
|
|
2202
|
+
if (stripped === null) {
|
|
2203
|
+
deps.io.out(`Nothing to remove: ${file} does not exist.`)
|
|
2204
|
+
return EXIT.ok
|
|
1722
2205
|
}
|
|
1723
2206
|
deps.io.out(
|
|
1724
2207
|
stripped.replaced.length > 0
|
|
@@ -1728,6 +2211,17 @@ export function hooksUninstallCommand(deps: CommandDeps, flags: HooksInstallFlag
|
|
|
1728
2211
|
return EXIT.ok
|
|
1729
2212
|
}
|
|
1730
2213
|
|
|
2214
|
+
function assertOwnedRegularFile(file: string): void {
|
|
2215
|
+
const stat = lstatSync(file)
|
|
2216
|
+
if (stat.isSymbolicLink() || !stat.isFile()) {
|
|
2217
|
+
throw new Error(`${file} is not a regular file; refusing to read or replace it.`)
|
|
2218
|
+
}
|
|
2219
|
+
const uid = typeof process.getuid === 'function' ? process.getuid() : undefined
|
|
2220
|
+
if (uid !== undefined && stat.uid !== uid) {
|
|
2221
|
+
throw new Error(`${file} is owned by uid ${stat.uid}, not the current user.`)
|
|
2222
|
+
}
|
|
2223
|
+
}
|
|
2224
|
+
|
|
1731
2225
|
function resolveHarness(deps: CommandDeps, requested: string | undefined): Harness | null {
|
|
1732
2226
|
if (requested !== undefined) {
|
|
1733
2227
|
if ((HARNESSES as readonly string[]).includes(requested)) return requested as Harness
|
|
@@ -1745,14 +2239,14 @@ function resolveHarness(deps: CommandDeps, requested: string | undefined): Harne
|
|
|
1745
2239
|
}
|
|
1746
2240
|
|
|
1747
2241
|
// ---------------------------------------------------------------------------
|
|
1748
|
-
// config show / set
|
|
2242
|
+
// config show / set / unset
|
|
1749
2243
|
// ---------------------------------------------------------------------------
|
|
1750
2244
|
|
|
1751
2245
|
export function configShowCommand(
|
|
1752
2246
|
deps: CommandDeps,
|
|
1753
|
-
flags: { json?: boolean; explain?: boolean },
|
|
2247
|
+
flags: { json?: boolean; explain?: boolean; plain?: boolean },
|
|
1754
2248
|
): number {
|
|
1755
|
-
const config =
|
|
2249
|
+
const config = loadLoggedConfig(deps, { cwd: deps.cwd, env: deps.env })
|
|
1756
2250
|
if (flags.json) {
|
|
1757
2251
|
const output = Object.fromEntries(
|
|
1758
2252
|
CONFIG_KEYS.map((key) => [key, { value: config[key].value, source: config[key].source }]),
|
|
@@ -1760,47 +2254,198 @@ export function configShowCommand(
|
|
|
1760
2254
|
deps.io.out(JSON.stringify(output, null, 2))
|
|
1761
2255
|
return EXIT.ok
|
|
1762
2256
|
}
|
|
1763
|
-
|
|
1764
|
-
|
|
1765
|
-
|
|
1766
|
-
|
|
2257
|
+
// Anything that is not a person at a terminal keeps the flat `key = value`
|
|
2258
|
+
// form it has always had. Scripts parse this, and a prettier layout for an
|
|
2259
|
+
// audience that cannot see it would only be a breaking change.
|
|
2260
|
+
if (deps.io.interactive !== true || flags.plain === true) {
|
|
2261
|
+
for (const line of renderConfigPlain(config, flags.explain === true)) deps.io.out(line)
|
|
2262
|
+
return EXIT.ok
|
|
2263
|
+
}
|
|
2264
|
+
for (const line of renderConfigList(config, { showAdvanced: flags.explain === true })) {
|
|
2265
|
+
deps.io.out(line)
|
|
1767
2266
|
}
|
|
1768
2267
|
return EXIT.ok
|
|
1769
2268
|
}
|
|
1770
2269
|
|
|
2270
|
+
/**
|
|
2271
|
+
* One setting, explained in full.
|
|
2272
|
+
*
|
|
2273
|
+
* The gap this closes: `config show` prints a value and provenance but that is
|
|
2274
|
+
* not enough to explain the setting's consequences. Every key already had a
|
|
2275
|
+
* careful explanation — in a TypeScript comment, read by everyone except the
|
|
2276
|
+
* person who needed it.
|
|
2277
|
+
*/
|
|
2278
|
+
export function configExplainCommand(
|
|
2279
|
+
deps: CommandDeps,
|
|
2280
|
+
key: string,
|
|
2281
|
+
flags: { json?: boolean } = {},
|
|
2282
|
+
): number {
|
|
2283
|
+
if (!(CONFIG_KEYS as readonly string[]).includes(key)) {
|
|
2284
|
+
deps.io.err(`Unknown setting "${key}".`)
|
|
2285
|
+
deps.io.err(`Valid settings: ${CONFIG_KEYS.join(', ')}`)
|
|
2286
|
+
return EXIT.usage
|
|
2287
|
+
}
|
|
2288
|
+
const configKey = key as ConfigKey
|
|
2289
|
+
const config = loadLoggedConfig(deps, { cwd: deps.cwd, env: deps.env })
|
|
2290
|
+
const info = configInfo(configKey)
|
|
2291
|
+
const entry = config[configKey]
|
|
2292
|
+
|
|
2293
|
+
if (flags.json) {
|
|
2294
|
+
deps.io.out(
|
|
2295
|
+
JSON.stringify(
|
|
2296
|
+
{
|
|
2297
|
+
key: configKey,
|
|
2298
|
+
label: info.label,
|
|
2299
|
+
group: info.group,
|
|
2300
|
+
kind: info.kind,
|
|
2301
|
+
summary: info.summary,
|
|
2302
|
+
detail: info.detail,
|
|
2303
|
+
accepts: acceptedValues(configKey),
|
|
2304
|
+
...(info.choices !== undefined ? { choices: info.choices } : {}),
|
|
2305
|
+
value: entry.value,
|
|
2306
|
+
source: entry.source,
|
|
2307
|
+
},
|
|
2308
|
+
null,
|
|
2309
|
+
2,
|
|
2310
|
+
),
|
|
2311
|
+
)
|
|
2312
|
+
return EXIT.ok
|
|
2313
|
+
}
|
|
2314
|
+
|
|
2315
|
+
if (deps.io.interactive !== true) {
|
|
2316
|
+
deps.io.out(`${configKey} = ${JSON.stringify(entry.value)} [${entry.source}]`)
|
|
2317
|
+
deps.io.out(info.detail.replace(/\n\n/g, '\n'))
|
|
2318
|
+
deps.io.out(`accepts: ${acceptedValues(configKey)}`)
|
|
2319
|
+
return EXIT.ok
|
|
2320
|
+
}
|
|
2321
|
+
for (const line of renderConfigExplain(configKey, config)) deps.io.out(line)
|
|
2322
|
+
return EXIT.ok
|
|
2323
|
+
}
|
|
2324
|
+
|
|
2325
|
+
/**
|
|
2326
|
+
* Closest config key by edit distance, or null when nothing is close.
|
|
2327
|
+
*
|
|
2328
|
+
* The threshold matters more than the algorithm: suggesting a key that shares
|
|
2329
|
+
* three letters with the typo sends the reader to the wrong setting with
|
|
2330
|
+
* confidence, which is worse than listing all fourteen and letting them look.
|
|
2331
|
+
*/
|
|
2332
|
+
function nearestKey(input: string): string | null {
|
|
2333
|
+
let best: string | null = null
|
|
2334
|
+
let bestDistance = Number.POSITIVE_INFINITY
|
|
2335
|
+
for (const candidate of CONFIG_KEYS) {
|
|
2336
|
+
const distance = editDistance(input.toLowerCase(), candidate)
|
|
2337
|
+
if (distance < bestDistance) {
|
|
2338
|
+
bestDistance = distance
|
|
2339
|
+
best = candidate
|
|
2340
|
+
}
|
|
2341
|
+
}
|
|
2342
|
+
return best !== null && bestDistance <= Math.max(2, Math.floor(best.length / 3)) ? best : null
|
|
2343
|
+
}
|
|
2344
|
+
|
|
2345
|
+
function editDistance(a: string, b: string): number {
|
|
2346
|
+
const previous = Array.from({ length: b.length + 1 }, (_, i) => i)
|
|
2347
|
+
for (let i = 1; i <= a.length; i += 1) {
|
|
2348
|
+
let diagonal = previous[0]!
|
|
2349
|
+
previous[0] = i
|
|
2350
|
+
for (let j = 1; j <= b.length; j += 1) {
|
|
2351
|
+
const above = previous[j]!
|
|
2352
|
+
previous[j] = Math.min(
|
|
2353
|
+
previous[j]! + 1,
|
|
2354
|
+
previous[j - 1]! + 1,
|
|
2355
|
+
diagonal + (a[i - 1] === b[j - 1] ? 0 : 1),
|
|
2356
|
+
)
|
|
2357
|
+
diagonal = above
|
|
2358
|
+
}
|
|
2359
|
+
}
|
|
2360
|
+
return previous[b.length]!
|
|
2361
|
+
}
|
|
2362
|
+
|
|
1771
2363
|
export async function configSetCommand(
|
|
1772
2364
|
deps: CommandDeps,
|
|
1773
2365
|
key: string,
|
|
1774
2366
|
rawValue: string,
|
|
1775
2367
|
flags: { project?: boolean; local?: boolean; session?: string; yes?: boolean },
|
|
1776
2368
|
): Promise<number> {
|
|
1777
|
-
|
|
1778
|
-
|
|
1779
|
-
|
|
1780
|
-
}
|
|
2369
|
+
const configKey = configMutationKey(deps, key)
|
|
2370
|
+
if (configKey === null) return EXIT.usage
|
|
2371
|
+
const info = configInfo(configKey)
|
|
1781
2372
|
let value: unknown = rawValue
|
|
1782
|
-
if (NUMERIC_CONFIG_KEYS.includes(
|
|
2373
|
+
if (NUMERIC_CONFIG_KEYS.includes(configKey)) {
|
|
1783
2374
|
const numeric = Number(rawValue)
|
|
1784
2375
|
if (!Number.isInteger(numeric)) {
|
|
1785
|
-
deps.io.err(
|
|
2376
|
+
deps.io.err(`${key} takes ${acceptedValues(configKey)}, not "${rawValue}".`)
|
|
1786
2377
|
return EXIT.usage
|
|
1787
2378
|
}
|
|
1788
|
-
const bounds = configBounds(
|
|
2379
|
+
const bounds = configBounds(configKey)
|
|
1789
2380
|
if (bounds !== undefined && (numeric < bounds.min || numeric > bounds.max)) {
|
|
1790
2381
|
deps.io.err(`${key} must be between ${bounds.min} and ${bounds.max}.`)
|
|
1791
2382
|
return EXIT.usage
|
|
1792
2383
|
}
|
|
1793
2384
|
value = numeric
|
|
1794
2385
|
}
|
|
1795
|
-
if (BOOLEAN_CONFIG_KEYS.includes(
|
|
2386
|
+
if (BOOLEAN_CONFIG_KEYS.includes(configKey)) {
|
|
1796
2387
|
if (rawValue !== 'true' && rawValue !== 'false') {
|
|
1797
2388
|
deps.io.err(`${key} is a toggle — pass "true" or "false", not "${rawValue}".`)
|
|
1798
2389
|
return EXIT.usage
|
|
1799
2390
|
}
|
|
1800
2391
|
value = rawValue === 'true'
|
|
1801
2392
|
}
|
|
2393
|
+
// Enum keys were accepted unchecked, so `config set sound whatever` wrote a
|
|
2394
|
+
// value the sender would later reject — a typo that only surfaces at the
|
|
2395
|
+
// moment a notification fails to carry the sound you asked for.
|
|
2396
|
+
if (info.kind === 'enum' && info.choices !== undefined && !info.choices.includes(rawValue)) {
|
|
2397
|
+
deps.io.err(`${key} takes one of: ${info.choices.join(', ')} — not "${rawValue}".`)
|
|
2398
|
+
return EXIT.usage
|
|
2399
|
+
}
|
|
1802
2400
|
if (key === 'devices') value = rawValue.split(',').map((s) => s.trim()).filter(Boolean)
|
|
1803
2401
|
|
|
2402
|
+
const target = await configMutationTarget(deps, flags)
|
|
2403
|
+
if (target === null) return EXIT.usage
|
|
2404
|
+
if (target.layer === 'global' && Object.is(value, configDefaultValue(configKey))) {
|
|
2405
|
+
deps.io.err(`${key} is already the shipped default (${JSON.stringify(value)}).`)
|
|
2406
|
+
deps.io.err(
|
|
2407
|
+
`Run \`notifai config unset ${key} --yes\` to remove a redundant override instead of creating one.`,
|
|
2408
|
+
)
|
|
2409
|
+
return EXIT.usage
|
|
2410
|
+
}
|
|
2411
|
+
|
|
2412
|
+
if (!flags.yes) {
|
|
2413
|
+
const confirmed = await deps.io.confirm(`Set ${key} = ${JSON.stringify(value)} in ${target.path}?`)
|
|
2414
|
+
if (!confirmed) {
|
|
2415
|
+
deps.io.err('Not confirmed. Pass --yes to skip the confirmation gate.')
|
|
2416
|
+
return EXIT.usage
|
|
2417
|
+
}
|
|
2418
|
+
}
|
|
2419
|
+
|
|
2420
|
+
const existing = existsSync(target.path)
|
|
2421
|
+
? (parseToml(readFileSync(target.path, 'utf8')) as Record<string, unknown>)
|
|
2422
|
+
: {}
|
|
2423
|
+
existing[key] = value
|
|
2424
|
+
mkdirSync(path.dirname(target.path), { recursive: true })
|
|
2425
|
+
writeFileSync(target.path, `${stringifyToml(existing)}\n`)
|
|
2426
|
+
deps.io.out(`Wrote ${key} to ${target.path}`)
|
|
2427
|
+
return EXIT.ok
|
|
2428
|
+
}
|
|
2429
|
+
|
|
2430
|
+
function configMutationKey(deps: CommandDeps, key: string): ConfigKey | null {
|
|
2431
|
+
if (!(CONFIG_KEYS as readonly string[]).includes(key)) {
|
|
2432
|
+
deps.io.err(`Unknown setting "${key}".`)
|
|
2433
|
+
const near = nearestKey(key)
|
|
2434
|
+
if (near !== null) deps.io.err(`Did you mean "${near}"?`)
|
|
2435
|
+
deps.io.err(`Valid settings: ${CONFIG_KEYS.join(', ')}`)
|
|
2436
|
+
deps.io.err('Run `notifai config explain <key>` to see what one of them does.')
|
|
2437
|
+
return null
|
|
2438
|
+
}
|
|
2439
|
+
return key as ConfigKey
|
|
2440
|
+
}
|
|
2441
|
+
|
|
2442
|
+
type ConfigMutationFlags = { project?: boolean; local?: boolean; session?: string; yes?: boolean }
|
|
2443
|
+
type ConfigMutationLayer = 'global' | 'project' | 'local' | 'session'
|
|
2444
|
+
|
|
2445
|
+
async function configMutationTarget(
|
|
2446
|
+
deps: CommandDeps,
|
|
2447
|
+
flags: ConfigMutationFlags,
|
|
2448
|
+
): Promise<{ path: string; layer: ConfigMutationLayer } | null> {
|
|
1804
2449
|
let layer = flags.local ? 'local' : flags.project ? 'project' : 'global'
|
|
1805
2450
|
if (
|
|
1806
2451
|
flags.session === undefined &&
|
|
@@ -1817,7 +2462,7 @@ export async function configSetCommand(
|
|
|
1817
2462
|
])
|
|
1818
2463
|
if (selected === null) {
|
|
1819
2464
|
deps.io.err('No configuration layer selected.')
|
|
1820
|
-
return
|
|
2465
|
+
return null
|
|
1821
2466
|
}
|
|
1822
2467
|
layer = selected
|
|
1823
2468
|
}
|
|
@@ -1829,22 +2474,273 @@ export async function configSetCommand(
|
|
|
1829
2474
|
: layer === 'project'
|
|
1830
2475
|
? (findProjectConfigPath(deps.cwd) ?? path.join(deps.cwd, '.notifai', 'config.toml'))
|
|
1831
2476
|
: globalConfigPath(deps.env)
|
|
2477
|
+
return {
|
|
2478
|
+
path: targetPath,
|
|
2479
|
+
layer: flags.session ? 'session' : (layer as Exclude<ConfigMutationLayer, 'session'>),
|
|
2480
|
+
}
|
|
2481
|
+
}
|
|
1832
2482
|
|
|
2483
|
+
export async function configUnsetCommand(
|
|
2484
|
+
deps: CommandDeps,
|
|
2485
|
+
key: string,
|
|
2486
|
+
flags: ConfigMutationFlags,
|
|
2487
|
+
): Promise<number> {
|
|
2488
|
+
const configKey = configMutationKey(deps, key)
|
|
2489
|
+
if (configKey === null) return EXIT.usage
|
|
2490
|
+
const target = await configMutationTarget(deps, flags)
|
|
2491
|
+
if (target === null) return EXIT.usage
|
|
2492
|
+
const existing = existsSync(target.path)
|
|
2493
|
+
? (parseToml(readFileSync(target.path, 'utf8')) as Record<string, unknown>)
|
|
2494
|
+
: {}
|
|
2495
|
+
if (!Object.prototype.hasOwnProperty.call(existing, configKey)) {
|
|
2496
|
+
deps.io.out(`${configKey} is not set in ${target.path}`)
|
|
2497
|
+
return EXIT.ok
|
|
2498
|
+
}
|
|
1833
2499
|
if (!flags.yes) {
|
|
1834
|
-
const confirmed = await deps.io.confirm(`
|
|
2500
|
+
const confirmed = await deps.io.confirm(`Remove ${configKey} from ${target.path}?`)
|
|
1835
2501
|
if (!confirmed) {
|
|
1836
2502
|
deps.io.err('Not confirmed. Pass --yes to skip the confirmation gate.')
|
|
1837
2503
|
return EXIT.usage
|
|
1838
2504
|
}
|
|
1839
2505
|
}
|
|
1840
2506
|
|
|
1841
|
-
|
|
1842
|
-
|
|
1843
|
-
:
|
|
1844
|
-
|
|
1845
|
-
|
|
1846
|
-
|
|
1847
|
-
deps.io.out(`
|
|
2507
|
+
delete existing[configKey]
|
|
2508
|
+
if (Object.keys(existing).length === 0) {
|
|
2509
|
+
rmSync(target.path, { force: true })
|
|
2510
|
+
} else {
|
|
2511
|
+
writeFileSync(target.path, `${stringifyToml(existing)}\n`)
|
|
2512
|
+
}
|
|
2513
|
+
deps.io.out(`Removed ${configKey} from ${target.path}; the inherited value now applies.`)
|
|
2514
|
+
return EXIT.ok
|
|
2515
|
+
}
|
|
2516
|
+
|
|
2517
|
+
// ---------------------------------------------------------------------------
|
|
2518
|
+
// logs
|
|
2519
|
+
// ---------------------------------------------------------------------------
|
|
2520
|
+
|
|
2521
|
+
export interface LogsFlags {
|
|
2522
|
+
json?: boolean
|
|
2523
|
+
limit?: number
|
|
2524
|
+
all?: boolean
|
|
2525
|
+
since?: string
|
|
2526
|
+
level?: string
|
|
2527
|
+
event?: string[]
|
|
2528
|
+
run?: string
|
|
2529
|
+
request?: string
|
|
2530
|
+
session?: string
|
|
2531
|
+
project?: string
|
|
2532
|
+
allProjects?: boolean
|
|
2533
|
+
grep?: string
|
|
2534
|
+
path?: boolean
|
|
2535
|
+
clear?: boolean
|
|
2536
|
+
}
|
|
2537
|
+
|
|
2538
|
+
const LOG_RECORD_OPTIONS: readonly (readonly [keyof LogsFlags, string])[] = [
|
|
2539
|
+
['limit', '--limit'],
|
|
2540
|
+
['all', '--all'],
|
|
2541
|
+
['since', '--since'],
|
|
2542
|
+
['level', '--level'],
|
|
2543
|
+
['event', '--event'],
|
|
2544
|
+
['run', '--run'],
|
|
2545
|
+
['request', '--request'],
|
|
2546
|
+
['session', '--session'],
|
|
2547
|
+
['project', '--project'],
|
|
2548
|
+
['allProjects', '--all-projects'],
|
|
2549
|
+
['grep', '--grep'],
|
|
2550
|
+
]
|
|
2551
|
+
|
|
2552
|
+
/**
|
|
2553
|
+
* Deliberately small. The reader is usually a model with a finite context, and
|
|
2554
|
+
* a log command whose default answer is ten thousand lines gets used once.
|
|
2555
|
+
* Everything beyond this is reachable by asking for it.
|
|
2556
|
+
*/
|
|
2557
|
+
const DEFAULT_LOG_LIMIT = 30
|
|
2558
|
+
/** Even `--all` stops somewhere; an unbounded dump is never the useful answer. */
|
|
2559
|
+
const MAX_LOG_LIMIT = 2_000
|
|
2560
|
+
|
|
2561
|
+
/** `10m`, `2h`, `1d`, or an ISO 8601 instant. */
|
|
2562
|
+
export function parseSince(raw: string, now: number): number | null {
|
|
2563
|
+
const relative = /^(\d+)([smhd])$/.exec(raw.trim())
|
|
2564
|
+
if (relative !== null) {
|
|
2565
|
+
const amount = Number(relative[1])
|
|
2566
|
+
const unit = relative[2] as 's' | 'm' | 'h' | 'd'
|
|
2567
|
+
const seconds = { s: 1, m: 60, h: 3600, d: 86400 }[unit]
|
|
2568
|
+
return now - amount * seconds * 1000
|
|
2569
|
+
}
|
|
2570
|
+
const absolute = Date.parse(raw)
|
|
2571
|
+
return Number.isNaN(absolute) ? null : absolute
|
|
2572
|
+
}
|
|
2573
|
+
|
|
2574
|
+
/**
|
|
2575
|
+
* What this machine recorded about itself.
|
|
2576
|
+
*
|
|
2577
|
+
* The retrieval half of the local log, and the half that decides whether the
|
|
2578
|
+
* log is worth having. Three things make it usable by an agent rather than
|
|
2579
|
+
* merely available to one: the default is bounded and scoped to this project,
|
|
2580
|
+
* the filters match the questions actually asked ("that run", "that
|
|
2581
|
+
* notification", "just the failures"), and `--json` gives the machine the
|
|
2582
|
+
* records untouched on stdout while every word of explanation goes to stderr.
|
|
2583
|
+
*/
|
|
2584
|
+
export function logsCommand(deps: CommandDeps, flags: LogsFlags): number {
|
|
2585
|
+
const selectedRetrievalOptions = LOG_RECORD_OPTIONS
|
|
2586
|
+
.filter(([key]) => {
|
|
2587
|
+
const value = flags[key]
|
|
2588
|
+
return Array.isArray(value) ? value.length > 0 : value !== undefined && value !== false
|
|
2589
|
+
})
|
|
2590
|
+
.map(([, name]) => name)
|
|
2591
|
+
|
|
2592
|
+
if (flags.path === true && flags.clear === true) {
|
|
2593
|
+
deps.io.err('Pass --path or --clear, not both.')
|
|
2594
|
+
return EXIT.usage
|
|
2595
|
+
}
|
|
2596
|
+
if (flags.path === true && selectedRetrievalOptions.length > 0) {
|
|
2597
|
+
deps.io.err(`--path cannot be combined with record options: ${selectedRetrievalOptions.join(', ')}.`)
|
|
2598
|
+
return EXIT.usage
|
|
2599
|
+
}
|
|
2600
|
+
if (flags.clear === true && selectedRetrievalOptions.length > 0) {
|
|
2601
|
+
deps.io.err(`--clear cannot be combined with record options: ${selectedRetrievalOptions.join(', ')}.`)
|
|
2602
|
+
return EXIT.usage
|
|
2603
|
+
}
|
|
2604
|
+
if (flags.project !== undefined && flags.allProjects === true) {
|
|
2605
|
+
deps.io.err('Pass --project or --all-projects, not both.')
|
|
2606
|
+
return EXIT.usage
|
|
2607
|
+
}
|
|
2608
|
+
if (flags.limit !== undefined && flags.all === true) {
|
|
2609
|
+
deps.io.err('Pass --limit or --all, not both.')
|
|
2610
|
+
return EXIT.usage
|
|
2611
|
+
}
|
|
2612
|
+
if (flags.limit !== undefined && (!Number.isInteger(flags.limit) || flags.limit <= 0)) {
|
|
2613
|
+
deps.io.err('--limit must be a positive integer.')
|
|
2614
|
+
return EXIT.usage
|
|
2615
|
+
}
|
|
2616
|
+
if (flags.limit !== undefined && flags.limit > MAX_LOG_LIMIT) {
|
|
2617
|
+
deps.io.err(`--limit cannot exceed ${MAX_LOG_LIMIT}; narrow the result with --since or filters.`)
|
|
2618
|
+
return EXIT.usage
|
|
2619
|
+
}
|
|
2620
|
+
|
|
2621
|
+
const config = loadLoggedConfig(deps, { cwd: deps.cwd, env: deps.env })
|
|
2622
|
+
const now = (deps.now ?? Date.now)()
|
|
2623
|
+
|
|
2624
|
+
if (flags.path === true) {
|
|
2625
|
+
const usage = logsDiskUsage(deps.env)
|
|
2626
|
+
const archives = archiveLogPaths(deps.env)
|
|
2627
|
+
if (flags.json === true) {
|
|
2628
|
+
deps.io.out(
|
|
2629
|
+
JSON.stringify(
|
|
2630
|
+
{
|
|
2631
|
+
active: activeLogPath(deps.env),
|
|
2632
|
+
archives,
|
|
2633
|
+
files: usage.files,
|
|
2634
|
+
bytes: usage.bytes,
|
|
2635
|
+
level: config.log_level.value,
|
|
2636
|
+
max_bytes: config.log_max_bytes.value,
|
|
2637
|
+
max_files: config.log_max_files.value,
|
|
2638
|
+
local_only: true,
|
|
2639
|
+
},
|
|
2640
|
+
null,
|
|
2641
|
+
2,
|
|
2642
|
+
),
|
|
2643
|
+
)
|
|
2644
|
+
return EXIT.ok
|
|
2645
|
+
}
|
|
2646
|
+
deps.io.out(activeLogPath(deps.env))
|
|
2647
|
+
for (const archive of archives) deps.io.out(archive)
|
|
2648
|
+
deps.io.err(
|
|
2649
|
+
`${usage.files} file(s), ${Math.round(usage.bytes / 1024)} KB, level ${config.log_level.value}. These stay on this machine.`,
|
|
2650
|
+
)
|
|
2651
|
+
return EXIT.ok
|
|
2652
|
+
}
|
|
2653
|
+
|
|
2654
|
+
if (flags.clear === true) {
|
|
2655
|
+
let removed = 0
|
|
2656
|
+
for (const file of [activeLogPath(deps.env), ...archiveLogPaths(deps.env)]) {
|
|
2657
|
+
try {
|
|
2658
|
+
unlinkSync(file)
|
|
2659
|
+
removed += 1
|
|
2660
|
+
} catch {
|
|
2661
|
+
// Absent, or not ours to remove; either way there is nothing to report.
|
|
2662
|
+
}
|
|
2663
|
+
}
|
|
2664
|
+
deps.io.out(
|
|
2665
|
+
flags.json === true
|
|
2666
|
+
? JSON.stringify({ cleared_files: removed })
|
|
2667
|
+
: `Cleared ${removed} log file${removed === 1 ? '' : 's'}.`,
|
|
2668
|
+
)
|
|
2669
|
+
return EXIT.ok
|
|
2670
|
+
}
|
|
2671
|
+
|
|
2672
|
+
const query: LogQuery = {
|
|
2673
|
+
limit: flags.limit ?? (flags.all === true ? MAX_LOG_LIMIT : DEFAULT_LOG_LIMIT),
|
|
2674
|
+
}
|
|
2675
|
+
|
|
2676
|
+
if (flags.since !== undefined) {
|
|
2677
|
+
const since = parseSince(flags.since, now)
|
|
2678
|
+
if (since === null) {
|
|
2679
|
+
deps.io.err(`Could not read "${flags.since}" as a time. Use 10m, 2h, 1d, or an ISO 8601 instant.`)
|
|
2680
|
+
return EXIT.usage
|
|
2681
|
+
}
|
|
2682
|
+
query.since = since
|
|
2683
|
+
}
|
|
2684
|
+
if (flags.level !== undefined) {
|
|
2685
|
+
if (!(LOG_LEVELS as readonly string[]).includes(flags.level)) {
|
|
2686
|
+
deps.io.err(`--level takes one of: ${LOG_LEVELS.join(', ')} — not "${flags.level}".`)
|
|
2687
|
+
return EXIT.usage
|
|
2688
|
+
}
|
|
2689
|
+
query.level = flags.level as LogLevel
|
|
2690
|
+
}
|
|
2691
|
+
if (flags.event !== undefined && flags.event.length > 0) {
|
|
2692
|
+
const unknown = flags.event.filter((name) => !(LOG_EVENTS as readonly string[]).includes(name))
|
|
2693
|
+
if (unknown.length > 0) {
|
|
2694
|
+
deps.io.err(`Unknown event ${unknown.join(', ')}. Valid: ${LOG_EVENTS.join(', ')}`)
|
|
2695
|
+
return EXIT.usage
|
|
2696
|
+
}
|
|
2697
|
+
query.event = flags.event
|
|
2698
|
+
}
|
|
2699
|
+
if (flags.run !== undefined) query.run = flags.run
|
|
2700
|
+
if (flags.session !== undefined) query.session = flags.session
|
|
2701
|
+
if (flags.request !== undefined) query.request = flags.request
|
|
2702
|
+
if (flags.grep !== undefined) query.contains = flags.grep
|
|
2703
|
+
|
|
2704
|
+
// Scoped to this project by default. A machine commonly runs several agents
|
|
2705
|
+
// in several worktrees at once, and the answer to "what just happened" is
|
|
2706
|
+
// useless if three other projects are interleaved through it.
|
|
2707
|
+
const scope =
|
|
2708
|
+
flags.project ?? (flags.allProjects === true ? undefined : (config.project.value ?? undefined))
|
|
2709
|
+
if (scope !== undefined) query.project = scope
|
|
2710
|
+
|
|
2711
|
+
const result = readLogRecords(deps.env, query)
|
|
2712
|
+
|
|
2713
|
+
if (flags.json === true) {
|
|
2714
|
+
for (const record of result.records) deps.io.out(JSON.stringify(record))
|
|
2715
|
+
} else {
|
|
2716
|
+
for (const record of result.records) deps.io.out(renderRecord(record))
|
|
2717
|
+
}
|
|
2718
|
+
|
|
2719
|
+
if (result.records.length === 0) {
|
|
2720
|
+
if (config.log_level.value === 'off') {
|
|
2721
|
+
deps.io.err('Nothing is being recorded: log_level is off.')
|
|
2722
|
+
deps.io.err('Turn it on with `notifai config set log_level info --yes`.')
|
|
2723
|
+
} else if (logsDiskUsage(deps.env).files === 0) {
|
|
2724
|
+
deps.io.err('No log yet. It is written the next time a notifai command or harness hook runs.')
|
|
2725
|
+
} else {
|
|
2726
|
+
deps.io.err('No records matched. Widen it with --all-projects, --since 1d, or --all.')
|
|
2727
|
+
}
|
|
2728
|
+
return EXIT.ok
|
|
2729
|
+
}
|
|
2730
|
+
|
|
2731
|
+
// Everything explanatory goes to stderr so `--json` leaves stdout as clean
|
|
2732
|
+
// JSONL for whatever is parsing it.
|
|
2733
|
+
const notes: string[] = []
|
|
2734
|
+
if (scope !== undefined) notes.push(`project ${scope} (--all-projects for every project)`)
|
|
2735
|
+
if (result.more) {
|
|
2736
|
+
notes.push(
|
|
2737
|
+
query.limit! < MAX_LOG_LIMIT
|
|
2738
|
+
? `more history behind this (-n ${Math.min(query.limit! * 4, MAX_LOG_LIMIT)} or --all)`
|
|
2739
|
+
: `more history exists beyond the ${MAX_LOG_LIMIT}-record safety cap; narrow with --since or filters`,
|
|
2740
|
+
)
|
|
2741
|
+
}
|
|
2742
|
+
if (config.log_level.value !== 'debug') notes.push('`log_level = debug` records request detail')
|
|
2743
|
+
if (notes.length > 0) deps.io.err(`— ${result.records.length} records; ${notes.join('; ')}`)
|
|
1848
2744
|
return EXIT.ok
|
|
1849
2745
|
}
|
|
1850
2746
|
|
|
@@ -1857,7 +2753,7 @@ export async function configSetCommand(
|
|
|
1857
2753
|
* 1.5.x, `owner/repo@name` selects a skill; a Git ref belongs after `#`.
|
|
1858
2754
|
* Keep this immutable and public because the command is printed to users.
|
|
1859
2755
|
*/
|
|
1860
|
-
export const SKILLS_SOURCE = 'Raidiant-io/notifai#v0.
|
|
2756
|
+
export const SKILLS_SOURCE = 'Raidiant-io/notifai#v0.4.0'
|
|
1861
2757
|
|
|
1862
2758
|
function skillSourceParts(): { source: string; ref: string } | null {
|
|
1863
2759
|
const match = /^([^#]+)#(.+)$/.exec(SKILLS_SOURCE)
|
|
@@ -2175,7 +3071,7 @@ async function waitForReadyDevice(deps: CommandDeps, state: ReadinessState): Pro
|
|
|
2175
3071
|
const remedy = state.remedy
|
|
2176
3072
|
if (deps.io.interactive !== true || remedy?.by !== 'user-elsewhere') return 'pending'
|
|
2177
3073
|
|
|
2178
|
-
const config =
|
|
3074
|
+
const config = loadLoggedConfig(deps, { cwd: deps.cwd, env: deps.env })
|
|
2179
3075
|
const authed = authedClient(deps, config)
|
|
2180
3076
|
if (!authed) {
|
|
2181
3077
|
deps.io.err('Could not start the companion-device wait: this machine is not signed in.')
|
|
@@ -2326,7 +3222,7 @@ async function submitSetupProof(
|
|
|
2326
3222
|
* the push, not that a companion process received it.
|
|
2327
3223
|
*/
|
|
2328
3224
|
async function runSetupProof(deps: CommandDeps): Promise<GapCloseResult> {
|
|
2329
|
-
const config =
|
|
3225
|
+
const config = loadLoggedConfig(deps, { cwd: deps.cwd, env: deps.env })
|
|
2330
3226
|
const authed = authedClient(deps, config)
|
|
2331
3227
|
if (!authed) return 'failed'
|
|
2332
3228
|
|
|
@@ -2694,7 +3590,7 @@ export async function assessReadiness(
|
|
|
2694
3590
|
deps: CommandDeps,
|
|
2695
3591
|
options: { skillScope?: SkillScope } = {},
|
|
2696
3592
|
): Promise<Readiness> {
|
|
2697
|
-
const config =
|
|
3593
|
+
const config = loadLoggedConfig(deps, { cwd: deps.cwd, env: deps.env })
|
|
2698
3594
|
const states: ReadinessState[] = []
|
|
2699
3595
|
let accountClient: ApiClient | null = null
|
|
2700
3596
|
let accountDevices: RoutableDevice[] | null = null
|
|
@@ -3012,7 +3908,13 @@ export async function doctorCommand(deps: CommandDeps, flags: { json?: boolean }
|
|
|
3012
3908
|
// costs nothing.
|
|
3013
3909
|
if (deps.io.interactive === true && deps.io.check) {
|
|
3014
3910
|
await deps.io.intro?.('Notifai doctor')
|
|
3015
|
-
for (const s of readiness.states)
|
|
3911
|
+
for (const s of readiness.states) {
|
|
3912
|
+
// Four states, not two. Rendering `optional-gap` and `unknown` as
|
|
3913
|
+
// successes made a green report that had never checked half of what it
|
|
3914
|
+
// listed, and put a tick beside things the reader had deliberately
|
|
3915
|
+
// declined.
|
|
3916
|
+
await deps.io.check(s.status !== 'gap', line(s), doctorTone(s.status))
|
|
3917
|
+
}
|
|
3016
3918
|
await deps.io.outro?.(ok ? 'Everything looks good' : `Start with: ${remedyLine(blocker)}`)
|
|
3017
3919
|
} else {
|
|
3018
3920
|
for (const s of readiness.states) {
|
|
@@ -3031,6 +3933,20 @@ export async function doctorCommand(deps: CommandDeps, flags: { json?: boolean }
|
|
|
3031
3933
|
return ok ? EXIT.ok : EXIT.failed
|
|
3032
3934
|
}
|
|
3033
3935
|
|
|
3936
|
+
/** Readiness status as a report tone. */
|
|
3937
|
+
function doctorTone(status: StateStatus): Tone {
|
|
3938
|
+
switch (status) {
|
|
3939
|
+
case 'ready':
|
|
3940
|
+
return 'ok'
|
|
3941
|
+
case 'gap':
|
|
3942
|
+
return 'bad'
|
|
3943
|
+
case 'optional-gap':
|
|
3944
|
+
return 'warn'
|
|
3945
|
+
case 'unknown':
|
|
3946
|
+
return 'pending'
|
|
3947
|
+
}
|
|
3948
|
+
}
|
|
3949
|
+
|
|
3034
3950
|
/** One line telling the reader what to actually do about a state. */
|
|
3035
3951
|
function remedyLine(state: ReadinessState): string {
|
|
3036
3952
|
const remedy = state.remedy
|
|
@@ -3059,23 +3975,21 @@ function remedyLine(state: ReadinessState): string {
|
|
|
3059
3975
|
*
|
|
3060
3976
|
* Not everything failed is in the way. A pointer that has never been
|
|
3061
3977
|
* published is the normal condition of an install thirty seconds old — the
|
|
3062
|
-
* next prompt fixes it and no command can
|
|
3063
|
-
*
|
|
3064
|
-
*
|
|
3065
|
-
* finish for an OpenCode user, or could only finish after a session had
|
|
3066
|
-
* already run — so they report as things worth knowing rather than things to
|
|
3978
|
+
* next prompt fixes it and no command can. Treating that as blocking would
|
|
3979
|
+
* mean `init` could only finish after a session had already run — so it
|
|
3980
|
+
* reports as something worth knowing rather than something to
|
|
3067
3981
|
* fix, and `init` walks on to the states it can actually close, delivery
|
|
3068
3982
|
* proof included. Which failures are informational, and what the true remedy
|
|
3069
3983
|
* is, is each check's own call (`HookCheck`).
|
|
3070
3984
|
*/
|
|
3071
3985
|
function hookStates(deps: CommandDeps): ReadinessState[] {
|
|
3072
|
-
const installations = findInstallations(deps.cwd, deps.env)
|
|
3986
|
+
const installations = findInstallations(deps.cwd, deps.env, deps.hookAdapterHome)
|
|
3073
3987
|
const active = activeHarnessSession(deps.env)
|
|
3074
|
-
const config =
|
|
3988
|
+
const config = loadLoggedConfig(deps, { cwd: deps.cwd, env: deps.env })
|
|
3075
3989
|
const settings: ReadinessState = {
|
|
3076
3990
|
id: 'question-routing-settings',
|
|
3077
3991
|
title: 'Question routing settings',
|
|
3078
|
-
status: 'ready',
|
|
3992
|
+
status: config.ask_notifications.value ? 'ready' : 'gap',
|
|
3079
3993
|
detail: [
|
|
3080
3994
|
`ask_notifications=${config.ask_notifications.value} (${config.ask_notifications.source})`,
|
|
3081
3995
|
`require_idle=${config.require_idle.value} (${config.require_idle.source})`,
|
|
@@ -3083,6 +3997,15 @@ function hookStates(deps: CommandDeps): ReadinessState[] {
|
|
|
3083
3997
|
`ask_grace_seconds=${config.ask_grace_seconds.value} (${config.ask_grace_seconds.source})`,
|
|
3084
3998
|
`hook_reply_timeout_seconds=${config.hook_reply_timeout_seconds.value} (${config.hook_reply_timeout_seconds.source})`,
|
|
3085
3999
|
].join(', '),
|
|
4000
|
+
...(config.ask_notifications.value
|
|
4001
|
+
? {}
|
|
4002
|
+
: {
|
|
4003
|
+
remedy: {
|
|
4004
|
+
by: 'cli' as const,
|
|
4005
|
+
summary: 'enable asynchronous question routing',
|
|
4006
|
+
command: 'notifai config set ask_notifications true',
|
|
4007
|
+
},
|
|
4008
|
+
}),
|
|
3086
4009
|
}
|
|
3087
4010
|
if (installations.length === 0) {
|
|
3088
4011
|
return [
|
|
@@ -3108,7 +4031,7 @@ function hookStates(deps: CommandDeps): ReadinessState[] {
|
|
|
3108
4031
|
}
|
|
3109
4032
|
|
|
3110
4033
|
/** Real but not in the way; see the note above. */
|
|
3111
|
-
const informational = new Set(
|
|
4034
|
+
const informational = new Set<string>()
|
|
3112
4035
|
return [
|
|
3113
4036
|
...hookChecks(deps).map((check) => ({
|
|
3114
4037
|
id: check.name.replace(/[ ()]+/g, '-').replace(/-$/, ''),
|
|
@@ -3153,7 +4076,7 @@ interface HookCheck {
|
|
|
3153
4076
|
|
|
3154
4077
|
function hookChecks(deps: CommandDeps): HookCheck[] {
|
|
3155
4078
|
const checks: HookCheck[] = []
|
|
3156
|
-
const installations = findInstallations(deps.cwd, deps.env)
|
|
4079
|
+
const installations = findInstallations(deps.cwd, deps.env, deps.hookAdapterHome)
|
|
3157
4080
|
|
|
3158
4081
|
// Not having hooks is a setup someone chose, not a fault: `send` works
|
|
3159
4082
|
// without them. A setup that cannot work is what deserves to go red.
|
|
@@ -3196,7 +4119,16 @@ function hookChecks(deps: CommandDeps): HookCheck[] {
|
|
|
3196
4119
|
}),
|
|
3197
4120
|
})
|
|
3198
4121
|
if (activeInstallations.length > 0) {
|
|
3199
|
-
const pointer =
|
|
4122
|
+
const pointer =
|
|
4123
|
+
active.sessionId === undefined
|
|
4124
|
+
? null
|
|
4125
|
+
: readMatchingProjectSessionPointer(
|
|
4126
|
+
deps.cwd,
|
|
4127
|
+
deps.env,
|
|
4128
|
+
(deps.now ?? Date.now)(),
|
|
4129
|
+
active.sessionId,
|
|
4130
|
+
active.harness,
|
|
4131
|
+
)
|
|
3200
4132
|
if (pointer === null) {
|
|
3201
4133
|
// The normal condition of hooks installed moments ago: the pointer
|
|
3202
4134
|
// appears when the harness next fires a hook, and no command can
|
|
@@ -3213,23 +4145,10 @@ function hookChecks(deps: CommandDeps): HookCheck[] {
|
|
|
3213
4145
|
},
|
|
3214
4146
|
})
|
|
3215
4147
|
} else {
|
|
3216
|
-
const matches =
|
|
3217
|
-
pointer.harness === active.harness &&
|
|
3218
|
-
(active.sessionId === undefined || pointer.sessionId === active.sessionId)
|
|
3219
4148
|
checks.push({
|
|
3220
4149
|
name: 'hooks (active session)',
|
|
3221
|
-
ok:
|
|
3222
|
-
detail:
|
|
3223
|
-
? `the project pointer belongs to the active ${active.label} session`
|
|
3224
|
-
: `the project pointer belongs to another ${active.label} session or harness; refusing cross-session routing`,
|
|
3225
|
-
...(matches
|
|
3226
|
-
? {}
|
|
3227
|
-
: {
|
|
3228
|
-
remedy: {
|
|
3229
|
-
summary: `send one ${active.label} prompt in this session so the pointer belongs to it`,
|
|
3230
|
-
command: 'notifai doctor',
|
|
3231
|
-
},
|
|
3232
|
-
}),
|
|
4150
|
+
ok: true,
|
|
4151
|
+
detail: `the concurrent project index contains the active ${active.label} session`,
|
|
3233
4152
|
})
|
|
3234
4153
|
}
|
|
3235
4154
|
}
|
|
@@ -3257,21 +4176,41 @@ function hookChecks(deps: CommandDeps): HookCheck[] {
|
|
|
3257
4176
|
const adapterProblems = installations.flatMap((installation) =>
|
|
3258
4177
|
(installation.problems ?? []).map((problem) => `${installation.file}: ${problem}`),
|
|
3259
4178
|
)
|
|
4179
|
+
const sharedAdapter = inspectHookAdapter(deps.hookAdapterHome)
|
|
4180
|
+
adapterProblems.push(...sharedAdapter.problems)
|
|
3260
4181
|
if (adapterProblems.length > 0) {
|
|
3261
4182
|
checks.push({
|
|
3262
4183
|
name: 'hooks (adapter)',
|
|
3263
4184
|
ok: false,
|
|
4185
|
+
// A machine-global install for a harness that is not active in this
|
|
4186
|
+
// project is useful diagnosis, but it must not block unrelated init.
|
|
4187
|
+
informational: active === null && installations.every((installation) => installation.global),
|
|
3264
4188
|
detail: adapterProblems.join('; '),
|
|
3265
4189
|
})
|
|
3266
4190
|
}
|
|
3267
4191
|
|
|
3268
|
-
const
|
|
3269
|
-
|
|
3270
|
-
|
|
3271
|
-
|
|
3272
|
-
|
|
4192
|
+
const trustProblems = codexTrustProblems(installations, deps.env)
|
|
4193
|
+
checks.push({
|
|
4194
|
+
name: 'hooks (trust)',
|
|
4195
|
+
ok: trustProblems.length === 0,
|
|
4196
|
+
detail:
|
|
4197
|
+
trustProblems.length === 0
|
|
4198
|
+
? 'best-effort check matches current persisted Codex approvals; Notifai never writes the trust store, and `/hooks` is authoritative'
|
|
4199
|
+
: `best-effort check only; Notifai never writes the trust store and \`/hooks\` is authoritative. ${trustProblems.join('; ')}`,
|
|
4200
|
+
...(trustProblems.length === 0
|
|
4201
|
+
? {}
|
|
4202
|
+
: {
|
|
4203
|
+
remedy: {
|
|
4204
|
+
summary: 'open `/hooks` in Codex and approve the changed Notifai handlers',
|
|
4205
|
+
command: '/hooks',
|
|
4206
|
+
},
|
|
4207
|
+
}),
|
|
4208
|
+
})
|
|
4209
|
+
|
|
4210
|
+
const requiredTimeout = blockingHookTimeoutSeconds()
|
|
3273
4211
|
const shortTimeouts = installations.flatMap((installation) =>
|
|
3274
4212
|
installation.handlers
|
|
4213
|
+
.filter(() => installation.harness !== 'codex')
|
|
3275
4214
|
.filter((handler) => handlerEvent(handler.command) === 'stop')
|
|
3276
4215
|
.filter((handler) => handler.timeout === undefined || handler.timeout < requiredTimeout)
|
|
3277
4216
|
.map(
|
|
@@ -3284,26 +4223,20 @@ function hookChecks(deps: CommandDeps): HookCheck[] {
|
|
|
3284
4223
|
ok: shortTimeouts.length === 0,
|
|
3285
4224
|
detail:
|
|
3286
4225
|
shortTimeouts.length === 0
|
|
3287
|
-
? `
|
|
4226
|
+
? `declared Stop timeouts match the fixed ${requiredTimeout}s process budget where required; Codex uses host defaults`
|
|
3288
4227
|
: shortTimeouts.join('; '),
|
|
3289
4228
|
})
|
|
3290
4229
|
|
|
3291
|
-
//
|
|
3292
|
-
//
|
|
3293
|
-
//
|
|
3294
|
-
//
|
|
3295
|
-
// session, so having Claude Code and OpenCode both set up is the ordinary
|
|
3296
|
-
// case and not a duplicate — comparing them turned a healthy machine red.
|
|
4230
|
+
// Project and global definitions for one harness both fire. Stable adapter
|
|
4231
|
+
// identity deliberately makes their command bytes equal, so comparing
|
|
4232
|
+
// command targets would now hide this duplicate rather than diagnose it.
|
|
4233
|
+
// Different harnesses remain independent: only the active one runs.
|
|
3297
4234
|
const duplicated = [...new Set(installations.map((i) => i.harness))]
|
|
3298
4235
|
.map((harness) => ({
|
|
3299
4236
|
harness,
|
|
3300
|
-
|
|
3301
|
-
installations
|
|
3302
|
-
.filter((i) => i.harness === harness)
|
|
3303
|
-
.flatMap((i) => i.handlers.map((h) => h.command.split(' hook ')[0] ?? '')),
|
|
3304
|
-
),
|
|
4237
|
+
installations: installations.filter((i) => i.harness === harness),
|
|
3305
4238
|
}))
|
|
3306
|
-
.filter((entry) => entry.
|
|
4239
|
+
.filter((entry) => entry.installations.length > 1)
|
|
3307
4240
|
if (duplicated.length > 0) {
|
|
3308
4241
|
checks.push({
|
|
3309
4242
|
name: 'hooks (duplicates)',
|
|
@@ -3311,43 +4244,86 @@ function hookChecks(deps: CommandDeps): HookCheck[] {
|
|
|
3311
4244
|
detail: duplicated
|
|
3312
4245
|
.map(
|
|
3313
4246
|
(entry) =>
|
|
3314
|
-
`${entry.harness}: ${entry.
|
|
4247
|
+
`${entry.harness}: ${entry.installations.length} hook definitions are active, so each event will fire all of them. Keep either project or global routing and uninstall the other: ${entry.installations.map((installation) => installation.file).join(', ')}`,
|
|
3315
4248
|
)
|
|
3316
4249
|
.join('; '),
|
|
3317
4250
|
})
|
|
3318
4251
|
}
|
|
3319
4252
|
|
|
3320
|
-
|
|
3321
|
-
|
|
4253
|
+
if (active !== null && activeInstallations.length > 0) {
|
|
4254
|
+
const admissionProblems = activeQuestionRouteProblems(deps, active, installations)
|
|
4255
|
+
checks.push({
|
|
4256
|
+
name: 'hooks (question admission)',
|
|
4257
|
+
ok: admissionProblems.length === 0,
|
|
4258
|
+
detail:
|
|
4259
|
+
admissionProblems.length === 0
|
|
4260
|
+
? `the active ${active.label} route is exact, current, singular, trusted where applicable, and bounded by a live owner`
|
|
4261
|
+
: admissionProblems.join('; '),
|
|
4262
|
+
})
|
|
4263
|
+
}
|
|
4264
|
+
|
|
4265
|
+
const firedPointer =
|
|
4266
|
+
active?.sessionId === undefined
|
|
4267
|
+
? readProjectSessionPointer(deps.cwd, deps.env, (deps.now ?? Date.now)())
|
|
4268
|
+
: readMatchingProjectSessionPointer(
|
|
4269
|
+
deps.cwd,
|
|
4270
|
+
deps.env,
|
|
4271
|
+
(deps.now ?? Date.now)(),
|
|
4272
|
+
active.sessionId,
|
|
4273
|
+
active.harness,
|
|
4274
|
+
)
|
|
4275
|
+
const firedState = firedPointer === null ? null : readSessionState(firedPointer.sessionId, deps.env)
|
|
4276
|
+
const promptFired = firedState?.last_prompt_at !== undefined
|
|
4277
|
+
const stopFired = firedState?.last_stop_at !== undefined
|
|
4278
|
+
const fired = firedPointer !== null && promptFired && stopFired
|
|
3322
4279
|
const activationInstallations =
|
|
3323
4280
|
active !== null && activeInstallations.length > 0 ? activeInstallations : installations
|
|
3324
4281
|
checks.push({
|
|
3325
4282
|
name: 'hooks (fired)',
|
|
3326
4283
|
ok: fired,
|
|
3327
|
-
//
|
|
3328
|
-
//
|
|
3329
|
-
informational:
|
|
4284
|
+
// A wholly fresh install is informational. Once UserPromptSubmit has fired,
|
|
4285
|
+
// a missing Stop is a broken route, not missing historical evidence.
|
|
4286
|
+
informational: firedPointer === null,
|
|
3330
4287
|
detail: fired
|
|
3331
|
-
? 'a session in this directory has run
|
|
3332
|
-
:
|
|
4288
|
+
? 'a session in this directory has run UserPromptSubmit and Stop'
|
|
4289
|
+
: firedPointer === null
|
|
4290
|
+
? `no session pointer from the last 24 hours — ${hookActivationAdvice(activationInstallations)}`
|
|
4291
|
+
: `the routed session has fired ${promptFired ? 'UserPromptSubmit' : 'neither required event'}, but Stop has not been observed — end one harmless turn, send a new prompt, then check again`,
|
|
3333
4292
|
...(fired
|
|
3334
4293
|
? {}
|
|
3335
4294
|
: {
|
|
3336
4295
|
remedy: {
|
|
3337
|
-
summary:
|
|
4296
|
+
summary:
|
|
4297
|
+
firedPointer === null
|
|
4298
|
+
? 'send one prompt in a session here, then re-check'
|
|
4299
|
+
: 'end one harmless turn, send a new prompt, then re-check',
|
|
3338
4300
|
command: 'notifai doctor',
|
|
3339
4301
|
},
|
|
3340
4302
|
}),
|
|
3341
4303
|
})
|
|
3342
4304
|
|
|
3343
|
-
|
|
3344
|
-
|
|
3345
|
-
|
|
3346
|
-
|
|
3347
|
-
|
|
3348
|
-
|
|
3349
|
-
|
|
3350
|
-
|
|
4305
|
+
const continuationInstallations =
|
|
4306
|
+
active !== null ? activeInstallations : installations
|
|
4307
|
+
const continuationHarnesses = [
|
|
4308
|
+
...new Set(continuationInstallations.map((installation) => installation.harness)),
|
|
4309
|
+
]
|
|
4310
|
+
checks.push({
|
|
4311
|
+
name: 'hooks (answer continuation)',
|
|
4312
|
+
ok:
|
|
4313
|
+
continuationHarnesses.length > 0 &&
|
|
4314
|
+
continuationHarnesses.every(
|
|
4315
|
+
(harness) => HARNESS_CAPABILITIES[harness].stopContinuation !== 'unsupported',
|
|
4316
|
+
),
|
|
4317
|
+
informational: active === null,
|
|
4318
|
+
detail:
|
|
4319
|
+
continuationHarnesses.length === 0
|
|
4320
|
+
? active === null
|
|
4321
|
+
? 'no installed harness route to assess'
|
|
4322
|
+
: `the active ${active.label} session has no matching continuation adapter`
|
|
4323
|
+
: continuationHarnesses
|
|
4324
|
+
.map((harness) => `${harness}: ${HARNESS_CAPABILITIES[harness].deliveryContract}`)
|
|
4325
|
+
.join('; '),
|
|
4326
|
+
})
|
|
3351
4327
|
|
|
3352
4328
|
const stray = codexStrayWorktreeCheck(deps)
|
|
3353
4329
|
if (stray !== null) checks.push(stray)
|
|
@@ -3463,11 +4439,21 @@ export function realIo(env: NodeJS.ProcessEnv = process.env): CommandIo {
|
|
|
3463
4439
|
error: (next) => progress.error(next),
|
|
3464
4440
|
}
|
|
3465
4441
|
},
|
|
3466
|
-
check: async (ok, message) => {
|
|
4442
|
+
check: async (ok, message, tone) => {
|
|
3467
4443
|
if (!interactive()) return
|
|
3468
4444
|
const { log } = await clack()
|
|
3469
|
-
|
|
3470
|
-
|
|
4445
|
+
switch (tone ?? (ok ? 'ok' : 'bad')) {
|
|
4446
|
+
case 'ok':
|
|
4447
|
+
return log.success(message)
|
|
4448
|
+
case 'warn':
|
|
4449
|
+
return log.warn(message)
|
|
4450
|
+
case 'pending':
|
|
4451
|
+
// Never checked. `log.info` reads as neutral, which is the honest
|
|
4452
|
+
// rendering for a state no evidence was gathered about.
|
|
4453
|
+
return log.info(message)
|
|
4454
|
+
case 'bad':
|
|
4455
|
+
return log.error(message)
|
|
4456
|
+
}
|
|
3471
4457
|
},
|
|
3472
4458
|
openUrl: (url) => {
|
|
3473
4459
|
try {
|