@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
package/README.md CHANGED
@@ -1,9 +1,9 @@
1
1
  <p align="center">
2
- <img src="docs/assets/brand/jeco-256.png" width="128" alt="Jeco, the steel-blue Jecode gecko">
2
+ <img src="assets/jeco-256.png" width="128" alt="Jeco, the steel-blue Jecode gecko">
3
3
  </p>
4
4
 
5
5
  <p align="center">
6
- <img src="docs/assets/brand/wordmark-steel.svg" width="280" alt="Jecode">
6
+ <img src="assets/wordmark-steel.svg" width="280" alt="Jecode">
7
7
  </p>
8
8
 
9
9
  <p align="center"><strong>Your code. Your loop.</strong></p>
@@ -22,35 +22,26 @@
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
+ <a href="https://github.com/giovannijecha/jecode/blob/main/docs/COMPATIBILITY.md">Compatibility</a> &middot;
26
26
  <a href="https://github.com/giovannijecha/jecode/releases">Releases</a>
27
27
  </p>
28
28
 
29
29
  > Jecode is currently pre-1.0. The core loop is usable today, but commands and
30
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).
31
+ > surface is now frozen in the [compatibility contract](https://github.com/giovannijecha/jecode/blob/main/docs/COMPATIBILITY.md).
32
32
 
33
33
  ## Why Jecode
34
34
 
35
- - **One visible loop.** One controller talks to the model, runs tools, and
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.
40
- - **Terminal-native today.** The transcript, composer, searchable menus, tool
41
- output, diffs, approvals, reasoning, and status share one full-screen TUI.
42
- - **Permission-aware.** Tool use is visible. Dangerous actions ask first, and
43
- remembered session approvals can be reviewed or revoked.
44
- - **Durable and context-bounded.** Interactive conversations survive terminal
45
- exits. Older model context can be compacted while the complete conversation
46
- and transcript remain available in the saved session.
47
- - **Multi-provider.** Use Anthropic or OpenAI API keys, an eligible ChatGPT
48
- account, or a local, cloud, or remote Ollama server without changing the
49
- workflow.
50
- - **Lean by construction.** Jecode ships as owned JavaScript built on Node.js
51
- primitives, executes no installation scripts, and has zero third-party
52
- runtime dependencies. This is a permanent product constraint, not a temporary
53
- optimization.
35
+ Jecode keeps the coding loop yours. Work stays in one terminal where you can
36
+ follow tool calls and diffs, steer the model while it runs, and resume the same
37
+ conversation later. One controller carries each turn from prompt to result—no
38
+ delegated agents or hidden model workers.
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.
54
45
 
55
46
  ## Quick start
56
47
 
@@ -73,13 +64,7 @@ jecode --version
73
64
 
74
65
  ### Start Jecode
75
66
 
76
- Open the project you want Jecode to work on:
77
-
78
- ```console
79
- cd path/to/your/project
80
- ```
81
-
82
- Then start Jecode:
67
+ Open the project directory you want Jecode to work on, then start Jecode:
83
68
 
84
69
  ```console
85
70
  jecode
@@ -118,16 +103,6 @@ jecode --help
118
103
 
119
104
  Windows, Ubuntu, and macOS are covered by the project test matrix.
120
105
 
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
-
128
- Git URL installs are intentionally unsupported: the source tree contains no
129
- generated runtime and defines no install-time build hook.
130
-
131
106
  ### Uninstall
132
107
 
133
108
  ```console
@@ -138,23 +113,6 @@ Uninstalling the command preserves `~/.jecode`. Remove that directory only when
138
113
  you intentionally want to erase saved settings, credentials, accounts, and
139
114
  sessions.
140
115
 
141
- ### Replace a legacy installation
142
-
143
- If an older GitHub installation still owns the `jecode` executable, first
144
- remove the legacy unscoped package:
145
-
146
- ```console
147
- npm uninstall --global jecode
148
- ```
149
-
150
- Then install the current scoped package:
151
-
152
- ```console
153
- npm install --global @giovannijecha/jecode@latest
154
- ```
155
-
156
- Do not work around the resulting `EEXIST` error with `--force`.
157
-
158
116
  ### Linux and WSL
159
117
 
160
118
  WSL uses its own Node.js installation and `PATH`; the Node.js version installed
@@ -200,228 +158,11 @@ WSL, `command -v jecode` should resolve below `/home/...`, not through an
200
158
  inherited Windows path below `/mnt/c`. Do not ignore `EBADENGINE`: `node
201
159
  --version` must report 22.18+ on the 22.x line, or 24+.
202
160
 
203
- ## Providers
204
-
205
- | Provider ID | Authentication | Notes |
206
- | --- | --- | --- |
207
- | `anthropic` | `ANTHROPIC_API_KEY` | Anthropic API |
208
- | `openai` | `OPENAI_API_KEY` | OpenAI API |
209
- | `openai-codex` | ChatGPT OAuth | Experimental; uses eligible ChatGPT Codex access |
210
- | `ollama` | `OLLAMA_API_KEY` for cloud or remote use | Cloud with a key, local without one |
211
-
212
- Choose **ChatGPT** in `/providers` to sign in on OpenAI's website without
213
- pasting a key. Jecode supports a local browser callback and a device-code flow;
214
- WSL and remote terminals default to the device code. Availability and usage
215
- limits depend on the ChatGPT account and plan, not on OpenAI API credits. This
216
- integration is experimental and is not an endorsement of Jecode by OpenAI.
217
-
218
- Anthropic remains API-key only. Jecode does not reuse a Claude consumer
219
- subscription or copy credentials from another client.
220
-
221
- For Ollama, `/providers` can select cloud, local, or a custom endpoint. With an
222
- Ollama API key Jecode defaults to `https://ollama.com`; without one it defaults
223
- to `http://127.0.0.1:11434`. Remote custom endpoints must use HTTPS.
224
-
225
- ## Use the TUI
226
-
227
- Type `/` to open searchable command completion inside the composer.
228
-
229
- | Command | What it does |
230
- | --- | --- |
231
- | `/settings` | Manage the selected model and saved non-secret defaults |
232
- | `/effort` | Change and save reasoning effort directly |
233
- | `/providers` | Manage provider connections, API keys, ChatGPT sign-in, and Ollama endpoints |
234
- | `/models` | Search all currently usable provider catalogues and select a model |
235
- | `/permissions` | Change session tool access and review remembered approvals |
236
- | `/timeline` | Browse resumable turns and select where the next branch should begin |
237
- | `/compact` | Compact the current branch context without deleting saved conversation history |
238
- | `/new` | Start a new conversation and reset session tool permissions |
239
- | `/export` | Save a timestamped Markdown transcript in the launch directory |
240
- | `/help` | Open a temporary keyboard reference in the composer dock |
241
- | `/exit` | Restore the terminal and exit |
242
-
243
- Useful controls:
244
-
245
- - **Up/Down** moves through suggestions, menus, and input history.
246
- - **Left/Right** moves the cursor or changes an inline value;
247
- **Ctrl+Left/Right** moves by word.
248
- - **Backspace/Delete** removes one character;
249
- **Ctrl+Backspace/Delete** removes one word.
250
- - **Home/End** moves to the beginning or end of the composer.
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.
253
- - **Alt+Enter** inserts a newline.
254
- - **Esc** closes the active menu or interrupts foreground work.
255
- - **Ctrl+C** interrupts, or exits while idle. **Ctrl+D** requests a clean exit.
256
- - **PageUp/PageDown** and the mouse wheel scroll the transcript.
257
- - **Ctrl+O** expands or compacts the latest reasoning or tool-detail block.
258
-
259
- The footer keeps the active model, effort, and workspace visible. During work,
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.
265
-
266
- ## Sessions and context
267
-
268
- Interactive conversations are stored under `~/.jecode/sessions` and scoped to
269
- the canonical workspace path. A fresh or `/new` conversation is not added to
270
- the resume picker until it has a settled turn. Resuming and continuing a
271
- conversation keeps its durable session identity and updates one picker entry
272
- instead of creating duplicates.
273
-
274
- `/timeline` shows completed, failed, and interrupted turns in the conversation
275
- tree. Selecting an older turn changes only the in-memory path: it creates and
276
- saves a branch only after the next real user message. Cancelling the picker or
277
- exiting before that message leaves the durable head unchanged. A failed turn
278
- keeps the same partial evidence and outcome in the live transcript, export, and
279
- resume, while the next model receives a neutral failure boundary instead of
280
- incomplete streamed text. Historical tools are displayed but never executed.
281
- If a process stops abruptly inside a tool loop, Jecode resumes from the latest
282
- safe ancestor and lets the next user turn create a branch. `/export` writes
283
- only the currently selected path.
284
-
285
- When model-facing context approaches the selected model's usable capacity,
286
- Jecode asks the provider for a bounded summary of the older prefix and keeps
287
- recent turns exact. The default trigger is 85% and can be changed from 50% to
288
- 95%. Live provider metadata or Ollama's allocated context determines the budget
289
- when available; provider safety limits always win.
290
-
291
- Compaction changes only the projection sent to the model. Complete messages,
292
- tool evidence, transcript, export, and conversation tree remain intact. The
293
- branch-local summary anchor is saved with the session so resume does not repeat
294
- the same compaction. `/compact` requests this process immediately, even below
295
- the automatic threshold; it leaves very small contexts unchanged. After
296
- selecting a historical turn, send the first new message before compacting so
297
- shared history is never rewritten.
298
-
299
- ## Batch mode
300
-
301
- When stdin or stdout is not a terminal, Jecode switches to a plain
302
- line-oriented mode:
303
-
304
- ```console
305
- printf "explain this project\n" | jecode --root .
306
- ```
307
-
308
- Batch conversations are stateless. Dangerous tools remain denied unless
309
- `--auto-approve` is supplied explicitly. Terminal failures are written to
310
- stderr and exit non-zero so scripts and CI pipelines can stop reliably.
311
-
312
- ## Configuration
313
-
314
- Startup precedence is: command-line flags, environment variables, saved
315
- settings, then built-in defaults.
316
-
317
- | Flag | Environment | Default |
318
- | --- | --- | --- |
319
- | `--provider` | `JECODE_PROVIDER` | `anthropic` |
320
- | `--model` | `JECODE_MODEL` | Provider default or interactive selection |
321
- | `--ollama-host` | `OLLAMA_HOST` | Cloud with an Ollama key, local without one |
322
- | `--root` | - | Current directory |
323
- | `--effort` | `JECODE_EFFORT` | `high` |
324
- | `--max-tokens` | `JECODE_MAX_TOKENS` | `64000` ceiling, clamped to the usable request budget; not sent by `openai-codex` |
325
- | `--max-steps` | `JECODE_MAX_STEPS` | No limit |
326
- | `--compaction-percent` | `JECODE_COMPACTION_PERCENT` | `85`; accepts `50` through `95` |
327
- | `--reduced-motion` | `JECODE_REDUCED_MOTION=1` | Off |
328
- | `--auto-approve` | `JECODE_AUTO_APPROVE=1` | Off |
329
- | `--ephemeral` | `JECODE_EPHEMERAL=1` | Off |
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
-
335
- Non-secret preferences live in `~/.jecode/settings.json`. Explicitly saved API
336
- keys live in `~/.jecode/credentials.json`, while ChatGPT OAuth accounts live in
337
- `~/.jecode/accounts.json`. Environment credentials always take precedence.
338
- Secret stores use owner-only permissions where the operating system supports
339
- them.
340
-
341
- Jecode has one current interface theme, Slate. `NO_COLOR` is supported for
342
- terminals and pipelines that disable colour.
343
-
344
- ## Safety model
345
-
346
- Jecode treats model output, workspace content, tool output, and terminal text as
347
- untrusted data.
348
-
349
- - Current filesystem tools are confined to the selected workspace. Writes
350
- reject symlink and junction components, revalidate boundaries and the
351
- approved file state immediately before atomic replacement.
352
- - Dangerous tools ask by default unless explicitly allowed for the session or
353
- the process starts with `--auto-approve`.
354
- - Credential fields are masked and excluded from transcripts. Recognized
355
- credential values are redacted before output reaches the model, screen,
356
- history, or export.
357
- - Approved shell commands receive no secret-bearing environment variables.
358
- `SSH_AUTH_SOCK` is preserved so Git and SSH can use the user's agent, which
359
- means an approved command may ask that agent to authenticate.
360
- - ChatGPT OAuth uses PKCE and an exact loopback callback or the OpenAI device
361
- flow. Refresh-token rotation is serialized across Jecode processes.
362
- - Terminal control characters are neutralized before rendering.
363
- - Remote Ollama endpoints require HTTPS, and provider redirects are rejected.
364
- - Provider handshakes and idle response bodies have finite deadlines. Only
365
- idempotent catalogue reads retry; generation requests are never replayed.
366
- - Model, terminal, and filesystem input are bounded before use.
367
- - Session files are versioned, symmetrically size-bounded before write and
368
- after read, atomically checkpointed, and treated as untrusted when loaded. A
369
- live lease prevents concurrent resume.
370
-
371
- `run_command` is not an operating-system sandbox. An approved command can still
372
- access files and account resources available to the current user. Review
373
- commands carefully and reserve `--auto-approve` for controlled environments.
374
-
375
- Read [SECURITY.md](SECURITY.md) before reporting a vulnerability.
376
-
377
- ## Project direction
378
-
379
- The terminal is Jecode's current primary interface, not the limit of the
380
- product. Future interfaces may reuse the same controller, session model, and
381
- visible control system. Expansion must remain deliberate: no hidden model
382
- hierarchy, no delegated authority, and no weakening of the zero-dependency
383
- runtime.
384
-
385
- The single-controller rule limits delegation, not duration or scope. Jecode may
386
- eventually supervise visible, interruptible processes such as development
387
- servers, file watchers, and test runners. Those processes remain tools owned by
388
- the controller; they do not receive independent goals, model loops, or tool
389
- authority.
390
-
391
- The selected workspace is the current default filesystem boundary. Any future
392
- access to additional directories or resources must use explicit, reviewable,
393
- revocable grants rather than silently widening that boundary.
394
-
395
- ## Build from source
396
-
397
- ```console
398
- git clone https://github.com/giovannijecha/jecode.git
399
- cd jecode
400
- npm ci --ignore-scripts
401
- npm run build:release
402
- npm link
403
- jecode --version
404
- ```
405
-
406
- Development runs TypeScript directly with `npm run start`. `dist/` is an
407
- ignored generated tree used only by linked commands and release tarballs.
408
- `npm run pack:release` rebuilds it from a clean target. Installing the published
409
- package runs no compilation or installation scripts.
410
-
411
- Run the complete project checks with:
412
-
413
- ```console
414
- npm run check
415
- ```
161
+ ## Documentation
416
162
 
417
- Use `npm run tui:lab` to inspect production TUI components with inert local
418
- fixtures. `npm run bench:transcript` and `npm run bench:search` provide manual
419
- probes for long-session rendering and workspace search. Architecture and
420
- security boundaries are documented in
421
- [docs/architecture.md](docs/architecture.md); brand assets and usage rules live
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).
163
+ The [user guide](https://github.com/giovannijecha/jecode/blob/main/docs/USAGE.md)
164
+ covers provider access, TUI controls, sessions and compaction, batch mode,
165
+ configuration, and safety boundaries.
425
166
 
426
167
  ## Community
427
168
 
@@ -434,8 +175,9 @@ procedure lives in [docs/releasing.md](docs/releasing.md).
434
175
 
435
176
  Public pull requests are not accepted at this stage. Code changes remain a
436
177
  maintainer and invited-collaborator workflow. See
437
- [CONTRIBUTING.md](CONTRIBUTING.md) and
438
- [CODE_OF_CONDUCT.md](CODE_OF_CONDUCT.md).
178
+ [CONTRIBUTING.md](https://github.com/giovannijecha/jecode/blob/main/CONTRIBUTING.md)
179
+ and
180
+ [CODE_OF_CONDUCT.md](https://github.com/giovannijecha/jecode/blob/main/CODE_OF_CONDUCT.md).
439
181
 
440
182
  ## License
441
183
 
@@ -0,0 +1,3 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 760 180" role="img" aria-label="jecode">
2
+ <text x="380" y="128" fill="#669BD2" font-family="Inter,Nunito Sans,system-ui,sans-serif" font-size="132" font-weight="650" letter-spacing="-7" text-anchor="middle">jecode</text>
3
+ </svg>
package/dist/accounts.js CHANGED
@@ -1,10 +1,10 @@
1
1
  // OAuth accounts persisted under ~/.jecode, apart from API keys.
2
2
  import { chmod, mkdir } from "node:fs/promises";
3
- import { readFileSync } from "node:fs";
4
3
  import * as path from "node:path";
5
4
  import { atomicWrite } from "./atomic.js";
6
5
  import { withStoreLock } from "./store-lock.js";
7
6
  import { userDataLabel, userDataPath } from "./user-data.js";
7
+ import { assertStoreText, 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"];
@@ -39,15 +39,20 @@ export async function updateOpenAICodexAccount(change, signal) {
39
39
  return withStoreLock(file, async () => {
40
40
  const current = readStore(file);
41
41
  const next = await change(current.accounts["openai-codex"]);
42
+ const normalized = next === undefined ? undefined : normalizeOpenAI(next);
43
+ if (next !== undefined && normalized === undefined)
44
+ throw new Error("invalid OpenAI account");
42
45
  const accounts = { ...current.accounts };
43
46
  if (next === undefined)
44
47
  delete accounts["openai-codex"];
45
48
  else
46
- accounts["openai-codex"] = { ...next };
49
+ accounts["openai-codex"] = normalized;
47
50
  const updated = { version: 1, accounts };
48
- await atomicWrite(file, `${JSON.stringify(updated, null, 2)}\n`, { mode: 0o600 });
51
+ const text = `${JSON.stringify(updated, null, 2)}\n`;
52
+ assertStoreText(text, USER_STORE_LIMITS.accountsBytes);
53
+ await atomicWrite(file, text, { mode: 0o600 });
49
54
  cached = updated;
50
- return next === undefined ? undefined : { ...next };
55
+ return normalized === undefined ? undefined : { ...normalized };
51
56
  }, signal);
52
57
  }
53
58
  export function reloadAccounts() {
@@ -60,8 +65,7 @@ function store() {
60
65
  }
61
66
  function readStore(file) {
62
67
  try {
63
- const parsed = JSON.parse(readFileSync(file, "utf8"));
64
- return normalize(parsed);
68
+ return normalize(readBoundedJsonSync(file, USER_STORE_LIMITS.accountsBytes));
65
69
  }
66
70
  catch {
67
71
  return { version: 1, accounts: {} };
@@ -80,9 +84,9 @@ function normalize(value) {
80
84
  function normalizeOpenAI(value) {
81
85
  if (!record(value))
82
86
  return undefined;
83
- const accessToken = nonempty(value["accessToken"]);
84
- const refreshToken = nonempty(value["refreshToken"]);
85
- const accountId = nonempty(value["accountId"]);
87
+ const accessToken = nonempty(value["accessToken"], USER_STORE_LIMITS.accountToken);
88
+ const refreshToken = nonempty(value["refreshToken"], USER_STORE_LIMITS.accountToken);
89
+ const accountId = nonempty(value["accountId"], USER_STORE_LIMITS.accountLabel);
86
90
  const expiresAt = value["expiresAt"];
87
91
  if (accessToken === undefined ||
88
92
  refreshToken === undefined ||
@@ -91,8 +95,8 @@ function normalizeOpenAI(value) {
91
95
  !Number.isSafeInteger(expiresAt) ||
92
96
  expiresAt <= 0)
93
97
  return undefined;
94
- const email = nonempty(value["email"]);
95
- const plan = nonempty(value["plan"]);
98
+ const email = nonempty(value["email"], USER_STORE_LIMITS.accountLabel);
99
+ const plan = nonempty(value["plan"], USER_STORE_LIMITS.accountLabel);
96
100
  return {
97
101
  accessToken,
98
102
  refreshToken,
@@ -102,8 +106,8 @@ function normalizeOpenAI(value) {
102
106
  ...(plan === undefined ? {} : { plan }),
103
107
  };
104
108
  }
105
- function nonempty(value) {
106
- return typeof value === "string" && value.trim() !== "" ? value : undefined;
109
+ function nonempty(value, max) {
110
+ return typeof value === "string" && value.length <= max && value.trim() !== "" ? value : undefined;
107
111
  }
108
112
  function record(value) {
109
113
  return typeof value === "object" && value !== null && !Array.isArray(value);
package/dist/batch.js CHANGED
@@ -2,7 +2,6 @@
2
2
  //
3
3
  // It exists so the agent can be scripted and tested. It shares the controller,
4
4
  // the tools and the block renderer with the TUI — only the surface differs.
5
- import * as readline from "node:readline/promises";
6
5
  import { stdin, stdout } from "node:process";
7
6
  import { runTurn } from "./controller.js";
8
7
  import { resolveContextPolicy } from "./context/capacity.js";
@@ -14,24 +13,31 @@ import { renderBatch } from "./batch-view.js";
14
13
  import { columns } from "./ui/render.js";
15
14
  import { terminalText } from "./ui/terminal-text.js";
16
15
  import { recordAuxiliaryUsage, recordRequestInput, recordUsage } from "./usage.js";
16
+ import { assertPromptLength, boundedInputLines } from "./input-boundary.js";
17
+ import { providerFailure } from "./provider-errors.js";
17
18
  export async function runBatch(session, environment = {}) {
18
- const rl = environment.lines === undefined ? readline.createInterface({ input: stdin }) : undefined;
19
- const lines = environment.lines ?? rl;
20
19
  const write = environment.write ?? ((text) => stdout.write(text));
21
20
  const width = environment.width ?? columns();
21
+ const signal = environment.signal;
22
+ const source = environment.lines ?? boundedInputLines(stdin);
23
+ const lines = abortableLines(source, signal);
22
24
  const emit = (block) => {
23
25
  for (const line of renderBatch(block, width, session.palette))
24
26
  write(`${line}\n`);
25
27
  };
26
28
  try {
29
+ throwIfAborted(signal);
27
30
  for await (const raw of lines) {
31
+ throwIfAborted(signal);
32
+ assertPromptLength(raw.length);
28
33
  const line = raw.trim();
29
34
  if (line === "")
30
35
  continue;
31
36
  if (line.startsWith("/")) {
32
37
  if ((await handleCommand(line, session, {
33
38
  emit,
34
- compact: () => compactSession(session),
39
+ signal,
40
+ compact: () => compactSession(session, { signal }),
35
41
  })) === "exit")
36
42
  break;
37
43
  continue;
@@ -87,6 +93,7 @@ export async function runBatch(session, environment = {}) {
87
93
  nodeId: nodeId ?? prospectiveNodeId,
88
94
  coveredMessages: context?.messageCount ?? 0,
89
95
  lastInputTokens: Math.max(session.usage.lastInputTokens, request.inputTokens),
96
+ estimatedInputTokens: request.inputTokens,
90
97
  force,
91
98
  policy: request.policy,
92
99
  });
@@ -109,12 +116,65 @@ export async function runBatch(session, environment = {}) {
109
116
  commit(checkpoint, settlement);
110
117
  return compacted;
111
118
  };
112
- await runTurn(history, options(session, policy), turn, undefined, modelHistory);
119
+ try {
120
+ await runTurn(history, options(session, policy), turn, signal, modelHistory);
121
+ }
122
+ catch (error) {
123
+ throwIfAborted(signal);
124
+ if (!(error instanceof Error))
125
+ throw error;
126
+ const message = providerFailure(session.provider, error);
127
+ if (message === error.message)
128
+ throw error;
129
+ throw new Error(message, { cause: error });
130
+ }
113
131
  turn.flush();
114
132
  }
133
+ throwIfAborted(signal);
134
+ }
135
+ finally {
136
+ if (environment.lines === undefined)
137
+ stdin.pause();
138
+ }
139
+ }
140
+ function throwIfAborted(signal) {
141
+ if (signal?.aborted === true)
142
+ throw signal.reason;
143
+ }
144
+ async function* abortableLines(source, signal) {
145
+ const iterator = source[Symbol.asyncIterator]();
146
+ let exhausted = false;
147
+ try {
148
+ while (true) {
149
+ throwIfAborted(signal);
150
+ const next = signal === undefined
151
+ ? await iterator.next()
152
+ : await new Promise((resolve, reject) => {
153
+ const abort = () => reject(signal.reason);
154
+ signal.addEventListener("abort", abort, { once: true });
155
+ iterator.next().then((result) => {
156
+ signal.removeEventListener("abort", abort);
157
+ resolve(result);
158
+ }, (error) => {
159
+ signal.removeEventListener("abort", abort);
160
+ reject(error);
161
+ });
162
+ });
163
+ if (next.done === true) {
164
+ exhausted = true;
165
+ return;
166
+ }
167
+ yield next.value;
168
+ }
115
169
  }
116
170
  finally {
117
- rl?.close();
171
+ if (!exhausted && iterator.return !== undefined) {
172
+ const closing = iterator.return();
173
+ if (signal?.aborted === true)
174
+ void closing.catch(() => { });
175
+ else
176
+ await closing;
177
+ }
118
178
  }
119
179
  }
120
180
  function options(session, contextPolicy) {
package/dist/config.js CHANGED
@@ -43,9 +43,7 @@ export function loadConfig(argv, saved = readSettings()) {
43
43
  : { maxModelRequests: toInt(maxModelRequests, "max-steps") }),
44
44
  compactionPercent: toPercent(pick(flags["compaction-percent"], process.env.JECODE_COMPACTION_PERCENT, String(saved.compactionPercent ?? DEFAULT_COMPACTION_PERCENT))),
45
45
  root: path.resolve(pick(flags.root, undefined, process.cwd())),
46
- autoApprove: flags["auto-approve"] === "true" ||
47
- flags["auto-approve"] === "1" ||
48
- process.env.JECODE_AUTO_APPROVE === "1",
46
+ autoApprove: autoApproval(flags["auto-approve"], process.env.JECODE_AUTO_APPROVE),
49
47
  ephemeral: bool(flags.ephemeral, process.env.JECODE_EPHEMERAL, false),
50
48
  };
51
49
  }
@@ -56,6 +54,11 @@ function bool(flag, env, fallback) {
56
54
  return env === "true" || env === "1";
57
55
  return fallback;
58
56
  }
57
+ function autoApproval(flag, env) {
58
+ if (flag !== undefined)
59
+ return flag === "true" || flag === "1";
60
+ return env === "1";
61
+ }
59
62
  function pick(flag, env, fallback) {
60
63
  if (flag !== undefined && flag !== "")
61
64
  return flag;
@@ -1,5 +1,5 @@
1
1
  // Conservative provider-neutral budgeting for one complete model request.
2
- import { estimateSerializedTokens } from "./estimate.js";
2
+ import { estimateSerializedTokens, estimateSerializedTokensResponsive, } from "./estimate.js";
3
3
  import { MIN_REQUEST_OUTPUT_TOKENS } from "./policy.js";
4
4
  const ENVELOPE_OVERHEAD_TOKENS = 64;
5
5
  const MESSAGE_OVERHEAD_TOKENS = 8;
@@ -16,6 +16,18 @@ export function estimateRequestInputTokens(envelope) {
16
16
  envelope.messages.length * MESSAGE_OVERHEAD_TOKENS +
17
17
  envelope.tools.length * TOOL_OVERHEAD_TOKENS;
18
18
  }
19
+ /** Preserve the request estimate while yielding during large local inputs. */
20
+ export async function estimateRequestInputTokensResponsive(envelope, signal) {
21
+ const contentTokens = await estimateSerializedTokensResponsive({
22
+ system: envelope.system,
23
+ messages: envelope.messages,
24
+ tools: envelope.tools,
25
+ }, signal);
26
+ return contentTokens +
27
+ ENVELOPE_OVERHEAD_TOKENS +
28
+ envelope.messages.length * MESSAGE_OVERHEAD_TOKENS +
29
+ envelope.tools.length * TOOL_OVERHEAD_TOKENS;
30
+ }
19
31
  /** Clamp the configured output ceiling so the complete request remains usable. */
20
32
  export function budgetRequest(envelope, configuredMaxOutputTokens, policy) {
21
33
  requirePositiveInteger(configuredMaxOutputTokens, "max output tokens");
@@ -1,5 +1,5 @@
1
1
  // One bounded provider request that condenses an older context prefix.
2
- import { budgetRequest } from "./budget.js";
2
+ import { budgetRequestFromInputTokens, estimateRequestInputTokensResponsive, } from "./budget.js";
3
3
  import { CONTEXT_LIMITS, summaryMessage } from "./projection.js";
4
4
  import { planCompaction } from "./policy.js";
5
5
  const SUMMARY_SYSTEM = [
@@ -13,17 +13,18 @@ const SUMMARY_SYSTEM = [
13
13
  ].join("\n");
14
14
  export async function compactContext(options) {
15
15
  const policy = options.policy;
16
- const plan = planCompaction(options.context, options.turn, options.coveredMessages, options.lastInputTokens, options.force ?? false, policy);
16
+ const plan = options.precomputedPlan ?? await planCompaction(options.context, options.turn, options.coveredMessages, options.lastInputTokens, options.force ?? false, policy, options.estimatedInputTokens, options.signal);
17
17
  if (plan === undefined)
18
18
  return undefined;
19
19
  options.onBegin?.();
20
20
  try {
21
21
  const messages = normalized(plan.prefix);
22
- const budget = budgetRequest({
22
+ const inputTokens = await estimateRequestInputTokensResponsive({
23
23
  system: SUMMARY_SYSTEM,
24
24
  messages,
25
25
  tools: [],
26
- }, policy.summaryMaxTokens, policy);
26
+ }, options.signal);
27
+ const budget = budgetRequestFromInputTokens(inputTokens, policy.summaryMaxTokens, policy);
27
28
  const response = await options.provider.send({
28
29
  model: options.model,
29
30
  system: SUMMARY_SYSTEM,