@raidiant/notifai 0.4.0 → 0.5.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (62) hide show
  1. package/README.md +3 -4
  2. package/dist/claude-wake.d.ts +101 -0
  3. package/dist/claude-wake.js +397 -0
  4. package/dist/claude-wake.js.map +1 -0
  5. package/dist/codex-wake.d.ts +84 -0
  6. package/dist/codex-wake.js +238 -0
  7. package/dist/codex-wake.js.map +1 -0
  8. package/dist/commands.d.ts +15 -10
  9. package/dist/commands.js +321 -88
  10. package/dist/commands.js.map +1 -1
  11. package/dist/config-schema.d.ts +2 -2
  12. package/dist/config-schema.js +9 -35
  13. package/dist/config-schema.js.map +1 -1
  14. package/dist/config.d.ts +5 -38
  15. package/dist/config.js +11 -32
  16. package/dist/config.js.map +1 -1
  17. package/dist/harnesses.d.ts +3 -13
  18. package/dist/harnesses.js +6 -17
  19. package/dist/harnesses.js.map +1 -1
  20. package/dist/hooks.d.ts +119 -65
  21. package/dist/hooks.js +330 -285
  22. package/dist/hooks.js.map +1 -1
  23. package/dist/install-hooks.d.ts +22 -7
  24. package/dist/install-hooks.js +43 -28
  25. package/dist/install-hooks.js.map +1 -1
  26. package/dist/interactive.d.ts +1 -1
  27. package/dist/interactive.js +2 -4
  28. package/dist/interactive.js.map +1 -1
  29. package/dist/main.js +6 -13
  30. package/dist/main.js.map +1 -1
  31. package/dist/release.d.ts +31 -0
  32. package/dist/release.js +52 -0
  33. package/dist/release.js.map +1 -0
  34. package/dist/ui/theme.d.ts +1 -1
  35. package/package.json +9 -9
  36. package/src/claude-wake.test.ts +320 -0
  37. package/src/claude-wake.ts +489 -0
  38. package/src/codex-wake.test.ts +253 -0
  39. package/src/codex-wake.ts +288 -0
  40. package/src/commands.test.ts +393 -40
  41. package/src/commands.ts +433 -109
  42. package/src/config-schema.test.ts +3 -3
  43. package/src/config-schema.ts +11 -40
  44. package/src/config.test.ts +21 -16
  45. package/src/config.ts +15 -69
  46. package/src/harnesses.test.ts +7 -21
  47. package/src/harnesses.ts +17 -19
  48. package/src/hooks.test.ts +780 -290
  49. package/src/hooks.ts +466 -339
  50. package/src/install-hooks.test.ts +44 -3
  51. package/src/install-hooks.ts +43 -28
  52. package/src/interactive.ts +2 -7
  53. package/src/logging.test.ts +3 -3
  54. package/src/main.ts +6 -12
  55. package/src/release.ts +51 -0
  56. package/src/skill.test.ts +23 -0
  57. package/src/ui/config-view.test.ts +5 -5
  58. package/dist/idle.d.ts +0 -16
  59. package/dist/idle.js +0 -44
  60. package/dist/idle.js.map +0 -1
  61. package/src/idle.test.ts +0 -32
  62. package/src/idle.ts +0 -41
package/dist/hooks.d.ts CHANGED
@@ -2,7 +2,7 @@ import type { LifecycleEndState, NotificationDraftT, QuestionT, ReplyView } from
2
2
  import { type ApiClient } from './client.js';
3
3
  import { type CliConfig } from './config.js';
4
4
  import type { Logger } from './logging.js';
