@raidiant/notifai 7.0.2 → 9.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 +75 -0
- package/README.md +20 -13
- package/dist/atomic-file.d.ts +14 -0
- package/dist/atomic-file.js +27 -2
- package/dist/atomic-file.js.map +1 -1
- package/dist/cli-bin.d.ts +4 -0
- package/dist/cli-bin.js +62 -0
- package/dist/cli-bin.js.map +1 -0
- package/dist/cli-release.d.ts +10 -0
- package/dist/cli-release.js +75 -0
- package/dist/cli-release.js.map +1 -0
- package/dist/client.d.ts +1 -0
- package/dist/client.js +13 -3
- package/dist/client.js.map +1 -1
- package/dist/commands-auth.d.ts +6 -0
- package/dist/commands-auth.js +28 -3
- package/dist/commands-auth.js.map +1 -1
- package/dist/commands-config.js +22 -3
- package/dist/commands-config.js.map +1 -1
- package/dist/commands-core.d.ts +12 -0
- package/dist/commands-core.js +27 -3
- package/dist/commands-core.js.map +1 -1
- package/dist/commands-devices.d.ts +2 -1
- package/dist/commands-devices.js +20 -8
- package/dist/commands-devices.js.map +1 -1
- package/dist/commands-doctor.d.ts +1 -0
- package/dist/commands-doctor.js +85 -27
- package/dist/commands-doctor.js.map +1 -1
- package/dist/commands-guidance.d.ts +27 -0
- package/dist/commands-guidance.js +163 -0
- package/dist/commands-guidance.js.map +1 -0
- package/dist/commands-hooks.d.ts +16 -5
- package/dist/commands-hooks.js +433 -38
- package/dist/commands-hooks.js.map +1 -1
- package/dist/commands-init.d.ts +10 -18
- package/dist/commands-init.js +139 -54
- package/dist/commands-init.js.map +1 -1
- package/dist/commands-send-support.d.ts +17 -2
- package/dist/commands-send-support.js +66 -8
- package/dist/commands-send-support.js.map +1 -1
- package/dist/commands-send.d.ts +0 -1
- package/dist/commands-send.js +105 -39
- package/dist/commands-send.js.map +1 -1
- package/dist/commands-setup-proof.d.ts +8 -0
- package/dist/commands-setup-proof.js +31 -2
- package/dist/commands-setup-proof.js.map +1 -1
- package/dist/commands-skill.d.ts +11 -1
- package/dist/commands-skill.js +61 -11
- package/dist/commands-skill.js.map +1 -1
- package/dist/commands.d.ts +2 -1
- package/dist/commands.js +1 -0
- package/dist/commands.js.map +1 -1
- package/dist/config-schema.d.ts +1 -1
- package/dist/config-schema.js +30 -14
- package/dist/config-schema.js.map +1 -1
- package/dist/config.d.ts +24 -5
- package/dist/config.js +37 -8
- package/dist/config.js.map +1 -1
- package/dist/credentials.d.ts +10 -1
- package/dist/credentials.js +25 -12
- package/dist/credentials.js.map +1 -1
- package/dist/guidance-content.d.ts +50 -0
- package/dist/guidance-content.js +240 -0
- package/dist/guidance-content.js.map +1 -0
- package/dist/guidance.d.ts +69 -0
- package/dist/guidance.js +173 -0
- package/dist/guidance.js.map +1 -0
- package/dist/hook-adapter.js +37 -3
- package/dist/hook-adapter.js.map +1 -1
- package/dist/hooks.d.ts +68 -7
- package/dist/hooks.js +238 -92
- package/dist/hooks.js.map +1 -1
- package/dist/install-hooks.d.ts +10 -18
- package/dist/install-hooks.js +110 -23
- package/dist/install-hooks.js.map +1 -1
- package/dist/interactive.d.ts +1 -1
- package/dist/interactive.js +14 -9
- package/dist/interactive.js.map +1 -1
- package/dist/logging.d.ts +8 -1
- package/dist/logging.js +43 -2
- package/dist/logging.js.map +1 -1
- package/dist/main.js +5 -487
- package/dist/main.js.map +1 -1
- package/dist/native-skills.d.ts +19 -0
- package/dist/native-skills.js +30 -8
- package/dist/native-skills.js.map +1 -1
- package/dist/opencode-plugin.js +14 -1
- package/dist/opencode-plugin.js.map +1 -1
- package/dist/program.d.ts +49 -0
- package/dist/program.js +576 -0
- package/dist/program.js.map +1 -0
- package/dist/send.d.ts +12 -10
- package/dist/send.js +65 -29
- package/dist/send.js.map +1 -1
- package/dist/session-activation.d.ts +15 -0
- package/dist/session-activation.js +35 -0
- package/dist/session-activation.js.map +1 -0
- package/dist/session-labels.d.ts +9 -3
- package/dist/session-labels.js +38 -7
- package/dist/session-labels.js.map +1 -1
- package/dist/url-policy.d.ts +85 -0
- package/dist/url-policy.js +369 -0
- package/dist/url-policy.js.map +1 -0
- package/package.json +6 -4
- package/src/atomic-file.ts +36 -2
- package/src/cli-bin.test.ts +38 -0
- package/src/cli-bin.ts +67 -0
- package/src/cli-release.test.ts +46 -0
- package/src/cli-release.ts +78 -0
- package/src/client.test.ts +95 -0
- package/src/client.ts +16 -3
- package/src/commands-auth.ts +32 -3
- package/src/commands-config.ts +25 -2
- package/src/commands-core.ts +38 -3
- package/src/commands-devices.ts +28 -9
- package/src/commands-doctor.ts +116 -25
- package/src/commands-guidance.ts +216 -0
- package/src/commands-hooks.ts +513 -42
- package/src/commands-init.ts +170 -60
- package/src/commands-send-support.test.ts +156 -0
- package/src/commands-send-support.ts +72 -8
- package/src/commands-send.ts +123 -38
- package/src/commands-setup-proof.ts +36 -2
- package/src/commands-skill.ts +78 -15
- package/src/commands.test.ts +1490 -197
- package/src/commands.ts +6 -0
- package/src/config-schema.ts +32 -16
- package/src/config.test.ts +57 -2
- package/src/config.ts +49 -10
- package/src/credentials.test.ts +71 -3
- package/src/credentials.ts +29 -15
- package/src/guidance-content.ts +256 -0
- package/src/guidance.test.ts +299 -0
- package/src/guidance.ts +233 -0
- package/src/hook-adapter.test.ts +113 -1
- package/src/hook-adapter.ts +37 -3
- package/src/hooks.test.ts +783 -170
- package/src/hooks.ts +306 -131
- package/src/install-hooks.test.ts +135 -16
- package/src/install-hooks.ts +113 -25
- package/src/interactive.ts +14 -9
- package/src/logging.test.ts +66 -3
- package/src/logging.ts +45 -2
- package/src/main.ts +6 -614
- package/src/native-skills.test.ts +41 -1
- package/src/native-skills.ts +40 -6
- package/src/opencode-plugin.ts +14 -1
- package/src/program.test.ts +237 -0
- package/src/program.ts +751 -0
- package/src/send.test.ts +28 -0
- package/src/send.ts +81 -34
- package/src/session-activation.ts +45 -0
- package/src/session-labels.test.ts +120 -3
- package/src/session-labels.ts +38 -7
- package/src/skill.test.ts +153 -12
- package/src/trust-policy.test.ts +701 -0
- package/src/url-policy.ts +399 -0
package/src/send.test.ts
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
import {
|
|
3
|
+
accidentalEscapedNewlineMessage,
|
|
4
|
+
hasAccidentalEscapedNewlines,
|
|
5
|
+
rejectAccidentalEscapedNewlines,
|
|
6
|
+
} from './send.js'
|
|
7
|
+
|
|
8
|
+
describe('accidental escaped newlines', () => {
|
|
9
|
+
it('catches a shell-quoted backslash-n body', () => {
|
|
10
|
+
expect(hasAccidentalEscapedNewlines('line1\\nline2')).toBe(true)
|
|
11
|
+
expect(rejectAccidentalEscapedNewlines('line1\\nline2', false)).toBe(
|
|
12
|
+
accidentalEscapedNewlineMessage(),
|
|
13
|
+
)
|
|
14
|
+
})
|
|
15
|
+
|
|
16
|
+
it('preserves real multiline Markdown', () => {
|
|
17
|
+
expect(hasAccidentalEscapedNewlines('line1\nline2')).toBe(false)
|
|
18
|
+
expect(rejectAccidentalEscapedNewlines('line1\nline2', false)).toBeNull()
|
|
19
|
+
})
|
|
20
|
+
|
|
21
|
+
it('lets an explicit flag keep visible backslash-n text', () => {
|
|
22
|
+
expect(rejectAccidentalEscapedNewlines('use \\n in the sample', true)).toBeNull()
|
|
23
|
+
})
|
|
24
|
+
|
|
25
|
+
it('does not treat an escaped backslash plus n as accidental', () => {
|
|
26
|
+
expect(hasAccidentalEscapedNewlines('escape as \\\\n')).toBe(false)
|
|
27
|
+
})
|
|
28
|
+
})
|
package/src/send.ts
CHANGED
|
@@ -6,6 +6,7 @@ import {
|
|
|
6
6
|
NOTIFICATION_SCHEMA_VERSION,
|
|
7
7
|
QUESTION_TEXT_MAX_LENGTH,
|
|
8
8
|
bannerExcerpt,
|
|
9
|
+
type AndroidOptionsT,
|
|
9
10
|
type IosOptionsT,
|
|
10
11
|
type LifecycleT,
|
|
11
12
|
type MacosOptionsT,
|
|
@@ -30,7 +31,6 @@ export interface SendFlags {
|
|
|
30
31
|
/** The one canonical Markdown body. */
|
|
31
32
|
body: string
|
|
32
33
|
subtitle?: string
|
|
33
|
-
event?: string
|
|
34
34
|
/** What this notification is; absent means update. */
|
|
35
35
|
kind?: string
|
|
36
36
|
project?: string
|
|
@@ -44,7 +44,8 @@ export interface SendFlags {
|
|
|
44
44
|
collapseKey?: string
|
|
45
45
|
sound?: string
|
|
46
46
|
threadId?: string
|
|
47
|
-
level
|
|
47
|
+
/** Apple interruption level; null is reserved for internal callers that must suppress config. */
|
|
48
|
+
level?: string | null
|
|
48
49
|
data?: string[]
|
|
49
50
|
/** Ready media ids in author-supplied order. Commands resolve paths and URLs first. */
|
|
50
51
|
image?: string[]
|
|
@@ -63,9 +64,9 @@ export interface SendFlags {
|
|
|
63
64
|
* Answer labels; turns the reply into a closed question. One flag occurrence
|
|
64
65
|
* per label, always — a label is a label, whatever characters it contains.
|
|
65
66
|
*/
|
|
66
|
-
|
|
67
|
+
choice?: string[]
|
|
67
68
|
/** The user may select several of the offered answers. */
|
|
68
|
-
|
|
69
|
+
multi?: boolean
|
|
69
70
|
/**
|
|
70
71
|
* A full question set, overriding the single question the flags describe.
|
|
71
72
|
* Not a user-facing flag: `ask --form` and the hooks build it.
|
|
@@ -78,6 +79,34 @@ export interface SendFlags {
|
|
|
78
79
|
* questions and their retirements ride the wire as what they are.
|
|
79
80
|
*/
|
|
80
81
|
lifecycle?: LifecycleT
|
|
82
|
+
/**
|
|
83
|
+
* Allow the two-character sequence backslash-n to be sent as visible text.
|
|
84
|
+
* Without this, a likely accidental escaped newline is refused.
|
|
85
|
+
*/
|
|
86
|
+
literalBackslashN?: boolean
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** A backslash-n pair that was not itself escaped as a literal backslash. */
|
|
90
|
+
const ACCIDENTAL_ESCAPED_NEWLINE = /(?:^|[^\\])\\n/
|
|
91
|
+
|
|
92
|
+
export function hasAccidentalEscapedNewlines(body: string): boolean {
|
|
93
|
+
return ACCIDENTAL_ESCAPED_NEWLINE.test(body)
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export function accidentalEscapedNewlineMessage(): string {
|
|
97
|
+
return [
|
|
98
|
+
'This body contains the two-character sequence \\n, which is sent literally and shows as \\n on the device.',
|
|
99
|
+
'To send a real line break, pass a multiline --body or --body-file -.',
|
|
100
|
+
'If you meant the visible characters \\n, pass --literal-backslash-n.',
|
|
101
|
+
].join('\n')
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export function rejectAccidentalEscapedNewlines(
|
|
105
|
+
body: string | undefined,
|
|
106
|
+
allowLiteral: boolean | undefined,
|
|
107
|
+
): string | null {
|
|
108
|
+
if (allowLiteral === true || body === undefined) return null
|
|
109
|
+
return hasAccidentalEscapedNewlines(body) ? accidentalEscapedNewlineMessage() : null
|
|
81
110
|
}
|
|
82
111
|
|
|
83
112
|
export type DraftBuild =
|
|
@@ -200,7 +229,8 @@ export function buildDraft(
|
|
|
200
229
|
error: `Unknown sound "${sound}" — supported: ${CLI_SOUNDS.map((value) => `"${value}"`).join(', ')}.`,
|
|
201
230
|
}
|
|
202
231
|
}
|
|
203
|
-
const level =
|
|
232
|
+
const level =
|
|
233
|
+
flags.level !== undefined ? flags.level : config.interruption_level.value
|
|
204
234
|
if (
|
|
205
235
|
level !== null &&
|
|
206
236
|
level !== undefined &&
|
|
@@ -208,15 +238,30 @@ export function buildDraft(
|
|
|
208
238
|
) {
|
|
209
239
|
return { ok: false, error: `Unknown interruption level "${level}".` }
|
|
210
240
|
}
|
|
241
|
+
if (platform === 'android' && level !== null && level !== undefined) {
|
|
242
|
+
return {
|
|
243
|
+
ok: false,
|
|
244
|
+
error:
|
|
245
|
+
'Android does not support caller-selected interruption levels. Remove --level or unset the configured interruption_level; kind, notification channels, and device settings own attention on Android.',
|
|
246
|
+
}
|
|
247
|
+
}
|
|
211
248
|
|
|
212
|
-
const
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
249
|
+
const appleOptions: IosOptionsT & MacosOptionsT = {}
|
|
250
|
+
const androidOptions: AndroidOptionsT = {}
|
|
251
|
+
if (sound === 'none') {
|
|
252
|
+
appleOptions.sound = null
|
|
253
|
+
androidOptions.sound = null
|
|
254
|
+
} else if (sound !== null && sound !== undefined) {
|
|
255
|
+
const semanticSound = sound as Exclude<(typeof CLI_SOUNDS)[number], 'none'>
|
|
256
|
+
appleOptions.sound = semanticSound
|
|
257
|
+
androidOptions.sound = semanticSound
|
|
258
|
+
}
|
|
259
|
+
if (flags.threadId !== undefined) {
|
|
260
|
+
appleOptions.thread_id = flags.threadId
|
|
261
|
+
androidOptions.thread_id = flags.threadId
|
|
216
262
|
}
|
|
217
|
-
if (flags.threadId !== undefined) options.thread_id = flags.threadId
|
|
218
263
|
if (level !== null && level !== undefined) {
|
|
219
|
-
|
|
264
|
+
appleOptions.interruption_level = level as (typeof INTERRUPTION_LEVELS)[number]
|
|
220
265
|
}
|
|
221
266
|
if (flags.data?.length) {
|
|
222
267
|
const data: Record<string, string> = {}
|
|
@@ -225,7 +270,8 @@ export function buildDraft(
|
|
|
225
270
|
if (eq <= 0) return { ok: false, error: `--data expects key=value, got "${pair}".` }
|
|
226
271
|
data[pair.slice(0, eq)] = pair.slice(eq + 1)
|
|
227
272
|
}
|
|
228
|
-
|
|
273
|
+
appleOptions.custom_data = data
|
|
274
|
+
androidOptions.custom_data = data
|
|
229
275
|
}
|
|
230
276
|
|
|
231
277
|
const ttl = flags.ttl ?? config.ttl_seconds.value
|
|
@@ -245,15 +291,15 @@ export function buildDraft(
|
|
|
245
291
|
|
|
246
292
|
// Ids are derived from labels so an agent writing a one-liner does not have
|
|
247
293
|
// to invent them, but they stay the stable thing it branches on afterwards.
|
|
248
|
-
const choices = parseChoices(flags.
|
|
294
|
+
const choices = parseChoices(flags.choice)
|
|
249
295
|
if (choices === 'invalid') {
|
|
250
296
|
return {
|
|
251
297
|
ok: false,
|
|
252
|
-
error:
|
|
298
|
+
error: CHOICE_USAGE,
|
|
253
299
|
}
|
|
254
300
|
}
|
|
255
|
-
if (flags.
|
|
256
|
-
return { ok: false, error: '--
|
|
301
|
+
if (flags.multi && choices === null && flags.questions === undefined) {
|
|
302
|
+
return { ok: false, error: '--multi needs answers to select between; add --choice.' }
|
|
257
303
|
}
|
|
258
304
|
// A single reply question is the first readable block of the canonical body.
|
|
259
305
|
// Context may follow after a blank line without changing what the user answers.
|
|
@@ -271,9 +317,25 @@ export function buildDraft(
|
|
|
271
317
|
}
|
|
272
318
|
}
|
|
273
319
|
|
|
320
|
+
const hasAppleOptions = Object.keys(appleOptions).length > 0
|
|
321
|
+
const hasAndroidOptions = Object.keys(androidOptions).length > 0
|
|
322
|
+
let platformOptions: NonNullable<NotificationDraftT['platform']> | undefined
|
|
323
|
+
if (flags.platform === undefined) {
|
|
324
|
+
if (hasAppleOptions || hasAndroidOptions) {
|
|
325
|
+
platformOptions = {
|
|
326
|
+
...(hasAppleOptions ? { ios: appleOptions, macos: appleOptions } : {}),
|
|
327
|
+
...(hasAndroidOptions ? { android: androidOptions } : {}),
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
} else if (platform === 'android') {
|
|
331
|
+
if (hasAndroidOptions) platformOptions = { android: androidOptions }
|
|
332
|
+
} else if (hasAppleOptions) {
|
|
333
|
+
platformOptions =
|
|
334
|
+
platform === 'ios' ? { ios: appleOptions } : { macos: appleOptions }
|
|
335
|
+
}
|
|
336
|
+
|
|
274
337
|
const draft: NotificationDraftT = {
|
|
275
338
|
schema_version: NOTIFICATION_SCHEMA_VERSION,
|
|
276
|
-
...(flags.event !== undefined ? { event: flags.event } : {}),
|
|
277
339
|
...(flags.kind !== undefined
|
|
278
340
|
? { kind: flags.kind as (typeof NOTIFICATION_KINDS)[number] }
|
|
279
341
|
: {}),
|
|
@@ -297,19 +359,13 @@ export function buildDraft(
|
|
|
297
359
|
id: 'q1',
|
|
298
360
|
text: derivedQuestion,
|
|
299
361
|
...(choices !== null ? { choices } : {}),
|
|
300
|
-
...(flags.
|
|
362
|
+
...(flags.multi ? { multi: true } : {}),
|
|
301
363
|
},
|
|
302
364
|
],
|
|
303
365
|
},
|
|
304
366
|
}
|
|
305
367
|
: {}),
|
|
306
|
-
...(
|
|
307
|
-
? flags.platform === undefined
|
|
308
|
-
? { platform: { ios: options, macos: options } }
|
|
309
|
-
: platform === 'ios'
|
|
310
|
-
? { platform: { ios: options } }
|
|
311
|
-
: { platform: { macos: options } }
|
|
312
|
-
: {}),
|
|
368
|
+
...(platformOptions !== undefined ? { platform: platformOptions } : {}),
|
|
313
369
|
}
|
|
314
370
|
return { ok: true, draft, platform }
|
|
315
371
|
}
|
|
@@ -369,19 +425,10 @@ export function parseChoices(
|
|
|
369
425
|
}
|
|
370
426
|
|
|
371
427
|
/** Message shared by every surface that accepts choice labels. */
|
|
372
|
-
/** `ask` spells this flag `--choice`. */
|
|
373
428
|
export const CHOICE_USAGE =
|
|
374
429
|
'Offer 2-6 answers, one --choice flag per answer, unique once slugified — ' +
|
|
375
430
|
'e.g. --choice "Yes, ship it" --choice "Not yet".'
|
|
376
431
|
|
|
377
|
-
/**
|
|
378
|
-
* `send` spells the same flag `--reply-choice`, and a remedy that names the
|
|
379
|
-
* other command's flag reproduces the error it is trying to fix.
|
|
380
|
-
*/
|
|
381
|
-
export const REPLY_CHOICE_USAGE =
|
|
382
|
-
'Offer 2-6 answers, one --reply-choice flag per answer, unique once slugified — ' +
|
|
383
|
-
'e.g. --reply-choice "Yes, ship it" --reply-choice "Not yet".'
|
|
384
|
-
|
|
385
432
|
export function slugify(label: string): string {
|
|
386
433
|
return label
|
|
387
434
|
.toLowerCase()
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import type { HookHarness } from './hooks.js'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Model-visible session bootstrap. It names the installed capability and its
|
|
5
|
+
* authoritative sources without restating any notification policy that the
|
|
6
|
+
* user may have overridden.
|
|
7
|
+
*/
|
|
8
|
+
export const SESSION_ACTIVATION_CONTEXT =
|
|
9
|
+
'Notifai is active for this session, even when the user did not mention it. ' +
|
|
10
|
+
'Before beginning task work, use the Notifai skill and run `notifai guidance`; its resolved guidance governs notification decisions for Agent Events. ' +
|
|
11
|
+
'Missing skill or CLI readiness is handled through `notifai init` and its diagnosis. ' +
|
|
12
|
+
'For delegated work, the parent owns User-visible Notification Requests unless it explicitly delegates that ownership; workers report Agent Events to the parent and do not send independently.'
|
|
13
|
+
|
|
14
|
+
/** One lifecycle meaning, encoded for each harness output contract. */
|
|
15
|
+
export function sessionActivationOutput(
|
|
16
|
+
harness: HookHarness | undefined,
|
|
17
|
+
hookEventName: 'SessionStart' | 'SubagentStart' | 'UserPromptSubmit',
|
|
18
|
+
): string | undefined {
|
|
19
|
+
if (harness === 'cursor') {
|
|
20
|
+
return JSON.stringify({ additional_context: SESSION_ACTIVATION_CONTEXT })
|
|
21
|
+
}
|
|
22
|
+
if (harness === 'claude-code' || harness === 'codex') {
|
|
23
|
+
return JSON.stringify({
|
|
24
|
+
hookSpecificOutput: {
|
|
25
|
+
hookEventName,
|
|
26
|
+
additionalContext: SESSION_ACTIVATION_CONTEXT,
|
|
27
|
+
},
|
|
28
|
+
})
|
|
29
|
+
}
|
|
30
|
+
return undefined
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Cursor currently accepts sessionStart context without reliably delivering it
|
|
35
|
+
* to the model. Its native post-Stop follow-up is the narrow fallback and is
|
|
36
|
+
* claimed once per conversation by the hook-state layer.
|
|
37
|
+
*/
|
|
38
|
+
export function cursorStopActivationOutput(): string {
|
|
39
|
+
return JSON.stringify({
|
|
40
|
+
followup_message: SESSION_ACTIVATION_CONTEXT.replace(
|
|
41
|
+
'Before beginning task work,',
|
|
42
|
+
'Before finalizing the Agent Event from the turn that just ended,',
|
|
43
|
+
),
|
|
44
|
+
})
|
|
45
|
+
}
|
|
@@ -46,6 +46,38 @@ describe('semantic session labels', () => {
|
|
|
46
46
|
).toEqual({ ok: true, label: 'Semantic session names', source: 'explicit' })
|
|
47
47
|
})
|
|
48
48
|
|
|
49
|
+
it('prefers the environment-supplied title over an explicit label, making the flag safe everywhere', () => {
|
|
50
|
+
// An agent must never need to know which environments name their own
|
|
51
|
+
// sessions: where one does, the explicit label is simply not used.
|
|
52
|
+
const { env, now } = fixture()
|
|
53
|
+
expect(
|
|
54
|
+
resolveSessionLabel({
|
|
55
|
+
env,
|
|
56
|
+
now,
|
|
57
|
+
sessionId: 'managed-session',
|
|
58
|
+
harness: 'opencode',
|
|
59
|
+
harnessLabel: 'NotifAI question lifecycle',
|
|
60
|
+
explicitLabel: 'Agent-chosen name',
|
|
61
|
+
}),
|
|
62
|
+
).toEqual({ ok: true, label: 'NotifAI question lifecycle', source: 'harness' })
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
it('upgrades a frozen fallback to the environment title even when an explicit label rides along', () => {
|
|
66
|
+
const { env, now } = fixture()
|
|
67
|
+
const frozen = resolveSessionLabel({ env, now, sessionId: 'late-title' })
|
|
68
|
+
expect(frozen).toMatchObject({ ok: true, source: 'fallback' })
|
|
69
|
+
expect(
|
|
70
|
+
resolveSessionLabel({
|
|
71
|
+
env,
|
|
72
|
+
now: now + 1,
|
|
73
|
+
sessionId: 'late-title',
|
|
74
|
+
harness: 'opencode',
|
|
75
|
+
harnessLabel: 'Worktree semantic title',
|
|
76
|
+
explicitLabel: 'Agent-chosen name',
|
|
77
|
+
}),
|
|
78
|
+
).toEqual({ ok: true, label: 'Worktree semantic title', source: 'harness' })
|
|
79
|
+
})
|
|
80
|
+
|
|
49
81
|
it('uses a trusted harness title when no explicit task label exists', () => {
|
|
50
82
|
const { env, now } = fixture()
|
|
51
83
|
expect(
|
|
@@ -59,7 +91,7 @@ describe('semantic session labels', () => {
|
|
|
59
91
|
).toEqual({ ok: true, label: 'NotifAI question lifecycle', source: 'harness' })
|
|
60
92
|
})
|
|
61
93
|
|
|
62
|
-
it('
|
|
94
|
+
it('replaces a frozen generated fallback when a managed semantic title appears later', () => {
|
|
63
95
|
const { env, now } = fixture()
|
|
64
96
|
const frozen = resolveSessionLabel({
|
|
65
97
|
env,
|
|
@@ -77,6 +109,82 @@ describe('semantic session labels', () => {
|
|
|
77
109
|
harness: 'claude-code',
|
|
78
110
|
harnessLabel: 'Worker - semantic session implementation',
|
|
79
111
|
}),
|
|
112
|
+
).toEqual({
|
|
113
|
+
ok: true,
|
|
114
|
+
label: 'Worker - semantic session implementation',
|
|
115
|
+
source: 'harness',
|
|
116
|
+
})
|
|
117
|
+
})
|
|
118
|
+
|
|
119
|
+
it('replaces a frozen generated fallback with a later explicit task name, once', () => {
|
|
120
|
+
const { env, now } = fixture()
|
|
121
|
+
const frozen = resolveSessionLabel({ env, now, sessionId: 'late-named', harness: 'codex' })
|
|
122
|
+
expect(frozen.ok && frozen.source).toBe('fallback')
|
|
123
|
+
|
|
124
|
+
expect(
|
|
125
|
+
resolveSessionLabel({
|
|
126
|
+
env,
|
|
127
|
+
now: now + 1_000,
|
|
128
|
+
sessionId: 'late-named',
|
|
129
|
+
harness: 'codex',
|
|
130
|
+
explicitLabel: 'Fix checkout retries',
|
|
131
|
+
}),
|
|
132
|
+
).toEqual({ ok: true, label: 'Fix checkout retries', source: 'explicit' })
|
|
133
|
+
expect(
|
|
134
|
+
resolveSessionLabel({
|
|
135
|
+
env,
|
|
136
|
+
now: now + 2_000,
|
|
137
|
+
sessionId: 'late-named',
|
|
138
|
+
harness: 'codex',
|
|
139
|
+
explicitLabel: 'A different later name',
|
|
140
|
+
}),
|
|
141
|
+
).toEqual({ ok: true, label: 'Fix checkout retries', source: 'explicit' })
|
|
142
|
+
})
|
|
143
|
+
|
|
144
|
+
it('disambiguates an upgraded name against every other stored session', () => {
|
|
145
|
+
const { env, now } = fixture()
|
|
146
|
+
expect(
|
|
147
|
+
resolveSessionLabel({
|
|
148
|
+
env,
|
|
149
|
+
now,
|
|
150
|
+
sessionId: 'other-session',
|
|
151
|
+
harness: 'codex',
|
|
152
|
+
explicitLabel: 'Release preparation',
|
|
153
|
+
}),
|
|
154
|
+
).toEqual({ ok: true, label: 'Release preparation', source: 'explicit' })
|
|
155
|
+
const frozen = resolveSessionLabel({ env, now, sessionId: 'upgraded', harness: 'codex' })
|
|
156
|
+
expect(frozen.ok && frozen.source).toBe('fallback')
|
|
157
|
+
|
|
158
|
+
expect(
|
|
159
|
+
resolveSessionLabel({
|
|
160
|
+
env,
|
|
161
|
+
now: now + 1_000,
|
|
162
|
+
sessionId: 'upgraded',
|
|
163
|
+
harness: 'codex',
|
|
164
|
+
explicitLabel: 'Release preparation',
|
|
165
|
+
}),
|
|
166
|
+
).toEqual({ ok: true, label: 'Release preparation · Codex', source: 'explicit' })
|
|
167
|
+
})
|
|
168
|
+
|
|
169
|
+
it('rejects an invalid explicit name instead of silently keeping the fallback', () => {
|
|
170
|
+
const { env, now } = fixture()
|
|
171
|
+
const frozen = resolveSessionLabel({ env, now, sessionId: 'still-fallback', harness: 'codex' })
|
|
172
|
+
expect(frozen.ok && frozen.source).toBe('fallback')
|
|
173
|
+
|
|
174
|
+
expect(
|
|
175
|
+
resolveSessionLabel({
|
|
176
|
+
env,
|
|
177
|
+
now: now + 1_000,
|
|
178
|
+
sessionId: 'still-fallback',
|
|
179
|
+
harness: 'codex',
|
|
180
|
+
explicitLabel: 'x'.repeat(65),
|
|
181
|
+
}),
|
|
182
|
+
).toEqual({
|
|
183
|
+
ok: false,
|
|
184
|
+
error: '--session-label (or NOTIFAI_SESSION_LABEL) must be at most 64 characters.',
|
|
185
|
+
})
|
|
186
|
+
expect(
|
|
187
|
+
resolveSessionLabel({ env, now: now + 2_000, sessionId: 'still-fallback', harness: 'codex' }),
|
|
80
188
|
).toEqual(frozen)
|
|
81
189
|
})
|
|
82
190
|
|
|
@@ -142,7 +250,7 @@ describe('semantic session labels', () => {
|
|
|
142
250
|
expect(stored).not.toContain('opaque-thread-1234567890')
|
|
143
251
|
})
|
|
144
252
|
|
|
145
|
-
it('migrates a frozen date fallback
|
|
253
|
+
it('migrates a frozen date fallback when no semantic candidate exists', () => {
|
|
146
254
|
const { env, now } = fixture()
|
|
147
255
|
const file = path.join(stateDir(env), 'session-labels.json')
|
|
148
256
|
expect(
|
|
@@ -164,10 +272,19 @@ describe('semantic session labels', () => {
|
|
|
164
272
|
now: now + 1_000,
|
|
165
273
|
sessionId: 'opaque-thread-1234567890',
|
|
166
274
|
harness: 'codex',
|
|
167
|
-
explicitLabel: 'Late semantic override',
|
|
168
275
|
}),
|
|
169
276
|
).toEqual({ ok: true, label: 'Ivory Koala', source: 'fallback' })
|
|
170
277
|
expect(readFileSync(file, 'utf8')).not.toContain('Codex session ·')
|
|
278
|
+
|
|
279
|
+
expect(
|
|
280
|
+
resolveSessionLabel({
|
|
281
|
+
env,
|
|
282
|
+
now: now + 2_000,
|
|
283
|
+
sessionId: 'opaque-thread-1234567890',
|
|
284
|
+
harness: 'codex',
|
|
285
|
+
explicitLabel: 'Late semantic override',
|
|
286
|
+
}),
|
|
287
|
+
).toEqual({ ok: true, label: 'Late semantic override', source: 'explicit' })
|
|
171
288
|
})
|
|
172
289
|
|
|
173
290
|
it('disambiguates repeated semantic titles without changing session identity', () => {
|
package/src/session-labels.ts
CHANGED
|
@@ -289,9 +289,15 @@ function uniqueLabel(
|
|
|
289
289
|
/**
|
|
290
290
|
* Freeze the first accepted human name for one immutable session.
|
|
291
291
|
*
|
|
292
|
-
*
|
|
293
|
-
*
|
|
294
|
-
*
|
|
292
|
+
* A title supplied by a trusted harness adapter wins, then explicit
|
|
293
|
+
* agent/User input, then a generated fallback. The environment-first order is
|
|
294
|
+
* what makes `--session-label` safe to pass unconditionally: an agent never
|
|
295
|
+
* needs to know which environments name their own sessions, because where one
|
|
296
|
+
* does, the explicit label is simply not used. A frozen generated fallback is
|
|
297
|
+
* the one exception to permanence: it names nothing, so the first semantic
|
|
298
|
+
* candidate to arrive later — a trusted harness title first, then explicit —
|
|
299
|
+
* replaces it once. Semantic names then stay frozen even when their branch,
|
|
300
|
+
* worktree, title candidate, or notification changes.
|
|
295
301
|
*/
|
|
296
302
|
export function resolveSessionLabel(input: SessionLabelInput): SessionLabelResolution {
|
|
297
303
|
const now = input.now ?? Date.now()
|
|
@@ -303,14 +309,39 @@ export function resolveSessionLabel(input: SessionLabelInput): SessionLabelResol
|
|
|
303
309
|
const store = readStore(file)
|
|
304
310
|
const existing = store.sessions[key]
|
|
305
311
|
if (existing !== undefined) {
|
|
306
|
-
if (!isLegacyDateFallback(existing)) {
|
|
307
|
-
return { ok: true, label: existing.label, source: existing.source }
|
|
308
|
-
}
|
|
309
312
|
const used = new Set(
|
|
310
313
|
Object.entries(store.sessions)
|
|
311
314
|
.filter(([storedKey]) => storedKey !== key)
|
|
312
315
|
.map(([, record]) => collisionKey(record.label)),
|
|
313
316
|
)
|
|
317
|
+
if (existing.source === 'fallback') {
|
|
318
|
+
const explicit = explicitCandidate(input.explicitLabel, input)
|
|
319
|
+
if (explicit !== null && 'error' in explicit) {
|
|
320
|
+
return { ok: false, error: explicit.error }
|
|
321
|
+
}
|
|
322
|
+
const native = harnessLabelIsPending(input.harnessLabel, input)
|
|
323
|
+
? null
|
|
324
|
+
: harnessCandidate(input.harnessLabel, input)
|
|
325
|
+
const upgrade = native ?? explicit
|
|
326
|
+
if (upgrade !== null) {
|
|
327
|
+
const label = uniqueLabel(upgrade.label, upgrade.source, input.harness, now, used)
|
|
328
|
+
store.sessions[key] = {
|
|
329
|
+
label,
|
|
330
|
+
source: upgrade.source,
|
|
331
|
+
first_seen_at: existing.first_seen_at,
|
|
332
|
+
...(input.harness === undefined
|
|
333
|
+
? existing.harness === undefined
|
|
334
|
+
? {}
|
|
335
|
+
: { harness: existing.harness }
|
|
336
|
+
: { harness: input.harness }),
|
|
337
|
+
}
|
|
338
|
+
writeStore(file, store)
|
|
339
|
+
return { ok: true, label, source: upgrade.source }
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
if (!isLegacyDateFallback(existing)) {
|
|
343
|
+
return { ok: true, label: existing.label, source: existing.source }
|
|
344
|
+
}
|
|
314
345
|
const label = uniqueLabel(
|
|
315
346
|
generatedSessionLabel(input.sessionId),
|
|
316
347
|
'fallback',
|
|
@@ -337,7 +368,7 @@ export function resolveSessionLabel(input: SessionLabelInput): SessionLabelResol
|
|
|
337
368
|
}
|
|
338
369
|
}
|
|
339
370
|
const candidate: SessionLabelCandidate =
|
|
340
|
-
|
|
371
|
+
native ?? explicit ?? fallbackCandidate(input.sessionId)
|
|
341
372
|
const used = new Set(
|
|
342
373
|
Object.values(store.sessions).map((record) => collisionKey(record.label)),
|
|
343
374
|
)
|