@raidiant/notifai 3.0.1 → 5.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 +73 -0
- package/README.md +12 -4
- package/dist/commands.d.ts +27 -10
- package/dist/commands.js +429 -152
- package/dist/commands.js.map +1 -1
- package/dist/config-schema.js +20 -11
- package/dist/config-schema.js.map +1 -1
- package/dist/config.d.ts +3 -1
- package/dist/config.js +12 -1
- package/dist/config.js.map +1 -1
- package/dist/file-lock.d.ts +6 -1
- package/dist/file-lock.js +55 -9
- package/dist/file-lock.js.map +1 -1
- package/dist/harnesses.d.ts +1 -1
- package/dist/harnesses.js.map +1 -1
- package/dist/hooks.d.ts +84 -12
- package/dist/hooks.js +433 -125
- package/dist/hooks.js.map +1 -1
- package/dist/install-hooks.d.ts +91 -14
- package/dist/install-hooks.js +226 -34
- package/dist/install-hooks.js.map +1 -1
- package/dist/interactive.d.ts +1 -1
- package/dist/interactive.js +1 -1
- package/dist/invocation-context.d.ts +42 -0
- package/dist/invocation-context.js +130 -0
- package/dist/invocation-context.js.map +1 -0
- package/dist/logging.d.ts +8 -0
- package/dist/logging.js +8 -0
- package/dist/logging.js.map +1 -1
- package/dist/main.js +84 -49
- package/dist/main.js.map +1 -1
- package/dist/opencode-plugin.d.ts +8 -0
- package/dist/opencode-plugin.js +24 -162
- package/dist/opencode-plugin.js.map +1 -1
- package/dist/send.d.ts +38 -7
- package/dist/send.js +97 -41
- package/dist/send.js.map +1 -1
- package/dist/ui/help.d.ts +0 -1
- package/dist/ui/help.js +6 -3
- package/dist/ui/help.js.map +1 -1
- package/dist/ui/theme.d.ts +0 -15
- package/dist/ui/theme.js +2 -18
- package/dist/ui/theme.js.map +1 -1
- package/package.json +2 -2
- package/src/commands.test.ts +501 -63
- package/src/commands.ts +547 -187
- package/src/config-schema.test.ts +1 -1
- package/src/config-schema.ts +22 -11
- package/src/config.test.ts +178 -18
- package/src/config.ts +14 -1
- package/src/file-lock.test.ts +41 -0
- package/src/file-lock.ts +59 -9
- package/src/harnesses.test.ts +37 -29
- package/src/harnesses.ts +1 -4
- package/src/hooks.test.ts +500 -58
- package/src/hooks.ts +523 -169
- package/src/install-hooks.test.ts +232 -12
- package/src/install-hooks.ts +241 -37
- package/src/interactive.ts +1 -1
- package/src/invocation-context.test.ts +253 -0
- package/src/invocation-context.ts +176 -0
- package/src/logging-process-worker.test.ts +8 -2
- package/src/logging.test.ts +33 -18
- package/src/logging.ts +11 -0
- package/src/main.ts +119 -54
- package/src/opencode-plugin.ts +24 -164
- package/src/question-caps.test.ts +49 -0
- package/src/send.ts +144 -50
- package/src/skill.test.ts +197 -120
- package/src/ui/help.ts +8 -3
- package/src/ui/theme.ts +2 -16
package/dist/commands.js
CHANGED
|
@@ -3,25 +3,26 @@ import { existsSync, lstatSync, mkdirSync, readFileSync, realpathSync, rmSync, u
|
|
|
3
3
|
import os from 'node:os';
|
|
4
4
|
import path from 'node:path';
|
|
5
5
|
import { parse as parseToml, stringify as stringifyToml } from 'smol-toml';
|
|
6
|
-
import { AGENT_ACKNOWLEDGEMENT_MAX_LENGTH, CAPABILITIES_V1, QUESTION_TEXT_MAX_LENGTH, REPLY_MAX_QUESTIONS, REPLY_MAX_WINDOW_SECONDS, validateDraft, } from '@raidiant/notifai-protocol';
|
|
6
|
+
import { AGENT_ACKNOWLEDGEMENT_MAX_LENGTH, CAPABILITIES_V1, PLATFORMS, QUESTION_TEXT_MAX_LENGTH, REPLY_MAX_QUESTIONS, REPLY_MAX_WINDOW_SECONDS, validateDraft, } from '@raidiant/notifai-protocol';
|
|
7
7
|
import { sha256Hex } from '@raidiant/notifai-protocol/node';
|
|
8
8
|
import { ApiCallError, NetworkError, createClient, isRetryableReplyPollError, } from './client.js';
|
|
9
|
-
import { BOOLEAN_CONFIG_KEYS, CONFIG_KEYS,
|
|
9
|
+
import { BOOLEAN_CONFIG_KEYS, CONFIG_KEYS, NUMERIC_CONFIG_KEYS, configBounds, configDefaultValue, findProjectConfigPath, personalProjectConfigPath, globalConfigPath, loadConfig, sessionConfigPath, stateDir, } from './config.js';
|
|
10
10
|
import { acceptedValues, configInfo } from './config-schema.js';
|
|
11
11
|
import { packageVersion, skillsSource } from './release.js';
|
|
12
12
|
import { atomicWriteFileSync } from './atomic-file.js';
|
|
13
13
|
import { withTargetFileLock } from './file-lock.js';
|
|
14
14
|
import { renderConfigExplain, renderConfigList, renderConfigPlain, } from './ui/config-view.js';
|
|
15
15
|
import { firstBlocker, } from './readiness.js';
|
|
16
|
-
import {
|
|
17
|
-
import { LOG_EVENTS, activeLogPath, archiveLogPaths, logConfigResolved, logSettingsFrom, logsDiskUsage, nullLogger, readLogRecords, renderRecord, } from './logging.js';
|
|
18
|
-
import { BLOCKING_STOP_TIMEOUT_SECONDS, CLAUDE_ASYNC_STOP_TIMEOUT_SECONDS, HARNESSES, applyPlan, buildCursorHookConfig, buildHookConfig, codexLayerDir, codexLayerPaths, codexRepresentationProblems, codexTrustProblems, codexProjectRoot, detectHarness, detectedHarnesses, findInstallations, handlerEvent, hookDefinitionFiles, inspectCodexLayer, loadCursorSettings, loadSettings, mergeCursorHooks, mergeHooks, removeCursorHooks, removeHooks, settingsFile, withCodexLayerTransaction, } from './install-hooks.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
19
|
import { HARNESS_CAPABILITIES, HARNESS_LABELS } from './harnesses.js';
|
|
20
20
|
import { claudeWakeRoute, inspectClaudeInbox, systemClaudeWakeAdapters, } from './claude-wake.js';
|
|
21
21
|
import { codexWakeRoute, inspectCodexResume, } from './codex-wake.js';
|
|
22
22
|
import { inspectHookAdapter, installHookAdapter, isNpxAdapterTarget, } from './hook-adapter.js';
|
|
23
23
|
import { isOurOpencodePlugin, opencodePluginSource, } from './opencode-plugin.js';
|
|
24
|
-
import { CHOICE_USAGE, buildDraft, formatReceipt, parseChoices, receiptExitCode, slugify, } from './send.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';
|
|
25
26
|
import { openUrl } from './platform.js';
|
|
26
27
|
/** The invocation's logger, or one that discards everything. */
|
|
27
28
|
export function log(deps) {
|
|
@@ -79,6 +80,20 @@ function reportError(deps, err, context = {}) {
|
|
|
79
80
|
});
|
|
80
81
|
if (err instanceof ApiCallError) {
|
|
81
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
|
+
}
|
|
82
97
|
if (err.nextAction)
|
|
83
98
|
deps.io.err(`next: ${err.nextAction}`);
|
|
84
99
|
if (err.code === 'auth_required' || err.code === 'machine_revoked')
|
|
@@ -301,6 +316,13 @@ export function canDeviceReceive(device) {
|
|
|
301
316
|
return deviceCanReceive(device);
|
|
302
317
|
}
|
|
303
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
|
+
}
|
|
304
326
|
const config = loadLoggedConfig(deps, { cwd: deps.cwd, env: deps.env });
|
|
305
327
|
const credential = deps.store.load();
|
|
306
328
|
const baseUrl = resolvedBaseUrl(config, credential);
|
|
@@ -324,12 +346,51 @@ export async function capabilitiesCommand(deps, flags) {
|
|
|
324
346
|
// ---------------------------------------------------------------------------
|
|
325
347
|
// send / status
|
|
326
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
|
+
}
|
|
327
375
|
export async function sendCommand(deps, flags) {
|
|
328
376
|
const hasReplyChoice = Array.isArray(flags.replyChoice)
|
|
329
377
|
? flags.replyChoice.length > 0
|
|
330
378
|
: flags.replyChoice !== undefined;
|
|
331
|
-
if (flags.reply && flags.kind
|
|
332
|
-
deps.io.err(
|
|
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.');
|
|
333
394
|
return EXIT.usage;
|
|
334
395
|
}
|
|
335
396
|
if (!flags.reply &&
|
|
@@ -361,9 +422,14 @@ export async function sendCommand(deps, flags) {
|
|
|
361
422
|
if (flags.reply &&
|
|
362
423
|
flags.replyWindow !== undefined &&
|
|
363
424
|
(!Number.isInteger(flags.replyWindow) ||
|
|
364
|
-
flags.replyWindow <
|
|
425
|
+
flags.replyWindow < MIN_REPLY_WINDOW_SECONDS ||
|
|
365
426
|
flags.replyWindow > REPLY_MAX_WINDOW_SECONDS)) {
|
|
366
|
-
deps.io.err(`--reply-window must be an integer from
|
|
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);
|
|
367
433
|
return EXIT.usage;
|
|
368
434
|
}
|
|
369
435
|
const config = loadLoggedConfig(deps, {
|
|
@@ -371,18 +437,30 @@ export async function sendCommand(deps, flags) {
|
|
|
371
437
|
env: deps.env,
|
|
372
438
|
flags: { base_url: flags.baseUrl, wait_seconds: flags.wait },
|
|
373
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
|
+
}
|
|
374
445
|
const authed = authedClient(deps, config);
|
|
375
446
|
if (!authed)
|
|
376
447
|
return EXIT.auth;
|
|
377
|
-
|
|
378
|
-
|
|
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);
|
|
379
455
|
if (!uploaded.ok) {
|
|
380
|
-
|
|
456
|
+
if (uploaded.error !== null)
|
|
457
|
+
deps.io.err(uploaded.error);
|
|
381
458
|
return uploaded.exit;
|
|
382
459
|
}
|
|
383
|
-
|
|
460
|
+
mediaIds.push(uploaded.mediaId);
|
|
384
461
|
}
|
|
385
|
-
|
|
462
|
+
flags = { ...flags, image: mediaIds };
|
|
463
|
+
const build = buildDraft(config, flags, source.invocation);
|
|
386
464
|
if (!build.ok) {
|
|
387
465
|
deps.io.err(build.error);
|
|
388
466
|
return EXIT.usage;
|
|
@@ -454,6 +532,7 @@ export async function sendCommand(deps, flags) {
|
|
|
454
532
|
request_id: receipt.request_id,
|
|
455
533
|
replies: [],
|
|
456
534
|
agent_acknowledgement_required: receipt.agent_acknowledgement_required,
|
|
535
|
+
agent_acknowledgement_text_required: receipt.agent_acknowledgement_text_required,
|
|
457
536
|
agent_acknowledgement: null,
|
|
458
537
|
acknowledgement_command: null,
|
|
459
538
|
degraded: false,
|
|
@@ -529,12 +608,8 @@ function emitSendWarnings(deps, flags, config) {
|
|
|
529
608
|
if (flags.title.length > 40) {
|
|
530
609
|
deps.io.err(`Heads up: this title is ${flags.title.length} characters; notification titles work best around 40 characters or fewer.`);
|
|
531
610
|
}
|
|
532
|
-
if (
|
|
533
|
-
deps.io.err('Heads up:
|
|
534
|
-
}
|
|
535
|
-
const effectiveKind = flags.reply ? 'question' : (flags.kind ?? 'update');
|
|
536
|
-
if (effectiveKind === 'update' && /^(done|failed)\b/i.test(flags.title.trim())) {
|
|
537
|
-
deps.io.err(`Heads up: this title announces completion but the notification kind is update. Use --kind done for finished work.`);
|
|
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.');
|
|
538
613
|
}
|
|
539
614
|
if (flags.collapseKey === undefined &&
|
|
540
615
|
config.collapse_key.value !== null &&
|
|
@@ -545,9 +620,6 @@ function emitSendWarnings(deps, flags, config) {
|
|
|
545
620
|
deps.io.err('Heads up: this explicit --ttl is longer than 72 hours; stale notifications may arrive after they are useful.');
|
|
546
621
|
}
|
|
547
622
|
}
|
|
548
|
-
function looksLikeMarkdown(value) {
|
|
549
|
-
return /(?:^|\n)\s{0,3}(?:#{1,6}\s|[-+*]\s|>\s|\d+\.\s|```)|(?:\*\*|__|~~|`)[^\n]+(?:\*\*|__|~~|`)|\[[^\]]+\]\([^)]+\)/m.test(value);
|
|
550
|
-
}
|
|
551
623
|
export async function repliesCommand(deps, requestedId, flags) {
|
|
552
624
|
const waitSeconds = flags.wait ?? 0;
|
|
553
625
|
const afterSeq = flags.after ?? 0;
|
|
@@ -577,7 +649,15 @@ export async function repliesCommand(deps, requestedId, flags) {
|
|
|
577
649
|
.map((entry) => entry.request_id)
|
|
578
650
|
.filter((id) => id !== undefined);
|
|
579
651
|
if (requestIds.length === 0) {
|
|
580
|
-
|
|
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
|
+
}
|
|
581
661
|
return EXIT.noReply;
|
|
582
662
|
}
|
|
583
663
|
}
|
|
@@ -696,6 +776,7 @@ export async function waitForReply(client, requestId, options) {
|
|
|
696
776
|
request_id: requestId,
|
|
697
777
|
reply_expires_at: null,
|
|
698
778
|
agent_acknowledgement_required: false,
|
|
779
|
+
agent_acknowledgement_text_required: false,
|
|
699
780
|
agent_acknowledgement: null,
|
|
700
781
|
replies: [],
|
|
701
782
|
},
|
|
@@ -734,9 +815,14 @@ function degradedWaitWarning(requestId) {
|
|
|
734
815
|
function isNonNegativeInteger(value) {
|
|
735
816
|
return Number.isInteger(value) && value >= 0;
|
|
736
817
|
}
|
|
737
|
-
function
|
|
738
|
-
return
|
|
818
|
+
function acknowledgeInvocation(requestId, textRequired) {
|
|
819
|
+
return textRequired
|
|
739
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)
|
|
740
826
|
: null;
|
|
741
827
|
}
|
|
742
828
|
function replyResultJson(response, degraded) {
|
|
@@ -747,7 +833,8 @@ function replyResultJson(response, degraded) {
|
|
|
747
833
|
replies: response.replies,
|
|
748
834
|
agent_acknowledgement_required: response.agent_acknowledgement_required,
|
|
749
835
|
agent_acknowledgement: response.agent_acknowledgement,
|
|
750
|
-
|
|
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),
|
|
751
838
|
degraded,
|
|
752
839
|
};
|
|
753
840
|
}
|
|
@@ -756,13 +843,19 @@ function printAcknowledgementStatus(deps, response) {
|
|
|
756
843
|
deps.io.out('Agent Acknowledgement: not required for this request.');
|
|
757
844
|
return;
|
|
758
845
|
}
|
|
846
|
+
const textRequired = response.agent_acknowledgement_text_required;
|
|
759
847
|
if (response.agent_acknowledgement !== null) {
|
|
760
|
-
|
|
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}.`);
|
|
761
852
|
return;
|
|
762
853
|
}
|
|
763
854
|
if (response.replies.length > 0) {
|
|
764
855
|
deps.io.out('Agent Acknowledgement required.');
|
|
765
|
-
deps.io.out(
|
|
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.`);
|
|
766
859
|
return;
|
|
767
860
|
}
|
|
768
861
|
deps.io.out('Agent Acknowledgement: required after a user reply; no reply is recorded yet.');
|
|
@@ -917,7 +1010,13 @@ async function uploadImage(deps, client, source) {
|
|
|
917
1010
|
return { ok: true, mediaId: grant.media_id };
|
|
918
1011
|
}
|
|
919
1012
|
catch (err) {
|
|
920
|
-
|
|
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) };
|
|
921
1020
|
}
|
|
922
1021
|
}
|
|
923
1022
|
// ---------------------------------------------------------------------------
|
|
@@ -947,7 +1046,11 @@ export async function hookRunCommand(deps, event, readStdin, harness) {
|
|
|
947
1046
|
// would grant slow setup a second budget and let the harness kill us before
|
|
948
1047
|
// an accepted answer is journaled or written to stdout.
|
|
949
1048
|
const now = deps.now ?? Date.now;
|
|
950
|
-
|
|
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;
|
|
951
1054
|
const logger = log(deps);
|
|
952
1055
|
logger.bind({ cmd: `hook ${event}` });
|
|
953
1056
|
let started = false;
|
|
@@ -1127,18 +1230,8 @@ export async function hookRunCommand(deps, event, readStdin, harness) {
|
|
|
1127
1230
|
});
|
|
1128
1231
|
for (const note of outcome.notes)
|
|
1129
1232
|
deps.io.err(`notifai: ${note}`);
|
|
1130
|
-
if (outcome.stdout !== undefined)
|
|
1131
|
-
|
|
1132
|
-
if (harness !== undefined &&
|
|
1133
|
-
event === 'stop' &&
|
|
1134
|
-
HARNESS_CAPABILITIES[harness].stopContinuation === 'followup-message') {
|
|
1135
|
-
const decision = JSON.parse(outcome.stdout);
|
|
1136
|
-
if (decision.decision === 'block' && typeof decision.reason === 'string') {
|
|
1137
|
-
stdout = JSON.stringify({ followup_message: decision.reason });
|
|
1138
|
-
}
|
|
1139
|
-
}
|
|
1140
|
-
deps.io.out(stdout);
|
|
1141
|
-
}
|
|
1233
|
+
if (outcome.stdout !== undefined)
|
|
1234
|
+
deps.io.out(outcome.stdout);
|
|
1142
1235
|
return EXIT.ok;
|
|
1143
1236
|
}
|
|
1144
1237
|
catch (err) {
|
|
@@ -1251,9 +1344,8 @@ function rejectedPaths(details) {
|
|
|
1251
1344
|
.slice(0, 5);
|
|
1252
1345
|
}
|
|
1253
1346
|
/**
|
|
1254
|
-
* Turn ask input into
|
|
1255
|
-
* validated
|
|
1256
|
-
* where a rejection becomes a stderr note the agent never reads.
|
|
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.
|
|
1257
1349
|
*/
|
|
1258
1350
|
export function buildQuestions(flags, question) {
|
|
1259
1351
|
if (flags.form !== undefined) {
|
|
@@ -1265,21 +1357,41 @@ export function buildQuestions(flags, question) {
|
|
|
1265
1357
|
parsed = JSON.parse(flags.form);
|
|
1266
1358
|
}
|
|
1267
1359
|
catch {
|
|
1268
|
-
return {
|
|
1360
|
+
return {
|
|
1361
|
+
ok: false,
|
|
1362
|
+
error: '--form must be JSON: {"questions": [{"text", "choices"?, "multi"?}], "body"?}.',
|
|
1363
|
+
};
|
|
1269
1364
|
}
|
|
1270
|
-
if (typeof parsed !== 'object' || parsed === null ||
|
|
1271
|
-
return { ok: false, error:
|
|
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).` };
|
|
1272
1367
|
}
|
|
1273
|
-
const
|
|
1274
|
-
|
|
1275
|
-
|
|
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
|
+
};
|
|
1276
1385
|
}
|
|
1277
|
-
if (
|
|
1278
|
-
return { ok: false, error: '"
|
|
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.' };
|
|
1279
1391
|
}
|
|
1280
1392
|
const questions = [];
|
|
1281
1393
|
const usedIds = new Set();
|
|
1282
|
-
for (const [index, entry] of
|
|
1394
|
+
for (const [index, entry] of formQuestions.entries()) {
|
|
1283
1395
|
if (typeof entry !== 'object' || entry === null || typeof entry.text !== 'string') {
|
|
1284
1396
|
return { ok: false, error: `Question ${index + 1} needs a "text" string.` };
|
|
1285
1397
|
}
|
|
@@ -1289,7 +1401,15 @@ export function buildQuestions(flags, question) {
|
|
|
1289
1401
|
return { ok: false, error: `Question ${index + 1}: ${built.error}` };
|
|
1290
1402
|
questions.push(built.question);
|
|
1291
1403
|
}
|
|
1292
|
-
|
|
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
|
+
};
|
|
1293
1413
|
}
|
|
1294
1414
|
if (question === undefined || question.trim() === '') {
|
|
1295
1415
|
return { ok: false, error: 'The question cannot be empty.' };
|
|
@@ -1297,10 +1417,13 @@ export function buildQuestions(flags, question) {
|
|
|
1297
1417
|
const built = buildOneQuestion(question, flags.choice, flags.multi === true, 0, new Set());
|
|
1298
1418
|
if ('error' in built)
|
|
1299
1419
|
return { ok: false, error: built.error };
|
|
1420
|
+
const context = flags.body;
|
|
1300
1421
|
return {
|
|
1301
1422
|
ok: true,
|
|
1302
1423
|
questions: [built.question],
|
|
1303
|
-
|
|
1424
|
+
body: context !== undefined && context.trim() !== ''
|
|
1425
|
+
? `${built.question.text}\n\n${context}`
|
|
1426
|
+
: built.question.text,
|
|
1304
1427
|
};
|
|
1305
1428
|
}
|
|
1306
1429
|
function buildOneQuestion(text, choiceLabels, multi, index, usedIds) {
|
|
@@ -1310,7 +1433,7 @@ function buildOneQuestion(text, choiceLabels, multi, index, usedIds) {
|
|
|
1310
1433
|
if (trimmed.length > QUESTION_TEXT_MAX_LENGTH) {
|
|
1311
1434
|
return {
|
|
1312
1435
|
error: `a question must be readable where it is answered: keep it within ` +
|
|
1313
|
-
`${QUESTION_TEXT_MAX_LENGTH} characters and put the longer context in
|
|
1436
|
+
`${QUESTION_TEXT_MAX_LENGTH} characters and put the longer context in --body.`,
|
|
1314
1437
|
};
|
|
1315
1438
|
}
|
|
1316
1439
|
const choices = parseChoices(choiceLabels);
|
|
@@ -1332,6 +1455,131 @@ function buildOneQuestion(text, choiceLabels, multi, index, usedIds) {
|
|
|
1332
1455
|
},
|
|
1333
1456
|
};
|
|
1334
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
|
+
}
|
|
1335
1583
|
/**
|
|
1336
1584
|
* Registers a question for turn-end routing. Returns immediately so the agent
|
|
1337
1585
|
* can ask in prose and end its turn; the terminal keeps the question to itself
|
|
@@ -1346,6 +1594,11 @@ export function askCommand(deps, question, flags) {
|
|
|
1346
1594
|
deps.io.err(built.error);
|
|
1347
1595
|
return EXIT.usage;
|
|
1348
1596
|
}
|
|
1597
|
+
const mediaInputError = validateMediaInputs(flags.image, flags.imageAlt);
|
|
1598
|
+
if (mediaInputError !== null) {
|
|
1599
|
+
deps.io.err(mediaInputError);
|
|
1600
|
+
return EXIT.usage;
|
|
1601
|
+
}
|
|
1349
1602
|
const routingConfig = loadConfig({ cwd: deps.cwd, env: deps.env });
|
|
1350
1603
|
if (!routingConfig.ask_notifications.value) {
|
|
1351
1604
|
deps.io.err('Question routing is disabled by ask_notifications=false; enable it or use a blocking `notifai send --reply` question.');
|
|
@@ -1383,10 +1636,6 @@ export function askCommand(deps, question, flags) {
|
|
|
1383
1636
|
}
|
|
1384
1637
|
return EXIT.usage;
|
|
1385
1638
|
}
|
|
1386
|
-
if (flags.session !== undefined && flags.session !== active.sessionId) {
|
|
1387
|
-
deps.io.err(`Question routing is not ready: --session ${flags.session} does not match the exact active ${active.label} session ${active.sessionId}; refusing cross-session routing.`);
|
|
1388
|
-
return EXIT.usage;
|
|
1389
|
-
}
|
|
1390
1639
|
const routeProblems = activeQuestionRouteProblems(deps, active, installations);
|
|
1391
1640
|
if (routeProblems.length > 0) {
|
|
1392
1641
|
for (const problem of routeProblems)
|
|
@@ -1410,57 +1659,27 @@ export function askCommand(deps, question, flags) {
|
|
|
1410
1659
|
deps.io.err(line);
|
|
1411
1660
|
return EXIT.usage;
|
|
1412
1661
|
}
|
|
1413
|
-
|
|
1414
|
-
|
|
1415
|
-
|
|
1416
|
-
|
|
1417
|
-
...(built.detail !== undefined ? { detail: built.detail } : {}),
|
|
1418
|
-
}, (deps.now ?? Date.now)());
|
|
1662
|
+
const source = resolveDraftInvocation(deps, flags, active);
|
|
1663
|
+
if (!source.ok) {
|
|
1664
|
+
deps.io.err(source.error);
|
|
1665
|
+
return EXIT.usage;
|
|
1419
1666
|
}
|
|
1420
|
-
|
|
1421
|
-
|
|
1422
|
-
|
|
1423
|
-
return EXIT.failed;
|
|
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;
|
|
1424
1670
|
}
|
|
1425
|
-
//
|
|
1426
|
-
//
|
|
1427
|
-
|
|
1428
|
-
|
|
1429
|
-
|
|
1430
|
-
|
|
1431
|
-
|
|
1432
|
-
text: built.questions[0].text,
|
|
1433
|
-
choices: built.questions[0].choices?.length ?? 0,
|
|
1434
|
-
});
|
|
1435
|
-
for (const [index, entry] of built.questions.entries()) {
|
|
1436
|
-
const prefix = built.questions.length > 1 ? `${index + 1}. ` : '';
|
|
1437
|
-
if (entry.choices !== undefined) {
|
|
1438
|
-
const kind = entry.multi === true ? 'answers offered (several may be chosen)' : 'answers offered';
|
|
1439
|
-
deps.io.out(`${prefix}${entry.text} — ${kind}: ${entry.choices.map((choice) => choice.label).join(' / ')}`);
|
|
1440
|
-
}
|
|
1441
|
-
else if (built.questions.length > 1) {
|
|
1442
|
-
deps.io.out(`${prefix}${entry.text} — free text`);
|
|
1443
|
-
}
|
|
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;
|
|
1444
1678
|
}
|
|
1445
|
-
|
|
1446
|
-
|
|
1447
|
-
: 'Question registered. Ask it in the conversation, state the concrete work you will resume when the answer arrives, then end your turn.');
|
|
1448
|
-
deps.io.out('Before ending this turn, pre-commit in your own words to the work you will resume:');
|
|
1449
|
-
for (const [index, entry] of built.questions.entries()) {
|
|
1450
|
-
const questionPrefix = built.questions.length > 1 ? `Question ${index + 1}, ` : '';
|
|
1451
|
-
if (entry.choices !== undefined) {
|
|
1452
|
-
for (const choice of entry.choices) {
|
|
1453
|
-
deps.io.out(`- ${questionPrefix}If the answer is ${JSON.stringify(choice.label)}: state the concrete work you will resume.`);
|
|
1454
|
-
}
|
|
1455
|
-
deps.io.out(`- ${questionPrefix}For an unexpected typed answer: state how it will determine the concrete work you resume.`);
|
|
1456
|
-
}
|
|
1457
|
-
else {
|
|
1458
|
-
deps.io.out(`- ${questionPrefix}For the free-text answer: state how its content will determine the concrete work you resume.`);
|
|
1459
|
-
}
|
|
1679
|
+
if ((flags.image?.length ?? 0) > 0) {
|
|
1680
|
+
return uploadAskMedia(deps, routingConfig, sessionId, built, flags, source.invocation);
|
|
1460
1681
|
}
|
|
1461
|
-
deps
|
|
1462
|
-
deps.io.out('A Notifai answer cannot answer a harness permission prompt or interactive picker; leave those to the harness and user.');
|
|
1463
|
-
return EXIT.ok;
|
|
1682
|
+
return recordRegisteredQuestion(deps, sessionId, built, preflight.draft, flags.json === true);
|
|
1464
1683
|
}
|
|
1465
1684
|
/**
|
|
1466
1685
|
* Every harness marker present in this environment, in declared order.
|
|
@@ -1659,15 +1878,22 @@ function hookActivationAdvice(installations) {
|
|
|
1659
1878
|
}
|
|
1660
1879
|
return `${advice.join('. ')}.`;
|
|
1661
1880
|
}
|
|
1662
|
-
/**
|
|
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
|
+
*/
|
|
1663
1889
|
export async function acknowledgeCommand(deps, requestId, flags) {
|
|
1664
1890
|
const text = flags.text?.trim() ?? '';
|
|
1665
|
-
if (text.length === 0) {
|
|
1666
|
-
deps.io.err('--text
|
|
1891
|
+
if (flags.text !== undefined && text.length === 0) {
|
|
1892
|
+
deps.io.err('--text must contain non-whitespace text. Drop it to acknowledge without text.');
|
|
1667
1893
|
return EXIT.usage;
|
|
1668
1894
|
}
|
|
1669
1895
|
if (text.length > AGENT_ACKNOWLEDGEMENT_MAX_LENGTH) {
|
|
1670
|
-
deps.io.err(`--text must be at most ${AGENT_ACKNOWLEDGEMENT_MAX_LENGTH} characters after trimming.`);
|
|
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.`);
|
|
1671
1897
|
return EXIT.usage;
|
|
1672
1898
|
}
|
|
1673
1899
|
const logger = log(deps);
|
|
@@ -1681,7 +1907,7 @@ export async function acknowledgeCommand(deps, requestId, flags) {
|
|
|
1681
1907
|
if (!authed)
|
|
1682
1908
|
return EXIT.auth;
|
|
1683
1909
|
try {
|
|
1684
|
-
const result = await authed.client.putAgentAcknowledgement(requestId, { text });
|
|
1910
|
+
const result = await authed.client.putAgentAcknowledgement(requestId, text.length > 0 ? { text } : {});
|
|
1685
1911
|
const output = {
|
|
1686
1912
|
request_id: requestId,
|
|
1687
1913
|
outcome: result.status,
|
|
@@ -1721,7 +1947,7 @@ export async function closeCommand(deps, requestId, flags = {}) {
|
|
|
1721
1947
|
if (flags.json) {
|
|
1722
1948
|
deps.io.out(JSON.stringify({
|
|
1723
1949
|
...response,
|
|
1724
|
-
acknowledgement_command: acknowledgementCommand(response.request_id, response.agent_acknowledgement_required, response.agent_acknowledgement, response.replies.length > 0),
|
|
1950
|
+
acknowledgement_command: acknowledgementCommand(response.request_id, response.agent_acknowledgement_required, response.agent_acknowledgement_text_required, response.agent_acknowledgement, response.replies.length > 0),
|
|
1725
1951
|
}, null, 2));
|
|
1726
1952
|
}
|
|
1727
1953
|
else {
|
|
@@ -1779,7 +2005,7 @@ export function hooksInstallCommand(deps, flags) {
|
|
|
1779
2005
|
if (flags.harness === undefined) {
|
|
1780
2006
|
const detected = detectedHarnesses(deps.cwd, deps.env);
|
|
1781
2007
|
if (detected.length === 0) {
|
|
1782
|
-
deps.io.err(`Could not tell which harness
|
|
2008
|
+
deps.io.err(`Could not tell which harness you mean — pass --harness <${HARNESSES.join('|')}>.`);
|
|
1783
2009
|
return EXIT.usage;
|
|
1784
2010
|
}
|
|
1785
2011
|
let ok = true;
|
|
@@ -1888,6 +2114,14 @@ export function hooksInstallCommand(deps, flags) {
|
|
|
1888
2114
|
for (const problem of codexRepresentationProblems(deps.cwd, deps.env, hookPlatform)) {
|
|
1889
2115
|
deps.io.out(problem);
|
|
1890
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
|
+
}
|
|
1891
2125
|
}
|
|
1892
2126
|
return EXIT.ok;
|
|
1893
2127
|
}
|
|
@@ -2040,12 +2274,6 @@ function assertOwnedRegularFile(file) {
|
|
|
2040
2274
|
throw new Error(`${file} is owned by uid ${stat.uid}, not the current user.`);
|
|
2041
2275
|
}
|
|
2042
2276
|
}
|
|
2043
|
-
const HARNESS_LABEL = {
|
|
2044
|
-
'claude-code': 'Claude Code',
|
|
2045
|
-
codex: 'Codex',
|
|
2046
|
-
cursor: 'Cursor',
|
|
2047
|
-
opencode: 'OpenCode',
|
|
2048
|
-
};
|
|
2049
2277
|
function resolveHarness(deps, requested) {
|
|
2050
2278
|
if (requested !== undefined) {
|
|
2051
2279
|
if (HARNESSES.includes(requested))
|
|
@@ -2076,7 +2304,7 @@ async function pickHarnessesToInstall(deps, requested) {
|
|
|
2076
2304
|
if (deps.io.interactive === true && deps.io.multiselect) {
|
|
2077
2305
|
const picked = await deps.io.multiselect('Which agent harnesses should Notifai wire here?', HARNESSES.map((name) => ({
|
|
2078
2306
|
value: name,
|
|
2079
|
-
label:
|
|
2307
|
+
label: HARNESS_LABELS[name],
|
|
2080
2308
|
...(detected.includes(name) ? { hint: 'detected on this machine' } : {}),
|
|
2081
2309
|
})), detected);
|
|
2082
2310
|
if (picked === null)
|
|
@@ -2462,8 +2690,8 @@ export function logsCommand(deps, flags) {
|
|
|
2462
2690
|
query.since = since;
|
|
2463
2691
|
}
|
|
2464
2692
|
if (flags.level !== undefined) {
|
|
2465
|
-
if (!
|
|
2466
|
-
deps.io.err(`--level takes one of: ${
|
|
2693
|
+
if (!RECORD_LEVELS.includes(flags.level)) {
|
|
2694
|
+
deps.io.err(`--level takes one of: ${RECORD_LEVELS.join(', ')} — not "${flags.level}".`);
|
|
2467
2695
|
return EXIT.usage;
|
|
2468
2696
|
}
|
|
2469
2697
|
query.level = flags.level;
|
|
@@ -2599,14 +2827,9 @@ async function skillReadiness(deps, selectedScope) {
|
|
|
2599
2827
|
},
|
|
2600
2828
|
};
|
|
2601
2829
|
}
|
|
2602
|
-
/** Derive a contract-valid project slug
|
|
2830
|
+
/** Derive a contract-valid project slug; init alone needs a non-empty fallback. */
|
|
2603
2831
|
export function projectSlugFrom(name) {
|
|
2604
|
-
|
|
2605
|
-
.toLowerCase()
|
|
2606
|
-
.replaceAll(/[^a-z0-9._-]+/g, '-')
|
|
2607
|
-
.replaceAll(/^[^a-z0-9]+|[^a-z0-9._-]+$/g, '')
|
|
2608
|
-
.slice(0, 64);
|
|
2609
|
-
return slug.length > 0 && /^[a-z0-9]/.test(slug) ? slug : 'project';
|
|
2832
|
+
return inferredProjectSlugFrom(name) ?? 'project';
|
|
2610
2833
|
}
|
|
2611
2834
|
/** Long enough for a first TestFlight install; keep-waiting extends another budget. */
|
|
2612
2835
|
const DEVICE_BRIDGE_TIMEOUT_MS = 10 * 60 * 1000;
|
|
@@ -3199,7 +3422,9 @@ async function printInitClose(deps, readiness, flags) {
|
|
|
3199
3422
|
];
|
|
3200
3423
|
await deps.io.note?.(lines.join('\n'), 'Ready');
|
|
3201
3424
|
printOptionalLeftovers(deps, leftovers);
|
|
3202
|
-
deps.io.out(
|
|
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.');
|
|
3203
3428
|
await deps.io.outro?.('All set ✨');
|
|
3204
3429
|
return;
|
|
3205
3430
|
}
|
|
@@ -3214,7 +3439,9 @@ async function printInitClose(deps, readiness, flags) {
|
|
|
3214
3439
|
}
|
|
3215
3440
|
if (blocker === null) {
|
|
3216
3441
|
printOptionalLeftovers(deps, leftovers);
|
|
3217
|
-
deps.io.out(
|
|
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.');
|
|
3218
3445
|
return;
|
|
3219
3446
|
}
|
|
3220
3447
|
deps.io.out(`Next: ${blocker.title} — ${blocker.detail}`);
|
|
@@ -3265,29 +3492,40 @@ async function contractCheck(client) {
|
|
|
3265
3492
|
}
|
|
3266
3493
|
}
|
|
3267
3494
|
function projectReadiness(deps, config) {
|
|
3268
|
-
const
|
|
3269
|
-
|
|
3270
|
-
|
|
3271
|
-
? {
|
|
3495
|
+
const configured = config.project.value;
|
|
3496
|
+
if (configured !== null) {
|
|
3497
|
+
return {
|
|
3272
3498
|
id: 'project',
|
|
3273
3499
|
title: 'Project identity',
|
|
3274
3500
|
status: 'ready',
|
|
3275
|
-
detail: `"${
|
|
3276
|
-
}
|
|
3277
|
-
|
|
3501
|
+
detail: `"${configured}" (${config.project.source})`,
|
|
3502
|
+
};
|
|
3503
|
+
}
|
|
3504
|
+
const inferred = inferInvocationContext(deps.cwd).project;
|
|
3505
|
+
if (inferred !== null) {
|
|
3506
|
+
return {
|
|
3278
3507
|
id: 'project',
|
|
3279
3508
|
title: 'Project identity',
|
|
3280
|
-
// Not a blocker: a send without a project simply carries no project
|
|
3281
|
-
// identity. init always sets one because it is free and reversible,
|
|
3282
|
-
// but an unlabelled setup works, so this must not go red.
|
|
3283
3509
|
status: 'optional-gap',
|
|
3284
|
-
detail: `
|
|
3510
|
+
detail: `"${inferred}" is inferred for each send; init can stamp it into shared config`,
|
|
3285
3511
|
remedy: {
|
|
3286
3512
|
by: 'cli',
|
|
3287
|
-
summary: '
|
|
3513
|
+
summary: 'make the inferred Project identity explicit for every checkout',
|
|
3288
3514
|
command: 'notifai init',
|
|
3289
3515
|
},
|
|
3290
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
|
+
};
|
|
3291
3529
|
}
|
|
3292
3530
|
function remoteStatesFrom(previous) {
|
|
3293
3531
|
const pick = (id) => previous.states.find((state) => state.id === id);
|
|
@@ -3732,14 +3970,39 @@ function hookStates(deps) {
|
|
|
3732
3970
|
settings,
|
|
3733
3971
|
];
|
|
3734
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
|
+
}
|
|
3735
4000
|
/** Real but not in the way; see the note above. */
|
|
3736
4001
|
const informational = new Set();
|
|
3737
4002
|
return [
|
|
3738
4003
|
...hookChecks(deps).map((check) => ({
|
|
3739
4004
|
id: check.name.replace(/[ ()]+/g, '-').replace(/-$/, ''),
|
|
3740
|
-
title: check.name
|
|
3741
|
-
? 'Question routing'
|
|
3742
|
-
: check.name,
|
|
4005
|
+
title: checkTitle(check.name),
|
|
3743
4006
|
status: check.ok
|
|
3744
4007
|
? 'ready'
|
|
3745
4008
|
: check.informational === true || informational.has(check.name)
|
|
@@ -3784,7 +4047,7 @@ function stopShapeProblems(installation, platform = process.platform) {
|
|
|
3784
4047
|
return [];
|
|
3785
4048
|
const problems = [];
|
|
3786
4049
|
for (const handler of installation.handlers.filter((entry) => handlerEvent(entry.command) === 'stop')) {
|
|
3787
|
-
if (installation.harness
|
|
4050
|
+
if (stopHandlerIsDetached(installation.harness, platform)) {
|
|
3788
4051
|
if (handler.async !== true) {
|
|
3789
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`);
|
|
3790
4053
|
}
|
|
@@ -3826,7 +4089,7 @@ function hookChecks(deps) {
|
|
|
3826
4089
|
name: 'hooks (detected)',
|
|
3827
4090
|
ok: false,
|
|
3828
4091
|
informational: true,
|
|
3829
|
-
detail: `${unwired.map((harness) =>
|
|
4092
|
+
detail: `${unwired.map((harness) => HARNESS_LABELS[harness]).join(', ')} detected on this machine but not wired`,
|
|
3830
4093
|
remedy: {
|
|
3831
4094
|
summary: 'install hooks for every detected harness',
|
|
3832
4095
|
command: 'notifai hooks install',
|
|
@@ -3962,6 +4225,20 @@ function hookChecks(deps) {
|
|
|
3962
4225
|
detail: representationProblems.join('; '),
|
|
3963
4226
|
});
|
|
3964
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
|
+
}
|
|
3965
4242
|
if (active !== null && activeInstallations.length > 0) {
|
|
3966
4243
|
const admissionProblems = activeQuestionRouteProblems(deps, active, installations);
|
|
3967
4244
|
checks.push({
|