agents-relay 1.0.30 → 1.0.31

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
@@ -93,6 +93,14 @@ V1 assumes one active runner/daemon per job. Use a distributed atomic lease befo
93
93
 
94
94
  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
95
 
96
+ XChat and other orchestrators can consume Relay's exact shared discovery implementation without duplicating filesystem search logic:
97
+
98
+ ~~~sh
99
+ npx agents-relay context resolve --project-path ~/Workspace/agents-relay --role orchestrator
100
+ ~~~
101
+
102
+ 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.
103
+
96
104
  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
105
 
98
106
  ### Agent worker lifecycle
@@ -103,7 +111,7 @@ Every model-backed task declares a durable output with `--output task-pr` or `--
103
111
 
104
112
  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
113
 
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.
114
+ 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
115
 
108
116
  ~~~sh
109
117
  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,18 @@ 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
+ const candidate = explicit ?? (resolvedSkill ? join(dirname(resolvedSkill.path), 'agents', 'browser-worker.agent.md') : undefined);
112
+ if (!candidate)
113
+ throw new Error('chatgpt-browser-worker is not visible in the resolved execution context');
114
+ try {
115
+ await access(candidate);
116
+ return { definition: await readFile(candidate, 'utf8'), root: dirname(dirname(candidate)) };
117
+ }
118
+ catch {
119
+ throw new Error(`chatgpt-browser-worker agent definition not found at resolved path: ${candidate}`);
136
120
  }
137
- throw new Error('chatgpt-browser-worker agent definition not found; set AGENTS_RELAY_CHATGPT_BROWSER_WORKER_AGENT');
138
121
  }
139
122
  function parseDriverJson(output) {
140
123
  const lines = output.trim().split(/\r?\n/).filter(Boolean);
@@ -260,7 +243,7 @@ export class ChatGptAdapter {
260
243
  execution.launched = new Promise((resolve, reject) => { launchResolve = resolve; launchReject = reject; });
261
244
  execution.launched.catch(() => { });
262
245
  execution.promise = (async () => {
263
- const runtime = await loadBrowserWorkerRuntime(this.agentPath);
246
+ const runtime = await loadBrowserWorkerRuntime(context.executionContext, this.agentPath);
264
247
  if (controller.signal.aborted)
265
248
  throw new Error('browser-worker execution aborted');
266
249
  const policy = chatGptTabClosePolicy(task.chatgptTabClosePolicy, this.tabClosePolicy);
package/dist/cli.js CHANGED
@@ -16,7 +16,9 @@ import { codexAndZaiUsageRegistry } from './usage.js';
16
16
  import { discoverAgents } from './registry.js';
17
17
  import { AgentObjectivePlanner, CommandObjectivePlanner } 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 };
@@ -390,6 +410,26 @@ async function runTaskCommand(action, args) {
390
410
  await saveLocal(localFile, job);
391
411
  console.log(JSON.stringify(task, null, 2));
392
412
  }
413
+ async function runContextCommand(action, args) {
414
+ if (action !== 'resolve')
415
+ throw new Error(usage);
416
+ const projectPath = arg(args, '--project-path');
417
+ if (!projectPath)
418
+ throw new Error('context resolve requires --project-path');
419
+ const roleValue = arg(args, '--role', 'orchestrator');
420
+ const roles = ['orchestrator', 'planner', 'reviewer', 'worker', 'nested-worker'];
421
+ if (!roles.includes(roleValue))
422
+ throw new Error('--role must be orchestrator, planner, reviewer, worker, or nested-worker');
423
+ const neoRootArg = arg(args, '--neo-root');
424
+ const context = await resolveExecutionContext(projectPath, roleValue, {
425
+ workspaceRoot: arg(args, '--workspace-root') || undefined,
426
+ neoRoot: neoRootArg === 'none' ? null : neoRootArg || undefined,
427
+ globalRoot: arg(args, '--global-root') || undefined,
428
+ explicitSkills: arg(args, '--skills').split(',').filter(Boolean),
429
+ explicitAgents: arg(args, '--agents').split(',').filter(Boolean),
430
+ });
431
+ console.log(JSON.stringify(context, null, 2));
432
+ }
393
433
  async function runJobCommand(action, args) {
394
434
  if (!['create', 'adopt', 'repair'].includes(action))
395
435
  throw new Error(usage);
@@ -441,7 +481,7 @@ async function runJobCommand(action, args) {
441
481
  }
442
482
  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
483
  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 }); }
484
+ 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
485
  export async function createService(args) {
446
486
  const loaded = await storeFor(args);
447
487
  const upstream = eventBus(args);
@@ -519,7 +559,7 @@ async function main() {
519
559
  return;
520
560
  }
521
561
  if (args.includes('--help') || args.includes('-h')) {
522
- console.log(helpText(command, command === 'job' || command === 'task' ? args[0] : undefined));
562
+ console.log(helpText(command, command === 'job' || command === 'task' || command === 'context' ? args[0] : undefined));
523
563
  return;
524
564
  }
525
565
  if (command === 'job') {
@@ -532,6 +572,11 @@ async function main() {
532
572
  await runTaskCommand(action ?? '', taskArgs);
533
573
  return;
534
574
  }
575
+ if (command === 'context') {
576
+ const [action, ...contextArgs] = args;
577
+ await runContextCommand(action ?? '', contextArgs);
578
+ return;
579
+ }
535
580
  if (command === 'init') {
536
581
  const now = new Date().toISOString();
537
582
  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: [] };
@@ -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
+ }
package/dist/planner.js CHANGED
@@ -3,6 +3,7 @@ import { readFile } from 'node:fs/promises';
3
3
  import { fileURLToPath } from 'node:url';
4
4
  import { dirname, join } from 'node:path';
5
5
  import { taskOverallTimeoutMs } from './types.js';
6
+ import { executionContextPrompt } from './execution-context.js';
6
7
  function record(value) {
7
8
  if (!value || typeof value !== 'object' || Array.isArray(value))
8
9
  throw new Error('Planner result must be an object');
@@ -111,7 +112,7 @@ export function parsePlannerOutput(output) {
111
112
  return parsePlannerResult(JSON.parse(candidate));
112
113
  }
113
114
  export function plannerInput(context) {
114
- return JSON.stringify({ objective: context.objective, previous_planner_task_id: context.previousPlannerTaskId, tasks: context.tasks.map(task => ({ id: task.id, kind: task.kind ?? 'work', parent_task_id: task.parentTaskId, state: task.state, input: task.input, result: task.result, planner_result: task.plannerResult, error: task.error })) });
115
+ return JSON.stringify({ objective: context.objective, previous_planner_task_id: context.previousPlannerTaskId, execution_context: context.executionContext, tasks: context.tasks.map(task => ({ id: task.id, kind: task.kind ?? 'work', parent_task_id: task.parentTaskId, state: task.state, input: task.input, result: task.result, planner_result: task.plannerResult, error: task.error })) });
115
116
  }
116
117
  export class CommandObjectivePlanner {
117
118
  command;
@@ -162,9 +163,11 @@ export class AgentObjectivePlanner {
162
163
  this.agentFile = agentFile;
163
164
  }
164
165
  async plan(context, signal) {
165
- const instructions = await readFile(this.agentFile, 'utf8');
166
+ const resolvedPlanner = context.executionContext?.agents.find(agent => agent.name === 'planner')?.path;
167
+ const instructions = await readFile(resolvedPlanner ?? this.agentFile, 'utf8');
166
168
  const contract = await readFile(DEFAULT_TASK_JOB_CONTRACT, 'utf8');
167
- const prompt = instructions.trim() + '\n\n## Shared Task / Job Contract\n\n' + contract.trim() + '\n\n## Durable planner input\n\n' + plannerInput(context);
169
+ const resolvedContext = context.executionContext ? executionContextPrompt(context.executionContext) + '\n\n' : '';
170
+ const prompt = instructions.trim() + '\n\n## Shared Task / Job Contract\n\n' + contract.trim() + '\n\n' + resolvedContext + '## Durable planner input\n\n' + plannerInput(context);
168
171
  return await new Promise((resolve, reject) => {
169
172
  const child = spawn(this.command, ['exec', '--json', '--', prompt], { stdio: ['ignore', 'pipe', 'pipe'] });
170
173
  let stdoutBuffer = '';
@@ -4,9 +4,10 @@ import { schedule, transitionTask } from './scheduler.js';
4
4
  import { LaunchFailure, StartAckTimeout } from './adapters.js';
5
5
  import { eventFor } from './events.js';
6
6
  import { parsePlannerResult, plannerInput } from './planner.js';
7
+ import { executionContextPrompt, resolveExecutionContext } from './execution-context.js';
7
8
  const PRIORITY_RANK = { P0: 0, P1: 1, P2: 2, P3: 3 };
8
9
  export function compareTaskPriority(a, b, jobPriority = 'P2') { const ar = PRIORITY_RANK[a.priority ?? jobPriority]; const br = PRIORITY_RANK[b.priority ?? jobPriority]; return ar - br || a.createdAt.localeCompare(b.createdAt) || a.id.localeCompare(b.id); }
9
- export function managedWorkerInput(job, task) {
10
+ export function managedWorkerInput(job, task, context) {
10
11
  if (!['codex', 'chatgpt'].includes(task.adapter) || !job.repository || job.prNumber <= 0)
11
12
  return task.input;
12
13
  if (!task.output)
@@ -20,9 +21,9 @@ PR: ${prUrl}
20
21
  Job: ${job.id}
21
22
  Task: ${task.id}
22
23
  Parent task: ${task.parentTaskId ?? 'none'}
23
- Project: ${task.projectName ?? 'unspecified'}
24
+ Project: ${task.projectName ?? context?.project.name ?? 'unspecified'}
24
25
 
25
- ${task.input}
26
+ ${context ? executionContextPrompt(context) + '\n\n' : ''}${task.input}${task.parentTaskId ? '\n\n[Nested delegation contract]\nAny sub-work must be created as a durable Agents Relay child task with parentTaskId=' + task.id + '; do not directly spawn arbitrary child agents.' : ''}
26
27
 
27
28
  [Output contract]
28
29
  ${output}
@@ -307,6 +308,14 @@ export class Reconciler {
307
308
  await this.emit(eventFor(job.id, task.id, task.parentTaskId, 'planner.created', 'queued', `Planner task ${task.id} created`, 'orchestrator'));
308
309
  return this.store.load(job.id);
309
310
  }
311
+ async resolvedContext(job, task, role) {
312
+ const projectPath = this.options.projectPathForJob ? await this.options.projectPathForJob(job) : task.routing?.cwd ?? process.cwd();
313
+ return resolveExecutionContext(projectPath, role, {
314
+ ...(this.options.executionContextRoots ?? {}),
315
+ registeredAgents: await this.store.listAgents(),
316
+ runtimes: this.options.adapters.map(adapter => ({ name: adapter.name, id: adapter.id, capabilities: [...adapter.capabilities] })),
317
+ });
318
+ }
310
319
  async launch(job, task) {
311
320
  const adapter = this.options.adapters.find(x => x.name === task.adapter);
312
321
  if (!adapter) {
@@ -322,8 +331,11 @@ export class Reconciler {
322
331
  return;
323
332
  }
324
333
  let workerInput;
334
+ let executionContext;
325
335
  try {
326
- workerInput = managedWorkerInput(job, task);
336
+ const role = task.agentName === 'reviewer' ? 'reviewer' : task.parentTaskId ? 'nested-worker' : 'worker';
337
+ executionContext = await this.resolvedContext(job, task, role);
338
+ workerInput = managedWorkerInput(job, task, executionContext);
327
339
  }
328
340
  catch (error) {
329
341
  transitionTask(task, 'BLOCKED');
@@ -342,7 +354,7 @@ export class Reconciler {
342
354
  await this.store.saveTask(task);
343
355
  const controller = new AbortController();
344
356
  let execution;
345
- const workerTask = { ...task, input: workerInput };
357
+ const workerTask = { ...task, input: workerInput, routing: task.routing ? { ...task.routing, cwd: task.routing.cwd ?? executionContext.project.path } : task.routing };
346
358
  const startAckSetup = task.adapter === 'chatgpt' ? await this.startAck(job.id, task.id, Date.now(), this.options.startAckTimeoutMs ?? chatGptStartAckTimeoutMs(task.timeoutMs)) : null;
347
359
  const closePolicy = task.chatgptTabClosePolicy ?? this.options.chatgptTabClosePolicy ?? adapter.tabClosePolicy ?? 'after-start';
348
360
  const terminalWait = task.adapter === 'chatgpt' && closePolicy === 'after-terminal'
@@ -351,6 +363,7 @@ export class Reconciler {
351
363
  const launchContext = {
352
364
  ...(startAckSetup ? { waitForWorkerStart: startAckSetup.ack } : {}),
353
365
  ...(terminalWait ? { waitForTerminal: terminalWait.promise } : {}),
366
+ executionContext,
354
367
  };
355
368
  try {
356
369
  execution = adapter.launch(workerTask, controller.signal, launchContext);
@@ -435,7 +448,8 @@ export class Reconciler {
435
448
  this.plannerLive.set(task.id, { executionId: executionId, controller, timer });
436
449
  let promise;
437
450
  try {
438
- promise = planner.plan({ job, tasks: job.tasks, objective: job.objective ?? job.description ?? job.title, previousPlannerTaskId: task.parentTaskId }, controller.signal);
451
+ const executionContext = await this.resolvedContext(job, task, 'planner');
452
+ promise = planner.plan({ job, tasks: job.tasks, objective: job.objective ?? job.description ?? job.title, previousPlannerTaskId: task.parentTaskId, executionContext }, controller.signal);
439
453
  }
440
454
  catch (error) {
441
455
  await this.finishPlanner(job.id, task.id, executionId, error instanceof Error ? error : new Error(String(error)));
package/dist/relayd.js CHANGED
@@ -13,7 +13,7 @@ import { dashboardManagedJobs, GitHubStore, InMemoryStore, loadManagedGitHubPull
13
13
  import { githubAuthContext } from './github-auth.js';
14
14
  import { isEntrypoint, runtimePlanner, runtimeReviewer, startService, SERVICE_DEFAULTS } from './cli.js';
15
15
  import { codexAndZaiUsageRegistry } from './usage.js';
16
- import { defaultWorkspaceRoot, discoverWorkspaceRepositories } from './workspace.js';
16
+ import { defaultWorkspaceRoot, discoverWorkspaceRepositories, resolveManagedProjectPath } from './workspace.js';
17
17
  export const daemonHelp = `Agents Relay daemon runs the dashboard and worker pool.
