@saadnvd1/agent-os 0.3.488 → 0.3.493
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 +32 -2
- package/components/Chat/ChatPanel.tsx +8 -2
- package/components/Chat/Tools.tsx +9 -3
- package/components/SessionList/OrchestratorPinRow.test.ts +2 -0
- package/components/SessionList/OrchestratorPinRow.tsx +36 -0
- package/components/SessionList/RowContext.tsx +4 -0
- package/components/SessionList/SessionRow.tsx +17 -4
- package/components/SessionList/SessionShelves.tsx +8 -2
- package/components/SessionList/SidebarBody.tsx +17 -1
- package/components/SessionList/index.tsx +4 -0
- package/components/SessionList/useSidebarData.ts +64 -18
- package/data/chat/useChat.ts +12 -1
- package/data/push/topics.test.ts +9 -0
- package/data/push/topics.ts +3 -14
- package/lib/chat/bodyCache.ts +69 -0
- package/lib/chat/store.ts +89 -0
- package/lib/chat/worker/host.ts +11 -3
- package/lib/orchestrator/brief.ts +1 -1
- package/lib/orchestrator/shell-guard.ts +536 -0
- package/lib/orchestrator/shell-parse.ts +289 -0
- package/lib/orchestrator/tool-names.ts +13 -22
- package/lib/orchestrator/write-guard.ts +54 -57
- package/lib/sidebar/queued.ts +3 -0
- package/lib/sidebar/rollup.ts +58 -0
- package/lib/sidebar/shelves.ts +24 -0
- package/lib/tasks/queue.ts +3 -0
- package/package.json +1 -1
- package/stores/sidebarUi.ts +15 -3
- package/lib/orchestrator/git-guard.ts +0 -201
package/README.md
CHANGED
|
@@ -920,7 +920,13 @@ other sessions. Sessions sit on shelves: **Pinned** (pin from a row's ⋯
|
|
|
920
920
|
menu), **Needs you** (an approval, a question, a terminal waiting for input, a
|
|
921
921
|
message typed and never sent, a failed task, or the orchestrator's open asks,
|
|
922
922
|
each with a badge), **Working**,
|
|
923
|
-
and **Done
|
|
923
|
+
and **Done**. Done is folded to one row, "3 done today · 12 earlier", until you
|
|
924
|
+
tap it (open or folded is remembered per device); open, it lists newest first,
|
|
925
|
+
ten at a time. The orchestrator's row carries small chips for the work it
|
|
926
|
+
started: running, in review, blocked and queued, each left out at zero. Tap
|
|
927
|
+
them to show only that orchestrator's work on the same shelves; the "Showing
|
|
928
|
+
… work ×" chip at the top clears it (it resets on reload). A session an
|
|
929
|
+
orchestrator started has a small orchestrator icon on its row. A purple dot marks a session that
|
|
924
930
|
finished or changed since you last opened it. Search matches
|
|
925
931
|
titles and project names, and the project filter narrows the list to one
|
|
926
932
|
project and holds that project's own actions: new session, terminal, dev
|
|
@@ -1059,7 +1065,31 @@ it may do on its own, and the lines that always come back to you as asks.
|
|
|
1059
1065
|
arrives marked as from that orchestrator and fenced as untrusted, so it can't
|
|
1060
1066
|
approve, answer an ask or pass a gate. The route
|
|
1061
1067
|
they call answers only the orchestrator's worker, by a per-orchestrator
|
|
1062
|
-
secret.
|
|
1068
|
+
secret.
|
|
1069
|
+
- **Shell:** it runs anything without asking, so an orchestrator can read and
|
|
1070
|
+
investigate on its own: `gh pr list`, `gh run view --log-failed`, `gh api`
|
|
1071
|
+
GETs, `curl`, `sqlite3 -readonly`, git in any repository, a skill's scripts,
|
|
1072
|
+
pipes and chains. Its chat worker refuses a deny list before a command runs,
|
|
1073
|
+
naming the tool to use instead: merging, closing or retargeting a PR
|
|
1074
|
+
(`gh pr merge`, `gh pr close`, `gh pr edit --base`, and `gh api` or `curl`
|
|
1075
|
+
calls that merge a pull request or PATCH or PUT one; use `sign_off`, `arm`,
|
|
1076
|
+
`land` or `drop`), `git push` (`quick_pr` pushes), writing in a project
|
|
1077
|
+
repository or task worktree other than its own open quick changes (git
|
|
1078
|
+
commit, reset, checkout, rebase, merge, stash, clean or rm there, and `rm`,
|
|
1079
|
+
`mv`, `cp`, `tee` or a redirect into one), writing AgentOS's state (`sqlite3`
|
|
1080
|
+
on its database without `-readonly` or with INSERT, UPDATE, DELETE, DROP,
|
|
1081
|
+
ALTER or ATTACH; files under `~/.agent-os` outside its folder), stopping
|
|
1082
|
+
AgentOS or its tmux (`pkill`/`kill` aimed at it, `tmux kill-*`, a
|
|
1083
|
+
`launchctl` or `serviceman` restart), and reading secret files (the same
|
|
1084
|
+
list as the write guard) or tokenvault (`tv get`, `tv dump`). It reads each
|
|
1085
|
+
command line the way a shell would, so these are caught inside pipes,
|
|
1086
|
+
chains and `$( )`, behind `bash -c`, `sh -c`, `env`, `sudo`, `xargs`,
|
|
1087
|
+
`find -exec` or an absolute path, and after a `cd` or `git -C`. **The deny
|
|
1088
|
+
list catches honest mistakes, not a determined bypass:** a script or
|
|
1089
|
+
`python -c` can do any of these where no parser sees it, and an
|
|
1090
|
+
orchestrator reads other sessions' text, which can carry planted
|
|
1091
|
+
instructions. The trade-off was chosen on 2026-10-11, for an orchestrator
|
|
1092
|
+
that does its own investigating instead of handing it to him.
|
|
1063
1093
|
- **Triage:** its brief has it decide each job: small and clear is a task with
|
|
1064
1094
|
`plan: false` and the steps written into the brief; big, unclear or
|
|
1065
1095
|
cross-cutting is `plan: true`; work outside a repository is a
|
|
@@ -226,6 +226,7 @@ export function ChatPanel({
|
|
|
226
226
|
earlierTasks,
|
|
227
227
|
toolBodies,
|
|
228
228
|
requestToolBody,
|
|
229
|
+
pinToolBody,
|
|
229
230
|
state,
|
|
230
231
|
connected,
|
|
231
232
|
commands,
|
|
@@ -364,8 +365,13 @@ export function ChatPanel({
|
|
|
364
365
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
365
366
|
const rowState = useMemo(() => new RowStore(), [sessionId]);
|
|
366
367
|
const bodies = useMemo(
|
|
367
|
-
() => ({
|
|
368
|
-
|
|
368
|
+
() => ({
|
|
369
|
+
bodies: toolBodies,
|
|
370
|
+
request: requestToolBody,
|
|
371
|
+
pin: pinToolBody,
|
|
372
|
+
connected,
|
|
373
|
+
}),
|
|
374
|
+
[toolBodies, requestToolBody, pinToolBody, connected]
|
|
369
375
|
);
|
|
370
376
|
const itemsById = useMemo(
|
|
371
377
|
() => new Map(items.map((i) => [i.id, i])),
|
|
@@ -5,6 +5,7 @@ import { Check, ChevronRight, Loader2, Square, X } from "lucide-react";
|
|
|
5
5
|
import type { FileDiff, ToolBody } from "@/lib/chat/events";
|
|
6
6
|
import type { ToolItem } from "@/lib/chat/group";
|
|
7
7
|
import { lineDiff, shortPath, type DiffLine } from "@/lib/chat/diff";
|
|
8
|
+
import { needsBody } from "@/lib/chat/bodyCache";
|
|
8
9
|
import { cn } from "@/lib/utils";
|
|
9
10
|
import { formatElapsed } from "@/lib/chat/elapsed";
|
|
10
11
|
import { Highlighted } from "./Code";
|
|
@@ -16,16 +17,21 @@ import { useRowState } from "./rowState";
|
|
|
16
17
|
export const ToolBodies = createContext<{
|
|
17
18
|
bodies: Record<string, ToolBody | null>;
|
|
18
19
|
request: (id: string) => void;
|
|
20
|
+
// Marks a body as on screen until the returned cleanup runs.
|
|
21
|
+
pin: (id: string) => () => void;
|
|
19
22
|
connected: boolean;
|
|
20
|
-
}>({ bodies: {}, request: () => {}, connected: false });
|
|
23
|
+
}>({ bodies: {}, request: () => {}, pin: () => () => {}, connected: false });
|
|
21
24
|
|
|
22
25
|
// A tool call's output and diff: its own, or once loaded. `undefined` while
|
|
23
26
|
// it loads.
|
|
24
27
|
export function useToolBody(tool: ToolItem): ToolBody | null | undefined {
|
|
25
|
-
const { bodies, request, connected } = useContext(ToolBodies);
|
|
28
|
+
const { bodies, request, pin, connected } = useContext(ToolBodies);
|
|
26
29
|
const body = tool.deferred ? bodies[tool.id] : tool;
|
|
27
30
|
useEffect(() => {
|
|
28
|
-
if (tool.deferred
|
|
31
|
+
if (tool.deferred) return pin(tool.id);
|
|
32
|
+
}, [tool.deferred, tool.id, pin]);
|
|
33
|
+
useEffect(() => {
|
|
34
|
+
if (needsBody(tool.deferred, body, connected)) request(tool.id);
|
|
29
35
|
}, [tool.deferred, tool.id, body, request, connected]);
|
|
30
36
|
return body;
|
|
31
37
|
}
|
|
@@ -6,6 +6,9 @@ import { Button } from "@/components/ui/button";
|
|
|
6
6
|
import * as DM from "@/components/ui/dropdown-menu";
|
|
7
7
|
import * as CM from "@/components/ui/context-menu";
|
|
8
8
|
import { sameRow, type SidebarRow } from "@/lib/sidebar/shelves";
|
|
9
|
+
import { visibleChips } from "@/lib/sidebar/rollup";
|
|
10
|
+
import { sidebarUi, sidebarUiActions } from "@/stores/sidebarUi";
|
|
11
|
+
import { useSnapshot } from "valtio";
|
|
9
12
|
import { orchestratorRowText } from "@/lib/sidebar/orchestrator-row";
|
|
10
13
|
import { cn } from "@/lib/utils";
|
|
11
14
|
import { orchestratorOpenActions } from "@/stores/orchestratorOpen";
|
|
@@ -36,6 +39,9 @@ export const OrchestratorPinRow = memo(function OrchestratorPinRow({
|
|
|
36
39
|
inReview: overview?.inReview ?? 0,
|
|
37
40
|
queued: ctx.queuedByWorkspace.get(ws) ?? 0,
|
|
38
41
|
});
|
|
42
|
+
const chips = visibleChips(ctx.rollups.get(session.id));
|
|
43
|
+
const { orchestratorId } = useSnapshot(sidebarUi);
|
|
44
|
+
const filtering = orchestratorId === session.id;
|
|
39
45
|
const label = ctx.workspaceNames.get(ws) ?? "Orchestrator";
|
|
40
46
|
const summary = asks ? `${status} · ${asks}` : status;
|
|
41
47
|
const menu = (kind: "dropdown" | "context") => (
|
|
@@ -100,6 +106,36 @@ export const OrchestratorPinRow = memo(function OrchestratorPinRow({
|
|
|
100
106
|
</>
|
|
101
107
|
)}
|
|
102
108
|
</span>
|
|
109
|
+
{chips.length > 0 && (
|
|
110
|
+
<button
|
|
111
|
+
type="button"
|
|
112
|
+
aria-pressed={filtering}
|
|
113
|
+
title={`Show only ${label}'s work`}
|
|
114
|
+
onClick={(e) => {
|
|
115
|
+
e.stopPropagation();
|
|
116
|
+
sidebarUiActions.setOrchestrator(
|
|
117
|
+
filtering ? null : session.id
|
|
118
|
+
);
|
|
119
|
+
}}
|
|
120
|
+
className={cn(
|
|
121
|
+
"text-muted-foreground -mx-1 mt-0.5 flex min-h-6 flex-wrap items-center gap-x-2.5 rounded px-1 text-xs",
|
|
122
|
+
"hover:bg-muted hover:text-foreground md:min-h-5",
|
|
123
|
+
filtering && "bg-muted text-foreground"
|
|
124
|
+
)}
|
|
125
|
+
>
|
|
126
|
+
{chips.map((c) => (
|
|
127
|
+
<span key={c.key} className="flex items-center gap-1">
|
|
128
|
+
<span
|
|
129
|
+
aria-hidden
|
|
130
|
+
className={cn("h-1.5 w-1.5 rounded-full", c.dot)}
|
|
131
|
+
/>
|
|
132
|
+
<span className="tabular-nums">
|
|
133
|
+
{ctx.rollups.get(session.id)![c.key]} {c.label}
|
|
134
|
+
</span>
|
|
135
|
+
</span>
|
|
136
|
+
))}
|
|
137
|
+
</button>
|
|
138
|
+
)}
|
|
103
139
|
</>
|
|
104
140
|
)}
|
|
105
141
|
</span>
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
import { createContext, useContext } from "react";
|
|
4
4
|
import type { ProjectWithRepositories } from "@/lib/projects";
|
|
5
|
+
import type { Rollup } from "@/lib/sidebar/rollup";
|
|
5
6
|
import type { OrchestratorOverview } from "@/lib/orchestrator/overview";
|
|
6
7
|
|
|
7
8
|
// What every row needs from the list, so shelves don't pass it along.
|
|
@@ -14,6 +15,9 @@ export interface RowContextValue {
|
|
|
14
15
|
orchestrators: OrchestratorOverview[];
|
|
15
16
|
runningByWorkspace: Map<string, number>;
|
|
16
17
|
queuedByWorkspace: Map<string, number>;
|
|
18
|
+
// What each orchestrator session started, and its name (its workspace's).
|
|
19
|
+
rollups: Map<string, Rollup>;
|
|
20
|
+
orchestratorNames: Map<string, string>;
|
|
17
21
|
// Render order, for shift-click range selection.
|
|
18
22
|
orderedIds: () => string[];
|
|
19
23
|
cardUrl: (sessionId: string) => string | null | undefined;
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
import { memo, useState } from "react";
|
|
4
4
|
import { useSnapshot } from "valtio";
|
|
5
|
-
import { CheckSquare, GitFork, Square } from "lucide-react";
|
|
5
|
+
import { CheckSquare, GitFork, Square, Workflow } from "lucide-react";
|
|
6
6
|
import { compactTimeAgo, fromSqliteTime } from "@/lib/session-meta";
|
|
7
7
|
import { sameRow, type SidebarRow } from "@/lib/sidebar/shelves";
|
|
8
8
|
import { useMinuteTick } from "@/hooks/useMinuteTick";
|
|
@@ -33,6 +33,9 @@ export const SessionRow = memo(function SessionRow({
|
|
|
33
33
|
const [renaming, setRenaming] = useState(false);
|
|
34
34
|
const { session } = row;
|
|
35
35
|
const active = session.id === ctx.activeSessionId;
|
|
36
|
+
const viaName = session.started_by
|
|
37
|
+
? ctx.orchestratorNames.get(session.started_by)
|
|
38
|
+
: undefined;
|
|
36
39
|
const selecting = selectedIds.size > 0;
|
|
37
40
|
const selected = selectedIds.has(session.id);
|
|
38
41
|
const onClick = (e: React.MouseEvent) => {
|
|
@@ -76,9 +79,19 @@ export const SessionRow = memo(function SessionRow({
|
|
|
76
79
|
)
|
|
77
80
|
}
|
|
78
81
|
aside={
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
+
<>
|
|
83
|
+
{viaName && (
|
|
84
|
+
<span title={`Started by ${viaName}`} className="shrink-0">
|
|
85
|
+
<Workflow
|
|
86
|
+
aria-label={`Via orchestrator: ${viaName}`}
|
|
87
|
+
className="text-muted-foreground/60 h-3 w-3"
|
|
88
|
+
/>
|
|
89
|
+
</span>
|
|
90
|
+
)}
|
|
91
|
+
{session.parent_session_id && (
|
|
92
|
+
<GitFork className="text-muted-foreground/60 h-3 w-3 shrink-0" />
|
|
93
|
+
)}
|
|
94
|
+
</>
|
|
82
95
|
}
|
|
83
96
|
trailing={
|
|
84
97
|
<>
|
|
@@ -2,7 +2,12 @@
|
|
|
2
2
|
|
|
3
3
|
import type { ReactNode } from "react";
|
|
4
4
|
import { ChevronDown } from "lucide-react";
|
|
5
|
-
import {
|
|
5
|
+
import {
|
|
6
|
+
DONE_PAGE,
|
|
7
|
+
doneLimit,
|
|
8
|
+
doneSummary,
|
|
9
|
+
type Shelves,
|
|
10
|
+
} from "@/lib/sidebar/shelves";
|
|
6
11
|
import type { MachineGroup } from "@/lib/sidebar/machines";
|
|
7
12
|
import { sidebarUiActions } from "@/stores/sidebarUi";
|
|
8
13
|
import { cn } from "@/lib/utils";
|
|
@@ -97,6 +102,7 @@ export function SessionShelves({
|
|
|
97
102
|
}) {
|
|
98
103
|
const shown = shelves.done.slice(0, doneLimit(donePages));
|
|
99
104
|
const hidden = shelves.done.length - shown.length;
|
|
105
|
+
const summary = doneSummary(shelves.done).text;
|
|
100
106
|
return (
|
|
101
107
|
<>
|
|
102
108
|
{shelves.orchestrators.length + toStart.length > 0 && (
|
|
@@ -140,7 +146,7 @@ export function SessionShelves({
|
|
|
140
146
|
{shelves.done.length > 0 && (
|
|
141
147
|
<section aria-label="Done">
|
|
142
148
|
<ShelfLabel
|
|
143
|
-
label="Done"
|
|
149
|
+
label={doneCollapsed ? summary : "Done"}
|
|
144
150
|
collapsed={doneCollapsed}
|
|
145
151
|
onToggle={sidebarUiActions.toggleDone}
|
|
146
152
|
/>
|
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
|
|
3
|
-
import { AlertCircle, FolderPlus, Plus } from "lucide-react";
|
|
3
|
+
import { AlertCircle, FolderPlus, Plus, X } from "lucide-react";
|
|
4
4
|
import { Button } from "@/components/ui/button";
|
|
5
5
|
import { ShimmeringLoader } from "@/components/ui/skeleton";
|
|
6
|
+
import { sidebarUiActions } from "@/stores/sidebarUi";
|
|
6
7
|
import type { SidebarData } from "./useSidebarData";
|
|
7
8
|
import { MachineShelves, SessionShelves } from "./SessionShelves";
|
|
8
9
|
import { SidebarExtras } from "./SidebarExtras";
|
|
@@ -71,6 +72,21 @@ export function SidebarBody({
|
|
|
71
72
|
|
|
72
73
|
return (
|
|
73
74
|
<>
|
|
75
|
+
{ui.orchestratorId && (
|
|
76
|
+
<button
|
|
77
|
+
type="button"
|
|
78
|
+
onClick={() => sidebarUiActions.setOrchestrator(null)}
|
|
79
|
+
aria-label="Clear the orchestrator filter"
|
|
80
|
+
className="bg-muted hover:bg-muted/70 text-foreground mx-2.5 mt-2 flex min-h-8 max-w-[calc(100%-1.25rem)] items-center gap-1.5 rounded-full px-3 text-xs md:min-h-7"
|
|
81
|
+
>
|
|
82
|
+
<span className="truncate">
|
|
83
|
+
Showing{" "}
|
|
84
|
+
{data.orchestratorNames.get(ui.orchestratorId) ?? "orchestrator"}
|
|
85
|
+
’s work
|
|
86
|
+
</span>
|
|
87
|
+
<X className="h-3 w-3 shrink-0" />
|
|
88
|
+
</button>
|
|
89
|
+
)}
|
|
74
90
|
<DraftShelf projects={data.projects} />
|
|
75
91
|
{count === 0 &&
|
|
76
92
|
(ui.query ? (
|
|
@@ -110,6 +110,8 @@ export function SessionList({
|
|
|
110
110
|
orchestrators,
|
|
111
111
|
runningByWorkspace: data.runningByWorkspace,
|
|
112
112
|
queuedByWorkspace: data.queuedByWorkspace,
|
|
113
|
+
rollups: data.rollupByOrchestrator,
|
|
114
|
+
orchestratorNames: data.orchestratorNames,
|
|
113
115
|
cardUrl: data.taskCardUrl,
|
|
114
116
|
}),
|
|
115
117
|
[
|
|
@@ -122,6 +124,8 @@ export function SessionList({
|
|
|
122
124
|
orchestrators,
|
|
123
125
|
data.runningByWorkspace,
|
|
124
126
|
data.queuedByWorkspace,
|
|
127
|
+
data.rollupByOrchestrator,
|
|
128
|
+
data.orchestratorNames,
|
|
125
129
|
data.taskCardUrl,
|
|
126
130
|
]
|
|
127
131
|
);
|
|
@@ -15,6 +15,7 @@ import { useSessionsQuery } from "@/data/sessions";
|
|
|
15
15
|
import { useLinkedHostNames } from "@/data/hosts";
|
|
16
16
|
import { useProjectsQuery } from "@/data/projects";
|
|
17
17
|
import { useQueuedTasksQuery, useTasksQuery } from "@/data/tasks";
|
|
18
|
+
import { rollups, type Rollup } from "@/lib/sidebar/rollup";
|
|
18
19
|
import { queuedRows } from "@/lib/sidebar/queued";
|
|
19
20
|
import { useSelectedWorkspace } from "@/hooks/useSelectedWorkspace";
|
|
20
21
|
import { useOrchestratorsQuery } from "@/data/orchestrators";
|
|
@@ -69,7 +70,7 @@ export function useSidebarData(
|
|
|
69
70
|
);
|
|
70
71
|
const project = workspaceProjects.find((p) => p.id === ui.projectId) ?? null;
|
|
71
72
|
|
|
72
|
-
const { shelves, machines } = useMemo(() => {
|
|
73
|
+
const { all, shelves, machines } = useMemo(() => {
|
|
73
74
|
const byId = new Map(projects.map((p) => [p.id, p]));
|
|
74
75
|
const workspaceName = new Map(workspaces.map((w) => [w.id, w.name]));
|
|
75
76
|
const taskStates: Record<string, TaskState> = {};
|
|
@@ -84,23 +85,32 @@ export function useSidebarData(
|
|
|
84
85
|
projectName,
|
|
85
86
|
query: ui.query,
|
|
86
87
|
};
|
|
88
|
+
const input = {
|
|
89
|
+
...common,
|
|
90
|
+
sessions: sessions.filter(
|
|
91
|
+
(s) =>
|
|
92
|
+
!onMachineOnly(s, linked) &&
|
|
93
|
+
inWorkspace(
|
|
94
|
+
s,
|
|
95
|
+
(id) => byId.get(id)?.workspace_id,
|
|
96
|
+
workspace?.id ?? null
|
|
97
|
+
)
|
|
98
|
+
),
|
|
99
|
+
projectId: project?.id ?? null,
|
|
100
|
+
currentOrchestrators,
|
|
101
|
+
};
|
|
102
|
+
// The rollups count the whole list; the filter only narrows what shows.
|
|
103
|
+
const all = buildShelves(input);
|
|
87
104
|
return {
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
)
|
|
98
|
-
),
|
|
99
|
-
projectId: project?.id ?? null,
|
|
100
|
-
currentOrchestrators,
|
|
101
|
-
}),
|
|
102
|
-
// Not in any project, so not under a chosen one.
|
|
103
|
-
machines: project ? [] : machineGroups({ ...common, sessions }, linked),
|
|
105
|
+
all,
|
|
106
|
+
shelves: ui.orchestratorId
|
|
107
|
+
? buildShelves({ ...input, startedBy: ui.orchestratorId })
|
|
108
|
+
: all,
|
|
109
|
+
// Not in any project, nor started by an orchestrator.
|
|
110
|
+
machines:
|
|
111
|
+
project || ui.orchestratorId
|
|
112
|
+
? []
|
|
113
|
+
: machineGroups({ ...common, sessions }, linked),
|
|
104
114
|
};
|
|
105
115
|
}, [
|
|
106
116
|
linked,
|
|
@@ -113,6 +123,7 @@ export function useSidebarData(
|
|
|
113
123
|
workspace,
|
|
114
124
|
project,
|
|
115
125
|
ui.query,
|
|
126
|
+
ui.orchestratorId,
|
|
116
127
|
]);
|
|
117
128
|
|
|
118
129
|
// Per workspace, how many of its project sessions are working: the
|
|
@@ -150,7 +161,7 @@ export function useSidebarData(
|
|
|
150
161
|
);
|
|
151
162
|
|
|
152
163
|
// Tasks waiting to start, in their line's order.
|
|
153
|
-
const
|
|
164
|
+
const queuedAll = useMemo(
|
|
154
165
|
() =>
|
|
155
166
|
queuedRows({
|
|
156
167
|
queue,
|
|
@@ -161,6 +172,39 @@ export function useSidebarData(
|
|
|
161
172
|
}),
|
|
162
173
|
[queue, sessions, workspace, project, ui.query]
|
|
163
174
|
);
|
|
175
|
+
const queued = useMemo(
|
|
176
|
+
() =>
|
|
177
|
+
ui.orchestratorId
|
|
178
|
+
? queuedAll.filter((r) => r.item.startedBy === ui.orchestratorId)
|
|
179
|
+
: queuedAll,
|
|
180
|
+
[queuedAll, ui.orchestratorId]
|
|
181
|
+
);
|
|
182
|
+
|
|
183
|
+
// Each orchestrator's started work, keyed by its counts like the ones
|
|
184
|
+
// above so a push that changed none of them redraws no row.
|
|
185
|
+
const rollupKey = useMemo(
|
|
186
|
+
() => JSON.stringify([...rollups(all, queuedAll)].sort()),
|
|
187
|
+
[all, queuedAll]
|
|
188
|
+
);
|
|
189
|
+
const rollupByOrchestrator = useMemo(
|
|
190
|
+
() => new Map(JSON.parse(rollupKey) as [string, Rollup][]),
|
|
191
|
+
[rollupKey]
|
|
192
|
+
);
|
|
193
|
+
|
|
194
|
+
// Orchestrator session id -> the workspace it serves, for tags and chips.
|
|
195
|
+
const orchestratorKey = JSON.stringify(
|
|
196
|
+
sessions
|
|
197
|
+
.filter((s) => s.role === "orchestrator")
|
|
198
|
+
.map((s) => [
|
|
199
|
+
s.id,
|
|
200
|
+
workspaces.find((w) => w.id === s.workspace_id)?.name ?? s.name,
|
|
201
|
+
])
|
|
202
|
+
.sort()
|
|
203
|
+
);
|
|
204
|
+
const orchestratorNames = useMemo(
|
|
205
|
+
() => new Map(JSON.parse(orchestratorKey) as [string, string][]),
|
|
206
|
+
[orchestratorKey]
|
|
207
|
+
);
|
|
164
208
|
|
|
165
209
|
const taskCardUrl = useCallback(
|
|
166
210
|
(id: string) => tasks.find((t) => t.id === id)?.cardUrl,
|
|
@@ -194,6 +238,8 @@ export function useSidebarData(
|
|
|
194
238
|
projectNames,
|
|
195
239
|
runningByWorkspace,
|
|
196
240
|
queuedByWorkspace,
|
|
241
|
+
rollupByOrchestrator,
|
|
242
|
+
orchestratorNames,
|
|
197
243
|
workspaces,
|
|
198
244
|
workspace,
|
|
199
245
|
workspaceProjects,
|
package/data/chat/useChat.ts
CHANGED
|
@@ -19,6 +19,7 @@ import type {
|
|
|
19
19
|
UndoPreview,
|
|
20
20
|
} from "@/lib/chat/events";
|
|
21
21
|
import type { SearchResult } from "@/lib/chat/find";
|
|
22
|
+
import { addBody, touchBody } from "@/lib/chat/bodyCache";
|
|
22
23
|
|
|
23
24
|
// The index of the item with `id`, searched from the end: what changes is
|
|
24
25
|
// almost always the latest.
|
|
@@ -129,6 +130,13 @@ export function useChat(
|
|
|
129
130
|
const [toolBodies, setToolBodies] = useState<Record<string, ToolBody | null>>(
|
|
130
131
|
{}
|
|
131
132
|
);
|
|
133
|
+
// Bodies open on screen, never evicted from toolBodies.
|
|
134
|
+
const visibleBodies = useRef(new Set<string>());
|
|
135
|
+
const pinToolBody = useCallback((id: string) => {
|
|
136
|
+
visibleBodies.current.add(id);
|
|
137
|
+
setToolBodies((prev) => touchBody(prev, id));
|
|
138
|
+
return () => void visibleBodies.current.delete(id);
|
|
139
|
+
}, []);
|
|
132
140
|
const [state, setState] = useState<ChatState>("idle");
|
|
133
141
|
const [connected, setConnected] = useState(false);
|
|
134
142
|
const [commands, setCommands] = useState<ChatCommand[]>([]);
|
|
@@ -249,7 +257,9 @@ export function useChat(
|
|
|
249
257
|
const have = new Set(prev.map((p) => p.id));
|
|
250
258
|
setItems(m.items.filter((i) => !have.has(i.id)).concat(prev));
|
|
251
259
|
} else if (m.type === "tool_body") {
|
|
252
|
-
setToolBodies((prev) =>
|
|
260
|
+
setToolBodies((prev) =>
|
|
261
|
+
addBody(prev, m.id, m.body, visibleBodies.current)
|
|
262
|
+
);
|
|
253
263
|
} else if (m.type === "item") {
|
|
254
264
|
withDeltas(() => {
|
|
255
265
|
const routed = routeItem(
|
|
@@ -457,6 +467,7 @@ export function useChat(
|
|
|
457
467
|
earlierTasks,
|
|
458
468
|
toolBodies,
|
|
459
469
|
requestToolBody,
|
|
470
|
+
pinToolBody,
|
|
460
471
|
state,
|
|
461
472
|
connected,
|
|
462
473
|
commands,
|
package/data/push/topics.test.ts
CHANGED
|
@@ -2,6 +2,7 @@ import { QueryClient } from "@tanstack/react-query";
|
|
|
2
2
|
import { describe, expect, it } from "vitest";
|
|
3
3
|
import { WATCHED_TABLES } from "@/lib/db/changes";
|
|
4
4
|
import { gitKeys } from "../git/keys";
|
|
5
|
+
import { taskKeys } from "../tasks/keys";
|
|
5
6
|
import { sessionKeys } from "../sessions/keys";
|
|
6
7
|
import { ALL_TOPICS, invalidateTopics, topicKeys } from "./topics";
|
|
7
8
|
|
|
@@ -16,6 +17,14 @@ describe("pushed topics", () => {
|
|
|
16
17
|
expect(topicKeys(topic), topic).not.toEqual([]);
|
|
17
18
|
});
|
|
18
19
|
|
|
20
|
+
it("a snapshot refetches the queue and every other watched table", () => {
|
|
21
|
+
for (const table of WATCHED_TABLES) expect(ALL_TOPICS).toContain(table);
|
|
22
|
+
const client = new QueryClient();
|
|
23
|
+
client.setQueryData(taskKeys.queue(), []);
|
|
24
|
+
invalidateTopics(client, ALL_TOPICS);
|
|
25
|
+
expect(client.getQueryState(taskKeys.queue())?.isInvalidated).toBe(true);
|
|
26
|
+
});
|
|
27
|
+
|
|
19
28
|
it("maps a git folder and a session's setup to their queries", () => {
|
|
20
29
|
expect(topicKeys("git:~/app")).toContainEqual(gitKeys.status("~/app"));
|
|
21
30
|
expect(topicKeys("git:~/app")).toContainEqual([
|
package/data/push/topics.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { QueryClient, QueryKey } from "@tanstack/react-query";
|
|
2
|
+
import { WATCHED_TABLES } from "@/lib/db/changes";
|
|
2
3
|
import { sessionKeys } from "../sessions/keys";
|
|
3
4
|
import { taskKeys } from "../tasks/keys";
|
|
4
5
|
import { projectKeys } from "../projects/keys";
|
|
@@ -88,19 +89,7 @@ export function invalidateTopics(
|
|
|
88
89
|
}
|
|
89
90
|
|
|
90
91
|
// Every pushed topic but git folders: after a gap the stream can't replay.
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
"projects",
|
|
94
|
-
"workspaces",
|
|
95
|
-
"hosts",
|
|
96
|
-
"discovered",
|
|
97
|
-
"stacks",
|
|
98
|
-
"bus_messages",
|
|
99
|
-
"schedules",
|
|
100
|
-
"orchestrator_asks",
|
|
101
|
-
"orchestrator_rules",
|
|
102
|
-
"away_periods",
|
|
103
|
-
"dev_servers",
|
|
104
|
-
];
|
|
92
|
+
// Every table the triggers watch is in it, so a new one can't be left out.
|
|
93
|
+
export const ALL_TOPICS = [...new Set([...WATCHED_TABLES, "discovered"])];
|
|
105
94
|
|
|
106
95
|
export { keysFor as topicKeys };
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import type { ToolBody } from "./events";
|
|
2
|
+
|
|
3
|
+
// Opened tool output kept in memory, least recently used first out.
|
|
4
|
+
export const MAX_LOADED_BODIES = 40;
|
|
5
|
+
export const MAX_LOADED_BODY_CHARS = 5_000_000;
|
|
6
|
+
|
|
7
|
+
export type Bodies = Record<string, ToolBody | null>;
|
|
8
|
+
|
|
9
|
+
export function bodySize(body: ToolBody | null): number {
|
|
10
|
+
if (!body) return 0;
|
|
11
|
+
const d = body.diff;
|
|
12
|
+
return (
|
|
13
|
+
(body.output?.length ?? 0) +
|
|
14
|
+
(d ? d.path.length + d.before.length + d.after.length : 0)
|
|
15
|
+
);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
// Object keys keep insertion order, so the first key is the least recently
|
|
19
|
+
// used. Puts `id` last, loaded or being opened again.
|
|
20
|
+
export function touchBody(bodies: Bodies, id: string): Bodies {
|
|
21
|
+
if (!(id in bodies) || Object.keys(bodies).pop() === id) return bodies;
|
|
22
|
+
const { [id]: body, ...rest } = bodies;
|
|
23
|
+
return { ...rest, [id]: body };
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// Drops the least recently used bodies until both limits hold. A body on
|
|
27
|
+
// screen stays, even when that leaves the cache over a limit.
|
|
28
|
+
export function capBodies(
|
|
29
|
+
bodies: Bodies,
|
|
30
|
+
visible: ReadonlySet<string>
|
|
31
|
+
): Bodies {
|
|
32
|
+
const ids = Object.keys(bodies);
|
|
33
|
+
let count = ids.length;
|
|
34
|
+
let chars = ids.reduce((n, id) => n + bodySize(bodies[id]), 0);
|
|
35
|
+
if (count <= MAX_LOADED_BODIES && chars <= MAX_LOADED_BODY_CHARS)
|
|
36
|
+
return bodies;
|
|
37
|
+
const drop = new Set<string>();
|
|
38
|
+
for (const id of ids) {
|
|
39
|
+
if (count <= MAX_LOADED_BODIES && chars <= MAX_LOADED_BODY_CHARS) break;
|
|
40
|
+
if (visible.has(id)) continue;
|
|
41
|
+
drop.add(id);
|
|
42
|
+
count--;
|
|
43
|
+
chars -= bodySize(bodies[id]);
|
|
44
|
+
}
|
|
45
|
+
return Object.fromEntries(
|
|
46
|
+
ids.filter((id) => !drop.has(id)).map((id) => [id, bodies[id]])
|
|
47
|
+
);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// A body arriving: it is the most recently used.
|
|
51
|
+
export function addBody(
|
|
52
|
+
bodies: Bodies,
|
|
53
|
+
id: string,
|
|
54
|
+
body: ToolBody | null,
|
|
55
|
+
visible: ReadonlySet<string>
|
|
56
|
+
): Bodies {
|
|
57
|
+
const { [id]: _old, ...rest } = bodies;
|
|
58
|
+
return capBodies({ ...rest, [id]: body }, visible);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// Whether an opened tool must ask for its body: deferred, not loaded (never
|
|
62
|
+
// was, or evicted), and the socket is up.
|
|
63
|
+
export function needsBody(
|
|
64
|
+
deferred: boolean | undefined,
|
|
65
|
+
body: ToolBody | null | undefined,
|
|
66
|
+
connected: boolean
|
|
67
|
+
): boolean {
|
|
68
|
+
return !!deferred && body === undefined && connected;
|
|
69
|
+
}
|