@giovannijecha/jecode 0.7.3 → 0.8.0

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 (54) hide show
  1. package/README.md +104 -29
  2. package/dist/atomic.js +14 -0
  3. package/dist/batch.js +5 -2
  4. package/dist/cli-info.js +1 -1
  5. package/dist/config.js +4 -1
  6. package/dist/context/policy.js +14 -7
  7. package/dist/controller-request.js +1 -1
  8. package/dist/controller.js +39 -15
  9. package/dist/duration.js +8 -0
  10. package/dist/oauth-http.js +2 -1
  11. package/dist/openai-account.js +12 -8
  12. package/dist/openai-oauth-callback.js +2 -1
  13. package/dist/providers/anthropic-stream.js +8 -0
  14. package/dist/providers/anthropic-wire.js +22 -10
  15. package/dist/providers/http.js +4 -3
  16. package/dist/providers/ollama-stream.js +7 -0
  17. package/dist/providers/ollama-wire.js +11 -3
  18. package/dist/providers/openai-codex.js +0 -2
  19. package/dist/providers/openai-stream.js +57 -2
  20. package/dist/providers/openai-wire.js +27 -12
  21. package/dist/providers/sse.js +115 -38
  22. package/dist/sessions/codec.js +13 -5
  23. package/dist/sessions/store.js +2 -1
  24. package/dist/settings-command.js +0 -5
  25. package/dist/settings.js +0 -2
  26. package/dist/steering.js +58 -0
  27. package/dist/text-boundary.js +47 -0
  28. package/dist/timeline.js +2 -1
  29. package/dist/tools/args.js +1 -1
  30. package/dist/tools/fs.js +5 -0
  31. package/dist/tools/text-boundary.js +1 -31
  32. package/dist/transcript.js +6 -1
  33. package/dist/tui/activity.js +20 -5
  34. package/dist/tui/app-input.js +31 -6
  35. package/dist/tui/app-workflows.js +57 -14
  36. package/dist/tui/app.js +18 -3
  37. package/dist/tui/blocks.js +7 -2
  38. package/dist/tui/components/messages.js +20 -17
  39. package/dist/tui/components/misc.js +7 -6
  40. package/dist/tui/components/status.js +12 -1
  41. package/dist/tui/components/tool.js +152 -50
  42. package/dist/tui/help.js +1 -1
  43. package/dist/tui/motion.js +32 -0
  44. package/dist/tui/session-view.js +3 -2
  45. package/dist/tui/transcript-grammar.js +25 -0
  46. package/dist/tui/transcript-view.js +132 -7
  47. package/dist/tui/turn.js +78 -39
  48. package/dist/tui/view.js +4 -2
  49. package/dist/ui/diff.js +51 -16
  50. package/dist/ui/render.js +17 -27
  51. package/dist/ui/theme.js +19 -18
  52. package/dist/ui/width.js +31 -22
  53. package/dist/usage.js +5 -1
  54. package/package.json +1 -1
package/README.md CHANGED
@@ -22,18 +22,21 @@
22
22
 
23
23
  <p align="center">
24
24
  <a href="https://github.com/giovannijecha/jecode/blob/main/CHANGELOG.md">Changelog</a> &middot;
25
+ <a href="https://github.com/giovannijecha/jecode/blob/main/docs/compatibility.md">Compatibility</a> &middot;
25
26
  <a href="https://github.com/giovannijecha/jecode/releases">Releases</a>
26
27
  </p>
27
28
 
28
29
  > Jecode is currently pre-1.0. The core loop is usable today, but commands and
29
- > terminal interactions may still evolve before 1.0.
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).
30
32
 
31
33
  ## Why Jecode
32
34
 
33
35
  - **One visible loop.** One controller talks to the model, runs tools, and
34
- returns control to you. Independent reads may overlap inside a step; writes
35
- and commands stay ordered. There are no delegated agents or hidden model
36
- workers.
36
+ returns control to you. Independent reads may overlap within one model
37
+ response; writes and commands stay ordered. You can steer an active turn
38
+ without starting a second loop. There are no delegated agents or hidden
39
+ model workers.
37
40
  - **Terminal-native today.** The transcript, composer, searchable menus, tool
38
41
  output, diffs, approvals, reasoning, and status share one full-screen TUI.
39
42
  - **Permission-aware.** Tool use is visible. Dangerous actions ask first, and
@@ -53,10 +56,32 @@
53
56
 
54
57
  Jecode requires **Node.js 22.18+ on the 22.x line, or Node.js 24+**, and npm.
