@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
@@ -3,160 +3,61 @@
3
3
  // Each node is its own atomically replaced file. The head is advanced only
4
4
  // after that node is durable, so a crash leaves either the prior checkpoint or
5
5
  // one strictly recoverable mutation -- never an ambiguous partial history.
6
- import { createHash, randomUUID } from "node:crypto";
7
- import { lstat, opendir, realpath, rename, rm, } from "node:fs/promises";
6
+ import { randomUUID } from "node:crypto";
7
+ import { lstat, rename } from "node:fs/promises";
8
8
  import * as path from "node:path";
9
9
  import { atomicWrite } from "../atomic.js";
10
- import { BoundedFileError, readBoundedText, stableFileExpectation, } from "../bounded-file.js";
11
- import { CONVERSATION_LIMITS, ConversationTree } from "../conversation.js";
12
- import { assertDirectoryAnchor, captureDirectDirectory, createPrivateDirectory, preparePrivateDirectory, } from "../directory-anchor.js";
10
+ import { stableFileExpectation } from "../bounded-file.js";
11
+ import { assertDirectoryAnchor, captureDirectDirectory, createPrivateDirectory, } from "../directory-anchor.js";
13
12
  import { sameFileIdentity } from "../file-identity.js";
14
- import { readStableDirectory } from "../stable-directory.js";
15
13
  import { userDataPath } from "../user-data.js";
16
- import { advanceSessionCatalog, catalogMatches, decodeSessionCatalog, encodeSessionCatalog, sameSessionHead, sessionCatalog, SESSION_CATALOG_BYTES, SESSION_CATALOG_FILE, SESSION_CHECKPOINT_FILE, } from "./catalog.js";
17
- import { decodeHead, decodeMeta, decodeNode, encodeHead, encodeMeta, encodeNode, SESSION_FILE_LIMITS, SESSION_SCHEMA, } from "./codec.js";
14
+ import { assertSessionId, SessionBucket } from "./bucket.js";
15
+ import { SessionCatalogIO } from "./catalog-io.js";
16
+ import { advanceSessionCatalog, encodeSessionCatalog, sameSessionHead, sessionCatalog, SESSION_CATALOG_FILE, SESSION_CHECKPOINT_FILE, } from "./catalog.js";
17
+ import { decodeHead, encodeHead, encodeMeta, encodeNode, SESSION_FILE_LIMITS, SESSION_SCHEMA, } from "./codec.js";
18
18
  import { claimLeaseDirectory, createLeaseDirectory, leaseFromGeneration, leaseOwner, leaseToken, pidIsAlive, removeLegacyLeaseExclusive, removeLease, sessionLease, sessionLeaseOwns, } from "./lease.js";
19
- const DIRECTORY_MODE = 0o700;
20
- const FILE_MODE = 0o600;
21
- const MAX_CATALOG_ENTRIES = 4_096;
22
- const CATALOG_READ_CONCURRENCY = 8;
23
- const MAX_NODE_READ_CONCURRENCY = 8;
24
- const MAX_NODE_READ_IN_FLIGHT_BYTES = 64 * 1_024 * 1_024;
25
- const MAX_SESSION_NODE_BYTES = 192 * 1_024 * 1_024;
26
- const NODE_READ_CONCURRENCY = Math.max(1, Math.min(MAX_NODE_READ_CONCURRENCY, Math.floor(MAX_NODE_READ_IN_FLIGHT_BYTES / SESSION_FILE_LIMITS.nodeBytes)));
27
- const SESSION_NAME = /^[a-zA-Z0-9][a-zA-Z0-9._-]{0,127}$/;
28
- const NODE_NAME = /^(\d{6})\.json$/;
29
- const ATOMIC_NODE_TEMP = /^\.\d{6}\.json\.\d+\.[a-f0-9-]+\.tmp$/;
19
+ import { assertMissingNode, DIRECTORY_MODE, FILE_MODE, nodeName, readJson, removeTemporaryDirectory, } from "./files.js";
20
+ import { loadSession } from "./load.js";
21
+ import { assertSharedNodes, assertSnapshot } from "./snapshot.js";
30
22
  export class DurableSessionStore {
31
23
  workspaceRoot;
32
24
  workspaceDigest;
33
25
  #bucket;
34
- #sessionsAnchor;
35
- #bucketAnchor;
26
+ #catalog;
36
27
  #hooks;
37
28
  #leaseScope = Object.freeze({});
38
- constructor(workspaceRoot, sessionsAnchor, bucketAnchor, hooks) {
39
- this.workspaceRoot = workspaceRoot;
40
- this.workspaceDigest = digestWorkspace(workspaceRoot);
41
- this.#bucket = bucketAnchor.path;
42
- this.#sessionsAnchor = sessionsAnchor;
43
- this.#bucketAnchor = bucketAnchor;
29
+ constructor(bucket, hooks) {
30
+ this.workspaceRoot = bucket.workspaceRoot;
31
+ this.workspaceDigest = bucket.workspaceDigest;
32
+ this.#bucket = bucket;
44
33
  this.#hooks = hooks;
34
+ this.#catalog = new SessionCatalogIO(bucket, {
35
+ claim: (id) => this.claim(id),
36
+ load: (id, lease) => this.load(id, lease),
37
+ });
45
38
  }
