@raidiant/notifai 6.0.0 → 7.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.
Files changed (39) hide show
  1. package/CHANGELOG.md +24 -0
  2. package/dist/atomic-file.js +13 -0
  3. package/dist/atomic-file.js.map +1 -1
  4. package/dist/commands-harness-context.d.ts +4 -0
  5. package/dist/commands-harness-context.js +4 -0
  6. package/dist/commands-harness-context.js.map +1 -1
  7. package/dist/commands-send-support.js +3 -0
  8. package/dist/commands-send-support.js.map +1 -1
  9. package/dist/config.d.ts +3 -3
  10. package/dist/config.js +3 -3
  11. package/dist/generated-session-label.d.ts +2 -0
  12. package/dist/generated-session-label.js +152 -0
  13. package/dist/generated-session-label.js.map +1 -0
  14. package/dist/invocation-context.d.ts +3 -0
  15. package/dist/invocation-context.js +23 -6
  16. package/dist/invocation-context.js.map +1 -1
  17. package/dist/main.js +2 -2
  18. package/dist/main.js.map +1 -1
  19. package/dist/opencode-plugin.js +18 -3
  20. package/dist/opencode-plugin.js.map +1 -1
  21. package/dist/session-labels.d.ts +30 -0
  22. package/dist/session-labels.js +278 -0
  23. package/dist/session-labels.js.map +1 -0
  24. package/package.json +2 -2
  25. package/src/atomic-file.ts +12 -0
  26. package/src/commands-harness-context.ts +8 -0
  27. package/src/commands-send-support.ts +3 -0
  28. package/src/commands.test.ts +74 -3
  29. package/src/config.ts +3 -3
  30. package/src/generated-session-label.ts +155 -0
  31. package/src/hooks.test.ts +4 -5
  32. package/src/install-hooks.test.ts +84 -1
  33. package/src/invocation-context.test.ts +115 -16
  34. package/src/invocation-context.ts +32 -11
  35. package/src/main.ts +8 -2
  36. package/src/opencode-plugin.ts +18 -3
  37. package/src/session-labels.test.ts +303 -0
  38. package/src/session-labels.ts +360 -0
  39. package/src/skill.test.ts +13 -0
