agents-relay 1.0.30 → 1.0.32

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
@@ -31,9 +31,16 @@ npx agents-relay task update --repo OWNER/REPO --pr 12 --id job-1 --task-id chil
31
31
  --capabilities model,chatgpt,browser-harness
32
32
  ~~~
33
33
 
34
- Jobs default to `fixed`, preserving the original submit-and-run behavior. Use
35
- `--mode autonomous` with `init` or `job create` to make reconciliation own
36
- planner progress. The durable status JSON and dashboard show the mode. An
34
+ Jobs default to `fixed`, preserving the original submit-and-run behavior. Every
35
+ executable task is now created by a completed durable planning decision. `job
36
+ create --start` records `planner-0` (supplied by the orchestrator) and the
37
+ initial task atomically from the caller's perspective; retrying the command is
38
+ idempotent. Use `--mode autonomous` with `init` or `job create` to make
39
+ reconciliation own planner progress. Autonomous creation with only an objective
40
+ may create a model planner first, but it cannot materialize work until that
41
+ planner succeeds. Fixed jobs never grow another planning round.
42
+
43
+ The durable status JSON and dashboard show the mode and planning provenance. An
37
44
  autonomous runtime uses the bundled `skills/agents-relay/agents/planner.agent.md` by default; `--planner-command` is an optional override
38
45
  snapshot as JSON on stdin and must return `{ "objective_status": "in_progress"|"satisfied", "assessment": "...", "next_tasks": [...] }`.
39
46
  Planner-created tasks use stable IDs and the existing parent/subtask tree, so
@@ -93,6 +100,14 @@ V1 assumes one active runner/daemon per job. Use a distributed atomic lease befo
93
100
 
94
101
  Codex/model-backed tasks require --provider and --model (or equivalent routing metadata) before launch. See [docs/architecture.md](docs/architecture.md) and [skills/agents-relay/SKILL.md](skills/agents-relay/SKILL.md).
95
102
 
103
+ XChat and other orchestrators can consume Relay's exact shared discovery implementation without duplicating filesystem search logic:
104
+
105
+ ~~~sh
106
+ npx agents-relay context resolve --project-path ~/Workspace/agents-relay --role orchestrator
107
+ ~~~
108
+
109
+ The command resolves concrete paths only for the current execution. Use `--skills` / `--agents` for explicit selections; no resolved absolute path is written into durable task state.
110
+
96
111
  The minimal agent-network surface is machine-driven registration and discovery. `agent-register` persists an agent identity, responsibility boundary, claimed capabilities, endpoint/runtime, availability, and routing metadata in a trusted PR marker; `agent-discover` applies hard filters and returns evidence-backed candidates. Adapters remain runtimes, not agent identities. See [docs/architecture.md](docs/architecture.md) for the constrained future remote submission contract.
97
112
 
98
113
  ### Agent worker lifecycle
