@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.ts CHANGED
@@ -38,6 +38,7 @@ import {
38
38
  import { buildDraft } from './send.js'
39
39
  import { atomicWriteFileSync } from './atomic-file.js'
40
40
  import { withFileLock } from './file-lock.js'
41
+ import { inferInvocationContext } from './invocation-context.js'
41
42
  import type { Logger } from './logging.js'
42
43
  import {
43
44
  HARNESS_CAPABILITIES,
@@ -242,6 +243,22 @@ export interface PendingQuestion {
242
243
  submission?: PendingSubmissionIntent
243
244
  }
244
245
 
246
+ /** Keep immutable session grouping while replacing per-event Git location authoritatively. */
247
+ function sourceContextAtHookEvent(
248
+ source: SourceContextT | undefined,
249
+ cwd: string | undefined,
250
+ ): SourceContextT | undefined {
251
+ const invocation = cwd === undefined ? null : inferInvocationContext(cwd)
252
+ const current: SourceContextT = {
253
+ ...(source?.session_id !== undefined ? { session_id: source.session_id } : {}),
254
+ ...(source?.session_label !== undefined ? { session_label: source.session_label } : {}),
255
+ ...(source?.harness !== undefined ? { harness: source.harness } : {}),
256
+ ...(invocation?.branch !== undefined ? { branch: invocation.branch } : {}),
257
+ ...(invocation?.worktree !== undefined ? { worktree: invocation.worktree } : {}),
258
+ }
259
+ return Object.keys(current).length === 0 ? undefined : current
260
+ }
261
+
245
262
  interface PendingSubmissionIntent {
246
263
  request_id: string
247
264
  idempotency_key: string
@@ -643,6 +660,50 @@ interface StoredProjectSessionPointer extends ProjectSessionPointer {
643
660
  updatedAt: number
644
661
  }
645
662
 
663
+ interface StoredProjectSessionDocument {
664
+ root: Record<string, unknown>
665
+ sessions: unknown[]
666
+ }
667
+
668
+ export interface ProjectSessionPointer {
669
+ sessionId: string
670
+ harness: HookHarness
671
+ }
672
+
673
+ function storedProjectSessionPointer(candidate: unknown): StoredProjectSessionPointer | null {
674
+ if (typeof candidate !== 'object' || candidate === null || Array.isArray(candidate)) return null
675
+ const entry = candidate as {
676
+ session_id?: unknown
677
+ updated_at?: unknown
678
+ harness?: unknown
679
+ }
680
+ if (typeof entry.session_id !== 'string' || entry.session_id === '') return null
681
+ if (typeof entry.updated_at !== 'number') return null
682
+ if (!(HARNESSES as readonly unknown[]).includes(entry.harness)) return null
683
+ return {
684
+ sessionId: entry.session_id,
685
+ updatedAt: entry.updated_at,
686
+ harness: entry.harness as HookHarness,
687
+ }
688
+ }
689
+
690
+ function readStoredProjectSessionDocument(file: string): StoredProjectSessionDocument {
691
+ if (!existsSync(file)) return { root: {}, sessions: [] }
692
+ try {
693
+ const parsed: unknown = JSON.parse(readFileSync(file, 'utf8'))
694
+ if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
695
+ return { root: {}, sessions: [] }
696
+ }
697
+ const root = parsed as Record<string, unknown>
698
+ return {
699
+ root,
700
+ sessions: Array.isArray(root['sessions']) ? root['sessions'] : [],
701
+ }
702
+ } catch {
703
+ return { root: {}, sessions: [] }
704
+ }
705
+ }
706
+
646
707
  /** Records every live session working in a directory, for `notifai ask`. */
647
708
  export function writeProjectSession(
648
709
  cwd: string,
@@ -653,56 +714,37 @@ export function writeProjectSession(
653
714
  ): void {
654
715
  const file = projectSessionPointerPath(cwd, env)
655
716
  withFileLock(`${file}.lock`, () => {
656
- const existing = readStoredProjectSessionPointers(file).filter(
657
- (entry) => entry.sessionId !== sessionId && now - entry.updatedAt <= 24 * 3600 * 1000,
658
- )
717
+ const stored = readStoredProjectSessionDocument(file)
718
+ const existing: unknown[] = []
719
+ let refreshed: Record<string, unknown> = {}
720
+ for (const candidate of stored.sessions) {
721
+ const entry = storedProjectSessionPointer(candidate)
722
+ if (entry?.sessionId === sessionId) {
723
+ refreshed = { ...refreshed, ...(candidate as Record<string, unknown>) }
724
+ } else if (entry === null || now - entry.updatedAt <= 24 * 3600 * 1000) {
725
+ existing.push(candidate)
726
+ }
727
+ }
659
728
  atomicWriteFileSync(
660
729
  file,
661
730
  `${JSON.stringify({
731
+ ...stored.root,
662
732
  sessions: [
663
- ...existing.map((entry) => ({
664
- session_id: entry.sessionId,
665
- updated_at: entry.updatedAt,
666
- harness: entry.harness,
667
- })),
668
- { session_id: sessionId, updated_at: now, harness },
733
+ ...existing,
734
+ { ...refreshed, session_id: sessionId, updated_at: now, harness },
669
735
  ],
670
736
  })}\n`,
671
737
  )
672
738
  })
673
739
  }
674
740
 
675
- export interface ProjectSessionPointer {
676
- sessionId: string
677
- harness: HookHarness
678
- }
679
-
680
741
  function readStoredProjectSessionPointers(file: string): StoredProjectSessionPointer[] {
681
- if (!existsSync(file)) return []
682
- try {
683
- const parsed = JSON.parse(readFileSync(file, 'utf8')) as { sessions?: unknown }
684
- if (!Array.isArray(parsed.sessions)) return []
685
- return parsed.sessions.flatMap((candidate): StoredProjectSessionPointer[] => {
686
- if (typeof candidate !== 'object' || candidate === null || Array.isArray(candidate)) return []
687
- const entry = candidate as {
688
- session_id?: unknown
689
- updated_at?: unknown
690
- harness?: unknown
691
- }
692
- if (typeof entry.session_id !== 'string' || entry.session_id === '') return []
693
- if (typeof entry.updated_at !== 'number') return []
694
- if (!(HARNESSES as readonly unknown[]).includes(entry.harness)) return []
695
- return [
696
- {
697
- sessionId: entry.session_id,
698
- updatedAt: entry.updated_at,
699
- harness: entry.harness as HookHarness,
700
- },
701
- ]
702
- })
703
- } catch {
704
- return []
705
- }
742
+ return readStoredProjectSessionDocument(file).sessions.flatMap(
743
+ (candidate): StoredProjectSessionPointer[] => {
744
+ const entry = storedProjectSessionPointer(candidate)
745
+ return entry === null ? [] : [entry]
746
+ },
747
+ )
706
748
  }
707
749
 
708
750
  /**
@@ -792,21 +834,21 @@ function clearMatchingProjectSession(
792
834
  ): void {
793
835
  const file = projectSessionPointerPath(cwd, env)
794
836
  withFileLock(`${file}.lock`, () => {
795
- const remaining = readStoredProjectSessionPointers(file).filter(
796
- (entry) => entry.sessionId !== sessionId,
797
- )
798
- if (remaining.length === 0) {
837
+ const stored = readStoredProjectSessionDocument(file)
838
+ const remaining = stored.sessions.filter((candidate) => {
839
+ const entry = storedProjectSessionPointer(candidate)
840
+ return entry === null || entry.sessionId !== sessionId
841
+ })
842
+ const hasUnknownRootFields = Object.keys(stored.root).some((key) => key !== 'sessions')
843
+ if (remaining.length === 0 && !hasUnknownRootFields) {
799
844
  rmSync(file, { force: true })
800
845
  return
801
846
  }
802
847
  atomicWriteFileSync(
803
848
  file,
804
849
  `${JSON.stringify({
805
- sessions: remaining.map((entry) => ({
806
- session_id: entry.sessionId,
807
- updated_at: entry.updatedAt,
808
- harness: entry.harness,
809
- })),
850
+ ...stored.root,
851
+ sessions: remaining,
810
852
  })}\n`,
811
853
  )
812
854
  })
@@ -1059,7 +1101,8 @@ async function answerableDevices(ctx: HookContext): Promise<string[]> {
1059
1101
  (device) =>
1060
1102
  (device.platform === 'ios' || device.platform === 'macos') &&
1061
1103
  device.registration_healthy &&
1062
- device.reply_protocol_version === 2,
1104
+ device.capabilities?.includes('answer') === true &&
1105
+ device.derived_status !== 'must_update',
1063
1106
  )
1064
1107
  .filter((device) => configured === null || configured.includes(device.device_id))
1065
1108
  .map((device) => device.device_id)
@@ -1149,7 +1192,6 @@ const RETIREMENT_TITLES: Record<LifecycleEndState, string> = {
1149
1192
  answered: 'Answered',
1150
1193
  answered_elsewhere: 'Answered in the terminal',
1151
1194
  expired: 'Question expired',
1152
- superseded: 'Replaced by a newer question',
1153
1195
  }
1154
1196
 
1155
1197
  /**
@@ -1192,6 +1234,7 @@ export function parkForRetirement(
1192
1234
  function retiringQuestion(
1193
1235
  pending: PendingQuestion,
1194
1236
  state: LifecycleEndState,
1237
+ cwd?: string,
1195
1238
  ): RetiringQuestion | null {
1196
1239
  const hasRequest = pending.request_id !== undefined
1197
1240
  const hasCollapse = pending.collapse_key !== undefined
@@ -1202,13 +1245,14 @@ function retiringQuestion(
1202
1245
  'live question state is incomplete; refusing to retire it without request, collapse, and device identifiers',
1203
1246
  )
1204
1247
  }
1248
+ const source = sourceContextAtHookEvent(pending.source, cwd)
1205
1249
  return {
1206
1250
  request_id: pending.request_id!,
1207
1251
  collapse_key: pending.collapse_key!,
1208
1252
  device_ids: [...pending.device_ids!],
1209
1253
  question: pending.question,
1210
1254
  ...(pending.project !== undefined ? { project: pending.project } : {}),
1211
- ...(pending.source !== undefined ? { source: pending.source } : {}),
1255
+ ...(source !== undefined ? { source } : {}),
1212
1256
  state,
1213
1257
  }
1214
1258
  }
@@ -1891,9 +1935,10 @@ function settleAcceptedAnswers(
1891
1935
  ctx: HookContext,
1892
1936
  sessionId: string,
1893
1937
  accepted: AcceptedAnswerDelivery,
1938
+ cwd?: string,
1894
1939
  ): void {
1895
1940
  const retirements = accepted.answers
1896
- .map(({ pending }) => retiringQuestion(pending, 'answered'))
1941
+ .map(({ pending }) => retiringQuestion(pending, 'answered', cwd))
1897
1942
  .filter((entry): entry is RetiringQuestion => entry !== null)
1898
1943
  updateSessionState(sessionId, ctx.env, (current) => {
1899
1944
  const retiring = [...(current.retiring ?? [])]
@@ -1922,7 +1967,7 @@ async function retirePendings(
1922
1967
  updateSessionState(sessionId, ctx.env, (current) => {
1923
1968
  const retiring = [...(current.retiring ?? [])]
1924
1969
  for (const entry of entries) {
1925
- const retirement = retiringQuestion(entry, state)
1970
+ const retirement = retiringQuestion(entry, state, envelope.cwd)
1926
1971
  if (retirement !== null && !retiring.some((item) => item.request_id === retirement.request_id)) {
1927
1972
  retiring.push(retirement)
1928
1973
  }
@@ -2032,7 +2077,7 @@ export async function handleUserPromptSubmit(
2032
2077
  const retiring = [...(current.retiring ?? [])]
2033
2078
  const unasked = pendingList(current).filter((entry) => entry.request_id === undefined)
2034
2079
  for (const entry of pendingList(current).filter((entry) => entry.request_id !== undefined)) {
2035
- const retirement = retiringQuestion(entry, 'answered_elsewhere')
2080
+ const retirement = retiringQuestion(entry, 'answered_elsewhere', envelope.cwd)
2036
2081
  if (
2037
2082
  retirement !== null &&
2038
2083
  !retiring.some((parked) => parked.request_id === retirement.request_id)
@@ -2040,31 +2085,24 @@ export async function handleUserPromptSubmit(
2040
2085
  retiring.push(retirement)
2041
2086
  }
2042
2087
  }
2043
- return {
2044
- last_prompt_at: ctx.now(),
2045
- ...(current.last_stop_at === undefined ? {} : { last_stop_at: current.last_stop_at }),
2046
- ...(unasked.length > 0 ? { pending: unasked } : {}),
2047
- ...(retiring.length > 0 ? { retiring } : {}),
2048
- ...(current.accepted === undefined ? {} : { accepted: current.accepted }),
2049
- // Carried across a prompt on purpose. This rewrite rebuilds the state
2050
- // from named fields, so anything omitted is silently erased — and an
2051
- // erased obligation means the user never learns their reply was read,
2052
- // which is the one thing acknowledgement exists to guarantee.
2053
- ...(current.acknowledgement_due === undefined
2054
- ? {}
2055
- : { acknowledgement_due: current.acknowledgement_due }),
2056
- ...(current.acknowledgement_blocks === undefined
2057
- ? {}
2058
- : { acknowledgement_blocks: current.acknowledgement_blocks }),
2059
- // The user typed, so whatever chain of answer-driven continuations was
2060
- // running, a human has taken the turn and the consecutive count starts
2061
- // over. Only a real prompt reaches here: the journal branch above returns
2062
- // first, and a wake route's injected message always arrives while its own
2063
- // answer is still journaled, so it can never pass for the user's presence.
2064
- ...(current.continuation === undefined
2065
- ? {}
2066
- : { continuation: { ...current.continuation, count: 0 } }),
2088
+
2089
+ // Start from the whole document so fields introduced by a newer CLI remain
2090
+ // attached while this older writer applies the prompt transition it knows.
2091
+ const next: SessionState = { ...current, last_prompt_at: ctx.now() }
2092
+ if (unasked.length > 0) next.pending = unasked
2093
+ else delete next.pending
2094
+ if (retiring.length > 0) next.retiring = retiring
2095
+ else delete next.retiring
2096
+
2097
+ // The user typed, so whatever chain of answer-driven continuations was
2098
+ // running, a human has taken the turn and the consecutive count starts
2099
+ // over. Only a real prompt reaches here: the journal branch above returns
2100
+ // first, and a wake route's injected message always arrives while its own
2101
+ // answer is still journaled, so it can never pass for the user's presence.
2102
+ if (current.continuation !== undefined) {
2103
+ next.continuation = { ...current.continuation, count: 0 }
2067
2104
  }
2105
+ return next
2068
2106
  })
2069
2107
  // The bridge that lets a plain `notifai ask` find the hook's canonical
2070
2108
  // session: an agent shell command gets no hook payload, and not every
@@ -2188,9 +2226,16 @@ export async function runEscalationWaiter(
2188
2226
  accepted.delivered_at !== undefined || envelope.stop_hook_active === true
2189
2227
  if (!deliveryProven) {
2190
2228
  for (const answer of accepted.answers) reportAnswer(ctx, notes, answer, true)
2191
- return await deliverAcceptedAnswers(ctx, sessionId, options.route, accepted, notes)
2229
+ return await deliverAcceptedAnswers(
2230
+ ctx,
2231
+ sessionId,
2232
+ options.route,
2233
+ accepted,
2234
+ notes,
2235
+ envelope.cwd,
2236
+ )
2192
2237
  }
2193
- settleAcceptedAnswers(ctx, sessionId, accepted)
2238
+ settleAcceptedAnswers(ctx, sessionId, accepted, envelope.cwd)
2194
2239
  settledAnswerThisPass = true
2195
2240
  state = readSessionState(sessionId, ctx.env)
2196
2241
  const currentAcceptedIds = new Set(
@@ -2286,6 +2331,7 @@ async function deliverAcceptedAnswers(
2286
2331
  route: EscalationDeliveryRoute,
2287
2332
  accepted: AcceptedAnswerDelivery,
2288
2333
  notes: string[],
2334
+ cwd?: string,
2289
2335
  ): Promise<HookOutcome> {
2290
2336
  const { answers: answered, remaining } = accepted
2291
2337
  const requestIds = summarizeRequestIds(answered.map((entry) => entry.pending)).ids
@@ -2297,7 +2343,7 @@ async function deliverAcceptedAnswers(
2297
2343
  limit: MAX_HELD_DELIVERIES,
2298
2344
  request_ids: requestIds,
2299
2345
  })
2300
- settleAcceptedAnswers(ctx, sessionId, accepted)
2346
+ settleAcceptedAnswers(ctx, sessionId, accepted, cwd)
2301
2347
  notes.push(
2302
2348
  `no route could hand the user's answer over in ${MAX_HELD_DELIVERIES} turns; not holding it any longer`,
2303
2349
  )
@@ -2314,7 +2360,7 @@ async function deliverAcceptedAnswers(
2314
2360
  // Settle rather than journal for ever. The answer has been offered as often
2315
2361
  // as any working route could need, it is already in the notes of each of
2316
2362
  // those turns, and the questions it answered still deserve retirement.
2317
- settleAcceptedAnswers(ctx, sessionId, accepted)
2363
+ settleAcceptedAnswers(ctx, sessionId, accepted, cwd)
2318
2364
  notes.push(
2319
2365
  `the user's answer reached the ${route.kind} route ${MAX_CONTINUATION_COUNT} times without being acknowledged; not delivering it again`,
2320
2366
  )
@@ -2464,7 +2510,7 @@ async function handleClaimedStop(
2464
2510
  for (const answer of authoritative) reportAnswer(ctx, notes, answer, true)
2465
2511
  // Anything still unasked rides the next Stop: the agent is being resumed
2466
2512
  // with answers right now, and may not even need the rest afterwards.
2467
- return deliverAcceptedAnswers(ctx, sessionId, route, accepted, notes)
2513
+ return deliverAcceptedAnswers(ctx, sessionId, route, accepted, notes, envelope.cwd)
2468
2514
  }
2469
2515
  const recoverableLive = live.filter(
2470
2516
  (entry) => !permanentFailures.has(entry.request_id!),
@@ -2611,6 +2657,7 @@ async function escalate(
2611
2657
  // server accepts answers into changed.
2612
2658
  const ownerDeadlineAt = ceilingAt
2613
2659
  const questions = pendingQuestions(entry)
2660
+ const eventSource = sourceContextAtHookEvent(entry.source, envelope.cwd)
2614
2661
  let intent = entry.submission
2615
2662
  if (intent === undefined) {
2616
2663
  if (entry.body === undefined) {
@@ -2624,7 +2671,7 @@ async function escalate(
2624
2671
  questions,
2625
2672
  ...(entry.media !== undefined ? { media: entry.media } : {}),
2626
2673
  ...(entry.project !== undefined ? { project: entry.project } : {}),
2627
- ...(entry.source !== undefined ? { source: entry.source } : {}),
2674
+ ...(eventSource !== undefined ? { source: eventSource } : {}),
2628
2675
  event: 'agent_question',
2629
2676
  windowSeconds: replyWindowSeconds,
2630
2677
  ownerDeadlineAt,
@@ -2703,7 +2750,7 @@ async function escalate(
2703
2750
  questions,
2704
2751
  ...(entry.media !== undefined ? { media: entry.media } : {}),
2705
2752
  ...(entry.project !== undefined ? { project: entry.project } : {}),
2706
- ...(entry.source !== undefined ? { source: entry.source } : {}),
2753
+ ...(eventSource !== undefined ? { source: eventSource } : {}),
2707
2754
  event: 'agent_question',
2708
2755
  windowSeconds: replyWindowSeconds,
2709
2756
  ownerDeadlineAt,
@@ -2783,6 +2830,7 @@ async function escalate(
2783
2830
  : Date.parse(receipt.reply_expires_at)
2784
2831
  const live: PendingQuestion = {
2785
2832
  ...entry,
2833
+ ...(intent.draft.source !== undefined ? { source: intent.draft.source } : {}),
2786
2834
  request_id: intent.request_id,
2787
2835
  collapse_key: intent.collapse_key,
2788
2836
  device_ids: intent.device_ids,
@@ -2802,6 +2850,7 @@ async function escalate(
2802
2850
  ? committedReplyDeadline
2803
2851
  : intent.owner_deadline_at,
2804
2852
  }
2853
+ if (intent.draft.source === undefined) delete live.source
2805
2854
  delete live.submission
2806
2855
  submitted.push(live)
2807
2856
  // Record what is now live on the user's devices BEFORE any wait. If we
@@ -2823,7 +2872,7 @@ async function escalate(
2823
2872
  // The entry vanished while the submit was in flight (the user's prompt
2824
2873
  // wiped the queue). The delivered notification must still be retirable,
2825
2874
  // so park it rather than lose its only identifiers.
2826
- const retirement = retiringQuestion(live, 'answered_elsewhere')!
2875
+ const retirement = retiringQuestion(live, 'answered_elsewhere', envelope.cwd)!
2827
2876
  const retiring = [...(current.retiring ?? [])]
2828
2877
  if (!retiring.some((parked) => parked.request_id === retirement.request_id)) {
2829
2878
  retiring.push(retirement)
@@ -2863,7 +2912,7 @@ async function escalate(
2863
2912
  )
2864
2913
  const accepted = stageAcceptedAnswers(ctx, sessionId, staleAnswers, remaining)
2865
2914
  for (const answer of staleAnswers) reportAnswer(ctx, notes, answer, true)
2866
- return deliverAcceptedAnswers(ctx, sessionId, route, accepted, notes)
2915
+ return deliverAcceptedAnswers(ctx, sessionId, route, accepted, notes, envelope.cwd)
2867
2916
  }
2868
2917
  const waitingOn = [
2869
2918
  ...alreadyLive.filter(
@@ -2941,7 +2990,7 @@ async function escalate(
2941
2990
  )
2942
2991
  const accepted = stageAcceptedAnswers(ctx, sessionId, finalAnswers, remaining)
2943
2992
  for (const answer of finalAnswers) reportAnswer(ctx, notes, answer, false)
2944
- return deliverAcceptedAnswers(ctx, sessionId, route, accepted, notes)
2993
+ return deliverAcceptedAnswers(ctx, sessionId, route, accepted, notes, envelope.cwd)
2945
2994
  }
2946
2995
  const requestIdSummary = summarizeRequestIds(waitingOn)
2947
2996
  ctx.log?.info('hook.answer', {
@@ -2996,7 +3045,7 @@ async function escalate(
2996
3045
  waitingOn.length - answered.length,
2997
3046
  )
2998
3047
  for (const answer of answered) reportAnswer(ctx, notes, answer, false)
2999
- return deliverAcceptedAnswers(ctx, sessionId, route, accepted, notes)
3048
+ return deliverAcceptedAnswers(ctx, sessionId, route, accepted, notes, envelope.cwd)
3000
3049
  }
3001
3050
 
3002
3051
  // ---------------------------------------------------------------------------
@@ -3025,7 +3074,7 @@ export function handleSessionEnd(
3025
3074
  const orphans: RetiringQuestion[] = [...(state.retiring ?? [])]
3026
3075
  for (const entry of pendingList(state)) {
3027
3076
  try {
3028
- const orphan = retiringQuestion(entry, 'expired')
3077
+ const orphan = retiringQuestion(entry, 'expired', envelope.cwd)
3029
3078
  if (orphan !== null) orphans.push(orphan)
3030
3079
  } catch (err) {
3031
3080
  notes.push(err instanceof Error ? err.message : String(err))
@@ -3044,14 +3093,13 @@ export function handleSessionEnd(
3044
3093
  )
3045
3094
  }
3046
3095
  if (state.accepted !== undefined || (state.acknowledgement_due?.length ?? 0) > 0) {
3047
- const preserved: SessionState = {}
3048
- if (state.accepted !== undefined) preserved.accepted = state.accepted
3049
- if (state.acknowledgement_due !== undefined && state.acknowledgement_due.length > 0) {
3050
- preserved.acknowledgement_due = state.acknowledgement_due
3096
+ const preserved: SessionState = { ...state }
3097
+ delete preserved.pending
3098
+ delete preserved.retiring
3099
+ delete preserved.acknowledgement_blocks
3100
+ if ((preserved.acknowledgement_due?.length ?? 0) === 0) {
3101
+ delete preserved.acknowledgement_due
3051
3102
  }
3052
- if (state.last_prompt_at !== undefined) preserved.last_prompt_at = state.last_prompt_at
3053
- if (state.last_stop_at !== undefined) preserved.last_stop_at = state.last_stop_at
3054
- if (state.continuation !== undefined) preserved.continuation = state.continuation
3055
3103
  writeSessionState(sessionId, env, preserved)
3056
3104
  notes.push(
3057
3105
  state.accepted !== undefined
@@ -838,11 +838,94 @@ describe('the OpenCode adapter', () => {
838
838
  expect(source).not.toContain('synthetic: true')
839
839
  })
840
840
 
841
- it('injects an exact active-harness and session marker into OpenCode shells', () => {
841
+ it('injects exact identity and the first-party OpenCode title into agent shells', () => {
842
842
  expect(source).toContain('"shell.env"')
843
843
  expect(source).toContain('NOTIFAI_ACTIVE_HARNESS')
844
844
  expect(source).toContain('NOTIFAI_ACTIVE_SESSION_ID')
845
+ expect(source).toContain('NOTIFAI_ACTIVE_SESSION_LABEL')
846
+ expect(source).toContain('NOTIFAI_ACTIVE_SESSION_LABEL_PENDING')
845
847
  expect(source).toContain('input?.sessionID')
848
+ expect(source).toContain('client.session.get({ path: { id: sessionID } })')
849
+ expect(source).toContain('response?.data?.title')
850
+ })
851
+
852
+ it('executes the generated adapter and publishes the SDK session title', async () => {
853
+ let requested = ''
854
+ const generated = (await import(
855
+ `data:text/javascript;base64,${Buffer.from(source).toString('base64')}`
856
+ )) as {
857
+ NotifAIPlugin(input: {
858
+ directory: string
859
+ client: {
860
+ session: {
861
+ get(input: { path: { id: string } }): Promise<{ data: { title: string } }>
862
+ }
863
+ }
864
+ }): Promise<{
865
+ 'shell.env'?: (
866
+ input: { sessionID?: string },
867
+ output: { env: Record<string, string> },
868
+ ) => Promise<void>
869
+ }>
870
+ }
871
+ const plugin = await generated.NotifAIPlugin({
872
+ directory: '/repo',
873
+ client: {
874
+ session: {
875
+ get: async ({ path: request }) => {
876
+ requested = request.id
877
+ return { data: { title: 'Semantic session names' } }
878
+ },
879
+ },
880
+ },
881
+ })
882
+ const output = { env: {} as Record<string, string> }
883
+ await plugin['shell.env']?.({ sessionID: 'opencode-session' }, output)
884
+
885
+ expect(requested).toBe('opencode-session')
886
+ expect(output.env).toMatchObject({
887
+ NOTIFAI_ACTIVE_HARNESS: 'opencode',
888
+ NOTIFAI_ACTIVE_SESSION_ID: 'opencode-session',
889
+ NOTIFAI_ACTIVE_SESSION_LABEL: 'Semantic session names',
890
+ })
891
+ })
892
+
893
+ it('marks OpenCode placeholder titles as pending instead of publishing them', async () => {
894
+ const generated = (await import(
895
+ `data:text/javascript;base64,${Buffer.from(source).toString('base64')}`
896
+ )) as {
897
+ NotifAIPlugin(input: {
898
+ directory: string
899
+ client: {
900
+ session: {
901
+ get(input: { path: { id: string } }): Promise<{ data: { title: string } }>
902
+ }
903
+ }
904
+ }): Promise<{
905
+ 'shell.env'?: (
906
+ input: { sessionID?: string },
907
+ output: { env: Record<string, string> },
908
+ ) => Promise<void>
909
+ }>
910
+ }
911
+ const plugin = await generated.NotifAIPlugin({
912
+ directory: '/repo',
913
+ client: {
914
+ session: {
915
+ get: async () => ({
916
+ data: { title: 'New session - 2026-08-20T13:05:00.000Z' },
917
+ }),
918
+ },
919
+ },
920
+ })
921
+ const output = { env: {} as Record<string, string> }
922
+ await plugin['shell.env']?.({ sessionID: 'opencode-session' }, output)
923
+
924
+ expect(output.env).toEqual({
925
+ NOTIFAI_ACTIVE_HARNESS: 'opencode',
926
+ NOTIFAI_ACTIVE_SESSION_ID: 'opencode-session',
927
+ NOTIFAI_ACTIVE_SESSION_LABEL_PENDING: '1',
928
+ })
846
929
  })
847
930
 
848
931
  it('carries the ownership marker so a second checkout replaces it', () => {
@@ -58,7 +58,25 @@ function isolatedEnv(cwd: string): NodeJS.ProcessEnv {
58
58
  function makeClient(): ApiClient {
59
59
  return {
60
60
  health: async () => true,
61
- capabilities: async () => ({ schema_version: 1, platform: 'ios' }),
61
+ capabilities: async (platform = 'ios') => ({ schema_version: 1, platform }),
62
+ compatibility: async () => ({
63
+ cli: {
64
+ state: 'current',
65
+ reason: 'current',
66
+ affected_operation: null,
67
+ recovery_action: null,
68
+ current_version: '5.0.0',
69
+ current_build: null,
70
+ recommended_version: '5.0.0',
71
+ recommended_build: null,
72
+ minimum_version: null,
73
+ minimum_build: null,
74
+ deprecation: null,
75
+ sunset: null,
76
+ },
77
+ platforms: [],
78
+ server_capabilities: ['answer', 'agent_acknowledgement'],
79
+ }),
62
80
  listDevices: async () => ({ devices: [] }),
63
81
  accessStatus: async () => ({ email: 'rafael@example.test' }),
64
82
  } as unknown as ApiClient
@@ -365,7 +365,7 @@ function menuFor(readiness: Readiness): { value: Screen; label: string; hint?: s
365
365
  if (!signedIn) {
366
366
  options.push({ value: 'account', label: 'Sign in', hint: 'approve this machine in your browser' })
367
367
  } else if (!hasDevice) {
368
- options.push({ value: 'devices', label: 'Add a device', hint: 'install the app on your phone or Mac' })
368
+ options.push({ value: 'devices', label: 'Add a device', hint: 'install the app on your iPhone' })
369
369
  } else {
370
370
  options.push({
371
371
  value: 'test',
@@ -457,8 +457,8 @@ async function devicesScreen(deps: CommandDeps): Promise<void> {
457
457
  if (devices.length === 0) {
458
458
  clack.note(
459
459
  'No devices are registered yet.\n\n' +
460
- 'Install the Notifai companion app on your iPhone or Mac, sign in with the\n' +
461
- 'same account, and allow notifications when it asks.',
460
+ 'Install the Notifai Companion App on your iPhone, sign in with the\n' +
461
+ 'same Account, and allow notifications when it asks.',
462
462
  'No devices',
463
463
  )
464
464
  return
@@ -733,7 +733,7 @@ async function routingScreen(deps: CommandDeps, readiness: Readiness): Promise<b
733
733
  const row = (label: string, value: string): string => `${style.dim(label.padEnd(16))}${value}`
734
734
  clack.note(
735
735
  'When an agent registers a question, it appears in your terminal first.\n' +
736
- 'After the grace below, it goes to your phone and Mac as a notification\n' +
736
+ 'After the grace below, it goes to your iPhone as a notification\n' +
737
737
  'with real answer buttons, and your answer comes back to the agent —\n' +
738
738
  'whether or not you are still at this machine.\n\n' +
739
739
  [