@giovannijecha/jecode 0.8.2 → 0.8.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (53) hide show
  1. package/README.md +22 -280
  2. package/assets/wordmark-steel.svg +3 -0
  3. package/dist/accounts.js +17 -13
  4. package/dist/batch.js +66 -6
  5. package/dist/config.js +6 -3
  6. package/dist/context/budget.js +13 -1
  7. package/dist/context/compactor.js +5 -4
  8. package/dist/context/estimate.js +43 -1
  9. package/dist/context/manual.js +8 -4
  10. package/dist/context/policy.js +68 -18
  11. package/dist/controller-request.js +8 -8
  12. package/dist/controller.js +6 -1
  13. package/dist/conversation.js +94 -33
  14. package/dist/credential-safety.js +56 -9
  15. package/dist/credentials.js +32 -4
  16. package/dist/input-boundary.js +80 -0
  17. package/dist/main.js +4 -1
  18. package/dist/openai-oauth-callback.js +1 -1
  19. package/dist/process-shutdown.js +52 -0
  20. package/dist/provider-commands.js +43 -6
  21. package/dist/provider-errors.js +59 -1
  22. package/dist/providers/anthropic-stream.js +24 -20
  23. package/dist/providers/anthropic-wire.js +7 -2
  24. package/dist/providers/http.js +4 -34
  25. package/dist/providers/ollama-wire.js +7 -15
  26. package/dist/providers/ollama.js +1 -0
  27. package/dist/providers/openai-codex.js +1 -1
  28. package/dist/providers/openai-stream.js +43 -7
  29. package/dist/providers/openai-wire.js +2 -16
  30. package/dist/providers/openai.js +1 -1
  31. package/dist/providers/sse.js +45 -17
  32. package/dist/providers/tool-input.js +17 -0
  33. package/dist/sessions/catalog.js +199 -0
  34. package/dist/sessions/codec.js +2 -1
  35. package/dist/sessions/lease.js +7 -0
  36. package/dist/sessions/runtime.js +11 -3
  37. package/dist/sessions/store.js +171 -78
  38. package/dist/settings.js +10 -5
  39. package/dist/start.js +12 -2
  40. package/dist/text-boundary.js +2 -0
  41. package/dist/tools/search.js +27 -18
  42. package/dist/tui/app-input.js +40 -5
  43. package/dist/tui/app-state.js +1 -0
  44. package/dist/tui/app-workflows.js +1 -0
  45. package/dist/tui/app.js +11 -3
  46. package/dist/tui/editor.js +2 -0
  47. package/dist/tui/keys.js +64 -5
  48. package/dist/tui/overlay.js +12 -4
  49. package/dist/tui/picker.js +2 -0
  50. package/dist/tui/screen.js +5 -17
  51. package/dist/user-store.js +54 -0
  52. package/package.json +7 -3
  53. /package/{docs/assets/brand → assets}/jeco-256.png +0 -0
@@ -10,11 +10,11 @@
10
10
  // secret in the working tree is one `git add -A` from being published, which
11
11
  // is why "not in the repo" is a rule and not a preference.
12
12
  import { chmod, mkdir } from "node:fs/promises";
13
- import { readFileSync } from "node:fs";
14
13
  import * as path from "node:path";
15
14
  import { atomicWrite } from "./atomic.js";
16
15
  import { withStoreLock } from "./store-lock.js";
17
16
  import { legacyUserDataPath, userDataLabel, userDataPath } from "./user-data.js";
17
+ import { assertStoreText, readBoundedJsonSync, USER_STORE_LIMITS } from "./user-store.js";
18
18
  /** Keys this session was given but not asked to keep. Dies with the window. */
19
19
  const held = new Map();
20
20
  /** The saved file, read once. `undefined` until the first look at it. */
@@ -58,6 +58,10 @@ export function hasSaved(name) {
58
58
  }
59
59
  /** Take a key for this session only. Nothing is written anywhere. */
