@saadnvd1/agent-os 0.3.508 → 0.3.510

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/README.md CHANGED
@@ -485,6 +485,17 @@ and the same actions in each row's menu; a row becomes the task's own row
485
485
  when it starts. Why it works
486
486
  this way: [docs/decisions/2026-10-10-queued-tasks.md](docs/decisions/2026-10-10-queued-tasks.md).
487
487
 
488
+ A paired device's token can run the building loop through read-only routes:
489
+ `GET /api/start-options?projectId=` (agents and models, the models a task can
490
+ run on, machines, branches, whether a task plans first, in one request),
491
+ `GET /api/tasks/<id>/diff` (the task's whole change, base to head: a file list,
492
+ then `?file=<path>` for one file's patch; capped, with `truncated` when a cap
493
+ is hit; read from the worktree, else the PR), and `GET /api/tasks/<id>/checks`
494
+ (the PR head's checks, the tail of a failing job's log, and the review gate's
495
+ verdict). A task's `plan` in `GET /api/tasks` carries `status` (`writing`,
496
+ `ready` or `approved`), a title and capped text. Their shapes are in
497
+ `lib/tasks/loop-types.ts`, which has no server imports so a client can share it.
498
+
488
499
  ## Model routing
489
500
 
490
501
  Each kind of work AgentOS launches an agent for runs on its own model, set in
@@ -1,10 +1,5 @@
1
1
  import { NextRequest, NextResponse } from "next/server";
2
- import {
3
- isGitRepo,
4
- getBranches,
5
- getDefaultBranch,
6
- getCurrentBranch,
7
- } from "@/lib/git";
2
+ import { gitCheck } from "@/lib/git";
8
3
 
