@raidiant/notifai 9.2.2 → 10.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 (149) hide show
  1. package/CHANGELOG.md +45 -0
  2. package/README.md +8 -5
  3. package/dist/claude-wake.js +11 -0
  4. package/dist/claude-wake.js.map +1 -1
  5. package/dist/client.d.ts +3 -1
  6. package/dist/client.js +2 -0
  7. package/dist/client.js.map +1 -1
  8. package/dist/codex-wake.js +9 -0
  9. package/dist/codex-wake.js.map +1 -1
  10. package/dist/commands-agent-sessions.d.ts +6 -0
  11. package/dist/commands-agent-sessions.js +51 -0
  12. package/dist/commands-agent-sessions.js.map +1 -0
  13. package/dist/commands-config.js +11 -4
  14. package/dist/commands-config.js.map +1 -1
  15. package/dist/commands-core.d.ts +1 -1
  16. package/dist/commands-doctor.js +83 -6
  17. package/dist/commands-doctor.js.map +1 -1
  18. package/dist/commands-harness-context.d.ts +15 -2
  19. package/dist/commands-harness-context.js +32 -1
  20. package/dist/commands-harness-context.js.map +1 -1
  21. package/dist/commands-hook-shape.d.ts +4 -3
  22. package/dist/commands-hook-shape.js +19 -10
  23. package/dist/commands-hook-shape.js.map +1 -1
  24. package/dist/commands-hooks.d.ts +4 -3
  25. package/dist/commands-hooks.js +80 -23
  26. package/dist/commands-hooks.js.map +1 -1
  27. package/dist/commands-init.js +12 -1
  28. package/dist/commands-init.js.map +1 -1
  29. package/dist/commands-project.d.ts +4 -0
  30. package/dist/commands-project.js +41 -0
  31. package/dist/commands-project.js.map +1 -0
  32. package/dist/commands-send-support.js +0 -4
  33. package/dist/commands-send-support.js.map +1 -1
  34. package/dist/commands-send.js +12 -6
  35. package/dist/commands-send.js.map +1 -1
  36. package/dist/commands-skill.js +58 -0
  37. package/dist/commands-skill.js.map +1 -1
  38. package/dist/commands-sounds.d.ts +12 -0
  39. package/dist/commands-sounds.js +35 -0
  40. package/dist/commands-sounds.js.map +1 -0
  41. package/dist/commands.d.ts +3 -0
  42. package/dist/commands.js +3 -0
  43. package/dist/commands.js.map +1 -1
  44. package/dist/config-schema.js +7 -4
  45. package/dist/config-schema.js.map +1 -1
  46. package/dist/config.d.ts +6 -7
  47. package/dist/config.js +9 -9
  48. package/dist/config.js.map +1 -1
  49. package/dist/credentials.js +4 -1
  50. package/dist/credentials.js.map +1 -1
  51. package/dist/guidance-content.d.ts +3 -0
  52. package/dist/guidance-content.js +20 -0
  53. package/dist/guidance-content.js.map +1 -1
  54. package/dist/guidance-render.js +3 -3
  55. package/dist/guidance-render.js.map +1 -1
  56. package/dist/harnesses.d.ts +49 -7
  57. package/dist/harnesses.js +90 -24
  58. package/dist/harnesses.js.map +1 -1
  59. package/dist/hooks.d.ts +28 -52
  60. package/dist/hooks.js +393 -152
  61. package/dist/hooks.js.map +1 -1
  62. package/dist/install-hooks.d.ts +17 -39
  63. package/dist/install-hooks.js +21 -41
  64. package/dist/install-hooks.js.map +1 -1
  65. package/dist/invocation-context.d.ts +2 -2
  66. package/dist/orca-session-title.d.ts +2 -2
  67. package/dist/orca-session-title.js +3 -4
  68. package/dist/orca-session-title.js.map +1 -1
  69. package/dist/program.d.ts +6 -1
  70. package/dist/program.js +46 -5
  71. package/dist/program.js.map +1 -1
  72. package/dist/project-enablement.d.ts +15 -0
  73. package/dist/project-enablement.js +87 -0
  74. package/dist/project-enablement.js.map +1 -0
  75. package/dist/question-timing.d.ts +46 -0
  76. package/dist/question-timing.js +48 -0
  77. package/dist/question-timing.js.map +1 -0
  78. package/dist/readiness.d.ts +7 -0
  79. package/dist/readiness.js.map +1 -1
  80. package/dist/send.d.ts +2 -0
  81. package/dist/send.js +17 -15
  82. package/dist/send.js.map +1 -1
  83. package/dist/session-activation.d.ts +1 -1
  84. package/dist/session-activation.js +3 -4
  85. package/dist/session-activation.js.map +1 -1
  86. package/dist/session-labels.d.ts +18 -2
  87. package/dist/session-labels.js +79 -8
  88. package/dist/session-labels.js.map +1 -1
  89. package/dist/sound-ref.d.ts +23 -0
  90. package/dist/sound-ref.js +28 -0
  91. package/dist/sound-ref.js.map +1 -0
  92. package/package.json +3 -3
  93. package/src/claude-wake.test.ts +1 -0
  94. package/src/claude-wake.ts +10 -0
  95. package/src/client.test.ts +12 -0
  96. package/src/client.ts +8 -0
  97. package/src/codex-wake.test.ts +1 -0
  98. package/src/codex-wake.ts +9 -0
  99. package/src/commands-agent-sessions.ts +72 -0
  100. package/src/commands-config.ts +10 -4
  101. package/src/commands-core.ts +1 -1
  102. package/src/commands-doctor.ts +96 -8
  103. package/src/commands-harness-context.ts +48 -2
  104. package/src/commands-hook-shape.ts +26 -10
  105. package/src/commands-hooks.ts +95 -31
  106. package/src/commands-init.ts +11 -1
  107. package/src/commands-project.ts +47 -0
  108. package/src/commands-send-support.ts +0 -4
  109. package/src/commands-send.ts +10 -5
  110. package/src/commands-skill.test.ts +44 -0
  111. package/src/commands-skill.ts +57 -0
  112. package/src/commands-sounds.test.ts +106 -0
  113. package/src/commands-sounds.ts +49 -0
  114. package/src/commands.test.ts +386 -23
  115. package/src/commands.ts +10 -0
  116. package/src/config-schema.test.ts +1 -0
  117. package/src/config-schema.ts +7 -4
  118. package/src/config.test.ts +44 -23
  119. package/src/config.ts +13 -15
  120. package/src/credentials.test.ts +20 -0
  121. package/src/credentials.ts +4 -1
  122. package/src/fixtures/hermes-0.20.6-classic-cli-local.json +50 -0
  123. package/src/guidance-content.ts +22 -0
  124. package/src/guidance-render.ts +3 -3
  125. package/src/harness-integration.test.ts +212 -0
  126. package/src/harnesses.test.ts +107 -8
  127. package/src/harnesses.ts +111 -30
  128. package/src/hook-adapter.test.ts +27 -8
  129. package/src/hooks.test.ts +812 -83
  130. package/src/hooks.ts +529 -185
  131. package/src/install-hooks.test.ts +13 -16
  132. package/src/install-hooks.ts +40 -58
  133. package/src/invocation-context.ts +2 -2
  134. package/src/orca-session-title.test.ts +8 -49
  135. package/src/orca-session-title.ts +3 -5
  136. package/src/program.test.ts +45 -0
  137. package/src/program.ts +60 -4
  138. package/src/project-enablement.test.ts +55 -0
  139. package/src/project-enablement.ts +99 -0
  140. package/src/question-timing.ts +56 -0
  141. package/src/readiness.ts +8 -1
  142. package/src/send.ts +18 -20
  143. package/src/session-activation.ts +3 -4
  144. package/src/session-labels.test.ts +58 -9
  145. package/src/session-labels.ts +95 -12
  146. package/src/skill.test.ts +14 -3
  147. package/src/sound-ref.test.ts +31 -0
  148. package/src/sound-ref.ts +34 -0
  149. package/src/ui/config-view.test.ts +1 -0
