agents-relay 2.0.170 → 2.0.172

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.
@@ -95,53 +95,92 @@ function mergeSections(base, child) {
95
95
  }
96
96
  return result.trim();
97
97
  }
98
- /** Exactly two lookup layers: Relay built-ins and a single job context agents directory. */
99
- export async function resolveAgentMarkdown(name, builtinsRoot, contextRoot) {
98
+ /** Resolve the visible agent through Relay, Neo shared, then project-local layers. */
99
+ export async function resolveAgentMarkdown(name, builtinsRoot, contextRoot, neoRoot) {
100
100
  if (!/^[a-zA-Z0-9_-]+$/.test(name))
101
101
  throw new Error(`Invalid agent name ${name}`);
102
- const builtin = join(builtinsRoot, `${name}.agent.md`);
103
- const local = join(contextRoot, 'agents', `${name}.agent.md`);
104
- const sources = [];
105
102
  const load = async (path, root) => {
106
103
  const canonical = await realpath(path);
107
104
  const canonicalRoot = await realpath(root);
108
105
  if (!(canonical === canonicalRoot || canonical.startsWith(canonicalRoot + '/')))
109
106
  throw new Error('Agent file escapes context root');
110
- const source = parseDocument(await readFile(canonical, 'utf8'));
107
+ const raw = await readFile(canonical, 'utf8');
108
+ const source = parseDocument(raw);
111
109
  if (source.name && source.name !== name)
112
110
  throw new Error(`Agent name mismatch ${source.name} != ${name}`);
113
- sources.push(canonical);
114
- return source;
111
+ return { ...source, path: canonical, metadata: Object.fromEntries(raw.match(/^---\r?\n([\s\S]*?)\r?\n---/m)?.[1].split(/\r?\n/).flatMap(line => {
112
+ const field = /^([A-Za-z0-9_-]+):\s*(.*)$/.exec(line);
113
+ return field ? [[field[1], field[2].trim().replace(/^['"]|['"]$/g, '')]] : [];
114
+ }) ?? []) };
115
+ };
116
+ const loadLayer = async (root) => {
117
+ for (const suffix of ['.agent.md', '.md']) {
118
+ try {
119
+ return await load(join(root, `${name}${suffix}`), root);
120
+ }
121
+ catch (error) {
122
+ if (error.code !== 'ENOENT')
123
+ throw error;
124
+ }
125
+ }
126
+ throw Object.assign(new Error('Agent file not found'), { code: 'ENOENT' });
115
127
  };
116
128
  let base;
117
129
  try {
118
- base = await load(builtin, builtinsRoot);
119
- }
120
- catch (e) {
121
- if (e.code !== 'ENOENT')
122
- throw e;
123
- }
124
- let overlay;
125
- try {
126
- overlay = await load(local, contextRoot + '/agents');
130
+ base = await loadLayer(builtinsRoot);
127
131
  }
128
132
  catch (e) {
129
133
  if (e.code !== 'ENOENT')
130
134
  throw e;
131
135
  }
132
- if (!base && !overlay)
133
- throw new Error(`Unknown agent ${name}`);
134
- if (overlay && (overlay.replace.length || overlay.extend.length) && !overlay.extendsAgent)
135
- throw new Error('Section operations require extends');
136
- if (overlay?.extendsAgent && overlay.extendsAgent !== `relay/${name}`)
137
- throw new Error(`Only extends: relay/${name} is supported`);
138
- if (overlay?.extendsAgent && !base)
139
- throw new Error(`Missing Relay base for agent ${name}`);
140
- if (overlay && base && !overlay.extendsAgent) { // explicit local definition without inheritance
141
- if (overlay.replace.length || overlay.extend.length)
136
+ let effective = base;
137
+ let shared;
138
+ let sharedSources = [];
139
+ let sources = base ? [base.path] : [];
140
+ let metadata = { ...(base?.metadata ?? {}) };
141
+ for (const [layer, root] of [['neo', neoRoot], ['project', contextRoot]]) {
142
+ if (!root)
143
+ continue;
144
+ let overlay;
145
+ try {
146
+ overlay = await loadLayer(join(root, 'agents'));
147
+ }
148
+ catch (e) {
149
+ if (e.code !== 'ENOENT')
150
+ throw e;
151
+ }
152
+ if (!overlay)
153
+ continue;
154
+ const hasOperations = overlay.replace.length || overlay.extend.length;
155
+ if (hasOperations && !overlay.extendsAgent)
142
156
  throw new Error('Section operations require extends');
143
- return { content: overlay.body, sources: [sources[sources.length - 1]], hash: createHash('sha256').update(overlay.body).digest('hex') };
157
+ if (overlay.extendsAgent && ![`relay/${name}`, `neo/${name}`].includes(overlay.extendsAgent))
158
+ throw new Error(`Only extends: relay/${name} or neo/${name} is supported`);
159
+ if (layer === 'neo' && overlay.extendsAgent === `neo/${name}`)
160
+ throw new Error(`Missing Neo base for agent ${name}`);
161
+ if (overlay.extendsAgent === `relay/${name}` && !base)
162
+ throw new Error(`Missing Relay base for agent ${name}`);
163
+ if (overlay.extendsAgent === `neo/${name}` && !shared)
164
+ throw new Error(`Missing Neo base for agent ${name}`);
165
+ if (!overlay.extendsAgent) {
166
+ effective = overlay;
167
+ metadata = {};
168
+ sources = [overlay.path];
169
+ }
170
+ else {
171
+ const parent = overlay.extendsAgent === `relay/${name}` ? base : overlay.extendsAgent === `neo/${name}` ? shared : effective;
172
+ effective = { ...overlay, body: mergeSections(parent.body, overlay) };
173
+ metadata = { ...parent.metadata };
174
+ sources = [...(overlay.extendsAgent === `relay/${name}` ? [base.path] : overlay.extendsAgent === `neo/${name}` ? sharedSources : sources), overlay.path];
175
+ }
176
+ Object.assign(metadata, overlay.metadata);
177
+ if (layer === 'neo') {
178
+ shared = effective;
179
+ sharedSources = [...sources];
180
+ }
144
181
  }
145
- const content = overlay ? mergeSections(base?.body ?? '', overlay) : base.body;
146
- return { content, sources, hash: createHash('sha256').update(content).digest('hex') };
182
+ if (!effective)
183
+ throw new Error(`Unknown agent ${name}`);
184
+ const content = effective.body;
185
+ return { content, metadata, sources, hash: createHash('sha256').update(content).digest('hex') };
147
186
  }
package/dist/cli.js CHANGED
@@ -27,7 +27,7 @@ import { classifyFailedTask } from './task-actions.js';
27
27
  import { resolveExecutionContext } from './execution-context.js';
28
28
  import { startServiceExternalActionFlusher } from './service-external-actions.js';
29
29
  import { handleServicePublication } from './service-publication.js';
30
- import { defaultWorkspaceRoot, ensureLocalJobSourceBranch, jobRequiresManagedWorktree, prepareLocalJobExecutionWorktree, resolveManagedProjectPath, resolveRepositoryProjectPath } from './workspace.js';
30
+ import { ensureLocalJobSourceBranch, prepareLocalJobExecutionWorktree, resolveManagedProjectPath, resolveRepositoryProjectPath, resolveWorkspaceRoot, taskRequiresManagedWorktree } from './workspace.js';
31
31
  import { runBlockerCommand } from './cli-blocker.js';
32
32
  import { runEscalationCommand } from './cli-escalation.js';
33
33
  import { runProjectionCommand } from './cli-projection.js';
@@ -404,10 +404,8 @@ async function runContextCommand(action, args) {
404
404
  const roles = ['orchestrator', 'planner', 'reviewer', 'worker', 'nested-worker'];
405
405
  if (!roles.includes(roleValue))
406
406
  throw new Error('--role must be orchestrator, planner, reviewer, worker, or nested-worker');
407
- const neoRootArg = arg(args, '--neo-root');
408
407
  const context = await resolveExecutionContext(projectPath, roleValue, {
409
408
  workspaceRoot: arg(args, '--workspace-root') || undefined,
410
- neoRoot: neoRootArg === 'none' ? null : neoRootArg || undefined,
411
409
  globalRoot: arg(args, '--global-root') || undefined,
412
410
  explicitSkills: arg(args, '--skills').split(',').filter(Boolean),
413
411
  explicitAgents: arg(args, '--agents').split(',').filter(Boolean),
@@ -559,16 +557,23 @@ export function runtime(store, args, bus, usageRegistry = codexAndZaiUsageRegist
559
557
  const modelRuntime = arg(args, '--codex', 'codex');
560
558
  const policy = chatGptTabClosePolicy(args);
561
559
  const planner = runtimePlanner(args);
562
- return new Reconciler(store, { noGitHub: noGitHubMode(args), owner: arg(args, '--owner', `cli-${process.pid}`), maxConcurrent: Number(arg(args, '--concurrency', '4')), leaseMs: Number(arg(args, '--lease-ms', '300000')), adapters: [new CodexAdapter(modelRuntime), new ChatGptAdapter({ tabClosePolicy: policy })], continuations: [new CodexThreadContinuation(modelRuntime), new CommandContinuation(), new WebhookContinuation()], planner, usageSnapshotProvider: () => usageRegistry.snapshot(), decisionEngine: executionDecisionEngine(), decisionMode: decisionMode(), reviewer: runtimeReviewer(args), eventBus: bus, emit, chatgptTabClosePolicy: policy, projectPathForJob: async (job) => {
563
- const workspaceRoot = arg(args, '--workspace-root') || defaultWorkspaceRoot();
560
+ return new Reconciler(store, { noGitHub: noGitHubMode(args), owner: arg(args, '--owner', `cli-${process.pid}`), maxConcurrent: Number(arg(args, '--concurrency', '4')), leaseMs: Number(arg(args, '--lease-ms', '300000')), adapters: [new CodexAdapter(modelRuntime), new ChatGptAdapter({ tabClosePolicy: policy })], continuations: [new CodexThreadContinuation(modelRuntime), new CommandContinuation(), new WebhookContinuation()], planner, usageSnapshotProvider: () => usageRegistry.snapshot(), decisionEngine: executionDecisionEngine(), decisionMode: decisionMode(), reviewer: runtimeReviewer(args), eventBus: bus, emit, chatgptTabClosePolicy: policy, executionContextRoots: { workspaceRoot: arg(args, '--workspace-root') || process.env.AGENTS_RELAY_WORKSPACE_ROOT }, projectPathForJob: async (job, task) => {
561
+ const workspaceRoot = await resolveWorkspaceRoot(arg(args, '--workspace-root') || undefined);
564
562
  const canonical = await resolveManagedProjectPath(noGitHubMode(args) ? undefined : job.repository, arg(args, '--project-path'), workspaceRoot);
565
- if (!jobRequiresManagedWorktree(job))
563
+ if (!(task ? taskRequiresManagedWorktree(job, task) : Boolean(job.localMerge?.sourceRef)))
566
564
  return resolveRepositoryProjectPath(canonical, job.projectPath);
567
- const sourceRef = await ensureLocalJobSourceBranch(canonical, job);
565
+ const sourceRef = job.localMerge?.sourceRef ?? await ensureLocalJobSourceBranch(canonical, job);
568
566
  if (!job.localMerge?.sourceRef) {
569
- if (!store.mutateJob)
570
- throw new Error('Managed worktree requires atomic SQLite Job mutation');
571
- await store.mutateJob(job.id, current => current.localMerge ? current : ({ ...current, localMerge: { sourceRef, targetRef: 'refs/heads/main', status: 'pending' }, updatedAt: new Date().toISOString() }));
567
+ if (!task || !store.mutateJob)
568
+ throw new Error('Managed source branch binding requires an executing code Task and atomic SQLite mutation');
569
+ const bound = await store.mutateJob(job.id, current => {
570
+ const activeTask = current.tasks.find(item => item.id === task.id);
571
+ if (current.localMerge || current.state !== 'ACTIVE' || !activeTask || !['QUEUED', 'RUNNING'].includes(activeTask.state) || !taskRequiresManagedWorktree(current, activeTask))
572
+ return current;
573
+ return { ...current, localMerge: { sourceRef, targetRef: 'refs/heads/main', status: 'pending' }, updatedAt: new Date().toISOString() };
574
+ });
575
+ if (!bound.localMerge?.sourceRef)
576
+ throw new Error('Job changed before managed source branch binding');
572
577
  }
573
578
  return prepareLocalJobExecutionWorktree(canonical, await store.load(job.id), workspaceRoot);
574
579
  } });
@@ -1,4 +1,5 @@
1
1
  import { resolveAgentMarkdown } from './agent-content-resolver.js';
2
+ import { resolveWorkspaceRoot } from './workspace.js';
2
3
  import { access, readFile, readdir, realpath, stat } from 'node:fs/promises';
3
4
  import { homedir } from 'node:os';
4
5
  import { basename, dirname, isAbsolute, join, relative, resolve, sep } from 'node:path';
@@ -25,22 +26,6 @@ async function directory(path) {
25
26
  return false;
26
27
  }
27
28
  }
28
- function configuredWorkspaceRoot(projectPath, explicit) {
29
- const configured = explicit ?? process.env.AGENTS_RELAY_WORKSPACE_ROOT ?? process.env.XCHAT_WORKSPACE_ROOT;
30
- if (configured)
31
- return resolve(configured);
32
- const conventional = join(homedir(), 'Workspace');
33
- return isWithin(projectPath, conventional) ? conventional : dirname(projectPath);
34
- }
35
- async function configuredNeoRoot(workspaceRoot, explicit) {
36
- if (explicit === null)
37
- return null;
38
- const configured = explicit ?? process.env.NEO_SHARED_ROOT;
39
- if (configured)
40
- return await directory(resolve(configured)) ? await realpath(resolve(configured)) : null;
41
- const candidate = join(workspaceRoot, 'neo');
42
- return await directory(candidate) ? await realpath(candidate) : null;
43
- }
44
29
  function configuredGlobalRoot(explicit) {
45
30
  return resolve(explicit ?? process.env.AGENTS_HOME ?? join(homedir(), '.agents'));
46
31
  }
@@ -176,9 +161,9 @@ export async function resolveExecutionContext(projectPath, role, options = {}) {
176
161
  const project = await realpath(resolve(projectPath)).catch(() => { throw new Error(`Project path not found: ${projectPath}`); });
177
162
  if (!await directory(project))
178
163
  throw new Error(`Project path is not a directory: ${projectPath}`);
179
- const workspaceCandidate = configuredWorkspaceRoot(project, options.workspaceRoot);
180
- const workspaceRoot = await realpath(workspaceCandidate).catch(() => workspaceCandidate);
181
- const neoRoot = await configuredNeoRoot(workspaceRoot, options.neoRoot);
164
+ const workspaceRoot = await resolveWorkspaceRoot(options.workspaceRoot);
165
+ const neoCandidate = join(workspaceRoot, 'neo');
166
+ const neoRoot = await directory(neoCandidate) ? await realpath(neoCandidate) : neoCandidate;
182
167
  const globalCandidate = configuredGlobalRoot(options.globalRoot);
183
168
  const globalRoot = await realpath(globalCandidate).catch(() => globalCandidate);
184
169
  const layerRoots = [
@@ -197,28 +182,26 @@ export async function resolveExecutionContext(projectPath, role, options = {}) {
197
182
  }
198
183
  const skills = selectByName([...skillMap.values()].sort((a, b) => a.name.localeCompare(b.name)), options.explicitSkills, 'skill');
199
184
  const agentMap = new Map();
200
- for (const agent of await agentFiles(dirname(BUNDLED_AGENTS_ROOT), 'builtin', []))
201
- agentMap.set(agent.name, agent);
202
- for (const agent of await agentFiles(project, 'project', [])) {
203
- // An extending agent inherits the builtin identity metadata unless the project
204
- // explicitly supplies a value. Local-only agents retain their own metadata.
205
- const builtin = agentMap.get(agent.name);
206
- if (builtin && agent.path) {
207
- const { fields } = await metadata(agent.path);
208
- if (fields.extends === `relay/${agent.name}`) {
209
- agentMap.set(agent.name, {
210
- ...builtin, ...agent,
211
- description: fields.description === undefined ? builtin.description : agent.description,
212
- capabilities: fields.capabilities === undefined ? builtin.capabilities : agent.capabilities,
213
- runtime: fields.runtime === undefined && fields.runtimes === undefined ? builtin.runtime : agent.runtime,
214
- trustDomains: fields.trustDomains === undefined && fields.trust_domains === undefined && fields.trust === undefined ? builtin.trustDomains : agent.trustDomains,
215
- delegation: fields.delegation === undefined ? builtin.delegation : agent.delegation,
216
- restrictions: fields.restrictions === undefined && fields.boundaries === undefined ? builtin.restrictions : agent.restrictions,
217
- });
218
- continue;
219
- }
220
- }
221
- agentMap.set(agent.name, agent);
185
+ const agentNames = new Set();
186
+ for (const [root, source] of [[dirname(BUNDLED_AGENTS_ROOT), 'builtin'], [neoRoot, 'neo'], [project, 'project']]) {
187
+ for (const agent of await agentFiles(root, source, []))
188
+ agentNames.add(agent.name);
189
+ }
190
+ for (const name of agentNames) {
191
+ const resolved = await resolveAgentMarkdown(name, BUNDLED_AGENTS_ROOT, project, neoRoot);
192
+ const fields = resolved.metadata;
193
+ const sourcePath = resolved.sources[resolved.sources.length - 1];
194
+ const source = sourcePath?.startsWith(project + '/') ? 'project'
195
+ : sourcePath?.startsWith(neoRoot + '/') ? 'neo' : 'builtin';
196
+ agentMap.set(name, {
197
+ name, path: sourcePath, source,
198
+ description: fields.description,
199
+ capabilities: listValue(fields.capabilities),
200
+ runtime: fields.runtime || listValue(fields.runtimes)[0] || undefined,
201
+ trustDomains: listValue(fields.trustDomains ?? fields.trust_domains ?? fields.trust),
202
+ delegation: listValue(fields.delegation),
203
+ restrictions: listValue(fields.restrictions ?? fields.boundaries),
204
+ });
222
205
  }
223
206
  for (const registration of options.registeredAgents ?? []) {
224
207
  if (agentMap.has(registration.name))
@@ -252,7 +235,7 @@ export async function resolveAgentContract(context, agentName) {
252
235
  throw new Error(`Agent ${agentName} is not visible in the resolved execution context`);
253
236
  if (!agent.path)
254
237
  throw new Error(`Agent ${agentName} has no readable Markdown contract`);
255
- const resolved = await resolveAgentMarkdown(agentName, BUNDLED_AGENTS_ROOT, context.project.path);
238
+ const resolved = await resolveAgentMarkdown(agentName, BUNDLED_AGENTS_ROOT, context.project.path, context.project.neoRoot);
256
239
  const definition = resolved.content;
257
240
  if (!definition.trim())
258
241
  throw new Error(`Agent ${agentName} Markdown contract is empty`);
@@ -282,6 +282,7 @@ export class ReconcilerRuntimeBase {
282
282
  ? 'worker_outcome_unknown' : platformFailure ? 'worker_platform_failure' : failureReason;
283
283
  task.leaseOwner = null;
284
284
  task.leaseExpiresAt = null;
285
+ task.executionId = null;
285
286
  },
286
287
  });
287
288
  if (!transition)
@@ -442,17 +442,11 @@ export class Reconciler extends ReconcilerReviewBase {
442
442
  let executionContext;
443
443
  try {
444
444
  const role = task.agentName === 'reviewer' ? 'reviewer' : task.agentName === 'troubleshooter' ? 'troubleshooter' : task.parentTaskId ? 'nested-worker' : 'worker';
445
- let projectPath = this.options.projectPathForJob ? await this.options.projectPathForJob(job) : task.routing?.cwd ?? process.cwd();
446
- const canonicalProjectRun = taskUsesCanonicalProjectRun(task, job.tasks);
445
+ const projectPath = this.options.projectPathForJob ? await this.options.projectPathForJob(job, task) : task.routing?.cwd ?? process.cwd();
447
446
  executionContext = await this.resolvedContext(job, task, role, projectPath);
448
447
  }
449
448
  catch (error) {
450
- task.leaseOwner = null;
451
- task.leaseExpiresAt = null;
452
- task.executionId = null;
453
- await transitionTaskEvent(task, 'block');
454
- task.error = `Execution checkout unavailable: ${error instanceof Error ? error.message : String(error)}`;
455
- await this.store.saveTask(task);
449
+ await this.failLaunch(job.id, task.id, task.executionId, `Execution context unavailable: ${error instanceof Error ? error.message : String(error)}`);
456
450
  return;
457
451
  }
458
452
  const startedAt = Date.now();
@@ -460,7 +454,14 @@ export class Reconciler extends ReconcilerReviewBase {
460
454
  const controller = new AbortController();
461
455
  let execution;
462
456
  const sourceId = executionEventSource(job.id, task.id, durableExecutionId);
463
- const baseWorkerInput = await managedWorkerInput(job, task, executionContext);
457
+ let baseWorkerInput;
458
+ try {
459
+ baseWorkerInput = await managedWorkerInput(job, task, executionContext);
460
+ }
461
+ catch (error) {
462
+ await this.failLaunch(job.id, task.id, task.executionId, `Execution contract unavailable: ${error instanceof Error ? error.message : String(error)}`);
463
+ return;
464
+ }
464
465
  const canonicalProjectRun = taskUsesCanonicalProjectRun(task, job.tasks);
465
466
  const workerInput = canonicalProjectRun
466
467
  ? `${baseWorkerInput}\n\n[Canonical Neo project run]\nExecution cwd: ${executionContext.project.path}\nKeep generated artifacts under runs/.`
@@ -1,5 +1,5 @@
1
1
  #!/usr/bin/env node
2
- import { defaultWorkspaceRoot } from './workspace.js';
2
+ import { resolve } from 'node:path';
3
3
  import { executionRouterRulesPath } from './routing-policy.js';
4
4
  import { RelayObservability, setObservability } from './observability.js';
5
5
  /** Initialize the process-wide structured observability sink once, from environment. */
@@ -37,7 +37,8 @@ Usage:
37
37
  Options:
38
38
  --no-github Local-only mode: no GitHub auth, transport, PR, merge, or fetch
39
39
  --repo OWNER/REPO Watch one repository
40
- --workspace PATH Discover repositories below PATH (default: ~/Workspace)
40
+ --workspace-root PATH Workspace Root (default: AGENTS_RELAY_WORKSPACE_ROOT, shared Neo config, or ~/Workspace)
41
+ --workspace PATH Legacy alias for --workspace-root
41
42
  --port PORT Dashboard port (default: 8787)
42
43
  --concurrency N Maximum concurrent workers (default: 4)
43
44
  --interval MS Recovery interval
@@ -50,7 +51,7 @@ Legacy single-job mode also accepts --repo OWNER/REPO --pr NUMBER --id JOB_ID.
50
51
 
51
52
  Examples:
52
53
  npx agents-relayd --repo OWNER/REPO
53
- npx agents-relayd --workspace ~/Workspace`;
54
+ npx agents-relayd --workspace-root ~/Workspace`;
54
55
  export function firstStageChoices(decisionInput) {
55
56
  const stages = decisionInput?.stages;
56
57
  if (!Array.isArray(stages) || stages.length === 0)
@@ -61,7 +62,10 @@ export function firstStageChoices(decisionInput) {
61
62
  export function value(args, name, fallback = '') { const index = args.indexOf(name); return index >= 0 ? args[index + 1] ?? fallback : fallback; }
62
63
  export function normalizeDaemonArguments(argv) {
63
64
  const repository = value(argv, '--repo') || null;
64
- const workspaceRoot = value(argv, '--workspace', defaultWorkspaceRoot()) || null;
65
+ if (argv.includes('--workspace-root') && argv.includes('--workspace'))
66
+ throw new Error('--workspace-root and --workspace cannot both be supplied');
67
+ const workspaceRootValue = value(argv, '--workspace-root') || value(argv, '--workspace') || process.env.AGENTS_RELAY_WORKSPACE_ROOT;
68
+ const workspaceRoot = workspaceRootValue ? resolve(workspaceRootValue) : null;
65
69
  const prValue = value(argv, '--pr');
66
70
  const id = value(argv, '--id');
67
71
  if ((prValue && !id) || (!prValue && id))
@@ -73,7 +77,7 @@ export function normalizeDaemonArguments(argv) {
73
77
  const pullRequest = prValue ? Number(prValue) : null;
74
78
  if (pullRequest !== null && (!Number.isInteger(pullRequest) || pullRequest <= 0))
75
79
  throw new Error('--pr must be positive');
76
- return { repository, workspaceRoot: repository ? null : workspaceRoot, pullRequest, jobId: id || null, serviceArguments: [...argv] };
80
+ return { repository, workspaceRoot, pullRequest, jobId: id || null, serviceArguments: [...argv] };
77
81
  }
78
82
  export function recoveryScanRepositories(repositories, cached, dormantProbeIndex = 0) {
79
83
  const byRepository = new Map();
package/dist/relayd.js CHANGED
@@ -34,7 +34,7 @@ import { applyJobActivateAction } from './job-actions.js';
34
34
  import { managedTaskUpdateHandler } from './task-update.js';
35
35
  import { dashboardJobSetState, dashboardTaskAction, dashboardTaskSetState } from './dashboard-actions.js';
36
36
  import { codexAndZaiUsageRegistry } from './usage.js';
37
- import { defaultWorkspaceRoot, discoverWorkspaceRepositories, ensureLocalJobSourceBranch, jobRequiresManagedWorktree, prepareLocalJobExecutionWorktree, resolveManagedProjectPath, resolveRepositoryProjectPath } from './workspace.js';
37
+ import { discoverWorkspaceRepositories, ensureLocalJobSourceBranch, prepareLocalJobExecutionWorktree, resolveManagedProjectPath, resolveRepositoryProjectPath, resolveWorkspaceRoot, taskRequiresManagedWorktree } from './workspace.js';
38
38
  import { deriveJobRuntimeStatus } from './runtime-status.js';
39
39
  import { createRuntimeStatusPublisher } from './runtime-status-events.js';
40
40
  import { subscribeLocalCommits } from './local-commit-events.js';
@@ -97,15 +97,16 @@ export async function runDaemon(argv) {
97
97
  const bus = eventResolution.bus;
98
98
  const stopRuntimeStatusEvents = bus ? subscribeLocalCommits(createRuntimeStatusPublisher(bus)) : () => { };
99
99
  process.stderr.write(`${eventBusResolutionMessage(eventResolution)}\n`);
100
- const workspaceRoot = noGitHub ? (value(argv, '--project-path') || value(argv, '--workspace') || process.cwd())
101
- : normalized.workspaceRoot ?? defaultWorkspaceRoot();
100
+ const workspaceRoot = await resolveWorkspaceRoot(normalized.workspaceRoot ?? undefined);
101
+ const explicitProjectPath = value(argv, '--project-path');
102
+ const localProjectPath = noGitHub && !explicitProjectPath && !argv.includes('--workspace') && !argv.includes('--workspace-root') && !process.env.AGENTS_RELAY_WORKSPACE_ROOT
103
+ ? process.cwd() : explicitProjectPath || workspaceRoot;
102
104
  const discoveredRepositories = normalized.repository || noGitHub ? [] : await discoverWorkspaceRepositories(workspaceRoot).catch(() => []);
103
- const repositories = noGitHub && !normalized.repository ? [`local/${basename(workspaceRoot)}`] : normalized.repository ? [normalized.repository] : discoveredRepositories.map(item => item.repository);
105
+ const repositories = noGitHub && !normalized.repository ? [`local/${basename(localProjectPath)}`] : normalized.repository ? [normalized.repository] : discoveredRepositories.map(item => item.repository);
104
106
  const repositoryPaths = new Map(discoveredRepositories.map(item => [item.repository, item.path]));
105
107
  if (noGitHub)
106
108
  for (const repo of repositories)
107
- repositoryPaths.set(repo, value(argv, '--project-path') || workspaceRoot);
108
- const explicitProjectPath = value(argv, '--project-path');
109
+ repositoryPaths.set(repo, localProjectPath);
109
110
  const usageRegistry = codexAndZaiUsageRegistry();
110
111
  const decisionAuditStore = new JsonlDecisionAuditStore(process.env.AGENTS_RELAY_DECISION_AUDIT_PATH ?? join(homedir(), '.agents-relay', 'decision-audit.jsonl'));
111
112
  let cachedOverviews = [];
@@ -152,15 +153,24 @@ export async function runDaemon(argv) {
152
153
  return dashboardManagedJobs(entries.filter(item => !['COMPLETED', 'CANCELLED'].includes(item.job.state) || counts.get(item.job.id) !== 1));
153
154
  };
154
155
  cachedOverviews = await inventoryFromLocal();
155
- const makeReconciler = (store, maxConcurrent) => new Reconciler(store, { noGitHub, 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: executionDecisionEngine(), decisionMode: decisionMode(), decisionAuditStore, eventBus: bus, escalationNotifier: escalationNotifierFromEnvironment(), reviewer: runtimeReviewer(argv), projectPathForJob: async (job) => {
156
+ const makeReconciler = (store, maxConcurrent) => new Reconciler(store, { noGitHub, 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: executionDecisionEngine(), decisionMode: decisionMode(), decisionAuditStore, eventBus: bus, escalationNotifier: escalationNotifierFromEnvironment(), reviewer: runtimeReviewer(argv), executionContextRoots: { workspaceRoot }, projectPathForJob: async (job, task) => {
156
157
  const canonical = repositoryPaths.get(job.repository) ?? await resolveManagedProjectPath(job.repository, explicitProjectPath, workspaceRoot);
157
- if (!jobRequiresManagedWorktree(job))
158
+ if (!(task ? taskRequiresManagedWorktree(job, task) : Boolean(job.localMerge?.sourceRef)))
158
159
  return resolveRepositoryProjectPath(canonical, job.projectPath);
159
- const sourceRef = await ensureLocalJobSourceBranch(canonical, job);
160
- if (!store.mutateJob)
161
- throw new Error('Local Job branch binding requires atomic SQLite mutation');
162
- const bound = await store.mutateJob(job.id, current => current.localMerge ? current : ({ ...current, localMerge: { sourceRef, targetRef: 'refs/heads/main', status: 'pending' }, updatedAt: new Date().toISOString() }));
163
- return prepareLocalJobExecutionWorktree(canonical, bound, workspaceRoot);
160
+ const sourceRef = job.localMerge?.sourceRef ?? await ensureLocalJobSourceBranch(canonical, job);
161
+ if (!job.localMerge?.sourceRef) {
162
+ if (!task || !store.mutateJob)
163
+ throw new Error('Managed source branch binding requires an executing code Task and atomic SQLite mutation');
164
+ const bound = await store.mutateJob(job.id, current => {
165
+ const activeTask = current.tasks.find(item => item.id === task.id);
166
+ if (current.localMerge || current.state !== 'ACTIVE' || !activeTask || !['QUEUED', 'RUNNING'].includes(activeTask.state) || !taskRequiresManagedWorktree(current, activeTask))
167
+ return current;
168
+ return { ...current, localMerge: { sourceRef, targetRef: 'refs/heads/main', status: 'pending' }, updatedAt: new Date().toISOString() };
169
+ });
170
+ if (!bound.localMerge?.sourceRef)
171
+ throw new Error('Job changed before managed source branch binding');
172
+ }
173
+ return prepareLocalJobExecutionWorktree(canonical, await store.load(job.id), workspaceRoot);
164
174
  } });
165
175
  const refreshAll = async (_full = false) => {
166
176
  cachedOverviews = await inventoryFromLocal();
package/dist/workspace.js CHANGED
@@ -1,4 +1,4 @@
1
- import { lstat, mkdir, readdir, realpath, stat } from 'node:fs/promises';
1
+ import { lstat, mkdir, readFile, readdir, realpath, stat } from 'node:fs/promises';
2
2
  import { basename, dirname, isAbsolute, join, relative, resolve, sep } from 'node:path';
3
3
  import { homedir } from 'node:os';
4
4
  import { createHash } from 'node:crypto';
@@ -17,6 +17,46 @@ async function gitOutput(args) {
17
17
  export function defaultWorkspaceRoot() {
18
18
  return join(homedir(), 'Workspace');
19
19
  }
20
+ export async function resolveWorkspaceRoot(explicit) {
21
+ let configured = explicit ?? process.env.AGENTS_RELAY_WORKSPACE_ROOT;
22
+ if (configured === undefined) {
23
+ const configPath = process.env.NEO_WORKSPACE_CONFIG ?? join(homedir(), '.config', 'neo', 'workspace.json');
24
+ let configText;
25
+ try {
26
+ configText = await readFile(configPath, 'utf8');
27
+ }
28
+ catch (error) {
29
+ if (error.code !== 'ENOENT')
30
+ throw new Error(`Cannot read workspace configuration: ${error instanceof Error ? error.message : String(error)}`);
31
+ }
32
+ if (configText !== undefined) {
33
+ let config;
34
+ try {
35
+ config = JSON.parse(configText);
36
+ }
37
+ catch {
38
+ throw new Error(`Invalid workspace configuration at ${configPath}: expected valid JSON`);
39
+ }
40
+ if (typeof config !== 'object' || config === null || !('schemaVersion' in config) || config.schemaVersion !== 1 || !('workspaceRoot' in config) || typeof config.workspaceRoot !== 'string' || !isAbsolute(config.workspaceRoot)) {
41
+ throw new Error(`Invalid workspace configuration at ${configPath}`);
42
+ }
43
+ configured = config.workspaceRoot;
44
+ }
45
+ }
46
+ // On first launch the default workspace may not exist yet. Only user-selected
47
+ // or saved roots are mandatory existing directories.
48
+ if (configured === undefined)
49
+ return defaultWorkspaceRoot();
50
+ const candidate = resolve(configured);
51
+ try {
52
+ if (!(await stat(candidate)).isDirectory())
53
+ throw new Error('not a directory');
54
+ return await realpath(candidate);
55
+ }
56
+ catch {
57
+ throw new Error(`Configured Workspace Root is not an accessible directory: ${candidate}`);
58
+ }
59
+ }
20
60
  export function githubRepositoryFromRemote(remote) {
21
61
  const value = remote.trim().replace(/\.git$/, '');
22
62
  const match = value.match(/github\.com[/:]([^/]+)\/([^/]+)$/i);
@@ -193,6 +233,9 @@ export function managedLocalJobWorktreePath(repository, jobId, workspaceRoot = d
193
233
  export function jobRequiresManagedWorktree(job) {
194
234
  return !(job.prNumber > 0) && (Boolean(job.localMerge?.sourceRef) || job.tasks.some(task => task.output?.kind === 'task_pr'));
195
235
  }
236
+ export function taskRequiresManagedWorktree(job, task) {
237
+ return jobRequiresManagedWorktree(job) && !(task && taskUsesCanonicalProjectRun(task, job.tasks));
238
+ }
196
239
  /** Bind an ordinary SQLite Job to one deterministic local work branch.
197
240
  * The target is always main, and branch creation never switches the user's checkout.
198
241
  * This is a Git-local operation and never needs GitHub credentials.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agents-relay",
3
- "version": "2.0.170",
3
+ "version": "2.0.172",
4
4
  "description": "Durable async agent jobs coordinated through GitHub pull requests",
5
5
  "repository": {
6
6
  "type": "git",
@@ -2,6 +2,18 @@
2
2
 
3
3
  **Scope:** Relay-specific event meaning and validation. This does **not** define Events Bus transport. The bus only handles generic event identity, type, timestamp, routing as needed, and opaque `data`. No Events Bus change is required when Relay adds agent roles or payload fields.
4
4
 
5
+ ## Execution event source
6
+
7
+ The Common Events Bus protocol (`<workspace>/neo/skills/events-bus/references/protocol.md`) owns the public `events.publish` input format. Its `source` field is a **string** identifying the exact current Relay execution:
8
+
9
+ ```text
10
+ source = "job/<job-id>/task/<task-id>/execution/<execution-id>"
11
+ ```
12
+
13
+ When calling the Neo MCP command facade, pass `command: "publish"` and nest `type`, `source`, and `data` under `args`. Do not pass an object such as `{ "source": { "id": "..." } }`; `source.id` belongs only to the internal Events Bus envelope.
14
+
15
+ Relay supplies the complete source string in the managed Task context. Agents must copy it unchanged; they must not infer a Job/Task ID, generate an execution ID, or reuse an identity from a previous attempt. Transport acceptance alone does not establish that Relay accepted the event for the current execution.
16
+
5
17
  ## Producer contract
6
18
 
7
19
  Every Implementation, Reviewer, and Troubleshooter execution publishes `task.started`, optional `task.progress`, then **exactly one** agent terminal event: