@pixelbyte-software/pixcode 1.33.4 → 1.33.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -699,6 +699,7 @@ async function getProjects(progressCallback = null) {
699
699
  sessions: [],
700
700
  geminiSessions: [],
701
701
  qwenSessions: [],
702
+ opencodeSessions: [],
702
703
  sessionMeta: {
703
704
  hasMore: false,
704
705
  total: 0
@@ -768,6 +769,14 @@ async function getProjects(progressCallback = null) {
768
769
  }
769
770
  applyCustomSessionNames(project.qwenSessions, 'qwen');
770
771
 
772
+ try {
773
+ project.opencodeSessions = await getOpencodeCliSessions(actualProjectDir);
774
+ } catch (e) {
775
+ console.warn(`Could not load OpenCode sessions for project ${entry.name}:`, e.message);
776
+ project.opencodeSessions = [];
777
+ }
778
+ applyCustomSessionNames(project.opencodeSessions, 'opencode');
779
+
771
780
  try {
772
781
  const taskMasterResult = await detectTaskMasterFolder(actualProjectDir);
773
782
  project.taskmaster = {
@@ -831,6 +840,7 @@ async function getProjects(progressCallback = null) {
831
840
  sessions: [],
832
841
  geminiSessions: [],
833
842
  qwenSessions: [],
843
+ opencodeSessions: [],
834
844
  sessionMeta: {
835
845
  hasMore: false,
836
846
  total: 0
@@ -876,6 +886,13 @@ async function getProjects(progressCallback = null) {
876
886
  }
877
887
  applyCustomSessionNames(project.geminiSessions, 'gemini');
878
888
 
889
+ try {
890
+ project.opencodeSessions = await getOpencodeCliSessions(actualProjectDir);
891
+ } catch (e) {
892
+ console.warn(`Could not load OpenCode sessions for tracked project ${projectName}:`, e.message);
893
+ }
894
+ applyCustomSessionNames(project.opencodeSessions, 'opencode');
895
+
879
896
  try {
880
897
  const taskMasterResult = await detectTaskMasterFolder(actualProjectDir);
881
898
 
@@ -2798,6 +2815,100 @@ async function getQwenCliSessions(projectPath, options = {}) {
2798
2815
  );
2799
2816
  }
2800
2817
 
2818
+ /**
2819
+ * OpenCode session enumerator. Walks `~/.local/share/opencode/project/*` (the
2820
+ * XDG data dir OpenCode uses on every platform — yes, including Windows under
2821
+ * the user profile) and surfaces every session whose `directory` field matches
2822
+ * the project root we're listing. `<projectID>` segments are git-root commit
2823
+ * hashes for git repos and the literal "global" for non-git dirs, so we
2824
+ * don't try to be clever about the slug — we just walk every project bucket
2825
+ * and filter by the stored `directory`.
2826
+ *
2827
+ * This is best-effort: when OpenCode hasn't written anything yet (brand-new
2828
+ * project, no chats sent) the data dir is missing and we return [] without
2829
+ * surfacing an error to the caller.
2830
+ */
2831
+ async function getOpencodeCliSessions(projectPath) {
2832
+ const normalizedProjectPath = normalizeComparablePath(projectPath);
2833
+ if (!normalizedProjectPath) return [];
2834
+
2835
+ const sessions = [];
2836
+ const opencodeDataDir = path.join(os.homedir(), '.local', 'share', 'opencode', 'project');
2837
+
2838
+ let projectBuckets;
2839
+ try {
2840
+ projectBuckets = await fs.readdir(opencodeDataDir);
2841
+ } catch {
2842
+ return [];
2843
+ }
2844
+
2845
+ for (const bucket of projectBuckets) {
2846
+ const sessionRoot = path.join(opencodeDataDir, bucket, 'storage', 'session');
2847
+ let projectIdDirs;
2848
+ try {
2849
+ projectIdDirs = await fs.readdir(sessionRoot);
2850
+ } catch {
2851
+ continue;
2852
+ }
2853
+
2854
+ for (const pidDir of projectIdDirs) {
2855
+ const dir = path.join(sessionRoot, pidDir);
2856
+ let sessionFiles;
2857
+ try {
2858
+ sessionFiles = await fs.readdir(dir);
2859
+ } catch {
2860
+ continue;
2861
+ }
2862
+
2863
+ for (const file of sessionFiles) {
2864
+ if (!file.startsWith('ses_') || !file.endsWith('.json')) continue;
2865
+ try {
2866
+ const data = await fs.readFile(path.join(dir, file), 'utf8');
2867
+ const sess = JSON.parse(data);
2868
+ const sessDir = typeof sess?.directory === 'string'
2869
+ ? normalizeComparablePath(sess.directory)
2870
+ : null;
2871
+ if (!sessDir || sessDir !== normalizedProjectPath) continue;
2872
+
2873
+ const id = typeof sess?.id === 'string' ? sess.id : file.replace(/\.json$/, '');
2874
+ sessions.push({
2875
+ id,
2876
+ summary: sess?.title || 'OpenCode Session',
2877
+ messageCount: typeof sess?.messageCount === 'number' ? sess.messageCount : 0,
2878
+ lastActivity: sess?.time?.updated || sess?.time?.created || null,
2879
+ provider: 'opencode',
2880
+ });
2881
+ } catch {
2882
+ // Skip unreadable session files silently — one corrupt file
2883
+ // shouldn't blank the whole list.
2884
+ continue;
2885
+ }
2886
+ }
2887
+ }
2888
+ }
2889
+
2890
+ // Merge in live sessions held by the in-memory sessionManager so a fresh
2891
+ // chat that hasn't been flushed to disk yet still shows in the sidebar.
2892
+ try {
2893
+ const liveSessions = sessionManager.getProjectSessions(projectPath) || [];
2894
+ for (const s of liveSessions) {
2895
+ if (!s.id || !s.id.startsWith('opencode_')) continue;
2896
+ if (sessions.some((existing) => existing.id === s.id)) continue;
2897
+ sessions.push({
2898
+ id: s.id,
2899
+ summary: s.summary || s.title || 'OpenCode Session',
2900
+ messageCount: s.messageCount || 0,
2901
+ lastActivity: s.lastActivity || s.createdAt || null,
2902
+ provider: 'opencode',
2903
+ });
2904
+ }
2905
+ } catch { /* sessionManager not initialised yet — ignore */ }
2906
+
2907
+ return sessions.sort((a, b) =>
2908
+ new Date(b.lastActivity || 0) - new Date(a.lastActivity || 0)
2909
+ );
2910
+ }
2911
+
2801
2912
  async function getQwenCliSessionMessages(sessionId) {
2802
2913
  const qwenTmpDir = path.join(os.homedir(), '.qwen', 'tmp');
2803
2914
  let projectDirs;
@@ -2989,5 +3100,6 @@ export {
2989
3100
  getGeminiCliSessionMessages,
2990
3101
  getQwenCliSessions,
2991
3102
  getQwenCliSessionMessages,
3103
+ getOpencodeCliSessions,
2992
3104
  searchConversations
2993
3105
  };
@@ -335,10 +335,13 @@ async function spawnQwen(command, options = {}, ws) {
335
335
  const installed = await providerAuthService.isProviderInstalled('qwen');
336
336
  const errorContent = !installed
337
337
  ? 'Qwen Code CLI is not installed. Install it first: npm install -g @qwen-code/qwen-code'
338
- : error.message;
338
+ : (error?.message || String(error));
339
339
 
340
340
  const errorSessionId = typeof ws.getSessionId === 'function' ? ws.getSessionId() : finalSessionId;
341
341
  ws.send(createNormalizedMessage({ kind: 'error', content: errorContent, sessionId: errorSessionId, provider: 'qwen' }));
342
+ // Always emit `complete` so the UI's "Processing..." state clears
343
+ // even when spawn fails (ENOENT, EACCES) and `close` never fires.
344
+ ws.send(createNormalizedMessage({ kind: 'complete', exitCode: 1, isNewSession: !sessionId && !!command, sessionId: errorSessionId, provider: 'qwen' }));
342
345
  notifyTerminalState({ error });
343
346
 
344
347
  reject(error);
@@ -71,7 +71,9 @@ function normalizeList(list) {
71
71
  seen.add(value);
72
72
  const label = typeof item.label === 'string' && item.label.trim() ? item.label.trim() : value;
73
73
  const source = item.source === 'api' ? 'api' : 'static';
74
- out.push({ value, label, source });
74
+ const entry = { value, label, source };
75
+ if (typeof item.free === 'boolean') entry.free = item.free;
76
+ out.push(entry);
75
77
  }
76
78
  return out;
77
79
  }
@@ -111,7 +113,18 @@ async function discoverOpenAiCompat(apiKey, baseUrl, fallbackBase) {
111
113
  const response = await fetch(endpoint, {
112
114
  headers: { Authorization: `Bearer ${apiKey}` },
113
115
  });
114
- if (!response.ok) throw new Error(`${endpoint} returned ${response.status}`);
116
+ if (!response.ok) {
117
+ // 401 specifically means our key is bad — but for codex/qwen/etc.
118
+ // users often log in via OAuth (`codex login`, `qwen auth`) which
119
+ // doesn't expose an OpenAI-compatible API key. Surface a clean
120
+ // "no live discovery available" rather than a scary 401 trace.
121
+ if (response.status === 401) {
122
+ const err = new Error('OpenAI-compatible /v1/models requires an API key. The static catalog is shown instead — that\'s expected when you signed in via OAuth (e.g. `codex login`).');
123
+ err.code = 'OAUTH_NO_API_KEY';
124
+ throw err;
125
+ }
126
+ throw new Error(`${endpoint} returned ${response.status}`);
127
+ }
115
128
  const data = await response.json();
116
129
  const rows = Array.isArray(data?.data) ? data.data : [];
117
130
  return rows
@@ -123,6 +136,123 @@ async function discoverOpenAiCompat(apiKey, baseUrl, fallbackBase) {
123
136
  }));
124
137
  }
125
138
 
139
+ /**
140
+ * Detect whether the user is authenticated via the provider's OAuth flow
141
+ * (codex login / qwen auth) so we can skip live model discovery silently
142
+ * — those flows don't surface a usable OpenAI-compatible API key, and the
143
+ * SDK calls the upstream APIs through its own internal auth path.
144
+ */
145
+ async function hasProviderOauthAuth(provider) {
146
+ if (provider === 'codex') {
147
+ try {
148
+ await fs.access(path.join(os.homedir(), '.codex', 'auth.json'));
149
+ return true;
150
+ } catch { return false; }
151
+ }
152
+ if (provider === 'qwen') {
153
+ try {
154
+ await fs.access(path.join(os.homedir(), '.qwen', 'oauth_creds.json'));
155
+ return true;
156
+ } catch { return false; }
157
+ }
158
+ return false;
159
+ }
160
+
161
+ /**
162
+ * OpenCode is multi-provider — its "model" picker isn't a single API list,
163
+ * it's the union of every provider it can route to (Anthropic, OpenAI,
164
+ * Google, xAI, OpenRouter, OpenCode Zen, Ollama, etc.). The canonical
165
+ * catalog lives at https://models.dev/api.json (no auth, ~1.8 MB JSON, 115
166
+ * providers as of 2026-04). We pull that, filter to providers the user
167
+ * has authenticated with (read `~/.local/share/opencode/auth.json`) plus
168
+ * always include the OpenCode Zen tier (works without explicit auth on
169
+ * the free models), drop deprecated entries, and tag free models.
170
+ */
171
+ async function discoverOpencode() {
172
+ const url = process.env.OPENCODE_MODELS_URL || 'https://models.dev/api.json';
173
+ const response = await fetch(url, {
174
+ // OpenCode itself caches this for hours; we cache for 6h via the
175
+ // outer wrapper so a single 7s fetch on cold start is acceptable.
176
+ signal: AbortSignal.timeout(15000),
177
+ });
178
+ if (!response.ok) throw new Error(`models.dev/api.json returned ${response.status}`);
179
+ const data = await response.json();
180
+ if (!data || typeof data !== 'object') throw new Error('models.dev returned a non-object payload');
181
+
182
+ // Read OpenCode's auth.json to know which providers the user can
183
+ // actually call. Missing file → only show always-free Zen.
184
+ const authedProviders = new Set(['opencode']);
185
+ try {
186
+ const authPath = path.join(os.homedir(), '.local', 'share', 'opencode', 'auth.json');
187
+ const raw = await fs.readFile(authPath, 'utf8');
188
+ const auth = JSON.parse(raw);
189
+ if (auth && typeof auth === 'object') {
190
+ for (const k of Object.keys(auth)) authedProviders.add(k);
191
+ }
192
+ } catch { /* no auth.json → only Zen free models surface */ }
193
+
194
+ // Common env-var providers OpenCode picks up automatically. If the user
195
+ // exported one in their shell, surface those models too even without
196
+ // auth.json. Mirrors the env list in opencode-auth.provider.ts.
197
+ const envProviderHints = {
198
+ anthropic: ['ANTHROPIC_API_KEY'],
199
+ openai: ['OPENAI_API_KEY'],
200
+ google: ['GOOGLE_GENERATIVE_AI_API_KEY', 'GEMINI_API_KEY'],
201
+ 'google-vertex': ['GOOGLE_APPLICATION_CREDENTIALS'],
202
+ xai: ['XAI_API_KEY'],
203
+ groq: ['GROQ_API_KEY'],
204
+ cerebras: ['CEREBRAS_API_KEY'],
205
+ openrouter: ['OPENROUTER_API_KEY'],
206
+ };
207
+ for (const [providerId, envVars] of Object.entries(envProviderHints)) {
208
+ if (envVars.some((v) => process.env[v]?.trim())) authedProviders.add(providerId);
209
+ }
210
+
211
+ const out = [];
212
+ for (const [providerId, providerCfg] of Object.entries(data)) {
213
+ if (!authedProviders.has(providerId)) continue;
214
+ if (!providerCfg || typeof providerCfg !== 'object') continue;
215
+ const models = providerCfg.models;
216
+ if (!models || typeof models !== 'object') continue;
217
+
218
+ const providerName = typeof providerCfg.name === 'string' && providerCfg.name.trim()
219
+ ? providerCfg.name
220
+ : providerId;
221
+
222
+ for (const [modelId, modelCfg] of Object.entries(models)) {
223
+ if (!modelCfg || typeof modelCfg !== 'object') continue;
224
+ // Skip deprecated entries from the default list — users can
225
+ // still hand-type them if they really need to.
226
+ if (modelCfg.status === 'deprecated') continue;
227
+ const cost = modelCfg.cost && typeof modelCfg.cost === 'object' ? modelCfg.cost : null;
228
+ const free = !cost || (Number(cost.input) === 0 && Number(cost.output) === 0);
229
+ const ctx = modelCfg.limit?.context;
230
+ const ctxLabel = typeof ctx === 'number' && ctx > 0
231
+ ? ` · ${ctx >= 1_000_000 ? `${(ctx / 1_000_000).toFixed(1)}M` : `${Math.round(ctx / 1000)}K`}`
232
+ : '';
233
+ const freeLabel = free ? ' · Free' : '';
234
+ const modelName = typeof modelCfg.name === 'string' && modelCfg.name.trim()
235
+ ? modelCfg.name
236
+ : modelId;
237
+
238
+ out.push({
239
+ value: `${providerId}/${modelId}`,
240
+ label: `${providerName} · ${modelName}${ctxLabel}${freeLabel}`,
241
+ source: 'api',
242
+ free,
243
+ });
244
+ }
245
+ }
246
+
247
+ // Sort: free first (handy when the user is unauthed), then by label.
248
+ out.sort((a, b) => {
249
+ if (a.free !== b.free) return a.free ? -1 : 1;
250
+ return a.label.localeCompare(b.label);
251
+ });
252
+
253
+ return out;
254
+ }
255
+
126
256
  async function discoverGoogle(apiKey) {
127
257
  // Google Generative Language API — public models list, API key as query.
128
258
  const endpoint = `https://generativelanguage.googleapis.com/v1beta/models?key=${encodeURIComponent(apiKey)}`;
@@ -165,6 +295,22 @@ export async function getProviderModels(provider, opts = {}) {
165
295
  };
166
296
  }
167
297
 
298
+ // OpenCode is the odd one out: its catalog is models.dev, not a per-key
299
+ // API endpoint. Skip the credential plumbing and dispatch straight.
300
+ let liveModels = [];
301
+ let error;
302
+ if (provider === 'opencode') {
303
+ try {
304
+ liveModels = await discoverOpencode();
305
+ } catch (err) {
306
+ error = err?.message || String(err);
307
+ }
308
+ const merged = mergeCatalogs(normalizeList(liveModels), staticCatalog);
309
+ const entry = { models: merged, error };
310
+ await saveCacheEntry(provider, entry).catch(() => { /* non-fatal */ });
311
+ return { models: merged, fetchedAt: new Date().toISOString(), error, fromCache: false };
312
+ }
313
+
168
314
  // Pick up credentials from Pixcode's UI store first, then fall back to
169
315
  // the native env vars so a user who already exported ANTHROPIC_API_KEY
170
316
  // (or authenticated Claude Code via OAuth — the SDK writes the key into
@@ -185,12 +331,17 @@ export async function getProviderModels(provider, opts = {}) {
185
331
  const apiKey = creds?.apiKey || envKey;
186
332
  const baseUrl = creds?.baseUrl || envBase || undefined;
187
333
 
188
- let liveModels = [];
189
- let error;
190
334
  if (!apiKey) {
191
- // Be explicit so the UI can surface a useful hint rather than just
192
- // showing the static baseline with no reason given.
193
- error = `No ${provider} API key configured. Save one in Settings > Agents > API Key to enable live discovery.`;
335
+ // Codex and Qwen support OAuth (`codex login`, `qwen auth`) which
336
+ // DOESN'T expose a usable API key the SDK auths against the
337
+ // upstream API directly. Skip the discovery step silently in that
338
+ // case; the static catalog is the right answer.
339
+ const oauthOnly = await hasProviderOauthAuth(provider);
340
+ if (!oauthOnly) {
341
+ // Be explicit so the UI can surface a useful hint rather than just
342
+ // showing the static baseline with no reason given.
343
+ error = `No ${provider} API key configured. Save one in Settings > Agents > API Key, or sign in via the CLI (e.g. \`codex login\`).`;
344
+ }
194
345
  } else {
195
346
  try {
196
347
  if (provider === 'claude') {
@@ -207,7 +358,12 @@ export async function getProviderModels(provider, opts = {}) {
207
358
  liveModels = await discoverGoogle(apiKey);
208
359
  }
209
360
  } catch (err) {
210
- error = err?.message || String(err);
361
+ // OAuth users get a clean message instead of a raw 401 stack.
362
+ if (err?.code === 'OAUTH_NO_API_KEY') {
363
+ error = err.message;
364
+ } else {
365
+ error = err?.message || String(err);
366
+ }
211
367
  }
212
368
  }
213
369
 
@@ -95,25 +95,48 @@ export const QWEN_MODELS = {
95
95
  };
96
96
 
97
97
  /**
98
- * OpenCode Models
98
+ * OpenCode Models — STATIC FALLBACK ONLY.
99
99
  *
100
- * OpenCode is multi-provider, so the picker is really "which upstream
101
- * model does opencode talk to?". The defaults here point at OpenCode Zen
102
- * (their curated routing tier) plus a few direct picks the docs call
103
- * out as well-supported. Advanced users override via opencode.json's
104
- * `provider` / `model` block, which bypasses this list.
100
+ * OpenCode is multi-provider and the live model catalog rotates often
101
+ * (Zen free models come and go; new Anthropic/OpenAI/Google models ship
102
+ * every few weeks). The runtime fetches the live catalog from
103
+ * `https://models.dev/api.json` via server/services/provider-models.js
104
+ * and merges it on top of this list — these entries only show when the
105
+ * fetch fails (offline install, firewalled host).
106
+ *
107
+ * Curated current free Zen tier + canonical paid picks. Update when the
108
+ * fallback feels stale, but the live fetch is the source of truth.
105
109
  */
106
110
  export const OPENCODE_MODELS = {
107
111
  OPTIONS: [
108
- { value: "opencode/zen-best", label: "OpenCode Zen (Best)" },
109
- { value: "opencode/zen-fast", label: "OpenCode Zen (Fast)" },
112
+ // OpenCode Zen free tier (no charge, may rate-limit). The "limited
113
+ // time" Zen freebies rotate, so this is the safest small set.
114
+ { value: "opencode/big-pickle", label: "OpenCode Zen · Big Pickle (Free)", free: true },
115
+ { value: "opencode/minimax-m2.5-free", label: "OpenCode Zen · MiniMax M2.5 (Free)", free: true },
116
+ { value: "opencode/hy3-preview-free", label: "OpenCode Zen · Hy3 Preview (Free)", free: true },
117
+ { value: "opencode/ling-2.6-flash-free", label: "OpenCode Zen · Ling 2.6 Flash (Free)", free: true },
118
+ { value: "opencode/nemotron-3-super-free", label: "OpenCode Zen · Nemotron 3 Super (Free)", free: true },
119
+ { value: "opencode/gpt-5-nano", label: "OpenCode Zen · GPT-5 Nano (Free)", free: true },
120
+
121
+ // Anthropic — current flagship + cheap.
122
+ { value: "anthropic/claude-opus-4-7", label: "Claude Opus 4.7 (Anthropic)" },
110
123
  { value: "anthropic/claude-sonnet-4-6", label: "Claude Sonnet 4.6 (Anthropic)" },
111
- { value: "anthropic/claude-opus-4-6", label: "Claude Opus 4.6 (Anthropic)" },
124
+ { value: "anthropic/claude-haiku-4-5", label: "Claude Haiku 4.5 (Anthropic)" },
125
+
126
+ // OpenAI — current GPT-5 family.
112
127
  { value: "openai/gpt-5.4", label: "GPT-5.4 (OpenAI)" },
113
- { value: "google/gemini-3-pro-preview", label: "Gemini 3 Pro (Google)" },
128
+ { value: "openai/gpt-5.1-codex", label: "GPT-5.1 Codex (OpenAI)" },
129
+
130
+ // Google — current Gemini 3 family.
131
+ { value: "google/gemini-3.1-pro-preview", label: "Gemini 3.1 Pro (Google)" },
132
+ { value: "google/gemini-3-flash-preview", label: "Gemini 3 Flash (Google)" },
133
+
134
+ // xAI — fast & cheap coding model.
135
+ { value: "xai/grok-code-fast-1", label: "Grok Code Fast 1 (xAI)" },
114
136
  ],
115
137
 
116
- DEFAULT: "opencode/zen-best",
138
+ // Free Zen freebie that historically works for unauthed installs.
139
+ DEFAULT: "opencode/big-pickle",
117
140
  };
118
141
 
119
142
  /**