@raidiant/notifai 1.0.1 → 2.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 (63) hide show
  1. package/CHANGELOG.md +50 -0
  2. package/README.md +17 -7
  3. package/dist/client.d.ts +5 -1
  4. package/dist/client.js +2 -0
  5. package/dist/client.js.map +1 -1
  6. package/dist/commands.d.ts +10 -1
  7. package/dist/commands.js +151 -54
  8. package/dist/commands.js.map +1 -1
  9. package/dist/config.d.ts +2 -2
  10. package/dist/config.js +5 -9
  11. package/dist/config.js.map +1 -1
  12. package/dist/credentials.d.ts +54 -3
  13. package/dist/credentials.js +237 -18
  14. package/dist/credentials.js.map +1 -1
  15. package/dist/file-lock.js +15 -6
  16. package/dist/file-lock.js.map +1 -1
  17. package/dist/hook-adapter.d.ts +6 -2
  18. package/dist/hook-adapter.js +110 -29
  19. package/dist/hook-adapter.js.map +1 -1
  20. package/dist/hooks.d.ts +15 -1
  21. package/dist/hooks.js +183 -12
  22. package/dist/hooks.js.map +1 -1
  23. package/dist/install-hooks.d.ts +50 -21
  24. package/dist/install-hooks.js +141 -57
  25. package/dist/install-hooks.js.map +1 -1
  26. package/dist/logging.d.ts +1 -1
  27. package/dist/logging.js +4 -0
  28. package/dist/logging.js.map +1 -1
  29. package/dist/main.js +14 -3
  30. package/dist/main.js.map +1 -1
  31. package/dist/native-skills.js +5 -10
  32. package/dist/native-skills.js.map +1 -1
  33. package/dist/opencode-plugin.d.ts +7 -2
  34. package/dist/opencode-plugin.js +142 -4
  35. package/dist/opencode-plugin.js.map +1 -1
  36. package/dist/platform.d.ts +73 -0
  37. package/dist/platform.js +143 -0
  38. package/dist/platform.js.map +1 -0
  39. package/dist/ui/config-view.js +5 -1
  40. package/dist/ui/config-view.js.map +1 -1
  41. package/package.json +11 -10
  42. package/src/client.test.ts +53 -0
  43. package/src/client.ts +24 -0
  44. package/src/commands.test.ts +351 -30
  45. package/src/commands.ts +207 -49
  46. package/src/config.ts +11 -9
  47. package/src/credentials.test.ts +291 -0
  48. package/src/credentials.ts +274 -17
  49. package/src/file-lock.ts +34 -9
  50. package/src/hook-adapter.test.ts +136 -0
  51. package/src/hook-adapter.ts +120 -28
  52. package/src/hooks.test.ts +175 -1
  53. package/src/hooks.ts +241 -12
  54. package/src/install-hooks.test.ts +174 -33
  55. package/src/install-hooks.ts +186 -54
  56. package/src/logging.ts +4 -0
  57. package/src/main.ts +17 -2
  58. package/src/native-skills.ts +5 -10
  59. package/src/opencode-plugin.ts +148 -3
  60. package/src/platform.test.ts +152 -0
  61. package/src/platform.ts +173 -0
  62. package/src/skill.test.ts +32 -1
  63. package/src/ui/config-view.ts +5 -5
package/src/commands.ts CHANGED
@@ -1,10 +1,10 @@
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'
6
5
  import { parse as parseToml, stringify as stringifyToml } from 'smol-toml'
