@raidiant/notifai 2.0.2 → 4.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.
package/src/hooks.ts CHANGED
@@ -14,9 +14,11 @@ import path from 'node:path'
14
14
  import type {
15
15
  LifecycleEndState,
16
16
  ListRepliesResponse,
17
+ MediaItemT,
17
18
  NotificationDraftT,
18
19
  QuestionT,
19
20
  ReplyView,
21
+ SourceContextT,
20
22
  SubmissionReceipt,
21
23
  } from '@raidiant/notifai-protocol'
22
24
  import {
@@ -160,6 +162,8 @@ export interface RetiringQuestion {
160
162
  device_ids: string[]
161
163
  /** Shown if the companion has no history entry to correlate against. */
162
164
  question: string
165
+ project?: string
166
+ source?: SourceContextT
163
167
  state: LifecycleEndState
164
168
  }
165
169
 
@@ -175,8 +179,6 @@ export interface RetiringQuestion {
175
179
  * next holds a client.
176
180
  */
177
181
  export interface OrphanRetirement extends RetiringQuestion {
178
- /** Label of the session that asked, so the retirement sync matches its badge. */
179
- session?: string
180
182
  /** Epoch ms when the entry was orphaned; entries beyond the TTL are dropped. */
181
183
  enqueued_at: number
182
184
  }
@@ -208,8 +210,13 @@ export interface PendingQuestion {
208
210
  * texts, choices, multi flags. What actually rides the push.
209
211
  */
210
212
  questions?: QuestionT[]
211
- /** Long-form markdown context, shown in the companion's detail view. */
212
- detail?: string
213
+ /** Canonical Markdown body composed when the question was registered. */
214
+ body?: string
215
+ /** Final ordered media collection; uploads complete before registration. */
216
+ media?: MediaItemT[]
217
+ /** Final Project and Source Context frozen at registration. */
218
+ project?: string
219
+ source?: SourceContextT
213
220
  /** Set once the question has actually been pushed, so it can be retired. */
214
221
  request_id?: string
215
222
  collapse_key?: string
@@ -840,10 +847,10 @@ async function prepareQuestionSubmission(
840
847
  title: string
841
848
  body: string
842
849
  questions: QuestionT[]
843
- detail?: string | undefined
850
+ media?: MediaItemT[] | undefined
851
+ project?: string | undefined
852
+ source?: SourceContextT | undefined
844
853
  event: string
845
- /** Which agent is asking; two of them must not look alike. */
846
- session?: string | undefined
847
854
  /** How long the server keeps accepting an answer. */
848
855
  windowSeconds: number
849
856
  /** Absolute owner deadline persisted before submit. */
@@ -857,19 +864,23 @@ async function prepareQuestionSubmission(
857
864
  if (answerable.length === 0) {
858
865
  return { error: 'no device can answer a question yet; leaving this to the terminal' }
859
866
  }
860
- const build = buildDraft(ctx.config, {
861
- title: options.title,
862
- body: options.body,
863
- event: options.event,
864
- lifecycle: { tier: 'needs_you' },
865
- ...(options.session !== undefined ? { session: options.session } : {}),
866
- device: answerable,
867
- reply: true,
868
- replyWindow: Math.max(60, options.windowSeconds),
869
- questions: options.questions,
870
- ...(options.detail !== undefined ? { detail: options.detail } : {}),
871
- collapseKey,
872
- })
867
+ const build = buildDraft(
868
+ ctx.config,
869
+ {
870
+ title: options.title,
871
+ body: options.body,
872
+ event: options.event,
873
+ lifecycle: { tier: 'needs_you' },
874
+ ...(options.project !== undefined ? { project: options.project } : {}),
875
+ ...(options.media !== undefined ? { media: options.media } : {}),
876
+ device: answerable,
877
+ reply: true,
878
+ replyWindow: Math.max(60, options.windowSeconds),
879
+ questions: options.questions,
880
+ collapseKey,
881
+ },
882
+ options.source === undefined ? {} : { source: options.source },
883
+ )
873
884
  if (!build.ok) return { error: build.error }
874
885
 
875
886
  return {
@@ -996,23 +1007,6 @@ async function answerableDevices(ctx: HookContext): Promise<string[]> {
996
1007
  .map((device) => device.device_id)
997
1008
  }
998
1009
 
999
- /**
1000
- * The session id this push is attributed to — the same one `send` carries.
1001
- *
1002
- * The hook has always known `session_id` and never passed it on, so two agents
1003
- * in separate worktrees produced identical notifications and the user could
1004
- * answer the wrong one's question. An exported `NOTIFAI_SESSION`
1005
- * still wins, for coherence rather than vanity: it is THE session id wherever
1006
- * it is set, so a session that exported one before launching must carry
1007
- * the same on its own sends and on the questions its hooks push. A name the
1008
- * user chose also outlives harness restarts, which a per-launch UUID cannot.
1009
- */
1010
- function sessionLabel(ctx: HookContext, envelope: HookEnvelope): string | undefined {
1011
- const explicit = ctx.env['NOTIFAI_SESSION']
1012
- if (explicit !== undefined && explicit !== '') return explicit
1013
- return envelope.session_id
1014
- }
1015
-
1016
1010
  /**
1017
1011
  * Everything retirement needs. Narrower than a HookContext on purpose:
1018
1012
  * `notifai ask` supersedes the previous question and it is a plain command with
@@ -1058,18 +1052,23 @@ async function retire(
1058
1052
  endState: LifecycleEndState,
1059
1053
  retiresRequestId: string,
1060
1054
  devices?: string[],
1061
- session?: string | undefined,
1055
+ project?: string | undefined,
1056
+ source?: SourceContextT | undefined,
1062
1057
  ): Promise<boolean> {
1063
- const build = buildDraft(ctx.config, {
1064
- title,
1065
- body,
1066
- event: 'question_retired',
1067
- lifecycle: { tier: 'done', state: endState, retires_request_id: retiresRequestId },
1068
- ...(session !== undefined ? { session } : {}),
1069
- ...(devices !== undefined && devices.length > 0 ? { device: devices } : {}),
1070
- collapseKey,
1071
- level: 'passive',
1072
- })
1058
+ const build = buildDraft(
1059
+ ctx.config,
1060
+ {
1061
+ title,
1062
+ body,
1063
+ event: 'question_retired',
1064
+ lifecycle: { tier: 'done', state: endState, retires_request_id: retiresRequestId },
1065
+ ...(project !== undefined ? { project } : {}),
1066
+ ...(devices !== undefined && devices.length > 0 ? { device: devices } : {}),
1067
+ collapseKey,
1068
+ level: 'passive',
1069
+ },
1070
+ source === undefined ? {} : { source },
1071
+ )
1073
1072
  if (!build.ok) return false
1074
1073
  try {
1075
1074
  await ctx.client.submit(
@@ -1150,6 +1149,8 @@ function retiringQuestion(
1150
1149
  collapse_key: pending.collapse_key!,
1151
1150
  device_ids: [...pending.device_ids!],
1152
1151
  question: pending.question,
1152
+ ...(pending.project !== undefined ? { project: pending.project } : {}),
1153
+ ...(pending.source !== undefined ? { source: pending.source } : {}),
1153
1154
  state,
1154
1155
  }
1155
1156
  }
@@ -1172,7 +1173,6 @@ export async function drainRetirements(
1172
1173
  ctx: RetireDeps,
1173
1174
  sessionId: string,
1174
1175
  env: NodeJS.ProcessEnv,
1175
- session?: string | undefined,
1176
1176
  ): Promise<string[]> {
1177
1177
  const queue = readSessionState(sessionId, env).retiring ?? []
1178
1178
  if (queue.length === 0) return []
@@ -1188,7 +1188,8 @@ export async function drainRetirements(
1188
1188
  entry.state,
1189
1189
  entry.request_id,
1190
1190
  retirementDeviceIds(entry),
1191
- session,
1191
+ entry.project,
1192
+ entry.source,
1192
1193
  )
1193
1194
  if (sent) {
1194
1195
  retired.push(entry.request_id)
@@ -1241,7 +1242,6 @@ function updateOrphanQueue(
1241
1242
  export function orphanRetirements(
1242
1243
  env: NodeJS.ProcessEnv,
1243
1244
  entries: RetiringQuestion[],
1244
- session: string | undefined,
1245
1245
  now: number,
1246
1246
  ): void {
1247
1247
  if (entries.length === 0) return
@@ -1249,11 +1249,7 @@ export function orphanRetirements(
1249
1249
  const known = new Set(queue.map((entry) => entry.request_id))
1250
1250
  const added = entries
1251
1251
  .filter((entry) => !known.has(entry.request_id))
1252
- .map((entry) => ({
1253
- ...entry,
1254
- ...(session !== undefined ? { session } : {}),
1255
- enqueued_at: now,
1256
- }))
1252
+ .map((entry) => ({ ...entry, enqueued_at: now }))
1257
1253
  return [...queue, ...added].slice(-ORPHAN_QUEUE_CAP)
1258
1254
  })
1259
1255
  }
@@ -1291,7 +1287,8 @@ export async function drainOrphanRetirements(
1291
1287
  entry.state,
1292
1288
  entry.request_id,
1293
1289
  retirementDeviceIds(entry),
1294
- entry.session,
1290
+ entry.project,
1291
+ entry.source,
1295
1292
  )
1296
1293
  if (sent) {
1297
1294
  done.push(entry.request_id)
@@ -1865,7 +1862,7 @@ export async function handleUserPromptSubmit(
1865
1862
  notes.push('the late device answer will continue the agent at this turn’s Stop')
1866
1863
  return { notes }
1867
1864
  }
1868
- const retired = await drainRetirements(ctx, sessionId, ctx.env, sessionLabel(ctx, envelope))
1865
+ const retired = await drainRetirements(ctx, sessionId, ctx.env)
1869
1866
  const orphaned = await drainOrphanRetirements(ctx, ctx.env, ctx.now())
1870
1867
  const swept = [...retired, ...orphaned]
1871
1868
  if (swept.length > 0) {
@@ -2031,7 +2028,7 @@ export async function runEscalationWaiter(
2031
2028
  const pending = pendingList(state)
2032
2029
  if (pending.length === 0) {
2033
2030
  const swept = [
2034
- ...(await drainRetirements(ctx, sessionId, ctx.env, sessionLabel(ctx, envelope))),
2031
+ ...(await drainRetirements(ctx, sessionId, ctx.env)),
2035
2032
  ...(await drainOrphanRetirements(ctx, ctx.env, ctx.now())),
2036
2033
  ]
2037
2034
  if (swept.length > 0) {
@@ -2378,21 +2375,19 @@ async function escalate(
2378
2375
  const questions = pendingQuestions(entry)
2379
2376
  let intent = entry.submission
2380
2377
  if (intent === undefined) {
2378
+ if (entry.body === undefined) {
2379
+ notes.push('registered question has no canonical body; register it again with this CLI')
2380
+ continue
2381
+ }
2381
2382
  const prepared = await prepareQuestionSubmission(ctx, {
2382
- title:
2383
- ctx.config.project.value === null
2384
- ? 'Question'
2385
- : `Question · ${ctx.config.project.value}`,
2386
- // A set is answered on the expanded card; the banner leads with the
2387
- // first question and says how much more is waiting behind it.
2388
- body:
2389
- questions.length > 1
2390
- ? `${questions[0]!.text} (+${questions.length - 1} more)`
2391
- : entry.question,
2383
+ // Type and Project have their own fields; the title is only substance.
2384
+ title: questions[0]!.text,
2385
+ body: entry.body,
2392
2386
  questions,
2393
- ...(entry.detail !== undefined ? { detail: entry.detail } : {}),
2387
+ ...(entry.media !== undefined ? { media: entry.media } : {}),
2388
+ ...(entry.project !== undefined ? { project: entry.project } : {}),
2389
+ ...(entry.source !== undefined ? { source: entry.source } : {}),
2394
2390
  event: 'agent_question',
2395
- session: sessionLabel(ctx, envelope),
2396
2391
  windowSeconds: replyWindowSeconds,
2397
2392
  ownerDeadlineAt,
2398
2393
  })
@@ -2704,8 +2699,7 @@ export function handleSessionEnd(
2704
2699
  }
2705
2700
  }
2706
2701
  if (orphans.length > 0) {
2707
- const label = env['NOTIFAI_SESSION']
2708
- orphanRetirements(env, orphans, label !== undefined && label !== '' ? label : sessionId, now)
2702
+ orphanRetirements(env, orphans, now)
2709
2703
  notes.push(
2710
2704
  `queued ${orphans.length} question${orphans.length > 1 ? 's' : ''} for retirement on the next hook`,
2711
2705
  )
@@ -0,0 +1,253 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import { sessionLabelFromId } from '@raidiant/notifai-protocol'
3
+ import {
4
+ buildSourceContext,
5
+ inferInvocationContext,
6
+ portableBasename,
7
+ projectSlugFrom,
8
+ truncateContext,
9
+ type GitCommand,
10
+ } from './invocation-context.js'
11
+
12
+ function fixtureGit(values: Record<string, string | null>): GitCommand {
13
+ return (_cwd, args) => values[args.join(' ')] ?? null
14
+ }
15
+
16
+ describe('projectSlugFrom', () => {
17
+ it.each([
18
+ ['My Project', 'my-project'],
19
+ ['.Hidden Repo', 'hidden-repo'],
20
+ ['API_v2.0', 'api_v2.0'],
21
+ ['---', null],
22
+ ['项目', null],
23
+ ])('turns %s into %s', (name, expected) => {
24
+ expect(projectSlugFrom(name)).toBe(expected)
25
+ })
26
+
27
+ it('keeps the Project identifier inside its 64-character bound', () => {
28
+ expect(projectSlugFrom(`repo-${'x'.repeat(100)}`)).toHaveLength(64)
29
+ })
30
+ })
31
+
32
+ describe('portableBasename', () => {
33
+ it.each([
34
+ ['/Users/person/code/notifai/', 'notifai'],
35
+ ['C:\\Users\\Person\\code\\notifai\\', 'notifai'],
36
+ ['\\\\server\\share\\worktrees\\release\\', 'release'],
37
+ ])('extracts the basename from %s', (value, expected) => {
38
+ expect(portableBasename(value)).toBe(expected)
39
+ })
40
+ })
41
+
42
+ describe('inferInvocationContext', () => {
43
+ it('uses the common-directory parent for a repository from a nested cwd', () => {
44
+ const context = inferInvocationContext(
45
+ '/code/Signal Garden/packages/api',
46
+ fixtureGit({
47
+ 'rev-parse --git-common-dir': '../../.git',
48
+ 'rev-parse --git-dir': '../../.git',
49
+ 'rev-parse --show-toplevel': '/code/Signal Garden',
50
+ 'rev-parse --abbrev-ref HEAD': 'feature/alerts',
51
+ }),
52
+ )
53
+ expect(context).toEqual({ project: 'signal-garden', branch: 'feature/alerts' })
54
+ })
55
+
56
+ it('keeps one Project across linked worktrees and exposes only the worktree basename', () => {
57
+ const context = inferInvocationContext(
58
+ '/Users/name/worktrees/notifai-release/apps/cli',
59
+ fixtureGit({
60
+ 'rev-parse --git-common-dir': '/Users/name/code/notifai/.git',
61
+ 'rev-parse --git-dir': '/Users/name/code/notifai/.git/worktrees/notifai-release',
62
+ 'rev-parse --show-toplevel': '/Users/name/worktrees/notifai-release/',
63
+ 'rev-parse --abbrev-ref HEAD': 'release/content',
64
+ }),
65
+ )
66
+ expect(context).toEqual({
67
+ project: 'notifai',
68
+ branch: 'release/content',
69
+ worktree: 'notifai-release',
70
+ })
71
+ expect(JSON.stringify(context)).not.toContain('/Users/name')
72
+ })
73
+
74
+ it('handles drive-letter paths and compares them case-insensitively', () => {
75
+ const context = inferInvocationContext(
76
+ 'D:\\Worktrees\\Alerts\\src',
77
+ fixtureGit({
78
+ 'rev-parse --git-common-dir': 'C:\\Code\\Notifai\\.git\\',
79
+ 'rev-parse --git-dir': 'c:\\code\\notifai\\.git\\worktrees\\Alerts',
80
+ 'rev-parse --show-toplevel': 'D:\\Worktrees\\Alerts\\',
81
+ 'rev-parse --abbrev-ref HEAD': 'feature/windows',
82
+ }),
83
+ )
84
+ expect(context).toEqual({
85
+ project: 'notifai',
86
+ branch: 'feature/windows',
87
+ worktree: 'Alerts',
88
+ })
89
+ expect(JSON.stringify(context)).not.toContain('D:\\Worktrees')
90
+ expect(JSON.stringify(context)).not.toContain('C:\\Code')
91
+ })
92
+
93
+ it('handles a UNC common directory and trailing separators', () => {
94
+ const context = inferInvocationContext(
95
+ '\\\\server\\share\\trees\\topic\\src',
96
+ fixtureGit({
97
+ 'rev-parse --git-common-dir': '\\\\server\\share\\Notifai\\.git\\',
98
+ 'rev-parse --git-dir': '\\\\server\\share\\Notifai\\.git\\worktrees\\topic\\',
99
+ 'rev-parse --show-toplevel': '\\\\server\\share\\trees\\topic\\',
100
+ 'rev-parse --abbrev-ref HEAD': 'topic',
101
+ }),
102
+ )
103
+ expect(context).toEqual({ project: 'notifai', branch: 'topic', worktree: 'topic' })
104
+ })
105
+
106
+ it('falls back to the cwd basename outside Git', () => {
107
+ expect(inferInvocationContext('/tmp/My Scratch/', fixtureGit({}))).toEqual({
108
+ project: 'my-scratch',
109
+ })
110
+ })
111
+
112
+ it('omits an unusable Project instead of inventing one', () => {
113
+ expect(inferInvocationContext('/tmp/项目', fixtureGit({}))).toEqual({ project: null })
114
+ })
115
+
116
+ it('omits branch and worktree on detached HEAD in an ordinary checkout', () => {
117
+ expect(
118
+ inferInvocationContext(
119
+ '/code/repo',
120
+ fixtureGit({
121
+ 'rev-parse --git-common-dir': '.git',
122
+ 'rev-parse --git-dir': '.git',
123
+ 'rev-parse --show-toplevel': '/code/repo',
124
+ 'rev-parse --abbrev-ref HEAD': 'HEAD',
125
+ }),
126
+ ),
127
+ ).toEqual({ project: 'repo' })
128
+ })
129
+
130
+ it('bounds branch and worktree display strings without leaking their parents', () => {
131
+ const longBranch = `feature/${'界'.repeat(140)}`
132
+ const longWorktree = `tree-${'x'.repeat(80)}`
133
+ const context = inferInvocationContext(
134
+ '/private/work/tree/src',
135
+ fixtureGit({
136
+ 'rev-parse --git-common-dir': '/private/main/repo/.git',
137
+ 'rev-parse --git-dir': '/private/main/repo/.git/worktrees/tree',
138
+ 'rev-parse --show-toplevel': `/private/work/${longWorktree}`,
139
+ 'rev-parse --abbrev-ref HEAD': longBranch,
140
+ }),
141
+ )
142
+ expect(Array.from(context.branch ?? '')).toHaveLength(128)
143
+ expect(Array.from(context.worktree ?? '')).toHaveLength(64)
144
+ expect(context.branch).toMatch(/…$/)
145
+ expect(context.worktree).toMatch(/…$/)
146
+ expect(JSON.stringify(context)).not.toContain('/private/')
147
+ })
148
+ })
149
+
150
+ describe('truncateContext', () => {
151
+ it('counts Unicode characters rather than UTF-16 code units', () => {
152
+ expect(truncateContext('A😀BC', 3)).toBe('A😀…')
153
+ })
154
+ })
155
+
156
+ describe('buildSourceContext', () => {
157
+ const invocation = { project: 'repo', branch: 'main', worktree: 'topic' }
158
+
159
+ it('applies flag, environment, then inferred precedence per session field', () => {
160
+ const built = buildSourceContext({
161
+ env: {
162
+ NOTIFAI_SESSION_ID: 'env-id',
163
+ NOTIFAI_SESSION_LABEL: 'Environment Label',
164
+ },
165
+ invocation,
166
+ sessionId: 'flag-id',
167
+ sessionLabel: 'Flag Label',
168
+ activeHarness: { harness: 'claude-code', sessionId: 'inferred-id' },
169
+ })
170
+ expect(built).toEqual({
171
+ ok: true,
172
+ source: {
173
+ session_id: 'flag-id',
174
+ session_label: 'Flag Label',
175
+ harness: 'claude-code',
176
+ branch: 'main',
177
+ worktree: 'topic',
178
+ },
179
+ })
180
+ })
181
+
182
+ it('uses environment values ahead of an inferred exact session', () => {
183
+ expect(
184
+ buildSourceContext({
185
+ env: {
186
+ NOTIFAI_SESSION_ID: 'env-id',
187
+ NOTIFAI_SESSION_LABEL: 'Environment Label',
188
+ },
189
+ invocation: { project: 'repo' },
190
+ activeHarness: { harness: 'codex', sessionId: 'inferred-id' },
191
+ }),
192
+ ).toEqual({
193
+ ok: true,
194
+ source: {
195
+ session_id: 'env-id',
196
+ session_label: 'Environment Label',
197
+ harness: 'codex',
198
+ },
199
+ })
200
+ })
201
+
202
+ it('generates a stable non-identifying label for an inferred exact session', () => {
203
+ const sessionId = 'opaque-thread-1234567890'
204
+ expect(
205
+ buildSourceContext({
206
+ env: {},
207
+ invocation: { project: 'repo', branch: 'main' },
208
+ activeHarness: { harness: 'opencode', sessionId },
209
+ }),
210
+ ).toEqual({
211
+ ok: true,
212
+ source: {
213
+ session_id: sessionId,
214
+ session_label: sessionLabelFromId(sessionId),
215
+ harness: 'opencode',
216
+ branch: 'main',
217
+ },
218
+ })
219
+ expect(sessionLabelFromId(sessionId)).not.toContain('1234567890')
220
+ })
221
+
222
+ it('does not emit a harness or label when no exact session id is available', () => {
223
+ expect(
224
+ buildSourceContext({
225
+ env: {},
226
+ invocation: { project: 'repo', branch: 'main' },
227
+ activeHarness: { harness: 'cursor' },
228
+ }),
229
+ ).toEqual({ ok: true, source: { branch: 'main' } })
230
+ })
231
+
232
+ it('ignores the deleted NOTIFAI_SESSION variable', () => {
233
+ expect(
234
+ buildSourceContext({
235
+ env: { NOTIFAI_SESSION: 'legacy-id' },
236
+ invocation: { project: 'repo' },
237
+ }),
238
+ ).toEqual({ ok: true })
239
+ })
240
+
241
+ it('rejects a display label without an exact session id', () => {
242
+ expect(
243
+ buildSourceContext({
244
+ env: {},
245
+ invocation: { project: 'repo' },
246
+ sessionLabel: 'Unbound Label',
247
+ }),
248
+ ).toEqual({
249
+ ok: false,
250
+ error: '--session-label (or NOTIFAI_SESSION_LABEL) needs an exact session id.',
251
+ })
252
+ })
253
+ })