55
58
 
59
+ ### Install or update
60
+
61
+ Install the current stable release. Running the same command again updates an
62
+ existing installation:
63
+
64
+ ```console
65
+ npm install --global @giovannijecha/jecode@latest
66
+ ```
67
+
68
+ Confirm the installed version:
69
+
56
70
  ```console
57
- npm install --global @giovannijecha/jecode
58
71
  jecode --version
72
+ ```
73
+
74
+ ### Start Jecode
75
+
76
+ Open the project you want Jecode to work on:
77
+
78
+ ```console
59
79
  cd path/to/your/project
80
+ ```
81
+
82
+ Then start Jecode:
83
+
84
+ ```console
60
85
  jecode
61
86
  ```
62
87
 
@@ -71,33 +96,41 @@ change to improve startup performance.
71
96
  Use `jecode --root path/to/project` to select another workspace, or
72
97
  `jecode --ephemeral` when the conversation must stay memory-only.
73
98
 
74
- Resume a saved conversation for the current workspace with a searchable picker:
99
+ ### Resume a conversation
100
+
101
+ Open the searchable resume picker for the current workspace:
75
102
 
76
103
  ```console
77
104
  jecode resume
78
- jecode resume --latest
79
105
  ```
80
106
 
81
- Run `jecode --help` for every startup option. Windows, Ubuntu, and macOS are
82
- covered by the project test matrix.
107
+ Resume the most recently updated conversation directly:
83
108
 
84
- ### Prereleases
109
+ ```console
110
+ jecode resume --latest
111
+ ```
85
112
 
86
- The stable npm package is the supported installation artifact. To try an
87
- opt-in prerelease:
113
+ List every startup option:
88
114
 
89
115
  ```console
90
- npm install --global @giovannijecha/jecode@next
116
+ jecode --help
91
117
  ```
92
118
 
119
+ Windows, Ubuntu, and macOS are covered by the project test matrix.
120
+
121
+ ### Prereleases
122
+
123
+ Prereleases exist only during an announced release-candidate cycle. When one is
124
+ active, its GitHub release provides the exact installation command. Outside an
125
+ active cycle, the stable npm package is the only supported installation
126
+ artifact.
127
+
93
128
  Git URL installs are intentionally unsupported: the source tree contains no
94
129
  generated runtime and defines no install-time build hook.
95
130
 
96
- ### Update and uninstall
131
+ ### Uninstall
97
132
 
98
133
  ```console
99
- npm install --global @giovannijecha/jecode
100
- jecode --version
101
134
  npm uninstall --global @giovannijecha/jecode
102
135
  ```
103
136
 
@@ -105,12 +138,19 @@ Uninstalling the command preserves `~/.jecode`. Remove that directory only when
105
138
  you intentionally want to erase saved settings, credentials, accounts, and
106
139
  sessions.
107
140
 
108
- If an older GitHub installation still owns the `jecode` executable, remove the
109
- legacy unscoped package before installing the scoped package:
141
+ ### Replace a legacy installation
142
+
143
+ If an older GitHub installation still owns the `jecode` executable, first
144
+ remove the legacy unscoped package:
110
145
 
111
146
  ```console
112
147
  npm uninstall --global jecode
113
- npm install --global @giovannijecha/jecode
148
+ ```
149
+
150
+ Then install the current scoped package:
151
+
152
+ ```console
153
+ npm install --global @giovannijecha/jecode@latest
114
154
  ```
115
155
 
116
156
  Do not work around the resulting `EEXIST` error with `--force`.
@@ -119,14 +159,39 @@ Do not work around the resulting `EEXIST` error with `--force`.
119
159
 
120
160
  WSL uses its own Node.js installation and `PATH`; the Node.js version installed
121
161
  on Windows does not apply inside it. Keep user-installed npm commands in the
122
- Linux user path:
162
+ Linux user path. Set the user-level npm prefix:
123
163
 
124
164
  ```console
125
165
  npm config set prefix "$HOME/.local"
166
+ ```
167
+
168
+ Add it to the current shell's `PATH`:
169
+
170
+ ```console
126
171
  export PATH="$HOME/.local/bin:$PATH"
127
- npm install --global @giovannijecha/jecode
172
+ ```
173
+
174
+ Install or update Jecode:
175
+
176
+ ```console
177
+ npm install --global @giovannijecha/jecode@latest
178
+ ```
179
+
180
+ Refresh the command cache:
181
+
182
+ ```console
128
183
  hash -r
184
+ ```
185
+
186
+ Confirm which executable will run:
187
+
188
+ ```console
129
189
  command -v jecode
190
+ ```
191
+
192
+ Verify the installed version:
193
+
194
+ ```console
130
195
  jecode --version
131
196
  ```
