@raidiant/notifai 0.1.7 → 0.1.8
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 +9 -2
- 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 +17 -4
- package/dist/commands.js +643 -212
- package/dist/commands.js.map +1 -1
- package/dist/hooks.d.ts +20 -7
- package/dist/hooks.js +472 -149
- 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 +45 -33
- 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 +0 -3
- package/dist/send.js +28 -13
- package/dist/send.js.map +1 -1
- package/package.json +12 -12
- package/src/atomic-file.ts +73 -0
- package/src/client.ts +3 -0
- package/src/commands.test.ts +932 -63
- package/src/commands.ts +780 -225
- package/src/config.test.ts +68 -6
- package/src/hooks.test.ts +398 -5
- package/src/hooks.ts +557 -161
- package/src/install-hooks.test.ts +36 -1
- package/src/install-hooks.ts +50 -68
- package/src/main.ts +54 -37
- package/src/native-skills.ts +177 -0
- package/src/opencode-plugin.ts +38 -5
- package/src/send.ts +32 -12
- package/src/skill.test.ts +43 -0
package/src/hooks.ts
CHANGED
|
@@ -1,5 +1,15 @@
|
|
|
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
14
|
import type { LifecycleEndState, ReplyView } from '@raidiant/notifai-protocol'
|
|
5
15
|
import type { ApiClient } from './client.js'
|
|
@@ -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. */
|
|
@@ -217,15 +233,83 @@ export function writeSessionState(
|
|
|
217
233
|
state: SessionState,
|
|
218
234
|
): void {
|
|
219
235
|
const file = sessionStatePath(sessionId, env)
|
|
220
|
-
|
|
221
|
-
writeFileSync(file, `${JSON.stringify(state, null, 2)}\n`, { mode: 0o600 })
|
|
236
|
+
withFileLock(`${file}.lock`, () => writeSessionStateUnlocked(file, state))
|
|
222
237
|
}
|
|
223
238
|
|
|
224
239
|
export function clearSessionState(sessionId: string, env: NodeJS.ProcessEnv): void {
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
240
|
+
const file = sessionStatePath(sessionId, env)
|
|
241
|
+
withFileLock(`${file}.lock`, () => {
|
|
242
|
+
rmSync(file, { force: true })
|
|
243
|
+
// The session override lives in a sibling file; leaving it behind meant a
|
|
244
|
+
// later session reusing the id silently inherited `ask_notifications = false`.
|
|
245
|
+
rmSync(sessionConfigPath(sessionId, env), { force: true })
|
|
246
|
+
})
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
function writeSessionStateUnlocked(file: string, state: SessionState): void {
|
|
250
|
+
atomicWriteFileSync(file, `${JSON.stringify(state, null, 2)}\n`)
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
function updateSessionState(
|
|
254
|
+
sessionId: string,
|
|
255
|
+
env: NodeJS.ProcessEnv,
|
|
256
|
+
update: (current: SessionState) => SessionState,
|
|
257
|
+
): SessionState {
|
|
258
|
+
const file = sessionStatePath(sessionId, env)
|
|
259
|
+
return withFileLock(`${file}.lock`, () => {
|
|
260
|
+
const next = update(readSessionState(sessionId, env))
|
|
261
|
+
writeSessionStateUnlocked(file, next)
|
|
262
|
+
return next
|
|
263
|
+
})
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
/**
|
|
267
|
+
* Serialize a short read-modify-write without ever exposing partial JSON.
|
|
268
|
+
* A crashed holder is recoverable after 30 seconds; releases compare their
|
|
269
|
+
* random token under the same cooperating lock discipline before unlinking.
|
|
270
|
+
*/
|
|
271
|
+
const FILE_LOCK_STALE_MS = 30_000
|
|
272
|
+
const FILE_LOCK_WAIT_MS = 1_000
|
|
273
|
+
const FILE_LOCK_POLL_MS = 5
|
|
274
|
+
const lockSleep = new Int32Array(new SharedArrayBuffer(4))
|
|
275
|
+
|
|
276
|
+
function withFileLock<T>(file: string, action: () => T): T {
|
|
277
|
+
mkdirSync(path.dirname(file), { recursive: true })
|
|
278
|
+
const token = randomBytes(12).toString('base64url')
|
|
279
|
+
const deadline = Date.now() + FILE_LOCK_WAIT_MS
|
|
280
|
+
for (;;) {
|
|
281
|
+
try {
|
|
282
|
+
const handle = openSync(file, 'wx', 0o600)
|
|
283
|
+
try {
|
|
284
|
+
writeFileSync(handle, `${JSON.stringify({ token, at: Date.now() })}\n`)
|
|
285
|
+
} finally {
|
|
286
|
+
closeSync(handle)
|
|
287
|
+
}
|
|
288
|
+
break
|
|
289
|
+
} catch (err) {
|
|
290
|
+
if ((err as NodeJS.ErrnoException).code !== 'EEXIST') throw err
|
|
291
|
+
let stale = false
|
|
292
|
+
try {
|
|
293
|
+
const held = JSON.parse(readFileSync(file, 'utf8')) as { at?: unknown }
|
|
294
|
+
stale = typeof held.at !== 'number' || Date.now() - held.at >= FILE_LOCK_STALE_MS
|
|
295
|
+
} catch {
|
|
296
|
+
stale = true
|
|
297
|
+
}
|
|
298
|
+
if (stale) rmSync(file, { force: true })
|
|
299
|
+
else if (Date.now() >= deadline) throw new Error(`timed out waiting for state lock ${file}`)
|
|
300
|
+
else Atomics.wait(lockSleep, 0, 0, FILE_LOCK_POLL_MS)
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
try {
|
|
304
|
+
return action()
|
|
305
|
+
} finally {
|
|
306
|
+
try {
|
|
307
|
+
const held = JSON.parse(readFileSync(file, 'utf8')) as { token?: unknown }
|
|
308
|
+
if (held.token === token) rmSync(file, { force: true })
|
|
309
|
+
} catch {
|
|
310
|
+
// A replaced or externally removed lock is no longer ours to release.
|
|
311
|
+
}
|
|
312
|
+
}
|
|
229
313
|
}
|
|
230
314
|
|
|
231
315
|
/**
|
|
@@ -288,52 +372,101 @@ export function pruneAbandonedSessions(
|
|
|
288
372
|
* pending question, see no `request_id`, and both escalate: one question, two
|
|
289
373
|
* notifications.
|
|
290
374
|
*
|
|
291
|
-
*
|
|
292
|
-
*
|
|
293
|
-
*
|
|
294
|
-
*
|
|
295
|
-
*
|
|
375
|
+
* Exclusive create is atomic on POSIX, so exactly one process gets the claim
|
|
376
|
+
* and the other steps aside. A short guard serializes stale replacement with
|
|
377
|
+
* contenders and releases; random ownership tokens keep an old holder from
|
|
378
|
+
* unlinking its replacement. A claim older than the hook budget is a crashed
|
|
379
|
+
* process rather than a live one and is broken, avoiding permanent suppression.
|
|
296
380
|
*/
|
|
297
381
|
const CLAIM_TTL_MS = STOP_BUDGET_SECONDS * 1000
|
|
382
|
+
const heldClaims = new Map<string, string>()
|
|
298
383
|
|
|
299
384
|
export function claimQuestionPush(
|
|
300
385
|
sessionId: string,
|
|
301
386
|
env: NodeJS.ProcessEnv,
|
|
302
387
|
now: number = Date.now(),
|
|
388
|
+
beforeStaleReplace?: () => void,
|
|
303
389
|
): boolean {
|
|
304
390
|
const file = claimPath(sessionId, env)
|
|
391
|
+
const guard = `${file}.guard`
|
|
392
|
+
const token = randomBytes(12).toString('base64url')
|
|
305
393
|
mkdirSync(path.dirname(file), { recursive: true })
|
|
394
|
+
if (!acquireClaimGuard(guard)) return false
|
|
306
395
|
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
|
-
try {
|
|
315
|
-
const held = JSON.parse(readFileSync(file, 'utf8')) as { at?: unknown }
|
|
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
396
|
try {
|
|
323
|
-
writeFileSync(file, `${JSON.stringify({ pid: process.pid, at: now })}\n`, {
|
|
397
|
+
writeFileSync(file, `${JSON.stringify({ pid: process.pid, at: now, token })}\n`, {
|
|
324
398
|
mode: 0o600,
|
|
325
399
|
flag: 'wx',
|
|
326
400
|
})
|
|
401
|
+
heldClaims.set(file, token)
|
|
327
402
|
return true
|
|
328
403
|
} catch {
|
|
329
|
-
//
|
|
330
|
-
|
|
404
|
+
// Held. Break it only if whoever holds it cannot still be running.
|
|
405
|
+
try {
|
|
406
|
+
const held = JSON.parse(readFileSync(file, 'utf8')) as { at?: unknown }
|
|
407
|
+
const age = typeof held.at === 'number' ? now - held.at : Number.POSITIVE_INFINITY
|
|
408
|
+
if (age >= 0 && age < CLAIM_TTL_MS) return false
|
|
409
|
+
} catch {
|
|
410
|
+
// Unreadable or corrupt: treat as abandoned.
|
|
411
|
+
}
|
|
412
|
+
beforeStaleReplace?.()
|
|
413
|
+
rmSync(file, { force: true })
|
|
414
|
+
try {
|
|
415
|
+
writeFileSync(file, `${JSON.stringify({ pid: process.pid, at: now, token })}\n`, {
|
|
416
|
+
mode: 0o600,
|
|
417
|
+
flag: 'wx',
|
|
418
|
+
})
|
|
419
|
+
heldClaims.set(file, token)
|
|
420
|
+
return true
|
|
421
|
+
} catch {
|
|
422
|
+
return false
|
|
423
|
+
}
|
|
331
424
|
}
|
|
425
|
+
} finally {
|
|
426
|
+
rmSync(guard, { force: true })
|
|
332
427
|
}
|
|
333
428
|
}
|
|
334
429
|
|
|
335
430
|
export function releaseQuestionPush(sessionId: string, env: NodeJS.ProcessEnv): void {
|
|
336
|
-
|
|
431
|
+
const file = claimPath(sessionId, env)
|
|
432
|
+
const token = heldClaims.get(file)
|
|
433
|
+
if (token === undefined) return
|
|
434
|
+
const guard = `${file}.guard`
|
|
435
|
+
if (!acquireClaimGuard(guard)) {
|
|
436
|
+
heldClaims.delete(file)
|
|
437
|
+
return
|
|
438
|
+
}
|
|
439
|
+
try {
|
|
440
|
+
try {
|
|
441
|
+
const held = JSON.parse(readFileSync(file, 'utf8')) as { token?: unknown }
|
|
442
|
+
if (held.token === token) rmSync(file, { force: true })
|
|
443
|
+
} catch {
|
|
444
|
+
// Missing or replaced claims are not ours to release.
|
|
445
|
+
}
|
|
446
|
+
} finally {
|
|
447
|
+
heldClaims.delete(file)
|
|
448
|
+
rmSync(guard, { force: true })
|
|
449
|
+
}
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
/** The guard serializes stale replacement and release; recover a crashed guard. */
|
|
453
|
+
function acquireClaimGuard(file: string): boolean {
|
|
454
|
+
for (let attempt = 0; attempt < 2; attempt += 1) {
|
|
455
|
+
try {
|
|
456
|
+
const handle = openSync(file, 'wx', 0o600)
|
|
457
|
+
closeSync(handle)
|
|
458
|
+
return true
|
|
459
|
+
} catch (err) {
|
|
460
|
+
if ((err as NodeJS.ErrnoException).code !== 'EEXIST') return false
|
|
461
|
+
try {
|
|
462
|
+
if (Date.now() - statSync(file).mtimeMs < CLAIM_TTL_MS) return false
|
|
463
|
+
} catch {
|
|
464
|
+
// A vanished/corrupt guard is safe to retry once.
|
|
465
|
+
}
|
|
466
|
+
rmSync(file, { force: true })
|
|
467
|
+
}
|
|
468
|
+
}
|
|
469
|
+
return false
|
|
337
470
|
}
|
|
338
471
|
|
|
339
472
|
function claimPath(sessionId: string, env: NodeJS.ProcessEnv): string {
|
|
@@ -354,14 +487,22 @@ export function writeProjectSession(
|
|
|
354
487
|
env: NodeJS.ProcessEnv,
|
|
355
488
|
sessionId: string,
|
|
356
489
|
now: number,
|
|
490
|
+
harness: HookHarness,
|
|
357
491
|
): void {
|
|
358
492
|
const file = projectSessionPointerPath(cwd, env)
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
493
|
+
withFileLock(`${file}.lock`, () => {
|
|
494
|
+
atomicWriteFileSync(
|
|
495
|
+
file,
|
|
496
|
+
`${JSON.stringify({ session_id: sessionId, updated_at: now, harness })}\n`,
|
|
497
|
+
)
|
|
362
498
|
})
|
|
363
499
|
}
|
|
364
500
|
|
|
501
|
+
export interface ProjectSessionPointer {
|
|
502
|
+
sessionId: string
|
|
503
|
+
harness: HookHarness
|
|
504
|
+
}
|
|
505
|
+
|
|
365
506
|
/**
|
|
366
507
|
* Resolves the session working in `cwd`. Stale pointers are ignored rather than
|
|
367
508
|
* trusted: an id left by a session that ended days ago would send the question
|
|
@@ -373,21 +514,57 @@ export function readProjectSession(
|
|
|
373
514
|
now: number,
|
|
374
515
|
maxAgeMs = 24 * 3600 * 1000,
|
|
375
516
|
): string | null {
|
|
517
|
+
return readProjectSessionPointer(cwd, env, now, maxAgeMs)?.sessionId ?? null
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
export function readProjectSessionPointer(
|
|
521
|
+
cwd: string,
|
|
522
|
+
env: NodeJS.ProcessEnv,
|
|
523
|
+
now: number,
|
|
524
|
+
maxAgeMs = 24 * 3600 * 1000,
|
|
525
|
+
): ProjectSessionPointer | null {
|
|
376
526
|
const file = projectSessionPointerPath(cwd, env)
|
|
377
527
|
if (!existsSync(file)) return null
|
|
378
528
|
try {
|
|
379
529
|
const parsed = JSON.parse(readFileSync(file, 'utf8')) as {
|
|
380
530
|
session_id?: unknown
|
|
381
531
|
updated_at?: unknown
|
|
532
|
+
harness?: unknown
|
|
382
533
|
}
|
|
383
534
|
if (typeof parsed.session_id !== 'string' || parsed.session_id === '') return null
|
|
384
|
-
if (typeof parsed.updated_at
|
|
385
|
-
|
|
535
|
+
if (typeof parsed.updated_at !== 'number' || now - parsed.updated_at > maxAgeMs) return null
|
|
536
|
+
if (!(HOOK_HARNESSES as readonly unknown[]).includes(parsed.harness)) return null
|
|
537
|
+
// A pointer is routing evidence only while its session state still exists
|
|
538
|
+
// and parses. SessionEnd and explicit cleanup therefore invalidate it even
|
|
539
|
+
// when a crash prevented the pointer file itself from being removed.
|
|
540
|
+
const sessionFile = sessionStatePath(parsed.session_id, env)
|
|
541
|
+
if (!existsSync(sessionFile)) return null
|
|
542
|
+
const state: unknown = JSON.parse(readFileSync(sessionFile, 'utf8'))
|
|
543
|
+
if (typeof state !== 'object' || state === null || Array.isArray(state)) return null
|
|
544
|
+
return { sessionId: parsed.session_id, harness: parsed.harness as HookHarness }
|
|
386
545
|
} catch {
|
|
387
546
|
return null
|
|
388
547
|
}
|
|
389
548
|
}
|
|
390
549
|
|
|
550
|
+
const HOOK_HARNESSES = ['claude-code', 'codex', 'cursor', 'opencode'] as const
|
|
551
|
+
|
|
552
|
+
function clearMatchingProjectSession(
|
|
553
|
+
cwd: string,
|
|
554
|
+
env: NodeJS.ProcessEnv,
|
|
555
|
+
sessionId: string,
|
|
556
|
+
): void {
|
|
557
|
+
const file = projectSessionPointerPath(cwd, env)
|
|
558
|
+
withFileLock(`${file}.lock`, () => {
|
|
559
|
+
try {
|
|
560
|
+
const parsed = JSON.parse(readFileSync(file, 'utf8')) as { session_id?: unknown }
|
|
561
|
+
if (parsed.session_id === sessionId) rmSync(file, { force: true })
|
|
562
|
+
} catch {
|
|
563
|
+
// Missing/corrupt is already not a live pointer.
|
|
564
|
+
}
|
|
565
|
+
})
|
|
566
|
+
}
|
|
567
|
+
|
|
391
568
|
/**
|
|
392
569
|
* Absent means the user is not at this machine's keyboard.
|
|
393
570
|
*
|
|
@@ -455,8 +632,12 @@ export interface HookContext {
|
|
|
455
632
|
requestId: string,
|
|
456
633
|
timeoutSeconds: number,
|
|
457
634
|
) => Promise<{ replies: ReplyView[]; timedOut: boolean; degraded?: boolean }>
|
|
635
|
+
/** OpenCode cannot consume Stop stdout, so its adapter must never wait there. */
|
|
636
|
+
harness?: HookHarness
|
|
458
637
|
}
|
|
459
638
|
|
|
639
|
+
export type HookHarness = 'claude-code' | 'codex' | 'cursor' | 'opencode'
|
|
640
|
+
|
|
460
641
|
interface AskResult {
|
|
461
642
|
requestId: string
|
|
462
643
|
collapseKey: string
|
|
@@ -465,8 +646,15 @@ interface AskResult {
|
|
|
465
646
|
devices: string[]
|
|
466
647
|
/** The wait ended amid network failures, so "no answer" is unproven. */
|
|
467
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
|
|
468
653
|
}
|
|
469
654
|
|
|
655
|
+
/** Re-check local presence at this cadence while a pushed question is waiting. */
|
|
656
|
+
const REPLY_PRESENCE_POLL_SECONDS = 5
|
|
657
|
+
|
|
470
658
|
/**
|
|
471
659
|
* Push a question and block for the answer.
|
|
472
660
|
*
|
|
@@ -509,7 +697,6 @@ async function askAndWait(
|
|
|
509
697
|
replyWindow: Math.max(60, options.windowSeconds),
|
|
510
698
|
...(options.choices !== undefined ? { replyChoice: options.choices } : {}),
|
|
511
699
|
collapseKey,
|
|
512
|
-
level: 'time_sensitive',
|
|
513
700
|
})
|
|
514
701
|
if (!build.ok) return { error: build.error }
|
|
515
702
|
|
|
@@ -526,14 +713,62 @@ async function askAndWait(
|
|
|
526
713
|
collapseKey,
|
|
527
714
|
devices: answerable,
|
|
528
715
|
})
|
|
529
|
-
|
|
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)
|
|
530
729
|
if (result.replies.length > 0) await closeQuietly(ctx, receipt.request_id)
|
|
531
730
|
return {
|
|
532
731
|
requestId: receipt.request_id,
|
|
533
732
|
collapseKey,
|
|
534
733
|
reply: result.replies[0] ?? null,
|
|
535
734
|
devices: answerable,
|
|
536
|
-
degraded: result.degraded
|
|
735
|
+
degraded: result.degraded,
|
|
736
|
+
waited: true,
|
|
737
|
+
userReturned: result.userReturned,
|
|
738
|
+
}
|
|
739
|
+
}
|
|
740
|
+
|
|
741
|
+
async function waitForReplyWhileAway(
|
|
742
|
+
ctx: HookContext,
|
|
743
|
+
requestId: string,
|
|
744
|
+
timeoutSeconds: number,
|
|
745
|
+
): Promise<{ replies: ReplyView[]; degraded: boolean; userReturned: boolean }> {
|
|
746
|
+
const deadline = ctx.now() + timeoutSeconds * 1000
|
|
747
|
+
let degraded = false
|
|
748
|
+
let firstPoll = true
|
|
749
|
+
|
|
750
|
+
for (;;) {
|
|
751
|
+
if (ctx.config.require_idle.value) {
|
|
752
|
+
const idle = ctx.idleSeconds()
|
|
753
|
+
if (idle !== null && idle < ctx.config.away_after_seconds.value) {
|
|
754
|
+
return { replies: [], degraded, userReturned: true }
|
|
755
|
+
}
|
|
756
|
+
}
|
|
757
|
+
|
|
758
|
+
const remainingMs = Math.max(0, deadline - ctx.now())
|
|
759
|
+
if (!firstPoll && remainingMs === 0) {
|
|
760
|
+
return { replies: [], degraded, userReturned: false }
|
|
761
|
+
}
|
|
762
|
+
firstPoll = false
|
|
763
|
+
const pollSeconds = Math.min(
|
|
764
|
+
REPLY_PRESENCE_POLL_SECONDS,
|
|
765
|
+
Math.max(0, Math.ceil(remainingMs / 1000)),
|
|
766
|
+
)
|
|
767
|
+
const result = await ctx.waitForFirstReply(requestId, pollSeconds)
|
|
768
|
+
degraded ||= result.degraded === true
|
|
769
|
+
if (result.replies.length > 0) {
|
|
770
|
+
return { replies: result.replies, degraded, userReturned: false }
|
|
771
|
+
}
|
|
537
772
|
}
|
|
538
773
|
}
|
|
539
774
|
|
|
@@ -655,12 +890,17 @@ export function parkForRetirement(
|
|
|
655
890
|
): void {
|
|
656
891
|
const retirement = retiringQuestion(pending, state)
|
|
657
892
|
if (retirement === null) return
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
...
|
|
663
|
-
|
|
893
|
+
updateSessionState(sessionId, env, (current) => {
|
|
894
|
+
const already = current.retiring ?? []
|
|
895
|
+
const existing = already.findIndex((entry) => entry.request_id === retirement.request_id)
|
|
896
|
+
if (existing < 0) return { ...current, retiring: [...already, retirement] }
|
|
897
|
+
const retiring = [...already]
|
|
898
|
+
// An observed answer is final truth and upgrades an earlier supersession
|
|
899
|
+
// parked while the submission callback was racing a newer question.
|
|
900
|
+
if (retirement.state === 'answered' && retiring[existing]!.state !== 'answered') {
|
|
901
|
+
retiring[existing] = retirement
|
|
902
|
+
}
|
|
903
|
+
return { ...current, retiring }
|
|
664
904
|
})
|
|
665
905
|
}
|
|
666
906
|
|
|
@@ -718,7 +958,6 @@ export async function drainRetirements(
|
|
|
718
958
|
if (queue.length === 0) return []
|
|
719
959
|
|
|
720
960
|
const retired: string[] = []
|
|
721
|
-
const remaining: RetiringQuestion[] = []
|
|
722
961
|
for (const entry of queue) {
|
|
723
962
|
await closeQuietly(ctx, entry.request_id)
|
|
724
963
|
const sent = await retire(
|
|
@@ -731,17 +970,18 @@ export async function drainRetirements(
|
|
|
731
970
|
retirementDeviceIds(entry),
|
|
732
971
|
session,
|
|
733
972
|
)
|
|
734
|
-
if (sent)
|
|
735
|
-
|
|
973
|
+
if (sent) {
|
|
974
|
+
retired.push(entry.request_id)
|
|
975
|
+
// Persist each success before touching the next entry. A later corrupt
|
|
976
|
+
// record or interrupted process must not resurrect work already done.
|
|
977
|
+
updateSessionState(sessionId, env, (current) => ({
|
|
978
|
+
...current,
|
|
979
|
+
retiring: (current.retiring ?? []).filter(
|
|
980
|
+
(candidate) => candidate.request_id !== entry.request_id,
|
|
981
|
+
),
|
|
982
|
+
}))
|
|
983
|
+
}
|
|
736
984
|
}
|
|
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
985
|
return retired
|
|
746
986
|
}
|
|
747
987
|
|
|
@@ -761,10 +1001,20 @@ function readOrphanQueue(env: NodeJS.ProcessEnv): OrphanRetirement[] {
|
|
|
761
1001
|
}
|
|
762
1002
|
}
|
|
763
1003
|
|
|
764
|
-
function
|
|
1004
|
+
function writeOrphanQueueUnlocked(file: string, queue: OrphanRetirement[]): void {
|
|
1005
|
+
atomicWriteFileSync(file, `${JSON.stringify(queue, null, 2)}\n`)
|
|
1006
|
+
}
|
|
1007
|
+
|
|
1008
|
+
function updateOrphanQueue(
|
|
1009
|
+
env: NodeJS.ProcessEnv,
|
|
1010
|
+
update: (current: OrphanRetirement[]) => OrphanRetirement[],
|
|
1011
|
+
): OrphanRetirement[] {
|
|
765
1012
|
const file = orphanQueuePath(env)
|
|
766
|
-
|
|
767
|
-
|
|
1013
|
+
return withFileLock(`${file}.lock`, () => {
|
|
1014
|
+
const next = update(readOrphanQueue(env))
|
|
1015
|
+
writeOrphanQueueUnlocked(file, next)
|
|
1016
|
+
return next
|
|
1017
|
+
})
|
|
768
1018
|
}
|
|
769
1019
|
|
|
770
1020
|
/** Move retirements into the global queue; deduped so a retry costs nothing. */
|
|
@@ -775,13 +1025,17 @@ export function orphanRetirements(
|
|
|
775
1025
|
now: number,
|
|
776
1026
|
): void {
|
|
777
1027
|
if (entries.length === 0) return
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
1028
|
+
updateOrphanQueue(env, (queue) => {
|
|
1029
|
+
const known = new Set(queue.map((entry) => entry.request_id))
|
|
1030
|
+
const added = entries
|
|
1031
|
+
.filter((entry) => !known.has(entry.request_id))
|
|
1032
|
+
.map((entry) => ({
|
|
1033
|
+
...entry,
|
|
1034
|
+
...(session !== undefined ? { session } : {}),
|
|
1035
|
+
enqueued_at: now,
|
|
1036
|
+
}))
|
|
1037
|
+
return [...queue, ...added].slice(-ORPHAN_QUEUE_CAP)
|
|
1038
|
+
})
|
|
785
1039
|
}
|
|
786
1040
|
|
|
787
1041
|
/**
|
|
@@ -803,6 +1057,9 @@ export async function drainOrphanRetirements(
|
|
|
803
1057
|
// and cheap, so treat it as due rather than letting it linger for ever.
|
|
804
1058
|
if (age > ORPHAN_TTL_MS) {
|
|
805
1059
|
done.push(entry.request_id)
|
|
1060
|
+
updateOrphanQueue(env, (current) =>
|
|
1061
|
+
current.filter((candidate) => candidate.request_id !== entry.request_id),
|
|
1062
|
+
)
|
|
806
1063
|
continue
|
|
807
1064
|
}
|
|
808
1065
|
await closeQuietly(ctx, entry.request_id)
|
|
@@ -816,17 +1073,103 @@ export async function drainOrphanRetirements(
|
|
|
816
1073
|
retirementDeviceIds(entry),
|
|
817
1074
|
entry.session,
|
|
818
1075
|
)
|
|
819
|
-
if (sent)
|
|
1076
|
+
if (sent) {
|
|
1077
|
+
done.push(entry.request_id)
|
|
1078
|
+
updateOrphanQueue(env, (current) =>
|
|
1079
|
+
current.filter((candidate) => candidate.request_id !== entry.request_id),
|
|
1080
|
+
)
|
|
1081
|
+
}
|
|
820
1082
|
}
|
|
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
1083
|
return done
|
|
828
1084
|
}
|
|
829
1085
|
|
|
1086
|
+
const LATE_STOP_POLL_SECONDS = 4
|
|
1087
|
+
const LATE_PROMPT_POLL_SECONDS = 3
|
|
1088
|
+
const MAX_CONTINUATION_COUNT = 3
|
|
1089
|
+
|
|
1090
|
+
interface PendingPoll {
|
|
1091
|
+
reply: ReplyView | null
|
|
1092
|
+
degraded: boolean
|
|
1093
|
+
failed: boolean
|
|
1094
|
+
}
|
|
1095
|
+
|
|
1096
|
+
/** One bounded recovery poll for a question that outlived its original wait. */
|
|
1097
|
+
async function pollPendingReply(
|
|
1098
|
+
ctx: HookContext,
|
|
1099
|
+
pending: PendingQuestion,
|
|
1100
|
+
timeoutSeconds: number,
|
|
1101
|
+
): Promise<PendingPoll> {
|
|
1102
|
+
if (pending.request_id === undefined) return { reply: null, degraded: false, failed: false }
|
|
1103
|
+
try {
|
|
1104
|
+
const result = await ctx.waitForFirstReply(pending.request_id, timeoutSeconds)
|
|
1105
|
+
return {
|
|
1106
|
+
reply: result.replies[0] ?? null,
|
|
1107
|
+
degraded: result.degraded === true,
|
|
1108
|
+
failed: false,
|
|
1109
|
+
}
|
|
1110
|
+
} catch {
|
|
1111
|
+
return { reply: null, degraded: true, failed: true }
|
|
1112
|
+
}
|
|
1113
|
+
}
|
|
1114
|
+
|
|
1115
|
+
function answerContext(reply: ReplyView): string {
|
|
1116
|
+
return `Notifai — the user answered from ${reply.device_name}: "${reply.text}". Continue with that answer.`
|
|
1117
|
+
}
|
|
1118
|
+
|
|
1119
|
+
function userPromptAnswerOutput(reply: ReplyView): string {
|
|
1120
|
+
return JSON.stringify({
|
|
1121
|
+
hookSpecificOutput: {
|
|
1122
|
+
hookEventName: 'UserPromptSubmit',
|
|
1123
|
+
additionalContext: answerContext(reply),
|
|
1124
|
+
},
|
|
1125
|
+
})
|
|
1126
|
+
}
|
|
1127
|
+
|
|
1128
|
+
function stopAnswerOutput(reply: ReplyView): string {
|
|
1129
|
+
return JSON.stringify({ decision: 'block', reason: answerContext(reply) })
|
|
1130
|
+
}
|
|
1131
|
+
|
|
1132
|
+
/**
|
|
1133
|
+
* Close, truthfully retire, and forget one answered pending question without
|
|
1134
|
+
* dropping any other retirement debt. Stop answers also open one bounded
|
|
1135
|
+
* continuation generation; UserPromptSubmit answers ride the user's new turn.
|
|
1136
|
+
*/
|
|
1137
|
+
async function finishPendingAnswer(
|
|
1138
|
+
ctx: HookContext,
|
|
1139
|
+
envelope: HookEnvelope,
|
|
1140
|
+
sessionId: string,
|
|
1141
|
+
pending: PendingQuestion,
|
|
1142
|
+
reply: ReplyView,
|
|
1143
|
+
continuation: boolean,
|
|
1144
|
+
): Promise<void> {
|
|
1145
|
+
const retirement = retiringQuestion(pending, 'answered')
|
|
1146
|
+
updateSessionState(sessionId, ctx.env, (current) => {
|
|
1147
|
+
const retiring = [...(current.retiring ?? [])]
|
|
1148
|
+
if (retirement !== null) {
|
|
1149
|
+
const existing = retiring.findIndex((entry) => entry.request_id === retirement.request_id)
|
|
1150
|
+
if (existing < 0) retiring.push(retirement)
|
|
1151
|
+
else retiring[existing] = retirement
|
|
1152
|
+
}
|
|
1153
|
+
const samePending =
|
|
1154
|
+
current.pending?.question === pending.question &&
|
|
1155
|
+
current.pending?.asked_at === pending.asked_at &&
|
|
1156
|
+
current.pending?.request_id === pending.request_id
|
|
1157
|
+
const next = { ...current, retiring }
|
|
1158
|
+
if (samePending) delete next.pending
|
|
1159
|
+
if (continuation) {
|
|
1160
|
+
next.continuation = {
|
|
1161
|
+
answered_at: ctx.now(),
|
|
1162
|
+
count:
|
|
1163
|
+
(envelope.stop_hook_active === true ? current.continuation?.count ?? 0 : 0) + 1,
|
|
1164
|
+
}
|
|
1165
|
+
} else {
|
|
1166
|
+
next.last_prompt_at = ctx.now()
|
|
1167
|
+
}
|
|
1168
|
+
return next
|
|
1169
|
+
})
|
|
1170
|
+
await drainRetirements(ctx, sessionId, ctx.env, sessionLabel(ctx, envelope))
|
|
1171
|
+
}
|
|
1172
|
+
|
|
830
1173
|
// ---------------------------------------------------------------------------
|
|
831
1174
|
// UserPromptSubmit — the user is at the keyboard
|
|
832
1175
|
// ---------------------------------------------------------------------------
|
|
@@ -847,18 +1190,43 @@ export async function handleUserPromptSubmit(
|
|
|
847
1190
|
|
|
848
1191
|
const state = readSessionState(sessionId, ctx.env)
|
|
849
1192
|
const pending = state.pending
|
|
1193
|
+
if (pending?.request_id !== undefined) {
|
|
1194
|
+
const late = await pollPendingReply(ctx, pending, LATE_PROMPT_POLL_SECONDS)
|
|
1195
|
+
if (late.reply !== null) {
|
|
1196
|
+
await finishPendingAnswer(ctx, envelope, sessionId, pending, late.reply, false)
|
|
1197
|
+
if (envelope.cwd !== undefined && ctx.harness !== undefined) {
|
|
1198
|
+
writeProjectSession(envelope.cwd, ctx.env, sessionId, ctx.now(), ctx.harness)
|
|
1199
|
+
}
|
|
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
|
+
}
|
|
1204
|
+
if (late.failed || late.degraded) {
|
|
1205
|
+
notes.push('could not check the pending question for a late answer before the prompt')
|
|
1206
|
+
}
|
|
1207
|
+
}
|
|
850
1208
|
// Park before dropping `pending`. If the process dies between these writes,
|
|
851
1209
|
// the next hook sees both copies and dedupes them; the old order could die in
|
|
852
1210
|
// the gap after erasing the only request/collapse/device identifiers.
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
1211
|
+
updateSessionState(sessionId, ctx.env, (current) => {
|
|
1212
|
+
const retiring = [...(current.retiring ?? [])]
|
|
1213
|
+
if (current.pending !== undefined) {
|
|
1214
|
+
const retirement = retiringQuestion(current.pending, 'answered_elsewhere')
|
|
1215
|
+
if (
|
|
1216
|
+
retirement !== null &&
|
|
1217
|
+
!retiring.some((entry) => entry.request_id === retirement.request_id)
|
|
1218
|
+
) {
|
|
1219
|
+
retiring.push(retirement)
|
|
1220
|
+
}
|
|
1221
|
+
}
|
|
1222
|
+
return { last_prompt_at: ctx.now(), ...(retiring.length > 0 ? { retiring } : {}) }
|
|
858
1223
|
})
|
|
859
|
-
// The bridge that lets a plain `notifai ask` find
|
|
860
|
-
// shell command gets no hook payload and
|
|
861
|
-
|
|
1224
|
+
// The bridge that lets a plain `notifai ask` find the hook's canonical
|
|
1225
|
+
// session: an agent shell command gets no hook payload, and not every
|
|
1226
|
+
// harness exports an id in the same shape.
|
|
1227
|
+
if (envelope.cwd !== undefined && ctx.harness !== undefined) {
|
|
1228
|
+
writeProjectSession(envelope.cwd, ctx.env, sessionId, ctx.now(), ctx.harness)
|
|
1229
|
+
}
|
|
862
1230
|
|
|
863
1231
|
const retired = await drainRetirements(ctx, sessionId, ctx.env, sessionLabel(ctx, envelope))
|
|
864
1232
|
const orphaned = await drainOrphanRetirements(ctx, ctx.env, ctx.now())
|
|
@@ -903,22 +1271,46 @@ export async function handleStop(ctx: HookContext, envelope: HookEnvelope): Prom
|
|
|
903
1271
|
notes.push(`retired superseded question${swept.length > 1 ? 's' : ''} ${swept.join(', ')}`)
|
|
904
1272
|
}
|
|
905
1273
|
|
|
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
1274
|
const state = readSessionState(sessionId, ctx.env)
|
|
914
1275
|
const pending = state.pending
|
|
915
1276
|
if (!pending) return { notes }
|
|
916
1277
|
if (pending.request_id !== undefined) {
|
|
1278
|
+
const late = await pollPendingReply(ctx, pending, LATE_STOP_POLL_SECONDS)
|
|
1279
|
+
if (late.reply !== null) {
|
|
1280
|
+
await finishPendingAnswer(ctx, envelope, sessionId, pending, late.reply, true)
|
|
1281
|
+
notes.push(`late answer from ${late.reply.device_name}: ${late.reply.text}`)
|
|
1282
|
+
return { stdout: stopAnswerOutput(late.reply), notes }
|
|
1283
|
+
}
|
|
917
1284
|
// Already live on the user's devices from an earlier Stop; asking twice for
|
|
918
1285
|
// one question is the nagging failure this feature exists to avoid.
|
|
919
|
-
notes.push(
|
|
1286
|
+
notes.push(
|
|
1287
|
+
late.failed || late.degraded
|
|
1288
|
+
? `already asked (${pending.request_id}); could not check whether its answer arrived`
|
|
1289
|
+
: `already asked (${pending.request_id}); waiting for that answer`,
|
|
1290
|
+
)
|
|
920
1291
|
return { notes }
|
|
921
1292
|
}
|
|
1293
|
+
|
|
1294
|
+
// A Stop answer may immediately produce a legitimate follow-up question.
|
|
1295
|
+
// Allow that new generation, but never re-run an old pending question and
|
|
1296
|
+
// never let answer continuations become an unbounded agent loop.
|
|
1297
|
+
if (envelope.stop_hook_active === true) {
|
|
1298
|
+
const continuation = state.continuation
|
|
1299
|
+
const isNew =
|
|
1300
|
+
continuation !== undefined &&
|
|
1301
|
+
pending.asked_at !== undefined &&
|
|
1302
|
+
pending.asked_at > continuation.answered_at
|
|
1303
|
+
if (!isNew) {
|
|
1304
|
+
notes.push('already continuing from an answer; not asking again this turn')
|
|
1305
|
+
return { notes }
|
|
1306
|
+
}
|
|
1307
|
+
if (continuation.count >= MAX_CONTINUATION_COUNT) {
|
|
1308
|
+
notes.push(
|
|
1309
|
+
`answer continuation limit (${MAX_CONTINUATION_COUNT}) reached; leaving the question in the terminal`,
|
|
1310
|
+
)
|
|
1311
|
+
return { notes }
|
|
1312
|
+
}
|
|
1313
|
+
}
|
|
922
1314
|
if (!ctx.config.ask_notifications.value) return { notes }
|
|
923
1315
|
if (!isUserAway(state, ctx.config, ctx.now(), ctx.idleSeconds())) {
|
|
924
1316
|
notes.push('you are at the keyboard; leaving the question in the terminal')
|
|
@@ -935,7 +1327,7 @@ export async function handleStop(ctx: HookContext, envelope: HookEnvelope): Prom
|
|
|
935
1327
|
return { notes }
|
|
936
1328
|
}
|
|
937
1329
|
try {
|
|
938
|
-
return await escalate(ctx, envelope, sessionId,
|
|
1330
|
+
return await escalate(ctx, envelope, sessionId, pending, notes)
|
|
939
1331
|
} finally {
|
|
940
1332
|
releaseQuestionPush(sessionId, ctx.env)
|
|
941
1333
|
}
|
|
@@ -946,7 +1338,6 @@ async function escalate(
|
|
|
946
1338
|
ctx: HookContext,
|
|
947
1339
|
envelope: HookEnvelope,
|
|
948
1340
|
sessionId: string,
|
|
949
|
-
state: SessionState,
|
|
950
1341
|
pending: PendingQuestion,
|
|
951
1342
|
notes: string[],
|
|
952
1343
|
): Promise<HookOutcome> {
|
|
@@ -962,7 +1353,10 @@ async function escalate(
|
|
|
962
1353
|
}
|
|
963
1354
|
|
|
964
1355
|
const asked = await askAndWait(ctx, {
|
|
965
|
-
title:
|
|
1356
|
+
title:
|
|
1357
|
+
ctx.config.project.value === null
|
|
1358
|
+
? 'Question'
|
|
1359
|
+
: `Question · ${ctx.config.project.value}`,
|
|
966
1360
|
body: pending.question,
|
|
967
1361
|
...(pending.choices !== undefined ? { choices: pending.choices } : {}),
|
|
968
1362
|
event: 'agent_question',
|
|
@@ -971,16 +1365,28 @@ async function escalate(
|
|
|
971
1365
|
// useful to the next turn, which collects it with `notifai replies`.
|
|
972
1366
|
windowSeconds: QUESTION_WINDOW_SECONDS,
|
|
973
1367
|
onSubmitted: (live) => {
|
|
974
|
-
|
|
975
|
-
|
|
976
|
-
|
|
977
|
-
|
|
978
|
-
|
|
979
|
-
|
|
980
|
-
|
|
981
|
-
|
|
982
|
-
|
|
983
|
-
|
|
1368
|
+
const livePending: PendingQuestion = {
|
|
1369
|
+
...pending,
|
|
1370
|
+
request_id: live.requestId,
|
|
1371
|
+
collapse_key: live.collapseKey,
|
|
1372
|
+
device_ids: live.devices,
|
|
1373
|
+
}
|
|
1374
|
+
updateSessionState(sessionId, ctx.env, (current) => {
|
|
1375
|
+
const stillCurrent =
|
|
1376
|
+
current.pending?.question === pending.question &&
|
|
1377
|
+
current.pending?.asked_at === pending.asked_at &&
|
|
1378
|
+
current.pending?.request_id === undefined
|
|
1379
|
+
if (stillCurrent) return { ...current, pending: livePending }
|
|
1380
|
+
|
|
1381
|
+
// A newer `notifai ask` won while this submit was in flight. Keep it,
|
|
1382
|
+
// but now that request identifiers exist, preserve the old delivered
|
|
1383
|
+
// question as collectable/retirable instead of overwriting either one.
|
|
1384
|
+
const retirement = retiringQuestion(livePending, 'superseded')!
|
|
1385
|
+
const retiring = [...(current.retiring ?? [])]
|
|
1386
|
+
if (!retiring.some((entry) => entry.request_id === retirement.request_id)) {
|
|
1387
|
+
retiring.push(retirement)
|
|
1388
|
+
}
|
|
1389
|
+
return { ...current, retiring }
|
|
984
1390
|
})
|
|
985
1391
|
},
|
|
986
1392
|
})
|
|
@@ -989,59 +1395,38 @@ async function escalate(
|
|
|
989
1395
|
return { notes }
|
|
990
1396
|
}
|
|
991
1397
|
|
|
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
1398
|
if (!asked.reply) {
|
|
1002
1399
|
// Keep the pending record so a returning user's UserPromptSubmit can retire
|
|
1003
1400
|
// the notification that is still live on their devices. `request_id` being
|
|
1004
1401
|
// 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
|
-
|
|
1402
|
+
if (!asked.waited) {
|
|
1403
|
+
notes.push(
|
|
1404
|
+
`question sent without blocking OpenCode; retrieve the answer on the next prompt or with: notifai replies --pending`,
|
|
1405
|
+
)
|
|
1406
|
+
} else if (asked.userReturned) {
|
|
1407
|
+
notes.push(
|
|
1408
|
+
`you came back after the question was sent; returning the terminal while it stays answerable (${asked.requestId}). ` +
|
|
1409
|
+
'Retrieve it with: notifai replies --pending',
|
|
1410
|
+
)
|
|
1411
|
+
} else {
|
|
1412
|
+
notes.push(
|
|
1413
|
+
asked.degraded
|
|
1414
|
+
? `could not reach the server to find out whether you answered; check with: notifai replies --pending`
|
|
1415
|
+
: `no answer in time; retrieve it later with: notifai replies --pending`,
|
|
1416
|
+
)
|
|
1417
|
+
}
|
|
1019
1418
|
return { notes }
|
|
1020
1419
|
}
|
|
1021
1420
|
|
|
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
|
-
)
|
|
1421
|
+
await finishPendingAnswer(ctx, envelope, sessionId, {
|
|
1422
|
+
...pending,
|
|
1423
|
+
request_id: asked.requestId,
|
|
1424
|
+
collapse_key: asked.collapseKey,
|
|
1425
|
+
device_ids: asked.devices,
|
|
1426
|
+
}, asked.reply, true)
|
|
1035
1427
|
notes.push(`answer from ${asked.reply.device_name}: ${asked.reply.text}`)
|
|
1036
1428
|
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
|
-
}),
|
|
1429
|
+
stdout: stopAnswerOutput(asked.reply),
|
|
1045
1430
|
notes,
|
|
1046
1431
|
}
|
|
1047
1432
|
}
|
|
@@ -1066,6 +1451,7 @@ export function handleSessionEnd(
|
|
|
1066
1451
|
const notes: string[] = []
|
|
1067
1452
|
const sessionId = envelope.session_id
|
|
1068
1453
|
if (!sessionId) return { notes }
|
|
1454
|
+
if (envelope.cwd !== undefined) clearMatchingProjectSession(envelope.cwd, env, sessionId)
|
|
1069
1455
|
|
|
1070
1456
|
const state = readSessionState(sessionId, env)
|
|
1071
1457
|
const orphans: RetiringQuestion[] = [...(state.retiring ?? [])]
|
|
@@ -1116,16 +1502,26 @@ export function registerQuestion(
|
|
|
1116
1502
|
question: PendingQuestion,
|
|
1117
1503
|
now: number = Date.now(),
|
|
1118
1504
|
): void {
|
|
1119
|
-
|
|
1120
|
-
|
|
1121
|
-
|
|
1122
|
-
|
|
1123
|
-
|
|
1124
|
-
|
|
1125
|
-
|
|
1126
|
-
|
|
1127
|
-
|
|
1128
|
-
|
|
1505
|
+
updateSessionState(sessionId, env, (state) => {
|
|
1506
|
+
const retiring = [...(state.retiring ?? [])]
|
|
1507
|
+
if (state.pending !== undefined) {
|
|
1508
|
+
const retirement = retiringQuestion(state.pending, 'superseded')
|
|
1509
|
+
if (
|
|
1510
|
+
retirement !== null &&
|
|
1511
|
+
!retiring.some((entry) => entry.request_id === retirement.request_id)
|
|
1512
|
+
) {
|
|
1513
|
+
retiring.push(retirement)
|
|
1514
|
+
}
|
|
1515
|
+
}
|
|
1516
|
+
return {
|
|
1517
|
+
...state,
|
|
1518
|
+
...(retiring.length > 0 ? { retiring } : {}),
|
|
1519
|
+
pending: {
|
|
1520
|
+
asked_at: now,
|
|
1521
|
+
...question,
|
|
1522
|
+
question: question.question.slice(0, MAX_STORED_QUESTION_CHARS),
|
|
1523
|
+
},
|
|
1524
|
+
}
|
|
1129
1525
|
})
|
|
1130
1526
|
}
|
|
1131
1527
|
|