@raidiant/notifai 0.5.1 → 1.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.
Files changed (57) hide show
  1. package/CHANGELOG.md +165 -0
  2. package/README.md +11 -4
  3. package/dist/commands.d.ts +16 -1
  4. package/dist/commands.js +371 -157
  5. package/dist/commands.js.map +1 -1
  6. package/dist/config-schema.d.ts +1 -1
  7. package/dist/config-schema.js +2 -14
  8. package/dist/config-schema.js.map +1 -1
  9. package/dist/config.d.ts +19 -8
  10. package/dist/config.js +50 -7
  11. package/dist/config.js.map +1 -1
  12. package/dist/harnesses.d.ts +1 -0
  13. package/dist/harnesses.js +6 -0
  14. package/dist/harnesses.js.map +1 -1
  15. package/dist/hook-adapter.d.ts +10 -1
  16. package/dist/hook-adapter.js +79 -10
  17. package/dist/hook-adapter.js.map +1 -1
  18. package/dist/install-hooks.d.ts +54 -17
  19. package/dist/install-hooks.js +258 -97
  20. package/dist/install-hooks.js.map +1 -1
  21. package/dist/interactive.d.ts +32 -1
  22. package/dist/interactive.js +163 -47
  23. package/dist/interactive.js.map +1 -1
  24. package/dist/main.js +8 -8
  25. package/dist/main.js.map +1 -1
  26. package/dist/native-skills.d.ts +1 -1
  27. package/dist/native-skills.js +23 -58
  28. package/dist/native-skills.js.map +1 -1
  29. package/dist/pack-feedback-slice.d.ts +35 -0
  30. package/dist/pack-feedback-slice.js +126 -0
  31. package/dist/pack-feedback-slice.js.map +1 -0
  32. package/dist/readiness.d.ts +20 -0
  33. package/dist/readiness.js +24 -0
  34. package/dist/readiness.js.map +1 -1
  35. package/package.json +3 -2
  36. package/src/commands.test.ts +364 -31
  37. package/src/commands.ts +450 -177
  38. package/src/config-schema.ts +2 -17
  39. package/src/config.test.ts +29 -4
  40. package/src/config.ts +67 -11
  41. package/src/harnesses.ts +7 -0
  42. package/src/hook-adapter.test.ts +43 -1
  43. package/src/hook-adapter.ts +83 -11
  44. package/src/hooks.test.ts +4 -5
  45. package/src/install-hooks.test.ts +154 -2
  46. package/src/install-hooks.ts +231 -44
  47. package/src/interactive.test.ts +338 -0
  48. package/src/interactive.ts +221 -49
  49. package/src/main.ts +8 -8
  50. package/src/native-skills.test.ts +75 -0
  51. package/src/native-skills.ts +31 -75
  52. package/src/pack-feedback-slice.test.ts +212 -0
  53. package/src/pack-feedback-slice.ts +187 -0
  54. package/src/readiness.test.ts +25 -0
  55. package/src/readiness.ts +38 -0
  56. package/src/skill.test.ts +43 -0
  57. package/src/ui/config-view.test.ts +5 -4
package/dist/commands.js CHANGED
@@ -7,20 +7,20 @@ import { parse as parseToml, stringify as stringifyToml } from 'smol-toml';
7
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, isRetryableReplyPollError, } from './client.js';
10
- import { BOOLEAN_CONFIG_KEYS, CONFIG_KEYS, LOG_LEVELS, NUMERIC_CONFIG_KEYS, configBounds, configDefaultValue, findProjectConfigPath, findProjectLocalConfigPath, globalConfigPath, loadConfig, sessionConfigPath, } from './config.js';
10
+ import { BOOLEAN_CONFIG_KEYS, CONFIG_KEYS, LOG_LEVELS, NUMERIC_CONFIG_KEYS, configBounds, configDefaultValue, findProjectConfigPath, personalProjectConfigPath, globalConfigPath, loadConfig, sessionConfigPath, } from './config.js';
11
11
  import { acceptedValues, configInfo } from './config-schema.js';
12
- import { skillsSource } from './release.js';
12
+ import { packageVersion, skillsSource } from './release.js';
13
13
  import { atomicWriteFileSync } from './atomic-file.js';
14
14
  import { withTargetFileLock } from './file-lock.js';
15
15
  import { renderConfigExplain, renderConfigList, renderConfigPlain, } from './ui/config-view.js';
16
- import { firstBlocker, openItems, } from './readiness.js';
16
+ import { firstBlocker, } from './readiness.js';
17
17
  import { WAITER_CEILING_SECONDS, handleSessionEnd, handleStop, handleUserPromptSubmit, parseHookInput, pruneAbandonedSessions, readLiveProjectSessionPointers, readMatchingProjectSessionPointer, readProjectSession, readProjectSessionPointer, readSessionState, registerQuestion, } from './hooks.js';
18
18
  import { LOG_EVENTS, activeLogPath, archiveLogPaths, logConfigResolved, logSettingsFrom, logsDiskUsage, nullLogger, readLogRecords, renderRecord, } from './logging.js';
19
- import { BLOCKING_STOP_TIMEOUT_SECONDS, CLAUDE_ASYNC_STOP_TIMEOUT_SECONDS, HARNESSES, applyPlan, buildCursorHookConfig, buildHookConfig, codexLayerDir, codexTrustProblems, codexProjectRoot, detectHarness, findInstallations, handlerEvent, loadCursorSettings, loadSettings, mergeCursorHooks, mergeHooks, removeCursorHooks, removeHooks, settingsFile, } from './install-hooks.js';
20
- import { HARNESS_CAPABILITIES } from './harnesses.js';
19
+ import { BLOCKING_STOP_TIMEOUT_SECONDS, CLAUDE_ASYNC_STOP_TIMEOUT_SECONDS, HARNESSES, applyPlan, buildCursorHookConfig, buildHookConfig, collapseUnusedCodexRepresentation, codexLayerDir, codexLayerPaths, codexRepresentationProblems, codexTrustProblems, codexProjectRoot, detectHarness, detectedHarnesses, findInstallations, handlerEvent, hookDefinitionFiles, inspectCodexLayer, loadCursorSettings, loadSettings, mergeCursorHooks, mergeHooks, removeCursorHooks, removeHooks, settingsFile, } from './install-hooks.js';
20
+ import { HARNESS_CAPABILITIES, HARNESS_LABELS } from './harnesses.js';
21
21
  import { claudeWakeRoute, inspectClaudeInbox, systemClaudeWakeAdapters, } from './claude-wake.js';
