@raidiant/notifai 0.1.8 → 0.2.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/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 "Yes,No"
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
@@ -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 other half of first-reply-wins.
115
+ * The correction note for a wait that saw more than one answer.
116
116
  *
117
- * A blocking send returns the first non-empty batch and acts on it, so a later
118
- * answer that disagrees is silently ignored. That is the right default — an
119
- * agent wants one answer, not a quorum — but silently is the wrong way to do
120
- * it. The server now retires the question on the other devices the moment one
121
- * answers, which makes this rare; it is still reachable, because a device can
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[0]` is the one that won.
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 | 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.8";
212
+ export declare const SKILLS_SOURCE = "Raidiant-io/notifai#v0.2.0";
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, ambiguousChoiceSplit, buildDraft, formatReceipt, parseChoices, receiptExitCode, } from './send.js';
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,
@@ -550,29 +550,27 @@ function printReplies(deps, replies) {
550
550
  deps.io.err(contradiction);
551
551
  }
552
552
  /**
553
- * The other half of first-reply-wins.
553
+ * The correction note for a wait that saw more than one answer.
554
554
  *
555
- * A blocking send returns the first non-empty batch and acts on it, so a later
556
- * answer that disagrees is silently ignored. That is the right default — an
557
- * agent wants one answer, not a quorum — but silently is the wrong way to do
558
- * it. The server now retires the question on the other devices the moment one
559
- * answers, which makes this rare; it is still reachable, because a device can
560
- * answer between the first reply landing and its retirement arriving.
555
+ * A later reply that conflicts with an earlier one is a correction, so the
556
+ * latest answer is the one that counts. The server retires the question on
557
+ * the other devices the moment one answers, which makes this rare; it is
558
+ * still reachable, because a device can answer between the first reply
559
+ * landing and its retirement arriving.
561
560
  *
562
- * Ordered by seq, so `replies[0]` is the one that won.
561
+ * Ordered by seq, so `replies.at(-1)` is the answer to act on.
563
562
  */
564
563
  export function contradictingAnswer(replies) {
565
- const winner = replies[0];
564
+ const winner = replies.at(-1);
566
565
  if (winner === undefined || replies.length < 2)
567
566
  return null;
568
- const disagreeing = replies
569
- .slice(1)
570
- .filter((reply) => (reply.choice_id ?? reply.text) !== (winner.choice_id ?? winner.text));
571
- if (disagreeing.length === 0)
567
+ const key = (reply) => reply.answers.length > 0 ? JSON.stringify(reply.answers) : reply.text;
568
+ const corrected = replies.slice(0, -1).filter((reply) => key(reply) !== key(winner));
569
+ if (corrected.length === 0)
572
570
  return null;
573
- const names = [...new Set(disagreeing.map((reply) => reply.device_name))].join(', ');
574
- return (`note: "${winner.text}" from ${winner.device_name} is the answer that counts — it arrived first. ` +
575
- `${names} answered differently afterwards and that answer was not used.`);
571
+ const names = [...new Set(corrected.map((reply) => reply.device_name))].join(', ');
572
+ return (`note: "${winner.text}" from ${winner.device_name} is the answer that counts — a later ` +
573
+ `answer corrects an earlier one. Earlier differing answers from ${names} were superseded.`);
576
574
  }
577
575
  function printNoReply(deps, requestId, expiresAt) {
578
576
  // A harness hook may have retired this question, in which case promising an
@@ -841,6 +839,88 @@ function rejectedPaths(details) {
841
839
  .filter((path) => path !== null && path !== '')
842
840
  .slice(0, 5);
843
841
  }
842
+ /**
843
+ * Turn ask input into the question set that will ride the push. Everything is
844
+ * validated here, at registration, because the push happens inside a hook
845
+ * where a rejection becomes a stderr note the agent never reads.
846
+ */
847
+ export function buildQuestions(flags, question) {
848
+ if (flags.form !== undefined) {
849
+ if (question !== undefined || flags.choice?.length || flags.multi) {
850
+ return { ok: false, error: '--form replaces the positional question, --choice, and --multi.' };
851
+ }
852
+ let parsed;
853
+ try {
854
+ parsed = JSON.parse(flags.form);
855
+ }
856
+ catch {
857
+ return { ok: false, error: '--form must be JSON: {"questions": [{"text", "choices"?, "multi"?}], "detail"?}.' };
858
+ }
859
+ if (typeof parsed !== 'object' || parsed === null || !Array.isArray(parsed.questions)) {
860
+ return { ok: false, error: '--form needs a "questions" array (1-4 entries).' };
861
+ }
862
+ const form = parsed;
863
+ if (form.questions.length < 1 || form.questions.length > REPLY_MAX_QUESTIONS) {
864
+ return { ok: false, error: `A form asks 1-${REPLY_MAX_QUESTIONS} questions; this one has ${form.questions.length}.` };
865
+ }
866
+ if (form.detail !== undefined && typeof form.detail !== 'string') {
867
+ return { ok: false, error: '"detail" must be a markdown string.' };
868
+ }
869
+ const questions = [];
870
+ const usedIds = new Set();
871
+ for (const [index, entry] of form.questions.entries()) {
872
+ if (typeof entry !== 'object' || entry === null || typeof entry.text !== 'string') {
873
+ return { ok: false, error: `Question ${index + 1} needs a "text" string.` };
874
+ }
875
+ const spec = entry;
876
+ const built = buildOneQuestion(spec.text, spec.choices, spec.multi === true, index, usedIds);
877
+ if ('error' in built)
878
+ return { ok: false, error: `Question ${index + 1}: ${built.error}` };
879
+ questions.push(built.question);
880
+ }
881
+ return { ok: true, questions, ...(form.detail !== undefined ? { detail: form.detail } : {}) };
882
+ }
883
+ if (question === undefined || question.trim() === '') {
884
+ return { ok: false, error: 'The question cannot be empty.' };
885
+ }
886
+ const built = buildOneQuestion(question, flags.choice, flags.multi === true, 0, new Set());
887
+ if ('error' in built)
888
+ return { ok: false, error: built.error };
889
+ return {
890
+ ok: true,
891
+ questions: [built.question],
892
+ ...(flags.detail !== undefined && flags.detail.trim() !== '' ? { detail: flags.detail } : {}),
893
+ };
894
+ }
895
+ function buildOneQuestion(text, choiceLabels, multi, index, usedIds) {
896
+ const trimmed = text.trim();
897
+ if (trimmed === '')
898
+ return { error: 'the question text cannot be empty.' };
899
+ if (trimmed.length > QUESTION_TEXT_MAX_LENGTH) {
900
+ return {
901
+ error: `a question must be readable where it is answered: keep it within ` +
902
+ `${QUESTION_TEXT_MAX_LENGTH} characters and put the longer context in detail.`,
903
+ };
904
+ }
905
+ const choices = parseChoices(choiceLabels);
906
+ if (choices === 'invalid')
907
+ return { error: CHOICE_USAGE };
908
+ if (multi && choices === null) {
909
+ return { error: '--multi needs answers to select between; add --choice.' };
910
+ }
911
+ let id = slugify(trimmed);
912
+ if (id === '' || usedIds.has(id))
913
+ id = `q${index + 1}`;
914
+ usedIds.add(id);
915
+ return {
916
+ question: {
917
+ id,
918
+ text: trimmed,
919
+ ...(choices !== null ? { choices } : {}),
920
+ ...(multi ? { multi: true } : {}),
921
+ },
922
+ };
923
+ }
844
924
  /**
845
925
  * Registers a question for turn-end routing under the user's presence config.
846
926
  * Returns immediately so the agent can ask in prose and end its turn. With the
@@ -895,37 +975,38 @@ export function askCommand(deps, question, flags) {
895
975
  deps.io.err(line);
896
976
  return EXIT.usage;
897
977
  }
898
- if (question.trim() === '') {
899
- deps.io.err('The question cannot be empty.');
900
- return EXIT.usage;
901
- }
902
978
  // Validate here, not at push time. The push happens inside a hook, where a
903
979
  // rejection becomes a stderr note the agent never reads — so a malformed
904
- // choice set would look like it registered fine and then silently never ask.
905
- const choices = parseChoices(flags.choice);
906
- if (choices === 'invalid') {
907
- deps.io.err(CHOICE_USAGE);
980
+ // question set would look like it registered fine and then silently never ask.
981
+ const built = buildQuestions(flags, question);
982
+ if (!built.ok) {
983
+ deps.io.err(built.error);
908
984
  return EXIT.usage;
909
985
  }
910
986
  try {
911
987
  registerQuestion(sessionId, deps.env, {
912
- question: question.trim(),
913
- ...(choices !== null ? { choices: choices.map((choice) => choice.label) } : {}),
988
+ question: built.questions[0].text,
989
+ questions: built.questions,
990
+ ...(built.detail !== undefined ? { detail: built.detail } : {}),
914
991
  }, (deps.now ?? Date.now)());
915
992
  }
916
993
  catch (err) {
917
994
  deps.io.err(`Could not register the question: ${err instanceof Error ? err.message : String(err)}`);
918
995
  return EXIT.failed;
919
996
  }
920
- if (choices !== null) {
921
- deps.io.out(`Answers offered: ${choices.map((choice) => choice.label).join(' / ')}`);
997
+ for (const [index, entry] of built.questions.entries()) {
998
+ const prefix = built.questions.length > 1 ? `${index + 1}. ` : '';
999
+ if (entry.choices !== undefined) {
1000
+ const kind = entry.multi === true ? 'answers offered (several may be chosen)' : 'answers offered';
1001
+ deps.io.out(`${prefix}${entry.text} — ${kind}: ${entry.choices.map((choice) => choice.label).join(' / ')}`);
1002
+ }
1003
+ else if (built.questions.length > 1) {
1004
+ deps.io.out(`${prefix}${entry.text} — free text`);
1005
+ }
922
1006
  }
923
- // On stdout, not stderr: the agent reads the former and this is only useful
924
- // if it is read while there is still time to fix the question.
925
- const ambiguous = ambiguousChoiceSplit(flags.choice);
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.');
1007
+ deps.io.out(built.questions.length > 1
1008
+ ? `${built.questions.length} questions registered as one form. Ask them in the conversation as usual and end your turn.`
1009
+ : 'Question registered. Ask it in the conversation as usual and end your turn.');
929
1010
  return EXIT.ok;
930
1011
  }
931
1012
  function activeHarnessSession(env) {
@@ -1381,7 +1462,7 @@ export async function configSetCommand(deps, key, rawValue, flags) {
1381
1462
  * 1.5.x, `owner/repo@name` selects a skill; a Git ref belongs after `#`.
1382
1463
  * Keep this immutable and public because the command is printed to users.
1383
1464
  */
1384
- export const SKILLS_SOURCE = 'Raidiant-io/notifai#v0.1.8';
1465
+ export const SKILLS_SOURCE = 'Raidiant-io/notifai#v0.2.0';
1385
1466
  function skillSourceParts() {
1386
1467
  const match = /^([^#]+)#(.+)$/.exec(SKILLS_SOURCE);
1387
1468
  return match === null ? null : { source: match[1], ref: match[2] };