@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.
- package/README.md +38 -2
- package/app/api/sessions/[id]/fresh/route.ts +31 -0
- package/components/Chat/ChatPanel.tsx +19 -3
- package/components/Chat/Tools.tsx +9 -3
- package/components/Pane/index.tsx +4 -0
- package/components/SessionList/OrchestratorPinRow.test.ts +3 -0
- package/components/SessionList/OrchestratorPinRow.tsx +36 -0
- package/components/SessionList/RowContext.tsx +5 -0
- package/components/SessionList/SessionRow.tsx +17 -4
- package/components/SessionList/SessionRowMenu.tsx +10 -0
- package/components/SessionList/SessionShelves.tsx +8 -2
- package/components/SessionList/SidebarBody.tsx +17 -1
- package/components/SessionList/index.tsx +23 -3
- package/components/SessionList/useSidebarData.ts +64 -18
- package/data/chat/useChat.ts +12 -1
- package/data/sessions/fresh.ts +20 -0
- package/data/sessions/index.ts +1 -0
- package/lib/chat/bodyCache.ts +69 -0
- package/lib/chat/commands.ts +16 -0
- package/lib/chat/refresh.ts +18 -0
- package/lib/chat/registry.ts +2 -0
- package/lib/chat/runner.ts +50 -0
- package/lib/chat/store.ts +89 -0
- package/lib/chat/worker/host.ts +46 -12
- package/lib/chat/worker/main.ts +1 -1
- package/lib/chat/worker/protocol.ts +4 -0
- 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
|
@@ -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
|
|
42
|
-
//
|
|
43
|
-
//
|
|
44
|
-
//
|
|
45
|
-
//
|
|
46
|
-
//
|
|
47
|
-
//
|
|
48
|
-
//
|
|
49
|
-
//
|
|
50
|
-
//
|
|
51
|
-
//
|
|
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
|
-
|
|
70
|
-
"Bash
|
|
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 =
|
|
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
|
|
317
|
-
//
|
|
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
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
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
|
-
//
|
|
334
|
-
//
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
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
|
}
|
package/lib/sidebar/queued.ts
CHANGED
|
@@ -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
|
+
}
|
package/lib/sidebar/shelves.ts
CHANGED
|
@@ -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(
|
package/lib/tasks/queue.ts
CHANGED
|
@@ -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
|
});
|