@giovannijecha/jecode 0.1.9 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -2,8 +2,9 @@
2
2
  import { heading } from "./tui/picker.js";
3
3
  import { PROVIDERS } from "./providers/index.js";
4
4
  import { readSettings } from "./settings.js";
5
- import { askForKey } from "./credential-commands.js";
5
+ import { authenticationNeed, ensureProviderAuthentication, } from "./credential-commands.js";
6
6
  import { providerFailure } from "./provider-errors.js";
7
+ import { providerLabel } from "./provider-label.js";
7
8
  /**
8
9
  * The provider menu.
9
10
  *
@@ -34,32 +35,22 @@ export async function providersCommand(session, host, behavior = {}) {
34
35
  // Picking the provider already in use is not a no-op when it cannot run:
35
36
  // it is how the user asks to fix the reason it cannot.
36
37
  if (chosen.id === session.provider.id) {
37
- const blocked = chosen.blocked();
38
- if (blocked !== undefined) {
39
- if (!isCredentialBlocker(chosen, blocked)) {
40
- host.emit({ kind: "notice", text: blocked, tone: "error" });
41
- return false;
42
- }
43
- await askForKey(chosen.keyVar, host, session.palette);
44
- if (chosen.blocked() !== undefined)
45
- return false;
46
- }
47
- return true;
38
+ if (!(await ensureProviderAuthentication(chosen, session, host)))
39
+ return false;
40
+ return session.model === ""
41
+ ? modelsCommand(session, host, { announce: false, save: behavior.save })
42
+ : true;
48
43
  }
49
44
  // A provider that cannot run is worth one offer to fix it, here, rather
50
45
  // than a note telling the user to leave and export something.
51
46
  const blocked = chosen.blocked();
52
47
  if (blocked !== undefined) {
53
- if (!isCredentialBlocker(chosen, blocked)) {
54
- host.emit({ kind: "notice", text: blocked, tone: "error" });
55
- return false;
56
- }
57
- await askForKey(chosen.keyVar, host, session.palette);
48
+ await ensureProviderAuthentication(chosen, session, host);
58
49
  const still = chosen.blocked();
59
50
  if (still !== undefined) {
60
51
  host.emit({
61
52
  kind: "notice",
62
- text: `${providerName(chosen.id)} still needs an API key · provider unchanged`,
53
+ text: `${providerLabel(chosen.id)} still needs ${authenticationNeed(chosen)} · provider unchanged`,
63
54
  tone: "warn",
64
55
  });
65
56
  return false;
@@ -77,6 +68,13 @@ export async function providersCommand(session, host, behavior = {}) {
77
68
  session.model = readSettings().models?.[chosen.id] ?? chosen.defaultModel;
78
69
  session.config.providerId = chosen.id;
79
70
  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;
77
+ }
80
78
  if (behavior.save !== false) {
81
79
  const saved = readSettings();
82
80
  const models = { ...saved.models };
@@ -111,16 +109,12 @@ export async function modelsCommand(session, host, behavior = {}) {
111
109
  // to pick a model, and "go and export a variable" is not an answer.
112
110
  const blocked = provider.blocked();
113
111
  if (blocked !== undefined) {
114
- if (!isCredentialBlocker(provider, blocked)) {
115
- host.emit({ kind: "notice", text: blocked, tone: "error" });
116
- return false;
117
- }
118
- await askForKey(provider.keyVar, host, session.palette);
112
+ await ensureProviderAuthentication(provider, session, host);
119
113
  const still = provider.blocked();
120
114
  if (still !== undefined) {
121
115
  host.emit({
122
116
  kind: "notice",
123
- text: `${providerName(provider.id)} still needs an API key`,
117
+ text: `${providerLabel(provider.id)} still needs ${authenticationNeed(provider)}`,
124
118
  tone: "warn",
125
119
  });
126
120
  return false;
@@ -182,12 +176,6 @@ function chooser(host) {
182
176
  }
183
177
  return host.choose;
184
178
  }
185
- function providerName(id) {
186
- return id === "" ? "Provider" : `${id[0]?.toUpperCase() ?? ""}${id.slice(1)}`;
187
- }
188
- function isCredentialBlocker(provider, blocked) {
189
- return blocked.startsWith(`${provider.keyVar} `);
190
- }
191
179
  async function saveDefaults(host, patch) {
192
180
  if (host.saveSettings === undefined)
193
181
  return true;
@@ -0,0 +1,10 @@
1
+ // Stable human-facing names for provider identifiers.
2
+ export function providerLabel(id) {
3
+ switch (id) {
4
+ case "anthropic": return "Anthropic";
5
+ case "openai": return "OpenAI";
6
+ case "openai-codex": return "OpenAI Codex";
7
+ case "ollama": return "Ollama";
8
+ default: return id === "" ? "Provider" : `${id[0]?.toUpperCase() ?? ""}${id.slice(1)}`;
9
+ }
10
+ }
@@ -27,7 +27,7 @@ export const anthropic = {
27
27
  // Sonnet is the default because it is the one that can be left running.
28
28
  // Opus via `--model claude-opus-5`, Haiku via `--model claude-haiku-4-5`.
29
29
  defaultModel: "claude-sonnet-5",
30
- keyVar: KEY,
30
+ auth: { kind: "api-key", keyVar: KEY },
31
31
  blocked() {
32
32
  return apiKey() === undefined ? `${KEY} is not set` : undefined;
33
33
  },
@@ -1,7 +1,8 @@
1
1
  import { anthropic } from "./anthropic.js";
2
2
  import { openai } from "./openai.js";
3
+ import { openaiCodex } from "./openai-codex.js";
3
4
  import { configureOllama, ollama } from "./ollama.js";
4
- export const PROVIDERS = [anthropic, openai, ollama];
5
+ export const PROVIDERS = [anthropic, openai, openaiCodex, ollama];
5
6
  export function providerNames() {
6
7
  return PROVIDERS.map((provider) => provider.id);
7
8
  }
@@ -25,7 +25,7 @@ export function ollamaConnection() {
25
25
  export const ollama = {
26
26
  id: "ollama",
27
27
  defaultModel: "",
28
- keyVar: KEY,
28
+ auth: { kind: "api-key", keyVar: KEY },
29
29
  // The only provider whose key is conditional: a daemon on this machine is
30
30
  // reached over loopback and asks for nothing, so demanding a key there
31
31
  // would be an invented requirement.
@@ -0,0 +1,114 @@
1
+ // ChatGPT-backed Codex Responses, kept separate from the OpenAI API provider.
2
+ import { randomUUID } from "node:crypto";
3
+ import { openAICodexAccount } from "../accounts.js";
4
+ import { openAIAuthorization } from "../openai-account.js";
5
+ import { applicationVersion } from "../version.js";
6
+ import { getJson, postSse } from "./http.js";
7
+ import { assembleOpenAI } from "./openai-stream.js";
8
+ import { fromWireResponse, stopNotice, toWireItems, toWireTool, } from "./openai-wire.js";
9
+ const ID = "openai-codex";
10
+ const BASE = "https://chatgpt.com/backend-api/codex";
11
+ // Jecode's product version is unrelated to the Codex protocol gate. OpenAI's
12
+ // own catalogue updater uses this sentinel to request the complete current
13
+ // manifest; Jecode then keeps only entries explicitly visible in that manifest.
14
+ const CATALOG_COMPATIBILITY_VERSION = "99.99.99";
15
+ const SESSION_ID = randomUUID();
16
+ const MAX_CATALOG_ITEMS = 4_000;
17
+ const MAX_MODELS = 1_000;
18
+ const MAX_MODEL_CHARS = 256;
19
+ export const openaiCodex = {
20
+ id: ID,
21
+ defaultModel: "",
22
+ auth: { kind: "oauth", account: ID, label: "ChatGPT" },
23
+ blocked() {
24
+ return openAICodexAccount() === undefined ? "ChatGPT account is not connected" : undefined;
25
+ },
26
+ location: () => "cloud",
27
+ 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);
32
+ },
33
+ async send(req) {
34
+ return withAuthorization(async (authorization) => {
35
+ const events = await postSse(`${BASE}/responses`, {
36
+ ...headers(authorization, randomUUID()),
37
+ "openai-beta": "responses=experimental",
38
+ }, {
39
+ model: req.model,
40
+ store: false,
41
+ stream: true,
42
+ instructions: req.system,
43
+ input: req.messages.flatMap((message) => toWireItems(message, ID)),
44
+ tools: req.tools.map(toWireTool),
45
+ tool_choice: "auto",
46
+ parallel_tool_calls: true,
47
+ reasoning: { effort: req.effort, summary: "auto" },
48
+ text: { verbosity: "low" },
49
+ include: ["reasoning.encrypted_content"],
50
+ prompt_cache_key: SESSION_ID,
51
+ }, req.signal, req.onStatus);
52
+ const data = await assembleOpenAI(events, req.onStream);
53
+ const notice = stopNotice(data);
54
+ if (notice !== undefined)
55
+ req.onStream?.({ kind: "text", text: `\n${notice}` });
56
+ return fromWireResponse(data, ID);
57
+ }, req.signal, req.onStatus);
58
+ },
59
+ };
60
+ async function withAuthorization(operation, signal, onStatus) {
61
+ let authorization = await openAIAuthorization(undefined, signal, onStatus);
62
+ try {
63
+ return await operation(authorization);
64
+ }
65
+ catch (error) {
66
+ if (statusOf(error) !== 401)
67
+ throw error;
68
+ authorization = await openAIAuthorization(authorization.accessToken, signal, onStatus);
69
+ return operation(authorization);
70
+ }
71
+ }
72
+ function headers(authorization, requestId) {
73
+ const version = applicationVersion();
74
+ return {
75
+ authorization: `Bearer ${authorization.accessToken}`,
76
+ "chatgpt-account-id": authorization.accountId,
77
+ originator: "jecode",
78
+ "user-agent": `jecode/${version} (${process.platform}; ${process.arch})`,
79
+ "session-id": SESSION_ID,
80
+ "x-client-request-id": requestId,
81
+ };
82
+ }
83
+ function modelIds(value) {
84
+ const source = record(value) && Array.isArray(value["models"]) ? value["models"] : undefined;
85
+ if (source === undefined)
86
+ throw new Error("OpenAI Codex did not return a model list");
87
+ const seen = new Set();
88
+ return source
89
+ .slice(0, MAX_CATALOG_ITEMS)
90
+ .flatMap((entry) => {
91
+ if (!record(entry))
92
+ return [];
93
+ const id = entry["slug"];
94
+ if (typeof id !== "string" ||
95
+ id === "" ||
96
+ id.length > MAX_MODEL_CHARS ||
97
+ entry["visibility"] !== "list" ||
98
+ seen.has(id))
99
+ return [];
100
+ seen.add(id);
101
+ return [{ id, priority: typeof entry["priority"] === "number" ? entry["priority"] : 0 }];
102
+ })
103
+ .sort((left, right) => left.priority - right.priority)
104
+ .slice(0, MAX_MODELS)
105
+ .map((entry) => entry.id);
106
+ }
107
+ function statusOf(error) {
108
+ return typeof error === "object" && error !== null && "status" in error
109
+ ? error.status
110
+ : undefined;
111
+ }
112
+ function record(value) {
113
+ return typeof value === "object" && value !== null && !Array.isArray(value);
114
+ }
@@ -1,12 +1,11 @@
1
1
  // Reassembling an OpenAI Responses reply from its event stream.
2
2
  //
3
- // Unlike Anthropic, this stream ends with the whole finished response in
4
- // `response.completed`, so there is nothing to rebuild: the deltas drive the
5
- // display, and the final event is taken as authoritative. Items collected
6
- // along the way are only a fallback for a stream that ends without it.
3
+ // Unlike Anthropic, a standard Responses stream ends with the whole finished
4
+ // response in `response.completed`. The ChatGPT Codex backend can instead send
5
+ // an empty final `output` after complete `response.output_item.done` events, so
6
+ // those streamed items remain the fallback when the final envelope is empty.
7
7
  export async function assembleOpenAI(events, onStream) {
8
8
  const items = [];
9
- let completed;
10
9
  let refusal = false;
11
10
  for await (const raw of events) {
12
11
  const event = raw;
@@ -29,11 +28,10 @@ export async function assembleOpenAI(events, onStream) {
29
28
  if (event.item !== undefined)
30
29
  items.push(event.item);
31
30
  break;
31
+ case "response.done":
32
32
  case "response.completed":
33
33
  case "response.incomplete":
34
- if (event.response !== undefined)
35
- completed = event.response;
36
- break;
34
+ return reconcileOutput(event.response, items);
37
35
  case "response.failed": {
38
36
  const response = event.response;
39
37
  throw new Error(`openai stream error: ${response?.error?.message ?? "unspecified"}`);
@@ -44,5 +42,11 @@ export async function assembleOpenAI(events, onStream) {
44
42
  break;
45
43
  }
46
44
  }
47
- return completed ?? { output: items };
45
+ return { output: items };
46
+ }
47
+ function reconcileOutput(completed, items) {
48
+ if (completed === undefined)
49
+ return { output: items };
50
+ const finalCount = Array.isArray(completed.output) ? completed.output.length : 0;
51
+ return items.length > finalCount ? { ...completed, output: items } : completed;
48
52
  }
@@ -14,8 +14,8 @@ export function toWireTool(tool) {
14
14
  parameters: tool.input,
15
15
  };
16
16
  }
17
- export function toWireItems(message) {
18
- if (message.rawFrom === "openai" && Array.isArray(message.raw)) {
17
+ export function toWireItems(message, providerId = "openai") {
18
+ if (message.rawFrom === providerId && Array.isArray(message.raw)) {
19
19
  return message.raw;
20
20
  }
21
21
  const items = [];
@@ -50,7 +50,7 @@ export function stopNotice(data) {
50
50
  ? "[truncated: hit max_output_tokens — raise --max-tokens]"
51
51
  : `[incomplete: ${reason}]`;
52
52
  }
53
- export function fromWireResponse(data) {
53
+ export function fromWireResponse(data, providerId = "openai") {
54
54
  const raw = Array.isArray(data.output) ? data.output : [];
55
55
  const content = [];
56
56
  for (const entry of raw) {
@@ -81,7 +81,7 @@ export function fromWireResponse(data) {
81
81
  const notice = stopNotice(data);
82
82
  if (notice !== undefined)
83
83
  content.push({ kind: "text", text: notice });
84
- return { role: "assistant", content, raw, rawFrom: "openai", usage: normalizeUsage(data) };
84
+ return { role: "assistant", content, raw, rawFrom: providerId, usage: normalizeUsage(data) };
85
85
  }
86
86
  function normalizeUsage(data) {
87
87
  const usage = data.usage;
@@ -23,7 +23,7 @@ const NON_TEXT_MODE = /(?:^|[-_])(audio|realtime|transcribe|tts)(?:[-_]|$)/;
23
23
  export const openai = {
24
24
  id: "openai",
25
25
  defaultModel: "gpt-5",
26
- keyVar: KEY,
26
+ auth: { kind: "api-key", keyVar: KEY },
27
27
  blocked() {
28
28
  return apiKey() === undefined ? `${KEY} is not set` : undefined;
29
29
  },
@@ -41,7 +41,7 @@ export const openai = {
41
41
  const events = await postSse(ENDPOINT, headers(key), {
42
42
  model: req.model,
43
43
  instructions: req.system,
44
- input: req.messages.flatMap(toWireItems),
44
+ input: req.messages.flatMap((message) => toWireItems(message)),
45
45
  tools: req.tools.map(toWireTool),
46
46
  max_output_tokens: req.maxTokens,
47
47
  reasoning: { effort: normalizeEffort(req.effort), summary: "auto" },
@@ -76,6 +76,6 @@ function parseData(chunk) {
76
76
  return JSON.parse(data);
77
77
  }
78
78
  catch {
79
- return undefined;
79
+ throw new Error("SSE event contained invalid JSON");
80
80
  }
81
81
  }
@@ -75,13 +75,21 @@ function settingsItems(values) {
75
75
  }]),
76
76
  { action: "model", option: { label: "model", hint: values.model || "choose a model" } },
77
77
  { action: "effort", option: { label: "effort", hint: values.effort } },
78
- { action: "maxTokens", option: { label: "max output tokens", hint: String(values.maxTokens) } },
78
+ ...(values.maxTokens === undefined
79
+ ? []
80
+ : [{
81
+ action: "maxTokens",
82
+ option: { label: "max output tokens", hint: String(values.maxTokens) },
83
+ }]),
79
84
  { action: "maxSteps", option: { label: "max tool steps", hint: String(values.maxSteps) } },
80
85
  {
81
86
  action: "reducedMotion",
82
87
  option: { label: "reduced motion", hint: values.reducedMotion ? "on" : "off" },
83
88
  },
84
- { action: "credentials", option: { label: "credentials", hint: "manage API keys" } },
89
+ {
90
+ action: "credentials",
91
+ option: { label: "authentication", hint: "manage API keys and accounts" },
92
+ },
85
93
  ];
86
94
  }
87
95
  function settingsValues(session) {
@@ -90,7 +98,7 @@ function settingsValues(session) {
90
98
  model: session.model,
91
99
  ...(session.provider.id === "ollama" ? { ollamaConnection: ollamaConnectionHint() } : {}),
92
100
  effort: session.config.effort,
93
- maxTokens: session.config.maxTokens,
101
+ ...(session.provider.id === "openai-codex" ? {} : { maxTokens: session.config.maxTokens }),
94
102
  maxSteps: session.config.maxSteps,
95
103
  reducedMotion: session.config.reducedMotion,
96
104
  };
@@ -5,6 +5,7 @@ import { updateSettings } from "../settings.js";
5
5
  import { saveTranscript } from "../transcript-export.js";
6
6
  import { recordUsage } from "../usage.js";
7
7
  import { answerAt } from "./approve.js";
8
+ import { cancel as cancelOpen } from "./overlay.js";
8
9
  import { controllerOptions, turnFailure } from "./session-view.js";
9
10
  import { transcribe } from "./turn.js";
10
11
  const WAITING = "Waiting";
@@ -26,6 +27,10 @@ export function appWorkflows(options) {
26
27
  state.open = { picker, settle: resolve };
27
28
  options.render();
28
29
  }),
30
+ dismiss: () => {
31
+ state.open = state.open === undefined ? undefined : cancelOpen(state.open);
32
+ options.render();
33
+ },
29
34
  type: (field) => new Promise((resolve) => {
30
35
  state.open = { field, settle: resolve };
31
36
  options.render();
@@ -1,4 +1,5 @@
1
1
  // Operational feedback belongs in the footer, not in the conversation.
2
+ import { providerLabel } from "../provider-label.js";
2
3
  const INFO_MS = 2_200;
3
4
  const WARN_MS = 4_200;
4
5
  const ERROR_MS = 6_000;
@@ -50,23 +51,26 @@ export function commandFeedback(block) {
50
51
  /** Explain why a model turn cannot start, without exposing configuration internals. */
