@raidiant/notifai 5.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 (104) hide show
  1. package/CHANGELOG.md +27 -0
  2. package/README.md +2 -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 -387
  52. package/dist/commands.js +12 -4536
  53. package/dist/commands.js.map +1 -1
  54. package/dist/config-schema.js +1 -1
  55. package/dist/config-schema.js.map +1 -1
  56. package/dist/config.d.ts +1 -1
  57. package/dist/config.js +3 -1
  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/hooks.d.ts +2 -2
  62. package/dist/hooks.js +127 -99
  63. package/dist/hooks.js.map +1 -1
  64. package/dist/interactive.js +4 -4
  65. package/dist/interactive.js.map +1 -1
  66. package/dist/logging.js +1 -0
  67. package/dist/logging.js.map +1 -1
  68. package/dist/main.js +1 -1
  69. package/dist/main.js.map +1 -1
  70. package/dist/readiness.d.ts +3 -1
  71. package/dist/readiness.js.map +1 -1
  72. package/package.json +2 -2
  73. package/src/client.test.ts +90 -1
  74. package/src/client.ts +35 -2
  75. package/src/commands-auth.ts +174 -0
  76. package/src/commands-config.ts +307 -0
  77. package/src/commands-core.ts +240 -0
  78. package/src/commands-devices.ts +150 -0
  79. package/src/commands-doctor.ts +1209 -0
  80. package/src/commands-harness-context.ts +126 -0
  81. package/src/commands-hook-shape.ts +53 -0
  82. package/src/commands-hooks.ts +1586 -0
  83. package/src/commands-init.ts +721 -0
  84. package/src/commands-io.ts +108 -0
  85. package/src/commands-logs.ts +243 -0
  86. package/src/commands-send-support.ts +224 -0
  87. package/src/commands-send.ts +623 -0
  88. package/src/commands-setup-proof.ts +71 -0
  89. package/src/commands-skill.ts +84 -0
  90. package/src/commands.test.ts +493 -39
  91. package/src/commands.ts +58 -5614
  92. package/src/config-schema.ts +1 -1
  93. package/src/config.test.ts +9 -0
  94. package/src/config.ts +7 -2
  95. package/src/credentials.test.ts +25 -1
  96. package/src/credentials.ts +13 -3
  97. package/src/hooks.test.ts +351 -11
  98. package/src/hooks.ts +145 -97
  99. package/src/interactive.test.ts +19 -1
  100. package/src/interactive.ts +4 -4
  101. package/src/logging.test.ts +15 -2
  102. package/src/logging.ts +1 -0
  103. package/src/main.ts +1 -1
  104. package/src/readiness.ts +3 -1
@@ -83,7 +83,7 @@ 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
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',
@@ -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', () => {
package/src/config.ts CHANGED
@@ -1,9 +1,12 @@
1
- import { REPLY_MAX_WINDOW_SECONDS } from '@raidiant/notifai-protocol'
1
+ import {
2
+ CLI_SOUNDS,
3
+ INTERRUPTION_LEVELS,
4
+ REPLY_MAX_WINDOW_SECONDS,
5
+ } from '@raidiant/notifai-protocol'
2
6
  import { createHash } from 'node:crypto'
3
7
  import { existsSync, readFileSync, realpathSync } from 'node:fs'
4
8
  import path from 'node:path'
5
9
  import { parse as parseToml } from 'smol-toml'
6
- import type { CLI_SOUNDS, INTERRUPTION_LEVELS } from '@raidiant/notifai-protocol'
7
10
  import { configHome, stateHome } from './platform.js'
8
11
 
9
12
  /**
@@ -133,6 +136,8 @@ export const NUMERIC_CONFIG_KEYS: readonly ConfigKey[] = [
133
136
  * is the same discipline the numeric bounds follow.
134
137
  */
135
138
  export const ENUM_CONFIG_VALUES: Partial<Record<ConfigKey, readonly string[]>> = {
139
+ sound: CLI_SOUNDS,
140
+ interruption_level: INTERRUPTION_LEVELS,
136
141
  log_level: LOG_LEVELS,
137
142
  }
