@clinebot/core 0.0.7 → 0.0.11

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 (67) hide show
  1. package/dist/auth/cline.d.ts +2 -0
  2. package/dist/auth/codex.d.ts +5 -1
  3. package/dist/auth/oca.d.ts +7 -1
  4. package/dist/auth/types.d.ts +2 -0
  5. package/dist/index.d.ts +3 -1
  6. package/dist/index.node.js +124 -122
  7. package/dist/input/mention-enricher.d.ts +1 -0
  8. package/dist/providers/local-provider-service.d.ts +1 -1
  9. package/dist/runtime/session-runtime.d.ts +1 -1
  10. package/dist/session/default-session-manager.d.ts +13 -17
  11. package/dist/session/runtime-oauth-token-manager.d.ts +4 -2
  12. package/dist/session/session-agent-events.d.ts +15 -0
  13. package/dist/session/session-config-builder.d.ts +13 -0
  14. package/dist/session/session-manager.d.ts +2 -2
  15. package/dist/session/session-team-coordination.d.ts +12 -0
  16. package/dist/session/session-telemetry.d.ts +9 -0
  17. package/dist/session/unified-session-persistence-service.d.ts +12 -16
  18. package/dist/session/utils/helpers.d.ts +2 -2
  19. package/dist/session/utils/types.d.ts +2 -1
  20. package/dist/telemetry/core-events.d.ts +122 -0
  21. package/dist/tools/definitions.d.ts +1 -1
  22. package/dist/tools/executors/file-read.d.ts +1 -1
  23. package/dist/tools/index.d.ts +1 -1
  24. package/dist/tools/presets.d.ts +1 -1
  25. package/dist/tools/schemas.d.ts +46 -3
  26. package/dist/tools/types.d.ts +3 -3
  27. package/dist/types/config.d.ts +1 -1
  28. package/dist/types/provider-settings.d.ts +4 -4
  29. package/dist/types.d.ts +1 -1
  30. package/package.json +4 -3
  31. package/src/auth/cline.ts +35 -1
  32. package/src/auth/codex.ts +27 -2
  33. package/src/auth/oca.ts +31 -4
  34. package/src/auth/types.ts +3 -0
  35. package/src/index.ts +27 -0
  36. package/src/input/mention-enricher.test.ts +3 -0
  37. package/src/input/mention-enricher.ts +3 -0
  38. package/src/providers/local-provider-service.ts +6 -7
  39. package/src/runtime/hook-file-hooks.ts +11 -10
  40. package/src/runtime/session-runtime.ts +1 -1
  41. package/src/session/default-session-manager.e2e.test.ts +2 -1
  42. package/src/session/default-session-manager.test.ts +131 -0
  43. package/src/session/default-session-manager.ts +372 -602
  44. package/src/session/runtime-oauth-token-manager.ts +21 -14
  45. package/src/session/session-agent-events.ts +159 -0
  46. package/src/session/session-config-builder.ts +111 -0
  47. package/src/session/session-host.ts +13 -0
  48. package/src/session/session-manager.ts +2 -2
  49. package/src/session/session-team-coordination.ts +198 -0
  50. package/src/session/session-telemetry.ts +95 -0
  51. package/src/session/unified-session-persistence-service.test.ts +81 -0
  52. package/src/session/unified-session-persistence-service.ts +470 -469
  53. package/src/session/utils/helpers.ts +14 -4
  54. package/src/session/utils/types.ts +2 -1
  55. package/src/storage/provider-settings-legacy-migration.ts +3 -3
  56. package/src/telemetry/core-events.ts +344 -0
  57. package/src/tools/definitions.test.ts +121 -7
  58. package/src/tools/definitions.ts +60 -24
  59. package/src/tools/executors/file-read.test.ts +29 -5
  60. package/src/tools/executors/file-read.ts +17 -6
  61. package/src/tools/index.ts +2 -0
  62. package/src/tools/presets.ts +1 -1
  63. package/src/tools/schemas.ts +65 -5
  64. package/src/tools/types.ts +7 -3
  65. package/src/types/config.ts +1 -1
  66. package/src/types/provider-settings.ts +6 -6
  67. package/src/types.ts +1 -1
@@ -9,7 +9,7 @@ import type {
9
9
  SubAgentEndContext,
10
10
  SubAgentStartContext,
11
11
  } from "@clinebot/agents";
12
- import type { providers as LlmsProviders } from "@clinebot/llms";
12
+ import type { LlmsProviders } from "@clinebot/llms";
13
13
  import { normalizeUserInput, resolveRootSessionId } from "@clinebot/shared";
14
14
  import { nanoid } from "nanoid";
15
15
  import { z } from "zod";
@@ -32,6 +32,9 @@ import type {
32
32
  } from "./session-service";
33
33
 
34
34
  const SUBSESSION_SOURCE = "cli_subagent";
35
+ const MAX_TITLE_LENGTH = 120;
36
+ const OCC_MAX_RETRIES = 4;
37
+
35
38
  const SpawnAgentInputSchema = z
36
39
  .object({
37
40
  task: z.string().optional(),
@@ -39,67 +42,86 @@ const SpawnAgentInputSchema = z
39
42
  })
40
43
  .passthrough();
41
44
 