132
197
 
@@ -183,7 +248,8 @@ Useful controls:
183
248
  - **Backspace/Delete** removes one character;
184
249
  **Ctrl+Backspace/Delete** removes one word.
185
250
  - **Home/End** moves to the beginning or end of the composer.
186
- - **Tab** completes a slash command without running it; **Enter** sends.
251
+ - **Tab** completes a slash command without running it; **Enter** sends. During
252
+ an active model turn, **Enter** queues guidance for its next safe boundary.
187
253
  - **Alt+Enter** inserts a newline.
188
254
  - **Esc** closes the active menu or interrupts foreground work.
189
255
  - **Ctrl+C** interrupts, or exits while idle. **Ctrl+D** requests a clean exit.
@@ -191,9 +257,11 @@ Useful controls:
191
257
  - **Ctrl+O** expands or compacts the latest reasoning or tool-detail block.
192
258
 
193
259
  The footer keeps the active model, effort, and workspace visible. During work,
194
- it adds the current state, elapsed time, and interrupt hint. Operational
195
- feedback uses the same replaceable status area instead of adding noise to the
196
- conversation or its Markdown export.
260
+ it adds the current state, elapsed time, steering availability or queue count,
261
+ and interrupt hint. Queued guidance joins the same conversation turn after the
262
+ provider response or complete tool batch already in progress; `Esc` remains an
263
+ immediate interruption. Operational feedback uses the same replaceable status
264
+ area instead of adding noise to the conversation or its Markdown export.
197
265
 
198
266
  ## Sessions and context
199
267
 
@@ -254,19 +322,23 @@ settings, then built-in defaults.
254
322
  | `--root` | - | Current directory |
255
323
  | `--effort` | `JECODE_EFFORT` | `high` |
256
324
  | `--max-tokens` | `JECODE_MAX_TOKENS` | `64000` ceiling, clamped to the usable request budget; not sent by `openai-codex` |
257
- | `--max-steps` | `JECODE_MAX_STEPS` | `40` |
325
+ | `--max-steps` | `JECODE_MAX_STEPS` | No limit |
258
326
  | `--compaction-percent` | `JECODE_COMPACTION_PERCENT` | `85`; accepts `50` through `95` |
259
327
  | `--reduced-motion` | `JECODE_REDUCED_MOTION=1` | Off |
260
328
  | `--auto-approve` | `JECODE_AUTO_APPROVE=1` | Off |
261
329
  | `--ephemeral` | `JECODE_EPHEMERAL=1` | Off |
262
330
 
331
+ `--max-steps` is an opt-in, process-only budget for deterministic automation
332
+ and diagnostics. Interactive work has no arbitrary model-loop ceiling, and the
333
+ budget is neither shown nor saved by `/settings`.
334
+
263
335
  Non-secret preferences live in `~/.jecode/settings.json`. Explicitly saved API
264
336
  keys live in `~/.jecode/credentials.json`, while ChatGPT OAuth accounts live in
265
337
  `~/.jecode/accounts.json`. Environment credentials always take precedence.
266
338
  Secret stores use owner-only permissions where the operating system supports
267
339
  them.
268
340
 
269
- Jecode has one current interface theme, Dark Steel. `NO_COLOR` is supported for
341
+ Jecode has one current interface theme, Slate. `NO_COLOR` is supported for
270
342
  terminals and pipelines that disable colour.
271
343
 
272
344
  ## Safety model
@@ -347,14 +419,17 @@ fixtures. `npm run bench:transcript` and `npm run bench:search` provide manual
347
419
  probes for long-session rendering and workspace search. Architecture and
348
420
  security boundaries are documented in
349
421
  [docs/architecture.md](docs/architecture.md); brand assets and usage rules live
350
- in [docs/brand.md](docs/brand.md).
422
+ in [docs/brand.md](docs/brand.md). The public 1.0 contract lives in
423
+ [docs/compatibility.md](docs/compatibility.md), and the maintainer release
424
+ procedure lives in [docs/releasing.md](docs/releasing.md).
351
425
 
352
426
  ## Community
353
427
 
354
428
  - Ask questions, share workflows, and explore early ideas in