@@ -0,0 +1,55 @@
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
+ })
@@ -0,0 +1,99 @@
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
+ }
@@ -0,0 +1,56 @@
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
package/src/readiness.ts CHANGED
@@ -34,7 +34,14 @@ export type Remedy =
34
34
  * the CLI's to launch but the human's to complete, so an agent must still
35
35
  * treat it as out of reach.
36
36
  */
37
- | { by: 'user-here'; summary: string; command: string; interactive?: boolean }
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
+ }
38
45
  /**
39
46
  * A human somewhere else entirely — phone in hand, app store, permission
40
47
  * dialog. The class the old init could only print a sentence about, and the
package/src/send.ts CHANGED
@@ -1,5 +1,4 @@
1
1
  import {
2
- CLI_SOUNDS,
3
2
  COLLAPSE_KEY_MAX_BYTES,
4
3
  INTERRUPTION_LEVELS,
5
4
  MEDIA_MAX_ITEMS,
@@ -13,9 +12,6 @@ import {
13
12
  type MediaItemT,
14
13
  type NotificationDraftT,
15
14
  NOTIFICATION_KINDS,
16
- DEFAULT_NOTIFICATION_KIND,
17
- defaultSoundForKind,
18
- type NotificationKind,
19
15
  PLATFORMS,
20
16
  type Platform,
21
17
  type QuestionT,
@@ -23,8 +19,7 @@ import {
23
19
  type SubmissionReceipt,
24
20
  } from '@raidiant/notifai-protocol'
25
21
  import type { CliConfig } from './config.js'
26
-
27
- type CliSound = (typeof CLI_SOUNDS)[number]
22
+ import { isCliSoundRef, unknownSoundMessage } from './sound-ref.js'
28
23
 
29
24
  export interface SendFlags {
30
25
  title: string
@@ -34,6 +29,8 @@ export interface SendFlags {
34
29
  /** What this notification is; absent means update. */