5
- import { type Harness } from './harnesses.js';
5
+ import { type DeliveryRoute, type Harness } from './harnesses.js';
6
6
  /**
7
7
  * Harness hook handlers.
8
8
  *
@@ -11,10 +11,13 @@ import { type Harness } from './harnesses.js';
11
11
  * and Codex can continue directly from a turn-end answer. Harnesses without a
12
12
  * proven exact-session continuation fail closed at question admission.
13
13
  *
14
- * The load-bearing constraint is that these hooks are synchronous: while one
15
- * blocks, the harness cannot show its own prompt either. So a hook may only
16
- * take over when the user is demonstrably absent. Present user, or no evidence
17
- * either way, means exit immediately and let the terminal do its job.
14
+ * Where the user is standing no longer decides anything here. It used to: the
15
+ * turn-end hook held the terminal for as long as it waited, so waiting was
16
+ * only defensible while the person was demonstrably not at the keyboard. On
17
+ * Claude Code the handler now returns instantly and the waiter runs out of
18
+ * band, and nothing is monopolised in the first place — so the question is
19
+ * simply whether the user wants to be reached, which they answer once in
20
+ * configuration rather than implicitly with every keystroke.
18
21
  */
19
22
  /** Fields we read from harness hook JSON. Everything else is passed through. */
