@delegoapp/runner 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +47 -0
- package/dist/bin.js +7 -0
- package/dist/config.js +159 -0
- package/dist/credentials-store.js +76 -0
- package/dist/execution-prefs.js +34 -0
- package/dist/executor/adapters/claude.js +124 -0
- package/dist/executor/adapters/codex.js +20 -0
- package/dist/executor/adapters/index.js +6 -0
- package/dist/executor/adapters/types.js +1 -0
- package/dist/executor/process.js +161 -0
- package/dist/executor/prompt.js +21 -0
- package/dist/git/command.js +33 -0
- package/dist/git/commit.js +82 -0
- package/dist/git/publish.js +70 -0
- package/dist/git/workspace.js +213 -0
- package/dist/index.js +6 -0
- package/dist/job-pipeline.js +164 -0
- package/dist/pairing.js +65 -0
- package/dist/relay-client.js +84 -0
- package/dist/run.js +143 -0
- package/dist/thinking.js +24 -0
- package/package.json +49 -0
package/README.md
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
# @delegoapp/runner
|
|
2
|
+
|
|
3
|
+
Local runner for [Delego](https://delegoapp.com). Polls the Delego relay, claims Linear-delegated jobs, and executes them through Codex CLI or Claude Code on your machine.
|
|
4
|
+
|
|
5
|
+
## Install + pair (first time)
|
|
6
|
+
|
|
7
|
+
1. In the Delego dashboard, go to **Runners → Pair a runner**. Enter a name, pick which executors this machine can run, and click "Generate command". Copy the command shown.
|
|
8
|
+
2. Run that command on the machine where the runner will live. The first run consumes the pairing token and saves a long-lived bearer to `${XDG_CONFIG_HOME ?? ~/.config}/delego-runner/credentials.json` (mode `0600`).
|
|
9
|
+
3. Subsequent runs need only the relay URL — the bearer is loaded automatically:
|
|
10
|
+
|
|
11
|
+
```bash
|
|
12
|
+
npx -y @delegoapp/runner --relay-url https://your-dashboard.example
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
The process polls the relay continuously. Pass `--once` to run a single iteration and exit.
|
|
16
|
+
|
|
17
|
+
## Configuration
|
|
18
|
+
|
|
19
|
+
All flags also accept the matching `DELEGO_*` environment variable.
|
|
20
|
+
|
|
21
|
+
| Flag | Env var | Default | Purpose |
|
|
22
|
+
|------|---------|---------|---------|
|
|
23
|
+
| `--relay-url` | `DELEGO_RELAY_URL` | (required) | Relay base URL |
|
|
24
|
+
| `--pairing-token` | `DELEGO_PAIRING_TOKEN` | — | Single-use bearer, first run only (`drs_pair_*`) |
|
|
25
|
+
| `--profile` | `DELEGO_PROFILE` | `default` | Credentials profile name |
|
|
26
|
+
| `--workspace-root` | `DELEGO_WORKSPACE_ROOT` | `process.cwd()` | Where to clone/worktree repos |
|
|
27
|
+
| `--git-clone-base-url` | `DELEGO_GIT_CLONE_BASE_URL` | `git@github.com:` | Base for `git clone` |
|
|
28
|
+
| `--codex-command` | `DELEGO_CODEX_COMMAND` | `codex` | Codex CLI executable |
|
|
29
|
+
| `--codex-args` | `DELEGO_CODEX_ARGS` | `exec --sandbox workspace-write` | Codex args before prompt |
|
|
30
|
+
| `--claude-command` | `DELEGO_CLAUDE_COMMAND` | `claude` | Claude CLI executable |
|
|
31
|
+
| `--claude-args` | `DELEGO_CLAUDE_ARGS` | `--print --verbose --output-format stream-json --dangerously-skip-permissions` | Claude args |
|
|
32
|
+
| `--supported-executors` | `DELEGO_SUPPORTED_EXECUTORS` | `codex,claude` | Comma-separated executors this runner advertises |
|
|
33
|
+
| `--executor-timeout-ms` | `DELEGO_EXECUTOR_TIMEOUT_MS` | `1800000` | Max executor runtime (ms) |
|
|
34
|
+
| `--no-commit` | `DELEGO_CREATE_COMMIT=false` | (create commit by default) | Disable local commit creation |
|
|
35
|
+
|
|
36
|
+
## Requirements
|
|
37
|
+
|
|
38
|
+
- Node ≥ 20
|
|
39
|
+
- `git` on `PATH`
|
|
40
|
+
- `gh` on `PATH` (only if publishing a PR is enabled for the job)
|
|
41
|
+
- `codex` or `claude` (or both) on `PATH` — at least one must match `--supported-executors`
|
|
42
|
+
|
|
43
|
+
## Credentials
|
|
44
|
+
|
|
45
|
+
Stored at `${XDG_CONFIG_HOME ?? ~/.config}/delego-runner/credentials.json` (mode `0600`). Each profile holds `{ relayUrl, runnerId, bearer, pairedAt }`. Override the path with `DELEGO_CREDENTIALS_PATH`.
|
|
46
|
+
|
|
47
|
+
To rotate or remove a runner, use the dashboard — local credentials become invalid as soon as the server rotates the bearer.
|
package/dist/bin.js
ADDED
package/dist/config.js
ADDED
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
import { ADAPTERS, ADAPTERS_BY_ID, SUPPORTED_EXECUTOR_IDS, } from './executor/adapters/index.js';
|
|
2
|
+
import { createRequire } from 'node:module';
|
|
3
|
+
import { resolve } from 'node:path';
|
|
4
|
+
const defaultHeartbeatIntervalMs = 10_000;
|
|
5
|
+
const defaultPollIntervalMs = 10_000;
|
|
6
|
+
const defaultCancellationPollIntervalMs = 5_000;
|
|
7
|
+
const defaultExecutorTimeoutMs = 30 * 60_000;
|
|
8
|
+
const PROFILE_PATTERN = /^[a-z0-9][a-z0-9-]{0,63}$/;
|
|
9
|
+
const DEFAULT_PROFILE = 'default';
|
|
10
|
+
const packageJson = createRequire(import.meta.url)('../package.json');
|
|
11
|
+
const runnerVersion = packageJson.version ?? '0.0.0';
|
|
12
|
+
function flagLine(flag, valueLabel, description) {
|
|
13
|
+
return ` ${`${flag} ${valueLabel}`.padEnd(38, ' ')}${description}`;
|
|
14
|
+
}
|
|
15
|
+
function executorFlagLines() {
|
|
16
|
+
const lines = [];
|
|
17
|
+
for (const adapter of ADAPTERS) {
|
|
18
|
+
lines.push(flagLine(adapter.flag.command, '<command>', `${adapter.displayName} CLI command (default: ${adapter.defaultCommand})`), flagLine(adapter.flag.args, '<args>', `${adapter.displayName} CLI args before the prompt (default: ${adapter.defaultArgs.join(' ')})`));
|
|
19
|
+
}
|
|
20
|
+
return lines;
|
|
21
|
+
}
|
|
22
|
+
function executorEnvNames() {
|
|
23
|
+
const names = [];
|
|
24
|
+
for (const adapter of ADAPTERS) {
|
|
25
|
+
names.push(adapter.env.command, adapter.env.args);
|
|
26
|
+
}
|
|
27
|
+
return names;
|
|
28
|
+
}
|
|
29
|
+
function usage() {
|
|
30
|
+
return [
|
|
31
|
+
'Usage: npx @delegoapp/runner --relay-url <url> [--pairing-token <drs_pair_...>] [--profile <name>]',
|
|
32
|
+
'',
|
|
33
|
+
'First run: pass --pairing-token <drs_pair_...> (mint one in the dashboard).',
|
|
34
|
+
'Subsequent runs: omit --pairing-token; the bearer is loaded from the stored profile.',
|
|
35
|
+
'',
|
|
36
|
+
'Pairing / profile:',
|
|
37
|
+
flagLine('--pairing-token', '<token>', 'Single-use drs_pair_... token; consumed on first run'),
|
|
38
|
+
flagLine('--profile', '<name>', `Credentials profile to read/write (default: ${DEFAULT_PROFILE}; pattern: ${PROFILE_PATTERN})`),
|
|
39
|
+
flagLine('--force', '', 'Suppress the warning when overwriting an existing profile'),
|
|
40
|
+
'',
|
|
41
|
+
'Real execution options:',
|
|
42
|
+
flagLine('--workspace-root', '<path>', 'Root containing local owner/repo or repo checkouts'),
|
|
43
|
+
flagLine('--git-clone-base-url', '<url>', 'Base URL for missing repo clones (default: git@github.com:)'),
|
|
44
|
+
...executorFlagLines(),
|
|
45
|
+
flagLine('--supported-executors', '<list>', `Comma-separated executor capabilities to advertise (default: ${SUPPORTED_EXECUTOR_IDS.join(',')})`),
|
|
46
|
+
flagLine('--executor-timeout-ms', '<ms>', 'Maximum executor runtime (default: 1800000)'),
|
|
47
|
+
flagLine('--no-commit', '', 'Report changed files without creating a local commit'),
|
|
48
|
+
'',
|
|
49
|
+
`Environment fallbacks: DELEGO_RELAY_URL, DELEGO_PAIRING_TOKEN, DELEGO_PROFILE, DELEGO_WORKSPACE_ROOT, DELEGO_GIT_CLONE_BASE_URL, ${executorEnvNames().join(', ')}, DELEGO_SUPPORTED_EXECUTORS, DELEGO_CREATE_COMMIT`,
|
|
50
|
+
`Credentials path override: DELEGO_CREDENTIALS_PATH`,
|
|
51
|
+
].join('\n');
|
|
52
|
+
}
|
|
53
|
+
export function validatePublishingPolicy(policy) {
|
|
54
|
+
if (policy.autoCreatePr && !policy.autoPush) {
|
|
55
|
+
throw new Error('Invalid publishing policy: autoCreatePr=true requires autoPush=true.');
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
function readArg(args, name) {
|
|
59
|
+
const index = args.indexOf(name);
|
|
60
|
+
if (index === -1) {
|
|
61
|
+
return null;
|
|
62
|
+
}
|
|
63
|
+
return args[index + 1] ?? null;
|
|
64
|
+
}
|
|
65
|
+
function readNumberOption(args, env, name, envName, fallback) {
|
|
66
|
+
const value = readArg(args, name) ?? env[envName];
|
|
67
|
+
return value ? Number(value) : fallback;
|
|
68
|
+
}
|
|
69
|
+
function splitArgs(value) {
|
|
70
|
+
if (!value) {
|
|
71
|
+
return [];
|
|
72
|
+
}
|
|
73
|
+
return value
|
|
74
|
+
.split(/\s+/)
|
|
75
|
+
.map((part) => part.trim())
|
|
76
|
+
.filter(Boolean);
|
|
77
|
+
}
|
|
78
|
+
function parseSupportedExecutors(value) {
|
|
79
|
+
if (!value) {
|
|
80
|
+
return [...SUPPORTED_EXECUTOR_IDS];
|
|
81
|
+
}
|
|
82
|
+
const executors = value
|
|
83
|
+
.split(',')
|
|
84
|
+
.map((part) => part.trim().toLowerCase())
|
|
85
|
+
.filter((part) => part in ADAPTERS_BY_ID);
|
|
86
|
+
if (executors.length === 0) {
|
|
87
|
+
throw new Error(`--supported-executors must include at least one of ${SUPPORTED_EXECUTOR_IDS.join(', ')}`);
|
|
88
|
+
}
|
|
89
|
+
return Array.from(new Set(executors));
|
|
90
|
+
}
|
|
91
|
+
function readExecutorOptions(args, env) {
|
|
92
|
+
const entries = ADAPTERS.map((adapter) => {
|
|
93
|
+
const command = readArg(args, adapter.flag.command) ??
|
|
94
|
+
env[adapter.env.command] ??
|
|
95
|
+
adapter.defaultCommand;
|
|
96
|
+
const argsValue = readArg(args, adapter.flag.args) ??
|
|
97
|
+
env[adapter.env.args] ??
|
|
98
|
+
adapter.defaultArgs.join(' ');
|
|
99
|
+
return [adapter.id, { command, args: splitArgs(argsValue) }];
|
|
100
|
+
});
|
|
101
|
+
return Object.fromEntries(entries);
|
|
102
|
+
}
|
|
103
|
+
export function readConfig(args, env = process.env) {
|
|
104
|
+
const relayUrlRaw = readArg(args, '--relay-url') ?? env.DELEGO_RELAY_URL ?? null;
|
|
105
|
+
const pairingToken = readArg(args, '--pairing-token') ?? env.DELEGO_PAIRING_TOKEN ?? null;
|
|
106
|
+
const profileName = readArg(args, '--profile') ?? env.DELEGO_PROFILE ?? DEFAULT_PROFILE;
|
|
107
|
+
const force = args.includes('--force');
|
|
108
|
+
if (!PROFILE_PATTERN.test(profileName)) {
|
|
109
|
+
throw new Error(`--profile must match ${PROFILE_PATTERN} (got ${JSON.stringify(profileName)})`);
|
|
110
|
+
}
|
|
111
|
+
if (pairingToken !== null && !pairingToken.startsWith('drs_pair_')) {
|
|
112
|
+
throw new Error('--pairing-token must start with drs_pair_');
|
|
113
|
+
}
|
|
114
|
+
const heartbeatIntervalMs = readNumberOption(args, env, '--heartbeat-interval-ms', 'DELEGO_HEARTBEAT_INTERVAL_MS', defaultHeartbeatIntervalMs);
|
|
115
|
+
const pollIntervalMs = readNumberOption(args, env, '--poll-interval-ms', 'DELEGO_POLL_INTERVAL_MS', defaultPollIntervalMs);
|
|
116
|
+
const cancellationPollIntervalMs = readNumberOption(args, env, '--cancellation-poll-interval-ms', 'DELEGO_CANCELLATION_POLL_INTERVAL_MS', defaultCancellationPollIntervalMs);
|
|
117
|
+
const executorTimeoutMs = readNumberOption(args, env, '--executor-timeout-ms', 'DELEGO_EXECUTOR_TIMEOUT_MS', defaultExecutorTimeoutMs);
|
|
118
|
+
const workspaceRoot = readArg(args, '--workspace-root') ??
|
|
119
|
+
env.DELEGO_WORKSPACE_ROOT ??
|
|
120
|
+
process.cwd();
|
|
121
|
+
const gitCloneBaseUrl = (readArg(args, '--git-clone-base-url') ??
|
|
122
|
+
env.DELEGO_GIT_CLONE_BASE_URL ??
|
|
123
|
+
'git@github.com:').replace(/\/+$/, '');
|
|
124
|
+
const executors = readExecutorOptions(args, env);
|
|
125
|
+
const createCommit = !args.includes('--no-commit') && env.DELEGO_CREATE_COMMIT !== 'false';
|
|
126
|
+
const mockExecuteOnce = args.includes('--mock-execute-once');
|
|
127
|
+
const supportedExecutors = parseSupportedExecutors(readArg(args, '--supported-executors') ?? env.DELEGO_SUPPORTED_EXECUTORS);
|
|
128
|
+
for (const [name, value, minimum] of [
|
|
129
|
+
['--heartbeat-interval-ms', heartbeatIntervalMs, 1_000],
|
|
130
|
+
['--poll-interval-ms', pollIntervalMs, 1_000],
|
|
131
|
+
['--cancellation-poll-interval-ms', cancellationPollIntervalMs, 1_000],
|
|
132
|
+
['--executor-timeout-ms', executorTimeoutMs, 1_000],
|
|
133
|
+
]) {
|
|
134
|
+
if (!Number.isFinite(value) || value < minimum) {
|
|
135
|
+
throw new Error(`${name} must be at least ${minimum}`);
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
return {
|
|
139
|
+
relayUrl: relayUrlRaw ? relayUrlRaw.replace(/\/+$/, '') : null,
|
|
140
|
+
pairingToken,
|
|
141
|
+
profileName,
|
|
142
|
+
force,
|
|
143
|
+
once: args.includes('--once'),
|
|
144
|
+
mockExecuteOnce,
|
|
145
|
+
heartbeatIntervalMs,
|
|
146
|
+
pollIntervalMs,
|
|
147
|
+
cancellationPollIntervalMs,
|
|
148
|
+
workspaceRoot: resolve(workspaceRoot),
|
|
149
|
+
gitCloneBaseUrl,
|
|
150
|
+
executors,
|
|
151
|
+
executorTimeoutMs,
|
|
152
|
+
createCommit,
|
|
153
|
+
version: runnerVersion,
|
|
154
|
+
supportedExecutors,
|
|
155
|
+
};
|
|
156
|
+
}
|
|
157
|
+
export function usageMessage() {
|
|
158
|
+
return usage();
|
|
159
|
+
}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import { chmodSync, mkdirSync, readFileSync, renameSync, statSync, writeFileSync, } from 'node:fs';
|
|
2
|
+
import { homedir } from 'node:os';
|
|
3
|
+
import { dirname, join } from 'node:path';
|
|
4
|
+
const EMPTY_STORE = { version: 1, profiles: {} };
|
|
5
|
+
export function resolveCredentialsPath(env) {
|
|
6
|
+
if (env.DELEGO_CREDENTIALS_PATH) {
|
|
7
|
+
return env.DELEGO_CREDENTIALS_PATH;
|
|
8
|
+
}
|
|
9
|
+
const base = env.XDG_CONFIG_HOME ?? join(env.HOME ?? homedir(), '.config');
|
|
10
|
+
return join(base, 'delego-runner', 'credentials.json');
|
|
11
|
+
}
|
|
12
|
+
export function readStore(path) {
|
|
13
|
+
let raw;
|
|
14
|
+
try {
|
|
15
|
+
raw = readFileSync(path, 'utf8');
|
|
16
|
+
}
|
|
17
|
+
catch (error) {
|
|
18
|
+
if (error.code === 'ENOENT') {
|
|
19
|
+
return { version: 1, profiles: {} };
|
|
20
|
+
}
|
|
21
|
+
throw error;
|
|
22
|
+
}
|
|
23
|
+
try {
|
|
24
|
+
const stat = statSync(path);
|
|
25
|
+
if ((stat.mode & 0o077) !== 0) {
|
|
26
|
+
console.warn(`delego-runner: credentials file ${path} has permissions ${(stat.mode & 0o777).toString(8)}; ` +
|
|
27
|
+
`recommended is 0600. Restrict access with: chmod 600 ${path}`);
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
catch {
|
|
31
|
+
// stat failure on a file we just read is unlikely; ignore.
|
|
32
|
+
}
|
|
33
|
+
let parsed;
|
|
34
|
+
try {
|
|
35
|
+
parsed = JSON.parse(raw);
|
|
36
|
+
}
|
|
37
|
+
catch (error) {
|
|
38
|
+
throw new Error(`delego-runner: credentials file ${path} is not valid JSON: ${error.message}`);
|
|
39
|
+
}
|
|
40
|
+
if (!parsed ||
|
|
41
|
+
typeof parsed !== 'object' ||
|
|
42
|
+
parsed.version !== 1) {
|
|
43
|
+
throw new Error(`delego-runner: credentials file ${path} has unexpected shape (expected version: 1)`);
|
|
44
|
+
}
|
|
45
|
+
const store = parsed;
|
|
46
|
+
if (!store.profiles || typeof store.profiles !== 'object') {
|
|
47
|
+
return { version: 1, profiles: {} };
|
|
48
|
+
}
|
|
49
|
+
return store;
|
|
50
|
+
}
|
|
51
|
+
export function writeStore(path, store) {
|
|
52
|
+
mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
|
|
53
|
+
const tempPath = `${path}.tmp-${process.pid}-${Math.random().toString(36).slice(2)}`;
|
|
54
|
+
writeFileSync(tempPath, JSON.stringify(store, null, 2), { mode: 0o600 });
|
|
55
|
+
renameSync(tempPath, path);
|
|
56
|
+
chmodSync(path, 0o600);
|
|
57
|
+
}
|
|
58
|
+
export function getProfile(store, profileName) {
|
|
59
|
+
return store.profiles[profileName];
|
|
60
|
+
}
|
|
61
|
+
export function upsertProfile(path, profileName, profile) {
|
|
62
|
+
const store = readStoreOrEmpty(path);
|
|
63
|
+
store.profiles[profileName] = profile;
|
|
64
|
+
writeStore(path, store);
|
|
65
|
+
}
|
|
66
|
+
function readStoreOrEmpty(path) {
|
|
67
|
+
try {
|
|
68
|
+
return readStore(path);
|
|
69
|
+
}
|
|
70
|
+
catch (error) {
|
|
71
|
+
if (error.code === 'ENOENT') {
|
|
72
|
+
return { ...EMPTY_STORE, profiles: {} };
|
|
73
|
+
}
|
|
74
|
+
throw error;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { ADAPTERS, ADAPTERS_BY_ID, } from './executor/adapters/index.js';
|
|
2
|
+
import { normalizeThinkingPreference, } from './thinking.js';
|
|
3
|
+
export { promptThinkingInstruction } from './thinking.js';
|
|
4
|
+
function isRecord(value) {
|
|
5
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
6
|
+
}
|
|
7
|
+
function stringOrNull(value) {
|
|
8
|
+
return typeof value === 'string' && value.trim() ? value : null;
|
|
9
|
+
}
|
|
10
|
+
function normalizeExecutor(value) {
|
|
11
|
+
if (typeof value === 'string' && value in ADAPTERS_BY_ID) {
|
|
12
|
+
return value;
|
|
13
|
+
}
|
|
14
|
+
return ADAPTERS[0].id;
|
|
15
|
+
}
|
|
16
|
+
export function normalizeExecutionPreferences(job) {
|
|
17
|
+
const explicit = isRecord(job.executionPreferences)
|
|
18
|
+
? job.executionPreferences
|
|
19
|
+
: null;
|
|
20
|
+
const requestPreferences = isRecord(job.requestPayload.executionPreferences)
|
|
21
|
+
? job.requestPayload.executionPreferences
|
|
22
|
+
: null;
|
|
23
|
+
const nestedPreferences = requestPreferences && isRecord(requestPreferences.preferences)
|
|
24
|
+
? requestPreferences.preferences
|
|
25
|
+
: null;
|
|
26
|
+
const executorValue = explicit?.executor ?? nestedPreferences?.executor;
|
|
27
|
+
const modelValue = explicit?.model ?? nestedPreferences?.model;
|
|
28
|
+
const thinkingValue = explicit?.thinking ?? nestedPreferences?.thinking;
|
|
29
|
+
return {
|
|
30
|
+
executor: normalizeExecutor(executorValue),
|
|
31
|
+
model: stringOrNull(modelValue),
|
|
32
|
+
thinking: normalizeThinkingPreference(thinkingValue),
|
|
33
|
+
};
|
|
34
|
+
}
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
import { promptForJob } from '../prompt.js';
|
|
2
|
+
const defaultArgs = [
|
|
3
|
+
'--print',
|
|
4
|
+
'--verbose',
|
|
5
|
+
'--output-format',
|
|
6
|
+
'stream-json',
|
|
7
|
+
'--dangerously-skip-permissions',
|
|
8
|
+
];
|
|
9
|
+
function summarizeToolInput(name, input) {
|
|
10
|
+
if (!input || typeof input !== 'object')
|
|
11
|
+
return '';
|
|
12
|
+
const record = input;
|
|
13
|
+
const candidateKeys = [
|
|
14
|
+
'file_path',
|
|
15
|
+
'path',
|
|
16
|
+
'command',
|
|
17
|
+
'pattern',
|
|
18
|
+
'url',
|
|
19
|
+
'description',
|
|
20
|
+
];
|
|
21
|
+
for (const key of candidateKeys) {
|
|
22
|
+
const value = record[key];
|
|
23
|
+
if (typeof value === 'string' && value.trim()) {
|
|
24
|
+
const truncated = value.length > 120 ? `${value.slice(0, 117)}…` : value;
|
|
25
|
+
return ` ${truncated}`;
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
if (name === 'TodoWrite' && Array.isArray(record.todos)) {
|
|
29
|
+
return ` (${record.todos.length} todos)`;
|
|
30
|
+
}
|
|
31
|
+
return '';
|
|
32
|
+
}
|
|
33
|
+
function formatAssistantContent(content) {
|
|
34
|
+
const lines = [];
|
|
35
|
+
const replyParts = [];
|
|
36
|
+
let progress = null;
|
|
37
|
+
for (const block of content) {
|
|
38
|
+
if (!block || typeof block !== 'object')
|
|
39
|
+
continue;
|
|
40
|
+
const entry = block;
|
|
41
|
+
if (entry.type === 'text' &&
|
|
42
|
+
typeof entry.text === 'string' &&
|
|
43
|
+
entry.text.trim()) {
|
|
44
|
+
lines.push(entry.text);
|
|
45
|
+
replyParts.push(entry.text);
|
|
46
|
+
if (!progress)
|
|
47
|
+
progress = entry.text.split(/\r?\n/)[0]?.trim() ?? null;
|
|
48
|
+
}
|
|
49
|
+
else if (entry.type === 'tool_use' && typeof entry.name === 'string') {
|
|
50
|
+
const detail = summarizeToolInput(entry.name, entry.input);
|
|
51
|
+
const formatted = `· ${entry.name}${detail}`;
|
|
52
|
+
lines.push(formatted);
|
|
53
|
+
if (!progress)
|
|
54
|
+
progress = `${entry.name}${detail}`;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
return {
|
|
58
|
+
display: lines.join('\n'),
|
|
59
|
+
progress,
|
|
60
|
+
replyText: replyParts.length ? replyParts.join('\n\n') : null,
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
function formatStreamEvent(event) {
|
|
64
|
+
const type = typeof event.type === 'string' ? event.type : '';
|
|
65
|
+
if (type === 'system' && event.subtype === 'init') {
|
|
66
|
+
return { display: null, progress: null };
|
|
67
|
+
}
|
|
68
|
+
if (type === 'assistant' &&
|
|
69
|
+
event.message &&
|
|
70
|
+
typeof event.message === 'object') {
|
|
71
|
+
const content = event.message.content;
|
|
72
|
+
if (Array.isArray(content)) {
|
|
73
|
+
const { display, progress, replyText } = formatAssistantContent(content);
|
|
74
|
+
return {
|
|
75
|
+
display: display.length ? display : null,
|
|
76
|
+
progress,
|
|
77
|
+
finalReply: replyText,
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
if (type === 'user') {
|
|
82
|
+
return { display: null, progress: null };
|
|
83
|
+
}
|
|
84
|
+
if (type === 'result') {
|
|
85
|
+
const subtype = typeof event.subtype === 'string' ? event.subtype : 'completed';
|
|
86
|
+
const ms = typeof event.duration_ms === 'number' ? event.duration_ms : null;
|
|
87
|
+
const cost = typeof event.total_cost_usd === 'number' ? event.total_cost_usd : null;
|
|
88
|
+
const parts = [`claude: ${subtype}`];
|
|
89
|
+
if (ms !== null)
|
|
90
|
+
parts.push(`${(ms / 1000).toFixed(1)}s`);
|
|
91
|
+
if (cost !== null)
|
|
92
|
+
parts.push(`$${cost.toFixed(4)}`);
|
|
93
|
+
return { display: parts.join(' · '), progress: null };
|
|
94
|
+
}
|
|
95
|
+
return { display: null, progress: null };
|
|
96
|
+
}
|
|
97
|
+
export const claudeAdapter = {
|
|
98
|
+
id: 'claude',
|
|
99
|
+
displayName: 'Claude',
|
|
100
|
+
defaultCommand: 'claude',
|
|
101
|
+
defaultArgs,
|
|
102
|
+
flag: { command: '--claude-command', args: '--claude-args' },
|
|
103
|
+
env: { command: 'DELEGO_CLAUDE_COMMAND', args: 'DELEGO_CLAUDE_ARGS' },
|
|
104
|
+
buildInvocation({ job, command, args }) {
|
|
105
|
+
const out = [...args];
|
|
106
|
+
if (job.executionPreferences.model) {
|
|
107
|
+
out.push('--model', job.executionPreferences.model);
|
|
108
|
+
}
|
|
109
|
+
out.push(promptForJob(job));
|
|
110
|
+
return { command, args: out, summaryLabel: 'Claude' };
|
|
111
|
+
},
|
|
112
|
+
formatStdoutLine(line) {
|
|
113
|
+
const trimmed = line.trim();
|
|
114
|
+
if (!trimmed)
|
|
115
|
+
return { display: null, progress: null };
|
|
116
|
+
try {
|
|
117
|
+
const event = JSON.parse(trimmed);
|
|
118
|
+
return formatStreamEvent(event);
|
|
119
|
+
}
|
|
120
|
+
catch {
|
|
121
|
+
return { display: line, progress: null };
|
|
122
|
+
}
|
|
123
|
+
},
|
|
124
|
+
};
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { promptForJob } from '../prompt.js';
|
|
2
|
+
export const codexAdapter = {
|
|
3
|
+
id: 'codex',
|
|
4
|
+
displayName: 'Codex',
|
|
5
|
+
defaultCommand: 'codex',
|
|
6
|
+
defaultArgs: ['exec', '--sandbox', 'workspace-write'],
|
|
7
|
+
flag: { command: '--codex-command', args: '--codex-args' },
|
|
8
|
+
env: { command: 'DELEGO_CODEX_COMMAND', args: 'DELEGO_CODEX_ARGS' },
|
|
9
|
+
buildInvocation({ job, repositoryPath, command, args, }) {
|
|
10
|
+
const out = [...args];
|
|
11
|
+
if (job.executionPreferences.model) {
|
|
12
|
+
out.push('-m', job.executionPreferences.model);
|
|
13
|
+
}
|
|
14
|
+
if (job.executionPreferences.thinking) {
|
|
15
|
+
out.push('--config', `model_reasoning_effort="${job.executionPreferences.thinking}"`);
|
|
16
|
+
}
|
|
17
|
+
out.push('--cd', repositoryPath, promptForJob(job));
|
|
18
|
+
return { command, args: out, summaryLabel: 'Codex' };
|
|
19
|
+
},
|
|
20
|
+
};
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import { claudeAdapter } from './claude.js';
|
|
2
|
+
import { codexAdapter } from './codex.js';
|
|
3
|
+
export const ADAPTERS = [codexAdapter, claudeAdapter];
|
|
4
|
+
export const ADAPTERS_BY_ID = Object.fromEntries(ADAPTERS.map((adapter) => [adapter.id, adapter]));
|
|
5
|
+
export const SUPPORTED_EXECUTOR_IDS = ADAPTERS.map((adapter) => adapter.id);
|
|
6
|
+
export { claudeAdapter, codexAdapter };
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
import { appendBoundedOutput } from '../git/command.js';
|
|
2
|
+
import { pollCancellation, reportProgress, } from '../relay-client.js';
|
|
3
|
+
import { ADAPTERS_BY_ID } from './adapters/index.js';
|
|
4
|
+
import { spawn } from 'node:child_process';
|
|
5
|
+
function terminateProcessGroup(child) {
|
|
6
|
+
if (!child.pid) {
|
|
7
|
+
return;
|
|
8
|
+
}
|
|
9
|
+
try {
|
|
10
|
+
process.kill(-child.pid, 'SIGTERM');
|
|
11
|
+
}
|
|
12
|
+
catch {
|
|
13
|
+
child.kill('SIGTERM');
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
function forceKillProcessGroup(child) {
|
|
17
|
+
if (!child.pid) {
|
|
18
|
+
return;
|
|
19
|
+
}
|
|
20
|
+
try {
|
|
21
|
+
process.kill(-child.pid, 'SIGKILL');
|
|
22
|
+
}
|
|
23
|
+
catch {
|
|
24
|
+
child.kill('SIGKILL');
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
export async function runExecutor(config, job, repositoryPath) {
|
|
28
|
+
const executorId = job.executionPreferences.executor;
|
|
29
|
+
const adapter = ADAPTERS_BY_ID[executorId];
|
|
30
|
+
const options = config.executors[executorId];
|
|
31
|
+
const invocation = adapter.buildInvocation({
|
|
32
|
+
job,
|
|
33
|
+
repositoryPath,
|
|
34
|
+
command: options.command,
|
|
35
|
+
args: options.args,
|
|
36
|
+
});
|
|
37
|
+
return new Promise((resolveExecutor, reject) => {
|
|
38
|
+
console.log(`executor: starting ${invocation.command} ${invocation.args.slice(0, -1).join(' ')} for ${job.id}`);
|
|
39
|
+
const child = spawn(invocation.command, invocation.args, {
|
|
40
|
+
cwd: repositoryPath,
|
|
41
|
+
detached: true,
|
|
42
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
43
|
+
});
|
|
44
|
+
let stdout = '';
|
|
45
|
+
let stderr = '';
|
|
46
|
+
let settled = false;
|
|
47
|
+
let timedOut = false;
|
|
48
|
+
let cancelled = false;
|
|
49
|
+
const finish = (result) => {
|
|
50
|
+
if (settled) {
|
|
51
|
+
return;
|
|
52
|
+
}
|
|
53
|
+
settled = true;
|
|
54
|
+
clearTimeout(timeout);
|
|
55
|
+
clearInterval(cancellationPoll);
|
|
56
|
+
resolveExecutor(result);
|
|
57
|
+
};
|
|
58
|
+
const timeout = setTimeout(() => {
|
|
59
|
+
timedOut = true;
|
|
60
|
+
terminateProcessGroup(child);
|
|
61
|
+
setTimeout(() => forceKillProcessGroup(child), 5_000).unref();
|
|
62
|
+
}, config.executorTimeoutMs);
|
|
63
|
+
const cancellationPoll = setInterval(() => {
|
|
64
|
+
pollCancellation(config, job)
|
|
65
|
+
.then((requested) => {
|
|
66
|
+
if (!requested || settled || cancelled) {
|
|
67
|
+
return;
|
|
68
|
+
}
|
|
69
|
+
cancelled = true;
|
|
70
|
+
terminateProcessGroup(child);
|
|
71
|
+
setTimeout(() => forceKillProcessGroup(child), 5_000).unref();
|
|
72
|
+
})
|
|
73
|
+
.catch((error) => {
|
|
74
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
75
|
+
console.error(`cancellation poll failed for ${job.id}: ${message}`);
|
|
76
|
+
});
|
|
77
|
+
}, config.cancellationPollIntervalMs);
|
|
78
|
+
let stdoutLineBuffer = '';
|
|
79
|
+
let finalReply = null;
|
|
80
|
+
const sendProgress = (summary) => {
|
|
81
|
+
void reportProgress(config, job, summary.slice(0, 240), {
|
|
82
|
+
stream: 'stdout',
|
|
83
|
+
executor: adapter.id,
|
|
84
|
+
}).catch((error) => {
|
|
85
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
86
|
+
console.error(`progress report failed for ${job.id}: ${message}`);
|
|
87
|
+
});
|
|
88
|
+
};
|
|
89
|
+
const handleFormattedLine = (line) => {
|
|
90
|
+
if (!adapter.formatStdoutLine)
|
|
91
|
+
return;
|
|
92
|
+
const formatted = adapter.formatStdoutLine(line);
|
|
93
|
+
if (formatted.display !== null) {
|
|
94
|
+
process.stdout.write(`${formatted.display}\n`);
|
|
95
|
+
}
|
|
96
|
+
if (formatted.progress) {
|
|
97
|
+
sendProgress(`${invocation.summaryLabel}: ${formatted.progress}`);
|
|
98
|
+
}
|
|
99
|
+
if (typeof formatted.finalReply === 'string' &&
|
|
100
|
+
formatted.finalReply.trim()) {
|
|
101
|
+
finalReply = formatted.finalReply;
|
|
102
|
+
}
|
|
103
|
+
};
|
|
104
|
+
child.stdout.on('data', (chunk) => {
|
|
105
|
+
stdout = appendBoundedOutput(stdout, chunk);
|
|
106
|
+
if (adapter.formatStdoutLine) {
|
|
107
|
+
stdoutLineBuffer += chunk.toString('utf8');
|
|
108
|
+
let newlineIndex = stdoutLineBuffer.indexOf('\n');
|
|
109
|
+
while (newlineIndex !== -1) {
|
|
110
|
+
const line = stdoutLineBuffer.slice(0, newlineIndex);
|
|
111
|
+
stdoutLineBuffer = stdoutLineBuffer.slice(newlineIndex + 1);
|
|
112
|
+
handleFormattedLine(line);
|
|
113
|
+
newlineIndex = stdoutLineBuffer.indexOf('\n');
|
|
114
|
+
}
|
|
115
|
+
return;
|
|
116
|
+
}
|
|
117
|
+
process.stdout.write(chunk);
|
|
118
|
+
const line = chunk
|
|
119
|
+
.toString('utf8')
|
|
120
|
+
.split(/\r?\n/)
|
|
121
|
+
.find((part) => part.trim().length > 0);
|
|
122
|
+
if (line) {
|
|
123
|
+
sendProgress(`${invocation.summaryLabel} output: ${line.trim()}`);
|
|
124
|
+
}
|
|
125
|
+
});
|
|
126
|
+
child.stderr.on('data', (chunk) => {
|
|
127
|
+
process.stderr.write(chunk);
|
|
128
|
+
stderr = appendBoundedOutput(stderr, chunk);
|
|
129
|
+
});
|
|
130
|
+
child.on('error', (error) => {
|
|
131
|
+
clearTimeout(timeout);
|
|
132
|
+
clearInterval(cancellationPoll);
|
|
133
|
+
reject(error);
|
|
134
|
+
});
|
|
135
|
+
child.on('close', (exitCode, signal) => {
|
|
136
|
+
if (adapter.formatStdoutLine && stdoutLineBuffer.trim()) {
|
|
137
|
+
handleFormattedLine(stdoutLineBuffer);
|
|
138
|
+
stdoutLineBuffer = '';
|
|
139
|
+
}
|
|
140
|
+
console.log(`executor: exited for ${job.id} with code ${exitCode ?? 'null'} signal ${signal ?? 'none'}`);
|
|
141
|
+
finish({
|
|
142
|
+
exitCode,
|
|
143
|
+
signal,
|
|
144
|
+
stdout,
|
|
145
|
+
stderr,
|
|
146
|
+
timedOut,
|
|
147
|
+
cancelled,
|
|
148
|
+
finalReply,
|
|
149
|
+
});
|
|
150
|
+
});
|
|
151
|
+
});
|
|
152
|
+
}
|
|
153
|
+
export function summarizeExecutorFailure(result) {
|
|
154
|
+
if (result.cancelled) {
|
|
155
|
+
return 'Executor was cancelled by relay request.';
|
|
156
|
+
}
|
|
157
|
+
if (result.timedOut) {
|
|
158
|
+
return 'Executor timed out and was terminated.';
|
|
159
|
+
}
|
|
160
|
+
return `Executor failed with exit code ${result.exitCode ?? 'unknown'}.`;
|
|
161
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { promptThinkingInstruction } from '../thinking.js';
|
|
2
|
+
export function promptForJob(job) {
|
|
3
|
+
const thinkingInstruction = promptThinkingInstruction(job.executionPreferences.thinking);
|
|
4
|
+
return [
|
|
5
|
+
`Linear issue: ${job.linearIssue.identifier}${job.linearIssue.title ? ` - ${job.linearIssue.title}` : ''}`,
|
|
6
|
+
job.linearIssue.url ? `URL: ${job.linearIssue.url}` : null,
|
|
7
|
+
'',
|
|
8
|
+
`Requested executor: ${job.executionPreferences.executor}`,
|
|
9
|
+
job.executionPreferences.model
|
|
10
|
+
? `Requested model: ${job.executionPreferences.model}`
|
|
11
|
+
: null,
|
|
12
|
+
thinkingInstruction,
|
|
13
|
+
'',
|
|
14
|
+
job.promptSummary,
|
|
15
|
+
'',
|
|
16
|
+
'Prompt context JSON:',
|
|
17
|
+
JSON.stringify(job.promptContext, null, 2),
|
|
18
|
+
]
|
|
19
|
+
.filter((part) => part !== null)
|
|
20
|
+
.join('\n');
|
|
21
|
+
}
|