@raidiant/notifai 0.1.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.
Files changed (54) hide show
  1. package/LICENSE +202 -0
  2. package/README.md +76 -0
  3. package/dist/client.d.ts +40 -0
  4. package/dist/client.js +118 -0
  5. package/dist/client.js.map +1 -0
  6. package/dist/commands.d.ts +225 -0
  7. package/dist/commands.js +1855 -0
  8. package/dist/commands.js.map +1 -0
  9. package/dist/config.d.ts +138 -0
  10. package/dist/config.js +224 -0
  11. package/dist/config.js.map +1 -0
  12. package/dist/credentials.d.ts +31 -0
  13. package/dist/credentials.js +94 -0
  14. package/dist/credentials.js.map +1 -0
  15. package/dist/hooks.d.ts +250 -0
  16. package/dist/hooks.js +792 -0
  17. package/dist/hooks.js.map +1 -0
  18. package/dist/idle.d.ts +16 -0
  19. package/dist/idle.js +44 -0
  20. package/dist/idle.js.map +1 -0
  21. package/dist/install-hooks.d.ts +194 -0
  22. package/dist/install-hooks.js +579 -0
  23. package/dist/install-hooks.js.map +1 -0
  24. package/dist/main.d.ts +2 -0
  25. package/dist/main.js +259 -0
  26. package/dist/main.js.map +1 -0
  27. package/dist/opencode-plugin.d.ts +65 -0
  28. package/dist/opencode-plugin.js +181 -0
  29. package/dist/opencode-plugin.js.map +1 -0
  30. package/dist/readiness.d.ts +87 -0
  31. package/dist/readiness.js +40 -0
  32. package/dist/readiness.js.map +1 -0
  33. package/dist/send.d.ts +86 -0
  34. package/dist/send.js +202 -0
  35. package/dist/send.js.map +1 -0
  36. package/package.json +56 -0
  37. package/src/client.test.ts +83 -0
  38. package/src/client.ts +183 -0
  39. package/src/commands.test.ts +1199 -0
  40. package/src/commands.ts +2201 -0
  41. package/src/config.test.ts +243 -0
  42. package/src/config.ts +347 -0
  43. package/src/credentials.ts +119 -0
  44. package/src/hooks.test.ts +1030 -0
  45. package/src/hooks.ts +1099 -0
  46. package/src/idle.test.ts +32 -0
  47. package/src/idle.ts +41 -0
  48. package/src/install-hooks.test.ts +500 -0
  49. package/src/install-hooks.ts +708 -0
  50. package/src/main.ts +315 -0
  51. package/src/opencode-plugin.ts +203 -0
  52. package/src/readiness.ts +88 -0
  53. package/src/send.ts +272 -0
  54. package/tsconfig.json +10 -0
