@giovannijecha/jecode 0.8.2 → 0.8.3

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 (42) hide show
  1. package/README.md +19 -278
  2. package/dist/accounts.js +17 -13
  3. package/dist/batch.js +54 -6
  4. package/dist/context/budget.js +13 -1
  5. package/dist/context/compactor.js +5 -4
  6. package/dist/context/estimate.js +43 -1
  7. package/dist/context/manual.js +8 -4
  8. package/dist/context/policy.js +68 -18
  9. package/dist/controller-request.js +8 -8
  10. package/dist/controller.js +6 -1
  11. package/dist/conversation.js +94 -33
  12. package/dist/credential-safety.js +56 -9
  13. package/dist/credentials.js +32 -4
  14. package/dist/input-boundary.js +80 -0
  15. package/dist/main.js +4 -1
  16. package/dist/openai-oauth-callback.js +1 -1
  17. package/dist/process-shutdown.js +52 -0
  18. package/dist/providers/anthropic-stream.js +24 -20
  19. package/dist/providers/anthropic-wire.js +7 -2
  20. package/dist/providers/ollama-wire.js +7 -15
  21. package/dist/providers/ollama.js +1 -0
  22. package/dist/providers/openai-wire.js +2 -16
  23. package/dist/providers/tool-input.js +17 -0
  24. package/dist/sessions/codec.js +2 -1
  25. package/dist/sessions/lease.js +7 -0
  26. package/dist/sessions/runtime.js +11 -3
  27. package/dist/sessions/store.js +70 -21
  28. package/dist/settings.js +10 -5
  29. package/dist/start.js +12 -2
  30. package/dist/text-boundary.js +2 -0
  31. package/dist/tui/app-input.js +40 -5
  32. package/dist/tui/app-state.js +1 -0
  33. package/dist/tui/app-workflows.js +1 -0
  34. package/dist/tui/app.js +11 -3
  35. package/dist/tui/editor.js +2 -0
  36. package/dist/tui/keys.js +64 -5
  37. package/dist/tui/overlay.js +12 -4
  38. package/dist/tui/picker.js +2 -0
  39. package/dist/tui/screen.js +5 -17
  40. package/dist/user-store.js +54 -0
  41. package/package.json +6 -3
  42. /package/{docs/assets/brand → assets}/jeco-256.png +0 -0
@@ -0,0 +1,80 @@
1
+ // One bounded ingress for text that can become a user prompt.
2
+ import { Buffer } from "node:buffer";
3
+ import { StringDecoder } from "node:string_decoder";
4
+ import { MAX_TEXT_CODE_UNITS } from "./text-boundary.js";
5
+ export const MAX_PROMPT_CODE_UNITS = MAX_TEXT_CODE_UNITS;
6
+ export const PROMPT_LIMIT_MESSAGE = `Prompt cannot exceed ${MAX_PROMPT_CODE_UNITS.toLocaleString("en-US")} UTF-16 code units`;
7
+ export class PromptLimitError extends Error {
8
+ constructor() {
9
+ super(PROMPT_LIMIT_MESSAGE);
10
+ this.name = "PromptLimitError";
11
+ }
12
+ }
13
+ export function assertPromptLength(length) {
14
+ if (length > MAX_PROMPT_CODE_UNITS)
15
+ throw new PromptLimitError();
16
+ }
17
+ export function assertPromptAppend(current, added) {
18
+ if (added > MAX_PROMPT_CODE_UNITS - current)
19
+ throw new PromptLimitError();
20
+ }
21
+ /**
22
+ * Split raw UTF-8 input without letting one unterminated line grow past the
23
+ * prompt boundary. Newline, CRLF, and a final line without a newline match the
24
+ * line semantics used by batch mode.
25
+ */
26
+ export async function* boundedInputLines(source) {
27
+ const decoder = new StringDecoder("utf8");
28
+ let line = "";
29
+ let pendingCr = false;
30
+ const append = (text, from, to) => {
31
+ const length = to - from;
32
+ assertPromptAppend(line.length, length);
33
+ if (length > 0)
34
+ line += text.slice(from, to);
35
+ };
36
+ const consume = function* (text) {
37
+ if (text === "")
38
+ return;
39
+ let from = 0;
40
+ if (pendingCr) {
41
+ if (text.startsWith("\n"))
42
+ from = 1;
43
+ pendingCr = false;
44
+ yield line;
45
+ line = "";
46
+ }
47
+ while (from < text.length) {
48
+ const cr = text.indexOf("\r", from);
49
+ const lf = text.indexOf("\n", from);
50
+ const newline = cr === -1 ? lf : lf === -1 ? cr : Math.min(cr, lf);
51
+ if (newline === -1) {
52
+ append(text, from, text.length);
53
+ return;
54
+ }
55
+ append(text, from, newline);
56
+ if (text[newline] === "\r" && newline + 1 === text.length) {
57
+ pendingCr = true;
58
+ return;
59
+ }
60
+ yield line;
61
+ line = "";
62
+ from = text[newline] === "\r" && text[newline + 1] === "\n"
63
+ ? newline + 2
64
+ : newline + 1;
65
+ }
66
+ };
67
+ for await (const chunk of source) {
68
+ const text = typeof chunk === "string" ? chunk : decoder.write(Buffer.from(chunk));
69
+ yield* consume(text);
70
+ }
71
+ const tail = decoder.end();
72
+ if (tail !== "")
73
+ yield* consume(tail);
74
+ if (pendingCr) {
75
+ yield line;
76
+ line = "";
77
+ }
78
+ if (line !== "")
79
+ yield line;
80
+ }
package/dist/main.js CHANGED
@@ -1,7 +1,10 @@
1
1
  // Process entry point. The testable bootstrap lives in start.ts.
