@fitlab-ai/agent-infra 0.9.4 → 0.9.5

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 (56) hide show
  1. package/README.md +1 -1
  2. package/README.zh-CN.md +1 -1
  3. package/dist/bin/internal-cli.js +10 -0
  4. package/dist/lib/agent-clients/adapter.js +2 -2
  5. package/dist/lib/agent-clients/adapters/codex-lifecycle/app-server.js +382 -0
  6. package/dist/lib/agent-clients/adapters/codex-lifecycle/evidence.js +163 -0
  7. package/dist/lib/agent-clients/adapters/codex-lifecycle/store.js +211 -0
  8. package/dist/lib/agent-clients/adapters/codex.js +2 -2
  9. package/dist/lib/git/worktree-identity.js +64 -0
  10. package/dist/lib/internal/codex-lifecycle.js +202 -0
  11. package/dist/lib/internal/task-create.js +48 -0
  12. package/dist/lib/internal/task-orchestration.js +68 -29
  13. package/dist/lib/sandbox/commands/create.js +1 -0
  14. package/dist/lib/sandbox/control/client.js +29 -15
  15. package/dist/lib/sandbox/control/protocol.js +23 -4
  16. package/dist/lib/sandbox/control/server.js +53 -18
  17. package/dist/lib/sandbox/workspace-view.js +8 -2
  18. package/dist/lib/task/activity-log.js +32 -5
  19. package/dist/lib/task/commit-finalization.js +14 -3
  20. package/dist/lib/task/create-service.js +137 -0
  21. package/dist/lib/task/create.js +333 -0
  22. package/dist/lib/task/orchestration.js +151 -15
  23. package/dist/lib/task/review-fingerprint.js +4 -0
  24. package/dist/lib/task/workspace-snapshot.js +78 -3
  25. package/lib/agent-clients/adapter.ts +4 -4
  26. package/lib/agent-clients/adapters/codex-lifecycle/app-server.ts +442 -0
  27. package/lib/agent-clients/adapters/codex-lifecycle/evidence.ts +307 -0
  28. package/lib/agent-clients/adapters/codex-lifecycle/store.ts +250 -0
  29. package/lib/agent-clients/adapters/codex.ts +2 -2
  30. package/lib/git/worktree-identity.ts +86 -0
  31. package/lib/internal/codex-lifecycle.ts +204 -0
  32. package/lib/internal/task-create.ts +49 -0
  33. package/lib/internal/task-orchestration.ts +54 -28
  34. package/lib/sandbox/commands/create.ts +1 -0
  35. package/lib/sandbox/control/client.ts +42 -18
  36. package/lib/sandbox/control/protocol.ts +43 -8
  37. package/lib/sandbox/control/server.ts +60 -22
  38. package/lib/sandbox/workspace-view.ts +9 -2
  39. package/lib/task/activity-log.ts +46 -7
  40. package/lib/task/commit-finalization.ts +23 -3
  41. package/lib/task/create-service.ts +171 -0
  42. package/lib/task/create.ts +372 -0
  43. package/lib/task/orchestration.ts +182 -18
  44. package/lib/task/review-fingerprint.ts +9 -0
  45. package/lib/task/workspace-snapshot.ts +98 -4
  46. package/package.json +7 -7
  47. package/templates/.agents/hooks/lifecycle-delegation.js +47 -3
  48. package/templates/.agents/rules/task-management.en.md +1 -0
  49. package/templates/.agents/rules/task-management.zh-CN.md +1 -0
  50. package/templates/.agents/skills/commit/SKILL.en.md +3 -9
  51. package/templates/.agents/skills/commit/SKILL.zh-CN.md +6 -10
  52. package/templates/.agents/skills/commit/reference/commit-orchestration.en.md +4 -3
  53. package/templates/.agents/skills/commit/reference/commit-orchestration.zh-CN.md +5 -4
  54. package/templates/.agents/skills/create-task/SKILL.en.md +26 -43
  55. package/templates/.agents/skills/create-task/SKILL.zh-CN.md +26 -43
  56. package/templates/.codex/hooks.json +50 -0