35
30
  kind?: string
36
31
  project?: string
32
+ /** Deliberately omit Project identity even when config or cwd could supply one. */
33
+ projectless?: boolean
37
34
  /** Opaque exact Agent Session override. */
38
35
  sessionId?: string
39
36
  /** Human-readable Agent Session label override. */
@@ -215,18 +212,15 @@ export function buildDraft(
215
212
  }
216
213
  }
217
214
 
218
- // Kind states what happened; the sound table turns that truth into the
219
- // attention it deserves, so an honest sender gets the right sound without
220
- // reasoning about audio. An explicit --sound and the user's saved preference
221
- // both outrank it, and `none` still means silent.
222
- const kindForSound: NotificationKind = flags.reply
223
- ? 'question'
224
- : ((flags.kind as NotificationKind | undefined) ?? DEFAULT_NOTIFICATION_KIND)
225
- const sound = flags.sound ?? config.sound.value ?? defaultSoundForKind(kindForSound)
226
- if (sound !== null && sound !== undefined && !CLI_SOUNDS.includes(sound as CliSound)) {
215
+ // Explicit --sound and the saved sound key outrank kind. The server is the
216
+ // one that applies kind, Project, and Account maps — stamping a kind default
217
+ // here would look like an explicit choice and skip those layers. `none` is
218
+ // still silent.
219
+ const sound = flags.sound !== undefined ? flags.sound : config.sound.value
220
+ if (sound !== null && sound !== undefined && !isCliSoundRef(sound)) {
227
221
  return {
228
222
  ok: false,
229
- error: `Unknown sound "${sound}" — supported: ${CLI_SOUNDS.map((value) => `"${value}"`).join(', ')}.`,
223
+ error: unknownSoundMessage(sound),
230
224
  }
231
225
  }
232
226
  const level =
@@ -252,9 +246,8 @@ export function buildDraft(
252
246
  appleOptions.sound = null
253
247
  androidOptions.sound = null
254
248
  } 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
249
+ appleOptions.sound = sound
250
+ androidOptions.sound = sound
258
251
  }
