@raidiant/notifai 10.1.0 → 10.1.2

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 (204) hide show
  1. package/CHANGELOG.md +37 -0
  2. package/README.md +3 -0
  3. package/dist/cli-bin.d.ts +21 -1
  4. package/dist/cli-bin.js +141 -10
  5. package/dist/cli-bin.js.map +1 -1
  6. package/dist/cli-update-contract.d.ts +7 -0
  7. package/dist/cli-update-contract.js +10 -0
  8. package/dist/cli-update-contract.js.map +1 -0
  9. package/dist/client.d.ts +6 -1
  10. package/dist/client.js +1 -0
  11. package/dist/client.js.map +1 -1
  12. package/dist/codex-wake.d.ts +2 -0
  13. package/dist/codex-wake.js +2 -1
  14. package/dist/codex-wake.js.map +1 -1
  15. package/dist/commands-auth.d.ts +15 -1
  16. package/dist/commands-auth.js +29 -11
  17. package/dist/commands-auth.js.map +1 -1
  18. package/dist/commands-core.d.ts +9 -2
  19. package/dist/commands-core.js +11 -4
  20. package/dist/commands-core.js.map +1 -1
  21. package/dist/commands-devices.d.ts +10 -1
  22. package/dist/commands-devices.js +17 -13
  23. package/dist/commands-devices.js.map +1 -1
  24. package/dist/commands-doctor.js +93 -49
  25. package/dist/commands-doctor.js.map +1 -1
  26. package/dist/commands-hooks.js +47 -10
  27. package/dist/commands-hooks.js.map +1 -1
  28. package/dist/commands-init.d.ts +16 -0
  29. package/dist/commands-init.js +254 -93
  30. package/dist/commands-init.js.map +1 -1
  31. package/dist/commands-send-support.js +3 -0
  32. package/dist/commands-send-support.js.map +1 -1
  33. package/dist/commands-send.d.ts +1 -1
  34. package/dist/commands-send.js +121 -35
  35. package/dist/commands-send.js.map +1 -1
  36. package/dist/commands-skill.js +72 -54
  37. package/dist/commands-skill.js.map +1 -1
  38. package/dist/commands-update.d.ts +10 -0
  39. package/dist/commands-update.js +141 -0
  40. package/dist/commands-update.js.map +1 -0
  41. package/dist/commands.d.ts +2 -1
  42. package/dist/commands.js +2 -1
  43. package/dist/commands.js.map +1 -1
  44. package/dist/hook-adapter.d.ts +2 -0
  45. package/dist/hook-adapter.js +12 -1
  46. package/dist/hook-adapter.js.map +1 -1
  47. package/dist/hooks.d.ts +43 -2
  48. package/dist/hooks.js +163 -21
  49. package/dist/hooks.js.map +1 -1
  50. package/dist/invocation-context.d.ts +1 -0
  51. package/dist/invocation-context.js +9 -1
  52. package/dist/invocation-context.js.map +1 -1
  53. package/dist/main-run.d.ts +1 -0
  54. package/dist/main-run.js +42 -0
  55. package/dist/main-run.js.map +1 -0
  56. package/dist/main.js +10 -31
  57. package/dist/main.js.map +1 -1
  58. package/dist/native-skills.d.ts +13 -3
  59. package/dist/native-skills.js +51 -12
  60. package/dist/native-skills.js.map +1 -1
  61. package/dist/node-floor.d.ts +19 -0
  62. package/dist/node-floor.js +39 -0
  63. package/dist/node-floor.js.map +1 -0
  64. package/dist/orca-session-title.d.ts +5 -4
  65. package/dist/orca-session-title.js +32 -19
  66. package/dist/orca-session-title.js.map +1 -1
  67. package/dist/platform.d.ts +11 -17
  68. package/dist/platform.js +20 -23
  69. package/dist/platform.js.map +1 -1
  70. package/dist/program.d.ts +2 -1
  71. package/dist/program.js +22 -5
  72. package/dist/program.js.map +1 -1
  73. package/dist/question-settlement-process.d.ts +15 -0
  74. package/dist/question-settlement-process.js +35 -0
  75. package/dist/question-settlement-process.js.map +1 -0
  76. package/dist/readiness.d.ts +25 -1
  77. package/dist/readiness.js +32 -2
  78. package/dist/readiness.js.map +1 -1
  79. package/dist/release.d.ts +5 -5
  80. package/dist/release.js +5 -5
  81. package/dist/setup-destinations.d.ts +23 -0
  82. package/dist/setup-destinations.js +44 -0
  83. package/dist/setup-destinations.js.map +1 -0
  84. package/dist/skill-integrity.d.ts +49 -0
  85. package/dist/skill-integrity.js +154 -0
  86. package/dist/skill-integrity.js.map +1 -0
  87. package/dist/skill-source/manifest.json +16 -0
  88. package/dist/skill-source/notifai/SKILL.md +390 -0
  89. package/dist/skill-source/notifai/references/harness-setup.md +220 -0
  90. package/package.json +11 -14
  91. package/src/atomic-file.ts +0 -198
  92. package/src/claude-wake.test.ts +0 -321
  93. package/src/claude-wake.ts +0 -499
  94. package/src/cli-bin.test.ts +0 -38
  95. package/src/cli-bin.ts +0 -67
  96. package/src/cli-release.test.ts +0 -46
  97. package/src/cli-release.ts +0 -78
  98. package/src/client.test.ts +0 -390
  99. package/src/client.ts +0 -345
  100. package/src/codex-wake.test.ts +0 -254
  101. package/src/codex-wake.ts +0 -297
  102. package/src/command-session.ts +0 -38
  103. package/src/commands-agent-sessions.ts +0 -72
  104. package/src/commands-auth.ts +0 -205
  105. package/src/commands-config.ts +0 -336
  106. package/src/commands-core.ts +0 -298
  107. package/src/commands-devices.test.ts +0 -15
  108. package/src/commands-devices.ts +0 -173
  109. package/src/commands-doctor.ts +0 -1440
  110. package/src/commands-guidance.ts +0 -181
  111. package/src/commands-harness-context.ts +0 -207
  112. package/src/commands-hook-shape.ts +0 -69
  113. package/src/commands-hooks.ts +0 -2268
  114. package/src/commands-init.ts +0 -878
  115. package/src/commands-io.ts +0 -108
  116. package/src/commands-logs.ts +0 -243
  117. package/src/commands-project.ts +0 -47
  118. package/src/commands-send-support.test.ts +0 -156
  119. package/src/commands-send-support.ts +0 -311
  120. package/src/commands-send.ts +0 -769
  121. package/src/commands-setup-proof.test.ts +0 -72
  122. package/src/commands-setup-proof.ts +0 -124
  123. package/src/commands-skill.test.ts +0 -44
  124. package/src/commands-skill.ts +0 -204
  125. package/src/commands-sounds.test.ts +0 -106
  126. package/src/commands-sounds.ts +0 -49
  127. package/src/commands.test.ts +0 -7828
  128. package/src/commands.ts +0 -75
  129. package/src/config-schema.test.ts +0 -153
  130. package/src/config-schema.ts +0 -349
  131. package/src/config.test.ts +0 -581
  132. package/src/config.ts +0 -475
  133. package/src/credentials.test.ts +0 -403
  134. package/src/credentials.ts +0 -403
  135. package/src/file-lock-process-worker.test.ts +0 -64
  136. package/src/file-lock.test.ts +0 -320
  137. package/src/file-lock.ts +0 -393
  138. package/src/fixtures/hermes-0.20.6-classic-cli-local.json +0 -50
  139. package/src/fixtures/openclaw-2026.7.1-2-agent-local.json +0 -57
  140. package/src/generated-session-label.ts +0 -155
  141. package/src/guidance-content.ts +0 -278
  142. package/src/guidance-render.ts +0 -56
  143. package/src/guidance.test.ts +0 -327
  144. package/src/guidance.ts +0 -233
  145. package/src/harness-integration.test.ts +0 -294
  146. package/src/harnesses.test.ts +0 -149
  147. package/src/harnesses.ts +0 -164
  148. package/src/hook-adapter.test.ts +0 -496
  149. package/src/hook-adapter.ts +0 -410
  150. package/src/hook-input.test.ts +0 -39
  151. package/src/hook-input.ts +0 -72
  152. package/src/hooks.test.ts +0 -5549
  153. package/src/hooks.ts +0 -3759
  154. package/src/install-hooks.test.ts +0 -1614
  155. package/src/install-hooks.ts +0 -1501
  156. package/src/interactive.test.ts +0 -387
  157. package/src/interactive.ts +0 -858
  158. package/src/invocation-context.test.ts +0 -352
  159. package/src/invocation-context.ts +0 -197
  160. package/src/logging-process-worker.test.ts +0 -45
  161. package/src/logging.test.ts +0 -544
  162. package/src/logging.ts +0 -698
  163. package/src/main.ts +0 -36
  164. package/src/native-skills.test.ts +0 -115
  165. package/src/native-skills.ts +0 -162
  166. package/src/onboarding-docs.test.ts +0 -25
  167. package/src/openclaw-plugin.ts +0 -409
  168. package/src/opencode-plugin.ts +0 -298
  169. package/src/orca-session-title.test.ts +0 -54
  170. package/src/orca-session-title.ts +0 -103
  171. package/src/pack-feedback-slice.test.ts +0 -212
  172. package/src/pack-feedback-slice.ts +0 -187
  173. package/src/platform.test.ts +0 -152
  174. package/src/platform.ts +0 -173
  175. package/src/program.test.ts +0 -282
  176. package/src/program.ts +0 -818
  177. package/src/project-enablement.test.ts +0 -55
  178. package/src/project-enablement.ts +0 -99
  179. package/src/question-caps.test.ts +0 -49
  180. package/src/question-timing.ts +0 -56
  181. package/src/readiness.test.ts +0 -25
  182. package/src/readiness.ts +0 -179
  183. package/src/release.ts +0 -51
  184. package/src/send-attempts.test.ts +0 -143
  185. package/src/send-attempts.ts +0 -188
  186. package/src/send.test.ts +0 -28
  187. package/src/send.ts +0 -436
  188. package/src/session-activation.ts +0 -61
  189. package/src/session-end-log-lock-worker.test.ts +0 -35
  190. package/src/session-labels.test.ts +0 -490
  191. package/src/session-labels.ts +0 -474
  192. package/src/skill.test.ts +0 -415
  193. package/src/sound-ref.test.ts +0 -31
  194. package/src/sound-ref.ts +0 -34
  195. package/src/trust-policy.test.ts +0 -701
  196. package/src/ui/banner.ts +0 -95
  197. package/src/ui/config-view.test.ts +0 -90
  198. package/src/ui/config-view.ts +0 -138
  199. package/src/ui/help.test.ts +0 -16
  200. package/src/ui/help.ts +0 -128
  201. package/src/ui/theme.test.ts +0 -92
  202. package/src/ui/theme.ts +0 -265
  203. package/src/url-policy.ts +0 -399
  204. package/tsconfig.json +0 -10