@@ -0,0 +1,204 @@
1
+ import { spawnSync } from 'node:child_process';
2
+ import crypto from 'node:crypto';
3
+ import fs from 'node:fs';
4
+ import path from 'node:path';
5
+
6
+ import {
7
+ preflightCodexLifecycleEvidence,
8
+ resolveCodexTerminal,
9
+ resolveCodexThread
10
+ } from '../agent-clients/adapters/codex-lifecycle/app-server.ts';
11
+ import { createCodexLifecycleStore } from '../agent-clients/adapters/codex-lifecycle/store.ts';
12
+ import type { CodexLifecycleEvent } from '../agent-clients/adapters/codex-lifecycle/evidence.ts';
13
+
14
+ const USAGE = 'Usage: agent-infra-internal codex-lifecycle <hook-event|resolve-start|resolve-stop|preflight|consume> [options]\n';
15
+ const MANAGED_AGENT = /^agent-infra-lifecycle-(executor|reviewer)$/;
16
+
17
+ type Parsed = Readonly<{ operation: string; values: Readonly<Record<string, string>> }>;
18
+
19
+ function output(value: unknown): void {
20
+ process.stdout.write(`${JSON.stringify(value)}\n`);
21
+ }
22
+
23
+ function failure(code: string, message: string): void {
24
+ output({ status: 'failed', changed: false, evidence: null, diagnostics: [], error: { code, message } });
25
+ process.exitCode = 1;
26
+ }
27
+
28
+ function parse(args: string[]): Parsed | null {
29
+ if (args[0] === '--help' || args[0] === '-h') {
30
+ process.stdout.write(USAGE);
31
+ return null;
32
+ }
33
+ const operation = args[0];
34
+ if (!operation) {
35
+ failure('CODEX_LIFECYCLE_PAYLOAD_INVALID', 'operation is required');
36
+ return null;
37
+ }
38
+ const allowed = {
39
+ 'hook-event': ['--event'],
40
+ 'resolve-start': ['--child-id'],
41
+ 'resolve-stop': ['--child-id'],
42
+ preflight: ['--format', '--session-id', '--turn-id', '--tool-use-id'],
43
+ consume: ['--child-id', '--consumer']
44
+ }[operation];
45
+ if (!allowed) {
46
+ failure('CODEX_LIFECYCLE_PAYLOAD_INVALID', 'unknown operation');
47
+ return null;
48
+ }
49
+ const values: Record<string, string> = {};
50
+ for (let index = 1; index < args.length; index += 1) {
51
+ const flag = args[index]!;
52
+ if (!allowed.includes(flag) || Object.hasOwn(values, flag)) {
53
+ failure('CODEX_LIFECYCLE_PAYLOAD_INVALID', `invalid or duplicate option '${flag}'`);
54
+ return null;
55
+ }
56
+ const value = args[++index];
57
+ if (!value || value.startsWith('--')) {
58
+ failure('CODEX_LIFECYCLE_PAYLOAD_INVALID', `option '${flag}' requires a value`);
59
+ return null;
60
+ }
61
+ values[flag] = value;
62
+ }
63
+ return Object.freeze({ operation, values: Object.freeze(values) });
64
+ }
65
+
66
+ function cliVersion(): string {
67
+ const result = spawnSync('codex', ['--version'], { encoding: 'utf8' });
68
+ const version = /codex-cli\s+(\d+\.\d+\.\d+)/.exec(result.stdout ?? '')?.[1];
69
+ if (result.status !== 0 || !version) throw new Error('Codex CLI version is unavailable');
70
+ return version;
71
+ }
72
+
73
+ function hookDefinitionHash(): string {
74
+ const file = path.join(process.cwd(), '.codex', 'hooks.json');
75
+ return crypto.createHash('sha256').update(fs.readFileSync(file)).digest('hex');
76
+ }
77
+
78
+ async function readStdin(): Promise<unknown> {
79
+ let input = '';
80
+ process.stdin.setEncoding('utf8');
81
+ for await (const chunk of process.stdin) {
82
+ input += chunk;
83
+ if (Buffer.byteLength(input) > 64 * 1024) throw new Error('Codex lifecycle input exceeds 64 KiB');
84
+ }
85
+ return JSON.parse(input || '{}') as unknown;
86
+ }
87
+
88
+ function recordFromPayload(phase: string, payload: unknown): CodexLifecycleEvent | null {
89
+ if (!payload || typeof payload !== 'object' || Array.isArray(payload)) {
90
+ throw new Error('Codex lifecycle hook payload must be an object');
91
+ }
92
+ const value = payload as Record<string, unknown>;
93
+ const text = (key: string) => typeof value[key] === 'string' ? value[key] as string : '';
94
+ if (!MANAGED_AGENT.test(text('nativeAgent'))) return null;
95
+ if (phase === 'pre-tool') return {
96
+ type: 'hook-spawn',
97
+ sessionId: text('sessionId'),
98
+ turnId: text('turnId'),
99
+ toolUseId: text('toolUseId'),
100
+ nativeAgent: text('nativeAgent'),
101
+ ...(text('requestedModel') ? { requestedModel: text('requestedModel') } : {}),
102
+ ...(text('requestedReasoningEffort') ? { requestedReasoningEffort: text('requestedReasoningEffort') } : {}),
103
+ hookDefinitionHash: text('hookDefinitionHash')
104
+ };
105
+ if (phase === 'subagent-start') return {
106
+ type: 'hook-child',
107
+ sessionId: text('sessionId'),
108
+ turnId: text('turnId'),
109
+ childThreadId: text('childThreadId'),
110
+ nativeAgent: text('nativeAgent')
111
+ };
112
+ if (phase === 'subagent-stop') return {
113
+ type: 'hook-stop',
114
+ sessionId: text('sessionId'),
115
+ turnId: text('turnId'),
116
+ childThreadId: text('childThreadId'),
117
+ nativeAgent: text('nativeAgent')
118
+ };
119
+ if (phase === 'post-tool') return null;
120
+ throw new Error(`unknown Codex lifecycle hook phase '${phase}'`);
121
+ }
122
+
123
+ async function codexLifecycle(args: string[] = []): Promise<void> {
124
+ const parsed = parse(args);
125
+ if (!parsed || process.exitCode) return;
126
+ try {
127
+ if (parsed.operation === 'preflight') {
128
+ const format = parsed.values['--format'] ?? 'json';
129
+ if (!['text', 'json'].includes(format)) throw new Error("format must be 'text' or 'json'");
130
+ const runtimeFields = [
131
+ parsed.values['--session-id'],
132
+ parsed.values['--turn-id'],
133
+ parsed.values['--tool-use-id']
134
+ ];
135
+ if (runtimeFields.some(Boolean) && !runtimeFields.every(Boolean)) {
136
+ throw new Error('preflight runtime identity requires --session-id, --turn-id, and --tool-use-id together');
137
+ }
138
+ const runtimeIdentity = runtimeFields.every(Boolean) ? {
139
+ sessionId: runtimeFields[0]!,
140
+ turnId: runtimeFields[1]!,
141
+ toolUseId: runtimeFields[2]!
142
+ } : undefined;
143
+ const result = await preflightCodexLifecycleEvidence(process.cwd(), runtimeIdentity);
144
+ if (format === 'text') {
145
+ process.stdout.write(`Codex lifecycle static preflight: ready\nCLI: ${result.cliVersion}\nRuntime hook liveness: ${result.runtimeLiveness ? 'observed' : 'not-yet-observed'}\n`);
146
+ } else output({ status: 'ready', changed: false, evidence: result, diagnostics: result.diagnostics, error: null });
147
+ return;
148
+ }
149
+
150
+ const store = createCodexLifecycleStore({ cliVersion: cliVersion() });
151
+ if (parsed.operation === 'hook-event') {
152
+ const phase = parsed.values['--event'];
153
+ if (!phase || !['pre-tool', 'subagent-start', 'post-tool', 'subagent-stop'].includes(phase)) {
154
+ throw new Error('hook-event requires a known --event');
155
+ }
156
+ const event = recordFromPayload(phase, await readStdin());
157
+ if (!event) {
158
+ output({ status: 'ignored', changed: false, evidence: null, diagnostics: [], error: null });
159
+ return;
160
+ }
161
+ const result = store.apply(event);
162
+ output({ status: result.state.status, changed: true, evidence: result.state, diagnostics: [], error: result.state.error });
163
+ if (result.state.status === 'invalid') process.exitCode = 1;
164
+ return;
165
+ }
166
+
167
+ const childThreadId = parsed.values['--child-id'];
168
+ if (!childThreadId) throw new Error(`operation '${parsed.operation}' requires --child-id`);
169
+ if (parsed.operation === 'resolve-start') {
170
+ if (store.read(childThreadId).state.spawn?.hookDefinitionHash !== hookDefinitionHash()) {
171
+ throw new Error('Codex lifecycle hook definition hash is stale');
172
+ }
173
+ const resolved = await resolveCodexThread(childThreadId);
174
+ let latest = store.apply(resolved.resolution.thread);
175
+ for (const reroute of resolved.reroutes) latest = store.apply(reroute);
176
+ latest = store.apply(resolved.resolution.settings);
177
+ output({ status: latest.state.status, changed: true, evidence: latest.state.startEvidence, diagnostics: resolved.diagnostics, error: latest.state.error });
178
+ if (latest.state.status !== 'start-ready') process.exitCode = 1;
179
+ return;
180
+ }
181
+ if (parsed.operation === 'resolve-stop') {
182
+ const terminal = await resolveCodexTerminal(childThreadId);
183
+ const latest = store.apply(terminal);
184
+ if (latest.state.status !== 'stop-ready') {
185
+ throw new Error(`Codex lifecycle stop evidence is not ready (status=${latest.state.status})`);
186
+ }
187
+ output({ status: latest.state.status, changed: true, evidence: latest.state.stopEvidence, diagnostics: [], error: latest.state.error });
188
+ return;
189
+ }
190
+ if (parsed.operation === 'consume') {
191
+ const consumer = parsed.values['--consumer'];
192
+ if (!consumer) throw new Error('consume requires --consumer');
193
+ const consumed = store.consume(childThreadId, consumer, hookDefinitionHash());
194
+ output({ status: 'consumed', changed: true, evidence: {
195
+ start: consumed.state.startEvidence,
196
+ stop: consumed.state.stopEvidence
197
+ }, diagnostics: [], error: null });
198
+ }
199
+ } catch (error) {
200
+ failure('CODEX_LIFECYCLE_FAILED', error instanceof Error ? error.message : String(error));
201
+ }
202
+ }
203
+
204
+ export { codexLifecycle };
@@ -0,0 +1,49 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+
4
+ import { requestSandboxTaskCreate } from '../sandbox/control/client.ts';
5
+ import { SANDBOX_CONTROL_MAX_BYTES } from '../sandbox/control/protocol.ts';
6
+ import { validateTaskCreateCandidate } from '../task/create.ts';
7
+ import { createTask, type TaskCreateResult } from '../task/create-service.ts';
8
+
9
+ function failed(code: string, message: string): TaskCreateResult {
10
+ return {
11
+ status: 'failed', changed: false, task: { id: null, shortId: null }, issue: null,
12
+ operations: [], warnings: [], error: { code, message, retryable: false }
13
+ };
14
+ }
15
+
16
+ function output(result: TaskCreateResult): void {
17
+ process.stdout.write(`${JSON.stringify(result)}\n`);
18
+ process.exitCode = result.status === 'blocked' ? 2 : result.status === 'failed' ? 1 : 0;
19
+ }
20
+
21
+ function taskCreate(args: string[]): void {
22
+ const inputIndex = args.indexOf('--input');
23
+ const input = inputIndex >= 0 ? args[inputIndex + 1] : undefined;
24
+ if (!input || args.length !== 2 || inputIndex !== 0) {
25
+ output(failed('TASK_CREATE_PAYLOAD_INVALID', 'Usage: agent-infra-internal task-create --input <candidate.json>'));
26
+ return;
27
+ }
28
+ try {
29
+ const inputPath = path.resolve(input);
30
+ const stat = fs.lstatSync(inputPath);
31
+ if (!stat.isFile() || stat.isSymbolicLink()) throw new Error('TASK_CREATE_INPUT_INVALID: input must be a regular file');
32
+ if (stat.size > SANDBOX_CONTROL_MAX_BYTES) throw new Error('TASK_CREATE_INPUT_TOO_LARGE: input exceeds the control limit');
33
+ const candidate = validateTaskCreateCandidate(JSON.parse(fs.readFileSync(inputPath, 'utf8')));
34
+ if (process.env.AGENT_INFRA_CONTROL_TOKEN) {
35
+ const response = requestSandboxTaskCreate({ candidate });
36
+ process.stdout.write(response.stdout);
37
+ process.stderr.write(response.stderr);
38
+ process.exitCode = response.exitCode;
39
+ return;
40
+ }
41
+ output(createTask(candidate, { repoRoot: process.cwd() }));
42
+ } catch (error) {
43
+ const message = error instanceof Error ? error.message : String(error);
44
+ const code = /^([A-Z][A-Z0-9_]+)/.exec(message)?.[1] ?? 'TASK_CREATE_INPUT_INVALID';
45
+ output(failed(code, message));
46
+ }
47
+ }
48
+
49
+ export { taskCreate };
@@ -14,13 +14,15 @@ import {
14
14
  routeOrchestration,
15
15
  sealMatchingOrchestrationDelegation,
16
16
  sealOrchestrationDelegation,
17
+ startCommitAttempt,
17
18
  statusCommitIntent,
18
- statusOrchestration
19
+ statusOrchestration,
20
+ terminateCommitAttempt
19
21
  } from '../task/orchestration.ts';
