@raidiant/notifai 4.0.0 → 6.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 (140) hide show
  1. package/CHANGELOG.md +73 -0
  2. package/README.md +7 -2
  3. package/dist/client.d.ts +8 -3
  4. package/dist/client.js +24 -3
  5. package/dist/client.js.map +1 -1
  6. package/dist/commands-auth.d.ts +14 -0
  7. package/dist/commands-auth.js +154 -0
  8. package/dist/commands-auth.js.map +1 -0
  9. package/dist/commands-config.d.ts +31 -0
  10. package/dist/commands-config.js +246 -0
  11. package/dist/commands-config.js.map +1 -0
  12. package/dist/commands-core.d.ts +102 -0
  13. package/dist/commands-core.js +135 -0
  14. package/dist/commands-core.js.map +1 -0
  15. package/dist/commands-devices.d.ts +31 -0
  16. package/dist/commands-devices.js +124 -0
  17. package/dist/commands-devices.js.map +1 -0
  18. package/dist/commands-doctor.d.ts +24 -0
  19. package/dist/commands-doctor.js +1011 -0
  20. package/dist/commands-doctor.js.map +1 -0
  21. package/dist/commands-harness-context.d.ts +61 -0
  22. package/dist/commands-harness-context.js +93 -0
  23. package/dist/commands-harness-context.js.map +1 -0
  24. package/dist/commands-hook-shape.d.ts +17 -0
  25. package/dist/commands-hook-shape.js +37 -0
  26. package/dist/commands-hook-shape.js.map +1 -0
  27. package/dist/commands-hooks.d.ts +115 -0
  28. package/dist/commands-hooks.js +1225 -0
  29. package/dist/commands-hooks.js.map +1 -0
  30. package/dist/commands-init.d.ts +35 -0
  31. package/dist/commands-init.js +580 -0
  32. package/dist/commands-init.js.map +1 -0
  33. package/dist/commands-io.d.ts +2 -0
  34. package/dist/commands-io.js +110 -0
  35. package/dist/commands-io.js.map +1 -0
  36. package/dist/commands-logs.d.ts +30 -0
  37. package/dist/commands-logs.js +201 -0
  38. package/dist/commands-logs.js.map +1 -0
  39. package/dist/commands-send-support.d.ts +44 -0
  40. package/dist/commands-send-support.js +180 -0
  41. package/dist/commands-send-support.js.map +1 -0
  42. package/dist/commands-send.d.ts +33 -0
  43. package/dist/commands-send.js +519 -0
  44. package/dist/commands-send.js.map +1 -0
  45. package/dist/commands-setup-proof.d.ts +15 -0
  46. package/dist/commands-setup-proof.js +53 -0
  47. package/dist/commands-setup-proof.js.map +1 -0
  48. package/dist/commands-skill.d.ts +10 -0
  49. package/dist/commands-skill.js +71 -0
  50. package/dist/commands-skill.js.map +1 -0
  51. package/dist/commands.d.ts +12 -378
  52. package/dist/commands.js +12 -4420
  53. package/dist/commands.js.map +1 -1
  54. package/dist/config-schema.js +15 -6
  55. package/dist/config-schema.js.map +1 -1
  56. package/dist/config.d.ts +4 -2
  57. package/dist/config.js +13 -0
  58. package/dist/config.js.map +1 -1
  59. package/dist/credentials.js +13 -3
  60. package/dist/credentials.js.map +1 -1
  61. package/dist/file-lock.d.ts +6 -1
  62. package/dist/file-lock.js +55 -9
  63. package/dist/file-lock.js.map +1 -1
  64. package/dist/harnesses.d.ts +1 -1
  65. package/dist/harnesses.js.map +1 -1
  66. package/dist/hooks.d.ts +74 -7
  67. package/dist/hooks.js +522 -166
  68. package/dist/hooks.js.map +1 -1
  69. package/dist/install-hooks.d.ts +91 -14
  70. package/dist/install-hooks.js +226 -34
  71. package/dist/install-hooks.js.map +1 -1
  72. package/dist/interactive.d.ts +1 -1
  73. package/dist/interactive.js +5 -5
  74. package/dist/interactive.js.map +1 -1
  75. package/dist/logging.d.ts +8 -0
  76. package/dist/logging.js +9 -0
  77. package/dist/logging.js.map +1 -1
  78. package/dist/main.js +45 -18
  79. package/dist/main.js.map +1 -1
  80. package/dist/opencode-plugin.d.ts +8 -0
  81. package/dist/opencode-plugin.js +24 -162
  82. package/dist/opencode-plugin.js.map +1 -1
  83. package/dist/readiness.d.ts +3 -1
  84. package/dist/readiness.js.map +1 -1
  85. package/dist/send.d.ts +6 -0
  86. package/dist/send.js +25 -14
  87. package/dist/send.js.map +1 -1
  88. package/dist/ui/help.d.ts +0 -1
  89. package/dist/ui/help.js +6 -3
  90. package/dist/ui/help.js.map +1 -1
  91. package/dist/ui/theme.d.ts +0 -15
  92. package/dist/ui/theme.js +2 -18
  93. package/dist/ui/theme.js.map +1 -1
  94. package/package.json +2 -2
  95. package/src/client.test.ts +90 -1
  96. package/src/client.ts +35 -2
  97. package/src/commands-auth.ts +174 -0
  98. package/src/commands-config.ts +307 -0
  99. package/src/commands-core.ts +240 -0
  100. package/src/commands-devices.ts +150 -0
  101. package/src/commands-doctor.ts +1209 -0
  102. package/src/commands-harness-context.ts +126 -0
  103. package/src/commands-hook-shape.ts +53 -0
  104. package/src/commands-hooks.ts +1586 -0
  105. package/src/commands-init.ts +721 -0
  106. package/src/commands-io.ts +108 -0
  107. package/src/commands-logs.ts +243 -0
  108. package/src/commands-send-support.ts +224 -0
  109. package/src/commands-send.ts +623 -0
  110. package/src/commands-setup-proof.ts +71 -0
  111. package/src/commands-skill.ts +84 -0
  112. package/src/commands.test.ts +677 -89
  113. package/src/commands.ts +58 -5472
  114. package/src/config-schema.test.ts +1 -1
  115. package/src/config-schema.ts +17 -6
  116. package/src/config.test.ts +50 -9
  117. package/src/config.ts +19 -1
  118. package/src/credentials.test.ts +25 -1
  119. package/src/credentials.ts +13 -3
  120. package/src/file-lock.test.ts +41 -0
  121. package/src/file-lock.ts +59 -9
  122. package/src/harnesses.test.ts +37 -29
  123. package/src/harnesses.ts +1 -4
  124. package/src/hooks.test.ts +703 -26
  125. package/src/hooks.ts +590 -182
  126. package/src/install-hooks.test.ts +232 -12
  127. package/src/install-hooks.ts +241 -37
  128. package/src/interactive.test.ts +19 -1
  129. package/src/interactive.ts +5 -5
  130. package/src/logging-process-worker.test.ts +8 -2
  131. package/src/logging.test.ts +48 -20
  132. package/src/logging.ts +12 -0
  133. package/src/main.ts +58 -24
  134. package/src/opencode-plugin.ts +24 -164
  135. package/src/question-caps.test.ts +49 -0
  136. package/src/readiness.ts +3 -1
  137. package/src/send.ts +32 -16
  138. package/src/skill.test.ts +199 -146
  139. package/src/ui/help.ts +8 -3
  140. package/src/ui/theme.ts +2 -16