355
429
  [GitHub Discussions](https://github.com/giovannijecha/jecode/discussions).
356
430
  - Report reproducible bugs and focused feature requests through
357
- [GitHub Issues](https://github.com/giovannijecha/jecode/issues).
431
+ [GitHub Issues](https://github.com/giovannijecha/jecode/issues). New
432
+ capabilities are deferred until the 1.0 stabilization cycle is complete.
358
433
  - Report security concerns privately through the repository Security tab.
359
434
 
360
435
  Public pull requests are not accepted at this stage. Code changes remain a
package/dist/atomic.js CHANGED
@@ -8,20 +8,29 @@ export async function atomicWrite(file, content, options = {}) {
8
8
  let handle;
9
9
  let identity;
10
10
  try {
11
+ throwIfAborted(options.signal);
11
12
  await options.validate?.("before-open");
13
+ throwIfAborted(options.signal);
12
14
  const permissions = options.mode ?? (await existingMode(file));
15
+ throwIfAborted(options.signal);
13
16
  handle = await open(temporary, "wx", permissions);
14
17
  identity = fileIdentity(await handle.stat());
18
+ throwIfAborted(options.signal);
15
19
  await options.validate?.("before-write");
20
+ throwIfAborted(options.signal);
16
21
  await assertNamedFile(temporary, identity);
17
22
  await handle.writeFile(content, "utf8");
23
+ throwIfAborted(options.signal);
18
24
  if (permissions !== undefined && process.platform !== "win32") {
19
25
  await handle.chmod(permissions);
20
26
  }
21
27
  await handle.sync();
28
+ throwIfAborted(options.signal);
22
29
  identity = fileIdentity(await handle.stat());
23
30
  await options.validate?.("before-rename");
31
+ throwIfAborted(options.signal);
24
32
  await assertNamedFile(temporary, identity);
33
+ throwIfAborted(options.signal);
25
34
  await rename(temporary, file);
26
35
  const completed = handle;
27
36
  handle = undefined;
@@ -38,6 +47,11 @@ export async function atomicWrite(file, content, options = {}) {
38
47
  throw error;
39
48
  }
40
49
  }
50
+ function throwIfAborted(signal) {
51
+ if (signal?.aborted !== true)
52
+ return;
53
+ throw signal.reason instanceof Error ? signal.reason : new Error("interrupted");
54
+ }
41
55
  async function assertNamedFile(file, expected) {
42
56
  const details = await lstat(file);
43
57
  if (details.isSymbolicLink() || !sameFile(expected, fileIdentity(details))) {
package/dist/batch.js CHANGED
@@ -13,7 +13,7 @@ import { handleCommand } from "./commands.js";
13
13
  import { renderBatch } from "./batch-view.js";
14
14
  import { columns } from "./ui/render.js";
15
15
  import { terminalText } from "./ui/terminal-text.js";
16
- import { recordAuxiliaryUsage, recordUsage } from "./usage.js";
16
+ import { recordAuxiliaryUsage, recordRequestInput, recordUsage } from "./usage.js";
17
17
  export async function runBatch(session, environment = {}) {
18
18
  const rl = environment.lines === undefined ? readline.createInterface({ input: stdin }) : undefined;
19
19
  const lines = environment.lines ?? rl;
@@ -126,7 +126,7 @@ function options(session, contextPolicy) {
126
126
  maxTokens: session.config.maxTokens,
127
127
  contextPolicy,
128
128
  effort: session.config.effort,
129
- maxSteps: session.config.maxSteps,
129
+ maxModelRequests: session.config.maxModelRequests,
130
130
  toolContext: { root: session.config.root },
131
131
  };
132
132
  }
@@ -167,5 +167,8 @@ function events(emit, session) {
167
167
  onUsage(usage) {
168
168
  recordUsage(session.usage, usage);
169
169
  },
170
+ onRequestInput(inputTokens) {
171
+ recordRequestInput(session.usage, inputTokens);
172
+ },
170
173
  };
171
174
  }
package/dist/cli-info.js CHANGED
@@ -15,7 +15,7 @@ Options:
15
15
  --ollama-host <url> Ollama Cloud, local, or custom endpoint
16
16
  --effort <level> low, medium, high, xhigh, or max
17
17
  --max-tokens <number> output-token ceiling
18
- --max-steps <number> tool-loop ceiling
18
+ --max-steps <number> optional per-turn model-request budget
19
19
  --compaction-percent <${MIN_COMPACTION_PERCENT}-${MAX_COMPACTION_PERCENT}>
20
20
  context usage that triggers compaction (default: ${DEFAULT_COMPACTION_PERCENT})
21
21
  --reduced-motion use a steady terminal cursor
package/dist/config.js CHANGED
@@ -27,6 +27,7 @@ export function loadConfig(argv, saved = readSettings()) {
27
27
  if (!EFFORTS.includes(effort)) {
28
28
  throw new Error(`unknown effort "${effort}" (expected one of: ${EFFORTS.join(", ")})`);
29
29
  }
30
+ const maxModelRequests = optional(flags["max-steps"], process.env.JECODE_MAX_STEPS, undefined);
30
31
  return {
31
32
  providerId,
32
33
  model: pick(flags.model, process.env.JECODE_MODEL, saved.models?.[providerId] ?? ""),
@@ -37,7 +38,9 @@ export function loadConfig(argv, saved = readSettings()) {
37
38
  effort,
38
39
  // Every request streams, so a large ceiling costs nothing in timeout risk.
39
40
  maxTokens: toInt(pick(flags["max-tokens"], process.env.JECODE_MAX_TOKENS, String(saved.maxTokens ?? 64000)), "max-tokens"),
40
- maxSteps: toInt(pick(flags["max-steps"], process.env.JECODE_MAX_STEPS, String(saved.maxSteps ?? 40)), "max-steps"),
41
+ ...(maxModelRequests === undefined
42
+ ? {}
43
+ : { maxModelRequests: toInt(maxModelRequests, "max-steps") }),
41
44
  compactionPercent: toPercent(pick(flags["compaction-percent"], process.env.JECODE_COMPACTION_PERCENT, String(saved.compactionPercent ?? DEFAULT_COMPACTION_PERCENT))),
42
45
  root: path.resolve(pick(flags.root, undefined, process.cwd())),
43
46
  autoApprove: flags["auto-approve"] === "true" ||
@@ -42,16 +42,16 @@ export function planCompaction(context, turn, coveredMessages, lastInputTokens,
42
42
  };
43
43
  }
44
44
  export function policyForContextWindow(context, compactionPercent) {
45
- const windowTokens = validWindow(context?.tokens)
46
- ? context.tokens
47
- : FALLBACK_CONTEXT_WINDOW_TOKENS;
45
+ const windowTokens = context === undefined
46
+ ? FALLBACK_CONTEXT_WINDOW_TOKENS
47
+ : requireWindow(context.tokens);
48
48
  const percent = validPercent(compactionPercent)
49
49
  ? compactionPercent
50
50
  : DEFAULT_COMPACTION_PERCENT;
51
51
  const percentageLimit = Math.floor(windowTokens * percent / 100);
52
- const providerLimit = validWindow(context?.compactAtTokens)
53
- ? context.compactAtTokens
54
- : windowTokens;
52
+ const providerLimit = context?.compactAtTokens === undefined
53
+ ? windowTokens
54
+ : requireWindow(context.compactAtTokens);
55
55
  const requestLimitTokens = Math.floor(Math.min(providerLimit, windowTokens) * (100 - REQUEST_ESTIMATE_HEADROOM_PERCENT) / 100);
56
56
  const triggerTokens = Math.min(percentageLimit, requestLimitTokens - MIN_REQUEST_OUTPUT_TOKENS);
57
57
  const targetTokens = Math.max(512, Math.min(Math.floor(windowTokens / 4), Math.floor(triggerTokens / 2)));
@@ -114,7 +114,14 @@ function validPolicy(policy) {
114
114
  policy.targetTokens < policy.triggerTokens && policy.recentTokens < policy.triggerTokens;
115
115
  }
116
116
  function validWindow(value) {
117
- return value !== undefined && Number.isSafeInteger(value) && value >= 4_096 && value <= 10_000_000;
117
+ // Adapters validate raw capacities at 4K before applying their own usable
118
+ // headroom. The resulting safe capacity can therefore be slightly smaller.
119
+ return value !== undefined && Number.isSafeInteger(value) && value >= 1_024 && value <= 10_000_000;
120
+ }
121
+ function requireWindow(value) {
122
+ if (validWindow(value))
123
+ return value;
124
+ throw new Error(`provider reported an invalid usable context window: ${value}`);
118
125
  }
119
126
  function validPercent(value) {
120
127
  return Number.isSafeInteger(value) &&
@@ -24,7 +24,7 @@ export async function requestAssistant(history, current, specs, options, events,
24
24
  onStream: (event) => events.onStream(event),
25
25
  onStatus: (status) => events.onStatus?.(status),
26
26
  });
27
- return { message, context };
27
+ return { message, context, inputTokens: budget.inputTokens };
28
28
  }
29
29
  catch (error) {
30
30
  if (recovered)
@@ -6,7 +6,7 @@
6
6
  import { isToolCall } from "./types.js";
7
7
  import { findTool, runTool, toolSpecs } from "./tools/index.js";
8
8
  import { requestAssistant } from "./controller-request.js";
9
- export const MAX_TOOL_CALLS_PER_STEP = 32;
9
+ export const MAX_TOOL_CALLS_PER_RESPONSE = 32;
10
10
  /** Independent read calls share one bounded execution wave. */
11
11
  export const MAX_CONCURRENT_TOOL_CALLS = 4;
12
12
  /**
@@ -28,9 +28,25 @@ export async function runTurn(history, options, events, signal, modelHistory = h
28
28
  if (projected !== undefined)
29
29
  context = clone([...projected]);
30
30
  };
31
- for (let step = 0; step < options.maxSteps; step++) {
31
+ const appendSteering = (messages) => {
32
+ for (const text of messages) {
33
+ append({ role: "user", content: [{ kind: "text", text }] });
34
+ }
35
+ for (const text of messages) {
36
+ events.onSteering?.(text);
37
+ }
38
+ return messages.length > 0;
39
+ };
40
+ let requests = 0;
41
+ while (true) {
32
42
  throwIfAborted(signal);
33
- events.onStep?.(step + 1, options.maxSteps);
43
+ if (options.maxModelRequests !== undefined &&
44
+ requests >= options.maxModelRequests) {
45
+ const requestLabel = options.maxModelRequests === 1 ? "request" : "requests";
46
+ throw new Error(`stopped after ${options.maxModelRequests} model ${requestLabel} (--max-steps limit reached)`);
47
+ }
48
+ appendSteering(options.steering?.drain() ?? []);
49
+ requests++;
34
50
  // The message is displayed as it streams; what comes back here is the
35
51
  // assembled version, which exists to be appended to the history.
36
52
  const response = await requestAssistant(history, context, specs, options, events, signal);
@@ -41,14 +57,22 @@ export async function runTurn(history, options, events, signal, modelHistory = h
41
57
  if (assistant.content.length === 0) {
42
58
  throw new Error(`${options.provider.id} completed without an answer or tool call`);
43
59
  }
44
- if (calls.length > MAX_TOOL_CALLS_PER_STEP) {
45
- throw new Error(`provider returned ${calls.length} tool calls in one step (maximum ${MAX_TOOL_CALLS_PER_STEP})`);
60
+ if (calls.length > MAX_TOOL_CALLS_PER_RESPONSE) {
61
+ throw new Error(`provider returned ${calls.length} tool calls in one response (maximum ${MAX_TOOL_CALLS_PER_RESPONSE})`);
46
62
  }
47
63
  assertToolCallIds(calls);
64
+ if (assistant.usage !== undefined)
65
+ events.onUsage?.(assistant.usage);
66
+ events.onRequestInput?.(assistant.usage !== undefined && assistant.usage.inputTokens > 0
67
+ ? assistant.usage.inputTokens
68
+ : response.inputTokens);
48
69
  append(assistant);
49
70
  if (calls.length === 0) {
50
- if (assistant.usage !== undefined)
51
- events.onUsage?.(assistant.usage);
71
+ const steering = options.steering?.drainOrClose();
72
+ if (steering !== undefined && appendSteering(steering.messages)) {
73
+ await checkpoint("checkpointed");
74
+ continue;
75
+ }
52
76
  await checkpoint("completed");
53
77
  return; // the model is done — hand back to the user
54
78
  }
@@ -58,8 +82,6 @@ export async function runTurn(history, options, events, signal, modelHistory = h
58
82
  const results = [];
59
83
  const announced = new Set();
60
84
  try {
61
- if (assistant.usage !== undefined)
62
- events.onUsage?.(assistant.usage);
63
85
  while (results.length < calls.length) {
64
86
  const start = results.length;
65
87
  const batch = nextBatch(calls, start, options.tools);
@@ -67,14 +89,15 @@ export async function runTurn(history, options, events, signal, modelHistory = h
67
89
  for (let offset = 0; offset < batch.length; offset++) {
68
90
  throwIfAborted(signal);
69
91
  const call = batch[offset];
70
- events.onToolProgress?.(start + offset + 1, calls.length);
92
+ const current = start + offset + 1;
93
+ events.onToolPreparing?.(call, current, calls.length);
71
94
  const preview = await look(call, options, signal);
72
95
  throwIfAborted(signal);
73
96
  announced.add(call.id);
74
97
  events.onToolCall(call, preview);
75
- prepared.push({ call, preview });
98
+ prepared.push({ call, current, preview });
76
99
  }
77
- const runs = await Promise.all(prepared.map(({ call, preview }) => settle(call, options, events, signal, preview)));
100
+ const runs = await Promise.all(prepared.map(({ call, current, preview }) => settle(call, current, calls.length, options, events, signal, preview)));
78
101
  for (let offset = 0; offset < runs.length; offset++) {
79
102
  const call = prepared[offset]?.call;
80
103
  const run = runs[offset];
@@ -112,9 +135,9 @@ export async function runTurn(history, options, events, signal, modelHistory = h
112
135
  throw error;
113
136
  }
114
137
  append({ role: "user", content: results });
138
+ appendSteering(options.steering?.drain() ?? []);
115
139
  await checkpoint("checkpointed");
116
140
  }
117
- throw new Error(`gave up after ${options.maxSteps} steps without finishing (raise --max-steps)`);
118
141
  }
119
142
  function nextBatch(calls, start, tools) {
120
143
  const first = calls[start];
@@ -140,12 +163,12 @@ function assertToolCallIds(calls) {
140
163
  if (call.id.trim() === "")
141
164
  throw new Error("provider returned a tool call without an id");
142
165
  if (seen.has(call.id)) {
143
- throw new Error("provider returned duplicate tool call ids in one step");
166
+ throw new Error("provider returned duplicate tool call ids in one response");
144
167
  }
145
168
  seen.add(call.id);
146
169
  }
147
170
  }
148
- async function settle(call, options, events, signal, preview) {
171
+ async function settle(call, current, total, options, events, signal, preview) {
149
172
  const tool = findTool(options.tools, call.name);
150
173
  if (tool === undefined) {
151
174
  return refuse(call, `no such tool: ${call.name}`, "unknown tool");
@@ -157,6 +180,7 @@ async function settle(call, options, events, signal, preview) {
157
180
  return refuse(call, "the user declined this call — ask them how to proceed", "declined");
158
181
  }
159
182
  throwIfAborted(signal);
183
+ events.onToolStart?.(call, current, total);
160
184
  return runTool(tool, call, {
161
185
  ...options.toolContext,
162
186
  signal,
@@ -0,0 +1,8 @@
1
+ // One compact duration grammar shared by terminal and exported tool evidence.
2
+ export function toolDuration(durationMs, live = false) {
3
+ const bounded = Number.isFinite(durationMs) ? Math.max(0, durationMs) : 0;
4
+ if (!live && bounded < 1_000)
5
+ return `${Math.round(bounded)}ms`;
6
+ const seconds = bounded / 1_000;
7
+ return `${seconds < 10 ? seconds.toFixed(1) : Math.round(seconds)}s`;
8
+ }
@@ -2,6 +2,7 @@
2
2
  //
3
3
  // These requests never redirect, never retry, and never retain an unbounded
4
4
  // response. Authorization codes and refresh tokens must not leak into errors.
5
+ import { leadingText } from "./text-boundary.js";
5
6
  const AUTH_ORIGIN = "https://auth.openai.com";
6
7
  const TIMEOUT_MS = 15_000;
7
8
  const MAX_BODY_CHARS = 64_000;
@@ -96,7 +97,7 @@ function errorDetail(value, secrets) {
96
97
  let safe = detail.replace(/[\r\n]+/g, " ");
97
98
  for (const secret of secrets)
98
99
  safe = safe.replaceAll(secret, "[credential redacted]");
99
- return ` · ${safe.slice(0, 300)}`;
100
+ return ` · ${leadingText(safe, 300)}`;
100
101
  }
101
102
  function bodySecrets(body) {
102
103
  const values = body.contentType === "application/x-www-form-urlencoded"
@@ -30,29 +30,27 @@ export async function removeOpenAIAccount(signal) {
30
30
  }
31
31
  return { removed: true, revokeFailed };
32
32
  }
33
- export async function openAIAuthorization(forceToken, signal, onStatus) {
33
+ export async function openAIAuthorization(rejectedToken, signal, onStatus) {
34
34
  const account = openAICodexAccount();
35
35
  if (account === undefined)
36
36
  throw new Error("ChatGPT account is not connected");
37
- const mustRefresh = forceToken !== undefined || expiresSoon(account);
37
+ const mustRefresh = rejectedToken !== undefined || expiresSoon(account);
38
38
  const ready = mustRefresh
39
- ? await refreshAccount(forceToken, signal, onStatus)
39
+ ? await refreshAccount(rejectedToken, signal, onStatus)
40
40
  : account;
41
41
  return { accessToken: ready.accessToken, accountId: ready.accountId };
42
42
  }
43
- async function refreshAccount(forceToken, signal, onStatus) {
43
+ async function refreshAccount(rejectedToken, signal, onStatus) {
44
44
  if (refreshing !== undefined)
45
45
  return abortable(refreshing, signal);
46
46
  if (signal?.aborted === true)
47
47
  throw abortReason(signal);
48
- onStatus?.("Refreshing ChatGPT sign-in");
49
48
  const task = updateOpenAICodexAccount(async (current) => {
50
49
  if (current === undefined)
51
50
  throw new Error("ChatGPT account is not connected");
52
- if (forceToken !== undefined && current.accessToken !== forceToken)
53
- return current;
54
- if (forceToken === undefined && !expiresSoon(current))
51
+ if (!requiresAuthorityRefresh(current, rejectedToken))
55
52
  return current;
53
+ onStatus?.("Refreshing ChatGPT sign-in");
56
54
  return refreshOpenAITokens(current);
57
55
  }).then((account) => {
58
56
  if (account === undefined)
@@ -72,6 +70,12 @@ async function refreshAccount(forceToken, signal, onStatus) {
72
70
  function expiresSoon(account) {
73
71
  return account.expiresAt - Date.now() <= REFRESH_EARLY_MS;
74
72
  }
73
+ function requiresAuthorityRefresh(account, rejectedToken) {
74
+ // A different fresh token means another process already recovered. An
75
+ // expiring replacement still needs rotation, while a rejected current token
76
+ // must be rotated regardless of its advertised expiry.
77
+ return expiresSoon(account) || account.accessToken === rejectedToken;
78
+ }
75
79
  function abortable(promise, signal) {
76
80
  if (signal === undefined)
77
81
  return promise;
@@ -2,6 +2,7 @@
2
2
  import { timingSafeEqual } from "node:crypto";
3
3
  import { readFileSync } from "node:fs";
4
4
  import { createServer } from "node:http";
5
+ import { leadingText } from "./text-boundary.js";
5
6
  const CALLBACK_PORTS = [1455, 1457];
6
7
  export const OPENAI_CALLBACK_PATH = "/auth/callback";
7
8
  export async function openAICallback(state) {
@@ -42,7 +43,7 @@ export async function openAICallback(state) {
42
43
  const authError = incoming.searchParams.get("error_description") ?? incoming.searchParams.get("error");
43
44
  const authorizationCode = incoming.searchParams.get("code");
44
45
  if (authError !== null) {
45
- rejectCode(new Error(`ChatGPT sign-in was rejected · ${authError.slice(0, 300)}`));
46
+ rejectCode(new Error(`ChatGPT sign-in was rejected · ${leadingText(authError, 300)}`));
46
47
  }
47
48
  else if (authorizationCode === null || authorizationCode === "") {
48
49
  rejectCode(new Error("ChatGPT sign-in returned no authorization code"));
@@ -9,6 +9,7 @@ import { addBounded, MAX_TOOL_ARGUMENT_CHARS } from "./stream-limits.js";
9
9
  export async function assembleAnthropic(events, onStream) {
10
10
  const blocks = new Map();
11
11
  const partialJson = new Map();
12
+ const announcedTools = new Set();
12
13
  const sizes = { toolArguments: 0 };
13
14
  let stopReason;
14
15
  let stopDetails;
@@ -23,6 +24,13 @@ export async function assembleAnthropic(events, onStream) {
23
24
  case "content_block_start": {
24
25
  if (typeof event.index === "number" && event.content_block !== undefined) {
25
26
  blocks.set(event.index, { ...event.content_block });
27
+ if (event.content_block["type"] === "tool_use" &&
28
+ !announcedTools.has(event.index)) {
29
+ announcedTools.add(event.index);
30
+ const rawName = event.content_block["name"];
31
+ const name = typeof rawName === "string" && rawName !== "" ? rawName : undefined;
32
+ onStream?.({ kind: "tool", ...(name === undefined ? {} : { name }) });
33
+ }
26
34
  }
27
35
  break;
28
36
  }