9
4
  /**
10
5
  * POST /api/git/check
@@ -19,31 +14,7 @@ export async function POST(request: NextRequest) {
19
14
  return NextResponse.json({ error: "Path is required" }, { status: 400 });
20
15
  }
21
16
 
22
- // Check if it's a git repo
23
- const isRepo = await isGitRepo(dirPath);
24
-
25
- if (!isRepo) {
26
- return NextResponse.json({
27
- isGitRepo: false,
28
- branches: [],
29
- defaultBranch: null,
30
- currentBranch: null,
31
- });
32
- }
33
-
34
- // Get branch info
35
- const [branches, defaultBranch, currentBranch] = await Promise.all([
36
- getBranches(dirPath),
37
- getDefaultBranch(dirPath),
38
- getCurrentBranch(dirPath),
39
- ]);
40
-
41
- return NextResponse.json({
42
- isGitRepo: true,
43
- branches,
44
- defaultBranch,
45
- currentBranch,
46
- });
17
+ return NextResponse.json(await gitCheck(dirPath));
47
18
  } catch (error) {
48
19
  console.error("Error checking git repo:", error);
49
20
  return NextResponse.json(
@@ -1,8 +1,6 @@
1
1
  import { NextResponse } from "next/server";
2
2
  import { getProject } from "@/lib/projects";
3
- import { projectRef } from "@/lib/tasks/project-ref";
4
- import { projectOnPeers } from "@/lib/hosts/peer-actions";
5
- import { linkedHostIds } from "@/lib/hosts/remote-api";
3
+ import { projectMachines } from "@/lib/hosts/project-machines";
6
4
 
7
5
  // Which linked machines can take a session for this project, and why not
8
6
  // where one can't: { machines: { [hostId]: reason | null } }.
@@ -13,19 +11,5 @@ export async function GET(
13
11
  const project = getProject((await params).id);
14
12
  if (!project || project.is_uncategorized)
15
13
  return NextResponse.json({ error: "Project not found" }, { status: 404 });
16
- if ((project.host_id || "local") !== "local")
17
- return NextResponse.json({ machines: {} });
18
- try {
19
- return NextResponse.json({
20
- machines: await projectOnPeers(await projectRef(project)),
21
- });
22
- } catch {
23
- // A project outside the home folder has no place on another machine.
24
- const reason = "Its folder is outside your home folder";
25
- return NextResponse.json({
26
- machines: Object.fromEntries(
27
- [...linkedHostIds()].map((id) => [id, reason])
28
- ),
29
- });
30
- }
14
+ return NextResponse.json({ machines: await projectMachines(project) });
31
15
  }
@@ -0,0 +1,22 @@
1
+ import { NextRequest, NextResponse } from "next/server";
2
+ import { startOptions, UnknownProjectError } from "@/lib/start-options";
3
+
4
+ // GET /api/start-options?projectId= - everything the start form needs in
5
+ // one request: agents and their models, the models a task can run on, the
6
+ // machines, the project's branches and whether a task plans first. Without
7
+ // a project, the options for a scratch chat.
8
+ export async function GET(request: NextRequest) {
9
+ try {
10
+ return NextResponse.json(
11
+ await startOptions(request.nextUrl.searchParams.get("projectId") || null)
12
+ );
13
+ } catch (error) {
14
+ if (error instanceof UnknownProjectError)
15
+ return NextResponse.json({ error: error.message }, { status: 404 });
16
+ console.error("Reading start options:", error);
17
+ return NextResponse.json(
18
+ { error: "Couldn't read the start options" },
19
+ { status: 500 }
20
+ );
21
+ }
22
+ }
@@ -0,0 +1,37 @@
1
+ import { NextRequest, NextResponse } from "next/server";
2
+ import { db, queries, type Session } from "@/lib/db";
3
+ import { taskChecks } from "@/lib/tasks/checks-view";
4
+ import { LoopError, localTask } from "@/lib/tasks/loop-error";
5
+ import { isMirror } from "@/lib/tasks/remote";
6
+
7
+ // GET /api/tasks/:id/checks - the PR head's checks, the tail of a failing
8
+ // job's log, and the review gate's verdict on the head. ?log=0 leaves the
9
+ // logs out. Log and verdict text is untrusted: show it, never run it.
10
+ export async function GET(
11
+ request: NextRequest,
12
+ { params }: { params: Promise<{ id: string }> }
13
+ ) {
14
+ try {
15
+ const { id } = await params;
16
+ const session = localTask(
17
+ queries.getSession(db).get(id) as Session | undefined,
18
+ isMirror
19
+ );
20
+ return NextResponse.json(
21
+ await taskChecks(session, {
22
+ logs: request.nextUrl.searchParams.get("log") !== "0",
23
+ })
24
+ );
25
+ } catch (error) {
26
+ if (error instanceof LoopError)
27
+ return NextResponse.json(
28
+ { error: error.message },
29
+ { status: error.status }
30
+ );
31
+ console.error("Reading a task's checks:", error);
32
+ return NextResponse.json(
33
+ { error: "Couldn't read the task's checks" },
34
+ { status: 500 }
35
+ );
36
+ }
37
+ }
@@ -0,0 +1,35 @@
1
+ import { NextRequest, NextResponse } from "next/server";
2
+ import { db, queries, type Session } from "@/lib/db";
3
+ import { taskDiff } from "@/lib/tasks/diff";
4
+ import { LoopError, localTask } from "@/lib/tasks/loop-error";
5
+ import { isMirror } from "@/lib/tasks/remote";
6
+
7
+ // GET /api/tasks/:id/diff - the task's whole change, base to head: a file
8
+ // list with status and line counts; with ?file=<path>, that file's patch.
9
+ // Sizes are capped and say "truncated" when a cap was hit.
10
+ export async function GET(
11
+ request: NextRequest,
12
+ { params }: { params: Promise<{ id: string }> }
13
+ ) {
14
+ try {
15
+ const { id } = await params;
16
+ const session = localTask(
17
+ queries.getSession(db).get(id) as Session | undefined,
18
+ isMirror
19
+ );
20
+ return NextResponse.json(
21
+ await taskDiff(session, request.nextUrl.searchParams.get("file"))
22
+ );
23
+ } catch (error) {
24
+ if (error instanceof LoopError)
25
+ return NextResponse.json(
26
+ { error: error.message },
27
+ { status: error.status }
28
+ );
29
+ console.error("Reading a task's diff:", error);
30
+ return NextResponse.json(
31
+ { error: "Couldn't read the task's change" },
32
+ { status: 500 }
33
+ );
34
+ }
35
+ }
@@ -112,14 +112,4 @@ export function useCompareQuery(days: number, enabled = true) {
112
112
  }
113
113
 
114
114
  // Every model a role can be set to: each usable provider's, as references.
115
- export function modelChoices(effective: EffectiveModels | undefined) {
116
- return (effective?.providers ?? [])
117
- .filter((p) => p.enabled)
118
- .flatMap((p) =>
119
- p.models.map((m) => ({
120
- ref: `${p.id}/${m}`,
121
- label: m,
122
- provider: p.label,
123
- }))
124
- );
125
- }
115
+ export { modelChoices } from "@/lib/models/choices";
@@ -0,0 +1,88 @@
1
+ /**
2
+ * Tells the Connect service this machine is alive: one signed beat when
3
+ * Connect starts, then one a minute. The answer can switch the machine off
4
+ * (the account disabled it) or say it is gone (removed from the account).
5
+ * Nothing here may throw: a rejection would take AgentOS down.
6
+ */
7
+
8
+ import fs from "fs";
9
+ import path from "path";
10
+ import { ApiError, clean } from "./control-api";
11
+
12
+ export const HEARTBEAT_MS = 60_000;
13
+
14
+ export interface HeartbeatOptions {
15
+ beat: () => Promise<{ enabled: boolean }>;
16
+ /** The account disabled this machine: stop the tunnel. */
17
+ onDisabled: () => void;
18
+ /** The service no longer knows this machine. */
19
+ onGone: () => void;
20
+ log?: (line: string) => void;
21
+ intervalMs?: number;
22
+ }
23
+
24
+ export function startHeartbeat(opts: HeartbeatOptions) {
25
+ const log = opts.log ?? (() => {});
26
+ const every = opts.intervalMs ?? HEARTBEAT_MS;
27
+ let stopped = false;
28
+ let failing = false;
29
+ let timer: NodeJS.Timeout | null = null;
30
+
31
+ const stop = () => {
32
+ stopped = true;
33
+ if (timer) clearTimeout(timer);
34
+ timer = null;
35
+ };
36
+
37
+ const next = () => {
38
+ if (stopped) return;
39
+ timer = setTimeout(tick, every);
40
+ timer.unref?.();
41
+ };
42
+
43
+ const tick = async () => {
44
+ timer = null;
45
+ try {
46
+ const { enabled } = await opts.beat();
47
+ if (stopped) return;
48
+ failing = false;
49
+ if (enabled === false) {
50
+ stop();
51
+ log("connect: this machine was disabled in your account; tunnel off");
52
+ opts.onDisabled();
53
+ return;
54
+ }
55
+ } catch (err) {
56
+ if (stopped) return;
57
+ const status = err instanceof ApiError ? err.status : 0;
58
+ if (status === 401 || status === 404) {
59
+ stop();
60
+ log("connect: machine no longer linked, run agent-os connect");
61
+ opts.onGone();
62
+ return;
63
+ }
64
+ if (!failing) {
65
+ failing = true;
66
+ log(
67
+ `connect: heartbeat failed: ${clean((err as Error)?.message ?? String(err))}; retrying`
68
+ );
69
+ }
70
+ }
71
+ next();
72
+ };
73
+
74
+ void tick();
75
+ return { stop };
76
+ }
77
+
78
+ /** The running AgentOS version, from package.json. */
79
+ export function appVersion(cwd = process.cwd()): string {
80
+ try {
81
+ const pkg = JSON.parse(
82
+ fs.readFileSync(path.join(cwd, "package.json"), "utf8")
83
+ ) as { version?: string };
84
+ return pkg.version ?? "unknown";
85
+ } catch {
86
+ return "unknown";
87
+ }
88
+ }
@@ -28,7 +28,13 @@ import {
28
28
  type ConnectConfig,
29
29
  } from "./config";
