@vybestack/llxprt-code-core 0.10.0 → 0.11.0-nightly.260728.ffaa8d5d7

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 (168) hide show
  1. package/dist/src/config/config.d.ts +20 -0
  2. package/dist/src/config/config.js +143 -23
  3. package/dist/src/config/config.js.map +1 -1
  4. package/dist/src/config/configBaseCore.d.ts +6 -2
  5. package/dist/src/config/configBaseCore.js +8 -0
  6. package/dist/src/config/configBaseCore.js.map +1 -1
  7. package/dist/src/config/configConstructor.d.ts +2 -0
  8. package/dist/src/config/configConstructor.js +9 -1
  9. package/dist/src/config/configConstructor.js.map +1 -1
  10. package/dist/src/config/configTestHarness.d.ts +8 -6
  11. package/dist/src/config/configTestHarness.js +10 -4
  12. package/dist/src/config/configTestHarness.js.map +1 -1
  13. package/dist/src/config/configTypes.d.ts +7 -2
  14. package/dist/src/config/configTypes.js +2 -0
  15. package/dist/src/config/configTypes.js.map +1 -1
  16. package/dist/src/config/liveTrustTransitionLifecycle.d.ts +35 -0
  17. package/dist/src/config/liveTrustTransitionLifecycle.js +180 -0
  18. package/dist/src/config/liveTrustTransitionLifecycle.js.map +1 -0
  19. package/dist/src/config/schedulerSingleton.d.ts +2 -2
  20. package/dist/src/config/schedulerSingleton.js.map +1 -1
  21. package/dist/src/core/compression/types.d.ts +48 -1
  22. package/dist/src/core/compression/types.js +41 -0
  23. package/dist/src/core/compression/types.js.map +1 -1
  24. package/dist/src/core/model-limits.json +96 -0
  25. package/dist/src/core/model-limits.schema.d.ts +123 -0
  26. package/dist/src/core/model-limits.schema.js +75 -0
  27. package/dist/src/core/model-limits.schema.js.map +1 -0
  28. package/dist/src/core/tokenLimits.d.ts +6 -8
  29. package/dist/src/core/tokenLimits.js +56 -132
  30. package/dist/src/core/tokenLimits.js.map +1 -1
  31. package/dist/src/core/turn.d.ts +20 -3
  32. package/dist/src/core/turn.js +20 -3
  33. package/dist/src/core/turn.js.map +1 -1
  34. package/dist/src/hooks/hookRegistry.d.ts +1 -1
  35. package/dist/src/hooks/hookRegistry.js +3 -1
  36. package/dist/src/hooks/hookRegistry.js.map +1 -1
  37. package/dist/src/hooks/hookRunner.d.ts +6 -0
  38. package/dist/src/hooks/hookRunner.js +22 -6
  39. package/dist/src/hooks/hookRunner.js.map +1 -1
  40. package/dist/src/hooks/hookSystem.d.ts +10 -1
  41. package/dist/src/hooks/hookSystem.js +69 -12
  42. package/dist/src/hooks/hookSystem.js.map +1 -1
  43. package/dist/src/index.d.ts +1 -0
  44. package/dist/src/index.js +1 -0
  45. package/dist/src/index.js.map +1 -1
  46. package/dist/src/llm-types/geminiContent.d.ts +3 -0
  47. package/dist/src/policy/config.d.ts +4 -2
  48. package/dist/src/policy/config.js +22 -18
  49. package/dist/src/policy/config.js.map +1 -1
  50. package/dist/src/recording/ReplayEngine.js +39 -2
  51. package/dist/src/recording/ReplayEngine.js.map +1 -1
  52. package/dist/src/recording/SessionDiscovery.d.ts +8 -0
  53. package/dist/src/recording/SessionDiscovery.js +88 -3
  54. package/dist/src/recording/SessionDiscovery.js.map +1 -1
  55. package/dist/src/recording/SessionLockManager.d.ts +3 -0
  56. package/dist/src/recording/SessionLockManager.js +9 -3
  57. package/dist/src/recording/SessionLockManager.js.map +1 -1
  58. package/dist/src/recording/SessionRecordingService.d.ts +14 -1
  59. package/dist/src/recording/SessionRecordingService.js +27 -3
  60. package/dist/src/recording/SessionRecordingService.js.map +1 -1
  61. package/dist/src/recording/index.d.ts +4 -3
  62. package/dist/src/recording/index.js +4 -3
  63. package/dist/src/recording/index.js.map +1 -1
  64. package/dist/src/recording/replay-test-helpers.d.ts +5 -0
  65. package/dist/src/recording/replay-test-helpers.js +13 -0
  66. package/dist/src/recording/replay-test-helpers.js.map +1 -1
  67. package/dist/src/recording/resumeNotFoundMessages.d.ts +56 -0
  68. package/dist/src/recording/resumeNotFoundMessages.js +64 -0
  69. package/dist/src/recording/resumeNotFoundMessages.js.map +1 -0
  70. package/dist/src/recording/resumeSession.js +3 -2
  71. package/dist/src/recording/resumeSession.js.map +1 -1
  72. package/dist/src/recording/sessionManagement.d.ts +3 -0
  73. package/dist/src/recording/sessionManagement.js +38 -19
  74. package/dist/src/recording/sessionManagement.js.map +1 -1
  75. package/dist/src/recording/types.d.ts +42 -3
  76. package/dist/src/recording/types.js +1 -1
  77. package/dist/src/recording/types.js.map +1 -1
  78. package/dist/src/runtime/AgentRuntimeLoader.js +2 -1
  79. package/dist/src/runtime/AgentRuntimeLoader.js.map +1 -1
  80. package/dist/src/scheduler/liveOutput.d.ts +10 -19
  81. package/dist/src/scheduler/liveOutput.js +21 -21
  82. package/dist/src/scheduler/liveOutput.js.map +1 -1
  83. package/dist/src/scheduler/types.d.ts +2 -2
  84. package/dist/src/services/history/ContentConverters.d.ts +4 -0
  85. package/dist/src/services/history/ContentConverters.js +77 -59
  86. package/dist/src/services/history/ContentConverters.js.map +1 -1
  87. package/dist/src/services/history/HistoryService.d.ts +13 -1
  88. package/dist/src/services/history/HistoryService.js +71 -0
  89. package/dist/src/services/history/HistoryService.js.map +1 -1
  90. package/dist/src/services/history/IContent.d.ts +10 -0
  91. package/dist/src/services/history/IContent.js.map +1 -1
  92. package/dist/src/skills/skillManager.d.ts +11 -1
  93. package/dist/src/skills/skillManager.js +21 -1
  94. package/dist/src/skills/skillManager.js.map +1 -1
  95. package/dist/src/test-utils/mock-tool.d.ts +3 -3
  96. package/dist/src/test-utils/mock-tool.js.map +1 -1
  97. package/dist/src/test-utils/tools.d.ts +2 -2
  98. package/dist/src/test-utils/tools.js.map +1 -1
  99. package/dist/src/tools-adapters/CoreShellToolHostAdapter.js +1 -6
  100. package/dist/src/tools-adapters/CoreShellToolHostAdapter.js.map +1 -1
  101. package/dist/src/utils/browser-profile-discovery.d.ts +40 -0
  102. package/dist/src/utils/browser-profile-discovery.js +251 -0
  103. package/dist/src/utils/browser-profile-discovery.js.map +1 -0
  104. package/dist/src/utils/env.d.ts +12 -0
  105. package/dist/src/utils/env.js +15 -0
  106. package/dist/src/utils/env.js.map +1 -0
  107. package/dist/src/utils/events.d.ts +10 -1
  108. package/dist/src/utils/events.js +22 -0
  109. package/dist/src/utils/events.js.map +1 -1
  110. package/dist/src/utils/fileUtils.js +8 -1
  111. package/dist/src/utils/fileUtils.js.map +1 -1
  112. package/dist/src/utils/retry.js +3 -4
  113. package/dist/src/utils/retry.js.map +1 -1
  114. package/dist/src/utils/secure-browser-launcher.d.ts +35 -1
  115. package/dist/src/utils/secure-browser-launcher.js +341 -1
  116. package/dist/src/utils/secure-browser-launcher.js.map +1 -1
  117. package/dist/src/utils/terminalSerializer.d.ts +12 -0
  118. package/dist/src/utils/terminalSerializer.js.map +1 -1
  119. package/dist/src/utils/thoughtUtils.d.ts +2 -0
  120. package/dist/src/utils/thoughtUtils.js.map +1 -1
  121. package/package.json +13 -9
  122. package/src/config/config.ts +161 -22
  123. package/src/config/configBaseCore.ts +10 -2
  124. package/src/config/configConstructor.ts +11 -0
  125. package/src/config/configTestHarness.ts +10 -4
  126. package/src/config/configTypes.ts +6 -1
  127. package/src/config/liveTrustTransitionLifecycle.ts +250 -0
  128. package/src/config/schedulerSingleton.ts +2 -5
  129. package/src/core/compression/types.ts +81 -1
  130. package/src/core/legacy-model-limits.expected.txt +96 -0
  131. package/src/core/model-limits.json +96 -0
  132. package/src/core/model-limits.schema.ts +87 -0
  133. package/src/core/tokenLimits.ts +73 -147
  134. package/src/core/turn.ts +20 -3
  135. package/src/hooks/hookRegistry.ts +3 -1
  136. package/src/hooks/hookRunner.ts +24 -7
  137. package/src/hooks/hookSystem.ts +73 -13
  138. package/src/index.ts +1 -0
  139. package/src/llm-types/geminiContent.ts +3 -0
  140. package/src/policy/config.ts +32 -19
  141. package/src/recording/ReplayEngine.ts +51 -2
  142. package/src/recording/SessionDiscovery.ts +99 -4
  143. package/src/recording/SessionLockManager.ts +10 -3
  144. package/src/recording/SessionRecordingService.ts +36 -3
  145. package/src/recording/index.ts +19 -2
  146. package/src/recording/replay-test-helpers.ts +14 -0
  147. package/src/recording/resumeNotFoundMessages.ts +75 -0
  148. package/src/recording/resumeSession.ts +3 -1
  149. package/src/recording/sessionManagement.ts +51 -21
  150. package/src/recording/types.ts +44 -2
  151. package/src/runtime/AgentRuntimeLoader.ts +3 -1
  152. package/src/scheduler/liveOutput.ts +25 -22
  153. package/src/scheduler/types.ts +5 -2
  154. package/src/services/history/ContentConverters.ts +91 -68
  155. package/src/services/history/HistoryService.ts +92 -1
  156. package/src/services/history/IContent.ts +13 -0
  157. package/src/skills/skillManager.ts +26 -1
  158. package/src/test-utils/mock-tool.ts +4 -3
  159. package/src/test-utils/tools.ts +4 -3
  160. package/src/tools-adapters/CoreShellToolHostAdapter.ts +1 -7
  161. package/src/utils/browser-profile-discovery.ts +358 -0
  162. package/src/utils/env.ts +15 -0
  163. package/src/utils/events.ts +31 -0
  164. package/src/utils/fileUtils.ts +11 -1
  165. package/src/utils/retry.ts +3 -4
  166. package/src/utils/secure-browser-launcher.ts +500 -2
  167. package/src/utils/terminalSerializer.ts +9 -0
  168. package/src/utils/thoughtUtils.ts +2 -0
