@agentrific/server 0.1.4 → 0.1.6

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.1.4",
33
+ version: "0.1.6",
34
34
  description: "Self-hosted control plane for long-running coding agents",
35
35
  keywords: [
36
36
  "agentrific",
@@ -245,7 +245,7 @@ function createPairingLink(payload) {
245
245
  link.searchParams.set("data", JSON.stringify(PairingPayloadSchema.parse(payload)));
246
246
  return link.toString();
247
247
  }
248
- var cronBounds, AutomationConfigSchema, AutomationStateSchema, SessionStatusSchema, AgentKindSchema, ModelReferenceSchema, ModelIdentitySchema, ModelOptionSchema, ModelCatalogSchema, GitFlowSchema, GitDeliverySchema, DeliveryRequestSchema, RunPreparationSchema, SessionSchema, MAX_ATTACHMENTS, MAX_ATTACHMENT_BYTES, AttachmentSchema, AttachmentIdsSchema, CreateSessionSchema, SendMessageSchema, SessionEventSchema, RecordedEventSchema, EventHistorySchema, ServerEventSchema, ServerSettingsSchema, ConnectionsStatusSchema, UpdateSettingsSchema, ProjectSchema, TaskStatusSchema, TaskSchema, CreateTaskSchema, UpdateTaskSchema, PROTOCOL_VERSION, ServerUpdateStatusSchema, ServerHealthSchema, PairingPayloadSchema, PairingRequestSchema, DeviceSchema, PairingResultSchema, HandshakeSchema, HandshakeAckSchema;
248
+ var cronBounds, AutomationConfigSchema, AutomationStateSchema, SessionStatusSchema, AgentKindSchema, ModelReferenceSchema, ModelIdentitySchema, ModelOptionSchema, ModelCatalogSchema, GitFlowSchema, GitDeliverySchema, DeliveryRequestSchema, RunPreparationSchema, SessionSchema, MAX_ATTACHMENTS, MAX_ATTACHMENT_BYTES, AttachmentSchema, AttachmentIdsSchema, CreateSessionSchema, SendMessageSchema, SessionEventSchema, RecordedEventSchema, EventHistorySchema, ServerEventSchema, ServerSettingsSchema, ConnectionsStatusSchema, UpdateSettingsSchema, ProjectSchema, TaskStatusSchema, TaskSchema, CreateTaskSchema, UpdateTaskSchema, PROTOCOL_VERSION, ServerUpdateStatusSchema, ServerHealthSchema, PairingPayloadSchema, PairingInvitationSchema, PairingRequestSchema, DeviceSchema, PairingResultSchema, HandshakeSchema, HandshakeAckSchema;
249
249
  var init_src = __esm({
250
250
  "../../packages/contracts/src/index.ts"() {
251
251
  cronBounds = [
@@ -519,6 +519,11 @@ var init_src = __esm({
519
519
  serverId: z2.string().uuid(),
520
520
  pairingId: z2.string().regex(/^[a-f0-9]{64}$/)
521
521
  });
522
+ PairingInvitationSchema = z2.object({
523
+ payload: PairingPayloadSchema,
524
+ pin: z2.string().regex(/^\d{6}$/),
525
+ expiresAt: z2.number()
526
+ });
522
527
  PairingRequestSchema = z2.object({
523
528
  pairingId: z2.string().regex(/^[a-f0-9]{64}$/),
524
529
  pin: z2.string().regex(/^\d{6}$/),
@@ -727,15 +732,519 @@ var init_control = __esm({
727
732
  }
728
733
  });
729
734
 
735
+ // src/cli-output.ts
736
+ import { styleText } from "node:util";
737
+ function printHeading(title) {
738
+ console.log();
739
+ console.log(styleText("bold", " ####### ##"));
740
+ console.log(styleText("bold", " ### #####"));
741
+ console.log(` ${styleText("bold", "## ####")} ${styleText("bold", "AGENTRIFIC")}`);
742
+ console.log(
743
+ ` ${styleText("bold", "## ")}${styleText(["bold", "yellow"], "o")}${styleText("bold", " ####")} ${styleText("dim", `${title} \xB7 v${serverVersion}`)}`
744
+ );
745
+ console.log(styleText("bold", " ## ####"));
746
+ console.log(styleText("bold", " ### #####"));
747
+ console.log(styleText("bold", " ####### ##"));
748
+ console.log();
749
+ }
750
+ function printDetail(label2, value) {
751
+ console.log(` ${styleText("dim", "\u203A")} ${styleText("dim", label2.padEnd(10))} ${value}`);
752
+ }
753
+ function printShortcuts(pairingAvailable = false) {
754
+ console.log();
755
+ console.log(` ${styleText("dim", "\u203A")} Press ${styleText("bold", "p")} \u2502 pair a device`);
756
+ if (pairingAvailable)
757
+ console.log(
758
+ ` ${styleText("dim", "\u203A")} Press ${styleText("bold", "j")} \u2502 show pairing JSON`
759
+ );
760
+ console.log(` ${styleText("dim", "\u203A")} Press ${styleText("bold", "?")} \u2502 show shortcuts`);
761
+ console.log(
762
+ ` ${styleText("dim", "\u203A")} Press ${styleText("bold", "Ctrl+C")} \u2502 stop the server
763
+ `
764
+ );
765
+ }
766
+ function printError(message) {
767
+ console.error(`
768
+ ${styleText(["bold", "red"], "Error")} ${message}
769
+ `);
770
+ }
771
+ function printHelp() {
772
+ printHeading("server");
773
+ console.log(` ${styleText("bold", "Usage")} agentrific <command> [options]
774
+ `);
775
+ for (const [command, description] of [
776
+ ["init", "Configure the server"],
777
+ ["start", "Start the server \xB7 press p to pair"],
778
+ ["pair", "Print a fresh pairing QR code and PIN"],
779
+ ["devices", "List enrolled devices"],
780
+ ["revoke <id>", "Revoke a device's access"],
781
+ ["status", "Show server status"],
782
+ ["maintenance enter/resume/drain", "Pause new work, resume, or wait for work to finish"],
783
+ ["service install/uninstall/restart", "Manage the background service"],
784
+ ["logs", "Follow server logs"],
785
+ ["update --check", "Check for a newer release"]
786
+ ])
787
+ console.log(` ${styleText("cyan", command.padEnd(34))}${description}`);
788
+ console.log(`
789
+ ${styleText("bold", "Options")}
790
+ `);
791
+ for (const [option, description] of [
792
+ ["--data-dir <path>", "Use a specific server data directory"],
793
+ ["--public-url <https-url>", "Use your tunnel URL without prompting"],
794
+ ["--no-interactive", "Disable prompts and keyboard shortcuts"],
795
+ ["--show-payload", "Print manual JSON with the pairing QR code"],
796
+ ["--timeout-ms <0-60000>", "Set the maintenance drain timeout"],
797
+ ["--version", "Print the server version"],
798
+ ["--help", "Show this help"]
799
+ ])
800
+ console.log(` ${styleText("cyan", option.padEnd(34))}${description}`);
801
+ console.log("\n Connect using Tailscale Serve, cloudflared, or another HTTPS tunnel.\n");
802
+ }
803
+ var init_cli_output = __esm({
804
+ "src/cli-output.ts"() {
805
+ "use strict";
806
+ init_version();
807
+ }
808
+ });
809
+
810
+ // src/pairing.ts
811
+ import { z as z4 } from "zod";
812
+ import { styleText as styleText2 } from "node:util";
813
+ import { emitKeypressEvents } from "node:readline";
814
+ import qrcode from "qrcode-terminal";
815
+ function printManualPairing(invitation) {
816
+ if (Date.now() >= invitation.expiresAt) {
817
+ printError("This invitation expired. Generate a fresh pairing QR code.");
818
+ return;
819
+ }
820
+ console.log(styleText2("dim", "\n Manual entry \xB7 paste this JSON into Pairing payload:\n"));
821
+ console.log(` ${invitation.payload}
822
+ `);
823
+ }
824
+ function waitForManualPairing(invitation) {
825
+ console.log(styleText2("dim", " Press j to show manual entry \xB7 Enter or q to finish.\n"));
826
+ return new Promise((resolve) => {
827
+ const input = process.stdin;
828
+ const previousRaw = input.isRaw;
829
+ const timeout = setTimeout(
830
+ () => {
831
+ console.log(
832
+ styleText2("dim", "\n Pairing invitation expired. Run agentrific pair again.\n")
833
+ );
834
+ close();
835
+ },
836
+ Math.max(0, invitation.expiresAt - Date.now())
837
+ );
838
+ function close() {
839
+ clearTimeout(timeout);
840
+ input.removeListener("keypress", pressed);
841
+ input.removeListener("end", close);
842
+ process.removeListener("SIGINT", interrupted);
843
+ process.removeListener("SIGTERM", terminated);
844
+ input.setRawMode(previousRaw);
845
+ input.pause();
846
+ resolve();
847
+ }
848
+ function interrupted() {
849
+ process.exitCode = 130;
850
+ close();
851
+ }
852
+ function terminated() {
853
+ process.exitCode = 143;
854
+ close();
855
+ }
856
+ function pressed(text, key) {
857
+ if (key.ctrl && key.name === "c") {
858
+ interrupted();
859
+ return;
860
+ }
861
+ if (key.ctrl || key.meta) return;
862
+ if (text?.toLowerCase() === "j") {
863
+ printManualPairing(invitation);
864
+ close();
865
+ } else if (text?.toLowerCase() === "q" || key.name === "return" || key.name === "escape") {
866
+ close();
867
+ }
868
+ }
869
+ emitKeypressEvents(input);
870
+ input.setRawMode(true);
871
+ input.on("keypress", pressed);
872
+ input.once("end", close);
873
+ process.once("SIGINT", interrupted);
874
+ process.once("SIGTERM", terminated);
875
+ input.resume();
876
+ });
877
+ }
878
+ function pairingOrigin(url) {
879
+ const endpoint = new URL(url);
880
+ if (endpoint.username || endpoint.password || endpoint.search || endpoint.hash || endpoint.pathname !== "/")
881
+ throw new Error("Use the HTTPS server origin without a path or credentials.");
882
+ if (endpoint.protocol !== "https:" && !(process.env.NODE_ENV !== "production" && endpoint.protocol === "http:" && ["127.0.0.1", "localhost", "[::1]"].includes(endpoint.hostname)))
883
+ throw new Error("Use HTTPS. Localhost HTTP is for development builds only.");
884
+ return endpoint.origin;
885
+ }
886
+ async function printPairing(dataDir, url) {
887
+ if (!url)
888
+ throw new Error(
889
+ "Specify --public-url with your Tailscale Serve or Cloudflare Tunnel HTTPS address."
890
+ );
891
+ const origin = pairingOrigin(url);
892
+ const grant = z4.object({
893
+ pairingId: z4.string(),
894
+ pin: z4.string(),
895
+ expiresAt: z4.number(),
896
+ serverId: z4.string()
897
+ }).parse(await controlRequest(dataDir, { command: "pair" }));
898
+ const invitation = {
899
+ v: 1,
900
+ url: origin,
901
+ serverId: grant.serverId,
902
+ pairingId: grant.pairingId
903
+ };
904
+ const payload = JSON.stringify(invitation);
905
+ const link = createPairingLink(invitation);
906
+ printHeading("pairing");
907
+ console.log(" 1. Scan below with your phone camera and tap Open Agentrific if offered.");
908
+ console.log(" 2. Or open Agentrific and choose Scan pairing QR code.");
909
+ console.log(" 3. Enter the PIN, then confirm pairing.\n");
910
+ qrcode.generate(link, { small: true }, (code) => {
911
+ console.log(
912
+ code.split("\n").map((line) => ` ${line}`).join("\n")
913
+ );
914
+ });
915
+ console.log();
916
+ printDetail("PIN:", styleText2(["bold", "yellow"], grant.pin));
917
+ printDetail("Server", styleText2("cyan", origin));
918
+ printDetail("Expires", "In five minutes \xB7 one device per code");
919
+ console.log("\n Pairing grants this device access to run agents as your host user.\n");
920
+ return { payload, expiresAt: grant.expiresAt };
921
+ }
922
+ var init_pairing = __esm({
923
+ "src/pairing.ts"() {
924
+ "use strict";
925
+ init_src();
926
+ init_control();
927
+ init_cli_output();
928
+ }
929
+ });
930
+
931
+ // ../../packages/contracts/src/push.ts
932
+ import { z as z6 } from "zod";
933
+ function pushSigningText(installationId, operation, request) {
934
+ return [
935
+ "agentrific-push-v1",
936
+ installationId,
937
+ operation,
938
+ request.bindingId,
939
+ request.timestamp,
940
+ request.nonce,
941
+ request.payload
942
+ ].join("\n");
943
+ }
944
+ var PUSH_API_URL, PushKindSchema, PushPreferencesSchema, PushPublicKeySchema, PushInstallationSchema, PushRegistrationSchema, PushEnrollmentSchema, PushGrantSchema, PushPullRequestUrlSchema, PushEventSchema, PushSignedRequestSchema, PushEntitlementSchema, PushServerStatusSchema;
945
+ var init_push = __esm({
946
+ "../../packages/contracts/src/push.ts"() {
947
+ "use strict";
948
+ PUSH_API_URL = "https://api.agentrific.com";
949
+ PushKindSchema = z6.enum([
950
+ "started",
951
+ "completed",
952
+ "needs_input",
953
+ "prepared",
954
+ "drafting_pr",
955
+ "pr_created",
956
+ "failed"
957
+ ]);
958
+ PushPreferencesSchema = z6.array(PushKindSchema).max(7);
959
+ PushPublicKeySchema = z6.string().regex(/^[a-f0-9]{64}$/);
960
+ PushInstallationSchema = z6.object({ installationId: z6.string().uuid(), token: z6.string().regex(/^[a-f0-9]{64}$/) }).strict();
961
+ PushRegistrationSchema = z6.object({
962
+ expoToken: z6.string().regex(/^(Expo|Exponent)PushToken\[[A-Za-z0-9_-]+\]$/),
963
+ preferences: PushPreferencesSchema
964
+ }).strict();
965
+ PushEnrollmentSchema = z6.object({ serverId: z6.string().uuid(), publicKey: PushPublicKeySchema }).strict();
966
+ PushGrantSchema = PushEnrollmentSchema.extend({
967
+ installationId: z6.string().uuid(),
968
+ bindingId: z6.string().uuid(),
969
+ grant: z6.string().regex(/^[a-f0-9]{64}$/),
970
+ expiresAt: z6.number()
971
+ }).strict();
972
+ PushPullRequestUrlSchema = z6.string().max(512).regex(/^https:\/\/github\.com\/[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+\/pull\/[1-9][0-9]*$/);
973
+ PushEventSchema = z6.object({
974
+ eventId: z6.string().uuid(),
975
+ sessionId: z6.string().uuid(),
976
+ kind: PushKindSchema,
977
+ taskTitle: z6.string().trim().min(1).max(160).optional(),
978
+ prUrl: PushPullRequestUrlSchema.optional()
979
+ }).strict();
980
+ PushSignedRequestSchema = z6.object({
981
+ bindingId: z6.string().uuid(),
982
+ timestamp: z6.number().int(),
983
+ nonce: z6.string().uuid(),
984
+ payload: z6.string().max(2048),
985
+ signature: z6.string().regex(/^[a-f0-9]{128}$/)
986
+ }).strict();
987
+ PushEntitlementSchema = z6.object({ active: z6.boolean(), expiresAt: z6.number().nullable() }).strict();
988
+ PushServerStatusSchema = z6.object({
989
+ publicKey: PushPublicKeySchema,
990
+ bindings: z6.array(
991
+ z6.object({
992
+ bindingId: z6.string().uuid(),
993
+ installationId: z6.string().uuid(),
994
+ deviceId: z6.string().uuid()
995
+ })
996
+ ),
997
+ pending: z6.number().int().nonnegative(),
998
+ error: z6.string().nullable()
999
+ });
1000
+ }
1001
+ });
1002
+
1003
+ // src/push.ts
1004
+ import {
1005
+ generateKeyPairSync,
1006
+ createPrivateKey,
1007
+ createPublicKey,
1008
+ sign,
1009
+ randomUUID
1010
+ } from "node:crypto";
1011
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
1012
+ import path4 from "node:path";
1013
+ import { Effect as Effect3 } from "effect";
1014
+ function pushKind(session, previous) {
1015
+ if (session.gitDelivery?.status === "pr_opened" && previous?.gitDelivery?.status !== "pr_opened")
1016
+ return "pr_created";
1017
+ if (["merged", "no_changes"].includes(session.gitDelivery?.status ?? "") && session.gitDelivery?.status !== previous?.gitDelivery?.status)
1018
+ return "completed";
1019
+ if (session.gitDelivery?.status === "failed" && previous?.gitDelivery?.status !== "failed")
1020
+ return "failed";
1021
+ if (session.gitDelivery?.stage === "opening_pr" && previous?.gitDelivery?.stage !== "opening_pr")
1022
+ return "drafting_pr";
1023
+ if (session.preparation?.status === "ready" && previous?.preparation?.status !== "ready")
1024
+ return "prepared";
1025
+ if (session.status === previous?.status) return void 0;
1026
+ switch (session.status) {
1027
+ case "running":
1028
+ return "started";
1029
+ case "completed":
1030
+ return session.gitFlow ? void 0 : "completed";
1031
+ case "waiting_for_input":
1032
+ return "needs_input";
1033
+ case "failed":
1034
+ return "failed";
1035
+ default:
1036
+ return void 0;
1037
+ }
1038
+ }
1039
+ function registerPushRoutes(app2, push2, security2) {
1040
+ function deviceId(authorization) {
1041
+ const device = security2.authenticate((authorization ?? "").slice(7));
1042
+ if (!device) throw new Error("Pair this device");
1043
+ return device.id;
1044
+ }
1045
+ app2.get("/api/push", (request) => push2.status(deviceId(request.headers.authorization)));
1046
+ app2.post("/api/push/bind", async (request, reply) => {
1047
+ const grant = PushGrantSchema.safeParse(request.body);
1048
+ if (!grant.success) return reply.code(400).send({ error: "Invalid notification enrolment" });
1049
+ try {
1050
+ return await push2.bind(grant.data, deviceId(request.headers.authorization));
1051
+ } catch {
1052
+ return reply.code(400).send({ error: "Could not enable notifications. Check your subscription and try again." });
1053
+ }
1054
+ });
1055
+ app2.delete("/api/push/bind", (request) => {
1056
+ push2.unbind(deviceId(request.headers.authorization));
1057
+ return { disabled: true };
1058
+ });
1059
+ }
1060
+ var ServerPush;
1061
+ var init_push2 = __esm({
1062
+ "src/push.ts"() {
1063
+ "use strict";
1064
+ init_push();
1065
+ ServerPush = class {
1066
+ constructor(store2, serverId, directory, transport = fetch, apiUrl = PUSH_API_URL) {
1067
+ this.store = store2;
1068
+ this.serverId = serverId;
1069
+ this.transport = transport;
1070
+ this.apiUrl = apiUrl;
1071
+ mkdirSync(directory, { recursive: true, mode: 448 });
1072
+ const filename = path4.join(directory, "push-signing-key.pem");
1073
+ if (!existsSync(filename))
1074
+ writeFileSync(
1075
+ filename,
1076
+ generateKeyPairSync("ed25519").privateKey.export({ format: "pem", type: "pkcs8" }),
1077
+ { mode: 384, flag: "wx" }
1078
+ );
1079
+ this.key = createPrivateKey(readFileSync(filename));
1080
+ this.publicKey = createPublicKey(this.key).export({ format: "der", type: "spki" }).subarray(-32).toString("hex");
1081
+ }
1082
+ store;
1083
+ serverId;
1084
+ transport;
1085
+ apiUrl;
1086
+ key;
1087
+ publicKey;
1088
+ draining;
1089
+ timer;
1090
+ stopped = false;
1091
+ pending(session, previous) {
1092
+ const kind = pushKind(session, previous);
1093
+ const state = Effect3.runSync(this.store.pushState());
1094
+ if (!kind || !state.bindings.length) return void 0;
1095
+ return {
1096
+ eventId: randomUUID(),
1097
+ sessionId: session.id,
1098
+ kind,
1099
+ taskTitle: session.title.replace(/[\r\n\t]+/g, " ").trim().slice(0, 160) || void 0,
1100
+ prUrl: kind === "pr_created" ? PushPullRequestUrlSchema.safeParse(session.gitDelivery?.url).data : void 0,
1101
+ remaining: state.bindings,
1102
+ createdAt: Date.now(),
1103
+ attempts: 0,
1104
+ nextAt: Date.now()
1105
+ };
1106
+ }
1107
+ status(deviceId) {
1108
+ const state = Effect3.runSync(this.store.pushState());
1109
+ return {
1110
+ publicKey: this.publicKey,
1111
+ bindings: state.bindings.filter((binding) => binding.deviceId === deviceId),
1112
+ pending: Effect3.runSync(this.store.pendingPush()).length,
1113
+ error: state.error
1114
+ };
1115
+ }
1116
+ async send(installationId, bindingId, operation, payload) {
1117
+ const request = { bindingId, timestamp: Date.now(), nonce: randomUUID(), payload };
1118
+ const signature = sign(
1119
+ null,
1120
+ Buffer.from(pushSigningText(installationId, operation, request)),
1121
+ this.key
1122
+ ).toString("hex");
1123
+ return this.transport(`${this.apiUrl}/v1/installations/${installationId}/${operation}`, {
1124
+ method: "POST",
1125
+ headers: { "Content-Type": "application/json" },
1126
+ body: JSON.stringify({ ...request, signature }),
1127
+ signal: AbortSignal.timeout(1e4),
1128
+ redirect: "error"
1129
+ });
1130
+ }
1131
+ async bind(grant, deviceId) {
1132
+ if (grant.serverId !== this.serverId || grant.publicKey !== this.publicKey || grant.expiresAt <= Date.now())
1133
+ throw new Error("Grant does not match this server or has expired");
1134
+ const existing = Effect3.runSync(this.store.pushState());
1135
+ if (existing.bindings.length >= 10 && !existing.bindings.some((binding) => binding.deviceId === deviceId))
1136
+ throw new Error("Server notification binding limit reached");
1137
+ const response = await this.send(
1138
+ grant.installationId,
1139
+ grant.bindingId,
1140
+ "redeem",
1141
+ JSON.stringify({ grant: grant.grant })
1142
+ );
1143
+ if (!response.ok)
1144
+ throw new Error("Notification enrolment failed; enable push again to obtain a fresh grant");
1145
+ const state = Effect3.runSync(this.store.pushState());
1146
+ state.bindings = state.bindings.filter((binding) => binding.deviceId !== deviceId);
1147
+ state.bindings.push({
1148
+ installationId: grant.installationId,
1149
+ bindingId: grant.bindingId,
1150
+ deviceId
1151
+ });
1152
+ state.error = null;
1153
+ await Effect3.runPromise(this.store.savePushState(state));
1154
+ return this.status(deviceId);
1155
+ }
1156
+ unbind(deviceId) {
1157
+ const state = Effect3.runSync(this.store.pushState());
1158
+ state.bindings = state.bindings.filter((binding) => binding.deviceId !== deviceId);
1159
+ Effect3.runSync(this.store.savePushState(state));
1160
+ }
1161
+ start() {
1162
+ this.timer = setInterval(() => void this.flush(), 5e3);
1163
+ this.timer.unref();
1164
+ void this.flush();
1165
+ }
1166
+ flush() {
1167
+ if (this.stopped) return Promise.resolve();
1168
+ this.draining ??= this.drain().catch(() => {
1169
+ const state = Effect3.runSync(this.store.pushState());
1170
+ state.error = "Notification delivery temporarily unavailable";
1171
+ Effect3.runSync(this.store.savePushState(state));
1172
+ }).finally(() => {
1173
+ this.draining = void 0;
1174
+ });
1175
+ return this.draining;
1176
+ }
1177
+ async drain() {
1178
+ for (const job of Effect3.runSync(this.store.pendingPush())) {
1179
+ if (job.createdAt + 36e5 < Date.now() || job.attempts >= 12) {
1180
+ Effect3.runSync(this.store.removePush(job.eventId));
1181
+ continue;
1182
+ }
1183
+ if (job.nextAt > Date.now()) continue;
1184
+ const remaining = [];
1185
+ for (const binding of job.remaining) {
1186
+ const state2 = Effect3.runSync(this.store.pushState());
1187
+ const device = Effect3.runSync(this.store.devices()).find(
1188
+ (device2) => device2.id === binding.deviceId
1189
+ );
1190
+ if (!device || device.revokedAt !== null || !state2.bindings.some((current) => current.bindingId === binding.bindingId))
1191
+ continue;
1192
+ try {
1193
+ const response = await this.send(
1194
+ binding.installationId,
1195
+ binding.bindingId,
1196
+ "events",
1197
+ JSON.stringify({
1198
+ eventId: job.eventId,
1199
+ sessionId: job.sessionId,
1200
+ kind: job.kind,
1201
+ taskTitle: job.taskTitle,
1202
+ prUrl: job.prUrl
1203
+ })
1204
+ );
1205
+ if (response.ok) continue;
1206
+ if ([401, 403, 404].includes(response.status)) {
1207
+ this.unbind(binding.deviceId);
1208
+ continue;
1209
+ }
1210
+ remaining.push(binding);
1211
+ } catch {
1212
+ remaining.push(binding);
1213
+ }
1214
+ }
1215
+ if (!remaining.length) Effect3.runSync(this.store.removePush(job.eventId));
1216
+ else
1217
+ Effect3.runSync(
1218
+ this.store.savePendingPush({
1219
+ ...job,
1220
+ remaining,
1221
+ attempts: job.attempts + 1,
1222
+ nextAt: Date.now() + Math.min(3e5, 5e3 * 2 ** job.attempts)
1223
+ })
1224
+ );
1225
+ const state = Effect3.runSync(this.store.pushState());
1226
+ state.error = remaining.length ? "Notification delivery pending; check subscription and connectivity" : null;
1227
+ Effect3.runSync(this.store.savePushState(state));
1228
+ }
1229
+ }
1230
+ async close() {
1231
+ this.stopped = true;
1232
+ clearInterval(this.timer);
1233
+ await this.draining;
1234
+ }
1235
+ };
1236
+ }
1237
+ });
1238
+
730
1239
  // src/attachments.ts
731
- import { randomUUID } from "node:crypto";
1240
+ import { randomUUID as randomUUID2 } from "node:crypto";
732
1241
  import { createReadStream, createWriteStream } from "node:fs";
733
1242
  import { mkdir as mkdir3, readFile as readFile2, rm, stat as stat2, readdir } from "node:fs/promises";
734
- import path4 from "node:path";
1243
+ import path5 from "node:path";
735
1244
  import { pipeline } from "node:stream/promises";
736
1245
  import sharp from "sharp";
737
- import { Context, Data, Effect as Effect3, Layer } from "effect";
738
- import { z as z6 } from "zod";
1246
+ import { Context, Data, Effect as Effect4, Layer } from "effect";
1247
+ import { z as z7 } from "zod";
739
1248
  var AttachmentError, textExtensions, imageMimes, AttachmentFiles, Attachments;
740
1249
  var init_attachments = __esm({
741
1250
  "src/attachments.ts"() {
@@ -790,10 +1299,10 @@ var init_attachments = __esm({
790
1299
  root;
791
1300
  store;
792
1301
  directory(id) {
793
- return path4.join(this.root, z6.string().uuid().parse(id));
1302
+ return path5.join(this.root, z7.string().uuid().parse(id));
794
1303
  }
795
1304
  io(operation) {
796
- return Effect3.tryPromise({
1305
+ return Effect4.tryPromise({
797
1306
  try: operation,
798
1307
  catch: (cause) => new AttachmentError({
799
1308
  message: cause instanceof Error ? cause.message : "Attachment operation failed."
@@ -802,12 +1311,12 @@ var init_attachments = __esm({
802
1311
  }
803
1312
  upload(projectId, part) {
804
1313
  return this.io(async () => {
805
- const id = randomUUID();
1314
+ const id = randomUUID2();
806
1315
  const directory = this.directory(id);
807
- const source = path4.join(directory, "source");
808
- const nameField = z6.object({ type: z6.literal("field"), value: z6.string() }).safeParse(part.fields.name);
1316
+ const source = path5.join(directory, "source");
1317
+ const nameField = z7.object({ type: z7.literal("field"), value: z7.string() }).safeParse(part.fields.name);
809
1318
  const originalName = nameField.success ? nameField.data.value : part.filename;
810
- const name = path4.basename(originalName.replaceAll("\\", "/")).replace(new RegExp("\\p{Cc}", "gu"), "").slice(0, 180) || "attachment";
1319
+ const name = path5.basename(originalName.replaceAll("\\", "/")).replace(new RegExp("\\p{Cc}", "gu"), "").slice(0, 180) || "attachment";
811
1320
  await mkdir3(directory, { recursive: true });
812
1321
  try {
813
1322
  await pipeline(part.file, createWriteStream(source, { flags: "wx" }));
@@ -823,7 +1332,7 @@ var init_attachments = __esm({
823
1332
  ...metadata,
824
1333
  createdAt: (/* @__PURE__ */ new Date()).toISOString()
825
1334
  });
826
- await Effect3.runPromise(this.store.saveAttachment(attachment));
1335
+ await Effect4.runPromise(this.store.saveAttachment(attachment));
827
1336
  return attachment;
828
1337
  } catch (error) {
829
1338
  await rm(directory, { recursive: true, force: true });
@@ -835,10 +1344,10 @@ var init_attachments = __esm({
835
1344
  const metadata = await sharp(source, { limitInputPixels: 4e7 }).metadata().catch(() => null);
836
1345
  const mimeType = metadata?.format && imageMimes.get(metadata.format);
837
1346
  if (mimeType) {
838
- await sharp(source, { limitInputPixels: 4e7 }).rotate().resize({ width: 2048, height: 2048, fit: "inside", withoutEnlargement: true }).png().toFile(path4.join(path4.dirname(source), "preview.png"));
1347
+ await sharp(source, { limitInputPixels: 4e7 }).rotate().resize({ width: 2048, height: 2048, fit: "inside", withoutEnlargement: true }).png().toFile(path5.join(path5.dirname(source), "preview.png"));
839
1348
  return { kind: "image", mimeType };
840
1349
  }
841
- if (!textExtensions.has(path4.extname(name).toLowerCase()))
1350
+ if (!textExtensions.has(path5.extname(name).toLowerCase()))
842
1351
  throw new Error(
843
1352
  "Choose a PNG, JPEG, WebP, GIF, or text/code file. PDF and Office documents aren't supported yet."
844
1353
  );
@@ -854,12 +1363,12 @@ var init_attachments = __esm({
854
1363
  throw new Error("Choose up to five different files.");
855
1364
  const attachments2 = [];
856
1365
  for (const id of ids) {
857
- const attachment = await Effect3.runPromise(this.store.getAttachment(id));
1366
+ const attachment = await Effect4.runPromise(this.store.getAttachment(id));
858
1367
  if (!attachment || attachment.projectId !== projectId)
859
1368
  throw new Error(
860
1369
  "An attachment is unavailable for this project. Remove it and upload it again."
861
1370
  );
862
- await stat2(path4.join(this.directory(id), "source"));
1371
+ await stat2(path5.join(this.directory(id), "source"));
863
1372
  attachments2.push(attachment);
864
1373
  }
865
1374
  return attachments2;
@@ -867,7 +1376,7 @@ var init_attachments = __esm({
867
1376
  }
868
1377
  content(attachment, preview) {
869
1378
  return createReadStream(
870
- path4.join(
1379
+ path5.join(
871
1380
  this.directory(attachment.id),
872
1381
  preview && attachment.kind === "image" ? "preview.png" : "source"
873
1382
  )
@@ -878,9 +1387,9 @@ var init_attachments = __esm({
878
1387
  const images = [];
879
1388
  const sections = [];
880
1389
  for (const attachment of attachments2) {
881
- const source = path4.join(this.directory(attachment.id), "source");
1390
+ const source = path5.join(this.directory(attachment.id), "source");
882
1391
  if (attachment.kind === "image") {
883
- const bytes = await readFile2(path4.join(this.directory(attachment.id), "preview.png"));
1392
+ const bytes = await readFile2(path5.join(this.directory(attachment.id), "preview.png"));
884
1393
  images.push({ type: "image", data: bytes.toString("base64"), mimeType: "image/png" });
885
1394
  sections.push(`Image attachment: ${JSON.stringify(attachment.name)}`);
886
1395
  } else {
@@ -903,18 +1412,18 @@ ${text.slice(0, 12e3)}${text.length > 12e3 ? "\n[Preview truncated; use the read
903
1412
  cleanup() {
904
1413
  return this.io(async () => {
905
1414
  const cutoff = Date.now() - 24 * 60 * 60 * 1e3;
906
- const expired = await Effect3.runPromise(
1415
+ const expired = await Effect4.runPromise(
907
1416
  this.store.expiredAttachments(new Date(cutoff).toISOString())
908
1417
  );
909
1418
  for (const attachment of expired) {
910
- if (await Effect3.runPromise(this.store.deleteAttachment(attachment.id)))
1419
+ if (await Effect4.runPromise(this.store.deleteAttachment(attachment.id)))
911
1420
  await rm(this.directory(attachment.id), { recursive: true, force: true });
912
1421
  }
913
1422
  await mkdir3(this.root, { recursive: true });
914
1423
  for (const entry of await readdir(this.root, { withFileTypes: true })) {
915
- if (!entry.isDirectory() || !z6.string().uuid().safeParse(entry.name).success) continue;
1424
+ if (!entry.isDirectory() || !z7.string().uuid().safeParse(entry.name).success) continue;
916
1425
  const directory = this.directory(entry.name);
917
- if ((await stat2(directory)).mtimeMs < cutoff && !await Effect3.runPromise(this.store.getAttachment(entry.name)))
1426
+ if ((await stat2(directory)).mtimeMs < cutoff && !await Effect4.runPromise(this.store.getAttachment(entry.name)))
918
1427
  await rm(directory, { recursive: true, force: true });
919
1428
  }
920
1429
  });
@@ -1000,24 +1509,24 @@ var init_event_server = __esm({
1000
1509
  });
1001
1510
 
1002
1511
  // src/security.ts
1003
- import { createHash, randomBytes, randomInt, randomUUID as randomUUID2, timingSafeEqual } from "node:crypto";
1004
- import { Effect as Effect4 } from "effect";
1005
- import { z as z7 } from "zod";
1512
+ import { createHash, randomBytes, randomInt, randomUUID as randomUUID3, timingSafeEqual } from "node:crypto";
1513
+ import { Effect as Effect5 } from "effect";
1514
+ import { z as z8 } from "zod";
1006
1515
  async function registerSecurity(app2, security2, version, allowedOrigins2) {
1007
1516
  app2.addHook("onRequest", async (request, reply) => {
1008
- const path11 = new URL(request.url, "http://localhost");
1517
+ const path13 = new URL(request.url, "http://localhost");
1009
1518
  reply.header("Cache-Control", "no-store");
1010
1519
  if (request.method === "OPTIONS") return;
1011
1520
  if (request.headers.origin && !allowedOrigins2.includes(request.headers.origin))
1012
1521
  return reply.code(403).send({ error: "Browser origin is not allowed." });
1013
- if (path11.pathname === "/health" || path11.pathname === "/api/auth/pair" && request.method === "POST")
1522
+ if (path13.pathname === "/health" || path13.pathname === "/api/auth/pair" && request.method === "POST")
1014
1523
  return;
1015
1524
  const authorization = request.headers.authorization ?? "";
1016
1525
  const token = authorization.startsWith("Bearer ") ? authorization.slice(7) : "";
1017
- const asset = request.method === "GET" && /^\/api\/projects\/[^/]+\/attachments\/[^/]+\/content$/.test(path11.pathname);
1018
- const ticket = path11.searchParams.get("ticket");
1019
- path11.searchParams.delete("ticket");
1020
- if (!security2.authenticate(token) && !(asset && ticket && security2.redeemTicket(ticket, path11.pathname + path11.search, false)))
1526
+ const asset = request.method === "GET" && /^\/api\/projects\/[^/]+\/attachments\/[^/]+\/content$/.test(path13.pathname);
1527
+ const ticket = path13.searchParams.get("ticket");
1528
+ path13.searchParams.delete("ticket");
1529
+ if (!security2.authenticate(token) && !(asset && ticket && security2.redeemTicket(ticket, path13.pathname + path13.search, false)))
1021
1530
  return reply.code(401).send({ error: "Pair this device with your server.", code: "UNAUTHORIZED" });
1022
1531
  if (!asset && request.headers["x-agentrific-protocol"] !== String(PROTOCOL_VERSION))
1023
1532
  return reply.code(409).send({
@@ -1041,6 +1550,29 @@ async function registerSecurity(app2, security2, version, allowedOrigins2) {
1041
1550
  const result = await security2.pair(input.data, request.ip);
1042
1551
  return result ?? reply.code(401).send({ error: "Pairing expired, locked, or invalid. Run agentrific pair again." });
1043
1552
  });
1553
+ app2.post("/api/auth/pairing-invitation", (_request, reply) => {
1554
+ const publicUrl = process.env.AGENTRIFIC_PUBLIC_URL;
1555
+ if (!publicUrl)
1556
+ return reply.code(503).send({
1557
+ error: "Configure the server\u2019s public HTTPS URL and restart the server before pairing a device."
1558
+ });
1559
+ let url;
1560
+ try {
1561
+ url = pairingOrigin(publicUrl);
1562
+ } catch {
1563
+ return reply.code(503).send({ error: "The server\u2019s public URL is invalid. Configure a public HTTPS origin." });
1564
+ }
1565
+ try {
1566
+ const grant = security2.createPairing();
1567
+ return {
1568
+ payload: { v: 1, url, serverId: grant.serverId, pairingId: grant.pairingId },
1569
+ pin: grant.pin,
1570
+ expiresAt: grant.expiresAt
1571
+ };
1572
+ } catch {
1573
+ return reply.code(429).send({ error: "Too many pending pairing codes. Wait five minutes and try again." });
1574
+ }
1575
+ });
1044
1576
  app2.get("/api/auth/session", (request) => ({
1045
1577
  serverId: security2.serverId,
1046
1578
  device: security2.authenticate((request.headers.authorization ?? "").slice(7))
@@ -1049,8 +1581,8 @@ async function registerSecurity(app2, security2, version, allowedOrigins2) {
1049
1581
  ticket: security2.ticket((request.headers.authorization ?? "").slice(7), "/api/events")
1050
1582
  }));
1051
1583
  app2.post("/api/auth/asset-ticket", (request, reply) => {
1052
- const input = z7.object({
1053
- path: z7.string().regex(/^\/api\/projects\/[^/?]+\/attachments\/[^/?]+\/content(?:\?preview=1)?$/)
1584
+ const input = z8.object({
1585
+ path: z8.string().regex(/^\/api\/projects\/[^/?]+\/attachments\/[^/?]+\/content(?:\?preview=1)?$/)
1054
1586
  }).safeParse(request.body);
1055
1587
  if (!input.success) return reply.code(400).send({ error: "Choose a valid attachment." });
1056
1588
  return {
@@ -1063,6 +1595,7 @@ var init_security = __esm({
1063
1595
  "src/security.ts"() {
1064
1596
  "use strict";
1065
1597
  init_src();
1598
+ init_pairing();
1066
1599
  secret = () => randomBytes(32).toString("hex");
1067
1600
  digest = (value) => createHash("sha256").update(value).digest("hex");
1068
1601
  ServerSecurity = class {
@@ -1112,36 +1645,36 @@ var init_security = __esm({
1112
1645
  const token = secret();
1113
1646
  const now = Date.now();
1114
1647
  const device = {
1115
- id: randomUUID2(),
1648
+ id: randomUUID3(),
1116
1649
  name: input.deviceName,
1117
1650
  platform: input.platform,
1118
1651
  createdAt: now,
1119
1652
  lastSeenAt: now,
1120
1653
  revokedAt: null
1121
1654
  };
1122
- await Effect4.runPromise(this.store.saveDevice(device, digest(token)));
1655
+ await Effect5.runPromise(this.store.saveDevice(device, digest(token)));
1123
1656
  return { deviceId: device.id, deviceToken: token, serverId: this.serverId };
1124
1657
  }
1125
1658
  authenticate(token) {
1126
1659
  if (!/^[a-f0-9]{64}$/.test(token)) return void 0;
1127
- return Effect4.runSync(this.store.deviceForToken(digest(token)));
1660
+ return Effect5.runSync(this.store.deviceForToken(digest(token)));
1128
1661
  }
1129
- ticket(token, path11) {
1662
+ ticket(token, path13) {
1130
1663
  this.prune();
1131
1664
  if (!this.authenticate(token) || this.tickets.size >= 1e4) return void 0;
1132
1665
  const value = secret();
1133
- this.tickets.set(value, { tokenHash: digest(token), expiresAt: Date.now() + 6e4, path: path11 });
1666
+ this.tickets.set(value, { tokenHash: digest(token), expiresAt: Date.now() + 6e4, path: path13 });
1134
1667
  return value;
1135
1668
  }
1136
- redeemTicket(value, path11, consume = true) {
1669
+ redeemTicket(value, path13, consume = true) {
1137
1670
  this.prune();
1138
1671
  const ticket = this.tickets.get(value);
1139
- if (!ticket || ticket.path !== path11) return void 0;
1672
+ if (!ticket || ticket.path !== path13) return void 0;
1140
1673
  if (consume) this.tickets.delete(value);
1141
- return Effect4.runSync(this.store.deviceForToken(ticket.tokenHash));
1674
+ return Effect5.runSync(this.store.deviceForToken(ticket.tokenHash));
1142
1675
  }
1143
1676
  async revoke(id) {
1144
- const revoked = await Effect4.runPromise(this.store.revokeDevice(id));
1677
+ const revoked = await Effect5.runPromise(this.store.revokeDevice(id));
1145
1678
  for (const [socket, owner] of this.sockets)
1146
1679
  if (owner === id) socket.close(4401, "Device revoked");
1147
1680
  return revoked;
@@ -1195,9 +1728,9 @@ var init_maintenance = __esm({
1195
1728
  }
1196
1729
  attach(app2) {
1197
1730
  app2.addHook("onRequest", async (request, reply) => {
1198
- const path11 = request.url.split("?")[0];
1731
+ const path13 = request.url.split("?")[0];
1199
1732
  if (["GET", "HEAD", "OPTIONS"].includes(request.method)) return;
1200
- const allowed = path11.startsWith("/api/auth/") || /^\/api\/sessions\/[^/]+\/stop$/.test(path11);
1733
+ const allowed = path13.startsWith("/api/auth/") || /^\/api\/sessions\/[^/]+\/stop$/.test(path13);
1201
1734
  if (this.paused && !allowed)
1202
1735
  return reply.code(503).send({ code: "MAINTENANCE", error: new MaintenanceError().message });
1203
1736
  this.requests.add(request.id);
@@ -1211,22 +1744,27 @@ var init_maintenance = __esm({
1211
1744
  });
1212
1745
 
1213
1746
  // src/tasks.ts
1214
- import { Effect as Effect5, Semaphore } from "effect";
1747
+ import { Effect as Effect6, Semaphore } from "effect";
1215
1748
  function withTaskLock(id, operation) {
1216
1749
  let lock = locks.get(id);
1217
1750
  if (!lock) {
1218
1751
  lock = Semaphore.makeUnsafe(1);
1219
1752
  locks.set(id, lock);
1220
1753
  }
1221
- return Effect5.runPromise(
1754
+ return Effect6.runPromise(
1222
1755
  lock.withPermit(
1223
- Effect5.tryPromise({
1756
+ Effect6.tryPromise({
1224
1757
  try: operation,
1225
1758
  catch: (cause) => cause
1226
1759
  })
1227
1760
  )
1228
1761
  );
1229
1762
  }
1763
+ async function sessionTaskLockId(store2, session) {
1764
+ if (session.taskId) return session.taskId;
1765
+ const tasks = await Effect6.runPromise(store2.listTasks(session.projectId, true));
1766
+ return tasks.find((task) => task.sessionId === session.id)?.id ?? `session-${session.id}`;
1767
+ }
1230
1768
  function taskStatusForSession(status, session) {
1231
1769
  if (session?.gitDelivery?.status === "pending") return "running";
1232
1770
  if (status === "completed" && session?.gitFlow && !["merged", "no_changes", "pr_opened"].includes(session.gitDelivery?.status ?? ""))
@@ -1263,8 +1801,8 @@ function sessionTask(session) {
1263
1801
  });
1264
1802
  }
1265
1803
  async function boardTasks(store2, projectId) {
1266
- const tasks = await Effect5.runPromise(store2.listTasks(projectId, true));
1267
- const sessions = await Effect5.runPromise(store2.list());
1804
+ const tasks = await Effect6.runPromise(store2.listTasks(projectId, true));
1805
+ const sessions = await Effect6.runPromise(store2.list());
1268
1806
  const linkedSessionIds = new Set(
1269
1807
  tasks.flatMap((task) => task.sessionId ? [task.sessionId] : [])
1270
1808
  );
@@ -1283,10 +1821,10 @@ async function boardTasks(store2, projectId) {
1283
1821
  return [...linkedTasks, ...legacyTasks].map((task) => TaskSchema.parse(task)).sort((left, right) => right.updatedAt.localeCompare(left.updatedAt));
1284
1822
  }
1285
1823
  async function findTask(store2, id) {
1286
- const saved = await Effect5.runPromise(store2.getTask(id));
1824
+ const saved = await Effect6.runPromise(store2.getTask(id));
1287
1825
  if (saved?.deletedAt) throw new TaskActionError("Task not found.", 404);
1288
1826
  if (saved) return saved;
1289
- const session = id.startsWith("session-") ? await Effect5.runPromise(store2.get(id.slice("session-".length))) : void 0;
1827
+ const session = id.startsWith("session-") ? await Effect6.runPromise(store2.get(id.slice("session-".length))) : void 0;
1290
1828
  if (!session || session.taskId) throw new TaskActionError("Task not found.", 404);
1291
1829
  return sessionTask(session);
1292
1830
  }
@@ -1302,7 +1840,7 @@ function editTask(store2, id, input, resolveAttachments) {
1302
1840
  throw new TaskActionError("Attachment validation is unavailable.", 400);
1303
1841
  files = await resolveAttachments(task.projectId, attachmentIds);
1304
1842
  }
1305
- return Effect5.runPromise(
1843
+ return Effect6.runPromise(
1306
1844
  store2.saveTask({
1307
1845
  ...task,
1308
1846
  ...updates2,
@@ -1313,16 +1851,25 @@ function editTask(store2, id, input, resolveAttachments) {
1313
1851
  );
1314
1852
  });
1315
1853
  }
1316
- function deleteTask(store2, id) {
1854
+ function deleteTask(store2, id, cleanup = async () => {
1855
+ }) {
1317
1856
  return withTaskLock(id, async () => {
1318
1857
  const task = await findTask(store2, id);
1319
- const sessions = await Effect5.runPromise(store2.list());
1858
+ const sessions = await Effect6.runPromise(store2.list());
1320
1859
  if (sessions.some(
1321
1860
  (session) => (session.taskId === task.id || session.id === task.sessionId) && (["starting", "running", "queued"].includes(session.status) || session.gitDelivery?.status === "pending")
1322
1861
  ))
1323
1862
  throw new TaskActionError("Stop the task's running agent before deleting it.", 409);
1324
- const now = (/* @__PURE__ */ new Date()).toISOString();
1325
- await Effect5.runPromise(store2.saveTask({ ...task, deletedAt: now, updatedAt: now }));
1863
+ const associated = sessions.filter(
1864
+ (session) => session.taskId === task.id || session.id === task.sessionId
1865
+ );
1866
+ for (const session of associated) await cleanup(session);
1867
+ await Effect6.runPromise(
1868
+ store2.purgeTask(
1869
+ task.id,
1870
+ associated.map((session) => session.id)
1871
+ )
1872
+ );
1326
1873
  });
1327
1874
  }
1328
1875
  var TaskActionError, locks;
@@ -1342,7 +1889,7 @@ var init_tasks = __esm({
1342
1889
  });
1343
1890
 
1344
1891
  // src/automation.ts
1345
- import { Effect as Effect6 } from "effect";
1892
+ import { Effect as Effect7 } from "effect";
1346
1893
  function activeAgents(sessions) {
1347
1894
  return sessions.filter(
1348
1895
  (session) => ["starting", "running", "queued"].includes(session.status) || session.gitDelivery?.status === "pending"
@@ -1371,18 +1918,18 @@ var init_automation = __esm({
1371
1918
  start;
1372
1919
  canStart;
1373
1920
  async snapshot() {
1374
- const state = await Effect6.runPromise(this.store.automation());
1921
+ const state = await Effect7.runPromise(this.store.automation());
1375
1922
  return {
1376
1923
  ...state,
1377
1924
  queue: automationQueue(await boardTasks(this.store), state),
1378
- active: activeAgents(await Effect6.runPromise(this.store.list()))
1925
+ active: activeAgents(await Effect7.runPromise(this.store.list()))
1379
1926
  };
1380
1927
  }
1381
1928
  // ponytail: one server process owns this SQLite database; use a durable lease before adding replicas.
1382
1929
  configure(config) {
1383
1930
  return withTaskLock("automation", async () => {
1384
- const state = await Effect6.runPromise(this.store.automation());
1385
- await Effect6.runPromise(this.store.saveAutomation({ ...state, config, error: null }));
1931
+ const state = await Effect7.runPromise(this.store.automation());
1932
+ await Effect7.runPromise(this.store.saveAutomation({ ...state, config, error: null }));
1386
1933
  return this.snapshot();
1387
1934
  });
1388
1935
  }
@@ -1394,7 +1941,7 @@ var init_automation = __esm({
1394
1941
  throw new TaskActionError("Queue changed. Refresh and try again.", 409);
1395
1942
  const order = ids.filter((id) => id !== taskId);
1396
1943
  order.splice(beforeId === null ? order.length : order.indexOf(beforeId), 0, taskId);
1397
- await Effect6.runPromise(
1944
+ await Effect7.runPromise(
1398
1945
  this.store.saveAutomation({
1399
1946
  config: snapshot.config,
1400
1947
  lastMinute: snapshot.lastMinute,
@@ -1407,22 +1954,22 @@ var init_automation = __esm({
1407
1954
  }
1408
1955
  tick(now = /* @__PURE__ */ new Date()) {
1409
1956
  return withTaskLock("automation", async () => {
1410
- let state = await Effect6.runPromise(this.store.automation());
1957
+ let state = await Effect7.runPromise(this.store.automation());
1411
1958
  const minute = now.toISOString().slice(0, 16);
1412
1959
  if (!this.canStart() || !state.config.enabled || state.lastMinute !== null && state.lastMinute >= minute || !cronMatches(state.config.cron, now))
1413
1960
  return;
1414
1961
  state = { ...state, lastMinute: minute, error: null };
1415
- await Effect6.runPromise(this.store.saveAutomation(state));
1962
+ await Effect7.runPromise(this.store.saveAutomation(state));
1416
1963
  const queue = automationQueue(await boardTasks(this.store), state);
1417
1964
  for (const task of queue) {
1418
1965
  if (!this.canStart()) break;
1419
- if (activeAgents(await Effect6.runPromise(this.store.list())) >= state.config.parallelism)
1966
+ if (activeAgents(await Effect7.runPromise(this.store.list())) >= state.config.parallelism)
1420
1967
  break;
1421
1968
  try {
1422
1969
  await this.start(task.id);
1423
1970
  } catch (error) {
1424
1971
  if (error instanceof MaintenanceError) break;
1425
- await Effect6.runPromise(
1972
+ await Effect7.runPromise(
1426
1973
  this.store.saveAutomation({
1427
1974
  ...state,
1428
1975
  config: { ...state.config, enabled: false },
@@ -1453,9 +2000,9 @@ var init_git_flow = __esm({
1453
2000
  });
1454
2001
 
1455
2002
  // src/runtime.ts
1456
- import path5 from "node:path";
1457
- import { randomUUID as randomUUID3 } from "node:crypto";
1458
- import { Context as Context2, Data as Data2, Effect as Effect7, Fiber, Layer as Layer2, Semaphore as Semaphore2 } from "effect";
2003
+ import path6 from "node:path";
2004
+ import { randomUUID as randomUUID4 } from "node:crypto";
2005
+ import { Context as Context2, Data as Data2, Effect as Effect8, Fiber, Layer as Layer2, Semaphore as Semaphore2 } from "effect";
1459
2006
  var AgentError, AgentController, AgentSessions;
1460
2007
  var init_runtime = __esm({
1461
2008
  "src/runtime.ts"() {
@@ -1500,20 +2047,20 @@ var init_runtime = __esm({
1500
2047
  this.emit({ type: "session.updated", session: entry.session });
1501
2048
  }
1502
2049
  operation(action) {
1503
- return Effect7.tryPromise({
2050
+ return Effect8.tryPromise({
1504
2051
  try: action,
1505
2052
  catch: (cause) => new AgentError({ message: String(cause), cause })
1506
2053
  });
1507
2054
  }
1508
2055
  open(session) {
1509
- return Effect7.gen({ self: this }, function* () {
2056
+ return Effect8.gen({ self: this }, function* () {
1510
2057
  const existing = this.active.get(session.id);
1511
2058
  if (existing) return existing;
1512
- const agent = yield* Effect7.tryPromise({
2059
+ const agent = yield* Effect8.tryPromise({
1513
2060
  try: () => this.factory.open(
1514
2061
  session.id,
1515
- session.workspacePath ?? path5.join(this.worktreesRoot, session.id),
1516
- path5.join(this.sessionsRoot, session.id),
2062
+ session.workspacePath ?? path6.join(this.worktreesRoot, session.id),
2063
+ path6.join(this.sessionsRoot, session.id),
1517
2064
  session.model,
1518
2065
  session
1519
2066
  ),
@@ -1536,14 +2083,14 @@ var init_runtime = __esm({
1536
2083
  });
1537
2084
  }
1538
2085
  run(entry, message) {
1539
- return Effect7.gen({ self: this }, function* () {
2086
+ return Effect8.gen({ self: this }, function* () {
1540
2087
  let next = message;
1541
2088
  while (next !== void 0) {
1542
2089
  const current = next;
1543
2090
  yield* this.operation(
1544
2091
  () => entry.agent.prompt(gitFlowPrompt(entry.session, current.text), current.attachments)
1545
2092
  );
1546
- next = yield* Effect7.sync(() => {
2093
+ next = yield* Effect8.sync(() => {
1547
2094
  const queued = entry.stopping ? void 0 : entry.pending.shift();
1548
2095
  if (queued === void 0) {
1549
2096
  entry.running = false;
@@ -1555,19 +2102,19 @@ var init_runtime = __esm({
1555
2102
  yield* this.operation(() => this.onSettled(entry.session));
1556
2103
  }
1557
2104
  }).pipe(
1558
- Effect7.catch(
1559
- (error) => Effect7.sync(() => {
2105
+ Effect8.catch(
2106
+ (error) => Effect8.sync(() => {
1560
2107
  entry.pending.length = 0;
1561
2108
  entry.running = false;
1562
2109
  this.emit({ type: "error", sessionId: entry.session.id, message: error.message });
1563
2110
  this.status(entry, entry.stopping ? "stopped" : "failed");
1564
2111
  })
1565
2112
  ),
1566
- Effect7.onInterrupt(
2113
+ Effect8.onInterrupt(
1567
2114
  () => this.operation(() => entry.agent.abort()).pipe(
1568
- Effect7.orDie,
1569
- Effect7.ensuring(
1570
- Effect7.sync(() => {
2115
+ Effect8.orDie,
2116
+ Effect8.ensuring(
2117
+ Effect8.sync(() => {
1571
2118
  entry.pending.length = 0;
1572
2119
  entry.running = false;
1573
2120
  this.status(entry, "stopped");
@@ -1575,16 +2122,16 @@ var init_runtime = __esm({
1575
2122
  )
1576
2123
  )
1577
2124
  ),
1578
- Effect7.ensuring(Effect7.sync(() => this.working.delete(entry.session.id)))
2125
+ Effect8.ensuring(Effect8.sync(() => this.working.delete(entry.session.id)))
1579
2126
  );
1580
2127
  }
1581
- send(session, message, attachments2 = [], messageId = randomUUID3()) {
2128
+ send(session, message, attachments2 = [], messageId = randomUUID4()) {
1582
2129
  return this.lock(session.id).withPermit(
1583
- Effect7.gen({ self: this }, function* () {
2130
+ Effect8.gen({ self: this }, function* () {
1584
2131
  if (this.closed)
1585
- return yield* Effect7.fail(new AgentError({ message: "Server is shutting down" }));
2132
+ return yield* Effect8.fail(new AgentError({ message: "Server is shutting down" }));
1586
2133
  if (!session.branch && (session.gitFlow || !session.workspacePath))
1587
- return yield* Effect7.fail(new AgentError({ message: "Session checkout is not ready" }));
2134
+ return yield* Effect8.fail(new AgentError({ message: "Session checkout is not ready" }));
1588
2135
  if (this.hasMessage(session.id, messageId)) return;
1589
2136
  const entry = yield* this.open(session);
1590
2137
  yield* this.operation(async () => entry.agent.validateAttachments?.(attachments2));
@@ -1603,7 +2150,7 @@ var init_runtime = __esm({
1603
2150
  entry.stopping = false;
1604
2151
  this.status(entry, "running");
1605
2152
  this.working.add(session.id);
1606
- entry.fiber = yield* Effect7.forkIn(
2153
+ entry.fiber = yield* Effect8.forkIn(
1607
2154
  this.run(entry, { text: message, attachments: attachments2 }),
1608
2155
  this.scope,
1609
2156
  {
@@ -1611,22 +2158,22 @@ var init_runtime = __esm({
1611
2158
  }
1612
2159
  );
1613
2160
  })
1614
- ).pipe(Effect7.uninterruptible);
2161
+ ).pipe(Effect8.uninterruptible);
1615
2162
  }
1616
2163
  setModel(session, model) {
1617
2164
  return this.lock(session.id).withPermit(
1618
- Effect7.gen({ self: this }, function* () {
2165
+ Effect8.gen({ self: this }, function* () {
1619
2166
  if (this.closed)
1620
- return yield* Effect7.fail(new AgentError({ message: "Server is shutting down" }));
2167
+ return yield* Effect8.fail(new AgentError({ message: "Server is shutting down" }));
1621
2168
  const entry = yield* this.open(session);
1622
2169
  if (entry.running || entry.pending.length)
1623
- return yield* Effect7.fail(
2170
+ return yield* Effect8.fail(
1624
2171
  new AgentError({
1625
2172
  message: "Wait for this reply to finish before changing the model."
1626
2173
  })
1627
2174
  );
1628
2175
  if (!entry.agent.setModel)
1629
- return yield* Effect7.fail(
2176
+ return yield* Effect8.fail(
1630
2177
  new AgentError({ message: "This agent does not support model selection." })
1631
2178
  );
1632
2179
  yield* this.operation(() => entry.agent.setModel(model));
@@ -1638,10 +2185,10 @@ var init_runtime = __esm({
1638
2185
  this.emit({ type: "session.updated", session: entry.session });
1639
2186
  return entry.session;
1640
2187
  })
1641
- ).pipe(Effect7.uninterruptible);
2188
+ ).pipe(Effect8.uninterruptible);
1642
2189
  }
1643
2190
  stopEntry(entry) {
1644
- return Effect7.gen({ self: this }, function* () {
2191
+ return Effect8.gen({ self: this }, function* () {
1645
2192
  entry.stopping = true;
1646
2193
  entry.pending.length = 0;
1647
2194
  yield* this.operation(() => entry.agent.abort());
@@ -1651,7 +2198,7 @@ var init_runtime = __esm({
1651
2198
  }
1652
2199
  stop(session) {
1653
2200
  return this.lock(session.id).withPermit(
1654
- Effect7.gen({ self: this }, function* () {
2201
+ Effect8.gen({ self: this }, function* () {
1655
2202
  const entry = this.active.get(session.id);
1656
2203
  if (entry) {
1657
2204
  yield* this.stopEntry(entry);
@@ -1662,14 +2209,26 @@ var init_runtime = __esm({
1662
2209
  });
1663
2210
  }
1664
2211
  })
1665
- ).pipe(Effect7.uninterruptible);
2212
+ ).pipe(Effect8.uninterruptible);
2213
+ }
2214
+ forget(id) {
2215
+ return this.lock(id).withPermit(
2216
+ Effect8.sync(() => {
2217
+ const entry = this.active.get(id);
2218
+ if (!entry) return;
2219
+ if (entry.running) throw new Error("Cannot delete an active run.");
2220
+ entry.unsubscribe();
2221
+ entry.agent.dispose();
2222
+ this.active.delete(id);
2223
+ })
2224
+ );
1666
2225
  }
1667
2226
  close() {
1668
- return Effect7.gen({ self: this }, function* () {
2227
+ return Effect8.gen({ self: this }, function* () {
1669
2228
  this.closed = true;
1670
2229
  for (const [id, lock] of this.locks) {
1671
2230
  yield* lock.withPermit(
1672
- Effect7.gen({ self: this }, function* () {
2231
+ Effect8.gen({ self: this }, function* () {
1673
2232
  const entry = this.active.get(id);
1674
2233
  if (!entry) return;
1675
2234
  if (entry.running) yield* this.stopEntry(entry);
@@ -1689,8 +2248,8 @@ var init_runtime = __esm({
1689
2248
  }) {
1690
2249
  return Layer2.effect(
1691
2250
  this,
1692
- Effect7.gen(function* () {
1693
- const scope = yield* Effect7.scope;
2251
+ Effect8.gen(function* () {
2252
+ const scope = yield* Effect8.scope;
1694
2253
  const controller = new AgentController(
1695
2254
  factory,
1696
2255
  emit,
@@ -1700,7 +2259,7 @@ var init_runtime = __esm({
1700
2259
  hasMessage,
1701
2260
  onSettled
1702
2261
  );
1703
- yield* Effect7.addFinalizer(() => controller.close().pipe(Effect7.orDie));
2262
+ yield* Effect8.addFinalizer(() => controller.close().pipe(Effect8.orDie));
1704
2263
  return controller;
1705
2264
  })
1706
2265
  );
@@ -1757,17 +2316,17 @@ var init_workflow_command = __esm({
1757
2316
  });
1758
2317
 
1759
2318
  // src/workflow-schema.ts
1760
- import { z as z8 } from "zod";
2319
+ import { z as z9 } from "zod";
1761
2320
  var DeliveryJobSchema, PreparationJobSchema, WorkflowJobSchema;
1762
2321
  var init_workflow_schema = __esm({
1763
2322
  "src/workflow-schema.ts"() {
1764
2323
  "use strict";
1765
2324
  init_src();
1766
- DeliveryJobSchema = z8.object({
1767
- kind: z8.literal("delivery"),
1768
- sessionId: z8.string(),
2325
+ DeliveryJobSchema = z9.object({
2326
+ kind: z9.literal("delivery"),
2327
+ sessionId: z9.string(),
1769
2328
  request: DeliveryRequestSchema,
1770
- phase: z8.enum([
2329
+ phase: z9.enum([
1771
2330
  "validating",
1772
2331
  "drafting",
1773
2332
  "committing",
@@ -1777,25 +2336,25 @@ var init_workflow_schema = __esm({
1777
2336
  "opening_pr",
1778
2337
  "delivered"
1779
2338
  ]),
1780
- attempt: z8.number().int().positive(),
1781
- commitSha: z8.string().optional(),
1782
- title: z8.string(),
1783
- body: z8.string(),
1784
- logs: z8.string().default(""),
1785
- failed: z8.boolean().default(false),
1786
- result: z8.enum(["merged", "pr_opened", "no_changes"]).optional(),
1787
- url: z8.string().optional(),
1788
- message: z8.string().optional()
2339
+ attempt: z9.number().int().positive(),
2340
+ commitSha: z9.string().optional(),
2341
+ title: z9.string(),
2342
+ body: z9.string(),
2343
+ logs: z9.string().default(""),
2344
+ failed: z9.boolean().default(false),
2345
+ result: z9.enum(["merged", "pr_opened", "no_changes"]).optional(),
2346
+ url: z9.string().optional(),
2347
+ message: z9.string().optional()
1789
2348
  });
1790
- PreparationJobSchema = z8.object({
1791
- kind: z8.literal("preparation"),
1792
- sessionId: z8.string(),
1793
- projectPath: z8.string(),
2349
+ PreparationJobSchema = z9.object({
2350
+ kind: z9.literal("preparation"),
2351
+ sessionId: z9.string(),
2352
+ projectPath: z9.string(),
1794
2353
  input: CreateSessionSchema,
1795
- stage: z8.enum(["creating", "installing", "ready", "failed"]),
1796
- logs: z8.string().default("")
2354
+ stage: z9.enum(["creating", "installing", "ready", "failed"]),
2355
+ logs: z9.string().default("")
1797
2356
  });
1798
- WorkflowJobSchema = z8.discriminatedUnion("kind", [
2357
+ WorkflowJobSchema = z9.discriminatedUnion("kind", [
1799
2358
  DeliveryJobSchema,
1800
2359
  PreparationJobSchema
1801
2360
  ]);
@@ -1803,8 +2362,8 @@ var init_workflow_schema = __esm({
1803
2362
  });
1804
2363
 
1805
2364
  // src/delivery.ts
1806
- import { Effect as Effect8 } from "effect";
1807
- import { z as z9 } from "zod";
2365
+ import { Effect as Effect9 } from "effect";
2366
+ import { z as z10 } from "zod";
1808
2367
  var DeliveryCoordinator;
1809
2368
  var init_delivery = __esm({
1810
2369
  "src/delivery.ts"() {
@@ -1827,20 +2386,20 @@ var init_delivery = __esm({
1827
2386
  return [...this.workers.keys()].map((id) => `delivery:${id}`);
1828
2387
  }
1829
2388
  async session(id) {
1830
- const session = await Effect8.runPromise(this.store.get(id));
2389
+ const session = await Effect9.runPromise(this.store.get(id));
1831
2390
  if (!session?.gitFlow || !session.branch || !session.workspacePath)
1832
2391
  throw new Error("This run does not have a ready Git flow workspace.");
1833
2392
  return session;
1834
2393
  }
1835
2394
  async job(id) {
1836
- const raw = await Effect8.runPromise(this.store.workflow(`delivery:${id}`));
2395
+ const raw = await Effect9.runPromise(this.store.workflow(`delivery:${id}`));
1837
2396
  return raw === void 0 ? void 0 : DeliveryJobSchema.parse(raw);
1838
2397
  }
1839
2398
  git(session, args, signal) {
1840
2399
  return this.command(session.workspacePath, "git", args, signal);
1841
2400
  }
1842
2401
  async record(job) {
1843
- await Effect8.runPromise(this.store.saveWorkflow(`delivery:${job.sessionId}`, job));
2402
+ await Effect9.runPromise(this.store.saveWorkflow(`delivery:${job.sessionId}`, job));
1844
2403
  const session = await this.session(job.sessionId);
1845
2404
  const delivery2 = {
1846
2405
  status: job.failed ? "failed" : job.result ?? "pending",
@@ -2029,7 +2588,7 @@ Agentrific run ${id}.`;
2029
2588
  return (await this.session(id)).gitDelivery;
2030
2589
  }
2031
2590
  async recover() {
2032
- const jobs = await Effect8.runPromise(this.store.workflows());
2591
+ const jobs = await Effect9.runPromise(this.store.workflows());
2033
2592
  for (const raw of jobs) {
2034
2593
  const parsed = DeliveryJobSchema.safeParse(raw);
2035
2594
  if (!parsed.success) continue;
@@ -2103,11 +2662,11 @@ ${workflowError(error instanceof Error ? error : String(error))}`
2103
2662
  "--json",
2104
2663
  "url,state,headRefOid"
2105
2664
  ]);
2106
- const prs = z9.array(
2107
- z9.object({
2108
- url: z9.string().url(),
2109
- state: z9.string(),
2110
- headRefOid: z9.string().optional()
2665
+ const prs = z10.array(
2666
+ z10.object({
2667
+ url: z10.string().url(),
2668
+ state: z10.string(),
2669
+ headRefOid: z10.string().optional()
2111
2670
  })
2112
2671
  ).parse(JSON.parse(existing));
2113
2672
  const open = prs.find(
@@ -2151,9 +2710,9 @@ ${workflowError(error instanceof Error ? error : String(error))}`
2151
2710
 
2152
2711
  // src/preparation.ts
2153
2712
  import { mkdir as mkdir4, readFile as readFile3 } from "node:fs/promises";
2154
- import path6 from "node:path";
2155
- import { Effect as Effect9 } from "effect";
2156
- import { z as z10 } from "zod";
2713
+ import path7 from "node:path";
2714
+ import { Effect as Effect10 } from "effect";
2715
+ import { z as z11 } from "zod";
2157
2716
  var PreparationCoordinator;
2158
2717
  var init_preparation = __esm({
2159
2718
  "src/preparation.ts"() {
@@ -2190,8 +2749,8 @@ var init_preparation = __esm({
2190
2749
  job.logs = workflowLog(`${job.logs}
2191
2750
  ${message}${logs ? `
2192
2751
  ${logs}` : ""}`);
2193
- await Effect9.runPromise(this.store.saveWorkflow(`preparation:${session.id}`, job));
2194
- const current = await Effect9.runPromise(this.store.get(session.id));
2752
+ await Effect10.runPromise(this.store.saveWorkflow(`preparation:${session.id}`, job));
2753
+ const current = await Effect10.runPromise(this.store.get(session.id));
2195
2754
  this.options.publish({
2196
2755
  type: "session.updated",
2197
2756
  session: {
@@ -2210,7 +2769,7 @@ ${logs}` : ""}`);
2210
2769
  await record("installing", "Installing run dependencies\u2026");
2211
2770
  await this.install(workspace, command);
2212
2771
  await record("ready", "Workspace ready.");
2213
- return await Effect9.runPromise(this.store.get(session.id));
2772
+ return await Effect10.runPromise(this.store.get(session.id));
2214
2773
  } catch (error) {
2215
2774
  const step = job.stage === "installing" ? "Dependency installation failed" : "Workspace preparation failed";
2216
2775
  await record(
@@ -2254,7 +2813,7 @@ ${logs}` : ""}`);
2254
2813
  const workspace = session.workspacePath;
2255
2814
  if (branchAtWorkspace !== void 0)
2256
2815
  throw new Error("The run workspace already exists on a different branch.");
2257
- await mkdir4(path6.dirname(workspace), { recursive: true });
2816
+ await mkdir4(path7.dirname(workspace), { recursive: true });
2258
2817
  try {
2259
2818
  await command(
2260
2819
  projectPath,
@@ -2274,12 +2833,12 @@ ${logs}` : ""}`);
2274
2833
  }
2275
2834
  }
2276
2835
  async install(workspace, command) {
2277
- const packageFile = await readFile3(path6.join(workspace, "package.json"), "utf8").catch(
2836
+ const packageFile = await readFile3(path7.join(workspace, "package.json"), "utf8").catch(
2278
2837
  () => void 0
2279
2838
  );
2280
2839
  if (!packageFile) return;
2281
- const metadata = z10.object({ packageManager: z10.string().optional() }).parse(JSON.parse(packageFile));
2282
- const has = async (name) => readFile3(path6.join(workspace, name)).then(
2840
+ const metadata = z11.object({ packageManager: z11.string().optional() }).parse(JSON.parse(packageFile));
2841
+ const has = async (name) => readFile3(path7.join(workspace, name)).then(
2283
2842
  () => true,
2284
2843
  () => false
2285
2844
  );
@@ -2292,10 +2851,10 @@ ${logs}` : ""}`);
2292
2851
  else if (await has("package-lock.json")) await command(workspace, "npm", ["ci"]);
2293
2852
  }
2294
2853
  async retry(id) {
2295
- const raw = await Effect9.runPromise(this.store.workflow(`preparation:${id}`));
2854
+ const raw = await Effect10.runPromise(this.store.workflow(`preparation:${id}`));
2296
2855
  const job = PreparationJobSchema.parse(raw);
2297
2856
  return withTaskLock(`repository:${job.projectPath}`, async () => {
2298
- const session = await Effect9.runPromise(this.store.get(id));
2857
+ const session = await Effect10.runPromise(this.store.get(id));
2299
2858
  if (!session || session.preparation?.status !== "failed")
2300
2859
  throw new Error("This run does not need preparation retried.");
2301
2860
  const prepared = await this.prepare(session, job.projectPath, job.input);
@@ -2303,11 +2862,11 @@ ${logs}` : ""}`);
2303
2862
  });
2304
2863
  }
2305
2864
  async recover() {
2306
- for (const raw of await Effect9.runPromise(this.store.workflows())) {
2865
+ for (const raw of await Effect10.runPromise(this.store.workflows())) {
2307
2866
  const parsed = PreparationJobSchema.safeParse(raw);
2308
2867
  if (!parsed.success) continue;
2309
2868
  const job = parsed.data;
2310
- const session = await Effect9.runPromise(this.store.get(job.sessionId));
2869
+ const session = await Effect10.runPromise(this.store.get(job.sessionId));
2311
2870
  if (!session) continue;
2312
2871
  if (job.stage === "failed") {
2313
2872
  if (session.preparation?.status === "failed") continue;
@@ -2338,7 +2897,7 @@ ${logs}` : ""}`);
2338
2897
  });
2339
2898
  continue;
2340
2899
  }
2341
- await Effect9.runPromise(
2900
+ await Effect10.runPromise(
2342
2901
  this.store.saveWorkflow(`preparation:${session.id}`, { ...job, stage: "failed" })
2343
2902
  );
2344
2903
  this.options.publish({
@@ -2362,7 +2921,7 @@ ${logs}` : ""}`);
2362
2921
 
2363
2922
  // src/delivery-writer.ts
2364
2923
  import { createAgentSession, SessionManager } from "@earendil-works/pi-coding-agent";
2365
- import { z as z11 } from "zod";
2924
+ import { z as z12 } from "zod";
2366
2925
  function deliveryWriter(models2, createSession = createAgentSession) {
2367
2926
  return async (run, input, diff, signal) => {
2368
2927
  signal?.throwIfAborted();
@@ -2403,12 +2962,12 @@ var DraftSchema;
2403
2962
  var init_delivery_writer = __esm({
2404
2963
  "src/delivery-writer.ts"() {
2405
2964
  "use strict";
2406
- DraftSchema = z11.object({ title: z11.string().trim().min(1).max(120), body: z11.string().trim().min(1).max(1e4) }).strict();
2965
+ DraftSchema = z12.object({ title: z12.string().trim().min(1).max(120), body: z12.string().trim().min(1).max(1e4) }).strict();
2407
2966
  }
2408
2967
  });
2409
2968
 
2410
2969
  // src/models.ts
2411
- import { Effect as Effect10 } from "effect";
2970
+ import { Effect as Effect11 } from "effect";
2412
2971
  var legacyModel, makers, ServerModels;
2413
2972
  var init_models = __esm({
2414
2973
  "src/models.ts"() {
@@ -2477,7 +3036,7 @@ var init_models = __esm({
2477
3036
  await this.refreshing;
2478
3037
  }
2479
3038
  async defaultReference() {
2480
- return await Effect10.runPromise(this.store.defaultModel()) ?? legacyModel;
3039
+ return await Effect11.runPromise(this.store.defaultModel()) ?? legacyModel;
2481
3040
  }
2482
3041
  async defaultIdentity() {
2483
3042
  const selected = await this.defaultReference();
@@ -2513,7 +3072,7 @@ var init_models = __esm({
2513
3072
  }
2514
3073
  async setDefault(reference) {
2515
3074
  await this.resolve(reference);
2516
- await Effect10.runPromise(this.store.setDefaultModel(reference));
3075
+ await Effect11.runPromise(this.store.setDefaultModel(reference));
2517
3076
  return this.identity(reference.platformId, reference.modelId);
2518
3077
  }
2519
3078
  };
@@ -2558,13 +3117,13 @@ var init_delivery_tool = __esm({
2558
3117
  });
2559
3118
 
2560
3119
  // src/pi.ts
2561
- import { Effect as Effect11 } from "effect";
2562
- import { randomUUID as randomUUID4 } from "node:crypto";
3120
+ import { Effect as Effect12 } from "effect";
3121
+ import { randomUUID as randomUUID5 } from "node:crypto";
2563
3122
  import {
2564
3123
  createAgentSession as createAgentSession2,
2565
3124
  SessionManager as SessionManager2
2566
3125
  } from "@earendil-works/pi-coding-agent";
2567
- import { z as z12 } from "zod";
3126
+ import { z as z13 } from "zod";
2568
3127
  function toolOutput(value) {
2569
3128
  const parsed = ToolResultSchema.safeParse(value);
2570
3129
  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);
@@ -2596,7 +3155,7 @@ function createPiFactory(files, models2, delivery2, createSession = createAgentS
2596
3155
  );
2597
3156
  },
2598
3157
  async prompt(message, attachments2 = []) {
2599
- const context = await Effect11.runPromise(files.prepare(attachments2));
3158
+ const context = await Effect12.runPromise(files.prepare(attachments2));
2600
3159
  await session.prompt(message + context.context, { images: context.images });
2601
3160
  const last = session.messages.at(-1);
2602
3161
  if (last?.role === "assistant" && last.stopReason === "error") {
@@ -2633,7 +3192,7 @@ function createPiFactory(files, models2, delivery2, createSession = createAgentS
2633
3192
  function handleMessage(event) {
2634
3193
  if (event.type === "message_start" && event.message.role === "assistant") {
2635
3194
  flushText();
2636
- assistantMessageId = randomUUID4();
3195
+ assistantMessageId = randomUUID5();
2637
3196
  emit({
2638
3197
  type: "message.started",
2639
3198
  sessionId,
@@ -2644,7 +3203,7 @@ function createPiFactory(files, models2, delivery2, createSession = createAgentS
2644
3203
  return true;
2645
3204
  }
2646
3205
  if (event.type === "message_update" && event.assistantMessageEvent.type === "text_delta") {
2647
- if (!assistantMessageId) assistantMessageId = randomUUID4();
3206
+ if (!assistantMessageId) assistantMessageId = randomUUID5();
2648
3207
  pendingText += event.assistantMessageEvent.delta;
2649
3208
  setActivity("responding");
2650
3209
  if (!flushTimer) flushTimer = setTimeout(flushText, 50);
@@ -2722,8 +3281,8 @@ var init_pi = __esm({
2722
3281
  "use strict";
2723
3282
  init_models();
2724
3283
  init_delivery_tool();
2725
- ToolResultSchema = z12.object({
2726
- content: z12.array(z12.object({ type: z12.string(), text: z12.string().optional() })).optional()
3284
+ ToolResultSchema = z13.object({
3285
+ content: z13.array(z13.object({ type: z13.string(), text: z13.string().optional() })).optional()
2727
3286
  }).passthrough();
2728
3287
  boundedJson = (value, limit) => {
2729
3288
  let text;
@@ -2739,18 +3298,18 @@ var init_pi = __esm({
2739
3298
 
2740
3299
  // src/projects.ts
2741
3300
  import { access as access2, readdir as readdir2, realpath, stat as stat3, readFile as readFile4, writeFile as writeFile3 } from "node:fs/promises";
2742
- import { randomUUID as randomUUID5 } from "node:crypto";
2743
- import { z as z13 } from "zod";
3301
+ import { randomUUID as randomUUID6 } from "node:crypto";
3302
+ import { z as z14 } from "zod";
2744
3303
  import { constants as constants2 } from "node:fs";
2745
3304
  import { homedir as homedir3 } from "node:os";
2746
- import path7 from "node:path";
2747
- import { Effect as Effect12, Semaphore as Semaphore3 } from "effect";
3305
+ import path8 from "node:path";
3306
+ import { Effect as Effect13, Semaphore as Semaphore3 } from "effect";
2748
3307
  function resolveProjectsRoot(input) {
2749
- return Effect12.tryPromise({
3308
+ return Effect13.tryPromise({
2750
3309
  try: async () => {
2751
3310
  const trimmed = input.trim();
2752
- const expanded = trimmed === "~" ? homedir3() : trimmed.startsWith("~/") ? path7.join(homedir3(), trimmed.slice(2)) : trimmed;
2753
- if (!path7.isAbsolute(expanded))
3311
+ const expanded = trimmed === "~" ? homedir3() : trimmed.startsWith("~/") ? path8.join(homedir3(), trimmed.slice(2)) : trimmed;
3312
+ if (!path8.isAbsolute(expanded))
2754
3313
  throw new Error("Use an absolute server path or a path beginning with ~/.");
2755
3314
  const resolved = await realpath(expanded);
2756
3315
  if (!(await stat3(resolved)).isDirectory())
@@ -2772,61 +3331,61 @@ function projectLock(store2) {
2772
3331
  return lock;
2773
3332
  }
2774
3333
  async function gitDirectory(directory) {
2775
- const location = path7.join(directory, ".git");
3334
+ const location = path8.join(directory, ".git");
2776
3335
  const info = await stat3(location);
2777
3336
  if (info.isDirectory()) return location;
2778
3337
  const contents = await readFile4(location, "utf8");
2779
3338
  const match = /^gitdir: (.+)\s*$/m.exec(contents);
2780
3339
  if (!match) throw new Error("Invalid Git worktree metadata.");
2781
- const resolved = await realpath(path7.resolve(directory, match[1].trim()));
3340
+ const resolved = await realpath(path8.resolve(directory, match[1].trim()));
2782
3341
  if (!(await stat3(resolved)).isDirectory()) throw new Error("Git directory is missing.");
2783
3342
  return resolved;
2784
3343
  }
2785
3344
  async function identity(directory, preferred) {
2786
- const filename = path7.join(await gitDirectory(directory), "agentrific-project-id");
3345
+ const filename = path8.join(await gitDirectory(directory), "agentrific-project-id");
2787
3346
  try {
2788
3347
  return ProjectId.parse(await readFile4(filename, "utf8"));
2789
3348
  } catch (error) {
2790
- if (!z13.object({ code: z13.literal("ENOENT") }).safeParse(error).success) throw error;
2791
- const legacyFilename = path7.join(await gitDirectory(directory), "chatplans-project-id");
3349
+ if (!z14.object({ code: z14.literal("ENOENT") }).safeParse(error).success) throw error;
3350
+ const legacyFilename = path8.join(await gitDirectory(directory), "chatplans-project-id");
2792
3351
  const legacy = await readFile4(legacyFilename, "utf8").catch((cause) => {
2793
- if (!z13.object({ code: z13.literal("ENOENT") }).safeParse(cause).success) throw cause;
3352
+ if (!z14.object({ code: z14.literal("ENOENT") }).safeParse(cause).success) throw cause;
2794
3353
  return null;
2795
3354
  });
2796
- const id = legacy === null ? preferred ?? randomUUID5() : ProjectId.parse(legacy);
3355
+ const id = legacy === null ? preferred ?? randomUUID6() : ProjectId.parse(legacy);
2797
3356
  try {
2798
3357
  await writeFile3(filename, id + "\n", { flag: "wx" });
2799
3358
  return id;
2800
3359
  } catch (writeError) {
2801
- if (!z13.object({ code: z13.literal("EEXIST") }).safeParse(writeError).success) throw writeError;
3360
+ if (!z14.object({ code: z14.literal("EEXIST") }).safeParse(writeError).success) throw writeError;
2802
3361
  return ProjectId.parse(await readFile4(filename, "utf8"));
2803
3362
  }
2804
3363
  }
2805
3364
  }
2806
3365
  function scanProjects(root, store2) {
2807
- return Effect12.tryPromise({
3366
+ return Effect13.tryPromise({
2808
3367
  try: async () => {
2809
3368
  root = await realpath(root);
2810
- const registered = await Effect12.runPromise(store2.registeredProjects(root));
3369
+ const registered = await Effect13.runPromise(store2.registeredProjects(root));
2811
3370
  const found = [];
2812
3371
  const entries = await readdir2(root, { withFileTypes: true });
2813
3372
  for (const entry of entries) {
2814
3373
  if (!entry.isDirectory()) continue;
2815
- const directory = path7.join(root, entry.name);
2816
- if (!await stat3(path7.join(directory, ".git")).catch(() => null)) continue;
3374
+ const directory = path8.join(root, entry.name);
3375
+ if (!await stat3(path8.join(directory, ".git")).catch(() => null)) continue;
2817
3376
  const previous = registered.find((project2) => project2.path === directory);
2818
3377
  let id = await identity(directory, previous?.available ? void 0 : previous?.id);
2819
3378
  let existing = registered.find((project2) => project2.id === id);
2820
- if (found.some((project2) => project2.id === id) || existing && existing.path !== directory && await stat3(path7.join(existing.path, ".git")).catch(() => null)) {
2821
- id = randomUUID5();
3379
+ if (found.some((project2) => project2.id === id) || existing && existing.path !== directory && await stat3(path8.join(existing.path, ".git")).catch(() => null)) {
3380
+ id = randomUUID6();
2822
3381
  await writeFile3(
2823
- path7.join(await gitDirectory(directory), "agentrific-project-id"),
3382
+ path8.join(await gitDirectory(directory), "agentrific-project-id"),
2824
3383
  id + "\n"
2825
3384
  );
2826
3385
  existing = void 0;
2827
3386
  }
2828
3387
  const project = { id, name: entry.name, path: directory, available: true };
2829
- await Effect12.runPromise(store2.relocateProject(project, existing?.path));
3388
+ await Effect13.runPromise(store2.relocateProject(project, existing?.path));
2830
3389
  found.push(project);
2831
3390
  }
2832
3391
  return [
@@ -2841,26 +3400,26 @@ function discoverProjects(root, store2) {
2841
3400
  return projectLock(store2).withPermit(scanProjects(root, store2));
2842
3401
  }
2843
3402
  function reconnectProject(root, id, input, store2) {
2844
- return Effect12.tryPromise({
3403
+ return Effect13.tryPromise({
2845
3404
  try: async () => {
2846
- const projects = await Effect12.runPromise(scanProjects(root, store2));
3405
+ const projects = await Effect13.runPromise(scanProjects(root, store2));
2847
3406
  const previous = projects.find((project2) => project2.id === id);
2848
3407
  if (!previous || previous.available)
2849
3408
  throw new Error("Only a missing project can be reconnected.");
2850
- const sessions = await Effect12.runPromise(store2.list());
3409
+ const sessions = await Effect13.runPromise(store2.list());
2851
3410
  if (sessions.some(
2852
3411
  (session) => session.projectId === id && ["starting", "running", "queued"].includes(session.status)
2853
3412
  ))
2854
3413
  throw new Error("Stop this project's running sessions before reconnecting its folder.");
2855
- const directory = await Effect12.runPromise(resolveProjectsRoot(input));
2856
- if (path7.dirname(directory) !== await realpath(root))
3414
+ const directory = await Effect13.runPromise(resolveProjectsRoot(input));
3415
+ if (path8.dirname(directory) !== await realpath(root))
2857
3416
  throw new Error("Choose a Git repository directly inside the configured projects folder.");
2858
- const filename = path7.join(await gitDirectory(directory), "agentrific-project-id");
3417
+ const filename = path8.join(await gitDirectory(directory), "agentrific-project-id");
2859
3418
  const replacedId = await identity(directory);
2860
- const project = { id, name: path7.basename(directory), path: directory, available: true };
3419
+ const project = { id, name: path8.basename(directory), path: directory, available: true };
2861
3420
  await writeFile3(filename, id + "\n");
2862
3421
  try {
2863
- await Effect12.runPromise(store2.relocateProject(project, previous.path, replacedId));
3422
+ await Effect13.runPromise(store2.relocateProject(project, previous.path, replacedId));
2864
3423
  } catch (error) {
2865
3424
  await writeFile3(filename, replacedId + "\n");
2866
3425
  throw error;
@@ -2875,34 +3434,86 @@ var init_projects = __esm({
2875
3434
  "src/projects.ts"() {
2876
3435
  "use strict";
2877
3436
  locks2 = /* @__PURE__ */ new WeakMap();
2878
- ProjectId = z13.string().trim().min(1).max(255);
3437
+ ProjectId = z14.string().trim().min(1).max(255);
3438
+ }
3439
+ });
3440
+
3441
+ // src/push-schema.ts
3442
+ import { z as z15 } from "zod";
3443
+ var PushBindingSchema, PushStateSchema, PendingPushSchema;
3444
+ var init_push_schema = __esm({
3445
+ "src/push-schema.ts"() {
3446
+ "use strict";
3447
+ init_push();
3448
+ PushBindingSchema = z15.object({
3449
+ bindingId: z15.string().uuid(),
3450
+ installationId: z15.string().uuid(),
3451
+ deviceId: z15.string().uuid()
3452
+ });
3453
+ PushStateSchema = z15.object({
3454
+ bindings: z15.array(PushBindingSchema).max(10),
3455
+ error: z15.string().nullable()
3456
+ });
3457
+ PendingPushSchema = PushEventSchema.extend({
3458
+ remaining: z15.array(PushBindingSchema),
3459
+ createdAt: z15.number(),
3460
+ attempts: z15.number().int().nonnegative(),
3461
+ nextAt: z15.number()
3462
+ });
2879
3463
  }
2880
3464
  });
2881
3465
 
2882
3466
  // src/store.ts
2883
- import { mkdirSync } from "node:fs";
2884
- import path8 from "node:path";
3467
+ import { chmodSync, existsSync as existsSync2, mkdirSync as mkdirSync2 } from "node:fs";
3468
+ import path9 from "node:path";
2885
3469
  import { DatabaseSync } from "node:sqlite";
2886
- import { Context as Context3, Data as Data3, Effect as Effect13, Layer as Layer3 } from "effect";
2887
- import { z as z14 } from "zod";
2888
- import { randomUUID as randomUUID6 } from "node:crypto";
3470
+ import { Context as Context3, Data as Data3, Effect as Effect14, Layer as Layer3 } from "effect";
3471
+ import { z as z16 } from "zod";
3472
+ import { randomUUID as randomUUID7 } from "node:crypto";
2889
3473
  var StorageError, PayloadRow, EventRow, SqliteSessionStore, SessionStore;
2890
3474
  var init_store = __esm({
2891
3475
  "src/store.ts"() {
2892
3476
  "use strict";
3477
+ init_push_schema();
2893
3478
  init_src();
2894
3479
  init_src();
2895
3480
  init_workflow_schema();
2896
3481
  init_src();
2897
3482
  StorageError = class extends Data3.TaggedError("StorageError") {
2898
3483
  };
2899
- PayloadRow = z14.object({ payload: z14.string() });
2900
- EventRow = z14.object({ sequence: z14.number(), recorded_at: z14.string(), payload: z14.string() });
3484
+ PayloadRow = z16.object({ payload: z16.string() });
3485
+ EventRow = z16.object({ sequence: z16.number(), recorded_at: z16.string(), payload: z16.string() });
2901
3486
  SqliteSessionStore = class {
2902
3487
  constructor(db) {
2903
3488
  this.db = db;
2904
3489
  }
2905
3490
  db;
3491
+ pushState() {
3492
+ return this.query(() => {
3493
+ const row = this.db.prepare("SELECT payload FROM settings WHERE id = 'push-state'").get();
3494
+ return row ? PushStateSchema.parse(JSON.parse(PayloadRow.parse(row).payload)) : { bindings: [], error: null };
3495
+ });
3496
+ }
3497
+ savePushState(state) {
3498
+ return this.query(
3499
+ () => this.db.prepare(
3500
+ "INSERT INTO settings(id, payload) VALUES ('push-state', ?) ON CONFLICT(id) DO UPDATE SET payload = excluded.payload"
3501
+ ).run(JSON.stringify(PushStateSchema.parse(state)))
3502
+ );
3503
+ }
3504
+ pendingPush() {
3505
+ return this.query(
3506
+ () => this.db.prepare("SELECT payload FROM push_outbox ORDER BY rowid LIMIT 100").all().map((row) => PendingPushSchema.parse(JSON.parse(PayloadRow.parse(row).payload)))
3507
+ );
3508
+ }
3509
+ savePendingPush(job) {
3510
+ return this.query(
3511
+ () => this.db.prepare("UPDATE push_outbox SET payload = ? WHERE id = ?").run(JSON.stringify(PendingPushSchema.parse(job)), job.eventId)
3512
+ );
3513
+ }
3514
+ removePush(eventId) {
3515
+ return this.query(() => this.db.prepare("DELETE FROM push_outbox WHERE id = ?").run(eventId));
3516
+ }
2906
3517
  serverUpdateStatus() {
2907
3518
  return this.query(() => {
2908
3519
  const row = this.db.prepare("SELECT payload FROM settings WHERE id = 'server-update-status'").get();
@@ -2918,11 +3529,11 @@ var init_store = __esm({
2918
3529
  }
2919
3530
  serverIdentity() {
2920
3531
  return this.query(() => {
2921
- this.db.prepare("INSERT OR IGNORE INTO settings(id, payload) VALUES ('server-id', ?)").run(JSON.stringify(randomUUID6()));
3532
+ this.db.prepare("INSERT OR IGNORE INTO settings(id, payload) VALUES ('server-id', ?)").run(JSON.stringify(randomUUID7()));
2922
3533
  const row = PayloadRow.parse(
2923
3534
  this.db.prepare("SELECT payload FROM settings WHERE id = 'server-id'").get()
2924
3535
  );
2925
- return z14.string().uuid().parse(JSON.parse(row.payload));
3536
+ return z16.string().uuid().parse(JSON.parse(row.payload));
2926
3537
  });
2927
3538
  }
2928
3539
  saveDevice(device, tokenHash) {
@@ -2959,7 +3570,7 @@ var init_store = __esm({
2959
3570
  });
2960
3571
  }
2961
3572
  query(operation) {
2962
- return Effect13.try({
3573
+ return Effect14.try({
2963
3574
  try: operation,
2964
3575
  catch: (cause) => new StorageError({
2965
3576
  message: cause instanceof Error ? cause.message : "Session storage operation failed",
@@ -3010,10 +3621,10 @@ var init_store = __esm({
3010
3621
  `).all();
3011
3622
  const insert = this.db.prepare("INSERT OR IGNORE INTO projects(id, payload) VALUES (?, ?)");
3012
3623
  for (const row of legacy) {
3013
- const id = z14.object({ id: z14.string() }).parse(row).id;
3624
+ const id = z16.object({ id: z16.string() }).parse(row).id;
3014
3625
  insert.run(
3015
3626
  id,
3016
- JSON.stringify({ id, name: id, path: path8.join(root, id), available: false })
3627
+ JSON.stringify({ id, name: id, path: path9.join(root, id), available: false })
3017
3628
  );
3018
3629
  }
3019
3630
  return this.db.prepare("SELECT payload FROM projects").all().map((row) => ProjectSchema.parse(JSON.parse(PayloadRow.parse(row).payload)));
@@ -3050,7 +3661,7 @@ var init_store = __esm({
3050
3661
  this.db.prepare(
3051
3662
  "UPDATE workflows SET payload = json_set(payload, '$.projectPath', ?) WHERE json_extract(payload, '$.kind') = 'preparation' AND json_extract(payload, '$.projectPath') = ?"
3052
3663
  ).run(project.path, previousPath);
3053
- const relocate = (workspace) => workspace === previousPath || workspace.startsWith(`${previousPath}${path8.sep}`) ? project.path + workspace.slice(previousPath.length) : workspace;
3664
+ const relocate = (workspace) => workspace === previousPath || workspace.startsWith(`${previousPath}${path9.sep}`) ? project.path + workspace.slice(previousPath.length) : workspace;
3054
3665
  for (const row of this.db.prepare("SELECT id, payload FROM sessions WHERE json_extract(payload, '$.projectId') = ?").all(project.id)) {
3055
3666
  const session = SessionSchema.parse(JSON.parse(PayloadRow.parse(row).payload));
3056
3667
  if (session.workspacePath) {
@@ -3125,6 +3736,31 @@ var init_store = __esm({
3125
3736
  return row ? TaskSchema.parse(JSON.parse(PayloadRow.parse(row).payload)) : void 0;
3126
3737
  });
3127
3738
  }
3739
+ purgeTask(id, sessionIds) {
3740
+ return this.query(() => {
3741
+ this.db.exec("BEGIN IMMEDIATE");
3742
+ try {
3743
+ for (const sessionId of sessionIds) {
3744
+ this.db.prepare("DELETE FROM events WHERE session_id = ?").run(sessionId);
3745
+ this.db.prepare("DELETE FROM push_outbox WHERE json_extract(payload, '$.sessionId') = ?").run(sessionId);
3746
+ this.db.prepare("DELETE FROM workflows WHERE json_extract(payload, '$.sessionId') = ?").run(sessionId);
3747
+ this.db.prepare("DELETE FROM sessions WHERE id = ?").run(sessionId);
3748
+ }
3749
+ this.db.prepare("DELETE FROM tasks WHERE id = ?").run(id);
3750
+ this.db.exec(`UPDATE attachments SET claimed = 0 WHERE NOT EXISTS (
3751
+ SELECT 1 FROM tasks, json_each(tasks.payload, '$.attachments') AS file
3752
+ WHERE json_extract(file.value, '$.id') = attachments.id
3753
+ ) AND NOT EXISTS (
3754
+ SELECT 1 FROM events, json_each(events.payload, '$.attachments') AS file
3755
+ WHERE json_extract(file.value, '$.id') = attachments.id
3756
+ )`);
3757
+ this.db.exec("COMMIT");
3758
+ } catch (error) {
3759
+ this.db.exec("ROLLBACK");
3760
+ throw error;
3761
+ }
3762
+ });
3763
+ }
3128
3764
  saveAttachment(attachment) {
3129
3765
  return this.query(() => {
3130
3766
  this.db.prepare("INSERT INTO attachments(id, payload) VALUES (?, ?)").run(attachment.id, JSON.stringify(AttachmentSchema.parse(attachment)));
@@ -3181,7 +3817,7 @@ var init_store = __esm({
3181
3817
  }
3182
3818
  });
3183
3819
  }
3184
- append(event) {
3820
+ append(event, notification) {
3185
3821
  return this.query(() => {
3186
3822
  const sessionId = event.type === "session.updated" ? event.session.id : event.sessionId;
3187
3823
  const recordedAt = (/* @__PURE__ */ new Date()).toISOString();
@@ -3194,6 +3830,14 @@ var init_store = __esm({
3194
3830
  }
3195
3831
  if (event.type === "message.user") this.claimAttachments(event.attachments ?? []);
3196
3832
  const result = this.db.prepare("INSERT INTO events(session_id, recorded_at, payload) VALUES (?, ?, ?)").run(sessionId, recordedAt, JSON.stringify(event));
3833
+ if (notification) {
3834
+ const count = z16.object({ total: z16.number() }).parse(this.db.prepare("SELECT COUNT(*) AS total FROM push_outbox").get());
3835
+ if (count.total >= 1e3)
3836
+ this.db.prepare(
3837
+ "DELETE FROM push_outbox WHERE rowid IN (SELECT rowid FROM push_outbox ORDER BY rowid LIMIT 1)"
3838
+ ).run();
3839
+ this.db.prepare("INSERT INTO push_outbox(id, payload) VALUES (?, ?)").run(notification.eventId, JSON.stringify(PendingPushSchema.parse(notification)));
3840
+ }
3197
3841
  const record = RecordedEventSchema.parse({
3198
3842
  sequence: Number(result.lastInsertRowid),
3199
3843
  recordedAt,
@@ -3230,13 +3874,16 @@ var init_store = __esm({
3230
3874
  static layer(filename) {
3231
3875
  return Layer3.effect(
3232
3876
  this,
3233
- Effect13.acquireRelease(
3234
- Effect13.try({
3877
+ Effect14.acquireRelease(
3878
+ Effect14.try({
3235
3879
  try: () => {
3236
- mkdirSync(path8.dirname(filename), { recursive: true });
3880
+ mkdirSync2(path9.dirname(filename), { recursive: true });
3237
3881
  const db = new DatabaseSync(filename);
3882
+ chmodSync(filename, 384);
3883
+ for (const suffix of ["-wal", "-shm"])
3884
+ if (existsSync2(filename + suffix)) chmodSync(filename + suffix, 384);
3238
3885
  try {
3239
- const schema = z14.object({ user_version: z14.number() }).parse(db.prepare("PRAGMA user_version").get());
3886
+ const schema = z16.object({ user_version: z16.number() }).parse(db.prepare("PRAGMA user_version").get());
3240
3887
  if (schema.user_version > 1)
3241
3888
  throw new Error("This database requires a newer Agentrific server.");
3242
3889
  db.exec(`
@@ -3247,6 +3894,7 @@ var init_store = __esm({
3247
3894
  CREATE TABLE IF NOT EXISTS settings(id TEXT PRIMARY KEY, payload TEXT NOT NULL);
3248
3895
  CREATE TABLE IF NOT EXISTS sessions(id TEXT PRIMARY KEY, payload TEXT NOT NULL);
3249
3896
  CREATE TABLE IF NOT EXISTS tasks(id TEXT PRIMARY KEY, payload TEXT NOT NULL);
3897
+ CREATE TABLE IF NOT EXISTS push_outbox(id TEXT PRIMARY KEY, payload TEXT NOT NULL);
3250
3898
  CREATE TABLE IF NOT EXISTS workflows(id TEXT PRIMARY KEY, payload TEXT NOT NULL);
3251
3899
  CREATE TABLE IF NOT EXISTS attachments(id TEXT PRIMARY KEY, payload TEXT NOT NULL, claimed INTEGER NOT NULL DEFAULT 0);
3252
3900
  CREATE TABLE IF NOT EXISTS events(sequence INTEGER PRIMARY KEY AUTOINCREMENT, session_id TEXT NOT NULL REFERENCES sessions(id), recorded_at TEXT NOT NULL, payload TEXT NOT NULL);
@@ -3262,14 +3910,85 @@ var init_store = __esm({
3262
3910
  },
3263
3911
  catch: (cause) => new StorageError({ message: "Could not open session database", cause })
3264
3912
  }),
3265
- (db) => Effect13.sync(() => db.close())
3266
- ).pipe(Effect13.map((db) => new SqliteSessionStore(db)))
3913
+ (db) => Effect14.sync(() => db.close())
3914
+ ).pipe(Effect14.map((db) => new SqliteSessionStore(db)))
3267
3915
  );
3268
3916
  }
3269
3917
  };
3270
3918
  }
3271
3919
  });
3272
3920
 
3921
+ // src/worktree-cleanup.ts
3922
+ import { lstat, rm as rm2 } from "node:fs/promises";
3923
+ import path10 from "node:path";
3924
+ import { Effect as Effect15 } from "effect";
3925
+ async function removeRunFiles(dataRoot2, sessionId) {
3926
+ if (path10.basename(sessionId) !== sessionId || sessionId === "." || sessionId === "..")
3927
+ throw new Error("Invalid session directory.");
3928
+ await rm2(path10.join(dataRoot2, "pi", sessionId), { recursive: true, force: true });
3929
+ }
3930
+ var WorktreeCleanup;
3931
+ var init_worktree_cleanup = __esm({
3932
+ "src/worktree-cleanup.ts"() {
3933
+ "use strict";
3934
+ init_tasks();
3935
+ init_workflow_command();
3936
+ WorktreeCleanup = class {
3937
+ constructor(store2, root, projectsRoot) {
3938
+ this.store = store2;
3939
+ this.root = root;
3940
+ this.projectsRoot = projectsRoot;
3941
+ }
3942
+ store;
3943
+ root;
3944
+ projectsRoot;
3945
+ async remove(session, discardChanges = true) {
3946
+ if (session.gitFlow?.workspace !== "worktree" || !session.workspacePath) return;
3947
+ const workspace = path10.resolve(session.workspacePath);
3948
+ const info = await lstat(workspace).catch((error) => {
3949
+ if (error.code === "ENOENT") return void 0;
3950
+ throw error;
3951
+ });
3952
+ if (workspace !== path10.join(this.root, session.id) || path10.basename(session.id) !== session.id) {
3953
+ if (!info) return;
3954
+ throw new Error("Refusing to remove a worktree outside the managed workspaces directory.");
3955
+ }
3956
+ if (info?.isSymbolicLink()) throw new Error("Refusing to remove a symlink worktree.");
3957
+ const job = await Effect15.runPromise(this.store.workflow(`preparation:${session.id}`));
3958
+ const projects = await Effect15.runPromise(this.store.registeredProjects(this.projectsRoot));
3959
+ const project = job?.kind === "preparation" ? job.projectPath : projects.find((item) => item.id === session.projectId)?.path;
3960
+ if (!project) throw new Error("Reconnect the project before removing its worktree.");
3961
+ if (info) {
3962
+ await workflowCommand(project, "git", [
3963
+ "worktree",
3964
+ "remove",
3965
+ ...discardChanges ? ["--force"] : [],
3966
+ workspace
3967
+ ]);
3968
+ } else {
3969
+ await workflowCommand(project, "git", ["worktree", "prune"]);
3970
+ }
3971
+ }
3972
+ async prune(maxAgeDays, now = Date.now()) {
3973
+ if (!Number.isFinite(maxAgeDays) || maxAgeDays <= 0) return;
3974
+ const sessions = await Effect15.runPromise(this.store.list());
3975
+ const failures = [];
3976
+ for (const session of sessions) {
3977
+ const lockId = await sessionTaskLockId(this.store, session);
3978
+ await withTaskLock(lockId, async () => {
3979
+ const current = await Effect15.runPromise(this.store.get(session.id));
3980
+ if (!current || taskStatusForSession(current.status, current) !== "done" || now - Date.parse(current.updatedAt) < maxAgeDays * 864e5)
3981
+ return;
3982
+ await this.remove(current, false);
3983
+ }).catch((error) => failures.push(error));
3984
+ }
3985
+ if (failures.length)
3986
+ throw new AggregateError(failures, "Some completed worktrees could not be pruned.");
3987
+ }
3988
+ };
3989
+ }
3990
+ });
3991
+
3273
3992
  // src/index.ts
3274
3993
  var index_exports = {};
3275
3994
  import Fastify from "fastify";
@@ -3277,18 +3996,18 @@ import pretty from "pino-pretty";
3277
3996
  import { styleText as styleText4 } from "node:util";
3278
3997
  import cors from "@fastify/cors";
3279
3998
  import multipart from "@fastify/multipart";
3280
- import { randomUUID as randomUUID7 } from "node:crypto";
3999
+ import { randomUUID as randomUUID8 } from "node:crypto";
3281
4000
  import { execFile as execFile3 } from "node:child_process";
3282
4001
  import { promisify as promisify3 } from "node:util";
3283
4002
  import { realpath as realpath2 } from "node:fs/promises";
3284
4003
  import { homedir as homedir4 } from "node:os";
3285
- import path9 from "node:path";
4004
+ import path11 from "node:path";
3286
4005
  import { WebSocket } from "ws";
3287
- import { Effect as Effect14, ManagedRuntime } from "effect";
3288
- import { z as z15 } from "zod";
4006
+ import { Effect as Effect16, ManagedRuntime } from "effect";
4007
+ import { z as z17 } from "zod";
3289
4008
  import { ModelRuntime } from "@earendil-works/pi-coding-agent";
3290
4009
  function publish(event, workflowUpdate = false) {
3291
- const previous = event.type === "session.updated" ? Effect14.runSync(store.get(event.session.id)) : void 0;
4010
+ const previous = event.type === "session.updated" ? Effect16.runSync(store.get(event.session.id)) : void 0;
3292
4011
  const emittedEvent = event.type === "session.updated" && previous && !workflowUpdate ? {
3293
4012
  type: "session.updated",
3294
4013
  session: {
@@ -3297,7 +4016,8 @@ function publish(event, workflowUpdate = false) {
3297
4016
  preparation: previous.preparation
3298
4017
  }
3299
4018
  } : event;
3300
- const record = Effect14.runSync(store.append(emittedEvent));
4019
+ const notification = emittedEvent.type === "session.updated" ? push.pending(emittedEvent.session, previous) : void 0;
4020
+ const record = Effect16.runSync(store.append(emittedEvent, notification));
3301
4021
  const message = JSON.stringify({ type: "event", record });
3302
4022
  for (const client of security.sockets.keys()) {
3303
4023
  if (!(client instanceof WebSocket) || client.readyState !== WebSocket.OPEN) continue;
@@ -3337,26 +4057,26 @@ async function selectSessionModel(input, hasImages) {
3337
4057
  }
3338
4058
  }
3339
4059
  async function resolveSessionProject(projectId) {
3340
- const projectsRoot = await Effect14.runPromise(store.projectsRoot(defaultProjectsRoot));
3341
- const project = (await Effect14.runPromise(discoverProjects(projectsRoot, store))).find(
4060
+ const projectsRoot = await Effect16.runPromise(store.projectsRoot(defaultProjectsRoot));
4061
+ const project = (await Effect16.runPromise(discoverProjects(projectsRoot, store))).find(
3342
4062
  (item) => item.id === projectId && item.available
3343
4063
  );
3344
4064
  if (!project)
3345
4065
  return { error: "Project folder is missing. Reconnect it before starting a run.", status: 404 };
3346
4066
  const projectPath = await realpath2(project.path);
3347
4067
  const resolvedRoot = await realpath2(projectsRoot);
3348
- if (path9.dirname(projectPath) !== resolvedRoot)
4068
+ if (path11.dirname(projectPath) !== resolvedRoot)
3349
4069
  return { error: "Project path is outside projects root.", status: 400 };
3350
4070
  return { projectPath };
3351
4071
  }
3352
4072
  async function resolveSessionWorkspace(id, gitFlow, projectPath) {
3353
- const worktreePath = path9.join(worktreesRoot, id);
4073
+ const worktreePath = path11.join(worktreesRoot, id);
3354
4074
  const branch = gitFlow ? `agentrific/${id.slice(0, 8)}` : null;
3355
4075
  const useProjectCheckout = gitFlow?.workspace === "checkout";
3356
4076
  const workspacePath = !gitFlow || useProjectCheckout ? projectPath : worktreePath;
3357
4077
  if (useProjectCheckout) {
3358
4078
  const serverPath = await realpath2(process.cwd());
3359
- if (serverPath === projectPath || serverPath.startsWith(`${projectPath}${path9.sep}`))
4079
+ if (serverPath === projectPath || serverPath.startsWith(`${projectPath}${path11.sep}`))
3360
4080
  return {
3361
4081
  error: "The server runs from this project. Choose a worktree for this task.",
3362
4082
  status: 409
@@ -3380,7 +4100,7 @@ async function createAndStartSession(input) {
3380
4100
  const projectPath = location.projectPath;
3381
4101
  let files;
3382
4102
  try {
3383
- files = await Effect14.runPromise(
4103
+ files = await Effect16.runPromise(
3384
4104
  attachments.resolve(input.projectId, input.attachmentIds ?? [])
3385
4105
  );
3386
4106
  } catch (error) {
@@ -3393,11 +4113,11 @@ async function createAndStartSession(input) {
3393
4113
  if (selection.error !== void 0) return { error: selection.error, status: selection.status };
3394
4114
  const selectedModel = selection.model;
3395
4115
  const now = (/* @__PURE__ */ new Date()).toISOString();
3396
- const task = input.taskId ? await Effect14.runPromise(store.getTask(input.taskId)) : void 0;
4116
+ const task = input.taskId ? await Effect16.runPromise(store.getTask(input.taskId)) : void 0;
3397
4117
  if (input.taskId && sessionTaskUnavailable(task, input.projectId))
3398
4118
  return { error: "Task not found.", status: 404 };
3399
4119
  const session = SessionSchema.parse({
3400
- id: randomUUID7(),
4120
+ id: randomUUID8(),
3401
4121
  taskId: input.taskId ?? null,
3402
4122
  projectId: input.projectId,
3403
4123
  title: task?.title ?? input.prompt.slice(0, 80),
@@ -3422,7 +4142,7 @@ async function createAndStartSession(input) {
3422
4142
  );
3423
4143
  } catch (error) {
3424
4144
  if (task)
3425
- await Effect14.runPromise(
4145
+ await Effect16.runPromise(
3426
4146
  store.saveTask({ ...task, sessionId: session.id, updatedAt: (/* @__PURE__ */ new Date()).toISOString() })
3427
4147
  );
3428
4148
  return {
@@ -3431,7 +4151,7 @@ async function createAndStartSession(input) {
3431
4151
  };
3432
4152
  }
3433
4153
  try {
3434
- await Effect14.runPromise(runtime.send(running, input.prompt, files));
4154
+ await Effect16.runPromise(runtime.send(running, input.prompt, files));
3435
4155
  } catch (error) {
3436
4156
  publish({
3437
4157
  type: "session.updated",
@@ -3440,18 +4160,18 @@ async function createAndStartSession(input) {
3440
4160
  publish({ type: "error", sessionId: session.id, message: String(error) });
3441
4161
  return { error: "Could not start the agent session.", status: 500 };
3442
4162
  }
3443
- return { session: await Effect14.runPromise(store.get(session.id)), status: 201 };
4163
+ return { session: await Effect16.runPromise(store.get(session.id)), status: 201 };
3444
4164
  }
3445
4165
  function startTask(id, instructionsOverride, automated = false) {
3446
4166
  return withTaskLock(
3447
4167
  "agent-dispatch",
3448
4168
  () => withTaskLock(id, async () => {
3449
4169
  maintenance.assertReady();
3450
- const task = await Effect14.runPromise(store.getTask(id));
4170
+ const task = await Effect16.runPromise(store.getTask(id));
3451
4171
  if (!task || task.deletedAt) throw new TaskActionError("Task not found.", 404);
3452
4172
  if (automated) {
3453
- const state = await Effect14.runPromise(store.automation());
3454
- if (activeAgents(await Effect14.runPromise(store.list())) >= state.config.parallelism) return;
4173
+ const state = await Effect16.runPromise(store.automation());
4174
+ if (activeAgents(await Effect16.runPromise(store.list())) >= state.config.parallelism) return;
3455
4175
  const backlog = await boardTasks(store);
3456
4176
  if (!backlog.some((item) => item.id === id && item.status === "backlog")) return;
3457
4177
  }
@@ -3459,7 +4179,7 @@ function startTask(id, instructionsOverride, automated = false) {
3459
4179
  const prompt = instructions ? `${task.title}
3460
4180
 
3461
4181
  ${instructions}` : task.title;
3462
- const linkedSession = task.sessionId ? await Effect14.runPromise(store.get(task.sessionId)) : void 0;
4182
+ const linkedSession = task.sessionId ? await Effect16.runPromise(store.get(task.sessionId)) : void 0;
3463
4183
  if (linkedSession && (["starting", "running", "queued"].includes(linkedSession.status) || linkedSession.gitDelivery?.status === "pending"))
3464
4184
  throw new TaskActionError("This task already has a running agent.", 409);
3465
4185
  const result = await createAndStartSession({
@@ -3473,7 +4193,7 @@ ${instructions}` : task.title;
3473
4193
  });
3474
4194
  if (!result.session)
3475
4195
  throw new TaskActionError(result.error ?? "Could not start task.", result.status);
3476
- const updatedTask = await Effect14.runPromise(
4196
+ const updatedTask = await Effect16.runPromise(
3477
4197
  store.saveTask({
3478
4198
  ...task,
3479
4199
  status: taskStatusForSession(result.session.status, result.session),
@@ -3485,11 +4205,17 @@ ${instructions}` : task.title;
3485
4205
  })
3486
4206
  );
3487
4207
  }
3488
- var host, port, defaultProjectsRoot, worktreesRoot, execFileAsync, app, dataRoot, releaseLock, effects, store, security, allowedOrigins, attachmentEffects, attachments, attachmentCleanup, models, delivery, preparation, agentEffects, runtime, updates, maintenance, stopUpdateDiscovery, HistoryQuery, StartTaskBody, automation, automationTimer, closeControl;
4208
+ async function withSessionTaskLock(id, operation) {
4209
+ const session = await Effect16.runPromise(store.get(id));
4210
+ const lockId = session ? await sessionTaskLockId(store, session) : `session-${id}`;
4211
+ return withTaskLock(lockId, operation);
4212
+ }
4213
+ var host, port, defaultProjectsRoot, worktreesRoot, execFileAsync, app, dataRoot, releaseLock, effects, store, security, testPushUrl, push, allowedOrigins, attachmentEffects, attachments, attachmentCleanup, models, delivery, preparation, agentEffects, runtime, worktreeCleanup, maxWorktreeAgeDays, pruneWorktrees, worktreeCleanupTimer, updates, maintenance, stopUpdateDiscovery, HistoryQuery, StartTaskBody, automation, automationTimer, closeControl;
3489
4214
  var init_index = __esm({
3490
4215
  async "src/index.ts"() {
3491
4216
  "use strict";
3492
4217
  init_warnings();
4218
+ init_push2();
3493
4219
  init_attachments();
3494
4220
  init_event_server();
3495
4221
  init_security();
@@ -3508,11 +4234,12 @@ var init_index = __esm({
3508
4234
  init_projects();
3509
4235
  init_store();
3510
4236
  init_tasks();
4237
+ init_worktree_cleanup();
3511
4238
  host = process.env.AGENTRIFIC_HOST ?? "127.0.0.1";
3512
4239
  port = Number(process.env.AGENTRIFIC_PORT ?? 4318);
3513
- defaultProjectsRoot = path9.resolve(process.env.AGENTRIFIC_PROJECTS_DIR ?? "./projects");
3514
- worktreesRoot = path9.resolve(
3515
- process.env.AGENTRIFIC_WORKTREES_DIR ?? path9.join(homedir4(), ".agentrific", "workspaces")
4240
+ defaultProjectsRoot = path11.resolve(process.env.AGENTRIFIC_PROJECTS_DIR ?? "./projects");
4241
+ worktreesRoot = path11.resolve(
4242
+ process.env.AGENTRIFIC_WORKTREES_DIR ?? path11.join(homedir4(), ".agentrific", "workspaces")
3516
4243
  );
3517
4244
  execFileAsync = promisify3(execFile3);
3518
4245
  if (host !== "127.0.0.1" && host !== "::1")
@@ -3535,13 +4262,18 @@ var init_index = __esm({
3535
4262
  }
3536
4263
  }
3537
4264
  });
3538
- dataRoot = path9.resolve(process.env.AGENTRIFIC_DATA_DIR ?? "./data");
4265
+ dataRoot = path11.resolve(process.env.AGENTRIFIC_DATA_DIR ?? "./data");
3539
4266
  releaseLock = await acquireServerLock(dataRoot);
3540
- effects = ManagedRuntime.make(SessionStore.layer(path9.join(dataRoot, "sessions.sqlite")));
4267
+ effects = ManagedRuntime.make(SessionStore.layer(path11.join(dataRoot, "sessions.sqlite")));
3541
4268
  store = await effects.runPromise(SessionStore);
3542
- security = new ServerSecurity(store, await Effect14.runPromise(store.serverIdentity()));
4269
+ security = new ServerSecurity(store, await Effect16.runPromise(store.serverIdentity()));
4270
+ testPushUrl = process.env.AGENTRIFIC_PUSH_TEST_URL;
4271
+ if (testPushUrl && (process.env.AGENTRIFIC_TEST_MODE !== "1" || !new URL(testPushUrl).hostname.endsWith(".ts.net") || new URL(testPushUrl).protocol !== "https:"))
4272
+ throw new Error("Test relay requires explicit test mode and Tailscale HTTPS.");
4273
+ push = new ServerPush(store, security.serverId, dataRoot, fetch, testPushUrl);
3543
4274
  allowedOrigins = [
3544
4275
  .../* @__PURE__ */ new Set([
4276
+ "agentrific-desktop://app",
3545
4277
  ...(process.env.AGENTRIFIC_ALLOWED_ORIGINS ?? "http://localhost:8081,http://127.0.0.1:8081").split(","),
3546
4278
  `http://${host === "::1" ? "[::1]" : host}:${port}`,
3547
4279
  ...process.env.AGENTRIFIC_PUBLIC_URL ? [new URL(process.env.AGENTRIFIC_PUBLIC_URL).origin] : []
@@ -3552,14 +4284,15 @@ var init_index = __esm({
3552
4284
  methods: ["GET", "POST", "PATCH", "DELETE"]
3553
4285
  });
3554
4286
  await registerSecurity(app, security, serverVersion, allowedOrigins);
4287
+ registerPushRoutes(app, push, security);
3555
4288
  attachmentEffects = ManagedRuntime.make(
3556
- Attachments.layer(path9.join(dataRoot, "attachments"), store)
4289
+ Attachments.layer(path11.join(dataRoot, "attachments"), store)
3557
4290
  );
3558
4291
  attachments = await attachmentEffects.runPromise(Attachments);
3559
- await Effect14.runPromise(attachments.cleanup());
4292
+ await Effect16.runPromise(attachments.cleanup());
3560
4293
  attachmentCleanup = setInterval(
3561
4294
  () => {
3562
- void Effect14.runPromise(attachments.cleanup()).catch((error) => app.log.error(error));
4295
+ void Effect16.runPromise(attachments.cleanup()).catch((error) => app.log.error(error));
3563
4296
  },
3564
4297
  60 * 60 * 1e3
3565
4298
  );
@@ -3570,14 +4303,14 @@ var init_index = __esm({
3570
4303
  app.post(
3571
4304
  "/api/projects/:projectId/attachments",
3572
4305
  async (request, reply) => {
3573
- const root = await Effect14.runPromise(store.projectsRoot(defaultProjectsRoot));
3574
- const projects = await Effect14.runPromise(discoverProjects(root, store));
4306
+ const root = await Effect16.runPromise(store.projectsRoot(defaultProjectsRoot));
4307
+ const projects = await Effect16.runPromise(discoverProjects(root, store));
3575
4308
  if (!projects.some((project) => project.id === request.params.projectId && project.available))
3576
4309
  return reply.code(404).send({ error: "Project not found." });
3577
4310
  try {
3578
4311
  const file = await request.file();
3579
4312
  if (!file) return reply.code(400).send({ error: "Choose a file to upload." });
3580
- return reply.code(201).send(await Effect14.runPromise(attachments.upload(request.params.projectId, file)));
4313
+ return reply.code(201).send(await Effect16.runPromise(attachments.upload(request.params.projectId, file)));
3581
4314
  } catch (error) {
3582
4315
  return reply.code(400).send({ error: error instanceof Error ? error.message : "Upload failed." });
3583
4316
  }
@@ -3586,7 +4319,7 @@ var init_index = __esm({
3586
4319
  app.get(
3587
4320
  "/api/projects/:projectId/attachments/:id/content",
3588
4321
  async (request, reply) => {
3589
- const attachment = await Effect14.runPromise(store.getAttachment(request.params.id));
4322
+ const attachment = await Effect16.runPromise(store.getAttachment(request.params.id));
3590
4323
  if (!attachment || attachment.projectId !== request.params.projectId)
3591
4324
  return reply.code(404).send({ error: "Attachment not found." });
3592
4325
  const preview = request.query.preview === "1" && attachment.kind === "image";
@@ -3604,8 +4337,8 @@ var init_index = __esm({
3604
4337
  publish: (event) => publish(event, true),
3605
4338
  draft: deliveryWriter(models),
3606
4339
  projectPath: async (session) => {
3607
- const projectsRoot = await Effect14.runPromise(store.projectsRoot(defaultProjectsRoot));
3608
- const project = (await Effect14.runPromise(discoverProjects(projectsRoot, store))).find(
4340
+ const projectsRoot = await Effect16.runPromise(store.projectsRoot(defaultProjectsRoot));
4341
+ const project = (await Effect16.runPromise(discoverProjects(projectsRoot, store))).find(
3609
4342
  (item) => item.id === session.projectId && item.available
3610
4343
  );
3611
4344
  if (!project) throw new Error("Reconnect the project folder before delivering changes.");
@@ -3618,12 +4351,27 @@ var init_index = __esm({
3618
4351
  createPiFactory(attachments, models, delivery),
3619
4352
  publish,
3620
4353
  worktreesRoot,
3621
- path9.join(dataRoot, "pi"),
3622
- (sessionId, messageId) => Effect14.runSync(store.hasMessage(sessionId, messageId)),
4354
+ path11.join(dataRoot, "pi"),
4355
+ (sessionId, messageId) => Effect16.runSync(store.hasMessage(sessionId, messageId)),
3623
4356
  (session) => delivery.settled(session.id)
3624
4357
  )
3625
4358
  );
3626
4359
  runtime = await agentEffects.runPromise(AgentSessions);
4360
+ worktreeCleanup = new WorktreeCleanup(store, worktreesRoot, defaultProjectsRoot);
4361
+ maxWorktreeAgeDays = Number(process.env.AGENTRIFIC_DONE_WORKTREE_MAX_AGE_DAYS ?? 0);
4362
+ if (!Number.isInteger(maxWorktreeAgeDays) || maxWorktreeAgeDays < 0)
4363
+ throw new Error("AGENTRIFIC_DONE_WORKTREE_MAX_AGE_DAYS must be a nonnegative integer.");
4364
+ pruneWorktrees = () => withTaskLock("worktree-pruning", async () => {
4365
+ await worktreeCleanup.prune(maxWorktreeAgeDays);
4366
+ });
4367
+ worktreeCleanupTimer = setInterval(
4368
+ () => {
4369
+ void pruneWorktrees().catch((error) => app.log.error(error));
4370
+ },
4371
+ 60 * 60 * 1e3
4372
+ );
4373
+ worktreeCleanupTimer.unref();
4374
+ void pruneWorktrees().catch((error) => app.log.error(error));
3627
4375
  updates = new UpdateDiscovery(store);
3628
4376
  maintenance = new Maintenance(() => [
3629
4377
  ...runtime.workloads(),
@@ -3639,7 +4387,7 @@ var init_index = __esm({
3639
4387
  () => maintenance.ready(),
3640
4388
  (error) => app.log.error(error)
3641
4389
  );
3642
- for (const session of await Effect14.runPromise(store.list())) {
4390
+ for (const session of await Effect16.runPromise(store.list())) {
3643
4391
  if (session.gitDelivery?.status === "pending") {
3644
4392
  publish(
3645
4393
  {
@@ -3669,7 +4417,7 @@ var init_index = __esm({
3669
4417
  await delivery.recover();
3670
4418
  app.get("/api/connections", async () => githubCliStatus());
3671
4419
  app.get("/api/settings", async () => ({
3672
- projectsRoot: await Effect14.runPromise(store.projectsRoot(defaultProjectsRoot)),
4420
+ projectsRoot: await Effect16.runPromise(store.projectsRoot(defaultProjectsRoot)),
3673
4421
  defaultModel: await models.defaultIdentity()
3674
4422
  }));
3675
4423
  app.get("/api/models", async (_request, reply) => {
@@ -3697,7 +4445,7 @@ var init_index = __esm({
3697
4445
  try {
3698
4446
  const defaultModel = await models.setDefault(parsed.data);
3699
4447
  return {
3700
- projectsRoot: await Effect14.runPromise(store.projectsRoot(defaultProjectsRoot)),
4448
+ projectsRoot: await Effect16.runPromise(store.projectsRoot(defaultProjectsRoot)),
3701
4449
  defaultModel
3702
4450
  };
3703
4451
  } catch {
@@ -3706,54 +4454,57 @@ var init_index = __esm({
3706
4454
  });
3707
4455
  }
3708
4456
  });
3709
- app.post("/api/sessions/:id/model", async (request, reply) => {
3710
- const parsed = ModelReferenceSchema.safeParse(request.body);
3711
- const session = await Effect14.runPromise(store.get(request.params.id));
3712
- if (!session) return reply.code(404).send({ error: "Session not found." });
3713
- if (!parsed.success)
3714
- return reply.code(400).send({ error: "Choose a model from the server catalogue." });
3715
- try {
3716
- await models.resolve(parsed.data);
3717
- return await Effect14.runPromise(runtime.setModel(session, parsed.data));
3718
- } catch {
3719
- return reply.code(409).send({
3720
- error: "Could not change the model. Wait for the current reply to finish and choose an available model."
3721
- });
3722
- }
3723
- });
4457
+ app.post(
4458
+ "/api/sessions/:id/model",
4459
+ async (request, reply) => withSessionTaskLock(request.params.id, async () => {
4460
+ const parsed = ModelReferenceSchema.safeParse(request.body);
4461
+ const session = await Effect16.runPromise(store.get(request.params.id));
4462
+ if (!session) return reply.code(404).send({ error: "Session not found." });
4463
+ if (!parsed.success)
4464
+ return reply.code(400).send({ error: "Choose a model from the server catalogue." });
4465
+ try {
4466
+ await models.resolve(parsed.data);
4467
+ return await Effect16.runPromise(runtime.setModel(session, parsed.data));
4468
+ } catch {
4469
+ return reply.code(409).send({
4470
+ error: "Could not change the model. Wait for the current reply to finish and choose an available model."
4471
+ });
4472
+ }
4473
+ })
4474
+ );
3724
4475
  app.post("/api/settings", async (request, reply) => {
3725
4476
  const parsed = UpdateSettingsSchema.safeParse(request.body);
3726
4477
  if (!parsed.success) return reply.code(400).send({ error: "Enter a projects folder." });
3727
- const result = await Effect14.runPromise(
3728
- resolveProjectsRoot(parsed.data.projectsRoot).pipe(Effect14.result)
4478
+ const result = await Effect16.runPromise(
4479
+ resolveProjectsRoot(parsed.data.projectsRoot).pipe(Effect16.result)
3729
4480
  );
3730
4481
  if (result._tag === "Failure") return reply.code(400).send({ error: result.failure.message });
3731
- return Effect14.runPromise(store.setProjectsRoot(result.success));
4482
+ return Effect16.runPromise(store.setProjectsRoot(result.success));
3732
4483
  });
3733
4484
  app.get("/api/projects", async (_request, reply) => {
3734
- const root = await Effect14.runPromise(store.projectsRoot(defaultProjectsRoot));
3735
- const result = await Effect14.runPromise(discoverProjects(root, store).pipe(Effect14.result));
4485
+ const root = await Effect16.runPromise(store.projectsRoot(defaultProjectsRoot));
4486
+ const result = await Effect16.runPromise(discoverProjects(root, store).pipe(Effect16.result));
3736
4487
  if (result._tag === "Failure") return reply.code(400).send({ error: result.failure.message });
3737
4488
  return result.success;
3738
4489
  });
3739
4490
  app.get("/api/projects/:id", async (request, reply) => {
3740
- const root = await Effect14.runPromise(store.projectsRoot(defaultProjectsRoot));
3741
- const result = await Effect14.runPromise(discoverProjects(root, store).pipe(Effect14.result));
4491
+ const root = await Effect16.runPromise(store.projectsRoot(defaultProjectsRoot));
4492
+ const result = await Effect16.runPromise(discoverProjects(root, store).pipe(Effect16.result));
3742
4493
  if (result._tag === "Failure") return reply.code(400).send({ error: result.failure.message });
3743
4494
  const project = result.success.find((item) => item.id === request.params.id);
3744
4495
  return project ?? reply.code(404).send({ error: "Project not found on this server." });
3745
4496
  });
3746
4497
  app.post("/api/projects/:id/reconnect", async (request, reply) => {
3747
- const parsed = z15.object({ path: z15.string().trim().min(1).max(4096) }).safeParse(request.body);
4498
+ const parsed = z17.object({ path: z17.string().trim().min(1).max(4096) }).safeParse(request.body);
3748
4499
  if (!parsed.success)
3749
4500
  return reply.code(400).send({ error: "Enter the project's new folder path." });
3750
- const root = await Effect14.runPromise(store.projectsRoot(defaultProjectsRoot));
3751
- const result = await Effect14.runPromise(
3752
- reconnectProject(root, request.params.id, parsed.data.path, store).pipe(Effect14.result)
4501
+ const root = await Effect16.runPromise(store.projectsRoot(defaultProjectsRoot));
4502
+ const result = await Effect16.runPromise(
4503
+ reconnectProject(root, request.params.id, parsed.data.path, store).pipe(Effect16.result)
3753
4504
  );
3754
4505
  return result._tag === "Failure" ? reply.code(400).send({ error: result.failure.message }) : result.success;
3755
4506
  });
3756
- app.get("/api/sessions", () => Effect14.runPromise(store.list()));
4507
+ app.get("/api/sessions", () => Effect16.runPromise(store.list()));
3757
4508
  app.get(
3758
4509
  "/api/tasks",
3759
4510
  (request) => boardTasks(store, request.query.projectId)
@@ -3766,15 +4517,15 @@ var init_index = __esm({
3766
4517
  if (!github.githubCli.authenticated)
3767
4518
  return reply.code(400).send({ error: "GitHub CLI must be authenticated on the server to open a PR." });
3768
4519
  }
3769
- const projectsRoot = await Effect14.runPromise(store.projectsRoot(defaultProjectsRoot));
3770
- const projects = await Effect14.runPromise(
3771
- discoverProjects(projectsRoot, store).pipe(Effect14.result)
4520
+ const projectsRoot = await Effect16.runPromise(store.projectsRoot(defaultProjectsRoot));
4521
+ const projects = await Effect16.runPromise(
4522
+ discoverProjects(projectsRoot, store).pipe(Effect16.result)
3772
4523
  );
3773
4524
  if (projects._tag === "Failure" || !projects.success.some((project) => project.id === parsed.data.projectId && project.available))
3774
4525
  return reply.code(404).send({ error: "Project not found in the server's projects folder." });
3775
4526
  let files;
3776
4527
  try {
3777
- files = await Effect14.runPromise(
4528
+ files = await Effect16.runPromise(
3778
4529
  attachments.resolve(parsed.data.projectId, parsed.data.attachmentIds ?? [])
3779
4530
  );
3780
4531
  } catch (error) {
@@ -3782,7 +4533,7 @@ var init_index = __esm({
3782
4533
  }
3783
4534
  const now = (/* @__PURE__ */ new Date()).toISOString();
3784
4535
  const task = TaskSchema.parse({
3785
- id: randomUUID7(),
4536
+ id: randomUUID8(),
3786
4537
  ...parsed.data,
3787
4538
  attachments: files,
3788
4539
  status: "backlog",
@@ -3790,7 +4541,7 @@ var init_index = __esm({
3790
4541
  createdAt: now,
3791
4542
  updatedAt: now
3792
4543
  });
3793
- return reply.code(201).send(await Effect14.runPromise(store.saveTask(task)));
4544
+ return reply.code(201).send(await Effect16.runPromise(store.saveTask(task)));
3794
4545
  });
3795
4546
  app.patch("/api/tasks/:id", async (request, reply) => {
3796
4547
  const parsed = UpdateTaskSchema.safeParse(request.body);
@@ -3805,8 +4556,8 @@ var init_index = __esm({
3805
4556
  return reply.code(400).send({ error: "GitHub CLI must be authenticated on the server to open a PR." });
3806
4557
  }
3807
4558
  return await editTask(store, request.params.id, parsed.data, async (projectId, ids) => {
3808
- const result = await Effect14.runPromise(
3809
- attachments.resolve(projectId, ids).pipe(Effect14.result)
4559
+ const result = await Effect16.runPromise(
4560
+ attachments.resolve(projectId, ids).pipe(Effect16.result)
3810
4561
  );
3811
4562
  if (result._tag === "Failure") throw new TaskActionError(result.failure.message, 400);
3812
4563
  return result.success;
@@ -3819,7 +4570,12 @@ var init_index = __esm({
3819
4570
  });
3820
4571
  app.delete("/api/tasks/:id", async (request, reply) => {
3821
4572
  try {
3822
- await deleteTask(store, request.params.id);
4573
+ await deleteTask(store, request.params.id, async (session) => {
4574
+ await Effect16.runPromise(runtime.forget(session.id));
4575
+ await worktreeCleanup.remove(session);
4576
+ await removeRunFiles(dataRoot, session.id);
4577
+ });
4578
+ await Effect16.runPromise(attachments.cleanup());
3823
4579
  return reply.code(204).send();
3824
4580
  } catch (error) {
3825
4581
  if (error instanceof TaskActionError)
@@ -3828,31 +4584,34 @@ var init_index = __esm({
3828
4584
  }
3829
4585
  });
3830
4586
  app.get("/api/sessions/:id", async (request, reply) => {
3831
- const session = await Effect14.runPromise(store.get(request.params.id));
4587
+ const session = await Effect16.runPromise(store.get(request.params.id));
3832
4588
  if (!session) return reply.code(404).send({ error: "Session not found." });
3833
4589
  return session;
3834
4590
  });
3835
- app.post("/api/sessions/:id/delivery", async (request, reply) => {
3836
- const session = await Effect14.runPromise(store.get(request.params.id));
3837
- if (!session) return reply.code(404).send({ error: "Session not found." });
3838
- try {
3839
- const result = await delivery.retry(session.id);
3840
- return reply.code(202).send(result);
3841
- } catch (error) {
3842
- return reply.code(409).send({ error: error instanceof Error ? error.message : "Delivery could not be retried." });
3843
- }
3844
- });
4591
+ app.post(
4592
+ "/api/sessions/:id/delivery",
4593
+ async (request, reply) => withSessionTaskLock(request.params.id, async () => {
4594
+ const session = await Effect16.runPromise(store.get(request.params.id));
4595
+ if (!session) return reply.code(404).send({ error: "Session not found." });
4596
+ try {
4597
+ const result = await delivery.retry(session.id);
4598
+ return reply.code(202).send(result);
4599
+ } catch (error) {
4600
+ return reply.code(409).send({ error: error instanceof Error ? error.message : "Delivery could not be retried." });
4601
+ }
4602
+ })
4603
+ );
3845
4604
  app.post(
3846
4605
  "/api/sessions/:id/preparation/retry",
3847
- async (request, reply) => {
4606
+ async (request, reply) => withSessionTaskLock(request.params.id, async () => {
3848
4607
  let preparedSession;
3849
4608
  try {
3850
4609
  const prepared = await preparation.retry(request.params.id);
3851
4610
  preparedSession = prepared.session;
3852
- const files = await Effect14.runPromise(
4611
+ const files = await Effect16.runPromise(
3853
4612
  attachments.resolve(prepared.session.projectId, prepared.input.attachmentIds ?? [])
3854
4613
  );
3855
- await Effect14.runPromise(runtime.send(prepared.session, prepared.input.prompt, files));
4614
+ await Effect16.runPromise(runtime.send(prepared.session, prepared.input.prompt, files));
3856
4615
  return reply.code(202).send(prepared.session);
3857
4616
  } catch (error) {
3858
4617
  if (preparedSession) {
@@ -3866,18 +4625,18 @@ var init_index = __esm({
3866
4625
  error: error instanceof Error ? error.message : "Preparation could not be retried."
3867
4626
  });
3868
4627
  }
3869
- }
4628
+ })
3870
4629
  );
3871
- HistoryQuery = z15.object({
3872
- after: z15.coerce.number().int().nonnegative().default(0),
3873
- limit: z15.coerce.number().int().min(1).max(1e3).default(500)
4630
+ HistoryQuery = z17.object({
4631
+ after: z17.coerce.number().int().nonnegative().default(0),
4632
+ limit: z17.coerce.number().int().min(1).max(1e3).default(500)
3874
4633
  });
3875
4634
  app.get("/api/sessions/:id/events", async (request, reply) => {
3876
4635
  const query = HistoryQuery.safeParse(request.query);
3877
4636
  if (!query.success) return reply.code(400).send({ error: "Invalid history cursor or limit." });
3878
- const session = await Effect14.runPromise(store.get(request.params.id));
4637
+ const session = await Effect16.runPromise(store.get(request.params.id));
3879
4638
  if (!session) return reply.code(404).send({ error: "Session not found." });
3880
- return Effect14.runPromise(store.history(session.id, query.data.after, query.data.limit));
4639
+ return Effect16.runPromise(store.history(session.id, query.data.after, query.data.limit));
3881
4640
  });
3882
4641
  app.post("/api/sessions", async (request, reply) => {
3883
4642
  const parsed = CreateSessionSchema.safeParse(request.body);
@@ -3889,7 +4648,7 @@ var init_index = __esm({
3889
4648
  if (!result.session) return reply.code(result.status).send({ error: result.error });
3890
4649
  return reply.code(201).send(result.session);
3891
4650
  });
3892
- StartTaskBody = z15.object({ prompt: z15.string().trim().max(2e4).optional() }).default({});
4651
+ StartTaskBody = z17.object({ prompt: z17.string().trim().max(2e4).optional() }).default({});
3893
4652
  app.post("/api/tasks/:id/start", async (request, reply) => {
3894
4653
  const body = StartTaskBody.safeParse(request.body ?? {});
3895
4654
  if (!body.success) return reply.code(400).send({ error: "The task prompt is too long." });
@@ -3913,8 +4672,8 @@ var init_index = __esm({
3913
4672
  const parsed = AutomationConfigSchema.safeParse(request.body);
3914
4673
  if (!parsed.success)
3915
4674
  return reply.code(400).send({ error: parsed.error.issues.map((issue) => issue.message).join(" ") });
3916
- const root = await Effect14.runPromise(store.projectsRoot(defaultProjectsRoot));
3917
- const projects = await Effect14.runPromise(discoverProjects(root, store));
4675
+ const root = await Effect16.runPromise(store.projectsRoot(defaultProjectsRoot));
4676
+ const projects = await Effect16.runPromise(discoverProjects(root, store));
3918
4677
  if (parsed.data.projectIds.some(
3919
4678
  (id) => !projects.some((project) => project.id === id && project.available)
3920
4679
  ))
@@ -3924,7 +4683,7 @@ var init_index = __esm({
3924
4683
  return automation.configure(parsed.data);
3925
4684
  });
3926
4685
  app.patch("/api/automation/queue", async (request, reply) => {
3927
- const parsed = z15.object({ taskId: z15.string().min(1), beforeId: z15.string().min(1).nullable() }).safeParse(request.body);
4686
+ const parsed = z17.object({ taskId: z17.string().min(1), beforeId: z17.string().min(1).nullable() }).safeParse(request.body);
3928
4687
  if (!parsed.success)
3929
4688
  return reply.code(400).send({ error: "Choose a task and a queue position." });
3930
4689
  try {
@@ -3941,8 +4700,8 @@ var init_index = __esm({
3941
4700
  automationTimer.unref();
3942
4701
  app.post(
3943
4702
  "/api/sessions/:id/messages",
3944
- async (request, reply) => {
3945
- const session = await Effect14.runPromise(store.get(request.params.id));
4703
+ async (request, reply) => withSessionTaskLock(request.params.id, async () => {
4704
+ const session = await Effect16.runPromise(store.get(request.params.id));
3946
4705
  if (!session) return reply.code(404).send({ error: "Session not found." });
3947
4706
  const parsed = SendMessageSchema.safeParse(request.body);
3948
4707
  if (!parsed.success) return reply.code(400).send({ error: parsed.error.flatten() });
@@ -3950,27 +4709,30 @@ var init_index = __esm({
3950
4709
  await delivery.assertCanWork(session.id);
3951
4710
  if (session.preparation && session.preparation.status !== "ready")
3952
4711
  throw new Error("Retry workspace preparation before sending a message.");
3953
- const files = await Effect14.runPromise(
4712
+ const files = await Effect16.runPromise(
3954
4713
  attachments.resolve(session.projectId, parsed.data.attachmentIds ?? [])
3955
4714
  );
3956
- await Effect14.runPromise(
4715
+ await Effect16.runPromise(
3957
4716
  runtime.send(session, parsed.data.message, files, parsed.data.clientMessageId)
3958
4717
  );
3959
4718
  return reply.code(202).send({ accepted: true });
3960
4719
  } catch (error) {
3961
4720
  return reply.code(409).send({ error: String(error) });
3962
4721
  }
3963
- }
4722
+ })
4723
+ );
4724
+ app.post(
4725
+ "/api/sessions/:id/stop",
4726
+ async (request, reply) => withSessionTaskLock(request.params.id, async () => {
4727
+ const session = await Effect16.runPromise(store.get(request.params.id));
4728
+ if (!session) return reply.code(404).send({ error: "Session not found." });
4729
+ await Effect16.runPromise(runtime.stop(session));
4730
+ return reply.code(202).send({ accepted: true });
4731
+ })
3964
4732
  );
3965
- app.post("/api/sessions/:id/stop", async (request, reply) => {
3966
- const session = await Effect14.runPromise(store.get(request.params.id));
3967
- if (!session) return reply.code(404).send({ error: "Session not found." });
3968
- await Effect14.runPromise(runtime.stop(session));
3969
- return reply.code(202).send({ accepted: true });
3970
- });
3971
4733
  attachEventServer(app, security, allowedOrigins, () => ({
3972
4734
  type: "snapshot",
3973
- sessions: Effect14.runSync(store.list())
4735
+ sessions: Effect16.runSync(store.list())
3974
4736
  }));
3975
4737
  closeControl = await startControl(
3976
4738
  dataRoot,
@@ -3982,20 +4744,25 @@ var init_index = __esm({
3982
4744
  connectedDevices: new Set(security.sockets.values()).size,
3983
4745
  connectedSockets: security.sockets.size,
3984
4746
  memory: process.memoryUsage().rss,
3985
- activeRuns: activeAgents(Effect14.runSync(store.list())),
4747
+ activeRuns: activeAgents(Effect16.runSync(store.list())),
3986
4748
  maintenance: maintenance.status()
3987
4749
  }),
3988
4750
  maintenance
3989
4751
  );
4752
+ push.start();
3990
4753
  app.addHook("onClose", async () => {
4754
+ await push.close();
3991
4755
  maintenance.enter();
3992
4756
  stopUpdateDiscovery();
3993
4757
  await updates.settle();
3994
4758
  clearInterval(attachmentCleanup);
4759
+ clearInterval(worktreeCleanupTimer);
4760
+ await withTaskLock("worktree-pruning", async () => {
4761
+ });
3995
4762
  clearInterval(automationTimer);
3996
4763
  await withTaskLock("automation", async () => {
3997
4764
  });
3998
- await Effect14.runPromise(runtime.close());
4765
+ await Effect16.runPromise(runtime.close());
3999
4766
  await agentEffects.dispose();
4000
4767
  await attachmentEffects.dispose();
4001
4768
  await effects.dispose();
@@ -4018,7 +4785,7 @@ var init_index = __esm({
4018
4785
  init_warnings();
4019
4786
  import { mkdir as mkdir5, readFile as readFile5, writeFile as writeFile4 } from "node:fs/promises";
4020
4787
  import { homedir as homedir5 } from "node:os";
4021
- import path10 from "node:path";
4788
+ import path12 from "node:path";
4022
4789
  import { fileURLToPath } from "node:url";
4023
4790
 
4024
4791
  // src/service.ts
@@ -4114,204 +4881,21 @@ async function installService(platform, directory, filename, domain, dataDir, cl
4114
4881
  init_updates();
4115
4882
  init_control();
4116
4883
  init_version();
4884
+ init_pairing();
4117
4885
  import { spawn as spawn2 } from "node:child_process";
4118
4886
  import { parseArgs } from "node:util";
4119
- import { z as z16 } from "zod";
4120
-
4121
- // src/pairing.ts
4122
- init_src();
4123
- init_control();
4124
- import { z as z4 } from "zod";
4125
- import { styleText as styleText2 } from "node:util";
4126
- import { emitKeypressEvents } from "node:readline";
4127
- import qrcode from "qrcode-terminal";
4128
-
4129
- // src/cli-output.ts
4130
- init_version();
4131
- import { styleText } from "node:util";
4132
- function printHeading(title) {
4133
- console.log();
4134
- console.log(styleText("bold", " ####### ##"));
4135
- console.log(styleText("bold", " ### #####"));
4136
- console.log(` ${styleText("bold", "## ####")} ${styleText("bold", "AGENTRIFIC")}`);
4137
- console.log(
4138
- ` ${styleText("bold", "## ")}${styleText(["bold", "yellow"], "o")}${styleText("bold", " ####")} ${styleText("dim", `${title} \xB7 v${serverVersion}`)}`
4139
- );
4140
- console.log(styleText("bold", " ## ####"));
4141
- console.log(styleText("bold", " ### #####"));
4142
- console.log(styleText("bold", " ####### ##"));
4143
- console.log();
4144
- }
4145
- function printDetail(label2, value) {
4146
- console.log(` ${styleText("dim", "\u203A")} ${styleText("dim", label2.padEnd(10))} ${value}`);
4147
- }
4148
- function printShortcuts(pairingAvailable = false) {
4149
- console.log();
4150
- console.log(` ${styleText("dim", "\u203A")} Press ${styleText("bold", "p")} \u2502 pair a device`);
4151
- if (pairingAvailable)
4152
- console.log(
4153
- ` ${styleText("dim", "\u203A")} Press ${styleText("bold", "j")} \u2502 show pairing JSON`
4154
- );
4155
- console.log(` ${styleText("dim", "\u203A")} Press ${styleText("bold", "?")} \u2502 show shortcuts`);
4156
- console.log(
4157
- ` ${styleText("dim", "\u203A")} Press ${styleText("bold", "Ctrl+C")} \u2502 stop the server
4158
- `
4159
- );
4160
- }
4161
- function printError(message) {
4162
- console.error(`
4163
- ${styleText(["bold", "red"], "Error")} ${message}
4164
- `);
4165
- }
4166
- function printHelp() {
4167
- printHeading("server");
4168
- console.log(` ${styleText("bold", "Usage")} agentrific <command> [options]
4169
- `);
4170
- for (const [command, description] of [
4171
- ["init", "Configure the server"],
4172
- ["start", "Start the server \xB7 press p to pair"],
4173
- ["pair", "Print a fresh pairing QR code and PIN"],
4174
- ["devices", "List enrolled devices"],
4175
- ["revoke <id>", "Revoke a device's access"],
4176
- ["status", "Show server status"],
4177
- ["maintenance enter/resume/drain", "Pause new work, resume, or wait for work to finish"],
4178
- ["service install/uninstall/restart", "Manage the background service"],
4179
- ["logs", "Follow server logs"],
4180
- ["update --check", "Check for a newer release"]
4181
- ])
4182
- console.log(` ${styleText("cyan", command.padEnd(34))}${description}`);
4183
- console.log(`
4184
- ${styleText("bold", "Options")}
4185
- `);
4186
- for (const [option, description] of [
4187
- ["--data-dir <path>", "Use a specific server data directory"],
4188
- ["--public-url <https-url>", "Use your tunnel URL without prompting"],
4189
- ["--no-interactive", "Disable prompts and keyboard shortcuts"],
4190
- ["--show-payload", "Print manual JSON with the pairing QR code"],
4191
- ["--timeout-ms <0-60000>", "Set the maintenance drain timeout"],
4192
- ["--version", "Print the server version"],
4193
- ["--help", "Show this help"]
4194
- ])
4195
- console.log(` ${styleText("cyan", option.padEnd(34))}${description}`);
4196
- console.log("\n Connect using Tailscale Serve, cloudflared, or another HTTPS tunnel.\n");
4197
- }
4198
-
4199
- // src/pairing.ts
4200
- function printManualPairing(invitation) {
4201
- if (Date.now() >= invitation.expiresAt) {
4202
- printError("This invitation expired. Generate a fresh pairing QR code.");
4203
- return;
4204
- }
4205
- console.log(styleText2("dim", "\n Manual entry \xB7 paste this JSON into Pairing payload:\n"));
4206
- console.log(` ${invitation.payload}
4207
- `);
4208
- }
4209
- function waitForManualPairing(invitation) {
4210
- console.log(styleText2("dim", " Press j to show manual entry \xB7 Enter or q to finish.\n"));
4211
- return new Promise((resolve) => {
4212
- const input = process.stdin;
4213
- const previousRaw = input.isRaw;
4214
- const timeout = setTimeout(
4215
- () => {
4216
- console.log(
4217
- styleText2("dim", "\n Pairing invitation expired. Run agentrific pair again.\n")
4218
- );
4219
- close();
4220
- },
4221
- Math.max(0, invitation.expiresAt - Date.now())
4222
- );
4223
- function close() {
4224
- clearTimeout(timeout);
4225
- input.removeListener("keypress", pressed);
4226
- input.removeListener("end", close);
4227
- process.removeListener("SIGINT", interrupted);
4228
- process.removeListener("SIGTERM", terminated);
4229
- input.setRawMode(previousRaw);
4230
- input.pause();
4231
- resolve();
4232
- }
4233
- function interrupted() {
4234
- process.exitCode = 130;
4235
- close();
4236
- }
4237
- function terminated() {
4238
- process.exitCode = 143;
4239
- close();
4240
- }
4241
- function pressed(text, key) {
4242
- if (key.ctrl && key.name === "c") {
4243
- interrupted();
4244
- return;
4245
- }
4246
- if (key.ctrl || key.meta) return;
4247
- if (text?.toLowerCase() === "j") {
4248
- printManualPairing(invitation);
4249
- close();
4250
- } else if (text?.toLowerCase() === "q" || key.name === "return" || key.name === "escape") {
4251
- close();
4252
- }
4253
- }
4254
- emitKeypressEvents(input);
4255
- input.setRawMode(true);
4256
- input.on("keypress", pressed);
4257
- input.once("end", close);
4258
- process.once("SIGINT", interrupted);
4259
- process.once("SIGTERM", terminated);
4260
- input.resume();
4261
- });
4262
- }
4263
- function pairingOrigin(url) {
4264
- const endpoint = new URL(url);
4265
- if (endpoint.username || endpoint.password || endpoint.search || endpoint.hash || endpoint.pathname !== "/")
4266
- throw new Error("Use the HTTPS server origin without a path or credentials.");
4267
- if (endpoint.protocol !== "https:" && !(process.env.NODE_ENV !== "production" && endpoint.protocol === "http:" && ["127.0.0.1", "localhost", "[::1]"].includes(endpoint.hostname)))
4268
- throw new Error("Use HTTPS. Localhost HTTP is for development builds only.");
4269
- return endpoint.origin;
4270
- }
4271
- async function printPairing(dataDir, url) {
4272
- if (!url)
4273
- throw new Error(
4274
- "Specify --public-url with your Tailscale Serve or Cloudflare Tunnel HTTPS address."
4275
- );
4276
- const origin = pairingOrigin(url);
4277
- const grant = z4.object({
4278
- pairingId: z4.string(),
4279
- pin: z4.string(),
4280
- expiresAt: z4.number(),
4281
- serverId: z4.string()
4282
- }).parse(await controlRequest(dataDir, { command: "pair" }));
4283
- const invitation = {
4284
- v: 1,
4285
- url: origin,
4286
- serverId: grant.serverId,
4287
- pairingId: grant.pairingId
4288
- };
4289
- const payload = JSON.stringify(invitation);
4290
- const link = createPairingLink(invitation);
4291
- printHeading("pairing");
4292
- console.log(" 1. Scan below with your phone camera and tap Open Agentrific if offered.");
4293
- console.log(" 2. Or open Agentrific and choose Scan pairing QR code.");
4294
- console.log(" 3. Enter the PIN, then confirm pairing.\n");
4295
- qrcode.generate(link, { small: true }, (code) => {
4296
- console.log(
4297
- code.split("\n").map((line) => ` ${line}`).join("\n")
4298
- );
4299
- });
4300
- console.log();
4301
- printDetail("PIN:", styleText2(["bold", "yellow"], grant.pin));
4302
- printDetail("Server", styleText2("cyan", origin));
4303
- printDetail("Expires", "In five minutes \xB7 one device per code");
4304
- console.log("\n Pairing grants this device access to run agents as your host user.\n");
4305
- return { payload, expiresAt: grant.expiresAt };
4306
- }
4887
+ import { z as z18 } from "zod";
4307
4888
 
4308
4889
  // src/terminal.ts
4890
+ init_pairing();
4891
+ init_cli_output();
4309
4892
  import { createInterface } from "node:readline/promises";
4310
4893
  import select from "@inquirer/select";
4311
4894
  import { emitKeypressEvents as emitKeypressEvents2 } from "node:readline";
4312
4895
  import { styleText as styleText3 } from "node:util";
4313
4896
 
4314
4897
  // src/tunnels.ts
4898
+ init_cli_output();
4315
4899
  import { spawn } from "node:child_process";
4316
4900
  import { access, stat } from "node:fs/promises";
4317
4901
  import { constants } from "node:fs";
@@ -4780,10 +5364,11 @@ function startTerminal(dataDir, publicUrl, tunnel) {
4780
5364
  }
4781
5365
 
4782
5366
  // src/cli.ts
4783
- var ConfigSchema = z16.object({
4784
- dataDir: z16.string(),
4785
- projectsDir: z16.string(),
4786
- publicUrl: z16.string().url().optional()
5367
+ init_cli_output();
5368
+ var ConfigSchema = z18.object({
5369
+ dataDir: z18.string(),
5370
+ projectsDir: z18.string(),
5371
+ publicUrl: z18.string().url().optional()
4787
5372
  });
4788
5373
  function parseCommandArgs() {
4789
5374
  return parseArgs({
@@ -4811,15 +5396,15 @@ async function main() {
4811
5396
  printHelp();
4812
5397
  return;
4813
5398
  }
4814
- const dataDir = path10.resolve(
4815
- values["data-dir"] ?? process.env.AGENTRIFIC_DATA_DIR ?? path10.join(homedir5(), ".agentrific")
5399
+ const dataDir = path12.resolve(
5400
+ values["data-dir"] ?? process.env.AGENTRIFIC_DATA_DIR ?? path12.join(homedir5(), ".agentrific")
4816
5401
  );
4817
- const filename = path10.join(dataDir, "config.json");
5402
+ const filename = path12.join(dataDir, "config.json");
4818
5403
  const config = ConfigSchema.parse(
4819
5404
  JSON.parse(
4820
5405
  await readFile5(filename, "utf8").catch((error) => {
4821
- if (!z16.object({ code: z16.literal("ENOENT") }).safeParse(error).success) throw error;
4822
- return JSON.stringify({ dataDir, projectsDir: path10.join(homedir5(), "projects") });
5406
+ if (!z18.object({ code: z18.literal("ENOENT") }).safeParse(error).success) throw error;
5407
+ return JSON.stringify({ dataDir, projectsDir: path12.join(homedir5(), "projects") });
4823
5408
  })
4824
5409
  )
4825
5410
  );
@@ -4891,7 +5476,7 @@ async function serviceCommand({ positionals, dataDir }) {
4891
5476
  return;
4892
5477
  }
4893
5478
  async function logsCommand({ dataDir }) {
4894
- const child = process.platform === "linux" ? spawn2("journalctl", ["--user", "-u", "agentrific.service", "-f"], { stdio: "inherit" }) : spawn2("tail", ["-F", path10.join(dataDir, "logs", "server.log")], { stdio: "inherit" });
5479
+ 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" });
4895
5480
  child.on("error", (error) => {
4896
5481
  console.error(error.message);
4897
5482
  process.exitCode = 1;
@@ -4925,11 +5510,11 @@ async function pairCommand({ values, dataDir, config }) {
4925
5510
  return;
4926
5511
  }
4927
5512
  async function maintenanceCommand({ values, positionals, dataDir }) {
4928
- const action = z16.enum(["enter", "resume", "drain"]).parse(positionals[1]);
5513
+ const action = z18.enum(["enter", "resume", "drain"]).parse(positionals[1]);
4929
5514
  const result = await controlRequest(dataDir, {
4930
5515
  command: "maintenance",
4931
5516
  action,
4932
- timeoutMs: z16.coerce.number().int().min(0).max(6e4).parse(values["timeout-ms"] ?? 3e4)
5517
+ timeoutMs: z18.coerce.number().int().min(0).max(6e4).parse(values["timeout-ms"] ?? 3e4)
4933
5518
  });
4934
5519
  console.log(JSON.stringify(result, null, 2));
4935
5520
  if (action === "drain" && "drained" in result && !result.drained) process.exitCode = 1;
@@ -4938,7 +5523,7 @@ async function maintenanceCommand({ values, positionals, dataDir }) {
4938
5523
  async function devicesCommand({ dataDir, command }) {
4939
5524
  console.log(
4940
5525
  JSON.stringify(
4941
- await controlRequest(dataDir, { command: z16.enum(["devices", "status"]).parse(command) }),
5526
+ await controlRequest(dataDir, { command: z18.enum(["devices", "status"]).parse(command) }),
4942
5527
  null,
4943
5528
  2
4944
5529
  )
@@ -4946,7 +5531,7 @@ async function devicesCommand({ dataDir, command }) {
4946
5531
  return;
4947
5532
  }
4948
5533
  async function revokeCommand({ positionals, dataDir }) {
4949
- const id = z16.string().uuid().parse(positionals[1]);
5534
+ const id = z18.string().uuid().parse(positionals[1]);
4950
5535
  console.log(JSON.stringify(await controlRequest(dataDir, { command: "revoke", id })));
4951
5536
  return;
4952
5537
  }