60
60
  export function hold(name, value) {
61
+ assertCredential(name, value);
62
+ if (!held.has(name) && held.size >= USER_STORE_LIMITS.credentialEntries) {
63
+ throw new Error("too many session credentials");
64
+ }
61
65
  held.set(name, value);
62
66
  }
63
67
  /** Remove only the value held by this process. Saved and environment values remain. */
@@ -73,10 +77,14 @@ export function forgetSession(name) {
73
77
  * Windows ignores the mode and relies on the profile directory's own ACL.
74
78
  */
75
79
  export async function keep(name, value) {
80
+ assertCredential(name, value);
76
81
  const file = storePath();
77
82
  await prepare(file);
78
83
  return withStoreLock(file, async () => {
79
84
  const all = { ...readSavedStore(), [name]: value };
85
+ if (Object.keys(all).length > USER_STORE_LIMITS.credentialEntries) {
86
+ throw new Error("too many saved credentials");
87
+ }
80
88
  await persist(file, all);
81
89
  saved = all;
82
90
  // A newly saved replacement must become active immediately. Otherwise an
@@ -131,9 +139,14 @@ function readSavedStore() {
131
139
  }
132
140
  function readStore(file) {
133
141
  try {
134
- const parsed = JSON.parse(readFileSync(file, "utf8"));
142
+ const parsed = readBoundedJsonSync(file, USER_STORE_LIMITS.credentialsBytes);
143
+ if (!record(parsed))
144
+ return {};
135
145
  // Anything that is not a string is not a key, whatever the file says.
136
- return Object.fromEntries(Object.entries(parsed).filter((entry) => typeof entry[1] === "string"));
146
+ const entries = Object.entries(parsed);
147
+ if (entries.length > USER_STORE_LIMITS.credentialEntries)
148
+ return {};
149
+ return Object.fromEntries(entries.filter((entry) => credential(entry[0], entry[1])));
137
150
  }
138
151
  catch (error) {
139
152
  // Only a missing canonical file falls through to the legacy location. A
@@ -148,7 +161,22 @@ async function prepare(file) {
148
161
  await chmod(directory, 0o700);
149
162
  }
150
163
  async function persist(file, values) {
151
- await atomicWrite(file, `${JSON.stringify(values, null, 2)}\n`, { mode: 0o600 });
164
+ const text = `${JSON.stringify(values, null, 2)}\n`;
165
+ assertStoreText(text, USER_STORE_LIMITS.credentialsBytes);
166
+ await atomicWrite(file, text, { mode: 0o600 });
167
+ }
168
+ function assertCredential(name, value) {
169
+ if (!credential(name, value))
170
+ throw new Error("invalid credential name or value");
171
+ }
172
+ function credential(name, value) {
173
+ return name.length > 0 && name.length <= USER_STORE_LIMITS.credentialName &&
174
+ /^[A-Za-z_][A-Za-z0-9_]*$/.test(name) &&
175
+ typeof value === "string" && value.length > 0 &&
176
+ value.length <= USER_STORE_LIMITS.credentialValue;
177
+ }
178
+ function record(value) {
179
+ return typeof value === "object" && value !== null && !Array.isArray(value);
152
180
  }
153
181
  /** An empty variable is an unset variable — an exported "" is not a key. */
154
182
  function use(value) {
@@ -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
+ }
@@ -15,28 +15,65 @@ export async function providersCommand(session, host) {
15
15
  const choose = chooser(host);
16
16
  if (choose === undefined)
17
17
  return;
18
- let selected = Math.max(0, PROVIDERS.findIndex((provider) => provider.id === session.provider.id));
18
+ const groups = providerGroups();
19
+ let selected = Math.max(0, groups.findIndex((group) => group.providers.some((provider) => provider.id === session.provider.id)));
19
20
  while (true) {
20
21
  const index = await choose({
21
22
  title: [],
22
- options: PROVIDERS.map((provider) => ({
23
+ options: groups.map((group) => ({
24
+ label: group.label,
25
+ value: providerCount(group.providers.length),
26
+ })),
27
+ index: selected,
28
+ });
29
+ if (index === undefined) {
30
+ throwIfAborted(host.signal);
31
+ return;
32
+ }
33
+ const group = groups[index];
34
+ if (group === undefined)
35
+ return;
36
+ selected = index;
37
+ await providerGroupCommand(group, session, host);
38
+ throwIfAborted(host.signal);
39
+ }
40
+ }
41
+ function providerGroups() {
42
+ return [
43
+ { label: "Account", providers: PROVIDERS.filter((provider) => provider.auth.kind === "oauth") },
44
+ { label: "API", providers: PROVIDERS.filter((provider) => provider.auth.kind !== "oauth") },
45
+ ];
46
+ }
47
+ async function providerGroupCommand(group, session, host) {
48
+ if (host.choose === undefined)
49
+ return;
50
+ let selected = Math.max(0, group.providers.findIndex((provider) => provider.id === session.provider.id));
51
+ while (true) {
52
+ const index = await host.choose({
53
+ title: heading(group.label, "provider access", session.palette),
54
+ options: group.providers.map((provider) => ({
23
55
  label: providerLabel(provider.id),
24
56
  value: providerAccessHint(provider),
25
57
  })),
26
58
  index: selected,
27
59
  });
28
- if (index === undefined)
60
+ if (index === undefined) {
61
+ throwIfAborted(host.signal);
29
62
  return;
30
- const provider = PROVIDERS[index];
63
+ }
64
+ const provider = group.providers[index];
31
65
  if (provider === undefined)
32
66
  return;
33
67
  selected = index;
34
68
  await manageProvider(provider, session, host);
35
- // Esc closes only the nested provider flow. Ctrl+C also settles that
36
- // picker, but aborts the command signal and must not reopen the parent.
69
+ // Esc closes only the provider-specific flow. Ctrl+C also settles that
70
+ // interaction, but aborts the command signal and must not reopen a menu.
37
71
  throwIfAborted(host.signal);
38
72
  }
39
73
  }
74
+ function providerCount(count) {
75
+ return `${count} ${count === 1 ? "provider" : "providers"}`;
76
+ }
40
77
  export function providerAccessHint(provider) {
41
78
  if (provider.id === "ollama") {
42
79
  const connection = ollamaConnection();
@@ -1,11 +1,69 @@
1
1
  // Actionable provider failures for user-facing command and turn surfaces.
2
+ import { redactCredentials } from "./credential-safety.js";
2
3
  import { providerLabel } from "./provider-label.js";
4
+ import { leadingText } from "./text-boundary.js";
5
+ import { terminalText } from "./ui/terminal-text.js";
3
6
  const CONNECTION_FAILURE = /network error calling|timed out waiting for response headers|response body was idle/i;
7
+ const MAX_MESSAGE_CHARS = 1_000;
8
+ const MAX_REASON_CHARS = 500;
9
+ const HTML = /<!doctype\s|<\/?[a-z][^>]*>/i;
4
10
  export function providerFailure(provider, error, labelProvider = false) {
5
11
  if (provider.id === "ollama" && CONNECTION_FAILURE.test(error.message)) {
6
12
  return provider.location?.() === "local"
7
13
  ? "Ollama is not reachable on this computer · start Ollama or choose cloud in /providers"
8
14
  : "Ollama is not reachable · check its connection in /providers";
9
15
  }
10
- return labelProvider ? `${providerLabel(provider.id)}: ${error.message}` : error.message;
16
+ const message = safeText(error.message, MAX_MESSAGE_CHARS) || "provider request failed";
17
+ const reason = providerReason(error);
18
+ const detail = reason !== undefined && !message.toLocaleLowerCase().includes(reason.toLocaleLowerCase())
19
+ ? ` · ${reason}`
20
+ : "";
21
+ const failure = `${message}${detail}`;
22
+ return labelProvider ? `${providerLabel(provider.id)}: ${failure}` : failure;
23
+ }
24
+ function providerReason(error) {
25
+ const body = error.body;
26
+ if (typeof body !== "string" || body.trim() === "")
27
+ return undefined;
28
+ let parsed;
29
+ try {
30
+ parsed = JSON.parse(body);
31
+ }
32
+ catch {
33
+ const raw = body.trim();
34
+ if (raw.startsWith("{") || raw.startsWith("[") || HTML.test(raw))
35
+ return undefined;
36
+ return safeReason(raw);
37
+ }
38
+ for (const candidate of reasonCandidates(parsed)) {
39
+ const reason = safeReason(candidate);
40
+ if (reason !== undefined)
41
+ return reason;
42
+ }
43
+ return undefined;
44
+ }
45
+ function reasonCandidates(value) {
46
+ if (typeof value === "string")
47
+ return [value];
48
+ if (!record(value))
49
+ return [];
50
+ const error = value["error"];
51
+ return [
52
+ record(error) ? error["message"] : undefined,
53
+ typeof error === "string" ? error : undefined,
54
+ value["message"],
55
+ value["detail"],
56
+ ].filter((candidate) => typeof candidate === "string");
57
+ }
58
+ function safeReason(text) {
59
+ if (HTML.test(text))
60
+ return undefined;
61
+ return safeText(text, MAX_REASON_CHARS) || undefined;
62
+ }
63
+ function safeText(text, max) {
64
+ const redacted = redactCredentials(text).trim().replace(/\s+/gu, " ");
65
+ return leadingText(terminalText(redacted), max);
66
+ }
67
+ function record(value) {
68
+ return typeof value === "object" && value !== null && !Array.isArray(value);
11
69
  }
@@ -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 {
@@ -40,7 +40,10 @@ export async function postSse(url, headers, body, maxOutputTokens, signal, onSta
40
40
  const res = await request(url, { accept: "text/event-stream", ...headers }, body, signal, onStatus);
41
41
  if (res.body === null)
42
42
  throw httpError(`${url} returned no body`, res.status);
43
- return readSseJson(withIdleTimeout(url, res.body), maximumChars);
43
+ return readSseJson(res.body, maximumChars, {
44
+ milliseconds: BODY_IDLE_TIMEOUT_MS,
45
+ error: () => httpError(`${url} SSE stream was idle for ${BODY_IDLE_TIMEOUT_MS}ms without an event`, res.status),
46
+ });
44
47
  }
45
48
  async function request(url, headers, body, signal, onStatus) {
46
49
  const maxRetries = body === undefined ? GET_RETRIES : 0;
@@ -154,39 +157,6 @@ async function timedRead(url, reader) {
154
157
  clearTimeout(timer);
155
158
  }
156
159
  }
157
- function withIdleTimeout(url, body) {
158
- const reader = body.getReader();
159
- let released = false;
160
- const release = () => {
161
- if (released)
162
- return;
163
- released = true;
164
- reader.releaseLock();
165
- };
166
- return new ReadableStream({
167
- async pull(controller) {
168
- try {
169
- const { done, value } = await timedRead(url, reader);
170
- if (done) {
171
- release();
172
- controller.close();
173
- }
174
- else {
175
- controller.enqueue(value);
176
- }
177
- }
178
- catch (error) {
179
- await reader.cancel(error).catch(() => undefined);
180
- release();
181
- controller.error(error);
182
- }
183
- },
184
- async cancel(reason) {
185
- await reader.cancel(reason).catch(() => undefined);
186
- release();
187
- },
188
- });
189
- }
190
160
  function waitLabel(ms) {
191
161
  return ms < 1_000 ? `${ms}ms` : `${Math.ceil(ms / 1_000)}s`;
192
162
  }
@@ -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);
@@ -72,7 +72,7 @@ export const openaiCodex = {
72
72
  include: ["reasoning.encrypted_content"],
73
73
  prompt_cache_key: SESSION_ID,
74
74
  }, req.maxTokens, req.signal, req.onStatus);
75
- const data = await assembleOpenAI(events, req.onStream);
75
+ const data = await assembleOpenAI(events, req.onStream, req.onStatus);
76
76
  const notice = stopNotice(data);
77
77
  if (notice !== undefined)
78
78
  req.onStream?.({ kind: "text", text: `\n${notice}` });