@textcortex/zenocode 0.1.13 → 0.1.15

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.
@@ -1,75 +1,86 @@
1
1
  #!/usr/bin/env node
2
- import { spawn } from "node:child_process";
3
- import { realpathSync } from "node:fs";
4
- import fs from "node:fs/promises";
5
- import os from "node:os";
6
- import path from "node:path";
7
- import process from "node:process";
8
- import readline from "node:readline/promises";
9
- import { fileURLToPath } from "node:url";
10
- import {
11
- patchZenocodeBinaryText,
12
- zenocodeLogo,
13
- } from "./branding-patch.mjs";
14
- import {
15
- openCodePackageName,
16
- openCodePackageSpec,
17
- } from "./opencode-version.mjs";
2
+ import { spawn } from 'node:child_process';
3
+ import { existsSync, realpathSync } from 'node:fs';
4
+ import fs from 'node:fs/promises';
5
+ import os from 'node:os';
6
+ import path from 'node:path';
7
+ import process from 'node:process';
8
+ import readline from 'node:readline/promises';
9
+ import { fileURLToPath } from 'node:url';
10
+ import { zenocodeRuntimePackageSpec } from './opencode-version.mjs';
11
+ import { zenocodeLogo } from './source-branding.mjs';
18
12
 
19
13
  const currentFilePath = realpathSync(fileURLToPath(import.meta.url));
20
- const __dirname = path.dirname(currentFilePath);
21
- const appRoot = path.resolve(__dirname, "..");
22
14
  const runtimeDir =
23
15
  process.env.ZENOCODE_HOME ||
24
16
  process.env.CODECORTEX_HOME ||
25
- path.join(os.homedir(), ".zenocode");
17
+ path.join(os.homedir(), '.zenocode');
26
18
  const legacyRuntimeDir =
27
- process.env.CODECORTEX_HOME || path.join(os.homedir(), ".codecortex");
28
- const runtimeCredentialsPath = path.join(runtimeDir, "credentials.json");
19
+ process.env.CODECORTEX_HOME || path.join(os.homedir(), '.codecortex');
20
+ const runtimeCredentialsPath = path.join(runtimeDir, 'credentials.json');
29
21
  const legacyRuntimeCredentialsPath = path.join(
30
22
  legacyRuntimeDir,
31
- "credentials.json",
23
+ 'credentials.json'
32
24
  );
33
- const logoutMarkerPath = path.join(runtimeDir, "logout-marker.json");
34
- const modelsPath = path.join(runtimeDir, "models.json");
35
- const configPath = path.join(runtimeDir, "opencode.jsonc");
36
- const tuiConfigPath = path.join(runtimeDir, "opencode.tui.json");
37
- const localBaseUrlDefault = "http://127.0.0.1:8080";
38
- const cloudBaseUrlDefault = "https://api.textcortex.com";
39
- const localBaseUrlFlags = new Set(["--local", "--localhost"]);
40
-
41
- const providerID = "textcortex";
42
- const configuredOpencodePackage =
43
- process.env.ZENOCODE_OPENCODE_PACKAGE ||
44
- process.env.CODECORTEX_OPENCODE_PACKAGE ||
45
- process.env.OPENCODE_PACKAGE ||
46
- null;
47
- const defaultBrandedOpencodePackage = openCodePackageSpec;
48
- const legacyBrandedOpencodePackage = "@textcortex/opencode-ai";
49
- const fallbackOpencodePackage = openCodePackageName;
50
- const opencodePackage = configuredOpencodePackage || defaultBrandedOpencodePackage;
25
+ const logoutMarkerPath = path.join(runtimeDir, 'logout-marker.json');
26
+ const modelsPath = path.join(runtimeDir, 'models.json');
27
+ const configPath = path.join(runtimeDir, 'zenocode.jsonc');
28
+ const tuiConfigPath = path.join(runtimeDir, 'zenocode.tui.json');
29
+ const localBaseUrlDefault = 'http://127.0.0.1:8080';
30
+ const cloudBaseUrlDefault = 'https://api.textcortex.com';
31
+ const localBaseUrlFlags = new Set(['--local', '--localhost']);
32
+
33
+ const providerID = 'textcortex';
34
+ export function resolveConfiguredRuntimePackage(env = process.env) {
35
+ return (
36
+ env.ZENOCODE_OPENCODE_PACKAGE || env.CODECORTEX_OPENCODE_PACKAGE || null
37
+ );
38
+ }
39
+
40
+ const configuredOpencodePackage = resolveConfiguredRuntimePackage();
41
+ const opencodePackage = configuredOpencodePackage || zenocodeRuntimePackageSpec;
51
42
  const opencodeBinaryPath =
52
43
  process.env.ZENOCODE_OPENCODE_BIN_PATH ||
53
44
  process.env.CODECORTEX_OPENCODE_BIN_PATH ||
54
- "";
55
- const oauthInitiatePath = "/internal/v2/fastapi/zenocode/oauth2/initiate";
56
- const oauthTokenPath = "/internal/v2/fastapi/zenocode/oauth2/token";
45
+ '';
46
+ const oauthInitiatePath = '/internal/v2/fastapi/zenocode/oauth2/initiate';
47
+ const oauthTokenPath = '/internal/v2/fastapi/zenocode/oauth2/token';
57
48
  const defaultOrder = [
58
- "minimax-m3-thinking",
59
- "kimi-k2-6",
60
- "kimi-k2-5-thinking",
61
- "glm-5-1",
62
- "gpt-5-2",
63
- "gpt-5-1",
64
- "gpt-5",
65
- "claude-4-6-sonnet",
66
- "gpt-5-mini",
49
+ 'gpt-6-luna',
50
+ 'gpt-5-6-luna',
51
+ 'kimi-k3-non-thinking',
52
+ 'kimi-k3-thinking',
53
+ 'kimi-k2p7-code-non-thinking',
54
+ 'kimi-k2p7-code-thinking',
55
+ 'minimax-m3-thinking',
56
+ 'kimi-k2-6-non-thinking',
57
+ 'kimi-k2-6-thinking',
58
+ 'kimi-k2-5',
59
+ 'kimi-k2-5-thinking',
60
+ 'glm-5-3',
61
+ 'glm-5-2-non-thinking',
62
+ 'glm-5-2-thinking',
63
+ 'gpt-5-2',
64
+ 'gpt-5-1',
65
+ 'gpt-5',
66
+ 'claude-sonnet-5-5',
67
+ 'claude-5-sonnet',
68
+ 'claude-opus-5-5',
69
+ 'claude-opus-5',
70
+ 'gpt-5-mini',
67
71
  ];
72
+ const retiredModelIDs = new Set([
73
+ 'glm-4-7',
74
+ 'glm-5',
75
+ 'glm-5-1',
76
+ 'glm-5-1-non-thinking',
77
+ 'glm-5-1-thinking',
78
+ ]);
68
79
  const devCredentialFiles =
69
- process.env.ZENOCODE_DEV_MODE === "1"
80
+ process.env.ZENOCODE_DEV_MODE === '1'
70
81
  ? [
71
- path.join(process.cwd(), "backend", ".credentials.json"),
72
- path.join(process.cwd(), ".credentials.json"),
82
+ path.join(process.cwd(), 'backend', '.credentials.json'),
83
+ path.join(process.cwd(), '.credentials.json'),
73
84
  ]
74
85
  : [];
