@raidiant/notifai 0.3.0 → 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.
Files changed (80) hide show
  1. package/README.md +20 -4
  2. package/dist/atomic-file.d.ts +4 -0
  3. package/dist/atomic-file.js +60 -10
  4. package/dist/atomic-file.js.map +1 -1
  5. package/dist/client.d.ts +25 -2
  6. package/dist/client.js +45 -1
  7. package/dist/client.js.map +1 -1
  8. package/dist/commands.d.ts +56 -7
  9. package/dist/commands.js +980 -318
  10. package/dist/commands.js.map +1 -1
  11. package/dist/config-schema.d.ts +1 -1
  12. package/dist/config-schema.js +46 -8
  13. package/dist/config-schema.js.map +1 -1
  14. package/dist/config.d.ts +38 -3
  15. package/dist/config.js +61 -14
  16. package/dist/config.js.map +1 -1
  17. package/dist/file-lock.d.ts +26 -0
  18. package/dist/file-lock.js +268 -0
  19. package/dist/file-lock.js.map +1 -0
  20. package/dist/harnesses.d.ts +28 -0
  21. package/dist/harnesses.js +40 -0
  22. package/dist/harnesses.js.map +1 -0
  23. package/dist/hook-adapter.d.ts +27 -0
  24. package/dist/hook-adapter.js +188 -0
  25. package/dist/hook-adapter.js.map +1 -0
  26. package/dist/hook-input.d.ts +10 -0
  27. package/dist/hook-input.js +64 -0
  28. package/dist/hook-input.js.map +1 -0
  29. package/dist/hooks.d.ts +66 -10
  30. package/dist/hooks.js +751 -276
  31. package/dist/hooks.js.map +1 -1
  32. package/dist/install-hooks.d.ts +40 -16
  33. package/dist/install-hooks.js +217 -73
  34. package/dist/install-hooks.js.map +1 -1
  35. package/dist/interactive.d.ts +1 -1
  36. package/dist/interactive.js +3 -3
  37. package/dist/interactive.js.map +1 -1
  38. package/dist/logging.d.ts +142 -0
  39. package/dist/logging.js +533 -0
  40. package/dist/logging.js.map +1 -0
  41. package/dist/main.js +97 -36
  42. package/dist/main.js.map +1 -1
  43. package/dist/opencode-plugin.d.ts +7 -7
  44. package/dist/opencode-plugin.js +12 -13
  45. package/dist/opencode-plugin.js.map +1 -1
  46. package/dist/ui/config-view.js +3 -0
  47. package/dist/ui/config-view.js.map +1 -1
  48. package/dist/ui/theme.d.ts +1 -1
  49. package/package.json +3 -2
  50. package/src/atomic-file.ts +86 -7
  51. package/src/client.test.ts +12 -0
  52. package/src/client.ts +72 -4
  53. package/src/commands.test.ts +891 -43
  54. package/src/commands.ts +1133 -332
  55. package/src/config-schema.ts +51 -9
  56. package/src/config.test.ts +6 -5
  57. package/src/config.ts +85 -16
  58. package/src/file-lock-process-worker.test.ts +64 -0
  59. package/src/file-lock.test.ts +279 -0
  60. package/src/file-lock.ts +318 -0
  61. package/src/harnesses.test.ts +48 -0
  62. package/src/harnesses.ts +59 -0
  63. package/src/hook-adapter.test.ts +187 -0
  64. package/src/hook-adapter.ts +212 -0
  65. package/src/hook-input.test.ts +39 -0
  66. package/src/hook-input.ts +72 -0
  67. package/src/hooks.test.ts +1318 -240
  68. package/src/hooks.ts +1046 -296
  69. package/src/install-hooks.test.ts +90 -41
  70. package/src/install-hooks.ts +259 -74
  71. package/src/interactive.ts +3 -3
  72. package/src/logging-process-worker.test.ts +39 -0
  73. package/src/logging.test.ts +453 -0
  74. package/src/logging.ts +639 -0
  75. package/src/main.ts +113 -32
  76. package/src/opencode-plugin.ts +15 -16
  77. package/src/session-end-log-lock-worker.test.ts +35 -0
  78. package/src/skill.test.ts +11 -0
  79. package/src/ui/config-view.test.ts +4 -2
  80. package/src/ui/config-view.ts +3 -0
