@agentrific/server 0.4.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/CHANGELOG.md CHANGED
@@ -1,5 +1,13 @@
1
1
  # @agentrific/server
2
2
 
3
+ ## 0.5.0
4
+
5
+ ### Minor Changes
6
+
7
+ - fbb6281: Group matching repositories across connected servers while keeping each task and chat associated with its server. Show repository identity and Git status, support explicit checkout association for older servers, and choose a task server directly in the native composer. Allow never-run backlog tasks to move safely between compatible servers.
8
+
9
+ Preserve native navigation and floating glass tabs, center and truncate conversation titles, show compact status dots, and improve navigation performance. Fix native task editor character loss, server picker alignment, and tool-call grid sizing.
10
+
3
11
  ## 0.4.0
4
12
 
5
13
  ### Minor Changes
package/cli.js CHANGED
@@ -30,7 +30,7 @@ var init_package = __esm({
30
30
  "package.json"() {
31
31
  package_default = {
32
32
  name: "@agentrific/server",
33
- version: "0.4.0",
33
+ version: "0.5.0",
34
34
  description: "Self-hosted control plane for long-running coding agents",
35
35
  keywords: [
36
36
  "agentrific",
@@ -246,7 +246,7 @@ function createPairingLink(payload) {
246
246
  link.searchParams.set("data", JSON.stringify(PairingPayloadSchema.parse(payload)));
247
247
  return link.toString();
248
248
  }
249
- var cronBounds, AutomationConfigSchema, AutomationStateSchema, SessionStatusSchema, AgentKindSchema, ModelReferenceSchema, ModelIdentitySchema, ModelOptionSchema, ModelCatalogSchema, GitFlowSchema, GitDeliverySchema, DeliveryRequestSchema, RunPreparationSchema, SessionSchema, MAX_ATTACHMENTS, MAX_ATTACHMENT_BYTES, AttachmentSchema, AttachmentIdsSchema, CreateSessionSchema, SendMessageSchema, FileChangeSchema, SessionEventSchema, RecordedEventSchema, EventHistorySchema, ServerEventSchema, ServerSettingsSchema, ConnectionsStatusSchema, UpdateSettingsSchema, ProjectSchema, TaskStatusSchema, TaskSchema, CreateTaskSchema, UpdateTaskSchema, PROTOCOL_VERSION, ServerUpdateStatusSchema, ServerHealthSchema, PairingPayloadSchema, PairingInvitationSchema, PairingRequestSchema, DeviceSchema, PairingResultSchema, HandshakeSchema, HandshakeAckSchema;
249
+ var cronBounds, AutomationConfigSchema, AutomationStateSchema, SessionStatusSchema, AgentKindSchema, ModelReferenceSchema, ModelIdentitySchema, ModelOptionSchema, ModelCatalogSchema, GitFlowSchema, GitDeliverySchema, DeliveryRequestSchema, RunPreparationSchema, SessionSchema, MAX_ATTACHMENTS, MAX_ATTACHMENT_BYTES, AttachmentSchema, AttachmentIdsSchema, CreateSessionSchema, SendMessageSchema, FileChangeSchema, SessionEventSchema, RecordedEventSchema, EventHistorySchema, ServerEventSchema, ServerSettingsSchema, ConnectionsStatusSchema, UpdateSettingsSchema, RepositoryIdentitySchema, ProjectGitStatusSchema, ProjectSchema, TaskStatusSchema, TaskSchema, CreateTaskSchema, UpdateTaskSchema, PROTOCOL_VERSION, ServerUpdateStatusSchema, ServerHealthSchema, PairingPayloadSchema, PairingInvitationSchema, PairingRequestSchema, DeviceSchema, PairingResultSchema, HandshakeSchema, HandshakeAckSchema;
250
250
  var init_src = __esm({
251
251
  "../../packages/contracts/src/index.ts"() {
252
252
  cronBounds = [
@@ -476,11 +476,25 @@ var init_src = __esm({
476
476
  githubCli: z2.object({ installed: z2.boolean(), authenticated: z2.boolean() })
477
477
  });
478
478
  UpdateSettingsSchema = z2.object({ projectsRoot: z2.string().trim().min(1).max(4096) });
479
+ RepositoryIdentitySchema = z2.object({
480
+ key: z2.string(),
481
+ display: z2.string()
482
+ });
483
+ ProjectGitStatusSchema = z2.object({
484
+ observedAt: z2.string().datetime(),
485
+ state: z2.enum(["available", "unavailable"]),
486
+ repository: RepositoryIdentitySchema.nullable(),
487
+ head: z2.string().nullable(),
488
+ branch: z2.string().nullable(),
489
+ trackedChanges: z2.number().int().nonnegative(),
490
+ untrackedChanges: z2.number().int().nonnegative()
491
+ });
479
492
  ProjectSchema = z2.object({
480
493
  id: z2.string(),
481
494
  name: z2.string(),
482
495
  path: z2.string(),
483
- available: z2.boolean().default(true)
496
+ available: z2.boolean().default(true),
497
+ repository: RepositoryIdentitySchema.nullable().optional()
484
498
  });
485
499
  TaskStatusSchema = z2.enum(["backlog", "needs_user_input", "running", "done"]);
486
500
  TaskSchema = z2.object({
@@ -499,6 +513,7 @@ var init_src = __esm({
499
513
  deletedAt: z2.string().datetime().optional()
500
514
  });
501
515
  CreateTaskSchema = z2.object({
516
+ clientTaskId: z2.string().min(1).max(128).regex(/^[a-zA-Z0-9_-]+$/).optional(),
502
517
  projectId: z2.string().min(1),
503
518
  title: z2.string().trim().min(1).max(120),
504
519
  prompt: z2.string().trim().max(2e4).default(""),
@@ -1871,7 +1886,7 @@ function editTask(store2, id, input, resolveAttachments) {
1871
1886
  });
1872
1887
  }
1873
1888
  function deleteTask(store2, id, cleanup = async () => {
1874
- }) {
1889
+ }, unrunUpdatedAt) {
1875
1890
  return withTaskLock(id, async () => {
1876
1891
  const task = await findTask(store2, id);
1877
1892
  const sessions = await Effect6.runPromise(store2.list());
@@ -1882,6 +1897,8 @@ function deleteTask(store2, id, cleanup = async () => {
1882
1897
  const associated = sessions.filter(
1883
1898
  (session) => session.taskId === task.id || session.id === task.sessionId
1884
1899
  );
1900
+ if (unrunUpdatedAt !== void 0 && (task.updatedAt !== unrunUpdatedAt || task.sessionId || associated.length || task.status !== "backlog"))
1901
+ throw new TaskActionError("This task changed or has already run. It cannot be moved.", 409);
1885
1902
  for (const session of associated) await cleanup(session);
1886
1903
  await Effect6.runPromise(
1887
1904
  store2.purgeTask(
@@ -2320,7 +2337,7 @@ import { promisify as promisify2, stripVTControlCharacters } from "node:util";
2320
2337
  function workflowLog(text) {
2321
2338
  return stripVTControlCharacters(text).slice(-32e3);
2322
2339
  }
2323
- async function workflowCommand(cwd, command, args, signal, managedPreparation = false) {
2340
+ function workflowEnvironment() {
2324
2341
  const env = { ...process.env };
2325
2342
  for (const name of [
2326
2343
  "GIT_DIR",
@@ -2340,6 +2357,10 @@ async function workflowCommand(cwd, command, args, signal, managedPreparation =
2340
2357
  "GIT_REPLACE_REF_BASE"
2341
2358
  ])
2342
2359
  delete env[name];
2360
+ return env;
2361
+ }
2362
+ async function workflowCommand(cwd, command, args, signal, managedPreparation = false) {
2363
+ const env = workflowEnvironment();
2343
2364
  if (managedPreparation) env.AGENTRIFIC_MANAGED_PREPARATION = "1";
2344
2365
  const result = await exec(command, args, {
2345
2366
  cwd,
@@ -3573,6 +3594,113 @@ var init_projects = __esm({
3573
3594
  }
3574
3595
  });
3575
3596
 
3597
+ // src/project-git.ts
3598
+ import { execFile as execFile3 } from "node:child_process";
3599
+ import { promisify as promisify3 } from "node:util";
3600
+ function repositoryIdentity(remote) {
3601
+ const input = remote.trim();
3602
+ if (!input || input.startsWith("/") || input.startsWith(".")) return null;
3603
+ const scp = /^(?:[^@/\s]+@)?([^/:\s]+):([^\s]+)$/.exec(input);
3604
+ let url;
3605
+ try {
3606
+ url = new URL(scp && !input.includes("://") ? `ssh://${scp[1]}/${scp[2]}` : input);
3607
+ } catch {
3608
+ return null;
3609
+ }
3610
+ if (!["https:", "http:", "ssh:", "git:"].includes(url.protocol)) return null;
3611
+ if (url.search || url.hash || url.port) return null;
3612
+ const host2 = url.hostname.toLowerCase();
3613
+ let path13 = url.pathname.replace(/^\/+|\/+$/g, "").replace(/\.git$/, "");
3614
+ if (!host2 || !path13 || path13.includes("%") || path13.includes("//")) return null;
3615
+ if (["github.com", "bitbucket.org"].includes(host2)) path13 = path13.toLowerCase();
3616
+ const key = `${host2}/${path13}`;
3617
+ return { key, display: key };
3618
+ }
3619
+ async function git(directory, args) {
3620
+ const { stdout } = await execute3("git", ["-C", directory, ...args], {
3621
+ timeout: 3e3,
3622
+ maxBuffer: 1024 * 1024,
3623
+ env: { ...workflowEnvironment(), GIT_OPTIONAL_LOCKS: "0", GIT_TERMINAL_PROMPT: "0" }
3624
+ });
3625
+ return stdout.trim();
3626
+ }
3627
+ async function readRepositoryIdentity(directory) {
3628
+ try {
3629
+ const remotes = (await git(directory, ["remote"])).split("\n").filter(Boolean);
3630
+ const remote = remotes.includes("origin") ? "origin" : remotes.length === 1 ? remotes[0] : null;
3631
+ if (!remote) return null;
3632
+ return repositoryIdentity(await git(directory, ["remote", "get-url", remote]));
3633
+ } catch {
3634
+ return null;
3635
+ }
3636
+ }
3637
+ async function inspect(directory) {
3638
+ const observedAt = (/* @__PURE__ */ new Date()).toISOString();
3639
+ try {
3640
+ const [repository, head, branch, status] = await Promise.all([
3641
+ readRepositoryIdentity(directory),
3642
+ git(directory, ["rev-parse", "--verify", "HEAD"]),
3643
+ git(directory, ["symbolic-ref", "--quiet", "--short", "HEAD"]).catch(() => null),
3644
+ // NUL-separated records avoid ambiguous filenames/newlines. --no-renames
3645
+ // keeps each entry one record, so we only need its two status characters.
3646
+ execute3(
3647
+ "git",
3648
+ [
3649
+ "-C",
3650
+ directory,
3651
+ "status",
3652
+ "--porcelain=v1",
3653
+ "-z",
3654
+ "--no-renames",
3655
+ "--untracked-files=normal"
3656
+ ],
3657
+ {
3658
+ timeout: 3e3,
3659
+ maxBuffer: 1024 * 1024,
3660
+ env: { ...workflowEnvironment(), GIT_OPTIONAL_LOCKS: "0" }
3661
+ }
3662
+ )
3663
+ ]);
3664
+ const records = status.stdout.split("\0").filter(Boolean);
3665
+ return {
3666
+ observedAt,
3667
+ state: "available",
3668
+ repository,
3669
+ head,
3670
+ branch,
3671
+ trackedChanges: records.filter((record) => !record.startsWith("??")).length,
3672
+ untrackedChanges: records.filter((record) => record.startsWith("??")).length
3673
+ };
3674
+ } catch {
3675
+ return {
3676
+ observedAt,
3677
+ state: "unavailable",
3678
+ repository: null,
3679
+ head: null,
3680
+ branch: null,
3681
+ trackedChanges: 0,
3682
+ untrackedChanges: 0
3683
+ };
3684
+ }
3685
+ }
3686
+ function projectGitStatus(directory) {
3687
+ const cached = observations.get(directory);
3688
+ if (cached && cached.expires > Date.now()) return cached.value;
3689
+ if (observations.size >= 200) observations.delete(observations.keys().next().value);
3690
+ const value = inspect(directory);
3691
+ observations.set(directory, { expires: Date.now() + 15e3, value });
3692
+ return value;
3693
+ }
3694
+ var execute3, observations;
3695
+ var init_project_git = __esm({
3696
+ "src/project-git.ts"() {
3697
+ "use strict";
3698
+ init_workflow_command();
3699
+ execute3 = promisify3(execFile3);
3700
+ observations = /* @__PURE__ */ new Map();
3701
+ }
3702
+ });
3703
+
3576
3704
  // src/push-schema.ts
3577
3705
  import { z as z16 } from "zod";
3578
3706
  var PushBindingSchema, PushStateSchema, PendingPushSchema;
@@ -4170,8 +4298,8 @@ import { styleText as styleText4 } from "node:util";
4170
4298
  import cors from "@fastify/cors";
4171
4299
  import multipart from "@fastify/multipart";
4172
4300
  import { randomUUID as randomUUID9 } from "node:crypto";
4173
- import { execFile as execFile3 } from "node:child_process";
4174
- import { promisify as promisify3 } from "node:util";
4301
+ import { execFile as execFile4 } from "node:child_process";
4302
+ import { promisify as promisify4 } from "node:util";
4175
4303
  import { realpath as realpath2 } from "node:fs/promises";
4176
4304
  import { homedir as homedir4 } from "node:os";
4177
4305
  import path11 from "node:path";
@@ -4406,6 +4534,7 @@ var init_index = __esm({
4406
4534
  init_models();
4407
4535
  init_pi();
4408
4536
  init_projects();
4537
+ init_project_git();
4409
4538
  init_store();
4410
4539
  init_tasks();
4411
4540
  init_worktree_cleanup();
@@ -4416,7 +4545,7 @@ var init_index = __esm({
4416
4545
  worktreesRoot = path11.resolve(
4417
4546
  process.env.AGENTRIFIC_WORKTREES_DIR ?? path11.join(homedir4(), ".agentrific", "workspaces")
4418
4547
  );
4419
- execFileAsync = promisify3(execFile3);
4548
+ execFileAsync = promisify4(execFile4);
4420
4549
  if (host !== "127.0.0.1" && host !== "::1")
4421
4550
  throw new Error("Bind Agentrific to loopback and expose it with Tailscale Serve or cloudflared.");
4422
4551
  app = Fastify({
@@ -4660,7 +4789,24 @@ var init_index = __esm({
4660
4789
  const root = await Effect17.runPromise(store.projectsRoot(defaultProjectsRoot));
4661
4790
  const result = await Effect17.runPromise(discoverProjects(root, store).pipe(Effect17.result));
4662
4791
  if (result._tag === "Failure") return reply.code(400).send({ error: result.failure.message });
4663
- return result.success;
4792
+ return Effect17.runPromise(
4793
+ Effect17.forEach(
4794
+ result.success,
4795
+ (project) => Effect17.promise(async () => ({
4796
+ ...project,
4797
+ repository: project.available ? await readRepositoryIdentity(project.path) : null
4798
+ })),
4799
+ { concurrency: 4 }
4800
+ )
4801
+ );
4802
+ });
4803
+ app.get("/api/projects/:id/git-status", async (request, reply) => {
4804
+ const root = await Effect17.runPromise(store.projectsRoot(defaultProjectsRoot));
4805
+ const projects = await Effect17.runPromise(discoverProjects(root, store));
4806
+ const project = projects.find((item) => item.id === request.params.id);
4807
+ if (!project?.available)
4808
+ return reply.code(404).send({ error: "Project folder unavailable on this server." });
4809
+ return projectGitStatus(project.path);
4664
4810
  });
4665
4811
  app.get("/api/projects/:id", async (request, reply) => {
4666
4812
  const root = await Effect17.runPromise(store.projectsRoot(defaultProjectsRoot));
@@ -4684,39 +4830,51 @@ var init_index = __esm({
4684
4830
  "/api/tasks",
4685
4831
  (request) => boardTasks(store, request.query.projectId)
4686
4832
  );
4833
+ app.get("/api/task-moves", () => ({ supported: true }));
4687
4834
  app.post("/api/tasks", async (request, reply) => {
4688
4835
  const parsed = CreateTaskSchema.safeParse(request.body);
4689
4836
  if (!parsed.success) return reply.code(400).send({ error: "Enter a task title." });
4690
- if (parsed.data.gitFlow?.delivery === "pull_request") {
4691
- const github = await githubCliStatus();
4692
- if (!github.githubCli.authenticated)
4693
- return reply.code(400).send({ error: "GitHub CLI must be authenticated on the server to open a PR." });
4694
- }
4695
- const projectsRoot = await Effect17.runPromise(store.projectsRoot(defaultProjectsRoot));
4696
- const projects = await Effect17.runPromise(
4697
- discoverProjects(projectsRoot, store).pipe(Effect17.result)
4698
- );
4699
- if (projects._tag === "Failure" || !projects.success.some((project) => project.id === parsed.data.projectId && project.available))
4700
- return reply.code(404).send({ error: "Project not found in the server's projects folder." });
4701
- let files;
4702
- try {
4703
- files = await Effect17.runPromise(
4704
- attachments.resolve(parsed.data.projectId, parsed.data.attachmentIds ?? [])
4837
+ const save = async () => {
4838
+ if (parsed.data.clientTaskId) {
4839
+ const existing = await Effect17.runPromise(store.getTask(parsed.data.clientTaskId));
4840
+ if (existing) {
4841
+ 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))
4842
+ return reply.code(409).send({ error: "This task creation request has already been used." });
4843
+ return reply.code(201).send(existing);
4844
+ }
4845
+ }
4846
+ if (parsed.data.gitFlow?.delivery === "pull_request") {
4847
+ const github = await githubCliStatus();
4848
+ if (!github.githubCli.authenticated)
4849
+ return reply.code(400).send({ error: "GitHub CLI must be authenticated on the server to open a PR." });
4850
+ }
4851
+ const projectsRoot = await Effect17.runPromise(store.projectsRoot(defaultProjectsRoot));
4852
+ const projects = await Effect17.runPromise(
4853
+ discoverProjects(projectsRoot, store).pipe(Effect17.result)
4705
4854
  );
4706
- } catch (error) {
4707
- return reply.code(400).send({ error: String(error) });
4708
- }
4709
- const now = (/* @__PURE__ */ new Date()).toISOString();
4710
- const task = TaskSchema.parse({
4711
- id: randomUUID9(),
4712
- ...parsed.data,
4713
- attachments: files,
4714
- status: "backlog",
4715
- sessionId: null,
4716
- createdAt: now,
4717
- updatedAt: now
4718
- });
4719
- return reply.code(201).send(await Effect17.runPromise(store.saveTask(task)));
4855
+ if (projects._tag === "Failure" || !projects.success.some((project) => project.id === parsed.data.projectId && project.available))
4856
+ return reply.code(404).send({ error: "Project not found in the server's projects folder." });
4857
+ let files;
4858
+ try {
4859
+ files = await Effect17.runPromise(
4860
+ attachments.resolve(parsed.data.projectId, parsed.data.attachmentIds ?? [])
4861
+ );
4862
+ } catch (error) {
4863
+ return reply.code(400).send({ error: String(error) });
4864
+ }
4865
+ const now = (/* @__PURE__ */ new Date()).toISOString();
4866
+ const task = TaskSchema.parse({
4867
+ id: parsed.data.clientTaskId ?? randomUUID9(),
4868
+ ...parsed.data,
4869
+ attachments: files,
4870
+ status: "backlog",
4871
+ sessionId: null,
4872
+ createdAt: now,
4873
+ updatedAt: now
4874
+ });
4875
+ return reply.code(201).send(await Effect17.runPromise(store.saveTask(task)));
4876
+ };
4877
+ return parsed.data.clientTaskId ? withTaskLock(`create-task:${parsed.data.clientTaskId}`, save) : save();
4720
4878
  });
4721
4879
  app.patch("/api/tasks/:id", async (request, reply) => {
4722
4880
  const parsed = UpdateTaskSchema.safeParse(request.body);
@@ -4743,6 +4901,18 @@ var init_index = __esm({
4743
4901
  throw error;
4744
4902
  }
4745
4903
  });
4904
+ app.post("/api/tasks/:id/discard-unrun", async (request, reply) => {
4905
+ const parsed = z18.object({ updatedAt: z18.string().datetime() }).safeParse(request.body);
4906
+ if (!parsed.success) return reply.code(400).send({ error: "The task revision is required." });
4907
+ try {
4908
+ await deleteTask(store, request.params.id, void 0, parsed.data.updatedAt);
4909
+ return reply.code(204).send();
4910
+ } catch (error) {
4911
+ if (error instanceof TaskActionError)
4912
+ return reply.code(error.status).send({ error: error.message });
4913
+ throw error;
4914
+ }
4915
+ });
4746
4916
  app.delete("/api/tasks/:id", async (request, reply) => {
4747
4917
  try {
4748
4918
  await deleteTask(store, request.params.id, async (session) => {