75
86
  const runtimeCredentialFiles = [
@@ -77,53 +88,61 @@ const runtimeCredentialFiles = [
77
88
  legacyRuntimeCredentialsPath,
78
89
  ].filter((value, index, values) => values.indexOf(value) === index);
79
90
  const fallbackCredentialFiles = [
80
- path.join(os.homedir(), ".credentials.json"),
91
+ path.join(os.homedir(), '.credentials.json'),
81
92
  ...devCredentialFiles,
82
93
  ].filter((value, index, values) => values.indexOf(value) === index);
83
94
  const credentialFiles = [...runtimeCredentialFiles, ...fallbackCredentialFiles];
84
95
 
85
96
  function _decodeEscapedLogoLine(line) {
86
97
  return line.replace(/\\u([0-9a-fA-F]{4})/g, (_, codePoint) =>
87
- String.fromCharCode(Number.parseInt(codePoint, 16)),
98
+ String.fromCharCode(Number.parseInt(codePoint, 16))
88
99
  );
89
100
  }
90
101
 
91
102
  export function buildZenocodeBanner() {
92
103
  const bannerLines = zenocodeLogo.left
93
104
  .map((leftLine, index) => {
94
- const rightLine = zenocodeLogo.right[index] || "";
105
+ const rightLine = zenocodeLogo.right[index] || '';
95
106
  return `${_decodeEscapedLogoLine(leftLine)} ${_decodeEscapedLogoLine(rightLine)}`.trimEnd();
96
107
  })
97
108
  .filter((line) => line.trim());
98
- return `\n${bannerLines.join("\n")}\n`;
109
+ return `\n${bannerLines.join('\n')}\n`;
99
110
  }
100
111
  const privateDirectoryMode = 0o700;
101
112
  const privateFileMode = 0o600;
102
113
 
114
+ async function _pathExists(pathToCheck) {
115
+ try {
116
+ await fs.access(pathToCheck);
117
+ return true;
118
+ } catch {
119
+ return false;
120
+ }
121
+ }
122
+
103
123
  async function readJson(filePath) {
104
124
  try {
105
- return JSON.parse(await fs.readFile(filePath, "utf-8"));
125
+ return JSON.parse(await fs.readFile(filePath, 'utf-8'));
106
126
  } catch (error) {
107
- if (error?.code === "ENOENT") return null;
127
+ if (error?.code === 'ENOENT') return null;
108
128
  throw error;
109
129
  }
110
130
  }
111
131
 
112
132
  async function ensurePrivateDirectory(dirPath) {
113
133
  await fs.mkdir(dirPath, { recursive: true, mode: privateDirectoryMode });
114
- if (process.platform !== "win32") {
134
+ if (process.platform !== 'win32') {
115
135
  await fs.chmod(dirPath, privateDirectoryMode);
116
136
  }
117
137
  }
118
138
 
119
139
  export async function writePrivateJsonFile(filePath, payload) {
120
140
  await ensurePrivateDirectory(path.dirname(filePath));
121
- await fs.writeFile(
122
- filePath,
123
- `${JSON.stringify(payload, null, 2)}\n`,
124
- { encoding: "utf-8", mode: privateFileMode },
125
- );
126
- if (process.platform !== "win32") {
141
+ await fs.writeFile(filePath, `${JSON.stringify(payload, null, 2)}\n`, {
142
+ encoding: 'utf-8',
143
+ mode: privateFileMode,
144
+ });
145
+ if (process.platform !== 'win32') {
127
146
  await fs.chmod(filePath, privateFileMode);
128
147
  }
129
148
  }
@@ -132,7 +151,7 @@ async function clearLogoutMarker() {
132
151
  try {
133
152
  await fs.unlink(logoutMarkerPath);
134
153
  } catch (error) {
135
- if (error?.code !== "ENOENT") {
154
+ if (error?.code !== 'ENOENT') {
136
155
  throw error;
137
156
  }
138
157
  }
@@ -143,24 +162,31 @@ async function writeLogoutMarker() {
143
162
  await fs.writeFile(
144
163
  logoutMarkerPath,
145
164
  `${JSON.stringify({ logged_out_at: new Date().toISOString() }, null, 2)}\n`,
146
- { encoding: "utf-8", mode: privateFileMode },
165
+ {
166
+ encoding: 'utf-8',
167
+ mode: privateFileMode,
168
+ }
147
169
  );
148
- if (process.platform !== "win32") {
170
+ if (process.platform !== 'win32') {
149
171
  await fs.chmod(logoutMarkerPath, privateFileMode);
150
172
  }
151
173
  }
152
174
 
153
175
  function extractTokenFromCredentialPayload(parsed) {
154
176
  if (!parsed) return null;
155
- if (typeof parsed?.access_token === "string" && parsed.access_token) {
177
+ if (typeof parsed?.access_token === 'string' && parsed.access_token) {
156
178
  return parsed.access_token;
157
179
  }
158
180
  if (Array.isArray(parsed?.accounts)) {
159
- const active = parsed.accounts.find((entry) => typeof entry?.access_token === "string" && entry.access_token);
181
+ const active = parsed.accounts.find(
182
+ (entry) => typeof entry?.access_token === 'string' && entry.access_token
183
+ );
160
184
  if (active) return active.access_token;
161
185
  }
162
186
  if (Array.isArray(parsed)) {
163
- const match = parsed.find((entry) => typeof entry?.access_token === "string" && entry.access_token);
187
+ const match = parsed.find(
188
+ (entry) => typeof entry?.access_token === 'string' && entry.access_token
189
+ );
164
190
  if (match) return match.access_token;
165
191
  }
166
192
  return null;
@@ -168,17 +194,19 @@ function extractTokenFromCredentialPayload(parsed) {
168
194
 
169
195
  function extractBaseUrlFromCredentialPayload(parsed) {
170
196
  if (!parsed) return null;
171
- if (typeof parsed?.base_url === "string" && parsed.base_url) {
197
+ if (typeof parsed?.base_url === 'string' && parsed.base_url) {
172
198
  return parsed.base_url;
173
199
  }
174
200
  if (Array.isArray(parsed?.accounts)) {
175
201
  const active = parsed.accounts.find(
176
- (entry) => typeof entry?.base_url === "string" && entry.base_url,
202
+ (entry) => typeof entry?.base_url === 'string' && entry.base_url
177
203
  );
178
204
  if (active) return active.base_url;
179
205
  }
180
206
  if (Array.isArray(parsed)) {
181
- const match = parsed.find((entry) => typeof entry?.base_url === "string" && entry.base_url);
207
+ const match = parsed.find(
208
+ (entry) => typeof entry?.base_url === 'string' && entry.base_url
209
+ );
182
210
  if (match) return match.base_url;
183
211
  }
184
212
  return null;
@@ -186,17 +214,17 @@ function extractBaseUrlFromCredentialPayload(parsed) {
186
214
 
187
215
  export function resolveLoginSuccessIdentifier(tokenData) {
188
216
  const authId =
189
- typeof tokenData?.auth_id === "string" ? tokenData.auth_id.trim() : "";
190
- return authId || "unknown";
217
+ typeof tokenData?.auth_id === 'string' ? tokenData.auth_id.trim() : '';
218
+ return authId || 'unknown';
191
219
  }
192
220
 
193
221
  function _isCredentialEntry(entry) {
194
222
  return Boolean(
195
223
  entry &&
196
- typeof entry === "object" &&
224
+ typeof entry === 'object' &&
197
225
  !Array.isArray(entry) &&
198
- (typeof entry.access_token === "string" ||
199
- typeof entry.refresh_token === "string"),
226
+ (typeof entry.access_token === 'string' ||
227
+ typeof entry.refresh_token === 'string')
200
228
  );
201
229
  }
202
230
 
@@ -211,7 +239,9 @@ function _selectCredentialEntry(parsed) {
211
239
  }
212
240
 
213
241
  if (Array.isArray(parsed?.accounts)) {
214
- const index = parsed.accounts.findIndex((entry) => _isCredentialEntry(entry));
242
+ const index = parsed.accounts.findIndex((entry) =>
243
+ _isCredentialEntry(entry)
244
+ );
215
245
  if (index !== -1) {
216
246
  return {
217
247
  entry: parsed.accounts[index],
@@ -273,14 +303,14 @@ async function findStoredCredentialState() {
273
303
  }
274
304
 
275
305
  function _decodeBase64Url(value) {
276
- const normalized = value.replaceAll("-", "+").replaceAll("_", "/");
277
- const padding = "=".repeat((4 - (normalized.length % 4)) % 4);
278
- return Buffer.from(`${normalized}${padding}`, "base64").toString("utf-8");
306
+ const normalized = value.replaceAll('-', '+').replaceAll('_', '/');
307
+ const padding = '='.repeat((4 - (normalized.length % 4)) % 4);
308
+ return Buffer.from(`${normalized}${padding}`, 'base64').toString('utf-8');
279
309
  }
280
310
 
281
311
  function extractAccessTokenExpiry(accessToken) {
282
312
  try {
283
- const [, payloadSegment] = String(accessToken || "").split(".");
313
+ const [, payloadSegment] = String(accessToken || '').split('.');
284
314
  if (!payloadSegment) return null;
285
315
  const payload = JSON.parse(_decodeBase64Url(payloadSegment));
286
316
  const exp = Number(payload?.exp);
@@ -294,24 +324,24 @@ function extractAccessTokenExpiry(accessToken) {
294
324
  }
295
325
 
296
326
  async function exchangeRefreshToken(baseUrl, refreshToken) {
297
- const refreshUrl = new URL("/auth/token/refresh/", baseUrl).toString();
327
+ const refreshUrl = new URL('/auth/token/refresh/', baseUrl).toString();
298
328
  const { response, payload, text } = await requestJson(refreshUrl, {
299
- method: "POST",
329
+ method: 'POST',
300
330
  headers: {
301
331
  Authorization: `Bearer ${refreshToken}`,
302
- Accept: "application/json",
303
- "Content-Type": "application/json",
332
+ Accept: 'application/json',
333
+ 'Content-Type': 'application/json',
304
334
  },
305
335
  });
306
336
 
307
337
  if (!response.ok) {
308
- const message = extractErrorMessage(payload, text || "request failed");
338
+ const message = extractErrorMessage(payload, text || 'request failed');
309
339
  throw new Error(`Token refresh failed (${response.status}): ${message}`);
310
340
  }
311
341
 
312
342
  const data = unwrapData(payload);
313
343
  if (!data?.access_token) {
314
- throw new Error("Token refresh failed: invalid response payload.");
344
+ throw new Error('Token refresh failed: invalid response payload.');
315
345
  }
316
346
 
317
347
  return data;
@@ -320,20 +350,20 @@ async function exchangeRefreshToken(baseUrl, refreshToken) {
320
350
  async function refreshStoredCredentials(baseUrl) {
321
351
  const state = await findStoredCredentialState();
322
352
  const refreshToken =
323
- typeof state?.entry?.refresh_token === "string" && state.entry.refresh_token
353
+ typeof state?.entry?.refresh_token === 'string' && state.entry.refresh_token
324
354
  ? state.entry.refresh_token
325
355
  : null;
326
356
 
327
357
  if (!state || !refreshToken) {
328
- throw new Error("No refresh token available.");
358
+ throw new Error('No refresh token available.');
329
359
  }
330
360
 
331
361
  const resolvedBaseUrl =
332
362
  process.env.TEXTCORTEX_BASE_URL ||
333
- (typeof state.entry.base_url === "string" && state.entry.base_url) ||
363
+ (typeof state.entry.base_url === 'string' && state.entry.base_url) ||
334
364
  baseUrl;
335
365
  if (!resolvedBaseUrl) {
336
- throw new Error("Missing base URL for token refresh.");
366
+ throw new Error('Missing base URL for token refresh.');
337
367
  }
338
368
 
339
369
  const refreshed = await exchangeRefreshToken(resolvedBaseUrl, refreshToken);
@@ -342,7 +372,7 @@ async function refreshStoredCredentials(baseUrl) {
342
372
  base_url: resolvedBaseUrl,
343
373
  access_token: refreshed.access_token,
344
374
  refresh_token:
345
- typeof refreshed.refresh_token === "string" && refreshed.refresh_token
375
+ typeof refreshed.refresh_token === 'string' && refreshed.refresh_token
346
376
  ? refreshed.refresh_token
347
377
  : refreshToken,
348
378
  auth_id: refreshed.auth_id || state.entry.auth_id || null,
@@ -363,7 +393,8 @@ async function refreshStoredCredentials(baseUrl) {
363
393
  }
364
394
 
365
395
  async function resolveToken() {
366
- const envToken = process.env.TEXTCORTEX_API_KEY || process.env.TEXTCORTEX_API_TOKEN;
396
+ const envToken =
397
+ process.env.TEXTCORTEX_API_KEY || process.env.TEXTCORTEX_API_TOKEN;
367
398
  if (envToken) return envToken;
368
399
 
369
400
  for (const filePath of await resolveCredentialFiles()) {
@@ -374,10 +405,10 @@ async function resolveToken() {
374
405
 
375
406
  throw new Error(
376
407
  [
377
- "Missing API token.",
378
- "Run `zenocode login` first,",
379
- "or set TEXTCORTEX_API_KEY / TEXTCORTEX_API_TOKEN.",
380
- ].join(" "),
408
+ 'Missing API token.',
409
+ 'Run `zenocode login` first,',
410
+ 'or set TEXTCORTEX_API_KEY / TEXTCORTEX_API_TOKEN.',
411
+ ].join(' ')
381
412
  );
382
413
  }
383
414
 
@@ -389,34 +420,45 @@ async function resolveStoredBaseUrl() {
389
420
  return baseUrl || null;
390
421
  }
391
422
 
423
+ /** Choose runtime defaults without reviving retired rows from stale catalogs. */
392
424
  export function chooseDefaults(models) {
393
- const ids = Object.keys(models);
394
- if (!ids.length) throw new Error("No models were returned by the backend.");
425
+ const ids = Object.keys(models).filter((id) => !retiredModelIDs.has(id));
426
+ if (!ids.length) throw new Error('No models were returned by the backend.');
395
427
  const model = defaultOrder.find((id) => ids.includes(id)) || ids[0];
396
- const smallModel = ids.find((id) => id.includes("mini") || id.includes("haiku")) || model;
428
+ const smallModel =
429
+ ids.find((id) => id.includes('-mini') || id.includes('haiku')) || model;
397
430
  return { model, smallModel };
398
431
  }
399
432
 
400
- export function buildOpenCodeConfig({ baseUrl, providerID, model, smallModel }) {
433
+ export function buildOpenCodeConfig({
434
+ baseUrl,
435
+ providerID,
436
+ model,
437
+ smallModel,
438
+ }) {
401
439
  return {
402
- $schema: "https://opencode.ai/config.json",
440
+ $schema: 'https://opencode.ai/config.json',
403
441
  enabled_providers: [providerID],
442
+ // Zenocode owns runtime updates. Its source-branded runtime is released with
443
+ // the launcher, so an independently updating runtime would split the release.
444
+ autoupdate: false,
404
445
  // Keep the legacy theme key populated for older OpenCode builds.
405
- theme: "system",
446
+ theme: 'system',
406
447
  model: `${providerID}/${model}`,
407
448
  small_model: `${providerID}/${smallModel}`,
408
449
  provider: {
409
450
  [providerID]: {
410
- name: "Zenocode",
451
+ name: 'Zenocode',
411
452
  options: {
412
- baseURL: new URL("/internal/v2/fastapi/zenocode/v1", baseUrl).toString(),
453
+ baseURL: new URL(
454
+ '/internal/v2/fastapi/zenocode/v1',
455
+ baseUrl
456
+ ).toString(),
413
457
  },
414
458
  },
415
- // Older fallback opencode-ai builds can load the Codex auth plugin when
416
- // a local OpenAI OAuth session exists. They assume `database.openai`
417
- // exists and crash on `provider.models` otherwise, so keep a harmless
418
- // empty provider entry around even though Zenocode only enables
419
- // `textcortex`.
459
+ // The inherited Codex auth plugin assumes `database.openai` exists when a
460
+ // local OpenAI OAuth session is present. Keep a harmless empty entry even
461
+ // though Zenocode enables only `textcortex`.
420
462
  openai: {
421
463
  models: {},
422
464
  },
@@ -426,27 +468,35 @@ export function buildOpenCodeConfig({ baseUrl, providerID, model, smallModel })
426
468
 
427
469
  export function buildOpenCodeTuiConfig() {
428
470
  return {
429
- $schema: "https://opencode.ai/tui.json",
430
- theme: "system",
471
+ $schema: 'https://opencode.ai/tui.json',
472
+ theme: 'system',
431
473
  };
432
474
  }
433
475
 
434
476
  function unwrapData(payload) {
435
- if (payload && typeof payload === "object" && payload.data && typeof payload.data === "object") {
477
+ if (
478
+ payload &&
479
+ typeof payload === 'object' &&
480
+ payload.data &&
481
+ typeof payload.data === 'object'
482
+ ) {
436
483
  return payload.data;
437
484
  }
438
485
  return payload;
439
486
  }
440
487
 
441
488
  function extractErrorMessage(payload, fallbackMessage) {
442
- if (payload && typeof payload === "object") {
489
+ if (payload && typeof payload === 'object') {
443
490
  const errorMessage = payload?.error?.message;
444
- if (typeof errorMessage === "string" && errorMessage) return errorMessage;
445
- if (typeof payload.message === "string" && payload.message) return payload.message;
446
- if (typeof payload.detail === "string" && payload.detail) return payload.detail;
447
- if (payload.detail && typeof payload.detail === "object") {
491
+ if (typeof errorMessage === 'string' && errorMessage) return errorMessage;
492
+ if (typeof payload.message === 'string' && payload.message)
493
+ return payload.message;
494
+ if (typeof payload.detail === 'string' && payload.detail)
495
+ return payload.detail;
496
+ if (payload.detail && typeof payload.detail === 'object') {
448
497
  const detailMessage = payload.detail.message;
449
- if (typeof detailMessage === "string" && detailMessage) return detailMessage;
498
+ if (typeof detailMessage === 'string' && detailMessage)
499
+ return detailMessage;
450
500
  }
451
501
  try {
452
502
  return JSON.stringify(payload);
@@ -470,28 +520,46 @@ async function requestJson(url, init) {
470
520
  }
471
521
 
472
522
  async function prepareRuntime(baseUrl, token) {
473
- const modelsUrl = new URL("/internal/v2/fastapi/zenocode/models/api.json", baseUrl).toString();
523
+ const modelsUrl = new URL(
524
+ '/internal/v2/fastapi/zenocode/models/api.json',
525
+ baseUrl
526
+ ).toString();
474
527
  const { response, payload, text } = await requestJson(modelsUrl, {
475
- headers: { Authorization: `Bearer ${token}`, Accept: "application/json" },
528
+ headers: { Authorization: `Bearer ${token}`, Accept: 'application/json' },
476
529
  });
477
530
 
478
531
  if (!response.ok) {
479
532
  const body = payload ?? text;
480
- const message = extractErrorMessage(payload, String(body || "request failed"));
481
- if (response.status === 401 && message.includes("Email not confirmed")) {
533
+ const message = extractErrorMessage(
534
+ payload,
535
+ String(body || 'request failed')
536
+ );
537
+ if (response.status === 401 && message.includes('Email not confirmed')) {
482
538
  throw new Error(
483
- "Models fetch failed (401): Email is not confirmed for this account. Please verify the email address and try again.",
539
+ 'Models fetch failed (401): Email is not confirmed for this account. Please verify the email address and try again.'
484
540
  );
485
541
  }
486
542
  throw new Error(`Models fetch failed (${response.status}): ${message}`);
487
543
  }
488
544
 
489
545
  const provider = payload?.[providerID];
490
- if (!provider?.models || typeof provider.models !== "object") {
491
- throw new Error("Backend models response is missing textcortex provider data.");
546
+ if (!provider?.models || typeof provider.models !== 'object') {
547
+ throw new Error(
548
+ 'Backend models response is missing textcortex provider data.'
549
+ );
492
550
  }
493
551
 
494
- const { model, smallModel } = chooseDefaults(provider.models);
552
+ const supportedModels = Object.fromEntries(
553
+ Object.entries(provider.models).filter(([id]) => !retiredModelIDs.has(id))
554
+ );
555
+ const supportedPayload = {
556
+ ...payload,
557
+ [providerID]: {
558
+ ...provider,
559
+ models: supportedModels,
560
+ },
561
+ };
562
+ const { model, smallModel } = chooseDefaults(supportedModels);
495
563
  const config = buildOpenCodeConfig({
496
564
  baseUrl,
497
565
  providerID,
@@ -501,9 +569,21 @@ async function prepareRuntime(baseUrl, token) {
501
569
  const tuiConfig = buildOpenCodeTuiConfig();
502
570
 
503
571
  await fs.mkdir(runtimeDir, { recursive: true });
504
- await fs.writeFile(modelsPath, `${JSON.stringify(payload, null, 2)}\n`, "utf-8");
505
- await fs.writeFile(configPath, `${JSON.stringify(config, null, 2)}\n`, "utf-8");
506
- await fs.writeFile(tuiConfigPath, `${JSON.stringify(tuiConfig, null, 2)}\n`, "utf-8");
572
+ await fs.writeFile(
573
+ modelsPath,
574
+ `${JSON.stringify(supportedPayload, null, 2)}\n`,
575
+ 'utf-8'
576
+ );
577
+ await fs.writeFile(
578
+ configPath,
579
+ `${JSON.stringify(config, null, 2)}\n`,
580
+ 'utf-8'
581
+ );
582
+ await fs.writeFile(
583
+ tuiConfigPath,
584
+ `${JSON.stringify(tuiConfig, null, 2)}\n`,
585
+ 'utf-8'
586
+ );
507
587
  return model;
508
588
  }
509
589
 
@@ -512,7 +592,7 @@ function sleep(ms) {
512
592
  }
513
593
 
514
594
  export function hasLocalBaseUrlFlag(args = []) {
515
- const normalizedArgs = args[0] === "--" ? args.slice(1) : args;
595
+ const normalizedArgs = args[0] === '--' ? args.slice(1) : args;
516
596
  return normalizedArgs.some((arg) => localBaseUrlFlags.has(arg));
517
597
  }
518
598
 
@@ -521,7 +601,7 @@ function stripLocalBaseUrlFlags(args = []) {
521
601
  }
522
602
 
523
603
  async function parseLoginArgs(args) {
524
- const normalizedArgs = args[0] === "--" ? args.slice(1) : args;
604
+ const normalizedArgs = args[0] === '--' ? args.slice(1) : args;
525
605
  let emailHint = process.env.TEXTCORTEX_LOGIN_EMAIL || null;
526
606
  let launchBrowser = true;
527
607
 
@@ -530,32 +610,41 @@ async function parseLoginArgs(args) {
530
610
  if (localBaseUrlFlags.has(arg)) {
531
611
  continue;
532
612
  }
533
- if (arg === "--no-launch-browser") {
613
+ if (arg === '--no-launch-browser') {
534
614
  launchBrowser = false;
535
615
  continue;
536
616
  }
537
- if (arg === "--email") {
617
+ if (arg === '--email') {
538
618
  emailHint = normalizedArgs[idx + 1] || null;
539
619
  idx += 1;
540
620
  continue;
541
621
  }
542
- if (arg.startsWith("--email=")) {
543
- emailHint = arg.slice("--email=".length) || null;
622
+ if (arg.startsWith('--email=')) {
623
+ emailHint = arg.slice('--email='.length) || null;
544
624
  continue;
545
625
  }
546
- if (arg === "--help" || arg === "-h") {
547
- console.log("Zenocode login options:");
548
- console.log(" --email <address> Optional email hint for tenant/SSO routing");
549
- console.log(` --local Use the local FastAPI backend at ${localBaseUrlDefault}`);
550
- console.log(" --no-launch-browser Do not open browser automatically");
626
+ if (arg === '--help' || arg === '-h') {
627
+ console.log('Zenocode login options:');
628
+ console.log(
629
+ ' --email <address> Optional email hint for tenant/SSO routing'
630
+ );
631
+ console.log(
632
+ ` --local Use the local FastAPI backend at ${localBaseUrlDefault}`
633
+ );
634
+ console.log(' --no-launch-browser Do not open browser automatically');
551
635
  process.exit(0);
552
636
  }
553
637
  }
554
638
 
555
639
  if (!emailHint && process.stdin.isTTY) {
556
- const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
640
+ const rl = readline.createInterface({
641
+ input: process.stdin,
642
+ output: process.stdout,
643
+ });
557
644
  try {
558
- const response = (await rl.question("Email for tenant routing (optional): ")).trim();
645
+ const response = (
646
+ await rl.question('Email for tenant routing (optional): ')
647
+ ).trim();
559
648
  emailHint = response || null;
560
649
  } finally {
561
650
  rl.close();
@@ -567,15 +656,19 @@ async function parseLoginArgs(args) {
567
656
 
568
657
  function isFetchFailedError(error) {
569
658
  const message = error instanceof Error ? error.message : String(error);
570
- return message.includes("fetch failed");
659
+ return message.includes('fetch failed');
571
660
  }
572
661
 
573
662
  function isLoginRouteNotFoundError(error) {
574
663
  const message = error instanceof Error ? error.message : String(error);
575
- return message.includes("Login initiate failed (404)");
664
+ return message.includes('Login initiate failed (404)');
576
665
  }
577
666
 
578
- export function shouldFallbackLoginToCloud({ baseUrl, hasExplicitBaseUrl, error }) {
667
+ export function shouldFallbackLoginToCloud({
668
+ baseUrl,
669
+ hasExplicitBaseUrl,
670
+ error,
671
+ }) {
579
672
  if (hasExplicitBaseUrl || baseUrl !== localBaseUrlDefault) {
580
673
  return false;
581
674
  }
@@ -608,28 +701,28 @@ function _loginConnectivityHelp(baseUrl) {
608
701
  return [
609
702
  `Cannot reach Zenocode auth endpoint at ${baseUrl}.`,
610
703
  `Set TEXTCORTEX_BASE_URL to ${cloudBaseUrlDefault} or, for local development, run local backend FastAPI (\`cd backend && uv run dev_fastapi\`).`,
611
- ].join(" ");
704
+ ].join(' ');
612
705
  }
613
706
 
614
707
  async function openBrowser(url) {
615
708
  let cmd;
616
709
  let args;
617
- if (process.platform === "darwin") {
618
- cmd = "open";
710
+ if (process.platform === 'darwin') {
711
+ cmd = 'open';
619
712
  args = [url];
620
- } else if (process.platform === "win32") {
621
- cmd = "cmd";
622
- args = ["/c", "start", "", url];
713
+ } else if (process.platform === 'win32') {
714
+ cmd = 'cmd';
715
+ args = ['/c', 'start', '', url];
623
716
  } else {
624
- cmd = "xdg-open";
717
+ cmd = 'xdg-open';
625
718
  args = [url];
626
719
  }
627
720
 
628
721
  return new Promise((resolve) => {
629
722
  try {
630
- const child = spawn(cmd, args, { stdio: "ignore" });
631
- child.on("error", () => resolve(false));
632
- child.on("exit", (code) => resolve(code === 0));
723
+ const child = spawn(cmd, args, { stdio: 'ignore' });
724
+ child.on('error', () => resolve(false));
725
+ child.on('exit', (code) => resolve(code === 0));
633
726
  } catch {
634
727
  resolve(false);
635
728
  }
@@ -640,49 +733,67 @@ async function initiateDeviceLogin(baseUrl, emailHint) {
640
733
  const initiateUrl = new URL(oauthInitiatePath, baseUrl).toString();
641
734
  const body = emailHint ? { email_hint: emailHint } : {};
642
735
  const { response, payload, text } = await requestJson(initiateUrl, {
643
- method: "POST",
644
- headers: { "Content-Type": "application/json", Accept: "application/json" },
736
+ method: 'POST',
737
+ headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
645
738
  body: JSON.stringify(body),
646
739
  });
647
740
 
648
741
  if (!response.ok) {
649
- const message = extractErrorMessage(payload, text || "request failed");
742
+ const message = extractErrorMessage(payload, text || 'request failed');
650
743
  throw new Error(`Login initiate failed (${response.status}): ${message}`);
651
744
  }
652
745
 
653
746
  const data = unwrapData(payload);
654
- if (!data || typeof data !== "object") {
655
- throw new Error("Login initiate failed: invalid response payload.");
747
+ if (!data || typeof data !== 'object') {
748
+ throw new Error('Login initiate failed: invalid response payload.');
656
749
  }
657
750
 
658
- const deviceCode = typeof data.device_code === "string" ? data.device_code : null;
659
- const userCode = typeof data.user_code === "string" ? data.user_code : null;
660
- const verificationUrl = typeof data.verification_url_complete === "string" ? data.verification_url_complete : null;
661
- const interval = Number.isFinite(Number(data.interval)) ? Number(data.interval) : 5;
662
- const expiresIn = Number.isFinite(Number(data.expires_in)) ? Number(data.expires_in) : 600;
751
+ const deviceCode =
752
+ typeof data.device_code === 'string' ? data.device_code : null;
753
+ const userCode = typeof data.user_code === 'string' ? data.user_code : null;
754
+ const verificationUrl =
755
+ typeof data.verification_url_complete === 'string'
756
+ ? data.verification_url_complete
757
+ : null;
758
+ const interval = Number.isFinite(Number(data.interval))
759
+ ? Number(data.interval)
760
+ : 5;
761
+ const expiresIn = Number.isFinite(Number(data.expires_in))
762
+ ? Number(data.expires_in)
763
+ : 600;
663
764
 
664
765
  if (!deviceCode || !userCode || !verificationUrl) {
665
- throw new Error("Login initiate failed: incomplete device authorization payload.");
766
+ throw new Error(
767
+ 'Login initiate failed: incomplete device authorization payload.'
768
+ );
666
769
  }
667
770
 
668
771
  return { deviceCode, userCode, verificationUrl, interval, expiresIn };
669
772
  }
670
773
 
671
- async function pollDeviceToken(baseUrl, deviceCode, intervalSeconds, expiresInSeconds) {
774
+ async function pollDeviceToken(
775
+ baseUrl,
776
+ deviceCode,
777
+ intervalSeconds,
778
+ expiresInSeconds
779
+ ) {
672
780
  const deadline = Date.now() + Math.max(expiresInSeconds, 1) * 1000;
673
781
  const pollUrl = new URL(oauthTokenPath, baseUrl).toString();
674
782
 
675
783
  while (Date.now() <= deadline) {
676
784
  const { response, payload, text } = await requestJson(pollUrl, {
677
- method: "POST",
678
- headers: { "Content-Type": "application/json", Accept: "application/json" },
785
+ method: 'POST',
786
+ headers: {
787
+ 'Content-Type': 'application/json',
788
+ Accept: 'application/json',
789
+ },
679
790
  body: JSON.stringify({ device_code: deviceCode }),
680
791
  });
681
792
 
682
793
  if (response.status === 200) {
683
794
  const data = unwrapData(payload);
684
795
  if (!data?.access_token || !data?.refresh_token) {
685
- throw new Error("Token endpoint returned incomplete credentials.");
796
+ throw new Error('Token endpoint returned incomplete credentials.');
686
797
  }
687
798
  return data;
688
799
  }
@@ -692,11 +803,13 @@ async function pollDeviceToken(baseUrl, deviceCode, intervalSeconds, expiresInSe
692
803
  continue;
693
804
  }
694
805
 
695
- const message = extractErrorMessage(payload, text || "request failed");
696
- throw new Error(`Login token exchange failed (${response.status}): ${message}`);
806
+ const message = extractErrorMessage(payload, text || 'request failed');
807
+ throw new Error(
808
+ `Login token exchange failed (${response.status}): ${message}`
809
+ );
697
810
  }
698
811
 
699
- throw new Error("Device code expired. Please run login again.");
812
+ throw new Error('Device code expired. Please run login again.');
700
813
  }
701
814
 
702
815
  async function saveRuntimeCredentials(baseUrl, tokenData) {
@@ -716,6 +829,7 @@ async function saveRuntimeCredentials(baseUrl, tokenData) {
716
829
  await clearLogoutMarker();
717
830
  }
718
831
 
832
+ /** Complete device login, persist its credentials, and return that exact session. */
719
833
  async function runLoginCommand(baseUrl, args, options = {}) {
720
834
  const preferLocalhost = options.preferLocalhost === true;
721
835
  const { emailHint, launchBrowser } = await parseLoginArgs(args);
@@ -724,15 +838,17 @@ async function runLoginCommand(baseUrl, args, options = {}) {
724
838
  try {
725
839
  login = await initiateDeviceLogin(resolvedBaseUrl, emailHint);
726
840
  } catch (error) {
727
- if (shouldFallbackLoginToCloud({
728
- baseUrl: resolvedBaseUrl,
729
- hasExplicitBaseUrl:
730
- Boolean(process.env.TEXTCORTEX_BASE_URL) || preferLocalhost,
731
- error,
732
- })) {
841
+ if (
842
+ shouldFallbackLoginToCloud({
843
+ baseUrl: resolvedBaseUrl,
844
+ hasExplicitBaseUrl:
845
+ Boolean(process.env.TEXTCORTEX_BASE_URL) || preferLocalhost,
846
+ error,
847
+ })
848
+ ) {
733
849
  resolvedBaseUrl = cloudBaseUrlDefault;
734
850
  console.log(
735
- `Local backend not reachable at ${localBaseUrlDefault}. Falling back to ${cloudBaseUrlDefault}.`,
851
+ `Local backend not reachable at ${localBaseUrlDefault}. Falling back to ${cloudBaseUrlDefault}.`
736
852
  );
737
853
  try {
738
854
  login = await initiateDeviceLogin(resolvedBaseUrl, emailHint);
@@ -753,16 +869,18 @@ async function runLoginCommand(baseUrl, args, options = {}) {
753
869
  }
754
870
  }
755
871
 
756
- console.log("\nComplete authorization in your browser:");
872
+ console.log('\nComplete authorization in your browser:');
757
873
  console.log(` ${login.verificationUrl}`);
758
874
  console.log(`User code: ${login.userCode}\n`);
759
875
 
760
876
  if (launchBrowser) {
761
877
  const launched = await openBrowser(login.verificationUrl);
762
878
  if (!launched) {
763
- console.log("Unable to open a browser automatically. Open the URL above manually.");
879
+ console.log(
880
+ 'Unable to open a browser automatically. Open the URL above manually.'
881
+ );
764
882
  } else {
765
- console.log("Opened browser for authentication.");
883
+ console.log('Opened browser for authentication.');
766
884
  }
767
885
  }
768
886
 
@@ -770,13 +888,14 @@ async function runLoginCommand(baseUrl, args, options = {}) {
770
888
  resolvedBaseUrl,
771
889
  login.deviceCode,
772
890
  login.interval,
773
- login.expiresIn,
891
+ login.expiresIn
774
892
  );
775
893
  await saveRuntimeCredentials(resolvedBaseUrl, tokenData);
776
894
 
777
895
  const account = resolveLoginSuccessIdentifier(tokenData);
778
896
  console.log(`Login successful for ${account}.`);
779
897
  console.log(`Credentials saved to ${runtimeCredentialsPath}`);
898
+ return { token: tokenData.access_token, baseUrl: resolvedBaseUrl };
780
899
  }
781
900
 
782
901
  async function runLogoutCommand() {
@@ -786,7 +905,7 @@ async function runLogoutCommand() {
786
905
  await fs.unlink(credentialsPath);
787
906
  removedAnyCredentials = true;
788
907
  } catch (error) {
789
- if (error?.code === "ENOENT") {
908
+ if (error?.code === 'ENOENT') {
790
909
  continue;
791
910
  }
792
911
  throw error;
@@ -795,25 +914,27 @@ async function runLogoutCommand() {
795
914
 
796
915
  const fallbackCredentialsPresent = (
797
916
  await Promise.all(
798
- fallbackCredentialFiles.map((credentialsPath) => _pathExists(credentialsPath)),
917
+ fallbackCredentialFiles.map((credentialsPath) =>
918
+ _pathExists(credentialsPath)
919
+ )
799
920
  )
800
921
  ).some(Boolean);
801
922
 
802
923
  if (removedAnyCredentials || fallbackCredentialsPresent) {
803
924
  await writeLogoutMarker();
804
- console.log("Zenocode session cleared.");
925
+ console.log('Zenocode session cleared.');
805
926
  return;
806
927
  }
807
928
 
808
- console.log("No local Zenocode credentials found.");
929
+ console.log('No local Zenocode credentials found.');
809
930
  }
810
931
 
811
932
  async function runChild(command, args, options) {
812
933
  return new Promise((resolve, reject) => {
813
934
  try {
814
935
  const child = spawn(command, args, options);
815
- child.on("error", reject);
816
- child.on("exit", (code, signal) => resolve({ code, signal }));
936
+ child.on('error', reject);
937
+ child.on('exit', (code, signal) => resolve({ code, signal }));
817
938
  } catch (error) {
818
939
  reject(error);
819
940
  }
@@ -821,19 +942,19 @@ async function runChild(command, args, options) {
821
942
  }
822
943
 
823
944
  function _buildScriptCommand(runtimeBinaryPath, args, transcriptPath) {
824
- if (process.platform === "darwin") {
945
+ if (process.platform === 'darwin') {
825
946
  return {
826
- command: "script",
827
- args: ["-q", "-F", "-t", "0", transcriptPath, runtimeBinaryPath, ...args],
947
+ command: 'script',
948
+ args: ['-q', '-F', '-t', '0', transcriptPath, runtimeBinaryPath, ...args],
828
949
  };
829
950
  }
830
- if (process.platform === "linux") {
951
+ if (process.platform === 'linux') {
831
952
  const shellCommand = [runtimeBinaryPath, ...args]
832
953
  .map((value) => `'${String(value).replaceAll("'", `'\\''`)}'`)
833
- .join(" ");
954
+ .join(' ');
834
955
  return {
835
- command: "script",
836
- args: ["-q", "-f", "-c", shellCommand, transcriptPath],
956
+ command: 'script',
957
+ args: ['-q', '-f', '-c', shellCommand, transcriptPath],
837
958
  };
838
959
  }
839
960
  return null;
@@ -842,10 +963,10 @@ function _buildScriptCommand(runtimeBinaryPath, args, transcriptPath) {
842
963
  async function _readTranscriptUpdate(transcriptPath, offset) {
843
964
  let handle;
844
965
  try {
845
- handle = await fs.open(transcriptPath, "r");
966
+ handle = await fs.open(transcriptPath, 'r');
846
967
  } catch (error) {
847
- if (error?.code === "ENOENT") {
848
- return { offset, text: "" };
968
+ if (error?.code === 'ENOENT') {
969
+ return { offset, text: '' };
849
970
  }
850
971
  throw error;
851
972
  }
@@ -853,13 +974,13 @@ async function _readTranscriptUpdate(transcriptPath, offset) {
853
974
  try {
854
975
  const stats = await handle.stat();
855
976
  if (stats.size <= offset) {
856
- return { offset, text: "" };
977
+ return { offset, text: '' };
857
978
  }
858
979
 
859
980
  const length = stats.size - offset;
860
981
  const buffer = Buffer.alloc(length);
861
982
  await handle.read(buffer, 0, length, offset);
862
- return { offset: stats.size, text: buffer.toString("utf-8") };
983
+ return { offset: stats.size, text: buffer.toString('utf-8') };
863
984
  } finally {
864
985
  await handle.close();
865
986
  }
@@ -870,44 +991,61 @@ async function _terminateChildProcess(child, exitPromise) {
870
991
  return exitPromise;
871
992
  }
872
993
 
873
- child.kill("SIGTERM");
874
- const exited = await Promise.race([exitPromise, sleep(1_500).then(() => null)]);
994
+ child.kill('SIGTERM');
995
+ const exited = await Promise.race([
996
+ exitPromise,
997
+ sleep(1_500).then(() => null),
998
+ ]);
875
999
  if (exited) {
876
1000
  return exited;
877
1001
  }
878
1002
 
879
- child.kill("SIGKILL");
1003
+ child.kill('SIGKILL');
880
1004
  return exitPromise;
881
1005
  }
882
1006
 
883
- async function _runRuntimeWithTranscriptMonitor(runtimeBinaryPath, args, options) {
884
- const transcriptDir = await fs.mkdtemp(path.join(os.tmpdir(), "zenocode-runtime-"));
885
- const transcriptPath = path.join(transcriptDir, "session.log");
1007
+ async function _runRuntimeWithTranscriptMonitor(
1008
+ runtimeBinaryPath,
1009
+ args,
1010
+ options
1011
+ ) {
1012
+ const transcriptDir = await fs.mkdtemp(
1013
+ path.join(os.tmpdir(), 'zenocode-runtime-')
1014
+ );
1015
+ const transcriptPath = path.join(transcriptDir, 'session.log');
886
1016
  const command = _buildScriptCommand(runtimeBinaryPath, args, transcriptPath);
887
1017
  if (!command) {
888
- const result = await runChild(runtimeBinaryPath, args, { ...options, stdio: "inherit" });
1018
+ const result = await runChild(runtimeBinaryPath, args, {
1019
+ ...options,
1020
+ stdio: 'inherit',
1021
+ });
889
1022
  return { ...result, expiredSession: false };
890
1023
  }
891
1024
 
892
1025
  try {
893
1026
  const child = spawn(command.command, command.args, {
894
1027
  ...options,
895
- stdio: "inherit",
1028
+ stdio: 'inherit',
896
1029
  });
897
1030
  const exitPromise = new Promise((resolve, reject) => {
898
- child.on("error", reject);
899
- child.on("exit", (code, signal) => resolve({ code, signal, expiredSession: false }));
1031
+ child.on('error', reject);
1032
+ child.on('exit', (code, signal) =>
1033
+ resolve({ code, signal, expiredSession: false })
1034
+ );
900
1035
  });
901
1036
  let transcriptOffset = 0;
902
- let transcriptTail = "";
1037
+ let transcriptTail = '';
903
1038
 
904
1039
  while (true) {
905
1040
  const outcome = await Promise.race([
906
- exitPromise.then((result) => ({ type: "exit", result })),
907
- sleep(250).then(() => ({ type: "poll" })),
1041
+ exitPromise.then((result) => ({ type: 'exit', result })),
1042
+ sleep(250).then(() => ({ type: 'poll' })),
908
1043
  ]);
909
1044
 
910
- const update = await _readTranscriptUpdate(transcriptPath, transcriptOffset);
1045
+ const update = await _readTranscriptUpdate(
1046
+ transcriptPath,
1047
+ transcriptOffset
1048
+ );
911
1049
  transcriptOffset = update.offset;
912
1050
  if (update.text) {
913
1051
  transcriptTail = _appendTranscriptTail(transcriptTail, update.text);
@@ -918,15 +1056,18 @@ async function _runRuntimeWithTranscriptMonitor(runtimeBinaryPath, args, options
918
1056
  return { expiredSession: true };
919
1057
  }
920
1058
 
921
- if (outcome.type === "exit") {
1059
+ if (outcome.type === 'exit') {
922
1060
  return canRecoverRuntimeSessionFromTranscript(transcriptTail)
923
1061
  ? { expiredSession: true }
924
1062
  : outcome.result;
925
1063
  }
926
1064
  }
927
1065
  } catch (error) {
928
- if (error?.code === "ENOENT") {
929
- const result = await runChild(runtimeBinaryPath, args, { ...options, stdio: "inherit" });
1066
+ if (error?.code === 'ENOENT') {
1067
+ const result = await runChild(runtimeBinaryPath, args, {
1068
+ ...options,
1069
+ stdio: 'inherit',
1070
+ });
930
1071
  return { ...result, expiredSession: false };
931
1072
  }
932
1073
  throw error;
@@ -935,9 +1076,17 @@ async function _runRuntimeWithTranscriptMonitor(runtimeBinaryPath, args, options
935
1076
  }
936
1077
  }
937
1078
 
938
- async function runRuntimeBinary(runtimeBinaryPath, args, options, monitorRuntimeSession) {
1079
+ async function runRuntimeBinary(
1080
+ runtimeBinaryPath,
1081
+ args,
1082
+ options,
1083
+ monitorRuntimeSession
1084
+ ) {
939
1085
  if (!monitorRuntimeSession) {
940
- const result = await runChild(runtimeBinaryPath, args, { ...options, stdio: "inherit" });
1086
+ const result = await runChild(runtimeBinaryPath, args, {
1087
+ ...options,
1088
+ stdio: 'inherit',
1089
+ });
941
1090
  return { ...result, expiredSession: false };
942
1091
  }
943
1092
  return _runRuntimeWithTranscriptMonitor(runtimeBinaryPath, args, options);
@@ -951,396 +1100,106 @@ function exitWithChildResult(result) {
951
1100
  process.exit(result.code ?? 0);
952
1101
  }
953
1102
 
954
- function _runnerCommand(command) {
955
- return process.platform === "win32" ? `${command}.cmd` : command;
956
- }
957
-
958
- function _runtimeBrandedBinaryPath() {
959
- const binaryName = process.platform === "win32" ? "opencode-runtime.exe" : "opencode-runtime";
960
- return path.join(runtimeDir, "bin", binaryName);
961
- }
962
-
963
- export function packageNameFromSpecifier(packageSpecifier) {
964
- if (!packageSpecifier.includes("@")) {
965
- return packageSpecifier;
966
- }
967
- if (!packageSpecifier.startsWith("@")) {
968
- return packageSpecifier.split("@")[0];
969
- }
970
-
971
- const versionSeparator = packageSpecifier.lastIndexOf("@");
972
- return versionSeparator > packageSpecifier.indexOf("/")
973
- ? packageSpecifier.slice(0, versionSeparator)
974
- : packageSpecifier;
975
- }
976
-
977
- function shouldPatchOpencodeRuntimePackage(packageName) {
978
- const runtimePackageName = packageNameFromSpecifier(packageName);
979
- return (
980
- runtimePackageName.endsWith("/opencode-ai") ||
981
- runtimePackageName.endsWith("/zenocode-ai") ||
982
- runtimePackageName === openCodePackageName
983
- );
984
- }
985
-
986
- async function _pathExists(pathToCheck) {
987
- try {
988
- await fs.access(pathToCheck);
989
- return true;
990
- } catch {
991
- return false;
992
- }
993
- }
994
-
995
- async function _collectPnpmDlxPnpmDirs(rootDir) {
996
- const queue = [{ dir: rootDir, depth: 0 }];
997
- const pnpmDirs = [];
998
-
999
- while (queue.length) {
1000
- const current = queue.shift();
1001
- const nodeModulesPnpm = path.join(current.dir, "node_modules", ".pnpm");
1002
- if (await _pathExists(nodeModulesPnpm)) {
1003
- pnpmDirs.push(nodeModulesPnpm);
1004
- }
1005
-
1006
- if (current.depth >= 3) continue;
1007
- let entries = [];
1008
- try {
1009
- entries = await fs.readdir(current.dir, { withFileTypes: true });
1010
- } catch {
1011
- continue;
1012
- }
1013
- for (const entry of entries) {
1014
- if (!entry.isDirectory()) continue;
1015
- if (entry.name === "node_modules") continue;
1016
- queue.push({ dir: path.join(current.dir, entry.name), depth: current.depth + 1 });
1017
- }
1018
- }
1019
-
1020
- return pnpmDirs;
1021
- }
1022
-
1023
- async function _collectNodeModulesDirs(rootDir) {
1024
- const queue = [{ dir: rootDir, depth: 0 }];
1025
- const nodeModulesDirs = [];
1026
-
1027
- while (queue.length) {
1028
- const current = queue.shift();
1029
- const nodeModulesPath = path.join(current.dir, "node_modules");
1030
- if (await _pathExists(nodeModulesPath)) {
1031
- nodeModulesDirs.push(nodeModulesPath);
1032
- }
1033
-
1034
- if (current.depth >= 3) continue;
1035
- let entries = [];
1036
- try {
1037
- entries = await fs.readdir(current.dir, { withFileTypes: true });
1038
- } catch {
1039
- continue;
1040
- }
1041
- for (const entry of entries) {
1042
- if (!entry.isDirectory()) continue;
1043
- if (entry.name === "node_modules") continue;
1044
- queue.push({ dir: path.join(current.dir, entry.name), depth: current.depth + 1 });
1045
- }
1046
- }
1047
-
1048
- return nodeModulesDirs;
1049
- }
1050
-
1051
- async function _collectOpencodeBinariesFromNodeModules(nodeModulesPath) {
1052
- const binaryName = process.platform === "win32" ? "opencode.exe" : "opencode";
1053
- const hiddenCachedBinaryName = ".opencode";
1054
- const candidates = [];
1055
-
1056
- let topLevel = [];
1057
- try {
1058
- topLevel = await fs.readdir(nodeModulesPath, { withFileTypes: true });
1059
- } catch {
1060
- return candidates;
1061
- }
1062
-
1063
- for (const moduleEntry of topLevel) {
1064
- if (!moduleEntry.isDirectory()) continue;
1065
-
1066
- if (moduleEntry.name === openCodePackageName) {
1067
- const cachedPath = path.join(nodeModulesPath, openCodePackageName, "bin", hiddenCachedBinaryName);
1068
- if (await _pathExists(cachedPath)) candidates.push(cachedPath);
1069
- continue;
1070
- }
1071
-
1072
- if (moduleEntry.name.startsWith("opencode-")) {
1073
- const binaryPath = path.join(nodeModulesPath, moduleEntry.name, "bin", binaryName);
1074
- if (await _pathExists(binaryPath)) candidates.push(binaryPath);
1075
- continue;
1076
- }
1077
-
1078
- if (!moduleEntry.name.startsWith("@")) continue;
1079
- const scopePath = path.join(nodeModulesPath, moduleEntry.name);
1080
- let scopedPackages = [];
1081
- try {
1082
- scopedPackages = await fs.readdir(scopePath, { withFileTypes: true });
1083
- } catch {
1084
- continue;
1085
- }
1086
- for (const scopedPackage of scopedPackages) {
1087
- if (!scopedPackage.isDirectory()) continue;
1088
- const scopedNodeModulesPath = path.join(scopePath, scopedPackage.name);
1089
-
1090
- if (scopedPackage.name === "opencode-ai") {
1091
- const cachedPath = path.join(scopedNodeModulesPath, "bin", hiddenCachedBinaryName);
1092
- if (await _pathExists(cachedPath)) candidates.push(cachedPath);
1093
- continue;
1094
- }
1095
-
1096
- if (!scopedPackage.name.startsWith("opencode-")) continue;
1097
- const binaryPath = path.join(scopedNodeModulesPath, "bin", binaryName);
1098
- if (await _pathExists(binaryPath)) candidates.push(binaryPath);
1099
- }
1103
+ function _windowsNpxCliCandidates(execPath, env) {
1104
+ const candidates = [
1105
+ path.win32.join(
1106
+ path.win32.dirname(execPath),
1107
+ 'node_modules',
1108
+ 'npm',
1109
+ 'bin',
1110
+ 'npx-cli.js'
1111
+ ),
1112
+ ];
1113
+ const npmExecPath = env.npm_execpath || '';
1114
+ if (/npm-cli\.js$/i.test(npmExecPath)) {
1115
+ candidates.push(
1116
+ path.win32.join(path.win32.dirname(npmExecPath), 'npx-cli.js')
1117
+ );
1100
1118
  }
1101
-
1102
1119
  return candidates;
1103
1120
  }
1104
1121
 
1105
- async function _collectPnpmDlxOpencodeBinaries() {
1106
- const dlxRoots = [
1107
- path.join(os.homedir(), "Library", "Caches", "pnpm", "dlx"),
1108
- path.join(os.homedir(), ".cache", "pnpm", "dlx"),
1109
- path.join(process.env.LOCALAPPDATA || "", "pnpm", "dlx"),
1110
- ].filter(Boolean);
1111
-
1112
- const candidates = [];
1113
-
1114
- for (const root of dlxRoots) {
1115
- if (!(await _pathExists(root))) continue;
1116
- const pnpmDirs = await _collectPnpmDlxPnpmDirs(root);
1117
- for (const pnpmDir of pnpmDirs) {
1118
- let entries = [];
1119
- try {
1120
- entries = await fs.readdir(pnpmDir, { withFileTypes: true });
1121
- } catch {
1122
- continue;
1123
- }
1124
-
1125
- for (const entry of entries) {
1126
- if (!entry.isDirectory()) continue;
1127
- const nodeModulesPath = path.join(pnpmDir, entry.name, "node_modules");
1128
- if (!(await _pathExists(nodeModulesPath))) continue;
1129
-
1130
- candidates.push(...await _collectOpencodeBinariesFromNodeModules(nodeModulesPath));
1131
- }
1132
- }
1133
- }
1134
-
1135
- return [...new Set(candidates)];
1136
- }
1137
-
1138
- async function _collectNpxOpencodeBinaries() {
1139
- const npxRoots = [
1140
- path.join(os.homedir(), ".npm", "_npx"),
1141
- path.join(process.env.LOCALAPPDATA || "", "npm-cache", "_npx"),
1142
- ].filter(Boolean);
1143
- const candidates = [];
1144
-
1145
- for (const root of npxRoots) {
1146
- if (!(await _pathExists(root))) continue;
1147
- const nodeModulesDirs = await _collectNodeModulesDirs(root);
1148
- for (const nodeModulesDir of nodeModulesDirs) {
1149
- candidates.push(...await _collectOpencodeBinariesFromNodeModules(nodeModulesDir));
1150
- }
1151
- }
1152
-
1153
- return [...new Set(candidates)];
1154
- }
1155
-
1156
- function _buildPackageWarmupArgs(packageName, runner) {
1157
- if (runner.command === _runnerCommand("pnpm")) {
1158
- return ["dlx", packageName, "--version"];
1159
- }
1160
- if (runner.command === _runnerCommand("npx")) {
1161
- return ["--yes", packageName, "--version"];
1162
- }
1163
- return null;
1164
- }
1165
-
1166
- async function _collectRunnerOpencodeBinaries(runner) {
1167
- if (runner.command === _runnerCommand("pnpm")) {
1168
- return _collectPnpmDlxOpencodeBinaries();
1169
- }
1170
- if (runner.command === _runnerCommand("npx")) {
1171
- return _collectNpxOpencodeBinaries();
1122
+ /**
1123
+ * Return the package runners to try, in order, for launching `packageName`.
1124
+ *
1125
+ * On Windows, pnpm/npx are installed as `.cmd` shims, and Node >= 18.20.2 /
1126
+ * 20.12.2 refuses to spawn batch files without a shell (CVE-2024-27980),
1127
+ * failing with `spawn EINVAL`. Windows runners are therefore real executables:
1128
+ * the standalone `pnpm.exe`, then npm's `npx-cli.js` run through the current
1129
+ * Node binary. A shell is deliberately avoided so user arguments never pass
1130
+ * through cmd.exe parsing.
1131
+ */
1132
+ export function resolvePackageRunners(
1133
+ packageName,
1134
+ args,
1135
+ {
1136
+ platform = process.platform,
1137
+ execPath = process.execPath,
1138
+ env = process.env,
1139
+ fileExists = existsSync,
1140
+ } = {}
1141
+ ) {
1142
+ if (platform !== 'win32') {
1143
+ return [
1144
+ { command: 'pnpm', args: ['dlx', packageName, ...args] },
1145
+ { command: 'npx', args: ['--yes', packageName, ...args] },
1146
+ ];
1172
1147
  }
1173
- return [];
1174
- }
1175
1148
 
1176
- async function _adHocSignBinary(binaryPath) {
1177
- if (process.platform !== "darwin") return;
1178
- try {
1179
- await runChild("codesign", ["--force", "--sign", "-", binaryPath], {
1180
- stdio: "ignore",
1149
+ const runners = [
1150
+ { command: 'pnpm.exe', args: ['dlx', packageName, ...args] },
1151
+ ];
1152
+ const npxCli = _windowsNpxCliCandidates(execPath, env).find((candidate) =>
1153
+ fileExists(candidate)
1154
+ );
1155
+ if (npxCli) {
1156
+ runners.push({
1157
+ command: execPath,
1158
+ args: [npxCli, '--yes', packageName, ...args],
1181
1159
  });
1182
- } catch {
1183
- // If ad-hoc signing fails, keep going; runtime may still work on systems that do not enforce signatures.
1184
- }
1185
- }
1186
-
1187
- async function _patchOpencodeBinaryBranding(binaryPath) {
1188
- let buffer;
1189
- try {
1190
- buffer = await fs.readFile(binaryPath);
1191
- } catch {
1192
- return false;
1193
- }
1194
-
1195
- const originalLength = buffer.length;
1196
- let text = buffer.toString("latin1");
1197
- const patch = patchZenocodeBinaryText(text);
1198
- text = patch.text;
1199
- const patched = patch.patched;
1200
-
1201
- if (!patched) {
1202
- return false;
1203
- }
1204
-
1205
- buffer = Buffer.from(text, "latin1");
1206
- if (buffer.length !== originalLength) {
1207
- return false;
1208
- }
1209
-
1210
- await fs.writeFile(binaryPath, buffer);
1211
- if (process.platform !== "win32") {
1212
- await fs.chmod(binaryPath, 0o755);
1213
- }
1214
- await _adHocSignBinary(binaryPath);
1215
- return true;
1216
- }
1217
-
1218
- async function _preparePinnedRuntimeBinary(binaryCandidates) {
1219
- if (!binaryCandidates.length) return null;
1220
- const runtimeBinaryPath = _runtimeBrandedBinaryPath();
1221
- const stats = await Promise.all(binaryCandidates.map(async (binaryPath) => ({
1222
- binaryPath,
1223
- mtimeMs: (await fs.stat(binaryPath)).mtimeMs,
1224
- })));
1225
- stats.sort((a, b) => b.mtimeMs - a.mtimeMs);
1226
-
1227
- await fs.mkdir(path.dirname(runtimeBinaryPath), { recursive: true });
1228
- await fs.copyFile(stats[0].binaryPath, runtimeBinaryPath);
1229
- if (process.platform !== "win32") {
1230
- await fs.chmod(runtimeBinaryPath, 0o755);
1231
- }
1232
- await _patchOpencodeBinaryBranding(runtimeBinaryPath);
1233
- await _adHocSignBinary(runtimeBinaryPath);
1234
- return runtimeBinaryPath;
1235
- }
1236
-
1237
- async function _ensurePatchedOpencodeDlxBinaries(packageName, runner, options) {
1238
- if (!shouldPatchOpencodeRuntimePackage(packageName)) {
1239
- return null;
1240
- }
1241
- if (
1242
- process.env.ZENOCODE_DISABLE_OPENCODE_LOGO_PATCH === "1" ||
1243
- process.env.CODECORTEX_DISABLE_OPENCODE_LOGO_PATCH === "1"
1244
- ) {
1245
- return null;
1246
- }
1247
-
1248
- const warmupArgs = _buildPackageWarmupArgs(packageName, runner);
1249
- try {
1250
- if (warmupArgs) {
1251
- await runChild(runner.command, warmupArgs, {
1252
- ...options,
1253
- stdio: "ignore",
1254
- });
1255
- }
1256
- } catch {
1257
- // ignore warm-up failures and continue with best effort patching
1258
1160
  }
1259
- const binaryCandidates = await _collectRunnerOpencodeBinaries(runner);
1260
-
1261
- if (!binaryCandidates.length) {
1262
- return null;
1263
- }
1264
-
1265
- let patchedCount = 0;
1266
- for (const binaryPath of binaryCandidates) {
1267
- const patched = await _patchOpencodeBinaryBranding(binaryPath);
1268
- if (patched) patchedCount += 1;
1269
- }
1270
-
1271
- if (patchedCount > 0) {
1272
- console.log(`Patched Zenocode branding in ${patchedCount} OpenCode runtime ${patchedCount > 1 ? "binaries" : "binary"}.`);
1273
- }
1274
-
1275
- return _preparePinnedRuntimeBinary(binaryCandidates);
1161
+ return runners;
1276
1162
  }
1277
1163
 
1278
- /**
1279
- * Mirror direct runtime launches so fallback package runners stay interactive.
1280
- */
1281
- export function buildPackageLauncherChildOptions(options, pinnedRuntimePath) {
1164
+ export function buildPackageLauncherChildOptions(options) {
1282
1165
  return {
1283
1166
  ...options,
1284
- stdio: "inherit",
1167
+ stdio: 'inherit',
1285
1168
  env: {
1286
1169
  ...(options.env || {}),
1287
- ...(pinnedRuntimePath ? { OPENCODE_BIN_PATH: pinnedRuntimePath } : {}),
1288
1170
  },
1289
1171
  };
1290
1172
  }
1291
1173
 
1292
- export function buildPackageLauncherInvocation({ runner, args, pinnedRuntimePath }) {
1293
- if (pinnedRuntimePath) {
1294
- return {
1295
- command: pinnedRuntimePath,
1296
- args,
1297
- usePinnedRuntime: true,
1298
- };
1299
- }
1300
-
1174
+ export function buildPackageLauncherInvocation({ runner }) {
1301
1175
  return {
1302
1176
  command: runner.command,
1303
1177
  args: runner.args,
1304
- usePinnedRuntime: false,
1305
1178
  };
1306
1179
  }
1307
1180
 
1308
- async function runPackageLauncher(packageName, args, options) {
1309
- const runners = [
1310
- { command: _runnerCommand("pnpm"), args: ["dlx", packageName, ...args] },
1311
- { command: _runnerCommand("npx"), args: ["--yes", packageName, ...args] },
1312
- ];
1181
+ async function runPackageLauncher(
1182
+ packageName,
1183
+ args,
1184
+ options,
1185
+ monitorRuntimeSession = false
1186
+ ) {
1187
+ const runners = resolvePackageRunners(packageName, args);
1313
1188
  const missingRunners = [];
1314
1189
 
1315
1190
  for (const runner of runners) {
1316
1191
  try {
1317
- let pinnedRuntimePath = await _ensurePatchedOpencodeDlxBinaries(packageName, runner, options);
1318
- if (!pinnedRuntimePath && shouldPatchOpencodeRuntimePackage(packageName)) {
1319
- const existingPinnedPath = _runtimeBrandedBinaryPath();
1320
- if (await _pathExists(existingPinnedPath)) {
1321
- pinnedRuntimePath = existingPinnedPath;
1322
- }
1323
- }
1324
-
1325
1192
  const invocation = buildPackageLauncherInvocation({
1326
1193
  runner,
1327
- args,
1328
- pinnedRuntimePath,
1329
1194
  });
1330
- const childOptions = buildPackageLauncherChildOptions(
1331
- options,
1332
- invocation.usePinnedRuntime ? null : pinnedRuntimePath,
1195
+ return await runRuntimeBinary(
1196
+ invocation.command,
1197
+ invocation.args,
1198
+ buildPackageLauncherChildOptions(options),
1199
+ monitorRuntimeSession
1333
1200
  );
1334
-
1335
- const result = await runChild(invocation.command, invocation.args, childOptions);
1336
- if (result.signal) {
1337
- process.kill(process.pid, result.signal);
1338
- return;
1339
- }
1340
- process.exit(result.code ?? 0);
1341
- return;
1342
1201
  } catch (error) {
1343
- if (error?.code === "ENOENT") {
1202
+ if (error?.code === 'ENOENT') {
1344
1203
  missingRunners.push(runner.command);
1345
1204
  continue;
1346
1205
  }
@@ -1349,48 +1208,11 @@ async function runPackageLauncher(packageName, args, options) {
1349
1208
  }
1350
1209
 
1351
1210
  throw new Error(
1352
- `No package runner found (${missingRunners.join(", ")}). Install pnpm or npm, or set ZENOCODE_OPENCODE_BIN_PATH/CODECORTEX_OPENCODE_BIN_PATH.`,
1211
+ `No package runner found (${missingRunners.join(', ')}). Install pnpm or npm, or set ZENOCODE_OPENCODE_BIN_PATH/CODECORTEX_OPENCODE_BIN_PATH.`
1353
1212
  );
1354
1213
  }
1355
1214
 
1356
- async function resolvePinnedRuntimeBinary(packageName, options) {
1357
- const runners = [
1358
- { command: _runnerCommand("pnpm"), args: ["dlx", packageName] },
1359
- { command: _runnerCommand("npx"), args: ["--yes", packageName] },
1360
- ];
1361
- const missingRunners = [];
1362
-
1363
- for (const runner of runners) {
1364
- try {
1365
- let pinnedRuntimePath = await _ensurePatchedOpencodeDlxBinaries(packageName, runner, options);
1366
- if (!pinnedRuntimePath && shouldPatchOpencodeRuntimePackage(packageName)) {
1367
- const existingPinnedPath = _runtimeBrandedBinaryPath();
1368
- if (await _pathExists(existingPinnedPath)) {
1369
- pinnedRuntimePath = existingPinnedPath;
1370
- }
1371
- }
1372
-
1373
- if (pinnedRuntimePath) {
1374
- return pinnedRuntimePath;
1375
- }
1376
- } catch (error) {
1377
- if (error?.code === "ENOENT") {
1378
- missingRunners.push(runner.command);
1379
- continue;
1380
- }
1381
- throw error;
1382
- }
1383
- }
1384
-
1385
- if (missingRunners.length === runners.length) {
1386
- throw new Error(
1387
- `No package runner found (${missingRunners.join(", ")}). Install pnpm or npm, or set ZENOCODE_OPENCODE_BIN_PATH/CODECORTEX_OPENCODE_BIN_PATH.`,
1388
- );
1389
- }
1390
-
1391
- return null;
1392
- }
1393
-
1215
+ /** Relaunch the runtime with a refreshed or newly issued session after auth expiry. */
1394
1216
  export async function runRuntimeWithSessionRecovery({
1395
1217
  args,
1396
1218
  baseUrl,
@@ -1431,7 +1253,7 @@ export async function runRuntimeWithSessionRecovery({
1431
1253
 
1432
1254
  if (recoveryAttempts >= maxRecoveryAttempts) {
1433
1255
  throw new Error(
1434
- `Zenocode session recovery failed after ${maxRecoveryAttempts} attempts. Run \`zenocode login\` and try again.`,
1256
+ `Zenocode session recovery failed after ${maxRecoveryAttempts} attempts. Run \`zenocode login\` and try again.`
1435
1257
  );
1436
1258
  }
1437
1259
  recoveryAttempts += 1;
@@ -1443,7 +1265,7 @@ export async function runRuntimeWithSessionRecovery({
1443
1265
  await prepareRuntimeFn(activeBaseUrl, activeToken);
1444
1266
  const retryDelayMs = Math.min(
1445
1267
  Math.max(recoveryDelayMs, 0) * recoveryAttempts,
1446
- 3_000,
1268
+ 3_000
1447
1269
  );
1448
1270
  if (retryDelayMs > 0) {
1449
1271
  await sleepFn(retryDelayMs);
@@ -1455,21 +1277,30 @@ export async function runRuntimeWithSessionRecovery({
1455
1277
  }
1456
1278
  }
1457
1279
 
1458
- console.log("Zenocode session expired. Starting login flow...\n");
1459
- await runLogin(activeBaseUrl, buildAutoLoginArgs({ preferLocalhost }), {
1460
- preferLocalhost,
1461
- });
1462
- activeToken = await resolveTokenFn();
1463
- activeBaseUrl =
1464
- resolveTextCortexBaseUrl({
1465
- envBaseUrl: process.env.TEXTCORTEX_BASE_URL,
1466
- storedBaseUrl: await resolveStoredBaseUrlFn(),
1280
+ console.log('Zenocode session expired. Starting login flow...\n');
1281
+ const loginResult = await runLogin(
1282
+ activeBaseUrl,
1283
+ buildAutoLoginArgs({ preferLocalhost }),
1284
+ {
1467
1285
  preferLocalhost,
1468
- }) || activeBaseUrl;
1286
+ }
1287
+ );
1288
+ if (loginResult?.token && loginResult?.baseUrl) {
1289
+ activeToken = loginResult.token;
1290
+ activeBaseUrl = loginResult.baseUrl;
1291
+ } else {
1292
+ activeToken = await resolveTokenFn();
1293
+ activeBaseUrl =
1294
+ resolveTextCortexBaseUrl({
1295
+ envBaseUrl: process.env.TEXTCORTEX_BASE_URL,
1296
+ storedBaseUrl: await resolveStoredBaseUrlFn(),
1297
+ preferLocalhost,
1298
+ }) || activeBaseUrl;
1299
+ }
1469
1300
  await prepareRuntimeFn(activeBaseUrl, activeToken);
1470
1301
  const retryDelayMs = Math.min(
1471
1302
  Math.max(recoveryDelayMs, 0) * recoveryAttempts,
1472
- 3_000,
1303
+ 3_000
1473
1304
  );
1474
1305
  if (retryDelayMs > 0) {
1475
1306
  await sleepFn(retryDelayMs);
@@ -1477,71 +1308,57 @@ export async function runRuntimeWithSessionRecovery({
1477
1308
  }
1478
1309
  }
1479
1310
 
1480
- async function packageExistsOnNpm(packageName) {
1481
- const packageSpecName = packageNameFromSpecifier(packageName);
1482
- const controller = new AbortController();
1483
- const timeout = setTimeout(() => controller.abort(), 4_000);
1484
- try {
1485
- const response = await fetch(`https://registry.npmjs.org/${encodeURIComponent(packageSpecName)}`, {
1486
- method: "GET",
1487
- headers: { Accept: "application/json" },
1488
- signal: controller.signal,
1489
- });
1490
- return response.ok;
1491
- } catch {
1492
- return false;
1493
- } finally {
1494
- clearTimeout(timeout);
1495
- }
1311
+ export function resolveLaunchPackage() {
1312
+ return opencodePackage;
1496
1313
  }
1497
1314
 
1498
- async function resolveLaunchPackage() {
1499
- if (configuredOpencodePackage) {
1500
- return configuredOpencodePackage;
1315
+ /** Return launcher-owned guidance for runtime package-management commands. */
1316
+ export function zenocodeOwnedCommandMessage(args) {
1317
+ if (args[0] === 'upgrade') {
1318
+ return [
1319
+ 'Zenocode updates are managed by the @textcortex/zenocode package.',
1320
+ 'Run `npm install -g @textcortex/zenocode@latest` to update Zenocode and its managed runtime together.',
1321
+ ].join('\n');
1501
1322
  }
1502
-
1503
- const brandedAvailable = await packageExistsOnNpm(defaultBrandedOpencodePackage);
1504
- if (brandedAvailable) {
1505
- return defaultBrandedOpencodePackage;
1323
+ if (args[0] === 'uninstall') {
1324
+ return 'Run `npm uninstall -g @textcortex/zenocode` to uninstall Zenocode.';
1506
1325
  }
1507
-
1508
- const legacyBrandedAvailable = await packageExistsOnNpm(
1509
- legacyBrandedOpencodePackage,
1510
- );
1511
- if (legacyBrandedAvailable) {
1512
- console.warn(
1513
- `Package ${defaultBrandedOpencodePackage} was not found on npm. Falling back to ${legacyBrandedOpencodePackage}.`,
1514
- );
1515
- return legacyBrandedOpencodePackage;
1516
- }
1517
-
1518
- console.warn(
1519
- `Package ${defaultBrandedOpencodePackage} was not found on npm. Falling back to ${fallbackOpencodePackage}.`,
1520
- );
1521
- return fallbackOpencodePackage;
1326
+ return null;
1522
1327
  }
1523
1328
 
1524
1329
  function shouldRenderBanner(args) {
1525
1330
  if (
1526
- process.env.ZENOCODE_NO_BANNER === "1" ||
1527
- process.env.CODECORTEX_NO_BANNER === "1"
1331
+ process.env.ZENOCODE_NO_BANNER === '1' ||
1332
+ process.env.CODECORTEX_NO_BANNER === '1'
1528
1333
  ) {
1529
1334
  return false;
1530
1335
  }
1531
1336
  if (
1532
1337
  !process.stdout.isTTY &&
1533
- process.env.ZENOCODE_FORCE_BANNER !== "1" &&
1534
- process.env.CODECORTEX_FORCE_BANNER !== "1"
1338
+ process.env.ZENOCODE_FORCE_BANNER !== '1' &&
1339
+ process.env.CODECORTEX_FORCE_BANNER !== '1'
1535
1340
  ) {
1536
1341
  return false;
1537
1342
  }
1538
1343
 
1539
- const suppressFlags = new Set(["--help", "-h", "--version", "-v", "completion"]);
1344
+ const suppressFlags = new Set([
1345
+ '--help',
1346
+ '-h',
1347
+ '--version',
1348
+ '-v',
1349
+ 'completion',
1350
+ ]);
1540
1351
  return !args.some((arg) => suppressFlags.has(arg));
1541
1352
  }
1542
1353
 
1543
1354
  export function shouldBypassZenocodePreparation(args) {
1544
- const metadataFlags = new Set(["--help", "-h", "--version", "-v", "completion"]);
1355
+ const metadataFlags = new Set([
1356
+ '--help',
1357
+ '-h',
1358
+ '--version',
1359
+ '-v',
1360
+ 'completion',
1361
+ ]);
1545
1362
  return metadataFlags.has(args[0]);
1546
1363
  }
1547
1364
 
@@ -1555,39 +1372,48 @@ function maybeRenderBanner(args) {
1555
1372
  function buildAutoLoginArgs({ preferLocalhost = false } = {}) {
1556
1373
  const loginArgs = [];
1557
1374
  if (preferLocalhost) {
1558
- loginArgs.push("--local");
1375
+ loginArgs.push('--local');
1559
1376
  }
1560
1377
  if (
1561
- process.env.ZENOCODE_AUTO_LOGIN_NO_BROWSER === "1" ||
1562
- process.env.CODECORTEX_AUTO_LOGIN_NO_BROWSER === "1"
1378
+ process.env.ZENOCODE_AUTO_LOGIN_NO_BROWSER === '1' ||
1379
+ process.env.CODECORTEX_AUTO_LOGIN_NO_BROWSER === '1'
1563
1380
  ) {
1564
- loginArgs.push("--no-launch-browser");
1381
+ loginArgs.push('--no-launch-browser');
1565
1382
  }
1566
1383
  return loginArgs;
1567
1384
  }
1568
1385
 
1569
1386
  function isMissingTokenError(error) {
1570
1387
  const message = error instanceof Error ? error.message : String(error);
1571
- return message.includes("Missing API token");
1388
+ return message.includes('Missing API token');
1572
1389
  }
1573
1390
 
1574
- function isExpiredSessionError(error) {
1391
+ /** Return whether a models 401 can be repaired by refreshing or replacing auth. */
1392
+ export function isRecoverableSessionError(error) {
1575
1393
  const message = error instanceof Error ? error.message : String(error);
1576
- if (!message.includes("Models fetch failed (401)")) return false;
1577
- return /token has expired|expired token|token expired/i.test(message);
1394
+ if (!message.includes('Models fetch failed (401)')) return false;
1395
+ return /token has expired|expired token|token expired|invalid token format/i.test(
1396
+ message
1397
+ );
1578
1398
  }
1579
1399
 
1580
1400
  function canAutoLogin(args) {
1581
1401
  if (
1582
- process.env.ZENOCODE_AUTO_LOGIN === "0" ||
1583
- process.env.CODECORTEX_AUTO_LOGIN === "0"
1402
+ process.env.ZENOCODE_AUTO_LOGIN === '0' ||
1403
+ process.env.CODECORTEX_AUTO_LOGIN === '0'
1584
1404
  ) {
1585
1405
  return false;
1586
1406
  }
1587
1407
  if (!process.stdin.isTTY || !process.stdout.isTTY) return false;
1588
- if (process.env.CI === "1" || process.env.CI === "true") return false;
1589
-
1590
- const suppressFlags = new Set(["--help", "-h", "--version", "-v", "completion"]);
1408
+ if (process.env.CI === '1' || process.env.CI === 'true') return false;
1409
+
1410
+ const suppressFlags = new Set([
1411
+ '--help',
1412
+ '-h',
1413
+ '--version',
1414
+ '-v',
1415
+ 'completion',
1416
+ ]);
1591
1417
  if (args.some((arg) => suppressFlags.has(arg))) return false;
1592
1418
  return true;
1593
1419
  }
@@ -1596,16 +1422,16 @@ const ansiSequencePattern =
1596
1422
  /\u001B(?:\[[0-?]*[ -/]*[@-~]|\][^\u0007]*(?:\u0007|\u001B\\)|[@-Z\\-_])/g;
1597
1423
 
1598
1424
  function _stripTerminalControlCharacters(text) {
1599
- let normalized = "";
1425
+ let normalized = '';
1600
1426
  for (const char of text) {
1601
- if (char === "\b" || char === "\u007f") {
1427
+ if (char === '\b' || char === '\u007f') {
1602
1428
  normalized = normalized.slice(0, -1);
1603
1429
  continue;
1604
1430
  }
1605
- if (char === "\r") {
1431
+ if (char === '\r') {
1606
1432
  continue;
1607
1433
  }
1608
- if (char < " " && char !== "\n" && char !== "\t") {
1434
+ if (char < ' ' && char !== '\n' && char !== '\t') {
1609
1435
  continue;
1610
1436
  }
1611
1437
  normalized += char;
@@ -1614,13 +1440,17 @@ function _stripTerminalControlCharacters(text) {
1614
1440
  }
1615
1441
 
1616
1442
  export function normalizeRuntimeTranscript(text) {
1617
- return _stripTerminalControlCharacters(text.replaceAll(ansiSequencePattern, ""));
1443
+ return _stripTerminalControlCharacters(
1444
+ text.replaceAll(ansiSequencePattern, '')
1445
+ );
1618
1446
  }
1619
1447
 
1620
1448
  export function canRecoverRuntimeSessionFromTranscript(text) {
1621
1449
  const normalized = normalizeRuntimeTranscript(text);
1622
1450
  return (
1623
- /Unauthorized:\s*\{\s*"detail"\s*:\s*"Token has expired"\s*\}/i.test(normalized) ||
1451
+ /Unauthorized:\s*\{\s*"detail"\s*:\s*"Token has expired"\s*\}/i.test(
1452
+ normalized
1453
+ ) ||
1624
1454
  /authentication token may be missing or expired/i.test(normalized) ||
1625
1455
  /server returned 401 after successful authentication/i.test(normalized)
1626
1456
  );
@@ -1636,6 +1466,7 @@ function shouldAttemptAutoLogin(error, args) {
1636
1466
  return canAutoLogin(args);
1637
1467
  }
1638
1468
 
1469
+ /** Resolve credentials, returning the exact session produced by automatic login. */
1639
1470
  async function resolveTokenWithAutoLogin(baseUrl, args, options = {}) {
1640
1471
  const preferLocalhost = options.preferLocalhost === true;
1641
1472
  try {
@@ -1646,27 +1477,23 @@ async function resolveTokenWithAutoLogin(baseUrl, args, options = {}) {
1646
1477
  throw error;
1647
1478
  }
1648
1479
 
1649
- console.log("No local Zenocode credentials found. Starting login flow...\n");
1650
- await runLoginCommand(baseUrl, buildAutoLoginArgs({ preferLocalhost }), {
1651
- preferLocalhost,
1652
- });
1653
- const token = await resolveToken();
1654
- const persistedBaseUrl = resolveTextCortexBaseUrl({
1655
- envBaseUrl: process.env.TEXTCORTEX_BASE_URL,
1656
- storedBaseUrl: await resolveStoredBaseUrl(),
1480
+ console.log(
1481
+ 'No local Zenocode credentials found. Starting login flow...\n'
1482
+ );
1483
+ return runLoginCommand(baseUrl, buildAutoLoginArgs({ preferLocalhost }), {
1657
1484
  preferLocalhost,
1658
1485
  });
1659
- return { token, baseUrl: persistedBaseUrl };
1660
1486
  }
1661
1487
  }
1662
1488
 
1489
+ /** Prepare the runtime, refreshing or replacing rejected credentials when possible. */
1663
1490
  async function prepareRuntimeWithAutoLogin(baseUrl, token, args, options = {}) {
1664
1491
  const preferLocalhost = options.preferLocalhost === true;
1665
1492
  try {
1666
1493
  const model = await prepareRuntime(baseUrl, token);
1667
1494
  return { model, token, baseUrl };
1668
1495
  } catch (error) {
1669
- if (!isExpiredSessionError(error)) {
1496
+ if (!isRecoverableSessionError(error)) {
1670
1497
  throw error;
1671
1498
  }
1672
1499
 
@@ -1680,33 +1507,31 @@ async function prepareRuntimeWithAutoLogin(baseUrl, token, args, options = {}) {
1680
1507
  };
1681
1508
  } catch {
1682
1509
  if (!canAutoLogin(args)) {
1683
- throw new Error("Zenocode session expired. Run `zenocode login` and try again.");
1510
+ throw new Error(
1511
+ 'Zenocode session expired. Run `zenocode login` and try again.'
1512
+ );
1684
1513
  }
1685
1514
  }
1686
1515
 
1687
- console.log("Zenocode session expired. Starting login flow...\n");
1688
- await runLoginCommand(baseUrl, buildAutoLoginArgs({ preferLocalhost }), {
1689
- preferLocalhost,
1690
- });
1691
- const refreshedToken = await resolveToken();
1692
- const refreshedBaseUrl = resolveTextCortexBaseUrl({
1693
- envBaseUrl: process.env.TEXTCORTEX_BASE_URL,
1694
- storedBaseUrl: await resolveStoredBaseUrl(),
1695
- preferLocalhost,
1696
- });
1697
- const model = await prepareRuntime(refreshedBaseUrl, refreshedToken);
1698
- return { model, token: refreshedToken, baseUrl: refreshedBaseUrl };
1516
+ console.log('Zenocode session expired. Starting login flow...\n');
1517
+ const loginResult = await runLoginCommand(
1518
+ baseUrl,
1519
+ buildAutoLoginArgs({ preferLocalhost }),
1520
+ { preferLocalhost }
1521
+ );
1522
+ const model = await prepareRuntime(loginResult.baseUrl, loginResult.token);
1523
+ return { model, token: loginResult.token, baseUrl: loginResult.baseUrl };
1699
1524
  }
1700
1525
  }
1701
1526
 
1702
1527
  async function main() {
1703
1528
  const args = process.argv.slice(2);
1704
- const prepareOnly = args.includes("--prepare-only");
1705
- const passthrough = args.filter((arg) => arg !== "--prepare-only");
1529
+ const prepareOnly = args.includes('--prepare-only');
1530
+ const passthrough = args.filter((arg) => arg !== '--prepare-only');
1706
1531
 
1707
1532
  // `pnpm run <script> -- ...` can forward a leading `--`; drop it so subcommands
1708
1533
  // like `run` / `models` / `login` are parsed here first.
1709
- if (passthrough[0] === "--") {
1534
+ if (passthrough[0] === '--') {
1710
1535
  passthrough.shift();
1711
1536
  }
1712
1537
 
@@ -1719,42 +1544,54 @@ async function main() {
1719
1544
  preferLocalhost,
1720
1545
  });
1721
1546
  const subcommand = runtimeArgs[0];
1547
+ let loginResult = null;
1722
1548
 
1723
- if (subcommand === "login") {
1724
- await runLoginCommand(
1549
+ const ownedCommandMessage = zenocodeOwnedCommandMessage(runtimeArgs);
1550
+ if (ownedCommandMessage) {
1551
+ console.log(ownedCommandMessage);
1552
+ return;
1553
+ }
1554
+
1555
+ if (subcommand === 'login') {
1556
+ loginResult = await runLoginCommand(
1725
1557
  resolveLoginBaseUrl({
1726
1558
  envBaseUrl: process.env.TEXTCORTEX_BASE_URL,
1727
1559
  preferLocalhost,
1728
1560
  }),
1729
1561
  runtimeArgs.slice(1),
1730
- { preferLocalhost },
1562
+ { preferLocalhost }
1731
1563
  );
1732
1564
  runtimeArgs = [];
1733
1565
  }
1734
1566
 
1735
- if (subcommand === "logout") {
1567
+ if (subcommand === 'logout') {
1736
1568
  await runLogoutCommand();
1737
1569
  return;
1738
1570
  }
1739
1571
 
1740
1572
  maybeRenderBanner(runtimeArgs);
1741
1573
  if (shouldBypassZenocodePreparation(runtimeArgs)) {
1742
- const launchPackage = await resolveLaunchPackage();
1743
- await runPackageLauncher(launchPackage, runtimeArgs, {
1574
+ const launchPackage = resolveLaunchPackage();
1575
+ const result = await runPackageLauncher(launchPackage, runtimeArgs, {
1744
1576
  cwd: process.cwd(),
1745
1577
  env: { ...process.env },
1746
1578
  });
1579
+ exitWithChildResult(result);
1747
1580
  return;
1748
1581
  }
1749
1582
 
1750
- const tokenResolution = await resolveTokenWithAutoLogin(baseUrl, runtimeArgs, {
1751
- preferLocalhost,
1752
- });
1583
+ const tokenResolution =
1584
+ loginResult ||
1585
+ (await resolveTokenWithAutoLogin(baseUrl, runtimeArgs, {
1586
+ preferLocalhost,
1587
+ }));
1753
1588
  const runtime = await prepareRuntimeWithAutoLogin(
1754
1589
  tokenResolution.baseUrl,
1755
1590
  tokenResolution.token,
1756
1591
  runtimeArgs,
1757
- { preferLocalhost },
1592
+ {
1593
+ preferLocalhost,
1594
+ }
1758
1595
  );
1759
1596
  const token = runtime.token;
1760
1597
  const model = runtime.model;
@@ -1769,6 +1606,7 @@ async function main() {
1769
1606
  OPENCODE_MODELS_PATH: modelsPath,
1770
1607
  OPENCODE_CONFIG: configPath,
1771
1608
  OPENCODE_TUI_CONFIG: tuiConfigPath,
1609
+ OPENCODE_DISABLE_AUTOUPDATE: 'true',
1772
1610
  TEXTCORTEX_API_KEY: token,
1773
1611
  },
1774
1612
  };
@@ -1783,29 +1621,34 @@ async function main() {
1783
1621
  preferLocalhost,
1784
1622
  canAutoLoginRuntime: monitorRuntimeSession,
1785
1623
  launchRuntimeFn: ({ args, childOptions }) =>
1786
- runRuntimeBinary(opencodeBinaryPath, args, childOptions, monitorRuntimeSession),
1624
+ runRuntimeBinary(
1625
+ opencodeBinaryPath,
1626
+ args,
1627
+ childOptions,
1628
+ monitorRuntimeSession
1629
+ ),
1787
1630
  });
1788
1631
  exitWithChildResult(result);
1789
1632
  return;
1790
1633
  }
1791
1634
 
1792
- const launchPackage = await resolveLaunchPackage();
1793
- const pinnedRuntimePath = await resolvePinnedRuntimeBinary(launchPackage, childOptions);
1794
- if (pinnedRuntimePath) {
1795
- const result = await runRuntimeWithSessionRecovery({
1796
- args: runtimeArgs,
1797
- baseUrl: runtime.baseUrl,
1798
- token,
1799
- childOptions,
1800
- preferLocalhost,
1801
- canAutoLoginRuntime: monitorRuntimeSession,
1802
- launchRuntimeFn: ({ args, childOptions }) =>
1803
- runRuntimeBinary(pinnedRuntimePath, args, childOptions, monitorRuntimeSession),
1804
- });
1805
- exitWithChildResult(result);
1806
- return;
1807
- }
1808
- await runPackageLauncher(launchPackage, runtimeArgs, childOptions);
1635
+ const launchPackage = resolveLaunchPackage();
1636
+ const result = await runRuntimeWithSessionRecovery({
1637
+ args: runtimeArgs,
1638
+ baseUrl: runtime.baseUrl,
1639
+ token,
1640
+ childOptions,
1641
+ preferLocalhost,
1642
+ canAutoLoginRuntime: monitorRuntimeSession,
1643
+ launchRuntimeFn: ({ args, childOptions }) =>
1644
+ runPackageLauncher(
1645
+ launchPackage,
1646
+ args,
1647
+ childOptions,
1648
+ monitorRuntimeSession
1649
+ ),
1650
+ });
1651
+ exitWithChildResult(result);
1809
1652
  }
1810
1653
 
1811
1654
  const resolveExecutablePath = (value) => {
@@ -1816,7 +1659,10 @@ const resolveExecutablePath = (value) => {
1816
1659
  }
1817
1660
  };
1818
1661
 
1819
- if (process.argv[1] && resolveExecutablePath(process.argv[1]) === currentFilePath) {
1662
+ if (
1663
+ process.argv[1] &&
1664
+ resolveExecutablePath(process.argv[1]) === currentFilePath
1665
+ ) {
1820
1666
  main().catch((error) => {
1821
1667
  console.error(error instanceof Error ? error.message : String(error));
1822
1668
  process.exit(1);