@saadnvd1/agent-os 0.3.558 → 0.3.561
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 +1 -0
- package/app/api/sessions/route.ts +3 -3
- package/app/api/shares/[id]/route.ts +23 -0
- package/app/api/shares/preview/route.ts +30 -0
- package/app/api/shares/route.test.ts +455 -0
- package/app/api/shares/route.ts +101 -0
- package/app/page.tsx +5 -0
- package/components/Chat/Artifact.tsx +17 -1
- package/components/Pane/MobileTabBar.tsx +19 -0
- package/components/Pane/PaneMenu.tsx +16 -0
- package/components/SessionList/SessionRowMenu.tsx +16 -0
- package/components/Settings/SettingsDialog.test.ts +3 -0
- package/components/Settings/SettingsDialog.tsx +9 -0
- package/components/Settings/commands.ts +9 -0
- package/components/Settings/entry-points.test.ts +1 -0
- package/components/Share/ShareDialog.tsx +336 -0
- package/components/Share/format.test.ts +20 -0
- package/components/Share/format.ts +33 -0
- package/components/Shares/SharedLinksPanel.tsx +147 -0
- package/data/shares/index.ts +2 -0
- package/data/shares/keys.ts +4 -0
- package/data/shares/queries.ts +111 -0
- package/lib/chat/drivers/claude.ts +5 -4
- package/lib/connect/control-api.ts +84 -0
- package/lib/db/migrations.ts +50 -0
- package/lib/db/queries.ts +4 -3
- package/lib/models/plan-step.ts +107 -15
- package/lib/orchestrator/conditions.ts +12 -4
- package/lib/orchestrator/events.ts +23 -0
- package/lib/orchestrator/reconcile.ts +201 -0
- package/lib/orchestrator/untrusted.ts +12 -2
- package/lib/plans/features.ts +69 -13
- package/lib/plans/pieces.ts +70 -15
- package/lib/plans/release.ts +46 -4
- package/lib/recipes/settle.ts +3 -0
- package/lib/security/demo.ts +1 -0
- package/lib/session-audit/normalize.ts +1 -1
- package/lib/settings-url.ts +1 -0
- package/lib/share/connect.ts +86 -0
- package/lib/share/context.ts +50 -0
- package/lib/share/redact.ts +190 -0
- package/lib/share/render.tsx +161 -0
- package/lib/share/route.ts +33 -0
- package/lib/share/service.ts +159 -0
- package/lib/share/snapshot.ts +261 -0
- package/lib/share/static-markup.ts +16 -0
- package/lib/share/store.ts +39 -0
- package/lib/share/styles.ts +83 -0
- package/lib/sidebar/shelves.ts +5 -6
- package/package.json +1 -1
- package/stores/shareUi.ts +17 -0
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { toast } from "sonner";
|
|
4
|
+
import { Copy, Link2 } from "lucide-react";
|
|
5
|
+
import { Button } from "@/components/ui/button";
|
|
6
|
+
import { writeClipboard } from "@/hooks/useCopyToClipboard";
|
|
7
|
+
import {
|
|
8
|
+
useRevokeShare,
|
|
9
|
+
useSharesQuery,
|
|
10
|
+
type SharedLinkRow,
|
|
11
|
+
} from "@/data/shares";
|
|
12
|
+
import { cn } from "@/lib/utils";
|
|
13
|
+
|
|
14
|
+
const when = (iso: string) =>
|
|
15
|
+
new Date(iso).toLocaleDateString(undefined, {
|
|
16
|
+
month: "short",
|
|
17
|
+
day: "numeric",
|
|
18
|
+
year: "numeric",
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
function Row({ share }: { share: SharedLinkRow }) {
|
|
22
|
+
const revoke = useRevokeShare();
|
|
23
|
+
const live = share.status === "active";
|
|
24
|
+
|
|
25
|
+
const copy = async () => {
|
|
26
|
+
try {
|
|
27
|
+
await writeClipboard(share.url);
|
|
28
|
+
toast.success("Link copied");
|
|
29
|
+
} catch {
|
|
30
|
+
toast.error("Could not copy to clipboard");
|
|
31
|
+
}
|
|
32
|
+
};
|
|
33
|
+
const onRevoke = () => {
|
|
34
|
+
if (
|
|
35
|
+
!window.confirm(
|
|
36
|
+
"Revoke this link? The page is deleted from Connect and the link stops working."
|
|
37
|
+
)
|
|
38
|
+
)
|
|
39
|
+
return;
|
|
40
|
+
revoke.mutate(share.id, {
|
|
41
|
+
onError: (err) => toast.error((err as Error).message),
|
|
42
|
+
});
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
const title =
|
|
46
|
+
share.kind === "chat" && share.sessionId ? (
|
|
47
|
+
<a
|
|
48
|
+
href={`/?session=${encodeURIComponent(share.sessionId)}`}
|
|
49
|
+
className="hover:underline"
|
|
50
|
+
>
|
|
51
|
+
{share.title}
|
|
52
|
+
</a>
|
|
53
|
+
) : (
|
|
54
|
+
share.title
|
|
55
|
+
);
|
|
56
|
+
|
|
57
|
+
return (
|
|
58
|
+
<li className="bg-muted/40 flex flex-col gap-2 rounded-lg px-3 py-2.5 sm:flex-row sm:items-center">
|
|
59
|
+
<div className="min-w-0 flex-1">
|
|
60
|
+
<div className="truncate text-sm font-medium">{title}</div>
|
|
61
|
+
<div className="text-muted-foreground flex flex-wrap gap-x-3 text-xs">
|
|
62
|
+
<span>{share.kind === "chat" ? "Chat" : "Page"}</span>
|
|
63
|
+
<span>Created {when(share.createdAt)}</span>
|
|
64
|
+
<span>
|
|
65
|
+
{share.status === "revoked"
|
|
66
|
+
? "Revoked"
|
|
67
|
+
: share.status === "expired"
|
|
68
|
+
? "Expired"
|
|
69
|
+
: share.expiresAt
|
|
70
|
+
? `Expires ${when(share.expiresAt)}`
|
|
71
|
+
: "Never expires"}
|
|
72
|
+
</span>
|
|
73
|
+
<span>
|
|
74
|
+
{share.views === null
|
|
75
|
+
? "Views unavailable"
|
|
76
|
+
: `${share.views} ${share.views === 1 ? "view" : "views"}`}
|
|
77
|
+
</span>
|
|
78
|
+
</div>
|
|
79
|
+
</div>
|
|
80
|
+
<div className="flex shrink-0 gap-2">
|
|
81
|
+
<Button
|
|
82
|
+
variant="outline"
|
|
83
|
+
className={cn("min-h-11 sm:min-h-8", !live && "invisible")}
|
|
84
|
+
onClick={copy}
|
|
85
|
+
disabled={!live}
|
|
86
|
+
>
|
|
87
|
+
<Copy className="h-4 w-4" />
|
|
88
|
+
Copy link
|
|
89
|
+
</Button>
|
|
90
|
+
<Button
|
|
91
|
+
variant="outline"
|
|
92
|
+
className={cn(
|
|
93
|
+
"text-destructive min-h-11 sm:min-h-8",
|
|
94
|
+
!live && "invisible"
|
|
95
|
+
)}
|
|
96
|
+
onClick={onRevoke}
|
|
97
|
+
disabled={!live || revoke.isPending}
|
|
98
|
+
>
|
|
99
|
+
Revoke
|
|
100
|
+
</Button>
|
|
101
|
+
</div>
|
|
102
|
+
</li>
|
|
103
|
+
);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// Every snapshot link made from this machine, with views from Connect.
|
|
107
|
+
export function SharedLinksPanel() {
|
|
108
|
+
const { data, isLoading, error } = useSharesQuery();
|
|
109
|
+
return (
|
|
110
|
+
<div className="space-y-3">
|
|
111
|
+
<p className="text-muted-foreground text-sm">
|
|
112
|
+
Read-only snapshots you shared from a chat or a page. Revoking one
|
|
113
|
+
deletes it from Connect straight away.
|
|
114
|
+
</p>
|
|
115
|
+
{data?.connect === "unavailable" && (
|
|
116
|
+
<p className="text-muted-foreground bg-muted/40 rounded-lg px-3 py-2 text-xs">
|
|
117
|
+
Can't reach Connect, so views are hidden and revoking is
|
|
118
|
+
unavailable until it is back.
|
|
119
|
+
</p>
|
|
120
|
+
)}
|
|
121
|
+
{data?.connect === "unlinked" && data.shares.length > 0 && (
|
|
122
|
+
<p className="text-muted-foreground bg-muted/40 rounded-lg px-3 py-2 text-xs">
|
|
123
|
+
This machine isn't linked to Connect, so views are hidden and
|
|
124
|
+
revoking is unavailable.
|
|
125
|
+
</p>
|
|
126
|
+
)}
|
|
127
|
+
{error && (
|
|
128
|
+
<p className="text-destructive text-sm">{(error as Error).message}</p>
|
|
129
|
+
)}
|
|
130
|
+
{isLoading ? (
|
|
131
|
+
<p className="text-muted-foreground text-sm">Loading…</p>
|
|
132
|
+
) : data?.shares.length ? (
|
|
133
|
+
<ul className="space-y-2">
|
|
134
|
+
{data.shares.map((s) => (
|
|
135
|
+
<Row key={s.id} share={s} />
|
|
136
|
+
))}
|
|
137
|
+
</ul>
|
|
138
|
+
) : (
|
|
139
|
+
<div className="text-muted-foreground flex items-center gap-2 rounded-lg border border-dashed px-3 py-6 text-sm">
|
|
140
|
+
<Link2 className="h-4 w-4 shrink-0" />
|
|
141
|
+
Nothing shared yet. Use Share… in a chat's menu, or the share
|
|
142
|
+
button on a page.
|
|
143
|
+
</div>
|
|
144
|
+
)}
|
|
145
|
+
</div>
|
|
146
|
+
);
|
|
147
|
+
}
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
|
2
|
+
import { shareKeys } from "./keys";
|
|
3
|
+
|
|
4
|
+
async function json<T>(res: Response): Promise<T> {
|
|
5
|
+
const data = await res.json().catch(() => ({}));
|
|
6
|
+
if (!res.ok) throw new Error(data.error || "Request failed");
|
|
7
|
+
return data;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export type ShareExpiry = "1d" | "7d" | "30d" | "never";
|
|
11
|
+
|
|
12
|
+
export interface ShareTarget {
|
|
13
|
+
kind: "chat" | "artifact";
|
|
14
|
+
sessionId?: string;
|
|
15
|
+
artifactId?: string;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface PreviewRequest extends ShareTarget {
|
|
19
|
+
includeTools: boolean;
|
|
20
|
+
/** Messages left out of the share. */
|
|
21
|
+
dropIds?: string[];
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface PreviewItem {
|
|
25
|
+
id: string;
|
|
26
|
+
type: string;
|
|
27
|
+
tools: boolean;
|
|
28
|
+
excerpt: string;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export interface SharePreview {
|
|
32
|
+
html: string;
|
|
33
|
+
title: string;
|
|
34
|
+
bytes: number;
|
|
35
|
+
hash: string;
|
|
36
|
+
sharedAt: string;
|
|
37
|
+
items: PreviewItem[];
|
|
38
|
+
hits: Record<"secret" | "path" | "ip" | "host" | "email", number>;
|
|
39
|
+
tooBig: boolean;
|
|
40
|
+
connectLinked: boolean;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const post = (url: string, body: unknown) =>
|
|
44
|
+
fetch(url, {
|
|
45
|
+
method: "POST",
|
|
46
|
+
headers: { "Content-Type": "application/json" },
|
|
47
|
+
body: JSON.stringify(body),
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
/** What would be uploaded, redacted. Local only. */
|
|
51
|
+
export function useSharePreview() {
|
|
52
|
+
return useMutation({
|
|
53
|
+
mutationFn: async (req: PreviewRequest) =>
|
|
54
|
+
json<SharePreview>(await post("/api/shares/preview", req)),
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function useCreateShare() {
|
|
59
|
+
const queryClient = useQueryClient();
|
|
60
|
+
return useMutation({
|
|
61
|
+
mutationFn: async (
|
|
62
|
+
req: PreviewRequest & {
|
|
63
|
+
sharedAt: string;
|
|
64
|
+
hash: string;
|
|
65
|
+
expiresIn: ShareExpiry;
|
|
66
|
+
}
|
|
67
|
+
) =>
|
|
68
|
+
json<{ id: string; url: string; expiresAt: string | null }>(
|
|
69
|
+
await post("/api/shares", req)
|
|
70
|
+
),
|
|
71
|
+
onSuccess: () => queryClient.invalidateQueries({ queryKey: shareKeys.all }),
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export interface SharedLinkRow {
|
|
76
|
+
id: string;
|
|
77
|
+
url: string;
|
|
78
|
+
kind: "chat" | "artifact";
|
|
79
|
+
title: string;
|
|
80
|
+
sessionId: string | null;
|
|
81
|
+
artifactId: string | null;
|
|
82
|
+
createdAt: string;
|
|
83
|
+
expiresAt: string | null;
|
|
84
|
+
status: "active" | "expired" | "revoked";
|
|
85
|
+
views: number | null;
|
|
86
|
+
lastViewedAt: string | null;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export interface SharesResponse {
|
|
90
|
+
connect: "ok" | "unavailable" | "unlinked";
|
|
91
|
+
shares: SharedLinkRow[];
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export function useSharesQuery(enabled = true) {
|
|
95
|
+
return useQuery({
|
|
96
|
+
queryKey: shareKeys.list(),
|
|
97
|
+
queryFn: async () => json<SharesResponse>(await fetch("/api/shares")),
|
|
98
|
+
enabled,
|
|
99
|
+
});
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export function useRevokeShare() {
|
|
103
|
+
const queryClient = useQueryClient();
|
|
104
|
+
return useMutation({
|
|
105
|
+
mutationFn: async (id: string) =>
|
|
106
|
+
json<{ ok: true }>(
|
|
107
|
+
await fetch(`/api/shares/${id}`, { method: "DELETE" })
|
|
108
|
+
),
|
|
109
|
+
onSuccess: () => queryClient.invalidateQueries({ queryKey: shareKeys.all }),
|
|
110
|
+
});
|
|
111
|
+
}
|
|
@@ -19,7 +19,11 @@ import { toChatContext, usageTotals } from "../context";
|
|
|
19
19
|
import { cacheTtlMs } from "../stale";
|
|
20
20
|
import { ClaudeMapper, toCommand, type ClaudeMessage } from "./claude-mapper";
|
|
21
21
|
import { SuggestionTrace } from "./suggestion-trace";
|
|
22
|
-
import {
|
|
22
|
+
import {
|
|
23
|
+
redact,
|
|
24
|
+
SECRET_PARAM,
|
|
25
|
+
URL_USERINFO,
|
|
26
|
+
} from "../../orchestrator/untrusted";
|
|
23
27
|
import { NO_ATTRIBUTION } from "../../agents/attribution";
|
|
24
28
|
import { callFromAssistant } from "../../usage/calls";
|
|
25
29
|
|
|
@@ -52,9 +56,6 @@ const firstLine = (text?: string) => {
|
|
|
52
56
|
|
|
53
57
|
// A server's error text can quote its URL, and with it a key: shown and
|
|
54
58
|
// stored redacted, on one line, and short.
|
|
55
|
-
const URL_USERINFO = /\b([a-z][a-z0-9+.-]*:\/\/)[^@\s/]+@/gi;
|
|
56
|
-
const SECRET_PARAM =
|
|
57
|
-
/([?&][\w.-]*(?:key|token|secret|password|auth|sig|code)[\w.-]*=)[^&\s#]+/gi;
|
|
58
59
|
function cleanError(text?: string): string | undefined {
|
|
59
60
|
if (!text) return undefined;
|
|
60
61
|
const line = redact(
|
|
@@ -264,3 +264,87 @@ export async function pushRelay(
|
|
|
264
264
|
throw new Error("the Connect service sent no push results");
|
|
265
265
|
return r.results;
|
|
266
266
|
}
|
|
267
|
+
|
|
268
|
+
export type ShareStatus = "active" | "expired" | "revoked";
|
|
269
|
+
|
|
270
|
+
/** A snapshot link as Connect reports it. The link itself is never in a list. */
|
|
271
|
+
export interface RemoteShare {
|
|
272
|
+
id: string;
|
|
273
|
+
kind: string;
|
|
274
|
+
title: string;
|
|
275
|
+
status: ShareStatus;
|
|
276
|
+
byte_size: number;
|
|
277
|
+
view_count: number;
|
|
278
|
+
last_viewed_at: string | null;
|
|
279
|
+
created_at: string;
|
|
280
|
+
expires_at: string | null;
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
export type ShareExpiry = "1d" | "7d" | "30d" | "never";
|
|
284
|
+
|
|
285
|
+
const shareFrom = (r: unknown): RemoteShare => {
|
|
286
|
+
const s = (r ?? {}) as Record<string, unknown>;
|
|
287
|
+
if (typeof s.id !== "string" || typeof s.status !== "string")
|
|
288
|
+
throw new Error("the Connect service sent an unusable share");
|
|
289
|
+
return s as unknown as RemoteShare;
|
|
290
|
+
};
|
|
291
|
+
|
|
292
|
+
/** Uploads a rendered snapshot; the answer carries the public link, once. */
|
|
293
|
+
export async function uploadShare(
|
|
294
|
+
config: ConnectConfig,
|
|
295
|
+
privateKey: string,
|
|
296
|
+
share: {
|
|
297
|
+
kind: "chat" | "artifact";
|
|
298
|
+
title: string;
|
|
299
|
+
html: string;
|
|
300
|
+
expiresIn: ShareExpiry;
|
|
301
|
+
}
|
|
302
|
+
): Promise<RemoteShare & { url: string }> {
|
|
303
|
+
const res = await signed(config, privateKey)(
|
|
304
|
+
"POST",
|
|
305
|
+
`/api/v1/machines/${config.machineId}/shares`,
|
|
306
|
+
{
|
|
307
|
+
kind: share.kind,
|
|
308
|
+
title: share.title,
|
|
309
|
+
html: share.html,
|
|
310
|
+
expires_in: share.expiresIn,
|
|
311
|
+
}
|
|
312
|
+
);
|
|
313
|
+
const r = (await res.json().catch(() => null)) as Record<
|
|
314
|
+
string,
|
|
315
|
+
unknown
|
|
316
|
+
> | null;
|
|
317
|
+
const url = r?.url;
|
|
318
|
+
if (typeof url !== "string" || !/^https?:\/\//.test(url))
|
|
319
|
+
throw new Error("the Connect service sent no link");
|
|
320
|
+
return { ...shareFrom(r), url };
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
export async function listShares(
|
|
324
|
+
config: ConnectConfig,
|
|
325
|
+
privateKey: string
|
|
326
|
+
): Promise<RemoteShare[]> {
|
|
327
|
+
const res = await signed(config, privateKey)(
|
|
328
|
+
"GET",
|
|
329
|
+
`/api/v1/machines/${config.machineId}/shares`
|
|
330
|
+
);
|
|
331
|
+
const r = (await res.json().catch(() => null)) as {
|
|
332
|
+
shares?: unknown[];
|
|
333
|
+
} | null;
|
|
334
|
+
if (!Array.isArray(r?.shares))
|
|
335
|
+
throw new Error("the Connect service sent no share list");
|
|
336
|
+
return r.shares.map(shareFrom);
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
export async function revokeShare(
|
|
340
|
+
config: ConnectConfig,
|
|
341
|
+
privateKey: string,
|
|
342
|
+
id: string
|
|
343
|
+
): Promise<RemoteShare> {
|
|
344
|
+
if (!/^[a-z0-9]{12}$/.test(id)) throw new Error("not a share id");
|
|
345
|
+
const res = await signed(config, privateKey)(
|
|
346
|
+
"DELETE",
|
|
347
|
+
`/api/v1/machines/${config.machineId}/shares/${id}`
|
|
348
|
+
);
|
|
349
|
+
return shareFrom(await res.json().catch(() => null));
|
|
350
|
+
}
|
package/lib/db/migrations.ts
CHANGED
|
@@ -2087,6 +2087,56 @@ const migrations: Migration[] = [
|
|
|
2087
2087
|
`);
|
|
2088
2088
|
},
|
|
2089
2089
|
},
|
|
2090
|
+
{
|
|
2091
|
+
id: 87,
|
|
2092
|
+
name: "plan_pieces_reconcile",
|
|
2093
|
+
up: (db) => {
|
|
2094
|
+
// How many pieces a plan listed and what the checker folded, so a plan
|
|
2095
|
+
// that builds as fewer than it listed is never silent; the PRs agents
|
|
2096
|
+
// opened (from their `gh pr create` output) so one outside its task's
|
|
2097
|
+
// repo is noticed.
|
|
2098
|
+
const columns = db.prepare(`PRAGMA table_info(features)`).all() as {
|
|
2099
|
+
name: string;
|
|
2100
|
+
}[];
|
|
2101
|
+
if (!columns.some((c) => c.name === "listed"))
|
|
2102
|
+
db.exec(`ALTER TABLE features ADD COLUMN listed INTEGER`);
|
|
2103
|
+
if (!columns.some((c) => c.name === "folded"))
|
|
2104
|
+
db.exec(`ALTER TABLE features ADD COLUMN folded TEXT`);
|
|
2105
|
+
db.exec(`
|
|
2106
|
+
CREATE TABLE IF NOT EXISTS agent_prs (
|
|
2107
|
+
url TEXT PRIMARY KEY,
|
|
2108
|
+
session_id TEXT NOT NULL,
|
|
2109
|
+
repo TEXT NOT NULL,
|
|
2110
|
+
number INTEGER NOT NULL,
|
|
2111
|
+
seen_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
2112
|
+
told_at TEXT
|
|
2113
|
+
);
|
|
2114
|
+
`);
|
|
2115
|
+
},
|
|
2116
|
+
},
|
|
2117
|
+
{
|
|
2118
|
+
id: 88,
|
|
2119
|
+
name: "shared_links",
|
|
2120
|
+
up: (db) => {
|
|
2121
|
+
// Snapshot links made through Connect. Connect keeps only a digest of
|
|
2122
|
+
// the token, so this is the one place the link can be copied again.
|
|
2123
|
+
db.exec(`
|
|
2124
|
+
CREATE TABLE IF NOT EXISTS shared_links (
|
|
2125
|
+
id TEXT PRIMARY KEY,
|
|
2126
|
+
url TEXT NOT NULL,
|
|
2127
|
+
kind TEXT NOT NULL,
|
|
2128
|
+
title TEXT NOT NULL,
|
|
2129
|
+
session_id TEXT,
|
|
2130
|
+
artifact_id TEXT,
|
|
2131
|
+
expires_at TEXT,
|
|
2132
|
+
created_at TEXT NOT NULL,
|
|
2133
|
+
revoked_at TEXT
|
|
2134
|
+
);
|
|
2135
|
+
CREATE INDEX IF NOT EXISTS idx_shared_links_created
|
|
2136
|
+
ON shared_links(created_at DESC);
|
|
2137
|
+
`);
|
|
2138
|
+
},
|
|
2139
|
+
},
|
|
2090
2140
|
];
|
|
2091
2141
|
|
|
2092
2142
|
// `upTo`: stop after this id (tests that start from an older database).
|
package/lib/db/queries.ts
CHANGED
|
@@ -56,15 +56,16 @@ export const queries = {
|
|
|
56
56
|
`SELECT ${sessionListColumns(db)} FROM sessions ORDER BY updated_at DESC`
|
|
57
57
|
),
|
|
58
58
|
|
|
59
|
-
// The sidebar's sessions: not archived, and not a task
|
|
60
|
-
// lives on another machine.
|
|
59
|
+
// The sidebar's sessions: not archived, and not a dropped task (a
|
|
60
|
+
// rejection, not finished work) or one that lives on another machine.
|
|
61
|
+
// Merged tasks stay: they are the Done shelf.
|
|
61
62
|
getSidebarSessions: (db: Database.Database) =>
|
|
62
63
|
getStmt(
|
|
63
64
|
db,
|
|
64
65
|
`SELECT ${sessionListColumns(db)} FROM sessions
|
|
65
66
|
WHERE archived_at IS NULL
|
|
66
67
|
AND (task_status IS NULL
|
|
67
|
-
OR task_status NOT IN ('
|
|
68
|
+
OR task_status NOT IN ('dropped', 'moved'))
|
|
68
69
|
ORDER BY updated_at DESC`
|
|
69
70
|
),
|
|
70
71
|
|
package/lib/models/plan-step.ts
CHANGED
|
@@ -22,8 +22,14 @@ import { queueEvent } from "../orchestrator/events";
|
|
|
22
22
|
import { noApprovalsOn, recordAutoApproval } from "../approvals/no-approvals";
|
|
23
23
|
import { addNote } from "../orchestrator/notes";
|
|
24
24
|
import { amendBrief, amendmentsOf } from "../orchestrator/amendments";
|
|
25
|
-
import { PIECES_PROMPT, pieceBrief } from "../plans/pieces";
|
|
26
|
-
import {
|
|
25
|
+
import { PIECES_PROMPT, foldedBrief, pieceBrief } from "../plans/pieces";
|
|
26
|
+
import {
|
|
27
|
+
createFeature,
|
|
28
|
+
ownPieceOf,
|
|
29
|
+
planFor,
|
|
30
|
+
planPathOf,
|
|
31
|
+
splitOf,
|
|
32
|
+
} from "../plans/features";
|
|
27
33
|
|
|
28
34
|
export const PLAN_FILE = ".agentos/plan.md";
|
|
29
35
|
|
|
@@ -52,10 +58,11 @@ export function implementPrompt(
|
|
|
52
58
|
amendment?: string,
|
|
53
59
|
piece?: string
|
|
54
60
|
): string {
|
|
61
|
+
const where = ` Work only in this task's repository; if part of the work belongs in another repository, say so in your final message instead of doing it there.`;
|
|
55
62
|
const base = piece
|
|
56
|
-
? `The plan is in \`${PLAN_FILE}\`. ${piece}\nFinish it as the task's instructions above say. Where the plan turns out wrong, do what's right and say so
|
|
63
|
+
? `The plan is in \`${PLAN_FILE}\`. ${piece}\nFinish it as the task's instructions above say. Where the plan turns out wrong, do what's right and say so.${where}`
|
|
57
64
|
: planned
|
|
58
|
-
? `The plan is in \`${PLAN_FILE}\`. Implement it now, finishing the task as its instructions above say. Where the plan turns out wrong, do what's right and say so
|
|
65
|
+
? `The plan is in \`${PLAN_FILE}\`. Implement it now, finishing the task as its instructions above say. Where the plan turns out wrong, do what's right and say so.${where}`
|
|
59
66
|
: `No plan was written to \`${PLAN_FILE}\`. Plan briefly, then implement the task, finishing it as its instructions above say.`;
|
|
60
67
|
return amendment
|
|
61
68
|
? `${base}\n\nThe plan was approved with this change, which is now part of the task's scope:\n${amendment}`
|
|
@@ -104,7 +111,7 @@ function piecesLine(r: NonNullable<ReturnType<typeof planFor>>): string {
|
|
|
104
111
|
? ` after ${p.afterPosition} is ${p.afterKind}`
|
|
105
112
|
: "";
|
|
106
113
|
const why = p.reasons.length ? ` (${p.reasons.join("+")})` : "";
|
|
107
|
-
return `${p.position} ${p.name}${after}${why}`;
|
|
114
|
+
return `${p.position} ${p.name} [${p.project.name}]${after}${why}`;
|
|
108
115
|
});
|
|
109
116
|
const folded = r.folded.length ? `, ${r.folded.length} folded` : "";
|
|
110
117
|
return `${r.pieces.length} pieces${folded}: ${parts.join("; ")}`;
|
|
@@ -130,6 +137,8 @@ function announcePlan(s: Session, ref: string | null, retried = false): void {
|
|
|
130
137
|
if (summary.length) head += `\n${summary.map((l) => `- ${l}`).join("\n")}`;
|
|
131
138
|
const pieces = planFor(s, fs.readFileSync(file, "utf8"));
|
|
132
139
|
if (pieces && pieces.pieces.length > 1) head += `\n${piecesLine(pieces)}`;
|
|
140
|
+
if (pieces?.folded.length)
|
|
141
|
+
head += `\nPlan listed ${pieces.listed} pieces, building ${pieces.pieces.length}: ${pieces.folded.map((f) => `${f.name} folded (${f.why})`).join("; ")}`;
|
|
133
142
|
} catch {
|
|
134
143
|
// No plan file: the line says so.
|
|
135
144
|
}
|
|
@@ -143,6 +152,46 @@ function announcePlan(s: Session, ref: string | null, retried = false): void {
|
|
|
143
152
|
queueEvent(workspaceId!, `plan-done:${s.id}`, s.id, head);
|
|
144
153
|
}
|
|
145
154
|
|
|
155
|
+
// Plans that build as other than one piece in this repository say so, once.
|
|
156
|
+
function sayHowItSplit(
|
|
157
|
+
s: Session,
|
|
158
|
+
workspaceId: string,
|
|
159
|
+
split: NonNullable<ReturnType<typeof splitOf>>,
|
|
160
|
+
nothingOwn: boolean
|
|
161
|
+
): void {
|
|
162
|
+
if (nothingOwn)
|
|
163
|
+
queueEvent(
|
|
164
|
+
workspaceId,
|
|
165
|
+
`plan-routed:${s.id}`,
|
|
166
|
+
s.id,
|
|
167
|
+
`Task "${s.name}" split its plan into ${split.pieces.length} ${split.pieces.length === 1 ? "piece" : "pieces"} and none is first in its own repository, so it builds nothing itself and ends; each piece starts as its own task: ${split.pieces.map((p) => `${p.position} ${p.name} [${p.project.name}]`).join("; ")}.`
|
|
168
|
+
);
|
|
169
|
+
else if (split.folded.length)
|
|
170
|
+
queueEvent(
|
|
171
|
+
workspaceId,
|
|
172
|
+
`plan-folded:${s.id}`,
|
|
173
|
+
s.id,
|
|
174
|
+
`Plan of "${s.name}" listed ${split.listed} pieces, building ${split.pieces.length}: ${split.folded.map((f) => `${f.name} folded into ${f.into} (${f.why})`).join("; ")}. The task was told to build the folded work too.`
|
|
175
|
+
);
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
// A planning task with no piece of its own is finished: its pieces are
|
|
179
|
+
// started as tasks in their projects, now rather than on the next sweep.
|
|
180
|
+
async function handOffToPieces(s: Session): Promise<void> {
|
|
181
|
+
try {
|
|
182
|
+
const { doneSession } = await import("../done");
|
|
183
|
+
await doneSession(s.id, { by: "direct", noMerge: true });
|
|
184
|
+
} catch (e) {
|
|
185
|
+
console.error(`Ending planning task ${s.id} failed:`, e);
|
|
186
|
+
}
|
|
187
|
+
try {
|
|
188
|
+
const { releasePieces } = await import("../plans/release");
|
|
189
|
+
await releasePieces();
|
|
190
|
+
} catch (e) {
|
|
191
|
+
console.error("Starting the pieces failed:", e);
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
146
195
|
function readPlan(s: Session): string | null {
|
|
147
196
|
try {
|
|
148
197
|
return fs.readFileSync(path.join(s.worktree_path!, PLAN_FILE), "utf8");
|
|
@@ -180,8 +229,14 @@ async function startImplementing(
|
|
|
180
229
|
});
|
|
181
230
|
const id = implementMessageId(sessionId);
|
|
182
231
|
const planText = readPlan(s);
|
|
183
|
-
|
|
232
|
+
let split = planText ? splitOf(s, planText) : null;
|
|
184
233
|
const workspaceId = sessionWorkspace(s);
|
|
234
|
+
// Approved despite pieces that couldn't be registered: built as one piece,
|
|
235
|
+
// and told so, rather than guessing which repository each belongs to.
|
|
236
|
+
const unreadable = split?.errors.length ? split.errors : null;
|
|
237
|
+
if (unreadable) split = null;
|
|
238
|
+
let nothingOwn = false;
|
|
239
|
+
let featureId: string | null = null;
|
|
185
240
|
const claimed = db.transaction(() => {
|
|
186
241
|
const changed = db
|
|
187
242
|
.prepare(
|
|
@@ -193,18 +248,38 @@ async function startImplementing(
|
|
|
193
248
|
setSessionModel(sessionId, routed);
|
|
194
249
|
let piece: string | undefined;
|
|
195
250
|
let note = amendment;
|
|
251
|
+
if (unreadable)
|
|
252
|
+
note = [
|
|
253
|
+
amendment,
|
|
254
|
+
`The plan's pieces couldn't be registered (${unreadable.join("; ")}); build the whole plan as one piece in this repository.`,
|
|
255
|
+
]
|
|
256
|
+
.filter(Boolean)
|
|
257
|
+
.join("\n");
|
|
196
258
|
if (split?.notBuilding.length)
|
|
197
259
|
note = [amendment, `Not building: ${split.notBuilding.join("; ")}`]
|
|
198
260
|
.filter(Boolean)
|
|
199
261
|
.join("\n");
|
|
200
|
-
if (split &&
|
|
201
|
-
const feature = createFeature(s, planText, split.pieces, workspaceId
|
|
202
|
-
|
|
203
|
-
split.
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
);
|
|
262
|
+
if (split?.feature && planText && workspaceId) {
|
|
263
|
+
const feature = createFeature(s, planText, split.pieces, workspaceId, {
|
|
264
|
+
listed: split.listed,
|
|
265
|
+
folded: split.folded,
|
|
266
|
+
});
|
|
267
|
+
featureId = feature.id;
|
|
268
|
+
const own = ownPieceOf(split.pieces, s.project_id);
|
|
269
|
+
if (!own) nothingOwn = true;
|
|
270
|
+
else if (split.pieces.length === 1 && split.folded.length)
|
|
271
|
+
piece = foldedBrief(split);
|
|
272
|
+
else
|
|
273
|
+
piece = pieceBrief(
|
|
274
|
+
split.pieces.length,
|
|
275
|
+
own,
|
|
276
|
+
planPathOf(feature.id),
|
|
277
|
+
[],
|
|
278
|
+
own.project.name
|
|
279
|
+
);
|
|
207
280
|
}
|
|
281
|
+
// Every piece is another task's: nothing for this one to implement.
|
|
282
|
+
if (nothingOwn) return true;
|
|
208
283
|
enqueue(sessionId, {
|
|
209
284
|
id,
|
|
210
285
|
text: implementPrompt(
|
|
@@ -224,6 +299,12 @@ async function startImplementing(
|
|
|
224
299
|
console.error("plan-done event failed:", e);
|
|
225
300
|
}
|
|
226
301
|
}
|
|
302
|
+
if (split?.feature && featureId && workspaceId)
|
|
303
|
+
sayHowItSplit(s, workspaceId, split, nothingOwn);
|
|
304
|
+
if (nothingOwn) {
|
|
305
|
+
await handOffToPieces(s);
|
|
306
|
+
return routed.ref;
|
|
307
|
+
}
|
|
227
308
|
await applySessionModel(sessionId, s.model_provider ?? ANTHROPIC, routed);
|
|
228
309
|
await send(sessionId, id);
|
|
229
310
|
return routed.ref;
|
|
@@ -342,9 +423,12 @@ export async function handoffAfterPlan(
|
|
|
342
423
|
deleteQueued(sessionId, planRetryMessageId(sessionId));
|
|
343
424
|
const retried = waiting.get(sessionId)?.retried ?? false;
|
|
344
425
|
forget(sessionId);
|
|
345
|
-
// No approvals mode doesn't stop at the plan to wait for one.
|
|
426
|
+
// No approvals mode doesn't stop at the plan to wait for one. Pieces that
|
|
427
|
+
// couldn't be registered always do: building on a guess is the bug.
|
|
346
428
|
const auto = !!s.plan_only && noApprovalsOn();
|
|
347
|
-
|
|
429
|
+
const planText = readPlan(s);
|
|
430
|
+
const pieceErrors = (planText ? splitOf(s, planText)?.errors : null) ?? [];
|
|
431
|
+
if ((!s.plan_only || auto) && !pieceErrors.length) {
|
|
348
432
|
const ref = await startImplementing(
|
|
349
433
|
s,
|
|
350
434
|
"planning",
|
|
@@ -374,6 +458,14 @@ export async function handoffAfterPlan(
|
|
|
374
458
|
} catch (e) {
|
|
375
459
|
console.error("plan-done event failed:", e);
|
|
376
460
|
}
|
|
461
|
+
const workspaceId = sessionWorkspace(s);
|
|
462
|
+
if (pieceErrors.length && workspaceId)
|
|
463
|
+
queueEvent(
|
|
464
|
+
workspaceId,
|
|
465
|
+
`plan-pieces:${s.id}`,
|
|
466
|
+
s.id,
|
|
467
|
+
`Plan of "${s.name}": couldn't register its pieces: ${pieceErrors.join("; ")}. It is held, not building. Message the task to fix the plan, or approve_plan to build it as one piece.`
|
|
468
|
+
);
|
|
377
469
|
try {
|
|
378
470
|
const text = readPlan(s);
|
|
379
471
|
const planned = text ? planFor(s, text) : null;
|