agents-relay 1.0.106 → 1.0.108

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,30 @@ 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
+ .runtime-summary { display:none; }
124
+ #jobs-view .job-filters { width:auto; }
125
+ #jobs-view .job-search,
126
+ #jobs-view #quick-status,
127
+ #jobs-view #quick-autonomous,
128
+ #jobs-view #quick-draft { display:none; }
129
+ #jobs-view .panel-body { max-height:none; }
130
+ #jobs-workspace { gap:.65rem; }
131
+ #jobs-workspace .panel-body { max-height:none; }
132
+ .job-summary,.detail-groups { grid-template-columns:1fr; }
133
+ .panel-head { padding:.55rem .6rem; }
134
+ .panel-body { padding:.6rem; }
135
+ .tagline { display:none; }
136
+ }
113
137
  @media (prefers-reduced-motion:reduce) { .skeleton { animation:none; } .usage-fill { transition:none; } }
114
138
  </style>
115
139
  </head>
@@ -132,7 +156,7 @@ export const dashboardHtml = `<!doctype html>
132
156
  </section>
133
157
  <div class="workspace" id="jobs-workspace">
134
158
  <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>
159
+ <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
160
  <div class="panel-body">
137
161
  <div id="job-detail"><div class="skeleton"></div></div>
138
162
  <h3 class="section-title">Task hierarchy</h3>
@@ -209,6 +233,8 @@ const quickStatus=document.querySelector('#quick-status');
209
233
  const quickAutonomous=document.querySelector('#quick-autonomous');
210
234
  const quickDraft=document.querySelector('#quick-draft');
211
235
  const selectedJobRefresh=document.querySelector('#selected-job-refresh');
236
+ const mobileJobsBack=document.querySelector('#mobile-jobs-back');
237
+ const mobileJobs=window.matchMedia('(max-width:650px)');
212
238
  const jobDetail=document.querySelector('#job-detail');
213
239
  const taskTree=document.querySelector('#task-tree');
214
240
  const taskDetailRoot=document.querySelector('#task-detail');
@@ -245,6 +271,7 @@ function element(name,className,child){const node=document.createElement(name);i
245
271
  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
272
  function markdownField(label,input){const box=element('div','detail-group');box.append(element('p','detail-label',text(label)),markdownValue(input));return box;}
247
273
  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');}
274
+ function syncMobileJobsView(){const detail=mobileJobs.matches&&Boolean(selectedJob)&&location.hash.startsWith('#/jobs/');document.body.dataset.mobileJobsView=detail?'detail':'list';}
248
275
  function selected(){return jobs.find(job=>job.id===selectedJob)||jobs[0]||null;}
249
276
  function counts(tasks){const out={};for(const task of Array.isArray(tasks)?tasks:[])out[task.state]=(out[task.state]||0)+1;return out;}
250
277
  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 +321,7 @@ async function runTaskAction(task,action,button){const job=selected();if(!job)re
294
321
  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
322
  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
323
  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();}
324
+ function renderAll(){jobs.sort(compareJobs);syncFilters();renderJobList();renderSelectedJob();syncMobileJobsView();}
298
325
  function isCompleteUnmerged(job){return job.runtimeStatus==='MERGE_PENDING';}
299
326
  function isTerminalJob(job){return ['COMPLETED','CANCELLED'].includes(job.state)||['MERGED','CLOSED'].includes(job.githubState);}
300
327
  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 +361,9 @@ quickAutonomous.addEventListener('click',()=>toggleQuickMode('autonomous'));
334
361
  quickDraft.addEventListener('click',()=>toggleQuickMode('draft'));
335
362
  quickStatus.addEventListener('change',()=>setQuickMode(quickStatus.value?'status':'jobs',quickStatus.value));
336
363
  selectedJobRefresh.addEventListener('click',()=>void refreshSelected());
364
+ mobileJobsBack.addEventListener('click',()=>{location.hash='#/jobs';syncMobileJobsView();window.scrollTo({top:0,behavior:'instant'});});
365
+ mobileJobs.addEventListener('change',syncMobileJobsView);
366
+ window.addEventListener('hashchange',syncMobileJobsView);
337
367
  usageTab.addEventListener('click',()=>{location.hash='#/usage';showView('usage');});
338
368
  rulesTab.addEventListener('click',()=>{location.hash='#/model-router-rules';showView('rules');});
339
369
  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.108",
4
4
  "description": "Durable async agent jobs coordinated through GitHub pull requests",
5
5
  "type": "module",
6
6
  "bin": {