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 +9 -1
- package/dist/adapters.js +12 -29
- package/dist/cli.js +48 -3
- package/dist/execution-context.js +258 -0
- package/dist/planner.js +6 -3
- package/dist/reconciler.js +20 -6
- package/dist/relayd.js +7 -5
- package/dist/workspace.js +11 -0
- package/package.json +1 -1
- package/skills/agents-relay/SKILL.md +7 -1
- package/skills/chatgpt-browser-worker/SKILL.md +0 -67
- package/skills/chatgpt-browser-worker/agents/browser-worker.agent.md +0 -47
- package/skills/chatgpt-browser-worker/scripts/_submit_readiness.py +0 -30
- package/skills/chatgpt-browser-worker/scripts/_temporary_bh.py +0 -298
- package/skills/chatgpt-browser-worker/scripts/temporary_bh.py +0 -36
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
|
|
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
|
|
112
|
-
const
|
|
113
|
-
const
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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 = '';
|
package/dist/reconciler.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
|
68
|
-
const
|
|
69
|
-
|
|
70
|
-
|
|
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
|
@@ -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
|
|
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())
|