session-steward 0.4.0 → 0.5.1

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.
@@ -1,3 +1,4 @@
1
+ import { readSessionEvents } from "./events.mjs";
1
2
  import {
2
3
  assertDeepCleanupSupported,
3
4
  diagnoseStorageCompatibility,
@@ -38,6 +39,7 @@ export const codexProvider = Object.freeze({
38
39
  loadSessionStore,
39
40
  planSessionDeletion,
40
41
  preflightSessionDeletion,
42
+ readSessionEvents,
41
43
  restoreSessionDeletionBackup,
42
44
  verifySessionDeletion,
43
45
  });
@@ -460,6 +460,7 @@ export function getCodexPaths(codexHomeInput, { refresh = false } = {}) {
460
460
 
461
461
  return {
462
462
  archivedSessionsDirectory,
463
+ attachmentsDirectory: path.join(codexHome, "attachments"),
463
464
  codexHome,
464
465
  desktopStateBackupPath: path.join(codexHome, ".codex-global-state.json.bak"),
465
466
  desktopStatePath: path.join(codexHome, ".codex-global-state.json"),
@@ -477,6 +478,7 @@ export function getCodexPaths(codexHomeInput, { refresh = false } = {}) {
477
478
  stateDatabasePath,
478
479
  stateDatabases: allValidDatabases(state),
479
480
  stateDatabasePaths: allValidDatabases(state).map((database) => database.path),
481
+ threadWriterLocksDirectory: path.join(codexHome, "thread-writer-locks"),
480
482
  transcriptDirectories: [sessionsDirectory, archivedSessionsDirectory],
481
483
  };
482
484
  }
@@ -768,6 +770,7 @@ export async function loadSessionStore({ codexHome }) {
768
770
  });
769
771
 
770
772
  return {
773
+ attachmentsDirectory: paths.attachmentsDirectory,
771
774
  childIdsByParentId,
772
775
  codexHome: paths.codexHome,
773
776
  desktopStateBackupPath: paths.desktopStateBackupPath,
@@ -787,6 +790,7 @@ export async function loadSessionStore({ codexHome }) {
787
790
  stateDatabasePath: paths.stateDatabasePath,
788
791
  stateDatabasePaths: paths.stateDatabasePaths,
789
792
  stateDatabases: paths.stateDatabases,
793
+ threadWriterLocksDirectory: paths.threadWriterLocksDirectory,
790
794
  resolvedDatabases: paths.resolvedDatabases,
791
795
  logsDatabasePaths: paths.logsDatabasePaths,
792
796
  memoryDatabasePaths: paths.memoryDatabasePaths,
@@ -850,6 +854,16 @@ export async function diagnoseStorageCompatibility({ codexHome }) {
850
854
  .filter((entry) => entry.isFile() && entry.name.endsWith(".sqlite") && !recognizedDatabases.has(entry.name))
851
855
  .map((entry) => `Other local database found: ${entry.name}`)
852
856
  .sort();
857
+ try {
858
+ const attachmentEntries = await fs.readdir(paths.attachmentsDirectory, { withFileTypes: true });
859
+ if (attachmentEntries.some((entry) => entry.name !== ".DS_Store")) {
860
+ newlyDiscovered.push("Session attachments are kept because their ownership cannot be verified safely.");
861
+ }
862
+ } catch (error) {
863
+ if (error?.code !== "ENOENT") {
864
+ newlyDiscovered.push("The session attachments folder could not be examined.");
865
+ }
866
+ }
853
867
  const status = missing.length > 0 || changed.length > 0
854
868
  ? "unsupported"
855
869
  : newlyDiscovered.length > 0
@@ -906,7 +920,14 @@ function stateSchema(database) {
906
920
  const spawn = database.tables?.thread_spawn_edges?.exists !== undefined
907
921
  ? database.tables.thread_spawn_edges
908
922
  : inspectSqliteTable(database.path, "thread_spawn_edges");
909
- const schema = { columns, hasSpawnEdges: Boolean(spawn?.exists) };
923
+ const dynamicTools = database.tables?.thread_dynamic_tools?.exists !== undefined
924
+ ? database.tables.thread_dynamic_tools
925
+ : inspectSqliteTable(database.path, "thread_dynamic_tools");
926
+ const schema = {
927
+ columns,
928
+ hasDynamicTools: Boolean(dynamicTools?.exists),
929
+ hasSpawnEdges: Boolean(spawn?.exists),
930
+ };
910
931
  stateSchemaCache.set(database, schema);
911
932
  return schema;
912
933
  }
@@ -1935,6 +1956,10 @@ export async function planSessionDeletion({ recordIds, store }) {
1935
1956
 
1936
1957
  return count;
1937
1958
  }, 0);
1959
+ const dynamicToolRowCount = (store.stateDatabases ?? [{ path: store.stateDatabasePath }])
1960
+ .reduce((total, database) => total + (stateSchema(database).hasDynamicTools
1961
+ ? countRowsForIds(database.path, "thread_dynamic_tools", "thread_id", deletionIds)
1962
+ : 0), 0);
1938
1963
  const logRowCount = (store.logsDatabasePaths ?? [store.logsDatabasePath].filter(Boolean))
1939
1964
  .reduce((total, databasePath) => total + countRowsForIds(databasePath, "logs", "thread_id", deletionIds), 0);
1940
1965
  const memoryRowCount = (store.memoryDatabasePaths ?? [store.memoryDatabasePath].filter(Boolean))
@@ -1953,6 +1978,7 @@ export async function planSessionDeletion({ recordIds, store }) {
1953
1978
  backup: store.hasDesktopStateBackup ? "pending" : "absent",
1954
1979
  current: store.hasDesktopState ? "pending" : "absent",
1955
1980
  },
1981
+ dynamicToolRowCount,
1956
1982
  goalRowCount,
1957
1983
  historyMatchCount: historyMatches.count,
1958
1984
  ids: deletionIds,
@@ -2016,6 +2042,7 @@ export async function fingerprintSessionDeletion({ plan, scope, store }) {
2016
2042
  hash.update(`${store.codexHome}\0${scope}\0`);
2017
2043
 
2018
2044
  const counts = [
2045
+ plan.dynamicToolRowCount,
2019
2046
  plan.historyMatchCount,
2020
2047
  plan.logRowCount,
2021
2048
  plan.sessionIndexMatchCount,
@@ -2053,6 +2080,9 @@ export async function fingerprintSessionDeletion({ plan, scope, store }) {
2053
2080
 
2054
2081
  for (const database of store.stateDatabases ?? [{ path: store.stateDatabasePath }]) {
2055
2082
  fingerprintRowsForIds(hash, database.path, "threads", "id", plan.ids);
2083
+ if (stateSchema(database).hasDynamicTools) {
2084
+ fingerprintRowsForIds(hash, database.path, "thread_dynamic_tools", "thread_id", plan.ids);
2085
+ }
2056
2086
  if (stateSchema(database).hasSpawnEdges) {
2057
2087
  fingerprintRowsForIds(hash, database.path, "thread_spawn_edges", "parent_thread_id", plan.ids);
2058
2088
  fingerprintRowsForIds(hash, database.path, "thread_spawn_edges", "child_thread_id", plan.ids);
@@ -2129,6 +2159,36 @@ async function getAvailableDiskBytes(directoryPath) {
2129
2159
  return stats.bavail * stats.bsize;
2130
2160
  }
2131
2161
 
2162
+ async function findActiveThreadIds(store, ids) {
2163
+ if (!store.threadWriterLocksDirectory) return { available: false, ids: [] };
2164
+
2165
+ let entries;
2166
+ try {
2167
+ entries = await fs.readdir(store.threadWriterLocksDirectory, { withFileTypes: true });
2168
+ } catch (error) {
2169
+ if (error?.code === "ENOENT") return { available: false, ids: [] };
2170
+ throw error;
2171
+ }
2172
+
2173
+ const lockedIds = new Set(entries
2174
+ .filter((entry) => entry.isFile() && entry.name.endsWith(".lock"))
2175
+ .map((entry) => entry.name.slice(0, -5)));
2176
+ return {
2177
+ available: true,
2178
+ ids: ids.filter((id) => lockedIds.has(id)),
2179
+ };
2180
+ }
2181
+
2182
+ function activeThreadError(ids) {
2183
+ const error = new Error(
2184
+ ids.length === 1
2185
+ ? "Close the selected Codex session before cleanup."
2186
+ : `Close the ${ids.length} selected Codex sessions that are still open before cleanup.`,
2187
+ );
2188
+ error.activeThreadIds = ids;
2189
+ return error;
2190
+ }
2191
+
2132
2192
  function formatBytes(bytes) {
2133
2193
  if (bytes < 1024) {
2134
2194
  return `${bytes} bytes`;
@@ -2164,6 +2224,9 @@ export async function preflightSessionDeletion({ availableDiskBytes, plan, scope
2164
2224
  throw new Error("The selected Codex home is missing a required session store.");
2165
2225
  }
2166
2226
 
2227
+ const activeThreads = await findActiveThreadIds(store, plan.ids);
2228
+ if (activeThreads.ids.length > 0) throw activeThreadError(activeThreads.ids);
2229
+
2167
2230
  const deletedIdSet = new Set(plan.ids);
2168
2231
  const desktopStates = await Promise.all([
2169
2232
  store.hasDesktopState ? readJsonFile(store.desktopStatePath) : null,
@@ -2192,7 +2255,7 @@ export async function preflightSessionDeletion({ availableDiskBytes, plan, scope
2192
2255
  backup: store.hasDesktopStateBackup ? "supported" : "absent",
2193
2256
  current: store.hasDesktopState ? "supported" : "absent",
2194
2257
  },
2195
- activeThreadDetection: "unavailable",
2258
+ activeThreadDetection: activeThreads.available ? "writer-locks" : "unavailable",
2196
2259
  missingRequiredPaths,
2197
2260
  };
2198
2261
  }
@@ -2384,6 +2447,11 @@ export async function executeSessionDeletion({
2384
2447
  throw cleanupCancelled(backupDirectory);
2385
2448
  }
2386
2449
 
2450
+ const activeThreads = await findActiveThreadIds(store, plan.ids);
2451
+ if (activeThreads.ids.length > 0) {
2452
+ throw cleanupErrorWithBackup(activeThreadError(activeThreads.ids), backupDirectory);
2453
+ }
2454
+
2387
2455
  await reportProgress(onProgress, {
2388
2456
  canCancel: false,
2389
2457
  message: "Removing selected session data",
@@ -2413,10 +2481,14 @@ export async function executeSessionDeletion({
2413
2481
  }
2414
2482
  for (const { database, ids } of plan.stateTargets ?? (store.stateDatabases ?? [{ path: store.stateDatabasePath }]).map((database) => ({ database, ids: plan.ids }))) {
2415
2483
  const stateStatements = [];
2416
- const hasSpawnEdges = stateSchema(database).hasSpawnEdges;
2484
+ const schema = stateSchema(database);
2417
2485
  for (const idBatch of batches(ids)) {
2418
2486
  const idPlaceholders = placeholders(idBatch);
2419
- if (hasSpawnEdges) stateStatements.push({
2487
+ if (schema.hasDynamicTools) stateStatements.push({
2488
+ parameters: idBatch,
2489
+ sql: `delete from thread_dynamic_tools where thread_id in (${idPlaceholders})`,
2490
+ });
2491
+ if (schema.hasSpawnEdges) stateStatements.push({
2420
2492
  parameters: [...idBatch, ...idBatch],
2421
2493
  sql: `delete from thread_spawn_edges where parent_thread_id in (${idPlaceholders}) or child_thread_id in (${idPlaceholders})`,
2422
2494
  });
@@ -2749,6 +2821,10 @@ export async function verifySessionDeletion({ plan, scope = "deep", store }) {
2749
2821
  const deletedIdSet = new Set(plan.ids);
2750
2822
  const remainingThreads = (store.stateDatabasePaths ?? [store.stateDatabasePath])
2751
2823
  .flatMap((databasePath) => findRowsForIds(databasePath, "threads", "id", plan.ids));
2824
+ const remainingDynamicTools = (store.stateDatabases ?? [{ path: store.stateDatabasePath }])
2825
+ .flatMap((database) => stateSchema(database).hasDynamicTools
2826
+ ? findRowsForIds(database.path, "thread_dynamic_tools", "thread_id", plan.ids)
2827
+ : []);
2752
2828
  const remainingMemoryRecords = scope === "deep" && store.hasMemoryDatabase
2753
2829
  ? (store.memoryDatabasePaths ?? [store.memoryDatabasePath]).flatMap((databasePath) =>
2754
2830
  findRowsForIds(databasePath, "stage1_outputs", "thread_id", plan.ids))
@@ -2804,6 +2880,7 @@ export async function verifySessionDeletion({ plan, scope = "deep", store }) {
2804
2880
  return {
2805
2881
  complete:
2806
2882
  remainingThreads.length === 0 &&
2883
+ remainingDynamicTools.length === 0 &&
2807
2884
  remainingMemoryRecords.length === 0 &&
2808
2885
  remainingGoalRecords.length === 0 &&
2809
2886
  remainingLogRecords.length === 0 &&
@@ -2812,6 +2889,7 @@ export async function verifySessionDeletion({ plan, scope = "deep", store }) {
2812
2889
  remainingTranscriptPaths.length === 0 &&
2813
2890
  remainingDesktopStateReferences.length === 0,
2814
2891
  remainingDesktopStateReferences,
2892
+ remainingDynamicTools,
2815
2893
  remainingGoalRecords,
2816
2894
  remainingHistoryEntries,
2817
2895
  remainingHistoryEntryCount: historyMatches.count,
package/lib/server.mjs CHANGED
@@ -6,6 +6,10 @@ import path from "node:path";
6
6
  import { fileURLToPath } from "node:url";
7
7
 
8
8
  import { getProvider, listProviders } from "./providers/index.mjs";
9
+ import {
10
+ DEFAULT_SESSION_EVENT_LIMIT,
11
+ MAX_SESSION_EVENT_LIMIT,
12
+ } from "./session-event-reader.mjs";
9
13
  import { createProviderSettings } from "./settings.mjs";
10
14
  import { classifyInstalledVersion } from "./version-support.mjs";
11
15
 
@@ -174,6 +178,35 @@ function getPositiveInteger(value, fallback, maximum = Number.MAX_SAFE_INTEGER)
174
178
  return Math.min(parsed, maximum);
175
179
  }
176
180
 
181
+ function getSessionEventLimit(value) {
182
+ if (value === null) return DEFAULT_SESSION_EVENT_LIMIT;
183
+ if (!/^\d+$/u.test(value)) {
184
+ throw new Error(`limit must be between 1 and ${MAX_SESSION_EVENT_LIMIT}.`);
185
+ }
186
+
187
+ const limit = Number(value);
188
+ if (!Number.isSafeInteger(limit) || limit < 1 || limit > MAX_SESSION_EVENT_LIMIT) {
189
+ throw new Error(`limit must be between 1 and ${MAX_SESSION_EVENT_LIMIT}.`);
190
+ }
191
+
192
+ return limit;
193
+ }
194
+
195
+ function getSessionId(value) {
196
+ if (
197
+ typeof value !== "string"
198
+ || value.length === 0
199
+ || value.length > 512
200
+ || value.trim() !== value
201
+ || value.includes("\0")
202
+ || /[\\/]/u.test(value)
203
+ ) {
204
+ throw new Error("Enter a valid session ID.");
205
+ }
206
+
207
+ return value;
208
+ }
209
+
177
210
  function getInactiveBeforeMs(value) {
178
211
  if (value === null || value === "") {
179
212
  return null;
@@ -811,10 +844,38 @@ export async function startLocalServer({ claudeHome, codexHome, configDirectory,
811
844
  return;
812
845
  }
813
846
 
847
+ if (request.method === "GET" && requestUrl.pathname === "/api/session-events") {
848
+ const controller = new AbortController();
849
+ const abortRead = () => controller.abort();
850
+ request.once("aborted", abortRead);
851
+ response.once("close", () => {
852
+ if (!response.writableEnded) abortRead();
853
+ });
854
+ const providerId = resolveProviderId(requestUrl.searchParams.get("provider"));
855
+ const provider = getProvider(providerId);
856
+ const id = getSessionId(requestUrl.searchParams.get("id"));
857
+ const result = await provider.readSessionEvents({
858
+ ...providerOptions(providerId, settings.getHome(providerId)),
859
+ id,
860
+ limit: getSessionEventLimit(requestUrl.searchParams.get("limit")),
861
+ signal: controller.signal,
862
+ });
863
+
864
+ if (controller.signal.aborted) return;
865
+
866
+ if (!result) {
867
+ sendJson(response, 404, { error: "Session not found." });
868
+ return;
869
+ }
870
+
871
+ sendJson(response, 200, result);
872
+ return;
873
+ }
874
+
814
875
  if (request.method === "GET" && requestUrl.pathname.startsWith("/api/sessions/")) {
815
876
  const providerId = resolveProviderId(requestUrl.searchParams.get("provider"));
816
877
  const provider = getProvider(providerId);
817
- const id = decodeURIComponent(requestUrl.pathname.slice("/api/sessions/".length));
878
+ const id = getSessionId(decodeURIComponent(requestUrl.pathname.slice("/api/sessions/".length)));
818
879
  const record = await provider.getSessionRecord({
819
880
  ...providerOptions(providerId, settings.getHome(providerId)),
820
881
  id,
@@ -0,0 +1,126 @@
1
+ import {
2
+ SESSION_EVENT_READ_MODE,
3
+ SESSION_EVENT_WINDOW_END,
4
+ } from "./session-events.mjs";
5
+
6
+ export const DEFAULT_SESSION_EVENT_LIMIT = 100;
7
+ export const MAX_SESSION_EVENT_LIMIT = 1_000;
8
+
9
+ const MAX_PENDING_SESSION_EVENTS = 2_048;
10
+ const MAX_UNMAPPED_SESSION_EVENT_TYPES = 128;
11
+ const SESSION_EVENT_READ_MODES = new Set(Object.values(SESSION_EVENT_READ_MODE));
12
+ const WRAPPED_CONTEXT_PATTERN = /^<([A-Za-z][\w:.-]*[_-][\w:.-]*)(?:\s[^>]*)?>[\s\S]*?<\/\1>\s*/u;
13
+
14
+ export function isInjectedSessionAsk(value) {
15
+ if (typeof value !== "string") return false;
16
+ let remaining = value.trim();
17
+ if (!remaining) return false;
18
+ if (/^#{1,6}\s+[^\n]*\binstructions?\b/iu.test(remaining)) return true;
19
+ let matched = false;
20
+
21
+ while (remaining) {
22
+ const match = WRAPPED_CONTEXT_PATTERN.exec(remaining);
23
+ if (!match) return false;
24
+ matched = true;
25
+ remaining = remaining.slice(match[0].length).trimStart();
26
+ }
27
+
28
+ return matched;
29
+ }
30
+
31
+ export function createSessionEventReadState({
32
+ limit = DEFAULT_SESSION_EVENT_LIMIT,
33
+ mode = SESSION_EVENT_READ_MODE.RECENT,
34
+ } = {}) {
35
+ if (!Number.isSafeInteger(limit) || limit < 1 || limit > MAX_SESSION_EVENT_LIMIT) {
36
+ throw new TypeError(`limit must be between 1 and ${MAX_SESSION_EVENT_LIMIT}.`);
37
+ }
38
+
39
+ if (!SESSION_EVENT_READ_MODES.has(mode)) {
40
+ throw new TypeError(`Unsupported session event read mode: ${mode}`);
41
+ }
42
+
43
+ const events = new Array(limit);
44
+ const pending = new Map();
45
+ const pendingIds = new WeakMap();
46
+ let length = 0;
47
+ let start = 0;
48
+
49
+ function untrack(event) {
50
+ const pendingId = pendingIds.get(event);
51
+ if (pendingId && pending.get(pendingId) === event) pending.delete(pendingId);
52
+ }
53
+
54
+ return {
55
+ add(event, { pendingId = null } = {}) {
56
+ if (pendingId) {
57
+ pending.delete(pendingId);
58
+ pending.set(pendingId, event);
59
+ pendingIds.set(event, pendingId);
60
+
61
+ while (pending.size > MAX_PENDING_SESSION_EVENTS) {
62
+ pending.delete(pending.keys().next().value);
63
+ }
64
+ }
65
+
66
+ if (length < limit) {
67
+ events[(start + length) % limit] = event;
68
+ length += 1;
69
+ } else if (mode === SESSION_EVENT_READ_MODE.RECENT) {
70
+ untrack(events[start]);
71
+ events[start] = event;
72
+ start = (start + 1) % limit;
73
+ }
74
+
75
+ return mode === SESSION_EVENT_READ_MODE.PREVIEW && length === limit;
76
+ },
77
+ resolve(pendingId, update, { consume = true } = {}) {
78
+ const event = pending.get(pendingId);
79
+ if (!event) return false;
80
+ if (consume) pending.delete(pendingId);
81
+ update(event);
82
+ return true;
83
+ },
84
+ values() {
85
+ return Array.from({ length }, (_, index) => events[(start + index) % limit]);
86
+ },
87
+ window({ complete, stoppedEarly }) {
88
+ return {
89
+ complete,
90
+ end: stoppedEarly
91
+ ? SESSION_EVENT_WINDOW_END.OLDEST
92
+ : complete
93
+ ? SESSION_EVENT_WINDOW_END.NEWEST
94
+ : SESSION_EVENT_WINDOW_END.PARTIAL,
95
+ outcomesMayBeUnresolved: stoppedEarly || !complete,
96
+ };
97
+ },
98
+ };
99
+ }
100
+
101
+ export function createUnmappedSessionEventTracker() {
102
+ const counts = new Map();
103
+ let other = 0;
104
+
105
+ return {
106
+ add(type) {
107
+ const normalizedType = typeof type === "string" && type.trim()
108
+ ? type.trim()
109
+ : "unknown";
110
+ if (counts.has(normalizedType)) {
111
+ counts.set(normalizedType, counts.get(normalizedType) + 1);
112
+ } else if (counts.size < MAX_UNMAPPED_SESSION_EVENT_TYPES) {
113
+ counts.set(normalizedType, 1);
114
+ } else {
115
+ other += 1;
116
+ }
117
+ },
118
+ values(limit = 5) {
119
+ const entries = [...counts].map(([type, count]) => ({ count, type }));
120
+ if (other > 0) entries.push({ count: other, type: "other unmapped types" });
121
+ return entries
122
+ .sort((left, right) => right.count - left.count || left.type.localeCompare(right.type))
123
+ .slice(0, limit);
124
+ },
125
+ };
126
+ }