@@ -1,55 +0,0 @@
1
- import { execFileSync } from 'node:child_process'
2
- import { mkdirSync, mkdtempSync, writeFileSync } from 'node:fs'
3
- import os from 'node:os'
4
- import path from 'node:path'
5
- import { describe, expect, it } from 'vitest'
6
- import {
7
- disableProject,
8
- enableProject,
9
- projectBinding,
10
- projectEnabled,
11
- } from './project-enablement.js'
12
-
13
- function git(cwd: string, ...args: string[]): void {
14
- execFileSync('git', ['-C', cwd, ...args], { stdio: 'ignore' })
15
- }
16
-
17
- describe('Project enablement', () => {
18
- it('is disabled by default and changes only through its User-owned marker', () => {
19
- const root = mkdtempSync(path.join(os.tmpdir(), 'notifai-enablement-'))
20
- const env = { XDG_CONFIG_HOME: path.join(root, 'config') }
21
- const binding = projectBinding(root, env)
22
- expect(binding).not.toBeNull()
23
- expect(projectEnabled(binding)).toBe(false)
24
-
25
- enableProject(binding!, new Date('2026-08-27T00:00:00.000Z'))
26
- expect(projectEnabled(binding)).toBe(true)
27
- disableProject(binding!)
28
- expect(projectEnabled(binding)).toBe(false)
29
- })
30
-
31
- it('shares one decision across linked worktrees but not unrelated checkouts with the same name', () => {
32
- const root = mkdtempSync(path.join(os.tmpdir(), 'notifai-enablement-git-'))
33
- const repo = path.join(root, 'same-name')
34
- const linked = path.join(root, 'linked')
35
- const unrelatedParent = path.join(root, 'other')
36
- const unrelated = path.join(unrelatedParent, 'same-name')
37
- mkdirSync(repo, { recursive: true })
38
- mkdirSync(unrelated, { recursive: true })
39
- for (const cwd of [repo, unrelated]) {
40
- git(cwd, 'init')
41
- git(cwd, 'config', 'user.email', 'test@example.invalid')
42
- git(cwd, 'config', 'user.name', 'Test')
43
- writeFileSync(path.join(cwd, 'file'), 'x')
44
- git(cwd, 'add', 'file')
45
- git(cwd, 'commit', '-m', 'fixture')
46
- }
47
- git(repo, 'worktree', 'add', linked, '-b', 'linked')
48
- const env = { XDG_CONFIG_HOME: path.join(root, 'config') }
49
- const mainBinding = projectBinding(repo, env)
50
- const linkedBinding = projectBinding(linked, env)
51
- const unrelatedBinding = projectBinding(unrelated, env)
52
- expect(linkedBinding?.markerPath).toBe(mainBinding?.markerPath)
53
- expect(unrelatedBinding?.markerPath).not.toBe(mainBinding?.markerPath)
54
- })
55
- })
@@ -1,99 +0,0 @@
1
- import { execFileSync } from 'node:child_process'
2
- import { createHash } from 'node:crypto'
3
- import { existsSync, readFileSync, realpathSync, unlinkSync } from 'node:fs'
4
- import path from 'node:path'
5
- import { atomicWriteFileSync } from './atomic-file.js'
6
- import { globalConfigDir } from './config.js'
7
- import { inferInvocationContext } from './invocation-context.js'
8
-
9
- interface ProjectEnablementMarker {
10
- version: 1
11
- project: string
12
- enabled: true
13
- updated_at: string
14
- }
15
-
16
- export interface ProjectBinding {
17
- project: string
18
- markerPath: string
19
- }
20
-
21
- function gitCommonDirectory(cwd: string): string | null {
22
- try {
23
- const raw = execFileSync('git', ['-C', cwd, 'rev-parse', '--git-common-dir'], {
24
- encoding: 'utf8',
25
- stdio: ['ignore', 'pipe', 'ignore'],
26
- timeout: 2_000,
27
- windowsHide: true,
28
- }).trim()
29
- if (raw === '') return null
30
- const resolved = path.isAbsolute(raw) ? raw : path.resolve(cwd, raw)
31
- try {
32
- return realpathSync(resolved)
33
- } catch {
34
- return path.resolve(resolved)
35
- }
36
- } catch {
37
- return null
38
- }
39
- }
40
-
41
- /**
42
- * Resolve the User-owned enablement record for this exact local Project.
43
- *
44
- * The opaque key binds the semantic Project id to Git's shared common
45
- * directory. Linked worktrees therefore share one decision, while another
46
- * checkout cannot borrow it merely by committing the same Project id.
47
- */
48
- export function projectBinding(
49
- cwd: string,
50
- env: NodeJS.ProcessEnv = process.env,
51
- explicitProject?: string | null,
52
- ): ProjectBinding | null {
53
- const project = explicitProject ?? inferInvocationContext(cwd).project
54
- if (project === null) return null
55
- const repository = gitCommonDirectory(cwd) ?? path.resolve(cwd)
56
- const key = createHash('sha256')
57
- .update('notifai-project-enablement-v1\0')
58
- .update(repository)
59
- .update('\0')
60
- .update(project)
61
- .digest('hex')
62
- return {
63
- project,
64
- markerPath: path.join(globalConfigDir(env), 'project-enablement', `${key}.json`),
65
- }
66
- }
67
-
68
- export function projectEnabled(binding: ProjectBinding | null): boolean {
69
- if (binding === null || !existsSync(binding.markerPath)) return false
70
- try {
71
- const marker = JSON.parse(readFileSync(binding.markerPath, 'utf8')) as Partial<ProjectEnablementMarker>
72
- return marker.version === 1 && marker.enabled === true && marker.project === binding.project
73
- } catch {
74
- return false
75
- }
76
- }
77
-
78
- export function enableProject(binding: ProjectBinding, now: Date = new Date()): void {
79
- const marker: ProjectEnablementMarker = {
80
- version: 1,
81
- project: binding.project,
82
- enabled: true,
83
- updated_at: now.toISOString(),
84
- }
85
- atomicWriteFileSync(binding.markerPath, `${JSON.stringify(marker, null, 2)}\n`, {
86
- mode: 0o600,
87
- preserveMode: false,
88
- requireCurrentUserOwner: true,
89
- directoryMode: 0o700,
90
- })
91
- }
92
-
93
- export function disableProject(binding: ProjectBinding): void {
94
- try {
95
- unlinkSync(binding.markerPath)
96
- } catch (err) {
97
- if ((err as NodeJS.ErrnoException).code !== 'ENOENT') throw err
98
- }
99
- }
@@ -1,49 +0,0 @@
1
- import { mkdtempSync } from 'node:fs'
2
- import os from 'node:os'
3
- import path from 'node:path'
4
- import { describe, expect, it } from 'vitest'
5
- import { MAX_LIVE_QUESTIONS, MAX_PENDING_QUESTIONS, registerQuestion } from './hooks.js'
6
-
7
- /**
8
- * `registerQuestion` decides how many questions one session may hold, and these
9
- * exercise it directly against a bare state directory — no harness, no server,
10
- * no clock. They live apart from `hooks.test.ts` because nothing here needs
11
- * what that file spends its setup on.
12
- */
13
- const state = (): NodeJS.ProcessEnv => {
14
- const dir = mkdtempSync(path.join(os.tmpdir(), 'notifai-caps-'))
15
- return { HOME: dir, XDG_STATE_HOME: path.join(dir, 'state') } as NodeJS.ProcessEnv
16
- }
17
-
18
- describe('how many questions one session may hold', () => {
19
- it('stops an agent registering a fifth question nobody has asked yet', () => {
20
- const env = state()
21
- for (let i = 0; i < MAX_PENDING_QUESTIONS; i += 1) {
22
- registerQuestion('loop', env, { question: `Q${i}?` })
23
- }
24
- expect(() => registerQuestion('loop', env, { question: 'One more?' })).toThrow(
25
- /already waiting to be asked/,
26
- )
27
- })
28
-
29
- it('does not count a question the user simply has not answered yet', () => {
30
- // An answer stays accepted for a day. Counting questions already on the
31
- // user's devices against the loop guard would let a patient user stop the
32
- // agent from asking anything at all.
33
- const env = state()
34
- for (let i = 0; i < MAX_PENDING_QUESTIONS; i += 1) {
35
- registerQuestion('live', env, { question: `Asked ${i}?`, request_id: `req_${i}` })
36
- }
37
- expect(() => registerQuestion('live', env, { question: 'A new one?' })).not.toThrow()
38
- })
39
-
40
- it('still bounds what one session can put on the lock screen', () => {
41
- const env = state()
42
- for (let i = 0; i < MAX_LIVE_QUESTIONS; i += 1) {
43
- registerQuestion('flood', env, { question: `Asked ${i}?`, request_id: `req_${i}` })
44
- }
45
- expect(() => registerQuestion('flood', env, { question: 'One too many?' })).toThrow(
46
- /already open/,
47
- )
48
- })
49
- })
@@ -1,56 +0,0 @@
1
- import { REPLY_MAX_WINDOW_SECONDS } from '@raidiant/notifai-protocol'
2
-
3
- /**
4
- * Time before reply-window admission that the Stop owner may spend reading its
5
- * input, serving terminal-first grace, resolving devices, and submitting.
6
- *
7
- * Grace is capped at six minutes and every network operation is independently
8
- * bounded. Twenty minutes lets setup spend up to ten while preserving the
9
- * separate completion margin below; neither may borrow from the answer window
10
- * the service actually commits.
11
- */
12
- export const QUESTION_WAITER_STARTUP_HEADROOM_SECONDS = 20 * 60
13
-
14
- /**
15
- * Minimum lifetime still available when a question is admitted.
16
- *
17
- * The server window starts during submission, not when the hook process
18
- * started. Keeping ten minutes beyond the requested window covers the bounded
19
- * submit, clock skew, close fence, and route handoff. If setup used more of the
20
- * allowance, the frozen question remains local for a successor owner instead
21
- * of publishing a window this process cannot observe completely.
22
- */
23
- export const QUESTION_SUBMISSION_COMPLETION_HEADROOM_SECONDS = 10 * 60
24
-
25
- /**
26
- * The longest one Stop owner may live from process start.
27
- *
28
- * The service can accept an answer for up to 72 hours. Starting this budget
29
- * before submission is deliberate: the startup allowance above ensures that
30
- * even the maximum reply window still ends before the owner does.
31
- */
32
- export const QUESTION_WAITER_CEILING_SECONDS =
33
- REPLY_MAX_WINDOW_SECONDS + QUESTION_WAITER_STARTUP_HEADROOM_SECONDS
34
-
35
- /** Time for final fencing, diagnostics, stdout handoff, and clean process exit. */
36
- export const QUESTION_STOP_TEARDOWN_HEADROOM_SECONDS = 60
37
-
38
- /**
39
- * Declared harness timeout for every Stop handler that owns Question Routing.
40
- * It must outlive the runtime owner; otherwise the harness silently kills the
41
- * only process capable of continuing the exact Agent Session.
42
- */
43
- export const QUESTION_STOP_TIMEOUT_SECONDS =
44
- QUESTION_WAITER_CEILING_SECONDS + QUESTION_STOP_TEARDOWN_HEADROOM_SECONDS
45
-
46
- /**
47
- * Short Stop budget for harnesses that cannot own asynchronous Question
48
- * Routing. They retire/refuse promptly and never wait through an answer window.
49
- */
50
- export const NON_ROUTING_STOP_TIMEOUT_SECONDS = 540
51
-
52
- /**
53
- * Pre-PID claim files came only from the former eight-minute owner. Retain that
54
- * recovery bound instead of making a corrupt legacy file block for 72 hours.
55
- */
56
- export const LEGACY_QUESTION_CLAIM_TTL_SECONDS = 480
@@ -1,25 +0,0 @@
1
- import { describe, expect, it } from 'vitest'
2
- import { refreshAfterMenuAction } from './readiness.js'
3
-
4
- describe('refreshAfterMenuAction', () => {
5
- it('does not re-assess after doctor, a test send, or a device list', () => {
6
- expect(refreshAfterMenuAction('doctor', true)).toBeNull()
7
- expect(refreshAfterMenuAction('test', true)).toBeNull()
8
- expect(refreshAfterMenuAction('devices', true)).toBeNull()
9
- })
10
-
11
- it('does not re-assess when the action changed nothing', () => {
12
- expect(refreshAfterMenuAction('account', false)).toBeNull()
13
- expect(refreshAfterMenuAction('settings', false)).toBeNull()
14
- expect(refreshAfterMenuAction('routing', false)).toBeNull()
15
- expect(refreshAfterMenuAction('setup', false)).toBeNull()
16
- })
17
-
18
- it('re-probes the service only after an action that can change it', () => {
19
- expect(refreshAfterMenuAction('setup', true)).toEqual(['local', 'remote'])
20
- expect(refreshAfterMenuAction('account', true)).toEqual(['local', 'remote'])
21
- expect(refreshAfterMenuAction('settings', true)).toEqual(['local'])
22
- expect(refreshAfterMenuAction('routing', true)).toEqual(['local'])
23
- expect(refreshAfterMenuAction('settings', true, { remote: true })).toEqual(['local', 'remote'])
24
- })
25
- })
package/src/readiness.ts DELETED
@@ -1,179 +0,0 @@
1
- /**
2
- * What "set up" means, in one place.
3
- *
4
- * `init` and `doctor` used to each carry their own idea of this — a fixed
5
- * five-step script in one, a flat list of checks in the other — which is two
6
- * copies of the same knowledge that can disagree, and did: doctor called
7
- * missing hooks fine while init offered to install them, and only doctor knew
8
- * what a revoked credential looked like.
9
- *
10
- * So the model lives here and both commands render it. `doctor` shows every
11
- * state at once, because a report is what it is for. `init` walks the same
12
- * list in order and stops at the first thing standing in the way, because
13
- * someone handed five tasks does none of them. An agent gets the same states
14
- * through a third renderer that never prompts.
15
- *
16
- * The ordering is a dependency chain, not a preference: there is no point
17
- * asking the server about devices before there is a credential to ask with,
18
- * and no point reporting a stale contract to someone who cannot reach the
19
- * server at all. Assessment stops descending when a prerequisite is missing
20
- * and marks what follows `unknown` rather than guessing.
21
- */
22
-
23
- /** Whether the gap can be closed from here, and by whom. */
24
- export type Remedy =
25
- /**
26
- * The CLI can do it unattended — no prompt, no human. `command` is how a
27
- * reader would ask for it later, for the case where it was declined or
28
- * skipped; it is never needed to perform the action.
29
- */
30
- | { by: 'cli'; summary: string; command?: string }
31
- /**
32
- * A human at this terminal. `interactive` marks the ones the CLI can drive
33
- * on their behalf when someone is actually watching — a browser sign-in is
34
- * the CLI's to launch but the human's to complete, so an agent must still
35
- * treat it as out of reach.
36
- */
37
- | {
38
- by: 'user-here'
39
- summary: string
40
- command: string
41
- interactive?: boolean
42
- /** Stable agent-facing description of an unavoidable harness-owned action. */
43
- user_action?: { code: string; harness: string; action: string; message: string }
44
- }
45
- /**
46
- * A human somewhere else entirely — phone in hand, app store, permission
47
- * dialog. The class the old init could only print a sentence about, and the
48
- * one most likely to end an onboarding.
49
- */
50
- | { by: 'user-elsewhere'; summary: string }
51
-
52
- export type StateStatus =
53
- /** Satisfied. */
54
- | 'ready'
55
- /** Not satisfied, and it stands in the way. */
56
- | 'gap'
57
- /** Not satisfied, and that is a legitimate choice. */
58
- | 'optional-gap'
59
- /** A prerequisite is missing, so this was never evaluated. */
60
- | 'unknown'
61
-
62
- export interface ReadinessState {
63
- id: string
64
- /** Human words for what this is, not the field name. */
65
- title: string
66
- status: StateStatus
67
- /** The current condition, phrased for the human renderer. */
68
- detail: string
69
- /** Structured diagnostics emitted only by --json / non-human surfaces. */
70
- technical?: unknown
71
- remedy?: Remedy
72
- }
73
-
74
- export interface Readiness {
75
- states: ReadinessState[]
76
- }
77
-
78
- /** The first thing standing in the way, or null when nothing is. */
79
- export function firstBlocker(readiness: Readiness): ReadinessState | null {
80
- return readiness.states.find((s) => s.status === 'gap') ?? null
81
- }
82
-
83
- /**
84
- * Ready enough to be useful, which is not the same as every box ticked.
85
- *
86
- * Hooks and the agent skill are genuinely optional — `send` works without
87
- * them — so an install that declined both is finished, not half-done. Saying
88
- * otherwise trains people to ignore the summary line.
89
- */
90
- export function isReady(readiness: Readiness): boolean {
91
- return firstBlocker(readiness) === null
92
- }
93
-
94
- const SEND_PREREQUISITES = new Set([
95
- 'cli-bin',
96
- 'credential',
97
- 'server',
98
- 'contract',
99
- 'auth',
100
- 'devices',
101
- ])
102
-
103
- /** Baseline Notification Requests can be submitted in the final assessed state. */
104
- export function canSend(readiness: Readiness): boolean {
105
- return [...SEND_PREREQUISITES].every((id) => {
106
- const state = readiness.states.find((candidate) => candidate.id === id)
107
- return state?.status === 'ready' || state?.status === 'optional-gap'
108
- })
109
- }
110
-
111
- /** This exact invocation has a fully evidenced asynchronous question route. */
112
- export function questionRoutingReady(readiness: Readiness): boolean {
113
- const relevant = readiness.states.filter(
114
- (state) =>
115
- state.id === 'question-routing-settings' ||
116
- (state.id === 'hooks' || state.id.startsWith('hooks-')) && state.id !== 'hooks-detected',
117
- )
118
- return relevant.length > 0 && relevant.every((state) => state.status === 'ready')
119
- }
120
-
121
- /** Stable machine renderer shared by init and doctor. */
122
- export function readinessJson(readiness: Readiness): object {
123
- return {
124
- ready: isReady(readiness),
125
- can_send: canSend(readiness),
126
- question_routing_ready: questionRoutingReady(readiness),
127
- states: readiness.states.map((state) => ({
128
- id: state.id,
129
- title: state.title,
130
- status: state.status,
131
- detail: state.detail,
132
- technical: state.technical ?? null,
133
- remedy: state.remedy ?? null,
134
- })),
135
- }
136
- }
137
-
138
- /** Everything that could still be done, blocking or not, in dependency order. */
139
- export function openItems(readiness: Readiness): ReadinessState[] {
140
- return readiness.states.filter((s) => s.status === 'gap' || s.status === 'optional-gap')
141
- }
142
-
143
- /**
144
- * Work an assessment can do.
145
- *
146
- * `local` is this machine's files: project config, hook installs, skill lock
147
- * files. `remote` is everything that leaves the process — the keychain, the
148
- * service, the account, devices, and delivery evidence. The split exists so a
149
- * redraw after a settings change does not pay for a health check.
150
- */
151
- export type ReadinessRefresh = 'local' | 'remote'
152
-
153
- /**
154
- * After a menu action, which readiness work is worth doing.
155
- *
156
- * `null` means keep the assessment already in hand. Doctor is the report, not
157
- * a mutation: the check it just ran is the redraw. A test send and a device
158
- * list cannot change setup. Settings and hook wiring stay on this machine
159
- * unless the caller says the service URL itself moved.
160
- */
161
- export function refreshAfterMenuAction(
162
- action: 'setup' | 'account' | 'test' | 'devices' | 'settings' | 'routing' | 'doctor',
163
- changed: boolean,
164
- options: { remote?: boolean } = {},
165
- ): readonly ReadinessRefresh[] | null {
166
- switch (action) {
167
- case 'doctor':
168
- case 'test':
169
- case 'devices':
170
- return null
171
- case 'setup':
172
- case 'account':
173
- return changed ? ['local', 'remote'] : null
174
- case 'settings':
175
- case 'routing':
176
- if (!changed) return null
177
- return options.remote === true ? ['local', 'remote'] : ['local']
178
- }
179
- }
package/src/release.ts DELETED
@@ -1,51 +0,0 @@
1
- import { readFileSync } from 'node:fs'
2
- import path from 'node:path'
3
- import { fileURLToPath } from 'node:url'
4
-
5
- /**
6
- * What this build is, according to the manifest npm actually shipped.
7
- *
8
- * Read from disk rather than compiled in, because a constant baked into
9
- * `dist/` is only ever as fresh as the last build. A stale `dist/` sitting
10
- * next to a newer `package.json` is a real failure mode — it produces a CLI
11
- * that reports the new version perfectly while running the old code — and the
12
- * only defence that survives it is refusing to carry a second copy of the
13
- * number at all.
14
- *
15
- * The manifest sits one level above both `src/` and `dist/`, so a single
16
- * relative path resolves in a source checkout and in a published install.
17
- *
18
- * Returns null rather than guessing: a build that cannot establish its own
19
- * identity has to say so, so callers can degrade honestly instead of
20
- * asserting something they do not know.
21
- */
22
- export function packageVersion(): string | null {
23
- try {
24
- const manifest = path.join(path.dirname(fileURLToPath(import.meta.url)), '..', 'package.json')
25
- const parsed: unknown = JSON.parse(readFileSync(manifest, 'utf8'))
26
- if (typeof parsed === 'object' && parsed !== null) {
27
- const { version } = parsed as { version?: unknown }
28
- if (typeof version === 'string' && version.length > 0) return version
29
- }
30
- } catch {
31
- // Fall through: an unreadable manifest must not stop the CLI running.
32
- }
33
- return null
34
- }
35
-
36
- /**
37
- * Where `npx skills add` fetches the agent skill from, as printed to users.
38
- *
39
- * Always an immutable tag naming this exact build. The skill and the CLI ship
40
- * from one repository at one version, and the skill describes how to drive the
41
- * CLI — so a moving ref would hand someone guidance for a tool they do not
42
- * have. Deriving it from the version is what keeps the two from disagreeing;
43
- * it used to be maintained by hand in three places, and the copies drifted.
44
- *
45
- * In the skills CLI grammar `owner/repo#ref` selects a Git ref, while
46
- * `owner/repo@name` selects a skill — so the `#` here is load-bearing.
47
- */
48
- export function skillsSource(): string | null {
49
- const version = packageVersion()
50
- return version === null ? null : `Raidiant-io/notifai#v${version}`
51
- }
@@ -1,143 +0,0 @@
1
- import { mkdtempSync, readFileSync, readdirSync, rmSync, statSync, symlinkSync, writeFileSync } from 'node:fs'
2
- import os from 'node:os'
3
- import path from 'node:path'
4
- import { afterEach, describe, expect, it } from 'vitest'
5
- import type { NotificationDraftT } from '@raidiant/notifai-protocol'
6
- import type { MachineCredential } from './credentials.js'
7
- import {
8
- SEND_ATTEMPT_TTL_MS,
9
- beginSendAttempt,
10
- semanticMediaIds,
11
- sendDraftFingerprint,
12
- settleSendAttempt,
13
- } from './send-attempts.js'
14
-
15
- const roots: string[] = []
16
- afterEach(() => {
17
- while (roots.length > 0) rmSync(roots.pop()!, { recursive: true, force: true })
18
- })
19
-
20
- function fixture() {
21
- const root = mkdtempSync(path.join(os.tmpdir(), 'notifai-send-attempt-'))
22
- roots.push(root)
23
- const env = { XDG_STATE_HOME: path.join(root, 'state') }
24
- const credential: MachineCredential = {
25
- machineId: 'mac_attempt',
26
- machineName: 'Attempt Machine',
27
- baseUrl: 'https://app.notifai.test',
28
- secret: 'private-machine-secret',
29
- }
30
- const draft = {
31
- schema_version: 1,
32
- kind: 'done',
33
- project: 'private-project',
34
- presentation: { title: 'Sensitive result', body: 'Private body text' },
35
- targets: { mode: 'all' },
36
- } as NotificationDraftT
37
- return { root, env, credential, draft }
38
- }
39
-
40
- describe('opaque interrupted-send recovery', () => {
41
- it('matches one exact semantic retry without storing notification content', () => {
42
- const { env, credential, draft } = fixture()
43
- const fingerprint = sendDraftFingerprint(draft, credential)
44
- const first = beginSendAttempt({ env, credential, fingerprint, retry: false, now: 10 })
45
- expect(first.ok).toBe(true)
46
- if (!first.ok) return
47
-
48
- const files = readdirSync(path.join(env.XDG_STATE_HOME, 'notifai', 'send-attempts'))
49
- expect(files).toHaveLength(1)
50
- const file = path.join(env.XDG_STATE_HOME, 'notifai', 'send-attempts', files[0]!)
51
- const stored = readFileSync(file, 'utf8')
52
- expect(stored).not.toContain('Sensitive result')
53
- expect(stored).not.toContain('Private body text')
54
- if (process.platform !== 'win32') expect(statSync(file).mode & 0o777).toBe(0o600)
55
- if (process.platform !== 'win32') {
56
- expect(statSync(path.dirname(file)).mode & 0o777).toBe(0o700)
57
- }
58
-
59
- const retry = beginSendAttempt({ env, credential, fingerprint, retry: true, now: 11 })
60
- expect(retry).toMatchObject({
61
- ok: true,
62
- attemptId: first.attemptId,
63
- idempotencyKey: first.idempotencyKey,
64
- replay: true,
65
- })
66
- settleSendAttempt(env, first.attemptId)
67
- expect(beginSendAttempt({ env, credential, fingerprint, retry: true, now: 12 })).toMatchObject({
68
- ok: false,
69
- code: 'retry_not_found',
70
- })
71
- })
72
-
73
- it('does not follow a symlink presented as an attempt record', () => {
74
- const { root, env, credential, draft } = fixture()
75
- if (process.platform === 'win32') return
76
- const fingerprint = sendDraftFingerprint(draft, credential)
77
- const first = beginSendAttempt({ env, credential, fingerprint, retry: false, now: 10 })
78
- expect(first.ok).toBe(true)
79
- if (!first.ok) return
80
- const directory = path.join(env.XDG_STATE_HOME, 'notifai', 'send-attempts')
81
- const file = path.join(directory, `${first.attemptId}.json`)
82
- const target = path.join(root, 'foreign.json')
83
- writeFileSync(target, readFileSync(file, 'utf8'))
84
- rmSync(file)
85
- symlinkSync(target, file)
86
-
87
- expect(beginSendAttempt({ env, credential, fingerprint, retry: true, now: 11 })).toMatchObject({
88
- ok: false,
89
- code: 'retry_not_found',
90
- })
91
- expect(readFileSync(target, 'utf8')).toContain(first.idempotencyKey)
92
- })
93
-
94
- it('refuses ambiguity instead of choosing between identical unresolved events', () => {
95
- const { env, credential, draft } = fixture()
96
- const fingerprint = sendDraftFingerprint(draft, credential)
97
- beginSendAttempt({ env, credential, fingerprint, retry: false, now: 10 })
98
- beginSendAttempt({ env, credential, fingerprint, retry: false, now: 11 })
99
- expect(beginSendAttempt({ env, credential, fingerprint, retry: true, now: 12 })).toMatchObject({
100
- ok: false,
101
- code: 'retry_ambiguous',
102
- })
103
- })
104
-
105
- it('invalidates attempts after expiry or credential rotation', () => {
106
- const { env, credential, draft } = fixture()
107
- const fingerprint = sendDraftFingerprint(draft, credential)
108
- beginSendAttempt({ env, credential, fingerprint, retry: false, now: 10 })
109
- expect(
110
- beginSendAttempt({
111
- env,
112
- credential,
113
- fingerprint,
114
- retry: true,
115
- now: 10 + SEND_ATTEMPT_TTL_MS + 1,
116
- }),
117
- ).toMatchObject({ ok: false, code: 'retry_not_found' })
118
-
119
- beginSendAttempt({ env, credential, fingerprint, retry: false, now: 20 })
120
- const rotated = { ...credential, secret: 'rotated-secret' }
121
- expect(
122
- beginSendAttempt({
123
- env,
124
- credential: rotated,
125
- fingerprint: sendDraftFingerprint(draft, rotated),
126
- retry: true,
127
- now: 21,
128
- }),
129
- ).toMatchObject({ ok: false, code: 'retry_not_found' })
130
- })
131
-
132
- it('fingerprints local media bytes without retaining paths or bytes', () => {
133
- const { root } = fixture()
134
- const file = path.join(root, 'private.png')
135
- writeFileSync(file, 'first bytes')
136
- const first = semanticMediaIds([file], root)
137
- writeFileSync(file, 'different bytes')
138
- const second = semanticMediaIds([file], root)
139
- expect(first).not.toEqual(second)
140
- expect(first[0]).not.toContain(file)
141
- expect(first[0]).not.toContain('first bytes')
142
- })
143
- })