@raidiant/notifai 9.2.2 → 9.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (105) hide show
  1. package/CHANGELOG.md +19 -0
  2. package/README.md +8 -5
  3. package/dist/claude-wake.js +11 -0
  4. package/dist/claude-wake.js.map +1 -1
  5. package/dist/codex-wake.js +9 -0
  6. package/dist/codex-wake.js.map +1 -1
  7. package/dist/commands-core.d.ts +0 -3
  8. package/dist/commands-core.js.map +1 -1
  9. package/dist/commands-doctor.js +41 -2
  10. package/dist/commands-doctor.js.map +1 -1
  11. package/dist/commands-hook-shape.d.ts +4 -3
  12. package/dist/commands-hook-shape.js +19 -10
  13. package/dist/commands-hook-shape.js.map +1 -1
  14. package/dist/commands-hooks.js +64 -13
  15. package/dist/commands-hooks.js.map +1 -1
  16. package/dist/commands-init.js +12 -1
  17. package/dist/commands-init.js.map +1 -1
  18. package/dist/commands-project.d.ts +4 -0
  19. package/dist/commands-project.js +41 -0
  20. package/dist/commands-project.js.map +1 -0
  21. package/dist/commands-send-support.js +1 -22
  22. package/dist/commands-send-support.js.map +1 -1
  23. package/dist/commands-send.js +10 -4
  24. package/dist/commands-send.js.map +1 -1
  25. package/dist/commands-skill.js +58 -0
  26. package/dist/commands-skill.js.map +1 -1
  27. package/dist/commands.d.ts +1 -0
  28. package/dist/commands.js +1 -0
  29. package/dist/commands.js.map +1 -1
  30. package/dist/config-schema.js +2 -2
  31. package/dist/config-schema.js.map +1 -1
  32. package/dist/config.d.ts +3 -3
  33. package/dist/config.js +4 -7
  34. package/dist/config.js.map +1 -1
  35. package/dist/guidance-content.d.ts +3 -0
  36. package/dist/guidance-content.js +20 -0
  37. package/dist/guidance-content.js.map +1 -1
  38. package/dist/guidance-render.js +3 -3
  39. package/dist/guidance-render.js.map +1 -1
  40. package/dist/harnesses.js +2 -2
  41. package/dist/harnesses.js.map +1 -1
  42. package/dist/hooks.d.ts +26 -50
  43. package/dist/hooks.js +391 -150
  44. package/dist/hooks.js.map +1 -1
  45. package/dist/install-hooks.d.ts +7 -28
  46. package/dist/install-hooks.js +18 -37
  47. package/dist/install-hooks.js.map +1 -1
  48. package/dist/program.d.ts +4 -1
  49. package/dist/program.js +19 -1
  50. package/dist/program.js.map +1 -1
  51. package/dist/project-enablement.d.ts +15 -0
  52. package/dist/project-enablement.js +87 -0
  53. package/dist/project-enablement.js.map +1 -0
  54. package/dist/question-timing.d.ts +46 -0
  55. package/dist/question-timing.js +48 -0
  56. package/dist/question-timing.js.map +1 -0
  57. package/dist/readiness.d.ts +7 -0
  58. package/dist/readiness.js.map +1 -1
  59. package/dist/send.d.ts +2 -0
  60. package/dist/send.js +6 -1
  61. package/dist/send.js.map +1 -1
  62. package/dist/session-activation.d.ts +1 -1
  63. package/dist/session-activation.js +3 -4
  64. package/dist/session-activation.js.map +1 -1
  65. package/package.json +2 -2
  66. package/src/claude-wake.test.ts +1 -0
  67. package/src/claude-wake.ts +10 -0
  68. package/src/codex-wake.test.ts +1 -0
  69. package/src/codex-wake.ts +9 -0
  70. package/src/commands-core.ts +0 -3
  71. package/src/commands-doctor.ts +48 -4
  72. package/src/commands-hook-shape.ts +26 -10
  73. package/src/commands-hooks.ts +59 -13
  74. package/src/commands-init.ts +11 -1
  75. package/src/commands-project.ts +47 -0
  76. package/src/commands-send-support.ts +1 -27
  77. package/src/commands-send.ts +8 -3
  78. package/src/commands-skill.test.ts +44 -0
  79. package/src/commands-skill.ts +57 -0
  80. package/src/commands.test.ts +47 -17
  81. package/src/commands.ts +5 -0
  82. package/src/config-schema.ts +2 -2
  83. package/src/config.test.ts +2 -3
  84. package/src/config.ts +7 -10
  85. package/src/guidance-content.ts +22 -0
  86. package/src/guidance-render.ts +3 -3
  87. package/src/harnesses.ts +2 -2
  88. package/src/hook-adapter.test.ts +27 -8
  89. package/src/hooks.test.ts +808 -80
  90. package/src/hooks.ts +525 -181
  91. package/src/install-hooks.test.ts +8 -12
  92. package/src/install-hooks.ts +21 -37
  93. package/src/program.test.ts +18 -0
  94. package/src/program.ts +22 -0
  95. package/src/project-enablement.test.ts +55 -0
  96. package/src/project-enablement.ts +99 -0
  97. package/src/question-timing.ts +56 -0
  98. package/src/readiness.ts +8 -1
  99. package/src/send.ts +8 -1
  100. package/src/session-activation.ts +3 -4
  101. package/dist/orca-session-title.d.ts +0 -10
  102. package/dist/orca-session-title.js +0 -99
  103. package/dist/orca-session-title.js.map +0 -1
  104. package/src/orca-session-title.test.ts +0 -95
  105. package/src/orca-session-title.ts +0 -105
