codeclaw 0.2.7 → 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.
@@ -12,11 +12,35 @@ import { Bot, VERSION, fmtTokens, fmtUptime, fmtBytes, whichSync, listSubdirs, b
12
12
  import { TelegramChannel } from './channel-telegram.js';
13
13
  import { splitText } from './channel-base.js';
14
14
  // ---------------------------------------------------------------------------
15
+ // Context window sizes (max input tokens per model family)
16
+ // ---------------------------------------------------------------------------
17
+ // ---------------------------------------------------------------------------
15
18
  // Telegram HTML formatting
16
19
  // ---------------------------------------------------------------------------
17
20
  function escapeHtml(t) {
18
21
  return t.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
19
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
+ }
20
44
  function mdToTgHtml(text) {
21
45
  const result = [];
22
46
  const lines = text.split('\n');
@@ -321,6 +345,7 @@ export class TelegramBot extends Bot {
321
345
  static MENU_COMMANDS = [
322
346
  { command: 'sessions', description: 'List / switch sessions' },
323
347
  { command: 'agents', description: 'List / switch agents' },
348
+ { command: 'models', description: 'List / switch models' },
324
349
  { command: 'status', description: 'Bot status' },
325
350
  { command: 'host', description: 'Host machine info' },
326
351
  { command: 'switch', description: 'Switch working directory' },
@@ -336,6 +361,7 @@ export class TelegramBot extends Bot {
336
361
  await ctx.reply(`<b>codeclaw</b> v${VERSION}\n\n` +
337
362
  `/sessions \u2014 List / switch sessions\n` +
338
363
  `/agents \u2014 List / switch agents\n` +
364
+ `/models \u2014 List / switch models\n` +
339
365
  `/status \u2014 Bot status\n` +
340
366
  `/host \u2014 Host machine info\n` +
341
367
  `/switch \u2014 Switch working directory\n` +
@@ -454,6 +480,33 @@ export class TelegramBot extends Bot {
454
480
  }
455
481
  await ctx.reply(lines.join('\n'), { parseMode: 'HTML', keyboard: { inline_keyboard: rows } });
456
482
  }
483
+ async cmdModels(ctx) {
484
+ const cs = this.chat(ctx.chatId);
485
+ const res = this.fetchModels(cs.agent);
486
+ const currentModel = this.modelForAgent(cs.agent);
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('');
493
+ const rows = [];
494
+ if (!res.models.length) {
495
+ lines.push('<i>No discoverable models found.</i>');
496
+ }
497
+ for (const m of res.models) {
498
+ const isCurrent = modelMatchesSelection(cs.agent, m.id, currentModel);
499
+ const status = isCurrent ? '\u25CF' : '\u25CB';
500
+ const display = m.alias ? `${m.alias} (${m.id})` : m.id;
501
+ const currentSuffix = isCurrent
502
+ ? (m.id === currentModel ? ' \u2190 current' : ` \u2190 current (${escapeHtml(currentModel)})`)
503
+ : '';
504
+ lines.push(`${status} <code>${escapeHtml(display)}</code>${currentSuffix}`);
505
+ const label = isCurrent ? `\u25CF ${m.alias || m.id}` : (m.alias || m.id);
506
+ rows.push([{ text: label, callback_data: `mod:${m.id}` }]);
507
+ }
508
+ await ctx.reply(lines.join('\n'), { parseMode: 'HTML', keyboard: { inline_keyboard: rows } });
509
+ }
457
510
  async cmdRestart(ctx) {
458
511
  const activeTasks = this.activeTasks.size;
459
512
  if (activeTasks > 0) {
@@ -477,7 +530,8 @@ export class TelegramBot extends Bot {
477
530
  this.log(`restart: spawning \`${bin} ${allArgs.join(' ')}\``);
478
531
  // Collect all known chat IDs so the new process can send startup notices
479
532
  const knownIds = new Set(this.allowedChatIds);
480
- 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)
481
535
  knownIds.add(cid);
482
536
  const child = spawn(bin, allArgs, {
483
537
  stdio: 'inherit',
@@ -598,6 +652,14 @@ export class TelegramBot extends Bot {
598
652
  tp.push(`cached: ${fmtTokens(result.cachedInputTokens)}`);
599
653
  if (result.outputTokens != null)
600
654
  tp.push(`out: ${fmtTokens(result.outputTokens)}`);
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);
661
+ tp.push(`ctx: ${pct}%`);
662
+ }
601
663
  tokenBlock = `\n<blockquote expandable>${tp.join(' ')}</blockquote>`;
602
664
  }
603
665
  const quickReplies = result.incomplete ? [] : detectQuickReplies(result.message);
@@ -736,11 +798,13 @@ export class TelegramBot extends Bot {
736
798
  const cs = this.chat(ctx.chatId);
737
799
  if (sessionId === 'new') {
738
800
  cs.sessionId = null;
801
+ cs.codexCumulative = undefined;
739
802
  await ctx.answerCallback('New session');
740
803
  await ctx.editReply(ctx.messageId, 'Session reset. Send a message to start.', {});
741
804
  }
742
805
  else {
743
806
  cs.sessionId = sessionId;
807
+ cs.codexCumulative = undefined;
744
808
  await ctx.answerCallback(`Session: ${sessionId.slice(0, 12)}`);
745
809
  await ctx.editReply(ctx.messageId, `Switched to session: <code>${escapeHtml(sessionId.slice(0, 16))}</code>`, { parseMode: 'HTML' });
746
810
  }
@@ -755,11 +819,28 @@ export class TelegramBot extends Bot {
755
819
  }
756
820
  cs.agent = agent;
757
821
  cs.sessionId = null;
822
+ cs.codexCumulative = undefined;
758
823
  this.log(`agent switched to ${agent} chat=${ctx.chatId}`);
759
824
  await ctx.answerCallback(`Switched to ${agent}`);
760
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' });
761
826
  return;
762
827
  }
828
+ if (data.startsWith('mod:')) {
829
+ const modelId = data.slice(4);
830
+ const cs = this.chat(ctx.chatId);
831
+ const currentModel = this.modelForAgent(cs.agent);
832
+ if (currentModel === modelId) {
833
+ await ctx.answerCallback(`Already using ${modelId}`);
834
+ return;
835
+ }
836
+ this.setModelForAgent(cs.agent, modelId);
837
+ cs.sessionId = null;
838
+ cs.codexCumulative = undefined;
839
+ this.log(`model switched to ${modelId} for ${cs.agent} chat=${ctx.chatId}`);
840
+ await ctx.answerCallback(`Switched to ${modelId}`);
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' });
842
+ return;
843
+ }
763
844
  if (data.startsWith('qr:')) {
764
845
  const parts = data.split(':');
765
846
  if (parts.length === 3) {
@@ -788,6 +869,9 @@ export class TelegramBot extends Bot {
788
869
  case 'agents':
789
870
  await this.cmdAgents(ctx);
790
871
  return;
872
+ case 'models':
873
+ await this.cmdModels(ctx);
874
+ return;
791
875
  case 'status':
792
876
  await this.cmdStatus(ctx);
793
877
  return;
package/dist/bot.js CHANGED
@@ -7,8 +7,8 @@ import os from 'node:os';
7
7
  import fs from 'node:fs';
8
8
  import path from 'node:path';
9
9
  import { execSync, spawn } from 'node:child_process';
10
- import { doStream, getSessions, getUsage, listAgents, } from './code-agent.js';
11
- export const VERSION = '0.2.7';
10
+ import { doStream, getSessions, getUsage, listAgents, listModels, } from './code-agent.js';
11
+ export const VERSION = '0.2.9';
12
12
  // ---------------------------------------------------------------------------
13
13
  // Helpers
14
14
  // ---------------------------------------------------------------------------
@@ -186,6 +186,16 @@ export class Bot {
186
186
  fetchAgents() {
187
187
  return listAgents();
188
188
  }
189
+ fetchModels(agent) {
190
+ return listModels(agent, { workdir: this.workdir, currentModel: this.modelForAgent(agent) });
191
+ }
192
+ setModelForAgent(agent, modelId) {
193
+ if (agent === 'codex')
194
+ this.codexModel = modelId;
195
+ else
196
+ this.claudeModel = modelId;
197
+ this.log(`model for ${agent} changed to ${modelId}`);
198
+ }
189
199
  getStatusData(chatId) {
190
200
  const cs = this.chat(chatId);
191
201
  const mem = process.memoryUsage();
@@ -222,8 +232,10 @@ export class Bot {
222
232
  switchWorkdir(newPath) {
223
233
  const old = this.workdir;
224
234
  this.workdir = newPath;
225
- for (const [, cs] of this.chats)
235
+ for (const [, cs] of this.chats) {
226
236
  cs.sessionId = null;
237
+ cs.codexCumulative = undefined;
238
+ }
227
239
  this.log(`switch workdir: ${old} -> ${newPath}`);
228
240
  return old;
229
241
  }
@@ -242,6 +254,7 @@ export class Bot {
242
254
  attachments: attachments.length ? attachments : undefined,
243
255
  codexModel: this.codexModel, codexFullAccess: this.codexFullAccess,
244
256
  codexExtraArgs: this.codexExtraArgs.length ? this.codexExtraArgs : undefined,
257
+ codexPrevCumulative: cs.codexCumulative,
245
258
  claudeModel: this.claudeModel, claudePermissionMode: this.claudePermissionMode,
246
259
  claudeExtraArgs: this.claudeExtraArgs.length ? this.claudeExtraArgs : undefined,
247
260
  };
@@ -253,6 +266,9 @@ export class Bot {
253
266
  this.stats.totalOutputTokens += result.outputTokens;
254
267
  if (result.cachedInputTokens)
255
268
  this.stats.totalCachedTokens += result.cachedInputTokens;
269
+ // Store cumulative Codex totals for next invocation delta
270
+ if (result.codexCumulative)
271
+ cs.codexCumulative = result.codexCumulative;
256
272
  // Only update sessionId if it hasn't been changed externally (e.g. user switched session during run)
257
273
  if (result.sessionId && cs.sessionId === snapshotSessionId)
258
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,6 +561,258 @@ export function listAgents() {
528
561
  ],
529
562
  };
530
563
  }
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);
815
+ }
531
816
  function toIsoFromEpochSeconds(value) {
532
817
  const n = Number(value);
533
818
  if (!Number.isFinite(n) || n <= 0)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "codeclaw",
3
- "version": "0.2.7",
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": {