@agentrific/server 0.5.2 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/cli.js CHANGED
@@ -30,7 +30,7 @@ var init_package = __esm({
30
30
  "package.json"() {
31
31
  package_default = {
32
32
  name: "@agentrific/server",
33
- version: "0.5.2",
33
+ version: "0.6.0",
34
34
  description: "Self-hosted control plane for long-running coding agents",
35
35
  keywords: [
36
36
  "agentrific",
@@ -80,6 +80,7 @@ var init_package = __esm({
80
80
  },
81
81
  devDependencies: {
82
82
  "@agentrific/contracts": "workspace:*",
83
+ "@agentrific/opentunnel-runtime": "workspace:*",
83
84
  "@jest/globals": "30.5.2",
84
85
  "@types/node": "^22.19.1",
85
86
  "@types/qrcode-terminal": "^0.12.2",
@@ -650,7 +651,7 @@ async function acquireServerLock(directory) {
650
651
  await unlink2(filename);
651
652
  };
652
653
  }
653
- async function startControl(directory, security2, status, maintenance2, shutdown) {
654
+ async function startControl(directory, security2, status, maintenance2, shutdown, phone2) {
654
655
  const filename = controlSocketPath(directory);
655
656
  const socketDirectory = path2.dirname(filename);
656
657
  await mkdir2(socketDirectory, { recursive: true, mode: 448 });
@@ -677,6 +678,29 @@ async function startControl(directory, security2, status, maintenance2, shutdown
677
678
  const request = ControlRequest.parse(JSON.parse(buffer.split("\n")[0]));
678
679
  let result;
679
680
  switch (request.command) {
681
+ case "phone-options":
682
+ if (!phone2) throw new Error("Phone connections are managed by the desktop app.");
683
+ socket.setTimeout(18e4);
684
+ result = await phone2.options();
685
+ break;
686
+ case "phone-start": {
687
+ if (!phone2) throw new Error("Phone connections are managed by the desktop app.");
688
+ maintenance2?.assertReady();
689
+ socket.setTimeout(18e4);
690
+ const abort = new AbortController();
691
+ const disconnected = () => abort.abort();
692
+ socket.once("close", disconnected);
693
+ try {
694
+ result = await phone2.start(request.provider, abort.signal);
695
+ } finally {
696
+ socket.removeListener("close", disconnected);
697
+ }
698
+ break;
699
+ }
700
+ case "phone-stop":
701
+ if (!phone2) throw new Error("Phone connections are managed by the desktop app.");
702
+ result = await phone2.close();
703
+ break;
680
704
  case "shutdown":
681
705
  if (!shutdown || !maintenance2?.status().drained)
682
706
  throw new Error("Drain the server before shutdown");
@@ -746,12 +770,13 @@ function controlRequest(directory, command) {
746
770
  });
747
771
  });
748
772
  }
749
- var MaintenanceSchema, ControlStatusSchema, ControlResultSchema, ControlRequest;
773
+ var ProviderSchema, MaintenanceSchema, ControlStatusSchema, ControlResultSchema, ControlRequest;
750
774
  var init_control = __esm({
751
775
  "src/control.ts"() {
752
776
  "use strict";
753
777
  init_control_socket();
754
778
  init_src();
779
+ ProviderSchema = z3.enum(["opentunnel", "tailscale", "cloudflared"]);
755
780
  MaintenanceSchema = z3.object({
756
781
  active: z3.boolean(),
757
782
  drained: z3.boolean(),
@@ -760,6 +785,7 @@ var init_control = __esm({
760
785
  ControlStatusSchema = z3.object({
761
786
  serverId: z3.string().uuid(),
762
787
  publicUrl: z3.string().optional(),
788
+ phoneProvider: ProviderSchema.nullable().optional(),
763
789
  localUrl: z3.string().optional(),
764
790
  serverVersion: z3.string().optional(),
765
791
  connectedDevices: z3.number().optional(),
@@ -769,6 +795,8 @@ var init_control = __esm({
769
795
  maintenance: MaintenanceSchema.optional()
770
796
  });
771
797
  ControlResultSchema = z3.union([
798
+ z3.object({ provider: ProviderSchema.nullable(), publicUrl: z3.string().url().nullable() }),
799
+ z3.array(z3.object({ provider: ProviderSchema, available: z3.boolean(), reason: z3.string() })),
772
800
  z3.object({
773
801
  pairingId: z3.string(),
774
802
  pin: z3.string(),
@@ -782,6 +810,9 @@ var init_control = __esm({
782
810
  MaintenanceSchema
783
811
  ]);
784
812
  ControlRequest = z3.discriminatedUnion("command", [
813
+ z3.object({ command: z3.literal("phone-options") }),
814
+ z3.object({ command: z3.literal("phone-start"), provider: ProviderSchema }),
815
+ z3.object({ command: z3.literal("phone-stop") }),
785
816
  z3.object({ command: z3.literal("pair") }),
786
817
  z3.object({ command: z3.literal("devices") }),
787
818
  z3.object({ command: z3.literal("status") }),
@@ -855,6 +886,7 @@ function printHelp() {
855
886
  for (const [option, description] of [
856
887
  ["--data-dir <path>", "Use a specific server data directory"],
857
888
  ["--public-url <https-url>", "Use your tunnel URL without prompting"],
889
+ ["--connection <method>", "opentunnel (default), tailscale, cloudflared, existing, or local"],
858
890
  ["--no-interactive", "Disable prompts and keyboard shortcuts"],
859
891
  ["--show-payload", "Print manual JSON with the pairing QR code"],
860
892
  ["--timeout-ms <0-60000>", "Set the maintenance drain timeout"],
@@ -862,7 +894,9 @@ function printHelp() {
862
894
  ["--help", "Show this help"]
863
895
  ])
864
896
  console.log(` ${styleText("cyan", option.padEnd(34))}${description}`);
865
- console.log("\n Connect using Tailscale Serve, cloudflared, or another HTTPS tunnel.\n");
897
+ console.log(
898
+ "\n OpenTunnel is built in. Tailscale, cloudflared and existing HTTPS tunnels are also supported.\n"
899
+ );
866
900
  }
867
901
  var init_cli_output = __esm({
868
902
  "src/cli-output.ts"() {
@@ -992,8 +1026,379 @@ var init_pairing = __esm({
992
1026
  }
993
1027
  });
994
1028
 
995
- // ../../packages/contracts/src/push.ts
1029
+ // src/tunnels.ts
1030
+ import { spawn } from "node:child_process";
1031
+ import { access, stat } from "node:fs/promises";
1032
+ import { constants } from "node:fs";
1033
+ import { homedir as homedir2 } from "node:os";
1034
+ import path3 from "node:path";
1035
+ import { z as z5 } from "zod";
1036
+ function signalChild(child, signal) {
1037
+ if (!child.pid) return;
1038
+ if (process.platform === "win32") {
1039
+ child.kill(signal);
1040
+ return;
1041
+ }
1042
+ try {
1043
+ process.kill(-child.pid, signal);
1044
+ } catch (error) {
1045
+ if (!z5.object({ code: z5.literal("ESRCH") }).safeParse(error).success) child.kill(signal);
1046
+ }
1047
+ }
1048
+ function execute2(file, args, timeoutMs, env = process.env) {
1049
+ return new Promise((resolve2, reject) => {
1050
+ const child = spawn(file, args, {
1051
+ env,
1052
+ detached: process.platform !== "win32",
1053
+ stdio: ["ignore", "pipe", "pipe"]
1054
+ });
1055
+ let stdout = "";
1056
+ let stderr = "";
1057
+ const timeout = setTimeout(() => {
1058
+ signalChild(child, "SIGKILL");
1059
+ reject(new Error("Command probe timed out."));
1060
+ }, timeoutMs);
1061
+ child.stdout?.on("data", (data) => {
1062
+ stdout += data.toString();
1063
+ checkOutput();
1064
+ });
1065
+ child.stderr?.on("data", (data) => {
1066
+ stderr += data.toString();
1067
+ checkOutput();
1068
+ });
1069
+ child.once("error", (error) => {
1070
+ clearTimeout(timeout);
1071
+ reject(error);
1072
+ });
1073
+ child.once("exit", () => signalChild(child, "SIGKILL"));
1074
+ child.once("close", (code) => {
1075
+ clearTimeout(timeout);
1076
+ if (code !== 0) reject(new Error("Command probe failed."));
1077
+ else resolve2({ stdout, stderr });
1078
+ });
1079
+ function checkOutput() {
1080
+ if (stdout.length + stderr.length <= 1048576) return;
1081
+ signalChild(child, "SIGKILL");
1082
+ reject(new Error("Command probe output exceeded its limit."));
1083
+ }
1084
+ });
1085
+ }
1086
+ async function findExecutable(name) {
1087
+ const candidates = (process.env.PATH ?? "").split(path3.delimiter).map((directory) => path3.resolve(directory, name));
1088
+ if (process.platform === "darwin") {
1089
+ candidates.push(`/opt/homebrew/bin/${name}`, `/usr/local/bin/${name}`);
1090
+ if (name === "tailscale")
1091
+ candidates.push("/Applications/Tailscale.app/Contents/MacOS/Tailscale");
1092
+ }
1093
+ for (const candidate of candidates) {
1094
+ try {
1095
+ await access(candidate, constants.X_OK);
1096
+ if ((await stat(candidate)).isFile()) return candidate;
1097
+ } catch {
1098
+ continue;
1099
+ }
1100
+ }
1101
+ return void 0;
1102
+ }
1103
+ async function probeTailscale() {
1104
+ const option = { provider: "tailscale", available: false, reason: "Not installed" };
1105
+ const executable = await findExecutable("tailscale");
1106
+ if (!executable) return option;
1107
+ option.executable = executable;
1108
+ const env = { ...process.env, TAILSCALE_BE_CLI: "1" };
1109
+ try {
1110
+ const response = await execute2(executable, ["status", "--json"], 3e3, env);
1111
+ const status = z5.object({
1112
+ BackendState: z5.string(),
1113
+ CertDomains: z5.array(z5.string()).optional(),
1114
+ Self: z5.object({ DNSName: z5.string() }).nullable().optional()
1115
+ }).parse(JSON.parse(response.stdout));
1116
+ if (status.BackendState !== "Running")
1117
+ return { ...option, reason: "Installed \xB7 connect Tailscale first" };
1118
+ const dns = status.Self?.DNSName.replace(/\.$/, "");
1119
+ if (!dns || !status.CertDomains?.includes(dns))
1120
+ return { ...option, reason: "Enable HTTPS certificates in your tailnet first" };
1121
+ const help = await execute2(executable, ["serve", "--help"], 2e3, env);
1122
+ if (!`${help.stdout}${help.stderr}`.includes("--https"))
1123
+ return { ...option, reason: "Update Tailscale to use foreground HTTPS Serve" };
1124
+ const responseConfig = await execute2(executable, ["serve", "status", "--json"], 3e3, env);
1125
+ const config = ServeConfig.parse(JSON.parse(responseConfig.stdout) ?? {});
1126
+ const occupied = new Set(Object.keys(config.TCP ?? {}));
1127
+ for (const foreground of Object.values(config.Foreground ?? {}))
1128
+ for (const port3 of Object.keys(foreground.TCP ?? {})) occupied.add(port3);
1129
+ const port2 = [443, ...Array.from({ length: 10 }, (_, index) => 8443 + index)].find(
1130
+ (candidate) => !occupied.has(String(candidate))
1131
+ );
1132
+ if (!port2) return { ...option, reason: "HTTPS Serve ports are in use \xB7 paste an existing URL" };
1133
+ return {
1134
+ ...option,
1135
+ available: true,
1136
+ reason: "Private to your tailnet \xB7 Tailscale required on your phone",
1137
+ publicUrl: new URL(`https://${dns}${port2 === 443 ? "" : `:${port2}`}`).origin,
1138
+ httpsPort: port2
1139
+ };
1140
+ } catch {
1141
+ return { ...option, reason: "Installed \xB7 could not verify daemon or Serve access" };
1142
+ }
1143
+ }
1144
+ async function probeCloudflared(home) {
1145
+ const option = {
1146
+ provider: "cloudflared",
1147
+ available: false,
1148
+ reason: "Not installed"
1149
+ };
1150
+ const executable = await findExecutable("cloudflared");
1151
+ if (!executable) return option;
1152
+ option.executable = executable;
1153
+ try {
1154
+ await execute2(executable, ["--version"], 2e3);
1155
+ const help = await execute2(executable, ["tunnel", "--help"], 2e3);
1156
+ if (!`${help.stdout}${help.stderr}`.includes("--url"))
1157
+ return { ...option, reason: "Installed \xB7 this version does not support Quick Tunnels" };
1158
+ } catch {
1159
+ return { ...option, reason: "Installed \xB7 command probe failed or timed out" };
1160
+ }
1161
+ const directories = [
1162
+ path3.join(home, ".cloudflared"),
1163
+ path3.join(home, ".cloudflare-warp"),
1164
+ path3.join(home, "cloudflare-warp"),
1165
+ "/etc/cloudflared",
1166
+ "/usr/local/etc/cloudflared"
1167
+ ];
1168
+ const configurations = await Promise.all(
1169
+ directories.flatMap(
1170
+ (directory) => ["config.yml", "config.yaml"].map(
1171
+ async (name) => access(path3.join(directory, name), constants.F_OK).then(
1172
+ () => true,
1173
+ () => false
1174
+ )
1175
+ )
1176
+ )
1177
+ );
1178
+ if (configurations.some(Boolean))
1179
+ return { ...option, reason: "Existing tunnel configuration \xB7 paste its HTTPS URL" };
1180
+ return { ...option, available: true, reason: "Temporary public HTTPS URL \xB7 changes each start" };
1181
+ }
1182
+ async function detectTunnels(home = homedir2()) {
1183
+ return Promise.all([probeTailscale(), probeCloudflared(home)]);
1184
+ }
1185
+ var TcpConfig, ServeConfig, ManagedTunnel;
1186
+ var init_tunnels = __esm({
1187
+ "src/tunnels.ts"() {
1188
+ "use strict";
1189
+ init_cli_output();
1190
+ TcpConfig = z5.object({ TCP: z5.record(z5.string(), z5.unknown()).optional() });
1191
+ ServeConfig = TcpConfig.extend({
1192
+ Foreground: z5.record(z5.string(), TcpConfig).optional()
1193
+ });
1194
+ ManagedTunnel = class {
1195
+ constructor(option, disconnected = () => {
1196
+ }) {
1197
+ this.option = option;
1198
+ this.disconnected = disconnected;
1199
+ }
1200
+ option;
1201
+ disconnected;
1202
+ child;
1203
+ stopping = false;
1204
+ connected = false;
1205
+ closing;
1206
+ running() {
1207
+ return this.connected && !this.stopping;
1208
+ }
1209
+ stopOnSignal = () => {
1210
+ void this.close();
1211
+ };
1212
+ stopOnExit = () => {
1213
+ if (this.child) signalChild(this.child, "SIGKILL");
1214
+ };
1215
+ async start(origin, signal) {
1216
+ signal?.throwIfAborted();
1217
+ if (!this.option.available) throw new Error(this.option.reason);
1218
+ const tailscale = this.option.provider === "tailscale";
1219
+ const child = spawn(
1220
+ this.option.executable ?? this.option.provider,
1221
+ tailscale ? ["serve", "--bg=false", `--https=${this.option.httpsPort}`, origin] : ["tunnel", "--no-autoupdate", "--url", origin],
1222
+ {
1223
+ stdio: ["ignore", "pipe", "pipe"],
1224
+ detached: process.platform !== "win32",
1225
+ env: tailscale ? { ...process.env, TAILSCALE_BE_CLI: "1" } : process.env
1226
+ }
1227
+ );
1228
+ this.child = child;
1229
+ process.once("SIGINT", this.stopOnSignal);
1230
+ process.once("SIGTERM", this.stopOnSignal);
1231
+ process.once("exit", this.stopOnExit);
1232
+ return new Promise((resolve2, reject) => {
1233
+ let output = "";
1234
+ let address;
1235
+ let settled = false;
1236
+ const abort = () => fail("Tunnel startup canceled.");
1237
+ const timeout = setTimeout(
1238
+ () => fail("Tunnel startup timed out. Check your connection or paste an existing HTTPS URL."),
1239
+ 45e3
1240
+ );
1241
+ const fail = (message) => {
1242
+ if (settled) return;
1243
+ settled = true;
1244
+ clearTimeout(timeout);
1245
+ signal?.removeEventListener("abort", abort);
1246
+ void this.close();
1247
+ reject(new Error(message));
1248
+ };
1249
+ const read = (data) => {
1250
+ output = `${output}${data.toString()}`.slice(-65536);
1251
+ address = tailscale ? this.option.publicUrl : output.match(/https:\/\/[a-z0-9-]+\.trycloudflare\.com\b/)?.[0];
1252
+ const ready = tailscale ? Boolean(
1253
+ address && output.includes(address) && output.includes("Available within your tailnet")
1254
+ ) : output.includes("Registered tunnel connection");
1255
+ if (settled || !address || !ready) return;
1256
+ settled = true;
1257
+ this.connected = true;
1258
+ clearTimeout(timeout);
1259
+ signal?.removeEventListener("abort", abort);
1260
+ resolve2(address);
1261
+ };
1262
+ child.stdout?.on("data", read);
1263
+ child.stderr?.on("data", read);
1264
+ signal?.addEventListener("abort", abort, { once: true });
1265
+ child.once(
1266
+ "error",
1267
+ () => fail(`${this.option.provider} could not start. Check the installation and permissions.`)
1268
+ );
1269
+ child.once("exit", () => signalChild(child, "SIGTERM"));
1270
+ child.once("close", (code) => {
1271
+ const unexpected = this.connected && !this.stopping;
1272
+ this.connected = false;
1273
+ process.removeListener("SIGINT", this.stopOnSignal);
1274
+ process.removeListener("SIGTERM", this.stopOnSignal);
1275
+ process.removeListener("exit", this.stopOnExit);
1276
+ fail(
1277
+ `${this.option.provider} exited (${code ?? "signal"}). Check tunnel access or paste an existing HTTPS URL.`
1278
+ );
1279
+ if (unexpected) {
1280
+ this.disconnected();
1281
+ printError(
1282
+ "The managed tunnel stopped. Restart the server to reconnect your phone; local work can continue."
1283
+ );
1284
+ }
1285
+ });
1286
+ });
1287
+ }
1288
+ close() {
1289
+ this.stopping = true;
1290
+ const child = this.child;
1291
+ if (!child?.pid || child.exitCode !== null || child.signalCode !== null)
1292
+ return Promise.resolve();
1293
+ this.closing ??= new Promise((resolve2) => {
1294
+ const timeout = setTimeout(() => signalChild(child, "SIGKILL"), 2e3);
1295
+ child.once("close", () => {
1296
+ clearTimeout(timeout);
1297
+ resolve2();
1298
+ });
1299
+ signalChild(child, "SIGTERM");
1300
+ });
1301
+ return this.closing;
1302
+ }
1303
+ };
1304
+ }
1305
+ });
1306
+
1307
+ // src/opentunnel.ts
1308
+ import { randomBytes } from "node:crypto";
1309
+ import { chmod as chmod2, mkdir as mkdir3, readFile as readFile2, writeFile as writeFile3 } from "node:fs/promises";
1310
+ import path4 from "node:path";
996
1311
  import { z as z6 } from "zod";
1312
+ import {
1313
+ create,
1314
+ OpenTunnelStorage
1315
+ } from "./opentunnel-sdk.js";
1316
+ var OpenTunnel;
1317
+ var init_opentunnel = __esm({
1318
+ "src/opentunnel.ts"() {
1319
+ "use strict";
1320
+ OpenTunnel = class {
1321
+ constructor(directory, disconnected = () => {
1322
+ }, makeClient = create) {
1323
+ this.directory = directory;
1324
+ this.disconnected = disconnected;
1325
+ this.makeClient = makeClient;
1326
+ }
1327
+ directory;
1328
+ disconnected;
1329
+ makeClient;
1330
+ client;
1331
+ connection;
1332
+ stopping = false;
1333
+ running() {
1334
+ return this.connection?.status().state === "connected" && !this.stopping;
1335
+ }
1336
+ async start(origin, signal) {
1337
+ const endpoint = new URL(origin);
1338
+ if (!["127.0.0.1", "localhost", "[::1]"].includes(endpoint.hostname))
1339
+ throw new Error("OpenTunnel must forward to the local loopback server.");
1340
+ await mkdir3(this.directory, { recursive: true, mode: 448 });
1341
+ await chmod2(this.directory, 448);
1342
+ const routeFile = path4.join(this.directory, "route.json");
1343
+ let route;
1344
+ try {
1345
+ route = z6.string().regex(/^[a-f0-9]{16}$/).parse(JSON.parse(await readFile2(routeFile, "utf8")));
1346
+ } catch (error) {
1347
+ if (!z6.object({ code: z6.literal("ENOENT") }).safeParse(error).success) throw error;
1348
+ route = randomBytes(8).toString("hex");
1349
+ await writeFile3(routeFile, JSON.stringify(route), { flag: "wx", mode: 384 });
1350
+ }
1351
+ this.stopping = false;
1352
+ this.client = this.makeClient({
1353
+ store: OpenTunnelStorage.xdg({ env: { XDG_DATA_HOME: this.directory } })
1354
+ });
1355
+ const abort = AbortSignal.any([
1356
+ signal ?? new AbortController().signal,
1357
+ AbortSignal.timeout(12e4)
1358
+ ]);
1359
+ let canceled = () => {
1360
+ };
1361
+ try {
1362
+ const connecting = this.client.tunnel.connect({
1363
+ profile: "agentrific",
1364
+ routes: {
1365
+ [route]: `${endpoint.hostname === "[::1]" ? "[::1]" : "127.0.0.1"}:${endpoint.port || "80"}`
1366
+ },
1367
+ signal
1368
+ });
1369
+ const cancellation = new Promise((_resolve, reject) => {
1370
+ canceled = () => reject(new Error("OpenTunnel connection canceled or timed out."));
1371
+ abort.addEventListener("abort", canceled, { once: true });
1372
+ if (abort.aborted) canceled();
1373
+ });
1374
+ const connection = await Promise.race([connecting, cancellation]);
1375
+ this.connection = connection;
1376
+ void connection.closed.then(() => {
1377
+ if (!this.stopping) this.disconnected();
1378
+ }).catch(() => {
1379
+ if (!this.stopping) this.disconnected();
1380
+ });
1381
+ return `https://${route}.${connection.tunnel.hostname}`;
1382
+ } catch (error) {
1383
+ await this.close();
1384
+ throw error;
1385
+ } finally {
1386
+ abort.removeEventListener("abort", canceled);
1387
+ }
1388
+ }
1389
+ async close() {
1390
+ this.stopping = true;
1391
+ await this.connection?.close();
1392
+ await this.client?.dispose();
1393
+ this.connection = void 0;
1394
+ this.client = void 0;
1395
+ }
1396
+ };
1397
+ }
1398
+ });
1399
+
1400
+ // ../../packages/contracts/src/push.ts
1401
+ import { z as z7 } from "zod";
997
1402
  function pushSigningText(installationId, operation, request) {
998
1403
  return [
999
1404
  "agentrific-push-v1",
@@ -1011,7 +1416,7 @@ var init_push = __esm({
1011
1416
  "use strict";
1012
1417
  PUSH_API_URL = "https://api.agentrific.com";
1013
1418
  PUSH_SANDBOX_API_URL = "https://sandbox-api.agentrific.com";
1014
- PushKindSchema = z6.enum([
1419
+ PushKindSchema = z7.enum([
1015
1420
  "started",
1016
1421
  "completed",
1017
1422
  "needs_input",
@@ -1020,47 +1425,47 @@ var init_push = __esm({
1020
1425
  "pr_created",
1021
1426
  "failed"
1022
1427
  ]);
1023
- PushPreferencesSchema = z6.array(PushKindSchema).max(7);
1024
- PushPublicKeySchema = z6.string().regex(/^[a-f0-9]{64}$/);
1025
- PushInstallationSchema = z6.object({ installationId: z6.string().uuid(), token: z6.string().regex(/^[a-f0-9]{64}$/) }).strict();
1026
- PushRegistrationSchema = z6.object({
1027
- expoToken: z6.string().regex(/^(Expo|Exponent)PushToken\[[A-Za-z0-9_-]+\]$/),
1428
+ PushPreferencesSchema = z7.array(PushKindSchema).max(7);
1429
+ PushPublicKeySchema = z7.string().regex(/^[a-f0-9]{64}$/);
1430
+ PushInstallationSchema = z7.object({ installationId: z7.string().uuid(), token: z7.string().regex(/^[a-f0-9]{64}$/) }).strict();
1431
+ PushRegistrationSchema = z7.object({
1432
+ expoToken: z7.string().regex(/^(Expo|Exponent)PushToken\[[A-Za-z0-9_-]+\]$/),
1028
1433
  preferences: PushPreferencesSchema
1029
1434
  }).strict();
1030
- PushEnrollmentSchema = z6.object({ serverId: z6.string().uuid(), publicKey: PushPublicKeySchema }).strict();
1435
+ PushEnrollmentSchema = z7.object({ serverId: z7.string().uuid(), publicKey: PushPublicKeySchema }).strict();
1031
1436
  PushGrantSchema = PushEnrollmentSchema.extend({
1032
- installationId: z6.string().uuid(),
1033
- bindingId: z6.string().uuid(),
1034
- grant: z6.string().regex(/^[a-f0-9]{64}$/),
1035
- expiresAt: z6.number()
1437
+ installationId: z7.string().uuid(),
1438
+ bindingId: z7.string().uuid(),
1439
+ grant: z7.string().regex(/^[a-f0-9]{64}$/),
1440
+ expiresAt: z7.number()
1036
1441
  }).strict();
1037
- PushPullRequestUrlSchema = z6.string().max(512).regex(/^https:\/\/github\.com\/[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+\/pull\/[1-9][0-9]*$/);
1038
- PushEventSchema = z6.object({
1039
- eventId: z6.string().uuid(),
1040
- sessionId: z6.string().uuid(),
1442
+ PushPullRequestUrlSchema = z7.string().max(512).regex(/^https:\/\/github\.com\/[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+\/pull\/[1-9][0-9]*$/);
1443
+ PushEventSchema = z7.object({
1444
+ eventId: z7.string().uuid(),
1445
+ sessionId: z7.string().uuid(),
1041
1446
  kind: PushKindSchema,
1042
- taskTitle: z6.string().trim().min(1).max(160).optional(),
1447
+ taskTitle: z7.string().trim().min(1).max(160).optional(),
1043
1448
  prUrl: PushPullRequestUrlSchema.optional()
1044
1449
  }).strict();
1045
- PushSignedRequestSchema = z6.object({
1046
- bindingId: z6.string().uuid(),
1047
- timestamp: z6.number().int(),
1048
- nonce: z6.string().uuid(),
1049
- payload: z6.string().max(2048),
1050
- signature: z6.string().regex(/^[a-f0-9]{128}$/)
1450
+ PushSignedRequestSchema = z7.object({
1451
+ bindingId: z7.string().uuid(),
1452
+ timestamp: z7.number().int(),
1453
+ nonce: z7.string().uuid(),
1454
+ payload: z7.string().max(2048),
1455
+ signature: z7.string().regex(/^[a-f0-9]{128}$/)
1051
1456
  }).strict();
1052
- PushEntitlementSchema = z6.object({ active: z6.boolean(), expiresAt: z6.number().nullable() }).strict();
1053
- PushServerStatusSchema = z6.object({
1457
+ PushEntitlementSchema = z7.object({ active: z7.boolean(), expiresAt: z7.number().nullable() }).strict();
1458
+ PushServerStatusSchema = z7.object({
1054
1459
  publicKey: PushPublicKeySchema,
1055
- bindings: z6.array(
1056
- z6.object({
1057
- bindingId: z6.string().uuid(),
1058
- installationId: z6.string().uuid(),
1059
- deviceId: z6.string().uuid()
1460
+ bindings: z7.array(
1461
+ z7.object({
1462
+ bindingId: z7.string().uuid(),
1463
+ installationId: z7.string().uuid(),
1464
+ deviceId: z7.string().uuid()
1060
1465
  })
1061
1466
  ),
1062
- pending: z6.number().int().nonnegative(),
1063
- error: z6.string().nullable()
1467
+ pending: z7.number().int().nonnegative(),
1468
+ error: z7.string().nullable()
1064
1469
  });
1065
1470
  }
1066
1471
  });
@@ -1074,7 +1479,7 @@ import {
1074
1479
  randomUUID
1075
1480
  } from "node:crypto";
1076
1481
  import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
1077
- import path4 from "node:path";
1482
+ import path6 from "node:path";
1078
1483
  import { Effect as Effect3 } from "effect";
1079
1484
  function pushKind(session, previous) {
1080
1485
  if (session.gitDelivery?.status === "pr_opened" && previous?.gitDelivery?.status !== "pr_opened")
@@ -1134,7 +1539,7 @@ var init_push2 = __esm({
1134
1539
  this.transport = transport;
1135
1540
  this.apiUrl = apiUrl;
1136
1541
  mkdirSync(directory, { recursive: true, mode: 448 });
1137
- const filename = path4.join(directory, "push-signing-key.pem");
1542
+ const filename = path6.join(directory, "push-signing-key.pem");
1138
1543
  if (!existsSync(filename))
1139
1544
  writeFileSync(
1140
1545
  filename,
@@ -1304,12 +1709,12 @@ var init_push2 = __esm({
1304
1709
  // src/attachments.ts
1305
1710
  import { randomUUID as randomUUID2 } from "node:crypto";
1306
1711
  import { createReadStream, createWriteStream } from "node:fs";
1307
- import { mkdir as mkdir3, readFile as readFile2, rm, stat as stat2, readdir } from "node:fs/promises";
1308
- import path5 from "node:path";
1712
+ import { mkdir as mkdir4, readFile as readFile3, rm, stat as stat2, readdir } from "node:fs/promises";
1713
+ import path7 from "node:path";
1309
1714
  import { pipeline } from "node:stream/promises";
1310
1715
  import sharp from "sharp";
1311
1716
  import { Context, Data, Effect as Effect4, Layer } from "effect";
1312
- import { z as z7 } from "zod";
1717
+ import { z as z8 } from "zod";
1313
1718
  var AttachmentError, textExtensions, imageMimes, AttachmentFiles, Attachments;
1314
1719
  var init_attachments = __esm({
1315
1720
  "src/attachments.ts"() {
@@ -1364,7 +1769,7 @@ var init_attachments = __esm({
1364
1769
  root;
1365
1770
  store;
1366
1771
  directory(id) {
1367
- return path5.join(this.root, z7.string().uuid().parse(id));
1772
+ return path7.join(this.root, z8.string().uuid().parse(id));
1368
1773
  }
1369
1774
  io(operation) {
1370
1775
  return Effect4.tryPromise({
@@ -1378,11 +1783,11 @@ var init_attachments = __esm({
1378
1783
  return this.io(async () => {
1379
1784
  const id = randomUUID2();
1380
1785
  const directory = this.directory(id);
1381
- const source = path5.join(directory, "source");
1382
- const nameField = z7.object({ type: z7.literal("field"), value: z7.string() }).safeParse(part.fields.name);
1786
+ const source = path7.join(directory, "source");
1787
+ const nameField = z8.object({ type: z8.literal("field"), value: z8.string() }).safeParse(part.fields.name);
1383
1788
  const originalName = nameField.success ? nameField.data.value : part.filename;
1384
- const name = path5.basename(originalName.replaceAll("\\", "/")).replace(new RegExp("\\p{Cc}", "gu"), "").slice(0, 180) || "attachment";
1385
- await mkdir3(directory, { recursive: true });
1789
+ const name = path7.basename(originalName.replaceAll("\\", "/")).replace(new RegExp("\\p{Cc}", "gu"), "").slice(0, 180) || "attachment";
1790
+ await mkdir4(directory, { recursive: true });
1386
1791
  try {
1387
1792
  await pipeline(part.file, createWriteStream(source, { flags: "wx" }));
1388
1793
  if (part.file.truncated) throw new Error("Files must be 20 MB or smaller.");
@@ -1409,14 +1814,14 @@ var init_attachments = __esm({
1409
1814
  const metadata = await sharp(source, { limitInputPixels: 4e7 }).metadata().catch(() => null);
1410
1815
  const mimeType = metadata?.format && imageMimes.get(metadata.format);
1411
1816
  if (mimeType) {
1412
- await sharp(source, { limitInputPixels: 4e7 }).rotate().resize({ width: 2048, height: 2048, fit: "inside", withoutEnlargement: true }).png().toFile(path5.join(path5.dirname(source), "preview.png"));
1817
+ await sharp(source, { limitInputPixels: 4e7 }).rotate().resize({ width: 2048, height: 2048, fit: "inside", withoutEnlargement: true }).png().toFile(path7.join(path7.dirname(source), "preview.png"));
1413
1818
  return { kind: "image", mimeType };
1414
1819
  }
1415
- if (!textExtensions.has(path5.extname(name).toLowerCase()))
1820
+ if (!textExtensions.has(path7.extname(name).toLowerCase()))
1416
1821
  throw new Error(
1417
1822
  "Choose a PNG, JPEG, WebP, GIF, or text/code file. PDF and Office documents aren't supported yet."
1418
1823
  );
1419
- const bytes = await readFile2(source);
1824
+ const bytes = await readFile3(source);
1420
1825
  const text = new TextDecoder("utf-8", { fatal: true }).decode(bytes);
1421
1826
  if (new RegExp("\\p{Cc}", "u").test(text.replaceAll("\n", "").replaceAll("\r", "").replaceAll(" ", "")))
1422
1827
  throw new Error("This file isn't readable UTF-8 text.");
@@ -1433,7 +1838,7 @@ var init_attachments = __esm({
1433
1838
  throw new Error(
1434
1839
  "An attachment is unavailable for this project. Remove it and upload it again."
1435
1840
  );
1436
- await stat2(path5.join(this.directory(id), "source"));
1841
+ await stat2(path7.join(this.directory(id), "source"));
1437
1842
  attachments2.push(attachment);
1438
1843
  }
1439
1844
  return attachments2;
@@ -1441,7 +1846,7 @@ var init_attachments = __esm({
1441
1846
  }
1442
1847
  content(attachment, preview) {
1443
1848
  return createReadStream(
1444
- path5.join(
1849
+ path7.join(
1445
1850
  this.directory(attachment.id),
1446
1851
  preview && attachment.kind === "image" ? "preview.png" : "source"
1447
1852
  )
@@ -1452,13 +1857,13 @@ var init_attachments = __esm({
1452
1857
  const images = [];
1453
1858
  const sections = [];
1454
1859
  for (const attachment of attachments2) {
1455
- const source = path5.join(this.directory(attachment.id), "source");
1860
+ const source = path7.join(this.directory(attachment.id), "source");
1456
1861
  if (attachment.kind === "image") {
1457
- const bytes = await readFile2(path5.join(this.directory(attachment.id), "preview.png"));
1862
+ const bytes = await readFile3(path7.join(this.directory(attachment.id), "preview.png"));
1458
1863
  images.push({ type: "image", data: bytes.toString("base64"), mimeType: "image/png" });
1459
1864
  sections.push(`Image attachment: ${JSON.stringify(attachment.name)}`);
1460
1865
  } else {
1461
- const text = await readFile2(source, "utf8");
1866
+ const text = await readFile3(source, "utf8");
1462
1867
  sections.push(
1463
1868
  `File attachment: ${JSON.stringify(attachment.name)}
1464
1869
  Full file available to the read tool at: ${JSON.stringify(source)}
@@ -1484,9 +1889,9 @@ ${text.slice(0, 12e3)}${text.length > 12e3 ? "\n[Preview truncated; use the read
1484
1889
  if (await Effect4.runPromise(this.store.deleteAttachment(attachment.id)))
1485
1890
  await rm(this.directory(attachment.id), { recursive: true, force: true });
1486
1891
  }
1487
- await mkdir3(this.root, { recursive: true });
1892
+ await mkdir4(this.root, { recursive: true });
1488
1893
  for (const entry of await readdir(this.root, { withFileTypes: true })) {
1489
- if (!entry.isDirectory() || !z7.string().uuid().safeParse(entry.name).success) continue;
1894
+ if (!entry.isDirectory() || !z8.string().uuid().safeParse(entry.name).success) continue;
1490
1895
  const directory = this.directory(entry.name);
1491
1896
  if ((await stat2(directory)).mtimeMs < cutoff && !await Effect4.runPromise(this.store.getAttachment(entry.name)))
1492
1897
  await rm(directory, { recursive: true, force: true });
@@ -1574,24 +1979,24 @@ var init_event_server = __esm({
1574
1979
  });
1575
1980
 
1576
1981
  // src/security.ts
1577
- import { createHash as createHash2, randomBytes, randomInt, randomUUID as randomUUID3, timingSafeEqual } from "node:crypto";
1982
+ import { createHash as createHash2, randomBytes as randomBytes2, randomInt, randomUUID as randomUUID3, timingSafeEqual } from "node:crypto";
1578
1983
  import { Effect as Effect5 } from "effect";
1579
- import { z as z8 } from "zod";
1984
+ import { z as z9 } from "zod";
1580
1985
  async function registerSecurity(app2, security2, version, allowedOrigins2) {
1581
1986
  app2.addHook("onRequest", async (request, reply) => {
1582
- const path13 = new URL(request.url, "http://localhost");
1987
+ const path15 = new URL(request.url, "http://localhost");
1583
1988
  reply.header("Cache-Control", "no-store");
1584
1989
  if (request.method === "OPTIONS") return;
1585
1990
  if (request.headers.origin && !allowedOrigins2.includes(request.headers.origin))
1586
1991
  return reply.code(403).send({ error: "Browser origin is not allowed." });
1587
- if (path13.pathname === "/health" || path13.pathname === "/api/auth/pair" && request.method === "POST")
1992
+ if (path15.pathname === "/health" || path15.pathname === "/api/auth/pair" && request.method === "POST")
1588
1993
  return;
1589
1994
  const authorization = request.headers.authorization ?? "";
1590
1995
  const token = authorization.startsWith("Bearer ") ? authorization.slice(7) : "";
1591
- const asset = request.method === "GET" && /^\/api\/projects\/[^/]+\/attachments\/[^/]+\/content$/.test(path13.pathname);
1592
- const ticket = path13.searchParams.get("ticket");
1593
- path13.searchParams.delete("ticket");
1594
- if (!security2.authenticate(token) && !(asset && ticket && security2.redeemTicket(ticket, path13.pathname + path13.search, false)))
1996
+ const asset = request.method === "GET" && /^\/api\/projects\/[^/]+\/attachments\/[^/]+\/content$/.test(path15.pathname);
1997
+ const ticket = path15.searchParams.get("ticket");
1998
+ path15.searchParams.delete("ticket");
1999
+ if (!security2.authenticate(token) && !(asset && ticket && security2.redeemTicket(ticket, path15.pathname + path15.search, false)))
1595
2000
  return reply.code(401).send({ error: "Pair this device with your server.", code: "UNAUTHORIZED" });
1596
2001
  if (!asset && request.headers["x-agentrific-protocol"] !== String(PROTOCOL_VERSION))
1597
2002
  return reply.code(409).send({
@@ -1646,8 +2051,8 @@ async function registerSecurity(app2, security2, version, allowedOrigins2) {
1646
2051
  ticket: security2.ticket((request.headers.authorization ?? "").slice(7), "/api/events")
1647
2052
  }));
1648
2053
  app2.post("/api/auth/asset-ticket", (request, reply) => {
1649
- const input = z8.object({
1650
- path: z8.string().regex(/^\/api\/projects\/[^/?]+\/attachments\/[^/?]+\/content(?:\?preview=1)?$/)
2054
+ const input = z9.object({
2055
+ path: z9.string().regex(/^\/api\/projects\/[^/?]+\/attachments\/[^/?]+\/content(?:\?preview=1)?$/)
1651
2056
  }).safeParse(request.body);
1652
2057
  if (!input.success) return reply.code(400).send({ error: "Choose a valid attachment." });
1653
2058
  return {
@@ -1661,7 +2066,7 @@ var init_security = __esm({
1661
2066
  "use strict";
1662
2067
  init_src();
1663
2068
  init_pairing();
1664
- secret = () => randomBytes(32).toString("hex");
2069
+ secret = () => randomBytes2(32).toString("hex");
1665
2070
  digest = (value) => createHash2("sha256").update(value).digest("hex");
1666
2071
  ServerSecurity = class {
1667
2072
  constructor(store2, serverId) {
@@ -1724,17 +2129,17 @@ var init_security = __esm({
1724
2129
  if (!/^[a-f0-9]{64}$/.test(token)) return void 0;
1725
2130
  return Effect5.runSync(this.store.deviceForToken(digest(token)));
1726
2131
  }
1727
- ticket(token, path13) {
2132
+ ticket(token, path15) {
1728
2133
  this.prune();
1729
2134
  if (!this.authenticate(token) || this.tickets.size >= 1e4) return void 0;
1730
2135
  const value = secret();
1731
- this.tickets.set(value, { tokenHash: digest(token), expiresAt: Date.now() + 6e4, path: path13 });
2136
+ this.tickets.set(value, { tokenHash: digest(token), expiresAt: Date.now() + 6e4, path: path15 });
1732
2137
  return value;
1733
2138
  }
1734
- redeemTicket(value, path13, consume = true) {
2139
+ redeemTicket(value, path15, consume = true) {
1735
2140
  this.prune();
1736
2141
  const ticket = this.tickets.get(value);
1737
- if (!ticket || ticket.path !== path13) return void 0;
2142
+ if (!ticket || ticket.path !== path15) return void 0;
1738
2143
  if (consume) this.tickets.delete(value);
1739
2144
  return Effect5.runSync(this.store.deviceForToken(ticket.tokenHash));
1740
2145
  }
@@ -1748,6 +2153,101 @@ var init_security = __esm({
1748
2153
  }
1749
2154
  });
1750
2155
 
2156
+ // src/desktop-phone.ts
2157
+ var DesktopPhoneConnection;
2158
+ var init_desktop_phone = __esm({
2159
+ "src/desktop-phone.ts"() {
2160
+ "use strict";
2161
+ init_tunnels();
2162
+ init_opentunnel();
2163
+ DesktopPhoneConnection = class {
2164
+ constructor(origin, changed, directory, detect = async () => [
2165
+ { provider: "opentunnel", available: true, reason: "Built in \xB7 stable public HTTPS address" },
2166
+ ...await detectTunnels()
2167
+ ], create2 = (option, disconnected) => option.provider === "opentunnel" ? new OpenTunnel(directory, disconnected) : new ManagedTunnel({ ...option, provider: option.provider }, disconnected)) {
2168
+ this.origin = origin;
2169
+ this.changed = changed;
2170
+ this.directory = directory;
2171
+ this.detect = detect;
2172
+ this.create = create2;
2173
+ }
2174
+ origin;
2175
+ changed;
2176
+ directory;
2177
+ detect;
2178
+ create;
2179
+ tunnel;
2180
+ provider = null;
2181
+ publicUrl = null;
2182
+ pending = Promise.resolve();
2183
+ snapshot() {
2184
+ return { provider: this.provider, publicUrl: this.publicUrl };
2185
+ }
2186
+ async options() {
2187
+ return (await this.detect()).map(({ provider, available, reason }) => ({
2188
+ provider,
2189
+ available,
2190
+ reason
2191
+ }));
2192
+ }
2193
+ serialize(operation) {
2194
+ const result = this.pending.then(operation);
2195
+ this.pending = result.then(
2196
+ () => {
2197
+ },
2198
+ () => {
2199
+ }
2200
+ );
2201
+ return result;
2202
+ }
2203
+ start(provider, signal) {
2204
+ return this.serialize(async () => {
2205
+ signal?.throwIfAborted();
2206
+ if (this.tunnel && this.publicUrl && this.provider === provider) return this.snapshot();
2207
+ if (this.tunnel)
2208
+ throw new Error("Disconnect the current phone connection before changing providers.");
2209
+ const option = (await this.detect()).find((candidate) => candidate.provider === provider);
2210
+ if (!option?.available) throw new Error(option?.reason || "Tunnel provider unavailable.");
2211
+ let tunnel;
2212
+ tunnel = this.create(option, () => {
2213
+ if (this.tunnel !== tunnel) return;
2214
+ void tunnel.close().catch(() => {
2215
+ });
2216
+ this.tunnel = void 0;
2217
+ this.provider = null;
2218
+ this.publicUrl = null;
2219
+ this.changed(null);
2220
+ });
2221
+ this.tunnel = tunnel;
2222
+ try {
2223
+ const url = await tunnel.start(this.origin(), signal);
2224
+ signal?.throwIfAborted();
2225
+ this.provider = provider;
2226
+ this.publicUrl = url;
2227
+ this.changed(url);
2228
+ return this.snapshot();
2229
+ } catch (error) {
2230
+ await tunnel.close();
2231
+ this.tunnel = void 0;
2232
+ this.changed(null);
2233
+ throw error;
2234
+ }
2235
+ });
2236
+ }
2237
+ close() {
2238
+ return this.serialize(async () => {
2239
+ await this.tunnel?.close();
2240
+ this.tunnel = void 0;
2241
+ this.provider = null;
2242
+ this.publicUrl = null;
2243
+ this.changed(null);
2244
+ return this.snapshot();
2245
+ });
2246
+ }
2247
+ };
2248
+ }
2249
+ });
2250
+
1751
2251
  // src/maintenance.ts
1752
2252
  var MaintenanceError, Maintenance;
1753
2253
  var init_maintenance = __esm({
@@ -1793,9 +2293,9 @@ var init_maintenance = __esm({
1793
2293
  }
1794
2294
  attach(app2) {
1795
2295
  app2.addHook("onRequest", async (request, reply) => {
1796
- const path13 = request.url.split("?")[0];
2296
+ const path15 = request.url.split("?")[0];
1797
2297
  if (["GET", "HEAD", "OPTIONS"].includes(request.method)) return;
1798
- const allowed = path13.startsWith("/api/auth/") || /^\/api\/sessions\/[^/]+\/stop$/.test(path13);
2298
+ const allowed = path15.startsWith("/api/auth/") || /^\/api\/sessions\/[^/]+\/stop$/.test(path15);
1799
2299
  if (this.paused && !allowed)
1800
2300
  return reply.code(503).send({ code: "MAINTENANCE", error: new MaintenanceError().message });
1801
2301
  this.requests.add(request.id);
@@ -2069,7 +2569,7 @@ var init_git_flow = __esm({
2069
2569
  });
2070
2570
 
2071
2571
  // src/runtime.ts
2072
- import path6 from "node:path";
2572
+ import path8 from "node:path";
2073
2573
  import { randomUUID as randomUUID4 } from "node:crypto";
2074
2574
  import { Context as Context2, Data as Data2, Effect as Effect8, Fiber, Layer as Layer2, Semaphore as Semaphore2 } from "effect";
2075
2575
  var AgentError, AgentController, AgentSessions;
@@ -2128,8 +2628,8 @@ var init_runtime = __esm({
2128
2628
  const agent = yield* Effect8.tryPromise({
2129
2629
  try: () => this.factory.open(
2130
2630
  session.id,
2131
- session.workspacePath ?? path6.join(this.worktreesRoot, session.id),
2132
- path6.join(this.sessionsRoot, session.id),
2631
+ session.workspacePath ?? path8.join(this.worktreesRoot, session.id),
2632
+ path8.join(this.sessionsRoot, session.id),
2133
2633
  session.model,
2134
2634
  session
2135
2635
  ),
@@ -2416,17 +2916,17 @@ var init_workflow_command = __esm({
2416
2916
  });
2417
2917
 
2418
2918
  // src/workflow-schema.ts
2419
- import { z as z9 } from "zod";
2919
+ import { z as z10 } from "zod";
2420
2920
  var DeliveryJobSchema, PreparationJobSchema, WorkflowJobSchema;
2421
2921
  var init_workflow_schema = __esm({
2422
2922
  "src/workflow-schema.ts"() {
2423
2923
  "use strict";
2424
2924
  init_src();
2425
- DeliveryJobSchema = z9.object({
2426
- kind: z9.literal("delivery"),
2427
- sessionId: z9.string(),
2925
+ DeliveryJobSchema = z10.object({
2926
+ kind: z10.literal("delivery"),
2927
+ sessionId: z10.string(),
2428
2928
  request: DeliveryRequestSchema,
2429
- phase: z9.enum([
2929
+ phase: z10.enum([
2430
2930
  "validating",
2431
2931
  "drafting",
2432
2932
  "committing",
@@ -2436,25 +2936,25 @@ var init_workflow_schema = __esm({
2436
2936
  "opening_pr",
2437
2937
  "delivered"
2438
2938
  ]),
2439
- attempt: z9.number().int().positive(),
2440
- commitSha: z9.string().optional(),
2441
- title: z9.string(),
2442
- body: z9.string(),
2443
- logs: z9.string().default(""),
2444
- failed: z9.boolean().default(false),
2445
- result: z9.enum(["merged", "pr_opened", "no_changes"]).optional(),
2446
- url: z9.string().optional(),
2447
- message: z9.string().optional()
2939
+ attempt: z10.number().int().positive(),
2940
+ commitSha: z10.string().optional(),
2941
+ title: z10.string(),
2942
+ body: z10.string(),
2943
+ logs: z10.string().default(""),
2944
+ failed: z10.boolean().default(false),
2945
+ result: z10.enum(["merged", "pr_opened", "no_changes"]).optional(),
2946
+ url: z10.string().optional(),
2947
+ message: z10.string().optional()
2448
2948
  });
2449
- PreparationJobSchema = z9.object({
2450
- kind: z9.literal("preparation"),
2451
- sessionId: z9.string(),
2452
- projectPath: z9.string(),
2949
+ PreparationJobSchema = z10.object({
2950
+ kind: z10.literal("preparation"),
2951
+ sessionId: z10.string(),
2952
+ projectPath: z10.string(),
2453
2953
  input: CreateSessionSchema,
2454
- stage: z9.enum(["creating", "installing", "ready", "failed"]),
2455
- logs: z9.string().default("")
2954
+ stage: z10.enum(["creating", "installing", "ready", "failed"]),
2955
+ logs: z10.string().default("")
2456
2956
  });
2457
- WorkflowJobSchema = z9.discriminatedUnion("kind", [
2957
+ WorkflowJobSchema = z10.discriminatedUnion("kind", [
2458
2958
  DeliveryJobSchema,
2459
2959
  PreparationJobSchema
2460
2960
  ]);
@@ -2463,7 +2963,7 @@ var init_workflow_schema = __esm({
2463
2963
 
2464
2964
  // src/delivery.ts
2465
2965
  import { Effect as Effect9 } from "effect";
2466
- import { z as z10 } from "zod";
2966
+ import { z as z11 } from "zod";
2467
2967
  var DeliveryCoordinator;
2468
2968
  var init_delivery = __esm({
2469
2969
  "src/delivery.ts"() {
@@ -2762,11 +3262,11 @@ ${workflowError(error instanceof Error ? error : String(error))}`
2762
3262
  "--json",
2763
3263
  "url,state,headRefOid"
2764
3264
  ]);
2765
- const prs = z10.array(
2766
- z10.object({
2767
- url: z10.string().url(),
2768
- state: z10.string(),
2769
- headRefOid: z10.string().optional()
3265
+ const prs = z11.array(
3266
+ z11.object({
3267
+ url: z11.string().url(),
3268
+ state: z11.string(),
3269
+ headRefOid: z11.string().optional()
2770
3270
  })
2771
3271
  ).parse(JSON.parse(existing));
2772
3272
  const open = prs.find(
@@ -2809,10 +3309,10 @@ ${workflowError(error instanceof Error ? error : String(error))}`
2809
3309
  });
2810
3310
 
2811
3311
  // src/preparation.ts
2812
- import { mkdir as mkdir4, readFile as readFile3 } from "node:fs/promises";
2813
- import path7 from "node:path";
3312
+ import { mkdir as mkdir5, readFile as readFile4 } from "node:fs/promises";
3313
+ import path9 from "node:path";
2814
3314
  import { Effect as Effect10 } from "effect";
2815
- import { z as z11 } from "zod";
3315
+ import { z as z12 } from "zod";
2816
3316
  var PreparationCoordinator;
2817
3317
  var init_preparation = __esm({
2818
3318
  "src/preparation.ts"() {
@@ -2913,7 +3413,7 @@ ${logs}` : ""}`);
2913
3413
  const workspace = session.workspacePath;
2914
3414
  if (branchAtWorkspace !== void 0)
2915
3415
  throw new Error("The run workspace already exists on a different branch.");
2916
- await mkdir4(path7.dirname(workspace), { recursive: true });
3416
+ await mkdir5(path9.dirname(workspace), { recursive: true });
2917
3417
  try {
2918
3418
  await command(
2919
3419
  projectPath,
@@ -2933,12 +3433,12 @@ ${logs}` : ""}`);
2933
3433
  }
2934
3434
  }
2935
3435
  async install(workspace, command) {
2936
- const packageFile = await readFile3(path7.join(workspace, "package.json"), "utf8").catch(
3436
+ const packageFile = await readFile4(path9.join(workspace, "package.json"), "utf8").catch(
2937
3437
  () => void 0
2938
3438
  );
2939
3439
  if (!packageFile) return;
2940
- const metadata = z11.object({ packageManager: z11.string().optional() }).parse(JSON.parse(packageFile));
2941
- const has = async (name) => readFile3(path7.join(workspace, name)).then(
3440
+ const metadata = z12.object({ packageManager: z12.string().optional() }).parse(JSON.parse(packageFile));
3441
+ const has = async (name) => readFile4(path9.join(workspace, name)).then(
2942
3442
  () => true,
2943
3443
  () => false
2944
3444
  );
@@ -3021,7 +3521,7 @@ ${logs}` : ""}`);
3021
3521
 
3022
3522
  // src/delivery-writer.ts
3023
3523
  import { createAgentSession, SessionManager } from "@earendil-works/pi-coding-agent";
3024
- import { z as z12 } from "zod";
3524
+ import { z as z13 } from "zod";
3025
3525
  function deliveryWriter(models2, createSession = createAgentSession) {
3026
3526
  return async (run, input, diff, signal) => {
3027
3527
  signal?.throwIfAborted();
@@ -3062,7 +3562,7 @@ var DraftSchema;
3062
3562
  var init_delivery_writer = __esm({
3063
3563
  "src/delivery-writer.ts"() {
3064
3564
  "use strict";
3065
- DraftSchema = z12.object({ title: z12.string().trim().min(1).max(120), body: z12.string().trim().min(1).max(1e4) }).strict();
3565
+ DraftSchema = z13.object({ title: z13.string().trim().min(1).max(120), body: z13.string().trim().min(1).max(1e4) }).strict();
3066
3566
  }
3067
3567
  });
3068
3568
 
@@ -3219,19 +3719,19 @@ var init_delivery_tool = __esm({
3219
3719
  // src/file-change.ts
3220
3720
  import { createWriteToolDefinition, defineTool as defineTool2 } from "@earendil-works/pi-coding-agent";
3221
3721
  import { createTwoFilesPatch } from "diff";
3222
- import { mkdir as mkdir5, readFile as readFile4, writeFile as writeFile3 } from "node:fs/promises";
3223
- import { z as z13 } from "zod";
3722
+ import { mkdir as mkdir6, readFile as readFile5, writeFile as writeFile4 } from "node:fs/promises";
3723
+ import { z as z14 } from "zod";
3224
3724
  function boundFileChange(change) {
3225
3725
  if ((change.patch?.length ?? 0) + (change.content?.length ?? 0) > previewLimit)
3226
3726
  return { path: change.path, omitted: true };
3227
3727
  return change;
3228
3728
  }
3229
- function completedFileChange(result, path13) {
3729
+ function completedFileChange(result, path15) {
3230
3730
  const parsed = EditResultSchema.safeParse(result);
3231
3731
  if (!parsed.success) return;
3232
3732
  const details = parsed.data.details;
3233
3733
  if (details?.fileChange) return boundFileChange(details.fileChange);
3234
- if (path13 && details?.patch) return boundFileChange({ path: path13, patch: details.patch });
3734
+ if (path15 && details?.patch) return boundFileChange({ path: path15, patch: details.patch });
3235
3735
  }
3236
3736
  function fileWriteTool(cwd) {
3237
3737
  const base = createWriteToolDefinition(cwd);
@@ -3248,16 +3748,16 @@ function fileWriteTool(cwd) {
3248
3748
  const tool = createWriteToolDefinition(cwd, {
3249
3749
  operations: {
3250
3750
  mkdir: async (dir) => {
3251
- await mkdir5(dir, { recursive: true });
3751
+ await mkdir6(dir, { recursive: true });
3252
3752
  },
3253
3753
  // Pi invokes this inside its per-file mutation queue, so the snapshot
3254
3754
  // and write cannot race another built-in edit/write on the same file.
3255
- writeFile: async (path13, content) => {
3256
- const previous = await readFile4(path13, "utf8").catch((error) => {
3755
+ writeFile: async (path15, content) => {
3756
+ const previous = await readFile5(path15, "utf8").catch((error) => {
3257
3757
  if (error.code === "ENOENT") return void 0;
3258
3758
  throw error;
3259
3759
  });
3260
- await writeFile3(path13, content, "utf8");
3760
+ await writeFile4(path15, content, "utf8");
3261
3761
  fileChange = boundFileChange(
3262
3762
  previous === void 0 ? { path: args.path, content } : {
3263
3763
  path: args.path,
@@ -3278,8 +3778,8 @@ var init_file_change = __esm({
3278
3778
  "use strict";
3279
3779
  init_src();
3280
3780
  previewLimit = 256e3;
3281
- EditResultSchema = z13.object({
3282
- details: z13.object({ patch: z13.string().optional(), fileChange: FileChangeSchema.optional() }).optional()
3781
+ EditResultSchema = z14.object({
3782
+ details: z14.object({ patch: z14.string().optional(), fileChange: FileChangeSchema.optional() }).optional()
3283
3783
  });
3284
3784
  }
3285
3785
  });
@@ -3291,7 +3791,7 @@ import {
3291
3791
  createAgentSession as createAgentSession2,
3292
3792
  SessionManager as SessionManager2
3293
3793
  } from "@earendil-works/pi-coding-agent";
3294
- import { z as z14 } from "zod";
3794
+ import { z as z15 } from "zod";
3295
3795
  function toolOutput(value) {
3296
3796
  const parsed = ToolResultSchema.safeParse(value);
3297
3797
  const text = parsed.success ? parsed.data.content?.filter((part) => part.type === "text").map((part) => part.text || "").join("\n") || "Completed without text output." : boundedJson(value, 4e3);
@@ -3405,7 +3905,7 @@ function createPiFactory(files, models2, delivery2, createSession = createAgentS
3405
3905
  return false;
3406
3906
  }
3407
3907
  const filePaths = /* @__PURE__ */ new Map();
3408
- const FileArgsSchema = z14.object({ path: z14.string() });
3908
+ const FileArgsSchema = z15.object({ path: z15.string() });
3409
3909
  function handleTool(event) {
3410
3910
  if (event.type === "tool_execution_start") {
3411
3911
  flushText();
@@ -3470,8 +3970,8 @@ var init_pi = __esm({
3470
3970
  init_models();
3471
3971
  init_delivery_tool();
3472
3972
  init_file_change();
3473
- ToolResultSchema = z14.object({
3474
- content: z14.array(z14.object({ type: z14.string(), text: z14.string().optional() })).optional()
3973
+ ToolResultSchema = z15.object({
3974
+ content: z15.array(z15.object({ type: z15.string(), text: z15.string().optional() })).optional()
3475
3975
  }).passthrough();
3476
3976
  boundedJson = (value, limit) => {
3477
3977
  let text;
@@ -3486,19 +3986,19 @@ var init_pi = __esm({
3486
3986
  });
3487
3987
 
3488
3988
  // src/projects.ts
3489
- import { access as access2, readdir as readdir2, realpath, stat as stat3, readFile as readFile5, writeFile as writeFile4 } from "node:fs/promises";
3989
+ import { access as access2, readdir as readdir2, realpath, stat as stat3, readFile as readFile6, writeFile as writeFile5 } from "node:fs/promises";
3490
3990
  import { randomUUID as randomUUID6 } from "node:crypto";
3491
- import { z as z15 } from "zod";
3991
+ import { z as z16 } from "zod";
3492
3992
  import { constants as constants2 } from "node:fs";
3493
3993
  import { homedir as homedir3 } from "node:os";
3494
- import path8 from "node:path";
3994
+ import path10 from "node:path";
3495
3995
  import { Effect as Effect13, Semaphore as Semaphore3 } from "effect";
3496
3996
  function resolveProjectsRoot(input) {
3497
3997
  return Effect13.tryPromise({
3498
3998
  try: async () => {
3499
3999
  const trimmed = input.trim();
3500
- const expanded = trimmed === "~" ? homedir3() : trimmed.startsWith("~/") ? path8.join(homedir3(), trimmed.slice(2)) : trimmed;
3501
- if (!path8.isAbsolute(expanded))
4000
+ const expanded = trimmed === "~" ? homedir3() : trimmed.startsWith("~/") ? path10.join(homedir3(), trimmed.slice(2)) : trimmed;
4001
+ if (!path10.isAbsolute(expanded))
3502
4002
  throw new Error("Use an absolute server path or a path beginning with ~/.");
3503
4003
  const resolved = await realpath(expanded);
3504
4004
  if (!(await stat3(resolved)).isDirectory())
@@ -3520,34 +4020,34 @@ function projectLock(store2) {
3520
4020
  return lock;
3521
4021
  }
3522
4022
  async function gitDirectory(directory) {
3523
- const location = path8.join(directory, ".git");
4023
+ const location = path10.join(directory, ".git");
3524
4024
  const info = await stat3(location);
3525
4025
  if (info.isDirectory()) return location;
3526
- const contents = await readFile5(location, "utf8");
4026
+ const contents = await readFile6(location, "utf8");
3527
4027
  const match = /^gitdir: (.+)\s*$/m.exec(contents);
3528
4028
  if (!match) throw new Error("Invalid Git worktree metadata.");
3529
- const resolved = await realpath(path8.resolve(directory, match[1].trim()));
4029
+ const resolved = await realpath(path10.resolve(directory, match[1].trim()));
3530
4030
  if (!(await stat3(resolved)).isDirectory()) throw new Error("Git directory is missing.");
3531
4031
  return resolved;
3532
4032
  }
3533
4033
  async function identity(directory, preferred) {
3534
- const filename = path8.join(await gitDirectory(directory), "agentrific-project-id");
4034
+ const filename = path10.join(await gitDirectory(directory), "agentrific-project-id");
3535
4035
  try {
3536
- return ProjectId.parse(await readFile5(filename, "utf8"));
4036
+ return ProjectId.parse(await readFile6(filename, "utf8"));
3537
4037
  } catch (error) {
3538
- if (!z15.object({ code: z15.literal("ENOENT") }).safeParse(error).success) throw error;
3539
- const legacyFilename = path8.join(await gitDirectory(directory), "chatplans-project-id");
3540
- const legacy = await readFile5(legacyFilename, "utf8").catch((cause) => {
3541
- if (!z15.object({ code: z15.literal("ENOENT") }).safeParse(cause).success) throw cause;
4038
+ if (!z16.object({ code: z16.literal("ENOENT") }).safeParse(error).success) throw error;
4039
+ const legacyFilename = path10.join(await gitDirectory(directory), "chatplans-project-id");
4040
+ const legacy = await readFile6(legacyFilename, "utf8").catch((cause) => {
4041
+ if (!z16.object({ code: z16.literal("ENOENT") }).safeParse(cause).success) throw cause;
3542
4042
  return null;
3543
4043
  });
3544
4044
  const id = legacy === null ? preferred ?? randomUUID6() : ProjectId.parse(legacy);
3545
4045
  try {
3546
- await writeFile4(filename, id + "\n", { flag: "wx" });
4046
+ await writeFile5(filename, id + "\n", { flag: "wx" });
3547
4047
  return id;
3548
4048
  } catch (writeError) {
3549
- if (!z15.object({ code: z15.literal("EEXIST") }).safeParse(writeError).success) throw writeError;
3550
- return ProjectId.parse(await readFile5(filename, "utf8"));
4049
+ if (!z16.object({ code: z16.literal("EEXIST") }).safeParse(writeError).success) throw writeError;
4050
+ return ProjectId.parse(await readFile6(filename, "utf8"));
3551
4051
  }
3552
4052
  }
3553
4053
  }
@@ -3560,15 +4060,15 @@ function scanProjects(root, store2) {
3560
4060
  const entries = await readdir2(root, { withFileTypes: true });
3561
4061
  for (const entry of entries) {
3562
4062
  if (!entry.isDirectory()) continue;
3563
- const directory = path8.join(root, entry.name);
3564
- if (!await stat3(path8.join(directory, ".git")).catch(() => null)) continue;
4063
+ const directory = path10.join(root, entry.name);
4064
+ if (!await stat3(path10.join(directory, ".git")).catch(() => null)) continue;
3565
4065
  const previous = registered.find((project2) => project2.path === directory);
3566
4066
  let id = await identity(directory, previous?.available ? void 0 : previous?.id);
3567
4067
  let existing = registered.find((project2) => project2.id === id);
3568
- if (found.some((project2) => project2.id === id) || existing && existing.path !== directory && await stat3(path8.join(existing.path, ".git")).catch(() => null)) {
4068
+ if (found.some((project2) => project2.id === id) || existing && existing.path !== directory && await stat3(path10.join(existing.path, ".git")).catch(() => null)) {
3569
4069
  id = randomUUID6();
3570
- await writeFile4(
3571
- path8.join(await gitDirectory(directory), "agentrific-project-id"),
4070
+ await writeFile5(
4071
+ path10.join(await gitDirectory(directory), "agentrific-project-id"),
3572
4072
  id + "\n"
3573
4073
  );
3574
4074
  existing = void 0;
@@ -3601,16 +4101,16 @@ function reconnectProject(root, id, input, store2) {
3601
4101
  ))
3602
4102
  throw new Error("Stop this project's running sessions before reconnecting its folder.");
3603
4103
  const directory = await Effect13.runPromise(resolveProjectsRoot(input));
3604
- if (path8.dirname(directory) !== await realpath(root))
4104
+ if (path10.dirname(directory) !== await realpath(root))
3605
4105
  throw new Error("Choose a Git repository directly inside the configured projects folder.");
3606
- const filename = path8.join(await gitDirectory(directory), "agentrific-project-id");
4106
+ const filename = path10.join(await gitDirectory(directory), "agentrific-project-id");
3607
4107
  const replacedId = await identity(directory);
3608
- const project = { id, name: path8.basename(directory), path: directory, available: true };
3609
- await writeFile4(filename, id + "\n");
4108
+ const project = { id, name: path10.basename(directory), path: directory, available: true };
4109
+ await writeFile5(filename, id + "\n");
3610
4110
  try {
3611
4111
  await Effect13.runPromise(store2.relocateProject(project, previous.path, replacedId));
3612
4112
  } catch (error) {
3613
- await writeFile4(filename, replacedId + "\n");
4113
+ await writeFile5(filename, replacedId + "\n");
3614
4114
  throw error;
3615
4115
  }
3616
4116
  return project;
@@ -3623,7 +4123,7 @@ var init_projects = __esm({
3623
4123
  "src/projects.ts"() {
3624
4124
  "use strict";
3625
4125
  locks2 = /* @__PURE__ */ new WeakMap();
3626
- ProjectId = z15.string().trim().min(1).max(255);
4126
+ ProjectId = z16.string().trim().min(1).max(255);
3627
4127
  }
3628
4128
  });
3629
4129
 
@@ -3643,10 +4143,10 @@ function repositoryIdentity(remote) {
3643
4143
  if (!["https:", "http:", "ssh:", "git:"].includes(url.protocol)) return null;
3644
4144
  if (url.search || url.hash || url.port) return null;
3645
4145
  const host2 = url.hostname.toLowerCase();
3646
- let path13 = url.pathname.replace(/^\/+|\/+$/g, "").replace(/\.git$/, "");
3647
- if (!host2 || !path13 || path13.includes("%") || path13.includes("//")) return null;
3648
- if (["github.com", "bitbucket.org"].includes(host2)) path13 = path13.toLowerCase();
3649
- const key = `${host2}/${path13}`;
4146
+ let path15 = url.pathname.replace(/^\/+|\/+$/g, "").replace(/\.git$/, "");
4147
+ if (!host2 || !path15 || path15.includes("%") || path15.includes("//")) return null;
4148
+ if (["github.com", "bitbucket.org"].includes(host2)) path15 = path15.toLowerCase();
4149
+ const key = `${host2}/${path15}`;
3650
4150
  return { key, display: key };
3651
4151
  }
3652
4152
  async function git(directory, args) {
@@ -3735,36 +4235,36 @@ var init_project_git = __esm({
3735
4235
  });
3736
4236
 
3737
4237
  // src/push-schema.ts
3738
- import { z as z16 } from "zod";
4238
+ import { z as z17 } from "zod";
3739
4239
  var PushBindingSchema, PushStateSchema, PendingPushSchema;
3740
4240
  var init_push_schema = __esm({
3741
4241
  "src/push-schema.ts"() {
3742
4242
  "use strict";
3743
4243
  init_push();
3744
- PushBindingSchema = z16.object({
3745
- bindingId: z16.string().uuid(),
3746
- installationId: z16.string().uuid(),
3747
- deviceId: z16.string().uuid()
4244
+ PushBindingSchema = z17.object({
4245
+ bindingId: z17.string().uuid(),
4246
+ installationId: z17.string().uuid(),
4247
+ deviceId: z17.string().uuid()
3748
4248
  });
3749
- PushStateSchema = z16.object({
3750
- bindings: z16.array(PushBindingSchema).max(10),
3751
- error: z16.string().nullable()
4249
+ PushStateSchema = z17.object({
4250
+ bindings: z17.array(PushBindingSchema).max(10),
4251
+ error: z17.string().nullable()
3752
4252
  });
3753
4253
  PendingPushSchema = PushEventSchema.extend({
3754
- remaining: z16.array(PushBindingSchema),
3755
- createdAt: z16.number(),
3756
- attempts: z16.number().int().nonnegative(),
3757
- nextAt: z16.number()
4254
+ remaining: z17.array(PushBindingSchema),
4255
+ createdAt: z17.number(),
4256
+ attempts: z17.number().int().nonnegative(),
4257
+ nextAt: z17.number()
3758
4258
  });
3759
4259
  }
3760
4260
  });
3761
4261
 
3762
4262
  // src/store.ts
3763
4263
  import { chmodSync, existsSync as existsSync2, mkdirSync as mkdirSync2 } from "node:fs";
3764
- import path9 from "node:path";
4264
+ import path11 from "node:path";
3765
4265
  import { DatabaseSync } from "node:sqlite";
3766
4266
  import { Context as Context3, Data as Data3, Effect as Effect14, Layer as Layer3 } from "effect";
3767
- import { z as z17 } from "zod";
4267
+ import { z as z18 } from "zod";
3768
4268
  import { randomUUID as randomUUID7 } from "node:crypto";
3769
4269
  var StorageError, PayloadRow, EventRow, SqliteSessionStore, SessionStore;
3770
4270
  var init_store = __esm({
@@ -3777,8 +4277,8 @@ var init_store = __esm({
3777
4277
  init_src();
3778
4278
  StorageError = class extends Data3.TaggedError("StorageError") {
3779
4279
  };
3780
- PayloadRow = z17.object({ payload: z17.string() });
3781
- EventRow = z17.object({ sequence: z17.number(), recorded_at: z17.string(), payload: z17.string() });
4280
+ PayloadRow = z18.object({ payload: z18.string() });
4281
+ EventRow = z18.object({ sequence: z18.number(), recorded_at: z18.string(), payload: z18.string() });
3782
4282
  SqliteSessionStore = class {
3783
4283
  constructor(db) {
3784
4284
  this.db = db;
@@ -3829,7 +4329,7 @@ var init_store = __esm({
3829
4329
  const row = PayloadRow.parse(
3830
4330
  this.db.prepare("SELECT payload FROM settings WHERE id = 'server-id'").get()
3831
4331
  );
3832
- return z17.string().uuid().parse(JSON.parse(row.payload));
4332
+ return z18.string().uuid().parse(JSON.parse(row.payload));
3833
4333
  });
3834
4334
  }
3835
4335
  saveDevice(device, tokenHash) {
@@ -3917,10 +4417,10 @@ var init_store = __esm({
3917
4417
  `).all();
3918
4418
  const insert = this.db.prepare("INSERT OR IGNORE INTO projects(id, payload) VALUES (?, ?)");
3919
4419
  for (const row of legacy) {
3920
- const id = z17.object({ id: z17.string() }).parse(row).id;
4420
+ const id = z18.object({ id: z18.string() }).parse(row).id;
3921
4421
  insert.run(
3922
4422
  id,
3923
- JSON.stringify({ id, name: id, path: path9.join(root, id), available: false })
4423
+ JSON.stringify({ id, name: id, path: path11.join(root, id), available: false })
3924
4424
  );
3925
4425
  }
3926
4426
  return this.db.prepare("SELECT payload FROM projects").all().map((row) => ProjectSchema.parse(JSON.parse(PayloadRow.parse(row).payload)));
@@ -3957,7 +4457,7 @@ var init_store = __esm({
3957
4457
  this.db.prepare(
3958
4458
  "UPDATE workflows SET payload = json_set(payload, '$.projectPath', ?) WHERE json_extract(payload, '$.kind') = 'preparation' AND json_extract(payload, '$.projectPath') = ?"
3959
4459
  ).run(project.path, previousPath);
3960
- const relocate = (workspace) => workspace === previousPath || workspace.startsWith(`${previousPath}${path9.sep}`) ? project.path + workspace.slice(previousPath.length) : workspace;
4460
+ const relocate = (workspace) => workspace === previousPath || workspace.startsWith(`${previousPath}${path11.sep}`) ? project.path + workspace.slice(previousPath.length) : workspace;
3961
4461
  for (const row of this.db.prepare("SELECT id, payload FROM sessions WHERE json_extract(payload, '$.projectId') = ?").all(project.id)) {
3962
4462
  const session = SessionSchema.parse(JSON.parse(PayloadRow.parse(row).payload));
3963
4463
  if (session.workspacePath) {
@@ -4127,7 +4627,7 @@ var init_store = __esm({
4127
4627
  if (event.type === "message.user") this.claimAttachments(event.attachments ?? []);
4128
4628
  const result = this.db.prepare("INSERT INTO events(session_id, recorded_at, payload) VALUES (?, ?, ?)").run(sessionId, recordedAt, JSON.stringify(event));
4129
4629
  if (notification) {
4130
- const count = z17.object({ total: z17.number() }).parse(this.db.prepare("SELECT COUNT(*) AS total FROM push_outbox").get());
4630
+ const count = z18.object({ total: z18.number() }).parse(this.db.prepare("SELECT COUNT(*) AS total FROM push_outbox").get());
4131
4631
  if (count.total >= 1e3)
4132
4632
  this.db.prepare(
4133
4633
  "DELETE FROM push_outbox WHERE rowid IN (SELECT rowid FROM push_outbox ORDER BY rowid LIMIT 1)"
@@ -4173,13 +4673,13 @@ var init_store = __esm({
4173
4673
  Effect14.acquireRelease(
4174
4674
  Effect14.try({
4175
4675
  try: () => {
4176
- mkdirSync2(path9.dirname(filename), { recursive: true });
4676
+ mkdirSync2(path11.dirname(filename), { recursive: true });
4177
4677
  const db = new DatabaseSync(filename);
4178
4678
  chmodSync(filename, 384);
4179
4679
  for (const suffix of ["-wal", "-shm"])
4180
4680
  if (existsSync2(filename + suffix)) chmodSync(filename + suffix, 384);
4181
4681
  try {
4182
- const schema = z17.object({ user_version: z17.number() }).parse(db.prepare("PRAGMA user_version").get());
4682
+ const schema = z18.object({ user_version: z18.number() }).parse(db.prepare("PRAGMA user_version").get());
4183
4683
  if (schema.user_version > 1)
4184
4684
  throw new Error("This database requires a newer Agentrific server.");
4185
4685
  db.exec(`
@@ -4216,12 +4716,12 @@ var init_store = __esm({
4216
4716
 
4217
4717
  // src/worktree-cleanup.ts
4218
4718
  import { lstat as lstat2, rm as rm2 } from "node:fs/promises";
4219
- import path10 from "node:path";
4719
+ import path12 from "node:path";
4220
4720
  import { Effect as Effect15 } from "effect";
4221
4721
  async function removeRunFiles(dataRoot2, sessionId) {
4222
- if (path10.basename(sessionId) !== sessionId || sessionId === "." || sessionId === "..")
4722
+ if (path12.basename(sessionId) !== sessionId || sessionId === "." || sessionId === "..")
4223
4723
  throw new Error("Invalid session directory.");
4224
- await rm2(path10.join(dataRoot2, "pi", sessionId), { recursive: true, force: true });
4724
+ await rm2(path12.join(dataRoot2, "pi", sessionId), { recursive: true, force: true });
4225
4725
  }
4226
4726
  var WorktreeCleanup;
4227
4727
  var init_worktree_cleanup = __esm({
@@ -4240,12 +4740,12 @@ var init_worktree_cleanup = __esm({
4240
4740
  projectsRoot;
4241
4741
  async remove(session, discardChanges = true) {
4242
4742
  if (session.gitFlow?.workspace !== "worktree" || !session.workspacePath) return;
4243
- const workspace = path10.resolve(session.workspacePath);
4743
+ const workspace = path12.resolve(session.workspacePath);
4244
4744
  const info = await lstat2(workspace).catch((error) => {
4245
4745
  if (error.code === "ENOENT") return void 0;
4246
4746
  throw error;
4247
4747
  });
4248
- if (workspace !== path10.join(this.root, session.id) || path10.basename(session.id) !== session.id) {
4748
+ if (workspace !== path12.join(this.root, session.id) || path12.basename(session.id) !== session.id) {
4249
4749
  if (!info) return;
4250
4750
  throw new Error("Refusing to remove a worktree outside the managed workspaces directory.");
4251
4751
  }
@@ -4335,10 +4835,10 @@ import { execFile as execFile4 } from "node:child_process";
4335
4835
  import { promisify as promisify4 } from "node:util";
4336
4836
  import { realpath as realpath2 } from "node:fs/promises";
4337
4837
  import { homedir as homedir4 } from "node:os";
4338
- import path11 from "node:path";
4838
+ import path13 from "node:path";
4339
4839
  import { WebSocket } from "ws";
4340
4840
  import { Effect as Effect17, ManagedRuntime } from "effect";
4341
- import { z as z18 } from "zod";
4841
+ import { z as z19 } from "zod";
4342
4842
  import { ModelRuntime } from "@earendil-works/pi-coding-agent";
4343
4843
  function publish(event, workflowUpdate = false) {
4344
4844
  const previous = event.type === "session.updated" ? Effect17.runSync(store.get(event.session.id)) : void 0;
@@ -4399,18 +4899,18 @@ async function resolveSessionProject(projectId) {
4399
4899
  return { error: "Project folder is missing. Reconnect it before starting a run.", status: 404 };
4400
4900
  const projectPath = await realpath2(project.path);
4401
4901
  const resolvedRoot = await realpath2(projectsRoot);
4402
- if (path11.dirname(projectPath) !== resolvedRoot)
4902
+ if (path13.dirname(projectPath) !== resolvedRoot)
4403
4903
  return { error: "Project path is outside projects root.", status: 400 };
4404
4904
  return { projectPath };
4405
4905
  }
4406
4906
  async function resolveSessionWorkspace(id, gitFlow, projectPath) {
4407
- const worktreePath = path11.join(worktreesRoot, id);
4907
+ const worktreePath = path13.join(worktreesRoot, id);
4408
4908
  const branch = gitFlow ? `agentrific/${id.slice(0, 8)}` : null;
4409
4909
  const useProjectCheckout = gitFlow?.workspace === "checkout";
4410
4910
  const workspacePath = !gitFlow || useProjectCheckout ? projectPath : worktreePath;
4411
4911
  if (useProjectCheckout) {
4412
4912
  const serverPath = await realpath2(process.cwd());
4413
- if (serverPath === projectPath || serverPath.startsWith(`${projectPath}${path11.sep}`))
4913
+ if (serverPath === projectPath || serverPath.startsWith(`${projectPath}${path13.sep}`))
4414
4914
  return {
4415
4915
  error: "The server runs from this project. Choose a worktree for this task.",
4416
4916
  status: 409
@@ -4546,10 +5046,10 @@ async function withSessionTaskLock(id, operation) {
4546
5046
  return withTaskLock(lockId, operation);
4547
5047
  }
4548
5048
  function localServerUrl() {
4549
- const address = z18.object({ port: z18.number() }).safeParse(app.server.address());
5049
+ const address = z19.object({ port: z19.number() }).safeParse(app.server.address());
4550
5050
  return address.success ? `http://${host}:${address.data.port}` : void 0;
4551
5051
  }
4552
- var host, port, defaultProjectsRoot, worktreesRoot, execFileAsync, app, dataRoot, releaseLock, effects, store, security, sandboxPushUrl, testPushUrl, push, allowedOrigins, attachmentEffects, attachments, attachmentCleanup, models, delivery, preparation, agentEffects, runtime, worktreeCleanup, maxWorktreeAgeDays, pruneWorktrees, worktreeCleanupTimer, updates, maintenance, stopUpdateDiscovery, HistoryQuery, StartTaskBody, automation, automationTimer, closeControl;
5052
+ var host, port, defaultProjectsRoot, worktreesRoot, execFileAsync, app, dataRoot, releaseLock, effects, store, security, sandboxPushUrl, testPushUrl, push, allowedOrigins, attachmentEffects, attachments, attachmentCleanup, models, delivery, preparation, agentEffects, runtime, worktreeCleanup, maxWorktreeAgeDays, pruneWorktrees, worktreeCleanupTimer, updates, maintenance, stopUpdateDiscovery, HistoryQuery, StartTaskBody, automation, automationTimer, phoneOrigin, phone, closeControl;
4553
5053
  var init_index = __esm({
4554
5054
  async "src/index.ts"() {
4555
5055
  "use strict";
@@ -4560,6 +5060,7 @@ var init_index = __esm({
4560
5060
  init_event_server();
4561
5061
  init_security();
4562
5062
  init_control();
5063
+ init_desktop_phone();
4563
5064
  init_version();
4564
5065
  init_maintenance();
4565
5066
  init_updates();
@@ -4579,9 +5080,9 @@ var init_index = __esm({
4579
5080
  init_repo_chat();
4580
5081
  host = process.env.AGENTRIFIC_HOST ?? "127.0.0.1";
4581
5082
  port = Number(process.env.AGENTRIFIC_PORT ?? 4318);
4582
- defaultProjectsRoot = path11.resolve(process.env.AGENTRIFIC_PROJECTS_DIR ?? "./projects");
4583
- worktreesRoot = path11.resolve(
4584
- process.env.AGENTRIFIC_WORKTREES_DIR ?? path11.join(homedir4(), ".agentrific", "workspaces")
5083
+ defaultProjectsRoot = path13.resolve(process.env.AGENTRIFIC_PROJECTS_DIR ?? "./projects");
5084
+ worktreesRoot = path13.resolve(
5085
+ process.env.AGENTRIFIC_WORKTREES_DIR ?? path13.join(homedir4(), ".agentrific", "workspaces")
4585
5086
  );
4586
5087
  execFileAsync = promisify4(execFile4);
4587
5088
  if (host !== "127.0.0.1" && host !== "::1")
@@ -4604,9 +5105,9 @@ var init_index = __esm({
4604
5105
  }
4605
5106
  }
4606
5107
  });
4607
- dataRoot = path11.resolve(process.env.AGENTRIFIC_DATA_DIR ?? "./data");
5108
+ dataRoot = path13.resolve(process.env.AGENTRIFIC_DATA_DIR ?? "./data");
4608
5109
  releaseLock = await acquireServerLock(dataRoot);
4609
- effects = ManagedRuntime.make(SessionStore.layer(path11.join(dataRoot, "sessions.sqlite")));
5110
+ effects = ManagedRuntime.make(SessionStore.layer(path13.join(dataRoot, "sessions.sqlite")));
4610
5111
  store = await effects.runPromise(SessionStore);
4611
5112
  security = new ServerSecurity(store, await Effect17.runPromise(store.serverIdentity()));
4612
5113
  sandboxPushUrl = process.env.AGENTRIFIC_PUSH_SANDBOX === "1" ? PUSH_SANDBOX_API_URL : void 0;
@@ -4638,7 +5139,7 @@ var init_index = __esm({
4638
5139
  await registerSecurity(app, security, serverVersion, allowedOrigins);
4639
5140
  registerPushRoutes(app, push, security);
4640
5141
  attachmentEffects = ManagedRuntime.make(
4641
- Attachments.layer(path11.join(dataRoot, "attachments"), store)
5142
+ Attachments.layer(path13.join(dataRoot, "attachments"), store)
4642
5143
  );
4643
5144
  attachments = await attachmentEffects.runPromise(Attachments);
4644
5145
  await Effect17.runPromise(attachments.cleanup());
@@ -4703,7 +5204,7 @@ var init_index = __esm({
4703
5204
  createPiFactory(attachments, models, delivery),
4704
5205
  publish,
4705
5206
  worktreesRoot,
4706
- path11.join(dataRoot, "pi"),
5207
+ path13.join(dataRoot, "pi"),
4707
5208
  (sessionId, messageId) => Effect17.runSync(store.hasMessage(sessionId, messageId)),
4708
5209
  (session) => delivery.settled(session.id)
4709
5210
  )
@@ -4864,7 +5365,7 @@ var init_index = __esm({
4864
5365
  return project ?? reply.code(404).send({ error: "Project not found on this server." });
4865
5366
  });
4866
5367
  app.post("/api/projects/:id/reconnect", async (request, reply) => {
4867
- const parsed = z18.object({ path: z18.string().trim().min(1).max(4096) }).safeParse(request.body);
5368
+ const parsed = z19.object({ path: z19.string().trim().min(1).max(4096) }).safeParse(request.body);
4868
5369
  if (!parsed.success)
4869
5370
  return reply.code(400).send({ error: "Enter the project's new folder path." });
4870
5371
  const root = await Effect17.runPromise(store.projectsRoot(defaultProjectsRoot));
@@ -4950,7 +5451,7 @@ var init_index = __esm({
4950
5451
  }
4951
5452
  });
4952
5453
  app.post("/api/tasks/:id/discard-unrun", async (request, reply) => {
4953
- const parsed = z18.object({ updatedAt: z18.string().datetime() }).safeParse(request.body);
5454
+ const parsed = z19.object({ updatedAt: z19.string().datetime() }).safeParse(request.body);
4954
5455
  if (!parsed.success) return reply.code(400).send({ error: "The task revision is required." });
4955
5456
  try {
4956
5457
  await deleteTask(store, request.params.id, void 0, parsed.data.updatedAt);
@@ -5020,9 +5521,9 @@ var init_index = __esm({
5020
5521
  }
5021
5522
  })
5022
5523
  );
5023
- HistoryQuery = z18.object({
5024
- after: z18.coerce.number().int().nonnegative().default(0),
5025
- limit: z18.coerce.number().int().min(1).max(1e3).default(500)
5524
+ HistoryQuery = z19.object({
5525
+ after: z19.coerce.number().int().nonnegative().default(0),
5526
+ limit: z19.coerce.number().int().min(1).max(1e3).default(500)
5026
5527
  });
5027
5528
  app.get("/api/sessions/:id/events", async (request, reply) => {
5028
5529
  const query = HistoryQuery.safeParse(request.query);
@@ -5036,7 +5537,7 @@ var init_index = __esm({
5036
5537
  const location = await resolveSessionProject(request.params.id);
5037
5538
  if (location.error !== void 0)
5038
5539
  return reply.code(location.status).send({ error: location.error });
5039
- const parsed = z18.object({ reset: z18.boolean().optional() }).safeParse(request.body ?? {});
5540
+ const parsed = z19.object({ reset: z19.boolean().optional() }).safeParse(request.body ?? {});
5040
5541
  if (!parsed.success) return reply.code(400).send({ error: parsed.error.flatten() });
5041
5542
  try {
5042
5543
  return await openRepoChat(
@@ -5060,7 +5561,7 @@ var init_index = __esm({
5060
5561
  if (!result.session) return reply.code(result.status).send({ error: result.error });
5061
5562
  return reply.code(201).send(result.session);
5062
5563
  });
5063
- StartTaskBody = z18.object({ prompt: z18.string().trim().max(2e4).optional() }).default({});
5564
+ StartTaskBody = z19.object({ prompt: z19.string().trim().max(2e4).optional() }).default({});
5064
5565
  app.post("/api/tasks/:id/start", async (request, reply) => {
5065
5566
  const body = StartTaskBody.safeParse(request.body ?? {});
5066
5567
  if (!body.success) return reply.code(400).send({ error: "The task prompt is too long." });
@@ -5095,7 +5596,7 @@ var init_index = __esm({
5095
5596
  return automation.configure(parsed.data);
5096
5597
  });
5097
5598
  app.patch("/api/automation/queue", async (request, reply) => {
5098
- const parsed = z18.object({ taskId: z18.string().min(1), beforeId: z18.string().min(1).nullable() }).safeParse(request.body);
5599
+ const parsed = z19.object({ taskId: z19.string().min(1), beforeId: z19.string().min(1).nullable() }).safeParse(request.body);
5099
5600
  if (!parsed.success)
5100
5601
  return reply.code(400).send({ error: "Choose a task and a queue position." });
5101
5602
  try {
@@ -5152,12 +5653,30 @@ var init_index = __esm({
5152
5653
  type: "snapshot",
5153
5654
  sessions: Effect17.runSync(store.list())
5154
5655
  }));
5656
+ phone = process.env.AGENTRIFIC_DESKTOP_SERVER === "1" ? new DesktopPhoneConnection(
5657
+ () => {
5658
+ const origin = localServerUrl();
5659
+ if (!origin) throw new Error("Start the local server before connecting your phone.");
5660
+ return origin;
5661
+ },
5662
+ (url) => {
5663
+ if (phoneOrigin) {
5664
+ const index = allowedOrigins.indexOf(phoneOrigin);
5665
+ if (index >= 0) allowedOrigins.splice(index, 1);
5666
+ }
5667
+ phoneOrigin = url || void 0;
5668
+ process.env.AGENTRIFIC_PUBLIC_URL = url || "";
5669
+ if (url && !allowedOrigins.includes(url)) allowedOrigins.push(url);
5670
+ },
5671
+ path13.join(dataRoot, "phone-connection")
5672
+ ) : void 0;
5155
5673
  closeControl = await startControl(
5156
5674
  dataRoot,
5157
5675
  security,
5158
5676
  () => ({
5159
5677
  serverId: security.serverId,
5160
5678
  publicUrl: process.env.AGENTRIFIC_PUBLIC_URL,
5679
+ phoneProvider: phone?.snapshot().provider,
5161
5680
  localUrl: localServerUrl(),
5162
5681
  serverVersion,
5163
5682
  connectedDevices: new Set(security.sockets.values()).size,
@@ -5170,10 +5689,12 @@ var init_index = __esm({
5170
5689
  async () => {
5171
5690
  await app.close();
5172
5691
  process.exit(0);
5173
- }
5692
+ },
5693
+ phone
5174
5694
  );
5175
5695
  push.start();
5176
5696
  app.addHook("onClose", async () => {
5697
+ await phone?.close();
5177
5698
  await push.close();
5178
5699
  maintenance.enter();
5179
5700
  stopUpdateDiscovery();
@@ -5206,9 +5727,9 @@ var init_index = __esm({
5206
5727
 
5207
5728
  // src/cli.ts
5208
5729
  init_warnings();
5209
- import { mkdir as mkdir6, readFile as readFile6, writeFile as writeFile5 } from "node:fs/promises";
5730
+ import { mkdir as mkdir7, readFile as readFile7, writeFile as writeFile6 } from "node:fs/promises";
5210
5731
  import { homedir as homedir5 } from "node:os";
5211
- import path12 from "node:path";
5732
+ import path14 from "node:path";
5212
5733
  import { fileURLToPath } from "node:url";
5213
5734
 
5214
5735
  // src/service.ts
@@ -5307,284 +5828,18 @@ init_version();
5307
5828
  init_pairing();
5308
5829
  import { spawn as spawn2 } from "node:child_process";
5309
5830
  import { parseArgs } from "node:util";
5310
- import { z as z19 } from "zod";
5831
+ import { z as z20 } from "zod";
5311
5832
 
5312
5833
  // src/terminal.ts
5313
5834
  init_pairing();
5314
5835
  init_cli_output();
5836
+ init_tunnels();
5837
+ init_opentunnel();
5315
5838
  import { createInterface } from "node:readline/promises";
5316
5839
  import select from "@inquirer/select";
5317
5840
  import { emitKeypressEvents as emitKeypressEvents2 } from "node:readline";
5318
5841
  import { styleText as styleText3 } from "node:util";
5319
-
5320
- // src/tunnels.ts
5321
- init_cli_output();
5322
- import { spawn } from "node:child_process";
5323
- import { access, stat } from "node:fs/promises";
5324
- import { constants } from "node:fs";
5325
- import { homedir as homedir2 } from "node:os";
5326
- import path3 from "node:path";
5327
- import { z as z5 } from "zod";
5328
- function signalChild(child, signal) {
5329
- if (!child.pid) return;
5330
- if (process.platform === "win32") {
5331
- child.kill(signal);
5332
- return;
5333
- }
5334
- try {
5335
- process.kill(-child.pid, signal);
5336
- } catch (error) {
5337
- if (!z5.object({ code: z5.literal("ESRCH") }).safeParse(error).success) child.kill(signal);
5338
- }
5339
- }
5340
- function execute2(file, args, timeoutMs, env = process.env) {
5341
- return new Promise((resolve2, reject) => {
5342
- const child = spawn(file, args, {
5343
- env,
5344
- detached: process.platform !== "win32",
5345
- stdio: ["ignore", "pipe", "pipe"]
5346
- });
5347
- let stdout = "";
5348
- let stderr = "";
5349
- const timeout = setTimeout(() => {
5350
- signalChild(child, "SIGKILL");
5351
- reject(new Error("Command probe timed out."));
5352
- }, timeoutMs);
5353
- child.stdout?.on("data", (data) => {
5354
- stdout += data.toString();
5355
- checkOutput();
5356
- });
5357
- child.stderr?.on("data", (data) => {
5358
- stderr += data.toString();
5359
- checkOutput();
5360
- });
5361
- child.once("error", (error) => {
5362
- clearTimeout(timeout);
5363
- reject(error);
5364
- });
5365
- child.once("exit", () => signalChild(child, "SIGKILL"));
5366
- child.once("close", (code) => {
5367
- clearTimeout(timeout);
5368
- if (code !== 0) reject(new Error("Command probe failed."));
5369
- else resolve2({ stdout, stderr });
5370
- });
5371
- function checkOutput() {
5372
- if (stdout.length + stderr.length <= 1048576) return;
5373
- signalChild(child, "SIGKILL");
5374
- reject(new Error("Command probe output exceeded its limit."));
5375
- }
5376
- });
5377
- }
5378
- async function findExecutable(name) {
5379
- const candidates = (process.env.PATH ?? "").split(path3.delimiter).map((directory) => path3.resolve(directory, name));
5380
- if (process.platform === "darwin") {
5381
- candidates.push(`/opt/homebrew/bin/${name}`, `/usr/local/bin/${name}`);
5382
- if (name === "tailscale")
5383
- candidates.push("/Applications/Tailscale.app/Contents/MacOS/Tailscale");
5384
- }
5385
- for (const candidate of candidates) {
5386
- try {
5387
- await access(candidate, constants.X_OK);
5388
- if ((await stat(candidate)).isFile()) return candidate;
5389
- } catch {
5390
- continue;
5391
- }
5392
- }
5393
- return void 0;
5394
- }
5395
- var TcpConfig = z5.object({ TCP: z5.record(z5.string(), z5.unknown()).optional() });
5396
- var ServeConfig = TcpConfig.extend({
5397
- Foreground: z5.record(z5.string(), TcpConfig).optional()
5398
- });
5399
- async function probeTailscale() {
5400
- const option = { provider: "tailscale", available: false, reason: "Not installed" };
5401
- const executable = await findExecutable("tailscale");
5402
- if (!executable) return option;
5403
- option.executable = executable;
5404
- const env = { ...process.env, TAILSCALE_BE_CLI: "1" };
5405
- try {
5406
- const response = await execute2(executable, ["status", "--json"], 3e3, env);
5407
- const status = z5.object({
5408
- BackendState: z5.string(),
5409
- CertDomains: z5.array(z5.string()).optional(),
5410
- Self: z5.object({ DNSName: z5.string() }).nullable().optional()
5411
- }).parse(JSON.parse(response.stdout));
5412
- if (status.BackendState !== "Running")
5413
- return { ...option, reason: "Installed \xB7 connect Tailscale first" };
5414
- const dns = status.Self?.DNSName.replace(/\.$/, "");
5415
- if (!dns || !status.CertDomains?.includes(dns))
5416
- return { ...option, reason: "Enable HTTPS certificates in your tailnet first" };
5417
- const help = await execute2(executable, ["serve", "--help"], 2e3, env);
5418
- if (!`${help.stdout}${help.stderr}`.includes("--https"))
5419
- return { ...option, reason: "Update Tailscale to use foreground HTTPS Serve" };
5420
- const responseConfig = await execute2(executable, ["serve", "status", "--json"], 3e3, env);
5421
- const config = ServeConfig.parse(JSON.parse(responseConfig.stdout) ?? {});
5422
- const occupied = new Set(Object.keys(config.TCP ?? {}));
5423
- for (const foreground of Object.values(config.Foreground ?? {}))
5424
- for (const port3 of Object.keys(foreground.TCP ?? {})) occupied.add(port3);
5425
- const port2 = [443, ...Array.from({ length: 10 }, (_, index) => 8443 + index)].find(
5426
- (candidate) => !occupied.has(String(candidate))
5427
- );
5428
- if (!port2) return { ...option, reason: "HTTPS Serve ports are in use \xB7 paste an existing URL" };
5429
- return {
5430
- ...option,
5431
- available: true,
5432
- reason: "Private to your tailnet \xB7 Tailscale required on your phone",
5433
- publicUrl: new URL(`https://${dns}${port2 === 443 ? "" : `:${port2}`}`).origin,
5434
- httpsPort: port2
5435
- };
5436
- } catch {
5437
- return { ...option, reason: "Installed \xB7 could not verify daemon or Serve access" };
5438
- }
5439
- }
5440
- async function probeCloudflared(home) {
5441
- const option = {
5442
- provider: "cloudflared",
5443
- available: false,
5444
- reason: "Not installed"
5445
- };
5446
- const executable = await findExecutable("cloudflared");
5447
- if (!executable) return option;
5448
- option.executable = executable;
5449
- try {
5450
- await execute2(executable, ["--version"], 2e3);
5451
- const help = await execute2(executable, ["tunnel", "--help"], 2e3);
5452
- if (!`${help.stdout}${help.stderr}`.includes("--url"))
5453
- return { ...option, reason: "Installed \xB7 this version does not support Quick Tunnels" };
5454
- } catch {
5455
- return { ...option, reason: "Installed \xB7 command probe failed or timed out" };
5456
- }
5457
- const directories = [
5458
- path3.join(home, ".cloudflared"),
5459
- path3.join(home, ".cloudflare-warp"),
5460
- path3.join(home, "cloudflare-warp"),
5461
- "/etc/cloudflared",
5462
- "/usr/local/etc/cloudflared"
5463
- ];
5464
- const configurations = await Promise.all(
5465
- directories.flatMap(
5466
- (directory) => ["config.yml", "config.yaml"].map(
5467
- async (name) => access(path3.join(directory, name), constants.F_OK).then(
5468
- () => true,
5469
- () => false
5470
- )
5471
- )
5472
- )
5473
- );
5474
- if (configurations.some(Boolean))
5475
- return { ...option, reason: "Existing tunnel configuration \xB7 paste its HTTPS URL" };
5476
- return { ...option, available: true, reason: "Temporary public HTTPS URL \xB7 changes each start" };
5477
- }
5478
- async function detectTunnels(home = homedir2()) {
5479
- return Promise.all([probeTailscale(), probeCloudflared(home)]);
5480
- }
5481
- var ManagedTunnel = class {
5482
- constructor(option) {
5483
- this.option = option;
5484
- }
5485
- option;
5486
- child;
5487
- stopping = false;
5488
- connected = false;
5489
- closing;
5490
- running() {
5491
- return this.connected && !this.stopping;
5492
- }
5493
- stopOnSignal = () => {
5494
- void this.close();
5495
- };
5496
- stopOnExit = () => {
5497
- if (this.child) signalChild(this.child, "SIGKILL");
5498
- };
5499
- async start(origin, signal) {
5500
- signal?.throwIfAborted();
5501
- if (!this.option.available) throw new Error(this.option.reason);
5502
- const tailscale = this.option.provider === "tailscale";
5503
- const child = spawn(
5504
- this.option.executable ?? this.option.provider,
5505
- tailscale ? ["serve", "--bg=false", `--https=${this.option.httpsPort}`, origin] : ["tunnel", "--no-autoupdate", "--url", origin],
5506
- {
5507
- stdio: ["ignore", "pipe", "pipe"],
5508
- detached: process.platform !== "win32",
5509
- env: tailscale ? { ...process.env, TAILSCALE_BE_CLI: "1" } : process.env
5510
- }
5511
- );
5512
- this.child = child;
5513
- process.once("SIGINT", this.stopOnSignal);
5514
- process.once("SIGTERM", this.stopOnSignal);
5515
- process.once("exit", this.stopOnExit);
5516
- return new Promise((resolve2, reject) => {
5517
- let output = "";
5518
- let address;
5519
- let settled = false;
5520
- const abort = () => fail("Tunnel startup canceled.");
5521
- const timeout = setTimeout(
5522
- () => fail("Tunnel startup timed out. Check your connection or paste an existing HTTPS URL."),
5523
- 45e3
5524
- );
5525
- const fail = (message) => {
5526
- if (settled) return;
5527
- settled = true;
5528
- clearTimeout(timeout);
5529
- signal?.removeEventListener("abort", abort);
5530
- void this.close();
5531
- reject(new Error(message));
5532
- };
5533
- const read = (data) => {
5534
- output = `${output}${data.toString()}`.slice(-65536);
5535
- address = tailscale ? this.option.publicUrl : output.match(/https:\/\/[a-z0-9-]+\.trycloudflare\.com\b/)?.[0];
5536
- const ready = tailscale ? Boolean(
5537
- address && output.includes(address) && output.includes("Available within your tailnet")
5538
- ) : output.includes("Registered tunnel connection");
5539
- if (settled || !address || !ready) return;
5540
- settled = true;
5541
- this.connected = true;
5542
- clearTimeout(timeout);
5543
- signal?.removeEventListener("abort", abort);
5544
- resolve2(address);
5545
- };
5546
- child.stdout?.on("data", read);
5547
- child.stderr?.on("data", read);
5548
- signal?.addEventListener("abort", abort, { once: true });
5549
- child.once(
5550
- "error",
5551
- () => fail(`${this.option.provider} could not start. Check the installation and permissions.`)
5552
- );
5553
- child.once("exit", () => signalChild(child, "SIGTERM"));
5554
- child.once("close", (code) => {
5555
- const unexpected = this.connected && !this.stopping;
5556
- this.connected = false;
5557
- process.removeListener("SIGINT", this.stopOnSignal);
5558
- process.removeListener("SIGTERM", this.stopOnSignal);
5559
- process.removeListener("exit", this.stopOnExit);
5560
- fail(
5561
- `${this.option.provider} exited (${code ?? "signal"}). Check tunnel access or paste an existing HTTPS URL.`
5562
- );
5563
- if (unexpected)
5564
- printError(
5565
- "The managed tunnel stopped. Restart the server to reconnect your phone; local work can continue."
5566
- );
5567
- });
5568
- });
5569
- }
5570
- close() {
5571
- this.stopping = true;
5572
- const child = this.child;
5573
- if (!child?.pid || child.exitCode !== null || child.signalCode !== null)
5574
- return Promise.resolve();
5575
- this.closing ??= new Promise((resolve2) => {
5576
- const timeout = setTimeout(() => signalChild(child, "SIGKILL"), 2e3);
5577
- child.once("close", () => {
5578
- clearTimeout(timeout);
5579
- resolve2();
5580
- });
5581
- signalChild(child, "SIGTERM");
5582
- });
5583
- return this.closing;
5584
- }
5585
- };
5586
-
5587
- // src/terminal.ts
5842
+ import path5 from "node:path";
5588
5843
  async function promptPublicUrl(signal, saved) {
5589
5844
  const prompt = createInterface({ input: process.stdin, output: process.stdout });
5590
5845
  const cancel = () => controller.abort();
@@ -5628,9 +5883,14 @@ async function selectConnectionMethod(options, signal, saved) {
5628
5883
  return select(
5629
5884
  {
5630
5885
  message: "Connection method",
5631
- default: saved ? "existing" : void 0,
5886
+ default: saved ? "existing" : "opentunnel",
5632
5887
  loop: false,
5633
5888
  choices: [
5889
+ {
5890
+ value: "opentunnel",
5891
+ name: "OpenTunnel (default)",
5892
+ description: "Built-in HTTPS connection with a persistent address. No separate CLI needed."
5893
+ },
5634
5894
  ...options.filter((option) => option.available).map((option) => ({
5635
5895
  value: option.provider,
5636
5896
  name: option.provider === "tailscale" ? "Tailscale" : "Cloudflared",
@@ -5661,13 +5921,14 @@ async function selectConnectionMethod(options, signal, saved) {
5661
5921
  { signal }
5662
5922
  );
5663
5923
  }
5664
- async function startSelectedTunnel(options, choice, origin, signal) {
5924
+ async function startSelectedTunnel(options, choice, origin, signal, dataDir) {
5665
5925
  const option = options.find((candidate) => candidate.provider === choice);
5666
- if (!option?.available) throw new Error("The selected tunnel is unavailable.");
5667
- const tunnel = new ManagedTunnel(option);
5926
+ if (choice !== "opentunnel" && !option?.available)
5927
+ throw new Error("The selected tunnel is unavailable.");
5928
+ const tunnel = choice === "opentunnel" ? new OpenTunnel(path5.join(dataDir, "phone-connection")) : new ManagedTunnel(option);
5668
5929
  try {
5669
5930
  console.log(styleText3("dim", `
5670
- Starting ${option.provider}\u2026
5931
+ Starting ${choice}\u2026
5671
5932
  `));
5672
5933
  const url = await tunnel.start(origin, signal);
5673
5934
  return { url, tunnel, remember: false };
@@ -5678,7 +5939,7 @@ async function startSelectedTunnel(options, choice, origin, signal) {
5678
5939
  return void 0;
5679
5940
  }
5680
5941
  }
5681
- async function selectPublicConnection(origin, saved) {
5942
+ async function selectPublicConnection(origin, saved, dataDir = process.env.AGENTRIFIC_DATA_DIR || ".agentrific") {
5682
5943
  const controller = new AbortController();
5683
5944
  const cancel = () => controller.abort();
5684
5945
  process.once("SIGINT", cancel);
@@ -5699,7 +5960,13 @@ async function selectPublicConnection(origin, saved) {
5699
5960
  console.log();
5700
5961
  return { url: await promptPublicUrl(controller.signal, saved), remember: true };
5701
5962
  }
5702
- const connection = await startSelectedTunnel(options, choice, origin, controller.signal);
5963
+ const connection = await startSelectedTunnel(
5964
+ options,
5965
+ choice,
5966
+ origin,
5967
+ controller.signal,
5968
+ dataDir
5969
+ );
5703
5970
  if (connection) return connection;
5704
5971
  }
5705
5972
  } finally {
@@ -5787,11 +6054,14 @@ function startTerminal(dataDir, publicUrl, tunnel) {
5787
6054
  }
5788
6055
 
5789
6056
  // src/cli.ts
6057
+ init_opentunnel();
6058
+ init_tunnels();
5790
6059
  init_cli_output();
5791
- var ConfigSchema = z19.object({
5792
- dataDir: z19.string(),
5793
- projectsDir: z19.string(),
5794
- publicUrl: z19.string().url().optional()
6060
+ var ConfigSchema = z20.object({
6061
+ dataDir: z20.string(),
6062
+ projectsDir: z20.string(),
6063
+ publicUrl: z20.string().url().optional(),
6064
+ connection: z20.enum(["opentunnel", "tailscale", "cloudflared", "existing", "local"]).optional()
5795
6065
  });
5796
6066
  function parseCommandArgs() {
5797
6067
  return parseArgs({
@@ -5799,6 +6069,7 @@ function parseCommandArgs() {
5799
6069
  options: {
5800
6070
  "data-dir": { type: "string" },
5801
6071
  "public-url": { type: "string" },
6072
+ connection: { type: "string" },
5802
6073
  version: { type: "boolean" },
5803
6074
  help: { type: "boolean" },
5804
6075
  check: { type: "boolean" },
@@ -5819,15 +6090,15 @@ async function main() {
5819
6090
  printHelp();
5820
6091
  return;
5821
6092
  }
5822
- const dataDir = path12.resolve(
5823
- values["data-dir"] ?? process.env.AGENTRIFIC_DATA_DIR ?? path12.join(homedir5(), ".agentrific")
6093
+ const dataDir = path14.resolve(
6094
+ values["data-dir"] ?? process.env.AGENTRIFIC_DATA_DIR ?? path14.join(homedir5(), ".agentrific")
5824
6095
  );
5825
- const filename = path12.join(dataDir, "config.json");
6096
+ const filename = path14.join(dataDir, "config.json");
5826
6097
  const config = ConfigSchema.parse(
5827
6098
  JSON.parse(
5828
- await readFile6(filename, "utf8").catch((error) => {
5829
- if (!z19.object({ code: z19.literal("ENOENT") }).safeParse(error).success) throw error;
5830
- return JSON.stringify({ dataDir, projectsDir: path12.join(homedir5(), "projects") });
6099
+ await readFile7(filename, "utf8").catch((error) => {
6100
+ if (!z20.object({ code: z20.literal("ENOENT") }).safeParse(error).success) throw error;
6101
+ return JSON.stringify({ dataDir, projectsDir: path14.join(homedir5(), "projects") });
5831
6102
  })
5832
6103
  )
5833
6104
  );
@@ -5844,11 +6115,12 @@ async function main() {
5844
6115
  throw new Error("Unknown command. Run agentrific --help.");
5845
6116
  }
5846
6117
  async function initCommand({ values, dataDir, filename, config }) {
5847
- await mkdir6(dataDir, { recursive: true, mode: 448 });
6118
+ await mkdir7(dataDir, { recursive: true, mode: 448 });
5848
6119
  const publicUrl = values["public-url"] ?? config.publicUrl;
6120
+ const connection = values.connection ? ConfigSchema.shape.connection.unwrap().parse(values.connection) : config.connection;
5849
6121
  if (publicUrl && new URL(publicUrl).protocol !== "https:")
5850
6122
  throw new Error("Configure an HTTPS URL from Tailscale Serve or Cloudflare Tunnel.");
5851
- await writeFile5(filename, JSON.stringify({ ...config, publicUrl }, null, 2) + "\n", {
6123
+ await writeFile6(filename, JSON.stringify({ ...config, publicUrl, connection }, null, 2) + "\n", {
5852
6124
  mode: 384
5853
6125
  });
5854
6126
  console.log(`Configuration saved to ${filename}. Start with agentrific start.`);
@@ -5861,28 +6133,48 @@ async function startCommand({ values, dataDir, filename, config }) {
5861
6133
  process.stdin.isTTY && process.stdout.isTTY && !values["no-interactive"]
5862
6134
  );
5863
6135
  const explicitUrl = values["public-url"] ?? process.env.AGENTRIFIC_PUBLIC_URL;
6136
+ const method = values.connection ? z20.enum(["opentunnel", "tailscale", "cloudflared", "existing", "local"]).parse(values.connection) : explicitUrl ? "existing" : config.connection;
6137
+ const origin = `http://${process.env.AGENTRIFIC_HOST ?? "127.0.0.1"}:${process.env.AGENTRIFIC_PORT ?? "4318"}`;
5864
6138
  if (interactive) {
5865
6139
  process.env.AGENTRIFIC_INTERACTIVE = "1";
5866
6140
  printHeading("server");
5867
6141
  }
5868
- const connection = interactive && !explicitUrl ? await selectPublicConnection(
5869
- `http://${process.env.AGENTRIFIC_HOST ?? "127.0.0.1"}:${process.env.AGENTRIFIC_PORT ?? "4318"}`,
5870
- config.publicUrl
5871
- ) : {
5872
- url: explicitUrl ? pairingOrigin(explicitUrl) : config.publicUrl,
6142
+ let connection = interactive && !explicitUrl && !method ? await selectPublicConnection(origin, config.publicUrl, dataDir) : {
6143
+ url: method === "local" ? void 0 : explicitUrl ? pairingOrigin(explicitUrl) : config.publicUrl,
5873
6144
  remember: false,
5874
6145
  tunnel: void 0
5875
6146
  };
6147
+ const provider = method || (!interactive && !connection.url ? "opentunnel" : void 0);
6148
+ if (provider && !["existing", "local"].includes(provider)) {
6149
+ const option = provider === "opentunnel" ? void 0 : (await detectTunnels()).find((candidate) => candidate.provider === provider);
6150
+ if (provider !== "opentunnel" && !option?.available)
6151
+ throw new Error(option?.reason || "The selected tunnel is unavailable.");
6152
+ const tunnel = provider === "opentunnel" ? new OpenTunnel(path14.join(dataDir, "phone-connection")) : new ManagedTunnel(option);
6153
+ try {
6154
+ connection = { url: await tunnel.start(origin), remember: false, tunnel };
6155
+ } catch (error) {
6156
+ await tunnel.close();
6157
+ throw error;
6158
+ }
6159
+ }
6160
+ if (method === "existing" && !connection.url)
6161
+ throw new Error("Pass --public-url with the HTTPS address from your existing tunnel.");
5876
6162
  const publicUrl = connection.url;
5877
6163
  try {
5878
6164
  if (connection.remember && publicUrl) {
5879
- await mkdir6(dataDir, { recursive: true, mode: 448 });
5880
- await writeFile5(filename, JSON.stringify({ ...config, publicUrl }, null, 2) + "\n", {
6165
+ await mkdir7(dataDir, { recursive: true, mode: 448 });
6166
+ await writeFile6(filename, JSON.stringify({ ...config, publicUrl }, null, 2) + "\n", {
5881
6167
  mode: 384
5882
6168
  });
5883
6169
  }
5884
6170
  if (publicUrl) process.env.AGENTRIFIC_PUBLIC_URL = pairingOrigin(publicUrl);
6171
+ else delete process.env.AGENTRIFIC_PUBLIC_URL;
5885
6172
  await init_index().then(() => index_exports);
6173
+ if (connection.tunnel) {
6174
+ const close = () => void connection.tunnel?.close();
6175
+ process.once("SIGINT", close);
6176
+ process.once("SIGTERM", close);
6177
+ }
5886
6178
  if (interactive) startTerminal(dataDir, publicUrl, connection.tunnel);
5887
6179
  } catch (error) {
5888
6180
  await connection.tunnel?.close();
@@ -5899,7 +6191,7 @@ async function serviceCommand({ positionals, dataDir }) {
5899
6191
  return;
5900
6192
  }
5901
6193
  async function logsCommand({ dataDir }) {
5902
- const child = process.platform === "linux" ? spawn2("journalctl", ["--user", "-u", "agentrific.service", "-f"], { stdio: "inherit" }) : spawn2("tail", ["-F", path12.join(dataDir, "logs", "server.log")], { stdio: "inherit" });
6194
+ const child = process.platform === "linux" ? spawn2("journalctl", ["--user", "-u", "agentrific.service", "-f"], { stdio: "inherit" }) : spawn2("tail", ["-F", path14.join(dataDir, "logs", "server.log")], { stdio: "inherit" });
5903
6195
  child.on("error", (error) => {
5904
6196
  console.error(error.message);
5905
6197
  process.exitCode = 1;
@@ -5933,11 +6225,11 @@ async function pairCommand({ values, dataDir, config }) {
5933
6225
  return;
5934
6226
  }
5935
6227
  async function maintenanceCommand({ values, positionals, dataDir }) {
5936
- const action = z19.enum(["enter", "resume", "drain"]).parse(positionals[1]);
6228
+ const action = z20.enum(["enter", "resume", "drain"]).parse(positionals[1]);
5937
6229
  const result = await controlRequest(dataDir, {
5938
6230
  command: "maintenance",
5939
6231
  action,
5940
- timeoutMs: z19.coerce.number().int().min(0).max(6e4).parse(values["timeout-ms"] ?? 3e4)
6232
+ timeoutMs: z20.coerce.number().int().min(0).max(6e4).parse(values["timeout-ms"] ?? 3e4)
5941
6233
  });
5942
6234
  console.log(JSON.stringify(result, null, 2));
5943
6235
  if (action === "drain" && "drained" in result && !result.drained) process.exitCode = 1;
@@ -5946,7 +6238,7 @@ async function maintenanceCommand({ values, positionals, dataDir }) {
5946
6238
  async function devicesCommand({ dataDir, command }) {
5947
6239
  console.log(
5948
6240
  JSON.stringify(
5949
- await controlRequest(dataDir, { command: z19.enum(["devices", "status"]).parse(command) }),
6241
+ await controlRequest(dataDir, { command: z20.enum(["devices", "status"]).parse(command) }),
5950
6242
  null,
5951
6243
  2
5952
6244
  )
@@ -5954,7 +6246,7 @@ async function devicesCommand({ dataDir, command }) {
5954
6246
  return;
5955
6247
  }
5956
6248
  async function revokeCommand({ positionals, dataDir }) {
5957
- const id = z19.string().uuid().parse(positionals[1]);
6249
+ const id = z20.string().uuid().parse(positionals[1]);
5958
6250
  console.log(JSON.stringify(await controlRequest(dataDir, { command: "revoke", id })));
5959
6251
  return;
5960
6252
  }