@raidiant/notifai 1.0.1 → 1.1.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 (49) hide show
  1. package/CHANGELOG.md +17 -0
  2. package/README.md +17 -7
  3. package/dist/commands.d.ts +2 -0
  4. package/dist/commands.js +31 -39
  5. package/dist/commands.js.map +1 -1
  6. package/dist/config.d.ts +2 -2
  7. package/dist/config.js +5 -9
  8. package/dist/config.js.map +1 -1
  9. package/dist/credentials.d.ts +54 -3
  10. package/dist/credentials.js +237 -18
  11. package/dist/credentials.js.map +1 -1
  12. package/dist/file-lock.js +15 -6
  13. package/dist/file-lock.js.map +1 -1
  14. package/dist/hook-adapter.d.ts +6 -2
  15. package/dist/hook-adapter.js +110 -29
  16. package/dist/hook-adapter.js.map +1 -1
  17. package/dist/hooks.js +2 -1
  18. package/dist/hooks.js.map +1 -1
  19. package/dist/install-hooks.d.ts +50 -21
  20. package/dist/install-hooks.js +141 -57
  21. package/dist/install-hooks.js.map +1 -1
  22. package/dist/native-skills.js +5 -10
  23. package/dist/native-skills.js.map +1 -1
  24. package/dist/opencode-plugin.d.ts +7 -2
  25. package/dist/opencode-plugin.js +142 -4
  26. package/dist/opencode-plugin.js.map +1 -1
  27. package/dist/platform.d.ts +73 -0
  28. package/dist/platform.js +143 -0
  29. package/dist/platform.js.map +1 -0
  30. package/dist/ui/config-view.js +5 -1
  31. package/dist/ui/config-view.js.map +1 -1
  32. package/package.json +3 -3
  33. package/src/commands.test.ts +100 -26
  34. package/src/commands.ts +49 -36
  35. package/src/config.ts +11 -9
  36. package/src/credentials.test.ts +291 -0
  37. package/src/credentials.ts +274 -17
  38. package/src/file-lock.ts +34 -9
  39. package/src/hook-adapter.test.ts +136 -0
  40. package/src/hook-adapter.ts +120 -28
  41. package/src/hooks.test.ts +27 -1
  42. package/src/hooks.ts +2 -1
  43. package/src/install-hooks.test.ts +174 -33
  44. package/src/install-hooks.ts +186 -54
  45. package/src/native-skills.ts +5 -10
  46. package/src/opencode-plugin.ts +148 -3
  47. package/src/platform.test.ts +152 -0
  48. package/src/platform.ts +173 -0
  49. package/src/ui/config-view.ts +5 -5
package/CHANGELOG.md CHANGED
@@ -8,6 +8,23 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
8
8
  This project follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html)
