@jc_stack/ez-agents 0.1.0-beta.12 → 0.1.0-beta.18

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.
Files changed (132) hide show
  1. package/.dockerignore +4 -0
  2. package/.env.example +16 -1
  3. package/AGENTS.md +16 -4
  4. package/CHANGELOG.md +71 -0
  5. package/CONTRIBUTING.md +37 -4
  6. package/README.md +114 -9
  7. package/SECURITY.md +7 -1
  8. package/bin/ezenciel-agents-schedule +2 -0
  9. package/bin/ezenciel-agents-schedule.mjs +16 -0
  10. package/bin/ezenciel-agents-task +2 -0
  11. package/bin/ezenciel-agents-task.mjs +16 -0
  12. package/compose.yaml +10 -2
  13. package/docker/recovery.ts +2 -2
  14. package/docker/run.ts +2 -2
  15. package/docs/architecture/ai-selection.md +8 -0
  16. package/docs/architecture/authority-boundaries.md +137 -12
  17. package/docs/architecture/event-sources.md +12 -7
  18. package/docs/architecture/telegram-intake.md +1 -1
  19. package/docs/channel-backend.md +36 -0
  20. package/docs/docker-runtime.md +35 -0
  21. package/docs/host-service.md +19 -0
  22. package/docs/local-qa.md +45 -0
  23. package/docs/pagerduty.md +42 -0
  24. package/docs/plugin-catalog.md +71 -0
  25. package/docs/plugin-contributions.md +12 -0
  26. package/docs/plugins.md +61 -1
  27. package/docs/releasing.md +20 -9
  28. package/docs/repair.md +41 -0
  29. package/docs/scheduling.md +153 -0
  30. package/docs/selective-monitoring.md +114 -0
  31. package/docs/setup.md +46 -0
  32. package/docs/standalone-cli.md +62 -0
  33. package/docs/trusted-publishing.md +140 -0
  34. package/docs/upgrades.md +24 -4
  35. package/package.json +12 -4
  36. package/scripts/generate-publish-caller.mjs +60 -0
  37. package/scripts/smoke-busy-reply.ts +58 -0
  38. package/scripts/smoke-scheduler.ts +90 -0
  39. package/scripts/stage-qa.mjs +42 -0
  40. package/scripts/trusted-beta.mjs +289 -0
  41. package/src/agent-guidance.ts +5 -0
  42. package/src/ai-cli.ts +2 -1
  43. package/src/ai.ts +15 -5
  44. package/src/channel-backend.ts +46 -0
  45. package/src/client-defaults.ts +29 -13
  46. package/src/codex-session.ts +98 -0
  47. package/src/config.ts +35 -2
  48. package/src/control-state.ts +24 -7
  49. package/src/desktop-bridge.ts +37 -12
  50. package/src/event-sources.ts +2 -1
  51. package/src/execution-authority.ts +25 -0
  52. package/src/executor.ts +97 -21
  53. package/src/failure.ts +32 -0
  54. package/src/host-executor.ts +48 -19
  55. package/src/identity.ts +8 -3
  56. package/src/inbox.ts +11 -3
  57. package/src/index.ts +315 -91
  58. package/src/install-tools.mjs +2 -2
  59. package/src/menu.ts +6 -4
  60. package/src/model-policy.ts +15 -0
  61. package/src/owner.ts +3 -3
  62. package/src/pagerduty.ts +109 -0
  63. package/src/plugins/exposure.mjs +13 -0
  64. package/src/plugins/manager.mjs +74 -20
  65. package/src/plugins/shared.mjs +76 -0
  66. package/src/process-tree.ts +33 -0
  67. package/src/repair-policy.ts +13 -0
  68. package/src/reply-context.ts +67 -0
  69. package/src/reply-executor.ts +54 -0
  70. package/src/reply-mcp.ts +23 -0
  71. package/src/runs.ts +63 -19
  72. package/src/schedule-cli.ts +98 -0
  73. package/src/schedule-time.ts +85 -0
  74. package/src/scheduler.ts +130 -0
  75. package/src/setup.ts +2 -1
  76. package/src/software-status.ts +5 -5
  77. package/src/source-cli.ts +1 -1
  78. package/src/task-cli.ts +16 -0
  79. package/src/task-executor.ts +65 -0
  80. package/src/task-mcp.ts +36 -0
  81. package/src/task-rpc.ts +45 -0
  82. package/src/task-workspace.ts +22 -0
  83. package/src/tasks.ts +210 -0
  84. package/src/telegram-source.ts +94 -0
  85. package/src/updates/artifact.mjs +16 -0
  86. package/src/updates/binding.mjs +4 -1
  87. package/src/updates/control.mjs +4 -4
  88. package/src/updates/runtime.mjs +3 -1
  89. package/src/updates/status.mjs +7 -1
  90. package/templates/agent/AGENTS.md +10 -2
  91. package/templates/agent/TOOLS.md +60 -1
  92. package/templates/agent-guidance.md +13 -0
  93. package/templates/failure-review.md +9 -0
  94. package/templates/maintainer-purpose.md +15 -0
  95. package/templates/standalone-tools.md +20 -0
  96. package/templates/updates.md +2 -2
  97. package/test/agent-guidance.test.ts +110 -0
  98. package/test/ai-cli.test.ts +7 -6
  99. package/test/ai.test.ts +41 -0
  100. package/test/busy-reply-relay.test.ts +41 -0
  101. package/test/channel-backend.test.ts +100 -0
  102. package/test/client-defaults.test.ts +37 -5
  103. package/test/codex-context.test.ts +39 -1
  104. package/test/codex-session.test.ts +51 -0
  105. package/test/config.test.ts +31 -2
  106. package/test/desktop-bridge.test.ts +19 -0
  107. package/test/event-sources.test.ts +47 -11
  108. package/test/execution-authority.test.ts +42 -0
  109. package/test/executor.test.ts +53 -2
  110. package/test/failure.test.ts +250 -0
  111. package/test/group-owner.test.ts +36 -0
  112. package/test/helpers/owner-run.ts +13 -0
  113. package/test/host-executor.test.ts +47 -10
  114. package/test/intake-relay.test.ts +141 -4
  115. package/test/local-qa.test.mjs +38 -0
  116. package/test/model-policy.test.ts +61 -0
  117. package/test/pagerduty.test.ts +104 -0
  118. package/test/plugin-manager.test.mjs +73 -3
  119. package/test/relay.test.ts +2 -2
  120. package/test/repair-policy.test.ts +23 -0
  121. package/test/reply.test.ts +131 -0
  122. package/test/schedule-cli.test.ts +55 -0
  123. package/test/scheduler-host.test.ts +55 -0
  124. package/test/scheduler-relay.test.ts +67 -0
  125. package/test/scheduler.test.ts +104 -0
  126. package/test/shared-services.test.mjs +98 -0
  127. package/test/software-status.test.ts +5 -5
  128. package/test/task-native.test.ts +87 -0
  129. package/test/tasks.test.ts +187 -0
  130. package/test/telegram-source.test.ts +75 -0
  131. package/test/trusted-beta.test.mjs +224 -0
  132. package/test/updates.test.mjs +35 -3
