create-openthrottle 1.0.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/index.mjs ADDED
@@ -0,0 +1,335 @@
1
+ #!/usr/bin/env node
2
+ // =============================================================================
3
+ // create-openthrottle — Set up openthrottle in any Node.js project.
4
+ //
5
+ // Usage: npx create-openthrottle
6
+ //
7
+ // Detects the project, prompts for config, generates .openthrottle.yml +
8
+ // wake-sandbox.yml, creates a Daytona snapshot + volume, prints next steps.
9
+ // =============================================================================
10
+
11
+ import { readFileSync, writeFileSync, existsSync, mkdirSync, copyFileSync, readdirSync, statSync } from 'node:fs';
12
+ import { join, dirname } from 'node:path';
13
+ import { execFileSync } from 'node:child_process';
14
+ import { fileURLToPath } from 'node:url';
15
+ import prompts from 'prompts';
16
+ import { stringify } from 'yaml';
17
+ import { Daytona } from '@daytonaio/sdk';
18
+
19
+ const __dirname = dirname(fileURLToPath(import.meta.url));
20
+ const cwd = process.cwd();
21
+
22
+ // ---------------------------------------------------------------------------
23
+ // 1. Detect project
24
+ // ---------------------------------------------------------------------------
25
+
26
+ function detectProject() {
27
+ const pkgPath = join(cwd, 'package.json');
28
+ if (!existsSync(pkgPath)) {
29
+ console.error('No package.json found. create-openthrottle currently supports Node.js projects only.');
30
+ process.exit(1);
31
+ }
32
+
33
+ let pkg;
34
+ try {
35
+ pkg = JSON.parse(readFileSync(pkgPath, 'utf8'));
36
+ } catch {
37
+ console.error('Could not parse package.json. Is it valid JSON?');
38
+ process.exit(1);
39
+ }
40
+ const scripts = pkg.scripts || {};
41
+ const rawName = pkg.name?.replace(/^@[^/]+\//, '') || 'project';
42
+ const name = rawName.toLowerCase().replace(/[^a-z0-9-]/g, '-').replace(/-+/g, '-');
43
+
44
+ // Detect package manager
45
+ let pm = 'npm';
46
+ if (pkg.packageManager?.startsWith('pnpm')) pm = 'pnpm';
47
+ else if (pkg.packageManager?.startsWith('yarn')) pm = 'yarn';
48
+ else if (existsSync(join(cwd, 'pnpm-lock.yaml'))) pm = 'pnpm';
49
+ else if (existsSync(join(cwd, 'yarn.lock'))) pm = 'yarn';
50
+ else if (existsSync(join(cwd, 'package-lock.json'))) pm = 'npm';
51
+
52
+ // Detect base branch
53
+ let baseBranch = 'main';
54
+ try {
55
+ const head = execFileSync('git', ['remote', 'show', 'origin'], { encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'] });
56
+ const match = head.match(/HEAD branch:\s*(\S+)/);
57
+ if (match) baseBranch = match[1];
58
+ } catch {
59
+ // Not a git repo or no remote — default to main
60
+ }
61
+
62
+ return {
63
+ name,
64
+ pm,
65
+ baseBranch,
66
+ test: scripts.test ? `${pm} test` : '',
67
+ build: scripts.build ? `${pm} build` : '',
68
+ lint: scripts.lint ? `${pm} lint` : '',
69
+ format: scripts.format ? `${pm} run format` : (pkg.devDependencies?.prettier ? 'npx prettier --write .' : ''),
70
+ dev: scripts.dev ? `${pm} dev --port 8080 --hostname 0.0.0.0` : '',
71
+ };
72
+ }
73
+
74
+ // ---------------------------------------------------------------------------
75
+ // 2. Prompt for config
76
+ // ---------------------------------------------------------------------------
77
+
78
+ async function promptConfig(detected) {
79
+ console.log(`\n Detected: package.json (${detected.pm})\n`);
80
+
81
+ const response = await prompts([
82
+ { type: 'text', name: 'baseBranch', message: 'Base branch', initial: detected.baseBranch },
83
+ { type: 'text', name: 'test', message: 'Test command', initial: detected.test },
84
+ { type: 'text', name: 'build', message: 'Build command', initial: detected.build },
85
+ { type: 'text', name: 'lint', message: 'Lint command', initial: detected.lint },
86
+ { type: 'text', name: 'format', message: 'Format command', initial: detected.format },
87
+ { type: 'text', name: 'dev', message: 'Dev command', initial: detected.dev },
88
+ { type: 'text', name: 'postBootstrap', message: 'Post-bootstrap command', initial: `${detected.pm} install` },
89
+ {
90
+ type: 'select', name: 'agent', message: 'Agent runtime',
91
+ choices: [
92
+ { title: 'Claude', value: 'claude' },
93
+ { title: 'Codex', value: 'codex' },
94
+ { title: 'Aider', value: 'aider' },
95
+ ],
96
+ initial: 0,
97
+ },
98
+ {
99
+ type: 'select', name: 'notifications', message: 'Notifications',
100
+ choices: [
101
+ { title: 'Telegram', value: 'telegram' },
102
+ { title: 'None', value: 'none' },
103
+ ],
104
+ initial: 0,
105
+ },
106
+ { type: 'confirm', name: 'reviewEnabled', message: 'Enable automated PR review?', initial: true },
107
+ {
108
+ type: (prev) => prev ? 'number' : null,
109
+ name: 'maxRounds', message: 'Max review rounds', initial: 3, min: 1, max: 10,
110
+ },
111
+ {
112
+ type: 'select', name: 'snapshotMode', message: 'Snapshot source',
113
+ choices: [
114
+ { title: 'Pre-built image (faster, recommended)', value: 'image' },
115
+ { title: 'Build from Dockerfile (customizable)', value: 'dockerfile' },
116
+ ],
117
+ initial: 0,
118
+ },
119
+ ], { onCancel: () => { console.log('\nCancelled.'); process.exit(0); } });
120
+
121
+ return { ...detected, ...response };
122
+ }
123
+
124
+ // ---------------------------------------------------------------------------
125
+ // 3. Generate .openthrottle.yml
126
+ // ---------------------------------------------------------------------------
127
+
128
+ function generateConfig(config) {
129
+ const doc = {
130
+ base_branch: config.baseBranch,
131
+ test: config.test || undefined,
132
+ dev: config.dev || undefined,
133
+ format: config.format || undefined,
134
+ lint: config.lint || undefined,
135
+ build: config.build || undefined,
136
+ notifications: config.notifications === 'none' ? undefined : config.notifications,
137
+ agent: config.agent,
138
+ snapshot: `openthrottle`,
139
+ post_bootstrap: [config.postBootstrap],
140
+ mcp_servers: {},
141
+ review: {
142
+ enabled: config.reviewEnabled,
143
+ max_rounds: config.maxRounds ?? 3,
144
+ },
145
+ };
146
+
147
+ // Remove undefined fields
148
+ for (const key of Object.keys(doc)) {
149
+ if (doc[key] === undefined) delete doc[key];
150
+ }
151
+
152
+ const header = [
153
+ '# openthrottle.yml — project config for Open Throttle (Daytona runtime)',
154
+ '# Generated by npx create-openthrottle. Committed to the repo so the',
155
+ '# sandbox knows how to work with this project.',
156
+ '',
157
+ ].join('\n');
158
+
159
+ return header + stringify(doc);
160
+ }
161
+
162
+ // ---------------------------------------------------------------------------
163
+ // 4. Copy wake-sandbox.yml
164
+ // ---------------------------------------------------------------------------
165
+
166
+ function copyWorkflow() {
167
+ const src = join(__dirname, 'templates', 'wake-sandbox.yml');
168
+ const destDir = join(cwd, '.github', 'workflows');
169
+ const dest = join(destDir, 'wake-sandbox.yml');
170
+ mkdirSync(destDir, { recursive: true });
171
+ copyFileSync(src, dest);
172
+ return dest;
173
+ }
174
+
175
+ // ---------------------------------------------------------------------------
176
+ // 5 & 6. Create Daytona snapshot + volume
177
+ // ---------------------------------------------------------------------------
178
+
179
+ async function setupDaytona(config) {
180
+ const apiKey = process.env.DAYTONA_API_KEY;
181
+ if (!apiKey) {
182
+ console.error('\n Missing DAYTONA_API_KEY env var. Get one at https://daytona.io/dashboard\n');
183
+ process.exit(1);
184
+ }
185
+
186
+ const daytona = new Daytona();
187
+ const snapshotName = `openthrottle`;
188
+ const volumeName = `openthrottle-${config.name}`;
189
+
190
+ // Create snapshot — from pre-built image or from Dockerfile
191
+ if (config.snapshotMode === 'dockerfile') {
192
+ // Copy Dockerfile + runtime scripts into user's project for customization
193
+ const dockerDir = join(cwd, '.openthrottle', 'docker');
194
+ mkdirSync(dockerDir, { recursive: true });
195
+
196
+ const templateDir = join(__dirname, 'templates', 'docker');
197
+ if (existsSync(templateDir)) {
198
+ for (const file of readdirSync(templateDir, { recursive: true })) {
199
+ const src = join(templateDir, file);
200
+ const dest = join(dockerDir, file);
201
+ const stat = statSync(src);
202
+ if (stat.isDirectory()) {
203
+ mkdirSync(dest, { recursive: true });
204
+ } else {
205
+ mkdirSync(dirname(dest), { recursive: true });
206
+ copyFileSync(src, dest);
207
+ }
208
+ }
209
+ }
210
+ console.log(' ✓ Copied Dockerfile + runtime to .openthrottle/docker/');
211
+ console.log(' Customize the Dockerfile, then create snapshot:');
212
+ console.log(` daytona snapshot create ${snapshotName} --dockerfile .openthrottle/docker/Dockerfile --build-arg AGENT=${config.agent}`);
213
+ } else {
214
+ const image = `ghcr.io/openthrottle/doer-${config.agent}:node-1.0.0`;
215
+ try {
216
+ await daytona.snapshot.create({
217
+ name: snapshotName,
218
+ image,
219
+ resources: { cpu: 2, memory: 4, disk: 10 },
220
+ });
221
+ console.log(` ✓ Created Daytona snapshot: ${snapshotName}`);
222
+ } catch (err) {
223
+ if (err.status === 409 || err.message?.includes('already exists')) {
224
+ console.log(` ✓ Snapshot already exists: ${snapshotName}`);
225
+ } else {
226
+ console.error(` ✗ Failed to create snapshot: ${err.message}`);
227
+ process.exit(1);
228
+ }
229
+ }
230
+ }
231
+
232
+ // Create volume (idempotent)
233
+ try {
234
+ await daytona.volume.create({ name: volumeName });
235
+ console.log(` ✓ Created Daytona volume: ${volumeName}`);
236
+ } catch (err) {
237
+ if (err.status === 409 || err.message?.includes('already exists')) {
238
+ console.log(` ✓ Volume already exists: ${volumeName}`);
239
+ } else {
240
+ console.error(` ✗ Failed to create volume: ${err.message}`);
241
+ process.exit(1);
242
+ }
243
+ }
244
+
245
+ return { snapshotName, volumeName };
246
+ }
247
+
248
+ // ---------------------------------------------------------------------------
249
+ // 7. Print next steps
250
+ // ---------------------------------------------------------------------------
251
+
252
+ function printNextSteps(config) {
253
+ const agentSecret =
254
+ config.agent === 'claude'
255
+ ? ' ANTHROPIC_API_KEY ← option a: pay-per-use API key\n CLAUDE_CODE_OAUTH_TOKEN ← option b: subscription token (claude setup-token)'
256
+ : config.agent === 'codex'
257
+ ? ' OPENAI_API_KEY ← required for Codex'
258
+ : ' OPENAI_API_KEY ← or ANTHROPIC_API_KEY (depends on your Aider model)';
259
+ const secrets = [
260
+ ' DAYTONA_API_KEY ← required',
261
+ agentSecret,
262
+ ];
263
+
264
+ console.log(`
265
+ Next steps:
266
+
267
+ 1. Set GitHub repo secrets:
268
+ ${secrets.join('\n')}
269
+ TELEGRAM_BOT_TOKEN ← optional (notifications)
270
+ TELEGRAM_CHAT_ID ← optional (notifications)
271
+
272
+ 2. Commit and push:
273
+ git add .openthrottle.yml .github/workflows/wake-sandbox.yml
274
+ git commit -m "feat: add openthrottle config"
275
+ git push
276
+
277
+ 3. Ship your first prompt:
278
+ gh issue create --title "My first feature" \\
279
+ --body-file docs/prds/my-feature.md \\
280
+ --label prd-queued
281
+ `);
282
+ }
283
+
284
+ // ---------------------------------------------------------------------------
285
+ // Main
286
+ // ---------------------------------------------------------------------------
287
+
288
+ async function main() {
289
+ console.log('\n create-openthrottle\n');
290
+
291
+ // Step 1: Detect
292
+ const detected = detectProject();
293
+
294
+ // Step 2: Prompt
295
+ const config = await promptConfig(detected);
296
+
297
+ // Step 3: Generate config
298
+ const configPath = join(cwd, '.openthrottle.yml');
299
+ if (existsSync(configPath)) {
300
+ const { overwrite } = await prompts({
301
+ type: 'confirm', name: 'overwrite',
302
+ message: '.openthrottle.yml already exists. Overwrite?', initial: false,
303
+ }, { onCancel: () => { console.log('\nCancelled.'); process.exit(0); } });
304
+ if (!overwrite) { console.log(' Skipped .openthrottle.yml'); }
305
+ else { writeFileSync(configPath, generateConfig(config)); console.log(' ✓ Generated .openthrottle.yml'); }
306
+ } else {
307
+ writeFileSync(configPath, generateConfig(config));
308
+ console.log(' ✓ Generated .openthrottle.yml');
309
+ }
310
+
311
+ // Step 4: Copy workflow
312
+ const workflowPath = join(cwd, '.github', 'workflows', 'wake-sandbox.yml');
313
+ if (existsSync(workflowPath)) {
314
+ const { overwrite } = await prompts({
315
+ type: 'confirm', name: 'overwrite',
316
+ message: 'wake-sandbox.yml already exists. Overwrite?', initial: false,
317
+ }, { onCancel: () => { console.log('\nCancelled.'); process.exit(0); } });
318
+ if (!overwrite) { console.log(' Skipped wake-sandbox.yml'); }
319
+ else { copyWorkflow(); console.log(' ✓ Copied .github/workflows/wake-sandbox.yml'); }
320
+ } else {
321
+ copyWorkflow();
322
+ console.log(' ✓ Copied .github/workflows/wake-sandbox.yml');
323
+ }
324
+
325
+ // Steps 5 & 6: Daytona setup
326
+ await setupDaytona(config);
327
+
328
+ // Step 7: Next steps
329
+ printNextSteps(config);
330
+ }
331
+
332
+ main().catch((err) => {
333
+ console.error(`\n Error: ${err.message}\n`);
334
+ process.exit(1);
335
+ });
package/package.json ADDED
@@ -0,0 +1,33 @@
1
+ {
2
+ "name": "create-openthrottle",
3
+ "version": "1.0.0",
4
+ "description": "Set up openthrottle in any Node.js project — agent-agnostic, config-driven.",
5
+ "type": "module",
6
+ "bin": {
7
+ "create-openthrottle": "./index.mjs"
8
+ },
9
+ "files": [
10
+ "index.mjs",
11
+ "templates/"
12
+ ],
13
+ "dependencies": {
14
+ "@daytonaio/sdk": "^0.153.0",
15
+ "prompts": "^2.4.2",
16
+ "yaml": "^2.4.0"
17
+ },
18
+ "engines": {
19
+ "node": ">=18"
20
+ },
21
+ "license": "MIT",
22
+ "repository": {
23
+ "type": "git",
24
+ "url": "https://github.com/knoxgraeme/sodaprompts"
25
+ },
26
+ "keywords": [
27
+ "openthrottle",
28
+ "daytona",
29
+ "agent",
30
+ "scaffolder",
31
+ "create"
32
+ ]
33
+ }
@@ -0,0 +1,25 @@
1
+ FROM daytonaio/sandbox:0.6.0
2
+
3
+ # Base image includes: Chromium, Xvfb, xfce4, Node.js, Claude Code, Python, git, curl
4
+ # Build remotely: daytona snapshot create openthrottle --dockerfile ./Dockerfile --context .
5
+
6
+ ARG AGENT=claude
7
+
8
+ USER root
9
+
10
+ RUN apt-get update && apt-get install -y --no-install-recommends \
11
+ gh jq gosu \
12
+ && rm -rf /var/lib/apt/lists/* \
13
+ && npm install -g pnpm agent-browser \
14
+ && if [ "$AGENT" = "codex" ]; then npm install -g @openai/codex; fi \
15
+ && if [ "$AGENT" = "aider" ]; then pip install --no-cache-dir aider-chat; fi \
16
+ && curl -sL "https://github.com/mikefarah/yq/releases/latest/download/yq_linux_amd64" \
17
+ -o /usr/local/bin/yq && chmod +x /usr/local/bin/yq
18
+
19
+ COPY entrypoint.sh run-builder.sh run-reviewer.sh task-adapter.sh agent-lib.sh /opt/openthrottle/
20
+ COPY hooks/ /opt/openthrottle/hooks/
21
+ COPY git-hooks/ /opt/openthrottle/git-hooks/
22
+
23
+ RUN chmod +x /opt/openthrottle/*.sh /opt/openthrottle/hooks/*.sh /opt/openthrottle/git-hooks/*
24
+
25
+ ENTRYPOINT ["/opt/openthrottle/entrypoint.sh"]
@@ -0,0 +1,223 @@
1
+ #!/usr/bin/env bash
2
+ # =============================================================================
3
+ # agent-lib.sh — Shared library for Daytona sandbox runners
4
+ #
5
+ # Sourced by run-builder.sh and run-reviewer.sh. Contains:
6
+ # - log / notify — logging and Telegram notifications
7
+ # - sanitize_secrets — redact secrets from text
8
+ # - invoke_agent — runtime-specific agent invocation with session management
9
+ #
10
+ # Requires: SANDBOX_HOME, LOG_DIR, SESSIONS_DIR, AGENT_RUNTIME, GITHUB_REPO
11
+ # =============================================================================
12
+
13
+ # ---------------------------------------------------------------------------
14
+ # Logging and notifications
15
+ # ---------------------------------------------------------------------------
16
+ RUNNER_NAME="${RUNNER_NAME:-agent}"
17
+
18
+ log() { echo "[${RUNNER_NAME} $(date +%H:%M:%S)] $1" | tee -a "${LOG_DIR}/${RUNNER_NAME}.log"; }
19
+
20
+ notify() {
21
+ if [[ -n "${TELEGRAM_BOT_TOKEN:-}" ]] && [[ -n "${TELEGRAM_CHAT_ID:-}" ]]; then
22
+ local HTTP_CODE
23
+ HTTP_CODE=$(curl -s -o /dev/null -w '%{http_code}' \
24
+ -X POST "https://api.telegram.org/bot${TELEGRAM_BOT_TOKEN}/sendMessage" \
25
+ -d "chat_id=${TELEGRAM_CHAT_ID}" \
26
+ -d "text=$1") || true
27
+ if [[ "$HTTP_CODE" != "200" ]] && [[ "${_NOTIFY_WARNED:-}" != "1" ]]; then
28
+ log "WARNING: Telegram notification failed (HTTP ${HTTP_CODE}). Check TELEGRAM_BOT_TOKEN and TELEGRAM_CHAT_ID."
29
+ _NOTIFY_WARNED=1
30
+ fi
31
+ fi
32
+ }
33
+
34
+ # ---------------------------------------------------------------------------
35
+ # Sanitize secrets from text before posting to GitHub or logs
36
+ # ---------------------------------------------------------------------------
37
+ sanitize_secrets() {
38
+ local TEXT="$1"
39
+ [[ -n "${GITHUB_TOKEN:-}" ]] && TEXT="${TEXT//$GITHUB_TOKEN/[REDACTED]}"
40
+ [[ -n "${TELEGRAM_BOT_TOKEN:-}" ]] && TEXT="${TEXT//$TELEGRAM_BOT_TOKEN/[REDACTED]}"
41
+ [[ -n "${ANTHROPIC_API_KEY:-}" ]] && TEXT="${TEXT//$ANTHROPIC_API_KEY/[REDACTED]}"
42
+ [[ -n "${CLAUDE_CODE_OAUTH_TOKEN:-}" ]] && TEXT="${TEXT//$CLAUDE_CODE_OAUTH_TOKEN/[REDACTED]}"
43
+ [[ -n "${SUPABASE_ACCESS_TOKEN:-}" ]] && TEXT="${TEXT//$SUPABASE_ACCESS_TOKEN/[REDACTED]}"
44
+ [[ -n "${OPENAI_API_KEY:-}" ]] && TEXT="${TEXT//$OPENAI_API_KEY/[REDACTED]}"
45
+ TEXT=$(echo "$TEXT" | sed \
46
+ -e 's/ghp_[A-Za-z0-9_]\{36,\}/[REDACTED]/g' \
47
+ -e 's/ghs_[A-Za-z0-9_]\{36,\}/[REDACTED]/g' \
48
+ -e 's/sk-[A-Za-z0-9_-]\{20,\}/[REDACTED]/g' \
49
+ -e 's/Bearer [^ ]*/Bearer [REDACTED]/g')
50
+ echo "$TEXT"
51
+ }
52
+
53
+ # ---------------------------------------------------------------------------
54
+ # Invoke the agent — runtime-specific command with session management
55
+ #
56
+ # Usage: invoke_agent PROMPT TIMEOUT SESSION_LOG [TASK_KEY]
57
+ # TASK_KEY — unique key for session persistence (e.g. "prd-42", "review-7").
58
+ # Session files are stored on the volume at SESSIONS_DIR.
59
+ # If RESUME_SESSION env var is set, it takes precedence (review-fix flow).
60
+ # ---------------------------------------------------------------------------
61
+ invoke_agent() {
62
+ local PROMPT="$1"
63
+ local AGENT_TIMEOUT="$2"
64
+ local SESSION_LOG="$3"
65
+ local TASK_KEY="${4:-}"
66
+
67
+ local -a SESSION_FLAGS=()
68
+ local ACTIVE_SESSION_ID=""
69
+
70
+ # Priority 1: RESUME_SESSION env var (set by GitHub Action for review-fix flow)
71
+ if [[ -n "${RESUME_SESSION:-}" ]]; then
72
+ SESSION_FLAGS=(--resume "$RESUME_SESSION")
73
+ ACTIVE_SESSION_ID="$RESUME_SESSION"
74
+ log "Resuming session from workflow: ${RESUME_SESSION}"
75
+
76
+ # Priority 2: Session file on volume (cross-sandbox resume)
77
+ elif [[ -n "$TASK_KEY" ]]; then
78
+ local SESSION_FILE="${SESSIONS_DIR}/${TASK_KEY}.id"
79
+ if [[ -f "$SESSION_FILE" ]]; then
80
+ local EXISTING_ID
81
+ EXISTING_ID=$(<"$SESSION_FILE")
82
+ if [[ -n "$EXISTING_ID" ]]; then
83
+ touch "$SESSION_FILE" # refresh mtime to prevent 7-day pruning
84
+ SESSION_FLAGS=(--resume "$EXISTING_ID")
85
+ ACTIVE_SESSION_ID="$EXISTING_ID"
86
+ log "Resuming session from volume: ${EXISTING_ID}"
87
+ else
88
+ log "WARNING: Empty session file for ${TASK_KEY} — starting fresh"
89
+ rm -f "$SESSION_FILE"
90
+ fi
91
+ fi
92
+
93
+ # Create new session if not resuming
94
+ if [[ ${#SESSION_FLAGS[@]} -eq 0 ]]; then
95
+ local NEW_ID
96
+ NEW_ID=$(uuidgen 2>/dev/null || cat /proc/sys/kernel/random/uuid)
97
+ echo "$NEW_ID" > "${SESSIONS_DIR}/${TASK_KEY}.id"
98
+ SESSION_FLAGS=(--session-id "$NEW_ID")
99
+ ACTIVE_SESSION_ID="$NEW_ID"
100
+ log "New session: ${NEW_ID}"
101
+ fi
102
+ fi
103
+
104
+ # Export session ID for post_session_report
105
+ export LAST_SESSION_ID="$ACTIVE_SESSION_ID"
106
+
107
+ case "$AGENT_RUNTIME" in
108
+ claude)
109
+ timeout "${AGENT_TIMEOUT}" claude \
110
+ "${SESSION_FLAGS[@]}" \
111
+ --dangerously-skip-permissions \
112
+ -p "$PROMPT" \
113
+ 2>&1 | tee -a "$SESSION_LOG"
114
+ ;;
115
+ codex)
116
+ local -a MODEL_FLAGS=()
117
+ [[ -n "${AGENT_MODEL:-}" ]] && MODEL_FLAGS=(--model "$AGENT_MODEL")
118
+ timeout "${AGENT_TIMEOUT}" codex \
119
+ "${MODEL_FLAGS[@]}" \
120
+ --approval-mode full-auto \
121
+ --quiet \
122
+ "$PROMPT" \
123
+ 2>&1 | tee -a "$SESSION_LOG"
124
+ ;;
125
+ aider)
126
+ local -a MODEL_FLAGS=()
127
+ [[ -n "${AGENT_MODEL:-}" ]] && MODEL_FLAGS=(--model "$AGENT_MODEL")
128
+ timeout "${AGENT_TIMEOUT}" aider \
129
+ "${MODEL_FLAGS[@]}" \
130
+ --yes \
131
+ --no-auto-commits \
132
+ --message "$PROMPT" \
133
+ 2>&1 | tee -a "$SESSION_LOG"
134
+ ;;
135
+ *)
136
+ log "Unknown AGENT_RUNTIME: ${AGENT_RUNTIME}"
137
+ return 1
138
+ ;;
139
+ esac
140
+ }
141
+
142
+ # ---------------------------------------------------------------------------
143
+ # Handle agent invocation result — common exit code handling
144
+ # ---------------------------------------------------------------------------
145
+ handle_agent_result() {
146
+ local EXIT_CODE=$1
147
+ local TASK_LABEL="$2"
148
+ local TIMEOUT_VAL="$3"
149
+
150
+ case $EXIT_CODE in
151
+ 0) return 0 ;;
152
+ 124)
153
+ log "${TASK_LABEL} timed out after ${TIMEOUT_VAL}s"
154
+ notify "${TASK_LABEL} timed out"
155
+ ;;
156
+ 127)
157
+ log "FATAL: Agent binary '${AGENT_RUNTIME}' not found"
158
+ notify "${TASK_LABEL} failed — agent binary not found"
159
+ return 1
160
+ ;;
161
+ 137)
162
+ log "Agent was OOM-killed during ${TASK_LABEL}"
163
+ notify "${TASK_LABEL} failed — out of memory"
164
+ ;;
165
+ *)
166
+ log "Agent failed with exit code ${EXIT_CODE} during ${TASK_LABEL}"
167
+ notify "${TASK_LABEL} — agent failed (exit ${EXIT_CODE})"
168
+ ;;
169
+ esac
170
+ }
171
+
172
+ # ---------------------------------------------------------------------------
173
+ # Post session report as a PR comment
174
+ # ---------------------------------------------------------------------------
175
+ post_session_report() {
176
+ local PR_NUM="$1"
177
+ local TASK_ID="$2"
178
+ local DURATION="$3"
179
+ local SESSION_LOG="$4"
180
+
181
+ local COMMIT_COUNT FILES_CHANGED
182
+ COMMIT_COUNT=$(git -C "$REPO" rev-list --count "${BASE_BRANCH}..HEAD" 2>/dev/null || echo "0")
183
+ FILES_CHANGED=$(git -C "$REPO" diff --name-only "${BASE_BRANCH}..HEAD" 2>/dev/null | wc -l | tr -d ' ')
184
+
185
+ local CMD_TOTAL CMD_FAILED
186
+ CMD_TOTAL=$(grep -c "\[${TASK_ID}\]" "${LOG_DIR}/bash-commands.log" 2>/dev/null || echo "0")
187
+ CMD_FAILED=$(grep "\[${TASK_ID}\]" "${LOG_DIR}/bash-commands.log" 2>/dev/null \
188
+ | grep -cv '\[exit:0\]' || echo "0")
189
+
190
+ local LOG_TAIL
191
+ LOG_TAIL=$(tail -50 "$SESSION_LOG" 2>/dev/null || echo "(no log)")
192
+ LOG_TAIL=$(sanitize_secrets "$LOG_TAIL")
193
+
194
+ # Include session-id for review-fix resume
195
+ local SESSION_MARKER=""
196
+ if [[ -n "${LAST_SESSION_ID:-}" ]]; then
197
+ SESSION_MARKER="
198
+ <!-- session-id: ${LAST_SESSION_ID} -->"
199
+ fi
200
+
201
+ local COMMENT_ERR
202
+ COMMENT_ERR=$(gh pr comment "$PR_NUM" --repo "$GITHUB_REPO" --body "$(cat <<EOF
203
+ ## Session Report
204
+ ${SESSION_MARKER}
205
+
206
+ | Metric | Value |
207
+ |---|---|
208
+ | Duration | ${DURATION}m |
209
+ | Commits | ${COMMIT_COUNT} |
210
+ | Files changed | ${FILES_CHANGED} |
211
+ | Bash commands | ${CMD_TOTAL} total, ${CMD_FAILED} failed |
212
+
213
+ <details>
214
+ <summary>Command log (last 50 lines)</summary>
215
+
216
+ \`\`\`
217
+ ${LOG_TAIL}
218
+ \`\`\`
219
+
220
+ </details>
221
+ EOF
222
+ )" 2>&1) || log "WARNING: Failed to post session report to PR #${PR_NUM}: ${COMMENT_ERR}"
223
+ }