@@ -0,0 +1,94 @@
1
+ import { execFileSync } from 'node:child_process';
2
+ import { chmodSync, existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
3
+ import path from 'node:path';
4
+ import { globalConfigDir } from './config.js';
5
+ const SERVICE = 'io.notifai.cli';
6
+ /** macOS Keychain via the `security` CLI — no native addons (proposal concern). */
7
+ export class KeychainStore {
8
+ load() {
9
+ try {
10
+ const raw = execFileSync('security', ['find-generic-password', '-s', SERVICE, '-a', 'machine', '-w'], { stdio: ['ignore', 'pipe', 'ignore'] })
11
+ .toString()
12
+ .trim();
13
+ return JSON.parse(raw);
14
+ }
15
+ catch {
16
+ return null;
17
+ }
18
+ }
19
+ save(credential) {
20
+ execFileSync('security', [
21
+ 'add-generic-password',
22
+ '-U',
23
+ '-s',
24
+ SERVICE,
25
+ '-a',
26
+ 'machine',
27
+ '-w',
28
+ JSON.stringify(credential),
29
+ ], { stdio: 'ignore' });
30
+ }
31
+ clear() {
32
+ try {
33
+ execFileSync('security', ['delete-generic-password', '-s', SERVICE, '-a', 'machine'], {
34
+ stdio: 'ignore',
35
+ });
36
+ }
37
+ catch {
38
+ // nothing stored
39
+ }
40
+ }
41
+ describe() {
42
+ return `macOS Keychain (${SERVICE})`;
43
+ }
44
+ }
45
+ /** 0600 file fallback for hosts without a keychain. */
46
+ export class FileStore {
47
+ env;
48
+ constructor(env = process.env) {
49
+ this.env = env;
50
+ }
51
+ filePath() {
52
+ return path.join(globalConfigDir(this.env), 'credentials.json');
53
+ }
54
+ load() {
55
+ const file = this.filePath();
56
+ if (!existsSync(file))
57
+ return null;
58
+ try {
59
+ return JSON.parse(readFileSync(file, 'utf8'));
60
+ }
61
+ catch {
62
+ return null;
63
+ }
64
+ }
65
+ save(credential) {
66
+ const file = this.filePath();
67
+ mkdirSync(path.dirname(file), { recursive: true });
68
+ writeFileSync(file, `${JSON.stringify(credential, null, 2)}\n`, { mode: 0o600 });
69
+ chmodSync(file, 0o600);
70
+ }
71
+ clear() {
72
+ rmSync(this.filePath(), { force: true });
73
+ }
74
+ describe() {
75
+ return this.filePath();
76
+ }
77
+ }
78
+ function keychainAvailable() {
79
+ if (process.platform !== 'darwin')
80
+ return false;
81
+ try {
82
+ execFileSync('security', ['help'], { stdio: 'ignore' });
83
+ return true;
84
+ }
85
+ catch {
86
+ return false;
87
+ }
88
+ }
89
+ export function defaultCredentialStore(env = process.env) {
90
+ if (env['NOTIFAI_CREDENTIALS'] === 'file')
91
+ return new FileStore(env);
92
+ return keychainAvailable() ? new KeychainStore() : new FileStore(env);
93
+ }
94
+ //# sourceMappingURL=credentials.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"credentials.js","sourceRoot":"","sources":["../src/credentials.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,250 @@
1
+ import type { LifecycleEndState, ReplyView } from '@raidiant/notifai-protocol';
2
+ import type { ApiClient } from './client.js';
3
+ import { type CliConfig } from './config.js';
4
+ /**
5
+ * Harness hook handlers.
6
+ *
7
+ * Claude Code, Codex and OpenCode all expose the same three joints: a blocking
8
+ * pre-approval hook that returns a decision, a turn-end hook, and a hook that
9
+ * fires when the user submits a prompt. That is enough to answer a blocked
10
+ * agent from the phone without the agent cooperating at all — no question
11
+ * detection, no pending state in a context window that compaction will eat.
12
+ *
13
+ * The load-bearing constraint is that these hooks are synchronous: while one
14
+ * blocks, the harness cannot show its own prompt either. So a hook may only
15
+ * take over when the user is demonstrably absent. Present user, or no evidence
16
+ * either way, means exit immediately and let the terminal do its job.
17
+ */
18
+ /** Fields we read from harness hook JSON. Everything else is passed through. */
19
+ export interface HookEnvelope {
20
+ session_id?: string;
21
+ cwd?: string;
22
+ hook_event_name?: string;
23
+ /** Set by the harness when this Stop follows a previous Stop continuation. */
24
+ stop_hook_active?: boolean;
25
+ /** Cursor's stable per-conversation identifier. */
26
+ conversation_id?: string;
27
+ /** Cursor's project roots; the first is the hook's configuration root. */
28
+ workspace_roots?: string[];
29
+ /** Cursor increments this after each stop-hook automatic follow-up. */
30
+ loop_count?: number;
31
+ }
32
+ export interface SessionState {
33
+ /** Epoch ms of the user's last prompt in this session — our presence signal. */
34
+ last_prompt_at?: number;
35
+ /** Question registered by `notifai ask`, awaiting the turn to end. */
36
+ pending?: PendingQuestion;
37
+ /**
38
+ * Questions that have been delivered to the user's devices and are now dead,
39
+ * but whose retirement has not been confirmed yet.
40
+ *
41
+ * A retirement needs a network call and the moment we learn a question is
42
+ * dead is not always a moment we can make one — `notifai ask` supersedes the
43
+ * previous question from a bare shell command, and the machine may be
44
+ * offline. Dropping the ids there is how a delivered question becomes
45
+ * permanently unretirable, so they are parked here instead and every later
46
+ * hook with a client drains them. Retirement is idempotent, so a duplicate
47
+ * attempt costs nothing and a missed one costs a stale notification for ever.
48
+ */
49
+ retiring?: RetiringQuestion[];
50
+ }
51
+ /** A delivered question awaiting its retirement push. */
52
+ export interface RetiringQuestion {
53
+ request_id: string;
54
+ collapse_key: string;
55
+ /** Shown if the companion has no history entry to correlate against. */
56
+ question: string;
57
+ state: LifecycleEndState;
58
+ }
59
+ /**
60
+ * A retirement that outlived its session.
61
+ *
62
+ * Per-session parking assumes some later hook in the SAME session will hold a
63
+ * client, and `SessionEnd` is exactly where that assumption breaks: it may not
64
+ * touch the network, and no hook for that session ever fires again. Deleting
65
+ * the state there lost the only copy of the delivered question's ids, so the
66
+ * phone kept an answerable question nobody was listening to. These entries are
67
+ * moved to a machine-global queue instead, drained by whichever session's hook
68
+ * next holds a client.
69
+ */
70
+ export interface OrphanRetirement extends RetiringQuestion {
71
+ /** Label of the session that asked, so the retirement sync matches its badge. */
72
+ session?: string;
73
+ /** Epoch ms when the entry was orphaned; entries beyond the TTL are dropped. */
74
+ enqueued_at: number;
75
+ }
76
+ export interface PendingQuestion {
77
+ question: string;
78
+ /**
79
+ * Epoch ms when `notifai ask` registered this. The grace window runs from
80
+ * here, not from the turn's end: a question the agent asked five minutes ago
81
+ * while it kept working has already served its wait in the terminal.
82
+ */
83
+ asked_at?: number;
84
+ /**
85
+ * Canonical answer labels, already split and validated by `notifai ask`.
86
+ * Stored as a list rather than a comma-joined string so a label containing a
87
+ * comma survives the round trip.
88
+ */
89
+ choices?: string[];
90
+ /** Set once the question has actually been pushed, so it can be retired. */
91
+ request_id?: string;
92
+ collapse_key?: string;
93
+ }
94
+ export type GraceOutcome =
95
+ /** The window elapsed with the user still gone; escalate. */
96
+ 'absent'
97
+ /** The user touched the machine; the terminal is theirs. */
98
+ | 'user-returned'
99
+ /** No idle source, so waiting would hold a terminal we cannot monitor. */
100
+ | 'no-signal';
101
+ /**
102
+ * The terminal-first wait: the question sits in the terminal for
103
+ * `ask_grace_seconds` from when it was sent, and only then reaches companion
104
+ * devices.
105
+ *
106
+ * Holding a blocking Stop hook open is normally hostile — while it blocks, the
107
+ * harness cannot show its prompt either, so a user wanting to answer locally is
108
+ * locked out. What makes it safe is that the wait is abandoned the moment the
109
+ * user touches the keyboard or mouse. The block therefore only ever persists
110
+ * while they are demonstrably not using the machine, which costs them nothing.
111
+ *
112
+ * That safety depends entirely on the idle signal, so with no idle source this
113
+ * refuses to wait at all rather than holding a terminal it cannot monitor.
114
+ *
115
+ * With `require_idle` off the whole calculus changes: the user has said they
116
+ * want the question to reach them whether or not they are at the keyboard, so
117
+ * there is nothing to watch for and this becomes what its name always claimed —
118
+ * a plain timer. It then works on machines with no idle source too.
119
+ */
120
+ export declare function awaitGrace(ctx: HookContext, askedAt: number): Promise<GraceOutcome>;
121
+ export interface HookOutcome {
122
+ /** Written to stdout verbatim — the harness parses this as the decision. */
123
+ stdout?: string;
124
+ /** Diagnostics; harnesses surface hook stderr in the transcript. */
125
+ notes: string[];
126
+ }
127
+ export declare function readSessionState(sessionId: string, env: NodeJS.ProcessEnv): SessionState;
128
+ export declare function writeSessionState(sessionId: string, env: NodeJS.ProcessEnv, state: SessionState): void;
129
+ export declare function clearSessionState(sessionId: string, env: NodeJS.ProcessEnv): void;
130
+ export declare function pruneAbandonedSessions(env: NodeJS.ProcessEnv, now?: number, maxAgeMs?: number): number;
131
+ export declare function claimQuestionPush(sessionId: string, env: NodeJS.ProcessEnv, now?: number): boolean;
132
+ export declare function releaseQuestionPush(sessionId: string, env: NodeJS.ProcessEnv): void;
133
+ /** Records which session is working in a directory, for `notifai ask`. */
134
+ export declare function writeProjectSession(cwd: string, env: NodeJS.ProcessEnv, sessionId: string, now: number): void;
135
+ /**
136
+ * Resolves the session working in `cwd`. Stale pointers are ignored rather than
137
+ * trusted: an id left by a session that ended days ago would send the question
138
+ * into state nothing is watching.
139
+ */
140
+ export declare function readProjectSession(cwd: string, env: NodeJS.ProcessEnv, now: number, maxAgeMs?: number): string | null;
141
+ /**
142
+ * Absent means the user is not at this machine's keyboard.
143
+ *
144
+ * OS idle time answers that directly, so where it exists it decides alone.
145
+ * Silence since the user's last prompt is only ever a proxy for it, and a poor
146
+ * one — it is wrong in both directions:
147
+ *
148
+ * - Too long: it counts the agent's own turn, so a user watching a build was
149
+ * read as absent and had the question pushed at them.
150
+ * - Too short: a session that has just been spawned always has a fresh
151
+ * prompt, so its FIRST question could never escalate however long its
152
+ * owner had been gone. That is the "kick off some agents and
153
+ * walk away" case this feature mainly exists for, and requiring both
154
+ * signals to agree is what broke it.
155
+ *
156
+ * The proxy therefore survives only as the fallback for machines with no idle
157
+ * source. There, never having seen a prompt is not evidence of absence — it is
158
+ * a missing `UserPromptSubmit` hook — so it resolves to "present".
159
+ *
160
+ * Answering from a companion device does not make the user present; only touching this
161
+ * machine does. Answering on a device is evidence of being away from it.
162
+ *
163
+ * All of which only matters if presence is being consulted at all. With
164
+ * `require_idle` off the user has said their whereabouts are not a
165
+ * precondition, so this stops guessing and answers yes.
166
+ */
167
+ export declare function isUserAway(state: SessionState, config: CliConfig, now: number, idleSeconds: number | null): boolean;
168
+ export interface HookContext {
169
+ client: ApiClient;
170
+ config: CliConfig;
171
+ env: NodeJS.ProcessEnv;
172
+ now: () => number;
173
+ /** Seconds since the last keyboard/mouse event, or null if unknowable. */
174
+ idleSeconds: () => number | null;
175
+ /** Injected so tests advance a virtual clock instead of sleeping. */
176
+ sleep: (milliseconds: number) => Promise<void>;
177
+ /** Bounded wait for the first reply; injected so tests do not sleep. */
178
+ waitForFirstReply: (requestId: string, timeoutSeconds: number) => Promise<{
179
+ replies: ReplyView[];
180
+ timedOut: boolean;
181
+ degraded?: boolean;
182
+ }>;
183
+ }
184
+ /**
185
+ * Everything retirement needs. Narrower than a HookContext on purpose:
186
+ * `notifai ask` supersedes the previous question and it is a plain command with
187
+ * no hook payload, no idle probe and nothing to sleep for.
188
+ */
189
+ export type RetireDeps = Pick<HookContext, 'client' | 'config'>;
190
+ /**
191
+ * Park a delivered question for retirement. Called at the moment we learn it is
192
+ * dead, which is frequently not a moment we can reach the network.
193
+ *
194
+ * Nothing is parked for a question that never reached a device: with no
195
+ * request_id there is nothing on any device to retire.
196
+ */
197
+ export declare function parkForRetirement(sessionId: string, env: NodeJS.ProcessEnv, pending: PendingQuestion, state: LifecycleEndState): void;
198
+ /**
199
+ * Send every parked retirement, and forget the ones that landed. A failure
200
+ * leaves the entry in place for the next hook rather than losing it, which is
201
+ * the whole reason the queue exists.
202
+ */
203
+ export declare function drainRetirements(ctx: RetireDeps, sessionId: string, env: NodeJS.ProcessEnv, session?: string | undefined): Promise<string[]>;
204
+ /** Move retirements into the global queue; deduped so a retry costs nothing. */
205
+ export declare function orphanRetirements(env: NodeJS.ProcessEnv, entries: RetiringQuestion[], session: string | undefined, now: number): void;
206
+ /**
207
+ * Retire everything a dead session left behind. Failures stay queued for the
208
+ * next holder of a client; entries past the TTL are dropped as already dead.
209
+ */
210
+ export declare function drainOrphanRetirements(ctx: RetireDeps, env: NodeJS.ProcessEnv, now: number): Promise<string[]>;
211
+ /**
212
+ * Records presence and retires anything still asking on companion devices. This is the
213
+ * "answered in the terminal" case from the original design: we cannot tell
214
+ * whether the new prompt answers the question, but we do not need to — the
215
+ * user being here is what makes the notification noise.
216
+ */
217
+ export declare function handleUserPromptSubmit(ctx: HookContext, envelope: HookEnvelope): Promise<HookOutcome>;
218
+ /**
219
+ * Only engages for a question the agent explicitly registered with
220
+ * `notifai ask`. Guessing from the last assistant message was the alternative
221
+ * and it is not worth it: a false positive here hijacks the terminal.
222
+ */
223
+ export declare function handleStop(ctx: HookContext, envelope: HookEnvelope): Promise<HookOutcome>;
224
+ /**
225
+ * Claude Code gives SessionEnd hooks a 1.5-second shared budget and Codex 1
226
+ * second, so this cannot make a network call. It drops the local marker — but
227
+ * first moves anything still live on the user's devices into the global
228
+ * retirement queue, because this file was the only record of those ids and no
229
+ * hook for this session will ever run again. A question whose
230
+ * agent just exited can receive no answer, so it is orphaned as `expired`.
231
+ */
232
+ export declare function handleSessionEnd(env: NodeJS.ProcessEnv, envelope: HookEnvelope, now?: number): HookOutcome;
233
+ /**
234
+ * One session holds one live question, so registering a second one ends the
235
+ * first.
236
+ *
237
+ * This used to replace `pending` wholesale, which silently discarded the
238
+ * `request_id` and `collapse_key` of a question already delivered to the user's
239
+ * devices. Nothing else knows those ids, so the notification became
240
+ * unretirable — it sat on the lock screen for ever asking a question no answer
241
+ * could reach. That is where the stale pile-up came from.
242
+ *
243
+ * Supersession is keyed on the session, not the project: several agents may be
244
+ * running in one project at once, and one agent's new question killing another
245
+ * agent's live one would be worse than the staleness this fixes.
246
+ */
247
+ export declare function registerQuestion(sessionId: string, env: NodeJS.ProcessEnv, question: PendingQuestion, now?: number): void;
248
+ /** Parses hook JSON from stdin, tolerating an empty or malformed body. */
249
+ export declare function parseHookInput(raw: string): HookEnvelope;
250
+ export declare function resolveHookConfig(cwd: string, env: NodeJS.ProcessEnv, sessionId: string | undefined): CliConfig;