@saadnvd1/agent-os 0.3.723 → 0.3.731
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/bus/ports/route.test.ts +43 -0
- package/app/api/bus/ports/route.ts +35 -0
- package/app/api/loops/route.ts +17 -0
- package/app/api/sessions/[id]/export/turn/route.test.ts +65 -0
- package/app/api/sessions/[id]/export/turn/route.ts +33 -0
- package/app/api/tasks/[id]/loop/cycles/[n]/log/route.ts +29 -0
- package/app/api/tasks/[id]/loop/draft-criteria/route.ts +17 -0
- package/app/api/tasks/[id]/loop/next/route.ts +25 -0
- package/app/api/tasks/[id]/loop/route.ts +72 -0
- package/app/api/tasks/[id]/loop/run/route.ts +12 -0
- package/app/api/tasks/[id]/loop/stop/route.ts +12 -0
- package/app/api/tasks/[id]/mode/route.ts +29 -0
- package/app/api/tasks/[id]/report/draft/route.ts +54 -0
- package/app/api/tasks/[id]/report/evidence/[evId]/route.ts +18 -0
- package/app/api/tasks/[id]/report/route.ts +39 -0
- package/app/api/workspaces/[id]/mode/route.ts +23 -0
- package/app/api/workspaces/[id]/reports/route.ts +17 -0
- package/app/page.tsx +8 -0
- package/components/Analyst/PairChip.tsx +131 -0
- package/components/Analyst/PairSwitch.tsx +76 -0
- package/components/Analyst/ReviewCard.tsx +272 -0
- package/components/Analyst/SignalChips.tsx +39 -0
- package/components/Analyst/StartAnalystSheet.tsx +109 -0
- package/components/Analyst/cardState.test.ts +109 -0
- package/components/Analyst/cardState.ts +95 -0
- package/components/Analyst/pair.ts +81 -0
- package/components/Chat/Code.tsx +3 -0
- package/components/Chat/Messages.tsx +8 -1
- package/components/Chat/TurnCopy.tsx +84 -0
- package/components/Chat/itemContext.ts +14 -0
- package/components/Draft/DraftChips.tsx +10 -0
- package/components/Draft/DraftPanel.tsx +7 -0
- package/components/Pane/DesktopTabBar.tsx +6 -0
- package/components/Pane/MobileTabBar.tsx +17 -0
- package/components/Pane/MobileViewPicker.tsx +4 -0
- package/components/Pane/PaneMenu.test.ts +21 -0
- package/components/Pane/PaneMenu.tsx +16 -0
- package/components/Pane/PaneViewToggle.tsx +12 -0
- package/components/Pane/index.tsx +19 -0
- package/components/Report/DraftCard.tsx +89 -0
- package/components/Report/EstimateBar.tsx +51 -0
- package/components/Report/EvidenceSheet.tsx +98 -0
- package/components/Report/ModeChooser.tsx +43 -0
- package/components/Report/ReportView.tsx +289 -0
- package/components/SessionList/GroupShelves.tsx +57 -22
- package/components/SessionList/MoveToGroupMenu.test.ts +77 -0
- package/components/SessionList/SessionRow.tsx +12 -1
- package/components/SessionList/SessionRowMenu.tsx +6 -0
- package/components/SessionList/SidebarContextMenu.test.ts +21 -1
- package/components/SessionList/SidebarContextMenu.tsx +33 -9
- package/components/SessionList/index.tsx +11 -2
- package/components/SessionList/useGroupDrag.ts +83 -0
- package/components/Sessions/RolePill.tsx +7 -0
- package/components/Tasks/Loop/LoopButton.tsx +74 -0
- package/components/Tasks/Loop/LoopSetup.tsx +146 -0
- package/components/Tasks/Loop/LoopView.tsx +350 -0
- package/components/Tasks/Loop/labels.ts +17 -0
- package/components/Tasks/TaskRow.tsx +2 -0
- package/components/Workspaces/WorkspaceModeForm.tsx +22 -0
- package/components/Workspaces/WorkspaceSettings.tsx +2 -0
- package/components/ui/segmented.tsx +55 -0
- package/components/ui/status-pill.tsx +3 -0
- package/contexts/PaneContext.tsx +21 -1
- package/data/exports/queries.ts +11 -0
- package/data/loops/index.ts +3 -0
- package/data/loops/keys.ts +4 -0
- package/data/loops/mutations.ts +68 -0
- package/data/loops/queries.ts +40 -0
- package/data/push/topics.ts +6 -0
- package/data/reports/index.ts +115 -0
- package/lib/agents/brief.ts +1 -1
- package/lib/analyst/forward.ts +1 -1
- package/lib/db/changes.ts +7 -0
- package/lib/db/migrations/20261021T1600_task_loops.ts +81 -0
- package/lib/db/migrations/20261021T1800_health_reports.ts +38 -0
- package/lib/done/index.ts +2 -0
- package/lib/drafts.ts +2 -0
- package/lib/export/markdown.ts +7 -1
- package/lib/export/turn.ts +48 -0
- package/lib/health/config.ts +2 -0
- package/lib/health/env/classes.ts +14 -1
- package/lib/health/port-wait-tick.ts +222 -0
- package/lib/health/port-wait.ts +160 -0
- package/lib/health/tick.ts +9 -2
- package/lib/health/types.ts +2 -0
- package/lib/loops/analyse.ts +46 -0
- package/lib/loops/analyst-prompt.ts +203 -0
- package/lib/loops/checkCriteria.ts +114 -0
- package/lib/loops/compactEvidence.ts +140 -0
- package/lib/loops/controls.ts +317 -0
- package/lib/loops/decideStop.ts +113 -0
- package/lib/loops/deps.ts +128 -0
- package/lib/loops/diffMap.ts +39 -0
- package/lib/loops/draftCriteria.ts +71 -0
- package/lib/loops/gatherEvidence.ts +265 -0
- package/lib/loops/hooks.ts +40 -0
- package/lib/loops/index.ts +7 -0
- package/lib/loops/route.ts +83 -0
- package/lib/loops/runCycle.ts +323 -0
- package/lib/loops/screenInstruction.ts +59 -0
- package/lib/loops/sendInstruction.ts +83 -0
- package/lib/loops/snapshot.ts +38 -0
- package/lib/loops/stopLoop.ts +86 -0
- package/lib/loops/store.ts +481 -0
- package/lib/loops/summary.ts +27 -0
- package/lib/loops/tickLoops.ts +141 -0
- package/lib/loops/types.ts +223 -0
- package/lib/loops/world.test-helpers.ts +223 -0
- package/lib/models/plan-step.ts +4 -1
- package/lib/orchestrator/digest.ts +2 -1
- package/lib/orchestrator/loop-guard.ts +5 -2
- package/lib/orchestrator/pr-wake.ts +4 -0
- package/lib/orchestrator/watcher.ts +8 -1
- package/lib/panes.ts +52 -0
- package/lib/plans/acceptance.ts +23 -0
- package/lib/{ports.ts → ports/allocatePorts.ts} +30 -7
- package/lib/ports/ensurePorts.ts +138 -0
- package/lib/ports/findHardcodedPorts.ts +116 -0
- package/lib/ports/index.ts +15 -0
- package/lib/ports/listListeners.ts +134 -0
- package/lib/ports/reassignPort.ts +45 -0
- package/lib/ports/types.ts +62 -0
- package/lib/ports/whoHolds.ts +155 -0
- package/lib/project-config/brief.ts +4 -2
- package/lib/project-config/session.ts +19 -3
- package/lib/reports/drafts.ts +87 -0
- package/lib/reports/end-hook.ts +17 -0
- package/lib/reports/estimate.ts +107 -0
- package/lib/reports/evidence/build.ts +193 -0
- package/lib/reports/evidence/gather.ts +229 -0
- package/lib/reports/fixtures.ts +17 -0
- package/lib/reports/generate.ts +159 -0
- package/lib/reports/hooks.ts +30 -0
- package/lib/reports/mode.ts +65 -0
- package/lib/reports/model.ts +115 -0
- package/lib/reports/read.ts +26 -0
- package/lib/reports/store.ts +180 -0
- package/lib/reports/template.ts +117 -0
- package/lib/reports/types.ts +135 -0
- package/lib/reports/validate.ts +142 -0
- package/lib/sidebar/groupDrop.ts +36 -0
- package/lib/sidebar/row-menu.ts +9 -0
- package/lib/tasks/contract/routes.ts +5 -0
- package/lib/tasks/contract/types.ts +3 -0
- package/lib/tasks/finish.ts +2 -0
- package/lib/tasks/move-carry.ts +1 -0
- package/lib/windows-ports.ts +36 -18
- package/package.json +2 -2
- package/stores/analystUi.ts +19 -0
|
@@ -21,6 +21,7 @@ import { cn } from "@/lib/utils";
|
|
|
21
21
|
import { SimulatorIndicator } from "@/components/Simulator";
|
|
22
22
|
import { PlanReview } from "./PlanReview";
|
|
23
23
|
import { MoveTaskButton } from "./MoveTaskButton";
|
|
24
|
+
import { LoopButton } from "./Loop/LoopButton";
|
|
24
25
|
import { ResumeOnModel } from "./ResumeOnModel";
|
|
25
26
|
import { TASK_STATE as STATE } from "./task-state-labels";
|
|
26
27
|
import { formatTokens } from "@/components/Usage/format";
|
|
@@ -119,6 +120,7 @@ export function TaskRow({ task }: { task: TaskView }) {
|
|
|
119
120
|
)}
|
|
120
121
|
{!task.hostId && <SimulatorIndicator sessionId={task.id} />}
|
|
121
122
|
<MoveTaskButton task={task} onError={setMoveError} />
|
|
123
|
+
{!task.hostId && <LoopButton taskId={task.id} name={task.name} />}
|
|
122
124
|
{!task.hostId && <ResumeOnModel task={task} />}
|
|
123
125
|
<span className="flex-1" />
|
|
124
126
|
{confirmDrop ? (
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { ModeChooser } from "@/components/Report/ModeChooser";
|
|
4
|
+
import { useSetWorkspaceMode, useWorkspaceMode } from "@/data/reports";
|
|
5
|
+
|
|
6
|
+
// A workspace's default operating mode; a task can override it.
|
|
7
|
+
export function WorkspaceModeForm({ workspaceId }: { workspaceId: string }) {
|
|
8
|
+
const { data: mode } = useWorkspaceMode(workspaceId);
|
|
9
|
+
const set = useSetWorkspaceMode(workspaceId);
|
|
10
|
+
if (!mode) return null;
|
|
11
|
+
return (
|
|
12
|
+
<div className="space-y-1.5">
|
|
13
|
+
<h4 className="text-sm font-medium">Operating mode</h4>
|
|
14
|
+
<ModeChooser
|
|
15
|
+
mode={mode}
|
|
16
|
+
onChange={(m) => set.mutate(m)}
|
|
17
|
+
disabled={set.isPending}
|
|
18
|
+
note="New tasks start in this mode; any task can change its own."
|
|
19
|
+
/>
|
|
20
|
+
</div>
|
|
21
|
+
);
|
|
22
|
+
}
|
|
@@ -9,6 +9,7 @@ import { McpServersForm } from "./McpServersForm";
|
|
|
9
9
|
import { HqFolderForm } from "./HqFolderForm";
|
|
10
10
|
import { ProjectTiersForm } from "./ProjectTiersForm";
|
|
11
11
|
import { StallForm } from "./StallForm";
|
|
12
|
+
import { WorkspaceModeForm } from "./WorkspaceModeForm";
|
|
12
13
|
import { WorkspaceModelsForm } from "./WorkspaceModelsForm";
|
|
13
14
|
import { AwayWindowForm } from "@/components/Away/AwayWindowForm";
|
|
14
15
|
|
|
@@ -46,6 +47,7 @@ export function WorkspaceSettings() {
|
|
|
46
47
|
key={`${w.id}:${w.max_running_tasks ?? "off"}`}
|
|
47
48
|
workspace={w}
|
|
48
49
|
/>
|
|
50
|
+
<WorkspaceModeForm workspaceId={w.id} />
|
|
49
51
|
<RulesForm workspaceId={w.id} />
|
|
50
52
|
<WorkspaceModelsForm workspaceId={w.id} />
|
|
51
53
|
<McpServersForm workspace={w} />
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { cn } from "@/lib/utils";
|
|
4
|
+
|
|
5
|
+
export interface SegmentedOption<T extends string> {
|
|
6
|
+
value: T;
|
|
7
|
+
label: string;
|
|
8
|
+
hint?: string;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/** One choice out of a few, side by side. Radio semantics, 44px targets. */
|
|
12
|
+
export function Segmented<T extends string>({
|
|
13
|
+
value,
|
|
14
|
+
options,
|
|
15
|
+
onChange,
|
|
16
|
+
label,
|
|
17
|
+
disabled,
|
|
18
|
+
}: {
|
|
19
|
+
value: T;
|
|
20
|
+
options: SegmentedOption<T>[];
|
|
21
|
+
onChange: (v: T) => void;
|
|
22
|
+
label: string;
|
|
23
|
+
disabled?: boolean;
|
|
24
|
+
}) {
|
|
25
|
+
return (
|
|
26
|
+
<div
|
|
27
|
+
role="radiogroup"
|
|
28
|
+
aria-label={label}
|
|
29
|
+
className="bg-muted/60 grid auto-cols-fr grid-flow-col gap-1 rounded-lg p-1"
|
|
30
|
+
>
|
|
31
|
+
{options.map((o) => {
|
|
32
|
+
const on = o.value === value;
|
|
33
|
+
return (
|
|
34
|
+
<button
|
|
35
|
+
key={o.value}
|
|
36
|
+
type="button"
|
|
37
|
+
role="radio"
|
|
38
|
+
aria-checked={on}
|
|
39
|
+
title={o.hint}
|
|
40
|
+
disabled={disabled}
|
|
41
|
+
onClick={() => !on && onChange(o.value)}
|
|
42
|
+
className={cn(
|
|
43
|
+
"min-h-11 rounded-md px-2 text-sm font-medium transition-colors disabled:opacity-50",
|
|
44
|
+
on
|
|
45
|
+
? "bg-card text-foreground shadow-sm"
|
|
46
|
+
: "text-muted-foreground hover:text-foreground"
|
|
47
|
+
)}
|
|
48
|
+
>
|
|
49
|
+
{o.label}
|
|
50
|
+
</button>
|
|
51
|
+
);
|
|
52
|
+
})}
|
|
53
|
+
</div>
|
|
54
|
+
);
|
|
55
|
+
}
|
|
@@ -4,6 +4,9 @@ import { cn } from "@/lib/utils";
|
|
|
4
4
|
const TONES = {
|
|
5
5
|
amber: "bg-amber-500/15 text-amber-700 dark:text-amber-400",
|
|
6
6
|
muted: "bg-muted text-muted-foreground",
|
|
7
|
+
green: "bg-green-500/15 text-green-700 dark:text-green-400",
|
|
8
|
+
primary: "bg-primary/15 text-primary",
|
|
9
|
+
red: "bg-red-500/15 text-red-700 dark:text-red-400",
|
|
7
10
|
} as const;
|
|
8
11
|
|
|
9
12
|
/** A small coloured pill for a status. A button when it has an onClick. */
|
package/contexts/PaneContext.tsx
CHANGED
|
@@ -23,11 +23,13 @@ import {
|
|
|
23
23
|
loadPaneState,
|
|
24
24
|
showDraft,
|
|
25
25
|
MAX_PANES,
|
|
26
|
+
openBeside as openBesideIn,
|
|
27
|
+
type OpenSession,
|
|
26
28
|
} from "@/lib/panes";
|
|
27
29
|
import { useViewport } from "@/hooks/useViewport";
|
|
28
30
|
|
|
29
31
|
export type ViewMode =
|
|
30
|
-
"terminal" | "files" | "git" | "workers" | "log" | "artifacts";
|
|
32
|
+
"terminal" | "files" | "git" | "workers" | "log" | "artifacts" | "report";
|
|
31
33
|
|
|
32
34
|
export interface PaneViewState {
|
|
33
35
|
viewMode: ViewMode;
|
|
@@ -59,6 +61,8 @@ interface PaneContextValue {
|
|
|
59
61
|
hostId?: string
|
|
60
62
|
) => void;
|
|
61
63
|
detachSession: (paneId: string) => void;
|
|
64
|
+
// Shows a session beside a pane: its pane if open, else a split or a tab.
|
|
65
|
+
openBeside: (paneId: string, open: OpenSession) => void;
|
|
62
66
|
openDraft: (paneId: string, draftId: string) => void;
|
|
63
67
|
getPaneData: (paneId: string) => PaneData;
|
|
64
68
|
getActiveTab: (paneId: string) => TabData | null;
|
|
@@ -270,6 +274,21 @@ export function PaneProvider({ children }: { children: ReactNode }) {
|
|
|
270
274
|
[]
|
|
271
275
|
);
|
|
272
276
|
|
|
277
|
+
// A phone shows one pane: the session takes this one's place.
|
|
278
|
+
const openBeside = useCallback(
|
|
279
|
+
(paneId: string, open: OpenSession) => {
|
|
280
|
+
if (isMobile)
|
|
281
|
+
attachSession(
|
|
282
|
+
paneId,
|
|
283
|
+
open.sessionId,
|
|
284
|
+
open.tmux,
|
|
285
|
+
open.hostId ?? undefined
|
|
286
|
+
);
|
|
287
|
+
else setState((prev) => openBesideIn(prev, paneId, open));
|
|
288
|
+
},
|
|
289
|
+
[isMobile, attachSession]
|
|
290
|
+
);
|
|
291
|
+
|
|
273
292
|
// Detach session from active tab
|
|
274
293
|
const detachSession = useCallback((paneId: string) => {
|
|
275
294
|
setState((prev) => {
|
|
@@ -393,6 +412,7 @@ export function PaneProvider({ children }: { children: ReactNode }) {
|
|
|
393
412
|
switchTab,
|
|
394
413
|
attachSession,
|
|
395
414
|
detachSession,
|
|
415
|
+
openBeside,
|
|
396
416
|
openDraft,
|
|
397
417
|
getPaneData,
|
|
398
418
|
getActiveTab,
|
package/data/exports/queries.ts
CHANGED
|
@@ -49,3 +49,14 @@ export async function fetchExport(
|
|
|
49
49
|
name: exportFileName(title, sel ? rangeSlug(sel) : "all", req.format),
|
|
50
50
|
};
|
|
51
51
|
}
|
|
52
|
+
|
|
53
|
+
/** One turn as Markdown (secrets masked), and its last agent message. */
|
|
54
|
+
export async function fetchTurnCopy(
|
|
55
|
+
sessionId: string,
|
|
56
|
+
startId: string
|
|
57
|
+
): Promise<{ full: string; response: string }> {
|
|
58
|
+
const res = await post(`/api/sessions/${sessionId}/export/turn`, {
|
|
59
|
+
startId,
|
|
60
|
+
});
|
|
61
|
+
return res.ok ? res.json() : failure(res);
|
|
62
|
+
}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
|
2
|
+
import type { CriterionDraft, Limits, LoopView } from "@/lib/loops/types";
|
|
3
|
+
import { loopKeys } from "./keys";
|
|
4
|
+
|
|
5
|
+
async function call<T>(
|
|
6
|
+
url: string,
|
|
7
|
+
method: string,
|
|
8
|
+
body?: unknown
|
|
9
|
+
): Promise<T> {
|
|
10
|
+
const res = await fetch(url, {
|
|
11
|
+
method,
|
|
12
|
+
headers: { "Content-Type": "application/json" },
|
|
13
|
+
body: body === undefined ? undefined : JSON.stringify(body),
|
|
14
|
+
});
|
|
15
|
+
const data = await res.json().catch(() => ({}));
|
|
16
|
+
if (!res.ok) throw new Error(data.error || "Something went wrong");
|
|
17
|
+
return data as T;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const base = (taskId: string) =>
|
|
21
|
+
`/api/tasks/${encodeURIComponent(taskId)}/loop`;
|
|
22
|
+
|
|
23
|
+
function useAct<I>(run: (input: I) => Promise<unknown>) {
|
|
24
|
+
const client = useQueryClient();
|
|
25
|
+
return useMutation({
|
|
26
|
+
mutationFn: run,
|
|
27
|
+
onSettled: () => void client.invalidateQueries({ queryKey: loopKeys.all }),
|
|
28
|
+
});
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export const useDraftCriteria = (taskId: string) =>
|
|
32
|
+
useMutation({
|
|
33
|
+
mutationFn: () =>
|
|
34
|
+
call<{ criteria: CriterionDraft[] }>(
|
|
35
|
+
`${base(taskId)}/draft-criteria`,
|
|
36
|
+
"POST",
|
|
37
|
+
{}
|
|
38
|
+
).then((r) => r.criteria),
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
export const useStartLoop = (taskId: string) =>
|
|
42
|
+
useAct((input: { criteria: CriterionDraft[]; limits: Partial<Limits> }) =>
|
|
43
|
+
call<{ view: LoopView }>(base(taskId), "POST", input)
|
|
44
|
+
);
|
|
45
|
+
|
|
46
|
+
export interface LoopEdit {
|
|
47
|
+
extend?: { cycles?: number; costUsd?: number; minutes?: number };
|
|
48
|
+
addCriteria?: CriterionDraft[];
|
|
49
|
+
editCriteria?: { id: number; text: string }[];
|
|
50
|
+
removeCriteria?: number[];
|
|
51
|
+
waive?: number[];
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export const useEditLoop = (taskId: string) =>
|
|
55
|
+
useAct((input: LoopEdit) =>
|
|
56
|
+
call<{ view: LoopView }>(base(taskId), "PATCH", input)
|
|
57
|
+
);
|
|
58
|
+
|
|
59
|
+
export const useStopLoop = (taskId: string) =>
|
|
60
|
+
useAct(() => call<{ view: LoopView }>(`${base(taskId)}/stop`, "POST", {}));
|
|
61
|
+
|
|
62
|
+
export const useRunLoop = (taskId: string) =>
|
|
63
|
+
useAct(() => call<{ view: LoopView }>(`${base(taskId)}/run`, "POST", {}));
|
|
64
|
+
|
|
65
|
+
export const useNextInstruction = (taskId: string) =>
|
|
66
|
+
useAct((input: { action: "send" | "hold" | "skip"; instruction?: string }) =>
|
|
67
|
+
call<{ view: LoopView }>(`${base(taskId)}/next`, "POST", input)
|
|
68
|
+
);
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { useQuery } from "@tanstack/react-query";
|
|
2
|
+
import type { LoopSummary, LoopView } from "@/lib/loops/types";
|
|
3
|
+
import { usePollWhenOffline } from "../push/connection";
|
|
4
|
+
import { loopKeys } from "./keys";
|
|
5
|
+
|
|
6
|
+
const base = (taskId: string) =>
|
|
7
|
+
`/api/tasks/${encodeURIComponent(taskId)}/loop`;
|
|
8
|
+
|
|
9
|
+
// The task's loop, or null when it has none. Pushed when the loop's tables
|
|
10
|
+
// change; it polls only while the stream is down.
|
|
11
|
+
export function useTaskLoop(taskId: string | undefined, enabled = true) {
|
|
12
|
+
const poll = usePollWhenOffline(10000);
|
|
13
|
+
return useQuery({
|
|
14
|
+
queryKey: loopKeys.ofTask(taskId ?? ""),
|
|
15
|
+
queryFn: async (): Promise<LoopView | null> => {
|
|
16
|
+
const res = await fetch(base(taskId!));
|
|
17
|
+
const body = await res.json().catch(() => ({}));
|
|
18
|
+
if (!res.ok) throw new Error(body.error || "Couldn't load the loop");
|
|
19
|
+
return body.view ?? null;
|
|
20
|
+
},
|
|
21
|
+
enabled: !!taskId && enabled,
|
|
22
|
+
staleTime: 3000,
|
|
23
|
+
refetchInterval: poll,
|
|
24
|
+
});
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// The pill of every task that has had a loop lately.
|
|
28
|
+
export function useLoopSummaries() {
|
|
29
|
+
const poll = usePollWhenOffline(15000);
|
|
30
|
+
return useQuery({
|
|
31
|
+
queryKey: [...loopKeys.all, "summaries"] as const,
|
|
32
|
+
queryFn: async (): Promise<Record<string, LoopSummary | null>> => {
|
|
33
|
+
const res = await fetch("/api/loops");
|
|
34
|
+
const body = await res.json().catch(() => ({}));
|
|
35
|
+
return res.ok ? (body.loops ?? {}) : {};
|
|
36
|
+
},
|
|
37
|
+
staleTime: 5000,
|
|
38
|
+
refetchInterval: poll,
|
|
39
|
+
});
|
|
40
|
+
}
|
package/data/push/topics.ts
CHANGED
|
@@ -20,6 +20,7 @@ import { bookmarkKeys } from "../bookmarks/keys";
|
|
|
20
20
|
import { learningCardsKeys } from "../learning-cards";
|
|
21
21
|
import { claimKeys } from "../claims/keys";
|
|
22
22
|
import { analystKeys } from "../analyst/keys";
|
|
23
|
+
import { loopKeys } from "../loops/keys";
|
|
23
24
|
|
|
24
25
|
// What each pushed topic (a table that changed, lib/db/changes.ts, or a
|
|
25
26
|
// pushed view) means the browser should refetch. Only queries on screen
|
|
@@ -85,6 +86,11 @@ function keysFor(topic: string): QueryKey[] {
|
|
|
85
86
|
return [analystKeys.all, sessionKeys.all];
|
|
86
87
|
case "analyst_recommendations":
|
|
87
88
|
return [analystKeys.all];
|
|
89
|
+
case "task_loops":
|
|
90
|
+
return [loopKeys.all, sessionKeys.all];
|
|
91
|
+
case "task_loop_criteria":
|
|
92
|
+
case "task_loop_cycles":
|
|
93
|
+
return [loopKeys.all];
|
|
88
94
|
case "chat_bookmarks":
|
|
89
95
|
return [bookmarkKeys.all];
|
|
90
96
|
default:
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
|
2
|
+
import type {
|
|
3
|
+
OperatingMode,
|
|
4
|
+
ReportResponse,
|
|
5
|
+
ReportSummary,
|
|
6
|
+
} from "@/lib/reports/types";
|
|
7
|
+
|
|
8
|
+
export const reportKeys = {
|
|
9
|
+
all: ["reports"] as const,
|
|
10
|
+
task: (id: string) => [...reportKeys.all, "task", id] as const,
|
|
11
|
+
workspace: (id: string) => [...reportKeys.all, "workspace", id] as const,
|
|
12
|
+
workspaceMode: (id: string) => [...reportKeys.all, "wmode", id] as const,
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
async function request<T>(url: string, init?: RequestInit): Promise<T> {
|
|
16
|
+
const res = await fetch(url, init);
|
|
17
|
+
const data = await res.json();
|
|
18
|
+
if (!res.ok) throw new Error(data.error || "Request failed");
|
|
19
|
+
return data as T;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const json = (method: string, body: unknown): RequestInit => ({
|
|
23
|
+
method,
|
|
24
|
+
headers: { "Content-Type": "application/json" },
|
|
25
|
+
body: JSON.stringify(body),
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
const GENERATING_POLL_MS = 2000;
|
|
29
|
+
const IDLE_POLL_MS = 30_000;
|
|
30
|
+
|
|
31
|
+
export function useTaskReport(sessionId: string) {
|
|
32
|
+
return useQuery({
|
|
33
|
+
queryKey: reportKeys.task(sessionId),
|
|
34
|
+
queryFn: () => request<ReportResponse>(`/api/tasks/${sessionId}/report`),
|
|
35
|
+
refetchInterval: (q) =>
|
|
36
|
+
q.state.data?.generating ? GENERATING_POLL_MS : IDLE_POLL_MS,
|
|
37
|
+
});
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function useGenerateReport(sessionId: string) {
|
|
41
|
+
const qc = useQueryClient();
|
|
42
|
+
return useMutation({
|
|
43
|
+
mutationFn: () =>
|
|
44
|
+
request(`/api/tasks/${sessionId}/report`, json("POST", {})),
|
|
45
|
+
onSuccess: () => {
|
|
46
|
+
// The next poll sees `generating` and keeps going until it clears.
|
|
47
|
+
qc.setQueryData<ReportResponse>(reportKeys.task(sessionId), (old) =>
|
|
48
|
+
old ? { ...old, generating: true } : old
|
|
49
|
+
);
|
|
50
|
+
qc.invalidateQueries({ queryKey: reportKeys.task(sessionId) });
|
|
51
|
+
},
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export interface DraftRef {
|
|
56
|
+
source: "report" | "analyst";
|
|
57
|
+
id: string;
|
|
58
|
+
linkId?: string;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export function useReportDraft(sessionId: string) {
|
|
62
|
+
const qc = useQueryClient();
|
|
63
|
+
return useMutation({
|
|
64
|
+
mutationFn: (v: DraftRef & { action: "send" | "dismiss"; text?: string }) =>
|
|
65
|
+
request<ReportResponse>(
|
|
66
|
+
`/api/tasks/${sessionId}/report/draft`,
|
|
67
|
+
json("POST", v)
|
|
68
|
+
),
|
|
69
|
+
onSuccess: (data) => qc.setQueryData(reportKeys.task(sessionId), data),
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export function useTaskMode(sessionId: string) {
|
|
74
|
+
const qc = useQueryClient();
|
|
75
|
+
return useMutation({
|
|
76
|
+
mutationFn: (mode: OperatingMode | null) =>
|
|
77
|
+
request(`/api/tasks/${sessionId}/mode`, json("PUT", { mode })),
|
|
78
|
+
onSuccess: () =>
|
|
79
|
+
qc.invalidateQueries({ queryKey: reportKeys.task(sessionId) }),
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export function useWorkspaceMode(workspaceId: string) {
|
|
84
|
+
return useQuery({
|
|
85
|
+
queryKey: reportKeys.workspaceMode(workspaceId),
|
|
86
|
+
queryFn: async () =>
|
|
87
|
+
(
|
|
88
|
+
await request<{ mode: OperatingMode }>(
|
|
89
|
+
`/api/workspaces/${workspaceId}/mode`
|
|
90
|
+
)
|
|
91
|
+
).mode,
|
|
92
|
+
});
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export function useSetWorkspaceMode(workspaceId: string) {
|
|
96
|
+
const qc = useQueryClient();
|
|
97
|
+
return useMutation({
|
|
98
|
+
mutationFn: (mode: OperatingMode) =>
|
|
99
|
+
request(`/api/workspaces/${workspaceId}/mode`, json("PUT", { mode })),
|
|
100
|
+
onSuccess: () => qc.invalidateQueries({ queryKey: reportKeys.all }),
|
|
101
|
+
});
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export function useWorkspaceReports(workspaceId: string) {
|
|
105
|
+
return useQuery({
|
|
106
|
+
queryKey: reportKeys.workspace(workspaceId),
|
|
107
|
+
queryFn: async () =>
|
|
108
|
+
(
|
|
109
|
+
await request<{ reports: ReportSummary[] }>(
|
|
110
|
+
`/api/workspaces/${workspaceId}/reports?active=1`
|
|
111
|
+
)
|
|
112
|
+
).reports,
|
|
113
|
+
refetchInterval: IDLE_POLL_MS,
|
|
114
|
+
});
|
|
115
|
+
}
|
package/lib/agents/brief.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
// What every agent AgentOS starts is told about waiting; the note sent after
|
|
2
2
|
// AgentOS stops a spinning background job repeats it (lib/health/spin.ts).
|
|
3
3
|
export const WAIT_ADVICE =
|
|
4
|
-
"To wait for a long command, run it with run_in_background and end your turn; you're woken when it exits. Don't start a second job to poll for the first. Any wait loop must sleep on each pass and test a condition that can actually become true (pgrep for the exact process, a log line), never a fixed sleep.";
|
|
4
|
+
"To wait for a long command, run it with run_in_background and end your turn; you're woken when it exits. Don't start a second job to poll for the first. Any wait loop must sleep on each pass and test a condition that can actually become true (pgrep for the exact process, a log line), never a fixed sleep. Never wait for a port another process holds: `aos port` gives you a free one.";
|
|
5
5
|
|
|
6
6
|
// What every agent AgentOS starts is told about the network it's on.
|
|
7
7
|
export const BUS_BRIEF = `You are one of several coding agents running in AgentOS. Other sessions may be working on related projects at the same time, and you can talk to them with the \`aos\` command:
|
package/lib/analyst/forward.ts
CHANGED
|
@@ -14,7 +14,7 @@ import { AnalystError, type AnalystLink, type Recommendation } from "./types";
|
|
|
14
14
|
// so a repeat is a no-op. It waits in the queue while a turn runs.
|
|
15
15
|
export async function forwardToCoder(
|
|
16
16
|
coderSessionId: string,
|
|
17
|
-
input: { text: string; id: string; label: string; fromSessionId
|
|
17
|
+
input: { text: string; id: string; label: string; fromSessionId?: string }
|
|
18
18
|
): Promise<void> {
|
|
19
19
|
await sendChat(coderSessionId, {
|
|
20
20
|
text: input.text,
|
package/lib/db/changes.ts
CHANGED
|
@@ -54,6 +54,12 @@ export const TABLES_WATCHED_ANALYST = [
|
|
|
54
54
|
"analyst_recommendations",
|
|
55
55
|
] as const;
|
|
56
56
|
|
|
57
|
+
export const TABLES_WATCHED_LOOPS = [
|
|
58
|
+
"task_loops",
|
|
59
|
+
"task_loop_criteria",
|
|
60
|
+
"task_loop_cycles",
|
|
61
|
+
] as const;
|
|
62
|
+
|
|
57
63
|
// Every table any migration watches, for the browser's topic map to cover.
|
|
58
64
|
export const WATCHED_TABLES: readonly string[] = [
|
|
59
65
|
...TABLES_WATCHED_45,
|
|
@@ -67,6 +73,7 @@ export const WATCHED_TABLES: readonly string[] = [
|
|
|
67
73
|
...TABLES_WATCHED_SESSION_GROUPS,
|
|
68
74
|
...TABLES_WATCHED_WORK_CLAIMS,
|
|
69
75
|
...TABLES_WATCHED_ANALYST,
|
|
76
|
+
...TABLES_WATCHED_LOOPS,
|
|
70
77
|
];
|
|
71
78
|
|
|
72
79
|
export function installChangeTriggers(
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import type { Migration } from "../migration-types";
|
|
2
|
+
import { installChangeTriggers, TABLES_WATCHED_LOOPS } from "../changes";
|
|
3
|
+
|
|
4
|
+
export default {
|
|
5
|
+
key: "20261021T1600_task_loops",
|
|
6
|
+
up: (db) => {
|
|
7
|
+
// The developer-analyst loop (lib/loops): one loop per run on a task, its
|
|
8
|
+
// acceptance checklist, and one row per cycle. Rows are kept for good.
|
|
9
|
+
db.exec(`
|
|
10
|
+
CREATE TABLE IF NOT EXISTS task_loops (
|
|
11
|
+
id TEXT PRIMARY KEY,
|
|
12
|
+
session_id TEXT NOT NULL,
|
|
13
|
+
workspace_id TEXT,
|
|
14
|
+
state TEXT NOT NULL DEFAULT 'running',
|
|
15
|
+
stop_reason TEXT,
|
|
16
|
+
stop_detail TEXT,
|
|
17
|
+
goal TEXT NOT NULL DEFAULT '',
|
|
18
|
+
max_cycles INTEGER NOT NULL,
|
|
19
|
+
max_cost_usd REAL NOT NULL,
|
|
20
|
+
max_minutes INTEGER NOT NULL,
|
|
21
|
+
grace_seconds INTEGER NOT NULL,
|
|
22
|
+
stall_cycles INTEGER NOT NULL,
|
|
23
|
+
repeat_cycles INTEGER NOT NULL,
|
|
24
|
+
cost_base_usd REAL NOT NULL DEFAULT 0,
|
|
25
|
+
started_at INTEGER NOT NULL,
|
|
26
|
+
ended_at INTEGER,
|
|
27
|
+
last_cycle_at INTEGER,
|
|
28
|
+
run_now INTEGER NOT NULL DEFAULT 0
|
|
29
|
+
);
|
|
30
|
+
CREATE UNIQUE INDEX IF NOT EXISTS task_loops_live
|
|
31
|
+
ON task_loops(session_id) WHERE state = 'running';
|
|
32
|
+
CREATE INDEX IF NOT EXISTS task_loops_session
|
|
33
|
+
ON task_loops(session_id, started_at);
|
|
34
|
+
CREATE TABLE IF NOT EXISTS task_loop_criteria (
|
|
35
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
36
|
+
loop_id TEXT NOT NULL,
|
|
37
|
+
position INTEGER NOT NULL,
|
|
38
|
+
text TEXT NOT NULL,
|
|
39
|
+
source TEXT NOT NULL DEFAULT 'user',
|
|
40
|
+
evidence_kind TEXT,
|
|
41
|
+
status TEXT NOT NULL DEFAULT 'open',
|
|
42
|
+
evidence TEXT,
|
|
43
|
+
verified INTEGER NOT NULL DEFAULT 1,
|
|
44
|
+
met_cycle INTEGER,
|
|
45
|
+
waived_by TEXT,
|
|
46
|
+
removed INTEGER NOT NULL DEFAULT 0
|
|
47
|
+
);
|
|
48
|
+
CREATE INDEX IF NOT EXISTS task_loop_criteria_loop
|
|
49
|
+
ON task_loop_criteria(loop_id, position);
|
|
50
|
+
CREATE TABLE IF NOT EXISTS task_loop_cycles (
|
|
51
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
52
|
+
loop_id TEXT NOT NULL,
|
|
53
|
+
n INTEGER NOT NULL,
|
|
54
|
+
trigger TEXT NOT NULL,
|
|
55
|
+
state TEXT NOT NULL DEFAULT 'claimed',
|
|
56
|
+
snapshot_sha TEXT,
|
|
57
|
+
tree TEXT,
|
|
58
|
+
changed INTEGER NOT NULL DEFAULT 1,
|
|
59
|
+
evidence TEXT,
|
|
60
|
+
log_path TEXT,
|
|
61
|
+
analysis TEXT,
|
|
62
|
+
signature TEXT,
|
|
63
|
+
score TEXT,
|
|
64
|
+
instruction TEXT,
|
|
65
|
+
instruction_sent TEXT,
|
|
66
|
+
sender TEXT,
|
|
67
|
+
delivery TEXT,
|
|
68
|
+
send_at INTEGER,
|
|
69
|
+
input TEXT,
|
|
70
|
+
cost_usd REAL NOT NULL DEFAULT 0,
|
|
71
|
+
error TEXT,
|
|
72
|
+
started_at INTEGER NOT NULL,
|
|
73
|
+
ended_at INTEGER
|
|
74
|
+
);
|
|
75
|
+
CREATE UNIQUE INDEX IF NOT EXISTS task_loop_cycles_n
|
|
76
|
+
ON task_loop_cycles(loop_id, n);
|
|
77
|
+
`);
|
|
78
|
+
// The loop view refetches when any of them changes.
|
|
79
|
+
installChangeTriggers(db, TABLES_WATCHED_LOOPS);
|
|
80
|
+
},
|
|
81
|
+
} satisfies Migration;
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import type { Migration } from "../migration-types";
|
|
2
|
+
|
|
3
|
+
export default {
|
|
4
|
+
key: "20261021T1800_health_reports",
|
|
5
|
+
up: (db) => {
|
|
6
|
+
// Plain-English health reports (lib/reports): one row per report, the
|
|
7
|
+
// report itself as JSON, and the next-prompt draft of a task with no
|
|
8
|
+
// analyst. Rows are kept for good.
|
|
9
|
+
db.exec(`
|
|
10
|
+
CREATE TABLE IF NOT EXISTS health_reports (
|
|
11
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
12
|
+
session_id TEXT NOT NULL,
|
|
13
|
+
workspace_id TEXT,
|
|
14
|
+
head TEXT,
|
|
15
|
+
trigger TEXT NOT NULL,
|
|
16
|
+
evidence_hash TEXT NOT NULL,
|
|
17
|
+
data TEXT NOT NULL,
|
|
18
|
+
model TEXT,
|
|
19
|
+
cost_usd REAL,
|
|
20
|
+
draft_text TEXT,
|
|
21
|
+
draft_status TEXT,
|
|
22
|
+
draft_decided_at TEXT,
|
|
23
|
+
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
|
24
|
+
);
|
|
25
|
+
CREATE INDEX IF NOT EXISTS health_reports_session
|
|
26
|
+
ON health_reports(session_id, id);
|
|
27
|
+
-- The operating mode (manual / assisted / autonomous) of a workspace
|
|
28
|
+
-- (the default) or of one task (the override).
|
|
29
|
+
CREATE TABLE IF NOT EXISTS operating_modes (
|
|
30
|
+
scope TEXT NOT NULL,
|
|
31
|
+
scope_id TEXT NOT NULL,
|
|
32
|
+
mode TEXT NOT NULL,
|
|
33
|
+
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
34
|
+
PRIMARY KEY (scope, scope_id)
|
|
35
|
+
);
|
|
36
|
+
`);
|
|
37
|
+
},
|
|
38
|
+
} satisfies Migration;
|
package/lib/done/index.ts
CHANGED
|
@@ -33,6 +33,7 @@ import { doneOnPeer, peerSessionLink } from "../hosts/peer-actions";
|
|
|
33
33
|
import { hostLink } from "../hosts/remote-api";
|
|
34
34
|
import { doneHeldTask, isHeldRemote } from "../tasks/contract/held-calls";
|
|
35
35
|
import { captureMergedTask } from "../session-logs";
|
|
36
|
+
import { taskEnded } from "../reports/end-hook";
|
|
36
37
|
import { sessionEnded } from "../experiments/hooks";
|
|
37
38
|
import { pushMerged } from "../notify/push/events";
|
|
38
39
|
import { setPr, setTask } from "../db/session-parts";
|
|
@@ -119,6 +120,7 @@ function mark(s: Session, status: "merged" | "done", pr: TaskPR | null) {
|
|
|
119
120
|
if (status === "merged") {
|
|
120
121
|
pushMerged(s.id);
|
|
121
122
|
void captureMergedTask(s.id, { head: pr?.head });
|
|
123
|
+
taskEnded(s.id);
|
|
122
124
|
}
|
|
123
125
|
}
|
|
124
126
|
|
package/lib/drafts.ts
CHANGED
|
@@ -33,6 +33,8 @@ export interface Draft {
|
|
|
33
33
|
// binding picks the model; roleModel overrides it for this session.
|
|
34
34
|
agentRole?: string | null;
|
|
35
35
|
roleModel?: string | null;
|
|
36
|
+
// Pair it with an analyst that reviews each turn (a local chat only).
|
|
37
|
+
withAnalyst?: boolean;
|
|
36
38
|
// A task's priority in the queue; unset: normal.
|
|
37
39
|
taskPriority?: Priority;
|
|
38
40
|
createdAt: number;
|
package/lib/export/markdown.ts
CHANGED
|
@@ -25,7 +25,10 @@ const quote = (s: string) =>
|
|
|
25
25
|
.map((l) => (l ? `> ${l}` : ">"))
|
|
26
26
|
.join("\n");
|
|
27
27
|
|
|
28
|
-
|
|
28
|
+
export const UNAVAILABLE = "[output unavailable]";
|
|
29
|
+
export const UNAVAILABLE_RUNNING = "[output unavailable: tool still running]";
|
|
30
|
+
|
|
31
|
+
export function itemMd(i: ExportItem): string {
|
|
29
32
|
const t = clock(i.at);
|
|
30
33
|
switch (i.kind) {
|
|
31
34
|
case "user": {
|
|
@@ -59,6 +62,9 @@ function itemMd(i: ExportItem): string {
|
|
|
59
62
|
);
|
|
60
63
|
if (i.diff?.patch) parts.push(fenced(i.diff.patch, "diff"));
|
|
61
64
|
if (i.output) parts.push(`Output:\n\n${fenced(i.output)}`);
|
|
65
|
+
else if (i.status === "running") parts.push(UNAVAILABLE_RUNNING);
|
|
66
|
+
else if (i.status === "stopped" || i.status === "error")
|
|
67
|
+
parts.push(UNAVAILABLE);
|
|
62
68
|
return parts.join("\n\n");
|
|
63
69
|
}
|
|
64
70
|
case "command_output":
|