@giovannijecha/jecode 0.8.4 → 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 (99) hide show
  1. package/README.md +28 -9
  2. package/dist/accounts.js +47 -10
  3. package/dist/atomic.js +24 -14
  4. package/dist/batch-view.js +27 -2
  5. package/dist/batch.js +39 -4
  6. package/dist/bounded-file.js +212 -0
  7. package/dist/cli-info.js +0 -1
  8. package/dist/commands.js +2 -0
  9. package/dist/config.js +16 -7
  10. package/dist/context/automatic.js +35 -0
  11. package/dist/context/compactor.js +32 -2
  12. package/dist/context/manual.js +20 -3
  13. package/dist/context/request-projection.js +130 -0
  14. package/dist/controller-request.js +33 -11
  15. package/dist/credential-commands.js +25 -9
  16. package/dist/credentials.js +56 -18
  17. package/dist/directory-anchor.js +91 -0
  18. package/dist/file-identity.js +12 -0
  19. package/dist/model-command.js +5 -4
  20. package/dist/openai-account-command.js +23 -10
  21. package/dist/openai-account.js +7 -5
  22. package/dist/openai-oauth-callback.js +13 -4
  23. package/dist/openai-oauth-tokens.js +9 -7
  24. package/dist/openai-oauth.js +8 -6
  25. package/dist/permission-command.js +1 -1
  26. package/dist/process-lease.js +329 -0
  27. package/dist/provider-commands.js +11 -37
  28. package/dist/provider-errors.js +31 -7
  29. package/dist/provider-label.js +9 -3
  30. package/dist/providers/anthropic-stream.js +4 -1
  31. package/dist/providers/anthropic-wire.js +8 -3
  32. package/dist/providers/anthropic.js +39 -20
  33. package/dist/providers/catalog.js +4 -4
  34. package/dist/providers/failure.js +181 -0
  35. package/dist/providers/http.js +82 -23
  36. package/dist/providers/index.js +1 -5
  37. package/dist/providers/ollama-context.js +42 -0
  38. package/dist/providers/ollama-endpoint.js +7 -34
  39. package/dist/providers/ollama-stream.js +5 -1
  40. package/dist/providers/ollama.js +36 -158
  41. package/dist/providers/openai-codex.js +71 -45
  42. package/dist/providers/openai-stream.js +26 -2
  43. package/dist/providers/openai.js +51 -25
  44. package/dist/providers/sse.js +52 -8
  45. package/dist/request-identity.js +32 -0
  46. package/dist/sessions/bucket.js +55 -0
  47. package/dist/sessions/catalog-io.js +162 -0
  48. package/dist/sessions/catalog.js +3 -1
  49. package/dist/sessions/codec-messages.js +122 -0
  50. package/dist/sessions/codec-transcript.js +93 -0
  51. package/dist/sessions/codec-values.js +52 -0
  52. package/dist/sessions/codec.js +4 -257
  53. package/dist/sessions/files.js +158 -0
  54. package/dist/sessions/lease.js +132 -49
  55. package/dist/sessions/load.js +90 -0
  56. package/dist/sessions/runtime.js +15 -8
  57. package/dist/sessions/snapshot.js +33 -0
  58. package/dist/sessions/store.js +183 -378
  59. package/dist/settings-command.js +10 -5
  60. package/dist/settings.js +75 -22
  61. package/dist/stable-directory.js +148 -0
  62. package/dist/start.js +1 -2
  63. package/dist/store-lock.js +68 -84
  64. package/dist/tools/args.js +2 -2
  65. package/dist/tools/file-read.js +192 -0
  66. package/dist/tools/file-summary.js +9 -0
  67. package/dist/tools/{fs.js → file-write.js} +5 -171
  68. package/dist/tools/glob.js +107 -0
  69. package/dist/tools/index.js +2 -1
  70. package/dist/tools/search.js +66 -205
  71. package/dist/tools/text-boundary.js +7 -33
  72. package/dist/tui/app-workflows.js +9 -334
  73. package/dist/tui/approve.js +5 -3
  74. package/dist/tui/blocks.js +8 -7
  75. package/dist/tui/command-workflow.js +106 -0
  76. package/dist/tui/components/command-menu.js +7 -10
  77. package/dist/tui/components/footer.js +1 -1
  78. package/dist/tui/components/menu.js +74 -43
  79. package/dist/tui/components/messages.js +16 -9
  80. package/dist/tui/components/tool-evidence.js +107 -0
  81. package/dist/tui/components/tool-motion.js +32 -0
  82. package/dist/tui/components/tool.js +48 -202
  83. package/dist/tui/feedback.js +4 -0
  84. package/dist/tui/help.js +1 -1
  85. package/dist/tui/picker-layout.js +40 -0
  86. package/dist/tui/picker.js +7 -71
  87. package/dist/tui/session-view.js +7 -2
  88. package/dist/tui/tool-details.js +135 -0
  89. package/dist/tui/transcript-grammar.js +8 -1
  90. package/dist/tui/transcript-view.js +26 -112
  91. package/dist/tui/turn-workflow.js +264 -0
  92. package/dist/tui/turn.js +7 -140
  93. package/dist/tui/workflow-types.js +2 -0
  94. package/dist/tui/workspace.js +21 -7
  95. package/dist/user-store.js +23 -31
  96. package/package.json +14 -15
  97. package/dist/ollama-settings-command.js +0 -74
  98. package/dist/tools/ripgrep.js +0 -230
  99. package/dist/tui/motion.js +0 -32
