@raidiant/notifai 5.0.0 → 7.0.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/CHANGELOG.md +51 -0
- package/README.md +2 -2
- package/dist/atomic-file.js +13 -0
- package/dist/atomic-file.js.map +1 -1
- package/dist/client.d.ts +8 -3
- package/dist/client.js +24 -3
- package/dist/client.js.map +1 -1
- package/dist/commands-auth.d.ts +14 -0
- package/dist/commands-auth.js +154 -0
- package/dist/commands-auth.js.map +1 -0
- package/dist/commands-config.d.ts +31 -0
- package/dist/commands-config.js +246 -0
- package/dist/commands-config.js.map +1 -0
- package/dist/commands-core.d.ts +102 -0
- package/dist/commands-core.js +135 -0
- package/dist/commands-core.js.map +1 -0
- package/dist/commands-devices.d.ts +31 -0
- package/dist/commands-devices.js +124 -0
- package/dist/commands-devices.js.map +1 -0
- package/dist/commands-doctor.d.ts +24 -0
- package/dist/commands-doctor.js +1011 -0
- package/dist/commands-doctor.js.map +1 -0
- package/dist/commands-harness-context.d.ts +65 -0
- package/dist/commands-harness-context.js +97 -0
- package/dist/commands-harness-context.js.map +1 -0
- package/dist/commands-hook-shape.d.ts +17 -0
- package/dist/commands-hook-shape.js +37 -0
- package/dist/commands-hook-shape.js.map +1 -0
- package/dist/commands-hooks.d.ts +115 -0
- package/dist/commands-hooks.js +1225 -0
- package/dist/commands-hooks.js.map +1 -0
- package/dist/commands-init.d.ts +35 -0
- package/dist/commands-init.js +580 -0
- package/dist/commands-init.js.map +1 -0
- package/dist/commands-io.d.ts +2 -0
- package/dist/commands-io.js +110 -0
- package/dist/commands-io.js.map +1 -0
- package/dist/commands-logs.d.ts +30 -0
- package/dist/commands-logs.js +201 -0
- package/dist/commands-logs.js.map +1 -0
- package/dist/commands-send-support.d.ts +44 -0
- package/dist/commands-send-support.js +183 -0
- package/dist/commands-send-support.js.map +1 -0
- package/dist/commands-send.d.ts +33 -0
- package/dist/commands-send.js +519 -0
- package/dist/commands-send.js.map +1 -0
- package/dist/commands-setup-proof.d.ts +15 -0
- package/dist/commands-setup-proof.js +53 -0
- package/dist/commands-setup-proof.js.map +1 -0
- package/dist/commands-skill.d.ts +10 -0
- package/dist/commands-skill.js +71 -0
- package/dist/commands-skill.js.map +1 -0
- package/dist/commands.d.ts +12 -387
- package/dist/commands.js +12 -4536
- package/dist/commands.js.map +1 -1
- package/dist/config-schema.js +1 -1
- package/dist/config-schema.js.map +1 -1
- package/dist/config.d.ts +4 -4
- package/dist/config.js +6 -4
- package/dist/config.js.map +1 -1
- package/dist/credentials.js +13 -3
- package/dist/credentials.js.map +1 -1
- package/dist/generated-session-label.d.ts +2 -0
- package/dist/generated-session-label.js +152 -0
- package/dist/generated-session-label.js.map +1 -0
- package/dist/hooks.d.ts +2 -2
- package/dist/hooks.js +127 -99
- package/dist/hooks.js.map +1 -1
- package/dist/interactive.js +4 -4
- package/dist/interactive.js.map +1 -1
- package/dist/invocation-context.d.ts +3 -0
- package/dist/invocation-context.js +23 -6
- package/dist/invocation-context.js.map +1 -1
- package/dist/logging.js +1 -0
- package/dist/logging.js.map +1 -1
- package/dist/main.js +3 -3
- package/dist/main.js.map +1 -1
- package/dist/opencode-plugin.js +18 -3
- package/dist/opencode-plugin.js.map +1 -1
- package/dist/readiness.d.ts +3 -1
- package/dist/readiness.js.map +1 -1
- package/dist/session-labels.d.ts +30 -0
- package/dist/session-labels.js +278 -0
- package/dist/session-labels.js.map +1 -0
- package/package.json +2 -2
- package/src/atomic-file.ts +12 -0
- package/src/client.test.ts +90 -1
- package/src/client.ts +35 -2
- package/src/commands-auth.ts +174 -0
- package/src/commands-config.ts +307 -0
- package/src/commands-core.ts +240 -0
- package/src/commands-devices.ts +150 -0
- package/src/commands-doctor.ts +1209 -0
- package/src/commands-harness-context.ts +134 -0
- package/src/commands-hook-shape.ts +53 -0
- package/src/commands-hooks.ts +1586 -0
- package/src/commands-init.ts +721 -0
- package/src/commands-io.ts +108 -0
- package/src/commands-logs.ts +243 -0
- package/src/commands-send-support.ts +227 -0
- package/src/commands-send.ts +623 -0
- package/src/commands-setup-proof.ts +71 -0
- package/src/commands-skill.ts +84 -0
- package/src/commands.test.ts +567 -42
- package/src/commands.ts +58 -5614
- package/src/config-schema.ts +1 -1
- package/src/config.test.ts +9 -0
- package/src/config.ts +10 -5
- package/src/credentials.test.ts +25 -1
- package/src/credentials.ts +13 -3
- package/src/generated-session-label.ts +155 -0
- package/src/hooks.test.ts +355 -16
- package/src/hooks.ts +145 -97
- package/src/install-hooks.test.ts +84 -1
- package/src/interactive.test.ts +19 -1
- package/src/interactive.ts +4 -4
- package/src/invocation-context.test.ts +115 -16
- package/src/invocation-context.ts +32 -11
- package/src/logging.test.ts +15 -2
- package/src/logging.ts +1 -0
- package/src/main.ts +9 -3
- package/src/opencode-plugin.ts +18 -3
- package/src/readiness.ts +3 -1
- package/src/session-labels.test.ts +303 -0
- package/src/session-labels.ts +360 -0
- package/src/skill.test.ts +13 -0
package/dist/commands.js
CHANGED
|
@@ -1,4537 +1,13 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
import { withTargetFileLock } from './file-lock.js';
|
|
14
|
-
import { renderConfigExplain, renderConfigList, renderConfigPlain, } from './ui/config-view.js';
|
|
15
|
-
import { firstBlocker, } from './readiness.js';
|
|
16
|
-
import { waiterCeilingSeconds, clearAcknowledgementObligation, handleSessionEnd, handleStop, handleUserPromptSubmit, parseHookInput, pruneAbandonedSessions, readLiveProjectSessionPointers, readMatchingProjectSessionPointer, readProjectSession, readProjectSessionPointer, readSessionState, registerQuestion, MIN_REPLY_WINDOW_SECONDS, } from './hooks.js';
|
|
17
|
-
import { LOG_EVENTS, activeLogPath, archiveLogPaths, logConfigResolved, logSettingsFrom, logsDiskUsage, nullLogger, readLogRecords, renderRecord, RECORD_LEVELS, } from './logging.js';
|
|
18
|
-
import { BLOCKING_STOP_TIMEOUT_SECONDS, stopHandlerIsDetached, CLAUDE_ASYNC_STOP_TIMEOUT_SECONDS, HARNESSES, applyPlan, buildCursorHookConfig, buildHookConfig, codexLayerDir, codexLayerPaths, codexCoexistenceNotes, codexHomeNote, codexRepresentationProblems, codexTrustProblems, codexProjectRoot, detectHarness, detectedHarnesses, findInstallations, handlerEvent, hookDefinitionFiles, inspectCodexLayer, loadCursorSettings, loadSettings, mergeCursorHooks, mergeHooks, removeCursorHooks, removeHooks, settingsFile, withCodexLayerTransaction, } from './install-hooks.js';
|
|
19
|
-
import { HARNESS_CAPABILITIES, HARNESS_LABELS } from './harnesses.js';
|
|
20
|
-
import { claudeWakeRoute, inspectClaudeInbox, systemClaudeWakeAdapters, } from './claude-wake.js';
|
|
21
|
-
import { codexWakeRoute, inspectCodexResume, } from './codex-wake.js';
|
|
22
|
-
import { inspectHookAdapter, installHookAdapter, isNpxAdapterTarget, } from './hook-adapter.js';
|
|
23
|
-
import { isOurOpencodePlugin, opencodePluginSource, } from './opencode-plugin.js';
|
|
24
|
-
import { CHOICE_USAGE, buildDraft, formatReceipt, parseChoices, receiptExitCode, slugify, validateMediaInputs, } from './send.js';
|
|
25
|
-
import { buildSourceContext, inferInvocationContext, projectSlugFrom as inferredProjectSlugFrom, } from './invocation-context.js';
|
|
26
|
-
import { openUrl } from './platform.js';
|
|
27
|
-
/** The invocation's logger, or one that discards everything. */
|
|
28
|
-
export function log(deps) {
|
|
29
|
-
return deps.logger ?? nullLogger();
|
|
30
|
-
}
|
|
31
|
-
function loadLoggedConfig(deps, options) {
|
|
32
|
-
const config = loadConfig(options);
|
|
33
|
-
const logger = log(deps);
|
|
34
|
-
logger.adopt(logSettingsFrom(config));
|
|
35
|
-
logger.bind({ project: config.project.value });
|
|
36
|
-
logConfigResolved(logger, config);
|
|
37
|
-
return config;
|
|
38
|
-
}
|
|
39
|
-
export const EXIT = {
|
|
40
|
-
ok: 0,
|
|
41
|
-
failed: 1,
|
|
42
|
-
usage: 2,
|
|
43
|
-
noReply: 3,
|
|
44
|
-
auth: 4,
|
|
45
|
-
network: 5,
|
|
46
|
-
};
|
|
47
|
-
function makeClient(deps, baseUrl, bearer, options) {
|
|
48
|
-
return (deps.clientFactory ?? createClient)(baseUrl, bearer, { ...options, logger: log(deps) });
|
|
49
|
-
}
|
|
50
|
-
function resolvedBaseUrl(config, credential) {
|
|
51
|
-
return config.base_url.source === 'default' && credential ? credential.baseUrl : config.base_url.value;
|
|
52
|
-
}
|
|
53
|
-
function authedClient(deps, config) {
|
|
54
|
-
const credential = deps.store.load();
|
|
55
|
-
if (!credential) {
|
|
56
|
-
// The commonest reason a command does nothing, and one that leaves no other
|
|
57
|
-
// trace: it returns before any request is made, so without this the log
|
|
58
|
-
// shows an exit code and no cause.
|
|
59
|
-
log(deps).error('cli.error', { kind: 'auth', message: 'not signed in', store: deps.store.describe() });
|
|
60
|
-
deps.io.err('Not signed in. Run `notifai login` first.');
|
|
61
|
-
return null;
|
|
62
|
-
}
|
|
63
|
-
const baseUrl = resolvedBaseUrl(config, credential);
|
|
64
|
-
return {
|
|
65
|
-
client: makeClient(deps, baseUrl, `Bearer nfm_${credential.machineId}.${credential.secret}`),
|
|
66
|
-
baseUrl,
|
|
67
|
-
};
|
|
68
|
-
}
|
|
69
|
-
function reportError(deps, err, context = {}) {
|
|
70
|
-
// Recorded before it is printed: stderr from an agent's shell call is often
|
|
71
|
-
// the one thing that does not survive into the next turn, and this is exactly
|
|
72
|
-
// the line someone comes back looking for.
|
|
73
|
-
log(deps).error('cli.error', {
|
|
74
|
-
...context,
|
|
75
|
-
...(err instanceof ApiCallError
|
|
76
|
-
? { kind: 'api', status: err.status, code: err.code, message: err.message, next_action: err.nextAction, details: err.details }
|
|
77
|
-
: err instanceof NetworkError
|
|
78
|
-
? { kind: 'network', message: err.message }
|
|
79
|
-
: { kind: 'unknown', message: String(err) }),
|
|
80
|
-
});
|
|
81
|
-
if (err instanceof ApiCallError) {
|
|
82
|
-
deps.io.err(`${err.code}: ${err.message}`);
|
|
83
|
-
// The server's field-level details were already being logged above, but
|
|
84
|
-
// stderr is the one surface an agent's next turn actually reads — so the
|
|
85
|
-
// diagnosis has to be on it, the same way the hook path prints it.
|
|
86
|
-
const paths = rejectedPaths(err.details);
|
|
87
|
-
if (paths.length > 0)
|
|
88
|
-
deps.io.err(`the server rejected: ${paths.join(', ')}`);
|
|
89
|
-
// A 422 on a request this CLI built is not a user error: the two sides
|
|
90
|
-
// disagree about the contract, in whichever direction. `doctor` says which.
|
|
91
|
-
// Codes that name a policy the account chose are the exception — the
|
|
92
|
-
// contract held, the body simply did not satisfy that account.
|
|
93
|
-
if (err.status === 422 && err.code !== 'acknowledgement_text_required') {
|
|
94
|
-
deps.io.err('this build sent a field the server did not accept — the CLI and server ' +
|
|
95
|
-
'disagree about the contract; check with `notifai doctor`');
|
|
96
|
-
}
|
|
97
|
-
if (err.nextAction)
|
|
98
|
-
deps.io.err(`next: ${err.nextAction}`);
|
|
99
|
-
if (err.code === 'auth_required' || err.code === 'machine_revoked')
|
|
100
|
-
return EXIT.auth;
|
|
101
|
-
return err.status >= 500 ? EXIT.network : EXIT.failed;
|
|
102
|
-
}
|
|
103
|
-
if (err instanceof NetworkError) {
|
|
104
|
-
deps.io.err(err.message);
|
|
105
|
-
return EXIT.network;
|
|
106
|
-
}
|
|
107
|
-
deps.io.err(String(err));
|
|
108
|
-
return EXIT.failed;
|
|
109
|
-
}
|
|
110
|
-
// ---------------------------------------------------------------------------
|
|
111
|
-
// login / logout / auth status
|
|
112
|
-
// ---------------------------------------------------------------------------
|
|
113
|
-
export async function loginCommand(deps, flags) {
|
|
114
|
-
const config = loadLoggedConfig(deps, { cwd: deps.cwd, env: deps.env, flags: { base_url: flags.baseUrl } });
|
|
115
|
-
const baseUrl = config.base_url.value;
|
|
116
|
-
const machineName = flags.name ?? os.hostname();
|
|
117
|
-
const secret = randomBytes(32).toString('base64url');
|
|
118
|
-
const pollVerifier = randomBytes(24).toString('base64url');
|
|
119
|
-
const client = makeClient(deps, baseUrl, null);
|
|
120
|
-
let begin;
|
|
121
|
-
try {
|
|
122
|
-
begin = await client.beginPairing({
|
|
123
|
-
machine_name: machineName,
|
|
124
|
-
credential_hash: sha256Hex(secret),
|
|
125
|
-
poll_verifier_hash: sha256Hex(pollVerifier),
|
|
126
|
-
});
|
|
127
|
-
}
|
|
128
|
-
catch (err) {
|
|
129
|
-
return reportError(deps, err);
|
|
130
|
-
}
|
|
131
|
-
const interactive = deps.io.interactive === true;
|
|
132
|
-
if (interactive) {
|
|
133
|
-
await deps.io.intro?.('Notifai sign in');
|
|
134
|
-
await deps.io.note?.(`Code: ${begin.code}\n${begin.approve_url}`, 'Approve this machine');
|
|
135
|
-
}
|
|
136
|
-
else {
|
|
137
|
-
deps.io.out(`Pairing code: ${begin.code}`);
|
|
138
|
-
deps.io.out(`Approve this machine at: ${begin.approve_url}`);
|
|
139
|
-
deps.io.out('Waiting for approval…');
|
|
140
|
-
}
|
|
141
|
-
if (flags.open !== false)
|
|
142
|
-
deps.io.openUrl(begin.approve_url);
|
|
143
|
-
const expiresAt = new Date(begin.expires_at).getTime();
|
|
144
|
-
const intervalMs = Math.max(begin.poll_interval_seconds, 1) * 1000;
|
|
145
|
-
const now = deps.now ?? Date.now;
|
|
146
|
-
const sleep = deps.sleep ?? ((milliseconds) => new Promise((resolve) => setTimeout(resolve, milliseconds)));
|
|
147
|
-
const approvalWaitMessage = () => {
|
|
148
|
-
const remainingSec = Math.max(0, Math.ceil((expiresAt - now()) / 1000));
|
|
149
|
-
const minutes = Math.floor(remainingSec / 60);
|
|
150
|
-
const seconds = remainingSec % 60;
|
|
151
|
-
const remaining = minutes > 0 ? `${minutes}m ${seconds.toString().padStart(2, '0')}s` : `${seconds}s`;
|
|
152
|
-
return `Waiting for approval… code ${begin.code} · ${remaining} left`;
|
|
153
|
-
};
|
|
154
|
-
const spinner = interactive ? await deps.io.spinner?.(approvalWaitMessage()) : null;
|
|
155
|
-
while (now() < expiresAt) {
|
|
156
|
-
await sleep(intervalMs);
|
|
157
|
-
let poll;
|
|
158
|
-
try {
|
|
159
|
-
poll = await client.pollPairing(begin.pairing_id, pollVerifier);
|
|
160
|
-
}
|
|
161
|
-
catch (err) {
|
|
162
|
-
if (err instanceof NetworkError) {
|
|
163
|
-
spinner?.message(`Connection lost — retrying… code ${begin.code}`);
|
|
164
|
-
continue;
|
|
165
|
-
}
|
|
166
|
-
spinner?.error('Pairing failed');
|
|
167
|
-
return reportError(deps, err);
|
|
168
|
-
}
|
|
169
|
-
if (poll.status === 'approved' && poll.machine_id) {
|
|
170
|
-
deps.store.save({ machineId: poll.machine_id, secret, baseUrl, machineName });
|
|
171
|
-
if (interactive) {
|
|
172
|
-
spinner?.stop(`Machine "${machineName}" approved`);
|
|
173
|
-
await deps.io.outro?.(`Credential stored in ${deps.store.describe()}`);
|
|
174
|
-
}
|
|
175
|
-
else {
|
|
176
|
-
deps.io.out(`Machine "${machineName}" approved. Credential stored in ${deps.store.describe()}.`);
|
|
177
|
-
}
|
|
178
|
-
return EXIT.ok;
|
|
179
|
-
}
|
|
180
|
-
if (poll.status === 'denied') {
|
|
181
|
-
spinner?.error('Pairing denied');
|
|
182
|
-
deps.io.err('Pairing was denied from the dashboard.');
|
|
183
|
-
return EXIT.auth;
|
|
184
|
-
}
|
|
185
|
-
if (poll.status === 'no_active_plan') {
|
|
186
|
-
const next = poll.next_action ??
|
|
187
|
-
`Open ${baseUrl.replace(/\/$/, '')}/support to request Alpha access, then retry.`;
|
|
188
|
-
spinner?.error('Account has no Alpha access');
|
|
189
|
-
deps.io.err('This account has no active plan or temporary Alpha access.');
|
|
190
|
-
deps.io.err(`next: ${next}`);
|
|
191
|
-
deps.io.err('After access is granted, run `notifai login` again.');
|
|
192
|
-
return EXIT.auth;
|
|
193
|
-
}
|
|
194
|
-
if (poll.status === 'expired')
|
|
195
|
-
break;
|
|
196
|
-
spinner?.message(approvalWaitMessage());
|
|
197
|
-
}
|
|
198
|
-
spinner?.error('Pairing expired');
|
|
199
|
-
deps.io.err('Pairing expired before it was approved. Run `notifai login` again.');
|
|
200
|
-
return EXIT.auth;
|
|
201
|
-
}
|
|
202
|
-
export function logoutCommand(deps) {
|
|
203
|
-
deps.store.clear();
|
|
204
|
-
deps.io.out('Machine credential removed. Revoke it in the dashboard too if the machine is untrusted.');
|
|
205
|
-
return EXIT.ok;
|
|
206
|
-
}
|
|
207
|
-
export function authStatusCommand(deps, flags) {
|
|
208
|
-
const credential = deps.store.load();
|
|
209
|
-
if (flags.json) {
|
|
210
|
-
deps.io.out(JSON.stringify(credential
|
|
211
|
-
? {
|
|
212
|
-
signed_in: true,
|
|
213
|
-
machine_id: credential.machineId,
|
|
214
|
-
machine_name: credential.machineName,
|
|
215
|
-
base_url: credential.baseUrl,
|
|
216
|
-
store: deps.store.describe(),
|
|
217
|
-
}
|
|
218
|
-
: { signed_in: false }, null, 2));
|
|
219
|
-
return credential ? EXIT.ok : EXIT.auth;
|
|
220
|
-
}
|
|
221
|
-
if (!credential) {
|
|
222
|
-
deps.io.err('Not signed in. Run `notifai login`.');
|
|
223
|
-
return EXIT.auth;
|
|
224
|
-
}
|
|
225
|
-
deps.io.out(`Signed in as machine "${credential.machineName}" (${credential.machineId})`);
|
|
226
|
-
deps.io.out(`Server: ${credential.baseUrl}`);
|
|
227
|
-
deps.io.out(`Credential store: ${deps.store.describe()}`);
|
|
228
|
-
return EXIT.ok;
|
|
229
|
-
}
|
|
230
|
-
/** Show the server's account access decision without attempting a product mutation. */
|
|
231
|
-
export async function accessStatusCommand(deps, flags) {
|
|
232
|
-
const config = loadLoggedConfig(deps, { cwd: deps.cwd, env: deps.env });
|
|
233
|
-
const authed = authedClient(deps, config);
|
|
234
|
-
if (!authed)
|
|
235
|
-
return EXIT.auth;
|
|
236
|
-
try {
|
|
237
|
-
const access = await authed.client.accessStatus();
|
|
238
|
-
if (flags.json) {
|
|
239
|
-
deps.io.out(JSON.stringify(access, null, 2));
|
|
240
|
-
return access.status === 'active' ? EXIT.ok : EXIT.failed;
|
|
241
|
-
}
|
|
242
|
-
if (access.status === 'no_active_plan') {
|
|
243
|
-
const supportUrl = `${authed.baseUrl.replace(/\/$/, '')}/support`;
|
|
244
|
-
deps.io.out('No active plan or temporary Alpha access for this account.');
|
|
245
|
-
deps.io.out(`next: Open ${supportUrl} to request Alpha access, then retry.`);
|
|
246
|
-
return EXIT.failed;
|
|
247
|
-
}
|
|
248
|
-
const expiry = access.expires_at ? ` until ${access.expires_at}` : '';
|
|
249
|
-
deps.io.out(`Access active (${access.reason})${expiry}`);
|
|
250
|
-
return EXIT.ok;
|
|
251
|
-
}
|
|
252
|
-
catch (err) {
|
|
253
|
-
return reportError(deps, err);
|
|
254
|
-
}
|
|
255
|
-
}
|
|
256
|
-
// ---------------------------------------------------------------------------
|
|
257
|
-
// devices / capabilities
|
|
258
|
-
// ---------------------------------------------------------------------------
|
|
259
|
-
export async function devicesCommand(deps, flags) {
|
|
260
|
-
const config = loadLoggedConfig(deps, { cwd: deps.cwd, env: deps.env });
|
|
261
|
-
const authed = authedClient(deps, config);
|
|
262
|
-
if (!authed)
|
|
263
|
-
return EXIT.auth;
|
|
264
|
-
try {
|
|
265
|
-
const result = await authed.client.listDevices();
|
|
266
|
-
if (flags.json) {
|
|
267
|
-
deps.io.out(JSON.stringify(result, null, 2));
|
|
268
|
-
return EXIT.ok;
|
|
269
|
-
}
|
|
270
|
-
if (result.devices.length === 0) {
|
|
271
|
-
const supportUrl = supportPageUrl(authed.baseUrl);
|
|
272
|
-
let email = null;
|
|
273
|
-
try {
|
|
274
|
-
email = (await authed.client.accessStatus()).email;
|
|
275
|
-
}
|
|
276
|
-
catch {
|
|
277
|
-
// Best-effort: the empty-state copy still points at /support without it.
|
|
278
|
-
}
|
|
279
|
-
deps.io.out(`No devices yet. Install Notifai from ${supportUrl}, ${sameAccountSignInLine(email)}, and allow notifications.`);
|
|
280
|
-
return EXIT.ok;
|
|
281
|
-
}
|
|
282
|
-
for (const d of result.devices) {
|
|
283
|
-
deps.io.out(`${d.device_id} ${d.display_name} ${d.platform} ${d.registration_healthy ? 'ready' : 'not ready'} (permission: ${d.permission_status})`);
|
|
284
|
-
}
|
|
285
|
-
return EXIT.ok;
|
|
286
|
-
}
|
|
287
|
-
catch (err) {
|
|
288
|
-
return reportError(deps, err);
|
|
289
|
-
}
|
|
290
|
-
}
|
|
291
|
-
/**
|
|
292
|
-
* Devices as data, for surfaces that render them themselves.
|
|
293
|
-
*
|
|
294
|
-
* `devicesCommand` prints and returns an exit code, which is the right shape
|
|
295
|
-
* for a command and the wrong one for a menu that wants to show names beside
|
|
296
|
-
* checkboxes. Silent on every failure by design: the interactive app already
|
|
297
|
-
* shows the credential and connectivity state on its status card, so a second
|
|
298
|
-
* error line here would be reporting the same fault twice.
|
|
299
|
-
*/
|
|
300
|
-
export async function deviceInventory(deps) {
|
|
301
|
-
if (!deps.store.load())
|
|
302
|
-
return null;
|
|
303
|
-
const config = loadLoggedConfig(deps, { cwd: deps.cwd, env: deps.env });
|
|
304
|
-
const authed = authedClient(deps, config);
|
|
305
|
-
if (!authed)
|
|
306
|
-
return null;
|
|
307
|
-
try {
|
|
308
|
-
return (await authed.client.listDevices()).devices;
|
|
309
|
-
}
|
|
310
|
-
catch {
|
|
311
|
-
return null;
|
|
312
|
-
}
|
|
313
|
-
}
|
|
314
|
-
/** Whether a device is in a state where a notification would actually arrive. */
|
|
315
|
-
export function canDeviceReceive(device) {
|
|
316
|
-
return deviceCanReceive(device);
|
|
317
|
-
}
|
|
318
|
-
export async function capabilitiesCommand(deps, flags) {
|
|
319
|
-
// Locally, and with the same message `send` gives. Spending a round trip to
|
|
320
|
-
// have the server answer "Request validation failed" told the caller neither
|
|
321
|
-
// which flag was wrong nor what it accepts.
|
|
322
|
-
if (flags.platform !== undefined && !PLATFORMS.includes(flags.platform)) {
|
|
323
|
-
deps.io.err(`Unknown platform "${flags.platform}" — use ${PLATFORMS.join(' or ')}.`);
|
|
324
|
-
return EXIT.usage;
|
|
325
|
-
}
|
|
326
|
-
const config = loadLoggedConfig(deps, { cwd: deps.cwd, env: deps.env });
|
|
327
|
-
const credential = deps.store.load();
|
|
328
|
-
const baseUrl = resolvedBaseUrl(config, credential);
|
|
329
|
-
const client = makeClient(deps, baseUrl, null);
|
|
330
|
-
try {
|
|
331
|
-
const doc = await client.capabilities(flags.platform ?? 'ios');
|
|
332
|
-
if (flags.json) {
|
|
333
|
-
deps.io.out(JSON.stringify(doc, null, 2));
|
|
334
|
-
return EXIT.ok;
|
|
335
|
-
}
|
|
336
|
-
deps.io.out(`${doc.platform} capability contract v${doc.schema_version} (payload limit ${doc.payload_limit_bytes} bytes)`);
|
|
337
|
-
for (const field of doc.fields) {
|
|
338
|
-
deps.io.out(` ${field.path}: ${field.status}${field.reason ? ` — ${field.reason}` : ''}`);
|
|
339
|
-
}
|
|
340
|
-
return EXIT.ok;
|
|
341
|
-
}
|
|
342
|
-
catch (err) {
|
|
343
|
-
return reportError(deps, err);
|
|
344
|
-
}
|
|
345
|
-
}
|
|
346
|
-
// ---------------------------------------------------------------------------
|
|
347
|
-
// send / status
|
|
348
|
-
// ---------------------------------------------------------------------------
|
|
349
|
-
function resolveDraftInvocation(deps, flags, active) {
|
|
350
|
-
const inferred = inferInvocationContext(deps.cwd);
|
|
351
|
-
const source = buildSourceContext({
|
|
352
|
-
env: deps.env,
|
|
353
|
-
invocation: inferred,
|
|
354
|
-
...(flags.sessionId !== undefined ? { sessionId: flags.sessionId } : {}),
|
|
355
|
-
...(flags.sessionLabel !== undefined ? { sessionLabel: flags.sessionLabel } : {}),
|
|
356
|
-
...(active === null
|
|
357
|
-
? {}
|
|
358
|
-
: {
|
|
359
|
-
activeHarness: {
|
|
360
|
-
harness: active.harness,
|
|
361
|
-
...(active.sessionId === undefined ? {} : { sessionId: active.sessionId }),
|
|
362
|
-
},
|
|
363
|
-
}),
|
|
364
|
-
});
|
|
365
|
-
if (!source.ok)
|
|
366
|
-
return source;
|
|
367
|
-
return {
|
|
368
|
-
ok: true,
|
|
369
|
-
invocation: {
|
|
370
|
-
inferredProject: inferred.project,
|
|
371
|
-
...(source.source === undefined ? {} : { source: source.source }),
|
|
372
|
-
},
|
|
373
|
-
};
|
|
374
|
-
}
|
|
375
|
-
export async function sendCommand(deps, flags) {
|
|
376
|
-
const hasReplyChoice = Array.isArray(flags.replyChoice)
|
|
377
|
-
? flags.replyChoice.length > 0
|
|
378
|
-
: flags.replyChoice !== undefined;
|
|
379
|
-
if (flags.reply && flags.kind !== undefined && flags.kind !== 'question') {
|
|
380
|
-
deps.io.err(`--kind ${flags.kind} cannot be combined with --reply; a reply request is a question.`);
|
|
381
|
-
return EXIT.usage;
|
|
382
|
-
}
|
|
383
|
-
// Kind now decides the sound a notification arrives with, so it is asked for
|
|
384
|
-
// at the boundary rather than defaulted silently: an unlabelled send would
|
|
385
|
-
// reach the user as ordinary news whatever actually happened.
|
|
386
|
-
if (!flags.reply && flags.kind === undefined) {
|
|
387
|
-
deps.io.err('--kind is required: say what this notification is. ' +
|
|
388
|
-
'update (news) · done (finished) · failed (terminal failure) · blocked (cannot proceed). ' +
|
|
389
|
-
'--reply makes it a question without --kind.');
|
|
390
|
-
return EXIT.usage;
|
|
391
|
-
}
|
|
392
|
-
if (!flags.reply && flags.kind === 'question') {
|
|
393
|
-
deps.io.err('--kind question requires --reply so the question can be answered.');
|
|
394
|
-
return EXIT.usage;
|
|
395
|
-
}
|
|
396
|
-
if (!flags.reply &&
|
|
397
|
-
(flags.replyTimeout !== undefined || flags.replyWindow !== undefined || flags.noBlock || hasReplyChoice)) {
|
|
398
|
-
deps.io.err('Use --reply with --reply-timeout, --reply-window, --reply-choice, or --no-block.');
|
|
399
|
-
return EXIT.usage;
|
|
400
|
-
}
|
|
401
|
-
const replyTimeout = flags.replyTimeout ?? 900;
|
|
402
|
-
if (flags.reply && !isNonNegativeInteger(replyTimeout)) {
|
|
403
|
-
deps.io.err('--reply-timeout must be a non-negative integer number of seconds.');
|
|
404
|
-
return EXIT.usage;
|
|
405
|
-
}
|
|
406
|
-
// Asking while declaring that nothing will wait for the answer. The reply is
|
|
407
|
-
// captured server-side and then unreachable: only a blocking send waits for
|
|
408
|
-
// it, and the hook path drains questions registered by `ask`, never a send's
|
|
409
|
-
// request id. So the user gets a real button, taps it, and nothing happens —
|
|
410
|
-
// worse than a banner that never asked, because it spends their attention
|
|
411
|
-
// and their trust in the channel.
|
|
412
|
-
//
|
|
413
|
-
// Both spellings of "do not wait" are rejected, because the defect is the
|
|
414
|
-
// zero wait and not the flag that produced it.
|
|
415
|
-
if (flags.reply && (flags.noBlock || replyTimeout === 0)) {
|
|
416
|
-
deps.io.err('A question needs someone to hear the answer, so --reply cannot be combined ' +
|
|
417
|
-
'with --no-block or --reply-timeout 0.\n' +
|
|
418
|
-
'To ask and end the turn, use `notifai ask` — the turn-end hook returns the answer.\n' +
|
|
419
|
-
'To announce finished work, drop --reply and its choices.');
|
|
420
|
-
return EXIT.usage;
|
|
421
|
-
}
|
|
422
|
-
if (flags.reply &&
|
|
423
|
-
flags.replyWindow !== undefined &&
|
|
424
|
-
(!Number.isInteger(flags.replyWindow) ||
|
|
425
|
-
flags.replyWindow < MIN_REPLY_WINDOW_SECONDS ||
|
|
426
|
-
flags.replyWindow > REPLY_MAX_WINDOW_SECONDS)) {
|
|
427
|
-
deps.io.err(`--reply-window must be an integer from ${MIN_REPLY_WINDOW_SECONDS} to ${REPLY_MAX_WINDOW_SECONDS} seconds.`);
|
|
428
|
-
return EXIT.usage;
|
|
429
|
-
}
|
|
430
|
-
const mediaInputError = validateMediaInputs(flags.image, flags.imageAlt);
|
|
431
|
-
if (mediaInputError !== null) {
|
|
432
|
-
deps.io.err(mediaInputError);
|
|
433
|
-
return EXIT.usage;
|
|
434
|
-
}
|
|
435
|
-
const config = loadLoggedConfig(deps, {
|
|
436
|
-
cwd: deps.cwd,
|
|
437
|
-
env: deps.env,
|
|
438
|
-
flags: { base_url: flags.baseUrl, wait_seconds: flags.wait },
|
|
439
|
-
});
|
|
440
|
-
const source = resolveDraftInvocation(deps, flags, activeHarnessSession(deps.env, deps.cwd, (deps.now ?? Date.now)()));
|
|
441
|
-
if (!source.ok) {
|
|
442
|
-
deps.io.err(source.error);
|
|
443
|
-
return EXIT.usage;
|
|
444
|
-
}
|
|
445
|
-
const authed = authedClient(deps, config);
|
|
446
|
-
if (!authed)
|
|
447
|
-
return EXIT.auth;
|
|
448
|
-
const mediaIds = [];
|
|
449
|
-
for (const image of flags.image ?? []) {
|
|
450
|
-
if (image.startsWith('med_')) {
|
|
451
|
-
mediaIds.push(image);
|
|
452
|
-
continue;
|
|
453
|
-
}
|
|
454
|
-
const uploaded = await uploadImage(deps, authed.client, image);
|
|
455
|
-
if (!uploaded.ok) {
|
|
456
|
-
if (uploaded.error !== null)
|
|
457
|
-
deps.io.err(uploaded.error);
|
|
458
|
-
return uploaded.exit;
|
|
459
|
-
}
|
|
460
|
-
mediaIds.push(uploaded.mediaId);
|
|
461
|
-
}
|
|
462
|
-
flags = { ...flags, image: mediaIds };
|
|
463
|
-
const build = buildDraft(config, flags, source.invocation);
|
|
464
|
-
if (!build.ok) {
|
|
465
|
-
deps.io.err(build.error);
|
|
466
|
-
return EXIT.usage;
|
|
467
|
-
}
|
|
468
|
-
const capabilities = CAPABILITIES_V1.describe(build.platform);
|
|
469
|
-
if (!capabilities) {
|
|
470
|
-
deps.io.err(`No capability contract is available for ${build.platform}.`);
|
|
471
|
-
return EXIT.usage;
|
|
472
|
-
}
|
|
473
|
-
const validation = validateDraft(build.draft, capabilities);
|
|
474
|
-
if (!validation.ok) {
|
|
475
|
-
for (const issue of validation.errors)
|
|
476
|
-
deps.io.err(`${issue.path}: ${issue.message}`);
|
|
477
|
-
return EXIT.usage;
|
|
478
|
-
}
|
|
479
|
-
emitSendWarnings(deps, flags, config);
|
|
480
|
-
if (!flags.reply &&
|
|
481
|
-
(flags.title.trim().endsWith('?') || flags.body.trim().endsWith('?'))) {
|
|
482
|
-
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.');
|
|
483
|
-
}
|
|
484
|
-
const waitSeconds = flags.noWait ? 0 : config.wait_seconds.value;
|
|
485
|
-
const idempotencyKey = flags.idempotencyKey ?? `cli-${randomBytes(12).toString('base64url')}`;
|
|
486
|
-
let receipt;
|
|
487
|
-
try {
|
|
488
|
-
receipt = await authed.client.submit({ idempotency_key: idempotencyKey, draft: build.draft }, waitSeconds);
|
|
489
|
-
}
|
|
490
|
-
catch (err) {
|
|
491
|
-
return reportError(deps, err);
|
|
492
|
-
}
|
|
493
|
-
const receiptExit = receiptExitCode(receipt);
|
|
494
|
-
// The single most useful line in the log: it ties the local invocation to
|
|
495
|
-
// the server-side request id, which is what every later question about this
|
|
496
|
-
// notification ("did it arrive?", "which device?") is asked in terms of.
|
|
497
|
-
log(deps).info('send.submitted', {
|
|
498
|
-
request_id: receipt.request_id,
|
|
499
|
-
kind: flags.reply ? 'question' : (flags.kind ?? 'update'),
|
|
500
|
-
title: flags.title,
|
|
501
|
-
overall: receipt.overall,
|
|
502
|
-
replayed: receipt.replayed,
|
|
503
|
-
agent_acknowledgement_required: receipt.agent_acknowledgement_required,
|
|
504
|
-
deliveries: receipt.deliveries.length,
|
|
505
|
-
wait_seconds: waitSeconds,
|
|
506
|
-
exit: receiptExit,
|
|
507
|
-
});
|
|
508
|
-
log(deps).info('send.outcome', {
|
|
509
|
-
request_id: receipt.request_id,
|
|
510
|
-
overall: receipt.overall,
|
|
511
|
-
// Per device, because "it said accepted but nothing arrived" is answered
|
|
512
|
-
// by which device reached which state and why the provider said so.
|
|
513
|
-
devices: receipt.deliveries.map((delivery) => ({
|
|
514
|
-
device: delivery.device_name,
|
|
515
|
-
state: delivery.state,
|
|
516
|
-
attempts: delivery.attempts,
|
|
517
|
-
provider_status: delivery.provider_status,
|
|
518
|
-
provider_reason: delivery.provider_reason,
|
|
519
|
-
})),
|
|
520
|
-
...(receipt.warnings.length > 0 ? { warnings: receipt.warnings } : {}),
|
|
521
|
-
});
|
|
522
|
-
if (!flags.json)
|
|
523
|
-
deps.io.out(formatReceipt(receipt));
|
|
524
|
-
else if (flags.reply)
|
|
525
|
-
deps.io.out(JSON.stringify({ type: 'receipt', receipt }));
|
|
526
|
-
// A zero wait can no longer reach here: --reply guarantees a positive one.
|
|
527
|
-
if (!flags.reply || receiptExit !== EXIT.ok) {
|
|
528
|
-
if (flags.json) {
|
|
529
|
-
deps.io.out(flags.reply
|
|
530
|
-
? JSON.stringify({
|
|
531
|
-
type: 'reply_result',
|
|
532
|
-
request_id: receipt.request_id,
|
|
533
|
-
replies: [],
|
|
534
|
-
agent_acknowledgement_required: receipt.agent_acknowledgement_required,
|
|
535
|
-
agent_acknowledgement_text_required: receipt.agent_acknowledgement_text_required,
|
|
536
|
-
agent_acknowledgement: null,
|
|
537
|
-
acknowledgement_command: null,
|
|
538
|
-
degraded: false,
|
|
539
|
-
})
|
|
540
|
-
: JSON.stringify(receipt, null, 2));
|
|
541
|
-
}
|
|
542
|
-
return receiptExit;
|
|
543
|
-
}
|
|
544
|
-
try {
|
|
545
|
-
const result = await waitForReply(authed.client, receipt.request_id, {
|
|
546
|
-
timeoutSeconds: replyTimeout,
|
|
547
|
-
afterSeq: 0,
|
|
548
|
-
now: deps.now,
|
|
549
|
-
sleep: deps.sleep,
|
|
550
|
-
});
|
|
551
|
-
recordReplies(deps, receipt.request_id, result.response.replies);
|
|
552
|
-
if (flags.json) {
|
|
553
|
-
deps.io.out(JSON.stringify(replyResultJson(result.response, result.degraded)));
|
|
554
|
-
}
|
|
555
|
-
else if (result.response.replies.length > 0) {
|
|
556
|
-
printReplies(deps, result.response.replies);
|
|
557
|
-
printAcknowledgementStatus(deps, result.response);
|
|
558
|
-
}
|
|
559
|
-
else {
|
|
560
|
-
printNoReply(deps, receipt.request_id, result.response.reply_expires_at);
|
|
561
|
-
printAcknowledgementStatus(deps, result.response);
|
|
562
|
-
}
|
|
563
|
-
if (result.degraded) {
|
|
564
|
-
log(deps).error('cli.error', {
|
|
565
|
-
kind: 'network',
|
|
566
|
-
operation: 'reply_wait',
|
|
567
|
-
request_id: receipt.request_id,
|
|
568
|
-
degraded: true,
|
|
569
|
-
message: 'the reply wait ended while the server was unreachable or faulting',
|
|
570
|
-
});
|
|
571
|
-
deps.io.err(degradedWaitWarning(receipt.request_id));
|
|
572
|
-
return EXIT.network;
|
|
573
|
-
}
|
|
574
|
-
if (result.timedOut) {
|
|
575
|
-
deps.io.err(`No reply yet. Retrieve it with \`notifai replies ${receipt.request_id}\` or retire the question with ` +
|
|
576
|
-
`\`notifai close ${receipt.request_id}\`. ` +
|
|
577
|
-
`This is a reply-wait timeout, not a Delivery or Companion Receipt failure — check with \`notifai status ${receipt.request_id}\`.`);
|
|
578
|
-
}
|
|
579
|
-
return result.timedOut ? EXIT.noReply : EXIT.ok;
|
|
580
|
-
}
|
|
581
|
-
catch (err) {
|
|
582
|
-
// Receipt already printed; a wait fault must not read as "send failed".
|
|
583
|
-
// Permanent poll errors (auth, closed window, not found) still surface, but
|
|
584
|
-
// always name the durable request and point at recovery.
|
|
585
|
-
if (err instanceof ApiCallError || err instanceof NetworkError) {
|
|
586
|
-
log(deps).error('cli.error', {
|
|
587
|
-
kind: err instanceof ApiCallError ? 'api' : 'network',
|
|
588
|
-
operation: 'reply_wait',
|
|
589
|
-
request_id: receipt.request_id,
|
|
590
|
-
...(err instanceof ApiCallError ? { status: err.status, code: err.code } : {}),
|
|
591
|
-
message: err.message,
|
|
592
|
-
});
|
|
593
|
-
deps.io.err(`notifai: reply wait failed for ${receipt.request_id} (${err instanceof ApiCallError ? err.code : 'network'}: ${err.message}). ` +
|
|
594
|
-
`Delivery and Companion Receipt are independent — check with \`notifai status ${receipt.request_id}\` and retry with \`notifai replies ${receipt.request_id}\`.`);
|
|
595
|
-
if (err instanceof ApiCallError) {
|
|
596
|
-
if (err.code === 'auth_required' || err.code === 'machine_revoked')
|
|
597
|
-
return EXIT.auth;
|
|
598
|
-
return err.status >= 500 || err.status === 429 || err.status === 408
|
|
599
|
-
? EXIT.network
|
|
600
|
-
: EXIT.failed;
|
|
601
|
-
}
|
|
602
|
-
return EXIT.network;
|
|
603
|
-
}
|
|
604
|
-
return reportError(deps, err);
|
|
605
|
-
}
|
|
606
|
-
}
|
|
607
|
-
function emitSendWarnings(deps, flags, config) {
|
|
608
|
-
if (flags.title.length > 40) {
|
|
609
|
-
deps.io.err(`Heads up: this title is ${flags.title.length} characters; notification titles work best around 40 characters or fewer.`);
|
|
610
|
-
}
|
|
611
|
-
if (/^(?:update|done|question|failed|blocked)\s*(?:[·:—-]|$)/i.test(flags.title.trim())) {
|
|
612
|
-
deps.io.err('Heads up: keep the title to the specific substance. Put notification type in --kind; project identity is inferred separately.');
|
|
613
|
-
}
|
|
614
|
-
if (flags.collapseKey === undefined &&
|
|
615
|
-
config.collapse_key.value !== null &&
|
|
616
|
-
config.collapse_key.source.startsWith('global:')) {
|
|
617
|
-
deps.io.err('Heads up: collapse_key comes from machine-global config, so unrelated notifications may replace each other. Prefer a project or command-specific --collapse-key.');
|
|
618
|
-
}
|
|
619
|
-
if (flags.ttl !== undefined && flags.ttl > 259_200) {
|
|
620
|
-
deps.io.err('Heads up: this explicit --ttl is longer than 72 hours; stale notifications may arrive after they are useful.');
|
|
621
|
-
}
|
|
622
|
-
}
|
|
623
|
-
export async function repliesCommand(deps, requestedId, flags) {
|
|
624
|
-
const waitSeconds = flags.wait ?? 0;
|
|
625
|
-
const afterSeq = flags.after ?? 0;
|
|
626
|
-
if (!isNonNegativeInteger(waitSeconds)) {
|
|
627
|
-
deps.io.err('--wait must be a non-negative integer number of seconds.');
|
|
628
|
-
return EXIT.usage;
|
|
629
|
-
}
|
|
630
|
-
if (!isNonNegativeInteger(afterSeq)) {
|
|
631
|
-
deps.io.err('--after must be a non-negative integer sequence number.');
|
|
632
|
-
return EXIT.usage;
|
|
633
|
-
}
|
|
634
|
-
if (flags.pending === true && requestedId !== undefined) {
|
|
635
|
-
deps.io.err('Pass a request id or --pending, not both.');
|
|
636
|
-
return EXIT.usage;
|
|
637
|
-
}
|
|
638
|
-
let requestIds = requestedId === undefined ? [] : [requestedId];
|
|
639
|
-
if (flags.pending === true) {
|
|
640
|
-
const sessionId = readProjectSession(deps.cwd, deps.env, (deps.now ?? Date.now)());
|
|
641
|
-
if (sessionId === null) {
|
|
642
|
-
deps.io.err('No active session pointer is available in this directory.');
|
|
643
|
-
return EXIT.noReply;
|
|
644
|
-
}
|
|
645
|
-
// Every delivered question in the session's queue, in registration order —
|
|
646
|
-
// an agent may have several outstanding at once.
|
|
647
|
-
const state = readSessionState(sessionId, deps.env);
|
|
648
|
-
requestIds = (Array.isArray(state.pending) ? state.pending : [])
|
|
649
|
-
.map((entry) => entry.request_id)
|
|
650
|
-
.filter((id) => id !== undefined);
|
|
651
|
-
if (requestIds.length === 0) {
|
|
652
|
-
// `--json` is a promise about stdout, and an empty result is still a
|
|
653
|
-
// result: a caller that asked for machine-readable output must not have
|
|
654
|
-
// to parse an English sentence to learn there was nothing pending.
|
|
655
|
-
if (flags.json === true) {
|
|
656
|
-
deps.io.out(JSON.stringify({ session_id: sessionId, pending: [], replies: [] }, null, 2));
|
|
657
|
-
}
|
|
658
|
-
else {
|
|
659
|
-
deps.io.err(`Session ${sessionId} has no pushed question pending.`);
|
|
660
|
-
}
|
|
661
|
-
return EXIT.noReply;
|
|
662
|
-
}
|
|
663
|
-
}
|
|
664
|
-
if (requestIds.length === 0) {
|
|
665
|
-
deps.io.err('Pass a request id or --pending.');
|
|
666
|
-
return EXIT.usage;
|
|
667
|
-
}
|
|
668
|
-
const config = loadLoggedConfig(deps, { cwd: deps.cwd, env: deps.env });
|
|
669
|
-
const authed = authedClient(deps, config);
|
|
670
|
-
if (!authed)
|
|
671
|
-
return EXIT.auth;
|
|
672
|
-
try {
|
|
673
|
-
let anyReplies = false;
|
|
674
|
-
const degradedRequestIds = [];
|
|
675
|
-
let allTimedOut = true;
|
|
676
|
-
const jsonBodies = [];
|
|
677
|
-
for (const requestId of requestIds) {
|
|
678
|
-
try {
|
|
679
|
-
const result = await waitForReply(authed.client, requestId, {
|
|
680
|
-
timeoutSeconds: waitSeconds,
|
|
681
|
-
afterSeq,
|
|
682
|
-
now: deps.now,
|
|
683
|
-
sleep: deps.sleep,
|
|
684
|
-
});
|
|
685
|
-
recordReplies(deps, requestId, result.response.replies);
|
|
686
|
-
if (flags.json) {
|
|
687
|
-
jsonBodies.push(replyResultJson(result.response, result.degraded));
|
|
688
|
-
}
|
|
689
|
-
else if (result.response.replies.length > 0) {
|
|
690
|
-
if (flags.pending === true)
|
|
691
|
-
deps.io.out(`pending request ${requestId}`);
|
|
692
|
-
printReplies(deps, result.response.replies);
|
|
693
|
-
printAcknowledgementStatus(deps, result.response);
|
|
694
|
-
}
|
|
695
|
-
else {
|
|
696
|
-
printNoReply(deps, requestId, result.response.reply_expires_at);
|
|
697
|
-
printAcknowledgementStatus(deps, result.response);
|
|
698
|
-
}
|
|
699
|
-
anyReplies ||= result.response.replies.length > 0;
|
|
700
|
-
if (result.degraded)
|
|
701
|
-
degradedRequestIds.push(requestId);
|
|
702
|
-
allTimedOut &&= result.timedOut;
|
|
703
|
-
}
|
|
704
|
-
catch (err) {
|
|
705
|
-
recordDegradedReplyWaits(deps, degradedRequestIds);
|
|
706
|
-
return reportError(deps, err, { operation: 'reply_wait', request_id: requestId });
|
|
707
|
-
}
|
|
708
|
-
}
|
|
709
|
-
if (flags.json) {
|
|
710
|
-
// One request keeps the response shape agents already parse; several
|
|
711
|
-
// (only possible via --pending) arrive as an array in queue order.
|
|
712
|
-
deps.io.out(JSON.stringify(jsonBodies.length === 1 ? jsonBodies[0] : jsonBodies, null, 2));
|
|
713
|
-
}
|
|
714
|
-
if (degradedRequestIds.length > 0) {
|
|
715
|
-
recordDegradedReplyWaits(deps, degradedRequestIds);
|
|
716
|
-
// Name a request whose polls actually degraded, not merely the first item
|
|
717
|
-
// in a multi-request queue that may have completed cleanly.
|
|
718
|
-
deps.io.err(degradedWaitWarning(degradedRequestIds[0]));
|
|
719
|
-
return EXIT.network;
|
|
720
|
-
}
|
|
721
|
-
if (anyReplies)
|
|
722
|
-
return EXIT.ok;
|
|
723
|
-
return allTimedOut ? EXIT.noReply : EXIT.ok;
|
|
724
|
-
}
|
|
725
|
-
catch (err) {
|
|
726
|
-
return reportError(deps, err);
|
|
727
|
-
}
|
|
728
|
-
}
|
|
729
|
-
/** Loop over server-capped long polls until a reply arrives or the caller's deadline passes. */
|
|
730
|
-
export async function waitForReply(client, requestId, options) {
|
|
731
|
-
const now = options.now ?? Date.now;
|
|
732
|
-
const sleep = options.sleep ?? ((milliseconds) => new Promise((resolve) => setTimeout(resolve, milliseconds)));
|
|
733
|
-
const deadline = now() + options.timeoutSeconds * 1000;
|
|
734
|
-
let lastResponse = null;
|
|
735
|
-
let lastTransientError = null;
|
|
736
|
-
let consecutiveTransientErrors = 0;
|
|
737
|
-
let firstPoll = true;
|
|
738
|
-
while (firstPoll || now() < deadline) {
|
|
739
|
-
firstPoll = false;
|
|
740
|
-
const remainingMs = Math.max(0, deadline - now());
|
|
741
|
-
const waitSeconds = Math.min(25, Math.floor(remainingMs / 1000));
|
|
742
|
-
try {
|
|
743
|
-
const response = await client.replies(requestId, {
|
|
744
|
-
waitSeconds,
|
|
745
|
-
afterSeq: options.afterSeq,
|
|
746
|
-
});
|
|
747
|
-
lastResponse = response;
|
|
748
|
-
lastTransientError = null;
|
|
749
|
-
consecutiveTransientErrors = 0;
|
|
750
|
-
if (response.replies.length > 0)
|
|
751
|
-
return { response, timedOut: false, degraded: false };
|
|
752
|
-
const pauseMs = Math.min(250, Math.max(0, deadline - now()));
|
|
753
|
-
if (pauseMs > 0)
|
|
754
|
-
await sleep(pauseMs);
|
|
755
|
-
}
|
|
756
|
-
catch (err) {
|
|
757
|
-
if (!isRetryableReplyPollError(err))
|
|
758
|
-
throw err;
|
|
759
|
-
lastTransientError = err instanceof Error ? err : new Error(String(err));
|
|
760
|
-
consecutiveTransientErrors += 1;
|
|
761
|
-
const remainingAfterError = Math.max(0, deadline - now());
|
|
762
|
-
if (remainingAfterError === 0)
|
|
763
|
-
break;
|
|
764
|
-
const backoffMs = Math.min(250 * 2 ** (consecutiveTransientErrors - 1), 2_000, remainingAfterError);
|
|
765
|
-
await sleep(backoffMs);
|
|
766
|
-
}
|
|
767
|
-
}
|
|
768
|
-
// Never saw a successful poll: surface the fault so callers get EXIT.network
|
|
769
|
-
// rather than a fake empty silence. The durable request is still on the
|
|
770
|
-
// server; the typed recovery is `notifai replies <id>`.
|
|
771
|
-
if (!lastResponse && lastTransientError)
|
|
772
|
-
throw lastTransientError;
|
|
773
|
-
return {
|
|
774
|
-
response: lastResponse ??
|
|
775
|
-
{
|
|
776
|
-
request_id: requestId,
|
|
777
|
-
reply_expires_at: null,
|
|
778
|
-
agent_acknowledgement_required: false,
|
|
779
|
-
agent_acknowledgement_text_required: false,
|
|
780
|
-
agent_acknowledgement: null,
|
|
781
|
-
replies: [],
|
|
782
|
-
},
|
|
783
|
-
timedOut: true,
|
|
784
|
-
// A poll succeeded at some point, so we do not throw — but the last thing
|
|
785
|
-
// we know is that we could not reach the server. Reporting that as a plain
|
|
786
|
-
// "no reply" would let an agent treat an unseen refusal as consent.
|
|
787
|
-
degraded: lastTransientError !== null,
|
|
788
|
-
};
|
|
789
|
-
}
|
|
790
|
-
function recordDegradedReplyWaits(deps, requestIds) {
|
|
791
|
-
if (requestIds.length === 0)
|
|
792
|
-
return;
|
|
793
|
-
log(deps).error('cli.error', {
|
|
794
|
-
kind: 'network',
|
|
795
|
-
operation: 'reply_wait',
|
|
796
|
-
request_ids: requestIds,
|
|
797
|
-
degraded: true,
|
|
798
|
-
message: 'the reply wait ended while the server was unreachable or faulting',
|
|
799
|
-
});
|
|
800
|
-
}
|
|
801
|
-
/**
|
|
802
|
-
* Shared by every surface that waits: "the user did not answer" and "I could
|
|
803
|
-
* not find out" must not look the same, because agents branch on the exit code
|
|
804
|
-
* and one of those two branches is safe to proceed from.
|
|
805
|
-
*
|
|
806
|
-
* Delivery / Companion Receipt / OS presentation are separate facts — a wait
|
|
807
|
-
* fault is not evidence that the push failed.
|
|
808
|
-
*/
|
|
809
|
-
function degradedWaitWarning(requestId) {
|
|
810
|
-
return (`notifai: the wait for ${requestId} ended while the server was unreachable or faulting, ` +
|
|
811
|
-
`so this is "could not find out", not "no answer" — the request is still durable and the ` +
|
|
812
|
-
`reply may already be waiting (Provider Acceptance and Companion Receipt are independent). ` +
|
|
813
|
-
`Retry with \`notifai replies ${requestId}\`.`);
|
|
814
|
-
}
|
|
815
|
-
function isNonNegativeInteger(value) {
|
|
816
|
-
return Number.isInteger(value) && value >= 0;
|
|
817
|
-
}
|
|
818
|
-
function acknowledgeInvocation(requestId, textRequired) {
|
|
819
|
-
return textRequired
|
|
820
|
-
? `notifai acknowledge ${requestId} --text <text>`
|
|
821
|
-
: `notifai acknowledge ${requestId}`;
|
|
822
|
-
}
|
|
823
|
-
function acknowledgementCommand(requestId, required, textRequired, acknowledgement, hasReply = true) {
|
|
824
|
-
return required && acknowledgement === null && hasReply
|
|
825
|
-
? acknowledgeInvocation(requestId, textRequired)
|
|
826
|
-
: null;
|
|
827
|
-
}
|
|
828
|
-
function replyResultJson(response, degraded) {
|
|
829
|
-
return {
|
|
830
|
-
type: 'reply_result',
|
|
831
|
-
request_id: response.request_id,
|
|
832
|
-
reply_expires_at: response.reply_expires_at,
|
|
833
|
-
replies: response.replies,
|
|
834
|
-
agent_acknowledgement_required: response.agent_acknowledgement_required,
|
|
835
|
-
agent_acknowledgement: response.agent_acknowledgement,
|
|
836
|
-
agent_acknowledgement_text_required: response.agent_acknowledgement_text_required,
|
|
837
|
-
acknowledgement_command: acknowledgementCommand(response.request_id, response.agent_acknowledgement_required, response.agent_acknowledgement_text_required, response.agent_acknowledgement, response.replies.length > 0),
|
|
838
|
-
degraded,
|
|
839
|
-
};
|
|
840
|
-
}
|
|
841
|
-
function printAcknowledgementStatus(deps, response) {
|
|
842
|
-
if (!response.agent_acknowledgement_required) {
|
|
843
|
-
deps.io.out('Agent Acknowledgement: not required for this request.');
|
|
844
|
-
return;
|
|
845
|
-
}
|
|
846
|
-
const textRequired = response.agent_acknowledgement_text_required;
|
|
847
|
-
if (response.agent_acknowledgement !== null) {
|
|
848
|
-
const recorded = `Agent Acknowledgement: recorded at ${response.agent_acknowledgement.created_at}`;
|
|
849
|
-
deps.io.out(response.agent_acknowledgement.text.length > 0
|
|
850
|
-
? `${recorded}: ${response.agent_acknowledgement.text}`
|
|
851
|
-
: `${recorded}.`);
|
|
852
|
-
return;
|
|
853
|
-
}
|
|
854
|
-
if (response.replies.length > 0) {
|
|
855
|
-
deps.io.out('Agent Acknowledgement required.');
|
|
856
|
-
deps.io.out(textRequired
|
|
857
|
-
? `next: Run \`${acknowledgeInvocation(response.request_id, true)}\` with concrete text saying what you will do because of the reply.`
|
|
858
|
-
: `next: Run \`${acknowledgeInvocation(response.request_id, false)}\` so the user sees you read the reply; this account turned acknowledgement text off.`);
|
|
859
|
-
return;
|
|
860
|
-
}
|
|
861
|
-
deps.io.out('Agent Acknowledgement: required after a user reply; no reply is recorded yet.');
|
|
862
|
-
}
|
|
863
|
-
function recordReplies(deps, requestId, replies) {
|
|
864
|
-
const logger = log(deps);
|
|
865
|
-
for (const reply of replies) {
|
|
866
|
-
logger.info('reply.received', {
|
|
867
|
-
request_id: requestId,
|
|
868
|
-
sequence: reply.seq,
|
|
869
|
-
device: reply.device_name,
|
|
870
|
-
text: reply.text,
|
|
871
|
-
answers: reply.answers,
|
|
872
|
-
source: reply.source,
|
|
873
|
-
});
|
|
874
|
-
}
|
|
875
|
-
}
|
|
876
|
-
function printReplies(deps, replies) {
|
|
877
|
-
for (const reply of replies)
|
|
878
|
-
deps.io.out(`reply from ${reply.device_name}: ${reply.text}`);
|
|
879
|
-
const contradiction = contradictingAnswer(replies);
|
|
880
|
-
if (contradiction !== null)
|
|
881
|
-
deps.io.err(contradiction);
|
|
882
|
-
}
|
|
883
|
-
/**
|
|
884
|
-
* The correction note for a wait that saw more than one answer.
|
|
885
|
-
*
|
|
886
|
-
* A later reply that conflicts with an earlier one is a correction, so the
|
|
887
|
-
* latest answer is the one that counts. The server retires the question on
|
|
888
|
-
* the other devices the moment one answers, which makes this rare; it is
|
|
889
|
-
* still reachable, because a device can answer between the first reply
|
|
890
|
-
* landing and its retirement arriving.
|
|
891
|
-
*
|
|
892
|
-
* Ordered by seq, so `replies.at(-1)` is the answer to act on.
|
|
893
|
-
*/
|
|
894
|
-
export function contradictingAnswer(replies) {
|
|
895
|
-
const winner = replies.at(-1);
|
|
896
|
-
if (winner === undefined || replies.length < 2)
|
|
897
|
-
return null;
|
|
898
|
-
const key = (reply) => reply.answers.length > 0 ? JSON.stringify(reply.answers) : reply.text;
|
|
899
|
-
const corrected = replies.slice(0, -1).filter((reply) => key(reply) !== key(winner));
|
|
900
|
-
if (corrected.length === 0)
|
|
901
|
-
return null;
|
|
902
|
-
const names = [...new Set(corrected.map((reply) => reply.device_name))].join(', ');
|
|
903
|
-
return (`note: "${winner.text}" from ${winner.device_name} is the answer that counts — a later ` +
|
|
904
|
-
`answer corrects an earlier one. Earlier differing answers from ${names} were superseded.`);
|
|
905
|
-
}
|
|
906
|
-
function printNoReply(deps, requestId, expiresAt) {
|
|
907
|
-
// A harness hook may have retired this question, in which case promising an
|
|
908
|
-
// open window would send the caller back to wait for an answer the server
|
|
909
|
-
// will now refuse.
|
|
910
|
-
const closed = expiresAt != null && Date.parse(expiresAt) <= Date.now();
|
|
911
|
-
deps.io.out(closed
|
|
912
|
-
? `no reply for request ${requestId}; the reply window has closed`
|
|
913
|
-
: `no reply yet for request ${requestId}; the reply window remains open`);
|
|
914
|
-
}
|
|
915
|
-
export async function statusCommand(deps, requestId, flags) {
|
|
916
|
-
const config = loadLoggedConfig(deps, { cwd: deps.cwd, env: deps.env });
|
|
917
|
-
const authed = authedClient(deps, config);
|
|
918
|
-
if (!authed)
|
|
919
|
-
return EXIT.auth;
|
|
920
|
-
try {
|
|
921
|
-
const snapshot = await authed.client.evidence(requestId);
|
|
922
|
-
if (flags.json) {
|
|
923
|
-
deps.io.out(JSON.stringify(snapshot, null, 2));
|
|
924
|
-
return EXIT.ok;
|
|
925
|
-
}
|
|
926
|
-
deps.io.out(`request ${snapshot.request_id} (${snapshot.event ?? 'no event'}) — ${snapshot.overall}`);
|
|
927
|
-
let anyReplyReceived = false;
|
|
928
|
-
for (const d of snapshot.deliveries) {
|
|
929
|
-
deps.io.out(` ${d.device_name}:`);
|
|
930
|
-
deps.io.out(` Delivery: ${d.state} after ${d.attempts} attempt(s)`);
|
|
931
|
-
deps.io.out(` Provider Acceptance: ${d.state === 'provider_accepted' ? 'accepted' : 'not recorded'}`);
|
|
932
|
-
if (d.companion_receipt.state === 'observed') {
|
|
933
|
-
const latency = d.companion_receipt.latency_ms;
|
|
934
|
-
deps.io.out(` Companion Receipt (the app's delivery confirmation): observed at ${d.companion_receipt.observed_at}` +
|
|
935
|
-
(latency === null ? '' : ` (${formatElapsed(latency)} after Provider Acceptance)`));
|
|
936
|
-
}
|
|
937
|
-
else {
|
|
938
|
-
deps.io.out(" Companion Receipt (the app's delivery confirmation): unknown — not observed; this is not a failure or proof of non-receipt");
|
|
939
|
-
}
|
|
940
|
-
// Notifai never learns whether the OS painted a banner; saying so stops
|
|
941
|
-
// a reply-wait fault from being misread as "the phone never showed it".
|
|
942
|
-
deps.io.out(' OS presentation: not observed by Notifai — Provider Acceptance and Companion Receipt do not prove a banner was shown');
|
|
943
|
-
const replyEvent = d.events.find((e) => e.stage === 'reply_received');
|
|
944
|
-
if (replyEvent) {
|
|
945
|
-
anyReplyReceived = true;
|
|
946
|
-
deps.io.out(` Reply received: yes (first at ${replyEvent.occurred_at})`);
|
|
947
|
-
}
|
|
948
|
-
else {
|
|
949
|
-
deps.io.out(' Reply received: not yet recorded on this delivery');
|
|
950
|
-
}
|
|
951
|
-
for (const e of d.events) {
|
|
952
|
-
deps.io.out(` ${e.occurred_at} ${e.stage}${e.reason ? ` (${e.reason})` : ''}`);
|
|
953
|
-
}
|
|
954
|
-
}
|
|
955
|
-
deps.io.out(anyReplyReceived
|
|
956
|
-
? ` Reply wait: answers are on the server — collect with \`notifai replies ${snapshot.request_id}\` (a local wait fault does not erase them)`
|
|
957
|
-
: ` Reply wait: no answer stored yet — a blocking wait failure is independent of Delivery above; retry with \`notifai replies ${snapshot.request_id}\``);
|
|
958
|
-
return EXIT.ok;
|
|
959
|
-
}
|
|
960
|
-
catch (err) {
|
|
961
|
-
return reportError(deps, err);
|
|
962
|
-
}
|
|
963
|
-
}
|
|
964
|
-
function formatElapsed(milliseconds) {
|
|
965
|
-
const totalSeconds = Math.floor(milliseconds / 1_000);
|
|
966
|
-
const minutes = Math.floor(totalSeconds / 60);
|
|
967
|
-
const seconds = totalSeconds % 60;
|
|
968
|
-
return minutes === 0 ? `${seconds}s` : `${minutes}m ${seconds}s`;
|
|
969
|
-
}
|
|
970
|
-
const MEDIA_TYPES = {
|
|
971
|
-
'.jpg': 'image/jpeg',
|
|
972
|
-
'.jpeg': 'image/jpeg',
|
|
973
|
-
'.png': 'image/png',
|
|
974
|
-
'.gif': 'image/gif',
|
|
975
|
-
};
|
|
976
|
-
/** `--image` accepts a media id, a local file path, or an http(s) URL. */
|
|
977
|
-
async function uploadImage(deps, client, source) {
|
|
978
|
-
let bytes;
|
|
979
|
-
let mediaType;
|
|
980
|
-
if (/^https?:\/\//.test(source)) {
|
|
981
|
-
try {
|
|
982
|
-
const response = await fetch(source);
|
|
983
|
-
if (!response.ok)
|
|
984
|
-
return { ok: false, error: `Could not fetch ${source} (${response.status}).`, exit: EXIT.usage };
|
|
985
|
-
const contentType = response.headers.get('content-type')?.split(';')[0]?.trim() ?? '';
|
|
986
|
-
mediaType = ['image/jpeg', 'image/png', 'image/gif'].find((t) => t === contentType);
|
|
987
|
-
bytes = new Uint8Array(await response.arrayBuffer());
|
|
988
|
-
}
|
|
989
|
-
catch (err) {
|
|
990
|
-
return { ok: false, error: `Could not fetch ${source}: ${String(err)}`, exit: EXIT.network };
|
|
991
|
-
}
|
|
992
|
-
}
|
|
993
|
-
else {
|
|
994
|
-
if (!existsSync(source)) {
|
|
995
|
-
return { ok: false, error: `--image: "${source}" is not a media id, file, or URL.`, exit: EXIT.usage };
|
|
996
|
-
}
|
|
997
|
-
bytes = new Uint8Array(readFileSync(source));
|
|
998
|
-
mediaType = MEDIA_TYPES[path.extname(source).toLowerCase()];
|
|
999
|
-
}
|
|
1000
|
-
if (!mediaType) {
|
|
1001
|
-
return { ok: false, error: 'Images must be JPEG, PNG, or GIF.', exit: EXIT.usage };
|
|
1002
|
-
}
|
|
1003
|
-
try {
|
|
1004
|
-
const grant = await client.createMediaUpload({
|
|
1005
|
-
media_type: mediaType,
|
|
1006
|
-
size_bytes: bytes.byteLength,
|
|
1007
|
-
sha256: sha256Hex(bytes),
|
|
1008
|
-
});
|
|
1009
|
-
await client.uploadMedia(grant, bytes);
|
|
1010
|
-
return { ok: true, mediaId: grant.media_id };
|
|
1011
|
-
}
|
|
1012
|
-
catch (err) {
|
|
1013
|
-
// Every other API failure in this file goes through `reportError`, which
|
|
1014
|
-
// maps a revoked credential to the auth code, a server fault to the network
|
|
1015
|
-
// one and everything else to plain failure, and records it in the local
|
|
1016
|
-
// log with whatever next step the server named. This used to answer
|
|
1017
|
-
// `network` to all of them, so an image too large for the account exited
|
|
1018
|
-
// the same way an unreachable server did, and nothing about it was logged.
|
|
1019
|
-
return { ok: false, error: null, exit: reportError(deps, err) };
|
|
1020
|
-
}
|
|
1021
|
-
}
|
|
1022
|
-
// ---------------------------------------------------------------------------
|
|
1023
|
-
// hook / ask / close — harness integration
|
|
1024
|
-
// ---------------------------------------------------------------------------
|
|
1025
|
-
export const HOOK_EVENTS = ['user-prompt-submit', 'stop', 'session-end'];
|
|
1026
|
-
/** SessionEnd cleanup must precede every diagnostic that can wait on a file lock. */
|
|
1027
|
-
export function hookDefersDiagnosticsUntilAfterCleanup(event) {
|
|
1028
|
-
return event === 'session-end';
|
|
1029
|
-
}
|
|
1030
|
-
/**
|
|
1031
|
-
* Runs one harness hook. Contract with every harness: hook JSON arrives on
|
|
1032
|
-
* stdin, the decision (if any) goes to stdout, diagnostics go to stderr, and
|
|
1033
|
-
* exit 0 with no stdout means "no decision — carry on as normal".
|
|
1034
|
-
*
|
|
1035
|
-
* Every failure path in here must reach that no-decision state. A hook that
|
|
1036
|
-
* throws, or that blocks past the harness's timeout, degrades the agent for a
|
|
1037
|
-
* feature the user only asked to make it more convenient.
|
|
1038
|
-
*/
|
|
1039
|
-
export async function hookRunCommand(deps, event, readStdin, harness) {
|
|
1040
|
-
if (!HOOK_EVENTS.includes(event)) {
|
|
1041
|
-
deps.io.err(`Unknown hook event "${event}". Valid: ${HOOK_EVENTS.join(', ')}`);
|
|
1042
|
-
return EXIT.usage;
|
|
1043
|
-
}
|
|
1044
|
-
// One clock owns the complete Stop invocation, including stdin, config,
|
|
1045
|
-
// credentials, and client construction. Starting this inside `handleStop`
|
|
1046
|
-
// would grant slow setup a second budget and let the harness kill us before
|
|
1047
|
-
// an accepted answer is journaled or written to stdout.
|
|
1048
|
-
const now = deps.now ?? Date.now;
|
|
1049
|
-
// The waiter may spend a long wall clock exactly when no turn is held open
|
|
1050
|
-
// for it, which is the same condition the installer used to declare
|
|
1051
|
-
// `async: true`. One predicate decides it for both.
|
|
1052
|
-
const detachedWaiter = stopHandlerIsDetached(harness, deps.hookPlatform ?? process.platform);
|
|
1053
|
-
const processDeadlineAt = now() + waiterCeilingSeconds(detachedWaiter) * 1000;
|
|
1054
|
-
const logger = log(deps);
|
|
1055
|
-
logger.bind({ cmd: `hook ${event}` });
|
|
1056
|
-
let started = false;
|
|
1057
|
-
const start = (data = {}) => {
|
|
1058
|
-
if (started)
|
|
1059
|
-
return;
|
|
1060
|
-
started = true;
|
|
1061
|
-
logger.info('hook.start', { hook: event, harness: harness ?? 'unknown', ...data });
|
|
1062
|
-
};
|
|
1063
|
-
const failureData = (err) => err instanceof ApiCallError
|
|
1064
|
-
? { status: err.status, code: err.code, message: err.message, details: err.details }
|
|
1065
|
-
: { message: err instanceof Error ? err.message : String(err) };
|
|
1066
|
-
let raw;
|
|
1067
|
-
try {
|
|
1068
|
-
raw = await readStdin();
|
|
1069
|
-
}
|
|
1070
|
-
catch (err) {
|
|
1071
|
-
start({ input: 'unavailable' });
|
|
1072
|
-
logger.error('hook.end', {
|
|
1073
|
-
hook: event,
|
|
1074
|
-
outcome: 'ignored',
|
|
1075
|
-
reason: 'input-read-failed',
|
|
1076
|
-
...failureData(err),
|
|
1077
|
-
});
|
|
1078
|
-
return EXIT.ok;
|
|
1079
|
-
}
|
|
1080
|
-
if (raw.trim() !== '') {
|
|
1081
|
-
try {
|
|
1082
|
-
const parsed = JSON.parse(raw);
|
|
1083
|
-
if (typeof parsed !== 'object' || parsed === null)
|
|
1084
|
-
throw new Error('not an object');
|
|
1085
|
-
}
|
|
1086
|
-
catch {
|
|
1087
|
-
start({ input: 'malformed' });
|
|
1088
|
-
logger.error('hook.end', { hook: event, outcome: 'ignored', reason: 'malformed-input' });
|
|
1089
|
-
deps.io.err('notifai: ignored malformed or truncated hook input; no routing action was taken');
|
|
1090
|
-
return EXIT.ok;
|
|
1091
|
-
}
|
|
1092
|
-
}
|
|
1093
|
-
let envelope = parseHookInput(raw);
|
|
1094
|
-
if (harness === 'cursor') {
|
|
1095
|
-
const sessionId = envelope.session_id ?? envelope.conversation_id;
|
|
1096
|
-
const cwd = envelope.cwd ?? envelope.workspace_roots?.[0];
|
|
1097
|
-
envelope = {
|
|
1098
|
-
...envelope,
|
|
1099
|
-
...(sessionId === undefined ? {} : { session_id: sessionId }),
|
|
1100
|
-
...(cwd === undefined ? {} : { cwd }),
|
|
1101
|
-
stop_hook_active: envelope.stop_hook_active ??
|
|
1102
|
-
(typeof envelope.loop_count === 'number' && envelope.loop_count > 0),
|
|
1103
|
-
};
|
|
1104
|
-
}
|
|
1105
|
-
const cwd = envelope.cwd ?? deps.cwd;
|
|
1106
|
-
const sessionEnd = hookDefersDiagnosticsUntilAfterCleanup(event);
|
|
1107
|
-
logger.bind({ session: envelope.session_id ?? null });
|
|
1108
|
-
let config = null;
|
|
1109
|
-
let configFailure;
|
|
1110
|
-
try {
|
|
1111
|
-
config = loadConfig({ cwd, env: deps.env, sessionId: envelope.session_id });
|
|
1112
|
-
// The hook's project is the session's, not this process's, and the log
|
|
1113
|
-
// settings that apply are that project's too. Keeping a mutable bootstrap
|
|
1114
|
-
// logger lets this more-specific layer turn logging back on.
|
|
1115
|
-
logger.adopt(logSettingsFrom(config));
|
|
1116
|
-
logger.bind({ project: config.project.value });
|
|
1117
|
-
if (!sessionEnd) {
|
|
1118
|
-
start({ cwd, stop_hook_active: envelope.stop_hook_active ?? null });
|
|
1119
|
-
logConfigResolved(logger, config);
|
|
1120
|
-
}
|
|
1121
|
-
}
|
|
1122
|
-
catch (err) {
|
|
1123
|
-
configFailure = err;
|
|
1124
|
-
if (!sessionEnd) {
|
|
1125
|
-
start({ cwd, stop_hook_active: envelope.stop_hook_active ?? null });
|
|
1126
|
-
logger.error('hook.end', {
|
|
1127
|
-
hook: event,
|
|
1128
|
-
outcome: 'failed',
|
|
1129
|
-
reason: 'config-failed',
|
|
1130
|
-
...failureData(err),
|
|
1131
|
-
});
|
|
1132
|
-
for (const line of describeHookFailure(err))
|
|
1133
|
-
deps.io.err(`notifai: ${line}`);
|
|
1134
|
-
return EXIT.ok;
|
|
1135
|
-
}
|
|
1136
|
-
}
|
|
1137
|
-
// Everything below is inside one fail-open boundary. Credential loading,
|
|
1138
|
-
// client construction and hook handling can all throw, and a hook that exits
|
|
1139
|
-
// non-zero makes the harness report a failure — strictly worse than skipping.
|
|
1140
|
-
try {
|
|
1141
|
-
if (sessionEnd) {
|
|
1142
|
-
// Codex gives SessionEnd one second total. Do every durable cleanup write
|
|
1143
|
-
// before lifecycle diagnostics: the log lock is deliberately allowed to
|
|
1144
|
-
// wait that long, and a busy log must never preserve ended-session state
|
|
1145
|
-
// or its inherited configuration. The resolved config above is retained
|
|
1146
|
-
// in memory so logging still uses the ending session's settings afterwards.
|
|
1147
|
-
const outcome = handleSessionEnd(deps.env, envelope, (deps.now ?? Date.now)());
|
|
1148
|
-
start({ cwd, stop_hook_active: envelope.stop_hook_active ?? null });
|
|
1149
|
-
if (config !== null)
|
|
1150
|
-
logConfigResolved(logger, config);
|
|
1151
|
-
const data = { hook: event, decided: false, ...outcome.log };
|
|
1152
|
-
if (configFailure === undefined)
|
|
1153
|
-
logger.info('hook.end', data);
|
|
1154
|
-
else {
|
|
1155
|
-
logger.error('hook.end', {
|
|
1156
|
-
...data,
|
|
1157
|
-
reason: 'config-failed',
|
|
1158
|
-
config_error: failureData(configFailure),
|
|
1159
|
-
});
|
|
1160
|
-
}
|
|
1161
|
-
for (const note of outcome.notes)
|
|
1162
|
-
deps.io.err(`notifai: ${note}`);
|
|
1163
|
-
return EXIT.ok;
|
|
1164
|
-
}
|
|
1165
|
-
// Non-SessionEnd hooks cannot reach here without resolved configuration.
|
|
1166
|
-
const resolved = config;
|
|
1167
|
-
const credential = deps.store.load();
|
|
1168
|
-
if (!credential) {
|
|
1169
|
-
logger.error('hook.end', { hook: event, outcome: 'not-paired' });
|
|
1170
|
-
deps.io.err('notifai: hook skipped: this machine is not paired; run `notifai login`');
|
|
1171
|
-
return EXIT.ok;
|
|
1172
|
-
}
|
|
1173
|
-
// Pin authenticated traffic to the origin the credential was issued for. A
|
|
1174
|
-
// repository can commit `.notifai/config.toml`, and honouring a base_url
|
|
1175
|
-
// from it would hand this machine's bearer token to whatever host it names.
|
|
1176
|
-
const baseUrl = credential.baseUrl;
|
|
1177
|
-
if (resolved.base_url.source !== 'default' && resolved.base_url.value !== baseUrl) {
|
|
1178
|
-
deps.io.err(`notifai: ignoring base_url from ${resolved.base_url.source}; hooks only talk to ${baseUrl}`);
|
|
1179
|
-
}
|
|
1180
|
-
// UserPromptSubmit runs in front of the user's own prompt under a 15s
|
|
1181
|
-
// harness ceiling and can make two calls, so each gets a small slice of it;
|
|
1182
|
-
// Stop is allowed to block and keeps the ordinary budget.
|
|
1183
|
-
const client = makeClient(deps, baseUrl, `Bearer nfm_${credential.machineId}.${credential.secret}`, {
|
|
1184
|
-
timeoutMs: event === 'user-prompt-submit' ? 4_000 : 20_000,
|
|
1185
|
-
...(event === 'stop' ? { deadlineAt: processDeadlineAt, now } : {}),
|
|
1186
|
-
});
|
|
1187
|
-
const ctx = {
|
|
1188
|
-
client,
|
|
1189
|
-
config: resolved,
|
|
1190
|
-
env: deps.env,
|
|
1191
|
-
now,
|
|
1192
|
-
sleep: deps.sleep ?? ((ms) => new Promise((resolve) => setTimeout(resolve, ms))),
|
|
1193
|
-
waitForFirstReply: async (requestId, timeoutSeconds) => {
|
|
1194
|
-
const result = await waitForReply(client, requestId, {
|
|
1195
|
-
timeoutSeconds,
|
|
1196
|
-
afterSeq: 0,
|
|
1197
|
-
now: deps.now,
|
|
1198
|
-
sleep: deps.sleep,
|
|
1199
|
-
});
|
|
1200
|
-
return {
|
|
1201
|
-
replies: result.response.replies,
|
|
1202
|
-
timedOut: result.timedOut,
|
|
1203
|
-
degraded: result.degraded,
|
|
1204
|
-
};
|
|
1205
|
-
},
|
|
1206
|
-
log: logger,
|
|
1207
|
-
...(harness === undefined ? {} : { harness }),
|
|
1208
|
-
};
|
|
1209
|
-
// Real clock, deliberately, not `deps.now`. This compares against file
|
|
1210
|
-
// mtimes, which are wall-clock facts — handing it a virtual or skewed clock
|
|
1211
|
-
// would have it delete live session state as "abandoned".
|
|
1212
|
-
// Daily state pruning is housekeeping, not part of the Stop delivery
|
|
1213
|
-
// contract. Its directory scan has no useful bound, so keep it on the
|
|
1214
|
-
// short prompt path and never spend the answer owner's finite budget on it.
|
|
1215
|
-
if (event !== 'stop')
|
|
1216
|
-
pruneAbandonedSessions(deps.env);
|
|
1217
|
-
const outcome = event === 'user-prompt-submit'
|
|
1218
|
-
? await handleUserPromptSubmit(ctx, envelope)
|
|
1219
|
-
: await handleStop(ctx, envelope, processDeadlineAt, stopWakeRoute(deps, harness, envelope.session_id, cwd));
|
|
1220
|
-
// Answer diagnostics are already persisted once as hook.answer. Keep every
|
|
1221
|
-
// other note in the lifecycle record without duplicating the user's text.
|
|
1222
|
-
const notes = outcome.notes.filter((note) => !/^(?:late )?answer from /.test(note));
|
|
1223
|
-
logger.info('hook.end', {
|
|
1224
|
-
hook: event,
|
|
1225
|
-
// A hook that returns stdout has taken over the turn; one that does not
|
|
1226
|
-
// has handed the terminal back. That distinction is the whole contract.
|
|
1227
|
-
decided: outcome.stdout !== undefined,
|
|
1228
|
-
...(notes.length === 0 ? {} : { notes }),
|
|
1229
|
-
...outcome.log,
|
|
1230
|
-
});
|
|
1231
|
-
for (const note of outcome.notes)
|
|
1232
|
-
deps.io.err(`notifai: ${note}`);
|
|
1233
|
-
if (outcome.stdout !== undefined)
|
|
1234
|
-
deps.io.out(outcome.stdout);
|
|
1235
|
-
return EXIT.ok;
|
|
1236
|
-
}
|
|
1237
|
-
catch (err) {
|
|
1238
|
-
// SessionEnd defers its start record until after cleanup; if cleanup itself
|
|
1239
|
-
// fails, begin the after-the-fact lifecycle here before recording why.
|
|
1240
|
-
if (sessionEnd)
|
|
1241
|
-
start({ cwd, stop_hook_active: envelope.stop_hook_active ?? null });
|
|
1242
|
-
// The hook still exits 0 — handing the terminal back is always right. What
|
|
1243
|
-
// this adds is that the reason survives, including the server's own words.
|
|
1244
|
-
logger.error('hook.end', {
|
|
1245
|
-
hook: event,
|
|
1246
|
-
outcome: 'failed',
|
|
1247
|
-
reason: 'execution-failed',
|
|
1248
|
-
...failureData(err),
|
|
1249
|
-
});
|
|
1250
|
-
for (const line of describeHookFailure(err))
|
|
1251
|
-
deps.io.err(`notifai: ${line}`);
|
|
1252
|
-
return EXIT.ok;
|
|
1253
|
-
}
|
|
1254
|
-
}
|
|
1255
|
-
/**
|
|
1256
|
-
* The last meter for an answer this Stop hook accepted, chosen by harness.
|
|
1257
|
-
*
|
|
1258
|
-
* Both wake adapters need the harness process that invoked this hook: Claude's
|
|
1259
|
-
* to prove exact own-child session ownership before it posts to the inbox
|
|
1260
|
-
* socket, Codex's to know whether its own stdout is still a live continuation
|
|
1261
|
-
* channel. Without an exact session id neither can prove anything, so the
|
|
1262
|
-
* waiter falls back to the plain blocking Stop continuation.
|
|
1263
|
-
*/
|
|
1264
|
-
function stopWakeRoute(deps, harness, sessionId, cwd) {
|
|
1265
|
-
if (sessionId === undefined)
|
|
1266
|
-
return undefined;
|
|
1267
|
-
if (harness === 'claude-code') {
|
|
1268
|
-
if ((deps.hookPlatform ?? process.platform) === 'win32')
|
|
1269
|
-
return undefined;
|
|
1270
|
-
return claudeWakeRoute({
|
|
1271
|
-
sessionId,
|
|
1272
|
-
cwd,
|
|
1273
|
-
sourcePid: deps.claudeSourcePid ?? claudeSessionPid(deps.env),
|
|
1274
|
-
...(deps.claudeWake === undefined ? {} : { adapters: deps.claudeWake }),
|
|
1275
|
-
});
|
|
1276
|
-
}
|
|
1277
|
-
if (harness === 'codex') {
|
|
1278
|
-
return codexWakeRoute({
|
|
1279
|
-
threadId: sessionId,
|
|
1280
|
-
cwd,
|
|
1281
|
-
sourcePid: deps.codexSourcePid ?? process.ppid,
|
|
1282
|
-
env: deps.env,
|
|
1283
|
-
...(deps.codexWake === undefined ? {} : { adapters: deps.codexWake }),
|
|
1284
|
-
});
|
|
1285
|
-
}
|
|
1286
|
-
return undefined;
|
|
1287
|
-
}
|
|
1288
|
-
/**
|
|
1289
|
-
* The pid of the Claude Code session this hook belongs to.
|
|
1290
|
-
*
|
|
1291
|
-
* Claude exports `CLAUDE_PID` to its hooks, and that is the authoritative
|
|
1292
|
-
* answer. `process.ppid` agrees with it today — the hook command runs through
|
|
1293
|
-
* a shell, but the shell `exec`s, so this process's parent *is* the session
|
|
1294
|
-
* (probed against a live 2.1.228 session) — and it remains the fallback for a
|
|
1295
|
-
* harness build that stops exporting the variable.
|
|
1296
|
-
*
|
|
1297
|
-
* Preferring the explicit value costs a line and removes a silent failure: if
|
|
1298
|
-
* this ever resolved to a shell instead, no session descriptor would match, the
|
|
1299
|
-
* route could not prove own-child ownership, and every answer would degrade to
|
|
1300
|
-
* hold-for-next-turn with nothing reported as wrong.
|
|
1301
|
-
*/
|
|
1302
|
-
function claudeSessionPid(env) {
|
|
1303
|
-
const declared = Number(env['CLAUDE_PID']);
|
|
1304
|
-
return Number.isInteger(declared) && declared > 0 ? declared : process.ppid;
|
|
1305
|
-
}
|
|
1306
|
-
/**
|
|
1307
|
-
* What went wrong, in terms of what to do about it.
|
|
1308
|
-
*
|
|
1309
|
-
* On 2026-08-03 a contract change shipped without the server deploy that goes
|
|
1310
|
-
* with it. The CLI stamped `lifecycle` on every question draft, the deployed
|
|
1311
|
-
* server rejected the unknown field, and escalation stopped working entirely —
|
|
1312
|
-
* announced as "hook failed, deferring to the terminal", which reads like a
|
|
1313
|
-
* flaky network. The information needed to diagnose it in one second was
|
|
1314
|
-
* already in hand: a 422 whose details name the offending path. It was being
|
|
1315
|
-
* thrown away by `String(err)`.
|
|
1316
|
-
*
|
|
1317
|
-
* A hook still exits 0 whatever this says. Handing the terminal back is always
|
|
1318
|
-
* right; the only question is whether the user is told anything they can use.
|
|
1319
|
-
*/
|
|
1320
|
-
export function describeHookFailure(err) {
|
|
1321
|
-
if (!(err instanceof ApiCallError)) {
|
|
1322
|
-
return [`hook failed, deferring to the terminal (${String(err)})`];
|
|
1323
|
-
}
|
|
1324
|
-
const lines = [`hook failed, deferring to the terminal (${err.code}: ${err.message})`];
|
|
1325
|
-
const paths = rejectedPaths(err.details);
|
|
1326
|
-
if (paths.length > 0)
|
|
1327
|
-
lines.push(`the server rejected: ${paths.join(', ')}`);
|
|
1328
|
-
// A 422 on a draft this CLI built is not a user error — this CLI's own
|
|
1329
|
-
// contract produced it. Either the server is behind, or the two disagree.
|
|
1330
|
-
if (err.status === 422) {
|
|
1331
|
-
lines.push('this build sent a field the server did not accept, which usually means the server ' +
|
|
1332
|
-
'is older than this CLI — check with `notifai doctor`');
|
|
1333
|
-
}
|
|
1334
|
-
return lines;
|
|
1335
|
-
}
|
|
1336
|
-
function rejectedPaths(details) {
|
|
1337
|
-
if (!Array.isArray(details))
|
|
1338
|
-
return [];
|
|
1339
|
-
return details
|
|
1340
|
-
.map((issue) => typeof issue === 'object' && issue !== null && typeof issue.path === 'string'
|
|
1341
|
-
? issue.path
|
|
1342
|
-
: null)
|
|
1343
|
-
.filter((path) => path !== null && path !== '')
|
|
1344
|
-
.slice(0, 5);
|
|
1345
|
-
}
|
|
1346
|
-
/**
|
|
1347
|
-
* Turn ask input into questions plus their canonical body. Everything is
|
|
1348
|
-
* validated at registration because a later hook failure is easy to miss.
|
|
1349
|
-
*/
|
|
1350
|
-
export function buildQuestions(flags, question) {
|
|
1351
|
-
if (flags.form !== undefined) {
|
|
1352
|
-
if (question !== undefined || flags.choice?.length || flags.multi) {
|
|
1353
|
-
return { ok: false, error: '--form replaces the positional question, --choice, and --multi.' };
|
|
1354
|
-
}
|
|
1355
|
-
let parsed;
|
|
1356
|
-
try {
|
|
1357
|
-
parsed = JSON.parse(flags.form);
|
|
1358
|
-
}
|
|
1359
|
-
catch {
|
|
1360
|
-
return {
|
|
1361
|
-
ok: false,
|
|
1362
|
-
error: '--form must be JSON: {"questions": [{"text", "choices"?, "multi"?}], "body"?}.',
|
|
1363
|
-
};
|
|
1364
|
-
}
|
|
1365
|
-
if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
|
|
1366
|
-
return { ok: false, error: `--form needs a "questions" array (1-${REPLY_MAX_QUESTIONS} entries).` };
|
|
1367
|
-
}
|
|
1368
|
-
const record = parsed;
|
|
1369
|
-
const unknownKeys = Object.keys(record).filter((key) => key !== 'questions' && key !== 'body');
|
|
1370
|
-
if (unknownKeys.length > 0) {
|
|
1371
|
-
return {
|
|
1372
|
-
ok: false,
|
|
1373
|
-
error: `Unknown --form ${unknownKeys.length === 1 ? 'key' : 'keys'}: ${unknownKeys.join(', ')}. Use "body" for Markdown context.`,
|
|
1374
|
-
};
|
|
1375
|
-
}
|
|
1376
|
-
if (!Array.isArray(record['questions'])) {
|
|
1377
|
-
return { ok: false, error: `--form needs a "questions" array (1-${REPLY_MAX_QUESTIONS} entries).` };
|
|
1378
|
-
}
|
|
1379
|
-
const formQuestions = record['questions'];
|
|
1380
|
-
if (formQuestions.length < 1 || formQuestions.length > REPLY_MAX_QUESTIONS) {
|
|
1381
|
-
return {
|
|
1382
|
-
ok: false,
|
|
1383
|
-
error: `A form asks 1-${REPLY_MAX_QUESTIONS} questions; this one has ${formQuestions.length}.`,
|
|
1384
|
-
};
|
|
1385
|
-
}
|
|
1386
|
-
if (record['body'] !== undefined && typeof record['body'] !== 'string') {
|
|
1387
|
-
return { ok: false, error: '"body" must be a Markdown string.' };
|
|
1388
|
-
}
|
|
1389
|
-
if (flags.body !== undefined && record['body'] !== undefined) {
|
|
1390
|
-
return { ok: false, error: 'Pass form context in either --body or the form "body" key, not both.' };
|
|
1391
|
-
}
|
|
1392
|
-
const questions = [];
|
|
1393
|
-
const usedIds = new Set();
|
|
1394
|
-
for (const [index, entry] of formQuestions.entries()) {
|
|
1395
|
-
if (typeof entry !== 'object' || entry === null || typeof entry.text !== 'string') {
|
|
1396
|
-
return { ok: false, error: `Question ${index + 1} needs a "text" string.` };
|
|
1397
|
-
}
|
|
1398
|
-
const spec = entry;
|
|
1399
|
-
const built = buildOneQuestion(spec.text, spec.choices, spec.multi === true, index, usedIds);
|
|
1400
|
-
if ('error' in built)
|
|
1401
|
-
return { ok: false, error: `Question ${index + 1}: ${built.error}` };
|
|
1402
|
-
questions.push(built.question);
|
|
1403
|
-
}
|
|
1404
|
-
const context = flags.body ?? record['body'];
|
|
1405
|
-
const questionBlock = questions.map((entry, index) => `${index + 1}. ${entry.text}`).join('\n');
|
|
1406
|
-
return {
|
|
1407
|
-
ok: true,
|
|
1408
|
-
questions,
|
|
1409
|
-
body: context !== undefined && context.trim() !== ''
|
|
1410
|
-
? `${questionBlock}\n\n${context}`
|
|
1411
|
-
: questionBlock,
|
|
1412
|
-
};
|
|
1413
|
-
}
|
|
1414
|
-
if (question === undefined || question.trim() === '') {
|
|
1415
|
-
return { ok: false, error: 'The question cannot be empty.' };
|
|
1416
|
-
}
|
|
1417
|
-
const built = buildOneQuestion(question, flags.choice, flags.multi === true, 0, new Set());
|
|
1418
|
-
if ('error' in built)
|
|
1419
|
-
return { ok: false, error: built.error };
|
|
1420
|
-
const context = flags.body;
|
|
1421
|
-
return {
|
|
1422
|
-
ok: true,
|
|
1423
|
-
questions: [built.question],
|
|
1424
|
-
body: context !== undefined && context.trim() !== ''
|
|
1425
|
-
? `${built.question.text}\n\n${context}`
|
|
1426
|
-
: built.question.text,
|
|
1427
|
-
};
|
|
1428
|
-
}
|
|
1429
|
-
function buildOneQuestion(text, choiceLabels, multi, index, usedIds) {
|
|
1430
|
-
const trimmed = text.trim();
|
|
1431
|
-
if (trimmed === '')
|
|
1432
|
-
return { error: 'the question text cannot be empty.' };
|
|
1433
|
-
if (trimmed.length > QUESTION_TEXT_MAX_LENGTH) {
|
|
1434
|
-
return {
|
|
1435
|
-
error: `a question must be readable where it is answered: keep it within ` +
|
|
1436
|
-
`${QUESTION_TEXT_MAX_LENGTH} characters and put the longer context in --body.`,
|
|
1437
|
-
};
|
|
1438
|
-
}
|
|
1439
|
-
const choices = parseChoices(choiceLabels);
|
|
1440
|
-
if (choices === 'invalid')
|
|
1441
|
-
return { error: CHOICE_USAGE };
|
|
1442
|
-
if (multi && choices === null) {
|
|
1443
|
-
return { error: '--multi needs answers to select between; add --choice.' };
|
|
1444
|
-
}
|
|
1445
|
-
let id = slugify(trimmed);
|
|
1446
|
-
if (id === '' || usedIds.has(id))
|
|
1447
|
-
id = `q${index + 1}`;
|
|
1448
|
-
usedIds.add(id);
|
|
1449
|
-
return {
|
|
1450
|
-
question: {
|
|
1451
|
-
id,
|
|
1452
|
-
text: trimmed,
|
|
1453
|
-
...(choices !== null ? { choices } : {}),
|
|
1454
|
-
...(multi ? { multi: true } : {}),
|
|
1455
|
-
},
|
|
1456
|
-
};
|
|
1457
|
-
}
|
|
1458
|
-
function buildAskDraft(config, built, flags, invocation, mediaIds) {
|
|
1459
|
-
const result = buildDraft(config, {
|
|
1460
|
-
title: built.questions[0].text,
|
|
1461
|
-
body: built.body,
|
|
1462
|
-
...(flags.project !== undefined ? { project: flags.project } : {}),
|
|
1463
|
-
...(mediaIds.length > 0 ? { image: mediaIds } : {}),
|
|
1464
|
-
...(flags.imageAlt !== undefined ? { imageAlt: flags.imageAlt } : {}),
|
|
1465
|
-
reply: true,
|
|
1466
|
-
questions: built.questions,
|
|
1467
|
-
}, invocation);
|
|
1468
|
-
if (!result.ok)
|
|
1469
|
-
return result;
|
|
1470
|
-
const capabilities = CAPABILITIES_V1.describe(result.platform);
|
|
1471
|
-
if (capabilities === null)
|
|
1472
|
-
return { ok: false, error: 'No iOS capability contract is available.' };
|
|
1473
|
-
const validation = validateDraft(result.draft, capabilities);
|
|
1474
|
-
if (!validation.ok) {
|
|
1475
|
-
return {
|
|
1476
|
-
ok: false,
|
|
1477
|
-
error: validation.errors.map((issue) => `${issue.path}: ${issue.message}`).join('\n'),
|
|
1478
|
-
};
|
|
1479
|
-
}
|
|
1480
|
-
return { ok: true, draft: result.draft };
|
|
1481
|
-
}
|
|
1482
|
-
function recordRegisteredQuestion(deps, sessionId, built, draft, json = false) {
|
|
1483
|
-
try {
|
|
1484
|
-
registerQuestion(sessionId, deps.env, {
|
|
1485
|
-
question: built.questions[0].text,
|
|
1486
|
-
questions: built.questions,
|
|
1487
|
-
body: draft.presentation.body,
|
|
1488
|
-
...(draft.project !== undefined ? { project: draft.project } : {}),
|
|
1489
|
-
...(draft.source !== undefined ? { source: draft.source } : {}),
|
|
1490
|
-
...(draft.presentation.media !== undefined ? { media: draft.presentation.media } : {}),
|
|
1491
|
-
}, (deps.now ?? Date.now)());
|
|
1492
|
-
}
|
|
1493
|
-
catch (err) {
|
|
1494
|
-
log(deps).error('ask.registered', { ok: false, session: sessionId, message: String(err) });
|
|
1495
|
-
deps.io.err(`Could not register the question: ${err instanceof Error ? err.message : String(err)}`);
|
|
1496
|
-
return EXIT.failed;
|
|
1497
|
-
}
|
|
1498
|
-
log(deps).info('ask.registered', {
|
|
1499
|
-
ok: true,
|
|
1500
|
-
session: sessionId,
|
|
1501
|
-
questions: built.questions.length,
|
|
1502
|
-
text: built.questions[0].text,
|
|
1503
|
-
choices: built.questions[0].choices?.length ?? 0,
|
|
1504
|
-
media: draft.presentation.media?.length ?? 0,
|
|
1505
|
-
});
|
|
1506
|
-
// The block below is the densest guidance this CLI prints, and until now it
|
|
1507
|
-
// was prose only: an agent could not read back the choice ids it must branch
|
|
1508
|
-
// on without asking the server for them. The JSON form carries the same
|
|
1509
|
-
// obligation as data.
|
|
1510
|
-
if (json) {
|
|
1511
|
-
deps.io.out(JSON.stringify({
|
|
1512
|
-
registered: true,
|
|
1513
|
-
questions: built.questions.map((entry) => ({
|
|
1514
|
-
id: entry.id,
|
|
1515
|
-
text: entry.text,
|
|
1516
|
-
...(entry.choices === undefined ? {} : { choices: entry.choices }),
|
|
1517
|
-
...(entry.multi === true ? { multi: true } : {}),
|
|
1518
|
-
})),
|
|
1519
|
-
next: {
|
|
1520
|
-
end_turn: true,
|
|
1521
|
-
in_this_turn: 'Ask the question in the conversation and say what concrete work each possible answer will make you resume, then end the turn.',
|
|
1522
|
-
route_neutral: 'Never say where the answer must arrive; it returns by whatever route the harness supports.',
|
|
1523
|
-
on_answer: 'Acknowledge it, then resume the committed work without asking the user to confirm again.',
|
|
1524
|
-
},
|
|
1525
|
-
}, null, 2));
|
|
1526
|
-
return EXIT.ok;
|
|
1527
|
-
}
|
|
1528
|
-
for (const [index, entry] of built.questions.entries()) {
|
|
1529
|
-
const prefix = built.questions.length > 1 ? `${index + 1}. ` : '';
|
|
1530
|
-
if (entry.choices !== undefined) {
|
|
1531
|
-
const kind = entry.multi === true ? 'answers offered (several may be chosen)' : 'answers offered';
|
|
1532
|
-
deps.io.out(`${prefix}${entry.text} — ${kind}: ${entry.choices.map((choice) => choice.label).join(' / ')}`);
|
|
1533
|
-
}
|
|
1534
|
-
else if (built.questions.length > 1) {
|
|
1535
|
-
deps.io.out(`${prefix}${entry.text} — free text`);
|
|
1536
|
-
}
|
|
1537
|
-
}
|
|
1538
|
-
deps.io.out(built.questions.length > 1
|
|
1539
|
-
? `${built.questions.length} questions registered as one form. Ask them in the conversation, state the concrete work you will resume for their answers, then end your turn.`
|
|
1540
|
-
: 'Question registered. Ask it in the conversation, state the concrete work you will resume when the answer arrives, then end your turn.');
|
|
1541
|
-
deps.io.out('Before ending this turn, pre-commit in your own words to the work you will resume:');
|
|
1542
|
-
for (const [index, entry] of built.questions.entries()) {
|
|
1543
|
-
const questionPrefix = built.questions.length > 1 ? `Question ${index + 1}, ` : '';
|
|
1544
|
-
if (entry.choices !== undefined) {
|
|
1545
|
-
for (const choice of entry.choices) {
|
|
1546
|
-
deps.io.out(`- ${questionPrefix}If the answer is ${JSON.stringify(choice.label)}: state the concrete work you will resume.`);
|
|
1547
|
-
}
|
|
1548
|
-
deps.io.out(`- ${questionPrefix}For an unexpected typed answer: state how it will determine the concrete work you resume.`);
|
|
1549
|
-
}
|
|
1550
|
-
else {
|
|
1551
|
-
deps.io.out(`- ${questionPrefix}For the free-text answer: state how its content will determine the concrete work you resume.`);
|
|
1552
|
-
}
|
|
1553
|
-
}
|
|
1554
|
-
deps.io.out('When the answer arrives, resume the matching work without asking the user to confirm again. Frame this as work you will resume, not as approval you receive.');
|
|
1555
|
-
deps.io.out('A Notifai answer cannot answer a harness permission prompt or interactive picker; leave those to the harness and user.');
|
|
1556
|
-
return EXIT.ok;
|
|
1557
|
-
}
|
|
1558
|
-
async function uploadAskMedia(deps, config, sessionId, built, flags, invocation) {
|
|
1559
|
-
const authed = authedClient(deps, config);
|
|
1560
|
-
if (!authed)
|
|
1561
|
-
return EXIT.auth;
|
|
1562
|
-
const mediaIds = [];
|
|
1563
|
-
for (const image of flags.image ?? []) {
|
|
1564
|
-
if (image.startsWith('med_')) {
|
|
1565
|
-
mediaIds.push(image);
|
|
1566
|
-
continue;
|
|
1567
|
-
}
|
|
1568
|
-
const uploaded = await uploadImage(deps, authed.client, image);
|
|
1569
|
-
if (!uploaded.ok) {
|
|
1570
|
-
if (uploaded.error !== null)
|
|
1571
|
-
deps.io.err(uploaded.error);
|
|
1572
|
-
return uploaded.exit;
|
|
1573
|
-
}
|
|
1574
|
-
mediaIds.push(uploaded.mediaId);
|
|
1575
|
-
}
|
|
1576
|
-
const ready = buildAskDraft(config, built, flags, invocation, mediaIds);
|
|
1577
|
-
if (!ready.ok) {
|
|
1578
|
-
deps.io.err(ready.error);
|
|
1579
|
-
return EXIT.usage;
|
|
1580
|
-
}
|
|
1581
|
-
return recordRegisteredQuestion(deps, sessionId, built, ready.draft, flags.json === true);
|
|
1582
|
-
}
|
|
1583
|
-
/**
|
|
1584
|
-
* Registers a question for turn-end routing. Returns immediately so the agent
|
|
1585
|
-
* can ask in prose and end its turn; the terminal keeps the question to itself
|
|
1586
|
-
* for `ask_grace_seconds` before it reaches any device.
|
|
1587
|
-
*/
|
|
1588
|
-
export function askCommand(deps, question, flags) {
|
|
1589
|
-
// Validate before route discovery. A malformed question belongs to the
|
|
1590
|
-
// caller and should not be hidden behind whichever harness setup issue
|
|
1591
|
-
// happens to exist on this machine.
|
|
1592
|
-
const built = buildQuestions(flags, question);
|
|
1593
|
-
if (!built.ok) {
|
|
1594
|
-
deps.io.err(built.error);
|
|
1595
|
-
return EXIT.usage;
|
|
1596
|
-
}
|
|
1597
|
-
const mediaInputError = validateMediaInputs(flags.image, flags.imageAlt);
|
|
1598
|
-
if (mediaInputError !== null) {
|
|
1599
|
-
deps.io.err(mediaInputError);
|
|
1600
|
-
return EXIT.usage;
|
|
1601
|
-
}
|
|
1602
|
-
const routingConfig = loadConfig({ cwd: deps.cwd, env: deps.env });
|
|
1603
|
-
if (!routingConfig.ask_notifications.value) {
|
|
1604
|
-
deps.io.err('Question routing is disabled by ask_notifications=false; enable it or use a blocking `notifai send --reply` question.');
|
|
1605
|
-
return EXIT.usage;
|
|
1606
|
-
}
|
|
1607
|
-
if (deps.store.load() === null) {
|
|
1608
|
-
deps.io.err('Question routing is not paired on this machine; run `notifai login` before registering an asynchronous question.');
|
|
1609
|
-
return EXIT.usage;
|
|
1610
|
-
}
|
|
1611
|
-
// An agent calling this gets no hook payload. Harness-native environment
|
|
1612
|
-
// markers identify the active owner, while UserPromptSubmit adds the hook's
|
|
1613
|
-
// canonical id to the directory's concurrent-session index.
|
|
1614
|
-
const now = (deps.now ?? Date.now)();
|
|
1615
|
-
const { active, contested } = resolveActiveHarness(deps.env, deps.cwd, now);
|
|
1616
|
-
let sessionId;
|
|
1617
|
-
if (active !== null) {
|
|
1618
|
-
const installations = findInstallations(deps.cwd, deps.env, deps.hookAdapterHome, deps.hookPlatform);
|
|
1619
|
-
const activeInstalled = installations.some((installation) => installation.harness === active.harness);
|
|
1620
|
-
if (!activeInstalled) {
|
|
1621
|
-
for (const line of diagnoseActiveHarnessSession(active, 'not-installed', installations, contested)) {
|
|
1622
|
-
deps.io.err(line);
|
|
1623
|
-
}
|
|
1624
|
-
return EXIT.usage;
|
|
1625
|
-
}
|
|
1626
|
-
if (active.sessionId === undefined) {
|
|
1627
|
-
for (const problem of activeQuestionRouteProblems(deps, active, installations)) {
|
|
1628
|
-
deps.io.err(`Question routing is not ready: ${problem}`);
|
|
1629
|
-
}
|
|
1630
|
-
return EXIT.usage;
|
|
1631
|
-
}
|
|
1632
|
-
const projectPointer = readMatchingProjectSessionPointer(deps.cwd, deps.env, now, active.sessionId, active.harness);
|
|
1633
|
-
if (projectPointer === null) {
|
|
1634
|
-
for (const line of diagnoseActiveHarnessSession(active, 'not-fired', installations, contested)) {
|
|
1635
|
-
deps.io.err(line);
|
|
1636
|
-
}
|
|
1637
|
-
return EXIT.usage;
|
|
1638
|
-
}
|
|
1639
|
-
const routeProblems = activeQuestionRouteProblems(deps, active, installations);
|
|
1640
|
-
if (routeProblems.length > 0) {
|
|
1641
|
-
for (const problem of routeProblems)
|
|
1642
|
-
deps.io.err(`Question routing is not ready: ${problem}`);
|
|
1643
|
-
return EXIT.usage;
|
|
1644
|
-
}
|
|
1645
|
-
if (readSessionState(projectPointer.sessionId, deps.env).last_stop_at === undefined) {
|
|
1646
|
-
deps.io.err(`Question routing is not ready: this ${active.label} session has fired UserPromptSubmit, but its Stop hook has not been observed. End one harmless turn, send a new prompt, then run \`notifai doctor\`.`);
|
|
1647
|
-
return EXIT.usage;
|
|
1648
|
-
}
|
|
1649
|
-
sessionId = projectPointer.sessionId;
|
|
1650
|
-
}
|
|
1651
|
-
else {
|
|
1652
|
-
for (const line of diagnoseMissingSession(deps))
|
|
1653
|
-
deps.io.err(line);
|
|
1654
|
-
deps.io.err('Could not prove which live harness session owns this command, so Notifai will not register a question that could be delivered to the wrong or already-ended agent. Run it from a supported harness with exact session identity, or use a blocking `notifai send --reply` question.');
|
|
1655
|
-
return EXIT.usage;
|
|
1656
|
-
}
|
|
1657
|
-
if (!sessionId) {
|
|
1658
|
-
for (const line of diagnoseMissingSession(deps))
|
|
1659
|
-
deps.io.err(line);
|
|
1660
|
-
return EXIT.usage;
|
|
1661
|
-
}
|
|
1662
|
-
const source = resolveDraftInvocation(deps, flags, active);
|
|
1663
|
-
if (!source.ok) {
|
|
1664
|
-
deps.io.err(source.error);
|
|
1665
|
-
return EXIT.usage;
|
|
1666
|
-
}
|
|
1667
|
-
if (source.invocation.source?.session_id !== sessionId) {
|
|
1668
|
-
deps.io.err(`Question routing is not ready: --session-id or NOTIFAI_SESSION_ID does not match the exact active ${active.label} session; refusing cross-session routing.`);
|
|
1669
|
-
return EXIT.usage;
|
|
1670
|
-
}
|
|
1671
|
-
// Placeholders let every body, source, project, media, and payload limit fail
|
|
1672
|
-
// before an upload starts. The real ids replace them only after this passes.
|
|
1673
|
-
const placeholders = (flags.image ?? []).map((_, index) => `med_pending_${index + 1}`);
|
|
1674
|
-
const preflight = buildAskDraft(routingConfig, built, flags, source.invocation, placeholders);
|
|
1675
|
-
if (!preflight.ok) {
|
|
1676
|
-
deps.io.err(preflight.error);
|
|
1677
|
-
return EXIT.usage;
|
|
1678
|
-
}
|
|
1679
|
-
if ((flags.image?.length ?? 0) > 0) {
|
|
1680
|
-
return uploadAskMedia(deps, routingConfig, sessionId, built, flags, source.invocation);
|
|
1681
|
-
}
|
|
1682
|
-
return recordRegisteredQuestion(deps, sessionId, built, preflight.draft, flags.json === true);
|
|
1683
|
-
}
|
|
1684
|
-
/**
|
|
1685
|
-
* Every harness marker present in this environment, in declared order.
|
|
1686
|
-
*
|
|
1687
|
-
* Order here is a last resort, not an answer. A harness exports its markers
|
|
1688
|
-
* into every process it starts, so a nested harness inherits its parent's
|
|
1689
|
-
* markers alongside its own and the environment alone cannot say which of them
|
|
1690
|
-
* owns this shell; `resolveActiveHarness` settles that with live evidence.
|
|
1691
|
-
*/
|
|
1692
|
-
function harnessEnvCandidates(env) {
|
|
1693
|
-
const candidates = [];
|
|
1694
|
-
if (env['NOTIFAI_ACTIVE_HARNESS'] === 'opencode') {
|
|
1695
|
-
const sessionId = env['NOTIFAI_ACTIVE_SESSION_ID'];
|
|
1696
|
-
candidates.push({
|
|
1697
|
-
harness: 'opencode',
|
|
1698
|
-
label: 'OpenCode',
|
|
1699
|
-
...(sessionId === undefined || sessionId === '' ? {} : { sessionId }),
|
|
1700
|
-
});
|
|
1701
|
-
}
|
|
1702
|
-
if (env['CLAUDECODE'] === '1') {
|
|
1703
|
-
const sessionId = env['CLAUDE_CODE_SESSION_ID'];
|
|
1704
|
-
candidates.push({
|
|
1705
|
-
harness: 'claude-code',
|
|
1706
|
-
label: 'Claude Code',
|
|
1707
|
-
...(sessionId === undefined || sessionId === '' ? {} : { sessionId }),
|
|
1708
|
-
});
|
|
1709
|
-
}
|
|
1710
|
-
const codexSession = env['CODEX_THREAD_ID'];
|
|
1711
|
-
if (codexSession !== undefined && codexSession !== '') {
|
|
1712
|
-
candidates.push({ harness: 'codex', label: 'Codex', sessionId: codexSession });
|
|
1713
|
-
}
|
|
1714
|
-
if ((env['CURSOR_AGENT'] ?? '') !== '')
|
|
1715
|
-
candidates.push({ harness: 'cursor', label: 'Cursor' });
|
|
1716
|
-
return candidates;
|
|
1717
|
-
}
|
|
1718
|
-
/**
|
|
1719
|
-
* Which harness session owns this shell, when several claim to.
|
|
1720
|
-
*
|
|
1721
|
-
* Nesting is ordinary: an orchestrator running inside Claude Code starts a
|
|
1722
|
-
* Codex session, and that Codex process inherits `CLAUDECODE` and
|
|
1723
|
-
* `CLAUDE_CODE_SESSION_ID` on top of its own `CODEX_THREAD_ID`. The mirror is
|
|
1724
|
-
* just as ordinary, so no fixed precedence between two markers can be right —
|
|
1725
|
-
* whichever one it favours is wrong in the opposite nesting, and the cost is
|
|
1726
|
-
* silent: `ask` looks up a session that is not this one, and every remedy the
|
|
1727
|
-
* agent is told to try addresses a harness that is not running here.
|
|
1728
|
-
*
|
|
1729
|
-
* The general rule is to prefer the most specific *live* signal over inherited
|
|
1730
|
-
* environment. An inherited marker is a claim about some ancestor process; a
|
|
1731
|
-
* session id that names an entry in this directory's live pointer index is
|
|
1732
|
-
* evidence that that exact session fired a hook here and its state still
|
|
1733
|
-
* exists. Live evidence therefore wins over declared order, and the most
|
|
1734
|
-
* recently active pointer wins between two live candidates: the harness whose
|
|
1735
|
-
* turn is running is the one that fired last, while its parent sits blocked on
|
|
1736
|
-
* the child it started. Declared order decides only when nothing here has
|
|
1737
|
-
* fired yet, and it says so — every route fails closed there anyway.
|
|
1738
|
-
*/
|
|
1739
|
-
function resolveActiveHarness(env, cwd, now) {
|
|
1740
|
-
const candidates = harnessEnvCandidates(env);
|
|
1741
|
-
const first = candidates[0];
|
|
1742
|
-
if (first === undefined)
|
|
1743
|
-
return { active: null, contested: [] };
|
|
1744
|
-
if (candidates.length === 1)
|
|
1745
|
-
return { active: first, contested: [] };
|
|
1746
|
-
for (const pointer of readLiveProjectSessionPointers(cwd, env, now)) {
|
|
1747
|
-
const owner = candidates.find((candidate) => candidate.harness === pointer.harness && candidate.sessionId === pointer.sessionId);
|
|
1748
|
-
if (owner !== undefined)
|
|
1749
|
-
return { active: owner, contested: [] };
|
|
1750
|
-
}
|
|
1751
|
-
return { active: first, contested: candidates };
|
|
1752
|
-
}
|
|
1753
|
-
function activeHarnessSession(env, cwd, now) {
|
|
1754
|
-
return resolveActiveHarness(env, cwd, now).active;
|
|
1755
|
-
}
|
|
1756
|
-
/**
|
|
1757
|
-
* One fail-closed admission gate shared by every active harness route.
|
|
1758
|
-
* Finding a file is not readiness: the exact session, one current definition,
|
|
1759
|
-
* the stable adapter, a long enough Stop owner, and (for Codex) trust must all
|
|
1760
|
-
* be true before `ask` is allowed to create an answerable notification.
|
|
1761
|
-
*/
|
|
1762
|
-
function activeQuestionRouteProblems(deps, active, installations) {
|
|
1763
|
-
const problems = [];
|
|
1764
|
-
if (active.sessionId === undefined) {
|
|
1765
|
-
problems.push(`the active ${active.label} shell does not expose an exact session id; a project-level last-writer pointer can cross-wire two sessions. Use a blocking \`notifai send --reply\` question`);
|
|
1766
|
-
}
|
|
1767
|
-
const capability = HARNESS_CAPABILITIES[active.harness];
|
|
1768
|
-
if (capability.stopContinuation === 'unsupported') {
|
|
1769
|
-
problems.push(`${active.label}: ${capability.deliveryContract}`);
|
|
1770
|
-
}
|
|
1771
|
-
const matching = installations.filter((installation) => installation.harness === active.harness);
|
|
1772
|
-
if (matching.length > 1) {
|
|
1773
|
-
problems.push(`${matching.length} ${active.label} definitions are active (${matching.map((entry) => entry.file).join(', ')}); keep either project or global routing`);
|
|
1774
|
-
}
|
|
1775
|
-
for (const installation of matching) {
|
|
1776
|
-
for (const problem of installation.problems ?? []) {
|
|
1777
|
-
problems.push(`${installation.file}: ${problem}`);
|
|
1778
|
-
}
|
|
1779
|
-
for (const handler of installation.handlers) {
|
|
1780
|
-
const event = handlerEvent(handler.command);
|
|
1781
|
-
if (event !== null && !HOOK_EVENTS.includes(event)) {
|
|
1782
|
-
problems.push(`${handler.event} in ${installation.file} names the unsupported event ${event}; reinstall the ${active.label} hooks`);
|
|
1783
|
-
}
|
|
1784
|
-
}
|
|
1785
|
-
}
|
|
1786
|
-
for (const problem of inspectHookAdapter(deps.hookAdapterHome, deps.hookPlatform).problems) {
|
|
1787
|
-
problems.push(problem);
|
|
1788
|
-
}
|
|
1789
|
-
for (const installation of matching)
|
|
1790
|
-
problems.push(...stopShapeProblems(installation, deps.hookPlatform));
|
|
1791
|
-
problems.push(...codexTrustProblems(matching, deps.env));
|
|
1792
|
-
return [...new Set(problems)];
|
|
1793
|
-
}
|
|
1794
|
-
function diagnoseActiveHarnessSession(active, problem, installations, contested = []) {
|
|
1795
|
-
// Naming one harness confidently is wrong when the environment carries the
|
|
1796
|
-
// markers of several and none of them has fired here: whichever one the
|
|
1797
|
-
// agent is told to prompt may not be the one it is running in.
|
|
1798
|
-
const ambiguity = contested.length > 1
|
|
1799
|
-
? [
|
|
1800
|
-
`Several harness markers are present here (${contested.map((candidate) => candidate.label).join(', ')}) and none names a session that has fired in this directory, so ${active.label} is a guess from the environment. Send the prompt in the harness you are actually running.`,
|
|
1801
|
-
]
|
|
1802
|
-
: [];
|
|
1803
|
-
if (problem === 'not-installed') {
|
|
1804
|
-
const others = installations.map((installation) => installation.harness);
|
|
1805
|
-
return [
|
|
1806
|
-
`Could not register the question for the active ${active.label} session: Notifai ${active.label} hooks are not installed for this project.`,
|
|
1807
|
-
...ambiguity,
|
|
1808
|
-
...(others.length === 0
|
|
1809
|
-
? []
|
|
1810
|
-
: [
|
|
1811
|
-
`Hooks installed for ${[...new Set(others)].join(', ')} do not route an active ${active.label} session.`,
|
|
1812
|
-
]),
|
|
1813
|
-
`Run \`notifai hooks install --harness ${active.harness}\`, then send one ${active.label} prompt and run \`notifai doctor\`.`,
|
|
1814
|
-
`Retry \`notifai ask\` only after doctor reports that the ${active.label} hooks fired.`,
|
|
1815
|
-
];
|
|
1816
|
-
}
|
|
1817
|
-
if (problem === 'different-session') {
|
|
1818
|
-
return [
|
|
1819
|
-
`Could not register the question for the active ${active.label} session: the project pointer belongs to another ${active.label} session or harness.`,
|
|
1820
|
-
...ambiguity,
|
|
1821
|
-
`Refusing to guess or cross-wire the question. Send one prompt in this ${active.label} session, then run \`notifai doctor\`.`,
|
|
1822
|
-
`Retry \`notifai ask\` only after doctor reports that this active ${active.label} session fired the hooks.`,
|
|
1823
|
-
];
|
|
1824
|
-
}
|
|
1825
|
-
return [
|
|
1826
|
-
`Could not register the question for the active ${active.label} session: Notifai hooks are installed, but this session has not published its pointer.`,
|
|
1827
|
-
...ambiguity,
|
|
1828
|
-
`Send one ${active.label} prompt, then run \`notifai doctor\`.`,
|
|
1829
|
-
`Retry \`notifai ask\` only after doctor reports that the active ${active.label} session fired the hooks.`,
|
|
1830
|
-
];
|
|
1831
|
-
}
|
|
1832
|
-
/**
|
|
1833
|
-
* Why `ask` cannot see a session, in terms of what to do about it.
|
|
1834
|
-
*
|
|
1835
|
-
* Only a UserPromptSubmit hook firing produces the pointer this reads, and the
|
|
1836
|
-
* old message answered every cause with "run `notifai hooks install` and send
|
|
1837
|
-
* one prompt". The useful next action depends on the harness: some reload
|
|
1838
|
-
* project hook files, OpenCode loads its plugin at startup, and Codex should be
|
|
1839
|
-
* checked after a prompt before assuming that a new session is required.
|
|
1840
|
-
*/
|
|
1841
|
-
function diagnoseMissingSession(deps) {
|
|
1842
|
-
const installations = findInstallations(deps.cwd, deps.env, deps.hookAdapterHome, deps.hookPlatform);
|
|
1843
|
-
if (installations.length === 0) {
|
|
1844
|
-
return [
|
|
1845
|
-
'Could not tell which harness session this is: no Notifai hooks are installed for this project.',
|
|
1846
|
-
'Run `notifai hooks install`, then follow the activation instruction it prints.',
|
|
1847
|
-
];
|
|
1848
|
-
}
|
|
1849
|
-
const where = installations.map((i) => `${i.harness} in ${i.file}`).join(', ');
|
|
1850
|
-
return [
|
|
1851
|
-
`Could not tell which harness session this is. Notifai hooks are installed (${where}),`,
|
|
1852
|
-
'but no usable session pointer from the last 24 hours exists here.',
|
|
1853
|
-
hookActivationAdvice(installations),
|
|
1854
|
-
'Do not bypass this with a guessed session id; run from the exact active harness session or use a blocking `notifai send --reply` question.',
|
|
1855
|
-
];
|
|
1856
|
-
}
|
|
1857
|
-
/** The least disruptive verified way to make each installed adapter run once. */
|
|
1858
|
-
function hookActivationAdvice(installations) {
|
|
1859
|
-
const harnesses = new Set(installations.map((installation) => installation.harness));
|
|
1860
|
-
const advice = [];
|
|
1861
|
-
if (installations.some((installation) => installation.harness === 'claude-code' && !installation.global)) {
|
|
1862
|
-
advice.push('Claude Code: send one new prompt; project hook files reload without a restart');
|
|
1863
|
-
}
|
|
1864
|
-
if (installations.some((installation) => installation.harness === 'claude-code' && installation.global)) {
|
|
1865
|
-
advice.push('Claude Code global hooks: send one prompt; start a new session only if it does not fire');
|
|
1866
|
-
}
|
|
1867
|
-
if (installations.some((installation) => installation.harness === 'cursor' && !installation.global)) {
|
|
1868
|
-
advice.push('Cursor: send one prompt, then run `notifai doctor`; start a new session only if it still has not fired');
|
|
1869
|
-
}
|
|
1870
|
-
if (installations.some((installation) => installation.harness === 'cursor' && installation.global)) {
|
|
1871
|
-
advice.push('Cursor global hooks: send one prompt; start a new session only if it does not fire');
|
|
1872
|
-
}
|
|
1873
|
-
if (harnesses.has('codex')) {
|
|
1874
|
-
advice.push('Codex: send one prompt, then run `notifai doctor`; start a new session only if it still has not fired');
|
|
1875
|
-
}
|
|
1876
|
-
if (harnesses.has('opencode')) {
|
|
1877
|
-
advice.push('OpenCode: restart it, then send one prompt; plugins load at startup, but non-blocking question continuation is intentionally unsupported');
|
|
1878
|
-
}
|
|
1879
|
-
return `${advice.join('. ')}.`;
|
|
1880
|
-
}
|
|
1881
|
-
/**
|
|
1882
|
-
* Record the one Agent Acknowledgement associated with a replied-to request.
|
|
1883
|
-
*
|
|
1884
|
-
* `--text` is optional here because the acknowledgement is not: an account may
|
|
1885
|
-
* turn the agent's written reply off, and the receipt must still be recorded so
|
|
1886
|
-
* the user sees that an agent read the answer. The service holds the account's
|
|
1887
|
-
* snapshot, so it — not this process — decides whether text was owed.
|
|
1888
|
-
*/
|
|
1889
|
-
export async function acknowledgeCommand(deps, requestId, flags) {
|
|
1890
|
-
const text = flags.text?.trim() ?? '';
|
|
1891
|
-
if (flags.text !== undefined && text.length === 0) {
|
|
1892
|
-
deps.io.err('--text must contain non-whitespace text. Drop it to acknowledge without text.');
|
|
1893
|
-
return EXIT.usage;
|
|
1894
|
-
}
|
|
1895
|
-
if (text.length > AGENT_ACKNOWLEDGEMENT_MAX_LENGTH) {
|
|
1896
|
-
deps.io.err(`--text must be at most ${AGENT_ACKNOWLEDGEMENT_MAX_LENGTH} characters after trimming. Shorten it: an acknowledgement is a receipt, not a report.`);
|
|
1897
|
-
return EXIT.usage;
|
|
1898
|
-
}
|
|
1899
|
-
const logger = log(deps);
|
|
1900
|
-
const config = loadLoggedConfig(deps, { cwd: deps.cwd, env: deps.env });
|
|
1901
|
-
logger.info('acknowledgement.attempted', {
|
|
1902
|
-
request_id: requestId,
|
|
1903
|
-
text,
|
|
1904
|
-
characters: text.length,
|
|
1905
|
-
});
|
|
1906
|
-
const authed = authedClient(deps, config);
|
|
1907
|
-
if (!authed)
|
|
1908
|
-
return EXIT.auth;
|
|
1909
|
-
try {
|
|
1910
|
-
const result = await authed.client.putAgentAcknowledgement(requestId, text.length > 0 ? { text } : {});
|
|
1911
|
-
const output = {
|
|
1912
|
-
request_id: requestId,
|
|
1913
|
-
outcome: result.status,
|
|
1914
|
-
acknowledgement: result.agent_acknowledgement,
|
|
1915
|
-
agent_acknowledgement_required: true,
|
|
1916
|
-
};
|
|
1917
|
-
logger.info('acknowledgement.outcome', {
|
|
1918
|
-
request_id: requestId,
|
|
1919
|
-
outcome: result.status,
|
|
1920
|
-
text: result.agent_acknowledgement.text,
|
|
1921
|
-
created_at: result.agent_acknowledgement.created_at,
|
|
1922
|
-
agent_acknowledgement_required: true,
|
|
1923
|
-
});
|
|
1924
|
-
const sessionId = readProjectSession(deps.cwd, deps.env, (deps.now ?? Date.now)());
|
|
1925
|
-
if (sessionId !== null) {
|
|
1926
|
-
clearAcknowledgementObligation(sessionId, deps.env, requestId);
|
|
1927
|
-
}
|
|
1928
|
-
if (flags.json)
|
|
1929
|
-
deps.io.out(JSON.stringify(output, null, 2));
|
|
1930
|
-
else {
|
|
1931
|
-
deps.io.out(`Agent Acknowledgement ${result.status} for ${requestId} at ${result.agent_acknowledgement.created_at}.`);
|
|
1932
|
-
}
|
|
1933
|
-
return EXIT.ok;
|
|
1934
|
-
}
|
|
1935
|
-
catch (err) {
|
|
1936
|
-
return reportError(deps, err, { operation: 'agent_acknowledgement', request_id: requestId });
|
|
1937
|
-
}
|
|
1938
|
-
}
|
|
1939
|
-
/** Retire a question so a late answer is rejected rather than silently lost. */
|
|
1940
|
-
export async function closeCommand(deps, requestId, flags = {}) {
|
|
1941
|
-
const config = loadLoggedConfig(deps, { cwd: deps.cwd, env: deps.env });
|
|
1942
|
-
const authed = authedClient(deps, config);
|
|
1943
|
-
if (!authed)
|
|
1944
|
-
return EXIT.auth;
|
|
1945
|
-
try {
|
|
1946
|
-
const response = await authed.client.closeReplies(requestId);
|
|
1947
|
-
if (flags.json) {
|
|
1948
|
-
deps.io.out(JSON.stringify({
|
|
1949
|
-
...response,
|
|
1950
|
-
acknowledgement_command: acknowledgementCommand(response.request_id, response.agent_acknowledgement_required, response.agent_acknowledgement_text_required, response.agent_acknowledgement, response.replies.length > 0),
|
|
1951
|
-
}, null, 2));
|
|
1952
|
-
}
|
|
1953
|
-
else {
|
|
1954
|
-
deps.io.out(`Closed the reply window for ${requestId}.`);
|
|
1955
|
-
if (response.replies.length > 0)
|
|
1956
|
-
printAcknowledgementStatus(deps, response);
|
|
1957
|
-
}
|
|
1958
|
-
return EXIT.ok;
|
|
1959
|
-
}
|
|
1960
|
-
catch (err) {
|
|
1961
|
-
return reportError(deps, err);
|
|
1962
|
-
}
|
|
1963
|
-
}
|
|
1964
|
-
/** True when this process is `npx` / `npm exec`, not a global or linked install. */
|
|
1965
|
-
export function runningViaNpx(env, scriptPath) {
|
|
1966
|
-
if (env['npm_command'] === 'exec')
|
|
1967
|
-
return true;
|
|
1968
|
-
return scriptPath.includes(`${path.sep}_npx${path.sep}`);
|
|
1969
|
-
}
|
|
1970
|
-
function fileHookInstallTarget(target) {
|
|
1971
|
-
if (target === undefined || isNpxAdapterTarget(target))
|
|
1972
|
-
return undefined;
|
|
1973
|
-
return target;
|
|
1974
|
-
}
|
|
1975
|
-
function resolveHookAdapterTarget(deps, flags) {
|
|
1976
|
-
if (deps.hookInstallTarget !== undefined && isNpxAdapterTarget(deps.hookInstallTarget)) {
|
|
1977
|
-
return deps.hookInstallTarget;
|
|
1978
|
-
}
|
|
1979
|
-
const fileTarget = fileHookInstallTarget(deps.hookInstallTarget);
|
|
1980
|
-
const execPath = flags.execPath ?? fileTarget?.execPath ?? process.execPath;
|
|
1981
|
-
const scriptPath = flags.scriptPath ?? fileTarget?.scriptPath ?? process.argv[1] ?? 'notifai';
|
|
1982
|
-
if (runningViaNpx(deps.env, scriptPath)) {
|
|
1983
|
-
const version = packageVersion();
|
|
1984
|
-
const npmCli = deps.env['npm_execpath'];
|
|
1985
|
-
if (version === null) {
|
|
1986
|
-
throw new Error('Could not read this CLI version, so an npx hook target cannot be pinned. Install `@raidiant/notifai` globally and rerun `notifai hooks install`.');
|
|
1987
|
-
}
|
|
1988
|
-
if (typeof npmCli !== 'string' || npmCli === '') {
|
|
1989
|
-
throw new Error('This process looks like npx but npm_execpath is missing. Install `@raidiant/notifai` globally and rerun `notifai hooks install`.');
|
|
1990
|
-
}
|
|
1991
|
-
return { kind: 'npx', execPath, npmCli, spec: `@raidiant/notifai@${version}` };
|
|
1992
|
-
}
|
|
1993
|
-
return { execPath, scriptPath };
|
|
1994
|
-
}
|
|
1995
|
-
function printHooksInstallClose(deps, harness, file) {
|
|
1996
|
-
const label = HARNESS_LABELS[harness];
|
|
1997
|
-
if (deps.io.interactive === true && deps.io.note) {
|
|
1998
|
-
void deps.io.note(`${file}\nSend one ${label} prompt, then run \`notifai doctor\`.`, `${label} hooks installed`);
|
|
1999
|
-
return;
|
|
2000
|
-
}
|
|
2001
|
-
deps.io.out(`Installed ${harness} hooks in ${file}`);
|
|
2002
|
-
deps.io.out(`Send one ${label} prompt, then check \`notifai doctor\`.`);
|
|
2003
|
-
}
|
|
2004
|
-
export function hooksInstallCommand(deps, flags) {
|
|
2005
|
-
if (flags.harness === undefined) {
|
|
2006
|
-
const detected = detectedHarnesses(deps.cwd, deps.env);
|
|
2007
|
-
if (detected.length === 0) {
|
|
2008
|
-
deps.io.err(`Could not tell which harness you mean — pass --harness <${HARNESSES.join('|')}>.`);
|
|
2009
|
-
return EXIT.usage;
|
|
2010
|
-
}
|
|
2011
|
-
let ok = true;
|
|
2012
|
-
for (const harness of detected) {
|
|
2013
|
-
if (hooksInstallCommand(deps, { ...flags, harness }) !== EXIT.ok)
|
|
2014
|
-
ok = false;
|
|
2015
|
-
}
|
|
2016
|
-
return ok ? EXIT.ok : EXIT.failed;
|
|
2017
|
-
}
|
|
2018
|
-
const harness = resolveHarness(deps, flags.harness);
|
|
2019
|
-
if (!harness)
|
|
2020
|
-
return EXIT.usage;
|
|
2021
|
-
const adapterTarget = resolveHookAdapterTarget(deps, flags);
|
|
2022
|
-
const scriptPath = flags.scriptPath ?? fileHookInstallTarget(adapterTarget)?.scriptPath ?? process.argv[1] ?? 'notifai';
|
|
2023
|
-
const hookPlatform = deps.hookPlatform ?? process.platform;
|
|
2024
|
-
const nodePath = adapterTarget.execPath;
|
|
2025
|
-
let adapterPath;
|
|
2026
|
-
try {
|
|
2027
|
-
adapterPath = installHookAdapter(adapterTarget, deps.hookAdapterHome, hookPlatform).path;
|
|
2028
|
-
}
|
|
2029
|
-
catch (err) {
|
|
2030
|
-
deps.io.err(`Could not prepare the stable hook adapter: ${String(err)}`);
|
|
2031
|
-
return EXIT.failed;
|
|
2032
|
-
}
|
|
2033
|
-
const wantGlobal = flags.global === true;
|
|
2034
|
-
const existing = findInstallations(deps.cwd, deps.env, deps.hookAdapterHome, deps.hookPlatform).filter((installation) => installation.harness === harness);
|
|
2035
|
-
const otherScope = existing.filter((installation) => installation.global !== wantGlobal);
|
|
2036
|
-
if (!wantGlobal && otherScope.some((installation) => installation.global)) {
|
|
2037
|
-
const globalFile = otherScope.find((installation) => installation.global)?.file;
|
|
2038
|
-
deps.io.out(`${HARNESS_LABELS[harness]} hooks already cover this machine (${globalFile}). This project does not need its own copy. To wire only this project: notifai hooks uninstall --harness ${harness} --global && notifai hooks install --harness ${harness}`);
|
|
2039
|
-
return EXIT.ok;
|
|
2040
|
-
}
|
|
2041
|
-
if (wantGlobal && otherScope.some((installation) => !installation.global)) {
|
|
2042
|
-
if (hooksUninstallCommand(deps, { ...flags, global: false, harness }) !== EXIT.ok) {
|
|
2043
|
-
return EXIT.failed;
|
|
2044
|
-
}
|
|
2045
|
-
}
|
|
2046
|
-
const codexPaths = harness === 'codex'
|
|
2047
|
-
? codexLayerPaths(wantGlobal, deps.cwd, deps.env, hookPlatform)
|
|
2048
|
-
: null;
|
|
2049
|
-
const settingsTarget = codexPaths?.configToml ?? settingsFile(harness, wantGlobal, deps.cwd, deps.env, hookPlatform);
|
|
2050
|
-
// OpenCode's adapter is a generated plugin module rather than a handler
|
|
2051
|
-
// merged into a settings document, so it owns the whole file.
|
|
2052
|
-
if (harness === 'opencode') {
|
|
2053
|
-
return installOpencodePlugin(deps, settingsTarget, {
|
|
2054
|
-
adapterPath,
|
|
2055
|
-
timeoutSeconds: BLOCKING_STOP_TIMEOUT_SECONDS,
|
|
2056
|
-
platform: hookPlatform,
|
|
2057
|
-
nodePath,
|
|
2058
|
-
});
|
|
2059
|
-
}
|
|
2060
|
-
if (harness === 'cursor') {
|
|
2061
|
-
try {
|
|
2062
|
-
withTargetFileLock(settingsTarget, () => {
|
|
2063
|
-
const document = loadCursorSettings(settingsTarget);
|
|
2064
|
-
const result = mergeCursorHooks(document, buildCursorHookConfig({
|
|
2065
|
-
adapterPath,
|
|
2066
|
-
harness: 'cursor',
|
|
2067
|
-
platform: hookPlatform,
|
|
2068
|
-
nodePath,
|
|
2069
|
-
}), scriptPath);
|
|
2070
|
-
applyPlan(settingsTarget, result.document);
|
|
2071
|
-
return result;
|
|
2072
|
-
});
|
|
2073
|
-
}
|
|
2074
|
-
catch (err) {
|
|
2075
|
-
deps.io.err(String(err));
|
|
2076
|
-
return EXIT.failed;
|
|
2077
|
-
}
|
|
2078
|
-
printHooksInstallClose(deps, harness, settingsTarget);
|
|
2079
|
-
return EXIT.ok;
|
|
2080
|
-
}
|
|
2081
|
-
const installInto = (file) => {
|
|
2082
|
-
const document = loadSettings(file);
|
|
2083
|
-
const foreignStopCount = foreignStopHandlers(document).length;
|
|
2084
|
-
const result = mergeHooks(document, buildHookConfig({
|
|
2085
|
-
adapterPath,
|
|
2086
|
-
harness,
|
|
2087
|
-
platform: hookPlatform,
|
|
2088
|
-
nodePath,
|
|
2089
|
-
}), scriptPath);
|
|
2090
|
-
applyPlan(file, result.document);
|
|
2091
|
-
return { file, foreignStopCount };
|
|
2092
|
-
};
|
|
2093
|
-
let installed;
|
|
2094
|
-
try {
|
|
2095
|
-
installed =
|
|
2096
|
-
codexPaths === null
|
|
2097
|
-
? withTargetFileLock(settingsTarget, () => installInto(settingsTarget))
|
|
2098
|
-
: withCodexLayerTransaction(codexPaths, (inspection) => installInto(inspection.writeTarget));
|
|
2099
|
-
}
|
|
2100
|
-
catch (err) {
|
|
2101
|
-
deps.io.err(String(err));
|
|
2102
|
-
return EXIT.failed;
|
|
2103
|
-
}
|
|
2104
|
-
if (harness === 'codex') {
|
|
2105
|
-
const layer = flags.global ? null : codexLayerDir(deps.cwd);
|
|
2106
|
-
if (layer !== null)
|
|
2107
|
-
mkdirSync(layer, { recursive: true });
|
|
2108
|
-
}
|
|
2109
|
-
printHooksInstallClose(deps, harness, installed.file);
|
|
2110
|
-
if (installed.foreignStopCount > 0) {
|
|
2111
|
-
deps.io.out("This layer already has a Stop handler. Codex runs every matching handler, so Notifai's Stop and the existing one will both fire.");
|
|
2112
|
-
}
|
|
2113
|
-
if (harness === 'codex') {
|
|
2114
|
-
for (const problem of codexRepresentationProblems(deps.cwd, deps.env, hookPlatform)) {
|
|
2115
|
-
deps.io.out(problem);
|
|
2116
|
-
}
|
|
2117
|
-
for (const note of codexCoexistenceNotes(deps.cwd, deps.env, hookPlatform)) {
|
|
2118
|
-
deps.io.out(note);
|
|
2119
|
-
}
|
|
2120
|
-
if (flags.global) {
|
|
2121
|
-
const home = codexHomeNote(deps.env, hookPlatform);
|
|
2122
|
-
if (home !== null)
|
|
2123
|
-
deps.io.out(home);
|
|
2124
|
-
}
|
|
2125
|
-
}
|
|
2126
|
-
return EXIT.ok;
|
|
2127
|
-
}
|
|
2128
|
-
function foreignStopHandlers(document) {
|
|
2129
|
-
const groups = document.hooks?.['Stop'];
|
|
2130
|
-
if (!Array.isArray(groups))
|
|
2131
|
-
return [];
|
|
2132
|
-
return groups
|
|
2133
|
-
.flatMap((group) => group.hooks ?? [])
|
|
2134
|
-
.filter((handler) => !/ hook (user-prompt-submit|stop|session-end)\b/.test(handler.command));
|
|
2135
|
-
}
|
|
2136
|
-
/**
|
|
2137
|
-
* Writes the OpenCode plugin, replacing any Notifai plugin already there —
|
|
2138
|
-
* including one a different checkout wrote, matched on the managed marker for
|
|
2139
|
-
* the same reason command hooks are.
|
|
2140
|
-
*/
|
|
2141
|
-
function installOpencodePlugin(deps, file, options) {
|
|
2142
|
-
try {
|
|
2143
|
-
withTargetFileLock(file, () => {
|
|
2144
|
-
if (existsSync(file)) {
|
|
2145
|
-
assertOwnedRegularFile(file);
|
|
2146
|
-
const existing = readFileSync(file, 'utf8');
|
|
2147
|
-
if (!isOurOpencodePlugin(existing)) {
|
|
2148
|
-
throw new Error(`${file} exists and was not written by Notifai; move it aside first.`);
|
|
2149
|
-
}
|
|
2150
|
-
}
|
|
2151
|
-
atomicWriteFileSync(file, opencodePluginSource(options), {
|
|
2152
|
-
mode: 0o600,
|
|
2153
|
-
preserveMode: false,
|
|
2154
|
-
requireCurrentUserOwner: true,
|
|
2155
|
-
});
|
|
2156
|
-
});
|
|
2157
|
-
}
|
|
2158
|
-
catch (err) {
|
|
2159
|
-
deps.io.err(String(err));
|
|
2160
|
-
return EXIT.failed;
|
|
2161
|
-
}
|
|
2162
|
-
printHooksInstallClose(deps, 'opencode', file);
|
|
2163
|
-
return EXIT.ok;
|
|
2164
|
-
}
|
|
2165
|
-
export function hooksUninstallCommand(deps, flags) {
|
|
2166
|
-
const harness = resolveHarness(deps, flags.harness);
|
|
2167
|
-
if (!harness)
|
|
2168
|
-
return EXIT.usage;
|
|
2169
|
-
const scriptPath = flags.scriptPath ?? process.argv[1] ?? 'notifai';
|
|
2170
|
-
const global = flags.global ?? false;
|
|
2171
|
-
const codexPaths = harness === 'codex'
|
|
2172
|
-
? codexLayerPaths(global, deps.cwd, deps.env, deps.hookPlatform)
|
|
2173
|
-
: null;
|
|
2174
|
-
const file = codexPaths?.configToml ?? settingsFile(harness, global, deps.cwd, deps.env, deps.hookPlatform);
|
|
2175
|
-
if (harness === 'opencode') {
|
|
2176
|
-
try {
|
|
2177
|
-
return withTargetFileLock(file, () => {
|
|
2178
|
-
if (!existsSync(file)) {
|
|
2179
|
-
deps.io.out(`Nothing to remove: ${file} does not exist.`);
|
|
2180
|
-
return EXIT.ok;
|
|
2181
|
-
}
|
|
2182
|
-
assertOwnedRegularFile(file);
|
|
2183
|
-
// We own the whole file, but only if we wrote it.
|
|
2184
|
-
if (!isOurOpencodePlugin(readFileSync(file, 'utf8'))) {
|
|
2185
|
-
deps.io.out(`Left ${file} alone: Notifai did not write it.`);
|
|
2186
|
-
return EXIT.ok;
|
|
2187
|
-
}
|
|
2188
|
-
rmSync(file, { force: true });
|
|
2189
|
-
deps.io.out(`Removed the Notifai OpenCode plugin at ${file}`);
|
|
2190
|
-
return EXIT.ok;
|
|
2191
|
-
});
|
|
2192
|
-
}
|
|
2193
|
-
catch (err) {
|
|
2194
|
-
deps.io.err(String(err));
|
|
2195
|
-
return EXIT.failed;
|
|
2196
|
-
}
|
|
2197
|
-
}
|
|
2198
|
-
if (harness === 'cursor') {
|
|
2199
|
-
let stripped;
|
|
2200
|
-
try {
|
|
2201
|
-
stripped = withTargetFileLock(file, () => {
|
|
2202
|
-
if (!existsSync(file))
|
|
2203
|
-
return null;
|
|
2204
|
-
const document = loadCursorSettings(file);
|
|
2205
|
-
const result = removeCursorHooks(document, scriptPath);
|
|
2206
|
-
applyPlan(file, result.document);
|
|
2207
|
-
return result;
|
|
2208
|
-
});
|
|
2209
|
-
}
|
|
2210
|
-
catch (err) {
|
|
2211
|
-
deps.io.err(String(err));
|
|
2212
|
-
return EXIT.failed;
|
|
2213
|
-
}
|
|
2214
|
-
if (stripped === null) {
|
|
2215
|
-
deps.io.out(`Nothing to remove: ${file} does not exist.`);
|
|
2216
|
-
return EXIT.ok;
|
|
2217
|
-
}
|
|
2218
|
-
deps.io.out(stripped.replaced.length > 0
|
|
2219
|
-
? `Removed Notifai hooks (${stripped.replaced.join(', ')}) from ${file}`
|
|
2220
|
-
: `No Notifai hooks found in ${file}`);
|
|
2221
|
-
return EXIT.ok;
|
|
2222
|
-
}
|
|
2223
|
-
const removeInstalledHooks = () => {
|
|
2224
|
-
const files = codexPaths === null
|
|
2225
|
-
? hookDefinitionFiles(harness, global, deps.cwd, deps.env, deps.hookPlatform)
|
|
2226
|
-
: [codexPaths.hooksJson, codexPaths.configToml];
|
|
2227
|
-
const existing = files.filter((candidate) => existsSync(candidate));
|
|
2228
|
-
let removedAny = false;
|
|
2229
|
-
for (const candidate of existing) {
|
|
2230
|
-
const removeFromCandidate = () => {
|
|
2231
|
-
const document = loadSettings(candidate);
|
|
2232
|
-
const result = removeHooks(document, scriptPath);
|
|
2233
|
-
if (result.replaced.length > 0)
|
|
2234
|
-
applyPlan(candidate, result.document);
|
|
2235
|
-
return result;
|
|
2236
|
-
};
|
|
2237
|
-
const stripped = codexPaths === null
|
|
2238
|
-
? withTargetFileLock(candidate, removeFromCandidate)
|
|
2239
|
-
: removeFromCandidate();
|
|
2240
|
-
if (stripped.replaced.length > 0) {
|
|
2241
|
-
removedAny = true;
|
|
2242
|
-
deps.io.out(`Removed Notifai hooks (${stripped.replaced.join(', ')}) from ${candidate}`);
|
|
2243
|
-
}
|
|
2244
|
-
}
|
|
2245
|
-
return { existing, removedAny };
|
|
2246
|
-
};
|
|
2247
|
-
let result;
|
|
2248
|
-
try {
|
|
2249
|
-
result =
|
|
2250
|
-
codexPaths === null
|
|
2251
|
-
? removeInstalledHooks()
|
|
2252
|
-
: withCodexLayerTransaction(codexPaths, removeInstalledHooks);
|
|
2253
|
-
}
|
|
2254
|
-
catch (err) {
|
|
2255
|
-
deps.io.err(String(err));
|
|
2256
|
-
return EXIT.failed;
|
|
2257
|
-
}
|
|
2258
|
-
if (result.existing.length === 0) {
|
|
2259
|
-
deps.io.out(`Nothing to remove: ${file} does not exist.`);
|
|
2260
|
-
return EXIT.ok;
|
|
2261
|
-
}
|
|
2262
|
-
if (!result.removedAny) {
|
|
2263
|
-
deps.io.out(`No Notifai hooks found in ${result.existing.join(', ')}`);
|
|
2264
|
-
}
|
|
2265
|
-
return EXIT.ok;
|
|
2266
|
-
}
|
|
2267
|
-
function assertOwnedRegularFile(file) {
|
|
2268
|
-
const stat = lstatSync(file);
|
|
2269
|
-
if (stat.isSymbolicLink() || !stat.isFile()) {
|
|
2270
|
-
throw new Error(`${file} is not a regular file; refusing to read or replace it.`);
|
|
2271
|
-
}
|
|
2272
|
-
const uid = typeof process.getuid === 'function' ? process.getuid() : undefined;
|
|
2273
|
-
if (uid !== undefined && stat.uid !== uid) {
|
|
2274
|
-
throw new Error(`${file} is owned by uid ${stat.uid}, not the current user.`);
|
|
2275
|
-
}
|
|
2276
|
-
}
|
|
2277
|
-
function resolveHarness(deps, requested) {
|
|
2278
|
-
if (requested !== undefined) {
|
|
2279
|
-
if (HARNESSES.includes(requested))
|
|
2280
|
-
return requested;
|
|
2281
|
-
deps.io.err(`Unknown harness "${requested}". Supported: ${HARNESSES.join(', ')}.`);
|
|
2282
|
-
return null;
|
|
2283
|
-
}
|
|
2284
|
-
const detected = detectHarness(deps.cwd, deps.env);
|
|
2285
|
-
if (!detected) {
|
|
2286
|
-
deps.io.err(`Could not tell which harness to install for — pass --harness <${HARNESSES.join('|')}>.`);
|
|
2287
|
-
return null;
|
|
2288
|
-
}
|
|
2289
|
-
return detected;
|
|
2290
|
-
}
|
|
2291
|
-
/**
|
|
2292
|
-
* Which harnesses to wire. An explicit `--harness` still wins as a singleton.
|
|
2293
|
-
* Otherwise: every detected harness, or a human picker when detection is empty
|
|
2294
|
-
* or names more than one.
|
|
2295
|
-
*/
|
|
2296
|
-
async function pickHarnessesToInstall(deps, requested) {
|
|
2297
|
-
if (requested !== undefined) {
|
|
2298
|
-
const harness = resolveHarness(deps, requested);
|
|
2299
|
-
return harness === null ? null : [harness];
|
|
2300
|
-
}
|
|
2301
|
-
const detected = detectedHarnesses(deps.cwd, deps.env);
|
|
2302
|
-
if (detected.length === 1)
|
|
2303
|
-
return detected;
|
|
2304
|
-
if (deps.io.interactive === true && deps.io.multiselect) {
|
|
2305
|
-
const picked = await deps.io.multiselect('Which agent harnesses should Notifai wire here?', HARNESSES.map((name) => ({
|
|
2306
|
-
value: name,
|
|
2307
|
-
label: HARNESS_LABELS[name],
|
|
2308
|
-
...(detected.includes(name) ? { hint: 'detected on this machine' } : {}),
|
|
2309
|
-
})), detected);
|
|
2310
|
-
if (picked === null)
|
|
2311
|
-
return null;
|
|
2312
|
-
const unknown = picked.filter((name) => !HARNESSES.includes(name));
|
|
2313
|
-
if (unknown.length > 0) {
|
|
2314
|
-
deps.io.err(`Unknown harness "${unknown[0]}". Supported: ${HARNESSES.join(', ')}.`);
|
|
2315
|
-
return null;
|
|
2316
|
-
}
|
|
2317
|
-
return picked;
|
|
2318
|
-
}
|
|
2319
|
-
if (detected.length === 0) {
|
|
2320
|
-
deps.io.err(`Could not tell which harness to wire. Run: notifai hooks install --harness <${HARNESSES.join('|')}>`);
|
|
2321
|
-
return null;
|
|
2322
|
-
}
|
|
2323
|
-
return detected;
|
|
2324
|
-
}
|
|
2325
|
-
// ---------------------------------------------------------------------------
|
|
2326
|
-
// config show / set / unset
|
|
2327
|
-
// ---------------------------------------------------------------------------
|
|
2328
|
-
export function configShowCommand(deps, flags) {
|
|
2329
|
-
const config = loadLoggedConfig(deps, { cwd: deps.cwd, env: deps.env });
|
|
2330
|
-
if (flags.json) {
|
|
2331
|
-
const output = Object.fromEntries(CONFIG_KEYS.map((key) => [
|
|
2332
|
-
key,
|
|
2333
|
-
{
|
|
2334
|
-
value: config[key].value,
|
|
2335
|
-
source: config[key].source,
|
|
2336
|
-
summary: configInfo(key).summary,
|
|
2337
|
-
},
|
|
2338
|
-
]));
|
|
2339
|
-
deps.io.out(JSON.stringify(output, null, 2));
|
|
2340
|
-
return EXIT.ok;
|
|
2341
|
-
}
|
|
2342
|
-
// Anything that is not a person at a terminal keeps the flat `key = value`
|
|
2343
|
-
// form it has always had. Scripts parse this, and a prettier layout for an
|
|
2344
|
-
// audience that cannot see it would only be a breaking change.
|
|
2345
|
-
if (deps.io.interactive !== true || flags.plain === true) {
|
|
2346
|
-
for (const line of renderConfigPlain(config, flags.explain === true))
|
|
2347
|
-
deps.io.out(line);
|
|
2348
|
-
return EXIT.ok;
|
|
2349
|
-
}
|
|
2350
|
-
for (const line of renderConfigList(config, { showAdvanced: flags.explain === true })) {
|
|
2351
|
-
deps.io.out(line);
|
|
2352
|
-
}
|
|
2353
|
-
return EXIT.ok;
|
|
2354
|
-
}
|
|
2355
|
-
/**
|
|
2356
|
-
* One setting, explained in full.
|
|
2357
|
-
*
|
|
2358
|
-
* The gap this closes: `config show` prints a value and provenance but that is
|
|
2359
|
-
* not enough to explain the setting's consequences. Every key already had a
|
|
2360
|
-
* careful explanation — in a TypeScript comment, read by everyone except the
|
|
2361
|
-
* person who needed it.
|
|
2362
|
-
*/
|
|
2363
|
-
export function configExplainCommand(deps, key, flags = {}) {
|
|
2364
|
-
if (!CONFIG_KEYS.includes(key)) {
|
|
2365
|
-
deps.io.err(`Unknown setting "${key}".`);
|
|
2366
|
-
deps.io.err(`Valid settings: ${CONFIG_KEYS.join(', ')}`);
|
|
2367
|
-
return EXIT.usage;
|
|
2368
|
-
}
|
|
2369
|
-
const configKey = key;
|
|
2370
|
-
const config = loadLoggedConfig(deps, { cwd: deps.cwd, env: deps.env });
|
|
2371
|
-
const info = configInfo(configKey);
|
|
2372
|
-
const entry = config[configKey];
|
|
2373
|
-
if (flags.json) {
|
|
2374
|
-
deps.io.out(JSON.stringify({
|
|
2375
|
-
key: configKey,
|
|
2376
|
-
label: info.label,
|
|
2377
|
-
group: info.group,
|
|
2378
|
-
kind: info.kind,
|
|
2379
|
-
summary: info.summary,
|
|
2380
|
-
detail: info.detail,
|
|
2381
|
-
accepts: acceptedValues(configKey),
|
|
2382
|
-
...(info.choices !== undefined ? { choices: info.choices } : {}),
|
|
2383
|
-
value: entry.value,
|
|
2384
|
-
source: entry.source,
|
|
2385
|
-
}, null, 2));
|
|
2386
|
-
return EXIT.ok;
|
|
2387
|
-
}
|
|
2388
|
-
if (deps.io.interactive !== true) {
|
|
2389
|
-
deps.io.out(`${configKey} = ${JSON.stringify(entry.value)} [${entry.source}]`);
|
|
2390
|
-
deps.io.out(info.detail.replace(/\n\n/g, '\n'));
|
|
2391
|
-
deps.io.out(`accepts: ${acceptedValues(configKey)}`);
|
|
2392
|
-
return EXIT.ok;
|
|
2393
|
-
}
|
|
2394
|
-
for (const line of renderConfigExplain(configKey, config))
|
|
2395
|
-
deps.io.out(line);
|
|
2396
|
-
return EXIT.ok;
|
|
2397
|
-
}
|
|
2398
|
-
/**
|
|
2399
|
-
* Closest config key by edit distance, or null when nothing is close.
|
|
2400
|
-
*
|
|
2401
|
-
* The threshold matters more than the algorithm: suggesting a key that shares
|
|
2402
|
-
* three letters with the typo sends the reader to the wrong setting with
|
|
2403
|
-
* confidence, which is worse than listing all fourteen and letting them look.
|
|
2404
|
-
*/
|
|
2405
|
-
function nearestKey(input) {
|
|
2406
|
-
let best = null;
|
|
2407
|
-
let bestDistance = Number.POSITIVE_INFINITY;
|
|
2408
|
-
for (const candidate of CONFIG_KEYS) {
|
|
2409
|
-
const distance = editDistance(input.toLowerCase(), candidate);
|
|
2410
|
-
if (distance < bestDistance) {
|
|
2411
|
-
bestDistance = distance;
|
|
2412
|
-
best = candidate;
|
|
2413
|
-
}
|
|
2414
|
-
}
|
|
2415
|
-
return best !== null && bestDistance <= Math.max(2, Math.floor(best.length / 3)) ? best : null;
|
|
2416
|
-
}
|
|
2417
|
-
function editDistance(a, b) {
|
|
2418
|
-
const previous = Array.from({ length: b.length + 1 }, (_, i) => i);
|
|
2419
|
-
for (let i = 1; i <= a.length; i += 1) {
|
|
2420
|
-
let diagonal = previous[0];
|
|
2421
|
-
previous[0] = i;
|
|
2422
|
-
for (let j = 1; j <= b.length; j += 1) {
|
|
2423
|
-
const above = previous[j];
|
|
2424
|
-
previous[j] = Math.min(previous[j] + 1, previous[j - 1] + 1, diagonal + (a[i - 1] === b[j - 1] ? 0 : 1));
|
|
2425
|
-
diagonal = above;
|
|
2426
|
-
}
|
|
2427
|
-
}
|
|
2428
|
-
return previous[b.length];
|
|
2429
|
-
}
|
|
2430
|
-
export async function configSetCommand(deps, key, rawValue, flags) {
|
|
2431
|
-
const configKey = configMutationKey(deps, key);
|
|
2432
|
-
if (configKey === null)
|
|
2433
|
-
return EXIT.usage;
|
|
2434
|
-
const info = configInfo(configKey);
|
|
2435
|
-
let value = rawValue;
|
|
2436
|
-
if (NUMERIC_CONFIG_KEYS.includes(configKey)) {
|
|
2437
|
-
const numeric = Number(rawValue);
|
|
2438
|
-
if (!Number.isInteger(numeric)) {
|
|
2439
|
-
deps.io.err(`${key} takes ${acceptedValues(configKey)}, not "${rawValue}".`);
|
|
2440
|
-
return EXIT.usage;
|
|
2441
|
-
}
|
|
2442
|
-
const bounds = configBounds(configKey);
|
|
2443
|
-
if (bounds !== undefined && (numeric < bounds.min || numeric > bounds.max)) {
|
|
2444
|
-
deps.io.err(`${key} must be between ${bounds.min} and ${bounds.max}.`);
|
|
2445
|
-
return EXIT.usage;
|
|
2446
|
-
}
|
|
2447
|
-
value = numeric;
|
|
2448
|
-
}
|
|
2449
|
-
if (BOOLEAN_CONFIG_KEYS.includes(configKey)) {
|
|
2450
|
-
if (rawValue !== 'true' && rawValue !== 'false') {
|
|
2451
|
-
deps.io.err(`${key} is a toggle — pass "true" or "false", not "${rawValue}".`);
|
|
2452
|
-
return EXIT.usage;
|
|
2453
|
-
}
|
|
2454
|
-
value = rawValue === 'true';
|
|
2455
|
-
}
|
|
2456
|
-
// Enum keys were accepted unchecked, so `config set sound whatever` wrote a
|
|
2457
|
-
// value the sender would later reject — a typo that only surfaces at the
|
|
2458
|
-
// moment a notification fails to carry the sound you asked for.
|
|
2459
|
-
if (info.kind === 'enum' && info.choices !== undefined && !info.choices.includes(rawValue)) {
|
|
2460
|
-
deps.io.err(`${key} takes one of: ${info.choices.join(', ')} — not "${rawValue}".`);
|
|
2461
|
-
return EXIT.usage;
|
|
2462
|
-
}
|
|
2463
|
-
if (key === 'devices')
|
|
2464
|
-
value = rawValue.split(',').map((s) => s.trim()).filter(Boolean);
|
|
2465
|
-
const target = await configMutationTarget(deps, flags);
|
|
2466
|
-
if (target === null)
|
|
2467
|
-
return EXIT.usage;
|
|
2468
|
-
if (target.layer === 'global' && Object.is(value, configDefaultValue(configKey))) {
|
|
2469
|
-
deps.io.err(`${key} is already the shipped default (${JSON.stringify(value)}).`);
|
|
2470
|
-
deps.io.err(`Run \`notifai config unset ${key} --yes\` to remove a redundant override instead of creating one.`);
|
|
2471
|
-
return EXIT.usage;
|
|
2472
|
-
}
|
|
2473
|
-
if (!flags.yes) {
|
|
2474
|
-
const confirmed = await deps.io.confirm(`Set ${key} = ${JSON.stringify(value)} in ${target.path}?`);
|
|
2475
|
-
if (!confirmed) {
|
|
2476
|
-
deps.io.err('Not confirmed. Pass --yes to skip the confirmation gate.');
|
|
2477
|
-
return EXIT.usage;
|
|
2478
|
-
}
|
|
2479
|
-
}
|
|
2480
|
-
const existing = existsSync(target.path)
|
|
2481
|
-
? parseToml(readFileSync(target.path, 'utf8'))
|
|
2482
|
-
: {};
|
|
2483
|
-
existing[key] = value;
|
|
2484
|
-
mkdirSync(path.dirname(target.path), { recursive: true });
|
|
2485
|
-
writeFileSync(target.path, `${stringifyToml(existing)}\n`);
|
|
2486
|
-
deps.io.out(`Wrote ${key} to ${target.path}`);
|
|
2487
|
-
return EXIT.ok;
|
|
2488
|
-
}
|
|
2489
|
-
function configMutationKey(deps, key) {
|
|
2490
|
-
if (!CONFIG_KEYS.includes(key)) {
|
|
2491
|
-
deps.io.err(`Unknown setting "${key}".`);
|
|
2492
|
-
const near = nearestKey(key);
|
|
2493
|
-
if (near !== null)
|
|
2494
|
-
deps.io.err(`Did you mean "${near}"?`);
|
|
2495
|
-
deps.io.err(`Valid settings: ${CONFIG_KEYS.join(', ')}`);
|
|
2496
|
-
deps.io.err('Run `notifai config explain <key>` to see what one of them does.');
|
|
2497
|
-
return null;
|
|
2498
|
-
}
|
|
2499
|
-
return key;
|
|
2500
|
-
}
|
|
2501
|
-
async function configMutationTarget(deps, flags) {
|
|
2502
|
-
let layer = flags.local ? 'local' : flags.project ? 'project' : 'global';
|
|
2503
|
-
if (flags.session === undefined &&
|
|
2504
|
-
flags.local !== true &&
|
|
2505
|
-
flags.project !== true &&
|
|
2506
|
-
flags.yes !== true &&
|
|
2507
|
-
deps.io.interactive === true &&
|
|
2508
|
-
deps.io.select) {
|
|
2509
|
-
const selected = await deps.io.select('Where should this setting live?', [
|
|
2510
|
-
{ value: 'global', label: 'This machine', hint: 'applies across projects' },
|
|
2511
|
-
{ value: 'project', label: 'This project (shared)', hint: '.notifai/config.toml' },
|
|
2512
|
-
{ value: 'local', label: 'This project (personal)', hint: 'stored on this machine, not in the repo' },
|
|
2513
|
-
]);
|
|
2514
|
-
if (selected === null) {
|
|
2515
|
-
deps.io.err('No configuration layer selected.');
|
|
2516
|
-
return null;
|
|
2517
|
-
}
|
|
2518
|
-
layer = selected;
|
|
2519
|
-
}
|
|
2520
|
-
const targetPath = flags.session
|
|
2521
|
-
? sessionConfigPath(flags.session, deps.env)
|
|
2522
|
-
: layer === 'local'
|
|
2523
|
-
? personalProjectConfigPath(deps.cwd, deps.env)
|
|
2524
|
-
: layer === 'project'
|
|
2525
|
-
? (findProjectConfigPath(deps.cwd) ?? path.join(deps.cwd, '.notifai', 'config.toml'))
|
|
2526
|
-
: globalConfigPath(deps.env);
|
|
2527
|
-
return {
|
|
2528
|
-
path: targetPath,
|
|
2529
|
-
layer: flags.session ? 'session' : layer,
|
|
2530
|
-
};
|
|
2531
|
-
}
|
|
2532
|
-
export async function configUnsetCommand(deps, key, flags) {
|
|
2533
|
-
const configKey = configMutationKey(deps, key);
|
|
2534
|
-
if (configKey === null)
|
|
2535
|
-
return EXIT.usage;
|
|
2536
|
-
const target = await configMutationTarget(deps, flags);
|
|
2537
|
-
if (target === null)
|
|
2538
|
-
return EXIT.usage;
|
|
2539
|
-
const existing = existsSync(target.path)
|
|
2540
|
-
? parseToml(readFileSync(target.path, 'utf8'))
|
|
2541
|
-
: {};
|
|
2542
|
-
if (!Object.prototype.hasOwnProperty.call(existing, configKey)) {
|
|
2543
|
-
deps.io.out(`${configKey} is not set in ${target.path}`);
|
|
2544
|
-
return EXIT.ok;
|
|
2545
|
-
}
|
|
2546
|
-
if (!flags.yes) {
|
|
2547
|
-
const confirmed = await deps.io.confirm(`Remove ${configKey} from ${target.path}?`);
|
|
2548
|
-
if (!confirmed) {
|
|
2549
|
-
deps.io.err('Not confirmed. Pass --yes to skip the confirmation gate.');
|
|
2550
|
-
return EXIT.usage;
|
|
2551
|
-
}
|
|
2552
|
-
}
|
|
2553
|
-
delete existing[configKey];
|
|
2554
|
-
if (Object.keys(existing).length === 0) {
|
|
2555
|
-
rmSync(target.path, { force: true });
|
|
2556
|
-
}
|
|
2557
|
-
else {
|
|
2558
|
-
writeFileSync(target.path, `${stringifyToml(existing)}\n`);
|
|
2559
|
-
}
|
|
2560
|
-
deps.io.out(`Removed ${configKey} from ${target.path}; the inherited value now applies.`);
|
|
2561
|
-
return EXIT.ok;
|
|
2562
|
-
}
|
|
2563
|
-
const LOG_RECORD_OPTIONS = [
|
|
2564
|
-
['limit', '--limit'],
|
|
2565
|
-
['all', '--all'],
|
|
2566
|
-
['since', '--since'],
|
|
2567
|
-
['level', '--level'],
|
|
2568
|
-
['event', '--event'],
|
|
2569
|
-
['run', '--run'],
|
|
2570
|
-
['request', '--request'],
|
|
2571
|
-
['session', '--session'],
|
|
2572
|
-
['project', '--project'],
|
|
2573
|
-
['allProjects', '--all-projects'],
|
|
2574
|
-
['grep', '--grep'],
|
|
2575
|
-
];
|
|
2576
|
-
/**
|
|
2577
|
-
* Deliberately small. The reader is usually a model with a finite context, and
|
|
2578
|
-
* a log command whose default answer is ten thousand lines gets used once.
|
|
2579
|
-
* Everything beyond this is reachable by asking for it.
|
|
2580
|
-
*/
|
|
2581
|
-
const DEFAULT_LOG_LIMIT = 30;
|
|
2582
|
-
/** Even `--all` stops somewhere; an unbounded dump is never the useful answer. */
|
|
2583
|
-
const MAX_LOG_LIMIT = 2_000;
|
|
2584
|
-
/** `10m`, `2h`, `1d`, or an ISO 8601 instant. */
|
|
2585
|
-
export function parseSince(raw, now) {
|
|
2586
|
-
const relative = /^(\d+)([smhd])$/.exec(raw.trim());
|
|
2587
|
-
if (relative !== null) {
|
|
2588
|
-
const amount = Number(relative[1]);
|
|
2589
|
-
const unit = relative[2];
|
|
2590
|
-
const seconds = { s: 1, m: 60, h: 3600, d: 86400 }[unit];
|
|
2591
|
-
return now - amount * seconds * 1000;
|
|
2592
|
-
}
|
|
2593
|
-
const absolute = Date.parse(raw);
|
|
2594
|
-
return Number.isNaN(absolute) ? null : absolute;
|
|
2595
|
-
}
|
|
2596
|
-
/**
|
|
2597
|
-
* What this machine recorded about itself.
|
|
2598
|
-
*
|
|
2599
|
-
* The retrieval half of the local log, and the half that decides whether the
|
|
2600
|
-
* log is worth having. Three things make it usable by an agent rather than
|
|
2601
|
-
* merely available to one: the default is bounded and scoped to this project,
|
|
2602
|
-
* the filters match the questions actually asked ("that run", "that
|
|
2603
|
-
* notification", "just the failures"), and `--json` gives the machine the
|
|
2604
|
-
* records untouched on stdout while every word of explanation goes to stderr.
|
|
2605
|
-
*/
|
|
2606
|
-
export function logsCommand(deps, flags) {
|
|
2607
|
-
const selectedRetrievalOptions = LOG_RECORD_OPTIONS
|
|
2608
|
-
.filter(([key]) => {
|
|
2609
|
-
const value = flags[key];
|
|
2610
|
-
return Array.isArray(value) ? value.length > 0 : value !== undefined && value !== false;
|
|
2611
|
-
})
|
|
2612
|
-
.map(([, name]) => name);
|
|
2613
|
-
if (flags.path === true && flags.clear === true) {
|
|
2614
|
-
deps.io.err('Pass --path or --clear, not both.');
|
|
2615
|
-
return EXIT.usage;
|
|
2616
|
-
}
|
|
2617
|
-
if (flags.path === true && selectedRetrievalOptions.length > 0) {
|
|
2618
|
-
deps.io.err(`--path cannot be combined with record options: ${selectedRetrievalOptions.join(', ')}.`);
|
|
2619
|
-
return EXIT.usage;
|
|
2620
|
-
}
|
|
2621
|
-
if (flags.clear === true && selectedRetrievalOptions.length > 0) {
|
|
2622
|
-
deps.io.err(`--clear cannot be combined with record options: ${selectedRetrievalOptions.join(', ')}.`);
|
|
2623
|
-
return EXIT.usage;
|
|
2624
|
-
}
|
|
2625
|
-
if (flags.project !== undefined && flags.allProjects === true) {
|
|
2626
|
-
deps.io.err('Pass --project or --all-projects, not both.');
|
|
2627
|
-
return EXIT.usage;
|
|
2628
|
-
}
|
|
2629
|
-
if (flags.limit !== undefined && flags.all === true) {
|
|
2630
|
-
deps.io.err('Pass --limit or --all, not both.');
|
|
2631
|
-
return EXIT.usage;
|
|
2632
|
-
}
|
|
2633
|
-
if (flags.limit !== undefined && (!Number.isInteger(flags.limit) || flags.limit <= 0)) {
|
|
2634
|
-
deps.io.err('--limit must be a positive integer.');
|
|
2635
|
-
return EXIT.usage;
|
|
2636
|
-
}
|
|
2637
|
-
if (flags.limit !== undefined && flags.limit > MAX_LOG_LIMIT) {
|
|
2638
|
-
deps.io.err(`--limit cannot exceed ${MAX_LOG_LIMIT}; narrow the result with --since or filters.`);
|
|
2639
|
-
return EXIT.usage;
|
|
2640
|
-
}
|
|
2641
|
-
const config = loadLoggedConfig(deps, { cwd: deps.cwd, env: deps.env });
|
|
2642
|
-
const now = (deps.now ?? Date.now)();
|
|
2643
|
-
if (flags.path === true) {
|
|
2644
|
-
const usage = logsDiskUsage(deps.env);
|
|
2645
|
-
const archives = archiveLogPaths(deps.env);
|
|
2646
|
-
if (flags.json === true) {
|
|
2647
|
-
deps.io.out(JSON.stringify({
|
|
2648
|
-
active: activeLogPath(deps.env),
|
|
2649
|
-
archives,
|
|
2650
|
-
files: usage.files,
|
|
2651
|
-
bytes: usage.bytes,
|
|
2652
|
-
level: config.log_level.value,
|
|
2653
|
-
max_bytes: config.log_max_bytes.value,
|
|
2654
|
-
max_files: config.log_max_files.value,
|
|
2655
|
-
local_only: true,
|
|
2656
|
-
}, null, 2));
|
|
2657
|
-
return EXIT.ok;
|
|
2658
|
-
}
|
|
2659
|
-
deps.io.out(activeLogPath(deps.env));
|
|
2660
|
-
for (const archive of archives)
|
|
2661
|
-
deps.io.out(archive);
|
|
2662
|
-
deps.io.err(`${usage.files} file(s), ${Math.round(usage.bytes / 1024)} KB, level ${config.log_level.value}. These stay on this machine.`);
|
|
2663
|
-
return EXIT.ok;
|
|
2664
|
-
}
|
|
2665
|
-
if (flags.clear === true) {
|
|
2666
|
-
let removed = 0;
|
|
2667
|
-
for (const file of [activeLogPath(deps.env), ...archiveLogPaths(deps.env)]) {
|
|
2668
|
-
try {
|
|
2669
|
-
unlinkSync(file);
|
|
2670
|
-
removed += 1;
|
|
2671
|
-
}
|
|
2672
|
-
catch {
|
|
2673
|
-
// Absent, or not ours to remove; either way there is nothing to report.
|
|
2674
|
-
}
|
|
2675
|
-
}
|
|
2676
|
-
deps.io.out(flags.json === true
|
|
2677
|
-
? JSON.stringify({ cleared_files: removed })
|
|
2678
|
-
: `Cleared ${removed} log file${removed === 1 ? '' : 's'}.`);
|
|
2679
|
-
return EXIT.ok;
|
|
2680
|
-
}
|
|
2681
|
-
const query = {
|
|
2682
|
-
limit: flags.limit ?? (flags.all === true ? MAX_LOG_LIMIT : DEFAULT_LOG_LIMIT),
|
|
2683
|
-
};
|
|
2684
|
-
if (flags.since !== undefined) {
|
|
2685
|
-
const since = parseSince(flags.since, now);
|
|
2686
|
-
if (since === null) {
|
|
2687
|
-
deps.io.err(`Could not read "${flags.since}" as a time. Use 10m, 2h, 1d, or an ISO 8601 instant.`);
|
|
2688
|
-
return EXIT.usage;
|
|
2689
|
-
}
|
|
2690
|
-
query.since = since;
|
|
2691
|
-
}
|
|
2692
|
-
if (flags.level !== undefined) {
|
|
2693
|
-
if (!RECORD_LEVELS.includes(flags.level)) {
|
|
2694
|
-
deps.io.err(`--level takes one of: ${RECORD_LEVELS.join(', ')} — not "${flags.level}".`);
|
|
2695
|
-
return EXIT.usage;
|
|
2696
|
-
}
|
|
2697
|
-
query.level = flags.level;
|
|
2698
|
-
}
|
|
2699
|
-
if (flags.event !== undefined && flags.event.length > 0) {
|
|
2700
|
-
const unknown = flags.event.filter((name) => !LOG_EVENTS.includes(name));
|
|
2701
|
-
if (unknown.length > 0) {
|
|
2702
|
-
deps.io.err(`Unknown event ${unknown.join(', ')}. Valid: ${LOG_EVENTS.join(', ')}`);
|
|
2703
|
-
return EXIT.usage;
|
|
2704
|
-
}
|
|
2705
|
-
query.event = flags.event;
|
|
2706
|
-
}
|
|
2707
|
-
if (flags.run !== undefined)
|
|
2708
|
-
query.run = flags.run;
|
|
2709
|
-
if (flags.session !== undefined)
|
|
2710
|
-
query.session = flags.session;
|
|
2711
|
-
if (flags.request !== undefined)
|
|
2712
|
-
query.request = flags.request;
|
|
2713
|
-
if (flags.grep !== undefined)
|
|
2714
|
-
query.contains = flags.grep;
|
|
2715
|
-
// Scoped to this project by default. A machine commonly runs several agents
|
|
2716
|
-
// in several worktrees at once, and the answer to "what just happened" is
|
|
2717
|
-
// useless if three other projects are interleaved through it.
|
|
2718
|
-
const scope = flags.project ?? (flags.allProjects === true ? undefined : (config.project.value ?? undefined));
|
|
2719
|
-
if (scope !== undefined)
|
|
2720
|
-
query.project = scope;
|
|
2721
|
-
const result = readLogRecords(deps.env, query);
|
|
2722
|
-
if (flags.json === true) {
|
|
2723
|
-
for (const record of result.records)
|
|
2724
|
-
deps.io.out(JSON.stringify(record));
|
|
2725
|
-
}
|
|
2726
|
-
else {
|
|
2727
|
-
for (const record of result.records)
|
|
2728
|
-
deps.io.out(renderRecord(record));
|
|
2729
|
-
}
|
|
2730
|
-
if (result.records.length === 0) {
|
|
2731
|
-
if (config.log_level.value === 'off') {
|
|
2732
|
-
deps.io.err('Nothing is being recorded: log_level is off.');
|
|
2733
|
-
deps.io.err('Turn it on with `notifai config set log_level info --yes`.');
|
|
2734
|
-
}
|
|
2735
|
-
else if (logsDiskUsage(deps.env).files === 0) {
|
|
2736
|
-
deps.io.err('No log yet. It is written the next time a notifai command or harness hook runs.');
|
|
2737
|
-
}
|
|
2738
|
-
else {
|
|
2739
|
-
deps.io.err('No records matched. Widen it with --all-projects, --since 1d, or --all.');
|
|
2740
|
-
}
|
|
2741
|
-
return EXIT.ok;
|
|
2742
|
-
}
|
|
2743
|
-
// Everything explanatory goes to stderr so `--json` leaves stdout as clean
|
|
2744
|
-
// JSONL for whatever is parsing it.
|
|
2745
|
-
const notes = [];
|
|
2746
|
-
if (scope !== undefined)
|
|
2747
|
-
notes.push(`project ${scope} (--all-projects for every project)`);
|
|
2748
|
-
if (result.more) {
|
|
2749
|
-
notes.push(query.limit < MAX_LOG_LIMIT
|
|
2750
|
-
? `more history behind this (-n ${Math.min(query.limit * 4, MAX_LOG_LIMIT)} or --all)`
|
|
2751
|
-
: `more history exists beyond the ${MAX_LOG_LIMIT}-record safety cap; narrow with --since or filters`);
|
|
2752
|
-
}
|
|
2753
|
-
if (config.log_level.value !== 'debug')
|
|
2754
|
-
notes.push('`log_level = debug` records request detail');
|
|
2755
|
-
if (notes.length > 0)
|
|
2756
|
-
deps.io.err(`— ${result.records.length} records; ${notes.join('; ')}`);
|
|
2757
|
-
return EXIT.ok;
|
|
2758
|
-
}
|
|
2759
|
-
// ---------------------------------------------------------------------------
|
|
2760
|
-
// init
|
|
2761
|
-
// ---------------------------------------------------------------------------
|
|
2762
|
-
/**
|
|
2763
|
-
* Where `npx skills add` fetches the optional agent skill from, derived from
|
|
2764
|
-
* this build's own version so the pin cannot drift from the release it names.
|
|
2765
|
-
* Null when the build cannot establish its version; see `./release.js`.
|
|
2766
|
-
*/
|
|
2767
|
-
export const SKILLS_SOURCE = skillsSource();
|
|
2768
|
-
/**
|
|
2769
|
-
* How to refer to the pin in user-facing text.
|
|
2770
|
-
*
|
|
2771
|
-
* Only reached in a corrupted install, where naming the tag is impossible but
|
|
2772
|
-
* saying nothing would be worse — the sentence still has to read as English.
|
|
2773
|
-
*/
|
|
2774
|
-
const SKILLS_SOURCE_LABEL = SKILLS_SOURCE ?? 'the public release tag matching this CLI';
|
|
2775
|
-
function skillSourceParts() {
|
|
2776
|
-
if (SKILLS_SOURCE === null)
|
|
2777
|
-
return null;
|
|
2778
|
-
const match = /^([^#]+)#(.+)$/.exec(SKILLS_SOURCE);
|
|
2779
|
-
return match === null ? null : { source: match[1], ref: match[2] };
|
|
2780
|
-
}
|
|
2781
|
-
function expectedSkill(skill) {
|
|
2782
|
-
const expected = skillSourceParts();
|
|
2783
|
-
return (expected !== null &&
|
|
2784
|
-
skill.name === 'notifai' &&
|
|
2785
|
-
skill.source === expected.source &&
|
|
2786
|
-
skill.sourceType === 'github' &&
|
|
2787
|
-
skill.ref === expected.ref);
|
|
2788
|
-
}
|
|
2789
|
-
async function skillReadiness(deps, selectedScope) {
|
|
2790
|
-
const scopes = selectedScope === undefined ? ['project', 'global'] : [selectedScope];
|
|
2791
|
-
const results = await Promise.all(scopes.map(async (scope) => {
|
|
2792
|
-
if (deps.nativeSkills === undefined)
|
|
2793
|
-
return { scope, skills: [] };
|
|
2794
|
-
try {
|
|
2795
|
-
return { scope, ...(await deps.nativeSkills.list(scope, deps.cwd, deps.env)) };
|
|
2796
|
-
}
|
|
2797
|
-
catch (err) {
|
|
2798
|
-
return { scope, skills: [], error: String(err) };
|
|
2799
|
-
}
|
|
2800
|
-
}));
|
|
2801
|
-
const installed = results.flatMap(({ skills }) => skills).find(expectedSkill);
|
|
2802
|
-
if (installed !== undefined) {
|
|
2803
|
-
return {
|
|
2804
|
-
id: 'skill',
|
|
2805
|
-
title: 'Agent guidance skill',
|
|
2806
|
-
status: 'ready',
|
|
2807
|
-
detail: `installed from ${SKILLS_SOURCE_LABEL} in the ${installed.scope} scope`,
|
|
2808
|
-
};
|
|
2809
|
-
}
|
|
2810
|
-
const errors = results
|
|
2811
|
-
.filter((result) => result.error !== undefined)
|
|
2812
|
-
.map((result) => `${result.scope}: ${result.error}`);
|
|
2813
|
-
const scopeText = selectedScope === undefined ? 'project or machine-global scope' : `${selectedScope} scope`;
|
|
2814
|
-
return {
|
|
2815
|
-
id: 'skill',
|
|
2816
|
-
title: 'Agent guidance skill',
|
|
2817
|
-
status: 'optional-gap',
|
|
2818
|
-
detail: errors.length > 0
|
|
2819
|
-
? `could not verify installer-managed state in ${scopeText} (${errors.join('; ')})`
|
|
2820
|
-
: `not installed from ${SKILLS_SOURCE_LABEL} in ${scopeText}`,
|
|
2821
|
-
remedy: {
|
|
2822
|
-
by: 'cli',
|
|
2823
|
-
summary: 'install the skill agents follow when deciding to notify',
|
|
2824
|
-
command: selectedScope === undefined
|
|
2825
|
-
? 'notifai init --skills'
|
|
2826
|
-
: `notifai init --skills --skills-scope ${selectedScope}`,
|
|
2827
|
-
},
|
|
2828
|
-
};
|
|
2829
|
-
}
|
|
2830
|
-
/** Derive a contract-valid project slug; init alone needs a non-empty fallback. */
|
|
2831
|
-
export function projectSlugFrom(name) {
|
|
2832
|
-
return inferredProjectSlugFrom(name) ?? 'project';
|
|
2833
|
-
}
|
|
2834
|
-
/** Long enough for a first TestFlight install; keep-waiting extends another budget. */
|
|
2835
|
-
const DEVICE_BRIDGE_TIMEOUT_MS = 10 * 60 * 1000;
|
|
2836
|
-
const DEVICE_BRIDGE_POLL_MS = 2_000;
|
|
2837
|
-
const PROOF_TIMEOUT_MS = 30_000;
|
|
2838
|
-
const PROOF_POLL_MS = 1_000;
|
|
2839
|
-
function supportPageUrl(baseUrl) {
|
|
2840
|
-
return `${baseUrl.replace(/\/$/, '')}/support`;
|
|
2841
|
-
}
|
|
2842
|
-
function formatWaitBudget(milliseconds) {
|
|
2843
|
-
const minutes = Math.round(milliseconds / 60_000);
|
|
2844
|
-
return minutes === 1 ? '1 minute' : `${minutes} minutes`;
|
|
2845
|
-
}
|
|
2846
|
-
/** Same-account line for the device hop; prefers the real email when known. */
|
|
2847
|
-
function sameAccountSignInLine(email) {
|
|
2848
|
-
return email
|
|
2849
|
-
? `sign in with the same email as this account (${email})`
|
|
2850
|
-
: 'sign in with the same email as this account';
|
|
2851
|
-
}
|
|
2852
|
-
function deviceInstallRemedy(options) {
|
|
2853
|
-
const support = supportPageUrl(options.baseUrl);
|
|
2854
|
-
const sameEmail = sameAccountSignInLine(options.email);
|
|
2855
|
-
if (options.devices.length === 0) {
|
|
2856
|
-
return (`open the install steps at ${support} on that device (or your phone’s browser), ` +
|
|
2857
|
-
`install Notifai, ${sameEmail}, and allow notifications`);
|
|
2858
|
-
}
|
|
2859
|
-
if (options.devices.some((d) => d.permission_status === 'denied')) {
|
|
2860
|
-
return 'allow notifications for Notifai in that device’s system settings';
|
|
2861
|
-
}
|
|
2862
|
-
return 'open Notifai on that device and allow its notification prompt';
|
|
2863
|
-
}
|
|
2864
|
-
function setupProofPath(deps) {
|
|
2865
|
-
let projectDir = path.resolve(deps.cwd);
|
|
2866
|
-
try {
|
|
2867
|
-
projectDir = realpathSync(projectDir);
|
|
2868
|
-
}
|
|
2869
|
-
catch {
|
|
2870
|
-
// A deleted or not-yet-created cwd cannot collide with a real directory:
|
|
2871
|
-
// the resolved absolute path is still a stable local identity for it.
|
|
2872
|
-
}
|
|
2873
|
-
const digest = createHash('sha256').update(projectDir).digest('hex').slice(0, 32);
|
|
2874
|
-
return path.join(stateDir(deps.env), 'setup-proofs', `${digest}.json`);
|
|
2875
|
-
}
|
|
2876
|
-
function readSetupProof(deps) {
|
|
2877
|
-
const file = setupProofPath(deps);
|
|
2878
|
-
if (!existsSync(file))
|
|
2879
|
-
return null;
|
|
2880
|
-
try {
|
|
2881
|
-
const parsed = JSON.parse(readFileSync(file, 'utf8'));
|
|
2882
|
-
return typeof parsed.request_id === 'string' &&
|
|
2883
|
-
typeof parsed.device_id === 'string' &&
|
|
2884
|
-
(typeof parsed.project === 'string' || parsed.project === null) &&
|
|
2885
|
-
typeof parsed.started_at === 'string'
|
|
2886
|
-
? parsed
|
|
2887
|
-
: null;
|
|
2888
|
-
}
|
|
2889
|
-
catch {
|
|
2890
|
-
// Corrupt local evidence is not readiness. A fresh proof replaces it.
|
|
2891
|
-
return null;
|
|
2892
|
-
}
|
|
2893
|
-
}
|
|
2894
|
-
function writeSetupProof(deps, proof) {
|
|
2895
|
-
const file = setupProofPath(deps);
|
|
2896
|
-
try {
|
|
2897
|
-
mkdirSync(path.dirname(file), { recursive: true });
|
|
2898
|
-
writeFileSync(file, `${JSON.stringify(proof, null, 2)}\n`, { mode: 0o600 });
|
|
2899
|
-
return true;
|
|
2900
|
-
}
|
|
2901
|
-
catch (err) {
|
|
2902
|
-
deps.io.err(`Could not save setup proof ${proof.request_id} at ${file}: ${String(err)}`);
|
|
2903
|
-
return false;
|
|
2904
|
-
}
|
|
2905
|
-
}
|
|
2906
|
-
function observedCompanionReceipt(snapshot, deviceId) {
|
|
2907
|
-
const delivery = snapshot.deliveries.find((candidate) => candidate.device_id === deviceId);
|
|
2908
|
-
const receipt = delivery?.events.find((event) => event.stage === 'companion_received');
|
|
2909
|
-
return delivery && receipt ? { delivery, observedAt: receipt.occurred_at } : null;
|
|
2910
|
-
}
|
|
2911
|
-
async function closeGap(deps, state, flags) {
|
|
2912
|
-
if (state.id === 'project') {
|
|
2913
|
-
const configPath = path.join(deps.cwd, '.notifai', 'config.toml');
|
|
2914
|
-
const existing = existsSync(configPath)
|
|
2915
|
-
? parseToml(readFileSync(configPath, 'utf8'))
|
|
2916
|
-
: {};
|
|
2917
|
-
existing['project'] = projectSlugFrom(flags.projectId ?? path.basename(deps.cwd));
|
|
2918
|
-
mkdirSync(path.dirname(configPath), { recursive: true });
|
|
2919
|
-
writeFileSync(configPath, `${stringifyToml(existing)}\n`);
|
|
2920
|
-
return 'closed';
|
|
2921
|
-
}
|
|
2922
|
-
if (state.id === 'hooks') {
|
|
2923
|
-
const harnesses = await pickHarnessesToInstall(deps);
|
|
2924
|
-
if (harnesses === null || harnesses.length === 0)
|
|
2925
|
-
return 'failed';
|
|
2926
|
-
let ok = true;
|
|
2927
|
-
for (const harness of harnesses) {
|
|
2928
|
-
if (hooksInstallCommand(deps, { harness }) !== EXIT.ok)
|
|
2929
|
-
ok = false;
|
|
2930
|
-
}
|
|
2931
|
-
return ok ? 'closed' : 'failed';
|
|
2932
|
-
}
|
|
2933
|
-
if (state.id === 'skill') {
|
|
2934
|
-
if (deps.nativeSkills === undefined) {
|
|
2935
|
-
deps.io.err('Skill installation failed — the native `npx skills` flow is unavailable.');
|
|
2936
|
-
return 'failed';
|
|
2937
|
-
}
|
|
2938
|
-
// Refuse rather than reach for a mutable ref: installing the skill from a
|
|
2939
|
-
// moving branch is the one outcome the pin exists to prevent.
|
|
2940
|
-
if (SKILLS_SOURCE === null) {
|
|
2941
|
-
deps.io.err('Skill installation failed — this build cannot determine its own version, so there is no release tag to install from.');
|
|
2942
|
-
return 'failed';
|
|
2943
|
-
}
|
|
2944
|
-
const scopeText = flags.skillsScope === undefined ? 'the scope you choose' : `${flags.skillsScope} scope`;
|
|
2945
|
-
deps.io.out(`Starting the native npx skills setup for the notifai agent skill (${scopeText})...`);
|
|
2946
|
-
const addOptions = {
|
|
2947
|
-
source: SKILLS_SOURCE,
|
|
2948
|
-
skill: 'notifai',
|
|
2949
|
-
cwd: deps.cwd,
|
|
2950
|
-
env: deps.env,
|
|
2951
|
-
...(flags.skillsScope === undefined ? {} : { scope: flags.skillsScope }),
|
|
2952
|
-
};
|
|
2953
|
-
const code = await deps.nativeSkills.add(addOptions);
|
|
2954
|
-
if (code !== 0) {
|
|
2955
|
-
deps.io.err('Skill installation failed — run it manually with:');
|
|
2956
|
-
deps.io.err(` npx skills add ${SKILLS_SOURCE} --skill notifai${flags.skillsScope === 'global' ? ' --global' : ''}${flags.skillsScope === undefined ? '' : ' --yes'}`);
|
|
2957
|
-
}
|
|
2958
|
-
return code === 0 ? 'closed' : 'failed';
|
|
2959
|
-
}
|
|
2960
|
-
if (state.id === 'proof')
|
|
2961
|
-
return await runSetupProof(deps);
|
|
2962
|
-
return 'failed';
|
|
2963
|
-
}
|
|
2964
|
-
function deviceCanReceive(device) {
|
|
2965
|
-
return (device.registration_healthy &&
|
|
2966
|
-
(device.permission_status === 'authorized' || device.permission_status === 'provisional'));
|
|
2967
|
-
}
|
|
2968
|
-
function readyIosDevices(devices) {
|
|
2969
|
-
return devices.filter((device) => device.platform === 'ios' && deviceCanReceive(device));
|
|
2970
|
-
}
|
|
2971
|
-
function deviceBridgeMessage(devices) {
|
|
2972
|
-
if (devices.length === 0) {
|
|
2973
|
-
return 'Waiting for the companion app to sign in and register…';
|
|
2974
|
-
}
|
|
2975
|
-
const denied = devices.find((device) => device.permission_status === 'denied');
|
|
2976
|
-
if (denied)
|
|
2977
|
-
return `Waiting for notifications to be allowed on ${denied.display_name}…`;
|
|
2978
|
-
const undecided = devices.find((device) => device.permission_status === 'not_determined');
|
|
2979
|
-
if (undecided)
|
|
2980
|
-
return `Waiting for ${undecided.display_name} to allow the notification prompt…`;
|
|
2981
|
-
return 'Waiting for a companion device to become ready…';
|
|
2982
|
-
}
|
|
2983
|
-
/**
|
|
2984
|
-
* Observe the supported Device Installation path while the user finishes the
|
|
2985
|
-
* app-side work. The live bridge is the dashboard `/support` page (TestFlight
|
|
2986
|
-
* steps). Interactive runs offer to open it so the user never types the URL;
|
|
2987
|
-
* non-interactive/agent paths only print plain text and never wait on a prompt.
|
|
2988
|
-
*
|
|
2989
|
-
* The wait budget is stated up front. On expiry we say the *timer* expired —
|
|
2990
|
-
* not the setup — and offer another budget when a human is present. Agents
|
|
2991
|
-
* never hang: this whole path is gated on `io.interactive`, and keep-waiting
|
|
2992
|
-
* uses `confirm` which resolves the safe default when not interactive.
|
|
2993
|
-
*/
|
|
2994
|
-
async function waitForReadyDevice(deps, state) {
|
|
2995
|
-
const remedy = state.remedy;
|
|
2996
|
-
if (deps.io.interactive !== true || remedy?.by !== 'user-elsewhere')
|
|
2997
|
-
return 'pending';
|
|
2998
|
-
const config = loadLoggedConfig(deps, { cwd: deps.cwd, env: deps.env });
|
|
2999
|
-
const authed = authedClient(deps, config);
|
|
3000
|
-
if (!authed) {
|
|
3001
|
-
deps.io.err('Could not start the companion-device wait: this machine is not signed in.');
|
|
3002
|
-
return 'failed';
|
|
3003
|
-
}
|
|
3004
|
-
const budgetLabel = formatWaitBudget(DEVICE_BRIDGE_TIMEOUT_MS);
|
|
3005
|
-
const supportUrl = supportPageUrl(authed.baseUrl);
|
|
3006
|
-
await deps.io.note?.([
|
|
3007
|
-
state.detail,
|
|
3008
|
-
remedy.summary,
|
|
3009
|
-
`Install steps (no typing): ${supportUrl}`,
|
|
3010
|
-
`I will wait up to ${budgetLabel} for a companion device to become ready.`,
|
|
3011
|
-
].join('\n'), 'Finish setup on your companion device');
|
|
3012
|
-
// Open the real support page so the user never has to type the URL. Decline
|
|
3013
|
-
// is fine — the URL remains in the note and in the Next: line if they leave.
|
|
3014
|
-
if (await deps.io.confirm('Open install instructions in your browser?', true)) {
|
|
3015
|
-
deps.io.openUrl(supportUrl);
|
|
3016
|
-
}
|
|
3017
|
-
if (!(await deps.io.confirm('Wait here while you finish that on your device?', true))) {
|
|
3018
|
-
deps.io.out(`OK — finish device setup when you can (install steps: ${supportUrl}), then re-run \`notifai init\`.`);
|
|
3019
|
-
return 'pending';
|
|
3020
|
-
}
|
|
3021
|
-
const now = deps.now ?? Date.now;
|
|
3022
|
-
const sleep = deps.sleep ??
|
|
3023
|
-
((milliseconds) => new Promise((resolve) => setTimeout(resolve, milliseconds)));
|
|
3024
|
-
const spinner = await deps.io.spinner?.(`Waiting up to ${budgetLabel} for a companion device…`);
|
|
3025
|
-
let lastDevices = [];
|
|
3026
|
-
let deadline = now() + DEVICE_BRIDGE_TIMEOUT_MS;
|
|
3027
|
-
for (;;) {
|
|
3028
|
-
while (now() < deadline) {
|
|
3029
|
-
try {
|
|
3030
|
-
const response = await authed.client.listDevices();
|
|
3031
|
-
lastDevices = response.devices;
|
|
3032
|
-
const ready = response.devices.find(deviceCanReceive);
|
|
3033
|
-
if (ready) {
|
|
3034
|
-
spinner?.stop(`${ready.display_name} is ready to receive`);
|
|
3035
|
-
return 'closed';
|
|
3036
|
-
}
|
|
3037
|
-
spinner?.message(deviceBridgeMessage(response.devices));
|
|
3038
|
-
}
|
|
3039
|
-
catch (err) {
|
|
3040
|
-
if (!(err instanceof NetworkError)) {
|
|
3041
|
-
spinner?.error('Could not check companion readiness');
|
|
3042
|
-
reportError(deps, err);
|
|
3043
|
-
return 'failed';
|
|
3044
|
-
}
|
|
3045
|
-
spinner?.message('Connection lost — still watching…');
|
|
3046
|
-
}
|
|
3047
|
-
await sleep(Math.min(DEVICE_BRIDGE_POLL_MS, Math.max(0, deadline - now())));
|
|
3048
|
-
}
|
|
3049
|
-
// Timer expired — setup did not. Offer another budget only when a human
|
|
3050
|
-
// can answer; confirm() already returns the fallback for agents, so this
|
|
3051
|
-
// never hangs unattended even if interactive were mis-set.
|
|
3052
|
-
spinner?.error(`The ${budgetLabel} wait timer expired`);
|
|
3053
|
-
deps.io.err(`The ${budgetLabel} wait timer expired — setup is not finished, only this wait.`);
|
|
3054
|
-
deps.io.err(deviceBridgeMessage(lastDevices).replace(/…$/, '.'));
|
|
3055
|
-
deps.io.err(`Re-run \`notifai init\` later and it will pick up from here (install steps: ${supportUrl}).`);
|
|
3056
|
-
const keepWaiting = await deps.io.confirm(`Keep waiting for another ${budgetLabel}?`, false);
|
|
3057
|
-
if (!keepWaiting) {
|
|
3058
|
-
deps.io.out('Stopping the wait. Device setup can continue; re-run `notifai init` when ready.');
|
|
3059
|
-
return 'pending';
|
|
3060
|
-
}
|
|
3061
|
-
spinner?.message(`Waiting another ${budgetLabel} for a companion device…`);
|
|
3062
|
-
deadline = now() + DEVICE_BRIDGE_TIMEOUT_MS;
|
|
3063
|
-
}
|
|
3064
|
-
}
|
|
3065
|
-
function setupProofDraft(config, device) {
|
|
3066
|
-
const project = config.project.value;
|
|
3067
|
-
return buildDraft(config, {
|
|
3068
|
-
title: 'Notifai is ready',
|
|
3069
|
-
body: project === null
|
|
3070
|
-
? 'This real notification completed setup verification.'
|
|
3071
|
-
: `This real notification completed setup verification for ${project}.`,
|
|
3072
|
-
event: 'setup_verified',
|
|
3073
|
-
kind: 'update',
|
|
3074
|
-
platform: 'ios',
|
|
3075
|
-
device: [device.device_id],
|
|
3076
|
-
sound: 'none',
|
|
3077
|
-
level: 'passive',
|
|
3078
|
-
collapseKey: 'notifai-setup-verification',
|
|
3079
|
-
});
|
|
3080
|
-
}
|
|
3081
|
-
async function submitSetupProof(deps, client, config, device) {
|
|
3082
|
-
const build = setupProofDraft(config, device);
|
|
3083
|
-
if (!build.ok) {
|
|
3084
|
-
deps.io.err(`Could not build the setup verification notification: ${build.error}`);
|
|
3085
|
-
return null;
|
|
3086
|
-
}
|
|
3087
|
-
const capabilities = CAPABILITIES_V1.describe(build.platform);
|
|
3088
|
-
if (!capabilities) {
|
|
3089
|
-
deps.io.err(`No capability contract is available for ${build.platform}.`);
|
|
3090
|
-
return null;
|
|
3091
|
-
}
|
|
3092
|
-
const validation = validateDraft(build.draft, capabilities);
|
|
3093
|
-
if (!validation.ok) {
|
|
3094
|
-
for (const issue of validation.errors) {
|
|
3095
|
-
deps.io.err(`Setup verification ${issue.path}: ${issue.message}`);
|
|
3096
|
-
}
|
|
3097
|
-
return null;
|
|
3098
|
-
}
|
|
3099
|
-
try {
|
|
3100
|
-
return await client.submit({
|
|
3101
|
-
idempotency_key: `init-${randomBytes(12).toString('base64url')}`,
|
|
3102
|
-
draft: build.draft,
|
|
3103
|
-
}, config.wait_seconds.value);
|
|
3104
|
-
}
|
|
3105
|
-
catch (err) {
|
|
3106
|
-
reportError(deps, err);
|
|
3107
|
-
return null;
|
|
3108
|
-
}
|
|
3109
|
-
}
|
|
3110
|
-
/**
|
|
3111
|
-
* Send or resume one real setup probe, then wait for a Companion Receipt.
|
|
3112
|
-
* Provider Acceptance is intentionally insufficient: it proves APNs accepted
|
|
3113
|
-
* the push, not that a companion process received it.
|
|
3114
|
-
*/
|
|
3115
|
-
async function runSetupProof(deps) {
|
|
3116
|
-
const config = loadLoggedConfig(deps, { cwd: deps.cwd, env: deps.env });
|
|
3117
|
-
const authed = authedClient(deps, config);
|
|
3118
|
-
if (!authed)
|
|
3119
|
-
return 'failed';
|
|
3120
|
-
let devices;
|
|
3121
|
-
try {
|
|
3122
|
-
devices = (await authed.client.listDevices()).devices;
|
|
3123
|
-
}
|
|
3124
|
-
catch (err) {
|
|
3125
|
-
reportError(deps, err);
|
|
3126
|
-
return 'failed';
|
|
3127
|
-
}
|
|
3128
|
-
const candidates = readyIosDevices(devices);
|
|
3129
|
-
const existing = readSetupProof(deps);
|
|
3130
|
-
const target = candidates.find((device) => device.device_id === existing?.device_id && existing.project === config.project.value) ?? candidates[0];
|
|
3131
|
-
if (!target) {
|
|
3132
|
-
deps.io.err("Setup proof needs a receipt-capable iPhone. The current macOS notification path does not emit Companion Receipts (the app's delivery confirmation).");
|
|
3133
|
-
return 'pending';
|
|
3134
|
-
}
|
|
3135
|
-
let proof = existing?.device_id === target.device_id && existing.project === config.project.value
|
|
3136
|
-
? existing
|
|
3137
|
-
: null;
|
|
3138
|
-
if (proof === null) {
|
|
3139
|
-
const receipt = await submitSetupProof(deps, authed.client, config, target);
|
|
3140
|
-
if (receipt === null)
|
|
3141
|
-
return 'failed';
|
|
3142
|
-
if (receipt.overall === 'provider_rejected_all') {
|
|
3143
|
-
deps.io.err(formatReceipt(receipt));
|
|
3144
|
-
return 'failed';
|
|
3145
|
-
}
|
|
3146
|
-
proof = {
|
|
3147
|
-
request_id: receipt.request_id,
|
|
3148
|
-
device_id: target.device_id,
|
|
3149
|
-
project: config.project.value,
|
|
3150
|
-
started_at: new Date((deps.now ?? Date.now)()).toISOString(),
|
|
3151
|
-
};
|
|
3152
|
-
if (!writeSetupProof(deps, proof))
|
|
3153
|
-
return 'failed';
|
|
3154
|
-
deps.io.out(`Verification notification sent to ${target.display_name} (${proof.request_id}).`);
|
|
3155
|
-
}
|
|
3156
|
-
else {
|
|
3157
|
-
deps.io.out(`Checking verification notification ${proof.request_id} again.`);
|
|
3158
|
-
}
|
|
3159
|
-
const now = deps.now ?? Date.now;
|
|
3160
|
-
const sleep = deps.sleep ?? ((milliseconds) => new Promise((resolve) => setTimeout(resolve, milliseconds)));
|
|
3161
|
-
const deadline = now() + PROOF_TIMEOUT_MS;
|
|
3162
|
-
const spinner = deps.io.interactive === true
|
|
3163
|
-
? await deps.io.spinner?.("Waiting for a Companion Receipt (the app's delivery confirmation)…")
|
|
3164
|
-
: null;
|
|
3165
|
-
let lastError = null;
|
|
3166
|
-
let replacedMissingProof = false;
|
|
3167
|
-
for (;;) {
|
|
3168
|
-
try {
|
|
3169
|
-
const snapshot = await authed.client.evidence(proof.request_id);
|
|
3170
|
-
const observed = observedCompanionReceipt(snapshot, proof.device_id);
|
|
3171
|
-
if (observed) {
|
|
3172
|
-
spinner?.stop(`Receipt observed from ${observed.delivery.device_name}`);
|
|
3173
|
-
deps.io.out(`Companion Receipt (the app's delivery confirmation) observed from ${observed.delivery.device_name}.`);
|
|
3174
|
-
return 'closed';
|
|
3175
|
-
}
|
|
3176
|
-
lastError = null;
|
|
3177
|
-
}
|
|
3178
|
-
catch (err) {
|
|
3179
|
-
lastError = err;
|
|
3180
|
-
if (err instanceof ApiCallError &&
|
|
3181
|
-
err.code === 'not_found' &&
|
|
3182
|
-
!replacedMissingProof) {
|
|
3183
|
-
const receipt = await submitSetupProof(deps, authed.client, config, target);
|
|
3184
|
-
if (receipt === null)
|
|
3185
|
-
return 'failed';
|
|
3186
|
-
if (receipt.overall === 'provider_rejected_all') {
|
|
3187
|
-
deps.io.err(formatReceipt(receipt));
|
|
3188
|
-
return 'failed';
|
|
3189
|
-
}
|
|
3190
|
-
proof = {
|
|
3191
|
-
request_id: receipt.request_id,
|
|
3192
|
-
device_id: target.device_id,
|
|
3193
|
-
project: config.project.value,
|
|
3194
|
-
started_at: new Date(now()).toISOString(),
|
|
3195
|
-
};
|
|
3196
|
-
if (!writeSetupProof(deps, proof))
|
|
3197
|
-
return 'failed';
|
|
3198
|
-
replacedMissingProof = true;
|
|
3199
|
-
lastError = null;
|
|
3200
|
-
deps.io.out(`The saved proof had expired; sent replacement ${proof.request_id}.`);
|
|
3201
|
-
continue;
|
|
3202
|
-
}
|
|
3203
|
-
if (!(err instanceof NetworkError)) {
|
|
3204
|
-
spinner?.error('Could not read Companion Receipt evidence');
|
|
3205
|
-
reportError(deps, err);
|
|
3206
|
-
return 'failed';
|
|
3207
|
-
}
|
|
3208
|
-
spinner?.message('Connection lost — still checking the same request…');
|
|
3209
|
-
}
|
|
3210
|
-
if (now() >= deadline)
|
|
3211
|
-
break;
|
|
3212
|
-
await sleep(Math.min(PROOF_POLL_MS, Math.max(0, deadline - now())));
|
|
3213
|
-
}
|
|
3214
|
-
spinner?.stop('Delivery confirmation not observed yet');
|
|
3215
|
-
if (lastError instanceof NetworkError)
|
|
3216
|
-
deps.io.err(lastError.message);
|
|
3217
|
-
deps.io.out(`Provider accepted the notification; Companion Receipt (the app's delivery confirmation) for ${proof.request_id} was not observed within ${PROOF_TIMEOUT_MS / 1000}s. ` +
|
|
3218
|
-
'Proof may still arrive — re-run `notifai init` and it will re-check this same notification.');
|
|
3219
|
-
return 'pending';
|
|
3220
|
-
}
|
|
3221
|
-
/** Closing a local gap cannot have changed the service, the keychain, or devices. */
|
|
3222
|
-
function refreshAfterClose(id) {
|
|
3223
|
-
return id === 'project' || id === 'hooks' || id === 'skill' || id === 'question-routing-settings'
|
|
3224
|
-
? ['local']
|
|
3225
|
-
: undefined;
|
|
3226
|
-
}
|
|
3227
|
-
/** Whether an optional gap should be closed, given flags and who is watching. */
|
|
3228
|
-
function wantsOptional(deps, state, flags) {
|
|
3229
|
-
// Naming the project is init's whole reason to touch the filesystem, costs
|
|
3230
|
-
// nothing, and is undone by editing one line — so it is done rather than
|
|
3231
|
-
// asked about, for a human and an agent alike.
|
|
3232
|
-
if (state.id === 'project')
|
|
3233
|
-
return Promise.resolve(true);
|
|
3234
|
-
const explicit = state.id === 'hooks' ? flags.hooks : state.id === 'skill' ? flags.skills : undefined;
|
|
3235
|
-
if (explicit !== undefined)
|
|
3236
|
-
return Promise.resolve(explicit);
|
|
3237
|
-
// An agent is never asked, and never assumed into a change it did not
|
|
3238
|
-
// request: silence means no, and the summary says what was skipped.
|
|
3239
|
-
if (deps.io.interactive !== true)
|
|
3240
|
-
return Promise.resolve(false);
|
|
3241
|
-
const question = state.id === 'hooks'
|
|
3242
|
-
? 'Install harness hooks, so questions reach your devices when you are away?'
|
|
3243
|
-
: 'Install/update the agent guidance skill through the native npx skills flow?';
|
|
3244
|
-
return deps.io.confirm(question, true);
|
|
3245
|
-
}
|
|
3246
|
-
/**
|
|
3247
|
-
* Setup as one step at a time.
|
|
3248
|
-
*
|
|
3249
|
-
* The old version ran five steps in a fixed order and ended with a list of
|
|
3250
|
-
* everything still outstanding. That is a report, and a report is the wrong
|
|
3251
|
-
* output here: someone handed five things to do does none of them, and the
|
|
3252
|
-
* order was the script's rather than the dependency graph's — it offered to
|
|
3253
|
-
* install hooks after a sign-in that had just failed.
|
|
3254
|
-
*
|
|
3255
|
-
* So this closes what it can, then surfaces exactly one thing, the first that
|
|
3256
|
-
* stands in the way. Re-running advances by one. Idempotence stops being a
|
|
3257
|
-
* property to preserve and becomes the mechanism: every decision is derived
|
|
3258
|
-
* from observed state, so a partial run, a second project, a fresh worktree
|
|
3259
|
-
* and a revoked credential are the same code path arriving at different
|
|
3260
|
-
* states rather than four branches to enumerate.
|
|
3261
|
-
*/
|
|
3262
|
-
export async function initCommand(deps, flags) {
|
|
3263
|
-
if (flags.skillsScope !== undefined &&
|
|
3264
|
-
flags.skillsScope !== 'project' &&
|
|
3265
|
-
flags.skillsScope !== 'global') {
|
|
3266
|
-
deps.io.err('Invalid skill scope. Choose `project` or `global`.');
|
|
3267
|
-
return EXIT.usage;
|
|
3268
|
-
}
|
|
3269
|
-
if (flags.skillsScope !== undefined && flags.skills !== true) {
|
|
3270
|
-
deps.io.err('`--skills-scope` requires `--skills`. Choose project or global in the native installer.');
|
|
3271
|
-
return EXIT.usage;
|
|
3272
|
-
}
|
|
3273
|
-
if (flags.skills === true &&
|
|
3274
|
-
deps.io.interactive !== true &&
|
|
3275
|
-
flags.skillsScope === undefined) {
|
|
3276
|
-
deps.io.err('Unattended skill setup requires an explicit scope: `notifai init --skills --skills-scope project` or `... global`.');
|
|
3277
|
-
return EXIT.usage;
|
|
3278
|
-
}
|
|
3279
|
-
await deps.io.intro?.('Notifai setup');
|
|
3280
|
-
const skillOpts = flags.skillsScope === undefined ? {} : { skillScope: flags.skillsScope };
|
|
3281
|
-
let readiness = await assessReadiness(deps, skillOpts);
|
|
3282
|
-
const reassess = (refresh) => assessReadiness(deps, {
|
|
3283
|
-
...skillOpts,
|
|
3284
|
-
...(refresh === undefined ? {} : { previous: readiness, refresh }),
|
|
3285
|
-
});
|
|
3286
|
-
let failed = false;
|
|
3287
|
-
const attempted = new Set();
|
|
3288
|
-
// Re-assess after every successful action. This is how a browser approval or
|
|
3289
|
-
// companion registration can unlock the next state while the user is still
|
|
3290
|
-
// here, without copying the dependency graph into a second setup script.
|
|
3291
|
-
for (;;) {
|
|
3292
|
-
let advanced = false;
|
|
3293
|
-
let stop = false;
|
|
3294
|
-
for (const state of readiness.states) {
|
|
3295
|
-
if (state.status === 'ready')
|
|
3296
|
-
continue;
|
|
3297
|
-
if (state.status === 'unknown') {
|
|
3298
|
-
stop = true;
|
|
3299
|
-
break;
|
|
3300
|
-
}
|
|
3301
|
-
const remedy = state.remedy;
|
|
3302
|
-
if (remedy === undefined || attempted.has(state.id)) {
|
|
3303
|
-
if (state.status === 'gap')
|
|
3304
|
-
stop = true;
|
|
3305
|
-
if (stop)
|
|
3306
|
-
break;
|
|
3307
|
-
continue;
|
|
3308
|
-
}
|
|
3309
|
-
if (state.status === 'optional-gap') {
|
|
3310
|
-
if (remedy.by !== 'cli' || !(await wantsOptional(deps, state, flags)))
|
|
3311
|
-
continue;
|
|
3312
|
-
attempted.add(state.id);
|
|
3313
|
-
const result = await closeGap(deps, state, flags);
|
|
3314
|
-
if (result === 'failed')
|
|
3315
|
-
failed = true;
|
|
3316
|
-
if (result === 'failed' && state.status === 'optional-gap') {
|
|
3317
|
-
readiness = await reassess(refreshAfterClose(state.id));
|
|
3318
|
-
advanced = true;
|
|
3319
|
-
break;
|
|
3320
|
-
}
|
|
3321
|
-
if (result !== 'closed') {
|
|
3322
|
-
stop = true;
|
|
3323
|
-
break;
|
|
3324
|
-
}
|
|
3325
|
-
readiness = await reassess(refreshAfterClose(state.id));
|
|
3326
|
-
advanced = true;
|
|
3327
|
-
break;
|
|
3328
|
-
}
|
|
3329
|
-
if (remedy.by === 'cli') {
|
|
3330
|
-
attempted.add(state.id);
|
|
3331
|
-
const result = await closeGap(deps, state, flags);
|
|
3332
|
-
if (result === 'failed')
|
|
3333
|
-
failed = true;
|
|
3334
|
-
if (result !== 'closed') {
|
|
3335
|
-
stop = true;
|
|
3336
|
-
break;
|
|
3337
|
-
}
|
|
3338
|
-
readiness = await reassess(refreshAfterClose(state.id));
|
|
3339
|
-
advanced = true;
|
|
3340
|
-
break;
|
|
3341
|
-
}
|
|
3342
|
-
// Its to launch, theirs to complete. Running `init` is the consent; announce
|
|
3343
|
-
// and open rather than re-asking. An agent never reaches this path.
|
|
3344
|
-
if (remedy.by === 'user-here' &&
|
|
3345
|
-
remedy.interactive === true &&
|
|
3346
|
-
deps.io.interactive === true) {
|
|
3347
|
-
attempted.add(state.id);
|
|
3348
|
-
deps.io.out('Opening your browser to approve this machine — Ctrl-C to stop.');
|
|
3349
|
-
if ((await loginCommand(deps, {})) !== EXIT.ok) {
|
|
3350
|
-
failed = true;
|
|
3351
|
-
stop = true;
|
|
3352
|
-
break;
|
|
3353
|
-
}
|
|
3354
|
-
readiness = await reassess();
|
|
3355
|
-
advanced = true;
|
|
3356
|
-
break;
|
|
3357
|
-
}
|
|
3358
|
-
if (state.id === 'devices' &&
|
|
3359
|
-
remedy.by === 'user-elsewhere' &&
|
|
3360
|
-
deps.io.interactive === true) {
|
|
3361
|
-
attempted.add(state.id);
|
|
3362
|
-
const result = await waitForReadyDevice(deps, state);
|
|
3363
|
-
if (result === 'failed')
|
|
3364
|
-
failed = true;
|
|
3365
|
-
if (result !== 'closed') {
|
|
3366
|
-
stop = true;
|
|
3367
|
-
break;
|
|
3368
|
-
}
|
|
3369
|
-
readiness = await reassess();
|
|
3370
|
-
advanced = true;
|
|
3371
|
-
break;
|
|
3372
|
-
}
|
|
3373
|
-
// A human-only remedy is the first blocker for an unattended agent.
|
|
3374
|
-
stop = true;
|
|
3375
|
-
break;
|
|
3376
|
-
}
|
|
3377
|
-
if (stop || !advanced)
|
|
3378
|
-
break;
|
|
3379
|
-
}
|
|
3380
|
-
await printInitClose(deps, readiness, flags);
|
|
3381
|
-
const blocker = firstBlocker(readiness);
|
|
3382
|
-
if (blocker === null)
|
|
3383
|
-
return failed ? EXIT.failed : EXIT.ok;
|
|
3384
|
-
return failed || deps.io.interactive !== true ? EXIT.failed : EXIT.ok;
|
|
3385
|
-
}
|
|
3386
|
-
function isHookSubstate(id) {
|
|
3387
|
-
return id === 'hooks' || id.startsWith('hooks-') || id === 'question-routing-settings';
|
|
3388
|
-
}
|
|
3389
|
-
function leftoverOptionals(readiness, flags) {
|
|
3390
|
-
return readiness.states.filter((state) => {
|
|
3391
|
-
if (state.status !== 'optional-gap')
|
|
3392
|
-
return false;
|
|
3393
|
-
if (isHookSubstate(state.id))
|
|
3394
|
-
return false;
|
|
3395
|
-
if (state.id === 'skill' && flags.skills === false)
|
|
3396
|
-
return false;
|
|
3397
|
-
return true;
|
|
3398
|
-
});
|
|
3399
|
-
}
|
|
3400
|
-
function printOptionalLeftovers(deps, leftovers) {
|
|
3401
|
-
for (const state of leftovers) {
|
|
3402
|
-
deps.io.out(`Optional, not set up — ${remedyLine(state)}`);
|
|
3403
|
-
}
|
|
3404
|
-
}
|
|
3405
|
-
function questionsWillRoute(readiness) {
|
|
3406
|
-
const hooks = readiness.states.find((state) => state.id === 'hooks');
|
|
3407
|
-
const settings = readiness.states.find((state) => state.id === 'question-routing-settings');
|
|
3408
|
-
return hooks?.status === 'ready' && settings?.status !== 'gap';
|
|
3409
|
-
}
|
|
3410
|
-
async function printInitClose(deps, readiness, flags) {
|
|
3411
|
-
const blocker = firstBlocker(readiness);
|
|
3412
|
-
const canSend = readiness.states.find((state) => state.id === 'devices')?.status === 'ready';
|
|
3413
|
-
const questions = questionsWillRoute(readiness);
|
|
3414
|
-
const leftovers = leftoverOptionals(readiness, flags);
|
|
3415
|
-
if (deps.io.interactive === true) {
|
|
3416
|
-
if (blocker === null) {
|
|
3417
|
-
const lines = [
|
|
3418
|
-
canSend ? 'You can send notifications.' : 'You are signed in.',
|
|
3419
|
-
questions
|
|
3420
|
-
? 'Questions will reach your devices.'
|
|
3421
|
-
: 'Questions stay in the terminal until hooks are installed.',
|
|
3422
|
-
];
|
|
3423
|
-
await deps.io.note?.(lines.join('\n'), 'Ready');
|
|
3424
|
-
printOptionalLeftovers(deps, leftovers);
|
|
3425
|
-
deps.io.out(questions
|
|
3426
|
-
? 'All set. Agents in this project can notify you and ask you questions.'
|
|
3427
|
-
: 'All set. Agents in this project can notify you. Questions stay in the terminal until hooks are installed.');
|
|
3428
|
-
await deps.io.outro?.('All set ✨');
|
|
3429
|
-
return;
|
|
3430
|
-
}
|
|
3431
|
-
await deps.io.note?.(`${blocker.title} — ${blocker.detail}\n${remedyLine(blocker)}`, 'Next');
|
|
3432
|
-
deps.io.out(`Next: ${blocker.title} — ${blocker.detail}`);
|
|
3433
|
-
deps.io.out(` ${remedyLine(blocker)}`);
|
|
3434
|
-
if (blocker.remedy?.by === 'user-elsewhere' || blocker.remedy?.by === 'user-here') {
|
|
3435
|
-
deps.io.out(' Then re-run `notifai init` and it will pick up from here.');
|
|
3436
|
-
}
|
|
3437
|
-
await deps.io.outro?.('One step remains (above)');
|
|
3438
|
-
return;
|
|
3439
|
-
}
|
|
3440
|
-
if (blocker === null) {
|
|
3441
|
-
printOptionalLeftovers(deps, leftovers);
|
|
3442
|
-
deps.io.out(questions
|
|
3443
|
-
? 'All set. Agents in this project can notify you and ask you questions.'
|
|
3444
|
-
: 'All set. Agents in this project can notify you. Questions stay in the terminal until hooks are installed.');
|
|
3445
|
-
return;
|
|
3446
|
-
}
|
|
3447
|
-
deps.io.out(`Next: ${blocker.title} — ${blocker.detail}`);
|
|
3448
|
-
deps.io.out(` ${remedyLine(blocker)}`);
|
|
3449
|
-
if (blocker.remedy?.by === 'user-elsewhere' || blocker.remedy?.by === 'user-here') {
|
|
3450
|
-
deps.io.out(' Then re-run `notifai init` and it will pick up from here.');
|
|
3451
|
-
}
|
|
3452
|
-
}
|
|
3453
|
-
// ---------------------------------------------------------------------------
|
|
3454
|
-
// doctor
|
|
3455
|
-
// ---------------------------------------------------------------------------
|
|
3456
|
-
/**
|
|
3457
|
-
* Whether the deployed server understands the contract this build speaks
|
|
3458
|
-
*.
|
|
3459
|
-
*
|
|
3460
|
-
* Every test in the suite runs the CLI and the server from the same commit, so
|
|
3461
|
-
* a client sending a field the deployed server has not learned yet is
|
|
3462
|
-
* structurally invisible to it. That gap shipped a silent production outage on
|
|
3463
|
-
* 2026-08-03: questions stopped reaching devices entirely and nothing said so.
|
|
3464
|
-
*
|
|
3465
|
-
* The capability document already carries a schema version and is served
|
|
3466
|
-
* unauthenticated, so a single GET answers the question. This does not claim to
|
|
3467
|
-
* catch every skew — an additive field inside the same schema version would
|
|
3468
|
-
* still pass — but it catches the one that has actually happened, and it names
|
|
3469
|
-
* the remedy rather than leaving someone to derive it.
|
|
3470
|
-
*/
|
|
3471
|
-
async function contractCheck(client) {
|
|
3472
|
-
const local = CAPABILITIES_V1.describe('ios')?.schema_version;
|
|
3473
|
-
try {
|
|
3474
|
-
const remote = (await client.capabilities('ios')).schema_version;
|
|
3475
|
-
if (local === remote) {
|
|
3476
|
-
return { name: 'contract', ok: true, detail: `server and CLI both speak schema v${remote}` };
|
|
3477
|
-
}
|
|
3478
|
-
return {
|
|
3479
|
-
name: 'contract',
|
|
3480
|
-
ok: false,
|
|
3481
|
-
detail: local !== undefined && remote < local
|
|
3482
|
-
? `server speaks schema v${remote}, this CLI speaks v${local} — the service is being updated; try again later`
|
|
3483
|
-
: `server speaks schema v${remote}, this CLI speaks v${local} — update the CLI: npm install -g @raidiant/notifai`,
|
|
3484
|
-
};
|
|
3485
|
-
}
|
|
3486
|
-
catch (err) {
|
|
3487
|
-
return {
|
|
3488
|
-
name: 'contract',
|
|
3489
|
-
ok: false,
|
|
3490
|
-
detail: `could not read the server capability document (${err instanceof ApiCallError ? err.code : String(err)})`,
|
|
3491
|
-
};
|
|
3492
|
-
}
|
|
3493
|
-
}
|
|
3494
|
-
function projectReadiness(deps, config) {
|
|
3495
|
-
const configured = config.project.value;
|
|
3496
|
-
if (configured !== null) {
|
|
3497
|
-
return {
|
|
3498
|
-
id: 'project',
|
|
3499
|
-
title: 'Project identity',
|
|
3500
|
-
status: 'ready',
|
|
3501
|
-
detail: `"${configured}" (${config.project.source})`,
|
|
3502
|
-
};
|
|
3503
|
-
}
|
|
3504
|
-
const inferred = inferInvocationContext(deps.cwd).project;
|
|
3505
|
-
if (inferred !== null) {
|
|
3506
|
-
return {
|
|
3507
|
-
id: 'project',
|
|
3508
|
-
title: 'Project identity',
|
|
3509
|
-
status: 'optional-gap',
|
|
3510
|
-
detail: `"${inferred}" is inferred for each send; init can stamp it into shared config`,
|
|
3511
|
-
remedy: {
|
|
3512
|
-
by: 'cli',
|
|
3513
|
-
summary: 'make the inferred Project identity explicit for every checkout',
|
|
3514
|
-
command: 'notifai init',
|
|
3515
|
-
},
|
|
3516
|
-
};
|
|
3517
|
-
}
|
|
3518
|
-
return {
|
|
3519
|
-
id: 'project',
|
|
3520
|
-
title: 'Project identity',
|
|
3521
|
-
status: 'optional-gap',
|
|
3522
|
-
detail: 'the directory name has no characters a Project identifier can use',
|
|
3523
|
-
remedy: {
|
|
3524
|
-
by: 'cli',
|
|
3525
|
-
summary: 'choose an explicit Project identifier',
|
|
3526
|
-
command: 'notifai init --project-id my-project',
|
|
3527
|
-
},
|
|
3528
|
-
};
|
|
3529
|
-
}
|
|
3530
|
-
function remoteStatesFrom(previous) {
|
|
3531
|
-
const pick = (id) => previous.states.find((state) => state.id === id);
|
|
3532
|
-
const credential = pick('credential');
|
|
3533
|
-
const server = pick('server');
|
|
3534
|
-
const contract = pick('contract');
|
|
3535
|
-
const auth = pick('auth');
|
|
3536
|
-
const devices = pick('devices');
|
|
3537
|
-
const proof = pick('proof');
|
|
3538
|
-
if (credential === undefined ||
|
|
3539
|
-
server === undefined ||
|
|
3540
|
-
contract === undefined ||
|
|
3541
|
-
auth === undefined ||
|
|
3542
|
-
devices === undefined ||
|
|
3543
|
-
proof === undefined) {
|
|
3544
|
-
return null;
|
|
3545
|
-
}
|
|
3546
|
-
return { credential, server, contract, auth, devices, proof };
|
|
3547
|
-
}
|
|
3548
|
-
function remoteInvalidatedByConfig(previous, config) {
|
|
3549
|
-
if (config.base_url.source === 'default')
|
|
3550
|
-
return false;
|
|
3551
|
-
const server = previous.states.find((state) => state.id === 'server');
|
|
3552
|
-
return server === undefined || !server.detail.includes(config.base_url.value);
|
|
3553
|
-
}
|
|
3554
|
-
/**
|
|
3555
|
-
* Read the whole setup once, in dependency order.
|
|
3556
|
-
*
|
|
3557
|
-
* Descent stops where a prerequisite is missing: without a credential there is
|
|
3558
|
-
* nothing to ask the server with, and without a reachable server a contract
|
|
3559
|
-
* mismatch is unknowable rather than absent. Those downstream states report
|
|
3560
|
-
* `unknown`, which is the honest answer and keeps a network outage from
|
|
3561
|
-
* looking like a broken install.
|
|
3562
|
-
*/
|
|
3563
|
-
export async function assessReadiness(deps, options = {}) {
|
|
3564
|
-
const config = loadLoggedConfig(deps, { cwd: deps.cwd, env: deps.env });
|
|
3565
|
-
const previous = options.previous;
|
|
3566
|
-
const refresh = options.refresh;
|
|
3567
|
-
if (previous !== undefined && refresh !== undefined && refresh.length === 0)
|
|
3568
|
-
return previous;
|
|
3569
|
-
const reuseRemote = previous !== undefined &&
|
|
3570
|
-
refresh !== undefined &&
|
|
3571
|
-
!refresh.includes('remote') &&
|
|
3572
|
-
!remoteInvalidatedByConfig(previous, config);
|
|
3573
|
-
if (reuseRemote) {
|
|
3574
|
-
const reused = remoteStatesFrom(previous);
|
|
3575
|
-
if (reused !== null) {
|
|
3576
|
-
return {
|
|
3577
|
-
states: [
|
|
3578
|
-
projectReadiness(deps, config),
|
|
3579
|
-
reused.credential,
|
|
3580
|
-
reused.server,
|
|
3581
|
-
reused.contract,
|
|
3582
|
-
reused.auth,
|
|
3583
|
-
...hookStates(deps),
|
|
3584
|
-
await skillReadiness(deps, options.skillScope),
|
|
3585
|
-
reused.devices,
|
|
3586
|
-
reused.proof,
|
|
3587
|
-
],
|
|
3588
|
-
};
|
|
3589
|
-
}
|
|
3590
|
-
}
|
|
3591
|
-
const states = [];
|
|
3592
|
-
let accountClient = null;
|
|
3593
|
-
let accountDevices = null;
|
|
3594
|
-
states.push(projectReadiness(deps, config));
|
|
3595
|
-
const credential = deps.store.load();
|
|
3596
|
-
states.push(credential
|
|
3597
|
-
? {
|
|
3598
|
-
id: 'credential',
|
|
3599
|
-
title: 'This machine',
|
|
3600
|
-
status: 'ready',
|
|
3601
|
-
detail: `paired as "${credential.machineName}" (${deps.store.describe()})`,
|
|
3602
|
-
}
|
|
3603
|
-
: {
|
|
3604
|
-
id: 'credential',
|
|
3605
|
-
title: 'This machine',
|
|
3606
|
-
status: 'gap',
|
|
3607
|
-
detail: 'not paired with your account',
|
|
3608
|
-
remedy: {
|
|
3609
|
-
by: 'user-here',
|
|
3610
|
-
summary: 'sign in — this opens your browser to approve the machine',
|
|
3611
|
-
command: 'notifai login',
|
|
3612
|
-
interactive: true,
|
|
3613
|
-
},
|
|
3614
|
-
});
|
|
3615
|
-
const baseUrl = resolvedBaseUrl(config, credential);
|
|
3616
|
-
const anon = makeClient(deps, baseUrl, null);
|
|
3617
|
-
// A probe that throws is unreachable, not a crash: this runs against a
|
|
3618
|
-
// half-configured machine by definition, which is where a client that
|
|
3619
|
-
// cannot even be constructed properly shows up.
|
|
3620
|
-
let reachable = false;
|
|
3621
|
-
try {
|
|
3622
|
-
reachable = await anon.health();
|
|
3623
|
-
}
|
|
3624
|
-
catch {
|
|
3625
|
-
reachable = false;
|
|
3626
|
-
}
|
|
3627
|
-
states.push(reachable
|
|
3628
|
-
? { id: 'server', title: 'Service', status: 'ready', detail: `${baseUrl} reachable` }
|
|
3629
|
-
: {
|
|
3630
|
-
id: 'server',
|
|
3631
|
-
title: 'Service',
|
|
3632
|
-
status: 'gap',
|
|
3633
|
-
detail: `cannot reach ${baseUrl}`,
|
|
3634
|
-
remedy: {
|
|
3635
|
-
by: 'user-here',
|
|
3636
|
-
summary: 'check your network',
|
|
3637
|
-
command: 'notifai doctor',
|
|
3638
|
-
},
|
|
3639
|
-
});
|
|
3640
|
-
if (!reachable) {
|
|
3641
|
-
states.push({
|
|
3642
|
-
id: 'contract',
|
|
3643
|
-
title: 'Protocol version',
|
|
3644
|
-
status: 'unknown',
|
|
3645
|
-
detail: 'not checked — the server is unreachable',
|
|
3646
|
-
});
|
|
3647
|
-
}
|
|
3648
|
-
else {
|
|
3649
|
-
const contract = await contractCheck(anon);
|
|
3650
|
-
states.push(contract.ok
|
|
3651
|
-
? { id: 'contract', title: 'Protocol version', status: 'ready', detail: contract.detail }
|
|
3652
|
-
: {
|
|
3653
|
-
id: 'contract',
|
|
3654
|
-
title: 'Protocol version',
|
|
3655
|
-
status: 'gap',
|
|
3656
|
-
detail: contract.detail,
|
|
3657
|
-
remedy: {
|
|
3658
|
-
by: 'user-here',
|
|
3659
|
-
summary: 'the CLI and server disagree; the detail above says which to move',
|
|
3660
|
-
command: 'notifai doctor',
|
|
3661
|
-
},
|
|
3662
|
-
});
|
|
3663
|
-
}
|
|
3664
|
-
let accountEmail = null;
|
|
3665
|
-
let accountLookupFailed = false;
|
|
3666
|
-
if (!credential || !reachable) {
|
|
3667
|
-
const why = !credential ? 'this machine is not paired' : 'the server is unreachable';
|
|
3668
|
-
states.push({ id: 'auth', title: 'Account', status: 'unknown', detail: `not checked — ${why}` });
|
|
3669
|
-
}
|
|
3670
|
-
else {
|
|
3671
|
-
const client = makeClient(deps, baseUrl, `Bearer nfm_${credential.machineId}.${credential.secret}`);
|
|
3672
|
-
accountClient = client;
|
|
3673
|
-
try {
|
|
3674
|
-
const [{ devices }, email] = await Promise.all([
|
|
3675
|
-
client.listDevices(),
|
|
3676
|
-
Promise.resolve()
|
|
3677
|
-
.then(async () => (await client.accessStatus()).email)
|
|
3678
|
-
.catch(() => null),
|
|
3679
|
-
]);
|
|
3680
|
-
accountDevices = devices;
|
|
3681
|
-
accountEmail = email;
|
|
3682
|
-
states.push({
|
|
3683
|
-
id: 'auth',
|
|
3684
|
-
title: 'Account',
|
|
3685
|
-
status: 'ready',
|
|
3686
|
-
detail: accountEmail
|
|
3687
|
-
? `machine ${credential.machineId} accepted (${accountEmail})`
|
|
3688
|
-
: `machine ${credential.machineId} accepted`,
|
|
3689
|
-
});
|
|
3690
|
-
}
|
|
3691
|
-
catch (err) {
|
|
3692
|
-
// A credential the server rejects is revocation, not absence, and the
|
|
3693
|
-
// remedy is the same sign-in either way.
|
|
3694
|
-
accountLookupFailed = true;
|
|
3695
|
-
states.push({
|
|
3696
|
-
id: 'auth',
|
|
3697
|
-
title: 'Account',
|
|
3698
|
-
status: 'gap',
|
|
3699
|
-
detail: err instanceof ApiCallError ? `${err.code}: ${err.message}` : String(err),
|
|
3700
|
-
remedy: {
|
|
3701
|
-
by: 'user-here',
|
|
3702
|
-
summary: 'this machine is no longer recognised; pair it again',
|
|
3703
|
-
command: 'notifai login',
|
|
3704
|
-
},
|
|
3705
|
-
});
|
|
3706
|
-
}
|
|
3707
|
-
}
|
|
3708
|
-
// Optional setup that works without a companion device must appear before the
|
|
3709
|
-
// device gap: init stops at the first user-elsewhere blocker, and hooks/skill
|
|
3710
|
-
// are reachable without a phone.
|
|
3711
|
-
states.push(...hookStates(deps));
|
|
3712
|
-
states.push(await skillReadiness(deps, options.skillScope));
|
|
3713
|
-
if (!credential || !reachable) {
|
|
3714
|
-
const why = !credential ? 'this machine is not paired' : 'the server is unreachable';
|
|
3715
|
-
states.push({ id: 'devices', title: 'Your devices', status: 'unknown', detail: `not checked — ${why}` });
|
|
3716
|
-
}
|
|
3717
|
-
else if (accountLookupFailed || accountDevices === null) {
|
|
3718
|
-
states.push({ id: 'devices', title: 'Your devices', status: 'unknown', detail: 'not checked — sign-in failed' });
|
|
3719
|
-
}
|
|
3720
|
-
else {
|
|
3721
|
-
const devices = accountDevices;
|
|
3722
|
-
const ready = devices.filter(deviceCanReceive);
|
|
3723
|
-
states.push(ready.length > 0
|
|
3724
|
-
? {
|
|
3725
|
-
id: 'devices',
|
|
3726
|
-
title: 'Your devices',
|
|
3727
|
-
status: 'ready',
|
|
3728
|
-
detail: `${ready.map((d) => d.display_name).join(', ')} ready to receive`,
|
|
3729
|
-
}
|
|
3730
|
-
: {
|
|
3731
|
-
id: 'devices',
|
|
3732
|
-
title: 'Your devices',
|
|
3733
|
-
status: 'gap',
|
|
3734
|
-
// The one gap that cannot be closed from this terminal, and the
|
|
3735
|
-
// likeliest place a first setup is abandoned. Naming which of
|
|
3736
|
-
// the three sub-states it is matters: "install the app" is
|
|
3737
|
-
// useless advice to someone who installed it and denied the
|
|
3738
|
-
// permission prompt. The live bridge is /support on the
|
|
3739
|
-
// dashboard origin — not a placeholder, and not typed by hand.
|
|
3740
|
-
detail: devices.length === 0
|
|
3741
|
-
? `nothing registered yet; install Notifai on iPhone or Mac via ${supportPageUrl(baseUrl)}`
|
|
3742
|
-
: `${devices.map((d) => `${d.display_name} (${d.permission_status})`).join(', ')} — registered but not able to receive`,
|
|
3743
|
-
remedy: {
|
|
3744
|
-
by: 'user-elsewhere',
|
|
3745
|
-
summary: deviceInstallRemedy({
|
|
3746
|
-
baseUrl,
|
|
3747
|
-
email: accountEmail,
|
|
3748
|
-
devices,
|
|
3749
|
-
}),
|
|
3750
|
-
},
|
|
3751
|
-
});
|
|
3752
|
-
}
|
|
3753
|
-
states.push(await setupProofState(deps, config, accountClient, accountDevices));
|
|
3754
|
-
return { states };
|
|
3755
|
-
}
|
|
3756
|
-
async function setupProofState(deps, config, client, devices) {
|
|
3757
|
-
if (client === null || devices === null) {
|
|
3758
|
-
return {
|
|
3759
|
-
id: 'proof',
|
|
3760
|
-
title: 'Delivery proof',
|
|
3761
|
-
status: 'unknown',
|
|
3762
|
-
detail: 'not checked — account and device readiness must be established first',
|
|
3763
|
-
};
|
|
3764
|
-
}
|
|
3765
|
-
const ready = devices.filter(deviceCanReceive);
|
|
3766
|
-
if (ready.length === 0) {
|
|
3767
|
-
return {
|
|
3768
|
-
id: 'proof',
|
|
3769
|
-
title: 'Delivery proof',
|
|
3770
|
-
status: 'unknown',
|
|
3771
|
-
detail: 'not checked — no companion device is ready',
|
|
3772
|
-
};
|
|
3773
|
-
}
|
|
3774
|
-
const ios = readyIosDevices(devices);
|
|
3775
|
-
if (ios.length === 0) {
|
|
3776
|
-
// Honest non-blocking caveat: notifications can reach the Mac; only the
|
|
3777
|
-
// receipt proof path is unavailable in this release. Never emit a Next:
|
|
3778
|
-
// step the user cannot satisfy, and never claim unobserved proof.
|
|
3779
|
-
return {
|
|
3780
|
-
id: 'proof',
|
|
3781
|
-
title: 'Delivery proof',
|
|
3782
|
-
status: 'optional-gap',
|
|
3783
|
-
detail: "unprovable in this release — notifications can reach your Mac, but the macOS path does not emit Companion Receipts (the app's delivery confirmation); receipt proof needs an iPhone",
|
|
3784
|
-
remedy: {
|
|
3785
|
-
by: 'user-elsewhere',
|
|
3786
|
-
summary: 'receipt proof needs an iPhone in this release (notifications still reach this Mac)',
|
|
3787
|
-
},
|
|
3788
|
-
};
|
|
3789
|
-
}
|
|
3790
|
-
const proof = readSetupProof(deps);
|
|
3791
|
-
const target = proof === null ? null : ios.find((device) => device.device_id === proof.device_id);
|
|
3792
|
-
if (proof === null || proof.project !== config.project.value || target === undefined) {
|
|
3793
|
-
return {
|
|
3794
|
-
id: 'proof',
|
|
3795
|
-
title: 'Delivery proof',
|
|
3796
|
-
status: 'gap',
|
|
3797
|
-
detail: "no Companion Receipt (the app's delivery confirmation) has proven this project on this machine yet",
|
|
3798
|
-
remedy: {
|
|
3799
|
-
by: 'cli',
|
|
3800
|
-
summary: "send one real verification notification and wait for its Companion Receipt (the app's delivery confirmation)",
|
|
3801
|
-
command: 'notifai init',
|
|
3802
|
-
},
|
|
3803
|
-
};
|
|
3804
|
-
}
|
|
3805
|
-
try {
|
|
3806
|
-
const snapshot = await client.evidence(proof.request_id);
|
|
3807
|
-
const observed = observedCompanionReceipt(snapshot, proof.device_id);
|
|
3808
|
-
if (observed) {
|
|
3809
|
-
return {
|
|
3810
|
-
id: 'proof',
|
|
3811
|
-
title: 'Delivery proof',
|
|
3812
|
-
status: 'ready',
|
|
3813
|
-
detail: `Companion Receipt (the app's delivery confirmation) observed from ${observed.delivery.device_name} at ${observed.observedAt} (${proof.request_id})`,
|
|
3814
|
-
};
|
|
3815
|
-
}
|
|
3816
|
-
return {
|
|
3817
|
-
id: 'proof',
|
|
3818
|
-
title: 'Delivery proof',
|
|
3819
|
-
status: 'gap',
|
|
3820
|
-
detail: `${proof.request_id} was sent, but its Companion Receipt (the app's delivery confirmation) is still unknown`,
|
|
3821
|
-
remedy: {
|
|
3822
|
-
by: 'cli',
|
|
3823
|
-
summary: 'check the same verification notification again',
|
|
3824
|
-
command: 'notifai init',
|
|
3825
|
-
},
|
|
3826
|
-
};
|
|
3827
|
-
}
|
|
3828
|
-
catch (err) {
|
|
3829
|
-
return {
|
|
3830
|
-
id: 'proof',
|
|
3831
|
-
title: 'Delivery proof',
|
|
3832
|
-
status: 'gap',
|
|
3833
|
-
detail: `could not read ${proof.request_id} evidence (${err instanceof ApiCallError ? err.code : String(err)})`,
|
|
3834
|
-
remedy: {
|
|
3835
|
-
by: 'cli',
|
|
3836
|
-
summary: 'retry the existing verification evidence check',
|
|
3837
|
-
command: 'notifai init',
|
|
3838
|
-
},
|
|
3839
|
-
};
|
|
3840
|
-
}
|
|
3841
|
-
}
|
|
3842
|
-
export async function doctorCommand(deps, flags, options = {}) {
|
|
3843
|
-
const readiness = options.readiness ?? (await assessReadiness(deps));
|
|
3844
|
-
const blocker = firstBlocker(readiness);
|
|
3845
|
-
const ok = blocker === null;
|
|
3846
|
-
if (flags.json) {
|
|
3847
|
-
deps.io.out(JSON.stringify({ ok, exit_code: ok ? EXIT.ok : EXIT.failed, states: readiness.states }, null, 2));
|
|
3848
|
-
return ok ? EXIT.ok : EXIT.failed;
|
|
3849
|
-
}
|
|
3850
|
-
const line = (s) => `${s.title}: ${s.detail}`;
|
|
3851
|
-
// Doctor reports everything — that is the difference from init, which acts
|
|
3852
|
-
// on one thing. It still names where to start, because a list of five
|
|
3853
|
-
// problems in dependency order has an obvious first move and saying so
|
|
3854
|
-
// costs nothing.
|
|
3855
|
-
if (deps.io.interactive === true && deps.io.check) {
|
|
3856
|
-
await deps.io.intro?.('Notifai doctor');
|
|
3857
|
-
for (const s of readiness.states) {
|
|
3858
|
-
// Four states, not two. Rendering `optional-gap` and `unknown` as
|
|
3859
|
-
// successes made a green report that had never checked half of what it
|
|
3860
|
-
// listed, and put a tick beside things the reader had deliberately
|
|
3861
|
-
// declined.
|
|
3862
|
-
await deps.io.check(s.status !== 'gap', line(s), doctorTone(s.status));
|
|
3863
|
-
}
|
|
3864
|
-
await deps.io.outro?.(ok ? 'Everything looks good' : `Start with: ${remedyLine(blocker)}`);
|
|
3865
|
-
}
|
|
3866
|
-
else {
|
|
3867
|
-
for (const s of readiness.states) {
|
|
3868
|
-
const mark = s.status === 'gap'
|
|
3869
|
-
? 'FAIL'
|
|
3870
|
-
: s.status === 'unknown'
|
|
3871
|
-
? ' ? '
|
|
3872
|
-
: s.status === 'optional-gap'
|
|
3873
|
-
? ' --'
|
|
3874
|
-
: 'ok ';
|
|
3875
|
-
deps.io.out(`${mark} ${line(s)}`);
|
|
3876
|
-
}
|
|
3877
|
-
if (!ok)
|
|
3878
|
-
deps.io.out(`\nStart with: ${remedyLine(blocker)}`);
|
|
3879
|
-
}
|
|
3880
|
-
return ok ? EXIT.ok : EXIT.failed;
|
|
3881
|
-
}
|
|
3882
|
-
/** Readiness status as a report tone. */
|
|
3883
|
-
function doctorTone(status) {
|
|
3884
|
-
switch (status) {
|
|
3885
|
-
case 'ready':
|
|
3886
|
-
return 'ok';
|
|
3887
|
-
case 'gap':
|
|
3888
|
-
return 'bad';
|
|
3889
|
-
case 'optional-gap':
|
|
3890
|
-
return 'warn';
|
|
3891
|
-
case 'unknown':
|
|
3892
|
-
return 'pending';
|
|
3893
|
-
}
|
|
3894
|
-
}
|
|
3895
|
-
/** One line telling the reader what to actually do about a state. */
|
|
3896
|
-
function remedyLine(state) {
|
|
3897
|
-
const remedy = state.remedy;
|
|
3898
|
-
if (!remedy)
|
|
3899
|
-
return state.detail;
|
|
3900
|
-
if (remedy.by === 'user-elsewhere')
|
|
3901
|
-
return remedy.summary;
|
|
3902
|
-
return remedy.command === undefined
|
|
3903
|
-
? remedy.summary
|
|
3904
|
-
: `${remedy.summary} — run \`${remedy.command}\``;
|
|
3905
|
-
}
|
|
3906
|
-
/**
|
|
3907
|
-
* Whether the hook installation is internally ready, plus evidence that a
|
|
3908
|
-
* project session has fired it before. This cannot prove future execution or
|
|
3909
|
-
* end-to-end notification delivery without a live harness and device test.
|
|
3910
|
-
*
|
|
3911
|
-
* Every failure mode here was found the expensive way, by spawning a session
|
|
3912
|
-
* and watching nothing happen: hooks not installed, installed but never fired,
|
|
3913
|
-
* or left behind by an older build that named events this one does not serve.
|
|
3914
|
-
*/
|
|
3915
|
-
/**
|
|
3916
|
-
* Hook diagnostics as readiness states.
|
|
3917
|
-
*
|
|
3918
|
-
* A thin adapter over `hookChecks`, whose every branch was found the expensive
|
|
3919
|
-
* way and is not worth re-deriving. The judgment added here is which failures
|
|
3920
|
-
* actually stand in the way.
|
|
3921
|
-
*
|
|
3922
|
-
* Not everything failed is in the way. A pointer that has never been
|
|
3923
|
-
* published is the normal condition of an install thirty seconds old — the
|
|
3924
|
-
* next prompt fixes it and no command can. Treating that as blocking would
|
|
3925
|
-
* mean `init` could only finish after a session had already run — so it
|
|
3926
|
-
* reports as something worth knowing rather than something to
|
|
3927
|
-
* fix, and `init` walks on to the states it can actually close, delivery
|
|
3928
|
-
* proof included. Which failures are informational, and what the true remedy
|
|
3929
|
-
* is, is each check's own call (`HookCheck`).
|
|
3930
|
-
*/
|
|
3931
|
-
function hookStates(deps) {
|
|
3932
|
-
const installations = findInstallations(deps.cwd, deps.env, deps.hookAdapterHome, deps.hookPlatform);
|
|
3933
|
-
const active = activeHarnessSession(deps.env, deps.cwd, (deps.now ?? Date.now)());
|
|
3934
|
-
const config = loadLoggedConfig(deps, { cwd: deps.cwd, env: deps.env });
|
|
3935
|
-
const settings = {
|
|
3936
|
-
id: 'question-routing-settings',
|
|
3937
|
-
title: 'Question routing settings',
|
|
3938
|
-
status: config.ask_notifications.value ? 'ready' : 'gap',
|
|
3939
|
-
detail: [
|
|
3940
|
-
`ask_notifications=${config.ask_notifications.value} (${config.ask_notifications.source})`,
|
|
3941
|
-
`ask_grace_seconds=${config.ask_grace_seconds.value} (${config.ask_grace_seconds.source})`,
|
|
3942
|
-
].join(', '),
|
|
3943
|
-
...(config.ask_notifications.value
|
|
3944
|
-
? {}
|
|
3945
|
-
: {
|
|
3946
|
-
remedy: {
|
|
3947
|
-
by: 'cli',
|
|
3948
|
-
summary: 'enable asynchronous question routing',
|
|
3949
|
-
command: 'notifai config set ask_notifications true',
|
|
3950
|
-
},
|
|
3951
|
-
}),
|
|
3952
|
-
};
|
|
3953
|
-
if (installations.length === 0) {
|
|
3954
|
-
return [
|
|
3955
|
-
{
|
|
3956
|
-
id: 'hooks',
|
|
3957
|
-
title: 'Question routing',
|
|
3958
|
-
status: active === null ? 'optional-gap' : 'gap',
|
|
3959
|
-
detail: active === null
|
|
3960
|
-
? 'hooks not installed, so questions stay in the terminal'
|
|
3961
|
-
: `active ${active.label} session detected, but ${active.label} hooks are not installed; \`notifai ask\` cannot route this session`,
|
|
3962
|
-
remedy: {
|
|
3963
|
-
by: 'cli',
|
|
3964
|
-
summary: 'install harness hooks so questions reach your devices when you are away',
|
|
3965
|
-
command: active === null
|
|
3966
|
-
? 'notifai hooks install'
|
|
3967
|
-
: `notifai hooks install --harness ${active.harness}`,
|
|
3968
|
-
},
|
|
3969
|
-
},
|
|
3970
|
-
settings,
|
|
3971
|
-
];
|
|
3972
|
-
}
|
|
3973
|
-
/**
|
|
3974
|
-
* A human title per check.
|
|
3975
|
-
*
|
|
3976
|
-
* Three checks used to collapse onto "Question routing", so a reader could not
|
|
3977
|
-
* tell which of them had failed, and the rest fell through to their internal
|
|
3978
|
-
* name — `hooks (stale)` beside `Delivery proof`. The `id` stays the stable
|
|
3979
|
-
* thing to branch on; this is only what a person reads.
|
|
3980
|
-
*/
|
|
3981
|
-
const CHECK_TITLES = {
|
|
3982
|
-
hooks: 'Question routing',
|
|
3983
|
-
'hooks (detected)': 'Harnesses detected',
|
|
3984
|
-
'hooks (active harness)': 'Routing for this harness',
|
|
3985
|
-
'hooks (active session)': 'Routing for this session',
|
|
3986
|
-
'hooks (stale)': 'Hook definitions current',
|
|
3987
|
-
'hooks (adapter)': 'Hook adapter',
|
|
3988
|
-
'hooks (trust)': 'Codex hook trust',
|
|
3989
|
-
'hooks (stop shape)': 'Turn-end hook shape',
|
|
3990
|
-
'hooks (duplicates)': 'Duplicate hook installs',
|
|
3991
|
-
'hooks (codex representation)': 'Codex hook representation',
|
|
3992
|
-
'hooks (question admission)': 'Question admission',
|
|
3993
|
-
'hooks (fired)': 'Hooks have run here',
|
|
3994
|
-
'hooks (answer continuation)': 'How an answer returns',
|
|
3995
|
-
'hooks (wake route)': 'Direct wake route',
|
|
3996
|
-
};
|
|
3997
|
-
function checkTitle(name) {
|
|
3998
|
-
return CHECK_TITLES[name] ?? name;
|
|
3999
|
-
}
|
|
4000
|
-
/** Real but not in the way; see the note above. */
|
|
4001
|
-
const informational = new Set();
|
|
4002
|
-
return [
|
|
4003
|
-
...hookChecks(deps).map((check) => ({
|
|
4004
|
-
id: check.name.replace(/[ ()]+/g, '-').replace(/-$/, ''),
|
|
4005
|
-
title: checkTitle(check.name),
|
|
4006
|
-
status: check.ok
|
|
4007
|
-
? 'ready'
|
|
4008
|
-
: check.informational === true || informational.has(check.name)
|
|
4009
|
-
? 'optional-gap'
|
|
4010
|
-
: 'gap',
|
|
4011
|
-
detail: check.detail,
|
|
4012
|
-
...(check.ok
|
|
4013
|
-
? {}
|
|
4014
|
-
: {
|
|
4015
|
-
remedy: {
|
|
4016
|
-
by: 'user-here',
|
|
4017
|
-
// The check's own remedy when it has one; the generic reinstall
|
|
4018
|
-
// line was wrong exactly where it mattered (an unfired pointer
|
|
4019
|
-
// needs a prompt, not `hooks install`).
|
|
4020
|
-
...(check.remedy ?? {
|
|
4021
|
-
summary: 'the detail above names what to change',
|
|
4022
|
-
command: 'notifai hooks install',
|
|
4023
|
-
}),
|
|
4024
|
-
},
|
|
4025
|
-
}),
|
|
4026
|
-
})),
|
|
4027
|
-
settings,
|
|
4028
|
-
];
|
|
4029
|
-
}
|
|
4030
|
-
/**
|
|
4031
|
-
* Whether an installed Stop handler declares the shape its harness needs.
|
|
4032
|
-
*
|
|
4033
|
-
* The three answers differ, and getting one wrong fails silently in a
|
|
4034
|
-
* different way each time:
|
|
4035
|
-
*
|
|
4036
|
-
* - Claude Code's handler must be `async: true`, or the waiter holds the
|
|
4037
|
-
* user's turn for its whole wait instead of returning at once. It must
|
|
4038
|
-
* also declare a `timeout`, because the harness default is 600 s and the
|
|
4039
|
-
* kill is silent — the backgrounded waiter vanishes and the answer the
|
|
4040
|
-
* user already gave is never delivered.
|
|
4041
|
-
* - Codex owns its Stop timeout. Declaring one changes the definition it
|
|
4042
|
-
* hashes into `trusted_hash`, and an untrusted handler is simply not run.
|
|
4043
|
-
* - Everything else blocks its turn and needs a ceiling above the wait.
|
|
4044
|
-
*/
|
|
4045
|
-
function stopShapeProblems(installation, platform = process.platform) {
|
|
4046
|
-
if (installation.harness === 'codex')
|
|
4047
|
-
return [];
|
|
4048
|
-
const problems = [];
|
|
4049
|
-
for (const handler of installation.handlers.filter((entry) => handlerEvent(entry.command) === 'stop')) {
|
|
4050
|
-
if (stopHandlerIsDetached(installation.harness, platform)) {
|
|
4051
|
-
if (handler.async !== true) {
|
|
4052
|
-
problems.push(`${installation.file} declares a blocking Stop handler; the Claude Code wake route needs \`async: true\` so the turn ends while the waiter runs`);
|
|
4053
|
-
}
|
|
4054
|
-
if (handler.timeout === undefined || handler.timeout < CLAUDE_ASYNC_STOP_TIMEOUT_SECONDS) {
|
|
4055
|
-
problems.push(`${installation.file} gives Stop ${handler.timeout ?? 'no'} declared seconds; Claude Code then kills the backgrounded waiter at its 600s default without reporting anything, so it needs an explicit ${CLAUDE_ASYNC_STOP_TIMEOUT_SECONDS}s`);
|
|
4056
|
-
}
|
|
4057
|
-
continue;
|
|
4058
|
-
}
|
|
4059
|
-
if (handler.timeout === undefined || handler.timeout < BLOCKING_STOP_TIMEOUT_SECONDS) {
|
|
4060
|
-
problems.push(`${installation.file} gives Stop ${handler.timeout ?? 'no'}s, but the blocking answer owner requires ${BLOCKING_STOP_TIMEOUT_SECONDS}s`);
|
|
4061
|
-
}
|
|
4062
|
-
}
|
|
4063
|
-
return problems;
|
|
4064
|
-
}
|
|
4065
|
-
function hookChecks(deps) {
|
|
4066
|
-
const checks = [];
|
|
4067
|
-
const installations = findInstallations(deps.cwd, deps.env, deps.hookAdapterHome, deps.hookPlatform);
|
|
4068
|
-
// Not having hooks is a setup someone chose, not a fault: `send` works
|
|
4069
|
-
// without them. A setup that cannot work is what deserves to go red.
|
|
4070
|
-
if (installations.length === 0) {
|
|
4071
|
-
checks.push({
|
|
4072
|
-
name: 'hooks',
|
|
4073
|
-
ok: true,
|
|
4074
|
-
detail: 'not installed (optional) — `notifai hooks install` adds question routing',
|
|
4075
|
-
});
|
|
4076
|
-
return checks;
|
|
4077
|
-
}
|
|
4078
|
-
checks.push({
|
|
4079
|
-
name: 'hooks',
|
|
4080
|
-
ok: true,
|
|
4081
|
-
detail: installations
|
|
4082
|
-
.map((i) => `${i.harness} ${i.global ? 'global' : 'project'} (${i.file})`)
|
|
4083
|
-
.join(', '),
|
|
4084
|
-
});
|
|
4085
|
-
const wired = new Set(installations.map((installation) => installation.harness));
|
|
4086
|
-
const unwired = detectedHarnesses(deps.cwd, deps.env).filter((harness) => !wired.has(harness));
|
|
4087
|
-
if (unwired.length > 0) {
|
|
4088
|
-
checks.push({
|
|
4089
|
-
name: 'hooks (detected)',
|
|
4090
|
-
ok: false,
|
|
4091
|
-
informational: true,
|
|
4092
|
-
detail: `${unwired.map((harness) => HARNESS_LABELS[harness]).join(', ')} detected on this machine but not wired`,
|
|
4093
|
-
remedy: {
|
|
4094
|
-
summary: 'install hooks for every detected harness',
|
|
4095
|
-
command: 'notifai hooks install',
|
|
4096
|
-
},
|
|
4097
|
-
});
|
|
4098
|
-
}
|
|
4099
|
-
const { active, contested } = resolveActiveHarness(deps.env, deps.cwd, (deps.now ?? Date.now)());
|
|
4100
|
-
const activeInstallations = active === null
|
|
4101
|
-
? []
|
|
4102
|
-
: installations.filter((installation) => installation.harness === active.harness);
|
|
4103
|
-
if (active !== null) {
|
|
4104
|
-
checks.push({
|
|
4105
|
-
name: 'hooks (active harness)',
|
|
4106
|
-
ok: activeInstallations.length > 0,
|
|
4107
|
-
detail: activeInstallations.length > 0
|
|
4108
|
-
? `active ${active.label} session has a matching hook installation`
|
|
4109
|
-
: `active ${active.label} session has no matching hook installation — run \`notifai hooks install --harness ${active.harness}\``,
|
|
4110
|
-
...(activeInstallations.length > 0
|
|
4111
|
-
? {}
|
|
4112
|
-
: {
|
|
4113
|
-
remedy: {
|
|
4114
|
-
summary: `install hooks for the active ${active.label} session`,
|
|
4115
|
-
command: `notifai hooks install --harness ${active.harness}`,
|
|
4116
|
-
},
|
|
4117
|
-
}),
|
|
4118
|
-
});
|
|
4119
|
-
if (activeInstallations.length > 0) {
|
|
4120
|
-
const pointer = active.sessionId === undefined
|
|
4121
|
-
? null
|
|
4122
|
-
: readMatchingProjectSessionPointer(deps.cwd, deps.env, (deps.now ?? Date.now)(), active.sessionId, active.harness);
|
|
4123
|
-
if (pointer === null) {
|
|
4124
|
-
// The normal condition of hooks installed moments ago: the pointer
|
|
4125
|
-
// appears when the harness next fires a hook, and no command can
|
|
4126
|
-
// force that. Informational, so `init` walks on to the states it can
|
|
4127
|
-
// actually prove instead of exiting over evidence only time produces.
|
|
4128
|
-
checks.push({
|
|
4129
|
-
name: 'hooks (active session)',
|
|
4130
|
-
ok: false,
|
|
4131
|
-
informational: true,
|
|
4132
|
-
detail: `active ${active.label} session has not published a live pointer — send one ${active.label} prompt, then check again`,
|
|
4133
|
-
remedy: {
|
|
4134
|
-
summary: `send one ${active.label} prompt — its hook publishes the routing pointer`,
|
|
4135
|
-
command: 'notifai doctor',
|
|
4136
|
-
},
|
|
4137
|
-
});
|
|
4138
|
-
}
|
|
4139
|
-
else {
|
|
4140
|
-
checks.push({
|
|
4141
|
-
name: 'hooks (active session)',
|
|
4142
|
-
ok: true,
|
|
4143
|
-
detail: `the concurrent project index contains the active ${active.label} session`,
|
|
4144
|
-
});
|
|
4145
|
-
}
|
|
4146
|
-
}
|
|
4147
|
-
}
|
|
4148
|
-
// A handler naming an event this build dropped exits 2 every time the harness
|
|
4149
|
-
// fires it, which the harness reports as a hook failure.
|
|
4150
|
-
const stale = installations.flatMap((i) => i.handlers
|
|
4151
|
-
.filter((h) => {
|
|
4152
|
-
const event = handlerEvent(h.command);
|
|
4153
|
-
return event !== null && !HOOK_EVENTS.includes(event);
|
|
4154
|
-
})
|
|
4155
|
-
.map((h) => `${h.event} -> ${handlerEvent(h.command)} in ${i.file}`));
|
|
4156
|
-
checks.push({
|
|
4157
|
-
name: 'hooks (stale)',
|
|
4158
|
-
ok: stale.length === 0,
|
|
4159
|
-
detail: stale.length === 0
|
|
4160
|
-
? 'every installed handler names an event this build serves'
|
|
4161
|
-
: `${stale.join('; ')} — rerun \`notifai hooks install\` to drop ${stale.length === 1 ? 'it' : 'them'}`,
|
|
4162
|
-
});
|
|
4163
|
-
const adapterProblems = installations.flatMap((installation) => (installation.problems ?? []).map((problem) => `${installation.file}: ${problem}`));
|
|
4164
|
-
const sharedAdapter = inspectHookAdapter(deps.hookAdapterHome, deps.hookPlatform);
|
|
4165
|
-
adapterProblems.push(...sharedAdapter.problems);
|
|
4166
|
-
if (adapterProblems.length > 0) {
|
|
4167
|
-
checks.push({
|
|
4168
|
-
name: 'hooks (adapter)',
|
|
4169
|
-
ok: false,
|
|
4170
|
-
// A machine-global install for a harness that is not active in this
|
|
4171
|
-
// project is useful diagnosis, but it must not block unrelated init.
|
|
4172
|
-
informational: active === null && installations.every((installation) => installation.global),
|
|
4173
|
-
detail: adapterProblems.join('; '),
|
|
4174
|
-
});
|
|
4175
|
-
}
|
|
4176
|
-
const trustProblems = codexTrustProblems(installations, deps.env);
|
|
4177
|
-
checks.push({
|
|
4178
|
-
name: 'hooks (trust)',
|
|
4179
|
-
ok: trustProblems.length === 0,
|
|
4180
|
-
detail: trustProblems.length === 0
|
|
4181
|
-
? 'best-effort check matches current persisted Codex approvals; Notifai never writes the trust store, and `/hooks` is authoritative'
|
|
4182
|
-
: `best-effort check only; Notifai never writes the trust store and \`/hooks\` is authoritative. ${trustProblems.join('; ')}`,
|
|
4183
|
-
...(trustProblems.length === 0
|
|
4184
|
-
? {}
|
|
4185
|
-
: {
|
|
4186
|
-
remedy: {
|
|
4187
|
-
summary: 'open `/hooks` in Codex and approve the changed Notifai handlers',
|
|
4188
|
-
command: '/hooks',
|
|
4189
|
-
},
|
|
4190
|
-
}),
|
|
4191
|
-
});
|
|
4192
|
-
const shapeProblems = installations.flatMap((installation) => stopShapeProblems(installation, deps.hookPlatform).map((problem) => `${problem} — run \`notifai hooks install --harness ${installation.harness}${installation.global ? ' --global' : ''}\``));
|
|
4193
|
-
checks.push({
|
|
4194
|
-
name: 'hooks (stop shape)',
|
|
4195
|
-
ok: shapeProblems.length === 0,
|
|
4196
|
-
detail: shapeProblems.length === 0
|
|
4197
|
-
? `every installed Stop handler declares the shape its harness needs: Claude Code async with an explicit ${CLAUDE_ASYNC_STOP_TIMEOUT_SECONDS}s waiter budget, blocking hosts ${BLOCKING_STOP_TIMEOUT_SECONDS}s, Codex host-owned`
|
|
4198
|
-
: shapeProblems.join('; '),
|
|
4199
|
-
});
|
|
4200
|
-
// Project and global definitions for one harness both fire. Stable adapter
|
|
4201
|
-
// identity deliberately makes their command bytes equal, so comparing
|
|
4202
|
-
// command targets would now hide this duplicate rather than diagnose it.
|
|
4203
|
-
// Different harnesses remain independent: only the active one runs.
|
|
4204
|
-
const duplicated = [...new Set(installations.map((i) => i.harness))]
|
|
4205
|
-
.map((harness) => ({
|
|
4206
|
-
harness,
|
|
4207
|
-
installations: installations.filter((i) => i.harness === harness),
|
|
4208
|
-
}))
|
|
4209
|
-
.filter((entry) => entry.installations.some((installation) => installation.global) &&
|
|
4210
|
-
entry.installations.some((installation) => !installation.global));
|
|
4211
|
-
if (duplicated.length > 0) {
|
|
4212
|
-
checks.push({
|
|
4213
|
-
name: 'hooks (duplicates)',
|
|
4214
|
-
ok: false,
|
|
4215
|
-
detail: duplicated
|
|
4216
|
-
.map((entry) => `${entry.harness}: ${entry.installations.length} hook definitions are active, so each event will fire all of them. Keep either project or global routing and uninstall the other: ${entry.installations.map((installation) => installation.file).join(', ')}`)
|
|
4217
|
-
.join('; '),
|
|
4218
|
-
});
|
|
4219
|
-
}
|
|
4220
|
-
const representationProblems = codexRepresentationProblems(deps.cwd, deps.env, deps.hookPlatform);
|
|
4221
|
-
if (representationProblems.length > 0) {
|
|
4222
|
-
checks.push({
|
|
4223
|
-
name: 'hooks (codex representation)',
|
|
4224
|
-
ok: false,
|
|
4225
|
-
detail: representationProblems.join('; '),
|
|
4226
|
-
});
|
|
4227
|
-
}
|
|
4228
|
-
else {
|
|
4229
|
-
const coexistence = codexCoexistenceNotes(deps.cwd, deps.env, deps.hookPlatform);
|
|
4230
|
-
if (coexistence.length > 0) {
|
|
4231
|
-
checks.push({
|
|
4232
|
-
name: 'hooks (codex representation)',
|
|
4233
|
-
ok: true,
|
|
4234
|
-
detail: coexistence.join('; '),
|
|
4235
|
-
});
|
|
4236
|
-
}
|
|
4237
|
-
}
|
|
4238
|
-
const codexHome = codexHomeNote(deps.env, deps.hookPlatform);
|
|
4239
|
-
if (codexHome !== null) {
|
|
4240
|
-
checks.push({ name: 'hooks (codex home)', ok: true, detail: codexHome });
|
|
4241
|
-
}
|
|
4242
|
-
if (active !== null && activeInstallations.length > 0) {
|
|
4243
|
-
const admissionProblems = activeQuestionRouteProblems(deps, active, installations);
|
|
4244
|
-
checks.push({
|
|
4245
|
-
name: 'hooks (question admission)',
|
|
4246
|
-
ok: admissionProblems.length === 0,
|
|
4247
|
-
detail: admissionProblems.length === 0
|
|
4248
|
-
? `the active ${active.label} route is exact, current, singular, trusted where applicable, and bounded by a live owner`
|
|
4249
|
-
: admissionProblems.join('; '),
|
|
4250
|
-
});
|
|
4251
|
-
}
|
|
4252
|
-
// Which route this judges, and which remedy it prints, must both belong to
|
|
4253
|
-
// the harness that is actually running here. A machine-global installation
|
|
4254
|
-
// matches every directory, so picking whichever installation matched — or
|
|
4255
|
-
// whichever session last wrote a pointer — reports on a harness the agent
|
|
4256
|
-
// cannot influence: it is told to send a prompt in a harness that is not
|
|
4257
|
-
// running, follows the fail-closed rule, and can never clear the check.
|
|
4258
|
-
const firedPointer = active === null
|
|
4259
|
-
? readProjectSessionPointer(deps.cwd, deps.env, (deps.now ?? Date.now)())
|
|
4260
|
-
: active.sessionId === undefined
|
|
4261
|
-
? (readLiveProjectSessionPointers(deps.cwd, deps.env, (deps.now ?? Date.now)()).find((pointer) => pointer.harness === active.harness) ?? null)
|
|
4262
|
-
: readMatchingProjectSessionPointer(deps.cwd, deps.env, (deps.now ?? Date.now)(), active.sessionId, active.harness);
|
|
4263
|
-
const firedState = firedPointer === null ? null : readSessionState(firedPointer.sessionId, deps.env);
|
|
4264
|
-
const promptFired = firedState?.last_prompt_at !== undefined;
|
|
4265
|
-
const stopFired = firedState?.last_stop_at !== undefined;
|
|
4266
|
-
const fired = firedPointer !== null && promptFired && stopFired;
|
|
4267
|
-
// Installations for other harnesses are irrelevant to the active one, and an
|
|
4268
|
-
// active harness with none of its own has nothing to activate: say that
|
|
4269
|
-
// instead of advising a prompt in some other harness. When the environment
|
|
4270
|
-
// is contested and nothing has fired, every candidate is still possible, so
|
|
4271
|
-
// the advice covers all of them rather than betting on one.
|
|
4272
|
-
const activationHarnesses = new Set(contested.length > 1
|
|
4273
|
-
? contested.map((candidate) => candidate.harness)
|
|
4274
|
-
: active === null
|
|
4275
|
-
? installations.map((installation) => installation.harness)
|
|
4276
|
-
: [active.harness]);
|
|
4277
|
-
const activationInstallations = installations.filter((installation) => activationHarnesses.has(installation.harness));
|
|
4278
|
-
const activationAdvice = activationInstallations.length > 0
|
|
4279
|
-
? hookActivationAdvice(activationInstallations)
|
|
4280
|
-
: active === null
|
|
4281
|
-
? hookActivationAdvice(installations)
|
|
4282
|
-
: `${active.label}: no ${active.label} hook installation matches this project — run \`notifai hooks install --harness ${active.harness}\`.`;
|
|
4283
|
-
checks.push({
|
|
4284
|
-
name: 'hooks (fired)',
|
|
4285
|
-
ok: fired,
|
|
4286
|
-
// A wholly fresh install is informational. Once UserPromptSubmit has fired,
|
|
4287
|
-
// a missing Stop is a broken route, not missing historical evidence.
|
|
4288
|
-
informational: firedPointer === null,
|
|
4289
|
-
detail: fired
|
|
4290
|
-
? active === null
|
|
4291
|
-
? 'a session in this directory has run UserPromptSubmit and Stop'
|
|
4292
|
-
: `the active ${active.label} session has run UserPromptSubmit and Stop`
|
|
4293
|
-
: firedPointer === null
|
|
4294
|
-
? `no ${contested.length > 1
|
|
4295
|
-
? `session pointer for any harness whose markers are present here (${contested
|
|
4296
|
-
.map((candidate) => candidate.label)
|
|
4297
|
-
.join(', ')})`
|
|
4298
|
-
: active === null
|
|
4299
|
-
? 'session pointer'
|
|
4300
|
-
: `${active.label} session pointer`} from the last 24 hours — ${activationAdvice}`
|
|
4301
|
-
: `the routed session has fired ${promptFired ? 'UserPromptSubmit' : 'neither required event'}, but Stop has not been observed — end one harmless turn, send a new prompt, then check again`,
|
|
4302
|
-
...(fired
|
|
4303
|
-
? {}
|
|
4304
|
-
: {
|
|
4305
|
-
remedy: {
|
|
4306
|
-
summary: firedPointer === null
|
|
4307
|
-
? `send one ${active === null || contested.length > 1 ? '' : `${active.label} `}prompt in a session here, then re-check`
|
|
4308
|
-
: 'end one harmless turn, send a new prompt, then re-check',
|
|
4309
|
-
command: 'notifai doctor',
|
|
4310
|
-
},
|
|
4311
|
-
}),
|
|
4312
|
-
});
|
|
4313
|
-
const continuationInstallations = active !== null ? activeInstallations : installations;
|
|
4314
|
-
const continuationHarnesses = [
|
|
4315
|
-
...new Set(continuationInstallations.map((installation) => installation.harness)),
|
|
4316
|
-
];
|
|
4317
|
-
checks.push({
|
|
4318
|
-
name: 'hooks (answer continuation)',
|
|
4319
|
-
ok: continuationHarnesses.length > 0 &&
|
|
4320
|
-
continuationHarnesses.every((harness) => HARNESS_CAPABILITIES[harness].stopContinuation !== 'unsupported'),
|
|
4321
|
-
informational: active === null,
|
|
4322
|
-
detail: continuationHarnesses.length === 0
|
|
4323
|
-
? active === null
|
|
4324
|
-
? 'no installed harness route to assess'
|
|
4325
|
-
: `the active ${active.label} session has no matching continuation adapter`
|
|
4326
|
-
: continuationHarnesses
|
|
4327
|
-
.map((harness) => `${harness}: ${HARNESS_CAPABILITIES[harness].deliveryContract}`)
|
|
4328
|
-
.join('; '),
|
|
4329
|
-
});
|
|
4330
|
-
const wakeRoute = wakeRouteCheck(deps, active, activeInstallations);
|
|
4331
|
-
if (wakeRoute !== null)
|
|
4332
|
-
checks.push(wakeRoute);
|
|
4333
|
-
const stray = codexStrayWorktreeCheck(deps);
|
|
4334
|
-
if (stray !== null)
|
|
4335
|
-
checks.push(stray);
|
|
4336
|
-
return checks;
|
|
4337
|
-
}
|
|
4338
|
-
/**
|
|
4339
|
-
* Whether an answer arriving after this turn ends could actually reach this
|
|
4340
|
-
* exact session — the question `notifai ask` really turns on, and the one no
|
|
4341
|
-
* other check answers.
|
|
4342
|
-
*
|
|
4343
|
-
* Read-only, and deliberately so: nothing here connects to a socket, takes a
|
|
4344
|
-
* lock, or sends a message. A diagnostic that wakes the agent it is diagnosing
|
|
4345
|
-
* would be its own bug report.
|
|
4346
|
-
*
|
|
4347
|
-
* Everything it can report negatively is a degradation rather than a failure —
|
|
4348
|
-
* the accepted journal still replays the answer at the session's next turn —
|
|
4349
|
-
* so this is never a blocker. What it buys is that the reason has a name
|
|
4350
|
-
* before the user notices the silence.
|
|
4351
|
-
*
|
|
4352
|
-
* It never asks for `crossSessionInbound`. The poster is the session's own
|
|
4353
|
-
* hook child and takes the privileged own-child path, verified to be delivered
|
|
4354
|
-
* even against a `bypassPermissions` receiver while an unrelated process was
|
|
4355
|
-
* held. Widening a user's general inbound policy to suit Notifai would be a
|
|
4356
|
-
* real change to their machine's posture in exchange for nothing.
|
|
4357
|
-
*/
|
|
4358
|
-
function wakeRouteCheck(deps, active, activeInstallations) {
|
|
4359
|
-
if (active === null || activeInstallations.length === 0)
|
|
4360
|
-
return null;
|
|
4361
|
-
if (active.harness === 'claude-code') {
|
|
4362
|
-
const readiness = inspectClaudeInbox({
|
|
4363
|
-
pid: deps.claudeSourcePid ?? claudeSessionPid(deps.env),
|
|
4364
|
-
platform: deps.hookPlatform ?? process.platform,
|
|
4365
|
-
readDescriptor: deps.claudeWake?.readDescriptor ?? systemClaudeWakeAdapters(deps.env).readDescriptor,
|
|
4366
|
-
socketExists: (socketPath) => existsSync(socketPath),
|
|
4367
|
-
});
|
|
4368
|
-
return {
|
|
4369
|
-
name: 'hooks (wake route)',
|
|
4370
|
-
ok: readiness.state === 'ready',
|
|
4371
|
-
informational: true,
|
|
4372
|
-
detail: readiness.state === 'ready'
|
|
4373
|
-
? `this Claude Code ${readiness.version} session is listening on ${readiness.socketPath}, so an answer can start a turn here without you`
|
|
4374
|
-
: `${readiness.reason}. Answers are still delivered, at this session's next turn rather than on their own`,
|
|
4375
|
-
};
|
|
4376
|
-
}
|
|
4377
|
-
if (active.harness === 'codex') {
|
|
4378
|
-
const readiness = inspectCodexResume(deps.env, {
|
|
4379
|
-
platform: deps.hookPlatform ?? process.platform,
|
|
4380
|
-
directoryExists: (directory) => existsSync(directory),
|
|
4381
|
-
});
|
|
4382
|
-
return {
|
|
4383
|
-
name: 'hooks (wake route)',
|
|
4384
|
-
ok: readiness.state === 'ready',
|
|
4385
|
-
informational: true,
|
|
4386
|
-
detail: readiness.state === 'ready'
|
|
4387
|
-
? `the held Codex turn continues from its own hook, and after it returns ${readiness.lockDirectory} can prove a stopped thread unowned before resuming it`
|
|
4388
|
-
: `the held Codex turn still continues from its own hook, but after it returns nothing can be resumed: ${readiness.reason}. Answers wait for the next turn`,
|
|
4389
|
-
};
|
|
4390
|
-
}
|
|
4391
|
-
return null;
|
|
4392
|
-
}
|
|
4393
|
-
/**
|
|
4394
|
-
* A Codex hooks file sitting in a worktree, which Codex will never read.
|
|
4395
|
-
*
|
|
4396
|
-
* `settingsFile` now writes to the main repository, so this only fires for a
|
|
4397
|
-
* file an older build left behind — but that file is indistinguishable from a
|
|
4398
|
-
* working install if you go looking, and it is exactly what made this bug take
|
|
4399
|
-
* a day to find. Omitted entirely when there is nothing to say.
|
|
4400
|
-
*/
|
|
4401
|
-
function codexStrayWorktreeCheck(deps) {
|
|
4402
|
-
const layer = codexLayerDir(deps.cwd);
|
|
4403
|
-
if (layer === null)
|
|
4404
|
-
return null;
|
|
4405
|
-
const root = codexProjectRoot(deps.cwd);
|
|
4406
|
-
const project = inspectCodexLayer(codexLayerPaths(false, deps.cwd, deps.env));
|
|
4407
|
-
if (project.jsonEvents.length === 0 && project.tomlEvents.length === 0)
|
|
4408
|
-
return null;
|
|
4409
|
-
const strayJson = path.join(path.dirname(layer), '.codex', 'hooks.json');
|
|
4410
|
-
const strayToml = path.join(path.dirname(layer), '.codex', 'config.toml');
|
|
4411
|
-
const problems = [];
|
|
4412
|
-
if (!existsSync(layer)) {
|
|
4413
|
-
problems.push(`${layer} is missing, so Codex never looks for project hooks here`);
|
|
4414
|
-
}
|
|
4415
|
-
if (existsSync(strayJson) && path.resolve(strayJson) !== path.resolve(project.paths.hooksJson)) {
|
|
4416
|
-
problems.push(`${strayJson} is never read — Codex reads ${project.writeTarget} instead`);
|
|
4417
|
-
}
|
|
4418
|
-
if (existsSync(strayToml) && path.resolve(strayToml) !== path.resolve(project.paths.configToml)) {
|
|
4419
|
-
problems.push(`${strayToml} is never read — Codex reads ${project.writeTarget} instead`);
|
|
4420
|
-
}
|
|
4421
|
-
return {
|
|
4422
|
-
name: 'hooks (codex worktree)',
|
|
4423
|
-
ok: problems.length === 0,
|
|
4424
|
-
detail: problems.length === 0
|
|
4425
|
-
? `worktree wired to the main repository at ${root}`
|
|
4426
|
-
: `${problems.join('; ')}. Re-run \`notifai hooks install\` to fix.`,
|
|
4427
|
-
};
|
|
4428
|
-
}
|
|
4429
|
-
// ---------------------------------------------------------------------------
|
|
4430
|
-
// production IO
|
|
4431
|
-
// ---------------------------------------------------------------------------
|
|
4432
|
-
/**
|
|
4433
|
-
* Whether a human is driving this terminal.
|
|
4434
|
-
*
|
|
4435
|
-
* A TTY alone is NOT that evidence: agent harnesses frequently allocate a PTY
|
|
4436
|
-
* for the commands they run, and a prompt shown to an agent does not fail — it
|
|
4437
|
-
* hangs, because every prompt library waits on stdin rather than erroring. So
|
|
4438
|
-
* this also honours `CI` and an explicit `NOTIFAI_NO_INPUT=1` escape hatch,
|
|
4439
|
-
* and every interactive affordance stays strictly optional: anything `init`
|
|
4440
|
-
* can ask, a flag can answer.
|
|
4441
|
-
*/
|
|
4442
|
-
function isHumanTerminal(env) {
|
|
4443
|
-
return (process.stdin.isTTY === true &&
|
|
4444
|
-
process.stdout.isTTY === true &&
|
|
4445
|
-
(env['CI'] ?? '') === '' &&
|
|
4446
|
-
(env['NOTIFAI_NO_INPUT'] ?? '') === '');
|
|
4447
|
-
}
|
|
4448
|
-
/**
|
|
4449
|
-
* Lazy on purpose: the hook path runs in front of every prompt the user types,
|
|
4450
|
-
* and must not pay for a prompt library it will never show.
|
|
4451
|
-
*/
|
|
4452
|
-
async function clack() {
|
|
4453
|
-
return await import('@clack/prompts');
|
|
4454
|
-
}
|
|
4455
|
-
export function realIo(env = process.env) {
|
|
4456
|
-
const interactive = () => isHumanTerminal(env);
|
|
4457
|
-
return {
|
|
4458
|
-
out: (line) => console.log(line),
|
|
4459
|
-
err: (line) => console.error(line),
|
|
4460
|
-
get interactive() {
|
|
4461
|
-
return interactive();
|
|
4462
|
-
},
|
|
4463
|
-
confirm: async (question, fallback = false) => {
|
|
4464
|
-
if (!interactive())
|
|
4465
|
-
return fallback;
|
|
4466
|
-
const p = await clack();
|
|
4467
|
-
const answer = await p.confirm({ message: question, initialValue: fallback });
|
|
4468
|
-
// Ctrl-C mid-prompt arrives as a cancel symbol, not a SIGINT; treat it
|
|
4469
|
-
// as the safe answer rather than letting a Symbol escape into logic.
|
|
4470
|
-
return p.isCancel(answer) ? false : answer;
|
|
4471
|
-
},
|
|
4472
|
-
select: async (message, options) => {
|
|
4473
|
-
if (!interactive())
|
|
4474
|
-
return null;
|
|
4475
|
-
const p = await clack();
|
|
4476
|
-
const answer = await p.select({ message, options });
|
|
4477
|
-
return p.isCancel(answer) ? null : answer;
|
|
4478
|
-
},
|
|
4479
|
-
multiselect: async (message, options, initial) => {
|
|
4480
|
-
if (!interactive())
|
|
4481
|
-
return null;
|
|
4482
|
-
const p = await clack();
|
|
4483
|
-
const answer = await p.multiselect({
|
|
4484
|
-
message,
|
|
4485
|
-
options,
|
|
4486
|
-
required: true,
|
|
4487
|
-
...(initial !== undefined && initial.length > 0 ? { initialValues: initial } : {}),
|
|
4488
|
-
});
|
|
4489
|
-
return p.isCancel(answer) ? null : answer;
|
|
4490
|
-
},
|
|
4491
|
-
intro: async (title) => {
|
|
4492
|
-
if (!interactive())
|
|
4493
|
-
return;
|
|
4494
|
-
(await clack()).intro(title);
|
|
4495
|
-
},
|
|
4496
|
-
outro: async (message) => {
|
|
4497
|
-
if (!interactive())
|
|
4498
|
-
return;
|
|
4499
|
-
(await clack()).outro(message);
|
|
4500
|
-
},
|
|
4501
|
-
note: async (message, title) => {
|
|
4502
|
-
if (!interactive())
|
|
4503
|
-
return;
|
|
4504
|
-
(await clack()).note(message, title);
|
|
4505
|
-
},
|
|
4506
|
-
spinner: async (message) => {
|
|
4507
|
-
if (!interactive())
|
|
4508
|
-
return null;
|
|
4509
|
-
const progress = (await clack()).spinner();
|
|
4510
|
-
progress.start(message);
|
|
4511
|
-
return {
|
|
4512
|
-
message: (next) => progress.message(next),
|
|
4513
|
-
stop: (next) => progress.stop(next),
|
|
4514
|
-
error: (next) => progress.error(next),
|
|
4515
|
-
};
|
|
4516
|
-
},
|
|
4517
|
-
check: async (ok, message, tone) => {
|
|
4518
|
-
if (!interactive())
|
|
4519
|
-
return;
|
|
4520
|
-
const { log } = await clack();
|
|
4521
|
-
switch (tone ?? (ok ? 'ok' : 'bad')) {
|
|
4522
|
-
case 'ok':
|
|
4523
|
-
return log.success(message);
|
|
4524
|
-
case 'warn':
|
|
4525
|
-
return log.warn(message);
|
|
4526
|
-
case 'pending':
|
|
4527
|
-
// Never checked. `log.info` reads as neutral, which is the honest
|
|
4528
|
-
// rendering for a state no evidence was gathered about.
|
|
4529
|
-
return log.info(message);
|
|
4530
|
-
case 'bad':
|
|
4531
|
-
return log.error(message);
|
|
4532
|
-
}
|
|
4533
|
-
},
|
|
4534
|
-
openUrl,
|
|
4535
|
-
};
|
|
4536
|
-
}
|
|
1
|
+
export { EXIT, log, } from './commands-core.js';
|
|
2
|
+
export { accessStatusCommand, authStatusCommand, loginCommand, logoutCommand, } from './commands-auth.js';
|
|
3
|
+
export { canDeviceReceive, capabilitiesCommand, deviceInventory, devicesCommand, } from './commands-devices.js';
|
|
4
|
+
export { contradictingAnswer, repliesCommand, sendCommand, statusCommand, } from './commands-send.js';
|
|
5
|
+
export { waitForReply } from './commands-send-support.js';
|
|
6
|
+
export { HOOK_EVENTS, acknowledgeCommand, askCommand, buildQuestions, closeCommand, describeHookFailure, hookDefersDiagnosticsUntilAfterCleanup, hookRunCommand, hooksInstallCommand, hooksUninstallCommand, runningViaNpx, } from './commands-hooks.js';
|
|
7
|
+
export { configExplainCommand, configSetCommand, configShowCommand, configUnsetCommand, } from './commands-config.js';
|
|
8
|
+
export { logsCommand, parseSince } from './commands-logs.js';
|
|
9
|
+
export { SKILLS_SOURCE } from './commands-skill.js';
|
|
10
|
+
export { initCommand, projectSlugFrom, } from './commands-init.js';
|
|
11
|
+
export { assessReadiness, doctorCommand } from './commands-doctor.js';
|
|
12
|
+
export { realIo } from './commands-io.js';
|
|
4537
13
|
//# sourceMappingURL=commands.js.map
|