22
22
  import { codexWakeRoute, inspectCodexResume, } from './codex-wake.js';
23
- import { inspectHookAdapter, installHookAdapter, } from './hook-adapter.js';
23
+ import { inspectHookAdapter, installHookAdapter, isNpxAdapterTarget, } from './hook-adapter.js';
24
24
  import { isOurOpencodePlugin, opencodePluginSource, } from './opencode-plugin.js';
25
25
  import { CHOICE_USAGE, buildDraft, formatReceipt, parseChoices, receiptExitCode, slugify, } from './send.js';
26
26
  /** The invocation's logger, or one that discards everything. */
@@ -1628,21 +1628,73 @@ export async function closeCommand(deps, requestId) {
1628
1628
  return reportError(deps, err);
1629
1629
  }
1630
1630
  }
1631
+ /** True when this process is `npx` / `npm exec`, not a global or linked install. */
1632
+ export function runningViaNpx(env, scriptPath) {
1633
+ if (env['npm_command'] === 'exec')
1634
+ return true;
1635
+ return scriptPath.includes(`${path.sep}_npx${path.sep}`);
1636
+ }
1637
+ function fileHookInstallTarget(target) {
1638
+ if (target === undefined || isNpxAdapterTarget(target))
1639
+ return undefined;
1640
+ return target;
1641
+ }
1642
+ function resolveHookAdapterTarget(deps, flags) {
1643
+ if (deps.hookInstallTarget !== undefined && isNpxAdapterTarget(deps.hookInstallTarget)) {
1644
+ return deps.hookInstallTarget;
1645
+ }
1646
+ const fileTarget = fileHookInstallTarget(deps.hookInstallTarget);
1647
+ const execPath = flags.execPath ?? fileTarget?.execPath ?? process.execPath;
1648
+ const scriptPath = flags.scriptPath ?? fileTarget?.scriptPath ?? process.argv[1] ?? 'notifai';
1649
+ if (runningViaNpx(deps.env, scriptPath)) {
1650
+ const version = packageVersion();
1651
+ const npmCli = deps.env['npm_execpath'];
1652
+ if (version === null) {
1653
+ throw new Error('Could not read this CLI version, so an npx hook target cannot be pinned. Install `@raidiant/notifai` globally and rerun `notifai hooks install`.');
1654
+ }
1655
+ if (typeof npmCli !== 'string' || npmCli === '') {
1656
+ throw new Error('This process looks like npx but npm_execpath is missing. Install `@raidiant/notifai` globally and rerun `notifai hooks install`.');
1657
+ }
1658
+ return { kind: 'npx', execPath, npmCli, spec: `@raidiant/notifai@${version}` };
1659
+ }
1660
+ return { execPath, scriptPath };
1661
+ }
1662
+ function printHooksInstallClose(deps, harness, file) {
1663
+ const label = HARNESS_LABELS[harness];
1664
+ if (deps.io.interactive === true && deps.io.note) {
1665
+ void deps.io.note(`${file}\nSend one ${label} prompt, then run \`notifai doctor\`.`, `${label} hooks installed`);
1666
+ return;
1667
+ }
1668
+ deps.io.out(`Installed ${harness} hooks in ${file}`);
1669
+ deps.io.out(`Send one ${label} prompt, then check \`notifai doctor\`.`);
1670
+ }
1631
1671
  export function hooksInstallCommand(deps, flags) {
1672
+ if (flags.harness === undefined) {
1673
+ const detected = detectedHarnesses(deps.cwd, deps.env);
1674
+ if (detected.length === 0) {
1675
+ deps.io.err(`Could not tell which harness to install for — pass --harness <${HARNESSES.join('|')}>.`);
1676
+ return EXIT.usage;
1677
+ }
1678
+ let ok = true;
1679
+ for (const harness of detected) {
1680
+ if (hooksInstallCommand(deps, { ...flags, harness }) !== EXIT.ok)
1681
+ ok = false;
1682
+ }
1683
+ return ok ? EXIT.ok : EXIT.failed;
1684
+ }
1632
1685
  const harness = resolveHarness(deps, flags.harness);
1633
1686
  if (!harness)
1634
1687
  return EXIT.usage;
1635
- const execPath = flags.execPath ?? process.execPath;
1636
- const scriptPath = flags.scriptPath ?? process.argv[1] ?? 'notifai';
1688
+ const adapterTarget = resolveHookAdapterTarget(deps, flags);
1689
+ const scriptPath = flags.scriptPath ?? fileHookInstallTarget(adapterTarget)?.scriptPath ?? process.argv[1] ?? 'notifai';
1637
1690
  let adapterPath;
1638
1691
  try {
1639
- adapterPath = installHookAdapter({ execPath, scriptPath }, deps.hookAdapterHome).path;
1692
+ adapterPath = installHookAdapter(adapterTarget, deps.hookAdapterHome).path;
1640
1693
  }
1641
1694
  catch (err) {
1642
1695
  deps.io.err(`Could not prepare the stable hook adapter: ${String(err)}`);
1643
1696
  return EXIT.failed;
1644
1697
  }
1645
- const config = loadLoggedConfig(deps, { cwd: deps.cwd, env: deps.env });
1646
1698
  const file = settingsFile(harness, flags.global ?? false, deps.cwd, deps.env);
1647
1699
  // OpenCode's adapter is a generated plugin module rather than a handler
1648
1700
  // merged into a settings document, so it owns the whole file.
@@ -1653,9 +1705,8 @@ export function hooksInstallCommand(deps, flags) {
1653
1705
  });
1654
1706
  }
