@raidiant/notifai 0.1.8 → 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/README.md +1 -1
- package/dist/commands.d.ts +30 -12
- package/dist/commands.js +234 -78
- package/dist/commands.js.map +1 -1
- package/dist/hooks.d.ts +38 -24
- package/dist/hooks.js +316 -187
- package/dist/hooks.js.map +1 -1
- package/dist/main.js +35 -3
- package/dist/main.js.map +1 -1
- package/dist/send.d.ts +19 -26
- package/dist/send.js +35 -45
- package/dist/send.js.map +1 -1
- package/package.json +2 -2
- package/src/commands.test.ts +174 -16
- package/src/commands.ts +270 -80
- package/src/hooks.test.ts +314 -83
- package/src/hooks.ts +389 -223
- package/src/install-hooks.test.ts +8 -15
- package/src/main.ts +41 -4
- package/src/send.ts +50 -48
package/src/hooks.ts
CHANGED
|
@@ -11,7 +11,7 @@ import {
|
|
|
11
11
|
writeFileSync,
|
|
12
12
|
} from 'node:fs'
|
|
13
13
|
import path from 'node:path'
|
|
14
|
-
import type { LifecycleEndState, ReplyView } from '@raidiant/notifai-protocol'
|
|
14
|
+
import type { LifecycleEndState, QuestionT, ReplyView } from '@raidiant/notifai-protocol'
|
|
15
15
|
import type { ApiClient } from './client.js'
|
|
16
16
|
import {
|
|
17
17
|
loadConfig,
|
|
@@ -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. */
|
|
@@ -120,6 +127,7 @@ const ORPHAN_TTL_MS = 24 * 3600 * 1000
|
|
|
120
127
|
const ORPHAN_QUEUE_CAP = 50
|
|
121
128
|
|
|
122
129
|
export interface PendingQuestion {
|
|
130
|
+
/** One-line summary: the single question's text, or the set's first. */
|
|
123
131
|
question: string
|
|
124
132
|
/**
|
|
125
133
|
* Epoch ms when `notifai ask` registered this. The grace window runs from
|
|
@@ -128,11 +136,12 @@ export interface PendingQuestion {
|
|
|
128
136
|
*/
|
|
129
137
|
asked_at?: number
|
|
130
138
|
/**
|
|
131
|
-
*
|
|
132
|
-
*
|
|
133
|
-
* comma survives the round trip.
|
|
139
|
+
* The full question set as `notifai ask` validated it: generated ids,
|
|
140
|
+
* texts, choices, multi flags. What actually rides the push.
|
|
134
141
|
*/
|
|
135
|
-
|
|
142
|
+
questions?: QuestionT[]
|
|
143
|
+
/** Long-form markdown context, shown in the companion's detail view. */
|
|
144
|
+
detail?: string
|
|
136
145
|
/** Set once the question has actually been pushed, so it can be retired. */
|
|
137
146
|
request_id?: string
|
|
138
147
|
collapse_key?: string
|
|
@@ -638,18 +647,11 @@ export interface HookContext {
|
|
|
638
647
|
|
|
639
648
|
export type HookHarness = 'claude-code' | 'codex' | 'cursor' | 'opencode'
|
|
640
649
|
|
|
641
|
-
interface
|
|
650
|
+
interface SubmittedQuestion {
|
|
642
651
|
requestId: string
|
|
643
652
|
collapseKey: string
|
|
644
|
-
reply: ReplyView | null
|
|
645
653
|
/** The devices the question went to; retirement must not reach any other. */
|
|
646
654
|
devices: string[]
|
|
647
|
-
/** The wait ended amid network failures, so "no answer" is unproven. */
|
|
648
|
-
degraded: boolean
|
|
649
|
-
/** False when the harness cannot consume a Stop answer and polling was skipped. */
|
|
650
|
-
waited: boolean
|
|
651
|
-
/** The user returned to this machine after the question was pushed. */
|
|
652
|
-
userReturned: boolean
|
|
653
655
|
}
|
|
654
656
|
|
|
655
657
|
/** Re-check local presence at this cadence while a pushed question is waiting. */
|
|
@@ -658,28 +660,33 @@ const REPLY_PRESENCE_POLL_SECONDS = 5
|
|
|
658
660
|
/**
|
|
659
661
|
* Push a question and block for the answer.
|
|
660
662
|
*
|
|
661
|
-
* An answered question is closed
|
|
662
|
-
*
|
|
663
|
+
* An answered question is closed the moment this wait sees the answer: the
|
|
664
|
+
* first device to answer claims the question and the others are told. Until
|
|
665
|
+
* that close, a later reply is a correction — so what this returns as "the"
|
|
666
|
+
* answer is always the latest reply, with the full stream alongside for
|
|
667
|
+
* free-text answers given in parts. An unanswered question stays open,
|
|
663
668
|
* because nobody has acted on the silence yet and the next turn can still
|
|
664
669
|
* collect the answer with `notifai replies`.
|
|
665
670
|
*/
|
|
666
|
-
|
|
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(
|
|
667
676
|
ctx: HookContext,
|
|
668
677
|
options: {
|
|
669
678
|
title: string
|
|
670
679
|
body: string
|
|
671
|
-
|
|
680
|
+
questions: QuestionT[]
|
|
681
|
+
detail?: string | undefined
|
|
672
682
|
event: string
|
|
673
683
|
/** Which agent is asking; two of them must not look alike. */
|
|
674
684
|
session?: string | undefined
|
|
675
685
|
/** How long the server keeps accepting an answer. */
|
|
676
686
|
windowSeconds: number
|
|
677
|
-
/** Called once the question is live, before the block begins. */
|
|
678
|
-
onSubmitted?: (live: { requestId: string; collapseKey: string; devices: string[] }) => void
|
|
679
687
|
},
|
|
680
|
-
): Promise<
|
|
688
|
+
): Promise<SubmittedQuestion | { error: string }> {
|
|
681
689
|
const collapseKey = `notifai-hook-${randomBytes(8).toString('base64url')}`
|
|
682
|
-
const timeoutSeconds = ctx.config.hook_reply_timeout_seconds.value
|
|
683
690
|
// A draft carrying `reply` is rejected outright if it targets a device that
|
|
684
691
|
// cannot answer, so resolve the healthy companion platforms explicitly.
|
|
685
692
|
const answerable = await answerableDevices(ctx)
|
|
@@ -695,7 +702,8 @@ async function askAndWait(
|
|
|
695
702
|
device: answerable,
|
|
696
703
|
reply: true,
|
|
697
704
|
replyWindow: Math.max(60, options.windowSeconds),
|
|
698
|
-
|
|
705
|
+
questions: options.questions,
|
|
706
|
+
...(options.detail !== undefined ? { detail: options.detail } : {}),
|
|
699
707
|
collapseKey,
|
|
700
708
|
})
|
|
701
709
|
if (!build.ok) return { error: build.error }
|
|
@@ -704,45 +712,20 @@ async function askAndWait(
|
|
|
704
712
|
{ idempotency_key: `hook-${randomBytes(12).toString('base64url')}`, draft: build.draft },
|
|
705
713
|
0,
|
|
706
714
|
)
|
|
707
|
-
|
|
708
|
-
// learned these ids after the wait, a question that timed out would leave no
|
|
709
|
-
// trace, and the user returning to the terminal could never retire it — the
|
|
710
|
-
// notification would stay answerable for an hour with nobody listening.
|
|
711
|
-
options.onSubmitted?.({
|
|
712
|
-
requestId: receipt.request_id,
|
|
713
|
-
collapseKey,
|
|
714
|
-
devices: answerable,
|
|
715
|
-
})
|
|
716
|
-
if (ctx.harness === 'opencode') {
|
|
717
|
-
return {
|
|
718
|
-
requestId: receipt.request_id,
|
|
719
|
-
collapseKey,
|
|
720
|
-
reply: null,
|
|
721
|
-
devices: answerable,
|
|
722
|
-
degraded: false,
|
|
723
|
-
waited: false,
|
|
724
|
-
userReturned: false,
|
|
725
|
-
}
|
|
726
|
-
}
|
|
727
|
-
|
|
728
|
-
const result = await waitForReplyWhileAway(ctx, receipt.request_id, timeoutSeconds)
|
|
729
|
-
if (result.replies.length > 0) await closeQuietly(ctx, receipt.request_id)
|
|
730
|
-
return {
|
|
731
|
-
requestId: receipt.request_id,
|
|
732
|
-
collapseKey,
|
|
733
|
-
reply: result.replies[0] ?? null,
|
|
734
|
-
devices: answerable,
|
|
735
|
-
degraded: result.degraded,
|
|
736
|
-
waited: true,
|
|
737
|
-
userReturned: result.userReturned,
|
|
738
|
-
}
|
|
715
|
+
return { requestId: receipt.request_id, collapseKey, devices: answerable }
|
|
739
716
|
}
|
|
740
717
|
|
|
741
|
-
|
|
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(
|
|
742
725
|
ctx: HookContext,
|
|
743
|
-
|
|
726
|
+
requestIds: string[],
|
|
744
727
|
timeoutSeconds: number,
|
|
745
|
-
): Promise<{
|
|
728
|
+
): Promise<{ byRequest: Map<string, ReplyView[]>; degraded: boolean; userReturned: boolean }> {
|
|
746
729
|
const deadline = ctx.now() + timeoutSeconds * 1000
|
|
747
730
|
let degraded = false
|
|
748
731
|
let firstPoll = true
|
|
@@ -751,24 +734,34 @@ async function waitForReplyWhileAway(
|
|
|
751
734
|
if (ctx.config.require_idle.value) {
|
|
752
735
|
const idle = ctx.idleSeconds()
|
|
753
736
|
if (idle !== null && idle < ctx.config.away_after_seconds.value) {
|
|
754
|
-
return {
|
|
737
|
+
return { byRequest: new Map(), degraded, userReturned: true }
|
|
755
738
|
}
|
|
756
739
|
}
|
|
757
740
|
|
|
758
741
|
const remainingMs = Math.max(0, deadline - ctx.now())
|
|
759
742
|
if (!firstPoll && remainingMs === 0) {
|
|
760
|
-
return {
|
|
743
|
+
return { byRequest: new Map(), degraded, userReturned: false }
|
|
761
744
|
}
|
|
762
745
|
firstPoll = false
|
|
763
746
|
const pollSeconds = Math.min(
|
|
764
747
|
REPLY_PRESENCE_POLL_SECONDS,
|
|
765
748
|
Math.max(0, Math.ceil(remainingMs / 1000)),
|
|
766
749
|
)
|
|
767
|
-
const
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
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)
|
|
771
763
|
}
|
|
764
|
+
if (byRequest.size > 0) return { byRequest, degraded, userReturned: false }
|
|
772
765
|
}
|
|
773
766
|
}
|
|
774
767
|
|
|
@@ -1088,7 +1081,10 @@ const LATE_PROMPT_POLL_SECONDS = 3
|
|
|
1088
1081
|
const MAX_CONTINUATION_COUNT = 3
|
|
1089
1082
|
|
|
1090
1083
|
interface PendingPoll {
|
|
1084
|
+
/** The answer to act on: the latest reply, because a later one corrects. */
|
|
1091
1085
|
reply: ReplyView | null
|
|
1086
|
+
/** Every reply in arrival order, for free-text answers given in parts. */
|
|
1087
|
+
replies: ReplyView[]
|
|
1092
1088
|
degraded: boolean
|
|
1093
1089
|
failed: boolean
|
|
1094
1090
|
}
|
|
@@ -1099,63 +1095,161 @@ async function pollPendingReply(
|
|
|
1099
1095
|
pending: PendingQuestion,
|
|
1100
1096
|
timeoutSeconds: number,
|
|
1101
1097
|
): Promise<PendingPoll> {
|
|
1102
|
-
if (pending.request_id === undefined)
|
|
1098
|
+
if (pending.request_id === undefined) {
|
|
1099
|
+
return { reply: null, replies: [], degraded: false, failed: false }
|
|
1100
|
+
}
|
|
1103
1101
|
try {
|
|
1104
1102
|
const result = await ctx.waitForFirstReply(pending.request_id, timeoutSeconds)
|
|
1105
1103
|
return {
|
|
1106
|
-
reply: result.replies
|
|
1104
|
+
reply: result.replies.at(-1) ?? null,
|
|
1105
|
+
replies: result.replies,
|
|
1107
1106
|
degraded: result.degraded === true,
|
|
1108
1107
|
failed: false,
|
|
1109
1108
|
}
|
|
1110
1109
|
} catch {
|
|
1111
|
-
return { reply: null, degraded: true, failed: true }
|
|
1110
|
+
return { reply: null, replies: [], degraded: true, failed: true }
|
|
1112
1111
|
}
|
|
1113
1112
|
}
|
|
1114
1113
|
|
|
1115
|
-
|
|
1116
|
-
|
|
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
|
+
|
|
1154
|
+
/** The question set this pending record pushes, however it was registered. */
|
|
1155
|
+
function pendingQuestions(pending: PendingQuestion): QuestionT[] {
|
|
1156
|
+
return pending.questions ?? [{ id: 'q1', text: pending.question }]
|
|
1157
|
+
}
|
|
1158
|
+
|
|
1159
|
+
/** Did any question in the set offer choices? Decides how replies combine. */
|
|
1160
|
+
function pendingHasChoices(pending: PendingQuestion): boolean {
|
|
1161
|
+
return pendingQuestions(pending).some((question) => question.choices !== undefined)
|
|
1162
|
+
}
|
|
1163
|
+
|
|
1164
|
+
/**
|
|
1165
|
+
* The answer as the agent should read it. For a question with choices the
|
|
1166
|
+
* latest reply IS the answer — an earlier conflicting one was corrected by
|
|
1167
|
+
* it. Free-text answers can arrive in parts, and every part reaches the
|
|
1168
|
+
* agent in the order it was written, so it can tell expansion from
|
|
1169
|
+
* correction itself.
|
|
1170
|
+
*/
|
|
1171
|
+
function answerContext(replies: ReplyView[], hadChoices: boolean): string {
|
|
1172
|
+
const latest = replies.at(-1)
|
|
1173
|
+
if (latest === undefined) return 'Notifai — no answer was recorded.'
|
|
1174
|
+
if (replies.length === 1 || hadChoices) {
|
|
1175
|
+
return `Notifai — the user answered from ${latest.device_name}: "${latest.text}". Continue with that answer.`
|
|
1176
|
+
}
|
|
1177
|
+
const parts = replies.map((reply) => `"${reply.text}"`).join(', then ')
|
|
1178
|
+
return (
|
|
1179
|
+
`Notifai — the user answered from ${latest.device_name} in ${replies.length} parts, ` +
|
|
1180
|
+
`in the order written: ${parts}. Later parts extend or correct earlier ones. ` +
|
|
1181
|
+
'Continue with that answer.'
|
|
1182
|
+
)
|
|
1183
|
+
}
|
|
1184
|
+
|
|
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}`
|
|
1117
1209
|
}
|
|
1118
1210
|
|
|
1119
|
-
function userPromptAnswerOutput(
|
|
1211
|
+
function userPromptAnswerOutput(context: string): string {
|
|
1120
1212
|
return JSON.stringify({
|
|
1121
1213
|
hookSpecificOutput: {
|
|
1122
1214
|
hookEventName: 'UserPromptSubmit',
|
|
1123
|
-
additionalContext:
|
|
1215
|
+
additionalContext: context,
|
|
1124
1216
|
},
|
|
1125
1217
|
})
|
|
1126
1218
|
}
|
|
1127
1219
|
|
|
1128
|
-
function stopAnswerOutput(
|
|
1129
|
-
return JSON.stringify({ decision: 'block', reason:
|
|
1220
|
+
function stopAnswerOutput(context: string): string {
|
|
1221
|
+
return JSON.stringify({ decision: 'block', reason: context })
|
|
1130
1222
|
}
|
|
1131
1223
|
|
|
1132
1224
|
/**
|
|
1133
|
-
* Close, truthfully retire, and forget
|
|
1134
|
-
* dropping any other retirement debt
|
|
1135
|
-
*
|
|
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.
|
|
1136
1229
|
*/
|
|
1137
|
-
async function
|
|
1230
|
+
async function finishAnsweredPendings(
|
|
1138
1231
|
ctx: HookContext,
|
|
1139
1232
|
envelope: HookEnvelope,
|
|
1140
1233
|
sessionId: string,
|
|
1141
|
-
|
|
1142
|
-
reply: ReplyView,
|
|
1234
|
+
answered: AnsweredPending[],
|
|
1143
1235
|
continuation: boolean,
|
|
1144
1236
|
): Promise<void> {
|
|
1145
|
-
const
|
|
1237
|
+
const retirements = answered
|
|
1238
|
+
.map(({ pending }) => retiringQuestion(pending, 'answered'))
|
|
1239
|
+
.filter((entry): entry is RetiringQuestion => entry !== null)
|
|
1146
1240
|
updateSessionState(sessionId, ctx.env, (current) => {
|
|
1147
1241
|
const retiring = [...(current.retiring ?? [])]
|
|
1148
|
-
|
|
1242
|
+
for (const retirement of retirements) {
|
|
1149
1243
|
const existing = retiring.findIndex((entry) => entry.request_id === retirement.request_id)
|
|
1150
1244
|
if (existing < 0) retiring.push(retirement)
|
|
1151
1245
|
else retiring[existing] = retirement
|
|
1152
1246
|
}
|
|
1153
|
-
const
|
|
1154
|
-
|
|
1155
|
-
|
|
1156
|
-
|
|
1157
|
-
|
|
1158
|
-
|
|
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
|
|
1159
1253
|
if (continuation) {
|
|
1160
1254
|
next.continuation = {
|
|
1161
1255
|
answered_at: ctx.now(),
|
|
@@ -1189,20 +1283,19 @@ export async function handleUserPromptSubmit(
|
|
|
1189
1283
|
if (!sessionId) return { notes }
|
|
1190
1284
|
|
|
1191
1285
|
const state = readSessionState(sessionId, ctx.env)
|
|
1192
|
-
const
|
|
1193
|
-
|
|
1194
|
-
|
|
1195
|
-
|
|
1196
|
-
|
|
1197
|
-
|
|
1198
|
-
|
|
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}`)
|
|
1199
1295
|
}
|
|
1200
|
-
await drainOrphanRetirements(ctx, ctx.env, ctx.now())
|
|
1201
|
-
notes.push(`late answer from ${late.reply.device_name}: ${late.reply.text}`)
|
|
1202
|
-
return { stdout: userPromptAnswerOutput(late.reply), notes }
|
|
1203
1296
|
}
|
|
1204
|
-
if (
|
|
1205
|
-
notes.push('could not check
|
|
1297
|
+
if (troubled) {
|
|
1298
|
+
notes.push('could not check every pending question for a late answer before the prompt')
|
|
1206
1299
|
}
|
|
1207
1300
|
}
|
|
1208
1301
|
// Park before dropping `pending`. If the process dies between these writes,
|
|
@@ -1210,11 +1303,11 @@ export async function handleUserPromptSubmit(
|
|
|
1210
1303
|
// the gap after erasing the only request/collapse/device identifiers.
|
|
1211
1304
|
updateSessionState(sessionId, ctx.env, (current) => {
|
|
1212
1305
|
const retiring = [...(current.retiring ?? [])]
|
|
1213
|
-
|
|
1214
|
-
const retirement = retiringQuestion(
|
|
1306
|
+
for (const entry of pendingList(current)) {
|
|
1307
|
+
const retirement = retiringQuestion(entry, 'answered_elsewhere')
|
|
1215
1308
|
if (
|
|
1216
1309
|
retirement !== null &&
|
|
1217
|
-
!retiring.some((
|
|
1310
|
+
!retiring.some((parked) => parked.request_id === retirement.request_id)
|
|
1218
1311
|
) {
|
|
1219
1312
|
retiring.push(retirement)
|
|
1220
1313
|
}
|
|
@@ -1234,6 +1327,11 @@ export async function handleUserPromptSubmit(
|
|
|
1234
1327
|
if (swept.length > 0) {
|
|
1235
1328
|
notes.push(`retired question${swept.length > 1 ? 's' : ''} ${swept.join(', ')}`)
|
|
1236
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
|
+
}
|
|
1237
1335
|
return { notes }
|
|
1238
1336
|
}
|
|
1239
1337
|
|
|
@@ -1272,23 +1370,38 @@ export async function handleStop(ctx: HookContext, envelope: HookEnvelope): Prom
|
|
|
1272
1370
|
}
|
|
1273
1371
|
|
|
1274
1372
|
const state = readSessionState(sessionId, ctx.env)
|
|
1275
|
-
const pending = state
|
|
1276
|
-
if (
|
|
1277
|
-
|
|
1278
|
-
|
|
1279
|
-
|
|
1280
|
-
|
|
1281
|
-
|
|
1282
|
-
|
|
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
|
+
}
|
|
1283
1391
|
}
|
|
1284
|
-
|
|
1285
|
-
|
|
1286
|
-
|
|
1287
|
-
|
|
1288
|
-
|
|
1289
|
-
|
|
1290
|
-
|
|
1291
|
-
|
|
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.
|
|
1292
1405
|
}
|
|
1293
1406
|
|
|
1294
1407
|
// A Stop answer may immediately produce a legitimate follow-up question.
|
|
@@ -1298,8 +1411,9 @@ export async function handleStop(ctx: HookContext, envelope: HookEnvelope): Prom
|
|
|
1298
1411
|
const continuation = state.continuation
|
|
1299
1412
|
const isNew =
|
|
1300
1413
|
continuation !== undefined &&
|
|
1301
|
-
|
|
1302
|
-
|
|
1414
|
+
unasked.some(
|
|
1415
|
+
(entry) => entry.asked_at !== undefined && entry.asked_at > continuation.answered_at,
|
|
1416
|
+
)
|
|
1303
1417
|
if (!isNew) {
|
|
1304
1418
|
notes.push('already continuing from an answer; not asking again this turn')
|
|
1305
1419
|
return { notes }
|
|
@@ -1327,7 +1441,7 @@ export async function handleStop(ctx: HookContext, envelope: HookEnvelope): Prom
|
|
|
1327
1441
|
return { notes }
|
|
1328
1442
|
}
|
|
1329
1443
|
try {
|
|
1330
|
-
return await escalate(ctx, envelope, sessionId,
|
|
1444
|
+
return await escalate(ctx, envelope, sessionId, unasked, live, notes)
|
|
1331
1445
|
} finally {
|
|
1332
1446
|
releaseQuestionPush(sessionId, ctx.env)
|
|
1333
1447
|
}
|
|
@@ -1338,95 +1452,138 @@ async function escalate(
|
|
|
1338
1452
|
ctx: HookContext,
|
|
1339
1453
|
envelope: HookEnvelope,
|
|
1340
1454
|
sessionId: string,
|
|
1341
|
-
|
|
1455
|
+
unasked: PendingQuestion[],
|
|
1456
|
+
alreadyLive: PendingQuestion[],
|
|
1342
1457
|
notes: string[],
|
|
1343
1458
|
): Promise<HookOutcome> {
|
|
1344
|
-
// Away right now, but the
|
|
1345
|
-
// window before anything reaches their devices
|
|
1346
|
-
|
|
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)
|
|
1347
1464
|
if (grace === 'user-returned') {
|
|
1348
|
-
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')
|
|
1349
1466
|
return { notes }
|
|
1350
1467
|
}
|
|
1351
1468
|
if (grace === 'no-signal') {
|
|
1352
1469
|
notes.push('no idle signal on this machine; asking now rather than holding the terminal')
|
|
1353
1470
|
}
|
|
1354
1471
|
|
|
1355
|
-
|
|
1356
|
-
|
|
1357
|
-
|
|
1358
|
-
|
|
1359
|
-
|
|
1360
|
-
|
|
1361
|
-
|
|
1362
|
-
|
|
1363
|
-
|
|
1364
|
-
|
|
1365
|
-
|
|
1366
|
-
|
|
1367
|
-
|
|
1368
|
-
|
|
1369
|
-
|
|
1370
|
-
|
|
1371
|
-
|
|
1372
|
-
|
|
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 }
|
|
1373
1521
|
}
|
|
1374
|
-
|
|
1375
|
-
|
|
1376
|
-
|
|
1377
|
-
|
|
1378
|
-
|
|
1379
|
-
|
|
1380
|
-
|
|
1381
|
-
|
|
1382
|
-
|
|
1383
|
-
|
|
1384
|
-
|
|
1385
|
-
|
|
1386
|
-
|
|
1387
|
-
|
|
1388
|
-
|
|
1389
|
-
|
|
1390
|
-
|
|
1391
|
-
|
|
1392
|
-
|
|
1393
|
-
if ('error' in asked) {
|
|
1394
|
-
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
|
+
)
|
|
1395
1541
|
return { notes }
|
|
1396
1542
|
}
|
|
1397
1543
|
|
|
1398
|
-
|
|
1399
|
-
|
|
1400
|
-
|
|
1401
|
-
|
|
1402
|
-
|
|
1403
|
-
|
|
1404
|
-
|
|
1405
|
-
|
|
1406
|
-
|
|
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) {
|
|
1407
1558
|
notes.push(
|
|
1408
|
-
`you came back after the question was sent; returning the terminal while it stays answerable (${
|
|
1409
|
-
'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',
|
|
1410
1561
|
)
|
|
1411
1562
|
} else {
|
|
1412
1563
|
notes.push(
|
|
1413
|
-
|
|
1414
|
-
?
|
|
1415
|
-
:
|
|
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',
|
|
1416
1567
|
)
|
|
1417
1568
|
}
|
|
1418
1569
|
return { notes }
|
|
1419
1570
|
}
|
|
1420
1571
|
|
|
1421
|
-
|
|
1422
|
-
|
|
1423
|
-
|
|
1424
|
-
|
|
1425
|
-
|
|
1426
|
-
|
|
1427
|
-
|
|
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
|
+
}
|
|
1428
1585
|
return {
|
|
1429
|
-
stdout: stopAnswerOutput(
|
|
1586
|
+
stdout: stopAnswerOutput(answersContext(answered, waitingOn.length - answered.length)),
|
|
1430
1587
|
notes,
|
|
1431
1588
|
}
|
|
1432
1589
|
}
|
|
@@ -1455,10 +1612,9 @@ export function handleSessionEnd(
|
|
|
1455
1612
|
|
|
1456
1613
|
const state = readSessionState(sessionId, env)
|
|
1457
1614
|
const orphans: RetiringQuestion[] = [...(state.retiring ?? [])]
|
|
1458
|
-
const
|
|
1459
|
-
if (pending !== undefined) {
|
|
1615
|
+
for (const entry of pendingList(state)) {
|
|
1460
1616
|
try {
|
|
1461
|
-
const orphan = retiringQuestion(
|
|
1617
|
+
const orphan = retiringQuestion(entry, 'expired')
|
|
1462
1618
|
if (orphan !== null) orphans.push(orphan)
|
|
1463
1619
|
} catch (err) {
|
|
1464
1620
|
notes.push(err instanceof Error ? err.message : String(err))
|
|
@@ -1483,18 +1639,24 @@ export function handleSessionEnd(
|
|
|
1483
1639
|
// ---------------------------------------------------------------------------
|
|
1484
1640
|
|
|
1485
1641
|
/**
|
|
1486
|
-
*
|
|
1487
|
-
*
|
|
1488
|
-
*
|
|
1489
|
-
*
|
|
1490
|
-
|
|
1491
|
-
|
|
1492
|
-
|
|
1493
|
-
|
|
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).
|
|
1494
1657
|
*
|
|
1495
|
-
*
|
|
1496
|
-
* running in one project at once, and
|
|
1497
|
-
* 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.
|
|
1498
1660
|
*/
|
|
1499
1661
|
export function registerQuestion(
|
|
1500
1662
|
sessionId: string,
|
|
@@ -1502,27 +1664,31 @@ export function registerQuestion(
|
|
|
1502
1664
|
question: PendingQuestion,
|
|
1503
1665
|
now: number = Date.now(),
|
|
1504
1666
|
): void {
|
|
1667
|
+
let full = false
|
|
1505
1668
|
updateSessionState(sessionId, env, (state) => {
|
|
1506
|
-
const
|
|
1507
|
-
if (
|
|
1508
|
-
|
|
1509
|
-
|
|
1510
|
-
retirement !== null &&
|
|
1511
|
-
!retiring.some((entry) => entry.request_id === retirement.request_id)
|
|
1512
|
-
) {
|
|
1513
|
-
retiring.push(retirement)
|
|
1514
|
-
}
|
|
1669
|
+
const pending = pendingList(state)
|
|
1670
|
+
if (pending.length >= MAX_PENDING_QUESTIONS) {
|
|
1671
|
+
full = true
|
|
1672
|
+
return state
|
|
1515
1673
|
}
|
|
1516
1674
|
return {
|
|
1517
1675
|
...state,
|
|
1518
|
-
|
|
1519
|
-
|
|
1520
|
-
|
|
1521
|
-
|
|
1522
|
-
|
|
1523
|
-
|
|
1676
|
+
pending: [
|
|
1677
|
+
...pending,
|
|
1678
|
+
{
|
|
1679
|
+
asked_at: now,
|
|
1680
|
+
...question,
|
|
1681
|
+
question: question.question.slice(0, MAX_STORED_QUESTION_CHARS),
|
|
1682
|
+
},
|
|
1683
|
+
],
|
|
1524
1684
|
}
|
|
1525
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
|
+
}
|
|
1526
1692
|
}
|
|
1527
1693
|
|
|
1528
1694
|
/** Parses hook JSON from stdin, tolerating an empty or malformed body. */
|