42
- function stringifyMetadataJson(
45
+ // ── Metadata helpers ──────────────────────────────────────────────────
46
+
47
+ function stringifyMetadata(
43
48
  metadata: Record<string, unknown> | null | undefined,
44
49
  ): string | null {
45
- if (!metadata || Object.keys(metadata).length === 0) {
46
- return null;
47
- }
50
+ if (!metadata || Object.keys(metadata).length === 0) return null;
48
51
  return JSON.stringify(metadata);
49
52
  }
50
53
 
51
- function normalizeSessionTitle(title?: string | null): string | undefined {
54
+ function parseMetadataJson(
55
+ raw: string | null | undefined,
56
+ ): Record<string, unknown> | undefined {
57
+ const trimmed = raw?.trim();
58
+ if (!trimmed) return undefined;
59
+ try {
60
+ const parsed = JSON.parse(trimmed) as unknown;
61
+ if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
62
+ return parsed as Record<string, unknown>;
63
+ }
64
+ } catch {
65
+ // Ignore malformed metadata payloads.
66
+ }
67
+ return undefined;
68
+ }
69
+
70
+ function normalizeTitle(title?: string | null): string | undefined {
52
71
  const trimmed = title?.trim();
53
- return trimmed ? trimmed.slice(0, 120) : undefined;
72
+ return trimmed ? trimmed.slice(0, MAX_TITLE_LENGTH) : undefined;
54
73
  }
55
74
 
56
- function deriveSessionTitleFromPrompt(
57
- prompt?: string | null,
58
- ): string | undefined {
59
- const normalizedPrompt = normalizeUserInput(prompt ?? "").trim();
60
- if (!normalizedPrompt) {
61
- return undefined;
62
- }
63
- const firstLine = normalizedPrompt.split("\n")[0]?.trim();
64
- return normalizeSessionTitle(firstLine);
75
+ function deriveTitleFromPrompt(prompt?: string | null): string | undefined {
76
+ const normalized = normalizeUserInput(prompt ?? "").trim();
77
+ if (!normalized) return undefined;
78
+ return normalizeTitle(normalized.split("\n")[0]?.trim());
65
79
  }
66
80
 
67
- function normalizeMetadataForStorage(
81
+ /** Strip invalid title from metadata, drop empty objects. */
82
+ function sanitizeMetadata(
68
83
  metadata: Record<string, unknown> | null | undefined,
69
84
  ): Record<string, unknown> | undefined {
70
- if (!metadata) {
71
- return undefined;
72
- }
85
+ if (!metadata) return undefined;
73
86
  const next = { ...metadata };
74
- if (typeof next.title === "string") {
75
- const normalizedTitle = normalizeSessionTitle(next.title);
76
- if (normalizedTitle) {
77
- next.title = normalizedTitle;
78
- } else {
79
- delete next.title;
80
- }
87
+ const title = normalizeTitle(
88
+ typeof next.title === "string" ? next.title : undefined,
89
+ );
90
+ if (title) {
91
+ next.title = title;
81
92
  } else {
82
93
  delete next.title;
83
94
  }
84
95
  return Object.keys(next).length > 0 ? next : undefined;
85
96
  }
86
97
 
87
- function metadataWithResolvedTitle(input: {
98
+ /** Resolve title from explicit title, prompt, or existing metadata. */
99
+ function resolveMetadataWithTitle(input: {
88
100
  metadata?: Record<string, unknown> | null;
89
101
  title?: string | null;
90
102
  prompt?: string | null;
91
103
  }): Record<string, unknown> | undefined {
92
- const next = { ...(normalizeMetadataForStorage(input.metadata) ?? {}) };
93
- const resolvedTitle =
104
+ const base = sanitizeMetadata(input.metadata) ?? {};
105
+ const title =
94
106
  input.title !== undefined
95
- ? normalizeSessionTitle(input.title)
96
- : deriveSessionTitleFromPrompt(input.prompt);
97
- if (resolvedTitle) {
98
- next.title = resolvedTitle;
99
- }
100
- return Object.keys(next).length > 0 ? next : undefined;
107
+ ? normalizeTitle(input.title)
108
+ : deriveTitleFromPrompt(input.prompt);
109
+ if (title) base.title = title;
110
+ return Object.keys(base).length > 0 ? base : undefined;
101
111
  }
102
112
 
113
+ // ── File helpers ──────────────────────────────────────────────────────
114
+
115
+ function writeEmptyMessagesFile(path: string, startedAt: string): void {
116
+ writeFileSync(
117
+ path,
118
+ `${JSON.stringify({ version: 1, updated_at: startedAt, messages: [] }, null, 2)}\n`,
119
+ "utf8",
120
+ );
121
+ }
122
+
123
+ // ── Interfaces ────────────────────────────────────────────────────────
124
+
103
125
  export interface PersistedSessionUpdateInput {
104
126
  sessionId: string;
105
127
  expectedStatusLock?: number;
@@ -141,47 +163,108 @@ export interface SessionPersistenceAdapter {
141
163
  ): Promise<string | undefined>;
142
164
  }
143
165
 
166
+ // ── Service ───────────────────────────────────────────────────────────
167
+
144
168
  export class UnifiedSessionPersistenceService {
145
169
  private readonly teamTaskSessionsByAgent = new Map<string, string[]>();
146
170
  protected readonly artifacts: SessionArtifacts;
171
+ private static readonly STALE_REASON = "failed_external_process_exit";
172
+ private static readonly STALE_SOURCE = "stale_session_reconciler";
147
173
 
148
174
  constructor(private readonly adapter: SessionPersistenceAdapter) {
149
175
  this.artifacts = new SessionArtifacts(() => this.ensureSessionsDir());
150
176
  }
151
177
 
152
- private teamTaskQueueKey(rootSessionId: string, agentId: string): string {
153
- return `${rootSessionId}::${agentId}`;
154
- }
155
-
156
178
  ensureSessionsDir(): string {
157
179
  return this.adapter.ensureSessionsDir();
158
180
  }
159
181
 
160
- private sessionTranscriptPath(sessionId: string): string {
161
- return this.artifacts.sessionTranscriptPath(sessionId);
182
+ // ── Manifest I/O ──────────────────────────────────────────────────
183
+
184
+ private writeManifestFile(
185
+ manifestPath: string,
186
+ manifest: SessionManifest,
187
+ ): void {
188
+ writeFileSync(
189
+ manifestPath,
190
+ `${JSON.stringify(SessionManifestSchema.parse(manifest), null, 2)}\n`,
191
+ "utf8",
192
+ );
162
193
  }
163
194
 
164
- private sessionHookPath(sessionId: string): string {
165
- return this.artifacts.sessionHookPath(sessionId);
195
+ writeSessionManifest(manifestPath: string, manifest: SessionManifest): void {
196
+ this.writeManifestFile(manifestPath, manifest);
166
197
  }
167
198
 
168
- private sessionMessagesPath(sessionId: string): string {
169
- return this.artifacts.sessionMessagesPath(sessionId);
199
+ private readManifestFile(sessionId: string): {
200
+ path: string;
201
+ manifest?: SessionManifest;
202
+ } {
203
+ const manifestPath = this.artifacts.sessionManifestPath(sessionId, false);
204
+ if (!existsSync(manifestPath)) return { path: manifestPath };
205
+ try {
206
+ return {
207
+ path: manifestPath,
208
+ manifest: SessionManifestSchema.parse(
209
+ JSON.parse(readFileSync(manifestPath, "utf8")) as SessionManifest,
210
+ ),
211
+ };
212
+ } catch {
213
+ return { path: manifestPath };
214
+ }
170
215
  }
171
216
 
172
- private sessionManifestPath(sessionId: string, ensureDir = true): string {
173
- return this.artifacts.sessionManifestPath(sessionId, ensureDir);
217
+ private buildManifestFromRow(
218
+ row: SessionRowShape,
219
+ overrides?: {
220
+ status?: SessionStatus;
221
+ endedAt?: string | null;
222
+ exitCode?: number | null;
223
+ metadata?: Record<string, unknown>;
224
+ },
225
+ ): SessionManifest {
226
+ return SessionManifestSchema.parse({
227
+ version: 1,
228
+ session_id: row.session_id,
229
+ source: row.source,
230
+ pid: row.pid,
231
+ started_at: row.started_at,
232
+ ended_at: overrides?.endedAt ?? row.ended_at ?? undefined,
233
+ exit_code: overrides?.exitCode ?? row.exit_code ?? undefined,
234
+ status: overrides?.status ?? row.status,
235
+ interactive: row.interactive === 1,
236
+ provider: row.provider,
237
+ model: row.model,
238
+ cwd: row.cwd,
239
+ workspace_root: row.workspace_root,
240
+ team_name: row.team_name ?? undefined,
241
+ enable_tools: row.enable_tools === 1,
242
+ enable_spawn: row.enable_spawn === 1,
243
+ enable_teams: row.enable_teams === 1,
244
+ prompt: row.prompt ?? undefined,
245
+ metadata: overrides?.metadata ?? parseMetadataJson(row.metadata_json),
246
+ messages_path: row.messages_path ?? undefined,
247
+ });
174
248
  }
175
249
 
176
- private async sessionPathFromStore(
250
+ // ── Path resolution ───────────────────────────────────────────────
251
+
252
+ private async resolveArtifactPath(
177
253
  sessionId: string,
178
254
  kind: "transcript_path" | "hook_path" | "messages_path",
179
- ): Promise<string | undefined> {
255
+ fallback: (id: string) => string,
256
+ ): Promise<string> {
180
257
  const row = await this.adapter.getSession(sessionId);
181
258
  const value = row?.[kind];
182
259
  return typeof value === "string" && value.trim().length > 0
183
260
  ? value
184
- : undefined;
261
+ : fallback(sessionId);
262
+ }
263
+
264
+ // ── Team task queue ───────────────────────────────────────────────
265
+
266
+ private teamTaskQueueKey(rootSessionId: string, agentId: string): string {
267
+ return `${rootSessionId}::${agentId}`;
185
268
  }
186
269
 
187
270
  private activeTeamTaskSessionId(
@@ -191,115 +274,27 @@ export class UnifiedSessionPersistenceService {
191
274
  const queue = this.teamTaskSessionsByAgent.get(
192
275
  this.teamTaskQueueKey(rootSessionId, parentAgentId),
193
276
  );
194
- if (!queue || queue.length === 0) {
195
- return undefined;
196
- }
197
- return queue[queue.length - 1];
198
- }
199
-
200
- private subagentArtifactPaths(
201
- rootSessionId: string,
202
- sessionId: string,
203
- parentAgentId: string,
204
- subAgentId: string,
205
- ): {
206
- transcriptPath: string;
207
- hookPath: string;
208
- messagesPath: string;
209
- } {
210
- return this.artifacts.subagentArtifactPaths(
211
- sessionId,
212
- subAgentId,
213
- this.activeTeamTaskSessionId(rootSessionId, parentAgentId),
214
- );
215
- }
216
-
217
- private writeSessionManifestFile(
218
- manifestPath: string,
219
- manifest: SessionManifest,
220
- ): void {
221
- const parsedManifest = SessionManifestSchema.parse(manifest);
222
- writeFileSync(
223
- manifestPath,
224
- `${JSON.stringify(parsedManifest, null, 2)}\n`,
225
- "utf8",
226
- );
227
- }
228
-
229
- private readSessionManifestFile(sessionId: string): {
230
- path: string;
231
- manifest?: SessionManifest;
232
- } {
233
- const manifestPath = this.sessionManifestPath(sessionId, false);
234
- if (!existsSync(manifestPath)) {
235
- return { path: manifestPath };
236
- }
237
- try {
238
- const manifest = SessionManifestSchema.parse(
239
- JSON.parse(readFileSync(manifestPath, "utf8")) as SessionManifest,
240
- );
241
- return { path: manifestPath, manifest };
242
- } catch {
243
- return { path: manifestPath };
244
- }
245
- }
246
-
247
- private applyResolvedTitleToRow(row: SessionRowShape): SessionRowShape {
248
- const existingMetadata =
249
- typeof row.metadata_json === "string" &&
250
- row.metadata_json.trim().length > 0
251
- ? (() => {
252
- try {
253
- const parsed = JSON.parse(row.metadata_json) as unknown;
254
- if (
255
- parsed &&
256
- typeof parsed === "object" &&
257
- !Array.isArray(parsed)
258
- ) {
259
- return parsed as Record<string, unknown>;
260
- }
261
- } catch {
262
- // Ignore malformed metadata payloads.
263
- }
264
- return undefined;
265
- })()
266
- : undefined;
267
- const sanitizedMetadata = normalizeMetadataForStorage(existingMetadata);
268
- const { manifest } = this.readSessionManifestFile(row.session_id);
269
- const manifestTitle = normalizeSessionTitle(
270
- typeof manifest?.metadata?.title === "string"
271
- ? (manifest.metadata.title as string)
272
- : undefined,
273
- );
274
- const resolvedMetadata = manifestTitle
275
- ? {
276
- ...(sanitizedMetadata ?? {}),
277
- title: manifestTitle,
278
- }
279
- : sanitizedMetadata;
280
- return {
281
- ...row,
282
- metadata_json: stringifyMetadataJson(resolvedMetadata),
283
- };
277
+ return queue?.at(-1);
284
278
  }
285
279
 
286
- private createRootSessionId(): string {
287
- return `${Date.now()}_${nanoid(5)}`;
288
- }
280
+ // ── Root session ──────────────────────────────────────────────────
289
281
 
290
282
  async createRootSessionWithArtifacts(
291
283
  input: CreateRootSessionWithArtifactsInput,
292
284
  ): Promise<RootSessionArtifacts> {
293
285
  const startedAt = input.startedAt ?? nowIso();
294
- const providedSessionId = input.sessionId.trim();
286
+ const providedId = input.sessionId.trim();
295
287
  const sessionId =
296
- providedSessionId.length > 0
297
- ? providedSessionId
298
- : this.createRootSessionId();
299
- const transcriptPath = this.sessionTranscriptPath(sessionId);
300
- const hookPath = this.sessionHookPath(sessionId);
301
- const messagesPath = this.sessionMessagesPath(sessionId);
302
- const manifestPath = this.sessionManifestPath(sessionId);
288
+ providedId.length > 0 ? providedId : `${Date.now()}_${nanoid(5)}`;
289
+ const transcriptPath = this.artifacts.sessionTranscriptPath(sessionId);
290
+ const hookPath = this.artifacts.sessionHookPath(sessionId);
291
+ const messagesPath = this.artifacts.sessionMessagesPath(sessionId);
292
+ const manifestPath = this.artifacts.sessionManifestPath(sessionId);
293
+
294
+ const metadata = resolveMetadataWithTitle({
295
+ metadata: input.metadata,
296
+ prompt: input.prompt,
297
+ });
303
298
  const manifest = SessionManifestSchema.parse({
304
299
  version: 1,
305
300
  session_id: sessionId,
@@ -317,13 +312,9 @@ export class UnifiedSessionPersistenceService {
317
312
  enable_spawn: input.enableSpawn,
318
313
  enable_teams: input.enableTeams,
319
314
  prompt: input.prompt?.trim() || undefined,
320
- metadata: metadataWithResolvedTitle({
321
- metadata: input.metadata,
322
- prompt: input.prompt,
323
- }),
315
+ metadata,
324
316
  messages_path: messagesPath,
325
317
  });
326
- const storedMetadata = normalizeMetadataForStorage(manifest.metadata);
327
318
 
328
319
  await this.adapter.upsertSession({
329
320
  session_id: sessionId,
@@ -349,42 +340,30 @@ export class UnifiedSessionPersistenceService {
349
340
  conversation_id: null,
350
341
  is_subagent: 0,
351
342
  prompt: manifest.prompt ?? null,
352
- metadata_json: stringifyMetadataJson(storedMetadata),
343
+ metadata_json: stringifyMetadata(sanitizeMetadata(manifest.metadata)),
353
344
  transcript_path: transcriptPath,
354
345
  hook_path: hookPath,
355
346
  messages_path: messagesPath,
356
347
  updated_at: nowIso(),
357
348
  });
358
349
 
359
- writeFileSync(
360
- messagesPath,
361
- `${JSON.stringify({ version: 1, updated_at: startedAt, messages: [] }, null, 2)}\n`,
362
- "utf8",
363
- );
364
- this.writeSessionManifestFile(manifestPath, manifest);
365
- return {
366
- manifestPath,
367
- transcriptPath,
368
- hookPath,
369
- messagesPath,
370
- manifest,
371
- };
350
+ writeEmptyMessagesFile(messagesPath, startedAt);
351
+ this.writeManifestFile(manifestPath, manifest);
352
+ return { manifestPath, transcriptPath, hookPath, messagesPath, manifest };
372
353
  }
373
354
 
374
- writeSessionManifest(manifestPath: string, manifest: SessionManifest): void {
375
- this.writeSessionManifestFile(manifestPath, manifest);
376
- }
355
+ // ── Session status updates ────────────────────────────────────────
377
356
 
378
357
  async updateSessionStatus(
379
358
  sessionId: string,
380
359
  status: SessionStatus,
381
360
  exitCode?: number | null,
382
361
  ): Promise<{ updated: boolean; endedAt?: string }> {
383
- for (let attempt = 0; attempt < 4; attempt++) {
362
+ for (let attempt = 0; attempt < OCC_MAX_RETRIES; attempt++) {
384
363
  const row = await this.adapter.getSession(sessionId);
385
- if (!row || typeof row.status_lock !== "number") {
364
+ if (!row || typeof row.status_lock !== "number")
386
365
  return { updated: false };
387
- }
366
+
388
367
  const endedAt = nowIso();
389
368
  const changed = await this.adapter.updateSession({
390
369
  sessionId,
@@ -409,192 +388,183 @@ export class UnifiedSessionPersistenceService {
409
388
  metadata?: Record<string, unknown> | null;
410
389
  title?: string | null;
411
390
  }): Promise<{ updated: boolean }> {
412
- for (let attempt = 0; attempt < 4; attempt++) {
391
+ for (let attempt = 0; attempt < OCC_MAX_RETRIES; attempt++) {
413
392
  const row = await this.adapter.getSession(input.sessionId);
414
- if (!row || typeof row.status_lock !== "number") {
393
+ if (!row || typeof row.status_lock !== "number")
415
394
  return { updated: false };
416
- }
417
- const sanitizedMetadata =
418
- input.metadata === undefined
419
- ? undefined
420
- : normalizeMetadataForStorage(input.metadata);
421
- const existingMetadata = (() => {
422
- const raw = row.metadata_json?.trim();
423
- if (!raw) {
424
- return undefined;
425
- }
426
- try {
427
- const parsed = JSON.parse(raw) as unknown;
428
- if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
429
- return normalizeMetadataForStorage(
430
- parsed as Record<string, unknown>,
431
- );
432
- }
433
- } catch {
434
- // Ignore malformed metadata payloads.
435
- }
436
- return undefined;
437
- })();
438
- const existingTitle = normalizeSessionTitle(
439
- typeof existingMetadata?.title === "string"
440
- ? (existingMetadata.title as string)
395
+
396
+ const existingMeta = parseMetadataJson(row.metadata_json);
397
+ const baseMeta =
398
+ input.metadata !== undefined
399
+ ? (sanitizeMetadata(input.metadata) ?? {})
400
+ : (sanitizeMetadata(existingMeta) ?? {});
401
+
402
+ const existingTitle = normalizeTitle(
403
+ typeof existingMeta?.title === "string"
404
+ ? (existingMeta.title as string)
441
405
  : undefined,
442
406
  );
443
407
  const nextTitle =
444
408
  input.title !== undefined
445
- ? normalizeSessionTitle(input.title)
409
+ ? normalizeTitle(input.title)
446
410
  : input.prompt !== undefined
447
- ? deriveSessionTitleFromPrompt(input.prompt)
411
+ ? deriveTitleFromPrompt(input.prompt)
448
412
  : existingTitle;
449
- const nextMetadata =
450
- input.metadata !== undefined
451
- ? { ...(sanitizedMetadata ?? {}) }
452
- : { ...(existingMetadata ?? {}) };
413
+
453
414
  if (nextTitle) {
454
- nextMetadata.title = nextTitle;
415
+ baseMeta.title = nextTitle;
455
416
  } else {
456
- delete nextMetadata.title;
417
+ delete baseMeta.title;
457
418
  }
419
+
420
+ const hasMetadataChange =
421
+ input.metadata !== undefined ||
422
+ input.prompt !== undefined ||
423
+ input.title !== undefined;
424
+
458
425
  const changed = await this.adapter.updateSession({
459
426
  sessionId: input.sessionId,
460
427
  prompt: input.prompt,
461
- metadataJson:
462
- input.metadata === undefined &&
463
- input.prompt === undefined &&
464
- input.title === undefined
465
- ? undefined
466
- : stringifyMetadataJson(nextMetadata),
428
+ metadataJson: hasMetadataChange
429
+ ? stringifyMetadata(baseMeta)
430
+ : undefined,
467
431
  title: nextTitle,
468
432
  expectedStatusLock: row.status_lock,
469
433
  });
470
- if (!changed.updated) {
471
- continue;
472
- }
473
- const { path: manifestPath, manifest } = this.readSessionManifestFile(
434
+ if (!changed.updated) continue;
435
+
436
+ const { path: manifestPath, manifest } = this.readManifestFile(
474
437
  input.sessionId,
475
438
  );
476
439
  if (manifest) {
477
440
  if (input.prompt !== undefined) {
478
441
  manifest.prompt = input.prompt ?? undefined;
479
442
  }
480
- const nextMetadata =
443
+ const manifestMeta =
481
444
  input.metadata !== undefined
482
- ? { ...(normalizeMetadataForStorage(input.metadata) ?? {}) }
483
- : { ...(normalizeMetadataForStorage(manifest.metadata) ?? {}) };
484
- if (nextTitle) {
485
- nextMetadata.title = nextTitle;
486
- }
445
+ ? (sanitizeMetadata(input.metadata) ?? {})
446
+ : (sanitizeMetadata(manifest.metadata) ?? {});
447
+ if (nextTitle) manifestMeta.title = nextTitle;
487
448
  manifest.metadata =
488
- Object.keys(nextMetadata).length > 0 ? nextMetadata : undefined;
489
- this.writeSessionManifestFile(manifestPath, manifest);
449
+ Object.keys(manifestMeta).length > 0 ? manifestMeta : undefined;
450
+ this.writeManifestFile(manifestPath, manifest);
490
451
  }
491
452
  return { updated: true };
492
453
  }
493
454
  return { updated: false };
494
455
  }
495
456
 
457
+ // ── Spawn queue ───────────────────────────────────────────────────
458
+
496
459
  async queueSpawnRequest(event: HookEventPayload): Promise<void> {
497
- if (event.hookName !== "tool_call" || event.parent_agent_id !== null) {
498
- return;
499
- }
500
- if (event.tool_call?.name !== "spawn_agent") {
460
+ if (event.hookName !== "tool_call" || event.parent_agent_id !== null)
501
461
  return;
502
- }
462
+ if (event.tool_call?.name !== "spawn_agent") return;
463
+
503
464
  const rootSessionId = resolveRootSessionId(event.sessionContext);
504
- if (!rootSessionId) {
505
- return;
506
- }
507
- const parsedInput = SpawnAgentInputSchema.safeParse(event.tool_call.input);
508
- const task = parsedInput.success ? parsedInput.data.task : undefined;
509
- const systemPrompt = parsedInput.success
510
- ? parsedInput.data.systemPrompt
511
- : undefined;
465
+ if (!rootSessionId) return;
466
+
467
+ const parsed = SpawnAgentInputSchema.safeParse(event.tool_call.input);
512
468
  await this.adapter.enqueueSpawnRequest({
513
469
  rootSessionId,
514
470
  parentAgentId: event.agent_id,
515
- task,
516
- systemPrompt,
471
+ task: parsed.success ? parsed.data.task : undefined,
472
+ systemPrompt: parsed.success ? parsed.data.systemPrompt : undefined,
517
473
  });
518
474
  }
519
475
 
520
- private async readRootSession(
521
- rootSessionId: string,
522
- ): Promise<SessionRowShape | null> {
523
- const row = await this.adapter.getSession(rootSessionId);
524
- return row ?? null;
525
- }
526
-
527
- private async claimQueuedSpawnTask(
528
- rootSessionId: string,
529
- parentAgentId: string,
530
- ): Promise<string | undefined> {
531
- return await this.adapter.claimSpawnRequest(rootSessionId, parentAgentId);
476
+ // ── Subagent sessions ─────────────────────────────────────────────
477
+
478
+ private buildSubsessionRow(
479
+ root: SessionRowShape,
480
+ opts: {
481
+ sessionId: string;
482
+ parentSessionId: string;
483
+ parentAgentId: string;
484
+ agentId: string;
485
+ conversationId?: string | null;
486
+ prompt: string;
487
+ startedAt: string;
488
+ transcriptPath: string;
489
+ hookPath: string;
490
+ messagesPath: string;
491
+ },
492
+ ): SessionRowShape {
493
+ return {
494
+ session_id: opts.sessionId,
495
+ source: SUBSESSION_SOURCE,
496
+ pid: process.ppid,
497
+ started_at: opts.startedAt,
498
+ ended_at: null,
499
+ exit_code: null,
500
+ status: "running",
501
+ status_lock: 0,
502
+ interactive: 0,
503
+ provider: root.provider,
504
+ model: root.model,
505
+ cwd: root.cwd,
506
+ workspace_root: root.workspace_root,
507
+ team_name: root.team_name ?? null,
508
+ enable_tools: root.enable_tools,
509
+ enable_spawn: root.enable_spawn,
510
+ enable_teams: root.enable_teams,
511
+ parent_session_id: opts.parentSessionId,
512
+ parent_agent_id: opts.parentAgentId,
513
+ agent_id: opts.agentId,
514
+ conversation_id: opts.conversationId ?? null,
515
+ is_subagent: 1,
516
+ prompt: opts.prompt,
517
+ metadata_json: stringifyMetadata(
518
+ resolveMetadataWithTitle({ prompt: opts.prompt }),
519
+ ),
520
+ transcript_path: opts.transcriptPath,
521
+ hook_path: opts.hookPath,
522
+ messages_path: opts.messagesPath,
523
+ updated_at: opts.startedAt,
524
+ };
532
525
  }
533
526
 
534
527
  async upsertSubagentSession(
535
528
  input: UpsertSubagentInput,
536
529
  ): Promise<string | undefined> {
537
530
  const rootSessionId = input.rootSessionId;
538
- if (!rootSessionId) {
539
- return undefined;
540
- }
541
- const root = await this.readRootSession(rootSessionId);
542
- if (!root) {
543
- return undefined;
544
- }
531
+ if (!rootSessionId) return undefined;
532
+
533
+ const root = await this.adapter.getSession(rootSessionId);
534
+ if (!root) return undefined;
535
+
545
536
  const sessionId = makeSubSessionId(rootSessionId, input.agentId);
546
537
  const existing = await this.adapter.getSession(sessionId);
547
538
  const startedAt = nowIso();
548
- const artifactPaths = this.subagentArtifactPaths(
549
- rootSessionId,
539
+ const artifactPaths = this.artifacts.subagentArtifactPaths(
550
540
  sessionId,
551
- input.parentAgentId,
552
541
  input.agentId,
542
+ this.activeTeamTaskSessionId(rootSessionId, input.parentAgentId),
553
543
  );
544
+
554
545
  let prompt = input.prompt ?? existing?.prompt ?? undefined;
555
546
  if (!prompt) {
556
547
  prompt =
557
- (await this.claimQueuedSpawnTask(rootSessionId, input.parentAgentId)) ??
558
- `Subagent run by ${input.parentAgentId}`;
548
+ (await this.adapter.claimSpawnRequest(
549
+ rootSessionId,
550
+ input.parentAgentId,
551
+ )) ?? `Subagent run by ${input.parentAgentId}`;
559
552
  }
553
+
560
554
  if (!existing) {
561
- await this.adapter.upsertSession({
562
- session_id: sessionId,
563
- source: SUBSESSION_SOURCE,
564
- pid: process.ppid,
565
- started_at: startedAt,
566
- ended_at: null,
567
- exit_code: null,
568
- status: "running",
569
- status_lock: 0,
570
- interactive: 0,
571
- provider: root.provider,
572
- model: root.model,
573
- cwd: root.cwd,
574
- workspace_root: root.workspace_root,
575
- team_name: root.team_name ?? null,
576
- enable_tools: root.enable_tools,
577
- enable_spawn: root.enable_spawn,
578
- enable_teams: root.enable_teams,
579
- parent_session_id: rootSessionId,
580
- parent_agent_id: input.parentAgentId,
581
- agent_id: input.agentId,
582
- conversation_id: input.conversationId,
583
- is_subagent: 1,
584
- prompt,
585
- metadata_json: stringifyMetadataJson(
586
- metadataWithResolvedTitle({ prompt }),
587
- ),
588
- transcript_path: artifactPaths.transcriptPath,
589
- hook_path: artifactPaths.hookPath,
590
- messages_path: artifactPaths.messagesPath,
591
- updated_at: startedAt,
592
- });
593
- writeFileSync(
594
- artifactPaths.messagesPath,
595
- `${JSON.stringify({ version: 1, updated_at: startedAt, messages: [] }, null, 2)}\n`,
596
- "utf8",
555
+ await this.adapter.upsertSession(
556
+ this.buildSubsessionRow(root, {
557
+ sessionId,
558
+ parentSessionId: rootSessionId,
559
+ parentAgentId: input.parentAgentId,
560
+ agentId: input.agentId,
561
+ conversationId: input.conversationId,
562
+ prompt,
563
+ startedAt,
564
+ ...artifactPaths,
565
+ }),
597
566
  );
567
+ writeEmptyMessagesFile(artifactPaths.messagesPath, startedAt);
598
568
  return sessionId;
599
569
  }
600
570
 
@@ -606,27 +576,9 @@ export class UnifiedSessionPersistenceService {
606
576
  agentId: input.agentId,
607
577
  conversationId: input.conversationId,
608
578
  prompt: existing.prompt ?? prompt ?? null,
609
- metadataJson: stringifyMetadataJson(
610
- metadataWithResolvedTitle({
611
- metadata: (() => {
612
- const raw = existing.metadata_json?.trim();
613
- if (!raw) {
614
- return undefined;
615
- }
616
- try {
617
- const parsed = JSON.parse(raw) as unknown;
618
- if (
619
- parsed &&
620
- typeof parsed === "object" &&
621
- !Array.isArray(parsed)
622
- ) {
623
- return parsed as Record<string, unknown>;
624
- }
625
- } catch {
626
- // Ignore malformed metadata payloads.
627
- }
628
- return undefined;
629
- })(),
579
+ metadataJson: stringifyMetadata(
580
+ resolveMetadataWithTitle({
581
+ metadata: parseMetadataJson(existing.metadata_json),
630
582
  prompt: existing.prompt ?? prompt ?? null,
631
583
  }),
632
584
  ),
@@ -638,13 +590,11 @@ export class UnifiedSessionPersistenceService {
638
590
  async upsertSubagentSessionFromHook(
639
591
  event: HookEventPayload,
640
592
  ): Promise<string | undefined> {
641
- if (!event.parent_agent_id) {
642
- return undefined;
643
- }
593
+ if (!event.parent_agent_id) return undefined;
594
+
644
595
  const rootSessionId = resolveRootSessionId(event.sessionContext);
645
- if (!rootSessionId) {
646
- return undefined;
647
- }
596
+ if (!rootSessionId) return undefined;
597
+
648
598
  if (event.hookName === "session_shutdown") {
649
599
  const sessionId = makeSubSessionId(rootSessionId, event.agent_id);
650
600
  const existing = await this.adapter.getSession(sessionId);
@@ -658,27 +608,34 @@ export class UnifiedSessionPersistenceService {
658
608
  });
659
609
  }
660
610
 
611
+ // ── Subagent audit / transcript ───────────────────────────────────
612
+
661
613
  async appendSubagentHookAudit(
662
614
  subSessionId: string,
663
615
  event: HookEventPayload,
664
616
  ): Promise<void> {
665
- const line = `${JSON.stringify({ ts: nowIso(), ...event })}\n`;
666
- const path =
667
- (await this.sessionPathFromStore(subSessionId, "hook_path")) ??
668
- this.sessionHookPath(subSessionId);
669
- appendFileSync(path, line, "utf8");
617
+ const path = await this.resolveArtifactPath(
618
+ subSessionId,
619
+ "hook_path",
620
+ (id) => this.artifacts.sessionHookPath(id),
621
+ );
622
+ appendFileSync(
623
+ path,
624
+ `${JSON.stringify({ ts: nowIso(), ...event })}\n`,
625
+ "utf8",
626
+ );
670
627
  }
671
628
 
672
629
  async appendSubagentTranscriptLine(
673
630
  subSessionId: string,
674
631
  line: string,
675
632
  ): Promise<void> {
676
- if (!line.trim()) {
677
- return;
678
- }
679
- const path =
680
- (await this.sessionPathFromStore(subSessionId, "transcript_path")) ??
681
- this.sessionTranscriptPath(subSessionId);
633
+ if (!line.trim()) return;
634
+ const path = await this.resolveArtifactPath(
635
+ subSessionId,
636
+ "transcript_path",
637
+ (id) => this.artifacts.sessionTranscriptPath(id),
638
+ );
682
639
  appendFileSync(path, `${line}\n`, "utf8");
683
640
  }
684
641
 
@@ -687,21 +644,23 @@ export class UnifiedSessionPersistenceService {
687
644
  messages: LlmsProviders.Message[],
688
645
  systemPrompt?: string,
689
646
  ): Promise<void> {
690
- const path =
691
- (await this.sessionPathFromStore(sessionId, "messages_path")) ??
692
- this.sessionMessagesPath(sessionId);
647
+ const path = await this.resolveArtifactPath(
648
+ sessionId,
649
+ "messages_path",
650
+ (id) => this.artifacts.sessionMessagesPath(id),
651
+ );
693
652
  const payload: {
694
653
  version: number;
695
654
  updated_at: string;
696
655
  systemPrompt?: string;
697
656
  messages: LlmsProviders.Message[];
698
657
  } = { version: 1, updated_at: nowIso(), messages };
699
- if (systemPrompt !== undefined && systemPrompt !== "") {
700
- payload.systemPrompt = systemPrompt;
701
- }
658
+ if (systemPrompt) payload.systemPrompt = systemPrompt;
702
659
  writeFileSync(path, `${JSON.stringify(payload, null, 2)}\n`, "utf8");
703
660
  }
704
661
 
662
+ // ── Subagent status ───────────────────────────────────────────────
663
+
705
664
  async applySubagentStatus(
706
665
  subSessionId: string,
707
666
  event: HookEventPayload,
@@ -717,17 +676,15 @@ export class UnifiedSessionPersistenceService {
717
676
  status: SessionStatus,
718
677
  ): Promise<void> {
719
678
  const row = await this.adapter.getSession(subSessionId);
720
- if (!row || typeof row.status_lock !== "number") {
721
- return;
722
- }
723
- const ts = nowIso();
724
- const endedAt = status === "running" ? null : ts;
725
- const exitCode = status === "failed" ? 1 : 0;
679
+ if (!row || typeof row.status_lock !== "number") return;
680
+
681
+ const endedAt = status === "running" ? null : nowIso();
682
+ const exitCode = status === "running" ? null : status === "failed" ? 1 : 0;
726
683
  await this.adapter.updateSession({
727
684
  sessionId: subSessionId,
728
685
  status,
729
686
  endedAt,
730
- exitCode: status === "running" ? null : exitCode,
687
+ exitCode,
731
688
  expectedStatusLock: row.status_lock,
732
689
  });
733
690
  }
@@ -736,9 +693,7 @@ export class UnifiedSessionPersistenceService {
736
693
  parentSessionId: string,
737
694
  status: Exclude<SessionStatus, "running">,
738
695
  ): Promise<void> {
739
- if (!parentSessionId) {
740
- return;
741
- }
696
+ if (!parentSessionId) return;
742
697
  const rows = await this.adapter.listSessions({
743
698
  limit: 2000,
744
699
  parentSessionId,
@@ -749,74 +704,38 @@ export class UnifiedSessionPersistenceService {
749
704
  }
750
705
  }
751
706
 
752
- private async createTeamTaskSubSession(
707
+ // ── Team tasks ────────────────────────────────────────────────────
708
+
709
+ async onTeamTaskStart(
753
710
  rootSessionId: string,
754
711
  agentId: string,
755
712
  message: string,
756
- ): Promise<string | undefined> {
757
- const root = await this.readRootSession(rootSessionId);
758
- if (!root) {
759
- return undefined;
760
- }
713
+ ): Promise<void> {
714
+ const root = await this.adapter.getSession(rootSessionId);
715
+ if (!root) return;
716
+
761
717
  const sessionId = makeTeamTaskSubSessionId(rootSessionId, agentId);
762
718
  const startedAt = nowIso();
763
- const transcriptPath = this.sessionTranscriptPath(sessionId);
764
- const hookPath = this.sessionHookPath(sessionId);
765
- const messagesPath = this.sessionMessagesPath(sessionId);
766
- await this.adapter.upsertSession({
767
- session_id: sessionId,
768
- source: SUBSESSION_SOURCE,
769
- pid: process.ppid,
770
- started_at: startedAt,
771
- ended_at: null,
772
- exit_code: null,
773
- status: "running",
774
- status_lock: 0,
775
- interactive: 0,
776
- provider: root.provider,
777
- model: root.model,
778
- cwd: root.cwd,
779
- workspace_root: root.workspace_root,
780
- team_name: root.team_name ?? null,
781
- enable_tools: root.enable_tools,
782
- enable_spawn: root.enable_spawn,
783
- enable_teams: root.enable_teams,
784
- parent_session_id: rootSessionId,
785
- parent_agent_id: "lead",
786
- agent_id: agentId,
787
- conversation_id: null,
788
- is_subagent: 1,
789
- prompt: message || `Team task for ${agentId}`,
790
- metadata_json: stringifyMetadataJson(
791
- metadataWithResolvedTitle({ prompt: message }),
792
- ),
793
- transcript_path: transcriptPath,
794
- hook_path: hookPath,
795
- messages_path: messagesPath,
796
- updated_at: startedAt,
797
- });
798
- writeFileSync(
799
- messagesPath,
800
- `${JSON.stringify({ version: 1, updated_at: startedAt, messages: [] }, null, 2)}\n`,
801
- "utf8",
719
+ const transcriptPath = this.artifacts.sessionTranscriptPath(sessionId);
720
+ const hookPath = this.artifacts.sessionHookPath(sessionId);
721
+ const messagesPath = this.artifacts.sessionMessagesPath(sessionId);
722
+
723
+ await this.adapter.upsertSession(
724
+ this.buildSubsessionRow(root, {
725
+ sessionId,
726
+ parentSessionId: rootSessionId,
727
+ parentAgentId: "lead",
728
+ agentId,
729
+ prompt: message || `Team task for ${agentId}`,
730
+ startedAt,
731
+ transcriptPath,
732
+ hookPath,
733
+ messagesPath,
734
+ }),
802
735
  );
736
+ writeEmptyMessagesFile(messagesPath, startedAt);
803
737
  await this.appendSubagentTranscriptLine(sessionId, `[start] ${message}`);
804
- return sessionId;
805
- }
806
738
 
807
- async onTeamTaskStart(
808
- rootSessionId: string,
809
- agentId: string,
810
- message: string,
811
- ): Promise<void> {
812
- const sessionId = await this.createTeamTaskSubSession(
813
- rootSessionId,
814
- agentId,
815
- message,
816
- );
817
- if (!sessionId) {
818
- return;
819
- }
820
739
  const key = this.teamTaskQueueKey(rootSessionId, agentId);
821
740
  const queue = this.teamTaskSessionsByAgent.get(key) ?? [];
822
741
  queue.push(sessionId);
@@ -832,21 +751,13 @@ export class UnifiedSessionPersistenceService {
832
751
  ): Promise<void> {
833
752
  const key = this.teamTaskQueueKey(rootSessionId, agentId);
834
753
  const queue = this.teamTaskSessionsByAgent.get(key);
835
- if (!queue || queue.length === 0) {
836
- return;
837
- }
754
+ if (!queue || queue.length === 0) return;
755
+
838
756
  const sessionId = queue.shift();
839
- if (queue.length === 0) {
840
- this.teamTaskSessionsByAgent.delete(key);
841
- } else {
842
- this.teamTaskSessionsByAgent.set(key, queue);
843
- }
844
- if (!sessionId) {
845
- return;
846
- }
847
- if (messages) {
848
- await this.persistSessionMessages(sessionId, messages);
849
- }
757
+ if (queue.length === 0) this.teamTaskSessionsByAgent.delete(key);
758
+ if (!sessionId) return;
759
+
760
+ if (messages) await this.persistSessionMessages(sessionId, messages);
850
761
  await this.appendSubagentTranscriptLine(
851
762
  sessionId,
852
763
  summary ?? `[done] ${status}`,
@@ -854,6 +765,8 @@ export class UnifiedSessionPersistenceService {
854
765
  await this.applySubagentStatusBySessionId(sessionId, status);
855
766
  }
856
767
 
768
+ // ── SubAgent lifecycle ────────────────────────────────────────────
769
+
857
770
  async handleSubAgentStart(
858
771
  rootSessionId: string,
859
772
  context: SubAgentStartContext,
@@ -865,9 +778,7 @@ export class UnifiedSessionPersistenceService {
865
778
  prompt: context.input.task,
866
779
  rootSessionId,
867
780
  });
868
- if (!subSessionId) {
869
- return;
870
- }
781
+ if (!subSessionId) return;
871
782
  await this.appendSubagentTranscriptLine(
872
783
  subSessionId,
873
784
  `[start] ${context.input.task}`,
@@ -886,9 +797,8 @@ export class UnifiedSessionPersistenceService {
886
797
  prompt: context.input.task,
887
798
  rootSessionId,
888
799
  });
889
- if (!subSessionId) {
890
- return;
891
- }
800
+ if (!subSessionId) return;
801
+
892
802
  if (context.error) {
893
803
  await this.appendSubagentTranscriptLine(
894
804
  subSessionId,
@@ -897,21 +807,18 @@ export class UnifiedSessionPersistenceService {
897
807
  await this.applySubagentStatusBySessionId(subSessionId, "failed");
898
808
  return;
899
809
  }
900
- await this.appendSubagentTranscriptLine(
810
+ const reason = context.result?.finishReason ?? "completed";
811
+ await this.appendSubagentTranscriptLine(subSessionId, `[done] ${reason}`);
812
+ await this.applySubagentStatusBySessionId(
901
813
  subSessionId,
902
- `[done] ${context.result?.finishReason ?? "completed"}`,
814
+ reason === "aborted" ? "cancelled" : "completed",
903
815
  );
904
- if (context.result?.finishReason === "aborted") {
905
- await this.applySubagentStatusBySessionId(subSessionId, "cancelled");
906
- return;
907
- }
908
- await this.applySubagentStatusBySessionId(subSessionId, "completed");
909
816
  }
910
817
 
818
+ // ── Stale session reconciliation ──────────────────────────────────
819
+
911
820
  private isPidAlive(pid: number): boolean {
912
- if (!Number.isFinite(pid) || pid <= 0) {
913
- return false;
914
- }
821
+ if (!Number.isFinite(pid) || pid <= 0) return false;
915
822
  try {
916
823
  process.kill(Math.floor(pid), 0);
917
824
  return true;
@@ -925,34 +832,125 @@ export class UnifiedSessionPersistenceService {
925
832
  }
926
833
  }
927
834
 
835
+ private async reconcileDeadRunningSession(
836
+ row: SessionRowShape,
837
+ ): Promise<SessionRowShape | undefined> {
838
+ if (row.status !== "running" || this.isPidAlive(row.pid)) return row;
839
+
840
+ const detectedAt = nowIso();
841
+ const reason = UnifiedSessionPersistenceService.STALE_REASON;
842
+
843
+ for (let attempt = 0; attempt < OCC_MAX_RETRIES; attempt++) {
844
+ const latest = await this.adapter.getSession(row.session_id);
845
+ if (!latest) return undefined;
846
+ if (latest.status !== "running") return latest;
847
+
848
+ const nextMetadata = {
849
+ ...(parseMetadataJson(latest.metadata_json) ?? {}),
850
+ terminal_marker: reason,
851
+ terminal_marker_at: detectedAt,
852
+ terminal_marker_pid: latest.pid,
853
+ terminal_marker_source: UnifiedSessionPersistenceService.STALE_SOURCE,
854
+ };
855
+
856
+ const changed = await this.adapter.updateSession({
857
+ sessionId: latest.session_id,
858
+ status: "failed",
859
+ endedAt: detectedAt,
860
+ exitCode: 1,
861
+ metadataJson: stringifyMetadata(nextMetadata),
862
+ expectedStatusLock: latest.status_lock,
863
+ });
864
+ if (!changed.updated) continue;
865
+
866
+ await this.applyStatusToRunningChildSessions(latest.session_id, "failed");
867
+
868
+ const manifest = this.buildManifestFromRow(latest, {
869
+ status: "failed",
870
+ endedAt: detectedAt,
871
+ exitCode: 1,
872
+ metadata: nextMetadata,
873
+ });
874
+ const { path: manifestPath } = this.readManifestFile(latest.session_id);
875
+ this.writeManifestFile(manifestPath, manifest);
876
+
877
+ // Write termination markers to hook + transcript files
878
+ appendFileSync(
879
+ latest.hook_path,
880
+ `${JSON.stringify({
881
+ ts: detectedAt,
882
+ hookName: "session_shutdown",
883
+ reason,
884
+ sessionId: latest.session_id,
885
+ pid: latest.pid,
886
+ source: UnifiedSessionPersistenceService.STALE_SOURCE,
887
+ })}\n`,
888
+ "utf8",
889
+ );
890
+ appendFileSync(
891
+ latest.transcript_path,
892
+ `[shutdown] ${reason} (pid=${latest.pid})\n`,
893
+ "utf8",
894
+ );
895
+
896
+ return {
897
+ ...latest,
898
+ status: "failed",
899
+ ended_at: detectedAt,
900
+ exit_code: 1,
901
+ metadata_json: stringifyMetadata(nextMetadata),
902
+ status_lock: changed.statusLock,
903
+ updated_at: detectedAt,
904
+ };
905
+ }
906
+ return await this.adapter.getSession(row.session_id);
907
+ }
908
+
909
+ // ── List / reconcile / delete ─────────────────────────────────────
910
+
928
911
  async listSessions(limit = 200): Promise<SessionRowShape[]> {
929
912
  const requestedLimit = Math.max(1, Math.floor(limit));
930
913
  const scanLimit = Math.min(requestedLimit * 5, 2000);
931
- let rows = await this.adapter.listSessions({ limit: scanLimit });
932
- const staleRunning = rows.filter(
933
- (row) => row.status === "running" && !this.isPidAlive(row.pid),
934
- );
935
- if (staleRunning.length > 0) {
936
- for (const row of staleRunning) {
937
- await this.updateSessionStatus(row.session_id, "failed", 1);
938
- }
939
- rows = await this.adapter.listSessions({ limit: scanLimit });
914
+ await this.reconcileDeadSessions(scanLimit);
915
+
916
+ const rows = await this.adapter.listSessions({ limit: scanLimit });
917
+ return rows.slice(0, requestedLimit).map((row) => {
918
+ const meta = sanitizeMetadata(parseMetadataJson(row.metadata_json));
919
+ const { manifest } = this.readManifestFile(row.session_id);
920
+ const manifestTitle = normalizeTitle(
921
+ typeof manifest?.metadata?.title === "string"
922
+ ? (manifest.metadata.title as string)
923
+ : undefined,
924
+ );
925
+ const resolved = manifestTitle
926
+ ? { ...(meta ?? {}), title: manifestTitle }
927
+ : meta;
928
+ return { ...row, metadata_json: stringifyMetadata(resolved) };
929
+ });
930
+ }
931
+
932
+ async reconcileDeadSessions(limit = 2000): Promise<number> {
933
+ const rows = await this.adapter.listSessions({
934
+ limit: Math.max(1, Math.floor(limit)),
935
+ status: "running",
936
+ });
937
+ let reconciled = 0;
938
+ for (const row of rows) {
939
+ const updated = await this.reconcileDeadRunningSession(row);
940
+ if (updated && updated.status !== row.status) reconciled++;
940
941
  }
941
- return rows
942
- .slice(0, requestedLimit)
943
- .map((row) => this.applyResolvedTitleToRow(row));
942
+ return reconciled;
944
943
  }
945
944
 
946
945
  async deleteSession(sessionId: string): Promise<{ deleted: boolean }> {
947
946
  const id = sessionId.trim();
948
- if (!id) {
949
- throw new Error("session id is required");
950
- }
947
+ if (!id) throw new Error("session id is required");
948
+
951
949
  const row = await this.adapter.getSession(id);
952
- if (!row) {
953
- return { deleted: false };
954
- }
950
+ if (!row) return { deleted: false };
951
+
955
952
  await this.adapter.deleteSession(id, false);
953
+
956
954
  if (!row.is_subagent) {
957
955
  const children = await this.adapter.listSessions({
958
956
  limit: 2000,
@@ -963,14 +961,17 @@ export class UnifiedSessionPersistenceService {
963
961
  unlinkIfExists(child.transcript_path);
964
962
  unlinkIfExists(child.hook_path);
965
963
  unlinkIfExists(child.messages_path);
966
- unlinkIfExists(this.sessionManifestPath(child.session_id, false));
964
+ unlinkIfExists(
965
+ this.artifacts.sessionManifestPath(child.session_id, false),
966
+ );
967
967
  this.artifacts.removeSessionDirIfEmpty(child.session_id);
968
968
  }
969
969
  }
970
+
970
971
  unlinkIfExists(row.transcript_path);
971
972
  unlinkIfExists(row.hook_path);
972
973
  unlinkIfExists(row.messages_path);
973
- unlinkIfExists(this.sessionManifestPath(id, false));
974
+ unlinkIfExists(this.artifacts.sessionManifestPath(id, false));
974
975
  this.artifacts.removeSessionDirIfEmpty(id);
975
976
  return { deleted: true };
976
977
  }