@@ -0,0 +1,30 @@
1
+ import { type Harness } from './harnesses.js';
2
+ type SessionLabelSource = 'explicit' | 'harness' | 'fallback';
3
+ export interface SessionLabelInput {
4
+ env: NodeJS.ProcessEnv;
5
+ sessionId: string;
6
+ harness?: Harness;
7
+ explicitLabel?: string;
8
+ harnessLabel?: string;
9
+ harnessLabelPending?: boolean;
10
+ now?: number;
11
+ }
12
+ export type SessionLabelResolution = {
13
+ ok: true;
14
+ label: string;
15
+ source: SessionLabelSource;
16
+ } | {
17
+ ok: false;
18
+ error: string;
19
+ };
20
+ /** Stable English shape using the machine's local calendar and clock. */
21
+ export declare function formatSessionFirstSeen(now: number): string;
22
+ /**
23
+ * Freeze the first accepted human name for one immutable session.
24
+ *
25
+ * Explicit agent/User input wins, then a title supplied by a trusted harness
26
+ * adapter, then a generated fallback. Later sends reuse the frozen value
27
+ * even when their branch, worktree, title candidate, or notification changes.
28
+ */
29
+ export declare function resolveSessionLabel(input: SessionLabelInput): SessionLabelResolution;
30
+ export {};
@@ -0,0 +1,278 @@
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 } from './harnesses.js';
10
+ const STORE_VERSION = 1;
11
+ const MONTHS = [
12
+ 'Jan',
13
+ 'Feb',
14
+ 'Mar',
15
+ 'Apr',
16
+ 'May',
17
+ 'Jun',
18
+ 'Jul',
19
+ 'Aug',
20
+ 'Sep',
21
+ 'Oct',
22
+ 'Nov',
23
+ 'Dec',
24
+ ];
25
+ function storePath(env) {
26
+ return path.join(stateDir(env), 'session-labels.json');
27
+ }
28
+ /** The state key cannot reveal the harness's opaque session identifier. */
29
+ function sessionKey(sessionId) {
30
+ return createHash('sha256').update(sessionId).digest('hex');
31
+ }
32
+ function emptyStore() {
33
+ return { version: STORE_VERSION, sessions: {} };
34
+ }
35
+ function storedRecord(candidate) {
36
+ if (typeof candidate !== 'object' || candidate === null)
37
+ return null;
38
+ const value = candidate;
39
+ if (typeof value.label !== 'string' ||
40
+ value.label.length === 0 ||
41
+ value.label.length > SESSION_LABEL_MAX_LENGTH ||
42
+ !['explicit', 'harness', 'fallback'].includes(value.source ?? '') ||
43
+ typeof value.first_seen_at !== 'number' ||
44
+ !Number.isFinite(value.first_seen_at)) {
45
+ return null;
46
+ }
47
+ const harness = value.harness;
48
+ if (harness !== undefined && !Object.hasOwn(HARNESS_LABELS, harness))
49
+ return null;
50
+ return {
51
+ label: value.label,
52
+ source: value.source,
53
+ first_seen_at: value.first_seen_at,
54
+ ...(harness === undefined ? {} : { harness }),
55
+ };
56
+ }
57
+ function readStore(file) {
58
+ if (!existsSync(file))
59
+ return emptyStore();
60
+ let parsed;
61
+ try {
62
+ parsed = JSON.parse(readFileSync(file, 'utf8'));
63
+ }
64
+ catch (err) {
65
+ throw new Error(`the session-name store is unreadable: ${err instanceof Error ? err.message : String(err)}`);
66
+ }
67
+ if (typeof parsed !== 'object' || parsed === null) {
68
+ throw new Error('the session-name store is not a JSON object');
69
+ }
70
+ const raw = parsed;
71
+ if (raw.version !== STORE_VERSION) {
72
+ throw new Error(`unsupported session-name store version ${String(raw.version)}`);
73
+ }
74
+ if (typeof raw.sessions !== 'object' || raw.sessions === null) {
75
+ throw new Error('the session-name store has no sessions object');
76
+ }
77
+ const sessions = {};
78
+ for (const [key, candidate] of Object.entries(raw.sessions)) {
79
+ const record = storedRecord(candidate);
80
+ if (!/^[a-f0-9]{64}$/.test(key) || record === null) {
81
+ throw new Error('the session-name store contains an invalid record');
82
+ }
83
+ sessions[key] = record;
84
+ }
85
+ return { version: STORE_VERSION, sessions };
86
+ }
87
+ function writeStore(file, store) {
88
+ atomicWriteFileSync(file, `${JSON.stringify(store, null, 2)}\n`);
89
+ }
90
+ function normalizeWhitespace(value) {
91
+ return value.trim().replaceAll(/\s+/gu, ' ');
92
+ }
93
+ /** Match the same UTF-16 length TypeBox enforces at the wire boundary. */
94
+ function truncate(value, maxLength) {
95
+ if (value.length <= maxLength)
96
+ return value;
97
+ let prefix = value.slice(0, Math.max(0, maxLength - 1));
98
+ const last = prefix.charCodeAt(prefix.length - 1);
99
+ if (last >= 0xd800 && last <= 0xdbff)
100
+ prefix = prefix.slice(0, -1);
101
+ return `${prefix}…`;
102
+ }
103
+ const UUID_PATTERN = /\b[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\b/iu;
104
+ const HASH_PATTERN = /\b[0-9a-f]{12,}\b/iu;
105
+ const HARNESS_ID_PATTERN = /\b(?:ses|thread)_[A-Za-z0-9_-]{8,}\b/iu;
106
+ const FILESYSTEM_PATH_PATTERN = /(?:^|[\s("'=:])(?:~[\\/]|[A-Za-z]:[\\/]|\\\\|\/(?:Users|home|private|tmp|var|Volumes|opt|etc|usr|workspace|workspaces)(?:[\\/]|$))/u;
107
+ const OPENCODE_PENDING_TITLE = /^New session(?:\s*-\s*\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?Z)?$/iu;
108
+ function containsPrivatePath(value, env) {
109
+ if (FILESYSTEM_PATH_PATTERN.test(value))
110
+ return true;
111
+ const folded = value.toLocaleLowerCase('en-US');
112
+ for (const key of ['HOME', 'USERPROFILE', 'TMPDIR']) {
113
+ const candidate = env[key]?.trim().replace(/[\\/]+$/u, '');
114
+ if (candidate !== undefined &&
115
+ candidate.length > 1 &&
116
+ folded.includes(candidate.toLocaleLowerCase('en-US'))) {
117
+ return true;
118
+ }
119
+ }
120
+ return false;
121
+ }
122
+ function violatesLabelPrivacy(value, input) {
123
+ const sessionId = input.sessionId.trim();
124
+ const exposesCurrentId = sessionId.length > 0 &&
125
+ (value === sessionId || (sessionId.length >= 8 && value.includes(sessionId)));
126
+ return (exposesCurrentId ||
127
+ UUID_PATTERN.test(value) ||
128
+ HASH_PATTERN.test(value) ||
129
+ HARNESS_ID_PATTERN.test(value) ||
130
+ containsPrivatePath(value, input.env));
131
+ }
132
+ function explicitCandidate(value, input) {
133
+ if (value === undefined)
134
+ return null;
135
+ const normalized = normalizeWhitespace(value);
136
+ if (normalized.length === 0) {
137
+ return { error: '--session-label (or NOTIFAI_SESSION_LABEL) must not be empty.' };
138
+ }
139
+ if (normalized.length > SESSION_LABEL_MAX_LENGTH) {
140
+ return {
141
+ error: `--session-label (or NOTIFAI_SESSION_LABEL) must be at most ${SESSION_LABEL_MAX_LENGTH} characters.`,
142
+ };
143
+ }
144
+ if (violatesLabelPrivacy(normalized, input)) {
145
+ return {
146
+ error: '--session-label (or NOTIFAI_SESSION_LABEL) must not contain a session identifier, hash, or filesystem path.',
147
+ };
148
+ }
149
+ return { label: normalized, source: 'explicit' };
150
+ }
151
+ function harnessLabelIsPending(value, input) {
152
+ if (input.harnessLabelPending === true)
153
+ return true;
154
+ if (input.harness !== 'opencode' || value === undefined)
155
+ return false;
156
+ return OPENCODE_PENDING_TITLE.test(normalizeWhitespace(value));
157
+ }
158
+ function harnessCandidate(value, input) {
159
+ if (value === undefined)
160
+ return null;
161
+ const normalized = normalizeWhitespace(value);
162
+ if (normalized.length === 0 || violatesLabelPrivacy(normalized, input))
163
+ return null;
164
+ return {
165
+ label: truncate(normalized, SESSION_LABEL_MAX_LENGTH),
166
+ source: 'harness',
167
+ };
168
+ }
169
+ function pad(value) {
170
+ return String(value).padStart(2, '0');
171
+ }
172
+ /** Stable English shape using the machine's local calendar and clock. */
173
+ export function formatSessionFirstSeen(now) {
174
+ const date = new Date(now);
175
+ return `${MONTHS[date.getMonth()]} ${date.getDate()}, ${date.getFullYear()} ${pad(date.getHours())}:${pad(date.getMinutes())}`;
176
+ }
177
+ function fallbackCandidate(sessionId) {
178
+ return {
179
+ label: generatedSessionLabel(sessionId),
180
+ source: 'fallback',
181
+ };
182
+ }
183
+ function isLegacyDateFallback(record) {
184
+ if (record.source !== 'fallback')
185
+ return false;
186
+ const owner = record.harness === undefined ? 'Agent' : HARNESS_LABELS[record.harness];
187
+ return record.label.startsWith(`${owner} session · `);
188
+ }
189
+ function withSuffix(base, suffix) {
190
+ const room = SESSION_LABEL_MAX_LENGTH - suffix.length;
191
+ return `${truncate(base, room)}${suffix}`;
192
+ }
193
+ function collisionKey(value) {
194
+ return value.normalize('NFKC').toLocaleLowerCase('en-US');
195
+ }
196
+ function uniqueLabel(base, source, harness, now, used) {
197
+ if (!used.has(collisionKey(base)))
198
+ return base;
199
+ if (source === 'fallback') {
200
+ for (let ordinal = 2;; ordinal += 1) {
201
+ const candidate = withSuffix(base, ` · ${ordinal}`);
202
+ if (!used.has(collisionKey(candidate)))
203
+ return candidate;
204
+ }
205
+ }
206
+ const owner = harness === undefined ? 'Agent' : HARNESS_LABELS[harness];
207
+ const ownerCandidate = withSuffix(base, ` · ${owner}`);
208
+ if (!used.has(collisionKey(ownerCandidate)))
209
+ return ownerCandidate;
210
+ const timed = withSuffix(base, ` · ${owner} · ${formatSessionFirstSeen(now)}`);
211
+ if (!used.has(collisionKey(timed)))
212
+ return timed;
213
+ for (let ordinal = 2;; ordinal += 1) {
214
+ const candidate = withSuffix(timed, ` · ${ordinal}`);
215
+ if (!used.has(collisionKey(candidate)))
216
+ return candidate;
217
+ }
218
+ }
219
+ /**
220
+ * Freeze the first accepted human name for one immutable session.
221
+ *
222
+ * Explicit agent/User input wins, then a title supplied by a trusted harness
223
+ * adapter, then a generated fallback. Later sends reuse the frozen value
224
+ * even when their branch, worktree, title candidate, or notification changes.
225
+ */
226
+ export function resolveSessionLabel(input) {
227
+ const now = input.now ?? Date.now();
228
+ const file = storePath(input.env);
229
+ const key = sessionKey(input.sessionId);
230
+ try {
231
+ return withFileLock(`${file}.lock`, () => {
232
+ const store = readStore(file);
233
+ const existing = store.sessions[key];
234
+ if (existing !== undefined) {
235
+ if (!isLegacyDateFallback(existing)) {
236
+ return { ok: true, label: existing.label, source: existing.source };
237
+ }
238
+ const used = new Set(Object.entries(store.sessions)
239
+ .filter(([storedKey]) => storedKey !== key)
240
+ .map(([, record]) => collisionKey(record.label)));
241
+ const label = uniqueLabel(generatedSessionLabel(input.sessionId), 'fallback', input.harness ?? existing.harness, now, used);
242
+ store.sessions[key] = { ...existing, label };
243
+ writeStore(file, store);
244
+ return { ok: true, label, source: existing.source };
245
+ }
246
+ const explicit = explicitCandidate(input.explicitLabel, input);
247
+ if (explicit !== null && 'error' in explicit) {
248
+ return { ok: false, error: explicit.error };
249
+ }
250
+ const nativePending = harnessLabelIsPending(input.harnessLabel, input);
251
+ const native = nativePending ? null : harnessCandidate(input.harnessLabel, input);
252
+ if (explicit === null && nativePending) {
253
+ return {
254
+ ok: false,
255
+ error: "OpenCode is still generating this session's title; retry shortly or pass --session-label.",
256
+ };
257
+ }
258
+ const candidate = explicit ?? native ?? fallbackCandidate(input.sessionId);
259
+ const used = new Set(Object.values(store.sessions).map((record) => collisionKey(record.label)));
260
+ const label = uniqueLabel(candidate.label, candidate.source, input.harness, now, used);
261
+ store.sessions[key] = {
262
+ label,
263
+ source: candidate.source,
264
+ first_seen_at: now,
265
+ ...(input.harness === undefined ? {} : { harness: input.harness }),
266
+ };
267
+ writeStore(file, store);
268
+ return { ok: true, label, source: candidate.source };
269
+ });
270
+ }
271
+ catch (err) {
272
+ return {
273
+ ok: false,
274
+ error: `Could not persist this session's name: ${err instanceof Error ? err.message : String(err)}`,
275
+ };
276
+ }
277
+ }
278
+ //# sourceMappingURL=session-labels.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"session-labels.js","sourceRoot":"","sources":["../src/session-labels.ts"],"names":[],"mappings":"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"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@raidiant/notifai",
3
- "version": "6.0.0",
3
+ "version": "7.0.0",
4
4
  "type": "module",
5
5
  "description": "Send native phone notifications from agents and local programs",
6
6
  "bin": {
@@ -21,7 +21,7 @@
21
21
  },
22
22
  "dependencies": {
23
23
  "@clack/prompts": "^1.7.0",
24
- "@raidiant/notifai-protocol": "3.0.0",
24
+ "@raidiant/notifai-protocol": "4.0.0",
25
25
  "commander": "^14.0.0",
26
26
  "picocolors": "^1.1.1",
27
27
  "smol-toml": "^1.4.0"
@@ -57,6 +57,7 @@ export function atomicWriteFileSync(
57
57
  assertSameDirectory(directory, parent)
58
58
  assertUnchangedTarget(file, target)
59
59
  renameSync(temp, file)
60
+ syncDirectory(directory)
60
61
  } catch (err) {
61
62
  if (handle !== undefined) closeSync(handle)
62
63
  try {
@@ -68,6 +69,17 @@ export function atomicWriteFileSync(
68
69
  }
69
70
  }
70
71
 
72
+ /** Persist the published directory entry where the host supports directory fsync. */
73
+ function syncDirectory(directory: string): void {
74
+ if (process.platform === 'win32') return
75
+ const handle = openSync(directory, 'r')
76
+ try {
77
+ fsyncSync(handle)
78
+ } finally {
79
+ closeSync(handle)
80
+ }
81
+ }
82
+
71
83
  /** Mode to preserve, or the mode for a new file. Refuses a non-regular target. */
72
84
  interface TargetMetadata {
73
85
  mode: number
@@ -12,6 +12,10 @@ export interface ActiveHarnessSession {
12
12
  harness: Harness
13
13
  label: string
14
14
  sessionId?: string
15
+ /** Trusted human title published by this harness's managed adapter. */
16
+ sessionLabel?: string
17
+ /** The harness has only a temporary placeholder title so far. */
18
+ sessionLabelPending?: boolean
15
19
  }
16
20
 
17
21
  /**
@@ -26,10 +30,14 @@ function harnessEnvCandidates(env: NodeJS.ProcessEnv): ActiveHarnessSession[] {
26
30
  const candidates: ActiveHarnessSession[] = []
27
31
  if (env['NOTIFAI_ACTIVE_HARNESS'] === 'opencode') {
28
32
  const sessionId = env['NOTIFAI_ACTIVE_SESSION_ID']
33
+ const sessionLabel = env['NOTIFAI_ACTIVE_SESSION_LABEL']
34
+ const sessionLabelPending = env['NOTIFAI_ACTIVE_SESSION_LABEL_PENDING'] === '1'
29
35
  candidates.push({
30
36
  harness: 'opencode',
31
37
  label: 'OpenCode',
32
38
  ...(sessionId === undefined || sessionId === '' ? {} : { sessionId }),
39
+ ...(sessionLabel === undefined || sessionLabel === '' ? {} : { sessionLabel }),
40
+ ...(sessionLabelPending ? { sessionLabelPending: true } : {}),
33
41
  })
34
42
  }
35
43
  if (env['CLAUDECODE'] === '1') {
@@ -25,8 +25,11 @@ export function resolveDraftInvocation(
25
25
  activeHarness: {
26
26
  harness: active.harness,
27
27
  ...(active.sessionId === undefined ? {} : { sessionId: active.sessionId }),
28
+ ...(active.sessionLabel === undefined ? {} : { sessionLabel: active.sessionLabel }),
29
+ ...(active.sessionLabelPending === true ? { sessionLabelPending: true } : {}),
28
30
  },
29
31
  }),
32
+ now: (deps.now ?? Date.now)(),
30
33
  })
31
34
  if (!source.ok) return source
32
35
  return {
@@ -500,11 +500,82 @@ describe('command contracts', () => {
500
500
  session_id: 'opaque-claude-session-42',
501
501
  harness: 'claude-code',
502
502
  })
503
- expect(submitted?.draft.source?.session_label).toMatch(/^[A-Z][a-z]+ [A-Z][a-z]+$/)
503
+ expect(submitted?.draft.source?.session_label).toBe('Rapid Antelope')
504
504
  expect(io.outLines.join('\n')).not.toContain('opaque-claude-session-42')
505
505
  expect(io.errLines.join('\n')).not.toContain('opaque-claude-session-42')
506
506
  })
507
507
 
508
+ it('uses the trusted OpenCode session title published by its managed adapter', async () => {
509
+ const cwd = mkdtempSync(path.join(os.tmpdir(), 'notifai-opencode-title-'))
510
+ const io = new CapturedIo()
511
+ let submitted: SubmitNotificationRequestT | undefined
512
+ const client = {
513
+ submit: async (body: SubmitNotificationRequestT) => {
514
+ submitted = body
515
+ return receipt
516
+ },
517
+ } as unknown as ApiClient
518
+ const deps = {
519
+ ...makeDeps(io, client),
520
+ cwd,
521
+ env: {
522
+ XDG_CONFIG_HOME: path.join(cwd, 'config'),
523
+ XDG_STATE_HOME: path.join(cwd, 'state'),
524
+ NOTIFAI_ACTIVE_HARNESS: 'opencode',
525
+ NOTIFAI_ACTIVE_SESSION_ID: 'opencode-session',
526
+ NOTIFAI_ACTIVE_SESSION_LABEL: 'Semantic session names',
527
+ },
528
+ }
529
+
530
+ expect(
531
+ await sendCommand(deps, {
532
+ title: 'Resolver implemented',
533
+ body: 'The first-party title was frozen locally.',
534
+ kind: 'done',
535
+ }),
536
+ ).toBe(EXIT.ok)
537
+
538
+ expect(submitted?.draft.source).toMatchObject({
539
+ session_id: 'opencode-session',
540
+ session_label: 'Semantic session names',
541
+ harness: 'opencode',
542
+ })
543
+ })
544
+
545
+ it('does not freeze or submit an OpenCode placeholder title', async () => {
546
+ const cwd = mkdtempSync(path.join(os.tmpdir(), 'notifai-opencode-pending-'))
547
+ const io = new CapturedIo()
548
+ let submitted = false
549
+ const client = {
550
+ submit: async () => {
551
+ submitted = true
552
+ return receipt
553
+ },
554
+ } as unknown as ApiClient
555
+ const deps = {
556
+ ...makeDeps(io, client),
557
+ cwd,
558
+ env: {
559
+ XDG_CONFIG_HOME: path.join(cwd, 'config'),
560
+ XDG_STATE_HOME: path.join(cwd, 'state'),
561
+ NOTIFAI_ACTIVE_HARNESS: 'opencode',
562
+ NOTIFAI_ACTIVE_SESSION_ID: 'opencode-session',
563
+ NOTIFAI_ACTIVE_SESSION_LABEL_PENDING: '1',
564
+ },
565
+ }
566
+
567
+ expect(
568
+ await sendCommand(deps, {
569
+ title: 'Resolver implemented',
570
+ body: 'Wait for the semantic title before freezing a name.',
571
+ kind: 'done',
572
+ }),
573
+ ).toBe(EXIT.usage)
574
+ expect(submitted).toBe(false)
575
+ expect(io.errLines.join('\n')).toContain('still generating this session')
576
+ expect(existsSync(path.join(cwd, 'state', 'notifai', 'session-labels.json'))).toBe(false)
577
+ })
578
+
508
579
  it('uploads repeatable images in order and sends only canonical media references', async () => {
509
580
  const root = mkdtempSync(path.join(os.tmpdir(), 'notifai-media-order-'))
510
581
  const first = path.join(root, 'first.png')
@@ -653,7 +724,7 @@ describe('command contracts', () => {
653
724
  expect(grammar).not.toContain(".option('--session <")
654
725
  expect(grammar).toContain(".option('--body-file")
655
726
  expect(grammar).toContain(".option('--session-id")
656
- expect(grammar).toContain(".option('--session-label")
727
+ expect(grammar).toContain("'--session-label <text>'")
657
728
  expect(grammar).toContain("'--image <path|url|media_id>'")
658
729
  expect(grammar).toContain("'--image-alt <text>'")
659
730
  }
@@ -4352,7 +4423,7 @@ describe('asking before the hooks have ever run', () => {
4352
4423
  })
4353
4424
  expect(
4354
4425
  readSessionState('codex-current-thread', env).pending?.[0]?.source?.session_label,
4355
- ).toMatch(/^[A-Z][a-z]+ [A-Z][a-z]+$/)
4426
+ ).toBe('Gentle Salmon')
4356
4427
  })
4357
4428
 
4358
4429
  it('uploads ask images before registration and freezes canonical body media', async () => {
package/src/config.ts CHANGED
@@ -188,9 +188,9 @@ export function globalConfigPath(env: NodeJS.ProcessEnv = process.env): string {
188
188
  }
189
189
 
190
190
  /**
191
- * Machine-local mutable state: presence markers, pending questions, and
192
- * session config overrides. Separate from the config dir because none of it is
193
- * user-authored and none of it should ever be synced or committed.
191
+ * Machine-local mutable state: presence markers, pending questions, frozen
192
+ * session names, and session config overrides. Separate from the config dir
193
+ * because none of it should ever be synced or committed.
194
194
  */
195
195
  export function stateDir(
196
196
  env: NodeJS.ProcessEnv = process.env,
@@ -0,0 +1,155 @@
1
+ const FNV1A_64_OFFSET = 0xcbf29ce484222325n
2
+ const FNV1A_64_PRIME = 0x100000001b3n
3
+ const WORD_COUNT = 64n
4
+
5
+ /** Frozen word lists keep generated fallbacks stable for a given opaque id. */
6
+ const ADJECTIVES = [
7
+ 'Amber',
8
+ 'Arctic',
9
+ 'Ash',
10
+ 'Azure',
11
+ 'Blue',
12
+ 'Bold',
13
+ 'Bright',
14
+ 'Bronze',
15
+ 'Calm',
16
+ 'Cedar',
17
+ 'Clear',
18
+ 'Copper',
19
+ 'Coral',
20
+ 'Crisp',
21
+ 'Dawn',
22
+ 'Deep',
23
+ 'Ember',
24
+ 'Fern',
25
+ 'Frost',
26
+ 'Gentle',
27
+ 'Golden',
28
+ 'Green',
29
+ 'Harbor',
30
+ 'Hazel',
31
+ 'Indigo',
32
+ 'Ivory',
33
+ 'Jade',
34
+ 'Keen',
35
+ 'Lively',
36
+ 'Lunar',
37
+ 'Maple',
38
+ 'Meadow',
39
+ 'Mellow',
40
+ 'Misty',
41
+ 'Noble',
42
+ 'Ocean',
43
+ 'Olive',
44
+ 'Onyx',
45
+ 'Quiet',
46
+ 'Rapid',
47
+ 'Red',
48
+ 'River',
49
+ 'Royal',
50
+ 'Sage',
51
+ 'Silver',
52
+ 'Solar',
53
+ 'Steady',
54
+ 'Stone',
55
+ 'Sunny',
56
+ 'Swift',
57
+ 'Teal',
58
+ 'Timber',
59
+ 'True',
60
+ 'Velvet',
61
+ 'Verdant',
62
+ 'Violet',
63
+ 'Warm',
64
+ 'Wild',
65
+ 'Winter',
66
+ 'Wise',
67
+ 'Witty',
68
+ 'Yellow',
69
+ 'Young',
70
+ 'Zealous',
71
+ ] as const
72
+
73
+ const NOUNS = [
74
+ 'Alpaca',
75
+ 'Antelope',
76
+ 'Badger',
77
+ 'Bear',
78
+ 'Beaver',
79
+ 'Bison',
80
+ 'Bobcat',
81
+ 'Caribou',
82
+ 'Cougar',
83
+ 'Coyote',
84
+ 'Crane',
85
+ 'Deer',
86
+ 'Dolphin',
87
+ 'Dragonfly',
88
+ 'Eagle',
89
+ 'Elk',
90
+ 'Falcon',
91
+ 'Finch',
92
+ 'Firefly',
93
+ 'Fox',
94
+ 'Gazelle',
95
+ 'Gecko',
96
+ 'Hare',
97
+ 'Heron',
98
+ 'Ibex',
99
+ 'Jay',
100
+ 'Kestrel',
101
+ 'Kingfisher',
102
+ 'Koala',
103
+ 'Lark',
104
+ 'Leopard',
105
+ 'Lynx',
106
+ 'Magpie',
107
+ 'Mantis',
108
+ 'Marten',
109
+ 'Moose',
110
+ 'Osprey',
111
+ 'Otter',
112
+ 'Owl',
113
+ 'Panda',
114
+ 'Panther',
115
+ 'Pelican',
116
+ 'Penguin',
117
+ 'Puma',
118
+ 'Quail',
119
+ 'Rabbit',
120
+ 'Raven',
121
+ 'Robin',
122
+ 'Sable',
123
+ 'Salmon',
124
+ 'Seal',
125
+ 'Shark',
126
+ 'Sparrow',
127
+ 'Stag',
128
+ 'Swan',
129
+ 'Tiger',
130
+ 'Tortoise',
131
+ 'Trout',
132
+ 'Turtle',
133
+ 'Viper',
134
+ 'Whale',
135
+ 'Wolf',
136
+ 'Wren',
137
+ 'Yak',
138
+ ] as const
139
+
140
+ function fnv1a64(value: string): bigint {
141
+ let hash = FNV1A_64_OFFSET
142
+ for (const byte of new TextEncoder().encode(value)) {
143
+ hash ^= BigInt(byte)
144
+ hash = BigInt.asUintN(64, hash * FNV1A_64_PRIME)
145
+ }
146
+ return hash
147
+ }
148
+
149
+ /** Deterministic fallback for a session whose work has no semantic title. */
150
+ export function generatedSessionLabel(sessionId: string): string {
151
+ const hash = fnv1a64(sessionId)
152
+ const adjective = ADJECTIVES[Number(hash % WORD_COUNT)]
153
+ const noun = NOUNS[Number((hash >> 6n) % WORD_COUNT)]
154
+ return `${adjective} ${noun}`
155
+ }