@clinebot/core 0.0.14 → 0.0.16

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 (33) hide show
  1. package/dist/account/cline-account-service.d.ts +2 -1
  2. package/dist/account/index.d.ts +1 -1
  3. package/dist/account/types.d.ts +5 -0
  4. package/dist/index.node.d.ts +2 -0
  5. package/dist/index.node.js +178 -174
  6. package/dist/session/default-session-manager.d.ts +2 -1
  7. package/dist/session/file-session-service.d.ts +5 -0
  8. package/dist/session/session-host.d.ts +2 -1
  9. package/dist/storage/file-team-store.d.ts +27 -0
  10. package/dist/storage/sqlite-session-store.d.ts +1 -0
  11. package/dist/storage/team-store.d.ts +13 -0
  12. package/package.json +4 -4
  13. package/src/account/cline-account-service.ts +7 -0
  14. package/src/account/index.ts +1 -0
  15. package/src/account/types.ts +6 -0
  16. package/src/agents/agent-config-loader.test.ts +3 -3
  17. package/src/agents/hooks-config-loader.test.ts +20 -0
  18. package/src/agents/hooks-config-loader.ts +1 -0
  19. package/src/agents/user-instruction-config-loader.test.ts +6 -6
  20. package/src/index.node.ts +39 -0
  21. package/src/runtime/hook-file-hooks.test.ts +34 -0
  22. package/src/runtime/hook-file-hooks.ts +34 -4
  23. package/src/runtime/runtime-builder.team-persistence.test.ts +4 -5
  24. package/src/runtime/runtime-builder.ts +2 -3
  25. package/src/session/default-session-manager.ts +5 -1
  26. package/src/session/file-session-service.ts +280 -0
  27. package/src/session/session-host.test.ts +29 -0
  28. package/src/session/session-host.ts +17 -3
  29. package/src/session/session-team-coordination.ts +6 -5
  30. package/src/session/unified-session-persistence-service.test.ts +7 -3
  31. package/src/storage/file-team-store.ts +257 -0
  32. package/src/storage/sqlite-session-store.ts +5 -0
  33. package/src/storage/team-store.ts +35 -0
