@raidiant/notifai 8.0.0 → 9.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +41 -0
- package/README.md +20 -13
- package/dist/atomic-file.d.ts +14 -0
- package/dist/atomic-file.js +27 -2
- package/dist/atomic-file.js.map +1 -1
- package/dist/cli-bin.d.ts +4 -0
- package/dist/cli-bin.js +62 -0
- package/dist/cli-bin.js.map +1 -0
- package/dist/cli-release.d.ts +10 -0
- package/dist/cli-release.js +75 -0
- package/dist/cli-release.js.map +1 -0
- package/dist/client.d.ts +1 -0
- package/dist/client.js +13 -3
- package/dist/client.js.map +1 -1
- package/dist/commands-auth.d.ts +6 -0
- package/dist/commands-auth.js +28 -3
- package/dist/commands-auth.js.map +1 -1
- package/dist/commands-config.js +22 -3
- package/dist/commands-config.js.map +1 -1
- package/dist/commands-core.d.ts +12 -0
- package/dist/commands-core.js +27 -3
- package/dist/commands-core.js.map +1 -1
- package/dist/commands-doctor.d.ts +1 -0
- package/dist/commands-doctor.js +71 -15
- package/dist/commands-doctor.js.map +1 -1
- package/dist/commands-guidance.d.ts +7 -3
- package/dist/commands-guidance.js +63 -28
- package/dist/commands-guidance.js.map +1 -1
- package/dist/commands-hooks.d.ts +9 -4
- package/dist/commands-hooks.js +430 -32
- package/dist/commands-hooks.js.map +1 -1
- package/dist/commands-init.d.ts +10 -18
- package/dist/commands-init.js +114 -32
- package/dist/commands-init.js.map +1 -1
- package/dist/commands-send-support.d.ts +17 -2
- package/dist/commands-send-support.js +61 -5
- package/dist/commands-send-support.js.map +1 -1
- package/dist/commands-send.js +60 -11
- package/dist/commands-send.js.map +1 -1
- package/dist/commands-setup-proof.d.ts +8 -0
- package/dist/commands-setup-proof.js +31 -2
- package/dist/commands-setup-proof.js.map +1 -1
- package/dist/commands-skill.d.ts +11 -1
- package/dist/commands-skill.js +61 -11
- package/dist/commands-skill.js.map +1 -1
- package/dist/commands.d.ts +1 -1
- package/dist/commands.js.map +1 -1
- package/dist/config-schema.d.ts +1 -1
- package/dist/config-schema.js +25 -0
- package/dist/config-schema.js.map +1 -1
- package/dist/config.d.ts +24 -3
- package/dist/config.js +37 -5
- package/dist/config.js.map +1 -1
- package/dist/credentials.d.ts +10 -1
- package/dist/credentials.js +25 -12
- package/dist/credentials.js.map +1 -1
- package/dist/guidance-content.d.ts +20 -0
- package/dist/guidance-content.js +73 -8
- package/dist/guidance-content.js.map +1 -1
- package/dist/guidance.d.ts +21 -0
- package/dist/guidance.js +73 -18
- package/dist/guidance.js.map +1 -1
- package/dist/hook-adapter.js +37 -3
- package/dist/hook-adapter.js.map +1 -1
- package/dist/hooks.d.ts +68 -7
- package/dist/hooks.js +231 -85
- package/dist/hooks.js.map +1 -1
- package/dist/install-hooks.d.ts +10 -18
- package/dist/install-hooks.js +110 -23
- package/dist/install-hooks.js.map +1 -1
- package/dist/interactive.d.ts +1 -1
- package/dist/interactive.js +1 -1
- package/dist/interactive.js.map +1 -1
- package/dist/logging.d.ts +8 -1
- package/dist/logging.js +43 -2
- package/dist/logging.js.map +1 -1
- package/dist/main.js +2 -13
- package/dist/main.js.map +1 -1
- package/dist/native-skills.d.ts +19 -0
- package/dist/native-skills.js +30 -8
- package/dist/native-skills.js.map +1 -1
- package/dist/opencode-plugin.js +14 -1
- package/dist/opencode-plugin.js.map +1 -1
- package/dist/program.js +13 -8
- package/dist/program.js.map +1 -1
- package/dist/send.d.ts +8 -1
- package/dist/send.js +17 -1
- package/dist/send.js.map +1 -1
- package/dist/session-activation.d.ts +15 -0
- package/dist/session-activation.js +35 -0
- package/dist/session-activation.js.map +1 -0
- package/dist/url-policy.d.ts +85 -0
- package/dist/url-policy.js +369 -0
- package/dist/url-policy.js.map +1 -0
- package/package.json +3 -3
- package/src/atomic-file.ts +36 -2
- package/src/cli-bin.test.ts +38 -0
- package/src/cli-bin.ts +67 -0
- package/src/cli-release.test.ts +46 -0
- package/src/cli-release.ts +78 -0
- package/src/client.test.ts +95 -0
- package/src/client.ts +16 -3
- package/src/commands-auth.ts +32 -3
- package/src/commands-config.ts +25 -2
- package/src/commands-core.ts +38 -3
- package/src/commands-doctor.ts +99 -13
- package/src/commands-guidance.ts +75 -29
- package/src/commands-hooks.ts +503 -34
- package/src/commands-init.ts +139 -38
- package/src/commands-send-support.test.ts +156 -0
- package/src/commands-send-support.ts +67 -5
- package/src/commands-send.ts +73 -10
- package/src/commands-setup-proof.ts +36 -2
- package/src/commands-skill.ts +78 -15
- package/src/commands.test.ts +1083 -101
- package/src/commands.ts +1 -0
- package/src/config-schema.ts +28 -1
- package/src/config.test.ts +1 -1
- package/src/config.ts +49 -5
- package/src/credentials.test.ts +71 -3
- package/src/credentials.ts +29 -15
- package/src/guidance-content.ts +74 -8
- package/src/guidance.test.ts +42 -11
- package/src/guidance.ts +100 -18
- package/src/hook-adapter.test.ts +113 -1
- package/src/hook-adapter.ts +37 -3
- package/src/hooks.test.ts +746 -165
- package/src/hooks.ts +299 -124
- package/src/install-hooks.test.ts +135 -16
- package/src/install-hooks.ts +113 -25
- package/src/interactive.ts +1 -1
- package/src/logging.test.ts +66 -3
- package/src/logging.ts +45 -2
- package/src/main.ts +2 -14
- package/src/native-skills.test.ts +41 -1
- package/src/native-skills.ts +40 -6
- package/src/opencode-plugin.ts +14 -1
- package/src/program.test.ts +37 -2
- package/src/program.ts +40 -12
- package/src/send.test.ts +28 -0
- package/src/send.ts +28 -2
- package/src/session-activation.ts +45 -0
- package/src/skill.test.ts +120 -4
- package/src/trust-policy.test.ts +701 -0
- package/src/url-policy.ts +399 -0
package/src/hooks.ts
CHANGED
|
@@ -69,6 +69,8 @@ export interface HookEnvelope {
|
|
|
69
69
|
session_id?: string
|
|
70
70
|
cwd?: string
|
|
71
71
|
hook_event_name?: string
|
|
72
|
+
/** How a harness lifecycle began: startup, resume, clear, compact, or fork. */
|
|
73
|
+
source?: string
|
|
72
74
|
/** Set by the harness when this Stop follows a previous Stop continuation. */
|
|
73
75
|
stop_hook_active?: boolean
|
|
74
76
|
/** Cursor's stable per-conversation identifier. */
|
|
@@ -77,9 +79,20 @@ export interface HookEnvelope {
|
|
|
77
79
|
workspace_roots?: string[]
|
|
78
80
|
/** Cursor increments this after each stop-hook automatic follow-up. */
|
|
79
81
|
loop_count?: number
|
|
82
|
+
/** Cursor Stop completion state; cancellation must not auto-follow. */
|
|
83
|
+
status?: string
|
|
84
|
+
/** The prompt the user just submitted, when the harness includes it. */
|
|
85
|
+
prompt?: string
|
|
80
86
|
}
|
|
81
87
|
|
|
82
88
|
export interface SessionState {
|
|
89
|
+
/** Model-visible proactive context already emitted for this harness session. */
|
|
90
|
+
activation_context_emitted_at?: number
|
|
91
|
+
/** Harness process that received it; a resumed session has a new owner. */
|
|
92
|
+
activation_context_owner_pid?: number
|
|
93
|
+
/** Cursor's documented session context path is lossy; bounded first-Stop fallback journal. */
|
|
94
|
+
cursor_activation_claimed_at?: number
|
|
95
|
+
cursor_activation_confirmed_at?: number
|
|
83
96
|
/** Epoch ms of the user's last prompt in this session — our presence signal. */
|
|
84
97
|
last_prompt_at?: number
|
|
85
98
|
/** Epoch ms of the last observed Stop hook, distinct from prompt routing. */
|
|
@@ -239,6 +252,15 @@ export interface PendingQuestion {
|
|
|
239
252
|
device_ids?: string[]
|
|
240
253
|
/** Absolute end of the server reply window and its local continuation owner. */
|
|
241
254
|
reply_deadline_at?: number
|
|
255
|
+
/**
|
|
256
|
+
* When this Stop owner stops listening for a direct wake. Distinct from
|
|
257
|
+
* `reply_deadline_at`: the server may still accept an answer for a day after
|
|
258
|
+
* this owner has returned. A later Stop that treats the reply window as a
|
|
259
|
+
* fresh waiter ceiling monopolizes the claim and skips newly registered
|
|
260
|
+
* questions. Absent on state written before this field existed — treated as
|
|
261
|
+
* already spent, because that is the shape a degraded waiter left behind.
|
|
262
|
+
*/
|
|
263
|
+
owner_deadline_at?: number
|
|
242
264
|
/** Frozen before the first network byte, so an ambiguous submit is replayable. */
|
|
243
265
|
submission?: PendingSubmissionIntent
|
|
244
266
|
}
|
|
@@ -319,6 +341,14 @@ export function waiterCeilingSeconds(detached: boolean): number {
|
|
|
319
341
|
*/
|
|
320
342
|
export const MIN_REPLY_WINDOW_SECONDS = 60
|
|
321
343
|
|
|
344
|
+
/**
|
|
345
|
+
* HTTP budget reserved for the waiter's final reply fetch and close fence.
|
|
346
|
+
* Taken from the owner deadline rather than added after it, so the hook does
|
|
347
|
+
* not block past its ceiling. A near-zero remainder used to produce a
|
|
348
|
+
* misleading 0s timeout and an unproven retirement.
|
|
349
|
+
*/
|
|
350
|
+
export const FINALIZATION_NETWORK_BUDGET_SECONDS = 3
|
|
351
|
+
|
|
322
352
|
/**
|
|
323
353
|
* The terminal-first wait: the question sits in the terminal for
|
|
324
354
|
* `ask_grace_seconds` from when it was sent, and only then reaches companion
|
|
@@ -348,8 +378,10 @@ async function awaitTerminalFirstWindow(
|
|
|
348
378
|
}
|
|
349
379
|
|
|
350
380
|
export interface HookOutcome {
|
|
351
|
-
/** Written to stdout verbatim — the harness parses this as
|
|
381
|
+
/** Written to stdout verbatim — the harness parses this as output. */
|
|
352
382
|
stdout?: string
|
|
383
|
+
/** Whether stdout takes over the turn, rather than adding prompt context. */
|
|
384
|
+
decided?: boolean
|
|
353
385
|
/** Diagnostics; harnesses surface hook stderr in the transcript. */
|
|
354
386
|
notes: string[]
|
|
355
387
|
/** Structured lifecycle detail that belongs on hook.end without user text. */
|
|
@@ -462,6 +494,108 @@ function updateSessionState(
|
|
|
462
494
|
})
|
|
463
495
|
}
|
|
464
496
|
|
|
497
|
+
/** Record a lifecycle activation so the first-prompt compatibility path stays silent. */
|
|
498
|
+
export function markSessionActivation(
|
|
499
|
+
sessionId: string,
|
|
500
|
+
env: NodeJS.ProcessEnv,
|
|
501
|
+
now: number,
|
|
502
|
+
ownerPid: number,
|
|
503
|
+
): void {
|
|
504
|
+
updateSessionState(sessionId, env, (current) => ({
|
|
505
|
+
...current,
|
|
506
|
+
activation_context_emitted_at: now,
|
|
507
|
+
activation_context_owner_pid: ownerPid,
|
|
508
|
+
}))
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
/**
|
|
512
|
+
* Atomically own first-prompt activation only when no lifecycle hook did.
|
|
513
|
+
*
|
|
514
|
+
* Some managed hosts preserve UserPromptSubmit while regenerating away newer
|
|
515
|
+
* SessionStart definitions. This is a compatibility seam, not the primary
|
|
516
|
+
* lifecycle: one session gets at most one model-visible activation from it.
|
|
517
|
+
*/
|
|
518
|
+
export function claimPromptActivation(
|
|
519
|
+
sessionId: string,
|
|
520
|
+
env: NodeJS.ProcessEnv,
|
|
521
|
+
now: number,
|
|
522
|
+
ownerPid: number,
|
|
523
|
+
): boolean {
|
|
524
|
+
let claimed = false
|
|
525
|
+
updateSessionState(sessionId, env, (current) => {
|
|
526
|
+
if (
|
|
527
|
+
current.activation_context_emitted_at !== undefined &&
|
|
528
|
+
current.activation_context_owner_pid === ownerPid
|
|
529
|
+
) {
|
|
530
|
+
return current
|
|
531
|
+
}
|
|
532
|
+
claimed = true
|
|
533
|
+
return {
|
|
534
|
+
...current,
|
|
535
|
+
activation_context_emitted_at: now,
|
|
536
|
+
activation_context_owner_pid: ownerPid,
|
|
537
|
+
}
|
|
538
|
+
})
|
|
539
|
+
return claimed
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
/** Atomically claim Cursor's first-Stop activation fallback once per conversation. */
|
|
543
|
+
export function claimCursorStopActivation(
|
|
544
|
+
sessionId: string,
|
|
545
|
+
env: NodeJS.ProcessEnv,
|
|
546
|
+
now: number,
|
|
547
|
+
): boolean {
|
|
548
|
+
const claimGuardMs = 30_000
|
|
549
|
+
let claimed = false
|
|
550
|
+
updateSessionState(sessionId, env, (current) => {
|
|
551
|
+
if (current.cursor_activation_confirmed_at !== undefined) return current
|
|
552
|
+
if (
|
|
553
|
+
current.cursor_activation_claimed_at !== undefined &&
|
|
554
|
+
now - current.cursor_activation_claimed_at < claimGuardMs
|
|
555
|
+
) {
|
|
556
|
+
return current
|
|
557
|
+
}
|
|
558
|
+
claimed = true
|
|
559
|
+
return { ...current, cursor_activation_claimed_at: now }
|
|
560
|
+
})
|
|
561
|
+
return claimed
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
/** Cursor's loop_count proves the previous Stop follow-up reached the host. */
|
|
565
|
+
export function confirmCursorStopActivation(
|
|
566
|
+
sessionId: string,
|
|
567
|
+
env: NodeJS.ProcessEnv,
|
|
568
|
+
now: number,
|
|
569
|
+
): boolean {
|
|
570
|
+
let owned = false
|
|
571
|
+
updateSessionState(sessionId, env, (current) => {
|
|
572
|
+
if (current.cursor_activation_confirmed_at !== undefined) {
|
|
573
|
+
owned = true
|
|
574
|
+
return current
|
|
575
|
+
}
|
|
576
|
+
if (current.cursor_activation_claimed_at === undefined) return current
|
|
577
|
+
owned = true
|
|
578
|
+
return { ...current, cursor_activation_confirmed_at: now }
|
|
579
|
+
})
|
|
580
|
+
return owned
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
/** A fresh/cleared Cursor lifecycle makes its next Stop the fallback owner again. */
|
|
584
|
+
export function resetCursorStopActivation(sessionId: string, env: NodeJS.ProcessEnv): void {
|
|
585
|
+
updateSessionState(sessionId, env, (current) => {
|
|
586
|
+
if (
|
|
587
|
+
current.cursor_activation_claimed_at === undefined &&
|
|
588
|
+
current.cursor_activation_confirmed_at === undefined
|
|
589
|
+
) {
|
|
590
|
+
return current
|
|
591
|
+
}
|
|
592
|
+
const next = { ...current }
|
|
593
|
+
delete next.cursor_activation_claimed_at
|
|
594
|
+
delete next.cursor_activation_confirmed_at
|
|
595
|
+
return next
|
|
596
|
+
})
|
|
597
|
+
}
|
|
598
|
+
|
|
465
599
|
/**
|
|
466
600
|
* Session state a crashed harness left behind.
|
|
467
601
|
*
|
|
@@ -951,7 +1085,6 @@ async function prepareQuestionSubmission(
|
|
|
951
1085
|
media?: MediaItemT[] | undefined
|
|
952
1086
|
project?: string | undefined
|
|
953
1087
|
source?: SourceContextT | undefined
|
|
954
|
-
event: string
|
|
955
1088
|
/** How long the server keeps accepting an answer. */
|
|
956
1089
|
windowSeconds: number
|
|
957
1090
|
/** Absolute owner deadline persisted before submit. */
|
|
@@ -970,7 +1103,6 @@ async function prepareQuestionSubmission(
|
|
|
970
1103
|
{
|
|
971
1104
|
title: options.title,
|
|
972
1105
|
body: options.body,
|
|
973
|
-
event: options.event,
|
|
974
1106
|
lifecycle: { tier: 'needs_you' },
|
|
975
1107
|
...(options.project !== undefined ? { project: options.project } : {}),
|
|
976
1108
|
...(options.media !== undefined ? { media: options.media } : {}),
|
|
@@ -1037,6 +1169,10 @@ async function waitForAnyReply(
|
|
|
1037
1169
|
let firstPoll = true
|
|
1038
1170
|
const permanentFailures = new Map<string, string>()
|
|
1039
1171
|
|
|
1172
|
+
if (timeoutSeconds <= 0 || deadline <= ctx.now()) {
|
|
1173
|
+
return { byRequest: new Map(), degraded: false, permanentFailures }
|
|
1174
|
+
}
|
|
1175
|
+
|
|
1040
1176
|
for (;;) {
|
|
1041
1177
|
const remainingMs = Math.max(0, deadline - ctx.now())
|
|
1042
1178
|
if (!firstPoll && remainingMs === 0) {
|
|
@@ -1113,7 +1249,7 @@ async function answerableDevices(ctx: HookContext): Promise<string[]> {
|
|
|
1113
1249
|
* `notifai ask` supersedes the previous question and it is a plain command with
|
|
1114
1250
|
* no hook payload, no idle probe and nothing to sleep for.
|
|
1115
1251
|
*/
|
|
1116
|
-
export type RetireDeps = Pick<HookContext, 'client' | 'config'>
|
|
1252
|
+
export type RetireDeps = Pick<HookContext, 'client' | 'config'> & Pick<Partial<HookContext>, 'log'>
|
|
1117
1253
|
|
|
1118
1254
|
/**
|
|
1119
1255
|
* Close is a server-side serialization fence: a successful response contains
|
|
@@ -1138,60 +1274,22 @@ async function closeQuietly(ctx: RetireDeps, requestId: string): Promise<void> {
|
|
|
1138
1274
|
}
|
|
1139
1275
|
|
|
1140
1276
|
/**
|
|
1141
|
-
*
|
|
1142
|
-
*
|
|
1143
|
-
*
|
|
1144
|
-
* delivered question and marks it done. If the app cannot run, the stale
|
|
1145
|
-
* question simply waits until next open, which beats a tombstone banner
|
|
1146
|
-
* announcing what the user just did.
|
|
1277
|
+
* Close the reply window so the server can mint the retirement push. Client
|
|
1278
|
+
* submissions cannot carry `question_retired`; that event is reserved for
|
|
1279
|
+
* server-origin state syncs.
|
|
1147
1280
|
*/
|
|
1148
|
-
async function
|
|
1281
|
+
async function proveRetirement(
|
|
1149
1282
|
ctx: RetireDeps,
|
|
1150
|
-
|
|
1151
|
-
title: string,
|
|
1152
|
-
body: string,
|
|
1153
|
-
endState: LifecycleEndState,
|
|
1154
|
-
retiresRequestId: string,
|
|
1155
|
-
devices?: string[],
|
|
1156
|
-
project?: string | undefined,
|
|
1157
|
-
source?: SourceContextT | undefined,
|
|
1283
|
+
requestId: string,
|
|
1158
1284
|
): Promise<boolean> {
|
|
1159
|
-
const
|
|
1160
|
-
|
|
1161
|
-
|
|
1162
|
-
|
|
1163
|
-
|
|
1164
|
-
|
|
1165
|
-
|
|
1166
|
-
|
|
1167
|
-
...(devices !== undefined && devices.length > 0 ? { device: devices } : {}),
|
|
1168
|
-
collapseKey,
|
|
1169
|
-
level: 'passive',
|
|
1170
|
-
},
|
|
1171
|
-
source === undefined ? {} : { source },
|
|
1172
|
-
)
|
|
1173
|
-
if (!build.ok) return false
|
|
1174
|
-
try {
|
|
1175
|
-
await ctx.client.submit(
|
|
1176
|
-
{ idempotency_key: `retire-${randomBytes(12).toString('base64url')}`, draft: build.draft },
|
|
1177
|
-
0,
|
|
1178
|
-
)
|
|
1179
|
-
return true
|
|
1180
|
-
} catch {
|
|
1181
|
-
// Same reasoning as closeQuietly: the window is already closed server-side.
|
|
1182
|
-
return false
|
|
1183
|
-
}
|
|
1184
|
-
}
|
|
1185
|
-
|
|
1186
|
-
/**
|
|
1187
|
-
* The title a retirement carries. It is never rendered as a banner — the push
|
|
1188
|
-
* is `content-available` only — but it is what a companion with no matching
|
|
1189
|
-
* history entry has to fall back on, and it is what shows in server-side logs.
|
|
1190
|
-
*/
|
|
1191
|
-
const RETIREMENT_TITLES: Record<LifecycleEndState, string> = {
|
|
1192
|
-
answered: 'Answered',
|
|
1193
|
-
answered_elsewhere: 'Answered in the terminal',
|
|
1194
|
-
expired: 'Question expired',
|
|
1285
|
+
const response = await finalizeReplies(ctx, requestId)
|
|
1286
|
+
const proven = response !== null
|
|
1287
|
+
ctx.log?.info('hook.retirement', {
|
|
1288
|
+
request_id: requestId,
|
|
1289
|
+
attempted: true,
|
|
1290
|
+
proven,
|
|
1291
|
+
})
|
|
1292
|
+
return proven
|
|
1195
1293
|
}
|
|
1196
1294
|
|
|
1197
1295
|
/**
|
|
@@ -1257,13 +1355,42 @@ function retiringQuestion(
|
|
|
1257
1355
|
}
|
|
1258
1356
|
}
|
|
1259
1357
|
|
|
1260
|
-
|
|
1261
|
-
|
|
1262
|
-
|
|
1263
|
-
|
|
1264
|
-
|
|
1265
|
-
|
|
1266
|
-
|
|
1358
|
+
const SHORT_CONFIRMATIONS = new Set(['done', 'yes', 'y', 'no', 'n', 'ok', 'okay'])
|
|
1359
|
+
|
|
1360
|
+
function normalizePrompt(value: string): string {
|
|
1361
|
+
return value.trim().replace(/\s+/g, ' ').toLowerCase()
|
|
1362
|
+
}
|
|
1363
|
+
|
|
1364
|
+
function pendingChoiceLabels(pending: PendingQuestion): string[] {
|
|
1365
|
+
const fromQuestions = (pending.questions ?? []).flatMap((question) =>
|
|
1366
|
+
(question.choices ?? []).flatMap((choice) => [choice.label, choice.id]),
|
|
1367
|
+
)
|
|
1368
|
+
return fromQuestions.map(normalizePrompt).filter((label) => label.length > 0)
|
|
1369
|
+
}
|
|
1370
|
+
|
|
1371
|
+
function pendingHasMatchingChoice(pending: PendingQuestion, normalizedPrompt: string): boolean {
|
|
1372
|
+
return pendingChoiceLabels(pending).includes(normalizedPrompt)
|
|
1373
|
+
}
|
|
1374
|
+
|
|
1375
|
+
/**
|
|
1376
|
+
* Which outstanding questions this prompt plausibly answers.
|
|
1377
|
+
*
|
|
1378
|
+
* A unique closed-choice match is enough. A short confirmation such as "done"
|
|
1379
|
+
* matches only when exactly one question is outstanding. Long unrelated
|
|
1380
|
+
* prompts leave every question open for the reply window.
|
|
1381
|
+
*/
|
|
1382
|
+
export function pendingAnsweredByPrompt(
|
|
1383
|
+
prompt: string | undefined,
|
|
1384
|
+
pending: readonly PendingQuestion[],
|
|
1385
|
+
): PendingQuestion[] {
|
|
1386
|
+
const normalized = normalizePrompt(prompt ?? '')
|
|
1387
|
+
if (normalized.length === 0 || pending.length === 0) return []
|
|
1388
|
+
|
|
1389
|
+
const labelled = pending.filter((entry) => pendingHasMatchingChoice(entry, normalized))
|
|
1390
|
+
if (labelled.length === 1) return labelled
|
|
1391
|
+
if (labelled.length > 1) return []
|
|
1392
|
+
if (pending.length === 1 && SHORT_CONFIRMATIONS.has(normalized)) return [...pending]
|
|
1393
|
+
return []
|
|
1267
1394
|
}
|
|
1268
1395
|
|
|
1269
1396
|
/**
|
|
@@ -1281,19 +1408,8 @@ export async function drainRetirements(
|
|
|
1281
1408
|
|
|
1282
1409
|
const retired: string[] = []
|
|
1283
1410
|
for (const entry of queue) {
|
|
1284
|
-
await
|
|
1285
|
-
|
|
1286
|
-
ctx,
|
|
1287
|
-
entry.collapse_key,
|
|
1288
|
-
RETIREMENT_TITLES[entry.state],
|
|
1289
|
-
entry.question,
|
|
1290
|
-
entry.state,
|
|
1291
|
-
entry.request_id,
|
|
1292
|
-
retirementDeviceIds(entry),
|
|
1293
|
-
entry.project,
|
|
1294
|
-
entry.source,
|
|
1295
|
-
)
|
|
1296
|
-
if (sent) {
|
|
1411
|
+
const proven = await proveRetirement(ctx, entry.request_id)
|
|
1412
|
+
if (proven) {
|
|
1297
1413
|
retired.push(entry.request_id)
|
|
1298
1414
|
// Persist each success before touching the next entry. A later corrupt
|
|
1299
1415
|
// record or interrupted process must not resurrect work already done.
|
|
@@ -1380,19 +1496,8 @@ export async function drainOrphanRetirements(
|
|
|
1380
1496
|
)
|
|
1381
1497
|
continue
|
|
1382
1498
|
}
|
|
1383
|
-
await
|
|
1384
|
-
|
|
1385
|
-
ctx,
|
|
1386
|
-
entry.collapse_key,
|
|
1387
|
-
RETIREMENT_TITLES[entry.state],
|
|
1388
|
-
entry.question,
|
|
1389
|
-
entry.state,
|
|
1390
|
-
entry.request_id,
|
|
1391
|
-
retirementDeviceIds(entry),
|
|
1392
|
-
entry.project,
|
|
1393
|
-
entry.source,
|
|
1394
|
-
)
|
|
1395
|
-
if (sent) {
|
|
1499
|
+
const proven = await proveRetirement(ctx, entry.request_id)
|
|
1500
|
+
if (proven) {
|
|
1396
1501
|
done.push(entry.request_id)
|
|
1397
1502
|
updateOrphanQueue(env, (current) =>
|
|
1398
1503
|
current.filter((candidate) => candidate.request_id !== entry.request_id),
|
|
@@ -1484,10 +1589,15 @@ async function finalizePendings(
|
|
|
1484
1589
|
pending: PendingQuestion[],
|
|
1485
1590
|
): Promise<FinalizedPending[]> {
|
|
1486
1591
|
return Promise.all(
|
|
1487
|
-
pending.map(async (entry) =>
|
|
1488
|
-
|
|
1489
|
-
|
|
1490
|
-
|
|
1592
|
+
pending.map(async (entry) => {
|
|
1593
|
+
const response = await finalizeReplies(ctx, entry.request_id!)
|
|
1594
|
+
ctx.log?.info('hook.retirement', {
|
|
1595
|
+
request_id: entry.request_id,
|
|
1596
|
+
attempted: true,
|
|
1597
|
+
proven: response !== null,
|
|
1598
|
+
})
|
|
1599
|
+
return { pending: entry, response }
|
|
1600
|
+
}),
|
|
1491
1601
|
)
|
|
1492
1602
|
}
|
|
1493
1603
|
|
|
@@ -1521,7 +1631,7 @@ function reportAnswer(
|
|
|
1521
1631
|
...(late ? { late: true } : {}),
|
|
1522
1632
|
request_id: answered.pending.request_id,
|
|
1523
1633
|
device: answered.reply.device_name,
|
|
1524
|
-
|
|
1634
|
+
text_chars: answered.reply.text.length,
|
|
1525
1635
|
})
|
|
1526
1636
|
notes.push(`${late ? 'late ' : ''}answer from ${answered.reply.device_name}: ${answered.reply.text}`)
|
|
1527
1637
|
}
|
|
@@ -1579,7 +1689,7 @@ function isTerminalDraftRejection(err: ApiCallError): boolean {
|
|
|
1579
1689
|
return err.status === 422 || (err.status === 400 && err.code === 'invalid_request')
|
|
1580
1690
|
}
|
|
1581
1691
|
|
|
1582
|
-
function dropPendingQuestion(
|
|
1692
|
+
export function dropPendingQuestion(
|
|
1583
1693
|
sessionId: string,
|
|
1584
1694
|
env: NodeJS.ProcessEnv,
|
|
1585
1695
|
entry: PendingQuestion,
|
|
@@ -1593,6 +1703,29 @@ function dropPendingQuestion(
|
|
|
1593
1703
|
})
|
|
1594
1704
|
}
|
|
1595
1705
|
|
|
1706
|
+
/**
|
|
1707
|
+
* Drop registrations that never reached a device, so a later Stop cannot push
|
|
1708
|
+
* them. A retirement push would be noise: there is nothing on any device to
|
|
1709
|
+
* withdraw. Returns the withdrawn entries in registration order.
|
|
1710
|
+
*/
|
|
1711
|
+
export function withdrawUnpushedQuestions(
|
|
1712
|
+
sessionId: string,
|
|
1713
|
+
env: NodeJS.ProcessEnv,
|
|
1714
|
+
): PendingQuestion[] {
|
|
1715
|
+
let withdrawn: PendingQuestion[] = []
|
|
1716
|
+
updateSessionState(sessionId, env, (current) => {
|
|
1717
|
+
const pending = pendingList(current)
|
|
1718
|
+
withdrawn = pending.filter((entry) => entry.request_id === undefined)
|
|
1719
|
+
if (withdrawn.length === 0) return current
|
|
1720
|
+
const remaining = pending.filter((entry) => entry.request_id !== undefined)
|
|
1721
|
+
const next: SessionState = { ...current }
|
|
1722
|
+
if (remaining.length > 0) next.pending = remaining
|
|
1723
|
+
else delete next.pending
|
|
1724
|
+
return next
|
|
1725
|
+
})
|
|
1726
|
+
return withdrawn
|
|
1727
|
+
}
|
|
1728
|
+
|
|
1596
1729
|
function clearFrozenSubmission(
|
|
1597
1730
|
sessionId: string,
|
|
1598
1731
|
env: NodeJS.ProcessEnv,
|
|
@@ -1992,10 +2125,9 @@ async function retirePendings(
|
|
|
1992
2125
|
// ---------------------------------------------------------------------------
|
|
1993
2126
|
|
|
1994
2127
|
/**
|
|
1995
|
-
* Records presence
|
|
1996
|
-
*
|
|
1997
|
-
*
|
|
1998
|
-
* user being here is what makes the notification noise.
|
|
2128
|
+
* Records presence. Retires a live question only when this prompt plausibly
|
|
2129
|
+
* answers it. Typing in the terminal is not by itself a resolution: the User
|
|
2130
|
+
* may be answering a different question on a device.
|
|
1999
2131
|
*/
|
|
2000
2132
|
export async function handleUserPromptSubmit(
|
|
2001
2133
|
ctx: HookContext,
|
|
@@ -2070,13 +2202,26 @@ export async function handleUserPromptSubmit(
|
|
|
2070
2202
|
const permanentFailure = permanentReplyFailureNote(permanentFailures)
|
|
2071
2203
|
if (permanentFailure !== null) notes.push(permanentFailure)
|
|
2072
2204
|
}
|
|
2205
|
+
const matched = pendingAnsweredByPrompt(envelope.prompt, pendingList(state))
|
|
2206
|
+
for (const entry of matched) {
|
|
2207
|
+
ctx.log?.info('hook.retirement', {
|
|
2208
|
+
request_id: entry.request_id,
|
|
2209
|
+
question_id: entry.question_id,
|
|
2210
|
+
reason: 'prompt-matched',
|
|
2211
|
+
attempted: entry.request_id !== undefined,
|
|
2212
|
+
proven: false,
|
|
2213
|
+
})
|
|
2214
|
+
}
|
|
2073
2215
|
// Park before dropping `pending`. If the process dies between these writes,
|
|
2074
2216
|
// the next hook sees both copies and dedupes them; the old order could die in
|
|
2075
2217
|
// the gap after erasing the only request/collapse/device identifiers.
|
|
2076
2218
|
updateSessionState(sessionId, ctx.env, (current) => {
|
|
2077
2219
|
const retiring = [...(current.retiring ?? [])]
|
|
2078
|
-
const
|
|
2079
|
-
|
|
2220
|
+
const matchedNow = pendingAnsweredByPrompt(envelope.prompt, pendingList(current))
|
|
2221
|
+
const unmatched = pendingList(current).filter(
|
|
2222
|
+
(entry) => !matchedNow.some((item) => isSamePending(item, entry)),
|
|
2223
|
+
)
|
|
2224
|
+
for (const entry of matchedNow.filter((item) => item.request_id !== undefined)) {
|
|
2080
2225
|
const retirement = retiringQuestion(entry, 'answered_elsewhere', envelope.cwd)
|
|
2081
2226
|
if (
|
|
2082
2227
|
retirement !== null &&
|
|
@@ -2085,11 +2230,17 @@ export async function handleUserPromptSubmit(
|
|
|
2085
2230
|
retiring.push(retirement)
|
|
2086
2231
|
}
|
|
2087
2232
|
}
|
|
2233
|
+
const unasked = unmatched.filter((entry) => entry.request_id === undefined)
|
|
2234
|
+
const stillLive = unmatched.filter((entry) => entry.request_id !== undefined)
|
|
2235
|
+
const remaining = [...stillLive, ...unasked]
|
|
2088
2236
|
|
|
2089
2237
|
// Start from the whole document so fields introduced by a newer CLI remain
|
|
2090
2238
|
// attached while this older writer applies the prompt transition it knows.
|
|
2091
|
-
const next: SessionState = {
|
|
2092
|
-
|
|
2239
|
+
const next: SessionState = {
|
|
2240
|
+
...current,
|
|
2241
|
+
last_prompt_at: ctx.now(),
|
|
2242
|
+
}
|
|
2243
|
+
if (remaining.length > 0) next.pending = remaining
|
|
2093
2244
|
else delete next.pending
|
|
2094
2245
|
if (retiring.length > 0) next.retiring = retiring
|
|
2095
2246
|
else delete next.retiring
|
|
@@ -2428,6 +2579,11 @@ async function deliverAcceptedAnswers(
|
|
|
2428
2579
|
return outcome
|
|
2429
2580
|
}
|
|
2430
2581
|
|
|
2582
|
+
/** True while this Stop owner is still allowed a direct-wake wait. */
|
|
2583
|
+
function ownerLeaseActive(entry: PendingQuestion, now: number): boolean {
|
|
2584
|
+
return entry.owner_deadline_at !== undefined && entry.owner_deadline_at > now
|
|
2585
|
+
}
|
|
2586
|
+
|
|
2431
2587
|
/** The complete per-session Stop decision, after this process owns the claim. */
|
|
2432
2588
|
async function handleClaimedStop(
|
|
2433
2589
|
ctx: HookContext,
|
|
@@ -2515,26 +2671,44 @@ async function handleClaimedStop(
|
|
|
2515
2671
|
const recoverableLive = live.filter(
|
|
2516
2672
|
(entry) => !permanentFailures.has(entry.request_id!),
|
|
2517
2673
|
)
|
|
2518
|
-
|
|
2674
|
+
// A spent owner has already used its waiter ceiling. Passing those
|
|
2675
|
+
// questions back into `escalate` starts a fresh ceiling, holds the
|
|
2676
|
+
// per-session claim for another 3300s, and is why a later `ask` never
|
|
2677
|
+
// reached a device after a degraded waiter (the next Stop never ran, or
|
|
2678
|
+
// ran as claimed-elsewhere). Late answers still ride the short poll above
|
|
2679
|
+
// and every later turn. Independent questions whose lease is still running
|
|
2680
|
+
// keep their successor wait.
|
|
2681
|
+
const stillOwned = recoverableLive.filter((entry) => ownerLeaseActive(entry, ctx.now()))
|
|
2682
|
+
liveToEscalate = stillOwned
|
|
2519
2683
|
if (unasked.length === 0) {
|
|
2520
2684
|
if (recoverableLive.length === 0) return { notes }
|
|
2521
|
-
|
|
2522
|
-
|
|
2523
|
-
|
|
2524
|
-
|
|
2685
|
+
if (stillOwned.length === 0) {
|
|
2686
|
+
const requestIdSummary = summarizeRequestIds(recoverableLive)
|
|
2687
|
+
ctx.log?.info('hook.answer', {
|
|
2688
|
+
answered: false,
|
|
2689
|
+
stage: 'queued',
|
|
2690
|
+
request_ids: requestIdSummary.ids,
|
|
2691
|
+
owner_spent: true,
|
|
2692
|
+
})
|
|
2693
|
+
notes.push(
|
|
2694
|
+
`${recoverableLive.length} question${recoverableLive.length === 1 ? ' is' : 's are'} still answerable; the previous waiter already used its ceiling, so a later turn will collect a late answer rather than waiting again`,
|
|
2695
|
+
)
|
|
2696
|
+
return { notes }
|
|
2697
|
+
}
|
|
2525
2698
|
return await escalate(
|
|
2526
2699
|
ctx,
|
|
2527
2700
|
envelope,
|
|
2528
2701
|
sessionId,
|
|
2529
2702
|
[],
|
|
2530
|
-
|
|
2703
|
+
stillOwned,
|
|
2531
2704
|
notes,
|
|
2532
2705
|
hardDeadlineAt,
|
|
2533
2706
|
route,
|
|
2534
2707
|
)
|
|
2535
2708
|
}
|
|
2536
2709
|
// Questions registered after the earlier push still owe the user their
|
|
2537
|
-
// notification; fall through and escalate just those
|
|
2710
|
+
// notification; fall through and escalate just those, plus any live
|
|
2711
|
+
// question whose owner lease is still running.
|
|
2538
2712
|
}
|
|
2539
2713
|
|
|
2540
2714
|
// A Stop answer may immediately produce a legitimate follow-up question.
|
|
@@ -2672,7 +2846,6 @@ async function escalate(
|
|
|
2672
2846
|
...(entry.media !== undefined ? { media: entry.media } : {}),
|
|
2673
2847
|
...(entry.project !== undefined ? { project: entry.project } : {}),
|
|
2674
2848
|
...(eventSource !== undefined ? { source: eventSource } : {}),
|
|
2675
|
-
event: 'agent_question',
|
|
2676
2849
|
windowSeconds: replyWindowSeconds,
|
|
2677
2850
|
ownerDeadlineAt,
|
|
2678
2851
|
})
|
|
@@ -2751,7 +2924,6 @@ async function escalate(
|
|
|
2751
2924
|
...(entry.media !== undefined ? { media: entry.media } : {}),
|
|
2752
2925
|
...(entry.project !== undefined ? { project: entry.project } : {}),
|
|
2753
2926
|
...(eventSource !== undefined ? { source: eventSource } : {}),
|
|
2754
|
-
event: 'agent_question',
|
|
2755
2927
|
windowSeconds: replyWindowSeconds,
|
|
2756
2928
|
ownerDeadlineAt,
|
|
2757
2929
|
})
|
|
@@ -2821,7 +2993,7 @@ async function escalate(
|
|
|
2821
2993
|
request_id: intent.request_id,
|
|
2822
2994
|
devices: intent.device_ids.length,
|
|
2823
2995
|
questions: questions.length,
|
|
2824
|
-
|
|
2996
|
+
text_chars: questions[0]!.text.length,
|
|
2825
2997
|
})
|
|
2826
2998
|
}
|
|
2827
2999
|
const committedReplyDeadline =
|
|
@@ -2841,14 +3013,14 @@ async function escalate(
|
|
|
2841
3013
|
// stale about eight minutes later and retired, so the answer given over
|
|
2842
3014
|
// lunch met a closed question.
|
|
2843
3015
|
//
|
|
2844
|
-
//
|
|
2845
|
-
//
|
|
2846
|
-
//
|
|
2847
|
-
//
|
|
2848
|
-
// this process waits.
|
|
3016
|
+
// The reply window is how long the server accepts an answer. This
|
|
3017
|
+
// process still clamps its own wait to `hardDeadlineAt`. Recording the
|
|
3018
|
+
// owner lease separately is what stops the *next* Stop from treating
|
|
3019
|
+
// the remaining window as a fresh waiter ceiling.
|
|
2849
3020
|
reply_deadline_at: Number.isFinite(committedReplyDeadline)
|
|
2850
3021
|
? committedReplyDeadline
|
|
2851
3022
|
: intent.owner_deadline_at,
|
|
3023
|
+
owner_deadline_at: intent.owner_deadline_at,
|
|
2852
3024
|
}
|
|
2853
3025
|
if (intent.draft.source === undefined) delete live.source
|
|
2854
3026
|
delete live.submission
|
|
@@ -2927,7 +3099,8 @@ async function escalate(
|
|
|
2927
3099
|
ceilingAt,
|
|
2928
3100
|
Math.max(...waitingOn.map((entry) => entry.reply_deadline_at!)),
|
|
2929
3101
|
)
|
|
2930
|
-
const
|
|
3102
|
+
const waitUntil = ownerDeadline - FINALIZATION_NETWORK_BUDGET_SECONDS * 1000
|
|
3103
|
+
const timeoutSeconds = Math.max(0, Math.ceil((waitUntil - ctx.now()) / 1000))
|
|
2931
3104
|
const waited = await waitForAnyReply(
|
|
2932
3105
|
ctx,
|
|
2933
3106
|
waitingOn.map((entry) => entry.request_id!),
|
|
@@ -2955,7 +3128,7 @@ async function escalate(
|
|
|
2955
3128
|
const expired = waitingOn.filter(
|
|
2956
3129
|
(entry) =>
|
|
2957
3130
|
entry.reply_deadline_at === undefined ||
|
|
2958
|
-
entry.reply_deadline_at <=
|
|
3131
|
+
entry.reply_deadline_at <= ownerDeadline ||
|
|
2959
3132
|
// A permanent rejection is the server saying this question can never be
|
|
2960
3133
|
// answered, whatever its window says. Leaving it open would strand a
|
|
2961
3134
|
// dead question on the user's devices.
|
|
@@ -3001,10 +3174,10 @@ async function escalate(
|
|
|
3001
3174
|
permanent_failures: waited.permanentFailures.size,
|
|
3002
3175
|
waited_seconds: timeoutSeconds,
|
|
3003
3176
|
})
|
|
3004
|
-
if (waited.permanentFailures.size === 0) {
|
|
3177
|
+
if (waited.permanentFailures.size === 0 && confirmedSilent.length > 0) {
|
|
3005
3178
|
notes.push(
|
|
3006
3179
|
waited.degraded
|
|
3007
|
-
? 'could not reach the server before the owner deadline;
|
|
3180
|
+
? 'could not reach the server before the owner deadline; expired questions were retired so no answer can be lost later'
|
|
3008
3181
|
: 'no answer in time; the question expired with its continuation owner',
|
|
3009
3182
|
)
|
|
3010
3183
|
}
|
|
@@ -3159,8 +3332,9 @@ export function registerQuestion(
|
|
|
3159
3332
|
env: NodeJS.ProcessEnv,
|
|
3160
3333
|
question: PendingQuestion,
|
|
3161
3334
|
now: number = Date.now(),
|
|
3162
|
-
):
|
|
3335
|
+
): string {
|
|
3163
3336
|
let full: 'unasked' | 'live' | null = null
|
|
3337
|
+
const questionId = question.question_id ?? `q_${randomBytes(12).toString('base64url')}`
|
|
3164
3338
|
updateSessionState(sessionId, env, (state) => {
|
|
3165
3339
|
const pending = pendingList(state)
|
|
3166
3340
|
const unasked = pending.filter((entry) => entry.request_id === undefined)
|
|
@@ -3177,9 +3351,9 @@ export function registerQuestion(
|
|
|
3177
3351
|
pending: [
|
|
3178
3352
|
...pending,
|
|
3179
3353
|
{
|
|
3180
|
-
question_id: `q_${randomBytes(12).toString('base64url')}`,
|
|
3181
3354
|
asked_at: now,
|
|
3182
3355
|
...question,
|
|
3356
|
+
question_id: questionId,
|
|
3183
3357
|
question: question.question.slice(0, MAX_STORED_QUESTION_CHARS),
|
|
3184
3358
|
},
|
|
3185
3359
|
],
|
|
@@ -3194,9 +3368,10 @@ export function registerQuestion(
|
|
|
3194
3368
|
if (full === 'live') {
|
|
3195
3369
|
throw new Error(
|
|
3196
3370
|
`${MAX_LIVE_QUESTIONS} questions from this session are already open. ` +
|
|
3197
|
-
'Retire the ones you no longer need with `notifai close <
|
|
3371
|
+
'Retire the ones you no longer need with `notifai close <question_id>` or `notifai close --pending` before asking another.',
|
|
3198
3372
|
)
|
|
3199
3373
|
}
|
|
3374
|
+
return questionId
|
|
3200
3375
|
}
|
|
3201
3376
|
|
|
3202
3377
|
/** Parses hook JSON from stdin, tolerating an empty or malformed body. */
|