@giovannijecha/jecode 0.8.5 → 0.8.6

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 (64) hide show
  1. package/README.md +22 -5
  2. package/dist/batch-view.js +27 -2
  3. package/dist/batch.js +2 -0
  4. package/dist/cli-info.js +0 -1
  5. package/dist/config.js +14 -6
  6. package/dist/credential-commands.js +3 -3
  7. package/dist/openai-account-command.js +14 -12
  8. package/dist/openai-account.js +7 -5
  9. package/dist/openai-oauth-callback.js +13 -4
  10. package/dist/openai-oauth-tokens.js +9 -7
  11. package/dist/openai-oauth.js +8 -6
  12. package/dist/permission-command.js +1 -1
  13. package/dist/provider-commands.js +1 -33
  14. package/dist/provider-errors.js +1 -10
  15. package/dist/provider-label.js +3 -10
  16. package/dist/providers/anthropic.js +0 -1
  17. package/dist/providers/index.js +1 -5
  18. package/dist/providers/ollama-context.js +42 -0
  19. package/dist/providers/ollama-endpoint.js +7 -34
  20. package/dist/providers/ollama.js +13 -147
  21. package/dist/providers/openai-codex.js +4 -4
  22. package/dist/providers/openai.js +0 -1
  23. package/dist/sessions/bucket.js +55 -0
  24. package/dist/sessions/catalog-io.js +162 -0
  25. package/dist/sessions/catalog.js +3 -1
  26. package/dist/sessions/codec-messages.js +122 -0
  27. package/dist/sessions/codec-transcript.js +93 -0
  28. package/dist/sessions/codec-values.js +52 -0
  29. package/dist/sessions/codec.js +4 -257
  30. package/dist/sessions/files.js +158 -0
  31. package/dist/sessions/load.js +90 -0
  32. package/dist/sessions/snapshot.js +33 -0
  33. package/dist/sessions/store.js +42 -461
  34. package/dist/settings-command.js +10 -5
  35. package/dist/settings.js +16 -15
  36. package/dist/start.js +1 -2
  37. package/dist/tools/file-read.js +192 -0
  38. package/dist/tools/file-summary.js +9 -0
  39. package/dist/tools/{fs.js → file-write.js} +5 -193
  40. package/dist/tools/glob.js +107 -0
  41. package/dist/tools/index.js +2 -1
  42. package/dist/tools/search.js +1 -105
  43. package/dist/tui/app-workflows.js +8 -361
  44. package/dist/tui/approve.js +5 -3
  45. package/dist/tui/blocks.js +8 -7
  46. package/dist/tui/command-workflow.js +106 -0
  47. package/dist/tui/components/command-menu.js +7 -10
  48. package/dist/tui/components/menu.js +74 -43
  49. package/dist/tui/components/messages.js +16 -9
  50. package/dist/tui/components/tool-evidence.js +107 -0
  51. package/dist/tui/components/tool-motion.js +32 -0
  52. package/dist/tui/components/tool.js +48 -202
  53. package/dist/tui/help.js +1 -1
  54. package/dist/tui/picker-layout.js +40 -0
  55. package/dist/tui/picker.js +7 -71
  56. package/dist/tui/tool-details.js +135 -0
  57. package/dist/tui/transcript-grammar.js +8 -1
  58. package/dist/tui/transcript-view.js +26 -112
  59. package/dist/tui/turn-workflow.js +264 -0
  60. package/dist/tui/turn.js +7 -140
  61. package/dist/tui/workflow-types.js +2 -0
  62. package/package.json +12 -12
  63. package/dist/ollama-settings-command.js +0 -74
  64. package/dist/tui/motion.js +0 -32
