@giovannijecha/jecode 0.8.2 → 0.8.4

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 (53) hide show
  1. package/README.md +22 -280
  2. package/assets/wordmark-steel.svg +3 -0
  3. package/dist/accounts.js +17 -13
  4. package/dist/batch.js +66 -6
  5. package/dist/config.js +6 -3
  6. package/dist/context/budget.js +13 -1
  7. package/dist/context/compactor.js +5 -4
  8. package/dist/context/estimate.js +43 -1
  9. package/dist/context/manual.js +8 -4
  10. package/dist/context/policy.js +68 -18
  11. package/dist/controller-request.js +8 -8
  12. package/dist/controller.js +6 -1
  13. package/dist/conversation.js +94 -33
  14. package/dist/credential-safety.js +56 -9
  15. package/dist/credentials.js +32 -4
  16. package/dist/input-boundary.js +80 -0
  17. package/dist/main.js +4 -1
  18. package/dist/openai-oauth-callback.js +1 -1
  19. package/dist/process-shutdown.js +52 -0
  20. package/dist/provider-commands.js +43 -6
  21. package/dist/provider-errors.js +59 -1
  22. package/dist/providers/anthropic-stream.js +24 -20
  23. package/dist/providers/anthropic-wire.js +7 -2
  24. package/dist/providers/http.js +4 -34
  25. package/dist/providers/ollama-wire.js +7 -15
  26. package/dist/providers/ollama.js +1 -0
  27. package/dist/providers/openai-codex.js +1 -1
  28. package/dist/providers/openai-stream.js +43 -7
  29. package/dist/providers/openai-wire.js +2 -16
  30. package/dist/providers/openai.js +1 -1
  31. package/dist/providers/sse.js +45 -17
  32. package/dist/providers/tool-input.js +17 -0
  33. package/dist/sessions/catalog.js +199 -0
  34. package/dist/sessions/codec.js +2 -1
  35. package/dist/sessions/lease.js +7 -0
  36. package/dist/sessions/runtime.js +11 -3
  37. package/dist/sessions/store.js +171 -78
  38. package/dist/settings.js +10 -5
  39. package/dist/start.js +12 -2
  40. package/dist/text-boundary.js +2 -0
  41. package/dist/tools/search.js +27 -18
  42. package/dist/tui/app-input.js +40 -5
  43. package/dist/tui/app-state.js +1 -0
  44. package/dist/tui/app-workflows.js +1 -0
  45. package/dist/tui/app.js +11 -3
  46. package/dist/tui/editor.js +2 -0
  47. package/dist/tui/keys.js +64 -5
  48. package/dist/tui/overlay.js +12 -4
  49. package/dist/tui/picker.js +2 -0
  50. package/dist/tui/screen.js +5 -17
  51. package/dist/user-store.js +54 -0
  52. package/package.json +7 -3
  53. /package/{docs/assets/brand → assets}/jeco-256.png +0 -0
@@ -4,12 +4,12 @@
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
6
  import { createHash, randomUUID } from "node:crypto";
7
- import { chmod, lstat, mkdir, open, opendir, readFile, readdir, realpath, rename, rm, stat, } from "node:fs/promises";
7
+ import { chmod, lstat, mkdir, open, opendir, readFile, readdir, realpath, rename, rm, } from "node:fs/promises";
8
8
  import * as path from "node:path";
9
9
  import { atomicWrite } from "../atomic.js";
10
10
  import { CONVERSATION_LIMITS, ConversationTree } from "../conversation.js";
11
- import { leadingText } from "../text-boundary.js";
12
11
  import { userDataPath } from "../user-data.js";
12
+ import { advanceSessionCatalog, catalogMatches, decodeSessionCatalog, encodeSessionCatalog, sameSessionHead, sessionCatalog, SESSION_CATALOG_BYTES, SESSION_CATALOG_FILE, SESSION_CHECKPOINT_FILE, } from "./catalog.js";
13
13
  import { decodeHead, decodeMeta, decodeNode, encodeHead, encodeMeta, encodeNode, SESSION_FILE_LIMITS, SESSION_SCHEMA, } from "./codec.js";
14
14
  import { leaseOwner, leaseToken, pidIsAlive, removeLease, sessionLease, } from "./lease.js";
15
15
  const DIRECTORY_MODE = 0o700;