2
2
  import { start } from "./start.js";
3
+ import { isProcessSignalError, withProcessShutdown } from "./process-shutdown.js";
3
4
  import { terminalText } from "./ui/terminal-text.js";
4
- start().catch((error) => {
5
+ withProcessShutdown((signal) => start(process.argv.slice(2), { signal })).catch((error) => {
6
+ if (isProcessSignalError(error))
7
+ return;
5
8
  process.stderr.write(`jecode: ${terminalText(error.message)}\n`);
6
9
  process.exitCode = 1;
7
10
  });
@@ -173,7 +173,7 @@ function resultPage(success) {
173
173
  let mascot;
174
174
  function mascotDataUri() {
175
175
  if (mascot === undefined) {
176
- const file = new URL("../docs/assets/brand/jeco-256.png", import.meta.url);
176
+ const file = new URL("../assets/jeco-256.png", import.meta.url);
177
177
  mascot = `data:image/png;base64,${readFileSync(file).toString("base64")}`;
178
178
  }
179
179
  return mascot;
@@ -0,0 +1,52 @@
1
+ // One process-wide cancellation boundary for operating-system signals.
2
+ const SHUTDOWN_GRACE_MS = 2_000;
3
+ const SIGNALS = [
4
+ ["SIGINT", 2],
5
+ ["SIGHUP", 1],
6
+ ["SIGTERM", 15],
7
+ ];
8
+ export class ProcessSignalError extends Error {
9
+ signal;
10
+ exitCode;
11
+ constructor(signal, exitCode) {
12
+ super(`received ${signal}`);
13
+ this.name = "ProcessSignalError";
14
+ this.signal = signal;
15
+ this.exitCode = exitCode;
16
+ }
17
+ }
18
+ export function isProcessSignalError(error) {
19
+ return error instanceof ProcessSignalError;
20
+ }
21
+ /**
22
+ * Abort foreground work on the first fatal signal and reserve a bounded hard
23
+ * exit for work that ignores cancellation. A second signal exits immediately.
24
+ */
25
+ export async function withProcessShutdown(work) {
26
+ const control = new AbortController();
27
+ let exitCode;
28
+ let forceTimer;
29
+ const listeners = [];
30
+ for (const [name, number] of SIGNALS) {
31
+ const listener = () => {
32
+ if (control.signal.aborted) {
33
+ process.exit(exitCode ?? 128 + number);
34
+ }
35
+ exitCode = 128 + number;
36
+ process.exitCode = exitCode;
37
+ control.abort(new ProcessSignalError(name, exitCode));
38
+ forceTimer = setTimeout(() => process.exit(exitCode), SHUTDOWN_GRACE_MS);
39
+ };
40
+ listeners.push([name, listener]);
41
+ process.on(name, listener);
42
+ }
43
+ try {
44
+ return await work(control.signal);
45
+ }
46
+ finally {
47
+ if (forceTimer !== undefined)
48
+ clearTimeout(forceTimer);
49
+ for (const [name, listener] of listeners)
50
+ process.off(name, listener);
51
+ }
52
+ }
@@ -6,11 +6,13 @@
6
6
  // beyond display: thinking blocks carry a signature and must be echoed back
7
7
  // byte-for-byte on the next request.
8
8
  import { addBounded, MAX_TOOL_ARGUMENT_CHARS } from "./stream-limits.js";
9
+ import { toolInputFromJson } from "./tool-input.js";
9
10
  export async function assembleAnthropic(events, onStream) {
10
11
  const blocks = new Map();
11
12
  const partialJson = new Map();
12
13
  const announcedTools = new Set();
13
14
  const sizes = { toolArguments: 0 };
15
+ const toolInputErrors = {};
14
16
  let stopReason;
15
17
  let stopDetails;
16
18
  let usage;
@@ -46,7 +48,10 @@ export async function assembleAnthropic(events, onStream) {
46
48
  const pending = partialJson.get(event.index);
47
49
  const block = blocks.get(event.index);
48
50
  if (pending !== undefined && block !== undefined) {
49
- block.input = parseJsonObject(pending);
51
+ const parsed = toolInputFromJson(pending);
52
+ block.input = parsed.input;
53
+ if (parsed.inputError !== undefined)
54
+ toolInputErrors[event.index] = parsed.inputError;
50
55
  partialJson.delete(event.index);
51
56
  }
52
57
  break;
@@ -69,10 +74,24 @@ export async function assembleAnthropic(events, onStream) {
69
74
  }
70
75
  if (!complete)
71
76
  throw new Error("anthropic stream ended before message_stop");
72
- const content = [...blocks.entries()]
73
- .sort(([a], [b]) => a - b)
74
- .map(([, block]) => block);
75
- return { content, stop_reason: stopReason, stop_details: stopDetails, usage };
77
+ const ordered = [...blocks.entries()].sort(([a], [b]) => a - b);
78
+ const content = ordered.map(([, block]) => block);
79
+ const orderedInputErrors = {};
80
+ for (let index = 0; index < ordered.length; index++) {
81
+ const sourceIndex = ordered[index]?.[0];
82
+ if (sourceIndex === undefined || toolInputErrors[sourceIndex] === undefined)
83
+ continue;
84
+ orderedInputErrors[index] = toolInputErrors[sourceIndex];
85
+ }
86
+ return {
87
+ content,
88
+ stop_reason: stopReason,
89
+ stop_details: stopDetails,
90
+ usage,
91
+ ...(Object.keys(orderedInputErrors).length === 0
92
+ ? {}
93
+ : { toolInputErrors: orderedInputErrors }),
94
+ };
76
95
  }
77
96
  function mergeUsage(before, after) {
78
97
  return after === undefined ? before : { ...before, ...after };
@@ -108,18 +127,3 @@ function applyDelta(blocks, partialJson, sizes, index, delta, onStream) {
108
127
  return;
109
128
  }
110
129
  }
111
- // Tool arguments arrive as a stream of JSON fragments. An empty accumulation
112
- // is a call with no arguments, not a malformed one.
113
- function parseJsonObject(text) {
114
- if (text.trim() === "")
115
- return {};
116
- try {
117
- const parsed = JSON.parse(text);
118
- return typeof parsed === "object" && parsed !== null
119
- ? parsed
120
- : {};
121
- }
122
- catch {
123
- return {};
124
- }
125
- }
@@ -1,5 +1,6 @@
1
1
  // Translation between the normalized vocabulary and the Anthropic wire shape.
2
2
  // Pure functions, no I/O — which is what makes them testable without a key.
3
+ import { toolInputFromValue } from "./tool-input.js";
3
4
  import { wireTokenCount } from "./wire-usage.js";
4
5
  export function toWireTool(tool) {
5
6
  return { name: tool.name, description: tool.description, input_schema: tool.input };
@@ -45,7 +46,8 @@ export function fromWireResponse(data) {
45
46
  const raw = Array.isArray(data.content) ? data.content : [];
46
47
  const content = [];
47
48
  let suppressedToolCall = false;
48
- for (const item of raw) {
49
+ for (let index = 0; index < raw.length; index++) {
50
+ const item = raw[index];
49
51
  const block = item;
50
52
  if (block.type === "text" && typeof block.text === "string") {
51
53
  content.push({ kind: "text", text: block.text });
@@ -54,11 +56,14 @@ export function fromWireResponse(data) {
54
56
  if (data.stop_reason === "tool_use" &&
55
57
  typeof block.id === "string" &&
56
58
  typeof block.name === "string") {
59
+ const parsed = toolInputFromValue(block.input ?? {});
60
+ const inputError = data.toolInputErrors?.[index] ?? parsed.inputError;
57
61
  content.push({
58
62
  kind: "tool_call",
59
63
  id: block.id,
60
64
  name: block.name,
61
- input: (block.input ?? {}),
65
+ input: parsed.input,
66
+ ...(inputError === undefined ? {} : { inputError }),
62
67
  });
63
68
  }
64
69
  else {
@@ -6,6 +6,7 @@
6
6
  // Chat Completions differs on two points that matter here — a tool result is a
7
7
  // message of its own with role "tool", not a block inside a user turn, and tool
8
8
  // arguments travel as a JSON string rather than an object.
9
+ import { toolInputFromJson } from "./tool-input.js";
9
10
  import { wireTokenCount } from "./wire-usage.js";
10
11
  export function toWireTool(tool) {
11
12
  return {
@@ -63,7 +64,12 @@ export function fromWireReply(reply) {
63
64
  const acceptsToolCalls = reply.finishReason === "tool_calls";
64
65
  if (acceptsToolCalls) {
65
66
  for (const call of reply.toolCalls) {
66
- content.push({ kind: "tool_call", id: call.id, name: call.name, input: parseArgs(call.args) });
67
+ content.push({
68
+ kind: "tool_call",
69
+ id: call.id,
70
+ name: call.name,
71
+ ...toolInputFromJson(call.args),
72
+ });
67
73
  }
68
74
  }
69
75
  const notice = stopNotice(reply);
@@ -104,17 +110,3 @@ export function stopNotice(reply) {
104
110
  ? "[truncated: hit the output limit — raise --max-tokens]"
105
111
  : undefined;
106
112
  }
107
- // A model that emits malformed JSON gets the empty object, which fails
108
- // validation in tools/args.ts with a message written for it to read. That is a
109
- // recoverable turn; throwing here would end the whole thing instead.
110
- function parseArgs(args) {
111
- if (args.trim() === "")
112
- return {};
113
- try {
114
- const parsed = JSON.parse(args);
115
- return typeof parsed === "object" && parsed !== null ? parsed : {};
116
- }
117
- catch {
118
- return {};
119
- }
120
- }
@@ -90,6 +90,7 @@ export const ollama = {
90
90
  max_tokens: req.maxTokens,
91
91
  reasoning_effort: effort,
92
92
  stream: true,
93
+ stream_options: { include_usage: true },
93
94
  }, req.maxTokens, req.signal, req.onStatus);
94
95
  const reply = await assembleOllama(events, req.onStream);
95
96
  const notice = stopNotice(reply);
@@ -1,6 +1,7 @@
1
1
  // Translation between the normalized vocabulary and the OpenAI Responses wire
2
2
  // shape: a flat `input` list where tool calls and their outputs are top-level
3
3
  // items keyed by `call_id`, rather than blocks nested inside a message.
4
+ import { toolInputFromJson } from "./tool-input.js";
4
5
  import { wireTokenCount } from "./wire-usage.js";
5
6
  export function toWireTool(tool) {
6
7
  return {
@@ -73,7 +74,7 @@ export function fromWireResponse(data, providerId = "openai") {
73
74
  kind: "tool_call",
74
75
  id: item.call_id,
75
76
  name: item.name,
76
- input: parseArguments(item.arguments),
77
+ ...toolInputFromJson(item.arguments),
77
78
  });
78
79
  }
79
80
  else {
@@ -106,18 +107,3 @@ function normalizeUsage(data) {
106
107
  reasoningTokens: wireTokenCount(usage.output_tokens_details?.reasoning_tokens),
107
108
  };
108
109
  }
109
- // Arguments arrive as a JSON string and models vary in how they escape it, so
110
- // this always goes through a real parse — never string matching.
111
- function parseArguments(text) {
112
- if (text === undefined || text === "")
113
- return {};
114
- try {
115
- const parsed = JSON.parse(text);
116
- return typeof parsed === "object" && parsed !== null
117
- ? parsed
118
- : {};
119
- }
120
- catch {
121
- return {};
122
- }
123
- }
@@ -0,0 +1,17 @@
1
+ // Provider-neutral validation for tool arguments crossing a wire boundary.
2
+ export function toolInputFromJson(text) {
3
+ if (text === undefined || text.trim() === "")
4
+ return { input: {} };
5
+ try {
6
+ return toolInputFromValue(JSON.parse(text));
7
+ }
8
+ catch {
9
+ return { input: {}, inputError: "tool arguments were not valid JSON" };
10
+ }
11
+ }
12
+ export function toolInputFromValue(value) {
13
+ if (typeof value === "object" && value !== null && !Array.isArray(value)) {
14
+ return { input: value };
15
+ }
16
+ return { input: {}, inputError: "tool arguments must be a JSON object" };
17
+ }
@@ -3,9 +3,10 @@
3
3
  // can become conversation or provider input.
4
4
  import { Buffer } from "node:buffer";
5
5
  import { CONTEXT_LIMITS } from "../context/projection.js";
6
+ import { MAX_TEXT_CODE_UNITS } from "../text-boundary.js";
6
7
  export const SESSION_SCHEMA = 4;
7
8
  export const SESSION_FILE_LIMITS = Object.freeze({
8
- text: 1_048_576,
9
+ text: MAX_TEXT_CODE_UNITS,
9
10
  metadataBytes: 64 * 1_024,
10
11
  nodeBytes: 20 * 1_024 * 1_024,
11
12
  jsonDepth: 24,
@@ -9,6 +9,13 @@ export function sessionLease(id, file, token) {
9
9
  let closed = false;
10
10
  return Object.freeze({
11
11
  id,
12
+ assertOwned: async () => {
13
+ if (closed)
14
+ throw new Error("session lease is closed");
15
+ const current = await readLease(file);
16
+ if (current !== token)
17
+ throw new Error("session lease is no longer owned by this process");
18
+ },
12
19
  close: async () => {
13
20
  if (closed)
14
21
  return;
@@ -8,11 +8,13 @@ export class SessionPersistence {
8
8
  #store;
9
9
  #sessionId;
10
10
  #lease;
11
+ #snapshot;
11
12
  #failure;
12
- constructor(store, sessionId, lease) {
13
+ constructor(store, sessionId, lease, snapshot) {
13
14
  this.#store = store;
14
15
  this.#sessionId = sessionId;
15
16
  this.#lease = lease;
17
+ this.#snapshot = snapshot;
16
18
  }
17
19
  static fresh(store) {
18
20
  return new SessionPersistence(store, null);
@@ -26,7 +28,7 @@ export class SessionPersistence {
26
28
  throw new Error("session has no resumable turn");
27
29
  return Object.freeze({
28
30
  conversation,
29
- persistence: new SessionPersistence(store, id, lease),
31
+ persistence: new SessionPersistence(store, id, lease, snapshot),
30
32
  });
31
33
  }
32
34
  catch (error) {
@@ -51,9 +53,14 @@ export class SessionPersistence {
51
53
  const published = await this.#store.publish(conversation, true);
52
54
  this.#lease = published.lease;
53
55
  this.#sessionId = published.meta.id;
56
+ this.#snapshot = published;
54
57
  return;
55
58
  }
56
- await this.#store.checkpoint(this.#sessionId, conversation);
59
+ if (this.#lease === undefined || this.#snapshot === undefined) {
60
+ throw new Error("session persistence has no verified owner snapshot");
61
+ }
62
+ await this.#lease.assertOwned();
63
+ this.#snapshot = await this.#store.checkpoint(this.#snapshot, conversation);
57
64
  }
58
65
  catch (error) {
59
66
  this.#failure = error;
@@ -64,6 +71,7 @@ export class SessionPersistence {
64
71
  await this.#lease?.close();
65
72
  this.#lease = undefined;
66
73
  this.#sessionId = null;
74
+ this.#snapshot = undefined;
67
75
  this.#failure = undefined;
68
76
  }
69
77
  async close() {
@@ -105,15 +105,18 @@ export class DurableSessionStore {
105
105
  if (!replacesHead && !extendsTree) {
106
106
  throw new Error("session checkpoint cannot be recovered safely");
107
107
  }
108
- head = {
108
+ head = Object.freeze({
109
109
  version: SESSION_SCHEMA,
110
110
  sequence: candidate.sequence,
111
111
  nodeId: candidate.node.id,
112
112
  parentId: candidate.node.parentId,
113
113
  revision: candidate.node.revision,
114
114
  updatedAt: candidate.updatedAt,
115
- };
116
- await atomicWrite(path.join(directory, "head.json"), encodeHead(head), { mode: FILE_MODE });
115
+ });
116
+ await atomicWrite(path.join(directory, "head.json"), encodeHead(head), {
117
+ mode: FILE_MODE,
118
+ validate: async () => assertDirectory(directory),
119
+ });
117
120
  }
118
121
  const nodes = stored.map((entry) => entry.node);
119
122
  const conversation = ConversationTree.restore(nodes, head.nodeId);
@@ -129,21 +132,21 @@ export class DurableSessionStore {
129
132
  const token = claim === true ? leaseToken() : undefined;
130
133
  const temporary = path.join(this.#bucket, `.${id}.${randomUUID()}.tmp`);
131
134
  const target = this.#sessionDirectory(id);
132
- const meta = {
135
+ const meta = Object.freeze({
133
136
  version: SESSION_SCHEMA,
134
137
  id,
135
138
  workspaceRoot: this.workspaceRoot,
136
139
  workspaceDigest: this.workspaceDigest,
137
140
  createdAt: now,
138
- };
139
- const head = {
141
+ });
142
+ const head = Object.freeze({
140
143
  version: SESSION_SCHEMA,
141
144
  sequence: conversation.nodes.length,
142
145
  nodeId: active.id,
143
146
  parentId: active.parentId,
144
147
  revision: active.revision,
145
148
  updatedAt: now,
146
- };
149
+ });
147
150
  try {
148
151
  await makePrivateDirectory(temporary);
149
152
  const nodes = path.join(temporary, "nodes");
@@ -169,12 +172,26 @@ export class DurableSessionStore {
169
172
  const lease = sessionLease(id, path.join(target, "active"), token);
170
173
  return Object.freeze({ ...snapshot, lease });
171
174
  }
172
- async checkpoint(id, conversation) {
173
- const previous = await this.load(id);
175
+ async checkpoint(previous, conversation) {
176
+ assertSnapshot(previous, this.workspaceRoot, this.workspaceDigest);
177
+ const id = previous.meta.id;
178
+ const directory = this.#sessionDirectory(id);
179
+ await assertDirectory(directory);
180
+ const nodesDirectory = path.join(directory, "nodes");
181
+ const validateNodesDirectory = async () => {
182
+ await assertDirectory(directory);
183
+ await assertDirectory(nodesDirectory);
184
+ };
185
+ await validateNodesDirectory();
186
+ const currentHead = decodeHead(await readJson(path.join(directory, "head.json"), SESSION_FILE_LIMITS.metadataBytes));
187
+ if (!sameHead(currentHead, previous.head)) {
188
+ throw new Error("session head changed after its verified snapshot");
189
+ }
174
190
  const active = conversation.activeNode;
175
191
  if (active === undefined)
176
192
  throw new Error("an empty conversation cannot be checkpointed");
177
- if (sameNode(active, previous.conversation.activeNode)) {
193
+ if (active === previous.conversation.activeNode &&
194
+ conversation.nodes === previous.conversation.nodes) {
178
195
  return Object.freeze({ ...previous, conversation });
179
196
  }
180
197
  const replacesHead = active.id === previous.head.nodeId &&
@@ -187,18 +204,23 @@ export class DurableSessionStore {
187
204
  throw new Error("session checkpoint does not extend its durable tree");
188
205
  }
189
206
  assertSharedNodes(previous.conversation, conversation, replacesHead ? active.id : undefined);
207
+ if (extendsTree) {
208
+ await assertMissingNode(path.join(nodesDirectory, nodeName(active.id)));
209
+ }
190
210
  const now = new Date().toISOString();
191
- const head = {
211
+ const head = Object.freeze({
192
212
  version: SESSION_SCHEMA,
193
213
  sequence: previous.head.sequence + 1,
194
214
  nodeId: active.id,
195
215
  parentId: active.parentId,
196
216
  revision: active.revision,
197
217
  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 });
218
+ });
219
+ await atomicWrite(path.join(nodesDirectory, nodeName(active.id)), encodeNode(active, head.sequence, now), { mode: FILE_MODE, validate: async () => validateNodesDirectory() });
220
+ await atomicWrite(path.join(directory, "head.json"), encodeHead(head), {
221
+ mode: FILE_MODE,
222
+ validate: async () => assertDirectory(directory),
223
+ });
202
224
  return Object.freeze({ meta: previous.meta, head, conversation });
203
225
  }
204
226
  async claim(id) {
@@ -271,17 +293,44 @@ function assertSharedNodes(previous, next, replacedId) {
271
293
  if (node.id === replacedId)
272
294
  continue;
273
295
  const candidate = next.node(node.id);
274
- if (candidate === undefined || normalizedNode(candidate) !== normalizedNode(node)) {
296
+ if (candidate !== node) {
275
297
  throw new Error("session checkpoint rewrites prior conversation history");
276
298
  }
277
299
  }
278
300
  }
279
- function normalizedNode(node) {
280
- const encoded = JSON.parse(encodeNode(node, 1, "2026-01-01T00:00:00.000Z"));
281
- return JSON.stringify(encoded.node);
301
+ function assertSnapshot(snapshot, workspaceRoot, workspaceDigest) {
302
+ encodeMeta(snapshot.meta);
303
+ encodeHead(snapshot.head);
304
+ assertSessionId(snapshot.meta.id);
305
+ if (snapshot.meta.workspaceDigest !== workspaceDigest ||
306
+ workspaceKey(snapshot.meta.workspaceRoot) !== workspaceKey(workspaceRoot))
307
+ throw new Error("session snapshot belongs to a different workspace");
308
+ const active = snapshot.conversation.activeNode;
309
+ if (active === undefined ||
310
+ active.id !== snapshot.head.nodeId ||
311
+ active.parentId !== snapshot.head.parentId ||
312
+ active.revision !== snapshot.head.revision ||
313
+ snapshot.head.sequence < snapshot.conversation.nodes.length)
314
+ throw new Error("session snapshot does not match its verified head");
315
+ }
316
+ function sameHead(left, right) {
317
+ return left.version === right.version &&
318
+ left.sequence === right.sequence &&
319
+ left.nodeId === right.nodeId &&
320
+ left.parentId === right.parentId &&
321
+ left.revision === right.revision &&
322
+ left.updatedAt === right.updatedAt;
282
323
  }
283
- function sameNode(left, right) {
284
- return right !== undefined && normalizedNode(left) === normalizedNode(right);
324
+ async function assertMissingNode(file) {
325
+ try {
326
+ await lstat(file);
327
+ }
328
+ catch (error) {
329
+ if (error.code === "ENOENT")
330
+ return;
331
+ throw error;
332
+ }
333
+ throw new Error("session has an incomplete node outside its verified snapshot");
285
334
  }
286
335
  async function readNodes(directory) {
287
336
  await assertDirectory(directory);
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
  }