30
30
  import { shouldStartConnect, takeConnectLock } from "./guard";
31
- import { checkApiUrl, requestTunnel, type Tunnel } from "./control-api";
31
+ import {
32
+ checkApiUrl,
33
+ heartbeat as sendHeartbeat,
34
+ requestTunnel,
35
+ type Tunnel,
36
+ } from "./control-api";
37
+ import { appVersion, startHeartbeat } from "./heartbeat";
32
38
  import { ensureCloudflared } from "./cloudflared";
33
39
  import { superviseTunnel, type Spawn, type TunnelState } from "./supervise";
34
40
 
@@ -93,12 +99,15 @@ export interface ConnectDeps {
93
99
  ) => Promise<Tunnel>;
94
100
  spawn?: Spawn;
95
101
  delays?: Parameters<typeof superviseTunnel>[0]["delays"];
102
+ heartbeat?: typeof sendHeartbeat;
103
+ heartbeatMs?: number;
96
104
  }
97
105
 
98
106
  interface Running {
99
107
  server: http.Server;
100
108
  sockets: Set<Socket>;
101
109
  supervisor: ReturnType<typeof superviseTunnel>;
110
+ heartbeat: ReturnType<typeof startHeartbeat>;
102
111
  release: () => void;
103
112
  }
104
113
 