@@ -44,24 +44,7 @@ export class DurableSessionStore {
44
44
  for (let start = 0; start < names.length; start += CATALOG_READ_CONCURRENCY) {
45
45
  const batch = await Promise.all(names.slice(start, start + CATALOG_READ_CONCURRENCY)
46
46
  .map(async (id) => {
47
- try {
48
- const snapshot = await this.load(id);
49
- const conversation = snapshot.conversation.latestResumable();
50
- if (conversation === undefined)
51
- return undefined;
52
- return {
53
- id,
54
- createdAt: snapshot.meta.createdAt,
55
- updatedAt: snapshot.head.updatedAt,
56
- turns: selectedTurnCount(conversation),
57
- preview: firstUserText(conversation),
58
- active: await this.#leaseIsActive(id),
59
- };
60
- }
61
- catch {
62
- // Corrupt or foreign data never becomes a resume candidate.
63
- return undefined;
64
- }
47
+ return await this.#catalogEntry(id);
65
48
  }));
66
49
  catalog.push(...batch.filter((entry) => entry !== undefined));
67
50
  }
@@ -74,9 +57,7 @@ export class DurableSessionStore {
74
57
  const directory = this.#sessionDirectory(id);
75
58
  await assertDirectory(directory);
76
59
  const meta = decodeMeta(await readJson(path.join(directory, "meta.json"), SESSION_FILE_LIMITS.metadataBytes));
77
- if (meta.id !== id || meta.workspaceDigest !== this.workspaceDigest ||
78
- workspaceKey(meta.workspaceRoot) !== workspaceKey(this.workspaceRoot))
79
- throw new Error("session belongs to a different workspace");
60
+ assertSessionWorkspace(meta, id, this.workspaceRoot, this.workspaceDigest);
80
61
  let head = decodeHead(await readJson(path.join(directory, "head.json"), SESSION_FILE_LIMITS.metadataBytes));
81
62
  const stored = await readNodes(path.join(directory, "nodes"));
82
63
  const ahead = stored.filter((entry) => entry.sequence > head.sequence);
@@ -105,19 +86,23 @@ export class DurableSessionStore {
105
86
  if (!replacesHead && !extendsTree) {
106
87
  throw new Error("session checkpoint cannot be recovered safely");
107
88
  }
108
- head = {
89
+ head = Object.freeze({
109
90
  version: SESSION_SCHEMA,
110
91
  sequence: candidate.sequence,
111
92
  nodeId: candidate.node.id,
112
93
  parentId: candidate.node.parentId,
113
94
  revision: candidate.node.revision,
114
95
  updatedAt: candidate.updatedAt,
115
- };
116
- await atomicWrite(path.join(directory, "head.json"), encodeHead(head), { mode: FILE_MODE });
96
+ });
97
+ await atomicWrite(path.join(directory, "head.json"), encodeHead(head), {
98
+ mode: FILE_MODE,
99
+ validate: async () => assertDirectory(directory),
100
+ });
117
101
  }
118
102
  const nodes = stored.map((entry) => entry.node);
119
103
  const conversation = ConversationTree.restore(nodes, head.nodeId);
120
- return Object.freeze({ meta, head, conversation });
104
+ const catalog = sessionCatalog(meta, head, conversation);
105
+ return Object.freeze({ meta, head, conversation, catalog });
121
106
  }
122
107
  async publish(conversation, claim) {
123
108
  const active = conversation.activeNode;
@@ -129,21 +114,22 @@ export class DurableSessionStore {
129
114
  const token = claim === true ? leaseToken() : undefined;
130
115
  const temporary = path.join(this.#bucket, `.${id}.${randomUUID()}.tmp`);
131
116
  const target = this.#sessionDirectory(id);
132
- const meta = {
117
+ const meta = Object.freeze({
133
118
  version: SESSION_SCHEMA,
134
119
  id,
135
120
  workspaceRoot: this.workspaceRoot,
136
121
  workspaceDigest: this.workspaceDigest,
137
122
  createdAt: now,
138
- };
139
- const head = {
123
+ });
124
+ const head = Object.freeze({
140
125
  version: SESSION_SCHEMA,
141
126
  sequence: conversation.nodes.length,
142
127
  nodeId: active.id,
143
128
  parentId: active.parentId,
144
129
  revision: active.revision,
145
130
  updatedAt: now,
146
- };
131
+ });
132
+ const catalog = sessionCatalog(meta, head, conversation);
147
133
  try {
148
134
  await makePrivateDirectory(temporary);
149
135
  const nodes = path.join(temporary, "nodes");
@@ -154,6 +140,7 @@ export class DurableSessionStore {
154
140
  }
155
141
  await atomicWrite(path.join(temporary, "meta.json"), encodeMeta(meta), { mode: FILE_MODE });
156
142
  await atomicWrite(path.join(temporary, "head.json"), encodeHead(head), { mode: FILE_MODE });
143
+ await atomicWrite(path.join(temporary, SESSION_CATALOG_FILE), encodeSessionCatalog(catalog), { mode: FILE_MODE });
157
144
  if (token !== undefined) {
158
145
  await atomicWrite(path.join(temporary, "active"), token, { mode: FILE_MODE });
159
146
  }
@@ -163,18 +150,32 @@ export class DurableSessionStore {
163
150
  await removeTemporaryDirectory(temporary, this.#bucket);
164
151
  throw error;
165
152
  }
166
- const snapshot = Object.freeze({ meta, head, conversation });
153
+ const snapshot = Object.freeze({ meta, head, conversation, catalog });
167
154
  if (token === undefined)
168
155
  return snapshot;
169
156
  const lease = sessionLease(id, path.join(target, "active"), token);
170
157
  return Object.freeze({ ...snapshot, lease });
171
158
  }
172
- async checkpoint(id, conversation) {
173
- const previous = await this.load(id);
159
+ async checkpoint(previous, conversation) {
160
+ assertSnapshot(previous, this.workspaceRoot, this.workspaceDigest);
161
+ const id = previous.meta.id;
162
+ const directory = this.#sessionDirectory(id);
163
+ await assertDirectory(directory);
164
+ const nodesDirectory = path.join(directory, "nodes");
165
+ const validateNodesDirectory = async () => {
166
+ await assertDirectory(directory);
167
+ await assertDirectory(nodesDirectory);
168
+ };
169
+ await validateNodesDirectory();
170
+ const currentHead = decodeHead(await readJson(path.join(directory, "head.json"), SESSION_FILE_LIMITS.metadataBytes));
171
+ if (!sameSessionHead(currentHead, previous.head)) {
172
+ throw new Error("session head changed after its verified snapshot");
173
+ }
174
174
  const active = conversation.activeNode;
175
175
  if (active === undefined)
176
176
  throw new Error("an empty conversation cannot be checkpointed");
177
- if (sameNode(active, previous.conversation.activeNode)) {
177
+ if (active === previous.conversation.activeNode &&
178
+ conversation.nodes === previous.conversation.nodes) {
178
179
  return Object.freeze({ ...previous, conversation });
179
180
  }
180
181
  const replacesHead = active.id === previous.head.nodeId &&
@@ -187,19 +188,31 @@ export class DurableSessionStore {
187
188
  throw new Error("session checkpoint does not extend its durable tree");
188
189
  }
189
190
  assertSharedNodes(previous.conversation, conversation, replacesHead ? active.id : undefined);
191
+ if (extendsTree) {
192
+ await assertMissingNode(path.join(nodesDirectory, nodeName(active.id)));
193
+ }
190
194
  const now = new Date().toISOString();
191
- const head = {
195
+ const head = Object.freeze({
192
196
  version: SESSION_SCHEMA,
193
197
  sequence: previous.head.sequence + 1,
194
198
  nodeId: active.id,
195
199
  parentId: active.parentId,
196
200
  revision: active.revision,
197
201
  updatedAt: now,
198
- };
199
- const directory = this.#sessionDirectory(id);
200
- await atomicWrite(path.join(directory, "nodes", nodeName(active.id)), encodeNode(active, head.sequence, now), { mode: FILE_MODE });
201
- await atomicWrite(path.join(directory, "head.json"), encodeHead(head), { mode: FILE_MODE });
202
- return Object.freeze({ meta: previous.meta, head, conversation });
202
+ });
203
+ const catalog = advanceSessionCatalog(previous.catalog, previous.meta, head, conversation);
204
+ const checkpointToken = leaseToken();
205
+ await atomicWrite(path.join(directory, SESSION_CHECKPOINT_FILE), checkpointToken, { mode: FILE_MODE, validate: async () => assertDirectory(directory) });
206
+ await atomicWrite(path.join(nodesDirectory, nodeName(active.id)), encodeNode(active, head.sequence, now), { mode: FILE_MODE, validate: async () => validateNodesDirectory() });
207
+ await atomicWrite(path.join(directory, "head.json"), encodeHead(head), {
208
+ mode: FILE_MODE,
209
+ validate: async () => assertDirectory(directory),
210
+ });
211
+ await this.#writeCatalog(previous.meta.id, catalog, checkpointToken).catch(() => undefined);
212
+ // Once the canonical head is durable, a missing summary is detectable by
213
+ // its head mismatch and can be rebuilt without retaining a live marker.
214
+ await removeLease(path.join(directory, SESSION_CHECKPOINT_FILE), checkpointToken);
215
+ return Object.freeze({ meta: previous.meta, head, conversation, catalog });
203
216
  }
204
217
  async claim(id) {
205
218
  assertSessionId(id);
@@ -243,6 +256,74 @@ export class DurableSessionStore {
243
256
  const owner = await leaseOwner(path.join(this.#sessionDirectory(id), "active"));
244
257
  return owner !== undefined && pidIsAlive(owner.pid);
245
258
  }
259
+ async #catalogEntry(id) {
260
+ try {
261
+ const directory = this.#sessionDirectory(id);
262
+ await assertDirectory(directory);
263
+ const checkpointFile = path.join(directory, SESSION_CHECKPOINT_FILE);
264
+ // A second head read closes the only useful race: a checkpoint landing
265
+ // between the small record reads. A changing marker gets one retry.
266
+ for (let attempt = 0; attempt < 2; attempt++) {
267
+ const checkpointBefore = await leaseOwner(checkpointFile);
268
+ try {
269
+ const [metaValue, headValue, catalogValue] = await Promise.all([
270
+ readJson(path.join(directory, "meta.json"), SESSION_FILE_LIMITS.metadataBytes),
271
+ readJson(path.join(directory, "head.json"), SESSION_FILE_LIMITS.metadataBytes),
272
+ readJson(path.join(directory, SESSION_CATALOG_FILE), SESSION_CATALOG_BYTES),
273
+ ]);
274
+ const meta = decodeMeta(metaValue);
275
+ const head = decodeHead(headValue);
276
+ const storedCatalog = decodeSessionCatalog(catalogValue);
277
+ assertSessionWorkspace(meta, id, this.workspaceRoot, this.workspaceDigest);
278
+ const confirmedHead = decodeHead(await readJson(path.join(directory, "head.json"), SESSION_FILE_LIMITS.metadataBytes));
279
+ const checkpointAfter = await leaseOwner(checkpointFile);
280
+ if (!sameLease(checkpointBefore, checkpointAfter))
281
+ continue;
282
+ if (!sameSessionHead(head, confirmedHead) ||
283
+ !catalogMatches(storedCatalog, meta, head))
284
+ break;
285
+ if (checkpointAfter !== undefined && !pidIsAlive(checkpointAfter.pid))
286
+ break;
287
+ const active = await this.#leaseIsActive(id) || checkpointAfter !== undefined;
288
+ return catalogEntry(storedCatalog, active);
289
+ }
290
+ catch {
291
+ break;
292
+ }
293
+ }
294
+ // Missing, stale, or malformed summaries are rebuilt only while the
295
+ // session is idle. Selecting a session still performs this strict load.
296
+ const checkpoint = await leaseOwner(checkpointFile);
297
+ if (await this.#leaseIsActive(id) ||
298
+ (checkpoint !== undefined && pidIsAlive(checkpoint.pid)))
299
+ return undefined;
300
+ const snapshot = await this.load(id);
301
+ await this.#writeCatalog(id, snapshot.catalog, checkpoint?.token).catch(() => undefined);
302
+ const currentCheckpoint = await leaseOwner(checkpointFile);
303
+ const active = await this.#leaseIsActive(id) ||
304
+ (currentCheckpoint !== undefined && pidIsAlive(currentCheckpoint.pid));
305
+ return catalogEntry(snapshot.catalog, active);
306
+ }
307
+ catch {
308
+ // Corrupt, unsafe, active-without-a-summary, or foreign data never
309
+ // becomes a resume candidate.
310
+ return undefined;
311
+ }
312
+ }
313
+ async #writeCatalog(id, catalog, checkpointToken) {
314
+ const directory = this.#sessionDirectory(id);
315
+ const validate = async () => {
316
+ await assertDirectory(directory);
317
+ const currentHead = decodeHead(await readJson(path.join(directory, "head.json"), SESSION_FILE_LIMITS.metadataBytes));
318
+ if (!sameSessionHead(currentHead, catalog.head)) {
319
+ throw new Error("session head changed while updating its catalogue");
320
+ }
321
+ };
322
+ await atomicWrite(path.join(directory, SESSION_CATALOG_FILE), encodeSessionCatalog(catalog), { mode: FILE_MODE, validate });
323
+ if (checkpointToken !== undefined) {
324
+ await removeLease(path.join(directory, SESSION_CHECKPOINT_FILE), checkpointToken);
325
+ }
326
+ }
246
327
  }
247
328
  async function catalogNames(directory) {
248
329
  try {
@@ -266,22 +347,65 @@ async function catalogNames(directory) {
266
347
  }
267
348
  return names.sort((left, right) => right.localeCompare(left));
268
349
  }
350
+ function catalogEntry(catalog, active) {
351
+ if (catalog.resumeNodeId === 0)
352
+ return undefined;
353
+ return Object.freeze({
354
+ id: catalog.id,
355
+ createdAt: catalog.createdAt,
356
+ updatedAt: catalog.head.updatedAt,
357
+ turns: catalog.turns,
358
+ preview: catalog.preview,
359
+ active,
360
+ });
361
+ }
362
+ function sameLease(left, right) {
363
+ return left?.token === right?.token;
364
+ }
365
+ function assertSessionWorkspace(meta, id, workspaceRoot, workspaceDigest) {
366
+ if (meta.id !== id || meta.workspaceDigest !== workspaceDigest ||
367
+ workspaceKey(meta.workspaceRoot) !== workspaceKey(workspaceRoot))
368
+ throw new Error("session belongs to a different workspace");
369
+ }
269
370
  function assertSharedNodes(previous, next, replacedId) {
270
371
  for (const node of previous.nodes) {
271
372
  if (node.id === replacedId)
272
373
  continue;
273
374
  const candidate = next.node(node.id);
274
- if (candidate === undefined || normalizedNode(candidate) !== normalizedNode(node)) {
375
+ if (candidate !== node) {
275
376
  throw new Error("session checkpoint rewrites prior conversation history");
276
377
  }
277
378
  }
278
379
  }
279
- function normalizedNode(node) {
280
- const encoded = JSON.parse(encodeNode(node, 1, "2026-01-01T00:00:00.000Z"));
281
- return JSON.stringify(encoded.node);
380
+ function assertSnapshot(snapshot, workspaceRoot, workspaceDigest) {
381
+ encodeMeta(snapshot.meta);
382
+ encodeHead(snapshot.head);
383
+ encodeSessionCatalog(snapshot.catalog);
384
+ assertSessionId(snapshot.meta.id);
385
+ if (snapshot.meta.workspaceDigest !== workspaceDigest ||
386
+ workspaceKey(snapshot.meta.workspaceRoot) !== workspaceKey(workspaceRoot))
387
+ throw new Error("session snapshot belongs to a different workspace");
388
+ const active = snapshot.conversation.activeNode;
389
+ if (active === undefined ||
390
+ active.id !== snapshot.head.nodeId ||
391
+ active.parentId !== snapshot.head.parentId ||
392
+ active.revision !== snapshot.head.revision ||
393
+ snapshot.head.sequence < snapshot.conversation.nodes.length)
394
+ throw new Error("session snapshot does not match its verified head");
395
+ if (!catalogMatches(snapshot.catalog, snapshot.meta, snapshot.head)) {
396
+ throw new Error("session snapshot does not match its verified catalogue");
397
+ }
282
398
  }
283
- function sameNode(left, right) {
284
- return right !== undefined && normalizedNode(left) === normalizedNode(right);
399
+ async function assertMissingNode(file) {
400
+ try {
401
+ await lstat(file);
402
+ }
403
+ catch (error) {
404
+ if (error.code === "ENOENT")
405
+ return;
406
+ throw error;
407
+ }
408
+ throw new Error("session has an incomplete node outside its verified snapshot");
285
409
  }
286
410
  async function readNodes(directory) {
287
411
  await assertDirectory(directory);
@@ -344,17 +468,6 @@ async function makePrivateDirectory(directory) {
344
468
  if (process.platform !== "win32")
345
469
  await chmod(directory, DIRECTORY_MODE);
346
470
  }
347
- async function directoryEntries(directory) {
348
- try {
349
- await assertDirectory(directory);
350
- return await readdir(directory, { withFileTypes: true });
351
- }
352
- catch (error) {
353
- if (error.code === "ENOENT")
354
- return [];
355
- throw error;
356
- }
357
- }
358
471
  async function removeTemporaryDirectory(directory, bucket) {
359
472
  const relative = path.relative(bucket, directory);
360
473
  if (relative === "" || relative.startsWith("..") || path.isAbsolute(relative) ||
@@ -362,26 +475,6 @@ async function removeTemporaryDirectory(directory, bucket) {
362
475
  throw new Error("refusing to remove an unverified session directory");
363
476
  await rm(directory, { recursive: true, force: true });
364
477
  }
365
- function selectedTurnCount(conversation) {
366
- let count = 0;
367
- let id = conversation.activeNodeId;
368
- while (id !== 0) {
369
- count++;
370
- id = conversation.node(id)?.parentId ?? 0;
371
- }
372
- return count;
373
- }
374
- function firstUserText(conversation) {
375
- for (const message of conversation.history) {
376
- if (message.role !== "user")
377
- continue;
378
- const text = message.content.find((block) => block.kind === "text")?.text
379
- .replace(/\s+/gu, " ").trim();
380
- if (text !== undefined && text !== "")
381
- return leadingText(text, 160);
382
- }
383
- return "Untitled session";
384
- }
385
478
  function nodeName(id) {
386
479
  return `${String(id).padStart(6, "0")}.json`;
387
480
  }
package/dist/settings.js CHANGED
@@ -1,5 +1,4 @@
1
1
  // Persistent, non-secret defaults for interactive and batch sessions.
2
- import { readFileSync } from "node:fs";
3
2
  import { chmod, mkdir } from "node:fs/promises";
4
3
  import * as path from "node:path";
5
4
  import { atomicWrite } from "./atomic.js";
@@ -9,6 +8,7 @@ import { providerNames } from "./providers/index.js";
9
8
  import { parseOllamaEndpoint } from "./providers/ollama-endpoint.js";
10
9
  import { withStoreLock } from "./store-lock.js";
11
10
  import { userDataLabel, userDataPath } from "./user-data.js";
11
+ import { assertStoreText, readBoundedJsonSync, USER_STORE_LIMITS } from "./user-store.js";
12
12
  export { EFFORTS } from "./effort.js";
13
13
  let saved;
14
14
  export function readSettings() {
@@ -24,7 +24,9 @@ export async function updateSettings(patch) {
24
24
  await chmod(directory, 0o700);
25
25
  return withStoreLock(file, async () => {
26
26
  const next = normalize({ ...readStore(file), ...patch });
27
- await atomicWrite(file, `${JSON.stringify(next, null, 2)}\n`, { mode: 0o600 });
27
+ const text = `${JSON.stringify(next, null, 2)}\n`;
28
+ assertStoreText(text, USER_STORE_LIMITS.settingsBytes);
29
+ await atomicWrite(file, text, { mode: 0o600 });
28
30
  saved = next;
29
31
  return file;
30
32
  });
@@ -41,7 +43,7 @@ export function reloadSettings() {
41
43
  }
42
44
  function readStore(file = settingsPath()) {
43
45
  try {
44
- return normalize(JSON.parse(readFileSync(file, "utf8")));
46
+ return normalize(readBoundedJsonSync(file, USER_STORE_LIMITS.settingsBytes));
45
47
  }
46
48
  catch {
47
49
  // Missing, unreadable, and malformed stores all fall back safely. A bad
@@ -73,14 +75,14 @@ function normalize(value) {
73
75
  function modelsOf(value, providers) {
74
76
  if (!record(value))
75
77
  return undefined;
76
- const models = Object.fromEntries(Object.entries(value).filter((entry) => providers.includes(entry[0]) && typeof entry[1] === "string" && entry[1].trim() !== ""));
78
+ const models = Object.fromEntries(Object.entries(value).filter((entry) => providers.includes(entry[0]) && boundedNonempty(entry[1], USER_STORE_LIMITS.model)));
77
79
  return Object.keys(models).length === 0 ? undefined : models;
78
80
  }
79
81
  function member(value, values) {
80
82
  return typeof value === "string" && values.includes(value) ? value : undefined;
81
83
  }
82
84
  function endpoint(value) {
83
- if (typeof value !== "string")
85
+ if (!boundedNonempty(value, USER_STORE_LIMITS.endpoint))
84
86
  return undefined;
85
87
  try {
86
88
  return parseOllamaEndpoint(value).baseUrl;
@@ -89,6 +91,9 @@ function endpoint(value) {
89
91
  return undefined;
90
92
  }
91
93
  }
94
+ function boundedNonempty(value, max) {
95
+ return typeof value === "string" && value.length <= max && value.trim() !== "";
96
+ }
92
97
  function positiveInteger(value) {
93
98
  return typeof value === "number" && Number.isSafeInteger(value) && value > 0 ? value : undefined;
94
99
  }
package/dist/start.js CHANGED
@@ -76,14 +76,24 @@ export async function start(args = process.argv.slice(2), environment = {}) {
76
76
  }
77
77
  }
78
78
  try {
79
- await (environment.runInteractive ?? runApp)(session, transcriptRoot);
79
+ if (environment.runInteractive === undefined) {
80
+ await runApp(session, transcriptRoot, { shutdownSignal: environment.signal });
81
+ }
82
+ else {
83
+ await environment.runInteractive(session, transcriptRoot, environment.signal);
84
+ }
80
85
  }
81
86
  finally {
82
87
  await session.persistence?.close();
83
88
  }
84
89
  }
85
90
  else {
86
- await (environment.runNonInteractive ?? runBatch)(session);
91
+ if (environment.runNonInteractive === undefined) {
92
+ await runBatch(session, { signal: environment.signal });
93
+ }
94
+ else {
95
+ await environment.runNonInteractive(session, environment.signal);
96
+ }
87
97
  }
88
98
  }
89
99
  function applyResumedSession(session, conversation, persistence) {
@@ -1,5 +1,7 @@
1
1
  // Shared grapheme boundaries for every projection of user-visible text.
2
2
  const SEGMENTER = new Intl.Segmenter(undefined, { granularity: "grapheme" });
3
+ /** Shared persisted-text and user-prompt boundary, measured like String.length. */
4
+ export const MAX_TEXT_CODE_UNITS = 1_048_576;
3
5
  export function segmentGraphemes(text) {
4
6
  return SEGMENTER.segment(text);
5
7
  }
@@ -232,11 +232,10 @@ function preferRipgrep(files, bytes) {
232
232
  return files.length >= MIN_RG_TAIL_FILES || bytes >= MIN_RG_TAIL_BYTES;
233
233
  }
234
234
  async function walk(start, ctx, visit) {
235
- const pending = [start];
235
+ const pending = [];
236
236
  let seen = 0;
237
- while (pending.length > 0) {
237
+ const enter = async (lexical) => {
238
238
  checkAbort(ctx.signal);
239
- const lexical = pending.pop();
240
239
  const directory = await resolveExistingInRoot(ctx.root, lexical);
241
240
  let entries;
242
241
  try {
@@ -244,24 +243,34 @@ async function walk(start, ctx, visit) {
244
243
  }
245
244
  catch (error) {
246
245
  if (skippable(error))
247
- continue;
246
+ return;
248
247
  throw error;
249
248
  }
250
249
  entries.sort((a, b) => a.name.localeCompare(b.name));
251
- for (const entry of entries) {
252
- checkAbort(ctx.signal);
253
- if (++seen > MAX_VISITED)
254
- return { capped: true };
255
- if (entry.isSymbolicLink())
256
- continue;
257
- const target = path.join(directory, entry.name);
258
- if (entry.isDirectory()) {
259
- if (!SKIP.has(entry.name))
260
- pending.push(target);
261
- }
262
- else if (entry.isFile() && (await visit(target))) {
263
- return { capped: false };
264
- }
250
+ pending.push({ directory, entries, next: 0 });
251
+ };
252
+ await enter(start);
253
+ while (pending.length > 0) {
254
+ checkAbort(ctx.signal);
255
+ const frame = pending[pending.length - 1];
256
+ if (frame === undefined)
257
+ break;
258
+ const entry = frame.entries[frame.next++];
259
+ if (entry === undefined) {
260
+ pending.pop();
261
+ continue;
262
+ }
263
+ if (++seen > MAX_VISITED)
264
+ return { capped: true };
265
+ if (entry.isSymbolicLink())
266
+ continue;
267
+ const target = path.join(frame.directory, entry.name);
268
+ if (entry.isDirectory()) {
269
+ if (!SKIP.has(entry.name))
270
+ await enter(target);
271
+ }
272
+ else if (entry.isFile() && (await visit(target))) {
273
+ return { capped: false };
265
274
  }
266
275
  }
267
276
  return { capped: false };
@@ -1,4 +1,5 @@
1
1
  // Keyboard and pointer intent for the TUI shell.
2
+ import { PROMPT_LIMIT_MESSAGE, PromptLimitError } from "../input-boundary.js";
2
3
  import { activate as activateCompletion, move as moveCompletion, selected as selectedCompletion, } from "./complete.js";
3
4
  import * as edit from "./editor.js";
4
5
  import { turnBlocker } from "./feedback.js";
@@ -21,6 +22,13 @@ export function appInput(options) {
21
22
  }
22
23
  if (state.feedback !== undefined)
23
24
  feedback.dismiss();
25
+ if (key.name === "input_limit") {
26
+ if (state.open === undefined)
27
+ rejectPrompt();
28
+ else
29
+ showInputLimit();
30
+ return;
31
+ }
24
32
  // Detail expansion remains available while an approval is open. A large
25
33
  // diff may be compacted, but the user must be able to inspect it before
26
34
  // answering the permission prompt.
@@ -33,6 +41,8 @@ export function appInput(options) {
33
41
  if (state.open !== undefined) {
34
42
  const outcome = overlay.handle(state.open, key);
35
43
  state.open = outcome.open;
44
+ if (outcome.inputLimit === true)
45
+ showInputLimit();
36
46
  if (outcome.abort === true)
37
47
  state.activity?.control.abort(new Error("interrupted"));
38
48
  if (outcome.quit === true)
@@ -61,8 +71,10 @@ export function appInput(options) {
61
71
  case "enter": {
62
72
  if (state.completing !== undefined) {
63
73
  const completed = selectedCompletion(state.completing);
64
- if (completed !== undefined)
74
+ if (completed !== undefined) {
65
75
  state.editor = edit.of(completed);
76
+ state.promptRejected = false;
77
+ }
66
78
  state.completing = undefined;
67
79
  }
68
80
  submit();
@@ -75,6 +87,7 @@ export function appInput(options) {
75
87
  const completed = completion === undefined ? undefined : selectedCompletion(completion);
76
88
  if (completed !== undefined) {
77
89
  state.editor = edit.of(completed);
90
+ state.promptRejected = false;
78
91
  state.completing = undefined;
79
92
  }
80
93
  return;
@@ -106,16 +119,26 @@ export function appInput(options) {
106
119
  options.invalidate();
107
120
  return;
108
121
  }
109
- const edited = applyKey(state.editor, key);
110
- if (edited !== undefined) {
111
- state.editor = edited;
112
- state.completing = state.activity === undefined ? activateCompletion(edited.text) : undefined;
122
+ try {
123
+ const edited = applyKey(state.editor, key);
124
+ if (edited !== undefined) {
125
+ if (edited.text !== state.editor.text)
126
+ state.promptRejected = false;
127
+ state.editor = edited;
128
+ state.completing = state.activity === undefined ? activateCompletion(edited.text) : undefined;
129
+ }
130
+ }
131
+ catch (error) {
132
+ if (!(error instanceof PromptLimitError))
133
+ throw error;
134
+ rejectPrompt();
113
135
  }
114
136
  }
115
137
  function recall(step) {
116
138
  if (state.past.length === 0)
117
139
  return;
118
140
  state.completing = undefined;
141
+ state.promptRejected = false;
119
142
  if (state.recall === -1) {
120
143
  if (step > 0)
121
144
  return;
@@ -132,6 +155,10 @@ export function appInput(options) {
132
155
  state.editor = edit.of(state.past[state.recall]);
133
156
  }
134
157
  function submit() {
158
+ if (state.promptRejected) {
159
+ keep(PROMPT_LIMIT_MESSAGE);
160
+ return;
161
+ }
135
162
  const text = state.editor.text.trim();
136
163
  if (text === "")
137
164
  return;
@@ -171,6 +198,7 @@ export function appInput(options) {
171
198
  state.recall = -1;
172
199
  state.draft = "";
173
200
  state.completing = undefined;
201
+ state.promptRejected = false;
174
202
  state.past.push(text);
175
203
  state.scroll = 0;
176
204
  state.follow = true;
@@ -179,5 +207,12 @@ export function appInput(options) {
179
207
  function keep(text) {
180
208
  feedback.show({ text: `${text} · prompt kept`, tone: "warn", timeoutMs: 4_000 });
181
209
  }
210
+ function rejectPrompt() {
211
+ state.promptRejected = true;
212
+ keep(PROMPT_LIMIT_MESSAGE);
213
+ }
214
+ function showInputLimit() {
215
+ feedback.show({ text: PROMPT_LIMIT_MESSAGE, tone: "warn", timeoutMs: 4_000 });
216
+ }
182
217
  return { handle };
183
218
  }
@@ -13,5 +13,6 @@ export function appState() {
13
13
  draft: "",
14
14
  closeWhenIdle: false,
15
15
  committedNodeId: 0,
16
+ promptRejected: false,
16
17
  };
17
18
  }
@@ -212,6 +212,7 @@ export function appWorkflows(options) {
212
212
  nodeId: nodeId ?? prospectiveNodeId,
213
213
  coveredMessages: context?.messageCount ?? 0,
214
214
  lastInputTokens: Math.max(session.usage.lastInputTokens, request.inputTokens),
215
+ estimatedInputTokens: request.inputTokens,
215
216
  signal: activity.control.signal,
216
217
  force,
217
218
  policy: request.policy,