20
23
  export interface HookEnvelope {
@@ -57,16 +60,20 @@ export interface SessionState {
57
60
  * costs nothing and a missed one costs a stale notification for ever.
58
61
  */
59
62
  retiring?: RetiringQuestion[];
60
- /** Tracks bounded Stop continuations so a follow-up ask is delivered once. */
63
+ /**
64
+ * Tracks bounded Stop continuations so a follow-up ask is delivered once.
65
+ * `count` is how many answer generations have run consecutively without the
66
+ * user taking a turn themselves; their next prompt starts it over.
67
+ */
61
68
  continuation?: {
62
69
  answered_at: number;
63
70
  count: number;
64
71
  };
65
72
  /**
66
- * A phone answer durably captured but not yet acknowledged by a successor
67
- * hook. It stays here until the next Stop proves the harness processed the
68
- * continuation; a crash before stdout therefore replays instead of erasing
69
- * the user's answer.
73
+ * A phone answer durably captured but not yet acknowledged. It stays here
74
+ * until a delivery is acknowledged — by the route's own write, or by the
75
+ * successor Stop of a blocking continuation — so a crash before the answer
76
+ * reaches the harness replays it instead of erasing it.
70
77
  */
71
78
  accepted?: AcceptedAnswerDelivery;
72
79
  }
@@ -74,7 +81,20 @@ interface AcceptedAnswerDelivery {
74
81
  answers: AnsweredPending[];
75
82
  remaining: number;
76
83
  recorded_at: number;
77
- continuation_count?: number;
84
+ /**
85
+ * Epoch ms when a route's own write handed this answer to the harness, with
86
+ * the route that performed it. Recorded so that "this answer was delivered"
87
+ * is a fact on the journal rather than something inferred from a harness flag
88
+ * that only one route ever sets.
89
+ */
90
+ delivered_at?: number;
91
+ delivered_route?: string;
92
+ /**
93
+ * How many times this answer has been handed to any route. The route-agnostic
94
+ * loop backstop: bounded by `MAX_CONTINUATION_COUNT` in one place every route
95
+ * passes through.
96
+ */
97
+ delivery_attempts?: number;
78
98
  }
79
99
  /** A delivered question awaiting its retirement push. */
80
100
  export interface RetiringQuestion {
@@ -139,33 +159,16 @@ interface PendingSubmissionIntent {
139
159
  draft: NotificationDraftT;
140
160
  owner_deadline_at: number;
141
161
  }
142
- export type GraceOutcome =
143
- /** The window elapsed with the user still gone; escalate. */
144
- 'absent'
145
- /** The user touched the machine; the terminal is theirs. */
146
- | 'user-returned'
147
- /** No idle source, so waiting would hold a terminal we cannot monitor. */
148
- | 'no-signal';
149
162
  /**
150
- * The terminal-first wait: the question sits in the terminal for
151
- * `ask_grace_seconds` from when it was sent, and only then reaches companion
152
- * devices.
153
- *
154
- * Holding a blocking Stop hook open is normally hostile — while it blocks, the
155
- * harness cannot show its prompt either, so a user wanting to answer locally is
156
- * locked out. What makes it safe is that the wait is abandoned the moment the
157
- * user touches the keyboard or mouse. The block therefore only ever persists
158
- * while they are demonstrably not using the machine, which costs them nothing.
163
+ * The waiter's whole wall clock: the terminal-first timer, submission, and the
164
+ * wait for an answer, from the moment the turn-end hook starts.
159
165
  *
160
- * That safety depends entirely on the idle signal, so with no idle source this
161
- * refuses to wait at all rather than holding a terminal it cannot monitor.
162
- *
163
- * With `require_idle` off the whole calculus changes: the user has said they
164
- * want the question to reach them whether or not they are at the keyboard, so
165
- * there is nothing to watch for and this becomes what its name always claimed —
166
- * a plain timer. It then works on machines with no idle source too.
166
+ * One plain ceiling, deliberately not a budget carved into reserves. The
167
+ * reserves existed to fit every phase inside a *held* turn; on Claude Code the
168
+ * turn is not held any more, and on a host that still holds one the useful
169
+ * question is only how long that host is willing to wait in total.
167
170
  */
168
- export declare function awaitGrace(ctx: HookContext, askedAt: number): Promise<GraceOutcome>;
171
+ export declare const WAITER_CEILING_SECONDS = 480;
169
172
  export interface HookOutcome {
170
173
  /** Written to stdout verbatim — the harness parses this as the decision. */
171
174
  stdout?: string;
@@ -174,6 +177,54 @@ export interface HookOutcome {
174
177
  /** Structured lifecycle detail that belongs on hook.end without user text. */
175
178
  log?: Record<string, unknown>;
176
179
  }
180
+ /** An accepted continuation ready for whichever host owns the last meter. */
181
+ export interface ContinuationEvent {
182
+ context: string;
183
+ answers: number;
184
+ remaining: number;
185
+ request_ids: string[];
186
+ journal_recorded_at: number;
187
+ }
188
+ /**
189
+ * How much a route's own return proves about where the answer ended up.
190
+ *
191
+ * Acknowledgement belongs to the delivery, not to the harness envelope: routes
192
+ * end in different places, so no single field on a hook payload can speak for
193
+ * all of them. `stop_hook_active` is the harness confirming that a *blocking
194
+ * continuation* was admitted; it stays false for ever on a route that starts a
195
+ * brand-new turn instead of continuing this one, so a journal keyed to it alone
196
+ * never settles and every later turn-end redelivers the same answer.
197
+ *
198
+ * - `delivered` — the route completed a write to the harness itself (an inbox
199
+ * socket, a cold resume). Delivery is not consumption: the write proves the
200
+ * harness accepted the message, never that the model acted on it. But nothing
201
+ * later will ever prove more, and redelivering an answer without end is
202
+ * strictly worse than settling on the write, so the journal settles here.
203
+ * - `stdout` — the answer is this process's stdout, which the harness reads only
204
+ * after this process exits. Nothing this process can write is proof, so the
205
+ * journal waits for the successor Stop's `stop_hook_active`, and a crash
206
+ * before stdout replays the answer instead of losing it.
207
+ * - `held` — the route handed nothing over; the journal replays the answer.
208
+ */
209
+ export type DeliveryAcknowledgement = 'delivered' | 'stdout' | 'held';
210
+ /** What a route returns: the hook's outcome plus what the attempt proved. */
211
+ export interface DeliveryOutcome {
212
+ stdout?: string;
213
+ notes?: string[];
214
+ log?: Record<string, unknown>;
215
+ acknowledgement: DeliveryAcknowledgement;
216
+ }
217
+ /** Host adapter injected into the waiter; no route is implemented by the waiter. */
218
+ export interface EscalationDeliveryRoute {
219
+ kind: Exclude<DeliveryRoute, 'unsupported'>;
220
+ deliver(event: ContinuationEvent): Promise<DeliveryOutcome>;
221
+ }
222
+ export interface EscalationWaiterOptions {
223
+ sessionId: string;
224
+ envelope: HookEnvelope;
225
+ route: EscalationDeliveryRoute;
226
+ processDeadlineAt?: number;
227
+ }
177
228
  export declare function readSessionState(sessionId: string, env: NodeJS.ProcessEnv): SessionState;
178
229
  export declare function writeSessionState(sessionId: string, env: NodeJS.ProcessEnv, state: SessionState): void;
179
230
  export declare function clearSessionState(sessionId: string, env: NodeJS.ProcessEnv): void;
@@ -192,42 +243,25 @@ export interface ProjectSessionPointer {
192
243
  * into state nothing is watching.
193
244
  */
194
245
  export declare function readProjectSession(cwd: string, env: NodeJS.ProcessEnv, now: number, maxAgeMs?: number): string | null;
195
- export declare function readProjectSessionPointer(cwd: string, env: NodeJS.ProcessEnv, now: number, maxAgeMs?: number): ProjectSessionPointer | null;
196
- export declare function readMatchingProjectSessionPointer(cwd: string, env: NodeJS.ProcessEnv, now: number, sessionId: string, harness: HookHarness, maxAgeMs?: number): ProjectSessionPointer | null;
246
+ /** A live pointer keeps when it was last written, so callers can rank by it. */
247
+ export interface LiveProjectSessionPointer extends ProjectSessionPointer {
248
+ updatedAt: number;
249
+ }
197
250
  /**
198
- * Absent means the user is not at this machine's keyboard.
199
- *
200
- * OS idle time answers that directly, so where it exists it decides alone.
201
- * Silence since the user's last prompt is only ever a proxy for it, and a poor
202
- * one — it is wrong in both directions:
203
- *
204
- * - Too long: it counts the agent's own turn, so a user watching a build was
205
- * read as absent and had the question pushed at them.
206
- * - Too short: a session that has just been spawned always has a fresh
207
- * prompt, so its FIRST question could never escalate however long its
208
- * owner had been gone. That is the "kick off some agents and
209
- * walk away" case this feature mainly exists for, and requiring both
210
- * signals to agree is what broke it.
211
- *
212
- * The proxy therefore survives only as the fallback for machines with no idle
213
- * source. There, never having seen a prompt is not evidence of absence — it is
214
- * a missing `UserPromptSubmit` hook — so it resolves to "present".
215
- *
216
- * Answering from a companion device does not make the user present; only touching this
217
- * machine does. Answering on a device is evidence of being away from it.
251
+ * Every session that has fired a hook in this directory recently, newest first.
218
252
  *
219
- * All of which only matters if presence is being consulted at all. With
220
- * `require_idle` off the user has said their whereabouts are not a
221
- * precondition, so this stops guessing and answers yes.
253
+ * This is the only local record of which harness sessions are actually alive
254
+ * here, which makes it the tiebreaker when the environment alone cannot say
255
+ * which harness owns the current shell.
222
256
  */
223
- export declare function isUserAway(state: SessionState, config: CliConfig, now: number, idleSeconds: number | null): boolean;
257
+ export declare function readLiveProjectSessionPointers(cwd: string, env: NodeJS.ProcessEnv, now: number, maxAgeMs?: number): LiveProjectSessionPointer[];
258
+ export declare function readProjectSessionPointer(cwd: string, env: NodeJS.ProcessEnv, now: number, maxAgeMs?: number): ProjectSessionPointer | null;
259
+ export declare function readMatchingProjectSessionPointer(cwd: string, env: NodeJS.ProcessEnv, now: number, sessionId: string, harness: HookHarness, maxAgeMs?: number): ProjectSessionPointer | null;
224
260
  export interface HookContext {
225
261
  client: ApiClient;
226
262
  config: CliConfig;
227
263
  env: NodeJS.ProcessEnv;
228
264
  now: () => number;
229
- /** Seconds since the last keyboard/mouse event, or null if unknowable. */
230
- idleSeconds: () => number | null;
231
265
  /** Injected so tests advance a virtual clock instead of sleeping. */
232
266
  sleep: (milliseconds: number) => Promise<void>;
233
267
  /** Bounded wait for the first reply; injected so tests do not sleep. */
@@ -254,7 +288,7 @@ export interface HookContext {
254
288
  * the user sees in the terminal — but it is free to be rewritten, and a log a
255
289
  * filter cannot rely on is a log nobody filters.
256
290
  */
257
- export type GateReason = 'no-session' | 'no-question' | 'answered' | 'already-asked' | 'continuation-repeat' | 'continuation-limit' | 'notifications-off' | 'user-present' | 'claimed-elsewhere' | 'user-returned' | 'no-devices' | 'proceeding';
291
+ export type GateReason = 'no-session' | 'no-question' | 'answered' | 'already-asked' | 'continuation-repeat' | 'continuation-limit' | 'delivery-limit' | 'notifications-off' | 'claimed-elsewhere' | 'no-devices' | 'proceeding';
258
292
  export type HookHarness = Harness;
259
293
  /**
260
294
  * Everything retirement needs. Narrower than a HookContext on purpose:
@@ -283,6 +317,15 @@ export declare function orphanRetirements(env: NodeJS.ProcessEnv, entries: Retir
283
317
  * next holder of a client; entries past the TTL are dropped as already dead.
284
318
  */
285
319
  export declare function drainOrphanRetirements(ctx: RetireDeps, env: NodeJS.ProcessEnv, now: number): Promise<string[]>;
320
+ /**
321
+ * The one bound on answer deliveries, for every route.
322
+ *
323
+ * It caps two things that both used to be able to run away: how many times a
324
+ * single accepted answer may be handed to a route before the journal gives up
325
+ * on it, and how many answer-driven continuation generations one chain may
326
+ * produce.
327
+ */
328
+ export declare const MAX_CONTINUATION_COUNT = 3;
286
329
  /** One answered registered question, with everything the agent needs to read it. */
287
330
  interface AnsweredPending {
288
331
  pending: PendingQuestion;
@@ -301,7 +344,18 @@ export declare function handleUserPromptSubmit(ctx: HookContext, envelope: HookE
301
344
  * `notifai ask`. Guessing from the last assistant message was the alternative
302
345
  * and it is not worth it: a false positive here hijacks the terminal.
303
346
  */
304
- export declare function handleStop(ctx: HookContext, envelope: HookEnvelope, processDeadlineAt?: number): Promise<HookOutcome>;
347
+ export declare function handleStop(ctx: HookContext, envelope: HookEnvelope, processDeadlineAt?: number, route?: EscalationDeliveryRoute): Promise<HookOutcome>;
348
+ /**
349
+ * Own one registered question pipeline from its terminal-first timer through
350
+ * its final delivery or retirement. Hosts differ only in the injected route.
351
+ */
352
+ export declare function runEscalationWaiter(ctx: HookContext, options: EscalationWaiterOptions): Promise<HookOutcome>;
353
+ /**
354
+ * The blocking Stop hook's own last meter: the answer becomes this process's
355
+ * stdout, and the harness admits it as a new user turn. Exported because a
356
+ * host adapter that has other routes still falls back to this one.
357
+ */
358
+ export declare function hookContinuationRoute(): EscalationDeliveryRoute;
305
359
  /**
306
360
  * Claude Code gives SessionEnd hooks a 1.5-second shared budget and Codex 1
307
361
  * second, so this cannot make a network call. It drops the local marker — but