@@ -147,10 +147,21 @@ function handleSessionStart(
147
147
  return undefined;
148
148
  }
149
149
  const startPayload = payload as unknown as SessionStartPayload;
150
- if (!startPayload.sessionId || !startPayload.projectHash) {
150
+ const requiredStrings = [
151
+ startPayload.sessionId,
152
+ startPayload.projectHash,
153
+ startPayload.provider,
154
+ startPayload.model,
155
+ startPayload.startTime,
156
+ ];
157
+ if (
158
+ requiredStrings.some(
159
+ (value) => typeof value !== 'string' || value.length === 0,
160
+ )
161
+ ) {
151
162
  return {
152
163
  ok: false,
153
- error: 'Invalid session_start: missing required fields',
164
+ error: 'Invalid session_start: missing or malformed required fields',
154
165
  warnings: acc.warnings,
155
166
  };
156
167
  }
@@ -167,6 +178,7 @@ function handleSessionStart(
167
178
  provider: startPayload.provider,
168
179
  model: startPayload.model,
169
180
  workspaceDirs: resolveWorkspaceDirs(startPayload.workspaceDirs),
181
+ ...(typeof startPayload.cwd === 'string' ? { cwd: startPayload.cwd } : {}),
170
182
  startTime: startPayload.startTime,
171
183
  };
172
184
  return undefined;
@@ -289,6 +301,40 @@ function handleSessionEvent(
289
301
  }
290
302
  }