138
143
 
@@ -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']
package/src/hooks.test.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { spawn, type ChildProcess } from 'node:child_process'
1
+ import { execFileSync, spawn, type ChildProcess } from 'node:child_process'
2
2
  import {
3
3
  existsSync,
4
4
  mkdirSync,
@@ -167,7 +167,8 @@ function fakeClient(recorder: Recorder, replies: ReplyView[]): ApiClient {
167
167
  platform: 'ios' as const,
168
168
  permission_status: 'authorized',
169
169
  registration_healthy: true,
170
- reply_protocol_version: 2,
170
+ capabilities: ['answer'],
171
+ derived_status: 'working',
171
172
  last_seen_at: null,
172
173
  },
173
174
  {
@@ -176,7 +177,8 @@ function fakeClient(recorder: Recorder, replies: ReplyView[]): ApiClient {
176
177
  platform: 'macos' as const,
177
178
  permission_status: 'authorized',
178
179
  registration_healthy: true,
179
- reply_protocol_version: 2,
180
+ capabilities: ['answer'],
181
+ derived_status: 'working',
180
182
  last_seen_at: null,
181
183
  },
182
184
  ],
@@ -337,6 +339,23 @@ function harness(replies: ReplyView[] = []): Harness {
337
339
  }
338
340
  }
339
341
 
342
+ function runFixtureGit(cwd: string, ...args: string[]): string {
343
+ return execFileSync('git', ['-C', cwd, ...args], {
344
+ encoding: 'utf8',
345
+ stdio: ['ignore', 'pipe', 'pipe'],
346
+ }).trim()
347
+ }
348
+
349
+ function initializeFixtureRepository(root: string, branch = 'branch-a'): void {
350
+ runFixtureGit(root, 'init')
351
+ runFixtureGit(root, 'config', 'user.email', 'notifai-tests@example.invalid')
352
+ runFixtureGit(root, 'config', 'user.name', 'Notifai Tests')
353
+ writeFileSync(path.join(root, 'tracked.txt'), 'fixture\n')
354
+ runFixtureGit(root, 'add', 'tracked.txt')
355
+ runFixtureGit(root, 'commit', '-m', 'fixture')
356
+ runFixtureGit(root, 'branch', '-M', branch)
357
+ }
358
+
340
359
  function stdin(payload: unknown): () => Promise<string> {
341
360
  return async () => JSON.stringify(payload)
342
361
  }
@@ -494,6 +513,26 @@ describe('the waiter wall clock', () => {
494
513
  })
495
514
 