7
6
  import {
7
+ AGENT_ACKNOWLEDGEMENT_MAX_LENGTH,
8
8
  CAPABILITIES_V1,
9
9
  QUESTION_TEXT_MAX_LENGTH,
10
10
  REPLY_MAX_QUESTIONS,
@@ -40,6 +40,7 @@ import {
40
40
  globalConfigPath,
41
41
  loadConfig,
42
42
  sessionConfigPath,
43
+ stateDir,
43
44
  type CliConfig,
44
45
  type ConfigKey,
45
46
  type FlagOverrides,
@@ -65,6 +66,7 @@ import {
65
66
  import type { Tone } from './ui/theme.js'
66
67
  import {
67
68
  WAITER_CEILING_SECONDS,
69
+ clearAcknowledgementObligation,
68
70
  handleSessionEnd,
69
71
  handleStop,
70
72
  handleUserPromptSubmit,
@@ -154,6 +156,7 @@ import {
154
156
  type SendFlags,
155
157
  } from './send.js'
156
158
  import type { NativeSkill, NativeSkills, SkillScope } from './native-skills.js'
159
+ import { openUrl } from './platform.js'
157
160
 
158
161
  export interface CommandIo {
159
162
  out(line: string): void
@@ -204,6 +207,8 @@ export interface CommandDeps {
204
207
  hookAdapterHome?: string
205
208
  /** Test seam; production uses this process's Node and CLI paths. */
206
209
  hookInstallTarget?: HookAdapterTarget
210
+ /** Test seam; production uses process.platform for adapter format and quoting. */
211
+ hookPlatform?: NodeJS.Platform
207
212
  /** Test seam; production uses fetch against base_url. */
208
213
  clientFactory?: (baseUrl: string, bearer: string | null, options?: ClientOptions) => ApiClient
209
214
  /** Test seam for bounded polling without wall-clock sleeps. */
@@ -682,6 +687,7 @@ export async function sendCommand(
682
687
  title: flags.title,
683
688
  overall: receipt.overall,
684
689
  replayed: receipt.replayed,
690
+ agent_acknowledgement_required: receipt.agent_acknowledgement_required,
685
691
  deliveries: receipt.deliveries.length,
686
692
  wait_seconds: waitSeconds,
687
693
  exit: receiptExit,
@@ -712,6 +718,9 @@ export async function sendCommand(
712
718
  type: 'reply_result',
713
719
  request_id: receipt.request_id,
714
720
  replies: [],
721
+ agent_acknowledgement_required: receipt.agent_acknowledgement_required,
722
+ agent_acknowledgement: null,
723
+ acknowledgement_command: null,
715
724
  degraded: false,
716
725
  })
717
726
  : JSON.stringify(receipt, null, 2),
@@ -730,15 +739,15 @@ export async function sendCommand(
730
739
  recordReplies(deps, receipt.request_id, result.response.replies)
731
740
  if (flags.json) {
732
741
  deps.io.out(
733
- JSON.stringify({
734
- type: 'reply_result',
735
- request_id: receipt.request_id,
736
- replies: result.response.replies,
737
- degraded: result.degraded,
738
- }),
742
+ JSON.stringify(replyResultJson(result.response, result.degraded)),
739
743
  )
740
- } else if (result.response.replies.length > 0) printReplies(deps, result.response.replies)
741
- else printNoReply(deps, receipt.request_id, result.response.reply_expires_at)
744
+ } else if (result.response.replies.length > 0) {
745
+ printReplies(deps, result.response.replies)
746
+ printAcknowledgementStatus(deps, result.response)
747
+ } else {
748
+ printNoReply(deps, receipt.request_id, result.response.reply_expires_at)
749
+ printAcknowledgementStatus(deps, result.response)
750
+ }
742
751
  if (result.degraded) {
743
752
  log(deps).error('cli.error', {
744
753
  kind: 'network',
@@ -890,12 +899,14 @@ export async function repliesCommand(
890
899
  })
891
900
  recordReplies(deps, requestId, result.response.replies)
892
901
  if (flags.json) {
893
- jsonBodies.push({ ...result.response, degraded: result.degraded })
902
+ jsonBodies.push(replyResultJson(result.response, result.degraded))
894
903
  } else if (result.response.replies.length > 0) {
895
904
  if (flags.pending === true) deps.io.out(`pending request ${requestId}`)
896
905
  printReplies(deps, result.response.replies)
906
+ printAcknowledgementStatus(deps, result.response)
897
907
  } else {
898
908
  printNoReply(deps, requestId, result.response.reply_expires_at)
909
+ printAcknowledgementStatus(deps, result.response)
899
910
  }
900
911
  anyReplies ||= result.response.replies.length > 0
901
912
  if (result.degraded) degradedRequestIds.push(requestId)
@@ -993,7 +1004,13 @@ export async function waitForReply(
993
1004
  return {
994
1005
  response:
995
1006
  lastResponse ??
996
- ({ request_id: requestId, reply_expires_at: null, replies: [] } satisfies ListRepliesResponse),
1007
+ ({
1008
+ request_id: requestId,
1009
+ reply_expires_at: null,
1010
+ agent_acknowledgement_required: false,
1011
+ agent_acknowledgement: null,
1012
+ replies: [],
1013
+ } satisfies ListRepliesResponse),
997
1014
  timedOut: true,
998
1015
  // A poll succeeded at some point, so we do not throw — but the last thing
999
1016
  // we know is that we could not reach the server. Reporting that as a plain
@@ -1033,6 +1050,56 @@ function isNonNegativeInteger(value: number): boolean {
1033
1050
  return Number.isInteger(value) && value >= 0
1034
1051
  }
1035
1052
 
1053
+ function acknowledgementCommand(
1054
+ requestId: string,
1055
+ required: boolean,
1056
+ acknowledgement: ListRepliesResponse['agent_acknowledgement'],
1057
+ hasReply = true,
1058
+ ): string | null {
1059
+ return required && acknowledgement === null && hasReply
1060
+ ? `notifai acknowledge ${requestId} --text <text>`
1061
+ : null
1062
+ }
1063
+
1064
+ function replyResultJson(response: ListRepliesResponse, degraded: boolean): object {
1065
+ return {
1066
+ type: 'reply_result',
1067
+ request_id: response.request_id,
1068
+ reply_expires_at: response.reply_expires_at,
1069
+ replies: response.replies,
1070
+ agent_acknowledgement_required: response.agent_acknowledgement_required,
1071
+ agent_acknowledgement: response.agent_acknowledgement,
1072
+ acknowledgement_command: acknowledgementCommand(
1073
+ response.request_id,
1074
+ response.agent_acknowledgement_required,
1075
+ response.agent_acknowledgement,
1076
+ response.replies.length > 0,
1077
+ ),
1078
+ degraded,
1079
+ }
1080
+ }
1081
+
1082
+ function printAcknowledgementStatus(deps: CommandDeps, response: ListRepliesResponse): void {
1083
+ if (!response.agent_acknowledgement_required) {
1084
+ deps.io.out('Agent Acknowledgement: not required for this request.')
1085
+ return
1086
+ }
1087
+ if (response.agent_acknowledgement !== null) {
1088
+ deps.io.out(
1089
+ `Agent Acknowledgement: recorded at ${response.agent_acknowledgement.created_at}: ${response.agent_acknowledgement.text}`,
1090
+ )
1091
+ return
1092
+ }
1093
+ if (response.replies.length > 0) {
1094
+ deps.io.out('Agent Acknowledgement required.')
1095
+ deps.io.out(
1096
+ `next: Run \`${acknowledgementCommand(response.request_id, true, null)}\` with concrete text saying what you will do because of the reply.`,
1097
+ )
1098
+ return
1099
+ }
1100
+ deps.io.out('Agent Acknowledgement: required after a user reply; no reply is recorded yet.')
1101
+ }
1102
+
1036
1103
  function recordReplies(deps: CommandDeps, requestId: string, replies: readonly ReplyView[]): void {
1037
1104
  const logger = log(deps)
1038
1105
  for (const reply of replies) {
@@ -1486,6 +1553,7 @@ function stopWakeRoute(
1486
1553
  ): EscalationDeliveryRoute | undefined {
1487
1554
  if (sessionId === undefined) return undefined
1488
1555
  if (harness === 'claude-code') {
1556
+ if ((deps.hookPlatform ?? process.platform) === 'win32') return undefined
1489
1557
  return claudeWakeRoute({
1490
1558
  sessionId,
1491
1559
  cwd,
@@ -1709,7 +1777,7 @@ export function askCommand(deps: CommandDeps, question: string | undefined, flag
1709
1777
  const { active, contested } = resolveActiveHarness(deps.env, deps.cwd, now)
1710
1778
  let sessionId: string | undefined
1711
1779
  if (active !== null) {
1712
- const installations = findInstallations(deps.cwd, deps.env, deps.hookAdapterHome)
1780
+ const installations = findInstallations(deps.cwd, deps.env, deps.hookAdapterHome, deps.hookPlatform)
1713
1781
  const activeInstalled = installations.some(
1714
1782
  (installation) => installation.harness === active.harness,
1715
1783
  )
@@ -1991,10 +2059,10 @@ function activeQuestionRouteProblems(
1991
2059
  }
1992
2060
  }
1993
2061
  }
1994
- for (const problem of inspectHookAdapter(deps.hookAdapterHome).problems) {
2062
+ for (const problem of inspectHookAdapter(deps.hookAdapterHome, deps.hookPlatform).problems) {
1995
2063
  problems.push(problem)
1996
2064
  }
1997
- for (const installation of matching) problems.push(...stopShapeProblems(installation))
2065
+ for (const installation of matching) problems.push(...stopShapeProblems(installation, deps.hookPlatform))
1998
2066
  problems.push(...codexTrustProblems(matching, deps.env))
1999
2067
  return [...new Set(problems)]
2000
2068
  }
@@ -2056,7 +2124,7 @@ function diagnoseActiveHarnessSession(
2056
2124
  * checked after a prompt before assuming that a new session is required.
2057
2125
  */
2058
2126
  function diagnoseMissingSession(deps: CommandDeps): string[] {
2059
- const installations = findInstallations(deps.cwd, deps.env, deps.hookAdapterHome)
2127
+ const installations = findInstallations(deps.cwd, deps.env, deps.hookAdapterHome, deps.hookPlatform)
2060
2128
  if (installations.length === 0) {
2061
2129
  return [
2062
2130
  'Could not tell which harness session this is: no Notifai hooks are installed for this project.',
@@ -2119,14 +2187,95 @@ function hookActivationAdvice(installations: Installation[]): string {
2119
2187
  return `${advice.join('. ')}.`
2120
2188
  }
2121
2189
 
2190
+ /** Record the one Agent Acknowledgement associated with a replied-to request. */
2191
+ export async function acknowledgeCommand(
2192
+ deps: CommandDeps,
2193
+ requestId: string,
2194
+ flags: { text?: string; json?: boolean },
2195
+ ): Promise<number> {
2196
+ const text = flags.text?.trim() ?? ''
2197
+ if (text.length === 0) {
2198
+ deps.io.err('--text is required and must contain non-whitespace text.')
2199
+ return EXIT.usage
2200
+ }
2201
+ if (text.length > AGENT_ACKNOWLEDGEMENT_MAX_LENGTH) {
2202
+ deps.io.err(
2203
+ `--text must be at most ${AGENT_ACKNOWLEDGEMENT_MAX_LENGTH} characters after trimming.`,
2204
+ )
2205
+ return EXIT.usage
2206
+ }
2207
+
2208
+ const logger = log(deps)
2209
+ const config = loadLoggedConfig(deps, { cwd: deps.cwd, env: deps.env })
2210
+ logger.info('acknowledgement.attempted', {
2211
+ request_id: requestId,
2212
+ text,
2213
+ characters: text.length,
2214
+ })
2215
+ const authed = authedClient(deps, config)
2216
+ if (!authed) return EXIT.auth
2217
+ try {
2218
+ const result = await authed.client.putAgentAcknowledgement(requestId, { text })
2219
+ const output = {
2220
+ request_id: requestId,
2221
+ outcome: result.status,
2222
+ acknowledgement: result.agent_acknowledgement,
2223
+ agent_acknowledgement_required: true,
2224
+ }
2225
+ logger.info('acknowledgement.outcome', {
2226
+ request_id: requestId,
2227
+ outcome: result.status,
2228
+ text: result.agent_acknowledgement.text,
2229
+ created_at: result.agent_acknowledgement.created_at,
2230
+ agent_acknowledgement_required: true,
2231
+ })
2232
+ const sessionId = readProjectSession(deps.cwd, deps.env, (deps.now ?? Date.now)())
2233
+ if (sessionId !== null) {
2234
+ clearAcknowledgementObligation(sessionId, deps.env, requestId)
2235
+ }
2236
+ if (flags.json) deps.io.out(JSON.stringify(output, null, 2))
2237
+ else {
2238
+ deps.io.out(
2239
+ `Agent Acknowledgement ${result.status} for ${requestId} at ${result.agent_acknowledgement.created_at}.`,
2240
+ )
2241
+ }
2242
+ return EXIT.ok
2243
+ } catch (err) {
2244
+ return reportError(deps, err, { operation: 'agent_acknowledgement', request_id: requestId })
2245
+ }
2246
+ }
2247
+
2122
2248
  /** Retire a question so a late answer is rejected rather than silently lost. */
2123
- export async function closeCommand(deps: CommandDeps, requestId: string): Promise<number> {
2249
+ export async function closeCommand(
2250
+ deps: CommandDeps,
2251
+ requestId: string,
2252
+ flags: { json?: boolean } = {},
2253
+ ): Promise<number> {
2124
2254
  const config = loadLoggedConfig(deps, { cwd: deps.cwd, env: deps.env })
2125
2255
  const authed = authedClient(deps, config)
2126
2256
  if (!authed) return EXIT.auth
2127
2257
  try {
2128
- await authed.client.closeReplies(requestId)
2129
- deps.io.out(`Closed the reply window for ${requestId}.`)
2258
+ const response = await authed.client.closeReplies(requestId)
2259
+ if (flags.json) {
2260
+ deps.io.out(
2261
+ JSON.stringify(
2262
+ {
2263
+ ...response,
2264
+ acknowledgement_command: acknowledgementCommand(
2265
+ response.request_id,
2266
+ response.agent_acknowledgement_required,
2267
+ response.agent_acknowledgement,
2268
+ response.replies.length > 0,
2269
+ ),
2270
+ },
2271
+ null,
2272
+ 2,
2273
+ ),
2274
+ )
2275
+ } else {
2276
+ deps.io.out(`Closed the reply window for ${requestId}.`)
2277
+ if (response.replies.length > 0) printAcknowledgementStatus(deps, response)
2278
+ }
2130
2279
  return EXIT.ok
2131
2280
  } catch (err) {
2132
2281
  return reportError(deps, err)
@@ -2207,15 +2356,17 @@ export function hooksInstallCommand(deps: CommandDeps, flags: HooksInstallFlags)
2207
2356
  const adapterTarget = resolveHookAdapterTarget(deps, flags)
2208
2357
  const scriptPath =
2209
2358
  flags.scriptPath ?? fileHookInstallTarget(adapterTarget)?.scriptPath ?? process.argv[1] ?? 'notifai'
2359
+ const hookPlatform = deps.hookPlatform ?? process.platform
2360
+ const nodePath = adapterTarget.execPath
2210
2361
  let adapterPath: string
2211
2362
  try {
2212
- adapterPath = installHookAdapter(adapterTarget, deps.hookAdapterHome).path
2363
+ adapterPath = installHookAdapter(adapterTarget, deps.hookAdapterHome, hookPlatform).path
2213
2364
  } catch (err) {
2214
2365
  deps.io.err(`Could not prepare the stable hook adapter: ${String(err)}`)
2215
2366
  return EXIT.failed
2216
2367
  }
2217
2368
  const wantGlobal = flags.global === true
2218
- const existing = findInstallations(deps.cwd, deps.env, deps.hookAdapterHome).filter(
2369
+ const existing = findInstallations(deps.cwd, deps.env, deps.hookAdapterHome, deps.hookPlatform).filter(
2219
2370
  (installation) => installation.harness === harness,
2220
2371
  )
2221
2372
  const otherScope = existing.filter((installation) => installation.global !== wantGlobal)
@@ -2231,7 +2382,7 @@ export function hooksInstallCommand(deps: CommandDeps, flags: HooksInstallFlags)
2231
2382
  return EXIT.failed
2232
2383
  }
2233
2384
  }
2234
- const file = settingsFile(harness, wantGlobal, deps.cwd, deps.env)
2385
+ const file = settingsFile(harness, wantGlobal, deps.cwd, deps.env, hookPlatform)
2235
2386
 
2236
2387
  // OpenCode's adapter is a generated plugin module rather than a handler
2237
2388
  // merged into a settings document, so it owns the whole file.
@@ -2239,6 +2390,8 @@ export function hooksInstallCommand(deps: CommandDeps, flags: HooksInstallFlags)
2239
2390
  return installOpencodePlugin(deps, file, {
2240
2391
  adapterPath,
2241
2392
  timeoutSeconds: BLOCKING_STOP_TIMEOUT_SECONDS,
2393
+ platform: hookPlatform,
2394
+ nodePath,
2242
2395
  })
2243
2396
  }
2244
2397
 
@@ -2251,6 +2404,8 @@ export function hooksInstallCommand(deps: CommandDeps, flags: HooksInstallFlags)
2251
2404
  buildCursorHookConfig({
2252
2405
  adapterPath,
2253
2406
  harness: 'cursor',
2407
+ platform: hookPlatform,
2408
+ nodePath,
2254
2409
  }),
2255
2410
  scriptPath,
2256
2411
  )
@@ -2276,6 +2431,8 @@ export function hooksInstallCommand(deps: CommandDeps, flags: HooksInstallFlags)
2276
2431
  buildHookConfig({
2277
2432
  adapterPath,
2278
2433
  harness,
2434
+ platform: hookPlatform,
2435
+ nodePath,
2279
2436
  }),
2280
2437
  scriptPath,
2281
2438
  )
@@ -2331,7 +2488,12 @@ function foreignStopHandlers(document: { hooks?: Record<string, { hooks?: { comm
2331
2488
  function installOpencodePlugin(
2332
2489
  deps: CommandDeps,
2333
2490
  file: string,
2334
- options: { adapterPath: string; timeoutSeconds: number },
2491
+ options: {
2492
+ adapterPath: string
2493
+ timeoutSeconds: number
2494
+ platform?: NodeJS.Platform
2495
+ nodePath?: string
2496
+ },
2335
2497
  ): number {
2336
2498
  try {
2337
2499
  withTargetFileLock(file, () => {
@@ -2360,7 +2522,13 @@ export function hooksUninstallCommand(deps: CommandDeps, flags: HooksInstallFlag
2360
2522
  const harness = resolveHarness(deps, flags.harness)
2361
2523
  if (!harness) return EXIT.usage
2362
2524
  const scriptPath = flags.scriptPath ?? process.argv[1] ?? 'notifai'
2363
- const file = settingsFile(harness, flags.global ?? false, deps.cwd, deps.env)
2525
+ const file = settingsFile(
2526
+ harness,
2527
+ flags.global ?? false,
2528
+ deps.cwd,
2529
+ deps.env,
2530
+ deps.hookPlatform,
2531
+ )
2364
2532
  if (harness === 'opencode') {
2365
2533
  try {
2366
2534
  return withTargetFileLock(file, () => {
@@ -3193,9 +3361,7 @@ function setupProofPath(deps: CommandDeps): string {
3193
3361
  // the resolved absolute path is still a stable local identity for it.
3194
3362
  }
3195
3363
  const digest = createHash('sha256').update(projectDir).digest('hex').slice(0, 32)
3196
- const xdg = deps.env['XDG_STATE_HOME']
3197
- const base = xdg && xdg !== '' ? xdg : path.join(os.homedir(), '.local', 'state')
3198
- return path.join(base, 'notifai', 'setup-proofs', `${digest}.json`)
3364
+ return path.join(stateDir(deps.env), 'setup-proofs', `${digest}.json`)
3199
3365
  }
3200
3366
 
3201
3367
  function readSetupProof(deps: CommandDeps): SetupProofRecord | null {
@@ -3579,6 +3745,9 @@ async function runSetupProof(deps: CommandDeps): Promise<GapCloseResult> {
3579
3745
  const observed = observedCompanionReceipt(snapshot, proof.device_id)
3580
3746
  if (observed) {
3581
3747
  spinner?.stop(`Receipt observed from ${observed.delivery.device_name}`)
3748
+ deps.io.out(
3749
+ `Companion Receipt (the app's delivery confirmation) observed from ${observed.delivery.device_name}.`,
3750
+ )
3582
3751
  return 'closed'
3583
3752
  }
3584
3753
  lastError = null
@@ -4399,7 +4568,7 @@ function remedyLine(state: ReadinessState): string {
4399
4568
  * is, is each check's own call (`HookCheck`).
4400
4569
  */
4401
4570
  function hookStates(deps: CommandDeps): ReadinessState[] {
4402
- const installations = findInstallations(deps.cwd, deps.env, deps.hookAdapterHome)
4571
+ const installations = findInstallations(deps.cwd, deps.env, deps.hookAdapterHome, deps.hookPlatform)
4403
4572
  const active = activeHarnessSession(deps.env, deps.cwd, (deps.now ?? Date.now)())
4404
4573
  const config = loadLoggedConfig(deps, { cwd: deps.cwd, env: deps.env })
4405
4574
  const settings: ReadinessState = {
@@ -4492,13 +4661,16 @@ function hookStates(deps: CommandDeps): ReadinessState[] {
4492
4661
  * hashes into `trusted_hash`, and an untrusted handler is simply not run.
4493
4662
  * - Everything else blocks its turn and needs a ceiling above the wait.
4494
4663
  */
4495
- function stopShapeProblems(installation: Installation): string[] {
4664
+ function stopShapeProblems(
4665
+ installation: Installation,
4666
+ platform: NodeJS.Platform = process.platform,
4667
+ ): string[] {
4496
4668
  if (installation.harness === 'codex') return []
4497
4669
  const problems: string[] = []
4498
4670
  for (const handler of installation.handlers.filter(
4499
4671
  (entry) => handlerEvent(entry.command) === 'stop',
4500
4672
  )) {
4501
- if (installation.harness === 'claude-code') {
4673
+ if (installation.harness === 'claude-code' && platform !== 'win32') {
4502
4674
  if (handler.async !== true) {
4503
4675
  problems.push(
4504
4676
  `${installation.file} declares a blocking Stop handler; the Claude Code wake route needs \`async: true\` so the turn ends while the waiter runs`,
@@ -4532,7 +4704,7 @@ interface HookCheck {
4532
4704
 
4533
4705
  function hookChecks(deps: CommandDeps): HookCheck[] {
4534
4706
  const checks: HookCheck[] = []
4535
- const installations = findInstallations(deps.cwd, deps.env, deps.hookAdapterHome)
4707
+ const installations = findInstallations(deps.cwd, deps.env, deps.hookAdapterHome, deps.hookPlatform)
4536
4708
 
4537
4709
  // Not having hooks is a setup someone chose, not a fault: `send` works
4538
4710
  // without them. A setup that cannot work is what deserves to go red.
@@ -4651,7 +4823,7 @@ function hookChecks(deps: CommandDeps): HookCheck[] {
4651
4823
  const adapterProblems = installations.flatMap((installation) =>
4652
4824
  (installation.problems ?? []).map((problem) => `${installation.file}: ${problem}`),
4653
4825
  )
4654
- const sharedAdapter = inspectHookAdapter(deps.hookAdapterHome)
4826
+ const sharedAdapter = inspectHookAdapter(deps.hookAdapterHome, deps.hookPlatform)
4655
4827
  adapterProblems.push(...sharedAdapter.problems)
4656
4828
  if (adapterProblems.length > 0) {
4657
4829
  checks.push({
@@ -4683,7 +4855,7 @@ function hookChecks(deps: CommandDeps): HookCheck[] {
4683
4855
  })
4684
4856
 
4685
4857
  const shapeProblems = installations.flatMap((installation) =>
4686
- stopShapeProblems(installation).map(
4858
+ stopShapeProblems(installation, deps.hookPlatform).map(
4687
4859
  (problem) =>
4688
4860
  `${problem} — run \`notifai hooks install --harness ${installation.harness}${installation.global ? ' --global' : ''}\``,
4689
4861
  ),
@@ -4896,7 +5068,7 @@ function wakeRouteCheck(
4896
5068
  if (active.harness === 'claude-code') {
4897
5069
  const readiness = inspectClaudeInbox({
4898
5070
  pid: deps.claudeSourcePid ?? claudeSessionPid(deps.env),
4899
- platform: process.platform,
5071
+ platform: deps.hookPlatform ?? process.platform,
4900
5072
  readDescriptor:
4901
5073
  deps.claudeWake?.readDescriptor ?? systemClaudeWakeAdapters(deps.env).readDescriptor,
4902
5074
  socketExists: (socketPath) => existsSync(socketPath),
@@ -4913,7 +5085,7 @@ function wakeRouteCheck(
4913
5085
  }
4914
5086
  if (active.harness === 'codex') {
4915
5087
  const readiness = inspectCodexResume(deps.env, {
4916
- platform: process.platform,
5088
+ platform: deps.hookPlatform ?? process.platform,
4917
5089
  directoryExists: (directory) => existsSync(directory),
4918
5090
  })
4919
5091
  return {
@@ -5069,20 +5241,6 @@ export function realIo(env: NodeJS.ProcessEnv = process.env): CommandIo {
5069
5241
  return log.error(message)
5070
5242
  }
5071
5243
  },
5072
- openUrl: (url) => {
5073
- try {
5074
- if (process.platform === 'darwin') {
5075
- spawn('open', [url], { stdio: 'ignore', detached: true }).unref()
5076
- } else if (process.platform === 'win32') {
5077
- // `start` is a cmd builtin; spawn('start', …) cannot work on Windows.
5078
- // Empty title argument keeps URLs with special characters intact.
5079
- spawn('cmd', ['/c', 'start', '', url], { stdio: 'ignore', detached: true }).unref()
5080
- } else {
5081
- spawn('xdg-open', [url], { stdio: 'ignore', detached: true }).unref()
5082
- }
5083
- } catch {
5084
- // Browser opening is best-effort; the URL is printed anyway.
5085
- }
5086
- },
5244
+ openUrl,
5087
5245
  }
5088
5246
  }
package/src/config.ts CHANGED
@@ -1,9 +1,9 @@
1
1
  import { createHash } from 'node:crypto'
2
2
  import { existsSync, readFileSync, realpathSync } from 'node:fs'
3
- import os from 'node:os'
4
3
  import path from 'node:path'
5
4
  import { parse as parseToml } from 'smol-toml'
6
5
  import type { CLI_SOUNDS, INTERRUPTION_LEVELS } from '@raidiant/notifai-protocol'
6
+ import { configHome, stateHome } from './platform.js'
7
7
 
8
8
  /**
9
9
  * Layered configuration with provenance. Most specific wins:
@@ -163,10 +163,11 @@ export function configDefaultValue(key: ConfigKey): unknown {
163
163
  return DEFAULTS[key]
164
164
  }
165
165
 
166
- export function globalConfigDir(env: NodeJS.ProcessEnv = process.env): string {
167
- const xdg = env['XDG_CONFIG_HOME']
168
- const base = xdg && xdg !== '' ? xdg : path.join(os.homedir(), '.config')
169
- return path.join(base, 'notifai')
166
+ export function globalConfigDir(
167
+ env: NodeJS.ProcessEnv = process.env,
168
+ platform: NodeJS.Platform = process.platform,
169
+ ): string {
170
+ return path.join(configHome(env, platform), 'notifai')
170
171
  }
171
172
 
172
173
  export function globalConfigPath(env: NodeJS.ProcessEnv = process.env): string {
@@ -178,10 +179,11 @@ export function globalConfigPath(env: NodeJS.ProcessEnv = process.env): string {
178
179
  * session config overrides. Separate from the config dir because none of it is
179
180
  * user-authored and none of it should ever be synced or committed.
180
181
  */
181
- export function stateDir(env: NodeJS.ProcessEnv = process.env): string {
182
- const xdg = env['XDG_STATE_HOME']
183
- const base = xdg && xdg !== '' ? xdg : path.join(os.homedir(), '.local', 'state')
184
- return path.join(base, 'notifai')
182
+ export function stateDir(
183
+ env: NodeJS.ProcessEnv = process.env,
184
+ platform: NodeJS.Platform = process.platform,
185
+ ): string {
186
+ return path.join(stateHome(env, platform), 'notifai')
185
187
  }
186
188
 
187
189
  /** Per-harness-session override file; see the ConfigSource doc comment. */