@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.
- package/CHANGELOG.md +7 -0
- package/dist/commands-harness-context.js +2 -2
- package/dist/commands-harness-context.js.map +1 -1
- package/dist/commands-hooks.js +5 -47
- package/dist/commands-hooks.js.map +1 -1
- package/dist/hooks.d.ts +2 -14
- package/dist/hooks.js +2 -29
- package/dist/hooks.js.map +1 -1
- package/dist/opencode-plugin.js +21 -5
- package/dist/opencode-plugin.js.map +1 -1
- package/dist/session-activation.d.ts +2 -2
- package/dist/session-activation.js +1 -1
- package/dist/session-activation.js.map +1 -1
- package/package.json +1 -1
- package/src/commands-harness-context.ts +2 -6
- package/src/commands-hooks.ts +5 -53
- package/src/commands.test.ts +2 -2
- package/src/hooks.test.ts +4 -130
- package/src/hooks.ts +2 -45
- package/src/install-hooks.test.ts +41 -16
- package/src/opencode-plugin.ts +24 -5
- package/src/session-activation.ts +2 -2
- package/src/skill.test.ts +11 -10
package/src/commands-hooks.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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: '
|
|
303
|
-
reason: '
|
|
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
|
})
|
package/src/commands.test.ts
CHANGED
|
@@ -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 =
|
|
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 =
|
|
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('
|
|
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
|
-
|
|
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
|
|
551
|
-
export function
|
|
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 {
|
|
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('
|
|
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('
|
|
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: {
|
|
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:
|
|
902
|
+
input: { sessionID?: string },
|
|
893
903
|
output: { system: string[] },
|
|
894
904
|
) => Promise<void>
|
|
895
905
|
}>
|
|
896
906
|
}
|
|
897
|
-
const plugin = await generated.NotifAIPlugin({
|
|
898
|
-
|
|
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
|
-
|
|
923
|
+
expect(parent.system).toEqual([`existing context\n\n${MISSING_LIFECYCLE_GUIDANCE_CONTEXT}`])
|
|
901
924
|
|
|
902
|
-
|
|
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
|
|
905
|
-
await plugin['experimental.chat.system.transform']?.({},
|
|
906
|
-
expect(
|
|
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
|
|
909
|
-
await plugin['experimental.chat.system.transform']?.({
|
|
910
|
-
expect(
|
|
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', () => {
|
package/src/opencode-plugin.ts
CHANGED
|
@@ -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 {
|
|
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 =
|
|
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
|
-
|
|
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:
|
|
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'
|
|
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
|
|
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.
|
|
53
|
-
//
|
|
54
|
-
|
|
55
|
-
expect(description).toMatch(/
|
|
56
|
-
expect(description).toMatch(/
|
|
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
|
|
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
|
|
63
|
-
expect(decide).toMatch(/
|
|
64
|
-
expect(decide).toMatch(/workers[\s\S]{0,100}do not send
|
|
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', () => {
|