|
10 | 10 | // Env: |
11 | 11 | // N8N_DEQUEUE_URL n8n webhook that hands back one pending turn (required) |
12 | 12 | // AGENT_WORKER_TOKEN shared bearer sent on every dequeue (required) |
13 | | -// GITHUB_USER box owner's login; the bootstrap route for a new thread (codespaces set this) |
14 | | -// CODESPACE_NAME stable box id; routes a thread back to the box holding its session (codespaces set this) |
| 13 | +// GITHUB_USER box owner's login; the bootstrap route for a new thread (see codespace-env.mjs) |
| 14 | +// CODESPACE_NAME stable box id; routes a thread back to the box holding its session (same source) |
15 | 15 | // TURN_TIMEOUT_MS per-turn limit; keep below the n8n Wait limit (default 25 min) |
16 | 16 | import { execFile } from 'node:child_process'; |
17 | 17 | import { resolve as resolvePath, sep } from 'node:path'; |
18 | 18 | import { setTimeout as sleep } from 'node:timers/promises'; |
19 | 19 |
|
| 20 | +import { codespaceEnv } from '../../scripts/codespace-env.mjs'; |
| 21 | + |
20 | 22 | const DEQUEUE_URL = process.env.N8N_DEQUEUE_URL; |
21 | 23 | const TOKEN = process.env.AGENT_WORKER_TOKEN; |
22 | | -const GITHUB_USER = process.env.GITHUB_USER; |
23 | | -const BOX_ID = process.env.CODESPACE_NAME; |
| 24 | +// Read both identities from the codespace. tmux can give an empty copy of either. |
| 25 | +const GITHUB_USER = codespaceEnv('GITHUB_USER'); |
| 26 | +const BOX_ID = codespaceEnv('CODESPACE_NAME'); |
24 | 27 | const ROOT = '/workspaces'; |
25 | 28 |
|
26 | 29 | const POLL_INTERVAL_MS = 3000; |
@@ -53,20 +56,71 @@ for (const [k, v] of Object.entries({ |
53 | 56 |
|
54 | 57 | // Not fatal, but box pinning needs it: without a box id every turn routes by |
55 | 58 | // owner, so a thread cannot follow the box holding its session. |
56 | | -if (!BOX_ID) console.error('CODESPACE_NAME is not set — box pinning disabled; turns route by githubUser only.'); |
| 59 | +if (!BOX_ID) |
| 60 | + console.error( |
| 61 | + 'CODESPACE_NAME did not resolve — box pinning disabled; turns route by githubUser only.', |
| 62 | + ); |
| 63 | + |
| 64 | +// A turn gets a copy of this environment. Put the correct values in it, because |
| 65 | +// `pnpm dev:up` and `gh -c $CODESPACE_NAME` in the session need them. |
| 66 | +const TURN_ENV = { ...process.env }; |
| 67 | +if (BOX_ID) TURN_ENV.CODESPACE_NAME = BOX_ID; |
| 68 | +if (GITHUB_USER) TURN_ENV.GITHUB_USER = GITHUB_USER; |
| 69 | + |
| 70 | +const CODESPACE_DOCS = '.devcontainer/codespaces/README.md'; |
| 71 | + |
| 72 | +// A session cannot be told any of this after its final message, and a system |
| 73 | +// prompt is not part of the resumed transcript, so send it on every turn. State |
| 74 | +// the turn's hard limits inline: a session that has to read a file to learn them |
| 75 | +// can reply before it gets there. Point at the box docs for the rest — they |
| 76 | +// already cover dev:up, ports, and build cost, and AGENTS.md does not. |
| 77 | +function turnContract(author) { |
| 78 | + return [ |
| 79 | + '# Your runtime', |
| 80 | + 'You are one turn of a Slack thread, driven by an n8n workflow that runs you as a headless', |
| 81 | + '`claude -p` on a GitHub codespace. Your final message is the reply that reaches Slack, so keep', |
| 82 | + 'it short and skip heavy markdown.', |
| 83 | + author ? `You are replying to ${author}.` : '', |
| 84 | + '', |
| 85 | + '# A turn is atomic', |
| 86 | + 'The turn ends when you emit your final message, and everything you started ends with it:', |
| 87 | + 'background Bash tasks are killed, Monitor events never arrive, PushNotification has nowhere to', |
| 88 | + 'go, and ScheduleWakeup never fires. You get no turn of your own afterwards — you cannot speak', |
| 89 | + 'again until a human writes again. So run long work (builds, test suites, restarts) in the', |
| 90 | + 'foreground of this turn and wait for it, or do not start it at all. Never end a turn promising', |
| 91 | + `to verify, check back, or follow up. Work that will not fit the turn limit of ~${Math.round( |
| 92 | + TURN_TIMEOUT_MS / 60_000, |
| 93 | + )} minutes`, |
| 94 | + 'should be split: do the part that fits, then say what to ask for next.', |
| 95 | + '', |
| 96 | + '# This box', |
| 97 | + `You are on codespace ${BOX_ID ?? '(unknown)'}, not a laptop. Before you build, start, or expose`, |
| 98 | + `the app, read ${CODESPACE_DOCS} ("Build and run the app in a session"). It is box-specific and`, |
| 99 | + 'the repo AGENTS.md does not cover it.', |
| 100 | + ] |
| 101 | + .filter(Boolean) |
| 102 | + .join('\n'); |
| 103 | +} |
57 | 104 |
|
58 | | -function runClaude({ message, sessionId, cwd }) { |
| 105 | +function runClaude({ message, sessionId, cwd, author }) { |
59 | 106 | const safeCwd = resolvePath(typeof cwd === 'string' && cwd ? cwd : `${ROOT}/n8n`); |
60 | 107 | if (safeCwd !== ROOT && !safeCwd.startsWith(ROOT + sep)) |
61 | 108 | throw new Error(`cwd must be under ${ROOT}`); |
62 | | - const args = ['-p', '--output-format', 'json', '--dangerously-skip-permissions']; |
| 109 | + const args = [ |
| 110 | + '-p', |
| 111 | + '--output-format', |
| 112 | + 'json', |
| 113 | + '--dangerously-skip-permissions', |
| 114 | + '--append-system-prompt', |
| 115 | + turnContract(typeof author === 'string' ? author : ''), |
| 116 | + ]; |
63 | 117 | if (sessionId) args.push('--resume', sessionId); |
64 | 118 | args.push(message); |
65 | 119 | return new Promise((res, rej) => { |
66 | 120 | execFile( |
67 | 121 | 'claude', |
68 | 122 | args, |
69 | | - { cwd: safeCwd, timeout: TURN_TIMEOUT_MS, maxBuffer: 64 * 1024 * 1024 }, |
| 123 | + { cwd: safeCwd, env: TURN_ENV, timeout: TURN_TIMEOUT_MS, maxBuffer: 64 * 1024 * 1024 }, |
70 | 124 | (err, stdout, stderr) => { |
71 | 125 | try { |
72 | 126 | res(JSON.parse(stdout)); |
|
0 commit comments