51
52
  export function turnBlocker(session) {
52
53
  const blocked = session.provider.blocked();
53
- if (blocked !== undefined && !blocked.startsWith(`${session.provider.keyVar} `)) {
54
+ const auth = session.provider.auth;
55
+ const expected = auth.kind === "api-key"
56
+ ? blocked?.startsWith(`${auth.keyVar} `) === true
57
+ : blocked !== undefined;
58
+ if (blocked !== undefined && !expected) {
54
59
  return { text: blocked, tone: "error" };
55
60
  }
56
61
  if (blocked !== undefined) {
57
62
  return {
58
- text: `${providerName(session.provider.id)} needs an API key · /settings`,
63
+ text: auth.kind === "oauth"
64
+ ? `${providerLabel(session.provider.id)} needs ${auth.label} sign-in · /settings`
65
+ : `${providerLabel(session.provider.id)} needs an API key · /settings`,
59
66
  tone: "warn",
60
67
  };
61
68
  }
62
69
  if (session.model === "") {
63
70
  return {
64
- text: `${providerName(session.provider.id)} needs a model · /models`,
71
+ text: `${providerLabel(session.provider.id)} needs a model · /models`,
65
72
  tone: "warn",
66
73
  };
67
74
  }
68
75
  return undefined;
69
76
  }
70
- function providerName(id) {
71
- return id === "" ? "Provider" : `${id[0]?.toUpperCase() ?? ""}${id.slice(1)}`;
72
- }
@@ -26,10 +26,16 @@ export function turnFailure(session, error, aborted) {
26
26
  return { kind: "notice", text: "[interrupted]", tone: "warn" };
27
27
  let text = providerFailure(session.provider, error);
28
28
  if (/\b401\b/.test(text)) {
29
- const source = credentialSource(session.provider.keyVar);
30
- text += source === "environment"
31
- ? ` · update ${session.provider.keyVar} in the environment and restart`
32
- : " · check credentials with /settings";
29
+ const auth = session.provider.auth;
30
+ if (auth.kind === "oauth") {
31
+ text += ` · reconnect ${auth.label} in /settings`;
32
+ }
33
+ else {
34
+ const source = credentialSource(auth.keyVar);
35
+ text += source === "environment"
36
+ ? ` · update ${auth.keyVar} in the environment and restart`
37
+ : " · check credentials with /settings";
38
+ }
33
39
  }
34
40
  return { kind: "notice", text, tone: "error" };
35
41
  }
@@ -0,0 +1,14 @@
1
+ // The package version is runtime identity, not configuration.
2
+ import { readFileSync } from "node:fs";
3
+ import * as path from "node:path";
4
+ let cached;
5
+ export function applicationVersion() {
6
+ if (cached !== undefined)
7
+ return cached;
8
+ const manifest = JSON.parse(readFileSync(path.resolve(import.meta.dirname, "..", "package.json"), "utf8"));
9
+ if (typeof manifest.version !== "string" || manifest.version === "") {
10
+ throw new Error("package version is missing");
11
+ }
12
+ cached = manifest.version;
13
+ return cached;
14
+ }
Binary file
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@giovannijecha/jecode",
3
- "version": "0.1.9",
3
+ "version": "0.2.0",
4
4
  "description": "An owned coding agent with zero external runtime dependencies.",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -16,12 +16,16 @@
16
16
  "terminal",
17
17
  "tui",
18
18
  "ollama",
19
+ "openai",
20
+ "chatgpt",
21
+ "oauth",
19
22
  "typescript"
20
23
  ],
21
24
  "type": "module",
22
25
  "files": [
23
26
  "bin/",
24
27
  "dist/",
28
+ "docs/assets/brand/jeco-256.png",
25
29
  "LICENSE",
26
30
  "README.md"
27
31
  ],