@saadnvd1/agent-os 0.3.541 → 0.3.543

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 CHANGED
@@ -23,7 +23,7 @@ and check on all of it from your phone.
23
23
  - **Project config.** `agentos.json` gives each session its own ports, environment, files and a private Postgres copy. [More](docs/reference/project-config.md)
24
24
  - **Schedules and recipes.** Start work on a timer, or save a job that comes up again. [More](docs/guides/schedules.md)
25
25
  - **Plans in pieces and stacks.** A plan splits into several PRs only for a real reason; a board can run as stacked PRs. [More](docs/guides/tasks.md#plans-in-pieces)
26
- - **Machine load and status.** A load gauge, each session's share of CPU and memory, and Needs you, Working and Done as it changes. [More](docs/guides/sessions.md)
26
+ - **Machine load and status.** A load gauge, each session's share of CPU and memory, and Needs you, Working, In review and Done as it changes. [More](docs/guides/sessions.md)
27
27
  - **Workspaces.** Group projects, finish work with Done, and get a log of every merged task. [More](docs/reference/workspaces.md)
28
28
  - **LumifyHub (optional).** Boards, cards and docs for a workspace, if you want them. [More](docs/lumifyhub.md)
29
29
  - **Also:** a ⌘K palette, code search, a file picker, a git panel, dev servers, up to four panes, and dictation.
@@ -51,7 +51,7 @@ export function AwayToggle() {
51
51
  }
52
52
  disabled={setAway.isPending}
53
53
  className={cn(
54
- "relative h-11 w-11 md:h-8 md:w-8",
54
+ "relative h-11 w-11 md:h-8 md:w-8 [@media(hover:none)]:md:h-11 [@media(hover:none)]:md:w-11",
55
55
  away &&
56
56
  "bg-amber-500/20 text-amber-700 ring-1 ring-amber-500/50 hover:bg-amber-500/30 dark:text-amber-300"
57
57
  )}
@@ -37,7 +37,7 @@ export function LoadGauge() {
37
37
  size="icon-sm"
38
38
  aria-label={`Machine load ${load.level}. ${summary}`}
39
39
  title={summary}
40
- className="-ml-1.5 h-11 w-11 shrink-0 md:ml-0 md:h-8 md:w-6"
40
+ className="-ml-1.5 h-11 w-11 shrink-0 md:ml-0 md:h-8 md:w-6 [@media(hover:none)]:md:-ml-1.5 [@media(hover:none)]:md:h-11 [@media(hover:none)]:md:w-11"
41
41
  >
42
42
  <span className={cn("h-2 w-2 rounded-full", DOT[load.level])} />
43
43
  </Button>
@@ -25,7 +25,7 @@ export function ProjectFilter({
25
25
  <button
26
26
  type="button"
27
27
  className={cn(
28
- "bg-card flex min-h-11 max-w-[8.5rem] shrink-0 items-center gap-1 rounded-xl px-3 text-sm shadow-xs md:min-h-9",
28
+ "bg-card flex min-h-11 max-w-[7.5rem] shrink items-center gap-1 rounded-xl px-3 text-sm shadow-xs md:min-h-9 [@media(hover:none)]:md:min-h-11",
29
29
  current && "text-primary"
30
30
  )}
31
31
  >
@@ -65,7 +65,7 @@ export function RowShell({
65
65
  )}
66
66
  >
67
67
  {leading}
68
- <span className="flex min-w-[7rem] flex-1 flex-col leading-tight">
68
+ <span className="@container flex min-w-0 flex-1 flex-col leading-tight">
69
69
  {children}
70
70
  </span>
71
71
  {aside}
@@ -61,7 +61,7 @@ export function RowSubtitle({
61
61
  <LiveLine {...live} />
62
62
  {group && (
63
63
  <span
64
- className="text-muted-foreground/70 flex min-w-0 shrink-0 items-center gap-1 truncate text-xs"
64
+ className="text-muted-foreground/70 hidden min-w-0 shrink items-center gap-1 truncate text-xs @[14rem]:flex"
65
65
  title={group.kind === "recipe" ? "A recipe run" : "A piece of a plan"}
66
66
  >
67
67
  {group.kind === "recipe" ? (
@@ -73,7 +73,7 @@ export function RowSubtitle({
73
73
  </span>
74
74
  )}
75
75
  {(project || detail || usage) && (
76
- <span className="text-muted-foreground/70 truncate text-xs">
76
+ <span className="text-muted-foreground/70 min-w-0 truncate text-xs">
77
77
  {[usage && usageLabel(usage), detail?.slice(0, 200), project]
78
78
  .filter(Boolean)
79
79
  .join(" · ")}
@@ -12,6 +12,7 @@ export interface SessionStatus {
12
12
  unread?: boolean;
13
13
  stalled?: boolean;
14
14
  compacting?: { since: number; before?: number };
15
+ background?: number;
15
16
  prStage?: PrStage;
16
17
  }
17
18
 
@@ -48,7 +48,10 @@ function ShelfLabel({
48
48
  type="button"
49
49
  onClick={onToggle}
50
50
  aria-expanded={!collapsed}
51
- className={cn(cls, "hover:text-foreground min-h-11 w-full md:min-h-8")}
51
+ className={cn(
52
+ cls,
53
+ "hover:text-foreground min-h-11 w-full md:min-h-8 [@media(hover:none)]:md:min-h-11"
54
+ )}
52
55
  >
53
56
  {text}
54
57
  </button>
@@ -86,7 +89,7 @@ export function MachineShelves({ groups }: { groups: MachineGroup[] }) {
86
89
  }
87
90
 
88
91
  // The pinned orchestrator (or a row to start one), then Pinned, Needs you,
89
- // Working, Queued and Done: once for the whole list.
92
+ // Working, In review, Queued and Done: once for the whole list.
90
93
  export function SessionShelves({
91
94
  shelves,
92
95
  queued,
@@ -136,6 +139,13 @@ export function SessionShelves({
136
139
  ))}
137
140
  </Shelf>
138
141
  )}
142
+ {shelves.inReview.length > 0 && (
143
+ <Shelf label="In review" count={shelves.inReview.length}>
144
+ {shelves.inReview.map((r) => (
145
+ <SessionRow key={r.session.id} row={r} />
146
+ ))}
147
+ </Shelf>
148
+ )}
139
149
  {queued.length > 0 && (
140
150
  <Shelf label="Queued" count={queued.length}>
141
151
  {queued.map((r) => (
@@ -65,6 +65,7 @@ export function SidebarBody({
65
65
  shelves.pinned.length +
66
66
  shelves.needsYou.length +
67
67
  shelves.working.length +
68
+ shelves.inReview.length +
68
69
  data.queued.length +
69
70
  shelves.done.length +
70
71
  data.machines.length;
@@ -62,15 +62,15 @@ export function SidebarHeader(props: SidebarHeaderProps) {
62
62
  const workspaceId = props.workspace?.id ?? null;
63
63
  const schedules = useSchedulesGlance(workspaceId);
64
64
  return (
65
- <div className="flex items-center gap-1 px-3 pt-3 pb-2">
66
- <div className="flex min-w-0 flex-1 items-center">
65
+ <div className="flex flex-wrap items-center gap-x-1 px-3 pt-3 pb-2">
66
+ <div className="flex min-w-[9.5rem] flex-1 items-center">
67
67
  <WorkspaceSwitcher
68
68
  workspaces={props.workspaces}
69
69
  current={props.workspace}
70
70
  />
71
71
  <LoadGauge />
72
72
  </div>
73
- <div className="flex shrink-0 items-center gap-0.5">
73
+ <div className="ml-auto flex shrink-0 items-center gap-0.5">
74
74
  <AwayToggle />
75
75
  <ADropdownMenu
76
76
  trigger={
@@ -80,7 +80,7 @@ export function SidebarHeader(props: SidebarHeaderProps) {
80
80
  aria-label={
81
81
  schedules.failedText ? `More (${schedules.failedText})` : "More"
82
82
  }
83
- className="relative h-11 w-11 md:h-8 md:w-8"
83
+ className="relative h-11 w-11 md:h-8 md:w-8 [@media(hover:none)]:md:h-11 [@media(hover:none)]:md:w-11"
84
84
  >
85
85
  <MoreHorizontal className="h-4 w-4" />
86
86
  {schedules.failed > 0 && (
@@ -88,7 +88,7 @@ export function SidebarHeader(props: SidebarHeaderProps) {
88
88
  aria-hidden
89
89
  className={cn(
90
90
  failedDot,
91
- "ring-background absolute top-2 right-2 ring-2 md:top-1 md:right-1"
91
+ "ring-background absolute top-2 right-2 ring-2 md:top-1 md:right-1 [@media(hover:none)]:md:top-2 [@media(hover:none)]:md:right-2"
92
92
  )}
93
93
  />
94
94
  )}
@@ -149,7 +149,7 @@ export function SidebarHeader(props: SidebarHeaderProps) {
149
149
  <Button
150
150
  size="icon-sm"
151
151
  aria-label="New"
152
- className="ml-0.5 h-11 w-11 rounded-[10px] md:h-8 md:w-8"
152
+ className="ml-0.5 h-11 w-11 rounded-[10px] md:h-8 md:w-8 [@media(hover:none)]:md:h-11 [@media(hover:none)]:md:w-11"
153
153
  >
154
154
  <Plus className="h-4 w-4" />
155
155
  </Button>
@@ -7,7 +7,7 @@ import { sidebarUiActions } from "@/stores/sidebarUi";
7
7
  // sessions too.
8
8
  export function SidebarSearch({ query }: { query: string }) {
9
9
  return (
10
- <label className="bg-card text-muted-foreground focus-within:ring-primary/40 flex min-h-11 min-w-0 flex-1 items-center gap-2 rounded-xl px-3 shadow-xs focus-within:ring-2 md:min-h-9">
10
+ <label className="bg-card text-muted-foreground focus-within:ring-primary/40 flex min-h-11 min-w-[6rem] flex-1 items-center gap-2 rounded-xl px-3 shadow-xs focus-within:ring-2 md:min-h-9 [@media(hover:none)]:md:min-h-11">
11
11
  <Search className="h-3.5 w-3.5 shrink-0" />
12
12
  <input
13
13
  type="search"
@@ -28,7 +28,7 @@ export function SidebarSearch({ query }: { query: string }) {
28
28
  type="button"
29
29
  aria-label="Clear search"
30
30
  onClick={() => sidebarUiActions.setQuery("")}
31
- className="-mr-3 flex h-11 w-11 items-center justify-center md:h-7 md:w-7"
31
+ className="-mr-3 flex h-11 w-11 items-center justify-center md:h-7 md:w-7 [@media(hover:none)]:md:h-11 [@media(hover:none)]:md:w-11"
32
32
  >
33
33
  <X className="h-3.5 w-3.5" />
34
34
  </button>
@@ -59,12 +59,11 @@ export function WorkspaceSwitcher({
59
59
  <button
60
60
  type="button"
61
61
  title={name}
62
- className="hover:bg-foreground/[0.04] -ml-1.5 flex min-h-11 min-w-0 items-center gap-2 rounded-lg px-2 md:min-h-9"
62
+ className="hover:bg-foreground/[0.04] -ml-1.5 flex min-h-11 min-w-0 items-center gap-2 rounded-lg px-2 md:min-h-9 [@media(hover:none)]:md:min-h-11"
63
63
  >
64
- <MiddleTruncate
65
- text={name}
66
- className="text-[15px] font-semibold tracking-tight"
67
- />
64
+ <span className="truncate text-[15px] font-semibold tracking-tight">
65
+ {name}
66
+ </span>
68
67
  <ChevronDown className="text-muted-foreground h-3.5 w-3.5 shrink-0" />
69
68
  </button>
70
69
  </DM.DropdownMenuTrigger>
@@ -190,31 +189,3 @@ export function WorkspaceSwitcher({
190
189
  </>
191
190
  );
192
191
  }
193
-
194
- const TAIL = 4;
195
-
196
- // Cuts a name that doesn't fit in the middle ("All wo…aces"), so the end
197
- // that tells similar names apart stays: the head truncates, the tail doesn't.
198
- function MiddleTruncate({
199
- text,
200
- className,
201
- }: {
202
- text: string;
203
- className?: string;
204
- }) {
205
- if (text.length <= TAIL * 2) {
206
- return <span className={`truncate ${className}`}>{text}</span>;
207
- }
208
- // Screen readers get the name whole, not as two words.
209
- return (
210
- <span className={`flex min-w-0 ${className}`}>
211
- <span className="sr-only">{text}</span>
212
- <span aria-hidden className="truncate whitespace-pre">
213
- {text.slice(0, -TAIL)}
214
- </span>
215
- <span aria-hidden className="shrink-0 whitespace-pre">
216
- {text.slice(-TAIL)}
217
- </span>
218
- </span>
219
- );
220
- }
@@ -31,7 +31,7 @@ const flatten = (rows: SidebarRow[]): string[] =>
31
31
  rows.flatMap((r) => [r.session.id, ...flatten(r.workers)]);
32
32
 
33
33
  // One flat list of the workspace's sessions, on shelves: Pinned, Needs
34
- // you, Working, Done.
34
+ // you, Working, In review, Done.
35
35
  export function SessionList({
36
36
  activeSessionId,
37
37
  sessionStatuses,
@@ -62,6 +62,7 @@ export function SessionList({
62
62
  ...shelves.pinned,
63
63
  ...shelves.needsYou,
64
64
  ...shelves.working,
65
+ ...shelves.inReview,
65
66
  ...shelves.done,
66
67
  ...data.machines.flatMap((g) => g.rows),
67
68
  ]),
@@ -30,7 +30,11 @@ export function ThemeToggle() {
30
30
  return (
31
31
  <DropdownMenu>
32
32
  <DropdownMenuTrigger asChild>
33
- <Button variant="ghost" size="icon" className="h-8 w-8">
33
+ <Button
34
+ variant="ghost"
35
+ size="icon"
36
+ className="h-8 w-8 [@media(hover:none)]:md:h-11 [@media(hover:none)]:md:w-11"
37
+ >
34
38
  <Palette className="h-4 w-4" />
35
39
  <span className="sr-only">Change theme</span>
36
40
  </Button>
package/lib/db/queries.ts CHANGED
@@ -33,7 +33,7 @@ export function sessionListColumns(db: Database.Database, alias = ""): string {
33
33
  .map((c) => `${prefix}${c}`)
34
34
  .join(", ");
35
35
  // The recipe run or feature piece a session is, as JSON, for its row's chip.
36
- const recipe = `(SELECT json_object('id', r.id, 'name', r.name, 'seq', rr.seq) FROM recipe_runs rr JOIN recipes r ON r.id = rr.recipe_id WHERE rr.session_id = ${alias || "sessions"}.id)`;
36
+ const recipe = `(SELECT json_object('id', r.id, 'name', r.name, 'seq', rr.seq, 'status', rr.status) FROM recipe_runs rr JOIN recipes r ON r.id = rr.recipe_id WHERE rr.session_id = ${alias || "sessions"}.id)`;
37
37
  const piece = `(SELECT json_object('feature', f.id, 'name', f.name, 'position', fp.position, 'total', (SELECT COUNT(*) FROM feature_pieces WHERE feature_id = f.id AND status != 'dropped')) FROM feature_pieces fp JOIN features f ON f.id = fp.feature_id WHERE fp.session_id = ${alias || "sessions"}.id)`;
38
38
  return `${list}, (${prefix}task_brief IS NOT NULL AND ${prefix}task_brief != '') AS has_brief, ${recipe} AS recipe_run, ${piece} AS feature_piece`;
39
39
  }
@@ -40,7 +40,13 @@ export function machineGroups(
40
40
  return {
41
41
  hostId,
42
42
  name,
43
- rows: [...s.pinned, ...s.needsYou, ...s.working, ...s.done],
43
+ rows: [
44
+ ...s.pinned,
45
+ ...s.needsYou,
46
+ ...s.working,
47
+ ...s.inReview,
48
+ ...s.done,
49
+ ],
44
50
  };
45
51
  })
46
52
  .filter((g) => g.rows.length > 0)
@@ -30,6 +30,7 @@ export function rollups(
30
30
  ...shelves.pinned,
31
31
  ...shelves.needsYou,
32
32
  ...shelves.working,
33
+ ...shelves.inReview,
33
34
  ...shelves.done,
34
35
  ]);
35
36
  for (const row of rows) {
@@ -1,9 +1,9 @@
1
1
  // The sidebar's one flat list, sorted onto shelves: the workspace's pinned
2
- // orchestrator, Pinned, Needs you, Working, then Done. Client-safe: no database.
2
+ // orchestrator, Pinned, Needs you, Working, In review, then Done. Client-safe: no database.
3
3
  import type { Session } from "@/lib/db/types";
4
4
  import { fromSqliteTime } from "@/lib/session-meta";
5
5
  import type { PrStage } from "@/lib/tasks/pr-stage";
6
- import type { TaskState } from "@/lib/tasks/state";
6
+ import { isFinished, type TaskState } from "@/lib/tasks/state";
7
7
 
8
8
  // Why a session is blocked on you.
9
9
  export type SessionNeed =
@@ -33,6 +33,8 @@ export interface RowStatus {
33
33
  stalled?: boolean;
34
34
  // A chat summarising its context (status stays "running").
35
35
  compacting?: { since: number; before?: number };
36
+ // Background commands, subagents and monitors still running.
37
+ background?: number;
36
38
  asks?: number;
37
39
  prStage?: PrStage;
38
40
  // A program's own message (OSC 7501): untrusted, plain text only.
@@ -61,6 +63,7 @@ export interface Shelves {
61
63
  pinned: SidebarRow[];
62
64
  needsYou: SidebarRow[];
63
65
  working: SidebarRow[];
66
+ inReview: SidebarRow[];
64
67
  done: SidebarRow[];
65
68
  }
66
69
 
@@ -70,6 +73,17 @@ export function taskNeed(state: TaskState | undefined): SessionNeed | null {
70
73
  return null;
71
74
  }
72
75
 
76
+ // Where a row that needs nothing and isn't running goes: an open PR or a plan
77
+ // waiting on approval is in review, any other unfinished task is mid-job, and
78
+ // only finished work or a plain idle session is done.
79
+ export function quietShelf(
80
+ task: TaskState | undefined
81
+ ): "inReview" | "working" | "done" {
82
+ if (task === "review" || task === "plan-ready") return "inReview";
83
+ if (!task || isFinished(task)) return "done";
84
+ return "working";
85
+ }
86
+
73
87
  // An approval or a question beats what the task says; a terminal waiting
74
88
  // for input or an errored chat only counts when the task has nothing.
75
89
  export function needOf(
@@ -118,6 +132,20 @@ const newestFirst = (a: SidebarRow, b: SidebarRow) =>
118
132
  const oldestFirst = (a: SidebarRow, b: SidebarRow) =>
119
133
  a.session.created_at.localeCompare(b.session.created_at);
120
134
 
135
+ const OPEN_RUN = ["queued", "starting", "running"];
136
+
137
+ // A recipe run that hasn't reported its result yet: its session going quiet
138
+ // between turns (an EAS build running in the background, say) isn't it ending.
139
+ // The list query carries the run as JSON.
140
+ export function recipeRunOpen(session: Pick<Session, "recipe_run">): boolean {
141
+ if (!session.recipe_run) return false;
142
+ try {
143
+ return OPEN_RUN.includes(JSON.parse(session.recipe_run)?.status);
144
+ } catch {
145
+ return false;
146
+ }
147
+ }
148
+
121
149
  function toRow(input: ShelfInput, session: Session): SidebarRow {
122
150
  const status = input.statuses[session.id];
123
151
  return {
@@ -125,7 +153,10 @@ function toRow(input: ShelfInput, session: Session): SidebarRow {
125
153
  status,
126
154
  need: needOf(session, status, input.tasks[session.id]),
127
155
  unread: !!status?.unread,
128
- working: status?.status === "running",
156
+ working:
157
+ status?.status === "running" ||
158
+ (status?.background ?? 0) > 0 ||
159
+ recipeRunOpen(session),
129
160
  compacting: !!status?.compacting,
130
161
  workers: [],
131
162
  task: input.tasks[session.id],
@@ -169,6 +200,7 @@ export function buildShelves(input: ShelfInput): Shelves {
169
200
  pinned: [],
170
201
  needsYou: [],
171
202
  working: [],
203
+ inReview: [],
172
204
  done: [],
173
205
  };
174
206
  for (const row of top) {
@@ -178,12 +210,13 @@ export function buildShelves(input: ShelfInput): Shelves {
178
210
  else if (pinned && role !== "orchestrator") shelves.pinned.push(row);
179
211
  else if (row.need) shelves.needsYou.push(row);
180
212
  else if (row.working) shelves.working.push(row);
181
- else shelves.done.push(row);
213
+ else shelves[quietShelf(row.task)].push(row);
182
214
  }
183
215
  shelves.orchestrators.sort(oldestFirst);
184
216
  shelves.pinned.sort(oldestFirst);
185
217
  shelves.needsYou.sort(newestFirst);
186
218
  shelves.working.sort(newestFirst);
219
+ shelves.inReview.sort(newestFirst);
187
220
  shelves.done.sort(newestFirst);
188
221
  return shelves;
189
222
  }
@@ -265,6 +298,7 @@ function sameStatus(a: RowStatus | undefined, b: RowStatus | undefined) {
265
298
  !!a.stalled === !!b.stalled &&
266
299
  (a.compacting?.since ?? null) === (b.compacting?.since ?? null) &&
267
300
  (a.compacting?.before ?? null) === (b.compacting?.before ?? null) &&
301
+ (a.background ?? 0) === (b.background ?? 0) &&
268
302
  a.asks === b.asks &&
269
303
  (a.prStage?.stage ?? null) === (b.prStage?.stage ?? null) &&
270
304
  (a.prStage?.number ?? null) === (b.prStage?.number ?? null) &&
@@ -26,7 +26,7 @@ import { noteWaiting } from "../notify/push/waiting";
26
26
  import { getDb } from "../db";
27
27
  import { beginPass, chatMemo } from "./incremental";
28
28
  import { chatCompacting, chatState } from "../chat/runner";
29
- import { chatActivityLine, chatStall } from "../chat/activity";
29
+ import { chatActivity, chatActivityLine, chatStall } from "../chat/activity";
30
30
  import {
31
31
  chatNeed,
32
32
  isUnread,
@@ -72,6 +72,8 @@ export interface SessionStatusResponse {
72
72
  stalled?: boolean;
73
73
  // A chat whose agent is summarising its context ("status" stays running).
74
74
  compacting?: Compacting;
75
+ // Background commands, subagents and monitors a chat still has running.
76
+ background?: number;
75
77
  // Where a task's PR is in the gates (lib/tasks/pr-stage.ts).
76
78
  prStage?: PrStage;
77
79
  // A program's own message (OSC 7501): untrusted, plain text only.
@@ -474,6 +476,7 @@ async function collect(full = false): Promise<StatusSnapshot> {
474
476
  (s) => s.view === "chat" && (!s.host_id || s.host_id === "local")
475
477
  )) {
476
478
  const state = chatState(session.id);
479
+ const background = chatActivity(session.id)?.background;
477
480
  const orchestrator = session.role === "orchestrator";
478
481
  // What the database says; the rest below moves with time or the process.
479
482
  const base = chatMemo(pass, session.id, orchestrator, state ?? "", () => {
@@ -498,6 +501,7 @@ async function collect(full = false): Promise<StatusSnapshot> {
498
501
  ...(chatCompacting(session.id) && {
499
502
  compacting: chatCompacting(session.id)!,
500
503
  }),
504
+ ...(background && { background }),
501
505
  ...(orchestrator && { asks: base.asks }),
502
506
  };
503
507
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@saadnvd1/agent-os",
3
- "version": "0.3.541",
3
+ "version": "0.3.543",
4
4
  "description": "Self-hosted web UI for managing Claude Code sessions",
5
5
  "bin": {
6
6
  "agent-os": "scripts/agent-os"