@raidiant/notifai 9.0.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 +14 -0
- package/dist/command-session.d.ts +14 -0
- package/dist/command-session.js +30 -0
- package/dist/command-session.js.map +1 -0
- package/dist/commands-doctor.js +17 -15
- package/dist/commands-doctor.js.map +1 -1
- package/dist/commands-guidance.js +3 -29
- package/dist/commands-guidance.js.map +1 -1
- package/dist/commands-harness-context.js +21 -1
- package/dist/commands-harness-context.js.map +1 -1
- package/dist/commands-hooks.d.ts +11 -1
- package/dist/commands-hooks.js +98 -184
- package/dist/commands-hooks.js.map +1 -1
- package/dist/commands-init.d.ts +2 -0
- package/dist/commands-init.js +41 -21
- package/dist/commands-init.js.map +1 -1
- package/dist/commands-send.d.ts +1 -0
- package/dist/commands-send.js +62 -14
- package/dist/commands-send.js.map +1 -1
- package/dist/commands-setup-proof.d.ts +4 -3
- package/dist/commands-setup-proof.js +29 -14
- package/dist/commands-setup-proof.js.map +1 -1
- package/dist/commands.d.ts +1 -1
- package/dist/commands.js +1 -1
- package/dist/commands.js.map +1 -1
- package/dist/guidance-render.d.ts +20 -0
- package/dist/guidance-render.js +42 -0
- package/dist/guidance-render.js.map +1 -0
- package/dist/hooks.d.ts +16 -13
- package/dist/hooks.js +57 -32
- package/dist/hooks.js.map +1 -1
- package/dist/opencode-plugin.js +32 -5
- package/dist/opencode-plugin.js.map +1 -1
- package/dist/program.js +21 -12
- package/dist/program.js.map +1 -1
- package/dist/readiness.d.ts +6 -0
- package/dist/readiness.js +37 -0
- package/dist/readiness.js.map +1 -1
- package/dist/send-attempts.d.ts +26 -0
- package/dist/send-attempts.js +139 -0
- package/dist/send-attempts.js.map +1 -0
- package/dist/session-activation.d.ts +4 -8
- package/dist/session-activation.js +25 -14
- package/dist/session-activation.js.map +1 -1
- package/package.json +1 -1
- package/src/command-session.ts +38 -0
- package/src/commands-doctor.ts +18 -28
- package/src/commands-guidance.ts +3 -38
- package/src/commands-harness-context.ts +20 -1
- package/src/commands-hooks.ts +219 -220
- package/src/commands-init.ts +46 -24
- package/src/commands-send.ts +70 -14
- package/src/commands-setup-proof.test.ts +72 -0
- package/src/commands-setup-proof.ts +33 -14
- package/src/commands.test.ts +209 -67
- package/src/commands.ts +1 -0
- package/src/guidance-render.ts +56 -0
- package/src/guidance.test.ts +28 -0
- package/src/hooks.test.ts +42 -137
- package/src/hooks.ts +67 -46
- package/src/install-hooks.test.ts +41 -16
- package/src/opencode-plugin.ts +35 -5
- package/src/program.ts +22 -12
- package/src/readiness.ts +44 -0
- package/src/send-attempts.test.ts +143 -0
- package/src/send-attempts.ts +188 -0
- package/src/session-activation.ts +35 -18
- package/src/skill.test.ts +22 -23
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,
|
|
@@ -104,7 +107,8 @@ import { spawn } from "node:child_process"
|
|
|
104
107
|
${nodeConstant}const ADAPTER = ${JSON.stringify(adapterPath)}
|
|
105
108
|
const TIMEOUT_MS = ${timeoutSeconds * 1000}
|
|
106
109
|
const ADAPTER_VERSION = ${OPENCODE_ADAPTER_VERSION}
|
|
107
|
-
const
|
|
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
|
|
|
@@ -158,17 +162,43 @@ function runHook(event, envelope) {
|
|
|
158
162
|
|
|
159
163
|
export const NotifAIPlugin = async ({ directory, client }) => {
|
|
160
164
|
const cwd = typeof directory === "string" && directory.length > 0 ? directory : process.cwd()
|
|
165
|
+
const activated = new Set()
|
|
161
166
|
|
|
162
167
|
return {
|
|
163
168
|
/** Model-visible activation, independent of CLI setup and delivery state. */
|
|
164
169
|
"experimental.chat.system.transform": async (input, output) => {
|
|
165
170
|
const sessionID = input?.sessionID
|
|
166
171
|
if (typeof sessionID !== "string" || sessionID.length === 0) return
|
|
172
|
+
if (activated.has(sessionID)) return
|
|
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", {
|
|
189
|
+
session_id: sessionID,
|
|
190
|
+
cwd,
|
|
191
|
+
hook_event_name: hookEventName,
|
|
192
|
+
})
|
|
193
|
+
const context = typeof resolved === "string" && resolved.trim().length > 0
|
|
194
|
+
? resolved.trim()
|
|
195
|
+
: worker ? WORKER_ACTIVATION_CONTEXT : MISSING_LIFECYCLE_GUIDANCE_CONTEXT
|
|
167
196
|
if (output.system.length === 0) {
|
|
168
|
-
output.system.push(
|
|
197
|
+
output.system.push(context)
|
|
169
198
|
} else {
|
|
170
|
-
output.system[0] = output.system[0] + "\\n\\n" +
|
|
199
|
+
output.system[0] = output.system[0] + "\\n\\n" + context
|
|
171
200
|
}
|
|
201
|
+
activated.add(sessionID)
|
|
172
202
|
},
|
|
173
203
|
|
|
174
204
|
/** Exact active-harness identity and first-party title for agent shell commands. */
|
package/src/program.ts
CHANGED
|
@@ -25,6 +25,7 @@ import {
|
|
|
25
25
|
logoutCommand,
|
|
26
26
|
logsCommand,
|
|
27
27
|
repliesCommand,
|
|
28
|
+
reportAskFailure,
|
|
28
29
|
sendCommand,
|
|
29
30
|
statusCommand,
|
|
30
31
|
type CommandDeps,
|
|
@@ -216,6 +217,7 @@ export function buildProgram(deps: CommandDeps, options: BuildProgramOptions = {
|
|
|
216
217
|
'Interactive at a human terminal; never prompts otherwise (agents: pass flags)',
|
|
217
218
|
)
|
|
218
219
|
.option('--project-id <id>', 'project identifier slug (default: derived from the directory name)')
|
|
220
|
+
.option('--json', 'machine-readable final readiness; never prompts')
|
|
219
221
|
.option('--skills', 'install/update the agent skill from its pinned public release')
|
|
220
222
|
.option('--no-skills', 'suppress the optional agent-skill status line')
|
|
221
223
|
.option(
|
|
@@ -228,6 +230,7 @@ export function buildProgram(deps: CommandDeps, options: BuildProgramOptions = {
|
|
|
228
230
|
.action(
|
|
229
231
|
async (opts: {
|
|
230
232
|
projectId?: string
|
|
233
|
+
json?: boolean
|
|
231
234
|
skills?: boolean
|
|
232
235
|
setupScope?: SkillScope
|
|
233
236
|
skillsScope?: SkillScope
|
|
@@ -339,10 +342,10 @@ export function buildProgram(deps: CommandDeps, options: BuildProgramOptions = {
|
|
|
339
342
|
.option('--project <id>', 'project identifier override (otherwise configured or inferred)')
|
|
340
343
|
.option('--device <id>', 'target a device id (repeatable)', (v: string, all: string[] = []) => [...all, v])
|
|
341
344
|
.option('--all', 'target all routable devices (overrides configured devices)')
|
|
342
|
-
.option('--session-id <id>', 'opaque exact
|
|
345
|
+
.option('--session-id <id>', 'low-level automation override for an opaque exact session (env: NOTIFAI_SESSION_ID)')
|
|
343
346
|
.option(
|
|
344
347
|
'--session-label <text>',
|
|
345
|
-
'
|
|
348
|
+
'human session name; safe to repeat, with the first accepted name frozen (env: NOTIFAI_SESSION_LABEL)',
|
|
346
349
|
)
|
|
347
350
|
.optionsGroup(SEND_GROUP.presentation)
|
|
348
351
|
.option('--collapse-key <key>', 'replace earlier notifications with the same key')
|
|
@@ -373,6 +376,7 @@ export function buildProgram(deps: CommandDeps, options: BuildProgramOptions = {
|
|
|
373
376
|
.option('--no-wait', 'return immediately after acceptance')
|
|
374
377
|
.option('--data <key=value>', 'custom data (repeatable)', (v: string, all: string[] = []) => [...all, v])
|
|
375
378
|
.option('--idempotency-key <key>', 'safe-retry key (default: random)')
|
|
379
|
+
.option('--retry', 'explicitly retry one unresolved exact semantic send using its opaque saved attempt')
|
|
376
380
|
.option(
|
|
377
381
|
'--base-url <url>',
|
|
378
382
|
'pairing override for the service origin; ignored when this machine is already paired (also NOTIFAI_BASE_URL)',
|
|
@@ -478,10 +482,9 @@ export function buildProgram(deps: CommandDeps, options: BuildProgramOptions = {
|
|
|
478
482
|
(v: string, all: string[] = []) => [...all, v],
|
|
479
483
|
)
|
|
480
484
|
.option('--project <id>', 'project identifier override (otherwise configured or inferred)')
|
|
481
|
-
.option('--session-id <id>', 'opaque exact-session override (env: NOTIFAI_SESSION_ID)')
|
|
482
485
|
.option(
|
|
483
486
|
'--session-label <text>',
|
|
484
|
-
'
|
|
487
|
+
'human session name; safe to repeat, with the first accepted name frozen (env: NOTIFAI_SESSION_LABEL)',
|
|
485
488
|
)
|
|
486
489
|
.action(async (question: string | undefined, opts: {
|
|
487
490
|
choice?: string[]
|
|
@@ -494,20 +497,25 @@ export function buildProgram(deps: CommandDeps, options: BuildProgramOptions = {
|
|
|
494
497
|
image?: string[]
|
|
495
498
|
imageAlt?: string[]
|
|
496
499
|
project?: string
|
|
497
|
-
sessionId?: string
|
|
498
500
|
sessionLabel?: string
|
|
499
501
|
}) => {
|
|
500
502
|
let body = opts.body
|
|
501
503
|
if (typeof opts.bodyFile === 'string') {
|
|
502
504
|
if (body !== undefined) {
|
|
503
|
-
deps
|
|
504
|
-
|
|
505
|
+
return exit(reportAskFailure(deps, opts, {
|
|
506
|
+
code: 'invalid_input', check_id: 'body', exit_code: 2,
|
|
507
|
+
remedy: 'pass either --body or --body-file, then retry',
|
|
508
|
+
message: 'Pass either --body or --body-file, not both.',
|
|
509
|
+
}))
|
|
505
510
|
}
|
|
506
511
|
try {
|
|
507
512
|
body = readFileSync(opts.bodyFile === '-' ? 0 : opts.bodyFile, 'utf8')
|
|
508
513
|
} catch (err) {
|
|
509
|
-
deps
|
|
510
|
-
|
|
514
|
+
return exit(reportAskFailure(deps, opts, {
|
|
515
|
+
code: 'input_unreadable', check_id: 'body_file', exit_code: 2,
|
|
516
|
+
remedy: 'fix the body-file path or permissions, then retry',
|
|
517
|
+
message: `Could not read ${opts.bodyFile}: ${String(err)}`,
|
|
518
|
+
}))
|
|
511
519
|
}
|
|
512
520
|
}
|
|
513
521
|
let form: string | undefined
|
|
@@ -515,8 +523,11 @@ export function buildProgram(deps: CommandDeps, options: BuildProgramOptions = {
|
|
|
515
523
|
try {
|
|
516
524
|
form = readFileSync(opts.form === '-' ? 0 : opts.form, 'utf8')
|
|
517
525
|
} catch (err) {
|
|
518
|
-
deps
|
|
519
|
-
|
|
526
|
+
return exit(reportAskFailure(deps, opts, {
|
|
527
|
+
code: 'input_unreadable', check_id: 'form_file', exit_code: 2,
|
|
528
|
+
remedy: 'fix the form-file path or permissions, then retry',
|
|
529
|
+
message: `Could not read ${opts.form}: ${String(err)}`,
|
|
530
|
+
}))
|
|
520
531
|
}
|
|
521
532
|
}
|
|
522
533
|
// Commander collectors default to []; an empty list means "not passed".
|
|
@@ -528,7 +539,6 @@ export function buildProgram(deps: CommandDeps, options: BuildProgramOptions = {
|
|
|
528
539
|
...(opts.image?.length ? { image: opts.image } : {}),
|
|
529
540
|
...(opts.imageAlt?.length ? { imageAlt: opts.imageAlt } : {}),
|
|
530
541
|
...(opts.project !== undefined ? { project: opts.project } : {}),
|
|
531
|
-
...(opts.sessionId !== undefined ? { sessionId: opts.sessionId } : {}),
|
|
532
542
|
...(opts.sessionLabel !== undefined ? { sessionLabel: opts.sessionLabel } : {}),
|
|
533
543
|
...(opts.json === true ? { json: true } : {}),
|
|
534
544
|
...(opts.literalBackslashN === true ? { literalBackslashN: true } : {}),
|
package/src/readiness.ts
CHANGED
|
@@ -84,6 +84,50 @@ export function isReady(readiness: Readiness): boolean {
|
|
|
84
84
|
return firstBlocker(readiness) === null
|
|
85
85
|
}
|
|
86
86
|
|
|
87
|
+
const SEND_PREREQUISITES = new Set([
|
|
88
|
+
'cli-bin',
|
|
89
|
+
'credential',
|
|
90
|
+
'server',
|
|
91
|
+
'contract',
|
|
92
|
+
'auth',
|
|
93
|
+
'devices',
|
|
94
|
+
])
|
|
95
|
+
|
|
96
|
+
/** Baseline Notification Requests can be submitted in the final assessed state. */
|
|
97
|
+
export function canSend(readiness: Readiness): boolean {
|
|
98
|
+
return [...SEND_PREREQUISITES].every((id) => {
|
|
99
|
+
const state = readiness.states.find((candidate) => candidate.id === id)
|
|
100
|
+
return state?.status === 'ready' || state?.status === 'optional-gap'
|
|
101
|
+
})
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** This exact invocation has a fully evidenced asynchronous question route. */
|
|
105
|
+
export function questionRoutingReady(readiness: Readiness): boolean {
|
|
106
|
+
const relevant = readiness.states.filter(
|
|
107
|
+
(state) =>
|
|
108
|
+
state.id === 'question-routing-settings' ||
|
|
109
|
+
(state.id === 'hooks' || state.id.startsWith('hooks-')) && state.id !== 'hooks-detected',
|
|
110
|
+
)
|
|
111
|
+
return relevant.length > 0 && relevant.every((state) => state.status === 'ready')
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** Stable machine renderer shared by init and doctor. */
|
|
115
|
+
export function readinessJson(readiness: Readiness): object {
|
|
116
|
+
return {
|
|
117
|
+
ready: isReady(readiness),
|
|
118
|
+
can_send: canSend(readiness),
|
|
119
|
+
question_routing_ready: questionRoutingReady(readiness),
|
|
120
|
+
states: readiness.states.map((state) => ({
|
|
121
|
+
id: state.id,
|
|
122
|
+
title: state.title,
|
|
123
|
+
status: state.status,
|
|
124
|
+
detail: state.detail,
|
|
125
|
+
technical: state.technical ?? null,
|
|
126
|
+
remedy: state.remedy ?? null,
|
|
127
|
+
})),
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
87
131
|
/** Everything that could still be done, blocking or not, in dependency order. */
|
|
88
132
|
export function openItems(readiness: Readiness): ReadinessState[] {
|
|
89
133
|
return readiness.states.filter((s) => s.status === 'gap' || s.status === 'optional-gap')
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
import { mkdtempSync, readFileSync, readdirSync, rmSync, statSync, symlinkSync, writeFileSync } from 'node:fs'
|
|
2
|
+
import os from 'node:os'
|
|
3
|
+
import path from 'node:path'
|
|
4
|
+
import { afterEach, describe, expect, it } from 'vitest'
|
|
5
|
+
import type { NotificationDraftT } from '@raidiant/notifai-protocol'
|
|
6
|
+
import type { MachineCredential } from './credentials.js'
|
|
7
|
+
import {
|
|
8
|
+
SEND_ATTEMPT_TTL_MS,
|
|
9
|
+
beginSendAttempt,
|
|
10
|
+
semanticMediaIds,
|
|
11
|
+
sendDraftFingerprint,
|
|
12
|
+
settleSendAttempt,
|
|
13
|
+
} from './send-attempts.js'
|
|
14
|
+
|
|
15
|
+
const roots: string[] = []
|
|
16
|
+
afterEach(() => {
|
|
17
|
+
while (roots.length > 0) rmSync(roots.pop()!, { recursive: true, force: true })
|
|
18
|
+
})
|
|
19
|
+
|
|
20
|
+
function fixture() {
|
|
21
|
+
const root = mkdtempSync(path.join(os.tmpdir(), 'notifai-send-attempt-'))
|
|
22
|
+
roots.push(root)
|
|
23
|
+
const env = { XDG_STATE_HOME: path.join(root, 'state') }
|
|
24
|
+
const credential: MachineCredential = {
|
|
25
|
+
machineId: 'mac_attempt',
|
|
26
|
+
machineName: 'Attempt Machine',
|
|
27
|
+
baseUrl: 'https://app.notifai.test',
|
|
28
|
+
secret: 'private-machine-secret',
|
|
29
|
+
}
|
|
30
|
+
const draft = {
|
|
31
|
+
schema_version: 1,
|
|
32
|
+
kind: 'done',
|
|
33
|
+
project: 'private-project',
|
|
34
|
+
presentation: { title: 'Sensitive result', body: 'Private body text' },
|
|
35
|
+
targets: { mode: 'all' },
|
|
36
|
+
} as NotificationDraftT
|
|
37
|
+
return { root, env, credential, draft }
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
describe('opaque interrupted-send recovery', () => {
|
|
41
|
+
it('matches one exact semantic retry without storing notification content', () => {
|
|
42
|
+
const { env, credential, draft } = fixture()
|
|
43
|
+
const fingerprint = sendDraftFingerprint(draft, credential)
|
|
44
|
+
const first = beginSendAttempt({ env, credential, fingerprint, retry: false, now: 10 })
|
|
45
|
+
expect(first.ok).toBe(true)
|
|
46
|
+
if (!first.ok) return
|
|
47
|
+
|
|
48
|
+
const files = readdirSync(path.join(env.XDG_STATE_HOME, 'notifai', 'send-attempts'))
|
|
49
|
+
expect(files).toHaveLength(1)
|
|
50
|
+
const file = path.join(env.XDG_STATE_HOME, 'notifai', 'send-attempts', files[0]!)
|
|
51
|
+
const stored = readFileSync(file, 'utf8')
|
|
52
|
+
expect(stored).not.toContain('Sensitive result')
|
|
53
|
+
expect(stored).not.toContain('Private body text')
|
|
54
|
+
if (process.platform !== 'win32') expect(statSync(file).mode & 0o777).toBe(0o600)
|
|
55
|
+
if (process.platform !== 'win32') {
|
|
56
|
+
expect(statSync(path.dirname(file)).mode & 0o777).toBe(0o700)
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const retry = beginSendAttempt({ env, credential, fingerprint, retry: true, now: 11 })
|
|
60
|
+
expect(retry).toMatchObject({
|
|
61
|
+
ok: true,
|
|
62
|
+
attemptId: first.attemptId,
|
|
63
|
+
idempotencyKey: first.idempotencyKey,
|
|
64
|
+
replay: true,
|
|
65
|
+
})
|
|
66
|
+
settleSendAttempt(env, first.attemptId)
|
|
67
|
+
expect(beginSendAttempt({ env, credential, fingerprint, retry: true, now: 12 })).toMatchObject({
|
|
68
|
+
ok: false,
|
|
69
|
+
code: 'retry_not_found',
|
|
70
|
+
})
|
|
71
|
+
})
|
|
72
|
+
|
|
73
|
+
it('does not follow a symlink presented as an attempt record', () => {
|
|
74
|
+
const { root, env, credential, draft } = fixture()
|
|
75
|
+
if (process.platform === 'win32') return
|
|
76
|
+
const fingerprint = sendDraftFingerprint(draft, credential)
|
|
77
|
+
const first = beginSendAttempt({ env, credential, fingerprint, retry: false, now: 10 })
|
|
78
|
+
expect(first.ok).toBe(true)
|
|
79
|
+
if (!first.ok) return
|
|
80
|
+
const directory = path.join(env.XDG_STATE_HOME, 'notifai', 'send-attempts')
|
|
81
|
+
const file = path.join(directory, `${first.attemptId}.json`)
|
|
82
|
+
const target = path.join(root, 'foreign.json')
|
|
83
|
+
writeFileSync(target, readFileSync(file, 'utf8'))
|
|
84
|
+
rmSync(file)
|
|
85
|
+
symlinkSync(target, file)
|
|
86
|
+
|
|
87
|
+
expect(beginSendAttempt({ env, credential, fingerprint, retry: true, now: 11 })).toMatchObject({
|
|
88
|
+
ok: false,
|
|
89
|
+
code: 'retry_not_found',
|
|
90
|
+
})
|
|
91
|
+
expect(readFileSync(target, 'utf8')).toContain(first.idempotencyKey)
|
|
92
|
+
})
|
|
93
|
+
|
|
94
|
+
it('refuses ambiguity instead of choosing between identical unresolved events', () => {
|
|
95
|
+
const { env, credential, draft } = fixture()
|
|
96
|
+
const fingerprint = sendDraftFingerprint(draft, credential)
|
|
97
|
+
beginSendAttempt({ env, credential, fingerprint, retry: false, now: 10 })
|
|
98
|
+
beginSendAttempt({ env, credential, fingerprint, retry: false, now: 11 })
|
|
99
|
+
expect(beginSendAttempt({ env, credential, fingerprint, retry: true, now: 12 })).toMatchObject({
|
|
100
|
+
ok: false,
|
|
101
|
+
code: 'retry_ambiguous',
|
|
102
|
+
})
|
|
103
|
+
})
|
|
104
|
+
|
|
105
|
+
it('invalidates attempts after expiry or credential rotation', () => {
|
|
106
|
+
const { env, credential, draft } = fixture()
|
|
107
|
+
const fingerprint = sendDraftFingerprint(draft, credential)
|
|
108
|
+
beginSendAttempt({ env, credential, fingerprint, retry: false, now: 10 })
|
|
109
|
+
expect(
|
|
110
|
+
beginSendAttempt({
|
|
111
|
+
env,
|
|
112
|
+
credential,
|
|
113
|
+
fingerprint,
|
|
114
|
+
retry: true,
|
|
115
|
+
now: 10 + SEND_ATTEMPT_TTL_MS + 1,
|
|
116
|
+
}),
|
|
117
|
+
).toMatchObject({ ok: false, code: 'retry_not_found' })
|
|
118
|
+
|
|
119
|
+
beginSendAttempt({ env, credential, fingerprint, retry: false, now: 20 })
|
|
120
|
+
const rotated = { ...credential, secret: 'rotated-secret' }
|
|
121
|
+
expect(
|
|
122
|
+
beginSendAttempt({
|
|
123
|
+
env,
|
|
124
|
+
credential: rotated,
|
|
125
|
+
fingerprint: sendDraftFingerprint(draft, rotated),
|
|
126
|
+
retry: true,
|
|
127
|
+
now: 21,
|
|
128
|
+
}),
|
|
129
|
+
).toMatchObject({ ok: false, code: 'retry_not_found' })
|
|
130
|
+
})
|
|
131
|
+
|
|
132
|
+
it('fingerprints local media bytes without retaining paths or bytes', () => {
|
|
133
|
+
const { root } = fixture()
|
|
134
|
+
const file = path.join(root, 'private.png')
|
|
135
|
+
writeFileSync(file, 'first bytes')
|
|
136
|
+
const first = semanticMediaIds([file], root)
|
|
137
|
+
writeFileSync(file, 'different bytes')
|
|
138
|
+
const second = semanticMediaIds([file], root)
|
|
139
|
+
expect(first).not.toEqual(second)
|
|
140
|
+
expect(first[0]).not.toContain(file)
|
|
141
|
+
expect(first[0]).not.toContain('first bytes')
|
|
142
|
+
})
|
|
143
|
+
})
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
import { createHash, createHmac, randomBytes } from 'node:crypto'
|
|
2
|
+
import {
|
|
3
|
+
closeSync,
|
|
4
|
+
constants,
|
|
5
|
+
existsSync,
|
|
6
|
+
fstatSync,
|
|
7
|
+
lstatSync,
|
|
8
|
+
openSync,
|
|
9
|
+
readFileSync,
|
|
10
|
+
readdirSync,
|
|
11
|
+
rmSync,
|
|
12
|
+
} from 'node:fs'
|
|
13
|
+
import path from 'node:path'
|
|
14
|
+
import type { NotificationDraftT } from '@raidiant/notifai-protocol'
|
|
15
|
+
import { atomicWriteFileSync } from './atomic-file.js'
|
|
16
|
+
import { stateDir } from './config.js'
|
|
17
|
+
import type { MachineCredential } from './credentials.js'
|
|
18
|
+
|
|
19
|
+
interface SendAttemptRecord {
|
|
20
|
+
version: 1
|
|
21
|
+
attempt_id: string
|
|
22
|
+
fingerprint: string
|
|
23
|
+
idempotency_key: string
|
|
24
|
+
credential_tag: string
|
|
25
|
+
machine_id: string
|
|
26
|
+
service: string
|
|
27
|
+
created_at: string
|
|
28
|
+
expires_at: string
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export const SEND_ATTEMPT_TTL_MS = 24 * 60 * 60 * 1000
|
|
32
|
+
|
|
33
|
+
/** Stable pre-upload media identities used only inside the keyed draft digest. */
|
|
34
|
+
export function semanticMediaIds(images: readonly string[], cwd: string): string[] {
|
|
35
|
+
return images.map((image) => {
|
|
36
|
+
if (image.startsWith('med_')) return image
|
|
37
|
+
const digest = createHash('sha256')
|
|
38
|
+
if (/^https?:\/\//i.test(image)) digest.update(`url\0${image}`)
|
|
39
|
+
else digest.update('file\0').update(readFileSync(path.resolve(cwd, image)))
|
|
40
|
+
return `med_retry_${digest.digest('base64url')}`
|
|
41
|
+
})
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function directory(env: NodeJS.ProcessEnv): string {
|
|
45
|
+
return path.join(stateDir(env), 'send-attempts')
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function credentialTag(credential: MachineCredential): string {
|
|
49
|
+
return createHmac('sha256', credential.secret)
|
|
50
|
+
.update('notifai-send-attempt-credential-v1')
|
|
51
|
+
.digest('base64url')
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** A keyed digest proves equality without persisting notification content. */
|
|
55
|
+
export function sendDraftFingerprint(
|
|
56
|
+
draft: NotificationDraftT,
|
|
57
|
+
credential: MachineCredential,
|
|
58
|
+
): string {
|
|
59
|
+
return createHmac('sha256', credential.secret)
|
|
60
|
+
.update('notifai-send-draft-v1\0')
|
|
61
|
+
.update(JSON.stringify(draft))
|
|
62
|
+
.digest('base64url')
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function parseRecord(file: string): SendAttemptRecord | null {
|
|
66
|
+
let descriptor: number | undefined
|
|
67
|
+
try {
|
|
68
|
+
const before = lstatSync(file)
|
|
69
|
+
const uid = typeof process.getuid === 'function' ? process.getuid() : undefined
|
|
70
|
+
if (before.isSymbolicLink() || !before.isFile() || (uid !== undefined && before.uid !== uid)) {
|
|
71
|
+
return null
|
|
72
|
+
}
|
|
73
|
+
const flags = constants.O_RDONLY | (process.platform === 'win32' ? 0 : constants.O_NOFOLLOW)
|
|
74
|
+
descriptor = openSync(file, flags)
|
|
75
|
+
const opened = fstatSync(descriptor)
|
|
76
|
+
if (!opened.isFile() || opened.dev !== before.dev || opened.ino !== before.ino) return null
|
|
77
|
+
const value = JSON.parse(readFileSync(descriptor, 'utf8')) as Partial<SendAttemptRecord>
|
|
78
|
+
return value.version === 1 &&
|
|
79
|
+
typeof value.attempt_id === 'string' &&
|
|
80
|
+
typeof value.fingerprint === 'string' &&
|
|
81
|
+
typeof value.idempotency_key === 'string' &&
|
|
82
|
+
typeof value.credential_tag === 'string' &&
|
|
83
|
+
typeof value.machine_id === 'string' &&
|
|
84
|
+
typeof value.service === 'string' &&
|
|
85
|
+
typeof value.created_at === 'string' &&
|
|
86
|
+
typeof value.expires_at === 'string'
|
|
87
|
+
? value as SendAttemptRecord
|
|
88
|
+
: null
|
|
89
|
+
} catch {
|
|
90
|
+
return null
|
|
91
|
+
} finally {
|
|
92
|
+
if (descriptor !== undefined) closeSync(descriptor)
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function liveRecords(
|
|
97
|
+
env: NodeJS.ProcessEnv,
|
|
98
|
+
credential: MachineCredential,
|
|
99
|
+
now: number,
|
|
100
|
+
): SendAttemptRecord[] {
|
|
101
|
+
const dir = directory(env)
|
|
102
|
+
if (!existsSync(dir)) return []
|
|
103
|
+
const directoryStat = lstatSync(dir)
|
|
104
|
+
const uid = typeof process.getuid === 'function' ? process.getuid() : undefined
|
|
105
|
+
if (
|
|
106
|
+
directoryStat.isSymbolicLink() ||
|
|
107
|
+
!directoryStat.isDirectory() ||
|
|
108
|
+
(uid !== undefined && directoryStat.uid !== uid)
|
|
109
|
+
) return []
|
|
110
|
+
const tag = credentialTag(credential)
|
|
111
|
+
const records: SendAttemptRecord[] = []
|
|
112
|
+
for (const name of readdirSync(dir)) {
|
|
113
|
+
if (!name.endsWith('.json')) continue
|
|
114
|
+
const file = path.join(dir, name)
|
|
115
|
+
const record = parseRecord(file)
|
|
116
|
+
const expired = record === null || Date.parse(record.expires_at) <= now
|
|
117
|
+
const rotated = record !== null && (
|
|
118
|
+
record.machine_id !== credential.machineId ||
|
|
119
|
+
record.service !== credential.baseUrl ||
|
|
120
|
+
record.credential_tag !== tag
|
|
121
|
+
)
|
|
122
|
+
if (expired || rotated) {
|
|
123
|
+
rmSync(file, { force: true })
|
|
124
|
+
continue
|
|
125
|
+
}
|
|
126
|
+
records.push(record)
|
|
127
|
+
}
|
|
128
|
+
return records
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
export type BeginAttempt =
|
|
132
|
+
| { ok: true; attemptId: string; idempotencyKey: string; replay: boolean }
|
|
133
|
+
| { ok: false; code: 'retry_not_found' | 'retry_ambiguous'; message: string }
|
|
134
|
+
|
|
135
|
+
export function beginSendAttempt(options: {
|
|
136
|
+
env: NodeJS.ProcessEnv
|
|
137
|
+
credential: MachineCredential
|
|
138
|
+
fingerprint: string
|
|
139
|
+
retry: boolean
|
|
140
|
+
idempotencyKey?: string
|
|
141
|
+
now?: number
|
|
142
|
+
}): BeginAttempt {
|
|
143
|
+
const now = options.now ?? Date.now()
|
|
144
|
+
const live = liveRecords(options.env, options.credential, now)
|
|
145
|
+
if (options.retry) {
|
|
146
|
+
const matches = live.filter((record) => record.fingerprint === options.fingerprint)
|
|
147
|
+
if (matches.length === 0) {
|
|
148
|
+
return {
|
|
149
|
+
ok: false,
|
|
150
|
+
code: 'retry_not_found',
|
|
151
|
+
message: 'No unresolved opaque attempt matches this exact send on the current Approved Machine.',
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
if (matches.length > 1) {
|
|
155
|
+
return {
|
|
156
|
+
ok: false,
|
|
157
|
+
code: 'retry_ambiguous',
|
|
158
|
+
message: 'More than one unresolved opaque attempt matches this exact send; refusing to guess or create another notification.',
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
const match = matches[0]!
|
|
162
|
+
return { ok: true, attemptId: match.attempt_id, idempotencyKey: match.idempotency_key, replay: true }
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
const attemptId = `sat_${randomBytes(12).toString('base64url')}`
|
|
166
|
+
const idempotencyKey = options.idempotencyKey ?? `cli-${randomBytes(12).toString('base64url')}`
|
|
167
|
+
const record: SendAttemptRecord = {
|
|
168
|
+
version: 1,
|
|
169
|
+
attempt_id: attemptId,
|
|
170
|
+
fingerprint: options.fingerprint,
|
|
171
|
+
idempotency_key: idempotencyKey,
|
|
172
|
+
credential_tag: credentialTag(options.credential),
|
|
173
|
+
machine_id: options.credential.machineId,
|
|
174
|
+
service: options.credential.baseUrl,
|
|
175
|
+
created_at: new Date(now).toISOString(),
|
|
176
|
+
expires_at: new Date(now + SEND_ATTEMPT_TTL_MS).toISOString(),
|
|
177
|
+
}
|
|
178
|
+
atomicWriteFileSync(
|
|
179
|
+
path.join(directory(options.env), `${attemptId}.json`),
|
|
180
|
+
`${JSON.stringify(record)}\n`,
|
|
181
|
+
{ mode: 0o600, directoryMode: 0o700, requireCurrentUserOwner: true },
|
|
182
|
+
)
|
|
183
|
+
return { ok: true, attemptId, idempotencyKey, replay: false }
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
export function settleSendAttempt(env: NodeJS.ProcessEnv, attemptId: string): void {
|
|
187
|
+
rmSync(path.join(directory(env), `${attemptId}.json`), { force: true })
|
|
188
|
+
}
|
|
@@ -1,29 +1,46 @@
|
|
|
1
1
|
import type { HookHarness } from './hooks.js'
|
|
2
|
+
import { Buffer } from 'node:buffer'
|
|
3
|
+
import { GUIDANCE_CONTEXT_MAX_BYTES, boundedEffectiveGuidance } from './guidance-render.js'
|
|
2
4
|
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
export const
|
|
9
|
-
'Notifai
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
'
|
|
5
|
+
const ROOT_OWNERSHIP =
|
|
6
|
+
'Notifai is active for this session. The effective, provenance-marked guidance below governs Notification Requests. ' +
|
|
7
|
+
'Missing CLI readiness is handled through `notifai init --json`. ' +
|
|
8
|
+
'The root agent owns User-visible Notification Requests unless it explicitly delegates that ownership; workers report Agent Events to the parent.'
|
|
9
|
+
|
|
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, load the Notifai skill and run `notifai guidance` before composing one.'
|
|
12
|
+
|
|
13
|
+
export const MISSING_LIFECYCLE_GUIDANCE_CONTEXT =
|
|
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.'
|
|
15
|
+
|
|
16
|
+
function rootActivationContext(cwd: string, env: NodeJS.ProcessEnv): string {
|
|
17
|
+
const guidance = boundedEffectiveGuidance({
|
|
18
|
+
cwd,
|
|
19
|
+
env,
|
|
20
|
+
maxBytes: GUIDANCE_CONTEXT_MAX_BYTES - Buffer.byteLength(`${ROOT_OWNERSHIP}\n\n`, 'utf8'),
|
|
21
|
+
})
|
|
22
|
+
return `${ROOT_OWNERSHIP}\n\n${guidance.ok ? guidance.content : guidance.fallback}`
|
|
23
|
+
}
|
|
13
24
|
|
|
14
25
|
/** One lifecycle meaning, encoded for each harness output contract. */
|
|
15
26
|
export function sessionActivationOutput(
|
|
16
27
|
harness: HookHarness | undefined,
|
|
17
|
-
hookEventName: 'SessionStart' | 'SubagentStart'
|
|
28
|
+
hookEventName: 'SessionStart' | 'SubagentStart',
|
|
29
|
+
cwd: string = process.cwd(),
|
|
30
|
+
env: NodeJS.ProcessEnv = process.env,
|
|
18
31
|
): string | undefined {
|
|
32
|
+
const context = hookEventName === 'SubagentStart'
|
|
33
|
+
? WORKER_ACTIVATION_CONTEXT
|
|
34
|
+
: rootActivationContext(cwd, env)
|
|
35
|
+
if (harness === 'opencode') return context
|
|
19
36
|
if (harness === 'cursor') {
|
|
20
|
-
return JSON.stringify({ additional_context:
|
|
37
|
+
return JSON.stringify({ additional_context: context })
|
|
21
38
|
}
|
|
22
39
|
if (harness === 'claude-code' || harness === 'codex') {
|
|
23
40
|
return JSON.stringify({
|
|
24
41
|
hookSpecificOutput: {
|
|
25
42
|
hookEventName,
|
|
26
|
-
additionalContext:
|
|
43
|
+
additionalContext: context,
|
|
27
44
|
},
|
|
28
45
|
})
|
|
29
46
|
}
|
|
@@ -35,11 +52,11 @@ export function sessionActivationOutput(
|
|
|
35
52
|
* to the model. Its native post-Stop follow-up is the narrow fallback and is
|
|
36
53
|
* claimed once per conversation by the hook-state layer.
|
|
37
54
|
*/
|
|
38
|
-
export function cursorStopActivationOutput(
|
|
55
|
+
export function cursorStopActivationOutput(
|
|
56
|
+
cwd: string = process.cwd(),
|
|
57
|
+
env: NodeJS.ProcessEnv = process.env,
|
|
58
|
+
): string {
|
|
39
59
|
return JSON.stringify({
|
|
40
|
-
followup_message:
|
|
41
|
-
'Before beginning task work,',
|
|
42
|
-
'Before finalizing the Agent Event from the turn that just ended,',
|
|
43
|
-
),
|
|
60
|
+
followup_message: rootActivationContext(cwd, env),
|
|
44
61
|
})
|
|
45
62
|
}
|