@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 +11 -0
- package/app/api/git/check/route.ts +2 -31
- package/app/api/projects/[id]/machines/route.ts +2 -18
- package/app/api/sessions/route.ts +1 -7
- package/app/api/start-options/route.ts +22 -0
- package/app/api/tasks/[id]/checks/route.ts +37 -0
- package/app/api/tasks/[id]/diff/route.ts +35 -0
- package/components/Pane/index.tsx +1 -1
- package/components/SessionList/SessionRowMenu.tsx +1 -1
- package/data/models/index.ts +1 -11
- package/lib/away/activity.ts +27 -0
- package/lib/away/brief.ts +4 -10
- package/lib/away/run.ts +16 -2
- package/lib/chat/runner.ts +2 -5
- package/lib/chat/store.ts +22 -0
- package/lib/db/index.ts +1 -1
- package/lib/db/queries.ts +38 -1
- package/lib/db/types.ts +2 -0
- package/lib/done/archive.ts +2 -1
- package/lib/git.ts +24 -0
- package/lib/hosts/project-machines.ts +19 -0
- package/lib/models/choices.ts +17 -0
- package/lib/needs-you.ts +1 -1
- package/lib/orchestrator/digest.ts +1 -1
- package/lib/orchestrator/repo.ts +1 -1
- package/lib/orchestrator/signoff.ts +1 -1
- package/lib/start-options.ts +93 -0
- package/lib/tasks/checks-view.ts +192 -0
- package/lib/tasks/diff.ts +259 -0
- package/lib/tasks/gh.ts +1 -1
- package/lib/tasks/index.ts +6 -9
- package/lib/tasks/loop-error.ts +21 -0
- package/lib/tasks/loop-types.ts +202 -0
- package/lib/tasks/plan-view.ts +127 -0
- package/lib/tasks/session.ts +14 -4
- package/package.json +1 -1
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
|
-
|
|
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 {
|
|
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
|
-
|
|
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 =
|
|
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.
|
|
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.
|
|
178
|
+
{(session.role === "orchestrator" || session.has_brief) &&
|
|
179
179
|
session.view === "chat" &&
|
|
180
180
|
!session.archived_at &&
|
|
181
181
|
!blocked && (
|
package/data/models/index.ts
CHANGED
|
@@ -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
|
|
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";
|
package/lib/away/activity.ts
CHANGED
|
@@ -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
|
|
22
|
-
//
|
|
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
|
|
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 {
|
|
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
|
|
package/lib/chat/runner.ts
CHANGED
|
@@ -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 =
|
|
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(
|
|
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;
|
package/lib/done/archive.ts
CHANGED
|
@@ -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
|
|
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
|
|
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(
|
|
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
|
[
|
package/lib/orchestrator/repo.ts
CHANGED
|
@@ -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
|