@raidiant/notifai 8.0.0 → 9.1.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 +48 -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/command-session.d.ts +14 -0
- package/dist/command-session.js +30 -0
- package/dist/command-session.js.map +1 -0
- 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 +87 -29
- package/dist/commands-doctor.js.map +1 -1
- package/dist/commands-guidance.d.ts +7 -3
- package/dist/commands-guidance.js +40 -31
- package/dist/commands-guidance.js.map +1 -1
- package/dist/commands-harness-context.js +21 -1
- package/dist/commands-harness-context.js.map +1 -1
- package/dist/commands-hooks.d.ts +20 -5
- package/dist/commands-hooks.js +509 -155
- package/dist/commands-hooks.js.map +1 -1
- package/dist/commands-init.d.ts +12 -18
- package/dist/commands-init.js +149 -47
- 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.d.ts +1 -0
- package/dist/commands-send.js +114 -17
- package/dist/commands-send.js.map +1 -1
- package/dist/commands-setup-proof.d.ts +12 -3
- package/dist/commands-setup-proof.js +60 -16
- 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 +2 -2
- package/dist/commands.js +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-render.d.ts +20 -0
- package/dist/guidance-render.js +42 -0
- package/dist/guidance-render.js.map +1 -0
- 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 +83 -7
- package/dist/hooks.js +288 -90
- 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 +25 -1
- package/dist/opencode-plugin.js.map +1 -1
- package/dist/program.js +34 -20
- package/dist/program.js.map +1 -1
- package/dist/readiness.d.ts +6 -0
- package/dist/readiness.js +37 -0
- package/dist/readiness.js.map +1 -1
- package/dist/send-attempts.d.ts +26 -0
- package/dist/send-attempts.js +139 -0
- package/dist/send-attempts.js.map +1 -0
- 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 +11 -0
- package/dist/session-activation.js +46 -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/command-session.ts +38 -0
- 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 +117 -41
- package/src/commands-guidance.ts +43 -32
- package/src/commands-harness-context.ts +24 -1
- package/src/commands-hooks.ts +704 -188
- package/src/commands-init.ts +180 -57
- package/src/commands-send-support.test.ts +156 -0
- package/src/commands-send-support.ts +67 -5
- package/src/commands-send.ts +135 -16
- package/src/commands-setup-proof.test.ts +72 -0
- package/src/commands-setup-proof.ts +69 -16
- package/src/commands-skill.ts +78 -15
- package/src/commands.test.ts +1281 -157
- package/src/commands.ts +2 -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-render.ts +56 -0
- package/src/guidance.test.ts +70 -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 +778 -166
- package/src/hooks.ts +368 -129
- 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 +25 -1
- package/src/program.test.ts +37 -2
- package/src/program.ts +62 -24
- package/src/readiness.ts +44 -0
- package/src/send-attempts.test.ts +143 -0
- package/src/send-attempts.ts +188 -0
- package/src/send.test.ts +28 -0
- package/src/send.ts +28 -2
- package/src/session-activation.ts +62 -0
- package/src/skill.test.ts +125 -11
- 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,24 @@ 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
|
+
/** Harness that owns this exact lifecycle state. */
|
|
90
|
+
harness?: HookHarness
|
|
91
|
+
/** Checkout whose hook definition activated this session; lifecycle diagnostics only. */
|
|
92
|
+
activation_cwd?: string
|
|
93
|
+
/** Model-visible proactive context already emitted for this harness session. */
|
|
94
|
+
activation_context_emitted_at?: number
|
|
95
|
+
/** Harness process that received it; a resumed session has a new owner. */
|
|
96
|
+
activation_context_owner_pid?: number
|
|
97
|
+
/** Cursor's documented session context path is lossy; bounded first-Stop fallback journal. */
|
|
98
|
+
cursor_activation_claimed_at?: number
|
|
99
|
+
cursor_activation_confirmed_at?: number
|
|
83
100
|
/** Epoch ms of the user's last prompt in this session — our presence signal. */
|
|
84
101
|
last_prompt_at?: number
|
|
85
102
|
/** Epoch ms of the last observed Stop hook, distinct from prompt routing. */
|
|
@@ -239,6 +256,15 @@ export interface PendingQuestion {
|
|
|
239
256
|
device_ids?: string[]
|
|
240
257
|
/** Absolute end of the server reply window and its local continuation owner. */
|
|
241
258
|
reply_deadline_at?: number
|
|
259
|
+
/**
|
|
260
|
+
* When this Stop owner stops listening for a direct wake. Distinct from
|
|
261
|
+
* `reply_deadline_at`: the server may still accept an answer for a day after
|
|
262
|
+
* this owner has returned. A later Stop that treats the reply window as a
|
|
263
|
+
* fresh waiter ceiling monopolizes the claim and skips newly registered
|
|
264
|
+
* questions. Absent on state written before this field existed — treated as
|
|
265
|
+
* already spent, because that is the shape a degraded waiter left behind.
|
|
266
|
+
*/
|
|
267
|
+
owner_deadline_at?: number
|
|
242
268
|
/** Frozen before the first network byte, so an ambiguous submit is replayable. */
|
|
243
269
|
submission?: PendingSubmissionIntent
|
|
244
270
|
}
|
|
@@ -319,6 +345,14 @@ export function waiterCeilingSeconds(detached: boolean): number {
|
|
|
319
345
|
*/
|
|
320
346
|
export const MIN_REPLY_WINDOW_SECONDS = 60
|
|
321
347
|
|
|
348
|
+
/**
|
|
349
|
+
* HTTP budget reserved for the waiter's final reply fetch and close fence.
|
|
350
|
+
* Taken from the owner deadline rather than added after it, so the hook does
|
|
351
|
+
* not block past its ceiling. A near-zero remainder used to produce a
|
|
352
|
+
* misleading 0s timeout and an unproven retirement.
|
|
353
|
+
*/
|
|
354
|
+
export const FINALIZATION_NETWORK_BUDGET_SECONDS = 3
|
|
355
|
+
|
|
322
356
|
/**
|
|
323
357
|
* The terminal-first wait: the question sits in the terminal for
|
|
324
358
|
* `ask_grace_seconds` from when it was sent, and only then reaches companion
|
|
@@ -348,8 +382,10 @@ async function awaitTerminalFirstWindow(
|
|
|
348
382
|
}
|
|
349
383
|
|
|
350
384
|
export interface HookOutcome {
|
|
351
|
-
/** Written to stdout verbatim — the harness parses this as
|
|
385
|
+
/** Written to stdout verbatim — the harness parses this as output. */
|
|
352
386
|
stdout?: string
|
|
387
|
+
/** Whether stdout takes over the turn, rather than adding prompt context. */
|
|
388
|
+
decided?: boolean
|
|
353
389
|
/** Diagnostics; harnesses surface hook stderr in the transcript. */
|
|
354
390
|
notes: string[]
|
|
355
391
|
/** Structured lifecycle detail that belongs on hook.end without user text. */
|
|
@@ -418,7 +454,10 @@ export function readSessionState(sessionId: string, env: NodeJS.ProcessEnv): Ses
|
|
|
418
454
|
if (!existsSync(file)) return {}
|
|
419
455
|
try {
|
|
420
456
|
const parsed: unknown = JSON.parse(readFileSync(file, 'utf8'))
|
|
421
|
-
|
|
457
|
+
if (typeof parsed !== 'object' || parsed === null) return {}
|
|
458
|
+
const state = { ...(parsed as SessionState & { session_id?: unknown }) }
|
|
459
|
+
delete state.session_id
|
|
460
|
+
return state
|
|
422
461
|
} catch {
|
|
423
462
|
// A corrupt marker must not wedge the harness; treat it as "no evidence",
|
|
424
463
|
// which fails closed onto ordinary terminal behaviour.
|
|
@@ -426,13 +465,59 @@ export function readSessionState(sessionId: string, env: NodeJS.ProcessEnv): Ses
|
|
|
426
465
|
}
|
|
427
466
|
}
|
|
428
467
|
|
|
468
|
+
/**
|
|
469
|
+
* Resolve a stable question or request id to the one exact session that owns it.
|
|
470
|
+
*
|
|
471
|
+
* This deliberately searches lifecycle state, not checkout pointers: question
|
|
472
|
+
* identity is global on this machine, while a linked worktree is only where a
|
|
473
|
+
* command happened to run. Ambiguous or corrupt matches fail closed.
|
|
474
|
+
*/
|
|
475
|
+
export function findOwningSession(
|
|
476
|
+
id: string,
|
|
477
|
+
env: NodeJS.ProcessEnv,
|
|
478
|
+
): { sessionId: string | null; ambiguous: boolean } {
|
|
479
|
+
const directory = path.join(stateDir(env), 'sessions')
|
|
480
|
+
if (!existsSync(directory)) return { sessionId: null, ambiguous: false }
|
|
481
|
+
const matches: string[] = []
|
|
482
|
+
for (const name of readdirSync(directory)) {
|
|
483
|
+
if (!name.endsWith('.json')) continue
|
|
484
|
+
let persisted: (SessionState & { session_id?: unknown }) | null = null
|
|
485
|
+
try {
|
|
486
|
+
const parsed: unknown = JSON.parse(readFileSync(path.join(directory, name), 'utf8'))
|
|
487
|
+
if (typeof parsed === 'object' && parsed !== null) {
|
|
488
|
+
persisted = parsed as SessionState & { session_id?: unknown }
|
|
489
|
+
}
|
|
490
|
+
} catch {
|
|
491
|
+
// Corrupt lifecycle state is not identity evidence.
|
|
492
|
+
}
|
|
493
|
+
if (persisted === null || typeof persisted.session_id !== 'string') continue
|
|
494
|
+
const sessionId = persisted.session_id
|
|
495
|
+
const state = persisted
|
|
496
|
+
const pendingMatch = pendingList(state).some(
|
|
497
|
+
(entry) => entry.question_id === id || entry.request_id === id,
|
|
498
|
+
)
|
|
499
|
+
const retiringMatch = (state.retiring ?? []).some((entry) => entry.request_id === id)
|
|
500
|
+
const acknowledgementMatch = (state.acknowledgement_due ?? []).some(
|
|
501
|
+
(entry) => entry.request_id === id,
|
|
502
|
+
)
|
|
503
|
+
const acceptedMatch = state.accepted?.answers.some(
|
|
504
|
+
({ pending }) => pending.question_id === id || pending.request_id === id,
|
|
505
|
+
) ?? false
|
|
506
|
+
if (pendingMatch || retiringMatch || acknowledgementMatch || acceptedMatch) {
|
|
507
|
+
matches.push(sessionId)
|
|
508
|
+
if (matches.length > 1) return { sessionId: null, ambiguous: true }
|
|
509
|
+
}
|
|
510
|
+
}
|
|
511
|
+
return { sessionId: matches[0] ?? null, ambiguous: false }
|
|
512
|
+
}
|
|
513
|
+
|
|
429
514
|
export function writeSessionState(
|
|
430
515
|
sessionId: string,
|
|
431
516
|
env: NodeJS.ProcessEnv,
|
|
432
517
|
state: SessionState,
|
|
433
518
|
): void {
|
|
434
519
|
const file = sessionStatePath(sessionId, env)
|
|
435
|
-
withFileLock(`${file}.lock`, () => writeSessionStateUnlocked(file, state))
|
|
520
|
+
withFileLock(`${file}.lock`, () => writeSessionStateUnlocked(file, sessionId, state))
|
|
436
521
|
}
|
|
437
522
|
|
|
438
523
|
export function clearSessionState(sessionId: string, env: NodeJS.ProcessEnv): void {
|
|
@@ -445,8 +530,8 @@ export function clearSessionState(sessionId: string, env: NodeJS.ProcessEnv): vo
|
|
|
445
530
|
})
|
|
446
531
|
}
|
|
447
532
|
|
|
448
|
-
function writeSessionStateUnlocked(file: string, state: SessionState): void {
|
|
449
|
-
atomicWriteFileSync(file, `${JSON.stringify(state, null, 2)}\n`)
|
|
533
|
+
function writeSessionStateUnlocked(file: string, sessionId: string, state: SessionState): void {
|
|
534
|
+
atomicWriteFileSync(file, `${JSON.stringify({ ...state, session_id: sessionId }, null, 2)}\n`)
|
|
450
535
|
}
|
|
451
536
|
|
|
452
537
|
function updateSessionState(
|
|
@@ -457,7 +542,117 @@ function updateSessionState(
|
|
|
457
542
|
const file = sessionStatePath(sessionId, env)
|
|
458
543
|
return withFileLock(`${file}.lock`, () => {
|
|
459
544
|
const next = update(readSessionState(sessionId, env))
|
|
460
|
-
writeSessionStateUnlocked(file, next)
|
|
545
|
+
writeSessionStateUnlocked(file, sessionId, next)
|
|
546
|
+
return next
|
|
547
|
+
})
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
/** Record a lifecycle activation so the first-prompt compatibility path stays silent. */
|
|
551
|
+
export function markSessionActivation(
|
|
552
|
+
sessionId: string,
|
|
553
|
+
env: NodeJS.ProcessEnv,
|
|
554
|
+
now: number,
|
|
555
|
+
ownerPid: number,
|
|
556
|
+
harness?: HookHarness,
|
|
557
|
+
cwd?: string,
|
|
558
|
+
): void {
|
|
559
|
+
updateSessionState(sessionId, env, (current) => ({
|
|
560
|
+
...current,
|
|
561
|
+
...(harness === undefined ? {} : { harness }),
|
|
562
|
+
...(cwd === undefined ? {} : { activation_cwd: current.activation_cwd ?? cwd }),
|
|
563
|
+
activation_context_emitted_at: now,
|
|
564
|
+
activation_context_owner_pid: ownerPid,
|
|
565
|
+
}))
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
/**
|
|
569
|
+
* Atomically own first-prompt activation only when no lifecycle hook did.
|
|
570
|
+
*
|
|
571
|
+
* Some managed hosts preserve UserPromptSubmit while regenerating away newer
|
|
572
|
+
* SessionStart definitions. This is a compatibility seam, not the primary
|
|
573
|
+
* lifecycle: one session gets at most one model-visible activation from it.
|
|
574
|
+
*/
|
|
575
|
+
export function claimPromptActivation(
|
|
576
|
+
sessionId: string,
|
|
577
|
+
env: NodeJS.ProcessEnv,
|
|
578
|
+
now: number,
|
|
579
|
+
ownerPid: number,
|
|
580
|
+
harness?: HookHarness,
|
|
581
|
+
cwd?: string,
|
|
582
|
+
): boolean {
|
|
583
|
+
let claimed = false
|
|
584
|
+
updateSessionState(sessionId, env, (current) => {
|
|
585
|
+
if (
|
|
586
|
+
current.activation_context_emitted_at !== undefined &&
|
|
587
|
+
current.activation_context_owner_pid === ownerPid
|
|
588
|
+
) {
|
|
589
|
+
return current
|
|
590
|
+
}
|
|
591
|
+
claimed = true
|
|
592
|
+
return {
|
|
593
|
+
...current,
|
|
594
|
+
...(harness === undefined ? {} : { harness }),
|
|
595
|
+
...(cwd === undefined ? {} : { activation_cwd: current.activation_cwd ?? cwd }),
|
|
596
|
+
activation_context_emitted_at: now,
|
|
597
|
+
activation_context_owner_pid: ownerPid,
|
|
598
|
+
}
|
|
599
|
+
})
|
|
600
|
+
return claimed
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
/** Atomically claim Cursor's first-Stop activation fallback once per conversation. */
|
|
604
|
+
export function claimCursorStopActivation(
|
|
605
|
+
sessionId: string,
|
|
606
|
+
env: NodeJS.ProcessEnv,
|
|
607
|
+
now: number,
|
|
608
|
+
): boolean {
|
|
609
|
+
const claimGuardMs = 30_000
|
|
610
|
+
let claimed = false
|
|
611
|
+
updateSessionState(sessionId, env, (current) => {
|
|
612
|
+
if (current.cursor_activation_confirmed_at !== undefined) return current
|
|
613
|
+
if (
|
|
614
|
+
current.cursor_activation_claimed_at !== undefined &&
|
|
615
|
+
now - current.cursor_activation_claimed_at < claimGuardMs
|
|
616
|
+
) {
|
|
617
|
+
return current
|
|
618
|
+
}
|
|
619
|
+
claimed = true
|
|
620
|
+
return { ...current, cursor_activation_claimed_at: now }
|
|
621
|
+
})
|
|
622
|
+
return claimed
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
/** Cursor's loop_count proves the previous Stop follow-up reached the host. */
|
|
626
|
+
export function confirmCursorStopActivation(
|
|
627
|
+
sessionId: string,
|
|
628
|
+
env: NodeJS.ProcessEnv,
|
|
629
|
+
now: number,
|
|
630
|
+
): boolean {
|
|
631
|
+
let owned = false
|
|
632
|
+
updateSessionState(sessionId, env, (current) => {
|
|
633
|
+
if (current.cursor_activation_confirmed_at !== undefined) {
|
|
634
|
+
owned = true
|
|
635
|
+
return current
|
|
636
|
+
}
|
|
637
|
+
if (current.cursor_activation_claimed_at === undefined) return current
|
|
638
|
+
owned = true
|
|
639
|
+
return { ...current, cursor_activation_confirmed_at: now }
|
|
640
|
+
})
|
|
641
|
+
return owned
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
/** A fresh/cleared Cursor lifecycle makes its next Stop the fallback owner again. */
|
|
645
|
+
export function resetCursorStopActivation(sessionId: string, env: NodeJS.ProcessEnv): void {
|
|
646
|
+
updateSessionState(sessionId, env, (current) => {
|
|
647
|
+
if (
|
|
648
|
+
current.cursor_activation_claimed_at === undefined &&
|
|
649
|
+
current.cursor_activation_confirmed_at === undefined
|
|
650
|
+
) {
|
|
651
|
+
return current
|
|
652
|
+
}
|
|
653
|
+
const next = { ...current }
|
|
654
|
+
delete next.cursor_activation_claimed_at
|
|
655
|
+
delete next.cursor_activation_confirmed_at
|
|
461
656
|
return next
|
|
462
657
|
})
|
|
463
658
|
}
|
|
@@ -951,7 +1146,6 @@ async function prepareQuestionSubmission(
|
|
|
951
1146
|
media?: MediaItemT[] | undefined
|
|
952
1147
|
project?: string | undefined
|
|
953
1148
|
source?: SourceContextT | undefined
|
|
954
|
-
event: string
|
|
955
1149
|
/** How long the server keeps accepting an answer. */
|
|
956
1150
|
windowSeconds: number
|
|
957
1151
|
/** Absolute owner deadline persisted before submit. */
|
|
@@ -970,7 +1164,6 @@ async function prepareQuestionSubmission(
|
|
|
970
1164
|
{
|
|
971
1165
|
title: options.title,
|
|
972
1166
|
body: options.body,
|
|
973
|
-
event: options.event,
|
|
974
1167
|
lifecycle: { tier: 'needs_you' },
|
|
975
1168
|
...(options.project !== undefined ? { project: options.project } : {}),
|
|
976
1169
|
...(options.media !== undefined ? { media: options.media } : {}),
|
|
@@ -1037,6 +1230,10 @@ async function waitForAnyReply(
|
|
|
1037
1230
|
let firstPoll = true
|
|
1038
1231
|
const permanentFailures = new Map<string, string>()
|
|
1039
1232
|
|
|
1233
|
+
if (timeoutSeconds <= 0 || deadline <= ctx.now()) {
|
|
1234
|
+
return { byRequest: new Map(), degraded: false, permanentFailures }
|
|
1235
|
+
}
|
|
1236
|
+
|
|
1040
1237
|
for (;;) {
|
|
1041
1238
|
const remainingMs = Math.max(0, deadline - ctx.now())
|
|
1042
1239
|
if (!firstPoll && remainingMs === 0) {
|
|
@@ -1113,7 +1310,7 @@ async function answerableDevices(ctx: HookContext): Promise<string[]> {
|
|
|
1113
1310
|
* `notifai ask` supersedes the previous question and it is a plain command with
|
|
1114
1311
|
* no hook payload, no idle probe and nothing to sleep for.
|
|
1115
1312
|
*/
|
|
1116
|
-
export type RetireDeps = Pick<HookContext, 'client' | 'config'>
|
|
1313
|
+
export type RetireDeps = Pick<HookContext, 'client' | 'config'> & Pick<Partial<HookContext>, 'log'>
|
|
1117
1314
|
|
|
1118
1315
|
/**
|
|
1119
1316
|
* Close is a server-side serialization fence: a successful response contains
|
|
@@ -1138,60 +1335,22 @@ async function closeQuietly(ctx: RetireDeps, requestId: string): Promise<void> {
|
|
|
1138
1335
|
}
|
|
1139
1336
|
|
|
1140
1337
|
/**
|
|
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.
|
|
1338
|
+
* Close the reply window so the server can mint the retirement push. Client
|
|
1339
|
+
* submissions cannot carry `question_retired`; that event is reserved for
|
|
1340
|
+
* server-origin state syncs.
|
|
1147
1341
|
*/
|
|
1148
|
-
async function
|
|
1342
|
+
async function proveRetirement(
|
|
1149
1343
|
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,
|
|
1344
|
+
requestId: string,
|
|
1158
1345
|
): 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',
|
|
1346
|
+
const response = await finalizeReplies(ctx, requestId)
|
|
1347
|
+
const proven = response !== null
|
|
1348
|
+
ctx.log?.info('hook.retirement', {
|
|
1349
|
+
request_id: requestId,
|
|
1350
|
+
attempted: true,
|
|
1351
|
+
proven,
|
|
1352
|
+
})
|
|
1353
|
+
return proven
|
|
1195
1354
|
}
|
|
1196
1355
|
|
|
1197
1356
|
/**
|
|
@@ -1257,13 +1416,42 @@ function retiringQuestion(
|
|
|
1257
1416
|
}
|
|
1258
1417
|
}
|
|
1259
1418
|
|
|
1260
|
-
|
|
1261
|
-
|
|
1262
|
-
|
|
1263
|
-
|
|
1264
|
-
|
|
1265
|
-
|
|
1266
|
-
|
|
1419
|
+
const SHORT_CONFIRMATIONS = new Set(['done', 'yes', 'y', 'no', 'n', 'ok', 'okay'])
|
|
1420
|
+
|
|
1421
|
+
function normalizePrompt(value: string): string {
|
|
1422
|
+
return value.trim().replace(/\s+/g, ' ').toLowerCase()
|
|
1423
|
+
}
|
|
1424
|
+
|
|
1425
|
+
function pendingChoiceLabels(pending: PendingQuestion): string[] {
|
|
1426
|
+
const fromQuestions = (pending.questions ?? []).flatMap((question) =>
|
|
1427
|
+
(question.choices ?? []).flatMap((choice) => [choice.label, choice.id]),
|
|
1428
|
+
)
|
|
1429
|
+
return fromQuestions.map(normalizePrompt).filter((label) => label.length > 0)
|
|
1430
|
+
}
|
|
1431
|
+
|
|
1432
|
+
function pendingHasMatchingChoice(pending: PendingQuestion, normalizedPrompt: string): boolean {
|
|
1433
|
+
return pendingChoiceLabels(pending).includes(normalizedPrompt)
|
|
1434
|
+
}
|
|
1435
|
+
|
|
1436
|
+
/**
|
|
1437
|
+
* Which outstanding questions this prompt plausibly answers.
|
|
1438
|
+
*
|
|
1439
|
+
* A unique closed-choice match is enough. A short confirmation such as "done"
|
|
1440
|
+
* matches only when exactly one question is outstanding. Long unrelated
|
|
1441
|
+
* prompts leave every question open for the reply window.
|
|
1442
|
+
*/
|
|
1443
|
+
export function pendingAnsweredByPrompt(
|
|
1444
|
+
prompt: string | undefined,
|
|
1445
|
+
pending: readonly PendingQuestion[],
|
|
1446
|
+
): PendingQuestion[] {
|
|
1447
|
+
const normalized = normalizePrompt(prompt ?? '')
|
|
1448
|
+
if (normalized.length === 0 || pending.length === 0) return []
|
|
1449
|
+
|
|
1450
|
+
const labelled = pending.filter((entry) => pendingHasMatchingChoice(entry, normalized))
|
|
1451
|
+
if (labelled.length === 1) return labelled
|
|
1452
|
+
if (labelled.length > 1) return []
|
|
1453
|
+
if (pending.length === 1 && SHORT_CONFIRMATIONS.has(normalized)) return [...pending]
|
|
1454
|
+
return []
|
|
1267
1455
|
}
|
|
1268
1456
|
|
|
1269
1457
|
/**
|
|
@@ -1281,19 +1469,8 @@ export async function drainRetirements(
|
|
|
1281
1469
|
|
|
1282
1470
|
const retired: string[] = []
|
|
1283
1471
|
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) {
|
|
1472
|
+
const proven = await proveRetirement(ctx, entry.request_id)
|
|
1473
|
+
if (proven) {
|
|
1297
1474
|
retired.push(entry.request_id)
|
|
1298
1475
|
// Persist each success before touching the next entry. A later corrupt
|
|
1299
1476
|
// record or interrupted process must not resurrect work already done.
|
|
@@ -1380,19 +1557,8 @@ export async function drainOrphanRetirements(
|
|
|
1380
1557
|
)
|
|
1381
1558
|
continue
|
|
1382
1559
|
}
|
|
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) {
|
|
1560
|
+
const proven = await proveRetirement(ctx, entry.request_id)
|
|
1561
|
+
if (proven) {
|
|
1396
1562
|
done.push(entry.request_id)
|
|
1397
1563
|
updateOrphanQueue(env, (current) =>
|
|
1398
1564
|
current.filter((candidate) => candidate.request_id !== entry.request_id),
|
|
@@ -1484,10 +1650,15 @@ async function finalizePendings(
|
|
|
1484
1650
|
pending: PendingQuestion[],
|
|
1485
1651
|
): Promise<FinalizedPending[]> {
|
|
1486
1652
|
return Promise.all(
|
|
1487
|
-
pending.map(async (entry) =>
|
|
1488
|
-
|
|
1489
|
-
|
|
1490
|
-
|
|
1653
|
+
pending.map(async (entry) => {
|
|
1654
|
+
const response = await finalizeReplies(ctx, entry.request_id!)
|
|
1655
|
+
ctx.log?.info('hook.retirement', {
|
|
1656
|
+
request_id: entry.request_id,
|
|
1657
|
+
attempted: true,
|
|
1658
|
+
proven: response !== null,
|
|
1659
|
+
})
|
|
1660
|
+
return { pending: entry, response }
|
|
1661
|
+
}),
|
|
1491
1662
|
)
|
|
1492
1663
|
}
|
|
1493
1664
|
|
|
@@ -1521,7 +1692,7 @@ function reportAnswer(
|
|
|
1521
1692
|
...(late ? { late: true } : {}),
|
|
1522
1693
|
request_id: answered.pending.request_id,
|
|
1523
1694
|
device: answered.reply.device_name,
|
|
1524
|
-
|
|
1695
|
+
text_chars: answered.reply.text.length,
|
|
1525
1696
|
})
|
|
1526
1697
|
notes.push(`${late ? 'late ' : ''}answer from ${answered.reply.device_name}: ${answered.reply.text}`)
|
|
1527
1698
|
}
|
|
@@ -1579,7 +1750,7 @@ function isTerminalDraftRejection(err: ApiCallError): boolean {
|
|
|
1579
1750
|
return err.status === 422 || (err.status === 400 && err.code === 'invalid_request')
|
|
1580
1751
|
}
|
|
1581
1752
|
|
|
1582
|
-
function dropPendingQuestion(
|
|
1753
|
+
export function dropPendingQuestion(
|
|
1583
1754
|
sessionId: string,
|
|
1584
1755
|
env: NodeJS.ProcessEnv,
|
|
1585
1756
|
entry: PendingQuestion,
|
|
@@ -1593,6 +1764,29 @@ function dropPendingQuestion(
|
|
|
1593
1764
|
})
|
|
1594
1765
|
}
|
|
1595
1766
|
|
|
1767
|
+
/**
|
|
1768
|
+
* Drop registrations that never reached a device, so a later Stop cannot push
|
|
1769
|
+
* them. A retirement push would be noise: there is nothing on any device to
|
|
1770
|
+
* withdraw. Returns the withdrawn entries in registration order.
|
|
1771
|
+
*/
|
|
1772
|
+
export function withdrawUnpushedQuestions(
|
|
1773
|
+
sessionId: string,
|
|
1774
|
+
env: NodeJS.ProcessEnv,
|
|
1775
|
+
): PendingQuestion[] {
|
|
1776
|
+
let withdrawn: PendingQuestion[] = []
|
|
1777
|
+
updateSessionState(sessionId, env, (current) => {
|
|
1778
|
+
const pending = pendingList(current)
|
|
1779
|
+
withdrawn = pending.filter((entry) => entry.request_id === undefined)
|
|
1780
|
+
if (withdrawn.length === 0) return current
|
|
1781
|
+
const remaining = pending.filter((entry) => entry.request_id !== undefined)
|
|
1782
|
+
const next: SessionState = { ...current }
|
|
1783
|
+
if (remaining.length > 0) next.pending = remaining
|
|
1784
|
+
else delete next.pending
|
|
1785
|
+
return next
|
|
1786
|
+
})
|
|
1787
|
+
return withdrawn
|
|
1788
|
+
}
|
|
1789
|
+
|
|
1596
1790
|
function clearFrozenSubmission(
|
|
1597
1791
|
sessionId: string,
|
|
1598
1792
|
env: NodeJS.ProcessEnv,
|
|
@@ -1992,10 +2186,9 @@ async function retirePendings(
|
|
|
1992
2186
|
// ---------------------------------------------------------------------------
|
|
1993
2187
|
|
|
1994
2188
|
/**
|
|
1995
|
-
* Records presence
|
|
1996
|
-
*
|
|
1997
|
-
*
|
|
1998
|
-
* user being here is what makes the notification noise.
|
|
2189
|
+
* Records presence. Retires a live question only when this prompt plausibly
|
|
2190
|
+
* answers it. Typing in the terminal is not by itself a resolution: the User
|
|
2191
|
+
* may be answering a different question on a device.
|
|
1999
2192
|
*/
|
|
2000
2193
|
export async function handleUserPromptSubmit(
|
|
2001
2194
|
ctx: HookContext,
|
|
@@ -2009,6 +2202,7 @@ export async function handleUserPromptSubmit(
|
|
|
2009
2202
|
if (state.accepted !== undefined) {
|
|
2010
2203
|
updateSessionState(sessionId, ctx.env, (current) => ({
|
|
2011
2204
|
...current,
|
|
2205
|
+
...(ctx.harness === undefined ? {} : { harness: ctx.harness }),
|
|
2012
2206
|
last_prompt_at: ctx.now(),
|
|
2013
2207
|
}))
|
|
2014
2208
|
if (envelope.cwd !== undefined && ctx.harness !== undefined) {
|
|
@@ -2070,13 +2264,26 @@ export async function handleUserPromptSubmit(
|
|
|
2070
2264
|
const permanentFailure = permanentReplyFailureNote(permanentFailures)
|
|
2071
2265
|
if (permanentFailure !== null) notes.push(permanentFailure)
|
|
2072
2266
|
}
|
|
2267
|
+
const matched = pendingAnsweredByPrompt(envelope.prompt, pendingList(state))
|
|
2268
|
+
for (const entry of matched) {
|
|
2269
|
+
ctx.log?.info('hook.retirement', {
|
|
2270
|
+
request_id: entry.request_id,
|
|
2271
|
+
question_id: entry.question_id,
|
|
2272
|
+
reason: 'prompt-matched',
|
|
2273
|
+
attempted: entry.request_id !== undefined,
|
|
2274
|
+
proven: false,
|
|
2275
|
+
})
|
|
2276
|
+
}
|
|
2073
2277
|
// Park before dropping `pending`. If the process dies between these writes,
|
|
2074
2278
|
// the next hook sees both copies and dedupes them; the old order could die in
|
|
2075
2279
|
// the gap after erasing the only request/collapse/device identifiers.
|
|
2076
2280
|
updateSessionState(sessionId, ctx.env, (current) => {
|
|
2077
2281
|
const retiring = [...(current.retiring ?? [])]
|
|
2078
|
-
const
|
|
2079
|
-
|
|
2282
|
+
const matchedNow = pendingAnsweredByPrompt(envelope.prompt, pendingList(current))
|
|
2283
|
+
const unmatched = pendingList(current).filter(
|
|
2284
|
+
(entry) => !matchedNow.some((item) => isSamePending(item, entry)),
|
|
2285
|
+
)
|
|
2286
|
+
for (const entry of matchedNow.filter((item) => item.request_id !== undefined)) {
|
|
2080
2287
|
const retirement = retiringQuestion(entry, 'answered_elsewhere', envelope.cwd)
|
|
2081
2288
|
if (
|
|
2082
2289
|
retirement !== null &&
|
|
@@ -2085,11 +2292,18 @@ export async function handleUserPromptSubmit(
|
|
|
2085
2292
|
retiring.push(retirement)
|
|
2086
2293
|
}
|
|
2087
2294
|
}
|
|
2295
|
+
const unasked = unmatched.filter((entry) => entry.request_id === undefined)
|
|
2296
|
+
const stillLive = unmatched.filter((entry) => entry.request_id !== undefined)
|
|
2297
|
+
const remaining = [...stillLive, ...unasked]
|
|
2088
2298
|
|
|
2089
2299
|
// Start from the whole document so fields introduced by a newer CLI remain
|
|
2090
2300
|
// attached while this older writer applies the prompt transition it knows.
|
|
2091
|
-
const next: SessionState = {
|
|
2092
|
-
|
|
2301
|
+
const next: SessionState = {
|
|
2302
|
+
...current,
|
|
2303
|
+
...(ctx.harness === undefined ? {} : { harness: ctx.harness }),
|
|
2304
|
+
last_prompt_at: ctx.now(),
|
|
2305
|
+
}
|
|
2306
|
+
if (remaining.length > 0) next.pending = remaining
|
|
2093
2307
|
else delete next.pending
|
|
2094
2308
|
if (retiring.length > 0) next.retiring = retiring
|
|
2095
2309
|
else delete next.retiring
|
|
@@ -2173,6 +2387,7 @@ export async function runEscalationWaiter(
|
|
|
2173
2387
|
// from the prompt-only state that previously looked healthy.
|
|
2174
2388
|
updateSessionState(sessionId, ctx.env, (current) => ({
|
|
2175
2389
|
...current,
|
|
2390
|
+
...(ctx.harness === undefined ? {} : { harness: ctx.harness }),
|
|
2176
2391
|
last_stop_at: ctx.now(),
|
|
2177
2392
|
}))
|
|
2178
2393
|
|
|
@@ -2428,6 +2643,11 @@ async function deliverAcceptedAnswers(
|
|
|
2428
2643
|
return outcome
|
|
2429
2644
|
}
|
|
2430
2645
|
|
|
2646
|
+
/** True while this Stop owner is still allowed a direct-wake wait. */
|
|
2647
|
+
function ownerLeaseActive(entry: PendingQuestion, now: number): boolean {
|
|
2648
|
+
return entry.owner_deadline_at !== undefined && entry.owner_deadline_at > now
|
|
2649
|
+
}
|
|
2650
|
+
|
|
2431
2651
|
/** The complete per-session Stop decision, after this process owns the claim. */
|
|
2432
2652
|
async function handleClaimedStop(
|
|
2433
2653
|
ctx: HookContext,
|
|
@@ -2515,26 +2735,44 @@ async function handleClaimedStop(
|
|
|
2515
2735
|
const recoverableLive = live.filter(
|
|
2516
2736
|
(entry) => !permanentFailures.has(entry.request_id!),
|
|
2517
2737
|
)
|
|
2518
|
-
|
|
2738
|
+
// A spent owner has already used its waiter ceiling. Passing those
|
|
2739
|
+
// questions back into `escalate` starts a fresh ceiling, holds the
|
|
2740
|
+
// per-session claim for another 3300s, and is why a later `ask` never
|
|
2741
|
+
// reached a device after a degraded waiter (the next Stop never ran, or
|
|
2742
|
+
// ran as claimed-elsewhere). Late answers still ride the short poll above
|
|
2743
|
+
// and every later turn. Independent questions whose lease is still running
|
|
2744
|
+
// keep their successor wait.
|
|
2745
|
+
const stillOwned = recoverableLive.filter((entry) => ownerLeaseActive(entry, ctx.now()))
|
|
2746
|
+
liveToEscalate = stillOwned
|
|
2519
2747
|
if (unasked.length === 0) {
|
|
2520
2748
|
if (recoverableLive.length === 0) return { notes }
|
|
2521
|
-
|
|
2522
|
-
|
|
2523
|
-
|
|
2524
|
-
|
|
2749
|
+
if (stillOwned.length === 0) {
|
|
2750
|
+
const requestIdSummary = summarizeRequestIds(recoverableLive)
|
|
2751
|
+
ctx.log?.info('hook.answer', {
|
|
2752
|
+
answered: false,
|
|
2753
|
+
stage: 'queued',
|
|
2754
|
+
request_ids: requestIdSummary.ids,
|
|
2755
|
+
owner_spent: true,
|
|
2756
|
+
})
|
|
2757
|
+
notes.push(
|
|
2758
|
+
`${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`,
|
|
2759
|
+
)
|
|
2760
|
+
return { notes }
|
|
2761
|
+
}
|
|
2525
2762
|
return await escalate(
|
|
2526
2763
|
ctx,
|
|
2527
2764
|
envelope,
|
|
2528
2765
|
sessionId,
|
|
2529
2766
|
[],
|
|
2530
|
-
|
|
2767
|
+
stillOwned,
|
|
2531
2768
|
notes,
|
|
2532
2769
|
hardDeadlineAt,
|
|
2533
2770
|
route,
|
|
2534
2771
|
)
|
|
2535
2772
|
}
|
|
2536
2773
|
// Questions registered after the earlier push still owe the user their
|
|
2537
|
-
// notification; fall through and escalate just those
|
|
2774
|
+
// notification; fall through and escalate just those, plus any live
|
|
2775
|
+
// question whose owner lease is still running.
|
|
2538
2776
|
}
|
|
2539
2777
|
|
|
2540
2778
|
// A Stop answer may immediately produce a legitimate follow-up question.
|
|
@@ -2672,7 +2910,6 @@ async function escalate(
|
|
|
2672
2910
|
...(entry.media !== undefined ? { media: entry.media } : {}),
|
|
2673
2911
|
...(entry.project !== undefined ? { project: entry.project } : {}),
|
|
2674
2912
|
...(eventSource !== undefined ? { source: eventSource } : {}),
|
|
2675
|
-
event: 'agent_question',
|
|
2676
2913
|
windowSeconds: replyWindowSeconds,
|
|
2677
2914
|
ownerDeadlineAt,
|
|
2678
2915
|
})
|
|
@@ -2751,7 +2988,6 @@ async function escalate(
|
|
|
2751
2988
|
...(entry.media !== undefined ? { media: entry.media } : {}),
|
|
2752
2989
|
...(entry.project !== undefined ? { project: entry.project } : {}),
|
|
2753
2990
|
...(eventSource !== undefined ? { source: eventSource } : {}),
|
|
2754
|
-
event: 'agent_question',
|
|
2755
2991
|
windowSeconds: replyWindowSeconds,
|
|
2756
2992
|
ownerDeadlineAt,
|
|
2757
2993
|
})
|
|
@@ -2821,7 +3057,7 @@ async function escalate(
|
|
|
2821
3057
|
request_id: intent.request_id,
|
|
2822
3058
|
devices: intent.device_ids.length,
|
|
2823
3059
|
questions: questions.length,
|
|
2824
|
-
|
|
3060
|
+
text_chars: questions[0]!.text.length,
|
|
2825
3061
|
})
|
|
2826
3062
|
}
|
|
2827
3063
|
const committedReplyDeadline =
|
|
@@ -2841,14 +3077,14 @@ async function escalate(
|
|
|
2841
3077
|
// stale about eight minutes later and retired, so the answer given over
|
|
2842
3078
|
// lunch met a closed question.
|
|
2843
3079
|
//
|
|
2844
|
-
//
|
|
2845
|
-
//
|
|
2846
|
-
//
|
|
2847
|
-
//
|
|
2848
|
-
// this process waits.
|
|
3080
|
+
// The reply window is how long the server accepts an answer. This
|
|
3081
|
+
// process still clamps its own wait to `hardDeadlineAt`. Recording the
|
|
3082
|
+
// owner lease separately is what stops the *next* Stop from treating
|
|
3083
|
+
// the remaining window as a fresh waiter ceiling.
|
|
2849
3084
|
reply_deadline_at: Number.isFinite(committedReplyDeadline)
|
|
2850
3085
|
? committedReplyDeadline
|
|
2851
3086
|
: intent.owner_deadline_at,
|
|
3087
|
+
owner_deadline_at: intent.owner_deadline_at,
|
|
2852
3088
|
}
|
|
2853
3089
|
if (intent.draft.source === undefined) delete live.source
|
|
2854
3090
|
delete live.submission
|
|
@@ -2927,7 +3163,8 @@ async function escalate(
|
|
|
2927
3163
|
ceilingAt,
|
|
2928
3164
|
Math.max(...waitingOn.map((entry) => entry.reply_deadline_at!)),
|
|
2929
3165
|
)
|
|
2930
|
-
const
|
|
3166
|
+
const waitUntil = ownerDeadline - FINALIZATION_NETWORK_BUDGET_SECONDS * 1000
|
|
3167
|
+
const timeoutSeconds = Math.max(0, Math.ceil((waitUntil - ctx.now()) / 1000))
|
|
2931
3168
|
const waited = await waitForAnyReply(
|
|
2932
3169
|
ctx,
|
|
2933
3170
|
waitingOn.map((entry) => entry.request_id!),
|
|
@@ -2955,7 +3192,7 @@ async function escalate(
|
|
|
2955
3192
|
const expired = waitingOn.filter(
|
|
2956
3193
|
(entry) =>
|
|
2957
3194
|
entry.reply_deadline_at === undefined ||
|
|
2958
|
-
entry.reply_deadline_at <=
|
|
3195
|
+
entry.reply_deadline_at <= ownerDeadline ||
|
|
2959
3196
|
// A permanent rejection is the server saying this question can never be
|
|
2960
3197
|
// answered, whatever its window says. Leaving it open would strand a
|
|
2961
3198
|
// dead question on the user's devices.
|
|
@@ -3001,10 +3238,10 @@ async function escalate(
|
|
|
3001
3238
|
permanent_failures: waited.permanentFailures.size,
|
|
3002
3239
|
waited_seconds: timeoutSeconds,
|
|
3003
3240
|
})
|
|
3004
|
-
if (waited.permanentFailures.size === 0) {
|
|
3241
|
+
if (waited.permanentFailures.size === 0 && confirmedSilent.length > 0) {
|
|
3005
3242
|
notes.push(
|
|
3006
3243
|
waited.degraded
|
|
3007
|
-
? 'could not reach the server before the owner deadline;
|
|
3244
|
+
? 'could not reach the server before the owner deadline; expired questions were retired so no answer can be lost later'
|
|
3008
3245
|
: 'no answer in time; the question expired with its continuation owner',
|
|
3009
3246
|
)
|
|
3010
3247
|
}
|
|
@@ -3159,8 +3396,9 @@ export function registerQuestion(
|
|
|
3159
3396
|
env: NodeJS.ProcessEnv,
|
|
3160
3397
|
question: PendingQuestion,
|
|
3161
3398
|
now: number = Date.now(),
|
|
3162
|
-
):
|
|
3399
|
+
): string {
|
|
3163
3400
|
let full: 'unasked' | 'live' | null = null
|
|
3401
|
+
const questionId = question.question_id ?? `q_${randomBytes(12).toString('base64url')}`
|
|
3164
3402
|
updateSessionState(sessionId, env, (state) => {
|
|
3165
3403
|
const pending = pendingList(state)
|
|
3166
3404
|
const unasked = pending.filter((entry) => entry.request_id === undefined)
|
|
@@ -3177,9 +3415,9 @@ export function registerQuestion(
|
|
|
3177
3415
|
pending: [
|
|
3178
3416
|
...pending,
|
|
3179
3417
|
{
|
|
3180
|
-
question_id: `q_${randomBytes(12).toString('base64url')}`,
|
|
3181
3418
|
asked_at: now,
|
|
3182
3419
|
...question,
|
|
3420
|
+
question_id: questionId,
|
|
3183
3421
|
question: question.question.slice(0, MAX_STORED_QUESTION_CHARS),
|
|
3184
3422
|
},
|
|
3185
3423
|
],
|
|
@@ -3194,9 +3432,10 @@ export function registerQuestion(
|
|
|
3194
3432
|
if (full === 'live') {
|
|
3195
3433
|
throw new Error(
|
|
3196
3434
|
`${MAX_LIVE_QUESTIONS} questions from this session are already open. ` +
|
|
3197
|
-
'Retire the ones you no longer need with `notifai close <
|
|
3435
|
+
'Retire the ones you no longer need with `notifai close <question_id>` or `notifai close --pending` before asking another.',
|
|
3198
3436
|
)
|
|
3199
3437
|
}
|
|
3438
|
+
return questionId
|
|
3200
3439
|
}
|
|
3201
3440
|
|
|
3202
3441
|
/** Parses hook JSON from stdin, tolerating an empty or malformed body. */
|