@raidiant/notifai 7.0.2 → 9.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 (157) hide show
  1. package/CHANGELOG.md +75 -0
  2. package/README.md +20 -13
  3. package/dist/atomic-file.d.ts +14 -0
  4. package/dist/atomic-file.js +27 -2
  5. package/dist/atomic-file.js.map +1 -1
  6. package/dist/cli-bin.d.ts +4 -0
  7. package/dist/cli-bin.js +62 -0
  8. package/dist/cli-bin.js.map +1 -0
  9. package/dist/cli-release.d.ts +10 -0
  10. package/dist/cli-release.js +75 -0
  11. package/dist/cli-release.js.map +1 -0
  12. package/dist/client.d.ts +1 -0
  13. package/dist/client.js +13 -3
  14. package/dist/client.js.map +1 -1
  15. package/dist/commands-auth.d.ts +6 -0
  16. package/dist/commands-auth.js +28 -3
  17. package/dist/commands-auth.js.map +1 -1
  18. package/dist/commands-config.js +22 -3
  19. package/dist/commands-config.js.map +1 -1
  20. package/dist/commands-core.d.ts +12 -0
  21. package/dist/commands-core.js +27 -3
  22. package/dist/commands-core.js.map +1 -1
  23. package/dist/commands-devices.d.ts +2 -1
  24. package/dist/commands-devices.js +20 -8
  25. package/dist/commands-devices.js.map +1 -1
  26. package/dist/commands-doctor.d.ts +1 -0
  27. package/dist/commands-doctor.js +85 -27
  28. package/dist/commands-doctor.js.map +1 -1
  29. package/dist/commands-guidance.d.ts +27 -0
  30. package/dist/commands-guidance.js +163 -0
  31. package/dist/commands-guidance.js.map +1 -0
  32. package/dist/commands-hooks.d.ts +16 -5
  33. package/dist/commands-hooks.js +433 -38
  34. package/dist/commands-hooks.js.map +1 -1
  35. package/dist/commands-init.d.ts +10 -18
  36. package/dist/commands-init.js +139 -54
  37. package/dist/commands-init.js.map +1 -1
  38. package/dist/commands-send-support.d.ts +17 -2
  39. package/dist/commands-send-support.js +66 -8
  40. package/dist/commands-send-support.js.map +1 -1
  41. package/dist/commands-send.d.ts +0 -1
  42. package/dist/commands-send.js +105 -39
  43. package/dist/commands-send.js.map +1 -1
  44. package/dist/commands-setup-proof.d.ts +8 -0
  45. package/dist/commands-setup-proof.js +31 -2
  46. package/dist/commands-setup-proof.js.map +1 -1
  47. package/dist/commands-skill.d.ts +11 -1
  48. package/dist/commands-skill.js +61 -11
  49. package/dist/commands-skill.js.map +1 -1
  50. package/dist/commands.d.ts +2 -1
  51. package/dist/commands.js +1 -0
  52. package/dist/commands.js.map +1 -1
  53. package/dist/config-schema.d.ts +1 -1
  54. package/dist/config-schema.js +30 -14
  55. package/dist/config-schema.js.map +1 -1
  56. package/dist/config.d.ts +24 -5
  57. package/dist/config.js +37 -8
  58. package/dist/config.js.map +1 -1
  59. package/dist/credentials.d.ts +10 -1
  60. package/dist/credentials.js +25 -12
  61. package/dist/credentials.js.map +1 -1
  62. package/dist/guidance-content.d.ts +50 -0
  63. package/dist/guidance-content.js +240 -0
  64. package/dist/guidance-content.js.map +1 -0
  65. package/dist/guidance.d.ts +69 -0
  66. package/dist/guidance.js +173 -0
  67. package/dist/guidance.js.map +1 -0
  68. package/dist/hook-adapter.js +37 -3
  69. package/dist/hook-adapter.js.map +1 -1
  70. package/dist/hooks.d.ts +68 -7
  71. package/dist/hooks.js +238 -92
  72. package/dist/hooks.js.map +1 -1
  73. package/dist/install-hooks.d.ts +10 -18
  74. package/dist/install-hooks.js +110 -23
  75. package/dist/install-hooks.js.map +1 -1
  76. package/dist/interactive.d.ts +1 -1
  77. package/dist/interactive.js +14 -9
  78. package/dist/interactive.js.map +1 -1
  79. package/dist/logging.d.ts +8 -1
  80. package/dist/logging.js +43 -2
  81. package/dist/logging.js.map +1 -1
  82. package/dist/main.js +5 -487
  83. package/dist/main.js.map +1 -1
  84. package/dist/native-skills.d.ts +19 -0
  85. package/dist/native-skills.js +30 -8
  86. package/dist/native-skills.js.map +1 -1
  87. package/dist/opencode-plugin.js +14 -1
  88. package/dist/opencode-plugin.js.map +1 -1
  89. package/dist/program.d.ts +49 -0
  90. package/dist/program.js +576 -0
  91. package/dist/program.js.map +1 -0
  92. package/dist/send.d.ts +12 -10
  93. package/dist/send.js +65 -29
  94. package/dist/send.js.map +1 -1
  95. package/dist/session-activation.d.ts +15 -0
  96. package/dist/session-activation.js +35 -0
  97. package/dist/session-activation.js.map +1 -0
  98. package/dist/session-labels.d.ts +9 -3
  99. package/dist/session-labels.js +38 -7
  100. package/dist/session-labels.js.map +1 -1
  101. package/dist/url-policy.d.ts +85 -0
  102. package/dist/url-policy.js +369 -0
  103. package/dist/url-policy.js.map +1 -0
  104. package/package.json +6 -4
  105. package/src/atomic-file.ts +36 -2
  106. package/src/cli-bin.test.ts +38 -0
  107. package/src/cli-bin.ts +67 -0
  108. package/src/cli-release.test.ts +46 -0
  109. package/src/cli-release.ts +78 -0
  110. package/src/client.test.ts +95 -0
  111. package/src/client.ts +16 -3
  112. package/src/commands-auth.ts +32 -3
  113. package/src/commands-config.ts +25 -2
  114. package/src/commands-core.ts +38 -3
  115. package/src/commands-devices.ts +28 -9
  116. package/src/commands-doctor.ts +116 -25
  117. package/src/commands-guidance.ts +216 -0
  118. package/src/commands-hooks.ts +513 -42
  119. package/src/commands-init.ts +170 -60
  120. package/src/commands-send-support.test.ts +156 -0
  121. package/src/commands-send-support.ts +72 -8
  122. package/src/commands-send.ts +123 -38
  123. package/src/commands-setup-proof.ts +36 -2
  124. package/src/commands-skill.ts +78 -15
  125. package/src/commands.test.ts +1490 -197
  126. package/src/commands.ts +6 -0
  127. package/src/config-schema.ts +32 -16
  128. package/src/config.test.ts +57 -2
  129. package/src/config.ts +49 -10
  130. package/src/credentials.test.ts +71 -3
  131. package/src/credentials.ts +29 -15
  132. package/src/guidance-content.ts +256 -0
  133. package/src/guidance.test.ts +299 -0
  134. package/src/guidance.ts +233 -0
  135. package/src/hook-adapter.test.ts +113 -1
  136. package/src/hook-adapter.ts +37 -3
  137. package/src/hooks.test.ts +783 -170
  138. package/src/hooks.ts +306 -131
  139. package/src/install-hooks.test.ts +135 -16
  140. package/src/install-hooks.ts +113 -25
  141. package/src/interactive.ts +14 -9
  142. package/src/logging.test.ts +66 -3
  143. package/src/logging.ts +45 -2
  144. package/src/main.ts +6 -614
  145. package/src/native-skills.test.ts +41 -1
  146. package/src/native-skills.ts +40 -6
  147. package/src/opencode-plugin.ts +14 -1
  148. package/src/program.test.ts +237 -0
  149. package/src/program.ts +751 -0
  150. package/src/send.test.ts +28 -0
  151. package/src/send.ts +81 -34
  152. package/src/session-activation.ts +45 -0
  153. package/src/session-labels.test.ts +120 -3
  154. package/src/session-labels.ts +38 -7
  155. package/src/skill.test.ts +153 -12
  156. package/src/trust-policy.test.ts +701 -0
  157. package/src/url-policy.ts +399 -0
