@agentrific/server 0.3.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/index.js CHANGED
@@ -455,6 +455,7 @@ var RunPreparationSchema = z2.object({
455
455
  var SessionSchema = z2.object({
456
456
  id: z2.string(),
457
457
  taskId: z2.string().nullable().optional(),
458
+ kind: z2.enum(["task", "repo_chat"]).optional(),
458
459
  projectId: z2.string(),
459
460
  title: z2.string(),
460
461
  status: SessionStatusSchema,
@@ -585,11 +586,25 @@ var ConnectionsStatusSchema = z2.object({
585
586
  githubCli: z2.object({ installed: z2.boolean(), authenticated: z2.boolean() })
586
587
  });
587
588
  var UpdateSettingsSchema = z2.object({ projectsRoot: z2.string().trim().min(1).max(4096) });
589
+ var RepositoryIdentitySchema = z2.object({
590
+ key: z2.string(),
591
+ display: z2.string()
592
+ });
593
+ var ProjectGitStatusSchema = z2.object({
594
+ observedAt: z2.string().datetime(),
595
+ state: z2.enum(["available", "unavailable"]),
596
+ repository: RepositoryIdentitySchema.nullable(),
597
+ head: z2.string().nullable(),
598
+ branch: z2.string().nullable(),
599
+ trackedChanges: z2.number().int().nonnegative(),
600
+ untrackedChanges: z2.number().int().nonnegative()
601
+ });
588
602
  var ProjectSchema = z2.object({
589
603
  id: z2.string(),
590
604
  name: z2.string(),
591
605
  path: z2.string(),
592
- available: z2.boolean().default(true)
606
+ available: z2.boolean().default(true),
607
+ repository: RepositoryIdentitySchema.nullable().optional()
593
608
  });
594
609
  var TaskStatusSchema = z2.enum(["backlog", "needs_user_input", "running", "done"]);
595
610
  var TaskSchema = z2.object({
@@ -608,6 +623,7 @@ var TaskSchema = z2.object({
608
623
  deletedAt: z2.string().datetime().optional()
609
624
  });
610
625
  var CreateTaskSchema = z2.object({
626
+ clientTaskId: z2.string().min(1).max(128).regex(/^[a-zA-Z0-9_-]+$/).optional(),
611
627
  projectId: z2.string().min(1),
612
628
  title: z2.string().trim().min(1).max(120),
613
629
  prompt: z2.string().trim().max(2e4).default(""),
@@ -869,9 +885,9 @@ var Attachments = class extends Context.Service()(
869
885
  };
870
886
 
871
887
  // src/index.ts
872
- import { randomUUID as randomUUID8 } from "node:crypto";
873
- import { execFile as execFile2 } from "node:child_process";
874
- import { promisify as promisify2 } from "node:util";
888
+ import { randomUUID as randomUUID9 } from "node:crypto";
889
+ import { execFile as execFile3 } from "node:child_process";
890
+ import { promisify as promisify3 } from "node:util";
875
891
  import { realpath as realpath2 } from "node:fs/promises";
876
892
  import { homedir as homedir2 } from "node:os";
877
893
  import path9 from "node:path";
@@ -1096,7 +1112,7 @@ async function startControl(directory, security2, status, maintenance2) {
1096
1112
  // package.json
1097
1113
  var package_default = {
1098
1114
  name: "@agentrific/server",
1099
- version: "0.3.0",
1115
+ version: "0.5.0",
1100
1116
  description: "Self-hosted control plane for long-running coding agents",
1101
1117
  keywords: [
1102
1118
  "agentrific",
@@ -1482,7 +1498,7 @@ var UpdateDiscovery = class {
1482
1498
  };
1483
1499
 
1484
1500
  // src/index.ts
1485
- import { Effect as Effect16, ManagedRuntime } from "effect";
1501
+ import { Effect as Effect17, ManagedRuntime } from "effect";
1486
1502
  import { z as z17 } from "zod";
1487
1503
 
1488
1504
  // src/automation.ts
@@ -1570,7 +1586,9 @@ async function boardTasks(store2, projectId) {
1570
1586
  status: taskStatusForSession(session.status, session)
1571
1587
  };
1572
1588
  });
1573
- const legacyTasks = sessions.filter((session) => !session.taskId && !linkedSessionIds.has(session.id)).filter((session) => !projectId || session.projectId === projectId).map(sessionTask);
1589
+ const legacyTasks = sessions.filter(
1590
+ (session) => session.kind !== "repo_chat" && !session.taskId && !linkedSessionIds.has(session.id)
1591
+ ).filter((session) => !projectId || session.projectId === projectId).map(sessionTask);
1574
1592
  return [...linkedTasks, ...legacyTasks].map((task) => TaskSchema.parse(task)).sort((left, right) => right.updatedAt.localeCompare(left.updatedAt));
1575
1593
  }
1576
1594
  async function findTask(store2, id) {
@@ -1605,7 +1623,7 @@ function editTask(store2, id, input, resolveAttachments) {
1605
1623
  });
1606
1624
  }
1607
1625
  function deleteTask(store2, id, cleanup = async () => {
1608
- }) {
1626
+ }, unrunUpdatedAt) {
1609
1627
  return withTaskLock(id, async () => {
1610
1628
  const task = await findTask(store2, id);
1611
1629
  const sessions = await Effect6.runPromise(store2.list());
@@ -1616,6 +1634,8 @@ function deleteTask(store2, id, cleanup = async () => {
1616
1634
  const associated = sessions.filter(
1617
1635
  (session) => session.taskId === task.id || session.id === task.sessionId
1618
1636
  );
1637
+ if (unrunUpdatedAt !== void 0 && (task.updatedAt !== unrunUpdatedAt || task.sessionId || associated.length || task.status !== "backlog"))
1638
+ throw new TaskActionError("This task changed or has already run. It cannot be moved.", 409);
1619
1639
  for (const session of associated) await cleanup(session);
1620
1640
  await Effect6.runPromise(
1621
1641
  store2.purgeTask(
@@ -2024,7 +2044,7 @@ var exec = promisify(execFile);
2024
2044
  function workflowLog(text) {
2025
2045
  return stripVTControlCharacters(text).slice(-32e3);
2026
2046
  }
2027
- async function workflowCommand(cwd, command, args, signal, managedPreparation = false) {
2047
+ function workflowEnvironment() {
2028
2048
  const env = { ...process.env };
2029
2049
  for (const name of [
2030
2050
  "GIT_DIR",
@@ -2044,6 +2064,10 @@ async function workflowCommand(cwd, command, args, signal, managedPreparation =
2044
2064
  "GIT_REPLACE_REF_BASE"
2045
2065
  ])
2046
2066
  delete env[name];
2067
+ return env;
2068
+ }
2069
+ async function workflowCommand(cwd, command, args, signal, managedPreparation = false) {
2070
+ const env = workflowEnvironment();
2047
2071
  if (managedPreparation) env.AGENTRIFIC_MANAGED_PREPARATION = "1";
2048
2072
  const result = await exec(command, args, {
2049
2073
  cwd,
@@ -3208,6 +3232,106 @@ function reconnectProject(root, id, input, store2) {
3208
3232
  }).pipe(projectLock(store2).withPermit);
3209
3233
  }
3210
3234
 
3235
+ // src/project-git.ts
3236
+ import { execFile as execFile2 } from "node:child_process";
3237
+ import { promisify as promisify2 } from "node:util";
3238
+ var execute = promisify2(execFile2);
3239
+ function repositoryIdentity(remote) {
3240
+ const input = remote.trim();
3241
+ if (!input || input.startsWith("/") || input.startsWith(".")) return null;
3242
+ const scp = /^(?:[^@/\s]+@)?([^/:\s]+):([^\s]+)$/.exec(input);
3243
+ let url;
3244
+ try {
3245
+ url = new URL(scp && !input.includes("://") ? `ssh://${scp[1]}/${scp[2]}` : input);
3246
+ } catch {
3247
+ return null;
3248
+ }
3249
+ if (!["https:", "http:", "ssh:", "git:"].includes(url.protocol)) return null;
3250
+ if (url.search || url.hash || url.port) return null;
3251
+ const host2 = url.hostname.toLowerCase();
3252
+ let path10 = url.pathname.replace(/^\/+|\/+$/g, "").replace(/\.git$/, "");
3253
+ if (!host2 || !path10 || path10.includes("%") || path10.includes("//")) return null;
3254
+ if (["github.com", "bitbucket.org"].includes(host2)) path10 = path10.toLowerCase();
3255
+ const key = `${host2}/${path10}`;
3256
+ return { key, display: key };
3257
+ }
3258
+ async function git(directory, args) {
3259
+ const { stdout } = await execute("git", ["-C", directory, ...args], {
3260
+ timeout: 3e3,
3261
+ maxBuffer: 1024 * 1024,
3262
+ env: { ...workflowEnvironment(), GIT_OPTIONAL_LOCKS: "0", GIT_TERMINAL_PROMPT: "0" }
3263
+ });
3264
+ return stdout.trim();
3265
+ }
3266
+ async function readRepositoryIdentity(directory) {
3267
+ try {
3268
+ const remotes = (await git(directory, ["remote"])).split("\n").filter(Boolean);
3269
+ const remote = remotes.includes("origin") ? "origin" : remotes.length === 1 ? remotes[0] : null;
3270
+ if (!remote) return null;
3271
+ return repositoryIdentity(await git(directory, ["remote", "get-url", remote]));
3272
+ } catch {
3273
+ return null;
3274
+ }
3275
+ }
3276
+ async function inspect(directory) {
3277
+ const observedAt = (/* @__PURE__ */ new Date()).toISOString();
3278
+ try {
3279
+ const [repository, head, branch, status] = await Promise.all([
3280
+ readRepositoryIdentity(directory),
3281
+ git(directory, ["rev-parse", "--verify", "HEAD"]),
3282
+ git(directory, ["symbolic-ref", "--quiet", "--short", "HEAD"]).catch(() => null),
3283
+ // NUL-separated records avoid ambiguous filenames/newlines. --no-renames
3284
+ // keeps each entry one record, so we only need its two status characters.
3285
+ execute(
3286
+ "git",
3287
+ [
3288
+ "-C",
3289
+ directory,
3290
+ "status",
3291
+ "--porcelain=v1",
3292
+ "-z",
3293
+ "--no-renames",
3294
+ "--untracked-files=normal"
3295
+ ],
3296
+ {
3297
+ timeout: 3e3,
3298
+ maxBuffer: 1024 * 1024,
3299
+ env: { ...workflowEnvironment(), GIT_OPTIONAL_LOCKS: "0" }
3300
+ }
3301
+ )
3302
+ ]);
3303
+ const records = status.stdout.split("\0").filter(Boolean);
3304
+ return {
3305
+ observedAt,
3306
+ state: "available",
3307
+ repository,
3308
+ head,
3309
+ branch,
3310
+ trackedChanges: records.filter((record) => !record.startsWith("??")).length,
3311
+ untrackedChanges: records.filter((record) => record.startsWith("??")).length
3312
+ };
3313
+ } catch {
3314
+ return {
3315
+ observedAt,
3316
+ state: "unavailable",
3317
+ repository: null,
3318
+ head: null,
3319
+ branch: null,
3320
+ trackedChanges: 0,
3321
+ untrackedChanges: 0
3322
+ };
3323
+ }
3324
+ }
3325
+ var observations = /* @__PURE__ */ new Map();
3326
+ function projectGitStatus(directory) {
3327
+ const cached = observations.get(directory);
3328
+ if (cached && cached.expires > Date.now()) return cached.value;
3329
+ if (observations.size >= 200) observations.delete(observations.keys().next().value);
3330
+ const value = inspect(directory);
3331
+ observations.set(directory, { expires: Date.now() + 15e3, value });
3332
+ return value;
3333
+ }
3334
+
3211
3335
  // src/store.ts
3212
3336
  import { chmodSync, existsSync as existsSync2, mkdirSync as mkdirSync2 } from "node:fs";
3213
3337
  import path7 from "node:path";
@@ -3735,6 +3859,38 @@ async function removeRunFiles(dataRoot2, sessionId) {
3735
3859
  await rm2(path8.join(dataRoot2, "pi", sessionId), { recursive: true, force: true });
3736
3860
  }
3737
3861
 
3862
+ // src/repo-chat.ts
3863
+ import { randomUUID as randomUUID8 } from "node:crypto";
3864
+ import { Effect as Effect16 } from "effect";
3865
+ function openRepoChat(store2, projectId, workspacePath, defaultModel, reset = false) {
3866
+ return withTaskLock(`repo-chat:${projectId}`, async () => {
3867
+ const existing = (await Effect16.runPromise(store2.list())).filter((session2) => session2.projectId === projectId && session2.kind === "repo_chat").sort((a, b) => b.createdAt.localeCompare(a.createdAt))[0];
3868
+ if (existing && !reset) return existing;
3869
+ if (existing?.status === "running")
3870
+ throw new Error("Wait for the current reply before resetting chat.");
3871
+ const model = await defaultModel();
3872
+ if (!model) throw new Error("Choose an available default model in Settings before chatting.");
3873
+ const now = new Date(
3874
+ Math.max(Date.now(), existing ? Date.parse(existing.createdAt) + 1 : 0)
3875
+ ).toISOString();
3876
+ const session = {
3877
+ id: randomUUID8(),
3878
+ projectId,
3879
+ kind: "repo_chat",
3880
+ title: workspacePath.split("/").at(-1) ?? projectId,
3881
+ status: "waiting_for_input",
3882
+ agent: "pi",
3883
+ model,
3884
+ branch: null,
3885
+ workspacePath,
3886
+ createdAt: now,
3887
+ updatedAt: now
3888
+ };
3889
+ await Effect16.runPromise(store2.append({ type: "session.updated", session }));
3890
+ return session;
3891
+ });
3892
+ }
3893
+
3738
3894
  // src/index.ts
3739
3895
  var host = process.env.AGENTRIFIC_HOST ?? "127.0.0.1";
3740
3896
  var port = Number(process.env.AGENTRIFIC_PORT ?? 4318);
@@ -3742,7 +3898,7 @@ var defaultProjectsRoot = path9.resolve(process.env.AGENTRIFIC_PROJECTS_DIR ?? "
3742
3898
  var worktreesRoot = path9.resolve(
3743
3899
  process.env.AGENTRIFIC_WORKTREES_DIR ?? path9.join(homedir2(), ".agentrific", "workspaces")
3744
3900
  );
3745
- var execFileAsync = promisify2(execFile2);
3901
+ var execFileAsync = promisify3(execFile3);
3746
3902
  if (host !== "127.0.0.1" && host !== "::1")
3747
3903
  throw new Error("Bind Agentrific to loopback and expose it with Tailscale Serve or cloudflared.");
3748
3904
  var app = Fastify({
@@ -3767,7 +3923,7 @@ var dataRoot = path9.resolve(process.env.AGENTRIFIC_DATA_DIR ?? "./data");
3767
3923
  var releaseLock = await acquireServerLock(dataRoot);
3768
3924
  var effects = ManagedRuntime.make(SessionStore.layer(path9.join(dataRoot, "sessions.sqlite")));
3769
3925
  var store = await effects.runPromise(SessionStore);
3770
- var security = new ServerSecurity(store, await Effect16.runPromise(store.serverIdentity()));
3926
+ var security = new ServerSecurity(store, await Effect17.runPromise(store.serverIdentity()));
3771
3927
  var testPushUrl = process.env.AGENTRIFIC_PUSH_TEST_URL;
3772
3928
  if (testPushUrl && (process.env.AGENTRIFIC_TEST_MODE !== "1" || !new URL(testPushUrl).hostname.endsWith(".ts.net") || new URL(testPushUrl).protocol !== "https:"))
3773
3929
  throw new Error("Test relay requires explicit test mode and Tailscale HTTPS.");
@@ -3790,10 +3946,10 @@ var attachmentEffects = ManagedRuntime.make(
3790
3946
  Attachments.layer(path9.join(dataRoot, "attachments"), store)
3791
3947
  );
3792
3948
  var attachments = await attachmentEffects.runPromise(Attachments);
3793
- await Effect16.runPromise(attachments.cleanup());
3949
+ await Effect17.runPromise(attachments.cleanup());
3794
3950
  var attachmentCleanup = setInterval(
3795
3951
  () => {
3796
- void Effect16.runPromise(attachments.cleanup()).catch((error) => app.log.error(error));
3952
+ void Effect17.runPromise(attachments.cleanup()).catch((error) => app.log.error(error));
3797
3953
  },
3798
3954
  60 * 60 * 1e3
3799
3955
  );
@@ -3804,14 +3960,14 @@ await app.register(multipart, {
3804
3960
  app.post(
3805
3961
  "/api/projects/:projectId/attachments",
3806
3962
  async (request, reply) => {
3807
- const root = await Effect16.runPromise(store.projectsRoot(defaultProjectsRoot));
3808
- const projects = await Effect16.runPromise(discoverProjects(root, store));
3963
+ const root = await Effect17.runPromise(store.projectsRoot(defaultProjectsRoot));
3964
+ const projects = await Effect17.runPromise(discoverProjects(root, store));
3809
3965
  if (!projects.some((project) => project.id === request.params.projectId && project.available))
3810
3966
  return reply.code(404).send({ error: "Project not found." });
3811
3967
  try {
3812
3968
  const file = await request.file();
3813
3969
  if (!file) return reply.code(400).send({ error: "Choose a file to upload." });
3814
- return reply.code(201).send(await Effect16.runPromise(attachments.upload(request.params.projectId, file)));
3970
+ return reply.code(201).send(await Effect17.runPromise(attachments.upload(request.params.projectId, file)));
3815
3971
  } catch (error) {
3816
3972
  return reply.code(400).send({ error: error instanceof Error ? error.message : "Upload failed." });
3817
3973
  }
@@ -3820,7 +3976,7 @@ app.post(
3820
3976
  app.get(
3821
3977
  "/api/projects/:projectId/attachments/:id/content",
3822
3978
  async (request, reply) => {
3823
- const attachment = await Effect16.runPromise(store.getAttachment(request.params.id));
3979
+ const attachment = await Effect17.runPromise(store.getAttachment(request.params.id));
3824
3980
  if (!attachment || attachment.projectId !== request.params.projectId)
3825
3981
  return reply.code(404).send({ error: "Attachment not found." });
3826
3982
  const preview = request.query.preview === "1" && attachment.kind === "image";
@@ -3834,7 +3990,7 @@ app.get(
3834
3990
  }
3835
3991
  );
3836
3992
  function publish(event, workflowUpdate = false) {
3837
- const previous = event.type === "session.updated" ? Effect16.runSync(store.get(event.session.id)) : void 0;
3993
+ const previous = event.type === "session.updated" ? Effect17.runSync(store.get(event.session.id)) : void 0;
3838
3994
  const emittedEvent = event.type === "session.updated" && previous && !workflowUpdate ? {
3839
3995
  type: "session.updated",
3840
3996
  session: {
@@ -3844,7 +4000,7 @@ function publish(event, workflowUpdate = false) {
3844
4000
  }
3845
4001
  } : event;
3846
4002
  const notification = emittedEvent.type === "session.updated" ? push.pending(emittedEvent.session, previous) : void 0;
3847
- const record = Effect16.runSync(store.append(emittedEvent, notification));
4003
+ const record = Effect17.runSync(store.append(emittedEvent, notification));
3848
4004
  const message = JSON.stringify({ type: "event", record });
3849
4005
  for (const client of security.sockets.keys()) {
3850
4006
  if (!(client instanceof WebSocket) || client.readyState !== WebSocket.OPEN) continue;
@@ -3857,8 +4013,8 @@ var delivery = new DeliveryCoordinator(store, {
3857
4013
  publish: (event) => publish(event, true),
3858
4014
  draft: deliveryWriter(models),
3859
4015
  projectPath: async (session) => {
3860
- const projectsRoot = await Effect16.runPromise(store.projectsRoot(defaultProjectsRoot));
3861
- const project = (await Effect16.runPromise(discoverProjects(projectsRoot, store))).find(
4016
+ const projectsRoot = await Effect17.runPromise(store.projectsRoot(defaultProjectsRoot));
4017
+ const project = (await Effect17.runPromise(discoverProjects(projectsRoot, store))).find(
3862
4018
  (item) => item.id === session.projectId && item.available
3863
4019
  );
3864
4020
  if (!project) throw new Error("Reconnect the project folder before delivering changes.");
@@ -3872,7 +4028,7 @@ var agentEffects = ManagedRuntime.make(
3872
4028
  publish,
3873
4029
  worktreesRoot,
3874
4030
  path9.join(dataRoot, "pi"),
3875
- (sessionId, messageId) => Effect16.runSync(store.hasMessage(sessionId, messageId)),
4031
+ (sessionId, messageId) => Effect17.runSync(store.hasMessage(sessionId, messageId)),
3876
4032
  (session) => delivery.settled(session.id)
3877
4033
  )
3878
4034
  );
@@ -3907,7 +4063,7 @@ var stopUpdateDiscovery = updates.start(
3907
4063
  () => maintenance.ready(),
3908
4064
  (error) => app.log.error(error)
3909
4065
  );
3910
- for (const session of await Effect16.runPromise(store.list())) {
4066
+ for (const session of await Effect17.runPromise(store.list())) {
3911
4067
  if (session.gitDelivery?.status === "pending") {
3912
4068
  publish(
3913
4069
  {
@@ -3951,7 +4107,7 @@ async function githubCliStatus() {
3951
4107
  }
3952
4108
  app.get("/api/connections", async () => githubCliStatus());
3953
4109
  app.get("/api/settings", async () => ({
3954
- projectsRoot: await Effect16.runPromise(store.projectsRoot(defaultProjectsRoot)),
4110
+ projectsRoot: await Effect17.runPromise(store.projectsRoot(defaultProjectsRoot)),
3955
4111
  defaultModel: await models.defaultIdentity()
3956
4112
  }));
3957
4113
  app.get("/api/models", async (_request, reply) => {
@@ -3979,7 +4135,7 @@ app.post("/api/settings/model", async (request, reply) => {
3979
4135
  try {
3980
4136
  const defaultModel = await models.setDefault(parsed.data);
3981
4137
  return {
3982
- projectsRoot: await Effect16.runPromise(store.projectsRoot(defaultProjectsRoot)),
4138
+ projectsRoot: await Effect17.runPromise(store.projectsRoot(defaultProjectsRoot)),
3983
4139
  defaultModel
3984
4140
  };
3985
4141
  } catch {
@@ -3992,13 +4148,13 @@ app.post(
3992
4148
  "/api/sessions/:id/model",
3993
4149
  async (request, reply) => withSessionTaskLock(request.params.id, async () => {
3994
4150
  const parsed = ModelReferenceSchema.safeParse(request.body);
3995
- const session = await Effect16.runPromise(store.get(request.params.id));
4151
+ const session = await Effect17.runPromise(store.get(request.params.id));
3996
4152
  if (!session) return reply.code(404).send({ error: "Session not found." });
3997
4153
  if (!parsed.success)
3998
4154
  return reply.code(400).send({ error: "Choose a model from the server catalogue." });
3999
4155
  try {
4000
4156
  await models.resolve(parsed.data);
4001
- return await Effect16.runPromise(runtime.setModel(session, parsed.data));
4157
+ return await Effect17.runPromise(runtime.setModel(session, parsed.data));
4002
4158
  } catch {
4003
4159
  return reply.code(409).send({
4004
4160
  error: "Could not change the model. Wait for the current reply to finish and choose an available model."
@@ -4009,21 +4165,38 @@ app.post(
4009
4165
  app.post("/api/settings", async (request, reply) => {
4010
4166
  const parsed = UpdateSettingsSchema.safeParse(request.body);
4011
4167
  if (!parsed.success) return reply.code(400).send({ error: "Enter a projects folder." });
4012
- const result = await Effect16.runPromise(
4013
- resolveProjectsRoot(parsed.data.projectsRoot).pipe(Effect16.result)
4168
+ const result = await Effect17.runPromise(
4169
+ resolveProjectsRoot(parsed.data.projectsRoot).pipe(Effect17.result)
4014
4170
  );
4015
4171
  if (result._tag === "Failure") return reply.code(400).send({ error: result.failure.message });
4016
- return Effect16.runPromise(store.setProjectsRoot(result.success));
4172
+ return Effect17.runPromise(store.setProjectsRoot(result.success));
4017
4173
  });
4018
4174
  app.get("/api/projects", async (_request, reply) => {
4019
- const root = await Effect16.runPromise(store.projectsRoot(defaultProjectsRoot));
4020
- const result = await Effect16.runPromise(discoverProjects(root, store).pipe(Effect16.result));
4175
+ const root = await Effect17.runPromise(store.projectsRoot(defaultProjectsRoot));
4176
+ const result = await Effect17.runPromise(discoverProjects(root, store).pipe(Effect17.result));
4021
4177
  if (result._tag === "Failure") return reply.code(400).send({ error: result.failure.message });
4022
- return result.success;
4178
+ return Effect17.runPromise(
4179
+ Effect17.forEach(
4180
+ result.success,
4181
+ (project) => Effect17.promise(async () => ({
4182
+ ...project,
4183
+ repository: project.available ? await readRepositoryIdentity(project.path) : null
4184
+ })),
4185
+ { concurrency: 4 }
4186
+ )
4187
+ );
4188
+ });
4189
+ app.get("/api/projects/:id/git-status", async (request, reply) => {
4190
+ const root = await Effect17.runPromise(store.projectsRoot(defaultProjectsRoot));
4191
+ const projects = await Effect17.runPromise(discoverProjects(root, store));
4192
+ const project = projects.find((item) => item.id === request.params.id);
4193
+ if (!project?.available)
4194
+ return reply.code(404).send({ error: "Project folder unavailable on this server." });
4195
+ return projectGitStatus(project.path);
4023
4196
  });
4024
4197
  app.get("/api/projects/:id", async (request, reply) => {
4025
- const root = await Effect16.runPromise(store.projectsRoot(defaultProjectsRoot));
4026
- const result = await Effect16.runPromise(discoverProjects(root, store).pipe(Effect16.result));
4198
+ const root = await Effect17.runPromise(store.projectsRoot(defaultProjectsRoot));
4199
+ const result = await Effect17.runPromise(discoverProjects(root, store).pipe(Effect17.result));
4027
4200
  if (result._tag === "Failure") return reply.code(400).send({ error: result.failure.message });
4028
4201
  const project = result.success.find((item) => item.id === request.params.id);
4029
4202
  return project ?? reply.code(404).send({ error: "Project not found on this server." });
@@ -4032,50 +4205,62 @@ app.post("/api/projects/:id/reconnect", async (request, reply) => {
4032
4205
  const parsed = z17.object({ path: z17.string().trim().min(1).max(4096) }).safeParse(request.body);
4033
4206
  if (!parsed.success)
4034
4207
  return reply.code(400).send({ error: "Enter the project's new folder path." });
4035
- const root = await Effect16.runPromise(store.projectsRoot(defaultProjectsRoot));
4036
- const result = await Effect16.runPromise(
4037
- reconnectProject(root, request.params.id, parsed.data.path, store).pipe(Effect16.result)
4208
+ const root = await Effect17.runPromise(store.projectsRoot(defaultProjectsRoot));
4209
+ const result = await Effect17.runPromise(
4210
+ reconnectProject(root, request.params.id, parsed.data.path, store).pipe(Effect17.result)
4038
4211
  );
4039
4212
  return result._tag === "Failure" ? reply.code(400).send({ error: result.failure.message }) : result.success;
4040
4213
  });
4041
- app.get("/api/sessions", () => Effect16.runPromise(store.list()));
4214
+ app.get("/api/sessions", () => Effect17.runPromise(store.list()));
4042
4215
  app.get(
4043
4216
  "/api/tasks",
4044
4217
  (request) => boardTasks(store, request.query.projectId)
4045
4218
  );
4219
+ app.get("/api/task-moves", () => ({ supported: true }));
4046
4220
  app.post("/api/tasks", async (request, reply) => {
4047
4221
  const parsed = CreateTaskSchema.safeParse(request.body);
4048
4222
  if (!parsed.success) return reply.code(400).send({ error: "Enter a task title." });
4049
- if (parsed.data.gitFlow?.delivery === "pull_request") {
4050
- const github = await githubCliStatus();
4051
- if (!github.githubCli.authenticated)
4052
- return reply.code(400).send({ error: "GitHub CLI must be authenticated on the server to open a PR." });
4053
- }
4054
- const projectsRoot = await Effect16.runPromise(store.projectsRoot(defaultProjectsRoot));
4055
- const projects = await Effect16.runPromise(
4056
- discoverProjects(projectsRoot, store).pipe(Effect16.result)
4057
- );
4058
- if (projects._tag === "Failure" || !projects.success.some((project) => project.id === parsed.data.projectId && project.available))
4059
- return reply.code(404).send({ error: "Project not found in the server's projects folder." });
4060
- let files;
4061
- try {
4062
- files = await Effect16.runPromise(
4063
- attachments.resolve(parsed.data.projectId, parsed.data.attachmentIds ?? [])
4223
+ const save = async () => {
4224
+ if (parsed.data.clientTaskId) {
4225
+ const existing = await Effect17.runPromise(store.getTask(parsed.data.clientTaskId));
4226
+ if (existing) {
4227
+ if (existing.deletedAt || existing.projectId !== parsed.data.projectId || existing.title !== parsed.data.title || existing.prompt !== parsed.data.prompt || JSON.stringify(existing.gitFlow) !== JSON.stringify(parsed.data.gitFlow) || JSON.stringify(existing.model) !== JSON.stringify(parsed.data.model))
4228
+ return reply.code(409).send({ error: "This task creation request has already been used." });
4229
+ return reply.code(201).send(existing);
4230
+ }
4231
+ }
4232
+ if (parsed.data.gitFlow?.delivery === "pull_request") {
4233
+ const github = await githubCliStatus();
4234
+ if (!github.githubCli.authenticated)
4235
+ return reply.code(400).send({ error: "GitHub CLI must be authenticated on the server to open a PR." });
4236
+ }
4237
+ const projectsRoot = await Effect17.runPromise(store.projectsRoot(defaultProjectsRoot));
4238
+ const projects = await Effect17.runPromise(
4239
+ discoverProjects(projectsRoot, store).pipe(Effect17.result)
4064
4240
  );
4065
- } catch (error) {
4066
- return reply.code(400).send({ error: String(error) });
4067
- }
4068
- const now = (/* @__PURE__ */ new Date()).toISOString();
4069
- const task = TaskSchema.parse({
4070
- id: randomUUID8(),
4071
- ...parsed.data,
4072
- attachments: files,
4073
- status: "backlog",
4074
- sessionId: null,
4075
- createdAt: now,
4076
- updatedAt: now
4077
- });
4078
- return reply.code(201).send(await Effect16.runPromise(store.saveTask(task)));
4241
+ if (projects._tag === "Failure" || !projects.success.some((project) => project.id === parsed.data.projectId && project.available))
4242
+ return reply.code(404).send({ error: "Project not found in the server's projects folder." });
4243
+ let files;
4244
+ try {
4245
+ files = await Effect17.runPromise(
4246
+ attachments.resolve(parsed.data.projectId, parsed.data.attachmentIds ?? [])
4247
+ );
4248
+ } catch (error) {
4249
+ return reply.code(400).send({ error: String(error) });
4250
+ }
4251
+ const now = (/* @__PURE__ */ new Date()).toISOString();
4252
+ const task = TaskSchema.parse({
4253
+ id: parsed.data.clientTaskId ?? randomUUID9(),
4254
+ ...parsed.data,
4255
+ attachments: files,
4256
+ status: "backlog",
4257
+ sessionId: null,
4258
+ createdAt: now,
4259
+ updatedAt: now
4260
+ });
4261
+ return reply.code(201).send(await Effect17.runPromise(store.saveTask(task)));
4262
+ };
4263
+ return parsed.data.clientTaskId ? withTaskLock(`create-task:${parsed.data.clientTaskId}`, save) : save();
4079
4264
  });
4080
4265
  app.patch("/api/tasks/:id", async (request, reply) => {
4081
4266
  const parsed = UpdateTaskSchema.safeParse(request.body);
@@ -4090,8 +4275,8 @@ app.patch("/api/tasks/:id", async (request, reply) => {
4090
4275
  return reply.code(400).send({ error: "GitHub CLI must be authenticated on the server to open a PR." });
4091
4276
  }
4092
4277
  return await editTask(store, request.params.id, parsed.data, async (projectId, ids) => {
4093
- const result = await Effect16.runPromise(
4094
- attachments.resolve(projectId, ids).pipe(Effect16.result)
4278
+ const result = await Effect17.runPromise(
4279
+ attachments.resolve(projectId, ids).pipe(Effect17.result)
4095
4280
  );
4096
4281
  if (result._tag === "Failure") throw new TaskActionError(result.failure.message, 400);
4097
4282
  return result.success;
@@ -4102,14 +4287,26 @@ app.patch("/api/tasks/:id", async (request, reply) => {
4102
4287
  throw error;
4103
4288
  }
4104
4289
  });
4290
+ app.post("/api/tasks/:id/discard-unrun", async (request, reply) => {
4291
+ const parsed = z17.object({ updatedAt: z17.string().datetime() }).safeParse(request.body);
4292
+ if (!parsed.success) return reply.code(400).send({ error: "The task revision is required." });
4293
+ try {
4294
+ await deleteTask(store, request.params.id, void 0, parsed.data.updatedAt);
4295
+ return reply.code(204).send();
4296
+ } catch (error) {
4297
+ if (error instanceof TaskActionError)
4298
+ return reply.code(error.status).send({ error: error.message });
4299
+ throw error;
4300
+ }
4301
+ });
4105
4302
  app.delete("/api/tasks/:id", async (request, reply) => {
4106
4303
  try {
4107
4304
  await deleteTask(store, request.params.id, async (session) => {
4108
- await Effect16.runPromise(runtime.forget(session.id));
4305
+ await Effect17.runPromise(runtime.forget(session.id));
4109
4306
  await worktreeCleanup.remove(session);
4110
4307
  await removeRunFiles(dataRoot, session.id);
4111
4308
  });
4112
- await Effect16.runPromise(attachments.cleanup());
4309
+ await Effect17.runPromise(attachments.cleanup());
4113
4310
  return reply.code(204).send();
4114
4311
  } catch (error) {
4115
4312
  if (error instanceof TaskActionError)
@@ -4118,14 +4315,14 @@ app.delete("/api/tasks/:id", async (request, reply) => {
4118
4315
  }
4119
4316
  });
4120
4317
  app.get("/api/sessions/:id", async (request, reply) => {
4121
- const session = await Effect16.runPromise(store.get(request.params.id));
4318
+ const session = await Effect17.runPromise(store.get(request.params.id));
4122
4319
  if (!session) return reply.code(404).send({ error: "Session not found." });
4123
4320
  return session;
4124
4321
  });
4125
4322
  app.post(
4126
4323
  "/api/sessions/:id/delivery",
4127
4324
  async (request, reply) => withSessionTaskLock(request.params.id, async () => {
4128
- const session = await Effect16.runPromise(store.get(request.params.id));
4325
+ const session = await Effect17.runPromise(store.get(request.params.id));
4129
4326
  if (!session) return reply.code(404).send({ error: "Session not found." });
4130
4327
  try {
4131
4328
  const result = await delivery.retry(session.id);
@@ -4142,10 +4339,10 @@ app.post(
4142
4339
  try {
4143
4340
  const prepared = await preparation.retry(request.params.id);
4144
4341
  preparedSession = prepared.session;
4145
- const files = await Effect16.runPromise(
4342
+ const files = await Effect17.runPromise(
4146
4343
  attachments.resolve(prepared.session.projectId, prepared.input.attachmentIds ?? [])
4147
4344
  );
4148
- await Effect16.runPromise(runtime.send(prepared.session, prepared.input.prompt, files));
4345
+ await Effect17.runPromise(runtime.send(prepared.session, prepared.input.prompt, files));
4149
4346
  return reply.code(202).send(prepared.session);
4150
4347
  } catch (error) {
4151
4348
  if (preparedSession) {
@@ -4168,9 +4365,9 @@ var HistoryQuery = z17.object({
4168
4365
  app.get("/api/sessions/:id/events", async (request, reply) => {
4169
4366
  const query = HistoryQuery.safeParse(request.query);
4170
4367
  if (!query.success) return reply.code(400).send({ error: "Invalid history cursor or limit." });
4171
- const session = await Effect16.runPromise(store.get(request.params.id));
4368
+ const session = await Effect17.runPromise(store.get(request.params.id));
4172
4369
  if (!session) return reply.code(404).send({ error: "Session not found." });
4173
- return Effect16.runPromise(store.history(session.id, query.data.after, query.data.limit));
4370
+ return Effect17.runPromise(store.history(session.id, query.data.after, query.data.limit));
4174
4371
  });
4175
4372
  async function selectSessionModel(input, hasImages) {
4176
4373
  try {
@@ -4190,8 +4387,8 @@ async function selectSessionModel(input, hasImages) {
4190
4387
  }
4191
4388
  }
4192
4389
  async function resolveSessionProject(projectId) {
4193
- const projectsRoot = await Effect16.runPromise(store.projectsRoot(defaultProjectsRoot));
4194
- const project = (await Effect16.runPromise(discoverProjects(projectsRoot, store))).find(
4390
+ const projectsRoot = await Effect17.runPromise(store.projectsRoot(defaultProjectsRoot));
4391
+ const project = (await Effect17.runPromise(discoverProjects(projectsRoot, store))).find(
4195
4392
  (item) => item.id === projectId && item.available
4196
4393
  );
4197
4394
  if (!project)
@@ -4233,7 +4430,7 @@ async function createAndStartSession(input) {
4233
4430
  const projectPath = location.projectPath;
4234
4431
  let files;
4235
4432
  try {
4236
- files = await Effect16.runPromise(
4433
+ files = await Effect17.runPromise(
4237
4434
  attachments.resolve(input.projectId, input.attachmentIds ?? [])
4238
4435
  );
4239
4436
  } catch (error) {
@@ -4246,11 +4443,11 @@ async function createAndStartSession(input) {
4246
4443
  if (selection.error !== void 0) return { error: selection.error, status: selection.status };
4247
4444
  const selectedModel = selection.model;
4248
4445
  const now = (/* @__PURE__ */ new Date()).toISOString();
4249
- const task = input.taskId ? await Effect16.runPromise(store.getTask(input.taskId)) : void 0;
4446
+ const task = input.taskId ? await Effect17.runPromise(store.getTask(input.taskId)) : void 0;
4250
4447
  if (input.taskId && sessionTaskUnavailable(task, input.projectId))
4251
4448
  return { error: "Task not found.", status: 404 };
4252
4449
  const session = SessionSchema.parse({
4253
- id: randomUUID8(),
4450
+ id: randomUUID9(),
4254
4451
  taskId: input.taskId ?? null,
4255
4452
  projectId: input.projectId,
4256
4453
  title: task?.title ?? input.prompt.slice(0, 80),
@@ -4276,7 +4473,7 @@ async function createAndStartSession(input) {
4276
4473
  );
4277
4474
  } catch (error) {
4278
4475
  if (task)
4279
- await Effect16.runPromise(
4476
+ await Effect17.runPromise(
4280
4477
  store.saveTask({ ...task, sessionId: session.id, updatedAt: (/* @__PURE__ */ new Date()).toISOString() })
4281
4478
  );
4282
4479
  return {
@@ -4285,7 +4482,7 @@ async function createAndStartSession(input) {
4285
4482
  };
4286
4483
  }
4287
4484
  try {
4288
- await Effect16.runPromise(runtime.send(running, input.prompt, files));
4485
+ await Effect17.runPromise(runtime.send(running, input.prompt, files));
4289
4486
  } catch (error) {
4290
4487
  publish({
4291
4488
  type: "session.updated",
@@ -4294,8 +4491,27 @@ async function createAndStartSession(input) {
4294
4491
  publish({ type: "error", sessionId: session.id, message: String(error) });
4295
4492
  return { error: "Could not start the agent session.", status: 500 };
4296
4493
  }
4297
- return { session: await Effect16.runPromise(store.get(session.id)), status: 201 };
4494
+ return { session: await Effect17.runPromise(store.get(session.id)), status: 201 };
4298
4495
  }
4496
+ app.post("/api/projects/:id/chat", async (request, reply) => {
4497
+ maintenance.assertReady();
4498
+ const location = await resolveSessionProject(request.params.id);
4499
+ if (location.error !== void 0)
4500
+ return reply.code(location.status).send({ error: location.error });
4501
+ const parsed = z17.object({ reset: z17.boolean().optional() }).safeParse(request.body ?? {});
4502
+ if (!parsed.success) return reply.code(400).send({ error: parsed.error.flatten() });
4503
+ try {
4504
+ return await openRepoChat(
4505
+ store,
4506
+ request.params.id,
4507
+ location.projectPath,
4508
+ () => models.defaultIdentity(),
4509
+ parsed.data.reset
4510
+ );
4511
+ } catch (error) {
4512
+ return reply.code(409).send({ error: String(error) });
4513
+ }
4514
+ });
4299
4515
  app.post("/api/sessions", async (request, reply) => {
4300
4516
  const parsed = CreateSessionSchema.safeParse(request.body);
4301
4517
  if (!parsed.success) return reply.code(400).send({ error: parsed.error.flatten() });
@@ -4312,11 +4528,11 @@ function startTask(id, instructionsOverride, automated = false) {
4312
4528
  "agent-dispatch",
4313
4529
  () => withTaskLock(id, async () => {
4314
4530
  maintenance.assertReady();
4315
- const task = await Effect16.runPromise(store.getTask(id));
4531
+ const task = await Effect17.runPromise(store.getTask(id));
4316
4532
  if (!task || task.deletedAt) throw new TaskActionError("Task not found.", 404);
4317
4533
  if (automated) {
4318
- const state = await Effect16.runPromise(store.automation());
4319
- if (activeAgents(await Effect16.runPromise(store.list())) >= state.config.parallelism) return;
4534
+ const state = await Effect17.runPromise(store.automation());
4535
+ if (activeAgents(await Effect17.runPromise(store.list())) >= state.config.parallelism) return;
4320
4536
  const backlog = await boardTasks(store);
4321
4537
  if (!backlog.some((item) => item.id === id && item.status === "backlog")) return;
4322
4538
  }
@@ -4324,7 +4540,7 @@ function startTask(id, instructionsOverride, automated = false) {
4324
4540
  const prompt = instructions ? `${task.title}
4325
4541
 
4326
4542
  ${instructions}` : task.title;
4327
- const linkedSession = task.sessionId ? await Effect16.runPromise(store.get(task.sessionId)) : void 0;
4543
+ const linkedSession = task.sessionId ? await Effect17.runPromise(store.get(task.sessionId)) : void 0;
4328
4544
  if (linkedSession && (["starting", "running", "queued"].includes(linkedSession.status) || linkedSession.gitDelivery?.status === "pending"))
4329
4545
  throw new TaskActionError("This task already has a running agent.", 409);
4330
4546
  const result = await createAndStartSession({
@@ -4338,7 +4554,7 @@ ${instructions}` : task.title;
4338
4554
  });
4339
4555
  if (!result.session)
4340
4556
  throw new TaskActionError(result.error ?? "Could not start task.", result.status);
4341
- const updatedTask = await Effect16.runPromise(
4557
+ const updatedTask = await Effect17.runPromise(
4342
4558
  store.saveTask({
4343
4559
  ...task,
4344
4560
  status: taskStatusForSession(result.session.status, result.session),
@@ -4373,8 +4589,8 @@ app.patch("/api/automation", async (request, reply) => {
4373
4589
  const parsed = AutomationConfigSchema.safeParse(request.body);
4374
4590
  if (!parsed.success)
4375
4591
  return reply.code(400).send({ error: parsed.error.issues.map((issue) => issue.message).join(" ") });
4376
- const root = await Effect16.runPromise(store.projectsRoot(defaultProjectsRoot));
4377
- const projects = await Effect16.runPromise(discoverProjects(root, store));
4592
+ const root = await Effect17.runPromise(store.projectsRoot(defaultProjectsRoot));
4593
+ const projects = await Effect17.runPromise(discoverProjects(root, store));
4378
4594
  if (parsed.data.projectIds.some(
4379
4595
  (id) => !projects.some((project) => project.id === id && project.available)
4380
4596
  ))
@@ -4402,7 +4618,7 @@ automationTimer.unref();
4402
4618
  app.post(
4403
4619
  "/api/sessions/:id/messages",
4404
4620
  async (request, reply) => withSessionTaskLock(request.params.id, async () => {
4405
- const session = await Effect16.runPromise(store.get(request.params.id));
4621
+ const session = await Effect17.runPromise(store.get(request.params.id));
4406
4622
  if (!session) return reply.code(404).send({ error: "Session not found." });
4407
4623
  const parsed = SendMessageSchema.safeParse(request.body);
4408
4624
  if (!parsed.success) return reply.code(400).send({ error: parsed.error.flatten() });
@@ -4410,10 +4626,10 @@ app.post(
4410
4626
  await delivery.assertCanWork(session.id);
4411
4627
  if (session.preparation && session.preparation.status !== "ready")
4412
4628
  throw new Error("Retry workspace preparation before sending a message.");
4413
- const files = await Effect16.runPromise(
4629
+ const files = await Effect17.runPromise(
4414
4630
  attachments.resolve(session.projectId, parsed.data.attachmentIds ?? [])
4415
4631
  );
4416
- await Effect16.runPromise(
4632
+ await Effect17.runPromise(
4417
4633
  runtime.send(
4418
4634
  session,
4419
4635
  parsed.data.message,
@@ -4429,22 +4645,22 @@ app.post(
4429
4645
  })
4430
4646
  );
4431
4647
  async function withSessionTaskLock(id, operation) {
4432
- const session = await Effect16.runPromise(store.get(id));
4648
+ const session = await Effect17.runPromise(store.get(id));
4433
4649
  const lockId = session ? await sessionTaskLockId(store, session) : `session-${id}`;
4434
4650
  return withTaskLock(lockId, operation);
4435
4651
  }
4436
4652
  app.post(
4437
4653
  "/api/sessions/:id/stop",
4438
4654
  async (request, reply) => withSessionTaskLock(request.params.id, async () => {
4439
- const session = await Effect16.runPromise(store.get(request.params.id));
4655
+ const session = await Effect17.runPromise(store.get(request.params.id));
4440
4656
  if (!session) return reply.code(404).send({ error: "Session not found." });
4441
- await Effect16.runPromise(runtime.stop(session));
4657
+ await Effect17.runPromise(runtime.stop(session));
4442
4658
  return reply.code(202).send({ accepted: true });
4443
4659
  })
4444
4660
  );
4445
4661
  attachEventServer(app, security, allowedOrigins, () => ({
4446
4662
  type: "snapshot",
4447
- sessions: Effect16.runSync(store.list())
4663
+ sessions: Effect17.runSync(store.list())
4448
4664
  }));
4449
4665
  var closeControl = await startControl(
4450
4666
  dataRoot,
@@ -4456,7 +4672,7 @@ var closeControl = await startControl(
4456
4672
  connectedDevices: new Set(security.sockets.values()).size,
4457
4673
  connectedSockets: security.sockets.size,
4458
4674
  memory: process.memoryUsage().rss,
4459
- activeRuns: activeAgents(Effect16.runSync(store.list())),
4675
+ activeRuns: activeAgents(Effect17.runSync(store.list())),
4460
4676
  maintenance: maintenance.status()
4461
4677
  }),
4462
4678
  maintenance
@@ -4474,7 +4690,7 @@ app.addHook("onClose", async () => {
4474
4690
  clearInterval(automationTimer);
4475
4691
  await withTaskLock("automation", async () => {
4476
4692
  });
4477
- await Effect16.runPromise(runtime.close());
4693
+ await Effect17.runPromise(runtime.close());
4478
4694
  await agentEffects.dispose();
4479
4695
  await attachmentEffects.dispose();
4480
4696
  await effects.dispose();