@raidiant/notifai 0.1.7 → 0.2.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/README.md +10 -3
- package/dist/atomic-file.d.ts +12 -0
- package/dist/atomic-file.js +55 -0
- package/dist/atomic-file.js.map +1 -0
- package/dist/client.d.ts +2 -1
- package/dist/client.js +1 -0
- package/dist/client.js.map +1 -1
- package/dist/commands.d.ts +46 -15
- package/dist/commands.js +760 -248
- package/dist/commands.js.map +1 -1
- package/dist/hooks.d.ts +27 -12
- package/dist/hooks.js +519 -155
- package/dist/hooks.js.map +1 -1
- package/dist/install-hooks.d.ts +8 -4
- package/dist/install-hooks.js +45 -64
- package/dist/install-hooks.js.map +1 -1
- package/dist/main.js +80 -36
- package/dist/main.js.map +1 -1
- package/dist/native-skills.d.ts +31 -0
- package/dist/native-skills.js +117 -0
- package/dist/native-skills.js.map +1 -0
- package/dist/opencode-plugin.d.ts +1 -0
- package/dist/opencode-plugin.js +37 -4
- package/dist/opencode-plugin.js.map +1 -1
- package/dist/send.d.ts +19 -29
- package/dist/send.js +62 -57
- package/dist/send.js.map +1 -1
- package/package.json +5 -5
- package/src/atomic-file.ts +73 -0
- package/src/client.ts +3 -0
- package/src/commands.test.ts +1036 -72
- package/src/commands.ts +923 -261
- package/src/config.test.ts +68 -6
- package/src/hooks.test.ts +500 -9
- package/src/hooks.ts +624 -173
- package/src/install-hooks.test.ts +44 -16
- package/src/install-hooks.ts +50 -68
- package/src/main.ts +95 -41
- package/src/native-skills.ts +177 -0
- package/src/opencode-plugin.ts +38 -5
- package/src/send.ts +82 -60
- package/src/skill.test.ts +43 -0
package/src/hooks.ts
CHANGED
|
@@ -1,7 +1,17 @@
|
|
|
1
1
|
import { randomBytes } from 'node:crypto'
|
|
2
|
-
import {
|
|
2
|
+
import {
|
|
3
|
+
closeSync,
|
|
4
|
+
existsSync,
|
|
5
|
+
mkdirSync,
|
|
6
|
+
openSync,
|
|
7
|
+
readFileSync,
|
|
8
|
+
readdirSync,
|
|
9
|
+
rmSync,
|
|
10
|
+
statSync,
|
|
11
|
+
writeFileSync,
|
|
12
|
+
} from 'node:fs'
|
|
3
13
|
import path from 'node:path'
|
|
4
|
-
import type { LifecycleEndState, ReplyView } from '@raidiant/notifai-protocol'
|
|
14
|
+
import type { LifecycleEndState, QuestionT, ReplyView } from '@raidiant/notifai-protocol'
|
|
5
15
|
import type { ApiClient } from './client.js'
|
|
6
16
|
import {
|
|
7
17
|
loadConfig,
|
|
@@ -12,15 +22,16 @@ import {
|
|
|
12
22
|
type CliConfig,
|
|
13
23
|
} from './config.js'
|
|
14
24
|
import { buildDraft } from './send.js'
|
|
25
|
+
import { atomicWriteFileSync } from './atomic-file.js'
|
|
15
26
|
|
|
16
27
|
/**
|
|
17
28
|
* Harness hook handlers.
|
|
18
29
|
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
22
|
-
*
|
|
23
|
-
* detection
|
|
30
|
+
* The supported harnesses expose the same useful lifecycle joints: a turn-end
|
|
31
|
+
* event and an event that fires when the user submits a prompt. Claude Code,
|
|
32
|
+
* Codex, and Cursor can continue directly from a turn-end answer. OpenCode
|
|
33
|
+
* preserves the pending answer at turn end and injects it on the next prompt.
|
|
34
|
+
* No question detection or context-window state is needed in either path.
|
|
24
35
|
*
|
|
25
36
|
* The load-bearing constraint is that these hooks are synchronous: while one
|
|
26
37
|
* blocks, the harness cannot show its own prompt either. So a hook may only
|
|
@@ -61,6 +72,11 @@ export interface SessionState {
|
|
|
61
72
|
* attempt costs nothing and a missed one costs a stale notification for ever.
|
|
62
73
|
*/
|
|
63
74
|
retiring?: RetiringQuestion[]
|
|
75
|
+
/** Tracks bounded Stop continuations so a follow-up ask is delivered once. */
|
|
76
|
+
continuation?: {
|
|
77
|
+
answered_at: number
|
|
78
|
+
count: number
|
|
79
|
+
}
|
|
64
80
|
}
|
|
65
81
|
|
|
66
82
|
/** A delivered question awaiting its retirement push. */
|
|
@@ -104,6 +120,7 @@ const ORPHAN_TTL_MS = 24 * 3600 * 1000
|
|
|
104
120
|
const ORPHAN_QUEUE_CAP = 50
|
|
105
121
|
|
|
106
122
|
export interface PendingQuestion {
|
|
123
|
+
/** One-line summary: the single question's text, or the set's first. */
|
|
107
124
|
question: string
|
|
108
125
|
/**
|
|
109
126
|
* Epoch ms when `notifai ask` registered this. The grace window runs from
|
|
@@ -112,11 +129,12 @@ export interface PendingQuestion {
|
|
|
112
129
|
*/
|
|
113
130
|
asked_at?: number
|
|
114
131
|
/**
|
|
115
|
-
*
|
|
116
|
-
*
|
|
117
|
-
* comma survives the round trip.
|
|
132
|
+
* The full question set as `notifai ask` validated it: generated ids,
|
|
133
|
+
* texts, choices, multi flags. What actually rides the push.
|
|
118
134
|
*/
|
|
119
|
-
|
|
135
|
+
questions?: QuestionT[]
|
|
136
|
+
/** Long-form markdown context, shown in the companion's detail view. */
|
|
137
|
+
detail?: string
|
|
120
138
|
/** Set once the question has actually been pushed, so it can be retired. */
|
|
121
139
|
request_id?: string
|
|
122
140
|
collapse_key?: string
|
|
@@ -217,15 +235,83 @@ export function writeSessionState(
|
|
|
217
235
|
state: SessionState,
|
|
218
236
|
): void {
|
|
219
237
|
const file = sessionStatePath(sessionId, env)
|
|
220
|
-
|
|
221
|
-
writeFileSync(file, `${JSON.stringify(state, null, 2)}\n`, { mode: 0o600 })
|
|
238
|
+
withFileLock(`${file}.lock`, () => writeSessionStateUnlocked(file, state))
|
|
222
239
|
}
|
|
223
240
|
|
|
224
241
|
export function clearSessionState(sessionId: string, env: NodeJS.ProcessEnv): void {
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
242
|
+
const file = sessionStatePath(sessionId, env)
|
|
243
|
+
withFileLock(`${file}.lock`, () => {
|
|
244
|
+
rmSync(file, { force: true })
|
|
245
|
+
// The session override lives in a sibling file; leaving it behind meant a
|
|
246
|
+
// later session reusing the id silently inherited `ask_notifications = false`.
|
|
247
|
+
rmSync(sessionConfigPath(sessionId, env), { force: true })
|
|
248
|
+
})
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
function writeSessionStateUnlocked(file: string, state: SessionState): void {
|
|
252
|
+
atomicWriteFileSync(file, `${JSON.stringify(state, null, 2)}\n`)
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
function updateSessionState(
|
|
256
|
+
sessionId: string,
|
|
257
|
+
env: NodeJS.ProcessEnv,
|
|
258
|
+
update: (current: SessionState) => SessionState,
|
|
259
|
+
): SessionState {
|
|
260
|
+
const file = sessionStatePath(sessionId, env)
|
|
261
|
+
return withFileLock(`${file}.lock`, () => {
|
|
262
|
+
const next = update(readSessionState(sessionId, env))
|
|
263
|
+
writeSessionStateUnlocked(file, next)
|
|
264
|
+
return next
|
|
265
|
+
})
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
/**
|
|
269
|
+
* Serialize a short read-modify-write without ever exposing partial JSON.
|
|
270
|
+
* A crashed holder is recoverable after 30 seconds; releases compare their
|
|
271
|
+
* random token under the same cooperating lock discipline before unlinking.
|
|
272
|
+
*/
|
|
273
|
+
const FILE_LOCK_STALE_MS = 30_000
|
|
274
|
+
const FILE_LOCK_WAIT_MS = 1_000
|
|
275
|
+
const FILE_LOCK_POLL_MS = 5
|
|
276
|
+
const lockSleep = new Int32Array(new SharedArrayBuffer(4))
|
|
277
|
+
|
|
278
|
+
function withFileLock<T>(file: string, action: () => T): T {
|
|
279
|
+
mkdirSync(path.dirname(file), { recursive: true })
|
|
280
|
+
const token = randomBytes(12).toString('base64url')
|
|
281
|
+
const deadline = Date.now() + FILE_LOCK_WAIT_MS
|
|
282
|
+
for (;;) {
|
|
283
|
+
try {
|
|
284
|
+
const handle = openSync(file, 'wx', 0o600)
|
|
285
|
+
try {
|
|
286
|
+
writeFileSync(handle, `${JSON.stringify({ token, at: Date.now() })}\n`)
|
|
287
|
+
} finally {
|
|
288
|
+
closeSync(handle)
|
|
289
|
+
}
|
|
290
|
+
break
|
|
291
|
+
} catch (err) {
|
|
292
|
+
if ((err as NodeJS.ErrnoException).code !== 'EEXIST') throw err
|
|
293
|
+
let stale = false
|
|
294
|
+
try {
|
|
295
|
+
const held = JSON.parse(readFileSync(file, 'utf8')) as { at?: unknown }
|
|
296
|
+
stale = typeof held.at !== 'number' || Date.now() - held.at >= FILE_LOCK_STALE_MS
|
|
297
|
+
} catch {
|
|
298
|
+
stale = true
|
|
299
|
+
}
|
|
300
|
+
if (stale) rmSync(file, { force: true })
|
|
301
|
+
else if (Date.now() >= deadline) throw new Error(`timed out waiting for state lock ${file}`)
|
|
302
|
+
else Atomics.wait(lockSleep, 0, 0, FILE_LOCK_POLL_MS)
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
try {
|
|
306
|
+
return action()
|
|
307
|
+
} finally {
|
|
308
|
+
try {
|
|
309
|
+
const held = JSON.parse(readFileSync(file, 'utf8')) as { token?: unknown }
|
|
310
|
+
if (held.token === token) rmSync(file, { force: true })
|
|
311
|
+
} catch {
|
|
312
|
+
// A replaced or externally removed lock is no longer ours to release.
|
|
313
|
+
}
|
|
314
|
+
}
|
|
229
315
|
}
|
|
230
316
|
|
|
231
317
|
/**
|
|
@@ -288,52 +374,101 @@ export function pruneAbandonedSessions(
|
|
|
288
374
|
* pending question, see no `request_id`, and both escalate: one question, two
|
|
289
375
|
* notifications.
|
|
290
376
|
*
|
|
291
|
-
*
|
|
292
|
-
*
|
|
293
|
-
*
|
|
294
|
-
*
|
|
295
|
-
*
|
|
377
|
+
* Exclusive create is atomic on POSIX, so exactly one process gets the claim
|
|
378
|
+
* and the other steps aside. A short guard serializes stale replacement with
|
|
379
|
+
* contenders and releases; random ownership tokens keep an old holder from
|
|
380
|
+
* unlinking its replacement. A claim older than the hook budget is a crashed
|
|
381
|
+
* process rather than a live one and is broken, avoiding permanent suppression.
|
|
296
382
|
*/
|
|
297
383
|
const CLAIM_TTL_MS = STOP_BUDGET_SECONDS * 1000
|
|
384
|
+
const heldClaims = new Map<string, string>()
|
|
298
385
|
|
|
299
386
|
export function claimQuestionPush(
|
|
300
387
|
sessionId: string,
|
|
301
388
|
env: NodeJS.ProcessEnv,
|
|
302
389
|
now: number = Date.now(),
|
|
390
|
+
beforeStaleReplace?: () => void,
|
|
303
391
|
): boolean {
|
|
304
392
|
const file = claimPath(sessionId, env)
|
|
393
|
+
const guard = `${file}.guard`
|
|
394
|
+
const token = randomBytes(12).toString('base64url')
|
|
305
395
|
mkdirSync(path.dirname(file), { recursive: true })
|
|
396
|
+
if (!acquireClaimGuard(guard)) return false
|
|
306
397
|
try {
|
|
307
|
-
writeFileSync(file, `${JSON.stringify({ pid: process.pid, at: now })}\n`, {
|
|
308
|
-
mode: 0o600,
|
|
309
|
-
flag: 'wx',
|
|
310
|
-
})
|
|
311
|
-
return true
|
|
312
|
-
} catch {
|
|
313
|
-
// Held. Break it only if whoever holds it cannot still be running.
|
|
314
398
|
try {
|
|
315
|
-
|
|
316
|
-
const age = typeof held.at === 'number' ? now - held.at : Number.POSITIVE_INFINITY
|
|
317
|
-
if (age >= 0 && age < CLAIM_TTL_MS) return false
|
|
318
|
-
} catch {
|
|
319
|
-
// Unreadable or corrupt: treat as abandoned.
|
|
320
|
-
}
|
|
321
|
-
rmSync(file, { force: true })
|
|
322
|
-
try {
|
|
323
|
-
writeFileSync(file, `${JSON.stringify({ pid: process.pid, at: now })}\n`, {
|
|
399
|
+
writeFileSync(file, `${JSON.stringify({ pid: process.pid, at: now, token })}\n`, {
|
|
324
400
|
mode: 0o600,
|
|
325
401
|
flag: 'wx',
|
|
326
402
|
})
|
|
403
|
+
heldClaims.set(file, token)
|
|
327
404
|
return true
|
|
328
405
|
} catch {
|
|
329
|
-
//
|
|
330
|
-
|
|
406
|
+
// Held. Break it only if whoever holds it cannot still be running.
|
|
407
|
+
try {
|
|
408
|
+
const held = JSON.parse(readFileSync(file, 'utf8')) as { at?: unknown }
|
|
409
|
+
const age = typeof held.at === 'number' ? now - held.at : Number.POSITIVE_INFINITY
|
|
410
|
+
if (age >= 0 && age < CLAIM_TTL_MS) return false
|
|
411
|
+
} catch {
|
|
412
|
+
// Unreadable or corrupt: treat as abandoned.
|
|
413
|
+
}
|
|
414
|
+
beforeStaleReplace?.()
|
|
415
|
+
rmSync(file, { force: true })
|
|
416
|
+
try {
|
|
417
|
+
writeFileSync(file, `${JSON.stringify({ pid: process.pid, at: now, token })}\n`, {
|
|
418
|
+
mode: 0o600,
|
|
419
|
+
flag: 'wx',
|
|
420
|
+
})
|
|
421
|
+
heldClaims.set(file, token)
|
|
422
|
+
return true
|
|
423
|
+
} catch {
|
|
424
|
+
return false
|
|
425
|
+
}
|
|
331
426
|
}
|
|
427
|
+
} finally {
|
|
428
|
+
rmSync(guard, { force: true })
|
|
332
429
|
}
|
|
333
430
|
}
|
|
334
431
|
|
|
335
432
|
export function releaseQuestionPush(sessionId: string, env: NodeJS.ProcessEnv): void {
|
|
336
|
-
|
|
433
|
+
const file = claimPath(sessionId, env)
|
|
434
|
+
const token = heldClaims.get(file)
|
|
435
|
+
if (token === undefined) return
|
|
436
|
+
const guard = `${file}.guard`
|
|
437
|
+
if (!acquireClaimGuard(guard)) {
|
|
438
|
+
heldClaims.delete(file)
|
|
439
|
+
return
|
|
440
|
+
}
|
|
441
|
+
try {
|
|
442
|
+
try {
|
|
443
|
+
const held = JSON.parse(readFileSync(file, 'utf8')) as { token?: unknown }
|
|
444
|
+
if (held.token === token) rmSync(file, { force: true })
|
|
445
|
+
} catch {
|
|
446
|
+
// Missing or replaced claims are not ours to release.
|
|
447
|
+
}
|
|
448
|
+
} finally {
|
|
449
|
+
heldClaims.delete(file)
|
|
450
|
+
rmSync(guard, { force: true })
|
|
451
|
+
}
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
/** The guard serializes stale replacement and release; recover a crashed guard. */
|
|
455
|
+
function acquireClaimGuard(file: string): boolean {
|
|
456
|
+
for (let attempt = 0; attempt < 2; attempt += 1) {
|
|
457
|
+
try {
|
|
458
|
+
const handle = openSync(file, 'wx', 0o600)
|
|
459
|
+
closeSync(handle)
|
|
460
|
+
return true
|
|
461
|
+
} catch (err) {
|
|
462
|
+
if ((err as NodeJS.ErrnoException).code !== 'EEXIST') return false
|
|
463
|
+
try {
|
|
464
|
+
if (Date.now() - statSync(file).mtimeMs < CLAIM_TTL_MS) return false
|
|
465
|
+
} catch {
|
|
466
|
+
// A vanished/corrupt guard is safe to retry once.
|
|
467
|
+
}
|
|
468
|
+
rmSync(file, { force: true })
|
|
469
|
+
}
|
|
470
|
+
}
|
|
471
|
+
return false
|
|
337
472
|
}
|
|
338
473
|
|
|
339
474
|
function claimPath(sessionId: string, env: NodeJS.ProcessEnv): string {
|
|
@@ -354,14 +489,22 @@ export function writeProjectSession(
|
|
|
354
489
|
env: NodeJS.ProcessEnv,
|
|
355
490
|
sessionId: string,
|
|
356
491
|
now: number,
|
|
492
|
+
harness: HookHarness,
|
|
357
493
|
): void {
|
|
358
494
|
const file = projectSessionPointerPath(cwd, env)
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
495
|
+
withFileLock(`${file}.lock`, () => {
|
|
496
|
+
atomicWriteFileSync(
|
|
497
|
+
file,
|
|
498
|
+
`${JSON.stringify({ session_id: sessionId, updated_at: now, harness })}\n`,
|
|
499
|
+
)
|
|
362
500
|
})
|
|
363
501
|
}
|
|
364
502
|
|
|
503
|
+
export interface ProjectSessionPointer {
|
|
504
|
+
sessionId: string
|
|
505
|
+
harness: HookHarness
|
|
506
|
+
}
|
|
507
|
+
|
|
365
508
|
/**
|
|
366
509
|
* Resolves the session working in `cwd`. Stale pointers are ignored rather than
|
|
367
510
|
* trusted: an id left by a session that ended days ago would send the question
|
|
@@ -373,21 +516,57 @@ export function readProjectSession(
|
|
|
373
516
|
now: number,
|
|
374
517
|
maxAgeMs = 24 * 3600 * 1000,
|
|
375
518
|
): string | null {
|
|
519
|
+
return readProjectSessionPointer(cwd, env, now, maxAgeMs)?.sessionId ?? null
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
export function readProjectSessionPointer(
|
|
523
|
+
cwd: string,
|
|
524
|
+
env: NodeJS.ProcessEnv,
|
|
525
|
+
now: number,
|
|
526
|
+
maxAgeMs = 24 * 3600 * 1000,
|
|
527
|
+
): ProjectSessionPointer | null {
|
|
376
528
|
const file = projectSessionPointerPath(cwd, env)
|
|
377
529
|
if (!existsSync(file)) return null
|
|
378
530
|
try {
|
|
379
531
|
const parsed = JSON.parse(readFileSync(file, 'utf8')) as {
|
|
380
532
|
session_id?: unknown
|
|
381
533
|
updated_at?: unknown
|
|
534
|
+
harness?: unknown
|
|
382
535
|
}
|
|
383
536
|
if (typeof parsed.session_id !== 'string' || parsed.session_id === '') return null
|
|
384
|
-
if (typeof parsed.updated_at
|
|
385
|
-
|
|
537
|
+
if (typeof parsed.updated_at !== 'number' || now - parsed.updated_at > maxAgeMs) return null
|
|
538
|
+
if (!(HOOK_HARNESSES as readonly unknown[]).includes(parsed.harness)) return null
|
|
539
|
+
// A pointer is routing evidence only while its session state still exists
|
|
540
|
+
// and parses. SessionEnd and explicit cleanup therefore invalidate it even
|
|
541
|
+
// when a crash prevented the pointer file itself from being removed.
|
|
542
|
+
const sessionFile = sessionStatePath(parsed.session_id, env)
|
|
543
|
+
if (!existsSync(sessionFile)) return null
|
|
544
|
+
const state: unknown = JSON.parse(readFileSync(sessionFile, 'utf8'))
|
|
545
|
+
if (typeof state !== 'object' || state === null || Array.isArray(state)) return null
|
|
546
|
+
return { sessionId: parsed.session_id, harness: parsed.harness as HookHarness }
|
|
386
547
|
} catch {
|
|
387
548
|
return null
|
|
388
549
|
}
|
|
389
550
|
}
|
|
390
551
|
|
|
552
|
+
const HOOK_HARNESSES = ['claude-code', 'codex', 'cursor', 'opencode'] as const
|
|
553
|
+
|
|
554
|
+
function clearMatchingProjectSession(
|
|
555
|
+
cwd: string,
|
|
556
|
+
env: NodeJS.ProcessEnv,
|
|
557
|
+
sessionId: string,
|
|
558
|
+
): void {
|
|
559
|
+
const file = projectSessionPointerPath(cwd, env)
|
|
560
|
+
withFileLock(`${file}.lock`, () => {
|
|
561
|
+
try {
|
|
562
|
+
const parsed = JSON.parse(readFileSync(file, 'utf8')) as { session_id?: unknown }
|
|
563
|
+
if (parsed.session_id === sessionId) rmSync(file, { force: true })
|
|
564
|
+
} catch {
|
|
565
|
+
// Missing/corrupt is already not a live pointer.
|
|
566
|
+
}
|
|
567
|
+
})
|
|
568
|
+
}
|
|
569
|
+
|
|
391
570
|
/**
|
|
392
571
|
* Absent means the user is not at this machine's keyboard.
|
|
393
572
|
*
|
|
@@ -455,23 +634,40 @@ export interface HookContext {
|
|
|
455
634
|
requestId: string,
|
|
456
635
|
timeoutSeconds: number,
|
|
457
636
|
) => Promise<{ replies: ReplyView[]; timedOut: boolean; degraded?: boolean }>
|
|
637
|
+
/** OpenCode cannot consume Stop stdout, so its adapter must never wait there. */
|
|
638
|
+
harness?: HookHarness
|
|
458
639
|
}
|
|
459
640
|
|
|
641
|
+
export type HookHarness = 'claude-code' | 'codex' | 'cursor' | 'opencode'
|
|
642
|
+
|
|
460
643
|
interface AskResult {
|
|
461
644
|
requestId: string
|
|
462
645
|
collapseKey: string
|
|
646
|
+
/** The answer to act on: the latest reply, because a later one corrects. */
|
|
463
647
|
reply: ReplyView | null
|
|
648
|
+
/** Every reply in arrival order, for free-text answers given in parts. */
|
|
649
|
+
replies: ReplyView[]
|
|
464
650
|
/** The devices the question went to; retirement must not reach any other. */
|
|
465
651
|
devices: string[]
|
|
466
652
|
/** The wait ended amid network failures, so "no answer" is unproven. */
|
|
467
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
|
|
468
658
|
}
|
|
469
659
|
|
|
660
|
+
/** Re-check local presence at this cadence while a pushed question is waiting. */
|
|
661
|
+
const REPLY_PRESENCE_POLL_SECONDS = 5
|
|
662
|
+
|
|
470
663
|
/**
|
|
471
664
|
* Push a question and block for the answer.
|
|
472
665
|
*
|
|
473
|
-
* An answered question is closed
|
|
474
|
-
*
|
|
666
|
+
* An answered question is closed the moment this wait sees the answer: the
|
|
667
|
+
* first device to answer claims the question and the others are told. Until
|
|
668
|
+
* that close, a later reply is a correction — so what this returns as "the"
|
|
669
|
+
* answer is always the latest reply, with the full stream alongside for
|
|
670
|
+
* free-text answers given in parts. An unanswered question stays open,
|
|
475
671
|
* because nobody has acted on the silence yet and the next turn can still
|
|
476
672
|
* collect the answer with `notifai replies`.
|
|
477
673
|
*/
|
|
@@ -480,7 +676,8 @@ async function askAndWait(
|
|
|
480
676
|
options: {
|
|
481
677
|
title: string
|
|
482
678
|
body: string
|
|
483
|
-
|
|
679
|
+
questions: QuestionT[]
|
|
680
|
+
detail?: string | undefined
|
|
484
681
|
event: string
|
|
485
682
|
/** Which agent is asking; two of them must not look alike. */
|
|
486
683
|
session?: string | undefined
|
|
@@ -507,9 +704,9 @@ async function askAndWait(
|
|
|
507
704
|
device: answerable,
|
|
508
705
|
reply: true,
|
|
509
706
|
replyWindow: Math.max(60, options.windowSeconds),
|
|
510
|
-
|
|
707
|
+
questions: options.questions,
|
|
708
|
+
...(options.detail !== undefined ? { detail: options.detail } : {}),
|
|
511
709
|
collapseKey,
|
|
512
|
-
level: 'time_sensitive',
|
|
513
710
|
})
|
|
514
711
|
if (!build.ok) return { error: build.error }
|
|
515
712
|
|
|
@@ -526,14 +723,67 @@ async function askAndWait(
|
|
|
526
723
|
collapseKey,
|
|
527
724
|
devices: answerable,
|
|
528
725
|
})
|
|
529
|
-
|
|
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)
|
|
530
740
|
if (result.replies.length > 0) await closeQuietly(ctx, receipt.request_id)
|
|
531
741
|
return {
|
|
532
742
|
requestId: receipt.request_id,
|
|
533
743
|
collapseKey,
|
|
534
|
-
reply:
|
|
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,
|
|
535
749
|
devices: answerable,
|
|
536
|
-
degraded: result.degraded
|
|
750
|
+
degraded: result.degraded,
|
|
751
|
+
waited: true,
|
|
752
|
+
userReturned: result.userReturned,
|
|
753
|
+
}
|
|
754
|
+
}
|
|
755
|
+
|
|
756
|
+
async function waitForReplyWhileAway(
|
|
757
|
+
ctx: HookContext,
|
|
758
|
+
requestId: string,
|
|
759
|
+
timeoutSeconds: number,
|
|
760
|
+
): Promise<{ replies: ReplyView[]; degraded: boolean; userReturned: boolean }> {
|
|
761
|
+
const deadline = ctx.now() + timeoutSeconds * 1000
|
|
762
|
+
let degraded = false
|
|
763
|
+
let firstPoll = true
|
|
764
|
+
|
|
765
|
+
for (;;) {
|
|
766
|
+
if (ctx.config.require_idle.value) {
|
|
767
|
+
const idle = ctx.idleSeconds()
|
|
768
|
+
if (idle !== null && idle < ctx.config.away_after_seconds.value) {
|
|
769
|
+
return { replies: [], degraded, userReturned: true }
|
|
770
|
+
}
|
|
771
|
+
}
|
|
772
|
+
|
|
773
|
+
const remainingMs = Math.max(0, deadline - ctx.now())
|
|
774
|
+
if (!firstPoll && remainingMs === 0) {
|
|
775
|
+
return { replies: [], degraded, userReturned: false }
|
|
776
|
+
}
|
|
777
|
+
firstPoll = false
|
|
778
|
+
const pollSeconds = Math.min(
|
|
779
|
+
REPLY_PRESENCE_POLL_SECONDS,
|
|
780
|
+
Math.max(0, Math.ceil(remainingMs / 1000)),
|
|
781
|
+
)
|
|
782
|
+
const result = await ctx.waitForFirstReply(requestId, pollSeconds)
|
|
783
|
+
degraded ||= result.degraded === true
|
|
784
|
+
if (result.replies.length > 0) {
|
|
785
|
+
return { replies: result.replies, degraded, userReturned: false }
|
|
786
|
+
}
|
|
537
787
|
}
|
|
538
788
|
}
|
|
539
789
|
|
|
@@ -655,12 +905,17 @@ export function parkForRetirement(
|
|
|
655
905
|
): void {
|
|
656
906
|
const retirement = retiringQuestion(pending, state)
|
|
657
907
|
if (retirement === null) return
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
...
|
|
663
|
-
|
|
908
|
+
updateSessionState(sessionId, env, (current) => {
|
|
909
|
+
const already = current.retiring ?? []
|
|
910
|
+
const existing = already.findIndex((entry) => entry.request_id === retirement.request_id)
|
|
911
|
+
if (existing < 0) return { ...current, retiring: [...already, retirement] }
|
|
912
|
+
const retiring = [...already]
|
|
913
|
+
// An observed answer is final truth and upgrades an earlier supersession
|
|
914
|
+
// parked while the submission callback was racing a newer question.
|
|
915
|
+
if (retirement.state === 'answered' && retiring[existing]!.state !== 'answered') {
|
|
916
|
+
retiring[existing] = retirement
|
|
917
|
+
}
|
|
918
|
+
return { ...current, retiring }
|
|
664
919
|
})
|
|
665
920
|
}
|
|
666
921
|
|
|
@@ -718,7 +973,6 @@ export async function drainRetirements(
|
|
|
718
973
|
if (queue.length === 0) return []
|
|
719
974
|
|
|
720
975
|
const retired: string[] = []
|
|
721
|
-
const remaining: RetiringQuestion[] = []
|
|
722
976
|
for (const entry of queue) {
|
|
723
977
|
await closeQuietly(ctx, entry.request_id)
|
|
724
978
|
const sent = await retire(
|
|
@@ -731,17 +985,18 @@ export async function drainRetirements(
|
|
|
731
985
|
retirementDeviceIds(entry),
|
|
732
986
|
session,
|
|
733
987
|
)
|
|
734
|
-
if (sent)
|
|
735
|
-
|
|
988
|
+
if (sent) {
|
|
989
|
+
retired.push(entry.request_id)
|
|
990
|
+
// Persist each success before touching the next entry. A later corrupt
|
|
991
|
+
// record or interrupted process must not resurrect work already done.
|
|
992
|
+
updateSessionState(sessionId, env, (current) => ({
|
|
993
|
+
...current,
|
|
994
|
+
retiring: (current.retiring ?? []).filter(
|
|
995
|
+
(candidate) => candidate.request_id !== entry.request_id,
|
|
996
|
+
),
|
|
997
|
+
}))
|
|
998
|
+
}
|
|
736
999
|
}
|
|
737
|
-
|
|
738
|
-
// Re-read rather than reusing the snapshot: `notifai ask` may have parked
|
|
739
|
-
// another question while these were in flight.
|
|
740
|
-
const current = readSessionState(sessionId, env)
|
|
741
|
-
const untouched = (current.retiring ?? []).filter(
|
|
742
|
-
(entry) => !retired.includes(entry.request_id),
|
|
743
|
-
)
|
|
744
|
-
writeSessionState(sessionId, env, { ...current, retiring: untouched.length > 0 ? untouched : [] })
|
|
745
1000
|
return retired
|
|
746
1001
|
}
|
|
747
1002
|
|
|
@@ -761,10 +1016,20 @@ function readOrphanQueue(env: NodeJS.ProcessEnv): OrphanRetirement[] {
|
|
|
761
1016
|
}
|
|
762
1017
|
}
|
|
763
1018
|
|
|
764
|
-
function
|
|
1019
|
+
function writeOrphanQueueUnlocked(file: string, queue: OrphanRetirement[]): void {
|
|
1020
|
+
atomicWriteFileSync(file, `${JSON.stringify(queue, null, 2)}\n`)
|
|
1021
|
+
}
|
|
1022
|
+
|
|
1023
|
+
function updateOrphanQueue(
|
|
1024
|
+
env: NodeJS.ProcessEnv,
|
|
1025
|
+
update: (current: OrphanRetirement[]) => OrphanRetirement[],
|
|
1026
|
+
): OrphanRetirement[] {
|
|
765
1027
|
const file = orphanQueuePath(env)
|
|
766
|
-
|
|
767
|
-
|
|
1028
|
+
return withFileLock(`${file}.lock`, () => {
|
|
1029
|
+
const next = update(readOrphanQueue(env))
|
|
1030
|
+
writeOrphanQueueUnlocked(file, next)
|
|
1031
|
+
return next
|
|
1032
|
+
})
|
|
768
1033
|
}
|
|
769
1034
|
|
|
770
1035
|
/** Move retirements into the global queue; deduped so a retry costs nothing. */
|
|
@@ -775,13 +1040,17 @@ export function orphanRetirements(
|
|
|
775
1040
|
now: number,
|
|
776
1041
|
): void {
|
|
777
1042
|
if (entries.length === 0) return
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
1043
|
+
updateOrphanQueue(env, (queue) => {
|
|
1044
|
+
const known = new Set(queue.map((entry) => entry.request_id))
|
|
1045
|
+
const added = entries
|
|
1046
|
+
.filter((entry) => !known.has(entry.request_id))
|
|
1047
|
+
.map((entry) => ({
|
|
1048
|
+
...entry,
|
|
1049
|
+
...(session !== undefined ? { session } : {}),
|
|
1050
|
+
enqueued_at: now,
|
|
1051
|
+
}))
|
|
1052
|
+
return [...queue, ...added].slice(-ORPHAN_QUEUE_CAP)
|
|
1053
|
+
})
|
|
785
1054
|
}
|
|
786
1055
|
|
|
787
1056
|
/**
|
|
@@ -803,6 +1072,9 @@ export async function drainOrphanRetirements(
|
|
|
803
1072
|
// and cheap, so treat it as due rather than letting it linger for ever.
|
|
804
1073
|
if (age > ORPHAN_TTL_MS) {
|
|
805
1074
|
done.push(entry.request_id)
|
|
1075
|
+
updateOrphanQueue(env, (current) =>
|
|
1076
|
+
current.filter((candidate) => candidate.request_id !== entry.request_id),
|
|
1077
|
+
)
|
|
806
1078
|
continue
|
|
807
1079
|
}
|
|
808
1080
|
await closeQuietly(ctx, entry.request_id)
|
|
@@ -816,17 +1088,136 @@ export async function drainOrphanRetirements(
|
|
|
816
1088
|
retirementDeviceIds(entry),
|
|
817
1089
|
entry.session,
|
|
818
1090
|
)
|
|
819
|
-
if (sent)
|
|
1091
|
+
if (sent) {
|
|
1092
|
+
done.push(entry.request_id)
|
|
1093
|
+
updateOrphanQueue(env, (current) =>
|
|
1094
|
+
current.filter((candidate) => candidate.request_id !== entry.request_id),
|
|
1095
|
+
)
|
|
1096
|
+
}
|
|
820
1097
|
}
|
|
821
|
-
|
|
822
|
-
// Re-read: another session's SessionEnd may have queued more while these were
|
|
823
|
-
// in flight, and clobbering its write would recreate the very loss this
|
|
824
|
-
// queue exists to prevent.
|
|
825
|
-
const current = readOrphanQueue(env).filter((entry) => !done.includes(entry.request_id))
|
|
826
|
-
writeOrphanQueue(env, current)
|
|
827
1098
|
return done
|
|
828
1099
|
}
|
|
829
1100
|
|
|
1101
|
+
const LATE_STOP_POLL_SECONDS = 4
|
|
1102
|
+
const LATE_PROMPT_POLL_SECONDS = 3
|
|
1103
|
+
const MAX_CONTINUATION_COUNT = 3
|
|
1104
|
+
|
|
1105
|
+
interface PendingPoll {
|
|
1106
|
+
/** The answer to act on: the latest reply, because a later one corrects. */
|
|
1107
|
+
reply: ReplyView | null
|
|
1108
|
+
/** Every reply in arrival order, for free-text answers given in parts. */
|
|
1109
|
+
replies: ReplyView[]
|
|
1110
|
+
degraded: boolean
|
|
1111
|
+
failed: boolean
|
|
1112
|
+
}
|
|
1113
|
+
|
|
1114
|
+
/** One bounded recovery poll for a question that outlived its original wait. */
|
|
1115
|
+
async function pollPendingReply(
|
|
1116
|
+
ctx: HookContext,
|
|
1117
|
+
pending: PendingQuestion,
|
|
1118
|
+
timeoutSeconds: number,
|
|
1119
|
+
): Promise<PendingPoll> {
|
|
1120
|
+
if (pending.request_id === undefined) {
|
|
1121
|
+
return { reply: null, replies: [], degraded: false, failed: false }
|
|
1122
|
+
}
|
|
1123
|
+
try {
|
|
1124
|
+
const result = await ctx.waitForFirstReply(pending.request_id, timeoutSeconds)
|
|
1125
|
+
return {
|
|
1126
|
+
reply: result.replies.at(-1) ?? null,
|
|
1127
|
+
replies: result.replies,
|
|
1128
|
+
degraded: result.degraded === true,
|
|
1129
|
+
failed: false,
|
|
1130
|
+
}
|
|
1131
|
+
} catch {
|
|
1132
|
+
return { reply: null, replies: [], degraded: true, failed: true }
|
|
1133
|
+
}
|
|
1134
|
+
}
|
|
1135
|
+
|
|
1136
|
+
/** The question set this pending record pushes, however it was registered. */
|
|
1137
|
+
function pendingQuestions(pending: PendingQuestion): QuestionT[] {
|
|
1138
|
+
return pending.questions ?? [{ id: 'q1', text: pending.question }]
|
|
1139
|
+
}
|
|
1140
|
+
|
|
1141
|
+
/** Did any question in the set offer choices? Decides how replies combine. */
|
|
1142
|
+
function pendingHasChoices(pending: PendingQuestion): boolean {
|
|
1143
|
+
return pendingQuestions(pending).some((question) => question.choices !== undefined)
|
|
1144
|
+
}
|
|
1145
|
+
|
|
1146
|
+
/**
|
|
1147
|
+
* The answer as the agent should read it. For a question with choices the
|
|
1148
|
+
* latest reply IS the answer — an earlier conflicting one was corrected by
|
|
1149
|
+
* it. Free-text answers can arrive in parts, and every part reaches the
|
|
1150
|
+
* agent in the order it was written, so it can tell expansion from
|
|
1151
|
+
* correction itself.
|
|
1152
|
+
*/
|
|
1153
|
+
function answerContext(replies: ReplyView[], hadChoices: boolean): string {
|
|
1154
|
+
const latest = replies.at(-1)
|
|
1155
|
+
if (latest === undefined) return 'Notifai — no answer was recorded.'
|
|
1156
|
+
if (replies.length === 1 || hadChoices) {
|
|
1157
|
+
return `Notifai — the user answered from ${latest.device_name}: "${latest.text}". Continue with that answer.`
|
|
1158
|
+
}
|
|
1159
|
+
const parts = replies.map((reply) => `"${reply.text}"`).join(', then ')
|
|
1160
|
+
return (
|
|
1161
|
+
`Notifai — the user answered from ${latest.device_name} in ${replies.length} parts, ` +
|
|
1162
|
+
`in the order written: ${parts}. Later parts extend or correct earlier ones. ` +
|
|
1163
|
+
'Continue with that answer.'
|
|
1164
|
+
)
|
|
1165
|
+
}
|
|
1166
|
+
|
|
1167
|
+
function userPromptAnswerOutput(replies: ReplyView[], hadChoices: boolean): string {
|
|
1168
|
+
return JSON.stringify({
|
|
1169
|
+
hookSpecificOutput: {
|
|
1170
|
+
hookEventName: 'UserPromptSubmit',
|
|
1171
|
+
additionalContext: answerContext(replies, hadChoices),
|
|
1172
|
+
},
|
|
1173
|
+
})
|
|
1174
|
+
}
|
|
1175
|
+
|
|
1176
|
+
function stopAnswerOutput(replies: ReplyView[], hadChoices: boolean): string {
|
|
1177
|
+
return JSON.stringify({ decision: 'block', reason: answerContext(replies, hadChoices) })
|
|
1178
|
+
}
|
|
1179
|
+
|
|
1180
|
+
/**
|
|
1181
|
+
* Close, truthfully retire, and forget one answered pending question without
|
|
1182
|
+
* dropping any other retirement debt. Stop answers also open one bounded
|
|
1183
|
+
* continuation generation; UserPromptSubmit answers ride the user's new turn.
|
|
1184
|
+
*/
|
|
1185
|
+
async function finishPendingAnswer(
|
|
1186
|
+
ctx: HookContext,
|
|
1187
|
+
envelope: HookEnvelope,
|
|
1188
|
+
sessionId: string,
|
|
1189
|
+
pending: PendingQuestion,
|
|
1190
|
+
reply: ReplyView,
|
|
1191
|
+
continuation: boolean,
|
|
1192
|
+
): Promise<void> {
|
|
1193
|
+
const retirement = retiringQuestion(pending, 'answered')
|
|
1194
|
+
updateSessionState(sessionId, ctx.env, (current) => {
|
|
1195
|
+
const retiring = [...(current.retiring ?? [])]
|
|
1196
|
+
if (retirement !== null) {
|
|
1197
|
+
const existing = retiring.findIndex((entry) => entry.request_id === retirement.request_id)
|
|
1198
|
+
if (existing < 0) retiring.push(retirement)
|
|
1199
|
+
else retiring[existing] = retirement
|
|
1200
|
+
}
|
|
1201
|
+
const samePending =
|
|
1202
|
+
current.pending?.question === pending.question &&
|
|
1203
|
+
current.pending?.asked_at === pending.asked_at &&
|
|
1204
|
+
current.pending?.request_id === pending.request_id
|
|
1205
|
+
const next = { ...current, retiring }
|
|
1206
|
+
if (samePending) delete next.pending
|
|
1207
|
+
if (continuation) {
|
|
1208
|
+
next.continuation = {
|
|
1209
|
+
answered_at: ctx.now(),
|
|
1210
|
+
count:
|
|
1211
|
+
(envelope.stop_hook_active === true ? current.continuation?.count ?? 0 : 0) + 1,
|
|
1212
|
+
}
|
|
1213
|
+
} else {
|
|
1214
|
+
next.last_prompt_at = ctx.now()
|
|
1215
|
+
}
|
|
1216
|
+
return next
|
|
1217
|
+
})
|
|
1218
|
+
await drainRetirements(ctx, sessionId, ctx.env, sessionLabel(ctx, envelope))
|
|
1219
|
+
}
|
|
1220
|
+
|
|
830
1221
|
// ---------------------------------------------------------------------------
|
|
831
1222
|
// UserPromptSubmit — the user is at the keyboard
|
|
832
1223
|
// ---------------------------------------------------------------------------
|
|
@@ -847,18 +1238,43 @@ export async function handleUserPromptSubmit(
|
|
|
847
1238
|
|
|
848
1239
|
const state = readSessionState(sessionId, ctx.env)
|
|
849
1240
|
const pending = state.pending
|
|
1241
|
+
if (pending?.request_id !== undefined) {
|
|
1242
|
+
const late = await pollPendingReply(ctx, pending, LATE_PROMPT_POLL_SECONDS)
|
|
1243
|
+
if (late.reply !== null) {
|
|
1244
|
+
await finishPendingAnswer(ctx, envelope, sessionId, pending, late.reply, false)
|
|
1245
|
+
if (envelope.cwd !== undefined && ctx.harness !== undefined) {
|
|
1246
|
+
writeProjectSession(envelope.cwd, ctx.env, sessionId, ctx.now(), ctx.harness)
|
|
1247
|
+
}
|
|
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
|
+
}
|
|
1252
|
+
if (late.failed || late.degraded) {
|
|
1253
|
+
notes.push('could not check the pending question for a late answer before the prompt')
|
|
1254
|
+
}
|
|
1255
|
+
}
|
|
850
1256
|
// Park before dropping `pending`. If the process dies between these writes,
|
|
851
1257
|
// the next hook sees both copies and dedupes them; the old order could die in
|
|
852
1258
|
// the gap after erasing the only request/collapse/device identifiers.
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
1259
|
+
updateSessionState(sessionId, ctx.env, (current) => {
|
|
1260
|
+
const retiring = [...(current.retiring ?? [])]
|
|
1261
|
+
if (current.pending !== undefined) {
|
|
1262
|
+
const retirement = retiringQuestion(current.pending, 'answered_elsewhere')
|
|
1263
|
+
if (
|
|
1264
|
+
retirement !== null &&
|
|
1265
|
+
!retiring.some((entry) => entry.request_id === retirement.request_id)
|
|
1266
|
+
) {
|
|
1267
|
+
retiring.push(retirement)
|
|
1268
|
+
}
|
|
1269
|
+
}
|
|
1270
|
+
return { last_prompt_at: ctx.now(), ...(retiring.length > 0 ? { retiring } : {}) }
|
|
858
1271
|
})
|
|
859
|
-
// The bridge that lets a plain `notifai ask` find
|
|
860
|
-
// shell command gets no hook payload and
|
|
861
|
-
|
|
1272
|
+
// The bridge that lets a plain `notifai ask` find the hook's canonical
|
|
1273
|
+
// session: an agent shell command gets no hook payload, and not every
|
|
1274
|
+
// harness exports an id in the same shape.
|
|
1275
|
+
if (envelope.cwd !== undefined && ctx.harness !== undefined) {
|
|
1276
|
+
writeProjectSession(envelope.cwd, ctx.env, sessionId, ctx.now(), ctx.harness)
|
|
1277
|
+
}
|
|
862
1278
|
|
|
863
1279
|
const retired = await drainRetirements(ctx, sessionId, ctx.env, sessionLabel(ctx, envelope))
|
|
864
1280
|
const orphaned = await drainOrphanRetirements(ctx, ctx.env, ctx.now())
|
|
@@ -903,22 +1319,46 @@ export async function handleStop(ctx: HookContext, envelope: HookEnvelope): Prom
|
|
|
903
1319
|
notes.push(`retired superseded question${swept.length > 1 ? 's' : ''} ${swept.join(', ')}`)
|
|
904
1320
|
}
|
|
905
1321
|
|
|
906
|
-
// The harness sets this when it is already resuming us from a previous Stop
|
|
907
|
-
// decision. Pushing again here is how a question turns into nagging.
|
|
908
|
-
if (envelope.stop_hook_active === true) {
|
|
909
|
-
notes.push('already continuing from an answer; not asking again this turn')
|
|
910
|
-
return { notes }
|
|
911
|
-
}
|
|
912
|
-
|
|
913
1322
|
const state = readSessionState(sessionId, ctx.env)
|
|
914
1323
|
const pending = state.pending
|
|
915
1324
|
if (!pending) return { notes }
|
|
916
1325
|
if (pending.request_id !== undefined) {
|
|
1326
|
+
const late = await pollPendingReply(ctx, pending, LATE_STOP_POLL_SECONDS)
|
|
1327
|
+
if (late.reply !== null) {
|
|
1328
|
+
await finishPendingAnswer(ctx, envelope, sessionId, pending, late.reply, true)
|
|
1329
|
+
notes.push(`late answer from ${late.reply.device_name}: ${late.reply.text}`)
|
|
1330
|
+
return { stdout: stopAnswerOutput(late.replies, pendingHasChoices(pending)), notes }
|
|
1331
|
+
}
|
|
917
1332
|
// Already live on the user's devices from an earlier Stop; asking twice for
|
|
918
1333
|
// one question is the nagging failure this feature exists to avoid.
|
|
919
|
-
notes.push(
|
|
1334
|
+
notes.push(
|
|
1335
|
+
late.failed || late.degraded
|
|
1336
|
+
? `already asked (${pending.request_id}); could not check whether its answer arrived`
|
|
1337
|
+
: `already asked (${pending.request_id}); waiting for that answer`,
|
|
1338
|
+
)
|
|
920
1339
|
return { notes }
|
|
921
1340
|
}
|
|
1341
|
+
|
|
1342
|
+
// A Stop answer may immediately produce a legitimate follow-up question.
|
|
1343
|
+
// Allow that new generation, but never re-run an old pending question and
|
|
1344
|
+
// never let answer continuations become an unbounded agent loop.
|
|
1345
|
+
if (envelope.stop_hook_active === true) {
|
|
1346
|
+
const continuation = state.continuation
|
|
1347
|
+
const isNew =
|
|
1348
|
+
continuation !== undefined &&
|
|
1349
|
+
pending.asked_at !== undefined &&
|
|
1350
|
+
pending.asked_at > continuation.answered_at
|
|
1351
|
+
if (!isNew) {
|
|
1352
|
+
notes.push('already continuing from an answer; not asking again this turn')
|
|
1353
|
+
return { notes }
|
|
1354
|
+
}
|
|
1355
|
+
if (continuation.count >= MAX_CONTINUATION_COUNT) {
|
|
1356
|
+
notes.push(
|
|
1357
|
+
`answer continuation limit (${MAX_CONTINUATION_COUNT}) reached; leaving the question in the terminal`,
|
|
1358
|
+
)
|
|
1359
|
+
return { notes }
|
|
1360
|
+
}
|
|
1361
|
+
}
|
|
922
1362
|
if (!ctx.config.ask_notifications.value) return { notes }
|
|
923
1363
|
if (!isUserAway(state, ctx.config, ctx.now(), ctx.idleSeconds())) {
|
|
924
1364
|
notes.push('you are at the keyboard; leaving the question in the terminal')
|
|
@@ -935,7 +1375,7 @@ export async function handleStop(ctx: HookContext, envelope: HookEnvelope): Prom
|
|
|
935
1375
|
return { notes }
|
|
936
1376
|
}
|
|
937
1377
|
try {
|
|
938
|
-
return await escalate(ctx, envelope, sessionId,
|
|
1378
|
+
return await escalate(ctx, envelope, sessionId, pending, notes)
|
|
939
1379
|
} finally {
|
|
940
1380
|
releaseQuestionPush(sessionId, ctx.env)
|
|
941
1381
|
}
|
|
@@ -946,7 +1386,6 @@ async function escalate(
|
|
|
946
1386
|
ctx: HookContext,
|
|
947
1387
|
envelope: HookEnvelope,
|
|
948
1388
|
sessionId: string,
|
|
949
|
-
state: SessionState,
|
|
950
1389
|
pending: PendingQuestion,
|
|
951
1390
|
notes: string[],
|
|
952
1391
|
): Promise<HookOutcome> {
|
|
@@ -961,26 +1400,48 @@ async function escalate(
|
|
|
961
1400
|
notes.push('no idle signal on this machine; asking now rather than holding the terminal')
|
|
962
1401
|
}
|
|
963
1402
|
|
|
1403
|
+
const questions = pendingQuestions(pending)
|
|
964
1404
|
const asked = await askAndWait(ctx, {
|
|
965
|
-
title:
|
|
966
|
-
|
|
967
|
-
|
|
1405
|
+
title:
|
|
1406
|
+
ctx.config.project.value === null
|
|
1407
|
+
? 'Question'
|
|
1408
|
+
: `Question · ${ctx.config.project.value}`,
|
|
1409
|
+
// A set is answered on the expanded card; the banner leads with the first
|
|
1410
|
+
// question and says how much more is waiting behind it.
|
|
1411
|
+
body:
|
|
1412
|
+
questions.length > 1
|
|
1413
|
+
? `${questions[0]!.text} (+${questions.length - 1} more)`
|
|
1414
|
+
: pending.question,
|
|
1415
|
+
questions,
|
|
1416
|
+
...(pending.detail !== undefined ? { detail: pending.detail } : {}),
|
|
968
1417
|
event: 'agent_question',
|
|
969
1418
|
session: sessionLabel(ctx, envelope),
|
|
970
1419
|
// Outlives the block, and stays open on purpose: the answer is still
|
|
971
1420
|
// useful to the next turn, which collects it with `notifai replies`.
|
|
972
1421
|
windowSeconds: QUESTION_WINDOW_SECONDS,
|
|
973
1422
|
onSubmitted: (live) => {
|
|
974
|
-
|
|
975
|
-
|
|
976
|
-
|
|
977
|
-
|
|
978
|
-
|
|
979
|
-
|
|
980
|
-
|
|
981
|
-
|
|
982
|
-
|
|
983
|
-
|
|
1423
|
+
const livePending: PendingQuestion = {
|
|
1424
|
+
...pending,
|
|
1425
|
+
request_id: live.requestId,
|
|
1426
|
+
collapse_key: live.collapseKey,
|
|
1427
|
+
device_ids: live.devices,
|
|
1428
|
+
}
|
|
1429
|
+
updateSessionState(sessionId, ctx.env, (current) => {
|
|
1430
|
+
const stillCurrent =
|
|
1431
|
+
current.pending?.question === pending.question &&
|
|
1432
|
+
current.pending?.asked_at === pending.asked_at &&
|
|
1433
|
+
current.pending?.request_id === undefined
|
|
1434
|
+
if (stillCurrent) return { ...current, pending: livePending }
|
|
1435
|
+
|
|
1436
|
+
// A newer `notifai ask` won while this submit was in flight. Keep it,
|
|
1437
|
+
// but now that request identifiers exist, preserve the old delivered
|
|
1438
|
+
// question as collectable/retirable instead of overwriting either one.
|
|
1439
|
+
const retirement = retiringQuestion(livePending, 'superseded')!
|
|
1440
|
+
const retiring = [...(current.retiring ?? [])]
|
|
1441
|
+
if (!retiring.some((entry) => entry.request_id === retirement.request_id)) {
|
|
1442
|
+
retiring.push(retirement)
|
|
1443
|
+
}
|
|
1444
|
+
return { ...current, retiring }
|
|
984
1445
|
})
|
|
985
1446
|
},
|
|
986
1447
|
})
|
|
@@ -989,59 +1450,38 @@ async function escalate(
|
|
|
989
1450
|
return { notes }
|
|
990
1451
|
}
|
|
991
1452
|
|
|
992
|
-
// What survives replacing the pending record. `retiring` has to: dropping it
|
|
993
|
-
// is exactly the class of bug this guards against — a delivered notification
|
|
994
|
-
// whose ids no longer exist anywhere.
|
|
995
|
-
const current = readSessionState(sessionId, ctx.env)
|
|
996
|
-
const carried = {
|
|
997
|
-
...(state.last_prompt_at !== undefined ? { last_prompt_at: state.last_prompt_at } : {}),
|
|
998
|
-
...(current.retiring !== undefined ? { retiring: current.retiring } : {}),
|
|
999
|
-
}
|
|
1000
|
-
|
|
1001
1453
|
if (!asked.reply) {
|
|
1002
1454
|
// Keep the pending record so a returning user's UserPromptSubmit can retire
|
|
1003
1455
|
// the notification that is still live on their devices. `request_id` being
|
|
1004
1456
|
// set is also what stops the next Stop pushing the same question again.
|
|
1005
|
-
|
|
1006
|
-
|
|
1007
|
-
|
|
1008
|
-
|
|
1009
|
-
|
|
1010
|
-
|
|
1011
|
-
|
|
1012
|
-
|
|
1013
|
-
|
|
1014
|
-
|
|
1015
|
-
|
|
1016
|
-
|
|
1017
|
-
|
|
1018
|
-
|
|
1457
|
+
if (!asked.waited) {
|
|
1458
|
+
notes.push(
|
|
1459
|
+
`question sent without blocking OpenCode; retrieve the answer on the next prompt or with: notifai replies --pending`,
|
|
1460
|
+
)
|
|
1461
|
+
} else if (asked.userReturned) {
|
|
1462
|
+
notes.push(
|
|
1463
|
+
`you came back after the question was sent; returning the terminal while it stays answerable (${asked.requestId}). ` +
|
|
1464
|
+
'Retrieve it with: notifai replies --pending',
|
|
1465
|
+
)
|
|
1466
|
+
} else {
|
|
1467
|
+
notes.push(
|
|
1468
|
+
asked.degraded
|
|
1469
|
+
? `could not reach the server to find out whether you answered; check with: notifai replies --pending`
|
|
1470
|
+
: `no answer in time; retrieve it later with: notifai replies --pending`,
|
|
1471
|
+
)
|
|
1472
|
+
}
|
|
1019
1473
|
return { notes }
|
|
1020
1474
|
}
|
|
1021
1475
|
|
|
1022
|
-
|
|
1023
|
-
|
|
1024
|
-
|
|
1025
|
-
|
|
1026
|
-
|
|
1027
|
-
|
|
1028
|
-
'Answered',
|
|
1029
|
-
asked.reply.text,
|
|
1030
|
-
'answered',
|
|
1031
|
-
asked.requestId,
|
|
1032
|
-
asked.devices,
|
|
1033
|
-
sessionLabel(ctx, envelope),
|
|
1034
|
-
)
|
|
1476
|
+
await finishPendingAnswer(ctx, envelope, sessionId, {
|
|
1477
|
+
...pending,
|
|
1478
|
+
request_id: asked.requestId,
|
|
1479
|
+
collapse_key: asked.collapseKey,
|
|
1480
|
+
device_ids: asked.devices,
|
|
1481
|
+
}, asked.reply, true)
|
|
1035
1482
|
notes.push(`answer from ${asked.reply.device_name}: ${asked.reply.text}`)
|
|
1036
1483
|
return {
|
|
1037
|
-
stdout:
|
|
1038
|
-
decision: 'block',
|
|
1039
|
-
// Harnesses render a Stop decision under an error-ish label, so this
|
|
1040
|
-
// text must read as an answer on its own rather than as a failure.
|
|
1041
|
-
reason:
|
|
1042
|
-
`NotifAI — the user answered from ${asked.reply.device_name}: ` +
|
|
1043
|
-
`"${asked.reply.text}". Continue with that answer.`,
|
|
1044
|
-
}),
|
|
1484
|
+
stdout: stopAnswerOutput(asked.replies, pendingHasChoices(pending)),
|
|
1045
1485
|
notes,
|
|
1046
1486
|
}
|
|
1047
1487
|
}
|
|
@@ -1066,6 +1506,7 @@ export function handleSessionEnd(
|
|
|
1066
1506
|
const notes: string[] = []
|
|
1067
1507
|
const sessionId = envelope.session_id
|
|
1068
1508
|
if (!sessionId) return { notes }
|
|
1509
|
+
if (envelope.cwd !== undefined) clearMatchingProjectSession(envelope.cwd, env, sessionId)
|
|
1069
1510
|
|
|
1070
1511
|
const state = readSessionState(sessionId, env)
|
|
1071
1512
|
const orphans: RetiringQuestion[] = [...(state.retiring ?? [])]
|
|
@@ -1116,16 +1557,26 @@ export function registerQuestion(
|
|
|
1116
1557
|
question: PendingQuestion,
|
|
1117
1558
|
now: number = Date.now(),
|
|
1118
1559
|
): void {
|
|
1119
|
-
|
|
1120
|
-
|
|
1121
|
-
|
|
1122
|
-
|
|
1123
|
-
|
|
1124
|
-
|
|
1125
|
-
|
|
1126
|
-
|
|
1127
|
-
|
|
1128
|
-
|
|
1560
|
+
updateSessionState(sessionId, env, (state) => {
|
|
1561
|
+
const retiring = [...(state.retiring ?? [])]
|
|
1562
|
+
if (state.pending !== undefined) {
|
|
1563
|
+
const retirement = retiringQuestion(state.pending, 'superseded')
|
|
1564
|
+
if (
|
|
1565
|
+
retirement !== null &&
|
|
1566
|
+
!retiring.some((entry) => entry.request_id === retirement.request_id)
|
|
1567
|
+
) {
|
|
1568
|
+
retiring.push(retirement)
|
|
1569
|
+
}
|
|
1570
|
+
}
|
|
1571
|
+
return {
|
|
1572
|
+
...state,
|
|
1573
|
+
...(retiring.length > 0 ? { retiring } : {}),
|
|
1574
|
+
pending: {
|
|
1575
|
+
asked_at: now,
|
|
1576
|
+
...question,
|
|
1577
|
+
question: question.question.slice(0, MAX_STORED_QUESTION_CHARS),
|
|
1578
|
+
},
|
|
1579
|
+
}
|
|
1129
1580
|
})
|
|
1130
1581
|
}
|
|
1131
1582
|
|