@raidiant/notifai 8.0.0 → 9.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.
Files changed (145) hide show
  1. package/CHANGELOG.md +41 -0
  2. package/README.md +20 -13
  3. package/dist/atomic-file.d.ts +14 -0
  4. package/dist/atomic-file.js +27 -2
  5. package/dist/atomic-file.js.map +1 -1
  6. package/dist/cli-bin.d.ts +4 -0
  7. package/dist/cli-bin.js +62 -0
  8. package/dist/cli-bin.js.map +1 -0
  9. package/dist/cli-release.d.ts +10 -0
  10. package/dist/cli-release.js +75 -0
  11. package/dist/cli-release.js.map +1 -0
  12. package/dist/client.d.ts +1 -0
  13. package/dist/client.js +13 -3
  14. package/dist/client.js.map +1 -1
  15. package/dist/commands-auth.d.ts +6 -0
  16. package/dist/commands-auth.js +28 -3
  17. package/dist/commands-auth.js.map +1 -1
  18. package/dist/commands-config.js +22 -3
  19. package/dist/commands-config.js.map +1 -1
  20. package/dist/commands-core.d.ts +12 -0
  21. package/dist/commands-core.js +27 -3
  22. package/dist/commands-core.js.map +1 -1
  23. package/dist/commands-doctor.d.ts +1 -0
  24. package/dist/commands-doctor.js +71 -15
  25. package/dist/commands-doctor.js.map +1 -1
  26. package/dist/commands-guidance.d.ts +7 -3
  27. package/dist/commands-guidance.js +63 -28
  28. package/dist/commands-guidance.js.map +1 -1
  29. package/dist/commands-hooks.d.ts +9 -4
  30. package/dist/commands-hooks.js +430 -32
  31. package/dist/commands-hooks.js.map +1 -1
  32. package/dist/commands-init.d.ts +10 -18
  33. package/dist/commands-init.js +114 -32
  34. package/dist/commands-init.js.map +1 -1
  35. package/dist/commands-send-support.d.ts +17 -2
  36. package/dist/commands-send-support.js +61 -5
  37. package/dist/commands-send-support.js.map +1 -1
  38. package/dist/commands-send.js +60 -11
  39. package/dist/commands-send.js.map +1 -1
  40. package/dist/commands-setup-proof.d.ts +8 -0
  41. package/dist/commands-setup-proof.js +31 -2
  42. package/dist/commands-setup-proof.js.map +1 -1
  43. package/dist/commands-skill.d.ts +11 -1
  44. package/dist/commands-skill.js +61 -11
  45. package/dist/commands-skill.js.map +1 -1
  46. package/dist/commands.d.ts +1 -1
  47. package/dist/commands.js.map +1 -1
  48. package/dist/config-schema.d.ts +1 -1
  49. package/dist/config-schema.js +25 -0
  50. package/dist/config-schema.js.map +1 -1
  51. package/dist/config.d.ts +24 -3
  52. package/dist/config.js +37 -5
  53. package/dist/config.js.map +1 -1
  54. package/dist/credentials.d.ts +10 -1
  55. package/dist/credentials.js +25 -12
  56. package/dist/credentials.js.map +1 -1
  57. package/dist/guidance-content.d.ts +20 -0
  58. package/dist/guidance-content.js +73 -8
  59. package/dist/guidance-content.js.map +1 -1
  60. package/dist/guidance.d.ts +21 -0
  61. package/dist/guidance.js +73 -18
  62. package/dist/guidance.js.map +1 -1
  63. package/dist/hook-adapter.js +37 -3
  64. package/dist/hook-adapter.js.map +1 -1
  65. package/dist/hooks.d.ts +68 -7
  66. package/dist/hooks.js +231 -85
  67. package/dist/hooks.js.map +1 -1
  68. package/dist/install-hooks.d.ts +10 -18
  69. package/dist/install-hooks.js +110 -23
  70. package/dist/install-hooks.js.map +1 -1
  71. package/dist/interactive.d.ts +1 -1
  72. package/dist/interactive.js +1 -1
  73. package/dist/interactive.js.map +1 -1
  74. package/dist/logging.d.ts +8 -1
  75. package/dist/logging.js +43 -2
  76. package/dist/logging.js.map +1 -1
  77. package/dist/main.js +2 -13
  78. package/dist/main.js.map +1 -1
  79. package/dist/native-skills.d.ts +19 -0
  80. package/dist/native-skills.js +30 -8
  81. package/dist/native-skills.js.map +1 -1
  82. package/dist/opencode-plugin.js +14 -1
  83. package/dist/opencode-plugin.js.map +1 -1
  84. package/dist/program.js +13 -8
  85. package/dist/program.js.map +1 -1
  86. package/dist/send.d.ts +8 -1
  87. package/dist/send.js +17 -1
  88. package/dist/send.js.map +1 -1
  89. package/dist/session-activation.d.ts +15 -0
  90. package/dist/session-activation.js +35 -0
  91. package/dist/session-activation.js.map +1 -0
  92. package/dist/url-policy.d.ts +85 -0
  93. package/dist/url-policy.js +369 -0
  94. package/dist/url-policy.js.map +1 -0
  95. package/package.json +3 -3
  96. package/src/atomic-file.ts +36 -2
  97. package/src/cli-bin.test.ts +38 -0
  98. package/src/cli-bin.ts +67 -0
  99. package/src/cli-release.test.ts +46 -0
  100. package/src/cli-release.ts +78 -0
  101. package/src/client.test.ts +95 -0
  102. package/src/client.ts +16 -3
  103. package/src/commands-auth.ts +32 -3
  104. package/src/commands-config.ts +25 -2
  105. package/src/commands-core.ts +38 -3
  106. package/src/commands-doctor.ts +99 -13
  107. package/src/commands-guidance.ts +75 -29
  108. package/src/commands-hooks.ts +503 -34
  109. package/src/commands-init.ts +139 -38
  110. package/src/commands-send-support.test.ts +156 -0
  111. package/src/commands-send-support.ts +67 -5
  112. package/src/commands-send.ts +73 -10
  113. package/src/commands-setup-proof.ts +36 -2
  114. package/src/commands-skill.ts +78 -15
  115. package/src/commands.test.ts +1083 -101
  116. package/src/commands.ts +1 -0
  117. package/src/config-schema.ts +28 -1
  118. package/src/config.test.ts +1 -1
  119. package/src/config.ts +49 -5
  120. package/src/credentials.test.ts +71 -3
  121. package/src/credentials.ts +29 -15
  122. package/src/guidance-content.ts +74 -8
  123. package/src/guidance.test.ts +42 -11
  124. package/src/guidance.ts +100 -18
  125. package/src/hook-adapter.test.ts +113 -1
  126. package/src/hook-adapter.ts +37 -3
  127. package/src/hooks.test.ts +746 -165
  128. package/src/hooks.ts +299 -124
  129. package/src/install-hooks.test.ts +135 -16
  130. package/src/install-hooks.ts +113 -25
  131. package/src/interactive.ts +1 -1
  132. package/src/logging.test.ts +66 -3
  133. package/src/logging.ts +45 -2
  134. package/src/main.ts +2 -14
  135. package/src/native-skills.test.ts +41 -1
  136. package/src/native-skills.ts +40 -6
  137. package/src/opencode-plugin.ts +14 -1
  138. package/src/program.test.ts +37 -2
  139. package/src/program.ts +40 -12
  140. package/src/send.test.ts +28 -0
  141. package/src/send.ts +28 -2
  142. package/src/session-activation.ts +45 -0
  143. package/src/skill.test.ts +120 -4
  144. package/src/trust-policy.test.ts +701 -0
  145. package/src/url-policy.ts +399 -0