@@ -0,0 +1,42 @@
1
+ // Cloud model capacity, with bounded metadata and conservative safety headroom.
2
+ import { postJson } from "./http.js";
3
+ import { OLLAMA_CLOUD_HOST } from "./ollama-endpoint.js";
4
+ const CACHE_MS = 30_000;
5
+ const capacities = new Map();
6
+ export async function ollamaContextWindow(model, headers, signal, onStatus) {
7
+ signal?.throwIfAborted();
8
+ const cached = capacities.get(model);
9
+ if (cached !== undefined && cached.expiresAt > Date.now())
10
+ return cached.value;
11
+ capacities.delete(model);
12
+ try {
13
+ const details = await postJson(`${OLLAMA_CLOUD_HOST}/api/show`, headers, { model }, signal, onStatus);
14
+ const tokens = modelCapacity(details);
15
+ if (tokens === undefined)
16
+ return undefined;
17
+ const value = Object.freeze({ tokens: Math.floor(tokens * 95 / 100) });
18
+ capacities.set(model, { value, expiresAt: Date.now() + CACHE_MS });
19
+ return value;
20
+ }
21
+ catch (error) {
22
+ if (signal?.aborted)
23
+ throw error;
24
+ // Missing metadata leaves budgeting to the controller's safe fallback.
25
+ return undefined;
26
+ }
27
+ }
28
+ function modelCapacity(value) {
29
+ if (!record(value) || !record(value["model_info"]))
30
+ return undefined;
31
+ const counts = Object.entries(value["model_info"])
32
+ .filter(([name, count]) => name.endsWith(".context_length") && validTokenCount(count))
33
+ .map(([, count]) => count);
34
+ return counts.length === 0 ? undefined : Math.max(...counts);
35
+ }
36
+ function validTokenCount(value) {
37
+ return typeof value === "number" && Number.isSafeInteger(value) &&
38
+ value >= 4_096 && value <= 10_000_000;
39
+ }
40
+ function record(value) {
41
+ return typeof value === "object" && value !== null && !Array.isArray(value);
42
+ }
@@ -1,40 +1,13 @@
1
- // Turning an Ollama host into one safe, normalized endpoint.
2
- export const OLLAMA_LOCAL_HOST = "http://127.0.0.1:11434";
1
+ // The fixed API endpoint and recognition of its retired saved setting.
3
2
  export const OLLAMA_CLOUD_HOST = "https://ollama.com";
