agents-relay 1.0.75 → 1.0.77

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/README.md CHANGED
@@ -135,6 +135,8 @@ events, durable-thread reuse, resume, or retry behavior.
135
135
 
136
136
  Open the dashboard on localhost port 8787. The marker format is intentionally public and append-friendly; the CLI uses GitHubStore with the authenticated gh client in operational mode.
137
137
 
138
+ External product feedback uses the reusable `src/feedback.ts` contract. See [docs/feedback-intake.md](docs/feedback-intake.md) for the schema, attachment limits, privacy rules, materialization path, and provenance envelope for later Jobs/Tasks/PRs.
139
+
138
140
  reconcile leases and launches ready tasks without waiting inside the runtime scheduler; the CLI waits only long enough for its launched workers to persist results. agents-relayd (or the equivalent npx agents-relay serve) is the watchdog/dashboard mode. Model-backed workers require --events nats; the event transport carries authoritative terminal worker events as well as wake/progress traffic. See [docs/service.md](docs/service.md). --file PATH is explicit local demo/test mode only.
139
141
 
140
142
  `agents-relayd` without `--pr`/`--id` runs in workspace mode by default: it discovers nested Git repositories under `~/Workspace`, keeps only GitHub `origin` remotes, and aggregates their managed PR jobs into one dashboard and worker pool. Pass `--workspace PATH` to use another root. `--repo OWNER/REPO` remains the single-repository pool mode.
package/dist/dashboard.js CHANGED
@@ -80,6 +80,7 @@ export const dashboardHtml = `<!doctype html>
80
80
  .panel-actions { display:flex; align-items:center; gap:.45rem; }
81
81
  .job-filters { display:flex; gap:.35rem; align-items:center; flex-wrap:wrap; }
82
82
  .job-filter { max-width:145px; border:1px solid var(--border); border-radius:.35rem; background:var(--surface); color:var(--text); padding:.27rem .4rem; font:inherit; font-size:.72rem; }
83
+ .job-search { width:190px; max-width:28vw; border:1px solid var(--border); border-radius:.35rem; background:var(--surface); color:var(--text); padding:.27rem .45rem; font:inherit; font-size:.72rem; }
83
84
  .quick-filter { border:1px solid var(--border); border-radius:.35rem; background:var(--surface); color:var(--muted); padding:.27rem .42rem; font:inherit; font-size:.7rem; font-weight:750; cursor:pointer; }
84
85
  .quick-filter[aria-pressed="true"] { border-color:var(--accent); background:var(--accent2); color:var(--accent); }
85
86
  .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; }
@@ -125,6 +126,12 @@ export const dashboardHtml = `<!doctype html>
125
126
  .task-row[aria-current="true"] { background:var(--accent2); }
126
127
  .task-id { min-width:0; flex:1; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; font: .77rem ui-monospace,SFMono-Regular,Menlo,monospace; }
127
128
  .task-actions { display:flex; justify-content:flex-end; gap:.25rem; margin-bottom:.5rem; }
129
+ .task-link { color:var(--accent); font-weight:700; }
130
+ .agent-graph { display:grid; gap:.45rem; margin-top:.55rem; padding:.55rem; border:1px solid var(--border); border-radius:.4rem; background:var(--surface2); overflow:auto; }
131
+ .agent-wave { display:flex; align-items:center; gap:.4rem; min-width:max-content; }
132
+ .agent-node { padding:.32rem .48rem; border:1px solid var(--border); border-radius:.35rem; background:var(--surface); font:.73rem ui-monospace,SFMono-Regular,Menlo,monospace; }
133
+ .agent-arrow { color:var(--muted); font-weight:800; }
134
+ .agent-graph-source { margin:.1rem 0 0; padding:.45rem; border-radius:.35rem; background:var(--surface); color:var(--muted); font:.7rem/1.45 ui-monospace,SFMono-Regular,Menlo,monospace; white-space:pre; overflow:auto; }
128
135
  .event-list { display:grid; gap:.45rem; }
129
136
  .event-item { padding-bottom:.4rem; border-bottom:1px solid var(--border); }
130
137
  .event-meta { display:flex; flex-wrap:wrap; gap:.4rem; color:var(--muted); font-size:.68rem; }
