@raidiant/notifai 0.2.0 → 0.2.1
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/dist/commands.d.ts +1 -1
- package/dist/commands.js +117 -42
- package/dist/commands.js.map +1 -1
- package/dist/hooks.d.ts +31 -19
- package/dist/hooks.js +277 -189
- package/dist/hooks.js.map +1 -1
- package/package.json +8 -8
- package/src/commands.test.ts +70 -7
- package/src/commands.ts +127 -44
- package/src/hooks.test.ts +214 -81
- package/src/hooks.ts +335 -224
package/src/hooks.ts
CHANGED
|
@@ -57,19 +57,26 @@ export interface HookEnvelope {
|
|
|
57
57
|
export interface SessionState {
|
|
58
58
|
/** Epoch ms of the user's last prompt in this session — our presence signal. */
|
|
59
59
|
last_prompt_at?: number
|
|
60
|
-
/**
|
|
61
|
-
|
|
60
|
+
/**
|
|
61
|
+
* Questions registered by `notifai ask`, in registration order, each
|
|
62
|
+
* awaiting escalation or its answer. A list, deliberately: registering a
|
|
63
|
+
* question never ends an earlier one. Superseding is reply semantics — a
|
|
64
|
+
* later reply corrects an earlier reply to the same question — never
|
|
65
|
+
* question semantics; the single-slot model silently discarded a live
|
|
66
|
+
* question the moment a second was registered (2026-08-09).
|
|
67
|
+
*/
|
|
68
|
+
pending?: PendingQuestion[]
|
|
62
69
|
/**
|
|
63
70
|
* Questions that have been delivered to the user's devices and are now dead,
|
|
64
71
|
* but whose retirement has not been confirmed yet.
|
|
65
72
|
*
|
|
66
73
|
* A retirement needs a network call and the moment we learn a question is
|
|
67
|
-
* dead is not always a moment we can make one —
|
|
68
|
-
*
|
|
69
|
-
*
|
|
70
|
-
*
|
|
71
|
-
*
|
|
72
|
-
*
|
|
74
|
+
* dead is not always a moment we can make one — the user's return to the
|
|
75
|
+
* terminal is observed by a bare hook, and the machine may be offline.
|
|
76
|
+
* Dropping the ids there is how a delivered question becomes permanently
|
|
77
|
+
* unretirable, so they are parked here instead and every later hook with a
|
|
78
|
+
* client drains them. Retirement is idempotent, so a duplicate attempt
|
|
79
|
+
* costs nothing and a missed one costs a stale notification for ever.
|
|
73
80
|
*/
|
|
74
81
|
retiring?: RetiringQuestion[]
|
|
75
82
|
/** Tracks bounded Stop continuations so a follow-up ask is delivered once. */
|
|
@@ -640,21 +647,11 @@ export interface HookContext {
|
|
|
640
647
|
|
|
641
648
|
export type HookHarness = 'claude-code' | 'codex' | 'cursor' | 'opencode'
|
|
642
649
|
|
|
643
|
-
interface
|
|
650
|
+
interface SubmittedQuestion {
|
|
644
651
|
requestId: string
|
|
645
652
|
collapseKey: string
|
|
646
|
-
/** The answer to act on: the latest reply, because a later one corrects. */
|
|
647
|
-
reply: ReplyView | null
|
|
648
|
-
/** Every reply in arrival order, for free-text answers given in parts. */
|
|
649
|
-
replies: ReplyView[]
|
|
650
653
|
/** The devices the question went to; retirement must not reach any other. */
|
|
651
654
|
devices: string[]
|
|
652
|
-
/** The wait ended amid network failures, so "no answer" is unproven. */
|
|
653
|
-
degraded: boolean
|
|
654
|
-
/** False when the harness cannot consume a Stop answer and polling was skipped. */
|
|
655
|
-
waited: boolean
|
|
656
|
-
/** The user returned to this machine after the question was pushed. */
|
|
657
|
-
userReturned: boolean
|
|
658
655
|
}
|
|
659
656
|
|
|
660
657
|
/** Re-check local presence at this cadence while a pushed question is waiting. */
|
|
@@ -671,7 +668,11 @@ const REPLY_PRESENCE_POLL_SECONDS = 5
|
|
|
671
668
|
* because nobody has acted on the silence yet and the next turn can still
|
|
672
669
|
* collect the answer with `notifai replies`.
|
|
673
670
|
*/
|
|
674
|
-
|
|
671
|
+
/**
|
|
672
|
+
* Put one registered question on the user's devices. Submission only — the
|
|
673
|
+
* wait happens once, across every live question, in the caller.
|
|
674
|
+
*/
|
|
675
|
+
async function submitQuestion(
|
|
675
676
|
ctx: HookContext,
|
|
676
677
|
options: {
|
|
677
678
|
title: string
|
|
@@ -683,12 +684,9 @@ async function askAndWait(
|
|
|
683
684
|
session?: string | undefined
|
|
684
685
|
/** How long the server keeps accepting an answer. */
|
|
685
686
|
windowSeconds: number
|
|
686
|
-
/** Called once the question is live, before the block begins. */
|
|
687
|
-
onSubmitted?: (live: { requestId: string; collapseKey: string; devices: string[] }) => void
|
|
688
687
|
},
|
|
689
|
-
): Promise<
|
|
688
|
+
): Promise<SubmittedQuestion | { error: string }> {
|
|
690
689
|
const collapseKey = `notifai-hook-${randomBytes(8).toString('base64url')}`
|
|
691
|
-
const timeoutSeconds = ctx.config.hook_reply_timeout_seconds.value
|
|
692
690
|
// A draft carrying `reply` is rejected outright if it targets a device that
|
|
693
691
|
// cannot answer, so resolve the healthy companion platforms explicitly.
|
|
694
692
|
const answerable = await answerableDevices(ctx)
|
|
@@ -714,50 +712,20 @@ async function askAndWait(
|
|
|
714
712
|
{ idempotency_key: `hook-${randomBytes(12).toString('base64url')}`, draft: build.draft },
|
|
715
713
|
0,
|
|
716
714
|
)
|
|
717
|
-
|
|
718
|
-
// learned these ids after the wait, a question that timed out would leave no
|
|
719
|
-
// trace, and the user returning to the terminal could never retire it — the
|
|
720
|
-
// notification would stay answerable for an hour with nobody listening.
|
|
721
|
-
options.onSubmitted?.({
|
|
722
|
-
requestId: receipt.request_id,
|
|
723
|
-
collapseKey,
|
|
724
|
-
devices: answerable,
|
|
725
|
-
})
|
|
726
|
-
if (ctx.harness === 'opencode') {
|
|
727
|
-
return {
|
|
728
|
-
requestId: receipt.request_id,
|
|
729
|
-
collapseKey,
|
|
730
|
-
reply: null,
|
|
731
|
-
replies: [],
|
|
732
|
-
devices: answerable,
|
|
733
|
-
degraded: false,
|
|
734
|
-
waited: false,
|
|
735
|
-
userReturned: false,
|
|
736
|
-
}
|
|
737
|
-
}
|
|
738
|
-
|
|
739
|
-
const result = await waitForReplyWhileAway(ctx, receipt.request_id, timeoutSeconds)
|
|
740
|
-
if (result.replies.length > 0) await closeQuietly(ctx, receipt.request_id)
|
|
741
|
-
return {
|
|
742
|
-
requestId: receipt.request_id,
|
|
743
|
-
collapseKey,
|
|
744
|
-
// The latest reply, not the first: an earlier conflicting answer was
|
|
745
|
-
// corrected by the one that followed it. The cross-device race is still
|
|
746
|
-
// first-answer-claims — that is what the close above enforces.
|
|
747
|
-
reply: result.replies.at(-1) ?? null,
|
|
748
|
-
replies: result.replies,
|
|
749
|
-
devices: answerable,
|
|
750
|
-
degraded: result.degraded,
|
|
751
|
-
waited: true,
|
|
752
|
-
userReturned: result.userReturned,
|
|
753
|
-
}
|
|
715
|
+
return { requestId: receipt.request_id, collapseKey, devices: answerable }
|
|
754
716
|
}
|
|
755
717
|
|
|
756
|
-
|
|
718
|
+
/**
|
|
719
|
+
* One blocking wait across every live question. Each round polls all of them
|
|
720
|
+
* concurrently, so several questions cost the same wall clock as one; the
|
|
721
|
+
* first round that finds any reply returns everything found in that round, and
|
|
722
|
+
* whatever was not answered stays registered.
|
|
723
|
+
*/
|
|
724
|
+
async function waitForAnyReplyWhileAway(
|
|
757
725
|
ctx: HookContext,
|
|
758
|
-
|
|
726
|
+
requestIds: string[],
|
|
759
727
|
timeoutSeconds: number,
|
|
760
|
-
): Promise<{
|
|
728
|
+
): Promise<{ byRequest: Map<string, ReplyView[]>; degraded: boolean; userReturned: boolean }> {
|
|
761
729
|
const deadline = ctx.now() + timeoutSeconds * 1000
|
|
762
730
|
let degraded = false
|
|
763
731
|
let firstPoll = true
|
|
@@ -766,24 +734,34 @@ async function waitForReplyWhileAway(
|
|
|
766
734
|
if (ctx.config.require_idle.value) {
|
|
767
735
|
const idle = ctx.idleSeconds()
|
|
768
736
|
if (idle !== null && idle < ctx.config.away_after_seconds.value) {
|
|
769
|
-
return {
|
|
737
|
+
return { byRequest: new Map(), degraded, userReturned: true }
|
|
770
738
|
}
|
|
771
739
|
}
|
|
772
740
|
|
|
773
741
|
const remainingMs = Math.max(0, deadline - ctx.now())
|
|
774
742
|
if (!firstPoll && remainingMs === 0) {
|
|
775
|
-
return {
|
|
743
|
+
return { byRequest: new Map(), degraded, userReturned: false }
|
|
776
744
|
}
|
|
777
745
|
firstPoll = false
|
|
778
746
|
const pollSeconds = Math.min(
|
|
779
747
|
REPLY_PRESENCE_POLL_SECONDS,
|
|
780
748
|
Math.max(0, Math.ceil(remainingMs / 1000)),
|
|
781
749
|
)
|
|
782
|
-
const
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
750
|
+
const results = await Promise.all(
|
|
751
|
+
requestIds.map(async (requestId) => {
|
|
752
|
+
try {
|
|
753
|
+
return { requestId, ...(await ctx.waitForFirstReply(requestId, pollSeconds)) }
|
|
754
|
+
} catch {
|
|
755
|
+
return { requestId, replies: [] as ReplyView[], degraded: true }
|
|
756
|
+
}
|
|
757
|
+
}),
|
|
758
|
+
)
|
|
759
|
+
const byRequest = new Map<string, ReplyView[]>()
|
|
760
|
+
for (const result of results) {
|
|
761
|
+
degraded ||= result.degraded === true
|
|
762
|
+
if (result.replies.length > 0) byRequest.set(result.requestId, result.replies)
|
|
786
763
|
}
|
|
764
|
+
if (byRequest.size > 0) return { byRequest, degraded, userReturned: false }
|
|
787
765
|
}
|
|
788
766
|
}
|
|
789
767
|
|
|
@@ -1133,6 +1111,46 @@ async function pollPendingReply(
|
|
|
1133
1111
|
}
|
|
1134
1112
|
}
|
|
1135
1113
|
|
|
1114
|
+
/** One answered registered question, with everything the agent needs to read it. */
|
|
1115
|
+
interface AnsweredPending {
|
|
1116
|
+
pending: PendingQuestion
|
|
1117
|
+
reply: ReplyView
|
|
1118
|
+
replies: ReplyView[]
|
|
1119
|
+
}
|
|
1120
|
+
|
|
1121
|
+
/**
|
|
1122
|
+
* One bounded recovery poll across every delivered question, concurrently —
|
|
1123
|
+
* N questions must not multiply the hook's latency budget by N.
|
|
1124
|
+
*/
|
|
1125
|
+
async function pollPendingReplies(
|
|
1126
|
+
ctx: HookContext,
|
|
1127
|
+
live: PendingQuestion[],
|
|
1128
|
+
timeoutSeconds: number,
|
|
1129
|
+
): Promise<{ answered: AnsweredPending[]; troubled: boolean }> {
|
|
1130
|
+
const polls = await Promise.all(
|
|
1131
|
+
live.map((entry) => pollPendingReply(ctx, entry, timeoutSeconds)),
|
|
1132
|
+
)
|
|
1133
|
+
const answered: AnsweredPending[] = []
|
|
1134
|
+
let troubled = false
|
|
1135
|
+
for (const [index, poll] of polls.entries()) {
|
|
1136
|
+
if (poll.reply !== null) {
|
|
1137
|
+
answered.push({ pending: live[index]!, reply: poll.reply, replies: poll.replies })
|
|
1138
|
+
}
|
|
1139
|
+
if (poll.failed || poll.degraded) troubled = true
|
|
1140
|
+
}
|
|
1141
|
+
return { answered, troubled }
|
|
1142
|
+
}
|
|
1143
|
+
|
|
1144
|
+
/** The registered-question queue. Anything but the current shape reads as empty. */
|
|
1145
|
+
function pendingList(state: SessionState): PendingQuestion[] {
|
|
1146
|
+
return Array.isArray(state.pending) ? state.pending : []
|
|
1147
|
+
}
|
|
1148
|
+
|
|
1149
|
+
/** Registration identity — the convention every racing writer compares by. */
|
|
1150
|
+
function isSamePending(a: PendingQuestion, b: PendingQuestion): boolean {
|
|
1151
|
+
return a.question === b.question && a.asked_at === b.asked_at
|
|
1152
|
+
}
|
|
1153
|
+
|
|
1136
1154
|
/** The question set this pending record pushes, however it was registered. */
|
|
1137
1155
|
function pendingQuestions(pending: PendingQuestion): QuestionT[] {
|
|
1138
1156
|
return pending.questions ?? [{ id: 'q1', text: pending.question }]
|
|
@@ -1164,46 +1182,74 @@ function answerContext(replies: ReplyView[], hadChoices: boolean): string {
|
|
|
1164
1182
|
)
|
|
1165
1183
|
}
|
|
1166
1184
|
|
|
1167
|
-
|
|
1185
|
+
/**
|
|
1186
|
+
* Every answer that has arrived, as one message. Several registered questions
|
|
1187
|
+
* may resolve in one hook pass; the agent reads them together, each answer
|
|
1188
|
+
* tied to the question that asked it, with a truthful note about anything
|
|
1189
|
+
* still waiting.
|
|
1190
|
+
*/
|
|
1191
|
+
function answersContext(answered: AnsweredPending[], remaining: number): string {
|
|
1192
|
+
const tail =
|
|
1193
|
+
remaining > 0
|
|
1194
|
+
? ` (${remaining} more registered question${remaining === 1 ? ' is' : 's are'} still waiting for an answer.)`
|
|
1195
|
+
: ''
|
|
1196
|
+
if (answered.length === 1) {
|
|
1197
|
+
const only = answered[0]!
|
|
1198
|
+
return answerContext(only.replies, pendingHasChoices(only.pending)) + tail
|
|
1199
|
+
}
|
|
1200
|
+
const lines = answered.map(({ pending, replies }) => {
|
|
1201
|
+
const latest = replies.at(-1)!
|
|
1202
|
+
const answer =
|
|
1203
|
+
replies.length === 1 || pendingHasChoices(pending)
|
|
1204
|
+
? `"${latest.text}"`
|
|
1205
|
+
: `${replies.map((reply) => `"${reply.text}"`).join(', then ')} (parts in the order written; later parts extend or correct earlier ones)`
|
|
1206
|
+
return `- "${pending.question}" → ${answer} (from ${latest.device_name})`
|
|
1207
|
+
})
|
|
1208
|
+
return `Notifai — the user answered ${answered.length} questions:\n${lines.join('\n')}\nContinue with these answers.${tail}`
|
|
1209
|
+
}
|
|
1210
|
+
|
|
1211
|
+
function userPromptAnswerOutput(context: string): string {
|
|
1168
1212
|
return JSON.stringify({
|
|
1169
1213
|
hookSpecificOutput: {
|
|
1170
1214
|
hookEventName: 'UserPromptSubmit',
|
|
1171
|
-
additionalContext:
|
|
1215
|
+
additionalContext: context,
|
|
1172
1216
|
},
|
|
1173
1217
|
})
|
|
1174
1218
|
}
|
|
1175
1219
|
|
|
1176
|
-
function stopAnswerOutput(
|
|
1177
|
-
return JSON.stringify({ decision: 'block', reason:
|
|
1220
|
+
function stopAnswerOutput(context: string): string {
|
|
1221
|
+
return JSON.stringify({ decision: 'block', reason: context })
|
|
1178
1222
|
}
|
|
1179
1223
|
|
|
1180
1224
|
/**
|
|
1181
|
-
* Close, truthfully retire, and forget
|
|
1182
|
-
* dropping any other retirement debt
|
|
1183
|
-
*
|
|
1225
|
+
* Close, truthfully retire, and forget every answered pending question in one
|
|
1226
|
+
* state write, without dropping any other retirement debt or any question
|
|
1227
|
+
* still waiting. Stop answers also open one bounded continuation generation;
|
|
1228
|
+
* UserPromptSubmit answers ride the user's new turn.
|
|
1184
1229
|
*/
|
|
1185
|
-
async function
|
|
1230
|
+
async function finishAnsweredPendings(
|
|
1186
1231
|
ctx: HookContext,
|
|
1187
1232
|
envelope: HookEnvelope,
|
|
1188
1233
|
sessionId: string,
|
|
1189
|
-
|
|
1190
|
-
reply: ReplyView,
|
|
1234
|
+
answered: AnsweredPending[],
|
|
1191
1235
|
continuation: boolean,
|
|
1192
1236
|
): Promise<void> {
|
|
1193
|
-
const
|
|
1237
|
+
const retirements = answered
|
|
1238
|
+
.map(({ pending }) => retiringQuestion(pending, 'answered'))
|
|
1239
|
+
.filter((entry): entry is RetiringQuestion => entry !== null)
|
|
1194
1240
|
updateSessionState(sessionId, ctx.env, (current) => {
|
|
1195
1241
|
const retiring = [...(current.retiring ?? [])]
|
|
1196
|
-
|
|
1242
|
+
for (const retirement of retirements) {
|
|
1197
1243
|
const existing = retiring.findIndex((entry) => entry.request_id === retirement.request_id)
|
|
1198
1244
|
if (existing < 0) retiring.push(retirement)
|
|
1199
1245
|
else retiring[existing] = retirement
|
|
1200
1246
|
}
|
|
1201
|
-
const
|
|
1202
|
-
|
|
1203
|
-
|
|
1204
|
-
|
|
1205
|
-
|
|
1206
|
-
|
|
1247
|
+
const remaining = pendingList(current).filter(
|
|
1248
|
+
(entry) => !answered.some(({ pending }) => isSamePending(entry, pending)),
|
|
1249
|
+
)
|
|
1250
|
+
const next: SessionState = { ...current, retiring }
|
|
1251
|
+
if (remaining.length > 0) next.pending = remaining
|
|
1252
|
+
else delete next.pending
|
|
1207
1253
|
if (continuation) {
|
|
1208
1254
|
next.continuation = {
|
|
1209
1255
|
answered_at: ctx.now(),
|
|
@@ -1237,20 +1283,19 @@ export async function handleUserPromptSubmit(
|
|
|
1237
1283
|
if (!sessionId) return { notes }
|
|
1238
1284
|
|
|
1239
1285
|
const state = readSessionState(sessionId, ctx.env)
|
|
1240
|
-
const
|
|
1241
|
-
|
|
1242
|
-
|
|
1243
|
-
|
|
1244
|
-
|
|
1245
|
-
|
|
1246
|
-
|
|
1286
|
+
const live = pendingList(state).filter((entry) => entry.request_id !== undefined)
|
|
1287
|
+
let lateAnswers: AnsweredPending[] = []
|
|
1288
|
+
if (live.length > 0) {
|
|
1289
|
+
const { answered, troubled } = await pollPendingReplies(ctx, live, LATE_PROMPT_POLL_SECONDS)
|
|
1290
|
+
lateAnswers = answered
|
|
1291
|
+
if (answered.length > 0) {
|
|
1292
|
+
await finishAnsweredPendings(ctx, envelope, sessionId, answered, false)
|
|
1293
|
+
for (const { reply } of answered) {
|
|
1294
|
+
notes.push(`late answer from ${reply.device_name}: ${reply.text}`)
|
|
1247
1295
|
}
|
|
1248
|
-
await drainOrphanRetirements(ctx, ctx.env, ctx.now())
|
|
1249
|
-
notes.push(`late answer from ${late.reply.device_name}: ${late.reply.text}`)
|
|
1250
|
-
return { stdout: userPromptAnswerOutput(late.replies, pendingHasChoices(pending)), notes }
|
|
1251
1296
|
}
|
|
1252
|
-
if (
|
|
1253
|
-
notes.push('could not check
|
|
1297
|
+
if (troubled) {
|
|
1298
|
+
notes.push('could not check every pending question for a late answer before the prompt')
|
|
1254
1299
|
}
|
|
1255
1300
|
}
|
|
1256
1301
|
// Park before dropping `pending`. If the process dies between these writes,
|
|
@@ -1258,11 +1303,11 @@ export async function handleUserPromptSubmit(
|
|
|
1258
1303
|
// the gap after erasing the only request/collapse/device identifiers.
|
|
1259
1304
|
updateSessionState(sessionId, ctx.env, (current) => {
|
|
1260
1305
|
const retiring = [...(current.retiring ?? [])]
|
|
1261
|
-
|
|
1262
|
-
const retirement = retiringQuestion(
|
|
1306
|
+
for (const entry of pendingList(current)) {
|
|
1307
|
+
const retirement = retiringQuestion(entry, 'answered_elsewhere')
|
|
1263
1308
|
if (
|
|
1264
1309
|
retirement !== null &&
|
|
1265
|
-
!retiring.some((
|
|
1310
|
+
!retiring.some((parked) => parked.request_id === retirement.request_id)
|
|
1266
1311
|
) {
|
|
1267
1312
|
retiring.push(retirement)
|
|
1268
1313
|
}
|
|
@@ -1282,6 +1327,11 @@ export async function handleUserPromptSubmit(
|
|
|
1282
1327
|
if (swept.length > 0) {
|
|
1283
1328
|
notes.push(`retired question${swept.length > 1 ? 's' : ''} ${swept.join(', ')}`)
|
|
1284
1329
|
}
|
|
1330
|
+
if (lateAnswers.length > 0) {
|
|
1331
|
+
// Whatever the user did not answer was just retired above — they are at
|
|
1332
|
+
// the keyboard now, so nothing is "still waiting" from their side.
|
|
1333
|
+
return { stdout: userPromptAnswerOutput(answersContext(lateAnswers, 0)), notes }
|
|
1334
|
+
}
|
|
1285
1335
|
return { notes }
|
|
1286
1336
|
}
|
|
1287
1337
|
|
|
@@ -1320,23 +1370,38 @@ export async function handleStop(ctx: HookContext, envelope: HookEnvelope): Prom
|
|
|
1320
1370
|
}
|
|
1321
1371
|
|
|
1322
1372
|
const state = readSessionState(sessionId, ctx.env)
|
|
1323
|
-
const pending = state
|
|
1324
|
-
if (
|
|
1325
|
-
|
|
1326
|
-
|
|
1327
|
-
|
|
1328
|
-
|
|
1329
|
-
|
|
1330
|
-
|
|
1373
|
+
const pending = pendingList(state)
|
|
1374
|
+
if (pending.length === 0) return { notes }
|
|
1375
|
+
const live = pending.filter((entry) => entry.request_id !== undefined)
|
|
1376
|
+
const unasked = pending.filter((entry) => entry.request_id === undefined)
|
|
1377
|
+
|
|
1378
|
+
if (live.length > 0) {
|
|
1379
|
+
const { answered, troubled } = await pollPendingReplies(ctx, live, LATE_STOP_POLL_SECONDS)
|
|
1380
|
+
if (answered.length > 0) {
|
|
1381
|
+
await finishAnsweredPendings(ctx, envelope, sessionId, answered, true)
|
|
1382
|
+
for (const { reply } of answered) {
|
|
1383
|
+
notes.push(`late answer from ${reply.device_name}: ${reply.text}`)
|
|
1384
|
+
}
|
|
1385
|
+
// Anything still unasked rides the next Stop: the agent is being resumed
|
|
1386
|
+
// with answers right now, and may not even need the rest afterwards.
|
|
1387
|
+
return {
|
|
1388
|
+
stdout: stopAnswerOutput(answersContext(answered, pending.length - answered.length)),
|
|
1389
|
+
notes,
|
|
1390
|
+
}
|
|
1331
1391
|
}
|
|
1332
|
-
|
|
1333
|
-
|
|
1334
|
-
|
|
1335
|
-
|
|
1336
|
-
|
|
1337
|
-
|
|
1338
|
-
|
|
1339
|
-
|
|
1392
|
+
if (unasked.length === 0) {
|
|
1393
|
+
// Already live on the user's devices from an earlier Stop; asking twice
|
|
1394
|
+
// for one question is the nagging failure this feature exists to avoid.
|
|
1395
|
+
const ids = live.map((entry) => entry.request_id).join(', ')
|
|
1396
|
+
notes.push(
|
|
1397
|
+
troubled
|
|
1398
|
+
? `already asked (${ids}); could not check whether ${live.length === 1 ? 'its answer' : 'their answers'} arrived`
|
|
1399
|
+
: `already asked (${ids}); waiting for ${live.length === 1 ? 'that answer' : 'those answers'}`,
|
|
1400
|
+
)
|
|
1401
|
+
return { notes }
|
|
1402
|
+
}
|
|
1403
|
+
// Questions registered after the earlier push still owe the user their
|
|
1404
|
+
// notification; fall through and escalate just those.
|
|
1340
1405
|
}
|
|
1341
1406
|
|
|
1342
1407
|
// A Stop answer may immediately produce a legitimate follow-up question.
|
|
@@ -1346,8 +1411,9 @@ export async function handleStop(ctx: HookContext, envelope: HookEnvelope): Prom
|
|
|
1346
1411
|
const continuation = state.continuation
|
|
1347
1412
|
const isNew =
|
|
1348
1413
|
continuation !== undefined &&
|
|
1349
|
-
|
|
1350
|
-
|
|
1414
|
+
unasked.some(
|
|
1415
|
+
(entry) => entry.asked_at !== undefined && entry.asked_at > continuation.answered_at,
|
|
1416
|
+
)
|
|
1351
1417
|
if (!isNew) {
|
|
1352
1418
|
notes.push('already continuing from an answer; not asking again this turn')
|
|
1353
1419
|
return { notes }
|
|
@@ -1375,7 +1441,7 @@ export async function handleStop(ctx: HookContext, envelope: HookEnvelope): Prom
|
|
|
1375
1441
|
return { notes }
|
|
1376
1442
|
}
|
|
1377
1443
|
try {
|
|
1378
|
-
return await escalate(ctx, envelope, sessionId,
|
|
1444
|
+
return await escalate(ctx, envelope, sessionId, unasked, live, notes)
|
|
1379
1445
|
} finally {
|
|
1380
1446
|
releaseQuestionPush(sessionId, ctx.env)
|
|
1381
1447
|
}
|
|
@@ -1386,102 +1452,138 @@ async function escalate(
|
|
|
1386
1452
|
ctx: HookContext,
|
|
1387
1453
|
envelope: HookEnvelope,
|
|
1388
1454
|
sessionId: string,
|
|
1389
|
-
|
|
1455
|
+
unasked: PendingQuestion[],
|
|
1456
|
+
alreadyLive: PendingQuestion[],
|
|
1390
1457
|
notes: string[],
|
|
1391
1458
|
): Promise<HookOutcome> {
|
|
1392
|
-
// Away right now, but the
|
|
1393
|
-
// window before anything reaches their devices
|
|
1394
|
-
|
|
1459
|
+
// Away right now, but the questions still owe the user their terminal-first
|
|
1460
|
+
// window before anything reaches their devices — measured from the oldest
|
|
1461
|
+
// registration, because that is the question that has waited longest.
|
|
1462
|
+
const oldest = Math.min(...unasked.map((entry) => entry.asked_at ?? ctx.now()))
|
|
1463
|
+
const grace = await awaitGrace(ctx, oldest)
|
|
1395
1464
|
if (grace === 'user-returned') {
|
|
1396
|
-
notes.push('you came back before the wait elapsed; leaving the
|
|
1465
|
+
notes.push('you came back before the wait elapsed; leaving the questions in the terminal')
|
|
1397
1466
|
return { notes }
|
|
1398
1467
|
}
|
|
1399
1468
|
if (grace === 'no-signal') {
|
|
1400
1469
|
notes.push('no idle signal on this machine; asking now rather than holding the terminal')
|
|
1401
1470
|
}
|
|
1402
1471
|
|
|
1403
|
-
|
|
1404
|
-
|
|
1405
|
-
|
|
1406
|
-
|
|
1407
|
-
|
|
1408
|
-
|
|
1409
|
-
|
|
1410
|
-
|
|
1411
|
-
|
|
1412
|
-
|
|
1413
|
-
|
|
1414
|
-
|
|
1415
|
-
|
|
1416
|
-
|
|
1417
|
-
|
|
1418
|
-
|
|
1419
|
-
|
|
1420
|
-
|
|
1421
|
-
|
|
1422
|
-
|
|
1423
|
-
|
|
1424
|
-
|
|
1425
|
-
|
|
1426
|
-
|
|
1427
|
-
|
|
1472
|
+
// Phase one: every registered question reaches the user's devices, each as
|
|
1473
|
+
// its own notification — one ask never stands in for another.
|
|
1474
|
+
const submitted: PendingQuestion[] = []
|
|
1475
|
+
for (const entry of unasked) {
|
|
1476
|
+
const questions = pendingQuestions(entry)
|
|
1477
|
+
const sent = await submitQuestion(ctx, {
|
|
1478
|
+
title:
|
|
1479
|
+
ctx.config.project.value === null
|
|
1480
|
+
? 'Question'
|
|
1481
|
+
: `Question · ${ctx.config.project.value}`,
|
|
1482
|
+
// A set is answered on the expanded card; the banner leads with the
|
|
1483
|
+
// first question and says how much more is waiting behind it.
|
|
1484
|
+
body:
|
|
1485
|
+
questions.length > 1
|
|
1486
|
+
? `${questions[0]!.text} (+${questions.length - 1} more)`
|
|
1487
|
+
: entry.question,
|
|
1488
|
+
questions,
|
|
1489
|
+
...(entry.detail !== undefined ? { detail: entry.detail } : {}),
|
|
1490
|
+
event: 'agent_question',
|
|
1491
|
+
session: sessionLabel(ctx, envelope),
|
|
1492
|
+
// Outlives the block, and stays open on purpose: the answer is still
|
|
1493
|
+
// useful to the next turn, which collects it with `notifai replies`.
|
|
1494
|
+
windowSeconds: QUESTION_WINDOW_SECONDS,
|
|
1495
|
+
})
|
|
1496
|
+
if ('error' in sent) {
|
|
1497
|
+
notes.push(sent.error)
|
|
1498
|
+
continue
|
|
1499
|
+
}
|
|
1500
|
+
const live: PendingQuestion = {
|
|
1501
|
+
...entry,
|
|
1502
|
+
request_id: sent.requestId,
|
|
1503
|
+
collapse_key: sent.collapseKey,
|
|
1504
|
+
device_ids: sent.devices,
|
|
1505
|
+
}
|
|
1506
|
+
submitted.push(live)
|
|
1507
|
+
// Record what is now live on the user's devices BEFORE any wait. If we
|
|
1508
|
+
// only learned these ids afterwards, a question that timed out would
|
|
1509
|
+
// leave no trace, and the user returning to the terminal could never
|
|
1510
|
+
// retire it — the notification would stay answerable for an hour with
|
|
1511
|
+
// nobody listening.
|
|
1512
|
+
updateSessionState(sessionId, ctx.env, (current) => {
|
|
1513
|
+
const list = pendingList(current)
|
|
1514
|
+
const index = list.findIndex(
|
|
1515
|
+
(candidate) => isSamePending(candidate, entry) && candidate.request_id === undefined,
|
|
1516
|
+
)
|
|
1517
|
+
if (index >= 0) {
|
|
1518
|
+
const next = [...list]
|
|
1519
|
+
next[index] = live
|
|
1520
|
+
return { ...current, pending: next }
|
|
1428
1521
|
}
|
|
1429
|
-
|
|
1430
|
-
|
|
1431
|
-
|
|
1432
|
-
|
|
1433
|
-
|
|
1434
|
-
|
|
1435
|
-
|
|
1436
|
-
|
|
1437
|
-
|
|
1438
|
-
|
|
1439
|
-
|
|
1440
|
-
|
|
1441
|
-
|
|
1442
|
-
|
|
1443
|
-
|
|
1444
|
-
|
|
1445
|
-
|
|
1446
|
-
|
|
1447
|
-
|
|
1448
|
-
if ('error' in asked) {
|
|
1449
|
-
notes.push(asked.error)
|
|
1522
|
+
// The entry vanished while the submit was in flight (the user's prompt
|
|
1523
|
+
// wiped the queue). The delivered notification must still be retirable,
|
|
1524
|
+
// so park it rather than lose its only identifiers.
|
|
1525
|
+
const retirement = retiringQuestion(live, 'answered_elsewhere')!
|
|
1526
|
+
const retiring = [...(current.retiring ?? [])]
|
|
1527
|
+
if (!retiring.some((parked) => parked.request_id === retirement.request_id)) {
|
|
1528
|
+
retiring.push(retirement)
|
|
1529
|
+
}
|
|
1530
|
+
return { ...current, retiring }
|
|
1531
|
+
})
|
|
1532
|
+
}
|
|
1533
|
+
|
|
1534
|
+
const waitingOn = [...alreadyLive, ...submitted]
|
|
1535
|
+
if (waitingOn.length === 0) return { notes }
|
|
1536
|
+
|
|
1537
|
+
if (ctx.harness === 'opencode') {
|
|
1538
|
+
notes.push(
|
|
1539
|
+
`question${submitted.length === 1 ? '' : 's'} sent without blocking OpenCode; retrieve answers on the next prompt or with: notifai replies --pending`,
|
|
1540
|
+
)
|
|
1450
1541
|
return { notes }
|
|
1451
1542
|
}
|
|
1452
1543
|
|
|
1453
|
-
|
|
1454
|
-
|
|
1455
|
-
|
|
1456
|
-
|
|
1457
|
-
|
|
1458
|
-
|
|
1459
|
-
|
|
1460
|
-
|
|
1461
|
-
|
|
1544
|
+
// Phase two: one blocking wait across everything live, old and new alike.
|
|
1545
|
+
const timeoutSeconds = ctx.config.hook_reply_timeout_seconds.value
|
|
1546
|
+
const waited = await waitForAnyReplyWhileAway(
|
|
1547
|
+
ctx,
|
|
1548
|
+
waitingOn.map((entry) => entry.request_id!),
|
|
1549
|
+
timeoutSeconds,
|
|
1550
|
+
)
|
|
1551
|
+
|
|
1552
|
+
if (waited.byRequest.size === 0) {
|
|
1553
|
+
// Keep the pending records so a returning user's UserPromptSubmit can
|
|
1554
|
+
// retire the notifications still live on their devices. `request_id`
|
|
1555
|
+
// being set is also what stops the next Stop pushing them again.
|
|
1556
|
+
const ids = waitingOn.map((entry) => entry.request_id).join(', ')
|
|
1557
|
+
if (waited.userReturned) {
|
|
1462
1558
|
notes.push(
|
|
1463
|
-
`you came back after the question was sent; returning the terminal while it stays answerable (${
|
|
1464
|
-
'Retrieve
|
|
1559
|
+
`you came back after the question${waitingOn.length === 1 ? ' was' : 's were'} sent; returning the terminal while ${waitingOn.length === 1 ? 'it stays' : 'they stay'} answerable (${ids}). ` +
|
|
1560
|
+
'Retrieve answers with: notifai replies --pending',
|
|
1465
1561
|
)
|
|
1466
1562
|
} else {
|
|
1467
1563
|
notes.push(
|
|
1468
|
-
|
|
1469
|
-
?
|
|
1470
|
-
:
|
|
1564
|
+
waited.degraded
|
|
1565
|
+
? 'could not reach the server to find out whether you answered; check with: notifai replies --pending'
|
|
1566
|
+
: 'no answer in time; retrieve it later with: notifai replies --pending',
|
|
1471
1567
|
)
|
|
1472
1568
|
}
|
|
1473
1569
|
return { notes }
|
|
1474
1570
|
}
|
|
1475
1571
|
|
|
1476
|
-
|
|
1477
|
-
|
|
1478
|
-
|
|
1479
|
-
|
|
1480
|
-
|
|
1481
|
-
|
|
1482
|
-
|
|
1572
|
+
const answered: AnsweredPending[] = []
|
|
1573
|
+
for (const entry of waitingOn) {
|
|
1574
|
+
const replies = waited.byRequest.get(entry.request_id!)
|
|
1575
|
+
if (replies === undefined) continue
|
|
1576
|
+
// First answer claims the question across devices; the close is what
|
|
1577
|
+
// enforces that, and the latest reply within the window is the answer.
|
|
1578
|
+
await closeQuietly(ctx, entry.request_id!)
|
|
1579
|
+
answered.push({ pending: entry, reply: replies.at(-1)!, replies })
|
|
1580
|
+
}
|
|
1581
|
+
await finishAnsweredPendings(ctx, envelope, sessionId, answered, true)
|
|
1582
|
+
for (const { reply } of answered) {
|
|
1583
|
+
notes.push(`answer from ${reply.device_name}: ${reply.text}`)
|
|
1584
|
+
}
|
|
1483
1585
|
return {
|
|
1484
|
-
stdout: stopAnswerOutput(
|
|
1586
|
+
stdout: stopAnswerOutput(answersContext(answered, waitingOn.length - answered.length)),
|
|
1485
1587
|
notes,
|
|
1486
1588
|
}
|
|
1487
1589
|
}
|
|
@@ -1510,10 +1612,9 @@ export function handleSessionEnd(
|
|
|
1510
1612
|
|
|
1511
1613
|
const state = readSessionState(sessionId, env)
|
|
1512
1614
|
const orphans: RetiringQuestion[] = [...(state.retiring ?? [])]
|
|
1513
|
-
const
|
|
1514
|
-
if (pending !== undefined) {
|
|
1615
|
+
for (const entry of pendingList(state)) {
|
|
1515
1616
|
try {
|
|
1516
|
-
const orphan = retiringQuestion(
|
|
1617
|
+
const orphan = retiringQuestion(entry, 'expired')
|
|
1517
1618
|
if (orphan !== null) orphans.push(orphan)
|
|
1518
1619
|
} catch (err) {
|
|
1519
1620
|
notes.push(err instanceof Error ? err.message : String(err))
|
|
@@ -1538,18 +1639,24 @@ export function handleSessionEnd(
|
|
|
1538
1639
|
// ---------------------------------------------------------------------------
|
|
1539
1640
|
|
|
1540
1641
|
/**
|
|
1541
|
-
*
|
|
1542
|
-
*
|
|
1543
|
-
*
|
|
1544
|
-
*
|
|
1545
|
-
|
|
1546
|
-
|
|
1547
|
-
|
|
1548
|
-
|
|
1642
|
+
* More pending questions than this means an agent is looping, not asking.
|
|
1643
|
+
* Related questions belong in one `ask --form` (the wire carries up to four
|
|
1644
|
+
* questions per notification); four separate pushes is already a lot of lock
|
|
1645
|
+
* screen.
|
|
1646
|
+
*/
|
|
1647
|
+
export const MAX_PENDING_QUESTIONS = 4
|
|
1648
|
+
|
|
1649
|
+
/**
|
|
1650
|
+
* A session may hold several registered questions at once: a new `ask` never
|
|
1651
|
+
* ends an earlier one. Each reaches the user as its own notification and is
|
|
1652
|
+
* answerable independently. Superseding is reply semantics — a later reply
|
|
1653
|
+
* corrects an earlier reply to the same question (that correction model lives
|
|
1654
|
+
* server-side) — never question semantics: the old single-slot model silently
|
|
1655
|
+
* discarded a question the moment a second was registered, and the user was
|
|
1656
|
+
* pointed at a notification that never existed (2026-08-09).
|
|
1549
1657
|
*
|
|
1550
|
-
*
|
|
1551
|
-
* running in one project at once, and
|
|
1552
|
-
* agent's live one would be worse than the staleness this fixes.
|
|
1658
|
+
* The queue is keyed on the session, not the project: several agents may be
|
|
1659
|
+
* running in one project at once, and their questions must never interfere.
|
|
1553
1660
|
*/
|
|
1554
1661
|
export function registerQuestion(
|
|
1555
1662
|
sessionId: string,
|
|
@@ -1557,27 +1664,31 @@ export function registerQuestion(
|
|
|
1557
1664
|
question: PendingQuestion,
|
|
1558
1665
|
now: number = Date.now(),
|
|
1559
1666
|
): void {
|
|
1667
|
+
let full = false
|
|
1560
1668
|
updateSessionState(sessionId, env, (state) => {
|
|
1561
|
-
const
|
|
1562
|
-
if (
|
|
1563
|
-
|
|
1564
|
-
|
|
1565
|
-
retirement !== null &&
|
|
1566
|
-
!retiring.some((entry) => entry.request_id === retirement.request_id)
|
|
1567
|
-
) {
|
|
1568
|
-
retiring.push(retirement)
|
|
1569
|
-
}
|
|
1669
|
+
const pending = pendingList(state)
|
|
1670
|
+
if (pending.length >= MAX_PENDING_QUESTIONS) {
|
|
1671
|
+
full = true
|
|
1672
|
+
return state
|
|
1570
1673
|
}
|
|
1571
1674
|
return {
|
|
1572
1675
|
...state,
|
|
1573
|
-
|
|
1574
|
-
|
|
1575
|
-
|
|
1576
|
-
|
|
1577
|
-
|
|
1578
|
-
|
|
1676
|
+
pending: [
|
|
1677
|
+
...pending,
|
|
1678
|
+
{
|
|
1679
|
+
asked_at: now,
|
|
1680
|
+
...question,
|
|
1681
|
+
question: question.question.slice(0, MAX_STORED_QUESTION_CHARS),
|
|
1682
|
+
},
|
|
1683
|
+
],
|
|
1579
1684
|
}
|
|
1580
1685
|
})
|
|
1686
|
+
if (full) {
|
|
1687
|
+
throw new Error(
|
|
1688
|
+
`${MAX_PENDING_QUESTIONS} questions are already waiting to be asked. ` +
|
|
1689
|
+
'Combine related questions into one `notifai ask --form` instead of registering more.',
|
|
1690
|
+
)
|
|
1691
|
+
}
|
|
1581
1692
|
}
|
|
1582
1693
|
|
|
1583
1694
|
/** Parses hook JSON from stdin, tolerating an empty or malformed body. */
|