@@ -0,0 +1,280 @@
1
+ import {
2
+ existsSync,
3
+ mkdirSync,
4
+ readFileSync,
5
+ renameSync,
6
+ writeFileSync,
7
+ } from "node:fs";
8
+ import { join } from "node:path";
9
+ import { resolveSessionDataDir } from "@clinebot/shared/storage";
10
+ import type { SessionRow } from "./session-service";
11
+ import type {
12
+ PersistedSessionUpdateInput,
13
+ SessionPersistenceAdapter,
14
+ } from "./unified-session-persistence-service";
15
+ import { UnifiedSessionPersistenceService } from "./unified-session-persistence-service";
16
+
17
+ interface FileSessionIndex {
18
+ version: 1;
19
+ sessions: Record<string, SessionRow>;
20
+ }
21
+
22
+ interface FileSpawnRequest {
23
+ id: number;
24
+ rootSessionId: string;
25
+ parentAgentId: string;
26
+ task?: string;
27
+ systemPrompt?: string;
28
+ createdAt: string;
29
+ consumedAt?: string;
30
+ }
31
+
32
+ interface FileSpawnQueue {
33
+ version: 1;
34
+ nextId: number;
35
+ requests: FileSpawnRequest[];
36
+ }
37
+
38
+ function nowIso(): string {
39
+ return new Date().toISOString();
40
+ }
41
+
42
+ function atomicWriteJson(path: string, value: unknown): void {
43
+ const tempPath = `${path}.tmp`;
44
+ writeFileSync(tempPath, `${JSON.stringify(value, null, 2)}\n`, "utf8");
45
+ renameSync(tempPath, path);
46
+ }
47
+
48
+ function defaultSessionsDir(): string {
49
+ return resolveSessionDataDir();
50
+ }
51
+
52
+ class FileSessionPersistenceAdapter implements SessionPersistenceAdapter {
53
+ constructor(
54
+ private readonly sessionsDirPath: string = defaultSessionsDir(),
55
+ ) {}
56
+
57
+ ensureSessionsDir(): string {
58
+ if (!existsSync(this.sessionsDirPath)) {
59
+ mkdirSync(this.sessionsDirPath, { recursive: true });
60
+ }
61
+ return this.sessionsDirPath;
62
+ }
63
+
64
+ private indexPath(): string {
65
+ return join(this.ensureSessionsDir(), "sessions.index.json");
66
+ }
67
+
68
+ private spawnQueuePath(): string {
69
+ return join(this.ensureSessionsDir(), "subagent-spawn-queue.json");
70
+ }
71
+
72
+ private readIndex(): FileSessionIndex {
73
+ const path = this.indexPath();
74
+ if (!existsSync(path)) {
75
+ return { version: 1, sessions: {} };
76
+ }
77
+ try {
78
+ const parsed = JSON.parse(readFileSync(path, "utf8")) as FileSessionIndex;
79
+ if (parsed?.version === 1 && parsed.sessions) {
80
+ return parsed;
81
+ }
82
+ } catch {
83
+ // Ignore invalid persistence and fall back to an empty index.
84
+ }
85
+ return { version: 1, sessions: {} };
86
+ }
87
+
88
+ private writeIndex(index: FileSessionIndex): void {
89
+ atomicWriteJson(this.indexPath(), index);
90
+ }
91
+
92
+ private readQueue(): FileSpawnQueue {
93
+ const path = this.spawnQueuePath();
94
+ if (!existsSync(path)) {
95
+ return { version: 1, nextId: 1, requests: [] };
96
+ }
97
+ try {
98
+ const parsed = JSON.parse(readFileSync(path, "utf8")) as FileSpawnQueue;
99
+ if (
100
+ parsed?.version === 1 &&
101
+ typeof parsed.nextId === "number" &&
102
+ Array.isArray(parsed.requests)
103
+ ) {
104
+ return parsed;
105
+ }
106
+ } catch {
107
+ // Ignore invalid persistence and fall back to an empty queue.
108
+ }
109
+ return { version: 1, nextId: 1, requests: [] };
110
+ }
111
+
112
+ private writeQueue(queue: FileSpawnQueue): void {
113
+ atomicWriteJson(this.spawnQueuePath(), queue);
114
+ }
115
+
116
+ async upsertSession(row: SessionRow): Promise<void> {
117
+ const index = this.readIndex();
118
+ index.sessions[row.sessionId] = row;
119
+ this.writeIndex(index);
120
+ }
121
+
122
+ async getSession(sessionId: string): Promise<SessionRow | undefined> {
123
+ return this.readIndex().sessions[sessionId];
124
+ }
125
+
126
+ async listSessions(options: {
127
+ limit: number;
128
+ parentSessionId?: string;
129
+ status?: string;
130
+ }): Promise<SessionRow[]> {
131
+ return Object.values(this.readIndex().sessions)
132
+ .filter((row) =>
133
+ options.parentSessionId !== undefined
134
+ ? row.parentSessionId === options.parentSessionId
135
+ : true,
136
+ )
137
+ .filter((row) =>
138
+ options.status !== undefined ? row.status === options.status : true,
139
+ )
140
+ .sort((a, b) => b.startedAt.localeCompare(a.startedAt))
141
+ .slice(0, options.limit);
142
+ }
143
+
144
+ async updateSession(
145
+ input: PersistedSessionUpdateInput,
146
+ ): Promise<{ updated: boolean; statusLock: number }> {
147
+ const index = this.readIndex();
148
+ const existing = index.sessions[input.sessionId];
149
+ if (!existing) {
150
+ return { updated: false, statusLock: 0 };
151
+ }
152
+ if (
153
+ input.expectedStatusLock !== undefined &&
154
+ existing.statusLock !== input.expectedStatusLock
155
+ ) {
156
+ return { updated: false, statusLock: existing.statusLock };
157
+ }
158
+
159
+ const nextStatusLock =
160
+ input.expectedStatusLock !== undefined
161
+ ? input.expectedStatusLock + 1
162
+ : existing.statusLock;
163
+ const next: SessionRow = {
164
+ ...existing,
165
+ status: input.status ?? existing.status,
166
+ endedAt:
167
+ input.endedAt !== undefined
168
+ ? input.endedAt
169
+ : (existing.endedAt ?? null),
170
+ exitCode:
171
+ input.exitCode !== undefined
172
+ ? input.exitCode
173
+ : (existing.exitCode ?? null),
174
+ prompt:
175
+ input.prompt !== undefined ? input.prompt : (existing.prompt ?? null),
176
+ metadata:
177
+ input.metadata !== undefined
178
+ ? (input.metadata ?? null)
179
+ : (existing.metadata ?? null),
180
+ parentSessionId:
181
+ input.parentSessionId !== undefined
182
+ ? (input.parentSessionId ?? null)
183
+ : (existing.parentSessionId ?? null),
184
+ parentAgentId:
185
+ input.parentAgentId !== undefined
186
+ ? (input.parentAgentId ?? null)
187
+ : (existing.parentAgentId ?? null),
188
+ agentId:
189
+ input.agentId !== undefined
190
+ ? (input.agentId ?? null)
191
+ : (existing.agentId ?? null),
192
+ conversationId:
193
+ input.conversationId !== undefined
194
+ ? (input.conversationId ?? null)
195
+ : (existing.conversationId ?? null),
196
+ statusLock: nextStatusLock,
197
+ isSubagent:
198
+ input.setRunning || input.parentSessionId !== undefined
199
+ ? true
200
+ : existing.isSubagent,
201
+ updatedAt: nowIso(),
202
+ };
203
+
204
+ if (input.setRunning) {
205
+ next.status = "running";
206
+ next.endedAt = null;
207
+ next.exitCode = null;
208
+ }
209
+
210
+ index.sessions[input.sessionId] = next;
211
+ this.writeIndex(index);
212
+ return { updated: true, statusLock: next.statusLock };
213
+ }
214
+
215
+ async deleteSession(sessionId: string, cascade: boolean): Promise<boolean> {
216
+ const index = this.readIndex();
217
+ const existing = index.sessions[sessionId];
218
+ if (!existing) {
219
+ return false;
220
+ }
221
+ delete index.sessions[sessionId];
222
+ if (cascade) {
223
+ for (const row of Object.values(index.sessions)) {
224
+ if (row.parentSessionId === sessionId) {
225
+ delete index.sessions[row.sessionId];
226
+ }
227
+ }
228
+ }
229
+ this.writeIndex(index);
230
+ return true;
231
+ }
232
+
233
+ async enqueueSpawnRequest(input: {
234
+ rootSessionId: string;
235
+ parentAgentId: string;
236
+ task?: string;
237
+ systemPrompt?: string;
238
+ }): Promise<void> {
239
+ const queue = this.readQueue();
240
+ queue.requests.push({
241
+ id: queue.nextId,
242
+ rootSessionId: input.rootSessionId,
243
+ parentAgentId: input.parentAgentId,
244
+ task: input.task,
245
+ systemPrompt: input.systemPrompt,
246
+ createdAt: nowIso(),
247
+ });
248
+ queue.nextId += 1;
249
+ this.writeQueue(queue);
250
+ }
251
+
252
+ async claimSpawnRequest(
253
+ rootSessionId: string,
254
+ parentAgentId: string,
255
+ ): Promise<string | undefined> {
256
+ const queue = this.readQueue();
257
+ const request = queue.requests.find(
258
+ (item) =>
259
+ item.rootSessionId === rootSessionId &&
260
+ item.parentAgentId === parentAgentId &&
261
+ !item.consumedAt,
262
+ );
263
+ if (!request) {
264
+ return undefined;
265
+ }
266
+ request.consumedAt = nowIso();
267
+ this.writeQueue(queue);
268
+ return request.task;
269
+ }
270
+ }
271
+
272
+ export class FileSessionService extends UnifiedSessionPersistenceService {
273
+ constructor(sessionsDir?: string) {
274
+ super(new FileSessionPersistenceAdapter(sessionsDir));
275
+ }
276
+
277
+ override ensureSessionsDir(): string {
278
+ return super.ensureSessionsDir();
279
+ }
280
+ }
@@ -0,0 +1,29 @@
1
+ import { afterEach, describe, expect, it, vi } from "vitest";
2
+
3
+ vi.mock("@clinebot/rpc", () => ({
4
+ getRpcServerDefaultAddress: () => "ws://127.0.0.1:0",
5
+ getRpcServerHealth: vi.fn(async () => undefined),
6
+ }));
7
+
8
+ describe("resolveSessionBackend", () => {
9
+ afterEach(() => {
10
+ vi.resetModules();
11
+ vi.restoreAllMocks();
12
+ });
13
+
14
+ it("falls back to file session storage when sqlite initialization fails", async () => {
15
+ vi.doMock("../storage/sqlite-session-store", () => ({
16
+ SqliteSessionStore: class {
17
+ init(): void {
18
+ throw new Error("sqlite unavailable");
19
+ }
20
+ },
21
+ }));
22
+
23
+ const { resolveSessionBackend } = await import("./session-host");
24
+ const { FileSessionService } = await import("./file-session-service");
25
+
26
+ const backend = await resolveSessionBackend({ backendMode: "local" });
27
+ expect(backend).toBeInstanceOf(FileSessionService);
28
+ });
29
+ });
@@ -13,6 +13,7 @@ import { nanoid } from "nanoid";
13
13
  import { SqliteSessionStore } from "../storage/sqlite-session-store";
