agents-relay 1.0.106 → 1.0.107

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.
@@ -37,6 +37,7 @@ export const dashboardHtml = `<!doctype html>
37
37
  .quick-filter[aria-pressed="true"] { border-color:var(--accent); background:var(--accent2); color:var(--accent); }
38
38
  .refresh-button { border:1px solid var(--border); border-radius:.35rem; background:var(--surface); color:var(--muted); padding:.28rem .55rem; font:inherit; font-size:.72rem; font-weight:700; cursor:pointer; }
39
39
  .refresh-button:disabled { cursor:default; opacity:.55; }
40
+ .mobile-only { display:none; }
40
41
  .panel-body { padding:.75rem; overflow:auto; max-height:calc(100vh - 11.5rem); }
41
42
  #jobs-workspace > .panel:first-child .panel-body { overflow:visible; max-height:none; }
42
43
  .jobs-panel { grid-row:1/span 2; }
@@ -109,7 +110,26 @@ export const dashboardHtml = `<!doctype html>
109
110
  .skeleton { height:3.2rem; border-radius:.4rem; background:linear-gradient(90deg,#edf0f4,#fafbfc,#edf0f4); animation:pulse 1.2s infinite; }
110
111
  @keyframes pulse { 50% { opacity:.55; } }
111
112
  @media (max-width:1050px) { main { grid-template-columns:1fr; } .jobs-panel { grid-row:auto; } .panel-body { max-height:24rem; } #jobs-workspace > .panel:first-child .panel-body { max-height:none; } }
112
- @media (max-width:650px) { main { padding:.65rem; } .job-summary,.detail-groups { grid-template-columns:1fr; } .tagline { display:none; } }
113
+ @media (max-width:650px) {
114
+ header { padding:.65rem .7rem; }
115
+ nav[role="tablist"] { overflow-x:auto; padding-bottom:.1rem; }
116
+ nav button { flex:0 0 auto; white-space:nowrap; }
117
+ main { padding:.5rem; gap:.65rem; }
118
+ body[data-mobile-jobs-view="list"] #jobs-workspace { display:none; }
119
+ body[data-mobile-jobs-view="detail"] #jobs-view { display:none; }
120
+ .mobile-only { display:inline-flex; }
121
+ #jobs-view .panel-head { display:grid; align-items:start; }
122
+ #jobs-view .panel-actions { width:100%; flex-wrap:wrap; }
123
+ #jobs-view .job-filters { width:100%; }
124
+ #jobs-view .job-search { width:100%; max-width:none; flex:1 1 100%; }
125
+ #jobs-view .panel-body { max-height:none; }
126
+ #jobs-workspace { gap:.65rem; }
127
+ #jobs-workspace .panel-body { max-height:none; }
128
+ .job-summary,.detail-groups { grid-template-columns:1fr; }
129
+ .panel-head { padding:.55rem .6rem; }
130
+ .panel-body { padding:.6rem; }
131
+ .tagline { display:none; }
132
+ }
113
133
  @media (prefers-reduced-motion:reduce) { .skeleton { animation:none; } .usage-fill { transition:none; } }
114
134
  </style>
115
135
  </head>
@@ -132,7 +152,7 @@ export const dashboardHtml = `<!doctype html>
132
152
  </section>
133
153
  <div class="workspace" id="jobs-workspace">
134
154
  <section class="panel" aria-labelledby="workspace-heading">
135
- <div class="panel-head"><h2 id="workspace-heading"><span id="selected-job-title" class="subtle"></span></h2><div class="panel-actions"><button id="selected-job-refresh" class="refresh-button" type="button">Refresh</button><span id="connection" class="status-pill neutral" aria-live="polite" aria-label="Connecting"></span></div></div>
155
+ <div class="panel-head"><button id="mobile-jobs-back" class="refresh-button mobile-only" type="button">← Jobs</button><h2 id="workspace-heading"><span id="selected-job-title" class="subtle"></span></h2><div class="panel-actions"><button id="selected-job-refresh" class="refresh-button" type="button">Refresh</button><span id="connection" class="status-pill neutral" aria-live="polite" aria-label="Connecting"></span></div></div>
136
156
  <div class="panel-body">
137
157
  <div id="job-detail"><div class="skeleton"></div></div>
138
158
  <h3 class="section-title">Task hierarchy</h3>
@@ -209,6 +229,8 @@ const quickStatus=document.querySelector('#quick-status');
209
229
  const quickAutonomous=document.querySelector('#quick-autonomous');
210
230
  const quickDraft=document.querySelector('#quick-draft');
211
231
  const selectedJobRefresh=document.querySelector('#selected-job-refresh');
232
+ const mobileJobsBack=document.querySelector('#mobile-jobs-back');
233
+ const mobileJobs=window.matchMedia('(max-width:650px)');
212
234
  const jobDetail=document.querySelector('#job-detail');
213
235
  const taskTree=document.querySelector('#task-tree');
214
236
  const taskDetailRoot=document.querySelector('#task-detail');
@@ -245,6 +267,7 @@ function element(name,className,child){const node=document.createElement(name);i
245
267
  function field(label,input){const box=element('div','detail-group');box.append(element('p','detail-label',text(label)),element('p','detail-value',text(input)));return box;}
246
268
  function markdownField(label,input){const box=element('div','detail-group');box.append(element('p','detail-label',text(label)),markdownValue(input));return box;}
247
269
  function rememberSelection(){if(selectedJob)sessionStorage.setItem('agents-relay:selected-job',selectedJob);else sessionStorage.removeItem('agents-relay:selected-job');if(selectedTaskId)sessionStorage.setItem('agents-relay:selected-task',selectedTaskId);else sessionStorage.removeItem('agents-relay:selected-task');}
270
+ function syncMobileJobsView(){const detail=mobileJobs.matches&&Boolean(selectedJob)&&/^#\/jobs\/.+/.test(location.hash);document.body.dataset.mobileJobsView=detail?'detail':'list';}
248
271
  function selected(){return jobs.find(job=>job.id===selectedJob)||jobs[0]||null;}
249
272
  function counts(tasks){const out={};for(const task of Array.isArray(tasks)?tasks:[])out[task.state]=(out[task.state]||0)+1;return out;}
250
273
  function compactCounts(input){const order=['RUNNING','BLOCKED','FAILED','QUEUED','DRAFT','SUCCEEDED','CANCELLED'];return order.filter(state=>input[state]).map(state=>input[state]+' '+state).join(', ')||'No tasks';}
@@ -294,7 +317,7 @@ async function runTaskAction(task,action,button){const job=selected();if(!job)re
294
317
  function renderTaskDetail(task){taskDetailRoot.replaceChildren();if(!task){taskDetailRoot.append(element('p','empty',text('Select a task to inspect execution details.')));return;}const actions=element('div','task-actions');actions.append(taskActionButton(task,'retry','Restart task','↻'),taskActionButton(task,'cancel','Cancel task','×'));const routing=task.routing||{};const groups=element('div','detail-groups');groups.append(field('Task',task.id),field('Kind',task.kind||'work'),field('State',task.state),field('Planning decision',task.planningDecisionId||'—'),field('Planning source',task.planningSource||'—'),field('Planning round',task.planningRound===undefined?'—':String(task.planningRound)),field('Project / agent',value(task.projectName)+' / '+value(task.agentName)),field('Parent task',task.parentTaskId||'root'),field('Adapter',task.adapter),field('Provider / model',value(routing.provider)+' / '+value(routing.model)),field('Reasoning / profile',value(routing.reasoning||routing.profile)),field('Attempt',attemptBudget(task)),field('Dependencies',Array.isArray(task.dependencies)&&task.dependencies.length?task.dependencies.join(', '):'none'),taskCommentField(task),chatField(task));const graph=agentGraphField(task);if(graph)groups.append(graph);if(task.plannerResult)groups.append(field('Objective status',task.plannerResult.objective_status),markdownField('Assessment',task.plannerResult.assessment));if(task.error)groups.append(field('Error',task.error));taskDetailRoot.append(actions,groups);}
295
318
  function reviewStatus(job){const review=job?.review;if(!review)return 'Not started';if(review.outcome==='approved'&&review.headSha===job.headSha)return 'MERGE READY · approved';const stale=Array.isArray(job?.tasks)&&job.tasks.some(task=>task.agentName==='reviewer'&&task.reviewHeadSha&&job.headSha&&task.reviewHeadSha!==job.headSha);if(stale)return 'STALE · awaiting review';return String(review.outcome||'pending').replace('_',' ').toUpperCase();}
296
319
  function renderSelectedJob(){jobDetail.replaceChildren();const job=selected();if(!job){selectedJobTitle.textContent='';jobDetail.append(element('div','empty',text('No durable jobs discovered.')));buildTree([]);renderTaskDetail(null);return;}selectedJobTitle.textContent=value(job.title||job.id);if(job.id!==selectedJob){selectedJob=job.id;selectedTaskId=null;eventTaskId=null;clearEvents();rememberSelection();}const summary=element('div','job-summary');summary.append(field('PR state',job.githubState||'UNKNOWN'),field('Lifecycle state',job.state),field('Runtime status',job.runtimeStatus||'IDLE'),field('Execution mode',job.executionMode||'fixed'),field('Review',reviewStatus(job)),field('Review head SHA',job.review?.headSha||job.headSha||'—'),prField(job),field('Repository',job.repository||'—'),field('Last activity',relativeTime(job.updatedAt)),field('Task states',compactCounts(job.tasks)));jobDetail.append(summary);const selectedTask=(job.tasks||[]).find(task=>task.id===selectedTaskId)||null;if(selectedTaskId&&!selectedTask){selectedTaskId=null;eventTaskId=null;clearEvents();rememberSelection();}buildTree(job.tasks||[]);renderTaskDetail(selectedTask);renderEventScope();}
297
- function renderAll(){jobs.sort(compareJobs);syncFilters();renderJobList();renderSelectedJob();}
320
+ function renderAll(){jobs.sort(compareJobs);syncFilters();renderJobList();renderSelectedJob();syncMobileJobsView();}
298
321
  function isCompleteUnmerged(job){return job.runtimeStatus==='MERGE_PENDING';}
299
322
  function isTerminalJob(job){return ['COMPLETED','CANCELLED'].includes(job.state)||['MERGED','CLOSED'].includes(job.githubState);}
300
323
  function jobGroup(job){if(isTerminalJob(job))return 3;if(job.executionMode==='autonomous')return 1;if(job.draft||job.state==='DRAFT')return 2;return 0;}
@@ -334,6 +357,9 @@ quickAutonomous.addEventListener('click',()=>toggleQuickMode('autonomous'));
334
357
  quickDraft.addEventListener('click',()=>toggleQuickMode('draft'));
335
358
  quickStatus.addEventListener('change',()=>setQuickMode(quickStatus.value?'status':'jobs',quickStatus.value));
336
359
  selectedJobRefresh.addEventListener('click',()=>void refreshSelected());
360
+ mobileJobsBack.addEventListener('click',()=>{location.hash='#/jobs';syncMobileJobsView();window.scrollTo({top:0,behavior:'instant'});});
361
+ mobileJobs.addEventListener('change',syncMobileJobsView);
362
+ window.addEventListener('hashchange',syncMobileJobsView);
337
363
  usageTab.addEventListener('click',()=>{location.hash='#/usage';showView('usage');});
338
364
  rulesTab.addEventListener('click',()=>{location.hash='#/model-router-rules';showView('rules');});
339
365
  workerRulesTab.addEventListener('click',()=>{location.hash='#/worker-router-rules';showView('worker-rules');});
@@ -108,6 +108,7 @@ export class ReconcilerReviewBase extends ReconcilerRuntimeBase {
108
108
  const usage = this.options.usageSnapshotProvider ? await this.options.usageSnapshotProvider() : undefined;
109
109
  const registered = await this.store.listAgents();
110
110
  const candidates = workerCandidates(this.options.adapters, registered, usage);
111
+ const runtimeSummary = this.options.runtimeSummaryProvider?.();
111
112
  for (const task of job.tasks.filter(item => item.kind !== 'planner' && item.agentName !== 'reviewer' && item.adapter !== 'orchestrator' && ['QUEUED', 'BLOCKED'].includes(item.state) && item.output && !item.workerSelection && (item.workerRequirements || item.planningSource === 'model-planner'))) {
112
113
  const requirements = task.workerRequirements ?? { capabilities: [] };
113
114
  const taskFacts = {
@@ -124,6 +125,7 @@ export class ReconcilerReviewBase extends ReconcilerRuntimeBase {
124
125
  decisionMode: this.options.decisionMode,
125
126
  taskFacts,
126
127
  usage,
128
+ chatgptRunning: runtimeSummary?.chatgptRunning ?? 0,
127
129
  });
128
130
  const models = await discoverModelCandidates(routedWorker.candidate, registered, usage);
129
131
  const routingConstraints = task.routing ? { provider: task.routing.provider, model: task.routing.model, profile: task.routing.profile, reasoning: task.routing.reasoning } : undefined;
package/dist/relayd.js CHANGED
@@ -17,6 +17,7 @@ import { defaultWorkspaceRoot, discoverWorkspaceRepositories, resolveManagedProj
17
17
  import { deriveJobRuntimeStatus } from './runtime-status.js';
18
18
  import { decisionMode, LayaCliDecisionEngine } from './decision.js';
19
19
  import { JsonlDecisionAuditStore } from './decision-audit.js';
20
+ import { summarizeRuntime } from './runtime-summary.js';
20
21
  export const daemonHelp = `Agents Relay daemon runs the dashboard and worker pool.
21
22
 
22
23
  Usage:
@@ -74,13 +75,13 @@ export async function runDaemon(argv) {
74
75
  const explicitProjectPath = value(argv, '--project-path');
75
76
  const usageRegistry = codexAndZaiUsageRegistry();
76
77
  const decisionAuditStore = new JsonlDecisionAuditStore(process.env.AGENTS_RELAY_DECISION_AUDIT_PATH ?? join(homedir(), '.agents-relay', 'decision-audit.jsonl'));
77
- const makeReconciler = (store, maxConcurrent) => new Reconciler(store, { owner: `relayd-${process.pid}`, maxConcurrent, leaseMs: Number(value(argv, '--lease-ms', '300000')), adapters: [new CodexAdapter(value(argv, '--codex', 'codex')), new ChatGptAdapter()], continuations: [new CodexThreadContinuation(value(argv, '--codex', 'codex')), new CommandContinuation(), new WebhookContinuation()], planner: runtimePlanner(argv), usageSnapshotProvider: () => usageRegistry.snapshot(), decisionEngine: new LayaCliDecisionEngine(), decisionMode: decisionMode(), decisionAuditStore, eventBus: bus, reviewer: runtimeReviewer(), projectPathForJob: job => repositoryPaths.get(job.repository) ?? resolveManagedProjectPath(job.repository, explicitProjectPath, workspaceRoot) });
78
+ let cachedOverviews = [];
79
+ const makeReconciler = (store, maxConcurrent) => new Reconciler(store, { owner: `relayd-${process.pid}`, maxConcurrent, leaseMs: Number(value(argv, '--lease-ms', '300000')), adapters: [new CodexAdapter(value(argv, '--codex', 'codex')), new ChatGptAdapter()], continuations: [new CodexThreadContinuation(value(argv, '--codex', 'codex')), new CommandContinuation(), new WebhookContinuation()], planner: runtimePlanner(argv), usageSnapshotProvider: () => usageRegistry.snapshot(), runtimeSummaryProvider: () => ({ chatgptRunning: summarizeRuntime(cachedOverviews.map(item => item.job)).workers.chatgpt }), decisionEngine: new LayaCliDecisionEngine(), decisionMode: decisionMode(), decisionAuditStore, eventBus: bus, reviewer: runtimeReviewer(), projectPathForJob: job => repositoryPaths.get(job.repository) ?? resolveManagedProjectPath(job.repository, explicitProjectPath, workspaceRoot) });
78
80
  if (repositories.length === 0)
79
81
  throw new Error(`No Git repositories with GitHub origins found under ${normalized.workspaceRoot ?? defaultWorkspaceRoot()}`);
80
82
  const pool = new RepositoryWorkerPool(client, repositories, trusted, concurrency, makeReconciler);
81
83
  const cacheDirectory = join(homedir(), '.agents-relay');
82
84
  const cacheFile = join(cacheDirectory, 'dashboard-jobs.json');
83
- let cachedOverviews = [];
84
85
  try {
85
86
  const parsed = JSON.parse(await readFile(cacheFile, 'utf8'));
86
87
  if (Array.isArray(parsed))
@@ -1,11 +1,13 @@
1
1
  import { chooseWithDecision } from './decision.js';
2
2
  import { loadWorkerRouterRules } from './routing-policy.js';
3
+ export const DEFAULT_CHATGPT_PREFERRED_CONCURRENCY = 10;
3
4
  export const DEFAULT_WORKER_ROUTER_PRINCIPLES = [
4
5
  'Worker Router chooses only the execution worker. Model Router chooses provider/model/profile after worker selection.',
5
6
  'Prefer ChatGPT when both workers can execute the task because it does not consume Codex coding-plan tokens and normally has access to a stronger ChatGPT Sol model.',
6
7
  'Prefer Codex when the task requires native/local execution or computer-use abilities that are unavailable through ChatGPT plus MacBridge.',
7
8
  'Treat coding-plan/provider quota as decision context, not worker legality. Codex may still use another provider selected later by Model Router.',
8
9
  'When Codex/OpenAI coding quota is scarce, prefer ChatGPT unless the Codex execution environment is necessary.',
10
+ 'When current ChatGPT running load reaches the preferred ChatGPT concurrency, strongly prefer Codex unless ChatGPT is materially required or better suited for the task.',
9
11
  ].join('\n');
10
12
  export function usageForProvider(snapshot, provider, now = Date.now()) {
11
13
  const usage = snapshot?.providers.find(item => item.id === provider)?.usage;
@@ -84,9 +86,10 @@ export function satisfiesWorker(candidate, requirements) {
84
86
  export function eligibleWorkerCandidates(candidates, requirements) {
85
87
  return candidates.filter(candidate => satisfiesWorker(candidate, requirements));
86
88
  }
87
- function deterministicWorker(eligible) {
89
+ function deterministicWorker(eligible, chatgptRunning = 0, preferredChatgptConcurrency = DEFAULT_CHATGPT_PREFERRED_CONCURRENCY) {
90
+ const chatgptAtPreferenceLimit = chatgptRunning >= preferredChatgptConcurrency;
88
91
  return [...eligible].map((candidate, index) => ({ candidate, index })).sort((left, right) => {
89
- const preference = (item) => item.adapter === 'chatgpt' ? 0 : 1;
92
+ const preference = (item) => chatgptAtPreferenceLimit ? (item.adapter === 'codex' ? 0 : 1) : (item.adapter === 'chatgpt' ? 0 : 1);
90
93
  return preference(left.candidate) - preference(right.candidate) || left.index - right.index;
91
94
  })[0].candidate;
92
95
  }
@@ -120,7 +123,9 @@ export async function routeWorker(candidates, requirements, options = {}) {
120
123
  const eligible = eligibleWorkerCandidates(candidates, requirements);
121
124
  if (eligible.length === 0)
122
125
  throw new Error('No eligible worker satisfies the authoritative worker constraints');
123
- const deterministic = deterministicWorker(eligible);
126
+ const preferredChatgptConcurrency = options.preferredChatgptConcurrency ?? DEFAULT_CHATGPT_PREFERRED_CONCURRENCY;
127
+ const chatgptRunning = options.chatgptRunning ?? 0;
128
+ const deterministic = deterministicWorker(eligible, chatgptRunning, preferredChatgptConcurrency);
124
129
  if (eligible.length === 1) {
125
130
  const evidence = { mode: options.decisionMode ?? 'off', fallback: true };
126
131
  return { candidate: deterministic, selection: selection(deterministic, 'worker-router:constraint', evidence) };
@@ -132,6 +137,10 @@ export async function routeWorker(candidates, requirements, options = {}) {
132
137
  routing_principles: DEFAULT_WORKER_ROUTER_PRINCIPLES,
133
138
  environment_rules: rules.text,
134
139
  usage_snapshot: options.usage ?? null,
140
+ runtime_load: {
141
+ chatgpt_running: chatgptRunning,
142
+ preferred_chatgpt_concurrency: preferredChatgptConcurrency,
143
+ },
135
144
  candidates: eligible.map(item => ({
136
145
  id: item.workerId,
137
146
  adapter: item.adapter,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agents-relay",
3
- "version": "1.0.106",
3
+ "version": "1.0.107",
4
4
  "description": "Durable async agent jobs coordinated through GitHub pull requests",
5
5
  "type": "module",
6
6
  "bin": {