259
252
  if (flags.threadId !== undefined) {
260
253
  appleOptions.thread_id = flags.threadId
@@ -287,7 +280,12 @@ export function buildDraft(
287
280
  }
288
281
  }
289
282
 
290
- const project = flags.project ?? config.project.value ?? invocation.inferredProject
283
+ if (flags.projectless === true && flags.project !== undefined) {
284
+ return { ok: false, error: 'Choose either --project or --projectless, not both.' }
285
+ }
286
+ const project = flags.projectless === true
287
+ ? undefined
288
+ : flags.project ?? config.project.value ?? invocation.inferredProject
291
289
 
292
290
  // Ids are derived from labels so an agent writing a one-liner does not have
293
291
  // to invent them, but they stay the stable thing it branches on afterwards.
@@ -3,12 +3,11 @@ import { Buffer } from 'node:buffer'
3
3
  import { GUIDANCE_CONTEXT_MAX_BYTES, boundedEffectiveGuidance } from './guidance-render.js'
4
4
 
5
5
  const ROOT_OWNERSHIP =
6
- 'Notifai is active for this session. The effective, provenance-marked guidance below governs Notification Requests. ' +
7
- 'Missing CLI readiness is handled through `notifai init --json`. ' +
8
- 'The root agent owns User-visible Notification Requests unless it explicitly delegates that ownership; workers report Agent Events to the parent.'
6
+ 'Notifai is enabled for this Project. The effective, provenance-marked guidance below governs Notification Requests. ' +
7
+ 'Missing CLI readiness is handled through `notifai init --json`.'
9
8
 
10
9
  export const WORKER_ACTIVATION_CONTEXT =
11
- 'Notifai worker context: report Agent Events to the parent; do not send User-visible Notification Requests unless the parent explicitly delegated that ownership. If delegated, load the Notifai skill and run `notifai guidance` before composing one.'
10
+ 'Notifai worker context: an Agent Event is a meaningful occurrence in your work; a Notification Request is a deliberate User-visible message about one. Report Agent Events to the parent and do not send Notification Requests unless the parent explicitly delegated that ownership. If delegated, load the Notifai skill and run `notifai guidance` before composing one.'
12
11
 
13
12
  export const MISSING_LIFECYCLE_GUIDANCE_CONTEXT =
14
13
  'Notifai is active, but lifecycle guidance could not be loaded. Run `notifai guidance` once before deciding whether or how to send a Notification Request.'
@@ -1,9 +1,20 @@
1
- import { existsSync, mkdirSync, mkdtempSync, readFileSync, writeFileSync } from 'node:fs'
1
+ import {
2
+ existsSync,
3
+ mkdirSync,
4
+ mkdtempSync,
5
+ readFileSync,
6
+ readdirSync,
7
+ writeFileSync,
8
+ } from 'node:fs'
2
9
  import os from 'node:os'
3
10
  import path from 'node:path'
4
11
  import { describe, expect, it } from 'vitest'
5
12
  import { stateDir } from './config.js'
6
- import { formatSessionFirstSeen, resolveSessionLabel } from './session-labels.js'
13
+ import {
14
+ formatSessionFirstSeen,
15
+ renameStoredSessionLabel,
16
+ resolveSessionLabel,
17
+ } from './session-labels.js'
7
18
 
