acdev 1.0.11 → 1.0.12

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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "acdev",
3
- "version": "1.0.11",
3
+ "version": "1.0.12",
4
4
  "description": "Local CLI + web UI for running AI agents on GitHub issues via git worktrees",
5
5
  "type": "module",
6
6
  "bin": {
package/public/app.js CHANGED
@@ -255,6 +255,7 @@ const els = {
255
255
  reviewList: document.getElementById('review-list'),
256
256
  reviewEmptyDetail: document.getElementById('review-empty-detail'),
257
257
  reviewDetailContent: document.getElementById('review-detail-content'),
258
+ reviewAgentMeta: document.getElementById('review-agent-meta'),
258
259
  reviewActions: document.getElementById('review-actions'),
259
260
  reviewFeedbackSection: document.getElementById('review-feedback-section'),
260
261
  reviewGeneralComment: document.getElementById('review-general-comment'),
@@ -936,10 +937,37 @@ function jobRepoLine(job) {
936
937
 
937
938
  function jobSubLine(job) {
938
939
  const meta = jobRepoLine(job);
939
- if (meta.isJira) {
940
- return `${meta.number}${meta.branch}`;
941
- }
942
- return `${meta.repo} #${meta.number}${meta.branch}`;
940
+ const agent = jobLlmLine(job);
941
+ const base = meta.isJira
942
+ ? `${meta.number}${meta.branch}`
943
+ : `${meta.repo} #${meta.number}${meta.branch}`;
944
+ return agent ? `${base} · ${agent}` : base;
945
+ }
946
+
947
+ /**
948
+ * @param {object | null | undefined} job
949
+ * @returns {'claude' | 'openrouter'}
950
+ */
951
+ function jobLlmProvider(job) {
952
+ if (job?.llmProvider === 'openrouter') return 'openrouter';
953
+ if (job?.llmProvider === 'claude') return 'claude';
954
+ if (job?.usage?.provider === 'openrouter') return 'openrouter';
955
+ if (job?.usage?.provider === 'claude') return 'claude';
956
+ return String(job?.model || '').includes('/') ? 'openrouter' : 'claude';
957
+ }
958
+
959
+ function jobLlmLabel(job) {
960
+ return jobLlmProvider(job) === 'openrouter' ? 'OpenRouter' : 'Claude';
961
+ }
962
+
963
+ /** `OpenRouter · google/gemini-2.5-pro` */
964
+ function jobLlmLine(job) {
965
+ if (!job) return '';
966
+ const label = jobLlmLabel(job);
967
+ const model = typeof job.model === 'string' ? job.model.trim() : '';
968
+ if (model && model !== '-') return `${label} · ${model}`;
969
+ if (job.llmProvider || job.model || job.usage?.provider) return label;
970
+ return '';
943
971
  }
944
972
 
945
973
  function issueBadge(job) {
@@ -1070,26 +1098,37 @@ function formatUsageDetail(usage) {
1070
1098
  return parts.length ? parts.join(' · ') : null;
1071
1099
  }
1072
1100
 
1073
- /** Sum cost / tokens across jobs that have usage (this app's jobs only). */
1101
+ /** Sum cost / tokens across jobs that have usage, split by LLM provider. */
1074
1102
  function aggregateJobUsage(jobs) {
1075
- let totalCostUsd = 0;
1076
- let totalTokens = 0;
1077
- let withCost = 0;
1078
- let withTokens = 0;
1103
+ const empty = () => ({
1104
+ totalCostUsd: 0,
1105
+ totalTokens: 0,
1106
+ withCost: 0,
1107
+ withTokens: 0,
1108
+ });
1109
+ const out = { claude: empty(), openrouter: empty() };
1079
1110
  for (const job of jobs) {
1080
1111
  const u = job.usage;
1081
1112
  if (!u) continue;
1113
+ const bucket = out[jobLlmProvider(job)];
1082
1114
  if (typeof u.totalCostUsd === 'number' && Number.isFinite(u.totalCostUsd)) {
1083
- totalCostUsd += u.totalCostUsd;
1084
- withCost += 1;
1115
+ bucket.totalCostUsd += u.totalCostUsd;
1116
+ bucket.withCost += 1;
1085
1117
  }
1086
1118
  const tok = (u.inputTokens || 0) + (u.outputTokens || 0);
1087
1119
  if (tok > 0) {
1088
- totalTokens += tok;
1089
- withTokens += 1;
1120
+ bucket.totalTokens += tok;
1121
+ bucket.withTokens += 1;
1090
1122
  }
1091
1123
  }
1092
- return { totalCostUsd, totalTokens, withCost, withTokens };
1124
+ return out;
1125
+ }
1126
+
1127
+ function formatProviderUsageStat(agg) {
1128
+ if (!agg.withCost && !agg.withTokens) return '—';
1129
+ const cost = formatUsd(agg.withCost ? agg.totalCostUsd : null) || '—';
1130
+ const tok = agg.withTokens ? formatTokenCount(agg.totalTokens) || '—' : '—';
1131
+ return `${cost} · ${tok}`;
1093
1132
  }
1094
1133
 
1095
1134
  /** Five progress dots: sync → worktree → agent → review → PR */
@@ -1244,6 +1283,27 @@ function formatLogEvent(event) {
1244
1283
  return { kind: 'status', label: 'Status', text, raw };
1245
1284
  }
1246
1285
 
1286
+ if (event.type === 'llm') {
1287
+ const payload = event.payload && typeof event.payload === 'object' ? event.payload : {};
1288
+ const provider =
1289
+ payload.provider === 'openrouter' || payload.provider === 'OpenRouter'
1290
+ ? 'OpenRouter'
1291
+ : payload.provider === 'claude' || payload.provider === 'Claude'
1292
+ ? 'Claude'
1293
+ : payload.provider
1294
+ ? String(payload.provider)
1295
+ : 'Agent';
1296
+ const model = typeof payload.model === 'string' && payload.model.trim()
1297
+ ? payload.model.trim()
1298
+ : '—';
1299
+ return {
1300
+ kind: 'status',
1301
+ label: 'Agent',
1302
+ text: `${provider} · ${model}`,
1303
+ raw,
1304
+ };
1305
+ }
1306
+
1247
1307
  if (event.type === 'error') {
1248
1308
  return {
1249
1309
  kind: 'error',
@@ -2039,8 +2099,6 @@ function renderStats(jobs) {
2039
2099
  const review = jobs.filter((j) => j.status === 'awaiting_review').length;
2040
2100
  const alerts = collectAlerts(jobs).length;
2041
2101
  const agg = aggregateJobUsage(jobs);
2042
- const costLabel = formatUsd(agg.withCost ? agg.totalCostUsd : null) || '—';
2043
- const tokLabel = agg.withTokens ? formatTokenCount(agg.totalTokens) || '—' : '—';
2044
2102
 
2045
2103
  const items = [
2046
2104
  { label: 'Queued', value: queued, color: 'var(--text-muted)', dot: false },
@@ -2048,18 +2106,18 @@ function renderStats(jobs) {
2048
2106
  { label: 'Ready for review', value: review, color: 'var(--primary)', dot: false },
2049
2107
  { label: 'Alerts', value: alerts, color: 'var(--accent)', dot: false },
2050
2108
  {
2051
- label: 'Job cost',
2052
- value: costLabel,
2109
+ label: 'Claude',
2110
+ value: formatProviderUsageStat(agg.claude),
2053
2111
  color: 'var(--primary)',
2054
2112
  dot: false,
2055
- hint: 'These jobs only',
2113
+ hint: 'Cost · in+out tok',
2056
2114
  },
2057
2115
  {
2058
- label: 'Tokens',
2059
- value: tokLabel,
2060
- color: 'var(--text-muted)',
2116
+ label: 'OpenRouter',
2117
+ value: formatProviderUsageStat(agg.openrouter),
2118
+ color: 'var(--primary)',
2061
2119
  dot: false,
2062
- hint: 'In + out',
2120
+ hint: 'Cost · in+out tok',
2063
2121
  },
2064
2122
  ];
2065
2123
 
@@ -2255,9 +2313,16 @@ function renderRuns(jobs) {
2255
2313
 
2256
2314
  const usageLine = document.createElement('div');
2257
2315
  usageLine.className = 'run-usage mono';
2316
+ const agentLine = jobLlmLine(job);
2258
2317
  const usageDetail = formatUsageDetail(job.usage);
2259
- usageLine.textContent = usageDetail || '—';
2260
- if (!usageDetail) usageLine.classList.add('muted');
2318
+ if (agentLine && usageDetail) {
2319
+ usageLine.textContent = `${agentLine} · ${usageDetail}`;
2320
+ } else if (agentLine) {
2321
+ usageLine.textContent = agentLine;
2322
+ } else {
2323
+ usageLine.textContent = usageDetail || '—';
2324
+ if (!usageDetail) usageLine.classList.add('muted');
2325
+ }
2261
2326
  card.appendChild(usageLine);
2262
2327
 
2263
2328
  const now = document.createElement('div');
@@ -2461,8 +2526,12 @@ function renderReview(jobs) {
2461
2526
  <div class="review-pick-sub"></div>
2462
2527
  `;
2463
2528
  btn.querySelector('.review-pick-title').textContent = jobTitle(job);
2464
- btn.querySelector('.review-pick-sub').textContent =
2465
- `${jobSubLine(job).replace(/ · .*$/, '')} · ${statusLabel(job.status)}`;
2529
+ const meta = jobRepoLine(job);
2530
+ const left = meta.isJira ? meta.number : `${meta.repo} #${meta.number}`;
2531
+ const agent = jobLlmLine(job);
2532
+ btn.querySelector('.review-pick-sub').textContent = [left, agent, statusLabel(job.status)]
2533
+ .filter(Boolean)
2534
+ .join(' · ');
2466
2535
  btn.addEventListener('click', () => selectReview(job.id));
2467
2536
  els.reviewList.appendChild(btn);
2468
2537
  }
@@ -2477,6 +2546,15 @@ function renderReview(jobs) {
2477
2546
  els.reviewEmptyDetail.classList.add('hidden');
2478
2547
  els.reviewDetailContent.classList.remove('hidden');
2479
2548
 
2549
+ if (els.reviewAgentMeta) {
2550
+ const agent = jobLlmLine(job);
2551
+ const usage = formatUsageDetail(job.usage);
2552
+ const parts = [agent, usage].filter(Boolean);
2553
+ els.reviewAgentMeta.textContent = parts.length
2554
+ ? parts.join(' · ')
2555
+ : 'Agent not recorded for this job.';
2556
+ }
2557
+
2480
2558
  const editable = job.status === 'awaiting_review';
2481
2559
  const isOpened = job.status === 'pr_opened';
2482
2560
  const isTerminal = job.status === 'discarded' || job.status === 'failed';
@@ -2725,6 +2803,8 @@ function jobMatchesReviewSearch(job, q) {
2725
2803
  ref?.full,
2726
2804
  jiraKey,
2727
2805
  job.ticketSource,
2806
+ job.llmProvider,
2807
+ job.model,
2728
2808
  ]
2729
2809
  .filter(Boolean)
2730
2810
  .map((s) => String(s).toLowerCase());
package/public/index.html CHANGED
@@ -286,6 +286,7 @@
286
286
  <div class="review-detail" id="review-detail">
287
287
  <div class="empty-dashed" id="review-empty-detail">Select a job to review</div>
288
288
  <div class="stack hidden" id="review-detail-content">
289
+ <p class="review-agent-meta" id="review-agent-meta"></p>
289
290
  <div class="card" id="review-pr-meta-card">
290
291
  <div class="field-label">PR title</div>
291
292
  <input
package/public/styles.css CHANGED
@@ -1064,6 +1064,13 @@ a { color: var(--primary); text-underline-offset: 3px; }
1064
1064
  color: var(--text-muted);
1065
1065
  }
1066
1066
 
1067
+ .review-agent-meta {
1068
+ margin: 0 0 4px;
1069
+ font-size: 13px;
1070
+ color: var(--text-muted);
1071
+ font-variant-numeric: tabular-nums;
1072
+ }
1073
+
1067
1074
  .btn-remove {
1068
1075
  width: 26px;
1069
1076
  height: 26px;
package/src/server.js CHANGED
@@ -37,7 +37,7 @@ import { publicConfig, updateConfig, normalizeLlmProvider } from './config.js';
37
37
  import { upsertEnvVars } from './env.js';
38
38
  import { listModels } from './models.js';
39
39
  import { splitIssueUrls } from './urls.js';
40
- import { usageFromLogs, withJobUsage } from './usage.js';
40
+ import { usageFromLogs, withJobUsage, snapshotJobLlm, tagUsageProvider } from './usage.js';
41
41
  import { checkGhAuth } from './gh-auth.js';
42
42
  import { checkClaudeAuth } from './claude-auth.js';
43
43
  import { checkOpenRouterAuth } from './openrouter-auth.js';
@@ -304,6 +304,25 @@ export function createServer({ repoRoot, config, store, useStubAgent = false, de
304
304
  return store.updateJob(job.id, { logs });
305
305
  }
306
306
 
307
+ /**
308
+ * Persist current LLM provider/model on the job and emit a log line.
309
+ * @param {string} jobId
310
+ */
311
+ function stampJobLlm(jobId) {
312
+ const snap = snapshotJobLlm(config);
313
+ let job = store.getJob(jobId);
314
+ if (!job) return undefined;
315
+ job = store.updateJob(jobId, snap);
316
+ job = appendLog(job, 'llm', {
317
+ provider: snap.llmProvider,
318
+ model: snap.model,
319
+ });
320
+ if (job?.logs?.length) {
321
+ emitEvent(jobId, job.logs[job.logs.length - 1]);
322
+ }
323
+ return job;
324
+ }
325
+
307
326
  function setStatus(jobId, status, extra = {}) {
308
327
  if (!store.getJob(jobId)) return undefined;
309
328
  const job = store.updateJob(jobId, { status, ...extra });
@@ -330,6 +349,9 @@ export function createServer({ repoRoot, config, store, useStubAgent = false, de
330
349
  return;
331
350
  }
332
351
 
352
+ job = stampJobLlm(jobId);
353
+ if (!job) return;
354
+
333
355
  try {
334
356
  job = setStatus(jobId, 'syncing');
335
357
  if (!job) return;
@@ -432,7 +454,7 @@ export function createServer({ repoRoot, config, store, useStubAgent = false, de
432
454
  prTitle: stripAiAttribution(prTitle),
433
455
  prBody: stripAiAttribution(prBody),
434
456
  };
435
- if (usage) patch.usage = usage;
457
+ if (usage) patch.usage = tagUsageProvider(usage, config.llmProvider);
436
458
  setStatus(jobId, 'awaiting_review', patch);
437
459
  } catch (err) {
438
460
  const message = formatAgentJobError(err);
@@ -445,7 +467,7 @@ export function createServer({ repoRoot, config, store, useStubAgent = false, de
445
467
  status: 'failed',
446
468
  error: message,
447
469
  };
448
- if (usage) failPatch.usage = usage;
470
+ if (usage) failPatch.usage = tagUsageProvider(usage, config.llmProvider);
449
471
  store.updateJob(jobId, failPatch);
450
472
  const updated = store.getJob(jobId);
451
473
  if (updated?.logs?.length) {
@@ -492,6 +514,9 @@ export function createServer({ repoRoot, config, store, useStubAgent = false, de
492
514
  return;
493
515
  }
494
516
 
517
+ job = stampJobLlm(jobId);
518
+ if (!job) return;
519
+
495
520
  try {
496
521
  const onEvent = (message) => {
497
522
  const current = store.getJob(jobId);
@@ -533,7 +558,7 @@ export function createServer({ repoRoot, config, store, useStubAgent = false, de
533
558
  prBody: stripAiAttribution(prBody),
534
559
  pendingReviewFeedback: undefined,
535
560
  };
536
- if (usage) patch.usage = usage;
561
+ if (usage) patch.usage = tagUsageProvider(usage, config.llmProvider);
537
562
  setStatus(jobId, 'awaiting_review', patch);
538
563
  } catch (err) {
539
564
  const message = formatAgentJobError(err);
@@ -547,7 +572,7 @@ export function createServer({ repoRoot, config, store, useStubAgent = false, de
547
572
  error: message,
548
573
  pendingReviewFeedback: undefined,
549
574
  };
550
- if (usage) failPatch.usage = usage;
575
+ if (usage) failPatch.usage = tagUsageProvider(usage, config.llmProvider);
551
576
  store.updateJob(jobId, failPatch);
552
577
  const updated = store.getJob(jobId);
553
578
  if (updated?.logs?.length) {
@@ -796,12 +821,15 @@ export function createServer({ repoRoot, config, store, useStubAgent = false, de
796
821
  skipped.push(item.jiraKey || item.url);
797
822
  continue;
798
823
  }
824
+ const llm = snapshotJobLlm(config);
799
825
  const job = store.addJob({
800
826
  issueUrl: item.url,
801
827
  issueNumber: item.number,
802
828
  ticketSource: item.ticketSource,
803
829
  jiraKey: item.jiraKey,
804
830
  ...(preferredBranchName ? { preferredBranchName } : {}),
831
+ llmProvider: llm.llmProvider,
832
+ model: llm.model,
805
833
  });
806
834
  created.push(job);
807
835
  }
@@ -1135,6 +1163,7 @@ export function createServer({ repoRoot, config, store, useStubAgent = false, de
1135
1163
  usage: undefined,
1136
1164
  pendingReviewFeedback: undefined,
1137
1165
  latestReviewComments: undefined,
1166
+ ...snapshotJobLlm(config),
1138
1167
  });
1139
1168
  updated = appendLog(updated, 'status', 'retry queued');
1140
1169
  res.json(updated);
package/src/store.js CHANGED
@@ -53,21 +53,27 @@ export class Store {
53
53
  * @param {{
54
54
  * issueUrl: string,
55
55
  * issueNumber?: number,
56
- * ticketSource?: 'github' | 'jira',
57
- * jiraKey?: string,
58
- * preferredBranchName?: string,
59
- * }} data
60
- * @returns {Job}
61
- */
56
+ * ticketSource?: 'github' | 'jira',
57
+ * jiraKey?: string,
58
+ * preferredBranchName?: string,
59
+ * llmProvider?: 'claude' | 'openrouter',
60
+ * model?: string,
61
+ * }} data
62
+ * @returns {Job}
63
+ */
62
64
  addJob(data) {
63
65
  const now = new Date().toISOString();
64
66
  const ticketSource = data.ticketSource === 'jira' ? 'jira' : 'github';
67
+ const llmProvider = data.llmProvider === 'openrouter' ? 'openrouter' : 'claude';
68
+ const model = typeof data.model === 'string' ? data.model.trim() : '';
65
69
  /** @type {Job} */
66
70
  const job = {
67
71
  id: randomUUID(),
68
72
  issueUrl: data.issueUrl,
69
73
  issueNumber: data.issueNumber,
70
74
  ticketSource,
75
+ llmProvider,
76
+ ...(model ? { model } : {}),
71
77
  ...(data.jiraKey ? { jiraKey: data.jiraKey } : {}),
72
78
  ...(data.preferredBranchName ? { preferredBranchName: data.preferredBranchName } : {}),
73
79
  status: 'queued',
package/src/usage.js CHANGED
@@ -11,6 +11,7 @@
11
11
  * @property {number} [cacheCreationInputTokens]
12
12
  * @property {number} [numTurns]
13
13
  * @property {number} [durationMs]
14
+ * @property {'claude' | 'openrouter'} [provider]
14
15
  * @property {Record<string, object>} [modelUsage]
15
16
  */
16
17
 
@@ -23,6 +24,73 @@ function asFiniteNumber(n) {
23
24
  return n;
24
25
  }
25
26
 
27
+ /**
28
+ * @param {object | null | undefined} job
29
+ * @returns {'claude' | 'openrouter'}
30
+ */
31
+ export function jobLlmProvider(job) {
32
+ if (job?.llmProvider === 'openrouter') return 'openrouter';
33
+ if (job?.llmProvider === 'claude') return 'claude';
34
+ if (job?.usage?.provider === 'openrouter') return 'openrouter';
35
+ if (job?.usage?.provider === 'claude') return 'claude';
36
+ const model = String(job?.model || '');
37
+ return model.includes('/') ? 'openrouter' : 'claude';
38
+ }
39
+
40
+ /**
41
+ * Snapshot of the LLM used for a job (enqueue / run start).
42
+ * @param {object | null | undefined} config
43
+ * @returns {{ llmProvider: 'claude' | 'openrouter', model: string }}
44
+ */
45
+ export function snapshotJobLlm(config) {
46
+ const llmProvider = config?.llmProvider === 'openrouter' ? 'openrouter' : 'claude';
47
+ const model = typeof config?.model === 'string' ? config.model.trim() : '';
48
+ return { llmProvider, model: model || '-' };
49
+ }
50
+
51
+ /**
52
+ * @param {JobUsage | null | undefined} usage
53
+ * @param {'claude' | 'openrouter' | string | undefined} provider
54
+ * @returns {JobUsage | null | undefined}
55
+ */
56
+ export function tagUsageProvider(usage, provider) {
57
+ if (!usage || typeof usage !== 'object') return usage;
58
+ const p = provider === 'openrouter' ? 'openrouter' : 'claude';
59
+ return { ...usage, provider: p };
60
+ }
61
+
62
+ /**
63
+ * @typedef {{ totalCostUsd: number, totalTokens: number, withCost: number, withTokens: number }} UsageAgg
64
+ * @param {object[]} jobs
65
+ * @returns {{ claude: UsageAgg, openrouter: UsageAgg }}
66
+ */
67
+ export function aggregateUsageByProvider(jobs) {
68
+ const empty = () => ({
69
+ totalCostUsd: 0,
70
+ totalTokens: 0,
71
+ withCost: 0,
72
+ withTokens: 0,
73
+ });
74
+ /** @type {{ claude: UsageAgg, openrouter: UsageAgg }} */
75
+ const out = { claude: empty(), openrouter: empty() };
76
+ if (!Array.isArray(jobs)) return out;
77
+ for (const job of jobs) {
78
+ const u = job?.usage;
79
+ if (!u) continue;
80
+ const bucket = out[jobLlmProvider(job)];
81
+ if (typeof u.totalCostUsd === 'number' && Number.isFinite(u.totalCostUsd)) {
82
+ bucket.totalCostUsd += u.totalCostUsd;
83
+ bucket.withCost += 1;
84
+ }
85
+ const tok = (u.inputTokens || 0) + (u.outputTokens || 0);
86
+ if (tok > 0) {
87
+ bucket.totalTokens += tok;
88
+ bucket.withTokens += 1;
89
+ }
90
+ }
91
+ return out;
92
+ }
93
+
26
94
  /**
27
95
  * Extract usage from an SDK `result` message (success or error subtypes).
28
96
  * @param {object | null | undefined} message