@giovannijecha/jecode 0.8.5 → 0.8.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (64) hide show
  1. package/README.md +22 -5
  2. package/dist/batch-view.js +27 -2
  3. package/dist/batch.js +2 -0
  4. package/dist/cli-info.js +0 -1
  5. package/dist/config.js +14 -6
  6. package/dist/credential-commands.js +3 -3
  7. package/dist/openai-account-command.js +14 -12
  8. package/dist/openai-account.js +7 -5
  9. package/dist/openai-oauth-callback.js +13 -4
  10. package/dist/openai-oauth-tokens.js +9 -7
  11. package/dist/openai-oauth.js +8 -6
  12. package/dist/permission-command.js +1 -1
  13. package/dist/provider-commands.js +1 -33
  14. package/dist/provider-errors.js +1 -10
  15. package/dist/provider-label.js +3 -10
  16. package/dist/providers/anthropic.js +0 -1
  17. package/dist/providers/index.js +1 -5
  18. package/dist/providers/ollama-context.js +42 -0
  19. package/dist/providers/ollama-endpoint.js +7 -34
  20. package/dist/providers/ollama.js +13 -147
  21. package/dist/providers/openai-codex.js +4 -4
  22. package/dist/providers/openai.js +0 -1
  23. package/dist/sessions/bucket.js +55 -0
  24. package/dist/sessions/catalog-io.js +162 -0
  25. package/dist/sessions/catalog.js +3 -1
  26. package/dist/sessions/codec-messages.js +122 -0
  27. package/dist/sessions/codec-transcript.js +93 -0
  28. package/dist/sessions/codec-values.js +52 -0
  29. package/dist/sessions/codec.js +4 -257
  30. package/dist/sessions/files.js +158 -0
  31. package/dist/sessions/load.js +90 -0
  32. package/dist/sessions/snapshot.js +33 -0
  33. package/dist/sessions/store.js +42 -461
  34. package/dist/settings-command.js +10 -5
  35. package/dist/settings.js +16 -15
  36. package/dist/start.js +1 -2
  37. package/dist/tools/file-read.js +192 -0
  38. package/dist/tools/file-summary.js +9 -0
  39. package/dist/tools/{fs.js → file-write.js} +5 -193
  40. package/dist/tools/glob.js +107 -0
  41. package/dist/tools/index.js +2 -1
  42. package/dist/tools/search.js +1 -105
  43. package/dist/tui/app-workflows.js +8 -361
  44. package/dist/tui/approve.js +5 -3
  45. package/dist/tui/blocks.js +8 -7
  46. package/dist/tui/command-workflow.js +106 -0
  47. package/dist/tui/components/command-menu.js +7 -10
  48. package/dist/tui/components/menu.js +74 -43
  49. package/dist/tui/components/messages.js +16 -9
  50. package/dist/tui/components/tool-evidence.js +107 -0
  51. package/dist/tui/components/tool-motion.js +32 -0
  52. package/dist/tui/components/tool.js +48 -202
  53. package/dist/tui/help.js +1 -1
  54. package/dist/tui/picker-layout.js +40 -0
  55. package/dist/tui/picker.js +7 -71
  56. package/dist/tui/tool-details.js +135 -0
  57. package/dist/tui/transcript-grammar.js +8 -1
  58. package/dist/tui/transcript-view.js +26 -112
  59. package/dist/tui/turn-workflow.js +264 -0
  60. package/dist/tui/turn.js +7 -140
  61. package/dist/tui/workflow-types.js +2 -0
  62. package/package.json +12 -12
  63. package/dist/ollama-settings-command.js +0 -74
  64. package/dist/tui/motion.js +0 -32
package/README.md CHANGED
@@ -26,9 +26,9 @@
26
26
  <a href="https://github.com/giovannijecha/jecode/releases">Releases</a>
27
27
  </p>
28
28
 
