@raidiant/notifai 4.0.0 → 6.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 +73 -0
- package/README.md +7 -2
- package/dist/client.d.ts +8 -3
- package/dist/client.js +24 -3
- package/dist/client.js.map +1 -1
- package/dist/commands-auth.d.ts +14 -0
- package/dist/commands-auth.js +154 -0
- package/dist/commands-auth.js.map +1 -0
- package/dist/commands-config.d.ts +31 -0
- package/dist/commands-config.js +246 -0
- package/dist/commands-config.js.map +1 -0
- package/dist/commands-core.d.ts +102 -0
- package/dist/commands-core.js +135 -0
- package/dist/commands-core.js.map +1 -0
- package/dist/commands-devices.d.ts +31 -0
- package/dist/commands-devices.js +124 -0
- package/dist/commands-devices.js.map +1 -0
- package/dist/commands-doctor.d.ts +24 -0
- package/dist/commands-doctor.js +1011 -0
- package/dist/commands-doctor.js.map +1 -0
- package/dist/commands-harness-context.d.ts +61 -0
- package/dist/commands-harness-context.js +93 -0
- package/dist/commands-harness-context.js.map +1 -0
- package/dist/commands-hook-shape.d.ts +17 -0
- package/dist/commands-hook-shape.js +37 -0
- package/dist/commands-hook-shape.js.map +1 -0
- package/dist/commands-hooks.d.ts +115 -0
- package/dist/commands-hooks.js +1225 -0
- package/dist/commands-hooks.js.map +1 -0
- package/dist/commands-init.d.ts +35 -0
- package/dist/commands-init.js +580 -0
- package/dist/commands-init.js.map +1 -0
- package/dist/commands-io.d.ts +2 -0
- package/dist/commands-io.js +110 -0
- package/dist/commands-io.js.map +1 -0
- package/dist/commands-logs.d.ts +30 -0
- package/dist/commands-logs.js +201 -0
- package/dist/commands-logs.js.map +1 -0
- package/dist/commands-send-support.d.ts +44 -0
- package/dist/commands-send-support.js +180 -0
- package/dist/commands-send-support.js.map +1 -0
- package/dist/commands-send.d.ts +33 -0
- package/dist/commands-send.js +519 -0
- package/dist/commands-send.js.map +1 -0
- package/dist/commands-setup-proof.d.ts +15 -0
- package/dist/commands-setup-proof.js +53 -0
- package/dist/commands-setup-proof.js.map +1 -0
- package/dist/commands-skill.d.ts +10 -0
- package/dist/commands-skill.js +71 -0
- package/dist/commands-skill.js.map +1 -0
- package/dist/commands.d.ts +12 -378
- package/dist/commands.js +12 -4420
- package/dist/commands.js.map +1 -1
- package/dist/config-schema.js +15 -6
- package/dist/config-schema.js.map +1 -1
- package/dist/config.d.ts +4 -2
- package/dist/config.js +13 -0
- package/dist/config.js.map +1 -1
- package/dist/credentials.js +13 -3
- package/dist/credentials.js.map +1 -1
- package/dist/file-lock.d.ts +6 -1
- package/dist/file-lock.js +55 -9
- package/dist/file-lock.js.map +1 -1
- package/dist/harnesses.d.ts +1 -1
- package/dist/harnesses.js.map +1 -1
- package/dist/hooks.d.ts +74 -7
- package/dist/hooks.js +522 -166
- package/dist/hooks.js.map +1 -1
- package/dist/install-hooks.d.ts +91 -14
- package/dist/install-hooks.js +226 -34
- package/dist/install-hooks.js.map +1 -1
- package/dist/interactive.d.ts +1 -1
- package/dist/interactive.js +5 -5
- package/dist/interactive.js.map +1 -1
- package/dist/logging.d.ts +8 -0
- package/dist/logging.js +9 -0
- package/dist/logging.js.map +1 -1
- package/dist/main.js +45 -18
- package/dist/main.js.map +1 -1
- package/dist/opencode-plugin.d.ts +8 -0
- package/dist/opencode-plugin.js +24 -162
- package/dist/opencode-plugin.js.map +1 -1
- package/dist/readiness.d.ts +3 -1
- package/dist/readiness.js.map +1 -1
- package/dist/send.d.ts +6 -0
- package/dist/send.js +25 -14
- package/dist/send.js.map +1 -1
- package/dist/ui/help.d.ts +0 -1
- package/dist/ui/help.js +6 -3
- package/dist/ui/help.js.map +1 -1
- package/dist/ui/theme.d.ts +0 -15
- package/dist/ui/theme.js +2 -18
- package/dist/ui/theme.js.map +1 -1
- package/package.json +2 -2
- package/src/client.test.ts +90 -1
- package/src/client.ts +35 -2
- package/src/commands-auth.ts +174 -0
- package/src/commands-config.ts +307 -0
- package/src/commands-core.ts +240 -0
- package/src/commands-devices.ts +150 -0
- package/src/commands-doctor.ts +1209 -0
- package/src/commands-harness-context.ts +126 -0
- package/src/commands-hook-shape.ts +53 -0
- package/src/commands-hooks.ts +1586 -0
- package/src/commands-init.ts +721 -0
- package/src/commands-io.ts +108 -0
- package/src/commands-logs.ts +243 -0
- package/src/commands-send-support.ts +224 -0
- package/src/commands-send.ts +623 -0
- package/src/commands-setup-proof.ts +71 -0
- package/src/commands-skill.ts +84 -0
- package/src/commands.test.ts +677 -89
- package/src/commands.ts +58 -5472
- package/src/config-schema.test.ts +1 -1
- package/src/config-schema.ts +17 -6
- package/src/config.test.ts +50 -9
- package/src/config.ts +19 -1
- package/src/credentials.test.ts +25 -1
- package/src/credentials.ts +13 -3
- package/src/file-lock.test.ts +41 -0
- package/src/file-lock.ts +59 -9
- package/src/harnesses.test.ts +37 -29
- package/src/harnesses.ts +1 -4
- package/src/hooks.test.ts +703 -26
- package/src/hooks.ts +590 -182
- package/src/install-hooks.test.ts +232 -12
- package/src/install-hooks.ts +241 -37
- package/src/interactive.test.ts +19 -1
- package/src/interactive.ts +5 -5
- package/src/logging-process-worker.test.ts +8 -2
- package/src/logging.test.ts +48 -20
- package/src/logging.ts +12 -0
- package/src/main.ts +58 -24
- package/src/opencode-plugin.ts +24 -164
- package/src/question-caps.test.ts +49 -0
- package/src/readiness.ts +3 -1
- package/src/send.ts +32 -16
- package/src/skill.test.ts +199 -146
- package/src/ui/help.ts +8 -3
- package/src/ui/theme.ts +2 -16
package/src/hooks.ts
CHANGED
|
@@ -11,6 +11,7 @@ import {
|
|
|
11
11
|
writeFileSync,
|
|
12
12
|
} from 'node:fs'
|
|
13
13
|
import path from 'node:path'
|
|
14
|
+
import { REPLY_MAX_WINDOW_SECONDS } from '@raidiant/notifai-protocol'
|
|
14
15
|
import type {
|
|
15
16
|
LifecycleEndState,
|
|
16
17
|
ListRepliesResponse,
|
|
@@ -37,6 +38,7 @@ import {
|
|
|
37
38
|
import { buildDraft } from './send.js'
|
|
38
39
|
import { atomicWriteFileSync } from './atomic-file.js'
|
|
39
40
|
import { withFileLock } from './file-lock.js'
|
|
41
|
+
import { inferInvocationContext } from './invocation-context.js'
|
|
40
42
|
import type { Logger } from './logging.js'
|
|
41
43
|
import {
|
|
42
44
|
HARNESS_CAPABILITIES,
|
|
@@ -127,11 +129,19 @@ export interface SessionState {
|
|
|
127
129
|
* the service confirms the agent-authored follow-up exists.
|
|
128
130
|
*/
|
|
129
131
|
acknowledgement_due?: AcknowledgementDue[]
|
|
132
|
+
/** Consecutive turns this session has been held for an acknowledgement. */
|
|
133
|
+
acknowledgement_blocks?: number
|
|
130
134
|
}
|
|
131
135
|
|
|
132
136
|
export interface AcknowledgementDue {
|
|
133
137
|
request_id: string
|
|
134
138
|
recorded_at: number
|
|
139
|
+
/**
|
|
140
|
+
* Whether this request's acknowledgement must carry text. Absent on state
|
|
141
|
+
* written before the obligation started recording it; treated as true, which
|
|
142
|
+
* is the default the service ships.
|
|
143
|
+
*/
|
|
144
|
+
text_required?: boolean
|
|
135
145
|
}
|
|
136
146
|
|
|
137
147
|
interface AcceptedAnswerDelivery {
|
|
@@ -152,6 +162,8 @@ interface AcceptedAnswerDelivery {
|
|
|
152
162
|
* passes through.
|
|
153
163
|
*/
|
|
154
164
|
delivery_attempts?: number
|
|
165
|
+
/** Turns this answer has been held without being handed to the agent. */
|
|
166
|
+
held_deliveries?: number
|
|
155
167
|
}
|
|
156
168
|
|
|
157
169
|
/** A delivered question awaiting its retirement push. */
|
|
@@ -184,12 +196,15 @@ export interface OrphanRetirement extends RetiringQuestion {
|
|
|
184
196
|
}
|
|
185
197
|
|
|
186
198
|
/**
|
|
187
|
-
*
|
|
188
|
-
*
|
|
189
|
-
*
|
|
190
|
-
*
|
|
199
|
+
* A retirement is only worth syncing while the question it retires could still
|
|
200
|
+
* be answered, so this has to outlive the longest reply window a user can
|
|
201
|
+
* choose — it is derived from that bound rather than from a guess about how
|
|
202
|
+
* long questions stay open, which is exactly the assumption that broke when
|
|
203
|
+
* the window stopped being one hour. Past it the companion shows the question
|
|
204
|
+
* as dead on next open anyway. Also the backstop that keeps an unreachable
|
|
205
|
+
* server from growing the queue for ever.
|
|
191
206
|
*/
|
|
192
|
-
const ORPHAN_TTL_MS =
|
|
207
|
+
const ORPHAN_TTL_MS = (REPLY_MAX_WINDOW_SECONDS + 3600) * 1000
|
|
193
208
|
|
|
194
209
|
/** More orphans than this means something is looping; keep the newest. */
|
|
195
210
|
const ORPHAN_QUEUE_CAP = 50
|
|
@@ -228,6 +243,22 @@ export interface PendingQuestion {
|
|
|
228
243
|
submission?: PendingSubmissionIntent
|
|
229
244
|
}
|
|
230
245
|
|
|
246
|
+
/** Keep immutable session grouping while replacing per-event Git location authoritatively. */
|
|
247
|
+
function sourceContextAtHookEvent(
|
|
248
|
+
source: SourceContextT | undefined,
|
|
249
|
+
cwd: string | undefined,
|
|
250
|
+
): SourceContextT | undefined {
|
|
251
|
+
const invocation = cwd === undefined ? null : inferInvocationContext(cwd)
|
|
252
|
+
const current: SourceContextT = {
|
|
253
|
+
...(source?.session_id !== undefined ? { session_id: source.session_id } : {}),
|
|
254
|
+
...(source?.session_label !== undefined ? { session_label: source.session_label } : {}),
|
|
255
|
+
...(source?.harness !== undefined ? { harness: source.harness } : {}),
|
|
256
|
+
...(invocation?.branch !== undefined ? { branch: invocation.branch } : {}),
|
|
257
|
+
...(invocation?.worktree !== undefined ? { worktree: invocation.worktree } : {}),
|
|
258
|
+
}
|
|
259
|
+
return Object.keys(current).length === 0 ? undefined : current
|
|
260
|
+
}
|
|
261
|
+
|
|
231
262
|
interface PendingSubmissionIntent {
|
|
232
263
|
request_id: string
|
|
233
264
|
idempotency_key: string
|
|
@@ -258,12 +289,35 @@ function summarizeRequestIds(entries: readonly PendingQuestion[]): {
|
|
|
258
289
|
*/
|
|
259
290
|
export const WAITER_CEILING_SECONDS = 480
|
|
260
291
|
|
|
292
|
+
/**
|
|
293
|
+
* The same wall clock where the waiter runs in the background instead of
|
|
294
|
+
* inside a held turn.
|
|
295
|
+
*
|
|
296
|
+
* On a host that holds its turn, 480 s is a real cost to the person sitting in
|
|
297
|
+
* front of it, and the number above is right. On Claude Code the Stop hook
|
|
298
|
+
* returns at once and the waiter is detached, so the same 480 s buys nothing
|
|
299
|
+
* and costs a direct wake: an answer arriving at minute nine had to wait for
|
|
300
|
+
* the session's next turn even though nothing was blocked.
|
|
301
|
+
*
|
|
302
|
+
* This sits under `CLAUDE_ASYNC_STOP_TIMEOUT_SECONDS` (3600) with headroom,
|
|
303
|
+
* because that declaration's kill is silent, and under the reply window the
|
|
304
|
+
* question was pushed with. Past it the journal still delivers at the next
|
|
305
|
+
* turn — the ceiling decides how long a *direct* wake stays possible, never
|
|
306
|
+
* whether the answer survives.
|
|
307
|
+
*/
|
|
308
|
+
export const DETACHED_WAITER_CEILING_SECONDS = 3300
|
|
309
|
+
|
|
310
|
+
/** The waiter's wall clock, which depends on whether a turn is held open for it. */
|
|
311
|
+
export function waiterCeilingSeconds(detached: boolean): number {
|
|
312
|
+
return detached ? DETACHED_WAITER_CEILING_SECONDS : WAITER_CEILING_SECONDS
|
|
313
|
+
}
|
|
314
|
+
|
|
261
315
|
/**
|
|
262
316
|
* The wire contract's shortest reply window. A question submitted with less
|
|
263
317
|
* than this is rejected outright, and accepting one would in any case let the
|
|
264
318
|
* server go on taking an answer after the waiter that owns it has returned.
|
|
265
319
|
*/
|
|
266
|
-
const MIN_REPLY_WINDOW_SECONDS = 60
|
|
320
|
+
export const MIN_REPLY_WINDOW_SECONDS = 60
|
|
267
321
|
|
|
268
322
|
/**
|
|
269
323
|
* The terminal-first wait: the question sits in the terminal for
|
|
@@ -476,6 +530,17 @@ export function pruneAbandonedSessions(
|
|
|
476
530
|
* no trustworthy PID fall back to an age limit.
|
|
477
531
|
*/
|
|
478
532
|
const CLAIM_TTL_MS = WAITER_CEILING_SECONDS * 1000
|
|
533
|
+
|
|
534
|
+
/**
|
|
535
|
+
* How long a crashed claim *guard* blocks the next hook.
|
|
536
|
+
*
|
|
537
|
+
* The claim itself may legitimately be held for the whole waiter, so it takes
|
|
538
|
+
* that ceiling. The guard is a short lock around the claim's own bookkeeping —
|
|
539
|
+
* a few file operations — and borrowing the waiter's ceiling for it meant a
|
|
540
|
+
* hook killed at exactly the wrong moment wedged this session's Stop path for
|
|
541
|
+
* eight minutes, for a critical section that never runs longer than a second.
|
|
542
|
+
*/
|
|
543
|
+
const CLAIM_GUARD_TTL_MS = 30_000
|
|
479
544
|
const heldClaims = new Map<string, string>()
|
|
480
545
|
|
|
481
546
|
export function claimQuestionPush(
|
|
@@ -569,7 +634,7 @@ function acquireClaimGuard(file: string): boolean {
|
|
|
569
634
|
} catch (err) {
|
|
570
635
|
if ((err as NodeJS.ErrnoException).code !== 'EEXIST') return false
|
|
571
636
|
try {
|
|
572
|
-
if (Date.now() - statSync(file).mtimeMs <
|
|
637
|
+
if (Date.now() - statSync(file).mtimeMs < CLAIM_GUARD_TTL_MS) return false
|
|
573
638
|
} catch {
|
|
574
639
|
// A vanished/corrupt guard is safe to retry once.
|
|
575
640
|
}
|
|
@@ -595,6 +660,50 @@ interface StoredProjectSessionPointer extends ProjectSessionPointer {
|
|
|
595
660
|
updatedAt: number
|
|
596
661
|
}
|
|
597
662
|
|
|
663
|
+
interface StoredProjectSessionDocument {
|
|
664
|
+
root: Record<string, unknown>
|
|
665
|
+
sessions: unknown[]
|
|
666
|
+
}
|
|
667
|
+
|
|
668
|
+
export interface ProjectSessionPointer {
|
|
669
|
+
sessionId: string
|
|
670
|
+
harness: HookHarness
|
|
671
|
+
}
|
|
672
|
+
|
|
673
|
+
function storedProjectSessionPointer(candidate: unknown): StoredProjectSessionPointer | null {
|
|
674
|
+
if (typeof candidate !== 'object' || candidate === null || Array.isArray(candidate)) return null
|
|
675
|
+
const entry = candidate as {
|
|
676
|
+
session_id?: unknown
|
|
677
|
+
updated_at?: unknown
|
|
678
|
+
harness?: unknown
|
|
679
|
+
}
|
|
680
|
+
if (typeof entry.session_id !== 'string' || entry.session_id === '') return null
|
|
681
|
+
if (typeof entry.updated_at !== 'number') return null
|
|
682
|
+
if (!(HARNESSES as readonly unknown[]).includes(entry.harness)) return null
|
|
683
|
+
return {
|
|
684
|
+
sessionId: entry.session_id,
|
|
685
|
+
updatedAt: entry.updated_at,
|
|
686
|
+
harness: entry.harness as HookHarness,
|
|
687
|
+
}
|
|
688
|
+
}
|
|
689
|
+
|
|
690
|
+
function readStoredProjectSessionDocument(file: string): StoredProjectSessionDocument {
|
|
691
|
+
if (!existsSync(file)) return { root: {}, sessions: [] }
|
|
692
|
+
try {
|
|
693
|
+
const parsed: unknown = JSON.parse(readFileSync(file, 'utf8'))
|
|
694
|
+
if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
|
|
695
|
+
return { root: {}, sessions: [] }
|
|
696
|
+
}
|
|
697
|
+
const root = parsed as Record<string, unknown>
|
|
698
|
+
return {
|
|
699
|
+
root,
|
|
700
|
+
sessions: Array.isArray(root['sessions']) ? root['sessions'] : [],
|
|
701
|
+
}
|
|
702
|
+
} catch {
|
|
703
|
+
return { root: {}, sessions: [] }
|
|
704
|
+
}
|
|
705
|
+
}
|
|
706
|
+
|
|
598
707
|
/** Records every live session working in a directory, for `notifai ask`. */
|
|
599
708
|
export function writeProjectSession(
|
|
600
709
|
cwd: string,
|
|
@@ -605,56 +714,37 @@ export function writeProjectSession(
|
|
|
605
714
|
): void {
|
|
606
715
|
const file = projectSessionPointerPath(cwd, env)
|
|
607
716
|
withFileLock(`${file}.lock`, () => {
|
|
608
|
-
const
|
|
609
|
-
|
|
610
|
-
|
|
717
|
+
const stored = readStoredProjectSessionDocument(file)
|
|
718
|
+
const existing: unknown[] = []
|
|
719
|
+
let refreshed: Record<string, unknown> = {}
|
|
720
|
+
for (const candidate of stored.sessions) {
|
|
721
|
+
const entry = storedProjectSessionPointer(candidate)
|
|
722
|
+
if (entry?.sessionId === sessionId) {
|
|
723
|
+
refreshed = { ...refreshed, ...(candidate as Record<string, unknown>) }
|
|
724
|
+
} else if (entry === null || now - entry.updatedAt <= 24 * 3600 * 1000) {
|
|
725
|
+
existing.push(candidate)
|
|
726
|
+
}
|
|
727
|
+
}
|
|
611
728
|
atomicWriteFileSync(
|
|
612
729
|
file,
|
|
613
730
|
`${JSON.stringify({
|
|
731
|
+
...stored.root,
|
|
614
732
|
sessions: [
|
|
615
|
-
...existing
|
|
616
|
-
|
|
617
|
-
updated_at: entry.updatedAt,
|
|
618
|
-
harness: entry.harness,
|
|
619
|
-
})),
|
|
620
|
-
{ session_id: sessionId, updated_at: now, harness },
|
|
733
|
+
...existing,
|
|
734
|
+
{ ...refreshed, session_id: sessionId, updated_at: now, harness },
|
|
621
735
|
],
|
|
622
736
|
})}\n`,
|
|
623
737
|
)
|
|
624
738
|
})
|
|
625
739
|
}
|
|
626
740
|
|
|
627
|
-
export interface ProjectSessionPointer {
|
|
628
|
-
sessionId: string
|
|
629
|
-
harness: HookHarness
|
|
630
|
-
}
|
|
631
|
-
|
|
632
741
|
function readStoredProjectSessionPointers(file: string): StoredProjectSessionPointer[] {
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
const entry = candidate as {
|
|
640
|
-
session_id?: unknown
|
|
641
|
-
updated_at?: unknown
|
|
642
|
-
harness?: unknown
|
|
643
|
-
}
|
|
644
|
-
if (typeof entry.session_id !== 'string' || entry.session_id === '') return []
|
|
645
|
-
if (typeof entry.updated_at !== 'number') return []
|
|
646
|
-
if (!(HARNESSES as readonly unknown[]).includes(entry.harness)) return []
|
|
647
|
-
return [
|
|
648
|
-
{
|
|
649
|
-
sessionId: entry.session_id,
|
|
650
|
-
updatedAt: entry.updated_at,
|
|
651
|
-
harness: entry.harness as HookHarness,
|
|
652
|
-
},
|
|
653
|
-
]
|
|
654
|
-
})
|
|
655
|
-
} catch {
|
|
656
|
-
return []
|
|
657
|
-
}
|
|
742
|
+
return readStoredProjectSessionDocument(file).sessions.flatMap(
|
|
743
|
+
(candidate): StoredProjectSessionPointer[] => {
|
|
744
|
+
const entry = storedProjectSessionPointer(candidate)
|
|
745
|
+
return entry === null ? [] : [entry]
|
|
746
|
+
},
|
|
747
|
+
)
|
|
658
748
|
}
|
|
659
749
|
|
|
660
750
|
/**
|
|
@@ -744,21 +834,21 @@ function clearMatchingProjectSession(
|
|
|
744
834
|
): void {
|
|
745
835
|
const file = projectSessionPointerPath(cwd, env)
|
|
746
836
|
withFileLock(`${file}.lock`, () => {
|
|
747
|
-
const
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
837
|
+
const stored = readStoredProjectSessionDocument(file)
|
|
838
|
+
const remaining = stored.sessions.filter((candidate) => {
|
|
839
|
+
const entry = storedProjectSessionPointer(candidate)
|
|
840
|
+
return entry === null || entry.sessionId !== sessionId
|
|
841
|
+
})
|
|
842
|
+
const hasUnknownRootFields = Object.keys(stored.root).some((key) => key !== 'sessions')
|
|
843
|
+
if (remaining.length === 0 && !hasUnknownRootFields) {
|
|
751
844
|
rmSync(file, { force: true })
|
|
752
845
|
return
|
|
753
846
|
}
|
|
754
847
|
atomicWriteFileSync(
|
|
755
848
|
file,
|
|
756
849
|
`${JSON.stringify({
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
updated_at: entry.updatedAt,
|
|
760
|
-
harness: entry.harness,
|
|
761
|
-
})),
|
|
850
|
+
...stored.root,
|
|
851
|
+
sessions: remaining,
|
|
762
852
|
})}\n`,
|
|
763
853
|
)
|
|
764
854
|
})
|
|
@@ -795,19 +885,29 @@ export interface HookContext {
|
|
|
795
885
|
* the user sees in the terminal — but it is free to be rewritten, and a log a
|
|
796
886
|
* filter cannot rely on is a log nobody filters.
|
|
797
887
|
*/
|
|
798
|
-
export
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
888
|
+
export const GATE_REASONS = [
|
|
889
|
+
'no-session',
|
|
890
|
+
'no-question',
|
|
891
|
+
'answered',
|
|
892
|
+
'continuation-repeat',
|
|
893
|
+
'continuation-limit',
|
|
894
|
+
'delivery-limit',
|
|
895
|
+
'acknowledgement-required',
|
|
896
|
+
'acknowledgement-abandoned',
|
|
897
|
+
'harness-cannot-continue',
|
|
898
|
+
'notifications-off',
|
|
899
|
+
'claimed-elsewhere',
|
|
900
|
+
'elapsed',
|
|
901
|
+
'proceeding',
|
|
902
|
+
] as const
|
|
903
|
+
|
|
904
|
+
/**
|
|
905
|
+
* A runtime list rather than a bare type, so the guidance that tells agents to
|
|
906
|
+
* filter on these can be tested against the set the code can actually emit.
|
|
907
|
+
* `already-asked` and `no-devices` were declared here for a long time and never
|
|
908
|
+
* emitted once; documenting them taught agents to filter for silence.
|
|
909
|
+
*/
|
|
910
|
+
export type GateReason = (typeof GATE_REASONS)[number]
|
|
811
911
|
|
|
812
912
|
function gate(
|
|
813
913
|
ctx: HookContext,
|
|
@@ -875,7 +975,7 @@ async function prepareQuestionSubmission(
|
|
|
875
975
|
...(options.media !== undefined ? { media: options.media } : {}),
|
|
876
976
|
device: answerable,
|
|
877
977
|
reply: true,
|
|
878
|
-
replyWindow: Math.max(
|
|
978
|
+
replyWindow: Math.max(MIN_REPLY_WINDOW_SECONDS, options.windowSeconds),
|
|
879
979
|
questions: options.questions,
|
|
880
980
|
collapseKey,
|
|
881
981
|
},
|
|
@@ -1001,7 +1101,8 @@ async function answerableDevices(ctx: HookContext): Promise<string[]> {
|
|
|
1001
1101
|
(device) =>
|
|
1002
1102
|
(device.platform === 'ios' || device.platform === 'macos') &&
|
|
1003
1103
|
device.registration_healthy &&
|
|
1004
|
-
device.
|
|
1104
|
+
device.capabilities?.includes('answer') === true &&
|
|
1105
|
+
device.derived_status !== 'must_update',
|
|
1005
1106
|
)
|
|
1006
1107
|
.filter((device) => configured === null || configured.includes(device.device_id))
|
|
1007
1108
|
.map((device) => device.device_id)
|
|
@@ -1091,7 +1192,6 @@ const RETIREMENT_TITLES: Record<LifecycleEndState, string> = {
|
|
|
1091
1192
|
answered: 'Answered',
|
|
1092
1193
|
answered_elsewhere: 'Answered in the terminal',
|
|
1093
1194
|
expired: 'Question expired',
|
|
1094
|
-
superseded: 'Replaced by a newer question',
|
|
1095
1195
|
}
|
|
1096
1196
|
|
|
1097
1197
|
/**
|
|
@@ -1134,6 +1234,7 @@ export function parkForRetirement(
|
|
|
1134
1234
|
function retiringQuestion(
|
|
1135
1235
|
pending: PendingQuestion,
|
|
1136
1236
|
state: LifecycleEndState,
|
|
1237
|
+
cwd?: string,
|
|
1137
1238
|
): RetiringQuestion | null {
|
|
1138
1239
|
const hasRequest = pending.request_id !== undefined
|
|
1139
1240
|
const hasCollapse = pending.collapse_key !== undefined
|
|
@@ -1144,13 +1245,14 @@ function retiringQuestion(
|
|
|
1144
1245
|
'live question state is incomplete; refusing to retire it without request, collapse, and device identifiers',
|
|
1145
1246
|
)
|
|
1146
1247
|
}
|
|
1248
|
+
const source = sourceContextAtHookEvent(pending.source, cwd)
|
|
1147
1249
|
return {
|
|
1148
1250
|
request_id: pending.request_id!,
|
|
1149
1251
|
collapse_key: pending.collapse_key!,
|
|
1150
1252
|
device_ids: [...pending.device_ids!],
|
|
1151
1253
|
question: pending.question,
|
|
1152
1254
|
...(pending.project !== undefined ? { project: pending.project } : {}),
|
|
1153
|
-
...(
|
|
1255
|
+
...(source !== undefined ? { source } : {}),
|
|
1154
1256
|
state,
|
|
1155
1257
|
}
|
|
1156
1258
|
}
|
|
@@ -1312,6 +1414,21 @@ const LATE_PROMPT_POLL_SECONDS = 3
|
|
|
1312
1414
|
*/
|
|
1313
1415
|
export const MAX_CONTINUATION_COUNT = 3
|
|
1314
1416
|
|
|
1417
|
+
/**
|
|
1418
|
+
* How many turns an answer may be *held* before it is settled unread.
|
|
1419
|
+
*
|
|
1420
|
+
* A hold is not a delivery: the route reported that it handed nothing over, so
|
|
1421
|
+
* the agent has not seen the answer and the wake-loop the continuation cap
|
|
1422
|
+
* exists to stop has not happened. Counting holds against that cap meant three
|
|
1423
|
+
* turns where a liveness probe could not reach a busy session were enough to
|
|
1424
|
+
* discard an answer the user had already given.
|
|
1425
|
+
*
|
|
1426
|
+
* It still has to end. A route that can never deliver would otherwise retry
|
|
1427
|
+
* every turn for the life of the session, so holds get their own, far looser
|
|
1428
|
+
* bound — loose because each one costs nothing and the answer is still wanted.
|
|
1429
|
+
*/
|
|
1430
|
+
export const MAX_HELD_DELIVERIES = 20
|
|
1431
|
+
|
|
1315
1432
|
interface PendingPoll {
|
|
1316
1433
|
/** The answer to act on: the latest reply, because a later one corrects. */
|
|
1317
1434
|
reply: ReplyView | null
|
|
@@ -1352,6 +1469,8 @@ interface AnsweredPending {
|
|
|
1352
1469
|
replies: ReplyView[]
|
|
1353
1470
|
/** Immutable server snapshot for this request, known after a replies/close response. */
|
|
1354
1471
|
agent_acknowledgement_required?: boolean | undefined
|
|
1472
|
+
/** Whether that acknowledgement must carry text; the account's snapshot. */
|
|
1473
|
+
agent_acknowledgement_text_required?: boolean | undefined
|
|
1355
1474
|
}
|
|
1356
1475
|
|
|
1357
1476
|
interface FinalizedPending {
|
|
@@ -1384,6 +1503,8 @@ function finalizedAnswer(finalized: FinalizedPending): AnsweredPending | null {
|
|
|
1384
1503
|
replies,
|
|
1385
1504
|
agent_acknowledgement_required:
|
|
1386
1505
|
finalized.response?.agent_acknowledgement_required,
|
|
1506
|
+
agent_acknowledgement_text_required:
|
|
1507
|
+
finalized.response?.agent_acknowledgement_text_required,
|
|
1387
1508
|
}
|
|
1388
1509
|
}
|
|
1389
1510
|
|
|
@@ -1449,6 +1570,46 @@ function isSamePending(a: PendingQuestion, b: PendingQuestion): boolean {
|
|
|
1449
1570
|
return a.question === b.question && a.asked_at === b.asked_at
|
|
1450
1571
|
}
|
|
1451
1572
|
|
|
1573
|
+
/**
|
|
1574
|
+
* The frozen draft itself will never be accepted. Replaying it forever hides a
|
|
1575
|
+
* contract-skew (deleted fields, unexpected properties) as a recoverable wait.
|
|
1576
|
+
* Auth, conflicts, and timeouts are not this class.
|
|
1577
|
+
*/
|
|
1578
|
+
function isTerminalDraftRejection(err: ApiCallError): boolean {
|
|
1579
|
+
return err.status === 422 || (err.status === 400 && err.code === 'invalid_request')
|
|
1580
|
+
}
|
|
1581
|
+
|
|
1582
|
+
function dropPendingQuestion(
|
|
1583
|
+
sessionId: string,
|
|
1584
|
+
env: NodeJS.ProcessEnv,
|
|
1585
|
+
entry: PendingQuestion,
|
|
1586
|
+
): void {
|
|
1587
|
+
updateSessionState(sessionId, env, (current) => {
|
|
1588
|
+
const pending = pendingList(current).filter((candidate) => !isSamePending(candidate, entry))
|
|
1589
|
+
const next: SessionState = { ...current }
|
|
1590
|
+
if (pending.length > 0) next.pending = pending
|
|
1591
|
+
else delete next.pending
|
|
1592
|
+
return next
|
|
1593
|
+
})
|
|
1594
|
+
}
|
|
1595
|
+
|
|
1596
|
+
function clearFrozenSubmission(
|
|
1597
|
+
sessionId: string,
|
|
1598
|
+
env: NodeJS.ProcessEnv,
|
|
1599
|
+
entry: PendingQuestion,
|
|
1600
|
+
): void {
|
|
1601
|
+
updateSessionState(sessionId, env, (current) => {
|
|
1602
|
+
const list = pendingList(current)
|
|
1603
|
+
const index = list.findIndex((candidate) => isSamePending(candidate, entry))
|
|
1604
|
+
if (index < 0) return current
|
|
1605
|
+
const next = [...list]
|
|
1606
|
+
const copy = { ...next[index]! }
|
|
1607
|
+
delete copy.submission
|
|
1608
|
+
next[index] = copy
|
|
1609
|
+
return { ...current, pending: next }
|
|
1610
|
+
})
|
|
1611
|
+
}
|
|
1612
|
+
|
|
1452
1613
|
/** The question set this pending record pushes, however it was registered. */
|
|
1453
1614
|
function pendingQuestions(pending: PendingQuestion): QuestionT[] {
|
|
1454
1615
|
return pending.questions ?? [{ id: 'q1', text: pending.question }]
|
|
@@ -1496,8 +1657,20 @@ function answerContext(answered: AnsweredPending): string {
|
|
|
1496
1657
|
* tied to the question that asked it, with a truthful note about anything
|
|
1497
1658
|
* still waiting.
|
|
1498
1659
|
*/
|
|
1499
|
-
function acknowledgementCommand(requestId: string): string {
|
|
1500
|
-
return
|
|
1660
|
+
function acknowledgementCommand(requestId: string, textRequired = true): string {
|
|
1661
|
+
return textRequired
|
|
1662
|
+
? `notifai acknowledge ${requestId} --text <text>`
|
|
1663
|
+
: `notifai acknowledge ${requestId}`
|
|
1664
|
+
}
|
|
1665
|
+
|
|
1666
|
+
/**
|
|
1667
|
+
* The acknowledgement is owed either way; only its text is conditional. So the
|
|
1668
|
+
* instruction never says "you may skip this" — it says what to run.
|
|
1669
|
+
*/
|
|
1670
|
+
function acknowledgementDemand(textRequired: boolean): string {
|
|
1671
|
+
return textRequired
|
|
1672
|
+
? ' with non-empty text saying what concrete work you will do because of the reply; a bare acknowledgement is insufficient'
|
|
1673
|
+
: ' exactly as shown; this account turned acknowledgement text off, so the receipt carries no words'
|
|
1501
1674
|
}
|
|
1502
1675
|
|
|
1503
1676
|
function acknowledgementContext(answered: AnsweredPending[]): string {
|
|
@@ -1509,20 +1682,23 @@ function acknowledgementContext(answered: AnsweredPending[]): string {
|
|
|
1509
1682
|
return ' Agent Acknowledgement is not required for the answered request(s).'
|
|
1510
1683
|
}
|
|
1511
1684
|
if (due.length === 1) {
|
|
1512
|
-
const
|
|
1685
|
+
const entry = due[0]!
|
|
1686
|
+
const requestId = entry.pending.request_id!
|
|
1687
|
+
const textRequired = entry.agent_acknowledgement_text_required !== false
|
|
1513
1688
|
return (
|
|
1514
1689
|
` Agent Acknowledgement is required for request ${requestId}. Immediately, before doing the resumed work or ending this turn, run ` +
|
|
1515
|
-
`\`${acknowledgementCommand(requestId)}
|
|
1690
|
+
`\`${acknowledgementCommand(requestId, textRequired)}\`${acknowledgementDemand(textRequired)}.`
|
|
1516
1691
|
)
|
|
1517
1692
|
}
|
|
1693
|
+
const anyTextRequired = due.some((entry) => entry.agent_acknowledgement_text_required !== false)
|
|
1518
1694
|
const commands = due
|
|
1519
1695
|
.map((entry) => {
|
|
1520
1696
|
const requestId = entry.pending.request_id!
|
|
1521
|
-
return `- ${requestId}: \`${acknowledgementCommand(requestId)}\``
|
|
1697
|
+
return `- ${requestId}: \`${acknowledgementCommand(requestId, entry.agent_acknowledgement_text_required !== false)}\``
|
|
1522
1698
|
})
|
|
1523
1699
|
.join('\n')
|
|
1524
1700
|
return (
|
|
1525
|
-
` Agent Acknowledgement is required for ${due.length} requests. Immediately, before doing the resumed work or ending this turn, run every command below
|
|
1701
|
+
` Agent Acknowledgement is required for ${due.length} requests. Immediately, before doing the resumed work or ending this turn, run every command below${acknowledgementDemand(anyTextRequired)}:\n` +
|
|
1526
1702
|
commands
|
|
1527
1703
|
)
|
|
1528
1704
|
}
|
|
@@ -1585,7 +1761,11 @@ function stageAcceptedAnswers(
|
|
|
1585
1761
|
requestId !== undefined &&
|
|
1586
1762
|
!acknowledgementDue.some((entry) => entry.request_id === requestId)
|
|
1587
1763
|
) {
|
|
1588
|
-
acknowledgementDue.push({
|
|
1764
|
+
acknowledgementDue.push({
|
|
1765
|
+
request_id: requestId,
|
|
1766
|
+
recorded_at: accepted.recorded_at,
|
|
1767
|
+
text_required: answer.agent_acknowledgement_text_required !== false,
|
|
1768
|
+
})
|
|
1589
1769
|
}
|
|
1590
1770
|
}
|
|
1591
1771
|
const next: SessionState = {
|
|
@@ -1633,14 +1813,83 @@ export function clearAcknowledgementObligation(
|
|
|
1633
1813
|
|
|
1634
1814
|
function acknowledgementBlockContext(due: readonly AcknowledgementDue[]): string {
|
|
1635
1815
|
const commands = due
|
|
1636
|
-
.map(
|
|
1816
|
+
.map(
|
|
1817
|
+
(entry) =>
|
|
1818
|
+
`- ${entry.request_id}: \`${acknowledgementCommand(entry.request_id, entry.text_required !== false)}\``,
|
|
1819
|
+
)
|
|
1637
1820
|
.join('\n')
|
|
1821
|
+
const anyTextRequired = due.some((entry) => entry.text_required !== false)
|
|
1638
1822
|
return (
|
|
1639
1823
|
`Notifai — required Agent Acknowledgement${due.length === 1 ? '' : 's'} still missing for request${due.length === 1 ? '' : 's'} ${due.map((entry) => entry.request_id).join(', ')}. ` +
|
|
1640
|
-
`Before doing more resumed work or ending this turn, run ${due.length === 1 ? 'this command' : 'every command'}
|
|
1824
|
+
`Before doing more resumed work or ending this turn, run ${due.length === 1 ? 'this command' : 'every command'}${acknowledgementDemand(anyTextRequired)}:\n${commands}`
|
|
1641
1825
|
)
|
|
1642
1826
|
}
|
|
1643
1827
|
|
|
1828
|
+
/**
|
|
1829
|
+
* How many turns in a row a session may be held waiting for an acknowledgement.
|
|
1830
|
+
*
|
|
1831
|
+
* The gate is the one place hooks deliberately break the fail-open rule, and it
|
|
1832
|
+
* had no bound at all: an agent that could not or would not acknowledge — or a
|
|
1833
|
+
* server that stayed unreachable, since an error here counts as unresolved —
|
|
1834
|
+
* held every turn of that session for ever. Blocking a user's agent
|
|
1835
|
+
* indefinitely does not get them an acknowledgement; it costs them the agent as
|
|
1836
|
+
* well as the acknowledgement.
|
|
1837
|
+
*/
|
|
1838
|
+
const MAX_ACKNOWLEDGEMENT_BLOCKS = 3
|
|
1839
|
+
|
|
1840
|
+
/**
|
|
1841
|
+
* Hold the turn for an outstanding acknowledgement, or give up and let it
|
|
1842
|
+
* through once holding has stopped being worth its cost.
|
|
1843
|
+
*
|
|
1844
|
+
* Returns the outcome that blocks the turn, or `null` to carry on.
|
|
1845
|
+
*/
|
|
1846
|
+
function holdForAcknowledgement(
|
|
1847
|
+
ctx: HookContext,
|
|
1848
|
+
sessionId: string,
|
|
1849
|
+
due: readonly AcknowledgementDue[],
|
|
1850
|
+
notes: string[],
|
|
1851
|
+
): HookOutcome | null {
|
|
1852
|
+
if (due.length === 0) return null
|
|
1853
|
+
const requestIds = due.map((entry) => entry.request_id)
|
|
1854
|
+
const blocks = (readSessionState(sessionId, ctx.env).acknowledgement_blocks ?? 0) + 1
|
|
1855
|
+
if (blocks > MAX_ACKNOWLEDGEMENT_BLOCKS) {
|
|
1856
|
+
// Drop the obligation with the reason recorded. The answer was already
|
|
1857
|
+
// delivered; what is lost is the agent's receipt for it, and the log is
|
|
1858
|
+
// where that loss stays visible.
|
|
1859
|
+
for (const requestId of requestIds) clearAcknowledgementObligation(sessionId, ctx.env, requestId)
|
|
1860
|
+
resetAcknowledgementBlocks(sessionId, ctx.env)
|
|
1861
|
+
gate(ctx, 'proceeding', 'acknowledgement-abandoned', {
|
|
1862
|
+
request_ids: requestIds,
|
|
1863
|
+
blocks: blocks - 1,
|
|
1864
|
+
limit: MAX_ACKNOWLEDGEMENT_BLOCKS,
|
|
1865
|
+
})
|
|
1866
|
+
notes.push(
|
|
1867
|
+
`no acknowledgement after ${MAX_ACKNOWLEDGEMENT_BLOCKS} turns; continuing without one rather than holding this session for ever`,
|
|
1868
|
+
)
|
|
1869
|
+
return null
|
|
1870
|
+
}
|
|
1871
|
+
updateSessionState(sessionId, ctx.env, (current) => ({
|
|
1872
|
+
...current,
|
|
1873
|
+
acknowledgement_blocks: blocks,
|
|
1874
|
+
}))
|
|
1875
|
+
gate(ctx, 'held', 'acknowledgement-required', { request_ids: requestIds, blocks })
|
|
1876
|
+
return {
|
|
1877
|
+
stdout: stopAnswerOutput(acknowledgementBlockContext(due)),
|
|
1878
|
+
notes,
|
|
1879
|
+
log: { stage: 'acknowledgement-required', request_ids: requestIds },
|
|
1880
|
+
}
|
|
1881
|
+
}
|
|
1882
|
+
|
|
1883
|
+
/** A turn that was not held resets the streak; only consecutive holds count. */
|
|
1884
|
+
function resetAcknowledgementBlocks(sessionId: string, env: NodeJS.ProcessEnv): void {
|
|
1885
|
+
updateSessionState(sessionId, env, (current) => {
|
|
1886
|
+
if (current.acknowledgement_blocks === undefined) return current
|
|
1887
|
+
const next = { ...current }
|
|
1888
|
+
delete next.acknowledgement_blocks
|
|
1889
|
+
return next
|
|
1890
|
+
})
|
|
1891
|
+
}
|
|
1892
|
+
|
|
1644
1893
|
async function reconcileAcknowledgementObligations(
|
|
1645
1894
|
ctx: HookContext,
|
|
1646
1895
|
sessionId: string,
|
|
@@ -1686,9 +1935,10 @@ function settleAcceptedAnswers(
|
|
|
1686
1935
|
ctx: HookContext,
|
|
1687
1936
|
sessionId: string,
|
|
1688
1937
|
accepted: AcceptedAnswerDelivery,
|
|
1938
|
+
cwd?: string,
|
|
1689
1939
|
): void {
|
|
1690
1940
|
const retirements = accepted.answers
|
|
1691
|
-
.map(({ pending }) => retiringQuestion(pending, 'answered'))
|
|
1941
|
+
.map(({ pending }) => retiringQuestion(pending, 'answered', cwd))
|
|
1692
1942
|
.filter((entry): entry is RetiringQuestion => entry !== null)
|
|
1693
1943
|
updateSessionState(sessionId, ctx.env, (current) => {
|
|
1694
1944
|
const retiring = [...(current.retiring ?? [])]
|
|
@@ -1717,7 +1967,7 @@ async function retirePendings(
|
|
|
1717
1967
|
updateSessionState(sessionId, ctx.env, (current) => {
|
|
1718
1968
|
const retiring = [...(current.retiring ?? [])]
|
|
1719
1969
|
for (const entry of entries) {
|
|
1720
|
-
const retirement = retiringQuestion(entry, state)
|
|
1970
|
+
const retirement = retiringQuestion(entry, state, envelope.cwd)
|
|
1721
1971
|
if (retirement !== null && !retiring.some((item) => item.request_id === retirement.request_id)) {
|
|
1722
1972
|
retiring.push(retirement)
|
|
1723
1973
|
}
|
|
@@ -1800,6 +2050,9 @@ export async function handleUserPromptSubmit(
|
|
|
1800
2050
|
agent_acknowledgement_required:
|
|
1801
2051
|
response?.agent_acknowledgement_required ??
|
|
1802
2052
|
entry.agent_acknowledgement_required,
|
|
2053
|
+
agent_acknowledgement_text_required:
|
|
2054
|
+
response?.agent_acknowledgement_text_required ??
|
|
2055
|
+
entry.agent_acknowledgement_text_required,
|
|
1803
2056
|
}
|
|
1804
2057
|
})
|
|
1805
2058
|
if (answered.length > 0) {
|
|
@@ -1824,7 +2077,7 @@ export async function handleUserPromptSubmit(
|
|
|
1824
2077
|
const retiring = [...(current.retiring ?? [])]
|
|
1825
2078
|
const unasked = pendingList(current).filter((entry) => entry.request_id === undefined)
|
|
1826
2079
|
for (const entry of pendingList(current).filter((entry) => entry.request_id !== undefined)) {
|
|
1827
|
-
const retirement = retiringQuestion(entry, 'answered_elsewhere')
|
|
2080
|
+
const retirement = retiringQuestion(entry, 'answered_elsewhere', envelope.cwd)
|
|
1828
2081
|
if (
|
|
1829
2082
|
retirement !== null &&
|
|
1830
2083
|
!retiring.some((parked) => parked.request_id === retirement.request_id)
|
|
@@ -1832,21 +2085,24 @@ export async function handleUserPromptSubmit(
|
|
|
1832
2085
|
retiring.push(retirement)
|
|
1833
2086
|
}
|
|
1834
2087
|
}
|
|
1835
|
-
|
|
1836
|
-
|
|
1837
|
-
|
|
1838
|
-
|
|
1839
|
-
|
|
1840
|
-
|
|
1841
|
-
|
|
1842
|
-
|
|
1843
|
-
|
|
1844
|
-
|
|
1845
|
-
|
|
1846
|
-
|
|
1847
|
-
|
|
1848
|
-
|
|
2088
|
+
|
|
2089
|
+
// Start from the whole document so fields introduced by a newer CLI remain
|
|
2090
|
+
// attached while this older writer applies the prompt transition it knows.
|
|
2091
|
+
const next: SessionState = { ...current, last_prompt_at: ctx.now() }
|
|
2092
|
+
if (unasked.length > 0) next.pending = unasked
|
|
2093
|
+
else delete next.pending
|
|
2094
|
+
if (retiring.length > 0) next.retiring = retiring
|
|
2095
|
+
else delete next.retiring
|
|
2096
|
+
|
|
2097
|
+
// The user typed, so whatever chain of answer-driven continuations was
|
|
2098
|
+
// running, a human has taken the turn and the consecutive count starts
|
|
2099
|
+
// over. Only a real prompt reaches here: the journal branch above returns
|
|
2100
|
+
// first, and a wake route's injected message always arrives while its own
|
|
2101
|
+
// answer is still journaled, so it can never pass for the user's presence.
|
|
2102
|
+
if (current.continuation !== undefined) {
|
|
2103
|
+
next.continuation = { ...current.continuation, count: 0 }
|
|
1849
2104
|
}
|
|
2105
|
+
return next
|
|
1850
2106
|
})
|
|
1851
2107
|
// The bridge that lets a plain `notifai ask` find the hook's canonical
|
|
1852
2108
|
// session: an agent shell command gets no hook payload, and not every
|
|
@@ -1970,9 +2226,16 @@ export async function runEscalationWaiter(
|
|
|
1970
2226
|
accepted.delivered_at !== undefined || envelope.stop_hook_active === true
|
|
1971
2227
|
if (!deliveryProven) {
|
|
1972
2228
|
for (const answer of accepted.answers) reportAnswer(ctx, notes, answer, true)
|
|
1973
|
-
return await deliverAcceptedAnswers(
|
|
2229
|
+
return await deliverAcceptedAnswers(
|
|
2230
|
+
ctx,
|
|
2231
|
+
sessionId,
|
|
2232
|
+
options.route,
|
|
2233
|
+
accepted,
|
|
2234
|
+
notes,
|
|
2235
|
+
envelope.cwd,
|
|
2236
|
+
)
|
|
1974
2237
|
}
|
|
1975
|
-
settleAcceptedAnswers(ctx, sessionId, accepted)
|
|
2238
|
+
settleAcceptedAnswers(ctx, sessionId, accepted, envelope.cwd)
|
|
1976
2239
|
settledAnswerThisPass = true
|
|
1977
2240
|
state = readSessionState(sessionId, ctx.env)
|
|
1978
2241
|
const currentAcceptedIds = new Set(
|
|
@@ -1987,19 +2250,8 @@ export async function runEscalationWaiter(
|
|
|
1987
2250
|
currentAcceptedIds.has(entry.request_id),
|
|
1988
2251
|
),
|
|
1989
2252
|
)
|
|
1990
|
-
|
|
1991
|
-
|
|
1992
|
-
request_ids: due.map((entry) => entry.request_id),
|
|
1993
|
-
})
|
|
1994
|
-
return {
|
|
1995
|
-
stdout: stopAnswerOutput(acknowledgementBlockContext(due)),
|
|
1996
|
-
notes,
|
|
1997
|
-
log: {
|
|
1998
|
-
stage: 'acknowledgement-required',
|
|
1999
|
-
request_ids: due.map((entry) => entry.request_id),
|
|
2000
|
-
},
|
|
2001
|
-
}
|
|
2002
|
-
}
|
|
2253
|
+
const held = holdForAcknowledgement(ctx, sessionId, due, notes)
|
|
2254
|
+
if (held !== null) return held
|
|
2003
2255
|
state = readSessionState(sessionId, ctx.env)
|
|
2004
2256
|
}
|
|
2005
2257
|
|
|
@@ -2009,19 +2261,9 @@ export async function runEscalationWaiter(
|
|
|
2009
2261
|
sessionId,
|
|
2010
2262
|
state.acknowledgement_due!,
|
|
2011
2263
|
)
|
|
2012
|
-
|
|
2013
|
-
|
|
2014
|
-
|
|
2015
|
-
})
|
|
2016
|
-
return {
|
|
2017
|
-
stdout: stopAnswerOutput(acknowledgementBlockContext(due)),
|
|
2018
|
-
notes,
|
|
2019
|
-
log: {
|
|
2020
|
-
stage: 'acknowledgement-required',
|
|
2021
|
-
request_ids: due.map((entry) => entry.request_id),
|
|
2022
|
-
},
|
|
2023
|
-
}
|
|
2024
|
-
}
|
|
2264
|
+
const held = holdForAcknowledgement(ctx, sessionId, due, notes)
|
|
2265
|
+
if (held !== null) return held
|
|
2266
|
+
resetAcknowledgementBlocks(sessionId, ctx.env)
|
|
2025
2267
|
state = readSessionState(sessionId, ctx.env)
|
|
2026
2268
|
}
|
|
2027
2269
|
|
|
@@ -2089,9 +2331,24 @@ async function deliverAcceptedAnswers(
|
|
|
2089
2331
|
route: EscalationDeliveryRoute,
|
|
2090
2332
|
accepted: AcceptedAnswerDelivery,
|
|
2091
2333
|
notes: string[],
|
|
2334
|
+
cwd?: string,
|
|
2092
2335
|
): Promise<HookOutcome> {
|
|
2093
2336
|
const { answers: answered, remaining } = accepted
|
|
2094
2337
|
const requestIds = summarizeRequestIds(answered.map((entry) => entry.pending)).ids
|
|
2338
|
+
const held = accepted.held_deliveries ?? 0
|
|
2339
|
+
if (held >= MAX_HELD_DELIVERIES) {
|
|
2340
|
+
gate(ctx, 'held', 'delivery-limit', {
|
|
2341
|
+
route: route.kind,
|
|
2342
|
+
held,
|
|
2343
|
+
limit: MAX_HELD_DELIVERIES,
|
|
2344
|
+
request_ids: requestIds,
|
|
2345
|
+
})
|
|
2346
|
+
settleAcceptedAnswers(ctx, sessionId, accepted, cwd)
|
|
2347
|
+
notes.push(
|
|
2348
|
+
`no route could hand the user's answer over in ${MAX_HELD_DELIVERIES} turns; not holding it any longer`,
|
|
2349
|
+
)
|
|
2350
|
+
return { notes }
|
|
2351
|
+
}
|
|
2095
2352
|
const attempt = (accepted.delivery_attempts ?? 0) + 1
|
|
2096
2353
|
if (attempt > MAX_CONTINUATION_COUNT) {
|
|
2097
2354
|
gate(ctx, 'held', 'delivery-limit', {
|
|
@@ -2103,7 +2360,7 @@ async function deliverAcceptedAnswers(
|
|
|
2103
2360
|
// Settle rather than journal for ever. The answer has been offered as often
|
|
2104
2361
|
// as any working route could need, it is already in the notes of each of
|
|
2105
2362
|
// those turns, and the questions it answered still deserve retirement.
|
|
2106
|
-
settleAcceptedAnswers(ctx, sessionId, accepted)
|
|
2363
|
+
settleAcceptedAnswers(ctx, sessionId, accepted, cwd)
|
|
2107
2364
|
notes.push(
|
|
2108
2365
|
`the user's answer reached the ${route.kind} route ${MAX_CONTINUATION_COUNT} times without being acknowledged; not delivering it again`,
|
|
2109
2366
|
)
|
|
@@ -2142,6 +2399,17 @@ async function deliverAcceptedAnswers(
|
|
|
2142
2399
|
delivered_at: ctx.now(),
|
|
2143
2400
|
delivered_route: deliveredRoute,
|
|
2144
2401
|
}))
|
|
2402
|
+
} else if (delivered.acknowledgement === 'held') {
|
|
2403
|
+
// Give the delivery attempt back and count the hold instead. The attempt
|
|
2404
|
+
// was counted before the call because one that dies mid-delivery still
|
|
2405
|
+
// happened and a counter written afterwards could be evaded by crashing —
|
|
2406
|
+
// but a route that *returns* `held` proves both that this process survived
|
|
2407
|
+
// and that nothing was handed over.
|
|
2408
|
+
amendAcceptedAnswers(ctx, sessionId, (current) => ({
|
|
2409
|
+
...current,
|
|
2410
|
+
delivery_attempts: accepted.delivery_attempts ?? 0,
|
|
2411
|
+
held_deliveries: (current.held_deliveries ?? 0) + 1,
|
|
2412
|
+
}))
|
|
2145
2413
|
}
|
|
2146
2414
|
ctx.log?.info('hook.answer', {
|
|
2147
2415
|
answered: true,
|
|
@@ -2182,7 +2450,11 @@ async function handleClaimedStop(
|
|
|
2182
2450
|
) {
|
|
2183
2451
|
for (const entry of live) await closeQuietly(ctx, entry.request_id!)
|
|
2184
2452
|
await retirePendings(ctx, envelope, sessionId, pending, 'expired')
|
|
2185
|
-
|
|
2453
|
+
// Not `no-question`: there was a question, and this says so. Reading the
|
|
2454
|
+
// log to find out why an ask never travelled is the whole reason these
|
|
2455
|
+
// reasons exist, and the wrong one sent that reader looking for a question
|
|
2456
|
+
// that was right there.
|
|
2457
|
+
gate(ctx, 'held', 'harness-cannot-continue', { harness: ctx.harness })
|
|
2186
2458
|
notes.push(`${HARNESS_CAPABILITIES[ctx.harness].deliveryContract}; use a blocking \`notifai send --reply\` question`)
|
|
2187
2459
|
return { notes }
|
|
2188
2460
|
}
|
|
@@ -2224,6 +2496,9 @@ async function handleClaimedStop(
|
|
|
2224
2496
|
agent_acknowledgement_required:
|
|
2225
2497
|
response?.agent_acknowledgement_required ??
|
|
2226
2498
|
entry.agent_acknowledgement_required,
|
|
2499
|
+
agent_acknowledgement_text_required:
|
|
2500
|
+
response?.agent_acknowledgement_text_required ??
|
|
2501
|
+
entry.agent_acknowledgement_text_required,
|
|
2227
2502
|
}
|
|
2228
2503
|
})
|
|
2229
2504
|
const accepted = stageAcceptedAnswers(
|
|
@@ -2235,7 +2510,7 @@ async function handleClaimedStop(
|
|
|
2235
2510
|
for (const answer of authoritative) reportAnswer(ctx, notes, answer, true)
|
|
2236
2511
|
// Anything still unasked rides the next Stop: the agent is being resumed
|
|
2237
2512
|
// with answers right now, and may not even need the rest afterwards.
|
|
2238
|
-
return deliverAcceptedAnswers(ctx, sessionId, route, accepted, notes)
|
|
2513
|
+
return deliverAcceptedAnswers(ctx, sessionId, route, accepted, notes, envelope.cwd)
|
|
2239
2514
|
}
|
|
2240
2515
|
const recoverableLive = live.filter(
|
|
2241
2516
|
(entry) => !permanentFailures.has(entry.request_id!),
|
|
@@ -2352,7 +2627,7 @@ async function escalate(
|
|
|
2352
2627
|
await awaitTerminalFirstWindow(ctx, oldest, ceilingAt)
|
|
2353
2628
|
ctx.log?.debug('hook.gate', {
|
|
2354
2629
|
verdict: 'grace',
|
|
2355
|
-
reason: 'elapsed',
|
|
2630
|
+
reason: 'elapsed' satisfies GateReason,
|
|
2356
2631
|
waited_from: oldest,
|
|
2357
2632
|
grace_seconds: ctx.config.ask_grace_seconds.value,
|
|
2358
2633
|
})
|
|
@@ -2361,18 +2636,28 @@ async function escalate(
|
|
|
2361
2636
|
// its own notification — one ask never stands in for another.
|
|
2362
2637
|
const submitted: PendingQuestion[] = []
|
|
2363
2638
|
for (const entry of unasked) {
|
|
2364
|
-
//
|
|
2365
|
-
//
|
|
2366
|
-
//
|
|
2367
|
-
|
|
2368
|
-
|
|
2639
|
+
// How long the answer is accepted, and how long this owner listens for it,
|
|
2640
|
+
// are two different spans. They used to be one: the window was whatever
|
|
2641
|
+
// remained of the waiter's ceiling, so a question asked at a turn's end
|
|
2642
|
+
// stopped being answerable about eight minutes later, whatever the user
|
|
2643
|
+
// had configured.
|
|
2644
|
+
//
|
|
2645
|
+
// That was right when the waiter was the only way an answer came back. It
|
|
2646
|
+
// is not any more: a late answer is found by the next turn's poll or
|
|
2647
|
+
// replayed from the journal, so this owner giving up listening says
|
|
2648
|
+
// nothing about whether the user's answer is still wanted.
|
|
2649
|
+
const replyWindowSeconds = ctx.config.reply_window_seconds.value
|
|
2650
|
+
if (ceilingAt - ctx.now() < MIN_REPLY_WINDOW_SECONDS * 1000) {
|
|
2369
2651
|
notes.push(
|
|
2370
|
-
'too little of the waiter ceiling is left
|
|
2652
|
+
'too little of the waiter ceiling is left to submit and confirm this question; leaving it in the terminal',
|
|
2371
2653
|
)
|
|
2372
2654
|
continue
|
|
2373
2655
|
}
|
|
2656
|
+
// The owner still stops listening at its own ceiling. Only the span the
|
|
2657
|
+
// server accepts answers into changed.
|
|
2374
2658
|
const ownerDeadlineAt = ceilingAt
|
|
2375
2659
|
const questions = pendingQuestions(entry)
|
|
2660
|
+
const eventSource = sourceContextAtHookEvent(entry.source, envelope.cwd)
|
|
2376
2661
|
let intent = entry.submission
|
|
2377
2662
|
if (intent === undefined) {
|
|
2378
2663
|
if (entry.body === undefined) {
|
|
@@ -2386,7 +2671,7 @@ async function escalate(
|
|
|
2386
2671
|
questions,
|
|
2387
2672
|
...(entry.media !== undefined ? { media: entry.media } : {}),
|
|
2388
2673
|
...(entry.project !== undefined ? { project: entry.project } : {}),
|
|
2389
|
-
...(
|
|
2674
|
+
...(eventSource !== undefined ? { source: eventSource } : {}),
|
|
2390
2675
|
event: 'agent_question',
|
|
2391
2676
|
windowSeconds: replyWindowSeconds,
|
|
2392
2677
|
ownerDeadlineAt,
|
|
@@ -2439,12 +2724,80 @@ async function escalate(
|
|
|
2439
2724
|
try {
|
|
2440
2725
|
receipt = await submitQuestion(ctx, intent)
|
|
2441
2726
|
admissionConfirmed = true
|
|
2442
|
-
} catch (
|
|
2443
|
-
|
|
2444
|
-
|
|
2445
|
-
|
|
2446
|
-
|
|
2447
|
-
|
|
2727
|
+
} catch (caught) {
|
|
2728
|
+
let err: unknown = caught
|
|
2729
|
+
if (err instanceof ApiCallError && isTerminalDraftRejection(err)) {
|
|
2730
|
+
ctx.log?.error('hook.pushed', {
|
|
2731
|
+
ok: false,
|
|
2732
|
+
request_id: intent.request_id,
|
|
2733
|
+
status: err.status,
|
|
2734
|
+
code: err.code,
|
|
2735
|
+
message: err.message,
|
|
2736
|
+
})
|
|
2737
|
+
if (entry.submission !== undefined) {
|
|
2738
|
+
notes.push(
|
|
2739
|
+
`question submission was rejected (${err.code}, HTTP ${err.status}); reminting the draft in the current contract instead of replaying the frozen one`,
|
|
2740
|
+
)
|
|
2741
|
+
clearFrozenSubmission(sessionId, ctx.env, entry)
|
|
2742
|
+
if (entry.body === undefined) {
|
|
2743
|
+
dropPendingQuestion(sessionId, ctx.env, entry)
|
|
2744
|
+
notes.push('registered question has no canonical body; retired the frozen draft instead of retrying it')
|
|
2745
|
+
continue
|
|
2746
|
+
}
|
|
2747
|
+
const reminted = await prepareQuestionSubmission(ctx, {
|
|
2748
|
+
title: questions[0]!.text,
|
|
2749
|
+
body: entry.body,
|
|
2750
|
+
questions,
|
|
2751
|
+
...(entry.media !== undefined ? { media: entry.media } : {}),
|
|
2752
|
+
...(entry.project !== undefined ? { project: entry.project } : {}),
|
|
2753
|
+
...(eventSource !== undefined ? { source: eventSource } : {}),
|
|
2754
|
+
event: 'agent_question',
|
|
2755
|
+
windowSeconds: replyWindowSeconds,
|
|
2756
|
+
ownerDeadlineAt,
|
|
2757
|
+
})
|
|
2758
|
+
if ('error' in reminted) {
|
|
2759
|
+
dropPendingQuestion(sessionId, ctx.env, entry)
|
|
2760
|
+
notes.push(reminted.error)
|
|
2761
|
+
continue
|
|
2762
|
+
}
|
|
2763
|
+
intent = reminted
|
|
2764
|
+
updateSessionState(sessionId, ctx.env, (current) => {
|
|
2765
|
+
const list = pendingList(current)
|
|
2766
|
+
const index = list.findIndex((candidate) => isSamePending(candidate, entry))
|
|
2767
|
+
if (index < 0) return current
|
|
2768
|
+
const next = [...list]
|
|
2769
|
+
next[index] = { ...next[index]!, submission: reminted }
|
|
2770
|
+
return { ...current, pending: next }
|
|
2771
|
+
})
|
|
2772
|
+
try {
|
|
2773
|
+
receipt = await submitQuestion(ctx, intent)
|
|
2774
|
+
admissionConfirmed = true
|
|
2775
|
+
} catch (retryErr) {
|
|
2776
|
+
if (retryErr instanceof ApiCallError && isTerminalDraftRejection(retryErr)) {
|
|
2777
|
+
dropPendingQuestion(sessionId, ctx.env, entry)
|
|
2778
|
+
notes.push(
|
|
2779
|
+
`reminted draft was also rejected (${retryErr.code}, HTTP ${retryErr.status}); retiring the question so it is not retried forever`,
|
|
2780
|
+
)
|
|
2781
|
+
ctx.log?.error('hook.pushed', {
|
|
2782
|
+
ok: false,
|
|
2783
|
+
request_id: intent.request_id,
|
|
2784
|
+
status: retryErr.status,
|
|
2785
|
+
code: retryErr.code,
|
|
2786
|
+
message: retryErr.message,
|
|
2787
|
+
})
|
|
2788
|
+
continue
|
|
2789
|
+
}
|
|
2790
|
+
err = retryErr
|
|
2791
|
+
}
|
|
2792
|
+
} else {
|
|
2793
|
+
dropPendingQuestion(sessionId, ctx.env, entry)
|
|
2794
|
+
notes.push(
|
|
2795
|
+
`question submission was rejected (${err.code}, HTTP ${err.status}); retiring it because the current draft will never be accepted`,
|
|
2796
|
+
)
|
|
2797
|
+
continue
|
|
2798
|
+
}
|
|
2799
|
+
}
|
|
2800
|
+
if (!admissionConfirmed && err instanceof ApiCallError && err.status < 500 && err.status !== 408) {
|
|
2448
2801
|
notes.push(
|
|
2449
2802
|
`question submission was rejected (${err.code}, HTTP ${err.status}); preserving it for recovery`,
|
|
2450
2803
|
)
|
|
@@ -2457,11 +2810,10 @@ async function escalate(
|
|
|
2457
2810
|
})
|
|
2458
2811
|
continue
|
|
2459
2812
|
}
|
|
2460
|
-
|
|
2461
|
-
|
|
2462
|
-
|
|
2463
|
-
|
|
2464
|
-
ctx.log?.error('hook.pushed', { ok: false, request_id: intent.request_id, message: String(err) })
|
|
2813
|
+
if (!admissionConfirmed) {
|
|
2814
|
+
notes.push(`question submission response was ambiguous; recovering ${intent.request_id}`)
|
|
2815
|
+
ctx.log?.error('hook.pushed', { ok: false, request_id: intent.request_id, message: String(err) })
|
|
2816
|
+
}
|
|
2465
2817
|
}
|
|
2466
2818
|
if (admissionConfirmed) {
|
|
2467
2819
|
ctx.log?.info('hook.pushed', {
|
|
@@ -2478,20 +2830,27 @@ async function escalate(
|
|
|
2478
2830
|
: Date.parse(receipt.reply_expires_at)
|
|
2479
2831
|
const live: PendingQuestion = {
|
|
2480
2832
|
...entry,
|
|
2833
|
+
...(intent.draft.source !== undefined ? { source: intent.draft.source } : {}),
|
|
2481
2834
|
request_id: intent.request_id,
|
|
2482
2835
|
collapse_key: intent.collapse_key,
|
|
2483
2836
|
device_ids: intent.device_ids,
|
|
2484
|
-
//
|
|
2485
|
-
//
|
|
2486
|
-
//
|
|
2487
|
-
//
|
|
2488
|
-
|
|
2489
|
-
|
|
2490
|
-
|
|
2491
|
-
|
|
2492
|
-
|
|
2493
|
-
),
|
|
2837
|
+
// When the server stops accepting an answer — not when this owner stops
|
|
2838
|
+
// waiting for one. Those were the same number until the window became a
|
|
2839
|
+
// setting, and taking the minimum silently capped every question at the
|
|
2840
|
+
// waiter's ceiling: a question asked with a day-long window was swept as
|
|
2841
|
+
// stale about eight minutes later and retired, so the answer given over
|
|
2842
|
+
// lunch met a closed question.
|
|
2843
|
+
//
|
|
2844
|
+
// Both consumers of this field already clamp to the owner's own ceiling
|
|
2845
|
+
// (`Math.min(hardDeadlineAt, …)` when deriving the ceiling, and
|
|
2846
|
+
// `Math.min(ceilingAt, …)` when deriving the wait), so recording the
|
|
2847
|
+
// real expiry here lengthens what the user gets without lengthening what
|
|
2848
|
+
// this process waits.
|
|
2849
|
+
reply_deadline_at: Number.isFinite(committedReplyDeadline)
|
|
2850
|
+
? committedReplyDeadline
|
|
2851
|
+
: intent.owner_deadline_at,
|
|
2494
2852
|
}
|
|
2853
|
+
if (intent.draft.source === undefined) delete live.source
|
|
2495
2854
|
delete live.submission
|
|
2496
2855
|
submitted.push(live)
|
|
2497
2856
|
// Record what is now live on the user's devices BEFORE any wait. If we
|
|
@@ -2513,7 +2872,7 @@ async function escalate(
|
|
|
2513
2872
|
// The entry vanished while the submit was in flight (the user's prompt
|
|
2514
2873
|
// wiped the queue). The delivered notification must still be retirable,
|
|
2515
2874
|
// so park it rather than lose its only identifiers.
|
|
2516
|
-
const retirement = retiringQuestion(live, 'answered_elsewhere')!
|
|
2875
|
+
const retirement = retiringQuestion(live, 'answered_elsewhere', envelope.cwd)!
|
|
2517
2876
|
const retiring = [...(current.retiring ?? [])]
|
|
2518
2877
|
if (!retiring.some((parked) => parked.request_id === retirement.request_id)) {
|
|
2519
2878
|
retiring.push(retirement)
|
|
@@ -2553,7 +2912,7 @@ async function escalate(
|
|
|
2553
2912
|
)
|
|
2554
2913
|
const accepted = stageAcceptedAnswers(ctx, sessionId, staleAnswers, remaining)
|
|
2555
2914
|
for (const answer of staleAnswers) reportAnswer(ctx, notes, answer, true)
|
|
2556
|
-
return deliverAcceptedAnswers(ctx, sessionId, route, accepted, notes)
|
|
2915
|
+
return deliverAcceptedAnswers(ctx, sessionId, route, accepted, notes, envelope.cwd)
|
|
2557
2916
|
}
|
|
2558
2917
|
const waitingOn = [
|
|
2559
2918
|
...alreadyLive.filter(
|
|
@@ -2578,10 +2937,37 @@ async function escalate(
|
|
|
2578
2937
|
if (permanentFailure !== null) notes.push(permanentFailure)
|
|
2579
2938
|
|
|
2580
2939
|
if (waited.byRequest.size === 0) {
|
|
2581
|
-
//
|
|
2582
|
-
//
|
|
2583
|
-
//
|
|
2584
|
-
|
|
2940
|
+
// This owner is done listening. That used to mean the question was over:
|
|
2941
|
+
// it closed the window server-side and retired the record, on the reasoning
|
|
2942
|
+
// that no later answer could reach the same agent turn.
|
|
2943
|
+
//
|
|
2944
|
+
// The reaching was never the point — the *accepting* is. A question whose
|
|
2945
|
+
// window is still open is polled again by the next prompt and the next
|
|
2946
|
+
// Stop, and an answer found there is journaled and replayed. Closing here
|
|
2947
|
+
// capped every question at this owner's ceiling however long a window the
|
|
2948
|
+
// user had configured, which is what made a day-long window mean eight
|
|
2949
|
+
// minutes.
|
|
2950
|
+
//
|
|
2951
|
+
// So: finalize only what the server has genuinely stopped accepting, and
|
|
2952
|
+
// leave the rest live for the turn that comes next. A session that truly
|
|
2953
|
+
// ends still retires its questions — that is what the session-end hook is
|
|
2954
|
+
// for, and it is the honest place for it.
|
|
2955
|
+
const expired = waitingOn.filter(
|
|
2956
|
+
(entry) =>
|
|
2957
|
+
entry.reply_deadline_at === undefined ||
|
|
2958
|
+
entry.reply_deadline_at <= ctx.now() ||
|
|
2959
|
+
// A permanent rejection is the server saying this question can never be
|
|
2960
|
+
// answered, whatever its window says. Leaving it open would strand a
|
|
2961
|
+
// dead question on the user's devices.
|
|
2962
|
+
waited.permanentFailures.has(entry.request_id!),
|
|
2963
|
+
)
|
|
2964
|
+
const stillAnswerable = waitingOn.filter((entry) => !expired.includes(entry))
|
|
2965
|
+
if (stillAnswerable.length > 0) {
|
|
2966
|
+
notes.push(
|
|
2967
|
+
`${stillAnswerable.length} question${stillAnswerable.length === 1 ? ' is' : 's are'} still answerable; leaving ${stillAnswerable.length === 1 ? 'it' : 'them'} open for the next turn rather than closing ${stillAnswerable.length === 1 ? 'it' : 'them'} with this waiter`,
|
|
2968
|
+
)
|
|
2969
|
+
}
|
|
2970
|
+
const finalized = await finalizePendings(ctx, expired)
|
|
2585
2971
|
const finalAnswers = finalized
|
|
2586
2972
|
.map(finalizedAnswer)
|
|
2587
2973
|
.filter((entry): entry is AnsweredPending => entry !== null)
|
|
@@ -2604,7 +2990,7 @@ async function escalate(
|
|
|
2604
2990
|
)
|
|
2605
2991
|
const accepted = stageAcceptedAnswers(ctx, sessionId, finalAnswers, remaining)
|
|
2606
2992
|
for (const answer of finalAnswers) reportAnswer(ctx, notes, answer, false)
|
|
2607
|
-
return deliverAcceptedAnswers(ctx, sessionId, route, accepted, notes)
|
|
2993
|
+
return deliverAcceptedAnswers(ctx, sessionId, route, accepted, notes, envelope.cwd)
|
|
2608
2994
|
}
|
|
2609
2995
|
const requestIdSummary = summarizeRequestIds(waitingOn)
|
|
2610
2996
|
ctx.log?.info('hook.answer', {
|
|
@@ -2643,6 +3029,8 @@ async function escalate(
|
|
|
2643
3029
|
replies: authoritative,
|
|
2644
3030
|
agent_acknowledgement_required:
|
|
2645
3031
|
finalized.response?.agent_acknowledgement_required,
|
|
3032
|
+
agent_acknowledgement_text_required:
|
|
3033
|
+
finalized.response?.agent_acknowledgement_text_required,
|
|
2646
3034
|
})
|
|
2647
3035
|
if (finalized.response === null) {
|
|
2648
3036
|
notes.push(
|
|
@@ -2657,7 +3045,7 @@ async function escalate(
|
|
|
2657
3045
|
waitingOn.length - answered.length,
|
|
2658
3046
|
)
|
|
2659
3047
|
for (const answer of answered) reportAnswer(ctx, notes, answer, false)
|
|
2660
|
-
return deliverAcceptedAnswers(ctx, sessionId, route, accepted, notes)
|
|
3048
|
+
return deliverAcceptedAnswers(ctx, sessionId, route, accepted, notes, envelope.cwd)
|
|
2661
3049
|
}
|
|
2662
3050
|
|
|
2663
3051
|
// ---------------------------------------------------------------------------
|
|
@@ -2686,7 +3074,7 @@ export function handleSessionEnd(
|
|
|
2686
3074
|
const orphans: RetiringQuestion[] = [...(state.retiring ?? [])]
|
|
2687
3075
|
for (const entry of pendingList(state)) {
|
|
2688
3076
|
try {
|
|
2689
|
-
const orphan = retiringQuestion(entry, 'expired')
|
|
3077
|
+
const orphan = retiringQuestion(entry, 'expired', envelope.cwd)
|
|
2690
3078
|
if (orphan !== null) orphans.push(orphan)
|
|
2691
3079
|
} catch (err) {
|
|
2692
3080
|
notes.push(err instanceof Error ? err.message : String(err))
|
|
@@ -2705,14 +3093,13 @@ export function handleSessionEnd(
|
|
|
2705
3093
|
)
|
|
2706
3094
|
}
|
|
2707
3095
|
if (state.accepted !== undefined || (state.acknowledgement_due?.length ?? 0) > 0) {
|
|
2708
|
-
const preserved: SessionState = {}
|
|
2709
|
-
|
|
2710
|
-
|
|
2711
|
-
|
|
3096
|
+
const preserved: SessionState = { ...state }
|
|
3097
|
+
delete preserved.pending
|
|
3098
|
+
delete preserved.retiring
|
|
3099
|
+
delete preserved.acknowledgement_blocks
|
|
3100
|
+
if ((preserved.acknowledgement_due?.length ?? 0) === 0) {
|
|
3101
|
+
delete preserved.acknowledgement_due
|
|
2712
3102
|
}
|
|
2713
|
-
if (state.last_prompt_at !== undefined) preserved.last_prompt_at = state.last_prompt_at
|
|
2714
|
-
if (state.last_stop_at !== undefined) preserved.last_stop_at = state.last_stop_at
|
|
2715
|
-
if (state.continuation !== undefined) preserved.continuation = state.continuation
|
|
2716
3103
|
writeSessionState(sessionId, env, preserved)
|
|
2717
3104
|
notes.push(
|
|
2718
3105
|
state.accepted !== undefined
|
|
@@ -2738,13 +3125,23 @@ export function handleSessionEnd(
|
|
|
2738
3125
|
// ---------------------------------------------------------------------------
|
|
2739
3126
|
|
|
2740
3127
|
/**
|
|
2741
|
-
*
|
|
2742
|
-
*
|
|
2743
|
-
*
|
|
2744
|
-
*
|
|
3128
|
+
* Registrations still waiting for their first push. More than this in one turn
|
|
3129
|
+
* means an agent is looping, not asking, and related questions belong in one
|
|
3130
|
+
* `ask --form` instead.
|
|
3131
|
+
*
|
|
3132
|
+
* Deliberately counts only unasked entries. A question already on the user's
|
|
3133
|
+
* devices is waiting on a person, not on the agent, and an answer stays
|
|
3134
|
+
* accepted for a day — so counting live questions here would have turned a
|
|
3135
|
+
* patient user into a reason the agent could no longer ask anything.
|
|
2745
3136
|
*/
|
|
2746
3137
|
export const MAX_PENDING_QUESTIONS = 4
|
|
2747
3138
|
|
|
3139
|
+
/**
|
|
3140
|
+
* Everything this session has open at once, asked or not. The lock screen is
|
|
3141
|
+
* the shared resource being protected here, not the agent's turn.
|
|
3142
|
+
*/
|
|
3143
|
+
export const MAX_LIVE_QUESTIONS = 10
|
|
3144
|
+
|
|
2748
3145
|
/**
|
|
2749
3146
|
* A session may hold several registered questions at once: a new `ask` never
|
|
2750
3147
|
* ends an earlier one. Each reaches the user as its own notification and is
|
|
@@ -2763,11 +3160,16 @@ export function registerQuestion(
|
|
|
2763
3160
|
question: PendingQuestion,
|
|
2764
3161
|
now: number = Date.now(),
|
|
2765
3162
|
): void {
|
|
2766
|
-
let full =
|
|
3163
|
+
let full: 'unasked' | 'live' | null = null
|
|
2767
3164
|
updateSessionState(sessionId, env, (state) => {
|
|
2768
3165
|
const pending = pendingList(state)
|
|
2769
|
-
|
|
2770
|
-
|
|
3166
|
+
const unasked = pending.filter((entry) => entry.request_id === undefined)
|
|
3167
|
+
if (unasked.length >= MAX_PENDING_QUESTIONS) {
|
|
3168
|
+
full = 'unasked'
|
|
3169
|
+
return state
|
|
3170
|
+
}
|
|
3171
|
+
if (pending.length >= MAX_LIVE_QUESTIONS) {
|
|
3172
|
+
full = 'live'
|
|
2771
3173
|
return state
|
|
2772
3174
|
}
|
|
2773
3175
|
return {
|
|
@@ -2783,12 +3185,18 @@ export function registerQuestion(
|
|
|
2783
3185
|
],
|
|
2784
3186
|
}
|
|
2785
3187
|
})
|
|
2786
|
-
if (full) {
|
|
3188
|
+
if (full === 'unasked') {
|
|
2787
3189
|
throw new Error(
|
|
2788
3190
|
`${MAX_PENDING_QUESTIONS} questions are already waiting to be asked. ` +
|
|
2789
3191
|
'Combine related questions into one `notifai ask --form` instead of registering more.',
|
|
2790
3192
|
)
|
|
2791
3193
|
}
|
|
3194
|
+
if (full === 'live') {
|
|
3195
|
+
throw new Error(
|
|
3196
|
+
`${MAX_LIVE_QUESTIONS} questions from this session are already open. ` +
|
|
3197
|
+
'Retire the ones you no longer need with `notifai close <request_id>` before asking another.',
|
|
3198
|
+
)
|
|
3199
|
+
}
|
|
2792
3200
|
}
|
|
2793
3201
|
|
|
2794
3202
|
/** Parses hook JSON from stdin, tolerating an empty or malformed body. */
|