8
19
  function fixture(): { env: NodeJS.ProcessEnv; now: number } {
9
20
  const root = mkdtempSync(path.join(os.tmpdir(), 'notifai-session-labels-'))
@@ -416,26 +427,64 @@ describe('semantic session labels', () => {
416
427
  })
417
428
  })
418
429
 
419
- it('fails closed instead of replacing an unreadable name store', () => {
430
+ it('isolates an unreadable name store before creating a clean one', () => {
420
431
  const { env, now } = fixture()
421
432
  const file = path.join(stateDir(env), 'session-labels.json')
422
433
  mkdirSync(path.dirname(file), { recursive: true })
423
434
  writeFileSync(file, '{not json}\n')
424
435
 
425
- const resolved = resolveSessionLabel({
436
+ expect(
437
+ resolveSessionLabel({
426
438
  env,
427
439
  now,
428
440
  sessionId: 'new-session',
429
441
  explicitLabel: 'Do not overwrite me',
430
- })
431
-
432
- expect(resolved.ok).toBe(false)
433
- expect(!resolved.ok && resolved.error).toContain('session-name store is unreadable')
434
- expect(readFileSync(file, 'utf8')).toBe('{not json}\n')
442
+ }),
443
+ ).toEqual({ ok: true, label: 'Do not overwrite me', source: 'explicit' })
444
+ expect(readFileSync(file, 'utf8')).not.toBe('{not json}\n')
445
+ const backups = readdirSync(path.dirname(file)).filter((name) =>
446
+ /^session-labels\.invalid-[a-f0-9]{64}\.json$/u.test(name),
447
+ )
448
+ expect(backups).toHaveLength(1)
449
+ expect(readFileSync(path.join(path.dirname(file), backups[0]!), 'utf8')).toBe('{not json}\n')
435
450
  })
436
451
 
437
452
  it('formats first seen using the machine local calendar', () => {
438
453
  const now = new Date(2026, 0, 2, 3, 4).getTime()
439
454
  expect(formatSessionFirstSeen(now)).toBe('Jan 2, 2026 03:04')
440
455
  })
456
+
457
+ it('explicitly replaces a semantic or fallback label after an authorized rename', () => {
458
+ const { env, now } = fixture()
459
+ resolveSessionLabel({
460
+ env,
461
+ now,
462
+ sessionId: 'rename-me',
463
+ harness: 'codex',
464
+ explicitLabel: 'Old job',
465
+ })
466
+
467
+ expect(
468
+ renameStoredSessionLabel({
469
+ env,
470
+ now: now + 1_000,
471
+ sessionId: 'rename-me',
472
+ harness: 'codex',
473
+ label: ' Completely different job ',
474
+ }),
475
+ ).toEqual({ ok: true, label: 'Completely different job', source: 'explicit' })
476
+ expect(
477
+ resolveSessionLabel({
478
+ env,
479
+ now: now + 2_000,
480
+ sessionId: 'rename-me',
481
+ harness: 'codex',
482
+ harnessLabel: 'Later task context',
483
+ }),
484
+ ).toEqual({
485
+ ok: true,
486
+ label: 'Completely different job',
487
+ source: 'explicit',
488
+ })
489
+ })
441
490
  })
@@ -1,12 +1,12 @@
1
1
  import { createHash } from 'node:crypto'
2
- import { existsSync, readFileSync } from 'node:fs'
2
+ import { copyFileSync, existsSync, readFileSync } from 'node:fs'
3
3
  import path from 'node:path'
4
4
  import { SESSION_LABEL_MAX_LENGTH } from '@raidiant/notifai-protocol'
5
5
  import { atomicWriteFileSync } from './atomic-file.js'
6
6
  import { stateDir } from './config.js'
7
7
  import { withFileLock } from './file-lock.js'
8
8
  import { generatedSessionLabel } from './generated-session-label.js'
