@raidiant/notifai 4.0.0 → 6.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +73 -0
- package/README.md +7 -2
- package/dist/client.d.ts +8 -3
- package/dist/client.js +24 -3
- package/dist/client.js.map +1 -1
- package/dist/commands-auth.d.ts +14 -0
- package/dist/commands-auth.js +154 -0
- package/dist/commands-auth.js.map +1 -0
- package/dist/commands-config.d.ts +31 -0
- package/dist/commands-config.js +246 -0
- package/dist/commands-config.js.map +1 -0
- package/dist/commands-core.d.ts +102 -0
- package/dist/commands-core.js +135 -0
- package/dist/commands-core.js.map +1 -0
- package/dist/commands-devices.d.ts +31 -0
- package/dist/commands-devices.js +124 -0
- package/dist/commands-devices.js.map +1 -0
- package/dist/commands-doctor.d.ts +24 -0
- package/dist/commands-doctor.js +1011 -0
- package/dist/commands-doctor.js.map +1 -0
- package/dist/commands-harness-context.d.ts +61 -0
- package/dist/commands-harness-context.js +93 -0
- package/dist/commands-harness-context.js.map +1 -0
- package/dist/commands-hook-shape.d.ts +17 -0
- package/dist/commands-hook-shape.js +37 -0
- package/dist/commands-hook-shape.js.map +1 -0
- package/dist/commands-hooks.d.ts +115 -0
- package/dist/commands-hooks.js +1225 -0
- package/dist/commands-hooks.js.map +1 -0
- package/dist/commands-init.d.ts +35 -0
- package/dist/commands-init.js +580 -0
- package/dist/commands-init.js.map +1 -0
- package/dist/commands-io.d.ts +2 -0
- package/dist/commands-io.js +110 -0
- package/dist/commands-io.js.map +1 -0
- package/dist/commands-logs.d.ts +30 -0
- package/dist/commands-logs.js +201 -0
- package/dist/commands-logs.js.map +1 -0
- package/dist/commands-send-support.d.ts +44 -0
- package/dist/commands-send-support.js +180 -0
- package/dist/commands-send-support.js.map +1 -0
- package/dist/commands-send.d.ts +33 -0
- package/dist/commands-send.js +519 -0
- package/dist/commands-send.js.map +1 -0
- package/dist/commands-setup-proof.d.ts +15 -0
- package/dist/commands-setup-proof.js +53 -0
- package/dist/commands-setup-proof.js.map +1 -0
- package/dist/commands-skill.d.ts +10 -0
- package/dist/commands-skill.js +71 -0
- package/dist/commands-skill.js.map +1 -0
- package/dist/commands.d.ts +12 -378
- package/dist/commands.js +12 -4420
- package/dist/commands.js.map +1 -1
- package/dist/config-schema.js +15 -6
- package/dist/config-schema.js.map +1 -1
- package/dist/config.d.ts +4 -2
- package/dist/config.js +13 -0
- package/dist/config.js.map +1 -1
- package/dist/credentials.js +13 -3
- package/dist/credentials.js.map +1 -1
- package/dist/file-lock.d.ts +6 -1
- package/dist/file-lock.js +55 -9
- package/dist/file-lock.js.map +1 -1
- package/dist/harnesses.d.ts +1 -1
- package/dist/harnesses.js.map +1 -1
- package/dist/hooks.d.ts +74 -7
- package/dist/hooks.js +522 -166
- package/dist/hooks.js.map +1 -1
- package/dist/install-hooks.d.ts +91 -14
- package/dist/install-hooks.js +226 -34
- package/dist/install-hooks.js.map +1 -1
- package/dist/interactive.d.ts +1 -1
- package/dist/interactive.js +5 -5
- package/dist/interactive.js.map +1 -1
- package/dist/logging.d.ts +8 -0
- package/dist/logging.js +9 -0
- package/dist/logging.js.map +1 -1
- package/dist/main.js +45 -18
- package/dist/main.js.map +1 -1
- package/dist/opencode-plugin.d.ts +8 -0
- package/dist/opencode-plugin.js +24 -162
- package/dist/opencode-plugin.js.map +1 -1
- package/dist/readiness.d.ts +3 -1
- package/dist/readiness.js.map +1 -1
- package/dist/send.d.ts +6 -0
- package/dist/send.js +25 -14
- package/dist/send.js.map +1 -1
- package/dist/ui/help.d.ts +0 -1
- package/dist/ui/help.js +6 -3
- package/dist/ui/help.js.map +1 -1
- package/dist/ui/theme.d.ts +0 -15
- package/dist/ui/theme.js +2 -18
- package/dist/ui/theme.js.map +1 -1
- package/package.json +2 -2
- package/src/client.test.ts +90 -1
- package/src/client.ts +35 -2
- package/src/commands-auth.ts +174 -0
- package/src/commands-config.ts +307 -0
- package/src/commands-core.ts +240 -0
- package/src/commands-devices.ts +150 -0
- package/src/commands-doctor.ts +1209 -0
- package/src/commands-harness-context.ts +126 -0
- package/src/commands-hook-shape.ts +53 -0
- package/src/commands-hooks.ts +1586 -0
- package/src/commands-init.ts +721 -0
- package/src/commands-io.ts +108 -0
- package/src/commands-logs.ts +243 -0
- package/src/commands-send-support.ts +224 -0
- package/src/commands-send.ts +623 -0
- package/src/commands-setup-proof.ts +71 -0
- package/src/commands-skill.ts +84 -0
- package/src/commands.test.ts +677 -89
- package/src/commands.ts +58 -5472
- package/src/config-schema.test.ts +1 -1
- package/src/config-schema.ts +17 -6
- package/src/config.test.ts +50 -9
- package/src/config.ts +19 -1
- package/src/credentials.test.ts +25 -1
- package/src/credentials.ts +13 -3
- package/src/file-lock.test.ts +41 -0
- package/src/file-lock.ts +59 -9
- package/src/harnesses.test.ts +37 -29
- package/src/harnesses.ts +1 -4
- package/src/hooks.test.ts +703 -26
- package/src/hooks.ts +590 -182
- package/src/install-hooks.test.ts +232 -12
- package/src/install-hooks.ts +241 -37
- package/src/interactive.test.ts +19 -1
- package/src/interactive.ts +5 -5
- package/src/logging-process-worker.test.ts +8 -2
- package/src/logging.test.ts +48 -20
- package/src/logging.ts +12 -0
- package/src/main.ts +58 -24
- package/src/opencode-plugin.ts +24 -164
- package/src/question-caps.test.ts +49 -0
- package/src/readiness.ts +3 -1
- package/src/send.ts +32 -16
- package/src/skill.test.ts +199 -146
- package/src/ui/help.ts +8 -3
- package/src/ui/theme.ts +2 -16
package/src/interactive.test.ts
CHANGED
|
@@ -58,7 +58,25 @@ function isolatedEnv(cwd: string): NodeJS.ProcessEnv {
|
|
|
58
58
|
function makeClient(): ApiClient {
|
|
59
59
|
return {
|
|
60
60
|
health: async () => true,
|
|
61
|
-
capabilities: async () => ({ schema_version: 1, platform
|
|
61
|
+
capabilities: async (platform = 'ios') => ({ schema_version: 1, platform }),
|
|
62
|
+
compatibility: async () => ({
|
|
63
|
+
cli: {
|
|
64
|
+
state: 'current',
|
|
65
|
+
reason: 'current',
|
|
66
|
+
affected_operation: null,
|
|
67
|
+
recovery_action: null,
|
|
68
|
+
current_version: '5.0.0',
|
|
69
|
+
current_build: null,
|
|
70
|
+
recommended_version: '5.0.0',
|
|
71
|
+
recommended_build: null,
|
|
72
|
+
minimum_version: null,
|
|
73
|
+
minimum_build: null,
|
|
74
|
+
deprecation: null,
|
|
75
|
+
sunset: null,
|
|
76
|
+
},
|
|
77
|
+
platforms: [],
|
|
78
|
+
server_capabilities: ['answer', 'agent_acknowledgement'],
|
|
79
|
+
}),
|
|
62
80
|
listDevices: async () => ({ devices: [] }),
|
|
63
81
|
accessStatus: async () => ({ email: 'rafael@example.test' }),
|
|
64
82
|
} as unknown as ApiClient
|
package/src/interactive.ts
CHANGED
|
@@ -132,7 +132,7 @@ export function routingHookActions(input: {
|
|
|
132
132
|
}
|
|
133
133
|
|
|
134
134
|
options.push(
|
|
135
|
-
{ value: 'settings', label: 'Change
|
|
135
|
+
{ value: 'settings', label: 'Change question settings', hint: 'when a question may leave this terminal' },
|
|
136
136
|
{ value: 'back', label: '← Back' },
|
|
137
137
|
)
|
|
138
138
|
return options
|
|
@@ -365,7 +365,7 @@ function menuFor(readiness: Readiness): { value: Screen; label: string; hint?: s
|
|
|
365
365
|
if (!signedIn) {
|
|
366
366
|
options.push({ value: 'account', label: 'Sign in', hint: 'approve this machine in your browser' })
|
|
367
367
|
} else if (!hasDevice) {
|
|
368
|
-
options.push({ value: 'devices', label: 'Add a device', hint: 'install the app on your
|
|
368
|
+
options.push({ value: 'devices', label: 'Add a device', hint: 'install the app on your iPhone' })
|
|
369
369
|
} else {
|
|
370
370
|
options.push({
|
|
371
371
|
value: 'test',
|
|
@@ -457,8 +457,8 @@ async function devicesScreen(deps: CommandDeps): Promise<void> {
|
|
|
457
457
|
if (devices.length === 0) {
|
|
458
458
|
clack.note(
|
|
459
459
|
'No devices are registered yet.\n\n' +
|
|
460
|
-
'Install the Notifai
|
|
461
|
-
'same
|
|
460
|
+
'Install the Notifai Companion App on your iPhone, sign in with the\n' +
|
|
461
|
+
'same Account, and allow notifications when it asks.',
|
|
462
462
|
'No devices',
|
|
463
463
|
)
|
|
464
464
|
return
|
|
@@ -733,7 +733,7 @@ async function routingScreen(deps: CommandDeps, readiness: Readiness): Promise<b
|
|
|
733
733
|
const row = (label: string, value: string): string => `${style.dim(label.padEnd(16))}${value}`
|
|
734
734
|
clack.note(
|
|
735
735
|
'When an agent registers a question, it appears in your terminal first.\n' +
|
|
736
|
-
'After the grace below, it goes to your
|
|
736
|
+
'After the grace below, it goes to your iPhone as a notification\n' +
|
|
737
737
|
'with real answer buttons, and your answer comes back to the agent —\n' +
|
|
738
738
|
'whether or not you are still at this machine.\n\n' +
|
|
739
739
|
[
|
|
@@ -19,7 +19,10 @@ if (worker === undefined) {
|
|
|
19
19
|
const maxBytes = Number(process.env['NOTIFAI_ROTATION_MAX_BYTES'])
|
|
20
20
|
const maxFiles = Number(process.env['NOTIFAI_ROTATION_MAX_FILES'])
|
|
21
21
|
writeFileSync(readyPath, 'ready')
|
|
22
|
-
|
|
22
|
+
// The parent's absolute deadline, not a countdown of this worker's own:
|
|
23
|
+
// the barrier fails for everyone at one instant, so a worker that started
|
|
24
|
+
// early cannot walk out on a parent still waiting for its last sibling.
|
|
25
|
+
const startDeadline = Number(process.env['NOTIFAI_ROTATION_DEADLINE'])
|
|
23
26
|
while (!existsSync(startPath)) {
|
|
24
27
|
if (Date.now() >= startDeadline) throw new Error('rotation parent never released the barrier')
|
|
25
28
|
await new Promise((resolve) => setTimeout(resolve, 2))
|
|
@@ -33,7 +36,10 @@ if (worker === undefined) {
|
|
|
33
36
|
logger.info('cli.end', { worker, sequence, pad: 'p'.repeat(140) })
|
|
34
37
|
await new Promise((resolve) => setTimeout(resolve, 1))
|
|
35
38
|
}
|
|
36
|
-
|
|
39
|
+
// The sink switches itself off for the rest of a process the first time a
|
|
40
|
+
// write throws, so this is the whole point of the worker: every record it
|
|
41
|
+
// was given reached the shared file through the cross-process lock.
|
|
42
|
+
expect(logger.enabled, 'the shared-log sink switched itself off mid-run').toBe(true)
|
|
37
43
|
})
|
|
38
44
|
})
|
|
39
45
|
}
|
package/src/logging.test.ts
CHANGED
|
@@ -35,22 +35,30 @@ function lines(env: NodeJS.ProcessEnv): LogRecord[] {
|
|
|
35
35
|
.map((line) => JSON.parse(line) as LogRecord)
|
|
36
36
|
}
|
|
37
37
|
|
|
38
|
-
async function waitUntil(predicate: () => boolean,
|
|
39
|
-
const deadline = Date.now() + timeoutMs
|
|
38
|
+
async function waitUntil(predicate: () => boolean, deadline: number): Promise<void> {
|
|
40
39
|
while (!predicate()) {
|
|
41
40
|
if (Date.now() >= deadline) throw new Error('timed out waiting for rotation workers')
|
|
42
41
|
await new Promise((resolve) => setTimeout(resolve, 5))
|
|
43
42
|
}
|
|
44
43
|
}
|
|
45
44
|
|
|
46
|
-
|
|
47
|
-
env: NodeJS.ProcessEnv
|
|
48
|
-
worker: number
|
|
49
|
-
records: number
|
|
50
|
-
maxBytes: number
|
|
51
|
-
maxFiles: number
|
|
52
|
-
startPath: string
|
|
53
|
-
|
|
45
|
+
interface RotationRun {
|
|
46
|
+
env: NodeJS.ProcessEnv
|
|
47
|
+
worker: number
|
|
48
|
+
records: number
|
|
49
|
+
maxBytes: number
|
|
50
|
+
maxFiles: number
|
|
51
|
+
startPath: string
|
|
52
|
+
/**
|
|
53
|
+
* When the rendezvous has failed, as an absolute instant every participant
|
|
54
|
+
* reads the same way. A per-side countdown would let the first worker to
|
|
55
|
+
* report ready abandon a parent that is still waiting for the last one.
|
|
56
|
+
*/
|
|
57
|
+
deadline: number
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function rotationWorker(run: RotationRun): { readyPath: string; done: Promise<void> } {
|
|
61
|
+
const { env, worker } = run
|
|
54
62
|
const readyPath = path.join(env['XDG_STATE_HOME']!, `rotation-ready-${worker}`)
|
|
55
63
|
const vitest = path.join(process.cwd(), 'node_modules', 'vitest', 'vitest.mjs')
|
|
56
64
|
const child = spawn(
|
|
@@ -62,11 +70,12 @@ function rotationWorker(
|
|
|
62
70
|
...process.env,
|
|
63
71
|
...env,
|
|
64
72
|
NOTIFAI_ROTATION_WORKER: String(worker),
|
|
65
|
-
NOTIFAI_ROTATION_RECORDS: String(records),
|
|
66
|
-
NOTIFAI_ROTATION_MAX_BYTES: String(maxBytes),
|
|
67
|
-
NOTIFAI_ROTATION_MAX_FILES: String(maxFiles),
|
|
73
|
+
NOTIFAI_ROTATION_RECORDS: String(run.records),
|
|
74
|
+
NOTIFAI_ROTATION_MAX_BYTES: String(run.maxBytes),
|
|
75
|
+
NOTIFAI_ROTATION_MAX_FILES: String(run.maxFiles),
|
|
68
76
|
NOTIFAI_ROTATION_READY: readyPath,
|
|
69
|
-
NOTIFAI_ROTATION_START: startPath,
|
|
77
|
+
NOTIFAI_ROTATION_START: run.startPath,
|
|
78
|
+
NOTIFAI_ROTATION_DEADLINE: String(run.deadline),
|
|
70
79
|
},
|
|
71
80
|
stdio: ['ignore', 'pipe', 'pipe'],
|
|
72
81
|
},
|
|
@@ -263,11 +272,15 @@ describe('rotation', () => {
|
|
|
263
272
|
const maxBytes = 4_000
|
|
264
273
|
const maxFiles = 100
|
|
265
274
|
const startPath = path.join(env['XDG_STATE_HOME']!, 'rotation-start')
|
|
275
|
+
// Four cold Vitest processes have to reach the barrier before any of them
|
|
276
|
+
// writes, and a busy CI host schedules them far apart. The budget is shared,
|
|
277
|
+
// so it bounds the whole rendezvous rather than each side of it.
|
|
278
|
+
const deadline = Date.now() + 20_000
|
|
266
279
|
const children = Array.from({ length: workers }, (_, worker) =>
|
|
267
|
-
rotationWorker(env, worker, recordsPerWorker, maxBytes, maxFiles, startPath),
|
|
280
|
+
rotationWorker({ env, worker, records: recordsPerWorker, maxBytes, maxFiles, startPath, deadline }),
|
|
268
281
|
)
|
|
269
282
|
|
|
270
|
-
await waitUntil(() => children.every(({ readyPath }) => existsSync(readyPath)))
|
|
283
|
+
await waitUntil(() => children.every(({ readyPath }) => existsSync(readyPath)), deadline)
|
|
271
284
|
writeFileSync(startPath, 'go')
|
|
272
285
|
await Promise.all(children.map(({ done }) => done))
|
|
273
286
|
|
|
@@ -293,8 +306,10 @@ describe('rotation', () => {
|
|
|
293
306
|
// name race may lose or duplicate one.
|
|
294
307
|
expect(retainedRecords).toBe(workers * recordsPerWorker)
|
|
295
308
|
expect(seen.size).toBe(workers * recordsPerWorker)
|
|
296
|
-
|
|
297
|
-
|
|
309
|
+
// Name the offending records: a bare boolean would report a duplication bug
|
|
310
|
+
// as "expected false to be true" and leave the next reader nothing to go on.
|
|
311
|
+
expect([...seen].filter(([, count]) => count !== 1)).toEqual([])
|
|
312
|
+
}, 90_000)
|
|
298
313
|
})
|
|
299
314
|
|
|
300
315
|
describe('failure isolation', () => {
|
|
@@ -403,14 +418,27 @@ describe('reading', () => {
|
|
|
403
418
|
expect(records.length).toBeGreaterThan(lines(env).filter((r) => r.event === 'cli.end').length)
|
|
404
419
|
})
|
|
405
420
|
|
|
406
|
-
it('skips
|
|
421
|
+
it('skips damaged and unknown-future lines without throwing away the file', () => {
|
|
407
422
|
const env = sandbox()
|
|
408
423
|
seed(env)
|
|
409
424
|
// A half-written line is what a crashed process leaves behind, and a
|
|
410
425
|
// reader that dies on it loses every good record around it.
|
|
411
426
|
appendFileSync(activeLogPath(env), '{"v":1,"ts":"2026-0\n')
|
|
412
427
|
appendFileSync(activeLogPath(env), 'not json at all\n')
|
|
413
|
-
|
|
428
|
+
appendFileSync(
|
|
429
|
+
activeLogPath(env),
|
|
430
|
+
`${JSON.stringify({
|
|
431
|
+
v: LOG_SCHEMA_VERSION + 1,
|
|
432
|
+
ts: '2026-08-18T12:00:00.000Z',
|
|
433
|
+
level: 'info',
|
|
434
|
+
event: 'future.event',
|
|
435
|
+
})}\n`,
|
|
436
|
+
)
|
|
437
|
+
expect(readLogRecords(env).records.map((record) => record.event)).toEqual([
|
|
438
|
+
'send.submitted',
|
|
439
|
+
'cli.error',
|
|
440
|
+
'ask.registered',
|
|
441
|
+
])
|
|
414
442
|
})
|
|
415
443
|
|
|
416
444
|
it('reports nothing at all rather than failing when there is no log', () => {
|
package/src/logging.ts
CHANGED
|
@@ -61,6 +61,17 @@ export const LOG_SCHEMA_VERSION = 1
|
|
|
61
61
|
export { LOG_LEVELS, type LogLevel }
|
|
62
62
|
export type RecordLevel = Exclude<LogLevel, 'off'>
|
|
63
63
|
|
|
64
|
+
/**
|
|
65
|
+
* The levels a record can actually carry.
|
|
66
|
+
*
|
|
67
|
+
* `off` is a setting that stops writing, never a severity anything is written
|
|
68
|
+
* at — so offering it as a retrieval filter accepted a query that could only
|
|
69
|
+
* ever match nothing, and then blamed the result on too narrow a search.
|
|
70
|
+
*/
|
|
71
|
+
export const RECORD_LEVELS: readonly RecordLevel[] = LOG_LEVELS.filter(
|
|
72
|
+
(level): level is RecordLevel => level !== 'off',
|
|
73
|
+
)
|
|
74
|
+
|
|
64
75
|
const LEVEL_RANK: Record<LogLevel, number> = { off: 0, error: 1, info: 2, debug: 3 }
|
|
65
76
|
|
|
66
77
|
/**
|
|
@@ -523,6 +534,7 @@ function isRecord(parsed: unknown): parsed is LogRecord {
|
|
|
523
534
|
return (
|
|
524
535
|
typeof parsed === 'object' &&
|
|
525
536
|
parsed !== null &&
|
|
537
|
+
(parsed as { v?: unknown }).v === LOG_SCHEMA_VERSION &&
|
|
526
538
|
typeof (parsed as { event?: unknown }).event === 'string' &&
|
|
527
539
|
typeof (parsed as { ts?: unknown }).ts === 'string'
|
|
528
540
|
)
|
package/src/main.ts
CHANGED
|
@@ -124,8 +124,6 @@ const program = new Command('notifai')
|
|
|
124
124
|
// it. Without this, adding that default would silently break
|
|
125
125
|
// `notifai help send` for everyone who reaches for it before `--help`.
|
|
126
126
|
.helpCommand(true)
|
|
127
|
-
// Where the implicit help command lands; every other command names its own.
|
|
128
|
-
.commandsGroup(GROUP.help)
|
|
129
127
|
.hook('preAction', (_program, actionCommand) => {
|
|
130
128
|
logger.bind({ cmd: commandPath(actionCommand) })
|
|
131
129
|
// SessionEnd uses the hook policy shared with commands.ts: local cleanup
|
|
@@ -156,7 +154,40 @@ const program = new Command('notifai')
|
|
|
156
154
|
* interactive tree cost nothing to the paths that never draw them — `send`,
|
|
157
155
|
* `ask`, and the hook that runs in front of every prompt the user types.
|
|
158
156
|
*/
|
|
159
|
-
|
|
157
|
+
/**
|
|
158
|
+
* Commander exits 1 for every usage error it detects itself, which contradicts
|
|
159
|
+
* the exit vocabulary this CLI documents and every hand-written usage error it
|
|
160
|
+
* returns. A caller branching on exit codes should not have to know which layer
|
|
161
|
+
* rejected it. Help and version stay successful.
|
|
162
|
+
*/
|
|
163
|
+
program.exitOverride((err) => {
|
|
164
|
+
process.exit(err.exitCode === 0 ? 0 : 2)
|
|
165
|
+
})
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* An unrecognised subcommand reaches the root as an excess argument, and
|
|
169
|
+
* "expected 0 arguments but got 1" tells a caller nothing about what it typed
|
|
170
|
+
* or what exists. Naming it — with the nearest real command when there is one —
|
|
171
|
+
* turns a dead end into one more try.
|
|
172
|
+
*/
|
|
173
|
+
program.usage('[options] [command]')
|
|
174
|
+
program.argument('[command]').action(async (command?: string) => {
|
|
175
|
+
if (command !== undefined) {
|
|
176
|
+
const names = program.commands.map((cmd) => cmd.name())
|
|
177
|
+
const near = names.filter(
|
|
178
|
+
(name) =>
|
|
179
|
+
name.startsWith(command.slice(0, 2)) ||
|
|
180
|
+
command.startsWith(name.slice(0, 2)) ||
|
|
181
|
+
name.includes(command) ||
|
|
182
|
+
command.includes(name),
|
|
183
|
+
)
|
|
184
|
+
deps.io.err(
|
|
185
|
+
`Unknown command "${command}".` +
|
|
186
|
+
(near.length > 0 ? ` Did you mean: ${near.slice(0, 3).join(', ')}?` : '') +
|
|
187
|
+
' Run notifai --help for the full list.',
|
|
188
|
+
)
|
|
189
|
+
process.exit(2)
|
|
190
|
+
}
|
|
160
191
|
if (deps.io.interactive !== true) {
|
|
161
192
|
program.outputHelp()
|
|
162
193
|
return
|
|
@@ -269,19 +300,17 @@ const send = program
|
|
|
269
300
|
.option(
|
|
270
301
|
'--image <path|url|media_id>',
|
|
271
302
|
'upload or attach an image in collection order (repeatable, maximum 8)',
|
|
272
|
-
(v: string, all: string[]) => [...all, v],
|
|
273
|
-
[],
|
|
303
|
+
(v: string, all: string[] = []) => [...all, v],
|
|
274
304
|
)
|
|
275
305
|
.option(
|
|
276
306
|
'--image-alt <text>',
|
|
277
307
|
'alt text paired with --image occurrences by position (repeatable)',
|
|
278
|
-
(v: string, all: string[]) => [...all, v],
|
|
279
|
-
[],
|
|
308
|
+
(v: string, all: string[] = []) => [...all, v],
|
|
280
309
|
)
|
|
281
310
|
.optionsGroup(SEND_GROUP.routing)
|
|
282
|
-
.option('--kind <kind>', 'what this is: update
|
|
311
|
+
.option('--kind <kind>', 'what this is (required): update | done | failed | blocked — question is set by --reply')
|
|
283
312
|
.option('--project <id>', 'project identifier override (otherwise configured or inferred)')
|
|
284
|
-
.option('--device <id>', 'target a device id (repeatable)', (v: string, all: string[]) => [...all, v]
|
|
313
|
+
.option('--device <id>', 'target a device id (repeatable)', (v: string, all: string[] = []) => [...all, v])
|
|
285
314
|
.option('--all', 'target all routable devices (overrides configured devices)')
|
|
286
315
|
.option('--session-id <id>', 'opaque exact-session override (env: NOTIFAI_SESSION_ID)')
|
|
287
316
|
.option('--session-label <text>', 'human session label override (env: NOTIFAI_SESSION_LABEL)')
|
|
@@ -290,15 +319,19 @@ const send = program
|
|
|
290
319
|
.option('--collapse-key <key>', 'replace earlier notifications with the same key')
|
|
291
320
|
.option('--thread-id <id>', 'group related notifications')
|
|
292
321
|
.option('--ttl <seconds>', 'delivery window in seconds', (v: string) => Number(v))
|
|
293
|
-
.option('--platform <platform>', 'limit optional fields to
|
|
322
|
+
.option('--platform <platform>', 'limit optional fields to one supported platform')
|
|
294
323
|
.optionsGroup(SEND_GROUP.reply)
|
|
295
324
|
.option('--reply', 'enable the inline reply action and block for the answer')
|
|
296
325
|
.option('--reply-timeout <seconds>', 'how long to wait for a reply (default: 900)', (v: string) => Number(v))
|
|
297
|
-
.option(
|
|
326
|
+
.option(
|
|
327
|
+
'--reply-window <seconds>',
|
|
328
|
+
'how long an answer is still accepted, 60-259200 (default: reply_window_seconds, a day)',
|
|
329
|
+
(v: string) => Number(v),
|
|
330
|
+
)
|
|
298
331
|
.option(
|
|
299
332
|
'--reply-choice <label>',
|
|
300
333
|
'with --reply, ask a closed question; repeat the flag once per answer (2-6)',
|
|
301
|
-
(v: string, all: string[]) => [...all, v],
|
|
334
|
+
(v: string, all: string[] = []) => [...all, v],
|
|
302
335
|
)
|
|
303
336
|
.option('--reply-multi', 'with --reply-choice, several answers may be selected')
|
|
304
337
|
// Kept registered, and always a usage error with --reply, so the caller gets
|
|
@@ -309,7 +342,7 @@ const send = program
|
|
|
309
342
|
.option('--level <level>', 'override saved interruption level: passive | active | time_sensitive')
|
|
310
343
|
.option('--wait <seconds>', 'how long to wait for provider outcomes', (v: string) => Number(v))
|
|
311
344
|
.option('--no-wait', 'return immediately after acceptance')
|
|
312
|
-
.option('--data <key=value>', 'custom data (repeatable)', (v: string, all: string[]) => [...all, v]
|
|
345
|
+
.option('--data <key=value>', 'custom data (repeatable)', (v: string, all: string[] = []) => [...all, v])
|
|
313
346
|
.option('--idempotency-key <key>', 'safe-retry key (default: random)')
|
|
314
347
|
.option('--base-url <url>', 'developer override for the service origin (also NOTIFAI_BASE_URL)')
|
|
315
348
|
.option('--json', 'print the full submission receipt as JSON')
|
|
@@ -346,8 +379,8 @@ const send = program
|
|
|
346
379
|
|
|
347
380
|
send.addHelpText(
|
|
348
381
|
'after',
|
|
349
|
-
`\nKind
|
|
350
|
-
)
|
|
382
|
+
`\nKind is required, and it selects the sound the notification arrives with: done rings the completion chime, failed the most insistent tone, blocked and question a distinct attention tone, update the standard one.\nAn explicit --sound or --level, and the user's saved preference, both outrank that default.\n`,
|
|
383
|
+
)
|
|
351
384
|
|
|
352
385
|
program
|
|
353
386
|
.command('replies [request_id]')
|
|
@@ -369,9 +402,9 @@ program
|
|
|
369
402
|
.description(
|
|
370
403
|
'Record the required Agent Acknowledgement for a replied-to notification request; never prompts',
|
|
371
404
|
)
|
|
372
|
-
.
|
|
405
|
+
.option('--text <text>', 'concrete work you will do because of the reply')
|
|
373
406
|
.option('--json', 'machine-readable output')
|
|
374
|
-
.action(async (requestId: string, opts: { text
|
|
407
|
+
.action(async (requestId: string, opts: { text?: string; json?: boolean }) => {
|
|
375
408
|
process.exit(await acknowledgeCommand(deps, requestId, opts))
|
|
376
409
|
})
|
|
377
410
|
|
|
@@ -389,27 +422,26 @@ program
|
|
|
389
422
|
.command('ask [question]')
|
|
390
423
|
.helpGroup(GROUP.agent)
|
|
391
424
|
.summary('Register a question, then end the turn')
|
|
392
|
-
.description('Register a question for the turn-end hook to route
|
|
425
|
+
.description('Register a question for the turn-end hook to route to your devices, subject to your question-routing settings')
|
|
393
426
|
.option(
|
|
394
427
|
'--choice <label>',
|
|
395
428
|
'answers to offer instead of free text; repeat the flag once per answer (2-6)',
|
|
396
|
-
(v: string, all: string[]) => [...all, v],
|
|
429
|
+
(v: string, all: string[] = []) => [...all, v],
|
|
397
430
|
)
|
|
398
431
|
.option('--multi', 'with --choice, several answers may be selected')
|
|
432
|
+
.option('--json', 'machine-readable output')
|
|
399
433
|
.option('--body <markdown>', 'optional Markdown context appended after the question block')
|
|
400
434
|
.option('--body-file <path>', 'read optional Markdown context from a file (use - for stdin)')
|
|
401
435
|
.option('--form <path>', 'ask several questions as one form; JSON file (use - for stdin)')
|
|
402
436
|
.option(
|
|
403
437
|
'--image <path|url|media_id>',
|
|
404
438
|
'upload or attach an image in collection order (repeatable, maximum 8)',
|
|
405
|
-
(v: string, all: string[]) => [...all, v],
|
|
406
|
-
[],
|
|
439
|
+
(v: string, all: string[] = []) => [...all, v],
|
|
407
440
|
)
|
|
408
441
|
.option(
|
|
409
442
|
'--image-alt <text>',
|
|
410
443
|
'alt text paired with --image occurrences by position (repeatable)',
|
|
411
|
-
(v: string, all: string[]) => [...all, v],
|
|
412
|
-
[],
|
|
444
|
+
(v: string, all: string[] = []) => [...all, v],
|
|
413
445
|
)
|
|
414
446
|
.option('--project <id>', 'project identifier override (otherwise configured or inferred)')
|
|
415
447
|
.option('--session-id <id>', 'opaque exact-session override (env: NOTIFAI_SESSION_ID)')
|
|
@@ -417,6 +449,7 @@ program
|
|
|
417
449
|
.action(async (question: string | undefined, opts: {
|
|
418
450
|
choice?: string[]
|
|
419
451
|
multi?: boolean
|
|
452
|
+
json?: boolean
|
|
420
453
|
body?: string
|
|
421
454
|
bodyFile?: string
|
|
422
455
|
form?: string
|
|
@@ -459,6 +492,7 @@ program
|
|
|
459
492
|
...(opts.project !== undefined ? { project: opts.project } : {}),
|
|
460
493
|
...(opts.sessionId !== undefined ? { sessionId: opts.sessionId } : {}),
|
|
461
494
|
...(opts.sessionLabel !== undefined ? { sessionLabel: opts.sessionLabel } : {}),
|
|
495
|
+
...(opts.json === true ? { json: true } : {}),
|
|
462
496
|
}
|
|
463
497
|
process.exit(await askCommand(deps, question, flags))
|
|
464
498
|
})
|
|
@@ -528,7 +562,7 @@ program
|
|
|
528
562
|
.option('--all', 'lift the default limit, up to a hard cap')
|
|
529
563
|
.option('--since <when>', 'only records newer than this: 10m, 2h, 1d, or an ISO 8601 instant')
|
|
530
564
|
.option('--level <level>', 'minimum severity: error | info | debug (error shows only failures)')
|
|
531
|
-
.option('--event <name>', 'only this event; repeatable', (v: string, all: string[]) => [...all, v]
|
|
565
|
+
.option('--event <name>', 'only this event; repeatable', (v: string, all: string[] = []) => [...all, v])
|
|
532
566
|
.option('--run <id>', 'everything one command invocation did')
|
|
533
567
|
.option('--request <id>', 'everything about one notification request')
|
|
534
568
|
.option('--session <id>', 'only this harness session')
|
package/src/opencode-plugin.ts
CHANGED
|
@@ -31,7 +31,7 @@ export const OPENCODE_PLUGIN_MARKER = '// notifai managed opencode plugin'
|
|
|
31
31
|
export const OPENCODE_PLUGIN_FILENAME = 'notifai.js'
|
|
32
32
|
|
|
33
33
|
/** Bump when an installed generated file must be rewritten to remain functional. */
|
|
34
|
-
const OPENCODE_ADAPTER_VERSION =
|
|
34
|
+
const OPENCODE_ADAPTER_VERSION = 6
|
|
35
35
|
|
|
36
36
|
export function opencodePluginDir(
|
|
37
37
|
global: boolean,
|
|
@@ -75,150 +75,32 @@ export interface OpencodePluginOptions {
|
|
|
75
75
|
* hand that to the CLI on stdin. It deliberately fails open: an adapter error
|
|
76
76
|
* never changes OpenCode's own behavior.
|
|
77
77
|
*/
|
|
78
|
-
export function opencodePluginSource(options: OpencodePluginOptions): string {
|
|
79
|
-
if (hookHostPlatform(options.platform) === 'win32') {
|
|
80
|
-
return win32OpencodePluginSource(options)
|
|
81
|
-
}
|
|
82
|
-
return posixOpencodePluginSource(options)
|
|
83
|
-
}
|
|
84
|
-
|
|
85
|
-
function posixOpencodePluginSource(options: OpencodePluginOptions): string {
|
|
86
|
-
const { adapterPath, timeoutSeconds } = options
|
|
87
|
-
return `${OPENCODE_PLUGIN_MARKER}
|
|
88
|
-
// Generated by \`notifai hooks install --harness opencode\`. Edits are lost on
|
|
89
|
-
// the next install; change the CLI instead, which is where the logic lives.
|
|
90
|
-
import { spawn } from "node:child_process"
|
|
91
|
-
|
|
92
|
-
const ADAPTER = ${JSON.stringify(adapterPath)}
|
|
93
|
-
const TIMEOUT_MS = ${timeoutSeconds * 1000}
|
|
94
|
-
const ADAPTER_VERSION = ${OPENCODE_ADAPTER_VERSION}
|
|
95
|
-
|
|
96
78
|
/**
|
|
97
|
-
*
|
|
98
|
-
*
|
|
79
|
+
* The plugin body, which is the same on every platform except for how it
|
|
80
|
+
* reaches the adapter.
|
|
81
|
+
*
|
|
82
|
+
* It was two near-identical copies — about a hundred and twenty lines each,
|
|
83
|
+
* differing only in the spawn call — so every fix to the plugin had to be made
|
|
84
|
+
* twice or silently apply to one platform.
|
|
99
85
|
*/
|
|
100
|
-
function
|
|
101
|
-
return new Promise((resolve) => {
|
|
102
|
-
let child
|
|
103
|
-
try {
|
|
104
|
-
child = spawn(ADAPTER, ["hook", event, "--owner", "notifai", "--harness", "opencode"], {
|
|
105
|
-
env: process.env,
|
|
106
|
-
shell: false,
|
|
107
|
-
stdio: ["pipe", "pipe", "pipe"],
|
|
108
|
-
})
|
|
109
|
-
} catch {
|
|
110
|
-
resolve(null)
|
|
111
|
-
return
|
|
112
|
-
}
|
|
113
|
-
|
|
114
|
-
let stdout = ""
|
|
115
|
-
let settled = false
|
|
116
|
-
const finish = (value) => {
|
|
117
|
-
if (settled) return
|
|
118
|
-
settled = true
|
|
119
|
-
clearTimeout(timer)
|
|
120
|
-
resolve(value)
|
|
121
|
-
}
|
|
122
|
-
const timer = setTimeout(() => {
|
|
123
|
-
child.kill("SIGTERM")
|
|
124
|
-
finish(null)
|
|
125
|
-
}, TIMEOUT_MS)
|
|
126
|
-
|
|
127
|
-
child.stdout?.setEncoding("utf-8")
|
|
128
|
-
child.stdout?.on("data", (chunk) => {
|
|
129
|
-
stdout += chunk
|
|
130
|
-
})
|
|
131
|
-
// Hook stderr is diagnostics, not plugin output; discard it here.
|
|
132
|
-
child.stderr?.resume()
|
|
133
|
-
child.on("error", () => finish(null))
|
|
134
|
-
child.on("close", () => finish(stdout))
|
|
135
|
-
|
|
136
|
-
try {
|
|
137
|
-
child.stdin?.end(JSON.stringify(envelope))
|
|
138
|
-
} catch {
|
|
139
|
-
finish(null)
|
|
140
|
-
}
|
|
141
|
-
})
|
|
142
|
-
}
|
|
143
|
-
|
|
144
|
-
function additionalContext(stdout) {
|
|
145
|
-
if (typeof stdout !== "string" || stdout.trim() === "") return null
|
|
146
|
-
try {
|
|
147
|
-
const parsed = JSON.parse(stdout)
|
|
148
|
-
const context = parsed?.hookSpecificOutput?.additionalContext
|
|
149
|
-
return typeof context === "string" && context.length > 0 ? context : null
|
|
150
|
-
} catch {
|
|
151
|
-
return null
|
|
152
|
-
}
|
|
153
|
-
}
|
|
154
|
-
|
|
155
|
-
export const NotifAIPlugin = async ({ directory }) => {
|
|
156
|
-
const cwd = typeof directory === "string" && directory.length > 0 ? directory : process.cwd()
|
|
157
|
-
|
|
158
|
-
return {
|
|
159
|
-
/** Exact active-harness identity for shell commands such as \`notifai ask\`. */
|
|
160
|
-
"shell.env": async (input, output) => {
|
|
161
|
-
output.env.NOTIFAI_ACTIVE_HARNESS = "opencode"
|
|
162
|
-
const sessionID = input?.sessionID
|
|
163
|
-
if (typeof sessionID === "string" && sessionID.length > 0) {
|
|
164
|
-
output.env.NOTIFAI_ACTIVE_SESSION_ID = sessionID
|
|
165
|
-
}
|
|
166
|
-
},
|
|
167
|
-
|
|
168
|
-
/**
|
|
169
|
-
* Presence. The user typing is the signal every harness shares, and it is
|
|
170
|
-
* also what publishes the project -> session pointer \`notifai ask\` reads.
|
|
171
|
-
*/
|
|
172
|
-
"chat.message": async (input, output) => {
|
|
173
|
-
const stdout = await runHook("user-prompt-submit", {
|
|
174
|
-
session_id: input?.sessionID ?? input?.info?.sessionID,
|
|
175
|
-
cwd,
|
|
176
|
-
hook_event_name: "UserPromptSubmit",
|
|
177
|
-
})
|
|
178
|
-
const context = additionalContext(stdout)
|
|
179
|
-
if (context !== null) {
|
|
180
|
-
output.parts.push({
|
|
181
|
-
id: "prt_notifai_" + Date.now(),
|
|
182
|
-
sessionID: input.sessionID,
|
|
183
|
-
messageID: output.message.id,
|
|
184
|
-
type: "text",
|
|
185
|
-
text: context,
|
|
186
|
-
synthetic: true,
|
|
187
|
-
})
|
|
188
|
-
}
|
|
189
|
-
},
|
|
190
|
-
|
|
191
|
-
/** Session lifecycle events are delivered through OpenCode's event bus. */
|
|
192
|
-
event: async ({ event }) => {
|
|
193
|
-
if (event?.type === "session.idle") {
|
|
194
|
-
await runHook("stop", {
|
|
195
|
-
session_id: event?.properties?.sessionID,
|
|
196
|
-
cwd,
|
|
197
|
-
hook_event_name: "Stop",
|
|
198
|
-
})
|
|
199
|
-
} else if (event?.type === "session.deleted") {
|
|
200
|
-
await runHook("session-end", {
|
|
201
|
-
session_id: event?.properties?.info?.id,
|
|
202
|
-
cwd,
|
|
203
|
-
hook_event_name: "SessionEnd",
|
|
204
|
-
})
|
|
205
|
-
}
|
|
206
|
-
},
|
|
207
|
-
}
|
|
208
|
-
}
|
|
209
|
-
`
|
|
210
|
-
}
|
|
211
|
-
|
|
212
|
-
function win32OpencodePluginSource(options: OpencodePluginOptions): string {
|
|
86
|
+
export function opencodePluginSource(options: OpencodePluginOptions): string {
|
|
213
87
|
const { adapterPath, timeoutSeconds } = options
|
|
214
|
-
|
|
88
|
+
// Windows cannot execute the adapter script directly, so it goes through the
|
|
89
|
+
// registered Node executable and the console window is suppressed.
|
|
90
|
+
const win32 = hookHostPlatform(options.platform) === 'win32'
|
|
91
|
+
const nodeConstant = win32
|
|
92
|
+
? `const NODE = ${JSON.stringify(options.nodePath ?? process.execPath)}\n`
|
|
93
|
+
: ''
|
|
94
|
+
const spawnArguments = win32
|
|
95
|
+
? 'NODE, [ADAPTER, "hook", event, "--owner", "notifai", "--harness", "opencode"]'
|
|
96
|
+
: 'ADAPTER, ["hook", event, "--owner", "notifai", "--harness", "opencode"]'
|
|
97
|
+
const windowsHide = win32 ? '\n windowsHide: true,' : ''
|
|
215
98
|
return `${OPENCODE_PLUGIN_MARKER}
|
|
216
99
|
// Generated by \`notifai hooks install --harness opencode\`. Edits are lost on
|
|
217
100
|
// the next install; change the CLI instead, which is where the logic lives.
|
|
218
101
|
import { spawn } from "node:child_process"
|
|
219
102
|
|
|
220
|
-
const
|
|
221
|
-
const ADAPTER = ${JSON.stringify(adapterPath)}
|
|
103
|
+
${nodeConstant}const ADAPTER = ${JSON.stringify(adapterPath)}
|
|
222
104
|
const TIMEOUT_MS = ${timeoutSeconds * 1000}
|
|
223
105
|
const ADAPTER_VERSION = ${OPENCODE_ADAPTER_VERSION}
|
|
224
106
|
|
|
@@ -230,10 +112,9 @@ function runHook(event, envelope) {
|
|
|
230
112
|
return new Promise((resolve) => {
|
|
231
113
|
let child
|
|
232
114
|
try {
|
|
233
|
-
child = spawn(
|
|
115
|
+
child = spawn(${spawnArguments}, {
|
|
234
116
|
env: process.env,
|
|
235
|
-
shell: false
|
|
236
|
-
windowsHide: true,
|
|
117
|
+
shell: false,${windowsHide}
|
|
237
118
|
stdio: ["pipe", "pipe", "pipe"],
|
|
238
119
|
})
|
|
239
120
|
} catch {
|
|
@@ -271,17 +152,6 @@ function runHook(event, envelope) {
|
|
|
271
152
|
})
|
|
272
153
|
}
|
|
273
154
|
|
|
274
|
-
function additionalContext(stdout) {
|
|
275
|
-
if (typeof stdout !== "string" || stdout.trim() === "") return null
|
|
276
|
-
try {
|
|
277
|
-
const parsed = JSON.parse(stdout)
|
|
278
|
-
const context = parsed?.hookSpecificOutput?.additionalContext
|
|
279
|
-
return typeof context === "string" && context.length > 0 ? context : null
|
|
280
|
-
} catch {
|
|
281
|
-
return null
|
|
282
|
-
}
|
|
283
|
-
}
|
|
284
|
-
|
|
285
155
|
export const NotifAIPlugin = async ({ directory }) => {
|
|
286
156
|
const cwd = typeof directory === "string" && directory.length > 0 ? directory : process.cwd()
|
|
287
157
|
|
|
@@ -299,23 +169,13 @@ export const NotifAIPlugin = async ({ directory }) => {
|
|
|
299
169
|
* Presence. The user typing is the signal every harness shares, and it is
|
|
300
170
|
* also what publishes the project -> session pointer \`notifai ask\` reads.
|
|
301
171
|
*/
|
|
302
|
-
|
|
303
|
-
|
|
172
|
+
/** Publishes the session pointer; the CLI returns no content to inject. */
|
|
173
|
+
"chat.message": async (input) => {
|
|
174
|
+
await runHook("user-prompt-submit", {
|
|
304
175
|
session_id: input?.sessionID ?? input?.info?.sessionID,
|
|
305
176
|
cwd,
|
|
306
177
|
hook_event_name: "UserPromptSubmit",
|
|
307
178
|
})
|
|
308
|
-
const context = additionalContext(stdout)
|
|
309
|
-
if (context !== null) {
|
|
310
|
-
output.parts.push({
|
|
311
|
-
id: "prt_notifai_" + Date.now(),
|
|
312
|
-
sessionID: input.sessionID,
|
|
313
|
-
messageID: output.message.id,
|
|
314
|
-
type: "text",
|
|
315
|
-
text: context,
|
|
316
|
-
synthetic: true,
|
|
317
|
-
})
|
|
318
|
-
}
|
|
319
179
|
},
|
|
320
180
|
|
|
321
181
|
/** Session lifecycle events are delivered through OpenCode's event bus. */
|