package/src/main.ts CHANGED
@@ -12,14 +12,17 @@ import {
12
12
  configExplainCommand,
13
13
  configSetCommand,
14
14
  configShowCommand,
15
+ configUnsetCommand,
15
16
  devicesCommand,
16
17
  doctorCommand,
18
+ hookDefersDiagnosticsUntilAfterCleanup,
17
19
  hookRunCommand,
18
20
  hooksInstallCommand,
19
21
  hooksUninstallCommand,
20
22
  initCommand,
21
23
  loginCommand,
22
24
  logoutCommand,
25
+ logsCommand,
23
26
  realIo,
24
27
  repliesCommand,
25
28
  sendCommand,
@@ -32,42 +35,26 @@ import type { Platform } from '@raidiant/notifai-protocol'
32
35
  import { nativeSkills, type SkillScope } from './native-skills.js'
33
36
  import { GROUP, SEND_GROUP, helpConfiguration, rootHelpFooter } from './ui/help.js'
34
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()
50
+
35
51
  const deps: CommandDeps = {
36
52
  io: realIo(),
37
53
  store: defaultCredentialStore(),
38
54
  env: process.env,
39
55
  cwd: process.cwd(),
40
56
  nativeSkills,
41
- }
42
-
43
- /**
44
- * Harness hooks deliver their event payload on stdin. Bounded on both time and
45
- * size: a wrapper that opens the pipe without writing would otherwise hold the
46
- * read until the harness killed the hook, delaying the user's own prompt.
47
- */
48
- async function readStdin(timeoutMs = 2000, maxBytes = 1_000_000): Promise<string> {
49
- if (process.stdin.isTTY) return ''
50
- return new Promise((resolve) => {
51
- const chunks: Buffer[] = []
52
- let total = 0
53
- let settled = false
54
- const finish = (): void => {
55
- if (settled) return
56
- settled = true
57
- clearTimeout(timer)
58
- process.stdin.destroy()
59
- resolve(Buffer.concat(chunks).toString('utf8'))
60
- }
61
- const timer = setTimeout(finish, timeoutMs)
62
- timer.unref?.()
63
- process.stdin.on('data', (chunk: Buffer) => {
64
- total += chunk.length
65
- if (total > maxBytes) return finish()
66
- chunks.push(chunk)
67
- })
68
- process.stdin.on('end', finish)
69
- process.stdin.on('error', finish)
70
- })
57
+ logger,
71
58
  }
72
59
 
73
60
  /**
@@ -91,6 +78,39 @@ function version(): string {
91
78
  return 'unknown'
92
79
  }
93
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
+
94
114
  const program = new Command('notifai')
95
115
  .description('Send native device notifications from agents and local programs')
96
116
  .version(version())
@@ -111,6 +131,22 @@ const program = new Command('notifai')
111
131
  .helpCommand(true)
112
132
  // Where the implicit help command lands; every other command names its own.
113
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
+ })
114
150
 
115
151
  /**
116
152
  * Bare `notifai`.
@@ -411,7 +447,7 @@ program
411
447
  .action(async (event: string, opts: { harness?: string }) => {
412
448
  const harness = HARNESSES.find((candidate) => candidate === opts.harness)
413
449
  process.exit(
414
- await hookRunCommand(deps, event, readStdin, harness),
450
+ await hookRunCommand(deps, event, () => readStdinWithTimeout(), harness),
415
451
  )
416
452
  })
417
453
 
@@ -425,7 +461,7 @@ hooks
425
461
  .description('Wire this harness to route registered questions to your devices')
426
462
  .option(
427
463
  '--harness <name>',
428
- 'claude-code | codex | cursor | opencode (default: detected; OpenCode delivers answers on the next prompt)',
464
+ 'claude-code | codex | cursor | opencode (default: detected)',
429
465
  )
430
466
  .option('--global', 'install for every project instead of just this one')
431
467
  .action((opts: { harness?: string; global?: boolean }) => {
@@ -440,6 +476,34 @@ hooks
440
476
  process.exit(hooksUninstallCommand(deps, opts))
441
477
  })
442
478
 
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
+
443
507
  const config = program
444
508
  .command('config')
445
509
  .description('Show or change settings')
@@ -479,5 +543,22 @@ config
479
543
  process.exit(await configSetCommand(deps, key, value, opts))
480
544
  },
481
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
+ )
482
563
 
483
564
  await program.parseAsync(process.argv)
@@ -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 = 4
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
- execPath: string
55
- scriptPath: string
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 it has to
64
- * embed the absolute interpreter and script paths — a bare `notifai` on PATH
65
- * fails silently under a plugin host the same way it does under a hook shell.
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 { execPath, scriptPath, timeoutSeconds } = options
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 EXEC = ${JSON.stringify(execPath)}
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(EXEC, [SCRIPT, "hook", event, "--owner", "notifai", "--harness", "opencode"], {
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
- ): { exec: string; script: string; current: boolean; timeoutSeconds?: number } | null {
219
+ ): { adapter: string; current: boolean; timeoutSeconds?: number } | null {
220
220
  if (!isOurOpencodePlugin(contents)) return null
221
- const exec = /^const EXEC = (".*")$/m.exec(contents)?.[1]
222
- const script = /^const SCRIPT = (".*")$/m.exec(contents)?.[1]
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 (exec === undefined || script === undefined) return null
225
+ if (adapter === undefined && legacyScript === undefined) return null
226
226
  try {
227
227
  return {
228
- exec: JSON.parse(exec) as string,
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']) {
@@ -2,6 +2,7 @@ import { describe, expect, it } from 'vitest'
2
2
  import os from 'node:os'
3
3
  import path from 'node:path'
4
4
  import { CONFIG_KEYS, loadConfig } from '../config.js'
5
+ import { configInfo } from '../config-schema.js'
5
6
  import { renderConfigExplain, renderConfigList, renderConfigPlain } from './config-view.js'
6
7
  import { width } from './theme.js'
7
8
 
@@ -39,8 +40,9 @@ describe('renderConfigList', () => {
39
40
  it('names every non-advanced key and explains it', () => {
40
41
  const text = renderConfigList(config).join('\n')
41
42
  for (const key of CONFIG_KEYS) {
42
- const advanced = key === 'base_url' || key === 'collapse_key'
43
- if (advanced) continue
43
+ // Derived, not listed: a hardcoded set of advanced keys silently becomes
44
+ // an assertion about yesterday's key list the moment a key is added.
45
+ if (configInfo(key).advanced === true) continue
44
46
  expect(text, key).toContain(key)
45
47
  }
46
48
  expect(text).toContain('Questions & presence')
@@ -116,6 +116,9 @@ export function renderConfigExplain(key: ConfigKey, config: CliConfig): string[]
116
116
  lines.push('')
117
117
  const example = info.example ?? (info.choices?.[0] ?? 'value')
118
118
  lines.push(` ${style.dim('Change it')} ${style.code(`notifai config set ${key} ${example}`)}`)
119
+ if (entry.source !== 'default' && entry.source !== 'flag') {
120
+ lines.push(` ${style.dim('Inherit it')} ${style.code(`notifai config unset ${key}`)}`)
121
+ }
119
122
  lines.push('')
120
123
  return lines
121
124
  }