@raidiant/notifai 9.3.0 → 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 +26 -0
- package/dist/client.d.ts +3 -1
- package/dist/client.js +2 -0
- package/dist/client.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 +3 -0
- package/dist/commands-core.js.map +1 -1
- package/dist/commands-doctor.js +42 -4
- 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-hooks.d.ts +4 -3
- package/dist/commands-hooks.js +17 -11
- package/dist/commands-hooks.js.map +1 -1
- package/dist/commands-send-support.js +18 -1
- package/dist/commands-send-support.js.map +1 -1
- package/dist/commands-send.js +2 -2
- package/dist/commands-send.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 +2 -0
- package/dist/commands.js +2 -0
- package/dist/commands.js.map +1 -1
- package/dist/config-schema.js +5 -2
- package/dist/config-schema.js.map +1 -1
- package/dist/config.d.ts +3 -4
- package/dist/config.js +5 -2
- package/dist/config.js.map +1 -1
- package/dist/credentials.js +4 -1
- package/dist/credentials.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 +2 -2
- package/dist/hooks.js +2 -2
- package/dist/hooks.js.map +1 -1
- package/dist/install-hooks.d.ts +10 -11
- package/dist/install-hooks.js +3 -4
- package/dist/install-hooks.js.map +1 -1
- package/dist/invocation-context.d.ts +2 -2
- package/dist/orca-session-title.d.ts +10 -0
- package/dist/orca-session-title.js +98 -0
- package/dist/orca-session-title.js.map +1 -0
- package/dist/program.d.ts +3 -1
- package/dist/program.js +28 -5
- package/dist/program.js.map +1 -1
- package/dist/send.js +11 -14
- package/dist/send.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/client.test.ts +12 -0
- package/src/client.ts +8 -0
- package/src/commands-agent-sessions.ts +72 -0
- package/src/commands-config.ts +10 -4
- package/src/commands-core.ts +3 -0
- package/src/commands-doctor.ts +48 -4
- package/src/commands-harness-context.ts +48 -2
- package/src/commands-hooks.ts +36 -18
- package/src/commands-send-support.ts +23 -1
- package/src/commands-send.ts +2 -2
- package/src/commands-sounds.test.ts +106 -0
- package/src/commands-sounds.ts +49 -0
- package/src/commands.test.ts +344 -11
- package/src/commands.ts +5 -0
- package/src/config-schema.test.ts +1 -0
- package/src/config-schema.ts +5 -2
- package/src/config.test.ts +42 -20
- package/src/config.ts +6 -5
- 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/harness-integration.test.ts +212 -0
- package/src/harnesses.test.ts +107 -8
- package/src/harnesses.ts +111 -30
- package/src/hooks.test.ts +4 -3
- package/src/hooks.ts +4 -4
- package/src/install-hooks.test.ts +5 -4
- package/src/install-hooks.ts +19 -21
- package/src/invocation-context.ts +2 -2
- package/src/orca-session-title.test.ts +54 -0
- package/src/orca-session-title.ts +103 -0
- package/src/program.test.ts +27 -0
- package/src/program.ts +38 -4
- package/src/send.ts +10 -19
- 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
|
@@ -27,9 +27,9 @@ import {
|
|
|
27
27
|
WORKER_ACTIVATION_CONTEXT,
|
|
28
28
|
} from './session-activation.js'
|
|
29
29
|
import { hookAdapterPath, installHookAdapter } from './hook-adapter.js'
|
|
30
|
+
import { HOOK_INSTALLABLE_HARNESSES } from './harnesses.js'
|
|
30
31
|
import {
|
|
31
32
|
QUESTION_STOP_TIMEOUT_SECONDS,
|
|
32
|
-
HARNESSES,
|
|
33
33
|
applyPlan,
|
|
34
34
|
detectHarness,
|
|
35
35
|
detectedHarnesses,
|
|
@@ -1045,7 +1045,8 @@ describe('the OpenCode adapter', () => {
|
|
|
1045
1045
|
})
|
|
1046
1046
|
|
|
1047
1047
|
it('is a harness `hooks install` knows about', () => {
|
|
1048
|
-
expect(
|
|
1048
|
+
expect(HOOK_INSTALLABLE_HARNESSES).toContain('opencode')
|
|
1049
|
+
expect(HOOK_INSTALLABLE_HARNESSES).not.toContain('hermes')
|
|
1049
1050
|
expect(settingsFile('opencode', false, '/repo', {})).toContain('notifai.js')
|
|
1050
1051
|
})
|
|
1051
1052
|
|
|
@@ -1099,7 +1100,7 @@ describe('detectHarness', () => {
|
|
|
1099
1100
|
const dir = project('AGENTS.md')
|
|
1100
1101
|
const answer = detectHarness(dir)
|
|
1101
1102
|
expect(answer).not.toBe('codex')
|
|
1102
|
-
expect(
|
|
1103
|
+
expect(HOOK_INSTALLABLE_HARNESSES.includes(answer as never) || answer === null).toBe(true)
|
|
1103
1104
|
})
|
|
1104
1105
|
|
|
1105
1106
|
it('pairs AGENTS.md with CLAUDE.md as Claude Code, not Codex', () => {
|
|
@@ -1155,7 +1156,7 @@ describe('detectHarness', () => {
|
|
|
1155
1156
|
// Whatever this machine has, an empty project must never crash and must
|
|
1156
1157
|
// return either a single harness or null — never an arbitrary pick.
|
|
1157
1158
|
const answer = detectHarness(empty)
|
|
1158
|
-
expect(answer === null ||
|
|
1159
|
+
expect(answer === null || HOOK_INSTALLABLE_HARNESSES.includes(answer)).toBe(true)
|
|
1159
1160
|
})
|
|
1160
1161
|
})
|
|
1161
1162
|
|
package/src/install-hooks.ts
CHANGED
|
@@ -20,15 +20,13 @@ 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
25
|
import {
|
|
26
26
|
NON_ROUTING_STOP_TIMEOUT_SECONDS,
|
|
27
27
|
QUESTION_STOP_TIMEOUT_SECONDS,
|
|
28
28
|
} from './question-timing.js'
|
|
29
29
|
|
|
30
|
-
export { HARNESSES, type Harness } from './harnesses.js'
|
|
31
|
-
|
|
32
30
|
/**
|
|
33
31
|
* The three joints the OpenCode plugin hooks into, as (harness event, the
|
|
34
32
|
* `notifai hook` event it runs). The plugin is a module and has no settings
|
|
@@ -73,7 +71,7 @@ export interface HookGroup {
|
|
|
73
71
|
export type HookConfig = Record<string, HookGroup[]>
|
|
74
72
|
|
|
75
73
|
export interface InstallPlan {
|
|
76
|
-
harness:
|
|
74
|
+
harness: HookInstallableHarness
|
|
77
75
|
/** Absolute path of the settings file that will be written. */
|
|
78
76
|
file: string
|
|
79
77
|
/** True when the file is machine-wide rather than per-project. */
|
|
@@ -97,7 +95,7 @@ export interface HookCommandOptions {
|
|
|
97
95
|
export function hookCommand(
|
|
98
96
|
adapterPath: string,
|
|
99
97
|
event: string,
|
|
100
|
-
harness?:
|
|
98
|
+
harness?: HookInstallableHarness,
|
|
101
99
|
options: HookCommandOptions = {},
|
|
102
100
|
): string {
|
|
103
101
|
return (
|
|
@@ -174,7 +172,7 @@ export interface BuildOptions {
|
|
|
174
172
|
/** Stable user-level executable installed by the hook adapter module. */
|
|
175
173
|
adapterPath: string
|
|
176
174
|
/** The installed adapter stamps its exact harness into project pointers. */
|
|
177
|
-
harness?:
|
|
175
|
+
harness?: HookInstallableHarness
|
|
178
176
|
platform?: NodeJS.Platform | HookHostPlatform
|
|
179
177
|
/** Registered Node named first in Windows hook commands. */
|
|
180
178
|
nodePath?: string
|
|
@@ -195,7 +193,7 @@ export { QUESTION_STOP_TIMEOUT_SECONDS } from './question-timing.js'
|
|
|
195
193
|
* `question-timing.ts`.
|
|
196
194
|
*/
|
|
197
195
|
export function stopHandlerIsDetached(
|
|
198
|
-
harness:
|
|
196
|
+
harness: HookInstallableHarness | undefined,
|
|
199
197
|
platform?: Parameters<typeof hookHostPlatform>[0],
|
|
200
198
|
): boolean {
|
|
201
199
|
return harness === 'claude-code' && hookHostPlatform(platform) === 'posix'
|
|
@@ -213,7 +211,7 @@ export function stopHandlerIsDetached(
|
|
|
213
211
|
*/
|
|
214
212
|
function stopHandler(
|
|
215
213
|
adapterPath: string,
|
|
216
|
-
harness:
|
|
214
|
+
harness: HookInstallableHarness | undefined,
|
|
217
215
|
options: HookCommandOptions,
|
|
218
216
|
): HookHandler {
|
|
219
217
|
const command = hookCommand(adapterPath, 'stop', harness, options)
|
|
@@ -355,7 +353,7 @@ export function buildCursorHookConfig(options: BuildOptions): CursorHookConfig {
|
|
|
355
353
|
* questions pushed to is not a property of the repository.
|
|
356
354
|
*/
|
|
357
355
|
export function settingsFile(
|
|
358
|
-
harness:
|
|
356
|
+
harness: HookInstallableHarness,
|
|
359
357
|
global: boolean,
|
|
360
358
|
cwd: string,
|
|
361
359
|
env: NodeJS.ProcessEnv = process.env,
|
|
@@ -606,7 +604,7 @@ export function withCodexLayerTransaction<T>(
|
|
|
606
604
|
|
|
607
605
|
/** Every file in this layer Codex might already be reading hooks from. */
|
|
608
606
|
export function hookDefinitionFiles(
|
|
609
|
-
harness:
|
|
607
|
+
harness: HookInstallableHarness,
|
|
610
608
|
global: boolean,
|
|
611
609
|
cwd: string,
|
|
612
610
|
env: NodeJS.ProcessEnv = process.env,
|
|
@@ -741,8 +739,8 @@ function ourHandlerEvents(document: SettingsDocument | null): string[] {
|
|
|
741
739
|
* document. Treating it as evidence of any one harness would be a guess
|
|
742
740
|
* dressed up as detection.
|
|
743
741
|
*/
|
|
744
|
-
function localHarnessEvidence(cwd: string):
|
|
745
|
-
const found:
|
|
742
|
+
function localHarnessEvidence(cwd: string): HookInstallableHarness[] {
|
|
743
|
+
const found: HookInstallableHarness[] = []
|
|
746
744
|
if (existsSync(path.join(cwd, '.claude')) || existsSync(path.join(cwd, 'CLAUDE.md'))) {
|
|
747
745
|
found.push('claude-code')
|
|
748
746
|
}
|
|
@@ -756,9 +754,9 @@ function localHarnessEvidence(cwd: string): Harness[] {
|
|
|
756
754
|
function globalHarnessEvidence(
|
|
757
755
|
env: NodeJS.ProcessEnv = process.env,
|
|
758
756
|
platform: NodeJS.Platform | HookHostPlatform = process.platform,
|
|
759
|
-
):
|
|
757
|
+
): HookInstallableHarness[] {
|
|
760
758
|
const home = harnessAccountHome(env, platform)
|
|
761
|
-
const found:
|
|
759
|
+
const found: HookInstallableHarness[] = []
|
|
762
760
|
if (existsSync(path.join(home, '.claude'))) found.push('claude-code')
|
|
763
761
|
if (existsSync(codexGlobalDir(env, platform))) found.push('codex')
|
|
764
762
|
if (existsSync(path.join(home, '.cursor'))) found.push('cursor')
|
|
@@ -777,12 +775,12 @@ export function detectedHarnesses(
|
|
|
777
775
|
cwd: string,
|
|
778
776
|
env: NodeJS.ProcessEnv = process.env,
|
|
779
777
|
platform: NodeJS.Platform | HookHostPlatform = process.platform,
|
|
780
|
-
):
|
|
781
|
-
const seen = new Set<
|
|
778
|
+
): HookInstallableHarness[] {
|
|
779
|
+
const seen = new Set<HookInstallableHarness>([
|
|
782
780
|
...localHarnessEvidence(cwd),
|
|
783
781
|
...globalHarnessEvidence(env, platform),
|
|
784
782
|
])
|
|
785
|
-
return
|
|
783
|
+
return HOOK_INSTALLABLE_HARNESSES.filter((harness) => seen.has(harness))
|
|
786
784
|
}
|
|
787
785
|
|
|
788
786
|
/**
|
|
@@ -798,7 +796,7 @@ export function detectHarness(
|
|
|
798
796
|
cwd: string,
|
|
799
797
|
env: NodeJS.ProcessEnv = process.env,
|
|
800
798
|
platform: NodeJS.Platform | HookHostPlatform = process.platform,
|
|
801
|
-
):
|
|
799
|
+
): HookInstallableHarness | null {
|
|
802
800
|
const local = localHarnessEvidence(cwd)
|
|
803
801
|
if (local.length > 0) return local.length === 1 ? local[0]! : null
|
|
804
802
|
const global = globalHarnessEvidence(env, platform)
|
|
@@ -1193,7 +1191,7 @@ export interface InstalledHandler {
|
|
|
1193
1191
|
}
|
|
1194
1192
|
|
|
1195
1193
|
export interface Installation {
|
|
1196
|
-
harness:
|
|
1194
|
+
harness: HookInstallableHarness
|
|
1197
1195
|
file: string
|
|
1198
1196
|
global: boolean
|
|
1199
1197
|
handlers: InstalledHandler[]
|
|
@@ -1331,7 +1329,7 @@ export function findInstallations(
|
|
|
1331
1329
|
...(nodePath === undefined ? {} : { nodePath }),
|
|
1332
1330
|
}
|
|
1333
1331
|
const found: Installation[] = []
|
|
1334
|
-
for (const harness of
|
|
1332
|
+
for (const harness of HOOK_INSTALLABLE_HARNESSES) {
|
|
1335
1333
|
for (const global of [false, true]) {
|
|
1336
1334
|
const files = hookDefinitionFiles(harness, global, cwd, env, platform)
|
|
1337
1335
|
for (const file of files) {
|
|
@@ -1411,7 +1409,7 @@ export function findInstallations(
|
|
|
1411
1409
|
}
|
|
1412
1410
|
|
|
1413
1411
|
function harnessMarkerProblems(
|
|
1414
|
-
harness:
|
|
1412
|
+
harness: HookInstallableHarness,
|
|
1415
1413
|
handlers: InstalledHandler[],
|
|
1416
1414
|
adapterHome?: string,
|
|
1417
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
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
import { readOrcaSessionTitle, type OrcaCommand } from './orca-session-title.js'
|
|
3
|
+
|
|
4
|
+
const worktreeId = 'repo-123::/worktrees/agent-session-labels'
|
|
5
|
+
|
|
6
|
+
function response(overrides: Record<string, unknown> = {}): string {
|
|
7
|
+
return JSON.stringify({
|
|
8
|
+
ok: true,
|
|
9
|
+
result: {
|
|
10
|
+
worktree: {
|
|
11
|
+
id: worktreeId,
|
|
12
|
+
path: '/worktrees/agent-session-labels',
|
|
13
|
+
displayName: 'Agent Session context labels',
|
|
14
|
+
...overrides,
|
|
15
|
+
},
|
|
16
|
+
},
|
|
17
|
+
})
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
describe('Orca Agent Session title context', () => {
|
|
21
|
+
it('accepts only the User-facing title returned for the exact managed worktree', () => {
|
|
22
|
+
const calls: { executable: string; args: readonly string[] }[] = []
|
|
23
|
+
const command: OrcaCommand = (executable, args) => {
|
|
24
|
+
calls.push({ executable, args })
|
|
25
|
+
return response()
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
expect(
|
|
29
|
+
readOrcaSessionTitle({ TERM_PROGRAM: 'Orca', ORCA_WORKTREE_ID: worktreeId }, command),
|
|
30
|
+
).toBe('Agent Session context labels')
|
|
31
|
+
expect(calls).toEqual([
|
|
32
|
+
{
|
|
33
|
+
executable: 'orca',
|
|
34
|
+
args: ['worktree', 'show', '--worktree', `id:${worktreeId}`, '--json'],
|
|
35
|
+
},
|
|
36
|
+
])
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
it.each([
|
|
40
|
+
['command failure', () => null],
|
|
41
|
+
['malformed JSON', () => '{not json}'],
|
|
42
|
+
['failed response', () => JSON.stringify({ ok: false })],
|
|
43
|
+
['different worktree id', () => response({ id: 'repo-123::/worktrees/other' })],
|
|
44
|
+
['different worktree path', () => response({ path: '/private/untrusted' })],
|
|
45
|
+
['blank display name', () => response({ displayName: ' ' })],
|
|
46
|
+
])('leaves the generated fallback exceptional when context is unavailable: %s', (_name, command) => {
|
|
47
|
+
expect(
|
|
48
|
+
readOrcaSessionTitle(
|
|
49
|
+
{ TERM_PROGRAM: 'Orca', ORCA_WORKTREE_ID: worktreeId },
|
|
50
|
+
command,
|
|
51
|
+
),
|
|
52
|
+
).toBeUndefined()
|
|
53
|
+
})
|
|
54
|
+
})
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import { execFileSync } from 'node:child_process'
|
|
2
|
+
import path from 'node:path'
|
|
3
|
+
|
|
4
|
+
export type OrcaSessionTitleLookup = (env: NodeJS.ProcessEnv) => string | undefined
|
|
5
|
+
export type OrcaCommand = (executable: string, args: readonly string[]) => string | null
|
|
6
|
+
|
|
7
|
+
const runOrcaCommand: OrcaCommand = (executable, args) => {
|
|
8
|
+
try {
|
|
9
|
+
return execFileSync(executable, args, {
|
|
10
|
+
encoding: 'utf8',
|
|
11
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
12
|
+
timeout: 2_000,
|
|
13
|
+
maxBuffer: 512 * 1024,
|
|
14
|
+
windowsHide: true,
|
|
15
|
+
}).trim()
|
|
16
|
+
} catch {
|
|
17
|
+
return null
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function containsControlCharacter(value: string): boolean {
|
|
22
|
+
return Array.from(value).some((character) => {
|
|
23
|
+
const codePoint = character.codePointAt(0) ?? 0
|
|
24
|
+
return codePoint < 32 || codePoint === 127
|
|
25
|
+
})
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function worktreePathFromId(value: string): string | null {
|
|
29
|
+
if (value.length === 0 || value.length > 4_096 || containsControlCharacter(value)) return null
|
|
30
|
+
const separator = value.indexOf('::')
|
|
31
|
+
if (separator <= 0) return null
|
|
32
|
+
const repoId = value.slice(0, separator)
|
|
33
|
+
const worktreePath = value.slice(separator + 2)
|
|
34
|
+
if (!/^[A-Za-z0-9._-]+$/u.test(repoId) || worktreePath.length === 0) return null
|
|
35
|
+
return path.posix.isAbsolute(worktreePath) || path.win32.isAbsolute(worktreePath)
|
|
36
|
+
? worktreePath
|
|
37
|
+
: null
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function orcaExecutable(env: NodeJS.ProcessEnv): string | null {
|
|
41
|
+
const configured = env['ORCA_CLI_COMMAND']?.trim()
|
|
42
|
+
if (configured !== undefined && configured.length > 0) {
|
|
43
|
+
return containsControlCharacter(configured) ? null : configured
|
|
44
|
+
}
|
|
45
|
+
return env['ORCA_DEV_REPO_ROOT'] === undefined ? 'orca' : 'orca-dev'
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Read the User-facing task name assigned to this exact Orca worktree.
|
|
50
|
+
*
|
|
51
|
+
* The environment contributes only an opaque selector. Orca must return that
|
|
52
|
+
* exact id and its matching path before its display name becomes trusted; no
|
|
53
|
+
* path component or identifier is ever promoted into an Agent Session label.
|
|
54
|
+
*/
|
|
55
|
+
export function readOrcaSessionTitle(
|
|
56
|
+
env: NodeJS.ProcessEnv,
|
|
57
|
+
command: OrcaCommand = runOrcaCommand,
|
|
58
|
+
): string | undefined {
|
|
59
|
+
if (env['TERM_PROGRAM'] !== 'Orca') return undefined
|
|
60
|
+
const worktreeId = env['ORCA_WORKTREE_ID']
|
|
61
|
+
if (worktreeId === undefined) return undefined
|
|
62
|
+
const expectedPath = worktreePathFromId(worktreeId)
|
|
63
|
+
if (expectedPath === null) return undefined
|
|
64
|
+
const executable = orcaExecutable(env)
|
|
65
|
+
if (executable === null) return undefined
|
|
66
|
+
|
|
67
|
+
let output: string | null
|
|
68
|
+
try {
|
|
69
|
+
output = command(executable, [
|
|
70
|
+
'worktree',
|
|
71
|
+
'show',
|
|
72
|
+
'--worktree',
|
|
73
|
+
`id:${worktreeId}`,
|
|
74
|
+
'--json',
|
|
75
|
+
])
|
|
76
|
+
} catch {
|
|
77
|
+
return undefined
|
|
78
|
+
}
|
|
79
|
+
if (output === null || output === '') return undefined
|
|
80
|
+
|
|
81
|
+
let parsed: unknown
|
|
82
|
+
try {
|
|
83
|
+
parsed = JSON.parse(output)
|
|
84
|
+
} catch {
|
|
85
|
+
return undefined
|
|
86
|
+
}
|
|
87
|
+
if (typeof parsed !== 'object' || parsed === null) return undefined
|
|
88
|
+
const response = parsed as {
|
|
89
|
+
ok?: unknown
|
|
90
|
+
result?: { worktree?: { id?: unknown; path?: unknown; displayName?: unknown } }
|
|
91
|
+
}
|
|
92
|
+
const worktree = response.result?.worktree
|
|
93
|
+
if (
|
|
94
|
+
response.ok !== true ||
|
|
95
|
+
worktree?.id !== worktreeId ||
|
|
96
|
+
worktree.path !== expectedPath ||
|
|
97
|
+
typeof worktree.displayName !== 'string' ||
|
|
98
|
+
worktree.displayName.trim().length === 0
|
|
99
|
+
) {
|
|
100
|
+
return undefined
|
|
101
|
+
}
|
|
102
|
+
return worktree.displayName
|
|
103
|
+
}
|
package/src/program.test.ts
CHANGED
|
@@ -116,6 +116,33 @@ describe('program argv parsing', () => {
|
|
|
116
116
|
expect(enabled).toBe(1)
|
|
117
117
|
})
|
|
118
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
|
+
|
|
119
146
|
it('drops empty collector defaults so "not passed" stays absent', async () => {
|
|
120
147
|
let seen: Record<string, unknown> | undefined
|
|
121
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,
|
|
@@ -30,11 +31,12 @@ import {
|
|
|
30
31
|
repliesCommand,
|
|
31
32
|
reportAskFailure,
|
|
32
33
|
sendCommand,
|
|
34
|
+
soundsCommand,
|
|
33
35
|
statusCommand,
|
|
34
36
|
type CommandDeps,
|
|
35
37
|
} from './commands.js'
|
|
36
38
|
import { packageVersion } from './release.js'
|
|
37
|
-
import {
|
|
39
|
+
import { HOOK_INSTALLABLE_HARNESSES } from './harnesses.js'
|
|
38
40
|
import type { Platform } from '@raidiant/notifai-protocol'
|
|
39
41
|
import type { SkillScope } from './native-skills.js'
|
|
40
42
|
import { GROUP, SEND_GROUP, helpConfiguration, rootHelpFooter } from './ui/help.js'
|
|
@@ -83,10 +85,12 @@ const defaultRunners = {
|
|
|
83
85
|
authStatus: authStatusCommand,
|
|
84
86
|
accessStatus: accessStatusCommand,
|
|
85
87
|
devices: devicesCommand,
|
|
88
|
+
sounds: soundsCommand,
|
|
86
89
|
capabilities: capabilitiesCommand,
|
|
87
90
|
projectEnable: projectEnableCommand,
|
|
88
91
|
projectDisable: projectDisableCommand,
|
|
89
92
|
projectStatus: projectStatusCommand,
|
|
93
|
+
agentSessionRename: agentSessionRenameCommand,
|
|
90
94
|
send: sendCommand,
|
|
91
95
|
replies: repliesCommand,
|
|
92
96
|
acknowledge: acknowledgeCommand,
|
|
@@ -271,6 +275,21 @@ export function buildProgram(deps: CommandDeps, options: BuildProgramOptions = {
|
|
|
271
275
|
exit(runners.projectStatus(deps, opts.json === true))
|
|
272
276
|
})
|
|
273
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
|
+
|
|
274
293
|
program
|
|
275
294
|
.command('login')
|
|
276
295
|
.helpGroup(GROUP.start)
|
|
@@ -323,6 +342,18 @@ export function buildProgram(deps: CommandDeps, options: BuildProgramOptions = {
|
|
|
323
342
|
exit(await runners.devices(deps, opts))
|
|
324
343
|
})
|
|
325
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
|
+
|
|
326
357
|
program
|
|
327
358
|
.command('capabilities')
|
|
328
359
|
.helpGroup(GROUP.advanced)
|
|
@@ -389,7 +420,10 @@ export function buildProgram(deps: CommandDeps, options: BuildProgramOptions = {
|
|
|
389
420
|
)
|
|
390
421
|
.option('--multi', 'with --choice, several answers may be selected')
|
|
391
422
|
.optionsGroup(SEND_GROUP.advanced)
|
|
392
|
-
.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
|
+
)
|
|
393
427
|
.option(
|
|
394
428
|
'--level <level>',
|
|
395
429
|
'Apple-only interruption level: passive | active | time_sensitive (unsupported with --platform android)',
|
|
@@ -435,7 +469,7 @@ export function buildProgram(deps: CommandDeps, options: BuildProgramOptions = {
|
|
|
435
469
|
|
|
436
470
|
send.addHelpText(
|
|
437
471
|
'after',
|
|
438
|
-
`\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`,
|
|
439
473
|
)
|
|
440
474
|
|
|
441
475
|
program
|
|
@@ -593,7 +627,7 @@ export function buildProgram(deps: CommandDeps, options: BuildProgramOptions = {
|
|
|
593
627
|
.option('--owner <name>', 'internal ownership marker')
|
|
594
628
|
.option('--harness <name>', 'internal harness output adapter')
|
|
595
629
|
.action(async (event: string, opts: { harness?: string }) => {
|
|
596
|
-
const harness =
|
|
630
|
+
const harness = HOOK_INSTALLABLE_HARNESSES.find((candidate) => candidate === opts.harness)
|
|
597
631
|
exit(await runners.hookRun(deps, event, () => readStdinWithTimeout(), harness))
|
|
598
632
|
})
|
|
599
633
|
|
package/src/send.ts
CHANGED
|
@@ -1,5 +1,4 @@
|
|
|
1
1
|
import {
|
|
2
|
-
CLI_SOUNDS,
|
|
3
2
|
COLLAPSE_KEY_MAX_BYTES,
|
|
4
3
|
INTERRUPTION_LEVELS,
|
|
5
4
|
MEDIA_MAX_ITEMS,
|
|
@@ -13,9 +12,6 @@ import {
|
|
|
13
12
|
type MediaItemT,
|
|
14
13
|
type NotificationDraftT,
|
|
15
14
|
NOTIFICATION_KINDS,
|
|
16
|
-
DEFAULT_NOTIFICATION_KIND,
|
|
17
|
-
defaultSoundForKind,
|
|
18
|
-
type NotificationKind,
|
|
19
15
|
PLATFORMS,
|
|
20
16
|
type Platform,
|
|
21
17
|
type QuestionT,
|
|
@@ -23,8 +19,7 @@ import {
|
|
|
23
19
|
type SubmissionReceipt,
|
|
24
20
|
} from '@raidiant/notifai-protocol'
|
|
25
21
|
import type { CliConfig } from './config.js'
|
|
26
|
-
|
|
27
|
-
type CliSound = (typeof CLI_SOUNDS)[number]
|
|
22
|
+
import { isCliSoundRef, unknownSoundMessage } from './sound-ref.js'
|
|
28
23
|
|
|
29
24
|
export interface SendFlags {
|
|
30
25
|
title: string
|
|
@@ -217,18 +212,15 @@ export function buildDraft(
|
|
|
217
212
|
}
|
|
218
213
|
}
|
|
219
214
|
|
|
220
|
-
//
|
|
221
|
-
//
|
|
222
|
-
//
|
|
223
|
-
//
|
|
224
|
-
const
|
|
225
|
-
|
|
226
|
-
: ((flags.kind as NotificationKind | undefined) ?? DEFAULT_NOTIFICATION_KIND)
|
|
227
|
-
const sound = flags.sound ?? config.sound.value ?? defaultSoundForKind(kindForSound)
|
|
228
|
-
if (sound !== null && sound !== undefined && !CLI_SOUNDS.includes(sound as CliSound)) {
|
|
215
|
+
// Explicit --sound and the saved sound key outrank kind. The server is the
|
|
216
|
+
// one that applies kind, Project, and Account maps — stamping a kind default
|
|
217
|
+
// here would look like an explicit choice and skip those layers. `none` is
|
|
218
|
+
// still silent.
|
|
219
|
+
const sound = flags.sound !== undefined ? flags.sound : config.sound.value
|
|
220
|
+
if (sound !== null && sound !== undefined && !isCliSoundRef(sound)) {
|
|
229
221
|
return {
|
|
230
222
|
ok: false,
|
|
231
|
-
error:
|
|
223
|
+
error: unknownSoundMessage(sound),
|
|
232
224
|
}
|
|
233
225
|
}
|
|
234
226
|
const level =
|
|
@@ -254,9 +246,8 @@ export function buildDraft(
|
|
|
254
246
|
appleOptions.sound = null
|
|
255
247
|
androidOptions.sound = null
|
|
256
248
|
} else if (sound !== null && sound !== undefined) {
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
androidOptions.sound = semanticSound
|
|
249
|
+
appleOptions.sound = sound
|
|
250
|
+
androidOptions.sound = sound
|
|
260
251
|
}
|
|
261
252
|
if (flags.threadId !== undefined) {
|
|
262
253
|
appleOptions.thread_id = flags.threadId
|
|
@@ -1,9 +1,20 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import {
|
|
2
|
+
existsSync,
|
|
3
|
+
mkdirSync,
|
|
4
|
+
mkdtempSync,
|
|
5
|
+
readFileSync,
|
|
6
|
+
readdirSync,
|
|
7
|
+
writeFileSync,
|
|
8
|
+
} from 'node:fs'
|
|
2
9
|
import os from 'node:os'
|
|
3
10
|
import path from 'node:path'
|
|
4
11
|
import { describe, expect, it } from 'vitest'
|
|
5
12
|
import { stateDir } from './config.js'
|
|
6
|
-
import {
|
|
13
|
+
import {
|
|
14
|
+
formatSessionFirstSeen,
|
|
15
|
+
renameStoredSessionLabel,
|
|
16
|
+
resolveSessionLabel,
|
|
17
|
+
} from './session-labels.js'
|
|
7
18
|
|
|
8
19
|
function fixture(): { env: NodeJS.ProcessEnv; now: number } {
|
|
9
20
|
const root = mkdtempSync(path.join(os.tmpdir(), 'notifai-session-labels-'))
|
|
@@ -416,26 +427,64 @@ describe('semantic session labels', () => {
|
|
|
416
427
|
})
|
|
417
428
|
})
|
|
418
429
|
|
|
419
|
-
it('
|
|
430
|
+
it('isolates an unreadable name store before creating a clean one', () => {
|
|
420
431
|
const { env, now } = fixture()
|
|
421
432
|
const file = path.join(stateDir(env), 'session-labels.json')
|
|
422
433
|
mkdirSync(path.dirname(file), { recursive: true })
|
|
423
434
|
writeFileSync(file, '{not json}\n')
|
|
424
435
|
|
|
425
|
-
|
|
436
|
+
expect(
|
|
437
|
+
resolveSessionLabel({
|
|
426
438
|
env,
|
|
427
439
|
now,
|
|
428
440
|
sessionId: 'new-session',
|
|
429
441
|
explicitLabel: 'Do not overwrite me',
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
expect(
|
|
433
|
-
|
|
434
|
-
|
|
442
|
+
}),
|
|
443
|
+
).toEqual({ ok: true, label: 'Do not overwrite me', source: 'explicit' })
|
|
444
|
+
expect(readFileSync(file, 'utf8')).not.toBe('{not json}\n')
|
|
445
|
+
const backups = readdirSync(path.dirname(file)).filter((name) =>
|
|
446
|
+
/^session-labels\.invalid-[a-f0-9]{64}\.json$/u.test(name),
|
|
447
|
+
)
|
|
448
|
+
expect(backups).toHaveLength(1)
|
|
449
|
+
expect(readFileSync(path.join(path.dirname(file), backups[0]!), 'utf8')).toBe('{not json}\n')
|
|
435
450
|
})
|
|
436
451
|
|
|
437
452
|
it('formats first seen using the machine local calendar', () => {
|
|
438
453
|
const now = new Date(2026, 0, 2, 3, 4).getTime()
|
|
439
454
|
expect(formatSessionFirstSeen(now)).toBe('Jan 2, 2026 03:04')
|
|
440
455
|
})
|
|
456
|
+
|
|
457
|
+
it('explicitly replaces a semantic or fallback label after an authorized rename', () => {
|
|
458
|
+
const { env, now } = fixture()
|
|
459
|
+
resolveSessionLabel({
|
|
460
|
+
env,
|
|
461
|
+
now,
|
|
462
|
+
sessionId: 'rename-me',
|
|
463
|
+
harness: 'codex',
|
|
464
|
+
explicitLabel: 'Old job',
|
|
465
|
+
})
|
|
466
|
+
|
|
467
|
+
expect(
|
|
468
|
+
renameStoredSessionLabel({
|
|
469
|
+
env,
|
|
470
|
+
now: now + 1_000,
|
|
471
|
+
sessionId: 'rename-me',
|
|
472
|
+
harness: 'codex',
|
|
473
|
+
label: ' Completely different job ',
|
|
474
|
+
}),
|
|
475
|
+
).toEqual({ ok: true, label: 'Completely different job', source: 'explicit' })
|
|
476
|
+
expect(
|
|
477
|
+
resolveSessionLabel({
|
|
478
|
+
env,
|
|
479
|
+
now: now + 2_000,
|
|
480
|
+
sessionId: 'rename-me',
|
|
481
|
+
harness: 'codex',
|
|
482
|
+
harnessLabel: 'Later task context',
|
|
483
|
+
}),
|
|
484
|
+
).toEqual({
|
|
485
|
+
ok: true,
|
|
486
|
+
label: 'Completely different job',
|
|
487
|
+
source: 'explicit',
|
|
488
|
+
})
|
|
489
|
+
})
|
|
441
490
|
})
|