291
303
 
304
+ /**
305
+ * @requirement issue #1611: session_metadata — update metadata.title (tri-state).
306
+ *
307
+ * The title is tri-state: undefined (field absent) is legacy and does NOT
308
+ * modify metadata.title; null sets it to null (explicit untitled); string sets
309
+ * it to that string. Malformed title values (non-string, non-null, non-
310
+ * undefined) are recorded as malformed.
311
+ */
312
+ function handleSessionMetadata(
313
+ payload: Record<string, unknown>,
314
+ acc: ReplayAccumulators,
315
+ lineNumber: number,
316
+ ): void {
317
+ if (acc.metadata === null) {
318
+ acc.malformedCount++;
319
+ acc.warnings.push(
320
+ `Line ${lineNumber}: session_metadata before session_start, skipping`,
321
+ );
322
+ return;
323
+ }
324
+ if (!('title' in payload)) {
325
+ return;
326
+ }
327
+ const title = payload.title;
328
+ if (title === null || typeof title === 'string') {
329
+ acc.metadata.title = title;
330
+ } else if (title !== undefined) {
331
+ acc.malformedCount++;
332
+ acc.warnings.push(
333
+ `Line ${lineNumber}: malformed session_metadata title, skipping`,
334
+ );
335
+ }
336
+ }
337
+
292
338
  /** @pseudocode line 126-131: directories_changed — update metadata or record malformed. */