18
18
 
19
19
  Usage:
@@ -64,10 +64,12 @@ export async function runDaemon(argv) {
64
64
  const trusted = auth.trustedAuthors;
65
65
  const concurrency = Number(value(argv, '--concurrency', '4'));
66
66
  const bus = value(argv, '--events') === 'nats' ? new NatsEventBus(value(argv, '--nats-url', 'nats://127.0.0.1:4222'), value(argv, '--subject-prefix', 'neo.events.job')) : undefined;
67
- const makeReconciler = (store, maxConcurrent) => new Reconciler(store, { owner: `relayd-${process.pid}`, maxConcurrent, leaseMs: Number(value(argv, '--lease-ms', '300000')), adapters: [new CodexAdapter(value(argv, '--codex', 'codex')), new ChatGptAdapter()], continuations: [new CodexThreadContinuation(value(argv, '--codex', 'codex')), new CommandContinuation(), new WebhookContinuation()], planner: runtimePlanner(argv), eventBus: bus, reviewer: runtimeReviewer() });
68
- const repositories = normalized.repository
69
- ? [normalized.repository]
70
- : (await discoverWorkspaceRepositories(normalized.workspaceRoot ?? defaultWorkspaceRoot())).map(item => item.repository);
67
+ const workspaceRoot = normalized.workspaceRoot ?? defaultWorkspaceRoot();
68
+ const discoveredRepositories = normalized.repository ? [] : await discoverWorkspaceRepositories(workspaceRoot);
69
+ const repositories = normalized.repository ? [normalized.repository] : discoveredRepositories.map(item => item.repository);
70
+ const repositoryPaths = new Map(discoveredRepositories.map(item => [item.repository, item.path]));
71
+ const explicitProjectPath = value(argv, '--project-path');
72
+ const makeReconciler = (store, maxConcurrent) => new Reconciler(store, { owner: `relayd-${process.pid}`, maxConcurrent, leaseMs: Number(value(argv, '--lease-ms', '300000')), adapters: [new CodexAdapter(value(argv, '--codex', 'codex')), new ChatGptAdapter()], continuations: [new CodexThreadContinuation(value(argv, '--codex', 'codex')), new CommandContinuation(), new WebhookContinuation()], planner: runtimePlanner(argv), eventBus: bus, reviewer: runtimeReviewer(), projectPathForJob: job => repositoryPaths.get(job.repository) ?? resolveManagedProjectPath(job.repository, explicitProjectPath, workspaceRoot) });
71
73
  if (repositories.length === 0)
72
74
  throw new Error(`No Git repositories with GitHub origins found under ${normalized.workspaceRoot ?? defaultWorkspaceRoot()}`);
73
75
  const pool = new RepositoryWorkerPool(client, repositories, trusted, concurrency, makeReconciler);
package/dist/workspace.js CHANGED
@@ -64,6 +64,17 @@ export async function discoverWorkspaceRepositories(root) {
64
64
  }
65
65
  return [...unique.values()].sort((a, b) => a.repository.localeCompare(b.repository));
66
66
  }
