@giovannijecha/jecode 0.8.3 → 0.8.5

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 (55) hide show
  1. package/README.md +11 -8
  2. package/assets/wordmark-steel.svg +3 -0
  3. package/dist/accounts.js +47 -10
  4. package/dist/atomic.js +24 -14
  5. package/dist/batch.js +49 -4
  6. package/dist/bounded-file.js +212 -0
  7. package/dist/commands.js +2 -0
  8. package/dist/config.js +8 -4
  9. package/dist/context/automatic.js +35 -0
  10. package/dist/context/compactor.js +32 -2
  11. package/dist/context/manual.js +20 -3
  12. package/dist/context/request-projection.js +130 -0
  13. package/dist/controller-request.js +33 -11
  14. package/dist/credential-commands.js +22 -6
  15. package/dist/credentials.js +56 -18
  16. package/dist/directory-anchor.js +91 -0
  17. package/dist/file-identity.js +12 -0
  18. package/dist/model-command.js +5 -4
  19. package/dist/openai-account-command.js +13 -2
  20. package/dist/process-lease.js +329 -0
  21. package/dist/provider-commands.js +53 -10
  22. package/dist/provider-errors.js +94 -3
  23. package/dist/provider-label.js +13 -0
  24. package/dist/providers/anthropic-stream.js +4 -1
  25. package/dist/providers/anthropic-wire.js +8 -3
  26. package/dist/providers/anthropic.js +39 -19
  27. package/dist/providers/catalog.js +4 -4
  28. package/dist/providers/failure.js +181 -0
  29. package/dist/providers/http.js +86 -57
  30. package/dist/providers/ollama-stream.js +5 -1
  31. package/dist/providers/ollama.js +31 -19
  32. package/dist/providers/openai-codex.js +67 -41
  33. package/dist/providers/openai-stream.js +69 -9
  34. package/dist/providers/openai.js +51 -24
  35. package/dist/providers/sse.js +89 -17
  36. package/dist/request-identity.js +32 -0
  37. package/dist/sessions/catalog.js +199 -0
  38. package/dist/sessions/lease.js +132 -49
  39. package/dist/sessions/runtime.js +15 -8
  40. package/dist/sessions/store.js +451 -183
  41. package/dist/settings.js +62 -10
  42. package/dist/stable-directory.js +148 -0
  43. package/dist/store-lock.js +68 -84
  44. package/dist/tools/args.js +2 -2
  45. package/dist/tools/fs.js +124 -102
  46. package/dist/tools/search.js +81 -107
  47. package/dist/tools/text-boundary.js +7 -33
  48. package/dist/tui/app-workflows.js +32 -4
  49. package/dist/tui/components/footer.js +1 -1
  50. package/dist/tui/feedback.js +4 -0
  51. package/dist/tui/session-view.js +7 -2
  52. package/dist/tui/workspace.js +21 -7
  53. package/dist/user-store.js +23 -31
  54. package/package.json +4 -4
  55. package/dist/tools/ripgrep.js +0 -230
package/README.md CHANGED
@@ -37,11 +37,12 @@ follow tool calls and diffs, steer the model while it runs, and resume the same
37
37
  conversation later. One controller carries each turn from prompt to result—no
38
38
  delegated agents or hidden model workers.
39
39
 
40
- Choose Anthropic, OpenAI, an eligible ChatGPT account, or Ollama without
41
- changing the workflow. Jecode is written in TypeScript and released as plain
42
- JavaScript for Node.js, with no installation scripts and zero third-party
43
- runtime dependencies. The runtime stays small enough to inspect, understand,
44
- and change.
40
+ Choose Anthropic API, OpenAI API, an eligible ChatGPT account, or Ollama without
41
+ changing the workflow. API connections and account access stay separate, and
42
+ the footer always names the route selected for the next request. Jecode is
43
+ written in TypeScript and released as plain JavaScript for Node.js, with no
44
+ installation scripts and zero third-party runtime dependencies. The runtime
45
+ stays small enough to inspect, understand, and change.
45
46
 
46
47
  ## Quick start
47
48
 
@@ -71,7 +72,8 @@ jecode
71
72
  ```
72
73
 
73
74
  Jecode opens directly on an empty composer. Use `/providers` to connect a
74
- service, `/models` to choose a model, then describe the work you want done.
75
+ service and `/models` to select both the model and the connection that will run
76
+ it, then describe the work you want done.
75
77
 
76
78
  ```text