@@ -103,7 +118,7 @@ Every model-backed task declares a durable output with `--output task-pr` or `--
103
118
 
104
119
  For Codex and ChatGPT, **events are authoritative task state**. Event publication uses the canonical Neo `events-bus` surface (`events__publish` for sandboxed/hosted workers, or `NEO_EVENTS_EMIT` for direct local workers); Agents Relay only subscribes and reconciles. Relay publishes `task.process.launched` only after successful adapter launch; `task.started` is worker-owned and must be a new, correlated start acknowledgement. Codex publishes `task.process.exited` when its synchronous runtime exits, while ChatGPT publishes `task.process.async_exited` after its submission runtime exits; neither substitutes for `task.completed`, `task.failed`, `task.blocked`, or `task.cancelled`. The ChatGPT worker-owned tab follows the configured close policy: `after-start` waits for the correlated start acknowledgement, `never` keeps it open for debug, and `after-terminal` waits for a new exact-task terminal event; start-ack failure closes only the owned tab after 60 seconds. The executing agent must publish exactly one correlated terminal event. Relay persists the matching durable state only from that terminal event. Model-backed tasks require an event bus and time out explicitly if no terminal event arrives.
105
120
 
106
- The ChatGPT adapter uses the packaged `chatgpt-browser-worker` as a one-shot submitter. It opens a fresh Temporary Chat tab, uses the account defaults, submits the prompt, verifies acceptance, and applies `--chatgpt-tab-close-policy` (or `AGENTS_RELAY_CHATGPT_TAB_CLOSE_POLICY`): `after-start` by default, `never` for test/debug, or `after-terminal` after a new exact-task terminal event. It never closes a user tab and does not poll for the assistant response, resume/reopen a thread, or use conversation text as the result channel. Any observed ChatGPT thread ID is diagnostic only.
121
+ The ChatGPT adapter uses the `chatgpt-browser-worker` path selected by the shared execution-context resolver; Relay does not bundle its own copy. The resolver applies project-local > Neo shared > global `~/.agents` precedence at execution time, so durable tasks store names/intent rather than stale absolute skill paths. It opens a fresh Temporary Chat tab, uses the account defaults, submits the prompt, verifies acceptance, and applies `--chatgpt-tab-close-policy` (or `AGENTS_RELAY_CHATGPT_TAB_CLOSE_POLICY`): `after-start` by default, `never` for test/debug, or `after-terminal` after a new exact-task terminal event. It never closes a user tab and does not poll for the assistant response, resume/reopen a thread, or use conversation text as the result channel. Any observed ChatGPT thread ID is diagnostic only.
107
122
 
108
123
  ~~~sh
109
124
  npx agents-relay submit --repo OWNER/REPO --pr 5 --id job-1 \
package/dist/adapters.js CHANGED
@@ -1,8 +1,6 @@
1
1
  import { spawn } from 'node:child_process';
2
2
  import { access, readFile, writeFile, unlink } from 'node:fs/promises';
3
- import { homedir } from 'node:os';
4
3
  import { dirname, join } from 'node:path';
5
- import { fileURLToPath } from 'node:url';
6
4
  import { randomUUID } from 'node:crypto';
7
5
  export function buildCodexArgs(route, input) {
8
6
  const args = ['exec', '--json'];
@@ -108,33 +106,23 @@ export class LaunchFailure extends Error {
108
106
  }
109
107
  export class StartAckTimeout extends LaunchFailure {
110
108
  }
111
- function browserWorkerCandidates(explicit) {
112
- const roots = (process.env.AGENTS_RELAY_SKILL_ROOTS ?? '').split(':').filter(Boolean);
113
- const packageSkills = join(dirname(fileURLToPath(import.meta.url)), '..', 'skills');
114
- return [
115
- explicit,
116
- ...roots,
117
- packageSkills,
118
- join(homedir(), '.codex', 'skills'),
119
- join(homedir(), '.agents', 'skills'),
120
- join(process.cwd(), 'skills'),
121
- ]
122
- .filter((value) => Boolean(value))
123
- .map(value => value.endsWith('browser-worker.agent.md')
124
- ? value
125
- : join(value, 'chatgpt-browser-worker', 'agents', 'browser-worker.agent.md'));
126
- }
127
- async function loadBrowserWorkerRuntime(explicit) {
128
- for (const candidate of browserWorkerCandidates(explicit)) {
129
- try {
130
- await access(candidate);
131
- return { definition: await readFile(candidate, 'utf8'), root: dirname(dirname(candidate)) };
132
- }
133
- catch {
134
- // Try the next installed skill root.
135
- }
109
+ async function loadBrowserWorkerRuntime(context, explicit) {
110
+ const resolvedSkill = context?.skills.find(skill => skill.name === 'chatgpt-browser-worker');
111
+ // Once a worker receives a resolved execution context, that context owns
112
+ // skill discovery. The explicit path remains the fallback for direct
113
+ // adapter use, where no shared context has been resolved yet.
114
+ const candidate = resolvedSkill
115
+ ? join(dirname(resolvedSkill.path), 'agents', 'browser-worker.agent.md')
116
+ : explicit;
117
+ if (!candidate)
118
+ throw new Error('chatgpt-browser-worker is not visible in the resolved execution context');
119
+ try {
120
+ await access(candidate);
121
+ return { definition: await readFile(candidate, 'utf8'), root: dirname(dirname(candidate)) };
122
+ }
123
+ catch {
124
+ throw new Error(`chatgpt-browser-worker agent definition not found at resolved path: ${candidate}`);
136
125
  }
137
- throw new Error('chatgpt-browser-worker agent definition not found; set AGENTS_RELAY_CHATGPT_BROWSER_WORKER_AGENT');
138
126
  }
139
127
  function parseDriverJson(output) {
140
128
  const lines = output.trim().split(/\r?\n/).filter(Boolean);
@@ -260,7 +248,7 @@ export class ChatGptAdapter {
260
248
  execution.launched = new Promise((resolve, reject) => { launchResolve = resolve; launchReject = reject; });
261
249
  execution.launched.catch(() => { });
262
250
  execution.promise = (async () => {
263
- const runtime = await loadBrowserWorkerRuntime(this.agentPath);
251
+ const runtime = await loadBrowserWorkerRuntime(context.executionContext, this.agentPath);
264
252
  if (controller.signal.aborted)
265
253
  throw new Error('browser-worker execution aborted');
266
254
  const policy = chatGptTabClosePolicy(task.chatgptTabClosePolicy, this.tabClosePolicy);
package/dist/cli.js CHANGED
@@ -14,9 +14,11 @@ import { NatsEventBus, eventFor } from './events.js';
14
14
  import { serveDashboard } from './dashboard.js';
15
15
  import { codexAndZaiUsageRegistry } from './usage.js';
16
16
  import { discoverAgents } from './registry.js';
17
- import { AgentObjectivePlanner, CommandObjectivePlanner } from './planner.js';
17
+ import { AgentObjectivePlanner, CommandObjectivePlanner, orchestratorPlanningDecision } from './planner.js';
18
18
  import { extendRetryBudget } from './scheduler.js';
19
- const usage = 'agents-relay <job|task|init|submit|record|agent-register|agent-discover|status|reconcile|retry|cancel|serve> [options]';
19
+ import { resolveExecutionContext } from './execution-context.js';
20
+ import { defaultWorkspaceRoot, resolveManagedProjectPath } from './workspace.js';
21
+ const usage = 'agents-relay <job|task|context|init|submit|record|agent-register|agent-discover|status|reconcile|retry|cancel|serve> [options]';
20
22
  const help = {
21
23
  root: `Agents Relay coordinates durable asynchronous agent jobs through GitHub pull requests.