@@ -0,0 +1,28 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import {
3
+ accidentalEscapedNewlineMessage,
4
+ hasAccidentalEscapedNewlines,
5
+ rejectAccidentalEscapedNewlines,
6
+ } from './send.js'
7
+
8
+ describe('accidental escaped newlines', () => {
9
+ it('catches a shell-quoted backslash-n body', () => {
10
+ expect(hasAccidentalEscapedNewlines('line1\\nline2')).toBe(true)
11
+ expect(rejectAccidentalEscapedNewlines('line1\\nline2', false)).toBe(
12
+ accidentalEscapedNewlineMessage(),
13
+ )
14
+ })
15
+
16
+ it('preserves real multiline Markdown', () => {
17
+ expect(hasAccidentalEscapedNewlines('line1\nline2')).toBe(false)
18
+ expect(rejectAccidentalEscapedNewlines('line1\nline2', false)).toBeNull()
19
+ })
20
+
21
+ it('lets an explicit flag keep visible backslash-n text', () => {
22
+ expect(rejectAccidentalEscapedNewlines('use \\n in the sample', true)).toBeNull()
23
+ })
24
+
25
+ it('does not treat an escaped backslash plus n as accidental', () => {
26
+ expect(hasAccidentalEscapedNewlines('escape as \\\\n')).toBe(false)
27
+ })
28
+ })
package/src/send.ts CHANGED
@@ -6,6 +6,7 @@ import {
6
6
  NOTIFICATION_SCHEMA_VERSION,
7
7
  QUESTION_TEXT_MAX_LENGTH,
8
8
  bannerExcerpt,
9
+ type AndroidOptionsT,
9
10
  type IosOptionsT,
10
11
  type LifecycleT,
11
12
  type MacosOptionsT,
@@ -30,7 +31,6 @@ export interface SendFlags {
30
31
  /** The one canonical Markdown body. */
31
32
  body: string
32
33
  subtitle?: string
33
- event?: string
34
34
  /** What this notification is; absent means update. */
35
35
  kind?: string
36
36
  project?: string
@@ -44,7 +44,8 @@ export interface SendFlags {
44
44
  collapseKey?: string
45
45
  sound?: string
46
46
  threadId?: string
47
- level?: string
47
+ /** Apple interruption level; null is reserved for internal callers that must suppress config. */
48
+ level?: string | null
48
49
  data?: string[]
49
50
  /** Ready media ids in author-supplied order. Commands resolve paths and URLs first. */
50
51
  image?: string[]
@@ -63,9 +64,9 @@ export interface SendFlags {
63
64
  * Answer labels; turns the reply into a closed question. One flag occurrence
64
65
  * per label, always — a label is a label, whatever characters it contains.
65
66
  */
66
- replyChoice?: string[]
67
+ choice?: string[]
67
68
  /** The user may select several of the offered answers. */
68
- replyMulti?: boolean
69
+ multi?: boolean
69
70
  /**
70
71
  * A full question set, overriding the single question the flags describe.
71
72
  * Not a user-facing flag: `ask --form` and the hooks build it.
@@ -78,6 +79,34 @@ export interface SendFlags {
78
79
  * questions and their retirements ride the wire as what they are.
79
80
  */
80
81
  lifecycle?: LifecycleT
82
+ /**
83
+ * Allow the two-character sequence backslash-n to be sent as visible text.
84
+ * Without this, a likely accidental escaped newline is refused.
85
+ */
86
+ literalBackslashN?: boolean
87
+ }
88
+
89
+ /** A backslash-n pair that was not itself escaped as a literal backslash. */
90
+ const ACCIDENTAL_ESCAPED_NEWLINE = /(?:^|[^\\])\\n/
91
+
92
+ export function hasAccidentalEscapedNewlines(body: string): boolean {
93
+ return ACCIDENTAL_ESCAPED_NEWLINE.test(body)
94
+ }
95
+
96
+ export function accidentalEscapedNewlineMessage(): string {
97
+ return [
98
+ 'This body contains the two-character sequence \\n, which is sent literally and shows as \\n on the device.',
99
+ 'To send a real line break, pass a multiline --body or --body-file -.',
100
+ 'If you meant the visible characters \\n, pass --literal-backslash-n.',
101
+ ].join('\n')
102
+ }
103
+
104
+ export function rejectAccidentalEscapedNewlines(
105
+ body: string | undefined,
106
+ allowLiteral: boolean | undefined,
107
+ ): string | null {
108
+ if (allowLiteral === true || body === undefined) return null
109
+ return hasAccidentalEscapedNewlines(body) ? accidentalEscapedNewlineMessage() : null
81
110
  }
82
111
 
83
112
  export type DraftBuild =
@@ -200,7 +229,8 @@ export function buildDraft(
200
229
  error: `Unknown sound "${sound}" — supported: ${CLI_SOUNDS.map((value) => `"${value}"`).join(', ')}.`,
201
230
  }
202
231
  }
203
- const level = flags.level ?? config.interruption_level.value
232
+ const level =
233
+ flags.level !== undefined ? flags.level : config.interruption_level.value
204
234
  if (
205
235
  level !== null &&
206
236
  level !== undefined &&
@@ -208,15 +238,30 @@ export function buildDraft(
208
238
  ) {
209
239
  return { ok: false, error: `Unknown interruption level "${level}".` }
210
240
  }
241
+ if (platform === 'android' && level !== null && level !== undefined) {
242
+ return {
243
+ ok: false,
244
+ error:
245
+ 'Android does not support caller-selected interruption levels. Remove --level or unset the configured interruption_level; kind, notification channels, and device settings own attention on Android.',
246
+ }
247
+ }
211
248
 
212
- const options: IosOptionsT & MacosOptionsT = {}
213
- if (sound === 'none') options.sound = null
214
- else if (sound !== null && sound !== undefined) {
215
- options.sound = sound as Exclude<(typeof CLI_SOUNDS)[number], 'none'>
249
+ const appleOptions: IosOptionsT & MacosOptionsT = {}
250
+ const androidOptions: AndroidOptionsT = {}
251
+ if (sound === 'none') {
252
+ appleOptions.sound = null
253
+ androidOptions.sound = null
254
+ } else if (sound !== null && sound !== undefined) {
255
+ const semanticSound = sound as Exclude<(typeof CLI_SOUNDS)[number], 'none'>
256
+ appleOptions.sound = semanticSound
257
+ androidOptions.sound = semanticSound
258
+ }
259
+ if (flags.threadId !== undefined) {
260
+ appleOptions.thread_id = flags.threadId
261
+ androidOptions.thread_id = flags.threadId
216
262
  }
217
- if (flags.threadId !== undefined) options.thread_id = flags.threadId
218
263
  if (level !== null && level !== undefined) {
219
- options.interruption_level = level as (typeof INTERRUPTION_LEVELS)[number]
264
+ appleOptions.interruption_level = level as (typeof INTERRUPTION_LEVELS)[number]
220
265
  }
221
266
  if (flags.data?.length) {
222
267
  const data: Record<string, string> = {}
@@ -225,7 +270,8 @@ export function buildDraft(
225
270
  if (eq <= 0) return { ok: false, error: `--data expects key=value, got "${pair}".` }
226
271
  data[pair.slice(0, eq)] = pair.slice(eq + 1)
227
272
  }
228
- options.custom_data = data
273
+ appleOptions.custom_data = data
274
+ androidOptions.custom_data = data
229
275
  }
230
276
 
231
277
  const ttl = flags.ttl ?? config.ttl_seconds.value
@@ -245,15 +291,15 @@ export function buildDraft(
245
291
 
246
292
  // Ids are derived from labels so an agent writing a one-liner does not have
247
293
  // to invent them, but they stay the stable thing it branches on afterwards.
248
- const choices = parseChoices(flags.replyChoice)
294
+ const choices = parseChoices(flags.choice)
249
295
  if (choices === 'invalid') {
250
296
  return {
251
297
  ok: false,
252
- error: REPLY_CHOICE_USAGE,
298
+ error: CHOICE_USAGE,
253
299
  }
254
300
  }
255
- if (flags.replyMulti && choices === null && flags.questions === undefined) {
256
- return { ok: false, error: '--reply-multi needs answers to select between; add --reply-choice.' }
301
+ if (flags.multi && choices === null && flags.questions === undefined) {
302
+ return { ok: false, error: '--multi needs answers to select between; add --choice.' }
257
303
  }
258
304
  // A single reply question is the first readable block of the canonical body.
259
305
  // Context may follow after a blank line without changing what the user answers.
@@ -271,9 +317,25 @@ export function buildDraft(
271
317
  }
272
318
  }
273
319
 
320
+ const hasAppleOptions = Object.keys(appleOptions).length > 0
321
+ const hasAndroidOptions = Object.keys(androidOptions).length > 0
322
+ let platformOptions: NonNullable<NotificationDraftT['platform']> | undefined
323
+ if (flags.platform === undefined) {
324
+ if (hasAppleOptions || hasAndroidOptions) {
325
+ platformOptions = {
326
+ ...(hasAppleOptions ? { ios: appleOptions, macos: appleOptions } : {}),
327
+ ...(hasAndroidOptions ? { android: androidOptions } : {}),
328
+ }
329
+ }
330
+ } else if (platform === 'android') {
331
+ if (hasAndroidOptions) platformOptions = { android: androidOptions }
332
+ } else if (hasAppleOptions) {
333
+ platformOptions =
334
+ platform === 'ios' ? { ios: appleOptions } : { macos: appleOptions }
335
+ }
336
+
274
337
  const draft: NotificationDraftT = {
275
338
  schema_version: NOTIFICATION_SCHEMA_VERSION,
276
- ...(flags.event !== undefined ? { event: flags.event } : {}),
277
339
  ...(flags.kind !== undefined
278
340
  ? { kind: flags.kind as (typeof NOTIFICATION_KINDS)[number] }
279
341
  : {}),
@@ -297,19 +359,13 @@ export function buildDraft(
297
359
  id: 'q1',
298
360
  text: derivedQuestion,
299
361
  ...(choices !== null ? { choices } : {}),
300
- ...(flags.replyMulti ? { multi: true } : {}),
362
+ ...(flags.multi ? { multi: true } : {}),
301
363
  },
302
364
  ],
303
365
  },
304
366
  }
305
367
  : {}),
306
- ...(Object.keys(options).length > 0
307
- ? flags.platform === undefined
308
- ? { platform: { ios: options, macos: options } }
309
- : platform === 'ios'
310
- ? { platform: { ios: options } }
311
- : { platform: { macos: options } }
312
- : {}),
368
+ ...(platformOptions !== undefined ? { platform: platformOptions } : {}),
313
369
  }
314
370
  return { ok: true, draft, platform }
315
371
  }
