@ashulab/carrier 0.0.0-stage → 0.1.1
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/LICENSE +21 -0
- package/README.md +265 -2
- package/dist/chat/claude.js +367 -0
- package/dist/chat/codex.js +375 -0
- package/dist/chat/config.js +61 -0
- package/dist/chat/db.js +9 -0
- package/dist/chat/fake.js +203 -0
- package/dist/chat/folder.js +56 -0
- package/dist/chat/git.js +27 -0
- package/dist/chat/group.js +28 -0
- package/dist/chat/health.js +49 -0
- package/dist/chat/host.js +123 -0
- package/dist/chat/idle.js +20 -0
- package/dist/chat/migrations/0000_lame_quasar.sql +15 -0
- package/dist/chat/migrations/0001_quiet_penance.sql +23 -0
- package/dist/chat/migrations/0002_slim_ezekiel_stane.sql +1 -0
- package/dist/chat/migrations/0003_naive_iron_lad.sql +5 -0
- package/dist/chat/migrations/0004_skinny_dakota_north.sql +1 -0
- package/dist/chat/migrations/0005_tidy_strong_guy.sql +1 -0
- package/dist/chat/migrations/meta/0000_snapshot.json +121 -0
- package/dist/chat/migrations/meta/0001_snapshot.json +277 -0
- package/dist/chat/migrations/meta/0002_snapshot.json +284 -0
- package/dist/chat/migrations/meta/0003_snapshot.json +320 -0
- package/dist/chat/migrations/meta/0004_snapshot.json +327 -0
- package/dist/chat/migrations/meta/0005_snapshot.json +334 -0
- package/dist/chat/migrations/meta/_journal.json +48 -0
- package/dist/chat/names.js +97 -0
- package/dist/chat/plan.js +21 -0
- package/dist/chat/projects.js +242 -0
- package/dist/chat/runner.js +6 -0
- package/dist/chat/schema.js +48 -0
- package/dist/chat/server.js +202 -0
- package/dist/chat/sessions.js +766 -0
- package/dist/chat/sweep.js +64 -0
- package/dist/chat/transcript.js +31 -0
- package/dist/chat/words.js +20 -0
- package/dist/chat/workspace.js +134 -0
- package/dist/chat/worktree.js +142 -0
- package/dist/config/default-path.js +7 -0
- package/dist/config/engine-places.js +9 -0
- package/dist/config/json-schema.js +15 -0
- package/dist/config/load.js +48 -0
- package/dist/config/resolve.js +41 -0
- package/dist/config/schema.js +225 -0
- package/dist/control/epic.js +219 -0
- package/dist/control/events.js +48 -0
- package/dist/control/files.js +262 -0
- package/dist/control/replies.js +1 -0
- package/dist/control/server.js +237 -0
- package/dist/control/sessions.js +68 -0
- package/dist/doctor/doctor.js +255 -0
- package/dist/doctor/fixes.js +35 -0
- package/dist/execution/agent-runner.js +1 -0
- package/dist/execution/caller-result.js +61 -0
- package/dist/execution/events.js +19 -0
- package/dist/execution/failure.js +3 -0
- package/dist/execution/fake.js +82 -0
- package/dist/execution/git-workspaces.js +136 -0
- package/dist/execution/import-result.js +23 -0
- package/dist/execution/workspaces.js +1 -0
- package/dist/format/duration.js +12 -0
- package/dist/harnesses/agy.js +131 -0
- package/dist/harnesses/claude.js +246 -0
- package/dist/harnesses/codex.js +118 -0
- package/dist/harnesses/common.js +59 -0
- package/dist/harnesses/credentials.js +38 -0
- package/dist/harnesses/host-runner.js +225 -0
- package/dist/harnesses/local.js +25 -0
- package/dist/harnesses/names.js +9 -0
- package/dist/harnesses/replay-events.js +57 -0
- package/dist/integrations/available-repos.js +29 -0
- package/dist/integrations/forge-cwd.js +27 -0
- package/dist/integrations/github-graphql.js +216 -0
- package/dist/integrations/github.js +231 -0
- package/dist/logging/logger.js +53 -0
- package/dist/logging/redact.js +21 -0
- package/dist/orchestration/blockers.js +25 -0
- package/dist/orchestration/changes-slices.js +5 -0
- package/dist/orchestration/clock.js +7 -0
- package/dist/orchestration/engine.js +1068 -0
- package/dist/orchestration/fake.js +152 -0
- package/dist/orchestration/final-pr.js +15 -0
- package/dist/orchestration/github.js +31 -0
- package/dist/orchestration/reserve.js +32 -0
- package/dist/orchestration/resume-refused.js +7 -0
- package/dist/orchestration/retention.js +145 -0
- package/dist/orchestration/retry.js +16 -0
- package/dist/prompts/build-prompt.js +13 -0
- package/dist/prompts/fix.js +21 -0
- package/dist/prompts/intro.js +21 -0
- package/dist/prompts/invoke.js +2 -0
- package/dist/prompts/persona.js +12 -0
- package/dist/prompts/result.js +32 -0
- package/dist/prompts/review.js +15 -0
- package/dist/prompts/ship.js +8 -0
- package/dist/status/snapshot.js +92 -0
- package/dist/status/types.js +1 -0
- package/dist/storage/caller-results.js +7 -0
- package/dist/storage/db.js +8 -0
- package/dist/storage/epic-review-threads.js +19 -0
- package/dist/storage/epic-slices.js +17 -0
- package/dist/storage/events.js +52 -0
- package/dist/storage/executions.js +31 -0
- package/dist/storage/lock.js +21 -0
- package/dist/storage/migrations/0000_nosy_demogoblin.sql +5 -0
- package/dist/storage/migrations/0001_worried_grey_gargoyle.sql +3 -0
- package/dist/storage/migrations/0002_tough_squirrel_girl.sql +5 -0
- package/dist/storage/migrations/0003_loose_maria_hill.sql +4 -0
- package/dist/storage/migrations/0004_long_cerise.sql +18 -0
- package/dist/storage/migrations/0005_review_result.sql +2 -0
- package/dist/storage/migrations/0006_melted_menace.sql +10 -0
- package/dist/storage/migrations/0007_powerful_bloodscream.sql +2 -0
- package/dist/storage/migrations/0008_panel_state.sql +4 -0
- package/dist/storage/migrations/0009_workable_tiger_shark.sql +5 -0
- package/dist/storage/migrations/0010_nasty_blue_marvel.sql +4 -0
- package/dist/storage/migrations/0011_steep_lila_cheney.sql +13 -0
- package/dist/storage/migrations/0012_talented_big_bertha.sql +1 -0
- package/dist/storage/migrations/0013_third_mulholland_black.sql +17 -0
- package/dist/storage/migrations/0014_perfect_marten_broadcloak.sql +20 -0
- package/dist/storage/migrations/0015_long_bloodaxe.sql +1 -0
- package/dist/storage/migrations/0016_neat_true_believers.sql +5 -0
- package/dist/storage/migrations/0017_previous_swarm.sql +6 -0
- package/dist/storage/migrations/0018_opposite_plazm.sql +2 -0
- package/dist/storage/migrations/0019_hesitant_triton.sql +3 -0
- package/dist/storage/migrations/meta/0000_snapshot.json +49 -0
- package/dist/storage/migrations/meta/0001_snapshot.json +74 -0
- package/dist/storage/migrations/meta/0002_snapshot.json +105 -0
- package/dist/storage/migrations/meta/0003_snapshot.json +134 -0
- package/dist/storage/migrations/meta/0004_snapshot.json +257 -0
- package/dist/storage/migrations/meta/0005_snapshot.json +271 -0
- package/dist/storage/migrations/meta/0006_snapshot.json +337 -0
- package/dist/storage/migrations/meta/0007_snapshot.json +351 -0
- package/dist/storage/migrations/meta/0008_snapshot.json +379 -0
- package/dist/storage/migrations/meta/0009_snapshot.json +414 -0
- package/dist/storage/migrations/meta/0010_snapshot.json +442 -0
- package/dist/storage/migrations/meta/0011_snapshot.json +531 -0
- package/dist/storage/migrations/meta/0012_snapshot.json +538 -0
- package/dist/storage/migrations/meta/0013_snapshot.json +655 -0
- package/dist/storage/migrations/meta/0014_snapshot.json +772 -0
- package/dist/storage/migrations/meta/0015_snapshot.json +779 -0
- package/dist/storage/migrations/meta/0016_snapshot.json +793 -0
- package/dist/storage/migrations/meta/0017_snapshot.json +835 -0
- package/dist/storage/migrations/meta/0018_snapshot.json +849 -0
- package/dist/storage/migrations/meta/0019_snapshot.json +870 -0
- package/dist/storage/migrations/meta/_journal.json +146 -0
- package/dist/storage/schema.js +43 -0
- package/dist/terminal/cli/args.js +91 -0
- package/dist/terminal/cli/cli-error.js +11 -0
- package/dist/terminal/cli/commands/chat-host.js +18 -0
- package/dist/terminal/cli/commands/chat.js +321 -0
- package/dist/terminal/cli/commands/control.js +46 -0
- package/dist/terminal/cli/commands/doctor.js +15 -0
- package/dist/terminal/cli/commands/init.js +43 -0
- package/dist/terminal/cli/commands/restart.js +92 -0
- package/dist/terminal/cli/commands/run.js +20 -0
- package/dist/terminal/cli/commands/schema.js +4 -0
- package/dist/terminal/cli/commands/start.js +21 -0
- package/dist/terminal/cli/commands/status.js +61 -0
- package/dist/terminal/cli/commands/watch.js +112 -0
- package/dist/terminal/cli/compose.js +163 -0
- package/dist/terminal/cli/config-file.js +24 -0
- package/dist/terminal/cli/control-client.js +94 -0
- package/dist/terminal/cli/detached.js +41 -0
- package/dist/terminal/cli/interrupt.js +39 -0
- package/dist/terminal/cli/main.js +85 -0
- package/dist/terminal/cli/open-url.js +9 -0
- package/dist/terminal/dashboard/dashboard.js +204 -0
- package/dist/terminal/dashboard/history.js +38 -0
- package/dist/terminal/dashboard/interactive.js +60 -0
- package/dist/terminal/dashboard/keys.js +22 -0
- package/dist/terminal/dashboard/logs.js +85 -0
- package/dist/terminal/dashboard/panel.js +62 -0
- package/dist/terminal/dashboard/plain.js +32 -0
- package/dist/terminal/dashboard/printable.js +2 -0
- package/dist/terminal/dashboard/ui-state.js +110 -0
- package/dist/terminal/prompts/clack.js +121 -0
- package/dist/terminal/prompts/types.js +3 -0
- package/dist/terminal/wizard/build-config.js +66 -0
- package/dist/terminal/wizard/deps.js +94 -0
- package/dist/terminal/wizard/flags.js +1 -0
- package/dist/terminal/wizard/forge-registry.js +23 -0
- package/dist/terminal/wizard/prior.js +20 -0
- package/dist/terminal/wizard/questions/agent.js +61 -0
- package/dist/terminal/wizard/questions/identities.js +28 -0
- package/dist/terminal/wizard/questions/identity.js +30 -0
- package/dist/terminal/wizard/questions/options.js +93 -0
- package/dist/terminal/wizard/questions/out.js +16 -0
- package/dist/terminal/wizard/questions/persona.js +47 -0
- package/dist/terminal/wizard/questions/repos.js +61 -0
- package/dist/terminal/wizard/summary.js +31 -0
- package/dist/terminal/wizard/testing.js +106 -0
- package/dist/terminal/wizard/wizard.js +172 -0
- package/package.json +46 -3
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
import { tmpdir } from 'node:os';
|
|
2
|
+
import { dirname, join } from 'node:path';
|
|
3
|
+
import { buildPrompt } from '../prompts/build-prompt.js';
|
|
4
|
+
import { childEnv, markerFor } from './common.js';
|
|
5
|
+
import { boundOutput, caps, clean } from '../execution/events.js';
|
|
6
|
+
/**
|
|
7
|
+
* Headless Antigravity as in docs/research/antigravity.md; stdin is closed by the runner.
|
|
8
|
+
* `agy` has no flag to name a run, so the marker rides in the path of its log file, which is part of argv.
|
|
9
|
+
* Permissions are skipped explicitly (ADR 0004) instead of relying on the operator's `toolPermission` setting, and
|
|
10
|
+
* there is no sandbox to turn off: `--sandbox` is opt-in. Resume restores context from any cwd, like Claude.
|
|
11
|
+
*/
|
|
12
|
+
export function buildAgyCommand(task, ctx) {
|
|
13
|
+
return {
|
|
14
|
+
bin: ctx.binary,
|
|
15
|
+
args: [
|
|
16
|
+
'-p',
|
|
17
|
+
buildPrompt(task, { forgeAgent: ctx.forgeAgent }),
|
|
18
|
+
// stream-json (not json) so the conversation id arrives while the run is going.
|
|
19
|
+
'--output-format',
|
|
20
|
+
'stream-json',
|
|
21
|
+
'--model',
|
|
22
|
+
task.model,
|
|
23
|
+
'--dangerously-skip-permissions',
|
|
24
|
+
'--add-dir',
|
|
25
|
+
dirname(task.resultPath),
|
|
26
|
+
'--log-file',
|
|
27
|
+
join(tmpdir(), `${markerFor(task.executionId)}.log`), // agy's own log, not ours to keep: the OS cleans the temp folder
|
|
28
|
+
...(task.resumeSession ? ['--conversation', task.resumeSession] : []),
|
|
29
|
+
],
|
|
30
|
+
cwd: task.workspace,
|
|
31
|
+
env: { ...childEnv(ctx), CARRIER_RESULT_PATH: task.resultPath },
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
const usageLimitTexts = ['AI credits balance is too low', 'individual quota reached', 'exhausted your quota'];
|
|
35
|
+
// Shapes observed with agy 1.4.0: the first stdout line is `init` with the conversation id, written before the model is
|
|
36
|
+
// called; a missing login prints "Authentication required" on stderr and ~60 s later ends with a `result` whose error is
|
|
37
|
+
// "authentication failed or timed out"; resuming an unknown id does NOT fail: it exits 0 in a new conversation and only
|
|
38
|
+
// says so on stderr. Both streams go through here.
|
|
39
|
+
export function parseAgyLine(line) {
|
|
40
|
+
if (line.includes('Authentication required'))
|
|
41
|
+
return { authFailed: true };
|
|
42
|
+
if (/warning: conversation ".*" not found/.test(line))
|
|
43
|
+
return { sessionUnavailable: true };
|
|
44
|
+
let event;
|
|
45
|
+
try {
|
|
46
|
+
event = JSON.parse(line);
|
|
47
|
+
}
|
|
48
|
+
catch {
|
|
49
|
+
return undefined;
|
|
50
|
+
}
|
|
51
|
+
if (event.event === 'init' && typeof event.conversation_id === 'string')
|
|
52
|
+
return { session: event.conversation_id };
|
|
53
|
+
if (event.event === 'result' && event.result?.status === 'ERROR') {
|
|
54
|
+
const error = str(event.result.error);
|
|
55
|
+
if (error === 'authentication failed or timed out')
|
|
56
|
+
return { authFailed: true };
|
|
57
|
+
// No reset time in the text (docs/research/agent-usage-limit-signals.md).
|
|
58
|
+
const usageLimit = error !== undefined && usageLimitTexts.some((text) => error.includes(text));
|
|
59
|
+
return { failure: { kind: usageLimit ? 'usage_limit' : 'api_error', detail: error ?? 'the run ended: ERROR' } };
|
|
60
|
+
}
|
|
61
|
+
return undefined;
|
|
62
|
+
}
|
|
63
|
+
const str = (value) => (typeof value === 'string' ? value : undefined);
|
|
64
|
+
const obj = (value) => (typeof value === 'object' && value !== null ? value : {});
|
|
65
|
+
// What the call acts on: the command or the path, else the first text parameter of a tool not seen before.
|
|
66
|
+
function argOf(parameters) {
|
|
67
|
+
return str(parameters.CommandLine) ?? str(parameters.Path) ?? Object.values(parameters).find((value) => typeof value === 'string') ?? '';
|
|
68
|
+
}
|
|
69
|
+
const exitCode = (output) => /^Command exited with code (\d+)/.exec(output)?.[1];
|
|
70
|
+
function agyEvents(line, state) {
|
|
71
|
+
const event = obj(JSON.parse(line));
|
|
72
|
+
if (event.event === 'result') {
|
|
73
|
+
// A turn left open (the run was cut short) still said what it said.
|
|
74
|
+
const pending = [...state.text.values()].join('').trim();
|
|
75
|
+
state.text.clear();
|
|
76
|
+
const { status, error } = obj(event.result);
|
|
77
|
+
const failed = str(error) ?? (str(status) !== undefined && status !== 'SUCCESS' ? `the run ended: ${status}` : undefined);
|
|
78
|
+
return [...(pending ? [{ kind: 'say', text: clean(pending, caps.text) }] : []), ...(failed === undefined ? [] : [{ kind: 'error', text: clean(failed, caps.text) }])];
|
|
79
|
+
}
|
|
80
|
+
if (event.event !== 'step_update')
|
|
81
|
+
return []; // init, and whatever a newer agy adds
|
|
82
|
+
const step = obj(event.step_update);
|
|
83
|
+
const key = `${str(step.conversation_id) ?? ''}:${String(step.step_index)}`;
|
|
84
|
+
const done = step.state === 'DONE';
|
|
85
|
+
const delta = str(step.text_delta) ?? '';
|
|
86
|
+
if (step.step_type === 'agent_response') {
|
|
87
|
+
const text = (state.text.get(key) ?? '') + delta;
|
|
88
|
+
if (!done) {
|
|
89
|
+
state.text.set(key, text);
|
|
90
|
+
return [];
|
|
91
|
+
}
|
|
92
|
+
state.text.delete(key);
|
|
93
|
+
return text.trim() === '' ? [] : [{ kind: 'say', text: clean(text.trim(), caps.text) }];
|
|
94
|
+
}
|
|
95
|
+
if (step.step_type !== 'tool')
|
|
96
|
+
return []; // user_input, and step types not seen yet: nothing to record
|
|
97
|
+
const info = obj(step.tool_info);
|
|
98
|
+
const tool = str(step.tool_name) ?? str(info.name) ?? 'tool';
|
|
99
|
+
if (!done) {
|
|
100
|
+
state.calls.set(key, { tool });
|
|
101
|
+
return [{ kind: 'tool_start', callId: key, tool, arg: clean(argOf(obj(info.parameters)), caps.arg) }];
|
|
102
|
+
}
|
|
103
|
+
state.calls.delete(key);
|
|
104
|
+
const output = str(info.output) ?? '';
|
|
105
|
+
const code = tool === 'run_command' ? exitCode(output) : undefined;
|
|
106
|
+
// A command's own output is what the design collapses; a read returns only a size, an edit nothing.
|
|
107
|
+
const body = code === undefined ? [] : boundOutput(output.slice(output.indexOf('Command output:') + 'Command output:'.length).replace(/\r\n/g, '\n').replace(/^\n/, ''));
|
|
108
|
+
const first = output.split('\n').find((l) => l.trim() !== '');
|
|
109
|
+
const summary = code === undefined ? first && clean(first, caps.summary) : `Exit code ${code}`;
|
|
110
|
+
return [
|
|
111
|
+
{
|
|
112
|
+
kind: 'tool_end',
|
|
113
|
+
callId: key,
|
|
114
|
+
tool,
|
|
115
|
+
status: code === undefined || code === '0' ? 'ok' : 'err',
|
|
116
|
+
...(typeof step.duration_seconds === 'number' && { durationMs: Math.round(step.duration_seconds * 1000) }),
|
|
117
|
+
...(summary && { summary }),
|
|
118
|
+
...(body.length > 0 && { output: body }),
|
|
119
|
+
},
|
|
120
|
+
];
|
|
121
|
+
}
|
|
122
|
+
/** Events of one stream-json line (docs/research/antigravity-stream.md). Never throws. */
|
|
123
|
+
export function parseAgyEvents(line, state) {
|
|
124
|
+
try {
|
|
125
|
+
return agyEvents(line, state);
|
|
126
|
+
}
|
|
127
|
+
catch {
|
|
128
|
+
return []; // not JSON, or a shape this version of agy changed: the log keeps it, the events skip it
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
export const agyAdapter = { command: buildAgyCommand, parseLine: parseAgyLine, parseEvents: parseAgyEvents };
|
|
@@ -0,0 +1,246 @@
|
|
|
1
|
+
import { dirname } from 'node:path';
|
|
2
|
+
import { buildPrompt } from '../prompts/build-prompt.js';
|
|
3
|
+
import { claudeLocalEnv } from './local.js';
|
|
4
|
+
import { childEnv, markerFor } from './common.js';
|
|
5
|
+
import { boundOutput, caps, clean } from '../execution/events.js';
|
|
6
|
+
/** Headless Claude as in docs/research/headless-agents.md; stdin is closed by the runner. */
|
|
7
|
+
export function buildClaudeCommand(task, ctx) {
|
|
8
|
+
return {
|
|
9
|
+
bin: ctx.binary,
|
|
10
|
+
args: [
|
|
11
|
+
'-p',
|
|
12
|
+
buildPrompt(task, { forgeAgent: ctx.forgeAgent }),
|
|
13
|
+
'--permission-mode',
|
|
14
|
+
'bypassPermissions',
|
|
15
|
+
// stream-json (not json) so the session id arrives while the run is going.
|
|
16
|
+
'--output-format',
|
|
17
|
+
'stream-json',
|
|
18
|
+
'--verbose',
|
|
19
|
+
'--model',
|
|
20
|
+
task.model,
|
|
21
|
+
'--add-dir',
|
|
22
|
+
dirname(task.resultPath),
|
|
23
|
+
'--name',
|
|
24
|
+
markerFor(task.executionId),
|
|
25
|
+
...(task.resumeSession ? ['--resume', task.resumeSession] : []),
|
|
26
|
+
],
|
|
27
|
+
cwd: task.workspace,
|
|
28
|
+
env: { ...childEnv(ctx), ...(task.provider ? claudeLocalEnv(task.provider) : {}), ...(task.configDir && { CLAUDE_CONFIG_DIR: task.configDir }), CARRIER_RESULT_PATH: task.resultPath },
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
// Shapes observed with claude 2.1.301: init carries the session; a login or key error is an assistant
|
|
32
|
+
// message with error "authentication_failed" and a result with api_error_status 401; a lost session is
|
|
33
|
+
// a result "error_during_execution" saying "No conversation found"; an API error is a result with a numeric
|
|
34
|
+
// api_error_status (429 with api_error "usage_limit_reached" is the session limit) and the message in result.
|
|
35
|
+
export function parseClaudeLine(line) {
|
|
36
|
+
let event;
|
|
37
|
+
try {
|
|
38
|
+
event = JSON.parse(line);
|
|
39
|
+
}
|
|
40
|
+
catch {
|
|
41
|
+
return undefined;
|
|
42
|
+
}
|
|
43
|
+
if (event.type === 'system' && event.subtype === 'init' && typeof event.session_id === 'string')
|
|
44
|
+
return { session: event.session_id };
|
|
45
|
+
if (event.type === 'assistant' && event.error === 'authentication_failed')
|
|
46
|
+
return { authFailed: true };
|
|
47
|
+
if (event.type === 'result') {
|
|
48
|
+
if (event.api_error_status === 401)
|
|
49
|
+
return { authFailed: true };
|
|
50
|
+
if (event.is_error === true && JSON.stringify(event.errors ?? '').includes('No conversation found'))
|
|
51
|
+
return { sessionUnavailable: true };
|
|
52
|
+
if (event.is_error === true && typeof event.api_error_status === 'number') {
|
|
53
|
+
const text = typeof event.result === 'string' ? event.result : '';
|
|
54
|
+
if (event.api_error_status === 429 && event.api_error === 'usage_limit_reached')
|
|
55
|
+
return { failure: { kind: 'usage_limit', detail: text } };
|
|
56
|
+
return { failure: { kind: 'api_error', detail: `HTTP ${event.api_error_status}${text ? `: ${text}` : ''}` } };
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
return undefined;
|
|
60
|
+
}
|
|
61
|
+
function blocksOf(line) {
|
|
62
|
+
const content = line.message?.content;
|
|
63
|
+
return Array.isArray(content) ? content : [];
|
|
64
|
+
}
|
|
65
|
+
const sliceIn = (description) => {
|
|
66
|
+
const slice = /slice #(\d+)/i.exec(description)?.[1];
|
|
67
|
+
return slice === undefined ? undefined : Number(slice);
|
|
68
|
+
};
|
|
69
|
+
const laneIn = (blocks) => {
|
|
70
|
+
const lane = /--lane-(\d+)/.exec(JSON.stringify(blocks.filter((block) => block.type === 'tool_use').map((block) => block.input)))?.[1];
|
|
71
|
+
return lane === undefined ? undefined : Number(lane);
|
|
72
|
+
};
|
|
73
|
+
/**
|
|
74
|
+
* Which subagent, lane and slice each parsed stream line belongs to (docs/research/claude-subagent-attribution.md).
|
|
75
|
+
* Exact: `agent_id` on a subagent's messages; `parent_tool_use_id` points back to its `Agent` call.
|
|
76
|
+
* Hints, from the model's own conventions: the slice is "slice #N" in that call's description, the lane is
|
|
77
|
+
* `--lane-N` in a path the subagent touched. Both stay undefined when the convention was not followed.
|
|
78
|
+
*/
|
|
79
|
+
export function attributeSubagentLines(lines) {
|
|
80
|
+
const callDescription = new Map();
|
|
81
|
+
const laneOfAgent = new Map();
|
|
82
|
+
for (const line of lines) {
|
|
83
|
+
const agent = line.agent_id;
|
|
84
|
+
if (typeof agent === 'string' && !laneOfAgent.has(agent)) {
|
|
85
|
+
const lane = laneIn(blocksOf(line));
|
|
86
|
+
if (lane !== undefined)
|
|
87
|
+
laneOfAgent.set(agent, lane);
|
|
88
|
+
}
|
|
89
|
+
for (const block of blocksOf(line)) {
|
|
90
|
+
if (block.type === 'tool_use' && block.name === 'Agent' && typeof block.id === 'string' && typeof block.input?.description === 'string') {
|
|
91
|
+
callDescription.set(block.id, block.input.description);
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
return lines.map((line) => {
|
|
96
|
+
const parent = typeof line.parent_tool_use_id === 'string' ? line.parent_tool_use_id : undefined;
|
|
97
|
+
const agentId = typeof line.agent_id === 'string' ? line.agent_id : null;
|
|
98
|
+
if (agentId === null)
|
|
99
|
+
return { agentId };
|
|
100
|
+
const description = (parent && callDescription.get(parent)) ?? (typeof line.task_description === 'string' ? line.task_description : '');
|
|
101
|
+
const slice = sliceIn(description);
|
|
102
|
+
const lane = laneOfAgent.get(agentId);
|
|
103
|
+
return { agentId, ...(lane === undefined ? {} : { lane }), ...(slice === undefined ? {} : { slice }) };
|
|
104
|
+
});
|
|
105
|
+
}
|
|
106
|
+
const str = (value) => (typeof value === 'string' ? value : undefined);
|
|
107
|
+
// The one-line argument of a call: what it acts on.
|
|
108
|
+
function argOf(input) {
|
|
109
|
+
if (!input)
|
|
110
|
+
return '';
|
|
111
|
+
for (const key of ['command', 'file_path', 'pattern', 'path', 'url', 'query', 'description']) {
|
|
112
|
+
const value = str(input[key]);
|
|
113
|
+
if (value !== undefined)
|
|
114
|
+
return value;
|
|
115
|
+
}
|
|
116
|
+
return Object.values(input).find((value) => typeof value === 'string') ?? '';
|
|
117
|
+
}
|
|
118
|
+
// A tool_result's content is a string, or a list of blocks of which the text ones count.
|
|
119
|
+
function resultText(content) {
|
|
120
|
+
if (typeof content === 'string')
|
|
121
|
+
return content;
|
|
122
|
+
if (!Array.isArray(content))
|
|
123
|
+
return '';
|
|
124
|
+
return content.map((part) => str(part?.text) ?? '').join('\n');
|
|
125
|
+
}
|
|
126
|
+
// `structuredPatch` of an Edit or Write result: hunks of lines that start with ' ', '-' or '+'.
|
|
127
|
+
function diffOf(result) {
|
|
128
|
+
const hunks = result?.structuredPatch;
|
|
129
|
+
if (!Array.isArray(hunks))
|
|
130
|
+
return undefined;
|
|
131
|
+
const lines = hunks.flatMap((hunk) => hunk?.lines ?? []).filter((line) => typeof line === 'string');
|
|
132
|
+
if (lines.length === 0)
|
|
133
|
+
return undefined;
|
|
134
|
+
const diff = lines.slice(0, caps.diffLines).map((line) => ({ sign: line[0] === '+' ? '+' : line[0] === '-' ? '-' : ' ', text: clean(line.slice(1), caps.diffLine) }));
|
|
135
|
+
if (lines.length > caps.diffLines)
|
|
136
|
+
diff.push({ sign: ' ', text: `… ${lines.length - caps.diffLines} more lines` });
|
|
137
|
+
return { diff, added: lines.filter((line) => line[0] === '+').length, removed: lines.filter((line) => line[0] === '-').length };
|
|
138
|
+
}
|
|
139
|
+
function claudeEvents(line, state) {
|
|
140
|
+
const event = JSON.parse(line);
|
|
141
|
+
if (typeof event !== 'object' || event === null)
|
|
142
|
+
return [];
|
|
143
|
+
const time = typeof event.timestamp === 'string' ? Date.parse(event.timestamp) : NaN;
|
|
144
|
+
const at = Number.isNaN(time) ? undefined : time;
|
|
145
|
+
const agentId = str(event.agent_id);
|
|
146
|
+
const scope = { ...(at === undefined ? {} : { at }), ...(agentId === undefined ? {} : { agentId }) };
|
|
147
|
+
const blocks = blocksOf(event);
|
|
148
|
+
const events = [];
|
|
149
|
+
if (event.type === 'assistant') {
|
|
150
|
+
if (agentId !== undefined && !state.lanes.has(agentId)) {
|
|
151
|
+
const lane = laneIn(blocks);
|
|
152
|
+
if (lane !== undefined)
|
|
153
|
+
state.lanes.set(agentId, lane);
|
|
154
|
+
}
|
|
155
|
+
if (str(event.error) !== undefined) {
|
|
156
|
+
const text = blocks.map((block) => str(block.text)).find((t) => t) ?? String(event.error);
|
|
157
|
+
return [{ ...scope, kind: 'error', text: clean(text, caps.text) }];
|
|
158
|
+
}
|
|
159
|
+
for (const block of blocks) {
|
|
160
|
+
if (block.type === 'text') {
|
|
161
|
+
const text = str(block.text);
|
|
162
|
+
if (text)
|
|
163
|
+
events.push({ ...scope, kind: 'say', text: clean(text, caps.text) });
|
|
164
|
+
}
|
|
165
|
+
else if (block.type === 'tool_use' && typeof block.id === 'string' && typeof block.name === 'string') {
|
|
166
|
+
state.calls.set(block.id, { at, tool: block.name });
|
|
167
|
+
const arg = clean(argOf(block.input), caps.arg);
|
|
168
|
+
const description = str(block.input?.description);
|
|
169
|
+
// Only when `arg` is something else: a call whose argument is its description (an Agent) would say it twice.
|
|
170
|
+
events.push({ ...scope, kind: 'tool_start', callId: block.id, tool: block.name, arg, ...(description && description !== arg && { description: clean(description, caps.summary) }) });
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
else if (event.type === 'user') {
|
|
175
|
+
for (const block of blocks) {
|
|
176
|
+
const result = block;
|
|
177
|
+
if (result.type !== 'tool_result' || typeof result.tool_use_id !== 'string')
|
|
178
|
+
continue;
|
|
179
|
+
const call = state.calls.get(result.tool_use_id);
|
|
180
|
+
state.calls.delete(result.tool_use_id);
|
|
181
|
+
const text = resultText(result.content);
|
|
182
|
+
const patch = diffOf(event.tool_use_result);
|
|
183
|
+
const firstLine = text.split('\n').find((l) => l.trim() !== '');
|
|
184
|
+
const summary = patch ? `+${patch.added} -${patch.removed}` : firstLine && clean(firstLine, caps.summary);
|
|
185
|
+
// Only a command's output is kept: it is what the design collapses; a Read would copy whole files.
|
|
186
|
+
const output = call?.tool === 'Bash' ? boundOutput(text) : [];
|
|
187
|
+
events.push({
|
|
188
|
+
...scope,
|
|
189
|
+
kind: 'tool_end',
|
|
190
|
+
callId: result.tool_use_id,
|
|
191
|
+
...(call && { tool: call.tool }),
|
|
192
|
+
status: result.is_error === true ? 'err' : 'ok',
|
|
193
|
+
...(call?.at !== undefined && at !== undefined && { durationMs: Math.max(0, at - call.at) }),
|
|
194
|
+
...(summary && { summary }),
|
|
195
|
+
...(output.length > 0 && { output }),
|
|
196
|
+
...(patch && { diff: patch.diff }),
|
|
197
|
+
});
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
else if (event.type === 'system') {
|
|
201
|
+
const taskId = str(event.task_id);
|
|
202
|
+
const callId = str(event.tool_use_id);
|
|
203
|
+
if (event.subtype === 'task_started' && event.task_type === 'local_agent' && taskId !== undefined) {
|
|
204
|
+
const description = str(event.description) ?? '';
|
|
205
|
+
const slice = sliceIn(description);
|
|
206
|
+
state.tasks.set(taskId, { description, slice });
|
|
207
|
+
events.push({ kind: 'task', agentId: taskId, ...(callId && { callId }), text: clean(description, caps.summary), ...(slice !== undefined && { slice }) });
|
|
208
|
+
}
|
|
209
|
+
else if (event.subtype === 'task_notification' && taskId !== undefined && state.tasks.has(taskId)) {
|
|
210
|
+
const { description, slice } = state.tasks.get(taskId);
|
|
211
|
+
state.tasks.delete(taskId);
|
|
212
|
+
const lane = state.lanes.get(taskId);
|
|
213
|
+
const summary = str(event.summary);
|
|
214
|
+
events.push({
|
|
215
|
+
kind: 'task',
|
|
216
|
+
agentId: taskId,
|
|
217
|
+
...(callId && { callId }),
|
|
218
|
+
text: clean(description, caps.summary),
|
|
219
|
+
status: event.status === 'completed' ? 'ok' : 'err',
|
|
220
|
+
...(summary && { summary: clean(summary, caps.summary) }),
|
|
221
|
+
...(slice !== undefined && { slice }),
|
|
222
|
+
...(lane !== undefined && { lane }),
|
|
223
|
+
});
|
|
224
|
+
}
|
|
225
|
+
else if (event.subtype === 'code_change_published') {
|
|
226
|
+
const branch = str(event.branch);
|
|
227
|
+
const text = [str(event.action) ?? 'published', str(event.identifier), branch && `(${branch})`].filter(Boolean).join(' ');
|
|
228
|
+
events.push({ kind: 'note', text: clean(text, caps.summary) });
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
else if (event.type === 'result' && event.is_error === true) {
|
|
232
|
+
events.push({ kind: 'error', text: clean(str(event.result) ?? JSON.stringify(event.errors ?? 'the run ended with an error'), caps.text) });
|
|
233
|
+
}
|
|
234
|
+
// Thinking, hooks, progress, rate limits and whatever else a newer CLI adds fall through: nothing to record.
|
|
235
|
+
return events;
|
|
236
|
+
}
|
|
237
|
+
/** Events of one stream-json line (docs/research/macos-app-data-engine.md, "Capturing events"). Never throws. */
|
|
238
|
+
export function parseClaudeEvents(line, state) {
|
|
239
|
+
try {
|
|
240
|
+
return claudeEvents(line, state);
|
|
241
|
+
}
|
|
242
|
+
catch {
|
|
243
|
+
return []; // not JSON, or a shape this version of the CLI changed: the log keeps it, the events skip it
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
export const claudeAdapter = { command: buildClaudeCommand, parseLine: parseClaudeLine, parseEvents: parseClaudeEvents };
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
import { buildPrompt } from '../prompts/build-prompt.js';
|
|
2
|
+
import { childEnv, markerFor } from './common.js';
|
|
3
|
+
import { boundOutput, caps, clean } from '../execution/events.js';
|
|
4
|
+
/**
|
|
5
|
+
* Headless Codex as in docs/research/headless-agents.md; stdin is closed by the runner.
|
|
6
|
+
* The unattended agent has to push and call `gh`, which Codex's sandbox blocks (no network), and nobody
|
|
7
|
+
* can answer an approval, so the command skips both: the same trade as Claude's bypassPermissions (ADR 0004).
|
|
8
|
+
* That one flag also covers `resume`, which takes neither --sandbox nor --add-dir, so the result path outside
|
|
9
|
+
* the workspace needs no --add-dir. Login, config, plugins and hooks stay those of the user's CLI.
|
|
10
|
+
*/
|
|
11
|
+
export function buildCodexCommand(task, ctx) {
|
|
12
|
+
return {
|
|
13
|
+
bin: ctx.binary,
|
|
14
|
+
args: [
|
|
15
|
+
'exec',
|
|
16
|
+
...(task.resumeSession ? ['resume', task.resumeSession] : []),
|
|
17
|
+
buildPrompt(task, { forgeAgent: ctx.forgeAgent }),
|
|
18
|
+
// JSONL: thread.started carries the session id while the run is going.
|
|
19
|
+
'--json',
|
|
20
|
+
'--model',
|
|
21
|
+
task.model,
|
|
22
|
+
// `--oss --local-provider` is refused by `exec resume`; the config key both of them set is accepted by every form.
|
|
23
|
+
...(task.provider ? ['-c', `model_provider="${task.provider}"`] : []),
|
|
24
|
+
'--dangerously-bypass-approvals-and-sandbox',
|
|
25
|
+
// Codex has no flag to name a run; a config key it ignores keeps the marker in argv for listActive/stop.
|
|
26
|
+
'-c',
|
|
27
|
+
`carrier_marker="${markerFor(task.executionId)}"`,
|
|
28
|
+
],
|
|
29
|
+
cwd: task.workspace,
|
|
30
|
+
env: { ...childEnv(ctx), ...(task.configDir && { CODEX_HOME: task.configDir }), CARRIER_RESULT_PATH: task.resultPath },
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
const str = (value) => (typeof value === 'string' ? value : undefined);
|
|
34
|
+
const usageLimitStarts = ["You've hit your usage limit", 'You hit your spend cap', 'Your workspace is out of credits', 'Quota exceeded.'];
|
|
35
|
+
// Shapes observed with codex 0.153.4: thread.started carries the session; a missing login ends with
|
|
36
|
+
// turn.failed "unexpected status 401 Unauthorized" (the "Reconnecting... n/5 (... 401" error events before it
|
|
37
|
+
// are retries, not the end); resuming an unknown thread prints "no rollout found for thread id" on stderr, not JSON.
|
|
38
|
+
export function parseCodexLine(line) {
|
|
39
|
+
let event;
|
|
40
|
+
try {
|
|
41
|
+
event = JSON.parse(line);
|
|
42
|
+
}
|
|
43
|
+
catch {
|
|
44
|
+
return line.includes('no rollout found for thread id') ? { sessionUnavailable: true } : undefined;
|
|
45
|
+
}
|
|
46
|
+
if (event.type === 'thread.started' && typeof event.thread_id === 'string')
|
|
47
|
+
return { session: event.thread_id };
|
|
48
|
+
if (event.type === 'turn.failed') {
|
|
49
|
+
const message = str(event.error?.message) ?? 'the turn failed';
|
|
50
|
+
if (message.includes('401 Unauthorized'))
|
|
51
|
+
return { authFailed: true };
|
|
52
|
+
// Text only (docs/research/agent-usage-limit-signals.md); the reset time stays in the detail, nothing computes on it.
|
|
53
|
+
return { failure: { kind: usageLimitStarts.some((start) => message.startsWith(start)) ? 'usage_limit' : 'api_error', detail: message } };
|
|
54
|
+
}
|
|
55
|
+
return undefined;
|
|
56
|
+
}
|
|
57
|
+
// Codex runs every command as `<shell> -lc '<the command>'`; the user's command is what is inside.
|
|
58
|
+
const unwrap = (command) => {
|
|
59
|
+
const match = /^\S+ -lc (['"])([\s\S]*)\1$/.exec(command);
|
|
60
|
+
if (match === null)
|
|
61
|
+
return command;
|
|
62
|
+
return match[1] === '"' ? match[2].replace(/\\(["\\$`])/g, '$1') : match[2];
|
|
63
|
+
};
|
|
64
|
+
function codexEvents(line, state) {
|
|
65
|
+
const event = JSON.parse(line);
|
|
66
|
+
if (typeof event !== 'object' || event === null)
|
|
67
|
+
return [];
|
|
68
|
+
if (event.type === 'turn.failed')
|
|
69
|
+
return [{ kind: 'error', text: clean(str(event.error?.message) ?? 'the turn failed', caps.text) }];
|
|
70
|
+
const item = event.item;
|
|
71
|
+
if (typeof item !== 'object' || item === null)
|
|
72
|
+
return [];
|
|
73
|
+
const started = event.type === 'item.started';
|
|
74
|
+
if (!started && event.type !== 'item.completed')
|
|
75
|
+
return [];
|
|
76
|
+
const callId = str(item.id);
|
|
77
|
+
// The stream has no time: the engine stamps each event with the arrival of its line (and so no duration either).
|
|
78
|
+
if (item.type === 'agent_message') {
|
|
79
|
+
const text = str(item.text);
|
|
80
|
+
return text ? [{ kind: 'say', text: clean(text, caps.text) }] : [];
|
|
81
|
+
}
|
|
82
|
+
// Codex prints its own warnings as error items (a notice about a flag, not a failure of the run).
|
|
83
|
+
if (item.type === 'error') {
|
|
84
|
+
const text = str(item.message);
|
|
85
|
+
return text ? [{ kind: 'note', text: clean(text, caps.summary) }] : [];
|
|
86
|
+
}
|
|
87
|
+
if (callId === undefined || (item.type !== 'command_execution' && item.type !== 'file_change'))
|
|
88
|
+
return [];
|
|
89
|
+
const command = item.type === 'command_execution';
|
|
90
|
+
const tool = command ? 'shell' : 'Edit';
|
|
91
|
+
if (started) {
|
|
92
|
+
state.calls.set(callId, { tool });
|
|
93
|
+
const paths = Array.isArray(item.changes) ? item.changes.map((change) => str(change?.path)).filter((path) => path !== undefined) : [];
|
|
94
|
+
return [{ kind: 'tool_start', callId, tool, arg: clean(command ? unwrap(str(item.command) ?? '') : paths.join(', '), caps.arg) }];
|
|
95
|
+
}
|
|
96
|
+
state.calls.delete(callId);
|
|
97
|
+
if (!command) {
|
|
98
|
+
const files = Array.isArray(item.changes) ? item.changes.length : 0;
|
|
99
|
+
return [{ kind: 'tool_end', callId, tool, status: item.status === 'failed' ? 'err' : 'ok', summary: `${files} file${files === 1 ? '' : 's'}` }];
|
|
100
|
+
}
|
|
101
|
+
const code = typeof item.exit_code === 'number' ? item.exit_code : undefined;
|
|
102
|
+
const text = str(item.aggregated_output) ?? '';
|
|
103
|
+
const failed = code === undefined ? item.status === 'failed' : code !== 0;
|
|
104
|
+
const firstLine = text.split('\n').find((l) => l.trim() !== '');
|
|
105
|
+
const summary = failed && code !== undefined ? `Exit code ${code}` : firstLine && clean(firstLine, caps.summary);
|
|
106
|
+
const output = boundOutput(text);
|
|
107
|
+
return [{ kind: 'tool_end', callId, tool, status: failed ? 'err' : 'ok', ...(summary && { summary }), ...(output.length > 0 && { output }) }];
|
|
108
|
+
}
|
|
109
|
+
/** Events of one `exec --json` line (docs/research/codex-exec-stream.md). Never throws; reasoning, todo lists and whatever else a newer CLI adds give []. */
|
|
110
|
+
export function parseCodexEvents(line, state) {
|
|
111
|
+
try {
|
|
112
|
+
return codexEvents(line, state);
|
|
113
|
+
}
|
|
114
|
+
catch {
|
|
115
|
+
return []; // not JSON, or a shape this version of the CLI changed: the log keeps it, the events skip it
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
export const codexAdapter = { command: buildCodexCommand, parseLine: parseCodexLine, parseEvents: parseCodexEvents };
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { accessSync, constants } from 'node:fs';
|
|
2
|
+
import { delimiter, join } from 'node:path';
|
|
3
|
+
/** Marker in the child's argv, so a later process finds the executions an earlier one left running. */
|
|
4
|
+
export const markerFor = (executionId) => `carrier-exec-${executionId}`;
|
|
5
|
+
// git over HTTPS ignores GH_TOKEN: it asks the user's credential helpers (on macOS the keychain, i.e. the person's own login),
|
|
6
|
+
// so without this every push and branch operation of a bot would be made as that person. The first entry empties the helper
|
|
7
|
+
// list; the second answers with the execution's token, read from the environment when git asks (the token is not in the value).
|
|
8
|
+
// For a Forge bot (CARRIER_FORGE_AGENT set; read as a variable, never pasted into this text) it asks `agent-forge` for a token
|
|
9
|
+
// minted now, since the one the run started with expires after about an hour; if Forge prints nothing, fails or takes longer
|
|
10
|
+
// than 10 seconds (a watchdog kills it; `timeout` is not on a stock macOS, and CARRIER_FORGE_TIMEOUT is only for the tests), the starting token.
|
|
11
|
+
// Only `get` answers: git's `store` and `erase` must not mint tokens for nothing.
|
|
12
|
+
const gitUsesToken = '!f() { [ "$1" = get ] || return 0; echo username=x-access-token; t=; ' +
|
|
13
|
+
'if [ -n "$CARRIER_FORGE_AGENT" ] && o=$(mktemp); then ' +
|
|
14
|
+
'agent-forge token --agent "$CARRIER_FORGE_AGENT" >"$o" 2>/dev/null & p=$!; ' +
|
|
15
|
+
'(sleep "${CARRIER_FORGE_TIMEOUT:-10}"; kill $p) >/dev/null 2>&1 & w=$!; ' +
|
|
16
|
+
'{ wait $p; kill $w; wait $w; } 2>/dev/null; t=$(cat "$o"); rm -f "$o"; fi; ' +
|
|
17
|
+
'echo "password=${t:-$GH_TOKEN}"; }; f';
|
|
18
|
+
export function childEnv({ githubToken, gitIdentity, forgeAgent, baseEnv }) {
|
|
19
|
+
const identity = gitIdentity && {
|
|
20
|
+
GIT_AUTHOR_NAME: gitIdentity.name,
|
|
21
|
+
GIT_AUTHOR_EMAIL: gitIdentity.email,
|
|
22
|
+
GIT_COMMITTER_NAME: gitIdentity.name,
|
|
23
|
+
GIT_COMMITTER_EMAIL: gitIdentity.email,
|
|
24
|
+
};
|
|
25
|
+
const inherited = Object.entries(baseEnv).filter((e) => e[1] !== undefined);
|
|
26
|
+
const env = Object.fromEntries(inherited);
|
|
27
|
+
// Keeps the config entries the environment already had (GIT_CONFIG_COUNT) and adds ours after them.
|
|
28
|
+
const first = Number.isInteger(Number(env.GIT_CONFIG_COUNT)) ? Number(env.GIT_CONFIG_COUNT) : 0;
|
|
29
|
+
const credentialHelper = {
|
|
30
|
+
GIT_CONFIG_COUNT: String(first + 2),
|
|
31
|
+
[`GIT_CONFIG_KEY_${first}`]: 'credential.helper',
|
|
32
|
+
[`GIT_CONFIG_VALUE_${first}`]: '',
|
|
33
|
+
[`GIT_CONFIG_KEY_${first + 1}`]: 'credential.helper',
|
|
34
|
+
[`GIT_CONFIG_VALUE_${first + 1}`]: gitUsesToken,
|
|
35
|
+
};
|
|
36
|
+
return { ...env, GH_TOKEN: githubToken, GITHUB_TOKEN: githubToken, ...credentialHelper, ...identity, ...(forgeAgent && { CARRIER_FORGE_AGENT: forgeAgent }) };
|
|
37
|
+
}
|
|
38
|
+
const isExecutable = (path) => {
|
|
39
|
+
try {
|
|
40
|
+
accessSync(path, constants.X_OK);
|
|
41
|
+
return true;
|
|
42
|
+
}
|
|
43
|
+
catch {
|
|
44
|
+
return false;
|
|
45
|
+
}
|
|
46
|
+
};
|
|
47
|
+
/**
|
|
48
|
+
* The real CLI binary, never a wrapper: Superset puts wrappers under ~/.superset/bin ahead of the real one
|
|
49
|
+
* on PATH (they add flags and trap signals), see docs/research/headless-agents.md.
|
|
50
|
+
*/
|
|
51
|
+
export function resolveBinary(name, path = process.env.PATH ?? '', executable = isExecutable) {
|
|
52
|
+
for (const dir of path.split(delimiter)) {
|
|
53
|
+
if (dir === '' || dir.includes('/.superset/'))
|
|
54
|
+
continue;
|
|
55
|
+
if (executable(join(dir, name)))
|
|
56
|
+
return join(dir, name);
|
|
57
|
+
}
|
|
58
|
+
throw new Error(`cannot find the real "${name}" binary on PATH`);
|
|
59
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
const botUser = z.object({ id: z.int().positive() });
|
|
3
|
+
/** The id of a bot account from GitHub's public users endpoint (as Forge computes it), asked once per agent. */
|
|
4
|
+
export function botIdFromGitHub(fetchImpl = fetch) {
|
|
5
|
+
const known = new Map();
|
|
6
|
+
return async (agent, token) => {
|
|
7
|
+
const cached = known.get(agent);
|
|
8
|
+
if (cached !== undefined)
|
|
9
|
+
return cached;
|
|
10
|
+
const response = await fetchImpl(`https://api.github.com/users/${encodeURIComponent(`${agent}[bot]`)}`, {
|
|
11
|
+
headers: { authorization: `Bearer ${token}`, accept: 'application/vnd.github+json' },
|
|
12
|
+
signal: AbortSignal.timeout(30_000),
|
|
13
|
+
});
|
|
14
|
+
if (!response.ok)
|
|
15
|
+
throw new Error(`GitHub answered HTTP ${response.status} for the user ${agent}[bot]`);
|
|
16
|
+
const { id } = botUser.parse(await response.json());
|
|
17
|
+
known.set(agent, id);
|
|
18
|
+
return id;
|
|
19
|
+
};
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* What each execution gets: the token of its profile (minted for this execution, handed to the child process
|
|
23
|
+
* only) and, for a Forge bot, the commit identity `<agent>[bot]`, which Forge exports only in its interactive
|
|
24
|
+
* mode. A `{kind:'gh'}` profile is the user's own login: `gh` as it is, no identity override.
|
|
25
|
+
* Any failure rejects; the runner reports that as `auth_failed`, since the run cannot start without a token.
|
|
26
|
+
*/
|
|
27
|
+
export function profileCredentials({ profiles, mint, botId = botIdFromGitHub() }) {
|
|
28
|
+
return async (task) => {
|
|
29
|
+
const profile = profiles[task.profile];
|
|
30
|
+
if (!profile)
|
|
31
|
+
throw new Error(`profile "${task.profile}" is not declared`);
|
|
32
|
+
const githubToken = await mint(profile, task.repo);
|
|
33
|
+
if (profile.kind === 'gh')
|
|
34
|
+
return { githubToken };
|
|
35
|
+
const name = `${profile.agent}[bot]`;
|
|
36
|
+
return { githubToken, forgeAgent: profile.agent, gitIdentity: { name, email: `${await botId(profile.agent, githubToken)}+${name}@users.noreply.github.com` } };
|
|
37
|
+
};
|
|
38
|
+
}
|