22
24
 
@@ -26,6 +28,7 @@ Usage:
26
28
  Commands:
27
29
  job create|adopt|repair Create or repair a durable GitHub-backed job
28
30
  task update Update mutable durable task fields in place
31
+ context resolve Resolve shared project instructions, skills, agents, and runtimes
29
32
  init Create a local demo job or initialize a GitHub job
30
33
  submit Queue a task for a managed job
31
34
  record Backfill a completed task into durable history
@@ -96,6 +99,21 @@ Use --clear-routing to remove routing metadata. A model update requires --model;
96
99
 
97
100
  Example:
98
101
  npx agents-relay task update --repo OWNER/REPO --pr 12 --id job-1 --task-id build --adapter chatgpt --provider chatgpt --model gpt-5.6-sol --reasoning high`,
102
+ context: `Resolve the shared execution context used by XChat and Relay.
103
+
104
+ Usage:
105
+ npx agents-relay context resolve --project-path PATH --role ROLE [options]
106
+
107
+ Actions:
108
+ resolve Resolve instructions, visible skills/agents, and runtime/delegation metadata`,
109
+ 'context resolve': `Resolve shared execution context without creating durable state.
110
+
111
+ Required: --project-path PATH.
112
+ Common: --role orchestrator|planner|reviewer|worker|nested-worker, --skills a,b, --agents a,b.
113
+ Root overrides for testing/configuration: --workspace-root PATH, --neo-root PATH|none, --global-root PATH.
114
+
115
+ Example:
116
+ npx agents-relay context resolve --project-path ~/Workspace/agents-relay --role orchestrator`,
99
117
  submit: `Queue a task for a managed job.
100
118
 
101
119
  Required: --repo OWNER/REPO, --pr NUMBER, --id JOB_ID, and --input TEXT.
@@ -167,6 +185,8 @@ export function helpText(command, action) {
167
185
  return action ? help[`job ${action}`] ?? help.job : help.job;
168
186
  if (command === 'task')
169
187
  return action ? help[`task ${action}`] ?? help.task : help.task;
188
+ if (command === 'context')
189
+ return action ? help[`context ${action}`] ?? help.context : help.context;
170
190
  return command ? help[command] ?? help.root : help.root;
171
191
  }
172
192
  export const SERVICE_DEFAULTS = { port: 8787, watchdogMs: 300000, webhookWatchdogMs: 1800000, dashboardRefreshMs: 300000 };
@@ -250,7 +270,7 @@ export async function ensureManagedGitHubJob(client, repositoryName, trustedAuth
250
270
  if (!(error instanceof Error) || !error.message.includes('has no durable'))
251
271
  throw error;
252
272
  const now = new Date().toISOString();
253
- job = { id: options.id, title: options.title || pr.title || 'Agents Relay job', objective: options.objective || pr.body || undefined, description: pr.body || undefined, priority: options.priority ?? 'P2', executionMode: options.executionMode ?? 'fixed', autoMerge: options.autoMerge ?? true, prNumber: pr.number, repository: repositoryName, state: 'OPEN', continuation: options.continuation ?? null, createdAt: now, updatedAt: now, tasks: [] };
273
+ job = { id: options.id, title: options.title || pr.title || 'Agents Relay job', objective: options.objective || pr.body || undefined, description: pr.body || undefined, priority: options.priority ?? 'P2', executionMode: options.executionMode ?? 'fixed', autoMerge: options.autoMerge ?? true, prNumber: pr.number, repository: repositoryName, state: 'OPEN', continuation: options.continuation ?? null, initialPlanning: options.initialTasks?.length ? { status: 'pending', decisionId: 'planner-0' } : undefined, createdAt: now, updatedAt: now, tasks: [] };
254
274
  }
255
275
  if (!job.description && pr.body)
256
276
  job.description = pr.body;
@@ -266,9 +286,40 @@ export async function ensureManagedGitHubJob(client, repositoryName, trustedAuth
266
286
  job.priority = 'P2';
267
287
  if (!job.title && pr.title)
268
288
  job.title = pr.title;
269
- await store.saveJob(job);
289
+ if (options.initialTasks?.length) {
290
+ const decisionId = job.initialPlanning?.decisionId ?? 'planner-0';
291
+ job.initialPlanning = { status: 'pending', decisionId };
292
+ job.updatedAt = new Date().toISOString();
293
+ await store.saveJob(job);
294
+ const decision = orchestratorPlanningDecision(job, options.initialTasks, decisionId, null, 0);
295
+ if (!(await store.load(options.id)).tasks.some(task => task.id === decision.id))
296
+ await store.appendTask(decision);
297
+ for (const original of options.initialTasks) {
298
+ const task = { ...original, parentTaskId: decision.id, planningDecisionId: decision.id, planningSource: 'orchestrator', planningRound: 0 };
299
+ if (!(await store.load(options.id)).tasks.some(item => item.id === task.id))
300
+ await store.appendTask(task);
301
+ }
302
+ job.initialPlanning = { status: 'complete', decisionId };
303
+ job.updatedAt = new Date().toISOString();
304
+ await store.saveJob(job);
305
+ }
306
+ else
307
+ await store.saveJob(job);
270
308
  return { job: await store.load(options.id), pr };
271
309
  }