@@ -0,0 +1,289 @@
1
+ #!/usr/bin/env node
2
+ import { createHash } from 'node:crypto';
3
+ import { execFileSync } from 'node:child_process';
4
+ import { mkdir, readFile, writeFile, mkdtemp, rm, rename } from 'node:fs/promises';
5
+ import { tmpdir } from 'node:os';
6
+ import { resolve, join } from 'node:path';
7
+ import { fileURLToPath } from 'node:url';
8
+ import { isDeepStrictEqual } from 'node:util';
9
+
10
+ const REGISTRY = 'https://registry.npmjs.org/';
11
+ const CORE = 'jdorado/ez-agents';
12
+ const MAX_ARTIFACT = 100 * 1024 * 1024;
13
+ const assert = (condition, message) => { if (!condition) throw new Error(message); };
14
+ export const sha256 = bytes => createHash('sha256').update(bytes).digest('hex');
15
+
16
+ export function publishEnvironment(env) {
17
+ const allowed = ['PATH', 'HOME', 'TMPDIR', 'TEMP', 'TMP', 'LANG', 'LC_ALL', 'CI',
18
+ 'GITHUB_ACTIONS', 'GITHUB_WORKFLOW', 'GITHUB_WORKFLOW_REF', 'GITHUB_WORKFLOW_SHA',
19
+ 'GITHUB_REPOSITORY', 'GITHUB_REPOSITORY_ID', 'GITHUB_REPOSITORY_OWNER', 'GITHUB_REPOSITORY_OWNER_ID',
20
+ 'GITHUB_SERVER_URL', 'GITHUB_REF', 'GITHUB_REF_NAME', 'GITHUB_REF_TYPE', 'GITHUB_SHA',
21
+ 'GITHUB_RUN_ID', 'GITHUB_RUN_NUMBER', 'GITHUB_RUN_ATTEMPT', 'GITHUB_EVENT_NAME', 'GITHUB_JOB',
22
+ 'GITHUB_ACTOR', 'GITHUB_ACTOR_ID', 'RUNNER_ENVIRONMENT', 'RUNNER_OS', 'RUNNER_ARCH',
23
+ 'ACTIONS_ID_TOKEN_REQUEST_URL', 'ACTIONS_ID_TOKEN_REQUEST_TOKEN'];
24
+ return Object.fromEntries(allowed.filter(key => env[key] !== undefined).map(key => [key, env[key]]));
25
+ }
26
+
27
+ export function publishArguments(path, npmrc, globalNpmrc) {
28
+ return ['publish', path, '--fetch-retries=0', '--ignore-scripts', '--provenance', '--access', 'public', '--tag', 'latest', '--registry', REGISTRY, '--userconfig', npmrc, '--globalconfig', globalNpmrc];
29
+ }
30
+
31
+ export function identity(env) {
32
+ const value = { repository: env.RELEASE_REPOSITORY, package: env.RELEASE_PACKAGE,
33
+ version: env.RELEASE_VERSION, sourceSha: env.RELEASE_SOURCE_SHA,
34
+ sha256: env.RELEASE_SHA256, releaseId: Number(env.RELEASE_ID), requiredChecks: JSON.parse(env.RELEASE_REQUIRED_CHECKS || 'null') };
35
+ assert(/^[1-9]\d*$/.test(env.RELEASE_ID || '') && Number.isSafeInteger(value.releaseId), 'Invalid draft release ID');
36
+ assert(/^jdorado\/[A-Za-z0-9_.-]+$/.test(value.repository || ''), 'Invalid repository');
37
+ assert(/^@jc_stack\/[a-z0-9-]+$/.test(value.package || ''), 'Invalid package');
38
+ assert(/^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)-beta\.(0|[1-9]\d*)$/.test(value.version || ''), 'Only immutable beta.N versions are allowed');
39
+ assert(/^[a-f0-9]{40}$/.test(value.sourceSha || ''), 'Invalid source SHA');
40
+ assert(/^[a-f0-9]{64}$/.test(value.sha256 || ''), 'Invalid artifact SHA256');
41
+ assert(Array.isArray(value.requiredChecks) && value.requiredChecks.length > 0 && value.requiredChecks.every(x => typeof x === 'string' && x.length > 0) && new Set(value.requiredChecks).size === value.requiredChecks.length, 'Required checks must be a nonempty unique list');
42
+ assert(env.GITHUB_REPOSITORY === value.repository, 'Caller repository mismatch');
43
+ assert(env.GITHUB_EVENT_NAME === 'workflow_dispatch' && env.GITHUB_REF === 'refs/heads/main', 'Must manually dispatch from main');
44
+ assert(env.GITHUB_SHA === value.sourceSha, 'Source must equal dispatched source');
45
+ return value;
46
+ }
47
+
48
+ export function validateManifest(manifest, expected) {
49
+ assert(manifest.name === expected.package && manifest.version === expected.version, 'Package identity mismatch');
50
+ assert(manifest.private !== true, 'Private packages cannot be published');
51
+ const repo = typeof manifest.repository === 'string' ? manifest.repository : manifest.repository?.url;
52
+ assert(repo === `git+https://github.com/${expected.repository}.git` || repo === `https://github.com/${expected.repository}.git` || repo === `https://github.com/${expected.repository}`, 'Package repository mismatch');
53
+ const config = manifest.publishConfig || {};
54
+ assert(Object.keys(config).every(key => ['access', 'tag', 'registry', 'provenance'].includes(key)), 'Unsupported publish configuration');
55
+ assert(config.access === undefined || config.access === 'public', 'Invalid publish access');
56
+ assert(config.tag === undefined || config.tag === 'latest', 'Invalid publish tag');
57
+ assert(config.registry === undefined || config.registry === REGISTRY || config.registry === REGISTRY.slice(0, -1), 'Invalid publish registry');
58
+ assert(config.provenance !== false, 'Provenance must not be disabled');
59
+ }
60
+
61
+ export function validateReceipt(receipt, expected) {
62
+ for (const key of ['repository', 'package', 'version', 'sourceSha', 'sha256']) {
63
+ assert(receipt[key] === expected[key], `Receipt ${key} mismatch`);
64
+ }
65
+ const base = `https://github.com/${expected.repository}/`;
66
+ assert(typeof receipt.independentReviewUrl === 'string' && receipt.independentReviewUrl.startsWith(base) && /^pull\/[1-9]\d*(?:#[A-Za-z0-9_-]+)?$/.test(receipt.independentReviewUrl.slice(base.length)), 'Missing independent review PR URL');
67
+ assert(Array.isArray(receipt.testEvidenceUrls) && receipt.testEvidenceUrls.length > 0 && receipt.testEvidenceUrls.every(url => typeof url === 'string' && url.startsWith(base) && /^(?:actions\/runs|pull|issues)\/[1-9]\d*(?:#[A-Za-z0-9_-]+)?$/.test(url.slice(base.length))), 'Missing test evidence URLs');
68
+ }
69
+
70
+ export function validateChecks(checks, expected) {
71
+ const selected = [];
72
+ for (const name of expected.requiredChecks) {
73
+ const runs = checks.filter(check => check.name === name && check.head_sha === expected.sourceSha && check.app?.id === 15368).sort((a, b) => b.id - a.id);
74
+ assert(runs.length > 0 && runs[0].status === 'completed' && runs[0].conclusion === 'success', `Required GitHub Actions check not successful: ${name}`);
75
+ selected.push(runs[0]);
76
+ }
77
+ return selected;
78
+ }
79
+
80
+ export function tarManifest(path) {
81
+ const options = { encoding: 'utf8', maxBuffer: 8 * 1024 * 1024, timeout: 30_000 };
82
+ const entries = execFileSync('tar', ['-tzf', path], options).trim().split('\n');
83
+ assert(entries.filter(name => name === 'package/package.json').length === 1, 'Tarball must have exactly one package/package.json');
84
+ assert(entries.every(name => name.startsWith('package/') && !name.split('/').includes('..') && !name.includes('\\')), 'Unsafe tarball path');
85
+ const details = execFileSync('tar', ['-tvzf', path], options).trim().split('\n');
86
+ assert(details.every(line => line.startsWith('-') || line.startsWith('d')), 'Tarball links or special files are forbidden');
87
+ return JSON.parse(execFileSync('tar', ['-xOf', path, 'package/package.json'], { ...options, maxBuffer: 1024 * 1024 }));
88
+ }
89
+
90
+ async function responseBytes(response, max = MAX_ARTIFACT) {
91
+ assert(response.ok, `HTTP ${response.status} while reading release evidence`);
92
+ assert(Number(response.headers.get('content-length') || 0) <= max, 'Response too large');
93
+ let size = 0; const chunks = [];
94
+ for await (const chunk of response.body) { size += chunk.length; assert(size <= max, 'Response too large'); chunks.push(chunk); }
95
+ return Buffer.concat(chunks);
96
+ }
97
+
98
+ export function githubClient(token, fetcher = fetch) {
99
+ return async (path, binary = false) => {
100
+ assert(path.startsWith('/repos/'), 'Invalid GitHub API path');
101
+ const response = await fetcher(`https://api.github.com${path}`, {
102
+ headers: { Accept: binary ? 'application/octet-stream' : 'application/vnd.github+json', ...(token ? { Authorization: `Bearer ${token}` } : {}), 'X-GitHub-Api-Version': '2022-11-28' },
103
+ redirect: 'manual', signal: AbortSignal.timeout(30_000),
104
+ });
105
+ if (binary && [301, 302, 303, 307, 308].includes(response.status)) {
106
+ const location = new URL(response.headers.get('location'));
107
+ assert(location.protocol === 'https:' && (location.hostname === 'release-assets.githubusercontent.com' || location.hostname === 'objects.githubusercontent.com'), 'Unexpected asset redirect');
108
+ return responseBytes(await fetcher(location, { signal: AbortSignal.timeout(60_000), redirect: 'error' }));
109
+ }
110
+ const bytes = await responseBytes(response, binary ? MAX_ARTIFACT : 8 * 1024 * 1024);
111
+ return binary ? bytes : JSON.parse(bytes.toString());
112
+ };
113
+ }
114
+
115
+ export async function validateSource(expected, api) {
116
+ const repo = await api(`/repos/${expected.repository}`);
117
+ assert(repo.full_name === expected.repository && repo.private === false && repo.visibility === 'public' && repo.default_branch === 'main' && !repo.archived, 'Repository must be public and active on main');
118
+ const catalog = await api(`/repos/${CORE}/contents/docs/plugin-catalog.md?ref=main`);
119
+ const catalogText = Buffer.from(catalog.content, 'base64').toString('utf8');
120
+ const enrolled = catalogText.split('\n').some(line => line.startsWith('|') && line.includes(`](https://github.com/${expected.repository})`) && line.includes('`' + expected.package + '`'));
121
+ assert(expected.repository === CORE ? expected.package === '@jc_stack/ez-agents' : enrolled, 'Repository/package is not enrolled in public catalog');
122
+ const main = await api(`/repos/${expected.repository}/git/ref/heads/main`);
123
+ assert(main.object?.sha === expected.sourceSha, 'Source is no longer current main');
124
+ const manifest = await api(`/repos/${expected.repository}/contents/package.json?ref=${expected.sourceSha}`);
125
+ const sourceManifest = JSON.parse(Buffer.from(manifest.content, 'base64').toString('utf8'));
126
+ validateManifest(sourceManifest, expected);
127
+ let tag = (await api(`/repos/${expected.repository}/git/ref/tags/v${expected.version}`)).object;
128
+ for (let depth = 0; tag?.type === 'tag' && depth < 5; depth++) tag = (await api(`/repos/${expected.repository}/git/tags/${tag.sha}`)).object;
129
+ assert(tag?.type === 'commit' && tag.sha === expected.sourceSha, 'Release tag does not identify approved source');
130
+ // Select CI identity before considering outcomes: tag/PR runs at the same SHA
131
+ // must neither shadow main CI nor let a failed latest main run fall back.
132
+ const runs = [];
133
+ for (let page = 1; ; page++) {
134
+ assert(page <= 100, 'Too many workflow run pages');
135
+ const result = await api(`/repos/${expected.repository}/actions/workflows/ci.yml/runs?branch=main&event=push&head_sha=${expected.sourceSha}&per_page=100&page=${page}`);
136
+ assert(Array.isArray(result.workflow_runs), 'Invalid workflow run response');
137
+ runs.push(...result.workflow_runs.filter(run => run.head_sha === expected.sourceSha && run.event === 'push' && run.head_branch === 'main' && run.path === '.github/workflows/ci.yml'));
138
+ if (result.workflow_runs.length < 100) break;
139
+ }
140
+ const run = runs.sort((a, b) => b.id - a.id)[0];
141
+ assert(run && Number.isSafeInteger(run.id) && run.id > 0 && Number.isSafeInteger(run.run_attempt) && run.run_attempt > 0 && run.status === 'completed' && run.conclusion === 'success', 'Required check is not successful main push CI');
142
+ const jobs = [];
143
+ for (let page = 1; ; page++) {
144
+ assert(page <= 100, 'Too many job pages');
145
+ const result = await api(`/repos/${expected.repository}/actions/runs/${run.id}/attempts/${run.run_attempt}/jobs?per_page=100&page=${page}`);
146
+ assert(Array.isArray(result.jobs), 'Invalid job response');
147
+ jobs.push(...result.jobs);
148
+ if (result.jobs.length < 100) break;
149
+ }
150
+ const checks = [];
151
+ for (const name of expected.requiredChecks) {
152
+ const matches = jobs.filter(job => job.name === name);
153
+ assert(matches.length === 1, `Missing or ambiguous required CI job: ${name}`);
154
+ const job = matches[0];
155
+ assert(job.run_id === run.id && job.run_attempt === run.run_attempt && job.head_sha === expected.sourceSha && job.status === 'completed' && job.conclusion === 'success', `Required CI job not successful: ${name}`);
156
+ const prefix = `https://api.github.com/repos/${expected.repository}/check-runs/`;
157
+ assert(typeof job.check_run_url === 'string' && job.check_run_url.startsWith(prefix) && /^[1-9]\d*$/.test(job.check_run_url.slice(prefix.length)), 'Invalid job check evidence URL');
158
+ const check = await api(job.check_run_url.slice('https://api.github.com'.length));
159
+ assert(check.name === name && check.details_url === `https://github.com/${expected.repository}/actions/runs/${run.id}/job/${job.id}`, 'Check does not identify the required CI job');
160
+ checks.push(check);
161
+ }
162
+ validateChecks(checks, expected);
163
+ return sourceManifest;
164
+ }
165
+
166
+ export async function registryState(expected, fetcher = fetch) {
167
+ const response = await fetcher(`${REGISTRY}${encodeURIComponent(expected.package)}`, { signal: AbortSignal.timeout(30_000), redirect: 'error', headers: { Accept: 'application/json' } });
168
+ assert(response.status !== 404, 'Package does not exist: first reviewed beta needs interactive registry-owner publication before trust enrollment');
169
+ const data = JSON.parse((await responseBytes(response, 32 * 1024 * 1024)).toString());
170
+ assert(data.name === expected.package && data.versions && data['dist-tags'], 'Invalid registry package metadata');
171
+ const published = data.versions[expected.version];
172
+ if (!published) return { exists: false, latest: data['dist-tags'].latest ?? null, beta: data['dist-tags'].beta ?? null };
173
+ assert(published.name === expected.package && published.version === expected.version, 'Registry version identity mismatch');
174
+ const url = new URL(published.dist?.tarball);
175
+ assert(url.origin === REGISTRY.slice(0, -1), 'Unexpected registry tarball host');
176
+ const bytes = await responseBytes(await fetcher(url, { signal: AbortSignal.timeout(60_000), redirect: 'error' }));
177
+ assert(sha256(bytes) === expected.sha256, 'Existing registry artifact differs; never overwrite');
178
+ return { exists: true, latest: data['dist-tags'].latest ?? null, beta: data['dist-tags'].beta ?? null };
179
+ }
180
+
181
+ export async function validate(output, env = process.env, api = githubClient(env.GH_TOKEN)) {
182
+ const expected = identity(env);
183
+ const sourceManifest = await validateSource(expected, api);
184
+ const release = await api(`/repos/${expected.repository}/releases/${expected.releaseId}`);
185
+ assert(release.id === expected.releaseId && release.draft === true && release.prerelease === true && release.tag_name === `v${expected.version}`, 'Candidate must be a draft prerelease');
186
+ const asset = name => {
187
+ const matches = (release.assets || []).filter(item => item.name === name && item.state === 'uploaded');
188
+ assert(matches.length === 1 && Number.isSafeInteger(matches[0].id), `Missing or ambiguous asset: ${name}`);
189
+ return matches[0];
190
+ };
191
+ const candidateAsset = asset('candidate.tgz');
192
+ const receiptAsset = asset('release-receipt.json');
193
+ const bytes = await api(`/repos/${expected.repository}/releases/assets/${candidateAsset.id}`, true);
194
+ assert(sha256(bytes) === expected.sha256, 'Candidate artifact SHA256 mismatch');
195
+ const receipt = JSON.parse((await api(`/repos/${expected.repository}/releases/assets/${receiptAsset.id}`, true)).toString());
196
+ validateReceipt(receipt, expected);
197
+ // Dispatch is the authorized maintainer's attestation to these review/test URLs.
198
+ // Their existence alone is not an independent review verdict.
199
+ const reviewPr = Number(new URL(receipt.independentReviewUrl).pathname.split('/')[4]);
200
+ const pr = await api(`/repos/${expected.repository}/pulls/${reviewPr}`);
201
+ assert(pr.merged === true && pr.base?.repo?.full_name === expected.repository && pr.base?.ref === 'main' && pr.merge_commit_sha === expected.sourceSha, 'Review PR must be merged as the exact release source');
202
+ await mkdir(output, { recursive: true });
203
+ const tarball = resolve(output, 'candidate.tgz');
204
+ await writeFile(tarball, bytes, { flag: 'wx' });
205
+ const packedManifest = tarManifest(tarball);
206
+ validateManifest(packedManifest, expected);
207
+ assert(isDeepStrictEqual(packedManifest, sourceManifest), 'Packed manifest differs from approved source manifest');
208
+ await writeFile(resolve(output, 'validated.json'), JSON.stringify({ schema: 1, expected, receipt, releaseId: release.id, candidateAssetId: candidateAsset.id }, null, 2) + '\n', { flag: 'wx' });
209
+ return { sourceSha: expected.sourceSha, sha256: expected.sha256, version: expected.version };
210
+ }
211
+
212
+ export async function publishOnce(expected, { readState, publishTarball, sleep = ms => new Promise(resolve => setTimeout(resolve, ms)), report = console.log, record = async () => {}, allowWrite = true }) {
213
+ const before = await readState();
214
+ await record({ phase: 'preflight', before });
215
+ if (before.exists) {
216
+ assert(before.latest === expected.version, 'Artifact exists but latest tag differs; reconcile without republishing');
217
+ return { status: 'already-published', version: expected.version, sha256: expected.sha256 };
218
+ }
219
+ assert(allowWrite, 'Rerun cannot repeat publication: reconcile registry state and create a fresh authorized dispatch if a new attempt is needed');
220
+ await record({ phase: 'write-started', before });
221
+ let writeError;
222
+ try { await publishTarball(); } catch (error) { writeError = error; }
223
+ let lastError;
224
+ for (let attempt = 0; attempt < 4; attempt++) {
225
+ if (attempt) await sleep(5000);
226
+ try {
227
+ const after = await readState();
228
+ await record({ phase: 'readback', before, after, publishCommandFailed: Boolean(writeError), attempt });
229
+ if (after.exists && after.latest === expected.version) {
230
+ if (writeError) report('Publish command was uncertain; registry readback verified exact artifact and latest tag.');
231
+ return { status: 'published', version: expected.version, sha256: expected.sha256 };
232
+ }
233
+ lastError = new Error('Exact artifact and latest tag not yet verified');
234
+ } catch (error) { lastError = error; await record({ phase: 'readback-error', before, error: error.message, attempt }); }
235
+ }
236
+ throw new Error(`Publication unresolved; do not repeat the write before registry reconciliation: ${lastError?.message || writeError?.message}`);
237
+ }
238
+
239
+ export async function publish(output, env = process.env) {
240
+ const expected = identity(env);
241
+ const bundle = JSON.parse(await readFile(resolve(output, 'validated.json'), 'utf8'));
242
+ assert(bundle.schema === 1 && JSON.stringify(bundle.expected) === JSON.stringify(expected), 'Validated bundle does not match dispatched identity');
243
+ validateReceipt(bundle.receipt, expected);
244
+ const path = resolve(output, 'candidate.tgz');
245
+ assert(sha256(await readFile(path)) === expected.sha256, 'Validated artifact changed');
246
+ const packedManifest = tarManifest(path);
247
+ validateManifest(packedManifest, expected);
248
+ const sourceManifest = await validateSource(expected, githubClient(env.GH_TOKEN));
249
+ assert(isDeepStrictEqual(packedManifest, sourceManifest), 'Packed manifest differs from approved source manifest');
250
+ assert(!env.NODE_AUTH_TOKEN && !env.NPM_TOKEN, 'Token-based npm publishing is forbidden');
251
+ const temporary = await mkdtemp(join(tmpdir(), 'trusted-beta-'));
252
+ try {
253
+ const npmrc = join(temporary, 'npmrc');
254
+ const globalNpmrc = join(temporary, 'global-npmrc');
255
+ await writeFile(npmrc, 'registry=https://registry.npmjs.org/\n');
256
+ await writeFile(globalNpmrc, '');
257
+ const events = [];
258
+ const record = async event => {
259
+ events.push({ at: new Date().toISOString(), ...event });
260
+ const receiptPath = resolve(output, 'publication-receipt.json');
261
+ await writeFile(`${receiptPath}.${process.pid}.tmp`, JSON.stringify({ schema: 1, expected, events }, null, 2) + '\n', { mode: 0o600 });
262
+ await rename(`${receiptPath}.${process.pid}.tmp`, receiptPath);
263
+ };
264
+ try {
265
+ const result = await publishOnce(expected, {
266
+ record,
267
+ allowWrite: env.GITHUB_RUN_ATTEMPT === '1',
268
+ readState: () => registryState(expected),
269
+ publishTarball: () => execFileSync('npm', publishArguments(path, npmrc, globalNpmrc), {
270
+ cwd: temporary, stdio: 'inherit', timeout: 180_000,
271
+ env: publishEnvironment(env),
272
+ }),
273
+ });
274
+ await record({ phase: 'complete', result });
275
+ return result;
276
+ } catch (error) {
277
+ await record({ phase: 'failed', error: error.message });
278
+ throw error;
279
+ }
280
+ } finally { await rm(temporary, { recursive: true, force: true }); }
281
+ }
282
+
283
+ if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
284
+ try {
285
+ const [mode, output] = process.argv.slice(2);
286
+ assert(['validate', 'publish'].includes(mode) && output && process.argv.length === 4, 'Usage: trusted-beta.mjs validate|publish OUTPUT_DIR');
287
+ console.log(JSON.stringify(await (mode === 'validate' ? validate(output) : publish(output))));
288
+ } catch (error) { console.error(error.message); process.exitCode = 1; }
289
+ }
@@ -0,0 +1,5 @@
1
+ import { readFileSync } from 'node:fs'
2
+
3
+ // Resolve against the installed package, never the agent's editable workspace.
4
+ export const agentGuidance = (): string =>
5
+ readFileSync(new URL('../templates/agent-guidance.md', import.meta.url), 'utf8').trim()
package/src/ai-cli.ts CHANGED
@@ -1,11 +1,12 @@
1
1
  import { parseArgs } from 'node:util'
2
2
  import { randomBytes } from 'node:crypto'
3
+ import { join } from 'node:path'
3
4
  import { readModels, validateSelection, type AiPreset } from './ai.js'
4
5
  import { ControlStore } from './control-state.js'
5
6
 
6
7
  const {values,positionals}=parseArgs({allowPositionals:true,options:{cli:{type:'string'},model:{type:'string'},effort:{type:'string'}}})
7
8
  if (!process.env.EZ_CONTROL_DIR) throw new Error('Use this agent’s bound control directory')
8
- const catalog=await readModels()
9
+ const catalog=await readModels(undefined,undefined,join(process.env.EZ_CONTROL_DIR,'cli','codex'))
9
10
  if(positionals[0]==='list')console.log(JSON.stringify(catalog))
10
11
  else if(positionals[0]==='select'){
11
12
  const preset:AiPreset={id:randomBytes(8).toString('hex'),name:[values.model||values.cli,values.effort].filter(Boolean).join(' · '),cli:values.cli||'',model:values.model,effort:values.effort}
package/src/ai.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import { CODEX_DEFAULT_MODEL, DEFAULT_EFFORT, assertEffort, allowedEffort } from './model-policy.js'
1
2
  import { access, readFile } from 'node:fs/promises'
2
3
  import { constants } from 'node:fs'
3
4
  import { homedir } from 'node:os'
@@ -21,10 +22,18 @@ export const isExecutionChoice = (v: unknown): v is ExecutionChoice => {
21
22
  return Boolean(c && /^[0-9a-f-]{36}$/i.test(c.sessionId) && isPreset(c.preset))
22
23
  }
23
24
  export const presetLabel = (p: AiPreset) => `${p.cli} · ${p.model || 'client default'} · ${p.effort || 'default effort'}`
25
+ // The seed delegates model selection to the native client. Project its resolved
26
+ // settings for status without pinning future conversations to that snapshot.
27
+ export const statusPreset = (preset: AiPreset, discovered: AiPreset[]): AiPreset =>
28
+ preset.cli === 'codex' && !preset.model && !preset.effort
29
+ ? discovered.find((candidate) => candidate.cli === preset.cli) ?? preset
30
+ : preset
24
31
  export const initialPreset = (cli: string): AiPreset => {
25
32
  const key = executorKey(cli)
26
33
  return {
27
34
  id: 'initial', name: `${resolveExecutor(key).name} · current setup`, cli: key,
35
+ ...(key === 'codex' || key === 'codex-gui'
36
+ ? { model: CODEX_DEFAULT_MODEL, effort: DEFAULT_EFFORT } : {}),
28
37
  ...(key === 'opencode'
29
38
  ? { model: process.env.OPENCODE_MODEL || 'opencode/nemotron-3.5-lightning-free' } : {}),
30
39
  }
@@ -40,17 +49,17 @@ export const installed = async (cli: string): Promise<boolean> => {
40
49
 
41
50
  // Read only metadata from native client catalogs. Never import prompts, credentials,
42
51
  // provider configuration, or model instructions into relay context.
43
- export const readModels = async (home = homedir(), available = installed): Promise<ModelChoice[]> => {
52
+ export const readModels = async (home = homedir(), available = installed, codexHome = join(home, '.codex')): Promise<ModelChoice[]> => {
44
53
  const models: ModelChoice[] = []
45
54
  const record = (value: unknown): Record<string, unknown> =>
46
55
  value && typeof value === 'object' && !Array.isArray(value) ? value as Record<string, unknown> : {}
47
56
  const efforts = (value: unknown, key: string): string[] =>
48
- (Array.isArray(value) ? value : []).map((e: unknown) => record(e)[key]).filter(safe)
57
+ (Array.isArray(value) ? value : []).map((e: unknown) => record(e)[key]).filter(safe).filter(allowedEffort)
49
58
  const json = async (file: string) => {
50
- try { return record(JSON.parse(await readFile(join(home, file), 'utf8'))) } catch { return {} }
59
+ try { return record(JSON.parse(await readFile(file, 'utf8'))) } catch { return {} }
51
60
  }
52
61
  if (await available('grok')) {
53
- const cache = await json('.grok/models_cache.json')
62
+ const cache = await json(join(home, '.grok', 'models_cache.json'))
54
63
  for (const entry of Object.values(record(cache.models))) {
55
64
  const info = record(record(entry).info)
56
65
  if (info.hidden || !safe(info.id)) continue
@@ -59,7 +68,7 @@ export const readModels = async (home = homedir(), available = installed): Promi
59
68
  }
60
69
  }
61
70
  if (await available('codex')) {
62
- const cache = await json('.codex/models_cache.json')
71
+ const cache = await json(join(codexHome, 'models_cache.json'))
63
72
  for (const entry of Array.isArray(cache.models) ? cache.models : []) {
64
73
  const info = record(entry)
65
74
  if (info.visibility !== 'list' || !safe(info.slug)) continue
@@ -80,6 +89,7 @@ export const readModels = async (home = homedir(), available = installed): Promi
80
89
  }
81
90
 
82
91
  export const validateSelection = async (p: AiPreset, catalog: ModelChoice[], available = installed): Promise<void> => {
92
+ assertEffort(p.effort)
83
93
  if (!isPreset(p) || !(await available(p.cli))) throw new Error('This CLI is not installed.')
84
94
  if (!p.model && !p.effort && p.cli !== 'agy') return
85
95
  const model = catalog.find((m) => m.cli === p.cli && m.model === p.model)
@@ -0,0 +1,46 @@
1
+ import { readFile } from 'node:fs/promises'
2
+ import type { Config } from './config.js'
3
+ import type { RunRecord } from './runs.js'
4
+ import { workspaceFile } from './files.js'
5
+
6
+ // Application-owned jobs; no CLI state, provider credentials or business routing here.
7
+ export async function dispatchChannel(config: Config, run: RunRecord): Promise<string | null> {
8
+ const url = new URL(config.channelBackendUrl!)
9
+ if (url.protocol !== 'https:' && !(url.protocol === 'http:' && ['localhost', '127.0.0.1'].includes(url.hostname)))
10
+ throw new Error('Channel backend requires HTTPS (or loopback HTTP)')
11
+ if (!config.channelBackendToken || url.username || url.password || url.search || url.hash)
12
+ throw new Error('Channel backend requires a private token and plain endpoint URL')
13
+ const items = []
14
+ for (const item of run.items ?? []) {
15
+ let attachment
16
+ if (item.attachment) {
17
+ const bytes = await readFile(await workspaceFile(config.workspace, item.attachment.path))
18
+ if (bytes.length > 12 * 1024 * 1024) throw new Error('Channel attachment exceeds 12 MB')
19
+ attachment = { type: item.attachment.type, data: bytes.toString('base64') }
20
+ }
21
+ items.push({ text: item.attachment ? (item.caption ?? '') : item.text,
22
+ message_id: item.messageId, sent_at: item.sentAt, album_id: item.albumId, attachment })
23
+ }
24
+ if (!items.length) throw new Error('Channel run is missing normalized items')
25
+ const response = await fetch(url, {
26
+ method: 'POST', redirect: 'error', signal: AbortSignal.timeout(60_000),
27
+ headers: { Authorization: `Bearer ${config.channelBackendToken}`, 'Content-Type': 'application/json' },
28
+ body: JSON.stringify({ version: 1, event_id: run.id, channel: 'telegram',
29
+ sender_id: String(run.telegramUserId), chat_id: String(run.chatId), items }),
30
+ })
31
+ if (!response.ok) {
32
+ const error = new Error(`Channel backend HTTP ${response.status}`)
33
+ if (response.status >= 400 && response.status < 500 && ![408, 429].includes(response.status))
34
+ Object.assign(error, { permanent: true })
35
+ throw error
36
+ }
37
+ const result = await response.json() as { status?: string; reply?: string }
38
+ if (result.status === 'queued' || result.status === 'running') {
39
+ // Yield to the durable relay queue; backend requests with the same ID only resume/poll.
40
+ await new Promise(resolve => setTimeout(resolve, 4000))
41
+ return null
42
+ }
43
+ if (!['complete', 'failed'].includes(result.status ?? '') || typeof result.reply !== 'string')
44
+ throw new Error('Invalid channel backend response')
45
+ return result.reply
46
+ }
@@ -15,11 +15,28 @@ const value = (v: unknown): string | undefined =>
15
15
  const command = async (cli: string, args: string[], cwd: string): Promise<string> =>
16
16
  (await promisify(execFile)(cli, args, { cwd, env: executorEnvironment(), timeout: 8000, maxBuffer: 2 * 1024 * 1024 })).stdout
17
17
 
18
+ export const resolvedCodexDefaults = (configValue: unknown, catalogValue: unknown): Record<string, unknown> => {
19
+ const config = record(configValue)
20
+ const managed = record(record(config.models).new_thread)
21
+ const model = value(managed.model) ?? value(config.model)
22
+ const effort = value(managed.model_reasoning_effort) ?? value(config.model_reasoning_effort)
23
+ const catalog = (Array.isArray(catalogValue) ? catalogValue : []).map(record)
24
+ const selected = model
25
+ ? catalog.find((entry) => value(entry.model) === model || value(entry.id) === model)
26
+ : catalog.find((entry) => entry.isDefault === true)
27
+ return {
28
+ ...(model ?? value(selected?.model) ?? value(selected?.id) ? { model: model ?? value(selected?.model) ?? value(selected?.id) } : {}),
29
+ ...(effort ?? value(selected?.defaultReasoningEffort) ? { effort: effort ?? value(selected?.defaultReasoningEffort) } : {}),
30
+ }
31
+ }
32
+
18
33
  // Native config/read resolves Codex's layers; do not reimplement TOML or start a turn.
19
- export const codexDefaults = (cwd: string): Promise<Record<string, unknown>> => new Promise((resolve) => {
20
- const child = spawn('codex', ['app-server'], { cwd, env: executorEnvironment(), stdio: ['pipe', 'pipe', 'ignore'] })
34
+ export const codexDefaults = (cwd: string, codexHome?: string, nativeFallback = false): Promise<Record<string, unknown>> => new Promise((resolve) => {
35
+ const child = spawn('codex', ['app-server'], { cwd,
36
+ env: { ...executorEnvironment(), ...(codexHome ? { CODEX_HOME: codexHome } : {}) }, stdio: ['pipe', 'pipe', 'ignore'] })
21
37
  const lines = createInterface({ input: child.stdout })
22
38
  let done = false
39
+ let config: unknown = {}
23
40
  const finish = (config: Record<string, unknown> = {}) => {
24
41
  if (done) return
25
42
  done = true
@@ -37,10 +54,12 @@ export const codexDefaults = (cwd: string): Promise<Record<string, unknown>> =>
37
54
  send({ method: 'initialized', params: {} })
38
55
  send({ id: 1, method: 'config/read', params: { includeLayers: false, cwd } })
39
56
  } else if (message.id === 1) {
40
- const config = record(message.result?.config)
41
- const managed = record(record(config.models).new_thread)
42
- finish({ model: managed.model ?? config.model,
43
- effort: managed.model_reasoning_effort ?? config.model_reasoning_effort })
57
+ config = message.result?.config
58
+ const resolved = resolvedCodexDefaults(config, [])
59
+ if (!nativeFallback || (resolved.model && resolved.effort)) finish(resolved)
60
+ else send({ id: 2, method: 'model/list', params: { limit: 100, includeHidden: false } })
61
+ } else if (message.id === 2) {
62
+ finish(resolvedCodexDefaults(config, message.result?.data))
44
63
  }
45
64
  } catch { finish() }
46
65
  })
@@ -56,7 +75,7 @@ export const grokSettings = (text: string): { model?: string; effort?: string }
56
75
  }
57
76
 
58
77
  export const discoverDefaults = async (cwd: string, options: {
59
- home?: string; available?: typeof installed; run?: typeof command; codex?: typeof codexDefaults
78
+ home?: string; codexHome?: string; nativeCodexFallback?: boolean; available?: typeof installed; run?: typeof command; codex?: typeof codexDefaults
60
79
  } = {}): Promise<AiPreset[]> => {
61
80
  const home = options.home ?? homedir()
62
81
  const available = options.available ?? installed
@@ -73,7 +92,7 @@ export const discoverDefaults = async (cwd: string, options: {
73
92
  model = settings.model ?? value((await run(cli, ['models'], cwd)).match(/^Default model:\s*(\S+)/m)?.[1])
74
93
  effort = settings.effort
75
94
  } else if (cli === 'codex') {
76
- const config = await (options.codex ?? codexDefaults)(cwd)
95
+ const config = await (options.codex ?? codexDefaults)(cwd, options.codexHome, options.nativeCodexFallback)
77
96
  model = value(config.model); effort = value(config.effort)
78
97
  } else if (cli === 'claude') {
79
98
  // Match Claude's documented user -> project -> local settings precedence.
@@ -91,11 +110,8 @@ export const discoverDefaults = async (cwd: string, options: {
91
110
  }))
92
111
  const discovered = results.filter((p): p is AiPreset => Boolean(p))
93
112
  if (await available('codex-gui')) {
94
- const source = discovered.find((preset) => preset.cli === 'codex')
95
- const model = source?.model, effort = source?.effort
96
- const id = 'detected_' + createHash('sha256').update(JSON.stringify(['codex-gui', model, effort])).digest('hex').slice(0, 12)
97
- discovered.push({ id, cli: 'codex-gui', model, effort,
98
- name: `codex-gui · ${model || 'desktop'}${effort ? ` · ${effort}` : ''}`.slice(0, 80) })
113
+ const id = 'detected_' + createHash('sha256').update('codex-gui').digest('hex').slice(0, 12)
114
+ discovered.push({ id, cli: 'codex-gui', name: 'codex-gui · desktop' })
99
115
  }
100
116
  return discovered
101
117
  }
@@ -0,0 +1,98 @@
1
+ import { executionDefaults } from './model-policy.js'
2
+ import { spawn, type ChildProcess } from 'node:child_process'
3
+ import { createInterface } from 'node:readline'
4
+ import { fileURLToPath } from 'node:url'
5
+ import path from 'node:path'
6
+ import { terminateJob } from './executor.js'
7
+
8
+ type Options = {workspace:string;controlDir:string;toolsHome?:string;sharedWorkspace?:string;model?:string;effort?:string;prompt:string;goal:boolean}
9
+ type Message = {id?:number;method?:string;params?:any;result?:any;error?:{message:string;code?:number}}
10
+
11
+ // Keep Codex's native session alive. Codex itself starts goal continuation turns;
12
+ // this transport never generates a continuation prompt or an Ez goal record.
13
+ export async function runCodexSession(options:Options, io:{launch?:()=>ChildProcess;emit?:(line:string)=>void}={}):Promise<number> {
14
+ options = executionDefaults('codex', options)
15
+ const child=io.launch?.() ?? spawn('codex',['app-server','--stdio','--disable','memories','--enable','skip_host_skill_discovery'],{cwd:options.workspace,env:process.env,stdio:['pipe','pipe','pipe']})
16
+ const emit=io.emit ?? (line=>process.stdout.write(line+'\n'))
17
+ let id=0,threadId:string|undefined,activeTurn:string|undefined,finished=false,sawTurn=false,hadGoal=options.goal
18
+ let resolveDone!:(code:number)=>void
19
+ const done=new Promise<number>(resolve=>{resolveDone=resolve})
20
+ const pending=new Map<number,{resolve:(value:any)=>void;reject:(error:Error)=>void;timer:ReturnType<typeof setTimeout>}>()
21
+ const finish=(code:number)=>{if(!finished){finished=true;resolveDone(code)}}
22
+ const fail=(error:unknown)=>{console.error('Codex native session failed:',error instanceof Error?error.message:String(error));finish(1)}
23
+ const send=(message:Message)=>child.stdin!.write(JSON.stringify(message)+'\n')
24
+ const request=(method:string,params:unknown):Promise<any>=>new Promise((resolve,reject)=>{
25
+ const next=++id
26
+ const timer=setTimeout(()=>{pending.delete(next);reject(new Error(`Codex request timed out: ${method}`))},30000)
27
+ pending.set(next,{resolve,reject,timer});send({id:next,method,params})
28
+ })
29
+ const settled=(goal:any)=>{
30
+ if(goal)hadGoal=true
31
+ if(activeTurn || goal?.status==='active')return
32
+ if(goal && goal.status!=='complete'){console.error(`Native goal stopped: ${goal.status}`);finish(1);return}
33
+ if(!goal && hadGoal){fail(new Error('Native goal disappeared without verified completion'));return}
34
+ if(!sawTurn)return
35
+ finish(0)
36
+ }
37
+ child.stderr?.pipe(process.stderr)
38
+ child.on('error',fail)
39
+ child.stdin?.on('error',fail)
40
+ child.once('close',()=>{
41
+ for(const p of pending.values()){clearTimeout(p.timer);p.reject(new Error('Codex app-server closed'))}
42
+ pending.clear();if(!finished)fail(new Error('Codex app-server closed before work completed'))
43
+ })
44
+ const lines=createInterface({input:child.stdout!})
45
+ lines.on('line',line=>{
46
+ let message:Message
47
+ try{message=JSON.parse(line)}catch{fail(new Error('Invalid Codex app-server response'));return}
48
+ if(message.id!==undefined && pending.has(message.id) && !message.method){
49
+ const p=pending.get(message.id)!;pending.delete(message.id);clearTimeout(p.timer)
50
+ if(message.error)p.reject(new Error(message.error.message));else p.resolve(message.result)
51
+ return
52
+ }
53
+ if(message.id!==undefined && message.method){
54
+ // Never turn an unexpected approval/elicitation request into permission.
55
+ send({id:message.id,error:{code:-32601,message:`Unsupported unattended request: ${message.method}`}});fail(new Error(`Codex requires attention: ${message.method}`));return
56
+ }
57
+ if(message.params?.threadId!==threadId)return
58
+ if(message.method==='turn/started'){activeTurn=message.params.turn.id;sawTurn=true}
59
+ if(message.method==='thread/goal/updated')settled(message.params.goal)
60
+ if(message.method==='thread/goal/cleared')settled(null)
61
+ if(message.method==='turn/completed'){
62
+ if(activeTurn===message.params.turn.id)activeTurn=undefined
63
+ if(message.params.turn.status!=='completed'){finish(message.params.turn.status==='interrupted'?130:1);return}
64
+ // Completion of a turn is not completion of a native goal. The native
65
+ // app-server remains running and owns any automatic next turn.
66
+ void request('thread/goal/get',{threadId}).then(result=>settled(result.goal)).catch(fail)
67
+ }
68
+ })
69
+ try{
70
+ await request('initialize',{clientInfo:{name:'ezenciel-agents',version:'1'},capabilities:{experimentalApi:true}})
71
+ send({method:'initialized',params:{}})
72
+ const result=await request('thread/start',{
73
+ cwd:options.workspace,approvalPolicy:'never',sandbox:'workspace-write',model:options.model,
74
+ config:{'sandbox_workspace_write.writable_roots':[options.controlDir,...(options.toolsHome?[options.toolsHome]:[]),...(options.sharedWorkspace?[options.sharedWorkspace]:[])],
75
+ 'sandbox_workspace_write.network_access':Boolean(options.toolsHome),...(options.effort?{model_reasoning_effort:options.effort}:{})},
76
+ })
77
+ threadId=result.thread?.id
78
+ if(!threadId)throw new Error('Codex did not return a native thread ID')
79
+ emit(JSON.stringify({type:'thread.started',thread_id:threadId}))
80
+ if(options.goal){
81
+ // This is the native request used by the interactive /goal command.
82
+ // Setting it active starts work in Codex; do not also send turn/start.
83
+ const result=await request('thread/goal/set',{threadId,objective:options.prompt,status:'active'})
84
+ if(result.goal)settled(result.goal)
85
+ }else await request('turn/start',{threadId,input:[{type:'text',text:options.prompt}],model:options.model,effort:options.effort})
86
+ return await done
87
+ }catch(error){fail(error);return 1}
88
+ finally{
89
+ finished=true
90
+ for(const p of pending.values()){clearTimeout(p.timer);p.reject(new Error('Codex session closed'))}
91
+ pending.clear();lines.close();child.stdin?.end();terminateJob(child)
92
+ }
93
+ }
94
+
95
+ if(process.argv[1] && path.resolve(process.argv[1])===fileURLToPath(import.meta.url)){
96
+ let input='';for await(const chunk of process.stdin)input+=chunk
97
+ process.exitCode=await runCodexSession(JSON.parse(input))
98
+ }