9
9
  and [Conventional Commits](https://www.conventionalcommits.org/en/v1.0.0/).
10
10
 
11
+ ## [1.1.0](https://github.com/Raidiant-io/notifai/compare/v1.0.1...v1.1.0) (2026-08-13)
12
+
13
+
14
+ ### Features
15
+
16
+ * **cli:** support Windows and Linux harness workflows ([779ab6b](https://github.com/Raidiant-io/notifai/commit/779ab6b075900a7a431b809ddcb715fd80836688))
17
+
18
+
19
+ ### Bug Fixes
20
+
21
+ * **cli:** cover Windows terminal behavior ([cda7abd](https://github.com/Raidiant-io/notifai/commit/cda7abd134744b2cac44204dd3eb1b20b9038edc))
22
+ * **cli:** isolate Codex hooks and reply routing ([#1](https://github.com/Raidiant-io/notifai/issues/1)) ([d060b6a](https://github.com/Raidiant-io/notifai/commit/d060b6a18b5937d82f74619a6c1bc711243be26c))
23
+ * **cli:** keep Codex hook install on ~/.codex, ignore CODEX_HOME ([3f75f13](https://github.com/Raidiant-io/notifai/commit/3f75f131c24534d3813a336bb72422f78e24a972))
24
+ * **cli:** preserve Codex hook trust on reinstall ([#3](https://github.com/Raidiant-io/notifai/issues/3)) ([5e2c2f4](https://github.com/Raidiant-io/notifai/commit/5e2c2f4d618276573f810a989f41a89ab1053564))
25
+ * **cli:** report observed setup receipts ([99d04bb](https://github.com/Raidiant-io/notifai/commit/99d04bbd487681db3023468fbd88b77a1ba13ceb))
26
+ * **cli:** stabilize Linux portability checks ([1462693](https://github.com/Raidiant-io/notifai/commit/146269306ae3dfc91f3559cce802460d3e058925))
27
+
11
28
  ## [Unreleased]
12
29
 
13
30
  ## [1.0.1] - 2026-08-13
package/README.md CHANGED
@@ -56,6 +56,10 @@ For a harness that cannot resume an idle agent turn, `notifai send
56
56
 
57
57
  ## Agent harnesses
58
58
 
59
+ The CLI and managed hook adapters support macOS, Linux, and Windows. Receiving
60
+ still requires the current iPhone or macOS Companion App; native Windows and
61
+ Linux Companion Apps are not shipped yet.
62
+
59
63
  ```sh
60
64
  notifai hooks install
61
65
  ```
@@ -67,13 +71,19 @@ or add one it did not see. This is what lets an agent's question reach
67
71
  your phone without the agent having to cooperate — no question detection,
68
72
  no state in a context window that compaction will eat.
69
73
 
70
- Every harness definition calls one stable user-level adapter at
71
- `~/.notifai/bin/hook-adapter`. Node, package manager, CLI version, checkout,
72
- XDG directories, and notification preferences are resolved behind it, so
73
- upgrades and configuration changes do not rewrite trusted hook definitions.
74
- The first migration may require one Codex `/hooks` approval; later repairs keep
75
- the same identity. Codex's Stop definition omits its timeout so the host default
76
- remains authoritative; prompt-submit and session-end retain fixed short limits.
74
+ Every harness definition calls one stable user-level adapter under the account
75
+ home (`~/.notifai/bin/hook-adapter` on macOS/Linux and the corresponding user
76
+ profile path on Windows). Node, package manager, CLI version, checkout,
77
+ configuration directories, and notification preferences are resolved behind
78
+ it, so upgrades and configuration changes do not rewrite trusted hook
79
+ definitions. Windows invokes its JavaScript adapter through the registered Node
80
+ executable; macOS/Linux retain the POSIX adapter and its stable command bytes.
81
+ Claude Code Stop returns immediately into the live inbox route on macOS/Linux;
82
+ on Windows, where upstream exposes no inbox socket, Stop stays open and returns
83
+ the accepted answer through the harness continuation. The first migration may
84
+ require one Codex `/hooks` approval; later repairs keep the same identity.
85
+ Codex's Stop definition omits its timeout so the host default remains
86
+ authoritative; prompt-submit and session-end retain fixed short limits.
77
87
 
78
88
  `notifai doctor` compares Codex trust on a best-effort basis against Codex's
79
89
  current persisted representation. Notifai never writes that trust store;
@@ -61,6 +61,8 @@ export interface CommandDeps {
61
61
  hookAdapterHome?: string;
62
62
  /** Test seam; production uses this process's Node and CLI paths. */
63
63
  hookInstallTarget?: HookAdapterTarget;
64
+ /** Test seam; production uses process.platform for adapter format and quoting. */
65
+ hookPlatform?: NodeJS.Platform;
64
66
  /** Test seam; production uses fetch against base_url. */
65
67
  clientFactory?: (baseUrl: string, bearer: string | null, options?: ClientOptions) => ApiClient;
66
68
  /** Test seam for bounded polling without wall-clock sleeps. */
package/dist/commands.js CHANGED
@@ -1,5 +1,4 @@
1
1
  import { createHash, randomBytes } from 'node:crypto';
2
- import { spawn } from 'node:child_process';
3
2
  import { existsSync, lstatSync, mkdirSync, readFileSync, realpathSync, rmSync, unlinkSync, writeFileSync } from 'node:fs';
4
3
  import os from 'node:os';
5
4
  import path from 'node:path';
@@ -7,7 +6,7 @@ import { parse as parseToml, stringify as stringifyToml } from 'smol-toml';
7
6
  import { CAPABILITIES_V1, QUESTION_TEXT_MAX_LENGTH, REPLY_MAX_QUESTIONS, REPLY_MAX_WINDOW_SECONDS, validateDraft, } from '@raidiant/notifai-protocol';
8
7
  import { sha256Hex } from '@raidiant/notifai-protocol/node';
9
8
  import { ApiCallError, NetworkError, createClient, isRetryableReplyPollError, } from './client.js';
10
- import { BOOLEAN_CONFIG_KEYS, CONFIG_KEYS, LOG_LEVELS, NUMERIC_CONFIG_KEYS, configBounds, configDefaultValue, findProjectConfigPath, personalProjectConfigPath, globalConfigPath, loadConfig, sessionConfigPath, } from './config.js';
9
+ import { BOOLEAN_CONFIG_KEYS, CONFIG_KEYS, LOG_LEVELS, NUMERIC_CONFIG_KEYS, configBounds, configDefaultValue, findProjectConfigPath, personalProjectConfigPath, globalConfigPath, loadConfig, sessionConfigPath, stateDir, } from './config.js';
11
10
  import { acceptedValues, configInfo } from './config-schema.js';
12
11
  import { packageVersion, skillsSource } from './release.js';
13
12
  import { atomicWriteFileSync } from './atomic-file.js';
@@ -23,6 +22,7 @@ import { codexWakeRoute, inspectCodexResume, } from './codex-wake.js';
23
22
  import { inspectHookAdapter, installHookAdapter, isNpxAdapterTarget, } from './hook-adapter.js';
24
23
  import { isOurOpencodePlugin, opencodePluginSource, } from './opencode-plugin.js';
25
24
  import { CHOICE_USAGE, buildDraft, formatReceipt, parseChoices, receiptExitCode, slugify, } from './send.js';
25
+ import { openUrl } from './platform.js';
26
26
  /** The invocation's logger, or one that discards everything. */
27
27
  export function log(deps) {
28
28
  return deps.logger ?? nullLogger();
@@ -1128,6 +1128,8 @@ function stopWakeRoute(deps, harness, sessionId, cwd) {
1128
1128
  if (sessionId === undefined)
1129
1129
  return undefined;
1130
1130
  if (harness === 'claude-code') {
1131
+ if ((deps.hookPlatform ?? process.platform) === 'win32')
1132
+ return undefined;
1131
1133
  return claudeWakeRoute({
1132
1134
  sessionId,
1133
1135
  cwd,
@@ -1316,7 +1318,7 @@ export function askCommand(deps, question, flags) {
1316
1318
  const { active, contested } = resolveActiveHarness(deps.env, deps.cwd, now);
1317
1319
  let sessionId;
1318
1320
  if (active !== null) {
1319
- const installations = findInstallations(deps.cwd, deps.env, deps.hookAdapterHome);
1321
+ const installations = findInstallations(deps.cwd, deps.env, deps.hookAdapterHome, deps.hookPlatform);
1320
1322
  const activeInstalled = installations.some((installation) => installation.harness === active.harness);
1321
1323
  if (!activeInstalled) {
1322
1324
  for (const line of diagnoseActiveHarnessSession(active, 'not-installed', installations, contested)) {
@@ -1518,11 +1520,11 @@ function activeQuestionRouteProblems(deps, active, installations) {
1518
1520
  }
1519
1521
  }
1520
1522
  }
1521
- for (const problem of inspectHookAdapter(deps.hookAdapterHome).problems) {
1523
+ for (const problem of inspectHookAdapter(deps.hookAdapterHome, deps.hookPlatform).problems) {
1522
1524
  problems.push(problem);
1523
1525
  }
1524
1526
  for (const installation of matching)
1525
- problems.push(...stopShapeProblems(installation));
1527
+ problems.push(...stopShapeProblems(installation, deps.hookPlatform));
1526
1528
  problems.push(...codexTrustProblems(matching, deps.env));
1527
1529
  return [...new Set(problems)];
1528
1530
  }
@@ -1574,7 +1576,7 @@ function diagnoseActiveHarnessSession(active, problem, installations, contested
1574
1576
  * checked after a prompt before assuming that a new session is required.
1575
1577
  */
1576
1578
  function diagnoseMissingSession(deps) {
1577
- const installations = findInstallations(deps.cwd, deps.env, deps.hookAdapterHome);
1579
+ const installations = findInstallations(deps.cwd, deps.env, deps.hookAdapterHome, deps.hookPlatform);
1578
1580
  if (installations.length === 0) {
1579
1581
  return [
1580
1582
  'Could not tell which harness session this is: no Notifai hooks are installed for this project.',
@@ -1687,16 +1689,18 @@ export function hooksInstallCommand(deps, flags) {
1687
1689
  return EXIT.usage;
1688
1690
  const adapterTarget = resolveHookAdapterTarget(deps, flags);
1689
1691
  const scriptPath = flags.scriptPath ?? fileHookInstallTarget(adapterTarget)?.scriptPath ?? process.argv[1] ?? 'notifai';
1692
+ const hookPlatform = deps.hookPlatform ?? process.platform;
1693
+ const nodePath = adapterTarget.execPath;
1690
1694
  let adapterPath;
1691
1695
  try {
1692
- adapterPath = installHookAdapter(adapterTarget, deps.hookAdapterHome).path;
1696
+ adapterPath = installHookAdapter(adapterTarget, deps.hookAdapterHome, hookPlatform).path;
1693
1697
  }
1694
1698
  catch (err) {
1695
1699
  deps.io.err(`Could not prepare the stable hook adapter: ${String(err)}`);
1696
1700
  return EXIT.failed;
1697
1701
  }
1698
1702
  const wantGlobal = flags.global === true;
1699
- const existing = findInstallations(deps.cwd, deps.env, deps.hookAdapterHome).filter((installation) => installation.harness === harness);
1703
+ const existing = findInstallations(deps.cwd, deps.env, deps.hookAdapterHome, deps.hookPlatform).filter((installation) => installation.harness === harness);
1700
1704
  const otherScope = existing.filter((installation) => installation.global !== wantGlobal);
1701
1705
  if (!wantGlobal && otherScope.some((installation) => installation.global)) {
1702
1706
  const globalFile = otherScope.find((installation) => installation.global)?.file;
@@ -1708,13 +1712,15 @@ export function hooksInstallCommand(deps, flags) {
1708
1712
  return EXIT.failed;
1709
1713
  }
1710
1714
  }
1711
- const file = settingsFile(harness, wantGlobal, deps.cwd, deps.env);
1715
+ const file = settingsFile(harness, wantGlobal, deps.cwd, deps.env, hookPlatform);
1712
1716
  // OpenCode's adapter is a generated plugin module rather than a handler
1713
1717
  // merged into a settings document, so it owns the whole file.
1714
1718
  if (harness === 'opencode') {
1715
1719
  return installOpencodePlugin(deps, file, {
1716
1720
  adapterPath,
1717
1721
  timeoutSeconds: BLOCKING_STOP_TIMEOUT_SECONDS,
1722
+ platform: hookPlatform,
1723
+ nodePath,
1718
1724
  });
1719
1725
  }
1720
1726
  if (harness === 'cursor') {
@@ -1724,6 +1730,8 @@ export function hooksInstallCommand(deps, flags) {
1724
1730
  const result = mergeCursorHooks(document, buildCursorHookConfig({
1725
1731
  adapterPath,
1726
1732
  harness: 'cursor',
1733
+ platform: hookPlatform,
1734
+ nodePath,
1727
1735
  }), scriptPath);
1728
1736
  applyPlan(file, result.document);
1729
1737
  return result;
@@ -1745,6 +1753,8 @@ export function hooksInstallCommand(deps, flags) {
1745
1753
  const result = mergeHooks(document, buildHookConfig({
1746
1754
  adapterPath,
1747
1755
  harness,
1756
+ platform: hookPlatform,
1757
+ nodePath,
1748
1758
  }), scriptPath);
1749
1759
  applyPlan(file, result.document);
1750
1760
  return result;
@@ -1818,7 +1828,7 @@ export function hooksUninstallCommand(deps, flags) {
1818
1828
  if (!harness)
1819
1829
  return EXIT.usage;
1820
1830
  const scriptPath = flags.scriptPath ?? process.argv[1] ?? 'notifai';
1821
- const file = settingsFile(harness, flags.global ?? false, deps.cwd, deps.env);
1831
+ const file = settingsFile(harness, flags.global ?? false, deps.cwd, deps.env, deps.hookPlatform);
1822
1832
  if (harness === 'opencode') {
1823
1833
  try {
1824
1834
  return withTargetFileLock(file, () => {
@@ -2515,9 +2525,7 @@ function setupProofPath(deps) {
2515
2525
  // the resolved absolute path is still a stable local identity for it.
2516
2526
  }
2517
2527
  const digest = createHash('sha256').update(projectDir).digest('hex').slice(0, 32);
2518
- const xdg = deps.env['XDG_STATE_HOME'];
2519
- const base = xdg && xdg !== '' ? xdg : path.join(os.homedir(), '.local', 'state');
2520
- return path.join(base, 'notifai', 'setup-proofs', `${digest}.json`);
2528
+ return path.join(stateDir(deps.env), 'setup-proofs', `${digest}.json`);
2521
2529
  }
2522
2530
  function readSetupProof(deps) {
2523
2531
  const file = setupProofPath(deps);
@@ -2816,6 +2824,7 @@ async function runSetupProof(deps) {
2816
2824
  const observed = observedCompanionReceipt(snapshot, proof.device_id);
2817
2825
  if (observed) {
2818
2826
  spinner?.stop(`Receipt observed from ${observed.delivery.device_name}`);
2827
+ deps.io.out(`Companion Receipt (the app's delivery confirmation) observed from ${observed.delivery.device_name}.`);
2819
2828
  return 'closed';
2820
2829
  }
2821
2830
  lastError = null;
@@ -3559,7 +3568,7 @@ function remedyLine(state) {
3559
3568
  * is, is each check's own call (`HookCheck`).
3560
3569
  */
3561
3570
  function hookStates(deps) {
3562
- const installations = findInstallations(deps.cwd, deps.env, deps.hookAdapterHome);
3571
+ const installations = findInstallations(deps.cwd, deps.env, deps.hookAdapterHome, deps.hookPlatform);
3563
3572
  const active = activeHarnessSession(deps.env, deps.cwd, (deps.now ?? Date.now)());
3564
3573
  const config = loadLoggedConfig(deps, { cwd: deps.cwd, env: deps.env });
3565
3574
  const settings = {
@@ -3647,12 +3656,12 @@ function hookStates(deps) {
3647
3656
  * hashes into `trusted_hash`, and an untrusted handler is simply not run.
3648
3657
  * - Everything else blocks its turn and needs a ceiling above the wait.
3649
3658
  */
3650
- function stopShapeProblems(installation) {
3659
+ function stopShapeProblems(installation, platform = process.platform) {
3651
3660
  if (installation.harness === 'codex')
3652
3661
  return [];
3653
3662
  const problems = [];
3654
3663
  for (const handler of installation.handlers.filter((entry) => handlerEvent(entry.command) === 'stop')) {
3655
- if (installation.harness === 'claude-code') {
3664
+ if (installation.harness === 'claude-code' && platform !== 'win32') {
3656
3665
  if (handler.async !== true) {
3657
3666
  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`);
3658
3667
  }
@@ -3669,7 +3678,7 @@ function stopShapeProblems(installation) {
3669
3678
  }
3670
3679
  function hookChecks(deps) {
3671
3680
  const checks = [];
3672
- const installations = findInstallations(deps.cwd, deps.env, deps.hookAdapterHome);
3681
+ const installations = findInstallations(deps.cwd, deps.env, deps.hookAdapterHome, deps.hookPlatform);
3673
3682
  // Not having hooks is a setup someone chose, not a fault: `send` works
3674
3683
  // without them. A setup that cannot work is what deserves to go red.
3675
3684
  if (installations.length === 0) {
@@ -3766,7 +3775,7 @@ function hookChecks(deps) {
3766
3775
  : `${stale.join('; ')} — rerun \`notifai hooks install\` to drop ${stale.length === 1 ? 'it' : 'them'}`,
3767
3776
  });
3768
3777
  const adapterProblems = installations.flatMap((installation) => (installation.problems ?? []).map((problem) => `${installation.file}: ${problem}`));
3769
- const sharedAdapter = inspectHookAdapter(deps.hookAdapterHome);
3778
+ const sharedAdapter = inspectHookAdapter(deps.hookAdapterHome, deps.hookPlatform);
3770
3779
  adapterProblems.push(...sharedAdapter.problems);
3771
3780
  if (adapterProblems.length > 0) {
3772
3781
  checks.push({
@@ -3794,7 +3803,7 @@ function hookChecks(deps) {
3794
3803
  },
3795
3804
  }),
3796
3805
  });
3797
- const shapeProblems = installations.flatMap((installation) => stopShapeProblems(installation).map((problem) => `${problem} — run \`notifai hooks install --harness ${installation.harness}${installation.global ? ' --global' : ''}\``));
3806
+ const shapeProblems = installations.flatMap((installation) => stopShapeProblems(installation, deps.hookPlatform).map((problem) => `${problem} — run \`notifai hooks install --harness ${installation.harness}${installation.global ? ' --global' : ''}\``));
3798
3807
  checks.push({
3799
3808
  name: 'hooks (stop shape)',
3800
3809
  ok: shapeProblems.length === 0,
@@ -3963,7 +3972,7 @@ function wakeRouteCheck(deps, active, activeInstallations) {
3963
3972
  if (active.harness === 'claude-code') {
3964
3973
  const readiness = inspectClaudeInbox({
3965
3974
  pid: deps.claudeSourcePid ?? claudeSessionPid(deps.env),
3966
- platform: process.platform,
3975
+ platform: deps.hookPlatform ?? process.platform,
3967
3976
  readDescriptor: deps.claudeWake?.readDescriptor ?? systemClaudeWakeAdapters(deps.env).readDescriptor,
3968
3977
  socketExists: (socketPath) => existsSync(socketPath),
3969
3978
  });
@@ -3978,7 +3987,7 @@ function wakeRouteCheck(deps, active, activeInstallations) {
3978
3987
  }
3979
3988
  if (active.harness === 'codex') {
3980
3989
  const readiness = inspectCodexResume(deps.env, {
3981
- platform: process.platform,
3990
+ platform: deps.hookPlatform ?? process.platform,
3982
3991
  directoryExists: (directory) => existsSync(directory),
3983
3992
  });
3984
3993
  return {
@@ -4133,24 +4142,7 @@ export function realIo(env = process.env) {
4133
4142
  return log.error(message);
4134
4143
  }
4135
4144
  },
4136
- openUrl: (url) => {
4137
- try {
4138
- if (process.platform === 'darwin') {
4139
- spawn('open', [url], { stdio: 'ignore', detached: true }).unref();
4140
- }
4141
- else if (process.platform === 'win32') {
4142
- // `start` is a cmd builtin; spawn('start', …) cannot work on Windows.
4143
- // Empty title argument keeps URLs with special characters intact.
4144
- spawn('cmd', ['/c', 'start', '', url], { stdio: 'ignore', detached: true }).unref();
4145
- }
4146
- else {
4147
- spawn('xdg-open', [url], { stdio: 'ignore', detached: true }).unref();
4148
- }
4149
- }
4150
- catch {
4151
- // Browser opening is best-effort; the URL is printed anyway.
4152
- }
4153
- },
4145
+ openUrl,
4154
4146
  };
4155
4147
  }
4156
4148
  //# sourceMappingURL=commands.js.map