@raidiant/notifai 6.0.0 → 7.0.1

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.
Files changed (54) hide show
  1. package/CHANGELOG.md +34 -0
  2. package/dist/atomic-file.js +13 -0
  3. package/dist/atomic-file.js.map +1 -1
  4. package/dist/commands-core.d.ts +3 -0
  5. package/dist/commands-core.js +23 -4
  6. package/dist/commands-core.js.map +1 -1
  7. package/dist/commands-doctor.js +7 -5
  8. package/dist/commands-doctor.js.map +1 -1
  9. package/dist/commands-harness-context.d.ts +4 -0
  10. package/dist/commands-harness-context.js +4 -0
  11. package/dist/commands-harness-context.js.map +1 -1
  12. package/dist/commands-init.js +0 -1
  13. package/dist/commands-init.js.map +1 -1
  14. package/dist/commands-send-support.js +22 -0
  15. package/dist/commands-send-support.js.map +1 -1
  16. package/dist/config.d.ts +3 -3
  17. package/dist/config.js +3 -3
  18. package/dist/generated-session-label.d.ts +2 -0
  19. package/dist/generated-session-label.js +152 -0
  20. package/dist/generated-session-label.js.map +1 -0
  21. package/dist/invocation-context.d.ts +3 -0
  22. package/dist/invocation-context.js +23 -6
  23. package/dist/invocation-context.js.map +1 -1
  24. package/dist/main.js +2 -2
  25. package/dist/main.js.map +1 -1
  26. package/dist/opencode-plugin.js +18 -3
  27. package/dist/opencode-plugin.js.map +1 -1
  28. package/dist/orca-session-title.d.ts +10 -0
  29. package/dist/orca-session-title.js +99 -0
  30. package/dist/orca-session-title.js.map +1 -0
  31. package/dist/session-labels.d.ts +30 -0
  32. package/dist/session-labels.js +278 -0
  33. package/dist/session-labels.js.map +1 -0
  34. package/package.json +2 -2
  35. package/src/atomic-file.ts +12 -0
  36. package/src/commands-core.ts +24 -4
  37. package/src/commands-doctor.ts +7 -6
  38. package/src/commands-harness-context.ts +8 -0
  39. package/src/commands-init.ts +0 -1
  40. package/src/commands-send-support.ts +27 -0
  41. package/src/commands.test.ts +203 -6
  42. package/src/config.ts +3 -3
  43. package/src/generated-session-label.ts +155 -0
  44. package/src/hooks.test.ts +4 -5
  45. package/src/install-hooks.test.ts +84 -1
  46. package/src/invocation-context.test.ts +115 -16
  47. package/src/invocation-context.ts +32 -11
  48. package/src/main.ts +8 -2
  49. package/src/opencode-plugin.ts +18 -3
  50. package/src/orca-session-title.test.ts +95 -0
  51. package/src/orca-session-title.ts +105 -0
  52. package/src/session-labels.test.ts +324 -0
  53. package/src/session-labels.ts +360 -0
  54. package/src/skill.test.ts +15 -0