310
+ async function appendPlannedTask(store, job, task) {
311
+ const existing = job.tasks.find(item => item.id === task.id);
312
+ if (existing)
313
+ return existing;
314
+ const decisionId = `plan-${task.id}`;
315
+ const decision = orchestratorPlanningDecision(job, [task], decisionId, task.parentTaskId, job.tasks.filter(item => item.kind === 'planner').length, 'orchestrator');
316
+ if (!job.tasks.some(item => item.id === decision.id))
317
+ await store.appendTask(decision);
318
+ const planned = { ...task, parentTaskId: task.parentTaskId ?? null, planningDecisionId: decision.id, planningSource: 'orchestrator', planningRound: decision.planningRound };
319
+ await store.appendTask(planned);
320
+ job.tasks.push(planned, decision);
321
+ return planned;
322
+ }
272
323
  function workerTaskFromArgs(job, args, now, requireInput = false) {
273
324
  const model = arg(args, '--model');
274
325
  const adapterValue = arg(args, '--adapter');
@@ -390,6 +441,26 @@ async function runTaskCommand(action, args) {
390
441
  await saveLocal(localFile, job);
391
442
  console.log(JSON.stringify(task, null, 2));
392
443
  }
444
+ async function runContextCommand(action, args) {
445
+ if (action !== 'resolve')
446
+ throw new Error(usage);
447
+ const projectPath = arg(args, '--project-path');
448
+ if (!projectPath)
449
+ throw new Error('context resolve requires --project-path');
450
+ const roleValue = arg(args, '--role', 'orchestrator');
451
+ const roles = ['orchestrator', 'planner', 'reviewer', 'worker', 'nested-worker'];
452
+ if (!roles.includes(roleValue))
453
+ throw new Error('--role must be orchestrator, planner, reviewer, worker, or nested-worker');
454
+ const neoRootArg = arg(args, '--neo-root');
455
+ const context = await resolveExecutionContext(projectPath, roleValue, {
456
+ workspaceRoot: arg(args, '--workspace-root') || undefined,
457
+ neoRoot: neoRootArg === 'none' ? null : neoRootArg || undefined,
458
+ globalRoot: arg(args, '--global-root') || undefined,
459
+ explicitSkills: arg(args, '--skills').split(',').filter(Boolean),
460
+ explicitAgents: arg(args, '--agents').split(',').filter(Boolean),
461
+ });
462
+ console.log(JSON.stringify(context, null, 2));
463
+ }
393
464
  async function runJobCommand(action, args) {
394
465
  if (!['create', 'adopt', 'repair'].includes(action))
395
466
  throw new Error(usage);
@@ -398,12 +469,12 @@ async function runJobCommand(action, args) {
398
469
  if (!id)
399
470
  throw new Error('--id is required for managed GitHub jobs');
400
471
  const start = hasArg(args, '--start');
472
+ const initialTask = start ? workerTaskFromArgs({ id, title: '', executionMode: 'fixed', prNumber: 0, repository: repo, state: 'OPEN', continuation: null, createdAt: '', updatedAt: '', tasks: [] }, args, new Date().toISOString(), true) : undefined;
401
473
  if (start && action !== 'create')
402
474
  throw new Error('--start is only supported by job create');
403
475
  if (start) {
404
476
  if (!hasArg(args, '--adapter'))
405
477
  throw new Error('job create --start requires --adapter codex|chatgpt');
406
- workerTaskFromArgs({ id, title: '', executionMode: 'fixed', prNumber: 0, repository: repo, state: 'OPEN', continuation: null, createdAt: '', updatedAt: '', tasks: [] }, args, new Date().toISOString(), true);
407
478
  }
408
479
  const auth = await githubAuthContext(args);
409
480
  const client = auth.client;
@@ -425,23 +496,16 @@ async function runJobCommand(action, args) {
425
496
  prNumber,
426
497
  head: arg(args, '--head') || undefined,
427
498
  base: arg(args, '--base', 'main'),
428
- body: arg(args, '--body') || undefined
499
+ body: arg(args, '--body') || undefined,
500
+ initialTasks: initialTask ? [initialTask] : undefined
429
501
  });
430
- if (start) {
431
- const task = workerTaskFromArgs(result.job, args, new Date().toISOString(), true);
432
- const existing = result.job.tasks.find(item => item.id === task.id);
433
- const store = new GitHubStore(client, repo, result.pr.number, trustedAuthors);
434
- await store.appendTask(task);
435
- if (!existing) {
436
- await publishWake(eventBus(args), result.job, task.id, `Task ${task.id} submitted`);
437
- result.job.tasks.push(task);
438
- }
439
- }
502
+ if (start)
503
+ await publishWake(eventBus(args), result.job, initialTask?.id ?? 'initial', `Initial planning decision and task ${initialTask?.id ?? 'initial'} recorded`);
440
504
  console.log(JSON.stringify({ repository: repo, pr: result.pr.number, job: result.job.id, executionMode: result.job.executionMode, state: result.job.state, task: start ? arg(args, '--task-id', 'initial') : undefined }, null, 2));
441
505
  }
442
506
  export function runtimePlanner(args) { const plannerCommand = arg([...args], '--planner-command'); const modelRuntime = arg([...args], '--codex', 'codex'); return plannerCommand ? new CommandObjectivePlanner(plannerCommand) : new AgentObjectivePlanner(modelRuntime); }
443
507
  export function runtimeReviewer() { return { adapter: 'chatgpt', routing: { provider: 'chatgpt', model: 'account-defaults', decidedBy: 'fixed-chatgpt-reviewer', decidedAt: new Date().toISOString() } }; }
444
- export function runtime(store, args, bus) { const emit = (event) => { process.stderr.write(`${JSON.stringify(event)}\n`); }; const modelRuntime = arg(args, '--codex', 'codex'); const policy = chatGptTabClosePolicy(args); const planner = runtimePlanner(args); return new Reconciler(store, { 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, reviewer: runtimeReviewer(), eventBus: bus, emit, chatgptTabClosePolicy: policy }); }
508
+ export function runtime(store, args, bus) { const emit = (event) => { process.stderr.write(`${JSON.stringify(event)}\n`); }; const modelRuntime = arg(args, '--codex', 'codex'); const policy = chatGptTabClosePolicy(args); const planner = runtimePlanner(args); return new Reconciler(store, { 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, reviewer: runtimeReviewer(), eventBus: bus, emit, chatgptTabClosePolicy: policy, projectPathForJob: job => resolveManagedProjectPath(job.repository, arg(args, '--project-path'), arg(args, '--workspace-root') || defaultWorkspaceRoot()) }); }
445
509
  export async function createService(args) {
446
510
  const loaded = await storeFor(args);
447
511
  const upstream = eventBus(args);
@@ -519,7 +583,7 @@ async function main() {
519
583
  return;
520
584
  }
521
585
  if (args.includes('--help') || args.includes('-h')) {
522
- console.log(helpText(command, command === 'job' || command === 'task' ? args[0] : undefined));
586
+ console.log(helpText(command, command === 'job' || command === 'task' || command === 'context' ? args[0] : undefined));
523
587
  return;
524
588
  }
525
589
  if (command === 'job') {
@@ -532,6 +596,11 @@ async function main() {
532
596
  await runTaskCommand(action ?? '', taskArgs);
533
597
  return;
534
598
  }
599
+ if (command === 'context') {
600
+ const [action, ...contextArgs] = args;
601
+ await runContextCommand(action ?? '', contextArgs);
602
+ return;
603
+ }
535
604
  if (command === 'init') {
536
605
  const now = new Date().toISOString();
537
606
  const job = { id: arg(args, '--id', randomUUID()), title: arg(args, '--title', 'Agents Relay job'), objective: arg(args, '--objective') || undefined, priority: priority(args), executionMode: executionMode(args), autoMerge: !hasArg(args, '--no-auto-merge'), prNumber: Number(arg(args, '--pr', '0')), repository: arg(args, '--repo'), state: 'OPEN', continuation: continuation(args), createdAt: now, updatedAt: now, tasks: [] };
@@ -576,8 +645,7 @@ async function main() {
576
645
  throw new Error('record requires --summary');
577
646
  const commit = arg(args, '--commit');
578
647
  const task = { jobId: job.id, id, projectName: arg(args, '--project') || undefined, agentName: arg(args, '--agent', 'orchestrator'), parentTaskId: arg(args, '--parent') || null, dependencies: arg(args, '--deps').split(',').filter(Boolean), capabilities: [], adapter: 'orchestrator', input: arg(args, '--input', summary), continuation: null, continuationDeliveredAt: null, state: 'SUCCEEDED', attempt: 1, maxAttempts: 1, leaseOwner: null, leaseExpiresAt: null, executionId: commit || null, threadId: null, result: { summary, data: commit ? { commit } : undefined }, error: null, timeoutMs: 0, createdAt: now, updatedAt: now };
579
- await store.appendTask(task);
580
- job.tasks.push(task);
648
+ await appendPlannedTask(store, job, task);
581
649
  await saveLocal(localFile, job);
582
650
  console.log(`Recorded ${id}`);
583
651
  return;
@@ -586,16 +654,15 @@ async function main() {
586
654
  const now = new Date().toISOString();
587
655
  const task = workerTaskFromArgs(job, args, now);
588
656
  task.id = arg(args, '--task-id', randomUUID());
589
- await store.appendTask(task);
590
- job.tasks.push(task);
657
+ const planned = await appendPlannedTask(store, job, task);
591
658
  if (job.state !== 'RUNNING') {
592
659
  job.state = 'RUNNING';
593
660
  job.updatedAt = now;
594
661
  await store.saveJob(job);
595
662
  }
596
663
  await saveLocal(localFile, job);
597
- await publishWake(bus, job, task.id, `Task ${task.id} submitted`);
598
- console.log(`Submitted ${task.id}`);
664
+ await publishWake(bus, job, planned.id, `Planning decision recorded and task ${planned.id} submitted`);
665
+ console.log(`Submitted ${planned.id}`);
599
666
  return;
600
667
  }
601
668
  if (command === 'status') {
package/dist/dashboard.js CHANGED
@@ -240,7 +240,7 @@ function renderJobList(){jobList.replaceChildren();const visible=filteredJobs();
240
240
  function taskNode(task,children,depth){const li=element('li');const row=document.createElement('button');row.type='button';row.className='task-row';row.setAttribute('aria-current',String(task.id===selectedTaskId));row.title=task.id;row.addEventListener('click',()=>{selectedTaskId=task.id;eventTaskId=task.id;rememberSelection();clearEvents();renderAll();void loadEventHistory();});const id=element('span','task-id',text(task.id));id.style.paddingLeft=Math.min(depth,4)*6+'px';row.append(id,element('span','badge '+value(task.state),text(task.state)));li.append(row);const nested=children.get(task.id)||[];if(nested.length){const ul=element('ul','tree');for(const child of nested)ul.append(taskNode(child,children,depth+1));li.append(ul);}return li;}
241
241
  function buildTree(tasks){taskTree.replaceChildren();if(!Array.isArray(tasks)||!tasks.length){taskTree.append(element('li','empty',text('No tasks in durable state.')));return;}const known=new Set(tasks.map(task=>task.id));const children=new Map();const roots=[];for(const task of tasks){if(!task.parentTaskId||!known.has(task.parentTaskId))roots.push(task);else{const siblings=children.get(task.parentTaskId)||[];siblings.push(task);children.set(task.parentTaskId,siblings);}}for(const task of roots)taskTree.append(taskNode(task,children,0));}
242
242
  function attemptBudget(task){const attempt=Number(task?.attempt)||0;const max=Number(task?.maxAttempts)||0;if(max>=3&&max%3===0){const effectiveMax=Math.max(max,Math.ceil(attempt/3)*3);return attempt+' / 3×'+(effectiveMax/3);}return attempt+' / '+max;}
243
- function renderTaskDetail(task){taskDetailRoot.replaceChildren();if(!task){taskDetailRoot.append(element('p','empty',text('Select a task to inspect execution details.')));return;}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('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(groups);}
243
+ function renderTaskDetail(task){taskDetailRoot.replaceChildren();if(!task){taskDetailRoot.append(element('p','empty',text('Select a task to inspect execution details.')));return;}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(groups);}
244
244
  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();}
245
245
  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('GitHub state',job.githubState||'UNKNOWN'),field('Durable state',job.state),field('Execution mode',job.executionMode||'fixed'),field('Status',awaitingInitialTask(job)?'Awaiting initial task':job.state),field('Review',reviewStatus(job)),field('Review head SHA',job.review?.headSha||job.headSha||'—'),field('Objective',job.objective||job.description||job.title),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();}
246
246
  function renderAll(){jobs.sort(compareJobs);syncFilters();renderJobList();renderSelectedJob();}
@@ -0,0 +1,258 @@
1
+ import { access, readFile, readdir, realpath, stat } from 'node:fs/promises';
2
+ import { homedir } from 'node:os';
3
+ import { basename, dirname, isAbsolute, join, relative, resolve, sep } from 'node:path';
4
+ async function exists(path) {
5
+ try {
6
+ await access(path);
7
+ return true;
8
+ }
9
+ catch {
10
+ return false;
11
+ }
12
+ }
13
+ function isWithin(path, root) {
14
+ const value = relative(root, path);
15
+ return value === '' || (!value.startsWith('..' + sep) && value !== '..' && !isAbsolute(value));
16
+ }
17
+ async function directory(path) {
18
+ try {
19
+ return (await stat(path)).isDirectory();
20
+ }
21
+ catch {
22
+ return false;
23
+ }
24
+ }
25
+ function configuredWorkspaceRoot(projectPath, explicit) {
26
+ const configured = explicit ?? process.env.AGENTS_RELAY_WORKSPACE_ROOT ?? process.env.XCHAT_WORKSPACE_ROOT;
27
+ if (configured)
28
+ return resolve(configured);
29
+ const conventional = join(homedir(), 'Workspace');
30
+ return isWithin(projectPath, conventional) ? conventional : dirname(projectPath);
31
+ }
32
+ async function configuredNeoRoot(workspaceRoot, explicit) {
33
+ if (explicit === null)
34
+ return null;
35
+ const configured = explicit ?? process.env.NEO_SHARED_ROOT;
36
+ if (configured)
37
+ return await directory(resolve(configured)) ? await realpath(resolve(configured)) : null;
38
+ const candidate = join(workspaceRoot, 'neo');
39
+ return await directory(candidate) ? await realpath(candidate) : null;
40
+ }
41
+ function configuredGlobalRoot(explicit) {
42
+ return resolve(explicit ?? process.env.AGENTS_HOME ?? join(homedir(), '.agents'));
43
+ }
44
+ function parseFrontmatter(content) {
45
+ if (!content.startsWith('---\n'))
46
+ return {};
47
+ const end = content.indexOf('\n---', 4);
48
+ if (end < 0)
49
+ return {};
50
+ const result = {};
51
+ for (const line of content.slice(4, end).split(/\r?\n/)) {
52
+ const match = line.match(/^([A-Za-z0-9_-]+):\s*(.*)$/);
53
+ if (!match)
54
+ continue;
55
+ result[match[1]] = match[2].trim().replace(/^['"]|['"]$/g, '');
56
+ }
57
+ return result;
58
+ }
59
+ function listValue(value) {
60
+ if (!value)
61
+ return [];
62
+ const trimmed = value.trim();
63
+ const body = trimmed.startsWith('[') && trimmed.endsWith(']') ? trimmed.slice(1, -1) : trimmed;
64
+ return body.split(/[|,]/).map(item => item.trim().replace(/^['"]|['"]$/g, '')).filter(Boolean);
65
+ }
66
+ async function metadata(path) {
67
+ const content = await readFile(path, 'utf8');
68
+ return { fields: parseFrontmatter(content), content };
69
+ }
70
+ async function instructionPaths(projectPath, workspaceRoot, neoRoot, globalRoot) {
71
+ const found = [];
72
+ const add = async (path, source) => {
73
+ if (await exists(path)) {
74
+ const concrete = await realpath(path);
75
+ if (!found.some(item => item.path === concrete))
76
+ found.push({ path: concrete, source });
77
+ }
78
+ };
79
+ await add(join(globalRoot, 'AGENTS.md'), 'global');
80
+ if (isWithin(projectPath, workspaceRoot)) {
81
+ const segments = relative(workspaceRoot, projectPath).split(sep).filter(Boolean);
82
+ let cursor = workspaceRoot;
83
+ await add(join(cursor, 'AGENTS.md'), neoRoot && isWithin(cursor, neoRoot) ? 'neo' : 'project');
84
+ for (const segment of segments) {
85
+ cursor = join(cursor, segment);
86
+ const source = neoRoot && isWithin(cursor, neoRoot) && !isWithin(cursor, projectPath) ? 'neo' : 'project';
87
+ await add(join(cursor, 'AGENTS.md'), source);
88
+ }
89
+ }
90
+ else {
91
+ await add(join(projectPath, 'AGENTS.md'), 'project');
92
+ }
93
+ await add(join(projectPath, 'README.md'), 'project');
94
+ return found;
95
+ }
96
+ async function skillLayer(root, source) {
97
+ const skillsRoot = join(root, 'skills');
98
+ let entries;
99
+ try {
100
+ entries = await readdir(skillsRoot, { withFileTypes: true });
101
+ }
102
+ catch {
103
+ return [];
104
+ }
105
+ const result = [];
106
+ for (const entry of entries) {
107
+ if (!entry.isDirectory())
108
+ continue;
109
+ const path = join(skillsRoot, entry.name, 'SKILL.md');
110
+ if (!await exists(path))
111
+ continue;
112
+ const { fields } = await metadata(path);
113
+ result.push({ name: fields.name || entry.name, path: await realpath(path), source, description: fields.description || undefined });
114
+ }
115
+ return result.sort((a, b) => a.name.localeCompare(b.name));
116
+ }
117
+ async function agentFiles(root, source, skills) {
118
+ const candidates = [];
119
+ const skillDirs = skills.filter(skill => skill.source === source).map(skill => dirname(skill.path));
120
+ for (const skillDir of skillDirs) {
121
+ let entries;
122
+ try {
123
+ entries = await readdir(join(skillDir, 'agents'), { withFileTypes: true });
124
+ }
125
+ catch {
126
+ continue;
127
+ }
128
+ for (const entry of entries)
129
+ if (entry.isFile() && entry.name.endsWith('.md'))
130
+ candidates.push(join(skillDir, 'agents', entry.name));
131
+ }
132
+ let direct;
133
+ try {
134
+ direct = await readdir(join(root, 'agents'), { withFileTypes: true });
135
+ }
136
+ catch {
137
+ direct = [];
138
+ }
139
+ for (const entry of direct)
140
+ if (entry.isFile() && entry.name.endsWith('.md'))
141
+ candidates.push(join(root, 'agents', entry.name));
142
+ const result = [];
143
+ for (const path of candidates) {
144
+ const { fields } = await metadata(path);
145
+ result.push({
146
+ name: fields.name || basename(path).replace(/\.agent\.md$/i, '').replace(/\.md$/i, ''),
147
+ path: await realpath(path),
148
+ source,
149
+ description: fields.description || undefined,
150
+ capabilities: listValue(fields.capabilities),
151
+ runtime: fields.runtime || listValue(fields.runtimes)[0] || undefined,
152
+ trustDomains: listValue(fields.trustDomains ?? fields.trust_domains ?? fields.trust),
153
+ delegation: listValue(fields.delegation),
154
+ restrictions: listValue(fields.restrictions ?? fields.boundaries),
155
+ });
156
+ }
157
+ return result;
158
+ }
159
+ function selectByName(items, explicit, kind) {
160
+ if (!explicit?.length)
161
+ return [...items];
162
+ const selected = explicit.map(name => {
163
+ const item = items.find(candidate => candidate.name === name);
164
+ if (!item)
165
+ throw new Error(`Explicit ${kind} ${name} is not visible in the resolved execution context`);
166
+ return item;
167
+ });
168
+ return selected;
169
+ }
170
+ export async function resolveExecutionContext(projectPath, role, options = {}) {
171
+ if (!projectPath)
172
+ throw new Error('projectPath is required');
173
+ const project = await realpath(resolve(projectPath)).catch(() => { throw new Error(`Project path not found: ${projectPath}`); });
174
+ if (!await directory(project))
175
+ throw new Error(`Project path is not a directory: ${projectPath}`);
176
+ const workspaceCandidate = configuredWorkspaceRoot(project, options.workspaceRoot);
177
+ const workspaceRoot = await realpath(workspaceCandidate).catch(() => workspaceCandidate);
178
+ const neoRoot = await configuredNeoRoot(workspaceRoot, options.neoRoot);
179
+ const globalCandidate = configuredGlobalRoot(options.globalRoot);
180
+ const globalRoot = await realpath(globalCandidate).catch(() => globalCandidate);
181
+ const layerRoots = [
182
+ { root: globalRoot, source: 'global' },
183
+ ...(neoRoot ? [{ root: neoRoot, source: 'neo' }] : []),
184
+ { root: project, source: 'project' },
185
+ ];
186
+ const uniqueLayers = layerRoots.filter((layer, index, list) => list.findIndex(item => resolve(item.root) === resolve(layer.root)) === index);
187
+ const skillMap = new Map();
188
+ const skillsByLayer = new Map();
189
+ for (const layer of uniqueLayers) {
190
+ const layerSkills = await skillLayer(layer.root, layer.source);
191
+ skillsByLayer.set(layer.root, layerSkills);
192
+ for (const skill of layerSkills)
193
+ skillMap.set(skill.name, skill);
194
+ }
195
+ const skills = selectByName([...skillMap.values()].sort((a, b) => a.name.localeCompare(b.name)), options.explicitSkills, 'skill');
196
+ const agentMap = new Map();
197
+ for (const layer of uniqueLayers) {
198
+ for (const agent of await agentFiles(layer.root, layer.source, skillsByLayer.get(layer.root) ?? []))
199
+ agentMap.set(agent.name, agent);
200
+ }
201
+ for (const registration of options.registeredAgents ?? []) {
202
+ if (agentMap.has(registration.name))
203
+ continue;
204
+ agentMap.set(registration.name, {
205
+ name: registration.name,
206
+ source: 'registry',
207
+ description: registration.responsibility,
208
+ capabilities: [...registration.capabilities],
209
+ runtime: registration.runtime.adapter,
210
+ trustDomains: [...(registration.routing.trustDomains ?? [])],
211
+ delegation: [],
212
+ restrictions: [...registration.boundaries],
213
+ registration,
214
+ });
215
+ }
216
+ const agents = selectByName([...agentMap.values()].sort((a, b) => a.name.localeCompare(b.name)), options.explicitAgents, 'agent');
217
+ return {
218
+ project: { name: basename(project), path: project, workspaceRoot, neoRoot, globalRoot },
219
+ role,
220
+ instructions: await instructionPaths(project, workspaceRoot, neoRoot, globalRoot),
221
+ skills,
222
+ agents,
223
+ runtimes: (options.runtimes ?? []).map(runtime => ({ name: runtime.name ?? runtime.adapter ?? 'unknown', id: runtime.id, capabilities: [...runtime.capabilities], source: 'runtime' })),
224
+ delegation: { mode: 'relay-child-task', parentTaskIdRequired: role === 'nested-worker' },
225
+ };
226
+ }
227
+ export function executionContextPrompt(context) {
228
+ const instructions = context.instructions.map(item => `- ${item.path} [${item.source}]`).join('\n') || '- none';
229
+ const skills = context.skills.map(item => `- ${item.name}: ${item.path} [${item.source}]`).join('\n') || '- none';
230
+ const agents = context.agents.map(item => {
231
+ const details = [
232
+ item.capabilities.length ? `capabilities=${item.capabilities.join(',')}` : '',
233
+ item.runtime ? `runtime=${item.runtime}` : '',
234
+ item.trustDomains.length ? `trust=${item.trustDomains.join(',')}` : '',
235
+ item.restrictions.length ? `restrictions=${item.restrictions.join(' | ')}` : '',
236
+ ].filter(Boolean).join('; ');
237
+ return `- ${item.name}${item.path ? `: ${item.path}` : ''} [${item.source}]${details ? ` — ${details}` : ''}`;
238
+ }).join('\n') || '- none';
239
+ const runtimes = context.runtimes.map(item => `- ${item.name}${item.id ? ` (${item.id})` : ''}: ${item.capabilities.join(',')}`).join('\n') || '- none';
240
+ return `[Resolved execution context]
241
+ Role: ${context.role}
242
+ Project path: ${context.project.path}
243
+
244
+ Instructions (read in listed order; later/project-local instructions override earlier shared defaults):
245
+ ${instructions}
246
+
247
+ Visible skills (already de-duplicated with project-local > Neo shared > global precedence):
248
+ ${skills}
249
+
250
+ Visible agents:
251
+ ${agents}
252
+
253
+ Runtime capabilities:
254
+ ${runtimes}
255
+
256
+ Delegation contract:
257
+ Nested work MUST be created as an Agents Relay durable child task with parentTaskId lineage. Do not directly spawn arbitrary child agents. Leaf workers do not inherit top-level XChat orchestration duties merely because xchat-orchestrator is visible.`;
258
+ }