20
22
  import { isAgentClientId } from '../agent-clients/types.ts';
21
23
  import type { AgentClientId } from '../agent-clients/types.ts';
22
24
 
23
- const USAGE = 'Usage: agent-infra-internal task-orchestration <task-ref|auto> <begin-or-resume|route|prepare|hook-start|hook-stop|advance|pause|status|commit-begin|commit-checkpoint|commit-complete|commit-recover|commit-abort|commit-status> [options]\n';
25
+ const USAGE = 'Usage: agent-infra-internal task-orchestration <task-ref|auto> <begin-or-resume|route|prepare|hook-start|hook-stop|advance|pause|status|commit-start|commit-begin|commit-checkpoint|commit-complete|commit-recover|commit-abort|commit-terminate|commit-status> [options]\n';
24
26
 
25
27
  function usageFailure(message: string): void {
26
28
  process.stdout.write(`${JSON.stringify({
@@ -43,7 +45,7 @@ function taskOrchestration(args: string[] = []): void {
43
45
  const [taskRef, intent] = args;
44
46
  if (![
45
47
  'begin-or-resume', 'route', 'prepare', 'hook-start', 'hook-stop', 'advance', 'pause', 'status',
46
- 'commit-begin', 'commit-checkpoint', 'commit-complete', 'commit-recover', 'commit-abort', 'commit-status'
48
+ 'commit-start', 'commit-begin', 'commit-checkpoint', 'commit-complete', 'commit-recover', 'commit-abort', 'commit-terminate', 'commit-status'
47
49
  ].includes(intent!)) {
48
50
  usageFailure(`unknown intent '${intent}'`);
49
51
  return;
@@ -67,6 +69,7 @@ function taskOrchestration(args: string[] = []): void {
67
69
  '--model-fallback-reason', '--reasoning-effort-fallback-reason',
68
70
  '--exit-code', '--after-fingerprint', '--changed-paths', '--code', '--message', '--recoverable', '--agent',
69
71
  '--baseline-head', '--token', '--kind', '--head', '--remote', '--ref', '--expected-head'
72
+ , '--attempt', '--git-worktree-root'
70
73
  ].includes(flag)) {
71
74
  usageFailure(`unknown option '${flag}'`);
72
75
  return;
@@ -84,12 +87,14 @@ function taskOrchestration(args: string[] = []): void {
84
87
  values[flag] = value;
85
88
  }
86
89
  const commitIntentFlags: Record<string, readonly string[]> = {
87
- 'commit-begin': ['--agent', '--baseline-head', '--orchestrated'],
88
- 'commit-checkpoint': ['--token', '--kind', '--head', '--remote', '--ref'],
89
- 'commit-complete': ['--token', '--agent'],
90
- 'commit-recover': ['--agent'],
91
- 'commit-abort': ['--token', '--expected-head'],
92
- 'commit-status': []
90
+ 'commit-start': ['--agent', '--git-worktree-root'],
91
+ 'commit-begin': ['--agent', '--baseline-head', '--attempt', '--orchestrated', '--git-worktree-root'],
92
+ 'commit-checkpoint': ['--token', '--kind', '--head', '--remote', '--ref', '--git-worktree-root'],
93
+ 'commit-complete': ['--token', '--agent', '--git-worktree-root'],
94
+ 'commit-recover': ['--agent', '--git-worktree-root'],
95
+ 'commit-abort': ['--token', '--expected-head', '--git-worktree-root'],
96
+ 'commit-terminate': ['--attempt', '--agent', '--code', '--git-worktree-root'],
97
+ 'commit-status': ['--git-worktree-root']
93
98
  };
94
99
  if (intent! in commitIntentFlags) {
95
100
  const allowed = commitIntentFlags[intent!]!;
@@ -97,24 +102,33 @@ function taskOrchestration(args: string[] = []): void {
97
102
  if (invalid) { usageFailure(`intent '${intent}' does not accept '${invalid}'`); return; }
98
103
  } else {
99
104
  const commitOnly = [...seen].find((flag) => [
100
- '--orchestrated', '--baseline-head', '--token', '--kind', '--head', '--remote', '--ref', '--expected-head'
105
+ '--orchestrated', '--baseline-head', '--attempt', '--token', '--kind', '--head', '--remote', '--ref', '--expected-head'
101
106
  ].includes(flag));
102
107
  if (commitOnly) { usageFailure(`intent '${intent}' does not accept '${commitOnly}'`); return; }
103
108
  }
104
109
  const requireValues = (flags: string[]) => flags.find((flag) => values[flag] === undefined);
110
+ const coreOptions = values['--git-worktree-root'] === undefined
111
+ ? {}
112
+ : { gitWorktreeRoot: values['--git-worktree-root'] };
105
113
  if (values['--client'] !== undefined && !isAgentClientId(values['--client'])) {
106
114
  usageFailure(`unknown client '${values['--client']}'`);
107
115
  return;
108
116
  }
109
117
  let result;
110
- if (intent === 'commit-begin') {
111
- const missing = requireValues(['--agent', '--baseline-head']);
118
+ if (intent === 'commit-start') {
119
+ const missing = requireValues(['--agent']);
120
+ if (missing) { usageFailure(`intent 'commit-start' requires '${missing}'`); return; }
121
+ const agent = normalizeAgentToken(values['--agent']!);
122
+ if (!agent) { usageFailure(`invalid --agent '${values['--agent']}': ${AGENT_USAGE_HINT}`); return; }
123
+ result = startCommitAttempt(taskRef!, { agent }, coreOptions);
124
+ } else if (intent === 'commit-begin') {
125
+ const missing = requireValues(['--agent', '--baseline-head', '--attempt']);
112
126
  if (missing) { usageFailure(`intent 'commit-begin' requires '${missing}'`); return; }
113
127
  const agent = normalizeAgentToken(values['--agent']!);
114
128
  if (!agent) { usageFailure(`invalid --agent '${values['--agent']}': ${AGENT_USAGE_HINT}`); return; }
115
129
  result = beginCommitIntent(taskRef!, {
116
- agent, orchestrated, baselineHead: values['--baseline-head']!
117
- });
130
+ agent, orchestrated, baselineHead: values['--baseline-head']!, attempt: values['--attempt']!
131
+ }, coreOptions);
118
132
  } else if (intent === 'commit-checkpoint') {
119
133
  const missing = requireValues(['--token', '--kind', '--head']);
120
134
  if (missing) { usageFailure(`intent 'commit-checkpoint' requires '${missing}'`); return; }
@@ -130,25 +144,36 @@ function taskOrchestration(args: string[] = []): void {
130
144
  result = checkpointCommitIntent(taskRef!, {
131
145
  token: values['--token']!, kind: values['--kind'] as 'committed' | 'pushed',
132
146
  head: values['--head']!, remote: values['--remote'], ref: values['--ref']
133
- });
147
+ }, coreOptions);
134
148
  } else if (intent === 'commit-complete') {
135
149
  const missing = requireValues(['--token', '--agent']);
136
150
  if (missing) { usageFailure(`intent 'commit-complete' requires '${missing}'`); return; }
137
151
  const agent = normalizeAgentToken(values['--agent']!);
138
152
  if (!agent) { usageFailure(`invalid --agent '${values['--agent']}': ${AGENT_USAGE_HINT}`); return; }
139
- result = completeCommitIntent(taskRef!, { token: values['--token']!, agent });
153
+ result = completeCommitIntent(taskRef!, { token: values['--token']!, agent }, coreOptions);
140
154
  } else if (intent === 'commit-recover') {
141
155
  const missing = requireValues(['--agent']);
142
156
  if (missing) { usageFailure(`intent 'commit-recover' requires '${missing}'`); return; }
143
157
  const agent = normalizeAgentToken(values['--agent']!);
144
158
  if (!agent) { usageFailure(`invalid --agent '${values['--agent']}': ${AGENT_USAGE_HINT}`); return; }
145
- result = recoverCommitIntent(taskRef!, { agent });
159
+ result = recoverCommitIntent(taskRef!, { agent }, coreOptions);
146
160
  } else if (intent === 'commit-abort') {
147
161
  const missing = requireValues(['--token', '--expected-head']);
148
162
  if (missing) { usageFailure(`intent 'commit-abort' requires '${missing}'`); return; }
149
- result = abortCommitIntent(taskRef!, { token: values['--token']!, expectedHead: values['--expected-head']! });
163
+ result = abortCommitIntent(taskRef!, { token: values['--token']!, expectedHead: values['--expected-head']! }, coreOptions);
164
+ } else if (intent === 'commit-terminate') {
165
+ const missing = requireValues(['--attempt', '--agent', '--code']);
166
+ if (missing) { usageFailure(`intent 'commit-terminate' requires '${missing}'`); return; }
167
+ const agent = normalizeAgentToken(values['--agent']!);
168
+ if (!agent) { usageFailure(`invalid --agent '${values['--agent']}': ${AGENT_USAGE_HINT}`); return; }
169
+ if (!/^[A-Z][A-Z0-9_]{2,63}$/.test(values['--code']!)) {
170
+ usageFailure('--code must be a stable uppercase identifier'); return;
171
+ }
172
+ result = terminateCommitAttempt(taskRef!, {
173
+ attempt: values['--attempt']!, agent, code: values['--code']!
174
+ }, coreOptions);
150
175
  } else if (intent === 'commit-status') {
151
- result = statusCommitIntent(taskRef!);
176
+ result = statusCommitIntent(taskRef!, coreOptions);
152
177
  } else if (intent === 'begin-or-resume') {
153
178
  const missing = requireValues(['--client']);
154
179
  if (missing) { usageFailure(`intent 'begin-or-resume' requires '${missing}'`); return; }
@@ -166,6 +191,7 @@ function taskOrchestration(args: string[] = []): void {
166
191
  return;
167
192
  }
168
193
  result = beginOrResumeOrchestration(taskRef!, {
194
+ ...coreOptions,
169
195
  maxSteps,
170
196
  client: values['--client'] as AgentClientId,
171
197
  modelPolicy: hasAnyPolicy ? {
@@ -180,9 +206,9 @@ function taskOrchestration(args: string[] = []): void {
180
206
  } : undefined
181
207
  });
182
208
  } else if (intent === 'route') {
183
- result = routeOrchestration(taskRef!);
209
+ result = routeOrchestration(taskRef!, coreOptions);
184
210
  } else if (intent === 'status') {
185
- result = statusOrchestration(taskRef!);
211
+ result = statusOrchestration(taskRef!, coreOptions);
186
212
  } else if (intent === 'prepare') {
187
213
  const missing = requireValues(['--client']);
188
214
  if (missing) { usageFailure(`intent 'prepare' requires '${missing}'`); return; }
@@ -190,7 +216,7 @@ function taskOrchestration(args: string[] = []): void {
190
216
  client: values['--client'] as AgentClientId,
191
217
  requestedModel: values['--requested-model'],
192
218
  requestedReasoningEffort: values['--requested-reasoning-effort']
193
- });
219
+ }, coreOptions);
194
220
  } else if (intent === 'hook-start') {
195
221
  const missing = requireValues([
196
222
  ...(taskRef === 'auto' ? ['--client'] : []),
@@ -206,15 +232,15 @@ function taskOrchestration(args: string[] = []): void {
206
232
  reasoningEffortFallbackReason: values['--reasoning-effort-fallback-reason']
207
233
  };
208
234
  result = taskRef === 'auto'
209
- ? activateMatchingOrchestrationDelegation(values['--client'] as AgentClientId, event)
210
- : activateOrchestrationDelegation(taskRef!, event);
235
+ ? activateMatchingOrchestrationDelegation(values['--client'] as AgentClientId, event, coreOptions)
236
+ : activateOrchestrationDelegation(taskRef!, event, coreOptions);
211
237
  } else if (intent === 'hook-stop') {
212
238
  if (taskRef === 'auto') {
213
239
  const missing = requireValues(['--client', '--native-agent', '--child-id']);
214
240
  if (missing) { usageFailure(`intent 'hook-stop' requires '${missing}'`); return; }
215
241
  result = sealMatchingOrchestrationDelegation(values['--client'] as AgentClientId, {
216
242
  nativeAgent: values['--native-agent']!, childId: values['--child-id']!
217
- });
243
+ }, coreOptions);
218
244
  } else {
219
245
  const missing = requireValues(['--child-id', '--exit-code', '--after-fingerprint']);
220
246
  if (missing) { usageFailure(`intent 'hook-stop' requires '${missing}'`); return; }
@@ -224,15 +250,15 @@ function taskOrchestration(args: string[] = []): void {
224
250
  childId: values['--child-id']!, exitCode,
225
251
  afterFingerprint: values['--after-fingerprint']!,
226
252
  changedPaths: values['--changed-paths'] ? values['--changed-paths']!.split(',').filter(Boolean) : []
227
- });
253
+ }, coreOptions);
228
254
  }
229
255
  } else if (intent === 'advance') {
230
- result = advanceOrchestration(taskRef!);
256
+ result = advanceOrchestration(taskRef!, coreOptions);
231
257
  } else {
232
258
  const missing = requireValues(['--code', '--message', '--recoverable']);
233
259
  if (missing) { usageFailure(`intent 'pause' requires '${missing}'`); return; }
234
260
  if (!['true', 'false'].includes(values['--recoverable']!)) { usageFailure('--recoverable must be true or false'); return; }
235
- result = pauseOrchestration(taskRef!, values['--code']!, values['--message']!, values['--recoverable'] === 'true');
261
+ result = pauseOrchestration(taskRef!, values['--code']!, values['--message']!, values['--recoverable'] === 'true', coreOptions);
236
262
  }
237
263
  process.stdout.write(`${JSON.stringify(result)}\n`);
238
264
  if (result.status === 'failed') process.exitCode = 1;
@@ -1345,6 +1345,7 @@ export async function create(args: string[]): Promise<void> {
1345
1345
  const control = materializeSandboxControl({
1346
1346
  base: effectiveConfig.controlBase,
1347
1347
  repoRoot: effectiveConfig.repoRoot,
1348
+ worktreeRoot: worktree,
1348
1349
  project: effectiveConfig.project,
1349
1350
  container,
1350
1351
  branch,
@@ -5,37 +5,29 @@ import {
5
5
  SANDBOX_CONTROL_MAX_BYTES,
6
6
  isSandboxControlFamily,
7
7
  type SandboxControlRequest,
8
- type SandboxControlResponse
8
+ type SandboxControlResponse,
9
+ type SandboxTaskCreateRequest
9
10
  } from './protocol.ts';
11
+ import type { TaskCreateCandidateV1 } from '../../task/create.ts';
10
12
 
11
13
  function sleep(milliseconds: number): void {
12
14
  Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, milliseconds);
13
15
  }
14
16
 
15
- export function requestSandboxControl(params: Readonly<{
16
- family: string;
17
- args: string[];
17
+ function exchangeSandboxControl(request: SandboxControlRequest, params: Readonly<{
18
18
  channelDir?: string;
19
- token?: string;
20
19
  timeoutMs?: number;
21
20
  }>): SandboxControlResponse {
22
- if (!isSandboxControlFamily(params.family)) {
23
- throw new Error(`SANDBOX_CONTROL_COMMAND_DENIED: '${params.family}' is not allowed`);
24
- }
25
21
  const channelDir = params.channelDir ?? process.env.AGENT_INFRA_CONTROL_DIR ?? '/run/agent-infra/control';
26
- const token = params.token ?? process.env.AGENT_INFRA_CONTROL_TOKEN;
27
- if (!token) throw new Error('SANDBOX_CONTROL_UNAVAILABLE: control token is missing');
28
- const requestsDir = path.join(channelDir, 'requests');
29
- const responsesDir = path.join(channelDir, 'responses');
30
- const id = randomUUID();
31
- const request: SandboxControlRequest = { version: 1, id, token, family: params.family, args: params.args };
32
22
  const encoded = `${JSON.stringify(request)}\n`;
33
23
  if (Buffer.byteLength(encoded, 'utf8') > SANDBOX_CONTROL_MAX_BYTES) {
34
24
  throw new Error('SANDBOX_CONTROL_REQUEST_TOO_LARGE: request exceeds the control limit');
35
25
  }
36
- const temporary = path.join(requestsDir, `.${id}.tmp`);
37
- const requestPath = path.join(requestsDir, `${id}.json`);
38
- const responsePath = path.join(responsesDir, `${id}.json`);
26
+ const requestsDir = path.join(channelDir, 'requests');
27
+ const responsesDir = path.join(channelDir, 'responses');
28
+ const temporary = path.join(requestsDir, `.${request.id}.tmp`);
29
+ const requestPath = path.join(requestsDir, `${request.id}.json`);
30
+ const responsePath = path.join(responsesDir, `${request.id}.json`);
39
31
  fs.writeFileSync(temporary, encoded, { mode: 0o600, flag: 'wx' });
40
32
  fs.renameSync(temporary, requestPath);
41
33
  const deadline = Date.now() + (params.timeoutMs ?? 30_000);
@@ -44,7 +36,7 @@ export function requestSandboxControl(params: Readonly<{
44
36
  const raw = fs.readFileSync(responsePath, 'utf8');
45
37
  fs.unlinkSync(responsePath);
46
38
  const response = JSON.parse(raw) as SandboxControlResponse;
47
- if (response.version !== 1 || response.id !== id || !Number.isInteger(response.exitCode)) {
39
+ if (response.version !== 1 || response.id !== request.id || !Number.isInteger(response.exitCode)) {
48
40
  throw new Error('SANDBOX_CONTROL_RESPONSE_INVALID: broker response is invalid');
49
41
  }
50
42
  return response;
@@ -53,3 +45,35 @@ export function requestSandboxControl(params: Readonly<{
53
45
  }
54
46
  throw new Error(`SANDBOX_CONTROL_TIMEOUT: broker did not respond within ${params.timeoutMs ?? 30_000}ms`);
55
47
  }
48
+
49
+ export function requestSandboxControl(params: Readonly<{
50
+ family: string;
51
+ args: string[];
52
+ channelDir?: string;
53
+ token?: string;
54
+ timeoutMs?: number;
55
+ }>): SandboxControlResponse {
56
+ if (!isSandboxControlFamily(params.family)) {
57
+ throw new Error(`SANDBOX_CONTROL_COMMAND_DENIED: '${params.family}' is not allowed`);
58
+ }
59
+ if (params.family === 'task-create') throw new Error("SANDBOX_CONTROL_COMMAND_DENIED: 'task-create' requires a typed candidate");
60
+ const token = params.token ?? process.env.AGENT_INFRA_CONTROL_TOKEN;
61
+ if (!token) throw new Error('SANDBOX_CONTROL_UNAVAILABLE: control token is missing');
62
+ const id = randomUUID();
63
+ const request: SandboxControlRequest = { version: 1, id, token, family: params.family, args: params.args };
64
+ return exchangeSandboxControl(request, params);
65
+ }
66
+
67
+ export function requestSandboxTaskCreate(params: Readonly<{
68
+ candidate: TaskCreateCandidateV1;
69
+ channelDir?: string;
70
+ token?: string;
71
+ timeoutMs?: number;
72
+ }>): SandboxControlResponse {
73
+ const token = params.token ?? process.env.AGENT_INFRA_CONTROL_TOKEN;
74
+ if (!token) throw new Error('SANDBOX_CONTROL_UNAVAILABLE: control token is missing');
75
+ const request: SandboxTaskCreateRequest = {
76
+ version: 1, id: randomUUID(), token, family: 'task-create', candidate: params.candidate
77
+ };
78
+ return exchangeSandboxControl(request, { ...params, timeoutMs: params.timeoutMs ?? 120_000 });
79
+ }
@@ -1,11 +1,14 @@
1
+ import { validateTaskCreateCandidate, type TaskCreateCandidateV1 } from '../../task/create.ts';
2
+
1
3
  export const SANDBOX_CONTROL_MAX_BYTES = 64 * 1024;
2
- export const SANDBOX_CONTROL_FAMILIES = ['task-lifecycle', 'task-orchestration'] as const;
4
+ export const SANDBOX_CONTROL_FAMILIES = ['task-lifecycle', 'task-orchestration', 'task-create'] as const;
3
5
 
4
6
  export type SandboxControlFamily = typeof SANDBOX_CONTROL_FAMILIES[number];
5
7
 
6
8
  export type SandboxControlManifest = Readonly<{
7
- version: 1;
9
+ version: 2;
8
10
  repoRoot: string;
11
+ worktreeRoot: string;
9
12
  project: string;
10
13
  container: string;
11
14
  branch: string;
@@ -15,14 +18,24 @@ export type SandboxControlManifest = Readonly<{
15
18
  channelDir: string;
16
19
  }>;
17
20
 
18
- export type SandboxControlRequest = Readonly<{
21
+ export type SandboxTaskCommandRequest = Readonly<{
19
22
  version: 1;
20
23
  id: string;
21
24
  token: string;
22
- family: SandboxControlFamily;
25
+ family: 'task-lifecycle' | 'task-orchestration';
23
26
  args: string[];
24
27
  }>;
25
28
 
29
+ export type SandboxTaskCreateRequest = Readonly<{
30
+ version: 1;
31
+ id: string;
32
+ token: string;
33
+ family: 'task-create';
34
+ candidate: TaskCreateCandidateV1;
35
+ }>;
36
+
37
+ export type SandboxControlRequest = SandboxTaskCommandRequest | SandboxTaskCreateRequest;
38
+
26
39
  export type SandboxControlResponse = Readonly<{
27
40
  version: 1;
28
41
  id: string;
@@ -42,7 +55,7 @@ export function validateSandboxControlRequest(
42
55
  if (!value || typeof value !== 'object' || Array.isArray(value)) {
43
56
  throw new Error('SANDBOX_CONTROL_REQUEST_INVALID: request must be an object');
44
57
  }
45
- const request = value as Partial<SandboxControlRequest>;
58
+ const request = value as Record<string, unknown>;
46
59
  if (
47
60
  request.version !== 1
48
61
  || typeof request.id !== 'string'
@@ -50,6 +63,22 @@ export function validateSandboxControlRequest(
50
63
  || request.token !== manifest.token
51
64
  || typeof request.family !== 'string'
52
65
  || !isSandboxControlFamily(request.family)
66
+ ) {
67
+ throw new Error('SANDBOX_CONTROL_REQUEST_INVALID: request schema or authorization is invalid');
68
+ }
69
+ if (Buffer.byteLength(JSON.stringify(request), 'utf8') > SANDBOX_CONTROL_MAX_BYTES) {
70
+ throw new Error('SANDBOX_CONTROL_REQUEST_TOO_LARGE: request exceeds the control limit');
71
+ }
72
+ if (request.family === 'task-create') {
73
+ const keys = Object.keys(request).sort().join(',');
74
+ if (keys !== ['candidate', 'family', 'id', 'token', 'version'].sort().join(',')) {
75
+ throw new Error('SANDBOX_CONTROL_REQUEST_INVALID: request schema or authorization is invalid');
76
+ }
77
+ return { ...request, candidate: validateTaskCreateCandidate(request.candidate) } as SandboxTaskCreateRequest;
78
+ }
79
+ const keys = Object.keys(request).sort().join(',');
80
+ if (
81
+ keys !== ['args', 'family', 'id', 'token', 'version'].sort().join(',')
53
82
  || !Array.isArray(request.args)
54
83
  || !request.args.every((arg) => typeof arg === 'string')
55
84
  ) {
@@ -58,16 +87,22 @@ export function validateSandboxControlRequest(
58
87
  if (manifest.mode !== 'task-bound' || !manifest.taskId) {
59
88
  throw new Error('SANDBOX_CONTROL_BRANCH_ONLY: branch-only sandboxes cannot coordinate tasks');
60
89
  }
61
- if (Buffer.byteLength(JSON.stringify(request), 'utf8') > SANDBOX_CONTROL_MAX_BYTES) {
62
- throw new Error('SANDBOX_CONTROL_REQUEST_TOO_LARGE: request exceeds the control limit');
90
+ if (
91
+ request.family === 'task-orchestration'
92
+ && request.args.some((arg) => arg === '--git-worktree-root' || arg.startsWith('--git-worktree-root='))
93
+ ) {
94
+ throw new Error('SANDBOX_CONTROL_REQUEST_INVALID: worktree binding is reserved for the control broker');
63
95
  }
64
- return request as SandboxControlRequest;
96
+ return request as SandboxTaskCommandRequest;
65
97
  }
66
98
 
67
99
  export function bindSandboxControlTask(
68
100
  request: SandboxControlRequest,
69
101
  taskId: string
70
102
  ): string[] {
103
+ if (request.family === 'task-create') {
104
+ throw new Error('SANDBOX_CONTROL_REQUEST_INVALID: task-create requests do not bind a current task');
105
+ }
71
106
  if (request.args.length === 0) {
72
107
  throw new Error('SANDBOX_CONTROL_REQUEST_INVALID: command arguments are required');
73
108
  }