@saadnvd1/agent-os 0.3.509 → 0.3.512

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
  }
@@ -13,13 +13,7 @@ export async function GET() {
13
13
  // Merged and dropped tasks live in the Tasks panel (a moved one lives on
14
14
  // the machine it moved to), archived sessions
15
15
  // in the Archived view: neither is in the sidebar.
16
- const sessions = (queries.getAllSessions(db).all() as Session[]).filter(
17
- (s) =>
18
- !s.archived_at &&
19
- s.task_status !== "merged" &&
20
- s.task_status !== "dropped" &&
21
- s.task_status !== "moved"
22
- );
16
+ const sessions = queries.getSidebarSessions(db).all() as Session[];
23
17
  const groups = queries.getAllGroups(db).all() as Group[];
24
18
 
25
19
  // Convert expanded from 0/1 to boolean
@@ -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
+ }
@@ -305,7 +305,7 @@ export const Pane = memo(function Pane({
305
305
  sessionName={chat.name}
306
306
  accessLocked={chat.role === "orchestrator"}
307
307
  freshStart={
308
- (chat.role === "orchestrator" || !!chat.task_brief) &&
308
+ (chat.role === "orchestrator" || !!chat.has_brief) &&
309
309
  !chat.archived_at
310
310
  }
311
311
  orchestratorOf={
@@ -175,7 +175,7 @@ export function SessionRowMenu({
175
175
  {summarizing ? "Summarizing..." : "Fresh start"}
176
176
  </Item>
177
177
  )}
178
- {(session.role === "orchestrator" || session.task_brief) &&
178
+ {(session.role === "orchestrator" || session.has_brief) &&
179
179
  session.view === "chat" &&
180
180
  !session.archived_at &&
181
181
  !blocked && (
@@ -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";
@@ -116,6 +116,33 @@ function chatTurn(sessionId: string): number {
116
116
  );
117
117
  }
118
118
 
119
+ // How recently Saad must have messaged an orchestrator for turning Away on
120
+ // to tell it.
121
+ export const SAAD_MESSAGE_WINDOW_MS = 24 * 60 * 60_000;
122
+
123
+ // When Saad last typed to a chat himself: a user item with no origin, so
124
+ // not an event, a peer, a schedule or AgentOS, or his own decision made
125
+ // outside the composer. 0 when he never has.
126
+ function saadMessageAt(sessionId: string): number {
127
+ const row = db
128
+ .prepare(
129
+ `SELECT json_extract(data, '$.createdAt') AS at FROM chat_items
130
+ WHERE session_id = ? AND kind = 'user'
131
+ AND (json_extract(data, '$.origin') IS NULL
132
+ OR json_extract(data, '$.origin.kind') = 'decision')
133
+ ORDER BY seq DESC LIMIT 1`
134
+ )
135
+ .get(sessionId) as { at: number | null } | undefined;
136
+ return row?.at ?? 0;
137
+ }
138
+
139
+ // Whether Saad has been talking to this session lately. A terminal keeps
140
+ // no record of who typed, so its last turn stands in.
141
+ export function saadMessagedRecently(s: Session, now = Date.now()): boolean {
142
+ const at = s.view === "chat" ? saadMessageAt(s.id) : terminalTurn(s);
143
+ return at > 0 && now - at <= SAAD_MESSAGE_WINDOW_MS;
144
+ }
145
+
119
146
  // A terminal's Claude conversation file is written on every turn and on
120
147
  // nothing else; other agents fall back to tmux's output clock, which a
121
148
  // status poll (capture-pane) doesn't move.
package/lib/away/brief.ts CHANGED
@@ -7,7 +7,6 @@ import {
7
7
  activeAway,
8
8
  clockTime,
9
9
  fromSql,
10
- reachedWorkspaces,
11
10
  untilText,
12
11
  type AwayRow,
13
12
  } from "./state";
@@ -18,14 +17,9 @@ export function awayOnLine(row: AwayRow, now = Date.now()): string {
18
17
  return `Saad is away (Away mode, since ${clockTime(fromSql(row.started_at), now)}, ${untilText(row, now)}). Follow work through rather than only reacting to events: keep each task moving to merged and deployed, sign off what passes its gates, and pick up what stalled. Every ${SWEEP_EVERY_MS / 60000} minutes AgentOS sweeps this workspace without messaging any session: it runs lost reviews again, checks the deploy after each merge, and sends you one digest of what's new (stalled tasks, idle ones with uncommitted work and no PR, Saad's own sessions idle with uncommitted work). You decide what needs work and act with your tools. Only sessions with real activity in the last ${awayWindowMinutes()} minutes are listed: leave the rest alone, since a message wakes an agent and re-reads its whole context. Interactive sessions (not tasks) are Saad's: ask a listed one at most once to wrap up, never drive them further. Hard lines still go to ask_saad; while he's away his asks reach his phone in one batch per sweep, so raise them as usual. \`aos notify\` is held for that batch too, so don't send one per item.`;
19
18
  }
20
19
 
21
- // For the digest: the same line while Away is on and has reached this
22
- // workspace, else nothing.
23
- export function awayDigestLine(
24
- workspaceId: string,
25
- now = Date.now()
26
- ): string | null {
20
+ // For the digest: the same line whenever Away is on, messaged or not, so an
21
+ // orchestrator skipped at the toggle still knows when it next starts.
22
+ export function awayDigestLine(now = Date.now()): string | null {
27
23
  const row = activeAway();
28
- return row && reachedWorkspaces(row.id).has(workspaceId)
29
- ? awayOnLine(row, now)
30
- : null;
24
+ return row ? awayOnLine(row, now) : null;
31
25
  }
package/lib/away/run.ts CHANGED
@@ -45,7 +45,12 @@ import {
45
45
  SWEEP_EVERY_MS,
46
46
  type Candidate,
47
47
  } from "./sweep";
48
- import { agentLive, awayWindowMs, lastTurnAt } from "./activity";
48
+ import {
49
+ agentLive,
50
+ awayWindowMs,
51
+ lastTurnAt,
52
+ saadMessagedRecently,
53
+ } from "./activity";
49
54
  import { checkDeploy } from "./deploy";
50
55
  import { buildAwayReport, phoneSummary, reportMarkdown } from "./report";
51
56
  import { awayOnLine } from "./brief";
@@ -92,6 +97,14 @@ function tellAway(
92
97
  // keep an orchestrator going that nothing else has.
93
98
  if (!orchestratorActive(orchestratorId, now)) return false;
94
99
  const told = reachedWorkspaces(away.id).has(workspaceId);
100
+ // A first message goes only to one Saad has messaged lately: waking the
101
+ // rest re-reads their whole context for nothing, and they read the away
102
+ // state in their brief when they next start. One already told keeps its
103
+ // sweeps for the rest of the period.
104
+ if (!told) {
105
+ const s = sessionRow(orchestratorId);
106
+ if (!s || !saadMessagedRecently(s, now)) return false;
107
+ }
95
108
  // One transaction: a crash can't leave the marker set with the event
96
109
  // never queued.
97
110
  db.transaction(() => {
@@ -133,8 +146,9 @@ export function turnAwayOn(
133
146
  ): AwayRow {
134
147
  const { row, started } = startAway(input, now);
135
148
  // Sent again when the end moves, so the orchestrator has the new one.
136
- for (const { workspaceId, orchestratorId } of orchestrated())
149
+ for (const { workspaceId, orchestratorId } of orchestrated()) {
137
150
  tellAway(row, workspaceId, orchestratorId, now, !started);
151
+ }
138
152
  return row;
139
153
  }
140
154
 
@@ -37,6 +37,7 @@ import {
37
37
  getItem,
38
38
  hasItem,
39
39
  itemsOfKind,
40
+ recentTasks,
40
41
  listItems,
41
42
  saveItem,
42
43
  settle,
@@ -865,11 +866,7 @@ export function watchChat(
865
866
  // The composer lists background work still going or just done, even when
866
867
  // it started before the page.
867
868
  const recent = Date.now() - 60 * 60 * 1000;
868
- const tasks = itemsOfKind(sessionId, "task").filter(
869
- (t) =>
870
- !onPage.has(t.id) &&
871
- (t.status === "running" || (t.endedAt ?? t.createdAt) > recent)
872
- );
869
+ const tasks = recentTasks(sessionId, recent).filter((t) => !onPage.has(t.id));
873
870
  listener({
874
871
  type: "snapshot",
875
872
  items: present(sessionId, page.items),
package/lib/chat/store.ts CHANGED
@@ -176,6 +176,28 @@ export function itemsOfKind<K extends ChatItem["kind"]>(
176
176
  ).map((r) => JSON.parse(r.data));
177
177
  }
178
178
 
179
+ // A chat's background tasks still running, or ended after `since` (ms): what
180
+ // the composer lists, read without parsing the rest of a long conversation.
181
+ export function recentTasks(
182
+ sessionId: string,
183
+ since: number
184
+ ): Extract<ChatItem, { kind: "task" }>[] {
185
+ flushItems();
186
+ return (
187
+ db
188
+ .prepare(
189
+ `SELECT data FROM chat_items WHERE session_id = ? AND kind = 'task'
190
+ AND (json_extract(data, '$.status') = 'running'
191
+ OR COALESCE(json_extract(data, '$.endedAt'),
192
+ json_extract(data, '$.createdAt')) > ?)
193
+ ORDER BY seq DESC LIMIT 200`
194
+ )
195
+ .all(sessionId, since) as { data: string }[]
196
+ )
197
+ .reverse()
198
+ .map((r) => JSON.parse(r.data));
199
+ }
200
+
179
201
  // Tool calls saved as still running.
180
202
  export function runningTools(
181
203
  sessionId: string
package/lib/db/index.ts CHANGED
@@ -6,7 +6,7 @@ import { runMigrations } from "./migrations";
6
6
 
7
7
  // Re-export types and queries
8
8
  export * from "./types";
9
- export { queries } from "./queries";
9
+ export { queries, sessionListColumns } from "./queries";
10
10
  export * from "./lumifyhub";
11
11
  export * from "./lumifyhub-docs";
12
12
  export * from "./stacks";
package/lib/db/queries.ts CHANGED
@@ -13,6 +13,28 @@ function getStmt(db: Database.Database, sql: string): Database.Statement {
13
13
  return stmt;
14
14
  }
15
15
 
16
+ // Every sessions column but the two large text ones, which only the single
17
+ // session paths read; a list gets has_brief for whether a brief exists.
18
+ const listColumns = new WeakMap<Database.Database, string>();
19
+ export function sessionListColumns(db: Database.Database, alias = ""): string {
20
+ let cols = listColumns.get(db);
21
+ if (!cols) {
22
+ cols = (
23
+ db.prepare(`PRAGMA table_info(sessions)`).all() as { name: string }[]
24
+ )
25
+ .map((c) => c.name)
26
+ .filter((n) => n !== "task_brief" && n !== "system_prompt")
27
+ .join(",");
28
+ listColumns.set(db, cols);
29
+ }
30
+ const prefix = alias ? `${alias}.` : "";
31
+ const list = cols
32
+ .split(",")
33
+ .map((c) => `${prefix}${c}`)
34
+ .join(", ");
35
+ return `${list}, (${prefix}task_brief IS NOT NULL AND ${prefix}task_brief != '') AS has_brief`;
36
+ }
37
+
16
38
  export const queries = {
17
39
  // Sessions
18
40
  createSession: (db: Database.Database) =>
@@ -26,7 +48,22 @@ export const queries = {
26
48
  getStmt(db, `SELECT * FROM sessions WHERE id = ?`),
27
49
 
28
50
  getAllSessions: (db: Database.Database) =>
29
- getStmt(db, `SELECT * FROM sessions ORDER BY updated_at DESC`),
51
+ getStmt(
52
+ db,
53
+ `SELECT ${sessionListColumns(db)} FROM sessions ORDER BY updated_at DESC`
54
+ ),
55
+
56
+ // The sidebar's sessions: not archived, and not a task that is over or
57
+ // lives on another machine.
58
+ getSidebarSessions: (db: Database.Database) =>
59
+ getStmt(
60
+ db,
61
+ `SELECT ${sessionListColumns(db)} FROM sessions
62
+ WHERE archived_at IS NULL
63
+ AND (task_status IS NULL
64
+ OR task_status NOT IN ('merged', 'dropped', 'moved'))
65
+ ORDER BY updated_at DESC`
66
+ ),
30
67
 
31
68
  // The tmux names AgentOS owns, for telling discovered sessions apart.
32
69
  getManagedTmuxNames: (db: Database.Database) =>
package/lib/db/types.ts CHANGED
@@ -51,6 +51,8 @@ export interface Session {
51
51
  setup_ms?: number | null;
52
52
  setup_error?: string | null;
53
53
  task_brief?: string | null;
54
+ // Lists carry this instead of the brief itself (0/1).
55
+ has_brief?: number;
54
56
  // How the session is shown: a chat conversation or a terminal.
55
57
  view: "chat" | "terminal";
56
58
  chat_access: ChatAccess;
@@ -44,7 +44,8 @@ export function unarchiveSession(id: string): Session {
44
44
  export function listArchived(workspaceId?: string): ArchivedView[] {
45
45
  const rows = db
46
46
  .prepare(
47
- `SELECT s.*, p.name AS project_name, p.workspace_id AS project_workspace
47
+ `SELECT s.id, s.name, s.project_id, s.view, s.task_status, s.branch_name,
48
+ s.pr_url, s.worktree_path, s.archived_at, p.name AS project_name, p.workspace_id AS project_workspace
48
49
  FROM sessions s LEFT JOIN projects p ON p.id = s.project_id
49
50
  WHERE s.archived_at IS NOT NULL
50
51
  AND (? IS NULL OR p.workspace_id = ?)
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
+ }
package/lib/needs-you.ts CHANGED
@@ -56,7 +56,7 @@ function pendingApproval(
56
56
  const row = getDb()
57
57
  .prepare(
58
58
  `SELECT data FROM chat_items WHERE session_id = ?
59
- AND json_extract(data, '$.kind') = 'approval'
59
+ AND kind = 'approval'
60
60
  AND json_extract(data, '$.status') = 'pending'
61
61
  ORDER BY seq DESC LIMIT 1`
62
62
  )
@@ -324,7 +324,7 @@ export function buildDigest(
324
324
  ): string {
325
325
  const checks = checksBySession(workspaceId);
326
326
  const failures = failuresBySession(workspaceId);
327
- const away = awayDigestLine(workspaceId);
327
+ const away = awayDigestLine();
328
328
  const head = `## Where things stand (read from AgentOS's database as you started)${away ? `\n\n${away}` : ""}`;
329
329
  const body = renderDigest(
330
330
  [
@@ -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