@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
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The shapes the building-loop endpoints answer with (start options, a
|
|
3
|
+
* task's diff, its checks and review verdict, its plan), plus their caps.
|
|
4
|
+
* Pure data, no imports, so the native app can sync this file on its own.
|
|
5
|
+
* Every string that came from a log, a review or a plan is untrusted text
|
|
6
|
+
* to display, never to run.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
export const DIFF_MAX_FILES = 300;
|
|
10
|
+
export const DIFF_PATCH_MAX_BYTES = 200_000;
|
|
11
|
+
export const CHECK_LOG_MAX_BYTES = 8_000;
|
|
12
|
+
export const CHECK_LOGS_MAX = 3;
|
|
13
|
+
export const REVIEW_DETAIL_MAX = 20_000;
|
|
14
|
+
export const REVIEW_FINDINGS_MAX = 50;
|
|
15
|
+
export const PLAN_TEXT_MAX = 4_000;
|
|
16
|
+
|
|
17
|
+
// -- Start options ---------------------------------------------------------
|
|
18
|
+
|
|
19
|
+
export interface StartAgent {
|
|
20
|
+
value: string;
|
|
21
|
+
label: string;
|
|
22
|
+
// On this machine; null when the project runs on another machine.
|
|
23
|
+
installed: boolean | null;
|
|
24
|
+
auth: string | null;
|
|
25
|
+
hint: string | null;
|
|
26
|
+
models: { value: string; label: string }[];
|
|
27
|
+
defaultModel: string;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export interface StartHost {
|
|
31
|
+
id: string;
|
|
32
|
+
name: string;
|
|
33
|
+
linked: boolean;
|
|
34
|
+
// Why a project here can't go to this machine, when it can't.
|
|
35
|
+
unavailable: string | null;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export interface TaskModelChoice {
|
|
39
|
+
ref: string;
|
|
40
|
+
label: string;
|
|
41
|
+
provider: string;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export interface StartOptions {
|
|
45
|
+
project: {
|
|
46
|
+
id: string;
|
|
47
|
+
name: string;
|
|
48
|
+
agentType: string;
|
|
49
|
+
defaultModel: string;
|
|
50
|
+
hostId: string;
|
|
51
|
+
} | null;
|
|
52
|
+
agents: StartAgent[];
|
|
53
|
+
// "provider/model" refs a task can run on, and the role defaults.
|
|
54
|
+
taskModels: TaskModelChoice[];
|
|
55
|
+
roles: { plan: string; implement: string };
|
|
56
|
+
planDefault: boolean;
|
|
57
|
+
planError: string | null;
|
|
58
|
+
hosts: StartHost[];
|
|
59
|
+
git: {
|
|
60
|
+
isGitRepo: boolean;
|
|
61
|
+
defaultBranch: string | null;
|
|
62
|
+
currentBranch: string | null;
|
|
63
|
+
branches: string[];
|
|
64
|
+
} | null;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// -- A task's whole change -------------------------------------------------
|
|
68
|
+
|
|
69
|
+
export interface TaskDiffFile {
|
|
70
|
+
path: string;
|
|
71
|
+
// git's letters: A added, M modified, D deleted, R renamed, C copied,
|
|
72
|
+
// T type changed.
|
|
73
|
+
status: string;
|
|
74
|
+
// null for a binary file.
|
|
75
|
+
additions: number | null;
|
|
76
|
+
deletions: number | null;
|
|
77
|
+
binary: boolean;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export interface TaskDiffList {
|
|
81
|
+
source: "worktree" | "pr";
|
|
82
|
+
base: string;
|
|
83
|
+
head: string;
|
|
84
|
+
files: TaskDiffFile[];
|
|
85
|
+
totalFiles: number;
|
|
86
|
+
additions: number;
|
|
87
|
+
deletions: number;
|
|
88
|
+
truncated: boolean;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export interface TaskDiffPatch {
|
|
92
|
+
source: "worktree" | "pr";
|
|
93
|
+
path: string;
|
|
94
|
+
patch: string | null;
|
|
95
|
+
truncated: boolean;
|
|
96
|
+
// No patch to show: binary, or too big for git or GitHub to give.
|
|
97
|
+
tooLarge: boolean;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// -- Checks and the review verdict ----------------------------------------
|
|
101
|
+
|
|
102
|
+
export type CheckState =
|
|
103
|
+
| "pass"
|
|
104
|
+
| "fail"
|
|
105
|
+
| "pending"
|
|
106
|
+
| "skipped"
|
|
107
|
+
| "cancelled"
|
|
108
|
+
| "neutral";
|
|
109
|
+
|
|
110
|
+
export interface TaskCheck {
|
|
111
|
+
name: string;
|
|
112
|
+
workflow: string | null;
|
|
113
|
+
state: CheckState;
|
|
114
|
+
conclusion: string | null;
|
|
115
|
+
url: string | null;
|
|
116
|
+
startedAt: string | null;
|
|
117
|
+
completedAt: string | null;
|
|
118
|
+
durationSec: number | null;
|
|
119
|
+
// The failed step's log around its error, for a failing Actions job.
|
|
120
|
+
log?: { step: string | null; tail: string; truncated: boolean };
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
export interface ReviewFinding {
|
|
124
|
+
severity: string;
|
|
125
|
+
file: string | null;
|
|
126
|
+
line: number | null;
|
|
127
|
+
summary: string;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
export interface TaskReviewVerdict {
|
|
131
|
+
sha: string;
|
|
132
|
+
status: "running" | "pass" | "block" | "error";
|
|
133
|
+
summary: string;
|
|
134
|
+
findings: ReviewFinding[];
|
|
135
|
+
at: string;
|
|
136
|
+
truncated: boolean;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
export interface TaskChecks {
|
|
140
|
+
pr: {
|
|
141
|
+
number: number;
|
|
142
|
+
url: string;
|
|
143
|
+
state: string;
|
|
144
|
+
head: string | null;
|
|
145
|
+
} | null;
|
|
146
|
+
checks: TaskCheck[];
|
|
147
|
+
review: TaskReviewVerdict | null;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// -- A task's plan ---------------------------------------------------------
|
|
151
|
+
|
|
152
|
+
export interface TaskPlan {
|
|
153
|
+
// As the starter decided (null reason when unstated).
|
|
154
|
+
planned: boolean;
|
|
155
|
+
reason: string | null;
|
|
156
|
+
// null: no plan document yet.
|
|
157
|
+
status: "writing" | "ready" | "approved" | null;
|
|
158
|
+
source: "step" | "chat" | null;
|
|
159
|
+
title: string | null;
|
|
160
|
+
// Relative to the task's worktree.
|
|
161
|
+
path: string | null;
|
|
162
|
+
text: string | null;
|
|
163
|
+
truncated: boolean;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// Splits a stored review detail (lib/orchestrator/review-prompt.ts writes
|
|
167
|
+
// "summary" then "- [severity] file:line: text" lines) back into a summary and
|
|
168
|
+
// findings. A line that isn't a finding stays in the summary.
|
|
169
|
+
const FINDING = /^- \[([^\]\n]{1,40})\](?: ([^\s:]+)(?::(\d+))?)?: (.*)$/;
|
|
170
|
+
|
|
171
|
+
export function parseReviewDetail(detail: string | null | undefined): {
|
|
172
|
+
summary: string;
|
|
173
|
+
findings: ReviewFinding[];
|
|
174
|
+
truncated: boolean;
|
|
175
|
+
} {
|
|
176
|
+
let truncated = false;
|
|
177
|
+
let text = detail ?? "";
|
|
178
|
+
if (text.length > REVIEW_DETAIL_MAX) {
|
|
179
|
+
text = text.slice(0, REVIEW_DETAIL_MAX);
|
|
180
|
+
truncated = true;
|
|
181
|
+
}
|
|
182
|
+
const summary: string[] = [];
|
|
183
|
+
const findings: ReviewFinding[] = [];
|
|
184
|
+
for (const line of text.split("\n")) {
|
|
185
|
+
const m = FINDING.exec(line);
|
|
186
|
+
if (!m) {
|
|
187
|
+
summary.push(line);
|
|
188
|
+
continue;
|
|
189
|
+
}
|
|
190
|
+
if (findings.length >= REVIEW_FINDINGS_MAX) {
|
|
191
|
+
truncated = true;
|
|
192
|
+
continue;
|
|
193
|
+
}
|
|
194
|
+
findings.push({
|
|
195
|
+
severity: m[1],
|
|
196
|
+
file: m[2] ?? null,
|
|
197
|
+
line: m[3] ? Number(m[3]) : null,
|
|
198
|
+
summary: m[4],
|
|
199
|
+
});
|
|
200
|
+
}
|
|
201
|
+
return { summary: summary.join("\n").trim(), findings, truncated };
|
|
202
|
+
}
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A task's plan as the list shows it: whether one is being written, waits for
|
|
3
|
+
* a decision, or was approved, with its title and a capped piece of text.
|
|
4
|
+
* Two sources: a chat in plan mode proposes one (a "plan" item, carried out
|
|
5
|
+
* when approved), and the plan step writes one to a file in the worktree.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import fs from "fs";
|
|
9
|
+
import path from "path";
|
|
10
|
+
import { db, type Session } from "../db";
|
|
11
|
+
import { PLAN_FILE, planSummary } from "../models/plan-step";
|
|
12
|
+
import { PLAN_TEXT_MAX, type TaskPlan } from "./loop-types";
|
|
13
|
+
import { expandHome } from "./session";
|
|
14
|
+
|
|
15
|
+
const TITLE_MAX = 120;
|
|
16
|
+
|
|
17
|
+
export function planTitle(text: string): string | null {
|
|
18
|
+
const heading = /^#{1,6}\s+(.+)$/m.exec(text)?.[1]?.trim();
|
|
19
|
+
const title = heading ?? planSummary(text, 1)[0] ?? null;
|
|
20
|
+
if (!title) return null;
|
|
21
|
+
return title.length > TITLE_MAX ? `${title.slice(0, TITLE_MAX - 1)}…` : title;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function chatPlan(
|
|
25
|
+
sessionId: string
|
|
26
|
+
): { text: string; carried: boolean } | null {
|
|
27
|
+
const row = db
|
|
28
|
+
.prepare(
|
|
29
|
+
`SELECT data FROM chat_items WHERE session_id = ?
|
|
30
|
+
AND json_extract(data, '$.kind') = 'plan' ORDER BY seq DESC LIMIT 1`
|
|
31
|
+
)
|
|
32
|
+
.get(sessionId) as { data: string } | undefined;
|
|
33
|
+
if (!row) return null;
|
|
34
|
+
try {
|
|
35
|
+
const item = JSON.parse(row.data) as { plan?: unknown; carried?: boolean };
|
|
36
|
+
return typeof item.plan === "string"
|
|
37
|
+
? { text: item.plan, carried: !!item.carried }
|
|
38
|
+
: null;
|
|
39
|
+
} catch {
|
|
40
|
+
return null;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// Only the first `max` bytes, not the whole file.
|
|
45
|
+
function readHead(file: string, max: number): string | null {
|
|
46
|
+
let fd: number | undefined;
|
|
47
|
+
try {
|
|
48
|
+
fd = fs.openSync(file, "r");
|
|
49
|
+
const buf = Buffer.alloc(max + 1);
|
|
50
|
+
const n = fs.readSync(fd, buf, 0, max + 1, 0);
|
|
51
|
+
return buf.subarray(0, n).toString("utf8");
|
|
52
|
+
} catch {
|
|
53
|
+
return null;
|
|
54
|
+
} finally {
|
|
55
|
+
if (fd !== undefined) fs.closeSync(fd);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function capped(text: string) {
|
|
60
|
+
const over = text.length > PLAN_TEXT_MAX;
|
|
61
|
+
return { text: over ? text.slice(0, PLAN_TEXT_MAX) : text, truncated: over };
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export function taskPlan(session: Session): TaskPlan | null {
|
|
65
|
+
const decided = session.plan_decided ?? null;
|
|
66
|
+
const phase = session.plan_phase ?? null;
|
|
67
|
+
// Only a chat in plan mode is asked: listing tasks must stay cheap.
|
|
68
|
+
const maybeChat = !!session.chat_plan;
|
|
69
|
+
if (decided === null && !phase && !maybeChat) return null;
|
|
70
|
+
|
|
71
|
+
const base = {
|
|
72
|
+
planned: decided === null ? false : !!decided,
|
|
73
|
+
reason: session.plan_reason ?? null,
|
|
74
|
+
};
|
|
75
|
+
const none = {
|
|
76
|
+
...base,
|
|
77
|
+
status: null,
|
|
78
|
+
source: null,
|
|
79
|
+
title: null,
|
|
80
|
+
path: null,
|
|
81
|
+
text: null,
|
|
82
|
+
truncated: false,
|
|
83
|
+
} satisfies TaskPlan;
|
|
84
|
+
|
|
85
|
+
const chat = maybeChat ? chatPlan(session.id) : null;
|
|
86
|
+
if (chat && !chat.carried)
|
|
87
|
+
return {
|
|
88
|
+
...base,
|
|
89
|
+
planned: true,
|
|
90
|
+
status: "ready",
|
|
91
|
+
source: "chat",
|
|
92
|
+
title: planTitle(chat.text),
|
|
93
|
+
path: null,
|
|
94
|
+
...capped(chat.text),
|
|
95
|
+
};
|
|
96
|
+
|
|
97
|
+
if (phase && session.worktree_path) {
|
|
98
|
+
const file = path.join(expandHome(session.worktree_path), PLAN_FILE);
|
|
99
|
+
const text = readHead(file, PLAN_TEXT_MAX);
|
|
100
|
+
if (text !== null || phase === "planning") {
|
|
101
|
+
const body = text ?? "";
|
|
102
|
+
return {
|
|
103
|
+
...base,
|
|
104
|
+
planned: true,
|
|
105
|
+
status: phase === "planning" ? "writing" : "approved",
|
|
106
|
+
source: "step",
|
|
107
|
+
title: planTitle(body),
|
|
108
|
+
path: text !== null ? PLAN_FILE : null,
|
|
109
|
+
...capped(body),
|
|
110
|
+
};
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
if (chat)
|
|
115
|
+
return {
|
|
116
|
+
...base,
|
|
117
|
+
planned: true,
|
|
118
|
+
status: "approved",
|
|
119
|
+
source: "chat",
|
|
120
|
+
title: planTitle(chat.text),
|
|
121
|
+
path: null,
|
|
122
|
+
...capped(chat.text),
|
|
123
|
+
};
|
|
124
|
+
|
|
125
|
+
if (decided === null) return null;
|
|
126
|
+
return none;
|
|
127
|
+
}
|
package/lib/tasks/session.ts
CHANGED
|
@@ -1,6 +1,12 @@
|
|
|
1
1
|
import fs from "fs";
|
|
2
2
|
import os from "os";
|
|
3
|
-
import {
|
|
3
|
+
import {
|
|
4
|
+
db,
|
|
5
|
+
queries,
|
|
6
|
+
sessionListColumns,
|
|
7
|
+
stackQueries,
|
|
8
|
+
type Session,
|
|
9
|
+
} from "../db";
|
|
4
10
|
import { getDefaultBranch, isBranchName } from "../git";
|
|
5
11
|
import { parentBranchOf } from "../stacks/guard";
|
|
6
12
|
import { voidApprovalsOn } from "../orchestrator/ask-approvals";
|
|
@@ -15,10 +21,14 @@ import type { TaskPR } from "./state";
|
|
|
15
21
|
|
|
16
22
|
export const expandHome = (p: string) => p.replace(/^~/, os.homedir());
|
|
17
23
|
|
|
18
|
-
|
|
24
|
+
// Without the brief and system prompt: a list doesn't need them.
|
|
25
|
+
export function taskSessions(includeArchived = true): Session[] {
|
|
19
26
|
return db
|
|
20
27
|
.prepare(
|
|
21
|
-
`SELECT
|
|
28
|
+
`SELECT ${sessionListColumns(db)} FROM sessions
|
|
29
|
+
WHERE task_status IS NOT NULL AND task_status != 'moved'
|
|
30
|
+
${includeArchived ? "" : "AND archived_at IS NULL"}
|
|
31
|
+
ORDER BY created_at DESC`
|
|
22
32
|
)
|
|
23
33
|
.all() as Session[];
|
|
24
34
|
}
|
|
@@ -37,7 +47,7 @@ export function projectPathFor(session: Session): string | null {
|
|
|
37
47
|
|
|
38
48
|
// The task's worktree, when it's here to look at: null when it's gone or on
|
|
39
49
|
// another host.
|
|
40
|
-
function localWorktree(session: Session): string | null {
|
|
50
|
+
export function localWorktree(session: Session): string | null {
|
|
41
51
|
if (!session.worktree_path) return null;
|
|
42
52
|
if (session.host_id && session.host_id !== "local") return null;
|
|
43
53
|
const dir = expandHome(session.worktree_path);
|