46
39
  static async open(workspaceRoot, sessionsRoot = userDataPath("sessions"), hooks = {}) {
47
- const canonical = await realpath(path.resolve(workspaceRoot));
48
- const digest = digestWorkspace(canonical);
49
- const sessionsAnchor = await preparePrivateDirectory(path.resolve(sessionsRoot), "session storage root", DIRECTORY_MODE);
50
- const bucketAnchor = await preparePrivateDirectory(path.join(sessionsAnchor.path, digest), "workspace session directory", DIRECTORY_MODE);
51
- return new DurableSessionStore(canonical, sessionsAnchor, bucketAnchor, hooks);
40
+ return new DurableSessionStore(await SessionBucket.open(workspaceRoot, sessionsRoot), hooks);
52
41
  }
53
42
  async list(limit = 32) {
54
- if (!Number.isSafeInteger(limit) || limit < 1 || limit > 64) {
55
- throw new Error("session catalogue limit is invalid");
56
- }
57
- await this.#ensureBucket();
58
- const names = await catalogNames(this.#bucketAnchor);
59
- const catalog = [];
60
- for (let start = 0; start < names.length; start += CATALOG_READ_CONCURRENCY) {
61
- const batch = await Promise.all(names.slice(start, start + CATALOG_READ_CONCURRENCY)
62
- .map(async (id) => {
63
- return await this.#catalogEntry(id);
64
- }));
65
- catalog.push(...batch.filter((entry) => entry !== undefined));
66
- }
67
- return catalog
68
- .sort((left, right) => right.updatedAt.localeCompare(left.updatedAt) || right.id.localeCompare(left.id))
69
- .slice(0, limit);
43
+ return this.#catalog.list(limit);
70
44
  }
71
45
  async load(id, recoveryLease) {
72
- assertSessionId(id);
73
- await this.#ensureBucket();
74
- const directory = this.#sessionDirectory(id);
75
- const directoryAnchor = await captureDirectDirectory(directory, "session directory");
76
- const nodesAnchor = await captureDirectDirectory(path.join(directory, "nodes"), "session node directory");
77
- const validateSession = async () => {
78
- await Promise.all([
79
- this.#ensureBucket(),
80
- assertDirectoryAnchor(directoryAnchor),
81
- assertDirectoryAnchor(nodesAnchor),
82
- ]);
83
- };
84
- const meta = decodeMeta(await readJson(path.join(directory, "meta.json"), SESSION_FILE_LIMITS.metadataBytes, undefined, validateSession));
85
- assertSessionWorkspace(meta, id, this.workspaceRoot, this.workspaceDigest);
86
- const persistedHead = decodeHead(await readJson(path.join(directory, "head.json"), SESSION_FILE_LIMITS.metadataBytes, undefined, validateSession));
87
- let head = persistedHead;
88
- const stored = await readNodes(nodesAnchor, validateSession);
89
- const ahead = stored.filter((entry) => entry.sequence > head.sequence);
90
- if (ahead.some((entry) => entry.sequence !== head.sequence + 1) || ahead.length > 1) {
91
- throw new Error("session has an ambiguous incomplete checkpoint");
92
- }
93
- const byId = new Map(stored.map((entry) => [entry.node.id, entry]));
94
- const headed = byId.get(head.nodeId);
95
- if (headed === undefined)
96
- throw new Error("session head is missing its conversation node");
97
- if (ahead.length === 0) {
98
- if (headed.sequence !== head.sequence || headed.node.revision !== head.revision ||
99
- headed.node.parentId !== head.parentId) {
100
- throw new Error("session head does not match its conversation node");
101
- }
102
- }
103
- else {
104
- const candidate = ahead[0];
105
- const replacesHead = candidate.node.id === head.nodeId &&
106
- candidate.node.parentId === head.parentId &&
107
- candidate.node.revision === head.revision + 1;
108
- const candidateParent = byId.get(candidate.node.parentId);
109
- const extendsTree = candidate.node.id === stored.length &&
110
- candidate.node.revision === 1 && candidateParent !== undefined &&
111
- candidateParent.sequence <= head.sequence;
112
- if (!replacesHead && !extendsTree) {
113
- throw new Error("session checkpoint cannot be recovered safely");
114
- }
115
- if (recoveryLease === undefined ||
116
- !sessionLeaseOwns(recoveryLease, id, this.#leaseScope)) {
117
- throw new Error("session recovery requires exclusive ownership");
118
- }
119
- await recoveryLease.assertOwned();
120
- head = Object.freeze({
121
- version: SESSION_SCHEMA,
122
- sequence: candidate.sequence,
123
- nodeId: candidate.node.id,
124
- parentId: candidate.node.parentId,
125
- revision: candidate.node.revision,
126
- updatedAt: candidate.updatedAt,
127
- });
128
- await atomicWrite(path.join(directory, "head.json"), encodeHead(head), {
129
- mode: FILE_MODE,
130
- validate: async (phase) => {
131
- await validateSession();
132
- if (phase !== "before-rename")
133
- return;
134
- await recoveryLease.assertOwned();
135
- const current = decodeHead(await readJson(path.join(directory, "head.json"), SESSION_FILE_LIMITS.metadataBytes, undefined, validateSession));
136
- if (!sameSessionHead(current, persistedHead)) {
137
- throw new Error("session head changed while recovering its checkpoint");
138
- }
139
- },
140
- });
141
- }
142
- const nodes = stored.map((entry) => entry.node);
143
- const conversation = ConversationTree.restore(nodes, head.nodeId);
144
- const catalog = sessionCatalog(meta, head, conversation);
145
- return Object.freeze({ meta, head, conversation, catalog });
46
+ return loadSession(this.#bucket, id, this.#leaseScope, recoveryLease);
146
47
  }
147
48
  async publish(conversation, claim, reservedId) {
148
49
  const active = conversation.activeNode;
149
50
  if (active === undefined)
150
51
  throw new Error("an empty conversation cannot be persisted");
151
- await this.#ensureBucket();
52
+ await this.#bucket.assert();
152
53
  const now = new Date().toISOString();
153
54
  const id = reservedId ?? reserveSessionId(now);
154
55
  assertSessionId(id);
155
56
  const token = claim === true ? leaseToken() : undefined;
156
57
  let leaseGeneration;
157
58
  let temporaryAnchor;
158
- const temporary = path.join(this.#bucket, `.${id}.${randomUUID()}.tmp`);
159
- const target = this.#sessionDirectory(id);
59
+ const temporary = path.join(this.#bucket.anchor.path, `.${id}.${randomUUID()}.tmp`);
60
+ const target = this.#bucket.directory(id);
160
61
  const meta = Object.freeze({
161
62
  version: SESSION_SCHEMA,
162
63
  id,
@@ -178,7 +79,7 @@ export class DurableSessionStore {
178
79
  const nodes = path.join(temporary, "nodes");
179
80
  const nodesAnchor = await createPrivateDirectory(nodes, "temporary session node directory", DIRECTORY_MODE);
180
81
  const validateTemporary = async () => {
181
- await this.#ensureBucket();
82
+ await this.#bucket.assert();
182
83
  await assertDirectoryAnchor(temporaryAnchor);
183
84
  await assertDirectoryAnchor(nodesAnchor);
184
85
  };
@@ -200,14 +101,14 @@ export class DurableSessionStore {
200
101
  }
201
102
  await validateTemporary();
202
103
  await rename(temporary, target);
203
- await this.#ensureBucket();
104
+ await this.#bucket.assert();
204
105
  const targetAnchor = await captureDirectDirectory(target, "session directory");
205
106
  if (!sameFileIdentity(temporaryAnchor.identity, targetAnchor.identity)) {
206
107
  throw new Error("session directory changed while publishing");
207
108
  }
208
109
  }
209
110
  catch (error) {
210
- await removeTemporaryDirectory(temporary, this.#bucketAnchor, temporaryAnchor)
111
+ await removeTemporaryDirectory(temporary, this.#bucket.anchor, temporaryAnchor)
211
112
  .catch(() => undefined);
212
113
  throw error;
213
114
  }
@@ -226,14 +127,14 @@ export class DurableSessionStore {
226
127
  throw new Error("session checkpoint requires exclusive ownership");
227
128
  }
228
129
  await lease.assertOwned();
229
- const directory = this.#sessionDirectory(id);
230
- const directoryAnchor = await this.#sessionAnchor(id);
130
+ const directory = this.#bucket.directory(id);
131
+ const directoryAnchor = await this.#bucket.captureSession(id);
231
132
  const nodesDirectory = path.join(directory, "nodes");
232
133
  const nodesAnchor = await captureDirectDirectory(nodesDirectory, "session node directory");
233
134
  const validateNodesDirectory = async () => {
234
135
  await Promise.all([
235
136
  lease.assertOwned(),
236
- this.#ensureBucket(),
137
+ this.#bucket.assert(),
237
138
  assertDirectoryAnchor(directoryAnchor),
238
139
  assertDirectoryAnchor(nodesAnchor),
239
140
  ]);
@@ -305,6 +206,13 @@ export class DurableSessionStore {
305
206
  if (extendsTree) {
306
207
  await assertMissingNode(path.join(nodesDirectory, nodeName(active.id)), validateNodesDirectory);
307
208
  }
209
+ // Publish the next head's index first. Until the head commits, listing
210
+ // treats it as suspect and loads the tree after ownership is released.
211
+ // A failed node/head write must never leave an apparently current index.
212
+ await atomicWrite(path.join(directory, SESSION_CATALOG_FILE), encodeSessionCatalog(catalog), {
213
+ mode: FILE_MODE,
214
+ validate: async () => assertBaseHead(),
215
+ });
308
216
  await atomicWrite(path.join(nodesDirectory, nodeName(active.id)), encodeNode(active, head.sequence, now), {
309
217
  mode: FILE_MODE,
310
218
  validate: async (phase) => {
@@ -318,7 +226,6 @@ export class DurableSessionStore {
318
226
  mode: FILE_MODE,
319
227
  validate: async () => assertBaseHead(),
320
228
  });
321
- await this.#writeCatalog(previous.meta.id, catalog, checkpointToken).catch(() => undefined);
322
229
  return Object.freeze({ meta: previous.meta, head, conversation, catalog });
323
230
  }
324
231
  catch (error) {
@@ -339,10 +246,10 @@ export class DurableSessionStore {
339
246
  }
340
247
  async claim(id) {
341
248
  assertSessionId(id);
342
- const directory = this.#sessionDirectory(id);
343
- const directoryAnchor = await this.#sessionAnchor(id);
249
+ const directory = this.#bucket.directory(id);
250
+ const directoryAnchor = await this.#bucket.captureSession(id);
344
251
  const validateDirectory = async () => {
345
- await this.#assertSessionAnchor(directoryAnchor);
252
+ await this.#bucket.assertSession(directoryAnchor);
346
253
  };
347
254
  await validateDirectory();
348
255
  const file = path.join(directory, "active");
@@ -386,333 +293,7 @@ export class DurableSessionStore {
386
293
  }
387
294
  return owned;
388
295
  }
389
- #sessionDirectory(id) {
390
- return path.join(this.#bucket, id);
391
- }
392
- async #sessionAnchor(id) {
393
- await this.#ensureBucket();
394
- return captureDirectDirectory(this.#sessionDirectory(id), "session directory");
395
- }
396
- async #assertSessionAnchor(anchor) {
397
- await Promise.all([this.#ensureBucket(), assertDirectoryAnchor(anchor)]);
398
- }
399
- async #ensureBucket() {
400
- await Promise.all([
401
- assertDirectoryAnchor(this.#sessionsAnchor),
402
- assertDirectoryAnchor(this.#bucketAnchor),
403
- ]);
404
- }
405
- async #leaseIsActive(id, directory) {
406
- if (directory !== undefined)
407
- await this.#assertSessionAnchor(directory);
408
- else
409
- await this.#ensureBucket();
410
- const owner = await leaseOwner(path.join(this.#sessionDirectory(id), "active"));
411
- if (directory !== undefined)
412
- await this.#assertSessionAnchor(directory);
413
- return owner !== undefined && pidIsAlive(owner.pid);
414
- }
415
- async #catalogEntry(id) {
416
- try {
417
- const directory = this.#sessionDirectory(id);
418
- const directoryAnchor = await this.#sessionAnchor(id);
419
- const validateDirectory = async () => {
420
- await this.#assertSessionAnchor(directoryAnchor);
421
- };
422
- const checkpointFile = path.join(directory, SESSION_CHECKPOINT_FILE);
423
- // A second head read closes the only useful race: a checkpoint landing
424
- // between the small record reads. A changing marker gets one retry.
425
- for (let attempt = 0; attempt < 2; attempt++) {
426
- await validateDirectory();
427
- const checkpointBefore = await leaseOwner(checkpointFile);
428
- try {
429
- const [metaValue, headValue, catalogValue] = await Promise.all([
430
- readJson(path.join(directory, "meta.json"), SESSION_FILE_LIMITS.metadataBytes, undefined, validateDirectory),
431
- readJson(path.join(directory, "head.json"), SESSION_FILE_LIMITS.metadataBytes, undefined, validateDirectory),
432
- readJson(path.join(directory, SESSION_CATALOG_FILE), SESSION_CATALOG_BYTES, undefined, validateDirectory),
433
- ]);
434
- const meta = decodeMeta(metaValue);
435
- const head = decodeHead(headValue);
436
- const storedCatalog = decodeSessionCatalog(catalogValue);
437
- assertSessionWorkspace(meta, id, this.workspaceRoot, this.workspaceDigest);
438
- const confirmedHead = decodeHead(await readJson(path.join(directory, "head.json"), SESSION_FILE_LIMITS.metadataBytes, undefined, validateDirectory));
439
- await validateDirectory();
440
- const checkpointAfter = await leaseOwner(checkpointFile);
441
- if (!sameLease(checkpointBefore, checkpointAfter))
442
- continue;
443
- if (!sameSessionHead(head, confirmedHead) ||
444
- !catalogMatches(storedCatalog, meta, head))
445
- break;
446
- if (checkpointAfter !== undefined && !pidIsAlive(checkpointAfter.pid))
447
- break;
448
- const active = await this.#leaseIsActive(id, directoryAnchor) ||
449
- checkpointAfter !== undefined;
450
- return catalogEntry(storedCatalog, active);
451
- }
452
- catch {
453
- break;
454
- }
455
- }
456
- // Missing, stale, or malformed summaries are rebuilt only while the
457
- // session is idle. Selecting a session still performs this strict load.
458
- const checkpoint = await leaseOwner(checkpointFile);
459
- if (await this.#leaseIsActive(id, directoryAnchor) ||
460
- (checkpoint !== undefined && pidIsAlive(checkpoint.pid)))
461
- return undefined;
462
- const repairLease = await this.claim(id);
463
- let snapshot;
464
- try {
465
- snapshot = await this.load(id, repairLease);
466
- await this.#writeCatalog(id, snapshot.catalog, checkpoint?.legacy === true ? undefined : checkpoint?.token).catch(() => undefined);
467
- }
468
- finally {
469
- await repairLease.close();
470
- }
471
- const currentCheckpoint = await leaseOwner(checkpointFile);
472
- const active = await this.#leaseIsActive(id, directoryAnchor) ||
473
- (currentCheckpoint !== undefined && pidIsAlive(currentCheckpoint.pid));
474
- return catalogEntry(snapshot.catalog, active);
475
- }
476
- catch {
477
- // Corrupt, unsafe, active-without-a-summary, or foreign data never
478
- // becomes a resume candidate.
479
- return undefined;
480
- }
481
- }
482
- async #writeCatalog(id, catalog, checkpointToken) {
483
- const directory = this.#sessionDirectory(id);
484
- const directoryAnchor = await this.#sessionAnchor(id);
485
- const validateDirectory = async () => {
486
- await this.#assertSessionAnchor(directoryAnchor);
487
- };
488
- const validate = async () => {
489
- await validateDirectory();
490
- const currentHead = decodeHead(await readJson(path.join(directory, "head.json"), SESSION_FILE_LIMITS.metadataBytes, undefined, validateDirectory));
491
- if (!sameSessionHead(currentHead, catalog.head)) {
492
- throw new Error("session head changed while updating its catalogue");
493
- }
494
- };
495
- await atomicWrite(path.join(directory, SESSION_CATALOG_FILE), encodeSessionCatalog(catalog), { mode: FILE_MODE, validate });
496
- if (checkpointToken !== undefined) {
497
- await validateDirectory();
498
- await removeLease(path.join(directory, SESSION_CHECKPOINT_FILE), checkpointToken);
499
- await validateDirectory();
500
- }
501
- }
502
- }
503
- async function catalogNames(directory) {
504
- await assertDirectoryAnchor(directory);
505
- const inspected = await readStableDirectory(directory.path, directory.path, {
506
- maxEntries: MAX_CATALOG_ENTRIES + 1,
507
- });
508
- if (inspected.capped || inspected.entries.length > MAX_CATALOG_ENTRIES) {
509
- throw new Error(`session catalogue exceeds ${MAX_CATALOG_ENTRIES} entries`);
510
- }
511
- return inspected.entries
512
- .filter((entry) => entry.kind === "directory" && SESSION_NAME.test(entry.name))
513
- .map((entry) => entry.name)
514
- .sort((left, right) => right.localeCompare(left));
515
- }
516
- function catalogEntry(catalog, active) {
517
- if (catalog.resumeNodeId === 0)
518
- return undefined;
519
- return Object.freeze({
520
- id: catalog.id,
521
- createdAt: catalog.createdAt,
522
- updatedAt: catalog.head.updatedAt,
523
- turns: catalog.turns,
524
- preview: catalog.preview,
525
- active,
526
- });
527
- }
528
- function sameLease(left, right) {
529
- return left?.token === right?.token && left?.legacy === right?.legacy;
530
- }
531
- function assertSessionWorkspace(meta, id, workspaceRoot, workspaceDigest) {
532
- if (meta.id !== id || meta.workspaceDigest !== workspaceDigest ||
533
- workspaceKey(meta.workspaceRoot) !== workspaceKey(workspaceRoot))
534
- throw new Error("session belongs to a different workspace");
535
- }
536
- function assertSharedNodes(previous, next, replacedId) {
537
- for (const node of previous.nodes) {
538
- if (node.id === replacedId)
539
- continue;
540
- const candidate = next.node(node.id);
541
- if (candidate !== node) {
542
- throw new Error("session checkpoint rewrites prior conversation history");
543
- }
544
- }
545
- }
546
- function assertSnapshot(snapshot, workspaceRoot, workspaceDigest) {
547
- encodeMeta(snapshot.meta);
548
- encodeHead(snapshot.head);
549
- encodeSessionCatalog(snapshot.catalog);
550
- assertSessionId(snapshot.meta.id);
551
- if (snapshot.meta.workspaceDigest !== workspaceDigest ||
552
- workspaceKey(snapshot.meta.workspaceRoot) !== workspaceKey(workspaceRoot))
553
- throw new Error("session snapshot belongs to a different workspace");
554
- const active = snapshot.conversation.activeNode;
555
- if (active === undefined ||
556
- active.id !== snapshot.head.nodeId ||
557
- active.parentId !== snapshot.head.parentId ||
558
- active.revision !== snapshot.head.revision ||
559
- snapshot.head.sequence < snapshot.conversation.nodes.length)
560
- throw new Error("session snapshot does not match its verified head");
561
- if (!catalogMatches(snapshot.catalog, snapshot.meta, snapshot.head)) {
562
- throw new Error("session snapshot does not match its verified catalogue");
563
- }
564
- }
565
- async function assertMissingNode(file, validate) {
566
- await validate?.();
567
- try {
568
- await lstat(file);
569
- }
570
- catch (error) {
571
- if (error.code === "ENOENT") {
572
- await validate?.();
573
- return;
574
- }
575
- throw error;
576
- }
577
- await validate?.();
578
- throw new Error("session has an incomplete node outside its verified snapshot");
579
- }
580
- async function readNodes(directory, validate) {
581
- await validate();
582
- await assertDirectoryAnchor(directory);
583
- const names = [];
584
- let entries = 0;
585
- const handle = await opendir(directory.path);
586
- for await (const entry of handle) {
587
- entries++;
588
- if (entries > CONVERSATION_LIMITS.nodes + 64) {
589
- throw new Error("session node directory contains unsupported data");
590
- }
591
- if (!entry.isFile() || (!NODE_NAME.test(entry.name) && !ATOMIC_NODE_TEMP.test(entry.name))) {
592
- throw new Error("session node directory contains unsupported data");
593
- }
594
- if (NODE_NAME.test(entry.name))
595
- names.push(entry.name);
596
- }
597
- await validate();
598
- await assertDirectoryAnchor(directory);
599
- names.sort();
600
- if (names.length === 0 || names.length > CONVERSATION_LIMITS.nodes) {
601
- throw new Error("session has an invalid conversation size");
602
- }
603
- const files = [];
604
- let storedBytes = 0;
605
- for (let index = 0; index < names.length; index++) {
606
- const name = names[index];
607
- const id = Number(NODE_NAME.exec(name)?.[1]);
608
- if (id !== index + 1) {
609
- throw new Error("session conversation nodes are not contiguous");
610
- }
611
- const details = await lstat(path.join(directory.path, name), { bigint: true });
612
- if (details.isSymbolicLink() || !details.isFile() || details.size < 0n ||
613
- details.size > BigInt(SESSION_FILE_LIMITS.nodeBytes))
614
- throw new Error("session node file is unsafe or too large");
615
- storedBytes += Number(details.size);
616
- if (storedBytes > MAX_SESSION_NODE_BYTES) {
617
- throw new Error("session node files exceed their aggregate storage limit");
618
- }
619
- files.push({ name, id, expected: stableFileExpectation(details) });
620
- }
621
- await validate();
622
- const stored = [];
623
- const sequences = new Set();
624
- let messageCodeUnits = 0;
625
- let transcriptCodeUnits = 0;
626
- let contextCodeUnits = 0;
627
- for (let start = 0; start < files.length; start += NODE_READ_CONCURRENCY) {
628
- const decoded = await Promise.all(files.slice(start, start + NODE_READ_CONCURRENCY)
629
- .map(async ({ name, id, expected }) => {
630
- const entry = decodeNode(await readJson(path.join(directory.path, name), SESSION_FILE_LIMITS.nodeBytes, expected));
631
- if (entry.node.id !== id) {
632
- throw new Error("session conversation node identity is invalid");
633
- }
634
- return entry;
635
- }));
636
- for (const entry of decoded) {
637
- if (sequences.has(entry.sequence)) {
638
- throw new Error("session conversation node identity is invalid");
639
- }
640
- messageCodeUnits += JSON.stringify(entry.node.messages).length;
641
- transcriptCodeUnits += JSON.stringify(entry.node.blocks).length;
642
- contextCodeUnits += entry.node.context?.summary.length ?? 0;
643
- if (messageCodeUnits > CONVERSATION_LIMITS.messageCodeUnits ||
644
- transcriptCodeUnits > CONVERSATION_LIMITS.transcriptCodeUnits ||
645
- contextCodeUnits > CONVERSATION_LIMITS.contextCodeUnits) {
646
- throw new Error("session conversation exceeds its aggregate limit");
647
- }
648
- sequences.add(entry.sequence);
649
- stored.push(entry);
650
- }
651
- }
652
- await validate();
653
- return stored;
654
- }
655
- async function readJson(file, limit, expected, validate) {
656
- try {
657
- return JSON.parse(await readBoundedText(file, limit, {
658
- label: "session file",
659
- expected,
660
- validate,
661
- }));
662
- }
663
- catch (error) {
664
- if (error instanceof SyntaxError) {
665
- throw new Error("session file is not valid JSON");
666
- }
667
- if (error instanceof BoundedFileError) {
668
- throw new Error("session file is unsafe or too large, or changed while opening");
669
- }
670
- throw error;
671
- }
672
- }
673
- async function removeTemporaryDirectory(directory, bucket, expected) {
674
- const relative = path.relative(bucket.path, directory);
675
- if (relative === "" || relative.startsWith("..") || path.isAbsolute(relative) ||
676
- !path.basename(directory).startsWith(".") || !path.basename(directory).endsWith(".tmp"))
677
- throw new Error("refusing to remove an unverified session directory");
678
- await assertDirectoryAnchor(bucket);
679
- let observed;
680
- try {
681
- observed = await captureDirectDirectory(directory, "temporary session directory");
682
- }
683
- catch (error) {
684
- if (error.code === "ENOENT")
685
- return;
686
- throw error;
687
- }
688
- if (expected !== undefined &&
689
- !sameFileIdentity(expected.identity, observed.identity))
690
- throw new Error("refusing to remove a replaced session directory");
691
- const quarantine = path.join(bucket.path, `.discard-${process.pid}-${randomUUID()}.tmp`);
692
- await assertDirectoryAnchor(bucket);
693
- await rename(directory, quarantine);
694
- const moved = await captureDirectDirectory(quarantine, "discarded session directory");
695
- if (!sameFileIdentity(observed.identity, moved.identity)) {
696
- throw new Error("session cleanup target changed during quarantine");
697
- }
698
- await assertDirectoryAnchor(bucket);
699
- await assertDirectoryAnchor(moved);
700
- await rm(quarantine, { recursive: true });
701
- }
702
- function nodeName(id) {
703
- return `${String(id).padStart(6, "0")}.json`;
704
296
  }
705
297
  export function reserveSessionId(now = new Date().toISOString()) {
706
298
  return `${now.replace(/[-:.]/g, "").replace("Z", "Z")}-${randomUUID()}`;
707
299
  }
708
- function digestWorkspace(workspaceRoot) {
709
- return createHash("sha256").update(workspaceKey(workspaceRoot)).digest("hex");
710
- }
711
- function workspaceKey(workspaceRoot) {
712
- const normalized = path.normalize(workspaceRoot);
713
- return process.platform === "win32" ? normalized.toLocaleLowerCase("en-US") : normalized;
714
- }
715
- function assertSessionId(id) {
716
- if (!SESSION_NAME.test(id))
717
- throw new Error("session id is invalid");
718
- }
@@ -69,26 +69,31 @@ function settingsItems(values) {
69
69
  option: {
70
70
  label: "model",
71
71
  value: `${providerLabel(values.provider)} · ${values.model || "choose a model"}`,
72
+ description: "Choose the provider and model used for subsequent requests.",
72
73
  },
73
74
  },
74
- { action: "effort", option: { label: "effort", value: values.effort } },
75
+ { action: "effort", option: { label: "effort", value: values.effort,
76
+ description: "Set the saved reasoning effort for model requests." } },
75
77
  ...(values.maxTokens === undefined
76
78
  ? []
77
79
  : [{
78
80
  action: "maxTokens",
79
- option: { label: "max output tokens", value: String(values.maxTokens) },
81
+ option: { label: "max output tokens", value: String(values.maxTokens),
82
+ description: "Limit the output tokens requested from the model in each response." },
80
83
  }]),
81
84
  {
82
85
  action: "compactionPercent",
83
- option: { label: "context compaction", value: `${values.compactionPercent}%` },
86
+ option: { label: "context compaction", value: `${values.compactionPercent}%`,
87
+ description: "Compact model context near this share of its usable window. The transcript stays complete." },
84
88
  },
85
89
  {
86
90
  action: "reducedMotion",
87
- option: { label: "reduced motion", value: values.reducedMotion ? "on" : "off" },
91
+ option: { label: "reduced motion", value: values.reducedMotion ? "on" : "off",
92
+ description: "Keep decorative movement still while output continues to update." },
88
93
  },
89
94
  {
90
95
  action: "providers",
91
- option: { label: "providers", hint: "manage connections" },
96
+ option: { label: "providers", hint: "manage connections", description: "Manage API keys and account connections." },
92
97
  },
93
98
  ];
94
99
  }