@saadnvd1/agent-os 0.3.489 → 0.3.494

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.
Files changed (38) hide show
  1. package/README.md +38 -2
  2. package/app/api/sessions/[id]/fresh/route.ts +31 -0
  3. package/components/Chat/ChatPanel.tsx +19 -3
  4. package/components/Chat/Tools.tsx +9 -3
  5. package/components/Pane/index.tsx +4 -0
  6. package/components/SessionList/OrchestratorPinRow.test.ts +3 -0
  7. package/components/SessionList/OrchestratorPinRow.tsx +36 -0
  8. package/components/SessionList/RowContext.tsx +5 -0
  9. package/components/SessionList/SessionRow.tsx +17 -4
  10. package/components/SessionList/SessionRowMenu.tsx +10 -0
  11. package/components/SessionList/SessionShelves.tsx +8 -2
  12. package/components/SessionList/SidebarBody.tsx +17 -1
  13. package/components/SessionList/index.tsx +23 -3
  14. package/components/SessionList/useSidebarData.ts +64 -18
  15. package/data/chat/useChat.ts +12 -1
  16. package/data/sessions/fresh.ts +20 -0
  17. package/data/sessions/index.ts +1 -0
  18. package/lib/chat/bodyCache.ts +69 -0
  19. package/lib/chat/commands.ts +16 -0
  20. package/lib/chat/refresh.ts +18 -0
  21. package/lib/chat/registry.ts +2 -0
  22. package/lib/chat/runner.ts +50 -0
  23. package/lib/chat/store.ts +89 -0
  24. package/lib/chat/worker/host.ts +46 -12
  25. package/lib/chat/worker/main.ts +1 -1
  26. package/lib/chat/worker/protocol.ts +4 -0
  27. package/lib/orchestrator/brief.ts +1 -1
  28. package/lib/orchestrator/shell-guard.ts +536 -0
  29. package/lib/orchestrator/shell-parse.ts +289 -0
  30. package/lib/orchestrator/tool-names.ts +13 -22
  31. package/lib/orchestrator/write-guard.ts +54 -57
  32. package/lib/sidebar/queued.ts +3 -0
  33. package/lib/sidebar/rollup.ts +58 -0
  34. package/lib/sidebar/shelves.ts +24 -0
  35. package/lib/tasks/queue.ts +3 -0
  36. package/package.json +1 -1
  37. package/stores/sidebarUi.ts +15 -3
  38. 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**, newest first, ten at a time. A purple dot marks a session that
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. Its shell runs only `aos` commands that read.
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
@@ -1191,6 +1221,12 @@ it may do on its own, and the lines that always come back to you as asks.
1191
1221
  Nothing is lost, because its state lives in the database; its chat goes on,
1192
1222
  with a line where it started over. The same can be switched on for chat
1193
1223
  tasks (off by default: a task keeps only its brief and its branch).