package/README.md CHANGED
@@ -26,9 +26,9 @@
26
26
  <a href="https://github.com/giovannijecha/jecode/releases">Releases</a>
27
27
  </p>
28
28
 
29
- > Jecode is currently pre-1.0. The core loop is usable today, but commands and
30
- > terminal interactions may still evolve before 1.0. The release-candidate
31
- > surface is now frozen in the [compatibility contract](https://github.com/giovannijecha/jecode/blob/main/docs/COMPATIBILITY.md).
29
+ > Jecode is stabilizing for 1.0. New capabilities are deferred while correctness,
30
+ > recovery, performance, accessibility, and interface refinements continue within
31
+ > the [compatibility contract](https://github.com/giovannijecha/jecode/blob/main/docs/COMPATIBILITY.md).
32
32
 
33
33
  ## Why Jecode
34
34
 
@@ -37,11 +37,20 @@ 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
+ Tool records keep the target, outcome, and compact evidence together. Expand a
41
+ record to inspect its complete output or diff while the composer stays ready
42
+ at the full width of the terminal.
43
+
44
+ Menus keep the selected choice and its details together. Approvals separate
45
+ the target from the decision and make the scope of remembered access explicit.
46
+
47
+ Choose Anthropic API, OpenAI API, OpenAI Account with eligible ChatGPT access,
48
+ or Ollama API without changing the workflow. Ollama uses its cloud API only.
49
+ API connections and account access stay separate, and
50
+ the footer always names the route selected for the next request. Jecode is
51
+ written in TypeScript and released as plain JavaScript for Node.js, with no
52
+ installation scripts and zero third-party runtime dependencies. The runtime
53
+ stays small enough to inspect, understand, and change.
45
54
 
46
55
  ## Quick start
47
56
 
@@ -71,7 +80,8 @@ jecode
71
80
  ```
72
81
 
73
82
  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.
83
+ service and `/models` to select both the model and the connection that will run
84
+ it, then describe the work you want done.
75
85
 
76
86
  ```text
77
87
  Review this project, explain its architecture, and propose the smallest safe
@@ -164,6 +174,15 @@ The [user guide](https://github.com/giovannijecha/jecode/blob/main/docs/USAGE.md
164
174
  covers provider access, TUI controls, sessions and compaction, batch mode,
165
175
  configuration, and safety boundaries.
166
176
 
177
+ The [architecture guide](https://github.com/giovannijecha/jecode/blob/main/docs/ARCHITECTURE.md)
178
+ explains the current implementation and module boundaries. Maintainers use the
179
+ [release guide](https://github.com/giovannijecha/jecode/blob/main/docs/RELEASING.md)
180
+ for publishing and release recovery.
181
+
182
+ The [development environments](https://github.com/giovannijecha/jecode/blob/main/dev/README.md)
183
+ provide the TUI lab, current interface direction, and manual performance probes
184
+ for working from a source checkout.
185
+
167
186
  ## Community
168
187
 
169
188
  - Ask questions, share workflows, and explore early ideas in
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;
@@ -1,9 +1,14 @@
1
1
  // Project semantic blocks into a compact transcript for pipes.
2
- import { render } from "./tui/blocks.js";
2
+ import { renderAnswer, renderReasoning, renderUser } from "./tui/components/messages.js";
3
+ import { renderNotice } from "./tui/components/misc.js";
4
+ import { renderDetail } from "./tui/components/tool-evidence.js";
5
+ import { stateInk, stateMark } from "./tui/components/tool-motion.js";
6
+ import { toolDuration } from "./duration.js";
7
+ import { row } from "./ui/render.js";
3
8
  export function renderBatch(block, width, pal) {
4
9
  const rows = [];
5
10
  let lastWasBlank = false;
6
- for (const rendered of render(block, width, pal)) {
11
+ for (const rendered of batchRows(block, width, pal)) {
7
12
  const line = rendered.trimEnd();
8
13
  const blank = line === "";
9
14
  if (blank && lastWasBlank)
@@ -15,3 +20,23 @@ export function renderBatch(block, width, pal) {
15
20
  rows.pop();
16
21
  return rows;
17
22
  }
23
+ function batchRows(block, width, pal) {
24
+ switch (block.kind) {
25
+ case "answer": return renderAnswer(block, width, pal);
26
+ case "reasoning": return renderReasoning(block, width, pal);
27
+ case "user": return renderUser(block, width, pal);
28
+ case "notice": return renderNotice(block, width, pal);
29
+ case "tool": {
30
+ // Pipes cannot expand records or repaint a running connector.
31
+ const mark = stateMark(block);
32
+ const result = [block.right, block.durationMs === undefined ? "" : toolDuration(block.durationMs)]
33
+ .filter(Boolean).join(" · ");
34
+ return ["", row(width, [
35
+ { text: `${mark} `, fg: stateInk(block, pal) },
36
+ { text: block.name, fg: pal.ink.bright },
37
+ { text: block.target === "" ? "" : ` ${block.target}`, fg: pal.technical },
38
+ ], [{ text: result, fg: pal.ink.muted }]),
39
+ ...(block.body ?? []).map((detail) => renderDetail(detail, block.tone, width, pal))];
40
+ }
41
+ }
42
+ }
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";
@@ -15,12 +16,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";
17
18
  import { providerFailure } from "./provider-errors.js";
19
+ import { toolSpecs } from "./tools/index.js";
20
+ import { requestIdentityForSession } from "./request-identity.js";
18
21
  export async function runBatch(session, environment = {}) {
19
22
  const write = environment.write ?? ((text) => stdout.write(text));
20
23
  const width = environment.width ?? columns();
21
24
  const signal = environment.signal;
22
25
  const source = environment.lines ?? boundedInputLines(stdin);
23
26
  const lines = abortableLines(source, signal);
27
+ const automaticCompaction = automaticCompactionGate();
24
28
  const emit = (block) => {
25
29
  for (const line of renderBatch(block, width, session.palette))
26
30
  write(`${line}\n`);
@@ -37,7 +41,15 @@ export async function runBatch(session, environment = {}) {
37
41
  if ((await handleCommand(line, session, {
38
42
  emit,
39
43
  signal,
40
- 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
+ },
41
53
  })) === "exit")
42
54
  break;
43
55
  continue;
@@ -56,12 +68,14 @@ export async function runBatch(session, environment = {}) {
56
68
  provider: session.provider,
57
69
  model: session.model,
58
70
  compactionPercent: session.config.compactionPercent,
71
+ signal,
59
72
  });
60
73
  };
61
74
  const user = { role: "user", content: [{ kind: "text", text: line }] };
62
75
  history.push(user);
63
76
  modelHistory.push(structuredClone(user));
64
77
  const turn = events(emit, session);
78
+ const specs = toolSpecs(session.tools);
65
79
  const commit = (checkpoint, settlement) => {
66
80
  session.conversation = session.conversation.commit({
67
81
  ...(nodeId === undefined ? {} : { nodeId }),
@@ -83,11 +97,17 @@ export async function runBatch(session, environment = {}) {
83
97
  (request.error === undefined || !isContextOverflow(request.error))) {
84
98
  return undefined;
85
99
  }
86
- const force = request.reason === "overflow";
100
+ const force = request.reason === "overflow" || request.projectionSaturated;
101
+ const key = automaticCompactionKey(session.provider.id, session.model, nodeId ?? prospectiveNodeId, checkpoint.length);
102
+ const attempt = { key, reason: request.reason };
103
+ if (!automaticCompaction.allows(attempt))
104
+ return undefined;
105
+ let attempted = false;
87
106
  const result = await compactContext({
88
107
  provider: session.provider,
89
108
  model: session.model,
90
109
  effort: session.config.effort,
110
+ signal,
91
111
  context: projected,
92
112
  turn: checkpoint.slice(before),
93
113
  nodeId: nodeId ?? prospectiveNodeId,
@@ -96,9 +116,22 @@ export async function runBatch(session, environment = {}) {
96
116
  estimatedInputTokens: request.inputTokens,
97
117
  force,
98
118
  policy: request.policy,
119
+ requestEnvelope: {
120
+ system: session.system,
121
+ tools: specs,
122
+ maxOutputTokens: session.config.maxTokens,
123
+ },
124
+ requestIdentity: requestIdentityForSession(session),
125
+ onBegin: () => {
126
+ attempted = true;
127
+ },
99
128
  });
100
- if (result === undefined)
129
+ if (result === undefined) {
130
+ if (attempted && signal?.aborted !== true)
131
+ automaticCompaction.failed(attempt);
101
132
  return undefined;
133
+ }
134
+ automaticCompaction.succeeded(attempt);
102
135
  context = result.anchor;
103
136
  if (result.usage !== undefined)
104
137
  recordAuxiliaryUsage(session.usage, result.usage);
@@ -111,6 +144,7 @@ export async function runBatch(session, environment = {}) {
111
144
  reason: "budget",
112
145
  policy: await policy(),
113
146
  inputTokens: session.usage.lastInputTokens,
147
+ projectionSaturated: false,
114
148
  });
115
149
  if (compacted !== undefined)
116
150
  commit(checkpoint, settlement);
@@ -123,7 +157,7 @@ export async function runBatch(session, environment = {}) {
123
157
  throwIfAborted(signal);
124
158
  if (!(error instanceof Error))
125
159
  throw error;
126
- const message = providerFailure(session.provider, error);
160
+ const message = providerFailure(session.provider, error, true);
127
161
  if (message === error.message)
128
162
  throw error;
129
163
  throw new Error(message, { cause: error });
@@ -186,6 +220,7 @@ function options(session, contextPolicy) {
186
220
  maxTokens: session.config.maxTokens,
187
221
  contextPolicy,
188
222
  effort: session.config.effort,
223
+ requestIdentity: requestIdentityForSession(session),
189
224
  maxModelRequests: session.config.maxModelRequests,
190
225
  toolContext: { root: session.config.root },
191
226
  };
@@ -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/cli-info.js CHANGED
@@ -12,7 +12,6 @@ Options:
12
12
  --root <path> workspace root (default: current directory)
13
13
  --provider <id> anthropic, openai, openai-codex, or ollama
14
14
  --model <id> model for the selected provider
15
- --ollama-host <url> Ollama Cloud, local, or custom endpoint
16
15
  --effort <level> low, medium, high, xhigh, or max
17
16
  --max-tokens <number> output-token ceiling
18
17
  --max-steps <number> optional per-turn model-request budget
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" });