496
515
  describe('pushing a registered question', () => {
516
+ it('keeps unknown future session keys through an older read-modify-write', () => {
517
+ const h = harness([])
518
+ const sessionId = 'future-session-format'
519
+ const file = path.join(stateDir(h.env), 'sessions', `${sanitizeSessionId(sessionId)}.json`)
520
+ mkdirSync(path.dirname(file), { recursive: true })
521
+ writeFileSync(
522
+ file,
523
+ `${JSON.stringify({
524
+ last_prompt_at: NOW,
525
+ future_session_key: { nested: ['kept'] },
526
+ })}\n`,
527
+ )
528
+
529
+ registerQuestion(sessionId, h.env, { question: 'Still preserved?' }, NOW)
530
+
531
+ const rewritten = JSON.parse(readFileSync(file, 'utf8')) as Record<string, unknown>
532
+ expect(rewritten['future_session_key']).toEqual({ nested: ['kept'] })
533
+ expect(rewritten['pending']).toHaveLength(1)
534
+ })
535
+
497
536
  // Found by a live Claude Code session: a spawned agent always has a
498
537
  // just-set last_prompt_at, and the routing gate once read that as the user
499
538
  // being present — so its FIRST question could never escalate, which is the
@@ -864,7 +903,7 @@ describe('terminal-first grace window', () => {
864
903
  ).toBeGreaterThanOrEqual(60)
865
904
  })
866
905
 
867
- it('never pushes a question to a Companion without the current reply protocol', async () => {
906
+ it('never pushes a question to a Companion that did not advertise answer', async () => {
868
907
  const h = harness([])
869
908
  const factory = h.deps.clientFactory
870
909
  h.deps.clientFactory = () => {
@@ -874,7 +913,7 @@ describe('terminal-first grace window', () => {
874
913
  listDevices: async () => ({
875
914
  devices: (await client.listDevices()).devices.map((device) => ({
876
915
  ...device,
877
- reply_protocol_version: null,
916
+ capabilities: [],
878
917
  })),
879
918
  }),
880
919
  } as ApiClient
@@ -2638,6 +2677,58 @@ describe('user-prompt-submit hook', () => {
2638
2677
  clearSessionState('s14', h.env)
2639
2678
  expect(readProjectSession(project, h.env, NOW)).toBeNull()
2640
2679
  })
2680
+
2681
+ it('round-trips future project-pointer fields while publishing a session', () => {
2682
+ const h = harness()
2683
+ const pointer = projectSessionPointerPath(h.deps.cwd, h.env)
2684
+ const futureEntry = {
2685
+ format: 2,
2686
+ session: { id: 'future-session', runtime: 'future-harness' },
2687
+ }
2688
+ mkdirSync(path.dirname(pointer), { recursive: true })
2689
+ writeFileSync(
2690
+ pointer,
2691
+ `${JSON.stringify({
2692
+ future_root: { retained: true },
2693
+ sessions: [
2694
+ {
2695
+ session_id: 'still-running',
2696
+ updated_at: NOW - 1,
2697
+ harness: 'codex',
2698
+ future_entry: { retained: true },
2699
+ },
2700
+ {
2701
+ session_id: 'new-session',
2702
+ updated_at: NOW - 2,
2703
+ harness: 'claude-code',
2704
+ future_current_entry: { retained: true },
2705
+ },
2706
+ futureEntry,
2707
+ ],
2708
+ })}\n`,
2709
+ )
2710
+
2711
+ writeProjectSession(h.deps.cwd, h.env, 'new-session', NOW, 'claude-code')
2712
+
2713
+ expect(JSON.parse(readFileSync(pointer, 'utf8'))).toEqual({
2714
+ future_root: { retained: true },
2715
+ sessions: [
2716
+ {
2717
+ session_id: 'still-running',
2718
+ updated_at: NOW - 1,
2719
+ harness: 'codex',
2720
+ future_entry: { retained: true },
2721
+ },
2722
+ futureEntry,
2723
+ {
2724
+ session_id: 'new-session',
2725
+ updated_at: NOW,
2726
+ harness: 'claude-code',
2727
+ future_current_entry: { retained: true },
2728
+ },
2729
+ ],
2730
+ })
2731
+ })
2641
2732
  })
2642
2733
 
2643
2734
  describe('session-end hook', () => {
@@ -2656,6 +2747,36 @@ describe('session-end hook', () => {
2656
2747
  expect(records.at(-1)?.data).toMatchObject({ outcome: 'cleaned', queued_retirements: 0 })
2657
2748
  })
2658
2749
 
2750
+ it('round-trips future session-state fields when SessionEnd preserves an obligation', async () => {
2751
+ const h = harness()
2752
+ const sessionId = 'session-end-future-state'
2753
+ const stateFile = path.join(
2754
+ stateDir(h.env),
2755
+ 'sessions',
2756
+ `${sanitizeSessionId(sessionId)}.json`,
2757
+ )
2758
+ const acknowledgement = {
2759
+ request_id: 'req_future_ack',
2760
+ recorded_at: NOW,
2761
+ future_entry: { retained: true },
2762
+ }
2763
+ mkdirSync(path.dirname(stateFile), { recursive: true })
2764
+ writeFileSync(
2765
+ stateFile,
2766
+ `${JSON.stringify({
2767
+ future_root: { retained: true },
2768
+ acknowledgement_due: [acknowledgement],
2769
+ })}\n`,
2770
+ )
2771
+
2772
+ await hookRunCommand(h.deps, 'session-end', stdin({ session_id: sessionId }))
2773
+
2774
+ expect(JSON.parse(readFileSync(stateFile, 'utf8'))).toEqual({
2775
+ future_root: { retained: true },
2776
+ acknowledgement_due: [acknowledgement],
2777
+ })
2778
+ })
2779
+
2659
2780
  it('runs the real Codex SessionEnd cleanup before contended diagnostics and exits fail-open', async () => {
2660
2781
  const h = harness()
2661
2782
  const sessionId = 'session-end-budget'
@@ -2772,6 +2893,54 @@ describe('session-end hook', () => {
2772
2893
  ).toEqual({ sessionId: 'still-running', harness: 'codex' })
2773
2894
  })
2774
2895
 
2896
+ it('keeps future project-pointer data when clearing the ending session', async () => {
2897
+ const h = harness()
2898
+ const pointer = projectSessionPointerPath(h.deps.cwd, h.env)
2899
+ const futureEntry = {
2900
+ format: 2,
2901
+ session: { id: 'future-session', runtime: 'future-harness' },
2902
+ }
2903
+ writeSessionState('still-running', h.env, { last_prompt_at: NOW })
2904
+ writeSessionState('ending', h.env, { last_prompt_at: NOW })
2905
+ mkdirSync(path.dirname(pointer), { recursive: true })
2906
+ writeFileSync(
2907
+ pointer,
2908
+ `${JSON.stringify({
2909
+ future_root: { retained: true },
2910
+ sessions: [
2911
+ {
2912
+ session_id: 'still-running',
2913
+ updated_at: NOW - 1,
2914
+ harness: 'codex',
2915
+ future_entry: { retained: true },
2916
+ },
2917
+ futureEntry,
2918
+ { session_id: 'ending', updated_at: NOW, harness: 'claude-code' },
2919
+ ],
2920
+ })}\n`,
2921
+ )
2922
+
2923
+ await hookRunCommand(
2924
+ h.deps,
2925
+ 'session-end',
2926
+ stdin({ session_id: 'ending', cwd: h.deps.cwd }),
2927
+ 'claude-code',
2928
+ )
2929
+
2930
+ expect(JSON.parse(readFileSync(pointer, 'utf8'))).toEqual({
2931
+ future_root: { retained: true },
2932
+ sessions: [
2933
+ {
2934
+ session_id: 'still-running',
2935
+ updated_at: NOW - 1,
2936
+ harness: 'codex',
2937
+ future_entry: { retained: true },
2938
+ },
2939
+ futureEntry,
2940
+ ],
2941
+ })
2942
+ })
2943
+
2775
2944
  it('preserves incomplete live state and reports why it cannot retire it', async () => {
2776
2945
  const h = harness()
2777
2946
  writeSessionState('s-incomplete', h.env, {
@@ -2803,6 +2972,164 @@ describe('malformed input', () => {
2803
2972
  })
2804
2973
 
2805
2974
  describe('telling concurrent agents apart', () => {
2975
+ it('uses each hook event branch while keeping one immutable session identity', async () => {
2976
+ const h = harness([reply({ text: 'Yes' })])
2977
+ initializeFixtureRepository(h.deps.cwd)
2978
+ registerQuestion(
2979
+ 'session-branch-transition',
2980
+ h.env,
2981
+ {
2982
+ question: 'Ship it?',
2983
+ source: {
2984
+ session_id: 'session-branch-transition',
2985
+ session_label: 'Branch transition',
2986
+ harness: 'claude-code',
2987
+ branch: 'branch-a',
2988
+ },
2989
+ },
2990
+ NOW,
2991
+ )
2992
+
2993
+ runFixtureGit(h.deps.cwd, 'checkout', '-b', 'branch-b')
2994
+ await hookRunCommand(
2995
+ h.deps,
2996
+ 'stop',
2997
+ stdin({ session_id: 'session-branch-transition', cwd: h.deps.cwd }),
2998
+ 'claude-code',
2999
+ )
3000
+
3001
+ const question = h.recorder.submitted.find((entry) => entry.draft.event === 'agent_question')
3002
+ expect(question?.draft.source).toEqual({
3003
+ session_id: 'session-branch-transition',
3004
+ session_label: 'Branch transition',
3005
+ harness: 'claude-code',
3006
+ branch: 'branch-b',
3007
+ })
3008
+
3009
+ runFixtureGit(h.deps.cwd, 'checkout', 'branch-a')
3010
+ h.recorder.acknowledged ??= new Set()
3011
+ h.recorder.acknowledged.add(h.recorder.receipts[0]!)
3012
+ await hookRunCommand(
3013
+ h.deps,
3014
+ 'stop',
3015
+ stdin({
3016
+ session_id: 'session-branch-transition',
3017
+ cwd: h.deps.cwd,
3018
+ stop_hook_active: true,
3019
+ }),
3020
+ 'claude-code',
3021
+ )
3022
+
3023
+ const retirement = h.recorder.submitted.find(
3024
+ (entry) => entry.draft.event === 'question_retired',
3025
+ )
3026
+ expect(retirement?.draft.source).toEqual({
3027
+ session_id: 'session-branch-transition',
3028
+ session_label: 'Branch transition',
3029
+ harness: 'claude-code',
3030
+ branch: 'branch-a',
3031
+ })
3032
+ })
3033
+
3034
+ it('clears registration-time branch and worktree on a detached-HEAD hook event', async () => {
3035
+ const h = harness([])
3036
+ initializeFixtureRepository(h.deps.cwd)
3037
+ registerQuestion(
3038
+ 'session-detached',
3039
+ h.env,
3040
+ {
3041
+ question: 'Ship it?',
3042
+ source: {
3043
+ session_id: 'session-detached',
3044
+ session_label: 'Detached review',
3045
+ harness: 'claude-code',
3046
+ branch: 'branch-a',
3047
+ worktree: 'stale-worktree',
3048
+ },
3049
+ },
3050
+ NOW,
3051
+ )
3052
+ runFixtureGit(h.deps.cwd, 'checkout', '--detach', 'HEAD')
3053
+
3054
+ await hookRunCommand(
3055
+ h.deps,
3056
+ 'stop',
3057
+ stdin({ session_id: 'session-detached', cwd: h.deps.cwd }),
3058
+ 'claude-code',
3059
+ )
3060
+
3061
+ const question = h.recorder.submitted.find((entry) => entry.draft.event === 'agent_question')
3062
+ expect(question?.draft.source).toEqual({
3063
+ session_id: 'session-detached',
3064
+ session_label: 'Detached review',
3065
+ harness: 'claude-code',
3066
+ })
3067
+ })
3068
+
3069
+ it('captures entry into and exit from a linked worktree on the same session', async () => {
3070
+ const h = harness([reply({ text: 'Yes' })])
3071
+ initializeFixtureRepository(h.deps.cwd, 'main')
3072
+ const linked = `${h.deps.cwd}-linked`
3073
+ runFixtureGit(h.deps.cwd, 'worktree', 'add', '-b', 'worktree-branch', linked)
3074
+ try {
3075
+ registerQuestion(
3076
+ 'session-worktree-transition',
3077
+ h.env,
3078
+ {
3079
+ question: 'Ship it?',
3080
+ source: {
3081
+ session_id: 'session-worktree-transition',
3082
+ session_label: 'Worktree transition',
3083
+ harness: 'claude-code',
3084
+ branch: 'main',
3085
+ },
3086
+ },
3087
+ NOW,
3088
+ )
3089
+
3090
+ await hookRunCommand(
3091
+ h.deps,
3092
+ 'stop',
3093
+ stdin({ session_id: 'session-worktree-transition', cwd: linked }),
3094
+ 'claude-code',
3095
+ )
3096
+ const question = h.recorder.submitted.find(
3097
+ (entry) => entry.draft.event === 'agent_question',
3098
+ )
3099
+ expect(question?.draft.source).toEqual({
3100
+ session_id: 'session-worktree-transition',
3101
+ session_label: 'Worktree transition',
3102
+ harness: 'claude-code',
3103
+ branch: 'worktree-branch',
3104
+ worktree: path.basename(linked),
3105
+ })
3106
+
3107
+ h.recorder.acknowledged ??= new Set()
3108
+ h.recorder.acknowledged.add(h.recorder.receipts[0]!)
3109
+ await hookRunCommand(
3110
+ h.deps,
3111
+ 'stop',
3112
+ stdin({
3113
+ session_id: 'session-worktree-transition',
3114
+ cwd: h.deps.cwd,
3115
+ stop_hook_active: true,
3116
+ }),
3117
+ 'claude-code',
3118
+ )
3119
+ const retirement = h.recorder.submitted.find(
3120
+ (entry) => entry.draft.event === 'question_retired',
3121
+ )
3122
+ expect(retirement?.draft.source).toEqual({
3123
+ session_id: 'session-worktree-transition',
3124
+ session_label: 'Worktree transition',
3125
+ harness: 'claude-code',
3126
+ branch: 'main',
3127
+ })
3128
+ } finally {
3129
+ runFixtureGit(h.deps.cwd, 'worktree', 'remove', '--force', linked)
3130
+ }
3131
+ })
3132
+
2806
3133
  it('stamps structured Source Context on the question it pushes', async () => {
2807
3134
  const h = harness([])
2808
3135
  registerQuestion(
@@ -3881,11 +4208,9 @@ describe('escalation waiter delivery seam', () => {
3881
4208
 
3882
4209
 
3883
4210
  describe('session state across a prompt', () => {
3884
- it('carries every kind of session state across a typed prompt', async () => {
3885
- // The prompt handler rebuilds state from named fields, so a field nobody
3886
- // remembered to name is erased by the user typing. That is how an
3887
- // acknowledgement obligation once vanished. This fails if a future field is
3888
- // added to SessionState and not carried across.
4211
+ it('carries known and future session state across a typed prompt', async () => {
4212
+ // The prompt transition deliberately changes pending/retiring state and the
4213
+ // continuation count, but fields introduced by a newer CLI remain opaque.
3889
4214
  const h = harness([])
3890
4215
  const before = {
3891
4216
  last_prompt_at: AWAY,
@@ -3903,7 +4228,11 @@ describe('session state across a prompt', () => {
3903
4228
  acknowledgement_due: [{ request_id: 'req_carry_ack', recorded_at: NOW }],
3904
4229
  acknowledgement_blocks: 2,
3905
4230
  }
3906
- writeSessionState('carry-all', h.env, before)
4231
+ const futureSessionValue = { nested: ['kept'], revision: 2 }
4232
+ writeSessionState('carry-all', h.env, {
4233
+ ...before,
4234
+ future_session_key: futureSessionValue,
4235
+ } as SessionState & { future_session_key: typeof futureSessionValue })
3907
4236
 
3908
4237
  await hookRunCommand(
3909
4238
  h.deps,
@@ -3915,6 +4244,17 @@ describe('session state across a prompt', () => {
3915
4244
  for (const key of Object.keys(before) as (keyof typeof before)[]) {
3916
4245
  expect(after[key], `${key} did not survive the prompt`).toBeDefined()
3917
4246
  }
4247
+ const persisted = JSON.parse(
4248
+ readFileSync(
4249
+ path.join(
4250
+ stateDir(h.env),
4251
+ 'sessions',
4252
+ `${sanitizeSessionId('carry-all')}.json`,
4253
+ ),
4254
+ 'utf8',
4255
+ ),
4256
+ ) as Record<string, unknown>
4257
+ expect(persisted['future_session_key']).toEqual(futureSessionValue)
3918
4258
  // The one field a prompt resets rather than carries.
3919
4259
  expect(after.continuation?.count).toBe(0)
3920
4260
  expect(after.acknowledgement_blocks).toBe(2)