9
- import { HARNESS_LABELS, type Harness } from './harnesses.js'
9
+ import { HARNESS_LABELS, type SourceContextHarness } from './harnesses.js'
10
10
 
11
11
  const STORE_VERSION = 1
12
12
  const MONTHS = [
@@ -35,7 +35,7 @@ interface StoredSessionLabel {
35
35
  label: string
36
36
  source: SessionLabelSource
37
37
  first_seen_at: number
38
- harness?: Harness
38
+ harness?: SourceContextHarness
39
39
  }
40
40
 
41
41
  interface SessionLabelStore {
@@ -46,13 +46,21 @@ interface SessionLabelStore {
46
46
  export interface SessionLabelInput {
47
47
  env: NodeJS.ProcessEnv
48
48
  sessionId: string
49
- harness?: Harness
49
+ harness?: SourceContextHarness
50
50
  explicitLabel?: string
51
51
  harnessLabel?: string
52
52
  harnessLabelPending?: boolean
53
53
  now?: number
54
54
  }
55
55
 
56
+ export interface SessionLabelRenameInput {
57
+ env: NodeJS.ProcessEnv
58
+ sessionId: string
59
+ label: string
60
+ harness?: SourceContextHarness
61
+ now?: number
62
+ }
63
+
56
64
  export type SessionLabelResolution =
57
65
  | { ok: true; label: string; source: SessionLabelSource }
58
66
  | { ok: false; error: string }
@@ -70,6 +78,24 @@ function emptyStore(): SessionLabelStore {
70
78
  return { version: STORE_VERSION, sessions: {} }
71
79
  }
72
80
 
81
+ function quarantineInvalidStore(file: string, raw: string): void {
82
+ const digest = createHash('sha256').update(raw).digest('hex')
83
+ const backup = path.join(path.dirname(file), `session-labels.invalid-${digest}.json`)
84
+ if (existsSync(backup)) {
85
+ if (readFileSync(backup, 'utf8') !== raw) {
86
+ throw new Error('the session-name store recovery backup does not match its content hash')
87
+ }
88
+ return
89
+ }
90
+ copyFileSync(file, backup)
91
+ }
92
+
93
+ function recoverInvalidStore(file: string, raw: string, store: SessionLabelStore): SessionLabelStore {
94
+ quarantineInvalidStore(file, raw)
95
+ writeStore(file, store)
96
+ return store
97
+ }
98
+
73
99
  function storedRecord(candidate: unknown): StoredSessionLabel | null {
74
100
  if (typeof candidate !== 'object' || candidate === null) return null
75
101
  const value = candidate as Partial<StoredSessionLabel>
@@ -95,33 +121,42 @@ function storedRecord(candidate: unknown): StoredSessionLabel | null {
95
121
 
96
122
  function readStore(file: string): SessionLabelStore {
97
123
  if (!existsSync(file)) return emptyStore()
98
- let parsed: unknown
124
+ let rawText: string
99
125
  try {
100
- parsed = JSON.parse(readFileSync(file, 'utf8'))
126
+ rawText = readFileSync(file, 'utf8')
101
127
  } catch (err) {
102
128
  throw new Error(
103
129
  `the session-name store is unreadable: ${err instanceof Error ? err.message : String(err)}`,
104
130
  )
105
131
  }
132
+ let parsed: unknown
133
+ try {
134
+ parsed = JSON.parse(rawText)
135
+ } catch {
136
+ return recoverInvalidStore(file, rawText, emptyStore())
137
+ }
106
138
  if (typeof parsed !== 'object' || parsed === null) {
107
- throw new Error('the session-name store is not a JSON object')
139
+ return recoverInvalidStore(file, rawText, emptyStore())
108
140
  }
109
141
  const raw = parsed as { version?: unknown; sessions?: unknown }
110
142
  if (raw.version !== STORE_VERSION) {
111
- throw new Error(`unsupported session-name store version ${String(raw.version)}`)
143
+ return recoverInvalidStore(file, rawText, emptyStore())
112
144
  }
113
145
  if (typeof raw.sessions !== 'object' || raw.sessions === null) {
114
- throw new Error('the session-name store has no sessions object')
146
+ return recoverInvalidStore(file, rawText, emptyStore())
115
147
  }
116
148
  const sessions: Record<string, StoredSessionLabel> = {}
149
+ let invalidRecordFound = false
117
150
  for (const [key, candidate] of Object.entries(raw.sessions)) {
118
151
  const record = storedRecord(candidate)
119
152
  if (!/^[a-f0-9]{64}$/.test(key) || record === null) {
120
- throw new Error('the session-name store contains an invalid record')
153
+ invalidRecordFound = true
154
+ continue
121
155
  }
122
156
  sessions[key] = record
123
157
  }
124
- return { version: STORE_VERSION, sessions }
158
+ const store = { version: STORE_VERSION, sessions } satisfies SessionLabelStore
159
+ return invalidRecordFound ? recoverInvalidStore(file, rawText, store) : store
125
160
  }
126
161
 
127
162
  function writeStore(file: string, store: SessionLabelStore): void {
@@ -203,6 +238,20 @@ function explicitCandidate(
203
238
  return { label: normalized, source: 'explicit' }
204
239
  }
205
240
 
241
+ export function normalizeExplicitSessionLabel(
242
+ input: Pick<SessionLabelRenameInput, 'env' | 'sessionId' | 'label'>,
243
+ ): { ok: true; label: string } | { ok: false; error: string } {
244
+ const candidate = explicitCandidate(input.label, {
245
+ env: input.env,
246
+ sessionId: input.sessionId,
247
+ })
248
+ if (candidate === null) {
249
+ return { ok: false, error: '--session-label must not be empty.' }
250
+ }
251
+ if ('error' in candidate) return { ok: false, error: candidate.error }
252
+ return { ok: true, label: candidate.label }
253
+ }
254
+
206
255
  function harnessLabelIsPending(
207
256
  value: string | undefined,
208
257
  input: SessionLabelInput,
@@ -260,7 +309,7 @@ function collisionKey(value: string): string {
260
309
  function uniqueLabel(
261
310
  base: string,
262
311
  source: SessionLabelSource,
263
- harness: Harness | undefined,
312
+ harness: SourceContextHarness | undefined,
264
313
  now: number,
265
314
  used: ReadonlySet<string>,
266
315
  ): string {
@@ -389,3 +438,37 @@ export function resolveSessionLabel(input: SessionLabelInput): SessionLabelResol
389
438
  }
390
439
  }
391
440
  }
441
+
442
+ /** Replace the local frozen label only after the Account-authoritative rename succeeds. */
443
+ export function renameStoredSessionLabel(
444
+ input: SessionLabelRenameInput,
445
+ ): SessionLabelResolution {
446
+ const normalized = normalizeExplicitSessionLabel(input)
447
+ if (!normalized.ok) return normalized
448
+ const file = storePath(input.env)
449
+ const key = sessionKey(input.sessionId)
450
+ const now = input.now ?? Date.now()
451
+ try {
452
+ return withFileLock(`${file}.lock`, () => {
453
+ const store = readStore(file)
454
+ const existing = store.sessions[key]
455
+ store.sessions[key] = {
456
+ label: normalized.label,
457
+ source: 'explicit',
458
+ first_seen_at: existing?.first_seen_at ?? now,
459
+ ...(input.harness === undefined
460
+ ? existing?.harness === undefined
461
+ ? {}
462
+ : { harness: existing.harness }
463
+ : { harness: input.harness }),
464
+ }
465
+ writeStore(file, store)
466
+ return { ok: true, label: normalized.label, source: 'explicit' }
467
+ })
468
+ } catch (err) {
469
+ return {
470
+ ok: false,
471
+ error: `The Account label changed, but this machine could not save it locally: ${err instanceof Error ? err.message : String(err)}`,
472
+ }
473
+ }
474
+ }
package/src/skill.test.ts CHANGED
@@ -225,7 +225,7 @@ describe('Notifai agent skill', () => {
225
225
  expect(send).toMatch(/kind and the project travel as their own\s+fields, never in it/i)
226
226
  })
227
227
 
228
- it('lets callers repeat one immutable semantic session name without supplying its id', () => {
228
+ it('keeps initial labels stable and teaches the narrow exact-session rename exception', () => {
229
229
  const send = section('## Send')
230
230
  expect(send).toContain('--session-label')
231
231
  expect(send).toMatch(/2-6 words/i)
@@ -239,6 +239,11 @@ describe('Notifai agent skill', () => {
239
239
  expect(send).toMatch(/generated fallback name[\s\S]*replaced by a later\s+semantic name/i)
240
240
  expect(send).toMatch(/never pass\s+`--session-id`/i)
241
241
  expect(send).toMatch(/identifier, hash, or\s+filesystem path/i)
242
+ expect(send).toContain('notifai session rename')
243
+ expect(send).toMatch(/job changed completely[\s\S]{0,100}current\s+name would now mislead/i)
244
+ expect(send).toMatch(/never rename for milestones, ordinary progress/i)
245
+ expect(send).toMatch(/accepts no Agent Session id/i)
246
+ expect(send).toMatch(/active harness proves the exact current Agent Session/i)
242
247
  })
243
248
 
244
249
  it('never teaches by enumerating environments the CLI can detect itself', () => {
@@ -247,7 +252,7 @@ describe('Notifai agent skill', () => {
247
252
  // Harness names may appear only in setup/routing material, where the
248
253
  // harness IS the subject.
249
254
  for (const heading of ['## Decide whether to notify', '## Send', '## Ask a question', '## When the answer arrives']) {
250
- expect(section(heading), heading).not.toMatch(/OpenCode|Orca|Cursor|Codex/)
255
+ expect(section(heading), heading).not.toMatch(/OpenCode|Orca|Cursor|Codex|Hermes/)
251
256
  }
252
257
  })
253
258
 
@@ -383,6 +388,7 @@ describe('Notifai agent skill', () => {
383
388
  'notifai guidance',
384
389
  'notifai logs',
385
390
  'notifai status',
391
+ 'notifai sounds',
386
392
  ]) {
387
393
  expect(skill, `${command} is unreachable from the skill`).toContain(command)
388
394
  }
@@ -391,9 +397,14 @@ describe('Notifai agent skill', () => {
391
397
  it('moves per-harness mechanics into progressive disclosure', () => {
392
398
  expect(skill).toContain('[Harness setup and recovery](references/harness-setup.md)')
393
399
  expect(skill).toMatch(/read it when you are\s+installing hooks or diagnosing routing/i)
394
- for (const harness of ['Claude Code', 'Codex', 'Cursor', 'OpenCode']) {
400
+ for (const harness of ['Claude Code', 'Codex', 'Cursor', 'OpenCode', 'Hermes']) {
395
401
  expect(harnessReference).toContain(`**${harness}:**`)
396
402
  }
403
+ expect(harnessReference).toMatch(/exact `HERMES_SESSION_ID`/i)
404
+ expect(harnessReference).toMatch(/Do not run `notifai hooks install --harness hermes`/i)
405
+ expect(harnessReference).toMatch(/blocking `notifai send --reply`/i)
406
+ expect(harnessReference).toMatch(/npx skills/i)
407
+ expect(harnessReference).toMatch(/no Hermes plugin may vendor/i)
397
408
  // The agent runs what a process can run; the human only does the human part.
398
409
  expect(harnessReference).toMatch(/let init install and assess\s+the hooks/i)
399
410
  expect(harnessReference).toContain('--setup-scope <project|global>')