@raidiant/notifai 0.1.8 → 0.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -1
- package/dist/commands.d.ts +30 -12
- package/dist/commands.js +234 -78
- package/dist/commands.js.map +1 -1
- package/dist/hooks.d.ts +38 -24
- package/dist/hooks.js +316 -187
- package/dist/hooks.js.map +1 -1
- package/dist/main.js +35 -3
- package/dist/main.js.map +1 -1
- package/dist/send.d.ts +19 -26
- package/dist/send.js +35 -45
- package/dist/send.js.map +1 -1
- package/package.json +2 -2
- package/src/commands.test.ts +174 -16
- package/src/commands.ts +270 -80
- package/src/hooks.test.ts +314 -83
- package/src/hooks.ts +389 -223
- package/src/install-hooks.test.ts +8 -15
- package/src/main.ts +41 -4
- package/src/send.ts +50 -48
package/README.md
CHANGED
|
@@ -35,7 +35,7 @@ banner and is shown in the companion app for when you sit down.
|
|
|
35
35
|
## Asking you a question
|
|
36
36
|
|
|
37
37
|
```sh
|
|
38
|
-
notifai ask "Deploy the migration to production?" --choice
|
|
38
|
+
notifai ask "Deploy the migration to production?" --choice Yes --choice No
|
|
39
39
|
```
|
|
40
40
|
|
|
41
41
|
The agent registers the question, ends its turn, and your answer comes
|
package/dist/commands.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { type ListRepliesResponse, type Platform, type ReplyView } from '@raidiant/notifai-protocol';
|
|
1
|
+
import { type ListRepliesResponse, type Platform, type QuestionT, type ReplyView } from '@raidiant/notifai-protocol';
|
|
2
2
|
import { type ApiClient, type ClientOptions } from './client.js';
|
|
3
3
|
import type { CredentialStore } from './credentials.js';
|
|
4
4
|
import { type Readiness } from './readiness.js';
|
|
@@ -112,16 +112,15 @@ interface ReplyWaitResult {
|
|
|
112
112
|
/** Loop over server-capped long polls until a reply arrives or the caller's deadline passes. */
|
|
113
113
|
export declare function waitForReply(client: ApiClient, requestId: string, options: ReplyWaitOptions): Promise<ReplyWaitResult>;
|
|
114
114
|
/**
|
|
115
|
-
* The
|
|
115
|
+
* The correction note for a wait that saw more than one answer.
|
|
116
116
|
*
|
|
117
|
-
* A
|
|
118
|
-
* answer
|
|
119
|
-
*
|
|
120
|
-
*
|
|
121
|
-
*
|
|
122
|
-
* answer between the first reply landing and its retirement arriving.
|
|
117
|
+
* A later reply that conflicts with an earlier one is a correction, so the
|
|
118
|
+
* latest answer is the one that counts. The server retires the question on
|
|
119
|
+
* the other devices the moment one answers, which makes this rare; it is
|
|
120
|
+
* still reachable, because a device can answer between the first reply
|
|
121
|
+
* landing and its retirement arriving.
|
|
123
122
|
*
|
|
124
|
-
* Ordered by seq, so `replies
|
|
123
|
+
* Ordered by seq, so `replies.at(-1)` is the answer to act on.
|
|
125
124
|
*/
|
|
126
125
|
export declare function contradictingAnswer(replies: ReplyView[]): string | null;
|
|
127
126
|
export declare function statusCommand(deps: CommandDeps, requestId: string, flags: {
|
|
@@ -155,16 +154,35 @@ export declare function hookRunCommand(deps: CommandDeps, event: string, readStd
|
|
|
155
154
|
*/
|
|
156
155
|
export declare function describeHookFailure(err: unknown): string[];
|
|
157
156
|
export interface AskFlags {
|
|
158
|
-
choice?: string
|
|
157
|
+
choice?: string[];
|
|
158
|
+
/** The single question is multi-select: several answers may be chosen. */
|
|
159
|
+
multi?: boolean;
|
|
160
|
+
/** Long-form markdown context; shown in the app, never on the banner. */
|
|
161
|
+
detail?: string;
|
|
162
|
+
/** Raw JSON for a multi-question form; replaces the positional question. */
|
|
163
|
+
form?: string;
|
|
159
164
|
session?: string;
|
|
160
165
|
}
|
|
166
|
+
/**
|
|
167
|
+
* Turn ask input into the question set that will ride the push. Everything is
|
|
168
|
+
* validated here, at registration, because the push happens inside a hook
|
|
169
|
+
* where a rejection becomes a stderr note the agent never reads.
|
|
170
|
+
*/
|
|
171
|
+
export declare function buildQuestions(flags: AskFlags, question: string | undefined): {
|
|
172
|
+
ok: true;
|
|
173
|
+
questions: QuestionT[];
|
|
174
|
+
detail?: string;
|
|
175
|
+
} | {
|
|
176
|
+
ok: false;
|
|
177
|
+
error: string;
|
|
178
|
+
};
|
|
161
179
|
/**
|
|
162
180
|
* Registers a question for turn-end routing under the user's presence config.
|
|
163
181
|
* Returns immediately so the agent can ask in prose and end its turn. With the
|
|
164
182
|
* default presence gate, recent keyboard or mouse activity keeps it local;
|
|
165
183
|
* `require_idle = false` intentionally permits a push while the user is active.
|
|
166
184
|
*/
|
|
167
|
-
export declare function askCommand(deps: CommandDeps, question: string, flags: AskFlags): number;
|
|
185
|
+
export declare function askCommand(deps: CommandDeps, question: string | undefined, flags: AskFlags): number;
|
|
168
186
|
/** Retire a question so a late answer is rejected rather than silently lost. */
|
|
169
187
|
export declare function closeCommand(deps: CommandDeps, requestId: string): Promise<number>;
|
|
170
188
|
export interface HooksInstallFlags {
|
|
@@ -191,7 +209,7 @@ export declare function configSetCommand(deps: CommandDeps, key: string, rawValu
|
|
|
191
209
|
* 1.5.x, `owner/repo@name` selects a skill; a Git ref belongs after `#`.
|
|
192
210
|
* Keep this immutable and public because the command is printed to users.
|
|
193
211
|
*/
|
|
194
|
-
export declare const SKILLS_SOURCE = "Raidiant-io/notifai#v0.1
|
|
212
|
+
export declare const SKILLS_SOURCE = "Raidiant-io/notifai#v0.2.1";
|
|
195
213
|
/** Derive a contract-valid project slug from a directory name. */
|
|
196
214
|
export declare function projectSlugFrom(name: string): string;
|
|
197
215
|
export interface InitFlags {
|
package/dist/commands.js
CHANGED
|
@@ -4,7 +4,7 @@ import { existsSync, mkdirSync, readFileSync, realpathSync, rmSync, writeFileSyn
|
|
|
4
4
|
import os from 'node:os';
|
|
5
5
|
import path from 'node:path';
|
|
6
6
|
import { parse as parseToml, stringify as stringifyToml } from 'smol-toml';
|
|
7
|
-
import { CAPABILITIES_V1, REPLY_MAX_WINDOW_SECONDS, validateDraft, } from '@raidiant/notifai-protocol';
|
|
7
|
+
import { CAPABILITIES_V1, QUESTION_TEXT_MAX_LENGTH, REPLY_MAX_QUESTIONS, REPLY_MAX_WINDOW_SECONDS, validateDraft, } from '@raidiant/notifai-protocol';
|
|
8
8
|
import { sha256Hex } from '@raidiant/notifai-protocol/node';
|
|
9
9
|
import { ApiCallError, NetworkError, createClient, } from './client.js';
|
|
10
10
|
import { BOOLEAN_CONFIG_KEYS, CONFIG_KEYS, NUMERIC_CONFIG_KEYS, configBounds, findProjectConfigPath, findProjectLocalConfigPath, globalConfigPath, loadConfig, sessionConfigPath, } from './config.js';
|
|
@@ -13,7 +13,7 @@ import { handleSessionEnd, handleStop, handleUserPromptSubmit, parseHookInput, p
|
|
|
13
13
|
import { readIdleSeconds } from './idle.js';
|
|
14
14
|
import { HARNESSES, applyPlan, blockingHookTimeoutSeconds, buildCursorHookConfig, buildHookConfig, codexLayerDir, codexProjectRoot, detectHarness, findInstallations, handlerEvent, loadCursorSettings, loadSettings, mergeCursorHooks, mergeHooks, removeCursorHooks, removeHooks, settingsFile, } from './install-hooks.js';
|
|
15
15
|
import { isOurOpencodePlugin, opencodePluginSource, } from './opencode-plugin.js';
|
|
16
|
-
import { CHOICE_USAGE,
|
|
16
|
+
import { CHOICE_USAGE, buildDraft, formatReceipt, parseChoices, receiptExitCode, slugify, } from './send.js';
|
|
17
17
|
export const EXIT = {
|
|
18
18
|
ok: 0,
|
|
19
19
|
failed: 1,
|
|
@@ -433,20 +433,25 @@ export async function repliesCommand(deps, requestedId, flags) {
|
|
|
433
433
|
deps.io.err('Pass a request id or --pending, not both.');
|
|
434
434
|
return EXIT.usage;
|
|
435
435
|
}
|
|
436
|
-
let
|
|
436
|
+
let requestIds = requestedId === undefined ? [] : [requestedId];
|
|
437
437
|
if (flags.pending === true) {
|
|
438
438
|
const sessionId = readProjectSession(deps.cwd, deps.env, (deps.now ?? Date.now)());
|
|
439
439
|
if (sessionId === null) {
|
|
440
440
|
deps.io.err('No active session pointer is available in this directory.');
|
|
441
441
|
return EXIT.noReply;
|
|
442
442
|
}
|
|
443
|
-
|
|
444
|
-
|
|
443
|
+
// Every delivered question in the session's queue, in registration order —
|
|
444
|
+
// an agent may have several outstanding at once.
|
|
445
|
+
const state = readSessionState(sessionId, deps.env);
|
|
446
|
+
requestIds = (Array.isArray(state.pending) ? state.pending : [])
|
|
447
|
+
.map((entry) => entry.request_id)
|
|
448
|
+
.filter((id) => id !== undefined);
|
|
449
|
+
if (requestIds.length === 0) {
|
|
445
450
|
deps.io.err(`Session ${sessionId} has no pushed question pending.`);
|
|
446
451
|
return EXIT.noReply;
|
|
447
452
|
}
|
|
448
453
|
}
|
|
449
|
-
if (
|
|
454
|
+
if (requestIds.length === 0) {
|
|
450
455
|
deps.io.err('Pass a request id or --pending.');
|
|
451
456
|
return EXIT.usage;
|
|
452
457
|
}
|
|
@@ -455,27 +460,44 @@ export async function repliesCommand(deps, requestedId, flags) {
|
|
|
455
460
|
if (!authed)
|
|
456
461
|
return EXIT.auth;
|
|
457
462
|
try {
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
463
|
+
let anyReplies = false;
|
|
464
|
+
let anyDegraded = false;
|
|
465
|
+
let allTimedOut = true;
|
|
466
|
+
const jsonBodies = [];
|
|
467
|
+
for (const requestId of requestIds) {
|
|
468
|
+
const result = await waitForReply(authed.client, requestId, {
|
|
469
|
+
timeoutSeconds: waitSeconds,
|
|
470
|
+
afterSeq,
|
|
471
|
+
now: deps.now,
|
|
472
|
+
sleep: deps.sleep,
|
|
473
|
+
});
|
|
474
|
+
if (flags.json) {
|
|
475
|
+
jsonBodies.push({ ...result.response, degraded: result.degraded });
|
|
476
|
+
}
|
|
477
|
+
else if (result.response.replies.length > 0) {
|
|
478
|
+
if (flags.pending === true)
|
|
479
|
+
deps.io.out(`pending request ${requestId}`);
|
|
480
|
+
printReplies(deps, result.response.replies);
|
|
481
|
+
}
|
|
482
|
+
else {
|
|
483
|
+
printNoReply(deps, requestId, result.response.reply_expires_at);
|
|
484
|
+
}
|
|
485
|
+
anyReplies ||= result.response.replies.length > 0;
|
|
486
|
+
anyDegraded ||= result.degraded;
|
|
487
|
+
allTimedOut &&= result.timedOut;
|
|
466
488
|
}
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
489
|
+
if (flags.json) {
|
|
490
|
+
// One request keeps the response shape agents already parse; several
|
|
491
|
+
// (only possible via --pending) arrive as an array in queue order.
|
|
492
|
+
deps.io.out(JSON.stringify(jsonBodies.length === 1 ? jsonBodies[0] : jsonBodies, null, 2));
|
|
471
493
|
}
|
|
472
|
-
|
|
473
|
-
printNoReply(deps, requestId, result.response.reply_expires_at);
|
|
474
|
-
if (result.degraded) {
|
|
494
|
+
if (anyDegraded) {
|
|
475
495
|
deps.io.err(DEGRADED_WAIT_WARNING);
|
|
476
496
|
return EXIT.network;
|
|
477
497
|
}
|
|
478
|
-
|
|
498
|
+
if (anyReplies)
|
|
499
|
+
return EXIT.ok;
|
|
500
|
+
return allTimedOut ? EXIT.noReply : EXIT.ok;
|
|
479
501
|
}
|
|
480
502
|
catch (err) {
|
|
481
503
|
return reportError(deps, err);
|
|
@@ -550,29 +572,27 @@ function printReplies(deps, replies) {
|
|
|
550
572
|
deps.io.err(contradiction);
|
|
551
573
|
}
|
|
552
574
|
/**
|
|
553
|
-
* The
|
|
575
|
+
* The correction note for a wait that saw more than one answer.
|
|
554
576
|
*
|
|
555
|
-
* A
|
|
556
|
-
* answer
|
|
557
|
-
*
|
|
558
|
-
*
|
|
559
|
-
*
|
|
560
|
-
* answer between the first reply landing and its retirement arriving.
|
|
577
|
+
* A later reply that conflicts with an earlier one is a correction, so the
|
|
578
|
+
* latest answer is the one that counts. The server retires the question on
|
|
579
|
+
* the other devices the moment one answers, which makes this rare; it is
|
|
580
|
+
* still reachable, because a device can answer between the first reply
|
|
581
|
+
* landing and its retirement arriving.
|
|
561
582
|
*
|
|
562
|
-
* Ordered by seq, so `replies
|
|
583
|
+
* Ordered by seq, so `replies.at(-1)` is the answer to act on.
|
|
563
584
|
*/
|
|
564
585
|
export function contradictingAnswer(replies) {
|
|
565
|
-
const winner = replies
|
|
586
|
+
const winner = replies.at(-1);
|
|
566
587
|
if (winner === undefined || replies.length < 2)
|
|
567
588
|
return null;
|
|
568
|
-
const
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
if (disagreeing.length === 0)
|
|
589
|
+
const key = (reply) => reply.answers.length > 0 ? JSON.stringify(reply.answers) : reply.text;
|
|
590
|
+
const corrected = replies.slice(0, -1).filter((reply) => key(reply) !== key(winner));
|
|
591
|
+
if (corrected.length === 0)
|
|
572
592
|
return null;
|
|
573
|
-
const names = [...new Set(
|
|
574
|
-
return (`note: "${winner.text}" from ${winner.device_name} is the answer that counts —
|
|
575
|
-
|
|
593
|
+
const names = [...new Set(corrected.map((reply) => reply.device_name))].join(', ');
|
|
594
|
+
return (`note: "${winner.text}" from ${winner.device_name} is the answer that counts — a later ` +
|
|
595
|
+
`answer corrects an earlier one. Earlier differing answers from ${names} were superseded.`);
|
|
576
596
|
}
|
|
577
597
|
function printNoReply(deps, requestId, expiresAt) {
|
|
578
598
|
// A harness hook may have retired this question, in which case promising an
|
|
@@ -841,6 +861,88 @@ function rejectedPaths(details) {
|
|
|
841
861
|
.filter((path) => path !== null && path !== '')
|
|
842
862
|
.slice(0, 5);
|
|
843
863
|
}
|
|
864
|
+
/**
|
|
865
|
+
* Turn ask input into the question set that will ride the push. Everything is
|
|
866
|
+
* validated here, at registration, because the push happens inside a hook
|
|
867
|
+
* where a rejection becomes a stderr note the agent never reads.
|
|
868
|
+
*/
|
|
869
|
+
export function buildQuestions(flags, question) {
|
|
870
|
+
if (flags.form !== undefined) {
|
|
871
|
+
if (question !== undefined || flags.choice?.length || flags.multi) {
|
|
872
|
+
return { ok: false, error: '--form replaces the positional question, --choice, and --multi.' };
|
|
873
|
+
}
|
|
874
|
+
let parsed;
|
|
875
|
+
try {
|
|
876
|
+
parsed = JSON.parse(flags.form);
|
|
877
|
+
}
|
|
878
|
+
catch {
|
|
879
|
+
return { ok: false, error: '--form must be JSON: {"questions": [{"text", "choices"?, "multi"?}], "detail"?}.' };
|
|
880
|
+
}
|
|
881
|
+
if (typeof parsed !== 'object' || parsed === null || !Array.isArray(parsed.questions)) {
|
|
882
|
+
return { ok: false, error: '--form needs a "questions" array (1-4 entries).' };
|
|
883
|
+
}
|
|
884
|
+
const form = parsed;
|
|
885
|
+
if (form.questions.length < 1 || form.questions.length > REPLY_MAX_QUESTIONS) {
|
|
886
|
+
return { ok: false, error: `A form asks 1-${REPLY_MAX_QUESTIONS} questions; this one has ${form.questions.length}.` };
|
|
887
|
+
}
|
|
888
|
+
if (form.detail !== undefined && typeof form.detail !== 'string') {
|
|
889
|
+
return { ok: false, error: '"detail" must be a markdown string.' };
|
|
890
|
+
}
|
|
891
|
+
const questions = [];
|
|
892
|
+
const usedIds = new Set();
|
|
893
|
+
for (const [index, entry] of form.questions.entries()) {
|
|
894
|
+
if (typeof entry !== 'object' || entry === null || typeof entry.text !== 'string') {
|
|
895
|
+
return { ok: false, error: `Question ${index + 1} needs a "text" string.` };
|
|
896
|
+
}
|
|
897
|
+
const spec = entry;
|
|
898
|
+
const built = buildOneQuestion(spec.text, spec.choices, spec.multi === true, index, usedIds);
|
|
899
|
+
if ('error' in built)
|
|
900
|
+
return { ok: false, error: `Question ${index + 1}: ${built.error}` };
|
|
901
|
+
questions.push(built.question);
|
|
902
|
+
}
|
|
903
|
+
return { ok: true, questions, ...(form.detail !== undefined ? { detail: form.detail } : {}) };
|
|
904
|
+
}
|
|
905
|
+
if (question === undefined || question.trim() === '') {
|
|
906
|
+
return { ok: false, error: 'The question cannot be empty.' };
|
|
907
|
+
}
|
|
908
|
+
const built = buildOneQuestion(question, flags.choice, flags.multi === true, 0, new Set());
|
|
909
|
+
if ('error' in built)
|
|
910
|
+
return { ok: false, error: built.error };
|
|
911
|
+
return {
|
|
912
|
+
ok: true,
|
|
913
|
+
questions: [built.question],
|
|
914
|
+
...(flags.detail !== undefined && flags.detail.trim() !== '' ? { detail: flags.detail } : {}),
|
|
915
|
+
};
|
|
916
|
+
}
|
|
917
|
+
function buildOneQuestion(text, choiceLabels, multi, index, usedIds) {
|
|
918
|
+
const trimmed = text.trim();
|
|
919
|
+
if (trimmed === '')
|
|
920
|
+
return { error: 'the question text cannot be empty.' };
|
|
921
|
+
if (trimmed.length > QUESTION_TEXT_MAX_LENGTH) {
|
|
922
|
+
return {
|
|
923
|
+
error: `a question must be readable where it is answered: keep it within ` +
|
|
924
|
+
`${QUESTION_TEXT_MAX_LENGTH} characters and put the longer context in detail.`,
|
|
925
|
+
};
|
|
926
|
+
}
|
|
927
|
+
const choices = parseChoices(choiceLabels);
|
|
928
|
+
if (choices === 'invalid')
|
|
929
|
+
return { error: CHOICE_USAGE };
|
|
930
|
+
if (multi && choices === null) {
|
|
931
|
+
return { error: '--multi needs answers to select between; add --choice.' };
|
|
932
|
+
}
|
|
933
|
+
let id = slugify(trimmed);
|
|
934
|
+
if (id === '' || usedIds.has(id))
|
|
935
|
+
id = `q${index + 1}`;
|
|
936
|
+
usedIds.add(id);
|
|
937
|
+
return {
|
|
938
|
+
question: {
|
|
939
|
+
id,
|
|
940
|
+
text: trimmed,
|
|
941
|
+
...(choices !== null ? { choices } : {}),
|
|
942
|
+
...(multi ? { multi: true } : {}),
|
|
943
|
+
},
|
|
944
|
+
};
|
|
945
|
+
}
|
|
844
946
|
/**
|
|
845
947
|
* Registers a question for turn-end routing under the user's presence config.
|
|
846
948
|
* Returns immediately so the agent can ask in prose and end its turn. With the
|
|
@@ -895,37 +997,38 @@ export function askCommand(deps, question, flags) {
|
|
|
895
997
|
deps.io.err(line);
|
|
896
998
|
return EXIT.usage;
|
|
897
999
|
}
|
|
898
|
-
if (question.trim() === '') {
|
|
899
|
-
deps.io.err('The question cannot be empty.');
|
|
900
|
-
return EXIT.usage;
|
|
901
|
-
}
|
|
902
1000
|
// Validate here, not at push time. The push happens inside a hook, where a
|
|
903
1001
|
// rejection becomes a stderr note the agent never reads — so a malformed
|
|
904
|
-
//
|
|
905
|
-
const
|
|
906
|
-
if (
|
|
907
|
-
deps.io.err(
|
|
1002
|
+
// question set would look like it registered fine and then silently never ask.
|
|
1003
|
+
const built = buildQuestions(flags, question);
|
|
1004
|
+
if (!built.ok) {
|
|
1005
|
+
deps.io.err(built.error);
|
|
908
1006
|
return EXIT.usage;
|
|
909
1007
|
}
|
|
910
1008
|
try {
|
|
911
1009
|
registerQuestion(sessionId, deps.env, {
|
|
912
|
-
question:
|
|
913
|
-
|
|
1010
|
+
question: built.questions[0].text,
|
|
1011
|
+
questions: built.questions,
|
|
1012
|
+
...(built.detail !== undefined ? { detail: built.detail } : {}),
|
|
914
1013
|
}, (deps.now ?? Date.now)());
|
|
915
1014
|
}
|
|
916
1015
|
catch (err) {
|
|
917
1016
|
deps.io.err(`Could not register the question: ${err instanceof Error ? err.message : String(err)}`);
|
|
918
1017
|
return EXIT.failed;
|
|
919
1018
|
}
|
|
920
|
-
|
|
921
|
-
|
|
1019
|
+
for (const [index, entry] of built.questions.entries()) {
|
|
1020
|
+
const prefix = built.questions.length > 1 ? `${index + 1}. ` : '';
|
|
1021
|
+
if (entry.choices !== undefined) {
|
|
1022
|
+
const kind = entry.multi === true ? 'answers offered (several may be chosen)' : 'answers offered';
|
|
1023
|
+
deps.io.out(`${prefix}${entry.text} — ${kind}: ${entry.choices.map((choice) => choice.label).join(' / ')}`);
|
|
1024
|
+
}
|
|
1025
|
+
else if (built.questions.length > 1) {
|
|
1026
|
+
deps.io.out(`${prefix}${entry.text} — free text`);
|
|
1027
|
+
}
|
|
922
1028
|
}
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
if (ambiguous !== null)
|
|
927
|
-
deps.io.out(ambiguous);
|
|
928
|
-
deps.io.out('Question registered. Ask it in the conversation as usual and end your turn.');
|
|
1029
|
+
deps.io.out(built.questions.length > 1
|
|
1030
|
+
? `${built.questions.length} questions registered as one form. Ask them in the conversation as usual and end your turn.`
|
|
1031
|
+
: 'Question registered. Ask it in the conversation as usual and end your turn.');
|
|
929
1032
|
return EXIT.ok;
|
|
930
1033
|
}
|
|
931
1034
|
function activeHarnessSession(env) {
|
|
@@ -1381,7 +1484,7 @@ export async function configSetCommand(deps, key, rawValue, flags) {
|
|
|
1381
1484
|
* 1.5.x, `owner/repo@name` selects a skill; a Git ref belongs after `#`.
|
|
1382
1485
|
* Keep this immutable and public because the command is printed to users.
|
|
1383
1486
|
*/
|
|
1384
|
-
export const SKILLS_SOURCE = 'Raidiant-io/notifai#v0.1
|
|
1487
|
+
export const SKILLS_SOURCE = 'Raidiant-io/notifai#v0.2.1';
|
|
1385
1488
|
function skillSourceParts() {
|
|
1386
1489
|
const match = /^([^#]+)#(.+)$/.exec(SKILLS_SOURCE);
|
|
1387
1490
|
return match === null ? null : { source: match[1], ref: match[2] };
|
|
@@ -2393,12 +2496,16 @@ function remedyLine(state) {
|
|
|
2393
2496
|
* way and is not worth re-deriving. The judgment added here is which failures
|
|
2394
2497
|
* actually stand in the way.
|
|
2395
2498
|
*
|
|
2396
|
-
*
|
|
2397
|
-
*
|
|
2398
|
-
*
|
|
2399
|
-
*
|
|
2400
|
-
*
|
|
2401
|
-
*
|
|
2499
|
+
* Not everything failed is in the way. A pointer that has never been
|
|
2500
|
+
* published is the normal condition of an install thirty seconds old — the
|
|
2501
|
+
* next prompt fixes it and no command can — and OpenCode's inability to
|
|
2502
|
+
* resume an idle turn is a property of that harness rather than a fault in
|
|
2503
|
+
* this setup. Treating those as blocking would mean `init` could never
|
|
2504
|
+
* finish for an OpenCode user, or could only finish after a session had
|
|
2505
|
+
* already run — so they report as things worth knowing rather than things to
|
|
2506
|
+
* fix, and `init` walks on to the states it can actually close, delivery
|
|
2507
|
+
* proof included. Which failures are informational, and what the true remedy
|
|
2508
|
+
* is, is each check's own call (`HookCheck`).
|
|
2402
2509
|
*/
|
|
2403
2510
|
function hookStates(deps) {
|
|
2404
2511
|
const installations = findInstallations(deps.cwd, deps.env);
|
|
@@ -2438,23 +2545,30 @@ function hookStates(deps) {
|
|
|
2438
2545
|
}
|
|
2439
2546
|
/** Real but not in the way; see the note above. */
|
|
2440
2547
|
const informational = new Set(['hooks (opencode continuation)']);
|
|
2441
|
-
if (active === null)
|
|
2442
|
-
informational.add('hooks (fired)');
|
|
2443
2548
|
return [
|
|
2444
2549
|
...hookChecks(deps).map((check) => ({
|
|
2445
2550
|
id: check.name.replace(/[ ()]+/g, '-').replace(/-$/, ''),
|
|
2446
2551
|
title: check.name === 'hooks' || check.name.startsWith('hooks (active')
|
|
2447
2552
|
? 'Question routing'
|
|
2448
2553
|
: check.name,
|
|
2449
|
-
status: check.ok
|
|
2554
|
+
status: check.ok
|
|
2555
|
+
? 'ready'
|
|
2556
|
+
: check.informational === true || informational.has(check.name)
|
|
2557
|
+
? 'optional-gap'
|
|
2558
|
+
: 'gap',
|
|
2450
2559
|
detail: check.detail,
|
|
2451
2560
|
...(check.ok
|
|
2452
2561
|
? {}
|
|
2453
2562
|
: {
|
|
2454
2563
|
remedy: {
|
|
2455
2564
|
by: 'user-here',
|
|
2456
|
-
|
|
2457
|
-
|
|
2565
|
+
// The check's own remedy when it has one; the generic reinstall
|
|
2566
|
+
// line was wrong exactly where it mattered (an unfired pointer
|
|
2567
|
+
// needs a prompt, not `hooks install`).
|
|
2568
|
+
...(check.remedy ?? {
|
|
2569
|
+
summary: 'the detail above names what to change',
|
|
2570
|
+
command: 'notifai hooks install',
|
|
2571
|
+
}),
|
|
2458
2572
|
},
|
|
2459
2573
|
}),
|
|
2460
2574
|
})),
|
|
@@ -2492,21 +2606,52 @@ function hookChecks(deps) {
|
|
|
2492
2606
|
detail: activeInstallations.length > 0
|
|
2493
2607
|
? `active ${active.label} session has a matching hook installation`
|
|
2494
2608
|
: `active ${active.label} session has no matching hook installation — run \`notifai hooks install --harness ${active.harness}\``,
|
|
2609
|
+
...(activeInstallations.length > 0
|
|
2610
|
+
? {}
|
|
2611
|
+
: {
|
|
2612
|
+
remedy: {
|
|
2613
|
+
summary: `install hooks for the active ${active.label} session`,
|
|
2614
|
+
command: `notifai hooks install --harness ${active.harness}`,
|
|
2615
|
+
},
|
|
2616
|
+
}),
|
|
2495
2617
|
});
|
|
2496
2618
|
if (activeInstallations.length > 0) {
|
|
2497
2619
|
const pointer = readProjectSessionPointer(deps.cwd, deps.env, (deps.now ?? Date.now)());
|
|
2498
|
-
|
|
2499
|
-
|
|
2500
|
-
|
|
2501
|
-
|
|
2502
|
-
|
|
2503
|
-
|
|
2504
|
-
|
|
2505
|
-
|
|
2506
|
-
:
|
|
2620
|
+
if (pointer === null) {
|
|
2621
|
+
// The normal condition of hooks installed moments ago: the pointer
|
|
2622
|
+
// appears when the harness next fires a hook, and no command can
|
|
2623
|
+
// force that. Informational, so `init` walks on to the states it can
|
|
2624
|
+
// actually prove instead of exiting over evidence only time produces.
|
|
2625
|
+
checks.push({
|
|
2626
|
+
name: 'hooks (active session)',
|
|
2627
|
+
ok: false,
|
|
2628
|
+
informational: true,
|
|
2629
|
+
detail: `active ${active.label} session has not published a live pointer — send one ${active.label} prompt, then check again`,
|
|
2630
|
+
remedy: {
|
|
2631
|
+
summary: `send one ${active.label} prompt — its hook publishes the routing pointer`,
|
|
2632
|
+
command: 'notifai doctor',
|
|
2633
|
+
},
|
|
2634
|
+
});
|
|
2635
|
+
}
|
|
2636
|
+
else {
|
|
2637
|
+
const matches = pointer.harness === active.harness &&
|
|
2638
|
+
(active.sessionId === undefined || pointer.sessionId === active.sessionId);
|
|
2639
|
+
checks.push({
|
|
2640
|
+
name: 'hooks (active session)',
|
|
2641
|
+
ok: matches,
|
|
2642
|
+
detail: matches
|
|
2507
2643
|
? `the project pointer belongs to the active ${active.label} session`
|
|
2508
2644
|
: `the project pointer belongs to another ${active.label} session or harness; refusing cross-session routing`,
|
|
2509
|
-
|
|
2645
|
+
...(matches
|
|
2646
|
+
? {}
|
|
2647
|
+
: {
|
|
2648
|
+
remedy: {
|
|
2649
|
+
summary: `send one ${active.label} prompt in this session so the pointer belongs to it`,
|
|
2650
|
+
command: 'notifai doctor',
|
|
2651
|
+
},
|
|
2652
|
+
}),
|
|
2653
|
+
});
|
|
2654
|
+
}
|
|
2510
2655
|
}
|
|
2511
2656
|
}
|
|
2512
2657
|
// A handler naming an event this build dropped exits 2 every time the harness
|
|
@@ -2574,9 +2719,20 @@ function hookChecks(deps) {
|
|
|
2574
2719
|
checks.push({
|
|
2575
2720
|
name: 'hooks (fired)',
|
|
2576
2721
|
ok: fired,
|
|
2722
|
+
// Never-fired is evidence about the past, not a fault in the setup, and
|
|
2723
|
+
// the cure is a prompt no command can send.
|
|
2724
|
+
informational: true,
|
|
2577
2725
|
detail: fired
|
|
2578
2726
|
? 'a session in this directory has run them'
|
|
2579
2727
|
: `no session pointer from the last 24 hours — ${hookActivationAdvice(activationInstallations)}`,
|
|
2728
|
+
...(fired
|
|
2729
|
+
? {}
|
|
2730
|
+
: {
|
|
2731
|
+
remedy: {
|
|
2732
|
+
summary: 'send one prompt in a session here, then re-check',
|
|
2733
|
+
command: 'notifai doctor',
|
|
2734
|
+
},
|
|
2735
|
+
}),
|
|
2580
2736
|
});
|
|
2581
2737
|
if (installations.some((installation) => installation.harness === 'opencode')) {
|
|
2582
2738
|
checks.push({
|