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