@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
@@ -5,42 +5,16 @@ import path from 'node:path'
5
5
  import { isRetryableReplyPollError, type ApiClient } from './client.js'
6
6
  import { fetchMediaUrl } from './url-policy.js'
7
7
  import { buildSourceContext, inferInvocationContext } from './invocation-context.js'
8
- import { readOrcaSessionTitle } from './orca-session-title.js'
9
8
  import { type DraftInvocation, type SendFlags } from './send.js'
10
9
  import { EXIT, reportError, type CommandDeps } from './commands-core.js'
11
10
  import type { ActiveHarnessSession } from './commands-harness-context.js'
12
11
 
13
- function managedSessionTitle(
14
- deps: CommandDeps,
15
- flags: Pick<SendFlags, 'sessionLabel'>,
16
- active: ActiveHarnessSession | null,
17
- ): string | undefined {
18
- if (active?.sessionLabel !== undefined) return active.sessionLabel
19
- // The Orca worktree title names the workspace, not one harness: every
20
- // Agent Session that Orca starts inside that worktree inherits the same
21
- // environment selector, so any active session with an identity to label
22
- // may use it. An explicit label still wins without paying for the lookup.
23
- if (
24
- active?.sessionId === undefined ||
25
- flags.sessionLabel !== undefined ||
26
- deps.env['NOTIFAI_SESSION_LABEL'] !== undefined
27
- ) {
28
- return undefined
29
- }
30
- try {
31
- return (deps.orcaSessionTitle ?? readOrcaSessionTitle)(deps.env)
32
- } catch {
33
- return undefined
34
- }
35
- }
36
-
37
12
  export function resolveDraftInvocation(
38
13
  deps: CommandDeps,
39
14
  flags: Pick<SendFlags, 'sessionId' | 'sessionLabel'>,
40
15
  active: ActiveHarnessSession | null,
41
16
  ): { ok: true; invocation: DraftInvocation } | { ok: false; error: string } {
42
17
  const inferred = inferInvocationContext(deps.cwd)
43
- const sessionTitle = managedSessionTitle(deps, flags, active)
44
18
  const source = buildSourceContext({
45
19
  env: deps.env,
46
20
  invocation: inferred,
@@ -52,7 +26,7 @@ export function resolveDraftInvocation(
52
26
  activeHarness: {
53
27
  harness: active.harness,
54
28
  ...(active.sessionId === undefined ? {} : { sessionId: active.sessionId }),
55
- ...(sessionTitle === undefined ? {} : { sessionLabel: sessionTitle }),
29
+ ...(active.sessionLabel === undefined ? {} : { sessionLabel: active.sessionLabel }),
56
30
  ...(active.sessionLabelPending === true ? { sessionLabelPending: true } : {}),
57
31
  },
58
32
  }),
@@ -9,6 +9,7 @@ import {
9
9
  import { ApiCallError, NetworkError } from './client.js'
10
10
  import type { FlagOverrides, loadConfig } from './config.js'
11
11
  import { MIN_REPLY_WINDOW_SECONDS, readSessionState } from './hooks.js'
12
+ import { enableProject, projectBinding } from './project-enablement.js'
12
13
  import {
13
14
  buildDraft,
14
15
  formatReceipt,
@@ -148,9 +149,6 @@ export async function sendCommand(
148
149
  deps.io.err(source.error)
149
150
  return EXIT.usage
150
151
  }
151
- const authed = authedClient(deps, config)
152
- if (!authed) return EXIT.auth
153
- const credential = deps.store.load()!
154
152
  let semanticImages: string[]
155
153
  try {
156
154
  semanticImages = semanticMediaIds(flags.image ?? [], deps.cwd)
@@ -167,6 +165,13 @@ export async function sendCommand(
167
165
  deps.io.err(semanticBuild.error)
168
166
  return EXIT.usage
169
167
  }
168
+ if (semanticBuild.draft.project !== undefined) {
169
+ const binding = projectBinding(deps.cwd, deps.env, semanticBuild.draft.project)
170
+ if (binding !== null) enableProject(binding)
171
+ }
172
+ const authed = authedClient(deps, config)
173
+ if (!authed) return EXIT.auth
174
+ const credential = deps.store.load()!
170
175
  const attempt = beginSendAttempt({
171
176
  env: deps.env,
172
177
  credential,
@@ -0,0 +1,44 @@
1
+ import { mkdirSync, mkdtempSync, writeFileSync } from 'node:fs'
2
+ import os from 'node:os'
3
+ import path from 'node:path'
4
+ import { describe, expect, it } from 'vitest'
5
+ import type { CommandDeps } from './commands-core.js'
6
+ import { skillReadiness, SKILLS_SOURCE } from './commands-skill.js'
7
+
8
+ describe('development CLI skill parity', () => {
9
+ it('reports an exact gap instead of calling a stale released skill ready', async () => {
10
+ const root = mkdtempSync(path.join(os.tmpdir(), 'notifai-dev-skill-'))
11
+ const installedPath = path.join(root, 'skills', 'notifai')
12
+ mkdirSync(installedPath, { recursive: true })
13
+ writeFileSync(path.join(installedPath, 'SKILL.md'), '# stale released skill\n')
14
+ const match = /^([^#]+)#(.+)$/.exec(SKILLS_SOURCE ?? '')
15
+ if (match === null) throw new Error('test build has no skill release pin')
16
+ const deps = {
17
+ cwd: root,
18
+ env: {},
19
+ io: { out() {}, err() {}, confirm: async () => false, openUrl() {} },
20
+ store: { load: () => null, save() {}, clear() {}, describe: () => 'test' },
21
+ nativeSkills: {
22
+ list: async (scope: 'project' | 'global') => ({
23
+ skills: scope === 'global'
24
+ ? [{ name: 'notifai', scope, path: installedPath, source: match[1]!, sourceType: 'github' as const, sourceUrl: `https://github.com/${match[1]}.git`, ref: match[2]! }]
25
+ : [],
26
+ }),
27
+ add: async () => 0,
28
+ remove: async () => 0,
29
+ },
30
+ } satisfies CommandDeps
31
+
32
+ const state = await skillReadiness(deps)
33
+ expect(state).toMatchObject({
34
+ status: 'gap',
35
+ technical: {
36
+ resolution: 'development-cli-skill-mismatch',
37
+ ref: match[2],
38
+ checkout_digest: expect.stringMatching(/^sha256:/),
39
+ installed_digest: expect.stringMatching(/^sha256:/),
40
+ },
41
+ })
42
+ expect(state.remedy).toBeUndefined()
43
+ })
44
+ })
@@ -1,4 +1,8 @@
1
1
  import { SKILLS_INSTALLER_SPEC, type NativeSkill, type SkillScope } from './native-skills.js'
2
+ import { createHash } from 'node:crypto'
3
+ import { existsSync, readdirSync, readFileSync } from 'node:fs'
4
+ import path from 'node:path'
5
+ import { fileURLToPath } from 'node:url'
2
6
  import { type ReadinessState } from './readiness.js'
3
7
  import { skillsSource } from './release.js'
4
8
  import type { CommandDeps } from './commands-core.js'
@@ -20,6 +24,40 @@ const SKILLS_SOURCE_LABEL = SKILLS_SOURCE ?? 'the public release tag matching th
20
24
 
21
25
  const SKILL_SCOPES: readonly SkillScope[] = ['project', 'global']
22
26
 
27
+ function markdownTreeDigest(root: string): string | null {
28
+ if (!existsSync(root)) return null
29
+ const files: string[] = []
30
+ const walk = (directory: string): void => {
31
+ for (const entry of readdirSync(directory, { withFileTypes: true })) {
32
+ const absolute = path.join(directory, entry.name)
33
+ if (entry.isDirectory()) walk(absolute)
34
+ else if (entry.isFile() && entry.name.endsWith('.md')) files.push(absolute)
35
+ }
36
+ }
37
+ try {
38
+ walk(root)
39
+ const hash = createHash('sha256')
40
+ for (const file of files.sort()) {
41
+ hash.update(path.relative(root, file))
42
+ hash.update('\0')
43
+ hash.update(readFileSync(file))
44
+ hash.update('\0')
45
+ }
46
+ return `sha256:${hash.digest('hex')}`
47
+ } catch {
48
+ return null
49
+ }
50
+ }
51
+
52
+ function developmentSkillMismatch(skill: NativeSkill): { checkout: string; installed: string } | null {
53
+ const checkoutRoot = fileURLToPath(new URL('../../../skills/notifai/', import.meta.url))
54
+ const checkout = markdownTreeDigest(checkoutRoot)
55
+ const installed = markdownTreeDigest(skill.path)
56
+ return checkout !== null && installed !== null && checkout !== installed
57
+ ? { checkout, installed }
58
+ : null
59
+ }
60
+
23
61
  function skillSourceParts(): { source: string; ref: string } | null {
24
62
  if (SKILLS_SOURCE === null) return null
25
63
  const match = /^([^#]+)#(.+)$/.exec(SKILLS_SOURCE)
@@ -118,6 +156,25 @@ export async function skillReadiness(
118
156
 
119
157
  const current = installed.find(expectedSkill)
120
158
  if (current !== undefined) {
159
+ const mismatch = developmentSkillMismatch(current)
160
+ if (mismatch !== null) {
161
+ return {
162
+ id: 'skill',
163
+ title: 'Agent guidance skill',
164
+ status: 'gap',
165
+ detail:
166
+ `the active development CLI's shipped guidance differs from the installer-managed ${skillPin(current)} skill. ` +
167
+ 'Released installs remain immutable; publish a new CLI/skill release before treating this combination as ready.',
168
+ technical: {
169
+ resolution: 'development-cli-skill-mismatch',
170
+ scope: current.scope,
171
+ ref: current.ref,
172
+ path: current.path,
173
+ checkout_digest: mismatch.checkout,
174
+ installed_digest: mismatch.installed,
175
+ },
176
+ }
177
+ }
121
178
  return {
122
179
  id: 'skill',
123
180
  title: 'Agent guidance skill',
@@ -68,6 +68,7 @@ import {
68
68
  codexTrustKey,
69
69
  findInstallations,
70
70
  handlerEvent,
71
+ QUESTION_STOP_TIMEOUT_SECONDS,
71
72
  settingsFile,
72
73
  } from './install-hooks.js'
73
74
  import { readSessionState, writeProjectSession, writeSessionState } from './hooks.js'
@@ -78,6 +79,7 @@ import { resetLatestPublishedCliVersionForTest } from './cli-release.js'
78
79
  import { activeLogPath, createLogger, logsDiskUsage, readLogRecords } from './logging.js'
79
80
  import { hookAdapterPath, inspectHookAdapter } from './hook-adapter.js'
80
81
  import type { Tone } from './ui/theme.js'
82
+ import { projectBinding, projectEnabled } from './project-enablement.js'
81
83
 
82
84
  afterEach(() => {
83
85
  resetLatestPublishedCliVersionForTest()
@@ -416,6 +418,31 @@ function replyResponse(
416
418
  }
417
419
 
418
420
  describe('command contracts', () => {
421
+ it('durably enables a valid in-Project send before missing authentication stops it', async () => {
422
+ const cwd = mkdtempSync(path.join(os.tmpdir(), 'notifai-explicit-enable-'))
423
+ const io = new CapturedIo()
424
+ const env = isolatedEnv(cwd)
425
+ const deps = {
426
+ ...makeDeps(io, {} as ApiClient),
427
+ cwd,
428
+ env,
429
+ store: { load: () => null, save: () => {}, clear: () => {}, describe: () => 'empty store' },
430
+ }
431
+ expect(await sendCommand(deps, { title: 'Ready', body: 'Ready.', kind: 'done' })).toBe(EXIT.auth)
432
+ expect(projectEnabled(projectBinding(cwd, env))).toBe(true)
433
+ })
434
+
435
+ it('sends deliberately Projectless without enabling the inferred Project', async () => {
436
+ const cwd = mkdtempSync(path.join(os.tmpdir(), 'notifai-projectless-'))
437
+ const io = new CapturedIo()
438
+ const env = isolatedEnv(cwd)
439
+ let submitted: SubmitNotificationRequestT | undefined
440
+ const client = { submit: async (body: SubmitNotificationRequestT) => ((submitted = body), receipt) } as unknown as ApiClient
441
+ const deps = { ...makeDeps(io, client), cwd, env }
442
+ expect(await sendCommand(deps, { title: 'Ready', body: 'Ready.', kind: 'done', projectless: true })).toBe(EXIT.ok)
443
+ expect(submitted?.draft.project).toBeUndefined()
444
+ expect(projectEnabled(projectBinding(cwd, env))).toBe(false)
445
+ })
419
446
  it('shows an actionable no-plan access state', async () => {
420
447
  const io = new CapturedIo()
421
448
  const client = {
@@ -829,7 +856,7 @@ describe('command contracts', () => {
829
856
  expect(io.errLines.join('\n')).not.toContain('opaque-claude-session-42')
830
857
  })
831
858
 
832
- it('uses the exact Orca worktree title for an active Claude session send', async () => {
859
+ it('never promotes an Orca worktree title into an Agent Session label', async () => {
833
860
  const cwd = mkdtempSync(path.join(os.tmpdir(), 'notifai-orca-claude-title-'))
834
861
  const io = new CapturedIo()
835
862
  let submitted: SubmitNotificationRequestT | undefined
@@ -852,10 +879,6 @@ describe('command contracts', () => {
852
879
  ...makeDeps(io, client),
853
880
  cwd,
854
881
  env,
855
- orcaSessionTitle: (lookupEnv: NodeJS.ProcessEnv) => {
856
- expect(lookupEnv['ORCA_WORKTREE_ID']).toBe(worktreeId)
857
- return 'Worker - semantic session implementation'
858
- },
859
882
  }
860
883
 
861
884
  expect(
@@ -868,12 +891,13 @@ describe('command contracts', () => {
868
891
 
869
892
  expect(submitted?.draft.source).toMatchObject({
870
893
  session_id: 'orca-claude-session',
871
- session_label: 'Worker - semantic session implementation',
872
894
  harness: 'claude-code',
873
895
  })
896
+ expect(submitted?.draft.source?.session_label).toMatch(/^[A-Z][a-z]+ [A-Z][a-z]+$/)
897
+ expect(submitted?.draft.source?.session_label).not.toBe('Worker - semantic session implementation')
874
898
  })
875
899
 
876
- it('uses the exact Orca worktree title for an active Codex session send', async () => {
900
+ it('never treats an Orca worktree title as a Codex Agent Session label', async () => {
877
901
  const cwd = mkdtempSync(path.join(os.tmpdir(), 'notifai-orca-codex-title-'))
878
902
  const io = new CapturedIo()
879
903
  let submitted: SubmitNotificationRequestT | undefined
@@ -894,10 +918,6 @@ describe('command contracts', () => {
894
918
  TERM_PROGRAM: 'Orca',
895
919
  ORCA_WORKTREE_ID: worktreeId,
896
920
  },
897
- orcaSessionTitle: (lookupEnv: NodeJS.ProcessEnv) => {
898
- expect(lookupEnv['ORCA_WORKTREE_ID']).toBe(worktreeId)
899
- return 'Worker - semantic session implementation'
900
- },
901
921
  }
902
922
 
903
923
  expect(
@@ -910,9 +930,9 @@ describe('command contracts', () => {
910
930
 
911
931
  expect(submitted?.draft.source).toMatchObject({
912
932
  session_id: 'orca-codex-thread',
913
- session_label: 'Worker - semantic session implementation',
914
933
  harness: 'codex',
915
934
  })
935
+ expect(submitted?.draft.source?.session_label).toMatch(/^[A-Z][a-z]+ [A-Z][a-z]+$/)
916
936
  })
917
937
 
918
938
  it('falls back safely when Orca returns a private path as its title', async () => {
@@ -936,7 +956,6 @@ describe('command contracts', () => {
936
956
  TERM_PROGRAM: 'Orca',
937
957
  ORCA_WORKTREE_ID: `repo-123::${cwd}`,
938
958
  },
939
- orcaSessionTitle: () => '/private/untrusted/worktree',
940
959
  }
941
960
 
942
961
  expect(
@@ -6093,7 +6112,7 @@ describe('asking before the hooks have ever run', () => {
6093
6112
  expect(readSessionState('codex-orchestrator', env).pending).toBeUndefined()
6094
6113
  })
6095
6114
 
6096
- it('registers an Orca-managed Claude question under the semantic worktree title', () => {
6115
+ it('registers an Orca-managed Claude question without borrowing the worktree title', () => {
6097
6116
  const cwd = scratchDir('notifai-orca-claude-ask-')
6098
6117
  const io = new CapturedIo()
6099
6118
  const env = {
@@ -6108,7 +6127,6 @@ describe('asking before the hooks have ever run', () => {
6108
6127
  cwd,
6109
6128
  env,
6110
6129
  now: () => 42,
6111
- orcaSessionTitle: () => 'Worker - semantic session implementation',
6112
6130
  }
6113
6131
 
6114
6132
  expect(hooksInstallCommand(deps, { harness: 'claude-code', execPath, scriptPath })).toBe(
@@ -6121,9 +6139,10 @@ describe('asking before the hooks have ever run', () => {
6121
6139
  expect(askCommand(deps, 'Ship the semantic resolver?', {})).toBe(EXIT.ok)
6122
6140
  expect(readSessionState('orca-claude-question', env).pending?.[0]?.source).toMatchObject({
6123
6141
  session_id: 'orca-claude-question',
6124
- session_label: 'Worker - semantic session implementation',
6125
6142
  harness: 'claude-code',
6126
6143
  })
6144
+ expect(readSessionState('orca-claude-question', env).pending?.[0]?.source?.session_label)
6145
+ .toMatch(/^[A-Z][a-z]+ [A-Z][a-z]+$/)
6127
6146
  })
6128
6147
 
6129
6148
  it('judges the fired check against the active harness, not a global installation of another', async () => {
@@ -6276,6 +6295,16 @@ describe('asking before the hooks have ever run', () => {
6276
6295
  await doctorCommand(deps, {})
6277
6296
  expect(io.outLines.join('\n')).toMatch(/FAIL\s+Codex hook trust.*Stop/is)
6278
6297
  expect(io.outLines.join('\n')).toMatch(/best-effort.*never writes.*\/hooks/is)
6298
+ const readiness = await assessReadiness(deps)
6299
+ expect(readiness.states.find((state) => state.id === 'hooks-trust')?.remedy).toMatchObject({
6300
+ command: '/hooks',
6301
+ user_action: {
6302
+ code: 'codex_hook_approval_required',
6303
+ harness: 'codex',
6304
+ action: 'approve_or_enable_notifai_hooks',
6305
+ message: expect.stringContaining('Open `/hooks` in Codex'),
6306
+ },
6307
+ })
6279
6308
  })
6280
6309
 
6281
6310
  it('gives doctor the same active-Codex diagnosis as ask', async () => {
@@ -6650,7 +6679,8 @@ describe('asking before the hooks have ever run', () => {
6650
6679
  const out = io.outLines.join('\n')
6651
6680
  expect(out).toMatch(/FAIL\s+Turn\-end hook shape/)
6652
6681
  expect(out).toContain('needs `async: true`')
6653
- expect(out).toContain('kills the backgrounded waiter at its 600s default')
6682
+ expect(out).toContain('kills the backgrounded waiter silently before the complete answer window')
6683
+ expect(out).toContain(`needs an explicit ${QUESTION_STOP_TIMEOUT_SECONDS}s`)
6654
6684
  })
6655
6685
 
6656
6686
  /** A configured Claude Code project with one live session claiming `pid`. */
package/src/commands.ts CHANGED
@@ -62,4 +62,9 @@ export {
62
62
  type SetupScope,
63
63
  } from './commands-init.js'
64
64
  export { assessReadiness, doctorCommand } from './commands-doctor.js'
65
+ export {
66
+ projectDisableCommand,
67
+ projectEnableCommand,
68
+ projectStatusCommand,
69
+ } from './commands-project.js'
65
70
  export { realIo } from './commands-io.js'
@@ -100,7 +100,7 @@ const INFO: Record<ConfigKey, Omit<ConfigKeyInfo, 'key'>> = {
100
100
  unit: 's',
101
101
  summary: 'Optional delay before a question may reach your devices',
102
102
  detail:
103
- 'Zero (the default) sends the question to your devices as soon as the agent turn ends. Set a positive duration to offer the terminal an exclusive answer window first; the timer is measured from the moment the agent asked.\n\nA timer and nothing more. Whether you happen to be at this keyboard does not change it: waiting no longer holds your terminal, so there is nothing your being here would protect you from.',
103
+ 'Zero (the default) sends the question to your devices as soon as the agent turn ends. Set a positive duration to offer the terminal an exclusive answer window first; the timer is measured from the moment the agent asked.\n\nThis controls when the question reaches devices, not how the harness keeps the Agent Session available afterward. Claude Code waits out of band; Codex holds the asking turn.',
104
104
  example: '0',
105
105
  },
106
106
 
@@ -111,7 +111,7 @@ const INFO: Record<ConfigKey, Omit<ConfigKeyInfo, 'key'>> = {
111
111
  unit: 's',
112
112
  summary: 'How long the service keeps accepting your answer to a question',
113
113
  detail:
114
- 'A question stays answerable for this long after it is asked. The default is a day, so a question that arrives while you are away is still yours to answer when you come back.\n\nThis is not how long anything waits on you. The agent ends its turn immediately; if it is still running when you answer, the answer reaches it directly, and otherwise it is delivered at that session\'s next turn. Shortening this only makes an answer arrive too late to be accepted.',
114
+ 'A question stays answerable for this long after it reaches the service. The default is a day, so a question that arrives while you are away is still yours to answer when you come back.\n\nQuestion Routing keeps the exact Agent Session available for this complete window. Claude Code waits out of band and wakes the Agent Session; Codex keeps its turn held. This is separate from `--reply-timeout`, which controls how long a direct `send --reply` command blocks.',
115
115
  example: '86400',
116
116
  },
117
117
 
@@ -141,10 +141,9 @@ describe('harness config layers', () => {
141
141
  expect(config.ask_grace_seconds).toEqual({ value: 0, source: 'default' })
142
142
  })
143
143
 
144
- it('bounds the terminal-first window so a reply window always survives it', () => {
144
+ it('bounds the terminal-first window to the supported preference', () => {
145
145
  // Config is readable from a repository, so an unbounded grace is hostile
146
- // input: it would eat the waiter's whole ceiling and leave no window the
147
- // server would accept an answer into.
146
+ // input rather than permission to hold question delivery arbitrarily.
148
147
  const { env, cwd } = setup({ globalToml: 'ask_grace_seconds = 100000\n' })
149
148
  expect(loadConfig({ cwd, env }).ask_grace_seconds.value).toBe(360)
150
149
  })
package/src/config.ts CHANGED
@@ -70,8 +70,8 @@ export interface CliConfig {
70
70
  * elapses does the question reach companion devices.
71
71
  *
72
72
  * A timer, and only a timer. Whether the user happens to be at this keyboard
73
- * decides nothing — the waiter no longer holds their terminal, so there is
74
- * nothing for their presence to protect them from.
73
+ * decides nothing; the harness-specific owner continues waiting either in
74
+ * the held turn or out of band.
75
75
  */
76
76
  ask_grace_seconds: ResolvedValue<number>
77
77
  /**
@@ -90,7 +90,7 @@ export interface CliConfig {
90
90
  * usefulness.
91
91
  */
92
92
  log_max_bytes: ResolvedValue<number>
93
- /** How long the server keeps accepting an answer to a question. */
93
+ /** How long the server accepts an answer and Question Routing owns its return path. */
94
94
  reply_window_seconds: ResolvedValue<number>
95
95
  /** Log files kept in total, the active one included. */
96
96
  log_max_files: ResolvedValue<number>
@@ -330,17 +330,14 @@ export function findProjectLocalConfigPath(
330
330
 
331
331
  /**
332
332
  * Ranges every layer is clamped to. Config is readable from a repository, so an
333
- * out-of-range value is attacker input, not a typo: an unbounded grace window
334
- * would consume the waiter's whole ceiling and silently leave no time at all in
335
- * which the user's answer could still be accepted.
333
+ * out-of-range value is attacker input, not a typo.
336
334
  */
337
335
  const NUMERIC_BOUNDS: Partial<Record<ConfigKey, { min: number; max: number }>> = {
338
336
  wait_seconds: { min: 0, max: 300 },
339
337
  ttl_seconds: { min: 0, max: 7 * 24 * 3600 },
340
- // A plain number, no longer derived from a hook budget's leftovers. Six
341
- // minutes leaves two clear minutes of the waiter's 480s ceiling, so the
342
- // longest window a user can choose still leaves an answer window the server
343
- // will accept — with room to spare for a timer that wakes late.
338
+ // Six minutes is the supported terminal-first preference bound. The Stop
339
+ // owner carries separate startup headroom, so this never shortens the answer
340
+ // window that begins after submission.
344
341
  ask_grace_seconds: { min: 0, max: 360 },
345
342
  // The floor is the shortest window the wire contract accepts; the ceiling is
346
343
  // the point past which a question would outlive the retained content it
@@ -24,6 +24,28 @@ export interface GuidanceTopic {
24
24
  content: string
25
25
  }
26
26
 
27
+ /** Canonical lifecycle vocabulary, always emitted from trusted CLI source. */
28
+ export const AGENT_TERMS_PREAMBLE = `# Notifai terms
29
+
30
+ An **Agent Event** is something that happened in the agent's work: completion,
31
+ failure, a blocker, a question that needs the User, or another meaningful
32
+ change. It stays inside the agent team until the Notification Request owner
33
+ decides it should reach the User.
34
+
35
+ A **Notification Request** is the deliberate User-visible message or question
36
+ submitted through Notifai about an Agent Event. It is not every event and it
37
+ is not an internal worker report.
38
+
39
+ The root agent owns Notification Requests by default. Ordinary workers report
40
+ Agent Events to their parent and do not notify the User themselves. Explicit
41
+ delegation transfers that ownership for the delegated work.`
42
+
43
+ export const AGENT_TERMS_FALLBACK = `# Notifai terms
44
+
45
+ An **Agent Event** is a meaningful occurrence in agent work. A **Notification
46
+ Request** is a deliberate User-visible message about one. Root owns requests;
47
+ workers report events unless ownership is explicitly delegated.`
48
+
27
49
  const WHEN_TO_NOTIFY = `# When to notify
28
50
 
29
51
  Notify when something changed for the user:
@@ -1,6 +1,6 @@
1
1
  import { Buffer } from 'node:buffer'
2
2
  import { describeSource } from './config-schema.js'
3
- import { GUIDANCE_TRUST_PREAMBLE } from './guidance-content.js'
3
+ import { AGENT_TERMS_FALLBACK, AGENT_TERMS_PREAMBLE, GUIDANCE_TRUST_PREAMBLE } from './guidance-content.js'
4
4
  import { resolveGuidance, type GuidanceAuthority, type ResolvedGuidanceTopic } from './guidance.js'
5
5
 
6
6
  const AUTHORITY_LABEL: Record<GuidanceAuthority, string> = {
@@ -24,7 +24,7 @@ export function renderGuidance(topics: readonly ResolvedGuidanceTopic[]): string
24
24
  `${filePath === null ? '' : ` file=${encodeURIComponent(filePath)}`} -->`
25
25
  return `${marker}\n${markerSafe(topic.content).trimEnd()}`
26
26
  })
27
- return [GUIDANCE_TRUST_PREAMBLE, ...blocks].join('\n\n')
27
+ return [GUIDANCE_TRUST_PREAMBLE, AGENT_TERMS_PREAMBLE, ...blocks].join('\n\n')
28
28
  }
29
29
 
30
30
  /** Hard ceiling for model-visible lifecycle context, including repository topics. */
@@ -48,7 +48,7 @@ export function boundedEffectiveGuidance(options: {
48
48
  bytes,
49
49
  max_bytes: maxBytes,
50
50
  fallback:
51
- `${GUIDANCE_TRUST_PREAMBLE}\n\n` +
51
+ `${GUIDANCE_TRUST_PREAMBLE}\n\n${AGENT_TERMS_FALLBACK}\n\n` +
52
52
  `Notifai guidance is ${bytes} bytes, above the ${maxBytes}-byte lifecycle limit. ` +
53
53
  'Before deciding whether or how to notify, run `notifai guidance` once in this session. ' +
54
54
  'Do not infer or partially follow the omitted topics.',
package/src/harnesses.ts CHANGED
@@ -38,13 +38,13 @@ export const HARNESS_CAPABILITIES: Record<Harness, HarnessCapability> = {
38
38
  stopContinuation: 'decision-block',
39
39
  deliveryRoutes: ['hook-continuation', 'inbox-socket', 'cold-resume', 'hold-for-next-turn'],
40
40
  deliveryContract:
41
- 'the Stop hook returns at once and waits out of band, then posts the answer into this same session over its own inbox socket; a session that has stopped is resumed only once a liveness probe proves it stopped',
41
+ 'the Stop hook returns at once and waits out of band through the complete answer window, then posts the answer into this same session over its own inbox socket; a session that has stopped is resumed only once a liveness probe proves it stopped',
42
42
  },
43
43
  codex: {
44
44
  stopContinuation: 'decision-block',
45
45
  deliveryRoutes: ['hook-continuation', 'cold-resume', 'hold-for-next-turn'],
46
46
  deliveryContract:
47
- 'live Stop continuation while the turn is held; once it returns, a stopped thread is resumed behind its writer lock and anything else waits for the next turn',
47
+ 'live Stop continuation while the turn is held through the complete answer window; crash recovery may resume only a stopped thread behind its writer lock',
48
48
  },
49
49
  cursor: {
50
50
  stopContinuation: 'unsupported',
@@ -21,6 +21,7 @@ import {
21
21
  isNpxAdapterTarget,
22
22
  } from './hook-adapter.js'
23
23
  import { buildHookConfig, codexHookIdentityHash, type InstalledHandler } from './install-hooks.js'
24
+ import { enableProject, projectBinding } from './project-enablement.js'
24
25
 
25
26
  function isolated(): { root: string; homeDir: string } {
26
27
  const root = mkdtempSync(path.join(os.tmpdir(), 'notifai-hook-adapter-'))
@@ -28,22 +29,40 @@ function isolated(): { root: string; homeDir: string } {
28
29
  }
29
30
 
30
31
  describe('stable hook adapter', () => {
31
- it('delivers session activation through the installed adapter before setup exists', () => {
32
+ it('keeps the installed adapter silent until the Project is enabled', () => {
32
33
  const { root, homeDir } = isolated()
34
+ const env = {
35
+ ...process.env,
36
+ HOME: homeDir,
37
+ XDG_CONFIG_HOME: path.join(root, 'config'),
38
+ XDG_STATE_HOME: path.join(root, 'state'),
39
+ }
33
40
  const scriptPath = fileURLToPath(new URL('../dist/main.js', import.meta.url))
34
41
  const installed = installHookAdapter({ execPath: process.execPath, scriptPath }, homeDir)
42
+ const disabled = spawnSync(
43
+ installed.path,
44
+ ['hook', 'session-start', '--owner', 'notifai', '--harness', 'codex'],
45
+ {
46
+ encoding: 'utf8',
47
+ input: JSON.stringify({ session_id: 'adapter-session', cwd: root, source: 'startup' }),
48
+ env,
49
+ },
50
+ )
51
+
52
+ expect(disabled.status).toBe(0)
53
+ expect(disabled.stderr).toBe('')
54
+ expect(disabled.stdout).toBe('')
55
+
56
+ const binding = projectBinding(root, env)
57
+ if (binding === null) throw new Error('test Project binding unavailable')
58
+ enableProject(binding)
35
59
  const run = spawnSync(
36
60
  installed.path,
37
61
  ['hook', 'session-start', '--owner', 'notifai', '--harness', 'codex'],
38
62
  {
39
63
  encoding: 'utf8',
40
64
  input: JSON.stringify({ session_id: 'adapter-session', cwd: root, source: 'startup' }),
41
- env: {
42
- ...process.env,
43
- HOME: homeDir,
44
- XDG_CONFIG_HOME: path.join(root, 'config'),
45
- XDG_STATE_HOME: path.join(root, 'state'),
46
- },
65
+ env,
47
66
  },
48
67
  )
49
68
 
@@ -52,7 +71,7 @@ describe('stable hook adapter', () => {
52
71
  expect(JSON.parse(run.stdout)).toMatchObject({
53
72
  hookSpecificOutput: {
54
73
  hookEventName: 'SessionStart',
55
- additionalContext: expect.stringMatching(/Notifai.*session/i),
74
+ additionalContext: expect.stringMatching(/Notifai.*enabled.*Project/i),
56
75
  },
57
76
  })
58
77
  })