@raidiant/notifai 9.0.0 → 9.1.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 (68) hide show
  1. package/CHANGELOG.md +7 -0
  2. package/dist/command-session.d.ts +14 -0
  3. package/dist/command-session.js +30 -0
  4. package/dist/command-session.js.map +1 -0
  5. package/dist/commands-doctor.js +17 -15
  6. package/dist/commands-doctor.js.map +1 -1
  7. package/dist/commands-guidance.js +3 -29
  8. package/dist/commands-guidance.js.map +1 -1
  9. package/dist/commands-harness-context.js +21 -1
  10. package/dist/commands-harness-context.js.map +1 -1
  11. package/dist/commands-hooks.d.ts +11 -1
  12. package/dist/commands-hooks.js +97 -141
  13. package/dist/commands-hooks.js.map +1 -1
  14. package/dist/commands-init.d.ts +2 -0
  15. package/dist/commands-init.js +41 -21
  16. package/dist/commands-init.js.map +1 -1
  17. package/dist/commands-send.d.ts +1 -0
  18. package/dist/commands-send.js +62 -14
  19. package/dist/commands-send.js.map +1 -1
  20. package/dist/commands-setup-proof.d.ts +4 -3
  21. package/dist/commands-setup-proof.js +29 -14
  22. package/dist/commands-setup-proof.js.map +1 -1
  23. package/dist/commands.d.ts +1 -1
  24. package/dist/commands.js +1 -1
  25. package/dist/commands.js.map +1 -1
  26. package/dist/guidance-render.d.ts +20 -0
  27. package/dist/guidance-render.js +42 -0
  28. package/dist/guidance-render.js.map +1 -0
  29. package/dist/hooks.d.ts +17 -2
  30. package/dist/hooks.js +59 -7
  31. package/dist/hooks.js.map +1 -1
  32. package/dist/opencode-plugin.js +16 -5
  33. package/dist/opencode-plugin.js.map +1 -1
  34. package/dist/program.js +21 -12
  35. package/dist/program.js.map +1 -1
  36. package/dist/readiness.d.ts +6 -0
  37. package/dist/readiness.js +37 -0
  38. package/dist/readiness.js.map +1 -1
  39. package/dist/send-attempts.d.ts +26 -0
  40. package/dist/send-attempts.js +139 -0
  41. package/dist/send-attempts.js.map +1 -0
  42. package/dist/session-activation.d.ts +4 -8
  43. package/dist/session-activation.js +25 -14
  44. package/dist/session-activation.js.map +1 -1
  45. package/package.json +1 -1
  46. package/src/command-session.ts +38 -0
  47. package/src/commands-doctor.ts +18 -28
  48. package/src/commands-guidance.ts +3 -38
  49. package/src/commands-harness-context.ts +24 -1
  50. package/src/commands-hooks.ts +217 -170
  51. package/src/commands-init.ts +46 -24
  52. package/src/commands-send.ts +70 -14
  53. package/src/commands-setup-proof.test.ts +72 -0
  54. package/src/commands-setup-proof.ts +33 -14
  55. package/src/commands.test.ts +209 -67
  56. package/src/commands.ts +1 -0
  57. package/src/guidance-render.ts +56 -0
  58. package/src/guidance.test.ts +28 -0
  59. package/src/hooks.test.ts +38 -7
  60. package/src/hooks.ts +69 -5
  61. package/src/install-hooks.test.ts +3 -3
  62. package/src/opencode-plugin.ts +16 -5
  63. package/src/program.ts +22 -12
  64. package/src/readiness.ts +44 -0
  65. package/src/send-attempts.test.ts +143 -0
  66. package/src/send-attempts.ts +188 -0
  67. package/src/session-activation.ts +34 -17
  68. package/src/skill.test.ts +12 -14