67
+ export async function resolveWorkspaceRepositoryPath(repository, root = defaultWorkspaceRoot()) {
68
+ const match = (await discoverWorkspaceRepositories(root)).find(item => item.repository === repository);
69
+ if (!match)
70
+ throw new Error(`Repository ${repository} not found under workspace ${resolve(root)}; pass --project-path explicitly`);
71
+ return match.path;
72
+ }
73
+ export async function resolveManagedProjectPath(repository, explicitProjectPath = '', root = defaultWorkspaceRoot(), cwd = process.cwd()) {
74
+ if (explicitProjectPath)
75
+ return resolve(explicitProjectPath);
76
+ return repository ? resolveWorkspaceRepositoryPath(repository, root) : resolve(cwd);
77
+ }
67
78
  function repositoryPathRank(path, root) {
68
79
  const relativePath = relative(root, path);
69
80
  if (!relativePath.includes('/'))
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agents-relay",
3
- "version": "1.0.30",
3
+ "version": "1.0.31",
4
4
  "description": "Durable async agent jobs coordinated through GitHub pull requests",
5
5
  "type": "module",
6
6
  "bin": {
@@ -82,9 +82,15 @@ For Codex and ChatGPT workers, Agents Relay adds only durable execution identity
82
82
 
83
83
  The job/task descriptions remain the durable source of intent. Adapters do not invent task meaning.
84
84
 
85
+ ## Shared execution context
86
+
87
+ Before planner, reviewer, normal worker, or nested-worker execution, resolve one execution context from the bound project path. The shared resolver owns project instructions, skills, visible agents, and runtime capabilities with project-local > Neo shared > global `~/.agents` precedence. Concrete paths are execution-time data and must not be persisted into durable task records.
88
+
89
+ Leaf workers consume the resolved instruction/skill/agent paths, not the full top-level XChat orchestrator contract. Nested workers may inspect visible agents, but any delegation must be represented as an Agents Relay durable child task preserving `parentTaskId`, output contract, routing, leases/retries, events, and review gates.
90
+
85
91
  ## ChatGPT worker
86
92
 
87
- The ChatGPT adapter uses the packaged `chatgpt-browser-worker` one-shot path. It opens a fresh Temporary Chat tab, submits the complete task with the account defaults, verifies acceptance, applies the configured `after-start` (default), `never`, or `after-terminal` owned-tab close policy, and returns a submission receipt.
93
+ The ChatGPT adapter uses the `chatgpt-browser-worker` path from the shared execution context; Relay does not bundle or independently rediscover that skill. It opens a fresh Temporary Chat tab, submits the complete task with the account defaults, verifies acceptance, applies the configured `after-start` (default), `never`, or `after-terminal` owned-tab close policy, and returns a submission receipt.
88
94
 
89
95
  ChatGPT thread identity is diagnostic only. It is not persisted as a resumable task handle, and Relay does not poll, resume, retrieve a result from, or delete a ChatGPT conversation as part of task lifecycle.
90
96
 
@@ -1,67 +0,0 @@
1
- ---
2
- name: chatgpt-browser-worker
3
- description: Submit an isolated one-shot ChatGPT browser worker task through browser-harness with a required durable output destination.
4
- ---
5
-
6
- # ChatGPT browser worker
7
-
8
- Use this skill to hand one self-contained task to ChatGPT through the authenticated
9
- browser session. The caller does not manage ChatGPT threads, model selection,
10
- thinking level, polling, or result retrieval.
11
-
12
- ## Output contract
13
-
14
- Every task handed to this worker MUST declare exactly one durable output mode
15
- before the worker is launched.
16
-
17
- ### 1. Task / PR output
18
-
19
- Use this for repository or managed-task work.
20
-
21
- The task prompt must identify the exact managed task/PR that owns the result.
22
- The worker performs the requested work directly against that task/PR and records
23
- its final outcome there.
24
-
25
- Output:
26
- task/PR
27
- <exact managed task/PR identity and required final action>
28
-
29
- ### 2. File output
30
-
31
- Use this for research, analysis, reports, or other artifact-producing work.
32
-
33
- The task prompt must name the exact file path. That file is the authoritative
34
- result.
35
-
36
- Output:
37
- file
38
- <exact file path>
39
-
40
- A Browser ChatGPT worker task without one of these output declarations is
41
- invalid and must not be launched. The orchestrator chooses and writes the output
42
- contract when it creates the worker task; the worker must not invent or change
43
- the destination.
44
-
45
- Progress and terminal success/failure are always reported through the normal
46
- Neo event contract. Events are execution observability, not a third output mode.
47
-
48
- ## Runtime behavior
49
-
50
- The worker is one-shot. It opens an isolated worker-owned ChatGPT tab, submits
51
- the complete task, verifies that ChatGPT accepted the submission, and applies
52
- the configured tab-close policy. `after-start` waits for the exact
53
- post-submission worker `task.started` acknowledgement, `never` leaves the owned
54
- tab open for debugging, and `after-terminal` waits for a new exact-task
55
- `task.completed`, `task.failed`, `task.blocked`, or `task.cancelled` event.
56
- ChatGPT continues the task independently and publishes the result through the
57
- declared output contract. If the start acknowledgement does not arrive within
58
- 60 seconds, close only the owned tab and fail with `worker_start_timeout`.
59
-
60
- The worker must never interact through a pre-existing user ChatGPT tab.
61
- Browser details, transient conversation identity, submission verification,
62
- model/default-thinking behavior, and tab cleanup are implementation concerns of
63
- the worker and are not part of the caller-facing contract.
64
-
65
- The runtime entry point is the browser-worker agent. Callers launch that agent
66
- with the complete task prompt and declared output; they do not call helper
67
- scripts directly.
@@ -1,47 +0,0 @@
1
- ---
2
- name: browser-worker
3
- description: Submit one isolated ChatGPT browser task and return after the task is accepted.
4
- type: worker
5
- ---
6
-
7
- # Browser Worker Agent
8
-
9
- You are the runtime owner for the `chatgpt-browser-worker` skill. Load and follow
10
- that skill and the `browser-harness` skill before execution.
11
-
12
- The outer orchestrator gives you one complete task. That task MUST already
13
- contain exactly one output declaration defined by the skill:
14
-
15
- - `task/PR` with the exact managed task/PR identity and required final action; or
16
- - `file` with the exact authoritative output path.
17
-
18
- If the output declaration is missing or ambiguous, do not invent one.
19
-
20
- ## Runtime rules
21
-
22
- - Use `browser-harness` for every ChatGPT browser interaction.
23
- - Execute only a one-shot submission. Do not expose or operate a
24
- create/resume/status/result/continue lifecycle for callers.
25
- - Always create a fresh worker-owned tab. Never type, upload, click New chat,
26
- select a Project, or submit through a pre-existing user ChatGPT tab.
27
- - Use Temporary Chat and the account's existing default model/thinking
28
- settings. Do not change model or thinking settings.
29
- - Upload requested files, submit the complete task prompt, and verify that the
30
- submission became a new user turn.
31
- - Once submission is verified, follow the configured owned-tab close policy:
32
- `after-start` closes after a new worker-owned `task.started` event, `never`
33
- leaves the owned tab open for test/debug use, and `after-terminal` closes only
34
- after a new exact-task `task.completed`, `task.failed`, `task.blocked`, or
35
- `task.cancelled` event. Never close a user tab. Do not wait for the assistant
36
- response or reopen/poll the conversation. If the start acknowledgement does
37
- not arrive within 60 seconds, close only the owned tab and report a timeout.
38
- - Treat any observed conversation/thread identity only as diagnostic evidence,
39
- never as a resumable handle.
40
- - Progress and terminal success/failure for the actual delegated task are
41
- reported through the normal Neo event contract by the executing task. The
42
- final durable result goes to the declared task/PR or file output.
43
- - Stop on authentication, MFA, consent, or ambiguous browser state rather than
44
- bypassing it.
45
-
46
- Helper scripts under `scripts/` are implementation details. Callers must not
47
- invoke them directly.
@@ -1,30 +0,0 @@
1
- """Bounded polling helpers for ChatGPT submit readiness."""
2
- from __future__ import annotations
3
-
4
- import time
5
- from collections.abc import Callable, Mapping
6
-
7
-
8
- def wait_until_stable(
9
- read_state: Callable[[], Mapping[str, object]],
10
- ready: Callable[[Mapping[str, object]], bool],
11
- *,
12
- timeout: float,
13
- interval: float = 0.25,
14
- stable_samples: int = 2,
15
- phase: str,
16
- ) -> Mapping[str, object]:
17
- """Return only after the observable state is ready for stable_samples polls."""
18
- deadline = time.monotonic() + timeout
19
- stable = 0
20
- last_state: Mapping[str, object] = {}
21
- while time.monotonic() < deadline:
22
- last_state = read_state()
23
- if ready(last_state):
24
- stable += 1
25
- if stable >= stable_samples:
26
- return last_state
27
- else:
28
- stable = 0
29
- time.sleep(interval)
30
- raise RuntimeError(f"Temporary Chat {phase} was not observed ready")
@@ -1,298 +0,0 @@
1
- import atexit
2
- import json
3
- import os
4
- import re
5
- import time
6
-
7
- from browser_harness import *
8
-
9
-
10
- CFG = json.load(open("__CFG_PATH__", encoding="utf-8"))
11
- _OWNED_TABS = []
12
- _KEEP_OWNED_TAB_OPEN = CFG.get("close_policy", "after-start") == "never"
13
-
14
-
15
- def _new_owned_tab(url):
16
- target_id = cdp("Target.createTarget", url="about:blank", background=True)["targetId"]
17
- switch_tab(target_id)
18
- _OWNED_TABS.append(target_id)
19
- if url != "about:blank":
20
- goto_url(url)
21
- return target_id
22
-
23
-
24
- def _close_owned_tabs():
25
- if _KEEP_OWNED_TAB_OPEN:
26
- return
27
- while _OWNED_TABS:
28
- try:
29
- close_tab(_OWNED_TABS.pop())
30
- except Exception:
31
- pass
32
-
33
-
34
- atexit.register(_close_owned_tabs)
35
-
36
-
37
- def _composer():
38
- selector = js("""(() => {
39
- const preferred = document.querySelector('#prompt-textarea');
40
- if (preferred) {
41
- const r=preferred.getBoundingClientRect();
42
- if (r.width>0 && r.height>0) return '#prompt-textarea';
43
- }
44
- const fallback = [...document.querySelectorAll('textarea,[contenteditable="true"]')]
45
- .find(e => { const r=e.getBoundingClientRect(); return r.width>0 && r.height>0 && !e.disabled; });
46
- if (!fallback) return null;
47
- return fallback.tagName === 'TEXTAREA' ? 'textarea' : '[contenteditable="true"]';
48
- })()""")
49
- if not selector:
50
- raise RuntimeError("Temporary Chat composer was not observed")
51
- return selector
52
-
53
-
54
- def _composer_text(selector):
55
- return js(f"""(() => {{
56
- const e=document.querySelector({json.dumps(selector)});
57
- return e ? ((e.innerText ?? e.value) || '') : '';
58
- }})()""") or ""
59
-
60
-
61
- def _wait_for_composer(timeout=30):
62
- def read_selector():
63
- try:
64
- return _composer()
65
- except RuntimeError:
66
- return None
67
-
68
- return wait_until_stable(
69
- lambda: {"selector": read_selector()},
70
- lambda state: bool(state["selector"]),
71
- timeout=timeout,
72
- phase="composer readiness",
73
- )["selector"]
74
-
75
-
76
- def _same_text(observed, expected):
77
- normalize = lambda value: re.sub(r"\\s+", " ", value or "").strip()
78
- return normalize(expected) in normalize(observed)
79
-
80
-
81
- def _normalized_text(value):
82
- return re.sub(r"\s+", " ", value or "").strip()
83
-
84
-
85
- def _attachment_names():
86
- return js(r"""(() => [...document.querySelectorAll('button[aria-label^="Remove file"]')]
87
- .map(b => (b.getAttribute('aria-label') || '').replace(/^Remove file\s+\d+:\s*/, ''))
88
- .filter(Boolean))()""") or []
89
-
90
-
91
- def _attachment_state():
92
- return js(r"""(() => {
93
- const names = [...document.querySelectorAll('button[aria-label^="Remove file"]')]
94
- .map(b => (b.getAttribute('aria-label') || '').replace(/^Remove file\s+\d+:\s*/, ''))
95
- .filter(Boolean);
96
- const pending = [...document.querySelectorAll(
97
- '[role="progressbar"], [aria-busy="true"], [data-state="loading"]'
98
- )].some(e => {
99
- const r=e.getBoundingClientRect();
100
- return r.width > 0 && r.height > 0;
101
- });
102
- return {names, pending};
103
- })()""") or {"names": [], "pending": True}
104
-
105
-
106
- def _send_state():
107
- return js(r"""(() => {
108
- const b=document.querySelector('button[data-testid="send-button"],button[aria-label="Send prompt"]');
109
- return {present: !!b, enabled: !!b && !b.disabled && b.getAttribute('aria-disabled') !== 'true'};
110
- })()""") or {"present": False, "enabled": False}
111
-
112
-
113
- def _wait_for_attachments(expected, timeout=45):
114
- expected = set(expected)
115
- wait_until_stable(
116
- _attachment_state,
117
- lambda state: expected.issubset(state["names"]) and not state["pending"],
118
- timeout=timeout,
119
- phase="attachment upload readiness",
120
- )
121
-
122
-
123
- def _upload_files(paths):
124
- if not paths:
125
- return []
126
- selector = js("""(() => {
127
- for (const s of ['#upload-files','#upload-media','input[name="upload-media"]','input[type="file"]'])
128
- if (document.querySelector(s)) return s;
129
- return null;
130
- })()""")
131
- if not selector:
132
- raise RuntimeError("ChatGPT file input was not observed")
133
- for path in paths:
134
- if not os.path.isfile(path):
135
- raise RuntimeError(f"attachment does not exist: {path}")
136
- upload_file(selector, path)
137
- expected = [os.path.basename(path) for path in paths]
138
- if expected:
139
- # Presence is a selection signal; upload readiness is checked separately.
140
- wait_until_stable(
141
- _attachment_state,
142
- lambda state: set(expected).issubset(state["names"]),
143
- timeout=30,
144
- phase="attachment presence",
145
- )
146
- _wait_for_attachments(expected)
147
- return expected
148
-
149
-
150
- def _user_turns():
151
- return js("""(() => [...document.querySelectorAll('[data-message-author-role="user"]')]
152
- .map(e => ({text:e.innerText.trim(), id:e.getAttribute('data-message-id') || ''}))
153
- .filter(e => e.text))()""") or []
154
-
155
-
156
- def _click_send():
157
- ok = js("""(() => {
158
- const b=document.querySelector('button[data-testid="send-button"],button[aria-label="Send prompt"]');
159
- if (!b || b.disabled || b.getAttribute('aria-disabled') === 'true') return false;
160
- b.click(); return true;
161
- })()""")
162
- if not ok:
163
- raise RuntimeError("Temporary Chat send readiness changed before click")
164
-
165
-
166
- def _wait_for_send_ready(selector, prompt, attachments):
167
- expected = set(attachments)
168
-
169
- def read_state():
170
- return {
171
- "text": _composer_text(selector),
172
- "attachments": _attachment_state(),
173
- "send": _send_state(),
174
- }
175
-
176
- wait_until_stable(
177
- read_state,
178
- lambda state: (
179
- _normalized_text(prompt) in _normalized_text(state["text"])
180
- and expected.issubset(state["attachments"]["names"])
181
- and not state["attachments"]["pending"]
182
- and state["send"]["enabled"]
183
- ),
184
- timeout=45 if expected else 15,
185
- phase="send readiness",
186
- )
187
-
188
-
189
- def _wait_user_turn(before_count, prompt, timeout=20):
190
- deadline = time.time() + timeout
191
- while time.time() < deadline:
192
- turns = _user_turns()
193
- for turn in turns[before_count:]:
194
- if _normalized_text(prompt) in _normalized_text(turn["text"]):
195
- return turn
196
- time.sleep(.25)
197
- raise RuntimeError("Temporary Chat submission verification did not observe a new user turn")
198
-
199
-
200
- def _diagnostic_thread_id():
201
- match = re.search(r"/c/([^/?#]+)", page_info().get("url", ""))
202
- return match.group(1) if match else None
203
-
204
-
205
- _new_owned_tab("https://chatgpt.com/")
206
- wait_for_load()
207
-
208
- enabled = js("""(() => {
209
- const matches=[...document.querySelectorAll('button')].filter(
210
- b => (b.getAttribute('aria-label') || '').trim() === 'Temporary chat'
211
- );
212
- if (matches.length !== 1) return false;
213
- matches[0].click();
214
- return true;
215
- })()""")
216
- if not enabled:
217
- raise RuntimeError("Temporary Chat toggle was not uniquely observed")
218
-
219
- deadline = time.time() + 20
220
- while time.time() < deadline:
221
- if "temporary-chat=true" in page_info().get("url", ""):
222
- try:
223
- break
224
- except RuntimeError:
225
- pass
226
- time.sleep(.25)
227
- else:
228
- raise RuntimeError("Temporary Chat composer readiness was not observed")
229
-
230
- attachments = _upload_files(CFG.get("file", []))
231
- selector = _wait_for_composer()
232
- before_count = len(_user_turns())
233
- prompt = CFG["prompt"]
234
- is_contenteditable = bool(js(f"""(() => {{
235
- const e=document.querySelector({json.dumps(selector)});
236
- return !!e && e.getAttribute('contenteditable') === 'true';
237
- }})()"""))
238
- if is_contenteditable:
239
- cleared = js(f"""(() => {{
240
- const e=document.querySelector({json.dumps(selector)});
241
- if (!e) return false;
242
- e.focus();
243
- const sel=window.getSelection();
244
- const range=document.createRange();
245
- range.selectNodeContents(e);
246
- sel.removeAllRanges();
247
- sel.addRange(range);
248
- document.execCommand('delete', false, null);
249
- return true;
250
- }})()""")
251
- if not cleared:
252
- raise RuntimeError("Temporary Chat contenteditable composer could not be cleared")
253
- for offset in range(0, len(prompt), 256):
254
- chunk = prompt[offset:offset + 256]
255
- inserted = js(f"""(() => {{
256
- const e=document.querySelector({json.dumps(selector)});
257
- if (!e) return false;
258
- e.focus();
259
- const ok=document.execCommand('insertText', false, {json.dumps(chunk)});
260
- e.dispatchEvent(new InputEvent('input', {{bubbles:true,inputType:'insertText',data:{json.dumps(chunk)}}}));
261
- return ok;
262
- }})()""")
263
- if not inserted:
264
- raise RuntimeError(f"Temporary Chat composer rejected prompt chunk at offset {offset}")
265
- else:
266
- fill_input(selector, prompt, clear_first=True)
267
-
268
- wait_until_stable(
269
- lambda: {"text": _composer_text(selector)},
270
- lambda state: _normalized_text(CFG["prompt"]) in _normalized_text(state["text"]),
271
- timeout=30,
272
- phase="composer readiness",
273
- )
274
-
275
- _wait_for_send_ready(selector, CFG["prompt"], attachments)
276
- _click_send()
277
- user_turn = _wait_user_turn(before_count, CFG["prompt"])
278
-
279
- print(json.dumps({
280
- "operation": "submit",
281
- "status": "submitted",
282
- "temporary": True,
283
- "attachments": attachments,
284
- "diagnostic_thread_id": _diagnostic_thread_id(),
285
- "user_message_id": user_turn.get("id") or None,
286
- "verified": True,
287
- }, ensure_ascii=False), flush=True)
288
-
289
- # Keep the owned Temporary Chat tab alive until Relay observes the worker's
290
- # post-launch task.started acknowledgement. The parent releases this file;
291
- # atexit then closes only this worker-owned tab.
292
- release_file = CFG.get("release_file")
293
- if not release_file:
294
- raise RuntimeError("worker release file was not configured")
295
- if CFG.get("close_policy", "after-start") == "never":
296
- raise SystemExit(0)
297
- while not os.path.exists(release_file):
298
- time.sleep(.1)
@@ -1,36 +0,0 @@
1
- #!/usr/bin/env python3
2
- """Submit one isolated Temporary Chat task through browser-harness."""
3
- from __future__ import annotations
4
-
5
- import argparse
6
- import json
7
- import os
8
- import subprocess
9
- import tempfile
10
-
11
-
12
- BH_SCRIPT = os.path.join(os.path.dirname(os.path.abspath(__file__)), "_temporary_bh.py")
13
- READINESS_HELPER = os.path.join(os.path.dirname(os.path.abspath(__file__)), "_submit_readiness.py")
14
-
15
-
16
- def main() -> int:
17
- parser = argparse.ArgumentParser()
18
- parser.add_argument("--prompt", required=True)
19
- parser.add_argument("--file", action="append", default=[])
20
- parser.add_argument("--release-file", required=True)
21
- parser.add_argument("--close-policy", choices=["after-start", "never", "after-terminal"], default="after-start")
22
- args = parser.parse_args()
23
- with tempfile.NamedTemporaryFile(mode="w", suffix=".json", delete=False) as handle:
24
- json.dump(vars(args), handle, ensure_ascii=False)
25
- config_path = handle.name
26
- try:
27
- helper = open(READINESS_HELPER, encoding="utf-8").read()
28
- code = helper + "\n" + open(BH_SCRIPT, encoding="utf-8").read()
29
- code = code.replace("__CFG_PATH__", config_path)
30
- return subprocess.run(["browser-harness"], input=code, text=True, timeout=180).returncode
31
- finally:
32
- os.unlink(config_path)
33
-
34
-
35
- if __name__ == "__main__":
36
- raise SystemExit(main())