@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/index.js CHANGED
@@ -7,6 +7,303 @@ process.on("warning", (warning) => {
7
7
  for (const handler of handlers) handler.call(process, warning);
8
8
  });
9
9
 
10
+ // src/push.ts
11
+ import {
12
+ generateKeyPairSync,
13
+ createPrivateKey,
14
+ createPublicKey,
15
+ sign,
16
+ randomUUID
17
+ } from "node:crypto";
18
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
19
+ import path from "node:path";
20
+ import { Effect } from "effect";
21
+
22
+ // ../../packages/contracts/src/push.ts
23
+ import { z } from "zod";
24
+ var PUSH_API_URL = "https://api.agentrific.com";
25
+ var PushKindSchema = z.enum([
26
+ "started",
27
+ "completed",
28
+ "needs_input",
29
+ "prepared",
30
+ "drafting_pr",
31
+ "pr_created",
32
+ "failed"
33
+ ]);
34
+ var PushPreferencesSchema = z.array(PushKindSchema).max(7);
35
+ var PushPublicKeySchema = z.string().regex(/^[a-f0-9]{64}$/);
36
+ var PushInstallationSchema = z.object({ installationId: z.string().uuid(), token: z.string().regex(/^[a-f0-9]{64}$/) }).strict();
37
+ var PushRegistrationSchema = z.object({
38
+ expoToken: z.string().regex(/^(Expo|Exponent)PushToken\[[A-Za-z0-9_-]+\]$/),
39
+ preferences: PushPreferencesSchema
40
+ }).strict();
41
+ var PushEnrollmentSchema = z.object({ serverId: z.string().uuid(), publicKey: PushPublicKeySchema }).strict();
42
+ var PushGrantSchema = PushEnrollmentSchema.extend({
43
+ installationId: z.string().uuid(),
44
+ bindingId: z.string().uuid(),
45
+ grant: z.string().regex(/^[a-f0-9]{64}$/),
46
+ expiresAt: z.number()
47
+ }).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();
56
+ var PushSignedRequestSchema = z.object({
57
+ bindingId: z.string().uuid(),
58
+ timestamp: z.number().int(),
59
+ nonce: z.string().uuid(),
60
+ payload: z.string().max(2048),
61
+ signature: z.string().regex(/^[a-f0-9]{128}$/)
62
+ }).strict();
63
+ var PushEntitlementSchema = z.object({ active: z.boolean(), expiresAt: z.number().nullable() }).strict();
64
+ var PushServerStatusSchema = z.object({
65
+ publicKey: PushPublicKeySchema,
66
+ bindings: z.array(
67
+ z.object({
68
+ bindingId: z.string().uuid(),
69
+ installationId: z.string().uuid(),
70
+ deviceId: z.string().uuid()
71
+ })
72
+ ),
73
+ pending: z.number().int().nonnegative(),
74
+ error: z.string().nullable()
75
+ });
76
+ function pushSigningText(installationId, operation, request) {
77
+ return [
78
+ "agentrific-push-v1",
79
+ installationId,
80
+ operation,
81
+ request.bindingId,
82
+ request.timestamp,
83
+ request.nonce,
84
+ request.payload
85
+ ].join("\n");
86
+ }
87
+
88
+ // src/push.ts
89
+ function pushKind(session, previous) {
90
+ if (session.gitDelivery?.status === "pr_opened" && previous?.gitDelivery?.status !== "pr_opened")
91
+ return "pr_created";
92
+ if (["merged", "no_changes"].includes(session.gitDelivery?.status ?? "") && session.gitDelivery?.status !== previous?.gitDelivery?.status)
93
+ return "completed";
94
+ if (session.gitDelivery?.status === "failed" && previous?.gitDelivery?.status !== "failed")
95
+ return "failed";
96
+ if (session.gitDelivery?.stage === "opening_pr" && previous?.gitDelivery?.stage !== "opening_pr")
97
+ return "drafting_pr";
98
+ if (session.preparation?.status === "ready" && previous?.preparation?.status !== "ready")
99
+ return "prepared";
100
+ if (session.status === previous?.status) return void 0;
101
+ switch (session.status) {
102
+ case "running":
103
+ return "started";
104
+ case "completed":
105
+ return session.gitFlow ? void 0 : "completed";
106
+ case "waiting_for_input":
107
+ return "needs_input";
108
+ case "failed":
109
+ return "failed";
110
+ default:
111
+ return void 0;
112
+ }
113
+ }
114
+ var ServerPush = class {
115
+ constructor(store2, serverId, directory, transport = fetch, apiUrl = PUSH_API_URL) {
116
+ this.store = store2;
117
+ this.serverId = serverId;
118
+ this.transport = transport;
119
+ this.apiUrl = apiUrl;
120
+ mkdirSync(directory, { recursive: true, mode: 448 });
121
+ const filename = path.join(directory, "push-signing-key.pem");
122
+ if (!existsSync(filename))
123
+ writeFileSync(
124
+ filename,
125
+ generateKeyPairSync("ed25519").privateKey.export({ format: "pem", type: "pkcs8" }),
126
+ { mode: 384, flag: "wx" }
127
+ );
128
+ this.key = createPrivateKey(readFileSync(filename));
129
+ this.publicKey = createPublicKey(this.key).export({ format: "der", type: "spki" }).subarray(-32).toString("hex");
130
+ }
131
+ store;
132
+ serverId;
133
+ transport;
134
+ apiUrl;
135
+ key;
136
+ publicKey;
137
+ draining;
138
+ timer;
139
+ stopped = false;
140
+ pending(session, previous) {
141
+ const kind = pushKind(session, previous);
142
+ const state = Effect.runSync(this.store.pushState());
143
+ if (!kind || !state.bindings.length) return void 0;
144
+ return {
145
+ eventId: randomUUID(),
146
+ sessionId: session.id,
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,
150
+ remaining: state.bindings,
151
+ createdAt: Date.now(),
152
+ attempts: 0,
153
+ nextAt: Date.now()
154
+ };
155
+ }
156
+ status(deviceId) {
157
+ const state = Effect.runSync(this.store.pushState());
158
+ return {
159
+ publicKey: this.publicKey,
160
+ bindings: state.bindings.filter((binding) => binding.deviceId === deviceId),
161
+ pending: Effect.runSync(this.store.pendingPush()).length,
162
+ error: state.error
163
+ };
164
+ }
165
+ async send(installationId, bindingId, operation, payload) {
166
+ const request = { bindingId, timestamp: Date.now(), nonce: randomUUID(), payload };
167
+ const signature = sign(
168
+ null,
169
+ Buffer.from(pushSigningText(installationId, operation, request)),
170
+ this.key
171
+ ).toString("hex");
172
+ return this.transport(`${this.apiUrl}/v1/installations/${installationId}/${operation}`, {
173
+ method: "POST",
174
+ headers: { "Content-Type": "application/json" },
175
+ body: JSON.stringify({ ...request, signature }),
176
+ signal: AbortSignal.timeout(1e4),
177
+ redirect: "error"
178
+ });
179
+ }
180
+ async bind(grant, deviceId) {
181
+ if (grant.serverId !== this.serverId || grant.publicKey !== this.publicKey || grant.expiresAt <= Date.now())
182
+ throw new Error("Grant does not match this server or has expired");
183
+ const existing = Effect.runSync(this.store.pushState());
184
+ if (existing.bindings.length >= 10 && !existing.bindings.some((binding) => binding.deviceId === deviceId))
185
+ throw new Error("Server notification binding limit reached");
186
+ const response = await this.send(
187
+ grant.installationId,
188
+ grant.bindingId,
189
+ "redeem",
190
+ JSON.stringify({ grant: grant.grant })
191
+ );
192
+ if (!response.ok)
193
+ throw new Error("Notification enrolment failed; enable push again to obtain a fresh grant");
194
+ const state = Effect.runSync(this.store.pushState());
195
+ state.bindings = state.bindings.filter((binding) => binding.deviceId !== deviceId);
196
+ state.bindings.push({
197
+ installationId: grant.installationId,
198
+ bindingId: grant.bindingId,
199
+ deviceId
200
+ });
201
+ state.error = null;
202
+ await Effect.runPromise(this.store.savePushState(state));
203
+ return this.status(deviceId);
204
+ }
205
+ unbind(deviceId) {
206
+ const state = Effect.runSync(this.store.pushState());
207
+ state.bindings = state.bindings.filter((binding) => binding.deviceId !== deviceId);
208
+ Effect.runSync(this.store.savePushState(state));
209
+ }
210
+ start() {
211
+ this.timer = setInterval(() => void this.flush(), 5e3);
212
+ this.timer.unref();
213
+ void this.flush();
214
+ }
215
+ flush() {
216
+ if (this.stopped) return Promise.resolve();
217
+ this.draining ??= this.drain().catch(() => {
218
+ const state = Effect.runSync(this.store.pushState());
219
+ state.error = "Notification delivery temporarily unavailable";
220
+ Effect.runSync(this.store.savePushState(state));
221
+ }).finally(() => {
222
+ this.draining = void 0;
223
+ });
224
+ return this.draining;
225
+ }
226
+ async drain() {
227
+ for (const job of Effect.runSync(this.store.pendingPush())) {
228
+ if (job.createdAt + 36e5 < Date.now() || job.attempts >= 12) {
229
+ Effect.runSync(this.store.removePush(job.eventId));
230
+ continue;
231
+ }
232
+ if (job.nextAt > Date.now()) continue;
233
+ const remaining = [];
234
+ for (const binding of job.remaining) {
235
+ const state2 = Effect.runSync(this.store.pushState());
236
+ const device = Effect.runSync(this.store.devices()).find(
237
+ (device2) => device2.id === binding.deviceId
238
+ );
239
+ if (!device || device.revokedAt !== null || !state2.bindings.some((current) => current.bindingId === binding.bindingId))
240
+ continue;
241
+ try {
242
+ const response = await this.send(
243
+ binding.installationId,
244
+ binding.bindingId,
245
+ "events",
246
+ JSON.stringify({
247
+ eventId: job.eventId,
248
+ sessionId: job.sessionId,
249
+ kind: job.kind,
250
+ taskTitle: job.taskTitle,
251
+ prUrl: job.prUrl
252
+ })
253
+ );
254
+ if (response.ok) continue;
255
+ if ([401, 403, 404].includes(response.status)) {
256
+ this.unbind(binding.deviceId);
257
+ continue;
258
+ }
259
+ remaining.push(binding);
260
+ } catch {
261
+ remaining.push(binding);
262
+ }
263
+ }
264
+ if (!remaining.length) Effect.runSync(this.store.removePush(job.eventId));
265
+ else
266
+ Effect.runSync(
267
+ this.store.savePendingPush({
268
+ ...job,
269
+ remaining,
270
+ attempts: job.attempts + 1,
271
+ nextAt: Date.now() + Math.min(3e5, 5e3 * 2 ** job.attempts)
272
+ })
273
+ );
274
+ const state = Effect.runSync(this.store.pushState());
275
+ state.error = remaining.length ? "Notification delivery pending; check subscription and connectivity" : null;
276
+ Effect.runSync(this.store.savePushState(state));
277
+ }
278
+ }
279
+ async close() {
280
+ this.stopped = true;
281
+ clearInterval(this.timer);
282
+ await this.draining;
283
+ }
284
+ };
285
+ function registerPushRoutes(app2, push2, security2) {
286
+ function deviceId(authorization) {
287
+ const device = security2.authenticate((authorization ?? "").slice(7));
288
+ if (!device) throw new Error("Pair this device");
289
+ return device.id;
290
+ }
291
+ app2.get("/api/push", (request) => push2.status(deviceId(request.headers.authorization)));
292
+ app2.post("/api/push/bind", async (request, reply) => {
293
+ const grant = PushGrantSchema.safeParse(request.body);
294
+ if (!grant.success) return reply.code(400).send({ error: "Invalid notification enrolment" });
295
+ try {
296
+ return await push2.bind(grant.data, deviceId(request.headers.authorization));
297
+ } catch {
298
+ return reply.code(400).send({ error: "Could not enable notifications. Check your subscription and try again." });
299
+ }
300
+ });
301
+ app2.delete("/api/push/bind", (request) => {
302
+ push2.unbind(deviceId(request.headers.authorization));
303
+ return { disabled: true };
304
+ });
305
+ }
306
+
10
307
  // src/index.ts
11
308
  import Fastify from "fastify";
12
309
  import pretty from "pino-pretty";
@@ -15,17 +312,17 @@ import cors from "@fastify/cors";
15
312
  import multipart from "@fastify/multipart";
16
313
 
17
314
  // src/attachments.ts
18
- import { randomUUID } from "node:crypto";
315
+ import { randomUUID as randomUUID2 } from "node:crypto";
19
316
  import { createReadStream, createWriteStream } from "node:fs";
20
317
  import { mkdir, readFile, rm, stat, readdir } from "node:fs/promises";
21
- import path from "node:path";
318
+ import path2 from "node:path";
22
319
  import { pipeline } from "node:stream/promises";
23
320
  import sharp from "sharp";
24
- import { Context, Data, Effect, Layer } from "effect";
25
- import { z as z2 } from "zod";
321
+ import { Context, Data, Effect as Effect2, Layer } from "effect";
322
+ import { z as z3 } from "zod";
26
323
 
27
324
  // ../../packages/contracts/src/index.ts