@@ -13,6 +13,7 @@ import os from 'node:os'
13
13
  import path from 'node:path'
14
14
  import { parse as parseToml, stringify as stringifyToml } from 'smol-toml'
15
15
  import { describe, expect, it } from 'vitest'
16
+ import { REPLY_MAX_WINDOW_SECONDS } from '@raidiant/notifai-protocol'
16
17
  import { parseChoices } from './send.js'
17
18
  import {
18
19
  OPENCODE_PLUGIN_MARKER,
@@ -27,7 +28,7 @@ import {
27
28
  } from './session-activation.js'
28
29
  import { hookAdapterPath, installHookAdapter } from './hook-adapter.js'
29
30
  import {
30
- BLOCKING_STOP_TIMEOUT_SECONDS,
31
+ QUESTION_STOP_TIMEOUT_SECONDS,
31
32
  HARNESSES,
32
33
  applyPlan,
33
34
  detectHarness,
@@ -152,18 +153,13 @@ describe('hook config', () => {
152
153
  ).toBe(true)
153
154
  })
154
155
 
155
- it('lets Codex own Stop timeout while keeping short fixed lifecycle budgets', () => {
156
+ it('gives Codex a Stop timeout beyond the longest answer window', () => {
156
157
  const codex = buildHookConfig({ adapterPath: ADAPTER, harness: 'codex' })
157
158
 
158
159
  expect(codex['UserPromptSubmit']?.[0]?.hooks[0]?.timeout).toBe(15)
159
160
  expect(codex['SessionEnd']?.[0]?.hooks[0]?.timeout).toBe(3)
160
- // Codex hashes the exact serialized definition into `trusted_hash`, so its
161
- // Stop handler stays a bare blocking command: declaring a timeout would
162
- // invalidate the user's approval and the handler would silently stop.
163
- expect(codex['Stop']?.[0]?.hooks[0]).toEqual({
164
- type: 'command',
165
- command: hookCommand(ADAPTER, 'stop', 'codex'),
166
- })
161
+ expect(codex['Stop']?.[0]?.hooks[0]?.timeout).toBe(QUESTION_STOP_TIMEOUT_SECONDS)
162
+ expect(QUESTION_STOP_TIMEOUT_SECONDS).toBeGreaterThan(REPLY_MAX_WINDOW_SECONDS)
167
163
  })
168
164
 
169
165
  it('gives Claude Code an asynchronous Stop with an explicit waiter budget on POSIX', () => {
@@ -176,7 +172,7 @@ describe('hook config', () => {
176
172
  expect(claude['Stop']?.[0]?.hooks[0]).toEqual({
177
173
  type: 'command',
178
174
  command: hookCommand(ADAPTER, 'stop', 'claude-code'),
179
- timeout: 3600,
175
+ timeout: QUESTION_STOP_TIMEOUT_SECONDS,
180
176
  async: true,
181
177
  })
182
178
  expect(claude['UserPromptSubmit']?.[0]?.hooks[0]?.async).toBeUndefined()
@@ -195,7 +191,7 @@ describe('hook config', () => {
195
191
  expect(claude['Stop']?.[0]?.hooks[0]).toEqual({
196
192
  type: 'command',
197
193
  command: hookCommand(options.adapterPath, 'stop', 'claude-code', options),
198
- timeout: BLOCKING_STOP_TIMEOUT_SECONDS,
194
+ timeout: QUESTION_STOP_TIMEOUT_SECONDS,
199
195
  })
200
196
  })
201
197
 
@@ -213,7 +209,7 @@ describe('hook config', () => {
213
209
  expect(groups.flatMap((group) => group.hooks)).toHaveLength(1)
214
210
  }
215
211
  expect(migrated.document.hooks?.['Stop']?.[0]?.hooks[0]).toMatchObject({
216
- timeout: 3600,
212
+ timeout: QUESTION_STOP_TIMEOUT_SECONDS,
217
213
  async: true,
218
214
  })
219
215
  expect(migrated.replaced.sort()).toEqual([
@@ -22,6 +22,10 @@ import {
22
22
  import { opencodePluginPath, opencodePluginTarget } from './opencode-plugin.js'
23
23
  import { HARNESSES, type Harness } from './harnesses.js'
24
24
  import { accountHome } from './platform.js'
25
+ import {
26
+ NON_ROUTING_STOP_TIMEOUT_SECONDS,
27
+ QUESTION_STOP_TIMEOUT_SECONDS,
28
+ } from './question-timing.js'
25
29
 
26
30
  export { HARNESSES, type Harness } from './harnesses.js'
27
31
 
@@ -177,40 +181,18 @@ export interface BuildOptions {
177
181
  }
178
182
 
179
183
  /**
180
- * Claude Code's Stop handler returns instantly and the same process lives on
181
- * out of band as the escalation waiter, so this `timeout` bounds a background
182
- * process rather than the user's turn.
183
- *
184
- * It is always declared, and declared far above the longest wait a waiter can
185
- * take. Claude's default is 600 s and the kill is **silent** — the backgrounded
186
- * process simply vanishes, no error is reported anywhere, and the answer the
187
- * user already gave is never delivered. Sitting anywhere near that boundary
188
- * turns a slow reply into a lost one, so this does not: an explicit value
189
- * raises the ceiling (verified against a 660 s wait under an hour-long
190
- * declaration), and an hour is several times the waiter's own ceiling.
184
+ * Harnesses without asynchronous Question Routing use this short Stop budget.
185
+ * Claude Code and Codex use `QUESTION_STOP_TIMEOUT_SECONDS` instead.
191
186
  */
192
- export const CLAUDE_ASYNC_STOP_TIMEOUT_SECONDS = 3600
193
-
194
- /**
195
- * The declared process budget for a host that holds its turn while the waiter
196
- * runs: the waiter's own ceiling plus a minute of host headroom, as one plain
197
- * number rather than a budget carved into reserves.
198
- *
199
- * Codex is deliberately absent from this. It owns its Stop timeout today, and
200
- * declaring one would change the serialized definition Codex hashes into
201
- * `trusted_hash` — which silently stops every Notifai handler from running
202
- * until the user re-approves them in `/hooks`.
203
- */
204
- export const BLOCKING_STOP_TIMEOUT_SECONDS = 540
187
+ export const NON_ROUTING_BLOCKING_STOP_TIMEOUT_SECONDS = NON_ROUTING_STOP_TIMEOUT_SECONDS
188
+ export { QUESTION_STOP_TIMEOUT_SECONDS } from './question-timing.js'
205
189
 
206
190
  /**
207
191
  * Whether this harness's Stop handler runs detached from the turn.
208
192
  *
209
- * Three places decide this — the installer that declares `async: true`, the
210
- * hook runtime that picks the waiter's wall clock, and doctor's check that the
211
- * installed definition still has the right shape — and they must agree or the
212
- * waiter outlives a turn nobody left open. They were three separate spellings
213
- * of the same condition with nothing tying them together.
193
+ * The installer and doctor share this predicate so they agree whether Claude
194
+ * runs out of band. Owner lifetime is route-neutral and lives in
195
+ * `question-timing.ts`.
214
196
  */
215
197
  export function stopHandlerIsDetached(
216
198
  harness: Harness | undefined,
@@ -224,10 +206,10 @@ export function stopHandlerIsDetached(
224
206
  *
225
207
  * Claude Code takes the answer over its own inbox socket, so its Stop hook is
226
208
  * `async: true`: it returns immediately, the terminal is never held, and the
227
- * waiter finishes out of band. Every other host still delivers by printing a
228
- * continuation to this process's stdout, so its Stop hook blocks and needs a
229
- * declared ceiling above the wait — except Codex, which owns that number
230
- * itself and whose definition must stay byte-stable for its trust store.
209
+ * waiter finishes out of band. Codex and Claude on Windows block and print a
210
+ * continuation to stdout. Every Question Routing owner declares the same
211
+ * complete-window timeout; changing Codex's definition requires one explicit
212
+ * trust approval.
231
213
  */
232
214
  function stopHandler(
233
215
  adapterPath: string,
@@ -236,10 +218,12 @@ function stopHandler(
236
218
  ): HookHandler {
237
219
  const command = hookCommand(adapterPath, 'stop', harness, options)
238
220
  if (stopHandlerIsDetached(harness, options.platform)) {
239
- return { type: 'command', command, timeout: CLAUDE_ASYNC_STOP_TIMEOUT_SECONDS, async: true }
221
+ return { type: 'command', command, timeout: QUESTION_STOP_TIMEOUT_SECONDS, async: true }
222
+ }
223
+ if (harness === 'codex' || harness === 'claude-code') {
224
+ return { type: 'command', command, timeout: QUESTION_STOP_TIMEOUT_SECONDS }
240
225
  }
241
- if (harness === 'codex') return { type: 'command', command }
242
- return { type: 'command', command, timeout: BLOCKING_STOP_TIMEOUT_SECONDS }
226
+ return { type: 'command', command, timeout: NON_ROUTING_BLOCKING_STOP_TIMEOUT_SECONDS }
243
227
  }
244
228
 
245
229
  function commandOptionsFrom(options: BuildOptions): HookCommandOptions {
@@ -350,7 +334,7 @@ export function buildCursorHookConfig(options: BuildOptions): CursorHookConfig {
350
334
  },
351
335
  {
352
336
  command: hookCommand(options.adapterPath, 'stop', 'cursor', commandOptions),
353
- timeout: BLOCKING_STOP_TIMEOUT_SECONDS,
337
+ timeout: NON_ROUTING_BLOCKING_STOP_TIMEOUT_SECONDS,
354
338
  // A continuation may register a real follow-up question. Match the
355
339
  // session-state cap so those chains are useful but never unbounded.
356
340
  loop_limit: 3,
@@ -98,6 +98,24 @@ describe('program argv parsing', () => {
98
98
  })
99
99
  })
100
100
 
101
+ it('preserves deliberate Projectless send intent at the argv boundary', async () => {
102
+ let seen: Record<string, unknown> | undefined
103
+ await parse(
104
+ ['send', '--kind', 'done', '--title', 'T', '--body', 'B', '--projectless'],
105
+ { send: (async (_deps, flags) => ((seen = flags as Record<string, unknown>), 0)) as ProgramRunners['send'] },
106
+ )
107
+ expect(seen).toMatchObject({ projectless: true })
108
+ })
109
+
110
+ it('dispatches User-owned Project enablement commands', async () => {
111
+ let enabled = 0
112
+ const result = await parse(['project', 'enable'], {
113
+ projectEnable: ((_deps) => (enabled += 1, 0)) as ProgramRunners['projectEnable'],
114
+ })
115
+ expect(result.exitCode).toBe(0)
116
+ expect(enabled).toBe(1)
117
+ })
118
+
101
119
  it('drops empty collector defaults so "not passed" stays absent', async () => {
102
120
  let seen: Record<string, unknown> | undefined
103
121
  await parse(
package/src/program.ts CHANGED
@@ -23,6 +23,9 @@ import {
23
23
  initCommand,
24
24
  loginCommand,
25
25
  logoutCommand,
26
+ projectDisableCommand,
27
+ projectEnableCommand,
28
+ projectStatusCommand,
26
29
  logsCommand,
27
30
  repliesCommand,
28
31
  reportAskFailure,
@@ -81,6 +84,9 @@ const defaultRunners = {
81
84
  accessStatus: accessStatusCommand,
82
85
  devices: devicesCommand,
83
86
  capabilities: capabilitiesCommand,
87
+ projectEnable: projectEnableCommand,
88
+ projectDisable: projectDisableCommand,
89
+ projectStatus: projectStatusCommand,
84
90
  send: sendCommand,
85
91
  replies: repliesCommand,
86
92
  acknowledge: acknowledgeCommand,
@@ -250,6 +256,21 @@ export function buildProgram(deps: CommandDeps, options: BuildProgramOptions = {
250
256
  exit(await runners.doctor(deps, opts))
251
257
  })
252
258
 
259
+ const project = program
260
+ .command('project')
261
+ .helpGroup(GROUP.daily)
262
+ .summary('Control Notifai for this Project')
263
+ .description('Inspect or change this Project\'s User-owned lifecycle enablement')
264
+ project.command('enable').description('Enable lifecycle guidance for this Project').action(() => {
265
+ exit(runners.projectEnable(deps))
266
+ })
267
+ project.command('disable').description('Disable lifecycle guidance for this Project').action(() => {
268
+ exit(runners.projectDisable(deps))
269
+ })
270
+ project.command('status').description('Show lifecycle enablement for this Project').option('--json').action((opts: { json?: boolean }) => {
271
+ exit(runners.projectStatus(deps, opts.json === true))
272
+ })
273
+
253
274
  program
254
275
  .command('login')
255
276
  .helpGroup(GROUP.start)
@@ -340,6 +361,7 @@ export function buildProgram(deps: CommandDeps, options: BuildProgramOptions = {
340
361
  .optionsGroup(SEND_GROUP.routing)
341
362
  .option('--kind <kind>', 'what this is (required): update | done | failed | blocked (no User reply would resume it) — question is set by --reply')
342
363
  .option('--project <id>', 'Project identifier override (otherwise configured or inferred)')
364
+ .option('--projectless', 'deliberately omit Project identity for this notification')
343
365
  .option('--device <id>', 'target a device id (repeatable)', (v: string, all: string[] = []) => [...all, v])
344
366
  .option('--all', 'target all routable devices (overrides configured devices)')
345
367
  .option('--session-id <id>', 'low-level automation override for an opaque exact Agent Session (env: NOTIFAI_SESSION_ID)')
@@ -0,0 +1,55 @@
1
+ import { execFileSync } from 'node:child_process'
2
+ import { mkdirSync, mkdtempSync, writeFileSync } from 'node:fs'
3
+ import os from 'node:os'
4
+ import path from 'node:path'
5
+ import { describe, expect, it } from 'vitest'
6
+ import {
7
+ disableProject,
8
+ enableProject,
9
+ projectBinding,
10
+ projectEnabled,
11
+ } from './project-enablement.js'
12
+
13
+ function git(cwd: string, ...args: string[]): void {
14
+ execFileSync('git', ['-C', cwd, ...args], { stdio: 'ignore' })
15
+ }
16
+
17
+ describe('Project enablement', () => {
18
+ it('is disabled by default and changes only through its User-owned marker', () => {
19
+ const root = mkdtempSync(path.join(os.tmpdir(), 'notifai-enablement-'))
20
+ const env = { XDG_CONFIG_HOME: path.join(root, 'config') }
21
+ const binding = projectBinding(root, env)
22
+ expect(binding).not.toBeNull()
23
+ expect(projectEnabled(binding)).toBe(false)
24
+
25
+ enableProject(binding!, new Date('2026-08-27T00:00:00.000Z'))
26
+ expect(projectEnabled(binding)).toBe(true)
27
+ disableProject(binding!)
28
+ expect(projectEnabled(binding)).toBe(false)
29
+ })
30
+
31
+ it('shares one decision across linked worktrees but not unrelated checkouts with the same name', () => {
32
+ const root = mkdtempSync(path.join(os.tmpdir(), 'notifai-enablement-git-'))
33
+ const repo = path.join(root, 'same-name')
34
+ const linked = path.join(root, 'linked')
35
+ const unrelatedParent = path.join(root, 'other')
36
+ const unrelated = path.join(unrelatedParent, 'same-name')
37
+ mkdirSync(repo, { recursive: true })
38
+ mkdirSync(unrelated, { recursive: true })
39
+ for (const cwd of [repo, unrelated]) {
40
+ git(cwd, 'init')
41
+ git(cwd, 'config', 'user.email', 'test@example.invalid')
42
+ git(cwd, 'config', 'user.name', 'Test')
43
+ writeFileSync(path.join(cwd, 'file'), 'x')
44
+ git(cwd, 'add', 'file')
45
+ git(cwd, 'commit', '-m', 'fixture')
46
+ }
47
+ git(repo, 'worktree', 'add', linked, '-b', 'linked')
48
+ const env = { XDG_CONFIG_HOME: path.join(root, 'config') }
49
+ const mainBinding = projectBinding(repo, env)
50
+ const linkedBinding = projectBinding(linked, env)
51
+ const unrelatedBinding = projectBinding(unrelated, env)
52
+ expect(linkedBinding?.markerPath).toBe(mainBinding?.markerPath)
53
+ expect(unrelatedBinding?.markerPath).not.toBe(mainBinding?.markerPath)
54
+ })
55
+ })
@@ -0,0 +1,99 @@
1
+ import { execFileSync } from 'node:child_process'
2
+ import { createHash } from 'node:crypto'
3
+ import { existsSync, readFileSync, realpathSync, unlinkSync } from 'node:fs'
4
+ import path from 'node:path'
5
+ import { atomicWriteFileSync } from './atomic-file.js'
6
+ import { globalConfigDir } from './config.js'
7
+ import { inferInvocationContext } from './invocation-context.js'
8
+
9
+ interface ProjectEnablementMarker {
10
+ version: 1
11
+ project: string
12
+ enabled: true
13
+ updated_at: string
14
+ }
15
+
16
+ export interface ProjectBinding {
17
+ project: string
18
+ markerPath: string
19
+ }
20
+
21
+ function gitCommonDirectory(cwd: string): string | null {
22
+ try {
23
+ const raw = execFileSync('git', ['-C', cwd, 'rev-parse', '--git-common-dir'], {
24
+ encoding: 'utf8',
25
+ stdio: ['ignore', 'pipe', 'ignore'],
26
+ timeout: 2_000,
27
+ windowsHide: true,
28
+ }).trim()
29
+ if (raw === '') return null
30
+ const resolved = path.isAbsolute(raw) ? raw : path.resolve(cwd, raw)
31
+ try {
32
+ return realpathSync(resolved)
33
+ } catch {
34
+ return path.resolve(resolved)
35
+ }
36
+ } catch {
37
+ return null
38
+ }
39
+ }
40
+
41
+ /**
42
+ * Resolve the User-owned enablement record for this exact local Project.
43
+ *
44
+ * The opaque key binds the semantic Project id to Git's shared common
45
+ * directory. Linked worktrees therefore share one decision, while another
46
+ * checkout cannot borrow it merely by committing the same Project id.
47
+ */
48
+ export function projectBinding(
49
+ cwd: string,
50
+ env: NodeJS.ProcessEnv = process.env,
51
+ explicitProject?: string | null,
52
+ ): ProjectBinding | null {
53
+ const project = explicitProject ?? inferInvocationContext(cwd).project
54
+ if (project === null) return null
55
+ const repository = gitCommonDirectory(cwd) ?? path.resolve(cwd)
56
+ const key = createHash('sha256')
57
+ .update('notifai-project-enablement-v1\0')
58
+ .update(repository)
59
+ .update('\0')
60
+ .update(project)
61
+ .digest('hex')
62
+ return {
63
+ project,
64
+ markerPath: path.join(globalConfigDir(env), 'project-enablement', `${key}.json`),
65
+ }
66
+ }
67
+
68
+ export function projectEnabled(binding: ProjectBinding | null): boolean {
69
+ if (binding === null || !existsSync(binding.markerPath)) return false
70
+ try {
71
+ const marker = JSON.parse(readFileSync(binding.markerPath, 'utf8')) as Partial<ProjectEnablementMarker>
72
+ return marker.version === 1 && marker.enabled === true && marker.project === binding.project
73
+ } catch {
74
+ return false
75
+ }
76
+ }
77
+
78
+ export function enableProject(binding: ProjectBinding, now: Date = new Date()): void {
79
+ const marker: ProjectEnablementMarker = {
80
+ version: 1,
81
+ project: binding.project,
82
+ enabled: true,
83
+ updated_at: now.toISOString(),
84
+ }
85
+ atomicWriteFileSync(binding.markerPath, `${JSON.stringify(marker, null, 2)}\n`, {
86
+ mode: 0o600,
87
+ preserveMode: false,
88
+ requireCurrentUserOwner: true,
89
+ directoryMode: 0o700,
90
+ })
91
+ }
92
+
93
+ export function disableProject(binding: ProjectBinding): void {
94
+ try {
95
+ unlinkSync(binding.markerPath)
96
+ } catch (err) {
97
+ if ((err as NodeJS.ErrnoException).code !== 'ENOENT') throw err
98
+ }
99
+ }
@@ -0,0 +1,56 @@
1
+ import { REPLY_MAX_WINDOW_SECONDS } from '@raidiant/notifai-protocol'
2
+
3
+ /**
4
+ * Time before reply-window admission that the Stop owner may spend reading its
5
+ * input, serving terminal-first grace, resolving devices, and submitting.
6
+ *
7
+ * Grace is capped at six minutes and every network operation is independently
8
+ * bounded. Twenty minutes lets setup spend up to ten while preserving the
9
+ * separate completion margin below; neither may borrow from the answer window
10
+ * the service actually commits.
11
+ */
12
+ export const QUESTION_WAITER_STARTUP_HEADROOM_SECONDS = 20 * 60
13
+
14
+ /**
15
+ * Minimum lifetime still available when a question is admitted.
16
+ *
17
+ * The server window starts during submission, not when the hook process
18
+ * started. Keeping ten minutes beyond the requested window covers the bounded
19
+ * submit, clock skew, close fence, and route handoff. If setup used more of the
20
+ * allowance, the frozen question remains local for a successor owner instead
21
+ * of publishing a window this process cannot observe completely.
22
+ */
23
+ export const QUESTION_SUBMISSION_COMPLETION_HEADROOM_SECONDS = 10 * 60
24
+
25
+ /**
26
+ * The longest one Stop owner may live from process start.
27
+ *
28
+ * The service can accept an answer for up to 72 hours. Starting this budget
29
+ * before submission is deliberate: the startup allowance above ensures that
30
+ * even the maximum reply window still ends before the owner does.
31
+ */
32
+ export const QUESTION_WAITER_CEILING_SECONDS =
33
+ REPLY_MAX_WINDOW_SECONDS + QUESTION_WAITER_STARTUP_HEADROOM_SECONDS
34
+
35
+ /** Time for final fencing, diagnostics, stdout handoff, and clean process exit. */
36
+ export const QUESTION_STOP_TEARDOWN_HEADROOM_SECONDS = 60
37
+
38
+ /**
39
+ * Declared harness timeout for every Stop handler that owns Question Routing.
40
+ * It must outlive the runtime owner; otherwise the harness silently kills the
41
+ * only process capable of continuing the exact Agent Session.
42
+ */
43
+ export const QUESTION_STOP_TIMEOUT_SECONDS =
44
+ QUESTION_WAITER_CEILING_SECONDS + QUESTION_STOP_TEARDOWN_HEADROOM_SECONDS
45
+
46
+ /**
47
+ * Short Stop budget for harnesses that cannot own asynchronous Question
48
+ * Routing. They retire/refuse promptly and never wait through an answer window.
49
+ */
50
+ export const NON_ROUTING_STOP_TIMEOUT_SECONDS = 540
51
+
52
+ /**
53
+ * Pre-PID claim files came only from the former eight-minute owner. Retain that
54
+ * recovery bound instead of making a corrupt legacy file block for 72 hours.
55
+ */
56
+ export const LEGACY_QUESTION_CLAIM_TTL_SECONDS = 480
package/src/readiness.ts CHANGED
@@ -34,7 +34,14 @@ export type Remedy =
34
34
  * the CLI's to launch but the human's to complete, so an agent must still
35
35
  * treat it as out of reach.
36
36
  */
37
- | { by: 'user-here'; summary: string; command: string; interactive?: boolean }
37
+ | {
38
+ by: 'user-here'
39
+ summary: string
40
+ command: string
41
+ interactive?: boolean
42
+ /** Stable agent-facing description of an unavoidable harness-owned action. */
43
+ user_action?: { code: string; harness: string; action: string; message: string }
44
+ }
38
45
  /**
39
46
  * A human somewhere else entirely — phone in hand, app store, permission
40
47
  * dialog. The class the old init could only print a sentence about, and the
package/src/send.ts CHANGED
@@ -34,6 +34,8 @@ export interface SendFlags {
34
34
  /** What this notification is; absent means update. */
35
35
  kind?: string
36
36
  project?: string
37
+ /** Deliberately omit Project identity even when config or cwd could supply one. */
38
+ projectless?: boolean
37
39
  /** Opaque exact Agent Session override. */
38
40
  sessionId?: string
39
41
  /** Human-readable Agent Session label override. */
@@ -287,7 +289,12 @@ export function buildDraft(
287
289
  }
288
290
  }
289
291
 
290
- const project = flags.project ?? config.project.value ?? invocation.inferredProject
292
+ if (flags.projectless === true && flags.project !== undefined) {
293
+ return { ok: false, error: 'Choose either --project or --projectless, not both.' }
294
+ }
295
+ const project = flags.projectless === true
296
+ ? undefined
297
+ : flags.project ?? config.project.value ?? invocation.inferredProject
291
298
 
292
299
  // Ids are derived from labels so an agent writing a one-liner does not have
293
300
  // to invent them, but they stay the stable thing it branches on afterwards.
@@ -3,12 +3,11 @@ import { Buffer } from 'node:buffer'
3
3
  import { GUIDANCE_CONTEXT_MAX_BYTES, boundedEffectiveGuidance } from './guidance-render.js'
4
4
 
5
5
  const ROOT_OWNERSHIP =
6
- 'Notifai is active for this session. The effective, provenance-marked guidance below governs Notification Requests. ' +
7
- 'Missing CLI readiness is handled through `notifai init --json`. ' +
8
- 'The root agent owns User-visible Notification Requests unless it explicitly delegates that ownership; workers report Agent Events to the parent.'
6
+ 'Notifai is enabled for this Project. The effective, provenance-marked guidance below governs Notification Requests. ' +
7
+ 'Missing CLI readiness is handled through `notifai init --json`.'
9
8
 
10
9
  export const WORKER_ACTIVATION_CONTEXT =
11
- 'Notifai worker context: report Agent Events to the parent; do not send User-visible Notification Requests unless the parent explicitly delegated that ownership. If delegated, load the Notifai skill and run `notifai guidance` before composing one.'
10
+ 'Notifai worker context: an Agent Event is a meaningful occurrence in your work; a Notification Request is a deliberate User-visible message about one. Report Agent Events to the parent and do not send Notification Requests unless the parent explicitly delegated that ownership. If delegated, load the Notifai skill and run `notifai guidance` before composing one.'
12
11
 
13
12
  export const MISSING_LIFECYCLE_GUIDANCE_CONTEXT =
14
13
  'Notifai is active, but lifecycle guidance could not be loaded. Run `notifai guidance` once before deciding whether or how to send a Notification Request.'
@@ -1,10 +0,0 @@
1
- export type OrcaSessionTitleLookup = (env: NodeJS.ProcessEnv) => string | undefined;
2
- export type OrcaCommand = (executable: string, args: readonly string[]) => string | null;
3
- /**
4
- * Read the semantic display name assigned to this exact Orca worktree.
5
- *
6
- * The environment contributes only an opaque selector. Orca must return that
7
- * exact id and its matching path before its display name becomes trusted; no
8
- * path component or identifier is ever promoted into the User-visible label.
9
- */
10
- export declare function readOrcaSessionTitle(env: NodeJS.ProcessEnv, command?: OrcaCommand): string | undefined;
@@ -1,99 +0,0 @@
1
- import { execFileSync } from 'node:child_process';
2
- import path from 'node:path';
3
- const runOrcaCommand = (executable, args) => {
4
- try {
5
- return execFileSync(executable, args, {
6
- encoding: 'utf8',
7
- stdio: ['ignore', 'pipe', 'ignore'],
8
- timeout: 2_000,
9
- maxBuffer: 512 * 1024,
10
- windowsHide: true,
11
- }).trim();
12
- }
13
- catch {
14
- return null;
15
- }
16
- };
17
- function containsControlCharacter(value) {
18
- return Array.from(value).some((character) => {
19
- const codePoint = character.codePointAt(0) ?? 0;
20
- return codePoint < 32 || codePoint === 127;
21
- });
22
- }
23
- function worktreePathFromId(value) {
24
- if (value.length === 0 || value.length > 4_096 || containsControlCharacter(value)) {
25
- return null;
26
- }
27
- const separator = value.indexOf('::');
28
- if (separator <= 0)
29
- return null;
30
- const repoId = value.slice(0, separator);
31
- const worktreePath = value.slice(separator + 2);
32
- if (!/^[A-Za-z0-9._-]+$/u.test(repoId) || worktreePath.length === 0)
33
- return null;
34
- return path.posix.isAbsolute(worktreePath) || path.win32.isAbsolute(worktreePath)
35
- ? worktreePath
36
- : null;
37
- }
38
- function orcaExecutable(env) {
39
- const configured = env['ORCA_CLI_COMMAND']?.trim();
40
- if (configured !== undefined && configured.length > 0) {
41
- return containsControlCharacter(configured) ? null : configured;
42
- }
43
- return env['ORCA_DEV_REPO_ROOT'] === undefined ? 'orca' : 'orca-dev';
44
- }
45
- /**
46
- * Read the semantic display name assigned to this exact Orca worktree.
47
- *
48
- * The environment contributes only an opaque selector. Orca must return that
49
- * exact id and its matching path before its display name becomes trusted; no
50
- * path component or identifier is ever promoted into the User-visible label.
51
- */
52
- export function readOrcaSessionTitle(env, command = runOrcaCommand) {
53
- if (env['TERM_PROGRAM'] !== 'Orca')
54
- return undefined;
55
- const worktreeId = env['ORCA_WORKTREE_ID'];
56
- if (worktreeId === undefined)
57
- return undefined;
58
- const expectedPath = worktreePathFromId(worktreeId);
59
- if (expectedPath === null)
60
- return undefined;
61
- const executable = orcaExecutable(env);
62
- if (executable === null)
63
- return undefined;
64
- let output;
65
- try {
66
- output = command(executable, [
67
- 'worktree',
68
- 'show',
69
- '--worktree',
70
- `id:${worktreeId}`,
71
- '--json',
72
- ]);
73
- }
74
- catch {
75
- return undefined;
76
- }
77
- if (output === null || output === '')
78
- return undefined;
79
- let parsed;
80
- try {
81
- parsed = JSON.parse(output);
82
- }
83
- catch {
84
- return undefined;
85
- }
86
- if (typeof parsed !== 'object' || parsed === null)
87
- return undefined;
88
- const response = parsed;
89
- const worktree = response.result?.worktree;
90
- if (response.ok !== true ||
91
- worktree?.id !== worktreeId ||
92
- worktree.path !== expectedPath ||
93
- typeof worktree.displayName !== 'string' ||
94
- worktree.displayName.trim().length === 0) {
95
- return undefined;
96
- }
97
- return worktree.displayName;
98
- }
99
- //# sourceMappingURL=orca-session-title.js.map
@@ -1 +0,0 @@
1
- {"version":3,"file":"orca-session-title.js","sourceRoot":"","sources":["../src/orca-session-title.ts"],"names":[],"mappings":"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"}