@@ -115,6 +124,9 @@ export function startConnect(
115
124
  let base: ConnectStatus = { state: "off" };
116
125
  let hostname = "";
117
126
  let running: Running | null = null;
127
+ // Set when the service disabled or dropped this machine; sync() leaves it
128
+ // off until connect.json is turned off or relinked.
129
+ let halted: string | null = null;
118
130
  // Set while the tunnel listener can't bind; it keeps trying.
119
131
  let bindError: string | undefined;
120
132
  g.__agentosConnect = () => {
@@ -140,6 +152,7 @@ export function startConnect(
140
152
  const down = (why: string) => {
141
153
  if (!running) return;
142
154
  bindError = undefined;
155
+ running.heartbeat.stop();
143
156
  running.supervisor.stop();
144
157
  running.sockets.forEach((s) => s.destroy());
145
158
  running.server.close();
@@ -162,6 +175,14 @@ export function startConnect(
162
175
  hostname = config.hostname;
163
176
  base = { state: "off", enrolled: true, hostname: hostname || undefined };
164
177
  if (!read.enabled) return;
178
+ if (halted) {
179
+ base = {
180
+ ...base,
181
+ state: "off",
182
+ reason: "stopped by the Connect service",
183
+ };
184
+ return;
185
+ }
165
186
  try {
166
187
  checkApiUrl(config.apiUrl, env.AGENTOS_CONNECT_OPERATOR === "1");
167
188
  } catch (err) {
@@ -225,7 +246,22 @@ export function startConnect(
225
246
  return { bin, token: tunnel.token };
226
247
  },
227
248
  });
228
- running = { server, sockets, supervisor, release: lock.release };
249
+ const beat = deps.heartbeat ?? sendHeartbeat;
250
+ const version = appVersion();
251
+ const heartbeat = startHeartbeat({
252
+ beat: () => beat(config, machineKey, version),
253
+ log,
254
+ intervalMs: deps.heartbeatMs,
255
+ onDisabled: () => {
256
+ halted = config.machineId;
257
+ down("disabled in your account");
258
+ },
259
+ onGone: () => {
260
+ halted = config.machineId;
261
+ down("machine no longer linked");
262
+ },
263
+ });
264
+ running = { server, sockets, supervisor, heartbeat, release: lock.release };
229
265
  server.listen(tunnelPort, "127.0.0.1");
230
266
  };
231
267
 
@@ -233,7 +269,12 @@ export function startConnect(
233
269
  const sync = () => {
234
270
  try {
235
271
  const read = readConnect(dir);
236
- const want = read.ok && read.enabled;
272
+ if (
273
+ halted &&
274
+ (!read.ok || !read.enabled || read.files.config.machineId !== halted)
275
+ )
276
+ halted = null;
277
+ const want = read.ok && read.enabled && !halted;
237
278
  if (running && !want) down(read.ok ? "turned off" : read.reason);
238
279
  else if (!running && want) up();
239
280
  else if (!running) up(); // refreshes what the status shows
package/lib/git.ts CHANGED
@@ -148,3 +148,27 @@ export async function renameBranch(
148
148
  { timeout: 10000 }
149
149
  );
150
150
  }
151
+
152
+ export interface GitCheck {
153
+ isGitRepo: boolean;
154
+ branches: string[];
155
+ defaultBranch: string | null;
156
+ currentBranch: string | null;
157
+ }
158
+
159
+ /** Whether a path is a repository, and its branches. */
160
+ export async function gitCheck(dirPath: string): Promise<GitCheck> {
161
+ if (!(await isGitRepo(dirPath)))
162
+ return {
163
+ isGitRepo: false,
164
+ branches: [],
165
+ defaultBranch: null,
166
+ currentBranch: null,
167
+ };
168
+ const [branches, defaultBranch, currentBranch] = await Promise.all([
169
+ getBranches(dirPath),
170
+ getDefaultBranch(dirPath),
171
+ getCurrentBranch(dirPath),
172
+ ]);
173
+ return { isGitRepo: true, branches, defaultBranch, currentBranch };
174
+ }
@@ -0,0 +1,19 @@
1
+ import type { Project } from "../db";
2
+ import { projectRef } from "../tasks/project-ref";
3
+ import { projectOnPeers } from "./peer-actions";
4
+ import { linkedHostIds } from "./remote-api";
5
+
6
+ // Which linked machines can take a session for this project, and why not
7
+ // where one can't: { [hostId]: reason | null }.
8
+ export async function projectMachines(
9
+ project: Project
10
+ ): Promise<Record<string, string | null>> {
11
+ if ((project.host_id || "local") !== "local") return {};
12
+ try {
13
+ return await projectOnPeers(await projectRef(project));
14
+ } catch {
15
+ // A project outside the home folder has no place on another machine.
16
+ const reason = "Its folder is outside your home folder";
17
+ return Object.fromEntries([...linkedHostIds()].map((id) => [id, reason]));
18
+ }
19
+ }
@@ -0,0 +1,17 @@
1
+ import type { EffectiveModels } from "./config";
2
+
3
+ // The "provider/model" refs a task can run on: every model of each enabled
4
+ // provider.
5
+ export function modelChoices(
6
+ effective: Pick<EffectiveModels, "providers"> | undefined
7
+ ) {
8
+ return (effective?.providers ?? [])
9
+ .filter((p) => p.enabled)
10
+ .flatMap((p) =>
11
+ p.models.map((m) => ({
12
+ ref: `${p.id}/${m}`,
13
+ label: m,
14
+ provider: p.label,
15
+ }))
16
+ );
17
+ }
@@ -88,7 +88,7 @@ async function fetchBranches(
88
88
  }
89
89
 
90
90
  // origin/<branch> when it's there, else a local <branch>.
91
- async function existingRef(
91
+ export async function existingRef(
92
92
  repo: string,
93
93
  branch: string
94
94
  ): Promise<string | null> {
@@ -43,7 +43,7 @@ const short = (sha: string) => sha.slice(0, 7);
43
43
 
44
44
  // The heads AgentOS's own restack, or its automatic rebase onto the base,
45
45
  // moved the task's branch between.
46
- function restackedOf(task: Session) {
46
+ export function restackedOf(task: Session) {
47
47
  const item = stackQueries.itemForSession(db, task.id);
48
48
  if (item) return { from: item.restacked_from, to: item.restacked_to };
49
49
  return task.rebased_to
@@ -0,0 +1,93 @@
1
+ /**
2
+ * What the start form needs in one request: the agents and their models, the
3
+ * models a task can run on, the machines, the default branch and whether a
4
+ * task plans first. Built from the helpers the web form already uses; never
5
+ * a provider's address or key reference, nor a machine's ssh target.
6
+ */
7
+
8
+ import { AGENT_OPTIONS } from "./agent-options";
9
+ import { probeAgents } from "./agents/probe";
10
+ import { gitCheck } from "./git";
11
+ import { listHosts } from "./hosts";
12
+ import { projectMachines } from "./hosts/project-machines";
13
+ import { getModelOptions, getDefaultModelForAgent } from "./model-catalog";
14
+ import { effectiveModels } from "./models/config";
15
+ import { modelChoices } from "./models/choices";
16
+ import { resolveRole } from "./models/resolve";
17
+ import { getProject } from "./projects";
18
+ import { expandHome } from "./tasks/session";
19
+ import { taskRouting } from "./tasks";
20
+ import type { StartOptions } from "./tasks/loop-types";
21
+
22
+ const BRANCHES_MAX = 50;
23
+
24
+ export class UnknownProjectError extends Error {}
25
+
26
+ export async function startOptions(
27
+ projectId: string | null
28
+ ): Promise<StartOptions> {
29
+ const project = projectId ? getProject(projectId) : undefined;
30
+ if (projectId && (!project || project.is_uncategorized))
31
+ throw new UnknownProjectError("Project not found");
32
+ const here = !project || (project.host_id || "local") === "local";
33
+ const models = effectiveModels();
34
+
35
+ let planDefault = false;
36
+ let planError: string | null = null;
37
+ const roles = { plan: "", implement: "" };
38
+ try {
39
+ const opts = { workspaceId: project?.workspace_id ?? null };
40
+ roles.plan = resolveRole("plan", opts, models).ref;
41
+ roles.implement = resolveRole("implement", opts, models).ref;
42
+ planDefault = taskRouting(opts.workspaceId, "chat", undefined).plan;
43
+ } catch (error) {
44
+ planError = error instanceof Error ? error.message : String(error);
45
+ }
46
+
47
+ const [probes, git, machines] = await Promise.all([
48
+ here ? probeAgents() : Promise.resolve(null),
49
+ project && here
50
+ ? gitCheck(expandHome(project.working_directory))
51
+ : Promise.resolve(null),
52
+ project
53
+ ? projectMachines(project)
54
+ : Promise.resolve<Record<string, string | null>>({}),
55
+ ]);
56
+
57
+ return {
58
+ project: project
59
+ ? {
60
+ id: project.id,
61
+ name: project.name,
62
+ agentType: project.agent_type,
63
+ defaultModel: project.default_model,
64
+ hostId: project.host_id || "local",
65
+ }
66
+ : null,
67
+ agents: AGENT_OPTIONS.map((a) => ({
68
+ value: a.value,
69
+ label: a.label,
70
+ installed: probes?.[a.value]?.installed ?? null,
71
+ auth: probes?.[a.value]?.auth ?? null,
72
+ hint: probes?.[a.value]?.hint ?? null,
73
+ models: getModelOptions(a.value),
74
+ defaultModel: getDefaultModelForAgent(a.value),
75
+ })),
76
+ taskModels: modelChoices(models),
77
+ roles,
78
+ planDefault,
79
+ planError,
80
+ hosts: listHosts().map((h) => ({
81
+ id: h.id,
82
+ name: h.name,
83
+ linked: h.id === "local" || Boolean(h.linked),
84
+ unavailable: machines[h.id] ?? null,
85
+ })),
86
+ git: git && {
87
+ isGitRepo: git.isGitRepo,
88
+ defaultBranch: git.defaultBranch,
89
+ currentBranch: git.currentBranch,
90
+ branches: git.branches.slice(0, BRANCHES_MAX),
91
+ },
92
+ };
93
+ }