28
- import { z } from "zod";
325
+ import { z as z2 } from "zod";
29
326
  var cronBounds = [
30
327
  [0, 59],
31
328
  [0, 23],
@@ -69,10 +366,10 @@ function cronMatches(expression, date) {
69
366
  const day = fields[2].wildcard || fields[4].wildcard ? matches[2] && matches[4] : matches[2] || matches[4];
70
367
  return Boolean(matches[0] && matches[1] && matches[3] && day);
71
368
  }
72
- var AutomationConfigSchema = z.object({
73
- enabled: z.boolean(),
74
- parallelism: z.number().int().min(1).max(32),
75
- cron: z.string().trim().min(1).max(100).superRefine((value, context) => {
369
+ var AutomationConfigSchema = z2.object({
370
+ enabled: z2.boolean(),
371
+ parallelism: z2.number().int().min(1).max(32),
372
+ cron: z2.string().trim().min(1).max(100).superRefine((value, context) => {
76
373
  try {
77
374
  cronFields(value);
78
375
  } catch (error) {
@@ -82,15 +379,15 @@ var AutomationConfigSchema = z.object({
82
379
  });
83
380
  }
84
381
  }),
85
- projectIds: z.array(z.string().min(1).max(200)).max(1e3).refine((ids) => new Set(ids).size === ids.length, "Projects must be unique.")
382
+ projectIds: z2.array(z2.string().min(1).max(200)).max(1e3).refine((ids) => new Set(ids).size === ids.length, "Projects must be unique.")
86
383
  });
87
- var AutomationStateSchema = z.object({
384
+ var AutomationStateSchema = z2.object({
88
385
  config: AutomationConfigSchema,
89
- order: z.array(z.string()),
90
- lastMinute: z.string().nullable(),
91
- error: z.string().nullable()
386
+ order: z2.array(z2.string()),
387
+ lastMinute: z2.string().nullable(),
388
+ error: z2.string().nullable()
92
389
  });
93
- var SessionStatusSchema = z.enum([
390
+ var SessionStatusSchema = z2.enum([
94
391
  "queued",
95
392
  "starting",
96
393
  "running",
@@ -99,36 +396,36 @@ var SessionStatusSchema = z.enum([
99
396
  "failed",
100
397
  "stopped"
101
398
  ]);
102
- var AgentKindSchema = z.enum(["pi"]);
103
- var ModelReferenceSchema = z.object({
104
- platformId: z.string().min(1).max(200),
105
- modelId: z.string().min(1).max(500)
399
+ var AgentKindSchema = z2.enum(["pi"]);
400
+ var ModelReferenceSchema = z2.object({
401
+ platformId: z2.string().min(1).max(200),
402
+ modelId: z2.string().min(1).max(500)
106
403
  });
107
404
  var ModelIdentitySchema = ModelReferenceSchema.extend({
108
- name: z.string(),
109
- platformName: z.string(),
110
- providerId: z.string(),
111
- providerName: z.string()
405
+ name: z2.string(),
406
+ platformName: z2.string(),
407
+ providerId: z2.string(),
408
+ providerName: z2.string()
112
409
  });
113
410
  var ModelOptionSchema = ModelIdentitySchema.extend({
114
- available: z.boolean(),
115
- reasoning: z.boolean(),
116
- input: z.array(z.enum(["text", "image"])),
117
- contextWindow: z.number()
411
+ available: z2.boolean(),
412
+ reasoning: z2.boolean(),
413
+ input: z2.array(z2.enum(["text", "image"])),
414
+ contextWindow: z2.number()
118
415
  });
119
- var ModelCatalogSchema = z.object({
120
- models: z.array(ModelOptionSchema),
416
+ var ModelCatalogSchema = z2.object({
417
+ models: z2.array(ModelOptionSchema),
121
418
  defaultModel: ModelIdentitySchema.nullable(),
122
- refreshedAt: z.string().datetime(),
123
- warning: z.string().nullable()
419
+ refreshedAt: z2.string().datetime(),
420
+ warning: z2.string().nullable()
124
421
  });
125
- var GitFlowSchema = z.object({
126
- workspace: z.enum(["checkout", "worktree"]),
127
- delivery: z.enum(["merge", "pull_request"])
422
+ var GitFlowSchema = z2.object({
423
+ workspace: z2.enum(["checkout", "worktree"]),
424
+ delivery: z2.enum(["merge", "pull_request"])
128
425
  });
129
- var GitDeliverySchema = z.object({
130
- status: z.enum(["pending", "waiting", "merged", "no_changes", "pr_opened", "failed"]),
131
- stage: z.enum([
426
+ var GitDeliverySchema = z2.object({
427
+ status: z2.enum(["pending", "waiting", "merged", "no_changes", "pr_opened", "failed"]),
428
+ stage: z2.enum([
132
429
  "validating",
133
430
  "drafting",
134
431
  "committing",
@@ -140,165 +437,165 @@ var GitDeliverySchema = z.object({
140
437
  "blocked",
141
438
  "delivered"
142
439
  ]).optional(),
143
- commitSha: z.string().optional(),
144
- attempt: z.number().int().positive().optional(),
145
- logs: z.string().optional(),
146
- url: z.string().url().optional(),
147
- message: z.string().optional()
440
+ commitSha: z2.string().optional(),
441
+ attempt: z2.number().int().positive().optional(),
442
+ logs: z2.string().optional(),
443
+ url: z2.string().url().optional(),
444
+ message: z2.string().optional()
148
445
  });
149
- var DeliveryRequestSchema = z.object({
150
- summary: z.string().trim().min(1).max(1e3),
151
- verification: z.array(z.string().max(1e3)).max(20).default([])
446
+ var DeliveryRequestSchema = z2.object({
447
+ summary: z2.string().trim().min(1).max(1e3),
448
+ verification: z2.array(z2.string().max(1e3)).max(20).default([])
152
449
  });
153
- var RunPreparationSchema = z.object({
154
- status: z.enum(["creating", "installing", "ready", "failed"]),
155
- message: z.string(),
156
- logs: z.string().optional()
450
+ var RunPreparationSchema = z2.object({
451
+ status: z2.enum(["creating", "installing", "ready", "failed"]),
452
+ message: z2.string(),
453
+ logs: z2.string().optional()
157
454
  });
158
- var SessionSchema = z.object({
159
- id: z.string(),
160
- taskId: z.string().nullable().optional(),
161
- projectId: z.string(),
162
- title: z.string(),
455
+ var SessionSchema = z2.object({
456
+ id: z2.string(),
457
+ taskId: z2.string().nullable().optional(),
458
+ projectId: z2.string(),
459
+ title: z2.string(),
163
460
  status: SessionStatusSchema,
164
461
  agent: AgentKindSchema,
165
462
  model: ModelIdentitySchema.optional(),
166
- branch: z.string().nullable(),
167
- workspacePath: z.string().optional(),
463
+ branch: z2.string().nullable(),
464
+ workspacePath: z2.string().optional(),
168
465
  gitFlow: GitFlowSchema.optional(),
169
466
  gitDelivery: GitDeliverySchema.optional(),
170
467
  preparation: RunPreparationSchema.optional(),
171
- createdAt: z.string().datetime(),
172
- updatedAt: z.string().datetime()
468
+ createdAt: z2.string().datetime(),
469
+ updatedAt: z2.string().datetime()
173
470
  });
174
471
  var MAX_ATTACHMENTS = 5;
175
472
  var MAX_ATTACHMENT_BYTES = 20 * 1024 * 1024;
176
- var AttachmentSchema = z.object({
177
- id: z.string().uuid(),
178
- projectId: z.string(),
179
- name: z.string(),
180
- mimeType: z.string(),
181
- size: z.number().int().nonnegative(),
182
- kind: z.enum(["image", "text"]),
183
- createdAt: z.string().datetime()
473
+ var AttachmentSchema = z2.object({
474
+ id: z2.string().uuid(),
475
+ projectId: z2.string(),
476
+ name: z2.string(),
477
+ mimeType: z2.string(),
478
+ size: z2.number().int().nonnegative(),
479
+ kind: z2.enum(["image", "text"]),
480
+ createdAt: z2.string().datetime()
184
481
  });
185
- var AttachmentIdsSchema = z.array(z.string().uuid()).max(MAX_ATTACHMENTS).refine((ids) => new Set(ids).size === ids.length, "Choose each attachment once.");
186
- var CreateSessionSchema = z.object({
187
- projectId: z.string().min(1),
188
- taskId: z.string().optional(),
189
- prompt: z.string().min(1),
482
+ var AttachmentIdsSchema = z2.array(z2.string().uuid()).max(MAX_ATTACHMENTS).refine((ids) => new Set(ids).size === ids.length, "Choose each attachment once.");
483
+ var CreateSessionSchema = z2.object({
484
+ projectId: z2.string().min(1),
485
+ taskId: z2.string().optional(),
486
+ prompt: z2.string().min(1),
190
487
  agent: AgentKindSchema.default("pi"),
191
488
  model: ModelReferenceSchema.optional(),
192
489
  gitFlow: GitFlowSchema.optional(),
193
490
  attachmentIds: AttachmentIdsSchema.optional()
194
491
  });
195
- var SendMessageSchema = z.object({
196
- message: z.string().trim().max(2e4),
492
+ var SendMessageSchema = z2.object({
493
+ message: z2.string().trim().max(2e4),
197
494
  attachmentIds: AttachmentIdsSchema.optional(),
198
- clientMessageId: z.string().min(1).max(120).optional()
495
+ clientMessageId: z2.string().min(1).max(120).optional()
199
496
  }).refine(
200
497
  (input) => input.message.length > 0 || (input.attachmentIds?.length ?? 0) > 0,
201
498
  "Write a message or attach a file."
202
499
  );
203
- var SessionEventSchema = z.discriminatedUnion("type", [
204
- z.object({ type: z.literal("session.updated"), session: SessionSchema }),
205
- z.object({
206
- type: z.literal("message.user"),
207
- sessionId: z.string(),
208
- messageId: z.string().optional(),
209
- text: z.string(),
210
- attachments: z.array(AttachmentSchema).optional()
500
+ var SessionEventSchema = z2.discriminatedUnion("type", [
501
+ z2.object({ type: z2.literal("session.updated"), session: SessionSchema }),
502
+ z2.object({
503
+ type: z2.literal("message.user"),
504
+ sessionId: z2.string(),
505
+ messageId: z2.string().optional(),
506
+ text: z2.string(),
507
+ attachments: z2.array(AttachmentSchema).optional()
211
508
  }),
212
- z.object({
213
- type: z.literal("message.started"),
509
+ z2.object({
510
+ type: z2.literal("message.started"),
214
511
  model: ModelIdentitySchema.optional(),
215
- sessionId: z.string(),
216
- messageId: z.string().optional()
512
+ sessionId: z2.string(),
513
+ messageId: z2.string().optional()
217
514
  }),
218
- z.object({
219
- type: z.literal("message.delta"),
515
+ z2.object({
516
+ type: z2.literal("message.delta"),
220
517
  model: ModelIdentitySchema.optional(),
221
- sessionId: z.string(),
222
- messageId: z.string().optional(),
223
- text: z.string()
518
+ sessionId: z2.string(),
519
+ messageId: z2.string().optional(),
520
+ text: z2.string()
224
521
  }),
225
- z.object({
226
- type: z.literal("message.completed"),
522
+ z2.object({
523
+ type: z2.literal("message.completed"),
227
524
  model: ModelIdentitySchema.optional(),
228
- sessionId: z.string(),
229
- messageId: z.string().optional(),
230
- text: z.string()
525
+ sessionId: z2.string(),
526
+ messageId: z2.string().optional(),
527
+ text: z2.string()
231
528
  }),
232
- z.object({
233
- type: z.literal("tool.started"),
234
- sessionId: z.string(),
235
- toolCallId: z.string().optional(),
236
- tool: z.string(),
237
- input: z.string()
529
+ z2.object({
530
+ type: z2.literal("tool.started"),
531
+ sessionId: z2.string(),
532
+ toolCallId: z2.string().optional(),
533
+ tool: z2.string(),
534
+ input: z2.string()
238
535
  }),
239
- z.object({
240
- type: z.literal("tool.completed"),
241
- sessionId: z.string(),
242
- toolCallId: z.string().optional(),
243
- tool: z.string(),
244
- output: z.string(),
245
- isError: z.boolean().default(false)
536
+ z2.object({
537
+ type: z2.literal("tool.completed"),
538
+ sessionId: z2.string(),
539
+ toolCallId: z2.string().optional(),
540
+ tool: z2.string(),
541
+ output: z2.string(),
542
+ isError: z2.boolean().default(false)
246
543
  }),
247
- z.object({
248
- type: z.literal("activity.updated"),
249
- sessionId: z.string(),
250
- state: z.enum(["thinking", "responding", "idle"])
544
+ z2.object({
545
+ type: z2.literal("activity.updated"),
546
+ sessionId: z2.string(),
547
+ state: z2.enum(["thinking", "responding", "idle"])
251
548
  }),
252
- z.object({ type: z.literal("error"), sessionId: z.string(), message: z.string() })
549
+ z2.object({ type: z2.literal("error"), sessionId: z2.string(), message: z2.string() })
253
550
  ]);
254
- var RecordedEventSchema = z.object({
255
- sequence: z.number().int().positive(),
256
- recordedAt: z.string().datetime(),
551
+ var RecordedEventSchema = z2.object({
552
+ sequence: z2.number().int().positive(),
553
+ recordedAt: z2.string().datetime(),
257
554
  event: SessionEventSchema
258
555
  });
259
- var EventHistorySchema = z.object({
260
- events: z.array(RecordedEventSchema),
261
- cursor: z.number().int().nonnegative(),
262
- hasMore: z.boolean()
556
+ var EventHistorySchema = z2.object({
557
+ events: z2.array(RecordedEventSchema),
558
+ cursor: z2.number().int().nonnegative(),
559
+ hasMore: z2.boolean()
263
560
  });
264
- var ServerEventSchema = z.union([
265
- z.object({ type: z.literal("snapshot"), sessions: z.array(SessionSchema) }),
266
- z.object({ type: z.literal("event"), record: RecordedEventSchema })
561
+ var ServerEventSchema = z2.union([
562
+ z2.object({ type: z2.literal("snapshot"), sessions: z2.array(SessionSchema) }),
563
+ z2.object({ type: z2.literal("event"), record: RecordedEventSchema })
267
564
  ]);
268
- var ServerSettingsSchema = z.object({
269
- projectsRoot: z.string().min(1),
565
+ var ServerSettingsSchema = z2.object({
566
+ projectsRoot: z2.string().min(1),
270
567
  defaultModel: ModelIdentitySchema.nullable().optional()
271
568
  });
272
- var ConnectionsStatusSchema = z.object({
273
- githubCli: z.object({ installed: z.boolean(), authenticated: z.boolean() })
569
+ var ConnectionsStatusSchema = z2.object({
570
+ githubCli: z2.object({ installed: z2.boolean(), authenticated: z2.boolean() })
274
571
  });
275
- var UpdateSettingsSchema = z.object({ projectsRoot: z.string().trim().min(1).max(4096) });
276
- var ProjectSchema = z.object({
277
- id: z.string(),
278
- name: z.string(),
279
- path: z.string(),
280
- available: z.boolean().default(true)
572
+ var UpdateSettingsSchema = z2.object({ projectsRoot: z2.string().trim().min(1).max(4096) });
573
+ var ProjectSchema = z2.object({
574
+ id: z2.string(),
575
+ name: z2.string(),
576
+ path: z2.string(),
577
+ available: z2.boolean().default(true)
281
578
  });
282
- var TaskStatusSchema = z.enum(["backlog", "needs_user_input", "running", "done"]);
283
- var TaskSchema = z.object({
284
- id: z.string(),
285
- projectId: z.string(),
286
- title: z.string(),
287
- prompt: z.string(),
288
- attachments: z.array(AttachmentSchema).optional(),
579
+ var TaskStatusSchema = z2.enum(["backlog", "needs_user_input", "running", "done"]);
580
+ var TaskSchema = z2.object({
581
+ id: z2.string(),
582
+ projectId: z2.string(),
583
+ title: z2.string(),
584
+ prompt: z2.string(),
585
+ attachments: z2.array(AttachmentSchema).optional(),
289
586
  model: ModelReferenceSchema.optional(),
290
587
  gitFlow: GitFlowSchema.optional(),
291
588
  status: TaskStatusSchema,
292
- sessionId: z.string().nullable(),
293
- pullRequestUrl: z.string().url().optional(),
294
- createdAt: z.string().datetime(),
295
- updatedAt: z.string().datetime(),
296
- deletedAt: z.string().datetime().optional()
589
+ sessionId: z2.string().nullable(),
590
+ pullRequestUrl: z2.string().url().optional(),
591
+ createdAt: z2.string().datetime(),
592
+ updatedAt: z2.string().datetime(),
593
+ deletedAt: z2.string().datetime().optional()
297
594
  });
298
- var CreateTaskSchema = z.object({
299
- projectId: z.string().min(1),
300
- title: z.string().trim().min(1).max(120),
301
- prompt: z.string().trim().max(2e4).default(""),
595
+ var CreateTaskSchema = z2.object({
596
+ projectId: z2.string().min(1),
597
+ title: z2.string().trim().min(1).max(120),
598
+ prompt: z2.string().trim().max(2e4).default(""),
302
599
  attachmentIds: AttachmentIdsSchema.optional(),
303
600
  model: ModelReferenceSchema.optional(),
304
601
  gitFlow: GitFlowSchema.optional()
@@ -310,57 +607,62 @@ var UpdateTaskSchema = CreateTaskSchema.pick({
310
607
  gitFlow: true
311
608
  }).extend({ gitFlow: GitFlowSchema.nullable().optional() }).strict();
312
609
  var PROTOCOL_VERSION = 1;
313
- var ServerUpdateStatusSchema = z.object({
314
- currentVersion: z.string(),
315
- latestVersion: z.string().optional(),
316
- available: z.boolean(),
317
- published: z.boolean().nullable(),
318
- canAutoUpdate: z.literal(false),
319
- checkedAt: z.string().datetime().nullable(),
320
- error: z.string().nullable()
610
+ var ServerUpdateStatusSchema = z2.object({
611
+ currentVersion: z2.string(),
612
+ latestVersion: z2.string().optional(),
613
+ available: z2.boolean(),
614
+ published: z2.boolean().nullable(),
615
+ canAutoUpdate: z2.literal(false),
616
+ checkedAt: z2.string().datetime().nullable(),
617
+ error: z2.string().nullable()
321
618
  });
322
- var ServerHealthSchema = z.object({
323
- status: z.literal("ok"),
324
- service: z.literal("agentrific-server"),
325
- serverId: z.string().uuid(),
326
- serverVersion: z.string(),
327
- protocolVersion: z.literal(PROTOCOL_VERSION),
328
- authentication: z.literal("pairing")
619
+ var ServerHealthSchema = z2.object({
620
+ status: z2.literal("ok"),
621
+ service: z2.literal("agentrific-server"),
622
+ serverId: z2.string().uuid(),
623
+ serverVersion: z2.string(),
624
+ protocolVersion: z2.literal(PROTOCOL_VERSION),
625
+ authentication: z2.literal("pairing")
329
626
  });
330
- var PairingPayloadSchema = z.object({
331
- v: z.literal(1),
332
- url: z.string().url(),
333
- serverId: z.string().uuid(),
334
- pairingId: z.string().regex(/^[a-f0-9]{64}$/)
627
+ var PairingPayloadSchema = z2.object({
628
+ v: z2.literal(1),
629
+ url: z2.string().url(),
630
+ serverId: z2.string().uuid(),
631
+ pairingId: z2.string().regex(/^[a-f0-9]{64}$/)
335
632
  });
336
- var PairingRequestSchema = z.object({
337
- pairingId: z.string().regex(/^[a-f0-9]{64}$/),
338
- pin: z.string().regex(/^\d{6}$/),
339
- deviceName: z.string().trim().min(1).max(100),
340
- platform: z.enum(["ios", "android", "web"])
633
+ var PairingInvitationSchema = z2.object({
634
+ payload: PairingPayloadSchema,
635
+ pin: z2.string().regex(/^\d{6}$/),
636
+ expiresAt: z2.number()
341
637
  });
342
- var DeviceSchema = z.object({
343
- id: z.string().uuid(),
344
- name: z.string(),
345
- platform: z.enum(["ios", "android", "web"]),
346
- createdAt: z.number(),
347
- lastSeenAt: z.number(),
348
- revokedAt: z.number().nullable()
638
+ var PairingRequestSchema = z2.object({
639
+ pairingId: z2.string().regex(/^[a-f0-9]{64}$/),
640
+ pin: z2.string().regex(/^\d{6}$/),
641
+ deviceName: z2.string().trim().min(1).max(100),
642
+ platform: z2.enum(["ios", "android", "web"])
349
643
  });
350
- var PairingResultSchema = z.object({
351
- deviceId: z.string().uuid(),
352
- deviceToken: z.string().regex(/^[a-f0-9]{64}$/),
353
- serverId: z.string().uuid()
644
+ var DeviceSchema = z2.object({
645
+ id: z2.string().uuid(),
646
+ name: z2.string(),
647
+ platform: z2.enum(["ios", "android", "web"]),
648
+ createdAt: z2.number(),
649
+ lastSeenAt: z2.number(),
650
+ revokedAt: z2.number().nullable()
354
651
  });
355
- var HandshakeSchema = z.object({
356
- type: z.literal("client.handshake"),
357
- ticket: z.string().regex(/^[a-f0-9]{64}$/),
358
- protocolVersion: z.literal(PROTOCOL_VERSION)
652
+ var PairingResultSchema = z2.object({
653
+ deviceId: z2.string().uuid(),
654
+ deviceToken: z2.string().regex(/^[a-f0-9]{64}$/),
655
+ serverId: z2.string().uuid()
359
656
  });
360
- var HandshakeAckSchema = z.object({
361
- type: z.literal("handshake.ack"),
362
- serverId: z.string().uuid(),
363
- protocolVersion: z.literal(PROTOCOL_VERSION)
657
+ var HandshakeSchema = z2.object({
658
+ type: z2.literal("client.handshake"),
659
+ ticket: z2.string().regex(/^[a-f0-9]{64}$/),
660
+ protocolVersion: z2.literal(PROTOCOL_VERSION)
661
+ });
662
+ var HandshakeAckSchema = z2.object({
663
+ type: z2.literal("handshake.ack"),
664
+ serverId: z2.string().uuid(),
665
+ protocolVersion: z2.literal(PROTOCOL_VERSION)
364
666
  });
365
667
 
366
668
  // src/attachments.ts
@@ -413,10 +715,10 @@ var AttachmentFiles = class {
413
715
  root;
414
716
  store;
415
717
  directory(id) {
416
- return path.join(this.root, z2.string().uuid().parse(id));
718
+ return path2.join(this.root, z3.string().uuid().parse(id));
417
719
  }
418
720
  io(operation) {
419
- return Effect.tryPromise({
721
+ return Effect2.tryPromise({
420
722
  try: operation,
421
723
  catch: (cause) => new AttachmentError({
422
724
  message: cause instanceof Error ? cause.message : "Attachment operation failed."
@@ -425,12 +727,12 @@ var AttachmentFiles = class {
425
727
  }
426
728
  upload(projectId, part) {
427
729
  return this.io(async () => {
428
- const id = randomUUID();
730
+ const id = randomUUID2();
429
731
  const directory = this.directory(id);
430
- const source = path.join(directory, "source");
431
- const nameField = z2.object({ type: z2.literal("field"), value: z2.string() }).safeParse(part.fields.name);
732
+ const source = path2.join(directory, "source");
733
+ const nameField = z3.object({ type: z3.literal("field"), value: z3.string() }).safeParse(part.fields.name);
432
734
  const originalName = nameField.success ? nameField.data.value : part.filename;
433
- const name = path.basename(originalName.replaceAll("\\", "/")).replace(new RegExp("\\p{Cc}", "gu"), "").slice(0, 180) || "attachment";
735
+ const name = path2.basename(originalName.replaceAll("\\", "/")).replace(new RegExp("\\p{Cc}", "gu"), "").slice(0, 180) || "attachment";
434
736
  await mkdir(directory, { recursive: true });
435
737
  try {
436
738
  await pipeline(part.file, createWriteStream(source, { flags: "wx" }));
@@ -446,7 +748,7 @@ var AttachmentFiles = class {
446
748
  ...metadata,
447
749
  createdAt: (/* @__PURE__ */ new Date()).toISOString()
448
750
  });
449
- await Effect.runPromise(this.store.saveAttachment(attachment));
751
+ await Effect2.runPromise(this.store.saveAttachment(attachment));
450
752
  return attachment;
451
753
  } catch (error) {
452
754
  await rm(directory, { recursive: true, force: true });
@@ -458,10 +760,10 @@ var AttachmentFiles = class {
458
760
  const metadata = await sharp(source, { limitInputPixels: 4e7 }).metadata().catch(() => null);
459
761
  const mimeType = metadata?.format && imageMimes.get(metadata.format);
460
762
  if (mimeType) {
461
- await sharp(source, { limitInputPixels: 4e7 }).rotate().resize({ width: 2048, height: 2048, fit: "inside", withoutEnlargement: true }).png().toFile(path.join(path.dirname(source), "preview.png"));
763
+ await sharp(source, { limitInputPixels: 4e7 }).rotate().resize({ width: 2048, height: 2048, fit: "inside", withoutEnlargement: true }).png().toFile(path2.join(path2.dirname(source), "preview.png"));
462
764
  return { kind: "image", mimeType };
463
765
  }
464
- if (!textExtensions.has(path.extname(name).toLowerCase()))
766
+ if (!textExtensions.has(path2.extname(name).toLowerCase()))
465
767
  throw new Error(
466
768
  "Choose a PNG, JPEG, WebP, GIF, or text/code file. PDF and Office documents aren't supported yet."
467
769
  );
@@ -477,12 +779,12 @@ var AttachmentFiles = class {
477
779
  throw new Error("Choose up to five different files.");
478
780
  const attachments2 = [];
479
781
  for (const id of ids) {
480
- const attachment = await Effect.runPromise(this.store.getAttachment(id));
782
+ const attachment = await Effect2.runPromise(this.store.getAttachment(id));
481
783
  if (!attachment || attachment.projectId !== projectId)
482
784
  throw new Error(
483
785
  "An attachment is unavailable for this project. Remove it and upload it again."
484
786
  );
485
- await stat(path.join(this.directory(id), "source"));
787
+ await stat(path2.join(this.directory(id), "source"));
486
788
  attachments2.push(attachment);
487
789
  }
488
790
  return attachments2;
@@ -490,7 +792,7 @@ var AttachmentFiles = class {
490
792
  }
491
793
  content(attachment, preview) {
492
794
  return createReadStream(
493
- path.join(
795
+ path2.join(
494
796
  this.directory(attachment.id),
495
797
  preview && attachment.kind === "image" ? "preview.png" : "source"
496
798
  )
@@ -501,9 +803,9 @@ var AttachmentFiles = class {
501
803
  const images = [];
502
804
  const sections = [];
503
805
  for (const attachment of attachments2) {
504
- const source = path.join(this.directory(attachment.id), "source");
806
+ const source = path2.join(this.directory(attachment.id), "source");
505
807
  if (attachment.kind === "image") {
506
- const bytes = await readFile(path.join(this.directory(attachment.id), "preview.png"));
808
+ const bytes = await readFile(path2.join(this.directory(attachment.id), "preview.png"));
507
809
  images.push({ type: "image", data: bytes.toString("base64"), mimeType: "image/png" });
508
810
  sections.push(`Image attachment: ${JSON.stringify(attachment.name)}`);
509
811
  } else {
@@ -526,18 +828,18 @@ ${text.slice(0, 12e3)}${text.length > 12e3 ? "\n[Preview truncated; use the read
526
828
  cleanup() {
527
829
  return this.io(async () => {
528
830
  const cutoff = Date.now() - 24 * 60 * 60 * 1e3;
529
- const expired = await Effect.runPromise(
831
+ const expired = await Effect2.runPromise(
530
832
  this.store.expiredAttachments(new Date(cutoff).toISOString())
531
833
  );
532
834
  for (const attachment of expired) {
533
- if (await Effect.runPromise(this.store.deleteAttachment(attachment.id)))
835
+ if (await Effect2.runPromise(this.store.deleteAttachment(attachment.id)))
534
836
  await rm(this.directory(attachment.id), { recursive: true, force: true });
535
837
  }
536
838
  await mkdir(this.root, { recursive: true });
537
839
  for (const entry of await readdir(this.root, { withFileTypes: true })) {
538
- if (!entry.isDirectory() || !z2.string().uuid().safeParse(entry.name).success) continue;
840
+ if (!entry.isDirectory() || !z3.string().uuid().safeParse(entry.name).success) continue;
539
841
  const directory = this.directory(entry.name);
540
- if ((await stat(directory)).mtimeMs < cutoff && !await Effect.runPromise(this.store.getAttachment(entry.name)))
842
+ if ((await stat(directory)).mtimeMs < cutoff && !await Effect2.runPromise(this.store.getAttachment(entry.name)))
541
843
  await rm(directory, { recursive: true, force: true });
542
844
  }
543
845
  });
@@ -552,12 +854,12 @@ var Attachments = class extends Context.Service()(
552
854
  };
553
855
 
554
856
  // src/index.ts
555
- import { randomUUID as randomUUID7 } from "node:crypto";
857
+ import { randomUUID as randomUUID8 } from "node:crypto";
556
858
  import { execFile as execFile2 } from "node:child_process";
557
859
  import { promisify as promisify2 } from "node:util";
558
860
  import { realpath as realpath2 } from "node:fs/promises";
559
861
  import { homedir as homedir2 } from "node:os";
560
- import path7 from "node:path";
862
+ import path9 from "node:path";
561
863
  import { WebSocket } from "ws";
562
864
 
563
865
  // src/event-server.ts
@@ -624,153 +926,18 @@ function attachEventServer(app2, security2, origins, snapshot) {
624
926
  }
625
927
 
626
928
  // src/security.ts
627
- import { createHash, randomBytes, randomInt, randomUUID as randomUUID2, timingSafeEqual } from "node:crypto";
628
- import { Effect as Effect2 } from "effect";
629
- import { z as z3 } from "zod";
630
- var secret = () => randomBytes(32).toString("hex");
631
- var digest = (value) => createHash("sha256").update(value).digest("hex");
632
- var ServerSecurity = class {
633
- constructor(store2, serverId) {
634
- this.store = store2;
635
- this.serverId = serverId;
636
- }
637
- store;
638
- serverId;
639
- grants = /* @__PURE__ */ new Map();
640
- tickets = /* @__PURE__ */ new Map();
641
- attempts = /* @__PURE__ */ new Map();
642
- sockets = /* @__PURE__ */ new Map();
643
- prune() {
644
- const now = Date.now();
645
- for (const [key, grant] of this.grants) if (grant.expiresAt <= now) this.grants.delete(key);
646
- for (const [key, ticket] of this.tickets) if (ticket.expiresAt <= now) this.tickets.delete(key);
647
- for (const [key, attempts] of this.attempts)
648
- if (attempts.until <= now) this.attempts.delete(key);
649
- }
650
- createPairing() {
651
- this.prune();
652
- if (this.grants.size >= 10)
653
- throw new Error("Too many pending enrollments. Wait for them to expire.");
654
- const pairingId = secret();
655
- const grant = {
656
- pin: String(randomInt(1e6)).padStart(6, "0"),
657
- expiresAt: Date.now() + 3e5,
658
- attempts: 0
659
- };
660
- this.grants.set(pairingId, grant);
661
- return { pairingId, pin: grant.pin, expiresAt: grant.expiresAt, serverId: this.serverId };
662
- }
663
- async pair(input, address) {
664
- this.prune();
665
- const attempts = this.attempts.get(address) ?? { count: 0, until: Date.now() + 6e4 };
666
- if (attempts.count >= 20 || !this.attempts.has(address) && this.attempts.size >= 5e3)
667
- return void 0;
668
- attempts.count++;
669
- this.attempts.set(address, attempts);
670
- const grant = this.grants.get(input.pairingId);
671
- if (!grant) return void 0;
672
- grant.attempts++;
673
- const match = timingSafeEqual(Buffer.from(grant.pin), Buffer.from(input.pin));
674
- if (grant.attempts >= 3 || match) this.grants.delete(input.pairingId);
675
- if (!match) return void 0;
676
- const token = secret();
677
- const now = Date.now();
678
- const device = {
679
- id: randomUUID2(),
680
- name: input.deviceName,
681
- platform: input.platform,
682
- createdAt: now,
683
- lastSeenAt: now,
684
- revokedAt: null
685
- };
686
- await Effect2.runPromise(this.store.saveDevice(device, digest(token)));
687
- return { deviceId: device.id, deviceToken: token, serverId: this.serverId };
688
- }
689
- authenticate(token) {
690
- if (!/^[a-f0-9]{64}$/.test(token)) return void 0;
691
- return Effect2.runSync(this.store.deviceForToken(digest(token)));
692
- }
693
- ticket(token, path8) {
694
- this.prune();
695
- if (!this.authenticate(token) || this.tickets.size >= 1e4) return void 0;
696
- const value = secret();
697
- this.tickets.set(value, { tokenHash: digest(token), expiresAt: Date.now() + 6e4, path: path8 });
698
- return value;
699
- }
700
- redeemTicket(value, path8, consume = true) {
701
- this.prune();
702
- const ticket = this.tickets.get(value);
703
- if (!ticket || ticket.path !== path8) return void 0;
704
- if (consume) this.tickets.delete(value);
705
- return Effect2.runSync(this.store.deviceForToken(ticket.tokenHash));
706
- }
707
- async revoke(id) {
708
- const revoked = await Effect2.runPromise(this.store.revokeDevice(id));
709
- for (const [socket, owner] of this.sockets)
710
- if (owner === id) socket.close(4401, "Device revoked");
711
- return revoked;
712
- }
713
- };
714
- async function registerSecurity(app2, security2, version, allowedOrigins2) {
715
- app2.addHook("onRequest", async (request, reply) => {
716
- const path8 = new URL(request.url, "http://localhost");
717
- reply.header("Cache-Control", "no-store");
718
- if (request.method === "OPTIONS") return;
719
- if (request.headers.origin && !allowedOrigins2.includes(request.headers.origin))
720
- return reply.code(403).send({ error: "Browser origin is not allowed." });
721
- if (path8.pathname === "/health" || path8.pathname === "/api/auth/pair" && request.method === "POST")
722
- return;
723
- const authorization = request.headers.authorization ?? "";
724
- const token = authorization.startsWith("Bearer ") ? authorization.slice(7) : "";
725
- const asset = request.method === "GET" && /^\/api\/projects\/[^/]+\/attachments\/[^/]+\/content$/.test(path8.pathname);
726
- const ticket = path8.searchParams.get("ticket");
727
- path8.searchParams.delete("ticket");
728
- if (!security2.authenticate(token) && !(asset && ticket && security2.redeemTicket(ticket, path8.pathname + path8.search, false)))
729
- return reply.code(401).send({ error: "Pair this device with your server.", code: "UNAUTHORIZED" });
730
- if (!asset && request.headers["x-agentrific-protocol"] !== String(PROTOCOL_VERSION))
731
- return reply.code(409).send({
732
- error: "Update your Agentrific client or server.",
733
- code: "VERSION_MISMATCH",
734
- protocolVersion: PROTOCOL_VERSION
735
- });
736
- });
737
- app2.get("/health", () => ({
738
- status: "ok",
739
- service: "agentrific-server",
740
- serverId: security2.serverId,
741
- serverVersion: version,
742
- protocolVersion: PROTOCOL_VERSION,
743
- authentication: "pairing"
744
- }));
745
- app2.post("/api/auth/pair", async (request, reply) => {
746
- const input = PairingRequestSchema.safeParse(request.body);
747
- if (!input.success)
748
- return reply.code(400).send({ error: "Enter a valid pairing payload and six-digit PIN." });
749
- const result = await security2.pair(input.data, request.ip);
750
- return result ?? reply.code(401).send({ error: "Pairing expired, locked, or invalid. Run agentrific pair again." });
751
- });
752
- app2.get("/api/auth/session", (request) => ({
753
- serverId: security2.serverId,
754
- device: security2.authenticate((request.headers.authorization ?? "").slice(7))
755
- }));
756
- app2.post("/api/auth/event-ticket", (request) => ({
757
- ticket: security2.ticket((request.headers.authorization ?? "").slice(7), "/api/events")
758
- }));
759
- app2.post("/api/auth/asset-ticket", (request, reply) => {
760
- const input = z3.object({
761
- path: z3.string().regex(/^\/api\/projects\/[^/?]+\/attachments\/[^/?]+\/content(?:\?preview=1)?$/)
762
- }).safeParse(request.body);
763
- if (!input.success) return reply.code(400).send({ error: "Choose a valid attachment." });
764
- return {
765
- ticket: security2.ticket((request.headers.authorization ?? "").slice(7), input.data.path)
766
- };
767
- });
768
- }
929
+ import { createHash, randomBytes, randomInt, randomUUID as randomUUID3, timingSafeEqual } from "node:crypto";
930
+ import { Effect as Effect4 } from "effect";
931
+ import { z as z6 } from "zod";
932
+
933
+ // src/pairing.ts
934
+ import { z as z5 } from "zod";
935
+ import qrcode from "qrcode-terminal";
769
936
 
770
937
  // src/control.ts
771
938
  import { createServer, createConnection } from "node:net";
772
939
  import { chmod, mkdir as mkdir2, readFile as readFile2, unlink, writeFile } from "node:fs/promises";
773
- import path2 from "node:path";
940
+ import path3 from "node:path";
774
941
  import { Effect as Effect3 } from "effect";
775
942
  import { z as z4 } from "zod";
776
943
  var MaintenanceSchema = z4.object({
@@ -814,7 +981,7 @@ var ControlRequest = z4.discriminatedUnion("command", [
814
981
  async function acquireServerLock(directory) {
815
982
  await mkdir2(directory, { recursive: true, mode: 448 });
816
983
  await chmod(directory, 448);
817
- const filename = path2.join(directory, "server.lock");
984
+ const filename = path3.join(directory, "server.lock");
818
985
  try {
819
986
  await writeFile(filename, String(process.pid), { flag: "wx", mode: 384 });
820
987
  } catch (cause) {
@@ -853,7 +1020,7 @@ async function acquireServerLock(directory) {
853
1020
  };
854
1021
  }
855
1022
  async function startControl(directory, security2, status, maintenance2) {
856
- const filename = path2.join(directory, "control.sock");
1023
+ const filename = path3.join(directory, "control.sock");
857
1024
  await unlink(filename).catch((error) => {
858
1025
  if (!z4.object({ code: z4.literal("ENOENT") }).safeParse(error).success) throw error;
859
1026
  });
@@ -914,7 +1081,7 @@ async function startControl(directory, security2, status, maintenance2) {
914
1081
  // package.json
915
1082
  var package_default = {
916
1083
  name: "@agentrific/server",
917
- version: "0.1.4",
1084
+ version: "0.1.6",
918
1085
  description: "Self-hosted control plane for long-running coding agents",
919
1086
  keywords: [
920
1087
  "agentrific",
@@ -977,6 +1144,180 @@ var package_default = {
977
1144
  // src/version.ts
978
1145
  var serverVersion = package_default.version;
979
1146
 
1147
+ // src/pairing.ts
1148
+ function pairingOrigin(url) {
1149
+ const endpoint = new URL(url);
1150
+ if (endpoint.username || endpoint.password || endpoint.search || endpoint.hash || endpoint.pathname !== "/")
1151
+ throw new Error("Use the HTTPS server origin without a path or credentials.");
1152
+ if (endpoint.protocol !== "https:" && !(process.env.NODE_ENV !== "production" && endpoint.protocol === "http:" && ["127.0.0.1", "localhost", "[::1]"].includes(endpoint.hostname)))
1153
+ throw new Error("Use HTTPS. Localhost HTTP is for development builds only.");
1154
+ return endpoint.origin;
1155
+ }
1156
+
1157
+ // src/security.ts
1158
+ var secret = () => randomBytes(32).toString("hex");
1159
+ var digest = (value) => createHash("sha256").update(value).digest("hex");
1160
+ var ServerSecurity = class {
1161
+ constructor(store2, serverId) {
1162
+ this.store = store2;
1163
+ this.serverId = serverId;
1164
+ }
1165
+ store;
1166
+ serverId;
1167
+ grants = /* @__PURE__ */ new Map();
1168
+ tickets = /* @__PURE__ */ new Map();
1169
+ attempts = /* @__PURE__ */ new Map();
1170
+ sockets = /* @__PURE__ */ new Map();
1171
+ prune() {
1172
+ const now = Date.now();
1173
+ for (const [key, grant] of this.grants) if (grant.expiresAt <= now) this.grants.delete(key);
1174
+ for (const [key, ticket] of this.tickets) if (ticket.expiresAt <= now) this.tickets.delete(key);
1175
+ for (const [key, attempts] of this.attempts)
1176
+ if (attempts.until <= now) this.attempts.delete(key);
1177
+ }
1178
+ createPairing() {
1179
+ this.prune();
1180
+ if (this.grants.size >= 10)
1181
+ throw new Error("Too many pending enrollments. Wait for them to expire.");
1182
+ const pairingId = secret();
1183
+ const grant = {
1184
+ pin: String(randomInt(1e6)).padStart(6, "0"),
1185
+ expiresAt: Date.now() + 3e5,
1186
+ attempts: 0
1187
+ };
1188
+ this.grants.set(pairingId, grant);
1189
+ return { pairingId, pin: grant.pin, expiresAt: grant.expiresAt, serverId: this.serverId };
1190
+ }
1191
+ async pair(input, address) {
1192
+ this.prune();
1193
+ const attempts = this.attempts.get(address) ?? { count: 0, until: Date.now() + 6e4 };
1194
+ if (attempts.count >= 20 || !this.attempts.has(address) && this.attempts.size >= 5e3)
1195
+ return void 0;
1196
+ attempts.count++;
1197
+ this.attempts.set(address, attempts);
1198
+ const grant = this.grants.get(input.pairingId);
1199
+ if (!grant) return void 0;
1200
+ grant.attempts++;
1201
+ const match = timingSafeEqual(Buffer.from(grant.pin), Buffer.from(input.pin));
1202
+ if (grant.attempts >= 3 || match) this.grants.delete(input.pairingId);
1203
+ if (!match) return void 0;
1204
+ const token = secret();
1205
+ const now = Date.now();
1206
+ const device = {
1207
+ id: randomUUID3(),
1208
+ name: input.deviceName,
1209
+ platform: input.platform,
1210
+ createdAt: now,
1211
+ lastSeenAt: now,
1212
+ revokedAt: null
1213
+ };
1214
+ await Effect4.runPromise(this.store.saveDevice(device, digest(token)));
1215
+ return { deviceId: device.id, deviceToken: token, serverId: this.serverId };
1216
+ }
1217
+ authenticate(token) {
1218
+ if (!/^[a-f0-9]{64}$/.test(token)) return void 0;
1219
+ return Effect4.runSync(this.store.deviceForToken(digest(token)));
1220
+ }
1221
+ ticket(token, path10) {
1222
+ this.prune();
1223
+ if (!this.authenticate(token) || this.tickets.size >= 1e4) return void 0;
1224
+ const value = secret();
1225
+ this.tickets.set(value, { tokenHash: digest(token), expiresAt: Date.now() + 6e4, path: path10 });
1226
+ return value;
1227
+ }
1228
+ redeemTicket(value, path10, consume = true) {
1229
+ this.prune();
1230
+ const ticket = this.tickets.get(value);
1231
+ if (!ticket || ticket.path !== path10) return void 0;
1232
+ if (consume) this.tickets.delete(value);
1233
+ return Effect4.runSync(this.store.deviceForToken(ticket.tokenHash));
1234
+ }
1235
+ async revoke(id) {
1236
+ const revoked = await Effect4.runPromise(this.store.revokeDevice(id));
1237
+ for (const [socket, owner] of this.sockets)
1238
+ if (owner === id) socket.close(4401, "Device revoked");
1239
+ return revoked;
1240
+ }
1241
+ };
1242
+ async function registerSecurity(app2, security2, version, allowedOrigins2) {
1243
+ app2.addHook("onRequest", async (request, reply) => {
1244
+ const path10 = new URL(request.url, "http://localhost");
1245
+ reply.header("Cache-Control", "no-store");
1246
+ if (request.method === "OPTIONS") return;
1247
+ if (request.headers.origin && !allowedOrigins2.includes(request.headers.origin))
1248
+ return reply.code(403).send({ error: "Browser origin is not allowed." });
1249
+ if (path10.pathname === "/health" || path10.pathname === "/api/auth/pair" && request.method === "POST")
1250
+ return;
1251
+ const authorization = request.headers.authorization ?? "";
1252
+ const token = authorization.startsWith("Bearer ") ? authorization.slice(7) : "";
1253
+ const asset = request.method === "GET" && /^\/api\/projects\/[^/]+\/attachments\/[^/]+\/content$/.test(path10.pathname);
1254
+ const ticket = path10.searchParams.get("ticket");
1255
+ path10.searchParams.delete("ticket");
1256
+ if (!security2.authenticate(token) && !(asset && ticket && security2.redeemTicket(ticket, path10.pathname + path10.search, false)))
1257
+ return reply.code(401).send({ error: "Pair this device with your server.", code: "UNAUTHORIZED" });
1258
+ if (!asset && request.headers["x-agentrific-protocol"] !== String(PROTOCOL_VERSION))
1259
+ return reply.code(409).send({
1260
+ error: "Update your Agentrific client or server.",
1261
+ code: "VERSION_MISMATCH",
1262
+ protocolVersion: PROTOCOL_VERSION
1263
+ });
1264
+ });
1265
+ app2.get("/health", () => ({
1266
+ status: "ok",
1267
+ service: "agentrific-server",
1268
+ serverId: security2.serverId,
1269
+ serverVersion: version,
1270
+ protocolVersion: PROTOCOL_VERSION,
1271
+ authentication: "pairing"
1272
+ }));
1273
+ app2.post("/api/auth/pair", async (request, reply) => {
1274
+ const input = PairingRequestSchema.safeParse(request.body);
1275
+ if (!input.success)
1276
+ return reply.code(400).send({ error: "Enter a valid pairing payload and six-digit PIN." });
1277
+ const result = await security2.pair(input.data, request.ip);
1278
+ return result ?? reply.code(401).send({ error: "Pairing expired, locked, or invalid. Run agentrific pair again." });
1279
+ });
1280
+ app2.post("/api/auth/pairing-invitation", (_request, reply) => {
1281
+ const publicUrl = process.env.AGENTRIFIC_PUBLIC_URL;
1282
+ if (!publicUrl)
1283
+ return reply.code(503).send({
1284
+ error: "Configure the server\u2019s public HTTPS URL and restart the server before pairing a device."
1285
+ });
1286
+ let url;
1287
+ try {
1288
+ url = pairingOrigin(publicUrl);
1289
+ } catch {
1290
+ return reply.code(503).send({ error: "The server\u2019s public URL is invalid. Configure a public HTTPS origin." });
1291
+ }
1292
+ try {
1293
+ const grant = security2.createPairing();
1294
+ return {
1295
+ payload: { v: 1, url, serverId: grant.serverId, pairingId: grant.pairingId },
1296
+ pin: grant.pin,
1297
+ expiresAt: grant.expiresAt
1298
+ };
1299
+ } catch {
1300
+ return reply.code(429).send({ error: "Too many pending pairing codes. Wait five minutes and try again." });
1301
+ }
1302
+ });
1303
+ app2.get("/api/auth/session", (request) => ({
1304
+ serverId: security2.serverId,
1305
+ device: security2.authenticate((request.headers.authorization ?? "").slice(7))
1306
+ }));
1307
+ app2.post("/api/auth/event-ticket", (request) => ({
1308
+ ticket: security2.ticket((request.headers.authorization ?? "").slice(7), "/api/events")
1309
+ }));
1310
+ app2.post("/api/auth/asset-ticket", (request, reply) => {
1311
+ const input = z6.object({
1312
+ path: z6.string().regex(/^\/api\/projects\/[^/?]+\/attachments\/[^/?]+\/content(?:\?preview=1)?$/)
1313
+ }).safeParse(request.body);
1314
+ if (!input.success) return reply.code(400).send({ error: "Choose a valid attachment." });
1315
+ return {
1316
+ ticket: security2.ticket((request.headers.authorization ?? "").slice(7), input.data.path)
1317
+ };
1318
+ });
1319
+ }
1320
+
980
1321
  // src/maintenance.ts
981
1322
  var MaintenanceError = class extends Error {
982
1323
  statusCode = 503;
@@ -1018,9 +1359,9 @@ var Maintenance = class {
1018
1359
  }
1019
1360
  attach(app2) {
1020
1361
  app2.addHook("onRequest", async (request, reply) => {
1021
- const path8 = request.url.split("?")[0];
1362
+ const path10 = request.url.split("?")[0];
1022
1363
  if (["GET", "HEAD", "OPTIONS"].includes(request.method)) return;
1023
- const allowed = path8.startsWith("/api/auth/") || /^\/api\/sessions\/[^/]+\/stop$/.test(path8);
1364
+ const allowed = path10.startsWith("/api/auth/") || /^\/api\/sessions\/[^/]+\/stop$/.test(path10);
1024
1365
  if (this.paused && !allowed)
1025
1366
  return reply.code(503).send({ code: "MAINTENANCE", error: new MaintenanceError().message });
1026
1367
  this.requests.add(request.id);
@@ -1033,10 +1374,10 @@ var Maintenance = class {
1033
1374
 
1034
1375
  // src/updates.ts
1035
1376
  import semver from "semver";
1036
- import { z as z5 } from "zod";
1037
- import { Effect as Effect4 } from "effect";
1038
- var RegistryRelease = z5.object({
1039
- version: z5.string().refine((value) => semver.valid(value) !== null)
1377
+ import { z as z7 } from "zod";
1378
+ import { Effect as Effect5 } from "effect";
1379
+ var RegistryRelease = z7.object({
1380
+ version: z7.string().refine((value) => semver.valid(value) !== null)
1040
1381
  });
1041
1382
  async function checkForUpdates() {
1042
1383
  const response = await fetch("https://registry.npmjs.org/@agentrific%2Fserver/latest", {
@@ -1073,7 +1414,7 @@ var UpdateDiscovery = class {
1073
1414
  checkedAt: null,
1074
1415
  error: null
1075
1416
  };
1076
- const saved = Effect4.runSync(store2.serverUpdateStatus());
1417
+ const saved = Effect5.runSync(store2.serverUpdateStatus());
1077
1418
  if (saved)
1078
1419
  this.state = {
1079
1420
  ...saved,
@@ -1110,7 +1451,7 @@ var UpdateDiscovery = class {
1110
1451
  error: "Could not check for server updates. Try again when the registry is reachable."
1111
1452
  };
1112
1453
  }
1113
- await Effect4.runPromise(this.store.saveServerUpdateStatus(this.state));
1454
+ await Effect5.runPromise(this.store.saveServerUpdateStatus(this.state));
1114
1455
  return this.state;
1115
1456
  }
1116
1457
  start(canCheck, onError) {
@@ -1125,14 +1466,14 @@ var UpdateDiscovery = class {
1125
1466
  };
1126
1467
 
1127
1468
  // src/index.ts
1128
- import { Effect as Effect14, ManagedRuntime } from "effect";
1129
- import { z as z13 } from "zod";
1469
+ import { Effect as Effect16, ManagedRuntime } from "effect";
1470
+ import { z as z16 } from "zod";
1130
1471
 
1131
1472
  // src/automation.ts
1132
- import { Effect as Effect6 } from "effect";
1473
+ import { Effect as Effect7 } from "effect";
1133
1474
 
1134
1475
  // src/tasks.ts
1135
- import { Effect as Effect5, Semaphore } from "effect";
1476
+ import { Effect as Effect6, Semaphore } from "effect";
1136
1477
  var TaskActionError = class extends Error {
1137
1478
  constructor(message, status) {
1138
1479
  super(message);
@@ -1147,15 +1488,20 @@ function withTaskLock(id, operation) {
1147
1488
  lock = Semaphore.makeUnsafe(1);
1148
1489
  locks.set(id, lock);
1149
1490
  }
1150
- return Effect5.runPromise(
1491
+ return Effect6.runPromise(
1151
1492
  lock.withPermit(
1152
- Effect5.tryPromise({
1493
+ Effect6.tryPromise({
1153
1494
  try: operation,
1154
1495
  catch: (cause) => cause
1155
1496
  })
1156
1497
  )
1157
1498
  );
1158
1499
  }
1500
+ async function sessionTaskLockId(store2, session) {
1501
+ if (session.taskId) return session.taskId;
1502
+ const tasks = await Effect6.runPromise(store2.listTasks(session.projectId, true));
1503
+ return tasks.find((task) => task.sessionId === session.id)?.id ?? `session-${session.id}`;
1504
+ }
1159
1505
  function taskStatusForSession(status, session) {
1160
1506
  if (session?.gitDelivery?.status === "pending") return "running";
1161
1507
  if (status === "completed" && session?.gitFlow && !["merged", "no_changes", "pr_opened"].includes(session.gitDelivery?.status ?? ""))
@@ -1192,8 +1538,8 @@ function sessionTask(session) {
1192
1538
  });
1193
1539
  }
1194
1540
  async function boardTasks(store2, projectId) {
1195
- const tasks = await Effect5.runPromise(store2.listTasks(projectId, true));
1196
- const sessions = await Effect5.runPromise(store2.list());
1541
+ const tasks = await Effect6.runPromise(store2.listTasks(projectId, true));
1542
+ const sessions = await Effect6.runPromise(store2.list());
1197
1543
  const linkedSessionIds = new Set(
1198
1544
  tasks.flatMap((task) => task.sessionId ? [task.sessionId] : [])
1199
1545
  );
@@ -1212,10 +1558,10 @@ async function boardTasks(store2, projectId) {
1212
1558
  return [...linkedTasks, ...legacyTasks].map((task) => TaskSchema.parse(task)).sort((left, right) => right.updatedAt.localeCompare(left.updatedAt));
1213
1559
  }
1214
1560
  async function findTask(store2, id) {
1215
- const saved = await Effect5.runPromise(store2.getTask(id));
1561
+ const saved = await Effect6.runPromise(store2.getTask(id));
1216
1562
  if (saved?.deletedAt) throw new TaskActionError("Task not found.", 404);
1217
1563
  if (saved) return saved;
1218
- const session = id.startsWith("session-") ? await Effect5.runPromise(store2.get(id.slice("session-".length))) : void 0;
1564
+ const session = id.startsWith("session-") ? await Effect6.runPromise(store2.get(id.slice("session-".length))) : void 0;
1219
1565
  if (!session || session.taskId) throw new TaskActionError("Task not found.", 404);
1220
1566
  return sessionTask(session);
1221
1567
  }
@@ -1231,7 +1577,7 @@ function editTask(store2, id, input, resolveAttachments) {
1231
1577
  throw new TaskActionError("Attachment validation is unavailable.", 400);
1232
1578
  files = await resolveAttachments(task.projectId, attachmentIds);
1233
1579
  }
1234
- return Effect5.runPromise(
1580
+ return Effect6.runPromise(
1235
1581
  store2.saveTask({
1236
1582
  ...task,
1237
1583
  ...updates2,
@@ -1242,16 +1588,25 @@ function editTask(store2, id, input, resolveAttachments) {
1242
1588
  );
1243
1589
  });
1244
1590
  }
1245
- function deleteTask(store2, id) {
1591
+ function deleteTask(store2, id, cleanup = async () => {
1592
+ }) {
1246
1593
  return withTaskLock(id, async () => {
1247
1594
  const task = await findTask(store2, id);
1248
- const sessions = await Effect5.runPromise(store2.list());
1595
+ const sessions = await Effect6.runPromise(store2.list());
1249
1596
  if (sessions.some(
1250
1597
  (session) => (session.taskId === task.id || session.id === task.sessionId) && (["starting", "running", "queued"].includes(session.status) || session.gitDelivery?.status === "pending")
1251
1598
  ))
1252
1599
  throw new TaskActionError("Stop the task's running agent before deleting it.", 409);
1253
- const now = (/* @__PURE__ */ new Date()).toISOString();
1254
- await Effect5.runPromise(store2.saveTask({ ...task, deletedAt: now, updatedAt: now }));
1600
+ const associated = sessions.filter(
1601
+ (session) => session.taskId === task.id || session.id === task.sessionId
1602
+ );
1603
+ for (const session of associated) await cleanup(session);
1604
+ await Effect6.runPromise(
1605
+ store2.purgeTask(
1606
+ task.id,
1607
+ associated.map((session) => session.id)
1608
+ )
1609
+ );
1255
1610
  });
1256
1611
  }
1257
1612
 
@@ -1277,18 +1632,18 @@ var AutomationCoordinator = class {
1277
1632
  start;
1278
1633
  canStart;
1279
1634
  async snapshot() {
1280
- const state = await Effect6.runPromise(this.store.automation());
1635
+ const state = await Effect7.runPromise(this.store.automation());
1281
1636
  return {
1282
1637
  ...state,
1283
1638
  queue: automationQueue(await boardTasks(this.store), state),
1284
- active: activeAgents(await Effect6.runPromise(this.store.list()))
1639
+ active: activeAgents(await Effect7.runPromise(this.store.list()))
1285
1640
  };
1286
1641
  }
1287
1642
  // ponytail: one server process owns this SQLite database; use a durable lease before adding replicas.
1288
1643
  configure(config) {
1289
1644
  return withTaskLock("automation", async () => {
1290
- const state = await Effect6.runPromise(this.store.automation());
1291
- await Effect6.runPromise(this.store.saveAutomation({ ...state, config, error: null }));
1645
+ const state = await Effect7.runPromise(this.store.automation());
1646
+ await Effect7.runPromise(this.store.saveAutomation({ ...state, config, error: null }));
1292
1647
  return this.snapshot();
1293
1648
  });
1294
1649
  }
@@ -1300,7 +1655,7 @@ var AutomationCoordinator = class {
1300
1655
  throw new TaskActionError("Queue changed. Refresh and try again.", 409);
1301
1656
  const order = ids.filter((id) => id !== taskId);
1302
1657
  order.splice(beforeId === null ? order.length : order.indexOf(beforeId), 0, taskId);
1303
- await Effect6.runPromise(
1658
+ await Effect7.runPromise(
1304
1659
  this.store.saveAutomation({
1305
1660
  config: snapshot.config,
1306
1661
  lastMinute: snapshot.lastMinute,
@@ -1313,22 +1668,22 @@ var AutomationCoordinator = class {
1313
1668
  }
1314
1669
  tick(now = /* @__PURE__ */ new Date()) {
1315
1670
  return withTaskLock("automation", async () => {
1316
- let state = await Effect6.runPromise(this.store.automation());
1671
+ let state = await Effect7.runPromise(this.store.automation());
1317
1672
  const minute = now.toISOString().slice(0, 16);
1318
1673
  if (!this.canStart() || !state.config.enabled || state.lastMinute !== null && state.lastMinute >= minute || !cronMatches(state.config.cron, now))
1319
1674
  return;
1320
1675
  state = { ...state, lastMinute: minute, error: null };
1321
- await Effect6.runPromise(this.store.saveAutomation(state));
1676
+ await Effect7.runPromise(this.store.saveAutomation(state));
1322
1677
  const queue = automationQueue(await boardTasks(this.store), state);
1323
1678
  for (const task of queue) {
1324
1679
  if (!this.canStart()) break;
1325
- if (activeAgents(await Effect6.runPromise(this.store.list())) >= state.config.parallelism)
1680
+ if (activeAgents(await Effect7.runPromise(this.store.list())) >= state.config.parallelism)
1326
1681
  break;
1327
1682
  try {
1328
1683
  await this.start(task.id);
1329
1684
  } catch (error) {
1330
1685
  if (error instanceof MaintenanceError) break;
1331
- await Effect6.runPromise(
1686
+ await Effect7.runPromise(
1332
1687
  this.store.saveAutomation({
1333
1688
  ...state,
1334
1689
  config: { ...state.config, enabled: false },
@@ -1343,8 +1698,8 @@ var AutomationCoordinator = class {
1343
1698
  };
1344
1699
 
1345
1700
  // src/runtime.ts
1346
- import path3 from "node:path";
1347
- import { randomUUID as randomUUID3 } from "node:crypto";
1701
+ import path4 from "node:path";
1702
+ import { randomUUID as randomUUID4 } from "node:crypto";
1348
1703
 
1349
1704
  // src/git-flow.ts
1350
1705
  function gitFlowPrompt(session, message) {
@@ -1356,7 +1711,7 @@ Work on the assigned run branch ${session.branch}. Before finishing, call delive
1356
1711
  }
1357
1712
 
1358
1713
  // src/runtime.ts
1359
- import { Context as Context2, Data as Data2, Effect as Effect7, Fiber, Layer as Layer2, Semaphore as Semaphore2 } from "effect";
1714
+ import { Context as Context2, Data as Data2, Effect as Effect8, Fiber, Layer as Layer2, Semaphore as Semaphore2 } from "effect";
1360
1715
  var AgentError = class extends Data2.TaggedError("AgentError") {
1361
1716
  };
1362
1717
  var AgentController = class {
@@ -1396,20 +1751,20 @@ var AgentController = class {
1396
1751
  this.emit({ type: "session.updated", session: entry.session });
1397
1752
  }
1398
1753
  operation(action) {
1399
- return Effect7.tryPromise({
1754
+ return Effect8.tryPromise({
1400
1755
  try: action,
1401
1756
  catch: (cause) => new AgentError({ message: String(cause), cause })
1402
1757
  });
1403
1758
  }
1404
1759
  open(session) {
1405
- return Effect7.gen({ self: this }, function* () {
1760
+ return Effect8.gen({ self: this }, function* () {
1406
1761
  const existing = this.active.get(session.id);
1407
1762
  if (existing) return existing;
1408
- const agent = yield* Effect7.tryPromise({
1763
+ const agent = yield* Effect8.tryPromise({
1409
1764
  try: () => this.factory.open(
1410
1765
  session.id,
1411
- session.workspacePath ?? path3.join(this.worktreesRoot, session.id),
1412
- path3.join(this.sessionsRoot, session.id),
1766
+ session.workspacePath ?? path4.join(this.worktreesRoot, session.id),
1767
+ path4.join(this.sessionsRoot, session.id),
1413
1768
  session.model,
1414
1769
  session
1415
1770
  ),
@@ -1432,14 +1787,14 @@ var AgentController = class {
1432
1787
  });
1433
1788
  }
1434
1789
  run(entry, message) {
1435
- return Effect7.gen({ self: this }, function* () {
1790
+ return Effect8.gen({ self: this }, function* () {
1436
1791
  let next = message;
1437
1792
  while (next !== void 0) {
1438
1793
  const current = next;
1439
1794
  yield* this.operation(
1440
1795
  () => entry.agent.prompt(gitFlowPrompt(entry.session, current.text), current.attachments)
1441
1796
  );
1442
- next = yield* Effect7.sync(() => {
1797
+ next = yield* Effect8.sync(() => {
1443
1798
  const queued = entry.stopping ? void 0 : entry.pending.shift();
1444
1799
  if (queued === void 0) {
1445
1800
  entry.running = false;
@@ -1451,19 +1806,19 @@ var AgentController = class {
1451
1806
  yield* this.operation(() => this.onSettled(entry.session));
1452
1807
  }
1453
1808
  }).pipe(
1454
- Effect7.catch(
1455
- (error) => Effect7.sync(() => {
1809
+ Effect8.catch(
1810
+ (error) => Effect8.sync(() => {
1456
1811
  entry.pending.length = 0;
1457
1812
  entry.running = false;
1458
1813
  this.emit({ type: "error", sessionId: entry.session.id, message: error.message });
1459
1814
  this.status(entry, entry.stopping ? "stopped" : "failed");
1460
1815
  })
1461
1816
  ),
1462
- Effect7.onInterrupt(
1817
+ Effect8.onInterrupt(
1463
1818
  () => this.operation(() => entry.agent.abort()).pipe(
1464
- Effect7.orDie,
1465
- Effect7.ensuring(
1466
- Effect7.sync(() => {
1819
+ Effect8.orDie,
1820
+ Effect8.ensuring(
1821
+ Effect8.sync(() => {
1467
1822
  entry.pending.length = 0;
1468
1823
  entry.running = false;
1469
1824
  this.status(entry, "stopped");
@@ -1471,16 +1826,16 @@ var AgentController = class {
1471
1826
  )
1472
1827
  )
1473
1828
  ),
1474
- Effect7.ensuring(Effect7.sync(() => this.working.delete(entry.session.id)))
1829
+ Effect8.ensuring(Effect8.sync(() => this.working.delete(entry.session.id)))
1475
1830
  );
1476
1831
  }
1477
- send(session, message, attachments2 = [], messageId = randomUUID3()) {
1832
+ send(session, message, attachments2 = [], messageId = randomUUID4()) {
1478
1833
  return this.lock(session.id).withPermit(
1479
- Effect7.gen({ self: this }, function* () {
1834
+ Effect8.gen({ self: this }, function* () {
1480
1835
  if (this.closed)
1481
- return yield* Effect7.fail(new AgentError({ message: "Server is shutting down" }));
1836
+ return yield* Effect8.fail(new AgentError({ message: "Server is shutting down" }));
1482
1837
  if (!session.branch && (session.gitFlow || !session.workspacePath))
1483
- return yield* Effect7.fail(new AgentError({ message: "Session checkout is not ready" }));
1838
+ return yield* Effect8.fail(new AgentError({ message: "Session checkout is not ready" }));
1484
1839
  if (this.hasMessage(session.id, messageId)) return;
1485
1840
  const entry = yield* this.open(session);
1486
1841
  yield* this.operation(async () => entry.agent.validateAttachments?.(attachments2));
@@ -1499,7 +1854,7 @@ var AgentController = class {
1499
1854
  entry.stopping = false;
1500
1855
  this.status(entry, "running");
1501
1856
  this.working.add(session.id);
1502
- entry.fiber = yield* Effect7.forkIn(
1857
+ entry.fiber = yield* Effect8.forkIn(
1503
1858
  this.run(entry, { text: message, attachments: attachments2 }),
1504
1859
  this.scope,
1505
1860
  {
@@ -1507,22 +1862,22 @@ var AgentController = class {
1507
1862
  }
1508
1863
  );
1509
1864
  })
1510
- ).pipe(Effect7.uninterruptible);
1865
+ ).pipe(Effect8.uninterruptible);
1511
1866
  }
1512
1867
  setModel(session, model) {
1513
1868
  return this.lock(session.id).withPermit(
1514
- Effect7.gen({ self: this }, function* () {
1869
+ Effect8.gen({ self: this }, function* () {
1515
1870
  if (this.closed)
1516
- return yield* Effect7.fail(new AgentError({ message: "Server is shutting down" }));
1871
+ return yield* Effect8.fail(new AgentError({ message: "Server is shutting down" }));
1517
1872
  const entry = yield* this.open(session);
1518
1873
  if (entry.running || entry.pending.length)
1519
- return yield* Effect7.fail(
1874
+ return yield* Effect8.fail(
1520
1875
  new AgentError({
1521
1876
  message: "Wait for this reply to finish before changing the model."
1522
1877
  })
1523
1878
  );
1524
1879
  if (!entry.agent.setModel)
1525
- return yield* Effect7.fail(
1880
+ return yield* Effect8.fail(
1526
1881
  new AgentError({ message: "This agent does not support model selection." })
1527
1882
  );
1528
1883
  yield* this.operation(() => entry.agent.setModel(model));
@@ -1534,10 +1889,10 @@ var AgentController = class {
1534
1889
  this.emit({ type: "session.updated", session: entry.session });
1535
1890
  return entry.session;
1536
1891
  })
1537
- ).pipe(Effect7.uninterruptible);
1892
+ ).pipe(Effect8.uninterruptible);
1538
1893
  }
1539
1894
  stopEntry(entry) {
1540
- return Effect7.gen({ self: this }, function* () {
1895
+ return Effect8.gen({ self: this }, function* () {
1541
1896
  entry.stopping = true;
1542
1897
  entry.pending.length = 0;
1543
1898
  yield* this.operation(() => entry.agent.abort());
@@ -1547,7 +1902,7 @@ var AgentController = class {
1547
1902
  }
1548
1903
  stop(session) {
1549
1904
  return this.lock(session.id).withPermit(
1550
- Effect7.gen({ self: this }, function* () {
1905
+ Effect8.gen({ self: this }, function* () {
1551
1906
  const entry = this.active.get(session.id);
1552
1907
  if (entry) {
1553
1908
  yield* this.stopEntry(entry);
@@ -1558,14 +1913,26 @@ var AgentController = class {
1558
1913
  });
1559
1914
  }
1560
1915
  })
1561
- ).pipe(Effect7.uninterruptible);
1916
+ ).pipe(Effect8.uninterruptible);
1917
+ }
1918
+ forget(id) {
1919
+ return this.lock(id).withPermit(
1920
+ Effect8.sync(() => {
1921
+ const entry = this.active.get(id);
1922
+ if (!entry) return;
1923
+ if (entry.running) throw new Error("Cannot delete an active run.");
1924
+ entry.unsubscribe();
1925
+ entry.agent.dispose();
1926
+ this.active.delete(id);
1927
+ })
1928
+ );
1562
1929
  }
1563
1930
  close() {
1564
- return Effect7.gen({ self: this }, function* () {
1931
+ return Effect8.gen({ self: this }, function* () {
1565
1932
  this.closed = true;
1566
1933
  for (const [id, lock] of this.locks) {
1567
1934
  yield* lock.withPermit(
1568
- Effect7.gen({ self: this }, function* () {
1935
+ Effect8.gen({ self: this }, function* () {
1569
1936
  const entry = this.active.get(id);
1570
1937
  if (!entry) return;
1571
1938
  if (entry.running) yield* this.stopEntry(entry);
@@ -1585,8 +1952,8 @@ var AgentSessions = class extends Context2.Service()(
1585
1952
  }) {
1586
1953
  return Layer2.effect(
1587
1954
  this,
1588
- Effect7.gen(function* () {
1589
- const scope = yield* Effect7.scope;
1955
+ Effect8.gen(function* () {
1956
+ const scope = yield* Effect8.scope;
1590
1957
  const controller = new AgentController(
1591
1958
  factory,
1592
1959
  emit,
@@ -1596,7 +1963,7 @@ var AgentSessions = class extends Context2.Service()(
1596
1963
  hasMessage,
1597
1964
  onSettled
1598
1965
  );
1599
- yield* Effect7.addFinalizer(() => controller.close().pipe(Effect7.orDie));
1966
+ yield* Effect8.addFinalizer(() => controller.close().pipe(Effect8.orDie));
1600
1967
  return controller;
1601
1968
  })
1602
1969
  );
@@ -1604,8 +1971,8 @@ var AgentSessions = class extends Context2.Service()(
1604
1971
  };
1605
1972
 
1606
1973
  // src/delivery.ts
1607
- import { Effect as Effect8 } from "effect";
1608
- import { z as z7 } from "zod";
1974
+ import { Effect as Effect9 } from "effect";
1975
+ import { z as z9 } from "zod";
1609
1976
 
1610
1977
  // src/workflow-command.ts
1611
1978
  import { execFile } from "node:child_process";
@@ -1649,12 +2016,12 @@ function workflowError(error) {
1649
2016
  }
1650
2017
 
1651
2018
  // src/workflow-schema.ts
1652
- import { z as z6 } from "zod";
1653
- var DeliveryJobSchema = z6.object({
1654
- kind: z6.literal("delivery"),
1655
- sessionId: z6.string(),
2019
+ import { z as z8 } from "zod";
2020
+ var DeliveryJobSchema = z8.object({
2021
+ kind: z8.literal("delivery"),
2022
+ sessionId: z8.string(),
1656
2023
  request: DeliveryRequestSchema,
1657
- phase: z6.enum([
2024
+ phase: z8.enum([
1658
2025
  "validating",
1659
2026
  "drafting",
1660
2027
  "committing",
@@ -1664,25 +2031,25 @@ var DeliveryJobSchema = z6.object({
1664
2031
  "opening_pr",
1665
2032
  "delivered"
1666
2033
  ]),
1667
- attempt: z6.number().int().positive(),
1668
- commitSha: z6.string().optional(),
1669
- title: z6.string(),
1670
- body: z6.string(),
1671
- logs: z6.string().default(""),
1672
- failed: z6.boolean().default(false),
1673
- result: z6.enum(["merged", "pr_opened", "no_changes"]).optional(),
1674
- url: z6.string().optional(),
1675
- message: z6.string().optional()
2034
+ attempt: z8.number().int().positive(),
2035
+ commitSha: z8.string().optional(),
2036
+ title: z8.string(),
2037
+ body: z8.string(),
2038
+ logs: z8.string().default(""),
2039
+ failed: z8.boolean().default(false),
2040
+ result: z8.enum(["merged", "pr_opened", "no_changes"]).optional(),
2041
+ url: z8.string().optional(),
2042
+ message: z8.string().optional()
1676
2043
  });
1677
- var PreparationJobSchema = z6.object({
1678
- kind: z6.literal("preparation"),
1679
- sessionId: z6.string(),
1680
- projectPath: z6.string(),
2044
+ var PreparationJobSchema = z8.object({
2045
+ kind: z8.literal("preparation"),
2046
+ sessionId: z8.string(),
2047
+ projectPath: z8.string(),
1681
2048
  input: CreateSessionSchema,
1682
- stage: z6.enum(["creating", "installing", "ready", "failed"]),
1683
- logs: z6.string().default("")
2049
+ stage: z8.enum(["creating", "installing", "ready", "failed"]),
2050
+ logs: z8.string().default("")
1684
2051
  });
1685
- var WorkflowJobSchema = z6.discriminatedUnion("kind", [
2052
+ var WorkflowJobSchema = z8.discriminatedUnion("kind", [
1686
2053
  DeliveryJobSchema,
1687
2054
  PreparationJobSchema
1688
2055
  ]);
@@ -1702,20 +2069,20 @@ var DeliveryCoordinator = class {
1702
2069
  return [...this.workers.keys()].map((id) => `delivery:${id}`);
1703
2070
  }
1704
2071
  async session(id) {
1705
- const session = await Effect8.runPromise(this.store.get(id));
2072
+ const session = await Effect9.runPromise(this.store.get(id));
1706
2073
  if (!session?.gitFlow || !session.branch || !session.workspacePath)
1707
2074
  throw new Error("This run does not have a ready Git flow workspace.");
1708
2075
  return session;
1709
2076
  }
1710
2077
  async job(id) {
1711
- const raw = await Effect8.runPromise(this.store.workflow(`delivery:${id}`));
2078
+ const raw = await Effect9.runPromise(this.store.workflow(`delivery:${id}`));
1712
2079
  return raw === void 0 ? void 0 : DeliveryJobSchema.parse(raw);
1713
2080
  }
1714
2081
  git(session, args, signal) {
1715
2082
  return this.command(session.workspacePath, "git", args, signal);
1716
2083
  }
1717
2084
  async record(job) {
1718
- await Effect8.runPromise(this.store.saveWorkflow(`delivery:${job.sessionId}`, job));
2085
+ await Effect9.runPromise(this.store.saveWorkflow(`delivery:${job.sessionId}`, job));
1719
2086
  const session = await this.session(job.sessionId);
1720
2087
  const delivery2 = {
1721
2088
  status: job.failed ? "failed" : job.result ?? "pending",
@@ -1904,7 +2271,7 @@ Agentrific run ${id}.`;
1904
2271
  return (await this.session(id)).gitDelivery;
1905
2272
  }
1906
2273
  async recover() {
1907
- const jobs = await Effect8.runPromise(this.store.workflows());
2274
+ const jobs = await Effect9.runPromise(this.store.workflows());
1908
2275
  for (const raw of jobs) {
1909
2276
  const parsed = DeliveryJobSchema.safeParse(raw);
1910
2277
  if (!parsed.success) continue;
@@ -1978,11 +2345,11 @@ ${workflowError(error instanceof Error ? error : String(error))}`
1978
2345
  "--json",
1979
2346
  "url,state,headRefOid"
1980
2347
  ]);
1981
- const prs = z7.array(
1982
- z7.object({
1983
- url: z7.string().url(),
1984
- state: z7.string(),
1985
- headRefOid: z7.string().optional()
2348
+ const prs = z9.array(
2349
+ z9.object({
2350
+ url: z9.string().url(),
2351
+ state: z9.string(),
2352
+ headRefOid: z9.string().optional()
1986
2353
  })
1987
2354
  ).parse(JSON.parse(existing));
1988
2355
  const open = prs.find(
@@ -2024,9 +2391,9 @@ ${workflowError(error instanceof Error ? error : String(error))}`
2024
2391
 
2025
2392
  // src/preparation.ts
2026
2393
  import { mkdir as mkdir3, readFile as readFile3 } from "node:fs/promises";
2027
- import path4 from "node:path";
2028
- import { Effect as Effect9 } from "effect";
2029
- import { z as z8 } from "zod";
2394
+ import path5 from "node:path";
2395
+ import { Effect as Effect10 } from "effect";
2396
+ import { z as z10 } from "zod";
2030
2397
  var PreparationCoordinator = class {
2031
2398
  constructor(store2, options) {
2032
2399
  this.store = store2;
@@ -2056,8 +2423,8 @@ var PreparationCoordinator = class {
2056
2423
  job.logs = workflowLog(`${job.logs}
2057
2424
  ${message}${logs ? `
2058
2425
  ${logs}` : ""}`);
2059
- await Effect9.runPromise(this.store.saveWorkflow(`preparation:${session.id}`, job));
2060
- const current = await Effect9.runPromise(this.store.get(session.id));
2426
+ await Effect10.runPromise(this.store.saveWorkflow(`preparation:${session.id}`, job));
2427
+ const current = await Effect10.runPromise(this.store.get(session.id));
2061
2428
  this.options.publish({
2062
2429
  type: "session.updated",
2063
2430
  session: {
@@ -2076,7 +2443,7 @@ ${logs}` : ""}`);
2076
2443
  await record("installing", "Installing run dependencies\u2026");
2077
2444
  await this.install(workspace, command);
2078
2445
  await record("ready", "Workspace ready.");
2079
- return await Effect9.runPromise(this.store.get(session.id));
2446
+ return await Effect10.runPromise(this.store.get(session.id));
2080
2447
  } catch (error) {
2081
2448
  const step = job.stage === "installing" ? "Dependency installation failed" : "Workspace preparation failed";
2082
2449
  await record(
@@ -2120,7 +2487,7 @@ ${logs}` : ""}`);
2120
2487
  const workspace = session.workspacePath;
2121
2488
  if (branchAtWorkspace !== void 0)
2122
2489
  throw new Error("The run workspace already exists on a different branch.");
2123
- await mkdir3(path4.dirname(workspace), { recursive: true });
2490
+ await mkdir3(path5.dirname(workspace), { recursive: true });
2124
2491
  try {
2125
2492
  await command(
2126
2493
  projectPath,
@@ -2140,12 +2507,12 @@ ${logs}` : ""}`);
2140
2507
  }
2141
2508
  }
2142
2509
  async install(workspace, command) {
2143
- const packageFile = await readFile3(path4.join(workspace, "package.json"), "utf8").catch(
2510
+ const packageFile = await readFile3(path5.join(workspace, "package.json"), "utf8").catch(
2144
2511
  () => void 0
2145
2512
  );
2146
2513
  if (!packageFile) return;
2147
- const metadata = z8.object({ packageManager: z8.string().optional() }).parse(JSON.parse(packageFile));
2148
- const has = async (name) => readFile3(path4.join(workspace, name)).then(
2514
+ const metadata = z10.object({ packageManager: z10.string().optional() }).parse(JSON.parse(packageFile));
2515
+ const has = async (name) => readFile3(path5.join(workspace, name)).then(
2149
2516
  () => true,
2150
2517
  () => false
2151
2518
  );
@@ -2158,10 +2525,10 @@ ${logs}` : ""}`);
2158
2525
  else if (await has("package-lock.json")) await command(workspace, "npm", ["ci"]);
2159
2526
  }
2160
2527
  async retry(id) {
2161
- const raw = await Effect9.runPromise(this.store.workflow(`preparation:${id}`));
2528
+ const raw = await Effect10.runPromise(this.store.workflow(`preparation:${id}`));
2162
2529
  const job = PreparationJobSchema.parse(raw);
2163
2530
  return withTaskLock(`repository:${job.projectPath}`, async () => {
2164
- const session = await Effect9.runPromise(this.store.get(id));
2531
+ const session = await Effect10.runPromise(this.store.get(id));
2165
2532
  if (!session || session.preparation?.status !== "failed")
2166
2533
  throw new Error("This run does not need preparation retried.");
2167
2534
  const prepared = await this.prepare(session, job.projectPath, job.input);
@@ -2169,11 +2536,11 @@ ${logs}` : ""}`);
2169
2536
  });
2170
2537
  }
2171
2538
  async recover() {
2172
- for (const raw of await Effect9.runPromise(this.store.workflows())) {
2539
+ for (const raw of await Effect10.runPromise(this.store.workflows())) {
2173
2540
  const parsed = PreparationJobSchema.safeParse(raw);
2174
2541
  if (!parsed.success) continue;
2175
2542
  const job = parsed.data;
2176
- const session = await Effect9.runPromise(this.store.get(job.sessionId));
2543
+ const session = await Effect10.runPromise(this.store.get(job.sessionId));
2177
2544
  if (!session) continue;
2178
2545
  if (job.stage === "failed") {
2179
2546
  if (session.preparation?.status === "failed") continue;
@@ -2204,7 +2571,7 @@ ${logs}` : ""}`);
2204
2571
  });
2205
2572
  continue;
2206
2573
  }
2207
- await Effect9.runPromise(
2574
+ await Effect10.runPromise(
2208
2575
  this.store.saveWorkflow(`preparation:${session.id}`, { ...job, stage: "failed" })
2209
2576
  );
2210
2577
  this.options.publish({
@@ -2226,8 +2593,8 @@ ${logs}` : ""}`);
2226
2593
 
2227
2594
  // src/delivery-writer.ts
2228
2595
  import { createAgentSession, SessionManager } from "@earendil-works/pi-coding-agent";
2229
- import { z as z9 } from "zod";
2230
- var DraftSchema = z9.object({ title: z9.string().trim().min(1).max(120), body: z9.string().trim().min(1).max(1e4) }).strict();
2596
+ import { z as z11 } from "zod";
2597
+ var DraftSchema = z11.object({ title: z11.string().trim().min(1).max(120), body: z11.string().trim().min(1).max(1e4) }).strict();
2231
2598
  function deliveryWriter(models2, createSession = createAgentSession) {
2232
2599
  return async (run, input, diff, signal) => {
2233
2600
  signal?.throwIfAborted();
@@ -2269,7 +2636,7 @@ ${diff}`
2269
2636
  import { ModelRuntime } from "@earendil-works/pi-coding-agent";
2270
2637
 
2271
2638
  // src/models.ts
2272
- import { Effect as Effect10 } from "effect";
2639
+ import { Effect as Effect11 } from "effect";
2273
2640
  var legacyModel = { platformId: "openai-codex", modelId: "gpt-6-luna" };
2274
2641
  var makers = [
2275
2642
  { id: "openai", name: "OpenAI", pattern: /(?:^|[/.:\s-])(?:gpt-|o[134](?:-|$))/i },
@@ -2334,7 +2701,7 @@ var ServerModels = class {
2334
2701
  await this.refreshing;
2335
2702
  }
2336
2703
  async defaultReference() {
2337
- return await Effect10.runPromise(this.store.defaultModel()) ?? legacyModel;
2704
+ return await Effect11.runPromise(this.store.defaultModel()) ?? legacyModel;
2338
2705
  }
2339
2706
  async defaultIdentity() {
2340
2707
  const selected = await this.defaultReference();
@@ -2370,19 +2737,19 @@ var ServerModels = class {
2370
2737
  }
2371
2738
  async setDefault(reference) {
2372
2739
  await this.resolve(reference);
2373
- await Effect10.runPromise(this.store.setDefaultModel(reference));
2740
+ await Effect11.runPromise(this.store.setDefaultModel(reference));
2374
2741
  return this.identity(reference.platformId, reference.modelId);
2375
2742
  }
2376
2743
  };
2377
2744
 
2378
2745
  // src/pi.ts
2379
- import { Effect as Effect11 } from "effect";
2380
- import { randomUUID as randomUUID4 } from "node:crypto";
2746
+ import { Effect as Effect12 } from "effect";
2747
+ import { randomUUID as randomUUID5 } from "node:crypto";
2381
2748
  import {
2382
2749
  createAgentSession as createAgentSession2,
2383
2750
  SessionManager as SessionManager2
2384
2751
  } from "@earendil-works/pi-coding-agent";
2385
- import { z as z10 } from "zod";
2752
+ import { z as z12 } from "zod";
2386
2753
 
2387
2754
  // src/delivery-tool.ts
2388
2755
  import { defineTool } from "@earendil-works/pi-coding-agent";
@@ -2417,8 +2784,8 @@ function deliveryTool(sessionId, delivery2) {
2417
2784
  }
2418
2785
 
2419
2786
  // src/pi.ts
2420
- var ToolResultSchema = z10.object({
2421
- content: z10.array(z10.object({ type: z10.string(), text: z10.string().optional() })).optional()
2787
+ var ToolResultSchema = z12.object({
2788
+ content: z12.array(z12.object({ type: z12.string(), text: z12.string().optional() })).optional()
2422
2789
  }).passthrough();
2423
2790
  var boundedJson = (value, limit) => {
2424
2791
  let text;
@@ -2460,7 +2827,7 @@ function createPiFactory(files, models2, delivery2, createSession = createAgentS
2460
2827
  );
2461
2828
  },
2462
2829
  async prompt(message, attachments2 = []) {
2463
- const context = await Effect11.runPromise(files.prepare(attachments2));
2830
+ const context = await Effect12.runPromise(files.prepare(attachments2));
2464
2831
  await session.prompt(message + context.context, { images: context.images });
2465
2832
  const last = session.messages.at(-1);
2466
2833
  if (last?.role === "assistant" && last.stopReason === "error") {
@@ -2497,7 +2864,7 @@ function createPiFactory(files, models2, delivery2, createSession = createAgentS
2497
2864
  function handleMessage(event) {
2498
2865
  if (event.type === "message_start" && event.message.role === "assistant") {
2499
2866
  flushText();
2500
- assistantMessageId = randomUUID4();
2867
+ assistantMessageId = randomUUID5();
2501
2868
  emit({
2502
2869
  type: "message.started",
2503
2870
  sessionId,
@@ -2508,7 +2875,7 @@ function createPiFactory(files, models2, delivery2, createSession = createAgentS
2508
2875
  return true;
2509
2876
  }
2510
2877
  if (event.type === "message_update" && event.assistantMessageEvent.type === "text_delta") {
2511
- if (!assistantMessageId) assistantMessageId = randomUUID4();
2878
+ if (!assistantMessageId) assistantMessageId = randomUUID5();
2512
2879
  pendingText += event.assistantMessageEvent.delta;
2513
2880
  setActivity("responding");
2514
2881
  if (!flushTimer) flushTimer = setTimeout(flushText, 50);
@@ -2583,18 +2950,18 @@ function createPiFactory(files, models2, delivery2, createSession = createAgentS
2583
2950
 
2584
2951
  // src/projects.ts
2585
2952
  import { access, readdir as readdir2, realpath, stat as stat2, readFile as readFile4, writeFile as writeFile2 } from "node:fs/promises";
2586
- import { randomUUID as randomUUID5 } from "node:crypto";
2587
- import { z as z11 } from "zod";
2953
+ import { randomUUID as randomUUID6 } from "node:crypto";
2954
+ import { z as z13 } from "zod";
2588
2955
  import { constants } from "node:fs";
2589
2956
  import { homedir } from "node:os";
2590
- import path5 from "node:path";
2591
- import { Effect as Effect12, Semaphore as Semaphore3 } from "effect";
2957
+ import path6 from "node:path";
2958
+ import { Effect as Effect13, Semaphore as Semaphore3 } from "effect";
2592
2959
  function resolveProjectsRoot(input) {
2593
- return Effect12.tryPromise({
2960
+ return Effect13.tryPromise({
2594
2961
  try: async () => {
2595
2962
  const trimmed = input.trim();
2596
- const expanded = trimmed === "~" ? homedir() : trimmed.startsWith("~/") ? path5.join(homedir(), trimmed.slice(2)) : trimmed;
2597
- if (!path5.isAbsolute(expanded))
2963
+ const expanded = trimmed === "~" ? homedir() : trimmed.startsWith("~/") ? path6.join(homedir(), trimmed.slice(2)) : trimmed;
2964
+ if (!path6.isAbsolute(expanded))
2598
2965
  throw new Error("Use an absolute server path or a path beginning with ~/.");
2599
2966
  const resolved = await realpath(expanded);
2600
2967
  if (!(await stat2(resolved)).isDirectory())
@@ -2616,63 +2983,63 @@ function projectLock(store2) {
2616
2983
  }
2617
2984
  return lock;
2618
2985
  }
2619
- var ProjectId = z11.string().trim().min(1).max(255);
2986
+ var ProjectId = z13.string().trim().min(1).max(255);
2620
2987
  async function gitDirectory(directory) {
2621
- const location = path5.join(directory, ".git");
2988
+ const location = path6.join(directory, ".git");
2622
2989
  const info = await stat2(location);
2623
2990
  if (info.isDirectory()) return location;
2624
2991
  const contents = await readFile4(location, "utf8");
2625
2992
  const match = /^gitdir: (.+)\s*$/m.exec(contents);
2626
2993
  if (!match) throw new Error("Invalid Git worktree metadata.");
2627
- const resolved = await realpath(path5.resolve(directory, match[1].trim()));
2994
+ const resolved = await realpath(path6.resolve(directory, match[1].trim()));
2628
2995
  if (!(await stat2(resolved)).isDirectory()) throw new Error("Git directory is missing.");
2629
2996
  return resolved;
2630
2997
  }
2631
2998
  async function identity(directory, preferred) {
2632
- const filename = path5.join(await gitDirectory(directory), "agentrific-project-id");
2999
+ const filename = path6.join(await gitDirectory(directory), "agentrific-project-id");
2633
3000
  try {
2634
3001
  return ProjectId.parse(await readFile4(filename, "utf8"));
2635
3002
  } catch (error) {
2636
- if (!z11.object({ code: z11.literal("ENOENT") }).safeParse(error).success) throw error;
2637
- const legacyFilename = path5.join(await gitDirectory(directory), "chatplans-project-id");
3003
+ if (!z13.object({ code: z13.literal("ENOENT") }).safeParse(error).success) throw error;
3004
+ const legacyFilename = path6.join(await gitDirectory(directory), "chatplans-project-id");
2638
3005
  const legacy = await readFile4(legacyFilename, "utf8").catch((cause) => {
2639
- if (!z11.object({ code: z11.literal("ENOENT") }).safeParse(cause).success) throw cause;
3006
+ if (!z13.object({ code: z13.literal("ENOENT") }).safeParse(cause).success) throw cause;
2640
3007
  return null;
2641
3008
  });
2642
- const id = legacy === null ? preferred ?? randomUUID5() : ProjectId.parse(legacy);
3009
+ const id = legacy === null ? preferred ?? randomUUID6() : ProjectId.parse(legacy);
2643
3010
  try {
2644
3011
  await writeFile2(filename, id + "\n", { flag: "wx" });
2645
3012
  return id;
2646
3013
  } catch (writeError) {
2647
- if (!z11.object({ code: z11.literal("EEXIST") }).safeParse(writeError).success) throw writeError;
3014
+ if (!z13.object({ code: z13.literal("EEXIST") }).safeParse(writeError).success) throw writeError;
2648
3015
  return ProjectId.parse(await readFile4(filename, "utf8"));
2649
3016
  }
2650
3017
  }
2651
3018
  }
2652
3019
  function scanProjects(root, store2) {
2653
- return Effect12.tryPromise({
3020
+ return Effect13.tryPromise({
2654
3021
  try: async () => {
2655
3022
  root = await realpath(root);
2656
- const registered = await Effect12.runPromise(store2.registeredProjects(root));
3023
+ const registered = await Effect13.runPromise(store2.registeredProjects(root));
2657
3024
  const found = [];
2658
3025
  const entries = await readdir2(root, { withFileTypes: true });
2659
3026
  for (const entry of entries) {
2660
3027
  if (!entry.isDirectory()) continue;
2661
- const directory = path5.join(root, entry.name);
2662
- if (!await stat2(path5.join(directory, ".git")).catch(() => null)) continue;
3028
+ const directory = path6.join(root, entry.name);
3029
+ if (!await stat2(path6.join(directory, ".git")).catch(() => null)) continue;
2663
3030
  const previous = registered.find((project2) => project2.path === directory);
2664
3031
  let id = await identity(directory, previous?.available ? void 0 : previous?.id);
2665
3032
  let existing = registered.find((project2) => project2.id === id);
2666
- if (found.some((project2) => project2.id === id) || existing && existing.path !== directory && await stat2(path5.join(existing.path, ".git")).catch(() => null)) {
2667
- id = randomUUID5();
3033
+ if (found.some((project2) => project2.id === id) || existing && existing.path !== directory && await stat2(path6.join(existing.path, ".git")).catch(() => null)) {
3034
+ id = randomUUID6();
2668
3035
  await writeFile2(
2669
- path5.join(await gitDirectory(directory), "agentrific-project-id"),
3036
+ path6.join(await gitDirectory(directory), "agentrific-project-id"),
2670
3037
  id + "\n"
2671
3038
  );
2672
3039
  existing = void 0;
2673
3040
  }
2674
3041
  const project = { id, name: entry.name, path: directory, available: true };
2675
- await Effect12.runPromise(store2.relocateProject(project, existing?.path));
3042
+ await Effect13.runPromise(store2.relocateProject(project, existing?.path));
2676
3043
  found.push(project);
2677
3044
  }
2678
3045
  return [
@@ -2687,26 +3054,26 @@ function discoverProjects(root, store2) {
2687
3054
  return projectLock(store2).withPermit(scanProjects(root, store2));
2688
3055
  }
2689
3056
  function reconnectProject(root, id, input, store2) {
2690
- return Effect12.tryPromise({
3057
+ return Effect13.tryPromise({
2691
3058
  try: async () => {
2692
- const projects = await Effect12.runPromise(scanProjects(root, store2));
3059
+ const projects = await Effect13.runPromise(scanProjects(root, store2));
2693
3060
  const previous = projects.find((project2) => project2.id === id);
2694
3061
  if (!previous || previous.available)
2695
3062
  throw new Error("Only a missing project can be reconnected.");
2696
- const sessions = await Effect12.runPromise(store2.list());
3063
+ const sessions = await Effect13.runPromise(store2.list());
2697
3064
  if (sessions.some(
2698
3065
  (session) => session.projectId === id && ["starting", "running", "queued"].includes(session.status)
2699
3066
  ))
2700
3067
  throw new Error("Stop this project's running sessions before reconnecting its folder.");
2701
- const directory = await Effect12.runPromise(resolveProjectsRoot(input));
2702
- if (path5.dirname(directory) !== await realpath(root))
3068
+ const directory = await Effect13.runPromise(resolveProjectsRoot(input));
3069
+ if (path6.dirname(directory) !== await realpath(root))
2703
3070
  throw new Error("Choose a Git repository directly inside the configured projects folder.");
2704
- const filename = path5.join(await gitDirectory(directory), "agentrific-project-id");
3071
+ const filename = path6.join(await gitDirectory(directory), "agentrific-project-id");
2705
3072
  const replacedId = await identity(directory);
2706
- const project = { id, name: path5.basename(directory), path: directory, available: true };
3073
+ const project = { id, name: path6.basename(directory), path: directory, available: true };
2707
3074
  await writeFile2(filename, id + "\n");
2708
3075
  try {
2709
- await Effect12.runPromise(store2.relocateProject(project, previous.path, replacedId));
3076
+ await Effect13.runPromise(store2.relocateProject(project, previous.path, replacedId));
2710
3077
  } catch (error) {
2711
3078
  await writeFile2(filename, replacedId + "\n");
2712
3079
  throw error;
@@ -2718,21 +3085,67 @@ function reconnectProject(root, id, input, store2) {
2718
3085
  }
2719
3086
 
2720
3087
  // src/store.ts
2721
- import { mkdirSync } from "node:fs";
2722
- import path6 from "node:path";
3088
+ import { chmodSync, existsSync as existsSync2, mkdirSync as mkdirSync2 } from "node:fs";
3089
+ import path7 from "node:path";
3090
+
3091
+ // src/push-schema.ts
3092
+ import { z as z14 } from "zod";
3093
+ var PushBindingSchema = z14.object({
3094
+ bindingId: z14.string().uuid(),
3095
+ installationId: z14.string().uuid(),
3096
+ deviceId: z14.string().uuid()
3097
+ });
3098
+ var PushStateSchema = z14.object({
3099
+ bindings: z14.array(PushBindingSchema).max(10),
3100
+ error: z14.string().nullable()
3101
+ });
3102
+ var PendingPushSchema = PushEventSchema.extend({
3103
+ remaining: z14.array(PushBindingSchema),
3104
+ createdAt: z14.number(),
3105
+ attempts: z14.number().int().nonnegative(),
3106
+ nextAt: z14.number()
3107
+ });
3108
+
3109
+ // src/store.ts
2723
3110
  import { DatabaseSync } from "node:sqlite";
2724
- import { Context as Context3, Data as Data3, Effect as Effect13, Layer as Layer3 } from "effect";
2725
- import { z as z12 } from "zod";
2726
- import { randomUUID as randomUUID6 } from "node:crypto";
3111
+ import { Context as Context3, Data as Data3, Effect as Effect14, Layer as Layer3 } from "effect";
3112
+ import { z as z15 } from "zod";
3113
+ import { randomUUID as randomUUID7 } from "node:crypto";
2727
3114
  var StorageError = class extends Data3.TaggedError("StorageError") {
2728
3115
  };
2729
- var PayloadRow = z12.object({ payload: z12.string() });
2730
- var EventRow = z12.object({ sequence: z12.number(), recorded_at: z12.string(), payload: z12.string() });
3116
+ var PayloadRow = z15.object({ payload: z15.string() });
3117
+ var EventRow = z15.object({ sequence: z15.number(), recorded_at: z15.string(), payload: z15.string() });
2731
3118
  var SqliteSessionStore = class {
2732
3119
  constructor(db) {
2733
3120
  this.db = db;
2734
3121
  }
2735
3122
  db;
3123
+ pushState() {
3124
+ return this.query(() => {
3125
+ const row = this.db.prepare("SELECT payload FROM settings WHERE id = 'push-state'").get();
3126
+ return row ? PushStateSchema.parse(JSON.parse(PayloadRow.parse(row).payload)) : { bindings: [], error: null };
3127
+ });
3128
+ }
3129
+ savePushState(state) {
3130
+ return this.query(
3131
+ () => this.db.prepare(
3132
+ "INSERT INTO settings(id, payload) VALUES ('push-state', ?) ON CONFLICT(id) DO UPDATE SET payload = excluded.payload"
3133
+ ).run(JSON.stringify(PushStateSchema.parse(state)))
3134
+ );
3135
+ }
3136
+ pendingPush() {
3137
+ return this.query(
3138
+ () => this.db.prepare("SELECT payload FROM push_outbox ORDER BY rowid LIMIT 100").all().map((row) => PendingPushSchema.parse(JSON.parse(PayloadRow.parse(row).payload)))
3139
+ );
3140
+ }
3141
+ savePendingPush(job) {
3142
+ return this.query(
3143
+ () => this.db.prepare("UPDATE push_outbox SET payload = ? WHERE id = ?").run(JSON.stringify(PendingPushSchema.parse(job)), job.eventId)
3144
+ );
3145
+ }
3146
+ removePush(eventId) {
3147
+ return this.query(() => this.db.prepare("DELETE FROM push_outbox WHERE id = ?").run(eventId));
3148
+ }
2736
3149
  serverUpdateStatus() {
2737
3150
  return this.query(() => {
2738
3151
  const row = this.db.prepare("SELECT payload FROM settings WHERE id = 'server-update-status'").get();
@@ -2748,11 +3161,11 @@ var SqliteSessionStore = class {
2748
3161
  }
2749
3162
  serverIdentity() {
2750
3163
  return this.query(() => {
2751
- this.db.prepare("INSERT OR IGNORE INTO settings(id, payload) VALUES ('server-id', ?)").run(JSON.stringify(randomUUID6()));
3164
+ this.db.prepare("INSERT OR IGNORE INTO settings(id, payload) VALUES ('server-id', ?)").run(JSON.stringify(randomUUID7()));
2752
3165
  const row = PayloadRow.parse(
2753
3166
  this.db.prepare("SELECT payload FROM settings WHERE id = 'server-id'").get()
2754
3167
  );
2755
- return z12.string().uuid().parse(JSON.parse(row.payload));
3168
+ return z15.string().uuid().parse(JSON.parse(row.payload));
2756
3169
  });
2757
3170
  }
2758
3171
  saveDevice(device, tokenHash) {
@@ -2789,7 +3202,7 @@ var SqliteSessionStore = class {
2789
3202
  });
2790
3203
  }
2791
3204
  query(operation) {
2792
- return Effect13.try({
3205
+ return Effect14.try({
2793
3206
  try: operation,
2794
3207
  catch: (cause) => new StorageError({
2795
3208
  message: cause instanceof Error ? cause.message : "Session storage operation failed",
@@ -2840,10 +3253,10 @@ var SqliteSessionStore = class {
2840
3253
  `).all();
2841
3254
  const insert = this.db.prepare("INSERT OR IGNORE INTO projects(id, payload) VALUES (?, ?)");
2842
3255
  for (const row of legacy) {
2843
- const id = z12.object({ id: z12.string() }).parse(row).id;
3256
+ const id = z15.object({ id: z15.string() }).parse(row).id;
2844
3257
  insert.run(
2845
3258
  id,
2846
- JSON.stringify({ id, name: id, path: path6.join(root, id), available: false })
3259
+ JSON.stringify({ id, name: id, path: path7.join(root, id), available: false })
2847
3260
  );
2848
3261
  }
2849
3262
  return this.db.prepare("SELECT payload FROM projects").all().map((row) => ProjectSchema.parse(JSON.parse(PayloadRow.parse(row).payload)));
@@ -2880,7 +3293,7 @@ var SqliteSessionStore = class {
2880
3293
  this.db.prepare(
2881
3294
  "UPDATE workflows SET payload = json_set(payload, '$.projectPath', ?) WHERE json_extract(payload, '$.kind') = 'preparation' AND json_extract(payload, '$.projectPath') = ?"
2882
3295
  ).run(project.path, previousPath);
2883
- const relocate = (workspace) => workspace === previousPath || workspace.startsWith(`${previousPath}${path6.sep}`) ? project.path + workspace.slice(previousPath.length) : workspace;
3296
+ const relocate = (workspace) => workspace === previousPath || workspace.startsWith(`${previousPath}${path7.sep}`) ? project.path + workspace.slice(previousPath.length) : workspace;
2884
3297
  for (const row of this.db.prepare("SELECT id, payload FROM sessions WHERE json_extract(payload, '$.projectId') = ?").all(project.id)) {
2885
3298
  const session = SessionSchema.parse(JSON.parse(PayloadRow.parse(row).payload));
2886
3299
  if (session.workspacePath) {
@@ -2955,6 +3368,31 @@ var SqliteSessionStore = class {
2955
3368
  return row ? TaskSchema.parse(JSON.parse(PayloadRow.parse(row).payload)) : void 0;
2956
3369
  });
2957
3370
  }
3371
+ purgeTask(id, sessionIds) {
3372
+ return this.query(() => {
3373
+ this.db.exec("BEGIN IMMEDIATE");
3374
+ try {
3375
+ for (const sessionId of sessionIds) {
3376
+ this.db.prepare("DELETE FROM events WHERE session_id = ?").run(sessionId);
3377
+ this.db.prepare("DELETE FROM push_outbox WHERE json_extract(payload, '$.sessionId') = ?").run(sessionId);
3378
+ this.db.prepare("DELETE FROM workflows WHERE json_extract(payload, '$.sessionId') = ?").run(sessionId);
3379
+ this.db.prepare("DELETE FROM sessions WHERE id = ?").run(sessionId);
3380
+ }
3381
+ this.db.prepare("DELETE FROM tasks WHERE id = ?").run(id);
3382
+ this.db.exec(`UPDATE attachments SET claimed = 0 WHERE NOT EXISTS (
3383
+ SELECT 1 FROM tasks, json_each(tasks.payload, '$.attachments') AS file
3384
+ WHERE json_extract(file.value, '$.id') = attachments.id
3385
+ ) AND NOT EXISTS (
3386
+ SELECT 1 FROM events, json_each(events.payload, '$.attachments') AS file
3387
+ WHERE json_extract(file.value, '$.id') = attachments.id
3388
+ )`);
3389
+ this.db.exec("COMMIT");
3390
+ } catch (error) {
3391
+ this.db.exec("ROLLBACK");
3392
+ throw error;
3393
+ }
3394
+ });
3395
+ }
2958
3396
  saveAttachment(attachment) {
2959
3397
  return this.query(() => {
2960
3398
  this.db.prepare("INSERT INTO attachments(id, payload) VALUES (?, ?)").run(attachment.id, JSON.stringify(AttachmentSchema.parse(attachment)));
@@ -3011,7 +3449,7 @@ var SqliteSessionStore = class {
3011
3449
  }
3012
3450
  });
3013
3451
  }
3014
- append(event) {
3452
+ append(event, notification) {
3015
3453
  return this.query(() => {
3016
3454
  const sessionId = event.type === "session.updated" ? event.session.id : event.sessionId;
3017
3455
  const recordedAt = (/* @__PURE__ */ new Date()).toISOString();
@@ -3024,6 +3462,14 @@ var SqliteSessionStore = class {
3024
3462
  }
3025
3463
  if (event.type === "message.user") this.claimAttachments(event.attachments ?? []);
3026
3464
  const result = this.db.prepare("INSERT INTO events(session_id, recorded_at, payload) VALUES (?, ?, ?)").run(sessionId, recordedAt, JSON.stringify(event));
3465
+ if (notification) {
3466
+ const count = z15.object({ total: z15.number() }).parse(this.db.prepare("SELECT COUNT(*) AS total FROM push_outbox").get());
3467
+ if (count.total >= 1e3)
3468
+ this.db.prepare(
3469
+ "DELETE FROM push_outbox WHERE rowid IN (SELECT rowid FROM push_outbox ORDER BY rowid LIMIT 1)"
3470
+ ).run();
3471
+ this.db.prepare("INSERT INTO push_outbox(id, payload) VALUES (?, ?)").run(notification.eventId, JSON.stringify(PendingPushSchema.parse(notification)));
3472
+ }
3027
3473
  const record = RecordedEventSchema.parse({
3028
3474
  sequence: Number(result.lastInsertRowid),
3029
3475
  recordedAt,
@@ -3060,13 +3506,16 @@ var SessionStore = class extends Context3.Service()(
3060
3506
  static layer(filename) {
3061
3507
  return Layer3.effect(
3062
3508
  this,
3063
- Effect13.acquireRelease(
3064
- Effect13.try({
3509
+ Effect14.acquireRelease(
3510
+ Effect14.try({
3065
3511
  try: () => {
3066
- mkdirSync(path6.dirname(filename), { recursive: true });
3512
+ mkdirSync2(path7.dirname(filename), { recursive: true });
3067
3513
  const db = new DatabaseSync(filename);
3514
+ chmodSync(filename, 384);
3515
+ for (const suffix of ["-wal", "-shm"])
3516
+ if (existsSync2(filename + suffix)) chmodSync(filename + suffix, 384);
3068
3517
  try {
3069
- const schema = z12.object({ user_version: z12.number() }).parse(db.prepare("PRAGMA user_version").get());
3518
+ const schema = z15.object({ user_version: z15.number() }).parse(db.prepare("PRAGMA user_version").get());
3070
3519
  if (schema.user_version > 1)
3071
3520
  throw new Error("This database requires a newer Agentrific server.");
3072
3521
  db.exec(`
@@ -3077,6 +3526,7 @@ var SessionStore = class extends Context3.Service()(
3077
3526
  CREATE TABLE IF NOT EXISTS settings(id TEXT PRIMARY KEY, payload TEXT NOT NULL);
3078
3527
  CREATE TABLE IF NOT EXISTS sessions(id TEXT PRIMARY KEY, payload TEXT NOT NULL);
3079
3528
  CREATE TABLE IF NOT EXISTS tasks(id TEXT PRIMARY KEY, payload TEXT NOT NULL);
3529
+ CREATE TABLE IF NOT EXISTS push_outbox(id TEXT PRIMARY KEY, payload TEXT NOT NULL);
3080
3530
  CREATE TABLE IF NOT EXISTS workflows(id TEXT PRIMARY KEY, payload TEXT NOT NULL);
3081
3531
  CREATE TABLE IF NOT EXISTS attachments(id TEXT PRIMARY KEY, payload TEXT NOT NULL, claimed INTEGER NOT NULL DEFAULT 0);
3082
3532
  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);
@@ -3092,18 +3542,81 @@ var SessionStore = class extends Context3.Service()(
3092
3542
  },
3093
3543
  catch: (cause) => new StorageError({ message: "Could not open session database", cause })
3094
3544
  }),
3095
- (db) => Effect13.sync(() => db.close())
3096
- ).pipe(Effect13.map((db) => new SqliteSessionStore(db)))
3545
+ (db) => Effect14.sync(() => db.close())
3546
+ ).pipe(Effect14.map((db) => new SqliteSessionStore(db)))
3097
3547
  );
3098
3548
  }
3099
3549
  };
3100
3550
 
3551
+ // src/worktree-cleanup.ts
3552
+ import { lstat, rm as rm2 } from "node:fs/promises";
3553
+ import path8 from "node:path";
3554
+ import { Effect as Effect15 } from "effect";
3555
+ var WorktreeCleanup = class {
3556
+ constructor(store2, root, projectsRoot) {
3557
+ this.store = store2;
3558
+ this.root = root;
3559
+ this.projectsRoot = projectsRoot;
3560
+ }
3561
+ store;
3562
+ root;
3563
+ projectsRoot;
3564
+ async remove(session, discardChanges = true) {
3565
+ if (session.gitFlow?.workspace !== "worktree" || !session.workspacePath) return;
3566
+ const workspace = path8.resolve(session.workspacePath);
3567
+ const info = await lstat(workspace).catch((error) => {
3568
+ if (error.code === "ENOENT") return void 0;
3569
+ throw error;
3570
+ });
3571
+ if (workspace !== path8.join(this.root, session.id) || path8.basename(session.id) !== session.id) {
3572
+ if (!info) return;
3573
+ throw new Error("Refusing to remove a worktree outside the managed workspaces directory.");
3574
+ }
3575
+ if (info?.isSymbolicLink()) throw new Error("Refusing to remove a symlink worktree.");
3576
+ const job = await Effect15.runPromise(this.store.workflow(`preparation:${session.id}`));
3577
+ const projects = await Effect15.runPromise(this.store.registeredProjects(this.projectsRoot));
3578
+ const project = job?.kind === "preparation" ? job.projectPath : projects.find((item) => item.id === session.projectId)?.path;
3579
+ if (!project) throw new Error("Reconnect the project before removing its worktree.");
3580
+ if (info) {
3581
+ await workflowCommand(project, "git", [
3582
+ "worktree",
3583
+ "remove",
3584
+ ...discardChanges ? ["--force"] : [],
3585
+ workspace
3586
+ ]);
3587
+ } else {
3588
+ await workflowCommand(project, "git", ["worktree", "prune"]);
3589
+ }
3590
+ }
3591
+ async prune(maxAgeDays, now = Date.now()) {
3592
+ if (!Number.isFinite(maxAgeDays) || maxAgeDays <= 0) return;
3593
+ const sessions = await Effect15.runPromise(this.store.list());
3594
+ const failures = [];
3595
+ for (const session of sessions) {
3596
+ const lockId = await sessionTaskLockId(this.store, session);
3597
+ await withTaskLock(lockId, async () => {
3598
+ const current = await Effect15.runPromise(this.store.get(session.id));
3599
+ if (!current || taskStatusForSession(current.status, current) !== "done" || now - Date.parse(current.updatedAt) < maxAgeDays * 864e5)
3600
+ return;
3601
+ await this.remove(current, false);
3602
+ }).catch((error) => failures.push(error));
3603
+ }
3604
+ if (failures.length)
3605
+ throw new AggregateError(failures, "Some completed worktrees could not be pruned.");
3606
+ }
3607
+ };
3608
+ async function removeRunFiles(dataRoot2, sessionId) {
3609
+ if (path8.basename(sessionId) !== sessionId || sessionId === "." || sessionId === "..")
3610
+ throw new Error("Invalid session directory.");
3611
+ await rm2(path8.join(dataRoot2, "pi", sessionId), { recursive: true, force: true });
3612
+ }
3613
+
3101
3614
  // src/index.ts
3102
3615
  var host = process.env.AGENTRIFIC_HOST ?? "127.0.0.1";
3103
3616
  var port = Number(process.env.AGENTRIFIC_PORT ?? 4318);
3104
- var defaultProjectsRoot = path7.resolve(process.env.AGENTRIFIC_PROJECTS_DIR ?? "./projects");
3105
- var worktreesRoot = path7.resolve(
3106
- process.env.AGENTRIFIC_WORKTREES_DIR ?? path7.join(homedir2(), ".agentrific", "workspaces")
3617
+ var defaultProjectsRoot = path9.resolve(process.env.AGENTRIFIC_PROJECTS_DIR ?? "./projects");
3618
+ var worktreesRoot = path9.resolve(
3619
+ process.env.AGENTRIFIC_WORKTREES_DIR ?? path9.join(homedir2(), ".agentrific", "workspaces")
3107
3620
  );
3108
3621
  var execFileAsync = promisify2(execFile2);
3109
3622
  if (host !== "127.0.0.1" && host !== "::1")
@@ -3126,13 +3639,18 @@ var app = Fastify({
3126
3639
  }
3127
3640
  }
3128
3641
  });
3129
- var dataRoot = path7.resolve(process.env.AGENTRIFIC_DATA_DIR ?? "./data");
3642
+ var dataRoot = path9.resolve(process.env.AGENTRIFIC_DATA_DIR ?? "./data");
3130
3643
  var releaseLock = await acquireServerLock(dataRoot);
3131
- var effects = ManagedRuntime.make(SessionStore.layer(path7.join(dataRoot, "sessions.sqlite")));
3644
+ var effects = ManagedRuntime.make(SessionStore.layer(path9.join(dataRoot, "sessions.sqlite")));
3132
3645
  var store = await effects.runPromise(SessionStore);
3133
- var security = new ServerSecurity(store, await Effect14.runPromise(store.serverIdentity()));
3646
+ var security = new ServerSecurity(store, await Effect16.runPromise(store.serverIdentity()));
3647
+ var testPushUrl = process.env.AGENTRIFIC_PUSH_TEST_URL;
3648
+ if (testPushUrl && (process.env.AGENTRIFIC_TEST_MODE !== "1" || !new URL(testPushUrl).hostname.endsWith(".ts.net") || new URL(testPushUrl).protocol !== "https:"))
3649
+ throw new Error("Test relay requires explicit test mode and Tailscale HTTPS.");
3650
+ var push = new ServerPush(store, security.serverId, dataRoot, fetch, testPushUrl);
3134
3651
  var allowedOrigins = [
3135
3652
  .../* @__PURE__ */ new Set([
3653
+ "agentrific-desktop://app",
3136
3654
  ...(process.env.AGENTRIFIC_ALLOWED_ORIGINS ?? "http://localhost:8081,http://127.0.0.1:8081").split(","),
3137
3655
  `http://${host === "::1" ? "[::1]" : host}:${port}`,
3138
3656
  ...process.env.AGENTRIFIC_PUBLIC_URL ? [new URL(process.env.AGENTRIFIC_PUBLIC_URL).origin] : []
@@ -3143,14 +3661,15 @@ await app.register(cors, {
3143
3661
  methods: ["GET", "POST", "PATCH", "DELETE"]
3144
3662
  });
3145
3663
  await registerSecurity(app, security, serverVersion, allowedOrigins);
3664
+ registerPushRoutes(app, push, security);
3146
3665
  var attachmentEffects = ManagedRuntime.make(
3147
- Attachments.layer(path7.join(dataRoot, "attachments"), store)
3666
+ Attachments.layer(path9.join(dataRoot, "attachments"), store)
3148
3667
  );
3149
3668
  var attachments = await attachmentEffects.runPromise(Attachments);
3150
- await Effect14.runPromise(attachments.cleanup());
3669
+ await Effect16.runPromise(attachments.cleanup());
3151
3670
  var attachmentCleanup = setInterval(
3152
3671
  () => {
3153
- void Effect14.runPromise(attachments.cleanup()).catch((error) => app.log.error(error));
3672
+ void Effect16.runPromise(attachments.cleanup()).catch((error) => app.log.error(error));
3154
3673
  },
3155
3674
  60 * 60 * 1e3
3156
3675
  );
@@ -3161,14 +3680,14 @@ await app.register(multipart, {
3161
3680
  app.post(
3162
3681
  "/api/projects/:projectId/attachments",
3163
3682
  async (request, reply) => {
3164
- const root = await Effect14.runPromise(store.projectsRoot(defaultProjectsRoot));
3165
- const projects = await Effect14.runPromise(discoverProjects(root, store));
3683
+ const root = await Effect16.runPromise(store.projectsRoot(defaultProjectsRoot));
3684
+ const projects = await Effect16.runPromise(discoverProjects(root, store));
3166
3685
  if (!projects.some((project) => project.id === request.params.projectId && project.available))
3167
3686
  return reply.code(404).send({ error: "Project not found." });
3168
3687
  try {
3169
3688
  const file = await request.file();
3170
3689
  if (!file) return reply.code(400).send({ error: "Choose a file to upload." });
3171
- return reply.code(201).send(await Effect14.runPromise(attachments.upload(request.params.projectId, file)));
3690
+ return reply.code(201).send(await Effect16.runPromise(attachments.upload(request.params.projectId, file)));
3172
3691
  } catch (error) {
3173
3692
  return reply.code(400).send({ error: error instanceof Error ? error.message : "Upload failed." });
3174
3693
  }
@@ -3177,7 +3696,7 @@ app.post(
3177
3696
  app.get(
3178
3697
  "/api/projects/:projectId/attachments/:id/content",
3179
3698
  async (request, reply) => {
3180
- const attachment = await Effect14.runPromise(store.getAttachment(request.params.id));
3699
+ const attachment = await Effect16.runPromise(store.getAttachment(request.params.id));
3181
3700
  if (!attachment || attachment.projectId !== request.params.projectId)
3182
3701
  return reply.code(404).send({ error: "Attachment not found." });
3183
3702
  const preview = request.query.preview === "1" && attachment.kind === "image";
@@ -3191,7 +3710,7 @@ app.get(
3191
3710
  }
3192
3711
  );
3193
3712
  function publish(event, workflowUpdate = false) {
3194
- const previous = event.type === "session.updated" ? Effect14.runSync(store.get(event.session.id)) : void 0;
3713
+ const previous = event.type === "session.updated" ? Effect16.runSync(store.get(event.session.id)) : void 0;
3195
3714
  const emittedEvent = event.type === "session.updated" && previous && !workflowUpdate ? {
3196
3715
  type: "session.updated",
3197
3716
  session: {
@@ -3200,7 +3719,8 @@ function publish(event, workflowUpdate = false) {
3200
3719
  preparation: previous.preparation
3201
3720
  }
3202
3721
  } : event;
3203
- const record = Effect14.runSync(store.append(emittedEvent));
3722
+ const notification = emittedEvent.type === "session.updated" ? push.pending(emittedEvent.session, previous) : void 0;
3723
+ const record = Effect16.runSync(store.append(emittedEvent, notification));
3204
3724
  const message = JSON.stringify({ type: "event", record });
3205
3725
  for (const client of security.sockets.keys()) {
3206
3726
  if (!(client instanceof WebSocket) || client.readyState !== WebSocket.OPEN) continue;
@@ -3213,8 +3733,8 @@ var delivery = new DeliveryCoordinator(store, {
3213
3733
  publish: (event) => publish(event, true),
3214
3734
  draft: deliveryWriter(models),
3215
3735
  projectPath: async (session) => {
3216
- const projectsRoot = await Effect14.runPromise(store.projectsRoot(defaultProjectsRoot));
3217
- const project = (await Effect14.runPromise(discoverProjects(projectsRoot, store))).find(
3736
+ const projectsRoot = await Effect16.runPromise(store.projectsRoot(defaultProjectsRoot));
3737
+ const project = (await Effect16.runPromise(discoverProjects(projectsRoot, store))).find(
3218
3738
  (item) => item.id === session.projectId && item.available
3219
3739
  );
3220
3740
  if (!project) throw new Error("Reconnect the project folder before delivering changes.");
@@ -3227,12 +3747,27 @@ var agentEffects = ManagedRuntime.make(
3227
3747
  createPiFactory(attachments, models, delivery),
3228
3748
  publish,
3229
3749
  worktreesRoot,
3230
- path7.join(dataRoot, "pi"),
3231
- (sessionId, messageId) => Effect14.runSync(store.hasMessage(sessionId, messageId)),
3750
+ path9.join(dataRoot, "pi"),
3751
+ (sessionId, messageId) => Effect16.runSync(store.hasMessage(sessionId, messageId)),
3232
3752
  (session) => delivery.settled(session.id)
3233
3753
  )
3234
3754
  );
3235
3755
  var runtime = await agentEffects.runPromise(AgentSessions);
3756
+ var worktreeCleanup = new WorktreeCleanup(store, worktreesRoot, defaultProjectsRoot);
3757
+ var maxWorktreeAgeDays = Number(process.env.AGENTRIFIC_DONE_WORKTREE_MAX_AGE_DAYS ?? 0);
3758
+ if (!Number.isInteger(maxWorktreeAgeDays) || maxWorktreeAgeDays < 0)
3759
+ throw new Error("AGENTRIFIC_DONE_WORKTREE_MAX_AGE_DAYS must be a nonnegative integer.");
3760
+ var pruneWorktrees = () => withTaskLock("worktree-pruning", async () => {
3761
+ await worktreeCleanup.prune(maxWorktreeAgeDays);
3762
+ });
3763
+ var worktreeCleanupTimer = setInterval(
3764
+ () => {
3765
+ void pruneWorktrees().catch((error) => app.log.error(error));
3766
+ },
3767
+ 60 * 60 * 1e3
3768
+ );
3769
+ worktreeCleanupTimer.unref();
3770
+ void pruneWorktrees().catch((error) => app.log.error(error));
3236
3771
  var updates = new UpdateDiscovery(store);
3237
3772
  var maintenance = new Maintenance(() => [
3238
3773
  ...runtime.workloads(),
@@ -3248,7 +3783,7 @@ var stopUpdateDiscovery = updates.start(
3248
3783
  () => maintenance.ready(),
3249
3784
  (error) => app.log.error(error)
3250
3785
  );
3251
- for (const session of await Effect14.runPromise(store.list())) {
3786
+ for (const session of await Effect16.runPromise(store.list())) {
3252
3787
  if (session.gitDelivery?.status === "pending") {
3253
3788
  publish(
3254
3789
  {
@@ -3292,7 +3827,7 @@ async function githubCliStatus() {
3292
3827
  }
3293
3828
  app.get("/api/connections", async () => githubCliStatus());
3294
3829
  app.get("/api/settings", async () => ({
3295
- projectsRoot: await Effect14.runPromise(store.projectsRoot(defaultProjectsRoot)),
3830
+ projectsRoot: await Effect16.runPromise(store.projectsRoot(defaultProjectsRoot)),
3296
3831
  defaultModel: await models.defaultIdentity()
3297
3832
  }));
3298
3833
  app.get("/api/models", async (_request, reply) => {
@@ -3320,7 +3855,7 @@ app.post("/api/settings/model", async (request, reply) => {
3320
3855
  try {
3321
3856
  const defaultModel = await models.setDefault(parsed.data);
3322
3857
  return {
3323
- projectsRoot: await Effect14.runPromise(store.projectsRoot(defaultProjectsRoot)),
3858
+ projectsRoot: await Effect16.runPromise(store.projectsRoot(defaultProjectsRoot)),
3324
3859
  defaultModel
3325
3860
  };
3326
3861
  } catch {
@@ -3329,54 +3864,57 @@ app.post("/api/settings/model", async (request, reply) => {
3329
3864
  });
3330
3865
  }
3331
3866
  });
3332
- app.post("/api/sessions/:id/model", async (request, reply) => {
3333
- const parsed = ModelReferenceSchema.safeParse(request.body);
3334
- const session = await Effect14.runPromise(store.get(request.params.id));
3335
- if (!session) return reply.code(404).send({ error: "Session not found." });
3336
- if (!parsed.success)
3337
- return reply.code(400).send({ error: "Choose a model from the server catalogue." });
3338
- try {
3339
- await models.resolve(parsed.data);
3340
- return await Effect14.runPromise(runtime.setModel(session, parsed.data));
3341
- } catch {
3342
- return reply.code(409).send({
3343
- error: "Could not change the model. Wait for the current reply to finish and choose an available model."
3344
- });
3345
- }
3346
- });
3867
+ app.post(
3868
+ "/api/sessions/:id/model",
3869
+ async (request, reply) => withSessionTaskLock(request.params.id, async () => {
3870
+ const parsed = ModelReferenceSchema.safeParse(request.body);
3871
+ const session = await Effect16.runPromise(store.get(request.params.id));
3872
+ if (!session) return reply.code(404).send({ error: "Session not found." });
3873
+ if (!parsed.success)
3874
+ return reply.code(400).send({ error: "Choose a model from the server catalogue." });
3875
+ try {
3876
+ await models.resolve(parsed.data);
3877
+ return await Effect16.runPromise(runtime.setModel(session, parsed.data));
3878
+ } catch {
3879
+ return reply.code(409).send({
3880
+ error: "Could not change the model. Wait for the current reply to finish and choose an available model."
3881
+ });
3882
+ }
3883
+ })
3884
+ );
3347
3885
  app.post("/api/settings", async (request, reply) => {
3348
3886
  const parsed = UpdateSettingsSchema.safeParse(request.body);
3349
3887
  if (!parsed.success) return reply.code(400).send({ error: "Enter a projects folder." });
3350
- const result = await Effect14.runPromise(
3351
- resolveProjectsRoot(parsed.data.projectsRoot).pipe(Effect14.result)
3888
+ const result = await Effect16.runPromise(
3889
+ resolveProjectsRoot(parsed.data.projectsRoot).pipe(Effect16.result)
3352
3890
  );
3353
3891
  if (result._tag === "Failure") return reply.code(400).send({ error: result.failure.message });
3354
- return Effect14.runPromise(store.setProjectsRoot(result.success));
3892
+ return Effect16.runPromise(store.setProjectsRoot(result.success));
3355
3893
  });
3356
3894
  app.get("/api/projects", async (_request, reply) => {
3357
- const root = await Effect14.runPromise(store.projectsRoot(defaultProjectsRoot));
3358
- const result = await Effect14.runPromise(discoverProjects(root, store).pipe(Effect14.result));
3895
+ const root = await Effect16.runPromise(store.projectsRoot(defaultProjectsRoot));
3896
+ const result = await Effect16.runPromise(discoverProjects(root, store).pipe(Effect16.result));
3359
3897
  if (result._tag === "Failure") return reply.code(400).send({ error: result.failure.message });
3360
3898
  return result.success;
3361
3899
  });
3362
3900
  app.get("/api/projects/:id", async (request, reply) => {
3363
- const root = await Effect14.runPromise(store.projectsRoot(defaultProjectsRoot));
3364
- const result = await Effect14.runPromise(discoverProjects(root, store).pipe(Effect14.result));
3901
+ const root = await Effect16.runPromise(store.projectsRoot(defaultProjectsRoot));
3902
+ const result = await Effect16.runPromise(discoverProjects(root, store).pipe(Effect16.result));
3365
3903
  if (result._tag === "Failure") return reply.code(400).send({ error: result.failure.message });
3366
3904
  const project = result.success.find((item) => item.id === request.params.id);
3367
3905
  return project ?? reply.code(404).send({ error: "Project not found on this server." });
3368
3906
  });
3369
3907
  app.post("/api/projects/:id/reconnect", async (request, reply) => {
3370
- const parsed = z13.object({ path: z13.string().trim().min(1).max(4096) }).safeParse(request.body);
3908
+ const parsed = z16.object({ path: z16.string().trim().min(1).max(4096) }).safeParse(request.body);
3371
3909
  if (!parsed.success)
3372
3910
  return reply.code(400).send({ error: "Enter the project's new folder path." });
3373
- const root = await Effect14.runPromise(store.projectsRoot(defaultProjectsRoot));
3374
- const result = await Effect14.runPromise(
3375
- reconnectProject(root, request.params.id, parsed.data.path, store).pipe(Effect14.result)
3911
+ const root = await Effect16.runPromise(store.projectsRoot(defaultProjectsRoot));
3912
+ const result = await Effect16.runPromise(
3913
+ reconnectProject(root, request.params.id, parsed.data.path, store).pipe(Effect16.result)
3376
3914
  );
3377
3915
  return result._tag === "Failure" ? reply.code(400).send({ error: result.failure.message }) : result.success;
3378
3916
  });
3379
- app.get("/api/sessions", () => Effect14.runPromise(store.list()));
3917
+ app.get("/api/sessions", () => Effect16.runPromise(store.list()));
3380
3918
  app.get(
3381
3919
  "/api/tasks",
3382
3920
  (request) => boardTasks(store, request.query.projectId)
@@ -3389,15 +3927,15 @@ app.post("/api/tasks", async (request, reply) => {
3389
3927
  if (!github.githubCli.authenticated)
3390
3928
  return reply.code(400).send({ error: "GitHub CLI must be authenticated on the server to open a PR." });
3391
3929
  }
3392
- const projectsRoot = await Effect14.runPromise(store.projectsRoot(defaultProjectsRoot));
3393
- const projects = await Effect14.runPromise(
3394
- discoverProjects(projectsRoot, store).pipe(Effect14.result)
3930
+ const projectsRoot = await Effect16.runPromise(store.projectsRoot(defaultProjectsRoot));
3931
+ const projects = await Effect16.runPromise(
3932
+ discoverProjects(projectsRoot, store).pipe(Effect16.result)
3395
3933
  );
3396
3934
  if (projects._tag === "Failure" || !projects.success.some((project) => project.id === parsed.data.projectId && project.available))
3397
3935
  return reply.code(404).send({ error: "Project not found in the server's projects folder." });
3398
3936
  let files;
3399
3937
  try {
3400
- files = await Effect14.runPromise(
3938
+ files = await Effect16.runPromise(
3401
3939
  attachments.resolve(parsed.data.projectId, parsed.data.attachmentIds ?? [])
3402
3940
  );
3403
3941
  } catch (error) {
@@ -3405,7 +3943,7 @@ app.post("/api/tasks", async (request, reply) => {
3405
3943
  }
3406
3944
  const now = (/* @__PURE__ */ new Date()).toISOString();
3407
3945
  const task = TaskSchema.parse({
3408
- id: randomUUID7(),
3946
+ id: randomUUID8(),
3409
3947
  ...parsed.data,
3410
3948
  attachments: files,
3411
3949
  status: "backlog",
@@ -3413,7 +3951,7 @@ app.post("/api/tasks", async (request, reply) => {
3413
3951
  createdAt: now,
3414
3952
  updatedAt: now
3415
3953
  });
3416
- return reply.code(201).send(await Effect14.runPromise(store.saveTask(task)));
3954
+ return reply.code(201).send(await Effect16.runPromise(store.saveTask(task)));
3417
3955
  });
3418
3956
  app.patch("/api/tasks/:id", async (request, reply) => {
3419
3957
  const parsed = UpdateTaskSchema.safeParse(request.body);
@@ -3428,8 +3966,8 @@ app.patch("/api/tasks/:id", async (request, reply) => {
3428
3966
  return reply.code(400).send({ error: "GitHub CLI must be authenticated on the server to open a PR." });
3429
3967
  }
3430
3968
  return await editTask(store, request.params.id, parsed.data, async (projectId, ids) => {
3431
- const result = await Effect14.runPromise(
3432
- attachments.resolve(projectId, ids).pipe(Effect14.result)
3969
+ const result = await Effect16.runPromise(
3970
+ attachments.resolve(projectId, ids).pipe(Effect16.result)
3433
3971
  );
3434
3972
  if (result._tag === "Failure") throw new TaskActionError(result.failure.message, 400);
3435
3973
  return result.success;
@@ -3442,7 +3980,12 @@ app.patch("/api/tasks/:id", async (request, reply) => {
3442
3980
  });
3443
3981
  app.delete("/api/tasks/:id", async (request, reply) => {
3444
3982
  try {
3445
- await deleteTask(store, request.params.id);
3983
+ await deleteTask(store, request.params.id, async (session) => {
3984
+ await Effect16.runPromise(runtime.forget(session.id));
3985
+ await worktreeCleanup.remove(session);
3986
+ await removeRunFiles(dataRoot, session.id);
3987
+ });
3988
+ await Effect16.runPromise(attachments.cleanup());
3446
3989
  return reply.code(204).send();
3447
3990
  } catch (error) {
3448
3991
  if (error instanceof TaskActionError)
@@ -3451,31 +3994,34 @@ app.delete("/api/tasks/:id", async (request, reply) => {
3451
3994
  }
3452
3995
  });
3453
3996
  app.get("/api/sessions/:id", async (request, reply) => {
3454
- const session = await Effect14.runPromise(store.get(request.params.id));
3997
+ const session = await Effect16.runPromise(store.get(request.params.id));
3455
3998
  if (!session) return reply.code(404).send({ error: "Session not found." });
3456
3999
  return session;
3457
4000
  });
3458
- app.post("/api/sessions/:id/delivery", async (request, reply) => {
3459
- const session = await Effect14.runPromise(store.get(request.params.id));
3460
- if (!session) return reply.code(404).send({ error: "Session not found." });
3461
- try {
3462
- const result = await delivery.retry(session.id);
3463
- return reply.code(202).send(result);
3464
- } catch (error) {
3465
- return reply.code(409).send({ error: error instanceof Error ? error.message : "Delivery could not be retried." });
3466
- }
3467
- });
4001
+ app.post(
4002
+ "/api/sessions/:id/delivery",
4003
+ async (request, reply) => withSessionTaskLock(request.params.id, async () => {
4004
+ const session = await Effect16.runPromise(store.get(request.params.id));
4005
+ if (!session) return reply.code(404).send({ error: "Session not found." });
4006
+ try {
4007
+ const result = await delivery.retry(session.id);
4008
+ return reply.code(202).send(result);
4009
+ } catch (error) {
4010
+ return reply.code(409).send({ error: error instanceof Error ? error.message : "Delivery could not be retried." });
4011
+ }
4012
+ })
4013
+ );
3468
4014
  app.post(
3469
4015
  "/api/sessions/:id/preparation/retry",
3470
- async (request, reply) => {
4016
+ async (request, reply) => withSessionTaskLock(request.params.id, async () => {
3471
4017
  let preparedSession;
3472
4018
  try {
3473
4019
  const prepared = await preparation.retry(request.params.id);
3474
4020
  preparedSession = prepared.session;
3475
- const files = await Effect14.runPromise(
4021
+ const files = await Effect16.runPromise(
3476
4022
  attachments.resolve(prepared.session.projectId, prepared.input.attachmentIds ?? [])
3477
4023
  );
3478
- await Effect14.runPromise(runtime.send(prepared.session, prepared.input.prompt, files));
4024
+ await Effect16.runPromise(runtime.send(prepared.session, prepared.input.prompt, files));
3479
4025
  return reply.code(202).send(prepared.session);
3480
4026
  } catch (error) {
3481
4027
  if (preparedSession) {
@@ -3489,18 +4035,18 @@ app.post(
3489
4035
  error: error instanceof Error ? error.message : "Preparation could not be retried."
3490
4036
  });
3491
4037
  }
3492
- }
4038
+ })
3493
4039
  );
3494
- var HistoryQuery = z13.object({
3495
- after: z13.coerce.number().int().nonnegative().default(0),
3496
- limit: z13.coerce.number().int().min(1).max(1e3).default(500)
4040
+ var HistoryQuery = z16.object({
4041
+ after: z16.coerce.number().int().nonnegative().default(0),
4042
+ limit: z16.coerce.number().int().min(1).max(1e3).default(500)
3497
4043
  });
3498
4044
  app.get("/api/sessions/:id/events", async (request, reply) => {
3499
4045
  const query = HistoryQuery.safeParse(request.query);
3500
4046
  if (!query.success) return reply.code(400).send({ error: "Invalid history cursor or limit." });
3501
- const session = await Effect14.runPromise(store.get(request.params.id));
4047
+ const session = await Effect16.runPromise(store.get(request.params.id));
3502
4048
  if (!session) return reply.code(404).send({ error: "Session not found." });
3503
- return Effect14.runPromise(store.history(session.id, query.data.after, query.data.limit));
4049
+ return Effect16.runPromise(store.history(session.id, query.data.after, query.data.limit));
3504
4050
  });
3505
4051
  async function selectSessionModel(input, hasImages) {
3506
4052
  try {
@@ -3520,26 +4066,26 @@ async function selectSessionModel(input, hasImages) {
3520
4066
  }
3521
4067
  }
3522
4068
  async function resolveSessionProject(projectId) {
3523
- const projectsRoot = await Effect14.runPromise(store.projectsRoot(defaultProjectsRoot));
3524
- const project = (await Effect14.runPromise(discoverProjects(projectsRoot, store))).find(
4069
+ const projectsRoot = await Effect16.runPromise(store.projectsRoot(defaultProjectsRoot));
4070
+ const project = (await Effect16.runPromise(discoverProjects(projectsRoot, store))).find(
3525
4071
  (item) => item.id === projectId && item.available
3526
4072
  );
3527
4073
  if (!project)
3528
4074
  return { error: "Project folder is missing. Reconnect it before starting a run.", status: 404 };
3529
4075
  const projectPath = await realpath2(project.path);
3530
4076
  const resolvedRoot = await realpath2(projectsRoot);
3531
- if (path7.dirname(projectPath) !== resolvedRoot)
4077
+ if (path9.dirname(projectPath) !== resolvedRoot)
3532
4078
  return { error: "Project path is outside projects root.", status: 400 };
3533
4079
  return { projectPath };
3534
4080
  }
3535
4081
  async function resolveSessionWorkspace(id, gitFlow, projectPath) {
3536
- const worktreePath = path7.join(worktreesRoot, id);
4082
+ const worktreePath = path9.join(worktreesRoot, id);
3537
4083
  const branch = gitFlow ? `agentrific/${id.slice(0, 8)}` : null;
3538
4084
  const useProjectCheckout = gitFlow?.workspace === "checkout";
3539
4085
  const workspacePath = !gitFlow || useProjectCheckout ? projectPath : worktreePath;
3540
4086
  if (useProjectCheckout) {
3541
4087
  const serverPath = await realpath2(process.cwd());
3542
- if (serverPath === projectPath || serverPath.startsWith(`${projectPath}${path7.sep}`))
4088
+ if (serverPath === projectPath || serverPath.startsWith(`${projectPath}${path9.sep}`))
3543
4089
  return {
3544
4090
  error: "The server runs from this project. Choose a worktree for this task.",
3545
4091
  status: 409
@@ -3563,7 +4109,7 @@ async function createAndStartSession(input) {
3563
4109
  const projectPath = location.projectPath;
3564
4110
  let files;
3565
4111
  try {
3566
- files = await Effect14.runPromise(
4112
+ files = await Effect16.runPromise(
3567
4113
  attachments.resolve(input.projectId, input.attachmentIds ?? [])
3568
4114
  );
3569
4115
  } catch (error) {
@@ -3576,11 +4122,11 @@ async function createAndStartSession(input) {
3576
4122
  if (selection.error !== void 0) return { error: selection.error, status: selection.status };
3577
4123
  const selectedModel = selection.model;
3578
4124
  const now = (/* @__PURE__ */ new Date()).toISOString();
3579
- const task = input.taskId ? await Effect14.runPromise(store.getTask(input.taskId)) : void 0;
4125
+ const task = input.taskId ? await Effect16.runPromise(store.getTask(input.taskId)) : void 0;
3580
4126
  if (input.taskId && sessionTaskUnavailable(task, input.projectId))
3581
4127
  return { error: "Task not found.", status: 404 };
3582
4128
  const session = SessionSchema.parse({
3583
- id: randomUUID7(),
4129
+ id: randomUUID8(),
3584
4130
  taskId: input.taskId ?? null,
3585
4131
  projectId: input.projectId,
3586
4132
  title: task?.title ?? input.prompt.slice(0, 80),
@@ -3605,7 +4151,7 @@ async function createAndStartSession(input) {
3605
4151
  );
3606
4152
  } catch (error) {
3607
4153
  if (task)
3608
- await Effect14.runPromise(
4154
+ await Effect16.runPromise(
3609
4155
  store.saveTask({ ...task, sessionId: session.id, updatedAt: (/* @__PURE__ */ new Date()).toISOString() })
3610
4156
  );
3611
4157
  return {
@@ -3614,7 +4160,7 @@ async function createAndStartSession(input) {
3614
4160
  };
3615
4161
  }
3616
4162
  try {
3617
- await Effect14.runPromise(runtime.send(running, input.prompt, files));
4163
+ await Effect16.runPromise(runtime.send(running, input.prompt, files));
3618
4164
  } catch (error) {
3619
4165
  publish({
3620
4166
  type: "session.updated",
@@ -3623,7 +4169,7 @@ async function createAndStartSession(input) {
3623
4169
  publish({ type: "error", sessionId: session.id, message: String(error) });
3624
4170
  return { error: "Could not start the agent session.", status: 500 };
3625
4171
  }
3626
- return { session: await Effect14.runPromise(store.get(session.id)), status: 201 };
4172
+ return { session: await Effect16.runPromise(store.get(session.id)), status: 201 };
3627
4173
  }
3628
4174
  app.post("/api/sessions", async (request, reply) => {
3629
4175
  const parsed = CreateSessionSchema.safeParse(request.body);
@@ -3635,17 +4181,17 @@ app.post("/api/sessions", async (request, reply) => {
3635
4181
  if (!result.session) return reply.code(result.status).send({ error: result.error });
3636
4182
  return reply.code(201).send(result.session);
3637
4183
  });
3638
- var StartTaskBody = z13.object({ prompt: z13.string().trim().max(2e4).optional() }).default({});
4184
+ var StartTaskBody = z16.object({ prompt: z16.string().trim().max(2e4).optional() }).default({});
3639
4185
  function startTask(id, instructionsOverride, automated = false) {
3640
4186
  return withTaskLock(
3641
4187
  "agent-dispatch",
3642
4188
  () => withTaskLock(id, async () => {
3643
4189
  maintenance.assertReady();
3644
- const task = await Effect14.runPromise(store.getTask(id));
4190
+ const task = await Effect16.runPromise(store.getTask(id));
3645
4191
  if (!task || task.deletedAt) throw new TaskActionError("Task not found.", 404);
3646
4192
  if (automated) {
3647
- const state = await Effect14.runPromise(store.automation());
3648
- if (activeAgents(await Effect14.runPromise(store.list())) >= state.config.parallelism) return;
4193
+ const state = await Effect16.runPromise(store.automation());
4194
+ if (activeAgents(await Effect16.runPromise(store.list())) >= state.config.parallelism) return;
3649
4195
  const backlog = await boardTasks(store);
3650
4196
  if (!backlog.some((item) => item.id === id && item.status === "backlog")) return;
3651
4197
  }
@@ -3653,7 +4199,7 @@ function startTask(id, instructionsOverride, automated = false) {
3653
4199
  const prompt = instructions ? `${task.title}
3654
4200
 
3655
4201
  ${instructions}` : task.title;
3656
- const linkedSession = task.sessionId ? await Effect14.runPromise(store.get(task.sessionId)) : void 0;
4202
+ const linkedSession = task.sessionId ? await Effect16.runPromise(store.get(task.sessionId)) : void 0;
3657
4203
  if (linkedSession && (["starting", "running", "queued"].includes(linkedSession.status) || linkedSession.gitDelivery?.status === "pending"))
3658
4204
  throw new TaskActionError("This task already has a running agent.", 409);
3659
4205
  const result = await createAndStartSession({
@@ -3667,7 +4213,7 @@ ${instructions}` : task.title;
3667
4213
  });
3668
4214
  if (!result.session)
3669
4215
  throw new TaskActionError(result.error ?? "Could not start task.", result.status);
3670
- const updatedTask = await Effect14.runPromise(
4216
+ const updatedTask = await Effect16.runPromise(
3671
4217
  store.saveTask({
3672
4218
  ...task,
3673
4219
  status: taskStatusForSession(result.session.status, result.session),
@@ -3702,8 +4248,8 @@ app.patch("/api/automation", async (request, reply) => {
3702
4248
  const parsed = AutomationConfigSchema.safeParse(request.body);
3703
4249
  if (!parsed.success)
3704
4250
  return reply.code(400).send({ error: parsed.error.issues.map((issue) => issue.message).join(" ") });
3705
- const root = await Effect14.runPromise(store.projectsRoot(defaultProjectsRoot));
3706
- const projects = await Effect14.runPromise(discoverProjects(root, store));
4251
+ const root = await Effect16.runPromise(store.projectsRoot(defaultProjectsRoot));
4252
+ const projects = await Effect16.runPromise(discoverProjects(root, store));
3707
4253
  if (parsed.data.projectIds.some(
3708
4254
  (id) => !projects.some((project) => project.id === id && project.available)
3709
4255
  ))
@@ -3713,7 +4259,7 @@ app.patch("/api/automation", async (request, reply) => {
3713
4259
  return automation.configure(parsed.data);
3714
4260
  });
3715
4261
  app.patch("/api/automation/queue", async (request, reply) => {
3716
- const parsed = z13.object({ taskId: z13.string().min(1), beforeId: z13.string().min(1).nullable() }).safeParse(request.body);
4262
+ const parsed = z16.object({ taskId: z16.string().min(1), beforeId: z16.string().min(1).nullable() }).safeParse(request.body);
3717
4263
  if (!parsed.success)
3718
4264
  return reply.code(400).send({ error: "Choose a task and a queue position." });
3719
4265
  try {
@@ -3730,8 +4276,8 @@ var automationTimer = setInterval(() => {
3730
4276
  automationTimer.unref();
3731
4277
  app.post(
3732
4278
  "/api/sessions/:id/messages",
3733
- async (request, reply) => {
3734
- const session = await Effect14.runPromise(store.get(request.params.id));
4279
+ async (request, reply) => withSessionTaskLock(request.params.id, async () => {
4280
+ const session = await Effect16.runPromise(store.get(request.params.id));
3735
4281
  if (!session) return reply.code(404).send({ error: "Session not found." });
3736
4282
  const parsed = SendMessageSchema.safeParse(request.body);
3737
4283
  if (!parsed.success) return reply.code(400).send({ error: parsed.error.flatten() });
@@ -3739,27 +4285,35 @@ app.post(
3739
4285
  await delivery.assertCanWork(session.id);
3740
4286
  if (session.preparation && session.preparation.status !== "ready")
3741
4287
  throw new Error("Retry workspace preparation before sending a message.");
3742
- const files = await Effect14.runPromise(
4288
+ const files = await Effect16.runPromise(
3743
4289
  attachments.resolve(session.projectId, parsed.data.attachmentIds ?? [])
3744
4290
  );
3745
- await Effect14.runPromise(
4291
+ await Effect16.runPromise(
3746
4292
  runtime.send(session, parsed.data.message, files, parsed.data.clientMessageId)
3747
4293
  );
3748
4294
  return reply.code(202).send({ accepted: true });
3749
4295
  } catch (error) {
3750
4296
  return reply.code(409).send({ error: String(error) });
3751
4297
  }
3752
- }
4298
+ })
4299
+ );
4300
+ async function withSessionTaskLock(id, operation) {
4301
+ const session = await Effect16.runPromise(store.get(id));
4302
+ const lockId = session ? await sessionTaskLockId(store, session) : `session-${id}`;
4303
+ return withTaskLock(lockId, operation);
4304
+ }
4305
+ app.post(
4306
+ "/api/sessions/:id/stop",
4307
+ async (request, reply) => withSessionTaskLock(request.params.id, async () => {
4308
+ const session = await Effect16.runPromise(store.get(request.params.id));
4309
+ if (!session) return reply.code(404).send({ error: "Session not found." });
4310
+ await Effect16.runPromise(runtime.stop(session));
4311
+ return reply.code(202).send({ accepted: true });
4312
+ })
3753
4313
  );
3754
- app.post("/api/sessions/:id/stop", async (request, reply) => {
3755
- const session = await Effect14.runPromise(store.get(request.params.id));
3756
- if (!session) return reply.code(404).send({ error: "Session not found." });
3757
- await Effect14.runPromise(runtime.stop(session));
3758
- return reply.code(202).send({ accepted: true });
3759
- });
3760
4314
  attachEventServer(app, security, allowedOrigins, () => ({
3761
4315
  type: "snapshot",
3762
- sessions: Effect14.runSync(store.list())
4316
+ sessions: Effect16.runSync(store.list())
3763
4317
  }));
3764
4318
  var closeControl = await startControl(
3765
4319
  dataRoot,
@@ -3771,20 +4325,25 @@ var closeControl = await startControl(
3771
4325
  connectedDevices: new Set(security.sockets.values()).size,
3772
4326
  connectedSockets: security.sockets.size,
3773
4327
  memory: process.memoryUsage().rss,
3774
- activeRuns: activeAgents(Effect14.runSync(store.list())),
4328
+ activeRuns: activeAgents(Effect16.runSync(store.list())),
3775
4329
  maintenance: maintenance.status()
3776
4330
  }),
3777
4331
  maintenance
3778
4332
  );
4333
+ push.start();
3779
4334
  app.addHook("onClose", async () => {
4335
+ await push.close();
3780
4336
  maintenance.enter();
3781
4337
  stopUpdateDiscovery();
3782
4338
  await updates.settle();
3783
4339
  clearInterval(attachmentCleanup);
4340
+ clearInterval(worktreeCleanupTimer);
4341
+ await withTaskLock("worktree-pruning", async () => {
4342
+ });
3784
4343
  clearInterval(automationTimer);
3785
4344
  await withTaskLock("automation", async () => {
3786
4345
  });
3787
- await Effect14.runPromise(runtime.close());
4346
+ await Effect16.runPromise(runtime.close());
3788
4347
  await agentEffects.dispose();
3789
4348
  await attachmentEffects.dispose();
3790
4349
  await effects.dispose();