@raidiant/notifai 9.2.2 → 10.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.
- package/CHANGELOG.md +45 -0
- package/README.md +8 -5
- package/dist/claude-wake.js +11 -0
- package/dist/claude-wake.js.map +1 -1
- package/dist/client.d.ts +3 -1
- package/dist/client.js +2 -0
- package/dist/client.js.map +1 -1
- package/dist/codex-wake.js +9 -0
- package/dist/codex-wake.js.map +1 -1
- package/dist/commands-agent-sessions.d.ts +6 -0
- package/dist/commands-agent-sessions.js +51 -0
- package/dist/commands-agent-sessions.js.map +1 -0
- package/dist/commands-config.js +11 -4
- package/dist/commands-config.js.map +1 -1
- package/dist/commands-core.d.ts +1 -1
- package/dist/commands-doctor.js +83 -6
- package/dist/commands-doctor.js.map +1 -1
- package/dist/commands-harness-context.d.ts +15 -2
- package/dist/commands-harness-context.js +32 -1
- package/dist/commands-harness-context.js.map +1 -1
- package/dist/commands-hook-shape.d.ts +4 -3
- package/dist/commands-hook-shape.js +19 -10
- package/dist/commands-hook-shape.js.map +1 -1
- package/dist/commands-hooks.d.ts +4 -3
- package/dist/commands-hooks.js +80 -23
- package/dist/commands-hooks.js.map +1 -1
- package/dist/commands-init.js +12 -1
- package/dist/commands-init.js.map +1 -1
- package/dist/commands-project.d.ts +4 -0
- package/dist/commands-project.js +41 -0
- package/dist/commands-project.js.map +1 -0
- package/dist/commands-send-support.js +0 -4
- package/dist/commands-send-support.js.map +1 -1
- package/dist/commands-send.js +12 -6
- package/dist/commands-send.js.map +1 -1
- package/dist/commands-skill.js +58 -0
- package/dist/commands-skill.js.map +1 -1
- package/dist/commands-sounds.d.ts +12 -0
- package/dist/commands-sounds.js +35 -0
- package/dist/commands-sounds.js.map +1 -0
- package/dist/commands.d.ts +3 -0
- package/dist/commands.js +3 -0
- package/dist/commands.js.map +1 -1
- package/dist/config-schema.js +7 -4
- package/dist/config-schema.js.map +1 -1
- package/dist/config.d.ts +6 -7
- package/dist/config.js +9 -9
- package/dist/config.js.map +1 -1
- package/dist/credentials.js +4 -1
- package/dist/credentials.js.map +1 -1
- package/dist/guidance-content.d.ts +3 -0
- package/dist/guidance-content.js +20 -0
- package/dist/guidance-content.js.map +1 -1
- package/dist/guidance-render.js +3 -3
- package/dist/guidance-render.js.map +1 -1
- package/dist/harnesses.d.ts +49 -7
- package/dist/harnesses.js +90 -24
- package/dist/harnesses.js.map +1 -1
- package/dist/hooks.d.ts +28 -52
- package/dist/hooks.js +393 -152
- package/dist/hooks.js.map +1 -1
- package/dist/install-hooks.d.ts +17 -39
- package/dist/install-hooks.js +21 -41
- package/dist/install-hooks.js.map +1 -1
- package/dist/invocation-context.d.ts +2 -2
- package/dist/orca-session-title.d.ts +2 -2
- package/dist/orca-session-title.js +3 -4
- package/dist/orca-session-title.js.map +1 -1
- package/dist/program.d.ts +6 -1
- package/dist/program.js +46 -5
- package/dist/program.js.map +1 -1
- package/dist/project-enablement.d.ts +15 -0
- package/dist/project-enablement.js +87 -0
- package/dist/project-enablement.js.map +1 -0
- package/dist/question-timing.d.ts +46 -0
- package/dist/question-timing.js +48 -0
- package/dist/question-timing.js.map +1 -0
- package/dist/readiness.d.ts +7 -0
- package/dist/readiness.js.map +1 -1
- package/dist/send.d.ts +2 -0
- package/dist/send.js +17 -15
- package/dist/send.js.map +1 -1
- package/dist/session-activation.d.ts +1 -1
- package/dist/session-activation.js +3 -4
- package/dist/session-activation.js.map +1 -1
- package/dist/session-labels.d.ts +18 -2
- package/dist/session-labels.js +79 -8
- package/dist/session-labels.js.map +1 -1
- package/dist/sound-ref.d.ts +23 -0
- package/dist/sound-ref.js +28 -0
- package/dist/sound-ref.js.map +1 -0
- package/package.json +3 -3
- package/src/claude-wake.test.ts +1 -0
- package/src/claude-wake.ts +10 -0
- package/src/client.test.ts +12 -0
- package/src/client.ts +8 -0
- package/src/codex-wake.test.ts +1 -0
- package/src/codex-wake.ts +9 -0
- package/src/commands-agent-sessions.ts +72 -0
- package/src/commands-config.ts +10 -4
- package/src/commands-core.ts +1 -1
- package/src/commands-doctor.ts +96 -8
- package/src/commands-harness-context.ts +48 -2
- package/src/commands-hook-shape.ts +26 -10
- package/src/commands-hooks.ts +95 -31
- package/src/commands-init.ts +11 -1
- package/src/commands-project.ts +47 -0
- package/src/commands-send-support.ts +0 -4
- package/src/commands-send.ts +10 -5
- package/src/commands-skill.test.ts +44 -0
- package/src/commands-skill.ts +57 -0
- package/src/commands-sounds.test.ts +106 -0
- package/src/commands-sounds.ts +49 -0
- package/src/commands.test.ts +386 -23
- package/src/commands.ts +10 -0
- package/src/config-schema.test.ts +1 -0
- package/src/config-schema.ts +7 -4
- package/src/config.test.ts +44 -23
- package/src/config.ts +13 -15
- package/src/credentials.test.ts +20 -0
- package/src/credentials.ts +4 -1
- package/src/fixtures/hermes-0.20.6-classic-cli-local.json +50 -0
- package/src/guidance-content.ts +22 -0
- package/src/guidance-render.ts +3 -3
- package/src/harness-integration.test.ts +212 -0
- package/src/harnesses.test.ts +107 -8
- package/src/harnesses.ts +111 -30
- package/src/hook-adapter.test.ts +27 -8
- package/src/hooks.test.ts +812 -83
- package/src/hooks.ts +529 -185
- package/src/install-hooks.test.ts +13 -16
- package/src/install-hooks.ts +40 -58
- package/src/invocation-context.ts +2 -2
- package/src/orca-session-title.test.ts +8 -49
- package/src/orca-session-title.ts +3 -5
- package/src/program.test.ts +45 -0
- package/src/program.ts +60 -4
- package/src/project-enablement.test.ts +55 -0
- package/src/project-enablement.ts +99 -0
- package/src/question-timing.ts +56 -0
- package/src/readiness.ts +8 -1
- package/src/send.ts +18 -20
- package/src/session-activation.ts +3 -4
- package/src/session-labels.test.ts +58 -9
- package/src/session-labels.ts +95 -12
- package/src/skill.test.ts +14 -3
- package/src/sound-ref.test.ts +31 -0
- package/src/sound-ref.ts +34 -0
- package/src/ui/config-view.test.ts +1 -0
|
@@ -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,
|
|
@@ -26,9 +27,9 @@ import {
|
|
|
26
27
|
WORKER_ACTIVATION_CONTEXT,
|
|
27
28
|
} from './session-activation.js'
|
|
28
29
|
import { hookAdapterPath, installHookAdapter } from './hook-adapter.js'
|
|
30
|
+
import { HOOK_INSTALLABLE_HARNESSES } from './harnesses.js'
|
|
29
31
|
import {
|
|
30
|
-
|
|
31
|
-
HARNESSES,
|
|
32
|
+
QUESTION_STOP_TIMEOUT_SECONDS,
|
|
32
33
|
applyPlan,
|
|
33
34
|
detectHarness,
|
|
34
35
|
detectedHarnesses,
|
|
@@ -152,18 +153,13 @@ describe('hook config', () => {
|
|
|
152
153
|
).toBe(true)
|
|
153
154
|
})
|
|
154
155
|
|
|
155
|
-
it('
|
|
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
|
-
|
|
161
|
-
|
|
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:
|
|
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:
|
|
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:
|
|
212
|
+
timeout: QUESTION_STOP_TIMEOUT_SECONDS,
|
|
217
213
|
async: true,
|
|
218
214
|
})
|
|
219
215
|
expect(migrated.replaced.sort()).toEqual([
|
|
@@ -1049,7 +1045,8 @@ describe('the OpenCode adapter', () => {
|
|
|
1049
1045
|
})
|
|
1050
1046
|
|
|
1051
1047
|
it('is a harness `hooks install` knows about', () => {
|
|
1052
|
-
expect(
|
|
1048
|
+
expect(HOOK_INSTALLABLE_HARNESSES).toContain('opencode')
|
|
1049
|
+
expect(HOOK_INSTALLABLE_HARNESSES).not.toContain('hermes')
|
|
1053
1050
|
expect(settingsFile('opencode', false, '/repo', {})).toContain('notifai.js')
|
|
1054
1051
|
})
|
|
1055
1052
|
|
|
@@ -1103,7 +1100,7 @@ describe('detectHarness', () => {
|
|
|
1103
1100
|
const dir = project('AGENTS.md')
|
|
1104
1101
|
const answer = detectHarness(dir)
|
|
1105
1102
|
expect(answer).not.toBe('codex')
|
|
1106
|
-
expect(
|
|
1103
|
+
expect(HOOK_INSTALLABLE_HARNESSES.includes(answer as never) || answer === null).toBe(true)
|
|
1107
1104
|
})
|
|
1108
1105
|
|
|
1109
1106
|
it('pairs AGENTS.md with CLAUDE.md as Claude Code, not Codex', () => {
|
|
@@ -1159,7 +1156,7 @@ describe('detectHarness', () => {
|
|
|
1159
1156
|
// Whatever this machine has, an empty project must never crash and must
|
|
1160
1157
|
// return either a single harness or null — never an arbitrary pick.
|
|
1161
1158
|
const answer = detectHarness(empty)
|
|
1162
|
-
expect(answer === null ||
|
|
1159
|
+
expect(answer === null || HOOK_INSTALLABLE_HARNESSES.includes(answer)).toBe(true)
|
|
1163
1160
|
})
|
|
1164
1161
|
})
|
|
1165
1162
|
|
package/src/install-hooks.ts
CHANGED
|
@@ -20,10 +20,12 @@ import {
|
|
|
20
20
|
type HookHostPlatform,
|
|
21
21
|
} from './hook-adapter.js'
|
|
22
22
|
import { opencodePluginPath, opencodePluginTarget } from './opencode-plugin.js'
|
|
23
|
-
import {
|
|
23
|
+
import { HOOK_INSTALLABLE_HARNESSES, type HookInstallableHarness } from './harnesses.js'
|
|
24
24
|
import { accountHome } from './platform.js'
|
|
25
|
-
|
|
26
|
-
|
|
25
|
+
import {
|
|
26
|
+
NON_ROUTING_STOP_TIMEOUT_SECONDS,
|
|
27
|
+
QUESTION_STOP_TIMEOUT_SECONDS,
|
|
28
|
+
} from './question-timing.js'
|
|
27
29
|
|
|
28
30
|
/**
|
|
29
31
|
* The three joints the OpenCode plugin hooks into, as (harness event, the
|
|
@@ -69,7 +71,7 @@ export interface HookGroup {
|
|
|
69
71
|
export type HookConfig = Record<string, HookGroup[]>
|
|
70
72
|
|
|
71
73
|
export interface InstallPlan {
|
|
72
|
-
harness:
|
|
74
|
+
harness: HookInstallableHarness
|
|
73
75
|
/** Absolute path of the settings file that will be written. */
|
|
74
76
|
file: string
|
|
75
77
|
/** True when the file is machine-wide rather than per-project. */
|
|
@@ -93,7 +95,7 @@ export interface HookCommandOptions {
|
|
|
93
95
|
export function hookCommand(
|
|
94
96
|
adapterPath: string,
|
|
95
97
|
event: string,
|
|
96
|
-
harness?:
|
|
98
|
+
harness?: HookInstallableHarness,
|
|
97
99
|
options: HookCommandOptions = {},
|
|
98
100
|
): string {
|
|
99
101
|
return (
|
|
@@ -170,50 +172,28 @@ export interface BuildOptions {
|
|
|
170
172
|
/** Stable user-level executable installed by the hook adapter module. */
|
|
171
173
|
adapterPath: string
|
|
172
174
|
/** The installed adapter stamps its exact harness into project pointers. */
|
|
173
|
-
harness?:
|
|
175
|
+
harness?: HookInstallableHarness
|
|
174
176
|
platform?: NodeJS.Platform | HookHostPlatform
|
|
175
177
|
/** Registered Node named first in Windows hook commands. */
|
|
176
178
|
nodePath?: string
|
|
177
179
|
}
|
|
178
180
|
|
|
179
181
|
/**
|
|
180
|
-
*
|
|
181
|
-
*
|
|
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.
|
|
191
|
-
*/
|
|
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`.
|
|
182
|
+
* Harnesses without asynchronous Question Routing use this short Stop budget.
|
|
183
|
+
* Claude Code and Codex use `QUESTION_STOP_TIMEOUT_SECONDS` instead.
|
|
203
184
|
*/
|
|
204
|
-
export const
|
|
185
|
+
export const NON_ROUTING_BLOCKING_STOP_TIMEOUT_SECONDS = NON_ROUTING_STOP_TIMEOUT_SECONDS
|
|
186
|
+
export { QUESTION_STOP_TIMEOUT_SECONDS } from './question-timing.js'
|
|
205
187
|
|
|
206
188
|
/**
|
|
207
189
|
* Whether this harness's Stop handler runs detached from the turn.
|
|
208
190
|
*
|
|
209
|
-
*
|
|
210
|
-
*
|
|
211
|
-
*
|
|
212
|
-
* waiter outlives a turn nobody left open. They were three separate spellings
|
|
213
|
-
* of the same condition with nothing tying them together.
|
|
191
|
+
* The installer and doctor share this predicate so they agree whether Claude
|
|
192
|
+
* runs out of band. Owner lifetime is route-neutral and lives in
|
|
193
|
+
* `question-timing.ts`.
|
|
214
194
|
*/
|
|
215
195
|
export function stopHandlerIsDetached(
|
|
216
|
-
harness:
|
|
196
|
+
harness: HookInstallableHarness | undefined,
|
|
217
197
|
platform?: Parameters<typeof hookHostPlatform>[0],
|
|
218
198
|
): boolean {
|
|
219
199
|
return harness === 'claude-code' && hookHostPlatform(platform) === 'posix'
|
|
@@ -224,22 +204,24 @@ export function stopHandlerIsDetached(
|
|
|
224
204
|
*
|
|
225
205
|
* Claude Code takes the answer over its own inbox socket, so its Stop hook is
|
|
226
206
|
* `async: true`: it returns immediately, the terminal is never held, and the
|
|
227
|
-
* waiter finishes out of band.
|
|
228
|
-
* continuation to
|
|
229
|
-
*
|
|
230
|
-
*
|
|
207
|
+
* waiter finishes out of band. Codex and Claude on Windows block and print a
|
|
208
|
+
* continuation to stdout. Every Question Routing owner declares the same
|
|
209
|
+
* complete-window timeout; changing Codex's definition requires one explicit
|
|
210
|
+
* trust approval.
|
|
231
211
|
*/
|
|
232
212
|
function stopHandler(
|
|
233
213
|
adapterPath: string,
|
|
234
|
-
harness:
|
|
214
|
+
harness: HookInstallableHarness | undefined,
|
|
235
215
|
options: HookCommandOptions,
|
|
236
216
|
): HookHandler {
|
|
237
217
|
const command = hookCommand(adapterPath, 'stop', harness, options)
|
|
238
218
|
if (stopHandlerIsDetached(harness, options.platform)) {
|
|
239
|
-
return { type: 'command', command, timeout:
|
|
219
|
+
return { type: 'command', command, timeout: QUESTION_STOP_TIMEOUT_SECONDS, async: true }
|
|
220
|
+
}
|
|
221
|
+
if (harness === 'codex' || harness === 'claude-code') {
|
|
222
|
+
return { type: 'command', command, timeout: QUESTION_STOP_TIMEOUT_SECONDS }
|
|
240
223
|
}
|
|
241
|
-
|
|
242
|
-
return { type: 'command', command, timeout: BLOCKING_STOP_TIMEOUT_SECONDS }
|
|
224
|
+
return { type: 'command', command, timeout: NON_ROUTING_BLOCKING_STOP_TIMEOUT_SECONDS }
|
|
243
225
|
}
|
|
244
226
|
|
|
245
227
|
function commandOptionsFrom(options: BuildOptions): HookCommandOptions {
|
|
@@ -350,7 +332,7 @@ export function buildCursorHookConfig(options: BuildOptions): CursorHookConfig {
|
|
|
350
332
|
},
|
|
351
333
|
{
|
|
352
334
|
command: hookCommand(options.adapterPath, 'stop', 'cursor', commandOptions),
|
|
353
|
-
timeout:
|
|
335
|
+
timeout: NON_ROUTING_BLOCKING_STOP_TIMEOUT_SECONDS,
|
|
354
336
|
// A continuation may register a real follow-up question. Match the
|
|
355
337
|
// session-state cap so those chains are useful but never unbounded.
|
|
356
338
|
loop_limit: 3,
|
|
@@ -371,7 +353,7 @@ export function buildCursorHookConfig(options: BuildOptions): CursorHookConfig {
|
|
|
371
353
|
* questions pushed to is not a property of the repository.
|
|
372
354
|
*/
|
|
373
355
|
export function settingsFile(
|
|
374
|
-
harness:
|
|
356
|
+
harness: HookInstallableHarness,
|
|
375
357
|
global: boolean,
|
|
376
358
|
cwd: string,
|
|
377
359
|
env: NodeJS.ProcessEnv = process.env,
|
|
@@ -622,7 +604,7 @@ export function withCodexLayerTransaction<T>(
|
|
|
622
604
|
|
|
623
605
|
/** Every file in this layer Codex might already be reading hooks from. */
|
|
624
606
|
export function hookDefinitionFiles(
|
|
625
|
-
harness:
|
|
607
|
+
harness: HookInstallableHarness,
|
|
626
608
|
global: boolean,
|
|
627
609
|
cwd: string,
|
|
628
610
|
env: NodeJS.ProcessEnv = process.env,
|
|
@@ -757,8 +739,8 @@ function ourHandlerEvents(document: SettingsDocument | null): string[] {
|
|
|
757
739
|
* document. Treating it as evidence of any one harness would be a guess
|
|
758
740
|
* dressed up as detection.
|
|
759
741
|
*/
|
|
760
|
-
function localHarnessEvidence(cwd: string):
|
|
761
|
-
const found:
|
|
742
|
+
function localHarnessEvidence(cwd: string): HookInstallableHarness[] {
|
|
743
|
+
const found: HookInstallableHarness[] = []
|
|
762
744
|
if (existsSync(path.join(cwd, '.claude')) || existsSync(path.join(cwd, 'CLAUDE.md'))) {
|
|
763
745
|
found.push('claude-code')
|
|
764
746
|
}
|
|
@@ -772,9 +754,9 @@ function localHarnessEvidence(cwd: string): Harness[] {
|
|
|
772
754
|
function globalHarnessEvidence(
|
|
773
755
|
env: NodeJS.ProcessEnv = process.env,
|
|
774
756
|
platform: NodeJS.Platform | HookHostPlatform = process.platform,
|
|
775
|
-
):
|
|
757
|
+
): HookInstallableHarness[] {
|
|
776
758
|
const home = harnessAccountHome(env, platform)
|
|
777
|
-
const found:
|
|
759
|
+
const found: HookInstallableHarness[] = []
|
|
778
760
|
if (existsSync(path.join(home, '.claude'))) found.push('claude-code')
|
|
779
761
|
if (existsSync(codexGlobalDir(env, platform))) found.push('codex')
|
|
780
762
|
if (existsSync(path.join(home, '.cursor'))) found.push('cursor')
|
|
@@ -793,12 +775,12 @@ export function detectedHarnesses(
|
|
|
793
775
|
cwd: string,
|
|
794
776
|
env: NodeJS.ProcessEnv = process.env,
|
|
795
777
|
platform: NodeJS.Platform | HookHostPlatform = process.platform,
|
|
796
|
-
):
|
|
797
|
-
const seen = new Set<
|
|
778
|
+
): HookInstallableHarness[] {
|
|
779
|
+
const seen = new Set<HookInstallableHarness>([
|
|
798
780
|
...localHarnessEvidence(cwd),
|
|
799
781
|
...globalHarnessEvidence(env, platform),
|
|
800
782
|
])
|
|
801
|
-
return
|
|
783
|
+
return HOOK_INSTALLABLE_HARNESSES.filter((harness) => seen.has(harness))
|
|
802
784
|
}
|
|
803
785
|
|
|
804
786
|
/**
|
|
@@ -814,7 +796,7 @@ export function detectHarness(
|
|
|
814
796
|
cwd: string,
|
|
815
797
|
env: NodeJS.ProcessEnv = process.env,
|
|
816
798
|
platform: NodeJS.Platform | HookHostPlatform = process.platform,
|
|
817
|
-
):
|
|
799
|
+
): HookInstallableHarness | null {
|
|
818
800
|
const local = localHarnessEvidence(cwd)
|
|
819
801
|
if (local.length > 0) return local.length === 1 ? local[0]! : null
|
|
820
802
|
const global = globalHarnessEvidence(env, platform)
|
|
@@ -1209,7 +1191,7 @@ export interface InstalledHandler {
|
|
|
1209
1191
|
}
|
|
1210
1192
|
|
|
1211
1193
|
export interface Installation {
|
|
1212
|
-
harness:
|
|
1194
|
+
harness: HookInstallableHarness
|
|
1213
1195
|
file: string
|
|
1214
1196
|
global: boolean
|
|
1215
1197
|
handlers: InstalledHandler[]
|
|
@@ -1347,7 +1329,7 @@ export function findInstallations(
|
|
|
1347
1329
|
...(nodePath === undefined ? {} : { nodePath }),
|
|
1348
1330
|
}
|
|
1349
1331
|
const found: Installation[] = []
|
|
1350
|
-
for (const harness of
|
|
1332
|
+
for (const harness of HOOK_INSTALLABLE_HARNESSES) {
|
|
1351
1333
|
for (const global of [false, true]) {
|
|
1352
1334
|
const files = hookDefinitionFiles(harness, global, cwd, env, platform)
|
|
1353
1335
|
for (const file of files) {
|
|
@@ -1427,7 +1409,7 @@ export function findInstallations(
|
|
|
1427
1409
|
}
|
|
1428
1410
|
|
|
1429
1411
|
function harnessMarkerProblems(
|
|
1430
|
-
harness:
|
|
1412
|
+
harness: HookInstallableHarness,
|
|
1431
1413
|
handlers: InstalledHandler[],
|
|
1432
1414
|
adapterHome?: string,
|
|
1433
1415
|
options: HookCommandOptions = {},
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { execFileSync } from 'node:child_process'
|
|
2
2
|
import path from 'node:path'
|
|
3
3
|
import { type SourceContextT } from '@raidiant/notifai-protocol'
|
|
4
|
-
import type {
|
|
4
|
+
import type { SourceContextHarness } from './harnesses.js'
|
|
5
5
|
import { resolveSessionLabel } from './session-labels.js'
|
|
6
6
|
|
|
7
7
|
export type GitCommand = (cwd: string, args: readonly string[]) => string | null
|
|
@@ -138,7 +138,7 @@ export interface SourceContextInput {
|
|
|
138
138
|
sessionId?: string
|
|
139
139
|
sessionLabel?: string
|
|
140
140
|
activeHarness?: {
|
|
141
|
-
harness:
|
|
141
|
+
harness: SourceContextHarness
|
|
142
142
|
sessionId?: string
|
|
143
143
|
sessionLabel?: string
|
|
144
144
|
sessionLabelPending?: boolean
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { describe, expect, it } from 'vitest'
|
|
2
2
|
import { readOrcaSessionTitle, type OrcaCommand } from './orca-session-title.js'
|
|
3
3
|
|
|
4
|
-
const worktreeId = 'repo-123::/worktrees/
|
|
4
|
+
const worktreeId = 'repo-123::/worktrees/agent-session-labels'
|
|
5
5
|
|
|
6
6
|
function response(overrides: Record<string, unknown> = {}): string {
|
|
7
7
|
return JSON.stringify({
|
|
@@ -9,16 +9,16 @@ function response(overrides: Record<string, unknown> = {}): string {
|
|
|
9
9
|
result: {
|
|
10
10
|
worktree: {
|
|
11
11
|
id: worktreeId,
|
|
12
|
-
path: '/worktrees/
|
|
13
|
-
displayName: '
|
|
12
|
+
path: '/worktrees/agent-session-labels',
|
|
13
|
+
displayName: 'Agent Session context labels',
|
|
14
14
|
...overrides,
|
|
15
15
|
},
|
|
16
16
|
},
|
|
17
17
|
})
|
|
18
18
|
}
|
|
19
19
|
|
|
20
|
-
describe('Orca
|
|
21
|
-
it('accepts only the
|
|
20
|
+
describe('Orca Agent Session title context', () => {
|
|
21
|
+
it('accepts only the User-facing title returned for the exact managed worktree', () => {
|
|
22
22
|
const calls: { executable: string; args: readonly string[] }[] = []
|
|
23
23
|
const command: OrcaCommand = (executable, args) => {
|
|
24
24
|
calls.push({ executable, args })
|
|
@@ -26,11 +26,8 @@ describe('Orca semantic session title', () => {
|
|
|
26
26
|
}
|
|
27
27
|
|
|
28
28
|
expect(
|
|
29
|
-
readOrcaSessionTitle(
|
|
30
|
-
|
|
31
|
-
command,
|
|
32
|
-
),
|
|
33
|
-
).toBe('Worker - semantic session implementation')
|
|
29
|
+
readOrcaSessionTitle({ TERM_PROGRAM: 'Orca', ORCA_WORKTREE_ID: worktreeId }, command),
|
|
30
|
+
).toBe('Agent Session context labels')
|
|
34
31
|
expect(calls).toEqual([
|
|
35
32
|
{
|
|
36
33
|
executable: 'orca',
|
|
@@ -39,52 +36,14 @@ describe('Orca semantic session title', () => {
|
|
|
39
36
|
])
|
|
40
37
|
})
|
|
41
38
|
|
|
42
|
-
it('uses Orca-provided executable selection without invoking a shell', () => {
|
|
43
|
-
const calls: string[] = []
|
|
44
|
-
expect(
|
|
45
|
-
readOrcaSessionTitle(
|
|
46
|
-
{
|
|
47
|
-
TERM_PROGRAM: 'Orca',
|
|
48
|
-
ORCA_WORKTREE_ID: worktreeId,
|
|
49
|
-
ORCA_CLI_COMMAND: '/mnt/c/Program Files/Orca/orca.exe',
|
|
50
|
-
},
|
|
51
|
-
(executable) => {
|
|
52
|
-
calls.push(executable)
|
|
53
|
-
return response()
|
|
54
|
-
},
|
|
55
|
-
),
|
|
56
|
-
).toBe('Worker - semantic session implementation')
|
|
57
|
-
expect(calls).toEqual(['/mnt/c/Program Files/Orca/orca.exe'])
|
|
58
|
-
})
|
|
59
|
-
|
|
60
|
-
it('ignores non-Orca and malformed selectors without running a command', () => {
|
|
61
|
-
const command: OrcaCommand = () => {
|
|
62
|
-
throw new Error('must not run')
|
|
63
|
-
}
|
|
64
|
-
expect(readOrcaSessionTitle({ ORCA_WORKTREE_ID: worktreeId }, command)).toBeUndefined()
|
|
65
|
-
expect(
|
|
66
|
-
readOrcaSessionTitle(
|
|
67
|
-
{ TERM_PROGRAM: 'Orca', ORCA_WORKTREE_ID: '/private/untrusted' },
|
|
68
|
-
command,
|
|
69
|
-
),
|
|
70
|
-
).toBeUndefined()
|
|
71
|
-
expect(
|
|
72
|
-
readOrcaSessionTitle(
|
|
73
|
-
{ TERM_PROGRAM: 'Orca', ORCA_WORKTREE_ID: 'repo-123::relative/path' },
|
|
74
|
-
command,
|
|
75
|
-
),
|
|
76
|
-
).toBeUndefined()
|
|
77
|
-
})
|
|
78
|
-
|
|
79
39
|
it.each([
|
|
80
40
|
['command failure', () => null],
|
|
81
|
-
['thrown command failure', () => { throw new Error('offline') }],
|
|
82
41
|
['malformed JSON', () => '{not json}'],
|
|
83
42
|
['failed response', () => JSON.stringify({ ok: false })],
|
|
84
43
|
['different worktree id', () => response({ id: 'repo-123::/worktrees/other' })],
|
|
85
44
|
['different worktree path', () => response({ path: '/private/untrusted' })],
|
|
86
45
|
['blank display name', () => response({ displayName: ' ' })],
|
|
87
|
-
])('
|
|
46
|
+
])('leaves the generated fallback exceptional when context is unavailable: %s', (_name, command) => {
|
|
88
47
|
expect(
|
|
89
48
|
readOrcaSessionTitle(
|
|
90
49
|
{ TERM_PROGRAM: 'Orca', ORCA_WORKTREE_ID: worktreeId },
|
|
@@ -26,9 +26,7 @@ function containsControlCharacter(value: string): boolean {
|
|
|
26
26
|
}
|
|
27
27
|
|
|
28
28
|
function worktreePathFromId(value: string): string | null {
|
|
29
|
-
if (value.length === 0 || value.length > 4_096 || containsControlCharacter(value))
|
|
30
|
-
return null
|
|
31
|
-
}
|
|
29
|
+
if (value.length === 0 || value.length > 4_096 || containsControlCharacter(value)) return null
|
|
32
30
|
const separator = value.indexOf('::')
|
|
33
31
|
if (separator <= 0) return null
|
|
34
32
|
const repoId = value.slice(0, separator)
|
|
@@ -48,11 +46,11 @@ function orcaExecutable(env: NodeJS.ProcessEnv): string | null {
|
|
|
48
46
|
}
|
|
49
47
|
|
|
50
48
|
/**
|
|
51
|
-
* Read the
|
|
49
|
+
* Read the User-facing task name assigned to this exact Orca worktree.
|
|
52
50
|
*
|
|
53
51
|
* The environment contributes only an opaque selector. Orca must return that
|
|
54
52
|
* exact id and its matching path before its display name becomes trusted; no
|
|
55
|
-
* path component or identifier is ever promoted into
|
|
53
|
+
* path component or identifier is ever promoted into an Agent Session label.
|
|
56
54
|
*/
|
|
57
55
|
export function readOrcaSessionTitle(
|
|
58
56
|
env: NodeJS.ProcessEnv,
|
package/src/program.test.ts
CHANGED
|
@@ -98,6 +98,51 @@ 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
|
+
|
|
119
|
+
it('dispatches the current Agent Session rename without exposing an id override', async () => {
|
|
120
|
+
let label: string | undefined
|
|
121
|
+
const result = await parse(['session', 'rename', 'Hermes Support'], {
|
|
122
|
+
agentSessionRename: (async (_deps, value) => ((label = value), 0)) as ProgramRunners['agentSessionRename'],
|
|
123
|
+
})
|
|
124
|
+
expect(result.exitCode).toBe(0)
|
|
125
|
+
expect(label).toBe('Hermes Support')
|
|
126
|
+
})
|
|
127
|
+
|
|
128
|
+
it('dispatches sounds --json onto soundsCommand', async () => {
|
|
129
|
+
let seen: Record<string, unknown> | undefined
|
|
130
|
+
const result = await parse(['sounds', '--json'], {
|
|
131
|
+
sounds: (async (_deps, flags) => ((seen = flags as Record<string, unknown>), 0)) as ProgramRunners['sounds'],
|
|
132
|
+
})
|
|
133
|
+
expect(result.exitCode).toBe(0)
|
|
134
|
+
expect(seen).toMatchObject({ json: true })
|
|
135
|
+
})
|
|
136
|
+
|
|
137
|
+
it('passes a custom sound name through --sound', async () => {
|
|
138
|
+
let seen: Record<string, unknown> | undefined
|
|
139
|
+
await parse(
|
|
140
|
+
['send', '--kind', 'done', '--title', 'T', '--body', 'B', '--sound', 'Kitchen timer'],
|
|
141
|
+
{ send: (async (_deps, flags) => ((seen = flags as Record<string, unknown>), 0)) as ProgramRunners['send'] },
|
|
142
|
+
)
|
|
143
|
+
expect(seen).toMatchObject({ sound: 'Kitchen timer' })
|
|
144
|
+
})
|
|
145
|
+
|
|
101
146
|
it('drops empty collector defaults so "not passed" stays absent', async () => {
|
|
102
147
|
let seen: Record<string, unknown> | undefined
|
|
103
148
|
await parse(
|
package/src/program.ts
CHANGED
|
@@ -4,6 +4,7 @@ import {
|
|
|
4
4
|
acknowledgeCommand,
|
|
5
5
|
askCommand,
|
|
6
6
|
accessStatusCommand,
|
|
7
|
+
agentSessionRenameCommand,
|
|
7
8
|
authStatusCommand,
|
|
8
9
|
capabilitiesCommand,
|
|
9
10
|
closeCommand,
|
|
@@ -23,15 +24,19 @@ import {
|
|
|
23
24
|
initCommand,
|
|
24
25
|
loginCommand,
|
|
25
26
|
logoutCommand,
|
|
27
|
+
projectDisableCommand,
|
|
28
|
+
projectEnableCommand,
|
|
29
|
+
projectStatusCommand,
|
|
26
30
|
logsCommand,
|
|
27
31
|
repliesCommand,
|
|
28
32
|
reportAskFailure,
|
|
29
33
|
sendCommand,
|
|
34
|
+
soundsCommand,
|
|
30
35
|
statusCommand,
|
|
31
36
|
type CommandDeps,
|
|
32
37
|
} from './commands.js'
|
|
33
38
|
import { packageVersion } from './release.js'
|
|
34
|
-
import {
|
|
39
|
+
import { HOOK_INSTALLABLE_HARNESSES } from './harnesses.js'
|
|
35
40
|
import type { Platform } from '@raidiant/notifai-protocol'
|
|
36
41
|
import type { SkillScope } from './native-skills.js'
|
|
37
42
|
import { GROUP, SEND_GROUP, helpConfiguration, rootHelpFooter } from './ui/help.js'
|
|
@@ -80,7 +85,12 @@ const defaultRunners = {
|
|
|
80
85
|
authStatus: authStatusCommand,
|
|
81
86
|
accessStatus: accessStatusCommand,
|
|
82
87
|
devices: devicesCommand,
|
|
88
|
+
sounds: soundsCommand,
|
|
83
89
|
capabilities: capabilitiesCommand,
|
|
90
|
+
projectEnable: projectEnableCommand,
|
|
91
|
+
projectDisable: projectDisableCommand,
|
|
92
|
+
projectStatus: projectStatusCommand,
|
|
93
|
+
agentSessionRename: agentSessionRenameCommand,
|
|
84
94
|
send: sendCommand,
|
|
85
95
|
replies: repliesCommand,
|
|
86
96
|
acknowledge: acknowledgeCommand,
|
|
@@ -250,6 +260,36 @@ export function buildProgram(deps: CommandDeps, options: BuildProgramOptions = {
|
|
|
250
260
|
exit(await runners.doctor(deps, opts))
|
|
251
261
|
})
|
|
252
262
|
|
|
263
|
+
const project = program
|
|
264
|
+
.command('project')
|
|
265
|
+
.helpGroup(GROUP.daily)
|
|
266
|
+
.summary('Control Notifai for this Project')
|
|
267
|
+
.description('Inspect or change this Project\'s User-owned lifecycle enablement')
|
|
268
|
+
project.command('enable').description('Enable lifecycle guidance for this Project').action(() => {
|
|
269
|
+
exit(runners.projectEnable(deps))
|
|
270
|
+
})
|
|
271
|
+
project.command('disable').description('Disable lifecycle guidance for this Project').action(() => {
|
|
272
|
+
exit(runners.projectDisable(deps))
|
|
273
|
+
})
|
|
274
|
+
project.command('status').description('Show lifecycle enablement for this Project').option('--json').action((opts: { json?: boolean }) => {
|
|
275
|
+
exit(runners.projectStatus(deps, opts.json === true))
|
|
276
|
+
})
|
|
277
|
+
|
|
278
|
+
const session = program
|
|
279
|
+
.command('session')
|
|
280
|
+
.helpGroup(GROUP.agent)
|
|
281
|
+
.summary('Inspect or rename the current Agent Session')
|
|
282
|
+
.description('Operate on the exact Agent Session owned by the active harness')
|
|
283
|
+
session
|
|
284
|
+
.command('rename <label>')
|
|
285
|
+
.description(
|
|
286
|
+
'Rename this exact Agent Session; agents use this only after its job changes completely enough that the old label would mislead',
|
|
287
|
+
)
|
|
288
|
+
.option('--json', 'machine-readable output')
|
|
289
|
+
.action(async (label: string, opts: { json?: boolean }) => {
|
|
290
|
+
exit(await runners.agentSessionRename(deps, label, opts))
|
|
291
|
+
})
|
|
292
|
+
|
|
253
293
|
program
|
|
254
294
|
.command('login')
|
|
255
295
|
.helpGroup(GROUP.start)
|
|
@@ -302,6 +342,18 @@ export function buildProgram(deps: CommandDeps, options: BuildProgramOptions = {
|
|
|
302
342
|
exit(await runners.devices(deps, opts))
|
|
303
343
|
})
|
|
304
344
|
|
|
345
|
+
program
|
|
346
|
+
.command('sounds')
|
|
347
|
+
.helpGroup(GROUP.daily)
|
|
348
|
+
.summary('List shipped and Account custom sounds')
|
|
349
|
+
.description(
|
|
350
|
+
'List the sounds --sound and the saved sound key accept: shipped names and this Account\'s custom sounds',
|
|
351
|
+
)
|
|
352
|
+
.option('--json', 'machine-readable output')
|
|
353
|
+
.action(async (opts: { json?: boolean }) => {
|
|
354
|
+
exit(await runners.sounds(deps, opts))
|
|
355
|
+
})
|
|
356
|
+
|
|
305
357
|
program
|
|
306
358
|
.command('capabilities')
|
|
307
359
|
.helpGroup(GROUP.advanced)
|
|
@@ -340,6 +392,7 @@ export function buildProgram(deps: CommandDeps, options: BuildProgramOptions = {
|
|
|
340
392
|
.optionsGroup(SEND_GROUP.routing)
|
|
341
393
|
.option('--kind <kind>', 'what this is (required): update | done | failed | blocked (no User reply would resume it) — question is set by --reply')
|
|
342
394
|
.option('--project <id>', 'Project identifier override (otherwise configured or inferred)')
|
|
395
|
+
.option('--projectless', 'deliberately omit Project identity for this notification')
|
|
343
396
|
.option('--device <id>', 'target a device id (repeatable)', (v: string, all: string[] = []) => [...all, v])
|
|
344
397
|
.option('--all', 'target all routable devices (overrides configured devices)')
|
|
345
398
|
.option('--session-id <id>', 'low-level automation override for an opaque exact Agent Session (env: NOTIFAI_SESSION_ID)')
|
|
@@ -367,7 +420,10 @@ export function buildProgram(deps: CommandDeps, options: BuildProgramOptions = {
|
|
|
367
420
|
)
|
|
368
421
|
.option('--multi', 'with --choice, several answers may be selected')
|
|
369
422
|
.optionsGroup(SEND_GROUP.advanced)
|
|
370
|
-
.option(
|
|
423
|
+
.option(
|
|
424
|
+
'--sound <sound>',
|
|
425
|
+
'override saved sound: default | done | attention | alert | none, or a custom name or id from notifai sounds',
|
|
426
|
+
)
|
|
371
427
|
.option(
|
|
372
428
|
'--level <level>',
|
|
373
429
|
'Apple-only interruption level: passive | active | time_sensitive (unsupported with --platform android)',
|
|
@@ -413,7 +469,7 @@ export function buildProgram(deps: CommandDeps, options: BuildProgramOptions = {
|
|
|
413
469
|
|
|
414
470
|
send.addHelpText(
|
|
415
471
|
'after',
|
|
416
|
-
`\nKind is required, and it selects the sound the notification arrives with: done rings the completion chime, failed the most insistent tone, blocked and question a distinct attention tone, update
|
|
472
|
+
`\nKind is required, and it selects the sound the notification arrives with: done rings the completion chime, failed the most insistent tone, blocked and question a distinct attention tone, update Device default.\nAn explicit --sound or saved sound preference outranks that default. --sound accepts a shipped name, an Account custom sound name or id from \`notifai sounds\`, or none. --level is Apple-only; Android attention is owned by kind, product channels, and device settings.\n`,
|
|
417
473
|
)
|
|
418
474
|
|
|
419
475
|
program
|
|
@@ -571,7 +627,7 @@ export function buildProgram(deps: CommandDeps, options: BuildProgramOptions = {
|
|
|
571
627
|
.option('--owner <name>', 'internal ownership marker')
|
|
572
628
|
.option('--harness <name>', 'internal harness output adapter')
|
|
573
629
|
.action(async (event: string, opts: { harness?: string }) => {
|
|
574
|
-
const harness =
|
|
630
|
+
const harness = HOOK_INSTALLABLE_HARNESSES.find((candidate) => candidate === opts.harness)
|
|
575
631
|
exit(await runners.hookRun(deps, event, () => readStdinWithTimeout(), harness))
|
|
576
632
|
})
|
|
577
633
|
|