@factiii/runner 0.0.1 → 0.2.1

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 (3) hide show
  1. package/Dockerfile.claude +25 -3
  2. package/dist/cli.js +2746 -885
  3. 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 path6 = require("node:path");
967
- var fs4 = require("node:fs");
966
+ var path7 = require("node:path");
967
+ var fs6 = 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 = path6.resolve(baseDir, baseName);
1900
- if (fs4.existsSync(localBin)) return localBin;
1901
- if (sourceExt.includes(path6.extname(baseName))) return void 0;
1899
+ const localBin = path7.resolve(baseDir, baseName);
1900
+ if (fs6.existsSync(localBin)) return localBin;
1901
+ if (sourceExt.includes(path7.extname(baseName))) return void 0;
1902
1902
  const foundExt = sourceExt.find(
1903
- (ext) => fs4.existsSync(`${localBin}${ext}`)
1903
+ (ext) => fs6.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 = fs4.realpathSync(this._scriptPath);
1915
+ resolvedScriptPath = fs6.realpathSync(this._scriptPath);
1916
1916
  } catch (err) {
1917
1917
  resolvedScriptPath = this._scriptPath;
1918
1918
  }
1919
- executableDir = path6.resolve(
1920
- path6.dirname(resolvedScriptPath),
1919
+ executableDir = path7.resolve(
1920
+ path7.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 = path6.basename(
1927
+ const legacyName = path7.basename(
1928
1928
  this._scriptPath,
1929
- path6.extname(this._scriptPath)
1929
+ path7.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(path6.extname(executableFile));
1940
+ launchWithNode = sourceExt.includes(path7.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 = path6.basename(filename, path6.extname(filename));
2780
+ this._name = path7.basename(filename, path7.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(path7) {
2795
- if (path7 === void 0) return this._executableDir;
2796
- this._executableDir = path7;
2794
+ executableDir(path8) {
2795
+ if (path8 === void 0) return this._executableDir;
2796
+ this._executableDir = path8;
2797
2797
  return this;
2798
2798
  }
2799
2799
  /**
@@ -3026,9 +3026,9 @@ 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 fs4 = require("fs");
3029
+ var fs6 = require("fs");
3030
3030
  var Url = require("url");
3031
- var spawn2 = require("child_process").spawn;
3031
+ var spawn3 = require("child_process").spawn;
3032
3032
  module2.exports = XMLHttpRequest3;
3033
3033
  XMLHttpRequest3.XMLHttpRequest = XMLHttpRequest3;
3034
3034
  function XMLHttpRequest3(opts) {
@@ -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
- fs4.readFile(unescape(url2.pathname), function(error, data2) {
3187
+ fs6.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 = fs4.readFileSync(unescape(url2.pathname));
3199
+ this.response = fs6.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
- fs4.writeFileSync(syncFile, "", "utf8");
3325
+ fs6.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
- var syncProc = spawn2(process.argv[0], ["-e", execString]);
3327
+ var syncProc = spawn3(process.argv[0], ["-e", execString]);
3328
3328
  var statusText;
3329
- while (fs4.existsSync(syncFile)) {
3329
+ while (fs6.existsSync(syncFile)) {
3330
3330
  }
3331
- self.responseText = fs4.readFileSync(contentFile, "utf8");
3331
+ self.responseText = fs6.readFileSync(contentFile, "utf8");
3332
3332
  syncProc.stdin.end();
3333
- fs4.unlinkSync(contentFile);
3333
+ fs6.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);
@@ -3984,7 +3984,7 @@ var require_has_flag = __commonJS({
3984
3984
  var require_supports_color = __commonJS({
3985
3985
  "../../node_modules/debug/node_modules/supports-color/index.js"(exports2, module2) {
3986
3986
  "use strict";
3987
- var os3 = require("os");
3987
+ var os4 = require("os");
3988
3988
  var hasFlag = require_has_flag();
3989
3989
  var env = process.env;
3990
3990
  var forceColor;
@@ -4022,7 +4022,7 @@ var require_supports_color = __commonJS({
4022
4022
  }
4023
4023
  const min = forceColor ? 1 : 0;
4024
4024
  if (process.platform === "win32") {
4025
- const osRelease = os3.release().split(".");
4025
+ const osRelease = os4.release().split(".");
4026
4026
  if (Number(process.versions.node.split(".")[0]) >= 8 && Number(osRelease[0]) >= 10 && Number(osRelease[2]) >= 10586) {
4027
4027
  return Number(osRelease[2]) >= 14931 ? 3 : 2;
4028
4028
  }
@@ -4260,15 +4260,16 @@ var require_src = __commonJS({
4260
4260
  }
4261
4261
  });
4262
4262
 
4263
- // ../../node_modules/engine.io-client/node_modules/ws/lib/constants.js
4263
+ // ../../node_modules/ws/lib/constants.js
4264
4264
  var require_constants = __commonJS({
4265
- "../../node_modules/engine.io-client/node_modules/ws/lib/constants.js"(exports2, module2) {
4265
+ "../../node_modules/ws/lib/constants.js"(exports2, module2) {
4266
4266
  "use strict";
4267
4267
  var BINARY_TYPES = ["nodebuffer", "arraybuffer", "fragments"];
4268
4268
  var hasBlob = typeof Blob !== "undefined";
4269
4269
  if (hasBlob) BINARY_TYPES.push("blob");
4270
4270
  module2.exports = {
4271
4271
  BINARY_TYPES,
4272
+ CLOSE_TIMEOUT: 3e4,
4272
4273
  EMPTY_BUFFER: Buffer.alloc(0),
4273
4274
  GUID: "258EAFA5-E914-47DA-95CA-C5AB0DC85B11",
4274
4275
  hasBlob,
@@ -4282,9 +4283,9 @@ var require_constants = __commonJS({
4282
4283
  }
4283
4284
  });
4284
4285
 
4285
- // ../../node_modules/engine.io-client/node_modules/ws/lib/buffer-util.js
4286
+ // ../../node_modules/ws/lib/buffer-util.js
4286
4287
  var require_buffer_util = __commonJS({
4287
- "../../node_modules/engine.io-client/node_modules/ws/lib/buffer-util.js"(exports2, module2) {
4288
+ "../../node_modules/ws/lib/buffer-util.js"(exports2, module2) {
4288
4289
  "use strict";
4289
4290
  var { EMPTY_BUFFER } = require_constants();
4290
4291
  var FastBuffer = Buffer[Symbol.species];
@@ -4357,9 +4358,9 @@ var require_buffer_util = __commonJS({
4357
4358
  }
4358
4359
  });
4359
4360
 
4360
- // ../../node_modules/engine.io-client/node_modules/ws/lib/limiter.js
4361
+ // ../../node_modules/ws/lib/limiter.js
4361
4362
  var require_limiter = __commonJS({
4362
- "../../node_modules/engine.io-client/node_modules/ws/lib/limiter.js"(exports2, module2) {
4363
+ "../../node_modules/ws/lib/limiter.js"(exports2, module2) {
4363
4364
  "use strict";
4364
4365
  var kDone = Symbol("kDone");
4365
4366
  var kRun = Symbol("kRun");
@@ -4407,9 +4408,9 @@ var require_limiter = __commonJS({
4407
4408
  }
4408
4409
  });
4409
4410
 
4410
- // ../../node_modules/engine.io-client/node_modules/ws/lib/permessage-deflate.js
4411
+ // ../../node_modules/ws/lib/permessage-deflate.js
4411
4412
  var require_permessage_deflate = __commonJS({
4412
- "../../node_modules/engine.io-client/node_modules/ws/lib/permessage-deflate.js"(exports2, module2) {
4413
+ "../../node_modules/ws/lib/permessage-deflate.js"(exports2, module2) {
4413
4414
  "use strict";
4414
4415
  var zlib = require("zlib");
4415
4416
  var bufferUtil = require_buffer_util();
@@ -4423,7 +4424,7 @@ var require_permessage_deflate = __commonJS({
4423
4424
  var kBuffers = Symbol("buffers");
4424
4425
  var kError = Symbol("error");
4425
4426
  var zlibLimiter;
4426
- var PerMessageDeflate = class {
4427
+ var PerMessageDeflate2 = class {
4427
4428
  /**
4428
4429
  * Creates a PerMessageDeflate instance.
4429
4430
  *
@@ -4434,6 +4435,9 @@ var require_permessage_deflate = __commonJS({
4434
4435
  * acknowledge disabling of client context takeover
4435
4436
  * @param {Number} [options.concurrencyLimit=10] The number of concurrent
4436
4437
  * calls to zlib
4438
+ * @param {Boolean} [options.isServer=false] Create the instance in either
4439
+ * server or client mode
4440
+ * @param {Number} [options.maxPayload=0] The maximum allowed message length
4437
4441
  * @param {(Boolean|Number)} [options.serverMaxWindowBits] Request/confirm the
4438
4442
  * use of a custom server window size
4439
4443
  * @param {Boolean} [options.serverNoContextTakeover=false] Request/accept
@@ -4444,15 +4448,12 @@ var require_permessage_deflate = __commonJS({
4444
4448
  * deflate
4445
4449
  * @param {Object} [options.zlibInflateOptions] Options to pass to zlib on
4446
4450
  * inflate
4447
- * @param {Boolean} [isServer=false] Create the instance in either server or
4448
- * client mode
4449
- * @param {Number} [maxPayload=0] The maximum allowed message length
4450
4451
  */
4451
- constructor(options, isServer, maxPayload) {
4452
- this._maxPayload = maxPayload | 0;
4452
+ constructor(options) {
4453
4453
  this._options = options || {};
4454
4454
  this._threshold = this._options.threshold !== void 0 ? this._options.threshold : 1024;
4455
- this._isServer = !!isServer;
4455
+ this._maxPayload = this._options.maxPayload | 0;
4456
+ this._isServer = !!this._options.isServer;
4456
4457
  this._deflate = null;
4457
4458
  this._inflate = null;
4458
4459
  this.params = null;
@@ -4761,7 +4762,7 @@ var require_permessage_deflate = __commonJS({
4761
4762
  });
4762
4763
  }
4763
4764
  };
4764
- module2.exports = PerMessageDeflate;
4765
+ module2.exports = PerMessageDeflate2;
4765
4766
  function deflateOnData(chunk) {
4766
4767
  this[kBuffers].push(chunk);
4767
4768
  this[kTotalLength] += chunk.length;
@@ -4790,9 +4791,9 @@ var require_permessage_deflate = __commonJS({
4790
4791
  }
4791
4792
  });
4792
4793
 
4793
- // ../../node_modules/engine.io-client/node_modules/ws/lib/validation.js
4794
+ // ../../node_modules/ws/lib/validation.js
4794
4795
  var require_validation = __commonJS({
4795
- "../../node_modules/engine.io-client/node_modules/ws/lib/validation.js"(exports2, module2) {
4796
+ "../../node_modules/ws/lib/validation.js"(exports2, module2) {
4796
4797
  "use strict";
4797
4798
  var { isUtf8 } = require("buffer");
4798
4799
  var { hasBlob } = require_constants();
@@ -4991,12 +4992,12 @@ var require_validation = __commonJS({
4991
4992
  }
4992
4993
  });
4993
4994
 
4994
- // ../../node_modules/engine.io-client/node_modules/ws/lib/receiver.js
4995
+ // ../../node_modules/ws/lib/receiver.js
4995
4996
  var require_receiver = __commonJS({
4996
- "../../node_modules/engine.io-client/node_modules/ws/lib/receiver.js"(exports2, module2) {
4997
+ "../../node_modules/ws/lib/receiver.js"(exports2, module2) {
4997
4998
  "use strict";
4998
4999
  var { Writable } = require("stream");
4999
- var PerMessageDeflate = require_permessage_deflate();
5000
+ var PerMessageDeflate2 = require_permessage_deflate();
5000
5001
  var {
5001
5002
  BINARY_TYPES,
5002
5003
  EMPTY_BUFFER,
@@ -5026,6 +5027,10 @@ var require_receiver = __commonJS({
5026
5027
  * extensions
5027
5028
  * @param {Boolean} [options.isServer=false] Specifies whether to operate in
5028
5029
  * client or server mode
5030
+ * @param {Number} [options.maxBufferedChunks=0] The maximum number of
5031
+ * buffered data chunks
5032
+ * @param {Number} [options.maxFragments=0] The maximum number of message
5033
+ * fragments
5029
5034
  * @param {Number} [options.maxPayload=0] The maximum allowed message length
5030
5035
  * @param {Boolean} [options.skipUTF8Validation=false] Specifies whether or
5031
5036
  * not to skip UTF-8 validation for text and close messages
@@ -5036,6 +5041,8 @@ var require_receiver = __commonJS({
5036
5041
  this._binaryType = options.binaryType || BINARY_TYPES[0];
5037
5042
  this._extensions = options.extensions || {};
5038
5043
  this._isServer = !!options.isServer;
5044
+ this._maxBufferedChunks = options.maxBufferedChunks | 0;
5045
+ this._maxFragments = options.maxFragments | 0;
5039
5046
  this._maxPayload = options.maxPayload | 0;
5040
5047
  this._skipUTF8Validation = !!options.skipUTF8Validation;
5041
5048
  this[kWebSocket] = void 0;
@@ -5065,6 +5072,18 @@ var require_receiver = __commonJS({
5065
5072
  */
5066
5073
  _write(chunk, encoding, cb) {
5067
5074
  if (this._opcode === 8 && this._state == GET_INFO) return cb();
5075
+ if (this._maxBufferedChunks > 0 && this._buffers.length >= this._maxBufferedChunks) {
5076
+ cb(
5077
+ this.createError(
5078
+ RangeError,
5079
+ "Too many buffered chunks",
5080
+ false,
5081
+ 1008,
5082
+ "WS_ERR_TOO_MANY_BUFFERED_PARTS"
5083
+ )
5084
+ );
5085
+ return;
5086
+ }
5068
5087
  this._bufferedBytes += chunk.length;
5069
5088
  this._buffers.push(chunk);
5070
5089
  this.startLoop(cb);
@@ -5163,7 +5182,7 @@ var require_receiver = __commonJS({
5163
5182
  return;
5164
5183
  }
5165
5184
  const compressed = (buf[0] & 64) === 64;
5166
- if (compressed && !this._extensions[PerMessageDeflate.extensionName]) {
5185
+ if (compressed && !this._extensions[PerMessageDeflate2.extensionName]) {
5167
5186
  const error = this.createError(
5168
5187
  RangeError,
5169
5188
  "RSV1 must be clear",
@@ -5394,6 +5413,17 @@ var require_receiver = __commonJS({
5394
5413
  return;
5395
5414
  }
5396
5415
  if (data.length) {
5416
+ if (this._maxFragments > 0 && this._fragments.length >= this._maxFragments) {
5417
+ const error = this.createError(
5418
+ RangeError,
5419
+ "Too many message fragments",
5420
+ false,
5421
+ 1008,
5422
+ "WS_ERR_TOO_MANY_BUFFERED_PARTS"
5423
+ );
5424
+ cb(error);
5425
+ return;
5426
+ }
5397
5427
  this._messageLength = this._totalPayloadLength;
5398
5428
  this._fragments.push(data);
5399
5429
  }
@@ -5407,7 +5437,7 @@ var require_receiver = __commonJS({
5407
5437
  * @private
5408
5438
  */
5409
5439
  decompress(data, cb) {
5410
- const perMessageDeflate = this._extensions[PerMessageDeflate.extensionName];
5440
+ const perMessageDeflate = this._extensions[PerMessageDeflate2.extensionName];
5411
5441
  perMessageDeflate.decompress(data, this._fin, (err, buf) => {
5412
5442
  if (err) return cb(err);
5413
5443
  if (buf.length) {
@@ -5423,6 +5453,17 @@ var require_receiver = __commonJS({
5423
5453
  cb(error);
5424
5454
  return;
5425
5455
  }
5456
+ if (this._maxFragments > 0 && this._fragments.length >= this._maxFragments) {
5457
+ const error = this.createError(
5458
+ RangeError,
5459
+ "Too many message fragments",
5460
+ false,
5461
+ 1008,
5462
+ "WS_ERR_TOO_MANY_BUFFERED_PARTS"
5463
+ );
5464
+ cb(error);
5465
+ return;
5466
+ }
5426
5467
  this._fragments.push(buf);
5427
5468
  }
5428
5469
  this.dataMessage(cb);
@@ -5583,13 +5624,16 @@ var require_receiver = __commonJS({
5583
5624
  }
5584
5625
  });
5585
5626
 
5586
- // ../../node_modules/engine.io-client/node_modules/ws/lib/sender.js
5627
+ // ../../node_modules/ws/lib/sender.js
5587
5628
  var require_sender = __commonJS({
5588
- "../../node_modules/engine.io-client/node_modules/ws/lib/sender.js"(exports2, module2) {
5629
+ "../../node_modules/ws/lib/sender.js"(exports2, module2) {
5589
5630
  "use strict";
5590
5631
  var { Duplex } = require("stream");
5591
5632
  var { randomFillSync } = require("crypto");
5592
- var PerMessageDeflate = require_permessage_deflate();
5633
+ var {
5634
+ types: { isUint8Array }
5635
+ } = require("util");
5636
+ var PerMessageDeflate2 = require_permessage_deflate();
5593
5637
  var { EMPTY_BUFFER, kWebSocket, NOOP } = require_constants();
5594
5638
  var { isBlob, isValidStatusCode } = require_validation();
5595
5639
  var { mask: applyMask, toBuffer: toBuffer2 } = require_buffer_util();
@@ -5742,8 +5786,10 @@ var require_sender = __commonJS({
5742
5786
  buf.writeUInt16BE(code, 0);
5743
5787
  if (typeof data === "string") {
5744
5788
  buf.write(data, 2);
5745
- } else {
5789
+ } else if (isUint8Array(data)) {
5746
5790
  buf.set(data, 2);
5791
+ } else {
5792
+ throw new TypeError("Second argument must be a string or a Uint8Array");
5747
5793
  }
5748
5794
  }
5749
5795
  const options = {
@@ -5873,7 +5919,7 @@ var require_sender = __commonJS({
5873
5919
  * @public
5874
5920
  */
5875
5921
  send(data, options, cb) {
5876
- const perMessageDeflate = this._extensions[PerMessageDeflate.extensionName];
5922
+ const perMessageDeflate = this._extensions[PerMessageDeflate2.extensionName];
5877
5923
  let opcode = options.binary ? 2 : 1;
5878
5924
  let rsv1 = options.compress;
5879
5925
  let byteLength2;
@@ -5997,7 +6043,7 @@ var require_sender = __commonJS({
5997
6043
  this.sendFrame(_Sender.frame(data, options), cb);
5998
6044
  return;
5999
6045
  }
6000
- const perMessageDeflate = this._extensions[PerMessageDeflate.extensionName];
6046
+ const perMessageDeflate = this._extensions[PerMessageDeflate2.extensionName];
6001
6047
  this._bufferedBytes += options[kByteLength];
6002
6048
  this._state = DEFLATING;
6003
6049
  perMessageDeflate.compress(data, options.fin, (_, buf) => {
@@ -6071,9 +6117,9 @@ var require_sender = __commonJS({
6071
6117
  }
6072
6118
  });
6073
6119
 
6074
- // ../../node_modules/engine.io-client/node_modules/ws/lib/event-target.js
6120
+ // ../../node_modules/ws/lib/event-target.js
6075
6121
  var require_event_target = __commonJS({
6076
- "../../node_modules/engine.io-client/node_modules/ws/lib/event-target.js"(exports2, module2) {
6122
+ "../../node_modules/ws/lib/event-target.js"(exports2, module2) {
6077
6123
  "use strict";
6078
6124
  var { kForOnEventAttribute, kListener } = require_constants();
6079
6125
  var kCode = Symbol("kCode");
@@ -6300,9 +6346,9 @@ var require_event_target = __commonJS({
6300
6346
  }
6301
6347
  });
6302
6348
 
6303
- // ../../node_modules/engine.io-client/node_modules/ws/lib/extension.js
6349
+ // ../../node_modules/ws/lib/extension.js
6304
6350
  var require_extension = __commonJS({
6305
- "../../node_modules/engine.io-client/node_modules/ws/lib/extension.js"(exports2, module2) {
6351
+ "../../node_modules/ws/lib/extension.js"(exports2, module2) {
6306
6352
  "use strict";
6307
6353
  var { tokenChars } = require_validation();
6308
6354
  function push(dest, name, elem) {
@@ -6435,11 +6481,11 @@ var require_extension = __commonJS({
6435
6481
  return offers;
6436
6482
  }
6437
6483
  function format(extensions) {
6438
- return Object.keys(extensions).map((extension) => {
6439
- let configurations = extensions[extension];
6484
+ return Object.keys(extensions).map((extension2) => {
6485
+ let configurations = extensions[extension2];
6440
6486
  if (!Array.isArray(configurations)) configurations = [configurations];
6441
6487
  return configurations.map((params) => {
6442
- return [extension].concat(
6488
+ return [extension2].concat(
6443
6489
  Object.keys(params).map((k) => {
6444
6490
  let values = params[k];
6445
6491
  if (!Array.isArray(values)) values = [values];
@@ -6453,9 +6499,9 @@ var require_extension = __commonJS({
6453
6499
  }
6454
6500
  });
6455
6501
 
6456
- // ../../node_modules/engine.io-client/node_modules/ws/lib/websocket.js
6502
+ // ../../node_modules/ws/lib/websocket.js
6457
6503
  var require_websocket = __commonJS({
6458
- "../../node_modules/engine.io-client/node_modules/ws/lib/websocket.js"(exports2, module2) {
6504
+ "../../node_modules/ws/lib/websocket.js"(exports2, module2) {
6459
6505
  "use strict";
6460
6506
  var EventEmitter = require("events");
6461
6507
  var https = require("https");
@@ -6465,12 +6511,13 @@ var require_websocket = __commonJS({
6465
6511
  var { randomBytes, createHash } = require("crypto");
6466
6512
  var { Duplex, Readable } = require("stream");
6467
6513
  var { URL: URL2 } = require("url");
6468
- var PerMessageDeflate = require_permessage_deflate();
6514
+ var PerMessageDeflate2 = require_permessage_deflate();
6469
6515
  var Receiver2 = require_receiver();
6470
6516
  var Sender2 = require_sender();
6471
6517
  var { isBlob } = require_validation();
6472
6518
  var {
6473
6519
  BINARY_TYPES,
6520
+ CLOSE_TIMEOUT,
6474
6521
  EMPTY_BUFFER,
6475
6522
  GUID,
6476
6523
  kForOnEventAttribute,
@@ -6484,7 +6531,6 @@ var require_websocket = __commonJS({
6484
6531
  } = require_event_target();
6485
6532
  var { format, parse: parse3 } = require_extension();
6486
6533
  var { toBuffer: toBuffer2 } = require_buffer_util();
6487
- var closeTimeout = 30 * 1e3;
6488
6534
  var kAborted = Symbol("kAborted");
6489
6535
  var protocolVersions = [8, 13];
6490
6536
  var readyStates = ["CONNECTING", "OPEN", "CLOSING", "CLOSED"];
@@ -6530,6 +6576,7 @@ var require_websocket = __commonJS({
6530
6576
  initAsClient(this, address, protocols, options);
6531
6577
  } else {
6532
6578
  this._autoPong = options.autoPong;
6579
+ this._closeTimeout = options.closeTimeout;
6533
6580
  this._isServer = true;
6534
6581
  }
6535
6582
  }
@@ -6623,6 +6670,10 @@ var require_websocket = __commonJS({
6623
6670
  * multiple times in the same tick
6624
6671
  * @param {Function} [options.generateMask] The function used to generate the
6625
6672
  * masking key
6673
+ * @param {Number} [options.maxBufferedChunks=0] The maximum number of
6674
+ * buffered data chunks
6675
+ * @param {Number} [options.maxFragments=0] The maximum number of message
6676
+ * fragments
6626
6677
  * @param {Number} [options.maxPayload=0] The maximum allowed message size
6627
6678
  * @param {Boolean} [options.skipUTF8Validation=false] Specifies whether or
6628
6679
  * not to skip UTF-8 validation for text and close messages
@@ -6634,6 +6685,8 @@ var require_websocket = __commonJS({
6634
6685
  binaryType: this.binaryType,
6635
6686
  extensions: this._extensions,
6636
6687
  isServer: this._isServer,
6688
+ maxBufferedChunks: options.maxBufferedChunks,
6689
+ maxFragments: options.maxFragments,
6637
6690
  maxPayload: options.maxPayload,
6638
6691
  skipUTF8Validation: options.skipUTF8Validation
6639
6692
  });
@@ -6672,8 +6725,8 @@ var require_websocket = __commonJS({
6672
6725
  this.emit("close", this._closeCode, this._closeMessage);
6673
6726
  return;
6674
6727
  }
6675
- if (this._extensions[PerMessageDeflate.extensionName]) {
6676
- this._extensions[PerMessageDeflate.extensionName].cleanup();
6728
+ if (this._extensions[PerMessageDeflate2.extensionName]) {
6729
+ this._extensions[PerMessageDeflate2.extensionName].cleanup();
6677
6730
  }
6678
6731
  this._receiver.removeAllListeners();
6679
6732
  this._readyState = _WebSocket.CLOSED;
@@ -6835,7 +6888,7 @@ var require_websocket = __commonJS({
6835
6888
  fin: true,
6836
6889
  ...options
6837
6890
  };
6838
- if (!this._extensions[PerMessageDeflate.extensionName]) {
6891
+ if (!this._extensions[PerMessageDeflate2.extensionName]) {
6839
6892
  opts.compress = false;
6840
6893
  }
6841
6894
  this._sender.send(data || EMPTY_BUFFER, opts, cb);
@@ -6931,7 +6984,10 @@ var require_websocket = __commonJS({
6931
6984
  const opts = {
6932
6985
  allowSynchronousEvents: true,
6933
6986
  autoPong: true,
6987
+ closeTimeout: CLOSE_TIMEOUT,
6934
6988
  protocolVersion: protocolVersions[1],
6989
+ maxBufferedChunks: 1024 * 1024,
6990
+ maxFragments: 128 * 1024,
6935
6991
  maxPayload: 100 * 1024 * 1024,
6936
6992
  skipUTF8Validation: false,
6937
6993
  perMessageDeflate: true,
@@ -6948,6 +7004,7 @@ var require_websocket = __commonJS({
6948
7004
  port: void 0
6949
7005
  };
6950
7006
  websocket._autoPong = opts.autoPong;
7007
+ websocket._closeTimeout = opts.closeTimeout;
6951
7008
  if (!protocolVersions.includes(opts.protocolVersion)) {
6952
7009
  throw new RangeError(
6953
7010
  `Unsupported protocol version: ${opts.protocolVersion} (supported versions: ${protocolVersions.join(", ")})`
@@ -6959,7 +7016,7 @@ var require_websocket = __commonJS({
6959
7016
  } else {
6960
7017
  try {
6961
7018
  parsedUrl = new URL2(address);
6962
- } catch (e) {
7019
+ } catch {
6963
7020
  throw new SyntaxError(`Invalid URL: ${address}`);
6964
7021
  }
6965
7022
  }
@@ -7007,13 +7064,13 @@ var require_websocket = __commonJS({
7007
7064
  opts.path = parsedUrl.pathname + parsedUrl.search;
7008
7065
  opts.timeout = opts.handshakeTimeout;
7009
7066
  if (opts.perMessageDeflate) {
7010
- perMessageDeflate = new PerMessageDeflate(
7011
- opts.perMessageDeflate !== true ? opts.perMessageDeflate : {},
7012
- false,
7013
- opts.maxPayload
7014
- );
7067
+ perMessageDeflate = new PerMessageDeflate2({
7068
+ ...opts.perMessageDeflate,
7069
+ isServer: false,
7070
+ maxPayload: opts.maxPayload
7071
+ });
7015
7072
  opts.headers["Sec-WebSocket-Extensions"] = format({
7016
- [PerMessageDeflate.extensionName]: perMessageDeflate.offer()
7073
+ [PerMessageDeflate2.extensionName]: perMessageDeflate.offer()
7017
7074
  });
7018
7075
  }
7019
7076
  if (protocols.length) {
@@ -7156,23 +7213,25 @@ var require_websocket = __commonJS({
7156
7213
  return;
7157
7214
  }
7158
7215
  const extensionNames = Object.keys(extensions);
7159
- if (extensionNames.length !== 1 || extensionNames[0] !== PerMessageDeflate.extensionName) {
7216
+ if (extensionNames.length !== 1 || extensionNames[0] !== PerMessageDeflate2.extensionName) {
7160
7217
  const message = "Server indicated an extension that was not requested";
7161
7218
  abortHandshake(websocket, socket, message);
7162
7219
  return;
7163
7220
  }
7164
7221
  try {
7165
- perMessageDeflate.accept(extensions[PerMessageDeflate.extensionName]);
7222
+ perMessageDeflate.accept(extensions[PerMessageDeflate2.extensionName]);
7166
7223
  } catch (err) {
7167
7224
  const message = "Invalid Sec-WebSocket-Extensions header";
7168
7225
  abortHandshake(websocket, socket, message);
7169
7226
  return;
7170
7227
  }
7171
- websocket._extensions[PerMessageDeflate.extensionName] = perMessageDeflate;
7228
+ websocket._extensions[PerMessageDeflate2.extensionName] = perMessageDeflate;
7172
7229
  }
7173
7230
  websocket.setSocket(socket, head, {
7174
7231
  allowSynchronousEvents: opts.allowSynchronousEvents,
7175
7232
  generateMask: opts.generateMask,
7233
+ maxBufferedChunks: opts.maxBufferedChunks,
7234
+ maxFragments: opts.maxFragments,
7176
7235
  maxPayload: opts.maxPayload,
7177
7236
  skipUTF8Validation: opts.skipUTF8Validation
7178
7237
  });
@@ -7290,7 +7349,7 @@ var require_websocket = __commonJS({
7290
7349
  function setCloseTimer(websocket) {
7291
7350
  websocket._closeTimer = setTimeout(
7292
7351
  websocket._socket.destroy.bind(websocket._socket),
7293
- closeTimeout
7352
+ websocket._closeTimeout
7294
7353
  );
7295
7354
  }
7296
7355
  function socketOnClose() {
@@ -7299,8 +7358,8 @@ var require_websocket = __commonJS({
7299
7358
  this.removeListener("data", socketOnData);
7300
7359
  this.removeListener("end", socketOnEnd);
7301
7360
  websocket._readyState = WebSocket2.CLOSING;
7302
- let chunk;
7303
- if (!this._readableState.endEmitted && !websocket._closeFrameReceived && !websocket._receiver._writableState.errorEmitted && (chunk = websocket._socket.read()) !== null) {
7361
+ if (!this._readableState.endEmitted && !websocket._closeFrameReceived && !websocket._receiver._writableState.errorEmitted && this._readableState.length !== 0) {
7362
+ const chunk = this.read(this._readableState.length);
7304
7363
  websocket._receiver.write(chunk);
7305
7364
  }
7306
7365
  websocket._receiver.end();
@@ -7336,9 +7395,9 @@ var require_websocket = __commonJS({
7336
7395
  }
7337
7396
  });
7338
7397
 
7339
- // ../../node_modules/engine.io-client/node_modules/ws/lib/stream.js
7398
+ // ../../node_modules/ws/lib/stream.js
7340
7399
  var require_stream = __commonJS({
7341
- "../../node_modules/engine.io-client/node_modules/ws/lib/stream.js"(exports2, module2) {
7400
+ "../../node_modules/ws/lib/stream.js"(exports2, module2) {
7342
7401
  "use strict";
7343
7402
  var WebSocket2 = require_websocket();
7344
7403
  var { Duplex } = require("stream");
@@ -7434,9 +7493,9 @@ var require_stream = __commonJS({
7434
7493
  }
7435
7494
  });
7436
7495
 
7437
- // ../../node_modules/engine.io-client/node_modules/ws/lib/subprotocol.js
7496
+ // ../../node_modules/ws/lib/subprotocol.js
7438
7497
  var require_subprotocol = __commonJS({
7439
- "../../node_modules/engine.io-client/node_modules/ws/lib/subprotocol.js"(exports2, module2) {
7498
+ "../../node_modules/ws/lib/subprotocol.js"(exports2, module2) {
7440
7499
  "use strict";
7441
7500
  var { tokenChars } = require_validation();
7442
7501
  function parse3(header) {
@@ -7479,19 +7538,19 @@ var require_subprotocol = __commonJS({
7479
7538
  }
7480
7539
  });
7481
7540
 
7482
- // ../../node_modules/engine.io-client/node_modules/ws/lib/websocket-server.js
7541
+ // ../../node_modules/ws/lib/websocket-server.js
7483
7542
  var require_websocket_server = __commonJS({
7484
- "../../node_modules/engine.io-client/node_modules/ws/lib/websocket-server.js"(exports2, module2) {
7543
+ "../../node_modules/ws/lib/websocket-server.js"(exports2, module2) {
7485
7544
  "use strict";
7486
7545
  var EventEmitter = require("events");
7487
7546
  var http = require("http");
7488
7547
  var { Duplex } = require("stream");
7489
7548
  var { createHash } = require("crypto");
7490
- var extension = require_extension();
7491
- var PerMessageDeflate = require_permessage_deflate();
7492
- var subprotocol = require_subprotocol();
7549
+ var extension2 = require_extension();
7550
+ var PerMessageDeflate2 = require_permessage_deflate();
7551
+ var subprotocol2 = require_subprotocol();
7493
7552
  var WebSocket2 = require_websocket();
7494
- var { GUID, kWebSocket } = require_constants();
7553
+ var { CLOSE_TIMEOUT, GUID, kWebSocket } = require_constants();
7495
7554
  var keyRegex = /^[+/0-9A-Za-z]{22}==$/;
7496
7555
  var RUNNING = 0;
7497
7556
  var CLOSING = 1;
@@ -7510,8 +7569,15 @@ var require_websocket_server = __commonJS({
7510
7569
  * pending connections
7511
7570
  * @param {Boolean} [options.clientTracking=true] Specifies whether or not to
7512
7571
  * track clients
7572
+ * @param {Number} [options.closeTimeout=30000] Duration in milliseconds to
7573
+ * wait for the closing handshake to finish after `websocket.close()` is
7574
+ * called
7513
7575
  * @param {Function} [options.handleProtocols] A hook to handle protocols
7514
7576
  * @param {String} [options.host] The hostname where to bind the server
7577
+ * @param {Number} [options.maxBufferedChunks=1048576] The maximum number of
7578
+ * buffered data chunks
7579
+ * @param {Number} [options.maxFragments=131072] The maximum number of message
7580
+ * fragments
7515
7581
  * @param {Number} [options.maxPayload=104857600] The maximum allowed message
7516
7582
  * size
7517
7583
  * @param {Boolean} [options.noServer=false] Enable no server mode
@@ -7533,11 +7599,14 @@ var require_websocket_server = __commonJS({
7533
7599
  options = {
7534
7600
  allowSynchronousEvents: true,
7535
7601
  autoPong: true,
7602
+ maxBufferedChunks: 1024 * 1024,
7603
+ maxFragments: 128 * 1024,
7536
7604
  maxPayload: 100 * 1024 * 1024,
7537
7605
  skipUTF8Validation: false,
7538
7606
  perMessageDeflate: false,
7539
7607
  handleProtocols: null,
7540
7608
  clientTracking: true,
7609
+ closeTimeout: CLOSE_TIMEOUT,
7541
7610
  verifyClient: null,
7542
7611
  noServer: false,
7543
7612
  backlog: null,
@@ -7708,7 +7777,7 @@ var require_websocket_server = __commonJS({
7708
7777
  let protocols = /* @__PURE__ */ new Set();
7709
7778
  if (secWebSocketProtocol !== void 0) {
7710
7779
  try {
7711
- protocols = subprotocol.parse(secWebSocketProtocol);
7780
+ protocols = subprotocol2.parse(secWebSocketProtocol);
7712
7781
  } catch (err) {
7713
7782
  const message = "Invalid Sec-WebSocket-Protocol header";
7714
7783
  abortHandshakeOrEmitwsClientError(this, req, socket, 400, message);
@@ -7718,16 +7787,16 @@ var require_websocket_server = __commonJS({
7718
7787
  const secWebSocketExtensions = req.headers["sec-websocket-extensions"];
7719
7788
  const extensions = {};
7720
7789
  if (this.options.perMessageDeflate && secWebSocketExtensions !== void 0) {
7721
- const perMessageDeflate = new PerMessageDeflate(
7722
- this.options.perMessageDeflate,
7723
- true,
7724
- this.options.maxPayload
7725
- );
7790
+ const perMessageDeflate = new PerMessageDeflate2({
7791
+ ...this.options.perMessageDeflate,
7792
+ isServer: true,
7793
+ maxPayload: this.options.maxPayload
7794
+ });
7726
7795
  try {
7727
- const offers = extension.parse(secWebSocketExtensions);
7728
- if (offers[PerMessageDeflate.extensionName]) {
7729
- perMessageDeflate.accept(offers[PerMessageDeflate.extensionName]);
7730
- extensions[PerMessageDeflate.extensionName] = perMessageDeflate;
7796
+ const offers = extension2.parse(secWebSocketExtensions);
7797
+ if (offers[PerMessageDeflate2.extensionName]) {
7798
+ perMessageDeflate.accept(offers[PerMessageDeflate2.extensionName]);
7799
+ extensions[PerMessageDeflate2.extensionName] = perMessageDeflate;
7731
7800
  }
7732
7801
  } catch (err) {
7733
7802
  const message = "Invalid or unacceptable Sec-WebSocket-Extensions header";
@@ -7798,10 +7867,10 @@ var require_websocket_server = __commonJS({
7798
7867
  ws._protocol = protocol4;
7799
7868
  }
7800
7869
  }
7801
- if (extensions[PerMessageDeflate.extensionName]) {
7802
- const params = extensions[PerMessageDeflate.extensionName].params;
7803
- const value2 = extension.format({
7804
- [PerMessageDeflate.extensionName]: [params]
7870
+ if (extensions[PerMessageDeflate2.extensionName]) {
7871
+ const params = extensions[PerMessageDeflate2.extensionName].params;
7872
+ const value2 = extension2.format({
7873
+ [PerMessageDeflate2.extensionName]: [params]
7805
7874
  });
7806
7875
  headers.push(`Sec-WebSocket-Extensions: ${value2}`);
7807
7876
  ws._extensions = extensions;
@@ -7811,6 +7880,8 @@ var require_websocket_server = __commonJS({
7811
7880
  socket.removeListener("error", socketOnError);
7812
7881
  ws.setSocket(socket, head, {
7813
7882
  allowSynchronousEvents: this.options.allowSynchronousEvents,
7883
+ maxBufferedChunks: this.options.maxBufferedChunks,
7884
+ maxFragments: this.options.maxFragments,
7814
7885
  maxPayload: this.options.maxPayload,
7815
7886
  skipUTF8Validation: this.options.skipUTF8Validation
7816
7887
  });
@@ -7868,6 +7939,9 @@ var require_websocket_server = __commonJS({
7868
7939
  }
7869
7940
  });
7870
7941
 
7942
+ // src/cli.ts
7943
+ var import_node_dns = __toESM(require("node:dns"));
7944
+
7871
7945
  // node_modules/commander/esm.mjs
7872
7946
  var import_index = __toESM(require_commander(), 1);
7873
7947
  var {
@@ -7963,10 +8037,10 @@ function configExists() {
7963
8037
  return import_fs.default.existsSync(CONFIG_FILE);
7964
8038
  }
7965
8039
 
7966
- // ../../shared/all/helpers/card-ai/claude.ts
8040
+ // ../../shared/all/helpers/board-agent-core/claude.ts
7967
8041
  var import_child_process2 = require("child_process");
7968
8042
 
7969
- // ../../shared/all/helpers/card-ai/claude-stream.ts
8043
+ // ../../shared/all/helpers/board-agent-core/claude-stream.ts
7970
8044
  function truncate(s, max) {
7971
8045
  return s.length > max ? s.slice(0, max) + "..." : s;
7972
8046
  }
@@ -8194,6 +8268,12 @@ function pipeClaudeStream(proc, callbacks) {
8194
8268
  for (const line of lines) {
8195
8269
  const trimmed = line.trim();
8196
8270
  if (!trimmed) continue;
8271
+ if (trimmed[0] !== "{" && trimmed[0] !== "[") {
8272
+ state.isError = true;
8273
+ state.fullText = state.fullText ? `${state.fullText}
8274
+ ${trimmed}` : trimmed;
8275
+ continue;
8276
+ }
8197
8277
  try {
8198
8278
  const raw = JSON.parse(trimmed);
8199
8279
  if (processStreamEvent(raw, state, callbacks)) continue;
@@ -8221,7 +8301,7 @@ function pipeClaudeStream(proc, callbacks) {
8221
8301
  };
8222
8302
  }
8223
8303
 
8224
- // ../../shared/all/helpers/card-ai/prompts.json
8304
+ // ../../shared/all/helpers/board-agent-core/prompts.json
8225
8305
  var prompts_default = {
8226
8306
  system: [
8227
8307
  "You are a coding agent working inside a Docker container on a task from a project management board.",
@@ -8231,7 +8311,7 @@ var prompts_default = {
8231
8311
  "2. **Implement** \u2014 Execute the plan. You gain Edit, Write, and Bash tools. Make the code changes.",
8232
8312
  "3. **Review** \u2014 Validate your changes, fix issues, generate reproduction steps for the developer.",
8233
8313
  "",
8234
- "An optional **Preview** phase may occur between Implement and Review where you set up a live dev environment.",
8314
+ "After Review, the developer may request an optional **Preview** where you set up a live dev environment on demand.",
8235
8315
  "",
8236
8316
  "## Task Tracking",
8237
8317
  "Use the TodoWrite tool to track your progress. Create todos at the start of each phase with your planned steps,",
@@ -8394,7 +8474,7 @@ var prompts_default = {
8394
8474
  commitMessage: 'Generate a single-line conventional commit message (e.g. "feat: ...", "fix: ...", "refactor: ...") for these changes. Output ONLY the commit message, nothing else.'
8395
8475
  };
8396
8476
 
8397
- // ../../shared/all/helpers/card-ai/claude.ts
8477
+ // ../../shared/all/helpers/board-agent-core/claude.ts
8398
8478
  function spawnClaude(opts) {
8399
8479
  const args = [
8400
8480
  "exec",
@@ -8494,11 +8574,18 @@ ${diff.slice(0, 8e3)}`);
8494
8574
  });
8495
8575
  }
8496
8576
 
8497
- // ../../shared/all/helpers/card-ai/docker.ts
8577
+ // ../../shared/all/helpers/board-agent-core/docker.ts
8498
8578
  var import_child_process3 = require("child_process");
8579
+ var import_crypto = __toESM(require("crypto"));
8580
+ var import_promises = __toESM(require("fs/promises"));
8499
8581
  var import_net = __toESM(require("net"));
8500
8582
  var import_util = require("util");
8501
8583
  var execFileAsync = (0, import_util.promisify)(import_child_process3.execFile);
8584
+ var DOCKERFILE_LABEL = "factiii.dockerfile-sha";
8585
+ async function hashDockerfile(dockerfilePath) {
8586
+ const buf = await import_promises.default.readFile(dockerfilePath);
8587
+ return import_crypto.default.createHash("sha256").update(buf).digest("hex");
8588
+ }
8502
8589
  async function getFreePort() {
8503
8590
  return new Promise((resolve, reject) => {
8504
8591
  const srv = import_net.default.createServer();
@@ -8524,28 +8611,105 @@ async function assertDockerRunning() {
8524
8611
  );
8525
8612
  }
8526
8613
  }
8527
- async function ensureDockerImage(dockerfilePath) {
8614
+ var BUILD_TIMEOUT_MS = 9e5;
8615
+ async function ensureDockerImage(dockerfilePath, onLog) {
8528
8616
  await assertDockerRunning();
8617
+ const wantedHash = await hashDockerfile(dockerfilePath);
8529
8618
  try {
8530
- await execFileAsync("docker", ["image", "inspect", "factiii-claude"], {});
8531
- return;
8619
+ const { stdout } = await execFileAsync(
8620
+ "docker",
8621
+ [
8622
+ "image",
8623
+ "inspect",
8624
+ "factiii-claude",
8625
+ "--format",
8626
+ `{{ index .Config.Labels "${DOCKERFILE_LABEL}" }}`
8627
+ ],
8628
+ {}
8629
+ );
8630
+ if (stdout.trim() === wantedHash) return;
8532
8631
  } catch {
8533
8632
  }
8534
- await execFileAsync(
8535
- "docker",
8536
- ["build", "-t", "factiii-claude", "-f", dockerfilePath, "."],
8537
- { timeout: 3e5 }
8538
- );
8633
+ await buildImageStreaming(dockerfilePath, wantedHash, onLog);
8634
+ await resetNixVolume();
8539
8635
  }
8540
- async function ensureCacheVolume() {
8636
+ async function resetNixVolume() {
8541
8637
  try {
8542
- await execFileAsync(
8638
+ const { stdout } = await execFileAsync("docker", [
8639
+ "ps",
8640
+ "-aq",
8641
+ "--filter",
8642
+ "volume=factiii-nix"
8643
+ ]);
8644
+ const ids = stdout.split("\n").map((s) => s.trim()).filter(Boolean);
8645
+ if (ids.length) await execFileAsync("docker", ["rm", "-f", ...ids]);
8646
+ await execFileAsync("docker", ["volume", "rm", "factiii-nix"]);
8647
+ } catch {
8648
+ }
8649
+ }
8650
+ function buildImageStreaming(dockerfilePath, wantedHash, onLog) {
8651
+ return new Promise((resolve, reject) => {
8652
+ const child = (0, import_child_process3.spawn)(
8543
8653
  "docker",
8544
- ["volume", "inspect", "factiii-card-cache"],
8545
- {}
8654
+ [
8655
+ "build",
8656
+ "--progress=plain",
8657
+ "-t",
8658
+ "factiii-claude",
8659
+ "-f",
8660
+ dockerfilePath,
8661
+ "--label",
8662
+ `${DOCKERFILE_LABEL}=${wantedHash}`,
8663
+ "."
8664
+ ],
8665
+ { env: { ...process.env, DOCKER_BUILDKIT: "1" } }
8546
8666
  );
8547
- } catch {
8548
- await execFileAsync("docker", ["volume", "create", "factiii-card-cache"]);
8667
+ let timer;
8668
+ const arm = () => {
8669
+ clearTimeout(timer);
8670
+ timer = setTimeout(() => {
8671
+ child.kill("SIGKILL");
8672
+ reject(
8673
+ new Error(
8674
+ "Building container image timed out. Check Docker and your connection, then Try Again."
8675
+ )
8676
+ );
8677
+ }, BUILD_TIMEOUT_MS);
8678
+ };
8679
+ arm();
8680
+ const makeLineEmitter = () => {
8681
+ let buffer = "";
8682
+ return (buf) => {
8683
+ arm();
8684
+ buffer += buf.toString();
8685
+ const parts2 = buffer.split("\n");
8686
+ buffer = parts2.pop() ?? "";
8687
+ for (const line of parts2) {
8688
+ const trimmed = line.trimEnd();
8689
+ if (trimmed) onLog?.(trimmed);
8690
+ }
8691
+ };
8692
+ };
8693
+ child.stdout.on("data", makeLineEmitter());
8694
+ child.stderr.on("data", makeLineEmitter());
8695
+ child.on("error", (err) => {
8696
+ clearTimeout(timer);
8697
+ reject(err);
8698
+ });
8699
+ child.on("close", (code) => {
8700
+ clearTimeout(timer);
8701
+ if (code === 0) resolve();
8702
+ else reject(new Error(`docker build failed (exit code ${code}).`));
8703
+ });
8704
+ });
8705
+ }
8706
+ async function ensureCacheVolume() {
8707
+ for (const name of ["factiii-card-cache", "factiii-nix"]) {
8708
+ try {
8709
+ await execFileAsync("docker", ["volume", "inspect", name], {});
8710
+ } catch {
8711
+ await execFileAsync("docker", ["volume", "create", name]);
8712
+ }
8549
8713
  }
8550
8714
  }
8551
8715
  async function killContainer(containerName) {
@@ -8554,7 +8718,7 @@ async function killContainer(containerName) {
8554
8718
  } catch {
8555
8719
  }
8556
8720
  }
8557
- async function spawnContainer(containerName, claudeToken) {
8721
+ async function spawnContainer(containerName, claudeToken, opts) {
8558
8722
  const NUM_PORTS = 6;
8559
8723
  const ports = [];
8560
8724
  const seen = /* @__PURE__ */ new Set();
@@ -8575,10 +8739,22 @@ async function spawnContainer(containerName, claudeToken) {
8575
8739
  containerName,
8576
8740
  "--user",
8577
8741
  "claude",
8742
+ // SYS_PTRACE: gdb/strace/valgrind (Docker seccomp gates ptrace on it).
8743
+ // NET_RAW: raw-socket tools (tcpdump/nmap/ping). Scoped to the container.
8744
+ "--cap-add",
8745
+ "SYS_PTRACE",
8746
+ "--cap-add",
8747
+ "NET_RAW",
8748
+ // FUSE for rclone mount on OneDrive spaces (gated - GitHub stays unprivileged).
8749
+ ...opts?.fuse ? ["--cap-add", "SYS_ADMIN", "--device", "/dev/fuse"] : [],
8578
8750
  "-e",
8579
8751
  "HOME=/home/claude",
8752
+ ...claudeToken ? ["-e", `CLAUDE_CODE_OAUTH_TOKEN=${claudeToken}`] : [],
8753
+ // Redirect the otherwise-`~/.claude.json` config file into the .claude
8754
+ // dir, which is symlinked to /cache/claude. Keeps all claude state
8755
+ // inside one path the CLI already understands.
8580
8756
  "-e",
8581
- `CLAUDE_CODE_OAUTH_TOKEN=${claudeToken}`,
8757
+ "CLAUDE_CONFIG_DIR=/home/claude/.claude",
8582
8758
  "-e",
8583
8759
  "NODE_OPTIONS=--max-old-space-size=4096",
8584
8760
  "-e",
@@ -8597,6 +8773,8 @@ async function spawnContainer(containerName, claudeToken) {
8597
8773
  `FACTIII_PORTS=${ports.join(",")}`,
8598
8774
  "-v",
8599
8775
  "factiii-card-cache:/cache",
8776
+ "-v",
8777
+ "factiii-nix:/nix",
8600
8778
  ...portArgs,
8601
8779
  "factiii-claude",
8602
8780
  "tail",
@@ -8613,6 +8791,37 @@ async function execInContainer(containerName, cmd) {
8613
8791
  );
8614
8792
  return stdout;
8615
8793
  }
8794
+ function streamExecInContainer(containerName, cmd, onLine) {
8795
+ const child = (0, import_child_process3.spawn)("docker", [
8796
+ "exec",
8797
+ "--user",
8798
+ "claude",
8799
+ containerName,
8800
+ "sh",
8801
+ "-c",
8802
+ cmd
8803
+ ]);
8804
+ let buffer = "";
8805
+ child.stdout.on("data", (buf) => {
8806
+ buffer += buf.toString();
8807
+ const parts2 = buffer.split("\n");
8808
+ buffer = parts2.pop() ?? "";
8809
+ for (const line of parts2) {
8810
+ const trimmed = line.trimEnd();
8811
+ if (trimmed) onLine(trimmed);
8812
+ }
8813
+ });
8814
+ child.on("error", () => {
8815
+ });
8816
+ let stopped = false;
8817
+ return {
8818
+ stop: () => {
8819
+ if (stopped) return;
8820
+ stopped = true;
8821
+ child.kill("SIGKILL");
8822
+ }
8823
+ };
8824
+ }
8616
8825
  async function execInContainerAsRoot(containerName, cmd) {
8617
8826
  const { stdout } = await execFileAsync(
8618
8827
  "docker",
@@ -8624,15 +8833,19 @@ async function execInContainerAsRoot(containerName, cmd) {
8624
8833
  async function setupContainerCache(containerName) {
8625
8834
  await execInContainer(
8626
8835
  containerName,
8627
- "mkdir -p /cache/repos /cache/claude /cache/pnpm-store /cache/pnpm-vstore /cache/pip-cache /cache/npm-cache /cache/apk /cache/xdg-cache"
8836
+ "mkdir -p /cache/repos /cache/claude /cache/pnpm-store /cache/pnpm-vstore /cache/pip-cache /cache/npm-cache /cache/apt /cache/xdg-cache"
8628
8837
  );
8629
8838
  await execInContainer(
8630
8839
  containerName,
8631
8840
  "ln -sfn /cache/claude /home/claude/.claude"
8632
8841
  );
8842
+ await execInContainer(
8843
+ containerName,
8844
+ "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"
8845
+ );
8633
8846
  await execInContainerAsRoot(
8634
8847
  containerName,
8635
- "ln -sfn /cache/apk /etc/apk/cache"
8848
+ "rm -rf /var/cache/apt/archives && ln -sfn /cache/apt /var/cache/apt/archives && mkdir -p /cache/apt/partial"
8636
8849
  );
8637
8850
  }
8638
8851
  async function isContainerRunning(containerName) {
@@ -8661,136 +8874,10 @@ async function getContainerPort(containerName, containerPort) {
8661
8874
  }
8662
8875
  }
8663
8876
 
8664
- // ../../shared/all/helpers/card-ai/frpc.ts
8877
+ // ../../shared/all/helpers/board-agent-core/git.ts
8665
8878
  var import_child_process4 = require("child_process");
8666
- var import_fs2 = require("fs");
8667
- var import_path2 = __toESM(require("path"));
8668
8879
  var import_util2 = require("util");
8669
-
8670
- // ../../shared/all/helpers/card-ai/scripts.ts
8671
- var FRPC_EXPOSE_SCRIPT = `#!/bin/sh
8672
- set -e
8673
- set -a; . /etc/environment.factiii 2>/dev/null || true; set +a
8674
- PORT="$1"
8675
- NAME="$2"
8676
- if [ -z "$PORT" ] || [ -z "$NAME" ]; then
8677
- echo "usage: factiii-expose <port> <name>" >&2
8678
- echo " e.g. factiii-expose 3000 app" >&2
8679
- exit 1
8680
- fi
8681
- case "$NAME" in *[!a-zA-Z0-9-]*|"") echo "name must be [a-zA-Z0-9-]" >&2; exit 1 ;; esac
8682
- PROXY="\${FACTIII_TASK_ID}-\${NAME}"
8683
- DOMAIN="\${PROXY}.\${FACTIII_FRP_DOMAIN}"
8684
- TOML="/tmp/frpc-\${NAME}.toml"
8685
- cat > "$TOML" <<EOF
8686
- serverAddr = "\${FACTIII_FRP_SERVER}"
8687
- serverPort = \${FACTIII_FRP_PORT}
8688
- transport.protocol = "kcp"
8689
-
8690
- [metadatas]
8691
- factiii_token = "\${FACTIII_FRP_TOKEN}"
8692
-
8693
- [[proxies]]
8694
- name = "\${PROXY}"
8695
- type = "http"
8696
- localPort = \${PORT}
8697
- subdomain = "\${PROXY}"
8698
- EOF
8699
- setsid frpc -c "$TOML" >/tmp/frpc-\${NAME}.log 2>&1 < /dev/null &
8700
- echo $! > /tmp/frpc-\${NAME}.pid
8701
- echo "http://\${DOMAIN}"
8702
- `;
8703
- var FRPC_UNEXPOSE_SCRIPT = `#!/bin/sh
8704
- NAME="$1"
8705
- if [ -z "$NAME" ]; then
8706
- echo "usage: factiii-unexpose <name>" >&2
8707
- exit 1
8708
- fi
8709
- PID_FILE="/tmp/frpc-\${NAME}.pid"
8710
- if [ -f "$PID_FILE" ]; then
8711
- kill "$(cat "$PID_FILE")" 2>/dev/null || true
8712
- rm -f "$PID_FILE" "/tmp/frpc-\${NAME}.toml" "/tmp/frpc-\${NAME}.log"
8713
- echo "stopped \${NAME}"
8714
- else
8715
- echo "no tunnel named \${NAME}" >&2
8716
- exit 1
8717
- fi
8718
- `;
8719
-
8720
- // ../../shared/all/helpers/card-ai/frpc.ts
8721
8880
  var execFileAsync2 = (0, import_util2.promisify)(import_child_process4.execFile);
8722
- var FRP_SERVER_ADDR = "dev.factiii.com";
8723
- var FRP_SERVER_PORT = 7443;
8724
- var FRP_DOMAIN_SUFFIX = "dev.factiii.com";
8725
- async function writeFileAsRoot(containerName, path6, contents) {
8726
- const b64 = Buffer.from(contents, "utf-8").toString("base64");
8727
- await execInContainerAsRoot(
8728
- containerName,
8729
- `echo '${b64}' | base64 -d > '${path6}'`
8730
- );
8731
- }
8732
- async function installFrpcBinary(containerName) {
8733
- const arch = (await execInContainer(containerName, "uname -m")).trim();
8734
- const map = {
8735
- x86_64: "frpc-linux-amd64",
8736
- aarch64: "frpc-linux-arm64"
8737
- };
8738
- const binaryName = map[arch];
8739
- if (!binaryName) {
8740
- throw new Error(`Unsupported container arch for frpc: ${arch}`);
8741
- }
8742
- const src = import_path2.default.join(__dirname, "..", "vendor", binaryName);
8743
- if (!(0, import_fs2.existsSync)(src)) {
8744
- throw new Error(
8745
- `Vendored frpc missing (${src}). Run apps/runner/scripts/fetch-frpc.sh.`
8746
- );
8747
- }
8748
- await execFileAsync2("docker", [
8749
- "cp",
8750
- src,
8751
- `${containerName}:/usr/local/bin/frpc`
8752
- ]);
8753
- await execInContainerAsRoot(containerName, "chmod +x /usr/local/bin/frpc");
8754
- }
8755
- async function installFrpcHarness(params) {
8756
- const { containerName, taskId, runnerToken } = params;
8757
- await installFrpcBinary(containerName);
8758
- const envLines = [
8759
- `FACTIII_FRP_SERVER=${FRP_SERVER_ADDR}`,
8760
- `FACTIII_FRP_PORT=${FRP_SERVER_PORT}`,
8761
- `FACTIII_FRP_TOKEN=${runnerToken}`,
8762
- `FACTIII_FRP_DOMAIN=${FRP_DOMAIN_SUFFIX}`,
8763
- `FACTIII_TASK_ID=${taskId}`
8764
- ];
8765
- await writeFileAsRoot(
8766
- containerName,
8767
- "/usr/local/bin/factiii-expose",
8768
- FRPC_EXPOSE_SCRIPT
8769
- );
8770
- await writeFileAsRoot(
8771
- containerName,
8772
- "/usr/local/bin/factiii-unexpose",
8773
- FRPC_UNEXPOSE_SCRIPT
8774
- );
8775
- await execInContainerAsRoot(
8776
- containerName,
8777
- "chmod +x /usr/local/bin/factiii-expose /usr/local/bin/factiii-unexpose"
8778
- );
8779
- await writeFileAsRoot(
8780
- containerName,
8781
- "/etc/environment.factiii",
8782
- envLines.join("\n") + "\n"
8783
- );
8784
- await execInContainerAsRoot(
8785
- containerName,
8786
- `grep -q 'environment.factiii' /home/claude/.profile 2>/dev/null || echo 'set -a; . /etc/environment.factiii; set +a' >> /home/claude/.profile && chown claude:claude /home/claude/.profile`
8787
- );
8788
- }
8789
-
8790
- // ../../shared/all/helpers/card-ai/git.ts
8791
- var import_child_process5 = require("child_process");
8792
- var import_util3 = require("util");
8793
- var execFileAsync3 = (0, import_util3.promisify)(import_child_process5.execFile);
8794
8881
  function buildCloneUrl(repoUrl, githubToken) {
8795
8882
  const url2 = new URL(repoUrl);
8796
8883
  url2.username = githubToken;
@@ -8804,7 +8891,7 @@ function repoCacheKey(repoUrl) {
8804
8891
  return repoUrl.replace(/^https?:\/\//, "").replace(/\/\/.*@/, "").replace(/\.git$/, "").replace(/[^a-zA-Z0-9._-]/g, "_");
8805
8892
  }
8806
8893
  async function gitInContainer(containerName, ...args) {
8807
- const { stdout } = await execFileAsync3(
8894
+ const { stdout } = await execFileAsync2(
8808
8895
  "docker",
8809
8896
  [
8810
8897
  "exec",
@@ -8851,7 +8938,7 @@ async function cloneRepo(containerName, cloneUrl, branch, cacheKey) {
8851
8938
  await gitInContainer(containerName, "clean", "-fd");
8852
8939
  return { fromCache: true };
8853
8940
  }
8854
- await execFileAsync3(
8941
+ await execFileAsync2(
8855
8942
  "docker",
8856
8943
  [
8857
8944
  "exec",
@@ -8894,6 +8981,7 @@ async function setGitIdentity(containerName, name, email) {
8894
8981
  );
8895
8982
  }
8896
8983
  async function extractDiff(containerName) {
8984
+ await gitInContainer(containerName, "add", "--intent-to-add", ".");
8897
8985
  const diff = await gitInContainer(containerName, "diff", "HEAD");
8898
8986
  const changedFilesRaw = (await gitInContainer(containerName, "diff", "HEAD", "--name-only")).trim();
8899
8987
  return {
@@ -8915,6 +9003,13 @@ async function listRemoteBranches(containerName) {
8915
9003
  return [];
8916
9004
  }
8917
9005
  }
9006
+ var NoChangesError = class extends Error {
9007
+ constructor(message = "No changes to commit.") {
9008
+ super(message);
9009
+ this.code = "NO_CHANGES";
9010
+ this.name = "NoChangesError";
9011
+ }
9012
+ };
8918
9013
  async function commitAndPush(containerName, branchName, commitMessage, gitConfig) {
8919
9014
  if (gitConfig?.gitName)
8920
9015
  await gitInContainer(
@@ -8930,8 +9025,8 @@ async function commitAndPush(containerName, branchName, commitMessage, gitConfig
8930
9025
  "user.email",
8931
9026
  gitConfig.gitEmail
8932
9027
  );
8933
- const diff = (await gitInContainer(containerName, "diff", "HEAD")).trim();
8934
- if (!diff) return;
9028
+ const status = (await gitInContainer(containerName, "status", "--porcelain")).trim();
9029
+ if (!status) throw new NoChangesError();
8935
9030
  let branchExists = false;
8936
9031
  try {
8937
9032
  await gitInContainer(
@@ -8946,7 +9041,7 @@ async function commitAndPush(containerName, branchName, commitMessage, gitConfig
8946
9041
  } catch {
8947
9042
  }
8948
9043
  if (branchExists) {
8949
- await gitInContainer(containerName, "stash");
9044
+ await gitInContainer(containerName, "stash", "--include-untracked");
8950
9045
  await gitInContainer(containerName, "fetch", "origin", branchName);
8951
9046
  await gitInContainer(
8952
9047
  containerName,
@@ -8962,128 +9057,273 @@ async function commitAndPush(containerName, branchName, commitMessage, gitConfig
8962
9057
  await gitInContainer(containerName, "add", "-A");
8963
9058
  await gitInContainer(containerName, "commit", "-m", commitMessage);
8964
9059
  await gitInContainer(containerName, "push", "origin", branchName);
9060
+ await gitInContainer(containerName, "fetch", "origin", branchName);
9061
+ const remoteSha = (await gitInContainer(containerName, "rev-parse", `origin/${branchName}`)).trim();
9062
+ const localSha = (await gitInContainer(containerName, "rev-parse", "HEAD")).trim();
9063
+ if (remoteSha !== localSha) {
9064
+ throw new Error(
9065
+ `Push verification failed: local HEAD ${localSha.slice(0, 8)} does not match origin/${branchName} ${remoteSha.slice(0, 8)}`
9066
+ );
9067
+ }
9068
+ return { pushedSha: localSha, remoteSha, branch: branchName };
8965
9069
  }
8966
9070
 
8967
- // ../../shared/all/helpers/card-ai/session.ts
8968
- function toSummary(session) {
9071
+ // ../../shared/all/helpers/board-agent-core/onedrive-auth.ts
9072
+ var DEVICECODE_URL = "https://login.microsoftonline.com/consumers/oauth2/v2.0/devicecode";
9073
+ var TOKEN_URL = "https://login.microsoftonline.com/consumers/oauth2/v2.0/token";
9074
+ var GRAPH_BASE = "https://graph.microsoft.com/v1.0";
9075
+ var DEVICE_CODE_GRANT = "urn:ietf:params:oauth:grant-type:device_code";
9076
+ var ONEDRIVE_SCOPES = "offline_access Files.ReadWrite.AppFolder User.Read";
9077
+ async function startDeviceCode(clientId) {
9078
+ const res = await fetch(DEVICECODE_URL, {
9079
+ method: "POST",
9080
+ headers: { "Content-Type": "application/x-www-form-urlencoded" },
9081
+ body: new URLSearchParams({ client_id: clientId, scope: ONEDRIVE_SCOPES })
9082
+ });
9083
+ if (!res.ok) {
9084
+ throw new Error(`OneDrive device-code start failed: ${await res.text()}`);
9085
+ }
9086
+ const data = await res.json();
8969
9087
  return {
8970
- taskId: session.taskId,
8971
- postId: session.postId,
8972
- phase: session.phase,
8973
- step: session.step,
8974
- diff: session.diff,
8975
- changedFiles: session.changedFiles,
8976
- commitMessage: session.commitMessage,
8977
- previewUrl: session.previewUrl,
8978
- reproSteps: session.reproSteps,
8979
- pendingQuestion: session.pendingQuestion,
8980
- error: session.error,
8981
- startedAt: session.startedAt,
8982
- targetBranch: session.targetBranch,
8983
- taskTitle: session.taskTitle,
8984
- todos: session.todos
9088
+ deviceCode: data.device_code,
9089
+ userCode: data.user_code,
9090
+ verificationUri: data.verification_uri,
9091
+ expiresIn: data.expires_in,
9092
+ interval: data.interval
8985
9093
  };
8986
9094
  }
8987
- function createEventSink(send, spaceSlug, notify) {
8988
- const PHASE_LABELS = {
8989
- research: "Research started",
8990
- "awaiting-input": "Input required",
8991
- implement: "Implementation started",
8992
- preview: "Preview ready",
8993
- review: "Review started",
8994
- complete: "Task complete",
8995
- error: "Task failed"
8996
- };
8997
- const channel = (event) => `board-agent:${spaceSlug}:${event}`;
9095
+ async function refreshAccessToken(clientId, refreshToken) {
9096
+ const res = await fetch(TOKEN_URL, {
9097
+ method: "POST",
9098
+ headers: { "Content-Type": "application/x-www-form-urlencoded" },
9099
+ body: new URLSearchParams({
9100
+ grant_type: "refresh_token",
9101
+ client_id: clientId,
9102
+ refresh_token: refreshToken,
9103
+ scope: ONEDRIVE_SCOPES
9104
+ })
9105
+ });
9106
+ if (!res.ok) {
9107
+ throw new Error(`OneDrive token refresh failed: ${await res.text()}`);
9108
+ }
9109
+ const tok = await res.json();
8998
9110
  return {
8999
- emitSessionUpdate(session) {
9000
- send(channel("session-update"), toSummary(session));
9001
- const label = PHASE_LABELS[session.phase];
9002
- if (label && notify) {
9003
- notify(
9004
- `Board AI \u2014 ${label}`,
9005
- `${session.taskTitle || "Card"} (step ${session.step}/3)`,
9006
- session.phase !== "complete" && session.phase !== "error" && session.phase !== "awaiting-input"
9007
- );
9008
- }
9009
- },
9010
- emitSessionRemoved(postId) {
9011
- send(channel("session-update"), {
9012
- postId,
9013
- phase: null,
9014
- step: 1,
9015
- taskId: "",
9016
- diff: "",
9017
- changedFiles: [],
9018
- commitMessage: "",
9019
- previewUrl: "",
9020
- reproSteps: "",
9021
- pendingQuestion: "",
9022
- error: "",
9023
- startedAt: 0,
9024
- targetBranch: "",
9025
- taskTitle: "",
9026
- todos: []
9027
- });
9028
- },
9029
- emitLog(session, entry) {
9030
- send(channel(`log:${session.postId}`), entry);
9031
- },
9032
- addLog(session, type, content) {
9033
- const entry = { time: Date.now(), type, content };
9034
- session.logs.push(entry);
9035
- this.emitLog(session, entry);
9036
- },
9037
- appendTextLog(session, text) {
9038
- const last = session.logs[session.logs.length - 1];
9039
- if (last && last.type === "text") {
9040
- last.content += text;
9041
- this.emitLog(session, {
9042
- time: last.time,
9043
- type: "text-append",
9044
- content: text
9045
- });
9046
- } else {
9047
- const entry = {
9048
- time: Date.now(),
9049
- type: "text",
9050
- content: text
9051
- };
9052
- session.logs.push(entry);
9053
- this.emitLog(session, entry);
9054
- }
9055
- },
9056
- emitSetupStatus(message) {
9057
- send(channel("setup-status"), message);
9111
+ accessToken: tok.access_token,
9112
+ // Microsoft rotates the refresh token on each refresh; fall back to the old
9113
+ // one if a new one wasn't returned.
9114
+ refreshToken: tok.refresh_token || refreshToken,
9115
+ expiry: new Date(Date.now() + tok.expires_in * 1e3).toISOString()
9116
+ };
9117
+ }
9118
+ async function pollDeviceCode(clientId, deviceCode) {
9119
+ const res = await fetch(TOKEN_URL, {
9120
+ method: "POST",
9121
+ headers: { "Content-Type": "application/x-www-form-urlencoded" },
9122
+ body: new URLSearchParams({
9123
+ grant_type: DEVICE_CODE_GRANT,
9124
+ client_id: clientId,
9125
+ device_code: deviceCode
9126
+ })
9127
+ });
9128
+ if (res.ok) {
9129
+ const tok = await res.json();
9130
+ if (!tok.refresh_token) {
9131
+ return { status: "error", message: "No refresh token returned." };
9058
9132
  }
9133
+ const appFolder = await getAppFolder(tok.access_token);
9134
+ return {
9135
+ status: "complete",
9136
+ refreshToken: tok.refresh_token,
9137
+ appFolder
9138
+ };
9139
+ }
9140
+ const err = await res.json().catch(() => ({}));
9141
+ switch (err.error) {
9142
+ case "authorization_pending":
9143
+ return { status: "pending" };
9144
+ case "slow_down":
9145
+ return { status: "slow_down" };
9146
+ case "expired_token":
9147
+ case "code_expired":
9148
+ return { status: "expired" };
9149
+ case "authorization_declined":
9150
+ case "access_denied":
9151
+ return { status: "declined" };
9152
+ default:
9153
+ return {
9154
+ status: "error",
9155
+ message: err.error_description || err.error || "Device-code poll failed"
9156
+ };
9157
+ }
9158
+ }
9159
+ async function getAppFolder(accessToken) {
9160
+ const res = await fetch(
9161
+ `${GRAPH_BASE}/me/drive/special/approot?$select=id,parentReference`,
9162
+ { headers: { Authorization: `Bearer ${accessToken}` } }
9163
+ );
9164
+ if (!res.ok) {
9165
+ throw new Error(
9166
+ `Failed to resolve OneDrive app folder: ${res.status} - ${await res.text()}`
9167
+ );
9168
+ }
9169
+ const data = await res.json();
9170
+ if (!data.id || !data.parentReference?.driveId) {
9171
+ throw new Error("OneDrive app folder response was incomplete.");
9172
+ }
9173
+ return {
9174
+ rootFolderId: data.id,
9175
+ driveId: data.parentReference.driveId,
9176
+ driveType: data.parentReference.driveType || "personal"
9059
9177
  };
9060
9178
  }
9061
9179
 
9062
- // ../../shared/all/helpers/card-ai/engine.ts
9063
- function buildPrompt(lines, vars = {}) {
9064
- let text = lines.join("\n");
9065
- for (const [key, value2] of Object.entries(vars)) {
9066
- text = text.replaceAll(`{{${key}}}`, value2);
9180
+ // ../../shared/all/helpers/board-agent-core/onedrive.ts
9181
+ var WORKSPACE = "/home/claude/workspace";
9182
+ var RCLONE_CONF = "/home/claude/.config/rclone/rclone.conf";
9183
+ var REMOTE = "onedrive";
9184
+ var SYNC_LOG = "/tmp/rclone-onedrive.log";
9185
+ function onedriveRemotePath(spaceSlug) {
9186
+ const safe = spaceSlug.replace(/[^a-zA-Z0-9._-]/g, "-");
9187
+ return `${REMOTE}:${safe}`;
9188
+ }
9189
+ async function writeRcloneMountConfig(containerName, conn) {
9190
+ const fresh = await refreshAccessToken(conn.clientId, conn.refreshToken);
9191
+ const tokenJson = JSON.stringify({
9192
+ access_token: fresh.accessToken,
9193
+ refresh_token: fresh.refreshToken,
9194
+ token_type: "Bearer",
9195
+ expiry: fresh.expiry
9196
+ });
9197
+ const conf = [
9198
+ `[${REMOTE}]`,
9199
+ "type = onedrive",
9200
+ `client_id = ${conn.clientId}`,
9201
+ `drive_id = ${conn.driveId}`,
9202
+ `drive_type = ${conn.driveType}`,
9203
+ `root_folder_id = ${conn.rootFolderId}`,
9204
+ // App is consumer-only, so rclone must refresh against /consumers (not the
9205
+ // default /common, which rejects it with AADSTS9002346).
9206
+ "auth_url = https://login.microsoftonline.com/consumers/oauth2/v2.0/authorize",
9207
+ "token_url = https://login.microsoftonline.com/consumers/oauth2/v2.0/token",
9208
+ `token = ${tokenJson}`,
9209
+ ""
9210
+ ].join("\n");
9211
+ const b64 = Buffer.from(conf, "utf-8").toString("base64");
9212
+ await execInContainer(
9213
+ containerName,
9214
+ `mkdir -p /home/claude/.config/rclone && printf %s '${b64}' | base64 -d > ${RCLONE_CONF} && chmod 600 ${RCLONE_CONF}`
9215
+ );
9216
+ return fresh.refreshToken;
9217
+ }
9218
+ async function mountWorkspace(containerName, remotePath) {
9219
+ await execInContainer(
9220
+ containerName,
9221
+ `mkdir -p ${WORKSPACE} && rclone mkdir '${remotePath}'`
9222
+ );
9223
+ const out = await execInContainer(
9224
+ containerName,
9225
+ `nohup rclone mount '${remotePath}' ${WORKSPACE} --vfs-cache-mode full --vfs-write-back 1s --dir-cache-time 30s -v --rc --rc-addr ${RC_ADDR} --rc-no-auth >> ${SYNC_LOG} 2>&1 & for i in $(seq 1 40); do mountpoint -q ${WORKSPACE} && break; sleep 0.25; done; mountpoint -q ${WORKSPACE} && echo mounted || (tail -n 20 ${SYNC_LOG} 2>/dev/null; echo mount-failed)`
9226
+ );
9227
+ if (!out.trim().endsWith("mounted")) {
9228
+ throw new Error(`OneDrive mount failed:
9229
+ ${out.trim()}`);
9067
9230
  }
9068
- return text;
9069
9231
  }
9070
- function sanitizeTaskId(taskId) {
9071
- const sanitized = String(taskId).replace(/[^a-zA-Z0-9_.-]/g, "");
9072
- if (!sanitized) throw new Error("Invalid taskId");
9073
- return sanitized;
9232
+ var syncWatchers = /* @__PURE__ */ new Map();
9233
+ var RC_ADDR = "127.0.0.1:5572";
9234
+ async function onedriveQueueNow(containerName) {
9235
+ const out = await execInContainer(
9236
+ containerName,
9237
+ `rclone rc --url http://${RC_ADDR}/ vfs/queue 2>/dev/null || echo '{}'`
9238
+ );
9239
+ try {
9240
+ const parsed = JSON.parse(out);
9241
+ return (parsed.queue ?? []).map((i) => i.name).filter((n) => !!n);
9242
+ } catch {
9243
+ return [];
9244
+ }
9245
+ }
9246
+ function startOneDriveSyncWatcher(containerName, onPending) {
9247
+ stopOneDriveSyncWatcher(containerName);
9248
+ let chain = Promise.resolve();
9249
+ const refresh = () => {
9250
+ chain = chain.then(async () => {
9251
+ const watcher = syncWatchers.get(containerName);
9252
+ if (!watcher) return;
9253
+ const files = await onedriveQueueNow(containerName);
9254
+ if (watcher.files.join("\n") !== files.join("\n")) {
9255
+ watcher.files = files;
9256
+ onPending(files.length, files);
9257
+ }
9258
+ });
9259
+ };
9260
+ const handle = streamExecInContainer(
9261
+ containerName,
9262
+ `tail -n0 -F ${SYNC_LOG} 2>/dev/null | grep --line-buffered -E 'vfs cache: (upload|starting|failed|cancel|queuing)'`,
9263
+ refresh
9264
+ );
9265
+ syncWatchers.set(containerName, { handle, files: [] });
9266
+ }
9267
+ function stopOneDriveSyncWatcher(containerName) {
9268
+ const watcher = syncWatchers.get(containerName);
9269
+ if (watcher) {
9270
+ watcher.handle.stop();
9271
+ syncWatchers.delete(containerName);
9272
+ }
9074
9273
  }
9075
- var BoardAgentEngine = class _BoardAgentEngine {
9274
+ function onedrivePendingUploads(containerName) {
9275
+ return syncWatchers.get(containerName)?.files.length ?? 0;
9276
+ }
9277
+ function onedriveUploadingFiles(containerName) {
9278
+ return syncWatchers.get(containerName)?.files ?? [];
9279
+ }
9280
+ async function unmountWorkspace(containerName) {
9281
+ await execInContainer(
9282
+ containerName,
9283
+ `fusermount -u ${WORKSPACE} 2>/dev/null || fusermount3 -u ${WORKSPACE} 2>/dev/null || rclone unmount ${WORKSPACE} 2>/dev/null || true`
9284
+ );
9285
+ }
9286
+ async function readbackRefreshToken(containerName) {
9287
+ let conf = "";
9288
+ try {
9289
+ conf = await execInContainer(
9290
+ containerName,
9291
+ `cat ${RCLONE_CONF} 2>/dev/null || true`
9292
+ );
9293
+ } catch {
9294
+ return null;
9295
+ }
9296
+ const match = conf.match(/token\s*=\s*(\{.*\})/);
9297
+ if (!match) return null;
9298
+ try {
9299
+ const parsed = JSON.parse(match[1]);
9300
+ return parsed.refresh_token ?? null;
9301
+ } catch {
9302
+ return null;
9303
+ }
9304
+ }
9305
+
9306
+ // ../../shared/all/helpers/board-agent-core/container.ts
9307
+ function withTimeout(work, ms, label) {
9308
+ let timer;
9309
+ const timeout = new Promise((_, reject) => {
9310
+ timer = setTimeout(
9311
+ () => reject(
9312
+ new Error(
9313
+ `${label} timed out. Check Docker and your connection, then Try Again.`
9314
+ )
9315
+ ),
9316
+ ms
9317
+ );
9318
+ });
9319
+ return Promise.race([work.finally(() => clearTimeout(timer)), timeout]);
9320
+ }
9321
+ var ContainerCore = class {
9076
9322
  constructor(options) {
9077
- // Utility container state
9323
+ // Utility container state.
9078
9324
  this.containerName = null;
9079
9325
  this.lastConfigKey = "";
9080
9326
  this.setupPromise = null;
9081
- // Board AI session id + active process
9082
- this.boardSessionId = null;
9083
- this.boardProcess = null;
9084
- // Board AI state
9085
- this.sessions = /* @__PURE__ */ new Map();
9086
- this.cardProcesses = /* @__PURE__ */ new Map();
9087
9327
  this.spaceSlug = options.spaceSlug;
9088
9328
  this.emitter = options.emitter;
9089
9329
  this.configProvider = options.configProvider;
@@ -9092,6 +9332,49 @@ var BoardAgentEngine = class _BoardAgentEngine {
9092
9332
  const raw = (options.instanceId || "shared").toLowerCase();
9093
9333
  this.instancePrefix = raw.replace(/[^a-z0-9-]/g, "-").replace(/-+/g, "-").replace(/^-|-$/g, "") || "shared";
9094
9334
  }
9335
+ readConfig() {
9336
+ return this.configProvider.readConfig(this.spaceSlug);
9337
+ }
9338
+ isReady() {
9339
+ return this.containerName !== null;
9340
+ }
9341
+ // ── Storage backend ──
9342
+ isOneDrive(config) {
9343
+ return config.storageBackend === "onedrive";
9344
+ }
9345
+ /** Per-space OneDrive remote path inside the app folder. */
9346
+ remotePath() {
9347
+ return onedriveRemotePath(this.spaceSlug);
9348
+ }
9349
+ isOneDriveConnected() {
9350
+ return this.configProvider.readOneDriveConnection(this.spaceSlug) !== null;
9351
+ }
9352
+ /** The stored OneDrive credential, or fail with a connect hint. */
9353
+ getOneDriveConnection() {
9354
+ const conn = this.configProvider.readOneDriveConnection(this.spaceSlug);
9355
+ if (!conn) {
9356
+ throw new Error(
9357
+ "OneDrive is not connected. Connect it in Board AI settings."
9358
+ );
9359
+ }
9360
+ return conn;
9361
+ }
9362
+ saveOneDriveConnection(connection) {
9363
+ this.configProvider.writeOneDriveConnection(this.spaceSlug, connection);
9364
+ }
9365
+ clearOneDriveConnection() {
9366
+ this.configProvider.writeOneDriveConnection(this.spaceSlug, null);
9367
+ }
9368
+ /** Persist a rotated refresh token onto the existing connection. */
9369
+ updateOneDriveRefreshToken(refreshToken) {
9370
+ const conn = this.configProvider.readOneDriveConnection(this.spaceSlug);
9371
+ if (conn) {
9372
+ this.configProvider.writeOneDriveConnection(this.spaceSlug, {
9373
+ ...conn,
9374
+ refreshToken
9375
+ });
9376
+ }
9377
+ }
9095
9378
  // ── Config ──
9096
9379
  getConfig() {
9097
9380
  const config = this.configProvider.readConfig(this.spaceSlug);
@@ -9103,7 +9386,11 @@ var BoardAgentEngine = class _BoardAgentEngine {
9103
9386
  hasToken: Boolean(config.claudeToken),
9104
9387
  hasGithubToken: Boolean(config.githubToken),
9105
9388
  tokenHint: config.claudeToken ? `\xB7\xB7\xB7\xB7${config.claudeToken.slice(-4)}` : "",
9106
- githubTokenHint: config.githubToken ? `\xB7\xB7\xB7\xB7${config.githubToken.slice(-4)}` : ""
9389
+ githubTokenHint: config.githubToken ? `\xB7\xB7\xB7\xB7${config.githubToken.slice(-4)}` : "",
9390
+ storageBackend: config.storageBackend ?? "github",
9391
+ onedrive: {
9392
+ connected: this.isOneDriveConnected()
9393
+ }
9107
9394
  };
9108
9395
  }
9109
9396
  setConfig(partial) {
@@ -9114,7 +9401,8 @@ var BoardAgentEngine = class _BoardAgentEngine {
9114
9401
  gitName: partial.gitName ?? existing.gitName,
9115
9402
  gitEmail: partial.gitEmail ?? existing.gitEmail,
9116
9403
  claudeToken: partial.claudeToken?.replace(/\s/g, "") || existing.claudeToken,
9117
- githubToken: partial.githubToken?.replace(/\s/g, "") || existing.githubToken
9404
+ githubToken: partial.githubToken?.replace(/\s/g, "") || existing.githubToken,
9405
+ storageBackend: partial.storageBackend ?? existing.storageBackend
9118
9406
  };
9119
9407
  this.configProvider.writeConfig(this.spaceSlug, updated);
9120
9408
  return { success: true };
@@ -9129,10 +9417,19 @@ var BoardAgentEngine = class _BoardAgentEngine {
9129
9417
  async ensureContainer(onStatus) {
9130
9418
  const config = this.configProvider.readConfig(this.spaceSlug);
9131
9419
  if (!config.claudeToken) throw new Error("No Claude token configured.");
9132
- if (!config.githubToken) throw new Error("No GitHub token configured.");
9133
- if (!config.repoUrl) throw new Error("No repository URL configured.");
9134
- const configKey = `${config.repoUrl}|${config.claudeToken}|${config.githubToken}`;
9135
- const cacheKey = repoCacheKey(config.repoUrl);
9420
+ const onedrive = this.isOneDrive(config);
9421
+ if (onedrive) {
9422
+ if (!this.isOneDriveConnected()) {
9423
+ throw new Error(
9424
+ "OneDrive is not connected. Connect it in Board AI settings, then retry."
9425
+ );
9426
+ }
9427
+ } else {
9428
+ if (!config.githubToken) throw new Error("No GitHub token configured.");
9429
+ if (!config.repoUrl) throw new Error("No repository URL configured.");
9430
+ }
9431
+ const configKey = onedrive ? `onedrive|${this.spaceSlug}|${config.claudeToken}` : `${config.repoUrl}|${config.claudeToken}|${config.githubToken}`;
9432
+ const cacheKey = onedrive ? `onedrive-${this.spaceSlug}`.replace(/[^a-zA-Z0-9._-]/g, "_") : repoCacheKey(config.repoUrl);
9136
9433
  const name = `factiii-${this.instancePrefix}-util-${this.spaceSlug}-${cacheKey}`;
9137
9434
  if (this.containerName && this.lastConfigKey !== configKey) {
9138
9435
  await killContainer(this.containerName);
@@ -9154,16 +9451,40 @@ var BoardAgentEngine = class _BoardAgentEngine {
9154
9451
  this.emitter.emitSetupStatus(msg);
9155
9452
  };
9156
9453
  try {
9157
- status("Building Docker image...");
9158
- await ensureDockerImage(this.dockerfilePath);
9454
+ status("Step 1/4 \xB7 Checking Docker\u2026");
9455
+ await withTimeout(assertDockerRunning(), 15e3, "Checking Docker");
9456
+ status("Step 2/4 \xB7 Building container image\u2026");
9457
+ await ensureDockerImage(this.dockerfilePath, (line) => status(line));
9159
9458
  await ensureCacheVolume();
9160
9459
  await killContainer(name);
9161
- status("Starting container...");
9162
- await spawnContainer(name, config.claudeToken);
9163
- status("Cloning repository...");
9164
- const cloneUrl = buildCloneUrl(config.repoUrl, config.githubToken);
9165
- await cloneRepo(name, cloneUrl, config.mainBranch || "main", cacheKey);
9166
- await setGitIdentity(name, config.gitName, config.gitEmail);
9460
+ status("Step 3/4 \xB7 Starting container\u2026");
9461
+ await withTimeout(
9462
+ spawnContainer(name, config.claudeToken),
9463
+ 9e4,
9464
+ "Starting container"
9465
+ );
9466
+ if (onedrive) {
9467
+ status("Step 4/4 \xB7 Verifying OneDrive\u2026");
9468
+ const rotated = await writeRcloneMountConfig(
9469
+ name,
9470
+ this.getOneDriveConnection()
9471
+ );
9472
+ this.updateOneDriveRefreshToken(rotated);
9473
+ await withTimeout(
9474
+ execInContainer(name, `rclone lsd 'onedrive:'`),
9475
+ 6e4,
9476
+ "Verifying OneDrive"
9477
+ );
9478
+ } else {
9479
+ status("Step 4/4 \xB7 Cloning repository\u2026");
9480
+ const cloneUrl = buildCloneUrl(config.repoUrl, config.githubToken);
9481
+ await withTimeout(
9482
+ cloneRepo(name, cloneUrl, config.mainBranch || "main", cacheKey),
9483
+ 18e4,
9484
+ "Cloning repository"
9485
+ );
9486
+ await setGitIdentity(name, config.gitName, config.gitEmail);
9487
+ }
9167
9488
  status("");
9168
9489
  this.containerName = name;
9169
9490
  this.lastConfigKey = configKey;
@@ -9175,200 +9496,1534 @@ var BoardAgentEngine = class _BoardAgentEngine {
9175
9496
  })();
9176
9497
  return this.setupPromise;
9177
9498
  }
9178
- // ── Board AI ──
9179
- async ask(params, sendLog) {
9499
+ setupStatus() {
9500
+ if (this.containerName) return { phase: "ready" };
9501
+ if (this.setupPromise) return { phase: "running" };
9502
+ return { phase: "idle" };
9503
+ }
9504
+ async ensureSetup() {
9505
+ await this.ensureContainer();
9506
+ return { ready: true };
9507
+ }
9508
+ async cardRemoteBranches() {
9509
+ const config = this.configProvider.readConfig(this.spaceSlug);
9510
+ if (this.isOneDrive(config)) return [];
9511
+ if (!config.githubToken || !config.repoUrl) return [];
9180
9512
  if (!this.isReady()) {
9181
9513
  throw new Error(
9182
9514
  'Environment not initialized. Open Board AI Settings and click "Initialize Environment".'
9183
9515
  );
9184
9516
  }
9185
- const containerName = this.containerName;
9186
- let prompt2;
9187
- if (this.boardSessionId) {
9188
- prompt2 = params.question;
9189
- } else {
9190
- const parts2 = ["Here is the board data:", "", params.boardContext || ""];
9191
- parts2.push("", params.question);
9192
- prompt2 = parts2.join("\n");
9193
- }
9194
- const systemPrompt = [params.componentSchema, buildPrompt(prompts_default.boardAI)].filter(Boolean).join("\n\n");
9195
- const { proc, result } = spawnClaude({
9196
- containerName,
9197
- prompt: prompt2,
9198
- allowedTools: "Read,Grep,Glob,Agent",
9199
- model: "claude-opus-4-6",
9200
- includePartialMessages: true,
9201
- appendSystemPrompt: systemPrompt,
9202
- resumeSessionId: this.boardSessionId || void 0,
9203
- callbacks: {
9204
- onLog: (entry) => sendLog(entry)
9205
- }
9206
- });
9207
- this.boardProcess = proc;
9208
- try {
9209
- const { text, sessionId } = await result;
9210
- if (sessionId) this.boardSessionId = sessionId;
9211
- return text;
9212
- } finally {
9213
- this.boardProcess = null;
9214
- }
9215
- }
9216
- resetBoardSession() {
9217
- if (this.boardProcess) {
9218
- try {
9219
- this.boardProcess.kill("SIGTERM");
9220
- } catch {
9221
- }
9222
- this.boardProcess = null;
9223
- }
9224
- this.boardSessionId = null;
9517
+ return listRemoteBranches(this.containerName);
9225
9518
  }
9226
- // ── Board AI: Claude runner ──
9227
- async runClaudeInContainer(session, prompt2, tools, resumeSessionId, options) {
9228
- const { proc, result } = spawnClaude({
9229
- containerName: session.containerName,
9230
- prompt: prompt2,
9231
- allowedTools: tools,
9232
- disallowedTools: options?.disallowedTools,
9233
- permissionMode: options?.permissionMode,
9234
- appendSystemPrompt: buildPrompt(prompts_default.system),
9235
- effort: session.effort,
9236
- model: "opus",
9237
- resumeSessionId,
9238
- callbacks: {
9239
- onLog: (entry) => {
9240
- if (entry.type === "text-append") {
9241
- this.emitter.appendTextLog(session, entry.content);
9242
- } else {
9243
- this.emitter.addLog(session, entry.type, entry.content);
9244
- }
9245
- },
9246
- onTodos: (todos) => {
9247
- session.todos = todos;
9248
- this.emitter.emitSessionUpdate(session);
9249
- }
9250
- }
9251
- });
9252
- this.cardProcesses.set(session.postId, proc);
9253
- try {
9254
- return await result;
9255
- } finally {
9256
- this.cardProcesses.delete(session.postId);
9519
+ destroy() {
9520
+ if (this.containerName) {
9521
+ void killContainer(this.containerName);
9522
+ this.containerName = null;
9523
+ this.setupPromise = null;
9257
9524
  }
9258
9525
  }
9259
- static {
9260
- // ── Board AI: Research with Q&A ──
9261
- this.QUESTION_MARKER = "AWAITING_INPUT";
9262
- }
9263
- async runResearch(session, prompt2, resumeSessionId) {
9264
- if (!this.sessions.has(session.postId)) return;
9265
- const { text, sessionId } = await this.runClaudeInContainer(
9526
+ };
9527
+
9528
+ // ../../shared/all/helpers/board-agent-core/runtimes.ts
9529
+ async function installRuntimes(emitter, session, attrs) {
9530
+ const clean = attrs.map((a) => a.trim()).filter((a) => a && /^[a-zA-Z0-9._-]+$/.test(a));
9531
+ if (!clean.length) return;
9532
+ emitter.addLog(
9533
+ session,
9534
+ "system",
9535
+ `Installing ${clean.join(", ")} via Nix...`
9536
+ );
9537
+ try {
9538
+ const refs = clean.map((a) => `nixpkgs#${a}`).join(" ");
9539
+ const wrapped = `nix profile install ${refs} 2>&1; echo "__FACTIII_NIX_EXIT__:$?"`;
9540
+ const out = await execInContainer(session.containerName, wrapped);
9541
+ const marker = out.lastIndexOf("__FACTIII_NIX_EXIT__:");
9542
+ const body = (marker >= 0 ? out.slice(0, marker) : out).trimEnd();
9543
+ const code = marker >= 0 ? parseInt(out.slice(marker).split(":")[1], 10) || 0 : 0;
9544
+ if (body) emitter.addLog(session, "text", body);
9545
+ emitter.addLog(
9266
9546
  session,
9267
- prompt2,
9268
- "Read,Grep,Glob,Agent,TodoWrite",
9269
- resumeSessionId,
9270
- { permissionMode: "plan", disallowedTools: "AskUserQuestion" }
9547
+ code === 0 ? "system" : "error",
9548
+ code === 0 ? "Environment ready." : `Install exited with code ${code}. Continuing \u2014 install by hand from the terminal if needed.`
9271
9549
  );
9272
- session.claudeSessionId = sessionId;
9273
- if (!this.sessions.has(session.postId)) return;
9274
- const markerIdx = text.lastIndexOf(_BoardAgentEngine.QUESTION_MARKER);
9275
- if (markerIdx === -1) return;
9276
- session.phase = "awaiting-input";
9277
- session.pendingQuestion = text.slice(markerIdx + _BoardAgentEngine.QUESTION_MARKER.length).trim();
9278
- this.emitter.emitSessionUpdate(session);
9279
- this.emitter.addLog(
9550
+ } catch (err) {
9551
+ emitter.addLog(
9280
9552
  session,
9281
- "system",
9282
- "Claude has a question \u2014 waiting for your input."
9553
+ "error",
9554
+ `Install failed: ${err instanceof Error ? err.message : String(err)}`
9283
9555
  );
9284
- const answer = await new Promise((resolve) => {
9285
- session.answerResolver = resolve;
9286
- });
9287
- if (!this.sessions.has(session.postId)) return;
9288
- session.phase = "research";
9289
- session.pendingQuestion = "";
9290
- session.answerResolver = null;
9291
- this.emitter.emitSessionUpdate(session);
9292
- this.emitter.addLog(session, "system", "Resuming research...");
9293
- return this.runResearch(session, answer, sessionId);
9294
9556
  }
9295
- // ── Board AI: Pipeline ──
9296
- async runPipeline(session, prompt2, enablePreview, baseBranch) {
9297
- if (!this.isReady()) {
9298
- throw new Error(
9299
- 'Environment not initialized. Open Board AI Settings and click "Initialize Environment".'
9557
+ }
9558
+
9559
+ // ../../shared/all/helpers/board-agent-core/session.ts
9560
+ function toSummary(session) {
9561
+ return {
9562
+ taskId: session.taskId,
9563
+ postId: session.postId,
9564
+ phase: session.phase,
9565
+ mode: session.mode,
9566
+ step: session.step,
9567
+ diff: session.diff,
9568
+ changedFiles: session.changedFiles,
9569
+ commitMessage: session.commitMessage,
9570
+ previewUrl: session.previewUrl,
9571
+ previewRunning: session.previewRunning,
9572
+ reproSteps: session.reproSteps,
9573
+ pendingQuestion: session.pendingQuestion,
9574
+ error: session.error,
9575
+ startedAt: session.startedAt,
9576
+ targetBranch: session.targetBranch,
9577
+ taskTitle: session.taskTitle,
9578
+ todos: session.todos
9579
+ };
9580
+ }
9581
+ function createEventSink(send, spaceSlug, notify) {
9582
+ const PHASE_LABELS = {
9583
+ research: "Research started",
9584
+ "awaiting-input": "Input required",
9585
+ implement: "Implementation started",
9586
+ review: "Review started",
9587
+ complete: "Task complete",
9588
+ error: "Task failed"
9589
+ };
9590
+ const channel = (event) => `board-agent:${spaceSlug}:${event}`;
9591
+ return {
9592
+ emitSessionUpdate(session) {
9593
+ send(channel("session-update"), toSummary(session));
9594
+ const label = PHASE_LABELS[session.phase];
9595
+ if (label && notify) {
9596
+ notify(
9597
+ `Board AI - ${label}`,
9598
+ `${session.taskTitle || "Card"} (step ${session.step}/3)`,
9599
+ session.phase !== "complete" && session.phase !== "error" && session.phase !== "awaiting-input"
9600
+ );
9601
+ }
9602
+ },
9603
+ emitSessionRemoved(postId) {
9604
+ send(channel("session-update"), {
9605
+ postId,
9606
+ phase: null,
9607
+ mode: "claude",
9608
+ step: 1,
9609
+ taskId: "",
9610
+ diff: "",
9611
+ changedFiles: [],
9612
+ commitMessage: "",
9613
+ previewUrl: "",
9614
+ previewRunning: false,
9615
+ reproSteps: "",
9616
+ pendingQuestion: "",
9617
+ error: "",
9618
+ startedAt: 0,
9619
+ targetBranch: "",
9620
+ taskTitle: "",
9621
+ todos: []
9622
+ });
9623
+ },
9624
+ emitLog(session, entry) {
9625
+ send(channel(`log:${session.postId}`), entry);
9626
+ },
9627
+ addLog(session, type, content) {
9628
+ const entry = { time: Date.now(), type, content };
9629
+ session.logs.push(entry);
9630
+ this.emitLog(session, entry);
9631
+ },
9632
+ appendTextLog(session, text) {
9633
+ const last = session.logs[session.logs.length - 1];
9634
+ if (last && last.type === "text") {
9635
+ last.content += text;
9636
+ this.emitLog(session, {
9637
+ time: last.time,
9638
+ type: "text-append",
9639
+ content: text
9640
+ });
9641
+ } else {
9642
+ const entry = {
9643
+ time: Date.now(),
9644
+ type: "text",
9645
+ content: text
9646
+ };
9647
+ session.logs.push(entry);
9648
+ this.emitLog(session, entry);
9649
+ }
9650
+ },
9651
+ emitSetupStatus(message) {
9652
+ send(channel("setup-status"), message);
9653
+ },
9654
+ emitOneDriveAuth(payload) {
9655
+ send(channel("onedrive-auth"), payload);
9656
+ },
9657
+ emitOneDriveSync(payload) {
9658
+ send(channel("onedrive-sync"), payload);
9659
+ }
9660
+ };
9661
+ }
9662
+
9663
+ // ../../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"));
9667
+ var import_util3 = require("util");
9668
+
9669
+ // ../../shared/all/helpers/card-ai/scripts.ts
9670
+ var FRPC_EXPOSE_SCRIPT = `#!/bin/sh
9671
+ set -e
9672
+ set -a; . /etc/environment.factiii 2>/dev/null || true; set +a
9673
+ PORT="$1"
9674
+ NAME="$2"
9675
+ if [ -z "$PORT" ] || [ -z "$NAME" ]; then
9676
+ echo "usage: factiii-expose <port> <name>" >&2
9677
+ echo " e.g. factiii-expose 3000 app" >&2
9678
+ exit 1
9679
+ fi
9680
+ case "$NAME" in *[!a-zA-Z0-9-]*|"") echo "name must be [a-zA-Z0-9-]" >&2; exit 1 ;; esac
9681
+ PROXY="\${FACTIII_TASK_ID}-\${NAME}"
9682
+ DOMAIN="\${PROXY}.\${FACTIII_FRP_DOMAIN}"
9683
+ TOML="/tmp/frpc-\${NAME}.toml"
9684
+ cat > "$TOML" <<EOF
9685
+ serverAddr = "\${FACTIII_FRP_SERVER}"
9686
+ serverPort = \${FACTIII_FRP_PORT}
9687
+ transport.protocol = "tcp"
9688
+
9689
+ [metadatas]
9690
+ factiii_token = "\${FACTIII_FRP_TOKEN}"
9691
+
9692
+ [[proxies]]
9693
+ name = "\${PROXY}"
9694
+ type = "http"
9695
+ localPort = \${PORT}
9696
+ subdomain = "\${PROXY}"
9697
+ EOF
9698
+ setsid frpc -c "$TOML" >/tmp/frpc-\${NAME}.log 2>&1 < /dev/null &
9699
+ echo $! > /tmp/frpc-\${NAME}.pid
9700
+ echo "http://\${DOMAIN}"
9701
+ `;
9702
+ var FRPC_UNEXPOSE_SCRIPT = `#!/bin/sh
9703
+ NAME="$1"
9704
+ if [ -z "$NAME" ]; then
9705
+ echo "usage: factiii-unexpose <name>" >&2
9706
+ exit 1
9707
+ fi
9708
+ PID_FILE="/tmp/frpc-\${NAME}.pid"
9709
+ if [ -f "$PID_FILE" ]; then
9710
+ kill "$(cat "$PID_FILE")" 2>/dev/null || true
9711
+ rm -f "$PID_FILE" "/tmp/frpc-\${NAME}.toml" "/tmp/frpc-\${NAME}.log"
9712
+ echo "stopped \${NAME}"
9713
+ else
9714
+ echo "no tunnel named \${NAME}" >&2
9715
+ exit 1
9716
+ fi
9717
+ `;
9718
+
9719
+ // ../../shared/all/helpers/card-ai/frpc.ts
9720
+ var execFileAsync3 = (0, import_util3.promisify)(import_child_process5.execFile);
9721
+ var FRP_SERVER_ADDR = "dev.factiii.com";
9722
+ var FRP_SERVER_PORT = 7443;
9723
+ var FRP_DOMAIN_SUFFIX = "dev.factiii.com";
9724
+ async function writeFileAsRoot(containerName, path7, contents) {
9725
+ const b64 = Buffer.from(contents, "utf-8").toString("base64");
9726
+ await execInContainerAsRoot(
9727
+ containerName,
9728
+ `echo '${b64}' | base64 -d > '${path7}'`
9729
+ );
9730
+ }
9731
+ async function installFrpcBinary(containerName) {
9732
+ const arch = (await execInContainer(containerName, "uname -m")).trim();
9733
+ const map = {
9734
+ x86_64: "frpc-linux-amd64",
9735
+ aarch64: "frpc-linux-arm64"
9736
+ };
9737
+ const binaryName = map[arch];
9738
+ if (!binaryName) {
9739
+ throw new Error(`Unsupported container arch for frpc: ${arch}`);
9740
+ }
9741
+ const src = import_path2.default.join(__dirname, "..", "vendor", binaryName);
9742
+ if (!(0, import_fs2.existsSync)(src)) {
9743
+ throw new Error(
9744
+ `Vendored frpc missing (${src}). Run apps/runner/scripts/fetch-frpc.sh.`
9745
+ );
9746
+ }
9747
+ await execFileAsync3("docker", [
9748
+ "cp",
9749
+ src,
9750
+ `${containerName}:/usr/local/bin/frpc`
9751
+ ]);
9752
+ await execInContainerAsRoot(containerName, "chmod +x /usr/local/bin/frpc");
9753
+ }
9754
+ async function installFrpcHarness(params) {
9755
+ const { containerName, taskId, runnerToken } = params;
9756
+ await installFrpcBinary(containerName);
9757
+ const envLines = [
9758
+ `FACTIII_FRP_SERVER=${FRP_SERVER_ADDR}`,
9759
+ `FACTIII_FRP_PORT=${FRP_SERVER_PORT}`,
9760
+ `FACTIII_FRP_TOKEN=${runnerToken}`,
9761
+ `FACTIII_FRP_DOMAIN=${FRP_DOMAIN_SUFFIX}`,
9762
+ `FACTIII_TASK_ID=${taskId}`
9763
+ ];
9764
+ await writeFileAsRoot(
9765
+ containerName,
9766
+ "/usr/local/bin/factiii-expose",
9767
+ FRPC_EXPOSE_SCRIPT
9768
+ );
9769
+ await writeFileAsRoot(
9770
+ containerName,
9771
+ "/usr/local/bin/factiii-unexpose",
9772
+ FRPC_UNEXPOSE_SCRIPT
9773
+ );
9774
+ await execInContainerAsRoot(
9775
+ containerName,
9776
+ "chmod +x /usr/local/bin/factiii-expose /usr/local/bin/factiii-unexpose"
9777
+ );
9778
+ await writeFileAsRoot(
9779
+ containerName,
9780
+ "/etc/environment.factiii",
9781
+ envLines.join("\n") + "\n"
9782
+ );
9783
+ await execInContainerAsRoot(
9784
+ containerName,
9785
+ `grep -q 'environment.factiii' /home/claude/.profile 2>/dev/null || echo 'set -a; . /etc/environment.factiii; set +a' >> /home/claude/.profile && chown claude:claude /home/claude/.profile`
9786
+ );
9787
+ }
9788
+
9789
+ // ../../shared/all/helpers/card-ai/claude-mode.ts
9790
+ function buildPrompt(lines, vars = {}) {
9791
+ let text = lines.join("\n");
9792
+ for (const [key, value2] of Object.entries(vars)) {
9793
+ text = text.replaceAll(`{{${key}}}`, value2);
9794
+ }
9795
+ return text;
9796
+ }
9797
+ var ONEDRIVE_PROMPT_SWAPS = [
9798
+ [
9799
+ "- Full clone with complete git history available.",
9800
+ "- Your file changes are saved automatically when the task finishes."
9801
+ ],
9802
+ [
9803
+ "1. Review the diff of your changes (git diff HEAD)",
9804
+ "1. Review the changes you made"
9805
+ ]
9806
+ ];
9807
+ function gitFree(text) {
9808
+ let out = text;
9809
+ for (const [from, to] of ONEDRIVE_PROMPT_SWAPS) out = out.replace(from, to);
9810
+ return out;
9811
+ }
9812
+ var ClaudeModeEngine = class _ClaudeModeEngine {
9813
+ constructor(core, emitter) {
9814
+ this.core = core;
9815
+ this.emitter = emitter;
9816
+ this.sessions = /* @__PURE__ */ new Map();
9817
+ this.board = { sessionId: null, process: null, messages: [] };
9818
+ }
9819
+ static {
9820
+ this.QUESTION_MARKER = "AWAITING_INPUT";
9821
+ }
9822
+ static {
9823
+ this.FULL_TOOLS = "Read,Grep,Glob,Agent,Edit,Write,Bash,TodoWrite";
9824
+ }
9825
+ // ── Board AI ("Ask AI") ──
9826
+ async ask(params, sendLog) {
9827
+ if (!this.core.isReady()) {
9828
+ throw new Error(
9829
+ 'Environment not initialized. Open Board AI Settings and click "Initialize Environment".'
9830
+ );
9831
+ }
9832
+ const containerName = this.core.containerName;
9833
+ await execInContainer(containerName, "mkdir -p /home/claude/workspace");
9834
+ this.board.messages.push({ role: "user", content: params.question });
9835
+ let prompt2;
9836
+ if (this.board.sessionId) {
9837
+ prompt2 = params.question;
9838
+ } else {
9839
+ const parts2 = ["Here is the board data:", "", params.boardContext || ""];
9840
+ parts2.push("", params.question);
9841
+ prompt2 = parts2.join("\n");
9842
+ }
9843
+ const systemPrompt = [params.componentSchema, buildPrompt(prompts_default.boardAI)].filter(Boolean).join("\n\n");
9844
+ const { proc, result } = spawnClaude({
9845
+ containerName,
9846
+ prompt: prompt2,
9847
+ allowedTools: "Read,Grep,Glob,Agent",
9848
+ model: "claude-opus-4-6",
9849
+ includePartialMessages: true,
9850
+ appendSystemPrompt: systemPrompt,
9851
+ resumeSessionId: this.board.sessionId || void 0,
9852
+ callbacks: {
9853
+ onLog: (entry) => sendLog(entry)
9854
+ }
9855
+ });
9856
+ this.board.process = proc;
9857
+ try {
9858
+ const { text, sessionId } = await result;
9859
+ if (sessionId) this.board.sessionId = sessionId;
9860
+ this.board.messages.push({ role: "assistant", content: text });
9861
+ return text;
9862
+ } finally {
9863
+ this.board.process = null;
9864
+ }
9865
+ }
9866
+ /** The persisted Ask AI conversation, for the client to hydrate on open. */
9867
+ boardSession() {
9868
+ return { messages: this.board.messages.slice() };
9869
+ }
9870
+ resetBoardSession() {
9871
+ if (this.board.process) {
9872
+ try {
9873
+ this.board.process.kill("SIGTERM");
9874
+ } catch {
9875
+ }
9876
+ }
9877
+ this.board = { sessionId: null, process: null, messages: [] };
9878
+ }
9879
+ // ── Claude runner ──
9880
+ /** Strip git mentions from an agent prompt on OneDrive spaces; pass-through otherwise. */
9881
+ agentPrompt(text) {
9882
+ return this.core.isOneDrive(this.core.readConfig()) ? gitFree(text) : text;
9883
+ }
9884
+ async runClaudeInContainer(session, prompt2, tools, resumeSessionId, options) {
9885
+ const { proc, result } = spawnClaude({
9886
+ containerName: session.containerName,
9887
+ prompt: prompt2,
9888
+ allowedTools: tools,
9889
+ disallowedTools: options?.disallowedTools,
9890
+ permissionMode: options?.permissionMode,
9891
+ appendSystemPrompt: this.agentPrompt(buildPrompt(prompts_default.system)),
9892
+ effort: session.effort,
9893
+ model: "opus",
9894
+ resumeSessionId,
9895
+ callbacks: {
9896
+ onLog: (entry) => {
9897
+ if (entry.type === "text-append") {
9898
+ this.emitter.appendTextLog(session, entry.content);
9899
+ } else {
9900
+ this.emitter.addLog(session, entry.type, entry.content);
9901
+ }
9902
+ },
9903
+ onTodos: (todos) => {
9904
+ session.todos = todos;
9905
+ this.emitter.emitSessionUpdate(session);
9906
+ }
9907
+ }
9908
+ });
9909
+ session.proc = proc;
9910
+ try {
9911
+ return await result;
9912
+ } finally {
9913
+ session.proc = null;
9914
+ }
9915
+ }
9916
+ // ── Research with Q&A ──
9917
+ async runResearch(session, prompt2, resumeSessionId) {
9918
+ if (!this.sessions.has(session.postId)) return;
9919
+ const { text, sessionId } = await this.runClaudeInContainer(
9920
+ session,
9921
+ prompt2,
9922
+ "Read,Grep,Glob,Agent,TodoWrite",
9923
+ resumeSessionId,
9924
+ { permissionMode: "plan", disallowedTools: "AskUserQuestion" }
9925
+ );
9926
+ session.claudeSessionId = sessionId;
9927
+ if (!this.sessions.has(session.postId)) return;
9928
+ const markerIdx = text.lastIndexOf(_ClaudeModeEngine.QUESTION_MARKER);
9929
+ if (markerIdx === -1) return;
9930
+ session.phase = "awaiting-input";
9931
+ session.pendingQuestion = text.slice(markerIdx + _ClaudeModeEngine.QUESTION_MARKER.length).trim();
9932
+ this.emitter.emitSessionUpdate(session);
9933
+ this.emitter.addLog(
9934
+ session,
9935
+ "system",
9936
+ "Claude has a question - waiting for your input."
9937
+ );
9938
+ const answer = await new Promise((resolve) => {
9939
+ session.answerResolver = resolve;
9940
+ });
9941
+ if (!this.sessions.has(session.postId)) return;
9942
+ session.phase = "research";
9943
+ session.pendingQuestion = "";
9944
+ session.answerResolver = null;
9945
+ this.emitter.emitSessionUpdate(session);
9946
+ this.emitter.addLog(session, "system", "Resuming research...");
9947
+ return this.runResearch(session, answer, sessionId);
9948
+ }
9949
+ // ── Pipeline ──
9950
+ async runPipeline(session, prompt2, baseBranch, runtimes) {
9951
+ if (!this.core.isReady()) {
9952
+ throw new Error(
9953
+ 'Environment not initialized. Open Board AI Settings and click "Initialize Environment".'
9954
+ );
9955
+ }
9956
+ const config = this.core.readConfig();
9957
+ this.emitter.addLog(session, "system", "Starting container...");
9958
+ const onedrive = this.core.isOneDrive(config);
9959
+ await killContainer(session.containerName);
9960
+ await spawnContainer(session.containerName, config.claudeToken, {
9961
+ fuse: onedrive
9962
+ });
9963
+ if (onedrive) {
9964
+ const rotated = await writeRcloneMountConfig(
9965
+ session.containerName,
9966
+ this.core.getOneDriveConnection()
9967
+ );
9968
+ this.core.updateOneDriveRefreshToken(rotated);
9969
+ await mountWorkspace(session.containerName, this.core.remotePath());
9970
+ startOneDriveSyncWatcher(
9971
+ session.containerName,
9972
+ (pending, files) => this.emitter.emitOneDriveSync({
9973
+ postId: session.postId,
9974
+ pending,
9975
+ files
9976
+ })
9977
+ );
9978
+ this.emitter.addLog(session, "system", "OneDrive folder mounted.");
9979
+ } else {
9980
+ const cloneUrl = buildCloneUrl(config.repoUrl, config.githubToken);
9981
+ const branch = baseBranch || config.mainBranch || "main";
9982
+ const cacheKey = repoCacheKey(config.repoUrl);
9983
+ const { fromCache } = await cloneRepo(
9984
+ session.containerName,
9985
+ cloneUrl,
9986
+ branch,
9987
+ cacheKey
9988
+ );
9989
+ this.emitter.addLog(
9990
+ session,
9991
+ "system",
9992
+ fromCache ? "Repository updated from cache." : "Repository cloned."
9993
+ );
9994
+ await setGitIdentity(
9995
+ session.containerName,
9996
+ config.gitName,
9997
+ config.gitEmail
9998
+ );
9999
+ }
10000
+ await installRuntimes(this.emitter, session, runtimes);
10001
+ this.emitter.addLog(session, "system", "Container ready.");
10002
+ session.phase = "research";
10003
+ session.step = 1;
10004
+ this.emitter.emitSessionUpdate(session);
10005
+ this.emitter.addLog(session, "system", "\u2500\u2500 Step 1: Research \u2500\u2500");
10006
+ await this.runResearch(
10007
+ session,
10008
+ buildPrompt(prompts_default.research, { task: prompt2 })
10009
+ );
10010
+ if (!this.sessions.has(session.postId)) return;
10011
+ await this.runImplementation(
10012
+ session,
10013
+ buildPrompt(prompts_default.implement),
10014
+ "\u2500\u2500 Step"
10015
+ );
10016
+ }
10017
+ async runImplementation(session, implementPrompt, logPrefix) {
10018
+ const tools = _ClaudeModeEngine.FULL_TOOLS;
10019
+ session.phase = "implement";
10020
+ session.step = 2;
10021
+ this.emitter.emitSessionUpdate(session);
10022
+ this.emitter.addLog(session, "system", `${logPrefix} 2: Implement \u2500\u2500`);
10023
+ const implResult = await this.runClaudeInContainer(
10024
+ session,
10025
+ implementPrompt,
10026
+ tools,
10027
+ session.claudeSessionId
10028
+ );
10029
+ session.claudeSessionId = implResult.sessionId || session.claudeSessionId;
10030
+ if (!this.sessions.has(session.postId)) return;
10031
+ session.phase = "review";
10032
+ session.step = 3;
10033
+ this.emitter.emitSessionUpdate(session);
10034
+ this.emitter.addLog(session, "system", `${logPrefix} 3: Review \u2500\u2500`);
10035
+ const reviewResult = await this.runClaudeInContainer(
10036
+ session,
10037
+ this.agentPrompt(buildPrompt(prompts_default.review)),
10038
+ tools,
10039
+ session.claudeSessionId
10040
+ );
10041
+ session.claudeSessionId = reviewResult.sessionId || session.claudeSessionId;
10042
+ if (!this.sessions.has(session.postId)) return;
10043
+ session.reproSteps = reviewResult.text.trim();
10044
+ await this.extractAndComplete(session);
10045
+ }
10046
+ // On-demand dev-server preview: reuses the live container + Claude session,
10047
+ // maps the port (or a public frpc tunnel), stores the URL. Stays in `complete`.
10048
+ async runPreview(session) {
10049
+ const tools = _ClaudeModeEngine.FULL_TOOLS;
10050
+ session.previewRunning = true;
10051
+ this.emitter.addLog(session, "system", "Setting up live preview...");
10052
+ this.emitter.emitSessionUpdate(session);
10053
+ const portsEnv = (await execInContainer(session.containerName, "echo -n $FACTIII_PORTS")).trim();
10054
+ const publicPreviewEnabled = !!this.core.runnerToken;
10055
+ if (publicPreviewEnabled && this.core.runnerToken) {
10056
+ await installFrpcHarness({
10057
+ containerName: session.containerName,
10058
+ taskId: session.taskId,
10059
+ runnerToken: this.core.runnerToken
10060
+ });
10061
+ }
10062
+ const previewResult = await this.runClaudeInContainer(
10063
+ session,
10064
+ buildPrompt(prompts_default.preview, {
10065
+ ports: portsEnv,
10066
+ publicPreviewBlock: publicPreviewEnabled ? prompts_default.previewPublic.join("\n") : ""
10067
+ }),
10068
+ tools,
10069
+ session.claudeSessionId
10070
+ );
10071
+ session.claudeSessionId = previewResult.sessionId || session.claudeSessionId;
10072
+ if (!this.sessions.has(session.postId)) return;
10073
+ const portMatch = previewResult.text.match(/PREVIEW_PORT:\s*(\d+)/);
10074
+ const previewPort = portMatch ? portMatch[1] : "3000";
10075
+ const urlMatch = previewResult.text.match(/PREVIEW_URL:\s*(\S+)/);
10076
+ if (publicPreviewEnabled && urlMatch) {
10077
+ session.previewUrl = urlMatch[1];
10078
+ this.emitter.addLog(
10079
+ session,
10080
+ "system",
10081
+ `Public preview available at ${session.previewUrl}`
10082
+ );
10083
+ } else {
10084
+ const previewUrl = await getContainerPort(
10085
+ session.containerName,
10086
+ previewPort
10087
+ );
10088
+ if (previewUrl) {
10089
+ session.previewUrl = previewUrl;
10090
+ this.emitter.addLog(
10091
+ session,
10092
+ "system",
10093
+ `Preview available at ${session.previewUrl}`
10094
+ );
10095
+ } else {
10096
+ this.emitter.addLog(
10097
+ session,
10098
+ "system",
10099
+ "Could not map preview port. Server may not be running."
10100
+ );
10101
+ }
10102
+ }
10103
+ this.emitter.emitSessionUpdate(session);
10104
+ }
10105
+ async extractAndComplete(session) {
10106
+ const config = this.core.readConfig();
10107
+ if (this.core.isOneDrive(config)) {
10108
+ session.diff = "";
10109
+ session.changedFiles = [];
10110
+ session.commitMessage = "";
10111
+ session.phase = "complete";
10112
+ this.emitter.emitSessionUpdate(session);
10113
+ return;
10114
+ }
10115
+ this.emitter.addLog(session, "system", "Extracting changes...");
10116
+ const { diff, changedFiles } = await extractDiff(session.containerName);
10117
+ session.diff = diff;
10118
+ session.changedFiles = changedFiles;
10119
+ if (changedFiles.length > 0) {
10120
+ this.emitter.addLog(
10121
+ session,
10122
+ "system",
10123
+ `${changedFiles.length} file${changedFiles.length !== 1 ? "s" : ""} changed.`
10124
+ );
10125
+ this.emitter.addLog(session, "system", "Generating commit message...");
10126
+ session.commitMessage = await generateCommitMessage(
10127
+ session.containerName,
10128
+ diff
10129
+ );
10130
+ } else {
10131
+ this.emitter.addLog(session, "system", "No files changed.");
10132
+ }
10133
+ session.phase = "complete";
10134
+ this.emitter.emitSessionUpdate(session);
10135
+ }
10136
+ // ── Lifecycle / ModeEngine ──
10137
+ start(base, args) {
10138
+ const session = {
10139
+ ...base,
10140
+ claudeSessionId: "",
10141
+ answerResolver: null,
10142
+ proc: null
10143
+ };
10144
+ this.sessions.set(session.postId, session);
10145
+ this.emitter.emitSessionUpdate(session);
10146
+ const onError = (err) => {
10147
+ if (!this.sessions.has(session.postId)) return;
10148
+ session.phase = "error";
10149
+ session.error = err instanceof Error ? err.message : String(err);
10150
+ this.emitter.addLog(session, "error", session.error);
10151
+ this.emitter.emitSessionUpdate(session);
10152
+ };
10153
+ this.runPipeline(
10154
+ session,
10155
+ args.prompt,
10156
+ args.baseBranch,
10157
+ args.runtimes
10158
+ ).catch(onError);
10159
+ }
10160
+ // Triggered from the review (complete) phase; returns the live preview URL.
10161
+ async cardStartPreview({
10162
+ postId: rawPostId
10163
+ }) {
10164
+ const postId = String(rawPostId);
10165
+ const session = this.sessions.get(postId);
10166
+ if (!session) throw new Error("No active session for this card.");
10167
+ if (session.phase !== "complete")
10168
+ throw new Error("Preview is only available once changes are ready.");
10169
+ if (!session.claudeSessionId)
10170
+ throw new Error("No Claude session to resume for preview.");
10171
+ if (session.previewRunning || session.proc)
10172
+ return { previewUrl: session.previewUrl };
10173
+ try {
10174
+ await this.runPreview(session);
10175
+ } finally {
10176
+ session.previewRunning = false;
10177
+ if (this.sessions.has(postId)) this.emitter.emitSessionUpdate(session);
10178
+ }
10179
+ return { previewUrl: session.previewUrl };
10180
+ }
10181
+ cardAnswerQuestion({
10182
+ postId: rawPostId,
10183
+ answer
10184
+ }) {
10185
+ const postId = String(rawPostId);
10186
+ const session = this.sessions.get(postId);
10187
+ if (!session || session.phase !== "awaiting-input" || !session.answerResolver)
10188
+ return;
10189
+ session.answerResolver(answer);
10190
+ }
10191
+ cardFeedback({
10192
+ postId: rawPostId,
10193
+ feedback
10194
+ }) {
10195
+ const postId = String(rawPostId);
10196
+ const session = this.sessions.get(postId);
10197
+ if (!session || session.phase !== "complete") return;
10198
+ if (!session.claudeSessionId) return;
10199
+ session.diff = "";
10200
+ session.changedFiles = [];
10201
+ session.commitMessage = "";
10202
+ session.reproSteps = "";
10203
+ const prompt2 = `The user reviewed your changes and has the following feedback:
10204
+
10205
+ ${feedback}
10206
+
10207
+ ${buildPrompt(prompts_default.implement)}`;
10208
+ this.runImplementation(session, prompt2, "\u2500\u2500 Follow-up").catch((err) => {
10209
+ if (!this.sessions.has(postId)) return;
10210
+ session.phase = "error";
10211
+ session.error = err instanceof Error ? err.message : String(err);
10212
+ this.emitter.addLog(session, "error", session.error);
10213
+ this.emitter.emitSessionUpdate(session);
10214
+ });
10215
+ }
10216
+ listSummaries() {
10217
+ const result = {};
10218
+ for (const [postId, session] of this.sessions) {
10219
+ result[postId] = toSummary(session);
10220
+ }
10221
+ return result;
10222
+ }
10223
+ get(postId) {
10224
+ return this.sessions.get(postId);
10225
+ }
10226
+ sessionWithLogs(postId) {
10227
+ const session = this.sessions.get(postId);
10228
+ if (!session) return null;
10229
+ return { ...toSummary(session), logs: session.logs };
10230
+ }
10231
+ has(postId) {
10232
+ return this.sessions.has(postId);
10233
+ }
10234
+ isRunning(postId) {
10235
+ const s = this.sessions.get(postId);
10236
+ return !!s && (s.phase === "starting" || s.phase === "research" || s.phase === "implement");
10237
+ }
10238
+ remove(postId) {
10239
+ this.sessions.delete(postId);
10240
+ }
10241
+ async kill(postId) {
10242
+ let session = this.sessions.get(postId);
10243
+ if (!session) {
10244
+ for (const [, s] of this.sessions) {
10245
+ if (s.taskId === postId) {
10246
+ session = s;
10247
+ break;
10248
+ }
10249
+ }
10250
+ }
10251
+ if (!session) return;
10252
+ if (session.answerResolver) {
10253
+ session.answerResolver("");
10254
+ session.answerResolver = null;
10255
+ }
10256
+ if (session.proc) {
10257
+ try {
10258
+ session.proc.kill("SIGTERM");
10259
+ } catch {
10260
+ }
10261
+ session.proc = null;
10262
+ }
10263
+ await killContainer(session.containerName);
10264
+ const removedPostId = session.postId;
10265
+ this.sessions.delete(removedPostId);
10266
+ this.emitter.emitSessionRemoved(removedPostId);
10267
+ }
10268
+ destroy() {
10269
+ this.resetBoardSession();
10270
+ for (const [, session] of this.sessions) {
10271
+ if (session.proc) {
10272
+ try {
10273
+ session.proc.kill("SIGTERM");
10274
+ } catch {
10275
+ }
10276
+ }
10277
+ void killContainer(session.containerName);
10278
+ }
10279
+ this.sessions.clear();
10280
+ }
10281
+ };
10282
+
10283
+ // ../../shared/all/helpers/terminal-ai/terminal-mode.ts
10284
+ var BARE_REPO_ROOT = "/home/claude/workspace";
10285
+ function posixNormalize(p) {
10286
+ const parts2 = p.split("/").filter(Boolean);
10287
+ const stack = [];
10288
+ for (const part of parts2) {
10289
+ if (part === ".") continue;
10290
+ if (part === "..") {
10291
+ if (stack.length) stack.pop();
10292
+ continue;
10293
+ }
10294
+ stack.push(part);
10295
+ }
10296
+ return "/" + stack.join("/");
10297
+ }
10298
+ function posixDirname(p) {
10299
+ const i = p.lastIndexOf("/");
10300
+ if (i <= 0) return "/";
10301
+ return p.slice(0, i);
10302
+ }
10303
+ function shEscape(s) {
10304
+ return `'${s.replace(/'/g, `'\\''`)}'`;
10305
+ }
10306
+ var TerminalModeEngine = class {
10307
+ constructor(core, emitter) {
10308
+ this.core = core;
10309
+ this.emitter = emitter;
10310
+ this.sessions = /* @__PURE__ */ new Map();
10311
+ }
10312
+ // ── Lifecycle / ModeEngine ──
10313
+ start(base, args) {
10314
+ const session = {
10315
+ ...base,
10316
+ openedFiles: [],
10317
+ buffers: {},
10318
+ exposed: []
10319
+ };
10320
+ this.sessions.set(session.postId, session);
10321
+ this.emitter.emitSessionUpdate(session);
10322
+ const onError = (err) => {
10323
+ if (!this.sessions.has(session.postId)) return;
10324
+ session.phase = "error";
10325
+ session.error = err instanceof Error ? err.message : String(err);
10326
+ this.emitter.addLog(session, "error", session.error);
10327
+ this.emitter.emitSessionUpdate(session);
10328
+ };
10329
+ this.runBareSession(session, args.baseBranch, args.runtimes ?? []).catch(
10330
+ onError
10331
+ );
10332
+ }
10333
+ listSummaries() {
10334
+ const result = {};
10335
+ for (const [postId, session] of this.sessions) {
10336
+ result[postId] = toSummary(session);
10337
+ }
10338
+ return result;
10339
+ }
10340
+ get(postId) {
10341
+ return this.sessions.get(postId);
10342
+ }
10343
+ sessionWithLogs(postId) {
10344
+ const session = this.sessions.get(postId);
10345
+ if (!session) return null;
10346
+ return { ...toSummary(session), logs: session.logs };
10347
+ }
10348
+ has(postId) {
10349
+ return this.sessions.has(postId);
10350
+ }
10351
+ isRunning(postId) {
10352
+ const s = this.sessions.get(postId);
10353
+ return !!s && (s.phase === "starting" || s.phase === "bare-active");
10354
+ }
10355
+ remove(postId) {
10356
+ this.sessions.delete(postId);
10357
+ }
10358
+ async kill(postId) {
10359
+ let session = this.sessions.get(postId);
10360
+ if (!session) {
10361
+ for (const [, s] of this.sessions) {
10362
+ if (s.taskId === postId) {
10363
+ session = s;
10364
+ break;
10365
+ }
10366
+ }
10367
+ }
10368
+ if (!session) return;
10369
+ await killContainer(session.containerName);
10370
+ const removedPostId = session.postId;
10371
+ this.sessions.delete(removedPostId);
10372
+ this.emitter.emitSessionRemoved(removedPostId);
10373
+ }
10374
+ destroy() {
10375
+ for (const [, session] of this.sessions) {
10376
+ void killContainer(session.containerName);
10377
+ }
10378
+ this.sessions.clear();
10379
+ }
10380
+ /** Active container for a card, or null when none / terminated. */
10381
+ getCardContainerName(postId) {
10382
+ const session = this.sessions.get(String(postId));
10383
+ if (!session) return null;
10384
+ if (session.phase === "error" || session.phase === "done") return null;
10385
+ return session.containerName;
10386
+ }
10387
+ // ── container + Claude CLI, user drives ──
10388
+ async runBareSession(session, baseBranch, runtimes) {
10389
+ if (!this.core.isReady()) {
10390
+ throw new Error(
10391
+ 'Environment not initialized. Open Board AI Settings and click "Initialize Environment".'
10392
+ );
10393
+ }
10394
+ const config = this.core.readConfig();
10395
+ this.emitter.addLog(session, "system", "Starting container...");
10396
+ const onedrive = this.core.isOneDrive(config);
10397
+ await killContainer(session.containerName);
10398
+ await spawnContainer(session.containerName, void 0, { fuse: onedrive });
10399
+ if (onedrive) {
10400
+ const rotated = await writeRcloneMountConfig(
10401
+ session.containerName,
10402
+ this.core.getOneDriveConnection()
9300
10403
  );
10404
+ this.core.updateOneDriveRefreshToken(rotated);
10405
+ await mountWorkspace(session.containerName, this.core.remotePath());
10406
+ startOneDriveSyncWatcher(
10407
+ session.containerName,
10408
+ (pending, files) => this.emitter.emitOneDriveSync({
10409
+ postId: session.postId,
10410
+ pending,
10411
+ files
10412
+ })
10413
+ );
10414
+ this.emitter.addLog(session, "system", "OneDrive folder mounted.");
10415
+ } else {
10416
+ const cloneUrl = buildCloneUrl(config.repoUrl, config.githubToken);
10417
+ const branch = baseBranch || config.mainBranch || "main";
10418
+ const cacheKey = repoCacheKey(config.repoUrl);
10419
+ const { fromCache } = await cloneRepo(
10420
+ session.containerName,
10421
+ cloneUrl,
10422
+ branch,
10423
+ cacheKey
10424
+ );
10425
+ this.emitter.addLog(
10426
+ session,
10427
+ "system",
10428
+ fromCache ? "Repository updated from cache." : "Repository cloned."
10429
+ );
10430
+ await setGitIdentity(
10431
+ session.containerName,
10432
+ config.gitName,
10433
+ config.gitEmail
10434
+ );
10435
+ }
10436
+ if (this.core.runnerToken) {
10437
+ try {
10438
+ await installFrpcHarness({
10439
+ containerName: session.containerName,
10440
+ taskId: session.taskId,
10441
+ runnerToken: this.core.runnerToken
10442
+ });
10443
+ } catch (err) {
10444
+ this.emitter.addLog(
10445
+ session,
10446
+ "system",
10447
+ `Port forwarding unavailable: ${err instanceof Error ? err.message : String(err)}`
10448
+ );
10449
+ }
10450
+ }
10451
+ await installRuntimes(this.emitter, session, runtimes);
10452
+ session.phase = "bare-active";
10453
+ this.emitter.emitSessionUpdate(session);
10454
+ this.emitter.addLog(
10455
+ session,
10456
+ "system",
10457
+ "Container ready. Drive it from the terminal."
10458
+ );
10459
+ }
10460
+ // ── file ops ──
10461
+ getBareContainer(postId) {
10462
+ const session = this.sessions.get(String(postId));
10463
+ if (!session) throw new Error("No active session for this card.");
10464
+ if (session.mode !== "bare") {
10465
+ throw new Error("File ops are only available in Bare mode.");
10466
+ }
10467
+ if (session.phase !== "bare-active" && session.phase !== "bare-review") {
10468
+ throw new Error("Container is not ready.");
10469
+ }
10470
+ return session.containerName;
10471
+ }
10472
+ /**
10473
+ * Resolve a repo-relative path to an absolute container path, rejecting
10474
+ * anything that escapes the repo root.
10475
+ */
10476
+ resolveBarePath(rel) {
10477
+ const normalized = posixNormalize("/" + (rel || "").replace(/^\/+/, ""));
10478
+ const abs = `${BARE_REPO_ROOT}${normalized === "/" ? "" : normalized}`;
10479
+ if (abs !== BARE_REPO_ROOT && !abs.startsWith(BARE_REPO_ROOT + "/")) {
10480
+ throw new Error("Path escapes repo root.");
10481
+ }
10482
+ return abs;
10483
+ }
10484
+ async bareFsList({
10485
+ postId,
10486
+ path: path7
10487
+ }) {
10488
+ const container = this.getBareContainer(postId);
10489
+ const abs = this.resolveBarePath(path7);
10490
+ const relRoot = abs.slice(BARE_REPO_ROOT.length) || "/";
10491
+ const out = await execInContainer(
10492
+ container,
10493
+ `cd ${shEscape(abs)} && for f in .* *; do
10494
+ case "$f" in
10495
+ "."|".."|".git"|"*") continue ;;
10496
+ esac
10497
+ [ -e "$f" ] || continue
10498
+ if [ -d "$f" ]; then echo "d/$f"; else echo "f/$f"; fi
10499
+ done | LC_ALL=C sort`
10500
+ );
10501
+ const entries = [];
10502
+ for (const line of out.split("\n")) {
10503
+ if (!line) continue;
10504
+ const sep = line.indexOf("/");
10505
+ if (sep < 0) continue;
10506
+ const ty = line.slice(0, sep);
10507
+ const name = line.slice(sep + 1);
10508
+ const type = ty === "d" ? "dir" : "file";
10509
+ entries.push({
10510
+ name,
10511
+ path: relRoot === "/" ? `/${name}` : `${relRoot}/${name}`,
10512
+ type
10513
+ });
10514
+ }
10515
+ entries.sort((a, b) => {
10516
+ if (a.type !== b.type) return a.type === "dir" ? -1 : 1;
10517
+ return a.name.localeCompare(b.name);
10518
+ });
10519
+ return entries;
10520
+ }
10521
+ async bareFsSearch({
10522
+ postId
10523
+ }) {
10524
+ const container = this.getBareContainer(postId);
10525
+ const root = BARE_REPO_ROOT;
10526
+ const out = await execInContainer(
10527
+ container,
10528
+ `cd ${shEscape(root)} && (git ls-files --cached --others --exclude-standard 2>/dev/null || find . -type f -not -path './.git/*' | sed 's|^\\./||') | head -n 5000`
10529
+ );
10530
+ const files = [];
10531
+ for (const line of out.split("\n")) {
10532
+ const p = line.trim();
10533
+ if (p) files.push("/" + p);
10534
+ }
10535
+ return files;
10536
+ }
10537
+ async bareFsRead({
10538
+ postId,
10539
+ path: path7
10540
+ }) {
10541
+ const container = this.getBareContainer(postId);
10542
+ const abs = this.resolveBarePath(path7);
10543
+ const b64 = (await execInContainer(container, `base64 -w 0 ${shEscape(abs)}`)).trim();
10544
+ return { content: b64, encoding: "base64" };
10545
+ }
10546
+ async bareFsWrite({
10547
+ postId,
10548
+ path: path7,
10549
+ content,
10550
+ encoding
10551
+ }) {
10552
+ const container = this.getBareContainer(postId);
10553
+ const abs = this.resolveBarePath(path7);
10554
+ const b64 = encoding === "base64" ? content : Buffer.from(content, "utf-8").toString("base64");
10555
+ if (!/^[A-Za-z0-9+/=\n\r]*$/.test(b64)) {
10556
+ throw new Error("Invalid base64 payload.");
10557
+ }
10558
+ await execInContainer(
10559
+ container,
10560
+ `mkdir -p ${shEscape(posixDirname(abs))} && printf %s ${shEscape(b64)} | base64 -d > ${shEscape(abs)}`
10561
+ );
10562
+ const session = this.sessions.get(String(postId));
10563
+ if (session) delete session.buffers[path7];
10564
+ }
10565
+ bareEditorState({
10566
+ postId: rawPostId
10567
+ }) {
10568
+ const postId = String(rawPostId);
10569
+ const session = this.sessions.get(postId);
10570
+ if (!session || session.mode !== "bare") {
10571
+ return { openedFiles: [], buffers: {} };
10572
+ }
10573
+ return {
10574
+ openedFiles: session.openedFiles.slice(),
10575
+ buffers: { ...session.buffers }
10576
+ };
10577
+ }
10578
+ bareOpenFile({
10579
+ postId: rawPostId,
10580
+ path: path7
10581
+ }) {
10582
+ const session = this.sessions.get(String(rawPostId));
10583
+ if (!session || session.mode !== "bare") return;
10584
+ if (!session.openedFiles.includes(path7)) {
10585
+ session.openedFiles.push(path7);
10586
+ }
10587
+ }
10588
+ bareCloseFile({
10589
+ postId: rawPostId,
10590
+ path: path7
10591
+ }) {
10592
+ const session = this.sessions.get(String(rawPostId));
10593
+ if (!session || session.mode !== "bare") return;
10594
+ session.openedFiles = session.openedFiles.filter((p) => p !== path7);
10595
+ delete session.buffers[path7];
10596
+ }
10597
+ bareBufferSet({
10598
+ postId: rawPostId,
10599
+ path: path7,
10600
+ content
10601
+ }) {
10602
+ const session = this.sessions.get(String(rawPostId));
10603
+ if (!session || session.mode !== "bare") return;
10604
+ session.buffers[path7] = content;
10605
+ }
10606
+ async bareFsDelete({
10607
+ postId,
10608
+ path: path7
10609
+ }) {
10610
+ const container = this.getBareContainer(postId);
10611
+ const abs = this.resolveBarePath(path7);
10612
+ if (abs === BARE_REPO_ROOT) {
10613
+ throw new Error("Refusing to delete the repo root.");
10614
+ }
10615
+ await execInContainer(container, `rm -rf ${shEscape(abs)}`);
10616
+ }
10617
+ async bareFsMkdir({
10618
+ postId,
10619
+ path: path7
10620
+ }) {
10621
+ const container = this.getBareContainer(postId);
10622
+ const abs = this.resolveBarePath(path7);
10623
+ await execInContainer(container, `mkdir -p ${shEscape(abs)}`);
10624
+ }
10625
+ async bareReview({
10626
+ postId: rawPostId
10627
+ }) {
10628
+ const postId = String(rawPostId);
10629
+ const session = this.sessions.get(postId);
10630
+ if (!session) throw new Error("No active session for this card.");
10631
+ if (session.mode !== "bare") {
10632
+ throw new Error("bareReview is only valid in Bare mode.");
10633
+ }
10634
+ if (session.phase !== "bare-active" && session.phase !== "bare-review") {
10635
+ throw new Error("Container is not ready.");
10636
+ }
10637
+ const config = this.core.readConfig();
10638
+ if (this.core.isOneDrive(config)) {
10639
+ session.diff = "";
10640
+ session.changedFiles = [];
10641
+ session.commitMessage = "";
10642
+ session.phase = "bare-review";
10643
+ this.emitter.emitSessionUpdate(session);
10644
+ return { diff: "", changedFiles: [], commitMessage: "" };
10645
+ }
10646
+ this.emitter.addLog(session, "system", "Extracting changes...");
10647
+ const { diff, changedFiles } = await extractDiff(session.containerName);
10648
+ session.diff = diff;
10649
+ session.changedFiles = changedFiles;
10650
+ if (changedFiles.length > 0) {
10651
+ this.emitter.addLog(
10652
+ session,
10653
+ "system",
10654
+ `${changedFiles.length} file${changedFiles.length !== 1 ? "s" : ""} changed.`
10655
+ );
10656
+ this.emitter.addLog(session, "system", "Generating commit message...");
10657
+ try {
10658
+ session.commitMessage = await generateCommitMessage(
10659
+ session.containerName,
10660
+ diff
10661
+ );
10662
+ } catch {
10663
+ session.commitMessage = "WIP: changes from bare session";
10664
+ }
10665
+ } else {
10666
+ session.commitMessage = "";
10667
+ this.emitter.addLog(session, "system", "No files changed.");
10668
+ }
10669
+ session.phase = "bare-review";
10670
+ this.emitter.emitSessionUpdate(session);
10671
+ return {
10672
+ diff: session.diff,
10673
+ changedFiles: session.changedFiles,
10674
+ commitMessage: session.commitMessage
10675
+ };
10676
+ }
10677
+ async bareGitStatus({
10678
+ postId: rawPostId
10679
+ }) {
10680
+ const postId = String(rawPostId);
10681
+ const session = this.sessions.get(postId);
10682
+ if (!session) throw new Error("No active session for this card.");
10683
+ if (session.mode !== "bare") {
10684
+ throw new Error("bareGitStatus is only valid in Bare mode.");
10685
+ }
10686
+ if (session.phase !== "bare-active" && session.phase !== "bare-review") {
10687
+ throw new Error("Container is not ready.");
10688
+ }
10689
+ if (this.core.isOneDrive(this.core.readConfig())) {
10690
+ return { diff: "", changedFiles: [] };
10691
+ }
10692
+ return await extractDiff(session.containerName);
10693
+ }
10694
+ async bareListTerminals({
10695
+ postId: rawPostId
10696
+ }) {
10697
+ const postId = String(rawPostId);
10698
+ const session = this.sessions.get(postId);
10699
+ if (!session) return [];
10700
+ if (session.mode !== "bare") return [];
10701
+ if (session.phase !== "bare-active" && session.phase !== "bare-review") {
10702
+ return [];
10703
+ }
10704
+ const sanitizedPostId = postId.replace(/[^a-zA-Z0-9._-]/g, "_");
10705
+ const prefix = `terminal-${sanitizedPostId}-`;
10706
+ try {
10707
+ const stdout = await execInContainer(
10708
+ session.containerName,
10709
+ `tmux list-panes -a -F '#{session_name}|#{pane_current_command}' 2>/dev/null || true`
10710
+ );
10711
+ const byId = /* @__PURE__ */ new Map();
10712
+ for (const raw of stdout.split("\n")) {
10713
+ const line = raw.trim();
10714
+ if (!line.startsWith(prefix)) continue;
10715
+ const rest = line.slice(prefix.length);
10716
+ const sep = rest.indexOf("|");
10717
+ if (sep === -1) continue;
10718
+ const id = rest.slice(0, sep);
10719
+ const cmd = rest.slice(sep + 1).trim() || "bash";
10720
+ if (!byId.has(id)) byId.set(id, cmd);
10721
+ }
10722
+ return Array.from(byId, ([id, name]) => ({ id, name })).sort(
10723
+ (a, b) => (parseInt(a.id, 10) || 0) - (parseInt(b.id, 10) || 0)
10724
+ );
10725
+ } catch {
10726
+ return [];
10727
+ }
10728
+ }
10729
+ // ── port forwarding (factiii-expose / factiii-unexpose) ──
10730
+ async bareExpose({
10731
+ postId,
10732
+ port,
10733
+ name
10734
+ }) {
10735
+ const container = this.getBareContainer(postId);
10736
+ const session = this.sessions.get(String(postId));
10737
+ const safeName = name.trim();
10738
+ if (!/^[a-zA-Z0-9-]+$/.test(safeName)) {
10739
+ throw new Error("Name must be letters, numbers, or dashes.");
10740
+ }
10741
+ if (!Number.isInteger(port) || port < 1 || port > 65535) {
10742
+ throw new Error("Port must be between 1 and 65535.");
10743
+ }
10744
+ const out = (await execInContainer(
10745
+ container,
10746
+ `factiii-expose ${port} ${shEscape(safeName)} 2>&1`
10747
+ )).trim();
10748
+ const url2 = out.split("\n").filter(Boolean).pop() || "";
10749
+ if (!/^https?:\/\//.test(url2)) {
10750
+ throw new Error(out || "Failed to expose port.");
10751
+ }
10752
+ const entry = { name: safeName, port, url: url2 };
10753
+ session.exposed = session.exposed.filter((e) => e.name !== safeName);
10754
+ session.exposed.push(entry);
10755
+ return entry;
10756
+ }
10757
+ async bareUnexpose({
10758
+ postId,
10759
+ name
10760
+ }) {
10761
+ const container = this.getBareContainer(postId);
10762
+ const session = this.sessions.get(String(postId));
10763
+ const safeName = name.trim();
10764
+ if (!/^[a-zA-Z0-9-]+$/.test(safeName)) {
10765
+ throw new Error("Invalid tunnel name.");
9301
10766
  }
9302
- const config = this.configProvider.readConfig(this.spaceSlug);
9303
- this.emitter.addLog(session, "system", "Starting container...");
9304
- const cloneUrl = buildCloneUrl(config.repoUrl, config.githubToken);
9305
- const branch = baseBranch || config.mainBranch || "main";
9306
- const cacheKey = repoCacheKey(config.repoUrl);
9307
- await killContainer(session.containerName);
9308
- await spawnContainer(session.containerName, config.claudeToken);
9309
- const { fromCache } = await cloneRepo(
9310
- session.containerName,
9311
- cloneUrl,
9312
- branch,
9313
- cacheKey
9314
- );
9315
- this.emitter.addLog(
9316
- session,
9317
- "system",
9318
- fromCache ? "Repository updated from cache." : "Repository cloned."
9319
- );
9320
- await setGitIdentity(
9321
- session.containerName,
9322
- config.gitName,
9323
- config.gitEmail
10767
+ await execInContainer(
10768
+ container,
10769
+ `factiii-unexpose ${shEscape(safeName)} 2>&1 || true`
9324
10770
  );
9325
- this.emitter.addLog(session, "system", "Container ready.");
9326
- session.phase = "research";
9327
- session.step = 1;
9328
- this.emitter.emitSessionUpdate(session);
9329
- this.emitter.addLog(session, "system", "\u2500\u2500 Step 1: Research \u2500\u2500");
9330
- await this.runResearch(
9331
- session,
9332
- buildPrompt(prompts_default.research, { task: prompt2 })
10771
+ session.exposed = session.exposed.filter((e) => e.name !== safeName);
10772
+ }
10773
+ bareListExposed({
10774
+ postId
10775
+ }) {
10776
+ const session = this.sessions.get(String(postId));
10777
+ if (!session || session.mode !== "bare") return [];
10778
+ return session.exposed.slice();
10779
+ }
10780
+ };
10781
+
10782
+ // ../../shared/all/helpers/board-agent-core/engine.ts
10783
+ function sanitizeTaskId(taskId) {
10784
+ const sanitized = String(taskId).replace(/[^a-zA-Z0-9_.-]/g, "");
10785
+ if (!sanitized) throw new Error("Invalid taskId");
10786
+ return sanitized;
10787
+ }
10788
+ var BoardAgentEngine = class {
10789
+ constructor(options) {
10790
+ this.core = new ContainerCore(options);
10791
+ this.emitter = options.emitter;
10792
+ this.claude = new ClaudeModeEngine(this.core, this.emitter);
10793
+ this.terminal = new TerminalModeEngine(this.core, this.emitter);
10794
+ }
10795
+ /** The mode engine that currently owns this postId, if any. */
10796
+ owner(postId) {
10797
+ if (this.claude.has(postId)) return this.claude;
10798
+ if (this.terminal.has(postId)) return this.terminal;
10799
+ return null;
10800
+ }
10801
+ // ── Config / setup (→ core) ──
10802
+ getConfig() {
10803
+ return this.core.getConfig();
10804
+ }
10805
+ setConfig(partial) {
10806
+ return this.core.setConfig(partial);
10807
+ }
10808
+ clearToken(key) {
10809
+ return this.core.clearToken(key);
10810
+ }
10811
+ setupStatus() {
10812
+ return this.core.setupStatus();
10813
+ }
10814
+ ensureSetup() {
10815
+ return this.core.ensureSetup();
10816
+ }
10817
+ cardRemoteBranches() {
10818
+ return this.core.cardRemoteBranches();
10819
+ }
10820
+ // Start the host-run device-code flow and return the code to show the user.
10821
+ // The host polls Microsoft and pushes one `onedrive-auth` event when done.
10822
+ async onedriveStartDeviceCode(p) {
10823
+ const start = await startDeviceCode(p.clientId);
10824
+ console.log(
10825
+ `[onedrive] device-code started: userCode=${start.userCode} expiresIn=${start.expiresIn}s interval=${start.interval}s`
9333
10826
  );
9334
- if (!this.sessions.has(session.postId)) return;
9335
- await this.runImplementation(
9336
- session,
9337
- buildPrompt(prompts_default.implement),
9338
- "\u2500\u2500 Step",
9339
- enablePreview
10827
+ void this.pollDeviceCodeUntilDone(
10828
+ p.clientId,
10829
+ start.deviceCode,
10830
+ start.interval,
10831
+ start.expiresIn
9340
10832
  );
10833
+ return {
10834
+ userCode: start.userCode,
10835
+ verificationUri: start.verificationUri,
10836
+ expiresIn: start.expiresIn
10837
+ };
9341
10838
  }
9342
- // ── Board AI: Handlers ──
9343
- async cardExecute({
9344
- postId: rawPostId,
9345
- prompt: prompt2,
9346
- enablePreview,
9347
- baseBranch,
9348
- targetBranch,
9349
- taskTitle,
9350
- effort
9351
- }) {
10839
+ async pollDeviceCodeUntilDone(clientId, deviceCode, interval, expiresIn) {
10840
+ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
10841
+ const deadline = Date.now() + expiresIn * 1e3;
10842
+ let wait = Math.max(interval, 5);
10843
+ while (Date.now() < deadline) {
10844
+ await sleep(wait * 1e3);
10845
+ let res;
10846
+ try {
10847
+ res = await pollDeviceCode(clientId, deviceCode);
10848
+ } catch (err) {
10849
+ console.log(`[onedrive] poll threw (will retry): ${String(err)}`);
10850
+ continue;
10851
+ }
10852
+ console.log(`[onedrive] poll status=${res.status}`);
10853
+ if (res.status === "pending") continue;
10854
+ if (res.status === "slow_down") {
10855
+ wait += 5;
10856
+ continue;
10857
+ }
10858
+ if (res.status === "complete") {
10859
+ try {
10860
+ this.core.saveOneDriveConnection({
10861
+ refreshToken: res.refreshToken,
10862
+ clientId,
10863
+ ...res.appFolder
10864
+ });
10865
+ console.log("[onedrive] connection saved locally");
10866
+ } catch (err) {
10867
+ console.log(
10868
+ `[onedrive] saveOneDriveConnection failed: ${String(err)}`
10869
+ );
10870
+ this.emitter.emitOneDriveAuth({
10871
+ status: "error",
10872
+ message: "Failed to store the OneDrive connection on the host."
10873
+ });
10874
+ return;
10875
+ }
10876
+ this.emitter.emitOneDriveAuth({ status: "complete" });
10877
+ return;
10878
+ }
10879
+ this.emitter.emitOneDriveAuth({
10880
+ status: res.status,
10881
+ message: res.status === "error" ? res.message : void 0
10882
+ });
10883
+ return;
10884
+ }
10885
+ console.log("[onedrive] device code reached deadline without completion");
10886
+ this.emitter.emitOneDriveAuth({ status: "expired" });
10887
+ }
10888
+ onedriveDisconnect() {
10889
+ this.core.clearOneDriveConnection();
10890
+ return { success: true };
10891
+ }
10892
+ // Unmount the workspace (flushing writes) and persist the rotated refresh
10893
+ // token. No-op for non-OneDrive spaces or when nothing is mounted.
10894
+ async onedriveTeardown(p) {
10895
+ const postId = String(p.postId);
10896
+ const session = this.owner(postId)?.get(postId);
10897
+ if (!session) return { success: true };
10898
+ stopOneDriveSyncWatcher(session.containerName);
10899
+ if (!this.core.isOneDrive(this.core.readConfig())) {
10900
+ return { success: true };
10901
+ }
10902
+ try {
10903
+ await unmountWorkspace(session.containerName);
10904
+ } catch {
10905
+ }
10906
+ const refreshToken = await readbackRefreshToken(
10907
+ session.containerName
10908
+ ).catch(() => null);
10909
+ if (refreshToken) this.core.updateOneDriveRefreshToken(refreshToken);
10910
+ return { success: true };
10911
+ }
10912
+ // Pending upload count to seed the UI (0 = synced); live updates come via the
10913
+ // onedrive-sync push. Synced for non-OneDrive spaces or idle sessions.
10914
+ onedriveStatus(p) {
10915
+ const postId = String(p.postId);
10916
+ const session = this.owner(postId)?.get(postId);
10917
+ if (!session || !this.core.isOneDrive(this.core.readConfig())) {
10918
+ return { syncing: false, pending: 0, files: [] };
10919
+ }
10920
+ const pending = onedrivePendingUploads(session.containerName);
10921
+ const files = onedriveUploadingFiles(session.containerName);
10922
+ return { syncing: pending > 0, pending, files };
10923
+ }
10924
+ // ── Ask AI + claude-only controls (→ claude) ──
10925
+ ask(params, sendLog) {
10926
+ return this.claude.ask(params, sendLog);
10927
+ }
10928
+ resetBoardSession() {
10929
+ this.claude.resetBoardSession();
10930
+ }
10931
+ boardSession() {
10932
+ return this.claude.boardSession();
10933
+ }
10934
+ cardStartPreview(payload) {
10935
+ return this.claude.cardStartPreview(payload);
10936
+ }
10937
+ cardAnswerQuestion(payload) {
10938
+ this.claude.cardAnswerQuestion(payload);
10939
+ }
10940
+ cardFeedback(payload) {
10941
+ this.claude.cardFeedback(payload);
10942
+ }
10943
+ // ── Terminal ops (→ terminal) ──
10944
+ bareFsList(p) {
10945
+ return this.terminal.bareFsList(p);
10946
+ }
10947
+ bareFsSearch(p) {
10948
+ return this.terminal.bareFsSearch(p);
10949
+ }
10950
+ bareFsRead(p) {
10951
+ return this.terminal.bareFsRead(p);
10952
+ }
10953
+ bareFsWrite(p) {
10954
+ return this.terminal.bareFsWrite(p);
10955
+ }
10956
+ bareFsDelete(p) {
10957
+ return this.terminal.bareFsDelete(p);
10958
+ }
10959
+ bareFsMkdir(p) {
10960
+ return this.terminal.bareFsMkdir(p);
10961
+ }
10962
+ bareReview(p) {
10963
+ return this.terminal.bareReview(p);
10964
+ }
10965
+ bareGitStatus(p) {
10966
+ return this.terminal.bareGitStatus(p);
10967
+ }
10968
+ bareListTerminals(p) {
10969
+ return this.terminal.bareListTerminals(p);
10970
+ }
10971
+ bareEditorState(p) {
10972
+ return this.terminal.bareEditorState(p);
10973
+ }
10974
+ bareOpenFile(p) {
10975
+ this.terminal.bareOpenFile(p);
10976
+ }
10977
+ bareCloseFile(p) {
10978
+ this.terminal.bareCloseFile(p);
10979
+ }
10980
+ bareBufferSet(p) {
10981
+ this.terminal.bareBufferSet(p);
10982
+ }
10983
+ bareExpose(p) {
10984
+ return this.terminal.bareExpose(p);
10985
+ }
10986
+ bareUnexpose(p) {
10987
+ return this.terminal.bareUnexpose(p);
10988
+ }
10989
+ bareListExposed(p) {
10990
+ return this.terminal.bareListExposed(p);
10991
+ }
10992
+ // ── Cross-mode coordinators ──
10993
+ async cardExecute(payload) {
10994
+ const {
10995
+ postId: rawPostId,
10996
+ prompt: prompt2,
10997
+ baseBranch,
10998
+ targetBranch,
10999
+ taskTitle,
11000
+ effort,
11001
+ mode,
11002
+ runtimes
11003
+ } = payload;
9352
11004
  const postId = String(rawPostId);
9353
- const existing = this.sessions.get(postId);
9354
- if (existing && (existing.phase === "starting" || existing.phase === "research" || existing.phase === "implement")) {
11005
+ if (this.claude.isRunning(postId) || this.terminal.isRunning(postId)) {
9355
11006
  throw new Error("A session is already running for this card.");
9356
11007
  }
9357
- if (existing) {
9358
- await killContainer(existing.containerName);
9359
- this.sessions.delete(postId);
11008
+ const stale = this.owner(postId);
11009
+ if (stale) {
11010
+ const s = stale.get(postId);
11011
+ await killContainer(s.containerName);
11012
+ stale.remove(postId);
9360
11013
  }
9361
11014
  const taskId = sanitizeTaskId(`${postId}-${Date.now()}`);
9362
- const session = {
11015
+ const base = {
9363
11016
  taskId,
9364
11017
  postId,
9365
11018
  phase: "starting",
11019
+ mode,
9366
11020
  step: 1,
9367
11021
  logs: [],
9368
11022
  diff: "",
9369
11023
  changedFiles: [],
9370
11024
  commitMessage: "",
9371
11025
  previewUrl: "",
11026
+ previewRunning: false,
9372
11027
  reproSteps: "",
9373
11028
  pendingQuestion: "",
9374
11029
  error: "",
@@ -9377,196 +11032,47 @@ var BoardAgentEngine = class _BoardAgentEngine {
9377
11032
  targetBranch: targetBranch || "",
9378
11033
  effort: effort || "high",
9379
11034
  todos: [],
9380
- containerName: `factiii-${this.instancePrefix}-card-${taskId}`,
9381
- claudeSessionId: "",
9382
- answerResolver: null,
9383
- previewResolver: null
11035
+ containerName: `factiii-${this.core.instancePrefix}-card-${taskId}`
9384
11036
  };
9385
- this.sessions.set(postId, session);
9386
- this.emitter.emitSessionUpdate(session);
9387
- this.runPipeline(
9388
- session,
9389
- prompt2,
9390
- enablePreview ?? false,
9391
- baseBranch ?? ""
9392
- ).catch((err) => {
9393
- if (!this.sessions.has(postId)) return;
9394
- session.phase = "error";
9395
- session.error = err instanceof Error ? err.message : String(err);
9396
- this.emitter.addLog(session, "error", session.error);
9397
- this.emitter.emitSessionUpdate(session);
9398
- });
9399
- return { taskId, postId };
9400
- }
9401
- static {
9402
- this.FULL_TOOLS = "Read,Grep,Glob,Agent,Edit,Write,Bash,TodoWrite";
9403
- }
9404
- async runImplementation(session, implementPrompt, logPrefix, enablePreview = false) {
9405
- const tools = _BoardAgentEngine.FULL_TOOLS;
9406
- session.phase = "implement";
9407
- session.step = 2;
9408
- this.emitter.emitSessionUpdate(session);
9409
- this.emitter.addLog(session, "system", `${logPrefix} 2: Implement \u2500\u2500`);
9410
- const implResult = await this.runClaudeInContainer(
9411
- session,
9412
- implementPrompt,
9413
- tools,
9414
- session.claudeSessionId
9415
- );
9416
- session.claudeSessionId = implResult.sessionId || session.claudeSessionId;
9417
- if (!this.sessions.has(session.postId)) return;
9418
- if (enablePreview) {
9419
- session.phase = "preview";
9420
- session.step = 3;
9421
- this.emitter.emitSessionUpdate(session);
9422
- this.emitter.addLog(session, "system", `${logPrefix} 3: Preview \u2500\u2500`);
9423
- const portsEnv = (await execInContainer(session.containerName, "echo -n $FACTIII_PORTS")).trim();
9424
- const publicPreviewEnabled = !!this.runnerToken;
9425
- if (publicPreviewEnabled && this.runnerToken) {
9426
- await installFrpcHarness({
9427
- containerName: session.containerName,
9428
- taskId: session.taskId,
9429
- runnerToken: this.runnerToken
9430
- });
9431
- }
9432
- const previewResult = await this.runClaudeInContainer(
9433
- session,
9434
- buildPrompt(prompts_default.preview, {
9435
- ports: portsEnv,
9436
- publicPreviewBlock: publicPreviewEnabled ? prompts_default.previewPublic.join("\n") : ""
9437
- }),
9438
- tools,
9439
- session.claudeSessionId
9440
- );
9441
- session.claudeSessionId = previewResult.sessionId || session.claudeSessionId;
9442
- if (!this.sessions.has(session.postId)) return;
9443
- const portMatch = previewResult.text.match(/PREVIEW_PORT:\s*(\d+)/);
9444
- const previewPort = portMatch ? portMatch[1] : "3000";
9445
- const urlMatch = previewResult.text.match(/PREVIEW_URL:\s*(\S+)/);
9446
- if (publicPreviewEnabled && urlMatch) {
9447
- session.previewUrl = urlMatch[1];
9448
- this.emitter.addLog(
9449
- session,
9450
- "system",
9451
- `Public preview available at ${session.previewUrl}`
9452
- );
9453
- } else {
9454
- const previewUrl = await getContainerPort(
9455
- session.containerName,
9456
- previewPort
9457
- );
9458
- if (previewUrl) {
9459
- session.previewUrl = previewUrl;
9460
- this.emitter.addLog(
9461
- session,
9462
- "system",
9463
- `Preview available at ${session.previewUrl}`
9464
- );
9465
- } else {
9466
- this.emitter.addLog(
9467
- session,
9468
- "system",
9469
- "Could not map preview port. Server may not be running."
9470
- );
9471
- }
9472
- }
9473
- this.emitter.emitSessionUpdate(session);
9474
- await new Promise((resolve) => {
9475
- session.previewResolver = resolve;
11037
+ if (mode === "bare") {
11038
+ this.terminal.start(base, {
11039
+ baseBranch: baseBranch ?? "",
11040
+ runtimes: runtimes ?? []
9476
11041
  });
9477
- session.previewResolver = null;
9478
- if (!this.sessions.has(session.postId)) return;
9479
- }
9480
- session.phase = "review";
9481
- session.step = 3;
9482
- this.emitter.emitSessionUpdate(session);
9483
- this.emitter.addLog(session, "system", `${logPrefix} 3: Review \u2500\u2500`);
9484
- const reviewResult = await this.runClaudeInContainer(
9485
- session,
9486
- buildPrompt(prompts_default.review),
9487
- tools,
9488
- session.claudeSessionId
9489
- );
9490
- session.claudeSessionId = reviewResult.sessionId || session.claudeSessionId;
9491
- if (!this.sessions.has(session.postId)) return;
9492
- session.reproSteps = reviewResult.text.trim();
9493
- await this.extractAndComplete(session);
9494
- }
9495
- async extractAndComplete(session) {
9496
- this.emitter.addLog(session, "system", "Extracting changes...");
9497
- const { diff, changedFiles } = await extractDiff(session.containerName);
9498
- session.diff = diff;
9499
- session.changedFiles = changedFiles;
9500
- if (changedFiles.length > 0) {
9501
- this.emitter.addLog(
9502
- session,
9503
- "system",
9504
- `${changedFiles.length} file${changedFiles.length !== 1 ? "s" : ""} changed.`
9505
- );
9506
- this.emitter.addLog(session, "system", "Generating commit message...");
9507
- session.commitMessage = await generateCommitMessage(
9508
- session.containerName,
9509
- diff
9510
- );
9511
11042
  } else {
9512
- this.emitter.addLog(session, "system", "No files changed.");
11043
+ this.claude.start(base, {
11044
+ prompt: prompt2,
11045
+ baseBranch: baseBranch ?? "",
11046
+ runtimes: runtimes ?? []
11047
+ });
9513
11048
  }
9514
- session.phase = "complete";
9515
- this.emitter.emitSessionUpdate(session);
11049
+ return { taskId, postId };
9516
11050
  }
9517
- cardFinishPreview({ postId: rawPostId }) {
9518
- const postId = String(rawPostId);
9519
- const session = this.sessions.get(postId);
9520
- if (!session) throw new Error("No active session for this card.");
9521
- if (session.phase !== "preview")
9522
- throw new Error("Session is not in preview phase.");
9523
- if (!session.previewResolver) return;
9524
- this.emitter.addLog(session, "system", "Finishing preview...");
9525
- session.previewResolver();
11051
+ /**
11052
+ * Look up the active container for a card. Used by the terminal bridge to
11053
+ * map postId to the docker container it should attach a shell to.
11054
+ */
11055
+ getCardContainerName(postId) {
11056
+ const session = this.claude.get(String(postId)) ?? this.terminal.get(String(postId));
11057
+ if (!session) return null;
11058
+ if (session.phase === "error" || session.phase === "done") return null;
11059
+ return session.containerName;
9526
11060
  }
9527
11061
  cardSessions() {
9528
- const result = {};
9529
- for (const [postId, session] of this.sessions) {
9530
- result[postId] = toSummary(session);
9531
- }
9532
- return result;
11062
+ return { ...this.terminal.listSummaries(), ...this.claude.listSummaries() };
9533
11063
  }
9534
- cardSession({
9535
- postId
9536
- }) {
9537
- const session = this.sessions.get(String(postId));
9538
- if (!session) return null;
9539
- return { ...toSummary(session), logs: session.logs };
11064
+ cardSession({ postId }) {
11065
+ return this.claude.sessionWithLogs(String(postId)) ?? this.terminal.sessionWithLogs(String(postId));
9540
11066
  }
9541
11067
  async cardDismiss({ postId }) {
9542
- const session = this.sessions.get(String(postId));
9543
- if (!session) return;
11068
+ const owner = this.owner(String(postId));
11069
+ if (!owner) return;
11070
+ const session = owner.get(String(postId));
11071
+ await this.onedriveTeardown({ postId: String(postId) });
9544
11072
  await killContainer(session.containerName);
9545
- this.sessions.delete(String(postId));
11073
+ owner.remove(String(postId));
9546
11074
  this.emitter.emitSessionRemoved(String(postId));
9547
11075
  }
9548
- setupStatus() {
9549
- if (this.containerName) return { phase: "ready" };
9550
- if (this.setupPromise) return { phase: "running" };
9551
- return { phase: "idle" };
9552
- }
9553
- async ensureSetup() {
9554
- await this.ensureContainer();
9555
- return { ready: true };
9556
- }
9557
- isReady() {
9558
- return this.containerName !== null;
9559
- }
9560
- async cardRemoteBranches() {
9561
- const config = this.configProvider.readConfig(this.spaceSlug);
9562
- if (!config.githubToken || !config.repoUrl) return [];
9563
- if (!this.isReady()) {
9564
- throw new Error(
9565
- 'Environment not initialized. Open Board AI Settings and click "Initialize Environment".'
9566
- );
9567
- }
9568
- return listRemoteBranches(this.containerName);
9569
- }
9570
11076
  async cardFinalize({
9571
11077
  postId: rawPostId,
9572
11078
  apply,
@@ -9574,128 +11080,79 @@ var BoardAgentEngine = class _BoardAgentEngine {
9574
11080
  commitMessage
9575
11081
  }) {
9576
11082
  const postId = String(rawPostId);
9577
- const session = this.sessions.get(postId);
9578
- if (!session) throw new Error("No active session for this card.");
11083
+ const owner = this.owner(postId);
11084
+ if (!owner) throw new Error("No active session for this card.");
11085
+ const session = owner.get(postId);
11086
+ const config = this.core.readConfig();
11087
+ if (this.core.isOneDrive(config)) {
11088
+ if (apply) {
11089
+ session.phase = "done";
11090
+ this.emitter.addLog(session, "system", "Changes are live on OneDrive.");
11091
+ this.emitter.emitSessionUpdate(session);
11092
+ return { applied: true, branchName: "" };
11093
+ }
11094
+ await this.onedriveTeardown({ postId });
11095
+ await killContainer(session.containerName);
11096
+ owner.remove(postId);
11097
+ this.emitter.emitSessionRemoved(postId);
11098
+ return { applied: false, branchName: "" };
11099
+ }
9579
11100
  if (apply && branchName) {
9580
- const config = this.configProvider.readConfig(this.spaceSlug);
9581
- await commitAndPush(
9582
- session.containerName,
9583
- branchName,
9584
- commitMessage || `Board AI: ${branchName}`,
9585
- {
9586
- gitName: config.gitName,
9587
- gitEmail: config.gitEmail
11101
+ let pushResult;
11102
+ try {
11103
+ pushResult = await commitAndPush(
11104
+ session.containerName,
11105
+ branchName,
11106
+ commitMessage || `Board AI: ${branchName}`,
11107
+ {
11108
+ gitName: config.gitName,
11109
+ gitEmail: config.gitEmail
11110
+ }
11111
+ );
11112
+ } catch (err) {
11113
+ if (err instanceof NoChangesError) {
11114
+ this.emitter.addLog(
11115
+ session,
11116
+ "system",
11117
+ "No changes to push - container kept alive for inspection."
11118
+ );
11119
+ } else {
11120
+ this.emitter.addLog(
11121
+ session,
11122
+ "error",
11123
+ `Push failed: ${err instanceof Error ? err.message : String(err)}`
11124
+ );
9588
11125
  }
9589
- );
9590
- }
9591
- await killContainer(session.containerName);
9592
- if (apply) {
11126
+ this.emitter.emitSessionUpdate(session);
11127
+ throw err;
11128
+ }
9593
11129
  session.phase = "done";
9594
11130
  this.emitter.addLog(
9595
11131
  session,
9596
11132
  "system",
9597
- `Changes committed to ${branchName}`
11133
+ `Pushed ${pushResult.pushedSha.slice(0, 8)} to origin/${pushResult.branch}`
9598
11134
  );
9599
11135
  this.emitter.emitSessionUpdate(session);
9600
- setTimeout(() => {
9601
- if (this.sessions.get(postId)?.phase === "done") {
9602
- this.sessions.delete(postId);
9603
- this.emitter.emitSessionRemoved(postId);
9604
- }
9605
- }, 1e4);
9606
- } else {
9607
- this.sessions.delete(postId);
9608
- this.emitter.emitSessionRemoved(postId);
11136
+ return { applied: true, branchName: pushResult.branch };
9609
11137
  }
11138
+ await killContainer(session.containerName);
11139
+ owner.remove(postId);
11140
+ this.emitter.emitSessionRemoved(postId);
9610
11141
  return { applied: apply, branchName: branchName || "" };
9611
11142
  }
9612
- cardAnswerQuestion({
9613
- postId: rawPostId,
9614
- answer
9615
- }) {
9616
- const postId = String(rawPostId);
9617
- const session = this.sessions.get(postId);
9618
- if (!session || session.phase !== "awaiting-input" || !session.answerResolver)
9619
- return;
9620
- session.answerResolver(answer);
9621
- }
9622
- cardFeedback({
9623
- postId: rawPostId,
9624
- feedback
9625
- }) {
9626
- const postId = String(rawPostId);
9627
- const session = this.sessions.get(postId);
9628
- if (!session || session.phase !== "complete") return;
9629
- if (!session.claudeSessionId) return;
9630
- session.diff = "";
9631
- session.changedFiles = [];
9632
- session.commitMessage = "";
9633
- session.reproSteps = "";
9634
- const prompt2 = `The user reviewed your changes and has the following feedback:
9635
-
9636
- ${feedback}
9637
-
9638
- ${buildPrompt(prompts_default.implement)}`;
9639
- this.runImplementation(session, prompt2, "\u2500\u2500 Follow-up").catch((err) => {
9640
- if (!this.sessions.has(postId)) return;
9641
- session.phase = "error";
9642
- session.error = err instanceof Error ? err.message : String(err);
9643
- this.emitter.addLog(session, "error", session.error);
9644
- this.emitter.emitSessionUpdate(session);
9645
- });
9646
- }
9647
11143
  async cardKill({ postId: rawPostId }) {
9648
11144
  const postId = String(rawPostId);
9649
- let session = this.sessions.get(postId);
9650
- if (!session) {
9651
- for (const [, s] of this.sessions) {
9652
- if (s.taskId === rawPostId) {
9653
- session = s;
9654
- break;
9655
- }
9656
- }
9657
- }
9658
- if (!session) return;
9659
- if (session.answerResolver) {
9660
- session.answerResolver("");
9661
- session.answerResolver = null;
9662
- }
9663
- if (session.previewResolver) {
9664
- session.previewResolver();
9665
- session.previewResolver = null;
9666
- }
9667
- const proc = this.cardProcesses.get(session.postId);
9668
- if (proc) {
9669
- try {
9670
- proc.kill("SIGTERM");
9671
- } catch {
9672
- }
9673
- this.cardProcesses.delete(session.postId);
9674
- }
9675
- await killContainer(session.containerName);
9676
- const removedPostId = session.postId;
9677
- this.sessions.delete(removedPostId);
9678
- this.emitter.emitSessionRemoved(removedPostId);
11145
+ await this.onedriveTeardown({ postId });
11146
+ if (this.claude.has(postId)) return this.claude.kill(postId);
11147
+ if (this.terminal.has(postId)) return this.terminal.kill(postId);
11148
+ await this.claude.kill(postId);
11149
+ await this.terminal.kill(postId);
9679
11150
  }
9680
11151
  // ── Lifecycle ──
9681
11152
  destroy() {
9682
- this.resetBoardSession();
9683
- for (const [, proc] of this.cardProcesses) {
9684
- try {
9685
- proc.kill("SIGTERM");
9686
- } catch {
9687
- }
9688
- }
9689
- this.cardProcesses.clear();
9690
- for (const [, session] of this.sessions) {
9691
- void killContainer(session.containerName);
9692
- }
9693
- this.sessions.clear();
9694
- if (this.containerName) {
9695
- void killContainer(this.containerName);
9696
- this.containerName = null;
9697
- this.setupPromise = null;
9698
- }
11153
+ this.claude.destroy();
11154
+ this.terminal.destroy();
11155
+ this.core.destroy();
9699
11156
  }
9700
11157
  /**
9701
11158
  * Route a single action/payload pair to the right engine method.
@@ -9725,13 +11182,16 @@ ${buildPrompt(prompts_default.implement)}`;
9725
11182
  case "resetBoardSession":
9726
11183
  this.resetBoardSession();
9727
11184
  return void 0;
11185
+ case "boardSession":
11186
+ return this.boardSession();
9728
11187
  case "cardExecute":
9729
11188
  return await this.cardExecute(
9730
11189
  payload
9731
11190
  );
9732
- case "cardFinishPreview":
9733
- this.cardFinishPreview(payload);
9734
- return void 0;
11191
+ case "cardStartPreview":
11192
+ return await this.cardStartPreview(
11193
+ payload
11194
+ );
9735
11195
  case "cardSessions":
9736
11196
  return this.cardSessions();
9737
11197
  case "cardSession":
@@ -9747,6 +11207,20 @@ ${buildPrompt(prompts_default.implement)}`;
9747
11207
  return await this.ensureSetup();
9748
11208
  case "cardRemoteBranches":
9749
11209
  return await this.cardRemoteBranches();
11210
+ case "onedriveStartDeviceCode":
11211
+ return await this.onedriveStartDeviceCode(
11212
+ payload
11213
+ );
11214
+ case "onedriveDisconnect":
11215
+ return this.onedriveDisconnect();
11216
+ case "onedriveTeardown":
11217
+ return await this.onedriveTeardown(
11218
+ payload
11219
+ );
11220
+ case "onedriveStatus":
11221
+ return this.onedriveStatus(
11222
+ payload
11223
+ );
9750
11224
  case "cardFinalize":
9751
11225
  return await this.cardFinalize(
9752
11226
  payload
@@ -9760,6 +11234,77 @@ ${buildPrompt(prompts_default.implement)}`;
9760
11234
  case "cardKill":
9761
11235
  await this.cardKill(payload);
9762
11236
  return void 0;
11237
+ case "bareFsList":
11238
+ return await this.bareFsList(
11239
+ payload
11240
+ );
11241
+ case "bareFsSearch":
11242
+ return await this.bareFsSearch(
11243
+ payload
11244
+ );
11245
+ case "bareFsRead":
11246
+ return await this.bareFsRead(
11247
+ payload
11248
+ );
11249
+ case "bareFsWrite":
11250
+ await this.bareFsWrite(
11251
+ payload
11252
+ );
11253
+ return void 0;
11254
+ case "bareFsDelete":
11255
+ await this.bareFsDelete(
11256
+ payload
11257
+ );
11258
+ return void 0;
11259
+ case "bareFsMkdir":
11260
+ await this.bareFsMkdir(
11261
+ payload
11262
+ );
11263
+ return void 0;
11264
+ case "bareReview":
11265
+ return await this.bareReview(
11266
+ payload
11267
+ );
11268
+ case "bareGitStatus":
11269
+ return await this.bareGitStatus(
11270
+ payload
11271
+ );
11272
+ case "bareListTerminals":
11273
+ return await this.bareListTerminals(
11274
+ payload
11275
+ );
11276
+ case "bareEditorState":
11277
+ return this.bareEditorState(
11278
+ payload
11279
+ );
11280
+ case "bareOpenFile":
11281
+ this.bareOpenFile(
11282
+ payload
11283
+ );
11284
+ return void 0;
11285
+ case "bareCloseFile":
11286
+ this.bareCloseFile(
11287
+ payload
11288
+ );
11289
+ return void 0;
11290
+ case "bareBufferSet":
11291
+ this.bareBufferSet(
11292
+ payload
11293
+ );
11294
+ return void 0;
11295
+ case "bareExpose":
11296
+ return await this.bareExpose(
11297
+ payload
11298
+ );
11299
+ case "bareUnexpose":
11300
+ await this.bareUnexpose(
11301
+ payload
11302
+ );
11303
+ return void 0;
11304
+ case "bareListExposed":
11305
+ return this.bareListExposed(
11306
+ payload
11307
+ );
9763
11308
  default:
9764
11309
  throw new Error(`Unknown board-agent action: ${String(action)}`);
9765
11310
  }
@@ -9816,7 +11361,7 @@ function makeChunkReassembler() {
9816
11361
 
9817
11362
  // src/daemon.ts
9818
11363
  var import_node_datachannel = require("node-datachannel");
9819
- var import_path4 = __toESM(require("path"));
11364
+ var import_path5 = __toESM(require("path"));
9820
11365
 
9821
11366
  // ../../node_modules/engine.io-client/build/esm-debug/transports/polling-xhr.node.js
9822
11367
  var XMLHttpRequestModule = __toESM(require_XMLHttpRequest(), 1);
@@ -10530,8 +12075,8 @@ var BaseXHR = class extends Polling {
10530
12075
  /**
10531
12076
  * Sends data.
10532
12077
  *
10533
- * @param {String} data to send.
10534
- * @param {Function} called upon flush.
12078
+ * @param {String} data - data to send.
12079
+ * @param {Function} fn - called upon flush.
10535
12080
  * @private
10536
12081
  */
10537
12082
  doWrite(data, fn) {
@@ -10749,10 +12294,13 @@ var XHR = class extends BaseXHR {
10749
12294
  }
10750
12295
  };
10751
12296
 
10752
- // ../../node_modules/engine.io-client/node_modules/ws/wrapper.mjs
12297
+ // ../../node_modules/ws/wrapper.mjs
10753
12298
  var import_stream = __toESM(require_stream(), 1);
12299
+ var import_extension = __toESM(require_extension(), 1);
12300
+ var import_permessage_deflate = __toESM(require_permessage_deflate(), 1);
10754
12301
  var import_receiver = __toESM(require_receiver(), 1);
10755
12302
  var import_sender = __toESM(require_sender(), 1);
12303
+ var import_subprotocol = __toESM(require_subprotocol(), 1);
10756
12304
  var import_websocket = __toESM(require_websocket(), 1);
10757
12305
  var import_websocket_server = __toESM(require_websocket_server(), 1);
10758
12306
 
@@ -10993,12 +12541,12 @@ function parse2(str) {
10993
12541
  uri.queryKey = queryKey(uri, uri["query"]);
10994
12542
  return uri;
10995
12543
  }
10996
- function pathNames(obj, path6) {
10997
- const regx = /\/{2,9}/g, names = path6.replace(regx, "/").split("/");
10998
- if (path6.slice(0, 1) == "/" || path6.length === 0) {
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) {
10999
12547
  names.splice(0, 1);
11000
12548
  }
11001
- if (path6.slice(-1) == "/") {
12549
+ if (path7.slice(-1) == "/") {
11002
12550
  names.splice(names.length - 1, 1);
11003
12551
  }
11004
12552
  return names;
@@ -11353,7 +12901,7 @@ var SocketWithoutUpgrade = class _SocketWithoutUpgrade extends import_component_
11353
12901
  /**
11354
12902
  * Sends a packet.
11355
12903
  *
11356
- * @param {String} type: packet type.
12904
+ * @param {String} type - packet type.
11357
12905
  * @param {String} data.
11358
12906
  * @param {Object} options.
11359
12907
  * @param {Function} fn - callback function.
@@ -11602,11 +13150,12 @@ var SocketWithUpgrade = class extends SocketWithoutUpgrade {
11602
13150
  };
11603
13151
  var Socket = class extends SocketWithUpgrade {
11604
13152
  constructor(uri, opts = {}) {
11605
- const o = typeof uri === "object" ? uri : opts;
13153
+ const isOptionsOnly = typeof uri === "object";
13154
+ const o = isOptionsOnly ? { ...uri } : { ...opts };
11606
13155
  if (!o.transports || o.transports && typeof o.transports[0] === "string") {
11607
13156
  o.transports = (o.transports || ["polling", "websocket", "webtransport"]).map((transportName) => transports[transportName]).filter((t) => !!t);
11608
13157
  }
11609
- super(uri, o);
13158
+ super(isOptionsOnly ? o : uri, o);
11610
13159
  }
11611
13160
  };
11612
13161
 
@@ -11616,7 +13165,7 @@ var protocol2 = Socket.protocol;
11616
13165
  // ../../node_modules/socket.io-client/build/esm-debug/url.js
11617
13166
  var import_debug7 = __toESM(require_src(), 1);
11618
13167
  var debug7 = (0, import_debug7.default)("socket.io-client:url");
11619
- function url(uri, path6 = "", loc) {
13168
+ function url(uri, path7 = "", loc) {
11620
13169
  let obj = uri;
11621
13170
  loc = loc || typeof location !== "undefined" && location;
11622
13171
  if (null == uri)
@@ -11650,7 +13199,7 @@ function url(uri, path6 = "", loc) {
11650
13199
  obj.path = obj.path || "/";
11651
13200
  const ipv6 = obj.host.indexOf(":") !== -1;
11652
13201
  const host = ipv6 ? "[" + obj.host + "]" : obj.host;
11653
- obj.id = obj.protocol + "://" + host + ":" + obj.port + path6;
13202
+ obj.id = obj.protocol + "://" + host + ":" + obj.port + path7;
11654
13203
  obj.href = obj.protocol + "://" + host + (loc && loc.port === obj.port ? "" : ":" + obj.port);
11655
13204
  return obj;
11656
13205
  }
@@ -13284,8 +14833,8 @@ function lookup(uri, opts) {
13284
14833
  const parsed = url(uri, opts.path || "/socket.io");
13285
14834
  const source = parsed.source;
13286
14835
  const id = parsed.id;
13287
- const path6 = parsed.path;
13288
- const sameNamespace = cache[id] && path6 in cache[id]["nsps"];
14836
+ const path7 = parsed.path;
14837
+ const sameNamespace = cache[id] && path7 in cache[id]["nsps"];
13289
14838
  const newConnection = opts.forceNew || opts["force new connection"] || false === opts.multiplex || sameNamespace;
13290
14839
  let io;
13291
14840
  if (newConnection) {
@@ -13310,44 +14859,296 @@ Object.assign(lookup, {
13310
14859
  connect: lookup
13311
14860
  });
13312
14861
 
13313
- // src/agent-adapter.ts
14862
+ // ../../shared/all/helpers/terminal-ai/terminal.ts
14863
+ var import_child_process6 = require("child_process");
14864
+ var import_dockerode = __toESM(require("dockerode"));
13314
14865
  var import_fs3 = __toESM(require("fs"));
13315
14866
  var import_os2 = __toESM(require("os"));
13316
14867
  var import_path3 = __toESM(require("path"));
13317
- var CONFIG_DIR2 = import_path3.default.join(import_os2.default.homedir(), ".factiii-runner");
14868
+ function resolveDocker() {
14869
+ if (process.env.DOCKER_HOST) return new import_dockerode.default();
14870
+ try {
14871
+ const out = (0, import_child_process6.execFileSync)(
14872
+ "docker",
14873
+ ["context", "inspect", "--format", "{{.Endpoints.docker.Host}}"],
14874
+ { encoding: "utf-8", stdio: ["ignore", "pipe", "ignore"] }
14875
+ ).trim();
14876
+ if (out.startsWith("unix://")) {
14877
+ const p = out.slice("unix://".length);
14878
+ if (import_fs3.default.existsSync(p)) return new import_dockerode.default({ socketPath: p });
14879
+ } else if (out) {
14880
+ process.env.DOCKER_HOST = out;
14881
+ return new import_dockerode.default();
14882
+ }
14883
+ } catch {
14884
+ }
14885
+ const candidates = [
14886
+ "/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")
14892
+ ];
14893
+ for (const p of candidates) {
14894
+ try {
14895
+ import_fs3.default.statSync(p);
14896
+ return new import_dockerode.default({ socketPath: p });
14897
+ } catch {
14898
+ }
14899
+ }
14900
+ return new import_dockerode.default();
14901
+ }
14902
+ var docker = resolveDocker();
14903
+ var TERMINAL_INIT_SCRIPT = [
14904
+ `export PS1='\\[\\e[1;32m\\]user@bare\\[\\e[0m\\]:\\w\\$ '`,
14905
+ `exec bash --norc -i`
14906
+ ].join("; ");
14907
+ function tmuxName(terminalKey) {
14908
+ const safe = terminalKey.replace(/[^a-zA-Z0-9._-]/g, "_").slice(0, 80);
14909
+ return `terminal-${safe}`;
14910
+ }
14911
+ var TerminalManager = class {
14912
+ constructor(send, getCardContainerName) {
14913
+ this.send = send;
14914
+ this.getCardContainerName = getCardContainerName;
14915
+ this.sessions = /* @__PURE__ */ new Map();
14916
+ }
14917
+ handle(msg) {
14918
+ switch (msg.op) {
14919
+ case "open":
14920
+ void this.open(msg);
14921
+ return;
14922
+ case "stdin":
14923
+ this.stdin(msg.sessionId, msg.data);
14924
+ return;
14925
+ case "resize":
14926
+ void this.resize(msg.sessionId, msg.cols, msg.rows);
14927
+ return;
14928
+ case "close":
14929
+ this.close(msg.sessionId);
14930
+ return;
14931
+ case "kill":
14932
+ void this.killTmuxSession(msg.postId, msg.terminalKey);
14933
+ return;
14934
+ }
14935
+ }
14936
+ async open(msg) {
14937
+ if (this.sessions.has(msg.sessionId)) {
14938
+ this.emit({
14939
+ type: "terminal",
14940
+ op: "error",
14941
+ sessionId: msg.sessionId,
14942
+ message: "Session already open."
14943
+ });
14944
+ return;
14945
+ }
14946
+ const containerName = this.getCardContainerName(msg.postId);
14947
+ if (!containerName) {
14948
+ this.emit({
14949
+ type: "terminal",
14950
+ op: "error",
14951
+ sessionId: msg.sessionId,
14952
+ message: `No active card session for postId=${msg.postId}.`
14953
+ });
14954
+ return;
14955
+ }
14956
+ const tmuxSession = tmuxName(msg.terminalKey);
14957
+ try {
14958
+ const exec = await docker.getContainer(containerName).exec({
14959
+ Cmd: [
14960
+ "tmux",
14961
+ // -u forces UTF-8 regardless of the locale. Slim base images often
14962
+ // ship without a UTF-8 locale, so tmux's auto-detect falls through to
14963
+ // ASCII line-draw chars (the "qqqq" mess in place of ─).
14964
+ "-u",
14965
+ "new-session",
14966
+ "-A",
14967
+ "-s",
14968
+ tmuxSession,
14969
+ "--",
14970
+ "bash",
14971
+ "-c",
14972
+ TERMINAL_INIT_SCRIPT
14973
+ ],
14974
+ AttachStdin: true,
14975
+ AttachStdout: true,
14976
+ AttachStderr: true,
14977
+ Tty: true,
14978
+ // Drop the user into the cloned repo so claude/git/etc. just work.
14979
+ WorkingDir: "/home/claude/workspace",
14980
+ Env: ["TERM=xterm-256color"],
14981
+ // The client follows up with a resize op carrying real dimensions.
14982
+ ConsoleSize: [msg.rows, msg.cols]
14983
+ });
14984
+ const stream = await exec.start({
14985
+ hijack: true,
14986
+ stdin: true,
14987
+ Tty: true
14988
+ });
14989
+ this.sessions.set(msg.sessionId, { exec, stream, containerName });
14990
+ stream.on("data", (chunk) => {
14991
+ this.emit({
14992
+ type: "terminal",
14993
+ op: "stdout",
14994
+ sessionId: msg.sessionId,
14995
+ data: chunk.toString("base64")
14996
+ });
14997
+ });
14998
+ stream.on("error", (err) => {
14999
+ this.cleanup(msg.sessionId);
15000
+ this.emit({
15001
+ type: "terminal",
15002
+ op: "error",
15003
+ sessionId: msg.sessionId,
15004
+ message: err.message
15005
+ });
15006
+ });
15007
+ stream.on("end", () => {
15008
+ void this.finalize(msg.sessionId, exec);
15009
+ });
15010
+ stream.on("close", () => {
15011
+ void this.finalize(msg.sessionId, exec);
15012
+ });
15013
+ this.emit({
15014
+ type: "terminal",
15015
+ op: "opened",
15016
+ sessionId: msg.sessionId,
15017
+ containerName
15018
+ });
15019
+ } catch (err) {
15020
+ this.emit({
15021
+ type: "terminal",
15022
+ op: "error",
15023
+ sessionId: msg.sessionId,
15024
+ message: err instanceof Error ? err.message : String(err)
15025
+ });
15026
+ }
15027
+ }
15028
+ stdin(sessionId, b64) {
15029
+ const session = this.sessions.get(sessionId);
15030
+ if (!session) return;
15031
+ try {
15032
+ session.stream.write(Buffer.from(b64, "base64"));
15033
+ } catch {
15034
+ }
15035
+ }
15036
+ async resize(sessionId, cols, rows) {
15037
+ const session = this.sessions.get(sessionId);
15038
+ if (!session) return;
15039
+ try {
15040
+ await session.exec.resize({ h: rows, w: cols });
15041
+ } catch {
15042
+ }
15043
+ }
15044
+ close(sessionId) {
15045
+ const session = this.sessions.get(sessionId);
15046
+ if (!session) return;
15047
+ try {
15048
+ session.stream.end();
15049
+ } catch {
15050
+ }
15051
+ }
15052
+ /** Tear down the tmux session for a tab. Used when the user explicitly
15053
+ * removes a tab (the X button), not on transient detach. */
15054
+ async killTmuxSession(postId, terminalKey) {
15055
+ const containerName = this.getCardContainerName(postId);
15056
+ if (!containerName) return;
15057
+ const session = tmuxName(terminalKey);
15058
+ try {
15059
+ const exec = await docker.getContainer(containerName).exec({
15060
+ Cmd: ["tmux", "kill-session", "-t", session],
15061
+ AttachStdout: false,
15062
+ AttachStderr: false,
15063
+ Tty: false
15064
+ });
15065
+ await exec.start({});
15066
+ } catch {
15067
+ }
15068
+ }
15069
+ destroyAll() {
15070
+ for (const sessionId of [...this.sessions.keys()]) {
15071
+ this.close(sessionId);
15072
+ }
15073
+ }
15074
+ // Called only once even if both 'end' and 'close' fire.
15075
+ async finalize(sessionId, exec) {
15076
+ if (!this.sessions.has(sessionId)) return;
15077
+ this.cleanup(sessionId);
15078
+ let code = null;
15079
+ try {
15080
+ const info = await exec.inspect();
15081
+ code = typeof info.ExitCode === "number" ? info.ExitCode : null;
15082
+ } catch {
15083
+ }
15084
+ this.emit({ type: "terminal", op: "exit", sessionId, code });
15085
+ }
15086
+ cleanup(sessionId) {
15087
+ this.sessions.delete(sessionId);
15088
+ }
15089
+ emit(msg) {
15090
+ const wire = JSON.stringify(msg);
15091
+ for (const frame of chunkWire(wire)) this.send(frame);
15092
+ }
15093
+ };
15094
+
15095
+ // src/agent-adapter.ts
15096
+ var import_fs4 = __toESM(require("fs"));
15097
+ 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");
15100
+ function safeSlug(spaceSlug) {
15101
+ return spaceSlug.replace(/[^a-zA-Z0-9_.-]/g, "_");
15102
+ }
13318
15103
  function configPath(spaceSlug) {
13319
- const safe = spaceSlug.replace(/[^a-zA-Z0-9_.-]/g, "_");
13320
- return import_path3.default.join(CONFIG_DIR2, `space-${safe}.json`);
15104
+ return import_path4.default.join(CONFIG_DIR2, `space-${safeSlug(spaceSlug)}.json`);
15105
+ }
15106
+ function connectionPath(spaceSlug) {
15107
+ return import_path4.default.join(CONFIG_DIR2, `space-${safeSlug(spaceSlug)}.onedrive.json`);
15108
+ }
15109
+ 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);
13321
15113
  }
13322
15114
  var localConfigProvider = {
13323
15115
  readConfig(spaceSlug) {
15116
+ let config = {};
13324
15117
  try {
13325
- const raw = import_fs3.default.readFileSync(configPath(spaceSlug), "utf-8");
13326
- const config = JSON.parse(raw);
13327
- return {
13328
- repoUrl: config.repoUrl || "",
13329
- mainBranch: config.mainBranch || "main",
13330
- gitName: config.gitName || "",
13331
- gitEmail: config.gitEmail || "",
13332
- claudeToken: config.claudeToken || "",
13333
- githubToken: config.githubToken || ""
13334
- };
15118
+ config = JSON.parse(
15119
+ import_fs4.default.readFileSync(configPath(spaceSlug), "utf-8")
15120
+ );
13335
15121
  } catch {
13336
- return {
13337
- repoUrl: "",
13338
- mainBranch: "main",
13339
- gitName: "",
13340
- gitEmail: "",
13341
- claudeToken: "",
13342
- githubToken: ""
13343
- };
13344
15122
  }
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
+ };
13345
15132
  },
13346
15133
  writeConfig(spaceSlug, config) {
13347
- import_fs3.default.mkdirSync(CONFIG_DIR2, { recursive: true });
13348
- const filePath = configPath(spaceSlug);
13349
- import_fs3.default.writeFileSync(filePath, JSON.stringify(config, null, 2));
13350
- import_fs3.default.chmodSync(filePath, 384);
15134
+ writeJson(configPath(spaceSlug), config);
15135
+ },
15136
+ readOneDriveConnection(spaceSlug) {
15137
+ try {
15138
+ return JSON.parse(
15139
+ import_fs4.default.readFileSync(connectionPath(spaceSlug), "utf-8")
15140
+ );
15141
+ } catch {
15142
+ return null;
15143
+ }
15144
+ },
15145
+ writeOneDriveConnection(spaceSlug, connection) {
15146
+ const filePath = connectionPath(spaceSlug);
15147
+ if (!connection) {
15148
+ import_fs4.default.rmSync(filePath, { force: true });
15149
+ return;
15150
+ }
15151
+ writeJson(filePath, connection);
13351
15152
  }
13352
15153
  };
13353
15154
 
@@ -13362,6 +15163,14 @@ async function startDaemon(config) {
13362
15163
  });
13363
15164
  const engines = /* @__PURE__ */ new Map();
13364
15165
  const openChannels = /* @__PURE__ */ new Set();
15166
+ const terminalByChannel = /* @__PURE__ */ new WeakMap();
15167
+ const findCardContainer = (postId) => {
15168
+ for (const engine of engines.values()) {
15169
+ const name = engine.getCardContainerName(postId);
15170
+ if (name) return name;
15171
+ }
15172
+ return null;
15173
+ };
13365
15174
  const broadcastToAll = (msg) => {
13366
15175
  const frames = chunkWire(msg);
13367
15176
  for (const dc of openChannels) {
@@ -13469,12 +15278,45 @@ async function startDaemon(config) {
13469
15278
  }
13470
15279
  }
13471
15280
  };
15281
+ const sendRawToChannel = (frame) => {
15282
+ try {
15283
+ dc.sendMessage(frame);
15284
+ } catch {
15285
+ }
15286
+ };
15287
+ terminalByChannel.set(
15288
+ dc,
15289
+ new TerminalManager(sendRawToChannel, findCardContainer)
15290
+ );
15291
+ dc.onOpen(() => {
15292
+ for (const [spaceSlug, engine] of engines.entries()) {
15293
+ const sessions = engine.cardSessions();
15294
+ for (const summary of Object.values(sessions)) {
15295
+ const msg = JSON.stringify({
15296
+ type: "event",
15297
+ spaceSlug,
15298
+ event: `board-agent:${spaceSlug}:session-update`,
15299
+ payload: summary
15300
+ });
15301
+ sendToChannel(msg);
15302
+ }
15303
+ }
15304
+ });
13472
15305
  dc.onMessage((raw) => {
13473
15306
  const rawStr = typeof raw === "string" ? raw : raw.toString("utf-8");
13474
15307
  const msgStr = reassembler.feed(rawStr);
13475
15308
  if (msgStr === null) return;
13476
15309
  try {
13477
15310
  const parsed = JSON.parse(msgStr);
15311
+ if (parsed.type === "terminal") {
15312
+ const mgr = terminalByChannel.get(dc);
15313
+ if (mgr) {
15314
+ mgr.handle(
15315
+ parsed
15316
+ );
15317
+ }
15318
+ return;
15319
+ }
13478
15320
  if (parsed.type === "broadcast" && parsed.spaceSlug && parsed.event) {
13479
15321
  const eventMsg = JSON.stringify({
13480
15322
  type: "event",
@@ -13506,6 +15348,11 @@ async function startDaemon(config) {
13506
15348
  dc.onClosed(() => {
13507
15349
  console.log(`Data channel closed with ${data.fromSocketId}`);
13508
15350
  openChannels.delete(dc);
15351
+ const mgr = terminalByChannel.get(dc);
15352
+ if (mgr) {
15353
+ mgr.destroyAll();
15354
+ terminalByChannel.delete(dc);
15355
+ }
13509
15356
  detachIceHandler();
13510
15357
  });
13511
15358
  });
@@ -13578,16 +15425,18 @@ async function handleDataChannelMessage(raw, sendToChannel, broadcastToAll, getE
13578
15425
  }
13579
15426
  }
13580
15427
  function getDockerfilePath() {
13581
- return import_path4.default.join(__dirname, "..", "Dockerfile.claude");
15428
+ return import_path5.default.join(__dirname, "..", "Dockerfile.claude");
13582
15429
  }
13583
15430
 
13584
15431
  // src/setup.ts
13585
- var import_child_process6 = require("child_process");
13586
- var import_fs4 = __toESM(require("fs"));
13587
- var import_path5 = __toESM(require("path"));
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";
13588
15437
  function run(bin, args, opts) {
13589
15438
  try {
13590
- return (0, import_child_process6.execFileSync)(bin, args, {
15439
+ return (0, import_child_process7.execFileSync)(bin, args, {
13591
15440
  encoding: "utf-8",
13592
15441
  stdio: opts?.silent ? "pipe" : "inherit",
13593
15442
  // Avoids a cmd.exe window flash on Windows during silent checks.
@@ -13600,7 +15449,7 @@ function run(bin, args, opts) {
13600
15449
  function check(cmd) {
13601
15450
  const lookup2 = process.platform === "win32" ? "where" : "which";
13602
15451
  try {
13603
- (0, import_child_process6.execFileSync)(lookup2, [cmd], { stdio: "pipe", windowsHide: true });
15452
+ (0, import_child_process7.execFileSync)(lookup2, [cmd], { stdio: "pipe", windowsHide: true });
13604
15453
  return true;
13605
15454
  } catch {
13606
15455
  return false;
@@ -13630,21 +15479,32 @@ async function setup(serverUrl) {
13630
15479
  );
13631
15480
  process.exit(1);
13632
15481
  }
13633
- const imageExists = run(
15482
+ const wantedHash = import_crypto2.default.createHash("sha256").update(import_fs5.default.readFileSync(dockerfilePath)).digest("hex");
15483
+ const existingHash = run(
13634
15484
  "docker",
13635
- ["image", "inspect", "factiii-claude"],
15485
+ [
15486
+ "image",
15487
+ "inspect",
15488
+ "factiii-claude",
15489
+ "--format",
15490
+ `{{ index .Config.Labels "${DOCKERFILE_LABEL2}" }}`
15491
+ ],
13636
15492
  { silent: true }
13637
- );
13638
- if (imageExists) {
13639
- console.log(" Image already exists.");
15493
+ ).trim();
15494
+ if (existingHash === wantedHash) {
15495
+ console.log(" Image is up to date.");
13640
15496
  } else {
13641
- console.log(` Building from ${dockerfilePath}...`);
15497
+ console.log(
15498
+ existingHash ? ` Dockerfile changed since last build, rebuilding from ${dockerfilePath}...` : ` Building from ${dockerfilePath}...`
15499
+ );
13642
15500
  run("docker", [
13643
15501
  "build",
13644
15502
  "-t",
13645
15503
  "factiii-claude",
13646
15504
  "-f",
13647
15505
  dockerfilePath,
15506
+ "--label",
15507
+ `${DOCKERFILE_LABEL2}=${wantedHash}`,
13648
15508
  "."
13649
15509
  ]);
13650
15510
  console.log(" Image built.");
@@ -13658,13 +15518,14 @@ async function setup(serverUrl) {
13658
15518
  console.log("Start the runner with: factiii-runner start\n");
13659
15519
  }
13660
15520
  function findDockerfile() {
13661
- const p = import_path5.default.join(__dirname, "..", "Dockerfile.claude");
13662
- return import_fs4.default.existsSync(p) ? p : null;
15521
+ const p = import_path6.default.join(__dirname, "..", "Dockerfile.claude");
15522
+ return import_fs5.default.existsSync(p) ? p : null;
13663
15523
  }
13664
15524
 
13665
15525
  // src/cli.ts
15526
+ import_node_dns.default.setDefaultResultOrder("ipv4first");
13666
15527
  var program2 = new Command();
13667
- program2.name("factiii-runner").description("Factiii Runner \u2014 run Board AI agents on remote servers").version("0.0.1");
15528
+ program2.name("factiii-runner").description("Factiii Runner - run Board AI agents on remote servers").version("0.0.1");
13668
15529
  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) => {
13669
15530
  try {
13670
15531
  await setup(opts.server);