codeclaw 0.2.8 → 0.2.9

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.
@@ -14,27 +14,33 @@ import { splitText } from './channel-base.js';
14
14
  // ---------------------------------------------------------------------------
15
15
  // Context window sizes (max input tokens per model family)
16
16
  // ---------------------------------------------------------------------------
17
- function getContextWindowSize(model) {
18
- if (!model)
19
- return null;
20
- const m = model.toLowerCase();
21
- // Claude models: 200k context
22
- if (m.includes('claude'))
23
- return 200_000;
24
- // GPT-4.1 / GPT-5 / o3/o4 family: 200k (1M for o3, but default to 200k)
25
- if (m.startsWith('gpt-') || m.startsWith('o3') || m.startsWith('o4'))
26
- return 200_000;
27
- // Gemini: 1M context
28
- if (m.includes('gemini'))
29
- return 1_000_000;
30
- return null;
31
- }
32
17
  // ---------------------------------------------------------------------------
33
18
  // Telegram HTML formatting
34
19
  // ---------------------------------------------------------------------------
35
20
  function escapeHtml(t) {
36
21
  return t.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
37
22
  }
23
+ function claudeModelAlias(modelId) {
24
+ const value = String(modelId || '').trim().toLowerCase();
25
+ if (!value)
26
+ return null;
27
+ if (value === 'opus' || value.startsWith('claude-opus-'))
28
+ return 'opus';
29
+ if (value === 'sonnet' || value.startsWith('claude-sonnet-'))
30
+ return 'sonnet';
31
+ if (value === 'haiku' || value.startsWith('claude-haiku-'))
32
+ return 'haiku';
33
+ return null;
34
+ }
35
+ function modelMatchesSelection(agent, selection, currentModel) {
36
+ if (selection === currentModel)
37
+ return true;
38
+ if (agent !== 'claude')
39
+ return false;
40
+ const a = claudeModelAlias(selection);
41
+ const b = claudeModelAlias(currentModel);
42
+ return !!a && a === b;
43
+ }
38
44
  function mdToTgHtml(text) {
39
45
  const result = [];
40
46
  const lines = text.split('\n');
@@ -478,13 +484,24 @@ export class TelegramBot extends Bot {
478
484
  const cs = this.chat(ctx.chatId);
479
485
  const res = this.fetchModels(cs.agent);
480
486
  const currentModel = this.modelForAgent(cs.agent);
481
- const lines = [`<b>Models for ${escapeHtml(cs.agent)}</b>\n`];
487
+ const lines = [`<b>Models for ${escapeHtml(cs.agent)}</b>`];
488
+ if (res.sources.length)
489
+ lines.push(`<i>Source: ${escapeHtml(res.sources.join(', '))}</i>`);
490
+ if (res.note)
491
+ lines.push(`<i>${escapeHtml(res.note)}</i>`);
492
+ lines.push('');
482
493
  const rows = [];
494
+ if (!res.models.length) {
495
+ lines.push('<i>No discoverable models found.</i>');
496
+ }
483
497
  for (const m of res.models) {
484
- const isCurrent = m.id === currentModel || m.alias === currentModel;
498
+ const isCurrent = modelMatchesSelection(cs.agent, m.id, currentModel);
485
499
  const status = isCurrent ? '\u25CF' : '\u25CB';
486
500
  const display = m.alias ? `${m.alias} (${m.id})` : m.id;
487
- lines.push(`${status} <code>${escapeHtml(display)}</code>${isCurrent ? ' \u2190 current' : ''}`);
501
+ const currentSuffix = isCurrent
502
+ ? (m.id === currentModel ? ' \u2190 current' : ` \u2190 current (${escapeHtml(currentModel)})`)
503
+ : '';
504
+ lines.push(`${status} <code>${escapeHtml(display)}</code>${currentSuffix}`);
488
505
  const label = isCurrent ? `\u25CF ${m.alias || m.id}` : (m.alias || m.id);
489
506
  rows.push([{ text: label, callback_data: `mod:${m.id}` }]);
490
507
  }
@@ -513,7 +530,8 @@ export class TelegramBot extends Bot {
513
530
  this.log(`restart: spawning \`${bin} ${allArgs.join(' ')}\``);
514
531
  // Collect all known chat IDs so the new process can send startup notices
515
532
  const knownIds = new Set(this.allowedChatIds);
516
- for (const cid of this.channel.knownChats)
533
+ const knownChats = this.channel.knownChats instanceof Set ? this.channel.knownChats : new Set();
534
+ for (const cid of knownChats)
517
535
  knownIds.add(cid);
518
536
  const child = spawn(bin, allArgs, {
519
537
  stdio: 'inherit',
@@ -634,10 +652,12 @@ export class TelegramBot extends Bot {
634
652
  tp.push(`cached: ${fmtTokens(result.cachedInputTokens)}`);
635
653
  if (result.outputTokens != null)
636
654
  tp.push(`out: ${fmtTokens(result.outputTokens)}`);
637
- // Context window usage percentage (based on input tokens = full conversation context)
638
- const ctxMax = getContextWindowSize(result.model);
639
- if (ctxMax && result.inputTokens != null) {
640
- const pct = (result.inputTokens / ctxMax * 100).toFixed(1);
655
+ // Context window usage percentage from CLI-reported contextWindow
656
+ // For Codex use cumulative input (= full session context); for Claude use inputTokens
657
+ const ctxMax = result.contextWindow;
658
+ const ctxInput = result.codexCumulative?.input ?? result.inputTokens;
659
+ if (ctxMax && ctxInput != null) {
660
+ const pct = (ctxInput / ctxMax * 100).toFixed(1);
641
661
  tp.push(`ctx: ${pct}%`);
642
662
  }
643
663
  tokenBlock = `\n<blockquote expandable>${tp.join(' ')}</blockquote>`;
@@ -778,11 +798,13 @@ export class TelegramBot extends Bot {
778
798
  const cs = this.chat(ctx.chatId);
779
799
  if (sessionId === 'new') {
780
800
  cs.sessionId = null;
801
+ cs.codexCumulative = undefined;
781
802
  await ctx.answerCallback('New session');
782
803
  await ctx.editReply(ctx.messageId, 'Session reset. Send a message to start.', {});
783
804
  }
784
805
  else {
785
806
  cs.sessionId = sessionId;
807
+ cs.codexCumulative = undefined;
786
808
  await ctx.answerCallback(`Session: ${sessionId.slice(0, 12)}`);
787
809
  await ctx.editReply(ctx.messageId, `Switched to session: <code>${escapeHtml(sessionId.slice(0, 16))}</code>`, { parseMode: 'HTML' });
788
810
  }
@@ -797,6 +819,7 @@ export class TelegramBot extends Bot {
797
819
  }
798
820
  cs.agent = agent;
799
821
  cs.sessionId = null;
822
+ cs.codexCumulative = undefined;
800
823
  this.log(`agent switched to ${agent} chat=${ctx.chatId}`);
801
824
  await ctx.answerCallback(`Switched to ${agent}`);
802
825
  await ctx.editReply(ctx.messageId, `<b>Switched to ${escapeHtml(agent)}</b>\n\nSession has been reset. Previous conversation history will not carry over.\nSend a message to start a new conversation.`, { parseMode: 'HTML' });
@@ -812,6 +835,7 @@ export class TelegramBot extends Bot {
812
835
  }
813
836
  this.setModelForAgent(cs.agent, modelId);
814
837
  cs.sessionId = null;
838
+ cs.codexCumulative = undefined;
815
839
  this.log(`model switched to ${modelId} for ${cs.agent} chat=${ctx.chatId}`);
816
840
  await ctx.answerCallback(`Switched to ${modelId}`);
817
841
  await ctx.editReply(ctx.messageId, `<b>Model switched to <code>${escapeHtml(modelId)}</code></b>\n\nAgent: ${escapeHtml(cs.agent)}\nSession has been reset. Send a message to start a new conversation.`, { parseMode: 'HTML' });
package/dist/bot.js CHANGED
@@ -8,7 +8,7 @@ import fs from 'node:fs';
8
8
  import path from 'node:path';
9
9
  import { execSync, spawn } from 'node:child_process';
10
10
  import { doStream, getSessions, getUsage, listAgents, listModels, } from './code-agent.js';
11
- export const VERSION = '0.2.8';
11
+ export const VERSION = '0.2.9';
12
12
  // ---------------------------------------------------------------------------
13
13
  // Helpers
14
14
  // ---------------------------------------------------------------------------
@@ -187,7 +187,7 @@ export class Bot {
187
187
  return listAgents();
188
188
  }
189
189
  fetchModels(agent) {
190
- return listModels(agent);
190
+ return listModels(agent, { workdir: this.workdir, currentModel: this.modelForAgent(agent) });
191
191
  }
192
192
  setModelForAgent(agent, modelId) {
193
193
  if (agent === 'codex')
@@ -232,8 +232,10 @@ export class Bot {
232
232
  switchWorkdir(newPath) {
233
233
  const old = this.workdir;
234
234
  this.workdir = newPath;
235
- for (const [, cs] of this.chats)
235
+ for (const [, cs] of this.chats) {
236
236
  cs.sessionId = null;
237
+ cs.codexCumulative = undefined;
238
+ }
237
239
  this.log(`switch workdir: ${old} -> ${newPath}`);
238
240
  return old;
239
241
  }
@@ -252,6 +254,7 @@ export class Bot {
252
254
  attachments: attachments.length ? attachments : undefined,
253
255
  codexModel: this.codexModel, codexFullAccess: this.codexFullAccess,
254
256
  codexExtraArgs: this.codexExtraArgs.length ? this.codexExtraArgs : undefined,
257
+ codexPrevCumulative: cs.codexCumulative,
255
258
  claudeModel: this.claudeModel, claudePermissionMode: this.claudePermissionMode,
256
259
  claudeExtraArgs: this.claudeExtraArgs.length ? this.claudeExtraArgs : undefined,
257
260
  };
@@ -263,6 +266,9 @@ export class Bot {
263
266
  this.stats.totalOutputTokens += result.outputTokens;
264
267
  if (result.cachedInputTokens)
265
268
  this.stats.totalCachedTokens += result.cachedInputTokens;
269
+ // Store cumulative Codex totals for next invocation delta
270
+ if (result.codexCumulative)
271
+ cs.codexCumulative = result.codexCumulative;
266
272
  // Only update sessionId if it hasn't been changed externally (e.g. user switched session during run)
267
273
  if (result.sessionId && cs.sessionId === snapshotSessionId)
268
274
  cs.sessionId = result.sessionId;
@@ -16,6 +16,8 @@ async function run(cmd, opts, parseLine) {
16
16
  sessionId: opts.sessionId, text: '', thinking: '', msgs: [], thinkParts: [],
17
17
  model: opts.model, thinkingEffort: opts.thinkingEffort, errors: null,
18
18
  inputTokens: null, outputTokens: null, cachedInputTokens: null,
19
+ contextWindow: null,
20
+ codexCumulative: null,
19
21
  stopReason: null,
20
22
  };
21
23
  const shellCmd = cmd.map(Q).join(' ');
@@ -89,6 +91,8 @@ async function run(cmd, opts, parseLine) {
89
91
  thinking: s.thinking.trim() || null,
90
92
  elapsedS: (Date.now() - start) / 1000,
91
93
  inputTokens: s.inputTokens, outputTokens: s.outputTokens, cachedInputTokens: s.cachedInputTokens,
94
+ contextWindow: s.contextWindow,
95
+ codexCumulative: s.codexCumulative,
92
96
  error,
93
97
  stopReason: s.stopReason,
94
98
  incomplete,
@@ -116,7 +120,7 @@ function codexCmd(o) {
116
120
  args.push('-');
117
121
  return args;
118
122
  }
119
- function codexParse(ev, s) {
123
+ function codexParse(ev, s, opts) {
120
124
  const t = ev.type || '';
121
125
  if (t === 'thread.started') {
122
126
  s.sessionId = ev.thread_id ?? s.sessionId;
@@ -136,15 +140,26 @@ function codexParse(ev, s) {
136
140
  if (t === 'turn.completed') {
137
141
  const u = ev.usage;
138
142
  if (u) {
139
- s.inputTokens = u.input_tokens ?? s.inputTokens;
140
- s.cachedInputTokens = u.cached_input_tokens ?? s.cachedInputTokens;
141
- s.outputTokens = u.output_tokens ?? s.outputTokens;
143
+ // Codex reports cumulative session totals in turn.completed.
144
+ // Store raw cumulative and compute per-invocation delta.
145
+ const cumInput = u.input_tokens ?? 0;
146
+ const cumOutput = u.output_tokens ?? 0;
147
+ const cumCached = u.cached_input_tokens ?? 0;
148
+ s.codexCumulative = { input: cumInput, output: cumOutput, cached: cumCached };
149
+ const prev = opts.codexPrevCumulative;
150
+ s.inputTokens = prev ? Math.max(0, cumInput - prev.input) : cumInput;
151
+ s.outputTokens = prev ? Math.max(0, cumOutput - prev.output) : cumOutput;
152
+ s.cachedInputTokens = prev ? Math.max(0, cumCached - prev.cached) : cumCached;
142
153
  }
143
154
  s.model = ev.model ?? s.model;
144
155
  }
145
156
  }
146
- export function doCodexStream(opts) {
147
- return run(codexCmd(opts), opts, codexParse);
157
+ export async function doCodexStream(opts) {
158
+ const result = await run(codexCmd(opts), opts, (ev, s) => codexParse(ev, s, opts));
159
+ // Codex doesn't report context_window in stream events; read from models_cache.json
160
+ if (!result.contextWindow)
161
+ result.contextWindow = readCodexContextWindow(result.model);
162
+ return result;
148
163
  }
149
164
  // --- claude ---
150
165
  const IMAGE_EXTS = new Set(['.jpg', '.jpeg', '.png', '.gif', '.webp']);
@@ -260,6 +275,16 @@ function claudeParse(ev, s) {
260
275
  s.cachedInputTokens = (u.cache_read_input_tokens ?? u.cached_input_tokens) ?? s.cachedInputTokens;
261
276
  s.outputTokens = u.output_tokens ?? s.outputTokens;
262
277
  }
278
+ // Extract contextWindow from modelUsage (Claude CLI reports this in result event)
279
+ const mu = ev.modelUsage;
280
+ if (mu && typeof mu === 'object') {
281
+ for (const info of Object.values(mu)) {
282
+ if (info?.contextWindow > 0) {
283
+ s.contextWindow = info.contextWindow;
284
+ break;
285
+ }
286
+ }
287
+ }
263
288
  }
264
289
  }
265
290
  export async function doClaudeStream(opts) {
@@ -361,7 +386,7 @@ function readLastLine(filePath) {
361
386
  }
362
387
  }
363
388
  function parseCodexSession(filePath) {
364
- const lines = readLines(filePath, 10);
389
+ const lines = readLines(filePath, 40);
365
390
  const line = lines[0];
366
391
  if (!line)
367
392
  return null;
@@ -370,24 +395,32 @@ function parseCodexSession(filePath) {
370
395
  if (ev.type !== 'session_meta')
371
396
  return null;
372
397
  const p = ev.payload || {};
398
+ let model = typeof p.model === 'string' ? p.model : null;
373
399
  let title = null;
374
400
  for (const raw of lines.slice(1)) {
375
401
  if (!raw || raw[0] !== '{')
376
402
  continue;
377
403
  try {
378
404
  const item = JSON.parse(raw);
405
+ if (!model && item.type === 'turn_context') {
406
+ const payload = item.payload || {};
407
+ model =
408
+ (typeof payload.model === 'string' ? payload.model : null)
409
+ || (typeof payload?.collaboration_mode?.settings?.model === 'string' ? payload.collaboration_mode.settings.model : null)
410
+ || model;
411
+ }
379
412
  if (item.type === 'response_item' && item.payload?.role === 'user' && item.payload?.type === 'message') {
380
413
  const content = item.payload.content;
381
414
  if (Array.isArray(content)) {
382
415
  const textBlock = content.find((b) => b?.type === 'input_text' && b.text && !/^[<#]/.test(b.text));
383
- if (textBlock) {
416
+ if (textBlock)
384
417
  title = textBlock.text.slice(0, 120);
385
- break;
386
- }
387
418
  }
388
419
  }
389
420
  }
390
421
  catch { /* skip */ }
422
+ if (model && title)
423
+ break;
391
424
  }
392
425
  // Codex writes task_complete as the last event when done
393
426
  let running = false;
@@ -403,7 +436,7 @@ function parseCodexSession(filePath) {
403
436
  sessionId: p.id ?? path.basename(filePath, '.jsonl'),
404
437
  agent: 'codex',
405
438
  workdir: p.cwd ?? null,
406
- model: p.model_provider ?? null,
439
+ model: model ?? null,
407
440
  createdAt: p.timestamp ?? null,
408
441
  title,
409
442
  running,
@@ -528,20 +561,257 @@ export function listAgents() {
528
561
  ],
529
562
  };
530
563
  }
531
- const CLAUDE_MODELS = [
532
- { id: 'claude-opus-4-6', alias: 'opus' },
533
- { id: 'claude-sonnet-4-5-20250929', alias: 'sonnet' },
534
- { id: 'claude-haiku-4-5-20250929', alias: 'haiku' },
535
- ];
536
- const CODEX_MODELS = [
537
- { id: 'o4-mini', alias: null },
538
- { id: 'o3', alias: null },
539
- { id: 'gpt-5.4', alias: null },
540
- { id: 'gpt-4.1', alias: null },
541
- { id: 'codex-mini', alias: null },
542
- ];
543
- export function listModels(agent) {
544
- return { agent, models: agent === 'codex' ? CODEX_MODELS : CLAUDE_MODELS };
564
+ function shellOutput(cmd) {
565
+ try {
566
+ return execSync(cmd, { encoding: 'utf-8', timeout: 3000 }).trim() || null;
567
+ }
568
+ catch {
569
+ return null;
570
+ }
571
+ }
572
+ function pushUnique(items, value) {
573
+ if (!items.includes(value))
574
+ items.push(value);
575
+ }
576
+ function pushModel(models, seen, id, alias) {
577
+ const cleanId = id.trim();
578
+ if (!cleanId || seen.has(cleanId))
579
+ return;
580
+ seen.add(cleanId);
581
+ models.push({ id: cleanId, alias: alias?.trim() || null });
582
+ }
583
+ function claudeModelAlias(modelId) {
584
+ const value = String(modelId || '').trim().toLowerCase();
585
+ if (!value)
586
+ return null;
587
+ if (value === 'opus' || value.startsWith('claude-opus-'))
588
+ return 'opus';
589
+ if (value === 'sonnet' || value.startsWith('claude-sonnet-'))
590
+ return 'sonnet';
591
+ if (value === 'haiku' || value.startsWith('claude-haiku-'))
592
+ return 'haiku';
593
+ return null;
594
+ }
595
+ function isClaudeModelToken(token) {
596
+ return token === 'opus' || token === 'sonnet' || token === 'haiku' || token.startsWith('claude-');
597
+ }
598
+ function addClaudeModel(models, seen, rawModel) {
599
+ const clean = String(rawModel || '').trim();
600
+ if (!clean)
601
+ return false;
602
+ const alias = claudeModelAlias(clean);
603
+ if (!alias && !clean.toLowerCase().startsWith('claude-'))
604
+ return false;
605
+ if (clean === alias) {
606
+ if (models.some((m) => m.alias === alias))
607
+ return false;
608
+ pushModel(models, seen, clean, null);
609
+ return true;
610
+ }
611
+ if (alias) {
612
+ const aliasIndex = models.findIndex((m) => m.id === alias && !m.alias);
613
+ if (aliasIndex >= 0) {
614
+ models.splice(aliasIndex, 1);
615
+ seen.delete(alias);
616
+ }
617
+ }
618
+ pushModel(models, seen, clean, alias);
619
+ return true;
620
+ }
621
+ function isCodexModelToken(token) {
622
+ return /^(?:o\d(?:-[a-z0-9.-]+)?|gpt-[a-z0-9.-]+|codex-mini(?:-[a-z0-9.-]+)?)$/i.test(token);
623
+ }
624
+ function addCodexModel(models, seen, rawModel) {
625
+ const clean = String(rawModel || '').trim();
626
+ if (!clean || !isCodexModelToken(clean))
627
+ return false;
628
+ pushModel(models, seen, clean, null);
629
+ return true;
630
+ }
631
+ function readCodexConfigModels(home) {
632
+ const configPath = path.join(home, '.codex', 'config.toml');
633
+ if (!fs.existsSync(configPath))
634
+ return [];
635
+ try {
636
+ const raw = fs.readFileSync(configPath, 'utf-8');
637
+ const found = [];
638
+ const defaultModel = raw.match(/^\s*model\s*=\s*"([^"]+)"/m)?.[1];
639
+ if (defaultModel)
640
+ pushUnique(found, defaultModel);
641
+ const migrationsSection = raw.match(/\[notice\.model_migrations\]\n([\s\S]*?)(?:\n\[|$)/)?.[1] || '';
642
+ for (const match of migrationsSection.matchAll(/"[^"]+"\s*=\s*"([^"]+)"/g)) {
643
+ if (match[1])
644
+ pushUnique(found, match[1]);
645
+ }
646
+ return found.filter(isCodexModelToken);
647
+ }
648
+ catch {
649
+ return [];
650
+ }
651
+ }
652
+ function readCodexCachedModels(home) {
653
+ const cachePath = path.join(home, '.codex', 'models_cache.json');
654
+ if (!fs.existsSync(cachePath))
655
+ return [];
656
+ try {
657
+ const raw = JSON.parse(fs.readFileSync(cachePath, 'utf-8'));
658
+ const models = Array.isArray(raw?.models) ? raw.models : [];
659
+ return models
660
+ .filter((m) => m?.visibility === 'list')
661
+ .sort((a, b) => (Number(a?.priority) || 0) - (Number(b?.priority) || 0))
662
+ .map((m) => String(m?.slug || '').trim())
663
+ .filter(isCodexModelToken);
664
+ }
665
+ catch {
666
+ return [];
667
+ }
668
+ }
669
+ /** Look up context_window for a Codex model from ~/.codex/models_cache.json */
670
+ function readCodexContextWindow(model) {
671
+ if (!model)
672
+ return null;
673
+ const home = process.env.HOME || '';
674
+ const cachePath = path.join(home, '.codex', 'models_cache.json');
675
+ if (!fs.existsSync(cachePath))
676
+ return null;
677
+ try {
678
+ const raw = JSON.parse(fs.readFileSync(cachePath, 'utf-8'));
679
+ const models = Array.isArray(raw?.models) ? raw.models : [];
680
+ const entry = models.find((m) => m?.slug === model);
681
+ const cw = Number(entry?.context_window);
682
+ return cw > 0 ? cw : null;
683
+ }
684
+ catch {
685
+ return null;
686
+ }
687
+ }
688
+ function readClaudeStateModels(home, workdir) {
689
+ const statePath = path.join(home, '.claude.json');
690
+ if (!fs.existsSync(statePath))
691
+ return [];
692
+ try {
693
+ const raw = JSON.parse(fs.readFileSync(statePath, 'utf-8'));
694
+ const projects = raw?.projects;
695
+ if (!projects || typeof projects !== 'object')
696
+ return [];
697
+ const found = [];
698
+ const addFromProject = (projectState) => {
699
+ const usage = projectState?.lastModelUsage;
700
+ if (!usage || typeof usage !== 'object')
701
+ return;
702
+ for (const modelId of Object.keys(usage)) {
703
+ if (isClaudeModelToken(modelId))
704
+ pushUnique(found, modelId);
705
+ }
706
+ };
707
+ if (workdir && typeof projects[workdir] === 'object') {
708
+ addFromProject(projects[workdir]);
709
+ }
710
+ for (const [projectPath, projectState] of Object.entries(projects)) {
711
+ if (projectPath === workdir)
712
+ continue;
713
+ addFromProject(projectState);
714
+ }
715
+ return found;
716
+ }
717
+ catch {
718
+ return [];
719
+ }
720
+ }
721
+ function discoverClaudeModels(opts) {
722
+ const models = [];
723
+ const seen = new Set();
724
+ const sources = [];
725
+ const home = process.env.HOME || '';
726
+ if (opts.currentModel?.trim()) {
727
+ addClaudeModel(models, seen, opts.currentModel);
728
+ pushUnique(sources, 'current config');
729
+ }
730
+ let foundStateModel = false;
731
+ for (const modelId of readClaudeStateModels(home, opts.workdir)) {
732
+ foundStateModel = addClaudeModel(models, seen, modelId) || foundStateModel;
733
+ }
734
+ if (foundStateModel)
735
+ pushUnique(sources, '~/.claude.json');
736
+ const help = shellOutput('claude --help 2>/dev/null');
737
+ if (help) {
738
+ let foundHelpModel = false;
739
+ for (const match of help.matchAll(/\b(?:opus|sonnet|haiku|claude-(?:opus|sonnet|haiku)-[a-z0-9-]+)\b/gi)) {
740
+ const token = match[0].trim();
741
+ foundHelpModel = addClaudeModel(models, seen, token) || foundHelpModel;
742
+ }
743
+ if (foundHelpModel)
744
+ pushUnique(sources, 'claude --help');
745
+ }
746
+ if (opts.workdir) {
747
+ const sessions = getClaudeSessions({ agent: 'claude', workdir: opts.workdir, limit: 20 });
748
+ let foundSessionModel = false;
749
+ for (const session of sessions.sessions) {
750
+ if (!session.model)
751
+ continue;
752
+ foundSessionModel = addClaudeModel(models, seen, session.model) || foundSessionModel;
753
+ }
754
+ if (foundSessionModel)
755
+ pushUnique(sources, 'recent sessions');
756
+ }
757
+ return {
758
+ agent: 'claude',
759
+ models,
760
+ sources,
761
+ note: 'Claude CLI does not expose a machine-readable model list; entries are discovered from current config, ~/.claude.json, CLI help, and local session state.',
762
+ };
763
+ }
764
+ function discoverCodexModels(opts) {
765
+ const models = [];
766
+ const seen = new Set();
767
+ const sources = [];
768
+ const home = process.env.HOME || '';
769
+ let foundCacheModel = false;
770
+ for (const modelId of readCodexCachedModels(home)) {
771
+ foundCacheModel = addCodexModel(models, seen, modelId) || foundCacheModel;
772
+ }
773
+ if (foundCacheModel)
774
+ pushUnique(sources, '~/.codex/models_cache.json');
775
+ const help = shellOutput('codex --help 2>/dev/null');
776
+ if (help) {
777
+ let foundHelpModel = false;
778
+ for (const match of help.matchAll(/model="([^"]+)"/g)) {
779
+ addCodexModel(models, seen, match[1]);
780
+ foundHelpModel = true;
781
+ }
782
+ if (foundHelpModel)
783
+ pushUnique(sources, 'codex --help');
784
+ }
785
+ if (opts.currentModel?.trim()) {
786
+ addCodexModel(models, seen, opts.currentModel);
787
+ pushUnique(sources, 'current config');
788
+ }
789
+ let foundConfigModel = false;
790
+ for (const modelId of readCodexConfigModels(home)) {
791
+ foundConfigModel = addCodexModel(models, seen, modelId) || foundConfigModel;
792
+ }
793
+ if (foundConfigModel)
794
+ pushUnique(sources, '~/.codex/config.toml');
795
+ if (opts.workdir) {
796
+ const sessions = getCodexSessions({ agent: 'codex', workdir: opts.workdir, limit: 20 });
797
+ let foundSessionModel = false;
798
+ for (const session of sessions.sessions) {
799
+ if (!session.model)
800
+ continue;
801
+ foundSessionModel = addCodexModel(models, seen, session.model) || foundSessionModel;
802
+ }
803
+ if (foundSessionModel)
804
+ pushUnique(sources, 'recent sessions');
805
+ }
806
+ return {
807
+ agent: 'codex',
808
+ models,
809
+ sources,
810
+ note: 'Codex CLI does not expose a model-list subcommand; entries are discovered from the local Codex model cache and other local state.',
811
+ };
812
+ }
813
+ export function listModels(agent, opts = {}) {
814
+ return agent === 'codex' ? discoverCodexModels(opts) : discoverClaudeModels(opts);
545
815
  }
546
816
  function toIsoFromEpochSeconds(value) {
547
817
  const n = Number(value);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "codeclaw",
3
- "version": "0.2.8",
3
+ "version": "0.2.9",
4
4
  "description": "The best IM-driven remote coding experience. Bridge AI coding agents to any IM.",
5
5
  "type": "module",
6
6
  "bin": {