@@ -159,7 +166,7 @@ export const dashboardHtml = `<!doctype html>
159
166
  </header>
160
167
  <main>
161
168
  <section id="jobs-view" role="tabpanel" aria-labelledby="jobs-tab" class="panel jobs-panel">
162
- <div class="panel-head"><h2>Jobs</h2><div class="panel-actions"><div class="job-filters"><select id="quick-status" class="job-filter" aria-label="Filter jobs by derived status"><option value="">STATUS</option></select><button id="quick-autonomous" class="quick-filter" type="button" aria-pressed="false">AUTONOMOUS</button><button id="quick-draft" class="quick-filter" type="button" aria-pressed="false">DRAFT</button><select id="repo-filter" class="job-filter" aria-label="Filter jobs by repository"><option value="">All repos</option></select></div><button id="jobs-refresh" class="refresh-button" type="button">Refresh</button></div></div>
169
+ <div class="panel-head"><h2>Jobs</h2><div class="panel-actions"><div class="job-filters"><input id="job-search" class="job-search" type="search" placeholder="Search title or PR #" aria-label="Search jobs by title or PR number"><select id="quick-status" class="job-filter" aria-label="Filter jobs by derived status"><option value="">STATUS</option></select><button id="quick-autonomous" class="quick-filter" type="button" aria-pressed="false">AUTONOMOUS</button><button id="quick-draft" class="quick-filter" type="button" aria-pressed="false">DRAFT</button><select id="repo-filter" class="job-filter" aria-label="Filter jobs by repository"><option value="">All repos</option></select></div><button id="jobs-refresh" class="refresh-button" type="button">Refresh</button></div></div>
163
170
  <div class="panel-body" aria-live="polite"><ul id="job-list" class="job-list"><li class="skeleton" aria-hidden="true"></li></ul></div>
164
171
  </section>
165
172
  <div class="workspace" id="jobs-workspace">
@@ -207,6 +214,7 @@ const usageView=document.querySelector('#usage-view');
207
214
  const jobsWorkspace=document.querySelector('#jobs-workspace');
208
215
  const jobList=document.querySelector('#job-list');
209
216
  const jobsRefresh=document.querySelector('#jobs-refresh');
217
+ const jobSearch=document.querySelector('#job-search');
210
218
  const repoFilter=document.querySelector('#repo-filter');
211
219
  const quickStatus=document.querySelector('#quick-status');
212
220
  const quickAutonomous=document.querySelector('#quick-autonomous');
@@ -229,6 +237,7 @@ let selectedTaskId=sessionStorage.getItem('agents-relay:selected-task');
229
237
  let selectedRepo=sessionStorage.getItem('agents-relay:repo-filter')||'';
230
238
  let quickMode=sessionStorage.getItem('agents-relay:quick-filter')||'jobs';
231
239
  let selectedRuntimeStatus=sessionStorage.getItem('agents-relay:runtime-status-filter')||'';
240
+ let searchQuery=sessionStorage.getItem('agents-relay:job-search')||'';
232
241
  let eventTaskId=selectedTaskId;
233
242
  const displayedEventIds=new Set();
234
243
 
@@ -245,12 +254,17 @@ function relativeTime(timestamp){if(!timestamp)return '—';const parsed=Date.pa
245
254
  function safePrUrl(repository,prNumber){if(typeof repository!=='string'||!Number.isInteger(prNumber)||prNumber<=0)return null;const parts=repository.split('/');if(parts.length!==2)return null;const [owner,repo]=parts;if(!owner||!repo||owner==='.'||owner==='..'||repo==='.'||repo==='..')return null;if(!/^[A-Za-z0-9-]+$/.test(owner)||!/^[A-Za-z0-9._-]+$/.test(repo))return null;return 'https://github.com/'+owner+'/'+repo+'/pull/'+prNumber;}
246
255
  function prField(job){const box=element('div','detail-group');box.append(element('p','detail-label',text('GitHub PR')));const line=element('p','detail-value');const url=safePrUrl(job.repository,job.prNumber);if(url){const link=document.createElement('a');link.href=url;link.target='_blank';link.rel='noopener noreferrer';link.textContent='PR #'+job.prNumber;line.append(link);}else line.append(text('unavailable'));box.append(line);return box;}
247
256
  function chatField(task){const box=element('div','detail-group');box.append(element('p','detail-label',text('Chat')));const line=element('p','detail-value');if(task.adapter==='chatgpt'&&typeof task.threadId==='string'&&task.threadId){const link=document.createElement('a');link.href='https://chatgpt.com/c/'+encodeURIComponent(task.threadId);link.target='_blank';link.rel='noopener noreferrer';link.textContent='Open chat';line.append(link);}else line.append(text('—'));box.append(line);return box;}
257
+ function taskCommentField(task){const box=element('div','detail-group');box.append(element('p','detail-label',text('PR comment')));const line=element('p','detail-value');if(typeof task.taskUrl==='string'&&task.taskUrl.startsWith('https://github.com/')){const link=document.createElement('a');link.className='task-link';link.href=task.taskUrl;link.target='_blank';link.rel='noopener noreferrer';link.textContent='Open task comment';line.append(link);}else line.append(text('—'));box.append(line);return box;}
258
+ function parseAgentGraphSource(source){if(typeof source!=='string'||!source.trim())return null;const agents=new Map(),edges=[];for(const raw of source.split(/\\r?\\n/)){const line=raw.trim();if(!line||line.startsWith('%%')||line.startsWith('flowchart '))continue;let match=line.match(/^([A-Za-z_][A-Za-z0-9_.-]*)(?:\\[agent:([A-Za-z_][A-Za-z0-9_.-]*)\\])?\\s*-->\\s*([A-Za-z_][A-Za-z0-9_.-]*)(?:\\[agent:([A-Za-z_][A-Za-z0-9_.-]*)\\])?$/);if(match){agents.set(match[1],match[2]||agents.get(match[1])||match[1]);agents.set(match[3],match[4]||agents.get(match[3])||match[3]);edges.push([match[1],match[3]]);continue;}match=line.match(/^([A-Za-z_][A-Za-z0-9_.-]*)\\[agent:([A-Za-z_][A-Za-z0-9_.-]*)\\]$/);if(match){agents.set(match[1],match[2]);continue;}return null;}const incoming=new Map([...agents.keys()].map(id=>[id,0]));for(const [,to] of edges)incoming.set(to,(incoming.get(to)||0)+1);const remaining=new Map(incoming),waves=[];let ready=[...remaining].filter(([,degree])=>degree===0).map(([id])=>id).sort();while(ready.length){waves.push(ready);const next=[];for(const id of ready)for(const [from,to] of edges)if(from===id){const degree=(remaining.get(to)||0)-1;remaining.set(to,degree);if(degree===0)next.push(to);}ready=[...new Set(next)].sort();}return {agents,waves};}
259
+ function agentGraphField(task){if(typeof task.agentGraph!=='string'||!task.agentGraph.trim())return null;const box=element('div','detail-group');box.style.gridColumn='1 / -1';box.append(element('p','detail-label',text('Task agent graph')));const graph=element('div','agent-graph');const parsed=parseAgentGraphSource(task.agentGraph);if(parsed&&parsed.waves.length){parsed.waves.forEach((wave,index)=>{const row=element('div','agent-wave');if(index>0)row.append(element('span','agent-arrow',text('↓')));for(const id of wave)row.append(element('span','agent-node',text(id+' · '+(parsed.agents.get(id)||id))));graph.append(row);});}const source=document.createElement('pre');source.className='agent-graph-source';source.textContent=task.agentGraph;graph.append(source);box.append(graph);return box;}
248
260
  function clearEvents(){displayedEventIds.clear();eventFeed.replaceChildren(element('li','empty',text('Waiting for events.')));}
249
261
  async function loadEventHistory(){if(!selectedJob){clearEvents();return;}const params=new URLSearchParams({job:selectedJob,limit:String(MAX_EVENTS)});if(eventTaskId)params.set('task',eventTaskId);try{const history=await loadJSON('/api/events?'+params.toString());clearEvents();for(const event of Array.isArray(history)?history.slice().reverse():[])addEvent(event);}catch{clearEvents();}}
250
262
  function selectJob(id){selectedJob=id;selectedTaskId=null;eventTaskId=null;rememberSelection();clearEvents();location.hash=id?'#/jobs/'+encodeURIComponent(id):'#/jobs';renderAll();void loadEventHistory();}
251
263
  function matchesQuickFilter(job){if(quickMode==='autonomous')return job.executionMode==='autonomous';if(quickMode==='draft')return job.draft||job.state==='DRAFT';if(quickMode==='status')return !!selectedRuntimeStatus&&(job.runtimeStatus||'IDLE')===selectedRuntimeStatus;return true;}
252
- function filteredJobs(){return jobs.filter(job=>(!selectedRepo||job.repository===selectedRepo)&&matchesQuickFilter(job));}
264
+ function matchesSearch(job){const query=searchQuery.trim().toLowerCase();if(!query)return true;const pr=String(job.prNumber||'');const normalized=query.replace(/^pr\s*#?/,'').replace(/^#/,'');return String(job.title||job.id||'').toLowerCase().includes(query)||pr.includes(normalized);}
265
+ function filteredJobs(){return jobs.filter(job=>(!selectedRepo||job.repository===selectedRepo)&&matchesQuickFilter(job)&&matchesSearch(job));}
253
266
  function setQuickMode(mode,status=''){quickMode=mode;selectedRuntimeStatus=mode==='status'?status:'';sessionStorage.setItem('agents-relay:quick-filter',quickMode);if(selectedRuntimeStatus)sessionStorage.setItem('agents-relay:runtime-status-filter',selectedRuntimeStatus);else sessionStorage.removeItem('agents-relay:runtime-status-filter');syncFilters();renderJobList();}
267
+ function toggleQuickMode(mode){setQuickMode(quickMode===mode?'jobs':mode);}
254
268
  function syncFilters(){
255
269
  const repos=[...new Set(jobs.map(job=>job.repository).filter(Boolean))].sort();
256
270
  const previousRepo=selectedRepo;
@@ -265,6 +279,7 @@ function syncFilters(){
265
279
  quickStatus.value=quickMode==='status'?selectedRuntimeStatus:'';
266
280
  quickAutonomous.setAttribute('aria-pressed',String(quickMode==='autonomous'));
267
281
  quickDraft.setAttribute('aria-pressed',String(quickMode==='draft'));
282
+ jobSearch.value=searchQuery;
268
283
  }
269
284
  function actionEnabled(job,action){if(action==='start')return job.state==='DRAFT'&&job.githubState!=='CLOSED'&&job.githubState!=='MERGED';if(action==='reconcile')return job.state==='ACTIVE'&&job.githubState==='OPEN';if(action==='cancel')return job.state!=='COMPLETED'&&job.state!=='CANCELLED'&&job.githubState==='OPEN';return false;}
270
285
  function actionButton(job,action,label,icon){const button=document.createElement('button');button.type='button';button.className='job-action';button.textContent=icon;button.title=label;button.setAttribute('aria-label',label);button.disabled=!actionEnabled(job,action);button.addEventListener('click',event=>{event.stopPropagation();void runJobAction(job,action,button);});return button;}
@@ -278,7 +293,7 @@ function attemptBudget(task){const attempt=Number(task?.attempt)||0;const max=Nu
278
293
  function taskActionEnabled(task,action){if(action==='retry')return ['FAILED','BLOCKED'].includes(task.state);if(action==='cancel')return ['DRAFT','QUEUED','BLOCKED','FAILED'].includes(task.state);return false;}
279
294
  function taskActionButton(task,action,label,icon){const button=document.createElement('button');button.type='button';button.className='job-action';button.textContent=icon;button.title=label;button.setAttribute('aria-label',label);button.disabled=!taskActionEnabled(task,action);button.addEventListener('click',()=>void runTaskAction(task,action,button));return button;}
280
295
  async function runTaskAction(task,action,button){const job=selected();if(!job)return;button.disabled=true;try{const response=await fetch('/api/task-action?id='+encodeURIComponent(job.id)+'&task='+encodeURIComponent(task.id)+'&action='+encodeURIComponent(action),{method:'POST'});if(!response.ok)throw new Error(response.status+' '+response.statusText);const updated=await response.json();const index=jobs.findIndex(candidate=>candidate.id===job.id);if(index>=0)jobs[index]=updated;else jobs.push(updated);renderAll();void loadEventHistory();}catch(error){button.title=(action==='retry'?'Restart':'Cancel')+': '+error.message;}finally{renderAll();}}
281
- 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'),chatField(task));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);}
296
+ 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);}
282
297
  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();}
283
298
  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();}
284
299
  function renderAll(){jobs.sort(compareJobs);syncFilters();renderJobList();renderSelectedJob();}
@@ -309,8 +324,9 @@ jobsTab.addEventListener('click',()=>{location.hash=selectedJob?'#/jobs/'+encode
309
324
  jobsRefresh.addEventListener('click',()=>void refresh(true));
310
325
  eventsAllTasks.addEventListener('click',()=>{eventTaskId=null;clearEvents();renderEventScope();void loadEventHistory();});
311
326
  repoFilter.addEventListener('change',()=>{selectedRepo=repoFilter.value;sessionStorage.setItem('agents-relay:repo-filter',selectedRepo);renderJobList();});
312
- quickAutonomous.addEventListener('click',()=>setQuickMode('autonomous'));
313
- quickDraft.addEventListener('click',()=>setQuickMode('draft'));
327
+ jobSearch.addEventListener('input',()=>{searchQuery=jobSearch.value;sessionStorage.setItem('agents-relay:job-search',searchQuery);renderJobList();});
328
+ quickAutonomous.addEventListener('click',()=>toggleQuickMode('autonomous'));
329
+ quickDraft.addEventListener('click',()=>toggleQuickMode('draft'));
314
330
  quickStatus.addEventListener('change',()=>setQuickMode(quickStatus.value?'status':'jobs',quickStatus.value));
315
331
  selectedJobRefresh.addEventListener('click',()=>void refreshSelected());
316
332
  usageTab.addEventListener('click',()=>{location.hash='#/usage';showView('usage');});
@@ -0,0 +1,234 @@
1
+ import { createHash, randomUUID } from 'node:crypto';
2
+ import { mkdir, readFile, readdir, rename, rm, writeFile } from 'node:fs/promises';
3
+ import { basename, join, resolve } from 'node:path';
4
+ export const FEEDBACK_MARKER = 'agents-relay:feedback:v1';
5
+ export const FEEDBACK_SCHEMA = 'agents-relay.feedback';
6
+ export const FEEDBACK_SCHEMA_VERSION = 1;
7
+ export const FEEDBACK_MAX_TEXT_LENGTH = 20_000;
8
+ export const FEEDBACK_MAX_ATTACHMENTS = 10;
9
+ export const FEEDBACK_MAX_ATTACHMENT_BYTES = 10 * 1024 * 1024;
10
+ export const FEEDBACK_IMAGE_TYPES = ['image/gif', 'image/jpeg', 'image/png', 'image/webp'];
11
+ export const DEFAULT_FEEDBACK_LIMITS = {
12
+ maxTextLength: 20_000,
13
+ maxAttachments: 5,
14
+ maxAttachmentBytes: 10 * 1024 * 1024,
15
+ allowedMediaTypes: ['image/png', 'image/jpeg', 'image/webp', 'image/gif'],
16
+ };
17
+ function effectiveLimits(limits = {}) {
18
+ return {
19
+ maxTextLength: limits.maxTextLength ?? DEFAULT_FEEDBACK_LIMITS.maxTextLength,
20
+ maxAttachments: limits.maxAttachments ?? DEFAULT_FEEDBACK_LIMITS.maxAttachments,
21
+ maxAttachmentBytes: limits.maxAttachmentBytes ?? DEFAULT_FEEDBACK_LIMITS.maxAttachmentBytes,
22
+ allowedMediaTypes: limits.allowedMediaTypes ?? DEFAULT_FEEDBACK_LIMITS.allowedMediaTypes,
23
+ };
24
+ }
25
+ function fail(message) { throw new Error(`Invalid feedback: ${message}`); }
26
+ function safeId(id) {
27
+ if (!/^[a-f0-9-]{36}$/i.test(id))
28
+ fail('feedback id is invalid');
29
+ return id;
30
+ }
31
+ function cleanFilename(filename) {
32
+ const cleaned = basename(filename).replace(/[^a-zA-Z0-9._-]/g, '_').slice(0, 120);
33
+ if (!cleaned || cleaned === '.' || cleaned === '..')
34
+ fail('attachment filename is invalid');
35
+ return cleaned;
36
+ }
37
+ function cleanOptional(value, field) {
38
+ if (value === undefined)
39
+ return undefined;
40
+ if (value.length > 500)
41
+ fail(`${field} is too long`);
42
+ return value;
43
+ }
44
+ function validateProvenance(input) {
45
+ if (!input.product.trim())
46
+ fail('provenance.product is required');
47
+ if (!input.submittedAt || Number.isNaN(Date.parse(input.submittedAt)))
48
+ fail('provenance.submittedAt must be an ISO timestamp');
49
+ if (input.contact && !input.contact.value.trim())
50
+ fail('contact value must not be empty');
51
+ // Only the explicitly documented fields are copied. Upload/browser/provider metadata is never persisted.
52
+ return {
53
+ source: input.source,
54
+ product: input.product.trim().slice(0, 200),
55
+ surface: cleanOptional(input.surface, 'surface'),
56
+ setupStep: cleanOptional(input.setupStep, 'setupStep'),
57
+ actorClass: input.actorClass,
58
+ version: cleanOptional(input.version, 'version'),
59
+ build: cleanOptional(input.build, 'build'),
60
+ submittedAt: new Date(input.submittedAt).toISOString(),
61
+ ...(input.contact ? { contact: { channel: input.contact.channel, value: input.contact.value.trim().slice(0, 500) } } : {}),
62
+ };
63
+ }
64
+ export function marker(label, value) {
65
+ return `<!-- ${label} ${JSON.stringify(value).replace(/>/g, '\\u003e')} -->`;
66
+ }
67
+ export function renderFeedbackMarker(record) {
68
+ const { userContent: _userContent, ...metadata } = record;
69
+ return marker(FEEDBACK_MARKER, metadata);
70
+ }
71
+ export function parseFeedbackMarker(value) {
72
+ const match = value.match(/<!--\s*agents-relay:feedback:v1\s+([\s\S]*?)\s*-->/);
73
+ if (!match)
74
+ return null;
75
+ try {
76
+ const parsed = JSON.parse(match[1]);
77
+ return parsed.schema === FEEDBACK_MARKER && parsed.schemaVersion === FEEDBACK_SCHEMA_VERSION ? parsed : null;
78
+ }
79
+ catch {
80
+ return null;
81
+ }
82
+ }
83
+ export class FileFeedbackStore {
84
+ root;
85
+ limits;
86
+ constructor(root, limits = {}) {
87
+ this.root = resolve(root);
88
+ this.limits = effectiveLimits(limits);
89
+ }
90
+ async submit(submission) {
91
+ const existing = submission.idempotencyKey ? await this.findByIdempotencyKey(submission.idempotencyKey) : null;
92
+ if (existing)
93
+ return { record: existing, recordRef: this.recordPath(existing.id) };
94
+ const text = submission.text.trim();
95
+ if (!text)
96
+ fail('text is required');
97
+ if (text.length > this.limits.maxTextLength)
98
+ fail(`text exceeds ${this.limits.maxTextLength} characters`);
99
+ const inputs = submission.attachments ?? [];
100
+ if (inputs.length > this.limits.maxAttachments)
101
+ fail(`at most ${this.limits.maxAttachments} attachments are allowed`);
102
+ const now = new Date().toISOString();
103
+ const id = randomUUID();
104
+ const attachmentDir = join(this.root, 'attachments', id);
105
+ const attachments = [];
106
+ await mkdir(attachmentDir, { recursive: true });
107
+ try {
108
+ for (const input of inputs) {
109
+ if (!this.limits.allowedMediaTypes.includes(input.mediaType.toLowerCase()))
110
+ fail(`unsupported attachment type: ${input.mediaType}`);
111
+ if (input.data.byteLength === 0 || input.data.byteLength > this.limits.maxAttachmentBytes)
112
+ fail(`attachment exceeds ${this.limits.maxAttachmentBytes} bytes`);
113
+ const attachmentId = randomUUID();
114
+ const filename = cleanFilename(input.filename);
115
+ const relative = join('attachments', id, `${attachmentId}-${filename}`);
116
+ await writeFile(join(this.root, relative), input.data, { flag: 'wx' });
117
+ attachments.push({ id: attachmentId, filename, mediaType: input.mediaType.toLowerCase(), sizeBytes: input.data.byteLength, sha256: createHash('sha256').update(input.data).digest('hex'), storageRef: relative, uploadedAt: now });
118
+ }
119
+ const record = {
120
+ schema: FEEDBACK_MARKER, schemaVersion: FEEDBACK_SCHEMA_VERSION, id,
121
+ ...(submission.idempotencyKey ? { idempotencyKey: submission.idempotencyKey } : {}),
122
+ userContent: { text }, attachments, systemProvenance: validateProvenance(submission.provenance),
123
+ triage: { status: 'new', updatedAt: now }, downstream: [], createdAt: now,
124
+ };
125
+ await mkdir(join(this.root, 'records'), { recursive: true });
126
+ const recordRef = this.recordPath(id);
127
+ const temp = `${recordRef}.${randomUUID()}.tmp`;
128
+ await writeFile(temp, `${JSON.stringify(record, null, 2)}\n`, { encoding: 'utf8', flag: 'wx' });
129
+ await rename(temp, recordRef);
130
+ return { record, recordRef };
131
+ }
132
+ catch (error) {
133
+ await this.removeAttachmentDirectory(attachmentDir);
134
+ throw error;
135
+ }
136
+ }
137
+ async read(id) {
138
+ const parsed = JSON.parse(await readFile(this.recordPath(safeId(id)), 'utf8'));
139
+ if (parsed.schema !== FEEDBACK_MARKER || parsed.schemaVersion !== FEEDBACK_SCHEMA_VERSION)
140
+ fail('record schema is unsupported');
141
+ return parsed;
142
+ }
143
+ recordPath(id) { return join(this.root, 'records', `${id}.json`); }
144
+ async findByIdempotencyKey(key) {
145
+ const directory = join(this.root, 'records');
146
+ let entries;
147
+ try {
148
+ entries = await readdir(directory);
149
+ }
150
+ catch {
151
+ return null;
152
+ }
153
+ for (const entry of entries.filter(item => item.endsWith('.json'))) {
154
+ const record = JSON.parse(await readFile(join(directory, entry), 'utf8'));
155
+ if (record.idempotencyKey === key)
156
+ return record;
157
+ }
158
+ return null;
159
+ }
160
+ async removeAttachmentDirectory(path) {
161
+ try {
162
+ await rm(path, { recursive: true, force: true });
163
+ }
164
+ catch { /* best-effort cleanup; the record was not materialized */ }
165
+ }
166
+ }
167
+ export async function materializeFeedbackRecord(root, submission, limits) {
168
+ return new FileFeedbackStore(root, limits).submit(submission);
169
+ }
170
+ /** Redacts common bearer/API-key shapes before a product stores user text. */
171
+ export function redactSensitiveText(text) {
172
+ return text
173
+ .replace(/\bBearer\s+[A-Za-z0-9._~+/=-]+/gi, 'Bearer [REDACTED]')
174
+ .replace(/\b(?:sk|pk)_[A-Za-z0-9_-]{12,}\b/g, '[REDACTED_KEY]')
175
+ .replace(/\b(?:api[_-]?key|token|secret)\s*[:=]\s*[^\s,;]+/gi, '$1=[REDACTED]');
176
+ }
177
+ export function createFeedbackRecord(submission, options = {}) {
178
+ if (typeof submission.text !== 'string' || submission.text.length === 0 || submission.text.length > FEEDBACK_MAX_TEXT_LENGTH)
179
+ fail(`text must contain 1-${FEEDBACK_MAX_TEXT_LENGTH} characters`);
180
+ const attachments = (submission.attachments ?? []).map((input, index) => {
181
+ if (!FEEDBACK_IMAGE_TYPES.includes(input.mimeType))
182
+ fail(`attachments[${index}] has an unsupported image format`);
183
+ if (!Number.isSafeInteger(input.sizeBytes) || input.sizeBytes <= 0 || input.sizeBytes > FEEDBACK_MAX_ATTACHMENT_BYTES)
184
+ fail(`attachments[${index}] exceeds the ${FEEDBACK_MAX_ATTACHMENT_BYTES} byte limit`);
185
+ if (!input.reference || input.reference.length > 2_000 || /[?#]/.test(input.reference) || /:\/\/[^/]*:[^@]+@/.test(input.reference))
186
+ fail('attachment reference must be a stable, credential-free reference');
187
+ return { ...input, id: `attachment-${index + 1}` };
188
+ });
189
+ if (attachments.length > FEEDBACK_MAX_ATTACHMENTS)
190
+ fail(`at most ${FEEDBACK_MAX_ATTACHMENTS} attachments are supported`);
191
+ const visibility = submission.visibility ?? 'private';
192
+ const sanitized = options.sanitize === true || submission.sanitized === true;
193
+ if (visibility === 'public' && !sanitized)
194
+ fail('public feedback must be explicitly sanitized');
195
+ const provenance = validateProvenance(submission.provenance);
196
+ return {
197
+ schema: FEEDBACK_SCHEMA, schemaVersion: FEEDBACK_SCHEMA_VERSION, id: options.id ?? `feedback-${randomUUID()}`,
198
+ userContent: { text: sanitized ? redactSensitiveText(submission.text) : submission.text }, attachments, provenance,
199
+ systemMetadata: { receivedAt: options.receivedAt ?? new Date().toISOString(), visibility, sanitized },
200
+ ...(submission.contact ? { contact: submission.contact } : {}), triage: { status: 'new' }, downstream: { work: [] },
201
+ };
202
+ }
203
+ export function feedbackWorkProvenance(record) {
204
+ return { feedbackId: record.id, feedbackSchema: `${record.schema}/v${record.schemaVersion}`, feedbackReference: `feedback://${record.id}`, feedbackSubmittedAt: record.provenance.submittedAt };
205
+ }
206
+ export function feedbackFingerprint(record) {
207
+ return createHash('sha256').update(JSON.stringify({ text: record.userContent.text, attachments: record.attachments.map(item => item.sha256 ?? item.reference), provenance: record.provenance })).digest('hex');
208
+ }
209
+ export function renderFeedbackMarkdown(record) {
210
+ const markerBody = { schema: record.schema, schemaVersion: record.schemaVersion, id: record.id, provenance: record.provenance, attachmentIds: record.attachments.map(item => item.id), triage: record.triage, downstream: record.downstream };
211
+ const attachments = record.attachments.length === 0 ? '_None._' : record.attachments.map(item => `- ${item.id}: ${item.mimeType}, ${item.sizeBytes} bytes, ref \`${item.reference}\``).join('\n');
212
+ return `${marker(FEEDBACK_MARKER, markerBody)}\n# Feedback ${record.id}\n\n## User report\n\n${record.userContent.text}\n\n## Attachments\n\n${attachments}\n`;
213
+ }
214
+ export async function materializeFeedback(first, second, third) {
215
+ if (typeof first === 'string')
216
+ return materializeFeedbackRecord(first, second, third);
217
+ const path = second;
218
+ const format = third ?? 'json';
219
+ if (!path || path.includes('\0'))
220
+ throw new Error('Feedback materialization path is invalid');
221
+ await mkdir(resolve(path, '..'), { recursive: true });
222
+ const body = format === 'markdown' ? renderFeedbackMarkdown(first) : `${JSON.stringify(first, null, 2)}\n`;
223
+ await writeFile(path, body, { encoding: 'utf8', mode: 0o600 });
224
+ }
225
+ export async function readFeedbackAttachment(root, attachment) {
226
+ const path = resolve(root, attachment.storageRef);
227
+ const rootPath = resolve(root);
228
+ if (!path.startsWith(`${rootPath}/`))
229
+ fail('attachment reference escapes storage root');
230
+ const bytes = await readFile(path);
231
+ if (bytes.byteLength !== attachment.sizeBytes || createHash('sha256').update(bytes).digest('hex') !== attachment.sha256)
232
+ fail('attachment integrity check failed');
233
+ return bytes;
234
+ }
package/dist/markers.js CHANGED
@@ -56,12 +56,14 @@ ${JOB_OBJECTIVE_END}`;
56
56
  }
57
57
  export function renderTaskComment(task) {
58
58
  const result = task.result?.summary?.trim() ? `\n\n### Result\n\n${task.result.summary.trim()}` : '';
