@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
package/src/commands-doctor.ts
CHANGED
|
@@ -5,7 +5,12 @@ import { inspectClaudeInbox, systemClaudeWakeAdapters } from './claude-wake.js'
|
|
|
5
5
|
import { ApiCallError, type ApiClient } from './client.js'
|
|
6
6
|
import { inspectCodexResume } from './codex-wake.js'
|
|
7
7
|
import { type CliConfig } from './config.js'
|
|
8
|
-
import {
|
|
8
|
+
import {
|
|
9
|
+
HARNESS_CAPABILITIES,
|
|
10
|
+
HARNESS_LABELS,
|
|
11
|
+
HERMES_QUESTION_ROUTING_UNAVAILABLE,
|
|
12
|
+
isHookInstallableHarness,
|
|
13
|
+
} from './harnesses.js'
|
|
9
14
|
import { inspectHookAdapter } from './hook-adapter.js'
|
|
10
15
|
import {
|
|
11
16
|
readLiveProjectSessionPointers,
|
|
@@ -52,7 +57,6 @@ import {
|
|
|
52
57
|
} from './commands-core.js'
|
|
53
58
|
import { deviceInstallRemedy, readyCompanionDevices, supportPageUrl } from './commands-devices.js'
|
|
54
59
|
import {
|
|
55
|
-
activeHarnessSession,
|
|
56
60
|
claudeSessionPid,
|
|
57
61
|
resolveActiveHarness,
|
|
58
62
|
type ActiveHarnessSession,
|
|
@@ -761,7 +765,11 @@ export function remedyLine(state: ReadinessState): string {
|
|
|
761
765
|
*/
|
|
762
766
|
function hookStates(deps: CommandDeps): ReadinessState[] {
|
|
763
767
|
const installations = findInstallations(deps.cwd, deps.env, deps.hookAdapterHome, deps.hookPlatform)
|
|
764
|
-
const active
|
|
768
|
+
const { active, contested } = resolveActiveHarness(
|
|
769
|
+
deps.env,
|
|
770
|
+
deps.cwd,
|
|
771
|
+
(deps.now ?? Date.now)(),
|
|
772
|
+
)
|
|
765
773
|
const config = loadLoggedConfig(deps, { cwd: deps.cwd, env: deps.env })
|
|
766
774
|
const settings: ReadinessState = {
|
|
767
775
|
id: 'question-routing-settings',
|
|
@@ -782,6 +790,28 @@ function hookStates(deps: CommandDeps): ReadinessState[] {
|
|
|
782
790
|
}),
|
|
783
791
|
}
|
|
784
792
|
if (installations.length === 0) {
|
|
793
|
+
if (contested.length > 1) {
|
|
794
|
+
return [
|
|
795
|
+
{
|
|
796
|
+
id: 'hooks',
|
|
797
|
+
title: 'Question routing',
|
|
798
|
+
status: 'optional-gap',
|
|
799
|
+
detail: `Several harness sessions could own this shell (${contested.map((candidate) => candidate.label).join(', ')}); routing readiness is intentionally not attributed to either one`,
|
|
800
|
+
},
|
|
801
|
+
settings,
|
|
802
|
+
]
|
|
803
|
+
}
|
|
804
|
+
if (active !== null && !isHookInstallableHarness(active.harness)) {
|
|
805
|
+
return [
|
|
806
|
+
{
|
|
807
|
+
id: 'hooks',
|
|
808
|
+
title: 'Question routing',
|
|
809
|
+
status: 'optional-gap',
|
|
810
|
+
detail: `${active.label}: ${HERMES_QUESTION_ROUTING_UNAVAILABLE.deliveryContract}`,
|
|
811
|
+
},
|
|
812
|
+
settings,
|
|
813
|
+
]
|
|
814
|
+
}
|
|
785
815
|
return [
|
|
786
816
|
{
|
|
787
817
|
id: 'hooks',
|
|
@@ -925,7 +955,21 @@ function hookChecks(deps: CommandDeps): HookCheck[] {
|
|
|
925
955
|
active === null
|
|
926
956
|
? []
|
|
927
957
|
: installations.filter((installation) => installation.harness === active.harness)
|
|
928
|
-
if (
|
|
958
|
+
if (contested.length > 1) {
|
|
959
|
+
checks.push({
|
|
960
|
+
name: 'hooks (active harness)',
|
|
961
|
+
ok: false,
|
|
962
|
+
informational: true,
|
|
963
|
+
detail: `Several harness sessions could own this shell (${contested.map((candidate) => candidate.label).join(', ')}); routing readiness is intentionally not attributed to either one`,
|
|
964
|
+
})
|
|
965
|
+
} else if (active !== null && !isHookInstallableHarness(active.harness)) {
|
|
966
|
+
checks.push({
|
|
967
|
+
name: 'hooks (active harness)',
|
|
968
|
+
ok: true,
|
|
969
|
+
informational: true,
|
|
970
|
+
detail: `${active.label}: ${HERMES_QUESTION_ROUTING_UNAVAILABLE.deliveryContract}`,
|
|
971
|
+
})
|
|
972
|
+
} else if (active !== null) {
|
|
929
973
|
checks.push({
|
|
930
974
|
name: 'hooks (active harness)',
|
|
931
975
|
ok: activeInstallations.length > 0,
|
|
@@ -1,5 +1,10 @@
|
|
|
1
|
+
import {
|
|
2
|
+
hermesClassicCliLocalInstance,
|
|
3
|
+
isHookInstallableHarness,
|
|
4
|
+
type HermesClassicCliLocalInstance,
|
|
5
|
+
type SourceContextHarness,
|
|
6
|
+
} from './harnesses.js'
|
|
1
7
|
import { readLiveProjectSessionPointers, readSessionState } from './hooks.js'
|
|
2
|
-
import { type Harness } from './install-hooks.js'
|
|
3
8
|
|
|
4
9
|
/**
|
|
5
10
|
* Exact evidence that this shell command is running inside one supported
|
|
@@ -7,11 +12,17 @@ import { type Harness } from './install-hooks.js'
|
|
|
7
12
|
* describe where a tool stores files, not which tool owns the current shell.
|
|
8
13
|
* OpenCode's generated plugin supplies the Notifai-owned marker because its
|
|
9
14
|
* plugin API exposes Agent Session identity but the ordinary environment does not.
|
|
15
|
+
*
|
|
16
|
+
* Hermes contributes `HERMES_SESSION_ID` only — the durable Agent Session id
|
|
17
|
+
* bridged into tool subprocesses. `HERMES_SESSION_KEY` is a gateway routing
|
|
18
|
+
* key and must never be treated as Agent Session identity.
|
|
10
19
|
*/
|
|
11
20
|
export interface ActiveHarnessSession {
|
|
12
|
-
harness:
|
|
21
|
+
harness: SourceContextHarness
|
|
13
22
|
label: string
|
|
14
23
|
sessionId?: string
|
|
24
|
+
/** Exact surface/backend cell proven for this marker envelope. */
|
|
25
|
+
integrationInstance?: HermesClassicCliLocalInstance
|
|
15
26
|
/** Trusted human title published by this harness's managed adapter. */
|
|
16
27
|
sessionLabel?: string
|
|
17
28
|
/** The harness has only a temporary placeholder title so far. */
|
|
@@ -53,6 +64,16 @@ function harnessEnvCandidates(env: NodeJS.ProcessEnv): ActiveHarnessSession[] {
|
|
|
53
64
|
candidates.push({ harness: 'codex', label: 'Codex', sessionId: codexSession })
|
|
54
65
|
}
|
|
55
66
|
if ((env['CURSOR_AGENT'] ?? '') !== '') candidates.push({ harness: 'cursor', label: 'Cursor' })
|
|
67
|
+
const hermesSession = env['HERMES_SESSION_ID']
|
|
68
|
+
if (hermesSession !== undefined && hermesSession !== '') {
|
|
69
|
+
const integrationInstance = hermesClassicCliLocalInstance(env)
|
|
70
|
+
candidates.push({
|
|
71
|
+
harness: 'hermes',
|
|
72
|
+
label: 'Hermes',
|
|
73
|
+
sessionId: hermesSession,
|
|
74
|
+
...(integrationInstance === null ? {} : { integrationInstance }),
|
|
75
|
+
})
|
|
76
|
+
}
|
|
56
77
|
return candidates
|
|
57
78
|
}
|
|
58
79
|
|
|
@@ -97,6 +118,12 @@ export function resolveActiveHarness(
|
|
|
97
118
|
if (first === undefined) return { active: null, contested: [] }
|
|
98
119
|
if (candidates.length === 1) return { active: first, contested: [] }
|
|
99
120
|
|
|
121
|
+
// A send-only harness has no hook evidence, so live pointer/state of a
|
|
122
|
+
// hook-installable parent cannot prove which nested process owns this
|
|
123
|
+
// shell. Mixing Hermes with another marker therefore stays contested.
|
|
124
|
+
const mixedWithSendOnly = candidates.some((candidate) => !isHookInstallableHarness(candidate.harness))
|
|
125
|
+
if (mixedWithSendOnly) return { active: first, contested: candidates }
|
|
126
|
+
|
|
100
127
|
// Exact lifecycle state is machine-global and survives a command moving to
|
|
101
128
|
// another linked checkout. Prefer it before the checkout-local
|
|
102
129
|
// pointer. A unique newest hook event proves which nested harness owns this
|
|
@@ -133,6 +160,25 @@ export function activeHarnessSession(
|
|
|
133
160
|
return resolveActiveHarness(env, cwd, now).active
|
|
134
161
|
}
|
|
135
162
|
|
|
163
|
+
/**
|
|
164
|
+
* Source Context attribution. Hook-installable nesting still uses declared
|
|
165
|
+
* order when nothing has fired — that existing send behavior is preserved.
|
|
166
|
+
* A contested mix that includes a send-only harness (Hermes) fails closed:
|
|
167
|
+
* the wrong Agent Session would otherwise be named as the owner.
|
|
168
|
+
*/
|
|
169
|
+
export function sourceContextHarnessSession(
|
|
170
|
+
env: NodeJS.ProcessEnv,
|
|
171
|
+
cwd: string,
|
|
172
|
+
now: number,
|
|
173
|
+
): ActiveHarnessSession | null {
|
|
174
|
+
const { active, contested } = resolveActiveHarness(env, cwd, now)
|
|
175
|
+
if (contested.some((candidate) => !isHookInstallableHarness(candidate.harness))) {
|
|
176
|
+
return null
|
|
177
|
+
}
|
|
178
|
+
if (active?.harness === 'hermes' && active.integrationInstance === undefined) return null
|
|
179
|
+
return active
|
|
180
|
+
}
|
|
181
|
+
|
|
136
182
|
/**
|
|
137
183
|
* The pid of the Claude Code session this hook belongs to.
|
|
138
184
|
*
|
package/src/commands-hooks.ts
CHANGED
|
@@ -21,7 +21,14 @@ import { ApiCallError } from './client.js'
|
|
|
21
21
|
import { codexWakeRoute } from './codex-wake.js'
|
|
22
22
|
import { loadConfig, type CliConfig } from './config.js'
|
|
23
23
|
import { withTargetFileLock } from './file-lock.js'
|
|
24
|
-
import {
|
|
24
|
+
import {
|
|
25
|
+
HARNESS_LABELS,
|
|
26
|
+
HERMES_QUESTION_ROUTING_UNAVAILABLE,
|
|
27
|
+
HOOK_INSTALLABLE_HARNESSES,
|
|
28
|
+
isHookInstallableHarness,
|
|
29
|
+
questionRoutingCapability,
|
|
30
|
+
type HookInstallableHarness,
|
|
31
|
+
} from './harnesses.js'
|
|
25
32
|
import {
|
|
26
33
|
inspectHookAdapter,
|
|
27
34
|
installHookAdapter,
|
|
@@ -51,7 +58,6 @@ import {
|
|
|
51
58
|
} from './hooks.js'
|
|
52
59
|
import {
|
|
53
60
|
NON_ROUTING_BLOCKING_STOP_TIMEOUT_SECONDS,
|
|
54
|
-
HARNESSES,
|
|
55
61
|
applyPlan,
|
|
56
62
|
buildCursorHookConfig,
|
|
57
63
|
buildHookConfig,
|
|
@@ -75,7 +81,6 @@ import {
|
|
|
75
81
|
removeHooks,
|
|
76
82
|
settingsFile,
|
|
77
83
|
withCodexLayerTransaction,
|
|
78
|
-
type Harness,
|
|
79
84
|
type Installation,
|
|
80
85
|
} from './install-hooks.js'
|
|
81
86
|
import { QUESTION_WAITER_CEILING_SECONDS } from './question-timing.js'
|
|
@@ -138,7 +143,7 @@ export const HOOK_EVENTS = [
|
|
|
138
143
|
export type HookEvent = (typeof HOOK_EVENTS)[number]
|
|
139
144
|
|
|
140
145
|
/** Lifecycle handlers one installed harness must carry in this CLI build. */
|
|
141
|
-
export function requiredHookEvents(harness:
|
|
146
|
+
export function requiredHookEvents(harness: HookInstallableHarness): readonly HookEvent[] {
|
|
142
147
|
if (harness === 'opencode') return []
|
|
143
148
|
return harness === 'cursor'
|
|
144
149
|
? ['session-start', 'activation-stop', 'user-prompt-submit', 'stop', 'session-end']
|
|
@@ -572,7 +577,7 @@ export async function hookRunCommand(
|
|
|
572
577
|
*/
|
|
573
578
|
function stopWakeRoute(
|
|
574
579
|
deps: CommandDeps,
|
|
575
|
-
harness:
|
|
580
|
+
harness: HookInstallableHarness | undefined,
|
|
576
581
|
sessionId: string | undefined,
|
|
577
582
|
cwd: string,
|
|
578
583
|
): EscalationDeliveryRoute | undefined {
|
|
@@ -1102,6 +1107,17 @@ export function askCommand(
|
|
|
1102
1107
|
'run the ask from a shell with one exact active harness session',
|
|
1103
1108
|
)
|
|
1104
1109
|
}
|
|
1110
|
+
if (!isHookInstallableHarness(active.harness)) {
|
|
1111
|
+
const capability = HERMES_QUESTION_ROUTING_UNAVAILABLE
|
|
1112
|
+
return askFailure(
|
|
1113
|
+
deps,
|
|
1114
|
+
flags,
|
|
1115
|
+
'question_routing_unavailable',
|
|
1116
|
+
'hook_contract',
|
|
1117
|
+
`${active.label}: ${capability.deliveryContract}`,
|
|
1118
|
+
'use a blocking `notifai send --reply` question',
|
|
1119
|
+
)
|
|
1120
|
+
}
|
|
1105
1121
|
const exactState = active.sessionId === undefined
|
|
1106
1122
|
? null
|
|
1107
1123
|
: readSessionState(active.sessionId, deps.env)
|
|
@@ -1253,7 +1269,9 @@ export function activeQuestionRouteProblems(
|
|
|
1253
1269
|
`the active ${active.label} shell does not expose an exact session id; a project-level last-writer pointer can cross-wire two sessions. Use a blocking \`notifai send --reply\` question`,
|
|
1254
1270
|
)
|
|
1255
1271
|
}
|
|
1256
|
-
const capability =
|
|
1272
|
+
const capability = isHookInstallableHarness(active.harness)
|
|
1273
|
+
? questionRoutingCapability(active.harness)
|
|
1274
|
+
: HERMES_QUESTION_ROUTING_UNAVAILABLE
|
|
1257
1275
|
if (capability.stopContinuation === 'unsupported') {
|
|
1258
1276
|
problems.push(`${active.label}: ${capability.deliveryContract}`)
|
|
1259
1277
|
}
|
|
@@ -1683,7 +1701,7 @@ function resolveHookAdapterTarget(deps: CommandDeps, flags: HooksInstallFlags):
|
|
|
1683
1701
|
return { execPath, scriptPath }
|
|
1684
1702
|
}
|
|
1685
1703
|
|
|
1686
|
-
function printHooksInstallClose(deps: CommandDeps, harness:
|
|
1704
|
+
function printHooksInstallClose(deps: CommandDeps, harness: HookInstallableHarness, file: string): void {
|
|
1687
1705
|
const label = HARNESS_LABELS[harness]
|
|
1688
1706
|
const activation =
|
|
1689
1707
|
harness === 'codex'
|
|
@@ -1705,7 +1723,7 @@ export function hooksInstallCommand(deps: CommandDeps, flags: HooksInstallFlags)
|
|
|
1705
1723
|
if (flags.harness === undefined) {
|
|
1706
1724
|
const detected = detectedHarnesses(deps.cwd, deps.env)
|
|
1707
1725
|
if (detected.length === 0) {
|
|
1708
|
-
deps.io.err(`Could not tell which harness you mean — pass --harness <${
|
|
1726
|
+
deps.io.err(`Could not tell which harness you mean — pass --harness <${HOOK_INSTALLABLE_HARNESSES.join('|')}>.`)
|
|
1709
1727
|
return EXIT.usage
|
|
1710
1728
|
}
|
|
1711
1729
|
let ok = true
|
|
@@ -2049,17 +2067,17 @@ function assertOwnedRegularFile(file: string): void {
|
|
|
2049
2067
|
}
|
|
2050
2068
|
}
|
|
2051
2069
|
|
|
2052
|
-
function resolveHarness(deps: CommandDeps, requested: string | undefined):
|
|
2070
|
+
function resolveHarness(deps: CommandDeps, requested: string | undefined): HookInstallableHarness | null {
|
|
2053
2071
|
if (requested !== undefined) {
|
|
2054
|
-
if ((
|
|
2072
|
+
if ((HOOK_INSTALLABLE_HARNESSES as readonly string[]).includes(requested)) return requested as HookInstallableHarness
|
|
2055
2073
|
deps.io.err(
|
|
2056
|
-
`Unknown harness "${requested}". Supported: ${
|
|
2074
|
+
`Unknown harness "${requested}". Supported: ${HOOK_INSTALLABLE_HARNESSES.join(', ')}.`,
|
|
2057
2075
|
)
|
|
2058
2076
|
return null
|
|
2059
2077
|
}
|
|
2060
2078
|
const detected = detectHarness(deps.cwd, deps.env)
|
|
2061
2079
|
if (!detected) {
|
|
2062
|
-
deps.io.err(`Could not tell which harness to install for — pass --harness <${
|
|
2080
|
+
deps.io.err(`Could not tell which harness to install for — pass --harness <${HOOK_INSTALLABLE_HARNESSES.join('|')}>.`)
|
|
2063
2081
|
return null
|
|
2064
2082
|
}
|
|
2065
2083
|
return detected
|
|
@@ -2073,7 +2091,7 @@ function resolveHarness(deps: CommandDeps, requested: string | undefined): Harne
|
|
|
2073
2091
|
export async function pickHarnessesToInstall(
|
|
2074
2092
|
deps: CommandDeps,
|
|
2075
2093
|
requested?: string,
|
|
2076
|
-
): Promise<
|
|
2094
|
+
): Promise<HookInstallableHarness[] | null> {
|
|
2077
2095
|
if (requested !== undefined) {
|
|
2078
2096
|
const harness = resolveHarness(deps, requested)
|
|
2079
2097
|
return harness === null ? null : [harness]
|
|
@@ -2083,7 +2101,7 @@ export async function pickHarnessesToInstall(
|
|
|
2083
2101
|
if (deps.io.interactive === true && deps.io.multiselect) {
|
|
2084
2102
|
const picked = await deps.io.multiselect(
|
|
2085
2103
|
'Which agent harnesses should Notifai wire here?',
|
|
2086
|
-
|
|
2104
|
+
HOOK_INSTALLABLE_HARNESSES.map((name) => ({
|
|
2087
2105
|
value: name,
|
|
2088
2106
|
label: HARNESS_LABELS[name],
|
|
2089
2107
|
...(detected.includes(name) ? { hint: 'detected on this machine' } : {}),
|
|
@@ -2091,16 +2109,16 @@ export async function pickHarnessesToInstall(
|
|
|
2091
2109
|
detected,
|
|
2092
2110
|
)
|
|
2093
2111
|
if (picked === null) return null
|
|
2094
|
-
const unknown = picked.filter((name) => !(
|
|
2112
|
+
const unknown = picked.filter((name) => !(HOOK_INSTALLABLE_HARNESSES as readonly string[]).includes(name))
|
|
2095
2113
|
if (unknown.length > 0) {
|
|
2096
|
-
deps.io.err(`Unknown harness "${unknown[0]}". Supported: ${
|
|
2114
|
+
deps.io.err(`Unknown harness "${unknown[0]}". Supported: ${HOOK_INSTALLABLE_HARNESSES.join(', ')}.`)
|
|
2097
2115
|
return null
|
|
2098
2116
|
}
|
|
2099
|
-
return picked as
|
|
2117
|
+
return picked as HookInstallableHarness[]
|
|
2100
2118
|
}
|
|
2101
2119
|
if (detected.length === 0) {
|
|
2102
2120
|
deps.io.err(
|
|
2103
|
-
`Could not tell which harness to wire. Run: notifai hooks install --harness <${
|
|
2121
|
+
`Could not tell which harness to wire. Run: notifai hooks install --harness <${HOOK_INSTALLABLE_HARNESSES.join('|')}>`,
|
|
2104
2122
|
)
|
|
2105
2123
|
return null
|
|
2106
2124
|
}
|
|
@@ -5,16 +5,38 @@ 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'
|
|
8
9
|
import { type DraftInvocation, type SendFlags } from './send.js'
|
|
9
10
|
import { EXIT, reportError, type CommandDeps } from './commands-core.js'
|
|
10
11
|
import type { ActiveHarnessSession } from './commands-harness-context.js'
|
|
11
12
|
|
|
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
|
+
if (
|
|
20
|
+
active?.sessionId === undefined ||
|
|
21
|
+
flags.sessionLabel !== undefined ||
|
|
22
|
+
deps.env['NOTIFAI_SESSION_LABEL'] !== undefined
|
|
23
|
+
) {
|
|
24
|
+
return undefined
|
|
25
|
+
}
|
|
26
|
+
try {
|
|
27
|
+
return (deps.orcaSessionTitle ?? readOrcaSessionTitle)(deps.env)
|
|
28
|
+
} catch {
|
|
29
|
+
return undefined
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
12
33
|
export function resolveDraftInvocation(
|
|
13
34
|
deps: CommandDeps,
|
|
14
35
|
flags: Pick<SendFlags, 'sessionId' | 'sessionLabel'>,
|
|
15
36
|
active: ActiveHarnessSession | null,
|
|
16
37
|
): { ok: true; invocation: DraftInvocation } | { ok: false; error: string } {
|
|
17
38
|
const inferred = inferInvocationContext(deps.cwd)
|
|
39
|
+
const sessionTitle = managedSessionTitle(deps, flags, active)
|
|
18
40
|
const source = buildSourceContext({
|
|
19
41
|
env: deps.env,
|
|
20
42
|
invocation: inferred,
|
|
@@ -26,7 +48,7 @@ export function resolveDraftInvocation(
|
|
|
26
48
|
activeHarness: {
|
|
27
49
|
harness: active.harness,
|
|
28
50
|
...(active.sessionId === undefined ? {} : { sessionId: active.sessionId }),
|
|
29
|
-
...(
|
|
51
|
+
...(sessionTitle === undefined ? {} : { sessionLabel: sessionTitle }),
|
|
30
52
|
...(active.sessionLabelPending === true ? { sessionLabelPending: true } : {}),
|
|
31
53
|
},
|
|
32
54
|
}),
|
package/src/commands-send.ts
CHANGED
|
@@ -27,7 +27,7 @@ import {
|
|
|
27
27
|
reportError,
|
|
28
28
|
type CommandDeps,
|
|
29
29
|
} from './commands-core.js'
|
|
30
|
-
import {
|
|
30
|
+
import { sourceContextHarnessSession } from './commands-harness-context.js'
|
|
31
31
|
import { resolveCommandSession } from './command-session.js'
|
|
32
32
|
import {
|
|
33
33
|
beginSendAttempt,
|
|
@@ -143,7 +143,7 @@ export async function sendCommand(
|
|
|
143
143
|
const source = resolveDraftInvocation(
|
|
144
144
|
deps,
|
|
145
145
|
flags,
|
|
146
|
-
|
|
146
|
+
sourceContextHarnessSession(deps.env, deps.cwd, (deps.now ?? Date.now)()),
|
|
147
147
|
)
|
|
148
148
|
if (!source.ok) {
|
|
149
149
|
deps.io.err(source.error)
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import os from 'node:os'
|
|
2
|
+
import path from 'node:path'
|
|
3
|
+
import { describe, expect, it } from 'vitest'
|
|
4
|
+
import type { ApiClient } from './client.js'
|
|
5
|
+
import { EXIT, type CommandDeps, type CommandIo } from './commands-core.js'
|
|
6
|
+
import { soundsCommand } from './commands-sounds.js'
|
|
7
|
+
|
|
8
|
+
class CapturedIo implements CommandIo {
|
|
9
|
+
readonly outLines: string[] = []
|
|
10
|
+
readonly errLines: string[] = []
|
|
11
|
+
out(line: string): void {
|
|
12
|
+
this.outLines.push(line)
|
|
13
|
+
}
|
|
14
|
+
err(line: string): void {
|
|
15
|
+
this.errLines.push(line)
|
|
16
|
+
}
|
|
17
|
+
async confirm(): Promise<boolean> {
|
|
18
|
+
return true
|
|
19
|
+
}
|
|
20
|
+
openUrl(): void {}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function makeDeps(io: CapturedIo, client: ApiClient): CommandDeps {
|
|
24
|
+
const testRoot = path.join(os.tmpdir(), 'notifai-cli-sounds-tests')
|
|
25
|
+
return {
|
|
26
|
+
io,
|
|
27
|
+
store: {
|
|
28
|
+
load: () => ({
|
|
29
|
+
machineId: 'mac_test',
|
|
30
|
+
secret: 'test-secret',
|
|
31
|
+
baseUrl: 'https://test.notifai.invalid',
|
|
32
|
+
machineName: 'test-machine',
|
|
33
|
+
}),
|
|
34
|
+
save: () => {},
|
|
35
|
+
clear: () => {},
|
|
36
|
+
describe: () => 'test credential store',
|
|
37
|
+
},
|
|
38
|
+
env: {
|
|
39
|
+
XDG_CONFIG_HOME: testRoot,
|
|
40
|
+
XDG_STATE_HOME: path.join(os.tmpdir(), 'notifai-cli-sounds-tests-state'),
|
|
41
|
+
},
|
|
42
|
+
cwd: os.tmpdir(),
|
|
43
|
+
clientFactory: () => client,
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const kitchenTimer = {
|
|
48
|
+
sound_id: 'snd_kitchen',
|
|
49
|
+
name: 'Kitchen timer',
|
|
50
|
+
duration_ms: 2400,
|
|
51
|
+
content_hash: 'a'.repeat(64),
|
|
52
|
+
url: 'https://wav.example/kitchen.wav',
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
describe('soundsCommand', () => {
|
|
56
|
+
it('lists shipped names and Account custom sounds', async () => {
|
|
57
|
+
const io = new CapturedIo()
|
|
58
|
+
const client = {
|
|
59
|
+
listSounds: async () => ({ sounds: [kitchenTimer] }),
|
|
60
|
+
} as unknown as ApiClient
|
|
61
|
+
|
|
62
|
+
expect(await soundsCommand(makeDeps(io, client), {})).toBe(EXIT.ok)
|
|
63
|
+
expect(io.outLines[0]).toBe('default Device default')
|
|
64
|
+
expect(io.outLines).toContain('done completion chime')
|
|
65
|
+
expect(io.outLines).toContain('none silent')
|
|
66
|
+
expect(io.outLines.at(-1)).toBe('snd_kitchen Kitchen timer 2.4s')
|
|
67
|
+
})
|
|
68
|
+
|
|
69
|
+
it('prints the catalog as JSON when asked', async () => {
|
|
70
|
+
const io = new CapturedIo()
|
|
71
|
+
const client = {
|
|
72
|
+
listSounds: async () => ({ sounds: [kitchenTimer] }),
|
|
73
|
+
} as unknown as ApiClient
|
|
74
|
+
|
|
75
|
+
expect(await soundsCommand(makeDeps(io, client), { json: true })).toBe(EXIT.ok)
|
|
76
|
+
const parsed = JSON.parse(io.outLines.join('\n')) as {
|
|
77
|
+
shipped: { ref: string; name: string }[]
|
|
78
|
+
custom: typeof kitchenTimer[]
|
|
79
|
+
}
|
|
80
|
+
expect(parsed.shipped[0]).toEqual({ ref: 'default', name: 'Device default' })
|
|
81
|
+
expect(parsed.custom).toEqual([kitchenTimer])
|
|
82
|
+
})
|
|
83
|
+
|
|
84
|
+
it('refuses to list custom sounds when this machine is not signed in', async () => {
|
|
85
|
+
const io = new CapturedIo()
|
|
86
|
+
let calls = 0
|
|
87
|
+
const deps: CommandDeps = {
|
|
88
|
+
...makeDeps(io, {
|
|
89
|
+
listSounds: async () => {
|
|
90
|
+
calls += 1
|
|
91
|
+
return { sounds: [] }
|
|
92
|
+
},
|
|
93
|
+
} as unknown as ApiClient),
|
|
94
|
+
store: {
|
|
95
|
+
load: () => null,
|
|
96
|
+
save: () => {},
|
|
97
|
+
clear: () => {},
|
|
98
|
+
describe: () => 'empty store',
|
|
99
|
+
},
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
expect(await soundsCommand(deps, {})).toBe(EXIT.auth)
|
|
103
|
+
expect(calls).toBe(0)
|
|
104
|
+
expect(io.errLines.join('\n')).toMatch(/not signed in/i)
|
|
105
|
+
})
|
|
106
|
+
})
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import type { ListSoundsResponse, SoundView } from '@raidiant/notifai-protocol'
|
|
2
|
+
import {
|
|
3
|
+
EXIT,
|
|
4
|
+
authedClient,
|
|
5
|
+
loadLoggedConfig,
|
|
6
|
+
reportError,
|
|
7
|
+
type CommandDeps,
|
|
8
|
+
} from './commands-core.js'
|
|
9
|
+
import { SHIPPED_SOUND_LISTING } from './sound-ref.js'
|
|
10
|
+
|
|
11
|
+
export interface SoundsListing {
|
|
12
|
+
shipped: readonly { ref: string; name: string }[]
|
|
13
|
+
custom: SoundView[]
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function formatSoundDuration(ms: number): string {
|
|
17
|
+
const seconds = ms / 1000
|
|
18
|
+
const text = Number.isInteger(seconds) ? String(seconds) : seconds.toFixed(1)
|
|
19
|
+
return `${text}s`
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export async function soundsCommand(
|
|
23
|
+
deps: CommandDeps,
|
|
24
|
+
flags: { json?: boolean },
|
|
25
|
+
): Promise<number> {
|
|
26
|
+
const config = loadLoggedConfig(deps, { cwd: deps.cwd, env: deps.env })
|
|
27
|
+
const authed = authedClient(deps, config)
|
|
28
|
+
if (!authed) return EXIT.auth
|
|
29
|
+
try {
|
|
30
|
+
const result: ListSoundsResponse = await authed.client.listSounds()
|
|
31
|
+
const listing: SoundsListing = {
|
|
32
|
+
shipped: SHIPPED_SOUND_LISTING,
|
|
33
|
+
custom: result.sounds,
|
|
34
|
+
}
|
|
35
|
+
if (flags.json) {
|
|
36
|
+
deps.io.out(JSON.stringify(listing, null, 2))
|
|
37
|
+
return EXIT.ok
|
|
38
|
+
}
|
|
39
|
+
for (const sound of listing.shipped) {
|
|
40
|
+
deps.io.out(`${sound.ref} ${sound.name}`)
|
|
41
|
+
}
|
|
42
|
+
for (const sound of listing.custom) {
|
|
43
|
+
deps.io.out(`${sound.sound_id} ${sound.name} ${formatSoundDuration(sound.duration_ms)}`)
|
|
44
|
+
}
|
|
45
|
+
return EXIT.ok
|
|
46
|
+
} catch (err) {
|
|
47
|
+
return reportError(deps, err)
|
|
48
|
+
}
|
|
49
|
+
}
|