29
- > Jecode is currently pre-1.0. The core loop is usable today, but commands and
30
- > terminal interactions may still evolve before 1.0. The release-candidate
31
- > surface is now frozen in the [compatibility contract](https://github.com/giovannijecha/jecode/blob/main/docs/COMPATIBILITY.md).
29
+ > Jecode is stabilizing for 1.0. New capabilities are deferred while correctness,
30
+ > recovery, performance, accessibility, and interface refinements continue within
31
+ > the [compatibility contract](https://github.com/giovannijecha/jecode/blob/main/docs/COMPATIBILITY.md).
32
32
 
33
33
  ## Why Jecode
34
34
 
@@ -37,8 +37,16 @@ follow tool calls and diffs, steer the model while it runs, and resume the same
37
37
  conversation later. One controller carries each turn from prompt to result—no
38
38
  delegated agents or hidden model workers.
39
39
 
40
- Choose Anthropic API, OpenAI API, an eligible ChatGPT account, or Ollama without
41
- changing the workflow. API connections and account access stay separate, and
40
+ Tool records keep the target, outcome, and compact evidence together. Expand a
41
+ record to inspect its complete output or diff while the composer stays ready
42
+ at the full width of the terminal.
43
+
44
+ Menus keep the selected choice and its details together. Approvals separate
45
+ the target from the decision and make the scope of remembered access explicit.
46
+
47
+ Choose Anthropic API, OpenAI API, OpenAI Account with eligible ChatGPT access,
48
+ or Ollama API without changing the workflow. Ollama uses its cloud API only.
49
+ API connections and account access stay separate, and
42
50
  the footer always names the route selected for the next request. Jecode is
43
51
  written in TypeScript and released as plain JavaScript for Node.js, with no
44
52
  installation scripts and zero third-party runtime dependencies. The runtime
@@ -166,6 +174,15 @@ The [user guide](https://github.com/giovannijecha/jecode/blob/main/docs/USAGE.md
166
174
  covers provider access, TUI controls, sessions and compaction, batch mode,
167
175
  configuration, and safety boundaries.
168
176
 
177
+ The [architecture guide](https://github.com/giovannijecha/jecode/blob/main/docs/ARCHITECTURE.md)
178
+ explains the current implementation and module boundaries. Maintainers use the
179
+ [release guide](https://github.com/giovannijecha/jecode/blob/main/docs/RELEASING.md)
180
+ for publishing and release recovery.
181
+
182
+ The [development environments](https://github.com/giovannijecha/jecode/blob/main/dev/README.md)
183
+ provide the TUI lab, current interface direction, and manual performance probes
184
+ for working from a source checkout.
185
+
169
186
  ## Community
170
187
 
171
188
  - Ask questions, share workflows, and explore early ideas in
@@ -1,9 +1,14 @@
1
1
  // Project semantic blocks into a compact transcript for pipes.
2
- import { render } from "./tui/blocks.js";
2
+ import { renderAnswer, renderReasoning, renderUser } from "./tui/components/messages.js";
3
+ import { renderNotice } from "./tui/components/misc.js";
4
+ import { renderDetail } from "./tui/components/tool-evidence.js";
5
+ import { stateInk, stateMark } from "./tui/components/tool-motion.js";
6
+ import { toolDuration } from "./duration.js";
7
+ import { row } from "./ui/render.js";
3
8
  export function renderBatch(block, width, pal) {
4
9
  const rows = [];
5
10
  let lastWasBlank = false;
6
- for (const rendered of render(block, width, pal)) {
11
+ for (const rendered of batchRows(block, width, pal)) {
7
12
  const line = rendered.trimEnd();
8
13
  const blank = line === "";
9
14
  if (blank && lastWasBlank)
@@ -15,3 +20,23 @@ export function renderBatch(block, width, pal) {
15
20
  rows.pop();
16
21
  return rows;
17
22
  }
23
+ function batchRows(block, width, pal) {
24
+ switch (block.kind) {
25
+ case "answer": return renderAnswer(block, width, pal);
26
+ case "reasoning": return renderReasoning(block, width, pal);
27
+ case "user": return renderUser(block, width, pal);
28
+ case "notice": return renderNotice(block, width, pal);
29
+ case "tool": {
30
+ // Pipes cannot expand records or repaint a running connector.
31
+ const mark = stateMark(block);
32
+ const result = [block.right, block.durationMs === undefined ? "" : toolDuration(block.durationMs)]
33
+ .filter(Boolean).join(" · ");
34
+ return ["", row(width, [
35
+ { text: `${mark} `, fg: stateInk(block, pal) },
36
+ { text: block.name, fg: pal.ink.bright },
37
+ { text: block.target === "" ? "" : ` ${block.target}`, fg: pal.technical },
38
+ ], [{ text: result, fg: pal.ink.muted }]),
39
+ ...(block.body ?? []).map((detail) => renderDetail(detail, block.tone, width, pal))];
40
+ }
41
+ }
42
+ }
package/dist/batch.js CHANGED
@@ -68,6 +68,7 @@ export async function runBatch(session, environment = {}) {
68
68
  provider: session.provider,
69
69
  model: session.model,
70
70
  compactionPercent: session.config.compactionPercent,
71
+ signal,
71
72
  });
72
73
  };
73
74
  const user = { role: "user", content: [{ kind: "text", text: line }] };
@@ -106,6 +107,7 @@ export async function runBatch(session, environment = {}) {
106
107
  provider: session.provider,
107
108
  model: session.model,
108
109
  effort: session.config.effort,
110
+ signal,
109
111
  context: projected,
110
112
  turn: checkpoint.slice(before),
111
113
  nodeId: nodeId ?? prospectiveNodeId,
package/dist/cli-info.js CHANGED
@@ -12,7 +12,6 @@ Options:
12
12
  --root <path> workspace root (default: current directory)
13
13
  --provider <id> anthropic, openai, openai-codex, or ollama
14
14
  --model <id> model for the selected provider
15
- --ollama-host <url> Ollama Cloud, local, or custom endpoint
16
15
  --effort <level> low, medium, high, xhigh, or max
17
16
  --max-tokens <number> output-token ceiling
18
17
  --max-steps <number> optional per-turn model-request budget
package/dist/config.js CHANGED
@@ -2,11 +2,10 @@
2
2
  import * as path from "node:path";
3
3
  import { DEFAULT_COMPACTION_PERCENT, MAX_COMPACTION_PERCENT, MIN_COMPACTION_PERCENT, } from "./context/policy.js";
4
4
  import { EFFORTS, readSettings } from "./settings.js";
5
- import { parseOllamaEndpoint } from "./providers/ollama-endpoint.js";
5
+ import { isLegacyOllamaCloudHost } from "./providers/ollama-endpoint.js";
6
6
  const VALUE_FLAGS = [
7
7
  "provider",
8
8
  "model",
9
- "ollama-host",
10
9
  "effort",
11
10
  "max-tokens",
12
11
  "max-steps",
@@ -21,8 +20,8 @@ const BOOLEAN_FLAGS = [
21
20
  const FLAGS = [...VALUE_FLAGS, ...BOOLEAN_FLAGS];
22
21
  export function loadConfig(argv, saved = readSettings()) {
23
22
  const flags = parseFlags(argv);
23
+ assertRetiredOllamaSettings(saved);
24
24
  const providerId = pick(flags.provider, process.env.JECODE_PROVIDER, saved.provider ?? "anthropic");
25
- const ollamaHost = optional(flags["ollama-host"], process.env.OLLAMA_HOST, saved.ollamaHost);
26
25
  const effort = pick(flags.effort, process.env.JECODE_EFFORT, saved.effort ?? "high");
27
26
  if (!EFFORTS.includes(effort)) {
28
27
  throw new Error(`unknown effort "${effort}" (expected one of: ${EFFORTS.join(", ")})`);
@@ -31,9 +30,6 @@ export function loadConfig(argv, saved = readSettings()) {
31
30
  return {
32
31
  providerId,
33
32
  model: pick(flags.model, process.env.JECODE_MODEL, saved.models?.[providerId] ?? ""),
34
- ...(ollamaHost === undefined
35
- ? {}
36
- : { ollamaHost: parseOllamaEndpoint(ollamaHost).baseUrl }),
37
33
  reducedMotion: bool(flags["reduced-motion"], process.env.JECODE_REDUCED_MOTION, saved.reducedMotion ?? false),
38
34
  effort,
39
35
  // This is a ceiling, not a target. Request budgeting may lower it to fit
@@ -48,6 +44,15 @@ export function loadConfig(argv, saved = readSettings()) {
48
44
  ephemeral: bool(flags.ephemeral, process.env.JECODE_EPHEMERAL, false),
49
45
  };
50
46
  }
47
+ function assertRetiredOllamaSettings(saved) {
48
+ const host = process.env.OLLAMA_HOST;
49
+ if (host !== undefined && host !== "" && !isLegacyOllamaCloudHost(host)) {
50
+ throw new Error("OLLAMA_HOST no longer selects an endpoint; remove it to use Ollama API at https://ollama.com");
51
+ }
52
+ if (saved.ollamaHost !== undefined && !isLegacyOllamaCloudHost(saved.ollamaHost)) {
53
+ throw new Error("settings.json has a retired Ollama endpoint; remove ollamaHost to use Ollama API at https://ollama.com");
54
+ }
55
+ }
51
56
  function bool(flag, env, fallback) {
52
57
  if (flag !== undefined)
53
58
  return flag === "true" || flag === "1";
@@ -105,6 +110,9 @@ function parseFlags(argv) {
105
110
  const eq = body.indexOf("=");
106
111
  const name = eq === -1 ? body : body.slice(0, eq);
107
112
  const inline = eq === -1 ? undefined : body.slice(eq + 1);
113
+ if (name === "ollama-host") {
114
+ throw new Error("--ollama-host is no longer supported; remove it to use Ollama API at https://ollama.com");
115
+ }
108
116
  if (!FLAGS.includes(name)) {
109
117
  throw new Error(`unknown flag --${name} (known: ${FLAGS.map((flag) => `--${flag}`).join(", ")})`);
110
118
  }
@@ -64,13 +64,13 @@ export async function apiKeyCommand(name, label, session, host, providerId) {
64
64
  if (!hasSaved(name)) {
65
65
  host.emit({
66
66
  kind: "notice",
67
- text: `${label} API key comes from the environment · restart after changing it`,
67
+ text: `${label} key comes from the environment · restart after changing it`,
68
68
  tone: "info",
69
69
  });
70
70
  return;
71
71
  }
72
72
  const action = await choose({
73
- title: heading(`${label} API key`, `${name} · environment`, session.palette),
73
+ title: heading(`${label} key`, `${name} · environment`, session.palette),
74
74
  description: "The environment value is read-only. A saved copy is currently shadowed.",
75
75
  options: [{ label: "forget saved copy", hint: storeLabel(), key: "f" }],
76
76
  index: 0,
@@ -93,7 +93,7 @@ export async function apiKeyCommand(name, label, session, host, providerId) {
93
93
  : []),
94
94
  ];
95
95
  const index = await choose({
96
- title: heading(`${label} API key`, `${name} · ${source ?? "missing"}`, session.palette),
96
+ title: heading(`${label} key`, `${name} · ${source ?? "missing"}`, session.palette),
97
97
  options: actions,
98
98
  index: 0,
99
99
  });
@@ -1,9 +1,11 @@
1
- // Interactive ChatGPT account management for the OpenAI Codex provider.
1
+ // Interactive account management for the OpenAI Codex provider.
2
2
  import { openAICodexAccount } from "./accounts.js";
3
3
  import { headlessEnvironment, openExternal } from "./external-browser.js";
4
4
  import { openAIAccountHint, removeOpenAIAccount, saveOpenAIAccount, } from "./openai-account.js";
5
5
  import { beginBrowserLogin, beginDeviceLogin, } from "./openai-oauth.js";
6
+ import { providerLabel } from "./provider-label.js";
6
7
  import { heading } from "./tui/picker.js";
8
+ const ACCOUNT_LABEL = providerLabel("openai-codex");
7
9
  const DEFAULT_DEPENDENCIES = {
8
10
  beginBrowser: beginBrowserLogin,
9
11
  beginDevice: beginDeviceLogin,
@@ -16,12 +18,12 @@ export async function openAIAccountCommand(session, host, dependencies = DEFAULT
16
18
  const connected = openAICodexAccount() !== undefined;
17
19
  const actions = connected
18
20
  ? [
19
- { label: "reconnect ChatGPT", hint: "replace the current sign-in", key: "r" },
21
+ { label: `reconnect ${ACCOUNT_LABEL}`, hint: "replace the current sign-in", key: "r" },
20
22
  { label: "sign out", hint: "remove the saved account", key: "s" },
21
23
  ]
22
- : [{ label: "connect ChatGPT", hint: "sign in with OpenAI", key: "c" }];
24
+ : [{ label: `connect ${ACCOUNT_LABEL}`, hint: "sign in with OpenAI", key: "c" }];
23
25
  const action = await host.choose({
24
- title: heading("ChatGPT", openAIAccountHint(), session.palette),
26
+ title: heading(ACCOUNT_LABEL, openAIAccountHint(), session.palette),
25
27
  options: actions,
26
28
  index: 0,
27
29
  });
@@ -35,8 +37,8 @@ export async function openAIAccountCommand(session, host, dependencies = DEFAULT
35
37
  host.emit({
36
38
  kind: "notice",
37
39
  text: result.revokeFailed
38
- ? `ChatGPT disconnected · remote sign-out could not be confirmed${route}`
39
- : `ChatGPT disconnected${route}`,
40
+ ? `${ACCOUNT_LABEL} disconnected · remote sign-out could not be confirmed${route}`
41
+ : `${ACCOUNT_LABEL} disconnected${route}`,
40
42
  tone: result.revokeFailed ? "warn" : "info",
41
43
  });
42
44
  return result.removed;
@@ -56,14 +58,14 @@ async function connectOpenAIAccount(session, host, dependencies) {
56
58
  { label: "sign in with device code", hint: "WSL, SSH, or headless", key: "d" },
57
59
  ];
58
60
  const choice = await host.choose({
59
- title: heading("connect ChatGPT", "OpenAI OAuth", session.palette),
61
+ title: heading(`connect ${ACCOUNT_LABEL}`, "OpenAI OAuth", session.palette),
60
62
  description: "Choose the flow for this terminal. Your password stays on OpenAI's website.",
61
63
  options: methods,
62
64
  index: dependencies.headless() ? 1 : 0,
63
65
  });
64
66
  if (choice === undefined)
65
67
  return false;
66
- host.status?.("Starting ChatGPT sign-in");
68
+ host.status?.(`Starting ${ACCOUNT_LABEL} sign-in`);
67
69
  let login;
68
70
  try {
69
71
  login = choice === 1
@@ -97,8 +99,8 @@ async function waitForLogin(login, session, host, openUrl) {
97
99
  host.emit({
98
100
  kind: "notice",
99
101
  text: session.provider.id === "openai-codex"
100
- ? "ChatGPT connected · current provider route"
101
- : "ChatGPT connected · choose a ChatGPT model in /models to use this account",
102
+ ? `${ACCOUNT_LABEL} connected · current provider route`
103
+ : `${ACCOUNT_LABEL} connected · choose an ${ACCOUNT_LABEL} model in /models to use this account`,
102
104
  tone: "info",
103
105
  });
104
106
  return true;
@@ -111,7 +113,7 @@ async function waitForLogin(login, session, host, openUrl) {
111
113
  await openUrl(login.url);
112
114
  continue;
113
115
  }
114
- local.abort(new Error("ChatGPT sign-in cancelled"));
116
+ local.abort(new Error(`${ACCOUNT_LABEL} sign-in cancelled`));
115
117
  return false;
116
118
  }
117
119
  }
@@ -124,7 +126,7 @@ function waitingPicker(login, session) {
124
126
  ? "Finish sign-in in your browser. Jecode will continue automatically."
125
127
  : `Enter ${login.code} on OpenAI's device page. Jecode will continue automatically.`;
126
128
  return {
127
- title: heading("ChatGPT sign-in", login.code ?? "waiting for browser", session.palette),
129
+ title: heading(`${ACCOUNT_LABEL} sign-in`, login.code ?? "waiting for browser", session.palette),
128
130
  description: detail,
129
131
  options: [
130
132
  { label: "open browser again", key: "o" },
@@ -1,6 +1,8 @@
1
- // The live ChatGPT account: persistence, proactive refresh, and logout.
1
+ // The live OpenAI account: persistence, proactive refresh, and logout.
2
2
  import { openAICodexAccount, updateOpenAICodexAccount } from "./accounts.js";
3
3
  import { refreshOpenAITokens, revokeOpenAITokens } from "./openai-oauth.js";
4
+ import { providerLabel } from "./provider-label.js";
5
+ const ACCOUNT_LABEL = providerLabel("openai-codex");
4
6
  const REFRESH_EARLY_MS = 5 * 60_000;
5
7
  let refreshing;
6
8
  export function openAIAccountHint() {
@@ -33,7 +35,7 @@ export async function removeOpenAIAccount(signal) {
33
35
  export async function openAIAuthorization(rejectedToken, signal, onStatus) {
34
36
  const account = openAICodexAccount();
35
37
  if (account === undefined)
36
- throw new Error("ChatGPT account is not connected");
38
+ throw new Error(`${ACCOUNT_LABEL} is not connected`);
37
39
  const mustRefresh = rejectedToken !== undefined || expiresSoon(account);
38
40
  const ready = mustRefresh
39
41
  ? await refreshAccount(rejectedToken, signal, onStatus)
@@ -47,14 +49,14 @@ async function refreshAccount(rejectedToken, signal, onStatus) {
47
49
  throw abortReason(signal);
48
50
  const task = updateOpenAICodexAccount(async (current) => {
49
51
  if (current === undefined)
50
- throw new Error("ChatGPT account is not connected");
52
+ throw new Error(`${ACCOUNT_LABEL} is not connected`);
51
53
  if (!requiresAuthorityRefresh(current, rejectedToken))
52
54
  return current;
53
- onStatus?.("Refreshing ChatGPT sign-in");
55
+ onStatus?.(`Refreshing ${ACCOUNT_LABEL} sign-in`);
54
56
  return refreshOpenAITokens(current);
55
57
  }).then((account) => {
56
58
  if (account === undefined)
57
- throw new Error("ChatGPT account is not connected");
59
+ throw new Error(`${ACCOUNT_LABEL} is not connected`);
58
60
  return account;
59
61
  });
60
62
  refreshing = task;
@@ -3,6 +3,8 @@ import { timingSafeEqual } from "node:crypto";
3
3
  import { readFileSync } from "node:fs";
4
4
  import { createServer } from "node:http";
5
5
  import { leadingText } from "./text-boundary.js";
6
+ import { providerLabel } from "./provider-label.js";
7
+ const ACCOUNT_LABEL = providerLabel("openai-codex");
6
8
  const CALLBACK_PORTS = [1455, 1457];
7
9
  export const OPENAI_CALLBACK_PATH = "/auth/callback";
8
10
  export async function openAICallback(state) {
@@ -26,7 +28,14 @@ export async function openAICallback(state) {
26
28
  outgoing.writeHead(400).end("Invalid sign-in callback.");
27
29
  return;
28
30
  }
29
- const incoming = new URL(request.url, "http://localhost");
31
+ let incoming;
32
+ try {
33
+ incoming = new URL(request.url, "http://localhost");
34
+ }
35
+ catch {
36
+ outgoing.writeHead(400).end("Invalid sign-in callback.");
37
+ return;
38
+ }
30
39
  if (incoming.pathname !== OPENAI_CALLBACK_PATH) {
31
40
  outgoing.writeHead(404).end("Not found.");
32
41
  return;
@@ -43,10 +52,10 @@ export async function openAICallback(state) {
43
52
  const authError = incoming.searchParams.get("error_description") ?? incoming.searchParams.get("error");
44
53
  const authorizationCode = incoming.searchParams.get("code");
45
54
  if (authError !== null) {
46
- rejectCode(new Error(`ChatGPT sign-in was rejected · ${leadingText(authError, 300)}`));
55
+ rejectCode(new Error(`${ACCOUNT_LABEL} sign-in was rejected · ${leadingText(authError, 300)}`));
47
56
  }
48
57
  else if (authorizationCode === null || authorizationCode === "") {
49
- rejectCode(new Error("ChatGPT sign-in returned no authorization code"));
58
+ rejectCode(new Error(`${ACCOUNT_LABEL} sign-in returned no authorization code`));
50
59
  }
51
60
  else {
52
61
  resolveCode(authorizationCode);
@@ -94,7 +103,7 @@ async function firstAvailableServer(handler) {
94
103
  throw error;
95
104
  }
96
105
  }
97
- throw new Error("ChatGPT sign-in could not open callback ports 1455 or 1457");
106
+ throw new Error(`${ACCOUNT_LABEL} sign-in could not open callback ports 1455 or 1457`);
98
107
  }
99
108
  function listen(server, port) {
100
109
  return new Promise((resolve, reject) => {
@@ -1,15 +1,17 @@
1
1
  // Validate OAuth token responses and extract the ChatGPT account claims.
2
+ import { providerLabel } from "./provider-label.js";
3
+ const ACCOUNT_LABEL = providerLabel("openai-codex");
2
4
  const CLAIMS = "https://api.openai.com/auth";
3
5
  export function openAITokenReply(value, previousRefresh) {
4
6
  if (!record(value))
5
- throw new Error("OpenAI sign-in returned an invalid token response");
7
+ throw new Error(`${ACCOUNT_LABEL} sign-in returned an invalid token response`);
6
8
  const accessToken = required(value["access_token"], "access token");
7
9
  const refreshToken = optional(value["refresh_token"]) ?? previousRefresh;
8
10
  const expiresIn = value["expires_in"];
9
11
  if (refreshToken === undefined)
10
- throw new Error("OpenAI sign-in did not return a refresh token");
12
+ throw new Error(`${ACCOUNT_LABEL} sign-in did not return a refresh token`);
11
13
  if (typeof expiresIn !== "number" || !Number.isFinite(expiresIn) || expiresIn <= 0) {
12
- throw new Error("OpenAI sign-in did not return a valid token lifetime");
14
+ throw new Error(`${ACCOUNT_LABEL} sign-in did not return a valid token lifetime`);
13
15
  }
14
16
  const idToken = optional(value["id_token"]);
15
17
  return {
@@ -25,7 +27,7 @@ export function openAIAccountFromTokens(token) {
25
27
  const auth = record(access[CLAIMS]) ? access[CLAIMS] : {};
26
28
  const accountId = optional(auth["chatgpt_account_id"]) ?? optional(identity?.["chatgpt_account_id"]);
27
29
  if (accountId === undefined)
28
- throw new Error("OpenAI sign-in did not identify a ChatGPT account");
30
+ throw new Error(`${ACCOUNT_LABEL} sign-in did not identify an account`);
29
31
  const email = optional(identity?.["email"]);
30
32
  const plan = optional(auth["chatgpt_plan_type"]) ?? optional(identity?.["chatgpt_plan_type"]);
31
33
  return {
@@ -40,7 +42,7 @@ export function openAIAccountFromTokens(token) {
40
42
  function jwt(token) {
41
43
  const part = token.split(".")[1];
42
44
  if (part === undefined)
43
- throw new Error("OpenAI sign-in returned an unreadable token");
45
+ throw new Error(`${ACCOUNT_LABEL} sign-in returned an unreadable token`);
44
46
  try {
45
47
  const value = JSON.parse(Buffer.from(part, "base64url").toString("utf8"));
46
48
  if (!record(value))
@@ -48,13 +50,13 @@ function jwt(token) {
48
50
  return value;
49
51
  }
50
52
  catch {
51
- throw new Error("OpenAI sign-in returned an unreadable token");
53
+ throw new Error(`${ACCOUNT_LABEL} sign-in returned an unreadable token`);
52
54
  }
53
55
  }
54
56
  function required(value, label) {
55
57
  const found = optional(value);
56
58
  if (found === undefined)
57
- throw new Error(`OpenAI sign-in returned no ${label}`);
59
+ throw new Error(`${ACCOUNT_LABEL} sign-in returned no ${label}`);
58
60
  return found;
59
61
  }
60
62
  function optional(value) {
@@ -5,8 +5,10 @@
5
5
  // an account on disk.
6
6
  import { createHash, randomBytes } from "node:crypto";
7
7
  import { oauthRequest } from "./oauth-http.js";
8
+ import { providerLabel } from "./provider-label.js";
8
9
  import { OPENAI_CALLBACK_PATH, openAICallback } from "./openai-oauth-callback.js";
9
10
  import { openAIAccountFromTokens, openAITokenReply, } from "./openai-oauth-tokens.js";
11
+ const ACCOUNT_LABEL = providerLabel("openai-codex");
10
12
  const CLIENT_ID = "app_EMoamEEZ73f0CkXaXp7hrann";
11
13
  const AUTHORITY = "https://auth.openai.com";
12
14
  const AUTHORIZE = `${AUTHORITY}/oauth/authorize`;
@@ -91,7 +93,7 @@ export async function refreshOpenAITokens(account, signal) {
91
93
  const token = openAITokenReply(response.value, account.refreshToken);
92
94
  const refreshed = openAIAccountFromTokens(token);
93
95
  if (refreshed.accountId !== account.accountId) {
94
- throw new Error("OpenAI refreshed a different ChatGPT account · sign in again");
96
+ throw new Error(`${ACCOUNT_LABEL} refresh returned a different account · sign in again`);
95
97
  }
96
98
  return {
97
99
  ...refreshed,
@@ -125,7 +127,7 @@ async function exchange(code, signal) {
125
127
  async function pollDevice(deviceAuthId, userCode, interval, signal) {
126
128
  const deadline = new AbortController();
127
129
  const timer = setTimeout(() => {
128
- deadline.abort(new Error("ChatGPT device sign-in timed out after 15 minutes"));
130
+ deadline.abort(new Error(`${ACCOUNT_LABEL} device sign-in timed out after 15 minutes`));
129
131
  }, LOGIN_LIMIT_MS);
130
132
  const combined = signal === undefined
131
133
  ? deadline.signal
@@ -149,10 +151,10 @@ async function pollDevice(deviceAuthId, userCode, interval, signal) {
149
151
  }
150
152
  const errorCode = deviceErrorCode(response.value);
151
153
  if (errorCode === "access_denied") {
152
- throw new Error("ChatGPT device sign-in was denied");
154
+ throw new Error(`${ACCOUNT_LABEL} device sign-in was denied`);
153
155
  }
154
156
  if (errorCode === "expired_token") {
155
- throw new Error("ChatGPT device sign-in code expired");
157
+ throw new Error(`${ACCOUNT_LABEL} device sign-in code expired`);
156
158
  }
157
159
  const pending = errorCode === "authorization_pending" ||
158
160
  errorCode === "deviceauth_authorization_pending" ||
@@ -160,7 +162,7 @@ async function pollDevice(deviceAuthId, userCode, interval, signal) {
160
162
  const slowDown = errorCode === "slow_down" ||
161
163
  (response.status === 429 && errorCode === undefined);
162
164
  if (!pending && !slowDown) {
163
- throw new Error(`ChatGPT device sign-in failed (${response.status})`);
165
+ throw new Error(`${ACCOUNT_LABEL} device sign-in failed (${response.status})`);
164
166
  }
165
167
  if (slowDown)
166
168
  intervalMs += SLOW_DOWN_INCREMENT_MS;
@@ -192,7 +194,7 @@ function intervalSeconds(value) {
192
194
  function required(value, label) {
193
195
  const found = optional(value);
194
196
  if (found === undefined)
195
- throw new Error(`OpenAI sign-in returned no ${label}`);
197
+ throw new Error(`${ACCOUNT_LABEL} sign-in returned no ${label}`);
196
198
  return found;
197
199
  }
198
200
  function optional(value) {
@@ -42,7 +42,7 @@ export function permissionsPicker(tools, index = 0, adjust) {
42
42
  index: Math.min(Math.max(0, index), Math.max(0, tools.length - 1)),
43
43
  };
44
44
  }
45
- function permissionControlPicker(control, host, selected) {
45
+ export function permissionControlPicker(control, host, selected) {
46
46
  return permissionsPicker(control.listTools(), selected, (index, step) => {
47
47
  const tool = control.listTools()[index];
48
48
  if (tool === undefined)
@@ -1,16 +1,12 @@
1
1
  // Provider access and connection management. Runtime selection lives in /models.
2
- import { saveCommandSettings } from "./command-settings.js";
3
2
  import { apiKeyCommand } from "./credential-commands.js";
4
3
  import { credentialSource } from "./credentials.js";
5
- import { ollamaConnectionHint, ollamaConnectionSetting, } from "./ollama-settings-command.js";
6
4
  import { openAIAccountHint } from "./openai-account.js";
7
5
  import { openAIAccountCommand } from "./openai-account-command.js";
8
6
  import { providerLabel, providerRouteLabel } from "./provider-label.js";
9
7
  import { PROVIDERS } from "./providers/index.js";
10
- import { ollamaConnection } from "./providers/ollama.js";
11
8
  import { heading } from "./tui/picker.js";
12
- const OLLAMA_KEY = "OLLAMA_API_KEY";
13
- /** The single control plane for API keys, OAuth accounts, and Ollama endpoints. */
9
+ /** The single control plane for API keys and OAuth accounts. */
14
10
  export async function providersCommand(session, host) {
15
11
  const choose = chooser(host);
16
12
  if (choose === undefined)
@@ -81,45 +77,17 @@ function providerGroupHint(group, activeId) {
81
77
  return active === undefined ? count : `${count} · current ${providerRouteLabel(active)}`;
82
78
  }
83
79
  export function providerAccessHint(provider) {
84
- if (provider.id === "ollama") {
85
- const connection = ollamaConnection();
86
- if (connection.loopback)
87
- return `${ollamaConnectionHint()} · no key needed`;
88
- return `${ollamaConnectionHint()} · API key ${credentialSource(OLLAMA_KEY) ?? "missing"}`;
89
- }
90
80
  if (provider.auth.kind === "oauth")
91
81
  return openAIAccountHint();
92
82
  return `API key · ${credentialSource(provider.auth.keyVar) ?? "missing"}`;
93
83
  }
94
84
  async function manageProvider(provider, session, host) {
95
- if (provider.id === "ollama") {
96
- await ollamaProviderCommand(session, host);
97
- return;
98
- }
99
85
  if (provider.auth.kind === "oauth") {
100
86
  await openAIAccountCommand(session, host);
101
87
  return;
102
88
  }
103
89
  await apiKeyCommand(provider.auth.keyVar, providerLabel(provider.id), session, host, provider.id);
104
90
  }
105
- async function ollamaProviderCommand(session, host) {
106
- if (host.choose === undefined)
107
- return;
108
- const index = await host.choose({
109
- title: heading("Ollama", "access and connection", session.palette),
110
- options: [
111
- { label: "connection", hint: ollamaConnectionHint() },
112
- { label: "API key", hint: credentialSource(OLLAMA_KEY) ?? "missing" },
113
- ],
114
- index: 0,
115
- });
116
- if (index === 0) {
117
- await ollamaConnectionSetting(session, host, (patch) => saveCommandSettings(host, patch));
118
- }
119
- else if (index === 1) {
120
- await apiKeyCommand(OLLAMA_KEY, "Ollama", session, host, "ollama");
121
- }
122
- }
123
91
  function chooser(host) {
124
92
  if (host.choose === undefined) {
125
93
  host.emit({ kind: "notice", text: "that command needs the screen", tone: "warn" });
@@ -4,21 +4,12 @@ import { providerLabel } from "./provider-label.js";
4
4
  import { leadingText } from "./text-boundary.js";
5
5
  import { terminalText } from "./ui/terminal-text.js";
6
6
  import { providerFailureDetails, } from "./providers/failure.js";
7
- const CONNECTION_FAILURE = /network error calling|timed out waiting for response headers|response body was idle/i;
8
7
  const MAX_MESSAGE_CHARS = 1_000;
9
8
  const MAX_REASON_CHARS = 500;
10
9
  const HTML = /<!doctype\s|<\/?[a-z][^>]*>/i;
11
10
  export function providerFailure(provider, error, labelProvider = false) {
12
11
  const details = providerFailureDetails(provider.id, error);
13
- let failure;
14
- if (provider.id === "ollama" && details.kind === "network" && CONNECTION_FAILURE.test(error.message)) {
15
- failure = provider.location?.() === "local"
16
- ? "Ollama is not reachable on this computer · start Ollama or choose cloud in /providers"
17
- : "Ollama is not reachable · check its connection in /providers";
18
- }
19
- else {
20
- failure = actionableFailure(provider.id, details.kind) ?? rawFailure(error);
21
- }
12
+ let failure = actionableFailure(provider.id, details.kind) ?? rawFailure(error);
22
13
  if (details.requestId !== undefined)
23
14
  failure += ` · request ${details.requestId}`;
24
15
  const label = providerLabel(provider.id);
@@ -1,23 +1,16 @@
1
1
  // Stable human-facing names for provider identifiers.
2
2
  export function providerLabel(id) {
3
3
  switch (id) {
4
- case "anthropic": return "Anthropic";
4
+ case "anthropic": return "Anthropic API";
5
5
  case "openai": return "OpenAI API";
6
- case "openai-codex": return "ChatGPT";
7
- case "ollama": return "Ollama";
6
+ case "openai-codex": return "OpenAI Account";
7
+ case "ollama": return "Ollama API";
8
8
  default: return id === "" ? "Provider" : `${id[0]?.toUpperCase() ?? ""}${id.slice(1)}`;
9
9
  }
10
10
  }
11
11
  /** Name the actual connection a model request will use. */
12
12
  export function providerRouteLabel(provider) {
13
- if (provider.auth.kind === "oauth")
14
- return `${providerLabel(provider.id)} account`;
15
13
  if (provider.id === "openai")
16
14
  return "OpenAI API · billed usage";
17
- if (provider.id === "anthropic")
18
- return "Anthropic API";
19
- if (provider.id === "ollama") {
20
- return provider.location?.() === "local" ? "Ollama · local" : "Ollama API";
21
- }
22
15
  return providerLabel(provider.id);
23
16
  }
@@ -72,7 +72,6 @@ export const anthropic = {
72
72
  throwProviderError(ID, signal, error);
73
73
  }
74
74
  },
75
- location: () => "cloud",
76
75
  async send(req) {
77
76
  const key = requireKey();
78
77
  const body = {
@@ -1,7 +1,7 @@
1
1
  import { anthropic } from "./anthropic.js";
2
2
  import { openai } from "./openai.js";
3
3
  import { openaiCodex } from "./openai-codex.js";
4
- import { configureOllama, ollama } from "./ollama.js";
4
+ import { ollama } from "./ollama.js";
5
5
  export const PROVIDERS = [anthropic, openai, openaiCodex, ollama];
6
6
  export function providerNames() {
7
7
  return PROVIDERS.map((provider) => provider.id);
@@ -13,7 +13,3 @@ export function selectProvider(id) {
13
13
  }
14
14
  return found;
15
15
  }
16
- /** Apply provider-specific process state after startup precedence is resolved. */
17
- export function configureProviders(config) {
18
- configureOllama(config.ollamaHost);
19
- }