4
- export function parseOllamaEndpoint(value) {
5
- let url;
3
+ /** Recognize old official-cloud values without making endpoints configurable. */
4
+ export function isLegacyOllamaCloudHost(value) {
5
+ if (typeof value !== "string")
6
+ return false;
6
7
  try {
7
- url = new URL(value.trim());
8
+ return new URL(value.trim()).href.replace(/\/+$/, "") === OLLAMA_CLOUD_HOST;
8
9
  }
9
10
  catch {
10
- throw new Error("Ollama endpoint must be an absolute HTTP(S) URL");
11
+ return false;
11
12
  }
12
- if (url.protocol !== "http:" && url.protocol !== "https:") {
13
- throw new Error("Ollama endpoint must use HTTP or HTTPS");
14
- }
15
- if (url.username !== "" || url.password !== "") {
16
- throw new Error("Ollama endpoint must not contain credentials");
17
- }
18
- if (url.search !== "" || url.hash !== "") {
19
- throw new Error("Ollama endpoint must not contain a query or fragment");
20
- }
21
- const loopback = isExactLoopback(url.hostname);
22
- if (url.protocol === "http:" && !loopback) {
23
- throw new Error("Ollama endpoint must use HTTPS unless it is an exact loopback address");
24
- }
25
- const pathname = url.pathname.replace(/\/+$/, "");
26
- return {
27
- baseUrl: `${url.origin}${pathname === "" ? "" : pathname}`,
28
- loopback,
29
- };
30
- }
31
- export function ollamaConnectionKind(endpoint) {
32
- if (endpoint.baseUrl === OLLAMA_CLOUD_HOST)
33
- return "cloud";
34
- if (endpoint.baseUrl === OLLAMA_LOCAL_HOST)
35
- return "local";
36
- return "custom";
37
- }
38
- function isExactLoopback(hostname) {
39
- return hostname === "localhost" || hostname === "127.0.0.1" || hostname === "[::1]";
40
13
  }
@@ -1,58 +1,27 @@
1
- // Ollama, spoken through its OpenAI-compatible Chat Completions endpoint.
2
- //
3
- // One provider covers both deployments. An explicit session endpoint wins;
4
- // otherwise a configured key selects Ollama Cloud and no key selects the local
5
- // daemon. There is no default model — the catalogue is whatever the host has
6
- // pulled or the subscription grants — so the model has to be named with
7
- // --model.
1
+ // Ollama Cloud through its OpenAI-compatible streaming Chat Completions API.
8
2
  import { requireSupportedEffort } from "../effort.js";
9
- import { getJson, postJson, postSse } from "./http.js";
3
+ import { postSse } from "./http.js";
10
4
  import { listModels } from "./catalog.js";
11
5
  import { keyFor } from "../credentials.js";
12
6
  import { assembleOllama } from "./ollama-stream.js";
7
+ import { ollamaContextWindow } from "./ollama-context.js";
13
8
  import { isRetryableGenerationFailure, isRetryableReadFailure, throwProviderError, } from "./failure.js";
14
- import { OLLAMA_CLOUD_HOST, OLLAMA_LOCAL_HOST, ollamaConnectionKind, parseOllamaEndpoint, } from "./ollama-endpoint.js";
9
+ import { OLLAMA_CLOUD_HOST } from "./ollama-endpoint.js";
15
10
  import { fromWireReply, stopNotice, toWireMessages, toWireTool } from "./ollama-wire.js";
16
11
  const KEY = "OLLAMA_API_KEY";
17
12
  const ID = "ollama";
18
13
  // Ollama also accepts `none`; Jecode's product-wide reasoning floor is `low`.
19
14
  const OLLAMA_EFFORTS = ["low", "medium", "high"];
20
- let configuredHost;
21
- const CONTEXT_CACHE_MS = 30_000;
22
- const runtimeContextByEndpoint = new Map();
23
- const modelContextByEndpoint = new Map();
24
- /** Set the endpoint selected for this process. Undefined restores key-aware inference. */
25
- export function configureOllama(host) {
26
- configuredHost = host === undefined ? undefined : parseOllamaEndpoint(host).baseUrl;
27
- }
28
- export function ollamaConnection() {
29
- const inferred = configuredHost === undefined;
30
- const endpoint = parseOllamaEndpoint(configuredHost ?? (apiKey() === undefined ? OLLAMA_LOCAL_HOST : OLLAMA_CLOUD_HOST));
31
- return { ...endpoint, kind: ollamaConnectionKind(endpoint), inferred };
32
- }
33
15
  export const ollama = {
34
16
  id: ID,
35
17
  defaultModel: "",
36
18
  auth: { kind: "api-key", keyVar: KEY },
37
- // The only provider whose key is conditional: a daemon on this machine is
38
- // reached over loopback and asks for nothing, so demanding a key there
39
- // would be an invented requirement.
40
19
  blocked() {
41
- try {
42
- const at = endpoint();
43
- if (apiKey() !== undefined || at.loopback)
44
- return undefined;
45
- return `${KEY} is not set (required by ${at.baseUrl})`;
46
- }
47
- catch (error) {
48
- return error.message;
49
- }
20
+ return keyFor(KEY) === undefined ? `${KEY} is not set` : undefined;
50
21
  },
51
- // Whatever the daemon has pulled, or whatever the subscription grants.
52
22
  async models(signal, onStatus) {
53
23
  try {
54
- const at = endpoint();
55
- return await listModels(`${at.baseUrl}/v1/models`, headers(at), signal, onStatus, (error) => isRetryableReadFailure(ID, error));
24
+ return await listModels(`${OLLAMA_CLOUD_HOST}/v1/models`, headers(), signal, onStatus, (error) => isRetryableReadFailure(ID, error));
56
25
  }
57
26
  catch (error) {
58
27
  throwProviderError(ID, signal, error);
@@ -62,36 +31,12 @@ export const ollama = {
62
31
  return OLLAMA_EFFORTS;
63
32
  },
64
33
  async contextWindow(model, signal, onStatus) {
65
- const at = endpoint();
66
- const cacheKey = `${at.baseUrl}\u0000${model}`;
67
- const runtime = cachedContext(runtimeContextByEndpoint, cacheKey);
68
- if (runtime !== undefined)
69
- return runtime;
70
- const modelCapacity = cachedContext(modelContextByEndpoint, cacheKey);
71
- const observed = await nativeContextWindow(at, model, modelCapacity, signal, onStatus);
72
- if (observed?.runtime === true) {
73
- rememberContext(runtimeContextByEndpoint, cacheKey, observed.value);
74
- }
75
- else if (observed !== undefined && modelCapacity === undefined) {
76
- rememberContext(modelContextByEndpoint, cacheKey, observed.value);
77
- }
78
- return observed?.value;
79
- },
80
- location: () => {
81
- try {
82
- return endpoint().loopback ? "local" : "cloud";
83
- }
84
- catch {
85
- return "cloud";
86
- }
34
+ return ollamaContextWindow(model, headers(), signal, onStatus);
87
35
  },
88
36
  async send(req) {
89
- const at = endpoint();
90
37
  const effort = requireSupportedEffort(req.model, req.effort, OLLAMA_EFFORTS);
91
- // The OpenAI-compatible endpoint accepts this vocabulary for thinking
92
- // models. Invalid levels are rejected locally instead of being rewritten.
93
38
  try {
94
- const events = await postSse(`${at.baseUrl}/v1/chat/completions`, headers(at), {
39
+ const events = await postSse(`${OLLAMA_CLOUD_HOST}/v1/chat/completions`, headers(), {
95
40
  model: req.model,
96
41
  messages: toWireMessages(req.system, req.messages),
97
42
  tools: req.tools.map(toWireTool),
@@ -111,88 +56,9 @@ export const ollama = {
111
56
  }
112
57
  },
113
58
  };
114
- async function nativeContextWindow(at, model, fallback, signal, onStatus) {
115
- try {
116
- const running = await getJson(`${at.baseUrl}/api/ps`, headers(at), signal, onStatus, (error) => isRetryableReadFailure(ID, error));
117
- const allocated = runningContext(running, model);
118
- if (allocated !== undefined)
119
- return { value: usableContext(allocated), runtime: true };
120
- }
121
- catch (error) {
122
- throwIfAborted(signal, error);
123
- }
124
- if (fallback !== undefined)
125
- return { value: fallback, runtime: false };
126
- try {
127
- const details = await postJson(`${at.baseUrl}/api/show`, headers(at), { model }, signal, onStatus);
128
- const capacity = modelCapacity(details);
129
- return capacity === undefined
130
- ? undefined
131
- : { value: usableContext(capacity), runtime: false };
132
- }
133
- catch (error) {
134
- throwIfAborted(signal, error);
135
- return undefined;
136
- }
137
- }
138
- function cachedContext(cache, key) {
139
- const cached = cache.get(key);
140
- if (cached === undefined)
141
- return undefined;
142
- if (cached.expiresAt > Date.now())
143
- return cached.value;
144
- cache.delete(key);
145
- return undefined;
146
- }
147
- function rememberContext(cache, key, value) {
148
- cache.set(key, { value, expiresAt: Date.now() + CONTEXT_CACHE_MS });
149
- }
150
- function runningContext(value, model) {
151
- if (!record(value) || !Array.isArray(value["models"]))
152
- return undefined;
153
- for (const entry of value["models"]) {
154
- if (!record(entry))
155
- continue;
156
- if (entry["name"] !== model && entry["model"] !== model)
157
- continue;
158
- if (validTokenCount(entry["context_length"]))
159
- return entry["context_length"];
160
- }
161
- return undefined;
162
- }
163
- function modelCapacity(value) {
164
- if (!record(value) || !record(value["model_info"]))
165
- return undefined;
166
- const capacities = Object.entries(value["model_info"])
167
- .filter(([name, count]) => name.endsWith(".context_length") && validTokenCount(count))
168
- .map(([, count]) => count);
169
- return capacities.length === 0 ? undefined : Math.max(...capacities);
170
- }
171
- function validTokenCount(value) {
172
- return typeof value === "number" && Number.isSafeInteger(value) &&
173
- value >= 4_096 && value <= 10_000_000;
174
- }
175
- function usableContext(tokens) {
176
- return Object.freeze({ tokens: Math.floor(tokens * 95 / 100) });
177
- }
178
- function record(value) {
179
- return typeof value === "object" && value !== null && !Array.isArray(value);
180
- }
181
- function throwIfAborted(signal, error) {
182
- if (signal?.aborted === true)
183
- throw error;
184
- }
185
- function endpoint() {
186
- return ollamaConnection();
187
- }
188
- function apiKey() {
189
- return keyFor(KEY);
190
- }
191
- function headers(at) {
192
- if (at.loopback)
193
- return {};
194
- const key = apiKey();
195
- if (key !== undefined)
196
- return { authorization: `Bearer ${key}` };
197
- throw new Error(`${KEY} is not set (required by ${at.baseUrl})`);
59
+ function headers() {
60
+ const key = keyFor(KEY);
61
+ if (key === undefined)
62
+ throw new Error(`${KEY} is not set`);
63
+ return { authorization: `Bearer ${key}` };
198
64
  }
@@ -2,6 +2,7 @@
2
2
  import { randomUUID } from "node:crypto";
3
3
  import { openAICodexAccount } from "../accounts.js";
4
4
  import { openAIAuthorization } from "../openai-account.js";
5
+ import { providerLabel } from "../provider-label.js";
5
6
  import { applicationVersion } from "../version.js";
6
7
  import { EFFORTS, isEffort, requireSupportedEffort } from "../effort.js";
7
8
  import { isRetryableGenerationFailure, isRetryableReadFailure, throwProviderError, } from "./failure.js";
@@ -23,11 +24,10 @@ let contextByModel = new Map();
23
24
  export const openaiCodex = {
24
25
  id: ID,
25
26
  defaultModel: "",
26
- auth: { kind: "oauth", account: ID, label: "ChatGPT" },
27
+ auth: { kind: "oauth", account: ID, label: providerLabel(ID) },
27
28
  blocked() {
28
- return openAICodexAccount() === undefined ? "ChatGPT account is not connected" : undefined;
29
+ return openAICodexAccount() === undefined ? `${providerLabel(ID)} is not connected` : undefined;
29
30
  },
30
- location: () => "cloud",
31
31
  async models(signal, onStatus) {
32
32
  try {
33
33
  const catalog = await loadCatalog(signal, onStatus);
@@ -140,7 +140,7 @@ function headers(authorization, sessionId, requestId) {
140
140
  function modelCatalog(value) {
141
141
  const source = record(value) && Array.isArray(value["models"]) ? value["models"] : undefined;
142
142
  if (source === undefined)
143
- throw new Error("OpenAI Codex did not return a model list");
143
+ throw new Error(`${providerLabel(ID)} did not return a model list`);
144
144
  const seen = new Set();
145
145
  const models = source
146
146
  .slice(0, MAX_CATALOG_ITEMS)
@@ -87,7 +87,6 @@ export const openai = {
87
87
  async contextWindow(model) {
88
88
  return openAIContextWindow(model);
89
89
  },
90
- location: () => "cloud",
91
90
  async send(req) {
92
91
  const key = requireKey();
93
92
  const effort = requireSupportedEffort(req.model, req.effort, openAIEfforts(req.model));
@@ -0,0 +1,55 @@
1
+ // Stable workspace identity and directory anchors shared by session operations.
2
+ import { createHash } from "node:crypto";
3
+ import { realpath } from "node:fs/promises";
4
+ import * as path from "node:path";
5
+ import { assertDirectoryAnchor, captureDirectDirectory, preparePrivateDirectory, } from "../directory-anchor.js";
6
+ import { DIRECTORY_MODE, SESSION_NAME } from "./files.js";
7
+ export class SessionBucket {
8
+ workspaceRoot;
9
+ workspaceDigest;
10
+ anchor;
11
+ #root;
12
+ constructor(workspaceRoot, root, anchor) {
13
+ this.workspaceRoot = workspaceRoot;
14
+ this.workspaceDigest = digestWorkspace(workspaceRoot);
15
+ this.#root = root;
16
+ this.anchor = anchor;
17
+ Object.freeze(this);
18
+ }
19
+ static async open(workspaceRoot, sessionsRoot) {
20
+ const canonical = await realpath(path.resolve(workspaceRoot));
21
+ const digest = digestWorkspace(canonical);
22
+ const root = await preparePrivateDirectory(path.resolve(sessionsRoot), "session storage root", DIRECTORY_MODE);
23
+ const anchor = await preparePrivateDirectory(path.join(root.path, digest), "workspace session directory", DIRECTORY_MODE);
24
+ return new SessionBucket(canonical, root, anchor);
25
+ }
26
+ directory(id) {
27
+ return path.join(this.anchor.path, id);
28
+ }
29
+ async captureSession(id) {
30
+ await this.assert();
31
+ return captureDirectDirectory(this.directory(id), "session directory");
32
+ }
33
+ async assertSession(anchor) {
34
+ await Promise.all([this.assert(), assertDirectoryAnchor(anchor)]);
35
+ }
36
+ async assert() {
37
+ await Promise.all([assertDirectoryAnchor(this.#root), assertDirectoryAnchor(this.anchor)]);
38
+ }
39
+ assertWorkspace(meta, id) {
40
+ if (meta.id !== id || meta.workspaceDigest !== this.workspaceDigest ||
41
+ workspaceKey(meta.workspaceRoot) !== workspaceKey(this.workspaceRoot))
42
+ throw new Error("session belongs to a different workspace");
43
+ }
44
+ }
45
+ function digestWorkspace(workspaceRoot) {
46
+ return createHash("sha256").update(workspaceKey(workspaceRoot)).digest("hex");
47
+ }
48
+ export function workspaceKey(workspaceRoot) {
49
+ const normalized = path.normalize(workspaceRoot);
50
+ return process.platform === "win32" ? normalized.toLocaleLowerCase("en-US") : normalized;
51
+ }
52
+ export function assertSessionId(id) {
53
+ if (!SESSION_NAME.test(id))
54
+ throw new Error("session id is invalid");
55
+ }
@@ -0,0 +1,162 @@
1
+ // Head-tied resume listing and repairable catalogue-file IO.
2
+ import * as path from "node:path";
3
+ import { atomicWrite } from "../atomic.js";
4
+ import { assertDirectoryAnchor } from "../directory-anchor.js";
5
+ import { readStableDirectory } from "../stable-directory.js";
6
+ import { catalogMatches, decodeSessionCatalog, encodeSessionCatalog, sameSessionHead, SESSION_CATALOG_BYTES, SESSION_CATALOG_FILE, SESSION_CHECKPOINT_FILE, } from "./catalog.js";
7
+ import { decodeHead, decodeMeta, SESSION_FILE_LIMITS } from "./codec.js";
8
+ import { FILE_MODE, readJson, SESSION_NAME } from "./files.js";
9
+ import { leaseOwner, pidIsAlive, removeLease } from "./lease.js";
10
+ const MAX_CATALOG_ENTRIES = 4_096;
11
+ const CATALOG_READ_CONCURRENCY = 8;
12
+ export class SessionCatalogIO {
13
+ #bucket;
14
+ #reader;
15
+ constructor(bucket, reader) {
16
+ this.#bucket = bucket;
17
+ this.#reader = reader;
18
+ }
19
+ async list(limit = 32) {
20
+ if (!Number.isSafeInteger(limit) || limit < 1 || limit > 64) {
21
+ throw new Error("session catalogue limit is invalid");
22
+ }
23
+ await this.#bucket.assert();
24
+ const names = await catalogNames(this.#bucket.anchor);
25
+ const catalog = [];
26
+ for (let start = 0; start < names.length; start += CATALOG_READ_CONCURRENCY) {
27
+ const batch = await Promise.all(names.slice(start, start + CATALOG_READ_CONCURRENCY)
28
+ .map(async (id) => {
29
+ return await this.#catalogEntry(id);
30
+ }));
31
+ catalog.push(...batch.filter((entry) => entry !== undefined));
32
+ }
33
+ return catalog
34
+ .sort((left, right) => right.updatedAt.localeCompare(left.updatedAt) || right.id.localeCompare(left.id))
35
+ .slice(0, limit);
36
+ }
37
+ async write(id, catalog, checkpointToken) {
38
+ const directory = this.#bucket.directory(id);
39
+ const directoryAnchor = await this.#bucket.captureSession(id);
40
+ const validateDirectory = async () => {
41
+ await this.#bucket.assertSession(directoryAnchor);
42
+ };
43
+ const validate = async () => {
44
+ await validateDirectory();
45
+ const currentHead = decodeHead(await readJson(path.join(directory, "head.json"), SESSION_FILE_LIMITS.metadataBytes, undefined, validateDirectory));
46
+ if (!sameSessionHead(currentHead, catalog.head)) {
47
+ throw new Error("session head changed while updating its catalogue");
48
+ }
49
+ };
50
+ await atomicWrite(path.join(directory, SESSION_CATALOG_FILE), encodeSessionCatalog(catalog), { mode: FILE_MODE, validate });
51
+ if (checkpointToken !== undefined) {
52
+ await validateDirectory();
53
+ await removeLease(path.join(directory, SESSION_CHECKPOINT_FILE), checkpointToken);
54
+ await validateDirectory();
55
+ }
56
+ }
57
+ async #leaseIsActive(id, directory) {
58
+ if (directory !== undefined)
59
+ await this.#bucket.assertSession(directory);
60
+ else
61
+ await this.#bucket.assert();
62
+ const owner = await leaseOwner(path.join(this.#bucket.directory(id), "active"));
63
+ if (directory !== undefined)
64
+ await this.#bucket.assertSession(directory);
65
+ return owner !== undefined && pidIsAlive(owner.pid);
66
+ }
67
+ async #catalogEntry(id) {
68
+ try {
69
+ const directory = this.#bucket.directory(id);
70
+ const directoryAnchor = await this.#bucket.captureSession(id);
71
+ const validateDirectory = async () => {
72
+ await this.#bucket.assertSession(directoryAnchor);
73
+ };
74
+ const checkpointFile = path.join(directory, SESSION_CHECKPOINT_FILE);
75
+ // A second head read closes the only useful race: a checkpoint landing
76
+ // between the small record reads. A changing marker gets one retry.
77
+ for (let attempt = 0; attempt < 2; attempt++) {
78
+ await validateDirectory();
79
+ const checkpointBefore = await leaseOwner(checkpointFile);
80
+ try {
81
+ const [metaValue, headValue, catalogValue] = await Promise.all([
82
+ readJson(path.join(directory, "meta.json"), SESSION_FILE_LIMITS.metadataBytes, undefined, validateDirectory),
83
+ readJson(path.join(directory, "head.json"), SESSION_FILE_LIMITS.metadataBytes, undefined, validateDirectory),
84
+ readJson(path.join(directory, SESSION_CATALOG_FILE), SESSION_CATALOG_BYTES, undefined, validateDirectory),
85
+ ]);
86
+ const meta = decodeMeta(metaValue);
87
+ const head = decodeHead(headValue);
88
+ const storedCatalog = decodeSessionCatalog(catalogValue);
89
+ this.#bucket.assertWorkspace(meta, id);
90
+ const confirmedHead = decodeHead(await readJson(path.join(directory, "head.json"), SESSION_FILE_LIMITS.metadataBytes, undefined, validateDirectory));
91
+ await validateDirectory();
92
+ const checkpointAfter = await leaseOwner(checkpointFile);
93
+ if (!sameLease(checkpointBefore, checkpointAfter))
94
+ continue;
95
+ if (!sameSessionHead(head, confirmedHead) ||
96
+ !catalogMatches(storedCatalog, meta, head))
97
+ break;
98
+ if (checkpointAfter !== undefined && !pidIsAlive(checkpointAfter.pid))
99
+ break;
100
+ const active = await this.#leaseIsActive(id, directoryAnchor) ||
101
+ checkpointAfter !== undefined;
102
+ return catalogEntry(storedCatalog, active);
103
+ }
104
+ catch {
105
+ break;
106
+ }
107
+ }
108
+ // Missing, stale, or malformed summaries are rebuilt only while the
109
+ // session is idle. Selecting a session still performs this strict load.
110
+ const checkpoint = await leaseOwner(checkpointFile);
111
+ if (await this.#leaseIsActive(id, directoryAnchor) ||
112
+ (checkpoint !== undefined && pidIsAlive(checkpoint.pid)))
113
+ return undefined;
114
+ const repairLease = await this.#reader.claim(id);
115
+ let snapshot;
116
+ try {
117
+ snapshot = await this.#reader.load(id, repairLease);
118
+ await this.write(id, snapshot.catalog, checkpoint?.legacy === true ? undefined : checkpoint?.token).catch(() => undefined);
119
+ }
120
+ finally {
121
+ await repairLease.close();
122
+ }
123
+ const currentCheckpoint = await leaseOwner(checkpointFile);
124
+ const active = await this.#leaseIsActive(id, directoryAnchor) ||
125
+ (currentCheckpoint !== undefined && pidIsAlive(currentCheckpoint.pid));
126
+ return catalogEntry(snapshot.catalog, active);
127
+ }
128
+ catch {
129
+ // Corrupt, unsafe, active-without-a-summary, or foreign data never
130
+ // becomes a resume candidate.
131
+ return undefined;
132
+ }
133
+ }
134
+ }
135
+ async function catalogNames(directory) {
136
+ await assertDirectoryAnchor(directory);
137
+ const inspected = await readStableDirectory(directory.path, directory.path, {
138
+ maxEntries: MAX_CATALOG_ENTRIES + 1,
139
+ });
140
+ if (inspected.capped || inspected.entries.length > MAX_CATALOG_ENTRIES) {
141
+ throw new Error(`session catalogue exceeds ${MAX_CATALOG_ENTRIES} entries`);
142
+ }
143
+ return inspected.entries
144
+ .filter((entry) => entry.kind === "directory" && SESSION_NAME.test(entry.name))
145
+ .map((entry) => entry.name)
146
+ .sort((left, right) => right.localeCompare(left));
147
+ }
148
+ function catalogEntry(catalog, active) {
149
+ if (catalog.resumeNodeId === 0)
150
+ return undefined;
151
+ return Object.freeze({
152
+ id: catalog.id,
153
+ createdAt: catalog.createdAt,
154
+ updatedAt: catalog.head.updatedAt,
155
+ turns: catalog.turns,
156
+ preview: catalog.preview,
157
+ active,
158
+ });
159
+ }
160
+ function sameLease(left, right) {
161
+ return left?.token === right?.token && left?.legacy === right?.legacy;
162
+ }
@@ -11,7 +11,9 @@ import { decodeHead, encodeHead } from "./codec.js";
11
11
  export const SESSION_CATALOG_FILE = "catalog.json";
12
12
  export const SESSION_CHECKPOINT_FILE = ".checkpoint";
13
13
  export const SESSION_CATALOG_BYTES = 4 * 1_024;
14
- const CATALOG_SCHEMA = 1;
14
+ // Older indexes can hide a node committed before a failed head write. Rebuild
15
+ // them through strict loading instead of trusting their apparently current head.
16
+ const CATALOG_SCHEMA = 2;
15
17
  const PREVIEW_CODE_UNITS = 160;
16
18
  export function sessionCatalog(meta, head, conversation) {
17
19
  assertActiveHead(head, conversation);