1655
1707
  if (harness === 'cursor') {
1656
- let merged;
1657
1708
  try {
1658
- merged = withTargetFileLock(file, () => {
1709
+ withTargetFileLock(file, () => {
1659
1710
  const document = loadCursorSettings(file);
1660
1711
  const result = mergeCursorHooks(document, buildCursorHookConfig({
1661
1712
  adapterPath,
@@ -1669,28 +1720,15 @@ export function hooksInstallCommand(deps, flags) {
1669
1720
  deps.io.err(String(err));
1670
1721
  return EXIT.failed;
1671
1722
  }
1672
- deps.io.out(`Installed ${harness} hooks in ${file}`);
1673
- if (merged.replaced.length > 0)
1674
- deps.io.out(` replaced: ${merged.replaced.join(', ')}`);
1675
- if (merged.added.length > 0)
1676
- deps.io.out(` added: ${merged.added.join(', ')}`);
1677
- if (flags.global) {
1678
- deps.io.out('Send one Cursor prompt, then check `notifai doctor`. If the hook has not fired,');
1679
- deps.io.out('start a new Cursor session and try one prompt again.');
1680
- }
1681
- else {
1682
- deps.io.out('Send one Cursor prompt, then check `notifai doctor`. If the hook has not fired,');
1683
- deps.io.out('start a new Cursor session and try one prompt again.');
1684
- }
1685
- deps.io.out('Cursor hooks can emit a native follow-up, but its agent shell does not expose');
1686
- deps.io.out('the exact conversation id needed to register safely. `notifai ask` therefore');
1687
- deps.io.out('fails closed; use blocking `notifai send --reply` for questions.');
1723
+ printHooksInstallClose(deps, harness, file);
1688
1724
  return EXIT.ok;
1689
1725
  }
1690
- let merged;
1726
+ let foreignStopCount = 0;
1727
+ let collapsedFrom = null;
1691
1728
  try {
1692
- merged = withTargetFileLock(file, () => {
1729
+ withTargetFileLock(file, () => {
1693
1730
  const document = loadSettings(file);
1731
+ foreignStopCount = foreignStopHandlers(document).length;
1694
1732
  const result = mergeHooks(document, buildHookConfig({
1695
1733
  adapterPath,
1696
1734
  harness,
@@ -1698,61 +1736,41 @@ export function hooksInstallCommand(deps, flags) {
1698
1736
  applyPlan(file, result.document);
1699
1737
  return result;
1700
1738
  });
1739
+ if (harness === 'codex') {
1740
+ collapsedFrom = collapseUnusedCodexRepresentation(file, scriptPath, codexLayerPaths(flags.global ?? false, deps.cwd, deps.env));
1741
+ }
1701
1742
  }
1702
1743
  catch (err) {
1703
1744
  deps.io.err(String(err));
1704
1745
  return EXIT.failed;
1705
1746
  }
1706
- deps.io.out(`Installed ${harness} hooks in ${file}`);
1707
- if (merged.replaced.length > 0)
1708
- deps.io.out(` replaced: ${merged.replaced.join(', ')}`);
1709
- if (merged.added.length > 0)
1710
- deps.io.out(` added: ${merged.added.join(', ')}`);
1711
- if (merged.removed.length > 0) {
1712
- deps.io.out(` removed: ${merged.removed.join(', ')} (this build no longer serves them)`);
1713
- }
1714
- deps.io.out('');
1715
- deps.io.out(config.ask_grace_seconds.value === 0
1716
- ? 'A question registered with `notifai ask` goes to your devices immediately when the agent turn ends, whether or not you are at this machine.'
1717
- : `A question registered with \`notifai ask\` stays in the terminal for ${config.ask_grace_seconds.value}s from registration and then goes to your devices whether or not you are at this machine.`);
1718
1747
  if (harness === 'codex') {
1719
1748
  const layer = flags.global ? null : codexLayerDir(deps.cwd);
1720
- if (layer !== null) {
1721
- // Codex reads project hooks from the main repository but only looks when
1722
- // a `.codex` directory sits at or above cwd, so a worktree install has to
1723
- // write one file and create one directory in two different places. Doing
1724
- // it silently would leave the next person deriving this the hard way
1725
- //.
1749
+ if (layer !== null)
1726
1750
  mkdirSync(layer, { recursive: true });
1727
- deps.io.out('');
1728
- deps.io.out('You are in a worktree. Codex reads project hooks from the main repository,');
1729
- deps.io.out(`so they were written to ${file}. ${layer} was created so this`);
1730
- deps.io.out('worktree discovers that file. Each other worktree needs its own `.codex`');
1731
- deps.io.out('directory; rerun this installer from that worktree to create it.');
1732
- }
1733
- }
1734
- deps.io.out('');
1735
- if (harness === 'claude-code') {
1736
- // Codex deliberately gets no counterpart line: its Stop definition is
1737
- // byte-identical to the one earlier builds wrote, so nothing about its
1738
- // trust state changed and saying so would invent a gate that is not there.
1739
- deps.io.out(`The Stop handler is asynchronous: it returns at once, so your turn ends normally, and it waits for your answer out of band for up to ${CLAUDE_ASYNC_STOP_TIMEOUT_SECONDS}s before delivering it back into this session. That timeout is written explicitly because Claude Code kills a background hook at its own 600s default and reports nothing when it does.`);
1740
- deps.io.out('');
1741
1751
  }
1742
- if (harness === 'claude-code' && flags.global !== true) {
1743
- deps.io.out('Claude Code reloads project hook files without a restart. Send one new prompt,');
1744
- deps.io.out('then check `notifai doctor` to confirm that the hook fired.');
1752
+ printHooksInstallClose(deps, harness, file);
1753
+ if (collapsedFrom !== null) {
1754
+ deps.io.out(`Collapsed ${collapsedFrom} onto ${file} so this layer has one representation.`);
1745
1755
  }
1746
- else if (harness === 'claude-code') {
1747
- deps.io.out('Send one new Claude Code prompt, then check `notifai doctor`. If the hook has');
1748
- deps.io.out('not fired, start a new Claude Code session and try one prompt again.');
1756
+ if (foreignStopCount > 0) {
1757
+ deps.io.out("This layer already has a Stop handler. Codex runs every matching handler, so Notifai's Stop and the existing one will both fire.");
1749
1758
  }
1750
- else {
1751
- deps.io.out('Send one Codex prompt, then check `notifai doctor`. If the hook has not fired,');
1752
- deps.io.out('start a new Codex session and try one prompt again.');
1759
+ if (harness === 'codex') {
1760
+ for (const problem of codexRepresentationProblems(deps.cwd, deps.env)) {
1761
+ deps.io.out(problem);
1762
+ }
1753
1763
  }
1754
1764
  return EXIT.ok;
1755
1765
  }
1766
+ function foreignStopHandlers(document) {
1767
+ const groups = document.hooks?.['Stop'];
1768
+ if (!Array.isArray(groups))
1769
+ return [];
1770
+ return groups
1771
+ .flatMap((group) => group.hooks ?? [])
1772
+ .filter((handler) => !/ hook (user-prompt-submit|stop|session-end)\b/.test(handler.command));
1773
+ }
1756
1774
  /**
1757
1775
  * Writes the OpenCode plugin, replacing any Notifai plugin already there —
1758
1776
  * including one a different checkout wrote, matched on the managed marker for
@@ -1779,16 +1797,7 @@ function installOpencodePlugin(deps, file, options) {
1779
1797
  deps.io.err(String(err));
1780
1798
  return EXIT.failed;
1781
1799
  }
1782
- deps.io.out(`Installed the OpenCode plugin at ${file}`);
1783
- deps.io.out('');
1784
- deps.io.out('It maps chat.message to presence, session.idle to question escalation, and');
1785
- deps.io.out('session.deleted to local cleanup through the same `notifai hook` commands');
1786
- deps.io.out('the other harnesses run. Permission prompts stay in OpenCode.');
1787
- deps.io.out('OpenCode does not expose a proven exactly-once continuation after session.idle,');
1788
- deps.io.out('so `notifai ask` fails closed there. Use blocking `notifai send --reply` when');
1789
- deps.io.out('the answer must return to the agent without another human prompt.');
1790
- deps.io.out('');
1791
- deps.io.out('Restart OpenCode: it loads plugins once at start.');
1800
+ printHooksInstallClose(deps, 'opencode', file);
1792
1801
  return EXIT.ok;
1793
1802
  }
1794
1803
  export function hooksUninstallCommand(deps, flags) {
@@ -1845,28 +1854,34 @@ export function hooksUninstallCommand(deps, flags) {
1845
1854
  : `No Notifai hooks found in ${file}`);
1846
1855
  return EXIT.ok;
1847
1856
  }
1848
- let stripped;
1857
+ const files = hookDefinitionFiles(harness, flags.global ?? false, deps.cwd, deps.env);
1858
+ const existing = files.filter((candidate) => existsSync(candidate));
1859
+ if (existing.length === 0) {
1860
+ deps.io.out(`Nothing to remove: ${file} does not exist.`);
1861
+ return EXIT.ok;
1862
+ }
1863
+ let removedAny = false;
1849
1864
  try {
1850
- stripped = withTargetFileLock(file, () => {
1851
- if (!existsSync(file))
1852
- return null;
1853
- const document = loadSettings(file);
1854
- const result = removeHooks(document, scriptPath);
1855
- applyPlan(file, result.document);
1856
- return result;
1857
- });
1865
+ for (const candidate of existing) {
1866
+ const stripped = withTargetFileLock(candidate, () => {
1867
+ const document = loadSettings(candidate);
1868
+ const result = removeHooks(document, scriptPath);
1869
+ applyPlan(candidate, result.document);
1870
+ return result;
1871
+ });
1872
+ if (stripped.replaced.length > 0) {
1873
+ removedAny = true;
1874
+ deps.io.out(`Removed Notifai hooks (${stripped.replaced.join(', ')}) from ${candidate}`);
1875
+ }
1876
+ }
1858
1877
  }
1859
1878
  catch (err) {
1860
1879
  deps.io.err(String(err));
1861
1880
  return EXIT.failed;
1862
1881
  }
1863
- if (stripped === null) {
1864
- deps.io.out(`Nothing to remove: ${file} does not exist.`);
1865
- return EXIT.ok;
1882
+ if (!removedAny) {
1883
+ deps.io.out(`No Notifai hooks found in ${existing.join(', ')}`);
1866
1884
  }
1867
- deps.io.out(stripped.replaced.length > 0
1868
- ? `Removed Notifai hooks (${stripped.replaced.join(', ')}) from ${file}`
1869
- : `No Notifai hooks found in ${file}`);
1870
1885
  return EXIT.ok;
1871
1886
  }
1872
1887
  function assertOwnedRegularFile(file) {
@@ -1879,6 +1894,12 @@ function assertOwnedRegularFile(file) {
1879
1894
  throw new Error(`${file} is owned by uid ${stat.uid}, not the current user.`);
1880
1895
  }
1881
1896
  }
1897
+ const HARNESS_LABEL = {
1898
+ 'claude-code': 'Claude Code',
1899
+ codex: 'Codex',
1900
+ cursor: 'Cursor',
1901
+ opencode: 'OpenCode',
1902
+ };
1882
1903
  function resolveHarness(deps, requested) {
1883
1904
  if (requested !== undefined) {
1884
1905
  if (HARNESSES.includes(requested))
@@ -1886,20 +1907,61 @@ function resolveHarness(deps, requested) {
1886
1907
  deps.io.err(`Unknown harness "${requested}". Supported: ${HARNESSES.join(', ')}.`);
1887
1908
  return null;
1888
1909
  }
1889
- const detected = detectHarness(deps.cwd);
1910
+ const detected = detectHarness(deps.cwd, deps.env);
1890
1911
  if (!detected) {
1891
1912
  deps.io.err(`Could not tell which harness to install for — pass --harness <${HARNESSES.join('|')}>.`);
1892
1913
  return null;
1893
1914
  }
1894
1915
  return detected;
1895
1916
  }
1917
+ /**
1918
+ * Which harnesses to wire. An explicit `--harness` still wins as a singleton.
1919
+ * Otherwise: every detected harness, or a human picker when detection is empty
1920
+ * or names more than one.
1921
+ */
1922
+ async function pickHarnessesToInstall(deps, requested) {
1923
+ if (requested !== undefined) {
1924
+ const harness = resolveHarness(deps, requested);
1925
+ return harness === null ? null : [harness];
1926
+ }
1927
+ const detected = detectedHarnesses(deps.cwd, deps.env);
1928
+ if (detected.length === 1)
1929
+ return detected;
1930
+ if (deps.io.interactive === true && deps.io.multiselect) {
1931
+ const picked = await deps.io.multiselect('Which agent harnesses should Notifai wire here?', HARNESSES.map((name) => ({
1932
+ value: name,
1933
+ label: HARNESS_LABEL[name],
1934
+ ...(detected.includes(name) ? { hint: 'detected on this machine' } : {}),
1935
+ })), detected);
1936
+ if (picked === null)
1937
+ return null;
1938
+ const unknown = picked.filter((name) => !HARNESSES.includes(name));
1939
+ if (unknown.length > 0) {
1940
+ deps.io.err(`Unknown harness "${unknown[0]}". Supported: ${HARNESSES.join(', ')}.`);
1941
+ return null;
1942
+ }
1943
+ return picked;
1944
+ }
1945
+ if (detected.length === 0) {
1946
+ deps.io.err(`Could not tell which harness to wire. Run: notifai hooks install --harness <${HARNESSES.join('|')}>`);
1947
+ return null;
1948
+ }
1949
+ return detected;
1950
+ }
1896
1951
  // ---------------------------------------------------------------------------
1897
1952
  // config show / set / unset
1898
1953
  // ---------------------------------------------------------------------------
1899
1954
  export function configShowCommand(deps, flags) {
1900
1955
  const config = loadLoggedConfig(deps, { cwd: deps.cwd, env: deps.env });
1901
1956
  if (flags.json) {
1902
- const output = Object.fromEntries(CONFIG_KEYS.map((key) => [key, { value: config[key].value, source: config[key].source }]));
1957
+ const output = Object.fromEntries(CONFIG_KEYS.map((key) => [
1958
+ key,
1959
+ {
1960
+ value: config[key].value,
1961
+ source: config[key].source,
1962
+ summary: configInfo(key).summary,
1963
+ },
1964
+ ]));
1903
1965
  deps.io.out(JSON.stringify(output, null, 2));
1904
1966
  return EXIT.ok;
1905
1967
  }
@@ -2073,7 +2135,7 @@ async function configMutationTarget(deps, flags) {
2073
2135
  const selected = await deps.io.select('Where should this setting live?', [
2074
2136
  { value: 'global', label: 'This machine', hint: 'applies across projects' },
2075
2137
  { value: 'project', label: 'This project (shared)', hint: '.notifai/config.toml' },
2076
- { value: 'local', label: 'This project (personal)', hint: 'keep config.local.toml gitignored' },
2138
+ { value: 'local', label: 'This project (personal)', hint: 'stored on this machine, not in the repo' },
2077
2139
  ]);
2078
2140
  if (selected === null) {
2079
2141
  deps.io.err('No configuration layer selected.');
@@ -2084,7 +2146,7 @@ async function configMutationTarget(deps, flags) {
2084
2146
  const targetPath = flags.session
2085
2147
  ? sessionConfigPath(flags.session, deps.env)
2086
2148
  : layer === 'local'
2087
- ? (findProjectLocalConfigPath(deps.cwd) ?? path.join(deps.cwd, '.notifai', 'config.local.toml'))
2149
+ ? personalProjectConfigPath(deps.cwd, deps.env)
2088
2150
  : layer === 'project'
2089
2151
  ? (findProjectConfigPath(deps.cwd) ?? path.join(deps.cwd, '.notifai', 'config.toml'))
2090
2152
  : globalConfigPath(deps.env);
@@ -2491,17 +2553,15 @@ async function closeGap(deps, state, flags) {
2491
2553
  return 'closed';
2492
2554
  }
2493
2555
  if (state.id === 'hooks') {
2494
- let harness = detectHarness(deps.cwd);
2495
- if (harness === null && deps.io.interactive === true && deps.io.select) {
2496
- const picked = await deps.io.select('Which agent harness do you use here?', HARNESSES.map((name) => ({ value: name, label: name })));
2497
- if (picked !== null)
2498
- harness = picked;
2499
- }
2500
- if (harness === null) {
2501
- deps.io.err(`Could not tell which harness to wire. Run: notifai hooks install --harness <${HARNESSES.join('|')}>`);
2556
+ const harnesses = await pickHarnessesToInstall(deps);
2557
+ if (harnesses === null || harnesses.length === 0)
2502
2558
  return 'failed';
2559
+ let ok = true;
2560
+ for (const harness of harnesses) {
2561
+ if (hooksInstallCommand(deps, { harness }) !== EXIT.ok)
2562
+ ok = false;
2503
2563
  }
2504
- return hooksInstallCommand(deps, { harness }) === EXIT.ok ? 'closed' : 'failed';
2564
+ return ok ? 'closed' : 'failed';
2505
2565
  }
2506
2566
  if (state.id === 'skill') {
2507
2567
  if (deps.nativeSkills === undefined) {
@@ -2790,6 +2850,12 @@ async function runSetupProof(deps) {
2790
2850
  'Proof may still arrive — re-run `notifai init` and it will re-check this same notification.');
2791
2851
  return 'pending';
2792
2852
  }
2853
+ /** Closing a local gap cannot have changed the service, the keychain, or devices. */
2854
+ function refreshAfterClose(id) {
2855
+ return id === 'project' || id === 'hooks' || id === 'skill' || id === 'question-routing-settings'
2856
+ ? ['local']
2857
+ : undefined;
2858
+ }
2793
2859
  /** Whether an optional gap should be closed, given flags and who is watching. */
2794
2860
  function wantsOptional(deps, state, flags) {
2795
2861
  // Naming the project is init's whole reason to touch the filesystem, costs
@@ -2843,8 +2909,12 @@ export async function initCommand(deps, flags) {
2843
2909
  return EXIT.usage;
2844
2910
  }
2845
2911
  await deps.io.intro?.('Notifai setup');
2846
- const reassess = () => assessReadiness(deps, flags.skillsScope === undefined ? {} : { skillScope: flags.skillsScope });
2847
- let readiness = await reassess();
2912
+ const skillOpts = flags.skillsScope === undefined ? {} : { skillScope: flags.skillsScope };
2913
+ let readiness = await assessReadiness(deps, skillOpts);
2914
+ const reassess = (refresh) => assessReadiness(deps, {
2915
+ ...skillOpts,
2916
+ ...(refresh === undefined ? {} : { previous: readiness, refresh }),
2917
+ });
2848
2918
  let failed = false;
2849
2919
  const attempted = new Set();
2850
2920
  // Re-assess after every successful action. This is how a browser approval or
@@ -2876,7 +2946,7 @@ export async function initCommand(deps, flags) {
2876
2946
  if (result === 'failed')
2877
2947
  failed = true;
2878
2948
  if (result === 'failed' && state.status === 'optional-gap') {
2879
- readiness = await reassess();
2949
+ readiness = await reassess(refreshAfterClose(state.id));
2880
2950
  advanced = true;
2881
2951
  break;
2882
2952
  }
@@ -2884,7 +2954,7 @@ export async function initCommand(deps, flags) {
2884
2954
  stop = true;
2885
2955
  break;
2886
2956
  }
2887
- readiness = await reassess();
2957
+ readiness = await reassess(refreshAfterClose(state.id));
2888
2958
  advanced = true;
2889
2959
  break;
2890
2960
  }
@@ -2897,7 +2967,7 @@ export async function initCommand(deps, flags) {
2897
2967
  stop = true;
2898
2968
  break;
2899
2969
  }
2900
- readiness = await reassess();
2970
+ readiness = await reassess(refreshAfterClose(state.id));
2901
2971
  advanced = true;
2902
2972
  break;
2903
2973
  }
@@ -2939,34 +3009,74 @@ export async function initCommand(deps, flags) {
2939
3009
  if (stop || !advanced)
2940
3010
  break;
2941
3011
  }
2942
- readiness = await reassess();
2943
- for (const state of readiness.states.filter((s) => s.status === 'ready')) {
2944
- deps.io.out(`${state.title}: ${state.detail}`);
3012
+ await printInitClose(deps, readiness, flags);
3013
+ const blocker = firstBlocker(readiness);
3014
+ if (blocker === null)
3015
+ return failed ? EXIT.failed : EXIT.ok;
3016
+ return failed || deps.io.interactive !== true ? EXIT.failed : EXIT.ok;
3017
+ }
3018
+ function isHookSubstate(id) {
3019
+ return id === 'hooks' || id.startsWith('hooks-') || id === 'question-routing-settings';
3020
+ }
3021
+ function leftoverOptionals(readiness, flags) {
3022
+ return readiness.states.filter((state) => {
3023
+ if (state.status !== 'optional-gap')
3024
+ return false;
3025
+ if (isHookSubstate(state.id))
3026
+ return false;
3027
+ if (state.id === 'skill' && flags.skills === false)
3028
+ return false;
3029
+ return true;
3030
+ });
3031
+ }
3032
+ function printOptionalLeftovers(deps, leftovers) {
3033
+ for (const state of leftovers) {
3034
+ deps.io.out(`Optional, not set up — ${remedyLine(state)}`);
2945
3035
  }
3036
+ }
3037
+ function questionsWillRoute(readiness) {
3038
+ const hooks = readiness.states.find((state) => state.id === 'hooks');
3039
+ const settings = readiness.states.find((state) => state.id === 'question-routing-settings');
3040
+ return hooks?.status === 'ready' && settings?.status !== 'gap';
3041
+ }
3042
+ async function printInitClose(deps, readiness, flags) {
2946
3043
  const blocker = firstBlocker(readiness);
3044
+ const canSend = readiness.states.find((state) => state.id === 'devices')?.status === 'ready';
3045
+ const questions = questionsWillRoute(readiness);
3046
+ const leftovers = leftoverOptionals(readiness, flags);
3047
+ if (deps.io.interactive === true) {
3048
+ if (blocker === null) {
3049
+ const lines = [
3050
+ canSend ? 'You can send notifications.' : 'You are signed in.',
3051
+ questions
3052
+ ? 'Questions will reach your devices.'
3053
+ : 'Questions stay in the terminal until hooks are installed.',
3054
+ ];
3055
+ await deps.io.note?.(lines.join('\n'), 'Ready');
3056
+ printOptionalLeftovers(deps, leftovers);
3057
+ deps.io.out('All set. Agents in this project can notify you and ask questions.');
3058
+ await deps.io.outro?.('All set ✨');
3059
+ return;
3060
+ }
3061
+ await deps.io.note?.(`${blocker.title} — ${blocker.detail}\n${remedyLine(blocker)}`, 'Next');
3062
+ deps.io.out(`Next: ${blocker.title} — ${blocker.detail}`);
3063
+ deps.io.out(` ${remedyLine(blocker)}`);
3064
+ if (blocker.remedy?.by === 'user-elsewhere' || blocker.remedy?.by === 'user-here') {
3065
+ deps.io.out(' Then re-run `notifai init` and it will pick up from here.');
3066
+ }
3067
+ await deps.io.outro?.('One step remains (above)');
3068
+ return;
3069
+ }
2947
3070
  if (blocker === null) {
2948
- const skipped = openItems(readiness).filter((state) => !(state.id === 'skill' && flags.skills === false));
2949
- for (const state of skipped)
2950
- deps.io.out(`Optional, not set up — ${remedyLine(state)}`);
3071
+ printOptionalLeftovers(deps, leftovers);
2951
3072
  deps.io.out('All set. Agents in this project can notify you and ask questions.');
2952
- await deps.io.outro?.('All set ✨');
2953
- return failed ? EXIT.failed : EXIT.ok;
3073
+ return;
2954
3074
  }
2955
- // Exactly one. Everything else waits until this is done, because the next
2956
- // gap is frequently a consequence of this one and naming it now would send
2957
- // the reader off to fix something that is not actually wrong.
2958
- deps.io.out('');
2959
3075
  deps.io.out(`Next: ${blocker.title} — ${blocker.detail}`);
2960
3076
  deps.io.out(` ${remedyLine(blocker)}`);
2961
- // Both "do it here" and "do it elsewhere" leave setup mid-journey; re-run is
2962
- // how the rest is recovered either way.
2963
3077
  if (blocker.remedy?.by === 'user-elsewhere' || blocker.remedy?.by === 'user-here') {
2964
3078
  deps.io.out(' Then re-run `notifai init` and it will pick up from here.');
2965
3079
  }
2966
- await deps.io.outro?.('One step remains (above)');
2967
- // An agent must be able to branch on setup being blocked without parsing
2968
- // prose. A present human may deliberately leave and resume later.
2969
- return failed || deps.io.interactive !== true ? EXIT.failed : EXIT.ok;
2970
3080
  }
2971
3081
  // ---------------------------------------------------------------------------
2972
3082
  // doctor
@@ -3009,23 +3119,10 @@ async function contractCheck(client) {
3009
3119
  };
3010
3120
  }
3011
3121
  }
3012
- /**
3013
- * Read the whole setup once, in dependency order.
3014
- *
3015
- * Descent stops where a prerequisite is missing: without a credential there is
3016
- * nothing to ask the server with, and without a reachable server a contract
3017
- * mismatch is unknowable rather than absent. Those downstream states report
3018
- * `unknown`, which is the honest answer and keeps a network outage from
3019
- * looking like a broken install.
3020
- */
3021
- export async function assessReadiness(deps, options = {}) {
3022
- const config = loadLoggedConfig(deps, { cwd: deps.cwd, env: deps.env });
3023
- const states = [];
3024
- let accountClient = null;
3025
- let accountDevices = null;
3122
+ function projectReadiness(deps, config) {
3026
3123
  const configPath = path.join(deps.cwd, '.notifai', 'config.toml');
3027
3124
  const projectSlug = config.project.value;
3028
- states.push(projectSlug !== null
3125
+ return projectSlug !== null
3029
3126
  ? {
3030
3127
  id: 'project',
3031
3128
  title: 'Project identity',
@@ -3045,7 +3142,73 @@ export async function assessReadiness(deps, options = {}) {
3045
3142
  summary: 'name this project after its directory',
3046
3143
  command: 'notifai init',
3047
3144
  },
3048
- });
3145
+ };
3146
+ }
3147
+ function remoteStatesFrom(previous) {
3148
+ const pick = (id) => previous.states.find((state) => state.id === id);
3149
+ const credential = pick('credential');
3150
+ const server = pick('server');
3151
+ const contract = pick('contract');
3152
+ const auth = pick('auth');
3153
+ const devices = pick('devices');
3154
+ const proof = pick('proof');
3155
+ if (credential === undefined ||
3156
+ server === undefined ||
3157
+ contract === undefined ||
3158
+ auth === undefined ||
3159
+ devices === undefined ||
3160
+ proof === undefined) {
3161
+ return null;
3162
+ }
3163
+ return { credential, server, contract, auth, devices, proof };
3164
+ }
3165
+ function remoteInvalidatedByConfig(previous, config) {
3166
+ if (config.base_url.source === 'default')
3167
+ return false;
3168
+ const server = previous.states.find((state) => state.id === 'server');
3169
+ return server === undefined || !server.detail.includes(config.base_url.value);
3170
+ }
3171
+ /**
3172
+ * Read the whole setup once, in dependency order.
3173
+ *
3174
+ * Descent stops where a prerequisite is missing: without a credential there is
3175
+ * nothing to ask the server with, and without a reachable server a contract
3176
+ * mismatch is unknowable rather than absent. Those downstream states report
3177
+ * `unknown`, which is the honest answer and keeps a network outage from
3178
+ * looking like a broken install.
3179
+ */
3180
+ export async function assessReadiness(deps, options = {}) {
3181
+ const config = loadLoggedConfig(deps, { cwd: deps.cwd, env: deps.env });
3182
+ const previous = options.previous;
3183
+ const refresh = options.refresh;
3184
+ if (previous !== undefined && refresh !== undefined && refresh.length === 0)
3185
+ return previous;
3186
+ const reuseRemote = previous !== undefined &&
3187
+ refresh !== undefined &&
3188
+ !refresh.includes('remote') &&
3189
+ !remoteInvalidatedByConfig(previous, config);
3190
+ if (reuseRemote) {
3191
+ const reused = remoteStatesFrom(previous);
3192
+ if (reused !== null) {
3193
+ return {
3194
+ states: [
3195
+ projectReadiness(deps, config),
3196
+ reused.credential,
3197
+ reused.server,
3198
+ reused.contract,
3199
+ reused.auth,
3200
+ ...hookStates(deps),
3201
+ await skillReadiness(deps, options.skillScope),
3202
+ reused.devices,
3203
+ reused.proof,
3204
+ ],
3205
+ };
3206
+ }
3207
+ }
3208
+ const states = [];
3209
+ let accountClient = null;
3210
+ let accountDevices = null;
3211
+ states.push(projectReadiness(deps, config));
3049
3212
  const credential = deps.store.load();
3050
3213
  states.push(credential
3051
3214
  ? {
@@ -3084,10 +3247,10 @@ export async function assessReadiness(deps, options = {}) {
3084
3247
  id: 'server',
3085
3248
  title: 'Service',
3086
3249
  status: 'gap',
3087
- detail: `cannot reach ${baseUrl} (${config.base_url.source})`,
3250
+ detail: `cannot reach ${baseUrl}`,
3088
3251
  remedy: {
3089
3252
  by: 'user-here',
3090
- summary: 'check your network, or the base_url shown above',
3253
+ summary: 'check your network',
3091
3254
  command: 'notifai doctor',
3092
3255
  },
3093
3256
  });
@@ -3293,8 +3456,8 @@ async function setupProofState(deps, config, client, devices) {
3293
3456
  };
3294
3457
  }
3295
3458
  }
3296
- export async function doctorCommand(deps, flags) {
3297
- const readiness = await assessReadiness(deps);
3459
+ export async function doctorCommand(deps, flags, options = {}) {
3460
+ const readiness = options.readiness ?? (await assessReadiness(deps));
3298
3461
  const blocker = firstBlocker(readiness);
3299
3462
  const ok = blocker === null;
3300
3463
  if (flags.json) {
@@ -3511,6 +3674,20 @@ function hookChecks(deps) {
3511
3674
  .map((i) => `${i.harness} ${i.global ? 'global' : 'project'} (${i.file})`)
3512
3675
  .join(', '),
3513
3676
  });
3677
+ const wired = new Set(installations.map((installation) => installation.harness));
3678
+ const unwired = detectedHarnesses(deps.cwd, deps.env).filter((harness) => !wired.has(harness));
3679
+ if (unwired.length > 0) {
3680
+ checks.push({
3681
+ name: 'hooks (detected)',
3682
+ ok: false,
3683
+ informational: true,
3684
+ detail: `${unwired.map((harness) => HARNESS_LABEL[harness]).join(', ')} detected on this machine but not wired`,
3685
+ remedy: {
3686
+ summary: 'install hooks for every detected harness',
3687
+ command: 'notifai hooks install',
3688
+ },
3689
+ });
3690
+ }
3514
3691
  const { active, contested } = resolveActiveHarness(deps.env, deps.cwd, (deps.now ?? Date.now)());
3515
3692
  const activeInstallations = active === null
3516
3693
  ? []
@@ -3621,7 +3798,8 @@ function hookChecks(deps) {
3621
3798
  harness,
3622
3799
  installations: installations.filter((i) => i.harness === harness),
3623
3800
  }))
3624
- .filter((entry) => entry.installations.length > 1);
3801
+ .filter((entry) => entry.installations.some((installation) => installation.global) &&
3802
+ entry.installations.some((installation) => !installation.global));
3625
3803
  if (duplicated.length > 0) {
3626
3804
  checks.push({
3627
3805
  name: 'hooks (duplicates)',
@@ -3631,6 +3809,25 @@ function hookChecks(deps) {
3631
3809
  .join('; '),
3632
3810
  });
3633
3811
  }
3812
+ const representationProblems = codexRepresentationProblems(deps.cwd, deps.env);
3813
+ if (representationProblems.length > 0) {
3814
+ const collapseCommand = representationProblems.some((problem) => problem.includes('--global'))
3815
+ ? 'notifai hooks install --harness codex --global'
3816
+ : 'notifai hooks install --harness codex';
3817
+ checks.push({
3818
+ name: 'hooks (codex representation)',
3819
+ ok: false,
3820
+ detail: representationProblems.join('; '),
3821
+ ...(representationProblems.some((problem) => problem.includes('to collapse onto'))
3822
+ ? {
3823
+ remedy: {
3824
+ summary: 'collapse this Codex layer onto a single hook file',
3825
+ command: collapseCommand,
3826
+ },
3827
+ }
3828
+ : {}),
3829
+ });
3830
+ }
3634
3831
  if (active !== null && activeInstallations.length > 0) {
3635
3832
  const admissionProblems = activeQuestionRouteProblems(deps, active, installations);
3636
3833
  checks.push({
@@ -3795,15 +3992,20 @@ function codexStrayWorktreeCheck(deps) {
3795
3992
  if (layer === null)
3796
3993
  return null;
3797
3994
  const root = codexProjectRoot(deps.cwd);
3798
- const stray = path.join(path.dirname(layer), '.codex', 'hooks.json');
3799
- if (!existsSync(path.join(root, '.codex', 'hooks.json')))
3995
+ const project = inspectCodexLayer(codexLayerPaths(false, deps.cwd, deps.env));
3996
+ if (project.jsonEvents.length === 0 && project.tomlEvents.length === 0)
3800
3997
  return null;
3998
+ const strayJson = path.join(path.dirname(layer), '.codex', 'hooks.json');
3999
+ const strayToml = path.join(path.dirname(layer), '.codex', 'config.toml');
3801
4000
  const problems = [];
3802
4001
  if (!existsSync(layer)) {
3803
4002
  problems.push(`${layer} is missing, so Codex never looks for project hooks here`);
3804
4003
  }
3805
- if (existsSync(stray)) {
3806
- problems.push(`${stray} is never read — Codex reads ${root}/.codex/hooks.json instead`);
4004
+ if (existsSync(strayJson) && path.resolve(strayJson) !== path.resolve(project.paths.hooksJson)) {
4005
+ problems.push(`${strayJson} is never read — Codex reads ${project.writeTarget} instead`);
4006
+ }
4007
+ if (existsSync(strayToml) && path.resolve(strayToml) !== path.resolve(project.paths.configToml)) {
4008
+ problems.push(`${strayToml} is never read — Codex reads ${project.writeTarget} instead`);
3807
4009
  }
3808
4010
  return {
3809
4011
  name: 'hooks (codex worktree)',
@@ -3863,6 +4065,18 @@ export function realIo(env = process.env) {
3863
4065
  const answer = await p.select({ message, options });
3864
4066
  return p.isCancel(answer) ? null : answer;
3865
4067
  },
4068
+ multiselect: async (message, options, initial) => {
4069
+ if (!interactive())
4070
+ return null;
4071
+ const p = await clack();
4072
+ const answer = await p.multiselect({
4073
+ message,
4074
+ options,
4075
+ required: true,
4076
+ ...(initial !== undefined && initial.length > 0 ? { initialValues: initial } : {}),
4077
+ });
4078
+ return p.isCancel(answer) ? null : answer;
4079
+ },
3866
4080
  intro: async (title) => {
3867
4081
  if (!interactive())
3868
4082
  return;