@factiii/runner 0.2.1 → 0.6.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.
Files changed (2) hide show
  1. package/dist/cli.js +634 -227
  2. package/package.json +4 -2
package/dist/cli.js CHANGED
@@ -963,8 +963,8 @@ var require_command = __commonJS({
963
963
  "node_modules/commander/lib/command.js"(exports2) {
964
964
  var EventEmitter = require("node:events").EventEmitter;
965
965
  var childProcess = require("node:child_process");
966
- var path7 = require("node:path");
967
- var fs6 = require("node:fs");
966
+ var path8 = require("node:path");
967
+ var fs7 = require("node:fs");
968
968
  var process2 = require("node:process");
969
969
  var { Argument: Argument2, humanReadableArgName } = require_argument();
970
970
  var { CommanderError: CommanderError2 } = require_error();
@@ -1896,11 +1896,11 @@ Expecting one of '${allowedValues.join("', '")}'`);
1896
1896
  let launchWithNode = false;
1897
1897
  const sourceExt = [".js", ".ts", ".tsx", ".mjs", ".cjs"];
1898
1898
  function findFile(baseDir, baseName) {
1899
- const localBin = path7.resolve(baseDir, baseName);
1900
- if (fs6.existsSync(localBin)) return localBin;
1901
- if (sourceExt.includes(path7.extname(baseName))) return void 0;
1899
+ const localBin = path8.resolve(baseDir, baseName);
1900
+ if (fs7.existsSync(localBin)) return localBin;
1901
+ if (sourceExt.includes(path8.extname(baseName))) return void 0;
1902
1902
  const foundExt = sourceExt.find(
1903
- (ext) => fs6.existsSync(`${localBin}${ext}`)
1903
+ (ext) => fs7.existsSync(`${localBin}${ext}`)
1904
1904
  );
1905
1905
  if (foundExt) return `${localBin}${foundExt}`;
1906
1906
  return void 0;
@@ -1912,21 +1912,21 @@ Expecting one of '${allowedValues.join("', '")}'`);
1912
1912
  if (this._scriptPath) {
1913
1913
  let resolvedScriptPath;
1914
1914
  try {
1915
- resolvedScriptPath = fs6.realpathSync(this._scriptPath);
1915
+ resolvedScriptPath = fs7.realpathSync(this._scriptPath);
1916
1916
  } catch (err) {
1917
1917
  resolvedScriptPath = this._scriptPath;
1918
1918
  }
1919
- executableDir = path7.resolve(
1920
- path7.dirname(resolvedScriptPath),
1919
+ executableDir = path8.resolve(
1920
+ path8.dirname(resolvedScriptPath),
1921
1921
  executableDir
1922
1922
  );
1923
1923
  }
1924
1924
  if (executableDir) {
1925
1925
  let localFile = findFile(executableDir, executableFile);
1926
1926
  if (!localFile && !subcommand._executableFile && this._scriptPath) {
1927
- const legacyName = path7.basename(
1927
+ const legacyName = path8.basename(
1928
1928
  this._scriptPath,
1929
- path7.extname(this._scriptPath)
1929
+ path8.extname(this._scriptPath)
1930
1930
  );
1931
1931
  if (legacyName !== this._name) {
1932
1932
  localFile = findFile(
@@ -1937,7 +1937,7 @@ Expecting one of '${allowedValues.join("', '")}'`);
1937
1937
  }
1938
1938
  executableFile = localFile || executableFile;
1939
1939
  }
1940
- launchWithNode = sourceExt.includes(path7.extname(executableFile));
1940
+ launchWithNode = sourceExt.includes(path8.extname(executableFile));
1941
1941
  let proc;
1942
1942
  if (process2.platform !== "win32") {
1943
1943
  if (launchWithNode) {
@@ -2777,7 +2777,7 @@ Expecting one of '${allowedValues.join("', '")}'`);
2777
2777
  * @return {Command}
2778
2778
  */
2779
2779
  nameFromFilename(filename) {
2780
- this._name = path7.basename(filename, path7.extname(filename));
2780
+ this._name = path8.basename(filename, path8.extname(filename));
2781
2781
  return this;
2782
2782
  }
2783
2783
  /**
@@ -2791,9 +2791,9 @@ Expecting one of '${allowedValues.join("', '")}'`);
2791
2791
  * @param {string} [path]
2792
2792
  * @return {(string|null|Command)}
2793
2793
  */
2794
- executableDir(path8) {
2795
- if (path8 === void 0) return this._executableDir;
2796
- this._executableDir = path8;
2794
+ executableDir(path9) {
2795
+ if (path9 === void 0) return this._executableDir;
2796
+ this._executableDir = path9;
2797
2797
  return this;
2798
2798
  }
2799
2799
  /**
@@ -3026,7 +3026,7 @@ var require_commander = __commonJS({
3026
3026
  // ../../node_modules/xmlhttprequest-ssl/lib/XMLHttpRequest.js
3027
3027
  var require_XMLHttpRequest = __commonJS({
3028
3028
  "../../node_modules/xmlhttprequest-ssl/lib/XMLHttpRequest.js"(exports2, module2) {
3029
- var fs6 = require("fs");
3029
+ var fs7 = require("fs");
3030
3030
  var Url = require("url");
3031
3031
  var spawn3 = require("child_process").spawn;
3032
3032
  module2.exports = XMLHttpRequest3;
@@ -3184,7 +3184,7 @@ var require_XMLHttpRequest = __commonJS({
3184
3184
  throw new Error("XMLHttpRequest: Only GET method is supported");
3185
3185
  }
3186
3186
  if (settings.async) {
3187
- fs6.readFile(unescape(url2.pathname), function(error, data2) {
3187
+ fs7.readFile(unescape(url2.pathname), function(error, data2) {
3188
3188
  if (error) {
3189
3189
  self.handleError(error, error.errno || -1);
3190
3190
  } else {
@@ -3196,7 +3196,7 @@ var require_XMLHttpRequest = __commonJS({
3196
3196
  });
3197
3197
  } else {
3198
3198
  try {
3199
- this.response = fs6.readFileSync(unescape(url2.pathname));
3199
+ this.response = fs7.readFileSync(unescape(url2.pathname));
3200
3200
  this.responseText = this.response.toString("utf8");
3201
3201
  this.status = 200;
3202
3202
  setState(self.DONE);
@@ -3322,15 +3322,15 @@ var require_XMLHttpRequest = __commonJS({
3322
3322
  } else {
3323
3323
  var contentFile = ".node-xmlhttprequest-content-" + process.pid;
3324
3324
  var syncFile = ".node-xmlhttprequest-sync-" + process.pid;
3325
- fs6.writeFileSync(syncFile, "", "utf8");
3325
+ fs7.writeFileSync(syncFile, "", "utf8");
3326
3326
  var execString = "var http = require('http'), https = require('https'), fs = require('fs');var doRequest = http" + (ssl ? "s" : "") + ".request;var options = " + JSON.stringify(options) + ";var responseText = '';var responseData = Buffer.alloc(0);var req = doRequest(options, function(response) {response.on('data', function(chunk) { var data = Buffer.from(chunk); responseText += data.toString('utf8'); responseData = Buffer.concat([responseData, data]);});response.on('end', function() {fs.writeFileSync('" + contentFile + "', JSON.stringify({err: null, data: {statusCode: response.statusCode, headers: response.headers, text: responseText, data: responseData.toString('base64')}}), 'utf8');fs.unlinkSync('" + syncFile + "');});response.on('error', function(error) {fs.writeFileSync('" + contentFile + "', 'NODE-XMLHTTPREQUEST-ERROR:' + JSON.stringify(error), 'utf8');fs.unlinkSync('" + syncFile + "');});}).on('error', function(error) {fs.writeFileSync('" + contentFile + "', 'NODE-XMLHTTPREQUEST-ERROR:' + JSON.stringify(error), 'utf8');fs.unlinkSync('" + syncFile + "');});" + (data ? "req.write('" + JSON.stringify(data).slice(1, -1).replace(/'/g, "\\'") + "');" : "") + "req.end();";
3327
3327
  var syncProc = spawn3(process.argv[0], ["-e", execString]);
3328
3328
  var statusText;
3329
- while (fs6.existsSync(syncFile)) {
3329
+ while (fs7.existsSync(syncFile)) {
3330
3330
  }
3331
- self.responseText = fs6.readFileSync(contentFile, "utf8");
3331
+ self.responseText = fs7.readFileSync(contentFile, "utf8");
3332
3332
  syncProc.stdin.end();
3333
- fs6.unlinkSync(contentFile);
3333
+ fs7.unlinkSync(contentFile);
3334
3334
  if (self.responseText.match(/^NODE-XMLHTTPREQUEST-ERROR:/)) {
3335
3335
  var errorObj = JSON.parse(self.responseText.replace(/^NODE-XMLHTTPREQUEST-ERROR:/, ""));
3336
3336
  self.handleError(errorObj, 503);
@@ -8037,8 +8037,144 @@ function configExists() {
8037
8037
  return import_fs.default.existsSync(CONFIG_FILE);
8038
8038
  }
8039
8039
 
8040
- // ../../shared/all/helpers/board-agent-core/claude.ts
8040
+ // src/setup.ts
8041
8041
  var import_child_process2 = require("child_process");
8042
+ var import_crypto = __toESM(require("crypto"));
8043
+ var import_fs2 = __toESM(require("fs"));
8044
+ var import_path2 = __toESM(require("path"));
8045
+ var DOCKERFILE_LABEL = "factiii.dockerfile-sha";
8046
+ function run(bin, args, opts) {
8047
+ try {
8048
+ return (0, import_child_process2.execFileSync)(bin, args, {
8049
+ encoding: "utf-8",
8050
+ stdio: opts?.silent ? "pipe" : "inherit",
8051
+ // Avoids a cmd.exe window flash on Windows during silent checks.
8052
+ windowsHide: true
8053
+ });
8054
+ } catch {
8055
+ return "";
8056
+ }
8057
+ }
8058
+ function check(cmd) {
8059
+ const lookup2 = process.platform === "win32" ? "where" : "which";
8060
+ try {
8061
+ (0, import_child_process2.execFileSync)(lookup2, [cmd], { stdio: "pipe", windowsHide: true });
8062
+ return true;
8063
+ } catch {
8064
+ return false;
8065
+ }
8066
+ }
8067
+ async function setup(serverUrl) {
8068
+ console.log("\n=== Factiii Runner \u2014 Setup ===\n");
8069
+ console.log("[1/3] Checking Docker...");
8070
+ if (!check("docker")) {
8071
+ console.error(
8072
+ " Docker is not installed. Install it from https://docs.docker.com/get-docker/ and re-run setup."
8073
+ );
8074
+ process.exit(1);
8075
+ }
8076
+ if (!run("docker", ["info"], { silent: true })) {
8077
+ console.error(
8078
+ " Docker is installed but the daemon is not running. Start Docker and re-run setup."
8079
+ );
8080
+ process.exit(1);
8081
+ }
8082
+ console.log(" Docker is ready.");
8083
+ console.log("\n[2/3] Building factiii-claude Docker image...");
8084
+ const dockerfilePath = findDockerfile();
8085
+ if (!dockerfilePath) {
8086
+ console.error(
8087
+ " Dockerfile.claude not found. Reinstall the package and re-run setup."
8088
+ );
8089
+ process.exit(1);
8090
+ }
8091
+ const wantedHash = import_crypto.default.createHash("sha256").update(import_fs2.default.readFileSync(dockerfilePath)).digest("hex");
8092
+ const existingHash = run(
8093
+ "docker",
8094
+ [
8095
+ "image",
8096
+ "inspect",
8097
+ "factiii-claude",
8098
+ "--format",
8099
+ `{{ index .Config.Labels "${DOCKERFILE_LABEL}" }}`
8100
+ ],
8101
+ { silent: true }
8102
+ ).trim();
8103
+ if (existingHash === wantedHash) {
8104
+ console.log(" Image is up to date.");
8105
+ } else {
8106
+ console.log(
8107
+ existingHash ? ` Dockerfile changed since last build, rebuilding from ${dockerfilePath}...` : ` Building from ${dockerfilePath}...`
8108
+ );
8109
+ run("docker", [
8110
+ "build",
8111
+ "-t",
8112
+ "factiii-claude",
8113
+ "-f",
8114
+ dockerfilePath,
8115
+ "--label",
8116
+ `${DOCKERFILE_LABEL}=${wantedHash}`,
8117
+ "."
8118
+ ]);
8119
+ console.log(" Image built.");
8120
+ }
8121
+ run("docker", ["volume", "create", "factiii-card-cache"], { silent: true });
8122
+ console.log("\n[3/3] Pairing this runner with a Factiii account...");
8123
+ const { token, runnerName } = await pairWithBrowser(serverUrl);
8124
+ writeRunnerConfig({ serverUrl, authToken: token, runnerName });
8125
+ console.log(" Paired and config saved.");
8126
+ console.log("\n=== Setup complete ===\n");
8127
+ console.log("Start the runner with: factiii-runner start\n");
8128
+ }
8129
+ function findDockerfile() {
8130
+ const p = import_path2.default.join(__dirname, "..", "Dockerfile.claude");
8131
+ return import_fs2.default.existsSync(p) ? p : null;
8132
+ }
8133
+
8134
+ // src/selfUpdate.ts
8135
+ var import_node_child_process = require("node:child_process");
8136
+ var import_node_fs = __toESM(require("node:fs"));
8137
+ var import_node_path = __toESM(require("node:path"));
8138
+ var PACKAGE_NAME = "@factiii/runner";
8139
+ var RESTART_FOR_UPDATE = 75;
8140
+ var WORKER_ENV = "FACTIII_RUNNER_WORKER";
8141
+ function markWorkerEnv(env) {
8142
+ return { ...env, [WORKER_ENV]: "1" };
8143
+ }
8144
+ function isWorker() {
8145
+ return process.env[WORKER_ENV] === "1";
8146
+ }
8147
+ function currentVersion() {
8148
+ try {
8149
+ const pkg = JSON.parse(
8150
+ import_node_fs.default.readFileSync(import_node_path.default.join(__dirname, "..", "package.json"), "utf-8")
8151
+ );
8152
+ return pkg.version ?? "0.0.0";
8153
+ } catch {
8154
+ return "0.0.0";
8155
+ }
8156
+ }
8157
+ function installLatest() {
8158
+ console.log(`[update] installing ${PACKAGE_NAME}@latest...`);
8159
+ const res = (0, import_node_child_process.spawnSync)("npm", ["install", "-g", `${PACKAGE_NAME}@latest`], {
8160
+ stdio: "inherit",
8161
+ // On Windows npm is npm.cmd, which spawnSync can't exec without a shell.
8162
+ shell: process.platform === "win32"
8163
+ });
8164
+ if (res.status !== 0) {
8165
+ console.error(
8166
+ `[update] npm install failed (exit ${res.status ?? "signal"}). Staying on ${currentVersion()}.`
8167
+ );
8168
+ return false;
8169
+ }
8170
+ return true;
8171
+ }
8172
+
8173
+ // src/supervisor.ts
8174
+ var import_node_child_process2 = require("node:child_process");
8175
+
8176
+ // ../../shared/all/helpers/board-agent-core/claude.ts
8177
+ var import_child_process3 = require("child_process");
8042
8178
 
8043
8179
  // ../../shared/all/helpers/board-agent-core/claude-stream.ts
8044
8180
  function truncate(s, max) {
@@ -8514,7 +8650,7 @@ function spawnClaude(opts) {
8514
8650
  if (opts.resumeSessionId) {
8515
8651
  args.push("--resume", opts.resumeSessionId);
8516
8652
  }
8517
- const proc = (0, import_child_process2.spawn)("docker", args);
8653
+ const proc = (0, import_child_process3.spawn)("docker", args);
8518
8654
  const result = new Promise(
8519
8655
  (resolve, reject) => {
8520
8656
  let errorOutput = "";
@@ -8546,7 +8682,7 @@ function spawnClaude(opts) {
8546
8682
  }
8547
8683
  function generateCommitMessage(containerName, diff) {
8548
8684
  return new Promise((resolve) => {
8549
- const proc = (0, import_child_process2.spawn)("docker", [
8685
+ const proc = (0, import_child_process3.spawn)("docker", [
8550
8686
  "exec",
8551
8687
  "-i",
8552
8688
  "--user",
@@ -8575,16 +8711,16 @@ ${diff.slice(0, 8e3)}`);
8575
8711
  }
8576
8712
 
8577
8713
  // ../../shared/all/helpers/board-agent-core/docker.ts
8578
- var import_child_process3 = require("child_process");
8579
- var import_crypto = __toESM(require("crypto"));
8714
+ var import_child_process4 = require("child_process");
8715
+ var import_crypto2 = __toESM(require("crypto"));
8580
8716
  var import_promises = __toESM(require("fs/promises"));
8581
8717
  var import_net = __toESM(require("net"));
8582
8718
  var import_util = require("util");
8583
- var execFileAsync = (0, import_util.promisify)(import_child_process3.execFile);
8584
- var DOCKERFILE_LABEL = "factiii.dockerfile-sha";
8719
+ var execFileAsync = (0, import_util.promisify)(import_child_process4.execFile);
8720
+ var DOCKERFILE_LABEL2 = "factiii.dockerfile-sha";
8585
8721
  async function hashDockerfile(dockerfilePath) {
8586
8722
  const buf = await import_promises.default.readFile(dockerfilePath);
8587
- return import_crypto.default.createHash("sha256").update(buf).digest("hex");
8723
+ return import_crypto2.default.createHash("sha256").update(buf).digest("hex");
8588
8724
  }
8589
8725
  async function getFreePort() {
8590
8726
  return new Promise((resolve, reject) => {
@@ -8623,7 +8759,7 @@ async function ensureDockerImage(dockerfilePath, onLog) {
8623
8759
  "inspect",
8624
8760
  "factiii-claude",
8625
8761
  "--format",
8626
- `{{ index .Config.Labels "${DOCKERFILE_LABEL}" }}`
8762
+ `{{ index .Config.Labels "${DOCKERFILE_LABEL2}" }}`
8627
8763
  ],
8628
8764
  {}
8629
8765
  );
@@ -8649,7 +8785,7 @@ async function resetNixVolume() {
8649
8785
  }
8650
8786
  function buildImageStreaming(dockerfilePath, wantedHash, onLog) {
8651
8787
  return new Promise((resolve, reject) => {
8652
- const child = (0, import_child_process3.spawn)(
8788
+ const child = (0, import_child_process4.spawn)(
8653
8789
  "docker",
8654
8790
  [
8655
8791
  "build",
@@ -8659,7 +8795,7 @@ function buildImageStreaming(dockerfilePath, wantedHash, onLog) {
8659
8795
  "-f",
8660
8796
  dockerfilePath,
8661
8797
  "--label",
8662
- `${DOCKERFILE_LABEL}=${wantedHash}`,
8798
+ `${DOCKERFILE_LABEL2}=${wantedHash}`,
8663
8799
  "."
8664
8800
  ],
8665
8801
  { env: { ...process.env, DOCKER_BUILDKIT: "1" } }
@@ -8718,6 +8854,44 @@ async function killContainer(containerName) {
8718
8854
  } catch {
8719
8855
  }
8720
8856
  }
8857
+ async function listContainersByPrefix(namePrefix) {
8858
+ try {
8859
+ const { stdout } = await execFileAsync(
8860
+ "docker",
8861
+ [
8862
+ "ps",
8863
+ "-a",
8864
+ "--no-trunc",
8865
+ "--filter",
8866
+ `name=${namePrefix}`,
8867
+ "--format",
8868
+ "{{.Names}} {{.State}} {{.Status}}"
8869
+ ],
8870
+ { encoding: "utf-8", maxBuffer: 10 * 1024 * 1024 }
8871
+ );
8872
+ const rows = [];
8873
+ for (const line of stdout.split("\n")) {
8874
+ if (!line.trim()) continue;
8875
+ const [name, state, ...statusParts] = line.split(" ");
8876
+ if (!name || !name.startsWith(namePrefix)) continue;
8877
+ rows.push({
8878
+ name,
8879
+ running: state === "running",
8880
+ status: statusParts.join(" ").trim() || state || ""
8881
+ });
8882
+ }
8883
+ return rows;
8884
+ } catch {
8885
+ return [];
8886
+ }
8887
+ }
8888
+ async function renameContainer(oldName, newName) {
8889
+ if (oldName === newName) return;
8890
+ try {
8891
+ await execFileAsync("docker", ["rename", oldName, newName], {});
8892
+ } catch {
8893
+ }
8894
+ }
8721
8895
  async function spawnContainer(containerName, claudeToken, opts) {
8722
8896
  const NUM_PORTS = 6;
8723
8897
  const ports = [];
@@ -8792,7 +8966,7 @@ async function execInContainer(containerName, cmd) {
8792
8966
  return stdout;
8793
8967
  }
8794
8968
  function streamExecInContainer(containerName, cmd, onLine) {
8795
- const child = (0, import_child_process3.spawn)("docker", [
8969
+ const child = (0, import_child_process4.spawn)("docker", [
8796
8970
  "exec",
8797
8971
  "--user",
8798
8972
  "claude",
@@ -8875,9 +9049,9 @@ async function getContainerPort(containerName, containerPort) {
8875
9049
  }
8876
9050
 
8877
9051
  // ../../shared/all/helpers/board-agent-core/git.ts
8878
- var import_child_process4 = require("child_process");
9052
+ var import_child_process5 = require("child_process");
8879
9053
  var import_util2 = require("util");
8880
- var execFileAsync2 = (0, import_util2.promisify)(import_child_process4.execFile);
9054
+ var execFileAsync2 = (0, import_util2.promisify)(import_child_process5.execFile);
8881
9055
  function buildCloneUrl(repoUrl, githubToken) {
8882
9056
  const url2 = new URL(repoUrl);
8883
9057
  url2.username = githubToken;
@@ -9318,6 +9492,27 @@ function withTimeout(work, ms, label) {
9318
9492
  });
9319
9493
  return Promise.race([work.finally(() => clearTimeout(timer)), timeout]);
9320
9494
  }
9495
+ function computeInstancePrefix(instanceId) {
9496
+ const raw = (instanceId || "shared").toLowerCase();
9497
+ return raw.replace(/[^a-z0-9-]/g, "-").replace(/-+/g, "-").replace(/^-|-$/g, "") || "shared";
9498
+ }
9499
+ function parseFactiiiContainerName(name, instancePrefix) {
9500
+ const head = `factiii-${instancePrefix}-`;
9501
+ if (!name.startsWith(head)) return null;
9502
+ const rest = name.slice(head.length);
9503
+ const withId = rest.match(/^(card|util)-(\d+)-(.+)$/);
9504
+ if (withId) {
9505
+ const kind = withId[1];
9506
+ const spaceId = Number(withId[2]);
9507
+ return kind === "card" ? { kind, spaceId, taskId: withId[3] } : { kind, spaceId, cacheKey: withId[3] };
9508
+ }
9509
+ const legacy = rest.match(/^(card|util)-(.+)$/);
9510
+ if (legacy) {
9511
+ const kind = legacy[1];
9512
+ return kind === "card" ? { kind, spaceId: null, taskId: legacy[2] } : { kind, spaceId: null, cacheKey: legacy[2] };
9513
+ }
9514
+ return null;
9515
+ }
9321
9516
  var ContainerCore = class {
9322
9517
  constructor(options) {
9323
9518
  // Utility container state.
@@ -9325,12 +9520,17 @@ var ContainerCore = class {
9325
9520
  this.lastConfigKey = "";
9326
9521
  this.setupPromise = null;
9327
9522
  this.spaceSlug = options.spaceSlug;
9523
+ this.spaceId = options.spaceId ?? null;
9328
9524
  this.emitter = options.emitter;
9329
9525
  this.configProvider = options.configProvider;
9330
9526
  this.dockerfilePath = options.dockerfilePath;
9331
9527
  this.runnerToken = options.runnerToken;
9332
- const raw = (options.instanceId || "shared").toLowerCase();
9333
- this.instancePrefix = raw.replace(/[^a-z0-9-]/g, "-").replace(/-+/g, "-").replace(/^-|-$/g, "") || "shared";
9528
+ this.instancePrefix = computeInstancePrefix(options.instanceId);
9529
+ }
9530
+ /** The space segment used in container names: the stable id when we have it,
9531
+ * else the slug (legacy / id-less callers). */
9532
+ get spaceSegment() {
9533
+ return this.spaceId != null ? String(this.spaceId) : this.spaceSlug;
9334
9534
  }
9335
9535
  readConfig() {
9336
9536
  return this.configProvider.readConfig(this.spaceSlug);
@@ -9430,7 +9630,11 @@ var ContainerCore = class {
9430
9630
  }
9431
9631
  const configKey = onedrive ? `onedrive|${this.spaceSlug}|${config.claudeToken}` : `${config.repoUrl}|${config.claudeToken}|${config.githubToken}`;
9432
9632
  const cacheKey = onedrive ? `onedrive-${this.spaceSlug}`.replace(/[^a-zA-Z0-9._-]/g, "_") : repoCacheKey(config.repoUrl);
9433
- const name = `factiii-${this.instancePrefix}-util-${this.spaceSlug}-${cacheKey}`;
9633
+ const name = `factiii-${this.instancePrefix}-util-${this.spaceSegment}-${cacheKey}`;
9634
+ if (this.spaceId != null) {
9635
+ const legacyName = `factiii-${this.instancePrefix}-util-${this.spaceSlug}-${cacheKey}`;
9636
+ await renameContainer(legacyName, name);
9637
+ }
9434
9638
  if (this.containerName && this.lastConfigKey !== configKey) {
9435
9639
  await killContainer(this.containerName);
9436
9640
  this.containerName = null;
@@ -9661,9 +9865,9 @@ function createEventSink(send, spaceSlug, notify) {
9661
9865
  }
9662
9866
 
9663
9867
  // ../../shared/all/helpers/card-ai/frpc.ts
9664
- var import_child_process5 = require("child_process");
9665
- var import_fs2 = require("fs");
9666
- var import_path2 = __toESM(require("path"));
9868
+ var import_child_process6 = require("child_process");
9869
+ var import_fs3 = require("fs");
9870
+ var import_path3 = __toESM(require("path"));
9667
9871
  var import_util3 = require("util");
9668
9872
 
9669
9873
  // ../../shared/all/helpers/card-ai/scripts.ts
@@ -9717,15 +9921,15 @@ fi
9717
9921
  `;
9718
9922
 
9719
9923
  // ../../shared/all/helpers/card-ai/frpc.ts
9720
- var execFileAsync3 = (0, import_util3.promisify)(import_child_process5.execFile);
9924
+ var execFileAsync3 = (0, import_util3.promisify)(import_child_process6.execFile);
9721
9925
  var FRP_SERVER_ADDR = "dev.factiii.com";
9722
9926
  var FRP_SERVER_PORT = 7443;
9723
9927
  var FRP_DOMAIN_SUFFIX = "dev.factiii.com";
9724
- async function writeFileAsRoot(containerName, path7, contents) {
9928
+ async function writeFileAsRoot(containerName, path8, contents) {
9725
9929
  const b64 = Buffer.from(contents, "utf-8").toString("base64");
9726
9930
  await execInContainerAsRoot(
9727
9931
  containerName,
9728
- `echo '${b64}' | base64 -d > '${path7}'`
9932
+ `echo '${b64}' | base64 -d > '${path8}'`
9729
9933
  );
9730
9934
  }
9731
9935
  async function installFrpcBinary(containerName) {
@@ -9738,8 +9942,8 @@ async function installFrpcBinary(containerName) {
9738
9942
  if (!binaryName) {
9739
9943
  throw new Error(`Unsupported container arch for frpc: ${arch}`);
9740
9944
  }
9741
- const src = import_path2.default.join(__dirname, "..", "vendor", binaryName);
9742
- if (!(0, import_fs2.existsSync)(src)) {
9945
+ const src = import_path3.default.join(__dirname, "..", "vendor", binaryName);
9946
+ if (!(0, import_fs3.existsSync)(src)) {
9743
9947
  throw new Error(
9744
9948
  `Vendored frpc missing (${src}). Run apps/runner/scripts/fetch-frpc.sh.`
9745
9949
  );
@@ -10483,10 +10687,10 @@ var TerminalModeEngine = class {
10483
10687
  }
10484
10688
  async bareFsList({
10485
10689
  postId,
10486
- path: path7
10690
+ path: path8
10487
10691
  }) {
10488
10692
  const container = this.getBareContainer(postId);
10489
- const abs = this.resolveBarePath(path7);
10693
+ const abs = this.resolveBarePath(path8);
10490
10694
  const relRoot = abs.slice(BARE_REPO_ROOT.length) || "/";
10491
10695
  const out = await execInContainer(
10492
10696
  container,
@@ -10536,21 +10740,21 @@ var TerminalModeEngine = class {
10536
10740
  }
10537
10741
  async bareFsRead({
10538
10742
  postId,
10539
- path: path7
10743
+ path: path8
10540
10744
  }) {
10541
10745
  const container = this.getBareContainer(postId);
10542
- const abs = this.resolveBarePath(path7);
10746
+ const abs = this.resolveBarePath(path8);
10543
10747
  const b64 = (await execInContainer(container, `base64 -w 0 ${shEscape(abs)}`)).trim();
10544
10748
  return { content: b64, encoding: "base64" };
10545
10749
  }
10546
10750
  async bareFsWrite({
10547
10751
  postId,
10548
- path: path7,
10752
+ path: path8,
10549
10753
  content,
10550
10754
  encoding
10551
10755
  }) {
10552
10756
  const container = this.getBareContainer(postId);
10553
- const abs = this.resolveBarePath(path7);
10757
+ const abs = this.resolveBarePath(path8);
10554
10758
  const b64 = encoding === "base64" ? content : Buffer.from(content, "utf-8").toString("base64");
10555
10759
  if (!/^[A-Za-z0-9+/=\n\r]*$/.test(b64)) {
10556
10760
  throw new Error("Invalid base64 payload.");
@@ -10560,7 +10764,7 @@ var TerminalModeEngine = class {
10560
10764
  `mkdir -p ${shEscape(posixDirname(abs))} && printf %s ${shEscape(b64)} | base64 -d > ${shEscape(abs)}`
10561
10765
  );
10562
10766
  const session = this.sessions.get(String(postId));
10563
- if (session) delete session.buffers[path7];
10767
+ if (session) delete session.buffers[path8];
10564
10768
  }
10565
10769
  bareEditorState({
10566
10770
  postId: rawPostId
@@ -10577,38 +10781,38 @@ var TerminalModeEngine = class {
10577
10781
  }
10578
10782
  bareOpenFile({
10579
10783
  postId: rawPostId,
10580
- path: path7
10784
+ path: path8
10581
10785
  }) {
10582
10786
  const session = this.sessions.get(String(rawPostId));
10583
10787
  if (!session || session.mode !== "bare") return;
10584
- if (!session.openedFiles.includes(path7)) {
10585
- session.openedFiles.push(path7);
10788
+ if (!session.openedFiles.includes(path8)) {
10789
+ session.openedFiles.push(path8);
10586
10790
  }
10587
10791
  }
10588
10792
  bareCloseFile({
10589
10793
  postId: rawPostId,
10590
- path: path7
10794
+ path: path8
10591
10795
  }) {
10592
10796
  const session = this.sessions.get(String(rawPostId));
10593
10797
  if (!session || session.mode !== "bare") return;
10594
- session.openedFiles = session.openedFiles.filter((p) => p !== path7);
10595
- delete session.buffers[path7];
10798
+ session.openedFiles = session.openedFiles.filter((p) => p !== path8);
10799
+ delete session.buffers[path8];
10596
10800
  }
10597
10801
  bareBufferSet({
10598
10802
  postId: rawPostId,
10599
- path: path7,
10803
+ path: path8,
10600
10804
  content
10601
10805
  }) {
10602
10806
  const session = this.sessions.get(String(rawPostId));
10603
10807
  if (!session || session.mode !== "bare") return;
10604
- session.buffers[path7] = content;
10808
+ session.buffers[path8] = content;
10605
10809
  }
10606
10810
  async bareFsDelete({
10607
10811
  postId,
10608
- path: path7
10812
+ path: path8
10609
10813
  }) {
10610
10814
  const container = this.getBareContainer(postId);
10611
- const abs = this.resolveBarePath(path7);
10815
+ const abs = this.resolveBarePath(path8);
10612
10816
  if (abs === BARE_REPO_ROOT) {
10613
10817
  throw new Error("Refusing to delete the repo root.");
10614
10818
  }
@@ -10616,10 +10820,10 @@ var TerminalModeEngine = class {
10616
10820
  }
10617
10821
  async bareFsMkdir({
10618
10822
  postId,
10619
- path: path7
10823
+ path: path8
10620
10824
  }) {
10621
10825
  const container = this.getBareContainer(postId);
10622
- const abs = this.resolveBarePath(path7);
10826
+ const abs = this.resolveBarePath(path8);
10623
10827
  await execInContainer(container, `mkdir -p ${shEscape(abs)}`);
10624
10828
  }
10625
10829
  async bareReview({
@@ -11032,7 +11236,9 @@ var BoardAgentEngine = class {
11032
11236
  targetBranch: targetBranch || "",
11033
11237
  effort: effort || "high",
11034
11238
  todos: [],
11035
- containerName: `factiii-${this.core.instancePrefix}-card-${taskId}`
11239
+ // Name by stable spaceId when we have it; fall back to the id-less legacy
11240
+ // form for old callers that only send the slug.
11241
+ containerName: this.core.spaceId != null ? `factiii-${this.core.instancePrefix}-card-${this.core.spaceId}-${taskId}` : `factiii-${this.core.instancePrefix}-card-${taskId}`
11036
11242
  };
11037
11243
  if (mode === "bare") {
11038
11244
  this.terminal.start(base, {
@@ -11061,6 +11267,57 @@ var BoardAgentEngine = class {
11061
11267
  cardSessions() {
11062
11268
  return { ...this.terminal.listSummaries(), ...this.claude.listSummaries() };
11063
11269
  }
11270
+ /** The stable board id this engine runs, if the caller provided one. */
11271
+ get spaceId() {
11272
+ return this.core.spaceId;
11273
+ }
11274
+ /**
11275
+ * Every factiii container/VM on this host (running + stopped) for this
11276
+ * runner's instance prefix - card and util, across ALL boards, since the
11277
+ * prefix is shared by every engine on the host. Returns them decoded; the
11278
+ * daemon scope-filters before handing them to a remote member. Electron
11279
+ * (single owner) gets the raw list straight through.
11280
+ */
11281
+ async listContainers() {
11282
+ const rows = await listContainersByPrefix(
11283
+ `factiii-${this.core.instancePrefix}-`
11284
+ );
11285
+ const out = [];
11286
+ for (const row of rows) {
11287
+ const parsed = parseFactiiiContainerName(
11288
+ row.name,
11289
+ this.core.instancePrefix
11290
+ );
11291
+ if (!parsed) continue;
11292
+ out.push({
11293
+ containerName: row.name,
11294
+ kind: parsed.kind,
11295
+ spaceId: parsed.spaceId,
11296
+ // This engine only knows its own board's slug; the daemon fills in
11297
+ // slugs for other boards (cross-board) when scope-filtering.
11298
+ spaceSlug: parsed.spaceId != null && parsed.spaceId === this.core.spaceId ? this.core.spaceSlug : void 0,
11299
+ taskId: parsed.taskId,
11300
+ cacheKey: parsed.cacheKey,
11301
+ running: row.running,
11302
+ status: row.status
11303
+ });
11304
+ }
11305
+ return out;
11306
+ }
11307
+ /**
11308
+ * Force-remove a container/VM by name. Guards the name against this runner's
11309
+ * prefix so a peer can't `docker rm` an arbitrary container on the host. The
11310
+ * daemon adds the per-board scope check on top for remote members.
11311
+ */
11312
+ async removeContainer({
11313
+ containerName
11314
+ }) {
11315
+ if (!containerName.startsWith(`factiii-${this.core.instancePrefix}-`)) {
11316
+ throw new Error("Refusing to remove a container outside this runner.");
11317
+ }
11318
+ await killContainer(containerName);
11319
+ return { removed: true };
11320
+ }
11064
11321
  cardSession({ postId }) {
11065
11322
  return this.claude.sessionWithLogs(String(postId)) ?? this.terminal.sessionWithLogs(String(postId));
11066
11323
  }
@@ -11305,6 +11562,12 @@ var BoardAgentEngine = class {
11305
11562
  return this.bareListExposed(
11306
11563
  payload
11307
11564
  );
11565
+ case "listContainers":
11566
+ return await this.listContainers();
11567
+ case "removeContainer":
11568
+ return await this.removeContainer(
11569
+ payload
11570
+ );
11308
11571
  default:
11309
11572
  throw new Error(`Unknown board-agent action: ${String(action)}`);
11310
11573
  }
@@ -11361,7 +11624,7 @@ function makeChunkReassembler() {
11361
11624
 
11362
11625
  // src/daemon.ts
11363
11626
  var import_node_datachannel = require("node-datachannel");
11364
- var import_path5 = __toESM(require("path"));
11627
+ var import_path6 = __toESM(require("path"));
11365
11628
 
11366
11629
  // ../../node_modules/engine.io-client/build/esm-debug/transports/polling-xhr.node.js
11367
11630
  var XMLHttpRequestModule = __toESM(require_XMLHttpRequest(), 1);
@@ -12541,12 +12804,12 @@ function parse2(str) {
12541
12804
  uri.queryKey = queryKey(uri, uri["query"]);
12542
12805
  return uri;
12543
12806
  }
12544
- function pathNames(obj, path7) {
12545
- const regx = /\/{2,9}/g, names = path7.replace(regx, "/").split("/");
12546
- if (path7.slice(0, 1) == "/" || path7.length === 0) {
12807
+ function pathNames(obj, path8) {
12808
+ const regx = /\/{2,9}/g, names = path8.replace(regx, "/").split("/");
12809
+ if (path8.slice(0, 1) == "/" || path8.length === 0) {
12547
12810
  names.splice(0, 1);
12548
12811
  }
12549
- if (path7.slice(-1) == "/") {
12812
+ if (path8.slice(-1) == "/") {
12550
12813
  names.splice(names.length - 1, 1);
12551
12814
  }
12552
12815
  return names;
@@ -13165,7 +13428,7 @@ var protocol2 = Socket.protocol;
13165
13428
  // ../../node_modules/socket.io-client/build/esm-debug/url.js
13166
13429
  var import_debug7 = __toESM(require_src(), 1);
13167
13430
  var debug7 = (0, import_debug7.default)("socket.io-client:url");
13168
- function url(uri, path7 = "", loc) {
13431
+ function url(uri, path8 = "", loc) {
13169
13432
  let obj = uri;
13170
13433
  loc = loc || typeof location !== "undefined" && location;
13171
13434
  if (null == uri)
@@ -13199,7 +13462,7 @@ function url(uri, path7 = "", loc) {
13199
13462
  obj.path = obj.path || "/";
13200
13463
  const ipv6 = obj.host.indexOf(":") !== -1;
13201
13464
  const host = ipv6 ? "[" + obj.host + "]" : obj.host;
13202
- obj.id = obj.protocol + "://" + host + ":" + obj.port + path7;
13465
+ obj.id = obj.protocol + "://" + host + ":" + obj.port + path8;
13203
13466
  obj.href = obj.protocol + "://" + host + (loc && loc.port === obj.port ? "" : ":" + obj.port);
13204
13467
  return obj;
13205
13468
  }
@@ -14833,8 +15096,8 @@ function lookup(uri, opts) {
14833
15096
  const parsed = url(uri, opts.path || "/socket.io");
14834
15097
  const source = parsed.source;
14835
15098
  const id = parsed.id;
14836
- const path7 = parsed.path;
14837
- const sameNamespace = cache[id] && path7 in cache[id]["nsps"];
15099
+ const path8 = parsed.path;
15100
+ const sameNamespace = cache[id] && path8 in cache[id]["nsps"];
14838
15101
  const newConnection = opts.forceNew || opts["force new connection"] || false === opts.multiplex || sameNamespace;
14839
15102
  let io;
14840
15103
  if (newConnection) {
@@ -14860,22 +15123,22 @@ Object.assign(lookup, {
14860
15123
  });
14861
15124
 
14862
15125
  // ../../shared/all/helpers/terminal-ai/terminal.ts
14863
- var import_child_process6 = require("child_process");
15126
+ var import_child_process7 = require("child_process");
14864
15127
  var import_dockerode = __toESM(require("dockerode"));
14865
- var import_fs3 = __toESM(require("fs"));
15128
+ var import_fs4 = __toESM(require("fs"));
14866
15129
  var import_os2 = __toESM(require("os"));
14867
- var import_path3 = __toESM(require("path"));
15130
+ var import_path4 = __toESM(require("path"));
14868
15131
  function resolveDocker() {
14869
15132
  if (process.env.DOCKER_HOST) return new import_dockerode.default();
14870
15133
  try {
14871
- const out = (0, import_child_process6.execFileSync)(
15134
+ const out = (0, import_child_process7.execFileSync)(
14872
15135
  "docker",
14873
15136
  ["context", "inspect", "--format", "{{.Endpoints.docker.Host}}"],
14874
15137
  { encoding: "utf-8", stdio: ["ignore", "pipe", "ignore"] }
14875
15138
  ).trim();
14876
15139
  if (out.startsWith("unix://")) {
14877
15140
  const p = out.slice("unix://".length);
14878
- if (import_fs3.default.existsSync(p)) return new import_dockerode.default({ socketPath: p });
15141
+ if (import_fs4.default.existsSync(p)) return new import_dockerode.default({ socketPath: p });
14879
15142
  } else if (out) {
14880
15143
  process.env.DOCKER_HOST = out;
14881
15144
  return new import_dockerode.default();
@@ -14884,15 +15147,15 @@ function resolveDocker() {
14884
15147
  }
14885
15148
  const candidates = [
14886
15149
  "/var/run/docker.sock",
14887
- import_path3.default.join(import_os2.default.homedir(), ".docker/run/docker.sock"),
14888
- import_path3.default.join(import_os2.default.homedir(), ".docker/desktop/docker.sock"),
14889
- import_path3.default.join(import_os2.default.homedir(), ".orbstack/run/docker.sock"),
14890
- import_path3.default.join(import_os2.default.homedir(), ".colima/default/docker.sock"),
14891
- import_path3.default.join(import_os2.default.homedir(), ".rd/docker.sock")
15150
+ import_path4.default.join(import_os2.default.homedir(), ".docker/run/docker.sock"),
15151
+ import_path4.default.join(import_os2.default.homedir(), ".docker/desktop/docker.sock"),
15152
+ import_path4.default.join(import_os2.default.homedir(), ".orbstack/run/docker.sock"),
15153
+ import_path4.default.join(import_os2.default.homedir(), ".colima/default/docker.sock"),
15154
+ import_path4.default.join(import_os2.default.homedir(), ".rd/docker.sock")
14892
15155
  ];
14893
15156
  for (const p of candidates) {
14894
15157
  try {
14895
- import_fs3.default.statSync(p);
15158
+ import_fs4.default.statSync(p);
14896
15159
  return new import_dockerode.default({ socketPath: p });
14897
15160
  } catch {
14898
15161
  }
@@ -15093,50 +15356,119 @@ var TerminalManager = class {
15093
15356
  };
15094
15357
 
15095
15358
  // src/agent-adapter.ts
15096
- var import_fs4 = __toESM(require("fs"));
15359
+ var import_fs5 = __toESM(require("fs"));
15097
15360
  var import_os3 = __toESM(require("os"));
15098
- var import_path4 = __toESM(require("path"));
15099
- var CONFIG_DIR2 = import_path4.default.join(import_os3.default.homedir(), ".factiii-runner");
15361
+ var import_path5 = __toESM(require("path"));
15362
+ var CONFIG_DIR2 = import_path5.default.join(import_os3.default.homedir(), ".factiii-runner");
15363
+ var RUNNER_CONFIG_PATH = import_path5.default.join(CONFIG_DIR2, "runner-config.json");
15100
15364
  function safeSlug(spaceSlug) {
15101
15365
  return spaceSlug.replace(/[^a-zA-Z0-9_.-]/g, "_");
15102
15366
  }
15103
- function configPath(spaceSlug) {
15104
- return import_path4.default.join(CONFIG_DIR2, `space-${safeSlug(spaceSlug)}.json`);
15105
- }
15106
15367
  function connectionPath(spaceSlug) {
15107
- return import_path4.default.join(CONFIG_DIR2, `space-${safeSlug(spaceSlug)}.onedrive.json`);
15368
+ return import_path5.default.join(CONFIG_DIR2, `space-${safeSlug(spaceSlug)}.onedrive.json`);
15108
15369
  }
15109
15370
  function writeJson(filePath, value2) {
15110
- import_fs4.default.mkdirSync(CONFIG_DIR2, { recursive: true });
15111
- import_fs4.default.writeFileSync(filePath, JSON.stringify(value2, null, 2));
15112
- import_fs4.default.chmodSync(filePath, 384);
15371
+ import_fs5.default.mkdirSync(CONFIG_DIR2, { recursive: true });
15372
+ import_fs5.default.writeFileSync(filePath, JSON.stringify(value2, null, 2));
15373
+ import_fs5.default.chmodSync(filePath, 384);
15113
15374
  }
15114
- var localConfigProvider = {
15115
- readConfig(spaceSlug) {
15116
- let config = {};
15375
+ var CONFIG_FIELDS = [
15376
+ "repoUrl",
15377
+ "mainBranch",
15378
+ "gitName",
15379
+ "gitEmail",
15380
+ "claudeToken",
15381
+ "githubToken",
15382
+ "storageBackend"
15383
+ ];
15384
+ function fillDefaults(config) {
15385
+ return {
15386
+ repoUrl: config.repoUrl || "",
15387
+ mainBranch: config.mainBranch || "main",
15388
+ gitName: config.gitName || "",
15389
+ gitEmail: config.gitEmail || "",
15390
+ claudeToken: config.claudeToken || "",
15391
+ githubToken: config.githubToken || "",
15392
+ storageBackend: config.storageBackend || "github"
15393
+ };
15394
+ }
15395
+ function migrateLegacyConfigs() {
15396
+ const file = { boards: {} };
15397
+ let legacy = [];
15398
+ try {
15399
+ legacy = import_fs5.default.readdirSync(CONFIG_DIR2).filter((f) => f.startsWith("space-") && f.endsWith(".json")).filter((f) => !f.endsWith(".onedrive.json"));
15400
+ } catch {
15401
+ return file;
15402
+ }
15403
+ for (const name of legacy) {
15404
+ const slug = name.slice("space-".length, -".json".length);
15405
+ let parsed = {};
15117
15406
  try {
15118
- config = JSON.parse(
15119
- import_fs4.default.readFileSync(configPath(spaceSlug), "utf-8")
15407
+ parsed = JSON.parse(
15408
+ import_fs5.default.readFileSync(import_path5.default.join(CONFIG_DIR2, name), "utf-8")
15120
15409
  );
15121
15410
  } catch {
15411
+ continue;
15122
15412
  }
15123
- return {
15124
- repoUrl: config.repoUrl || "",
15125
- mainBranch: config.mainBranch || "main",
15126
- gitName: config.gitName || "",
15127
- gitEmail: config.gitEmail || "",
15128
- claudeToken: config.claudeToken || "",
15129
- githubToken: config.githubToken || "",
15130
- storageBackend: config.storageBackend || "github"
15131
- };
15413
+ if (!file.defaultSlug) {
15414
+ file.defaultSlug = slug;
15415
+ file.default = parsed;
15416
+ } else {
15417
+ file.boards[slug] = parsed;
15418
+ }
15419
+ }
15420
+ if (file.defaultSlug) writeJson(RUNNER_CONFIG_PATH, file);
15421
+ return file;
15422
+ }
15423
+ function readRunnerConfig2() {
15424
+ try {
15425
+ return JSON.parse(
15426
+ import_fs5.default.readFileSync(RUNNER_CONFIG_PATH, "utf-8")
15427
+ );
15428
+ } catch {
15429
+ return migrateLegacyConfigs();
15430
+ }
15431
+ }
15432
+ var localConfigProvider = {
15433
+ readConfig(spaceSlug) {
15434
+ const file = readRunnerConfig2();
15435
+ const base = file.default ?? {};
15436
+ const over = file.boards?.[spaceSlug] ?? {};
15437
+ return fillDefaults({
15438
+ repoUrl: over.repoUrl || base.repoUrl,
15439
+ mainBranch: over.mainBranch || base.mainBranch,
15440
+ gitName: over.gitName || base.gitName,
15441
+ gitEmail: over.gitEmail || base.gitEmail,
15442
+ claudeToken: over.claudeToken || base.claudeToken,
15443
+ githubToken: over.githubToken || base.githubToken,
15444
+ storageBackend: over.storageBackend || base.storageBackend
15445
+ });
15132
15446
  },
15133
15447
  writeConfig(spaceSlug, config) {
15134
- writeJson(configPath(spaceSlug), config);
15448
+ const file = readRunnerConfig2();
15449
+ file.boards ??= {};
15450
+ if (!file.defaultSlug) {
15451
+ file.defaultSlug = spaceSlug;
15452
+ file.default = config;
15453
+ } else if (spaceSlug === file.defaultSlug) {
15454
+ file.default = config;
15455
+ } else {
15456
+ const base = file.default ?? {};
15457
+ const diff = {};
15458
+ for (const f of CONFIG_FIELDS) {
15459
+ if (config[f] && config[f] !== base[f]) {
15460
+ diff[f] = config[f];
15461
+ }
15462
+ }
15463
+ if (Object.keys(diff).length) file.boards[spaceSlug] = diff;
15464
+ else delete file.boards[spaceSlug];
15465
+ }
15466
+ writeJson(RUNNER_CONFIG_PATH, file);
15135
15467
  },
15136
15468
  readOneDriveConnection(spaceSlug) {
15137
15469
  try {
15138
15470
  return JSON.parse(
15139
- import_fs4.default.readFileSync(connectionPath(spaceSlug), "utf-8")
15471
+ import_fs5.default.readFileSync(connectionPath(spaceSlug), "utf-8")
15140
15472
  );
15141
15473
  } catch {
15142
15474
  return null;
@@ -15145,7 +15477,7 @@ var localConfigProvider = {
15145
15477
  writeOneDriveConnection(spaceSlug, connection) {
15146
15478
  const filePath = connectionPath(spaceSlug);
15147
15479
  if (!connection) {
15148
- import_fs4.default.rmSync(filePath, { force: true });
15480
+ import_fs5.default.rmSync(filePath, { force: true });
15149
15481
  return;
15150
15482
  }
15151
15483
  writeJson(filePath, connection);
@@ -15153,6 +15485,13 @@ var localConfigProvider = {
15153
15485
  };
15154
15486
 
15155
15487
  // src/daemon.ts
15488
+ function toChannelScope(raw) {
15489
+ if (!raw || raw.role === "owner") return { role: "owner" };
15490
+ return { role: "member", spaceSlugs: new Set(raw.spaceSlugs) };
15491
+ }
15492
+ function scopeAllows(scope, spaceSlug) {
15493
+ return scope.role === "owner" || scope.spaceSlugs.has(spaceSlug);
15494
+ }
15156
15495
  async function startDaemon(config) {
15157
15496
  console.log(`Connecting to ${config.serverUrl} as "${config.runnerName}"...`);
15158
15497
  const socket = lookup(config.serverUrl, {
@@ -15162,10 +15501,11 @@ async function startDaemon(config) {
15162
15501
  autoConnect: false
15163
15502
  });
15164
15503
  const engines = /* @__PURE__ */ new Map();
15165
- const openChannels = /* @__PURE__ */ new Set();
15504
+ const channelScopes = /* @__PURE__ */ new Map();
15166
15505
  const terminalByChannel = /* @__PURE__ */ new WeakMap();
15167
- const findCardContainer = (postId) => {
15168
- for (const engine of engines.values()) {
15506
+ const findCardContainerInScope = (scope, postId) => {
15507
+ for (const [spaceSlug, engine] of engines) {
15508
+ if (!scopeAllows(scope, spaceSlug)) continue;
15169
15509
  const name = engine.getCardContainerName(postId);
15170
15510
  if (name) return name;
15171
15511
  }
@@ -15173,7 +15513,7 @@ async function startDaemon(config) {
15173
15513
  };
15174
15514
  const broadcastToAll = (msg) => {
15175
15515
  const frames = chunkWire(msg);
15176
- for (const dc of openChannels) {
15516
+ for (const dc of channelScopes.keys()) {
15177
15517
  for (const frame of frames) {
15178
15518
  try {
15179
15519
  dc.sendMessage(frame);
@@ -15182,7 +15522,19 @@ async function startDaemon(config) {
15182
15522
  }
15183
15523
  }
15184
15524
  };
15185
- function getEngine(spaceSlug) {
15525
+ const broadcastToBoard = (spaceSlug, msg) => {
15526
+ const frames = chunkWire(msg);
15527
+ for (const [dc, scope] of channelScopes) {
15528
+ if (!scopeAllows(scope, spaceSlug)) continue;
15529
+ for (const frame of frames) {
15530
+ try {
15531
+ dc.sendMessage(frame);
15532
+ } catch {
15533
+ }
15534
+ }
15535
+ }
15536
+ };
15537
+ function getEngine(spaceSlug, spaceId) {
15186
15538
  let engine = engines.get(spaceSlug);
15187
15539
  if (!engine) {
15188
15540
  const send = (event, data) => {
@@ -15192,10 +15544,13 @@ async function startDaemon(config) {
15192
15544
  event,
15193
15545
  payload: data
15194
15546
  };
15195
- broadcastToAll(JSON.stringify(msg));
15547
+ broadcastToBoard(spaceSlug, JSON.stringify(msg));
15196
15548
  };
15197
15549
  engine = new BoardAgentEngine({
15198
15550
  spaceSlug,
15551
+ // Stable id for durable container naming (slugs can change); the engine
15552
+ // falls back to the slug when absent.
15553
+ spaceId,
15199
15554
  emitter: createEventSink(send, spaceSlug),
15200
15555
  configProvider: localConfigProvider,
15201
15556
  dockerfilePath: getDockerfilePath(),
@@ -15209,6 +15564,7 @@ async function startDaemon(config) {
15209
15564
  }
15210
15565
  return engine;
15211
15566
  }
15567
+ const instancePrefix = computeInstancePrefix(`runner-${config.runnerName}`);
15212
15568
  socket.on("connect", () => {
15213
15569
  console.log(`Connected. Runner "${config.runnerName}" is online.`);
15214
15570
  console.log("Waiting for connections from UI clients...\n");
@@ -15221,6 +15577,7 @@ async function startDaemon(config) {
15221
15577
  });
15222
15578
  socket.on("runnerOffer", (data) => {
15223
15579
  console.log(`Received WebRTC offer from ${data.fromSocketId}`);
15580
+ const scope = toChannelScope(data.scope);
15224
15581
  try {
15225
15582
  const peer = new import_node_datachannel.PeerConnection("runner", {
15226
15583
  iceServers: ["stun:stun.l.google.com:19302"]
@@ -15268,7 +15625,7 @@ async function startDaemon(config) {
15268
15625
  console.log(
15269
15626
  `Data channel "${dc.getLabel()}" opened with ${data.fromSocketId}`
15270
15627
  );
15271
- openChannels.add(dc);
15628
+ channelScopes.set(dc, scope);
15272
15629
  const reassembler = makeChunkReassembler();
15273
15630
  const sendToChannel = (msg) => {
15274
15631
  for (const frame of chunkWire(msg)) {
@@ -15286,10 +15643,17 @@ async function startDaemon(config) {
15286
15643
  };
15287
15644
  terminalByChannel.set(
15288
15645
  dc,
15289
- new TerminalManager(sendRawToChannel, findCardContainer)
15646
+ new TerminalManager(
15647
+ sendRawToChannel,
15648
+ (postId) => findCardContainerInScope(scope, postId)
15649
+ )
15290
15650
  );
15291
15651
  dc.onOpen(() => {
15652
+ sendToChannel(
15653
+ JSON.stringify({ type: "runner-info", version: currentVersion() })
15654
+ );
15292
15655
  for (const [spaceSlug, engine] of engines.entries()) {
15656
+ if (!scopeAllows(scope, spaceSlug)) continue;
15293
15657
  const sessions = engine.cardSessions();
15294
15658
  for (const summary of Object.values(sessions)) {
15295
15659
  const msg = JSON.stringify({
@@ -15317,15 +15681,21 @@ async function startDaemon(config) {
15317
15681
  }
15318
15682
  return;
15319
15683
  }
15684
+ if (parsed.type === "runner-control" && parsed.action === "update") {
15685
+ applyUpdateAndRestart();
15686
+ return;
15687
+ }
15320
15688
  if (parsed.type === "broadcast" && parsed.spaceSlug && parsed.event) {
15689
+ if (!scopeAllows(scope, parsed.spaceSlug)) return;
15321
15690
  const eventMsg = JSON.stringify({
15322
15691
  type: "event",
15323
15692
  event: `board-agent:${parsed.spaceSlug}:${parsed.event}`,
15324
15693
  payload: parsed.payload
15325
15694
  });
15326
15695
  const frames = chunkWire(eventMsg);
15327
- for (const other of openChannels) {
15696
+ for (const [other, otherScope] of channelScopes) {
15328
15697
  if (other === dc) continue;
15698
+ if (!scopeAllows(otherScope, parsed.spaceSlug)) continue;
15329
15699
  for (const frame of frames) {
15330
15700
  try {
15331
15701
  other.sendMessage(frame);
@@ -15340,14 +15710,17 @@ async function startDaemon(config) {
15340
15710
  }
15341
15711
  handleDataChannelMessage(
15342
15712
  msgStr,
15713
+ scope,
15343
15714
  sendToChannel,
15344
- broadcastToAll,
15345
- getEngine
15715
+ broadcastToBoard,
15716
+ getEngine,
15717
+ engines,
15718
+ instancePrefix
15346
15719
  );
15347
15720
  });
15348
15721
  dc.onClosed(() => {
15349
15722
  console.log(`Data channel closed with ${data.fromSocketId}`);
15350
- openChannels.delete(dc);
15723
+ channelScopes.delete(dc);
15351
15724
  const mgr = terminalByChannel.get(dc);
15352
15725
  if (mgr) {
15353
15726
  mgr.destroyAll();
@@ -15373,6 +15746,25 @@ async function startDaemon(config) {
15373
15746
  }
15374
15747
  socket.disconnect();
15375
15748
  };
15749
+ const applyUpdateAndRestart = () => {
15750
+ console.log("[update] update approved by a client \u2014 installing latest...");
15751
+ const status = (state) => broadcastToAll(JSON.stringify({ type: "runner-update", state }));
15752
+ status("installing");
15753
+ if (!installLatest()) {
15754
+ status("failed");
15755
+ return;
15756
+ }
15757
+ status("restarting");
15758
+ cleanup();
15759
+ process.exit(RESTART_FOR_UPDATE);
15760
+ };
15761
+ socket.on("runnerRevoked", () => {
15762
+ console.log(
15763
+ "\nThis runner was disconnected from your Factiii account. Stopping all jobs and removing containers..."
15764
+ );
15765
+ cleanup();
15766
+ process.exit(0);
15767
+ });
15376
15768
  process.on("SIGINT", () => {
15377
15769
  console.log("\nShutting down...");
15378
15770
  cleanup();
@@ -15382,8 +15774,27 @@ async function startDaemon(config) {
15382
15774
  cleanup();
15383
15775
  process.exit(0);
15384
15776
  });
15777
+ await new Promise(() => {
15778
+ });
15385
15779
  }
15386
- async function handleDataChannelMessage(raw, sendToChannel, broadcastToAll, getEngine) {
15780
+ function spaceIdToSlug(engines) {
15781
+ const map = /* @__PURE__ */ new Map();
15782
+ for (const [slug, engine] of engines) {
15783
+ if (engine.spaceId != null) map.set(engine.spaceId, slug);
15784
+ }
15785
+ return map;
15786
+ }
15787
+ function scopeFilterContainers(all, scope, engines) {
15788
+ const idToSlug = spaceIdToSlug(engines);
15789
+ return all.flatMap((c) => {
15790
+ const slug = c.spaceId != null ? idToSlug.get(c.spaceId) : void 0;
15791
+ if (scope.role !== "owner") {
15792
+ if (!slug || !scopeAllows(scope, slug)) return [];
15793
+ }
15794
+ return [{ ...c, spaceId: slug ? c.spaceId : null, spaceSlug: slug }];
15795
+ });
15796
+ }
15797
+ async function handleDataChannelMessage(raw, scope, sendToChannel, broadcastToBoard, getEngine, engines, instancePrefix) {
15387
15798
  let msg;
15388
15799
  try {
15389
15800
  msg = JSON.parse(raw);
@@ -15391,9 +15802,76 @@ async function handleDataChannelMessage(raw, sendToChannel, broadcastToAll, getE
15391
15802
  return;
15392
15803
  }
15393
15804
  if (msg.type !== "dispatch") return;
15394
- const engine = getEngine(msg.spaceSlug);
15395
15805
  const { action, payload, requestId } = msg;
15396
15806
  const ts = () => (/* @__PURE__ */ new Date()).toLocaleTimeString();
15807
+ const sendResult = (res) => sendToChannel(JSON.stringify(res));
15808
+ if (action === "listContainers") {
15809
+ try {
15810
+ const rows = await listContainersByPrefix(`factiii-${instancePrefix}-`);
15811
+ const all = rows.flatMap((row) => {
15812
+ const parsed = parseFactiiiContainerName(row.name, instancePrefix);
15813
+ if (!parsed) return [];
15814
+ return [
15815
+ {
15816
+ containerName: row.name,
15817
+ kind: parsed.kind,
15818
+ spaceId: parsed.spaceId,
15819
+ taskId: parsed.taskId,
15820
+ cacheKey: parsed.cacheKey,
15821
+ running: row.running,
15822
+ status: row.status
15823
+ }
15824
+ ];
15825
+ });
15826
+ sendResult({
15827
+ type: "result",
15828
+ requestId,
15829
+ result: scopeFilterContainers(all, scope, engines)
15830
+ });
15831
+ } catch (err) {
15832
+ sendResult({
15833
+ type: "result",
15834
+ requestId,
15835
+ error: err instanceof Error ? err.message : String(err)
15836
+ });
15837
+ }
15838
+ return;
15839
+ }
15840
+ if (action === "removeContainer") {
15841
+ const { containerName } = payload;
15842
+ try {
15843
+ if (!containerName.startsWith(`factiii-${instancePrefix}-`)) {
15844
+ throw new Error("Refusing to remove a container outside this runner.");
15845
+ }
15846
+ if (scope.role !== "owner") {
15847
+ const parsed = parseFactiiiContainerName(containerName, instancePrefix);
15848
+ const slug = parsed?.spaceId != null ? spaceIdToSlug(engines).get(parsed.spaceId) : void 0;
15849
+ if (!slug || !scopeAllows(scope, slug)) {
15850
+ throw new Error("Not authorized to remove this container");
15851
+ }
15852
+ }
15853
+ await killContainer(containerName);
15854
+ sendResult({ type: "result", requestId, result: { removed: true } });
15855
+ } catch (err) {
15856
+ sendResult({
15857
+ type: "result",
15858
+ requestId,
15859
+ error: err instanceof Error ? err.message : String(err)
15860
+ });
15861
+ }
15862
+ return;
15863
+ }
15864
+ if (!scopeAllows(scope, msg.spaceSlug)) {
15865
+ console.warn(`[${ts()}] \u20E0 ${action} denied for board "${msg.spaceSlug}"`);
15866
+ const denied = {
15867
+ type: "result",
15868
+ requestId,
15869
+ error: "Not authorized for this board on this runner"
15870
+ };
15871
+ sendToChannel(JSON.stringify(denied));
15872
+ return;
15873
+ }
15874
+ const engine = getEngine(msg.spaceSlug, msg.spaceId);
15397
15875
  console.log(`[${ts()}] \u2190 ${action} (${requestId})`);
15398
15876
  const askLogSender = (entry) => {
15399
15877
  const event = {
@@ -15402,7 +15880,7 @@ async function handleDataChannelMessage(raw, sendToChannel, broadcastToAll, getE
15402
15880
  event: `board-agent:${msg.spaceSlug}:log`,
15403
15881
  payload: entry
15404
15882
  };
15405
- broadcastToAll(JSON.stringify(event));
15883
+ broadcastToBoard(msg.spaceSlug, JSON.stringify(event));
15406
15884
  };
15407
15885
  try {
15408
15886
  const result = await engine.dispatch(
@@ -15425,107 +15903,41 @@ async function handleDataChannelMessage(raw, sendToChannel, broadcastToAll, getE
15425
15903
  }
15426
15904
  }
15427
15905
  function getDockerfilePath() {
15428
- return import_path5.default.join(__dirname, "..", "Dockerfile.claude");
15906
+ return import_path6.default.join(__dirname, "..", "Dockerfile.claude");
15429
15907
  }
15430
15908
 
15431
- // src/setup.ts
15432
- var import_child_process7 = require("child_process");
15433
- var import_crypto2 = __toESM(require("crypto"));
15434
- var import_fs5 = __toESM(require("fs"));
15435
- var import_path6 = __toESM(require("path"));
15436
- var DOCKERFILE_LABEL2 = "factiii.dockerfile-sha";
15437
- function run(bin, args, opts) {
15438
- try {
15439
- return (0, import_child_process7.execFileSync)(bin, args, {
15440
- encoding: "utf-8",
15441
- stdio: opts?.silent ? "pipe" : "inherit",
15442
- // Avoids a cmd.exe window flash on Windows during silent checks.
15443
- windowsHide: true
15444
- });
15445
- } catch {
15446
- return "";
15447
- }
15448
- }
15449
- function check(cmd) {
15450
- const lookup2 = process.platform === "win32" ? "where" : "which";
15451
- try {
15452
- (0, import_child_process7.execFileSync)(lookup2, [cmd], { stdio: "pipe", windowsHide: true });
15453
- return true;
15454
- } catch {
15455
- return false;
15456
- }
15457
- }
15458
- async function setup(serverUrl) {
15459
- console.log("\n=== Factiii Runner \u2014 Setup ===\n");
15460
- console.log("[1/3] Checking Docker...");
15461
- if (!check("docker")) {
15462
- console.error(
15463
- " Docker is not installed. Install it from https://docs.docker.com/get-docker/ and re-run setup."
15464
- );
15465
- process.exit(1);
15466
- }
15467
- if (!run("docker", ["info"], { silent: true })) {
15468
- console.error(
15469
- " Docker is installed but the daemon is not running. Start Docker and re-run setup."
15470
- );
15471
- process.exit(1);
15472
- }
15473
- console.log(" Docker is ready.");
15474
- console.log("\n[2/3] Building factiii-claude Docker image...");
15475
- const dockerfilePath = findDockerfile();
15476
- if (!dockerfilePath) {
15477
- console.error(
15478
- " Dockerfile.claude not found. Reinstall the package and re-run setup."
15479
- );
15480
- process.exit(1);
15909
+ // src/supervisor.ts
15910
+ async function runSupervised(workerArgs) {
15911
+ if (isWorker()) {
15912
+ const config = readRunnerConfig();
15913
+ if (!config) {
15914
+ console.error('Failed to read config. Run "factiii-runner setup" again.');
15915
+ process.exit(1);
15916
+ }
15917
+ await startDaemon(config);
15918
+ process.exit(0);
15481
15919
  }
15482
- const wantedHash = import_crypto2.default.createHash("sha256").update(import_fs5.default.readFileSync(dockerfilePath)).digest("hex");
15483
- const existingHash = run(
15484
- "docker",
15485
- [
15486
- "image",
15487
- "inspect",
15488
- "factiii-claude",
15489
- "--format",
15490
- `{{ index .Config.Labels "${DOCKERFILE_LABEL2}" }}`
15491
- ],
15492
- { silent: true }
15493
- ).trim();
15494
- if (existingHash === wantedHash) {
15495
- console.log(" Image is up to date.");
15496
- } else {
15497
- console.log(
15498
- existingHash ? ` Dockerfile changed since last build, rebuilding from ${dockerfilePath}...` : ` Building from ${dockerfilePath}...`
15920
+ for (; ; ) {
15921
+ const child = (0, import_node_child_process2.spawnSync)(
15922
+ process.execPath,
15923
+ [...process.execArgv, ...workerArgs],
15924
+ {
15925
+ stdio: "inherit",
15926
+ env: markWorkerEnv(process.env)
15927
+ }
15499
15928
  );
15500
- run("docker", [
15501
- "build",
15502
- "-t",
15503
- "factiii-claude",
15504
- "-f",
15505
- dockerfilePath,
15506
- "--label",
15507
- `${DOCKERFILE_LABEL2}=${wantedHash}`,
15508
- "."
15509
- ]);
15510
- console.log(" Image built.");
15929
+ if (child.status === RESTART_FOR_UPDATE) {
15930
+ console.log("[update] relaunching the runner on the new version...");
15931
+ continue;
15932
+ }
15933
+ process.exit(child.signal ? 0 : child.status ?? 0);
15511
15934
  }
15512
- run("docker", ["volume", "create", "factiii-card-cache"], { silent: true });
15513
- console.log("\n[3/3] Pairing this runner with a Factiii account...");
15514
- const { token, runnerName } = await pairWithBrowser(serverUrl);
15515
- writeRunnerConfig({ serverUrl, authToken: token, runnerName });
15516
- console.log(" Paired and config saved.");
15517
- console.log("\n=== Setup complete ===\n");
15518
- console.log("Start the runner with: factiii-runner start\n");
15519
- }
15520
- function findDockerfile() {
15521
- const p = import_path6.default.join(__dirname, "..", "Dockerfile.claude");
15522
- return import_fs5.default.existsSync(p) ? p : null;
15523
15935
  }
15524
15936
 
15525
15937
  // src/cli.ts
15526
15938
  import_node_dns.default.setDefaultResultOrder("ipv4first");
15527
15939
  var program2 = new Command();
15528
- program2.name("factiii-runner").description("Factiii Runner - run Board AI agents on remote servers").version("0.0.1");
15940
+ program2.name("factiii-runner").description("Factiii Runner - run Board AI agents on remote servers").version(currentVersion());
15529
15941
  program2.command("setup").description("Verify Docker, build the Claude image, and pair this runner").option("-s, --server <url>", "API server URL", "https://api.factiii.com").action(async (opts) => {
15530
15942
  try {
15531
15943
  await setup(opts.server);
@@ -15560,12 +15972,7 @@ program2.command("start").description("Start the runner daemon").action(async ()
15560
15972
  console.error('No config found. Run "factiii-runner setup" first.');
15561
15973
  process.exit(1);
15562
15974
  }
15563
- const config = readRunnerConfig();
15564
- if (!config) {
15565
- console.error('Failed to read config. Run "factiii-runner setup" again.');
15566
- process.exit(1);
15567
- }
15568
- await startDaemon(config);
15975
+ await runSupervised(process.argv.slice(1));
15569
15976
  });
15570
15977
  program2.command("status").description("Show current runner configuration").action(() => {
15571
15978
  if (!configExists()) {