@@ -0,0 +1,324 @@
1
+ import { existsSync, mkdirSync, mkdtempSync, readFileSync, writeFileSync } from 'node:fs'
2
+ import os from 'node:os'
3
+ import path from 'node:path'
4
+ import { describe, expect, it } from 'vitest'
5
+ import { stateDir } from './config.js'
6
+ import { formatSessionFirstSeen, resolveSessionLabel } from './session-labels.js'
7
+
8
+ function fixture(): { env: NodeJS.ProcessEnv; now: number } {
9
+ const root = mkdtempSync(path.join(os.tmpdir(), 'notifai-session-labels-'))
10
+ return {
11
+ env: { XDG_STATE_HOME: root },
12
+ now: new Date(2026, 7, 20, 14, 5).getTime(),
13
+ }
14
+ }
15
+
16
+ describe('semantic session labels', () => {
17
+ it('freezes the first explicit task label for the immutable session', () => {
18
+ const { env, now } = fixture()
19
+ expect(
20
+ resolveSessionLabel({
21
+ env,
22
+ now,
23
+ sessionId: 'opaque-session-one',
24
+ harness: 'claude-code',
25
+ explicitLabel: ' Semantic session names ',
26
+ }),
27
+ ).toEqual({ ok: true, label: 'Semantic session names', source: 'explicit' })
28
+
29
+ expect(
30
+ resolveSessionLabel({
31
+ env,
32
+ now: now + 1_000,
33
+ sessionId: 'opaque-session-one',
34
+ harness: 'claude-code',
35
+ explicitLabel: 'A later unrelated label',
36
+ }),
37
+ ).toEqual({ ok: true, label: 'Semantic session names', source: 'explicit' })
38
+ expect(
39
+ resolveSessionLabel({
40
+ env,
41
+ now: now + 2_000,
42
+ sessionId: 'opaque-session-one',
43
+ harness: 'claude-code',
44
+ explicitLabel: 'x'.repeat(100),
45
+ }),
46
+ ).toEqual({ ok: true, label: 'Semantic session names', source: 'explicit' })
47
+ })
48
+
49
+ it('uses a trusted harness title when no explicit task label exists', () => {
50
+ const { env, now } = fixture()
51
+ expect(
52
+ resolveSessionLabel({
53
+ env,
54
+ now,
55
+ sessionId: 'opencode-session',
56
+ harness: 'opencode',
57
+ harnessLabel: 'NotifAI question lifecycle',
58
+ }),
59
+ ).toEqual({ ok: true, label: 'NotifAI question lifecycle', source: 'harness' })
60
+ })
61
+
62
+ it('does not replace a frozen fallback when a managed semantic title appears later', () => {
63
+ const { env, now } = fixture()
64
+ const frozen = resolveSessionLabel({
65
+ env,
66
+ now,
67
+ sessionId: 'claude-session-before-orca-metadata',
68
+ harness: 'claude-code',
69
+ })
70
+ expect(frozen.ok && frozen.source).toBe('fallback')
71
+
72
+ expect(
73
+ resolveSessionLabel({
74
+ env,
75
+ now: now + 1_000,
76
+ sessionId: 'claude-session-before-orca-metadata',
77
+ harness: 'claude-code',
78
+ harnessLabel: 'Worker - semantic session implementation',
79
+ }),
80
+ ).toEqual(frozen)
81
+ })
82
+
83
+ it('does not freeze OpenCode placeholder titles before the semantic title arrives', () => {
84
+ const { env, now } = fixture()
85
+ const file = path.join(stateDir(env), 'session-labels.json')
86
+ expect(
87
+ resolveSessionLabel({
88
+ env,
89
+ now,
90
+ sessionId: 'opencode-pending',
91
+ harness: 'opencode',
92
+ harnessLabel: 'New session - 2026-08-20T13:05:00.000Z',
93
+ harnessLabelPending: true,
94
+ }),
95
+ ).toEqual({
96
+ ok: false,
97
+ error:
98
+ "OpenCode is still generating this session's title; retry shortly or pass --session-label.",
99
+ })
100
+ expect(existsSync(file)).toBe(false)
101
+
102
+ expect(
103
+ resolveSessionLabel({
104
+ env,
105
+ now: now + 1_000,
106
+ sessionId: 'opencode-pending',
107
+ harness: 'opencode',
108
+ harnessLabel: 'Semantic session names',
109
+ }),
110
+ ).toEqual({ ok: true, label: 'Semantic session names', source: 'harness' })
111
+ })
112
+
113
+ it('allows an explicit task name while a native title is still pending', () => {
114
+ const { env, now } = fixture()
115
+ expect(
116
+ resolveSessionLabel({
117
+ env,
118
+ now,
119
+ sessionId: 'opencode-pending',
120
+ harness: 'opencode',
121
+ harnessLabelPending: true,
122
+ explicitLabel: 'Verify release candidate',
123
+ }),
124
+ ).toEqual({ ok: true, label: 'Verify release candidate', source: 'explicit' })
125
+ })
126
+
127
+ it('falls back to a stable generated name without exposing the session id', () => {
128
+ const { env, now } = fixture()
129
+ const resolved = resolveSessionLabel({
130
+ env,
131
+ now,
132
+ sessionId: 'opaque-thread-1234567890',
133
+ harness: 'codex',
134
+ })
135
+ expect(resolved).toEqual({
136
+ ok: true,
137
+ label: 'Ivory Koala',
138
+ source: 'fallback',
139
+ })
140
+
141
+ const stored = readFileSync(path.join(stateDir(env), 'session-labels.json'), 'utf8')
142
+ expect(stored).not.toContain('opaque-thread-1234567890')
143
+ })
144
+
145
+ it('migrates a frozen date fallback without reopening semantic precedence', () => {
146
+ const { env, now } = fixture()
147
+ const file = path.join(stateDir(env), 'session-labels.json')
148
+ expect(
149
+ resolveSessionLabel({ env, now, sessionId: 'opaque-thread-1234567890', harness: 'codex' }),
150
+ ).toEqual({ ok: true, label: 'Ivory Koala', source: 'fallback' })
151
+
152
+ const store = JSON.parse(readFileSync(file, 'utf8')) as {
153
+ sessions: Record<string, { label: string }>
154
+ }
155
+ const key = Object.keys(store.sessions)[0]
156
+ const record = key === undefined ? undefined : store.sessions[key]
157
+ if (record === undefined) throw new Error('expected one stored session')
158
+ record.label = 'Codex session · Aug 20, 2026 14:05'
159
+ writeFileSync(file, `${JSON.stringify(store, null, 2)}\n`)
160
+
161
+ expect(
162
+ resolveSessionLabel({
163
+ env,
164
+ now: now + 1_000,
165
+ sessionId: 'opaque-thread-1234567890',
166
+ harness: 'codex',
167
+ explicitLabel: 'Late semantic override',
168
+ }),
169
+ ).toEqual({ ok: true, label: 'Ivory Koala', source: 'fallback' })
170
+ expect(readFileSync(file, 'utf8')).not.toContain('Codex session ·')
171
+ })
172
+
173
+ it('disambiguates repeated semantic titles without changing session identity', () => {
174
+ const { env, now } = fixture()
175
+ const labels = ['one', 'two', 'three', 'four'].map((sessionId) =>
176
+ resolveSessionLabel({
177
+ env,
178
+ now,
179
+ sessionId,
180
+ harness: 'codex',
181
+ explicitLabel: 'Release preparation',
182
+ }),
183
+ )
184
+ expect(labels).toEqual([
185
+ { ok: true, label: 'Release preparation', source: 'explicit' },
186
+ { ok: true, label: 'Release preparation · Codex', source: 'explicit' },
187
+ {
188
+ ok: true,
189
+ label: 'Release preparation · Codex · Aug 20, 2026 14:05',
190
+ source: 'explicit',
191
+ },
192
+ {
193
+ ok: true,
194
+ label: 'Release preparation · Codex · Aug 20, 2026 14:05 · 2',
195
+ source: 'explicit',
196
+ },
197
+ ])
198
+ })
199
+
200
+ it('adds only an ordinal when generated fallback labels collide', () => {
201
+ const { env, now } = fixture()
202
+ const first = resolveSessionLabel({ env, now, sessionId: 'collision-8', harness: 'codex' })
203
+ const second = resolveSessionLabel({
204
+ env,
205
+ now,
206
+ sessionId: 'collision-112',
207
+ harness: 'codex',
208
+ })
209
+ expect(first).toEqual({
210
+ ok: true,
211
+ label: 'Winter Dolphin',
212
+ source: 'fallback',
213
+ })
214
+ expect(second).toEqual({
215
+ ok: true,
216
+ label: 'Winter Dolphin · 2',
217
+ source: 'fallback',
218
+ })
219
+ })
220
+
221
+ it('keeps astral native and disambiguated labels inside the wire bound', () => {
222
+ const { env, now } = fixture()
223
+ const long = '😀'.repeat(100)
224
+ const first = resolveSessionLabel({
225
+ env,
226
+ now,
227
+ sessionId: 'first',
228
+ harness: 'opencode',
229
+ harnessLabel: long,
230
+ })
231
+ const second = resolveSessionLabel({
232
+ env,
233
+ now,
234
+ sessionId: 'second',
235
+ harness: 'opencode',
236
+ harnessLabel: long,
237
+ })
238
+ expect(first.ok && first.label.length).toBeLessThanOrEqual(64)
239
+ expect(second.ok && second.label.length).toBeLessThanOrEqual(64)
240
+ expect(first.ok && first.label.endsWith('…')).toBe(true)
241
+ })
242
+
243
+ it('rejects blank and oversized explicit labels before storing them', () => {
244
+ const { env, now } = fixture()
245
+ expect(
246
+ resolveSessionLabel({ env, now, sessionId: 'blank', explicitLabel: ' ' }),
247
+ ).toEqual({
248
+ ok: false,
249
+ error: '--session-label (or NOTIFAI_SESSION_LABEL) must not be empty.',
250
+ })
251
+ expect(
252
+ resolveSessionLabel({ env, now, sessionId: 'long', explicitLabel: 'x'.repeat(65) }),
253
+ ).toEqual({
254
+ ok: false,
255
+ error: '--session-label (or NOTIFAI_SESSION_LABEL) must be at most 64 characters.',
256
+ })
257
+ expect(
258
+ resolveSessionLabel({ env, now, sessionId: 'emoji', explicitLabel: '😀'.repeat(33) }),
259
+ ).toEqual({
260
+ ok: false,
261
+ error: '--session-label (or NOTIFAI_SESSION_LABEL) must be at most 64 characters.',
262
+ })
263
+ })
264
+
265
+ it('rejects explicit identifiers and paths and never promotes unsafe native titles', () => {
266
+ const { env, now } = fixture()
267
+ env['HOME'] = '/Users/example'
268
+ const error =
269
+ '--session-label (or NOTIFAI_SESSION_LABEL) must not contain a session identifier, hash, or filesystem path.'
270
+
271
+ expect(
272
+ resolveSessionLabel({
273
+ env,
274
+ now,
275
+ sessionId: 'ses_opaque123456',
276
+ explicitLabel: 'ses_opaque123456',
277
+ }),
278
+ ).toEqual({ ok: false, error })
279
+ expect(
280
+ resolveSessionLabel({
281
+ env,
282
+ now,
283
+ sessionId: 'safe-session',
284
+ explicitLabel: 'Fix /Users/example/private-client',
285
+ }),
286
+ ).toEqual({ ok: false, error })
287
+ expect(
288
+ resolveSessionLabel({
289
+ env,
290
+ now,
291
+ sessionId: 'native-session',
292
+ harness: 'opencode',
293
+ harnessLabel: 'Fix /Users/example/private-client',
294
+ }),
295
+ ).toEqual({
296
+ ok: true,
297
+ label: 'Violet Crane',
298
+ source: 'fallback',
299
+ })
300
+ })
301
+
302
+ it('fails closed instead of replacing an unreadable name store', () => {
303
+ const { env, now } = fixture()
304
+ const file = path.join(stateDir(env), 'session-labels.json')
305
+ mkdirSync(path.dirname(file), { recursive: true })
306
+ writeFileSync(file, '{not json}\n')
307
+
308
+ const resolved = resolveSessionLabel({
309
+ env,
310
+ now,
311
+ sessionId: 'new-session',
312
+ explicitLabel: 'Do not overwrite me',
313
+ })
314
+
315
+ expect(resolved.ok).toBe(false)
316
+ expect(!resolved.ok && resolved.error).toContain('session-name store is unreadable')
317
+ expect(readFileSync(file, 'utf8')).toBe('{not json}\n')
318
+ })
319
+
320
+ it('formats first seen using the machine local calendar', () => {
321
+ const now = new Date(2026, 0, 2, 3, 4).getTime()
322
+ expect(formatSessionFirstSeen(now)).toBe('Jan 2, 2026 03:04')
323
+ })
324
+ })
@@ -0,0 +1,360 @@
1
+ import { createHash } from 'node:crypto'
2
+ import { existsSync, readFileSync } from 'node:fs'
3
+ import path from 'node:path'
4
+ import { SESSION_LABEL_MAX_LENGTH } from '@raidiant/notifai-protocol'
5
+ import { atomicWriteFileSync } from './atomic-file.js'
6
+ import { stateDir } from './config.js'
7
+ import { withFileLock } from './file-lock.js'
8
+ import { generatedSessionLabel } from './generated-session-label.js'
9
+ import { HARNESS_LABELS, type Harness } from './harnesses.js'
10
+
11
+ const STORE_VERSION = 1
12
+ const MONTHS = [
13
+ 'Jan',
14
+ 'Feb',
15
+ 'Mar',
16
+ 'Apr',
17
+ 'May',
18
+ 'Jun',
19
+ 'Jul',
20
+ 'Aug',
21
+ 'Sep',
22
+ 'Oct',
23
+ 'Nov',
24
+ 'Dec',
25
+ ] as const
26
+
27
+ type SessionLabelSource = 'explicit' | 'harness' | 'fallback'
28
+
29
+ interface SessionLabelCandidate {
30
+ label: string
31
+ source: SessionLabelSource
32
+ }
33
+
34
+ interface StoredSessionLabel {
35
+ label: string
36
+ source: SessionLabelSource
37
+ first_seen_at: number
38
+ harness?: Harness
39
+ }
40
+
41
+ interface SessionLabelStore {
42
+ version: typeof STORE_VERSION
43
+ sessions: Record<string, StoredSessionLabel>
44
+ }
45
+
46
+ export interface SessionLabelInput {
47
+ env: NodeJS.ProcessEnv
48
+ sessionId: string
49
+ harness?: Harness
50
+ explicitLabel?: string
51
+ harnessLabel?: string
52
+ harnessLabelPending?: boolean
53
+ now?: number
54
+ }
55
+
56
+ export type SessionLabelResolution =
57
+ | { ok: true; label: string; source: SessionLabelSource }
58
+ | { ok: false; error: string }
59
+
60
+ function storePath(env: NodeJS.ProcessEnv): string {
61
+ return path.join(stateDir(env), 'session-labels.json')
62
+ }
63
+
64
+ /** The state key cannot reveal the harness's opaque session identifier. */
65
+ function sessionKey(sessionId: string): string {
66
+ return createHash('sha256').update(sessionId).digest('hex')
67
+ }
68
+
69
+ function emptyStore(): SessionLabelStore {
70
+ return { version: STORE_VERSION, sessions: {} }
71
+ }
72
+
73
+ function storedRecord(candidate: unknown): StoredSessionLabel | null {
74
+ if (typeof candidate !== 'object' || candidate === null) return null
75
+ const value = candidate as Partial<StoredSessionLabel>
76
+ if (
77
+ typeof value.label !== 'string' ||
78
+ value.label.length === 0 ||
79
+ value.label.length > SESSION_LABEL_MAX_LENGTH ||
80
+ !['explicit', 'harness', 'fallback'].includes(value.source ?? '') ||
81
+ typeof value.first_seen_at !== 'number' ||
82
+ !Number.isFinite(value.first_seen_at)
83
+ ) {
84
+ return null
85
+ }
86
+ const harness = value.harness
87
+ if (harness !== undefined && !Object.hasOwn(HARNESS_LABELS, harness)) return null
88
+ return {
89
+ label: value.label,
90
+ source: value.source as SessionLabelSource,
91
+ first_seen_at: value.first_seen_at,
92
+ ...(harness === undefined ? {} : { harness }),
93
+ }
94
+ }
95
+
96
+ function readStore(file: string): SessionLabelStore {
97
+ if (!existsSync(file)) return emptyStore()
98
+ let parsed: unknown
99
+ try {
100
+ parsed = JSON.parse(readFileSync(file, 'utf8'))
101
+ } catch (err) {
102
+ throw new Error(
103
+ `the session-name store is unreadable: ${err instanceof Error ? err.message : String(err)}`,
104
+ )
105
+ }
106
+ if (typeof parsed !== 'object' || parsed === null) {
107
+ throw new Error('the session-name store is not a JSON object')
108
+ }
109
+ const raw = parsed as { version?: unknown; sessions?: unknown }
110
+ if (raw.version !== STORE_VERSION) {
111
+ throw new Error(`unsupported session-name store version ${String(raw.version)}`)
112
+ }
113
+ if (typeof raw.sessions !== 'object' || raw.sessions === null) {
114
+ throw new Error('the session-name store has no sessions object')
115
+ }
116
+ const sessions: Record<string, StoredSessionLabel> = {}
117
+ for (const [key, candidate] of Object.entries(raw.sessions)) {
118
+ const record = storedRecord(candidate)
119
+ if (!/^[a-f0-9]{64}$/.test(key) || record === null) {
120
+ throw new Error('the session-name store contains an invalid record')
121
+ }
122
+ sessions[key] = record
123
+ }
124
+ return { version: STORE_VERSION, sessions }
125
+ }
126
+
127
+ function writeStore(file: string, store: SessionLabelStore): void {
128
+ atomicWriteFileSync(file, `${JSON.stringify(store, null, 2)}\n`)
129
+ }
130
+
131
+ function normalizeWhitespace(value: string): string {
132
+ return value.trim().replaceAll(/\s+/gu, ' ')
133
+ }
134
+
135
+ /** Match the same UTF-16 length TypeBox enforces at the wire boundary. */
136
+ function truncate(value: string, maxLength: number): string {
137
+ if (value.length <= maxLength) return value
138
+ let prefix = value.slice(0, Math.max(0, maxLength - 1))
139
+ const last = prefix.charCodeAt(prefix.length - 1)
140
+ if (last >= 0xd800 && last <= 0xdbff) prefix = prefix.slice(0, -1)
141
+ return `${prefix}…`
142
+ }
143
+
144
+ const UUID_PATTERN =
145
+ /\b[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\b/iu
146
+ const HASH_PATTERN = /\b[0-9a-f]{12,}\b/iu
147
+ const HARNESS_ID_PATTERN = /\b(?:ses|thread)_[A-Za-z0-9_-]{8,}\b/iu
148
+ const FILESYSTEM_PATH_PATTERN =
149
+ /(?:^|[\s("'=:])(?:~[\\/]|[A-Za-z]:[\\/]|\\\\|\/(?:Users|home|private|tmp|var|Volumes|opt|etc|usr|workspace|workspaces)(?:[\\/]|$))/u
150
+ const OPENCODE_PENDING_TITLE =
151
+ /^New session(?:\s*-\s*\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?Z)?$/iu
152
+
153
+ function containsPrivatePath(value: string, env: NodeJS.ProcessEnv): boolean {
154
+ if (FILESYSTEM_PATH_PATTERN.test(value)) return true
155
+ const folded = value.toLocaleLowerCase('en-US')
156
+ for (const key of ['HOME', 'USERPROFILE', 'TMPDIR'] as const) {
157
+ const candidate = env[key]?.trim().replace(/[\\/]+$/u, '')
158
+ if (
159
+ candidate !== undefined &&
160
+ candidate.length > 1 &&
161
+ folded.includes(candidate.toLocaleLowerCase('en-US'))
162
+ ) {
163
+ return true
164
+ }
165
+ }
166
+ return false
167
+ }
168
+
169
+ function violatesLabelPrivacy(value: string, input: SessionLabelInput): boolean {
170
+ const sessionId = input.sessionId.trim()
171
+ const exposesCurrentId =
172
+ sessionId.length > 0 &&
173
+ (value === sessionId || (sessionId.length >= 8 && value.includes(sessionId)))
174
+ return (
175
+ exposesCurrentId ||
176
+ UUID_PATTERN.test(value) ||
177
+ HASH_PATTERN.test(value) ||
178
+ HARNESS_ID_PATTERN.test(value) ||
179
+ containsPrivatePath(value, input.env)
180
+ )
181
+ }
182
+
183
+ function explicitCandidate(
184
+ value: string | undefined,
185
+ input: SessionLabelInput,
186
+ ): SessionLabelCandidate | { error: string } | null {
187
+ if (value === undefined) return null
188
+ const normalized = normalizeWhitespace(value)
189
+ if (normalized.length === 0) {
190
+ return { error: '--session-label (or NOTIFAI_SESSION_LABEL) must not be empty.' }
191
+ }
192
+ if (normalized.length > SESSION_LABEL_MAX_LENGTH) {
193
+ return {
194
+ error: `--session-label (or NOTIFAI_SESSION_LABEL) must be at most ${SESSION_LABEL_MAX_LENGTH} characters.`,
195
+ }
196
+ }
197
+ if (violatesLabelPrivacy(normalized, input)) {
198
+ return {
199
+ error:
200
+ '--session-label (or NOTIFAI_SESSION_LABEL) must not contain a session identifier, hash, or filesystem path.',
201
+ }
202
+ }
203
+ return { label: normalized, source: 'explicit' }
204
+ }
205
+
206
+ function harnessLabelIsPending(
207
+ value: string | undefined,
208
+ input: SessionLabelInput,
209
+ ): boolean {
210
+ if (input.harnessLabelPending === true) return true
211
+ if (input.harness !== 'opencode' || value === undefined) return false
212
+ return OPENCODE_PENDING_TITLE.test(normalizeWhitespace(value))
213
+ }
214
+
215
+ function harnessCandidate(
216
+ value: string | undefined,
217
+ input: SessionLabelInput,
218
+ ): SessionLabelCandidate | null {
219
+ if (value === undefined) return null
220
+ const normalized = normalizeWhitespace(value)
221
+ if (normalized.length === 0 || violatesLabelPrivacy(normalized, input)) return null
222
+ return {
223
+ label: truncate(normalized, SESSION_LABEL_MAX_LENGTH),
224
+ source: 'harness',
225
+ }
226
+ }
227
+
228
+ function pad(value: number): string {
229
+ return String(value).padStart(2, '0')
230
+ }
231
+
232
+ /** Stable English shape using the machine's local calendar and clock. */
233
+ export function formatSessionFirstSeen(now: number): string {
234
+ const date = new Date(now)
235
+ return `${MONTHS[date.getMonth()]} ${date.getDate()}, ${date.getFullYear()} ${pad(date.getHours())}:${pad(date.getMinutes())}`
236
+ }
237
+
238
+ function fallbackCandidate(sessionId: string): SessionLabelCandidate {
239
+ return {
240
+ label: generatedSessionLabel(sessionId),
241
+ source: 'fallback',
242
+ }
243
+ }
244
+
245
+ function isLegacyDateFallback(record: StoredSessionLabel): boolean {
246
+ if (record.source !== 'fallback') return false
247
+ const owner = record.harness === undefined ? 'Agent' : HARNESS_LABELS[record.harness]
248
+ return record.label.startsWith(`${owner} session · `)
249
+ }
250
+
251
+ function withSuffix(base: string, suffix: string): string {
252
+ const room = SESSION_LABEL_MAX_LENGTH - suffix.length
253
+ return `${truncate(base, room)}${suffix}`
254
+ }
255
+
256
+ function collisionKey(value: string): string {
257
+ return value.normalize('NFKC').toLocaleLowerCase('en-US')
258
+ }
259
+
260
+ function uniqueLabel(
261
+ base: string,
262
+ source: SessionLabelSource,
263
+ harness: Harness | undefined,
264
+ now: number,
265
+ used: ReadonlySet<string>,
266
+ ): string {
267
+ if (!used.has(collisionKey(base))) return base
268
+
269
+ if (source === 'fallback') {
270
+ for (let ordinal = 2; ; ordinal += 1) {
271
+ const candidate = withSuffix(base, ` · ${ordinal}`)
272
+ if (!used.has(collisionKey(candidate))) return candidate
273
+ }
274
+ }
275
+
276
+ const owner = harness === undefined ? 'Agent' : HARNESS_LABELS[harness]
277
+ const ownerCandidate = withSuffix(base, ` · ${owner}`)
278
+ if (!used.has(collisionKey(ownerCandidate))) return ownerCandidate
279
+
280
+ const timed = withSuffix(base, ` · ${owner} · ${formatSessionFirstSeen(now)}`)
281
+ if (!used.has(collisionKey(timed))) return timed
282
+
283
+ for (let ordinal = 2; ; ordinal += 1) {
284
+ const candidate = withSuffix(timed, ` · ${ordinal}`)
285
+ if (!used.has(collisionKey(candidate))) return candidate
286
+ }
287
+ }
288
+
289
+ /**
290
+ * Freeze the first accepted human name for one immutable session.
291
+ *
292
+ * Explicit agent/User input wins, then a title supplied by a trusted harness
293
+ * adapter, then a generated fallback. Later sends reuse the frozen value
294
+ * even when their branch, worktree, title candidate, or notification changes.
295
+ */
296
+ export function resolveSessionLabel(input: SessionLabelInput): SessionLabelResolution {
297
+ const now = input.now ?? Date.now()
298
+ const file = storePath(input.env)
299
+ const key = sessionKey(input.sessionId)
300
+
301
+ try {
302
+ return withFileLock(`${file}.lock`, () => {
303
+ const store = readStore(file)
304
+ const existing = store.sessions[key]
305
+ if (existing !== undefined) {
306
+ if (!isLegacyDateFallback(existing)) {
307
+ return { ok: true, label: existing.label, source: existing.source }
308
+ }
309
+ const used = new Set(
310
+ Object.entries(store.sessions)
311
+ .filter(([storedKey]) => storedKey !== key)
312
+ .map(([, record]) => collisionKey(record.label)),
313
+ )
314
+ const label = uniqueLabel(
315
+ generatedSessionLabel(input.sessionId),
316
+ 'fallback',
317
+ input.harness ?? existing.harness,
318
+ now,
319
+ used,
320
+ )
321
+ store.sessions[key] = { ...existing, label }
322
+ writeStore(file, store)
323
+ return { ok: true, label, source: existing.source }
324
+ }
325
+
326
+ const explicit = explicitCandidate(input.explicitLabel, input)
327
+ if (explicit !== null && 'error' in explicit) {
328
+ return { ok: false, error: explicit.error }
329
+ }
330
+ const nativePending = harnessLabelIsPending(input.harnessLabel, input)
331
+ const native = nativePending ? null : harnessCandidate(input.harnessLabel, input)
332
+ if (explicit === null && nativePending) {
333
+ return {
334
+ ok: false,
335
+ error:
336
+ "OpenCode is still generating this session's title; retry shortly or pass --session-label.",
337
+ }
338
+ }
339
+ const candidate: SessionLabelCandidate =
340
+ explicit ?? native ?? fallbackCandidate(input.sessionId)
341
+ const used = new Set(
342
+ Object.values(store.sessions).map((record) => collisionKey(record.label)),
343
+ )
344
+ const label = uniqueLabel(candidate.label, candidate.source, input.harness, now, used)
345
+ store.sessions[key] = {
346
+ label,
347
+ source: candidate.source,
348
+ first_seen_at: now,
349
+ ...(input.harness === undefined ? {} : { harness: input.harness }),
350
+ }
351
+ writeStore(file, store)
352
+ return { ok: true, label, source: candidate.source }
353
+ })
354
+ } catch (err) {
355
+ return {
356
+ ok: false,
357
+ error: `Could not persist this session's name: ${err instanceof Error ? err.message : String(err)}`,
358
+ }
359
+ }
360
+ }
package/src/skill.test.ts CHANGED
@@ -97,6 +97,21 @@ describe('Notifai agent skill', () => {
97
97
  expect(send).toMatch(/never\s+put the kind or the project in it/i)
98
98
  })
99
99
 
100
+ it('names each immutable session once without asking the agent for its id', () => {
101
+ const send = section('## Send')
102
+ expect(send).toContain('--session-label')
103
+ expect(send).toMatch(/first Notification\s+Request/i)
104
+ expect(send).toMatch(/2-6 words/i)
105
+ expect(send).toMatch(/Managed OpenCode and Orca-managed Claude Code/i)
106
+ expect(send).toMatch(/omit `--session-label` there/i)
107
+ expect(send).toMatch(/Outside those managed sources/i)
108
+ expect(send).toMatch(/Claude Code, Codex,\s+and Cursor/i)
109
+ expect(send).toMatch(/freezes the first accepted name/i)
110
+ expect(send).toMatch(/omit the\s+flag on later sends and questions/i)
111
+ expect(send).toMatch(/never pass\s+`--session-id`/i)
112
+ expect(send).toMatch(/identifier, hash, or filesystem path/i)
113
+ })
114
+
100
115
  it('separates how long the command waits from how long an answer is accepted', () => {
101
116
  const ask = section('## Ask a question')
102
117
  expect(ask).toContain('--reply-timeout')