@raidiant/notifai 9.1.0 → 9.2.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.
@@ -30,14 +30,13 @@ import {
30
30
  } from './hook-adapter.js'
31
31
  import {
32
32
  clearAcknowledgementObligation,
33
- claimPromptActivation,
34
33
  claimCursorStopActivation,
35
34
  confirmCursorStopActivation,
36
35
  dropPendingQuestion,
37
36
  handleSessionEnd,
38
37
  handleStop,
39
38
  handleUserPromptSubmit,
40
- markSessionActivation,
39
+ recordSessionStart,
41
40
  parkForRetirement,
42
41
  parseHookInput,
43
42
  pruneAbandonedSessions,
@@ -254,14 +253,6 @@ export async function hookRunCommand(
254
253
  const cwd = envelope.cwd ?? deps.cwd
255
254
  const sessionEnd = hookDefersDiagnosticsUntilAfterCleanup(event)
256
255
  logger.bind({ session: envelope.session_id ?? null })
257
- const declaredSourcePid = declaredHookSourcePid(deps)
258
- const activationOwnerPid =
259
- harness === 'claude-code'
260
- ? (deps.claudeSourcePid ?? declaredSourcePid ?? claudeSessionPid(deps.env))
261
- : harness === 'codex'
262
- ? (deps.codexSourcePid ?? declaredSourcePid ?? process.ppid)
263
- : undefined
264
-
265
256
  // Session activation is local model context, not authenticated routing. It
266
257
  // must survive the exact first-run states it exists to repair: no config,
267
258
  // no credential, no Companion device, and no network.
@@ -285,22 +276,14 @@ export async function hookRunCommand(
285
276
  deps.env,
286
277
  )
287
278
  if (stdout !== undefined) deps.io.out(stdout)
288
- if (
289
- stdout !== undefined &&
290
- event === 'session-start' &&
291
- activationOwnerPid !== undefined &&
292
- envelope.session_id !== undefined
293
- ) {
279
+ if (event === 'session-start' && envelope.session_id !== undefined) {
294
280
  try {
295
- // Write stdout first. If the process dies between these operations a
296
- // later prompt may duplicate context, but it cannot suppress context
297
- // that the host never received.
298
- markSessionActivation(envelope.session_id, deps.env, now(), activationOwnerPid, harness, cwd)
281
+ recordSessionStart(envelope.session_id, deps.env, harness, cwd)
299
282
  } catch (err) {
300
283
  logger.error('hook.end', {
301
284
  hook: event,
302
- outcome: 'mark-failed',
303
- reason: 'activation-state-failed',
285
+ outcome: 'record-failed',
286
+ reason: 'session-state-failed',
304
287
  ...failureData(err),
305
288
  })
306
289
  }
@@ -313,33 +296,6 @@ export async function hookRunCommand(
313
296
  return EXIT.ok
314
297
  }
315
298
 
316
- // SessionStart is the preferred model-visible activation seam. A few
317
- // managed hosts regenerate older hook definitions that retain
318
- // UserPromptSubmit but omit newer lifecycle events. Claim the first prompt
319
- // once per exact session as a compatibility fallback, before config/auth/
320
- // device/network work, and stay silent when SessionStart already ran.
321
- let promptActivationStdout: string | undefined
322
- if (
323
- event === 'user-prompt-submit' &&
324
- (harness === 'claude-code' || harness === 'codex') &&
325
- activationOwnerPid !== undefined &&
326
- envelope.session_id !== undefined
327
- ) {
328
- try {
329
- if (claimPromptActivation(envelope.session_id, deps.env, now(), activationOwnerPid, harness, cwd)) {
330
- promptActivationStdout = sessionActivationOutput(harness, 'UserPromptSubmit', cwd, deps.env)
331
- if (promptActivationStdout !== undefined) deps.io.out(promptActivationStdout)
332
- }
333
- } catch (err) {
334
- logger.error('hook.end', {
335
- hook: event,
336
- outcome: 'claim-failed',
337
- reason: 'activation-state-failed',
338
- ...failureData(err),
339
- })
340
- }
341
- }
342
-
343
299
  // Cursor has a confirmed host bug in which sessionStart.additional_context
344
300
  // is accepted but never reaches the model. A native Stop follow-up is the
345
301
  // host's guaranteed model-visible channel. Claim it once per conversation,
@@ -443,7 +399,6 @@ export async function hookRunCommand(
443
399
  hook: event,
444
400
  outcome: 'failed',
445
401
  reason: 'config-failed',
446
- ...(promptActivationStdout === undefined ? {} : { activation: 'context-added' }),
447
402
  ...failureData(err),
448
403
  })
449
404
  for (const line of describeHookFailure(err)) deps.io.err(`notifai: ${line}`)
@@ -547,10 +502,7 @@ export async function hookRunCommand(
547
502
  const notes = outcome.notes.filter((note) => !/^(?:late )?answer from /.test(note))
548
503
  logger.info('hook.end', {
549
504
  hook: event,
550
- // Stop stdout takes over the turn. UserPromptSubmit stdout may instead
551
- // add context to the user's own turn, which is explicitly non-decisive.
552
505
  decided: outcome.decided ?? outcome.stdout !== undefined,
553
- ...(promptActivationStdout === undefined ? {} : { activation: 'context-added' }),
554
506
  ...(notes.length === 0 ? {} : { notes }),
555
507
  ...outcome.log,
556
508
  })
@@ -3277,13 +3277,13 @@ describe('stable hook installation', () => {
3277
3277
  ).toBe(EXIT.ok)
3278
3278
  const globalFile = settingsFile('opencode', true, cwd, env)
3279
3279
  const current = readFileSync(globalFile, 'utf8')
3280
- writeFileSync(globalFile, current.replace('const ADAPTER_VERSION = 11', 'const ADAPTER_VERSION = 10'))
3280
+ writeFileSync(globalFile, current.replace('const ADAPTER_VERSION = 12', 'const ADAPTER_VERSION = 11'))
3281
3281
 
3282
3282
  io.outLines = []
3283
3283
  expect(hooksInstallCommand(deps, { harness: 'opencode', execPath, scriptPath })).toBe(EXIT.ok)
3284
3284
  expect(existsSync(path.join(cwd, '.opencode', 'plugins', 'notifai.js'))).toBe(false)
3285
3285
  const refreshed = readFileSync(globalFile, 'utf8')
3286
- expect(refreshed).toContain('const ADAPTER_VERSION = 11')
3286
+ expect(refreshed).toContain('const ADAPTER_VERSION = 12')
3287
3287
  expect(refreshed).toContain('experimental.chat.system.transform')
3288
3288
  expect(io.outLines.join('\n')).toMatch(/Installed opencode hooks/)
3289
3289
  })
package/src/hooks.test.ts CHANGED
@@ -2855,7 +2855,7 @@ describe('session-start hook', () => {
2855
2855
  })
2856
2856
 
2857
2857
  describe('user-prompt-submit hook', () => {
2858
- it('activates once as a compatibility fallback when SessionStart was absent', async () => {
2858
+ it('stays silent when SessionStart was absent while preserving prompt lifecycle', async () => {
2859
2859
  const h = harness()
2860
2860
 
2861
2861
  await hookRunCommand(
@@ -2865,136 +2865,10 @@ describe('user-prompt-submit hook', () => {
2865
2865
  'claude-code',
2866
2866
  )
2867
2867
 
2868
- expect(h.io.outLines).toHaveLength(1)
2869
- expect(JSON.parse(h.io.outLines[0] ?? '{}')).toMatchObject({
2870
- hookSpecificOutput: {
2871
- hookEventName: 'UserPromptSubmit',
2872
- additionalContext: expect.stringMatching(/Notifai.*session/i),
2873
- },
2874
- })
2875
- expect(readSessionState('presence-only', h.env).last_prompt_at).toBe(NOW)
2876
-
2877
- await hookRunCommand(
2878
- h.deps,
2879
- 'user-prompt-submit',
2880
- stdin({ session_id: 'presence-only' }),
2881
- 'claude-code',
2882
- )
2883
- expect(h.io.outLines).toHaveLength(1)
2884
- })
2885
-
2886
- it('stays silent when SessionStart already delivered activation', async () => {
2887
- const h = harness()
2888
- h.deps.codexSourcePid = 4242
2889
-
2890
- await hookRunCommand(
2891
- h.deps,
2892
- 'session-start',
2893
- stdin({ session_id: 'lifecycle-owned' }),
2894
- 'codex',
2895
- )
2896
- h.io.outLines = []
2897
-
2898
- await hookRunCommand(
2899
- h.deps,
2900
- 'user-prompt-submit',
2901
- stdin({ session_id: 'lifecycle-owned' }),
2902
- 'codex',
2903
- )
2904
-
2905
2868
  expect(h.io.outLines).toEqual([])
2906
- })
2907
-
2908
- it('reactivates a resumed session owned by a new harness process', async () => {
2909
- const h = harness()
2910
- h.deps.codexSourcePid = 4242
2911
- await hookRunCommand(
2912
- h.deps,
2913
- 'session-start',
2914
- stdin({ session_id: 'resumed-owner' }),
2915
- 'codex',
2916
- )
2917
- h.io.outLines = []
2918
- h.deps.codexSourcePid = 4343
2919
-
2920
- await hookRunCommand(
2921
- h.deps,
2922
- 'user-prompt-submit',
2923
- stdin({ session_id: 'resumed-owner' }),
2924
- 'codex',
2925
- )
2926
-
2927
- expect(JSON.parse(h.io.outLines[0] ?? '{}')).toMatchObject({
2928
- hookSpecificOutput: { hookEventName: 'UserPromptSubmit' },
2929
- })
2930
- })
2931
-
2932
- it('uses the adapter-propagated harness owner across per-hook CLI processes', async () => {
2933
- const h = harness()
2934
- h.env['NOTIFAI_HOOK_SOURCE_PID'] = '5252'
2935
-
2936
- await hookRunCommand(
2937
- h.deps,
2938
- 'user-prompt-submit',
2939
- stdin({ session_id: 'adapter-owned' }),
2940
- 'codex',
2941
- )
2942
- h.io.outLines = []
2943
- await hookRunCommand(
2944
- h.deps,
2945
- 'user-prompt-submit',
2946
- stdin({ session_id: 'adapter-owned' }),
2947
- 'codex',
2948
- )
2949
- expect(h.io.outLines).toEqual([])
2950
-
2951
- h.env['NOTIFAI_HOOK_SOURCE_PID'] = '5353'
2952
- await hookRunCommand(
2953
- h.deps,
2954
- 'user-prompt-submit',
2955
- stdin({ session_id: 'adapter-owned' }),
2956
- 'codex',
2957
- )
2958
- expect(h.io.outLines).toHaveLength(1)
2959
- })
2960
-
2961
- it('does not let a worker lifecycle suppress parent prompt activation', async () => {
2962
- const h = harness()
2963
- h.deps.codexSourcePid = 4242
2964
- await hookRunCommand(
2965
- h.deps,
2966
- 'subagent-start',
2967
- stdin({ session_id: 'shared-parent-worker' }),
2968
- 'codex',
2969
- )
2970
- h.io.outLines = []
2971
-
2972
- await hookRunCommand(
2973
- h.deps,
2974
- 'user-prompt-submit',
2975
- stdin({ session_id: 'shared-parent-worker' }),
2976
- 'codex',
2977
- )
2978
-
2979
- expect(JSON.parse(h.io.outLines[0] ?? '{}')).toMatchObject({
2980
- hookSpecificOutput: { hookEventName: 'UserPromptSubmit' },
2981
- })
2982
- })
2983
-
2984
- it('activates before config and authentication readiness', async () => {
2985
- const h = harness()
2986
- h.deps.cwd = path.join(h.deps.cwd, 'missing-project')
2987
- h.deps.store.load = () => null
2988
-
2989
- await hookRunCommand(
2990
- h.deps,
2991
- 'user-prompt-submit',
2992
- stdin({ session_id: 'first-run', cwd: h.deps.cwd }),
2993
- 'codex',
2994
- )
2995
-
2996
- expect(JSON.parse(h.io.outLines[0] ?? '{}')).toMatchObject({
2997
- hookSpecificOutput: { hookEventName: 'UserPromptSubmit' },
2869
+ expect(readSessionState('presence-only', h.env)).toEqual({
2870
+ harness: 'claude-code',
2871
+ last_prompt_at: NOW,
2998
2872
  })
2999
2873
  })
3000
2874
 
package/src/hooks.ts CHANGED
@@ -90,10 +90,6 @@ export interface SessionState {
90
90
  harness?: HookHarness
91
91
  /** Checkout whose hook definition activated this session; lifecycle diagnostics only. */
92
92
  activation_cwd?: string
93
- /** Model-visible proactive context already emitted for this harness session. */
94
- activation_context_emitted_at?: number
95
- /** Harness process that received it; a resumed session has a new owner. */
96
- activation_context_owner_pid?: number
97
93
  /** Cursor's documented session context path is lossy; bounded first-Stop fallback journal. */
98
94
  cursor_activation_claimed_at?: number
99
95
  cursor_activation_confirmed_at?: number
@@ -547,12 +543,10 @@ function updateSessionState(
547
543
  })
548
544
  }
549
545
 
550
- /** Record a lifecycle activation so the first-prompt compatibility path stays silent. */
551
- export function markSessionActivation(
546
+ /** Record the current installation when an owner session starts. */
547
+ export function recordSessionStart(
552
548
  sessionId: string,
553
549
  env: NodeJS.ProcessEnv,
554
- now: number,
555
- ownerPid: number,
556
550
  harness?: HookHarness,
557
551
  cwd?: string,
558
552
  ): void {
@@ -560,46 +554,9 @@ export function markSessionActivation(
560
554
  ...current,
561
555
  ...(harness === undefined ? {} : { harness }),
562
556
  ...(cwd === undefined ? {} : { activation_cwd: current.activation_cwd ?? cwd }),
563
- activation_context_emitted_at: now,
564
- activation_context_owner_pid: ownerPid,
565
557
  }))
566
558
  }
567
559
 
568
- /**
569
- * Atomically own first-prompt activation only when no lifecycle hook did.
570
- *
571
- * Some managed hosts preserve UserPromptSubmit while regenerating away newer
572
- * SessionStart definitions. This is a compatibility seam, not the primary
573
- * lifecycle: one session gets at most one model-visible activation from it.
574
- */
575
- export function claimPromptActivation(
576
- sessionId: string,
577
- env: NodeJS.ProcessEnv,
578
- now: number,
579
- ownerPid: number,
580
- harness?: HookHarness,
581
- cwd?: string,
582
- ): boolean {
583
- let claimed = false
584
- updateSessionState(sessionId, env, (current) => {
585
- if (
586
- current.activation_context_emitted_at !== undefined &&
587
- current.activation_context_owner_pid === ownerPid
588
- ) {
589
- return current
590
- }
591
- claimed = true
592
- return {
593
- ...current,
594
- ...(harness === undefined ? {} : { harness }),
595
- ...(cwd === undefined ? {} : { activation_cwd: current.activation_cwd ?? cwd }),
596
- activation_context_emitted_at: now,
597
- activation_context_owner_pid: ownerPid,
598
- }
599
- })
600
- return claimed
601
- }
602
-
603
560
  /** Atomically claim Cursor's first-Stop activation fallback once per conversation. */
604
561
  export function claimCursorStopActivation(
605
562
  sessionId: string,
@@ -21,7 +21,10 @@ import {
21
21
  opencodePluginSource,
22
22
  opencodePluginTarget,
23
23
  } from './opencode-plugin.js'
24
- import { MISSING_LIFECYCLE_GUIDANCE_CONTEXT } from './session-activation.js'
24
+ import {
25
+ MISSING_LIFECYCLE_GUIDANCE_CONTEXT,
26
+ WORKER_ACTIVATION_CONTEXT,
27
+ } from './session-activation.js'
25
28
  import { hookAdapterPath, installHookAdapter } from './hook-adapter.js'
26
29
  import {
27
30
  BLOCKING_STOP_TIMEOUT_SECONDS,
@@ -196,8 +199,9 @@ describe('hook config', () => {
196
199
  })
197
200
  })
198
201
 
199
- it('leaves exactly one handler per event when an old blocking shape is reinstalled over', () => {
202
+ it('restores missing SessionStart exactly once when an old owned shape is reinstalled over', () => {
200
203
  const old = mergeHooks({}, buildHookConfig({ adapterPath: ADAPTER }), SCRIPT)
204
+ delete old.document.hooks?.['SessionStart']
201
205
  const migrated = mergeHooks(
202
206
  old.document,
203
207
  buildHookConfig({ adapterPath: ADAPTER, harness: 'claude-code' }),
@@ -214,7 +218,6 @@ describe('hook config', () => {
214
218
  })
215
219
  expect(migrated.replaced.sort()).toEqual([
216
220
  'SessionEnd',
217
- 'SessionStart',
218
221
  'Stop',
219
222
  'SubagentStart',
220
223
  'UserPromptSubmit',
@@ -882,32 +885,54 @@ describe('the OpenCode adapter', () => {
882
885
  expect(source).not.toContain('"permission.ask"')
883
886
  })
884
887
 
885
- it('adds session activation through OpenCode system context without setup or network', async () => {
888
+ it('classifies OpenCode parent, child, lookup failure, and missing identity safely', async () => {
886
889
  expect(source).toContain('"experimental.chat.system.transform"')
887
890
  const generated = (await import(
888
891
  `data:text/javascript;base64,${Buffer.from(source).toString('base64')}`
889
892
  )) as {
890
- NotifAIPlugin(input: { directory: string; client: object }): Promise<{
893
+ NotifAIPlugin(input: {
894
+ directory: string
895
+ client: {
896
+ session: {
897
+ get(input: { path: { id: string } }): Promise<{ data: object }>
898
+ }
899
+ }
900
+ }): Promise<{
891
901
  'experimental.chat.system.transform'?: (
892
- input: object,
902
+ input: { sessionID?: string },
893
903
  output: { system: string[] },
894
904
  ) => Promise<void>
895
905
  }>
896
906
  }
897
- const plugin = await generated.NotifAIPlugin({ directory: '/repo', client: {} })
898
- const output = { system: ['existing context'] }
907
+ const plugin = await generated.NotifAIPlugin({
908
+ directory: '/repo',
909
+ client: {
910
+ session: {
911
+ get: async ({ path: { id } }) => {
912
+ if (id === 'ses_parent') return { data: { id } }
913
+ if (id === 'ses_child') return { data: { id, parentID: 'ses_parent' } }
914
+ throw new Error('relationship lookup unavailable')
915
+ },
916
+ },
917
+ },
918
+ })
919
+ const parent = { system: ['existing context'] }
920
+
921
+ await plugin['experimental.chat.system.transform']?.({ sessionID: 'ses_parent' }, parent)
899
922
 
900
- await plugin['experimental.chat.system.transform']?.({ sessionID: 'ses_1' }, output)
923
+ expect(parent.system).toEqual([`existing context\n\n${MISSING_LIFECYCLE_GUIDANCE_CONTEXT}`])
901
924
 
902
- expect(output.system).toEqual([`existing context\n\n${MISSING_LIFECYCLE_GUIDANCE_CONTEXT}`])
925
+ const child = { system: [] as string[] }
926
+ await plugin['experimental.chat.system.transform']?.({ sessionID: 'ses_child' }, child)
927
+ expect(child.system).toEqual([WORKER_ACTIVATION_CONTEXT])
903
928
 
904
- const internal = { system: ['internal agent-generation context'] }
905
- await plugin['experimental.chat.system.transform']?.({}, internal)
906
- expect(internal.system).toEqual(['internal agent-generation context'])
929
+ const lookupFailure = { system: [] as string[] }
930
+ await plugin['experimental.chat.system.transform']?.({ sessionID: 'ses_unknown' }, lookupFailure)
931
+ expect(lookupFailure.system).toEqual([WORKER_ACTIVATION_CONTEXT])
907
932
 
908
- const empty = { system: [] as string[] }
909
- await plugin['experimental.chat.system.transform']?.({ sessionID: 'ses_2' }, empty)
910
- expect(empty.system).toEqual([MISSING_LIFECYCLE_GUIDANCE_CONTEXT])
933
+ const missingIdentity = { system: ['internal agent-generation context'] }
934
+ await plugin['experimental.chat.system.transform']?.({}, missingIdentity)
935
+ expect(missingIdentity.system).toEqual(['internal agent-generation context'])
911
936
  })
912
937
 
913
938
  it('publishes the session pointer on a user message without injecting content', () => {
@@ -1,7 +1,10 @@
1
1
  import path from 'node:path'
2
2
  import { hookHostPlatform, type HookHostPlatform } from './hook-adapter.js'
3
3
  import { configHome } from './install-hooks.js'
4
- import { MISSING_LIFECYCLE_GUIDANCE_CONTEXT } from './session-activation.js'
4
+ import {
5
+ MISSING_LIFECYCLE_GUIDANCE_CONTEXT,
6
+ WORKER_ACTIVATION_CONTEXT,
7
+ } from './session-activation.js'
5
8
 
6
9
  /**
7
10
  * The OpenCode adapter.
@@ -32,7 +35,7 @@ export const OPENCODE_PLUGIN_MARKER = '// notifai managed opencode plugin'
32
35
  export const OPENCODE_PLUGIN_FILENAME = 'notifai.js'
33
36
 
34
37
  /** Bump when an installed generated file must be rewritten to remain functional. */
35
- const OPENCODE_ADAPTER_VERSION = 11
38
+ const OPENCODE_ADAPTER_VERSION = 12
36
39
 
37
40
  export function opencodePluginDir(
38
41
  global: boolean,
@@ -105,6 +108,7 @@ ${nodeConstant}const ADAPTER = ${JSON.stringify(adapterPath)}
105
108
  const TIMEOUT_MS = ${timeoutSeconds * 1000}
106
109
  const ADAPTER_VERSION = ${OPENCODE_ADAPTER_VERSION}
107
110
  const MISSING_LIFECYCLE_GUIDANCE_CONTEXT = ${JSON.stringify(MISSING_LIFECYCLE_GUIDANCE_CONTEXT)}
111
+ const WORKER_ACTIVATION_CONTEXT = ${JSON.stringify(WORKER_ACTIVATION_CONTEXT)}
108
112
  const PENDING_SESSION_TITLE =
109
113
  /^New session(?: *- *[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}(?:[.][0-9]+)?Z)?$/i
110
114
 
@@ -166,14 +170,29 @@ export const NotifAIPlugin = async ({ directory, client }) => {
166
170
  const sessionID = input?.sessionID
167
171
  if (typeof sessionID !== "string" || sessionID.length === 0) return
168
172
  if (activated.has(sessionID)) return
169
- const resolved = await runHook("session-start", {
173
+ // OpenCode represents delegated work as child sessions. Missing or
174
+ // malformed relationship data must fail closed as worker context: it is
175
+ // safe to suppress a notification, never safe to let a child duplicate
176
+ // its parent's User-visible request.
177
+ let worker = true
178
+ try {
179
+ const response = await client.session.get({ path: { id: sessionID } })
180
+ const session = response?.data
181
+ if (session !== null && typeof session === "object") {
182
+ worker = typeof session.parentID === "string" && session.parentID.length > 0
183
+ }
184
+ } catch {
185
+ // Keep the safe worker classification.
186
+ }
187
+ const hookEventName = worker ? "SubagentStart" : "SessionStart"
188
+ const resolved = await runHook(worker ? "subagent-start" : "session-start", {
170
189
  session_id: sessionID,
171
190
  cwd,
172
- hook_event_name: "SessionStart",
191
+ hook_event_name: hookEventName,
173
192
  })
174
193
  const context = typeof resolved === "string" && resolved.trim().length > 0
175
194
  ? resolved.trim()
176
- : MISSING_LIFECYCLE_GUIDANCE_CONTEXT
195
+ : worker ? WORKER_ACTIVATION_CONTEXT : MISSING_LIFECYCLE_GUIDANCE_CONTEXT
177
196
  if (output.system.length === 0) {
178
197
  output.system.push(context)
179
198
  } else {
@@ -8,7 +8,7 @@ const ROOT_OWNERSHIP =
8
8
  'The root agent owns User-visible Notification Requests unless it explicitly delegates that ownership; workers report Agent Events to the parent.'
9
9
 
10
10
  export const WORKER_ACTIVATION_CONTEXT =
11
- 'Notifai worker context: report Agent Events to the parent; do not send User-visible Notification Requests unless the parent explicitly delegated that ownership. If delegated, run `notifai guidance` before composing one.'
11
+ 'Notifai worker context: report Agent Events to the parent; do not send User-visible Notification Requests unless the parent explicitly delegated that ownership. If delegated, load the Notifai skill and run `notifai guidance` before composing one.'
12
12
 
13
13
  export const MISSING_LIFECYCLE_GUIDANCE_CONTEXT =
14
14
  'Notifai is active, but lifecycle guidance could not be loaded. Run `notifai guidance` once before deciding whether or how to send a Notification Request.'
@@ -25,7 +25,7 @@ function rootActivationContext(cwd: string, env: NodeJS.ProcessEnv): string {
25
25
  /** One lifecycle meaning, encoded for each harness output contract. */
26
26
  export function sessionActivationOutput(
27
27
  harness: HookHarness | undefined,
28
- hookEventName: 'SessionStart' | 'SubagentStart' | 'UserPromptSubmit',
28
+ hookEventName: 'SessionStart' | 'SubagentStart',
29
29
  cwd: string = process.cwd(),
30
30
  env: NodeJS.ProcessEnv = process.env,
31
31
  ): string | undefined {
package/src/skill.test.ts CHANGED
@@ -47,21 +47,22 @@ const section = (heading: string): string => {
47
47
  }
48
48
 
49
49
  describe('Notifai agent skill', () => {
50
- it('routes proactively in ordinary sessions without waiting for the user to name Notifai', () => {
50
+ it('routes proactively in owner sessions without turning ordinary workers into senders', () => {
51
51
  // The frontmatter description is the only skill text available during
52
- // automatic selection. If the ordinary-session trigger lives only in the
53
- // body, agents doing unrelated work never load it and therefore never see
54
- // the completion rule.
55
- expect(description).toMatch(/every agent session/i)
56
- expect(description).toMatch(/even when the user does not mention Notifai/i)
52
+ // automatic selection. It must select parent owners and workers given
53
+ // explicit delegation without selecting every ordinary worker.
54
+ expect(description).toMatch(/every agent session that owns User-visible Notification Requests/i)
55
+ expect(description).toMatch(/parent owns by default/i)
56
+ expect(description).toMatch(/worker only when ownership is explicitly delegated/i)
57
+ expect(description).toMatch(/even when the user does not mention (?:Notifai|it)/i)
57
58
  expect(description).toMatch(/guidance/i)
58
59
 
59
60
  const decide = section('## Decide whether to notify')
60
- expect(decide).toMatch(/lifecycle context normally includes.*effective guidance/is)
61
+ expect(decide).toMatch(/owner session lifecycle context normally includes.*effective\s+guidance/is)
61
62
  expect(decide).toMatch(/context is absent.*read it once before judging an Agent\s+Event/is)
62
- expect(decide).toMatch(/parent owns User-visible Notification Requests/i)
63
- expect(decide).toMatch(/unless\s+it explicitly delegates/i)
64
- expect(decide).toMatch(/workers[\s\S]{0,100}do not send independently/i)
63
+ expect(decide).toMatch(/parent owns by default/i)
64
+ expect(decide).toMatch(/explicit textual\s+delegation makes a worker the owner/i)
65
+ expect(decide).toMatch(/ordinary workers[\s\S]{0,100}do not load or send/i)
65
66
  })
66
67
 
67
68
  it('preserves the original Agent Event across setup and ambiguous delivery', () => {