@agentrific/server 0.1.5 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/index.js CHANGED
@@ -45,7 +45,14 @@ var PushGrantSchema = PushEnrollmentSchema.extend({
45
45
  grant: z.string().regex(/^[a-f0-9]{64}$/),
46
46
  expiresAt: z.number()
47
47
  }).strict();
48
- var PushEventSchema = z.object({ eventId: z.string().uuid(), sessionId: z.string().uuid(), kind: PushKindSchema }).strict();
48
+ var PushPullRequestUrlSchema = z.string().max(512).regex(/^https:\/\/github\.com\/[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+\/pull\/[1-9][0-9]*$/);
49
+ var PushEventSchema = z.object({
50
+ eventId: z.string().uuid(),
51
+ sessionId: z.string().uuid(),
52
+ kind: PushKindSchema,
53
+ taskTitle: z.string().trim().min(1).max(160).optional(),
54
+ prUrl: PushPullRequestUrlSchema.optional()
55
+ }).strict();
49
56
  var PushSignedRequestSchema = z.object({
50
57
  bindingId: z.string().uuid(),
51
58
  timestamp: z.number().int(),
@@ -105,10 +112,11 @@ function pushKind(session, previous) {
105
112
  }
106
113
  }
107
114
  var ServerPush = class {
108
- constructor(store2, serverId, directory, transport = fetch) {
115
+ constructor(store2, serverId, directory, transport = fetch, apiUrl = PUSH_API_URL) {
109
116
  this.store = store2;
110
117
  this.serverId = serverId;
111
118
  this.transport = transport;
119
+ this.apiUrl = apiUrl;
112
120
  mkdirSync(directory, { recursive: true, mode: 448 });
113
121
  const filename = path.join(directory, "push-signing-key.pem");
114
122
  if (!existsSync(filename))
@@ -123,6 +131,7 @@ var ServerPush = class {
123
131
  store;
124
132
  serverId;
125
133
  transport;
134
+ apiUrl;
126
135
  key;
127
136
  publicKey;
128
137
  draining;
@@ -136,6 +145,8 @@ var ServerPush = class {
136
145
  eventId: randomUUID(),
137
146
  sessionId: session.id,
138
147
  kind,
148
+ taskTitle: session.title.replace(/[\r\n\t]+/g, " ").trim().slice(0, 160) || void 0,
149
+ prUrl: kind === "pr_created" ? PushPullRequestUrlSchema.safeParse(session.gitDelivery?.url).data : void 0,
139
150
  remaining: state.bindings,
140
151
  createdAt: Date.now(),
141
152
  attempts: 0,
@@ -158,7 +169,7 @@ var ServerPush = class {
158
169
  Buffer.from(pushSigningText(installationId, operation, request)),
159
170
  this.key
160
171
  ).toString("hex");
161
- return this.transport(`${PUSH_API_URL}/v1/installations/${installationId}/${operation}`, {
172
+ return this.transport(`${this.apiUrl}/v1/installations/${installationId}/${operation}`, {
162
173
  method: "POST",
163
174
  headers: { "Content-Type": "application/json" },
164
175
  body: JSON.stringify({ ...request, signature }),
@@ -232,7 +243,13 @@ var ServerPush = class {
232
243
  binding.installationId,
233
244
  binding.bindingId,
234
245
  "events",
235
- JSON.stringify({ eventId: job.eventId, sessionId: job.sessionId, kind: job.kind })
246
+ JSON.stringify({
247
+ eventId: job.eventId,
248
+ sessionId: job.sessionId,
249
+ kind: job.kind,
250
+ taskTitle: job.taskTitle,
251
+ prUrl: job.prUrl
252
+ })
236
253
  );
237
254
  if (response.ok) continue;
238
255
  if ([401, 403, 404].includes(response.status)) {
@@ -475,11 +492,18 @@ var CreateSessionSchema = z2.object({
475
492
  var SendMessageSchema = z2.object({
476
493
  message: z2.string().trim().max(2e4),
477
494
  attachmentIds: AttachmentIdsSchema.optional(),
478
- clientMessageId: z2.string().min(1).max(120).optional()
495
+ clientMessageId: z2.string().min(1).max(120).optional(),
496
+ mode: z2.enum(["followUp", "steer"]).optional()
479
497
  }).refine(
480
498
  (input) => input.message.length > 0 || (input.attachmentIds?.length ?? 0) > 0,
481
499
  "Write a message or attach a file."
482
500
  );
501
+ var FileChangeSchema = z2.object({
502
+ path: z2.string(),
503
+ patch: z2.string().optional(),
504
+ content: z2.string().optional(),
505
+ omitted: z2.boolean().optional()
506
+ });
483
507
  var SessionEventSchema = z2.discriminatedUnion("type", [
484
508
  z2.object({ type: z2.literal("session.updated"), session: SessionSchema }),
485
509
  z2.object({
@@ -514,7 +538,8 @@ var SessionEventSchema = z2.discriminatedUnion("type", [
514
538
  sessionId: z2.string(),
515
539
  toolCallId: z2.string().optional(),
516
540
  tool: z2.string(),
517
- input: z2.string()
541
+ input: z2.string(),
542
+ filePath: z2.string().optional()
518
543
  }),
519
544
  z2.object({
520
545
  type: z2.literal("tool.completed"),
@@ -522,6 +547,7 @@ var SessionEventSchema = z2.discriminatedUnion("type", [
522
547
  toolCallId: z2.string().optional(),
523
548
  tool: z2.string(),
524
549
  output: z2.string(),
550
+ fileChange: FileChangeSchema.optional(),
525
551
  isError: z2.boolean().default(false)
526
552
  }),
527
553
  z2.object({
@@ -613,6 +639,11 @@ var PairingPayloadSchema = z2.object({
613
639
  serverId: z2.string().uuid(),
614
640
  pairingId: z2.string().regex(/^[a-f0-9]{64}$/)
615
641
  });
642
+ var PairingInvitationSchema = z2.object({
643
+ payload: PairingPayloadSchema,
644
+ pin: z2.string().regex(/^\d{6}$/),
645
+ expiresAt: z2.number()
646
+ });
616
647
  var PairingRequestSchema = z2.object({
617
648
  pairingId: z2.string().regex(/^[a-f0-9]{64}$/),
618
649
  pin: z2.string().regex(/^\d{6}$/),
@@ -905,190 +936,55 @@ function attachEventServer(app2, security2, origins, snapshot) {
905
936
 
906
937
  // src/security.ts
907
938
  import { createHash, randomBytes, randomInt, randomUUID as randomUUID3, timingSafeEqual } from "node:crypto";
908
- import { Effect as Effect3 } from "effect";
909
- import { z as z4 } from "zod";
910
- var secret = () => randomBytes(32).toString("hex");
911
- var digest = (value) => createHash("sha256").update(value).digest("hex");
912
- var ServerSecurity = class {
913
- constructor(store2, serverId) {
914
- this.store = store2;
915
- this.serverId = serverId;
916
- }
917
- store;
918
- serverId;
919
- grants = /* @__PURE__ */ new Map();
920
- tickets = /* @__PURE__ */ new Map();
921
- attempts = /* @__PURE__ */ new Map();
922
- sockets = /* @__PURE__ */ new Map();
923
- prune() {
924
- const now = Date.now();
925
- for (const [key, grant] of this.grants) if (grant.expiresAt <= now) this.grants.delete(key);
926
- for (const [key, ticket] of this.tickets) if (ticket.expiresAt <= now) this.tickets.delete(key);
927
- for (const [key, attempts] of this.attempts)
928
- if (attempts.until <= now) this.attempts.delete(key);
929
- }
930
- createPairing() {
931
- this.prune();
932
- if (this.grants.size >= 10)
933
- throw new Error("Too many pending enrollments. Wait for them to expire.");
934
- const pairingId = secret();
935
- const grant = {
936
- pin: String(randomInt(1e6)).padStart(6, "0"),
937
- expiresAt: Date.now() + 3e5,
938
- attempts: 0
939
- };
940
- this.grants.set(pairingId, grant);
941
- return { pairingId, pin: grant.pin, expiresAt: grant.expiresAt, serverId: this.serverId };
942
- }
943
- async pair(input, address) {
944
- this.prune();
945
- const attempts = this.attempts.get(address) ?? { count: 0, until: Date.now() + 6e4 };
946
- if (attempts.count >= 20 || !this.attempts.has(address) && this.attempts.size >= 5e3)
947
- return void 0;
948
- attempts.count++;
949
- this.attempts.set(address, attempts);
950
- const grant = this.grants.get(input.pairingId);
951
- if (!grant) return void 0;
952
- grant.attempts++;
953
- const match = timingSafeEqual(Buffer.from(grant.pin), Buffer.from(input.pin));
954
- if (grant.attempts >= 3 || match) this.grants.delete(input.pairingId);
955
- if (!match) return void 0;
956
- const token = secret();
957
- const now = Date.now();
958
- const device = {
959
- id: randomUUID3(),
960
- name: input.deviceName,
961
- platform: input.platform,
962
- createdAt: now,
963
- lastSeenAt: now,
964
- revokedAt: null
965
- };
966
- await Effect3.runPromise(this.store.saveDevice(device, digest(token)));
967
- return { deviceId: device.id, deviceToken: token, serverId: this.serverId };
968
- }
969
- authenticate(token) {
970
- if (!/^[a-f0-9]{64}$/.test(token)) return void 0;
971
- return Effect3.runSync(this.store.deviceForToken(digest(token)));
972
- }
973
- ticket(token, path10) {
974
- this.prune();
975
- if (!this.authenticate(token) || this.tickets.size >= 1e4) return void 0;
976
- const value = secret();
977
- this.tickets.set(value, { tokenHash: digest(token), expiresAt: Date.now() + 6e4, path: path10 });
978
- return value;
979
- }
980
- redeemTicket(value, path10, consume = true) {
981
- this.prune();
982
- const ticket = this.tickets.get(value);
983
- if (!ticket || ticket.path !== path10) return void 0;
984
- if (consume) this.tickets.delete(value);
985
- return Effect3.runSync(this.store.deviceForToken(ticket.tokenHash));
986
- }
987
- async revoke(id) {
988
- const revoked = await Effect3.runPromise(this.store.revokeDevice(id));
989
- for (const [socket, owner] of this.sockets)
990
- if (owner === id) socket.close(4401, "Device revoked");
991
- return revoked;
992
- }
993
- };
994
- async function registerSecurity(app2, security2, version, allowedOrigins2) {
995
- app2.addHook("onRequest", async (request, reply) => {
996
- const path10 = new URL(request.url, "http://localhost");
997
- reply.header("Cache-Control", "no-store");
998
- if (request.method === "OPTIONS") return;
999
- if (request.headers.origin && !allowedOrigins2.includes(request.headers.origin))
1000
- return reply.code(403).send({ error: "Browser origin is not allowed." });
1001
- if (path10.pathname === "/health" || path10.pathname === "/api/auth/pair" && request.method === "POST")
1002
- return;
1003
- const authorization = request.headers.authorization ?? "";
1004
- const token = authorization.startsWith("Bearer ") ? authorization.slice(7) : "";
1005
- const asset = request.method === "GET" && /^\/api\/projects\/[^/]+\/attachments\/[^/]+\/content$/.test(path10.pathname);
1006
- const ticket = path10.searchParams.get("ticket");
1007
- path10.searchParams.delete("ticket");
1008
- if (!security2.authenticate(token) && !(asset && ticket && security2.redeemTicket(ticket, path10.pathname + path10.search, false)))
1009
- return reply.code(401).send({ error: "Pair this device with your server.", code: "UNAUTHORIZED" });
1010
- if (!asset && request.headers["x-agentrific-protocol"] !== String(PROTOCOL_VERSION))
1011
- return reply.code(409).send({
1012
- error: "Update your Agentrific client or server.",
1013
- code: "VERSION_MISMATCH",
1014
- protocolVersion: PROTOCOL_VERSION
1015
- });
1016
- });
1017
- app2.get("/health", () => ({
1018
- status: "ok",
1019
- service: "agentrific-server",
1020
- serverId: security2.serverId,
1021
- serverVersion: version,
1022
- protocolVersion: PROTOCOL_VERSION,
1023
- authentication: "pairing"
1024
- }));
1025
- app2.post("/api/auth/pair", async (request, reply) => {
1026
- const input = PairingRequestSchema.safeParse(request.body);
1027
- if (!input.success)
1028
- return reply.code(400).send({ error: "Enter a valid pairing payload and six-digit PIN." });
1029
- const result = await security2.pair(input.data, request.ip);
1030
- return result ?? reply.code(401).send({ error: "Pairing expired, locked, or invalid. Run agentrific pair again." });
1031
- });
1032
- app2.get("/api/auth/session", (request) => ({
1033
- serverId: security2.serverId,
1034
- device: security2.authenticate((request.headers.authorization ?? "").slice(7))
1035
- }));
1036
- app2.post("/api/auth/event-ticket", (request) => ({
1037
- ticket: security2.ticket((request.headers.authorization ?? "").slice(7), "/api/events")
1038
- }));
1039
- app2.post("/api/auth/asset-ticket", (request, reply) => {
1040
- const input = z4.object({
1041
- path: z4.string().regex(/^\/api\/projects\/[^/?]+\/attachments\/[^/?]+\/content(?:\?preview=1)?$/)
1042
- }).safeParse(request.body);
1043
- if (!input.success) return reply.code(400).send({ error: "Choose a valid attachment." });
1044
- return {
1045
- ticket: security2.ticket((request.headers.authorization ?? "").slice(7), input.data.path)
1046
- };
1047
- });
1048
- }
939
+ import { Effect as Effect4 } from "effect";
940
+ import { z as z6 } from "zod";
941
+
942
+ // src/pairing.ts
943
+ import { z as z5 } from "zod";
944
+ import qrcode from "qrcode-terminal";
1049
945
 
1050
946
  // src/control.ts
1051
947
  import { createServer, createConnection } from "node:net";
1052
948
  import { chmod, mkdir as mkdir2, readFile as readFile2, unlink, writeFile } from "node:fs/promises";
1053
949
  import path3 from "node:path";
1054
- import { Effect as Effect4 } from "effect";
1055
- import { z as z5 } from "zod";
1056
- var MaintenanceSchema = z5.object({
1057
- active: z5.boolean(),
1058
- drained: z5.boolean(),
1059
- blockers: z5.array(z5.string())
950
+ import { Effect as Effect3 } from "effect";
951
+ import { z as z4 } from "zod";
952
+ var MaintenanceSchema = z4.object({
953
+ active: z4.boolean(),
954
+ drained: z4.boolean(),
955
+ blockers: z4.array(z4.string())
1060
956
  });
1061
- var ControlStatusSchema = z5.object({
1062
- serverId: z5.string().uuid(),
1063
- publicUrl: z5.string().optional(),
1064
- serverVersion: z5.string().optional(),
1065
- connectedDevices: z5.number().optional(),
1066
- connectedSockets: z5.number().optional(),
1067
- memory: z5.number().optional(),
1068
- activeRuns: z5.number().optional(),
957
+ var ControlStatusSchema = z4.object({
958
+ serverId: z4.string().uuid(),
959
+ publicUrl: z4.string().optional(),
960
+ serverVersion: z4.string().optional(),
961
+ connectedDevices: z4.number().optional(),
962
+ connectedSockets: z4.number().optional(),
963
+ memory: z4.number().optional(),
964
+ activeRuns: z4.number().optional(),
1069
965
  maintenance: MaintenanceSchema.optional()
1070
966
  });
1071
- var ControlResultSchema = z5.union([
1072
- z5.object({
1073
- pairingId: z5.string(),
1074
- pin: z5.string(),
1075
- expiresAt: z5.number(),
1076
- serverId: z5.string().uuid()
967
+ var ControlResultSchema = z4.union([
968
+ z4.object({
969
+ pairingId: z4.string(),
970
+ pin: z4.string(),
971
+ expiresAt: z4.number(),
972
+ serverId: z4.string().uuid()
1077
973
  }),
1078
974
  DeviceSchema.array(),
1079
975
  ControlStatusSchema,
1080
- z5.object({ revoked: z5.boolean() }),
976
+ z4.object({ revoked: z4.boolean() }),
1081
977
  MaintenanceSchema
1082
978
  ]);
1083
- var ControlRequest = z5.discriminatedUnion("command", [
1084
- z5.object({ command: z5.literal("pair") }),
1085
- z5.object({ command: z5.literal("devices") }),
1086
- z5.object({ command: z5.literal("status") }),
1087
- z5.object({ command: z5.literal("revoke"), id: z5.string().uuid() }),
1088
- z5.object({
1089
- command: z5.literal("maintenance"),
1090
- action: z5.enum(["enter", "resume", "drain"]),
1091
- timeoutMs: z5.number().int().min(0).max(6e4).default(3e4)
979
+ var ControlRequest = z4.discriminatedUnion("command", [
980
+ z4.object({ command: z4.literal("pair") }),
981
+ z4.object({ command: z4.literal("devices") }),
982
+ z4.object({ command: z4.literal("status") }),
983
+ z4.object({ command: z4.literal("revoke"), id: z4.string().uuid() }),
984
+ z4.object({
985
+ command: z4.literal("maintenance"),
986
+ action: z4.enum(["enter", "resume", "drain"]),
987
+ timeoutMs: z4.number().int().min(0).max(6e4).default(3e4)
1092
988
  })
1093
989
  ]);
1094
990
  async function acquireServerLock(directory) {
@@ -1098,14 +994,14 @@ async function acquireServerLock(directory) {
1098
994
  try {
1099
995
  await writeFile(filename, String(process.pid), { flag: "wx", mode: 384 });
1100
996
  } catch (cause) {
1101
- if (!z5.object({ code: z5.literal("EEXIST") }).safeParse(cause).success) throw cause;
997
+ if (!z4.object({ code: z4.literal("EEXIST") }).safeParse(cause).success) throw cause;
1102
998
  const owner = Number(await readFile2(filename, "utf8"));
1103
999
  if (!Number.isInteger(owner) || owner <= 0)
1104
1000
  throw new Error("Invalid server lock. Check the owner before removing it.");
1105
1001
  try {
1106
1002
  process.kill(owner, 0);
1107
1003
  } catch (error) {
1108
- if (!z5.object({ code: z5.literal("ESRCH") }).safeParse(error).success) throw error;
1004
+ if (!z4.object({ code: z4.literal("ESRCH") }).safeParse(error).success) throw error;
1109
1005
  const recovery = `${filename}.recovery`;
1110
1006
  await writeFile(recovery, String(process.pid), { flag: "wx", mode: 384 }).catch(() => {
1111
1007
  throw new Error(
@@ -1135,7 +1031,7 @@ async function acquireServerLock(directory) {
1135
1031
  async function startControl(directory, security2, status, maintenance2) {
1136
1032
  const filename = path3.join(directory, "control.sock");
1137
1033
  await unlink(filename).catch((error) => {
1138
- if (!z5.object({ code: z5.literal("ENOENT") }).safeParse(error).success) throw error;
1034
+ if (!z4.object({ code: z4.literal("ENOENT") }).safeParse(error).success) throw error;
1139
1035
  });
1140
1036
  const server = createServer((socket) => {
1141
1037
  socket.setTimeout(5e3, () => socket.destroy());
@@ -1157,7 +1053,7 @@ async function startControl(directory, security2, status, maintenance2) {
1157
1053
  result = security2.createPairing();
1158
1054
  break;
1159
1055
  case "devices":
1160
- result = await Effect4.runPromise(security2.store.devices());
1056
+ result = await Effect3.runPromise(security2.store.devices());
1161
1057
  break;
1162
1058
  case "status":
1163
1059
  result = status();
@@ -1194,7 +1090,7 @@ async function startControl(directory, security2, status, maintenance2) {
1194
1090
  // package.json
1195
1091
  var package_default = {
1196
1092
  name: "@agentrific/server",
1197
- version: "0.1.5",
1093
+ version: "0.2.0",
1198
1094
  description: "Self-hosted control plane for long-running coding agents",
1199
1095
  keywords: [
1200
1096
  "agentrific",
@@ -1231,6 +1127,7 @@ var package_default = {
1231
1127
  "@fastify/cors": "^11.3.0",
1232
1128
  "@fastify/multipart": "^10.1.2",
1233
1129
  "@inquirer/select": "^5.2.5",
1130
+ diff: "^9.0.0",
1234
1131
  effect: "4.0.0-rc.117",
1235
1132
  fastify: "^5.6.2",
1236
1133
  "pino-pretty": "^13.1.3",
@@ -1257,6 +1154,180 @@ var package_default = {
1257
1154
  // src/version.ts
1258
1155
  var serverVersion = package_default.version;
1259
1156
 
1157
+ // src/pairing.ts
1158
+ function pairingOrigin(url) {
1159
+ const endpoint = new URL(url);
1160
+ if (endpoint.username || endpoint.password || endpoint.search || endpoint.hash || endpoint.pathname !== "/")
1161
+ throw new Error("Use the HTTPS server origin without a path or credentials.");
1162
+ if (endpoint.protocol !== "https:" && !(process.env.NODE_ENV !== "production" && endpoint.protocol === "http:" && ["127.0.0.1", "localhost", "[::1]"].includes(endpoint.hostname)))
1163
+ throw new Error("Use HTTPS. Localhost HTTP is for development builds only.");
1164
+ return endpoint.origin;
1165
+ }
1166
+
1167
+ // src/security.ts
1168
+ var secret = () => randomBytes(32).toString("hex");
1169
+ var digest = (value) => createHash("sha256").update(value).digest("hex");
1170
+ var ServerSecurity = class {
1171
+ constructor(store2, serverId) {
1172
+ this.store = store2;
1173
+ this.serverId = serverId;
1174
+ }
1175
+ store;
1176
+ serverId;
1177
+ grants = /* @__PURE__ */ new Map();
1178
+ tickets = /* @__PURE__ */ new Map();
1179
+ attempts = /* @__PURE__ */ new Map();
1180
+ sockets = /* @__PURE__ */ new Map();
1181
+ prune() {
1182
+ const now = Date.now();
1183
+ for (const [key, grant] of this.grants) if (grant.expiresAt <= now) this.grants.delete(key);
1184
+ for (const [key, ticket] of this.tickets) if (ticket.expiresAt <= now) this.tickets.delete(key);
1185
+ for (const [key, attempts] of this.attempts)
1186
+ if (attempts.until <= now) this.attempts.delete(key);
1187
+ }
1188
+ createPairing() {
1189
+ this.prune();
1190
+ if (this.grants.size >= 10)
1191
+ throw new Error("Too many pending enrollments. Wait for them to expire.");
1192
+ const pairingId = secret();
1193
+ const grant = {
1194
+ pin: String(randomInt(1e6)).padStart(6, "0"),
1195
+ expiresAt: Date.now() + 3e5,
1196
+ attempts: 0
1197
+ };
1198
+ this.grants.set(pairingId, grant);
1199
+ return { pairingId, pin: grant.pin, expiresAt: grant.expiresAt, serverId: this.serverId };
1200
+ }
1201
+ async pair(input, address) {
1202
+ this.prune();
1203
+ const attempts = this.attempts.get(address) ?? { count: 0, until: Date.now() + 6e4 };
1204
+ if (attempts.count >= 20 || !this.attempts.has(address) && this.attempts.size >= 5e3)
1205
+ return void 0;
1206
+ attempts.count++;
1207
+ this.attempts.set(address, attempts);
1208
+ const grant = this.grants.get(input.pairingId);
1209
+ if (!grant) return void 0;
1210
+ grant.attempts++;
1211
+ const match = timingSafeEqual(Buffer.from(grant.pin), Buffer.from(input.pin));
1212
+ if (grant.attempts >= 3 || match) this.grants.delete(input.pairingId);
1213
+ if (!match) return void 0;
1214
+ const token = secret();
1215
+ const now = Date.now();
1216
+ const device = {
1217
+ id: randomUUID3(),
1218
+ name: input.deviceName,
1219
+ platform: input.platform,
1220
+ createdAt: now,
1221
+ lastSeenAt: now,
1222
+ revokedAt: null
1223
+ };
1224
+ await Effect4.runPromise(this.store.saveDevice(device, digest(token)));
1225
+ return { deviceId: device.id, deviceToken: token, serverId: this.serverId };
1226
+ }
1227
+ authenticate(token) {
1228
+ if (!/^[a-f0-9]{64}$/.test(token)) return void 0;
1229
+ return Effect4.runSync(this.store.deviceForToken(digest(token)));
1230
+ }
1231
+ ticket(token, path10) {
1232
+ this.prune();
1233
+ if (!this.authenticate(token) || this.tickets.size >= 1e4) return void 0;
1234
+ const value = secret();
1235
+ this.tickets.set(value, { tokenHash: digest(token), expiresAt: Date.now() + 6e4, path: path10 });
1236
+ return value;
1237
+ }
1238
+ redeemTicket(value, path10, consume = true) {
1239
+ this.prune();
1240
+ const ticket = this.tickets.get(value);
1241
+ if (!ticket || ticket.path !== path10) return void 0;
1242
+ if (consume) this.tickets.delete(value);
1243
+ return Effect4.runSync(this.store.deviceForToken(ticket.tokenHash));
1244
+ }
1245
+ async revoke(id) {
1246
+ const revoked = await Effect4.runPromise(this.store.revokeDevice(id));
1247
+ for (const [socket, owner] of this.sockets)
1248
+ if (owner === id) socket.close(4401, "Device revoked");
1249
+ return revoked;
1250
+ }
1251
+ };
1252
+ async function registerSecurity(app2, security2, version, allowedOrigins2) {
1253
+ app2.addHook("onRequest", async (request, reply) => {
1254
+ const path10 = new URL(request.url, "http://localhost");
1255
+ reply.header("Cache-Control", "no-store");
1256
+ if (request.method === "OPTIONS") return;
1257
+ if (request.headers.origin && !allowedOrigins2.includes(request.headers.origin))
1258
+ return reply.code(403).send({ error: "Browser origin is not allowed." });
1259
+ if (path10.pathname === "/health" || path10.pathname === "/api/auth/pair" && request.method === "POST")
1260
+ return;
1261
+ const authorization = request.headers.authorization ?? "";
1262
+ const token = authorization.startsWith("Bearer ") ? authorization.slice(7) : "";
1263
+ const asset = request.method === "GET" && /^\/api\/projects\/[^/]+\/attachments\/[^/]+\/content$/.test(path10.pathname);
1264
+ const ticket = path10.searchParams.get("ticket");
1265
+ path10.searchParams.delete("ticket");
1266
+ if (!security2.authenticate(token) && !(asset && ticket && security2.redeemTicket(ticket, path10.pathname + path10.search, false)))
1267
+ return reply.code(401).send({ error: "Pair this device with your server.", code: "UNAUTHORIZED" });
1268
+ if (!asset && request.headers["x-agentrific-protocol"] !== String(PROTOCOL_VERSION))
1269
+ return reply.code(409).send({
1270
+ error: "Update your Agentrific client or server.",
1271
+ code: "VERSION_MISMATCH",
1272
+ protocolVersion: PROTOCOL_VERSION
1273
+ });
1274
+ });
1275
+ app2.get("/health", () => ({
1276
+ status: "ok",
1277
+ service: "agentrific-server",
1278
+ serverId: security2.serverId,
1279
+ serverVersion: version,
1280
+ protocolVersion: PROTOCOL_VERSION,
1281
+ authentication: "pairing"
1282
+ }));
1283
+ app2.post("/api/auth/pair", async (request, reply) => {
1284
+ const input = PairingRequestSchema.safeParse(request.body);
1285
+ if (!input.success)
1286
+ return reply.code(400).send({ error: "Enter a valid pairing payload and six-digit PIN." });
1287
+ const result = await security2.pair(input.data, request.ip);
1288
+ return result ?? reply.code(401).send({ error: "Pairing expired, locked, or invalid. Run agentrific pair again." });
1289
+ });
1290
+ app2.post("/api/auth/pairing-invitation", (_request, reply) => {
1291
+ const publicUrl = process.env.AGENTRIFIC_PUBLIC_URL;
1292
+ if (!publicUrl)
1293
+ return reply.code(503).send({
1294
+ error: "Configure the server\u2019s public HTTPS URL and restart the server before pairing a device."
1295
+ });
1296
+ let url;
1297
+ try {
1298
+ url = pairingOrigin(publicUrl);
1299
+ } catch {
1300
+ return reply.code(503).send({ error: "The server\u2019s public URL is invalid. Configure a public HTTPS origin." });
1301
+ }
1302
+ try {
1303
+ const grant = security2.createPairing();
1304
+ return {
1305
+ payload: { v: 1, url, serverId: grant.serverId, pairingId: grant.pairingId },
1306
+ pin: grant.pin,
1307
+ expiresAt: grant.expiresAt
1308
+ };
1309
+ } catch {
1310
+ return reply.code(429).send({ error: "Too many pending pairing codes. Wait five minutes and try again." });
1311
+ }
1312
+ });
1313
+ app2.get("/api/auth/session", (request) => ({
1314
+ serverId: security2.serverId,
1315
+ device: security2.authenticate((request.headers.authorization ?? "").slice(7))
1316
+ }));
1317
+ app2.post("/api/auth/event-ticket", (request) => ({
1318
+ ticket: security2.ticket((request.headers.authorization ?? "").slice(7), "/api/events")
1319
+ }));
1320
+ app2.post("/api/auth/asset-ticket", (request, reply) => {
1321
+ const input = z6.object({
1322
+ path: z6.string().regex(/^\/api\/projects\/[^/?]+\/attachments\/[^/?]+\/content(?:\?preview=1)?$/)
1323
+ }).safeParse(request.body);
1324
+ if (!input.success) return reply.code(400).send({ error: "Choose a valid attachment." });
1325
+ return {
1326
+ ticket: security2.ticket((request.headers.authorization ?? "").slice(7), input.data.path)
1327
+ };
1328
+ });
1329
+ }
1330
+
1260
1331
  // src/maintenance.ts
1261
1332
  var MaintenanceError = class extends Error {
1262
1333
  statusCode = 503;
@@ -1313,10 +1384,10 @@ var Maintenance = class {
1313
1384
 
1314
1385
  // src/updates.ts
1315
1386
  import semver from "semver";
1316
- import { z as z6 } from "zod";
1387
+ import { z as z7 } from "zod";
1317
1388
  import { Effect as Effect5 } from "effect";
1318
- var RegistryRelease = z6.object({
1319
- version: z6.string().refine((value) => semver.valid(value) !== null)
1389
+ var RegistryRelease = z7.object({
1390
+ version: z7.string().refine((value) => semver.valid(value) !== null)
1320
1391
  });
1321
1392
  async function checkForUpdates() {
1322
1393
  const response = await fetch("https://registry.npmjs.org/@agentrific%2Fserver/latest", {
@@ -1406,7 +1477,7 @@ var UpdateDiscovery = class {
1406
1477
 
1407
1478
  // src/index.ts
1408
1479
  import { Effect as Effect16, ManagedRuntime } from "effect";
1409
- import { z as z15 } from "zod";
1480
+ import { z as z17 } from "zod";
1410
1481
 
1411
1482
  // src/automation.ts
1412
1483
  import { Effect as Effect7 } from "effect";
@@ -1768,7 +1839,7 @@ var AgentController = class {
1768
1839
  Effect8.ensuring(Effect8.sync(() => this.working.delete(entry.session.id)))
1769
1840
  );
1770
1841
  }
1771
- send(session, message, attachments2 = [], messageId = randomUUID4()) {
1842
+ send(session, message, attachments2 = [], messageId = randomUUID4(), mode = "followUp") {
1772
1843
  return this.lock(session.id).withPermit(
1773
1844
  Effect8.gen({ self: this }, function* () {
1774
1845
  if (this.closed)
@@ -1778,6 +1849,16 @@ var AgentController = class {
1778
1849
  if (this.hasMessage(session.id, messageId)) return;
1779
1850
  const entry = yield* this.open(session);
1780
1851
  yield* this.operation(async () => entry.agent.validateAttachments?.(attachments2));
1852
+ const steering = entry.running && mode === "steer";
1853
+ if (steering) {
1854
+ if (!entry.agent.steer)
1855
+ return yield* Effect8.fail(
1856
+ new AgentError({ message: "This agent does not support steering." })
1857
+ );
1858
+ yield* this.operation(
1859
+ () => entry.agent.steer(gitFlowPrompt(entry.session, message), attachments2)
1860
+ );
1861
+ }
1781
1862
  this.emit({
1782
1863
  type: "message.user",
1783
1864
  sessionId: session.id,
@@ -1785,6 +1866,7 @@ var AgentController = class {
1785
1866
  text: message,
1786
1867
  attachments: attachments2
1787
1868
  });
1869
+ if (steering) return;
1788
1870
  if (entry.running) {
1789
1871
  entry.pending.push({ text: message, attachments: attachments2 });
1790
1872
  return;
@@ -1911,7 +1993,7 @@ var AgentSessions = class extends Context2.Service()(
1911
1993
 
1912
1994
  // src/delivery.ts
1913
1995
  import { Effect as Effect9 } from "effect";
1914
- import { z as z8 } from "zod";
1996
+ import { z as z9 } from "zod";
1915
1997
 
1916
1998
  // src/workflow-command.ts
1917
1999
  import { execFile } from "node:child_process";
@@ -1955,12 +2037,12 @@ function workflowError(error) {
1955
2037
  }
1956
2038
 
1957
2039
  // src/workflow-schema.ts
1958
- import { z as z7 } from "zod";
1959
- var DeliveryJobSchema = z7.object({
1960
- kind: z7.literal("delivery"),
1961
- sessionId: z7.string(),
2040
+ import { z as z8 } from "zod";
2041
+ var DeliveryJobSchema = z8.object({
2042
+ kind: z8.literal("delivery"),
2043
+ sessionId: z8.string(),
1962
2044
  request: DeliveryRequestSchema,
1963
- phase: z7.enum([
2045
+ phase: z8.enum([
1964
2046
  "validating",
1965
2047
  "drafting",
1966
2048
  "committing",
@@ -1970,25 +2052,25 @@ var DeliveryJobSchema = z7.object({
1970
2052
  "opening_pr",
1971
2053
  "delivered"
1972
2054
  ]),
1973
- attempt: z7.number().int().positive(),
1974
- commitSha: z7.string().optional(),
1975
- title: z7.string(),
1976
- body: z7.string(),
1977
- logs: z7.string().default(""),
1978
- failed: z7.boolean().default(false),
1979
- result: z7.enum(["merged", "pr_opened", "no_changes"]).optional(),
1980
- url: z7.string().optional(),
1981
- message: z7.string().optional()
2055
+ attempt: z8.number().int().positive(),
2056
+ commitSha: z8.string().optional(),
2057
+ title: z8.string(),
2058
+ body: z8.string(),
2059
+ logs: z8.string().default(""),
2060
+ failed: z8.boolean().default(false),
2061
+ result: z8.enum(["merged", "pr_opened", "no_changes"]).optional(),
2062
+ url: z8.string().optional(),
2063
+ message: z8.string().optional()
1982
2064
  });
1983
- var PreparationJobSchema = z7.object({
1984
- kind: z7.literal("preparation"),
1985
- sessionId: z7.string(),
1986
- projectPath: z7.string(),
2065
+ var PreparationJobSchema = z8.object({
2066
+ kind: z8.literal("preparation"),
2067
+ sessionId: z8.string(),
2068
+ projectPath: z8.string(),
1987
2069
  input: CreateSessionSchema,
1988
- stage: z7.enum(["creating", "installing", "ready", "failed"]),
1989
- logs: z7.string().default("")
2070
+ stage: z8.enum(["creating", "installing", "ready", "failed"]),
2071
+ logs: z8.string().default("")
1990
2072
  });
1991
- var WorkflowJobSchema = z7.discriminatedUnion("kind", [
2073
+ var WorkflowJobSchema = z8.discriminatedUnion("kind", [
1992
2074
  DeliveryJobSchema,
1993
2075
  PreparationJobSchema
1994
2076
  ]);
@@ -2284,11 +2366,11 @@ ${workflowError(error instanceof Error ? error : String(error))}`
2284
2366
  "--json",
2285
2367
  "url,state,headRefOid"
2286
2368
  ]);
2287
- const prs = z8.array(
2288
- z8.object({
2289
- url: z8.string().url(),
2290
- state: z8.string(),
2291
- headRefOid: z8.string().optional()
2369
+ const prs = z9.array(
2370
+ z9.object({
2371
+ url: z9.string().url(),
2372
+ state: z9.string(),
2373
+ headRefOid: z9.string().optional()
2292
2374
  })
2293
2375
  ).parse(JSON.parse(existing));
2294
2376
  const open = prs.find(
@@ -2332,7 +2414,7 @@ ${workflowError(error instanceof Error ? error : String(error))}`
2332
2414
  import { mkdir as mkdir3, readFile as readFile3 } from "node:fs/promises";
2333
2415
  import path5 from "node:path";
2334
2416
  import { Effect as Effect10 } from "effect";
2335
- import { z as z9 } from "zod";
2417
+ import { z as z10 } from "zod";
2336
2418
  var PreparationCoordinator = class {
2337
2419
  constructor(store2, options) {
2338
2420
  this.store = store2;
@@ -2450,7 +2532,7 @@ ${logs}` : ""}`);
2450
2532
  () => void 0
2451
2533
  );
2452
2534
  if (!packageFile) return;
2453
- const metadata = z9.object({ packageManager: z9.string().optional() }).parse(JSON.parse(packageFile));
2535
+ const metadata = z10.object({ packageManager: z10.string().optional() }).parse(JSON.parse(packageFile));
2454
2536
  const has = async (name) => readFile3(path5.join(workspace, name)).then(
2455
2537
  () => true,
2456
2538
  () => false
@@ -2532,8 +2614,8 @@ ${logs}` : ""}`);
2532
2614
 
2533
2615
  // src/delivery-writer.ts
2534
2616
  import { createAgentSession, SessionManager } from "@earendil-works/pi-coding-agent";
2535
- import { z as z10 } from "zod";
2536
- var DraftSchema = z10.object({ title: z10.string().trim().min(1).max(120), body: z10.string().trim().min(1).max(1e4) }).strict();
2617
+ import { z as z11 } from "zod";
2618
+ var DraftSchema = z11.object({ title: z11.string().trim().min(1).max(120), body: z11.string().trim().min(1).max(1e4) }).strict();
2537
2619
  function deliveryWriter(models2, createSession = createAgentSession) {
2538
2620
  return async (run, input, diff, signal) => {
2539
2621
  signal?.throwIfAborted();
@@ -2688,7 +2770,7 @@ import {
2688
2770
  createAgentSession as createAgentSession2,
2689
2771
  SessionManager as SessionManager2
2690
2772
  } from "@earendil-works/pi-coding-agent";
2691
- import { z as z11 } from "zod";
2773
+ import { z as z13 } from "zod";
2692
2774
 
2693
2775
  // src/delivery-tool.ts
2694
2776
  import { defineTool } from "@earendil-works/pi-coding-agent";
@@ -2722,9 +2804,70 @@ function deliveryTool(sessionId, delivery2) {
2722
2804
  });
2723
2805
  }
2724
2806
 
2807
+ // src/file-change.ts
2808
+ import { createWriteToolDefinition, defineTool as defineTool2 } from "@earendil-works/pi-coding-agent";
2809
+ import { createTwoFilesPatch } from "diff";
2810
+ import { mkdir as mkdir4, readFile as readFile4, writeFile as writeFile2 } from "node:fs/promises";
2811
+ import { z as z12 } from "zod";
2812
+ var previewLimit = 256e3;
2813
+ function boundFileChange(change) {
2814
+ if ((change.patch?.length ?? 0) + (change.content?.length ?? 0) > previewLimit)
2815
+ return { path: change.path, omitted: true };
2816
+ return change;
2817
+ }
2818
+ var EditResultSchema = z12.object({
2819
+ details: z12.object({ patch: z12.string().optional(), fileChange: FileChangeSchema.optional() }).optional()
2820
+ });
2821
+ function completedFileChange(result, path10) {
2822
+ const parsed = EditResultSchema.safeParse(result);
2823
+ if (!parsed.success) return;
2824
+ const details = parsed.data.details;
2825
+ if (details?.fileChange) return boundFileChange(details.fileChange);
2826
+ if (path10 && details?.patch) return boundFileChange({ path: path10, patch: details.patch });
2827
+ }
2828
+ function fileWriteTool(cwd) {
2829
+ const base = createWriteToolDefinition(cwd);
2830
+ return defineTool2({
2831
+ name: base.name,
2832
+ label: base.label,
2833
+ description: base.description,
2834
+ promptSnippet: base.promptSnippet,
2835
+ promptGuidelines: base.promptGuidelines,
2836
+ parameters: base.parameters,
2837
+ constrainedSampling: base.constrainedSampling,
2838
+ async execute(toolCallId, args, signal, _onUpdate, ctx) {
2839
+ let fileChange;
2840
+ const tool = createWriteToolDefinition(cwd, {
2841
+ operations: {
2842
+ mkdir: async (dir) => {
2843
+ await mkdir4(dir, { recursive: true });
2844
+ },
2845
+ // Pi invokes this inside its per-file mutation queue, so the snapshot
2846
+ // and write cannot race another built-in edit/write on the same file.
2847
+ writeFile: async (path10, content) => {
2848
+ const previous = await readFile4(path10, "utf8").catch((error) => {
2849
+ if (error.code === "ENOENT") return void 0;
2850
+ throw error;
2851
+ });
2852
+ await writeFile2(path10, content, "utf8");
2853
+ fileChange = boundFileChange(
2854
+ previous === void 0 ? { path: args.path, content } : {
2855
+ path: args.path,
2856
+ patch: createTwoFilesPatch(args.path, args.path, previous, content)
2857
+ }
2858
+ );
2859
+ }
2860
+ }
2861
+ });
2862
+ const result = await tool.execute(toolCallId, args, signal, void 0, ctx);
2863
+ return { ...result, details: { fileChange } };
2864
+ }
2865
+ });
2866
+ }
2867
+
2725
2868
  // src/pi.ts
2726
- var ToolResultSchema = z11.object({
2727
- content: z11.array(z11.object({ type: z11.string(), text: z11.string().optional() })).optional()
2869
+ var ToolResultSchema = z13.object({
2870
+ content: z13.array(z13.object({ type: z13.string(), text: z13.string().optional() })).optional()
2728
2871
  }).passthrough();
2729
2872
  var boundedJson = (value, limit) => {
2730
2873
  let text;
@@ -2749,7 +2892,10 @@ function createPiFactory(files, models2, delivery2, createSession = createAgentS
2749
2892
  model,
2750
2893
  modelRuntime: models2.runtime,
2751
2894
  sessionManager: SessionManager2.continueRecent(cwd, sessionDirectory),
2752
- customTools: run?.gitFlow && delivery2 ? [deliveryTool(sessionId, delivery2)] : []
2895
+ customTools: [
2896
+ fileWriteTool(cwd),
2897
+ ...run?.gitFlow && delivery2 ? [deliveryTool(sessionId, delivery2)] : []
2898
+ ]
2753
2899
  });
2754
2900
  return {
2755
2901
  get model() {
@@ -2773,6 +2919,12 @@ function createPiFactory(files, models2, delivery2, createSession = createAgentS
2773
2919
  throw new Error(last.errorMessage ?? "The model request failed");
2774
2920
  }
2775
2921
  },
2922
+ async steer(message, attachments2 = []) {
2923
+ const context = await Effect12.runPromise(files.prepare(attachments2));
2924
+ if (!session.isStreaming)
2925
+ throw new Error("The response has finished. Send this message as a follow-up instead.");
2926
+ await session.steer(message + context.context, context.images);
2927
+ },
2776
2928
  async abort() {
2777
2929
  session.clearQueue();
2778
2930
  await session.abort();
@@ -2837,16 +2989,23 @@ function createPiFactory(files, models2, delivery2, createSession = createAgentS
2837
2989
  }
2838
2990
  return false;
2839
2991
  }
2992
+ const filePaths = /* @__PURE__ */ new Map();
2993
+ const FileArgsSchema = z13.object({ path: z13.string() });
2840
2994
  function handleTool(event) {
2841
2995
  if (event.type === "tool_execution_start") {
2842
2996
  flushText();
2843
2997
  setActivity("thinking");
2998
+ if (event.toolName === "edit" || event.toolName === "write") {
2999
+ const args = FileArgsSchema.safeParse(event.args);
3000
+ if (args.success) filePaths.set(event.toolCallId, args.data.path);
3001
+ }
2844
3002
  emit({
2845
3003
  type: "tool.started",
2846
3004
  sessionId,
2847
3005
  toolCallId: event.toolCallId,
2848
3006
  tool: event.toolName,
2849
- input: boundedJson(event.args, 1200)
3007
+ input: boundedJson(event.args, 1200),
3008
+ filePath: filePaths.get(event.toolCallId)
2850
3009
  });
2851
3010
  return true;
2852
3011
  }
@@ -2858,8 +3017,10 @@ function createPiFactory(files, models2, delivery2, createSession = createAgentS
2858
3017
  toolCallId: event.toolCallId,
2859
3018
  tool: event.toolName,
2860
3019
  output: toolOutput(event.result),
3020
+ fileChange: event.isError ? void 0 : completedFileChange(event.result, filePaths.get(event.toolCallId)),
2861
3021
  isError: event.isError
2862
3022
  });
3023
+ filePaths.delete(event.toolCallId);
2863
3024
  setActivity("thinking");
2864
3025
  return true;
2865
3026
  }
@@ -2888,9 +3049,9 @@ function createPiFactory(files, models2, delivery2, createSession = createAgentS
2888
3049
  }
2889
3050
 
2890
3051
  // src/projects.ts
2891
- import { access, readdir as readdir2, realpath, stat as stat2, readFile as readFile4, writeFile as writeFile2 } from "node:fs/promises";
3052
+ import { access, readdir as readdir2, realpath, stat as stat2, readFile as readFile5, writeFile as writeFile3 } from "node:fs/promises";
2892
3053
  import { randomUUID as randomUUID6 } from "node:crypto";
2893
- import { z as z12 } from "zod";
3054
+ import { z as z14 } from "zod";
2894
3055
  import { constants } from "node:fs";
2895
3056
  import { homedir } from "node:os";
2896
3057
  import path6 from "node:path";
@@ -2922,12 +3083,12 @@ function projectLock(store2) {
2922
3083
  }
2923
3084
  return lock;
2924
3085
  }
2925
- var ProjectId = z12.string().trim().min(1).max(255);
3086
+ var ProjectId = z14.string().trim().min(1).max(255);
2926
3087
  async function gitDirectory(directory) {
2927
3088
  const location = path6.join(directory, ".git");
2928
3089
  const info = await stat2(location);
2929
3090
  if (info.isDirectory()) return location;
2930
- const contents = await readFile4(location, "utf8");
3091
+ const contents = await readFile5(location, "utf8");
2931
3092
  const match = /^gitdir: (.+)\s*$/m.exec(contents);
2932
3093
  if (!match) throw new Error("Invalid Git worktree metadata.");
2933
3094
  const resolved = await realpath(path6.resolve(directory, match[1].trim()));
@@ -2937,21 +3098,21 @@ async function gitDirectory(directory) {
2937
3098
  async function identity(directory, preferred) {
2938
3099
  const filename = path6.join(await gitDirectory(directory), "agentrific-project-id");
2939
3100
  try {
2940
- return ProjectId.parse(await readFile4(filename, "utf8"));
3101
+ return ProjectId.parse(await readFile5(filename, "utf8"));
2941
3102
  } catch (error) {
2942
- if (!z12.object({ code: z12.literal("ENOENT") }).safeParse(error).success) throw error;
3103
+ if (!z14.object({ code: z14.literal("ENOENT") }).safeParse(error).success) throw error;
2943
3104
  const legacyFilename = path6.join(await gitDirectory(directory), "chatplans-project-id");
2944
- const legacy = await readFile4(legacyFilename, "utf8").catch((cause) => {
2945
- if (!z12.object({ code: z12.literal("ENOENT") }).safeParse(cause).success) throw cause;
3105
+ const legacy = await readFile5(legacyFilename, "utf8").catch((cause) => {
3106
+ if (!z14.object({ code: z14.literal("ENOENT") }).safeParse(cause).success) throw cause;
2946
3107
  return null;
2947
3108
  });
2948
3109
  const id = legacy === null ? preferred ?? randomUUID6() : ProjectId.parse(legacy);
2949
3110
  try {
2950
- await writeFile2(filename, id + "\n", { flag: "wx" });
3111
+ await writeFile3(filename, id + "\n", { flag: "wx" });
2951
3112
  return id;
2952
3113
  } catch (writeError) {
2953
- if (!z12.object({ code: z12.literal("EEXIST") }).safeParse(writeError).success) throw writeError;
2954
- return ProjectId.parse(await readFile4(filename, "utf8"));
3114
+ if (!z14.object({ code: z14.literal("EEXIST") }).safeParse(writeError).success) throw writeError;
3115
+ return ProjectId.parse(await readFile5(filename, "utf8"));
2955
3116
  }
2956
3117
  }
2957
3118
  }
@@ -2971,7 +3132,7 @@ function scanProjects(root, store2) {
2971
3132
  let existing = registered.find((project2) => project2.id === id);
2972
3133
  if (found.some((project2) => project2.id === id) || existing && existing.path !== directory && await stat2(path6.join(existing.path, ".git")).catch(() => null)) {
2973
3134
  id = randomUUID6();
2974
- await writeFile2(
3135
+ await writeFile3(
2975
3136
  path6.join(await gitDirectory(directory), "agentrific-project-id"),
2976
3137
  id + "\n"
2977
3138
  );
@@ -3010,11 +3171,11 @@ function reconnectProject(root, id, input, store2) {
3010
3171
  const filename = path6.join(await gitDirectory(directory), "agentrific-project-id");
3011
3172
  const replacedId = await identity(directory);
3012
3173
  const project = { id, name: path6.basename(directory), path: directory, available: true };
3013
- await writeFile2(filename, id + "\n");
3174
+ await writeFile3(filename, id + "\n");
3014
3175
  try {
3015
3176
  await Effect13.runPromise(store2.relocateProject(project, previous.path, replacedId));
3016
3177
  } catch (error) {
3017
- await writeFile2(filename, replacedId + "\n");
3178
+ await writeFile3(filename, replacedId + "\n");
3018
3179
  throw error;
3019
3180
  }
3020
3181
  return project;
@@ -3028,32 +3189,32 @@ import { chmodSync, existsSync as existsSync2, mkdirSync as mkdirSync2 } from "n
3028
3189
  import path7 from "node:path";
3029
3190
 
3030
3191
  // src/push-schema.ts
3031
- import { z as z13 } from "zod";
3032
- var PushBindingSchema = z13.object({
3033
- bindingId: z13.string().uuid(),
3034
- installationId: z13.string().uuid(),
3035
- deviceId: z13.string().uuid()
3192
+ import { z as z15 } from "zod";
3193
+ var PushBindingSchema = z15.object({
3194
+ bindingId: z15.string().uuid(),
3195
+ installationId: z15.string().uuid(),
3196
+ deviceId: z15.string().uuid()
3036
3197
  });
3037
- var PushStateSchema = z13.object({
3038
- bindings: z13.array(PushBindingSchema).max(10),
3039
- error: z13.string().nullable()
3198
+ var PushStateSchema = z15.object({
3199
+ bindings: z15.array(PushBindingSchema).max(10),
3200
+ error: z15.string().nullable()
3040
3201
  });
3041
3202
  var PendingPushSchema = PushEventSchema.extend({
3042
- remaining: z13.array(PushBindingSchema),
3043
- createdAt: z13.number(),
3044
- attempts: z13.number().int().nonnegative(),
3045
- nextAt: z13.number()
3203
+ remaining: z15.array(PushBindingSchema),
3204
+ createdAt: z15.number(),
3205
+ attempts: z15.number().int().nonnegative(),
3206
+ nextAt: z15.number()
3046
3207
  });
3047
3208
 
3048
3209
  // src/store.ts
3049
3210
  import { DatabaseSync } from "node:sqlite";
3050
3211
  import { Context as Context3, Data as Data3, Effect as Effect14, Layer as Layer3 } from "effect";
3051
- import { z as z14 } from "zod";
3212
+ import { z as z16 } from "zod";
3052
3213
  import { randomUUID as randomUUID7 } from "node:crypto";
3053
3214
  var StorageError = class extends Data3.TaggedError("StorageError") {
3054
3215
  };
3055
- var PayloadRow = z14.object({ payload: z14.string() });
3056
- var EventRow = z14.object({ sequence: z14.number(), recorded_at: z14.string(), payload: z14.string() });
3216
+ var PayloadRow = z16.object({ payload: z16.string() });
3217
+ var EventRow = z16.object({ sequence: z16.number(), recorded_at: z16.string(), payload: z16.string() });
3057
3218
  var SqliteSessionStore = class {
3058
3219
  constructor(db) {
3059
3220
  this.db = db;
@@ -3104,7 +3265,7 @@ var SqliteSessionStore = class {
3104
3265
  const row = PayloadRow.parse(
3105
3266
  this.db.prepare("SELECT payload FROM settings WHERE id = 'server-id'").get()
3106
3267
  );
3107
- return z14.string().uuid().parse(JSON.parse(row.payload));
3268
+ return z16.string().uuid().parse(JSON.parse(row.payload));
3108
3269
  });
3109
3270
  }
3110
3271
  saveDevice(device, tokenHash) {
@@ -3192,7 +3353,7 @@ var SqliteSessionStore = class {
3192
3353
  `).all();
3193
3354
  const insert = this.db.prepare("INSERT OR IGNORE INTO projects(id, payload) VALUES (?, ?)");
3194
3355
  for (const row of legacy) {
3195
- const id = z14.object({ id: z14.string() }).parse(row).id;
3356
+ const id = z16.object({ id: z16.string() }).parse(row).id;
3196
3357
  insert.run(
3197
3358
  id,
3198
3359
  JSON.stringify({ id, name: id, path: path7.join(root, id), available: false })
@@ -3402,7 +3563,7 @@ var SqliteSessionStore = class {
3402
3563
  if (event.type === "message.user") this.claimAttachments(event.attachments ?? []);
3403
3564
  const result = this.db.prepare("INSERT INTO events(session_id, recorded_at, payload) VALUES (?, ?, ?)").run(sessionId, recordedAt, JSON.stringify(event));
3404
3565
  if (notification) {
3405
- const count = z14.object({ total: z14.number() }).parse(this.db.prepare("SELECT COUNT(*) AS total FROM push_outbox").get());
3566
+ const count = z16.object({ total: z16.number() }).parse(this.db.prepare("SELECT COUNT(*) AS total FROM push_outbox").get());
3406
3567
  if (count.total >= 1e3)
3407
3568
  this.db.prepare(
3408
3569
  "DELETE FROM push_outbox WHERE rowid IN (SELECT rowid FROM push_outbox ORDER BY rowid LIMIT 1)"
@@ -3454,7 +3615,7 @@ var SessionStore = class extends Context3.Service()(
3454
3615
  for (const suffix of ["-wal", "-shm"])
3455
3616
  if (existsSync2(filename + suffix)) chmodSync(filename + suffix, 384);
3456
3617
  try {
3457
- const schema = z14.object({ user_version: z14.number() }).parse(db.prepare("PRAGMA user_version").get());
3618
+ const schema = z16.object({ user_version: z16.number() }).parse(db.prepare("PRAGMA user_version").get());
3458
3619
  if (schema.user_version > 1)
3459
3620
  throw new Error("This database requires a newer Agentrific server.");
3460
3621
  db.exec(`
@@ -3583,9 +3744,13 @@ var releaseLock = await acquireServerLock(dataRoot);
3583
3744
  var effects = ManagedRuntime.make(SessionStore.layer(path9.join(dataRoot, "sessions.sqlite")));
3584
3745
  var store = await effects.runPromise(SessionStore);
3585
3746
  var security = new ServerSecurity(store, await Effect16.runPromise(store.serverIdentity()));
3586
- var push = new ServerPush(store, security.serverId, dataRoot);
3747
+ var testPushUrl = process.env.AGENTRIFIC_PUSH_TEST_URL;
3748
+ if (testPushUrl && (process.env.AGENTRIFIC_TEST_MODE !== "1" || !new URL(testPushUrl).hostname.endsWith(".ts.net") || new URL(testPushUrl).protocol !== "https:"))
3749
+ throw new Error("Test relay requires explicit test mode and Tailscale HTTPS.");
3750
+ var push = new ServerPush(store, security.serverId, dataRoot, fetch, testPushUrl);
3587
3751
  var allowedOrigins = [
3588
3752
  .../* @__PURE__ */ new Set([
3753
+ "agentrific-desktop://app",
3589
3754
  ...(process.env.AGENTRIFIC_ALLOWED_ORIGINS ?? "http://localhost:8081,http://127.0.0.1:8081").split(","),
3590
3755
  `http://${host === "::1" ? "[::1]" : host}:${port}`,
3591
3756
  ...process.env.AGENTRIFIC_PUBLIC_URL ? [new URL(process.env.AGENTRIFIC_PUBLIC_URL).origin] : []
@@ -3840,7 +4005,7 @@ app.get("/api/projects/:id", async (request, reply) => {
3840
4005
  return project ?? reply.code(404).send({ error: "Project not found on this server." });
3841
4006
  });
3842
4007
  app.post("/api/projects/:id/reconnect", async (request, reply) => {
3843
- const parsed = z15.object({ path: z15.string().trim().min(1).max(4096) }).safeParse(request.body);
4008
+ const parsed = z17.object({ path: z17.string().trim().min(1).max(4096) }).safeParse(request.body);
3844
4009
  if (!parsed.success)
3845
4010
  return reply.code(400).send({ error: "Enter the project's new folder path." });
3846
4011
  const root = await Effect16.runPromise(store.projectsRoot(defaultProjectsRoot));
@@ -3972,9 +4137,9 @@ app.post(
3972
4137
  }
3973
4138
  })
3974
4139
  );
3975
- var HistoryQuery = z15.object({
3976
- after: z15.coerce.number().int().nonnegative().default(0),
3977
- limit: z15.coerce.number().int().min(1).max(1e3).default(500)
4140
+ var HistoryQuery = z17.object({
4141
+ after: z17.coerce.number().int().nonnegative().default(0),
4142
+ limit: z17.coerce.number().int().min(1).max(1e3).default(500)
3978
4143
  });
3979
4144
  app.get("/api/sessions/:id/events", async (request, reply) => {
3980
4145
  const query = HistoryQuery.safeParse(request.query);
@@ -4116,7 +4281,7 @@ app.post("/api/sessions", async (request, reply) => {
4116
4281
  if (!result.session) return reply.code(result.status).send({ error: result.error });
4117
4282
  return reply.code(201).send(result.session);
4118
4283
  });
4119
- var StartTaskBody = z15.object({ prompt: z15.string().trim().max(2e4).optional() }).default({});
4284
+ var StartTaskBody = z17.object({ prompt: z17.string().trim().max(2e4).optional() }).default({});
4120
4285
  function startTask(id, instructionsOverride, automated = false) {
4121
4286
  return withTaskLock(
4122
4287
  "agent-dispatch",
@@ -4194,7 +4359,7 @@ app.patch("/api/automation", async (request, reply) => {
4194
4359
  return automation.configure(parsed.data);
4195
4360
  });
4196
4361
  app.patch("/api/automation/queue", async (request, reply) => {
4197
- const parsed = z15.object({ taskId: z15.string().min(1), beforeId: z15.string().min(1).nullable() }).safeParse(request.body);
4362
+ const parsed = z17.object({ taskId: z17.string().min(1), beforeId: z17.string().min(1).nullable() }).safeParse(request.body);
4198
4363
  if (!parsed.success)
4199
4364
  return reply.code(400).send({ error: "Choose a task and a queue position." });
4200
4365
  try {
@@ -4224,7 +4389,13 @@ app.post(
4224
4389
  attachments.resolve(session.projectId, parsed.data.attachmentIds ?? [])
4225
4390
  );
4226
4391
  await Effect16.runPromise(
4227
- runtime.send(session, parsed.data.message, files, parsed.data.clientMessageId)
4392
+ runtime.send(
4393
+ session,
4394
+ parsed.data.message,
4395
+ files,
4396
+ parsed.data.clientMessageId,
4397
+ parsed.data.mode
4398
+ )
4228
4399
  );
4229
4400
  return reply.code(202).send({ accepted: true });
4230
4401
  } catch (error) {