@factiii/runner 0.7.0 → 0.8.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/dist/cli.js CHANGED
@@ -8173,7 +8173,7 @@ function installLatest() {
8173
8173
  // src/supervisor.ts
8174
8174
  var import_node_child_process2 = require("node:child_process");
8175
8175
 
8176
- // ../../shared/all/helpers/board-agent-core/claude.ts
8176
+ // ../../shared/all/helpers/board-agent-core/agent.ts
8177
8177
  var import_child_process5 = require("child_process");
8178
8178
 
8179
8179
  // ../../shared/all/helpers/board-agent-core/claude-stream.ts
@@ -8475,6 +8475,145 @@ ${trimmed}` : trimmed;
8475
8475
  };
8476
8476
  }
8477
8477
 
8478
+ // ../../shared/all/helpers/board-agent-core/codex-stream.ts
8479
+ function truncate2(s, max) {
8480
+ return s.length > max ? s.slice(0, max) + "..." : s;
8481
+ }
8482
+ var CHANGE_VERBS = {
8483
+ add: "Write",
8484
+ update: "Edit",
8485
+ delete: "Delete"
8486
+ };
8487
+ function processEvent(e, state, cb) {
8488
+ state.sawValid = true;
8489
+ if (e.type === "thread.started") {
8490
+ if (e.thread_id) state.sessionId = e.thread_id;
8491
+ return;
8492
+ }
8493
+ if (e.type === "turn.failed") {
8494
+ state.failed = true;
8495
+ const message = e.error?.message || "Codex turn failed";
8496
+ state.fullText = message;
8497
+ cb.onLog?.({ type: "error", content: message });
8498
+ return;
8499
+ }
8500
+ if (e.type === "error") {
8501
+ if (e.message) cb.onLog?.({ type: "error", content: e.message });
8502
+ return;
8503
+ }
8504
+ const item = e.item;
8505
+ if (!item?.type) return;
8506
+ if (item.type === "todo_list" && item.items) {
8507
+ const todos = item.items.map((t) => ({
8508
+ content: String(t.text || ""),
8509
+ status: t.completed ? "completed" : "pending"
8510
+ }));
8511
+ cb.onTodos?.(todos);
8512
+ return;
8513
+ }
8514
+ if (e.type === "item.started" && item.type === "command_execution") {
8515
+ if (item.command) {
8516
+ cb.onToolUse?.({
8517
+ id: item.id,
8518
+ name: "Bash",
8519
+ input: { command: item.command }
8520
+ });
8521
+ cb.onLog?.({ type: "tool", content: `$ ${item.command}` });
8522
+ }
8523
+ return;
8524
+ }
8525
+ if (e.type !== "item.completed") return;
8526
+ switch (item.type) {
8527
+ case "agent_message":
8528
+ if (item.text) {
8529
+ state.fullText += state.fullText ? `
8530
+ ${item.text}` : item.text;
8531
+ cb.onLog?.({ type: "text", content: item.text });
8532
+ }
8533
+ return;
8534
+ case "reasoning":
8535
+ if (item.text) cb.onLog?.({ type: "thinking", content: item.text });
8536
+ return;
8537
+ case "command_execution": {
8538
+ const output = item.aggregated_output || "";
8539
+ const isError = item.status === "failed" || (item.exit_code ?? 0) !== 0;
8540
+ cb.onToolResult?.({ toolUseId: item.id, content: output, isError });
8541
+ if (output) {
8542
+ cb.onLog?.({
8543
+ type: isError ? "tool" : "text",
8544
+ content: isError ? truncate2(`Tool error: ${output}`, 2e3) : truncate2(output, 2e3)
8545
+ });
8546
+ }
8547
+ return;
8548
+ }
8549
+ case "file_change":
8550
+ for (const change of item.changes ?? []) {
8551
+ if (!change.path) continue;
8552
+ const verb = CHANGE_VERBS[change.kind ?? ""] ?? "Edit";
8553
+ cb.onLog?.({ type: "tool", content: `${verb} ${change.path}` });
8554
+ }
8555
+ return;
8556
+ case "web_search":
8557
+ if (item.query) {
8558
+ cb.onLog?.({ type: "tool", content: `WebSearch "${item.query}"` });
8559
+ }
8560
+ return;
8561
+ case "mcp_tool_call":
8562
+ cb.onLog?.({
8563
+ type: "tool",
8564
+ content: `MCP ${[item.server, item.tool].filter(Boolean).join(".")}`
8565
+ });
8566
+ return;
8567
+ case "error":
8568
+ if (item.message) cb.onLog?.({ type: "error", content: item.message });
8569
+ return;
8570
+ }
8571
+ }
8572
+ function pipeCodexStream(proc, callbacks) {
8573
+ const state = {
8574
+ fullText: "",
8575
+ sessionId: "",
8576
+ failed: false,
8577
+ sawValid: false,
8578
+ junk: ""
8579
+ };
8580
+ let buffer = "";
8581
+ const drain = (flush) => {
8582
+ const lines = buffer.split("\n");
8583
+ buffer = flush ? "" : lines.pop() || "";
8584
+ for (const line of lines) {
8585
+ const trimmed = line.trim();
8586
+ if (!trimmed) continue;
8587
+ if (trimmed[0] !== "{") {
8588
+ state.junk = state.junk ? `${state.junk}
8589
+ ${trimmed}` : trimmed;
8590
+ continue;
8591
+ }
8592
+ try {
8593
+ processEvent(JSON.parse(trimmed), state, callbacks);
8594
+ } catch (err) {
8595
+ console.error(
8596
+ "[codex-stream] Failed to process line:",
8597
+ err,
8598
+ trimmed.slice(0, 200)
8599
+ );
8600
+ }
8601
+ }
8602
+ };
8603
+ proc.stdout?.on("data", (chunk) => {
8604
+ buffer += chunk.toString();
8605
+ drain(false);
8606
+ });
8607
+ proc.stdout?.on("end", () => {
8608
+ if (buffer.trim()) drain(true);
8609
+ });
8610
+ return {
8611
+ fullText: () => state.fullText || state.junk,
8612
+ sessionId: () => state.sessionId,
8613
+ isError: () => state.failed || Boolean(state.junk) && !state.sawValid
8614
+ };
8615
+ }
8616
+
8478
8617
  // ../../shared/all/helpers/board-agent-core/docker.ts
8479
8618
  var import_child_process3 = require("child_process");
8480
8619
  var import_crypto2 = __toESM(require("crypto"));
@@ -8485,6 +8624,35 @@ var import_net = __toESM(require("net"));
8485
8624
  var import_os2 = __toESM(require("os"));
8486
8625
  var import_path3 = __toESM(require("path"));
8487
8626
  var import_util = require("util");
8627
+
8628
+ // ../../shared/all/helpers/board-agent-core/providers.ts
8629
+ var PROVIDERS = {
8630
+ claude: {
8631
+ label: "Claude Code",
8632
+ bin: "claude",
8633
+ hostStateSubdir: "claude",
8634
+ containerStateDir: "/home/claude/.claude",
8635
+ stateEnvVar: "CLAUDE_CONFIG_DIR",
8636
+ credentialsFile: ".credentials.json"
8637
+ },
8638
+ codex: {
8639
+ label: "Codex",
8640
+ bin: "codex",
8641
+ hostStateSubdir: "codex",
8642
+ containerStateDir: "/home/claude/.codex",
8643
+ stateEnvVar: "CODEX_HOME",
8644
+ credentialsFile: "auth.json"
8645
+ }
8646
+ };
8647
+ function resolveProvider(value2) {
8648
+ return value2 === "codex" ? "codex" : "claude";
8649
+ }
8650
+ function containerCredentialsPath(provider) {
8651
+ const info = PROVIDERS[provider];
8652
+ return `${info.containerStateDir}/${info.credentialsFile}`;
8653
+ }
8654
+
8655
+ // ../../shared/all/helpers/board-agent-core/docker.ts
8488
8656
  var execFileAsync = (0, import_util.promisify)(import_child_process3.execFile);
8489
8657
  function resolveDocker() {
8490
8658
  if (process.env.DOCKER_HOST) return new import_dockerode.default();
@@ -8727,18 +8895,25 @@ async function spawnContainer(containerName, opts) {
8727
8895
  // Persistent agent filesystem for backups (DB dumps etc), surviving the
8728
8896
  // container. Lives next to the host's config JSONs; not configurable.
8729
8897
  ...opts?.backupsHostDir ? ["-v", `${opts.backupsHostDir}:/home/claude/backups`] : [],
8730
- // The space's claude state (credentials.json etc) lives on the HOST and
8731
- // is bind-mounted over its usual cache-volume path, so the one-click
8898
+ // Each provider's state (credentials etc) lives on the HOST and is
8899
+ // bind-mounted over its usual cache-volume path, so the one-click
8732
8900
  // sign-in serves container sessions AND host deploy sessions from the
8733
8901
  // same file (copies would diverge on OAuth refresh-token rotation).
8734
- ...opts?.claudeHostDir ? ["-v", `${opts.claudeHostDir}:${claudeStateDir(opts.spaceSlug)}`] : [],
8902
+ ...Object.entries(opts?.providerHostDirs ?? {}).flatMap(
8903
+ ([provider, hostDir]) => [
8904
+ "-v",
8905
+ `${hostDir}:${providerStateDir(provider, opts.spaceSlug)}`
8906
+ ]
8907
+ ),
8735
8908
  "-e",
8736
8909
  "HOME=/home/claude",
8737
- // Redirect the otherwise-`~/.claude.json` config file into the .claude
8738
- // dir, which is symlinked to this space's dir on the cache volume. Keeps
8739
- // all claude state inside one path the CLI already understands.
8910
+ // Redirect each CLI's config into its ~ state dir, which is symlinked to
8911
+ // this space's dir on the cache volume (see setupContainerCache). Keeps
8912
+ // all agent state inside one path per provider the CLIs already understand.
8740
8913
  "-e",
8741
- "CLAUDE_CONFIG_DIR=/home/claude/.claude",
8914
+ `CLAUDE_CONFIG_DIR=${PROVIDERS.claude.containerStateDir}`,
8915
+ "-e",
8916
+ `CODEX_HOME=${PROVIDERS.codex.containerStateDir}`,
8742
8917
  "-e",
8743
8918
  "NODE_OPTIONS=--max-old-space-size=4096",
8744
8919
  "-e",
@@ -8765,10 +8940,10 @@ async function spawnContainer(containerName, opts) {
8765
8940
  "-f",
8766
8941
  "/dev/null"
8767
8942
  ]);
8768
- await setupContainerCache(containerName, claudeStateDir(opts.spaceSlug));
8943
+ await setupContainerCache(containerName, opts.spaceSlug);
8769
8944
  }
8770
- function claudeStateDir(spaceSlug) {
8771
- return `/cache/claude/${spaceSlug.replace(/[^a-zA-Z0-9._-]/g, "_")}`;
8945
+ function providerStateDir(provider, spaceSlug) {
8946
+ return `/cache/${provider}/${spaceSlug.replace(/[^a-zA-Z0-9._-]/g, "_")}`;
8772
8947
  }
8773
8948
  async function execInContainer(containerName, cmd) {
8774
8949
  const { stdout } = await execFileAsync(
@@ -8844,15 +9019,20 @@ async function execInContainerAsRoot(containerName, cmd) {
8844
9019
  );
8845
9020
  return stdout;
8846
9021
  }
8847
- async function setupContainerCache(containerName, claudeDir) {
8848
- await execInContainer(
8849
- containerName,
8850
- `mkdir -p /cache/repos ${claudeDir} /cache/pnpm-store /cache/pnpm-vstore /cache/pip-cache /cache/npm-cache /cache/apt /cache/xdg-cache`
9022
+ async function setupContainerCache(containerName, spaceSlug) {
9023
+ const stateDirs = Object.keys(PROVIDERS).map(
9024
+ (p) => providerStateDir(p, spaceSlug)
8851
9025
  );
8852
9026
  await execInContainer(
8853
9027
  containerName,
8854
- `ln -sfn ${claudeDir} /home/claude/.claude`
9028
+ `mkdir -p /cache/repos ${stateDirs.join(" ")} /cache/pnpm-store /cache/pnpm-vstore /cache/pip-cache /cache/npm-cache /cache/apt /cache/xdg-cache`
8855
9029
  );
9030
+ for (const [provider, info] of Object.entries(PROVIDERS)) {
9031
+ await execInContainer(
9032
+ containerName,
9033
+ `ln -sfn ${providerStateDir(provider, spaceSlug)} ${info.containerStateDir}`
9034
+ );
9035
+ }
8856
9036
  await execInContainer(
8857
9037
  containerName,
8858
9038
  "printf 'set -g mouse on\\nset -g history-limit 50000\\nset -g set-clipboard on\\nset -ga terminal-features ,*:clipboard\\nbind -T copy-mode MouseDragEnd1Pane send -X copy-selection-and-cancel\\nbind -T copy-mode-vi MouseDragEnd1Pane send -X copy-selection-and-cancel\\n' > /home/claude/.tmux.conf"
@@ -8955,7 +9135,7 @@ var prompts_default = {
8955
9135
  "After Review, the developer may request an optional **Preview** where you set up a live dev environment on demand.",
8956
9136
  "",
8957
9137
  "## Task Tracking",
8958
- "Use the TodoWrite tool to track your progress. Create todos at the start of each phase with your planned steps,",
9138
+ "Use your todo/plan tool to track your progress. Create todos at the start of each phase with your planned steps,",
8959
9139
  "and update their status as you complete them. This is visible to the user in real time \u2014 it's how they know what you're doing.",
8960
9140
  "",
8961
9141
  "## openui-lang",
@@ -8986,7 +9166,7 @@ var prompts_default = {
8986
9166
  "## Environment",
8987
9167
  "- Working directory: /home/claude/workspace",
8988
9168
  "- Full clone with complete git history available.",
8989
- "- Persistent memory at ~/.claude that survives across sessions.",
9169
+ "- Persistent agent memory in your state dir (~/.claude or ~/.codex) that survives across sessions.",
8990
9170
  "- Package caches are shared: pnpm store, npm cache, pip cache are pre-warmed from previous runs."
8991
9171
  ],
8992
9172
  research: [
@@ -9031,17 +9211,17 @@ var prompts_default = {
9031
9211
  "You have sudo access (sudo apk add, sudo service, etc.).",
9032
9212
  "",
9033
9213
  "## Cache & Memory",
9034
- "Your ~/.claude directory persists across sessions. If you've set up this project before,",
9214
+ "Your agent state directory (~/.claude or ~/.codex) persists across sessions. If you've set up this project before,",
9035
9215
  "check your memory for the setup steps \u2014 you can skip re-discovering them.",
9036
9216
  "Package manager caches (pnpm, npm, pip) are shared across containers, so installs will be faster.",
9037
- "After successfully setting up the preview, save the setup steps to your Claude memory.",
9217
+ "After successfully setting up the preview, save the setup steps to your agent memory.",
9038
9218
  "",
9039
9219
  "## Rules",
9040
9220
  "- Always set up the FULL stack (frontend, backend, database, etc.) even if the task only touched one layer.",
9041
9221
  "- Never skip any setup process (seeding, migrations, installs, service setup, etc.).",
9042
9222
  "",
9043
9223
  "## Steps",
9044
- "1. Check your Claude memory for previous setup steps for this project",
9224
+ "1. Check your agent memory for previous setup steps for this project",
9045
9225
  "2. If no memory: read the project README, package.json, docker-compose.yml, or any setup docs",
9046
9226
  "3. Install any system-level services needed (e.g. sudo apk add postgresql, redis, etc.)",
9047
9227
  "4. Start those services (e.g. sudo pg_ctl start, redis-server --daemonize yes, etc.)",
@@ -9050,7 +9230,7 @@ var prompts_default = {
9050
9230
  "7. Build and serve the app \u2014 for web frameworks (React, Next.js, Vite, etc.) ALWAYS run `build` then `start` (e.g. `pnpm build && pnpm start`). Only fall back to a dev server if the build fails.",
9051
9231
  "8. Run the server in the background: nohup <start-command> > /tmp/dev-server.log 2>&1 &",
9052
9232
  "9. Wait a few seconds, then verify the server is running (curl -s http://localhost:<port> or check the log)",
9053
- "10. Save the working setup steps to your Claude memory for next time",
9233
+ "10. Save the working setup steps to your agent memory for next time",
9054
9234
  "",
9055
9235
  "## Ports (CRITICAL)",
9056
9236
  "You are inside a Docker container. Only these ports are published to the host (1:1 mapping \u2014 internal port X is exposed as host port X):",
@@ -9164,12 +9344,12 @@ var prompts_default = {
9164
9344
  "Reference paths ONLY via these variables or relative to the working directory. NEVER hardcode absolute machine paths (like /Users/...) anywhere in the skill - it must run unchanged on any runner. Secrets live in an encrypted store you never touch: their values reach commands only via runner-side injection of the keys declared in required-variables.json.",
9165
9345
  "",
9166
9346
  "## Produce exactly three files",
9167
- "1. .claude/skills/deploy/SKILL.md - the runbook, written for a competent operator: named steps in ship order, the exact commands, what success looks like per step, and explicit stop-and-ask points wherever human judgment or confirmation belongs (e.g. 'confirm before the production cutover'). Plain language; no secret values or key material anywhere in the file - reference secrets ONLY as $KEY env names.",
9168
- `2. .claude/skills/deploy/required-variables.json - a JSON array covering EVERY env key the skill's commands will need throughout, inferred from the description and the repo: [{ "key": string (exact env name), "title": string (short human label), "description": string (one plain sentence: what it is, where the owner finds it), "category": string (what the key serves, grouped how it makes sense for THIS repo - e.g. "server", "client", "database", "deploy access"; pick the categories yourself and reuse them across related keys), "sensitive": boolean }]. Never declare a key the skill doesn't use, never use a key you don't declare.`,
9169
- "3. .claude/skills/deploy/check-environment.sh - a plain bash script that verifies THIS machine can run the whole flow: every binary the skill invokes (with version checks where a minimum matters), daemons that must be up (e.g. the docker engine), platform toolchains (e.g. Xcode selected via xcode-select when iOS builds are in the flow), auth states checkable WITHOUT secrets (e.g. `gh auth status`), and free disk space if builds are heavy. STRICTLY read-only, no secret values needed or touched, finishes in under ~30 seconds. Output protocol, one line per check: `OK: <label>` or `FAIL: <label> - <how to fix it>`; exit 0 only when everything passed. Reference paths only via the environment variables.",
9347
+ "1. .agents/skills/deploy/SKILL.md - the runbook, written for a competent operator. It MUST start with YAML frontmatter (--- name: deploy / description: one line saying this is the repository's release runbook and when to run it ---) so agent CLIs discover it as a skill, followed by: named steps in ship order, the exact commands, what success looks like per step, and explicit stop-and-ask points wherever human judgment or confirmation belongs (e.g. 'confirm before the production cutover'). Plain language; no secret values or key material anywhere in the file - reference secrets ONLY as $KEY env names.",
9348
+ `2. .agents/skills/deploy/required-variables.json - a JSON array covering EVERY env key the skill's commands will need throughout, inferred from the description and the repo: [{ "key": string (exact env name), "title": string (short human label), "description": string (one plain sentence: what it is, where the owner finds it), "category": string (what the key serves, grouped how it makes sense for THIS repo - e.g. "server", "client", "database", "deploy access"; pick the categories yourself and reuse them across related keys), "sensitive": boolean }]. Never declare a key the skill doesn't use, never use a key you don't declare.`,
9349
+ "3. .agents/skills/deploy/check-environment.sh - a plain bash script that verifies THIS machine can run the whole flow: every binary the skill invokes (with version checks where a minimum matters), daemons that must be up (e.g. the docker engine), platform toolchains (e.g. Xcode selected via xcode-select when iOS builds are in the flow), auth states checkable WITHOUT secrets (e.g. `gh auth status`), and free disk space if builds are heavy. STRICTLY read-only, no secret values needed or touched, finishes in under ~30 seconds. Output protocol, one line per check: `OK: <label>` or `FAIL: <label> - <how to fix it>`; exit 0 only when everything passed. Reference paths only via the environment variables.",
9170
9350
  "",
9171
9351
  "## Absolute rules",
9172
- "- Write ONLY those three files under .claude/skills/deploy/.",
9352
+ "- Write ONLY those three files under .agents/skills/deploy/.",
9173
9353
  "- Do NOT git commit or git push; the harness pushes for review.",
9174
9354
  "- Cloud CLIs on this machine (aws, vercel, wrangler, gh...) may already be signed in. You may use them READ-ONLY to verify what exists (project names, repo names, regions) so the skill is accurate - but NEVER create, modify, deploy, or delete anything, and never echo a credential or token value.",
9175
9355
  "- The harness injects required variables per-command at execution time (the owner authorizes each secret-bearing command individually). Write the skill accordingly: commands simply reference the variables; NEVER include steps that check whether variables are set in the environment, and never instruct anyone to export them or put them in a .env/shell/CI config.",
@@ -9180,7 +9360,7 @@ var prompts_default = {
9180
9360
  "",
9181
9361
  "{{FEEDBACK}}",
9182
9362
  "",
9183
- "Apply them by editing .claude/skills/deploy/SKILL.md, .claude/skills/deploy/required-variables.json, and/or .claude/skills/deploy/check-environment.sh in your working directory - the same contract as before: only those three files, keys declared exactly when used, no secret values anywhere, no git commit/push, and paths referenced ONLY via $WORKSPACE / $BACKUPS_DIR / $BUILDS_DIR or relative to the working directory (never absolute machine paths). Keep everything the owner did NOT ask to change untouched."
9363
+ "Apply them by editing .agents/skills/deploy/SKILL.md, .agents/skills/deploy/required-variables.json, and/or .agents/skills/deploy/check-environment.sh in your working directory - the same contract as before: only those three files, keys declared exactly when used, no secret values anywhere, no git commit/push, and paths referenced ONLY via $WORKSPACE / $BACKUPS_DIR / $BUILDS_DIR or relative to the working directory (never absolute machine paths). Keep everything the owner did NOT ask to change untouched."
9184
9364
  ],
9185
9365
  deployRun: [
9186
9366
  "## Deploy run protocol",
@@ -9193,7 +9373,7 @@ var prompts_default = {
9193
9373
  "- If the skill has a step that verifies variables are set, SKIP it - it predates this protocol.",
9194
9374
  '- To run a command that needs any declared variable, print as the LAST line of your message exactly: NEED_SECRETS: <the complete command> - then END your turn. Write the variables unexpanded in that command (e.g. "$DATABASE_URL"). The owner inspects and authorizes it with their password; the runner executes it with every declared variable injected, and your next message contains its redacted output. One command per request.',
9195
9375
  "",
9196
- "- Track the release with TodoWrite: one todo per step in ship order, updated as you go. This is the owner's live timeline.",
9376
+ "- Track the release with your todo/plan tool: one todo per step in ship order, updated as you go. This is the owner's live timeline.",
9197
9377
  "- HUMAN GATES: wherever the skill says to stop and ask (and before anything irreversible the skill flags), print as the LAST line: AWAITING_INPUT: <the question> - then END your turn. The owner's answer arrives as your next message.",
9198
9378
  "- Background execution is fine for parallelism (start a long build, work on other steps meanwhile) - but background tasks only live as long as your turn. Ending your turn KILLS them, and no completion notification can ever reach you. Before ending ANY turn, wait for or reap every background task you started (check its output in-turn). Never end a turn to 'wait for' a task.",
9199
9379
  "- Environment problems (missing tool, PATH) may be fixed and retried; a step's intent must never change. Never print or echo secret values - if output contains one, do not repeat it.",
@@ -9219,23 +9399,23 @@ var prompts_default = {
9219
9399
  ]
9220
9400
  };
9221
9401
 
9222
- // ../../shared/all/helpers/board-agent-core/claude.ts
9223
- async function assertClaudeSignedIn(containerName) {
9402
+ // ../../shared/all/helpers/board-agent-core/agent.ts
9403
+ async function assertAgentSignedIn(provider, containerName) {
9404
+ const info = PROVIDERS[provider];
9224
9405
  try {
9225
9406
  await execInContainer(
9226
9407
  containerName,
9227
- "test -f /home/claude/.claude/.credentials.json"
9408
+ `test -f ${containerCredentialsPath(provider)}`
9228
9409
  );
9229
9410
  } catch {
9230
9411
  throw new Error(
9231
- 'Not signed in to Claude. Use "Sign in with Claude" in Board AI settings, then retry.'
9412
+ `Not signed in to ${info.label}. Use "Sign in with ${info.label}" in Board AI settings, then retry.`
9232
9413
  );
9233
9414
  }
9234
9415
  }
9235
- function runClaudeProc(proc, prompt2, callbacks, errorPrefix) {
9416
+ function runAgentProc(proc, prompt2, stream, label, errorPrefix) {
9236
9417
  return new Promise((resolve, reject) => {
9237
9418
  let errorOutput = "";
9238
- const stream = pipeClaudeStream(proc, callbacks);
9239
9419
  proc.stderr?.on("data", (chunk) => {
9240
9420
  errorOutput += chunk.toString();
9241
9421
  });
@@ -9244,10 +9424,10 @@ function runClaudeProc(proc, prompt2, callbacks, errorPrefix) {
9244
9424
  const sessionId = stream.sessionId();
9245
9425
  if (stream.isError()) {
9246
9426
  reject(
9247
- new Error(text || errorOutput || `Claude exited with code ${code}`)
9427
+ new Error(text || errorOutput || `${label} exited with code ${code}`)
9248
9428
  );
9249
9429
  } else if (code !== 0 && !text) {
9250
- reject(new Error(errorOutput || `Claude exited with code ${code}`));
9430
+ reject(new Error(errorOutput || `${label} exited with code ${code}`));
9251
9431
  } else {
9252
9432
  resolve({ text, sessionId });
9253
9433
  }
@@ -9255,10 +9435,26 @@ function runClaudeProc(proc, prompt2, callbacks, errorPrefix) {
9255
9435
  proc.on("error", (err) => {
9256
9436
  reject(new Error(`${errorPrefix}: ${err.message}`));
9257
9437
  });
9438
+ proc.stdin?.on("error", () => {
9439
+ });
9258
9440
  proc.stdin?.write(prompt2);
9259
9441
  proc.stdin?.end();
9260
9442
  });
9261
9443
  }
9444
+ function dockerExecArgs(containerName, env) {
9445
+ return [
9446
+ "exec",
9447
+ "-i",
9448
+ "--user",
9449
+ "claude",
9450
+ "-w",
9451
+ "/home/claude/workspace",
9452
+ "-e",
9453
+ "HOME=/home/claude",
9454
+ ...Object.entries(env ?? {}).flatMap(([k, v]) => ["-e", `${k}=${v}`]),
9455
+ containerName
9456
+ ];
9457
+ }
9262
9458
  function claudeArgs(opts) {
9263
9459
  const args = [
9264
9460
  "-p",
@@ -9280,69 +9476,71 @@ function claudeArgs(opts) {
9280
9476
  if (opts.resumeSessionId) args.push("--resume", opts.resumeSessionId);
9281
9477
  return args;
9282
9478
  }
9283
- async function spawnClaudeHost(opts) {
9284
- const proc = (0, import_child_process5.spawn)("claude", claudeArgs(opts), {
9285
- cwd: opts.cwd,
9286
- env: { ...await hostEnv(), ...opts.env }
9287
- });
9479
+ var CODEX_EFFORT = {
9480
+ low: "low",
9481
+ medium: "medium",
9482
+ high: "high",
9483
+ max: "high"
9484
+ };
9485
+ function codexArgs(opts, host) {
9486
+ const args = ["exec"];
9487
+ if (opts.resumeSessionId) args.push("resume", opts.resumeSessionId);
9488
+ args.push("--json", "--skip-git-repo-check");
9489
+ if (host) {
9490
+ if (opts.permissionMode === "plan") {
9491
+ args.push("--sandbox", "read-only");
9492
+ } else if (opts.permissionMode === "auto") {
9493
+ args.push("--sandbox", "workspace-write");
9494
+ } else {
9495
+ args.push("--dangerously-bypass-approvals-and-sandbox");
9496
+ }
9497
+ } else {
9498
+ args.push("--dangerously-bypass-approvals-and-sandbox");
9499
+ }
9500
+ const effort = opts.effort ? CODEX_EFFORT[opts.effort] : void 0;
9501
+ if (effort) args.push("-c", `model_reasoning_effort="${effort}"`);
9502
+ args.push("-");
9503
+ return args;
9504
+ }
9505
+ function codexPrompt(opts) {
9506
+ return opts.appendSystemPrompt ? `${opts.appendSystemPrompt}
9507
+
9508
+ ---
9509
+
9510
+ ${opts.prompt}` : opts.prompt;
9511
+ }
9512
+ function spawnAgent(provider, opts) {
9513
+ const base = dockerExecArgs(opts.containerName, opts.env);
9514
+ const info = PROVIDERS[provider];
9515
+ const proc = provider === "codex" ? (0, import_child_process5.spawn)("docker", [...base, info.bin, ...codexArgs(opts, false)]) : (0, import_child_process5.spawn)("docker", [...base, info.bin, ...claudeArgs(opts)]);
9516
+ const stream = provider === "codex" ? pipeCodexStream(proc, opts.callbacks) : pipeClaudeStream(proc, opts.callbacks);
9288
9517
  return {
9289
9518
  proc,
9290
- result: runClaudeProc(
9519
+ result: runAgentProc(
9291
9520
  proc,
9292
- opts.prompt,
9293
- opts.callbacks,
9294
- "Failed to run Claude on the host (is the claude CLI installed?)"
9521
+ provider === "codex" ? codexPrompt(opts) : opts.prompt,
9522
+ stream,
9523
+ info.label,
9524
+ `Failed to run ${info.label} in container`
9295
9525
  )
9296
9526
  };
9297
9527
  }
9298
- function spawnClaude(opts) {
9299
- const args = [
9300
- "exec",
9301
- "-i",
9302
- "--user",
9303
- "claude",
9304
- "-w",
9305
- "/home/claude/workspace",
9306
- "-e",
9307
- "HOME=/home/claude",
9308
- ...Object.entries(opts.env ?? {}).flatMap(([k, v]) => ["-e", `${k}=${v}`]),
9309
- opts.containerName,
9310
- "claude",
9311
- "-p",
9312
- "--output-format",
9313
- "stream-json",
9314
- "--verbose",
9315
- "--allowedTools",
9316
- opts.allowedTools,
9317
- "--permission-mode",
9318
- opts.permissionMode || "bypassPermissions"
9319
- ];
9320
- if (opts.disallowedTools) {
9321
- args.push("--disallowedTools", opts.disallowedTools);
9322
- }
9323
- if (opts.appendSystemPrompt) {
9324
- args.push("--append-system-prompt", opts.appendSystemPrompt);
9325
- }
9326
- if (opts.model) {
9327
- args.push("--model", opts.model);
9328
- }
9329
- if (opts.includePartialMessages) {
9330
- args.push("--include-partial-messages");
9331
- }
9332
- if (opts.effort) {
9333
- args.push("--effort", opts.effort);
9334
- }
9335
- if (opts.resumeSessionId) {
9336
- args.push("--resume", opts.resumeSessionId);
9337
- }
9338
- const proc = (0, import_child_process5.spawn)("docker", args);
9528
+ async function spawnAgentHost(provider, opts) {
9529
+ const info = PROVIDERS[provider];
9530
+ const proc = (0, import_child_process5.spawn)(
9531
+ info.bin,
9532
+ provider === "codex" ? codexArgs(opts, true) : claudeArgs(opts),
9533
+ { cwd: opts.cwd, env: { ...await hostEnv(), ...opts.env } }
9534
+ );
9535
+ const stream = provider === "codex" ? pipeCodexStream(proc, opts.callbacks) : pipeClaudeStream(proc, opts.callbacks);
9339
9536
  return {
9340
9537
  proc,
9341
- result: runClaudeProc(
9538
+ result: runAgentProc(
9342
9539
  proc,
9343
- opts.prompt,
9344
- opts.callbacks,
9345
- "Failed to run Claude in container"
9540
+ provider === "codex" ? codexPrompt(opts) : opts.prompt,
9541
+ stream,
9542
+ info.label,
9543
+ `Failed to run ${info.label} on the host (is the ${info.bin} CLI installed?)`
9346
9544
  )
9347
9545
  };
9348
9546
  }
@@ -9350,37 +9548,23 @@ function sanitizeBranchName(raw) {
9350
9548
  const cleaned = raw.trim().split("\n")[0].toLowerCase().replace(/\s+/g, "-").replace(/[^a-z0-9/_.-]/g, "").replace(/-{2,}/g, "-").replace(/\.{2,}/g, ".").replace(/(^|\/)\./g, "$1").replace(/\.lock(\/|$)/g, "$1").replace(/^[-/.]+|[-/.]+$/g, "").slice(0, 60);
9351
9549
  return cleaned || "board-ai-changes";
9352
9550
  }
9353
- function claudeOneShot(containerName, prompt2, fallback, finish) {
9354
- return new Promise((resolve) => {
9355
- const proc = (0, import_child_process5.spawn)("docker", [
9356
- "exec",
9357
- "-i",
9358
- "--user",
9359
- "claude",
9360
- "-w",
9361
- "/home/claude/workspace",
9362
- "-e",
9363
- "HOME=/home/claude",
9551
+ async function agentOneShot(provider, containerName, prompt2, fallback, finish) {
9552
+ try {
9553
+ const { result } = spawnAgent(provider, {
9364
9554
  containerName,
9365
- "claude",
9366
- "-p",
9367
- "--output-format",
9368
- "text"
9369
- ]);
9370
- let output = "";
9371
- proc.stdout?.on("data", (chunk) => {
9372
- output += chunk.toString();
9373
- });
9374
- proc.on("close", () => resolve(finish(output) || fallback));
9375
- proc.on("error", () => resolve(fallback));
9376
- proc.stdin?.on("error", () => {
9555
+ prompt: prompt2,
9556
+ allowedTools: "",
9557
+ callbacks: {}
9377
9558
  });
9378
- proc.stdin?.write(prompt2);
9379
- proc.stdin?.end();
9380
- });
9559
+ const { text } = await result;
9560
+ return finish(text) || fallback;
9561
+ } catch {
9562
+ return fallback;
9563
+ }
9381
9564
  }
9382
- function generateBranchName(containerName, diff, taskTitle) {
9383
- return claudeOneShot(
9565
+ function generateBranchName(provider, containerName, diff, taskTitle) {
9566
+ return agentOneShot(
9567
+ provider,
9384
9568
  containerName,
9385
9569
  `${prompts_default.branchName}
9386
9570
 
@@ -9391,8 +9575,9 @@ ${diff.slice(0, 8e3)}`,
9391
9575
  sanitizeBranchName
9392
9576
  );
9393
9577
  }
9394
- function generateCommitMessage(containerName, diff) {
9395
- return claudeOneShot(
9578
+ function generateCommitMessage(provider, containerName, diff) {
9579
+ return agentOneShot(
9580
+ provider,
9396
9581
  containerName,
9397
9582
  `${prompts_default.commitMessage}
9398
9583
 
@@ -9862,9 +10047,9 @@ var ContainerCore = class {
9862
10047
  this.containerName = null;
9863
10048
  this.lastConfigKey = "";
9864
10049
  this.setupPromise = null;
9865
- // Positive-only cache: true sticks until sign-out, false is re-probed
9866
- // (an in-terminal `claude /login` can flip it).
9867
- this.claudeLoggedIn = false;
10050
+ // Positive-only cache per provider: true sticks until sign-out, false is
10051
+ // re-probed (an in-terminal login can flip it).
10052
+ this.loggedIn = {};
9868
10053
  this.spaceSlug = options.spaceSlug;
9869
10054
  this.spaceId = options.spaceId ?? null;
9870
10055
  this.emitter = options.emitter;
@@ -9873,8 +10058,8 @@ var ContainerCore = class {
9873
10058
  this.runnerToken = options.runnerToken;
9874
10059
  this.instancePrefix = computeInstancePrefix(options.instanceId);
9875
10060
  }
9876
- setClaudeLoggedIn(value2) {
9877
- this.claudeLoggedIn = value2;
10061
+ setLoggedIn(provider, value2) {
10062
+ this.loggedIn[provider] = value2;
9878
10063
  }
9879
10064
  /** The space segment used in container names: the stable id when we have it,
9880
10065
  * else the slug (legacy / id-less callers). */
@@ -9925,24 +10110,37 @@ var ContainerCore = class {
9925
10110
  }
9926
10111
  }
9927
10112
  // ── Config ──
9928
- async getConfig() {
9929
- const config = this.configProvider.readConfig(this.spaceSlug);
9930
- if (!this.claudeLoggedIn && this.containerName) {
10113
+ /** The CLI agent this space runs (per-space config, default claude). */
10114
+ agentProvider() {
10115
+ return resolveProvider(
10116
+ this.configProvider.readConfig(this.spaceSlug).provider
10117
+ );
10118
+ }
10119
+ /** The space's persisted creds for one provider, probed through the running
10120
+ * container's ~ symlink; false until the env is initialized. */
10121
+ async probeLoggedIn(provider) {
10122
+ if (!this.loggedIn[provider] && this.containerName) {
9931
10123
  try {
9932
10124
  await execInContainer(
9933
10125
  this.containerName,
9934
- "test -f /home/claude/.claude/.credentials.json"
10126
+ `test -f ${containerCredentialsPath(provider)}`
9935
10127
  );
9936
- this.claudeLoggedIn = true;
10128
+ this.loggedIn[provider] = true;
9937
10129
  } catch {
9938
10130
  }
9939
10131
  }
10132
+ return this.loggedIn[provider] ?? false;
10133
+ }
10134
+ async getConfig() {
10135
+ const config = this.configProvider.readConfig(this.spaceSlug);
9940
10136
  return {
9941
10137
  repoUrl: config.repoUrl,
9942
10138
  mainBranch: config.mainBranch,
9943
10139
  gitName: config.gitName,
9944
10140
  gitEmail: config.gitEmail,
9945
- claudeLoggedIn: this.claudeLoggedIn,
10141
+ provider: resolveProvider(config.provider),
10142
+ claudeLoggedIn: await this.probeLoggedIn("claude"),
10143
+ codexLoggedIn: await this.probeLoggedIn("codex"),
9946
10144
  hasGithubToken: Boolean(config.githubToken),
9947
10145
  githubTokenHint: config.githubToken ? `\xB7\xB7\xB7\xB7${config.githubToken.slice(-4)}` : "",
9948
10146
  storageBackend: config.storageBackend ?? "github",
@@ -9959,7 +10157,8 @@ var ContainerCore = class {
9959
10157
  gitName: partial.gitName ?? existing.gitName,
9960
10158
  gitEmail: partial.gitEmail ?? existing.gitEmail,
9961
10159
  githubToken: partial.githubToken?.replace(/\s/g, "") || existing.githubToken,
9962
- storageBackend: partial.storageBackend ?? existing.storageBackend
10160
+ storageBackend: partial.storageBackend ?? existing.storageBackend,
10161
+ provider: partial.provider ?? existing.provider
9963
10162
  };
9964
10163
  this.configProvider.writeConfig(this.spaceSlug, updated);
9965
10164
  return { success: true };
@@ -9970,11 +10169,19 @@ var ContainerCore = class {
9970
10169
  backupsDir() {
9971
10170
  return `${this.spaceDir()}/backups`;
9972
10171
  }
9973
- /** Host home of the space's claude state; bind-mounted into the container
9974
- * and used as CLAUDE_CONFIG_DIR by host deploy sessions. One login, both
9975
- * worlds. */
9976
- claudeDir() {
9977
- return `${this.spaceDir()}/claude`;
10172
+ /** Host home of one provider's state; bind-mounted into the container and
10173
+ * used as the CLI's state env var by host deploy sessions. One login,
10174
+ * both worlds. */
10175
+ providerDir(provider) {
10176
+ return `${this.spaceDir()}/${PROVIDERS[provider].hostStateSubdir}`;
10177
+ }
10178
+ /** Every provider's host state dir, for container mounts - both CLIs are
10179
+ * usable in-terminal regardless of the configured pipeline provider. */
10180
+ providerHostDirs() {
10181
+ return {
10182
+ claude: this.providerDir("claude"),
10183
+ codex: this.providerDir("codex")
10184
+ };
9978
10185
  }
9979
10186
  clearToken(key) {
9980
10187
  const existing = this.configProvider.readConfig(this.spaceSlug);
@@ -10034,7 +10241,7 @@ var ContainerCore = class {
10034
10241
  spawnContainer(name, {
10035
10242
  spaceSlug: this.spaceSlug,
10036
10243
  backupsHostDir: this.backupsDir(),
10037
- claudeHostDir: this.claudeDir()
10244
+ providerHostDirs: this.providerHostDirs()
10038
10245
  }),
10039
10246
  9e4,
10040
10247
  "Starting container"
@@ -14214,7 +14421,8 @@ function toSummary(session) {
14214
14421
  startedAt: session.startedAt,
14215
14422
  targetBranch: session.targetBranch,
14216
14423
  taskTitle: session.taskTitle,
14217
- todos: session.todos
14424
+ todos: session.todos,
14425
+ agentSessionId: session.agentSessionId
14218
14426
  };
14219
14427
  }
14220
14428
  function createEventSink(send, spaceSlug, notify) {
@@ -14257,7 +14465,8 @@ function createEventSink(send, spaceSlug, notify) {
14257
14465
  startedAt: 0,
14258
14466
  targetBranch: "",
14259
14467
  taskTitle: "",
14260
- todos: []
14468
+ todos: [],
14469
+ agentSessionId: ""
14261
14470
  });
14262
14471
  },
14263
14472
  emitLog(session, entry) {
@@ -14296,6 +14505,12 @@ function createEventSink(send, spaceSlug, notify) {
14296
14505
  emitClaudeAuth(payload) {
14297
14506
  send(channel("claude-auth"), payload);
14298
14507
  },
14508
+ emitCodexAuth(payload) {
14509
+ send(channel("codex-auth"), payload);
14510
+ },
14511
+ emitGithubAuth(payload) {
14512
+ send(channel("github-auth"), payload);
14513
+ },
14299
14514
  emitDeployCreator(payload) {
14300
14515
  send(channel("deploy-creator"), payload);
14301
14516
  },
@@ -14474,8 +14689,9 @@ var ClaudeModeEngine = class _ClaudeModeEngine {
14474
14689
  );
14475
14690
  }
14476
14691
  const containerName = this.core.containerName;
14692
+ const provider = this.core.agentProvider();
14477
14693
  await execInContainer(containerName, "mkdir -p /home/claude/workspace");
14478
- await assertClaudeSignedIn(containerName);
14694
+ await assertAgentSignedIn(provider, containerName);
14479
14695
  this.board.messages.push({ role: "user", content: params.question });
14480
14696
  let prompt2;
14481
14697
  if (this.board.sessionId) {
@@ -14486,7 +14702,7 @@ var ClaudeModeEngine = class _ClaudeModeEngine {
14486
14702
  prompt2 = parts2.join("\n");
14487
14703
  }
14488
14704
  const systemPrompt = [params.componentSchema, buildPrompt(prompts_default.boardAI)].filter(Boolean).join("\n\n");
14489
- const { proc, result } = spawnClaude({
14705
+ const { proc, result } = spawnAgent(provider, {
14490
14706
  containerName,
14491
14707
  prompt: prompt2,
14492
14708
  allowedTools: "Read,Grep,Glob,Agent",
@@ -14521,14 +14737,15 @@ var ClaudeModeEngine = class _ClaudeModeEngine {
14521
14737
  }
14522
14738
  this.board = { sessionId: null, process: null, messages: [] };
14523
14739
  }
14524
- // ── Claude runner ──
14740
+ // ── Pipeline runner ──
14525
14741
  /** Strip git mentions from an agent prompt on OneDrive spaces; pass-through otherwise. */
14526
14742
  agentPrompt(text) {
14527
14743
  return this.core.isOneDrive(this.core.readConfig()) ? gitFree(text) : text;
14528
14744
  }
14529
- async runClaudeInContainer(session, prompt2, tools, resumeSessionId, options) {
14530
- await assertClaudeSignedIn(session.containerName);
14531
- const { proc, result } = spawnClaude({
14745
+ async runAgentInContainer(session, prompt2, tools, resumeSessionId, options) {
14746
+ const provider = this.core.agentProvider();
14747
+ await assertAgentSignedIn(provider, session.containerName);
14748
+ const { proc, result } = spawnAgent(provider, {
14532
14749
  containerName: session.containerName,
14533
14750
  prompt: prompt2,
14534
14751
  allowedTools: tools,
@@ -14565,14 +14782,14 @@ var ClaudeModeEngine = class _ClaudeModeEngine {
14565
14782
  // ── Research with Q&A ──
14566
14783
  async runResearch(session, prompt2, resumeSessionId) {
14567
14784
  if (!this.sessions.has(session.postId)) return;
14568
- const { text, sessionId } = await this.runClaudeInContainer(
14785
+ const { text, sessionId } = await this.runAgentInContainer(
14569
14786
  session,
14570
14787
  prompt2,
14571
14788
  "Read,Grep,Glob,Agent,TodoWrite",
14572
14789
  resumeSessionId,
14573
14790
  { permissionMode: "plan", disallowedTools: "AskUserQuestion" }
14574
14791
  );
14575
- session.claudeSessionId = sessionId;
14792
+ session.agentSessionId = sessionId;
14576
14793
  if (!this.sessions.has(session.postId)) return;
14577
14794
  const markerIdx = text.lastIndexOf(_ClaudeModeEngine.QUESTION_MARKER);
14578
14795
  if (markerIdx === -1) return;
@@ -14582,7 +14799,7 @@ var ClaudeModeEngine = class _ClaudeModeEngine {
14582
14799
  this.emitter.addLog(
14583
14800
  session,
14584
14801
  "system",
14585
- "Claude has a question - waiting for your input."
14802
+ "The agent has a question - waiting for your input."
14586
14803
  );
14587
14804
  const answer = await new Promise((resolve) => {
14588
14805
  session.answerResolver = resolve;
@@ -14598,8 +14815,8 @@ var ClaudeModeEngine = class _ClaudeModeEngine {
14598
14815
  // ── Pipeline ──
14599
14816
  /**
14600
14817
  * Fresh session container + sign-in gate (card + merge pipelines). The
14601
- * claude host-dir bind is required: one-click sign-in writes credentials
14602
- * there, not to the cache volume.
14818
+ * provider host-dir binds are required: one-click sign-in writes
14819
+ * credentials there, not to the cache volume.
14603
14820
  */
14604
14821
  async startSessionContainer(session, fuse) {
14605
14822
  this.emitter.addLog(session, "system", "Starting container...");
@@ -14607,9 +14824,9 @@ var ClaudeModeEngine = class _ClaudeModeEngine {
14607
14824
  await spawnContainer(session.containerName, {
14608
14825
  spaceSlug: this.core.spaceSlug,
14609
14826
  fuse,
14610
- claudeHostDir: this.core.claudeDir()
14827
+ providerHostDirs: this.core.providerHostDirs()
14611
14828
  });
14612
- await assertClaudeSignedIn(session.containerName);
14829
+ await assertAgentSignedIn(this.core.agentProvider(), session.containerName);
14613
14830
  }
14614
14831
  /** Clone (cache-aware) + git identity, shared by the card and merge pipelines. */
14615
14832
  async cloneWorkspace(session, config, baseBranch) {
@@ -14683,25 +14900,25 @@ var ClaudeModeEngine = class _ClaudeModeEngine {
14683
14900
  session.step = 2;
14684
14901
  this.emitter.emitSessionUpdate(session);
14685
14902
  this.emitter.addLog(session, "system", `${logPrefix} 2: Implement \u2500\u2500`);
14686
- const implResult = await this.runClaudeInContainer(
14903
+ const implResult = await this.runAgentInContainer(
14687
14904
  session,
14688
14905
  implementPrompt,
14689
14906
  tools,
14690
- session.claudeSessionId
14907
+ session.agentSessionId
14691
14908
  );
14692
- session.claudeSessionId = implResult.sessionId || session.claudeSessionId;
14909
+ session.agentSessionId = implResult.sessionId || session.agentSessionId;
14693
14910
  if (!this.sessions.has(session.postId)) return;
14694
14911
  session.phase = "review";
14695
14912
  session.step = 3;
14696
14913
  this.emitter.emitSessionUpdate(session);
14697
14914
  this.emitter.addLog(session, "system", `${logPrefix} 3: Review \u2500\u2500`);
14698
- const reviewResult = await this.runClaudeInContainer(
14915
+ const reviewResult = await this.runAgentInContainer(
14699
14916
  session,
14700
14917
  this.agentPrompt(buildPrompt(prompts_default.review)),
14701
14918
  tools,
14702
- session.claudeSessionId
14919
+ session.agentSessionId
14703
14920
  );
14704
- session.claudeSessionId = reviewResult.sessionId || session.claudeSessionId;
14921
+ session.agentSessionId = reviewResult.sessionId || session.agentSessionId;
14705
14922
  if (!this.sessions.has(session.postId)) return;
14706
14923
  session.reproSteps = reviewResult.text.trim();
14707
14924
  await this.extractAndComplete(session);
@@ -14722,16 +14939,16 @@ var ClaudeModeEngine = class _ClaudeModeEngine {
14722
14939
  runnerToken: this.core.runnerToken
14723
14940
  });
14724
14941
  }
14725
- const previewResult = await this.runClaudeInContainer(
14942
+ const previewResult = await this.runAgentInContainer(
14726
14943
  session,
14727
14944
  buildPrompt(prompts_default.preview, {
14728
14945
  ports: portsEnv,
14729
14946
  publicPreviewBlock: publicPreviewEnabled ? prompts_default.previewPublic.join("\n") : ""
14730
14947
  }),
14731
14948
  tools,
14732
- session.claudeSessionId
14949
+ session.agentSessionId
14733
14950
  );
14734
- session.claudeSessionId = previewResult.sessionId || session.claudeSessionId;
14951
+ session.agentSessionId = previewResult.sessionId || session.agentSessionId;
14735
14952
  if (!this.sessions.has(session.postId)) return;
14736
14953
  const portMatch = previewResult.text.match(/PREVIEW_PORT:\s*(\d+)/);
14737
14954
  const previewPort = portMatch ? portMatch[1] : "3000";
@@ -14786,12 +15003,15 @@ var ClaudeModeEngine = class _ClaudeModeEngine {
14786
15003
  `${changedFiles.length} file${changedFiles.length !== 1 ? "s" : ""} changed.`
14787
15004
  );
14788
15005
  this.emitter.addLog(session, "system", "Generating commit message...");
15006
+ const provider = this.core.agentProvider();
14789
15007
  session.commitMessage = await generateCommitMessage(
15008
+ provider,
14790
15009
  session.containerName,
14791
15010
  diff
14792
15011
  );
14793
15012
  if (!session.targetBranch) {
14794
15013
  session.targetBranch = await generateBranchName(
15014
+ provider,
14795
15015
  session.containerName,
14796
15016
  diff,
14797
15017
  session.taskTitle
@@ -14812,7 +15032,7 @@ var ClaudeModeEngine = class _ClaudeModeEngine {
14812
15032
  start(base, args) {
14813
15033
  const session = {
14814
15034
  ...base,
14815
- claudeSessionId: "",
15035
+ agentSessionId: "",
14816
15036
  answerResolver: null,
14817
15037
  proc: null
14818
15038
  };
@@ -14840,7 +15060,7 @@ var ClaudeModeEngine = class _ClaudeModeEngine {
14840
15060
  startMerge(base, args) {
14841
15061
  const session = {
14842
15062
  ...base,
14843
- claudeSessionId: "",
15063
+ agentSessionId: "",
14844
15064
  answerResolver: null,
14845
15065
  proc: null
14846
15066
  };
@@ -14899,8 +15119,8 @@ var ClaudeModeEngine = class _ClaudeModeEngine {
14899
15119
  if (!session) throw new Error("No active session for this card.");
14900
15120
  if (session.phase !== "complete")
14901
15121
  throw new Error("Preview is only available once changes are ready.");
14902
- if (!session.claudeSessionId)
14903
- throw new Error("No Claude session to resume for preview.");
15122
+ if (!session.agentSessionId)
15123
+ throw new Error("No agent session to resume for preview.");
14904
15124
  if (session.previewRunning || session.proc)
14905
15125
  return { previewUrl: session.previewUrl };
14906
15126
  try {
@@ -14912,9 +15132,9 @@ var ClaudeModeEngine = class _ClaudeModeEngine {
14912
15132
  return { previewUrl: session.previewUrl };
14913
15133
  }
14914
15134
  /**
14915
- * Push-failure recovery: let Claude repair the workspace (fetch + rebase,
14916
- * conflict resolution, leftover markers) so the engine can retry the
14917
- * commit + push. Ends with all changes uncommitted in the working tree.
15135
+ * Push-failure recovery: let the agent repair the workspace (fetch +
15136
+ * rebase, conflict resolution, leftover markers) so the engine can retry
15137
+ * the commit + push. Ends with all changes uncommitted in the working tree.
14918
15138
  */
14919
15139
  async resolvePushIssue(postId, branchName, errorMessage) {
14920
15140
  const session = this.sessions.get(String(postId));
@@ -14922,18 +15142,18 @@ var ClaudeModeEngine = class _ClaudeModeEngine {
14922
15142
  this.emitter.addLog(
14923
15143
  session,
14924
15144
  "system",
14925
- "Asking Claude to resolve the push issue..."
15145
+ "Asking the agent to resolve the push issue..."
14926
15146
  );
14927
- const { text, sessionId } = await this.runClaudeInContainer(
15147
+ const { text, sessionId } = await this.runAgentInContainer(
14928
15148
  session,
14929
15149
  buildPrompt(prompts_default.pushRecovery, {
14930
15150
  branch: branchName,
14931
15151
  error: errorMessage
14932
15152
  }),
14933
15153
  _ClaudeModeEngine.FULL_TOOLS,
14934
- session.claudeSessionId
15154
+ session.agentSessionId
14935
15155
  );
14936
- session.claudeSessionId = sessionId || session.claudeSessionId;
15156
+ session.agentSessionId = sessionId || session.agentSessionId;
14937
15157
  if (text.trim()) {
14938
15158
  this.emitter.addLog(session, "system", text.trim());
14939
15159
  }
@@ -14955,7 +15175,7 @@ var ClaudeModeEngine = class _ClaudeModeEngine {
14955
15175
  const postId = String(rawPostId);
14956
15176
  const session = this.sessions.get(postId);
14957
15177
  if (!session || session.phase !== "complete") return;
14958
- if (!session.claudeSessionId) return;
15178
+ if (!session.agentSessionId) return;
14959
15179
  session.diff = "";
14960
15180
  session.changedFiles = [];
14961
15181
  session.commitMessage = "";
@@ -15137,11 +15357,16 @@ var TerminalModeEngine = class {
15137
15357
  }
15138
15358
  this.sessions.clear();
15139
15359
  }
15140
- /** Active container for a card, or null when none / terminated. */
15360
+ /**
15361
+ * Container for a card this engine owns, or null when there's no session.
15362
+ * The terminal bridge uses BoardAgentEngine.getCardContainerName instead —
15363
+ * that one resolves cross-mode. Kept in step with it: errored and pushed
15364
+ * sessions still resolve, because neither phase kills the container and a
15365
+ * crashed run is exactly when a shell is worth the most.
15366
+ */
15141
15367
  getCardContainerName(postId) {
15142
15368
  const session = this.sessions.get(String(postId));
15143
15369
  if (!session) return null;
15144
- if (session.phase === "error" || session.phase === "done") return null;
15145
15370
  return session.containerName;
15146
15371
  }
15147
15372
  // ── container + Claude CLI, user drives ──
@@ -15158,7 +15383,7 @@ var TerminalModeEngine = class {
15158
15383
  await spawnContainer(session.containerName, {
15159
15384
  spaceSlug: this.core.spaceSlug,
15160
15385
  fuse: onedrive,
15161
- claudeHostDir: this.core.claudeDir()
15386
+ providerHostDirs: this.core.providerHostDirs()
15162
15387
  });
15163
15388
  if (onedrive) {
15164
15389
  const rotated = await writeRcloneMountConfig(
@@ -15420,6 +15645,7 @@ var TerminalModeEngine = class {
15420
15645
  this.emitter.addLog(session, "system", "Generating commit message...");
15421
15646
  try {
15422
15647
  session.commitMessage = await generateCommitMessage(
15648
+ this.core.agentProvider(),
15423
15649
  session.containerName,
15424
15650
  diff
15425
15651
  );
@@ -15455,21 +15681,22 @@ var TerminalModeEngine = class {
15455
15681
  }
15456
15682
  return await extractDiff(session.containerName);
15457
15683
  }
15458
- async bareListTerminals({
15459
- postId: rawPostId
15460
- }) {
15684
+ /**
15685
+ * `containerOverride` carries the cross-mode container lookup from
15686
+ * BoardAgentEngine so tabs also resolve for cards this engine doesn't own
15687
+ * (a claude session, including a crashed one). Without it we'd report "no
15688
+ * tabs" for the containers users most need to inspect.
15689
+ */
15690
+ async bareListTerminals({ postId: rawPostId }, containerOverride) {
15461
15691
  const postId = String(rawPostId);
15462
15692
  const session = this.sessions.get(postId);
15463
- if (!session) return [];
15464
- if (session.mode !== "bare") return [];
15465
- if (session.phase !== "bare-active" && session.phase !== "bare-review") {
15466
- return [];
15467
- }
15693
+ const containerName = session?.containerName ?? containerOverride;
15694
+ if (!containerName) return [];
15468
15695
  const sanitizedPostId = postId.replace(/[^a-zA-Z0-9._-]/g, "_");
15469
15696
  const prefix = `terminal-${sanitizedPostId}-`;
15470
15697
  try {
15471
15698
  const stdout = await execInContainer(
15472
- session.containerName,
15699
+ containerName,
15473
15700
  `tmux list-panes -a -F '#{session_name}|#{pane_current_command}' 2>/dev/null || true`
15474
15701
  );
15475
15702
  const byId = /* @__PURE__ */ new Map();
@@ -15543,10 +15770,74 @@ var TerminalModeEngine = class {
15543
15770
  }
15544
15771
  };
15545
15772
 
15546
- // ../../shared/all/helpers/board-agent-core/claude-login.ts
15773
+ // ../../shared/all/helpers/board-agent-core/login-exec.ts
15547
15774
  var import_child_process8 = require("child_process");
15548
15775
  var import_util7 = require("util");
15549
15776
  var execFileAsync4 = (0, import_util7.promisify)(import_child_process8.execFile);
15777
+ async function startLoginExec(opts) {
15778
+ const info = PROVIDERS[opts.provider];
15779
+ const stateDir = providerStateDir(opts.provider, opts.spaceSlug);
15780
+ const seg = opts.spaceSlug.replace(/[^a-zA-Z0-9._-]/g, "_");
15781
+ const containerName = `factiii-${opts.instancePrefix}-login-${opts.provider}-${seg}`;
15782
+ await killContainer(containerName);
15783
+ await execFileAsync4("docker", [
15784
+ "run",
15785
+ "-d",
15786
+ "--name",
15787
+ containerName,
15788
+ "--user",
15789
+ "claude",
15790
+ "-e",
15791
+ "HOME=/home/claude",
15792
+ "-e",
15793
+ `${info.stateEnvVar}=${stateDir}`,
15794
+ "-v",
15795
+ "factiii-card-cache:/cache",
15796
+ "-v",
15797
+ `${opts.hostStateDir}:${stateDir}`,
15798
+ "factiii-claude",
15799
+ "tail",
15800
+ "-f",
15801
+ "/dev/null"
15802
+ ]);
15803
+ await execInContainer(containerName, `mkdir -p ${stateDir}`);
15804
+ const docker2 = resolveDocker();
15805
+ const container = docker2.getContainer(containerName);
15806
+ const exec = await container.exec({
15807
+ Cmd: opts.cmd,
15808
+ User: "claude",
15809
+ Env: [
15810
+ "HOME=/home/claude",
15811
+ `${info.stateEnvVar}=${stateDir}`,
15812
+ "TERM=xterm-256color"
15813
+ ],
15814
+ AttachStdin: true,
15815
+ AttachStdout: true,
15816
+ AttachStderr: true,
15817
+ Tty: true
15818
+ });
15819
+ const stream = await exec.start({
15820
+ hijack: true,
15821
+ stdin: true,
15822
+ Tty: true
15823
+ });
15824
+ await exec.resize({ w: 500, h: 50 }).catch(() => {
15825
+ });
15826
+ return { containerName, stream };
15827
+ }
15828
+ async function loginCredentialsExist(provider, containerName, spaceSlug) {
15829
+ try {
15830
+ await execInContainer(
15831
+ containerName,
15832
+ `test -f ${providerStateDir(provider, spaceSlug)}/${PROVIDERS[provider].credentialsFile}`
15833
+ );
15834
+ return true;
15835
+ } catch {
15836
+ return false;
15837
+ }
15838
+ }
15839
+
15840
+ // ../../shared/all/helpers/board-agent-core/claude-login.ts
15550
15841
  var ANSI_RE = /\x1b\[[0-9;?]*[A-Za-z]|\x1b\][^\x07]*(\x07|\x1b\\)|\x1b[=>]/g;
15551
15842
  var ClaudeLoginFlow = class {
15552
15843
  constructor(spaceSlug, instancePrefix, claudeHostDir, onDone) {
@@ -15554,7 +15845,6 @@ var ClaudeLoginFlow = class {
15554
15845
  this.instancePrefix = instancePrefix;
15555
15846
  this.claudeHostDir = claudeHostDir;
15556
15847
  this.onDone = onDone;
15557
- this.docker = null;
15558
15848
  this.stream = null;
15559
15849
  this.containerName = null;
15560
15850
  this.output = "";
@@ -15584,54 +15874,15 @@ var ClaudeLoginFlow = class {
15584
15874
  urlPromise.catch(() => {
15585
15875
  });
15586
15876
  try {
15587
- const seg = this.spaceSlug.replace(/[^a-zA-Z0-9._-]/g, "_");
15588
- const name = `factiii-${this.instancePrefix}-login-${seg}`;
15589
- this.containerName = name;
15590
- await killContainer(name);
15591
- await execFileAsync4("docker", [
15592
- "run",
15593
- "-d",
15594
- "--name",
15595
- name,
15596
- "--user",
15597
- "claude",
15598
- "-e",
15599
- "HOME=/home/claude",
15600
- "-e",
15601
- `CLAUDE_CONFIG_DIR=${claudeStateDir(this.spaceSlug)}`,
15602
- "-v",
15603
- "factiii-card-cache:/cache",
15604
- "-v",
15605
- `${this.claudeHostDir}:${claudeStateDir(this.spaceSlug)}`,
15606
- "factiii-claude",
15607
- "tail",
15608
- "-f",
15609
- "/dev/null"
15610
- ]);
15611
- await execInContainer(name, `mkdir -p ${claudeStateDir(this.spaceSlug)}`);
15612
- this.docker = resolveDocker();
15613
- const container = this.docker.getContainer(name);
15614
- const exec = await container.exec({
15615
- Cmd: ["claude", "auth", "login", "--claudeai"],
15616
- User: "claude",
15617
- Env: [
15618
- "HOME=/home/claude",
15619
- `CLAUDE_CONFIG_DIR=${claudeStateDir(this.spaceSlug)}`,
15620
- "TERM=xterm-256color"
15621
- ],
15622
- AttachStdin: true,
15623
- AttachStdout: true,
15624
- AttachStderr: true,
15625
- Tty: true
15626
- });
15627
- const stream = await exec.start({
15628
- hijack: true,
15629
- stdin: true,
15630
- Tty: true
15877
+ const { containerName, stream } = await startLoginExec({
15878
+ provider: "claude",
15879
+ spaceSlug: this.spaceSlug,
15880
+ instancePrefix: this.instancePrefix,
15881
+ hostStateDir: this.claudeHostDir,
15882
+ cmd: ["claude", "auth", "login", "--claudeai"]
15631
15883
  });
15884
+ this.containerName = containerName;
15632
15885
  this.stream = stream;
15633
- await exec.resize({ w: 500, h: 50 }).catch(() => {
15634
- });
15635
15886
  stream.on("data", (chunk) => this.onData(chunk));
15636
15887
  stream.on("error", () => this.fail("Claude sign-in connection dropped."));
15637
15888
  stream.on("close", () => void this.onClose());
@@ -15697,15 +15948,11 @@ var ClaudeLoginFlow = class {
15697
15948
  // Process ended (or success was printed): the credentials file on the cache
15698
15949
  // volume is the ground truth for whether the sign-in worked.
15699
15950
  async verifyAndComplete(failTail) {
15700
- let ok = false;
15701
- try {
15702
- await execInContainer(
15703
- this.containerName,
15704
- `test -f ${claudeStateDir(this.spaceSlug)}/.credentials.json`
15705
- );
15706
- ok = true;
15707
- } catch {
15708
- }
15951
+ const ok = await loginCredentialsExist(
15952
+ "claude",
15953
+ this.containerName,
15954
+ this.spaceSlug
15955
+ );
15709
15956
  if (ok) {
15710
15957
  try {
15711
15958
  await this.markOnboardingComplete();
@@ -15723,7 +15970,7 @@ var ClaudeLoginFlow = class {
15723
15970
  // The CLI's own wizard-completion marker; without it a fresh space replays
15724
15971
  // onboarding even when signed in.
15725
15972
  async markOnboardingComplete() {
15726
- const configFile = `${claudeStateDir(this.spaceSlug)}/.claude.json`;
15973
+ const configFile = `${providerStateDir("claude", this.spaceSlug)}/.claude.json`;
15727
15974
  const script = [
15728
15975
  `const fs = require('fs')`,
15729
15976
  `let config = {}`,
@@ -15772,15 +16019,295 @@ var ClaudeLoginFlow = class {
15772
16019
  }
15773
16020
  };
15774
16021
 
16022
+ // ../../shared/all/helpers/board-agent-core/codex-login.ts
16023
+ var ANSI_RE2 = /\x1b\[[0-9;?]*[A-Za-z]|\x1b\][^\x07]*(\x07|\x1b\\)|\x1b[=>]/g;
16024
+ var CODE_LINE_RE = /code[^\n]*\n\s*([A-Z0-9]{4,10}(?:-[A-Z0-9]{4,10})+)/i;
16025
+ var CODE_TOKEN_RE = /\b([A-Z0-9]{4,10}-[A-Z0-9]{4,10})\b/;
16026
+ var CodexLoginFlow = class {
16027
+ constructor(spaceSlug, instancePrefix, codexHostDir, onDone) {
16028
+ this.spaceSlug = spaceSlug;
16029
+ this.instancePrefix = instancePrefix;
16030
+ this.codexHostDir = codexHostDir;
16031
+ this.onDone = onDone;
16032
+ this.stream = null;
16033
+ this.containerName = null;
16034
+ this.output = "";
16035
+ this.startResolver = null;
16036
+ this.startRejecter = null;
16037
+ this.settled = false;
16038
+ // Set before start()'s first await so overlapping starts can't race.
16039
+ this.inFlight = false;
16040
+ }
16041
+ get active() {
16042
+ return this.inFlight && !this.settled;
16043
+ }
16044
+ /**
16045
+ * Spawn the login container + PTY and resolve with the verification URL and
16046
+ * one-time code to show the user. Completion (auth.json written / failure)
16047
+ * arrives later via onDone once the CLI's polling settles.
16048
+ */
16049
+ async start() {
16050
+ if (this.active) throw new Error("A Codex sign-in is already running.");
16051
+ this.reset();
16052
+ this.inFlight = true;
16053
+ const startPromise = new Promise(
16054
+ (resolve, reject) => {
16055
+ this.startResolver = resolve;
16056
+ this.startRejecter = reject;
16057
+ }
16058
+ );
16059
+ startPromise.catch(() => {
16060
+ });
16061
+ try {
16062
+ const { containerName, stream } = await startLoginExec({
16063
+ provider: "codex",
16064
+ spaceSlug: this.spaceSlug,
16065
+ instancePrefix: this.instancePrefix,
16066
+ hostStateDir: this.codexHostDir,
16067
+ cmd: ["codex", "login", "--device-auth"]
16068
+ });
16069
+ this.containerName = containerName;
16070
+ this.stream = stream;
16071
+ stream.on("data", (chunk) => this.onData(chunk));
16072
+ stream.on("error", () => this.fail("Codex sign-in connection dropped."));
16073
+ stream.on("close", () => void this.onClose());
16074
+ } catch (err) {
16075
+ if (this.settled) throw new Error("Codex sign-in cancelled.");
16076
+ this.settled = true;
16077
+ this.startResolver = null;
16078
+ this.startRejecter = null;
16079
+ await this.cleanup();
16080
+ throw err;
16081
+ }
16082
+ return startPromise;
16083
+ }
16084
+ async cancel() {
16085
+ if (this.settled) return;
16086
+ this.settled = true;
16087
+ if (this.startRejecter) {
16088
+ const reject = this.startRejecter;
16089
+ this.startResolver = null;
16090
+ this.startRejecter = null;
16091
+ reject(new Error("Codex sign-in cancelled."));
16092
+ }
16093
+ await this.cleanup();
16094
+ }
16095
+ onData(chunk) {
16096
+ this.output += chunk.toString("utf-8").replace(ANSI_RE2, "");
16097
+ if (this.output.length > 2e5) {
16098
+ this.output = this.output.slice(-1e5);
16099
+ }
16100
+ if (this.startResolver) {
16101
+ const url2 = this.output.match(/https:\/\/\S+/)?.[0];
16102
+ const code = this.output.match(CODE_LINE_RE)?.[1] ?? this.output.match(CODE_TOKEN_RE)?.[1];
16103
+ if (url2 && code) {
16104
+ const resolve = this.startResolver;
16105
+ this.startResolver = null;
16106
+ this.startRejecter = null;
16107
+ resolve({ url: url2.replace(/[.,;)\]]+$/, ""), code });
16108
+ }
16109
+ return;
16110
+ }
16111
+ if (this.settled) return;
16112
+ if (/success|logged in/i.test(this.output)) {
16113
+ this.settled = true;
16114
+ void this.verifyAndComplete("");
16115
+ }
16116
+ }
16117
+ // Process ended (or success was printed): the auth.json on the cache volume
16118
+ // is the ground truth for whether the sign-in worked.
16119
+ async verifyAndComplete(failTail) {
16120
+ const ok = await loginCredentialsExist(
16121
+ "codex",
16122
+ this.containerName,
16123
+ this.spaceSlug
16124
+ );
16125
+ await this.cleanup();
16126
+ this.onDone(
16127
+ ok ? { status: "complete" } : {
16128
+ status: "error",
16129
+ message: failTail || "Codex sign-in ended without signing in."
16130
+ }
16131
+ );
16132
+ }
16133
+ async onClose() {
16134
+ if (this.settled) return;
16135
+ const tail2 = this.output.trim().slice(-300);
16136
+ if (this.startRejecter) {
16137
+ this.fail(tail2 || "Codex sign-in ended unexpectedly.");
16138
+ return;
16139
+ }
16140
+ this.settled = true;
16141
+ await this.verifyAndComplete(tail2);
16142
+ }
16143
+ fail(message) {
16144
+ if (this.settled) return;
16145
+ this.settled = true;
16146
+ if (this.startRejecter) {
16147
+ const reject = this.startRejecter;
16148
+ this.startResolver = null;
16149
+ this.startRejecter = null;
16150
+ reject(new Error(message));
16151
+ void this.cleanup();
16152
+ return;
16153
+ }
16154
+ void this.cleanup();
16155
+ this.onDone({ status: "error", message });
16156
+ }
16157
+ async cleanup() {
16158
+ this.stream?.destroy();
16159
+ this.stream = null;
16160
+ if (this.containerName) await killContainer(this.containerName);
16161
+ }
16162
+ reset() {
16163
+ this.output = "";
16164
+ this.settled = false;
16165
+ this.startResolver = null;
16166
+ this.startRejecter = null;
16167
+ }
16168
+ };
16169
+
16170
+ // ../../shared/all/helpers/board-agent-core/github-auth.ts
16171
+ var DEVICE_CODE_URL = "https://github.com/login/device/code";
16172
+ var TOKEN_URL2 = "https://github.com/login/oauth/access_token";
16173
+ var API_BASE = "https://api.github.com";
16174
+ var GITHUB_SCOPES = "repo read:user user:email";
16175
+ var DEVICE_CODE_GRANT2 = "urn:ietf:params:oauth:grant-type:device_code";
16176
+ async function startGithubDeviceCode(clientId) {
16177
+ const res = await fetch(DEVICE_CODE_URL, {
16178
+ method: "POST",
16179
+ headers: {
16180
+ "Content-Type": "application/x-www-form-urlencoded",
16181
+ Accept: "application/json"
16182
+ },
16183
+ body: new URLSearchParams({ client_id: clientId, scope: GITHUB_SCOPES })
16184
+ });
16185
+ if (!res.ok) {
16186
+ throw new Error(`GitHub device-code start failed: ${await res.text()}`);
16187
+ }
16188
+ const data = await res.json();
16189
+ if (data.error || !data.device_code || !data.user_code) {
16190
+ throw new Error(
16191
+ data.error_description || data.error || "GitHub returned no device code. Is device flow enabled on the OAuth App?"
16192
+ );
16193
+ }
16194
+ return {
16195
+ deviceCode: data.device_code,
16196
+ userCode: data.user_code,
16197
+ verificationUri: data.verification_uri || "https://github.com/login/device",
16198
+ expiresIn: data.expires_in ?? 900,
16199
+ interval: data.interval ?? 5
16200
+ };
16201
+ }
16202
+ async function pollGithubDeviceCode(clientId, deviceCode) {
16203
+ const res = await fetch(TOKEN_URL2, {
16204
+ method: "POST",
16205
+ headers: {
16206
+ "Content-Type": "application/x-www-form-urlencoded",
16207
+ Accept: "application/json"
16208
+ },
16209
+ body: new URLSearchParams({
16210
+ client_id: clientId,
16211
+ device_code: deviceCode,
16212
+ grant_type: DEVICE_CODE_GRANT2
16213
+ })
16214
+ });
16215
+ const data = await res.json();
16216
+ if (data.access_token) {
16217
+ return { status: "complete", accessToken: data.access_token };
16218
+ }
16219
+ switch (data.error) {
16220
+ case "authorization_pending":
16221
+ return { status: "pending" };
16222
+ case "slow_down":
16223
+ return { status: "slow_down" };
16224
+ case "expired_token":
16225
+ return { status: "expired" };
16226
+ case "access_denied":
16227
+ return { status: "declined" };
16228
+ default:
16229
+ return {
16230
+ status: "error",
16231
+ message: data.error_description || data.error || "GitHub sign-in failed."
16232
+ };
16233
+ }
16234
+ }
16235
+ async function githubApi(token, path11) {
16236
+ const res = await fetch(`${API_BASE}${path11}`, {
16237
+ headers: {
16238
+ Authorization: `Bearer ${token}`,
16239
+ Accept: "application/vnd.github+json",
16240
+ "X-GitHub-Api-Version": "2022-11-28"
16241
+ }
16242
+ });
16243
+ if (!res.ok) {
16244
+ throw new Error(
16245
+ `GitHub ${path11} failed (${res.status}): ${await res.text()}`
16246
+ );
16247
+ }
16248
+ return await res.json();
16249
+ }
16250
+ async function fetchGithubIdentity(token) {
16251
+ const user = await githubApi(token, "/user");
16252
+ let email = user.email ?? "";
16253
+ if (!email) {
16254
+ try {
16255
+ const emails = await githubApi(token, "/user/emails");
16256
+ email = emails.find((e) => e.primary && e.verified)?.email ?? emails.find((e) => e.verified)?.email ?? "";
16257
+ } catch {
16258
+ }
16259
+ }
16260
+ if (!email) email = `${user.id}+${user.login}@users.noreply.github.com`;
16261
+ return { login: user.login, name: user.name || user.login, email };
16262
+ }
16263
+ async function listGithubRepos(token) {
16264
+ const out = [];
16265
+ for (let page = 1; page <= 3; page++) {
16266
+ const batch = await githubApi(token, `/user/repos?per_page=100&sort=pushed&page=${page}`);
16267
+ for (const r of batch) {
16268
+ if (r.permissions && !r.permissions.push) continue;
16269
+ out.push({
16270
+ fullName: r.full_name,
16271
+ // Strip any embedded credentials; buildCloneUrl adds the token.
16272
+ cloneUrl: r.clone_url.replace(/\/\/[^@/]*@/, "//"),
16273
+ defaultBranch: r.default_branch,
16274
+ private: r.private
16275
+ });
16276
+ }
16277
+ if (batch.length < 100) break;
16278
+ }
16279
+ return out;
16280
+ }
16281
+ async function listGithubBranches(token, fullName) {
16282
+ const branches = await githubApi(
16283
+ token,
16284
+ `/repos/${fullName}/branches?per_page=100`
16285
+ );
16286
+ return branches.map((b) => b.name);
16287
+ }
16288
+
15775
16289
  // ../../shared/all/helpers/board-agent-core/host-deploy.ts
15776
16290
  var import_child_process9 = require("child_process");
15777
16291
  var import_promises2 = __toESM(require("fs/promises"));
15778
16292
  var import_path5 = __toESM(require("path"));
15779
16293
  var import_util8 = require("util");
15780
16294
  var execFileAsync5 = (0, import_util8.promisify)(import_child_process9.execFile);
15781
- var SKILL_PATH = ".claude/skills/deploy/SKILL.md";
15782
- var VARIABLES_PATH = ".claude/skills/deploy/required-variables.json";
15783
- var CHECKER_PATH = ".claude/skills/deploy/check-environment.sh";
16295
+ var SKILL_DIR = ".agents/skills/deploy";
16296
+ var SKILL_PATH = `${SKILL_DIR}/SKILL.md`;
16297
+ var VARIABLES_PATH = `${SKILL_DIR}/required-variables.json`;
16298
+ var CHECKER_PATH = `${SKILL_DIR}/check-environment.sh`;
16299
+ var CLAUDE_SKILLS_DIR = ".claude/skills";
16300
+ var CLAUDE_LINK = `${CLAUDE_SKILLS_DIR}/deploy`;
16301
+ async function ensureClaudeSkillLink(workspace) {
16302
+ const linkPath = import_path5.default.join(workspace, CLAUDE_LINK);
16303
+ try {
16304
+ await import_promises2.default.lstat(linkPath);
16305
+ return;
16306
+ } catch {
16307
+ }
16308
+ await import_promises2.default.mkdir(import_path5.default.join(workspace, CLAUDE_SKILLS_DIR), { recursive: true });
16309
+ await import_promises2.default.symlink("../../.agents/skills/deploy", linkPath, "dir");
16310
+ }
15784
16311
  var GIT_NO_HELPER = ["-c", "credential.helper="];
15785
16312
  var GIT_ENV = { ...process.env, GIT_TERMINAL_PROMPT: "0" };
15786
16313
  async function hostGit(cwd, ...args) {
@@ -15872,11 +16399,12 @@ var requiredVariableSchema = external_exports.object({
15872
16399
  category: external_exports.string().catch("general"),
15873
16400
  sensitive: external_exports.boolean().catch(true)
15874
16401
  });
15875
- function hostClaudeEnv(spaceDir) {
15876
- return {
15877
- ...spaceEnv(spaceDir),
15878
- CLAUDE_CONFIG_DIR: import_path5.default.join(spaceDir, "claude")
15879
- };
16402
+ function hostAgentEnv(spaceDir) {
16403
+ const env = spaceEnv(spaceDir);
16404
+ for (const info of Object.values(PROVIDERS)) {
16405
+ env[info.stateEnvVar] = import_path5.default.join(spaceDir, info.hostStateSubdir);
16406
+ }
16407
+ return env;
15880
16408
  }
15881
16409
  function spaceEnv(spaceDir) {
15882
16410
  return {
@@ -15935,7 +16463,7 @@ async function generateDeploySkill(opts) {
15935
16463
  type: "system",
15936
16464
  content: "Analyzing the repo and writing the deploy skill..."
15937
16465
  });
15938
- const { result } = await spawnClaudeHost({
16466
+ const { result } = await spawnAgentHost(opts.provider, {
15939
16467
  cwd: workspace,
15940
16468
  prompt: buildPrompt(prompts_default.deployCreate, {
15941
16469
  INSTRUCTIONS: instructions
@@ -15943,11 +16471,12 @@ async function generateDeploySkill(opts) {
15943
16471
  allowedTools: "Read,Grep,Glob,Bash,Edit,Write,WebSearch,WebFetch",
15944
16472
  // auto: the per-action classifier blocks escalations/unrecognized-infra
15945
16473
  // actions - a host session with no human reviewing each command should
15946
- // not run bypassPermissions.
16474
+ // not run bypassPermissions. (Codex maps this to a workspace-write
16475
+ // sandbox; see agent.ts.)
15947
16476
  permissionMode: "auto",
15948
16477
  model: "claude-opus-4-6",
15949
16478
  includePartialMessages: true,
15950
- env: hostClaudeEnv(opts.spaceDir),
16479
+ env: hostAgentEnv(opts.spaceDir),
15951
16480
  callbacks: { onLog: (entry) => sendLog(entry) }
15952
16481
  });
15953
16482
  const { sessionId } = await result;
@@ -15959,7 +16488,7 @@ async function reviseDeploySkill(opts) {
15959
16488
  if (!feedback.trim()) throw new Error("Corrections are empty.");
15960
16489
  const workspace = import_path5.default.join(opts.spaceDir, "workspace");
15961
16490
  sendLog({ type: "system", content: "Applying your corrections..." });
15962
- const { result } = await spawnClaudeHost({
16491
+ const { result } = await spawnAgentHost(opts.provider, {
15963
16492
  cwd: workspace,
15964
16493
  prompt: buildPrompt(prompts_default.deployCreateRevise, {
15965
16494
  FEEDBACK: feedback
@@ -15969,7 +16498,7 @@ async function reviseDeploySkill(opts) {
15969
16498
  model: "claude-opus-4-6",
15970
16499
  includePartialMessages: true,
15971
16500
  resumeSessionId: opts.resumeSessionId || void 0,
15972
- env: hostClaudeEnv(opts.spaceDir),
16501
+ env: hostAgentEnv(opts.spaceDir),
15973
16502
  callbacks: { onLog: (entry) => sendLog(entry) }
15974
16503
  });
15975
16504
  const { sessionId } = await result;
@@ -15996,6 +16525,7 @@ async function loadSkillDraft(opts) {
15996
16525
  async function approveDeploySkill(opts) {
15997
16526
  const workspace = import_path5.default.join(opts.spaceDir, "workspace");
15998
16527
  await readContract(workspace);
16528
+ await ensureClaudeSkillLink(workspace);
15999
16529
  if (opts.gitName)
16000
16530
  await hostGit(workspace, "config", "user.name", opts.gitName);
16001
16531
  if (opts.gitEmail)
@@ -16091,7 +16621,7 @@ async function fixEnvironment(opts) {
16091
16621
  type: "system",
16092
16622
  content: "Setting this machine up to pass the environment check..."
16093
16623
  });
16094
- const { result } = await spawnClaudeHost({
16624
+ const { result } = await spawnAgentHost(opts.provider, {
16095
16625
  cwd: workspace,
16096
16626
  prompt: buildPrompt(prompts_default.deployEnvFix, {
16097
16627
  CHECKS: failed || before.output.slice(-2e3),
@@ -16103,7 +16633,7 @@ async function fixEnvironment(opts) {
16103
16633
  permissionMode: "auto",
16104
16634
  model: "claude-opus-4-6",
16105
16635
  includePartialMessages: true,
16106
- env: hostClaudeEnv(opts.spaceDir),
16636
+ env: hostAgentEnv(opts.spaceDir),
16107
16637
  callbacks: { onLog: (entry) => sendLog(entry) }
16108
16638
  });
16109
16639
  await result;
@@ -16375,14 +16905,20 @@ async function startDeployRun(opts) {
16375
16905
  `origin/${status.branch}`
16376
16906
  );
16377
16907
  await hostGit(workspace, "clean", "-fd");
16908
+ await ensureClaudeSkillLink(workspace);
16378
16909
  const contract = prompts_default.deployRun.join("\n");
16379
- let prompt2 = "/deploy\n\nReminder: every declared deploy variable is intentionally unset in your shell. Never check for them or abort over them - route any command that needs one through NEED_SECRETS and the runner injects the values.";
16910
+ const reminder = "Reminder: every declared deploy variable is intentionally unset in your shell. Never check for them or abort over them - route any command that needs one through NEED_SECRETS and the runner injects the values.";
16911
+ let prompt2 = opts.provider === "codex" ? `Use the "deploy" skill: load it and execute it step by step.
16912
+
16913
+ ${reminder}` : `/deploy
16914
+
16915
+ ${reminder}`;
16380
16916
  let sessionId = "";
16381
16917
  let markerless = 0;
16382
16918
  let todos = [];
16383
16919
  for (; ; ) {
16384
16920
  if (hooks.isCancelled()) throw new Error("Cancelled by user.");
16385
- const { proc, result } = await spawnClaudeHost({
16921
+ const { proc, result } = await spawnAgentHost(opts.provider, {
16386
16922
  cwd: workspace,
16387
16923
  prompt: prompt2,
16388
16924
  allowedTools: "Bash,Read,Grep,Glob,TodoWrite,WebSearch,WebFetch",
@@ -16394,7 +16930,7 @@ async function startDeployRun(opts) {
16394
16930
  includePartialMessages: true,
16395
16931
  resumeSessionId: sessionId || void 0,
16396
16932
  appendSystemPrompt: sessionId ? void 0 : contract,
16397
- env: hostClaudeEnv(opts.spaceDir),
16933
+ env: hostAgentEnv(opts.spaceDir),
16398
16934
  callbacks: {
16399
16935
  initialTodos: todos,
16400
16936
  onLog: (entry) => hooks.sendLog(entry),
@@ -16532,8 +17068,9 @@ var BoardAgentEngine = class _BoardAgentEngine {
16532
17068
  this.runResolvers = {};
16533
17069
  this.runProc = null;
16534
17070
  this.runCancelled = false;
16535
- // ── One-click Claude sign-in (see claude-login.ts) ──
17071
+ // ── One-click sign-in (see claude-login.ts / codex-login.ts) ──
16536
17072
  this.claudeLogin = null;
17073
+ this.codexLogin = null;
16537
17074
  this.core = new ContainerCore(options);
16538
17075
  this.emitter = options.emitter;
16539
17076
  this.claude = new ClaudeModeEngine(this.core, this.emitter);
@@ -16679,6 +17216,7 @@ var BoardAgentEngine = class _BoardAgentEngine {
16679
17216
  repoUrl: opts.repoUrl,
16680
17217
  mainBranch: opts.mainBranch,
16681
17218
  githubToken: opts.githubToken,
17219
+ provider: opts.provider,
16682
17220
  hooks: {
16683
17221
  sendLog: bufferLog,
16684
17222
  onTodos: (todos) => this.setRun({ todos }),
@@ -16868,6 +17406,7 @@ var BoardAgentEngine = class _BoardAgentEngine {
16868
17406
  repoUrl: config.repoUrl,
16869
17407
  mainBranch: config.mainBranch,
16870
17408
  githubToken: config.githubToken,
17409
+ provider: this.core.agentProvider(),
16871
17410
  sendLog
16872
17411
  };
16873
17412
  }
@@ -17017,6 +17556,104 @@ var BoardAgentEngine = class _BoardAgentEngine {
17017
17556
  this.core.clearOneDriveConnection();
17018
17557
  return { success: true };
17019
17558
  }
17559
+ // ── GitHub device-code auth (see github-auth.ts) ──
17560
+ // The resulting access token lands in the space config's `githubToken`, the
17561
+ // same field a hand-pasted PAT used, so buildCloneUrl and everything
17562
+ // downstream is unchanged.
17563
+ async githubStartDeviceCode(p) {
17564
+ const start = await startGithubDeviceCode(p.clientId);
17565
+ console.log(
17566
+ `[github] device-code started: userCode=${start.userCode} expiresIn=${start.expiresIn}s interval=${start.interval}s`
17567
+ );
17568
+ void this.pollGithubUntilDone(
17569
+ p.clientId,
17570
+ start.deviceCode,
17571
+ start.interval,
17572
+ start.expiresIn
17573
+ );
17574
+ return {
17575
+ userCode: start.userCode,
17576
+ verificationUri: start.verificationUri,
17577
+ expiresIn: start.expiresIn
17578
+ };
17579
+ }
17580
+ async pollGithubUntilDone(clientId, deviceCode, interval, expiresIn) {
17581
+ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
17582
+ const deadline = Date.now() + expiresIn * 1e3;
17583
+ let wait = Math.max(interval, 5);
17584
+ while (Date.now() < deadline) {
17585
+ await sleep(wait * 1e3);
17586
+ let res;
17587
+ try {
17588
+ res = await pollGithubDeviceCode(clientId, deviceCode);
17589
+ } catch (err) {
17590
+ console.log(`[github] poll threw (will retry): ${String(err)}`);
17591
+ continue;
17592
+ }
17593
+ if (res.status === "pending") continue;
17594
+ if (res.status === "slow_down") {
17595
+ wait += 5;
17596
+ continue;
17597
+ }
17598
+ if (res.status === "complete") {
17599
+ try {
17600
+ let login = "";
17601
+ const patch = {
17602
+ githubToken: res.accessToken
17603
+ };
17604
+ try {
17605
+ const id = await fetchGithubIdentity(res.accessToken);
17606
+ login = id.login;
17607
+ const existing = this.core.readConfig();
17608
+ if (!existing.gitName) patch.gitName = id.name;
17609
+ if (!existing.gitEmail) patch.gitEmail = id.email;
17610
+ } catch (err) {
17611
+ console.log(`[github] identity lookup failed: ${String(err)}`);
17612
+ }
17613
+ this.core.setConfig(patch);
17614
+ console.log("[github] token saved locally");
17615
+ this.emitter.emitGithubAuth({ status: "complete", login });
17616
+ } catch (err) {
17617
+ console.log(`[github] saving the token failed: ${String(err)}`);
17618
+ this.emitter.emitGithubAuth({
17619
+ status: "error",
17620
+ message: "Failed to store the GitHub token on the host."
17621
+ });
17622
+ }
17623
+ return;
17624
+ }
17625
+ this.emitter.emitGithubAuth({
17626
+ status: res.status,
17627
+ message: res.status === "error" ? res.message : void 0
17628
+ });
17629
+ return;
17630
+ }
17631
+ console.log("[github] device code reached deadline without completion");
17632
+ this.emitter.emitGithubAuth({ status: "expired" });
17633
+ }
17634
+ githubDisconnect() {
17635
+ this.core.clearToken("githubToken");
17636
+ return { success: true };
17637
+ }
17638
+ async githubIdentity() {
17639
+ const { githubToken } = this.core.readConfig();
17640
+ if (!githubToken) return null;
17641
+ try {
17642
+ return await fetchGithubIdentity(githubToken);
17643
+ } catch {
17644
+ return null;
17645
+ }
17646
+ }
17647
+ async githubListRepos() {
17648
+ const { githubToken } = this.core.readConfig();
17649
+ if (!githubToken) throw new Error("Connect GitHub first.");
17650
+ return listGithubRepos(githubToken);
17651
+ }
17652
+ async githubListBranches(p) {
17653
+ const { githubToken } = this.core.readConfig();
17654
+ if (!githubToken) throw new Error("Connect GitHub first.");
17655
+ return listGithubBranches(githubToken, p.fullName);
17656
+ }
17020
17657
  async claudeLoginStart() {
17021
17658
  if (!this.core.isReady()) {
17022
17659
  throw new Error("Initialize the environment first, then sign in.");
@@ -17024,12 +17661,15 @@ var BoardAgentEngine = class _BoardAgentEngine {
17024
17661
  this.claudeLogin ??= new ClaudeLoginFlow(
17025
17662
  this.core.spaceSlug,
17026
17663
  this.core.instancePrefix,
17027
- this.core.claudeDir(),
17664
+ this.core.providerDir("claude"),
17028
17665
  (result) => {
17029
- if (result.status === "complete") this.core.setClaudeLoggedIn(true);
17666
+ if (result.status === "complete") {
17667
+ this.core.setLoggedIn("claude", true);
17668
+ }
17030
17669
  this.emitter.emitClaudeAuth(result);
17031
17670
  }
17032
17671
  );
17672
+ if (this.claudeLogin.active) await this.claudeLogin.cancel();
17033
17673
  return this.claudeLogin.start();
17034
17674
  }
17035
17675
  claudeLoginCode(p) {
@@ -17040,15 +17680,49 @@ var BoardAgentEngine = class _BoardAgentEngine {
17040
17680
  await this.claudeLogin?.cancel();
17041
17681
  return { success: true };
17042
17682
  }
17683
+ async codexLoginStart() {
17684
+ if (!this.core.isReady()) {
17685
+ throw new Error("Initialize the environment first, then sign in.");
17686
+ }
17687
+ this.codexLogin ??= new CodexLoginFlow(
17688
+ this.core.spaceSlug,
17689
+ this.core.instancePrefix,
17690
+ this.core.providerDir("codex"),
17691
+ (result) => {
17692
+ if (result.status === "complete") {
17693
+ this.core.setLoggedIn("codex", true);
17694
+ }
17695
+ this.emitter.emitCodexAuth(result);
17696
+ }
17697
+ );
17698
+ if (this.codexLogin.active) await this.codexLogin.cancel();
17699
+ return this.codexLogin.start();
17700
+ }
17701
+ async codexLoginCancel() {
17702
+ await this.codexLogin?.cancel();
17703
+ return { success: true };
17704
+ }
17705
+ // The ~ dirs symlink to the space's state dirs on the cache volume.
17043
17706
  async claudeLogout() {
17044
17707
  if (!this.core.isReady()) {
17045
17708
  throw new Error("Initialize the environment first.");
17046
17709
  }
17047
17710
  await execInContainer(
17048
17711
  this.core.containerName,
17049
- "rm -f /home/claude/.claude/.credentials.json"
17712
+ `rm -f ${containerCredentialsPath("claude")}`
17713
+ );
17714
+ this.core.setLoggedIn("claude", false);
17715
+ return { success: true };
17716
+ }
17717
+ async codexLogout() {
17718
+ if (!this.core.isReady()) {
17719
+ throw new Error("Initialize the environment first.");
17720
+ }
17721
+ await execInContainer(
17722
+ this.core.containerName,
17723
+ `rm -f ${containerCredentialsPath("codex")}`
17050
17724
  );
17051
- this.core.setClaudeLoggedIn(false);
17725
+ this.core.setLoggedIn("codex", false);
17052
17726
  return { success: true };
17053
17727
  }
17054
17728
  // Unmount the workspace (flushing writes) and persist the rotated refresh
@@ -17128,7 +17802,10 @@ var BoardAgentEngine = class _BoardAgentEngine {
17128
17802
  return this.terminal.bareGitStatus(p);
17129
17803
  }
17130
17804
  bareListTerminals(p) {
17131
- return this.terminal.bareListTerminals(p);
17805
+ return this.terminal.bareListTerminals(
17806
+ p,
17807
+ this.getCardContainerName(String(p.postId)) ?? void 0
17808
+ );
17132
17809
  }
17133
17810
  bareEditorState(p) {
17134
17811
  return this.terminal.bareEditorState(p);
@@ -17194,6 +17871,8 @@ var BoardAgentEngine = class _BoardAgentEngine {
17194
17871
  targetBranch: targetBranch || "",
17195
17872
  effort: effort || "high",
17196
17873
  todos: [],
17874
+ // Claude mode overwrites this once the CLI reports its conversation id.
17875
+ agentSessionId: "",
17197
17876
  // Name by stable spaceId when we have it; fall back to the id-less legacy
17198
17877
  // form for old callers that only send the slug.
17199
17878
  containerName: this.core.spaceId != null ? `factiii-${this.core.instancePrefix}-card-${this.core.spaceId}-${taskId}` : `factiii-${this.core.instancePrefix}-card-${taskId}`
@@ -17213,7 +17892,7 @@ var BoardAgentEngine = class _BoardAgentEngine {
17213
17892
  return { taskId, postId };
17214
17893
  }
17215
17894
  /**
17216
- * One-shot claude call on the runner host: combine the selected cards'
17895
+ * One-shot agent call on the runner host: combine the selected cards'
17217
17896
  * text into the merged card. The server never talks to a model.
17218
17897
  */
17219
17898
  async cardMergeContent(payload) {
@@ -17222,12 +17901,12 @@ var BoardAgentEngine = class _BoardAgentEngine {
17222
17901
  (c) => `### ${c.title.trim() || "(untitled)"}
17223
17902
  ${c.content.trim() || "(no description)"}`
17224
17903
  ).join("\n\n");
17225
- const { result } = await spawnClaudeHost({
17904
+ const { result } = await spawnAgentHost(this.core.agentProvider(), {
17226
17905
  cwd: spaceDir,
17227
17906
  prompt: buildPrompt(prompts_default.mergeCards, { cards }),
17228
17907
  allowedTools: "",
17229
17908
  model: "claude-sonnet-4-6",
17230
- env: hostClaudeEnv(spaceDir),
17909
+ env: hostAgentEnv(spaceDir),
17231
17910
  callbacks: {}
17232
17911
  });
17233
17912
  const { text } = await result;
@@ -17294,6 +17973,9 @@ ${c.content.trim() || "(no description)"}`
17294
17973
  targetBranch,
17295
17974
  effort: "high",
17296
17975
  todos: [],
17976
+ // The merge starts a brand-new thread; the sources' ids died with
17977
+ // their sessions above.
17978
+ agentSessionId: "",
17297
17979
  containerName: this.core.spaceId != null ? `factiii-${this.core.instancePrefix}-card-${this.core.spaceId}-${taskId}` : `factiii-${this.core.instancePrefix}-card-${taskId}`
17298
17980
  },
17299
17981
  { sources: sources.map(({ title, diff }) => ({ title, diff })) }
@@ -17303,11 +17985,16 @@ ${c.content.trim() || "(no description)"}`
17303
17985
  /**
17304
17986
  * Look up the active container for a card. Used by the terminal bridge to
17305
17987
  * map postId to the docker container it should attach a shell to.
17988
+ *
17989
+ * Errored and pushed ('done') sessions still resolve on purpose: neither
17990
+ * phase tears the container down (only cardKill/cardDismiss/destroy call
17991
+ * killContainer), and a crashed run is exactly when a shell is worth the
17992
+ * most. If the container really is gone, the `docker exec` behind this
17993
+ * fails and terminal.ts reports that instead of silently refusing.
17306
17994
  */
17307
17995
  getCardContainerName(postId) {
17308
17996
  const session = this.claude.get(String(postId)) ?? this.terminal.get(String(postId));
17309
17997
  if (!session) return null;
17310
- if (session.phase === "error" || session.phase === "done") return null;
17311
17998
  return session.containerName;
17312
17999
  }
17313
18000
  cardSessions() {
@@ -17466,6 +18153,7 @@ ${c.content.trim() || "(no description)"}`
17466
18153
  // ── Lifecycle ──
17467
18154
  destroy() {
17468
18155
  void this.claudeLogin?.cancel();
18156
+ void this.codexLogin?.cancel();
17469
18157
  this.claude.destroy();
17470
18158
  this.terminal.destroy();
17471
18159
  this.core.destroy();
@@ -17609,6 +18297,20 @@ ${c.content.trim() || "(no description)"}`
17609
18297
  );
17610
18298
  case "onedriveDisconnect":
17611
18299
  return this.onedriveDisconnect();
18300
+ case "githubStartDeviceCode":
18301
+ return await this.githubStartDeviceCode(
18302
+ payload
18303
+ );
18304
+ case "githubDisconnect":
18305
+ return this.githubDisconnect();
18306
+ case "githubIdentity":
18307
+ return await this.githubIdentity();
18308
+ case "githubListRepos":
18309
+ return await this.githubListRepos();
18310
+ case "githubListBranches":
18311
+ return await this.githubListBranches(
18312
+ payload
18313
+ );
17612
18314
  case "claudeLoginStart":
17613
18315
  return await this.claudeLoginStart();
17614
18316
  case "claudeLoginCode":
@@ -17619,6 +18321,12 @@ ${c.content.trim() || "(no description)"}`
17619
18321
  return await this.claudeLoginCancel();
17620
18322
  case "claudeLogout":
17621
18323
  return await this.claudeLogout();
18324
+ case "codexLoginStart":
18325
+ return await this.codexLoginStart();
18326
+ case "codexLoginCancel":
18327
+ return await this.codexLoginCancel();
18328
+ case "codexLogout":
18329
+ return await this.codexLogout();
17622
18330
  case "onedriveTeardown":
17623
18331
  return await this.onedriveTeardown(
17624
18332
  payload
@@ -21273,10 +21981,52 @@ Object.assign(lookup, {
21273
21981
 
21274
21982
  // ../../shared/all/helpers/terminal-ai/terminal.ts
21275
21983
  var docker = resolveDocker();
21276
- var TERMINAL_INIT_SCRIPT = [
21277
- `export PS1='\\[\\e[1;32m\\]user@bare\\[\\e[0m\\]:\\w\\$ '`,
21278
- `exec bash --norc -i`
21984
+ var REPO_ROOT = "/home/claude/workspace";
21985
+ var TERMINAL_PRELUDE = [
21986
+ // cd here rather than via the exec's WorkingDir: a session that died before
21987
+ // cloneRepo has no workspace dir, and docker refuses to start an exec whose
21988
+ // cwd is missing ("chdir to cwd ... no such file or directory"). That would
21989
+ // lock a shell out of exactly the containers worth inspecting, so fall back
21990
+ // to $HOME and let the user look around.
21991
+ //
21992
+ // The cwd also has to match the agent's (`-w /home/claude/workspace`, see
21993
+ // agent.ts) or a --resume can't find the thread: the provider CLIs key
21994
+ // their transcripts by working directory.
21995
+ `cd ${REPO_ROOT} 2>/dev/null || cd "$HOME" 2>/dev/null || cd /`,
21996
+ `export PS1='\\[\\e[1;32m\\]user@bare\\[\\e[0m\\]:\\w\\$ '`
21279
21997
  ].join("; ");
21998
+ var SAFE_SESSION_ID = /^[a-zA-Z0-9_-]{1,128}$/;
21999
+ var CARD_CONTEXT_PATH = "/tmp/factiii-card.md";
22000
+ var CARD_CONTEXT_PROMPT = `Read ${CARD_CONTEXT_PATH} - it is the Factiii card this session was opened from. Use it as the context for the work, then wait for instructions.`;
22001
+ function terminalInitScript(resumeAgentSessionId, provider, hasCardContext = false) {
22002
+ const shell = `exec bash --norc -i`;
22003
+ if (!resumeAgentSessionId || !SAFE_SESSION_ID.test(resumeAgentSessionId)) {
22004
+ if (!hasCardContext) return `${TERMINAL_PRELUDE}; ${shell}`;
22005
+ const start = provider === "codex" ? `codex '${CARD_CONTEXT_PROMPT}'` : `claude '${CARD_CONTEXT_PROMPT}'`;
22006
+ return `${TERMINAL_PRELUDE}; ${start}; ${shell}`;
22007
+ }
22008
+ const resume = provider === "codex" ? `codex resume ${resumeAgentSessionId}` : `claude --resume ${resumeAgentSessionId}`;
22009
+ return `${TERMINAL_PRELUDE}; ${resume}; ${shell}`;
22010
+ }
22011
+ async function writeCardContext(containerName, cardContext) {
22012
+ try {
22013
+ const b64 = Buffer.from(cardContext, "utf-8").toString("base64");
22014
+ const exec = await docker.getContainer(containerName).exec({
22015
+ Cmd: ["bash", "-c", `echo '${b64}' | base64 -d > ${CARD_CONTEXT_PATH}`],
22016
+ AttachStdout: true,
22017
+ AttachStderr: true
22018
+ });
22019
+ const stream = await exec.start({});
22020
+ await new Promise((resolve) => {
22021
+ stream.on("end", resolve);
22022
+ stream.on("error", resolve);
22023
+ stream.resume();
22024
+ });
22025
+ return true;
22026
+ } catch {
22027
+ return false;
22028
+ }
22029
+ }
21280
22030
  function tmuxName(terminalKey) {
21281
22031
  const safe = terminalKey.replace(/[^a-zA-Z0-9._-]/g, "_").slice(0, 80);
21282
22032
  return `terminal-${safe}`;
@@ -21327,6 +22077,8 @@ var TerminalManager = class {
21327
22077
  return;
21328
22078
  }
21329
22079
  const tmuxSession = tmuxName(msg.terminalKey);
22080
+ const wantsCardContext = !msg.resumeAgentSessionId && !!msg.cardContext;
22081
+ const hasCardContext = wantsCardContext && await writeCardContext(containerName, msg.cardContext);
21330
22082
  try {
21331
22083
  const exec = await docker.getContainer(containerName).exec({
21332
22084
  Cmd: [
@@ -21342,14 +22094,19 @@ var TerminalManager = class {
21342
22094
  "--",
21343
22095
  "bash",
21344
22096
  "-c",
21345
- TERMINAL_INIT_SCRIPT
22097
+ terminalInitScript(
22098
+ msg.resumeAgentSessionId,
22099
+ msg.resumeProvider,
22100
+ hasCardContext
22101
+ )
21346
22102
  ],
21347
22103
  AttachStdin: true,
21348
22104
  AttachStdout: true,
21349
22105
  AttachStderr: true,
21350
22106
  Tty: true,
21351
- // Drop the user into the cloned repo so claude/git/etc. just work.
21352
- WorkingDir: "/home/claude/workspace",
22107
+ // No WorkingDir on purpose docker validates it before the process
22108
+ // starts, so a missing workspace kills the exec outright. The init
22109
+ // script cds into the repo instead, with a fallback.
21353
22110
  Env: ["TERM=xterm-256color"],
21354
22111
  // The client follows up with a resize op carrying real dimensions.
21355
22112
  ConsoleSize: [msg.rows, msg.cols]
@@ -21488,7 +22245,8 @@ var CONFIG_FIELDS = [
21488
22245
  "gitName",
21489
22246
  "gitEmail",
21490
22247
  "githubToken",
21491
- "storageBackend"
22248
+ "storageBackend",
22249
+ "provider"
21492
22250
  ];
21493
22251
  function fillDefaults(config) {
21494
22252
  return {
@@ -21497,7 +22255,8 @@ function fillDefaults(config) {
21497
22255
  gitName: config.gitName || "",
21498
22256
  gitEmail: config.gitEmail || "",
21499
22257
  githubToken: config.githubToken || "",
21500
- storageBackend: config.storageBackend || "github"
22258
+ storageBackend: config.storageBackend || "github",
22259
+ provider: config.provider || "claude"
21501
22260
  };
21502
22261
  }
21503
22262
  function migrateLegacyConfigs() {
@@ -21537,6 +22296,21 @@ function readRunnerConfig2() {
21537
22296
  return migrateLegacyConfigs();
21538
22297
  }
21539
22298
  }
22299
+ function getConfiguredConnections() {
22300
+ const kinds = /* @__PURE__ */ new Set();
22301
+ const file = readRunnerConfig2();
22302
+ const configs = [file.default ?? {}, ...Object.values(file.boards ?? {})];
22303
+ for (const c of configs) {
22304
+ if (c.claudeToken) kinds.add("claude");
22305
+ if (c.githubToken) kinds.add("git");
22306
+ }
22307
+ try {
22308
+ const hasOneDrive = import_fs5.default.readdirSync(CONFIG_DIR2).some((f) => f.endsWith(".onedrive.json"));
22309
+ if (hasOneDrive) kinds.add("onedrive");
22310
+ } catch {
22311
+ }
22312
+ return [...kinds];
22313
+ }
21540
22314
  var localConfigProvider = {
21541
22315
  readConfig(spaceSlug) {
21542
22316
  const file = readRunnerConfig2();
@@ -21548,7 +22322,8 @@ var localConfigProvider = {
21548
22322
  gitName: over.gitName || base.gitName,
21549
22323
  gitEmail: over.gitEmail || base.gitEmail,
21550
22324
  githubToken: over.githubToken || base.githubToken,
21551
- storageBackend: over.storageBackend || base.storageBackend
22325
+ storageBackend: over.storageBackend || base.storageBackend,
22326
+ provider: over.provider || base.provider
21552
22327
  });
21553
22328
  },
21554
22329
  writeConfig(spaceSlug, config) {
@@ -21593,7 +22368,14 @@ var localConfigProvider = {
21593
22368
  // layout every deploy skill is written against. See .specs/deploy.md.
21594
22369
  spaceDir(spaceSlug) {
21595
22370
  const dir = import_path9.default.join(CONFIG_DIR2, safeSlug(spaceSlug));
21596
- for (const sub of ["workspace", "backups", "builds", "secrets", "claude"]) {
22371
+ for (const sub of [
22372
+ "workspace",
22373
+ "backups",
22374
+ "builds",
22375
+ "secrets",
22376
+ "claude",
22377
+ "codex"
22378
+ ]) {
21597
22379
  import_fs5.default.mkdirSync(import_path9.default.join(dir, sub), { recursive: true });
21598
22380
  }
21599
22381
  return dir;
@@ -21668,7 +22450,19 @@ async function startDaemon(config) {
21668
22450
  // falls back to the slug when absent.
21669
22451
  spaceId,
21670
22452
  emitter: createEventSink(send, spaceSlug),
21671
- configProvider: localConfigProvider,
22453
+ // Re-report connection facts after any config/OneDrive write so the
22454
+ // UI badges update when a token is added or cleared.
22455
+ configProvider: {
22456
+ ...localConfigProvider,
22457
+ writeConfig(slug, cfg) {
22458
+ localConfigProvider.writeConfig(slug, cfg);
22459
+ reportConnections();
22460
+ },
22461
+ writeOneDriveConnection(slug, connection) {
22462
+ localConfigProvider.writeOneDriveConnection(slug, connection);
22463
+ reportConnections();
22464
+ }
22465
+ },
21672
22466
  dockerfilePath: getDockerfilePath(),
21673
22467
  runnerToken: config.authToken,
21674
22468
  // Namespace containers per runner so multiple runners (or a desktop
@@ -21681,9 +22475,18 @@ async function startDaemon(config) {
21681
22475
  return engine;
21682
22476
  }
21683
22477
  const instancePrefix = computeInstancePrefix(`runner-${config.runnerName}`);
22478
+ const reportConnections = () => {
22479
+ try {
22480
+ socket.emit("runnerConnections", {
22481
+ connections: getConfiguredConnections()
22482
+ });
22483
+ } catch {
22484
+ }
22485
+ };
21684
22486
  socket.on("connect", () => {
21685
22487
  console.log(`Connected. Runner "${config.runnerName}" is online.`);
21686
22488
  console.log("Waiting for connections from UI clients...\n");
22489
+ reportConnections();
21687
22490
  });
21688
22491
  socket.on("disconnect", (reason) => {
21689
22492
  console.log(`Disconnected: ${reason}. Reconnecting...`);