agents-relay 1.0.108 → 1.0.110

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.
@@ -3,7 +3,7 @@ import { InMemoryStore, GitHubStore } from './store.js';
3
3
  import { githubAuthContext } from './github-auth.js';
4
4
  import { NatsEventBus, eventFor } from './events.js';
5
5
  import { parseJobSchedule } from './scheduler.js';
6
- export const SERVICE_DEFAULTS = { port: 8787, watchdogMs: 300000, webhookWatchdogMs: 1800000, dashboardRefreshMs: 300000 };
6
+ export const SERVICE_DEFAULTS = { port: 8787, watchdogMs: 300000, webhookWatchdogMs: 300000, dashboardRefreshMs: 300000 };
7
7
  export class ServiceEventBus {
8
8
  upstream;
9
9
  unsubscribers = new Set();
package/dist/cli.js CHANGED
@@ -181,7 +181,7 @@ async function runTaskCommand(action, args) {
181
181
  if (action === 'retry' || action === 'cancel') {
182
182
  if (action === 'retry')
183
183
  task.maxAttempts = extendRetryBudget(task);
184
- await transitionTaskEvent(task, action === 'retry' ? 'retry' : 'cancel');
184
+ await transitionTaskEvent(task, action === 'retry' ? 'retry' : task.state === 'RUNNING' ? 'task.cancelled' : 'cancel');
185
185
  task.error = action === 'retry' ? null : 'Cancelled by user';
186
186
  task.leaseOwner = null;
187
187
  task.leaseExpiresAt = null;
@@ -429,9 +429,9 @@ export function dashboardTaskAction(store, reconciler, action) {
429
429
  task.error = null;
430
430
  }
431
431
  else {
432
- if (!['DRAFT', 'QUEUED', 'BLOCKED', 'FAILED'].includes(task.state))
432
+ if (!['DRAFT', 'QUEUED', 'RUNNING', 'BLOCKED', 'FAILED'].includes(task.state))
433
433
  throw new Error(`Task ${task.id} is not cancellable (${task.state})`);
434
- await transitionTaskEvent(task, 'cancel');
434
+ await transitionTaskEvent(task, task.state === 'RUNNING' ? 'task.cancelled' : 'cancel');
435
435
  task.error = 'Cancelled by user';
436
436
  }
437
437
  task.leaseOwner = null;
@@ -73,11 +73,13 @@ export const dashboardHtml = `<!doctype html>
73
73
  .detail-label { margin:0; color:var(--muted); font-size:.66rem; text-transform:uppercase; letter-spacing:.04em; }
74
74
  .detail-value { margin:.12rem 0 0; font-weight:650; overflow-wrap:anywhere; }
75
75
  .section-title { margin:.8rem 0 .35rem; color:var(--muted); font-size:.72rem; letter-spacing:.05em; text-transform:uppercase; }
76
- ul.tree { display:grid; gap:.25rem; }
77
- ul.tree ul { list-style:none; margin:.2rem 0 .1rem .6rem; padding-left:.6rem; border-left:1px solid var(--border); }
78
- .task-row { width:100%; display:flex; align-items:center; gap:.4rem; min-width:0; padding:.32rem .38rem; border:0; border-radius:.35rem; background:transparent; color:inherit; font:inherit; text-align:left; cursor:pointer; }
76
+ ul.tree { display:grid; gap:.25rem; min-width:0; max-width:100%; }
77
+ ul.tree li { min-width:0; max-width:100%; }
78
+ ul.tree ul { width:calc(100% - .6rem); min-width:0; max-width:calc(100% - .6rem); list-style:none; margin:.2rem 0 .1rem .6rem; padding-left:.6rem; border-left:1px solid var(--border); }
79
+ .task-row { width:100%; max-width:100%; overflow:hidden; display:flex; align-items:center; gap:.4rem; min-width:0; padding:.32rem .38rem; border:0; border-radius:.35rem; background:transparent; color:inherit; font:inherit; text-align:left; cursor:pointer; }
79
80
  .task-row[aria-current="true"] { background:var(--accent2); }
80
- .task-id { min-width:0; flex:1; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; font: .77rem ui-monospace,SFMono-Regular,Menlo,monospace; }
81
+ .task-row .badge { flex:0 0 auto; }
82
+ .task-id { min-width:0; max-width:100%; flex:1 1 0; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; font: .77rem ui-monospace,SFMono-Regular,Menlo,monospace; }
81
83
  .task-actions { display:flex; justify-content:flex-end; gap:.25rem; margin-bottom:.5rem; }
82
84
  .task-link { color:var(--accent); font-weight:700; }
83
85
  .agent-graph { display:grid; gap:.45rem; margin-top:.55rem; padding:.55rem; border:1px solid var(--border); border-radius:.4rem; background:var(--surface2); overflow:auto; }
@@ -129,6 +131,25 @@ export const dashboardHtml = `<!doctype html>
129
131
  #jobs-view .panel-body { max-height:none; }
130
132
  #jobs-workspace { gap:.65rem; }
131
133
  #jobs-workspace .panel-body { max-height:none; }
134
+ #usage-view .panel-head { padding:.6rem .7rem .35rem; }
135
+ #usage-view .panel-head .subtle { display:none; }
136
+ #usage-view .panel-body { padding:.45rem .5rem .65rem; max-height:none; }
137
+ #usage-view .usage-providers { gap:.45rem; }
138
+ #usage-view .usage-provider {
139
+ grid-template-columns:1fr;
140
+ gap:.55rem;
141
+ padding:.65rem .7rem;
142
+ border-radius:.55rem;
143
+ background:var(--surface);
144
+ }
145
+ #usage-view .usage-provider h3 { font-size:.78rem; color:var(--muted); text-transform:uppercase; letter-spacing:.04em; }
146
+ #usage-view .usage-bars { gap:.7rem; }
147
+ #usage-view .usage-row { gap:.25rem; }
148
+ #usage-view .usage-label { align-items:baseline; gap:.5rem; font-size:.78rem; }
149
+ #usage-view .usage-label span:last-child { font-size:1.05rem; font-weight:800; color:var(--text); }
150
+ #usage-view .usage-track { height:.48rem; border:0; background:var(--border); }
151
+ #usage-view .usage-row .subtle { font-size:.68rem; line-height:1.25; }
152
+ #usage-view .usage-provider > .detail-value { font-size:1.2rem; font-weight:800; }
132
153
  .job-summary,.detail-groups { grid-template-columns:1fr; }
133
154
  .panel-head { padding:.55rem .6rem; }
134
155
  .panel-body { padding:.6rem; }
@@ -145,8 +166,8 @@ export const dashboardHtml = `<!doctype html>
145
166
  <nav role="tablist" aria-label="Console views">
146
167
  <button id="jobs-tab" type="button" role="tab" aria-selected="true" aria-controls="jobs-view">Jobs</button>
147
168
  <button id="usage-tab" type="button" role="tab" aria-selected="false" aria-controls="usage-view">Usage</button>
148
- <button id="rules-tab" type="button" role="tab" aria-selected="false" aria-controls="rules-view">Model Router Rules</button>
149
- <button id="worker-rules-tab" type="button" role="tab" aria-selected="false" aria-controls="worker-rules-view">Worker Router Rules</button>
169
+ <button id="rules-tab" type="button" role="tab" aria-selected="false" aria-controls="rules-view">Model Rules</button>
170
+ <button id="worker-rules-tab" type="button" role="tab" aria-selected="false" aria-controls="worker-rules-view">Worker Rules</button>
150
171
  </nav>
151
172
  </header>
152
173
  <main>
@@ -182,7 +203,7 @@ export const dashboardHtml = `<!doctype html>
182
203
  <div class="panel-body">
183
204
  <div class="rules-editor">
184
205
  <p class="rules-help">Free-text guidance loaded by the model decider for every routing decision. Hard capability and health gates remain enforced by code.</p>
185
- <textarea id="rules-text" class="rules-textarea" spellcheck="true" aria-label="Model router rules" placeholder="Example: Prefer ZAI when paid quota is abundant and task quality is sufficient. If a provider/runtime path just failed, reconsider using the failure evidence and fresh usage."></textarea>
206
+ <textarea id="rules-text" class="rules-textarea" spellcheck="true" aria-label="Model router rules" placeholder="Example: ZAI only glm-5.3-flash high thinking. Prefer it while paid quota is abundant. Non-OpenAI models enter the candidate set only when the provider is configured and the concrete model is named here."></textarea>
186
207
  <div class="rules-footer"><span id="rules-status" class="subtle" aria-live="polite"></span><button id="rules-save" class="rules-save" type="button">Save rules</button></div>
187
208
  </div>
188
209
  </div>
@@ -42,6 +42,9 @@ export function makeDecisionAudit(input) {
42
42
  if (item.resourceHealthy === false)
43
43
  reasons.push('resource_unhealthy');
44
44
  const constraints = input.contract.constraints;
45
+ const excludedRoutes = Array.isArray(input.contract.excludedRoutes) ? input.contract.excludedRoutes : [];
46
+ if (excludedRoutes.some(route => (!route.provider || route.provider === item.provider) && (!route.model || route.model === item.model) && (route.profile === undefined || route.profile === item.profile)))
47
+ reasons.push('prior_failed_route');
45
48
  if (typeof constraints?.provider === 'string' && constraints.provider !== item.provider)
46
49
  reasons.push('provider_constraint');
47
50
  if (typeof constraints?.model === 'string' && constraints.model !== item.model)
@@ -23,16 +23,18 @@ export async function redecideFailedTask(options) {
23
23
  if (options.evidence.classification !== 'model_provider_runtime')
24
24
  throw new Error('Model re-decision requires model/provider/runtime evidence');
25
25
  const usage = await readUsageSnapshot(options.usage);
26
- const candidates = await discoverModelCandidates(options.worker, options.registered, usage);
26
+ const candidates = await discoverModelCandidates(options.worker, options.registered, usage, undefined, options.rulesPath);
27
27
  const result = await routeModel(options.worker, candidates, {
28
28
  decisionEngine: options.decisionEngine,
29
29
  decisionMode: options.decisionMode,
30
30
  policyPath: options.policyPath,
31
+ rulesPath: options.rulesPath,
31
32
  auditStore: options.auditStore,
32
33
  auditTask: options.task,
33
34
  taskFacts: { kind: options.task.kind ?? 'work', capabilities: options.task.capabilities, prior_routing: options.task.routing ?? null, fresh_usage_generated_at: usage.generatedAt },
34
35
  priorDecisionId: options.task.routing?.decisionId,
35
36
  priorFailure: options.evidence,
37
+ excludedRoutes: options.task.routing ? [{ provider: options.task.routing.provider, model: options.task.routing.model, profile: options.task.routing.profile }] : [],
36
38
  });
37
39
  await options.updateRouting(options.task, result.routing);
38
40
  if (options.auditStore && options.task.routing?.decisionId)
@@ -4,7 +4,7 @@ import { readdir, readFile } from 'node:fs/promises';
4
4
  import { chooseWithDecision } from './decision.js';
5
5
  import { usageForProvider } from './worker-routing.js';
6
6
  import { makeDecisionAudit } from './decision-audit.js';
7
- import { loadRoutingPolicy, policyAllows, policyRank } from './routing-policy.js';
7
+ import { loadModelRouterRules, loadRoutingPolicy, policyAllows, policyRank } from './routing-policy.js';
8
8
  function topLevelCodexConfig(text) {
9
9
  const result = {};
10
10
  let inSection = false;
@@ -38,13 +38,34 @@ async function readConfig(path) {
38
38
  return null;
39
39
  }
40
40
  }
41
+ function compact(value) {
42
+ return value.toLowerCase().replace(/[^a-z0-9]+/g, '');
43
+ }
44
+ function modelAliases(model) {
45
+ const aliases = new Set([compact(model)]);
46
+ const firstDigit = model.search(/\d/);
47
+ if (firstDigit >= 0)
48
+ aliases.add(compact(model.slice(firstDigit)));
49
+ return [...aliases].filter(Boolean);
50
+ }
51
+ export function nonOpenAiModelAuthorized(provider, model, rulesText) {
52
+ if (provider === 'openai')
53
+ return true;
54
+ const rules = compact(rulesText);
55
+ if (!rules.includes(compact(provider)))
56
+ return false;
57
+ return modelAliases(model).some(alias => alias.length >= 3 && rules.includes(alias));
58
+ }
41
59
  function withResource(candidate, usage) {
42
60
  const resource = usageForProvider(usage, candidate.provider);
43
61
  return { ...candidate, resourceHealthy: resource.healthy, resourceUsedPercent: resource.usedPercent, resourceRemainingPercent: resource.remainingPercent, resourceResetAt: resource.resetAt, resourceResetInMinutes: resource.resetInMinutes, resourceWindows: resource.windows };
44
62
  }
45
- export async function discoverModelCandidates(worker, registered, usage, codexHome = process.env.CODEX_HOME ?? join(homedir(), '.codex')) {
63
+ export async function discoverModelCandidates(worker, registered, usage, codexHome = process.env.CODEX_HOME ?? join(homedir(), '.codex'), rulesPath) {
46
64
  const registration = registered.find(item => item.id === worker.workerId);
47
65
  if (registration?.runtime.provider && registration.runtime.model) {
66
+ const rules = await loadModelRouterRules(rulesPath);
67
+ if (!nonOpenAiModelAuthorized(registration.runtime.provider, registration.runtime.model, rules.text))
68
+ return [];
48
69
  return [withResource({
49
70
  id: `${registration.runtime.provider}/${registration.runtime.model}`,
50
71
  provider: registration.runtime.provider,
@@ -55,9 +76,13 @@ export async function discoverModelCandidates(worker, registered, usage, codexHo
55
76
  return [withResource({ id: 'chatgpt/account-defaults', provider: 'chatgpt', model: 'account-defaults' }, usage)];
56
77
  }
57
78
  const base = await readConfig(join(codexHome, 'config.toml')) ?? {};
79
+ const rules = await loadModelRouterRules(rulesPath);
58
80
  const candidates = [];
59
- if (base.model)
60
- candidates.push(withResource({ id: `default:${base.provider ?? 'openai'}/${base.model}`, provider: base.provider ?? 'openai', model: base.model, reasoning: base.reasoning }, usage));
81
+ if (base.model) {
82
+ const provider = base.provider ?? 'openai';
83
+ if (nonOpenAiModelAuthorized(provider, base.model, rules.text))
84
+ candidates.push(withResource({ id: `default:${provider}/${base.model}`, provider, model: base.model, reasoning: base.reasoning }, usage));
85
+ }
61
86
  let files = [];
62
87
  try {
63
88
  files = (await readdir(codexHome)).filter(name => name.endsWith('.config.toml')).sort();
@@ -69,16 +94,25 @@ export async function discoverModelCandidates(worker, registered, usage, codexHo
69
94
  continue;
70
95
  const profile = basename(file, '.config.toml');
71
96
  const provider = config.provider ?? base.provider ?? 'openai';
97
+ if (!nonOpenAiModelAuthorized(provider, config.model, rules.text))
98
+ continue;
72
99
  candidates.push(withResource({ id: `${profile}:${provider}/${config.model}`, provider, model: config.model, profile, reasoning: config.reasoning ?? base.reasoning }, usage));
73
100
  }
74
101
  if (candidates.length === 0)
75
102
  candidates.push(withResource({ id: 'default:openai/gpt-5.6-luna', provider: 'openai', model: 'gpt-5.6-luna' }, usage));
76
103
  return candidates.filter((candidate, index, all) => all.findIndex(item => item.id === candidate.id) === index);
77
104
  }
78
- function eligibleModels(candidates, constraints, policy) {
105
+ function sameRoute(candidate, route) {
106
+ return (!route.provider || candidate.provider === route.provider)
107
+ && (!route.model || candidate.model === route.model)
108
+ && (route.profile === undefined || candidate.profile === route.profile);
109
+ }
110
+ function eligibleModels(candidates, constraints, policy, excludedRoutes = []) {
79
111
  return candidates.filter(candidate => {
80
112
  if (candidate.resourceHealthy === false)
81
113
  return false;
114
+ if (excludedRoutes.some(route => sameRoute(candidate, route)))
115
+ return false;
82
116
  if (policy && !policyAllows(candidate, policy.policy))
83
117
  return false;
84
118
  if (constraints?.provider && candidate.provider !== constraints.provider)
@@ -100,7 +134,7 @@ function deterministicModel(candidates, taskKind, policy) {
100
134
  }
101
135
  export async function routeModel(worker, candidates, options = {}) {
102
136
  const policy = await loadRoutingPolicy(options.policyPath, options.rulesPath);
103
- const eligible = eligibleModels(candidates, options.constraints, policy);
137
+ const eligible = eligibleModels(candidates, options.constraints, policy, options.excludedRoutes);
104
138
  if (eligible.length === 0)
105
139
  throw new Error(`No eligible model profile is usable by worker ${worker.workerId}`);
106
140
  const taskKind = typeof options.taskFacts?.kind === 'string' ? options.taskFacts.kind : undefined;
@@ -130,7 +164,7 @@ export async function routeModel(worker, candidates, options = {}) {
130
164
  const audit = makeDecisionAudit({
131
165
  task: options.auditTask,
132
166
  worker: { id: worker.workerId, adapter: worker.adapter, capabilities: [...worker.capabilities] },
133
- contract: { question: 'model-profile-selection', task: options.taskFacts ?? {}, constraints: options.constraints ?? {} },
167
+ contract: { question: 'model-profile-selection', task: options.taskFacts ?? {}, constraints: options.constraints ?? {}, excludedRoutes: options.excludedRoutes ?? [] },
134
168
  policy,
135
169
  candidates,
136
170
  eligibleCandidates: eligible,
package/dist/pool.js CHANGED
@@ -24,6 +24,7 @@ export class RepositoryWorkerPool {
24
24
  makeReconciler;
25
25
  running = false;
26
26
  pending = false;
27
+ reconcileLocks = new Map();
27
28
  jobTargets = new Map();
28
29
  constructor(client, repository, trustedAuthors, concurrency, makeReconciler) {
29
30
  this.client = client;
@@ -33,14 +34,31 @@ export class RepositoryWorkerPool {
33
34
  this.repositories = [...new Set(typeof repository === 'string' ? [repository] : repository)];
34
35
  }
35
36
  repositories;
37
+ async reconcileExclusive(jobId, work) {
38
+ while (this.reconcileLocks.has(jobId))
39
+ await this.reconcileLocks.get(jobId);
40
+ let release;
41
+ const gate = new Promise(resolve => { release = resolve; });
42
+ this.reconcileLocks.set(jobId, gate);
43
+ try {
44
+ await work();
45
+ }
46
+ finally {
47
+ if (this.reconcileLocks.get(jobId) === gate)
48
+ this.reconcileLocks.delete(jobId);
49
+ release();
50
+ }
51
+ }
36
52
  async reconcileDiscovered(discovered) {
37
53
  for (const item of discovered)
38
54
  this.jobTargets.set(item.job.id, { repository: item.job.repository, prNumber: item.job.prNumber });
39
55
  // Terminal PRs must be reconciled before OPEN-job scheduling. Otherwise the
40
56
  // OPEN-only runnable filter can strand durable jobs after GitHub merges/closes them.
41
57
  for (const job of terminalManagedJobs(discovered)) {
42
- const store = new GitHubStore(this.client, job.repository, job.prNumber, this.trustedAuthors);
43
- await this.makeReconciler(store, 0).reconcile(job.id);
58
+ await this.reconcileExclusive(job.id, async () => {
59
+ const store = new GitHubStore(this.client, job.repository, job.prNumber, this.trustedAuthors);
60
+ await this.makeReconciler(store, 0).reconcile(job.id);
61
+ });
44
62
  }
45
63
  const jobs = runnableManagedJobs(discovered);
46
64
  let capacity = Math.max(1, this.concurrency);
@@ -53,7 +71,9 @@ export class RepositoryWorkerPool {
53
71
  if (!needsLifecycleReconciliation(job))
54
72
  continue;
55
73
  const allowance = Math.max(1, Math.min(capacity, ready || (expiredRunning ? 1 : 0) || 1));
56
- await this.makeReconciler(store, allowance).reconcile(job.id);
74
+ await this.reconcileExclusive(job.id, async () => {
75
+ await this.makeReconciler(store, allowance).reconcile(job.id);
76
+ });
57
77
  capacity -= allowance;
58
78
  }
59
79
  }
@@ -78,8 +98,10 @@ export class RepositoryWorkerPool {
78
98
  if (!this.repositories.includes(repository))
79
99
  return;
80
100
  this.jobTargets.set(jobId, { repository, prNumber });
81
- const store = new GitHubStore(this.client, repository, prNumber, this.trustedAuthors);
82
- await this.makeReconciler(store, Math.max(1, this.concurrency)).reconcile(jobId);
101
+ await this.reconcileExclusive(jobId, async () => {
102
+ const store = new GitHubStore(this.client, repository, prNumber, this.trustedAuthors);
103
+ await this.makeReconciler(store, Math.max(1, this.concurrency)).reconcile(jobId);
104
+ });
83
105
  }
84
106
  async handleLifecycleEvent(event) {
85
107
  const target = this.jobTargets.get(event.job_id);
@@ -129,7 +129,12 @@ export class ReconcilerReviewBase extends ReconcilerRuntimeBase {
129
129
  });
130
130
  const models = await discoverModelCandidates(routedWorker.candidate, registered, usage);
131
131
  const routingConstraints = task.routing ? { provider: task.routing.provider, model: task.routing.model, profile: task.routing.profile, reasoning: task.routing.reasoning } : undefined;
132
- const routedModel = await routeModel(routedWorker.candidate, models, { decisionEngine: this.options.decisionEngine, decisionMode: this.options.decisionMode, taskFacts, constraints: routingConstraints, auditStore: this.options.decisionAuditStore, auditTask: task });
132
+ const parent = task.agentName === 'troubleshooter' && task.parentTaskId ? job.tasks.find(item => item.id === task.parentTaskId) : undefined;
133
+ const parentRouteFailedBeforeStart = Boolean(parent?.routing && /exited before publishing task\.started|worker_exit_before_start|launch failed/i.test(parent.error ?? parent.failureReason ?? ''));
134
+ const excludedRoutes = parentRouteFailedBeforeStart && parent?.routing
135
+ ? [{ provider: parent.routing.provider, model: parent.routing.model, profile: parent.routing.profile }]
136
+ : [];
137
+ const routedModel = await routeModel(routedWorker.candidate, models, { decisionEngine: this.options.decisionEngine, decisionMode: this.options.decisionMode, taskFacts: { ...taskFacts, recovery_parent_failed_route: excludedRoutes[0] ?? null }, constraints: routingConstraints, excludedRoutes, auditStore: this.options.decisionAuditStore, auditTask: task });
133
138
  task.workerSelection = routedWorker.selection;
134
139
  task.adapter = routedWorker.candidate.adapter;
135
140
  task.routing = routedModel.routing;
package/dist/relayd.js CHANGED
@@ -129,6 +129,10 @@ export async function runDaemon(argv) {
129
129
  if (!overview)
130
130
  throw new Error(`Managed job ${jobId} not found`);
131
131
  const store = new GitHubStore(client, overview.job.repository, overview.job.prNumber, trusted);
132
+ if (action === 'reconcile') {
133
+ await pool.reconcileTarget(overview.job.repository, overview.job.prNumber, jobId);
134
+ return await store.load(jobId);
135
+ }
132
136
  return await dashboardJobAction(store, makeReconciler(store, concurrency), action)(jobId);
133
137
  };
134
138
  const workspaceTaskAction = (action) => async (jobId, taskId) => {
@@ -38,7 +38,7 @@ export function usageForProvider(snapshot, provider, now = Date.now()) {
38
38
  };
39
39
  }
40
40
  const credits = snapshot?.credits.find(item => item.id === provider)?.credits;
41
- if (credits?.available) {
41
+ if (credits) {
42
42
  const knownRemaining = credits.balances.map(item => item.remaining).filter((value) => typeof value === 'number' && Number.isFinite(value));
43
43
  if (knownRemaining.length > 0)
44
44
  return { healthy: knownRemaining.some(value => value > 0) };
package/dist/workspace.js CHANGED
@@ -35,7 +35,7 @@ export async function discoverGitRepositories(root) {
35
35
  catch {
36
36
  return;
37
37
  }
38
- await Promise.all(entries.filter(entry => entry.isDirectory() && !['node_modules', '.git', '.worktrees', '.backup'].includes(entry.name)).map(entry => visit(join(directory, entry.name))));
38
+ await Promise.all(entries.filter(entry => entry.isDirectory() && entry.name !== 'node_modules' && !entry.name.startsWith('.')).map(entry => visit(join(directory, entry.name))));
39
39
  };
40
40
  await visit(resolve(root));
41
41
  return found.sort((a, b) => relative(root, a).localeCompare(relative(root, b)));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agents-relay",
3
- "version": "1.0.108",
3
+ "version": "1.0.110",
4
4
  "description": "Durable async agent jobs coordinated through GitHub pull requests",
5
5
  "type": "module",
6
6
  "bin": {
@@ -9,7 +9,7 @@
9
9
  },
10
10
  "scripts": {
11
11
  "build": "tsc -p tsconfig.json && chmod +x dist/cli.js dist/relayd.js",
12
- "test": "npm run build && node --test test/*.test.js",
12
+ "test": "npm run build && node --test --test-force-exit test/*.test.js",
13
13
  "test:chatgpt-temp-quality": "AGENTS_RELAY_RUN_CHATGPT_TEMP_QUALITY=1 AGENTS_RELAY_CHATGPT_BROWSER_WORKER_AGENT=/Users/chengli/Workspace/neo/skills/chatgpt-browser-worker/agents/browser-worker.agent.md sh -c \"npm run build && node test/quality/run-temporary-chat.mjs\"",
14
14
  "dev": "tsx src/cli.ts"
15
15
  },