@raidiant/notifai 9.2.1 → 9.3.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 +33 -0
- package/README.md +8 -5
- package/dist/claude-wake.js +11 -0
- package/dist/claude-wake.js.map +1 -1
- package/dist/codex-wake.js +9 -0
- package/dist/codex-wake.js.map +1 -1
- package/dist/commands-core.d.ts +0 -3
- package/dist/commands-core.js.map +1 -1
- package/dist/commands-doctor.js +41 -2
- package/dist/commands-doctor.js.map +1 -1
- package/dist/commands-harness-context.d.ts +2 -2
- package/dist/commands-harness-context.js +1 -1
- package/dist/commands-hook-shape.d.ts +4 -3
- package/dist/commands-hook-shape.js +19 -10
- package/dist/commands-hook-shape.js.map +1 -1
- package/dist/commands-hooks.js +66 -15
- package/dist/commands-hooks.js.map +1 -1
- package/dist/commands-init.js +12 -1
- package/dist/commands-init.js.map +1 -1
- package/dist/commands-project.d.ts +4 -0
- package/dist/commands-project.js +41 -0
- package/dist/commands-project.js.map +1 -0
- package/dist/commands-send-support.js +1 -22
- package/dist/commands-send-support.js.map +1 -1
- package/dist/commands-send.js +11 -5
- package/dist/commands-send.js.map +1 -1
- package/dist/commands-skill.js +58 -0
- package/dist/commands-skill.js.map +1 -1
- package/dist/commands.d.ts +1 -0
- package/dist/commands.js +1 -0
- package/dist/commands.js.map +1 -1
- package/dist/config-schema.js +2 -2
- package/dist/config-schema.js.map +1 -1
- package/dist/config.d.ts +8 -8
- package/dist/config.js +6 -9
- package/dist/config.js.map +1 -1
- package/dist/guidance-content.d.ts +3 -0
- package/dist/guidance-content.js +20 -0
- package/dist/guidance-content.js.map +1 -1
- package/dist/guidance-render.js +3 -3
- package/dist/guidance-render.js.map +1 -1
- package/dist/harnesses.js +2 -2
- package/dist/harnesses.js.map +1 -1
- package/dist/hooks.d.ts +26 -50
- package/dist/hooks.js +391 -150
- package/dist/hooks.js.map +1 -1
- package/dist/install-hooks.d.ts +7 -28
- package/dist/install-hooks.js +18 -37
- package/dist/install-hooks.js.map +1 -1
- package/dist/invocation-context.d.ts +1 -1
- package/dist/invocation-context.js +2 -2
- package/dist/invocation-context.js.map +1 -1
- package/dist/program.d.ts +4 -1
- package/dist/program.js +33 -15
- package/dist/program.js.map +1 -1
- package/dist/project-enablement.d.ts +15 -0
- package/dist/project-enablement.js +87 -0
- package/dist/project-enablement.js.map +1 -0
- package/dist/question-timing.d.ts +46 -0
- package/dist/question-timing.js +48 -0
- package/dist/question-timing.js.map +1 -0
- package/dist/readiness.d.ts +7 -0
- package/dist/readiness.js.map +1 -1
- package/dist/send.d.ts +4 -2
- package/dist/send.js +6 -1
- package/dist/send.js.map +1 -1
- package/dist/session-activation.d.ts +1 -1
- package/dist/session-activation.js +3 -4
- package/dist/session-activation.js.map +1 -1
- package/package.json +2 -2
- package/src/claude-wake.test.ts +1 -0
- package/src/claude-wake.ts +10 -0
- package/src/codex-wake.test.ts +1 -0
- package/src/codex-wake.ts +9 -0
- package/src/commands-core.ts +0 -3
- package/src/commands-doctor.ts +48 -4
- package/src/commands-harness-context.ts +2 -2
- package/src/commands-hook-shape.ts +26 -10
- package/src/commands-hooks.ts +62 -16
- package/src/commands-init.ts +11 -1
- package/src/commands-project.ts +47 -0
- package/src/commands-send-support.ts +1 -27
- package/src/commands-send.ts +9 -4
- package/src/commands-skill.test.ts +44 -0
- package/src/commands-skill.ts +57 -0
- package/src/commands.test.ts +47 -17
- package/src/commands.ts +5 -0
- package/src/config-schema.ts +2 -2
- package/src/config.test.ts +2 -3
- package/src/config.ts +12 -15
- package/src/guidance-content.ts +22 -0
- package/src/guidance-render.ts +3 -3
- package/src/harnesses.ts +2 -2
- package/src/hook-adapter.test.ts +27 -8
- package/src/hooks.test.ts +808 -80
- package/src/hooks.ts +525 -181
- package/src/install-hooks.test.ts +8 -12
- package/src/install-hooks.ts +21 -37
- package/src/invocation-context.test.ts +1 -1
- package/src/invocation-context.ts +2 -2
- package/src/program.test.ts +18 -0
- package/src/program.ts +36 -14
- package/src/project-enablement.test.ts +55 -0
- package/src/project-enablement.ts +99 -0
- package/src/question-timing.ts +56 -0
- package/src/readiness.ts +8 -1
- package/src/send.ts +10 -3
- package/src/session-activation.ts +3 -4
- package/src/skill.test.ts +2 -2
- package/dist/orca-session-title.d.ts +0 -10
- package/dist/orca-session-title.js +0 -99
- package/dist/orca-session-title.js.map +0 -1
- package/src/orca-session-title.test.ts +0 -95
- package/src/orca-session-title.ts +0 -105
|
@@ -13,6 +13,7 @@ import os from 'node:os'
|
|
|
13
13
|
import path from 'node:path'
|
|
14
14
|
import { parse as parseToml, stringify as stringifyToml } from 'smol-toml'
|
|
15
15
|
import { describe, expect, it } from 'vitest'
|
|
16
|
+
import { REPLY_MAX_WINDOW_SECONDS } from '@raidiant/notifai-protocol'
|
|
16
17
|
import { parseChoices } from './send.js'
|
|
17
18
|
import {
|
|
18
19
|
OPENCODE_PLUGIN_MARKER,
|
|
@@ -27,7 +28,7 @@ import {
|
|
|
27
28
|
} from './session-activation.js'
|
|
28
29
|
import { hookAdapterPath, installHookAdapter } from './hook-adapter.js'
|
|
29
30
|
import {
|
|
30
|
-
|
|
31
|
+
QUESTION_STOP_TIMEOUT_SECONDS,
|
|
31
32
|
HARNESSES,
|
|
32
33
|
applyPlan,
|
|
33
34
|
detectHarness,
|
|
@@ -152,18 +153,13 @@ describe('hook config', () => {
|
|
|
152
153
|
).toBe(true)
|
|
153
154
|
})
|
|
154
155
|
|
|
155
|
-
it('
|
|
156
|
+
it('gives Codex a Stop timeout beyond the longest answer window', () => {
|
|
156
157
|
const codex = buildHookConfig({ adapterPath: ADAPTER, harness: 'codex' })
|
|
157
158
|
|
|
158
159
|
expect(codex['UserPromptSubmit']?.[0]?.hooks[0]?.timeout).toBe(15)
|
|
159
160
|
expect(codex['SessionEnd']?.[0]?.hooks[0]?.timeout).toBe(3)
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
// invalidate the user's approval and the handler would silently stop.
|
|
163
|
-
expect(codex['Stop']?.[0]?.hooks[0]).toEqual({
|
|
164
|
-
type: 'command',
|
|
165
|
-
command: hookCommand(ADAPTER, 'stop', 'codex'),
|
|
166
|
-
})
|
|
161
|
+
expect(codex['Stop']?.[0]?.hooks[0]?.timeout).toBe(QUESTION_STOP_TIMEOUT_SECONDS)
|
|
162
|
+
expect(QUESTION_STOP_TIMEOUT_SECONDS).toBeGreaterThan(REPLY_MAX_WINDOW_SECONDS)
|
|
167
163
|
})
|
|
168
164
|
|
|
169
165
|
it('gives Claude Code an asynchronous Stop with an explicit waiter budget on POSIX', () => {
|
|
@@ -176,7 +172,7 @@ describe('hook config', () => {
|
|
|
176
172
|
expect(claude['Stop']?.[0]?.hooks[0]).toEqual({
|
|
177
173
|
type: 'command',
|
|
178
174
|
command: hookCommand(ADAPTER, 'stop', 'claude-code'),
|
|
179
|
-
timeout:
|
|
175
|
+
timeout: QUESTION_STOP_TIMEOUT_SECONDS,
|
|
180
176
|
async: true,
|
|
181
177
|
})
|
|
182
178
|
expect(claude['UserPromptSubmit']?.[0]?.hooks[0]?.async).toBeUndefined()
|
|
@@ -195,7 +191,7 @@ describe('hook config', () => {
|
|
|
195
191
|
expect(claude['Stop']?.[0]?.hooks[0]).toEqual({
|
|
196
192
|
type: 'command',
|
|
197
193
|
command: hookCommand(options.adapterPath, 'stop', 'claude-code', options),
|
|
198
|
-
timeout:
|
|
194
|
+
timeout: QUESTION_STOP_TIMEOUT_SECONDS,
|
|
199
195
|
})
|
|
200
196
|
})
|
|
201
197
|
|
|
@@ -213,7 +209,7 @@ describe('hook config', () => {
|
|
|
213
209
|
expect(groups.flatMap((group) => group.hooks)).toHaveLength(1)
|
|
214
210
|
}
|
|
215
211
|
expect(migrated.document.hooks?.['Stop']?.[0]?.hooks[0]).toMatchObject({
|
|
216
|
-
timeout:
|
|
212
|
+
timeout: QUESTION_STOP_TIMEOUT_SECONDS,
|
|
217
213
|
async: true,
|
|
218
214
|
})
|
|
219
215
|
expect(migrated.replaced.sort()).toEqual([
|
package/src/install-hooks.ts
CHANGED
|
@@ -22,6 +22,10 @@ import {
|
|
|
22
22
|
import { opencodePluginPath, opencodePluginTarget } from './opencode-plugin.js'
|
|
23
23
|
import { HARNESSES, type Harness } from './harnesses.js'
|
|
24
24
|
import { accountHome } from './platform.js'
|
|
25
|
+
import {
|
|
26
|
+
NON_ROUTING_STOP_TIMEOUT_SECONDS,
|
|
27
|
+
QUESTION_STOP_TIMEOUT_SECONDS,
|
|
28
|
+
} from './question-timing.js'
|
|
25
29
|
|
|
26
30
|
export { HARNESSES, type Harness } from './harnesses.js'
|
|
27
31
|
|
|
@@ -177,40 +181,18 @@ export interface BuildOptions {
|
|
|
177
181
|
}
|
|
178
182
|
|
|
179
183
|
/**
|
|
180
|
-
*
|
|
181
|
-
*
|
|
182
|
-
* process rather than the user's turn.
|
|
183
|
-
*
|
|
184
|
-
* It is always declared, and declared far above the longest wait a waiter can
|
|
185
|
-
* take. Claude's default is 600 s and the kill is **silent** — the backgrounded
|
|
186
|
-
* process simply vanishes, no error is reported anywhere, and the answer the
|
|
187
|
-
* user already gave is never delivered. Sitting anywhere near that boundary
|
|
188
|
-
* turns a slow reply into a lost one, so this does not: an explicit value
|
|
189
|
-
* raises the ceiling (verified against a 660 s wait under an hour-long
|
|
190
|
-
* declaration), and an hour is several times the waiter's own ceiling.
|
|
184
|
+
* Harnesses without asynchronous Question Routing use this short Stop budget.
|
|
185
|
+
* Claude Code and Codex use `QUESTION_STOP_TIMEOUT_SECONDS` instead.
|
|
191
186
|
*/
|
|
192
|
-
export const
|
|
193
|
-
|
|
194
|
-
/**
|
|
195
|
-
* The declared process budget for a host that holds its turn while the waiter
|
|
196
|
-
* runs: the waiter's own ceiling plus a minute of host headroom, as one plain
|
|
197
|
-
* number rather than a budget carved into reserves.
|
|
198
|
-
*
|
|
199
|
-
* Codex is deliberately absent from this. It owns its Stop timeout today, and
|
|
200
|
-
* declaring one would change the serialized definition Codex hashes into
|
|
201
|
-
* `trusted_hash` — which silently stops every Notifai handler from running
|
|
202
|
-
* until the user re-approves them in `/hooks`.
|
|
203
|
-
*/
|
|
204
|
-
export const BLOCKING_STOP_TIMEOUT_SECONDS = 540
|
|
187
|
+
export const NON_ROUTING_BLOCKING_STOP_TIMEOUT_SECONDS = NON_ROUTING_STOP_TIMEOUT_SECONDS
|
|
188
|
+
export { QUESTION_STOP_TIMEOUT_SECONDS } from './question-timing.js'
|
|
205
189
|
|
|
206
190
|
/**
|
|
207
191
|
* Whether this harness's Stop handler runs detached from the turn.
|
|
208
192
|
*
|
|
209
|
-
*
|
|
210
|
-
*
|
|
211
|
-
*
|
|
212
|
-
* waiter outlives a turn nobody left open. They were three separate spellings
|
|
213
|
-
* of the same condition with nothing tying them together.
|
|
193
|
+
* The installer and doctor share this predicate so they agree whether Claude
|
|
194
|
+
* runs out of band. Owner lifetime is route-neutral and lives in
|
|
195
|
+
* `question-timing.ts`.
|
|
214
196
|
*/
|
|
215
197
|
export function stopHandlerIsDetached(
|
|
216
198
|
harness: Harness | undefined,
|
|
@@ -224,10 +206,10 @@ export function stopHandlerIsDetached(
|
|
|
224
206
|
*
|
|
225
207
|
* Claude Code takes the answer over its own inbox socket, so its Stop hook is
|
|
226
208
|
* `async: true`: it returns immediately, the terminal is never held, and the
|
|
227
|
-
* waiter finishes out of band.
|
|
228
|
-
* continuation to
|
|
229
|
-
*
|
|
230
|
-
*
|
|
209
|
+
* waiter finishes out of band. Codex and Claude on Windows block and print a
|
|
210
|
+
* continuation to stdout. Every Question Routing owner declares the same
|
|
211
|
+
* complete-window timeout; changing Codex's definition requires one explicit
|
|
212
|
+
* trust approval.
|
|
231
213
|
*/
|
|
232
214
|
function stopHandler(
|
|
233
215
|
adapterPath: string,
|
|
@@ -236,10 +218,12 @@ function stopHandler(
|
|
|
236
218
|
): HookHandler {
|
|
237
219
|
const command = hookCommand(adapterPath, 'stop', harness, options)
|
|
238
220
|
if (stopHandlerIsDetached(harness, options.platform)) {
|
|
239
|
-
return { type: 'command', command, timeout:
|
|
221
|
+
return { type: 'command', command, timeout: QUESTION_STOP_TIMEOUT_SECONDS, async: true }
|
|
222
|
+
}
|
|
223
|
+
if (harness === 'codex' || harness === 'claude-code') {
|
|
224
|
+
return { type: 'command', command, timeout: QUESTION_STOP_TIMEOUT_SECONDS }
|
|
240
225
|
}
|
|
241
|
-
|
|
242
|
-
return { type: 'command', command, timeout: BLOCKING_STOP_TIMEOUT_SECONDS }
|
|
226
|
+
return { type: 'command', command, timeout: NON_ROUTING_BLOCKING_STOP_TIMEOUT_SECONDS }
|
|
243
227
|
}
|
|
244
228
|
|
|
245
229
|
function commandOptionsFrom(options: BuildOptions): HookCommandOptions {
|
|
@@ -350,7 +334,7 @@ export function buildCursorHookConfig(options: BuildOptions): CursorHookConfig {
|
|
|
350
334
|
},
|
|
351
335
|
{
|
|
352
336
|
command: hookCommand(options.adapterPath, 'stop', 'cursor', commandOptions),
|
|
353
|
-
timeout:
|
|
337
|
+
timeout: NON_ROUTING_BLOCKING_STOP_TIMEOUT_SECONDS,
|
|
354
338
|
// A continuation may register a real follow-up question. Match the
|
|
355
339
|
// session-state cap so those chains are useful but never unbounded.
|
|
356
340
|
loop_limit: 3,
|
|
@@ -346,7 +346,7 @@ describe('buildSourceContext', () => {
|
|
|
346
346
|
}),
|
|
347
347
|
).toEqual({
|
|
348
348
|
ok: false,
|
|
349
|
-
error: '--session-label (or NOTIFAI_SESSION_LABEL) needs an exact
|
|
349
|
+
error: '--session-label (or NOTIFAI_SESSION_LABEL) needs an exact Agent Session id.',
|
|
350
350
|
})
|
|
351
351
|
})
|
|
352
352
|
})
|
|
@@ -150,7 +150,7 @@ export type SourceContextBuild =
|
|
|
150
150
|
| { ok: true; source?: SourceContextT }
|
|
151
151
|
| { ok: false; error: string }
|
|
152
152
|
|
|
153
|
-
/** Resolve per-field
|
|
153
|
+
/** Resolve per-field Source Context precedence without fabricating Agent Session identity. */
|
|
154
154
|
export function buildSourceContext(input: SourceContextInput): SourceContextBuild {
|
|
155
155
|
const sessionId =
|
|
156
156
|
input.sessionId ?? input.env['NOTIFAI_SESSION_ID'] ?? input.activeHarness?.sessionId
|
|
@@ -158,7 +158,7 @@ export function buildSourceContext(input: SourceContextInput): SourceContextBuil
|
|
|
158
158
|
if (explicitLabel !== undefined && (sessionId === undefined || sessionId === '')) {
|
|
159
159
|
return {
|
|
160
160
|
ok: false,
|
|
161
|
-
error: '--session-label (or NOTIFAI_SESSION_LABEL) needs an exact
|
|
161
|
+
error: '--session-label (or NOTIFAI_SESSION_LABEL) needs an exact Agent Session id.',
|
|
162
162
|
}
|
|
163
163
|
}
|
|
164
164
|
if (sessionId === '') {
|
package/src/program.test.ts
CHANGED
|
@@ -98,6 +98,24 @@ describe('program argv parsing', () => {
|
|
|
98
98
|
})
|
|
99
99
|
})
|
|
100
100
|
|
|
101
|
+
it('preserves deliberate Projectless send intent at the argv boundary', async () => {
|
|
102
|
+
let seen: Record<string, unknown> | undefined
|
|
103
|
+
await parse(
|
|
104
|
+
['send', '--kind', 'done', '--title', 'T', '--body', 'B', '--projectless'],
|
|
105
|
+
{ send: (async (_deps, flags) => ((seen = flags as Record<string, unknown>), 0)) as ProgramRunners['send'] },
|
|
106
|
+
)
|
|
107
|
+
expect(seen).toMatchObject({ projectless: true })
|
|
108
|
+
})
|
|
109
|
+
|
|
110
|
+
it('dispatches User-owned Project enablement commands', async () => {
|
|
111
|
+
let enabled = 0
|
|
112
|
+
const result = await parse(['project', 'enable'], {
|
|
113
|
+
projectEnable: ((_deps) => (enabled += 1, 0)) as ProgramRunners['projectEnable'],
|
|
114
|
+
})
|
|
115
|
+
expect(result.exitCode).toBe(0)
|
|
116
|
+
expect(enabled).toBe(1)
|
|
117
|
+
})
|
|
118
|
+
|
|
101
119
|
it('drops empty collector defaults so "not passed" stays absent', async () => {
|
|
102
120
|
let seen: Record<string, unknown> | undefined
|
|
103
121
|
await parse(
|
package/src/program.ts
CHANGED
|
@@ -23,6 +23,9 @@ import {
|
|
|
23
23
|
initCommand,
|
|
24
24
|
loginCommand,
|
|
25
25
|
logoutCommand,
|
|
26
|
+
projectDisableCommand,
|
|
27
|
+
projectEnableCommand,
|
|
28
|
+
projectStatusCommand,
|
|
26
29
|
logsCommand,
|
|
27
30
|
repliesCommand,
|
|
28
31
|
reportAskFailure,
|
|
@@ -81,6 +84,9 @@ const defaultRunners = {
|
|
|
81
84
|
accessStatus: accessStatusCommand,
|
|
82
85
|
devices: devicesCommand,
|
|
83
86
|
capabilities: capabilitiesCommand,
|
|
87
|
+
projectEnable: projectEnableCommand,
|
|
88
|
+
projectDisable: projectDisableCommand,
|
|
89
|
+
projectStatus: projectStatusCommand,
|
|
84
90
|
send: sendCommand,
|
|
85
91
|
replies: repliesCommand,
|
|
86
92
|
acknowledge: acknowledgeCommand,
|
|
@@ -216,7 +222,7 @@ export function buildProgram(deps: CommandDeps, options: BuildProgramOptions = {
|
|
|
216
222
|
'Set up Notifai here, idempotently: sign-in, project id, hooks, device readiness, live receipt proof. ' +
|
|
217
223
|
'Interactive at a human terminal; never prompts otherwise (agents: pass flags)',
|
|
218
224
|
)
|
|
219
|
-
.option('--project-id <id>', '
|
|
225
|
+
.option('--project-id <id>', 'Project identifier slug (default: derived from the directory name)')
|
|
220
226
|
.option('--json', 'machine-readable final readiness; never prompts')
|
|
221
227
|
.option('--skills', 'install/update the agent skill from its pinned public release')
|
|
222
228
|
.option('--no-skills', 'suppress the optional agent-skill status line')
|
|
@@ -250,6 +256,21 @@ export function buildProgram(deps: CommandDeps, options: BuildProgramOptions = {
|
|
|
250
256
|
exit(await runners.doctor(deps, opts))
|
|
251
257
|
})
|
|
252
258
|
|
|
259
|
+
const project = program
|
|
260
|
+
.command('project')
|
|
261
|
+
.helpGroup(GROUP.daily)
|
|
262
|
+
.summary('Control Notifai for this Project')
|
|
263
|
+
.description('Inspect or change this Project\'s User-owned lifecycle enablement')
|
|
264
|
+
project.command('enable').description('Enable lifecycle guidance for this Project').action(() => {
|
|
265
|
+
exit(runners.projectEnable(deps))
|
|
266
|
+
})
|
|
267
|
+
project.command('disable').description('Disable lifecycle guidance for this Project').action(() => {
|
|
268
|
+
exit(runners.projectDisable(deps))
|
|
269
|
+
})
|
|
270
|
+
project.command('status').description('Show lifecycle enablement for this Project').option('--json').action((opts: { json?: boolean }) => {
|
|
271
|
+
exit(runners.projectStatus(deps, opts.json === true))
|
|
272
|
+
})
|
|
273
|
+
|
|
253
274
|
program
|
|
254
275
|
.command('login')
|
|
255
276
|
.helpGroup(GROUP.start)
|
|
@@ -339,13 +360,14 @@ export function buildProgram(deps: CommandDeps, options: BuildProgramOptions = {
|
|
|
339
360
|
)
|
|
340
361
|
.optionsGroup(SEND_GROUP.routing)
|
|
341
362
|
.option('--kind <kind>', 'what this is (required): update | done | failed | blocked (no User reply would resume it) — question is set by --reply')
|
|
342
|
-
.option('--project <id>', '
|
|
363
|
+
.option('--project <id>', 'Project identifier override (otherwise configured or inferred)')
|
|
364
|
+
.option('--projectless', 'deliberately omit Project identity for this notification')
|
|
343
365
|
.option('--device <id>', 'target a device id (repeatable)', (v: string, all: string[] = []) => [...all, v])
|
|
344
366
|
.option('--all', 'target all routable devices (overrides configured devices)')
|
|
345
|
-
.option('--session-id <id>', 'low-level automation override for an opaque exact
|
|
367
|
+
.option('--session-id <id>', 'low-level automation override for an opaque exact Agent Session (env: NOTIFAI_SESSION_ID)')
|
|
346
368
|
.option(
|
|
347
369
|
'--session-label <text>',
|
|
348
|
-
'human
|
|
370
|
+
'human Agent Session name; safe to repeat, with the first accepted name frozen (env: NOTIFAI_SESSION_LABEL)',
|
|
349
371
|
)
|
|
350
372
|
.optionsGroup(SEND_GROUP.presentation)
|
|
351
373
|
.option('--collapse-key <key>', 'replace earlier notifications with the same key')
|
|
@@ -421,7 +443,7 @@ export function buildProgram(deps: CommandDeps, options: BuildProgramOptions = {
|
|
|
421
443
|
.helpGroup(GROUP.agent)
|
|
422
444
|
.summary('Retrieve replies to a question')
|
|
423
445
|
.description('Retrieve replies for a notification request')
|
|
424
|
-
.option('--pending', 'use the pushed question pending for this
|
|
446
|
+
.option('--pending', 'use the pushed question pending for this Project and Agent Session')
|
|
425
447
|
.option('--wait <seconds>', 'how long to wait for a reply', (v: string) => Number(v))
|
|
426
448
|
.option('--after <seq>', 'return replies after this sequence number', (v: string) => Number(v))
|
|
427
449
|
.option('--json', 'machine-readable output')
|
|
@@ -481,10 +503,10 @@ export function buildProgram(deps: CommandDeps, options: BuildProgramOptions = {
|
|
|
481
503
|
'alt text paired with --image occurrences by position (repeatable)',
|
|
482
504
|
(v: string, all: string[] = []) => [...all, v],
|
|
483
505
|
)
|
|
484
|
-
.option('--project <id>', '
|
|
506
|
+
.option('--project <id>', 'Project identifier override (otherwise configured or inferred)')
|
|
485
507
|
.option(
|
|
486
508
|
'--session-label <text>',
|
|
487
|
-
'human
|
|
509
|
+
'human Agent Session name; safe to repeat, with the first accepted name frozen (env: NOTIFAI_SESSION_LABEL)',
|
|
488
510
|
)
|
|
489
511
|
.action(async (question: string | undefined, opts: {
|
|
490
512
|
choice?: string[]
|
|
@@ -551,9 +573,9 @@ export function buildProgram(deps: CommandDeps, options: BuildProgramOptions = {
|
|
|
551
573
|
.helpGroup(GROUP.agent)
|
|
552
574
|
.summary('Retire a question so late answers are rejected')
|
|
553
575
|
.description(
|
|
554
|
-
'Retire a question so late answers are rejected rather than lost. Pass --pending to withdraw this
|
|
576
|
+
'Retire a question so late answers are rejected rather than lost. Pass --pending to withdraw this Agent Session\'s outstanding registrations, including ones the turn-end hook has not pushed yet.',
|
|
555
577
|
)
|
|
556
|
-
.option('--pending', 'retire this
|
|
578
|
+
.option('--pending', 'retire this Agent Session\'s outstanding questions, including ones not yet pushed')
|
|
557
579
|
.option('--json', 'machine-readable output')
|
|
558
580
|
.action(async (requestId: string | undefined, opts: { json?: boolean; pending?: boolean }) => {
|
|
559
581
|
exit(await runners.close(deps, requestId, opts))
|
|
@@ -615,9 +637,9 @@ export function buildProgram(deps: CommandDeps, options: BuildProgramOptions = {
|
|
|
615
637
|
.option('--event <name>', 'only this event; repeatable', (v: string, all: string[] = []) => [...all, v])
|
|
616
638
|
.option('--run <id>', 'everything one command invocation did')
|
|
617
639
|
.option('--request <id>', 'everything about one notification request')
|
|
618
|
-
.option('--session <id>', 'only this
|
|
619
|
-
.option('--project <id>', 'only this
|
|
620
|
-
.option('--all-projects', 'do not scope to the
|
|
640
|
+
.option('--session <id>', 'only this Agent Session')
|
|
641
|
+
.option('--project <id>', 'only this Project')
|
|
642
|
+
.option('--all-projects', 'do not scope to the Project in this directory')
|
|
621
643
|
.option('--grep <text>', 'only records containing this text')
|
|
622
644
|
.option('--json', 'one JSON record per line on stdout')
|
|
623
645
|
.option('--path', 'print the log file paths instead of the records')
|
|
@@ -656,7 +678,7 @@ export function buildProgram(deps: CommandDeps, options: BuildProgramOptions = {
|
|
|
656
678
|
)
|
|
657
679
|
.option('--project', 'write to the shared .notifai/config.toml instead')
|
|
658
680
|
.option('--local', 'write a personal project preference on this machine (not in the repo)')
|
|
659
|
-
.option('--session <id>', 'apply only to one
|
|
681
|
+
.option('--session <id>', 'apply only to one Agent Session')
|
|
660
682
|
.option('--yes', 'skip the confirmation gate')
|
|
661
683
|
.action(
|
|
662
684
|
async (
|
|
@@ -674,7 +696,7 @@ export function buildProgram(deps: CommandDeps, options: BuildProgramOptions = {
|
|
|
674
696
|
)
|
|
675
697
|
.option('--project', 'remove from the shared .notifai/config.toml instead')
|
|
676
698
|
.option('--local', 'remove a personal project preference stored on this machine')
|
|
677
|
-
.option('--session <id>', 'remove from one
|
|
699
|
+
.option('--session <id>', 'remove from one Agent Session')
|
|
678
700
|
.option('--yes', 'skip the confirmation gate')
|
|
679
701
|
.action(
|
|
680
702
|
async (
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { execFileSync } from 'node:child_process'
|
|
2
|
+
import { mkdirSync, mkdtempSync, writeFileSync } from 'node:fs'
|
|
3
|
+
import os from 'node:os'
|
|
4
|
+
import path from 'node:path'
|
|
5
|
+
import { describe, expect, it } from 'vitest'
|
|
6
|
+
import {
|
|
7
|
+
disableProject,
|
|
8
|
+
enableProject,
|
|
9
|
+
projectBinding,
|
|
10
|
+
projectEnabled,
|
|
11
|
+
} from './project-enablement.js'
|
|
12
|
+
|
|
13
|
+
function git(cwd: string, ...args: string[]): void {
|
|
14
|
+
execFileSync('git', ['-C', cwd, ...args], { stdio: 'ignore' })
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
describe('Project enablement', () => {
|
|
18
|
+
it('is disabled by default and changes only through its User-owned marker', () => {
|
|
19
|
+
const root = mkdtempSync(path.join(os.tmpdir(), 'notifai-enablement-'))
|
|
20
|
+
const env = { XDG_CONFIG_HOME: path.join(root, 'config') }
|
|
21
|
+
const binding = projectBinding(root, env)
|
|
22
|
+
expect(binding).not.toBeNull()
|
|
23
|
+
expect(projectEnabled(binding)).toBe(false)
|
|
24
|
+
|
|
25
|
+
enableProject(binding!, new Date('2026-08-27T00:00:00.000Z'))
|
|
26
|
+
expect(projectEnabled(binding)).toBe(true)
|
|
27
|
+
disableProject(binding!)
|
|
28
|
+
expect(projectEnabled(binding)).toBe(false)
|
|
29
|
+
})
|
|
30
|
+
|
|
31
|
+
it('shares one decision across linked worktrees but not unrelated checkouts with the same name', () => {
|
|
32
|
+
const root = mkdtempSync(path.join(os.tmpdir(), 'notifai-enablement-git-'))
|
|
33
|
+
const repo = path.join(root, 'same-name')
|
|
34
|
+
const linked = path.join(root, 'linked')
|
|
35
|
+
const unrelatedParent = path.join(root, 'other')
|
|
36
|
+
const unrelated = path.join(unrelatedParent, 'same-name')
|
|
37
|
+
mkdirSync(repo, { recursive: true })
|
|
38
|
+
mkdirSync(unrelated, { recursive: true })
|
|
39
|
+
for (const cwd of [repo, unrelated]) {
|
|
40
|
+
git(cwd, 'init')
|
|
41
|
+
git(cwd, 'config', 'user.email', 'test@example.invalid')
|
|
42
|
+
git(cwd, 'config', 'user.name', 'Test')
|
|
43
|
+
writeFileSync(path.join(cwd, 'file'), 'x')
|
|
44
|
+
git(cwd, 'add', 'file')
|
|
45
|
+
git(cwd, 'commit', '-m', 'fixture')
|
|
46
|
+
}
|
|
47
|
+
git(repo, 'worktree', 'add', linked, '-b', 'linked')
|
|
48
|
+
const env = { XDG_CONFIG_HOME: path.join(root, 'config') }
|
|
49
|
+
const mainBinding = projectBinding(repo, env)
|
|
50
|
+
const linkedBinding = projectBinding(linked, env)
|
|
51
|
+
const unrelatedBinding = projectBinding(unrelated, env)
|
|
52
|
+
expect(linkedBinding?.markerPath).toBe(mainBinding?.markerPath)
|
|
53
|
+
expect(unrelatedBinding?.markerPath).not.toBe(mainBinding?.markerPath)
|
|
54
|
+
})
|
|
55
|
+
})
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import { execFileSync } from 'node:child_process'
|
|
2
|
+
import { createHash } from 'node:crypto'
|
|
3
|
+
import { existsSync, readFileSync, realpathSync, unlinkSync } from 'node:fs'
|
|
4
|
+
import path from 'node:path'
|
|
5
|
+
import { atomicWriteFileSync } from './atomic-file.js'
|
|
6
|
+
import { globalConfigDir } from './config.js'
|
|
7
|
+
import { inferInvocationContext } from './invocation-context.js'
|
|
8
|
+
|
|
9
|
+
interface ProjectEnablementMarker {
|
|
10
|
+
version: 1
|
|
11
|
+
project: string
|
|
12
|
+
enabled: true
|
|
13
|
+
updated_at: string
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface ProjectBinding {
|
|
17
|
+
project: string
|
|
18
|
+
markerPath: string
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function gitCommonDirectory(cwd: string): string | null {
|
|
22
|
+
try {
|
|
23
|
+
const raw = execFileSync('git', ['-C', cwd, 'rev-parse', '--git-common-dir'], {
|
|
24
|
+
encoding: 'utf8',
|
|
25
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
26
|
+
timeout: 2_000,
|
|
27
|
+
windowsHide: true,
|
|
28
|
+
}).trim()
|
|
29
|
+
if (raw === '') return null
|
|
30
|
+
const resolved = path.isAbsolute(raw) ? raw : path.resolve(cwd, raw)
|
|
31
|
+
try {
|
|
32
|
+
return realpathSync(resolved)
|
|
33
|
+
} catch {
|
|
34
|
+
return path.resolve(resolved)
|
|
35
|
+
}
|
|
36
|
+
} catch {
|
|
37
|
+
return null
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Resolve the User-owned enablement record for this exact local Project.
|
|
43
|
+
*
|
|
44
|
+
* The opaque key binds the semantic Project id to Git's shared common
|
|
45
|
+
* directory. Linked worktrees therefore share one decision, while another
|
|
46
|
+
* checkout cannot borrow it merely by committing the same Project id.
|
|
47
|
+
*/
|
|
48
|
+
export function projectBinding(
|
|
49
|
+
cwd: string,
|
|
50
|
+
env: NodeJS.ProcessEnv = process.env,
|
|
51
|
+
explicitProject?: string | null,
|
|
52
|
+
): ProjectBinding | null {
|
|
53
|
+
const project = explicitProject ?? inferInvocationContext(cwd).project
|
|
54
|
+
if (project === null) return null
|
|
55
|
+
const repository = gitCommonDirectory(cwd) ?? path.resolve(cwd)
|
|
56
|
+
const key = createHash('sha256')
|
|
57
|
+
.update('notifai-project-enablement-v1\0')
|
|
58
|
+
.update(repository)
|
|
59
|
+
.update('\0')
|
|
60
|
+
.update(project)
|
|
61
|
+
.digest('hex')
|
|
62
|
+
return {
|
|
63
|
+
project,
|
|
64
|
+
markerPath: path.join(globalConfigDir(env), 'project-enablement', `${key}.json`),
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export function projectEnabled(binding: ProjectBinding | null): boolean {
|
|
69
|
+
if (binding === null || !existsSync(binding.markerPath)) return false
|
|
70
|
+
try {
|
|
71
|
+
const marker = JSON.parse(readFileSync(binding.markerPath, 'utf8')) as Partial<ProjectEnablementMarker>
|
|
72
|
+
return marker.version === 1 && marker.enabled === true && marker.project === binding.project
|
|
73
|
+
} catch {
|
|
74
|
+
return false
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export function enableProject(binding: ProjectBinding, now: Date = new Date()): void {
|
|
79
|
+
const marker: ProjectEnablementMarker = {
|
|
80
|
+
version: 1,
|
|
81
|
+
project: binding.project,
|
|
82
|
+
enabled: true,
|
|
83
|
+
updated_at: now.toISOString(),
|
|
84
|
+
}
|
|
85
|
+
atomicWriteFileSync(binding.markerPath, `${JSON.stringify(marker, null, 2)}\n`, {
|
|
86
|
+
mode: 0o600,
|
|
87
|
+
preserveMode: false,
|
|
88
|
+
requireCurrentUserOwner: true,
|
|
89
|
+
directoryMode: 0o700,
|
|
90
|
+
})
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export function disableProject(binding: ProjectBinding): void {
|
|
94
|
+
try {
|
|
95
|
+
unlinkSync(binding.markerPath)
|
|
96
|
+
} catch (err) {
|
|
97
|
+
if ((err as NodeJS.ErrnoException).code !== 'ENOENT') throw err
|
|
98
|
+
}
|
|
99
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { REPLY_MAX_WINDOW_SECONDS } from '@raidiant/notifai-protocol'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Time before reply-window admission that the Stop owner may spend reading its
|
|
5
|
+
* input, serving terminal-first grace, resolving devices, and submitting.
|
|
6
|
+
*
|
|
7
|
+
* Grace is capped at six minutes and every network operation is independently
|
|
8
|
+
* bounded. Twenty minutes lets setup spend up to ten while preserving the
|
|
9
|
+
* separate completion margin below; neither may borrow from the answer window
|
|
10
|
+
* the service actually commits.
|
|
11
|
+
*/
|
|
12
|
+
export const QUESTION_WAITER_STARTUP_HEADROOM_SECONDS = 20 * 60
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Minimum lifetime still available when a question is admitted.
|
|
16
|
+
*
|
|
17
|
+
* The server window starts during submission, not when the hook process
|
|
18
|
+
* started. Keeping ten minutes beyond the requested window covers the bounded
|
|
19
|
+
* submit, clock skew, close fence, and route handoff. If setup used more of the
|
|
20
|
+
* allowance, the frozen question remains local for a successor owner instead
|
|
21
|
+
* of publishing a window this process cannot observe completely.
|
|
22
|
+
*/
|
|
23
|
+
export const QUESTION_SUBMISSION_COMPLETION_HEADROOM_SECONDS = 10 * 60
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* The longest one Stop owner may live from process start.
|
|
27
|
+
*
|
|
28
|
+
* The service can accept an answer for up to 72 hours. Starting this budget
|
|
29
|
+
* before submission is deliberate: the startup allowance above ensures that
|
|
30
|
+
* even the maximum reply window still ends before the owner does.
|
|
31
|
+
*/
|
|
32
|
+
export const QUESTION_WAITER_CEILING_SECONDS =
|
|
33
|
+
REPLY_MAX_WINDOW_SECONDS + QUESTION_WAITER_STARTUP_HEADROOM_SECONDS
|
|
34
|
+
|
|
35
|
+
/** Time for final fencing, diagnostics, stdout handoff, and clean process exit. */
|
|
36
|
+
export const QUESTION_STOP_TEARDOWN_HEADROOM_SECONDS = 60
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Declared harness timeout for every Stop handler that owns Question Routing.
|
|
40
|
+
* It must outlive the runtime owner; otherwise the harness silently kills the
|
|
41
|
+
* only process capable of continuing the exact Agent Session.
|
|
42
|
+
*/
|
|
43
|
+
export const QUESTION_STOP_TIMEOUT_SECONDS =
|
|
44
|
+
QUESTION_WAITER_CEILING_SECONDS + QUESTION_STOP_TEARDOWN_HEADROOM_SECONDS
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Short Stop budget for harnesses that cannot own asynchronous Question
|
|
48
|
+
* Routing. They retire/refuse promptly and never wait through an answer window.
|
|
49
|
+
*/
|
|
50
|
+
export const NON_ROUTING_STOP_TIMEOUT_SECONDS = 540
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Pre-PID claim files came only from the former eight-minute owner. Retain that
|
|
54
|
+
* recovery bound instead of making a corrupt legacy file block for 72 hours.
|
|
55
|
+
*/
|
|
56
|
+
export const LEGACY_QUESTION_CLAIM_TTL_SECONDS = 480
|
package/src/readiness.ts
CHANGED
|
@@ -34,7 +34,14 @@ export type Remedy =
|
|
|
34
34
|
* the CLI's to launch but the human's to complete, so an agent must still
|
|
35
35
|
* treat it as out of reach.
|
|
36
36
|
*/
|
|
37
|
-
| {
|
|
37
|
+
| {
|
|
38
|
+
by: 'user-here'
|
|
39
|
+
summary: string
|
|
40
|
+
command: string
|
|
41
|
+
interactive?: boolean
|
|
42
|
+
/** Stable agent-facing description of an unavoidable harness-owned action. */
|
|
43
|
+
user_action?: { code: string; harness: string; action: string; message: string }
|
|
44
|
+
}
|
|
38
45
|
/**
|
|
39
46
|
* A human somewhere else entirely — phone in hand, app store, permission
|
|
40
47
|
* dialog. The class the old init could only print a sentence about, and the
|
package/src/send.ts
CHANGED
|
@@ -34,9 +34,11 @@ export interface SendFlags {
|
|
|
34
34
|
/** What this notification is; absent means update. */
|
|
35
35
|
kind?: string
|
|
36
36
|
project?: string
|
|
37
|
-
/**
|
|
37
|
+
/** Deliberately omit Project identity even when config or cwd could supply one. */
|
|
38
|
+
projectless?: boolean
|
|
39
|
+
/** Opaque exact Agent Session override. */
|
|
38
40
|
sessionId?: string
|
|
39
|
-
/** Human-readable
|
|
41
|
+
/** Human-readable Agent Session label override. */
|
|
40
42
|
sessionLabel?: string
|
|
41
43
|
device?: string[]
|
|
42
44
|
all?: boolean
|
|
@@ -287,7 +289,12 @@ export function buildDraft(
|
|
|
287
289
|
}
|
|
288
290
|
}
|
|
289
291
|
|
|
290
|
-
|
|
292
|
+
if (flags.projectless === true && flags.project !== undefined) {
|
|
293
|
+
return { ok: false, error: 'Choose either --project or --projectless, not both.' }
|
|
294
|
+
}
|
|
295
|
+
const project = flags.projectless === true
|
|
296
|
+
? undefined
|
|
297
|
+
: flags.project ?? config.project.value ?? invocation.inferredProject
|
|
291
298
|
|
|
292
299
|
// Ids are derived from labels so an agent writing a one-liner does not have
|
|
293
300
|
// to invent them, but they stay the stable thing it branches on afterwards.
|
|
@@ -3,12 +3,11 @@ import { Buffer } from 'node:buffer'
|
|
|
3
3
|
import { GUIDANCE_CONTEXT_MAX_BYTES, boundedEffectiveGuidance } from './guidance-render.js'
|
|
4
4
|
|
|
5
5
|
const ROOT_OWNERSHIP =
|
|
6
|
-
'Notifai is
|
|
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.'
|
|
6
|
+
'Notifai is enabled for this Project. The effective, provenance-marked guidance below governs Notification Requests. ' +
|
|
7
|
+
'Missing CLI readiness is handled through `notifai init --json`.'
|
|
9
8
|
|
|
10
9
|
export const WORKER_ACTIVATION_CONTEXT =
|
|
11
|
-
'Notifai worker context:
|
|
10
|
+
'Notifai worker context: an Agent Event is a meaningful occurrence in your work; a Notification Request is a deliberate User-visible message about one. Report Agent Events to the parent and do not send Notification Requests unless the parent explicitly delegated that ownership. If delegated, load the Notifai skill and run `notifai guidance` before composing one.'
|
|
12
11
|
|
|
13
12
|
export const MISSING_LIFECYCLE_GUIDANCE_CONTEXT =
|
|
14
13
|
'Notifai is active, but lifecycle guidance could not be loaded. Run `notifai guidance` once before deciding whether or how to send a Notification Request.'
|
package/src/skill.test.ts
CHANGED
|
@@ -229,8 +229,8 @@ describe('Notifai agent skill', () => {
|
|
|
229
229
|
const send = section('## Send')
|
|
230
230
|
expect(send).toContain('--session-label')
|
|
231
231
|
expect(send).toMatch(/2-6 words/i)
|
|
232
|
-
expect(send).toMatch(/when the current
|
|
233
|
-
expect(send).toMatch(/without an exact
|
|
232
|
+
expect(send).toMatch(/when the current environment exposes its exact\s+identifier/i)
|
|
233
|
+
expect(send).toMatch(/without an exact Agent Session[\s\S]{0,160}usage\s+error/i)
|
|
234
234
|
expect(send).toMatch(/omit `--session-label`/i)
|
|
235
235
|
expect(send).toMatch(/name the\s+environment supplies wins/i)
|
|
236
236
|
expect(send).toMatch(/freezes one semantic name/i)
|
|
@@ -1,10 +0,0 @@
|
|
|
1
|
-
export type OrcaSessionTitleLookup = (env: NodeJS.ProcessEnv) => string | undefined;
|
|
2
|
-
export type OrcaCommand = (executable: string, args: readonly string[]) => string | null;
|
|
3
|
-
/**
|
|
4
|
-
* Read the semantic display name assigned to this exact Orca worktree.
|
|
5
|
-
*
|
|
6
|
-
* The environment contributes only an opaque selector. Orca must return that
|
|
7
|
-
* exact id and its matching path before its display name becomes trusted; no
|
|
8
|
-
* path component or identifier is ever promoted into the User-visible label.
|
|
9
|
-
*/
|
|
10
|
-
export declare function readOrcaSessionTitle(env: NodeJS.ProcessEnv, command?: OrcaCommand): string | undefined;
|