@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
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { execFileSync } from 'node:child_process'
|
|
2
|
+
import { mkdirSync, mkdtempSync, writeFileSync } from 'node:fs'
|
|
3
|
+
import os from 'node:os'
|
|
4
|
+
import path from 'node:path'
|
|
5
|
+
import { describe, expect, it } from 'vitest'
|
|
6
|
+
import {
|
|
7
|
+
disableProject,
|
|
8
|
+
enableProject,
|
|
9
|
+
projectBinding,
|
|
10
|
+
projectEnabled,
|
|
11
|
+
} from './project-enablement.js'
|
|
12
|
+
|
|
13
|
+
function git(cwd: string, ...args: string[]): void {
|
|
14
|
+
execFileSync('git', ['-C', cwd, ...args], { stdio: 'ignore' })
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
describe('Project enablement', () => {
|
|
18
|
+
it('is disabled by default and changes only through its User-owned marker', () => {
|
|
19
|
+
const root = mkdtempSync(path.join(os.tmpdir(), 'notifai-enablement-'))
|
|
20
|
+
const env = { XDG_CONFIG_HOME: path.join(root, 'config') }
|
|
21
|
+
const binding = projectBinding(root, env)
|
|
22
|
+
expect(binding).not.toBeNull()
|
|
23
|
+
expect(projectEnabled(binding)).toBe(false)
|
|
24
|
+
|
|
25
|
+
enableProject(binding!, new Date('2026-08-27T00:00:00.000Z'))
|
|
26
|
+
expect(projectEnabled(binding)).toBe(true)
|
|
27
|
+
disableProject(binding!)
|
|
28
|
+
expect(projectEnabled(binding)).toBe(false)
|
|
29
|
+
})
|
|
30
|
+
|
|
31
|
+
it('shares one decision across linked worktrees but not unrelated checkouts with the same name', () => {
|
|
32
|
+
const root = mkdtempSync(path.join(os.tmpdir(), 'notifai-enablement-git-'))
|
|
33
|
+
const repo = path.join(root, 'same-name')
|
|
34
|
+
const linked = path.join(root, 'linked')
|
|
35
|
+
const unrelatedParent = path.join(root, 'other')
|
|
36
|
+
const unrelated = path.join(unrelatedParent, 'same-name')
|
|
37
|
+
mkdirSync(repo, { recursive: true })
|
|
38
|
+
mkdirSync(unrelated, { recursive: true })
|
|
39
|
+
for (const cwd of [repo, unrelated]) {
|
|
40
|
+
git(cwd, 'init')
|
|
41
|
+
git(cwd, 'config', 'user.email', 'test@example.invalid')
|
|
42
|
+
git(cwd, 'config', 'user.name', 'Test')
|
|
43
|
+
writeFileSync(path.join(cwd, 'file'), 'x')
|
|
44
|
+
git(cwd, 'add', 'file')
|
|
45
|
+
git(cwd, 'commit', '-m', 'fixture')
|
|
46
|
+
}
|
|
47
|
+
git(repo, 'worktree', 'add', linked, '-b', 'linked')
|
|
48
|
+
const env = { XDG_CONFIG_HOME: path.join(root, 'config') }
|
|
49
|
+
const mainBinding = projectBinding(repo, env)
|
|
50
|
+
const linkedBinding = projectBinding(linked, env)
|
|
51
|
+
const unrelatedBinding = projectBinding(unrelated, env)
|
|
52
|
+
expect(linkedBinding?.markerPath).toBe(mainBinding?.markerPath)
|
|
53
|
+
expect(unrelatedBinding?.markerPath).not.toBe(mainBinding?.markerPath)
|
|
54
|
+
})
|
|
55
|
+
})
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import { execFileSync } from 'node:child_process'
|
|
2
|
+
import { createHash } from 'node:crypto'
|
|
3
|
+
import { existsSync, readFileSync, realpathSync, unlinkSync } from 'node:fs'
|
|
4
|
+
import path from 'node:path'
|
|
5
|
+
import { atomicWriteFileSync } from './atomic-file.js'
|
|
6
|
+
import { globalConfigDir } from './config.js'
|
|
7
|
+
import { inferInvocationContext } from './invocation-context.js'
|
|
8
|
+
|
|
9
|
+
interface ProjectEnablementMarker {
|
|
10
|
+
version: 1
|
|
11
|
+
project: string
|
|
12
|
+
enabled: true
|
|
13
|
+
updated_at: string
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface ProjectBinding {
|
|
17
|
+
project: string
|
|
18
|
+
markerPath: string
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function gitCommonDirectory(cwd: string): string | null {
|
|
22
|
+
try {
|
|
23
|
+
const raw = execFileSync('git', ['-C', cwd, 'rev-parse', '--git-common-dir'], {
|
|
24
|
+
encoding: 'utf8',
|
|
25
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
26
|
+
timeout: 2_000,
|
|
27
|
+
windowsHide: true,
|
|
28
|
+
}).trim()
|
|
29
|
+
if (raw === '') return null
|
|
30
|
+
const resolved = path.isAbsolute(raw) ? raw : path.resolve(cwd, raw)
|
|
31
|
+
try {
|
|
32
|
+
return realpathSync(resolved)
|
|
33
|
+
} catch {
|
|
34
|
+
return path.resolve(resolved)
|
|
35
|
+
}
|
|
36
|
+
} catch {
|
|
37
|
+
return null
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Resolve the User-owned enablement record for this exact local Project.
|
|
43
|
+
*
|
|
44
|
+
* The opaque key binds the semantic Project id to Git's shared common
|
|
45
|
+
* directory. Linked worktrees therefore share one decision, while another
|
|
46
|
+
* checkout cannot borrow it merely by committing the same Project id.
|
|
47
|
+
*/
|
|
48
|
+
export function projectBinding(
|
|
49
|
+
cwd: string,
|
|
50
|
+
env: NodeJS.ProcessEnv = process.env,
|
|
51
|
+
explicitProject?: string | null,
|
|
52
|
+
): ProjectBinding | null {
|
|
53
|
+
const project = explicitProject ?? inferInvocationContext(cwd).project
|
|
54
|
+
if (project === null) return null
|
|
55
|
+
const repository = gitCommonDirectory(cwd) ?? path.resolve(cwd)
|
|
56
|
+
const key = createHash('sha256')
|
|
57
|
+
.update('notifai-project-enablement-v1\0')
|
|
58
|
+
.update(repository)
|
|
59
|
+
.update('\0')
|
|
60
|
+
.update(project)
|
|
61
|
+
.digest('hex')
|
|
62
|
+
return {
|
|
63
|
+
project,
|
|
64
|
+
markerPath: path.join(globalConfigDir(env), 'project-enablement', `${key}.json`),
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export function projectEnabled(binding: ProjectBinding | null): boolean {
|
|
69
|
+
if (binding === null || !existsSync(binding.markerPath)) return false
|
|
70
|
+
try {
|
|
71
|
+
const marker = JSON.parse(readFileSync(binding.markerPath, 'utf8')) as Partial<ProjectEnablementMarker>
|
|
72
|
+
return marker.version === 1 && marker.enabled === true && marker.project === binding.project
|
|
73
|
+
} catch {
|
|
74
|
+
return false
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export function enableProject(binding: ProjectBinding, now: Date = new Date()): void {
|
|
79
|
+
const marker: ProjectEnablementMarker = {
|
|
80
|
+
version: 1,
|
|
81
|
+
project: binding.project,
|
|
82
|
+
enabled: true,
|
|
83
|
+
updated_at: now.toISOString(),
|
|
84
|
+
}
|
|
85
|
+
atomicWriteFileSync(binding.markerPath, `${JSON.stringify(marker, null, 2)}\n`, {
|
|
86
|
+
mode: 0o600,
|
|
87
|
+
preserveMode: false,
|
|
88
|
+
requireCurrentUserOwner: true,
|
|
89
|
+
directoryMode: 0o700,
|
|
90
|
+
})
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export function disableProject(binding: ProjectBinding): void {
|
|
94
|
+
try {
|
|
95
|
+
unlinkSync(binding.markerPath)
|
|
96
|
+
} catch (err) {
|
|
97
|
+
if ((err as NodeJS.ErrnoException).code !== 'ENOENT') throw err
|
|
98
|
+
}
|
|
99
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { REPLY_MAX_WINDOW_SECONDS } from '@raidiant/notifai-protocol'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Time before reply-window admission that the Stop owner may spend reading its
|
|
5
|
+
* input, serving terminal-first grace, resolving devices, and submitting.
|
|
6
|
+
*
|
|
7
|
+
* Grace is capped at six minutes and every network operation is independently
|
|
8
|
+
* bounded. Twenty minutes lets setup spend up to ten while preserving the
|
|
9
|
+
* separate completion margin below; neither may borrow from the answer window
|
|
10
|
+
* the service actually commits.
|
|
11
|
+
*/
|
|
12
|
+
export const QUESTION_WAITER_STARTUP_HEADROOM_SECONDS = 20 * 60
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Minimum lifetime still available when a question is admitted.
|
|
16
|
+
*
|
|
17
|
+
* The server window starts during submission, not when the hook process
|
|
18
|
+
* started. Keeping ten minutes beyond the requested window covers the bounded
|
|
19
|
+
* submit, clock skew, close fence, and route handoff. If setup used more of the
|
|
20
|
+
* allowance, the frozen question remains local for a successor owner instead
|
|
21
|
+
* of publishing a window this process cannot observe completely.
|
|
22
|
+
*/
|
|
23
|
+
export const QUESTION_SUBMISSION_COMPLETION_HEADROOM_SECONDS = 10 * 60
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* The longest one Stop owner may live from process start.
|
|
27
|
+
*
|
|
28
|
+
* The service can accept an answer for up to 72 hours. Starting this budget
|
|
29
|
+
* before submission is deliberate: the startup allowance above ensures that
|
|
30
|
+
* even the maximum reply window still ends before the owner does.
|
|
31
|
+
*/
|
|
32
|
+
export const QUESTION_WAITER_CEILING_SECONDS =
|
|
33
|
+
REPLY_MAX_WINDOW_SECONDS + QUESTION_WAITER_STARTUP_HEADROOM_SECONDS
|
|
34
|
+
|
|
35
|
+
/** Time for final fencing, diagnostics, stdout handoff, and clean process exit. */
|
|
36
|
+
export const QUESTION_STOP_TEARDOWN_HEADROOM_SECONDS = 60
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Declared harness timeout for every Stop handler that owns Question Routing.
|
|
40
|
+
* It must outlive the runtime owner; otherwise the harness silently kills the
|
|
41
|
+
* only process capable of continuing the exact Agent Session.
|
|
42
|
+
*/
|
|
43
|
+
export const QUESTION_STOP_TIMEOUT_SECONDS =
|
|
44
|
+
QUESTION_WAITER_CEILING_SECONDS + QUESTION_STOP_TEARDOWN_HEADROOM_SECONDS
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Short Stop budget for harnesses that cannot own asynchronous Question
|
|
48
|
+
* Routing. They retire/refuse promptly and never wait through an answer window.
|
|
49
|
+
*/
|
|
50
|
+
export const NON_ROUTING_STOP_TIMEOUT_SECONDS = 540
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Pre-PID claim files came only from the former eight-minute owner. Retain that
|
|
54
|
+
* recovery bound instead of making a corrupt legacy file block for 72 hours.
|
|
55
|
+
*/
|
|
56
|
+
export const LEGACY_QUESTION_CLAIM_TTL_SECONDS = 480
|
package/src/readiness.ts
CHANGED
|
@@ -34,7 +34,14 @@ export type Remedy =
|
|
|
34
34
|
* the CLI's to launch but the human's to complete, so an agent must still
|
|
35
35
|
* treat it as out of reach.
|
|
36
36
|
*/
|
|
37
|
-
| {
|
|
37
|
+
| {
|
|
38
|
+
by: 'user-here'
|
|
39
|
+
summary: string
|
|
40
|
+
command: string
|
|
41
|
+
interactive?: boolean
|
|
42
|
+
/** Stable agent-facing description of an unavoidable harness-owned action. */
|
|
43
|
+
user_action?: { code: string; harness: string; action: string; message: string }
|
|
44
|
+
}
|
|
38
45
|
/**
|
|
39
46
|
* A human somewhere else entirely — phone in hand, app store, permission
|
|
40
47
|
* dialog. The class the old init could only print a sentence about, and the
|
package/src/send.ts
CHANGED
|
@@ -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
|
|
@@ -34,6 +29,8 @@ export interface SendFlags {
|
|
|
34
29
|
/** What this notification is; absent means update. */
|
|
35
30
|
kind?: string
|
|
36
31
|
project?: string
|
|
32
|
+
/** Deliberately omit Project identity even when config or cwd could supply one. */
|
|
33
|
+
projectless?: boolean
|
|
37
34
|
/** Opaque exact Agent Session override. */
|
|
38
35
|
sessionId?: string
|
|
39
36
|
/** Human-readable Agent Session label override. */
|
|
@@ -215,18 +212,15 @@ export function buildDraft(
|
|
|
215
212
|
}
|
|
216
213
|
}
|
|
217
214
|
|
|
218
|
-
//
|
|
219
|
-
//
|
|
220
|
-
//
|
|
221
|
-
//
|
|
222
|
-
const
|
|
223
|
-
|
|
224
|
-
: ((flags.kind as NotificationKind | undefined) ?? DEFAULT_NOTIFICATION_KIND)
|
|
225
|
-
const sound = flags.sound ?? config.sound.value ?? defaultSoundForKind(kindForSound)
|
|
226
|
-
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)) {
|
|
227
221
|
return {
|
|
228
222
|
ok: false,
|
|
229
|
-
error:
|
|
223
|
+
error: unknownSoundMessage(sound),
|
|
230
224
|
}
|
|
231
225
|
}
|
|
232
226
|
const level =
|
|
@@ -252,9 +246,8 @@ export function buildDraft(
|
|
|
252
246
|
appleOptions.sound = null
|
|
253
247
|
androidOptions.sound = null
|
|
254
248
|
} else if (sound !== null && sound !== undefined) {
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
androidOptions.sound = semanticSound
|
|
249
|
+
appleOptions.sound = sound
|
|
250
|
+
androidOptions.sound = sound
|
|
258
251
|
}
|
|
259
252
|
if (flags.threadId !== undefined) {
|
|
260
253
|
appleOptions.thread_id = flags.threadId
|
|
@@ -287,7 +280,12 @@ export function buildDraft(
|
|
|
287
280
|
}
|
|
288
281
|
}
|
|
289
282
|
|
|
290
|
-
|
|
283
|
+
if (flags.projectless === true && flags.project !== undefined) {
|
|
284
|
+
return { ok: false, error: 'Choose either --project or --projectless, not both.' }
|
|
285
|
+
}
|
|
286
|
+
const project = flags.projectless === true
|
|
287
|
+
? undefined
|
|
288
|
+
: flags.project ?? config.project.value ?? invocation.inferredProject
|
|
291
289
|
|
|
292
290
|
// Ids are derived from labels so an agent writing a one-liner does not have
|
|
293
291
|
// to invent them, but they stay the stable thing it branches on afterwards.
|
|
@@ -3,12 +3,11 @@ import { Buffer } from 'node:buffer'
|
|
|
3
3
|
import { GUIDANCE_CONTEXT_MAX_BYTES, boundedEffectiveGuidance } from './guidance-render.js'
|
|
4
4
|
|
|
5
5
|
const ROOT_OWNERSHIP =
|
|
6
|
-
'Notifai is
|
|
7
|
-
'Missing CLI readiness is handled through `notifai init --json`.
|
|
8
|
-
'The root agent owns User-visible Notification Requests unless it explicitly delegates that ownership; workers report Agent Events to the parent.'
|
|
6
|
+
'Notifai is enabled for this Project. The effective, provenance-marked guidance below governs Notification Requests. ' +
|
|
7
|
+
'Missing CLI readiness is handled through `notifai init --json`.'
|
|
9
8
|
|
|
10
9
|
export const WORKER_ACTIVATION_CONTEXT =
|
|
11
|
-
'Notifai worker context:
|
|
10
|
+
'Notifai worker context: an Agent Event is a meaningful occurrence in your work; a Notification Request is a deliberate User-visible message about one. Report Agent Events to the parent and do not send Notification Requests unless the parent explicitly delegated that ownership. If delegated, load the Notifai skill and run `notifai guidance` before composing one.'
|
|
12
11
|
|
|
13
12
|
export const MISSING_LIFECYCLE_GUIDANCE_CONTEXT =
|
|
14
13
|
'Notifai is active, but lifecycle guidance could not be loaded. Run `notifai guidance` once before deciding whether or how to send a Notification Request.'
|
|
@@ -1,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
|
})
|
package/src/session-labels.ts
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
import { createHash } from 'node:crypto'
|
|
2
|
-
import { existsSync, readFileSync } from 'node:fs'
|
|
2
|
+
import { copyFileSync, existsSync, readFileSync } from 'node:fs'
|
|
3
3
|
import path from 'node:path'
|
|
4
4
|
import { SESSION_LABEL_MAX_LENGTH } from '@raidiant/notifai-protocol'
|
|
5
5
|
import { atomicWriteFileSync } from './atomic-file.js'
|
|
6
6
|
import { stateDir } from './config.js'
|
|
7
7
|
import { withFileLock } from './file-lock.js'
|
|
8
8
|
import { generatedSessionLabel } from './generated-session-label.js'
|
|
9
|
-
import { HARNESS_LABELS, type
|
|
9
|
+
import { HARNESS_LABELS, type SourceContextHarness } from './harnesses.js'
|
|
10
10
|
|
|
11
11
|
const STORE_VERSION = 1
|
|
12
12
|
const MONTHS = [
|
|
@@ -35,7 +35,7 @@ interface StoredSessionLabel {
|
|
|
35
35
|
label: string
|
|
36
36
|
source: SessionLabelSource
|
|
37
37
|
first_seen_at: number
|
|
38
|
-
harness?:
|
|
38
|
+
harness?: SourceContextHarness
|
|
39
39
|
}
|
|
40
40
|
|
|
41
41
|
interface SessionLabelStore {
|
|
@@ -46,13 +46,21 @@ interface SessionLabelStore {
|
|
|
46
46
|
export interface SessionLabelInput {
|
|
47
47
|
env: NodeJS.ProcessEnv
|
|
48
48
|
sessionId: string
|
|
49
|
-
harness?:
|
|
49
|
+
harness?: SourceContextHarness
|
|
50
50
|
explicitLabel?: string
|
|
51
51
|
harnessLabel?: string
|
|
52
52
|
harnessLabelPending?: boolean
|
|
53
53
|
now?: number
|
|
54
54
|
}
|
|
55
55
|
|
|
56
|
+
export interface SessionLabelRenameInput {
|
|
57
|
+
env: NodeJS.ProcessEnv
|
|
58
|
+
sessionId: string
|
|
59
|
+
label: string
|
|
60
|
+
harness?: SourceContextHarness
|
|
61
|
+
now?: number
|
|
62
|
+
}
|
|
63
|
+
|
|
56
64
|
export type SessionLabelResolution =
|
|
57
65
|
| { ok: true; label: string; source: SessionLabelSource }
|
|
58
66
|
| { ok: false; error: string }
|
|
@@ -70,6 +78,24 @@ function emptyStore(): SessionLabelStore {
|
|
|
70
78
|
return { version: STORE_VERSION, sessions: {} }
|
|
71
79
|
}
|
|
72
80
|
|
|
81
|
+
function quarantineInvalidStore(file: string, raw: string): void {
|
|
82
|
+
const digest = createHash('sha256').update(raw).digest('hex')
|
|
83
|
+
const backup = path.join(path.dirname(file), `session-labels.invalid-${digest}.json`)
|
|
84
|
+
if (existsSync(backup)) {
|
|
85
|
+
if (readFileSync(backup, 'utf8') !== raw) {
|
|
86
|
+
throw new Error('the session-name store recovery backup does not match its content hash')
|
|
87
|
+
}
|
|
88
|
+
return
|
|
89
|
+
}
|
|
90
|
+
copyFileSync(file, backup)
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function recoverInvalidStore(file: string, raw: string, store: SessionLabelStore): SessionLabelStore {
|
|
94
|
+
quarantineInvalidStore(file, raw)
|
|
95
|
+
writeStore(file, store)
|
|
96
|
+
return store
|
|
97
|
+
}
|
|
98
|
+
|
|
73
99
|
function storedRecord(candidate: unknown): StoredSessionLabel | null {
|
|
74
100
|
if (typeof candidate !== 'object' || candidate === null) return null
|
|
75
101
|
const value = candidate as Partial<StoredSessionLabel>
|
|
@@ -95,33 +121,42 @@ function storedRecord(candidate: unknown): StoredSessionLabel | null {
|
|
|
95
121
|
|
|
96
122
|
function readStore(file: string): SessionLabelStore {
|
|
97
123
|
if (!existsSync(file)) return emptyStore()
|
|
98
|
-
let
|
|
124
|
+
let rawText: string
|
|
99
125
|
try {
|
|
100
|
-
|
|
126
|
+
rawText = readFileSync(file, 'utf8')
|
|
101
127
|
} catch (err) {
|
|
102
128
|
throw new Error(
|
|
103
129
|
`the session-name store is unreadable: ${err instanceof Error ? err.message : String(err)}`,
|
|
104
130
|
)
|
|
105
131
|
}
|
|
132
|
+
let parsed: unknown
|
|
133
|
+
try {
|
|
134
|
+
parsed = JSON.parse(rawText)
|
|
135
|
+
} catch {
|
|
136
|
+
return recoverInvalidStore(file, rawText, emptyStore())
|
|
137
|
+
}
|
|
106
138
|
if (typeof parsed !== 'object' || parsed === null) {
|
|
107
|
-
|
|
139
|
+
return recoverInvalidStore(file, rawText, emptyStore())
|
|
108
140
|
}
|
|
109
141
|
const raw = parsed as { version?: unknown; sessions?: unknown }
|
|
110
142
|
if (raw.version !== STORE_VERSION) {
|
|
111
|
-
|
|
143
|
+
return recoverInvalidStore(file, rawText, emptyStore())
|
|
112
144
|
}
|
|
113
145
|
if (typeof raw.sessions !== 'object' || raw.sessions === null) {
|
|
114
|
-
|
|
146
|
+
return recoverInvalidStore(file, rawText, emptyStore())
|
|
115
147
|
}
|
|
116
148
|
const sessions: Record<string, StoredSessionLabel> = {}
|
|
149
|
+
let invalidRecordFound = false
|
|
117
150
|
for (const [key, candidate] of Object.entries(raw.sessions)) {
|
|
118
151
|
const record = storedRecord(candidate)
|
|
119
152
|
if (!/^[a-f0-9]{64}$/.test(key) || record === null) {
|
|
120
|
-
|
|
153
|
+
invalidRecordFound = true
|
|
154
|
+
continue
|
|
121
155
|
}
|
|
122
156
|
sessions[key] = record
|
|
123
157
|
}
|
|
124
|
-
|
|
158
|
+
const store = { version: STORE_VERSION, sessions } satisfies SessionLabelStore
|
|
159
|
+
return invalidRecordFound ? recoverInvalidStore(file, rawText, store) : store
|
|
125
160
|
}
|
|
126
161
|
|
|
127
162
|
function writeStore(file: string, store: SessionLabelStore): void {
|
|
@@ -203,6 +238,20 @@ function explicitCandidate(
|
|
|
203
238
|
return { label: normalized, source: 'explicit' }
|
|
204
239
|
}
|
|
205
240
|
|
|
241
|
+
export function normalizeExplicitSessionLabel(
|
|
242
|
+
input: Pick<SessionLabelRenameInput, 'env' | 'sessionId' | 'label'>,
|
|
243
|
+
): { ok: true; label: string } | { ok: false; error: string } {
|
|
244
|
+
const candidate = explicitCandidate(input.label, {
|
|
245
|
+
env: input.env,
|
|
246
|
+
sessionId: input.sessionId,
|
|
247
|
+
})
|
|
248
|
+
if (candidate === null) {
|
|
249
|
+
return { ok: false, error: '--session-label must not be empty.' }
|
|
250
|
+
}
|
|
251
|
+
if ('error' in candidate) return { ok: false, error: candidate.error }
|
|
252
|
+
return { ok: true, label: candidate.label }
|
|
253
|
+
}
|
|
254
|
+
|
|
206
255
|
function harnessLabelIsPending(
|
|
207
256
|
value: string | undefined,
|
|
208
257
|
input: SessionLabelInput,
|
|
@@ -260,7 +309,7 @@ function collisionKey(value: string): string {
|
|
|
260
309
|
function uniqueLabel(
|
|
261
310
|
base: string,
|
|
262
311
|
source: SessionLabelSource,
|
|
263
|
-
harness:
|
|
312
|
+
harness: SourceContextHarness | undefined,
|
|
264
313
|
now: number,
|
|
265
314
|
used: ReadonlySet<string>,
|
|
266
315
|
): string {
|
|
@@ -389,3 +438,37 @@ export function resolveSessionLabel(input: SessionLabelInput): SessionLabelResol
|
|
|
389
438
|
}
|
|
390
439
|
}
|
|
391
440
|
}
|
|
441
|
+
|
|
442
|
+
/** Replace the local frozen label only after the Account-authoritative rename succeeds. */
|
|
443
|
+
export function renameStoredSessionLabel(
|
|
444
|
+
input: SessionLabelRenameInput,
|
|
445
|
+
): SessionLabelResolution {
|
|
446
|
+
const normalized = normalizeExplicitSessionLabel(input)
|
|
447
|
+
if (!normalized.ok) return normalized
|
|
448
|
+
const file = storePath(input.env)
|
|
449
|
+
const key = sessionKey(input.sessionId)
|
|
450
|
+
const now = input.now ?? Date.now()
|
|
451
|
+
try {
|
|
452
|
+
return withFileLock(`${file}.lock`, () => {
|
|
453
|
+
const store = readStore(file)
|
|
454
|
+
const existing = store.sessions[key]
|
|
455
|
+
store.sessions[key] = {
|
|
456
|
+
label: normalized.label,
|
|
457
|
+
source: 'explicit',
|
|
458
|
+
first_seen_at: existing?.first_seen_at ?? now,
|
|
459
|
+
...(input.harness === undefined
|
|
460
|
+
? existing?.harness === undefined
|
|
461
|
+
? {}
|
|
462
|
+
: { harness: existing.harness }
|
|
463
|
+
: { harness: input.harness }),
|
|
464
|
+
}
|
|
465
|
+
writeStore(file, store)
|
|
466
|
+
return { ok: true, label: normalized.label, source: 'explicit' }
|
|
467
|
+
})
|
|
468
|
+
} catch (err) {
|
|
469
|
+
return {
|
|
470
|
+
ok: false,
|
|
471
|
+
error: `The Account label changed, but this machine could not save it locally: ${err instanceof Error ? err.message : String(err)}`,
|
|
472
|
+
}
|
|
473
|
+
}
|
|
474
|
+
}
|
package/src/skill.test.ts
CHANGED
|
@@ -225,7 +225,7 @@ describe('Notifai agent skill', () => {
|
|
|
225
225
|
expect(send).toMatch(/kind and the project travel as their own\s+fields, never in it/i)
|
|
226
226
|
})
|
|
227
227
|
|
|
228
|
-
it('
|
|
228
|
+
it('keeps initial labels stable and teaches the narrow exact-session rename exception', () => {
|
|
229
229
|
const send = section('## Send')
|
|
230
230
|
expect(send).toContain('--session-label')
|
|
231
231
|
expect(send).toMatch(/2-6 words/i)
|
|
@@ -239,6 +239,11 @@ describe('Notifai agent skill', () => {
|
|
|
239
239
|
expect(send).toMatch(/generated fallback name[\s\S]*replaced by a later\s+semantic name/i)
|
|
240
240
|
expect(send).toMatch(/never pass\s+`--session-id`/i)
|
|
241
241
|
expect(send).toMatch(/identifier, hash, or\s+filesystem path/i)
|
|
242
|
+
expect(send).toContain('notifai session rename')
|
|
243
|
+
expect(send).toMatch(/job changed completely[\s\S]{0,100}current\s+name would now mislead/i)
|
|
244
|
+
expect(send).toMatch(/never rename for milestones, ordinary progress/i)
|
|
245
|
+
expect(send).toMatch(/accepts no Agent Session id/i)
|
|
246
|
+
expect(send).toMatch(/active harness proves the exact current Agent Session/i)
|
|
242
247
|
})
|
|
243
248
|
|
|
244
249
|
it('never teaches by enumerating environments the CLI can detect itself', () => {
|
|
@@ -247,7 +252,7 @@ describe('Notifai agent skill', () => {
|
|
|
247
252
|
// Harness names may appear only in setup/routing material, where the
|
|
248
253
|
// harness IS the subject.
|
|
249
254
|
for (const heading of ['## Decide whether to notify', '## Send', '## Ask a question', '## When the answer arrives']) {
|
|
250
|
-
expect(section(heading), heading).not.toMatch(/OpenCode|Orca|Cursor|Codex/)
|
|
255
|
+
expect(section(heading), heading).not.toMatch(/OpenCode|Orca|Cursor|Codex|Hermes/)
|
|
251
256
|
}
|
|
252
257
|
})
|
|
253
258
|
|
|
@@ -383,6 +388,7 @@ describe('Notifai agent skill', () => {
|
|
|
383
388
|
'notifai guidance',
|
|
384
389
|
'notifai logs',
|
|
385
390
|
'notifai status',
|
|
391
|
+
'notifai sounds',
|
|
386
392
|
]) {
|
|
387
393
|
expect(skill, `${command} is unreachable from the skill`).toContain(command)
|
|
388
394
|
}
|
|
@@ -391,9 +397,14 @@ describe('Notifai agent skill', () => {
|
|
|
391
397
|
it('moves per-harness mechanics into progressive disclosure', () => {
|
|
392
398
|
expect(skill).toContain('[Harness setup and recovery](references/harness-setup.md)')
|
|
393
399
|
expect(skill).toMatch(/read it when you are\s+installing hooks or diagnosing routing/i)
|
|
394
|
-
for (const harness of ['Claude Code', 'Codex', 'Cursor', 'OpenCode']) {
|
|
400
|
+
for (const harness of ['Claude Code', 'Codex', 'Cursor', 'OpenCode', 'Hermes']) {
|
|
395
401
|
expect(harnessReference).toContain(`**${harness}:**`)
|
|
396
402
|
}
|
|
403
|
+
expect(harnessReference).toMatch(/exact `HERMES_SESSION_ID`/i)
|
|
404
|
+
expect(harnessReference).toMatch(/Do not run `notifai hooks install --harness hermes`/i)
|
|
405
|
+
expect(harnessReference).toMatch(/blocking `notifai send --reply`/i)
|
|
406
|
+
expect(harnessReference).toMatch(/npx skills/i)
|
|
407
|
+
expect(harnessReference).toMatch(/no Hermes plugin may vendor/i)
|
|
397
408
|
// The agent runs what a process can run; the human only does the human part.
|
|
398
409
|
expect(harnessReference).toMatch(/let init install and assess\s+the hooks/i)
|
|
399
410
|
expect(harnessReference).toContain('--setup-scope <project|global>')
|