@@ -369,19 +425,10 @@ export function parseChoices(
369
425
  }
370
426
 
371
427
  /** Message shared by every surface that accepts choice labels. */
372
- /** `ask` spells this flag `--choice`. */
373
428
  export const CHOICE_USAGE =
374
429
  'Offer 2-6 answers, one --choice flag per answer, unique once slugified — ' +
375
430
  'e.g. --choice "Yes, ship it" --choice "Not yet".'
376
431
 
377
- /**
378
- * `send` spells the same flag `--reply-choice`, and a remedy that names the
379
- * other command's flag reproduces the error it is trying to fix.
380
- */
381
- export const REPLY_CHOICE_USAGE =
382
- 'Offer 2-6 answers, one --reply-choice flag per answer, unique once slugified — ' +
383
- 'e.g. --reply-choice "Yes, ship it" --reply-choice "Not yet".'
384
-
385
432
  export function slugify(label: string): string {
386
433
  return label
387
434
  .toLowerCase()
@@ -0,0 +1,45 @@
1
+ import type { HookHarness } from './hooks.js'
2
+
3
+ /**
4
+ * Model-visible session bootstrap. It names the installed capability and its
5
+ * authoritative sources without restating any notification policy that the
6
+ * user may have overridden.
7
+ */
8
+ export const SESSION_ACTIVATION_CONTEXT =
9
+ 'Notifai is active for this session, even when the user did not mention it. ' +
10
+ 'Before beginning task work, use the Notifai skill and run `notifai guidance`; its resolved guidance governs notification decisions for Agent Events. ' +
11
+ 'Missing skill or CLI readiness is handled through `notifai init` and its diagnosis. ' +
12
+ 'For delegated work, the parent owns User-visible Notification Requests unless it explicitly delegates that ownership; workers report Agent Events to the parent and do not send independently.'
13
+
14
+ /** One lifecycle meaning, encoded for each harness output contract. */
15
+ export function sessionActivationOutput(
16
+ harness: HookHarness | undefined,
17
+ hookEventName: 'SessionStart' | 'SubagentStart' | 'UserPromptSubmit',
18
+ ): string | undefined {
19
+ if (harness === 'cursor') {
20
+ return JSON.stringify({ additional_context: SESSION_ACTIVATION_CONTEXT })
21
+ }
22
+ if (harness === 'claude-code' || harness === 'codex') {
23
+ return JSON.stringify({
24
+ hookSpecificOutput: {
25
+ hookEventName,
26
+ additionalContext: SESSION_ACTIVATION_CONTEXT,
27
+ },
28
+ })
29
+ }
30
+ return undefined
31
+ }
32
+
33
+ /**
34
+ * Cursor currently accepts sessionStart context without reliably delivering it
35
+ * to the model. Its native post-Stop follow-up is the narrow fallback and is
36
+ * claimed once per conversation by the hook-state layer.
37
+ */
38
+ export function cursorStopActivationOutput(): string {
39
+ return JSON.stringify({
40
+ followup_message: SESSION_ACTIVATION_CONTEXT.replace(
41
+ 'Before beginning task work,',
42
+ 'Before finalizing the Agent Event from the turn that just ended,',
43
+ ),
44
+ })
45
+ }
@@ -46,6 +46,38 @@ describe('semantic session labels', () => {
46
46
  ).toEqual({ ok: true, label: 'Semantic session names', source: 'explicit' })
47
47
  })
