@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.
@@ -0,0 +1,289 @@
1
+ import os from "os";
2
+
3
+ /**
4
+ * A shell command line read into the simple commands it runs, for the
5
+ * orchestrator's deny list (shell-guard.ts). It reads what a zsh or bash
6
+ * would: quotes, escapes, `;` `&&` `||` `|` `&` and newlines between
7
+ * commands, subshells and groups, `$( )`, backticks and `<( )` (whose
8
+ * commands are read too), redirects and heredocs. It is a reader for honest
9
+ * mistakes, not a sandbox: a script or an interpreter's inline code runs
10
+ * what it likes, and nothing here sees that.
11
+ */
12
+
13
+ export interface SimpleCommand {
14
+ words: string[];
15
+ // Files it writes through >, >>, &> and the like.
16
+ writes: string[];
17
+ // Files it reads through <.
18
+ reads: string[];
19
+ // What a heredoc or here-string feeds it.
20
+ stdin: string;
21
+ }
22
+
23
+ // Stands in for text only the running shell knows: a substitution's
24
+ // output or a variable other than HOME.
25
+ export const UNKNOWN = "\u0000";
26
+
27
+ const MAX_DEPTH = 8;
28
+
29
+ const blank = (): SimpleCommand => ({
30
+ words: [],
31
+ writes: [],
32
+ reads: [],
33
+ stdin: "",
34
+ });
35
+
36
+ // The index of the `)` closing the `(` at `open`, skipping quoted text.
37
+ function closingParen(src: string, open: number): number {
38
+ let depth = 0;
39
+ for (let i = open; i < src.length; i++) {
40
+ const c = src[i];
41
+ if (c === "\\") i++;
42
+ else if (c === "'") {
43
+ const end = src.indexOf("'", i + 1);
44
+ if (end < 0) return -1;
45
+ i = end;
46
+ } else if (c === '"') {
47
+ for (i++; i < src.length && src[i] !== '"'; i++) if (src[i] === "\\") i++;
48
+ } else if (c === "(") depth++;
49
+ else if (c === ")" && --depth === 0) return i;
50
+ }
51
+ return -1;
52
+ }
53
+
54
+ function closingBacktick(src: string, open: number): number {
55
+ for (let i = open + 1; i < src.length; i++) {
56
+ if (src[i] === "\\") i++;
57
+ else if (src[i] === "`") return i;
58
+ }
59
+ return -1;
60
+ }
61
+
62
+ const ANSI: Record<string, string> = { n: "\n", t: "\t", r: "\r", e: "\x1b" };
63
+
64
+ // `$NAME` or `${NAME}` at `at` (just past the $): HOME expands, anything
65
+ // else is unknown. Returns the text and the last index read.
66
+ function variable(src: string, at: number): [string, number] {
67
+ if (src[at] === "{") {
68
+ const end = src.indexOf("}", at);
69
+ if (end < 0) return [UNKNOWN, src.length - 1];
70
+ return [src.slice(at + 1, end) === "HOME" ? os.homedir() : UNKNOWN, end];
71
+ }
72
+ const m = /^([A-Za-z_][A-Za-z0-9_]*|[0-9?#@*$!-])/.exec(src.slice(at));
73
+ if (!m) return ["$", at - 1];
74
+ return [m[1] === "HOME" ? os.homedir() : UNKNOWN, at + m[1].length - 1];
75
+ }
76
+
77
+ // The simple commands in a command line, or why it can't be read.
78
+ export function parseShell(src: string, depth = 0): SimpleCommand[] | string {
79
+ if (depth > MAX_DEPTH) return "commands nested too deeply to check";
80
+ const out: SimpleCommand[] = [];
81
+ let cmd = blank();
82
+ let word: string | null = null;
83
+ let pending: "write" | "read" | "here" | "heredoc" | null = null;
84
+ const heredocs: { delim: string; tabs: boolean; cmd: SimpleCommand }[] = [];
85
+ let tabs = false;
86
+
87
+ const endWord = () => {
88
+ if (word === null) return;
89
+ if (pending === "write") cmd.writes.push(word);
90
+ else if (pending === "read") cmd.reads.push(word);
91
+ else if (pending === "here") cmd.stdin += `${word}\n`;
92
+ else if (pending === "heredoc") heredocs.push({ delim: word, tabs, cmd });
93
+ else cmd.words.push(word);
94
+ pending = null;
95
+ word = null;
96
+ };
97
+ const endCmd = () => {
98
+ endWord();
99
+ if (cmd.words.length || cmd.writes.length || cmd.reads.length)
100
+ out.push(cmd);
101
+ cmd = blank();
102
+ };
103
+ const nest = (inner: string): string | null => {
104
+ const r = parseShell(inner, depth + 1);
105
+ if (typeof r === "string") return r;
106
+ out.push(...r);
107
+ return null;
108
+ };
109
+
110
+ for (let i = 0; i < src.length; i++) {
111
+ const c = src[i];
112
+ if (c === " " || c === "\t") {
113
+ endWord();
114
+ continue;
115
+ }
116
+ if (c === "\n" || c === "\r") {
117
+ endCmd();
118
+ // Heredoc bodies start on the next line.
119
+ for (const h of heredocs.splice(0)) {
120
+ while (i + 1 < src.length) {
121
+ const end = src.indexOf("\n", i + 1);
122
+ const line = src.slice(i + 1, end < 0 ? src.length : end);
123
+ i = end < 0 ? src.length : end;
124
+ const text = h.tabs ? line.replace(/^\t+/, "") : line;
125
+ if (text === h.delim) break;
126
+ h.cmd.stdin += `${text}\n`;
127
+ }
128
+ }
129
+ continue;
130
+ }
131
+ if (c === "#" && word === null) {
132
+ const end = src.indexOf("\n", i);
133
+ i = (end < 0 ? src.length : end) - 1;
134
+ continue;
135
+ }
136
+ if (c === "\\") {
137
+ if (src[i + 1] !== "\n") word = (word ?? "") + (src[i + 1] ?? "");
138
+ i++;
139
+ continue;
140
+ }
141
+ if (c === "'") {
142
+ const end = src.indexOf("'", i + 1);
143
+ if (end < 0) return "an unclosed ' quote";
144
+ word = (word ?? "") + src.slice(i + 1, end);
145
+ i = end;
146
+ continue;
147
+ }
148
+ if (c === '"') {
149
+ let text = "";
150
+ let j = i + 1;
151
+ for (; j < src.length && src[j] !== '"'; j++) {
152
+ const d = src[j];
153
+ if (d === "\\" && j + 1 < src.length) {
154
+ const n = src[++j];
155
+ if (n !== "\n") text += /["\\$`]/.test(n) ? n : `\\${n}`;
156
+ } else if (d === "$" && src[j + 1] === "(") {
157
+ const end = closingParen(src, j + 1);
158
+ if (end < 0) return "an unclosed $(";
159
+ const err = nest(src.slice(j + 2, end));
160
+ if (err) return err;
161
+ text += UNKNOWN;
162
+ j = end;
163
+ } else if (d === "`") {
164
+ const end = closingBacktick(src, j);
165
+ if (end < 0) return "an unclosed backtick";
166
+ const err = nest(src.slice(j + 1, end));
167
+ if (err) return err;
168
+ text += UNKNOWN;
169
+ j = end;
170
+ } else if (d === "$") {
171
+ const [v, end] = variable(src, j + 1);
172
+ text += v;
173
+ j = end;
174
+ } else text += d;
175
+ }
176
+ if (j >= src.length) return 'an unclosed " quote';
177
+ word = (word ?? "") + text;
178
+ i = j;
179
+ continue;
180
+ }
181
+ if (c === "$" && src[i + 1] === "(") {
182
+ const end = closingParen(src, i + 1);
183
+ if (end < 0) return "an unclosed $(";
184
+ // $(( )) is arithmetic, not a command.
185
+ if (src[i + 2] !== "(") {
186
+ const err = nest(src.slice(i + 2, end));
187
+ if (err) return err;
188
+ }
189
+ word = (word ?? "") + UNKNOWN;
190
+ i = end;
191
+ continue;
192
+ }
193
+ if (c === "$" && src[i + 1] === "'") {
194
+ let text = "";
195
+ let j = i + 2;
196
+ for (; j < src.length && src[j] !== "'"; j++) {
197
+ if (src[j] === "\\" && j + 1 < src.length) {
198
+ const n = src[++j];
199
+ text += ANSI[n] ?? n;
200
+ } else text += src[j];
201
+ }
202
+ if (j >= src.length) return "an unclosed $' quote";
203
+ word = (word ?? "") + text;
204
+ i = j;
205
+ continue;
206
+ }
207
+ if (c === "$") {
208
+ const [v, end] = variable(src, i + 1);
209
+ word = (word ?? "") + v;
210
+ i = end;
211
+ continue;
212
+ }
213
+ if (c === "`") {
214
+ const end = closingBacktick(src, i);
215
+ if (end < 0) return "an unclosed backtick";
216
+ const err = nest(src.slice(i + 1, end));
217
+ if (err) return err;
218
+ word = (word ?? "") + UNKNOWN;
219
+ i = end;
220
+ continue;
221
+ }
222
+ if ((c === "<" || c === ">") && src[i + 1] === "(") {
223
+ const end = closingParen(src, i + 1);
224
+ if (end < 0) return "an unclosed process substitution";
225
+ const err = nest(src.slice(i + 2, end));
226
+ if (err) return err;
227
+ word = (word ?? "") + UNKNOWN;
228
+ i = end;
229
+ continue;
230
+ }
231
+ if (c === ">" || c === "<" || (c === "&" && src[i + 1] === ">")) {
232
+ // A file descriptor's number (2>) belongs to the redirect.
233
+ if (word !== null && /^\d+$/.test(word)) word = null;
234
+ else endWord();
235
+ if (c === "&") {
236
+ i++;
237
+ if (src[i + 1] === ">") i++;
238
+ pending = "write";
239
+ } else if (c === ">") {
240
+ if (src[i + 1] === ">" || src[i + 1] === "|") i++;
241
+ if (src[i + 1] === "&") {
242
+ i++;
243
+ // >&2 and >&- duplicate a descriptor; >&file writes the file.
244
+ if (/[\d-]/.test(src[i + 1] ?? "")) {
245
+ i++;
246
+ continue;
247
+ }
248
+ }
249
+ pending = "write";
250
+ } else if (src[i + 1] === "<") {
251
+ i++;
252
+ if (src[i + 1] === "<") {
253
+ i++;
254
+ pending = "here";
255
+ } else {
256
+ tabs = src[i + 1] === "-";
257
+ if (tabs) i++;
258
+ pending = "heredoc";
259
+ }
260
+ } else if (src[i + 1] === "&") {
261
+ i += 2;
262
+ } else if (src[i + 1] === ">") {
263
+ i++;
264
+ pending = "write";
265
+ } else pending = "read";
266
+ continue;
267
+ }
268
+ if (/[;&|()]/.test(c)) {
269
+ endCmd();
270
+ continue;
271
+ }
272
+ if (
273
+ (c === "{" || c === "}") &&
274
+ word === null &&
275
+ /^[\s;]?$/.test(src[i + 1] ?? "")
276
+ ) {
277
+ endCmd();
278
+ continue;
279
+ }
280
+ // ~ and ~/ at a word's start are the home folder.
281
+ if (c === "~" && word === null && /^[/\s]?$/.test(src[i + 1] ?? "")) {
282
+ word = os.homedir();
283
+ continue;
284
+ }
285
+ word = (word ?? "") + c;
286
+ }
287
+ endCmd();
288
+ return out;
289
+ }
@@ -38,17 +38,17 @@ export const TOOL_NAMES = {
38
38
  ask_saad: name("ask_saad"),
39
39
  } as const;
40
40
 
41
- // What it may do, fixed by its role: it reads other sessions' text, so it
42
- // never gets a general shell. Anything not listed is refused without asking.
43
- // It acts through its own tools, which are scoped to its workspace and
44
- // braked; the shell keeps only `aos` commands that read, and notify, which
45
- // reaches only Saad, so `aos send`, `task`, `spawn` and `stack` can't go
46
- // around them. It writes files, but never in a project repository, a task
47
- // worktree, AgentOS's own state or a secret file, except inside its own
48
- // quick changes' worktrees (quick.ts), where it may also run git that
49
- // looks, stages or undoes, never commits with hooks or pushes: its worker
50
- // checks each write and each shell command first (write-guard.ts,
51
- // git-guard.ts).
41
+ // What it may do, fixed by its role. It acts through its own tools, which
42
+ // are scoped to its workspace and braked. Its shell runs anything, without
43
+ // asking: Saad chose that on 2026-10-11, knowing an orchestrator reads other
44
+ // sessions' text and a misled one could get around the gates with
45
+ // `python -c` or a script, which no parser sees. Its worker still refuses a
46
+ // deny list of shell commands before they run (shell-guard.ts): merging,
47
+ // closing or retargeting a PR, pushing, writing in a project repository or
48
+ // task worktree other than its own quick changes, writing AgentOS's state,
49
+ // stopping AgentOS or its tmux, and reading secrets. That catches honest
50
+ // mistakes, not a determined bypass. Its file writes are checked the same
51
+ // way, against a longer list (write-guard.ts).
52
52
  export const ORCHESTRATOR_PERMISSIONS: {
53
53
  permissionMode: PermissionMode;
54
54
  allowedTools: string[];
@@ -66,17 +66,8 @@ export const ORCHESTRATOR_PERMISSIONS: {
66
66
  "NotebookEdit",
67
67
  "ToolSearch",
68
68
  "TodoWrite",
69
- "Bash(aos peers:*)",
70
- "Bash(aos inbox:*)",
71
- "Bash(aos history:*)",
72
- "Bash(aos stacks:*)",
73
- "Bash(aos schedules:*)",
74
- // Reaches only Saad's phone, at most once a minute.
75
- "Bash(aos notify:*)",
76
- "Bash(aos docs:*)",
77
- "Bash(aos doc:*)",
78
- // Only `git -C <its own quick worktree>`, refused anywhere else.
79
- "Bash(git:*)",
69
+ // Anything; its worker refuses the deny list (shell-guard.ts).
70
+ "Bash",
80
71
  ],
81
72
  disallowedTools: [],
82
73
  };
@@ -12,6 +12,7 @@
12
12
  * `..` resolved the way the system will resolve it.
13
13
  */
14
14
 
15
+ import { execFile } from "child_process";
15
16
  import fs from "fs";
16
17
  import os from "os";
17
18
  import path from "path";
@@ -21,8 +22,8 @@ import { isSecretPath } from "../secret-paths";
21
22
  import { WORKTREES_DIR } from "../worktrees";
22
23
  import { orchestratorDir } from "./home";
23
24
  import { LIMITS_FILE } from "./usage";
24
- import { GIT_LAYOUT_FILE, readCommand } from "./git-guard";
25
25
  import { quickWorktrees } from "./quick-store";
26
+ import { shellRefusal } from "./shell-guard";
26
27
 
27
28
  // The tools that write a file, and the argument naming it.
28
29
  export const WRITE_TOOLS: Record<string, string> = {
@@ -60,6 +61,11 @@ const RUNS_CODE =
60
61
  // judges.
61
62
  const AGENTOS_TEMP = /(^|\/)aos-(review|rebase|body)-[^/]+(\/|$)/;
62
63
 
64
+ // A git repository's layout. Git config can run programs (core.fsmonitor,
65
+ // diff.external, filter and textconv drivers), so even in its own quick
66
+ // worktrees an orchestrator never writes these.
67
+ const GIT_LAYOUT_FILE = /(^|\/)(HEAD|\.gitattributes)$|(^|\/)\.git(\/|$)/i;
68
+
63
69
  const MAX_LINKS = 40;
64
70
 
65
71
  // The path as the system resolves it: component by component from the root,
@@ -120,10 +126,14 @@ const within = (p: string, root: string) => {
120
126
 
121
127
  const expandHome = (p: string) => p.replace(/^~(?=$|\/)/, os.homedir());
122
128
 
123
- // Why a write to this path is refused, or null when it may go ahead.
129
+ // Why a write to this path is refused, or null when it may go ahead. For a
130
+ // shell command (`shell`), only the deny list's places count: a project
131
+ // repository or task worktree that isn't its own quick change, a review's
132
+ // checkout, and AgentOS's state outside its folder.
124
133
  export async function writeRefusal(
125
134
  filePath: string,
126
- bounds: WriteBounds
135
+ bounds: WriteBounds,
136
+ { shell = false }: { shell?: boolean } = {}
127
137
  ): Promise<string | null> {
128
138
  if (!filePath) return "No file path to check.";
129
139
  const given = expandHome(filePath);
@@ -171,7 +181,7 @@ export async function writeRefusal(
171
181
  const repo = repos.find((r) => within(target, r) && !holdsQuick(r));
172
182
  if (repo)
173
183
  return `${filePath} is inside ${repo}, a project repository, task worktree or AgentOS itself. Change code there through a task (start_task), or for a small edit, in a quick change's worktree (quick_change), so its PR goes through the merge gates.`;
174
- if (ownQuick && GIT_LAYOUT_FILE.test(target))
184
+ if (ownQuick && !shell && GIT_LAYOUT_FILE.test(target))
175
185
  return `${filePath} is part of a git repository's layout (HEAD, .git/, .gitattributes); git config can run programs, so orchestrators never write those.`;
176
186
  if (AGENTOS_TEMP.test(target))
177
187
  return `${filePath} is in a checkout AgentOS is reviewing or rebasing; orchestrators never write there.`;
@@ -180,6 +190,7 @@ export async function writeRefusal(
180
190
  const own = ownDir.some((d) => within(target, d)) || !!ownQuick;
181
191
  if (state.some((d) => within(target, d)) && !own)
182
192
  return `${filePath} is in AgentOS's own state (${bounds.state}); you may write only in your folder, ${bounds.ownDir}.`;
193
+ if (shell) continue;
183
194
  if (isSecretPath(target) || isSecretPath(filePath))
184
195
  return `${filePath} holds or guards secrets; orchestrators never write those.`;
185
196
  const runsIn = runs.find((r) => within(target, r));
@@ -201,6 +212,9 @@ export async function writeRefusal(
201
212
  return null;
202
213
  }
203
214
 
215
+ const agentOsDbPath = () =>
216
+ path.resolve(process.env.DB_PATH || path.join(process.cwd(), "agent-os.db"));
217
+
204
218
  // What an orchestrator's writes are held to, read fresh for each one so a
205
219
  // project added since counts: every local project's repositories (not just
206
220
  // its workspace's, since a write to another workspace's repo skips its gates
@@ -219,9 +233,7 @@ export async function orchestratorWriteBounds(
219
233
  WHERE worktree_path IS NOT NULL AND worktree_path != ''`
220
234
  )
221
235
  .all() as { dir: string }[];
222
- const dbPath = path.resolve(
223
- process.env.DB_PATH || path.join(process.cwd(), "agent-os.db")
224
- );
236
+ const dbPath = agentOsDbPath();
225
237
  const repos = projects.map((p) => expandHome(p.dir)).filter((d) => d !== "");
226
238
  return {
227
239
  cwd: expandHome(session.working_directory),
@@ -313,60 +325,45 @@ export async function orchestratorToolRefusal(
313
325
  return writeRefusal(filePath, await orchestratorWriteBounds(session));
314
326
  }
315
327
 
316
- // A shell command: one plain command, and git only in its own quick
317
- // worktrees (git-guard.ts), the folder it names resolved like a write's.
328
+ // A shell command, against the deny list (shell-guard.ts), its writes held
329
+ // to the shell's share of the write bounds.
318
330
  async function commandRefusal(
319
331
  session: Session,
320
332
  command: unknown
321
333
  ): Promise<string | null> {
322
- const read = readCommand(command);
323
- if (read.refusal !== null || read.dir === null) return read.refusal;
324
- const quick = quickWorktrees(session.id);
325
- const dir = read.dir;
326
- if (!dir.startsWith("/") || !quick.length)
327
- return `Refused: git runs only in one of your quick changes' worktrees, named by its full path (${quick.length ? quick.join(", ") : "you have none open; start one with quick_change"}).`;
328
- return (await isQuickRoot(dir, quick))
329
- ? null
330
- : `Refused: ${read.dir} isn't the root of one of your quick changes' worktrees (${quick.join(", ")}). Git runs at the root only, never in a subfolder, a project checkout or another session's worktree.`;
334
+ const bounds = await orchestratorWriteBounds(session);
335
+ const dbFile = agentOsDbPath();
336
+ return shellRefusal(command, {
337
+ cwd: bounds.cwd,
338
+ writeRefusal: (p) => writeRefusal(p, bounds, { shell: true }),
339
+ isAgentOsState: async (p) => {
340
+ const real = await physicalPath(p);
341
+ const dbReal = await physicalPath(dbFile);
342
+ if ([p, real].some((f) => f === dbFile || f === dbReal)) return true;
343
+ const own = await physicalPath(bounds.ownDir);
344
+ const state = await physicalPath(bounds.state);
345
+ return within(real, state) && !within(real, own);
346
+ },
347
+ protectedPids,
348
+ });
331
349
  }
332
350
 
333
- // Whether a folder is inside one of these worktrees by every way the
334
- // system could read it: as named, with `..` taken by name, and with links
335
- // followed.
336
- export async function insideQuick(
337
- dir: string,
338
- quick: string[]
339
- ): Promise<boolean> {
340
- try {
341
- const forms = [
342
- path.resolve(dir),
343
- await physicalPath(dir),
344
- await physicalPath(path.resolve(dir)),
345
- ];
346
- const roots = (
347
- await Promise.all(
348
- quick.map(async (q) => [path.resolve(q), await physicalPath(q)])
349
- )
350
- ).flat();
351
- return forms.every((f) => roots.some((r) => within(f, r)));
352
- } catch {
353
- return false;
354
- }
355
- }
356
-
357
- // Whether a folder is exactly the root of one of these worktrees (as named,
358
- // and with links followed), where git finds the real git dir.
359
- async function isQuickRoot(dir: string, quick: string[]): Promise<boolean> {
360
- if (!(await insideQuick(dir, quick))) return false;
361
- try {
362
- const forms = [path.resolve(dir), await physicalPath(dir)];
363
- const roots = (
364
- await Promise.all(
365
- quick.map(async (q) => [path.resolve(q), await physicalPath(q)])
366
- )
367
- ).flat();
368
- return forms.every((f) => roots.includes(f));
369
- } catch {
370
- return false;
371
- }
351
+ // AgentOS's server (whatever listens on its port), the tmux servers its
352
+ // sessions run in, and this worker. A lookup that fails refuses the kill.
353
+ async function protectedPids(): Promise<Set<number>> {
354
+ const port = process.env.AGENTOS_PORT || process.env.PORT || "3011";
355
+ const pids = (cmd: string, args: string[]) =>
356
+ new Promise<number[]>((resolve, reject) =>
357
+ execFile(cmd, args, { timeout: 5000 }, (e, out) => {
358
+ // Exit 1 is "nothing matched".
359
+ if (e && (e as { code?: unknown }).code !== 1)
360
+ return reject(new Error(`couldn't list processes with ${cmd}`));
361
+ resolve(out.split(/\s+/).filter(Boolean).map(Number));
362
+ })
363
+ );
364
+ const [server, tmux] = await Promise.all([
365
+ pids("lsof", ["-ti", `tcp:${port}`, "-sTCP:LISTEN"]),
366
+ pids("pgrep", ["-x", "tmux"]),
367
+ ]);
368
+ return new Set([...server, ...tmux, process.pid, process.ppid]);
372
369
  }
@@ -19,6 +19,8 @@ export function queuedRows(input: {
19
19
  workspaceId: string | null;
20
20
  projectId?: string | null;
21
21
  query?: string;
22
+ // Only what this orchestrator session queued.
23
+ startedBy?: string | null;
22
24
  }): QueuedRow[] {
23
25
  const q = (input.query ?? "").trim().toLowerCase();
24
26
  // Each line's waiting tasks, in order, for what a move can swap with.
@@ -34,6 +36,7 @@ export function queuedRows(input: {
34
36
  !input.sessionIds.has(item.id) &&
35
37
  (!input.workspaceId || item.workspaceId === input.workspaceId) &&
36
38
  (!input.projectId || item.projectId === input.projectId) &&
39
+ (!input.startedBy || item.startedBy === input.startedBy) &&
37
40
  (!q ||
38
41
  item.name.toLowerCase().includes(q) ||
39
42
  (item.projectName ?? "").toLowerCase().includes(q))
@@ -0,0 +1,58 @@
1
+ // What an orchestrator started, counted from the same rows the shelves show:
2
+ // blocked is a row on Needs you, in review a task state, running a working
3
+ // row, queued an item in the Queued shelf. Client-safe.
4
+ import type { QueuedRow } from "./queued";
5
+ import type { Shelves, SidebarRow } from "./shelves";
6
+
7
+ export interface Rollup {
8
+ running: number;
9
+ inReview: number;
10
+ blocked: number;
11
+ queued: number;
12
+ }
13
+
14
+ const flatten = (rows: SidebarRow[]): SidebarRow[] =>
15
+ rows.flatMap((r) => [r, ...flatten(r.workers), ...flatten(r.quick ?? [])]);
16
+
17
+ export function rollups(
18
+ shelves: Shelves,
19
+ queued: readonly Pick<QueuedRow, "item">[]
20
+ ): Map<string, Rollup> {
21
+ const out = new Map<string, Rollup>();
22
+ const of = (id: string) => {
23
+ let r = out.get(id);
24
+ if (!r)
25
+ out.set(id, (r = { running: 0, inReview: 0, blocked: 0, queued: 0 }));
26
+ return r;
27
+ };
28
+ const rows = flatten([
29
+ ...shelves.orchestrators,
30
+ ...shelves.pinned,
31
+ ...shelves.needsYou,
32
+ ...shelves.working,
33
+ ...shelves.done,
34
+ ]);
35
+ for (const row of rows) {
36
+ const by = row.session.started_by;
37
+ if (!by) continue;
38
+ const r = of(by);
39
+ if (row.need) r.blocked++;
40
+ else if (row.task === "review") r.inReview++;
41
+ else if (row.working) r.running++;
42
+ }
43
+ for (const { item } of queued)
44
+ if (item.startedBy && item.status !== "failed") of(item.startedBy).queued++;
45
+ return out;
46
+ }
47
+
48
+ export const rollupChips = [
49
+ { key: "running", label: "running", dot: "bg-green-500" },
50
+ { key: "inReview", label: "in review", dot: "bg-primary" },
51
+ { key: "blocked", label: "blocked", dot: "bg-amber-500" },
52
+ { key: "queued", label: "queued", dot: "bg-muted-foreground/35" },
53
+ ] as const;
54
+
55
+ // The chips to draw: zero counts are left out.
56
+ export function visibleChips(r: Rollup | undefined) {
57
+ return r ? rollupChips.filter((c) => r[c.key] > 0) : [];
58
+ }
@@ -1,6 +1,7 @@
1
1
  // The sidebar's one flat list, sorted onto shelves: the workspace's pinned
2
2
  // orchestrator, Pinned, Needs you, Working, then Done. Client-safe: no database.
3
3
  import type { Session } from "@/lib/db/types";
4
+ import { fromSqliteTime } from "@/lib/session-meta";
4
5
  import type { TaskState } from "@/lib/tasks/state";
5
6
 
6
7
  // Why a session is blocked on you.
@@ -102,6 +103,8 @@ export interface ShelfInput {
102
103
  // Each workspace's current orchestrator (its overview's sessionId). Any
103
104
  // other orchestrator session is an ordinary row, pinned or not.
104
105
  currentOrchestrators?: ReadonlySet<string>;
106
+ // Only the sessions this orchestrator session started.
107
+ startedBy?: string | null;
105
108
  }
106
109
 
107
110
  const newestFirst = (a: SidebarRow, b: SidebarRow) =>
@@ -129,6 +132,7 @@ export function buildShelves(input: ShelfInput): Shelves {
129
132
  (s) =>
130
133
  !s.archived_at &&
131
134
  (!input.projectId || s.project_id === input.projectId) &&
135
+ (!input.startedBy || s.started_by === input.startedBy) &&
132
136
  matchesQuery(s, input.projectName(s), input.query ?? "")
133
137
  )
134
138
  .map((s) => toRow(input, s));
@@ -181,6 +185,26 @@ export function buildShelves(input: ShelfInput): Shelves {
181
185
  export const doneLimit = (pages: number) =>
182
186
  DONE_FIRST_PAGE + Math.max(0, pages) * DONE_PAGE;
183
187
 
188
+ // What a folded Done shelf says: how many finished today (local time), and
189
+ // how many before.
190
+ export function doneSummary(
191
+ done: readonly SidebarRow[],
192
+ now: Date = new Date()
193
+ ): { today: number; earlier: number; text: string } {
194
+ const day = now.toDateString();
195
+ const today = done.filter(
196
+ (r) => fromSqliteTime(r.session.updated_at).toDateString() === day
197
+ ).length;
198
+ const earlier = done.length - today;
199
+ const text = [
200
+ today > 0 && `${today} done today`,
201
+ earlier > 0 && `${earlier} earlier`,
202
+ ]
203
+ .filter(Boolean)
204
+ .join(" · ");
205
+ return { today, earlier, text };
206
+ }
207
+
184
208
  // A workspace holds its projects' sessions and its own orchestrator; no
185
209
  // workspace selected means every session.
186
210
  export function inWorkspace(
@@ -87,6 +87,8 @@ export interface QueuedTaskView {
87
87
  afterId: string | null;
88
88
  note: string | null;
89
89
  error: string | null;
90
+ // The orchestrator session that queued it, when one did.
91
+ startedBy: string | null;
90
92
  createdAt: string;
91
93
  }
92
94
 
@@ -380,6 +382,7 @@ export function listQueue(): QueuedTaskView[] {
380
382
  afterId: r.after_task,
381
383
  note: r.note,
382
384
  error: r.error,
385
+ startedBy: r.started_by,
383
386
  createdAt: r.created_at,
384
387
  };
385
388
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@saadnvd1/agent-os",
3
- "version": "0.3.488",
3
+ "version": "0.3.493",
4
4
  "description": "Self-hosted web UI for managing Claude Code sessions",
5
5
  "bin": {
6
6
  "agent-os": "scripts/agent-os"