@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.
- package/LICENSE +202 -0
- package/README.md +76 -0
- package/dist/client.d.ts +40 -0
- package/dist/client.js +118 -0
- package/dist/client.js.map +1 -0
- package/dist/commands.d.ts +225 -0
- package/dist/commands.js +1855 -0
- package/dist/commands.js.map +1 -0
- package/dist/config.d.ts +138 -0
- package/dist/config.js +224 -0
- package/dist/config.js.map +1 -0
- package/dist/credentials.d.ts +31 -0
- package/dist/credentials.js +94 -0
- package/dist/credentials.js.map +1 -0
- package/dist/hooks.d.ts +250 -0
- package/dist/hooks.js +792 -0
- package/dist/hooks.js.map +1 -0
- package/dist/idle.d.ts +16 -0
- package/dist/idle.js +44 -0
- package/dist/idle.js.map +1 -0
- package/dist/install-hooks.d.ts +194 -0
- package/dist/install-hooks.js +579 -0
- package/dist/install-hooks.js.map +1 -0
- package/dist/main.d.ts +2 -0
- package/dist/main.js +259 -0
- package/dist/main.js.map +1 -0
- package/dist/opencode-plugin.d.ts +65 -0
- package/dist/opencode-plugin.js +181 -0
- package/dist/opencode-plugin.js.map +1 -0
- package/dist/readiness.d.ts +87 -0
- package/dist/readiness.js +40 -0
- package/dist/readiness.js.map +1 -0
- package/dist/send.d.ts +86 -0
- package/dist/send.js +202 -0
- package/dist/send.js.map +1 -0
- package/package.json +56 -0
- package/src/client.test.ts +83 -0
- package/src/client.ts +183 -0
- package/src/commands.test.ts +1199 -0
- package/src/commands.ts +2201 -0
- package/src/config.test.ts +243 -0
- package/src/config.ts +347 -0
- package/src/credentials.ts +119 -0
- package/src/hooks.test.ts +1030 -0
- package/src/hooks.ts +1099 -0
- package/src/idle.test.ts +32 -0
- package/src/idle.ts +41 -0
- package/src/install-hooks.test.ts +500 -0
- package/src/install-hooks.ts +708 -0
- package/src/main.ts +315 -0
- package/src/opencode-plugin.ts +203 -0
- package/src/readiness.ts +88 -0
- package/src/send.ts +272 -0
- package/tsconfig.json +10 -0
package/dist/commands.js
ADDED
|
@@ -0,0 +1,1855 @@
|
|
|
1
|
+
import { randomBytes } from 'node:crypto';
|
|
2
|
+
import { spawn } from 'node:child_process';
|
|
3
|
+
import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
|
4
|
+
import os from 'node:os';
|
|
5
|
+
import path from 'node:path';
|
|
6
|
+
import { parse as parseToml, stringify as stringifyToml } from 'smol-toml';
|
|
7
|
+
import { CAPABILITIES_V1, REPLY_MAX_WINDOW_SECONDS, validateDraft, } from '@raidiant/notifai-protocol';
|
|
8
|
+
import { sha256Hex } from '@raidiant/notifai-protocol/node';
|
|
9
|
+
import { ApiCallError, NetworkError, createClient, } from './client.js';
|
|
10
|
+
import { BOOLEAN_CONFIG_KEYS, CONFIG_KEYS, NUMERIC_CONFIG_KEYS, configBounds, findProjectConfigPath, findProjectLocalConfigPath, globalConfigPath, loadConfig, sessionConfigPath, } from './config.js';
|
|
11
|
+
import { firstBlocker, openItems } from './readiness.js';
|
|
12
|
+
import { handleSessionEnd, handleStop, handleUserPromptSubmit, parseHookInput, pruneAbandonedSessions, readProjectSession, registerQuestion, } from './hooks.js';
|
|
13
|
+
import { readIdleSeconds } from './idle.js';
|
|
14
|
+
import { HARNESSES, applyPlan, blockingHookTimeoutSeconds, buildCursorHookConfig, buildHookConfig, codexLayerDir, codexProjectRoot, detectHarness, findInstallations, handlerEvent, loadCursorSettings, loadSettings, mergeCursorHooks, mergeHooks, removeCursorHooks, removeHooks, settingsFile, } from './install-hooks.js';
|
|
15
|
+
import { isOurOpencodePlugin, opencodePluginSource, } from './opencode-plugin.js';
|
|
16
|
+
import { CHOICE_USAGE, ambiguousChoiceSplit, buildDraft, formatReceipt, parseChoices, receiptExitCode, } from './send.js';
|
|
17
|
+
export const EXIT = {
|
|
18
|
+
ok: 0,
|
|
19
|
+
failed: 1,
|
|
20
|
+
usage: 2,
|
|
21
|
+
noReply: 3,
|
|
22
|
+
auth: 4,
|
|
23
|
+
network: 5,
|
|
24
|
+
};
|
|
25
|
+
function makeClient(deps, baseUrl, bearer, options) {
|
|
26
|
+
return (deps.clientFactory ?? createClient)(baseUrl, bearer, options);
|
|
27
|
+
}
|
|
28
|
+
function resolvedBaseUrl(config, credential) {
|
|
29
|
+
return config.base_url.source === 'default' && credential ? credential.baseUrl : config.base_url.value;
|
|
30
|
+
}
|
|
31
|
+
function authedClient(deps, config) {
|
|
32
|
+
const credential = deps.store.load();
|
|
33
|
+
if (!credential) {
|
|
34
|
+
deps.io.err('Not signed in. Run `notifai login` first.');
|
|
35
|
+
return null;
|
|
36
|
+
}
|
|
37
|
+
const baseUrl = resolvedBaseUrl(config, credential);
|
|
38
|
+
return {
|
|
39
|
+
client: makeClient(deps, baseUrl, `Bearer nfm_${credential.machineId}.${credential.secret}`),
|
|
40
|
+
baseUrl,
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
function reportError(deps, err) {
|
|
44
|
+
if (err instanceof ApiCallError) {
|
|
45
|
+
deps.io.err(`${err.code}: ${err.message}`);
|
|
46
|
+
if (err.nextAction)
|
|
47
|
+
deps.io.err(`next: ${err.nextAction}`);
|
|
48
|
+
if (err.code === 'auth_required' || err.code === 'machine_revoked')
|
|
49
|
+
return EXIT.auth;
|
|
50
|
+
return err.status >= 500 ? EXIT.network : EXIT.failed;
|
|
51
|
+
}
|
|
52
|
+
if (err instanceof NetworkError) {
|
|
53
|
+
deps.io.err(err.message);
|
|
54
|
+
return EXIT.network;
|
|
55
|
+
}
|
|
56
|
+
deps.io.err(String(err));
|
|
57
|
+
return EXIT.failed;
|
|
58
|
+
}
|
|
59
|
+
// ---------------------------------------------------------------------------
|
|
60
|
+
// login / logout / auth status
|
|
61
|
+
// ---------------------------------------------------------------------------
|
|
62
|
+
export async function loginCommand(deps, flags) {
|
|
63
|
+
const config = loadConfig({ cwd: deps.cwd, env: deps.env, flags: { base_url: flags.baseUrl } });
|
|
64
|
+
const baseUrl = config.base_url.value;
|
|
65
|
+
const machineName = flags.name ?? os.hostname();
|
|
66
|
+
const secret = randomBytes(32).toString('base64url');
|
|
67
|
+
const pollVerifier = randomBytes(24).toString('base64url');
|
|
68
|
+
const client = makeClient(deps, baseUrl, null);
|
|
69
|
+
let begin;
|
|
70
|
+
try {
|
|
71
|
+
begin = await client.beginPairing({
|
|
72
|
+
machine_name: machineName,
|
|
73
|
+
credential_hash: sha256Hex(secret),
|
|
74
|
+
poll_verifier_hash: sha256Hex(pollVerifier),
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
catch (err) {
|
|
78
|
+
return reportError(deps, err);
|
|
79
|
+
}
|
|
80
|
+
const interactive = deps.io.interactive === true;
|
|
81
|
+
if (interactive) {
|
|
82
|
+
await deps.io.intro?.('NotifAI sign in');
|
|
83
|
+
await deps.io.note?.(`Code: ${begin.code}\n${begin.approve_url}`, 'Approve this machine');
|
|
84
|
+
}
|
|
85
|
+
else {
|
|
86
|
+
deps.io.out(`Pairing code: ${begin.code}`);
|
|
87
|
+
deps.io.out(`Approve this machine at: ${begin.approve_url}`);
|
|
88
|
+
deps.io.out('Waiting for approval…');
|
|
89
|
+
}
|
|
90
|
+
if (flags.open !== false)
|
|
91
|
+
deps.io.openUrl(begin.approve_url);
|
|
92
|
+
const expiresAt = new Date(begin.expires_at).getTime();
|
|
93
|
+
const intervalMs = Math.max(begin.poll_interval_seconds, 1) * 1000;
|
|
94
|
+
const now = deps.now ?? Date.now;
|
|
95
|
+
const sleep = deps.sleep ?? ((milliseconds) => new Promise((resolve) => setTimeout(resolve, milliseconds)));
|
|
96
|
+
const spinner = interactive ? await deps.io.spinner?.('Waiting for approval…') : null;
|
|
97
|
+
while (now() < expiresAt) {
|
|
98
|
+
await sleep(intervalMs);
|
|
99
|
+
let poll;
|
|
100
|
+
try {
|
|
101
|
+
poll = await client.pollPairing(begin.pairing_id, pollVerifier);
|
|
102
|
+
}
|
|
103
|
+
catch (err) {
|
|
104
|
+
if (err instanceof NetworkError) {
|
|
105
|
+
spinner?.message('Connection lost — retrying…');
|
|
106
|
+
continue;
|
|
107
|
+
}
|
|
108
|
+
spinner?.error('Pairing failed');
|
|
109
|
+
return reportError(deps, err);
|
|
110
|
+
}
|
|
111
|
+
if (poll.status === 'approved' && poll.machine_id) {
|
|
112
|
+
deps.store.save({ machineId: poll.machine_id, secret, baseUrl, machineName });
|
|
113
|
+
if (interactive) {
|
|
114
|
+
spinner?.stop(`Machine "${machineName}" approved`);
|
|
115
|
+
await deps.io.outro?.(`Credential stored in ${deps.store.describe()}`);
|
|
116
|
+
}
|
|
117
|
+
else {
|
|
118
|
+
deps.io.out(`Machine "${machineName}" approved. Credential stored in ${deps.store.describe()}.`);
|
|
119
|
+
}
|
|
120
|
+
return EXIT.ok;
|
|
121
|
+
}
|
|
122
|
+
if (poll.status === 'denied') {
|
|
123
|
+
spinner?.error('Pairing denied');
|
|
124
|
+
deps.io.err('Pairing was denied from the dashboard.');
|
|
125
|
+
return EXIT.auth;
|
|
126
|
+
}
|
|
127
|
+
if (poll.status === 'expired')
|
|
128
|
+
break;
|
|
129
|
+
spinner?.message('Waiting for approval…');
|
|
130
|
+
}
|
|
131
|
+
spinner?.error('Pairing expired');
|
|
132
|
+
deps.io.err('Pairing expired before it was approved. Run `notifai login` again.');
|
|
133
|
+
return EXIT.auth;
|
|
134
|
+
}
|
|
135
|
+
export function logoutCommand(deps) {
|
|
136
|
+
deps.store.clear();
|
|
137
|
+
deps.io.out('Machine credential removed. Revoke it in the dashboard too if the machine is untrusted.');
|
|
138
|
+
return EXIT.ok;
|
|
139
|
+
}
|
|
140
|
+
export function authStatusCommand(deps, flags) {
|
|
141
|
+
const credential = deps.store.load();
|
|
142
|
+
if (flags.json) {
|
|
143
|
+
deps.io.out(JSON.stringify(credential
|
|
144
|
+
? {
|
|
145
|
+
signed_in: true,
|
|
146
|
+
machine_id: credential.machineId,
|
|
147
|
+
machine_name: credential.machineName,
|
|
148
|
+
base_url: credential.baseUrl,
|
|
149
|
+
store: deps.store.describe(),
|
|
150
|
+
}
|
|
151
|
+
: { signed_in: false }, null, 2));
|
|
152
|
+
return credential ? EXIT.ok : EXIT.auth;
|
|
153
|
+
}
|
|
154
|
+
if (!credential) {
|
|
155
|
+
deps.io.err('Not signed in. Run `notifai login`.');
|
|
156
|
+
return EXIT.auth;
|
|
157
|
+
}
|
|
158
|
+
deps.io.out(`Signed in as machine "${credential.machineName}" (${credential.machineId})`);
|
|
159
|
+
deps.io.out(`Server: ${credential.baseUrl}`);
|
|
160
|
+
deps.io.out(`Credential store: ${deps.store.describe()}`);
|
|
161
|
+
return EXIT.ok;
|
|
162
|
+
}
|
|
163
|
+
// ---------------------------------------------------------------------------
|
|
164
|
+
// devices / capabilities
|
|
165
|
+
// ---------------------------------------------------------------------------
|
|
166
|
+
export async function devicesCommand(deps, flags) {
|
|
167
|
+
const config = loadConfig({ cwd: deps.cwd, env: deps.env });
|
|
168
|
+
const authed = authedClient(deps, config);
|
|
169
|
+
if (!authed)
|
|
170
|
+
return EXIT.auth;
|
|
171
|
+
try {
|
|
172
|
+
const result = await authed.client.listDevices();
|
|
173
|
+
if (flags.json) {
|
|
174
|
+
deps.io.out(JSON.stringify(result, null, 2));
|
|
175
|
+
return EXIT.ok;
|
|
176
|
+
}
|
|
177
|
+
if (result.devices.length === 0) {
|
|
178
|
+
deps.io.out('No devices. Install a NotifAI companion app on a device and sign in.');
|
|
179
|
+
return EXIT.ok;
|
|
180
|
+
}
|
|
181
|
+
for (const d of result.devices) {
|
|
182
|
+
deps.io.out(`${d.device_id} ${d.display_name} ${d.platform} ${d.registration_healthy ? 'ready' : 'not ready'} (permission: ${d.permission_status})`);
|
|
183
|
+
}
|
|
184
|
+
return EXIT.ok;
|
|
185
|
+
}
|
|
186
|
+
catch (err) {
|
|
187
|
+
return reportError(deps, err);
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
export async function capabilitiesCommand(deps, flags) {
|
|
191
|
+
const config = loadConfig({ cwd: deps.cwd, env: deps.env });
|
|
192
|
+
const credential = deps.store.load();
|
|
193
|
+
const baseUrl = resolvedBaseUrl(config, credential);
|
|
194
|
+
const client = makeClient(deps, baseUrl, null);
|
|
195
|
+
try {
|
|
196
|
+
const doc = await client.capabilities(flags.platform ?? 'ios');
|
|
197
|
+
if (flags.json) {
|
|
198
|
+
deps.io.out(JSON.stringify(doc, null, 2));
|
|
199
|
+
return EXIT.ok;
|
|
200
|
+
}
|
|
201
|
+
deps.io.out(`${doc.platform} capability contract v${doc.schema_version} (payload limit ${doc.payload_limit_bytes} bytes)`);
|
|
202
|
+
for (const field of doc.fields) {
|
|
203
|
+
deps.io.out(` ${field.path}: ${field.status}${field.reason ? ` — ${field.reason}` : ''}`);
|
|
204
|
+
}
|
|
205
|
+
return EXIT.ok;
|
|
206
|
+
}
|
|
207
|
+
catch (err) {
|
|
208
|
+
return reportError(deps, err);
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
// ---------------------------------------------------------------------------
|
|
212
|
+
// send / status
|
|
213
|
+
// ---------------------------------------------------------------------------
|
|
214
|
+
export async function sendCommand(deps, flags) {
|
|
215
|
+
const hasReplyChoice = Array.isArray(flags.replyChoice)
|
|
216
|
+
? flags.replyChoice.length > 0
|
|
217
|
+
: flags.replyChoice !== undefined;
|
|
218
|
+
if (!flags.reply &&
|
|
219
|
+
(flags.replyTimeout !== undefined || flags.replyWindow !== undefined || flags.noBlock || hasReplyChoice)) {
|
|
220
|
+
deps.io.err('Use --reply with --reply-timeout, --reply-window, --reply-choice, or --no-block.');
|
|
221
|
+
return EXIT.usage;
|
|
222
|
+
}
|
|
223
|
+
const replyTimeout = flags.replyTimeout ?? 900;
|
|
224
|
+
if (flags.reply && !isNonNegativeInteger(replyTimeout)) {
|
|
225
|
+
deps.io.err('--reply-timeout must be a non-negative integer number of seconds.');
|
|
226
|
+
return EXIT.usage;
|
|
227
|
+
}
|
|
228
|
+
// Asking while declaring that nothing will wait for the answer. The reply is
|
|
229
|
+
// captured server-side and then unreachable: only a blocking send waits for
|
|
230
|
+
// it, and the hook path drains questions registered by `ask`, never a send's
|
|
231
|
+
// request id. So the user gets a real button, taps it, and nothing happens —
|
|
232
|
+
// worse than a banner that never asked, because it spends their attention
|
|
233
|
+
// and their trust in the channel.
|
|
234
|
+
//
|
|
235
|
+
// Both spellings of "do not wait" are rejected, because the defect is the
|
|
236
|
+
// zero wait and not the flag that produced it.
|
|
237
|
+
if (flags.reply && (flags.noBlock || replyTimeout === 0)) {
|
|
238
|
+
deps.io.err('A question needs someone to hear the answer, so --reply cannot be combined ' +
|
|
239
|
+
'with --no-block or --reply-timeout 0.\n' +
|
|
240
|
+
'To ask and end the turn, use `notifai ask` — the turn-end hook returns the answer.\n' +
|
|
241
|
+
'To announce finished work, drop --reply and its choices.');
|
|
242
|
+
return EXIT.usage;
|
|
243
|
+
}
|
|
244
|
+
if (flags.reply &&
|
|
245
|
+
flags.replyWindow !== undefined &&
|
|
246
|
+
(!Number.isInteger(flags.replyWindow) ||
|
|
247
|
+
flags.replyWindow < 60 ||
|
|
248
|
+
flags.replyWindow > REPLY_MAX_WINDOW_SECONDS)) {
|
|
249
|
+
deps.io.err(`--reply-window must be an integer from 60 to ${REPLY_MAX_WINDOW_SECONDS} seconds.`);
|
|
250
|
+
return EXIT.usage;
|
|
251
|
+
}
|
|
252
|
+
const config = loadConfig({
|
|
253
|
+
cwd: deps.cwd,
|
|
254
|
+
env: deps.env,
|
|
255
|
+
flags: { base_url: flags.baseUrl, wait_seconds: flags.wait },
|
|
256
|
+
});
|
|
257
|
+
const authed = authedClient(deps, config);
|
|
258
|
+
if (!authed)
|
|
259
|
+
return EXIT.auth;
|
|
260
|
+
if (flags.image !== undefined && !flags.image.startsWith('med_')) {
|
|
261
|
+
const uploaded = await uploadImage(deps, authed.client, flags.image);
|
|
262
|
+
if (!uploaded.ok) {
|
|
263
|
+
deps.io.err(uploaded.error);
|
|
264
|
+
return uploaded.exit;
|
|
265
|
+
}
|
|
266
|
+
flags = { ...flags, image: uploaded.mediaId };
|
|
267
|
+
}
|
|
268
|
+
const build = buildDraft(config, flags);
|
|
269
|
+
if (!build.ok) {
|
|
270
|
+
deps.io.err(build.error);
|
|
271
|
+
return EXIT.usage;
|
|
272
|
+
}
|
|
273
|
+
const capabilities = CAPABILITIES_V1.describe(build.platform);
|
|
274
|
+
if (!capabilities) {
|
|
275
|
+
deps.io.err(`No capability contract is available for ${build.platform}.`);
|
|
276
|
+
return EXIT.usage;
|
|
277
|
+
}
|
|
278
|
+
const validation = validateDraft(build.draft, capabilities);
|
|
279
|
+
if (!validation.ok) {
|
|
280
|
+
for (const issue of validation.errors)
|
|
281
|
+
deps.io.err(`${issue.path}: ${issue.message}`);
|
|
282
|
+
return EXIT.usage;
|
|
283
|
+
}
|
|
284
|
+
if (!flags.reply &&
|
|
285
|
+
(flags.title.trim().endsWith('?') || flags.body.trim().endsWith('?'))) {
|
|
286
|
+
deps.io.err('Heads up: this notification ends with a question but has no reply action. Add --reply (and optionally --reply-choice) so it can be answered from the notification.');
|
|
287
|
+
}
|
|
288
|
+
const waitSeconds = flags.noWait ? 0 : config.wait_seconds.value;
|
|
289
|
+
const idempotencyKey = flags.idempotencyKey ?? `cli-${randomBytes(12).toString('base64url')}`;
|
|
290
|
+
try {
|
|
291
|
+
const receipt = await authed.client.submit({ idempotency_key: idempotencyKey, draft: build.draft }, waitSeconds);
|
|
292
|
+
const receiptExit = receiptExitCode(receipt);
|
|
293
|
+
if (!flags.json)
|
|
294
|
+
deps.io.out(formatReceipt(receipt));
|
|
295
|
+
// A zero wait can no longer reach here: --reply guarantees a positive one.
|
|
296
|
+
if (!flags.reply || receiptExit !== EXIT.ok) {
|
|
297
|
+
if (flags.json) {
|
|
298
|
+
deps.io.out(JSON.stringify(flags.reply ? { receipt, replies: [] } : receipt, null, 2));
|
|
299
|
+
}
|
|
300
|
+
return receiptExit;
|
|
301
|
+
}
|
|
302
|
+
const result = await waitForReply(authed.client, receipt.request_id, {
|
|
303
|
+
timeoutSeconds: replyTimeout,
|
|
304
|
+
afterSeq: 0,
|
|
305
|
+
now: deps.now,
|
|
306
|
+
sleep: deps.sleep,
|
|
307
|
+
});
|
|
308
|
+
if (flags.json) {
|
|
309
|
+
deps.io.out(JSON.stringify({ receipt, replies: result.response.replies, degraded: result.degraded }, null, 2));
|
|
310
|
+
}
|
|
311
|
+
else if (result.response.replies.length > 0)
|
|
312
|
+
printReplies(deps, result.response.replies);
|
|
313
|
+
else
|
|
314
|
+
printNoReply(deps, receipt.request_id, result.response.reply_expires_at);
|
|
315
|
+
if (result.degraded) {
|
|
316
|
+
deps.io.err(DEGRADED_WAIT_WARNING);
|
|
317
|
+
return EXIT.network;
|
|
318
|
+
}
|
|
319
|
+
return result.timedOut ? EXIT.noReply : EXIT.ok;
|
|
320
|
+
}
|
|
321
|
+
catch (err) {
|
|
322
|
+
return reportError(deps, err);
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
export async function repliesCommand(deps, requestId, flags) {
|
|
326
|
+
const waitSeconds = flags.wait ?? 0;
|
|
327
|
+
const afterSeq = flags.after ?? 0;
|
|
328
|
+
if (!isNonNegativeInteger(waitSeconds)) {
|
|
329
|
+
deps.io.err('--wait must be a non-negative integer number of seconds.');
|
|
330
|
+
return EXIT.usage;
|
|
331
|
+
}
|
|
332
|
+
if (!isNonNegativeInteger(afterSeq)) {
|
|
333
|
+
deps.io.err('--after must be a non-negative integer sequence number.');
|
|
334
|
+
return EXIT.usage;
|
|
335
|
+
}
|
|
336
|
+
const config = loadConfig({ cwd: deps.cwd, env: deps.env });
|
|
337
|
+
const authed = authedClient(deps, config);
|
|
338
|
+
if (!authed)
|
|
339
|
+
return EXIT.auth;
|
|
340
|
+
try {
|
|
341
|
+
const result = await waitForReply(authed.client, requestId, {
|
|
342
|
+
timeoutSeconds: waitSeconds,
|
|
343
|
+
afterSeq,
|
|
344
|
+
now: deps.now,
|
|
345
|
+
sleep: deps.sleep,
|
|
346
|
+
});
|
|
347
|
+
if (flags.json) {
|
|
348
|
+
deps.io.out(JSON.stringify({ ...result.response, degraded: result.degraded }, null, 2));
|
|
349
|
+
}
|
|
350
|
+
else if (result.response.replies.length > 0)
|
|
351
|
+
printReplies(deps, result.response.replies);
|
|
352
|
+
else
|
|
353
|
+
printNoReply(deps, requestId, result.response.reply_expires_at);
|
|
354
|
+
if (result.degraded) {
|
|
355
|
+
deps.io.err(DEGRADED_WAIT_WARNING);
|
|
356
|
+
return EXIT.network;
|
|
357
|
+
}
|
|
358
|
+
return result.timedOut ? EXIT.noReply : EXIT.ok;
|
|
359
|
+
}
|
|
360
|
+
catch (err) {
|
|
361
|
+
return reportError(deps, err);
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
/** Loop over server-capped long polls until a reply arrives or the caller's deadline passes. */
|
|
365
|
+
export async function waitForReply(client, requestId, options) {
|
|
366
|
+
const now = options.now ?? Date.now;
|
|
367
|
+
const sleep = options.sleep ?? ((milliseconds) => new Promise((resolve) => setTimeout(resolve, milliseconds)));
|
|
368
|
+
const deadline = now() + options.timeoutSeconds * 1000;
|
|
369
|
+
let lastResponse = null;
|
|
370
|
+
let lastNetworkError = null;
|
|
371
|
+
let consecutiveNetworkErrors = 0;
|
|
372
|
+
let firstPoll = true;
|
|
373
|
+
while (firstPoll || now() < deadline) {
|
|
374
|
+
firstPoll = false;
|
|
375
|
+
const remainingMs = Math.max(0, deadline - now());
|
|
376
|
+
const waitSeconds = Math.min(25, Math.floor(remainingMs / 1000));
|
|
377
|
+
try {
|
|
378
|
+
const response = await client.replies(requestId, {
|
|
379
|
+
waitSeconds,
|
|
380
|
+
afterSeq: options.afterSeq,
|
|
381
|
+
});
|
|
382
|
+
lastResponse = response;
|
|
383
|
+
lastNetworkError = null;
|
|
384
|
+
consecutiveNetworkErrors = 0;
|
|
385
|
+
if (response.replies.length > 0)
|
|
386
|
+
return { response, timedOut: false, degraded: false };
|
|
387
|
+
const pauseMs = Math.min(250, Math.max(0, deadline - now()));
|
|
388
|
+
if (pauseMs > 0)
|
|
389
|
+
await sleep(pauseMs);
|
|
390
|
+
}
|
|
391
|
+
catch (err) {
|
|
392
|
+
if (!(err instanceof NetworkError))
|
|
393
|
+
throw err;
|
|
394
|
+
lastNetworkError = err;
|
|
395
|
+
consecutiveNetworkErrors += 1;
|
|
396
|
+
const remainingAfterError = Math.max(0, deadline - now());
|
|
397
|
+
if (remainingAfterError === 0)
|
|
398
|
+
break;
|
|
399
|
+
const backoffMs = Math.min(250 * 2 ** (consecutiveNetworkErrors - 1), 2_000, remainingAfterError);
|
|
400
|
+
await sleep(backoffMs);
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
if (!lastResponse && lastNetworkError)
|
|
404
|
+
throw lastNetworkError;
|
|
405
|
+
return {
|
|
406
|
+
response: lastResponse ??
|
|
407
|
+
{ request_id: requestId, reply_expires_at: null, replies: [] },
|
|
408
|
+
timedOut: true,
|
|
409
|
+
// A poll succeeded at some point, so we do not throw — but the last thing
|
|
410
|
+
// we know is that we could not reach the server. Reporting that as a plain
|
|
411
|
+
// "no reply" would let an agent treat an unseen refusal as consent.
|
|
412
|
+
degraded: lastNetworkError !== null,
|
|
413
|
+
};
|
|
414
|
+
}
|
|
415
|
+
/**
|
|
416
|
+
* Shared by every surface that waits: "the user did not answer" and "I could
|
|
417
|
+
* not find out" must not look the same, because agents branch on the exit code
|
|
418
|
+
* and one of those two branches is safe to proceed from.
|
|
419
|
+
*/
|
|
420
|
+
const DEGRADED_WAIT_WARNING = 'notifai: the wait ended during a network outage, so this is "could not find out", ' +
|
|
421
|
+
'not "no answer" — the reply may already be waiting. Retry with `notifai replies <id>`.';
|
|
422
|
+
function isNonNegativeInteger(value) {
|
|
423
|
+
return Number.isInteger(value) && value >= 0;
|
|
424
|
+
}
|
|
425
|
+
function printReplies(deps, replies) {
|
|
426
|
+
for (const reply of replies)
|
|
427
|
+
deps.io.out(`reply from ${reply.device_name}: ${reply.text}`);
|
|
428
|
+
const contradiction = contradictingAnswer(replies);
|
|
429
|
+
if (contradiction !== null)
|
|
430
|
+
deps.io.err(contradiction);
|
|
431
|
+
}
|
|
432
|
+
/**
|
|
433
|
+
* The other half of first-reply-wins.
|
|
434
|
+
*
|
|
435
|
+
* A blocking send returns the first non-empty batch and acts on it, so a later
|
|
436
|
+
* answer that disagrees is silently ignored. That is the right default — an
|
|
437
|
+
* agent wants one answer, not a quorum — but silently is the wrong way to do
|
|
438
|
+
* it. The server now retires the question on the other devices the moment one
|
|
439
|
+
* answers, which makes this rare; it is still reachable, because a device can
|
|
440
|
+
* answer between the first reply landing and its retirement arriving.
|
|
441
|
+
*
|
|
442
|
+
* Ordered by seq, so `replies[0]` is the one that won.
|
|
443
|
+
*/
|
|
444
|
+
export function contradictingAnswer(replies) {
|
|
445
|
+
const winner = replies[0];
|
|
446
|
+
if (winner === undefined || replies.length < 2)
|
|
447
|
+
return null;
|
|
448
|
+
const disagreeing = replies
|
|
449
|
+
.slice(1)
|
|
450
|
+
.filter((reply) => (reply.choice_id ?? reply.text) !== (winner.choice_id ?? winner.text));
|
|
451
|
+
if (disagreeing.length === 0)
|
|
452
|
+
return null;
|
|
453
|
+
const names = [...new Set(disagreeing.map((reply) => reply.device_name))].join(', ');
|
|
454
|
+
return (`note: "${winner.text}" from ${winner.device_name} is the answer that counts — it arrived first. ` +
|
|
455
|
+
`${names} answered differently afterwards and that answer was not used.`);
|
|
456
|
+
}
|
|
457
|
+
function printNoReply(deps, requestId, expiresAt) {
|
|
458
|
+
// A harness hook may have retired this question, in which case promising an
|
|
459
|
+
// open window would send the caller back to wait for an answer the server
|
|
460
|
+
// will now refuse.
|
|
461
|
+
const closed = expiresAt != null && Date.parse(expiresAt) <= Date.now();
|
|
462
|
+
deps.io.out(closed
|
|
463
|
+
? `no reply for request ${requestId}; the reply window has closed`
|
|
464
|
+
: `no reply yet for request ${requestId}; the reply window remains open`);
|
|
465
|
+
}
|
|
466
|
+
export async function statusCommand(deps, requestId, flags) {
|
|
467
|
+
const config = loadConfig({ cwd: deps.cwd, env: deps.env });
|
|
468
|
+
const authed = authedClient(deps, config);
|
|
469
|
+
if (!authed)
|
|
470
|
+
return EXIT.auth;
|
|
471
|
+
try {
|
|
472
|
+
const snapshot = await authed.client.evidence(requestId);
|
|
473
|
+
if (flags.json) {
|
|
474
|
+
deps.io.out(JSON.stringify(snapshot, null, 2));
|
|
475
|
+
return EXIT.ok;
|
|
476
|
+
}
|
|
477
|
+
deps.io.out(`request ${snapshot.request_id} (${snapshot.event ?? 'no event'}) — ${snapshot.overall}`);
|
|
478
|
+
for (const d of snapshot.deliveries) {
|
|
479
|
+
deps.io.out(` ${d.device_name}: ${d.state} after ${d.attempts} attempt(s)`);
|
|
480
|
+
for (const e of d.events) {
|
|
481
|
+
deps.io.out(` ${e.occurred_at} ${e.stage}${e.reason ? ` (${e.reason})` : ''}`);
|
|
482
|
+
}
|
|
483
|
+
}
|
|
484
|
+
return EXIT.ok;
|
|
485
|
+
}
|
|
486
|
+
catch (err) {
|
|
487
|
+
return reportError(deps, err);
|
|
488
|
+
}
|
|
489
|
+
}
|
|
490
|
+
const MEDIA_TYPES = {
|
|
491
|
+
'.jpg': 'image/jpeg',
|
|
492
|
+
'.jpeg': 'image/jpeg',
|
|
493
|
+
'.png': 'image/png',
|
|
494
|
+
'.gif': 'image/gif',
|
|
495
|
+
};
|
|
496
|
+
/** `--image` accepts a media id, a local file path, or an http(s) URL. */
|
|
497
|
+
async function uploadImage(deps, client, source) {
|
|
498
|
+
let bytes;
|
|
499
|
+
let mediaType;
|
|
500
|
+
if (/^https?:\/\//.test(source)) {
|
|
501
|
+
try {
|
|
502
|
+
const response = await fetch(source);
|
|
503
|
+
if (!response.ok)
|
|
504
|
+
return { ok: false, error: `Could not fetch ${source} (${response.status}).`, exit: EXIT.usage };
|
|
505
|
+
const contentType = response.headers.get('content-type')?.split(';')[0]?.trim() ?? '';
|
|
506
|
+
mediaType = ['image/jpeg', 'image/png', 'image/gif'].find((t) => t === contentType);
|
|
507
|
+
bytes = new Uint8Array(await response.arrayBuffer());
|
|
508
|
+
}
|
|
509
|
+
catch (err) {
|
|
510
|
+
return { ok: false, error: `Could not fetch ${source}: ${String(err)}`, exit: EXIT.network };
|
|
511
|
+
}
|
|
512
|
+
}
|
|
513
|
+
else {
|
|
514
|
+
if (!existsSync(source)) {
|
|
515
|
+
return { ok: false, error: `--image: "${source}" is not a media id, file, or URL.`, exit: EXIT.usage };
|
|
516
|
+
}
|
|
517
|
+
bytes = new Uint8Array(readFileSync(source));
|
|
518
|
+
mediaType = MEDIA_TYPES[path.extname(source).toLowerCase()];
|
|
519
|
+
}
|
|
520
|
+
if (!mediaType) {
|
|
521
|
+
return { ok: false, error: 'Images must be JPEG, PNG, or GIF.', exit: EXIT.usage };
|
|
522
|
+
}
|
|
523
|
+
try {
|
|
524
|
+
const grant = await client.createMediaUpload({
|
|
525
|
+
media_type: mediaType,
|
|
526
|
+
size_bytes: bytes.byteLength,
|
|
527
|
+
sha256: sha256Hex(bytes),
|
|
528
|
+
});
|
|
529
|
+
await client.uploadMedia(grant, bytes);
|
|
530
|
+
return { ok: true, mediaId: grant.media_id };
|
|
531
|
+
}
|
|
532
|
+
catch (err) {
|
|
533
|
+
return { ok: false, error: err instanceof Error ? err.message : String(err), exit: EXIT.network };
|
|
534
|
+
}
|
|
535
|
+
}
|
|
536
|
+
// ---------------------------------------------------------------------------
|
|
537
|
+
// hook / ask / close — harness integration
|
|
538
|
+
// ---------------------------------------------------------------------------
|
|
539
|
+
export const HOOK_EVENTS = ['user-prompt-submit', 'stop', 'session-end'];
|
|
540
|
+
/**
|
|
541
|
+
* Runs one harness hook. Contract with every harness: hook JSON arrives on
|
|
542
|
+
* stdin, the decision (if any) goes to stdout, diagnostics go to stderr, and
|
|
543
|
+
* exit 0 with no stdout means "no decision — carry on as normal".
|
|
544
|
+
*
|
|
545
|
+
* Every failure path in here must reach that no-decision state. A hook that
|
|
546
|
+
* throws, or that blocks past the harness's timeout, degrades the agent for a
|
|
547
|
+
* feature the user only asked to make it more convenient.
|
|
548
|
+
*/
|
|
549
|
+
export async function hookRunCommand(deps, event, readStdin, harness) {
|
|
550
|
+
if (!HOOK_EVENTS.includes(event)) {
|
|
551
|
+
deps.io.err(`Unknown hook event "${event}". Valid: ${HOOK_EVENTS.join(', ')}`);
|
|
552
|
+
return EXIT.usage;
|
|
553
|
+
}
|
|
554
|
+
let envelope;
|
|
555
|
+
try {
|
|
556
|
+
envelope = parseHookInput(await readStdin());
|
|
557
|
+
if (harness === 'cursor') {
|
|
558
|
+
const sessionId = envelope.session_id ?? envelope.conversation_id;
|
|
559
|
+
const cwd = envelope.cwd ?? envelope.workspace_roots?.[0];
|
|
560
|
+
envelope = {
|
|
561
|
+
...envelope,
|
|
562
|
+
...(sessionId === undefined ? {} : { session_id: sessionId }),
|
|
563
|
+
...(cwd === undefined ? {} : { cwd }),
|
|
564
|
+
stop_hook_active: envelope.stop_hook_active ??
|
|
565
|
+
(typeof envelope.loop_count === 'number' && envelope.loop_count > 0),
|
|
566
|
+
};
|
|
567
|
+
}
|
|
568
|
+
}
|
|
569
|
+
catch {
|
|
570
|
+
return EXIT.ok;
|
|
571
|
+
}
|
|
572
|
+
// Everything below is inside one fail-open boundary. Config parsing,
|
|
573
|
+
// credential loading and client construction can all throw, and a hook that
|
|
574
|
+
// exits non-zero makes the harness report a failure — strictly worse for the
|
|
575
|
+
// user than not having installed the hook at all.
|
|
576
|
+
try {
|
|
577
|
+
if (event === 'session-end') {
|
|
578
|
+
const outcome = handleSessionEnd(deps.env, envelope, (deps.now ?? Date.now)());
|
|
579
|
+
for (const note of outcome.notes)
|
|
580
|
+
deps.io.err(`notifai: ${note}`);
|
|
581
|
+
return EXIT.ok;
|
|
582
|
+
}
|
|
583
|
+
// Resolve config against the session's project rather than our own working
|
|
584
|
+
// directory. `cwd` is in the payload precisely because which project a
|
|
585
|
+
// session belongs to is the harness's business, not ours.
|
|
586
|
+
const cwd = envelope.cwd ?? deps.cwd;
|
|
587
|
+
const config = loadConfig({ cwd, env: deps.env, sessionId: envelope.session_id });
|
|
588
|
+
const credential = deps.store.load();
|
|
589
|
+
if (!credential)
|
|
590
|
+
return EXIT.ok;
|
|
591
|
+
// Pin authenticated traffic to the origin the credential was issued for. A
|
|
592
|
+
// repository can commit `.notifai/config.toml`, and honouring a base_url
|
|
593
|
+
// from it would hand this machine's bearer token to whatever host it names.
|
|
594
|
+
const baseUrl = credential.baseUrl;
|
|
595
|
+
if (config.base_url.source !== 'default' && config.base_url.value !== baseUrl) {
|
|
596
|
+
deps.io.err(`notifai: ignoring base_url from ${config.base_url.source}; hooks only talk to ${baseUrl}`);
|
|
597
|
+
}
|
|
598
|
+
// UserPromptSubmit runs in front of the user's own prompt under a 15s
|
|
599
|
+
// harness ceiling and can make two calls, so each gets a small slice of it;
|
|
600
|
+
// Stop is allowed to block and keeps the ordinary budget.
|
|
601
|
+
const client = makeClient(deps, baseUrl, `Bearer nfm_${credential.machineId}.${credential.secret}`, { timeoutMs: event === 'user-prompt-submit' ? 4_000 : 20_000 });
|
|
602
|
+
const now = deps.now ?? Date.now;
|
|
603
|
+
const ctx = {
|
|
604
|
+
client,
|
|
605
|
+
config,
|
|
606
|
+
env: deps.env,
|
|
607
|
+
now,
|
|
608
|
+
idleSeconds: deps.idleSeconds ?? (() => readIdleSeconds()),
|
|
609
|
+
sleep: deps.sleep ?? ((ms) => new Promise((resolve) => setTimeout(resolve, ms))),
|
|
610
|
+
waitForFirstReply: async (requestId, timeoutSeconds) => {
|
|
611
|
+
const result = await waitForReply(client, requestId, {
|
|
612
|
+
timeoutSeconds,
|
|
613
|
+
afterSeq: 0,
|
|
614
|
+
now: deps.now,
|
|
615
|
+
sleep: deps.sleep,
|
|
616
|
+
});
|
|
617
|
+
return {
|
|
618
|
+
replies: result.response.replies,
|
|
619
|
+
timedOut: result.timedOut,
|
|
620
|
+
degraded: result.degraded,
|
|
621
|
+
};
|
|
622
|
+
},
|
|
623
|
+
};
|
|
624
|
+
// Real clock, deliberately, not `deps.now`. This compares against file
|
|
625
|
+
// mtimes, which are wall-clock facts — handing it a virtual or skewed clock
|
|
626
|
+
// would have it delete live session state as "abandoned".
|
|
627
|
+
// Rate-limited to once a day by its own stamp file, so the common cost is
|
|
628
|
+
// one stat on a hook that sits on the critical path of every turn.
|
|
629
|
+
pruneAbandonedSessions(deps.env);
|
|
630
|
+
const outcome = event === 'user-prompt-submit'
|
|
631
|
+
? await handleUserPromptSubmit(ctx, envelope)
|
|
632
|
+
: await handleStop(ctx, envelope);
|
|
633
|
+
for (const note of outcome.notes)
|
|
634
|
+
deps.io.err(`notifai: ${note}`);
|
|
635
|
+
if (outcome.stdout !== undefined) {
|
|
636
|
+
let stdout = outcome.stdout;
|
|
637
|
+
if (harness === 'cursor' && event === 'stop') {
|
|
638
|
+
const decision = JSON.parse(outcome.stdout);
|
|
639
|
+
if (decision.decision === 'block' && typeof decision.reason === 'string') {
|
|
640
|
+
stdout = JSON.stringify({ followup_message: decision.reason });
|
|
641
|
+
}
|
|
642
|
+
}
|
|
643
|
+
deps.io.out(stdout);
|
|
644
|
+
}
|
|
645
|
+
return EXIT.ok;
|
|
646
|
+
}
|
|
647
|
+
catch (err) {
|
|
648
|
+
for (const line of describeHookFailure(err))
|
|
649
|
+
deps.io.err(`notifai: ${line}`);
|
|
650
|
+
return EXIT.ok;
|
|
651
|
+
}
|
|
652
|
+
}
|
|
653
|
+
/**
|
|
654
|
+
* What went wrong, in terms of what to do about it.
|
|
655
|
+
*
|
|
656
|
+
* On 2026-08-03 a contract change shipped without the server deploy that goes
|
|
657
|
+
* with it. The CLI stamped `lifecycle` on every question draft, the deployed
|
|
658
|
+
* server rejected the unknown field, and escalation stopped working entirely —
|
|
659
|
+
* announced as "hook failed, deferring to the terminal", which reads like a
|
|
660
|
+
* flaky network. The information needed to diagnose it in one second was
|
|
661
|
+
* already in hand: a 422 whose details name the offending path. It was being
|
|
662
|
+
* thrown away by `String(err)`.
|
|
663
|
+
*
|
|
664
|
+
* A hook still exits 0 whatever this says. Handing the terminal back is always
|
|
665
|
+
* right; the only question is whether the user is told anything they can use.
|
|
666
|
+
*/
|
|
667
|
+
export function describeHookFailure(err) {
|
|
668
|
+
if (!(err instanceof ApiCallError)) {
|
|
669
|
+
return [`hook failed, deferring to the terminal (${String(err)})`];
|
|
670
|
+
}
|
|
671
|
+
const lines = [`hook failed, deferring to the terminal (${err.code}: ${err.message})`];
|
|
672
|
+
const paths = rejectedPaths(err.details);
|
|
673
|
+
if (paths.length > 0)
|
|
674
|
+
lines.push(`the server rejected: ${paths.join(', ')}`);
|
|
675
|
+
// A 422 on a draft this CLI built is not a user error — this CLI's own
|
|
676
|
+
// contract produced it. Either the server is behind, or the two disagree.
|
|
677
|
+
if (err.status === 422) {
|
|
678
|
+
lines.push('this build sent a field the server did not accept, which usually means the server ' +
|
|
679
|
+
'is older than this CLI — check with `notifai doctor`');
|
|
680
|
+
}
|
|
681
|
+
return lines;
|
|
682
|
+
}
|
|
683
|
+
function rejectedPaths(details) {
|
|
684
|
+
if (!Array.isArray(details))
|
|
685
|
+
return [];
|
|
686
|
+
return details
|
|
687
|
+
.map((issue) => typeof issue === 'object' && issue !== null && typeof issue.path === 'string'
|
|
688
|
+
? issue.path
|
|
689
|
+
: null)
|
|
690
|
+
.filter((path) => path !== null && path !== '')
|
|
691
|
+
.slice(0, 5);
|
|
692
|
+
}
|
|
693
|
+
/**
|
|
694
|
+
* Registers a question for turn-end routing under the user's presence config.
|
|
695
|
+
* Returns immediately so the agent can ask in prose and end its turn. With the
|
|
696
|
+
* default presence gate, recent keyboard or mouse activity keeps it local;
|
|
697
|
+
* `require_idle = false` intentionally permits a push while the user is active.
|
|
698
|
+
*/
|
|
699
|
+
export function askCommand(deps, question, flags) {
|
|
700
|
+
// An agent calling this gets no hook payload and no harness exports its
|
|
701
|
+
// session id, so the UserPromptSubmit hook leaves a pointer keyed on the
|
|
702
|
+
// project directory and we read it back here. The pointer outranks the
|
|
703
|
+
// NOTIFAI_SESSION fallback deliberately: the exported id is often a chosen
|
|
704
|
+
// label rather than the harness's own id, and the hooks key state by the
|
|
705
|
+
// latter — the env var is only trusted when no hook has spoken.
|
|
706
|
+
const now = (deps.now ?? Date.now)();
|
|
707
|
+
const sessionId = flags.session ??
|
|
708
|
+
readProjectSession(deps.cwd, deps.env, now) ??
|
|
709
|
+
deps.env['NOTIFAI_SESSION'] ??
|
|
710
|
+
undefined;
|
|
711
|
+
if (!sessionId) {
|
|
712
|
+
for (const line of diagnoseMissingSession(deps))
|
|
713
|
+
deps.io.err(line);
|
|
714
|
+
return EXIT.usage;
|
|
715
|
+
}
|
|
716
|
+
if (question.trim() === '') {
|
|
717
|
+
deps.io.err('The question cannot be empty.');
|
|
718
|
+
return EXIT.usage;
|
|
719
|
+
}
|
|
720
|
+
// Validate here, not at push time. The push happens inside a hook, where a
|
|
721
|
+
// rejection becomes a stderr note the agent never reads — so a malformed
|
|
722
|
+
// choice set would look like it registered fine and then silently never ask.
|
|
723
|
+
const choices = parseChoices(flags.choice);
|
|
724
|
+
if (choices === 'invalid') {
|
|
725
|
+
deps.io.err(CHOICE_USAGE);
|
|
726
|
+
return EXIT.usage;
|
|
727
|
+
}
|
|
728
|
+
registerQuestion(sessionId, deps.env, {
|
|
729
|
+
question: question.trim(),
|
|
730
|
+
...(choices !== null ? { choices: choices.map((choice) => choice.label) } : {}),
|
|
731
|
+
}, (deps.now ?? Date.now)());
|
|
732
|
+
if (choices !== null) {
|
|
733
|
+
deps.io.out(`Answers offered: ${choices.map((choice) => choice.label).join(' / ')}`);
|
|
734
|
+
}
|
|
735
|
+
// On stdout, not stderr: the agent reads the former and this is only useful
|
|
736
|
+
// if it is read while there is still time to fix the question.
|
|
737
|
+
const ambiguous = ambiguousChoiceSplit(flags.choice);
|
|
738
|
+
if (ambiguous !== null)
|
|
739
|
+
deps.io.out(ambiguous);
|
|
740
|
+
deps.io.out('Question registered. Ask it in the conversation as usual and end your turn.');
|
|
741
|
+
return EXIT.ok;
|
|
742
|
+
}
|
|
743
|
+
/**
|
|
744
|
+
* Why `ask` cannot see a session, in terms of what to do about it.
|
|
745
|
+
*
|
|
746
|
+
* Only a UserPromptSubmit hook firing produces the pointer this reads, and the
|
|
747
|
+
* old message answered every cause with "run `notifai hooks install` and send
|
|
748
|
+
* one prompt". The useful next action depends on the harness: some reload
|
|
749
|
+
* project hook files, OpenCode loads its plugin at startup, and Codex should be
|
|
750
|
+
* checked after a prompt before assuming that a new session is required.
|
|
751
|
+
*/
|
|
752
|
+
function diagnoseMissingSession(deps) {
|
|
753
|
+
const installations = findInstallations(deps.cwd, deps.env);
|
|
754
|
+
if (installations.length === 0) {
|
|
755
|
+
return [
|
|
756
|
+
'Could not tell which harness session this is: no NotifAI hooks are installed for this project.',
|
|
757
|
+
'Run `notifai hooks install`, then follow the activation instruction it prints.',
|
|
758
|
+
];
|
|
759
|
+
}
|
|
760
|
+
const where = installations.map((i) => `${i.harness} in ${i.file}`).join(', ');
|
|
761
|
+
return [
|
|
762
|
+
`Could not tell which harness session this is. NotifAI hooks are installed (${where}),`,
|
|
763
|
+
'but no usable session pointer from the last 24 hours exists here.',
|
|
764
|
+
hookActivationAdvice(installations),
|
|
765
|
+
'To ask from this session anyway, pass --session <id>.',
|
|
766
|
+
];
|
|
767
|
+
}
|
|
768
|
+
/** The least disruptive verified way to make each installed adapter run once. */
|
|
769
|
+
function hookActivationAdvice(installations) {
|
|
770
|
+
const harnesses = new Set(installations.map((installation) => installation.harness));
|
|
771
|
+
const advice = [];
|
|
772
|
+
if (installations.some((installation) => installation.harness === 'claude-code' && !installation.global)) {
|
|
773
|
+
advice.push('Claude Code: send one new prompt; project hook files reload without a restart');
|
|
774
|
+
}
|
|
775
|
+
if (installations.some((installation) => installation.harness === 'claude-code' && installation.global)) {
|
|
776
|
+
advice.push('Claude Code global hooks: send one prompt; start a new session only if it does not fire');
|
|
777
|
+
}
|
|
778
|
+
if (installations.some((installation) => installation.harness === 'cursor' && !installation.global)) {
|
|
779
|
+
advice.push('Cursor: send one prompt, then run `notifai doctor`; start a new session only if it still has not fired');
|
|
780
|
+
}
|
|
781
|
+
if (installations.some((installation) => installation.harness === 'cursor' && installation.global)) {
|
|
782
|
+
advice.push('Cursor global hooks: send one prompt; start a new session only if it does not fire');
|
|
783
|
+
}
|
|
784
|
+
if (harnesses.has('codex')) {
|
|
785
|
+
advice.push('Codex: send one prompt, then run `notifai doctor`; start a new session only if it still has not fired');
|
|
786
|
+
}
|
|
787
|
+
if (harnesses.has('opencode')) {
|
|
788
|
+
advice.push('OpenCode: restart it, then send one prompt; plugins load at startup');
|
|
789
|
+
}
|
|
790
|
+
return `${advice.join('. ')}.`;
|
|
791
|
+
}
|
|
792
|
+
/** Retire a question so a late answer is rejected rather than silently lost. */
|
|
793
|
+
export async function closeCommand(deps, requestId) {
|
|
794
|
+
const config = loadConfig({ cwd: deps.cwd, env: deps.env });
|
|
795
|
+
const authed = authedClient(deps, config);
|
|
796
|
+
if (!authed)
|
|
797
|
+
return EXIT.auth;
|
|
798
|
+
try {
|
|
799
|
+
await authed.client.closeReplies(requestId);
|
|
800
|
+
deps.io.out(`Closed the reply window for ${requestId}.`);
|
|
801
|
+
return EXIT.ok;
|
|
802
|
+
}
|
|
803
|
+
catch (err) {
|
|
804
|
+
return reportError(deps, err);
|
|
805
|
+
}
|
|
806
|
+
}
|
|
807
|
+
export function hooksInstallCommand(deps, flags) {
|
|
808
|
+
const harness = resolveHarness(deps, flags.harness);
|
|
809
|
+
if (!harness)
|
|
810
|
+
return EXIT.usage;
|
|
811
|
+
const execPath = flags.execPath ?? process.execPath;
|
|
812
|
+
const scriptPath = flags.scriptPath ?? process.argv[1] ?? 'notifai';
|
|
813
|
+
const config = loadConfig({ cwd: deps.cwd, env: deps.env });
|
|
814
|
+
const file = settingsFile(harness, flags.global ?? false, deps.cwd, deps.env);
|
|
815
|
+
// OpenCode's adapter is a generated plugin module rather than a handler
|
|
816
|
+
// merged into a settings document, so it owns the whole file.
|
|
817
|
+
if (harness === 'opencode') {
|
|
818
|
+
return installOpencodePlugin(deps, file, {
|
|
819
|
+
execPath,
|
|
820
|
+
scriptPath,
|
|
821
|
+
timeoutSeconds: blockingHookTimeoutSeconds(config.ask_grace_seconds.value, config.hook_reply_timeout_seconds.value),
|
|
822
|
+
});
|
|
823
|
+
}
|
|
824
|
+
if (harness === 'cursor') {
|
|
825
|
+
let document;
|
|
826
|
+
try {
|
|
827
|
+
document = loadCursorSettings(file);
|
|
828
|
+
}
|
|
829
|
+
catch (err) {
|
|
830
|
+
deps.io.err(String(err));
|
|
831
|
+
return EXIT.failed;
|
|
832
|
+
}
|
|
833
|
+
const merged = mergeCursorHooks(document, buildCursorHookConfig({
|
|
834
|
+
execPath,
|
|
835
|
+
scriptPath,
|
|
836
|
+
replyTimeoutSeconds: config.hook_reply_timeout_seconds.value,
|
|
837
|
+
graceSeconds: config.ask_grace_seconds.value,
|
|
838
|
+
}), scriptPath);
|
|
839
|
+
try {
|
|
840
|
+
applyPlan(file, merged.document);
|
|
841
|
+
}
|
|
842
|
+
catch (err) {
|
|
843
|
+
deps.io.err(String(err));
|
|
844
|
+
return EXIT.failed;
|
|
845
|
+
}
|
|
846
|
+
deps.io.out(`Installed ${harness} hooks in ${file}`);
|
|
847
|
+
if (merged.replaced.length > 0)
|
|
848
|
+
deps.io.out(` replaced: ${merged.replaced.join(', ')}`);
|
|
849
|
+
if (merged.added.length > 0)
|
|
850
|
+
deps.io.out(` added: ${merged.added.join(', ')}`);
|
|
851
|
+
if (flags.global) {
|
|
852
|
+
deps.io.out('Send one Cursor prompt, then check `notifai doctor`. If the hook has not fired,');
|
|
853
|
+
deps.io.out('start a new Cursor session and try one prompt again.');
|
|
854
|
+
}
|
|
855
|
+
else {
|
|
856
|
+
deps.io.out('Send one Cursor prompt, then check `notifai doctor`. If the hook has not fired,');
|
|
857
|
+
deps.io.out('start a new Cursor session and try one prompt again.');
|
|
858
|
+
}
|
|
859
|
+
deps.io.out('A companion-device answer is submitted as one');
|
|
860
|
+
deps.io.out('follow-up user message; loop_limit = 1 prevents repeated answer turns.');
|
|
861
|
+
return EXIT.ok;
|
|
862
|
+
}
|
|
863
|
+
let document;
|
|
864
|
+
try {
|
|
865
|
+
document = loadSettings(file);
|
|
866
|
+
}
|
|
867
|
+
catch (err) {
|
|
868
|
+
deps.io.err(String(err));
|
|
869
|
+
return EXIT.failed;
|
|
870
|
+
}
|
|
871
|
+
const merged = mergeHooks(document, buildHookConfig({
|
|
872
|
+
execPath,
|
|
873
|
+
scriptPath,
|
|
874
|
+
replyTimeoutSeconds: config.hook_reply_timeout_seconds.value,
|
|
875
|
+
graceSeconds: config.ask_grace_seconds.value,
|
|
876
|
+
}), scriptPath);
|
|
877
|
+
try {
|
|
878
|
+
applyPlan(file, merged.document);
|
|
879
|
+
}
|
|
880
|
+
catch (err) {
|
|
881
|
+
deps.io.err(String(err));
|
|
882
|
+
return EXIT.failed;
|
|
883
|
+
}
|
|
884
|
+
deps.io.out(`Installed ${harness} hooks in ${file}`);
|
|
885
|
+
if (merged.replaced.length > 0)
|
|
886
|
+
deps.io.out(` replaced: ${merged.replaced.join(', ')}`);
|
|
887
|
+
if (merged.added.length > 0)
|
|
888
|
+
deps.io.out(` added: ${merged.added.join(', ')}`);
|
|
889
|
+
if (merged.removed.length > 0) {
|
|
890
|
+
deps.io.out(` removed: ${merged.removed.join(', ')} (this build no longer serves them)`);
|
|
891
|
+
}
|
|
892
|
+
deps.io.out('');
|
|
893
|
+
deps.io.out(config.require_idle.value
|
|
894
|
+
? `While keyboard or mouse idle time stays below ${config.away_after_seconds.value}s, ` +
|
|
895
|
+
`nothing is pushed. A question registered with \`notifai ask\` stays in the terminal ` +
|
|
896
|
+
`until its ${config.ask_grace_seconds.value}s grace window, counted from registration, ` +
|
|
897
|
+
`has elapsed; it goes to your devices only while the machine also meets the idle threshold. ` +
|
|
898
|
+
`Set \`require_idle = false\` to be notified even while you are working.`
|
|
899
|
+
: `A question registered with \`notifai ask\` stays in the terminal for ` +
|
|
900
|
+
`${config.ask_grace_seconds.value}s from registration and then goes to your devices ` +
|
|
901
|
+
`whether or not you are at this machine ` +
|
|
902
|
+
`(\`require_idle = false\`).`);
|
|
903
|
+
if (config.require_idle.value) {
|
|
904
|
+
deps.io.out('If this OS exposes no keyboard/mouse idle signal, the hook falls back to prompt silence and skips the blocking grace once it decides you are away.');
|
|
905
|
+
}
|
|
906
|
+
if (harness === 'codex') {
|
|
907
|
+
const layer = flags.global ? null : codexLayerDir(deps.cwd);
|
|
908
|
+
if (layer !== null) {
|
|
909
|
+
// Codex reads project hooks from the main repository but only looks when
|
|
910
|
+
// a `.codex` directory sits at or above cwd, so a worktree install has to
|
|
911
|
+
// write one file and create one directory in two different places. Doing
|
|
912
|
+
// it silently would leave the next person deriving this the hard way
|
|
913
|
+
//.
|
|
914
|
+
mkdirSync(layer, { recursive: true });
|
|
915
|
+
deps.io.out('');
|
|
916
|
+
deps.io.out('You are in a worktree. Codex reads project hooks from the main repository,');
|
|
917
|
+
deps.io.out(`so they were written to ${file}. ${layer} was created so this`);
|
|
918
|
+
deps.io.out('worktree discovers that file. Each other worktree needs its own `.codex`');
|
|
919
|
+
deps.io.out('directory; rerun this installer from that worktree to create it.');
|
|
920
|
+
}
|
|
921
|
+
}
|
|
922
|
+
deps.io.out('');
|
|
923
|
+
if (harness === 'claude-code' && flags.global !== true) {
|
|
924
|
+
deps.io.out('Claude Code reloads project hook files without a restart. Send one new prompt,');
|
|
925
|
+
deps.io.out('then check `notifai doctor` to confirm that the hook fired.');
|
|
926
|
+
}
|
|
927
|
+
else if (harness === 'claude-code') {
|
|
928
|
+
deps.io.out('Send one new Claude Code prompt, then check `notifai doctor`. If the hook has');
|
|
929
|
+
deps.io.out('not fired, start a new Claude Code session and try one prompt again.');
|
|
930
|
+
}
|
|
931
|
+
else {
|
|
932
|
+
deps.io.out('Send one Codex prompt, then check `notifai doctor`. If the hook has not fired,');
|
|
933
|
+
deps.io.out('start a new Codex session and try one prompt again.');
|
|
934
|
+
}
|
|
935
|
+
return EXIT.ok;
|
|
936
|
+
}
|
|
937
|
+
/**
|
|
938
|
+
* Writes the OpenCode plugin, replacing any NotifAI plugin already there —
|
|
939
|
+
* including one a different checkout wrote, matched on the managed marker for
|
|
940
|
+
* the same reason command hooks are.
|
|
941
|
+
*/
|
|
942
|
+
function installOpencodePlugin(deps, file, options) {
|
|
943
|
+
if (existsSync(file)) {
|
|
944
|
+
const existing = readFileSync(file, 'utf8');
|
|
945
|
+
if (!isOurOpencodePlugin(existing)) {
|
|
946
|
+
deps.io.err(`${file} exists and was not written by NotifAI; move it aside first.`);
|
|
947
|
+
return EXIT.failed;
|
|
948
|
+
}
|
|
949
|
+
}
|
|
950
|
+
try {
|
|
951
|
+
mkdirSync(path.dirname(file), { recursive: true });
|
|
952
|
+
writeFileSync(file, opencodePluginSource(options), { mode: 0o644 });
|
|
953
|
+
}
|
|
954
|
+
catch (err) {
|
|
955
|
+
deps.io.err(String(err));
|
|
956
|
+
return EXIT.failed;
|
|
957
|
+
}
|
|
958
|
+
deps.io.out(`Installed the OpenCode plugin at ${file}`);
|
|
959
|
+
deps.io.out('');
|
|
960
|
+
deps.io.out('It maps chat.message to presence, session.idle to question escalation, and');
|
|
961
|
+
deps.io.out('session.deleted to local cleanup through the same `notifai hook` commands');
|
|
962
|
+
deps.io.out('the other harnesses run. Permission prompts stay in OpenCode.');
|
|
963
|
+
deps.io.out('OpenCode cannot reliably resume an idle agent turn with a device answer; use');
|
|
964
|
+
deps.io.out('`notifai send --reply` when the answer must return to the agent.');
|
|
965
|
+
deps.io.out('');
|
|
966
|
+
deps.io.out('Restart OpenCode: it loads plugins once at start.');
|
|
967
|
+
return EXIT.ok;
|
|
968
|
+
}
|
|
969
|
+
export function hooksUninstallCommand(deps, flags) {
|
|
970
|
+
const harness = resolveHarness(deps, flags.harness);
|
|
971
|
+
if (!harness)
|
|
972
|
+
return EXIT.usage;
|
|
973
|
+
const scriptPath = flags.scriptPath ?? process.argv[1] ?? 'notifai';
|
|
974
|
+
const file = settingsFile(harness, flags.global ?? false, deps.cwd, deps.env);
|
|
975
|
+
if (!existsSync(file)) {
|
|
976
|
+
deps.io.out(`Nothing to remove: ${file} does not exist.`);
|
|
977
|
+
return EXIT.ok;
|
|
978
|
+
}
|
|
979
|
+
if (harness === 'opencode') {
|
|
980
|
+
// We own the whole file, but only if we wrote it.
|
|
981
|
+
if (!isOurOpencodePlugin(readFileSync(file, 'utf8'))) {
|
|
982
|
+
deps.io.out(`Left ${file} alone: NotifAI did not write it.`);
|
|
983
|
+
return EXIT.ok;
|
|
984
|
+
}
|
|
985
|
+
rmSync(file, { force: true });
|
|
986
|
+
deps.io.out(`Removed the NotifAI OpenCode plugin at ${file}`);
|
|
987
|
+
return EXIT.ok;
|
|
988
|
+
}
|
|
989
|
+
if (harness === 'cursor') {
|
|
990
|
+
let document;
|
|
991
|
+
try {
|
|
992
|
+
document = loadCursorSettings(file);
|
|
993
|
+
}
|
|
994
|
+
catch (err) {
|
|
995
|
+
deps.io.err(String(err));
|
|
996
|
+
return EXIT.failed;
|
|
997
|
+
}
|
|
998
|
+
const stripped = removeCursorHooks(document, scriptPath);
|
|
999
|
+
try {
|
|
1000
|
+
applyPlan(file, stripped.document);
|
|
1001
|
+
}
|
|
1002
|
+
catch (err) {
|
|
1003
|
+
deps.io.err(String(err));
|
|
1004
|
+
return EXIT.failed;
|
|
1005
|
+
}
|
|
1006
|
+
deps.io.out(stripped.replaced.length > 0
|
|
1007
|
+
? `Removed NotifAI hooks (${stripped.replaced.join(', ')}) from ${file}`
|
|
1008
|
+
: `No NotifAI hooks found in ${file}`);
|
|
1009
|
+
return EXIT.ok;
|
|
1010
|
+
}
|
|
1011
|
+
let document;
|
|
1012
|
+
try {
|
|
1013
|
+
document = loadSettings(file);
|
|
1014
|
+
}
|
|
1015
|
+
catch (err) {
|
|
1016
|
+
deps.io.err(String(err));
|
|
1017
|
+
return EXIT.failed;
|
|
1018
|
+
}
|
|
1019
|
+
const stripped = removeHooks(document, scriptPath);
|
|
1020
|
+
try {
|
|
1021
|
+
applyPlan(file, stripped.document);
|
|
1022
|
+
}
|
|
1023
|
+
catch (err) {
|
|
1024
|
+
deps.io.err(String(err));
|
|
1025
|
+
return EXIT.failed;
|
|
1026
|
+
}
|
|
1027
|
+
deps.io.out(stripped.replaced.length > 0
|
|
1028
|
+
? `Removed NotifAI hooks (${stripped.replaced.join(', ')}) from ${file}`
|
|
1029
|
+
: `No NotifAI hooks found in ${file}`);
|
|
1030
|
+
return EXIT.ok;
|
|
1031
|
+
}
|
|
1032
|
+
function resolveHarness(deps, requested) {
|
|
1033
|
+
if (requested !== undefined) {
|
|
1034
|
+
if (HARNESSES.includes(requested))
|
|
1035
|
+
return requested;
|
|
1036
|
+
deps.io.err(`Unknown harness "${requested}". Supported: ${HARNESSES.join(', ')}.`);
|
|
1037
|
+
return null;
|
|
1038
|
+
}
|
|
1039
|
+
const detected = detectHarness(deps.cwd);
|
|
1040
|
+
if (!detected) {
|
|
1041
|
+
deps.io.err(`Could not tell which harness to install for — pass --harness <${HARNESSES.join('|')}>.`);
|
|
1042
|
+
return null;
|
|
1043
|
+
}
|
|
1044
|
+
return detected;
|
|
1045
|
+
}
|
|
1046
|
+
// ---------------------------------------------------------------------------
|
|
1047
|
+
// config show / set
|
|
1048
|
+
// ---------------------------------------------------------------------------
|
|
1049
|
+
export function configShowCommand(deps, flags) {
|
|
1050
|
+
const config = loadConfig({ cwd: deps.cwd, env: deps.env });
|
|
1051
|
+
if (flags.json) {
|
|
1052
|
+
const output = Object.fromEntries(CONFIG_KEYS.map((key) => [key, { value: config[key].value, source: config[key].source }]));
|
|
1053
|
+
deps.io.out(JSON.stringify(output, null, 2));
|
|
1054
|
+
return EXIT.ok;
|
|
1055
|
+
}
|
|
1056
|
+
for (const key of CONFIG_KEYS) {
|
|
1057
|
+
const entry = config[key];
|
|
1058
|
+
const provenance = flags.explain ? ` [${entry.source}]` : '';
|
|
1059
|
+
deps.io.out(`${key} = ${JSON.stringify(entry.value)}${provenance}`);
|
|
1060
|
+
}
|
|
1061
|
+
return EXIT.ok;
|
|
1062
|
+
}
|
|
1063
|
+
export async function configSetCommand(deps, key, rawValue, flags) {
|
|
1064
|
+
if (!CONFIG_KEYS.includes(key)) {
|
|
1065
|
+
deps.io.err(`Unknown key "${key}". Valid keys: ${CONFIG_KEYS.join(', ')}`);
|
|
1066
|
+
return EXIT.usage;
|
|
1067
|
+
}
|
|
1068
|
+
let value = rawValue;
|
|
1069
|
+
if (NUMERIC_CONFIG_KEYS.includes(key)) {
|
|
1070
|
+
const numeric = Number(rawValue);
|
|
1071
|
+
if (!Number.isInteger(numeric)) {
|
|
1072
|
+
deps.io.err(`"${rawValue}" is not an integer.`);
|
|
1073
|
+
return EXIT.usage;
|
|
1074
|
+
}
|
|
1075
|
+
const bounds = configBounds(key);
|
|
1076
|
+
if (bounds !== undefined && (numeric < bounds.min || numeric > bounds.max)) {
|
|
1077
|
+
deps.io.err(`${key} must be between ${bounds.min} and ${bounds.max}.`);
|
|
1078
|
+
return EXIT.usage;
|
|
1079
|
+
}
|
|
1080
|
+
value = numeric;
|
|
1081
|
+
}
|
|
1082
|
+
if (BOOLEAN_CONFIG_KEYS.includes(key)) {
|
|
1083
|
+
if (rawValue !== 'true' && rawValue !== 'false') {
|
|
1084
|
+
deps.io.err(`${key} is a toggle — pass "true" or "false", not "${rawValue}".`);
|
|
1085
|
+
return EXIT.usage;
|
|
1086
|
+
}
|
|
1087
|
+
value = rawValue === 'true';
|
|
1088
|
+
}
|
|
1089
|
+
if (key === 'devices')
|
|
1090
|
+
value = rawValue.split(',').map((s) => s.trim()).filter(Boolean);
|
|
1091
|
+
let layer = flags.local ? 'local' : flags.project ? 'project' : 'global';
|
|
1092
|
+
if (flags.session === undefined &&
|
|
1093
|
+
flags.local !== true &&
|
|
1094
|
+
flags.project !== true &&
|
|
1095
|
+
flags.yes !== true &&
|
|
1096
|
+
deps.io.interactive === true &&
|
|
1097
|
+
deps.io.select) {
|
|
1098
|
+
const selected = await deps.io.select('Where should this setting live?', [
|
|
1099
|
+
{ value: 'global', label: 'This machine', hint: 'applies across projects' },
|
|
1100
|
+
{ value: 'project', label: 'This project (shared)', hint: '.notifai/config.toml' },
|
|
1101
|
+
{ value: 'local', label: 'This project (personal)', hint: 'keep config.local.toml gitignored' },
|
|
1102
|
+
]);
|
|
1103
|
+
if (selected === null) {
|
|
1104
|
+
deps.io.err('No configuration layer selected.');
|
|
1105
|
+
return EXIT.usage;
|
|
1106
|
+
}
|
|
1107
|
+
layer = selected;
|
|
1108
|
+
}
|
|
1109
|
+
const targetPath = flags.session
|
|
1110
|
+
? sessionConfigPath(flags.session, deps.env)
|
|
1111
|
+
: layer === 'local'
|
|
1112
|
+
? (findProjectLocalConfigPath(deps.cwd) ?? path.join(deps.cwd, '.notifai', 'config.local.toml'))
|
|
1113
|
+
: layer === 'project'
|
|
1114
|
+
? (findProjectConfigPath(deps.cwd) ?? path.join(deps.cwd, '.notifai', 'config.toml'))
|
|
1115
|
+
: globalConfigPath(deps.env);
|
|
1116
|
+
if (!flags.yes) {
|
|
1117
|
+
const confirmed = await deps.io.confirm(`Set ${key} = ${JSON.stringify(value)} in ${targetPath}?`);
|
|
1118
|
+
if (!confirmed) {
|
|
1119
|
+
deps.io.err('Not confirmed. Pass --yes to skip the confirmation gate.');
|
|
1120
|
+
return EXIT.usage;
|
|
1121
|
+
}
|
|
1122
|
+
}
|
|
1123
|
+
const existing = existsSync(targetPath)
|
|
1124
|
+
? parseToml(readFileSync(targetPath, 'utf8'))
|
|
1125
|
+
: {};
|
|
1126
|
+
existing[key] = value;
|
|
1127
|
+
mkdirSync(path.dirname(targetPath), { recursive: true });
|
|
1128
|
+
writeFileSync(targetPath, `${stringifyToml(existing)}\n`);
|
|
1129
|
+
deps.io.out(`Wrote ${key} to ${targetPath}`);
|
|
1130
|
+
return EXIT.ok;
|
|
1131
|
+
}
|
|
1132
|
+
// ---------------------------------------------------------------------------
|
|
1133
|
+
// init
|
|
1134
|
+
// ---------------------------------------------------------------------------
|
|
1135
|
+
/**
|
|
1136
|
+
* Where `npx skills add` fetches the optional agent skill from. The skill's
|
|
1137
|
+
* canonical home is the public repository (RafaelVidaurre/notifai), which has
|
|
1138
|
+
* nothing pushed to it yet, so no source can resolve today; the guard below
|
|
1139
|
+
* keeps `--skills` from installing anything until a tagged release exists to
|
|
1140
|
+
* pin this to. Never point this at the private repository — the installer
|
|
1141
|
+
* command is printed to users, and only public sources belong in it.
|
|
1142
|
+
*/
|
|
1143
|
+
const SKILLS_SOURCE = '';
|
|
1144
|
+
/** Derive a contract-valid project slug from a directory name. */
|
|
1145
|
+
export function projectSlugFrom(name) {
|
|
1146
|
+
const slug = name
|
|
1147
|
+
.toLowerCase()
|
|
1148
|
+
.replaceAll(/[^a-z0-9._-]+/g, '-')
|
|
1149
|
+
.replaceAll(/^[^a-z0-9]+|[^a-z0-9._-]+$/g, '')
|
|
1150
|
+
.slice(0, 64);
|
|
1151
|
+
return slug.length > 0 && /^[a-z0-9]/.test(slug) ? slug : 'project';
|
|
1152
|
+
}
|
|
1153
|
+
/**
|
|
1154
|
+
* The setup coordinator that observes each prerequisite and advances the ones
|
|
1155
|
+
* this build can perform.
|
|
1156
|
+
*
|
|
1157
|
+
* Idempotent by construction: every step first observes, then acts only on the
|
|
1158
|
+
* gap, so re-running is how you check the setup as much as how you create it.
|
|
1159
|
+
* With a human at a terminal it walks them through the missing pieces; run by
|
|
1160
|
+
* an agent it never prompts — each optional step is answered by a flag, and
|
|
1161
|
+
* whatever only the user can do (signing in, pairing a companion device) is printed as
|
|
1162
|
+
* the exact command to hand back to them.
|
|
1163
|
+
*/
|
|
1164
|
+
/**
|
|
1165
|
+
* Close a gap the CLI is allowed to close on its own, without asking.
|
|
1166
|
+
*
|
|
1167
|
+
* Only reached for `by: 'cli'` remedies, which by definition need no human, so
|
|
1168
|
+
* this stays silent about what it did — the re-assessment that follows reports
|
|
1169
|
+
* the new state, and narrating both is how a setup log becomes unreadable.
|
|
1170
|
+
*
|
|
1171
|
+
* Returns false when the attempt failed, which is different from declining:
|
|
1172
|
+
* a decline leaves an optional gap open and is fine, a failure is not.
|
|
1173
|
+
*/
|
|
1174
|
+
async function closeGap(deps, state, flags) {
|
|
1175
|
+
if (state.id === 'project') {
|
|
1176
|
+
const configPath = path.join(deps.cwd, '.notifai', 'config.toml');
|
|
1177
|
+
const existing = existsSync(configPath)
|
|
1178
|
+
? parseToml(readFileSync(configPath, 'utf8'))
|
|
1179
|
+
: {};
|
|
1180
|
+
existing['project'] = projectSlugFrom(flags.projectId ?? path.basename(deps.cwd));
|
|
1181
|
+
mkdirSync(path.dirname(configPath), { recursive: true });
|
|
1182
|
+
writeFileSync(configPath, `${stringifyToml(existing)}\n`);
|
|
1183
|
+
return true;
|
|
1184
|
+
}
|
|
1185
|
+
if (state.id === 'hooks') {
|
|
1186
|
+
let harness = detectHarness(deps.cwd);
|
|
1187
|
+
if (harness === null && deps.io.interactive === true && deps.io.select) {
|
|
1188
|
+
const picked = await deps.io.select('Which agent harness do you use here?', HARNESSES.map((name) => ({ value: name, label: name })));
|
|
1189
|
+
if (picked !== null)
|
|
1190
|
+
harness = picked;
|
|
1191
|
+
}
|
|
1192
|
+
if (harness === null) {
|
|
1193
|
+
deps.io.err(`Could not tell which harness to wire. Run: notifai hooks install --harness <${HARNESSES.join('|')}>`);
|
|
1194
|
+
return false;
|
|
1195
|
+
}
|
|
1196
|
+
return hooksInstallCommand(deps, { harness }) === EXIT.ok;
|
|
1197
|
+
}
|
|
1198
|
+
if (state.id === 'skill') {
|
|
1199
|
+
deps.io.out(`Installing the notifai agent skill (npx skills add ${SKILLS_SOURCE})...`);
|
|
1200
|
+
const code = await new Promise((resolve) => {
|
|
1201
|
+
const child = spawn('npx', ['-y', 'skills', 'add', SKILLS_SOURCE, '--skill', 'notifai'], {
|
|
1202
|
+
cwd: deps.cwd,
|
|
1203
|
+
stdio: 'inherit',
|
|
1204
|
+
});
|
|
1205
|
+
child.on('error', () => resolve(1));
|
|
1206
|
+
child.on('exit', (exitCode) => resolve(exitCode ?? 1));
|
|
1207
|
+
});
|
|
1208
|
+
if (code !== 0) {
|
|
1209
|
+
deps.io.err('Skill installation failed — run it manually with:');
|
|
1210
|
+
deps.io.err(` npx skills add ${SKILLS_SOURCE} --skill notifai`);
|
|
1211
|
+
}
|
|
1212
|
+
return code === 0;
|
|
1213
|
+
}
|
|
1214
|
+
return false;
|
|
1215
|
+
}
|
|
1216
|
+
/** Whether an optional gap should be closed, given flags and who is watching. */
|
|
1217
|
+
function wantsOptional(deps, state, flags) {
|
|
1218
|
+
// Naming the project is init's whole reason to touch the filesystem, costs
|
|
1219
|
+
// nothing, and is undone by editing one line — so it is done rather than
|
|
1220
|
+
// asked about, for a human and an agent alike.
|
|
1221
|
+
if (state.id === 'project')
|
|
1222
|
+
return Promise.resolve(true);
|
|
1223
|
+
const explicit = state.id === 'hooks' ? flags.hooks : state.id === 'skill' ? flags.skills : undefined;
|
|
1224
|
+
if (explicit !== undefined)
|
|
1225
|
+
return Promise.resolve(explicit);
|
|
1226
|
+
// An agent is never asked, and never assumed into a change it did not
|
|
1227
|
+
// request: silence means no, and the summary says what was skipped.
|
|
1228
|
+
if (deps.io.interactive !== true)
|
|
1229
|
+
return Promise.resolve(false);
|
|
1230
|
+
const question = state.id === 'hooks'
|
|
1231
|
+
? 'Install harness hooks, so questions reach your devices when you are away?'
|
|
1232
|
+
: 'Install/update the agent guidance skill in this repo?';
|
|
1233
|
+
return deps.io.confirm(question, true);
|
|
1234
|
+
}
|
|
1235
|
+
/**
|
|
1236
|
+
* Setup as one step at a time.
|
|
1237
|
+
*
|
|
1238
|
+
* The old version ran five steps in a fixed order and ended with a list of
|
|
1239
|
+
* everything still outstanding. That is a report, and a report is the wrong
|
|
1240
|
+
* output here: someone handed five things to do does none of them, and the
|
|
1241
|
+
* order was the script's rather than the dependency graph's — it offered to
|
|
1242
|
+
* install hooks after a sign-in that had just failed.
|
|
1243
|
+
*
|
|
1244
|
+
* So this closes what it can, then surfaces exactly one thing, the first that
|
|
1245
|
+
* stands in the way. Re-running advances by one. Idempotence stops being a
|
|
1246
|
+
* property to preserve and becomes the mechanism: every decision is derived
|
|
1247
|
+
* from observed state, so a partial run, a second project, a fresh worktree
|
|
1248
|
+
* and a revoked credential are the same code path arriving at different
|
|
1249
|
+
* states rather than four branches to enumerate.
|
|
1250
|
+
*/
|
|
1251
|
+
export async function initCommand(deps, flags) {
|
|
1252
|
+
await deps.io.intro?.('NotifAI setup');
|
|
1253
|
+
if (SKILLS_SOURCE === '' && flags.skills === true) {
|
|
1254
|
+
deps.io.err('The optional agent skill is not published yet; this build has no skill source configured.');
|
|
1255
|
+
return EXIT.failed;
|
|
1256
|
+
}
|
|
1257
|
+
let readiness = await assessReadiness(deps);
|
|
1258
|
+
let failed = false;
|
|
1259
|
+
// One pass is enough: closing a gap never opens an earlier one, and the
|
|
1260
|
+
// states that remain are exactly those needing someone who is not the CLI.
|
|
1261
|
+
for (const state of readiness.states) {
|
|
1262
|
+
const remedy = state.remedy;
|
|
1263
|
+
if (remedy === undefined)
|
|
1264
|
+
continue;
|
|
1265
|
+
// Its to launch, theirs to complete. Offering to start the sign-in is the
|
|
1266
|
+
// single most useful thing this command does for a person, so it is worth
|
|
1267
|
+
// the extra branch — but only when someone is actually watching, since an
|
|
1268
|
+
// agent that reached a browser prompt would simply hang.
|
|
1269
|
+
if (remedy.by === 'user-here' && remedy.interactive === true) {
|
|
1270
|
+
if (state.status !== 'gap' || deps.io.interactive !== true)
|
|
1271
|
+
continue;
|
|
1272
|
+
if (!(await deps.io.confirm('Sign in now? (opens your browser)', true)))
|
|
1273
|
+
continue;
|
|
1274
|
+
if ((await loginCommand(deps, {})) !== EXIT.ok)
|
|
1275
|
+
failed = true;
|
|
1276
|
+
continue;
|
|
1277
|
+
}
|
|
1278
|
+
if (remedy.by !== 'cli')
|
|
1279
|
+
continue;
|
|
1280
|
+
if (state.status === 'gap') {
|
|
1281
|
+
if (!(await closeGap(deps, state, flags)))
|
|
1282
|
+
failed = true;
|
|
1283
|
+
}
|
|
1284
|
+
else if (state.status === 'optional-gap' && (await wantsOptional(deps, state, flags))) {
|
|
1285
|
+
if (!(await closeGap(deps, state, flags)))
|
|
1286
|
+
failed = true;
|
|
1287
|
+
}
|
|
1288
|
+
}
|
|
1289
|
+
readiness = await assessReadiness(deps);
|
|
1290
|
+
for (const state of readiness.states.filter((s) => s.status === 'ready')) {
|
|
1291
|
+
deps.io.out(`${state.title}: ${state.detail}`);
|
|
1292
|
+
}
|
|
1293
|
+
const blocker = firstBlocker(readiness);
|
|
1294
|
+
if (blocker === null) {
|
|
1295
|
+
const skipped = openItems(readiness);
|
|
1296
|
+
for (const state of skipped)
|
|
1297
|
+
deps.io.out(`Optional, not set up — ${remedyLine(state)}`);
|
|
1298
|
+
deps.io.out('All set. Agents in this project can notify you and ask questions.');
|
|
1299
|
+
await deps.io.outro?.('All set ✨');
|
|
1300
|
+
return failed ? EXIT.failed : EXIT.ok;
|
|
1301
|
+
}
|
|
1302
|
+
// Exactly one. Everything else waits until this is done, because the next
|
|
1303
|
+
// gap is frequently a consequence of this one and naming it now would send
|
|
1304
|
+
// the reader off to fix something that is not actually wrong.
|
|
1305
|
+
deps.io.out('');
|
|
1306
|
+
deps.io.out(`Next: ${blocker.title} — ${blocker.detail}`);
|
|
1307
|
+
deps.io.out(` ${remedyLine(blocker)}`);
|
|
1308
|
+
if (blocker.remedy?.by === 'user-elsewhere') {
|
|
1309
|
+
deps.io.out(' Then re-run `notifai init` and it will pick up from here.');
|
|
1310
|
+
}
|
|
1311
|
+
await deps.io.outro?.('One step remains (above)');
|
|
1312
|
+
return failed ? EXIT.failed : EXIT.ok;
|
|
1313
|
+
}
|
|
1314
|
+
// ---------------------------------------------------------------------------
|
|
1315
|
+
// doctor
|
|
1316
|
+
// ---------------------------------------------------------------------------
|
|
1317
|
+
/**
|
|
1318
|
+
* Whether the deployed server understands the contract this build speaks
|
|
1319
|
+
*.
|
|
1320
|
+
*
|
|
1321
|
+
* Every test in the suite runs the CLI and the server from the same commit, so
|
|
1322
|
+
* a client sending a field the deployed server has not learned yet is
|
|
1323
|
+
* structurally invisible to it. That gap shipped a silent production outage on
|
|
1324
|
+
* 2026-08-03: questions stopped reaching devices entirely and nothing said so.
|
|
1325
|
+
*
|
|
1326
|
+
* The capability document already carries a schema version and is served
|
|
1327
|
+
* unauthenticated, so a single GET answers the question. This does not claim to
|
|
1328
|
+
* catch every skew — an additive field inside the same schema version would
|
|
1329
|
+
* still pass — but it catches the one that has actually happened, and it names
|
|
1330
|
+
* the remedy rather than leaving someone to derive it.
|
|
1331
|
+
*/
|
|
1332
|
+
async function contractCheck(client) {
|
|
1333
|
+
const local = CAPABILITIES_V1.describe('ios')?.schema_version;
|
|
1334
|
+
try {
|
|
1335
|
+
const remote = (await client.capabilities('ios')).schema_version;
|
|
1336
|
+
if (local === remote) {
|
|
1337
|
+
return { name: 'contract', ok: true, detail: `server and CLI both speak schema v${remote}` };
|
|
1338
|
+
}
|
|
1339
|
+
return {
|
|
1340
|
+
name: 'contract',
|
|
1341
|
+
ok: false,
|
|
1342
|
+
detail: local !== undefined && remote < local
|
|
1343
|
+
? `server speaks schema v${remote}, this CLI speaks v${local} — the server needs deploying, ` +
|
|
1344
|
+
'until then sends carrying newer fields are rejected'
|
|
1345
|
+
: `server speaks schema v${remote}, this CLI speaks v${local} — update the CLI`,
|
|
1346
|
+
};
|
|
1347
|
+
}
|
|
1348
|
+
catch (err) {
|
|
1349
|
+
return {
|
|
1350
|
+
name: 'contract',
|
|
1351
|
+
ok: false,
|
|
1352
|
+
detail: `could not read the server capability document (${err instanceof ApiCallError ? err.code : String(err)})`,
|
|
1353
|
+
};
|
|
1354
|
+
}
|
|
1355
|
+
}
|
|
1356
|
+
/**
|
|
1357
|
+
* Read the whole setup once, in dependency order.
|
|
1358
|
+
*
|
|
1359
|
+
* Descent stops where a prerequisite is missing: without a credential there is
|
|
1360
|
+
* nothing to ask the server with, and without a reachable server a contract
|
|
1361
|
+
* mismatch is unknowable rather than absent. Those downstream states report
|
|
1362
|
+
* `unknown`, which is the honest answer and keeps a network outage from
|
|
1363
|
+
* looking like a broken install.
|
|
1364
|
+
*/
|
|
1365
|
+
export async function assessReadiness(deps) {
|
|
1366
|
+
const config = loadConfig({ cwd: deps.cwd, env: deps.env });
|
|
1367
|
+
const states = [];
|
|
1368
|
+
const configPath = path.join(deps.cwd, '.notifai', 'config.toml');
|
|
1369
|
+
const projectSlug = config.project.value;
|
|
1370
|
+
states.push(projectSlug !== null
|
|
1371
|
+
? {
|
|
1372
|
+
id: 'project',
|
|
1373
|
+
title: 'Project identity',
|
|
1374
|
+
status: 'ready',
|
|
1375
|
+
detail: `"${projectSlug}" (${config.project.source})`,
|
|
1376
|
+
}
|
|
1377
|
+
: {
|
|
1378
|
+
id: 'project',
|
|
1379
|
+
title: 'Project identity',
|
|
1380
|
+
// Not a blocker: a send without a project simply carries no project
|
|
1381
|
+
// identity. init always sets one because it is free and reversible,
|
|
1382
|
+
// but an unlabelled setup works, so this must not go red.
|
|
1383
|
+
status: 'optional-gap',
|
|
1384
|
+
detail: `not set in ${configPath} — sends from here carry no project identity`,
|
|
1385
|
+
remedy: {
|
|
1386
|
+
by: 'cli',
|
|
1387
|
+
summary: 'name this project after its directory',
|
|
1388
|
+
command: 'notifai init',
|
|
1389
|
+
},
|
|
1390
|
+
});
|
|
1391
|
+
const credential = deps.store.load();
|
|
1392
|
+
states.push(credential
|
|
1393
|
+
? {
|
|
1394
|
+
id: 'credential',
|
|
1395
|
+
title: 'This machine',
|
|
1396
|
+
status: 'ready',
|
|
1397
|
+
detail: `paired as "${credential.machineName}" (${deps.store.describe()})`,
|
|
1398
|
+
}
|
|
1399
|
+
: {
|
|
1400
|
+
id: 'credential',
|
|
1401
|
+
title: 'This machine',
|
|
1402
|
+
status: 'gap',
|
|
1403
|
+
detail: 'not paired with your account',
|
|
1404
|
+
remedy: {
|
|
1405
|
+
by: 'user-here',
|
|
1406
|
+
summary: 'sign in — this opens your browser to approve the machine',
|
|
1407
|
+
command: 'notifai login',
|
|
1408
|
+
interactive: true,
|
|
1409
|
+
},
|
|
1410
|
+
});
|
|
1411
|
+
const baseUrl = resolvedBaseUrl(config, credential);
|
|
1412
|
+
const anon = makeClient(deps, baseUrl, null);
|
|
1413
|
+
// A probe that throws is unreachable, not a crash: this runs against a
|
|
1414
|
+
// half-configured machine by definition, which is where a client that
|
|
1415
|
+
// cannot even be constructed properly shows up.
|
|
1416
|
+
let reachable = false;
|
|
1417
|
+
try {
|
|
1418
|
+
reachable = await anon.health();
|
|
1419
|
+
}
|
|
1420
|
+
catch {
|
|
1421
|
+
reachable = false;
|
|
1422
|
+
}
|
|
1423
|
+
states.push(reachable
|
|
1424
|
+
? { id: 'server', title: 'Service', status: 'ready', detail: `${baseUrl} reachable` }
|
|
1425
|
+
: {
|
|
1426
|
+
id: 'server',
|
|
1427
|
+
title: 'Service',
|
|
1428
|
+
status: 'gap',
|
|
1429
|
+
detail: `cannot reach ${baseUrl} (${config.base_url.source})`,
|
|
1430
|
+
remedy: {
|
|
1431
|
+
by: 'user-here',
|
|
1432
|
+
summary: 'check your network, or the base_url shown above',
|
|
1433
|
+
command: 'notifai doctor',
|
|
1434
|
+
},
|
|
1435
|
+
});
|
|
1436
|
+
if (!reachable) {
|
|
1437
|
+
states.push({
|
|
1438
|
+
id: 'contract',
|
|
1439
|
+
title: 'Protocol version',
|
|
1440
|
+
status: 'unknown',
|
|
1441
|
+
detail: 'not checked — the server is unreachable',
|
|
1442
|
+
});
|
|
1443
|
+
}
|
|
1444
|
+
else {
|
|
1445
|
+
const contract = await contractCheck(anon);
|
|
1446
|
+
states.push(contract.ok
|
|
1447
|
+
? { id: 'contract', title: 'Protocol version', status: 'ready', detail: contract.detail }
|
|
1448
|
+
: {
|
|
1449
|
+
id: 'contract',
|
|
1450
|
+
title: 'Protocol version',
|
|
1451
|
+
status: 'gap',
|
|
1452
|
+
detail: contract.detail,
|
|
1453
|
+
remedy: {
|
|
1454
|
+
by: 'user-here',
|
|
1455
|
+
summary: 'the CLI and server disagree; the detail above says which to move',
|
|
1456
|
+
command: 'notifai doctor',
|
|
1457
|
+
},
|
|
1458
|
+
});
|
|
1459
|
+
}
|
|
1460
|
+
if (!credential || !reachable) {
|
|
1461
|
+
const why = !credential ? 'this machine is not paired' : 'the server is unreachable';
|
|
1462
|
+
states.push({ id: 'auth', title: 'Account', status: 'unknown', detail: `not checked — ${why}` });
|
|
1463
|
+
states.push({ id: 'devices', title: 'Your devices', status: 'unknown', detail: `not checked — ${why}` });
|
|
1464
|
+
}
|
|
1465
|
+
else {
|
|
1466
|
+
const client = makeClient(deps, baseUrl, `Bearer nfm_${credential.machineId}.${credential.secret}`);
|
|
1467
|
+
try {
|
|
1468
|
+
const { devices } = await client.listDevices();
|
|
1469
|
+
const ready = devices.filter((d) => d.registration_healthy);
|
|
1470
|
+
states.push({
|
|
1471
|
+
id: 'auth',
|
|
1472
|
+
title: 'Account',
|
|
1473
|
+
status: 'ready',
|
|
1474
|
+
detail: `machine ${credential.machineId} accepted`,
|
|
1475
|
+
});
|
|
1476
|
+
states.push(ready.length > 0
|
|
1477
|
+
? {
|
|
1478
|
+
id: 'devices',
|
|
1479
|
+
title: 'Your devices',
|
|
1480
|
+
status: 'ready',
|
|
1481
|
+
detail: `${ready.map((d) => d.display_name).join(', ')} ready to receive`,
|
|
1482
|
+
}
|
|
1483
|
+
: {
|
|
1484
|
+
id: 'devices',
|
|
1485
|
+
title: 'Your devices',
|
|
1486
|
+
status: 'gap',
|
|
1487
|
+
// The one gap that cannot be closed from this terminal, and the
|
|
1488
|
+
// likeliest place a first setup is abandoned. Naming which of
|
|
1489
|
+
// the three sub-states it is matters: "install the app" is
|
|
1490
|
+
// useless advice to someone who installed it and denied the
|
|
1491
|
+
// permission prompt.
|
|
1492
|
+
detail: devices.length === 0
|
|
1493
|
+
? 'nothing registered yet'
|
|
1494
|
+
: `${devices.map((d) => `${d.display_name} (${d.permission_status})`).join(', ')} — registered but not able to receive`,
|
|
1495
|
+
remedy: {
|
|
1496
|
+
by: 'user-elsewhere',
|
|
1497
|
+
summary: devices.length === 0
|
|
1498
|
+
? 'install the companion app on your phone, sign in with the same account, and allow notifications'
|
|
1499
|
+
: 'allow notifications for NotifAI in that device’s system settings',
|
|
1500
|
+
},
|
|
1501
|
+
});
|
|
1502
|
+
}
|
|
1503
|
+
catch (err) {
|
|
1504
|
+
// A credential the server rejects is revocation, not absence, and the
|
|
1505
|
+
// remedy is the same sign-in either way.
|
|
1506
|
+
states.push({
|
|
1507
|
+
id: 'auth',
|
|
1508
|
+
title: 'Account',
|
|
1509
|
+
status: 'gap',
|
|
1510
|
+
detail: err instanceof ApiCallError ? `${err.code}: ${err.message}` : String(err),
|
|
1511
|
+
remedy: {
|
|
1512
|
+
by: 'user-here',
|
|
1513
|
+
summary: 'this machine is no longer recognised; pair it again',
|
|
1514
|
+
command: 'notifai login',
|
|
1515
|
+
},
|
|
1516
|
+
});
|
|
1517
|
+
states.push({ id: 'devices', title: 'Your devices', status: 'unknown', detail: 'not checked — sign-in failed' });
|
|
1518
|
+
}
|
|
1519
|
+
}
|
|
1520
|
+
states.push(...hookStates(deps));
|
|
1521
|
+
states.push(SKILLS_SOURCE === ''
|
|
1522
|
+
? {
|
|
1523
|
+
id: 'skill',
|
|
1524
|
+
title: 'Agent guidance skill',
|
|
1525
|
+
// Nothing outstanding, because nothing can be done: this build has
|
|
1526
|
+
// no published skill source configured. Silence here would read as
|
|
1527
|
+
// an omission to anyone who had seen it mentioned in the docs.
|
|
1528
|
+
status: 'ready',
|
|
1529
|
+
detail: 'not applicable — this build has no published skill source configured',
|
|
1530
|
+
}
|
|
1531
|
+
: {
|
|
1532
|
+
id: 'skill',
|
|
1533
|
+
title: 'Agent guidance skill',
|
|
1534
|
+
status: 'optional-gap',
|
|
1535
|
+
detail: 'not installed in this project',
|
|
1536
|
+
remedy: {
|
|
1537
|
+
by: 'cli',
|
|
1538
|
+
summary: 'install the skill agents follow when deciding to notify',
|
|
1539
|
+
command: 'notifai init --skills',
|
|
1540
|
+
},
|
|
1541
|
+
});
|
|
1542
|
+
return { states };
|
|
1543
|
+
}
|
|
1544
|
+
export async function doctorCommand(deps, flags) {
|
|
1545
|
+
const readiness = await assessReadiness(deps);
|
|
1546
|
+
const blocker = firstBlocker(readiness);
|
|
1547
|
+
const ok = blocker === null;
|
|
1548
|
+
if (flags.json) {
|
|
1549
|
+
deps.io.out(JSON.stringify({ ok, states: readiness.states }, null, 2));
|
|
1550
|
+
return ok ? EXIT.ok : EXIT.failed;
|
|
1551
|
+
}
|
|
1552
|
+
const line = (s) => `${s.title}: ${s.detail}`;
|
|
1553
|
+
// Doctor reports everything — that is the difference from init, which acts
|
|
1554
|
+
// on one thing. It still names where to start, because a list of five
|
|
1555
|
+
// problems in dependency order has an obvious first move and saying so
|
|
1556
|
+
// costs nothing.
|
|
1557
|
+
if (deps.io.interactive === true && deps.io.check) {
|
|
1558
|
+
await deps.io.intro?.('NotifAI doctor');
|
|
1559
|
+
for (const s of readiness.states)
|
|
1560
|
+
await deps.io.check(s.status !== 'gap', line(s));
|
|
1561
|
+
await deps.io.outro?.(ok ? 'Everything looks good' : `Start with: ${remedyLine(blocker)}`);
|
|
1562
|
+
}
|
|
1563
|
+
else {
|
|
1564
|
+
for (const s of readiness.states) {
|
|
1565
|
+
const mark = s.status === 'gap' ? 'FAIL' : s.status === 'unknown' ? ' ? ' : 'ok ';
|
|
1566
|
+
deps.io.out(`${mark} ${line(s)}`);
|
|
1567
|
+
}
|
|
1568
|
+
if (!ok)
|
|
1569
|
+
deps.io.out(`\nStart with: ${remedyLine(blocker)}`);
|
|
1570
|
+
}
|
|
1571
|
+
return ok ? EXIT.ok : EXIT.failed;
|
|
1572
|
+
}
|
|
1573
|
+
/** One line telling the reader what to actually do about a state. */
|
|
1574
|
+
function remedyLine(state) {
|
|
1575
|
+
const remedy = state.remedy;
|
|
1576
|
+
if (!remedy)
|
|
1577
|
+
return state.detail;
|
|
1578
|
+
if (remedy.by === 'user-elsewhere')
|
|
1579
|
+
return remedy.summary;
|
|
1580
|
+
return remedy.command === undefined
|
|
1581
|
+
? remedy.summary
|
|
1582
|
+
: `${remedy.summary} — run \`${remedy.command}\``;
|
|
1583
|
+
}
|
|
1584
|
+
/**
|
|
1585
|
+
* Whether the hook installation is internally ready, plus evidence that a
|
|
1586
|
+
* project session has fired it before. This cannot prove future execution or
|
|
1587
|
+
* end-to-end notification delivery without a live harness and device test.
|
|
1588
|
+
*
|
|
1589
|
+
* Every failure mode here was found the expensive way, by spawning a session
|
|
1590
|
+
* and watching nothing happen: hooks not installed, installed but never fired,
|
|
1591
|
+
* or left behind by an older build that named events this one does not serve.
|
|
1592
|
+
*/
|
|
1593
|
+
/**
|
|
1594
|
+
* Hook diagnostics as readiness states.
|
|
1595
|
+
*
|
|
1596
|
+
* A thin adapter over `hookChecks`, whose every branch was found the expensive
|
|
1597
|
+
* way and is not worth re-deriving. The judgment added here is which failures
|
|
1598
|
+
* actually stand in the way.
|
|
1599
|
+
*
|
|
1600
|
+
* Two do not. A hook that has never fired is the normal condition of an
|
|
1601
|
+
* install thirty seconds old, and OpenCode's inability to resume an idle turn
|
|
1602
|
+
* is a property of that harness rather than a fault in this setup. Treating
|
|
1603
|
+
* either as blocking would mean `init` could never finish for an OpenCode
|
|
1604
|
+
* user, or could only finish after a session had already run — so both report
|
|
1605
|
+
* as things worth knowing rather than things to fix.
|
|
1606
|
+
*/
|
|
1607
|
+
function hookStates(deps) {
|
|
1608
|
+
const installations = findInstallations(deps.cwd, deps.env);
|
|
1609
|
+
if (installations.length === 0) {
|
|
1610
|
+
return [
|
|
1611
|
+
{
|
|
1612
|
+
id: 'hooks',
|
|
1613
|
+
title: 'Question routing',
|
|
1614
|
+
status: 'optional-gap',
|
|
1615
|
+
detail: 'hooks not installed, so questions stay in the terminal',
|
|
1616
|
+
remedy: {
|
|
1617
|
+
by: 'cli',
|
|
1618
|
+
summary: 'install harness hooks so questions reach your devices when you are away',
|
|
1619
|
+
command: 'notifai hooks install',
|
|
1620
|
+
},
|
|
1621
|
+
},
|
|
1622
|
+
];
|
|
1623
|
+
}
|
|
1624
|
+
/** Real but not in the way; see the note above. */
|
|
1625
|
+
const informational = new Set(['hooks (fired)', 'hooks (opencode continuation)']);
|
|
1626
|
+
return hookChecks(deps).map((check) => ({
|
|
1627
|
+
id: check.name.replace(/[ ()]+/g, '-').replace(/-$/, ''),
|
|
1628
|
+
title: check.name === 'hooks' ? 'Question routing' : check.name,
|
|
1629
|
+
status: check.ok ? 'ready' : informational.has(check.name) ? 'optional-gap' : 'gap',
|
|
1630
|
+
detail: check.detail,
|
|
1631
|
+
...(check.ok
|
|
1632
|
+
? {}
|
|
1633
|
+
: {
|
|
1634
|
+
remedy: {
|
|
1635
|
+
by: 'user-here',
|
|
1636
|
+
summary: 'the detail above names what to change',
|
|
1637
|
+
command: 'notifai hooks install',
|
|
1638
|
+
},
|
|
1639
|
+
}),
|
|
1640
|
+
}));
|
|
1641
|
+
}
|
|
1642
|
+
function hookChecks(deps) {
|
|
1643
|
+
const checks = [];
|
|
1644
|
+
const installations = findInstallations(deps.cwd, deps.env);
|
|
1645
|
+
// Not having hooks is a setup someone chose, not a fault: `send` works
|
|
1646
|
+
// without them. A setup that cannot work is what deserves to go red.
|
|
1647
|
+
if (installations.length === 0) {
|
|
1648
|
+
checks.push({
|
|
1649
|
+
name: 'hooks',
|
|
1650
|
+
ok: true,
|
|
1651
|
+
detail: 'not installed (optional) — `notifai hooks install` adds question routing',
|
|
1652
|
+
});
|
|
1653
|
+
return checks;
|
|
1654
|
+
}
|
|
1655
|
+
checks.push({
|
|
1656
|
+
name: 'hooks',
|
|
1657
|
+
ok: true,
|
|
1658
|
+
detail: installations
|
|
1659
|
+
.map((i) => `${i.harness} ${i.global ? 'global' : 'project'} (${i.file})`)
|
|
1660
|
+
.join(', '),
|
|
1661
|
+
});
|
|
1662
|
+
// A handler naming an event this build dropped exits 2 every time the harness
|
|
1663
|
+
// fires it, which the harness reports as a hook failure.
|
|
1664
|
+
const stale = installations.flatMap((i) => i.handlers
|
|
1665
|
+
.filter((h) => {
|
|
1666
|
+
const event = handlerEvent(h.command);
|
|
1667
|
+
return event !== null && !HOOK_EVENTS.includes(event);
|
|
1668
|
+
})
|
|
1669
|
+
.map((h) => `${h.event} -> ${handlerEvent(h.command)} in ${i.file}`));
|
|
1670
|
+
checks.push({
|
|
1671
|
+
name: 'hooks (stale)',
|
|
1672
|
+
ok: stale.length === 0,
|
|
1673
|
+
detail: stale.length === 0
|
|
1674
|
+
? 'every installed handler names an event this build serves'
|
|
1675
|
+
: `${stale.join('; ')} — rerun \`notifai hooks install\` to drop ${stale.length === 1 ? 'it' : 'them'}`,
|
|
1676
|
+
});
|
|
1677
|
+
const adapterProblems = installations.flatMap((installation) => (installation.problems ?? []).map((problem) => `${installation.file}: ${problem}`));
|
|
1678
|
+
if (adapterProblems.length > 0) {
|
|
1679
|
+
checks.push({
|
|
1680
|
+
name: 'hooks (adapter)',
|
|
1681
|
+
ok: false,
|
|
1682
|
+
detail: adapterProblems.join('; '),
|
|
1683
|
+
});
|
|
1684
|
+
}
|
|
1685
|
+
// Two checkouts each installing hooks means both fire for the same event, and
|
|
1686
|
+
// the user gets every question twice.
|
|
1687
|
+
//
|
|
1688
|
+
// Compared *within* a harness, not across. Only one harness runs a given
|
|
1689
|
+
// session, so having Claude Code and OpenCode both set up is the ordinary
|
|
1690
|
+
// case and not a duplicate — comparing them turned a healthy machine red.
|
|
1691
|
+
const duplicated = [...new Set(installations.map((i) => i.harness))]
|
|
1692
|
+
.map((harness) => ({
|
|
1693
|
+
harness,
|
|
1694
|
+
scripts: new Set(installations
|
|
1695
|
+
.filter((i) => i.harness === harness)
|
|
1696
|
+
.flatMap((i) => i.handlers.map((h) => h.command.split(' hook ')[0] ?? ''))),
|
|
1697
|
+
}))
|
|
1698
|
+
.filter((entry) => entry.scripts.size > 1);
|
|
1699
|
+
if (duplicated.length > 0) {
|
|
1700
|
+
checks.push({
|
|
1701
|
+
name: 'hooks (duplicates)',
|
|
1702
|
+
ok: false,
|
|
1703
|
+
detail: duplicated
|
|
1704
|
+
.map((entry) => `${entry.harness}: ${entry.scripts.size} different NotifAI builds are installed, so each event will fire all of them. Uninstall the ones you do not want: ${[...entry.scripts].join(', ')}`)
|
|
1705
|
+
.join('; '),
|
|
1706
|
+
});
|
|
1707
|
+
}
|
|
1708
|
+
const fired = readProjectSession(deps.cwd, deps.env, (deps.now ?? Date.now)()) !== null;
|
|
1709
|
+
checks.push({
|
|
1710
|
+
name: 'hooks (fired)',
|
|
1711
|
+
ok: fired,
|
|
1712
|
+
detail: fired
|
|
1713
|
+
? 'a session in this directory has run them'
|
|
1714
|
+
: `no session pointer from the last 24 hours — ${hookActivationAdvice(installations)}`,
|
|
1715
|
+
});
|
|
1716
|
+
if (installations.some((installation) => installation.harness === 'opencode')) {
|
|
1717
|
+
checks.push({
|
|
1718
|
+
name: 'hooks (opencode continuation)',
|
|
1719
|
+
ok: false,
|
|
1720
|
+
detail: 'question routing is installed, but OpenCode cannot reliably resume an idle agent turn with the answer; use `notifai send --reply` for decisions',
|
|
1721
|
+
});
|
|
1722
|
+
}
|
|
1723
|
+
const stray = codexStrayWorktreeCheck(deps);
|
|
1724
|
+
if (stray !== null)
|
|
1725
|
+
checks.push(stray);
|
|
1726
|
+
return checks;
|
|
1727
|
+
}
|
|
1728
|
+
/**
|
|
1729
|
+
* A Codex hooks file sitting in a worktree, which Codex will never read.
|
|
1730
|
+
*
|
|
1731
|
+
* `settingsFile` now writes to the main repository, so this only fires for a
|
|
1732
|
+
* file an older build left behind — but that file is indistinguishable from a
|
|
1733
|
+
* working install if you go looking, and it is exactly what made this bug take
|
|
1734
|
+
* a day to find. Omitted entirely when there is nothing to say.
|
|
1735
|
+
*/
|
|
1736
|
+
function codexStrayWorktreeCheck(deps) {
|
|
1737
|
+
const layer = codexLayerDir(deps.cwd);
|
|
1738
|
+
if (layer === null)
|
|
1739
|
+
return null;
|
|
1740
|
+
const root = codexProjectRoot(deps.cwd);
|
|
1741
|
+
const stray = path.join(path.dirname(layer), '.codex', 'hooks.json');
|
|
1742
|
+
if (!existsSync(path.join(root, '.codex', 'hooks.json')))
|
|
1743
|
+
return null;
|
|
1744
|
+
const problems = [];
|
|
1745
|
+
if (!existsSync(layer)) {
|
|
1746
|
+
problems.push(`${layer} is missing, so Codex never looks for project hooks here`);
|
|
1747
|
+
}
|
|
1748
|
+
if (existsSync(stray)) {
|
|
1749
|
+
problems.push(`${stray} is never read — Codex reads ${root}/.codex/hooks.json instead`);
|
|
1750
|
+
}
|
|
1751
|
+
return {
|
|
1752
|
+
name: 'hooks (codex worktree)',
|
|
1753
|
+
ok: problems.length === 0,
|
|
1754
|
+
detail: problems.length === 0
|
|
1755
|
+
? `worktree wired to the main repository at ${root}`
|
|
1756
|
+
: `${problems.join('; ')}. Re-run \`notifai hooks install\` to fix.`,
|
|
1757
|
+
};
|
|
1758
|
+
}
|
|
1759
|
+
// ---------------------------------------------------------------------------
|
|
1760
|
+
// production IO
|
|
1761
|
+
// ---------------------------------------------------------------------------
|
|
1762
|
+
/**
|
|
1763
|
+
* Whether a human is driving this terminal.
|
|
1764
|
+
*
|
|
1765
|
+
* A TTY alone is NOT that evidence: agent harnesses frequently allocate a PTY
|
|
1766
|
+
* for the commands they run, and a prompt shown to an agent does not fail — it
|
|
1767
|
+
* hangs, because every prompt library waits on stdin rather than erroring. So
|
|
1768
|
+
* this also honours `CI` and an explicit `NOTIFAI_NO_INPUT=1` escape hatch,
|
|
1769
|
+
* and every interactive affordance stays strictly optional: anything `init`
|
|
1770
|
+
* can ask, a flag can answer.
|
|
1771
|
+
*/
|
|
1772
|
+
function isHumanTerminal(env) {
|
|
1773
|
+
return (process.stdin.isTTY === true &&
|
|
1774
|
+
process.stdout.isTTY === true &&
|
|
1775
|
+
(env['CI'] ?? '') === '' &&
|
|
1776
|
+
(env['NOTIFAI_NO_INPUT'] ?? '') === '');
|
|
1777
|
+
}
|
|
1778
|
+
/**
|
|
1779
|
+
* Lazy on purpose: the hook path runs in front of every prompt the user types,
|
|
1780
|
+
* and must not pay for a prompt library it will never show.
|
|
1781
|
+
*/
|
|
1782
|
+
async function clack() {
|
|
1783
|
+
return await import('@clack/prompts');
|
|
1784
|
+
}
|
|
1785
|
+
export function realIo(env = process.env) {
|
|
1786
|
+
const interactive = () => isHumanTerminal(env);
|
|
1787
|
+
return {
|
|
1788
|
+
out: (line) => console.log(line),
|
|
1789
|
+
err: (line) => console.error(line),
|
|
1790
|
+
get interactive() {
|
|
1791
|
+
return interactive();
|
|
1792
|
+
},
|
|
1793
|
+
confirm: async (question, fallback = false) => {
|
|
1794
|
+
if (!interactive())
|
|
1795
|
+
return fallback;
|
|
1796
|
+
const p = await clack();
|
|
1797
|
+
const answer = await p.confirm({ message: question, initialValue: fallback });
|
|
1798
|
+
// Ctrl-C mid-prompt arrives as a cancel symbol, not a SIGINT; treat it
|
|
1799
|
+
// as the safe answer rather than letting a Symbol escape into logic.
|
|
1800
|
+
return p.isCancel(answer) ? false : answer;
|
|
1801
|
+
},
|
|
1802
|
+
select: async (message, options) => {
|
|
1803
|
+
if (!interactive())
|
|
1804
|
+
return null;
|
|
1805
|
+
const p = await clack();
|
|
1806
|
+
const answer = await p.select({ message, options });
|
|
1807
|
+
return p.isCancel(answer) ? null : answer;
|
|
1808
|
+
},
|
|
1809
|
+
intro: async (title) => {
|
|
1810
|
+
if (!interactive())
|
|
1811
|
+
return;
|
|
1812
|
+
(await clack()).intro(title);
|
|
1813
|
+
},
|
|
1814
|
+
outro: async (message) => {
|
|
1815
|
+
if (!interactive())
|
|
1816
|
+
return;
|
|
1817
|
+
(await clack()).outro(message);
|
|
1818
|
+
},
|
|
1819
|
+
note: async (message, title) => {
|
|
1820
|
+
if (!interactive())
|
|
1821
|
+
return;
|
|
1822
|
+
(await clack()).note(message, title);
|
|
1823
|
+
},
|
|
1824
|
+
spinner: async (message) => {
|
|
1825
|
+
if (!interactive())
|
|
1826
|
+
return null;
|
|
1827
|
+
const progress = (await clack()).spinner();
|
|
1828
|
+
progress.start(message);
|
|
1829
|
+
return {
|
|
1830
|
+
message: (next) => progress.message(next),
|
|
1831
|
+
stop: (next) => progress.stop(next),
|
|
1832
|
+
error: (next) => progress.error(next),
|
|
1833
|
+
};
|
|
1834
|
+
},
|
|
1835
|
+
check: async (ok, message) => {
|
|
1836
|
+
if (!interactive())
|
|
1837
|
+
return;
|
|
1838
|
+
const { log } = await clack();
|
|
1839
|
+
if (ok)
|
|
1840
|
+
log.success(message);
|
|
1841
|
+
else
|
|
1842
|
+
log.error(message);
|
|
1843
|
+
},
|
|
1844
|
+
openUrl: (url) => {
|
|
1845
|
+
const command = process.platform === 'darwin' ? 'open' : process.platform === 'win32' ? 'start' : 'xdg-open';
|
|
1846
|
+
try {
|
|
1847
|
+
spawn(command, [url], { stdio: 'ignore', detached: true }).unref();
|
|
1848
|
+
}
|
|
1849
|
+
catch {
|
|
1850
|
+
// Browser opening is best-effort; the URL is printed anyway.
|
|
1851
|
+
}
|
|
1852
|
+
},
|
|
1853
|
+
};
|
|
1854
|
+
}
|
|
1855
|
+
//# sourceMappingURL=commands.js.map
|