1224
+ **Start fresh by hand:** after a deploy that changes its permissions or
1225
+ brief, use **New conversation (keep brief)** in the orchestrator's row menu or type `/fresh`
1226
+ in its chat (task chats have both too). It takes the threshold's path: an
1227
+ idle agent starts over at once; a running turn finishes first, with a
1228
+ "Starts fresh after this turn" line. The next conversation launches with
1229
+ the brief, permissions and workspace state the server has then.
1194
1230
  - **Events:** the server sends it one short line per event (a PR opened, CI
1195
1231
  failed, or green once it has settled so `sign_off`'s ci gate passes, a `BLOCKED:` line, a merge, a session needing input, a
1196
1232
  stack step, a task idle 30 minutes with no PR, a review's verdict). Events
@@ -0,0 +1,31 @@
1
+ import { NextResponse } from "next/server";
2
+ import { queries, db, type Session } from "@/lib/db";
3
+ import { FreshRefusal, freshStartChat } from "@/lib/chat/runner";
4
+
5
+ // POST /api/sessions/:id/fresh - start the agent's conversation over (the
6
+ // path the context threshold takes, lib/chat/refresh): now if it's idle,
7
+ // else at the next turn boundary. Only for chats whose state lives outside
8
+ // the conversation: an orchestrator, or a task with a brief.
9
+ export async function POST(
10
+ _request: Request,
11
+ { params }: { params: Promise<{ id: string }> }
12
+ ) {
13
+ const { id } = await params;
14
+ const session = queries.getSession(db).get(id) as Session | undefined;
15
+ if (!session)
16
+ return NextResponse.json({ error: "Session not found" }, { status: 404 });
17
+ if (session.role !== "orchestrator" && !session.task_brief)
18
+ return NextResponse.json(
19
+ { error: "Only an orchestrator or a task can start fresh" },
20
+ { status: 400 }
21
+ );
22
+ try {
23
+ return NextResponse.json({ when: await freshStartChat(id) });
24
+ } catch (error) {
25
+ const message = error instanceof Error ? error.message : String(error);
26
+ return NextResponse.json(
27
+ { error: message },
28
+ { status: error instanceof FreshRefusal ? 409 : 500 }
29
+ );
30
+ }
31
+ }
@@ -19,6 +19,8 @@ import {
19
19
  type TimelineBlock,
20
20
  } from "@/lib/chat/group";
21
21
  import type { ApprovalDecision, ChatItem } from "@/lib/chat/events";
22
+ import { isFreshCommand, withFresh } from "@/lib/chat/commands";
23
+ import { useFreshStart } from "@/data/sessions";
22
24
  import {
23
25
  escapeAction,
24
26
  GHOST_SELECTOR,
@@ -206,6 +208,7 @@ export function ChatPanel({
206
208
  sessionName,
207
209
  accessLocked = false,
208
210
  orchestratorOf,
211
+ freshStart = false,
209
212
  active,
210
213
  }: {
211
214
  sessionId: string;
@@ -214,6 +217,8 @@ export function ChatPanel({
214
217
  accessLocked?: boolean;
215
218
  // The workspace it orchestrates: its header line, Pause and asks show on top.
216
219
  orchestratorOf?: string | null;
220
+ // An orchestrator or task: /fresh starts its conversation over.
221
+ freshStart?: boolean;
217
222
  // The tab on show in the focused pane: opening it takes the caret.
218
223
  active: boolean;
219
224
  }) {
@@ -226,6 +231,7 @@ export function ChatPanel({
226
231
  earlierTasks,
227
232
  toolBodies,
228
233
  requestToolBody,
234
+ pinToolBody,
229
235
  state,
230
236
  connected,
231
237
  commands,
@@ -258,6 +264,7 @@ export function ChatPanel({
258
264
  requestFiles,
259
265
  search,
260
266
  } = useChat(sessionId, (text) => setPrefill({ text, at: Date.now() }));
267
+ const startFresh = useFreshStart();
261
268
  const { isMobile } = useViewport();
262
269
  const scrollRef = useRef<HTMLDivElement>(null);
263
270
  const listRef = useRef<HTMLDivElement>(null);
@@ -364,8 +371,13 @@ export function ChatPanel({
364
371
  // eslint-disable-next-line react-hooks/exhaustive-deps
365
372
  const rowState = useMemo(() => new RowStore(), [sessionId]);
366
373
  const bodies = useMemo(
367
- () => ({ bodies: toolBodies, request: requestToolBody, connected }),
368
- [toolBodies, requestToolBody, connected]
374
+ () => ({
375
+ bodies: toolBodies,
376
+ request: requestToolBody,
377
+ pin: pinToolBody,
378
+ connected,
379
+ }),
380
+ [toolBodies, requestToolBody, pinToolBody, connected]
369
381
  );
370
382
  const itemsById = useMemo(
371
383
  () => new Map(items.map((i) => [i.id, i])),
@@ -574,6 +586,10 @@ export function ChatPanel({
574
586
  disabled={!connected}
575
587
  placeholder={connected ? `Message ${sessionName}` : "Reconnecting…"}
576
588
  onSend={(text, images) => {
589
+ if (freshStart && isFreshCommand(text)) {
590
+ startFresh.mutate(sessionId);
591
+ return;
592
+ }
577
593
  toEnd();
578
594
  const compact = composerCompacts(text, context, {
579
595
  state,
@@ -584,7 +600,7 @@ export function ChatPanel({
584
600
  send(text, images.length ? images : undefined, compact);
585
601
  }}
586
602
  onStop={interrupt}
587
- commands={commands}
603
+ commands={freshStart ? withFresh(commands) : commands}
588
604
  onRefreshCommands={refreshCommands}
589
605
  refreshingCommands={refreshingCommands}
590
606
  models={models}
@@ -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 && body === undefined && connected) request(tool.id);
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
  }
@@ -289,6 +289,10 @@ export const Pane = memo(function Pane({
289
289
  sessionId={chat.id}
290
290
  sessionName={chat.name}
291
291
  accessLocked={chat.role === "orchestrator"}
292
+ freshStart={
293
+ (chat.role === "orchestrator" || !!chat.task_brief) &&
294
+ !chat.archived_at
295
+ }
292
296
  orchestratorOf={
293
297
  chat.role === "orchestrator" ? chat.workspace_id : null
294
298
  }
@@ -77,6 +77,8 @@ describe("OrchestratorPinRow", () => {
77
77
  orchestrators: [],
78
78
  runningByWorkspace: new Map(),
79
79
  queuedByWorkspace: new Map(),
80
+ rollups: new Map(),
81
+ orchestratorNames: new Map(),
80
82
  orderedIds: () => [],
81
83
  cardUrl: () => null,
82
84
  onSelect,
@@ -86,6 +88,7 @@ describe("OrchestratorPinRow", () => {
86
88
  onDelete: vi.fn(),
87
89
  onMoveToProject: vi.fn(),
88
90
  onPin: vi.fn(),
91
+ onFresh: vi.fn(),
89
92
  };
90
93
  const render = (over: Partial<RowContextValue> = {}, unread = false) =>
91
94
  act(async () =>
@@ -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;
@@ -25,6 +29,7 @@ export interface RowContextValue {
25
29
  onDelete: (sessionId: string) => void;
26
30
  onMoveToProject: (sessionId: string, projectId: string) => void;
27
31
  onPin: (sessionId: string, pinned: boolean) => void;
32
+ onFresh: (sessionId: string) => void;
28
33
  }
29
34
 
30
35
  const RowContext = createContext<RowContextValue | null>(null);
@@ -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
- session.parent_session_id && (
80
- <GitFork className="text-muted-foreground/60 h-3 w-3 shrink-0" />
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
  <>
@@ -15,6 +15,7 @@ import {
15
15
  Pencil,
16
16
  Pin,
17
17
  PinOff,
18
+ RotateCcw,
18
19
  Sparkles,
19
20
  Trash2,
20
21
  } from "lucide-react";
@@ -174,6 +175,15 @@ export function SessionRowMenu({
174
175
  {summarizing ? "Summarizing..." : "Fresh start"}
175
176
  </Item>
176
177
  )}
178
+ {(session.role === "orchestrator" || session.task_brief) &&
179
+ session.view === "chat" &&
180
+ !session.archived_at &&
181
+ !blocked && (
182
+ <Item onClick={() => ctx.onFresh(session.id)}>
183
+ <RotateCcw className={icon} />
184
+ New conversation (keep brief)
185
+ </Item>
186
+ )}
177
187
  {session.pr_url && (
178
188
  <Item onClick={() => window.open(session.pr_url!, "_blank")}>
179
189
  <GitPullRequest className={icon} />
@@ -2,7 +2,12 @@
2
2
 
3
3
  import type { ReactNode } from "react";
4
4
  import { ChevronDown } from "lucide-react";
5
- import { DONE_PAGE, doneLimit, type Shelves } from "@/lib/sidebar/shelves";
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
+ &rsquo;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 ? (
@@ -4,7 +4,7 @@ import { useLayoutEffect, useMemo, useRef, useState } from "react";
4
4
  import { Loader2 } from "lucide-react";
5
5
  import { ScrollArea } from "@/components/ui/scroll-area";
6
6
  import { useDocsWorkspace } from "@/data/lumifyhub/docs";
7
- import { usePinSession } from "@/data/sessions";
7
+ import { useFreshStart, usePinSession } from "@/data/sessions";
8
8
  import { useOrchestratorsQuery } from "@/data/orchestrators";
9
9
  import { docsUiActions } from "@/stores/docsUi";
10
10
  import type { SidebarRow } from "@/lib/sidebar/shelves";
@@ -47,6 +47,7 @@ export function SessionList({
47
47
  const mutations = useSessionListMutations({ onSelectSession: onSelect });
48
48
  const bulkDelete = useBulkDelete();
49
49
  const pin = usePinSession();
50
+ const fresh = useFreshStart();
50
51
  const { data: orchestrators = NO_ORCHESTRATORS } = useOrchestratorsQuery();
51
52
  const [dialog, setDialog] = useState<SidebarDialog | null>(null);
52
53
  const addProject = useAddProject();
@@ -70,9 +71,23 @@ export function SessionList({
70
71
  // Rows read their handlers through the latest render, so the context only
71
72
  // changes when what rows draw does: a status push redraws the rows it
72
73
  // touched, not the whole list.
73
- const latest = useRef({ orderedIds, onSelect, onOpenInTab, mutations, pin });
74
+ const latest = useRef({
75
+ orderedIds,
76
+ onSelect,
77
+ onOpenInTab,
78
+ mutations,
79
+ pin,
80
+ fresh,
81
+ });
74
82
  useLayoutEffect(() => {
75
- latest.current = { orderedIds, onSelect, onOpenInTab, mutations, pin };
83
+ latest.current = {
84
+ orderedIds,
85
+ onSelect,
86
+ onOpenInTab,
87
+ mutations,
88
+ pin,
89
+ fresh,
90
+ };
76
91
  });
77
92
  const canOpenInTab = !!onOpenInTab;
78
93
  const handlers = useMemo(
@@ -92,6 +107,7 @@ export function SessionList({
92
107
  latest.current.mutations.handleMoveSessionToProject(id, projectId),
93
108
  onPin: (id: string, pinned: boolean) =>
94
109
  latest.current.pin.mutate({ id, pinned }),
110
+ onFresh: (id: string) => latest.current.fresh.mutate(id),
95
111
  }),
96
112
  [canOpenInTab]
97
113
  );
@@ -110,6 +126,8 @@ export function SessionList({
110
126
  orchestrators,
111
127
  runningByWorkspace: data.runningByWorkspace,
112
128
  queuedByWorkspace: data.queuedByWorkspace,
129
+ rollups: data.rollupByOrchestrator,
130
+ orchestratorNames: data.orchestratorNames,
113
131
  cardUrl: data.taskCardUrl,
114
132
  }),
115
133
  [
@@ -122,6 +140,8 @@ export function SessionList({
122
140
  orchestrators,
123
141
  data.runningByWorkspace,
124
142
  data.queuedByWorkspace,
143
+ data.rollupByOrchestrator,
144
+ data.orchestratorNames,
125
145
  data.taskCardUrl,
126
146
  ]
127
147
  );
@@ -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
- shelves: buildShelves({
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
- // 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 queued = useMemo(
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,