@clinebot/core 0.0.22 → 0.0.24

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