@raidiant/notifai 0.2.1 → 0.4.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/README.md +20 -4
- package/dist/atomic-file.d.ts +4 -0
- package/dist/atomic-file.js +60 -10
- package/dist/atomic-file.js.map +1 -1
- package/dist/client.d.ts +25 -2
- package/dist/client.js +45 -1
- package/dist/client.js.map +1 -1
- package/dist/commands.d.ts +85 -6
- package/dist/commands.js +1133 -322
- package/dist/commands.js.map +1 -1
- package/dist/config-schema.d.ts +62 -0
- package/dist/config-schema.js +312 -0
- package/dist/config-schema.js.map +1 -0
- package/dist/config.d.ts +38 -3
- package/dist/config.js +61 -14
- package/dist/config.js.map +1 -1
- package/dist/file-lock.d.ts +26 -0
- package/dist/file-lock.js +268 -0
- package/dist/file-lock.js.map +1 -0
- package/dist/harnesses.d.ts +28 -0
- package/dist/harnesses.js +40 -0
- package/dist/harnesses.js.map +1 -0
- package/dist/hook-adapter.d.ts +27 -0
- package/dist/hook-adapter.js +188 -0
- package/dist/hook-adapter.js.map +1 -0
- package/dist/hook-input.d.ts +10 -0
- package/dist/hook-input.js +64 -0
- package/dist/hook-input.js.map +1 -0
- package/dist/hooks.d.ts +66 -10
- package/dist/hooks.js +751 -276
- package/dist/hooks.js.map +1 -1
- package/dist/install-hooks.d.ts +40 -16
- package/dist/install-hooks.js +217 -73
- package/dist/install-hooks.js.map +1 -1
- package/dist/interactive.d.ts +4 -0
- package/dist/interactive.js +604 -0
- package/dist/interactive.js.map +1 -0
- package/dist/logging.d.ts +142 -0
- package/dist/logging.js +533 -0
- package/dist/logging.js.map +1 -0
- package/dist/main.js +201 -51
- package/dist/main.js.map +1 -1
- package/dist/opencode-plugin.d.ts +7 -7
- package/dist/opencode-plugin.js +12 -13
- package/dist/opencode-plugin.js.map +1 -1
- package/dist/ui/banner.d.ts +22 -0
- package/dist/ui/banner.js +87 -0
- package/dist/ui/banner.js.map +1 -0
- package/dist/ui/config-view.d.ts +32 -0
- package/dist/ui/config-view.js +108 -0
- package/dist/ui/config-view.js.map +1 -0
- package/dist/ui/help.d.ts +46 -0
- package/dist/ui/help.js +105 -0
- package/dist/ui/help.js.map +1 -0
- package/dist/ui/theme.d.ts +108 -0
- package/dist/ui/theme.js +261 -0
- package/dist/ui/theme.js.map +1 -0
- package/package.json +11 -9
- package/src/atomic-file.ts +86 -7
- package/src/client.test.ts +12 -0
- package/src/client.ts +72 -4
- package/src/commands.test.ts +971 -44
- package/src/commands.ts +1323 -337
- package/src/config-schema.test.ts +152 -0
- package/src/config-schema.ts +363 -0
- package/src/config.test.ts +6 -5
- package/src/config.ts +85 -16
- package/src/file-lock-process-worker.test.ts +64 -0
- package/src/file-lock.test.ts +279 -0
- package/src/file-lock.ts +318 -0
- package/src/harnesses.test.ts +48 -0
- package/src/harnesses.ts +59 -0
- package/src/hook-adapter.test.ts +187 -0
- package/src/hook-adapter.ts +212 -0
- package/src/hook-input.test.ts +39 -0
- package/src/hook-input.ts +72 -0
- package/src/hooks.test.ts +1318 -240
- package/src/hooks.ts +1046 -296
- package/src/install-hooks.test.ts +90 -41
- package/src/install-hooks.ts +259 -74
- package/src/interactive.ts +692 -0
- package/src/logging-process-worker.test.ts +39 -0
- package/src/logging.test.ts +453 -0
- package/src/logging.ts +639 -0
- package/src/main.ts +220 -48
- package/src/opencode-plugin.ts +15 -16
- package/src/session-end-log-lock-worker.test.ts +35 -0
- package/src/skill.test.ts +11 -0
- package/src/ui/banner.ts +95 -0
- package/src/ui/config-view.test.ts +88 -0
- package/src/ui/config-view.ts +138 -0
- package/src/ui/help.ts +124 -0
- package/src/ui/theme.test.ts +92 -0
- package/src/ui/theme.ts +279 -0
package/src/main.ts
CHANGED
|
@@ -9,16 +9,20 @@ import {
|
|
|
9
9
|
authStatusCommand,
|
|
10
10
|
capabilitiesCommand,
|
|
11
11
|
closeCommand,
|
|
12
|
+
configExplainCommand,
|
|
12
13
|
configSetCommand,
|
|
13
14
|
configShowCommand,
|
|
15
|
+
configUnsetCommand,
|
|
14
16
|
devicesCommand,
|
|
15
17
|
doctorCommand,
|
|
18
|
+
hookDefersDiagnosticsUntilAfterCleanup,
|
|
16
19
|
hookRunCommand,
|
|
17
20
|
hooksInstallCommand,
|
|
18
21
|
hooksUninstallCommand,
|
|
19
22
|
initCommand,
|
|
20
23
|
loginCommand,
|
|
21
24
|
logoutCommand,
|
|
25
|
+
logsCommand,
|
|
22
26
|
realIo,
|
|
23
27
|
repliesCommand,
|
|
24
28
|
sendCommand,
|
|
@@ -29,6 +33,20 @@ import { defaultCredentialStore } from './credentials.js'
|
|
|
29
33
|
import { HARNESSES } from './install-hooks.js'
|
|
30
34
|
import type { Platform } from '@raidiant/notifai-protocol'
|
|
31
35
|
import { nativeSkills, type SkillScope } from './native-skills.js'
|
|
36
|
+
import { GROUP, SEND_GROUP, helpConfiguration, rootHelpFooter } from './ui/help.js'
|
|
37
|
+
|
|
38
|
+
import { readStdinWithTimeout } from './hook-input.js'
|
|
39
|
+
import { bootstrapLogger } from './logging.js'
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* The local record for this invocation.
|
|
43
|
+
*
|
|
44
|
+
* Built before the command tree so that the very first thing recorded is the
|
|
45
|
+
* command starting — including for a command that goes on to fail before it has
|
|
46
|
+
* resolved anything. It configures itself from disk and disables itself if it
|
|
47
|
+
* cannot write, so nothing below has to handle it failing.
|
|
48
|
+
*/
|
|
49
|
+
const logger = bootstrapLogger()
|
|
32
50
|
|
|
33
51
|
const deps: CommandDeps = {
|
|
34
52
|
io: realIo(),
|
|
@@ -36,36 +54,7 @@ const deps: CommandDeps = {
|
|
|
36
54
|
env: process.env,
|
|
37
55
|
cwd: process.cwd(),
|
|
38
56
|
nativeSkills,
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
/**
|
|
42
|
-
* Harness hooks deliver their event payload on stdin. Bounded on both time and
|
|
43
|
-
* size: a wrapper that opens the pipe without writing would otherwise hold the
|
|
44
|
-
* read until the harness killed the hook, delaying the user's own prompt.
|
|
45
|
-
*/
|
|
46
|
-
async function readStdin(timeoutMs = 2000, maxBytes = 1_000_000): Promise<string> {
|
|
47
|
-
if (process.stdin.isTTY) return ''
|
|
48
|
-
return new Promise((resolve) => {
|
|
49
|
-
const chunks: Buffer[] = []
|
|
50
|
-
let total = 0
|
|
51
|
-
let settled = false
|
|
52
|
-
const finish = (): void => {
|
|
53
|
-
if (settled) return
|
|
54
|
-
settled = true
|
|
55
|
-
clearTimeout(timer)
|
|
56
|
-
process.stdin.destroy()
|
|
57
|
-
resolve(Buffer.concat(chunks).toString('utf8'))
|
|
58
|
-
}
|
|
59
|
-
const timer = setTimeout(finish, timeoutMs)
|
|
60
|
-
timer.unref?.()
|
|
61
|
-
process.stdin.on('data', (chunk: Buffer) => {
|
|
62
|
-
total += chunk.length
|
|
63
|
-
if (total > maxBytes) return finish()
|
|
64
|
-
chunks.push(chunk)
|
|
65
|
-
})
|
|
66
|
-
process.stdin.on('end', finish)
|
|
67
|
-
process.stdin.on('error', finish)
|
|
68
|
-
})
|
|
57
|
+
logger,
|
|
69
58
|
}
|
|
70
59
|
|
|
71
60
|
/**
|
|
@@ -89,13 +78,103 @@ function version(): string {
|
|
|
89
78
|
return 'unknown'
|
|
90
79
|
}
|
|
91
80
|
|
|
81
|
+
/**
|
|
82
|
+
* The full command path, e.g. `config set`, so a filter on `cmd` distinguishes
|
|
83
|
+
* subcommands that share a leaf name.
|
|
84
|
+
*/
|
|
85
|
+
function commandPath(command: Command): string {
|
|
86
|
+
const parts: string[] = []
|
|
87
|
+
for (let node: Command | null = command; node?.parent != null; node = node.parent) {
|
|
88
|
+
parts.unshift(node.name())
|
|
89
|
+
}
|
|
90
|
+
return parts.join(' ') || 'notifai'
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Which flags were passed, without their values.
|
|
95
|
+
*
|
|
96
|
+
* The flag names answer nearly every question worth asking of an invocation —
|
|
97
|
+
* was `--reply` set, was `--all` — while the values are notification content
|
|
98
|
+
* and user text that has no business being recorded merely because a command
|
|
99
|
+
* ran. Values are available under `log_level = debug`, which is a deliberate act.
|
|
100
|
+
*/
|
|
101
|
+
function flagNames(argv: readonly string[]): string[] {
|
|
102
|
+
return argv.filter((token) => token.startsWith('--'))
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const startedAt = Date.now()
|
|
106
|
+
process.on('exit', (code) => {
|
|
107
|
+
logger.info('cli.end', {
|
|
108
|
+
exit: code,
|
|
109
|
+
duration_ms: Date.now() - startedAt,
|
|
110
|
+
flags: flagNames(process.argv.slice(2)),
|
|
111
|
+
})
|
|
112
|
+
})
|
|
113
|
+
|
|
92
114
|
const program = new Command('notifai')
|
|
93
115
|
.description('Send native device notifications from agents and local programs')
|
|
94
116
|
.version(version())
|
|
117
|
+
.configureHelp(helpConfiguration)
|
|
118
|
+
// Lazy on purpose. `addHelpText` also takes a string, but that builds the
|
|
119
|
+
// footer on every invocation — including the hook that runs in front of
|
|
120
|
+
// every prompt the user types, which never renders help at all.
|
|
121
|
+
.addHelpText('after', () => rootHelpFooter())
|
|
122
|
+
// Commander's default is to print the full help and exit 0 for an unknown
|
|
123
|
+
// `help` topic, so `notifai help nonsense` looked like it had worked. A
|
|
124
|
+
// reader who mistyped a command deserves to be told, and a script deserves
|
|
125
|
+
// a nonzero code.
|
|
126
|
+
.showSuggestionAfterError(true)
|
|
127
|
+
// Commander drops the implicit `help [command]` as soon as the program
|
|
128
|
+
// itself has an action handler — which the interactive default below gives
|
|
129
|
+
// it. Without this, adding that default would silently break
|
|
130
|
+
// `notifai help send` for everyone who reaches for it before `--help`.
|
|
131
|
+
.helpCommand(true)
|
|
132
|
+
// Where the implicit help command lands; every other command names its own.
|
|
133
|
+
.commandsGroup(GROUP.help)
|
|
134
|
+
.hook('preAction', (_program, actionCommand) => {
|
|
135
|
+
logger.bind({ cmd: commandPath(actionCommand) })
|
|
136
|
+
// SessionEnd uses the hook policy shared with commands.ts: local cleanup
|
|
137
|
+
// precedes every diagnostic that can wait on the shared log lock.
|
|
138
|
+
const deferDiagnostics =
|
|
139
|
+
actionCommand.name() === 'hook' &&
|
|
140
|
+
hookDefersDiagnosticsUntilAfterCleanup(actionCommand.processedArgs[0])
|
|
141
|
+
// Values only at `debug`; `cli.end` carries the flag names at every level.
|
|
142
|
+
if (!deferDiagnostics) {
|
|
143
|
+
logger.debug('cli.start', {
|
|
144
|
+
version: version(),
|
|
145
|
+
argv: process.argv.slice(2),
|
|
146
|
+
cwd: process.cwd(),
|
|
147
|
+
})
|
|
148
|
+
}
|
|
149
|
+
})
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* Bare `notifai`.
|
|
153
|
+
*
|
|
154
|
+
* At a human terminal this opens the interactive app; anywhere else it prints
|
|
155
|
+
* help exactly as before. The check is the same one every other prompt in this
|
|
156
|
+
* CLI is gated on — stdin and stdout both TTYs, not CI, `NOTIFAI_NO_INPUT`
|
|
157
|
+
* unset — because an agent that reaches a prompt does not fail, it hangs
|
|
158
|
+
* for ever waiting on a stdin nobody is typing into.
|
|
159
|
+
*
|
|
160
|
+
* The import is dynamic so that the prompt library, the banner and the whole
|
|
161
|
+
* interactive tree cost nothing to the paths that never draw them — `send`,
|
|
162
|
+
* `ask`, and the hook that runs in front of every prompt the user types.
|
|
163
|
+
*/
|
|
164
|
+
program.action(async () => {
|
|
165
|
+
if (deps.io.interactive !== true) {
|
|
166
|
+
program.outputHelp()
|
|
167
|
+
return
|
|
168
|
+
}
|
|
169
|
+
const { interactiveCommand } = await import('./interactive.js')
|
|
170
|
+
process.exit(await interactiveCommand(deps, version()))
|
|
171
|
+
})
|
|
95
172
|
|
|
96
173
|
// Setup leads: `init` is the single entry command for first-run friendliness.
|
|
97
174
|
program
|
|
98
175
|
.command('init')
|
|
176
|
+
.helpGroup(GROUP.start)
|
|
177
|
+
.summary('Set this project up, step by step')
|
|
99
178
|
.description(
|
|
100
179
|
'Set up Notifai here, idempotently: sign-in, project id, hooks, device readiness, live receipt proof. ' +
|
|
101
180
|
'Interactive at a human terminal; never prompts otherwise (agents: pass flags)',
|
|
@@ -112,6 +191,8 @@ program
|
|
|
112
191
|
|
|
113
192
|
program
|
|
114
193
|
.command('doctor')
|
|
194
|
+
.helpGroup(GROUP.daily)
|
|
195
|
+
.summary('Check every part of the setup')
|
|
115
196
|
.description('Audit config, credential, server, contract, device, hook, and saved receipt proof; exits nonzero when any line is FAIL (no live send)')
|
|
116
197
|
.option('--json', 'machine-readable output')
|
|
117
198
|
.action(async (opts: { json?: boolean }) => {
|
|
@@ -120,6 +201,8 @@ program
|
|
|
120
201
|
|
|
121
202
|
program
|
|
122
203
|
.command('login')
|
|
204
|
+
.helpGroup(GROUP.start)
|
|
205
|
+
.summary('Sign in and pair this machine')
|
|
123
206
|
.description('Pair this machine with your Notifai account via browser approval')
|
|
124
207
|
.option('--name <name>', 'machine name shown in the dashboard (default: hostname)')
|
|
125
208
|
.option('--base-url <url>', 'Notifai server URL')
|
|
@@ -130,12 +213,18 @@ program
|
|
|
130
213
|
|
|
131
214
|
program
|
|
132
215
|
.command('logout')
|
|
216
|
+
.helpGroup(GROUP.advanced)
|
|
217
|
+
.summary('Remove the stored machine credential')
|
|
133
218
|
.description('Remove the stored machine credential')
|
|
134
219
|
.action(() => {
|
|
135
220
|
process.exit(logoutCommand(deps))
|
|
136
221
|
})
|
|
137
222
|
|
|
138
|
-
const auth = program
|
|
223
|
+
const auth = program
|
|
224
|
+
.command('auth')
|
|
225
|
+
.description('Authentication helpers')
|
|
226
|
+
.summary('Machine identity and account plan')
|
|
227
|
+
.helpGroup(GROUP.advanced)
|
|
139
228
|
auth
|
|
140
229
|
.command('status')
|
|
141
230
|
.description('Show the stored machine identity')
|
|
@@ -153,6 +242,8 @@ auth
|
|
|
153
242
|
|
|
154
243
|
program
|
|
155
244
|
.command('devices')
|
|
245
|
+
.helpGroup(GROUP.daily)
|
|
246
|
+
.summary('List your devices and whether they can receive')
|
|
156
247
|
.description('List registered devices and their delivery readiness')
|
|
157
248
|
.option('--json', 'machine-readable output')
|
|
158
249
|
.action(async (opts: { json?: boolean }) => {
|
|
@@ -161,6 +252,8 @@ program
|
|
|
161
252
|
|
|
162
253
|
program
|
|
163
254
|
.command('capabilities')
|
|
255
|
+
.helpGroup(GROUP.advanced)
|
|
256
|
+
.summary('Show what a platform can render')
|
|
164
257
|
.description('Show a platform capability contract')
|
|
165
258
|
.option('--platform <platform>', 'platform to describe (default: ios)')
|
|
166
259
|
.option('--json', 'machine-readable output')
|
|
@@ -170,26 +263,31 @@ program
|
|
|
170
263
|
|
|
171
264
|
const send = program
|
|
172
265
|
.command('send')
|
|
266
|
+
.helpGroup(GROUP.agent)
|
|
267
|
+
.summary('Send a notification')
|
|
173
268
|
.description('Send a notification')
|
|
269
|
+
.optionsGroup(SEND_GROUP.content)
|
|
174
270
|
.requiredOption('--title <title>', 'notification title')
|
|
175
271
|
.requiredOption('--body <body>', 'notification body')
|
|
176
|
-
.option('--subtitle <subtitle>')
|
|
272
|
+
.option('--subtitle <subtitle>', 'a line between the title and the body')
|
|
177
273
|
.option('--detail <markdown>', 'long-form markdown shown only in the app, never on the banner')
|
|
178
274
|
.option('--detail-file <path>', 'read --detail from a file (use - for stdin)')
|
|
179
|
-
.option('--
|
|
275
|
+
.option('--image <path|url|media_id>', 'upload or attach an image')
|
|
276
|
+
.optionsGroup(SEND_GROUP.routing)
|
|
180
277
|
.option('--kind <kind>', 'what this is: update (default) | done | question (requires --reply)')
|
|
181
278
|
.option('--project <id>', 'project identifier, e.g. my-app (lazily registered)')
|
|
182
|
-
.option('--session <id>', 'session identity (env: NOTIFAI_SESSION); presentation varies by surface')
|
|
183
279
|
.option('--device <id>', 'target a device id (repeatable)', (v: string, all: string[]) => [...all, v], [])
|
|
184
280
|
.option('--all', 'target all routable devices (overrides configured devices)')
|
|
185
|
-
.option('--
|
|
186
|
-
.option('--
|
|
187
|
-
.
|
|
281
|
+
.option('--session <id>', 'session identity (env: NOTIFAI_SESSION); presentation varies by surface')
|
|
282
|
+
.option('--event <event>', 'agent event name, e.g. tests_passed')
|
|
283
|
+
.optionsGroup(SEND_GROUP.presentation)
|
|
188
284
|
.option('--sound <sound>', 'default | done | attention | alert | none')
|
|
189
|
-
.option('--thread-id <id>', 'group related notifications')
|
|
190
285
|
.option('--level <level>', 'interruption level: passive | active | time_sensitive')
|
|
191
|
-
.option('--
|
|
192
|
-
.option('--
|
|
286
|
+
.option('--collapse-key <key>', 'replace earlier notifications with the same key')
|
|
287
|
+
.option('--thread-id <id>', 'group related notifications')
|
|
288
|
+
.option('--ttl <seconds>', 'delivery window in seconds', (v: string) => Number(v))
|
|
289
|
+
.option('--platform <platform>', 'limit optional fields to ios or macos (default: both)')
|
|
290
|
+
.optionsGroup(SEND_GROUP.reply)
|
|
193
291
|
.option('--reply', 'enable the inline reply action and block for the answer')
|
|
194
292
|
.option('--reply-timeout <seconds>', 'how long to wait for a reply (default: 900)', (v: string) => Number(v))
|
|
195
293
|
.option('--reply-window <seconds>', 'how long the server accepts a reply (default: 3600)', (v: string) => Number(v))
|
|
@@ -202,8 +300,10 @@ const send = program
|
|
|
202
300
|
// Kept registered, and always a usage error with --reply, so the caller gets
|
|
203
301
|
// a message pointing at `ask` instead of "unknown option".
|
|
204
302
|
.option('--no-block', 'rejected with --reply; use `notifai ask` to ask and end the turn')
|
|
303
|
+
.optionsGroup(SEND_GROUP.advanced)
|
|
205
304
|
.option('--wait <seconds>', 'how long to wait for provider outcomes', (v: string) => Number(v))
|
|
206
305
|
.option('--no-wait', 'return immediately after acceptance')
|
|
306
|
+
.option('--data <key=value>', 'custom data (repeatable)', (v: string, all: string[]) => [...all, v], [])
|
|
207
307
|
.option('--idempotency-key <key>', 'safe-retry key (default: random)')
|
|
208
308
|
.option('--base-url <url>', 'Notifai server URL')
|
|
209
309
|
.option('--json', 'print the full submission receipt as JSON')
|
|
@@ -247,6 +347,8 @@ send.addHelpText(
|
|
|
247
347
|
|
|
248
348
|
program
|
|
249
349
|
.command('replies [request_id]')
|
|
350
|
+
.helpGroup(GROUP.agent)
|
|
351
|
+
.summary('Retrieve replies to a question')
|
|
250
352
|
.description('Retrieve replies for a notification request')
|
|
251
353
|
.option('--pending', 'use the pushed question pending for this project session')
|
|
252
354
|
.option('--wait <seconds>', 'how long to wait for a reply', (v: string) => Number(v))
|
|
@@ -258,6 +360,8 @@ program
|
|
|
258
360
|
|
|
259
361
|
program
|
|
260
362
|
.command('status <request_id>')
|
|
363
|
+
.helpGroup(GROUP.agent)
|
|
364
|
+
.summary('Show the evidence trail for a request')
|
|
261
365
|
.description('Show the evidence trail for a notification request')
|
|
262
366
|
.option('--json', 'machine-readable output')
|
|
263
367
|
.action(async (requestId: string, opts: { json?: boolean }) => {
|
|
@@ -266,6 +370,8 @@ program
|
|
|
266
370
|
|
|
267
371
|
program
|
|
268
372
|
.command('ask [question]')
|
|
373
|
+
.helpGroup(GROUP.agent)
|
|
374
|
+
.summary('Register a question, then end the turn')
|
|
269
375
|
.description('Register a question for the turn-end hook to route under your presence settings')
|
|
270
376
|
.option(
|
|
271
377
|
'--choice <label>',
|
|
@@ -320,13 +426,18 @@ program
|
|
|
320
426
|
|
|
321
427
|
program
|
|
322
428
|
.command('close <request_id>')
|
|
429
|
+
.helpGroup(GROUP.agent)
|
|
430
|
+
.summary('Retire a question so late answers are rejected')
|
|
323
431
|
.description('Retire a question so late answers are rejected rather than lost')
|
|
324
432
|
.action(async (requestId: string) => {
|
|
325
433
|
process.exit(await closeCommand(deps, requestId))
|
|
326
434
|
})
|
|
327
435
|
|
|
436
|
+
// Hidden, not removed. It is an entry point the hook installer writes into a
|
|
437
|
+
// harness config file, never something a person types — and listing it beside
|
|
438
|
+
// `send` invited exactly one reading, which is that you were supposed to.
|
|
328
439
|
program
|
|
329
|
-
.command('hook <event>')
|
|
440
|
+
.command('hook <event>', { hidden: true })
|
|
330
441
|
.description('Internal: run a harness hook (reads hook JSON on stdin)')
|
|
331
442
|
// Inert, and the point of it is that it is inert: the installed command line
|
|
332
443
|
// carries a marker that says "Notifai wrote this" independently of which
|
|
@@ -336,17 +447,21 @@ program
|
|
|
336
447
|
.action(async (event: string, opts: { harness?: string }) => {
|
|
337
448
|
const harness = HARNESSES.find((candidate) => candidate === opts.harness)
|
|
338
449
|
process.exit(
|
|
339
|
-
await hookRunCommand(deps, event,
|
|
450
|
+
await hookRunCommand(deps, event, () => readStdinWithTimeout(), harness),
|
|
340
451
|
)
|
|
341
452
|
})
|
|
342
453
|
|
|
343
|
-
const hooks = program
|
|
454
|
+
const hooks = program
|
|
455
|
+
.command('hooks')
|
|
456
|
+
.description('Install harness hooks for registered-question routing')
|
|
457
|
+
.summary('Wire a harness to route questions to your devices')
|
|
458
|
+
.helpGroup(GROUP.advanced)
|
|
344
459
|
hooks
|
|
345
460
|
.command('install')
|
|
346
461
|
.description('Wire this harness to route registered questions to your devices')
|
|
347
462
|
.option(
|
|
348
463
|
'--harness <name>',
|
|
349
|
-
'claude-code | codex | cursor | opencode (default: detected
|
|
464
|
+
'claude-code | codex | cursor | opencode (default: detected)',
|
|
350
465
|
)
|
|
351
466
|
.option('--global', 'install for every project instead of just this one')
|
|
352
467
|
.action((opts: { harness?: string; global?: boolean }) => {
|
|
@@ -361,15 +476,55 @@ hooks
|
|
|
361
476
|
process.exit(hooksUninstallCommand(deps, opts))
|
|
362
477
|
})
|
|
363
478
|
|
|
364
|
-
|
|
479
|
+
program
|
|
480
|
+
.command('logs')
|
|
481
|
+
.helpGroup(GROUP.daily)
|
|
482
|
+
.summary('Show what this machine recorded about what it did')
|
|
483
|
+
.description(
|
|
484
|
+
'Read the local record of commands, notifications, and the question decisions harness hooks made. ' +
|
|
485
|
+
'Bounded and scoped to this project by default; never leaves this machine',
|
|
486
|
+
)
|
|
487
|
+
.option('-n, --limit <count>', 'how many records to show (default: 30)', (v: string) => Number(v))
|
|
488
|
+
.option('--all', 'lift the default limit, up to a hard cap')
|
|
489
|
+
.option('--since <when>', 'only records newer than this: 10m, 2h, 1d, or an ISO 8601 instant')
|
|
490
|
+
.option('--level <level>', 'minimum severity: error | info | debug (error shows only failures)')
|
|
491
|
+
.option('--event <name>', 'only this event; repeatable', (v: string, all: string[]) => [...all, v], [])
|
|
492
|
+
.option('--run <id>', 'everything one command invocation did')
|
|
493
|
+
.option('--request <id>', 'everything about one notification request')
|
|
494
|
+
.option('--session <id>', 'only this harness session')
|
|
495
|
+
.option('--project <id>', 'only this project')
|
|
496
|
+
.option('--all-projects', 'do not scope to the project in this directory')
|
|
497
|
+
.option('--grep <text>', 'only records containing this text')
|
|
498
|
+
.option('--json', 'one JSON record per line on stdout')
|
|
499
|
+
.option('--path', 'print the log file paths instead of the records')
|
|
500
|
+
.option('--clear', 'delete the log files')
|
|
501
|
+
.action((opts: Record<string, unknown>) => {
|
|
502
|
+
const flags = { ...opts } as Parameters<typeof logsCommand>[1]
|
|
503
|
+
if (Array.isArray(flags.event) && flags.event.length === 0) delete flags.event
|
|
504
|
+
process.exit(logsCommand(deps, flags))
|
|
505
|
+
})
|
|
506
|
+
|
|
507
|
+
const config = program
|
|
508
|
+
.command('config')
|
|
509
|
+
.description('Show or change settings')
|
|
510
|
+
.summary('Show or change settings')
|
|
511
|
+
.helpGroup(GROUP.daily)
|
|
365
512
|
config
|
|
366
513
|
.command('show')
|
|
367
|
-
.description('Show
|
|
368
|
-
.option('--explain', '
|
|
514
|
+
.description('Show every setting, what it does, and where its value came from')
|
|
515
|
+
.option('--explain', 'include advanced settings and the file each value came from')
|
|
516
|
+
.option('--plain', 'the flat key = value form, even at a terminal')
|
|
369
517
|
.option('--json', 'machine-readable output')
|
|
370
|
-
.action((opts: { json?: boolean; explain?: boolean }) => {
|
|
518
|
+
.action((opts: { json?: boolean; explain?: boolean; plain?: boolean }) => {
|
|
371
519
|
process.exit(configShowCommand(deps, opts))
|
|
372
520
|
})
|
|
521
|
+
config
|
|
522
|
+
.command('explain <key>')
|
|
523
|
+
.description('Explain one setting in full: what it does, what it accepts, what it is now')
|
|
524
|
+
.option('--json', 'machine-readable output')
|
|
525
|
+
.action((key: string, opts: { json?: boolean }) => {
|
|
526
|
+
process.exit(configExplainCommand(deps, key, opts))
|
|
527
|
+
})
|
|
373
528
|
config
|
|
374
529
|
.command('set <key> <value>')
|
|
375
530
|
.description(
|
|
@@ -388,5 +543,22 @@ config
|
|
|
388
543
|
process.exit(await configSetCommand(deps, key, value, opts))
|
|
389
544
|
},
|
|
390
545
|
)
|
|
546
|
+
config
|
|
547
|
+
.command('unset <key>')
|
|
548
|
+
.description(
|
|
549
|
+
'Remove a configuration value so the next layer or shipped default applies (choose a layer interactively; unattended defaults machine-global and requires --yes)',
|
|
550
|
+
)
|
|
551
|
+
.option('--project', 'remove from the shared .notifai/config.toml instead')
|
|
552
|
+
.option('--local', 'remove from personal .notifai/config.local.toml')
|
|
553
|
+
.option('--session <id>', 'remove from one session')
|
|
554
|
+
.option('--yes', 'skip the confirmation gate')
|
|
555
|
+
.action(
|
|
556
|
+
async (
|
|
557
|
+
key: string,
|
|
558
|
+
opts: { project?: boolean; local?: boolean; session?: string; yes?: boolean },
|
|
559
|
+
) => {
|
|
560
|
+
process.exit(await configUnsetCommand(deps, key, opts))
|
|
561
|
+
},
|
|
562
|
+
)
|
|
391
563
|
|
|
392
564
|
await program.parseAsync(process.argv)
|
package/src/opencode-plugin.ts
CHANGED
|
@@ -30,7 +30,7 @@ export const OPENCODE_PLUGIN_MARKER = '// notifai managed opencode plugin'
|
|
|
30
30
|
export const OPENCODE_PLUGIN_FILENAME = 'notifai.js'
|
|
31
31
|
|
|
32
32
|
/** Bump when an installed generated file must be rewritten to remain functional. */
|
|
33
|
-
const OPENCODE_ADAPTER_VERSION =
|
|
33
|
+
const OPENCODE_ADAPTER_VERSION = 5
|
|
34
34
|
|
|
35
35
|
export function opencodePluginDir(
|
|
36
36
|
global: boolean,
|
|
@@ -51,8 +51,8 @@ export function opencodePluginPath(
|
|
|
51
51
|
}
|
|
52
52
|
|
|
53
53
|
export interface OpencodePluginOptions {
|
|
54
|
-
|
|
55
|
-
|
|
54
|
+
/** The same stable user-level adapter every command-hook harness invokes. */
|
|
55
|
+
adapterPath: string
|
|
56
56
|
/** Child-process lifetime; covers grace, reply wait, and teardown headroom. */
|
|
57
57
|
timeoutSeconds: number
|
|
58
58
|
}
|
|
@@ -60,23 +60,23 @@ export interface OpencodePluginOptions {
|
|
|
60
60
|
/**
|
|
61
61
|
* The generated module.
|
|
62
62
|
*
|
|
63
|
-
* Written as a string rather than compiled from a source file because
|
|
64
|
-
*
|
|
65
|
-
*
|
|
63
|
+
* Written as a string rather than compiled from a source file because OpenCode
|
|
64
|
+
* loads a plugin module rather than a command definition. It embeds only the
|
|
65
|
+
* stable user-level adapter path; mutable runtime and CLI targets live behind
|
|
66
|
+
* that adapter exactly as they do for every other harness.
|
|
66
67
|
*
|
|
67
68
|
* Everything it does is build the hook envelope OpenCode's event gives it and
|
|
68
69
|
* hand that to the CLI on stdin. It deliberately fails open: an adapter error
|
|
69
70
|
* never changes OpenCode's own behavior.
|
|
70
71
|
*/
|
|
71
72
|
export function opencodePluginSource(options: OpencodePluginOptions): string {
|
|
72
|
-
const {
|
|
73
|
+
const { adapterPath, timeoutSeconds } = options
|
|
73
74
|
return `${OPENCODE_PLUGIN_MARKER}
|
|
74
75
|
// Generated by \`notifai hooks install --harness opencode\`. Edits are lost on
|
|
75
76
|
// the next install; change the CLI instead, which is where the logic lives.
|
|
76
77
|
import { spawn } from "node:child_process"
|
|
77
78
|
|
|
78
|
-
const
|
|
79
|
-
const SCRIPT = ${JSON.stringify(scriptPath)}
|
|
79
|
+
const ADAPTER = ${JSON.stringify(adapterPath)}
|
|
80
80
|
const TIMEOUT_MS = ${timeoutSeconds * 1000}
|
|
81
81
|
const ADAPTER_VERSION = ${OPENCODE_ADAPTER_VERSION}
|
|
82
82
|
|
|
@@ -88,7 +88,7 @@ function runHook(event, envelope) {
|
|
|
88
88
|
return new Promise((resolve) => {
|
|
89
89
|
let child
|
|
90
90
|
try {
|
|
91
|
-
child = spawn(
|
|
91
|
+
child = spawn(ADAPTER, ["hook", event, "--owner", "notifai", "--harness", "opencode"], {
|
|
92
92
|
env: process.env,
|
|
93
93
|
shell: false,
|
|
94
94
|
stdio: ["pipe", "pipe", "pipe"],
|
|
@@ -216,17 +216,16 @@ export function isOurOpencodePlugin(contents: string): boolean {
|
|
|
216
216
|
*/
|
|
217
217
|
export function opencodePluginTarget(
|
|
218
218
|
contents: string,
|
|
219
|
-
): {
|
|
219
|
+
): { adapter: string; current: boolean; timeoutSeconds?: number } | null {
|
|
220
220
|
if (!isOurOpencodePlugin(contents)) return null
|
|
221
|
-
const
|
|
222
|
-
const
|
|
221
|
+
const adapter = /^const ADAPTER = (".*")$/m.exec(contents)?.[1]
|
|
222
|
+
const legacyScript = /^const SCRIPT = (".*")$/m.exec(contents)?.[1]
|
|
223
223
|
const version = Number(/^const ADAPTER_VERSION = (\d+)$/m.exec(contents)?.[1] ?? 1)
|
|
224
224
|
const timeoutMs = Number(/^const TIMEOUT_MS = (\d+)$/m.exec(contents)?.[1] ?? Number.NaN)
|
|
225
|
-
if (
|
|
225
|
+
if (adapter === undefined && legacyScript === undefined) return null
|
|
226
226
|
try {
|
|
227
227
|
return {
|
|
228
|
-
|
|
229
|
-
script: JSON.parse(script) as string,
|
|
228
|
+
adapter: JSON.parse(adapter ?? legacyScript!) as string,
|
|
230
229
|
current: version === OPENCODE_ADAPTER_VERSION,
|
|
231
230
|
...(Number.isFinite(timeoutMs) ? { timeoutSeconds: timeoutMs / 1000 } : {}),
|
|
232
231
|
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { existsSync, writeFileSync } from 'node:fs'
|
|
2
|
+
import { describe, expect, it } from 'vitest'
|
|
3
|
+
import { withFileLock } from './file-lock.js'
|
|
4
|
+
|
|
5
|
+
const workerMode = process.env['NOTIFAI_SESSION_END_LOG_LOCK_WORKER_MODE']
|
|
6
|
+
const sleep = new Int32Array(new SharedArrayBuffer(4))
|
|
7
|
+
|
|
8
|
+
function waitFor(file: string): void {
|
|
9
|
+
const deadline = Date.now() + 15_000
|
|
10
|
+
while (!existsSync(file)) {
|
|
11
|
+
if (Date.now() >= deadline) throw new Error(`timed out waiting for ${file}`)
|
|
12
|
+
Atomics.wait(sleep, 0, 0, 2)
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
if (workerMode === undefined) {
|
|
17
|
+
describe('SessionEnd log lock worker', () => {
|
|
18
|
+
it('stays dormant outside the CLI deadline regression', () => {
|
|
19
|
+
expect(workerMode).toBeUndefined()
|
|
20
|
+
})
|
|
21
|
+
})
|
|
22
|
+
} else {
|
|
23
|
+
describe('SessionEnd log lock worker', () => {
|
|
24
|
+
it('holds a live bakery ticket until the parent releases it', () => {
|
|
25
|
+
withFileLock(
|
|
26
|
+
process.env['NOTIFAI_SESSION_END_LOG_LOCK_PATH']!,
|
|
27
|
+
() => {
|
|
28
|
+
writeFileSync(process.env['NOTIFAI_SESSION_END_LOG_LOCK_READY']!, workerMode)
|
|
29
|
+
waitFor(process.env['NOTIFAI_SESSION_END_LOG_LOCK_RELEASE']!)
|
|
30
|
+
},
|
|
31
|
+
{ waitMs: 15_000 },
|
|
32
|
+
)
|
|
33
|
+
})
|
|
34
|
+
})
|
|
35
|
+
}
|
package/src/skill.test.ts
CHANGED
|
@@ -34,6 +34,17 @@ describe('Notifai agent skill', () => {
|
|
|
34
34
|
}
|
|
35
35
|
})
|
|
36
36
|
|
|
37
|
+
it('tells the agent where to look when something did not happen', () => {
|
|
38
|
+
// The log's whole value is diagnostic, so the skill has to name the case
|
|
39
|
+
// that produces it — a question that was registered and never travelled.
|
|
40
|
+
expect(skill).toContain('notifai logs')
|
|
41
|
+
expect(skill).toContain('--request <id>')
|
|
42
|
+
expect(skill).toContain('hook.gate')
|
|
43
|
+
expect(skill).toContain('notifications-off')
|
|
44
|
+
// And that it is the user's data, on the user's machine.
|
|
45
|
+
expect(skill).toContain('stays on this machine')
|
|
46
|
+
})
|
|
47
|
+
|
|
37
48
|
it('moves per-harness mechanics into progressive disclosure', () => {
|
|
38
49
|
expect(skill).toContain('[Harness setup and recovery](references/harness-setup.md)')
|
|
39
50
|
for (const harness of ['Claude Code', 'Codex', 'Cursor', 'OpenCode']) {
|
package/src/ui/banner.ts
ADDED
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The wordmark shown when a human opens the interactive app.
|
|
3
|
+
*
|
|
4
|
+
* Three tiers, chosen by what the terminal can actually render rather than by
|
|
5
|
+
* what looks best in a screenshot: box-drawing blocks when the terminal is wide
|
|
6
|
+
* and Unicode-capable, a pure-ASCII form that survives anywhere, and a plain
|
|
7
|
+
* word when neither fits. A banner that wraps is worse than no banner, so the
|
|
8
|
+
* width check is not optional.
|
|
9
|
+
*
|
|
10
|
+
* The art is embedded rather than rendered at run time. It is one fixed word
|
|
11
|
+
* that will never change, and shipping a font engine to redraw a constant on
|
|
12
|
+
* every launch would cost every install — including the agents that never see
|
|
13
|
+
* it — for no benefit.
|
|
14
|
+
*/
|
|
15
|
+
import { gradientBlock, style, terminalWidth, unicode } from './theme.js'
|
|
16
|
+
|
|
17
|
+
/** ANSI Shadow. 50 columns, 6 rows. */
|
|
18
|
+
const BLOCK = [
|
|
19
|
+
'███╗ ██╗ ██████╗ ████████╗██╗███████╗ █████╗ ██╗',
|
|
20
|
+
'████╗ ██║██╔═══██╗╚══██╔══╝██║██╔════╝██╔══██╗██║',
|
|
21
|
+
'██╔██╗ ██║██║ ██║ ██║ ██║█████╗ ███████║██║',
|
|
22
|
+
'██║╚██╗██║██║ ██║ ██║ ██║██╔══╝ ██╔══██║██║',
|
|
23
|
+
'██║ ╚████║╚██████╔╝ ██║ ██║██║ ██║ ██║██║',
|
|
24
|
+
'╚═╝ ╚═══╝ ╚═════╝ ╚═╝ ╚═╝╚═╝ ╚═╝ ╚═╝╚═╝',
|
|
25
|
+
]
|
|
26
|
+
|
|
27
|
+
/** Pure ASCII. 34 columns, 4 rows — safe on any terminal, any code page. */
|
|
28
|
+
const ASCII = [
|
|
29
|
+
' _ _ ___ _____ ___ ___ _ ___ ',
|
|
30
|
+
' | \\| |/ _ \\_ _|_ _| __/_\\ |_ _|',
|
|
31
|
+
' | .` | (_) || | | || _/ _ \\ | | ',
|
|
32
|
+
' |_|\\_|\\___/ |_| |___|_/_/ \\_\\___|',
|
|
33
|
+
]
|
|
34
|
+
|
|
35
|
+
export type BannerSize = 'block' | 'ascii' | 'word'
|
|
36
|
+
|
|
37
|
+
export function bannerSize(columns = terminalWidth()): BannerSize {
|
|
38
|
+
if (unicode && columns >= 54) return 'block'
|
|
39
|
+
if (columns >= 38) return 'ascii'
|
|
40
|
+
return 'word'
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** The wordmark as rendered lines, gradient included. */
|
|
44
|
+
export function bannerLines(columns = terminalWidth()): string[] {
|
|
45
|
+
switch (bannerSize(columns)) {
|
|
46
|
+
case 'block':
|
|
47
|
+
return gradientBlock(BLOCK)
|
|
48
|
+
case 'ascii':
|
|
49
|
+
return gradientBlock(ASCII)
|
|
50
|
+
case 'word':
|
|
51
|
+
return gradientBlock(['N O T I F A I'])
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Reveal the wordmark one row at a time.
|
|
57
|
+
*
|
|
58
|
+
* Deliberately brief and deliberately skippable: this runs before someone can
|
|
59
|
+
* do anything, so it may not become a wait. It is skipped entirely when output
|
|
60
|
+
* is not a TTY, when `NOTIFAI_NO_ANIMATION` is set, and under `CI`.
|
|
61
|
+
*/
|
|
62
|
+
export async function printBanner(
|
|
63
|
+
options: { animate?: boolean; env?: NodeJS.ProcessEnv; write?: (line: string) => void } = {},
|
|
64
|
+
): Promise<void> {
|
|
65
|
+
const env = options.env ?? process.env
|
|
66
|
+
const write = options.write ?? ((line: string) => process.stdout.write(`${line}\n`))
|
|
67
|
+
const lines = bannerLines()
|
|
68
|
+
const animate =
|
|
69
|
+
options.animate !== false &&
|
|
70
|
+
process.stdout.isTTY === true &&
|
|
71
|
+
(env['NOTIFAI_NO_ANIMATION'] ?? '') === '' &&
|
|
72
|
+
(env['CI'] ?? '') === ''
|
|
73
|
+
|
|
74
|
+
write('')
|
|
75
|
+
for (const line of lines) {
|
|
76
|
+
// Two columns, matching the tagline and the status card beneath it. Flush
|
|
77
|
+
// left, the wordmark reads as a separate thing sitting above the app.
|
|
78
|
+
write(` ${line}`)
|
|
79
|
+
if (animate) await new Promise((resolve) => setTimeout(resolve, 26))
|
|
80
|
+
}
|
|
81
|
+
write('')
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* One line of orientation under the wordmark — and it must stay one line.
|
|
86
|
+
* Wrapped onto two it stops reading as a subtitle and starts reading as the
|
|
87
|
+
* first paragraph of something, so a narrow terminal gets the version alone.
|
|
88
|
+
*/
|
|
89
|
+
export function tagline(version: string, columns = terminalWidth()): string {
|
|
90
|
+
const full = 'Native notifications for agents and local programs'
|
|
91
|
+
// Two leading columns of indent, two trailing for the version.
|
|
92
|
+
return columns >= full.length + 12
|
|
93
|
+
? `${style.dim(full)} ${style.dim(`v${version}`)}`
|
|
94
|
+
: style.dim(`v${version}`)
|
|
95
|
+
}
|