@saadnvd1/agent-os 0.3.606 → 0.3.609
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/app/api/busy/route.ts +2 -1
- package/app/api/updates/route.test.ts +3 -1
- package/components/Chat/ContextMeter.tsx +2 -2
- package/components/Chat/composer/useCoarsePointer.ts +2 -14
- package/components/PrStageBadge.tsx +4 -4
- package/components/SessionList/LiveLine.tsx +0 -4
- package/components/SessionList/OrchestratorPinRow.tsx +3 -3
- package/components/SessionList/RowMenuParts.tsx +90 -0
- package/components/SessionList/RowShell.tsx +6 -2
- package/components/SessionList/SessionRowMenu.tsx +241 -377
- package/components/Stacks/StatusChip.tsx +9 -33
- package/components/Stacks/status-chip.test.ts +40 -0
- package/components/Stacks/status-chip.ts +44 -0
- package/components/ui/context-menu.tsx +4 -2
- package/components/ui/dropdown-menu.tsx +5 -2
- package/components/ui/menu-fit.test.ts +36 -0
- package/hooks/useCompactMenu.ts +9 -0
- package/hooks/useMediaQuery.ts +20 -0
- package/lib/busy.ts +9 -3
- package/lib/hosts/remote-menu.ts +4 -0
- package/lib/recipes/status-labels.ts +2 -2
- package/lib/resurrect/index.ts +2 -1
- package/lib/sidebar/live-status.ts +7 -14
- package/lib/sidebar/row-menu.ts +168 -0
- package/lib/simulator/index.ts +6 -1
- package/lib/tasks/brief.ts +16 -1
- package/lib/tasks/import.ts +6 -2
- package/lib/tasks/index.ts +2 -1
- package/lib/tasks/move.ts +2 -1
- package/package.json +1 -1
- package/scripts/autodeploy +28 -0
- package/scripts/autodeploy.test.ts +106 -0
- package/scripts/wait-quiet +1 -1
|
@@ -1,52 +1,28 @@
|
|
|
1
|
-
import
|
|
1
|
+
import { GitPullRequest, Loader2 } from "lucide-react";
|
|
2
2
|
import { cn } from "@/lib/utils";
|
|
3
|
-
|
|
4
|
-
type Status = StackItemView["status"] | StackStatus;
|
|
5
|
-
|
|
6
|
-
const amber = "bg-amber-500/15 text-amber-700 dark:text-amber-300";
|
|
7
|
-
const green = "bg-emerald-500/15 text-emerald-700 dark:text-emerald-300";
|
|
8
|
-
const muted = "bg-foreground/[0.06] text-muted-foreground";
|
|
9
|
-
const purple = "bg-primary/15 text-primary";
|
|
10
|
-
const red = "bg-destructive/15 text-destructive";
|
|
11
|
-
|
|
12
|
-
const CHIP: Record<Status, { label: string; tone: string }> = {
|
|
13
|
-
planned: { label: "Planned", tone: muted },
|
|
14
|
-
held: { label: "Held", tone: amber },
|
|
15
|
-
starting: { label: "Starting", tone: purple },
|
|
16
|
-
running: { label: "Working", tone: purple },
|
|
17
|
-
pr: { label: "PR open", tone: purple },
|
|
18
|
-
merged: { label: "Merged", tone: green },
|
|
19
|
-
failed: { label: "Failed", tone: red },
|
|
20
|
-
dropped: { label: "Dropped", tone: muted },
|
|
21
|
-
done: { label: "Done", tone: green },
|
|
22
|
-
excluded: { label: "Left out", tone: muted },
|
|
23
|
-
paused: { label: "Paused", tone: amber },
|
|
24
|
-
landing: { label: "Landing", tone: purple },
|
|
25
|
-
landed: { label: "Landed", tone: green },
|
|
26
|
-
};
|
|
3
|
+
import { chipFor, type ChipStatus } from "./status-chip";
|
|
27
4
|
|
|
28
5
|
export function StatusChip({
|
|
29
6
|
status,
|
|
30
7
|
attention,
|
|
31
8
|
stack,
|
|
32
9
|
}: {
|
|
33
|
-
status:
|
|
10
|
+
status: ChipStatus;
|
|
34
11
|
attention?: boolean;
|
|
35
|
-
// A whole stack: "running" reads as Running, not Working.
|
|
36
12
|
stack?: boolean;
|
|
37
13
|
}) {
|
|
38
|
-
const chip = attention
|
|
39
|
-
? { label: "Needs you", tone: amber }
|
|
40
|
-
: stack && status === "running"
|
|
41
|
-
? { label: "Running", tone: purple }
|
|
42
|
-
: CHIP[status];
|
|
14
|
+
const chip = chipFor(status, { attention, stack });
|
|
43
15
|
return (
|
|
44
16
|
<span
|
|
45
17
|
className={cn(
|
|
46
|
-
"inline-flex shrink-0 items-center rounded-full px-2 py-0.5 text-[11px] font-medium",
|
|
18
|
+
"inline-flex shrink-0 items-center gap-1 rounded-full px-2 py-0.5 text-[11px] font-medium",
|
|
47
19
|
chip.tone
|
|
48
20
|
)}
|
|
49
21
|
>
|
|
22
|
+
{chip.icon === "spinner" && (
|
|
23
|
+
<Loader2 className="h-3 w-3 animate-spin" aria-hidden />
|
|
24
|
+
)}
|
|
25
|
+
{chip.icon === "pr" && <GitPullRequest className="h-3 w-3" aria-hidden />}
|
|
50
26
|
{chip.label}
|
|
51
27
|
</span>
|
|
52
28
|
);
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { CHIP, chipFor, type ChipStatus } from "./status-chip";
|
|
3
|
+
|
|
4
|
+
const coloured = (tone: string) => /amber|emerald|destructive/.test(tone);
|
|
5
|
+
|
|
6
|
+
describe("status chip colour", () => {
|
|
7
|
+
it.each(["starting", "running", "pr", "landing"] as ChipStatus[])(
|
|
8
|
+
"%s is neutral with an icon",
|
|
9
|
+
(s) => {
|
|
10
|
+
expect(coloured(CHIP[s].tone)).toBe(false);
|
|
11
|
+
expect(CHIP[s].tone).not.toContain("primary");
|
|
12
|
+
expect(CHIP[s].icon).not.toBeNull();
|
|
13
|
+
}
|
|
14
|
+
);
|
|
15
|
+
|
|
16
|
+
it.each(["merged", "done", "landed"] as ChipStatus[])("%s is green", (s) =>
|
|
17
|
+
expect(CHIP[s].tone).toContain("emerald")
|
|
18
|
+
);
|
|
19
|
+
it("failed is red", () => expect(CHIP.failed.tone).toContain("destructive"));
|
|
20
|
+
it.each(["held", "paused"] as ChipStatus[])("%s is amber", (s) =>
|
|
21
|
+
expect(CHIP[s].tone).toContain("amber")
|
|
22
|
+
);
|
|
23
|
+
|
|
24
|
+
it("needs-you overrides status with amber", () => {
|
|
25
|
+
const c = chipFor("running", { attention: true });
|
|
26
|
+
expect(c.label).toBe("Needs you");
|
|
27
|
+
expect(c.tone).toContain("amber");
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it("a running stack reads Running, neutral", () => {
|
|
31
|
+
const c = chipFor("running", { stack: true });
|
|
32
|
+
expect(c.label).toBe("Running");
|
|
33
|
+
expect(coloured(c.tone)).toBe(false);
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it("no status uses the accent", () => {
|
|
37
|
+
for (const c of Object.values(CHIP))
|
|
38
|
+
expect(c.tone).not.toContain("primary");
|
|
39
|
+
});
|
|
40
|
+
});
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import type { StackItemView, StackStatus } from "@/lib/stacks/types";
|
|
2
|
+
|
|
3
|
+
export type ChipStatus = StackItemView["status"] | StackStatus;
|
|
4
|
+
export type ChipIcon = "spinner" | "pr" | null;
|
|
5
|
+
|
|
6
|
+
// Colour means "look at this": amber needs you, red failed, green final.
|
|
7
|
+
// Everything still moving on its own is neutral, and an icon carries the meaning.
|
|
8
|
+
const amber = "bg-amber-500/15 text-amber-700 dark:text-amber-300";
|
|
9
|
+
const green = "bg-emerald-500/15 text-emerald-700 dark:text-emerald-300";
|
|
10
|
+
const muted = "bg-foreground/[0.06] text-muted-foreground";
|
|
11
|
+
const red = "bg-destructive/15 text-destructive";
|
|
12
|
+
|
|
13
|
+
interface Chip {
|
|
14
|
+
label: string;
|
|
15
|
+
tone: string;
|
|
16
|
+
icon: ChipIcon;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export const CHIP: Record<ChipStatus, Chip> = {
|
|
20
|
+
planned: { label: "Planned", tone: muted, icon: null },
|
|
21
|
+
held: { label: "Held", tone: amber, icon: null },
|
|
22
|
+
starting: { label: "Starting", tone: muted, icon: "spinner" },
|
|
23
|
+
running: { label: "Working", tone: muted, icon: "spinner" },
|
|
24
|
+
pr: { label: "PR open", tone: muted, icon: "pr" },
|
|
25
|
+
merged: { label: "Merged", tone: green, icon: null },
|
|
26
|
+
failed: { label: "Failed", tone: red, icon: null },
|
|
27
|
+
dropped: { label: "Dropped", tone: muted, icon: null },
|
|
28
|
+
done: { label: "Done", tone: green, icon: null },
|
|
29
|
+
excluded: { label: "Left out", tone: muted, icon: null },
|
|
30
|
+
paused: { label: "Paused", tone: amber, icon: null },
|
|
31
|
+
landing: { label: "Landing", tone: muted, icon: "spinner" },
|
|
32
|
+
landed: { label: "Landed", tone: green, icon: null },
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
export function chipFor(
|
|
36
|
+
status: ChipStatus,
|
|
37
|
+
opts: { attention?: boolean; stack?: boolean } = {}
|
|
38
|
+
): Chip {
|
|
39
|
+
if (opts.attention) return { label: "Needs you", tone: amber, icon: null };
|
|
40
|
+
// A whole stack: "running" reads as Running, not Working.
|
|
41
|
+
if (opts.stack && status === "running")
|
|
42
|
+
return { label: "Running", tone: muted, icon: "spinner" };
|
|
43
|
+
return CHIP[status];
|
|
44
|
+
}
|
|
@@ -45,8 +45,9 @@ const ContextMenuSubContent = React.forwardRef<
|
|
|
45
45
|
>(({ className, ...props }, ref) => (
|
|
46
46
|
<ContextMenuPrimitive.SubContent
|
|
47
47
|
ref={ref}
|
|
48
|
+
collisionPadding={8}
|
|
48
49
|
className={cn(
|
|
49
|
-
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 popover-surface text-popover-foreground z-50 min-w-[8rem] overflow-hidden rounded-xl p-1.5",
|
|
50
|
+
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 popover-surface text-popover-foreground z-50 max-h-(--radix-context-menu-content-available-height) min-w-[8rem] overflow-x-hidden overflow-y-auto overscroll-contain rounded-xl p-1.5",
|
|
50
51
|
className
|
|
51
52
|
)}
|
|
52
53
|
{...props}
|
|
@@ -61,8 +62,9 @@ const ContextMenuContent = React.forwardRef<
|
|
|
61
62
|
<ContextMenuPrimitive.Portal>
|
|
62
63
|
<ContextMenuPrimitive.Content
|
|
63
64
|
ref={ref}
|
|
65
|
+
collisionPadding={8}
|
|
64
66
|
className={cn(
|
|
65
|
-
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 popover-surface text-popover-foreground z-50 min-w-[8rem] overflow-hidden rounded-xl p-1.5",
|
|
67
|
+
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 popover-surface text-popover-foreground z-50 max-h-(--radix-context-menu-content-available-height) min-w-[8rem] overflow-x-hidden overflow-y-auto overscroll-contain rounded-xl p-1.5",
|
|
66
68
|
className
|
|
67
69
|
)}
|
|
68
70
|
{...props}
|
|
@@ -34,6 +34,7 @@ function DropdownMenuTrigger({
|
|
|
34
34
|
function DropdownMenuContent({
|
|
35
35
|
className,
|
|
36
36
|
sideOffset = 4,
|
|
37
|
+
collisionPadding = 8,
|
|
37
38
|
...props
|
|
38
39
|
}: React.ComponentProps<typeof DropdownMenuPrimitive.Content>) {
|
|
39
40
|
return (
|
|
@@ -41,8 +42,9 @@ function DropdownMenuContent({
|
|
|
41
42
|
<DropdownMenuPrimitive.Content
|
|
42
43
|
data-slot="dropdown-menu-content"
|
|
43
44
|
sideOffset={sideOffset}
|
|
45
|
+
collisionPadding={collisionPadding}
|
|
44
46
|
className={cn(
|
|
45
|
-
"popover-surface text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 max-h-(--radix-dropdown-menu-content-available-height) min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-xl p-1.5",
|
|
47
|
+
"popover-surface text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 max-h-(--radix-dropdown-menu-content-available-height) min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-x-hidden overflow-y-auto overscroll-contain rounded-xl p-1.5",
|
|
46
48
|
className
|
|
47
49
|
)}
|
|
48
50
|
{...props}
|
|
@@ -229,8 +231,9 @@ function DropdownMenuSubContent({
|
|
|
229
231
|
return (
|
|
230
232
|
<DropdownMenuPrimitive.SubContent
|
|
231
233
|
data-slot="dropdown-menu-sub-content"
|
|
234
|
+
collisionPadding={8}
|
|
232
235
|
className={cn(
|
|
233
|
-
"popover-surface text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-hidden rounded-xl p-1.5",
|
|
236
|
+
"popover-surface text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 max-h-(--radix-dropdown-menu-content-available-height) min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-x-hidden overflow-y-auto overscroll-contain rounded-xl p-1.5",
|
|
234
237
|
className
|
|
235
238
|
)}
|
|
236
239
|
{...props}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { readFileSync } from "node:fs";
|
|
2
|
+
import { describe, expect, it } from "vitest";
|
|
3
|
+
|
|
4
|
+
// Where a menu opens (flip, shift, the 8px gap) is floating-ui's job inside
|
|
5
|
+
// Radix, and Radix can't mount in the suite's fake DOM. What is ours is that
|
|
6
|
+
// every menu surface hands its height to the room left on screen and scrolls
|
|
7
|
+
// past it instead of clipping, so that is what this pins. The geometry itself
|
|
8
|
+
// is checked in a browser.
|
|
9
|
+
const surfaces = (file: string) =>
|
|
10
|
+
readFileSync(new URL(file, import.meta.url), "utf8")
|
|
11
|
+
.split("\n")
|
|
12
|
+
.filter((l) => l.includes("popover-surface"));
|
|
13
|
+
|
|
14
|
+
describe.each([
|
|
15
|
+
["./dropdown-menu.tsx", "dropdown-menu"],
|
|
16
|
+
["./context-menu.tsx", "context-menu"],
|
|
17
|
+
])("%s", (file, name) => {
|
|
18
|
+
const lines = surfaces(file);
|
|
19
|
+
|
|
20
|
+
it("has a menu and a submenu surface", () => {
|
|
21
|
+
expect(lines).toHaveLength(2);
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
it.each([0, 1])("caps surface %i to the space available and scrolls", (i) => {
|
|
25
|
+
expect(lines[i]).toContain(
|
|
26
|
+
`max-h-(--radix-${name}-content-available-height)`
|
|
27
|
+
);
|
|
28
|
+
expect(lines[i]).toContain("overflow-y-auto");
|
|
29
|
+
expect(lines[i]).not.toContain("overflow-hidden");
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it("keeps menus off the window edge", () => {
|
|
33
|
+
const src = readFileSync(new URL(file, import.meta.url), "utf8");
|
|
34
|
+
expect(src.match(/collisionPadding/g)?.length).toBeGreaterThanOrEqual(2);
|
|
35
|
+
});
|
|
36
|
+
});
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useMediaQuery } from "./useMediaQuery";
|
|
4
|
+
|
|
5
|
+
// A phone or a touch screen: menus use one scrolling list with sections that
|
|
6
|
+
// open in place, not flyouts a finger can't hold open.
|
|
7
|
+
export function useCompactMenu(): boolean {
|
|
8
|
+
return useMediaQuery("(max-width: 767px), (pointer: coarse)");
|
|
9
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useSyncExternalStore } from "react";
|
|
4
|
+
|
|
5
|
+
const supported = () =>
|
|
6
|
+
typeof window !== "undefined" && typeof window.matchMedia === "function";
|
|
7
|
+
|
|
8
|
+
// Whether a CSS media query matches now, and stays in step as it changes.
|
|
9
|
+
export function useMediaQuery(query: string): boolean {
|
|
10
|
+
return useSyncExternalStore(
|
|
11
|
+
(listener) => {
|
|
12
|
+
if (!supported()) return () => {};
|
|
13
|
+
const mq = window.matchMedia(query);
|
|
14
|
+
mq.addEventListener("change", listener);
|
|
15
|
+
return () => mq.removeEventListener("change", listener);
|
|
16
|
+
},
|
|
17
|
+
() => supported() && window.matchMedia(query).matches,
|
|
18
|
+
() => false
|
|
19
|
+
);
|
|
20
|
+
}
|
package/lib/busy.ts
CHANGED
|
@@ -1,9 +1,14 @@
|
|
|
1
1
|
import { db } from "./db";
|
|
2
|
+
import { runningStarts } from "./simulator";
|
|
2
3
|
|
|
3
|
-
// What a restart would cut off and have to start over: reviews
|
|
4
|
-
// setups run in the server. (Chat turns run in their own workers and
|
|
4
|
+
// What a restart would cut off and have to start over: reviews, task
|
|
5
|
+
// setups and simulator starts run in the server. (Chat turns run in their own workers and
|
|
5
6
|
// outlive it.) Older than half an hour is stuck, not in flight.
|
|
6
|
-
export function inFlight(): {
|
|
7
|
+
export function inFlight(): {
|
|
8
|
+
reviews: number;
|
|
9
|
+
setups: number;
|
|
10
|
+
simulatorStarts: number;
|
|
11
|
+
} {
|
|
7
12
|
const count = (sql: string) => (db.prepare(sql).get() as { n: number }).n;
|
|
8
13
|
return {
|
|
9
14
|
reviews: count(
|
|
@@ -16,5 +21,6 @@ export function inFlight(): { reviews: number; setups: number } {
|
|
|
16
21
|
WHERE setup_status = 'running' AND archived_at IS NULL
|
|
17
22
|
AND created_at > datetime('now', '-30 minutes')`
|
|
18
23
|
),
|
|
24
|
+
simulatorStarts: runningStarts(),
|
|
19
25
|
};
|
|
20
26
|
}
|
package/lib/hosts/remote-menu.ts
CHANGED
|
@@ -13,6 +13,10 @@ export const REMOTE_UNSUPPORTED = {
|
|
|
13
13
|
schedule: (host: string) => `Check-ins can't message ${host} yet`,
|
|
14
14
|
};
|
|
15
15
|
|
|
16
|
+
// The one line that replaces the hidden items.
|
|
17
|
+
export const remoteNote = (host: string) =>
|
|
18
|
+
`Runs on ${host}: fork, fresh start, project moves and check-ins happen there`;
|
|
19
|
+
|
|
16
20
|
export type RemoteAction = keyof typeof REMOTE_UNSUPPORTED;
|
|
17
21
|
|
|
18
22
|
/**
|
|
@@ -8,8 +8,8 @@ export const RUN_STATUS_LABELS: Record<
|
|
|
8
8
|
{ label: string; tone: Tone }
|
|
9
9
|
> = {
|
|
10
10
|
queued: { label: "Queued", tone: "muted" },
|
|
11
|
-
starting: { label: "Starting", tone: "
|
|
12
|
-
running: { label: "Running", tone: "
|
|
11
|
+
starting: { label: "Starting", tone: "muted" },
|
|
12
|
+
running: { label: "Running", tone: "muted" },
|
|
13
13
|
succeeded: { label: "Succeeded", tone: "success" },
|
|
14
14
|
failed: { label: "Failed", tone: "destructive" },
|
|
15
15
|
blocked: { label: "Needs you", tone: "warning" },
|
package/lib/resurrect/index.ts
CHANGED
|
@@ -29,7 +29,7 @@ import {
|
|
|
29
29
|
transcriptPath,
|
|
30
30
|
writeTranscript,
|
|
31
31
|
} from "../tasks/transcript";
|
|
32
|
-
import { buildTaskBrief } from "../tasks/brief";
|
|
32
|
+
import { buildTaskBrief, declaredTest } from "../tasks/brief";
|
|
33
33
|
import { syncTaskCardInBackground } from "../lumifyhub/task-cards";
|
|
34
34
|
import { allocatePorts } from "../ports";
|
|
35
35
|
import { loadProjectConfig, portBases } from "../project-config";
|
|
@@ -416,6 +416,7 @@ async function relaunch(s: Session, cwd: string): Promise<void> {
|
|
|
416
416
|
? buildTaskBrief({
|
|
417
417
|
branch: s.branch_name,
|
|
418
418
|
baseBranch: s.base_branch ?? "main",
|
|
419
|
+
test: declaredTest(cwd),
|
|
419
420
|
})
|
|
420
421
|
: undefined,
|
|
421
422
|
});
|
|
@@ -6,14 +6,7 @@ import {
|
|
|
6
6
|
} from "../tasks/pr-stage";
|
|
7
7
|
import type { StatusBadge } from "./row-badge";
|
|
8
8
|
|
|
9
|
-
export type LiveTone =
|
|
10
|
-
| "blue"
|
|
11
|
-
| "amber"
|
|
12
|
-
| "red"
|
|
13
|
-
| "green"
|
|
14
|
-
| "purple"
|
|
15
|
-
| "primary"
|
|
16
|
-
| "muted";
|
|
9
|
+
export type LiveTone = "amber" | "red" | "green" | "primary" | "muted";
|
|
17
10
|
|
|
18
11
|
export interface LivePart {
|
|
19
12
|
text: string;
|
|
@@ -31,17 +24,17 @@ export const BADGE_TONE: Record<StatusBadge["kind"], LiveTone> = {
|
|
|
31
24
|
failed: "red",
|
|
32
25
|
stalled: "amber",
|
|
33
26
|
starting: "muted",
|
|
34
|
-
planning: "
|
|
27
|
+
planning: "muted",
|
|
35
28
|
"plan-ready": "primary",
|
|
36
29
|
};
|
|
37
30
|
|
|
38
31
|
export const PR_STAGE_TONE: Record<PrStageName, LiveTone> = {
|
|
39
|
-
merged: "
|
|
32
|
+
merged: "green",
|
|
40
33
|
conflicts: "amber",
|
|
41
34
|
"ci-failed": "red",
|
|
42
|
-
"ci-running": "
|
|
43
|
-
"ci-settling": "
|
|
44
|
-
"review-running": "
|
|
35
|
+
"ci-running": "muted",
|
|
36
|
+
"ci-settling": "muted",
|
|
37
|
+
"review-running": "muted",
|
|
45
38
|
"review-blocked": "red",
|
|
46
39
|
waiting: "amber",
|
|
47
40
|
armed: "green",
|
|
@@ -58,7 +51,7 @@ export function liveParts(input: {
|
|
|
58
51
|
}): LivePart[] {
|
|
59
52
|
const parts: LivePart[] = [];
|
|
60
53
|
if (input.compacting)
|
|
61
|
-
parts.push({ text: "Compacting", tone: "
|
|
54
|
+
parts.push({ text: "Compacting", tone: "muted", busy: true });
|
|
62
55
|
const b = input.badge;
|
|
63
56
|
if (b)
|
|
64
57
|
parts.push({
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
import type { Session } from "@/lib/db/types";
|
|
2
|
+
import type { SidebarRow } from "./shelves";
|
|
3
|
+
import { finishActions } from "./row-actions";
|
|
4
|
+
|
|
5
|
+
// What a session row's ⋯ / right-click menu offers, as data: which items,
|
|
6
|
+
// in which sections, and which sit together in a submenu. The component only
|
|
7
|
+
// draws it. An item that doesn't apply is absent, never shown disabled.
|
|
8
|
+
export type RowMenuItem =
|
|
9
|
+
| "rename"
|
|
10
|
+
| "pin"
|
|
11
|
+
| "unpin"
|
|
12
|
+
| "select"
|
|
13
|
+
| "open-tab"
|
|
14
|
+
| "open-pr"
|
|
15
|
+
| "card"
|
|
16
|
+
| "open-recipe"
|
|
17
|
+
| "copy-link"
|
|
18
|
+
| "share"
|
|
19
|
+
| "fork"
|
|
20
|
+
| "fresh-start"
|
|
21
|
+
| "new-conversation"
|
|
22
|
+
| "move-machine"
|
|
23
|
+
| "move-project"
|
|
24
|
+
| "check-ins"
|
|
25
|
+
| "save-recipe"
|
|
26
|
+
| "run-again"
|
|
27
|
+
| "snooze"
|
|
28
|
+
| "unsnooze"
|
|
29
|
+
| "pause"
|
|
30
|
+
| "resume"
|
|
31
|
+
| "settle"
|
|
32
|
+
| "reopen"
|
|
33
|
+
| "done"
|
|
34
|
+
| "restore"
|
|
35
|
+
| "archive"
|
|
36
|
+
| "delete";
|
|
37
|
+
|
|
38
|
+
export type RowMenuGroup = "open" | "share" | "restart" | "move" | "automate";
|
|
39
|
+
|
|
40
|
+
export type RowMenuEntry =
|
|
41
|
+
| RowMenuItem
|
|
42
|
+
| { group: RowMenuGroup; items: RowMenuItem[] };
|
|
43
|
+
|
|
44
|
+
export interface RowMenuInput {
|
|
45
|
+
session: Pick<
|
|
46
|
+
Session,
|
|
47
|
+
| "role"
|
|
48
|
+
| "quick_of"
|
|
49
|
+
| "pinned"
|
|
50
|
+
| "view"
|
|
51
|
+
| "agent_type"
|
|
52
|
+
| "task_status"
|
|
53
|
+
| "pr_url"
|
|
54
|
+
| "has_brief"
|
|
55
|
+
| "archived_at"
|
|
56
|
+
| "pause_state"
|
|
57
|
+
>;
|
|
58
|
+
row?: Pick<SidebarRow, "task" | "wakesAt" | "settled">;
|
|
59
|
+
// A linked machine runs it: what can't reach it yet is left out.
|
|
60
|
+
remote: boolean;
|
|
61
|
+
canOpenInTab: boolean;
|
|
62
|
+
hasCard: boolean;
|
|
63
|
+
recipeRun: boolean;
|
|
64
|
+
hasWorkspace: boolean;
|
|
65
|
+
machineTargets: number;
|
|
66
|
+
otherProjects: number;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export interface RowMenuPlan {
|
|
70
|
+
sections: RowMenuEntry[][];
|
|
71
|
+
// Say once why Fork, Fresh start, project moves and check-ins are missing.
|
|
72
|
+
remoteNote: boolean;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const GROUP_LABEL: Record<RowMenuGroup, string> = {
|
|
76
|
+
open: "Open",
|
|
77
|
+
share: "Share",
|
|
78
|
+
restart: "Fork or restart",
|
|
79
|
+
move: "Move",
|
|
80
|
+
automate: "Automate",
|
|
81
|
+
};
|
|
82
|
+
export const groupLabel = (g: RowMenuGroup) => GROUP_LABEL[g];
|
|
83
|
+
|
|
84
|
+
// A group of one is just that item; an empty one is nothing.
|
|
85
|
+
function group(
|
|
86
|
+
g: RowMenuGroup,
|
|
87
|
+
items: (RowMenuItem | false)[]
|
|
88
|
+
): RowMenuEntry[] {
|
|
89
|
+
const kept = items.filter((i): i is RowMenuItem => !!i);
|
|
90
|
+
if (kept.length === 0) return [];
|
|
91
|
+
return kept.length === 1 ? kept : [{ group: g, items: kept }];
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export function rowMenu(input: RowMenuInput): RowMenuPlan {
|
|
95
|
+
const { session: s, row, remote } = input;
|
|
96
|
+
const plain = !!row && !row.task && !s.task_status;
|
|
97
|
+
const finish = finishActions(s, plain);
|
|
98
|
+
const sections: RowMenuEntry[][] = [];
|
|
99
|
+
|
|
100
|
+
// A quick change has no agent: its PR and the ways to finish it.
|
|
101
|
+
if (s.quick_of) {
|
|
102
|
+
sections.push(s.pr_url ? ["open-pr"] : []);
|
|
103
|
+
sections.push(finish.filter((a) => a !== "archive"));
|
|
104
|
+
sections.push(["archive", "delete"]);
|
|
105
|
+
return {
|
|
106
|
+
sections: sections.filter((x) => x.length),
|
|
107
|
+
remoteNote: false,
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
const orchestrator = s.role === "orchestrator";
|
|
112
|
+
const agentRow = !s.role;
|
|
113
|
+
const live = !remote;
|
|
114
|
+
|
|
115
|
+
sections.push(["rename", s.pinned ? "unpin" : "pin", "select"]);
|
|
116
|
+
|
|
117
|
+
sections.push([
|
|
118
|
+
...group("open", [
|
|
119
|
+
input.canOpenInTab && "open-tab",
|
|
120
|
+
!!s.pr_url && "open-pr",
|
|
121
|
+
input.hasCard && "card",
|
|
122
|
+
input.recipeRun && "open-recipe",
|
|
123
|
+
]),
|
|
124
|
+
...group("share", ["copy-link", s.view === "chat" && "share"]),
|
|
125
|
+
...group("restart", [
|
|
126
|
+
s.agent_type === "claude" && agentRow && live && "fork",
|
|
127
|
+
agentRow && live && "fresh-start",
|
|
128
|
+
(orchestrator || !!s.has_brief) &&
|
|
129
|
+
s.view === "chat" &&
|
|
130
|
+
!s.archived_at &&
|
|
131
|
+
live &&
|
|
132
|
+
"new-conversation",
|
|
133
|
+
]),
|
|
134
|
+
...group("move", [
|
|
135
|
+
input.machineTargets > 0 && "move-machine",
|
|
136
|
+
agentRow && input.otherProjects > 0 && live && "move-project",
|
|
137
|
+
]),
|
|
138
|
+
...group("automate", [
|
|
139
|
+
input.hasWorkspace &&
|
|
140
|
+
!orchestrator &&
|
|
141
|
+
(!s.task_status || s.task_status === "running") &&
|
|
142
|
+
live &&
|
|
143
|
+
"check-ins",
|
|
144
|
+
input.recipeRun ? "run-again" : agentRow && "save-recipe",
|
|
145
|
+
]),
|
|
146
|
+
]);
|
|
147
|
+
|
|
148
|
+
if (agentRow) {
|
|
149
|
+
const state: RowMenuEntry[] = [];
|
|
150
|
+
if (row?.wakesAt) state.push("unsnooze");
|
|
151
|
+
else if (!s.pinned && row) state.push("snooze");
|
|
152
|
+
const finished = !!s.task_status && s.task_status !== "running";
|
|
153
|
+
if ((s.task_status || s.view === "chat") && !finished) {
|
|
154
|
+
if (s.pause_state === "paused" || s.pause_state === "pausing")
|
|
155
|
+
state.push("resume");
|
|
156
|
+
else if (!s.pause_state) state.push("pause");
|
|
157
|
+
}
|
|
158
|
+
if (plain) state.push(row.settled ? "reopen" : "settle");
|
|
159
|
+
state.push(...finish.filter((a) => a !== "archive"));
|
|
160
|
+
sections.push(state);
|
|
161
|
+
sections.push(["archive", "delete"]);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
return {
|
|
165
|
+
sections: sections.filter((x) => x.length),
|
|
166
|
+
remoteNote: remote && agentRow,
|
|
167
|
+
};
|
|
168
|
+
}
|
package/lib/simulator/index.ts
CHANGED
|
@@ -144,6 +144,11 @@ const tail = (file: string, lines = 15) => {
|
|
|
144
144
|
// simulator can stop it.
|
|
145
145
|
const starts = new Map<string, ChildProcess>();
|
|
146
146
|
|
|
147
|
+
// Simulator starts running now, for lib/busy: a restart kills them.
|
|
148
|
+
export function runningStarts(): number {
|
|
149
|
+
return starts.size;
|
|
150
|
+
}
|
|
151
|
+
|
|
147
152
|
// The whole group: the project's build and Metro are children of the shell.
|
|
148
153
|
export function killGroup(child: ChildProcess, signal: NodeJS.Signals) {
|
|
149
154
|
try {
|
|
@@ -153,7 +158,7 @@ export function killGroup(child: ChildProcess, signal: NodeJS.Signals) {
|
|
|
153
158
|
}
|
|
154
159
|
}
|
|
155
160
|
|
|
156
|
-
function runStart(
|
|
161
|
+
export function runStart(
|
|
157
162
|
sessionId: string,
|
|
158
163
|
command: string,
|
|
159
164
|
cwd: string,
|
package/lib/tasks/brief.ts
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { loadProjectConfig } from "../project-config";
|
|
2
|
+
|
|
1
3
|
// A task stacked on another task's unmerged branch.
|
|
2
4
|
export interface StackedOn {
|
|
3
5
|
// The parent's PR number, when it has one.
|
|
@@ -19,17 +21,30 @@ function stackedLines(baseBranch: string, stack: StackedOn): string {
|
|
|
19
21
|
- Never merge or rebase onto the default branch yourself. When the parent merges, AgentOS rebases your branch and retargets your PR, then tells you; re-run your checks when it does. AgentOS reviews your PR itself; a review of your head still counts for the head its restack left, and a push after that gets a new one.${also}`;
|
|
20
22
|
}
|
|
21
23
|
|
|
24
|
+
// The project's declared test command, read from the checkout the task runs in.
|
|
25
|
+
export function declaredTest(cwd: string): string | undefined {
|
|
26
|
+
return loadProjectConfig(cwd).config.test || undefined;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function checksLine(test?: string): string {
|
|
30
|
+
return test
|
|
31
|
+
? `Run exactly this to check your work: \`${test}\`. CI runs the full suite on your pull request, so don't run it locally.`
|
|
32
|
+
: "Run the project's own checks (typecheck, tests, lint) when they exist and keep them passing.";
|
|
33
|
+
}
|
|
34
|
+
|
|
22
35
|
// The system brief every task agent runs under. It finishes by opening a PR
|
|
23
36
|
// and stopping: merging is the human's sign-off, never the agent's.
|
|
24
37
|
export function buildTaskBrief(opts: {
|
|
25
38
|
branch: string;
|
|
26
39
|
baseBranch: string;
|
|
27
40
|
stack?: StackedOn;
|
|
41
|
+
// The project's `test` from agentos.json, when it declares one.
|
|
42
|
+
test?: string;
|
|
28
43
|
}): string {
|
|
29
44
|
return `You are running as an AgentOS background task, alone, in an isolated git worktree.
|
|
30
45
|
|
|
31
46
|
- Branch: ${opts.branch} (based on ${opts.baseBranch}). Stay in this directory and on this branch.
|
|
32
|
-
- Do the task fully.
|
|
47
|
+
- Do the task fully. ${checksLine(opts.test)}
|
|
33
48
|
- When the work is done, commit with a conventional commit message, run \`git push -u origin ${opts.branch}\` and open a pull request against ${opts.baseBranch} with \`gh pr create\`, using a clear title and a short body that says what changed and how you verified it. Then stop and wait.
|
|
34
49
|
- You don't need to review it yourself: AgentOS runs an independent review of the PR's exact head commit before it can merge, and the orchestrator sends you any blocking findings to fix. For a risky change you may still run \`/do-code-review\` (or \`/code-review\`) before the PR.
|
|
35
50
|
- Never merge the pull request, never push to ${opts.baseBranch}, and never delete branches or worktrees. A human reviews and merges.${opts.stack ? stackedLines(opts.baseBranch, opts.stack) : ""}
|
package/lib/tasks/import.ts
CHANGED
|
@@ -23,7 +23,7 @@ import { resolveModelForAgent } from "../model-catalog";
|
|
|
23
23
|
import { providerFor } from "../models/resolve";
|
|
24
24
|
import { preparePlanDir, PLAN_FILE } from "../models/plan-step";
|
|
25
25
|
import { carryTaskState } from "./move-carry";
|
|
26
|
-
import { buildTaskBrief } from "./brief";
|
|
26
|
+
import { buildTaskBrief, declaredTest } from "./brief";
|
|
27
27
|
import { expandHome } from "./session";
|
|
28
28
|
import { ensureProject } from "./project-ref";
|
|
29
29
|
import { checkoutBranchWorktree } from "./branch-worktree";
|
|
@@ -136,7 +136,11 @@ async function arrive(
|
|
|
136
136
|
? bundle.model
|
|
137
137
|
: resolveModelForAgent("claude", bundle.model || project.default_model);
|
|
138
138
|
const baseBranch = bundle.baseBranch ?? "main";
|
|
139
|
-
const brief = buildTaskBrief({
|
|
139
|
+
const brief = buildTaskBrief({
|
|
140
|
+
branch: bundle.branch,
|
|
141
|
+
baseBranch,
|
|
142
|
+
test: declaredTest(worktreePath),
|
|
143
|
+
});
|
|
140
144
|
// Checked and written with no await between, so two can't both pass.
|
|
141
145
|
db.transaction(() => {
|
|
142
146
|
refuseBusyBranch(project.id, bundle.branch);
|
package/lib/tasks/index.ts
CHANGED
|
@@ -33,7 +33,7 @@ import { agentLabel } from "../agent-options";
|
|
|
33
33
|
import { supportsChat } from "../chat/capabilities";
|
|
34
34
|
import { AGENT_DEFAULT_MODEL } from "../providers/registry";
|
|
35
35
|
import { statusDetector } from "../status-detector";
|
|
36
|
-
import { buildTaskBrief, type StackedOn } from "./brief";
|
|
36
|
+
import { buildTaskBrief, declaredTest, type StackedOn } from "./brief";
|
|
37
37
|
import {
|
|
38
38
|
attachTaskCard,
|
|
39
39
|
inBackground,
|
|
@@ -354,6 +354,7 @@ async function startTask(
|
|
|
354
354
|
branch: wt.branchName,
|
|
355
355
|
baseBranch,
|
|
356
356
|
stack: opts.base?.stack,
|
|
357
|
+
test: declaredTest(wt.worktreePath),
|
|
357
358
|
}),
|
|
358
359
|
naming.source,
|
|
359
360
|
view,
|