@giovannijecha/jecode 0.2.0 → 0.2.1

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.
package/README.md CHANGED
@@ -255,9 +255,10 @@ untrusted data.
255
255
  - Dangerous tools ask by default unless explicitly allowed for the session in
256
256
  **/permissions** or the process started with **--auto-approve**.
257
257
  - Credential fields are masked and excluded from transcripts. Approved shell
258
- commands receive no credential-like environment variables, and recognized
259
- credential values are redacted before tool output reaches the model, screen,
260
- history, or export.
258
+ commands receive no secret-bearing environment variables; `SSH_AUTH_SOCK`
259
+ is preserved so Git and SSH can use the user's agent, which means an approved
260
+ command can request that agent to authenticate. Recognized credential values
261
+ are redacted before tool output reaches the model, screen, history, or export.
261
262
  - ChatGPT OAuth uses PKCE and an exact loopback callback or the OpenAI device
262
263
  flow. Refresh-token rotation is serialized across Jecode processes; OAuth
263
264
  tokens are withheld and redacted like API keys.
@@ -14,6 +14,7 @@ export const MAX_TOOL_CALLS_PER_STEP = 32;
14
14
  export async function runTurn(history, options, events, signal) {
15
15
  const specs = toolSpecs(options.tools);
16
16
  for (let step = 0; step < options.maxSteps; step++) {
17
+ throwIfAborted(signal);
17
18
  events.onStep?.(step + 1, options.maxSteps);
18
19
  // The message is displayed as it streams; what comes back here is the
19
20
  // assembled version, which exists to be appended to the history.
@@ -28,6 +29,7 @@ export async function runTurn(history, options, events, signal) {
28
29
  onStream: (event) => events.onStream(event),
29
30
  onStatus: (status) => events.onStatus?.(status),
30
31
  });
32
+ throwIfAborted(signal);
31
33
  const calls = assistant.content.filter(isToolCall);
32
34
  if (assistant.content.length === 0) {
33
35
  throw new Error(`${options.provider.id} completed without an answer or tool call`);
@@ -44,14 +46,33 @@ export async function runTurn(history, options, events, signal) {
44
46
  // but every result from this step goes back in a SINGLE message. Splitting
45
47
  // them teaches the model to stop batching its calls.
46
48
  const results = [];
47
- for (let index = 0; index < calls.length; index++) {
48
- const call = calls[index];
49
- events.onToolProgress?.(index + 1, calls.length);
50
- const preview = await look(call, options);
51
- events.onToolCall(call, preview);
52
- const { result, summary } = await settle(call, options, events, signal, preview);
53
- events.onToolResult(call, result, summary);
54
- results.push(result);
49
+ const announced = new Set();
50
+ try {
51
+ for (let index = 0; index < calls.length; index++) {
52
+ throwIfAborted(signal);
53
+ const call = calls[index];
54
+ events.onToolProgress?.(index + 1, calls.length);
55
+ const preview = await look(call, options, signal);
56
+ throwIfAborted(signal);
57
+ events.onToolCall(call, preview);
58
+ announced.add(call.id);
59
+ const { result, summary } = await settle(call, options, events, signal, preview);
60
+ events.onToolResult(call, result, summary);
61
+ results.push(result);
62
+ }
63
+ }
64
+ catch (error) {
65
+ if (signal?.aborted !== true)
66
+ throw error;
67
+ for (const call of calls.slice(results.length)) {
68
+ const interrupted = refuse(call, "interrupted before completion", "interrupted");
69
+ if (announced.has(call.id)) {
70
+ events.onToolResult(call, interrupted.result, interrupted.summary);
71
+ }
72
+ results.push(interrupted.result);
73
+ }
74
+ history.push({ role: "user", content: results });
75
+ throw abortReason(signal);
55
76
  }
56
77
  history.push({ role: "user", content: results });
57
78
  }
@@ -62,9 +83,13 @@ async function settle(call, options, events, signal, preview) {
62
83
  if (tool === undefined) {
63
84
  return refuse(call, `no such tool: ${call.name}`, "unknown tool");
64
85
  }
65
- if (tool.dangerous && !(await events.approve(call))) {
86
+ throwIfAborted(signal);
87
+ const approved = !tool.dangerous || await events.approve(call);
88
+ throwIfAborted(signal);
89
+ if (!approved) {
66
90
  return refuse(call, "the user declined this call — ask them how to proceed", "declined");
67
91
  }
92
+ throwIfAborted(signal);
68
93
  return runTool(tool, call, {
69
94
  ...options.toolContext,
70
95
  signal,
@@ -85,14 +110,21 @@ function refuse(call, reason, summary) {
85
110
  * file, a match that is not there — is simply no preview. Nothing about the
86
111
  * turn depends on it, because it exists for the user, not for the model.
87
112
  */
88
- async function look(call, options) {
113
+ async function look(call, options, signal) {
89
114
  const tool = findTool(options.tools, call.name);
90
115
  if (tool?.preview === undefined)
91
116
  return undefined;
92
117
  try {
93
- return await tool.preview(call.input, options.toolContext);
118
+ return await tool.preview(call.input, { ...options.toolContext, signal });
94
119
  }
95
120
  catch {
96
121
  return undefined;
97
122
  }
98
123
  }
124
+ function throwIfAborted(signal) {
125
+ if (signal?.aborted === true)
126
+ throw abortReason(signal);
127
+ }
128
+ function abortReason(signal) {
129
+ return signal.reason instanceof Error ? signal.reason : new Error("interrupted");
130
+ }
@@ -4,6 +4,12 @@ import { accountValues } from "./accounts.js";
4
4
  const REDACTED = "[credential redacted]";
5
5
  const SENSITIVE_ENVIRONMENT_NAME = /(?:^|_)(?:API_?KEY|ACCESS_?KEY|PRIVATE_?KEY|KEY|TOKEN|SECRET|PASSWORD|PASSWD|PASS|PWD|CREDENTIALS?|AUTH|JWT|COOKIE|PAT)(?:_|$)/i;
6
6
  const COMPACT_SENSITIVE_ENVIRONMENT_NAME = /^(?:PGPASSWORD)$/i;
7
+ const SAFE_ENVIRONMENT_NAMES = new Set([
8
+ "SSH_AUTH_SOCK",
9
+ "PWD",
10
+ "OLDPWD",
11
+ "PASSWORD_STORE_DIR",
12
+ ]);
7
13
  /** Preserve ordinary tool configuration while withholding credential-like values. */
8
14
  export function shellEnvironment(source = process.env) {
9
15
  const environment = {};
@@ -57,6 +63,8 @@ function sensitiveEnvironmentName(name) {
57
63
  .replace(/([a-z0-9])([A-Z])/g, "$1_$2")
58
64
  .replace(/[^a-z0-9]+/gi, "_")
59
65
  .replace(/^_+|_+$/g, "");
66
+ if (SAFE_ENVIRONMENT_NAMES.has(normalized.toUpperCase()))
67
+ return false;
60
68
  return (SENSITIVE_ENVIRONMENT_NAME.test(normalized) ||
61
69
  COMPACT_SENSITIVE_ENVIRONMENT_NAME.test(normalized));
62
70
  }
package/dist/effort.js ADDED
@@ -0,0 +1,25 @@
1
+ // One ordered vocabulary for reasoning depth. Providers expose the subset a
2
+ // selected model accepts; this module only validates and reconciles that data.
3
+ export const EFFORTS = ["low", "medium", "high", "xhigh", "max"];
4
+ export function isEffort(value) {
5
+ return typeof value === "string" && EFFORTS.includes(value);
6
+ }
7
+ export function requireSupportedEffort(model, requested, supported) {
8
+ if (supported.includes(requested))
9
+ return requested;
10
+ const choices = supported.length === 0 ? "none" : supported.join(", ");
11
+ throw new Error(`${model} does not support effort "${requested}" (available: ${choices})`);
12
+ }
13
+ /** Keep the requested depth when possible, otherwise choose the nearest lower level. */
14
+ export function compatibleEffort(requested, supported) {
15
+ if (supported.length === 0)
16
+ return undefined;
17
+ if (supported.includes(requested))
18
+ return requested;
19
+ const requestedIndex = EFFORTS.indexOf(requested);
20
+ const candidates = EFFORTS.filter((value) => supported.includes(value));
21
+ if (requestedIndex < 0)
22
+ return candidates[0];
23
+ return [...candidates].reverse().find((value) => EFFORTS.indexOf(value) < requestedIndex)
24
+ ?? candidates[0];
25
+ }
@@ -43,6 +43,8 @@ export async function openAIAuthorization(forceToken, signal, onStatus) {
43
43
  async function refreshAccount(forceToken, signal, onStatus) {
44
44
  if (refreshing !== undefined)
45
45
  return abortable(refreshing, signal);
46
+ if (signal?.aborted === true)
47
+ throw abortReason(signal);
46
48
  onStatus?.("Refreshing ChatGPT sign-in");
47
49
  const task = updateOpenAICodexAccount(async (current) => {
48
50
  if (current === undefined)
@@ -51,20 +53,21 @@ async function refreshAccount(forceToken, signal, onStatus) {
51
53
  return current;
52
54
  if (forceToken === undefined && !expiresSoon(current))
53
55
  return current;
54
- return refreshOpenAITokens(current, signal);
55
- }, signal).then((account) => {
56
+ return refreshOpenAITokens(current);
57
+ }).then((account) => {
56
58
  if (account === undefined)
57
59
  throw new Error("ChatGPT account is not connected");
58
60
  return account;
59
61
  });
60
62
  refreshing = task;
61
- try {
62
- return await abortable(task, signal);
63
- }
64
- finally {
63
+ void task.then(() => {
65
64
  if (refreshing === task)
66
65
  refreshing = undefined;
67
- }
66
+ }, () => {
67
+ if (refreshing === task)
68
+ refreshing = undefined;
69
+ });
70
+ return abortable(task, signal);
68
71
  }
69
72
  function expiresSoon(account) {
70
73
  return account.expiresAt - Date.now() <= REFRESH_EARLY_MS;
@@ -5,6 +5,7 @@ import { readSettings } from "./settings.js";
5
5
  import { authenticationNeed, ensureProviderAuthentication, } from "./credential-commands.js";
6
6
  import { providerFailure } from "./provider-errors.js";
7
7
  import { providerLabel } from "./provider-label.js";
8
+ import { compatibleEffort } from "./effort.js";
8
9
  /**
9
10
  * The provider menu.
10
11
  *
@@ -61,6 +62,7 @@ export async function providersCommand(session, host, behavior = {}) {
61
62
  model: session.model,
62
63
  providerId: session.config.providerId,
63
64
  configModel: session.config.model,
65
+ effort: session.config.effort,
64
66
  };
65
67
  session.provider = chosen;
66
68
  // The model belonged to the old provider. Carrying it across would send
@@ -68,23 +70,31 @@ export async function providersCommand(session, host, behavior = {}) {
68
70
  session.model = readSettings().models?.[chosen.id] ?? chosen.defaultModel;
69
71
  session.config.providerId = chosen.id;
70
72
  session.config.model = session.model;
71
- if (session.model === "" && !(await modelsCommand(session, host, { announce: false, save: false }))) {
72
- session.provider = before.provider;
73
- session.model = before.model;
74
- session.config.providerId = before.providerId;
75
- session.config.model = before.configModel;
76
- return false;
73
+ if (session.model === "") {
74
+ if (!(await modelsCommand(session, host, { announce: false, save: false }))) {
75
+ restoreProvider(session, before);
76
+ return false;
77
+ }
78
+ }
79
+ else {
80
+ const alignment = await alignEffort(session, host);
81
+ if (!alignment.ok) {
82
+ restoreProvider(session, before);
83
+ return false;
84
+ }
77
85
  }
78
86
  if (behavior.save !== false) {
79
87
  const saved = readSettings();
80
88
  const models = { ...saved.models };
81
89
  if (session.model !== "")
82
90
  models[chosen.id] = session.model;
83
- if (!(await saveDefaults(host, { provider: chosen.id, models }))) {
84
- session.provider = before.provider;
85
- session.model = before.model;
86
- session.config.providerId = before.providerId;
87
- session.config.model = before.configModel;
91
+ const patch = {
92
+ provider: chosen.id,
93
+ models,
94
+ ...(session.config.effort === before.effort ? {} : { effort: session.config.effort }),
95
+ };
96
+ if (!(await saveDefaults(host, patch))) {
97
+ restoreProvider(session, before);
88
98
  return false;
89
99
  }
90
100
  }
@@ -152,15 +162,31 @@ export async function modelsCommand(session, host, behavior = {}) {
152
162
  const chosen = ids[index];
153
163
  if (chosen === undefined)
154
164
  return false;
155
- const before = { model: session.model, configModel: session.config.model };
165
+ const before = {
166
+ model: session.model,
167
+ configModel: session.config.model,
168
+ effort: session.config.effort,
169
+ };
156
170
  session.model = chosen;
157
171
  session.config.model = chosen;
172
+ const alignment = await alignEffort(session, host);
173
+ if (!alignment.ok) {
174
+ session.model = before.model;
175
+ session.config.model = before.configModel;
176
+ session.config.effort = before.effort;
177
+ return false;
178
+ }
158
179
  if (behavior.save !== false) {
159
180
  const saved = readSettings();
160
181
  const models = { ...saved.models, [provider.id]: chosen };
161
- if (!(await saveDefaults(host, { models }))) {
182
+ const patch = {
183
+ models,
184
+ ...(session.config.effort === before.effort ? {} : { effort: session.config.effort }),
185
+ };
186
+ if (!(await saveDefaults(host, patch))) {
162
187
  session.model = before.model;
163
188
  session.config.model = before.configModel;
189
+ session.config.effort = before.effort;
164
190
  return false;
165
191
  }
166
192
  }
@@ -169,7 +195,34 @@ export async function modelsCommand(session, host, behavior = {}) {
169
195
  }
170
196
  return true;
171
197
  }
172
- /** The way to put a menu up, or nothing — and the reason, already said. */
198
+ function restoreProvider(session, before) {
199
+ session.provider = before.provider;
200
+ session.model = before.model;
201
+ session.config.providerId = before.providerId;
202
+ session.config.model = before.configModel;
203
+ session.config.effort = before.effort;
204
+ }
205
+ async function alignEffort(session, host) {
206
+ if (session.provider.efforts === undefined)
207
+ return { ok: true };
208
+ let supported;
209
+ try {
210
+ supported = await session.provider.efforts(session.model, host.signal, (status) => host.status?.(status));
211
+ }
212
+ catch (error) {
213
+ host.emit({
214
+ kind: "notice",
215
+ text: providerFailure(session.provider, error, true),
216
+ tone: "error",
217
+ });
218
+ return { ok: false };
219
+ }
220
+ const adjusted = compatibleEffort(session.config.effort, supported);
221
+ if (adjusted === undefined || adjusted === session.config.effort)
222
+ return { ok: true };
223
+ session.config.effort = adjusted;
224
+ return { ok: true, adjusted };
225
+ }
173
226
  function chooser(host) {
174
227
  if (host.choose === undefined) {
175
228
  host.emit({ kind: "notice", text: "that command needs the screen", tone: "warn" });
@@ -13,7 +13,8 @@ export async function assembleAnthropic(events, onStream) {
13
13
  let stopReason;
14
14
  let stopDetails;
15
15
  let usage;
16
- for await (const raw of events) {
16
+ let complete = false;
17
+ stream: for await (const raw of events) {
17
18
  const event = raw;
18
19
  switch (event.type) {
19
20
  case "message_start":
@@ -51,10 +52,15 @@ export async function assembleAnthropic(events, onStream) {
51
52
  case "error": {
52
53
  throw new Error(`anthropic stream error: ${event.error?.message ?? "unspecified"}`);
53
54
  }
55
+ case "message_stop":
56
+ complete = true;
57
+ break stream;
54
58
  default:
55
- break; // message_start, ping, message_stop: nothing to accumulate
59
+ break; // ping and unknown forward-compatible events
56
60
  }
57
61
  }
62
+ if (!complete)
63
+ throw new Error("anthropic stream ended before message_stop");
58
64
  const content = [...blocks.entries()]
59
65
  .sort(([a], [b]) => a - b)
60
66
  .map(([, block]) => block);
@@ -9,19 +9,27 @@
9
9
  import { postSse } from "./http.js";
10
10
  import { listModels } from "./catalog.js";
11
11
  import { keyFor } from "../credentials.js";
12
+ import { EFFORTS, requireSupportedEffort } from "../effort.js";
12
13
  import { assembleAnthropic } from "./anthropic-stream.js";
13
14
  import { fromWireResponse, stopNotice, toWireMessage, toWireTool } from "./anthropic-wire.js";
14
15
  const ENDPOINT = "https://api.anthropic.com/v1/messages";
15
16
  const MODELS = "https://api.anthropic.com/v1/models?limit=100";
16
17
  const API_VERSION = "2023-06-01";
17
18
  const KEY = "ANTHROPIC_API_KEY";
18
- // Adaptive thinking and `output_config.effort` exist on the 4.6-and-later
19
- // families only. Older models reject both with a 400, so `/model claude-haiku-4-5`
20
- // would fail on send if the request shape were fixed. It follows the model.
21
- const ADAPTIVE = /^claude-(fable-5|opus-(5|4-[678])|sonnet-(5|4-6))/;
19
+ const ADAPTIVE = /^claude-(?:fable-5|mythos-(?:5|preview)|opus-(?:5|4-[678])|sonnet-(?:5|4-6))(?:-|$)/;
20
+ const MAX_WITHOUT_XHIGH = ["low", "medium", "high", "max"];
21
+ const ANTHROPIC_45_EFFORTS = ["low", "medium", "high"];
22
22
  export function supportsAdaptiveThinking(model) {
23
23
  return ADAPTIVE.test(model);
24
24
  }
25
+ export function anthropicEfforts(model) {
26
+ if (/^claude-(?:(?:opus|sonnet)-4-6|mythos-preview)(?:-|$)/.test(model)) {
27
+ return MAX_WITHOUT_XHIGH;
28
+ }
29
+ if (/^claude-opus-4-5(?:-|$)/.test(model))
30
+ return ANTHROPIC_45_EFFORTS;
31
+ return supportsAdaptiveThinking(model) ? EFFORTS : [];
32
+ }
25
33
  export const anthropic = {
26
34
  id: "anthropic",
27
35
  // Sonnet is the default because it is the one that can be left running.
@@ -36,6 +44,9 @@ export const anthropic = {
36
44
  models(signal, onStatus) {
37
45
  return listModels(MODELS, headers(requireKey()), signal, onStatus);
38
46
  },
47
+ async efforts(model) {
48
+ return anthropicEfforts(model);
49
+ },
39
50
  location: () => "cloud",
40
51
  async send(req) {
41
52
  const key = requireKey();
@@ -49,7 +60,12 @@ export const anthropic = {
49
60
  };
50
61
  if (supportsAdaptiveThinking(req.model)) {
51
62
  body["thinking"] = { type: "adaptive", display: "summarized" };
52
- body["output_config"] = { effort: req.effort };
63
+ }
64
+ const efforts = anthropicEfforts(req.model);
65
+ if (efforts.length > 0) {
66
+ body["output_config"] = {
67
+ effort: requireSupportedEffort(req.model, req.effort, efforts),
68
+ };
53
69
  }
54
70
  const events = await postSse(ENDPOINT, headers(key), body, req.signal, req.onStatus);
55
71
  const data = await assembleAnthropic(events, req.onStream);
@@ -51,6 +51,9 @@ export async function assembleOllama(events, onStream) {
51
51
  calls.set(index, call);
52
52
  }
53
53
  }
54
+ if (finishReason === undefined) {
55
+ throw new Error("ollama stream ended before a finish reason");
56
+ }
54
57
  const toolCalls = [...calls.entries()]
55
58
  .sort(([a], [b]) => a - b)
56
59
  // Not every server sends an id, and the loop needs one to pair the result
@@ -45,6 +45,9 @@ export const ollama = {
45
45
  const at = endpoint();
46
46
  return listModels(`${at.baseUrl}/v1/models`, headers(at), signal, onStatus);
47
47
  },
48
+ async efforts() {
49
+ return [];
50
+ },
48
51
  location: () => {
49
52
  try {
50
53
  return endpoint().loopback ? "local" : "cloud";
@@ -3,6 +3,7 @@ import { randomUUID } from "node:crypto";
3
3
  import { openAICodexAccount } from "../accounts.js";
4
4
  import { openAIAuthorization } from "../openai-account.js";
5
5
  import { applicationVersion } from "../version.js";
6
+ import { EFFORTS, isEffort, requireSupportedEffort } from "../effort.js";
6
7
  import { getJson, postSse } from "./http.js";
7
8
  import { assembleOpenAI } from "./openai-stream.js";
8
9
  import { fromWireResponse, stopNotice, toWireItems, toWireTool, } from "./openai-wire.js";
@@ -16,6 +17,8 @@ const SESSION_ID = randomUUID();
16
17
  const MAX_CATALOG_ITEMS = 4_000;
17
18
  const MAX_MODELS = 1_000;
18
19
  const MAX_MODEL_CHARS = 256;
20
+ const XHIGH_EFFORTS = ["low", "medium", "high", "xhigh"];
21
+ let effortByModel = new Map();
19
22
  export const openaiCodex = {
20
23
  id: ID,
21
24
  defaultModel: "",
@@ -25,12 +28,21 @@ export const openaiCodex = {
25
28
  },
26
29
  location: () => "cloud",
27
30
  async models(signal, onStatus) {
28
- return withAuthorization(async (authorization) => {
29
- const body = await getJson(`${BASE}/models?client_version=${CATALOG_COMPATIBILITY_VERSION}`, headers(authorization, randomUUID()), signal, onStatus);
30
- return modelIds(body);
31
- }, signal, onStatus);
31
+ const catalog = await loadCatalog(signal, onStatus);
32
+ effortByModel = catalog.efforts;
33
+ return catalog.ids;
34
+ },
35
+ async efforts(model, signal, onStatus) {
36
+ const cached = effortByModel.get(model);
37
+ if (cached !== undefined)
38
+ return cached;
39
+ const catalog = await loadCatalog(signal, onStatus);
40
+ effortByModel = catalog.efforts;
41
+ return effortByModel.get(model) ?? fallbackEfforts(model);
32
42
  },
33
43
  async send(req) {
44
+ const efforts = effortByModel.get(req.model) ?? fallbackEfforts(req.model);
45
+ const effort = requireSupportedEffort(req.model, req.effort, efforts);
34
46
  return withAuthorization(async (authorization) => {
35
47
  const events = await postSse(`${BASE}/responses`, {
36
48
  ...headers(authorization, randomUUID()),
@@ -44,7 +56,7 @@ export const openaiCodex = {
44
56
  tools: req.tools.map(toWireTool),
45
57
  tool_choice: "auto",
46
58
  parallel_tool_calls: true,
47
- reasoning: { effort: req.effort, summary: "auto" },
59
+ reasoning: { effort, summary: "auto" },
48
60
  text: { verbosity: "low" },
49
61
  include: ["reasoning.encrypted_content"],
50
62
  prompt_cache_key: SESSION_ID,
@@ -57,6 +69,12 @@ export const openaiCodex = {
57
69
  }, req.signal, req.onStatus);
58
70
  },
59
71
  };
72
+ async function loadCatalog(signal, onStatus) {
73
+ return withAuthorization(async (authorization) => {
74
+ const body = await getJson(`${BASE}/models?client_version=${CATALOG_COMPATIBILITY_VERSION}`, headers(authorization, randomUUID()), signal, onStatus);
75
+ return modelCatalog(body);
76
+ }, signal, onStatus);
77
+ }
60
78
  async function withAuthorization(operation, signal, onStatus) {
61
79
  let authorization = await openAIAuthorization(undefined, signal, onStatus);
62
80
  try {
@@ -80,12 +98,12 @@ function headers(authorization, requestId) {
80
98
  "x-client-request-id": requestId,
81
99
  };
82
100
  }
83
- function modelIds(value) {
101
+ function modelCatalog(value) {
84
102
  const source = record(value) && Array.isArray(value["models"]) ? value["models"] : undefined;
85
103
  if (source === undefined)
86
104
  throw new Error("OpenAI Codex did not return a model list");
87
105
  const seen = new Set();
88
- return source
106
+ const models = source
89
107
  .slice(0, MAX_CATALOG_ITEMS)
90
108
  .flatMap((entry) => {
91
109
  if (!record(entry))
@@ -98,11 +116,36 @@ function modelIds(value) {
98
116
  seen.has(id))
99
117
  return [];
100
118
  seen.add(id);
101
- return [{ id, priority: typeof entry["priority"] === "number" ? entry["priority"] : 0 }];
119
+ return [{
120
+ id,
121
+ priority: typeof entry["priority"] === "number" ? entry["priority"] : 0,
122
+ efforts: reasoningLevels(entry, id),
123
+ }];
102
124
  })
103
125
  .sort((left, right) => left.priority - right.priority)
104
- .slice(0, MAX_MODELS)
105
- .map((entry) => entry.id);
126
+ .slice(0, MAX_MODELS);
127
+ return {
128
+ ids: models.map((entry) => entry.id),
129
+ efforts: new Map(models.map((entry) => [entry.id, entry.efforts])),
130
+ };
131
+ }
132
+ function reasoningLevels(entry, model) {
133
+ const source = entry["supported_reasoning_levels"];
134
+ if (!Array.isArray(source))
135
+ return fallbackEfforts(model);
136
+ const seen = new Set();
137
+ const efforts = source.flatMap((level) => {
138
+ if (!record(level) || !isEffort(level["effort"]) || seen.has(level["effort"]))
139
+ return [];
140
+ seen.add(level["effort"]);
141
+ return [level["effort"]];
142
+ });
143
+ return efforts.length === 0 ? fallbackEfforts(model) : efforts;
144
+ }
145
+ function fallbackEfforts(model) {
146
+ if (/^gpt-5\.6-(?:sol|terra|luna)(?:-|$)/.test(model))
147
+ return EFFORTS;
148
+ return XHIGH_EFFORTS;
106
149
  }
107
150
  function statusOf(error) {
108
151
  return typeof error === "object" && error !== null && "status" in error
@@ -42,7 +42,7 @@ export async function assembleOpenAI(events, onStream) {
42
42
  break;
43
43
  }
44
44
  }
45
- return { output: items };
45
+ throw new Error("openai stream ended before a terminal response event");
46
46
  }
47
47
  function reconcileOutput(completed, items) {
48
48
  if (completed === undefined)
@@ -1,11 +1,6 @@
1
1
  // Translation between the normalized vocabulary and the OpenAI Responses wire
2
2
  // shape: a flat `input` list where tool calls and their outputs are top-level
3
3
  // items keyed by `call_id`, rather than blocks nested inside a message.
4
- // The Responses API has no `xhigh`; collapse it onto the nearest level rather
5
- // than passing through a value it will reject.
6
- export function normalizeEffort(effort) {
7
- return effort === "xhigh" || effort === "max" ? "high" : effort;
8
- }
9
4
  export function toWireTool(tool) {
10
5
  return {
11
6
  type: "function",
@@ -5,21 +5,38 @@
5
5
  import { postSse } from "./http.js";
6
6
  import { listModels } from "./catalog.js";
7
7
  import { keyFor } from "../credentials.js";
8
+ import { EFFORTS, requireSupportedEffort } from "../effort.js";
8
9
  import { assembleOpenAI } from "./openai-stream.js";
9
- import { fromWireResponse, normalizeEffort, stopNotice, toWireItems, toWireTool, } from "./openai-wire.js";
10
+ import { fromWireResponse, stopNotice, toWireItems, toWireTool, } from "./openai-wire.js";
10
11
  const ENDPOINT = "https://api.openai.com/v1/responses";
11
12
  const MODELS = "https://api.openai.com/v1/models";
12
13
  const KEY = "OPENAI_API_KEY";
13
- /**
14
- * What this account can reach that is not a chat model.
15
- *
16
- * The list is an exclusion rather than an allow-list on purpose: an unknown
17
- * `gpt-`something is far more likely to be a model worth offering than one
18
- * worth hiding, and an allow-list would quietly bury every family shipped
19
- * after this line was written.
20
- */
21
- const NOT_CHAT = /^(text-|tts-|whisper|dall-e|sora|gpt-image|omni-moderation|davinci|babbage)/;
22
- const NON_TEXT_MODE = /(?:^|[-_])(audio|realtime|transcribe|tts)(?:[-_]|$)/;
14
+ const RESPONSES_REASONING_MODEL = /^(?:gpt-5(?:[.-]|$)|o(?:1|3|4)(?:[.-]|$)|codex-mini(?:[.-]|$))/;
15
+ // Jecode's transport always streams and always declares local tools. Hide
16
+ // catalog entries that cannot satisfy either half of that contract.
17
+ const INCOMPATIBLE_MODEL = /^(?:gpt-5(?:\.[1-3])?-chat-latest|gpt-5\.5-pro|o1-mini|o(?:1|3)-pro|o3-deep-research|o4-mini-deep-research)(?:-|$)/;
18
+ const STANDARD_EFFORTS = ["low", "medium", "high"];
19
+ const XHIGH_EFFORTS = ["low", "medium", "high", "xhigh"];
20
+ const PRO_EFFORTS = ["medium", "high", "xhigh"];
21
+ const HIGH_ONLY_EFFORT = ["high"];
22
+ export function supportsOpenAIModel(model) {
23
+ return RESPONSES_REASONING_MODEL.test(model) && !INCOMPATIBLE_MODEL.test(model);
24
+ }
25
+ export function openAIEfforts(model) {
26
+ if (!supportsOpenAIModel(model))
27
+ return [];
28
+ if (/^gpt-5-pro(?:-|$)/.test(model))
29
+ return HIGH_ONLY_EFFORT;
30
+ if (/^gpt-5\.[2-5]-pro(?:-|$)/.test(model))
31
+ return PRO_EFFORTS;
32
+ if (/^gpt-5\.6(?:[.-]|$)/.test(model))
33
+ return EFFORTS;
34
+ if (/^gpt-5\.[2-5](?:[.-]|$)/.test(model))
35
+ return XHIGH_EFFORTS;
36
+ if (/^(?:o(?:1|3|4)|codex-mini)(?:[.-]|$)/.test(model))
37
+ return STANDARD_EFFORTS;
38
+ return STANDARD_EFFORTS;
39
+ }
23
40
  export const openai = {
24
41
  id: "openai",
25
42
  defaultModel: "gpt-5",
@@ -32,19 +49,23 @@ export const openai = {
32
49
  async models(signal, onStatus) {
33
50
  const ids = await listModels(MODELS, headers(requireKey()), signal, onStatus);
34
51
  return ids
35
- .filter((id) => !NOT_CHAT.test(id) && !NON_TEXT_MODE.test(id))
52
+ .filter(supportsOpenAIModel)
36
53
  .sort((a, b) => b.localeCompare(a));
37
54
  },
55
+ async efforts(model) {
56
+ return openAIEfforts(model);
57
+ },
38
58
  location: () => "cloud",
39
59
  async send(req) {
40
60
  const key = requireKey();
61
+ const effort = requireSupportedEffort(req.model, req.effort, openAIEfforts(req.model));
41
62
  const events = await postSse(ENDPOINT, headers(key), {
42
63
  model: req.model,
43
64
  instructions: req.system,
44
65
  input: req.messages.flatMap((message) => toWireItems(message)),
45
66
  tools: req.tools.map(toWireTool),
46
67
  max_output_tokens: req.maxTokens,
47
- reasoning: { effort: normalizeEffort(req.effort), summary: "auto" },
68
+ reasoning: { effort, summary: "auto" },
48
69
  store: false,
49
70
  include: ["reasoning.encrypted_content"],
50
71
  stream: true,
@@ -3,6 +3,7 @@
3
3
  import { modelsCommand, providersCommand } from "./provider-commands.js";
4
4
  import { credentialsCommand } from "./credential-commands.js";
5
5
  import { EFFORTS, readSettings, settingsLabel, updateSettings } from "./settings.js";
6
+ import { providerFailure } from "./provider-errors.js";
6
7
  import { ollamaConnectionHint, ollamaConnectionSetting, } from "./ollama-settings-command.js";
7
8
  import { of } from "./tui/editor.js";
8
9
  import { heading } from "./tui/picker.js";
@@ -109,6 +110,7 @@ async function providerSetting(session, host) {
109
110
  model: session.model,
110
111
  providerId: session.config.providerId,
111
112
  configModel: session.config.model,
113
+ effort: session.config.effort,
112
114
  };
113
115
  if (!(await providersCommand(session, host, { announce: false, save: false })))
114
116
  return;
@@ -116,40 +118,85 @@ async function providerSetting(session, host) {
116
118
  const models = { ...current.models };
117
119
  if (session.model !== "")
118
120
  models[session.provider.id] = session.model;
119
- if (await persist(host, { provider: session.provider.id, models }))
121
+ const patch = {
122
+ provider: session.provider.id,
123
+ models,
124
+ ...(session.config.effort === before.effort ? {} : { effort: session.config.effort }),
125
+ };
126
+ if (await persist(host, patch))
120
127
  return;
121
128
  session.provider = before.provider;
122
129
  session.model = before.model;
123
130
  session.config.providerId = before.providerId;
124
131
  session.config.model = before.configModel;
132
+ session.config.effort = before.effort;
125
133
  }
126
134
  async function modelSetting(session, host) {
127
- const before = { model: session.model, configModel: session.config.model };
135
+ const before = {
136
+ model: session.model,
137
+ configModel: session.config.model,
138
+ effort: session.config.effort,
139
+ };
128
140
  if (!(await modelsCommand(session, host, { announce: false, save: false })))
129
141
  return;
130
142
  const current = readSettings();
131
143
  const models = { ...current.models, [session.provider.id]: session.model };
132
- if (await persist(host, { models }))
144
+ const patch = {
145
+ models,
146
+ ...(session.config.effort === before.effort ? {} : { effort: session.config.effort }),
147
+ };
148
+ if (await persist(host, patch))
133
149
  return;
134
150
  session.model = before.model;
135
151
  session.config.model = before.configModel;
152
+ session.config.effort = before.effort;
136
153
  }
137
154
  async function effortSetting(session, host) {
138
155
  const choose = chooser(host);
139
156
  if (choose === undefined)
140
157
  return;
158
+ const efforts = await availableEfforts(session, host);
159
+ if (efforts === undefined)
160
+ return;
161
+ if (efforts.length === 0) {
162
+ host.emit({
163
+ kind: "notice",
164
+ text: `${session.model || session.provider.id} controls its own reasoning depth`,
165
+ tone: "info",
166
+ });
167
+ return;
168
+ }
141
169
  const current = session.config.effort;
142
170
  const index = await choose({
143
171
  title: heading("effort", "saved default", session.palette),
144
- options: EFFORTS.map((value) => ({ label: value })),
145
- index: Math.max(0, EFFORTS.findIndex((value) => value === current)),
172
+ options: efforts.map((value) => ({ label: value })),
173
+ index: Math.max(0, efforts.findIndex((value) => value === current)),
146
174
  });
147
- const value = index === undefined ? undefined : EFFORTS[index];
175
+ const value = index === undefined ? undefined : efforts[index];
148
176
  if (value === undefined || !(await persist(host, { effort: value })))
149
177
  return;
150
178
  session.config.effort = value;
151
179
  return value;
152
180
  }
181
+ async function availableEfforts(session, host) {
182
+ if (session.provider.efforts === undefined)
183
+ return EFFORTS;
184
+ host.status?.(`Asking ${session.provider.id}`);
185
+ try {
186
+ return await session.provider.efforts(session.model, host.signal, (status) => host.status?.(status));
187
+ }
188
+ catch (error) {
189
+ host.emit({
190
+ kind: "notice",
191
+ text: providerFailure(session.provider, error, true),
192
+ tone: "error",
193
+ });
194
+ return undefined;
195
+ }
196
+ finally {
197
+ host.status?.(undefined);
198
+ }
199
+ }
153
200
  async function motionSetting(session, host) {
154
201
  const choose = chooser(host);
155
202
  if (choose === undefined)
package/dist/settings.js CHANGED
@@ -3,10 +3,11 @@ import { readFileSync } from "node:fs";
3
3
  import { chmod, mkdir } from "node:fs/promises";
4
4
  import * as path from "node:path";
5
5
  import { atomicWrite } from "./atomic.js";
6
+ import { EFFORTS } from "./effort.js";
6
7
  import { providerNames } from "./providers/index.js";
7
8
  import { parseOllamaEndpoint } from "./providers/ollama-endpoint.js";
8
9
  import { userDataLabel, userDataPath } from "./user-data.js";
9
- export const EFFORTS = ["low", "medium", "high", "xhigh", "max"];
10
+ export { EFFORTS } from "./effort.js";
10
11
  let saved;
11
12
  export function readSettings() {
12
13
  if (saved === undefined)
@@ -3,10 +3,11 @@
3
3
  //
4
4
  // Every throw here becomes an is_error tool result the model can read and
5
5
  // correct on the next step, so the messages are written for that reader.
6
- export function requireString(args, name) {
6
+ export function requireString(args, name, allowEmpty = false) {
7
7
  const value = args[name];
8
- if (typeof value !== "string" || value === "") {
9
- throw new Error(`"${name}" is required and must be a non-empty string`);
8
+ if (typeof value !== "string" || (!allowEmpty && value === "")) {
9
+ const kind = allowEmpty ? "a string" : "a non-empty string";
10
+ throw new Error(`"${name}" is required and must be ${kind}`);
10
11
  }
11
12
  return value;
12
13
  }
package/dist/tools/fs.js CHANGED
@@ -101,7 +101,7 @@ export const writeFile = {
101
101
  async preview(args, ctx) {
102
102
  const root = await resolveExistingInRoot(ctx.root, ".");
103
103
  const target = await resolveDirectWritableInRoot(root, requireString(args, "path"));
104
- const content = requireString(args, "content");
104
+ const content = requireString(args, "content", true);
105
105
  assertEditableText(content);
106
106
  // A write against a file that is already there is a replacement, and the
107
107
  // user is owed the difference rather than a wall of green.
@@ -110,7 +110,7 @@ export const writeFile = {
110
110
  async run(args, ctx) {
111
111
  const root = await resolveExistingInRoot(ctx.root, ".");
112
112
  const target = await resolveDirectWritableInRoot(root, requireString(args, "path"));
113
- const content = requireString(args, "content");
113
+ const content = requireString(args, "content", true);
114
114
  assertEditableText(content);
115
115
  await fs.mkdir(path.dirname(target), { recursive: true });
116
116
  const validate = () => assertDirectWritableInRoot(root, target);
@@ -248,7 +248,10 @@ function applied(before, args) {
248
248
  }
249
249
  const made = replaceAll ? occurrences : 1;
250
250
  assertReplacementFits(before, oldText, newText, made);
251
- const after = replaceAll ? before.replaceAll(oldText, newText) : before.replace(oldText, newText);
251
+ const replacement = () => newText;
252
+ const after = replaceAll
253
+ ? before.replaceAll(oldText, replacement)
254
+ : before.replace(oldText, replacement);
252
255
  assertEditableText(after, "edited content");
253
256
  return { after, made };
254
257
  }
@@ -270,6 +273,8 @@ async function unchangedSinceApproval(target, approved) {
270
273
  }
271
274
  }
272
275
  function count(text, noun) {
276
+ if (text === "")
277
+ return "empty";
273
278
  return plural(text.split("\n").length, noun, `${noun}s`);
274
279
  }
275
280
  function plural(n, one, many) {
@@ -20,8 +20,8 @@ export function toolSpecs(tools) {
20
20
  // and gets another turn to fix its call. Only an aborted turn propagates.
21
21
  export async function runTool(tool, call, ctx) {
22
22
  try {
23
- const { output, summary } = await tool.run(call.input, ctx);
24
- return { result: { kind: "tool_result", id: call.id, output, isError: false }, summary };
23
+ const { output, summary, isError = false } = await tool.run(call.input, ctx);
24
+ return { result: { kind: "tool_result", id: call.id, output, isError }, summary };
25
25
  }
26
26
  catch (error) {
27
27
  if (ctx.signal?.aborted === true)
@@ -32,6 +32,7 @@ export const runCommand = {
32
32
  return {
33
33
  output: output === "" ? `[${summary}]` : `${output}\n[${summary}]`,
34
34
  summary,
35
+ isError: result.timedOut || result.code !== 0,
35
36
  };
36
37
  },
37
38
  };
@@ -45,6 +46,7 @@ function execute(command, cwd, timeoutMs, signal, onOutput) {
45
46
  shell: true,
46
47
  windowsHide: true,
47
48
  detached: process.platform !== "win32",
49
+ stdio: ["ignore", "pipe", "pipe"],
48
50
  });
49
51
  const output = capture(onOutput);
50
52
  let timedOut = false;
@@ -2,6 +2,8 @@ import { blank, row } from "../../ui/render.js";
2
2
  import { markdown } from "../../ui/markdown.js";
3
3
  const PAD = 1;
4
4
  export const REASONING_PREVIEW_ROWS = 3;
5
+ const MIN_REASONING_PREVIEW_CHARS = 4_096;
6
+ const REASONING_PREVIEW_OVERSCAN = 12;
5
7
  export function renderUser(block, width, pal) {
6
8
  const inner = Math.max(8, width - PAD * 2);
7
9
  const content = markdown(block.text, inner, pal, inner);
@@ -21,14 +23,21 @@ export function renderAnswer(block, width, pal) {
21
23
  }
22
24
  export function renderReasoning(block, width, pal) {
23
25
  const inner = Math.max(8, width - PAD * 2);
24
- const content = markdown(block.text, inner, pal, inner);
25
- const expanded = block.expanded === true;
26
+ // Expanding a live stream is deferred until it is sealed. Re-parsing an
27
+ // ever-growing full thought on every token makes the whole TUI stall.
28
+ const expanded = block.expanded === true && block.live !== true;
29
+ const source = !expanded
30
+ ? reasoningPreviewSource(block.text, inner)
31
+ : { text: block.text, truncated: false };
32
+ const content = markdown(source.text, inner, pal, inner);
26
33
  const visible = expanded ? content : content.slice(-REASONING_PREVIEW_ROWS);
27
34
  const action = expanded
28
35
  ? "ctrl+o compact"
29
- : content.length > REASONING_PREVIEW_ROWS
30
- ? "ctrl+o full"
31
- : undefined;
36
+ : block.live === true && block.expanded === true
37
+ ? "full when done"
38
+ : source.truncated || content.length > REASONING_PREVIEW_ROWS
39
+ ? "ctrl+o full"
40
+ : undefined;
32
41
  return [
33
42
  "",
34
43
  row(width, [
@@ -41,3 +50,15 @@ export function renderReasoning(block, width, pal) {
41
50
  ...visible.map((line) => row(width, line.segs.map((seg) => ({ ...seg, fg: pal.ink.muted, italic: true })), [], undefined, PAD)),
42
51
  ];
43
52
  }
53
+ export function reasoningPreviewSource(text, width) {
54
+ const limit = Math.max(MIN_REASONING_PREVIEW_CHARS, width * REASONING_PREVIEW_ROWS * REASONING_PREVIEW_OVERSCAN);
55
+ if (text.length <= limit)
56
+ return { text, truncated: false };
57
+ // A compact view only needs its visible tail. The complete text remains on
58
+ // the block for expansion after the reasoning stream is sealed.
59
+ let start = text.length - limit;
60
+ const code = text.charCodeAt(start);
61
+ if (code >= 0xdc00 && code <= 0xdfff)
62
+ start--;
63
+ return { text: text.slice(start), truncated: true };
64
+ }
package/dist/tui/turn.js CHANGED
@@ -110,7 +110,10 @@ export function transcribe(stage) {
110
110
  const block = tools.get(call.id);
111
111
  if (block === undefined || block.kind !== "tool")
112
112
  return;
113
- if (block.tone === "deny") {
113
+ // Explicit refusal stays a refusal. Cancellation can first settle an
114
+ // approval overlay as `no`, though, so its synthesized result must be
115
+ // allowed to reconcile that rail with the interrupted history.
116
+ if (block.tone === "deny" && summary !== "interrupted") {
114
117
  stage.render(block);
115
118
  return;
116
119
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@giovannijecha/jecode",
3
- "version": "0.2.0",
3
+ "version": "0.2.1",
4
4
  "description": "An owned coding agent with zero external runtime dependencies.",
5
5
  "license": "MIT",
6
6
  "repository": {