48
48
 
49
+ it('prefers the environment-supplied title over an explicit label, making the flag safe everywhere', () => {
50
+ // An agent must never need to know which environments name their own
51
+ // sessions: where one does, the explicit label is simply not used.
52
+ const { env, now } = fixture()
53
+ expect(
54
+ resolveSessionLabel({
55
+ env,
56
+ now,
57
+ sessionId: 'managed-session',
58
+ harness: 'opencode',
59
+ harnessLabel: 'NotifAI question lifecycle',
60
+ explicitLabel: 'Agent-chosen name',
61
+ }),
62
+ ).toEqual({ ok: true, label: 'NotifAI question lifecycle', source: 'harness' })
63
+ })
64
+
65
+ it('upgrades a frozen fallback to the environment title even when an explicit label rides along', () => {
66
+ const { env, now } = fixture()
67
+ const frozen = resolveSessionLabel({ env, now, sessionId: 'late-title' })
68
+ expect(frozen).toMatchObject({ ok: true, source: 'fallback' })
69
+ expect(
70
+ resolveSessionLabel({
71
+ env,
72
+ now: now + 1,
73
+ sessionId: 'late-title',
74
+ harness: 'opencode',
75
+ harnessLabel: 'Worktree semantic title',
76
+ explicitLabel: 'Agent-chosen name',
77
+ }),
78
+ ).toEqual({ ok: true, label: 'Worktree semantic title', source: 'harness' })
79
+ })
80
+
49
81
  it('uses a trusted harness title when no explicit task label exists', () => {
50
82
  const { env, now } = fixture()
51
83
  expect(
@@ -59,7 +91,7 @@ describe('semantic session labels', () => {
59
91
  ).toEqual({ ok: true, label: 'NotifAI question lifecycle', source: 'harness' })
60
92
  })
61
93
 
62
- it('does not replace a frozen fallback when a managed semantic title appears later', () => {
94
+ it('replaces a frozen generated fallback when a managed semantic title appears later', () => {
63
95
  const { env, now } = fixture()
64
96
  const frozen = resolveSessionLabel({
65
97
  env,
@@ -77,6 +109,82 @@ describe('semantic session labels', () => {
77
109
  harness: 'claude-code',
78
110
  harnessLabel: 'Worker - semantic session implementation',
79
111
  }),
112
+ ).toEqual({
113
+ ok: true,
114
+ label: 'Worker - semantic session implementation',
115
+ source: 'harness',
116
+ })
117
+ })
118
+
119
+ it('replaces a frozen generated fallback with a later explicit task name, once', () => {
120
+ const { env, now } = fixture()
121
+ const frozen = resolveSessionLabel({ env, now, sessionId: 'late-named', harness: 'codex' })
122
+ expect(frozen.ok && frozen.source).toBe('fallback')
123
+
124
+ expect(
125
+ resolveSessionLabel({
126
+ env,
127
+ now: now + 1_000,
128
+ sessionId: 'late-named',
129
+ harness: 'codex',
130
+ explicitLabel: 'Fix checkout retries',
131
+ }),
132
+ ).toEqual({ ok: true, label: 'Fix checkout retries', source: 'explicit' })
133
+ expect(
134
+ resolveSessionLabel({
135
+ env,
136
+ now: now + 2_000,
137
+ sessionId: 'late-named',
138
+ harness: 'codex',
139
+ explicitLabel: 'A different later name',
140
+ }),
141
+ ).toEqual({ ok: true, label: 'Fix checkout retries', source: 'explicit' })
142
+ })
143
+
144
+ it('disambiguates an upgraded name against every other stored session', () => {
145
+ const { env, now } = fixture()
146
+ expect(
147
+ resolveSessionLabel({
148
+ env,
149
+ now,
150
+ sessionId: 'other-session',
151
+ harness: 'codex',
152
+ explicitLabel: 'Release preparation',
153
+ }),
154
+ ).toEqual({ ok: true, label: 'Release preparation', source: 'explicit' })
155
+ const frozen = resolveSessionLabel({ env, now, sessionId: 'upgraded', harness: 'codex' })
156
+ expect(frozen.ok && frozen.source).toBe('fallback')
157
+
158
+ expect(
159
+ resolveSessionLabel({
160
+ env,
161
+ now: now + 1_000,
162
+ sessionId: 'upgraded',
163
+ harness: 'codex',
164
+ explicitLabel: 'Release preparation',
165
+ }),
166
+ ).toEqual({ ok: true, label: 'Release preparation · Codex', source: 'explicit' })
167
+ })
168
+
169
+ it('rejects an invalid explicit name instead of silently keeping the fallback', () => {
170
+ const { env, now } = fixture()
171
+ const frozen = resolveSessionLabel({ env, now, sessionId: 'still-fallback', harness: 'codex' })
172
+ expect(frozen.ok && frozen.source).toBe('fallback')
173
+
174
+ expect(
175
+ resolveSessionLabel({
176
+ env,
177
+ now: now + 1_000,
178
+ sessionId: 'still-fallback',
179
+ harness: 'codex',
180
+ explicitLabel: 'x'.repeat(65),
181
+ }),
182
+ ).toEqual({
183
+ ok: false,
184
+ error: '--session-label (or NOTIFAI_SESSION_LABEL) must be at most 64 characters.',
185
+ })
186
+ expect(
187
+ resolveSessionLabel({ env, now: now + 2_000, sessionId: 'still-fallback', harness: 'codex' }),
80
188
  ).toEqual(frozen)
81
189
  })
82
190
 
@@ -142,7 +250,7 @@ describe('semantic session labels', () => {
142
250
  expect(stored).not.toContain('opaque-thread-1234567890')
143
251
  })
144
252
 
145
- it('migrates a frozen date fallback without reopening semantic precedence', () => {
253
+ it('migrates a frozen date fallback when no semantic candidate exists', () => {
146
254
  const { env, now } = fixture()
147
255
  const file = path.join(stateDir(env), 'session-labels.json')
148
256
  expect(
@@ -164,10 +272,19 @@ describe('semantic session labels', () => {
164
272
  now: now + 1_000,
165
273
  sessionId: 'opaque-thread-1234567890',
166
274
  harness: 'codex',
167
- explicitLabel: 'Late semantic override',
168
275
  }),
169
276
  ).toEqual({ ok: true, label: 'Ivory Koala', source: 'fallback' })
170
277
  expect(readFileSync(file, 'utf8')).not.toContain('Codex session ·')
278
+
279
+ expect(
280
+ resolveSessionLabel({
281
+ env,
282
+ now: now + 2_000,
283
+ sessionId: 'opaque-thread-1234567890',
284
+ harness: 'codex',
285
+ explicitLabel: 'Late semantic override',
286
+ }),
287
+ ).toEqual({ ok: true, label: 'Late semantic override', source: 'explicit' })
171
288
  })
172
289
 
173
290
  it('disambiguates repeated semantic titles without changing session identity', () => {
@@ -289,9 +289,15 @@ function uniqueLabel(
289
289
  /**
290
290
  * Freeze the first accepted human name for one immutable session.
291
291
  *
292
- * Explicit agent/User input wins, then a title supplied by a trusted harness
293
- * adapter, then a generated fallback. Later sends reuse the frozen value
294
- * even when their branch, worktree, title candidate, or notification changes.
292
+ * A title supplied by a trusted harness adapter wins, then explicit
293
+ * agent/User input, then a generated fallback. The environment-first order is
294
+ * what makes `--session-label` safe to pass unconditionally: an agent never
295
+ * needs to know which environments name their own sessions, because where one
296
+ * does, the explicit label is simply not used. A frozen generated fallback is
297
+ * the one exception to permanence: it names nothing, so the first semantic
298
+ * candidate to arrive later — a trusted harness title first, then explicit —
299
+ * replaces it once. Semantic names then stay frozen even when their branch,
300
+ * worktree, title candidate, or notification changes.
295
301
  */
296
302
  export function resolveSessionLabel(input: SessionLabelInput): SessionLabelResolution {
297
303
  const now = input.now ?? Date.now()
@@ -303,14 +309,39 @@ export function resolveSessionLabel(input: SessionLabelInput): SessionLabelResol
303
309
  const store = readStore(file)
304
310
  const existing = store.sessions[key]
305
311
  if (existing !== undefined) {
306
- if (!isLegacyDateFallback(existing)) {
307
- return { ok: true, label: existing.label, source: existing.source }
308
- }
309
312
  const used = new Set(
310
313
  Object.entries(store.sessions)
311
314
  .filter(([storedKey]) => storedKey !== key)
312
315
  .map(([, record]) => collisionKey(record.label)),
313
316
  )
317
+ if (existing.source === 'fallback') {
318
+ const explicit = explicitCandidate(input.explicitLabel, input)
319
+ if (explicit !== null && 'error' in explicit) {
320
+ return { ok: false, error: explicit.error }
321
+ }
322
+ const native = harnessLabelIsPending(input.harnessLabel, input)
323
+ ? null
324
+ : harnessCandidate(input.harnessLabel, input)
325
+ const upgrade = native ?? explicit
326
+ if (upgrade !== null) {
327
+ const label = uniqueLabel(upgrade.label, upgrade.source, input.harness, now, used)
328
+ store.sessions[key] = {
329
+ label,
330
+ source: upgrade.source,
331
+ first_seen_at: existing.first_seen_at,
332
+ ...(input.harness === undefined
333
+ ? existing.harness === undefined
334
+ ? {}
335
+ : { harness: existing.harness }
336
+ : { harness: input.harness }),
337
+ }
338
+ writeStore(file, store)
339
+ return { ok: true, label, source: upgrade.source }
340
+ }
341
+ }
342
+ if (!isLegacyDateFallback(existing)) {
343
+ return { ok: true, label: existing.label, source: existing.source }
344
+ }
314
345
  const label = uniqueLabel(
315
346
  generatedSessionLabel(input.sessionId),
316
347
  'fallback',
@@ -337,7 +368,7 @@ export function resolveSessionLabel(input: SessionLabelInput): SessionLabelResol
337
368
  }
338
369
  }
339
370
  const candidate: SessionLabelCandidate =
340
- explicit ?? native ?? fallbackCandidate(input.sessionId)
371
+ native ?? explicit ?? fallbackCandidate(input.sessionId)
341
372
  const used = new Set(
342
373
  Object.values(store.sessions).map((record) => collisionKey(record.label)),
343
374
  )