77
79
  Review this project, explain its architecture, and propose the smallest safe
@@ -175,8 +177,9 @@ configuration, and safety boundaries.
175
177
 
176
178
  Public pull requests are not accepted at this stage. Code changes remain a
177
179
  maintainer and invited-collaborator workflow. See
178
- [CONTRIBUTING.md](CONTRIBUTING.md) and
179
- [CODE_OF_CONDUCT.md](CODE_OF_CONDUCT.md).
180
+ [CONTRIBUTING.md](https://github.com/giovannijecha/jecode/blob/main/CONTRIBUTING.md)
181
+ and
182
+ [CODE_OF_CONDUCT.md](https://github.com/giovannijecha/jecode/blob/main/CODE_OF_CONDUCT.md).
180
183
 
181
184
  ## License
182
185
 
@@ -0,0 +1,3 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 760 180" role="img" aria-label="jecode">
2
+ <text x="380" y="128" fill="#669BD2" font-family="Inter,Nunito Sans,system-ui,sans-serif" font-size="132" font-weight="650" letter-spacing="-7" text-anchor="middle">jecode</text>
3
+ </svg>
package/dist/accounts.js CHANGED
@@ -1,10 +1,10 @@
1
1
  // OAuth accounts persisted under ~/.jecode, apart from API keys.
2
- import { chmod, mkdir } from "node:fs/promises";
3
2
  import * as path from "node:path";
4
3
  import { atomicWrite } from "./atomic.js";
4
+ import { assertDirectoryAnchor, captureDirectDirectorySync, preparePrivateDirectory, } from "./directory-anchor.js";
5
5
  import { withStoreLock } from "./store-lock.js";
6
6
  import { userDataLabel, userDataPath } from "./user-data.js";
7
- import { assertStoreText, readBoundedJsonSync, USER_STORE_LIMITS } from "./user-store.js";
7
+ import { assertStoreText, readBoundedJsonForMutationSync, readBoundedJsonSync, USER_STORE_LIMITS, } from "./user-store.js";
8
8
  let cached;
9
9
  export function openAICodexAccount() {
10
10
  const account = store().accounts["openai-codex"];
@@ -33,11 +33,10 @@ export function accountsLabel() {
33
33
  export async function updateOpenAICodexAccount(change, signal) {
34
34
  const file = accountsPath();
35
35
  const directory = path.dirname(file);
36
- await mkdir(directory, { recursive: true, mode: 0o700 });
37
- if (process.platform !== "win32")
38
- await chmod(directory, 0o700);
39
- return withStoreLock(file, async () => {
40
- const current = readStore(file);
36
+ const anchor = await preparePrivateDirectory(directory, "account store directory");
37
+ const anchoredFile = path.join(anchor.path, path.basename(file));
38
+ return withStoreLock(anchoredFile, async () => {
39
+ const current = readStoreForMutation(anchoredFile, anchor);
41
40
  const next = await change(current.accounts["openai-codex"]);
42
41
  const normalized = next === undefined ? undefined : normalizeOpenAI(next);
43
42
  if (next !== undefined && normalized === undefined)
@@ -50,10 +49,13 @@ export async function updateOpenAICodexAccount(change, signal) {
50
49
  const updated = { version: 1, accounts };
51
50
  const text = `${JSON.stringify(updated, null, 2)}\n`;
52
51
  assertStoreText(text, USER_STORE_LIMITS.accountsBytes);
53
- await atomicWrite(file, text, { mode: 0o600 });
52
+ await atomicWrite(anchoredFile, text, {
53
+ mode: 0o600,
54
+ validate: async () => assertDirectoryAnchor(anchor),
55
+ });
54
56
  cached = updated;
55
57
  return normalized === undefined ? undefined : { ...normalized };
56
- }, signal);
58
+ }, signal, async () => assertDirectoryAnchor(anchor));
57
59
  }
58
60
  export function reloadAccounts() {
59
61
  cached = undefined;
@@ -65,12 +67,43 @@ function store() {
65
67
  }
66
68
  function readStore(file) {
67
69
  try {
68
- return normalize(readBoundedJsonSync(file, USER_STORE_LIMITS.accountsBytes));
70
+ const directory = captureDirectDirectorySync(path.dirname(file), "account store directory");
71
+ const anchoredFile = path.join(directory.path, path.basename(file));
72
+ return normalize(readBoundedJsonSync(anchoredFile, USER_STORE_LIMITS.accountsBytes, directory));
69
73
  }
70
74
  catch {
71
75
  return { version: 1, accounts: {} };
72
76
  }
73
77
  }
78
+ function readStoreForMutation(file, directory) {
79
+ const value = readBoundedJsonForMutationSync(file, USER_STORE_LIMITS.accountsBytes, "account store", directory);
80
+ if (value === undefined)
81
+ return { version: 1, accounts: {} };
82
+ if (!record(value) || value["version"] !== 1 || !record(value["accounts"]) ||
83
+ hasUnknownKeys(value, ["version", "accounts"])) {
84
+ throw new Error("account store has an unsupported structure");
85
+ }
86
+ const accounts = value["accounts"];
87
+ if (Object.keys(accounts).some((name) => name !== "openai-codex")) {
88
+ throw new Error("account store has an unsupported structure");
89
+ }
90
+ const raw = accounts["openai-codex"];
91
+ if (raw === undefined)
92
+ return { version: 1, accounts: {} };
93
+ if (!record(raw) || hasUnknownKeys(raw, [
94
+ "accessToken",
95
+ "refreshToken",
96
+ "expiresAt",
97
+ "accountId",
98
+ "email",
99
+ "plan",
100
+ ]))
101
+ throw new Error("account store has an unsupported structure");
102
+ const account = normalizeOpenAI(raw);
103
+ if (account === undefined)
104
+ throw new Error("account store has an invalid OpenAI account");
105
+ return { version: 1, accounts: { "openai-codex": account } };
106
+ }
74
107
  function normalize(value) {
75
108
  if (!record(value) || value["version"] !== 1 || !record(value["accounts"])) {
76
109
  return { version: 1, accounts: {} };
@@ -112,3 +145,7 @@ function nonempty(value, max) {
112
145
  function record(value) {
113
146
  return typeof value === "object" && value !== null && !Array.isArray(value);
114
147
  }
148
+ function hasUnknownKeys(value, known) {
149
+ const allowed = new Set(known);
150
+ return Object.keys(value).some((key) => !allowed.has(key));
151
+ }
package/dist/atomic.js CHANGED
@@ -1,8 +1,9 @@
1
- // Crash-safe replacement of one file, using a verified temporary sibling and
2
- // rename.
1
+ // Process-crash-safe replacement of one file, using a verified temporary
2
+ // sibling and rename. POSIX also receives a best-effort parent-directory sync.
3
3
  import { lstat, open, rename, rm, stat } from "node:fs/promises";
4
4
  import { randomUUID } from "node:crypto";
5
5
  import * as path from "node:path";
6
+ import { fileIdentity, sameFileIdentity } from "./file-identity.js";
6
7
  export async function atomicWrite(file, content, options = {}) {
7
8
  const temporary = path.join(path.dirname(file), `.${path.basename(file)}.${process.pid}.${randomUUID()}.tmp`);
8
9
  let handle;
@@ -14,7 +15,7 @@ export async function atomicWrite(file, content, options = {}) {
14
15
  const permissions = options.mode ?? (await existingMode(file));
15
16
  throwIfAborted(options.signal);
16
17
  handle = await open(temporary, "wx", permissions);
17
- identity = fileIdentity(await handle.stat());
18
+ identity = fileIdentity(await handle.stat({ bigint: true }));
18
19
  throwIfAborted(options.signal);
19
20
  await options.validate?.("before-write");
20
21
  throwIfAborted(options.signal);
@@ -26,7 +27,7 @@ export async function atomicWrite(file, content, options = {}) {
26
27
  }
27
28
  await handle.sync();
28
29
  throwIfAborted(options.signal);
29
- identity = fileIdentity(await handle.stat());
30
+ identity = fileIdentity(await handle.stat({ bigint: true }));
30
31
  await options.validate?.("before-rename");
31
32
  throwIfAborted(options.signal);
32
33
  await assertNamedFile(temporary, identity);
@@ -35,6 +36,7 @@ export async function atomicWrite(file, content, options = {}) {
35
36
  const completed = handle;
36
37
  handle = undefined;
37
38
  await completed.close().catch(() => undefined);
39
+ await syncParentDirectory(file);
38
40
  }
39
41
  catch (error) {
40
42
  if (handle !== undefined) {
@@ -47,14 +49,30 @@ export async function atomicWrite(file, content, options = {}) {
47
49
  throw error;
48
50
  }
49
51
  }
52
+ async function syncParentDirectory(file) {
53
+ if (process.platform === "win32")
54
+ return;
55
+ let directory;
56
+ try {
57
+ directory = await open(path.dirname(file), "r");
58
+ await directory.sync();
59
+ }
60
+ catch {
61
+ // The rename is already committed and some filesystems do not support
62
+ // directory fsync. Do not report a successful replacement as failed.
63
+ }
64
+ finally {
65
+ await directory?.close().catch(() => undefined);
66
+ }
67
+ }
50
68
  function throwIfAborted(signal) {
51
69
  if (signal?.aborted !== true)
52
70
  return;
53
71
  throw signal.reason instanceof Error ? signal.reason : new Error("interrupted");
54
72
  }
55
73
  async function assertNamedFile(file, expected) {
56
- const details = await lstat(file);
57
- if (details.isSymbolicLink() || !sameFile(expected, fileIdentity(details))) {
74
+ const details = await lstat(file, { bigint: true });
75
+ if (details.isSymbolicLink() || !sameFileIdentity(expected, fileIdentity(details))) {
58
76
  throw new Error("atomic write target changed during replacement");
59
77
  }
60
78
  }
@@ -70,14 +88,6 @@ async function removeTemporary(file, expected, validate) {
70
88
  // A changed path is no longer ours to remove.
71
89
  }
72
90
  }
73
- function fileIdentity(details) {
74
- return { dev: details.dev, ino: details.ino, birthtimeMs: details.birthtimeMs };
75
- }
76
- function sameFile(left, right) {
77
- if (left.dev !== right.dev || left.ino !== right.ino)
78
- return false;
79
- return left.ino !== 0 || left.birthtimeMs === right.birthtimeMs;
80
- }
81
91
  async function existingMode(file) {
82
92
  if (process.platform === "win32")
83
93
  return undefined;
package/dist/batch.js CHANGED
@@ -5,6 +5,7 @@
5
5
  import { stdin, stdout } from "node:process";
6
6
  import { runTurn } from "./controller.js";
7
7
  import { resolveContextPolicy } from "./context/capacity.js";
8
+ import { automaticCompactionGate, automaticCompactionKey, } from "./context/automatic.js";
8
9
  import { compactContext } from "./context/compactor.js";
9
10
  import { compactSession } from "./context/manual.js";
10
11
  import { isContextOverflow } from "./context/policy.js";
@@ -14,12 +15,16 @@ import { columns } from "./ui/render.js";
14
15
  import { terminalText } from "./ui/terminal-text.js";
15
16
  import { recordAuxiliaryUsage, recordRequestInput, recordUsage } from "./usage.js";
16
17
  import { assertPromptLength, boundedInputLines } from "./input-boundary.js";
18
+ import { providerFailure } from "./provider-errors.js";
19
+ import { toolSpecs } from "./tools/index.js";
20
+ import { requestIdentityForSession } from "./request-identity.js";
17
21
  export async function runBatch(session, environment = {}) {
18
22
  const write = environment.write ?? ((text) => stdout.write(text));
19
23
  const width = environment.width ?? columns();
20
24
  const signal = environment.signal;
21
25
  const source = environment.lines ?? boundedInputLines(stdin);
22
26
  const lines = abortableLines(source, signal);
27
+ const automaticCompaction = automaticCompactionGate();
23
28
  const emit = (block) => {
24
29
  for (const line of renderBatch(block, width, session.palette))
25
30
  write(`${line}\n`);
@@ -36,7 +41,15 @@ export async function runBatch(session, environment = {}) {
36
41
  if ((await handleCommand(line, session, {
37
42
  emit,
38
43
  signal,
39
- compact: () => compactSession(session, { signal }),
44
+ reset: () => {
45
+ automaticCompaction.reset();
46
+ },
47
+ compact: async () => {
48
+ const result = await compactSession(session, { signal });
49
+ if (result === "compacted")
50
+ automaticCompaction.reset();
51
+ return result;
52
+ },
40
53
  })) === "exit")
41
54
  break;
42
55
  continue;
@@ -61,6 +74,7 @@ export async function runBatch(session, environment = {}) {
61
74
  history.push(user);
62
75
  modelHistory.push(structuredClone(user));
63
76
  const turn = events(emit, session);
77
+ const specs = toolSpecs(session.tools);
64
78
  const commit = (checkpoint, settlement) => {
65
79
  session.conversation = session.conversation.commit({
66
80
  ...(nodeId === undefined ? {} : { nodeId }),
@@ -82,7 +96,12 @@ export async function runBatch(session, environment = {}) {
82
96
  (request.error === undefined || !isContextOverflow(request.error))) {
83
97
  return undefined;
84
98
  }
85
- const force = request.reason === "overflow";
99
+ const force = request.reason === "overflow" || request.projectionSaturated;
100
+ const key = automaticCompactionKey(session.provider.id, session.model, nodeId ?? prospectiveNodeId, checkpoint.length);
101
+ const attempt = { key, reason: request.reason };
102
+ if (!automaticCompaction.allows(attempt))
103
+ return undefined;
104
+ let attempted = false;
86
105
  const result = await compactContext({
87
106
  provider: session.provider,
88
107
  model: session.model,
@@ -95,9 +114,22 @@ export async function runBatch(session, environment = {}) {
95
114
  estimatedInputTokens: request.inputTokens,
96
115
  force,
97
116
  policy: request.policy,
117
+ requestEnvelope: {
118
+ system: session.system,
119
+ tools: specs,
120
+ maxOutputTokens: session.config.maxTokens,
121
+ },
122
+ requestIdentity: requestIdentityForSession(session),
123
+ onBegin: () => {
124
+ attempted = true;
125
+ },
98
126
  });
99
- if (result === undefined)
127
+ if (result === undefined) {
128
+ if (attempted && signal?.aborted !== true)
129
+ automaticCompaction.failed(attempt);
100
130
  return undefined;
131
+ }
132
+ automaticCompaction.succeeded(attempt);
101
133
  context = result.anchor;
102
134
  if (result.usage !== undefined)
103
135
  recordAuxiliaryUsage(session.usage, result.usage);
@@ -110,12 +142,24 @@ export async function runBatch(session, environment = {}) {
110
142
  reason: "budget",
111
143
  policy: await policy(),
112
144
  inputTokens: session.usage.lastInputTokens,
145
+ projectionSaturated: false,
113
146
  });
114
147
  if (compacted !== undefined)
115
148
  commit(checkpoint, settlement);
116
149
  return compacted;
117
150
  };
118
- await runTurn(history, options(session, policy), turn, signal, modelHistory);
151
+ try {
152
+ await runTurn(history, options(session, policy), turn, signal, modelHistory);
153
+ }
154
+ catch (error) {
155
+ throwIfAborted(signal);
156
+ if (!(error instanceof Error))
157
+ throw error;
158
+ const message = providerFailure(session.provider, error, true);
159
+ if (message === error.message)
160
+ throw error;
161
+ throw new Error(message, { cause: error });
162
+ }
119
163
  turn.flush();
120
164
  }
121
165
  throwIfAborted(signal);
@@ -174,6 +218,7 @@ function options(session, contextPolicy) {
174
218
  maxTokens: session.config.maxTokens,
175
219
  contextPolicy,
176
220
  effort: session.config.effort,
221
+ requestIdentity: requestIdentityForSession(session),
177
222
  maxModelRequests: session.config.maxModelRequests,
178
223
  toolContext: { root: session.config.root },
179
224
  };
@@ -0,0 +1,212 @@
1
+ // Descriptor-based reads for untrusted local files. Identity-sensitive
2
+ // metadata stays bigint so NTFS file IDs cannot collide through rounding.
3
+ import { constants } from "node:fs";
4
+ import { closeSync, fstatSync, lstatSync, openSync, readSync, } from "node:fs";
5
+ import { lstat, open } from "node:fs/promises";
6
+ import { fileIdentity, sameFileIdentity } from "./file-identity.js";
7
+ export class BoundedFileError extends Error {
8
+ kind;
9
+ constructor(kind, message) {
10
+ super(message);
11
+ this.name = "BoundedFileError";
12
+ this.kind = kind;
13
+ }
14
+ }
15
+ /**
16
+ * Run one reader against a verified regular-file descriptor, then confirm
17
+ * that the descriptor and named path still identify the same stable file.
18
+ */
19
+ export async function withStableFile(file, options, reader) {
20
+ const label = options.label ?? "file";
21
+ requireOptionalLimit(options.maxBytes);
22
+ throwIfAborted(options.signal);
23
+ await options.validate?.();
24
+ const before = await lstat(file, { bigint: true });
25
+ requireRegular(before, label);
26
+ requireSize(before, options.maxBytes, label);
27
+ if (options.expected !== undefined && !sameExpectation(options.expected, before)) {
28
+ throw changed(label);
29
+ }
30
+ await options.beforeOpen?.();
31
+ await options.validate?.();
32
+ throwIfAborted(options.signal);
33
+ let handle;
34
+ try {
35
+ handle = await open(file, readFlags());
36
+ }
37
+ catch (error) {
38
+ if (unsafeOpenError(error))
39
+ throw changed(label);
40
+ throw error;
41
+ }
42
+ try {
43
+ const opened = await handle.stat({ bigint: true });
44
+ requireRegular(opened, label);
45
+ requireSize(opened, options.maxBytes, label);
46
+ if (!sameIdentity(before, opened))
47
+ throw changed(label);
48
+ await options.validate?.();
49
+ const value = await reader(handle, opened);
50
+ throwIfAborted(options.signal);
51
+ await options.validate?.();
52
+ const after = await handle.stat({ bigint: true });
53
+ let linked;
54
+ try {
55
+ linked = await lstat(file, { bigint: true });
56
+ }
57
+ catch (error) {
58
+ if (unsafeOpenError(error))
59
+ throw changed(label);
60
+ throw error;
61
+ }
62
+ if (!stableMetadata(opened, after) || !sameIdentity(opened, after) ||
63
+ linked.isSymbolicLink() || !linked.isFile() ||
64
+ !stableMetadata(opened, linked) || !sameIdentity(opened, linked))
65
+ throw changed(label);
66
+ await options.validate?.();
67
+ return value;
68
+ }
69
+ finally {
70
+ await handle.close();
71
+ }
72
+ }
73
+ /** Open, verify, and read one complete bounded regular file. */
74
+ export async function readBoundedFile(file, maxBytes, options = {}) {
75
+ requireLimit(maxBytes);
76
+ return withStableFile(file, { ...options, maxBytes }, async (handle, opened) => {
77
+ const expected = Number(opened.size);
78
+ const capacity = Math.min(maxBytes + 1, Math.max(1, expected + 1));
79
+ const bytes = Buffer.allocUnsafe(capacity);
80
+ let offset = 0;
81
+ while (offset < capacity) {
82
+ throwIfAborted(options.signal);
83
+ const read = await handle.read(bytes, offset, capacity - offset, offset);
84
+ if (read.bytesRead === 0)
85
+ break;
86
+ offset += read.bytesRead;
87
+ }
88
+ if (offset > maxBytes || offset !== expected)
89
+ throw changed(options.label ?? "file");
90
+ return bytes.subarray(0, offset);
91
+ });
92
+ }
93
+ export async function readBoundedText(file, maxBytes, options = {}) {
94
+ return (await readBoundedFile(file, maxBytes, options)).toString("utf8");
95
+ }
96
+ export function stableFileExpectation(details) {
97
+ return Object.freeze({
98
+ identity: fileIdentity(details),
99
+ size: details.size,
100
+ mtimeNs: details.mtimeNs,
101
+ ctimeNs: details.ctimeNs,
102
+ });
103
+ }
104
+ /** Synchronous companion for tiny startup stores whose public APIs are sync. */
105
+ export function readBoundedFileSync(file, maxBytes, options = "file") {
106
+ requireLimit(maxBytes);
107
+ const normalized = typeof options === "string" ? { label: options } : options;
108
+ const label = normalized.label ?? "file";
109
+ normalized.validate?.();
110
+ const before = lstatSync(file, { bigint: true });
111
+ requireRegular(before, label);
112
+ requireSize(before, maxBytes, label);
113
+ normalized.beforeOpen?.();
114
+ normalized.validate?.();
115
+ let descriptor;
116
+ try {
117
+ descriptor = openSync(file, readFlags());
118
+ }
119
+ catch (error) {
120
+ if (unsafeOpenError(error))
121
+ throw changed(label);
122
+ throw error;
123
+ }
124
+ try {
125
+ const opened = fstatSync(descriptor, { bigint: true });
126
+ requireRegular(opened, label);
127
+ requireSize(opened, maxBytes, label);
128
+ if (!sameIdentity(before, opened))
129
+ throw changed(label);
130
+ normalized.validate?.();
131
+ const expected = Number(opened.size);
132
+ const capacity = Math.min(maxBytes + 1, Math.max(1, expected + 1));
133
+ const bytes = Buffer.allocUnsafe(capacity);
134
+ let offset = 0;
135
+ while (offset < capacity) {
136
+ const count = readSync(descriptor, bytes, offset, capacity - offset, offset);
137
+ if (count === 0)
138
+ break;
139
+ offset += count;
140
+ }
141
+ normalized.afterRead?.();
142
+ const after = fstatSync(descriptor, { bigint: true });
143
+ let linked;
144
+ try {
145
+ linked = lstatSync(file, { bigint: true });
146
+ }
147
+ catch (error) {
148
+ if (unsafeOpenError(error))
149
+ throw changed(label);
150
+ throw error;
151
+ }
152
+ if (offset > maxBytes || offset !== expected ||
153
+ !stableMetadata(opened, after) || !sameIdentity(opened, after) ||
154
+ linked.isSymbolicLink() || !linked.isFile() ||
155
+ !stableMetadata(opened, linked) || !sameIdentity(opened, linked))
156
+ throw changed(label);
157
+ normalized.validate?.();
158
+ return bytes.subarray(0, offset);
159
+ }
160
+ finally {
161
+ closeSync(descriptor);
162
+ }
163
+ }
164
+ function readFlags() {
165
+ return process.platform === "win32"
166
+ ? "r"
167
+ : constants.O_RDONLY | (constants.O_NONBLOCK ?? 0) | (constants.O_NOFOLLOW ?? 0);
168
+ }
169
+ function requireRegular(details, label) {
170
+ if (details.isSymbolicLink() || !details.isFile()) {
171
+ throw new BoundedFileError("unsafe", `${label} must be a regular file`);
172
+ }
173
+ }
174
+ function requireSize(details, maxBytes, label) {
175
+ if (details.size < 0n || (maxBytes !== undefined && details.size > BigInt(maxBytes))) {
176
+ throw new BoundedFileError("too-large", `${label} exceeds ${maxBytes} bytes`);
177
+ }
178
+ }
179
+ function requireOptionalLimit(maxBytes) {
180
+ if (maxBytes !== undefined)
181
+ requireLimit(maxBytes);
182
+ }
183
+ function requireLimit(maxBytes) {
184
+ if (!Number.isSafeInteger(maxBytes) || maxBytes < 0 || maxBytes >= Number.MAX_SAFE_INTEGER) {
185
+ throw new RangeError("bounded file limit must be a non-negative safe integer");
186
+ }
187
+ }
188
+ function sameIdentity(left, right) {
189
+ return sameFileIdentity(fileIdentity(left), fileIdentity(right));
190
+ }
191
+ function stableMetadata(left, right) {
192
+ return left.size === right.size && left.mtimeNs === right.mtimeNs &&
193
+ left.ctimeNs === right.ctimeNs;
194
+ }
195
+ function sameExpectation(expected, current) {
196
+ return sameFileIdentity(expected.identity, fileIdentity(current)) &&
197
+ expected.size === current.size && expected.mtimeNs === current.mtimeNs &&
198
+ expected.ctimeNs === current.ctimeNs;
199
+ }
200
+ function unsafeOpenError(error) {
201
+ const code = error.code;
202
+ return code === "ENOENT" || code === "ELOOP" || code === "EISDIR" ||
203
+ code === "ENXIO" || code === "ENOTDIR";
204
+ }
205
+ function changed(label) {
206
+ return new BoundedFileError("changed", `${label} changed while it was being read`);
207
+ }
208
+ function throwIfAborted(signal) {
209
+ if (signal?.aborted !== true)
210
+ return;
211
+ throw signal.reason instanceof Error ? signal.reason : new Error("aborted");
212
+ }
package/dist/commands.js CHANGED
@@ -4,6 +4,7 @@
4
4
  // slash commands never become user messages. This file keeps discovery,
5
5
  // dispatch, and session operations outside the canonical transcript.
6
6
  import { ConversationTree } from "./conversation.js";
7
+ import { resetRequestIdentity } from "./request-identity.js";
7
8
  import { modelsCommand } from "./model-command.js";
8
9
  import { providersCommand } from "./provider-commands.js";
9
10
  import { permissionsCommand } from "./permission-command.js";
@@ -48,6 +49,7 @@ export async function handleCommand(line, session, host) {
48
49
  return "exit";
49
50
  case "new":
50
51
  await host.reset?.();
52
+ resetRequestIdentity(session);
51
53
  session.conversation = ConversationTree.empty();
52
54
  session.usage = emptyUsage();
53
55
  host.emit({ kind: "notice", text: "new session", tone: "info" });
package/dist/config.js CHANGED
@@ -36,16 +36,15 @@ export function loadConfig(argv, saved = readSettings()) {
36
36
  : { ollamaHost: parseOllamaEndpoint(ollamaHost).baseUrl }),
37
37
  reducedMotion: bool(flags["reduced-motion"], process.env.JECODE_REDUCED_MOTION, saved.reducedMotion ?? false),
38
38
  effort,
39
- // Every request streams, so a large ceiling costs nothing in timeout risk.
39
+ // This is a ceiling, not a target. Request budgeting may lower it to fit
40
+ // the selected model, while provider rate and billing limits still apply.
40
41
  maxTokens: toInt(pick(flags["max-tokens"], process.env.JECODE_MAX_TOKENS, String(saved.maxTokens ?? 64000)), "max-tokens"),
41
42
  ...(maxModelRequests === undefined
42
43
  ? {}
43
44
  : { maxModelRequests: toInt(maxModelRequests, "max-steps") }),
44
45
  compactionPercent: toPercent(pick(flags["compaction-percent"], process.env.JECODE_COMPACTION_PERCENT, String(saved.compactionPercent ?? DEFAULT_COMPACTION_PERCENT))),
45
46
  root: path.resolve(pick(flags.root, undefined, process.cwd())),
46
- autoApprove: flags["auto-approve"] === "true" ||
47
- flags["auto-approve"] === "1" ||
48
- process.env.JECODE_AUTO_APPROVE === "1",
47
+ autoApprove: autoApproval(flags["auto-approve"], process.env.JECODE_AUTO_APPROVE),
49
48
  ephemeral: bool(flags.ephemeral, process.env.JECODE_EPHEMERAL, false),
50
49
  };
51
50
  }
@@ -56,6 +55,11 @@ function bool(flag, env, fallback) {
56
55
  return env === "true" || env === "1";
57
56
  return fallback;
58
57
  }
58
+ function autoApproval(flag, env) {
59
+ if (flag !== undefined)
60
+ return flag === "true" || flag === "1";
61
+ return env === "1";
62
+ }
59
63
  function pick(flag, env, fallback) {
60
64
  if (flag !== undefined && flag !== "")
61
65
  return flag;
@@ -0,0 +1,35 @@
1
+ // Process-local breaker for optional automatic compaction. It prevents an
2
+ // unchanged provider projection from opening the same failing summary request
3
+ // at consecutive checkpoint/request boundaries.
4
+ const MAX_SETTLED_GENERATIONS = 4_096;
5
+ export function automaticCompactionGate() {
6
+ const settled = new Set();
7
+ return Object.freeze({
8
+ allows(attempt) {
9
+ return !settled.has(settledKey(attempt));
10
+ },
11
+ failed(attempt) {
12
+ remember(settled, settledKey(attempt));
13
+ },
14
+ succeeded(attempt) {
15
+ remember(settled, settledKey(attempt));
16
+ },
17
+ reset() {
18
+ settled.clear();
19
+ },
20
+ });
21
+ }
22
+ function settledKey(attempt) {
23
+ return `${attempt.reason}\0${attempt.key}`;
24
+ }
25
+ function remember(settled, key) {
26
+ settled.add(key);
27
+ if (settled.size <= MAX_SETTLED_GENERATIONS)
28
+ return;
29
+ const oldest = settled.values().next().value;
30
+ if (oldest !== undefined)
31
+ settled.delete(oldest);
32
+ }
33
+ export function automaticCompactionKey(providerId, model, nodeId, canonicalMessages) {
34
+ return `${providerId}\0${model}\0${nodeId}\0${canonicalMessages}`;
35
+ }