14
14
  import type { ToolExecutors } from "../tools";
15
15
  import { DefaultSessionManager } from "./default-session-manager";
16
+ import { FileSessionService } from "./file-session-service";
16
17
  import { RpcCoreSessionService } from "./rpc-session-service";
17
18
  import { tryAcquireRpcSpawnLease } from "./rpc-spawn-lease";
18
19
  import type { SessionManager } from "./session-manager";
@@ -21,7 +22,10 @@ import { CoreSessionService } from "./session-service";
21
22
  const DEFAULT_RPC_ADDRESS =
22
23
  process.env.CLINE_RPC_ADDRESS?.trim() || getRpcServerDefaultAddress();
23
24
 
24
- export type SessionBackend = RpcCoreSessionService | CoreSessionService;
25
+ export type SessionBackend =
26
+ | RpcCoreSessionService
27
+ | CoreSessionService
28
+ | FileSessionService;
25
29
 
26
30
  let cachedBackend: SessionBackend | undefined;
27
31
  let backendInitPromise: Promise<SessionBackend> | undefined;
@@ -111,8 +115,18 @@ async function tryConnectRpcBackend(
111
115
  }
112
116
  }
113
117
 
114
- function createLocalBackend(): CoreSessionService {
115
- return new CoreSessionService(new SqliteSessionStore());
118
+ function createLocalBackend(): SessionBackend {
119
+ try {
120
+ const store = new SqliteSessionStore();
121
+ store.init();
122
+ return new CoreSessionService(store);
123
+ } catch (error) {
124
+ console.warn(
125
+ "SQLite session persistence unavailable, falling back to file-based session storage.",
126
+ error,
127
+ );
128
+ return new FileSessionService();
129
+ }
116
130
  }
117
131
 
118
132
  function resolveHostDistinctId(explicitDistinctId: string | undefined): string {
@@ -163,11 +163,12 @@ export function shouldAutoContinueTeamRuns(
163
163
  session: ActiveSession,
164
164
  finishReason: AgentResult["finishReason"],
165
165
  ): boolean {
166
- if (
167
- session.aborting ||
168
- finishReason === "aborted" ||
169
- finishReason === "error"
170
- ) {
166
+ if (session.aborting) {
167
+ return false;
168
+ }
169
+ const canAutoContinue =
170
+ finishReason === "completed" || finishReason === "max_iterations";
171
+ if (!canAutoContinue) {
171
172
  return false;
172
173
  }
173
174
  return (
@@ -8,8 +8,12 @@ import { CoreSessionService } from "./session-service";
8
8
 
9
9
  describe("UnifiedSessionPersistenceService", () => {
10
10
  const tempDirs: string[] = [];
11
+ const stores: Array<SqliteSessionStore> = [];
11
12
 
12
13
  afterEach(() => {
14
+ for (const store of stores.splice(0)) {
15
+ store.close();
16
+ }
13
17
  for (const dir of tempDirs.splice(0)) {
14
18
  rmSync(dir, { recursive: true, force: true });
15
19
  }
@@ -19,9 +23,9 @@ describe("UnifiedSessionPersistenceService", () => {
19
23
  const sessionsDir = mkdtempSync(join(tmpdir(), "stale-session-reconcile-"));
20
24
  tempDirs.push(sessionsDir);
21
25
 
22
- const service = new CoreSessionService(
23
- new SqliteSessionStore({ sessionsDir }),
24
- );
26
+ const store = new SqliteSessionStore({ sessionsDir });
27
+ stores.push(store);
28
+ const service = new CoreSessionService(store);
25
29
  const sessionId = "stale-root-session";
26
30
  const artifacts = await service.createRootSessionWithArtifacts({
27
31
  sessionId,
@@ -0,0 +1,257 @@
1
+ import {
2
+ appendFileSync,
3
+ existsSync,
4
+ mkdirSync,
5
+ readdirSync,
6
+ readFileSync,
7
+ renameSync,
8
+ writeFileSync,
9
+ } from "node:fs";
10
+ import { join } from "node:path";
11
+ import type {
12
+ TeamEvent,
13
+ TeamRuntimeState,
14
+ TeamTeammateSpec,
15
+ } from "@clinebot/agents";
16
+ import { resolveTeamDataDir } from "@clinebot/shared/storage";
17
+ import type { TeamStore } from "../types/storage";
18
+
19
+ function nowIso(): string {
20
+ return new Date().toISOString();
21
+ }
22
+
23
+ function sanitizeTeamName(name: string): string {
24
+ return name
25
+ .toLowerCase()
26
+ .replace(/[^a-z0-9._-]+/g, "-")
27
+ .replace(/^-+|-+$/g, "");
28
+ }
29
+
30
+ function reviveTeamRuntimeStateDates(
31
+ state: TeamRuntimeState,
32
+ ): TeamRuntimeState {
33
+ return {
34
+ ...state,
35
+ tasks: state.tasks.map((task) => ({
36
+ ...task,
37
+ createdAt: new Date(task.createdAt),
38
+ updatedAt: new Date(task.updatedAt),
39
+ })),
40
+ mailbox: state.mailbox.map((message) => ({
41
+ ...message,
42
+ sentAt: new Date(message.sentAt),
43
+ readAt: message.readAt ? new Date(message.readAt) : undefined,
44
+ })),
45
+ missionLog: state.missionLog.map((entry) => ({
46
+ ...entry,
47
+ ts: new Date(entry.ts),
48
+ })),
49
+ runs: (state.runs ?? []).map((run) => ({
50
+ ...run,
51
+ startedAt: new Date(run.startedAt),
52
+ endedAt: run.endedAt ? new Date(run.endedAt) : undefined,
53
+ nextAttemptAt: run.nextAttemptAt
54
+ ? new Date(run.nextAttemptAt)
55
+ : undefined,
56
+ heartbeatAt: run.heartbeatAt ? new Date(run.heartbeatAt) : undefined,
57
+ })),
58
+ outcomes: (state.outcomes ?? []).map((outcome) => ({
59
+ ...outcome,
60
+ createdAt: new Date(outcome.createdAt),
61
+ finalizedAt: outcome.finalizedAt
62
+ ? new Date(outcome.finalizedAt)
63
+ : undefined,
64
+ })),
65
+ outcomeFragments: (state.outcomeFragments ?? []).map((fragment) => ({
66
+ ...fragment,
67
+ createdAt: new Date(fragment.createdAt),
68
+ reviewedAt: fragment.reviewedAt
69
+ ? new Date(fragment.reviewedAt)
70
+ : undefined,
71
+ })),
72
+ };
73
+ }
74
+
75
+ interface PersistedTeamEnvelope {
76
+ version: 1;
77
+ updatedAt: string;
78
+ teamState: TeamRuntimeState;
79
+ teammates: TeamTeammateSpec[];
80
+ }
81
+
82
+ export interface FileTeamStoreOptions {
83
+ teamDir?: string;
84
+ }
85
+
86
+ export interface TeamRuntimeLoadResult {
87
+ state?: TeamRuntimeState;
88
+ teammates: TeamTeammateSpec[];
89
+ interruptedRunIds: string[];
90
+ }
91
+
92
+ export class FileTeamStore implements TeamStore {
93
+ private readonly teamDirPath: string;
94
+
95
+ constructor(options: FileTeamStoreOptions = {}) {
96
+ this.teamDirPath = options.teamDir ?? resolveTeamDataDir();
97
+ }
98
+
99
+ init(): void {
100
+ this.ensureTeamDir();
101
+ }
102
+
103
+ listTeamNames(): string[] {
104
+ if (!existsSync(this.teamDirPath)) {
105
+ return [];
106
+ }
107
+ return readdirSync(this.teamDirPath, { withFileTypes: true })
108
+ .filter((entry) => entry.isDirectory())
109
+ .filter((entry) => existsSync(this.statePath(entry.name)))
110
+ .map((entry) => entry.name)
111
+ .sort();
112
+ }
113
+
114
+ readState(teamName: string): TeamRuntimeState | undefined {
115
+ const envelope = this.readEnvelope(teamName);
116
+ return envelope?.teamState
117
+ ? reviveTeamRuntimeStateDates(envelope.teamState)
118
+ : undefined;
119
+ }
120
+
121
+ readHistory(teamName: string, limit = 200): unknown[] {
122
+ const historyPath = this.historyPath(teamName);
123
+ if (!existsSync(historyPath)) {
124
+ return [];
125
+ }
126
+ return readFileSync(historyPath, "utf8")
127
+ .split("\n")
128
+ .map((line) => line.trim())
129
+ .filter(Boolean)
130
+ .map((line) => {
131
+ try {
132
+ return JSON.parse(line) as unknown;
133
+ } catch {
134
+ return undefined;
135
+ }
136
+ })
137
+ .filter((item): item is unknown => item !== undefined)
138
+ .reverse()
139
+ .slice(0, limit);
140
+ }
141
+
142
+ loadRuntime(teamName: string): TeamRuntimeLoadResult {
143
+ const envelope = this.readEnvelope(teamName);
144
+ const interruptedRunIds = this.markInProgressRunsInterrupted(
145
+ teamName,
146
+ "runtime_recovered",
147
+ );
148
+ return {
149
+ state: envelope?.teamState
150
+ ? reviveTeamRuntimeStateDates(envelope.teamState)
151
+ : undefined,
152
+ teammates: envelope?.teammates ?? [],
153
+ interruptedRunIds,
154
+ };
155
+ }
156
+
157
+ handleTeamEvent(teamName: string, event: TeamEvent): void {
158
+ this.ensureTeamSubdir(teamName);
159
+ appendFileSync(
160
+ this.historyPath(teamName),
161
+ `${JSON.stringify({ ts: nowIso(), eventType: event.type, payload: event })}\n`,
162
+ "utf8",
163
+ );
164
+ }
165
+
166
+ persistRuntime(
167
+ teamName: string,
168
+ state: TeamRuntimeState,
169
+ teammates: TeamTeammateSpec[],
170
+ ): void {
171
+ this.ensureTeamSubdir(teamName);
172
+ const envelope: PersistedTeamEnvelope = {
173
+ version: 1,
174
+ updatedAt: nowIso(),
175
+ teamState: state,
176
+ teammates,
177
+ };
178
+ const path = this.statePath(teamName);
179
+ const tempPath = `${path}.tmp`;
180
+ writeFileSync(tempPath, `${JSON.stringify(envelope, null, 2)}\n`, "utf8");
181
+ renameSync(tempPath, path);
182
+ }
183
+
184
+ markInProgressRunsInterrupted(teamName: string, reason: string): string[] {
185
+ const envelope = this.readEnvelope(teamName);
186
+ if (!envelope?.teamState?.runs?.length) {
187
+ return [];
188
+ }
189
+ const interrupted = envelope.teamState.runs
190
+ .filter((run) => run.status === "queued" || run.status === "running")
191
+ .map((run) => run.id);
192
+ if (interrupted.length === 0) {
193
+ return [];
194
+ }
195
+ const endedAt = new Date();
196
+ envelope.teamState = {
197
+ ...envelope.teamState,
198
+ runs: envelope.teamState.runs.map((run) =>
199
+ run.status === "queued" || run.status === "running"
200
+ ? {
201
+ ...run,
202
+ status: "interrupted",
203
+ error: reason,
204
+ endedAt,
205
+ }
206
+ : run,
207
+ ),
208
+ };
209
+ this.persistRuntime(teamName, envelope.teamState, envelope.teammates);
210
+ return interrupted;
211
+ }
212
+
213
+ private ensureTeamDir(): string {
214
+ if (!existsSync(this.teamDirPath)) {
215
+ mkdirSync(this.teamDirPath, { recursive: true });
216
+ }
217
+ return this.teamDirPath;
218
+ }
219
+
220
+ private ensureTeamSubdir(teamName: string): string {
221
+ const path = join(this.ensureTeamDir(), sanitizeTeamName(teamName));
222
+ if (!existsSync(path)) {
223
+ mkdirSync(path, { recursive: true });
224
+ }
225
+ return path;
226
+ }
227
+
228
+ private statePath(teamName: string): string {
229
+ return join(this.ensureTeamDir(), sanitizeTeamName(teamName), "state.json");
230
+ }
231
+
232
+ private historyPath(teamName: string): string {
233
+ return join(
234
+ this.ensureTeamDir(),
235
+ sanitizeTeamName(teamName),
236
+ "task-history.jsonl",
237
+ );
238
+ }
239
+
240
+ private readEnvelope(teamName: string): PersistedTeamEnvelope | undefined {
241
+ const path = this.statePath(teamName);
242
+ if (!existsSync(path)) {
243
+ return undefined;
244
+ }
245
+ try {
246
+ const parsed = JSON.parse(
247
+ readFileSync(path, "utf8"),
248
+ ) as PersistedTeamEnvelope;
249
+ if (parsed?.version === 1 && parsed.teamState) {
250
+ return parsed;
251
+ }
252
+ } catch {
253
+ // Ignore invalid persistence and fall back to undefined.
254
+ }
255
+ return undefined;
256
+ }
257
+ }
@@ -57,6 +57,11 @@ export class SqliteSessionStore implements SessionStore {
57
57
  return db;
58
58
  }
59
59
 
60
+ close(): void {
61
+ this.db?.close?.();
62
+ this.db = undefined;
63
+ }
64
+
60
65
  run(sql: string, params: unknown[] = []): { changes?: number } {
61
66
  return this.getRawDb()
62
67
  .prepare(sql)
@@ -1,5 +1,40 @@
1
1
  export type { TeamStore } from "../types/storage";
2
2
  export {
3
+ FileTeamStore,
4
+ type FileTeamStoreOptions,
5
+ } from "./file-team-store";
6
+ export {
7
+ SqliteTeamStore,
8
+ type SqliteTeamStoreOptions,
9
+ } from "./sqlite-team-store";
10
+
11
+ import { FileTeamStore } from "./file-team-store";
12
+ import {
3
13
  SqliteTeamStore,
4
14
  type SqliteTeamStoreOptions,
5
15
  } from "./sqlite-team-store";
16
+
17
+ export function createLocalTeamStore(options: SqliteTeamStoreOptions = {}): {
18
+ init(): void;
19
+ listTeamNames(): string[];
20
+ readState(teamName: string): ReturnType<FileTeamStore["readState"]>;
21
+ readHistory(teamName: string, limit?: number): unknown[];
22
+ loadRuntime(teamName: string): ReturnType<FileTeamStore["loadRuntime"]>;
23
+ handleTeamEvent: FileTeamStore["handleTeamEvent"];
24
+ persistRuntime: FileTeamStore["persistRuntime"];
25
+ markInProgressRunsInterrupted: FileTeamStore["markInProgressRunsInterrupted"];
26
+ } {
27
+ try {
28
+ const store = new SqliteTeamStore(options);
29
+ store.init();
30
+ return store;
31
+ } catch (error) {
32
+ console.warn(
33
+ "SQLite team persistence unavailable, falling back to file-based team storage.",
34
+ error,
35
+ );
36
+ const store = new FileTeamStore({ teamDir: options.teamDir });
37
+ store.init();
38
+ return store;
39
+ }
40
+ }