@@ -86,7 +86,7 @@ describe('formatValue', () => {
86
86
  it('explains what an unset value means instead of printing null', () => {
87
87
  // `sound = null` is accurate and tells the reader nothing about what will
88
88
  // happen; the whole complaint about the old output in one line.
89
- expect(formatValue('sound', null)).toBe('not specified; the destination uses its normal behavior')
89
+ expect(formatValue('sound', null)).toBe('not set; each kind brings its own sound')
90
90
  expect(formatValue('devices', null)).toBe('every device that can receive')
91
91
  })
92
92
 
@@ -83,9 +83,9 @@ const INFO: Record<ConfigKey, Omit<ConfigKeyInfo, 'key'>> = {
83
83
  label: 'Send questions to my devices',
84
84
  group: 'questions',
85
85
  kind: 'boolean',
86
- summary: 'Whether a registered question may reach your phone and Mac at all',
86
+ summary: 'Whether a registered question may reach your iPhone at all',
87
87
  detail:
88
- 'The master switch for question routing. When this is off, a question an agent registers with `notifai ask` stays in the terminal and never leaves this machine, whatever the presence settings say. Turn it off to stop being reached for a while without uninstalling the harness hooks.',
88
+ 'The master switch for question routing. When this is off, a question an agent registers with `notifai ask` stays in the terminal and never leaves this machine, whatever else is configured. Turn it off to stop being reached for a while without uninstalling the harness hooks.',
89
89
  example: 'true',
90
90
  },
91
91
  ask_grace_seconds: {
@@ -99,6 +99,17 @@ const INFO: Record<ConfigKey, Omit<ConfigKeyInfo, 'key'>> = {
99
99
  example: '0',
100
100
  },
101
101
 
102
+ reply_window_seconds: {
103
+ label: 'How long an answer is accepted',
104
+ group: 'questions',
105
+ kind: 'integer',
106
+ unit: 's',
107
+ summary: 'How long the service keeps accepting your answer to a question',
108
+ detail:
109
+ 'A question stays answerable for this long after it is asked. The default is a day, so a question that arrives while you are away is still yours to answer when you come back.\n\nThis is not how long anything waits on you. The agent ends its turn immediately; if it is still running when you answer, the answer reaches it directly, and otherwise it is delivered at that session\'s next turn. Shortening this only makes an answer arrive too late to be accepted.',
110
+ example: '86400',
111
+ },
112
+
102
113
  sound: {
103
114
  label: 'Sound',
104
115
  group: 'delivery',
@@ -113,8 +124,8 @@ const INFO: Record<ConfigKey, Omit<ConfigKeyInfo, 'key'>> = {
113
124
  },
114
125
  summary: 'Set the sound notifications from here use',
115
126
  detail:
116
- 'Setting this pins one sound for every notification sent from here. Kind remains semantic status metadata and never changes native-banner attention.\n\nLeave this unset to omit the sound field and let the destination use its normal behavior. A common explicit preference is `none`, to make a noisy project completely silent.',
117
- unsetMeans: 'not specified; the destination uses its normal behavior',
127
+ 'Setting this pins one sound for every notification sent from here, whatever kind it is.\n\nLeave it unset and each notification arrives with the sound its kind implies: the completion chime when work finished, the most insistent tone when it failed, a distinct attention tone for a question or blocked work, and the standard tone for ordinary news. A common explicit preference is `none`, to make a noisy project completely silent.',
128
+ unsetMeans: 'not set; each kind brings its own sound',
118
129
  },
119
130
  interruption_level: {
120
131
  label: 'Interruption level',
@@ -124,11 +135,11 @@ const INFO: Record<ConfigKey, Omit<ConfigKeyInfo, 'key'>> = {
124
135
  choiceHints: {
125
136
  passive: 'no sound or wake — waits in the notification list',
126
137
  active: 'wakes the screen and plays the sound',
127
- time_sensitive: 'may break through Focus modes',
138
+ time_sensitive: 'marks the notification urgent; Focus breakthrough is not available yet',
128
139
  },
129
140
  summary: 'Set how insistently notifications from here arrive',
130
141
  detail:
131
- 'How hard a notification tries to reach you, in the operating system\'s terms. Setting this pins one level for everything sent from here; kind never changes it.\n\nLeave this unset to omit the field and let the destination use its normal behavior. `time_sensitive` can pierce a Focus mode, so reserve it for cases that genuinely earn it.',
142
+ 'How hard a notification tries to reach you, in the operating system\'s terms. Setting this pins one level for everything sent from here; kind chooses the sound, never this.\n\nLeave this unset to omit the field and let the destination use its normal behavior. `time_sensitive` is accepted and marks the notification urgent, but Focus breakthrough is not available yet — run `notifai capabilities` for what a platform actually honours.',
132
143
  unsetMeans: 'not specified; the destination uses its normal behavior',
133
144
  },
134
145
  devices: {
@@ -177,6 +177,15 @@ describe('config precedence', () => {
177
177
  expect(config.wait_seconds.value).toBe(2)
178
178
  expect(config.wait_seconds.source).toBe('flag')
179
179
  })
180
+
181
+ it('uses the prior default when a newer CLI wrote an unknown enum value', () => {
182
+ const { env, cwd } = setup({
183
+ globalToml: 'sound = "future-sound"\ninterruption_level = "future-level"\n',
184
+ })
185
+ const config = loadConfig({ cwd, env })
186
+ expect(config.sound).toEqual({ value: null, source: 'default' })
187
+ expect(config.interruption_level).toEqual({ value: null, source: 'default' })
188
+ })
180
189
  })
181
190
 
182
191
  describe('draft building', () => {
@@ -191,20 +200,32 @@ describe('draft building', () => {
191
200
  presentation: { title: 'T', body: 'B' },
192
201
  targets: { mode: 'all' },
193
202
  delivery: { ttl_seconds: 86400, collapse_key: null },
203
+ // An unlabelled draft is ordinary news, and news has a sound.
204
+ platform: { ios: { sound: 'default' }, macos: { sound: 'default' } },
194
205
  })
195
206
  })
196
207
 
197
208
  it.each([
198
- {},
199
- { kind: 'done' },
200
- { kind: 'failed' },
201
- { kind: 'blocked' },
202
- { reply: true },
203
- ])('never derives sound or interruption level from kind: %j', (flags) => {
209
+ { flags: {}, sound: 'default' },
210
+ { flags: { kind: 'update' }, sound: 'default' },
211
+ { flags: { kind: 'done' }, sound: 'done' },
212
+ { flags: { kind: 'failed' }, sound: 'alert' },
213
+ { flags: { kind: 'blocked' }, sound: 'attention' },
214
+ { flags: { kind: 'question' }, sound: 'attention' },
215
+ { flags: { reply: true }, sound: 'attention' },
216
+ ])('gives $flags the $sound sound when nobody chose one', ({ flags, sound }) => {
204
217
  const config = loadConfig({ cwd: base.cwd, env: base.env })
205
218
  const build = buildDraft(config, { title: 'T', body: 'B', ...flags })
206
219
  if (!build.ok) throw new Error(build.error)
207
- expect(build.draft.platform).toBeUndefined()
220
+ expect(build.draft.platform?.ios?.sound).toBe(sound)
221
+ expect(build.draft.platform?.ios?.interruption_level).toBeUndefined()
222
+ })
223
+
224
+ it('lets an explicit sound outrank the kind it would have derived', () => {
225
+ const config = loadConfig({ cwd: base.cwd, env: base.env })
226
+ const explicit = buildDraft(config, { title: 'T', body: 'B', kind: 'failed', sound: 'none' })
227
+ if (!explicit.ok) throw new Error(explicit.error)
228
+ expect(explicit.draft.platform?.ios?.sound).toBeNull()
208
229
  })
209
230
 
210
231
  it('orders explicit flags over saved user config', () => {
@@ -233,12 +254,32 @@ describe('draft building', () => {
233
254
  })
234
255
  })
235
256
 
236
- it('keeps a blocking reply answerable for one hour by default', () => {
257
+ it('keeps a question answerable for a day by default', () => {
258
+ // A question asked before someone steps away should still be theirs to
259
+ // answer when they come back; the CLI used to cut the wire contract's own
260
+ // default down to an hour.
237
261
  const config = loadConfig({ cwd: base.cwd, env: base.env })
238
262
  const build = buildDraft(config, { title: 'Question', body: 'Deploy?', reply: true })
239
263
  if (!build.ok) throw new Error(build.error)
240
264
 
241
- expect(build.draft.reply?.expires_in_seconds).toBe(3_600)
265
+ expect(build.draft.reply?.expires_in_seconds).toBe(86_400)
266
+ })
267
+
268
+ it('lets the user shorten or lengthen how long an answer is accepted', () => {
269
+ const { env, cwd } = setup({ projectToml: 'reply_window_seconds = 7200\n' })
270
+ const config = loadConfig({ cwd, env })
271
+ const build = buildDraft(config, { title: 'Question', body: 'Deploy?', reply: true })
272
+ if (!build.ok) throw new Error(build.error)
273
+ expect(build.draft.reply?.expires_in_seconds).toBe(7_200)
274
+
275
+ const explicit = buildDraft(config, {
276
+ title: 'Question',
277
+ body: 'Deploy?',
278
+ reply: true,
279
+ replyWindow: 600,
280
+ })
281
+ if (!explicit.ok) throw new Error(explicit.error)
282
+ expect(explicit.draft.reply?.expires_in_seconds).toBe(600)
242
283
  })
243
284
 
244
285
  it('orders explicit and configured Project identity over inference', () => {
package/src/config.ts CHANGED
@@ -1,8 +1,12 @@
1
+ import {
2
+ CLI_SOUNDS,
3
+ INTERRUPTION_LEVELS,
4
+ REPLY_MAX_WINDOW_SECONDS,
5
+ } from '@raidiant/notifai-protocol'
1
6
  import { createHash } from 'node:crypto'
2
7
  import { existsSync, readFileSync, realpathSync } from 'node:fs'
3
8
  import path from 'node:path'
4
9
  import { parse as parseToml } from 'smol-toml'
5
- import type { CLI_SOUNDS, INTERRUPTION_LEVELS } from '@raidiant/notifai-protocol'
6
10
  import { configHome, stateHome } from './platform.js'
7
11
 
8
12
  /**
@@ -87,6 +91,8 @@ export interface CliConfig {
87
91
  * usefulness.
88
92
  */
89
93
  log_max_bytes: ResolvedValue<number>
94
+ /** How long the server keeps accepting an answer to a question. */
95
+ reply_window_seconds: ResolvedValue<number>
90
96
  /** Log files kept in total, the active one included. */
91
97
  log_max_files: ResolvedValue<number>
92
98
  }
@@ -102,6 +108,7 @@ export const CONFIG_KEYS = [
102
108
  'notify_criteria',
103
109
  'ask_notifications',
104
110
  'ask_grace_seconds',
111
+ 'reply_window_seconds',
105
112
  'log_level',
106
113
  'log_max_bytes',
107
114
  'log_max_files',
@@ -116,6 +123,7 @@ export const NUMERIC_CONFIG_KEYS: readonly ConfigKey[] = [
116
123
  'wait_seconds',
117
124
  'ttl_seconds',
118
125
  'ask_grace_seconds',
126
+ 'reply_window_seconds',
119
127
  'log_max_bytes',
120
128
  'log_max_files',
121
129
  ]
@@ -128,6 +136,8 @@ export const NUMERIC_CONFIG_KEYS: readonly ConfigKey[] = [
128
136
  * is the same discipline the numeric bounds follow.
129
137
  */
130
138
  export const ENUM_CONFIG_VALUES: Partial<Record<ConfigKey, readonly string[]>> = {
139
+ sound: CLI_SOUNDS,
140
+ interruption_level: INTERRUPTION_LEVELS,
131
141
  log_level: LOG_LEVELS,
132
142
  }
133
143
 
@@ -149,6 +159,9 @@ const DEFAULTS = {
149
159
  // Questions reach devices as soon as the turn ends. A terminal-first grace
150
160
  // window is an opt-in delay on top of that.
151
161
  ask_grace_seconds: 0,
162
+ // A day. The wire contract's own default, and long enough that a question
163
+ // asked before someone steps away is still answerable when they come back.
164
+ reply_window_seconds: 86_400,
152
165
  // On by default: the log is the only account of what a headless hook did, and
153
166
  // a diagnostic nobody switched on before the thing went wrong is not one.
154
167
  log_level: 'info',
@@ -302,6 +315,10 @@ const NUMERIC_BOUNDS: Partial<Record<ConfigKey, { min: number; max: number }>> =
302
315
  // longest window a user can choose still leaves an answer window the server
303
316
  // will accept — with room to spare for a timer that wakes late.
304
317
  ask_grace_seconds: { min: 0, max: 360 },
318
+ // The floor is the shortest window the wire contract accepts; the ceiling is
319
+ // the point past which a question would outlive the retained content it
320
+ // refers to.
321
+ reply_window_seconds: { min: 60, max: REPLY_MAX_WINDOW_SECONDS },
305
322
  // The floor keeps rotation from thrashing (a file smaller than a handful of
306
323
  // records would rotate on nearly every write); the ceiling is the point past
307
324
  // which "local diagnostics" has become "a database on your disk".
@@ -413,6 +430,7 @@ export function loadConfig(options: {
413
430
  notify_criteria: resolve('notify_criteria'),
414
431
  ask_notifications: resolve('ask_notifications'),
415
432
  ask_grace_seconds: resolve('ask_grace_seconds'),
433
+ reply_window_seconds: resolve('reply_window_seconds'),
416
434
  log_level: resolve('log_level'),
417
435
  log_max_bytes: resolve('log_max_bytes'),
418
436
  log_max_files: resolve('log_max_files'),
@@ -168,7 +168,10 @@ describe('FileStore', () => {
168
168
  if (process.platform !== 'win32') {
169
169
  expect(statSync(file).mode & 0o777).toBe(0o600)
170
170
  }
171
- expect(JSON.parse(readFileSync(file, 'utf8'))).toEqual(SAMPLE)
171
+ expect(JSON.parse(readFileSync(file, 'utf8'))).toEqual({
172
+ format: 'notifai.machine-credential.v1',
173
+ ...SAMPLE,
174
+ })
172
175
  expect(store.load()).toEqual(SAMPLE)
173
176
  store.save(OTHER)
174
177
  expect(store.load()).toEqual(OTHER)
@@ -190,6 +193,27 @@ describe('FileStore', () => {
190
193
  writeRaw(file, `${JSON.stringify({ machineId: 'mach_fixture' })}\n`)
191
194
  expect(new FileStore(env).load()).toBeNull()
192
195
  })
196
+
197
+ it('reads the unversioned v1 shape while making every new save explicit', () => {
198
+ const { env } = sandbox('file-unversioned-v1')
199
+ const file = path.join(env.XDG_CONFIG_HOME!, 'notifai', 'credentials.json')
200
+ writeRaw(file, `${JSON.stringify(SAMPLE)}\n`)
201
+ const store = new FileStore(env)
202
+ expect(store.load()).toEqual(SAMPLE)
203
+ store.save(SAMPLE)
204
+ expect(JSON.parse(readFileSync(file, 'utf8'))).toMatchObject({
205
+ format: 'notifai.machine-credential.v1',
206
+ })
207
+ })
208
+
209
+ it('does not interpret or erase a credential from a future format epoch', () => {
210
+ const { env } = sandbox('file-future-format')
211
+ const file = path.join(env.XDG_CONFIG_HOME!, 'notifai', 'credentials.json')
212
+ const future = `${JSON.stringify({ format: 'notifai.machine-credential.v2', ...SAMPLE })}\n`
213
+ writeRaw(file, future)
214
+ expect(new FileStore(env).load()).toBeNull()
215
+ expect(readFileSync(file, 'utf8')).toBe(future)
216
+ })
193
217
  })
194
218
 
195
219
  describe('WindowsDpapiStore', () => {
@@ -44,6 +44,7 @@ export interface CredentialStoreOptions {
44
44
  }
45
45
 
46
46
  const SERVICE = 'io.notifai.cli'
47
+ const CREDENTIAL_FORMAT = 'notifai.machine-credential.v1'
47
48
  const DPAPI_FORMAT = 'notifai.dpapi.current-user.v1'
48
49
  const DPAPI_ENTROPY = 'io.notifai.cli'
49
50
  const DPAPI_TIMEOUT_MS = 15_000
@@ -136,7 +137,7 @@ export class KeychainStore implements CredentialStore {
136
137
  '-a',
137
138
  'machine',
138
139
  '-w',
139
- JSON.stringify(credential),
140
+ JSON.stringify(serializeCredential(credential)),
140
141
  ],
141
142
  { stdio: 'ignore' },
142
143
  )
@@ -189,7 +190,7 @@ export class FileStore implements CredentialStore {
189
190
  }
190
191
 
191
192
  save(credential: MachineCredential): void {
192
- atomicWriteFileSync(this.filePath(), `${JSON.stringify(credential, null, 2)}\n`, {
193
+ atomicWriteFileSync(this.filePath(), `${JSON.stringify(serializeCredential(credential), null, 2)}\n`, {
193
194
  mode: 0o600,
194
195
  preserveMode: false,
195
196
  requireCurrentUserOwner: true,
@@ -251,7 +252,7 @@ export class WindowsDpapiStore implements CredentialStore {
251
252
  if (file === null) {
252
253
  throw new Error('Windows credential store path is not configured (LOCALAPPDATA is missing)')
253
254
  }
254
- const blob = this.protect(Buffer.from(JSON.stringify(credential), 'utf8'))
255
+ const blob = this.protect(Buffer.from(JSON.stringify(serializeCredential(credential)), 'utf8'))
255
256
  atomicWriteFileSync(file, `${JSON.stringify({ format: DPAPI_FORMAT, data: blob })}\n`, {
256
257
  mode: 0o600,
257
258
  preserveMode: false,
@@ -302,6 +303,10 @@ export class WindowsDpapiStore implements CredentialStore {
302
303
  }
303
304
  }
304
305
 
306
+ function serializeCredential(credential: MachineCredential): Record<string, string> {
307
+ return { format: CREDENTIAL_FORMAT, ...credential }
308
+ }
309
+
305
310
  function parseCredentialJson(raw: string): MachineCredential | null {
306
311
  try {
307
312
  return parseCredential(JSON.parse(raw) as unknown)
@@ -313,6 +318,11 @@ function parseCredentialJson(raw: string): MachineCredential | null {
313
318
  function parseCredential(raw: unknown): MachineCredential | null {
314
319
  if (raw === null || typeof raw !== 'object') return null
315
320
  const value = raw as Record<string, unknown>
321
+ const format = value['format']
322
+ // Unversioned credentials are the pre-epoch v1 representation. A future
323
+ // format is left untouched on disk/keychain and treated as unavailable;
324
+ // interpreting it as v1 could silently change authentication meaning.
325
+ if (format !== undefined && format !== CREDENTIAL_FORMAT) return null
316
326
  const machineId = value['machineId']
317
327
  const secret = value['secret']
318
328
  const baseUrl = value['baseUrl']
@@ -206,6 +206,47 @@ describe('cross-process file lock', () => {
206
206
  }, 30_000)
207
207
  })
208
208
 
209
+ describe('rendezvous contention', () => {
210
+ // The last contender out removes the rendezvous directory, so a contender
211
+ // registering at that instant races the removal. Both tests drive that race
212
+ // from the registering observation, which is the window the racing releaser
213
+ // would otherwise have to hit by luck — on a loaded machine it does.
214
+ function raceAtRegistration(interfere: (lock: string) => void): void {
215
+ const root = mkdtempSync(path.join(os.tmpdir(), 'notifai-rendezvous-'))
216
+ const lock = path.join(root, 'shared.lock')
217
+ let raced = false
218
+ let entered = 0
219
+
220
+ withFileLock(lock, () => { entered += 1 }, {
221
+ observe(observation) {
222
+ if (observation.phase !== 'registering' || raced) return
223
+ raced = true
224
+ interfere(lock)
225
+ },
226
+ })
227
+
228
+ expect(raced).toBe(true)
229
+ expect(entered).toBe(1)
230
+ // Release still cleans up after itself, whichever rendezvous it ended in.
231
+ expect(existsSync(lock)).toBe(false)
232
+ rmSync(root, { recursive: true, force: true })
233
+ }
234
+
235
+ it('registers again when a releasing contender removed the rendezvous', () => {
236
+ raceAtRegistration((lock) => rmSync(lock, { recursive: true }))
237
+ })
238
+
239
+ it('holds the rendezvous it landed in, not the one it scanned', () => {
240
+ // A third contender recreates the directory before this one publishes, so
241
+ // the entry lands in a directory whose identity nobody has recorded yet.
242
+ // Treating that as tampering used to break the caller outright.
243
+ raceAtRegistration((lock) => {
244
+ rmSync(lock, { recursive: true })
245
+ mkdirSync(lock, { mode: 0o700 })
246
+ })
247
+ })
248
+ })
249
+
209
250
  describe('target file transaction lock', () => {
210
251
  it('refuses a symlinked target parent before publishing a contender', () => {
211
252
  const root = mkdtempSync(path.join(os.tmpdir(), 'notifai-target-lock-'))
package/src/file-lock.ts CHANGED
@@ -22,6 +22,14 @@ import path from 'node:path'
22
22
  * process cannot remove a later owner's entry; because process liveness, rather
23
23
  * than age, decides recovery, a delayed holder is never mistaken for a dead one.
24
24
  *
25
+ * The rendezvous directory lives only as long as it has entries: the last
26
+ * contender out removes it, so an idle machine keeps no lock state. That makes
27
+ * the directory itself a contended resource. A contender registering while
28
+ * another releases can find it gone, half-deleted, or already replaced by a
29
+ * third contender, and none of those is a broken lock — so publication retries
30
+ * within the caller's deadline and pins its rendezvous only once its own entry
31
+ * is in the directory, which is the moment removal becomes impossible.
32
+ *
25
33
  * Acquisition is deliberately bounded because both harness hooks and logging
26
34
  * must fail open rather than hold an agent turn indefinitely.
27
35
  */
@@ -32,6 +40,8 @@ const ENTRY_PATTERN = /^(choosing|ticket-(\d+))-(\d+)-([0-9a-f]+)$/
32
40
  const lockSleep = new Int32Array(new SharedArrayBuffer(4))
33
41
 
34
42
  export type FileLockObservation =
43
+ /** The rendezvous has been scanned and this contender is about to publish. */
44
+ | { phase: 'registering'; entry: string }
35
45
  | { phase: 'choosing-published'; entry: string }
36
46
  | { phase: 'stale-entry'; entry: string }
37
47
  | { phase: 'waiting'; blockers: string[] }
@@ -125,6 +135,16 @@ function assertOwnedDirectory(directory: string): FileIdentity {
125
135
  return { dev: stat.dev, ino: stat.ino }
126
136
  }
127
137
 
138
+ /**
139
+ * The rendezvous a contender was using is no longer the directory at that path.
140
+ *
141
+ * Fatal once an entry is published — a contender's own entry makes the
142
+ * directory non-empty, so no cooperating releaser can remove it. Before
143
+ * publication it means only that a releaser removed an empty rendezvous while
144
+ * this contender was registering in it, which is ordinary contention.
145
+ */
146
+ class RendezvousReplaced extends Error {}
147
+
128
148
  function assertSameDirectory(directory: string, expected: FileIdentity): void {
129
149
  const current = lstatSync(directory)
130
150
  if (
@@ -133,7 +153,7 @@ function assertSameDirectory(directory: string, expected: FileIdentity): void {
133
153
  current.dev !== expected.dev ||
134
154
  current.ino !== expected.ino
135
155
  ) {
136
- throw new Error(`${directory} changed while the file lock was active.`)
156
+ throw new RendezvousReplaced(`${directory} changed while the file lock was active.`)
137
157
  }
138
158
  }
139
159
 
@@ -207,6 +227,24 @@ function publishEmpty(file: string): EntryIdentity {
207
227
  }
208
228
  }
209
229
 
230
+ /**
231
+ * True when a publication attempt lost its rendezvous rather than found a
232
+ * broken one.
233
+ *
234
+ * A releaser removes an empty rendezvous directory the moment the last entry
235
+ * goes, so a contender registering at that instant sees the removal from
236
+ * whichever side it reached first. All three are the same event: `ENOENT` when
237
+ * the directory is already gone, `EINVAL` when APFS reports a directory that is
238
+ * still being deleted, and a replaced identity when a third contender recreated
239
+ * it first. None of them says anything about the lock's integrity, because a
240
+ * contender with no published entry owns nothing yet.
241
+ */
242
+ function lostTheRendezvous(err: unknown): boolean {
243
+ if (err instanceof RendezvousReplaced) return true
244
+ const code = (err as NodeJS.ErrnoException).code
245
+ return code === 'ENOENT' || code === 'EINVAL'
246
+ }
247
+
210
248
  function publishChoosing(
211
249
  directory: string,
212
250
  name: string,
@@ -217,21 +255,33 @@ function publishChoosing(
217
255
  for (;;) {
218
256
  try {
219
257
  mkdirSync(directory, { recursive: true, mode: 0o700 })
220
- const directoryIdentity = assertOwnedDirectory(directory)
258
+ const registrar = assertOwnedDirectory(directory)
221
259
  chmodSync(directory, 0o700)
222
260
  // Recover before registration so a delayed stale cleanup can only name an
223
261
  // old owner's unique path, never this contender's future one.
224
- liveEntries(directory, directoryIdentity, observe)
262
+ liveEntries(directory, registrar, observe)
263
+ observe?.({ phase: 'registering', entry: name })
225
264
  const fileIdentity = publishEmpty(file)
226
- assertSameDirectory(directory, directoryIdentity)
265
+ // Pin the rendezvous that holds the entry, not the one scanned before it
266
+ // existed. `open` resolves the path atomically, so the entry lands in
267
+ // whichever directory the path named at that instant — and from then on
268
+ // that directory is non-empty, so no cooperating releaser can remove it.
269
+ // Reading the identity here therefore names the contender's real
270
+ // rendezvous even when the scanned one was removed and recreated
271
+ // underneath it.
272
+ const directoryIdentity = assertOwnedDirectory(directory)
273
+ if (!sameEntryIdentity(file, fileIdentity)) {
274
+ throw new RendezvousReplaced(`${directory} no longer holds ${name}.`)
275
+ }
227
276
  return { file, fileIdentity, directoryIdentity }
228
277
  } catch (err) {
229
- if ((err as NodeJS.ErrnoException).code !== 'ENOENT') throw err
230
- // The previous holder removed an empty rendezvous directory between our
231
- // mkdir and publication. Recreate it; once our entry exists, no cooperating
232
- // releaser can remove the non-empty directory underneath us.
278
+ if (!lostTheRendezvous(err)) throw err
279
+ // Recreate it and register again; the deadline, not the number of losses,
280
+ // decides when contention becomes a failure.
233
281
  if (Date.now() >= deadline) {
234
- throw new Error(`timed out publishing file lock contender in ${directory}`)
282
+ throw new Error(
283
+ `timed out publishing file lock contender in ${directory}: ${(err as Error).message}`,
284
+ )
235
285
  }
236
286
  Atomics.wait(lockSleep, 0, 0, FILE_LOCK_POLL_MS)
237
287
  }
@@ -1,34 +1,42 @@
1
1
  import { describe, expect, it } from 'vitest'
2
- import { HARNESS_CAPABILITIES, HARNESSES } from './harnesses.js'
2
+ import { HARNESS_CAPABILITIES, HARNESS_LABELS, HARNESSES } from './harnesses.js'
3
3
 
4
- describe('harness continuation contract', () => {
5
- it('requires one explicit native delivery contract for every shipped harness', () => {
4
+ /**
5
+ * This file used to hold a verbatim copy of the table below it, which meant
6
+ * changing a harness contract required changing the same words twice and the
7
+ * test could never disagree with the code — only with whoever forgot the second
8
+ * edit. What is worth asserting is the shape the rest of the CLI assumes.
9
+ */
10
+ describe('harness contract', () => {
11
+ it('describes every shipped harness, and only those', () => {
6
12
  expect(Object.keys(HARNESS_CAPABILITIES).sort()).toEqual([...HARNESSES].sort())
7
- expect(HARNESS_CAPABILITIES).toEqual({
8
- 'claude-code': {
9
- stopContinuation: 'decision-block',
10
- deliveryRoutes: ['hook-continuation', 'inbox-socket', 'cold-resume', 'hold-for-next-turn'],
11
- deliveryContract:
12
- 'the Stop hook returns at once and waits out of band, then posts the answer into this same session over its own inbox socket; a session that has stopped is resumed only once a liveness probe proves it stopped',
13
- },
14
- codex: {
15
- stopContinuation: 'decision-block',
16
- deliveryRoutes: ['hook-continuation', 'cold-resume', 'hold-for-next-turn'],
17
- deliveryContract:
18
- 'live Stop continuation while the turn is held; once it returns, a stopped thread is resumed behind its writer lock and anything else waits for the next turn',
19
- },
20
- cursor: {
21
- stopContinuation: 'unsupported',
22
- deliveryRoutes: ['unsupported'],
23
- deliveryContract:
24
- 'the hook can return a live follow-up, but the invoking shell exposes no exact conversation id; asynchronous ask is unsupported',
25
- },
26
- opencode: {
27
- stopContinuation: 'unsupported',
28
- deliveryRoutes: ['unsupported'],
29
- deliveryContract:
30
- 'no proven answer continuation after session.idle; use a blocking reply command',
31
- },
32
- })
13
+ expect(Object.keys(HARNESS_LABELS).sort()).toEqual([...HARNESSES].sort())
14
+ })
15
+
16
+ it('gives a harness that cannot continue a turn no route to pretend with', () => {
17
+ // `ask` fails closed on these, and the reason it can is that the two facts
18
+ // agree. A harness claiming a delivery route while declaring it cannot
19
+ // continue would accept an answer into a void.
20
+ for (const [harness, capability] of Object.entries(HARNESS_CAPABILITIES)) {
21
+ if (capability.stopContinuation !== 'unsupported') continue
22
+ expect(capability.deliveryRoutes, harness).toEqual(['unsupported'])
23
+ }
24
+ })
25
+
26
+ it('gives a harness that can continue a turn at least one real route', () => {
27
+ for (const [harness, capability] of Object.entries(HARNESS_CAPABILITIES)) {
28
+ if (capability.stopContinuation === 'unsupported') continue
29
+ expect(capability.deliveryRoutes.length, harness).toBeGreaterThan(0)
30
+ expect(capability.deliveryRoutes, harness).not.toContain('unsupported')
31
+ // The journal is the floor under every supported route: without it an
32
+ // answer that misses its turn has nowhere to wait.
33
+ expect(capability.deliveryRoutes, harness).toContain('hold-for-next-turn')
34
+ }
35
+ })
36
+
37
+ it('explains each harness in prose an operator can act on', () => {
38
+ for (const [harness, capability] of Object.entries(HARNESS_CAPABILITIES)) {
39
+ expect(capability.deliveryContract.length, harness).toBeGreaterThan(40)
40
+ }
33
41
  })
34
42
  })
package/src/harnesses.ts CHANGED
@@ -18,10 +18,7 @@ export type DeliveryRoute =
18
18
  | 'hold-for-next-turn'
19
19
  | 'unsupported'
20
20
 
21
- export type StopContinuation =
22
- | 'decision-block'
23
- | 'followup-message'
24
- | 'unsupported'
21
+ export type StopContinuation = 'decision-block' | 'unsupported'
25
22
 
26
23
  export interface HarnessCapability {
27
24
  /** How an answer is admitted to another turn in the already-open session. */