59
+ const graph = task.agentGraph?.trim() ? `\n\n### Agent graph\n\n\`\`\`mermaid\n${task.agentGraph.trim()}\n\`\`\`` : '';
59
60
  const { input: _input, ...metadata } = task;
60
61
  return `${marker(TASK_MARKER, metadata)}
61
62
  ## Task: ${task.id}
62
63
 
63
64
  - **State:** ${task.state}
64
65
  - **Priority:** ${task.priority ?? 'P2'}
66
+ ${graph}
65
67
 
66
68
  ### Work
67
69
 
package/dist/store.js CHANGED
@@ -5,7 +5,7 @@ export async function loadManagedGitHubPullRequestGraphql(client, repository, pr
5
5
  const [owner, name] = repository.split('/', 2);
6
6
  if (!owner || !name || !Number.isInteger(prNumber) || prNumber <= 0 || !client.graphql)
7
7
  return [];
8
- const query = 'query($owner:String!,$name:String!,$number:Int!){repository(owner:$owner,name:$name){pullRequest(number:$number){state isDraft merged mergedAt closedAt body comments(last:100){nodes{author{login} body}}}}}';
8
+ const query = 'query($owner:String!,$name:String!,$number:Int!){repository(owner:$owner,name:$name){pullRequest(number:$number){state isDraft merged mergedAt closedAt body comments(last:100){nodes{databaseId url author{login} body}}}}}';
9
9
  const data = await client.graphql(query, { owner, name, number: prNumber });
10
10
  const repo = data.repository;
11
11
  const pr = repo?.pullRequest;
@@ -20,7 +20,14 @@ export async function loadManagedGitHubPullRequestGraphql(client, repository, pr
20
20
  const bodyJob = parseJob(typeof pr.body === 'string' ? pr.body : '');
21
21
  const commentJobs = trusted.map(item => parseJob(String(item.body ?? ''))).filter((item) => item !== null);
22
22
  const jobs = bodyJob ? [bodyJob] : commentJobs;
23
- const tasks = trusted.map(item => parseTask(String(item.body ?? ''))).filter((item) => item !== null);
23
+ const tasks = trusted.map(item => {
24
+ const row = item;
25
+ const task = parseTask(String(row.body ?? ''));
26
+ if (!task)
27
+ return null;
28
+ const taskUrl = typeof row.url === 'string' ? row.url : Number.isInteger(row.databaseId) ? `https://github.com/${repository}/pull/${prNumber}#issuecomment-${row.databaseId}` : undefined;
29
+ return taskUrl ? { ...task, taskUrl } : task;
30
+ }).filter((item) => item !== null);
24
31
  const state = String(pr.state ?? '').toUpperCase();
25
32
  const githubState = pr.merged === true ? 'MERGED' : state === 'CLOSED' ? 'CLOSED' : 'OPEN';
26
33
  return jobs.map(job => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agents-relay",
3
- "version": "1.0.75",
3
+ "version": "1.0.77",
4
4
  "description": "Durable async agent jobs coordinated through GitHub pull requests",
5
5
  "type": "module",
6
6
  "bin": {