package/src/main.ts CHANGED
@@ -2,7 +2,7 @@
2
2
  import { realIo, type CommandDeps } from './commands.js'
3
3
  import { defaultCredentialStore } from './credentials.js'
4
4
  import { nativeSkills } from './native-skills.js'
5
- import { bootstrapLogger } from './logging.js'
5
+ import { argvFlagNames, bootstrapLogger } from './logging.js'
6
6
  import { buildProgram } from './program.js'
7
7
 
8
8
  /**
@@ -24,24 +24,12 @@ const deps: CommandDeps = {
24
24
  logger,
25
25
  }
26
26
 
27
- /**
28
- * Which flags were passed, without their values.
29
- *
30
- * The flag names answer nearly every question worth asking of an invocation —
31
- * was `--reply` set, was `--all` — while the values are notification content
32
- * and user text that has no business being recorded merely because a command
33
- * ran. Values are available under `log_level = debug`, which is a deliberate act.
34
- */
35
- function flagNames(argv: readonly string[]): string[] {
36
- return argv.filter((token) => token.startsWith('--'))
37
- }
38
-
39
27
  const startedAt = Date.now()
40
28
  process.on('exit', (code) => {
41
29
  logger.info('cli.end', {
42
30
  exit: code,
43
31
  duration_ms: Date.now() - startedAt,
44
- flags: flagNames(process.argv.slice(2)),
32
+ flags: argvFlagNames(process.argv.slice(2)),
45
33
  })
46
34
  })