293
339
  function handleDirectoriesChanged(
294
340
  payload: Record<string, unknown>,
@@ -381,6 +427,9 @@ function dispatchEvent(
381
427
  case 'session_event':
382
428
  handleSessionEvent(payload, acc, lineNumber);
383
429
  break;
430
+ case 'session_metadata':
431
+ handleSessionMetadata(payload, acc, lineNumber);
432
+ break;
384
433
  case 'directories_changed':
385
434
  handleDirectoriesChanged(payload, acc, lineNumber);
386
435
  break;
@@ -24,10 +24,21 @@
24
24
  * for the resume flow.
25
25
  */
26
26
 
27
+ import { createReadStream } from 'node:fs';
27
28
  import * as fs from 'node:fs/promises';
28
29
  import * as path from 'node:path';
29
- import { type SessionSummary, type SessionStartPayload } from './types.js';
30
+ import * as readline from 'node:readline';
31
+ import {
32
+ SESSION_TITLE_MAX_LENGTH,
33
+ type SessionSummary,
34
+ type SessionStartPayload,
35
+ } from './types.js';
30
36
  import { readSessionHeader } from './ReplayEngine.js';
37
+ import {
38
+ resumeSessionIndexOutOfRangeMessage,
39
+ resumeSessionNotFoundMessage,
40
+ } from './resumeNotFoundMessages.js';
41
+ import { debugLogger } from '../utils/debugLogger.js';
31
42
 
32
43
  /**
33
44
  * Result of successfully resolving a session reference.
@@ -163,7 +174,7 @@ export class SessionDiscovery {
163
174
  return { session: sessions[indexNum - 1] };
164
175
  }
165
176
  return {
166
- error: `Session index ${ref} out of range (1-${sessions.length})`,
177
+ error: resumeSessionIndexOutOfRangeMessage(indexNum, sessions.length),
167
178
  };
168
179
  }
169
180
 
@@ -176,7 +187,7 @@ export class SessionDiscovery {
176
187
  };
177
188
  }
178
189
 
179
- return { error: `Session not found for this project: ${ref}` };
190
+ return { error: resumeSessionNotFoundMessage(ref) };
180
191
  }
181
192
 
182
193
  /**
@@ -268,7 +279,7 @@ export class SessionDiscovery {
268
279
  */
269
280
  static async readFirstUserMessage(
270
281
  filePath: string,
271
- maxLength: number = 120,
282
+ maxLength: number = SESSION_TITLE_MAX_LENGTH,
272
283
  ): Promise<string | null> {
273
284
  try {
274
285
  const fileContent = await fs.readFile(filePath, 'utf-8');
@@ -284,6 +295,44 @@ export class SessionDiscovery {
284
295
  return null;
285
296
  }
286
297
  }
298
+
299
+ /**
300
+ * Read the tri-state title from the last valid `session_metadata` event in a
301
+ * session file (issue #1611). Returns:
302
+ * - `undefined` when no `session_metadata` event exists (legacy fallback).
303
+ * - `null` when the event explicitly asserts untitled.
304
+ * - `string` when the event carries a concrete title.
305
+ */
306
+ static async readSessionMetadataTitle(
307
+ filePath: string,
308
+ ): Promise<string | null | undefined> {
309
+ let stream: ReturnType<typeof createReadStream> | undefined;
310
+ let reader: readline.Interface | undefined;
311
+ try {
312
+ stream = createReadStream(filePath, { encoding: 'utf8' });
313
+ reader = readline.createInterface({
314
+ input: stream,
315
+ crlfDelay: Infinity,
316
+ });
317
+ let title: string | null | undefined;
318
+ for await (const line of reader) {
319
+ const result = extractSessionMetadataTitle(line);
320
+ if (result !== undefined) {
321
+ title = result;
322
+ }
323
+ }
324
+ return title;
325
+ } catch (error) {
326
+ debugLogger.debug(
327
+ () =>
328
+ `readSessionMetadataTitle failed for ${filePath}: ${String(error)}`,
329
+ );
330
+ return undefined;
331
+ } finally {
332
+ reader?.close();
333
+ stream?.destroy();
334
+ }
335
+ }
287
336
  }
288
337
 
289
338
  async function readSessionSummary(
@@ -310,6 +359,10 @@ async function readSessionSummary(
310
359
  fileSize: stat.size,
311
360
  provider: header.provider,
312
361
  model: header.model,
362
+ ...(typeof header.cwd === 'string' ? { cwd: header.cwd } : {}),
363
+ ...(typeof header.startTime === 'string'
364
+ ? { createdAt: header.startTime }
365
+ : {}),
313
366
  };
314
367
  }
315
368
 
@@ -367,3 +420,45 @@ function extractUserMessageText(line: string): string | null {
367
420
 
368
421
  return text || null;
369
422
  }
423
+
424
+ /**
425
+ * Extract the title from a `session_metadata` JSONL line.
426
+ * Returns:
427
+ * - `undefined` when the line is not a valid `session_metadata` event (so
428
+ * callers can continue scanning).
429
+ * - `null` when title is explicitly null (untitled).
430
+ * - `string` when a concrete title is present.
431
+ */
432
+ function extractSessionMetadataTitle(line: string): string | null | undefined {
433
+ if (!line.trim()) {
434
+ return undefined;
435
+ }
436
+ let event: unknown;
437
+ try {
438
+ event = JSON.parse(line);
439
+ } catch {
440
+ return undefined;
441
+ }
442
+ if (event === null || typeof event !== 'object') {
443
+ return undefined;
444
+ }
445
+ const record = event as Record<string, unknown>;
446
+ if (record.type !== 'session_metadata') {
447
+ return undefined;
448
+ }
449
+ const payload = record.payload as Record<string, unknown> | undefined;
450
+ if (!payload || typeof payload !== 'object') {
451
+ return undefined;
452
+ }
453
+ if (!('title' in payload)) {
454
+ return undefined;
455
+ }
456
+ const title = payload.title;
457
+ if (title === null) {
458
+ return null;
459
+ }
460
+ if (typeof title === 'string') {
461
+ return title;
462
+ }
463
+ return undefined;
464
+ }
@@ -37,6 +37,13 @@ export interface LockHandle {
37
37
  release(): Promise<void>;
38
38
  }
39
39
 
40
+ export class SessionLockedError extends Error {
41
+ constructor() {
42
+ super('Session is in use by another process');
43
+ this.name = 'SessionLockedError';
44
+ }
45
+ }
46
+
40
47
  /** @pseudocode concurrency-lifecycle.md lines 10-134 */
41
48
  export class SessionLockManager {
42
49
  /** @pseudocode concurrency-lifecycle.md lines 12-14 */
@@ -108,7 +115,7 @@ export class SessionLockManager {
108
115
  await fs.writeFile(lockPath, lockContent, { flag: 'wx' });
109
116
  } catch (writeErr: unknown) {
110
117
  if ((writeErr as NodeJS.ErrnoException).code === 'EEXIST') {
111
- throw new Error('Session is in use by another process');
118
+ throw new SessionLockedError();
112
119
  }
113
120
  throw writeErr;
114
121
  }
@@ -121,7 +128,7 @@ export class SessionLockManager {
121
128
  ): Promise<void> {
122
129
  const isStale = await SessionLockManager.checkStale(lockPath);
123
130
  if (!isStale) {
124
- throw new Error('Session is in use by another process');
131
+ throw new SessionLockedError();
125
132
  }
126
133
  try {
127
134
  await fs.unlink(lockPath);
@@ -135,7 +142,7 @@ export class SessionLockManager {
135
142
  } catch (writeErr: unknown) {
136
143
  const code = (writeErr as NodeJS.ErrnoException).code;
137
144
  if (code === 'EEXIST') {
138
- throw new Error('Session is in use by another process');
145
+ throw new SessionLockedError();
139
146
  }
140
147
  if (code === 'ENOENT') {
141
148
  await SessionLockManager.createLockAfterMkdir(lockPath, lockContent);
@@ -35,6 +35,8 @@ import {
35
35
  type SessionRecordLine,
36
36
  } from './types.js';
37
37
 
38
+ export const SESSION_FILE_ID_PREFIX_LENGTH = 12;
39
+
38
40
  /**
39
41
  * Core service for recording session events to a JSONL file.
40
42
  *
@@ -55,6 +57,7 @@ export class SessionRecordingService {
55
57
  private readonly chatsDir: string;
56
58
  private preContentBuffer: SessionRecordLine[] = [];
57
59
  private chatsDirWatcher: FSWatcher | null = null;
60
+ private sessionTitle: string | null | undefined;
58
61
 
59
62
  /**
60
63
  * @plan PLAN-20260211-SESSIONRECORDING.P05
@@ -69,6 +72,7 @@ export class SessionRecordingService {
69
72
  sessionId: config.sessionId,
70
73
  projectHash: config.projectHash,
71
74
  workspaceDirs: config.workspaceDirs,
75
+ ...(config.cwd === undefined ? {} : { cwd: config.cwd }),
72
76
  provider: config.provider,
73
77
  model: config.model,
74
78
  startTime: new Date().toISOString(),
@@ -106,7 +110,10 @@ export class SessionRecordingService {
106
110
  enqueue(type: SessionEventType, payload: unknown): void {
107
111
  if (!this.active) return;
108
112
 
109
- if (type === 'content' && !this.materialized) {
113
+ if (
114
+ (type === 'content' || type === 'session_metadata') &&
115
+ !this.materialized
116
+ ) {
110
117
  this.materialize();
111
118
  for (const buffered of this.preContentBuffer) {
112
119
  this.queue.push(buffered);
@@ -142,7 +149,7 @@ export class SessionRecordingService {
142
149
  private materialize(): void {
143
150
  const now = new Date();
144
151
  const timestamp = now.toISOString().slice(0, 19).replace(/:/g, '-');
145
- const prefix = this.sessionId.substring(0, 12);
152
+ const prefix = this.sessionId.substring(0, SESSION_FILE_ID_PREFIX_LENGTH);
146
153
  const fileName = `session-${timestamp}-${prefix}.jsonl`;
147
154
  this.filePath = path.join(this.chatsDir, fileName);
148
155
  mkdirSync(this.chatsDir, { recursive: true });
@@ -307,11 +314,16 @@ export class SessionRecordingService {
307
314
  * @requirement REQ-REC-008
308
315
  * @pseudocode session-recording-service.md lines 174-179
309
316
  */
310
- initializeForResume(filePath: string, lastSeq: number): void {
317
+ initializeForResume(
318
+ filePath: string,
319
+ lastSeq: number,
320
+ title?: string | null,
321
+ ): void {
311
322
  this.filePath = filePath;
312
323
  this.seq = lastSeq;
313
324
  this.materialized = true;
314
325
  this.preContentBuffer = [];
326
+ this.sessionTitle = title;
315
327
  this.startChatsDirWatcher();
316
328
  }
317
329
 
@@ -468,4 +480,25 @@ export class SessionRecordingService {
468
480
  recordDirectoriesChanged(directories: string[]): void {
469
481
  this.enqueue('directories_changed', { directories });
470
482
  }
483
+
484
+ /**
485
+ * Record a session_metadata event — persisted human-readable title.
486
+ * The title is tri-state: `string` for a concrete title, `null` for explicit
487
+ * untitled, `undefined` for legacy (field absent). Like `content`, this event
488
+ * materializes the file so slash/failure sessions persist metadata even
489
+ * without a content event.
490
+ *
491
+ * @requirement REQ-REC-002
492
+ */
493
+ recordSessionMetadata(title: string | null): void {
494
+ if (!this.active) {
495
+ return;
496
+ }
497
+ this.enqueue('session_metadata', { title });
498
+ this.sessionTitle = title;
499
+ }
500
+
501
+ getSessionMetadataTitle(): string | null | undefined {
502
+ return this.sessionTitle;
503
+ }
471
504
  }
@@ -21,9 +21,16 @@
21
21
  */
22
22
 
23
23
  export * from './types.js';
24
- export { SessionRecordingService } from './SessionRecordingService.js';
24
+ export {
25
+ SessionRecordingService,
26
+ SESSION_FILE_ID_PREFIX_LENGTH,
27
+ } from './SessionRecordingService.js';
25
28
  export { replaySession, readSessionHeader } from './ReplayEngine.js';
26
- export { SessionLockManager, type LockHandle } from './SessionLockManager.js';
29
+ export {
30
+ SessionLockManager,
31
+ SessionLockedError,
32
+ type LockHandle,
33
+ } from './SessionLockManager.js';
27
34
  export { RecordingIntegration } from './RecordingIntegration.js';
28
35
  export {
29
36
  type JsonlSessionFileEntry,
@@ -43,9 +50,19 @@ export {
43
50
  type ResumeResult,
44
51
  type ResumeError,
45
52
  } from './resumeSession.js';
53
+ export {
54
+ RESUME_NO_SESSIONS_FOUND,
55
+ RESUME_SESSION_NOT_FOUND_PREFIX,
56
+ RESUME_SESSION_INDEX_OUT_OF_RANGE_PREFIX,
57
+ RESUME_SESSION_INDEX_OUT_OF_RANGE_RE,
58
+ resumeSessionNotFoundMessage,
59
+ resumeSessionIndexOutOfRangeMessage,
60
+ } from './resumeNotFoundMessages.js';
46
61
  export {
47
62
  listSessions,
48
63
  deleteSession,
64
+ deleteSessionById,
65
+ SESSION_NOT_FOUND_PREFIX,
49
66
  type ListSessionsResult,
50
67
  type DeleteSessionResult,
51
68
  type DeleteSessionError,
@@ -199,6 +199,20 @@ export function _directoriesChangedLine(
199
199
  });
200
200
  }
201
201
 
202
+ /**
203
+ * Build a valid JSONL line for a session_metadata event (issue #1611).
204
+ * The title is either a concrete string or explicit null.
205
+ */
206
+ export function sessionMetadataLine(seq: number, title: string | null): string {
207
+ return JSON.stringify({
208
+ v: 1,
209
+ seq,
210
+ ts: new Date().toISOString(),
211
+ type: 'session_metadata',
212
+ payload: { title },
213
+ });
214
+ }
215
+
202
216
  /**
203
217
  * Write raw JSONL lines to a file.
204
218
  */
@@ -0,0 +1,75 @@
1
+ /**
2
+ * Copyright 2025 Vybestack LLC
3
+ *
4
+ * Licensed under the Apache License, Version 2.0 (the "License");
5
+ * you may not use this file except in compliance with the License.
6
+ * You may obtain a copy of the License at
7
+ *
8
+ * http://www.apache.org/licenses/LICENSE-2.0
9
+ *
10
+ * Unless required by applicable law or agreed to in writing, software
11
+ * distributed under the License is distributed on an "AS IS" BASIS,
12
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13
+ * See the License for the specific language governing permissions and
14
+ * limitations under the License.
15
+ */
16
+
17
+ /**
18
+ * The "session not found"-style messages the resume/discovery flow emits, as
19
+ * shared constants so downstream classifiers (e.g. the Zed ACP loadSession
20
+ * error mapper) can match them WITHOUT string-literal coupling that would
21
+ * silently break if the wording changed. The emitting sites
22
+ * (resumeSession.ts, SessionDiscovery.ts) and every matcher must reference
23
+ * these — never inline copies of the sentences.
24
+ */
25
+
26
+ /** Emitted by resumeSession when the project has no recorded sessions at all. */
27
+ export const RESUME_NO_SESSIONS_FOUND = 'No sessions found for this project';
28
+
29
+ /**
30
+ * Prefix (including the trailing colon) of the
31
+ * SessionDiscovery.resolveSessionRef message for an unknown session reference;
32
+ * the full message appends ` <ref>` (space + ref) after it — see
33
+ * {@link resumeSessionNotFoundMessage}.
34
+ */
35
+ export const RESUME_SESSION_NOT_FOUND_PREFIX =
36
+ 'Session not found for this project:';
37
+
38
+ /**
39
+ * Builds the full SessionDiscovery.resolveSessionRef not-found message for a
40
+ * specific reference.
41
+ */
42
+ export function resumeSessionNotFoundMessage(ref: string): string {
43
+ return `${RESUME_SESSION_NOT_FOUND_PREFIX} ${ref}`;
44
+ }
45
+
46
+ /**
47
+ * Prefix of the SessionDiscovery.resolveSessionRef message for a numeric
48
+ * session index outside the listing range; the full message is
49
+ * `Session index <n> out of range (1-<m>)`.
50
+ */
51
+ export const RESUME_SESSION_INDEX_OUT_OF_RANGE_PREFIX = 'Session index ';
52
+
53
+ /**
54
+ * Builds the full SessionDiscovery.resolveSessionRef out-of-range message for
55
+ * a specific index and session count. `ref` must render as digits (a number,
56
+ * or the digit-only string the caller already validated with /^\d+$/) so the
57
+ * produced message stays matchable by
58
+ * {@link RESUME_SESSION_INDEX_OUT_OF_RANGE_RE}.
59
+ */
60
+ export function resumeSessionIndexOutOfRangeMessage(
61
+ ref: number | `${number}`,
62
+ count: number,
63
+ ): string {
64
+ if (count <= 0) {
65
+ return RESUME_NO_SESSIONS_FOUND;
66
+ }
67
+ return `${RESUME_SESSION_INDEX_OUT_OF_RANGE_PREFIX}${ref} out of range (1-${count})`;
68
+ }
69
+
70
+ /**
71
+ * Matches the out-of-range message shape for classifiers that only have the
72
+ * final (envelope-wrapped) detail string to inspect.
73
+ */
74
+ export const RESUME_SESSION_INDEX_OUT_OF_RANGE_RE =
75
+ /^(?:Failed to resume session: )?Session index \d+ out of range \(1-\d+\)$/;
@@ -30,6 +30,7 @@ import { SessionRecordingService } from './SessionRecordingService.js';
30
30
  import { SessionDiscovery } from './SessionDiscovery.js';
31
31
  import { SessionLockManager, type LockHandle } from './SessionLockManager.js';
32
32
  import { replaySession } from './ReplayEngine.js';
33
+ import { RESUME_NO_SESSIONS_FOUND } from './resumeNotFoundMessages.js';
33
34
 
34
35
  /**
35
36
  * Sentinel constant for "resume most recent session".
@@ -138,6 +139,7 @@ function initializeRecordingForResume(
138
139
  recording.initializeForResume(
139
140
  lockedSession.targetFilePath,
140
141
  replayResult.lastSeq,
142
+ replayResult.metadata.title,
141
143
  );
142
144
 
143
145
  if (
@@ -180,7 +182,7 @@ export async function resumeSession(
180
182
  );
181
183
 
182
184
  if (sessions.length === 0) {
183
- return { ok: false, error: 'No sessions found for this project' };
185
+ return { ok: false, error: RESUME_NO_SESSIONS_FOUND };
184
186
  }
185
187
 
186
188
  // Step 2: Resolve which session to resume
@@ -25,7 +25,12 @@
25
25
 
26
26
  import { type SessionSummary } from './types.js';
27
27
  import { SessionDiscovery } from './SessionDiscovery.js';
28
- import { SessionLockManager } from './SessionLockManager.js';
28
+ import {
29
+ SessionLockManager,
30
+ SessionLockedError,
31
+ type LockHandle,
32
+ } from './SessionLockManager.js';
33
+ import { RESUME_NO_SESSIONS_FOUND } from './resumeNotFoundMessages.js';
29
34
 
30
35
  /**
31
36
  * Result of listing sessions for a project.
@@ -77,7 +82,7 @@ export async function deleteSession(
77
82
  const sessions = await SessionDiscovery.listSessions(chatsDir, projectHash);
78
83
 
79
84
  if (sessions.length === 0) {
80
- return { ok: false, error: 'No sessions found for this project' };
85
+ return { ok: false, error: RESUME_NO_SESSIONS_FOUND };
81
86
  }
82
87
 
83
88
  const resolved = SessionDiscovery.resolveSessionRef(ref, sessions);
@@ -85,38 +90,63 @@ export async function deleteSession(
85
90
  return { ok: false, error: resolved.error };
86
91
  }
87
92
 
88
- const target = resolved.session;
93
+ return deleteResolvedSession(resolved.session, chatsDir);
94
+ }
95
+
96
+ export const SESSION_NOT_FOUND_PREFIX = 'Session not found:';
97
+
98
+ /** Delete a session only when the supplied identifier is an exact match. */
99
+ export async function deleteSessionById(
100
+ sessionId: string,
101
+ chatsDir: string,
102
+ projectHash: string,
103
+ ): Promise<DeleteSessionResult | DeleteSessionError> {
104
+ const sessions = await SessionDiscovery.listSessions(chatsDir, projectHash);
105
+ const target = sessions.find((session) => session.sessionId === sessionId);
106
+ if (target === undefined) {
107
+ return {
108
+ ok: false,
109
+ error: `${SESSION_NOT_FOUND_PREFIX} ${sessionId}`,
110
+ };
111
+ }
112
+
113
+ return deleteResolvedSession(target, chatsDir);
114
+ }
89
115
 
90
- const locked = await SessionLockManager.isLocked(chatsDir, target.sessionId);
91
- if (locked) {
92
- const stale = await SessionLockManager.isStale(chatsDir, target.sessionId);
93
- if (stale) {
94
- await SessionLockManager.removeStaleLock(chatsDir, target.sessionId);
95
- } else {
116
+ async function deleteResolvedSession(
117
+ target: SessionSummary,
118
+ chatsDir: string,
119
+ ): Promise<DeleteSessionResult | DeleteSessionError> {
120
+ let lock: LockHandle;
121
+ try {
122
+ lock = await SessionLockManager.acquire(chatsDir, target.sessionId);
123
+ } catch (error: unknown) {
124
+ if (error instanceof SessionLockedError) {
96
125
  return {
97
126
  ok: false,
98
127
  error: 'Cannot delete: session is in use by another process',
99
128
  };
100
129
  }
130
+ const detail = error instanceof Error ? error.message : String(error);
131
+ return {
132
+ ok: false,
133
+ error: `Failed to lock session for deletion: ${detail}`,
134
+ };
101
135
  }
102
136
 
103
137
  const { unlink } = await import('node:fs/promises');
104
-
138
+ let result: DeleteSessionResult | DeleteSessionError;
105
139
  try {
106
140
  await unlink(target.filePath);
141
+ result = { ok: true, deletedSessionId: target.sessionId };
107
142
  } catch (error: unknown) {
108
- return {
143
+ const detail = error instanceof Error ? error.message : String(error);
144
+ result = {
109
145
  ok: false,
110
- error: `Failed to delete session: ${(error as Error).message}`,
146
+ error: `Failed to delete session: ${detail}`,
111
147
  };
148
+ } finally {
149
+ await lock.release().catch(() => undefined);
112
150
  }
113
-
114
- const lockPath = SessionLockManager.getLockPath(chatsDir, target.sessionId);
115
- try {
116
- await unlink(lockPath);
117
- } catch {
118
- // Lock file may not exist
119
- }
120
-
121
- return { ok: true, deletedSessionId: target.sessionId };
151
+ return result;
122
152
  }