@@ -41,8 +41,6 @@ import {
41
41
  parkForRetirement,
42
42
  parseHookInput,
43
43
  pruneAbandonedSessions,
44
- readMatchingProjectSessionPointer,
45
- readProjectSession,
46
44
  readSessionState,
47
45
  resetCursorStopActivation,
48
46
  registerQuestion,
@@ -123,6 +121,7 @@ import {
123
121
  uploadImage,
124
122
  waitForReply,
125
123
  } from './commands-send-support.js'
124
+ import { resolveCommandSession } from './command-session.js'
126
125
 
127
126
  // ---------------------------------------------------------------------------
128
127
  // hook / ask / close — harness integration
@@ -282,6 +281,8 @@ export async function hookRunCommand(
282
281
  const stdout = sessionActivationOutput(
283
282
  harness,
284
283
  event === 'session-start' ? 'SessionStart' : 'SubagentStart',
284
+ cwd,
285
+ deps.env,
285
286
  )
286
287
  if (stdout !== undefined) deps.io.out(stdout)
287
288
  if (
@@ -294,7 +295,7 @@ export async function hookRunCommand(
294
295
  // Write stdout first. If the process dies between these operations a
295
296
  // later prompt may duplicate context, but it cannot suppress context
296
297
  // that the host never received.
297
- markSessionActivation(envelope.session_id, deps.env, now(), activationOwnerPid)
298
+ markSessionActivation(envelope.session_id, deps.env, now(), activationOwnerPid, harness, cwd)
298
299
  } catch (err) {
299
300
  logger.error('hook.end', {
300
301
  hook: event,
@@ -325,8 +326,8 @@ export async function hookRunCommand(
325
326
  envelope.session_id !== undefined
326
327
  ) {
327
328
  try {
328
- if (claimPromptActivation(envelope.session_id, deps.env, now(), activationOwnerPid)) {
329
- promptActivationStdout = sessionActivationOutput(harness, 'UserPromptSubmit')
329
+ if (claimPromptActivation(envelope.session_id, deps.env, now(), activationOwnerPid, harness, cwd)) {
330
+ promptActivationStdout = sessionActivationOutput(harness, 'UserPromptSubmit', cwd, deps.env)
330
331
  if (promptActivationStdout !== undefined) deps.io.out(promptActivationStdout)
331
332
  }
332
333
  } catch (err) {
@@ -417,7 +418,7 @@ export async function hookRunCommand(
417
418
  outcome: claimed ? 'followup-added' : 'already-activated',
418
419
  decided: claimed,
419
420
  })
420
- if (claimed) deps.io.out(cursorStopActivationOutput())
421
+ if (claimed) deps.io.out(cursorStopActivationOutput(cwd, deps.env))
421
422
  return EXIT.ok
422
423
  }
423
424
 
@@ -666,10 +667,52 @@ export interface AskFlags {
666
667
  image?: string[]
667
668
  imageAlt?: string[]
668
669
  project?: string
669
- sessionId?: string
670
670
  sessionLabel?: string
671
671
  }
672
672
 
673
+ export interface AskFailure {
674
+ ok: false
675
+ registered: false
676
+ code: string
677
+ check_id: string
678
+ exit_code: number
679
+ remedy: string
680
+ message: string
681
+ }
682
+
683
+ /** One stable failure contract for every machine-readable ask refusal. */
684
+ export function reportAskFailure(
685
+ deps: CommandDeps,
686
+ flags: Pick<AskFlags, 'json'>,
687
+ failure: Omit<AskFailure, 'ok' | 'registered'>,
688
+ ): number {
689
+ if (flags.json === true) {
690
+ deps.io.out(JSON.stringify({ ok: false, registered: false, ...failure }, null, 2))
691
+ } else {
692
+ deps.io.err(failure.message)
693
+ deps.io.err(`next: ${failure.remedy}`)
694
+ }
695
+ return failure.exit_code
696
+ }
697
+
698
+ function askFailure(
699
+ deps: CommandDeps,
700
+ flags: AskFlags,
701
+ code: string,
702
+ checkId: string,
703
+ message: string,
704
+ remedy: string,
705
+ exitCode: number = EXIT.usage,
706
+ ): number {
707
+ return reportAskFailure(deps, flags, {
708
+ code,
709
+ check_id: checkId,
710
+ exit_code: exitCode,
711
+ remedy,
712
+ message,
713
+ })
714
+ }
715
+
673
716
  /** The `--form` document: what an agent writes to ask several things at once. */
674
717
  interface AskFormQuestion {
675
718
  text: string
@@ -863,8 +906,15 @@ function recordRegisteredQuestion(
863
906
  )
864
907
  } catch (err) {
865
908
  log(deps).error('ask.registered', { ok: false, session: sessionId, message: String(err) })
866
- deps.io.err(`Could not register the question: ${err instanceof Error ? err.message : String(err)}`)
867
- return EXIT.failed
909
+ return askFailure(
910
+ deps,
911
+ { json },
912
+ 'registration_failed',
913
+ 'question_registration',
914
+ `Could not register the question: ${err instanceof Error ? err.message : String(err)}`,
915
+ 'retire an older pending question if the limit was reached, then retry the same ask',
916
+ EXIT.failed,
917
+ )
868
918
  }
869
919
  log(deps).info('ask.registered', {
870
920
  ok: true,
@@ -962,7 +1012,17 @@ async function uploadAskMedia(
962
1012
  invocation: DraftInvocation,
963
1013
  ): Promise<number> {
964
1014
  const authed = authedClient(deps, config)
965
- if (!authed) return EXIT.auth
1015
+ if (!authed) {
1016
+ return askFailure(
1017
+ deps,
1018
+ flags,
1019
+ 'auth_required',
1020
+ 'credential',
1021
+ 'Question routing is not paired on this machine.',
1022
+ 'run `notifai init --json`',
1023
+ EXIT.auth,
1024
+ )
1025
+ }
966
1026
  const mediaIds: string[] = []
967
1027
  for (const image of flags.image ?? []) {
968
1028
  if (image.startsWith('med_')) {
@@ -972,14 +1032,21 @@ async function uploadAskMedia(
972
1032
  const uploaded = await uploadImage(deps, authed.client, image, config.media_origins.value)
973
1033
  if (!uploaded.ok) {
974
1034
  if (uploaded.error !== null) deps.io.err(uploaded.error)
975
- return uploaded.exit
1035
+ return askFailure(
1036
+ deps,
1037
+ flags,
1038
+ 'media_upload_failed',
1039
+ 'media',
1040
+ uploaded.error ?? 'The image upload failed.',
1041
+ 'fix the reported image or network problem, then retry the same ask',
1042
+ uploaded.exit,
1043
+ )
976
1044
  }
977
1045
  mediaIds.push(uploaded.mediaId)
978
1046
  }
979
1047
  const ready = buildAskDraft(config, built, flags, invocation, mediaIds)
980
1048
  if (!ready.ok) {
981
- deps.io.err(ready.error)
982
- return EXIT.usage
1049
+ return askFailure(deps, flags, 'invalid_draft', 'draft', ready.error, 'fix the reported field and retry')
983
1050
  }
984
1051
  return recordRegisteredQuestion(deps, sessionId, built, ready.draft, flags.json === true)
985
1052
  }
@@ -999,31 +1066,26 @@ export function askCommand(
999
1066
  // happens to exist on this machine.
1000
1067
  const escapedBody = rejectAccidentalEscapedNewlines(flags.body, flags.literalBackslashN)
1001
1068
  if (escapedBody !== null) {
1002
- deps.io.err(escapedBody)
1003
- return EXIT.usage
1069
+ return askFailure(deps, flags, 'invalid_input', 'body', escapedBody, 'fix the body and retry')
1004
1070
  }
1005
1071
  const built = buildQuestions(flags, question)
1006
1072
  if (!built.ok) {
1007
- deps.io.err(built.error)
1008
- return EXIT.usage
1073
+ return askFailure(deps, flags, 'invalid_input', 'questions', built.error, 'fix the question input and retry')
1009
1074
  }
1010
1075
  const mediaInputError = validateMediaInputs(flags.image, flags.imageAlt)
1011
1076
  if (mediaInputError !== null) {
1012
- deps.io.err(mediaInputError)
1013
- return EXIT.usage
1014
- }
1015
- const routingConfig = loadConfig({ cwd: deps.cwd, env: deps.env })
1016
- if (!routingConfig.ask_notifications.value) {
1017
- deps.io.err(
1018
- 'Question routing is disabled by ask_notifications=false; enable it or use a blocking `notifai send --reply` question.',
1019
- )
1020
- return EXIT.usage
1077
+ return askFailure(deps, flags, 'invalid_input', 'media', mediaInputError, 'fix the media flags and retry')
1021
1078
  }
1022
1079
  if (deps.store.load() === null) {
1023
- deps.io.err(
1024
- 'Question routing is not paired on this machine; run `notifai login` before registering an asynchronous question.',
1080
+ return askFailure(
1081
+ deps,
1082
+ flags,
1083
+ 'auth_required',
1084
+ 'credential',
1085
+ 'Question routing is not paired on this machine.',
1086
+ 'run `notifai init --json`',
1087
+ EXIT.auth,
1025
1088
  )
1026
- return EXIT.usage
1027
1089
  }
1028
1090
  // An agent calling this gets no hook payload. Harness-native environment
1029
1091
  // markers identify the active owner, while UserPromptSubmit adds the hook's
@@ -1032,78 +1094,127 @@ export function askCommand(
1032
1094
  const { active, contested } = resolveActiveHarness(deps.env, deps.cwd, now)
1033
1095
  let sessionId: string | undefined
1034
1096
  if (active !== null) {
1035
- const installations = findInstallations(deps.cwd, deps.env, deps.hookAdapterHome, deps.hookPlatform)
1097
+ if (contested.length > 1) {
1098
+ return askFailure(
1099
+ deps,
1100
+ flags,
1101
+ 'session_identity_ambiguous',
1102
+ 'exact_session',
1103
+ `Several harness sessions could own this shell (${contested.map((candidate) => candidate.label).join(', ')}).`,
1104
+ 'run the ask from a shell with one exact active harness session',
1105
+ )
1106
+ }
1107
+ const exactState = active.sessionId === undefined
1108
+ ? null
1109
+ : readSessionState(active.sessionId, deps.env)
1110
+ const installationCwd = exactState?.activation_cwd ?? deps.cwd
1111
+ const installationDeps = installationCwd === deps.cwd ? deps : { ...deps, cwd: installationCwd }
1112
+ const installations = findInstallations(installationCwd, deps.env, deps.hookAdapterHome, deps.hookPlatform)
1036
1113
  const activeInstalled = installations.some(
1037
1114
  (installation) => installation.harness === active.harness,
1038
1115
  )
1039
1116
  if (!activeInstalled) {
1040
- for (const line of diagnoseActiveHarnessSession(
1041
- active,
1042
- 'not-installed',
1043
- installations,
1044
- contested,
1045
- )) {
1046
- deps.io.err(line)
1047
- }
1048
- return EXIT.usage
1117
+ return askFailure(
1118
+ deps,
1119
+ flags,
1120
+ 'hooks_not_installed',
1121
+ 'hook_installation',
1122
+ `Notifai ${active.label} hooks are not installed for this project.`,
1123
+ `run \`notifai init --json\` from this ${active.label} session`,
1124
+ )
1049
1125
  }
1050
1126
  if (active.sessionId === undefined) {
1051
- for (const problem of activeQuestionRouteProblems(deps, active, installations)) {
1052
- deps.io.err(`Question routing is not ready: ${problem}`)
1053
- }
1054
- return EXIT.usage
1055
- }
1056
- const projectPointer = readMatchingProjectSessionPointer(
1057
- deps.cwd,
1058
- deps.env,
1059
- now,
1060
- active.sessionId,
1061
- active.harness,
1062
- )
1063
- if (projectPointer === null) {
1064
- for (const line of diagnoseActiveHarnessSession(
1065
- active,
1066
- 'not-fired',
1067
- installations,
1068
- contested,
1069
- )) {
1070
- deps.io.err(line)
1071
- }
1072
- return EXIT.usage
1127
+ return askFailure(
1128
+ deps,
1129
+ flags,
1130
+ 'session_identity_missing',
1131
+ 'exact_session',
1132
+ `The active ${active.label} shell does not expose an exact session id.`,
1133
+ 'use a blocking `notifai send --reply` question',
1134
+ )
1073
1135
  }
1074
- const routeProblems = activeQuestionRouteProblems(deps, active, installations)
1136
+ const routeProblems = activeQuestionRouteProblems(installationDeps, active, installations)
1075
1137
  if (routeProblems.length > 0) {
1076
- for (const problem of routeProblems) deps.io.err(`Question routing is not ready: ${problem}`)
1077
- return EXIT.usage
1138
+ return askFailure(
1139
+ deps,
1140
+ flags,
1141
+ 'question_routing_unavailable',
1142
+ 'hook_contract',
1143
+ `Question routing is not ready: ${routeProblems.join('; ')}`,
1144
+ 'run `notifai init --json` and follow its question-routing remedy',
1145
+ )
1078
1146
  }
1079
- if (readSessionState(projectPointer.sessionId, deps.env).last_stop_at === undefined) {
1080
- deps.io.err(
1081
- `Question routing is not ready: this ${active.label} session has fired UserPromptSubmit, but its Stop hook has not been observed. End one harmless turn, send a new prompt, then run \`notifai doctor\`.`,
1147
+ const state = exactState ?? readSessionState(active.sessionId, deps.env)
1148
+ if (state.harness !== active.harness || state.last_prompt_at === undefined) {
1149
+ return askFailure(
1150
+ deps,
1151
+ flags,
1152
+ 'session_not_activated',
1153
+ 'user_prompt_submit',
1154
+ `This exact ${active.label} session has not fired UserPromptSubmit.`,
1155
+ `send one prompt in this ${active.label} session, then retry \`notifai ask --json\``,
1156
+ )
1157
+ }
1158
+ if (state.last_stop_at === undefined) {
1159
+ return askFailure(
1160
+ deps,
1161
+ flags,
1162
+ 'stop_not_observed',
1163
+ 'stop_hook',
1164
+ `This exact ${active.label} session has fired UserPromptSubmit, but its Stop hook has not been observed.`,
1165
+ `end one harmless turn, send a new prompt, then retry \`notifai ask --json\``,
1082
1166
  )
1083
- return EXIT.usage
1084
1167
  }
1085
- sessionId = projectPointer.sessionId
1168
+ sessionId = active.sessionId
1086
1169
  } else {
1087
- for (const line of diagnoseMissingSession(deps)) deps.io.err(line)
1088
- deps.io.err(
1089
- 'Could not prove which live harness session owns this command, so Notifai will not register a question that could be delivered to the wrong or already-ended agent. Run it from a supported harness with exact session identity, or use a blocking `notifai send --reply` question.',
1170
+ return askFailure(
1171
+ deps,
1172
+ flags,
1173
+ 'session_identity_missing',
1174
+ 'exact_session',
1175
+ 'Could not prove which live harness session owns this command.',
1176
+ 'run it from a supported harness with exact session identity, or use a blocking `notifai send --reply` question',
1090
1177
  )
1091
- return EXIT.usage
1092
1178
  }
1093
1179
  if (!sessionId) {
1094
- for (const line of diagnoseMissingSession(deps)) deps.io.err(line)
1095
- return EXIT.usage
1180
+ return askFailure(deps, flags, 'session_identity_missing', 'exact_session', 'No exact active session is available.', 'use a blocking `notifai send --reply` question')
1181
+ }
1182
+ let routingConfig: CliConfig
1183
+ try {
1184
+ routingConfig = loadConfig({ cwd: deps.cwd, env: deps.env, sessionId })
1185
+ } catch (err) {
1186
+ return askFailure(
1187
+ deps,
1188
+ flags,
1189
+ 'config_invalid',
1190
+ 'configuration',
1191
+ `Question routing configuration is invalid: ${String(err)}`,
1192
+ 'fix the reported Notifai configuration and retry',
1193
+ )
1194
+ }
1195
+ if (!routingConfig.ask_notifications.value) {
1196
+ return askFailure(
1197
+ deps,
1198
+ flags,
1199
+ 'question_routing_disabled',
1200
+ 'ask_notifications',
1201
+ 'Question routing is disabled by ask_notifications=false.',
1202
+ 'enable question routing or use a blocking `notifai send --reply` question',
1203
+ )
1096
1204
  }
1097
1205
  const source = resolveDraftInvocation(deps, flags, active)
1098
1206
  if (!source.ok) {
1099
- deps.io.err(source.error)
1100
- return EXIT.usage
1207
+ return askFailure(deps, flags, 'invalid_source', 'source_context', source.error, 'remove the conflicting source override and retry')
1101
1208
  }
1102
1209
  if (source.invocation.source?.session_id !== sessionId) {
1103
- deps.io.err(
1104
- `Question routing is not ready: --session-id or NOTIFAI_SESSION_ID does not match the exact active ${active.label} session; refusing cross-session routing.`,
1210
+ return askFailure(
1211
+ deps,
1212
+ flags,
1213
+ 'session_identity_mismatch',
1214
+ 'exact_session',
1215
+ `The inferred session does not match the exact active ${active.label} session.`,
1216
+ 'remove NOTIFAI_SESSION_ID and retry from the exact active session',
1105
1217
  )
1106
- return EXIT.usage
1107
1218
  }
1108
1219
 
1109
1220
  // Placeholders let every body, source, project, media, and payload limit fail
@@ -1111,8 +1222,7 @@ export function askCommand(
1111
1222
  const placeholders = (flags.image ?? []).map((_, index) => `med_pending_${index + 1}`)
1112
1223
  const preflight = buildAskDraft(routingConfig, built, flags, source.invocation, placeholders)
1113
1224
  if (!preflight.ok) {
1114
- deps.io.err(preflight.error)
1115
- return EXIT.usage
1225
+ return askFailure(deps, flags, 'invalid_draft', 'draft', preflight.error, 'fix the reported field and retry')
1116
1226
  }
1117
1227
  if ((flags.image?.length ?? 0) > 0) {
1118
1228
  return uploadAskMedia(
@@ -1178,79 +1288,6 @@ export function activeQuestionRouteProblems(
1178
1288
  return [...new Set(problems)]
1179
1289
  }
1180
1290
 
1181
- type ActiveHarnessProblem = 'not-installed' | 'not-fired' | 'different-session'
1182
-
1183
- function diagnoseActiveHarnessSession(
1184
- active: ActiveHarnessSession,
1185
- problem: ActiveHarnessProblem,
1186
- installations: Installation[],
1187
- contested: ActiveHarnessSession[] = [],
1188
- ): string[] {
1189
- // Naming one harness confidently is wrong when the environment carries the
1190
- // markers of several and none of them has fired here: whichever one the
1191
- // agent is told to prompt may not be the one it is running in.
1192
- const ambiguity =
1193
- contested.length > 1
1194
- ? [
1195
- `Several harness markers are present here (${contested.map((candidate) => candidate.label).join(', ')}) and none names a session that has fired in this directory, so ${active.label} is a guess from the environment. Send the prompt in the harness you are actually running.`,
1196
- ]
1197
- : []
1198
- if (problem === 'not-installed') {
1199
- const others = installations.map((installation) => installation.harness)
1200
- return [
1201
- `Could not register the question for the active ${active.label} session: Notifai ${active.label} hooks are not installed for this project.`,
1202
- ...ambiguity,
1203
- ...(others.length === 0
1204
- ? []
1205
- : [
1206
- `Hooks installed for ${[...new Set(others)].join(', ')} do not route an active ${active.label} session.`,
1207
- ]),
1208
- `Run \`notifai hooks install --harness ${active.harness}\`, then send one ${active.label} prompt and run \`notifai doctor\`.`,
1209
- `Retry \`notifai ask\` only after doctor reports that the ${active.label} hooks fired.`,
1210
- ]
1211
- }
1212
- if (problem === 'different-session') {
1213
- return [
1214
- `Could not register the question for the active ${active.label} session: the project pointer belongs to another ${active.label} session or harness.`,
1215
- ...ambiguity,
1216
- `Refusing to guess or cross-wire the question. Send one prompt in this ${active.label} session, then run \`notifai doctor\`.`,
1217
- `Retry \`notifai ask\` only after doctor reports that this active ${active.label} session fired the hooks.`,
1218
- ]
1219
- }
1220
- return [
1221
- `Could not register the question for the active ${active.label} session: Notifai hooks are installed, but this session has not published its pointer.`,
1222
- ...ambiguity,
1223
- `Send one ${active.label} prompt, then run \`notifai doctor\`.`,
1224
- `Retry \`notifai ask\` only after doctor reports that the active ${active.label} session fired the hooks.`,
1225
- ]
1226
- }
1227
-
1228
- /**
1229
- * Why `ask` cannot see a session, in terms of what to do about it.
1230
- *
1231
- * Only a UserPromptSubmit hook firing produces the pointer this reads, and the
1232
- * old message answered every cause with "run `notifai hooks install` and send
1233
- * one prompt". The useful next action depends on the harness: some reload
1234
- * project hook files, OpenCode loads its plugin at startup, and Codex should be
1235
- * checked after a prompt before assuming that a new session is required.
1236
- */
1237
- function diagnoseMissingSession(deps: CommandDeps): string[] {
1238
- const installations = findInstallations(deps.cwd, deps.env, deps.hookAdapterHome, deps.hookPlatform)
1239
- if (installations.length === 0) {
1240
- return [
1241
- 'Could not tell which harness session this is: no Notifai hooks are installed for this project.',
1242
- 'Run `notifai hooks install`, then follow the activation instruction it prints.',
1243
- ]
1244
- }
1245
- const where = installations.map((i) => `${i.harness} in ${i.file}`).join(', ')
1246
- return [
1247
- `Could not tell which harness session this is. Notifai hooks are installed (${where}),`,
1248
- 'but no usable session pointer from the last 24 hours exists here.',
1249
- hookActivationAdvice(installations),
1250
- 'Do not bypass this with a guessed session id; run from the exact active harness session or use a blocking `notifai send --reply` question.',
1251
- ]
1252
- }
1253
-
1254
1291
  /** The least disruptive verified way to make each installed adapter run once. */
1255
1292
  export function hookActivationAdvice(installations: Installation[]): string {
1256
1293
  const harnesses = new Set(installations.map((installation) => installation.harness))
@@ -1324,7 +1361,12 @@ export async function acknowledgeCommand(
1324
1361
  }
1325
1362
 
1326
1363
  const logger = log(deps)
1327
- const config = loadLoggedConfig(deps, { cwd: deps.cwd, env: deps.env })
1364
+ const lifecycleSession = resolveCommandSession(deps, requestId)
1365
+ const config = loadLoggedConfig(deps, {
1366
+ cwd: deps.cwd,
1367
+ env: deps.env,
1368
+ ...(lifecycleSession === null ? {} : { sessionId: lifecycleSession.sessionId }),
1369
+ })
1328
1370
  logger.info('acknowledgement.attempted', {
1329
1371
  request_id: requestId,
1330
1372
  characters: text.length,
@@ -1349,9 +1391,8 @@ export async function acknowledgeCommand(
1349
1391
  created_at: result.agent_acknowledgement.created_at,
1350
1392
  agent_acknowledgement_required: true,
1351
1393
  })
1352
- const sessionId = readProjectSession(deps.cwd, deps.env, (deps.now ?? Date.now)())
1353
- if (sessionId !== null) {
1354
- clearAcknowledgementObligation(sessionId, deps.env, requestId)
1394
+ if (lifecycleSession !== null) {
1395
+ clearAcknowledgementObligation(lifecycleSession.sessionId, deps.env, requestId)
1355
1396
  }
1356
1397
  if (flags.json) deps.io.out(JSON.stringify(output, null, 2))
1357
1398
  else {
@@ -1387,7 +1428,12 @@ export async function closeCommand(
1387
1428
  const local = await closeLocalQuestion(deps, requestId!, flags.json === true)
1388
1429
  if (local !== null) return local
1389
1430
 
1390
- const config = loadLoggedConfig(deps, { cwd: deps.cwd, env: deps.env })
1431
+ const lifecycleSession = resolveCommandSession(deps, requestId!)
1432
+ const config = loadLoggedConfig(deps, {
1433
+ cwd: deps.cwd,
1434
+ env: deps.env,
1435
+ ...(lifecycleSession === null ? {} : { sessionId: lifecycleSession.sessionId }),
1436
+ })
1391
1437
  const authed = authedClient(deps, config)
1392
1438
  if (!authed) return EXIT.auth
1393
1439
  try {
@@ -1426,12 +1472,12 @@ export async function closeCommand(
1426
1472
  * them is the whole retirement. Live ones still need their reply window closed.
1427
1473
  */
1428
1474
  async function closePendingQuestions(deps: CommandDeps, json: boolean): Promise<number> {
1429
- const now = (deps.now ?? Date.now)()
1430
- const sessionId = readProjectSession(deps.cwd, deps.env, now)
1431
- if (sessionId === null) {
1475
+ const lifecycleSession = resolveCommandSession(deps)
1476
+ if (lifecycleSession === null) {
1432
1477
  deps.io.err('No active session pointer is available in this directory.')
1433
1478
  return EXIT.noReply
1434
1479
  }
1480
+ const sessionId = lifecycleSession.sessionId
1435
1481
  const pending = readSessionState(sessionId, deps.env).pending ?? []
1436
1482
  if (pending.length === 0) {
1437
1483
  if (json) {
@@ -1452,7 +1498,7 @@ async function closePendingQuestions(deps: CommandDeps, json: boolean): Promise<
1452
1498
 
1453
1499
  const closed: string[] = []
1454
1500
  if (live.length > 0) {
1455
- const config = loadLoggedConfig(deps, { cwd: deps.cwd, env: deps.env })
1501
+ const config = loadLoggedConfig(deps, { cwd: deps.cwd, env: deps.env, sessionId })
1456
1502
  const authed = authedClient(deps, config)
1457
1503
  if (!authed) return EXIT.auth
1458
1504
  for (const entry of live) {
@@ -1502,9 +1548,9 @@ async function closeLocalQuestion(
1502
1548
  id: string,
1503
1549
  json: boolean,
1504
1550
  ): Promise<number | null> {
1505
- const now = (deps.now ?? Date.now)()
1506
- const sessionId = readProjectSession(deps.cwd, deps.env, now)
1507
- if (sessionId === null) return null
1551
+ const lifecycleSession = resolveCommandSession(deps, id)
1552
+ if (lifecycleSession === null) return null
1553
+ const sessionId = lifecycleSession.sessionId
1508
1554
  const entry = (readSessionState(sessionId, deps.env).pending ?? []).find(
1509
1555
  (candidate) => candidate.question_id === id || candidate.request_id === id,
1510
1556
  )
@@ -1531,7 +1577,7 @@ async function closeLocalQuestion(
1531
1577
  return EXIT.ok
1532
1578
  }
1533
1579
 
1534
- const config = loadLoggedConfig(deps, { cwd: deps.cwd, env: deps.env })
1580
+ const config = loadLoggedConfig(deps, { cwd: deps.cwd, env: deps.env, sessionId })
1535
1581
  const authed = authedClient(deps, config)
1536
1582
  if (!authed) return EXIT.auth
1537
1583
  try {
@@ -1567,8 +1613,9 @@ async function closeLocalQuestion(
1567
1613
  }
1568
1614
 
1569
1615
  function forgetClosedQuestion(deps: CommandDeps, requestId: string): void {
1570
- const sessionId = readProjectSession(deps.cwd, deps.env, (deps.now ?? Date.now)())
1571
- if (sessionId === null) return
1616
+ const lifecycleSession = resolveCommandSession(deps, requestId)
1617
+ if (lifecycleSession === null) return
1618
+ const sessionId = lifecycleSession.sessionId
1572
1619
  const entry = (readSessionState(sessionId, deps.env).pending ?? []).find(
1573
1620
  (candidate) => candidate.request_id === requestId,
1574
1621
  )