47
35
 
@@ -2,7 +2,7 @@ import { mkdirSync, mkdtempSync, writeFileSync } from 'node:fs'
2
2
  import os from 'node:os'
3
3
  import path from 'node:path'
4
4
  import { describe, expect, it } from 'vitest'
5
- import { nativeSkills } from './native-skills.js'
5
+ import { SKILLS_INSTALLER_SPEC, nativeSkills, skillsAddArgv, skillsRemoveArgv } from './native-skills.js'
6
6
 
7
7
  function writeLock(file: string, ref: string): void {
8
8
  mkdirSync(path.dirname(file), { recursive: true })
@@ -22,6 +22,46 @@ function writeLock(file: string, ref: string): void {
22
22
  )
23
23
  }
24
24
 
25
+ describe('skillsAddArgv', () => {
26
+ it('pins the installer package to an exact reviewed version', () => {
27
+ const argv = skillsAddArgv({
28
+ source: 'Raidiant-io/notifai#v8.0.0',
29
+ skill: 'notifai',
30
+ cwd: '/tmp',
31
+ env: {},
32
+ })
33
+ expect(SKILLS_INSTALLER_SPEC).toMatch(/^skills@\d+\.\d+\.\d+$/)
34
+ expect(argv).toEqual([
35
+ '-y',
36
+ SKILLS_INSTALLER_SPEC,
37
+ 'add',
38
+ 'Raidiant-io/notifai#v8.0.0',
39
+ '--skill',
40
+ 'notifai',
41
+ ])
42
+ expect(argv).not.toContain('skills')
43
+ })
44
+
45
+ it('uninstalls one skill in the named scope without a prompt', () => {
46
+ expect(
47
+ skillsRemoveArgv({
48
+ skill: 'notifai',
49
+ scope: 'project',
50
+ cwd: '/tmp',
51
+ env: {},
52
+ }),
53
+ ).toEqual(['-y', SKILLS_INSTALLER_SPEC, 'remove', 'notifai', '--yes'])
54
+ expect(
55
+ skillsRemoveArgv({
56
+ skill: 'notifai',
57
+ scope: 'global',
58
+ cwd: '/tmp',
59
+ env: {},
60
+ }),
61
+ ).toEqual(['-y', SKILLS_INSTALLER_SPEC, 'remove', 'notifai', '--global', '--yes'])
62
+ })
63
+ })
64
+
25
65
  describe('nativeSkills.list', () => {
26
66
  it('reads the project lock file instead of spawning npx', async () => {
27
67
  const cwd = mkdtempSync(path.join(os.tmpdir(), 'notifai-skills-project-'))
@@ -3,6 +3,14 @@ import { existsSync, readFileSync } from 'node:fs'
3
3
  import path from 'node:path'
4
4
  import { accountHome, npxLaunch } from './platform.js'
5
5
 
6
+ /**
7
+ * Exact reviewed version of the external `skills` installer.
8
+ *
9
+ * The GitHub skill source stays the immutable CLI version tag. The installer
10
+ * program that fetches it must not float on `latest`.
11
+ */
12
+ export const SKILLS_INSTALLER_SPEC = 'skills@1.5.23'
13
+
6
14
  /** The two scopes offered by the skills installer. */
7
15
  export type SkillScope = 'project' | 'global'
8
16
 
@@ -30,9 +38,18 @@ export interface SkillsAddOptions {
30
38
  env: NodeJS.ProcessEnv
31
39
  }
32
40
 
41
+ export interface SkillsRemoveOptions {
42
+ skill: string
43
+ scope: SkillScope
44
+ cwd: string
45
+ env: NodeJS.ProcessEnv
46
+ }
47
+
33
48
  export interface NativeSkills {
34
49
  /** Launch the native interactive `npx skills add` flow. */
35
50
  add(options: SkillsAddOptions): Promise<number>
51
+ /** Uninstall one installer-managed skill in one scope. */
52
+ remove(options: SkillsRemoveOptions): Promise<number>
36
53
  /** Read installer-managed inventory from lock files. Does not spawn npx. */
37
54
  list(scope: SkillScope, cwd: string, env: NodeJS.ProcessEnv): Promise<SkillsListResult>
38
55
  }
@@ -108,15 +125,32 @@ function run(args: string[], options: { cwd: string; env: NodeJS.ProcessEnv }):
108
125
  })
109
126
  }
110
127
 
128
+ /** argv for `npx`, including the pinned installer spec. */
129
+ export function skillsAddArgv(options: SkillsAddOptions): string[] {
130
+ const args = ['-y', SKILLS_INSTALLER_SPEC, 'add', options.source, '--skill', options.skill]
131
+ if (options.scope === 'global') args.push('--global')
132
+ // An explicit scope is the unattended contract. Native `--yes` keeps all
133
+ // remaining installer prompts non-interactive after the scope is chosen.
134
+ if (options.scope !== undefined) args.push('--yes')
135
+ return args
136
+ }
137
+
138
+ /** argv for uninstalling one skill in one installer scope. */
139
+ export function skillsRemoveArgv(options: SkillsRemoveOptions): string[] {
140
+ const args = ['-y', SKILLS_INSTALLER_SPEC, 'remove', options.skill]
141
+ if (options.scope === 'global') args.push('--global')
142
+ args.push('--yes')
143
+ return args
144
+ }
145
+
111
146
  /** The only process/filesystem adapter Notifai needs for the external installer. */
112
147
  export const nativeSkills: NativeSkills = {
113
148
  async add(options) {
114
- const args = ['-y', 'skills', 'add', options.source, '--skill', options.skill]
115
- if (options.scope === 'global') args.push('--global')
116
- // An explicit scope is the unattended contract. Native `--yes` keeps all
117
- // remaining installer prompts non-interactive after the scope is chosen.
118
- if (options.scope !== undefined) args.push('--yes')
119
- return run(args, { cwd: options.cwd, env: options.env })
149
+ return run(skillsAddArgv(options), { cwd: options.cwd, env: options.env })
150
+ },
151
+
152
+ async remove(options) {
153
+ return run(skillsRemoveArgv(options), { cwd: options.cwd, env: options.env })
120
154
  },
121
155
 
122
156
  async list(scope, cwd, env) {
@@ -1,6 +1,7 @@
1
1
  import path from 'node:path'
2
2
  import { hookHostPlatform, type HookHostPlatform } from './hook-adapter.js'
3
3
  import { configHome } from './install-hooks.js'
4
+ import { SESSION_ACTIVATION_CONTEXT } from './session-activation.js'
4
5
 
5
6
  /**
6
7
  * The OpenCode adapter.
@@ -31,7 +32,7 @@ export const OPENCODE_PLUGIN_MARKER = '// notifai managed opencode plugin'
31
32
  export const OPENCODE_PLUGIN_FILENAME = 'notifai.js'
32
33
 
33
34
  /** Bump when an installed generated file must be rewritten to remain functional. */
34
- const OPENCODE_ADAPTER_VERSION = 8
35
+ const OPENCODE_ADAPTER_VERSION = 10
35
36
 
36
37
  export function opencodePluginDir(
37
38
  global: boolean,
@@ -103,6 +104,7 @@ import { spawn } from "node:child_process"
103
104
  ${nodeConstant}const ADAPTER = ${JSON.stringify(adapterPath)}
104
105
  const TIMEOUT_MS = ${timeoutSeconds * 1000}
105
106
  const ADAPTER_VERSION = ${OPENCODE_ADAPTER_VERSION}
107
+ const SESSION_ACTIVATION_CONTEXT = ${JSON.stringify(SESSION_ACTIVATION_CONTEXT)}
106
108
  const PENDING_SESSION_TITLE =
107
109
  /^New session(?: *- *[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}(?:[.][0-9]+)?Z)?$/i
108
110
 
@@ -158,6 +160,17 @@ export const NotifAIPlugin = async ({ directory, client }) => {
158
160
  const cwd = typeof directory === "string" && directory.length > 0 ? directory : process.cwd()
159
161
 
160
162
  return {
163
+ /** Model-visible activation, independent of CLI setup and delivery state. */
164
+ "experimental.chat.system.transform": async (input, output) => {
165
+ const sessionID = input?.sessionID
166
+ if (typeof sessionID !== "string" || sessionID.length === 0) return
167
+ if (output.system.length === 0) {
168
+ output.system.push(SESSION_ACTIVATION_CONTEXT)
169
+ } else {
170
+ output.system[0] = output.system[0] + "\\n\\n" + SESSION_ACTIVATION_CONTEXT
171
+ }
172
+ },
173
+
161
174
  /** Exact active-harness identity and first-party title for agent shell commands. */
162
175
  "shell.env": async (input, output) => {
163
176
  output.env.NOTIFAI_ACTIVE_HARNESS = "opencode"
@@ -82,7 +82,6 @@ describe('program argv parsing', () => {
82
82
  '--multi',
83
83
  '--reply',
84
84
  '--reply-timeout', '30',
85
- '--event', 'tests_passed',
86
85
  ],
87
86
  { send: (async (_deps, flags) => ((seen = flags as Record<string, unknown>), 0)) as ProgramRunners['send'] },
88
87
  )
@@ -95,7 +94,6 @@ describe('program argv parsing', () => {
95
94
  multi: true,
96
95
  reply: true,
97
96
  replyTimeout: 30,
98
- event: 'tests_passed',
99
97
  noWait: false,
100
98
  })
101
99
  })
@@ -113,6 +111,16 @@ describe('program argv parsing', () => {
113
111
  expect(seen).not.toHaveProperty('wait')
114
112
  })
115
113
 
114
+ it('maps init --setup-scope onto the command flags', async () => {
115
+ let seen: Record<string, unknown> | undefined
116
+ const { exitCode } = await parse(
117
+ ['init', '--skills', '--setup-scope', 'global', '--no-hooks'],
118
+ { init: (async (_deps, flags) => ((seen = flags as Record<string, unknown>), 0)) as ProgramRunners['init'] },
119
+ )
120
+ expect(exitCode).toBe(0)
121
+ expect(seen).toMatchObject({ skills: true, setupScope: 'global', hooks: false })
122
+ })
123
+
116
124
  it('disentangles --no-wait and --wait from their shared commander flag', async () => {
117
125
  const capture = (sink: Record<string, unknown>[]): ProgramRunners['send'] =>
118
126
  (async (_deps, flags) => (sink.push(flags as Record<string, unknown>), 0)) as ProgramRunners['send']
@@ -191,6 +199,15 @@ describe('program argv parsing', () => {
191
199
  expect(deps.errLines.join('\n')).toContain('send')
192
200
  })
193
201
 
202
+ it('rejects send --event now that the public event field is gone', async () => {
203
+ const { exitCode, deps } = await parse(
204
+ ['send', '--kind', 'done', '--title', 'T', '--body', 'B', '--event', 'tests_passed'],
205
+ { send: (async () => 0) as ProgramRunners['send'] },
206
+ )
207
+ expect(exitCode).toBe(2)
208
+ expect(deps.errLines).toHaveLength(0)
209
+ })
210
+
194
211
  it('drops the empty --event collector for logs', async () => {
195
212
  let seen: Record<string, unknown> | undefined
196
213
  const logs = ((_deps, flags) => ((seen = flags as Record<string, unknown>), 0)) as ProgramRunners['logs']
@@ -199,4 +216,22 @@ describe('program argv parsing', () => {
199
216
  await parse(['logs', '--event', 'send.submitted'], { logs })
200
217
  expect(seen).toMatchObject({ event: ['send.submitted'] })
201
218
  })
219
+
220
+ it('passes close --pending without inventing a request id', async () => {
221
+ let seenId: string | undefined
222
+ let seenFlags: Record<string, unknown> | undefined
223
+ const close = (async (_deps, requestId, flags) => {
224
+ seenId = requestId
225
+ seenFlags = flags as Record<string, unknown>
226
+ return 0
227
+ }) as ProgramRunners['close']
228
+
229
+ await parse(['close', '--pending', '--json'], { close })
230
+ expect(seenId).toBeUndefined()
231
+ expect(seenFlags).toEqual({ pending: true, json: true })
232
+
233
+ await parse(['close', 'req_abc'], { close })
234
+ expect(seenId).toBe('req_abc')
235
+ expect(seenFlags).toEqual({})
236
+ })
202
237
  })
package/src/program.ts CHANGED
@@ -35,6 +35,7 @@ import type { Platform } from '@raidiant/notifai-protocol'
35
35
  import type { SkillScope } from './native-skills.js'
36
36
  import { GROUP, SEND_GROUP, helpConfiguration, rootHelpFooter } from './ui/help.js'
37
37
  import { readStdinWithTimeout } from './hook-input.js'
38
+ import { argvFlagNames } from './logging.js'
38
39
 
39
40
  /**
40
41
  * One source of truth for the version: the manifest npm actually published.
@@ -149,7 +150,7 @@ export function buildProgram(deps: CommandDeps, options: BuildProgramOptions = {
149
150
  if (!deferDiagnostics) {
150
151
  logger?.debug('cli.start', {
151
152
  version: version(),
152
- argv: process.argv.slice(2),
153
+ flags: argvFlagNames(process.argv.slice(2)),
153
154
  cwd: process.cwd(),
154
155
  })
155
156
  }
@@ -217,12 +218,24 @@ export function buildProgram(deps: CommandDeps, options: BuildProgramOptions = {
217
218
  .option('--project-id <id>', 'project identifier slug (default: derived from the directory name)')
218
219
  .option('--skills', 'install/update the agent skill from its pinned public release')
219
220
  .option('--no-skills', 'suppress the optional agent-skill status line')
220
- .option('--skills-scope <scope>', 'unattended skill scope: project or global')
221
+ .option(
222
+ '--setup-scope <scope>',
223
+ 'unattended setup scope: project or global (skill, hooks, and config). The CLI binary is always a global install',
224
+ )
225
+ .option('--skills-scope <scope>', 'unattended skill scope; same values as --setup-scope')
221
226
  .option('--hooks', 'install harness hooks for registered-question routing')
222
227
  .option('--no-hooks', 'skip the hooks without being asked')
223
- .action(async (opts: { projectId?: string; skills?: boolean; skillsScope?: SkillScope; hooks?: boolean }) => {
224
- exit(await runners.init(deps, opts))
225
- })
228
+ .action(
229
+ async (opts: {
230
+ projectId?: string
231
+ skills?: boolean
232
+ setupScope?: SkillScope
233
+ skillsScope?: SkillScope
234
+ hooks?: boolean
235
+ }) => {
236
+ exit(await runners.init(deps, opts))
237
+ },
238
+ )
226
239
 
227
240
  program
228
241
  .command('doctor')
@@ -240,7 +253,7 @@ export function buildProgram(deps: CommandDeps, options: BuildProgramOptions = {
240
253
  .summary('Sign in and pair this machine')
241
254
  .description('Pair this machine with your Notifai account via browser approval')
242
255
  .option('--name <name>', 'machine name shown in the dashboard (default: hostname)')
243
- .option('--base-url <url>', 'developer override for the service origin (also NOTIFAI_BASE_URL)')
256
+ .option('--base-url <url>', 'pairing override for the service origin (also NOTIFAI_BASE_URL)')
244
257
  .option('--no-open', 'do not open the approval page in a browser')
245
258
  .action(async (opts: { name?: string; baseUrl?: string; open?: boolean }) => {
246
259
  exit(await runners.login(deps, opts))
@@ -306,6 +319,10 @@ export function buildProgram(deps: CommandDeps, options: BuildProgramOptions = {
306
319
  .requiredOption('--title <title>', 'brief title whose substance is immediately understandable')
307
320
  .option('--body <markdown>', 'canonical Markdown body')
308
321
  .option('--body-file <path>', 'read the canonical Markdown body from a file (use - for stdin)')
322
+ .option(
323
+ '--literal-backslash-n',
324
+ 'allow the two-character sequence \\n in --body instead of treating it as a mistaken escaped newline',
325
+ )
309
326
  .option('--subtitle <subtitle>', 'a plain-text line between the title and body')
310
327
  .option(
311
328
  '--image <path|url|media_id>',
@@ -318,7 +335,7 @@ export function buildProgram(deps: CommandDeps, options: BuildProgramOptions = {
318
335
  (v: string, all: string[] = []) => [...all, v],
319
336
  )
320
337
  .optionsGroup(SEND_GROUP.routing)
321
- .option('--kind <kind>', 'what this is (required): update | done | failed | blocked — question is set by --reply')
338
+ .option('--kind <kind>', 'what this is (required): update | done | failed | blocked (no User reply would resume it) — question is set by --reply')
322
339
  .option('--project <id>', 'project identifier override (otherwise configured or inferred)')
323
340
  .option('--device <id>', 'target a device id (repeatable)', (v: string, all: string[] = []) => [...all, v])
324
341
  .option('--all', 'target all routable devices (overrides configured devices)')
@@ -327,7 +344,6 @@ export function buildProgram(deps: CommandDeps, options: BuildProgramOptions = {
327
344
  '--session-label <text>',
328
345
  'first human session name, frozen locally (env: NOTIFAI_SESSION_LABEL)',
329
346
  )
330
- .option('--event <event>', 'agent event name, e.g. tests_passed')
331
347
  .optionsGroup(SEND_GROUP.presentation)
332
348
  .option('--collapse-key <key>', 'replace earlier notifications with the same key')
333
349
  .option('--thread-id <id>', 'group related notifications')
@@ -357,7 +373,10 @@ export function buildProgram(deps: CommandDeps, options: BuildProgramOptions = {
357
373
  .option('--no-wait', 'return immediately after acceptance')
358
374
  .option('--data <key=value>', 'custom data (repeatable)', (v: string, all: string[] = []) => [...all, v])
359
375
  .option('--idempotency-key <key>', 'safe-retry key (default: random)')
360
- .option('--base-url <url>', 'developer override for the service origin (also NOTIFAI_BASE_URL)')
376
+ .option(
377
+ '--base-url <url>',
378
+ 'pairing override for the service origin; ignored when this machine is already paired (also NOTIFAI_BASE_URL)',
379
+ )
361
380
  .option('--json', 'print the full submission receipt as JSON')
362
381
  .action(async (opts: Record<string, unknown>) => {
363
382
  // commander maps --no-wait onto the same "wait" flag; disentangle.
@@ -443,6 +462,10 @@ export function buildProgram(deps: CommandDeps, options: BuildProgramOptions = {
443
462
  .option('--json', 'machine-readable output')
444
463
  .option('--body <markdown>', 'optional Markdown context appended after the question block')
445
464
  .option('--body-file <path>', 'read optional Markdown context from a file (use - for stdin)')
465
+ .option(
466
+ '--literal-backslash-n',
467
+ 'allow the two-character sequence \\n in --body instead of treating it as a mistaken escaped newline',
468
+ )
446
469
  .option('--form <path>', 'ask several questions as one form; JSON file (use - for stdin)')
447
470
  .option(
448
471
  '--image <path|url|media_id>',
@@ -466,6 +489,7 @@ export function buildProgram(deps: CommandDeps, options: BuildProgramOptions = {
466
489
  json?: boolean
467
490
  body?: string
468
491
  bodyFile?: string
492
+ literalBackslashN?: boolean
469
493
  form?: string
470
494
  image?: string[]
471
495
  imageAlt?: string[]
@@ -507,17 +531,21 @@ export function buildProgram(deps: CommandDeps, options: BuildProgramOptions = {
507
531
  ...(opts.sessionId !== undefined ? { sessionId: opts.sessionId } : {}),
508
532
  ...(opts.sessionLabel !== undefined ? { sessionLabel: opts.sessionLabel } : {}),
509
533
  ...(opts.json === true ? { json: true } : {}),
534
+ ...(opts.literalBackslashN === true ? { literalBackslashN: true } : {}),
510
535
  }
511
536
  exit(await runners.ask(deps, question, flags))
512
537
  })
513
538
 
514
539
  program
515
- .command('close <request_id>')
540
+ .command('close [request_id]')
516
541
  .helpGroup(GROUP.agent)
517
542
  .summary('Retire a question so late answers are rejected')
518
- .description('Retire a question so late answers are rejected rather than lost')
543
+ .description(
544
+ 'Retire a question so late answers are rejected rather than lost. Pass --pending to withdraw this session\'s outstanding registrations, including ones the turn-end hook has not pushed yet.',
545
+ )
546
+ .option('--pending', 'retire this session\'s outstanding questions, including ones not yet pushed')
519
547
  .option('--json', 'machine-readable output')
520
- .action(async (requestId: string, opts: { json?: boolean }) => {
548
+ .action(async (requestId: string | undefined, opts: { json?: boolean; pending?: boolean }) => {
521
549
  exit(await runners.close(deps, requestId, opts))
522
550
  })
523
551
 
@@ -0,0 +1,28 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import {
3
+ accidentalEscapedNewlineMessage,
4
+ hasAccidentalEscapedNewlines,
5
+ rejectAccidentalEscapedNewlines,
6
+ } from './send.js'
7
+
8
+ describe('accidental escaped newlines', () => {
9
+ it('catches a shell-quoted backslash-n body', () => {
10
+ expect(hasAccidentalEscapedNewlines('line1\\nline2')).toBe(true)
11
+ expect(rejectAccidentalEscapedNewlines('line1\\nline2', false)).toBe(
12
+ accidentalEscapedNewlineMessage(),
13
+ )
14
+ })
15
+
16
+ it('preserves real multiline Markdown', () => {
17
+ expect(hasAccidentalEscapedNewlines('line1\nline2')).toBe(false)
18
+ expect(rejectAccidentalEscapedNewlines('line1\nline2', false)).toBeNull()
19
+ })
20
+
21
+ it('lets an explicit flag keep visible backslash-n text', () => {
22
+ expect(rejectAccidentalEscapedNewlines('use \\n in the sample', true)).toBeNull()
23
+ })
24
+
25
+ it('does not treat an escaped backslash plus n as accidental', () => {
26
+ expect(hasAccidentalEscapedNewlines('escape as \\\\n')).toBe(false)
27
+ })
28
+ })
package/src/send.ts CHANGED
@@ -31,7 +31,6 @@ export interface SendFlags {
31
31
  /** The one canonical Markdown body. */
32
32
  body: string
33
33
  subtitle?: string
34
- event?: string
35
34
  /** What this notification is; absent means update. */
36
35
  kind?: string
37
36
  project?: string
@@ -80,6 +79,34 @@ export interface SendFlags {
80
79
  * questions and their retirements ride the wire as what they are.
81
80
  */
82
81
  lifecycle?: LifecycleT
82
+ /**
83
+ * Allow the two-character sequence backslash-n to be sent as visible text.
84
+ * Without this, a likely accidental escaped newline is refused.
85
+ */
86
+ literalBackslashN?: boolean
87
+ }
88
+
89
+ /** A backslash-n pair that was not itself escaped as a literal backslash. */
90
+ const ACCIDENTAL_ESCAPED_NEWLINE = /(?:^|[^\\])\\n/
91
+
92
+ export function hasAccidentalEscapedNewlines(body: string): boolean {
93
+ return ACCIDENTAL_ESCAPED_NEWLINE.test(body)
94
+ }
95
+
96
+ export function accidentalEscapedNewlineMessage(): string {
97
+ return [
98
+ 'This body contains the two-character sequence \\n, which is sent literally and shows as \\n on the device.',
99
+ 'To send a real line break, pass a multiline --body or --body-file -.',
100
+ 'If you meant the visible characters \\n, pass --literal-backslash-n.',
101
+ ].join('\n')
102
+ }
103
+
104
+ export function rejectAccidentalEscapedNewlines(
105
+ body: string | undefined,
106
+ allowLiteral: boolean | undefined,
107
+ ): string | null {
108
+ if (allowLiteral === true || body === undefined) return null
109
+ return hasAccidentalEscapedNewlines(body) ? accidentalEscapedNewlineMessage() : null
83
110
  }
84
111
 
85
112
  export type DraftBuild =
@@ -309,7 +336,6 @@ export function buildDraft(
309
336
 
310
337
  const draft: NotificationDraftT = {
311
338
  schema_version: NOTIFICATION_SCHEMA_VERSION,
312
- ...(flags.event !== undefined ? { event: flags.event } : {}),
313
339
  ...(flags.kind !== undefined
314
340
  ? { kind: flags.kind as (typeof NOTIFICATION_KINDS)[number] }
315
341
  : {}),
@@ -0,0 +1,45 @@
1
+ import type { HookHarness } from './hooks.js'
2
+
3
+ /**
4
+ * Model-visible session bootstrap. It names the installed capability and its
5
+ * authoritative sources without restating any notification policy that the
6
+ * user may have overridden.
7
+ */
8
+ export const SESSION_ACTIVATION_CONTEXT =
9
+ 'Notifai is active for this session, even when the user did not mention it. ' +
10
+ 'Before beginning task work, use the Notifai skill and run `notifai guidance`; its resolved guidance governs notification decisions for Agent Events. ' +
11
+ 'Missing skill or CLI readiness is handled through `notifai init` and its diagnosis. ' +
12
+ 'For delegated work, the parent owns User-visible Notification Requests unless it explicitly delegates that ownership; workers report Agent Events to the parent and do not send independently.'
13
+
14
+ /** One lifecycle meaning, encoded for each harness output contract. */
15
+ export function sessionActivationOutput(
16
+ harness: HookHarness | undefined,
17
+ hookEventName: 'SessionStart' | 'SubagentStart' | 'UserPromptSubmit',
18
+ ): string | undefined {
19
+ if (harness === 'cursor') {
20
+ return JSON.stringify({ additional_context: SESSION_ACTIVATION_CONTEXT })
21
+ }
22
+ if (harness === 'claude-code' || harness === 'codex') {
23
+ return JSON.stringify({
24
+ hookSpecificOutput: {
25
+ hookEventName,
26
+ additionalContext: SESSION_ACTIVATION_CONTEXT,
27
+ },
28
+ })
29
+ }
30
+ return undefined
31
+ }
32
+
33
+ /**
34
+ * Cursor currently accepts sessionStart context without reliably delivering it
35
+ * to the model. Its native post-Stop follow-up is the narrow fallback and is
36
+ * claimed once per conversation by the hook-state layer.
37
+ */
38
+ export function cursorStopActivationOutput(): string {
39
+ return JSON.stringify({
40
+ followup_message: SESSION_ACTIVATION_CONTEXT.replace(
41
+ 'Before beginning task work,',
42
+ 'Before finalizing the Agent Event from the turn that just ended,',
43
+ ),
44
+ })
45
+ }