@saadnvd1/agent-os 0.3.752 → 0.3.758
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 +7 -1
- package/components/Chat/ComposerMenu.tsx +71 -31
- package/components/Chat/composer/ComposerBody.tsx +29 -5
- package/components/Workspaces/WorkspaceSettings.tsx +1 -1
- package/lib/chat/commands.ts +72 -2
- package/lib/chat/events.ts +6 -0
- package/lib/chat/recent-commands.ts +28 -0
- package/lib/chat/settings.ts +2 -0
- package/lib/chat/skill-source.ts +95 -0
- package/lib/health/env/classes.ts +8 -2
- package/lib/health/env/classify.ts +40 -3
- package/lib/orchestrator/mcp-servers.ts +9 -1
- package/lib/orchestrator/worker.ts +3 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -17,7 +17,7 @@ and check on all of it from your phone.
|
|
|
17
17
|
- **Each kind of work on the right model.** Planning, review, implementation and titles each run on their own model, set in Settings → Models. [More](docs/reference/model-routing.md)
|
|
18
18
|
- **Nothing starts until you send.** ⌥N opens a draft with project, machine, agent and branch as chips. [More](docs/guides/sessions.md)
|
|
19
19
|
- **Other machines in the same list.** Link another machine that runs AgentOS and start or move work there. [More](docs/reference/machines.md)
|
|
20
|
-
- **Built for your phone.** The whole app works on a phone; pair it with a QR code. [More](docs/guides/phone.md)
|
|
20
|
+
- **Built for your phone.** The whole app works on a phone; pair it with a QR code, or get the [iPhone app](https://apps.apple.com/us/app/agentos-coding-agents/id6820980797) from the App Store. [More](docs/guides/phone.md)
|
|
21
21
|
- **Phone to orchestrator.** A paired phone can send a shake report or a shared page or file to a workspace's orchestrator (`POST /api/orchestrators/:workspaceId/intake`, saved under `intake/` beside the database, shared content marked untrusted), and fetch names to improve dictation (`GET /api/voice/vocab?workspace=`). [More](docs/guides/phone.md)
|
|
22
22
|
- **An analyst beside your coding agent.** Pair a chat with a second one that reviews each turn, explains it in plain English, flags issues and drafts the next prompt, which you send, edit or dismiss. It runs on Claude unless you pick another model, local ones included. Side by side on a desktop, a switch on a phone. [More](docs/decisions/2026-10-21-analyst-sessions.md)
|
|
23
23
|
- **Agents that talk to each other.** Sessions message each other with `aos`, and you see every conversation in Messages. [More](docs/guides/agent-network.md)
|
|
@@ -46,6 +46,10 @@ and check on all of it from your phone.
|
|
|
46
46
|
|
|
47
47
|
## Install
|
|
48
48
|
|
|
49
|
+
[<img src="docs/assets/app-store-badge.svg" alt="Download on the App Store" height="40">](https://apps.apple.com/us/app/agentos-coding-agents/id6820980797)
|
|
50
|
+
|
|
51
|
+
The iPhone app, [AgentOS: Coding Agents](https://apps.apple.com/us/app/agentos-coding-agents/id6820980797), is on the App Store.
|
|
52
|
+
|
|
49
53
|
You need Node.js 22+, tmux, and at least one agent CLI (the installer offers to set up what's missing).
|
|
50
54
|
|
|
51
55
|
```bash
|
|
@@ -71,6 +75,8 @@ Connect (in development) lets you reach AgentOS from anywhere, without a tailnet
|
|
|
71
75
|
agent-os connect # links this machine with the code from runagentos.com
|
|
72
76
|
```
|
|
73
77
|
|
|
78
|
+
Reach it from your phone with the [AgentOS iPhone app](https://apps.apple.com/us/app/agentos-coding-agents/id6820980797), now on the App Store.
|
|
79
|
+
|
|
74
80
|
The machine gets its own address at `https://m-<id>.runagentos.com`, served through a Cloudflare tunnel that AgentOS starts and stops with itself. Its key is made on the machine and never leaves it, and a device still has to be paired before it gets in. Connect also hosts the read-only links made with Share. How it works, and what it does and doesn't trust, is in [docs/reference/security.md](docs/reference/security.md#connect).
|
|
75
81
|
|
|
76
82
|
## Security
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
|
|
3
|
-
import { useEffect, useRef, type ReactNode } from "react";
|
|
3
|
+
import { Fragment, useEffect, useRef, type ReactNode } from "react";
|
|
4
4
|
import { RefreshCw } from "lucide-react";
|
|
5
5
|
import { cn } from "@/lib/utils";
|
|
6
6
|
|
|
@@ -10,6 +10,25 @@ export interface MenuOption {
|
|
|
10
10
|
icon?: ReactNode;
|
|
11
11
|
hint?: string;
|
|
12
12
|
description?: string;
|
|
13
|
+
// A section heading shown above this option when it starts a new section.
|
|
14
|
+
group?: string;
|
|
15
|
+
// A small label on the row (where a command comes from).
|
|
16
|
+
badge?: string;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
// The part of `text` that matches what was typed, picked out.
|
|
20
|
+
function Marked({ text, query }: { text: string; query?: string }) {
|
|
21
|
+
const at = query ? text.toLowerCase().indexOf(query) : -1;
|
|
22
|
+
if (at < 0 || !query) return <>{text}</>;
|
|
23
|
+
return (
|
|
24
|
+
<>
|
|
25
|
+
{text.slice(0, at)}
|
|
26
|
+
<mark className="text-primary bg-transparent font-semibold">
|
|
27
|
+
{text.slice(at, at + query.length)}
|
|
28
|
+
</mark>
|
|
29
|
+
{text.slice(at + query.length)}
|
|
30
|
+
</>
|
|
31
|
+
);
|
|
13
32
|
}
|
|
14
33
|
|
|
15
34
|
// The menu above the composer: "/" commands and skills, "@" files and
|
|
@@ -22,6 +41,7 @@ export function ComposerMenu({
|
|
|
22
41
|
onPick,
|
|
23
42
|
onHover,
|
|
24
43
|
refresh,
|
|
44
|
+
highlight,
|
|
25
45
|
}: {
|
|
26
46
|
label: string;
|
|
27
47
|
empty: string;
|
|
@@ -31,6 +51,7 @@ export function ComposerMenu({
|
|
|
31
51
|
onHover: (index: number) => void;
|
|
32
52
|
// Reloads the list (a skill added since it was read).
|
|
33
53
|
refresh?: { onClick: () => void; busy: boolean };
|
|
54
|
+
highlight?: string;
|
|
34
55
|
}) {
|
|
35
56
|
const listRef = useRef<HTMLDivElement>(null);
|
|
36
57
|
|
|
@@ -69,40 +90,59 @@ export function ComposerMenu({
|
|
|
69
90
|
<p className="text-muted-foreground px-2.5 py-2 text-sm">{empty}</p>
|
|
70
91
|
) : (
|
|
71
92
|
options.map((o, i) => (
|
|
72
|
-
<
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
data-index={i}
|
|
78
|
-
onMouseEnter={() => onHover(i)}
|
|
79
|
-
// Keep focus in the textarea so the keyboard stays up on phones.
|
|
80
|
-
onMouseDown={(e) => e.preventDefault()}
|
|
81
|
-
onClick={() => onPick(i)}
|
|
82
|
-
className={cn(
|
|
83
|
-
"flex min-h-11 w-full flex-col items-start justify-center rounded-lg px-2.5 py-1.5 text-left md:min-h-9",
|
|
84
|
-
i === active
|
|
85
|
-
? "bg-foreground/[0.07]"
|
|
86
|
-
: "hover:bg-foreground/[0.04]"
|
|
93
|
+
<Fragment key={o.key}>
|
|
94
|
+
{o.group && o.group !== options[i - 1]?.group && (
|
|
95
|
+
<div className="text-muted-foreground px-2.5 pt-2 pb-0.5 text-[11px] tracking-wide uppercase">
|
|
96
|
+
{o.group}
|
|
97
|
+
</div>
|
|
87
98
|
)}
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
99
|
+
<button
|
|
100
|
+
type="button"
|
|
101
|
+
role="option"
|
|
102
|
+
aria-selected={i === active}
|
|
103
|
+
data-index={i}
|
|
104
|
+
onMouseEnter={() => onHover(i)}
|
|
105
|
+
// Keep focus in the textarea so the keyboard stays up on phones.
|
|
106
|
+
onMouseDown={(e) => e.preventDefault()}
|
|
107
|
+
onClick={() => onPick(i)}
|
|
108
|
+
className={cn(
|
|
109
|
+
"flex min-h-11 w-full flex-col items-start justify-center rounded-lg px-2.5 py-1.5 text-left md:min-h-9",
|
|
110
|
+
i === active
|
|
111
|
+
? "bg-foreground/[0.07]"
|
|
112
|
+
: "hover:bg-foreground/[0.04]"
|
|
113
|
+
)}
|
|
114
|
+
>
|
|
115
|
+
<span className="flex w-full min-w-0 items-center gap-2">
|
|
116
|
+
{o.icon}
|
|
117
|
+
<span className="max-w-[65%] shrink-0 truncate font-mono text-sm">
|
|
118
|
+
{o.icon ? (
|
|
119
|
+
o.label
|
|
120
|
+
) : (
|
|
121
|
+
<Marked text={o.label} query={highlight} />
|
|
122
|
+
)}
|
|
123
|
+
</span>
|
|
124
|
+
{o.hint && (
|
|
125
|
+
<span className="text-muted-foreground/70 min-w-0 truncate font-mono text-xs">
|
|
126
|
+
{o.hint}
|
|
127
|
+
</span>
|
|
128
|
+
)}
|
|
129
|
+
{o.badge && (
|
|
130
|
+
<span className="text-muted-foreground border-border ml-auto shrink-0 rounded-full border px-2 text-[10px]">
|
|
131
|
+
{o.badge}
|
|
132
|
+
</span>
|
|
133
|
+
)}
|
|
93
134
|
</span>
|
|
94
|
-
{o.
|
|
95
|
-
<span className="text-muted-foreground
|
|
96
|
-
{o.
|
|
135
|
+
{o.description && (
|
|
136
|
+
<span className="text-muted-foreground line-clamp-2 w-full text-xs">
|
|
137
|
+
{o.icon ? (
|
|
138
|
+
o.description
|
|
139
|
+
) : (
|
|
140
|
+
<Marked text={o.description} query={highlight} />
|
|
141
|
+
)}
|
|
97
142
|
</span>
|
|
98
143
|
)}
|
|
99
|
-
</
|
|
100
|
-
|
|
101
|
-
<span className="text-muted-foreground w-full truncate text-xs">
|
|
102
|
-
{o.description}
|
|
103
|
-
</span>
|
|
104
|
-
)}
|
|
105
|
-
</button>
|
|
144
|
+
</button>
|
|
145
|
+
</Fragment>
|
|
106
146
|
))
|
|
107
147
|
)}
|
|
108
148
|
</div>
|
|
@@ -16,7 +16,14 @@ import {
|
|
|
16
16
|
composeMessage,
|
|
17
17
|
type TextAttachment,
|
|
18
18
|
} from "@/lib/chat/paste";
|
|
19
|
-
import {
|
|
19
|
+
import {
|
|
20
|
+
groupCommands,
|
|
21
|
+
insertCommand,
|
|
22
|
+
leadingCommand,
|
|
23
|
+
slashQuery,
|
|
24
|
+
sourceLabel,
|
|
25
|
+
} from "@/lib/chat/commands";
|
|
26
|
+
import { loadRecent, saveRecent } from "@/lib/chat/recent-commands";
|
|
20
27
|
import { mentionOption } from "@/lib/chat/mentions";
|
|
21
28
|
import { cn } from "@/lib/utils";
|
|
22
29
|
import { loadDraft, useSaveDraft } from "../useDraft";
|
|
@@ -184,25 +191,38 @@ export function ComposerBody({
|
|
|
184
191
|
const slash = slashQuery(caret.before);
|
|
185
192
|
const query = slash?.query ?? null;
|
|
186
193
|
const mentions = useMentions(editor, caret, query === null, requestFiles);
|
|
187
|
-
|
|
194
|
+
// Only read when the menu is open, so rendering on the server is harmless.
|
|
195
|
+
const [recent, setRecent] = useState(loadRecent);
|
|
196
|
+
const groups = useMemo(() => {
|
|
188
197
|
if (query === null) return [];
|
|
189
198
|
const all = [
|
|
190
199
|
...(onSetModel ? [MODEL_COMMAND] : []),
|
|
191
200
|
...commands.filter((c) => c.name !== "model"),
|
|
192
201
|
];
|
|
193
|
-
return
|
|
194
|
-
}, [query, commands, onSetModel]);
|
|
202
|
+
return groupCommands(query, all, recent);
|
|
203
|
+
}, [query, commands, onSetModel, recent]);
|
|
204
|
+
const rows = useMemo(
|
|
205
|
+
() => groups.flatMap((g) => g.commands.map((command) => ({ g, command }))),
|
|
206
|
+
[groups]
|
|
207
|
+
);
|
|
208
|
+
const matches = useMemo(() => rows.map((r) => r.command), [rows]);
|
|
195
209
|
// One menu at a time: "/" commands or "@" files at the caret.
|
|
196
210
|
const menuKey =
|
|
197
211
|
query !== null ? `/${caret.pos}:${caret.before}` : mentions.key;
|
|
198
212
|
const menuOpen = menuKey !== null && dismissed !== menuKey;
|
|
199
213
|
const options: MenuOption[] =
|
|
200
214
|
query !== null
|
|
201
|
-
?
|
|
215
|
+
? rows.map(({ g, command: c }, i) => ({
|
|
202
216
|
key: `${c.name}-${i}`,
|
|
203
217
|
label: `/${c.name}`,
|
|
204
218
|
hint: c.argumentHint,
|
|
205
219
|
description: c.description,
|
|
220
|
+
group: g.label || undefined,
|
|
221
|
+
// The heading already says it, except where a section mixes sources.
|
|
222
|
+
badge:
|
|
223
|
+
g.label === "Recent" || g.label === "Matched in description"
|
|
224
|
+
? (sourceLabel(c) ?? undefined)
|
|
225
|
+
: undefined,
|
|
206
226
|
}))
|
|
207
227
|
: mentions.files.map((f) => ({
|
|
208
228
|
key: `${f.dir ? "d" : "f"}:${f.path}`,
|
|
@@ -251,6 +271,9 @@ export function ComposerBody({
|
|
|
251
271
|
!text.trim() && !images.length && !files.length && !attach.uploads.length;
|
|
252
272
|
const submit = () => {
|
|
253
273
|
if (empty || disabled || attach.uploading) return;
|
|
274
|
+
const used = leadingCommand(text)?.slice(1);
|
|
275
|
+
if (used && commands.some((c) => c.name === used))
|
|
276
|
+
setRecent(saveRecent(used));
|
|
254
277
|
onSend(composeMessage(text, files), images, attached);
|
|
255
278
|
setImages([]);
|
|
256
279
|
setFiles([]);
|
|
@@ -308,6 +331,7 @@ export function ComposerBody({
|
|
|
308
331
|
label={query !== null ? "Commands" : "Files"}
|
|
309
332
|
empty={query !== null ? "No matching commands" : "No matching files"}
|
|
310
333
|
options={options}
|
|
334
|
+
highlight={query ?? undefined}
|
|
311
335
|
active={highlighted}
|
|
312
336
|
onPick={pick}
|
|
313
337
|
onHover={setActive}
|
|
@@ -61,7 +61,7 @@ export function WorkspaceSettings() {
|
|
|
61
61
|
/>
|
|
62
62
|
<SettingToggle
|
|
63
63
|
title="Orchestrator full access"
|
|
64
|
-
detail="Its shell and file writes aren't checked: it can push, commit, edit any repository or file on this machine, and merge a PR directly. Off: the deny list, write limits, quick-change cap and merge-only-through-the-gates apply."
|
|
64
|
+
detail="Its shell and file writes aren't checked: it can push, commit, edit any repository or file on this machine, and merge a PR directly, and edit .claude files (skills, settings, hooks). Applies when its conversation next restarts. Off: the deny list, write limits, quick-change cap and merge-only-through-the-gates apply."
|
|
65
65
|
checked={w.orch_full_access}
|
|
66
66
|
onChange={(on) => update.mutate({ id: w.id, orchFullAccess: on })}
|
|
67
67
|
/>
|
package/lib/chat/commands.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { ChatCommand } from "./events";
|
|
1
|
+
import type { ChatCommand, CommandSource } from "./events";
|
|
2
2
|
|
|
3
3
|
// The /command being typed: a "/" at the start of a line or after whitespace,
|
|
4
4
|
// then the partial name, right up to the caret. Not "a/b" or "http://x".
|
|
@@ -26,10 +26,15 @@ function score(q: string, c: ChatCommand): number {
|
|
|
26
26
|
if (parts.some((p) => p.startsWith(q))) return 60;
|
|
27
27
|
if (name.includes(q)) return 50;
|
|
28
28
|
if (isSubsequence(q, name)) return 20;
|
|
29
|
-
|
|
29
|
+
const desc = c.description.toLowerCase();
|
|
30
|
+
if (desc.split(/[^a-z0-9]+/).some((w) => w.startsWith(q))) return 12;
|
|
31
|
+
if (desc.includes(q)) return 10;
|
|
30
32
|
return 0;
|
|
31
33
|
}
|
|
32
34
|
|
|
35
|
+
// A match that only came from the description, not the name.
|
|
36
|
+
const DESCRIPTION_ONLY = 12;
|
|
37
|
+
|
|
33
38
|
// Best matches first; ties keep the agent's own order.
|
|
34
39
|
export function rankCommands(
|
|
35
40
|
query: string,
|
|
@@ -68,3 +73,68 @@ export function withFresh(commands: ChatCommand[]): ChatCommand[] {
|
|
|
68
73
|
? commands
|
|
69
74
|
: [FRESH_COMMAND, ...commands];
|
|
70
75
|
}
|
|
76
|
+
|
|
77
|
+
export interface MenuGroup {
|
|
78
|
+
label: string;
|
|
79
|
+
commands: ChatCommand[];
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const SOURCE_ORDER: CommandSource[] = [
|
|
83
|
+
"project",
|
|
84
|
+
"global",
|
|
85
|
+
"plugin",
|
|
86
|
+
"builtin",
|
|
87
|
+
];
|
|
88
|
+
|
|
89
|
+
export const SOURCE_LABEL: Record<CommandSource, string> = {
|
|
90
|
+
project: "Project",
|
|
91
|
+
global: "Global",
|
|
92
|
+
plugin: "Plugin",
|
|
93
|
+
builtin: "Built-in",
|
|
94
|
+
};
|
|
95
|
+
|
|
96
|
+
// "Global" / "Project" / "slack": the small label on a row.
|
|
97
|
+
export function sourceLabel(c: ChatCommand): string | null {
|
|
98
|
+
if (!c.source) return null;
|
|
99
|
+
return c.source === "plugin" && c.plugin ? c.plugin : SOURCE_LABEL[c.source];
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// The ranked commands as the menu shows them: what was used last, then one
|
|
103
|
+
// section per source, then what only matched by description. A command with
|
|
104
|
+
// no known source (an agent that doesn't report one) stays in one flat list.
|
|
105
|
+
export function groupCommands(
|
|
106
|
+
query: string,
|
|
107
|
+
commands: ChatCommand[],
|
|
108
|
+
recent: string[]
|
|
109
|
+
): MenuGroup[] {
|
|
110
|
+
const ranked = rankCommands(query, commands);
|
|
111
|
+
if (!commands.some((c) => c.source)) {
|
|
112
|
+
return ranked.length ? [{ label: "", commands: ranked }] : [];
|
|
113
|
+
}
|
|
114
|
+
const groups: MenuGroup[] = [];
|
|
115
|
+
const add = (label: string, list: ChatCommand[]) => {
|
|
116
|
+
if (list.length) groups.push({ label, commands: list });
|
|
117
|
+
};
|
|
118
|
+
const rest = (c: ChatCommand) => !recent.includes(c.name);
|
|
119
|
+
const used = recent
|
|
120
|
+
.map((n) => ranked.find((c) => c.name === n))
|
|
121
|
+
.filter((c): c is ChatCommand => !!c);
|
|
122
|
+
add("Recent", used);
|
|
123
|
+
const byName = (a: ChatCommand, b: ChatCommand) =>
|
|
124
|
+
a.name.localeCompare(b.name);
|
|
125
|
+
const named = ranked.filter(
|
|
126
|
+
(c) => rest(c) && (!query || score(query, c) > DESCRIPTION_ONLY)
|
|
127
|
+
);
|
|
128
|
+
for (const src of SOURCE_ORDER) {
|
|
129
|
+
const list = named.filter((c) => (c.source ?? "builtin") === src);
|
|
130
|
+
// Alphabetical when nothing was typed; best match first otherwise.
|
|
131
|
+
add(SOURCE_LABEL[src], query ? list : [...list].sort(byName));
|
|
132
|
+
}
|
|
133
|
+
add(
|
|
134
|
+
"Matched in description",
|
|
135
|
+
ranked.filter(
|
|
136
|
+
(c) => rest(c) && query && score(query, c) <= DESCRIPTION_ONLY
|
|
137
|
+
)
|
|
138
|
+
);
|
|
139
|
+
return groups;
|
|
140
|
+
}
|
package/lib/chat/events.ts
CHANGED
|
@@ -21,8 +21,14 @@ export interface ChatCommand {
|
|
|
21
21
|
description: string;
|
|
22
22
|
argumentHint?: string;
|
|
23
23
|
builtin?: boolean;
|
|
24
|
+
// Where it comes from, for grouping in the "/" menu.
|
|
25
|
+
source?: CommandSource;
|
|
26
|
+
// The plugin that ships it, when source is "plugin".
|
|
27
|
+
plugin?: string;
|
|
24
28
|
}
|
|
25
29
|
|
|
30
|
+
export type CommandSource = "project" | "global" | "plugin" | "builtin";
|
|
31
|
+
|
|
26
32
|
export interface ChatModel {
|
|
27
33
|
value: string;
|
|
28
34
|
label: string;
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
// The commands and skills last used from the composer, newest first. Kept in
|
|
2
|
+
// the browser: a short list that only matters to this device.
|
|
3
|
+
|
|
4
|
+
const KEY = "agentos:recent-commands";
|
|
5
|
+
const MAX = 5;
|
|
6
|
+
|
|
7
|
+
export function pushRecent(list: string[], name: string): string[] {
|
|
8
|
+
return [name, ...list.filter((n) => n !== name)].slice(0, MAX);
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function loadRecent(): string[] {
|
|
12
|
+
try {
|
|
13
|
+
const v: unknown = JSON.parse(localStorage.getItem(KEY) ?? "[]");
|
|
14
|
+
return Array.isArray(v)
|
|
15
|
+
? v.filter((n): n is string => typeof n === "string").slice(0, MAX)
|
|
16
|
+
: [];
|
|
17
|
+
} catch {
|
|
18
|
+
return [];
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function saveRecent(name: string): string[] {
|
|
23
|
+
const next = pushRecent(loadRecent(), name);
|
|
24
|
+
try {
|
|
25
|
+
localStorage.setItem(KEY, JSON.stringify(next));
|
|
26
|
+
} catch {}
|
|
27
|
+
return next;
|
|
28
|
+
}
|
package/lib/chat/settings.ts
CHANGED
|
@@ -16,6 +16,7 @@ import {
|
|
|
16
16
|
type ChatCommand,
|
|
17
17
|
type ChatServerMessage,
|
|
18
18
|
} from "./events";
|
|
19
|
+
import { tagSources } from "./skill-source";
|
|
19
20
|
import {
|
|
20
21
|
createSkillWatcher,
|
|
21
22
|
skillFolders,
|
|
@@ -200,6 +201,7 @@ async function loadCapabilities(
|
|
|
200
201
|
if (w.loads.get(key)?.gen !== gen) return;
|
|
201
202
|
registry.caps.set(key, {
|
|
202
203
|
...found,
|
|
204
|
+
commands: tagSources(found.commands, cwdOf(session)),
|
|
203
205
|
terminalOnly: registry.caps.get(key)?.terminalOnly ?? new Set(),
|
|
204
206
|
at: Date.now(),
|
|
205
207
|
});
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tells the "/" menu where each command or skill comes from: the project's
|
|
3
|
+
* own folders, the user's, a plugin (its name carries the plugin as a
|
|
4
|
+
* "plugin:" prefix), or the agent itself.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import fs from "fs";
|
|
8
|
+
import os from "os";
|
|
9
|
+
import path from "path";
|
|
10
|
+
import { SKILL_DIRS } from "../agents/skill-dirs";
|
|
11
|
+
import type { ChatCommand } from "./events";
|
|
12
|
+
|
|
13
|
+
const claudeDir = () =>
|
|
14
|
+
process.env.CLAUDE_CONFIG_DIR || path.join(os.homedir(), ".claude");
|
|
15
|
+
|
|
16
|
+
function entries(dir: string): fs.Dirent[] {
|
|
17
|
+
try {
|
|
18
|
+
return fs.readdirSync(dir, { withFileTypes: true });
|
|
19
|
+
} catch {
|
|
20
|
+
return [];
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const isDir = (e: fs.Dirent, dir: string) =>
|
|
25
|
+
e.isDirectory() ||
|
|
26
|
+
(e.isSymbolicLink() && fs.statSync(path.join(dir, e.name)).isDirectory());
|
|
27
|
+
|
|
28
|
+
// The name in a SKILL.md's frontmatter, when it differs from the folder.
|
|
29
|
+
function declaredName(file: string): string | null {
|
|
30
|
+
try {
|
|
31
|
+
const head = fs.readFileSync(file, "utf8").slice(0, 1024);
|
|
32
|
+
const fm = /^---\r?\n([\s\S]*?)\r?\n---/.exec(head)?.[1] ?? "";
|
|
33
|
+
return /^name:\s*["']?([^\s"']+)/m.exec(fm)?.[1] ?? null;
|
|
34
|
+
} catch {
|
|
35
|
+
return null;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// Names a folder of skills and commands offers: skill folders, and command
|
|
40
|
+
// files (nested ones as "dir:name").
|
|
41
|
+
export function namesIn(root: string): Set<string> {
|
|
42
|
+
const names = new Set<string>();
|
|
43
|
+
const skills = path.join(root, "skills");
|
|
44
|
+
for (const e of entries(skills)) {
|
|
45
|
+
try {
|
|
46
|
+
if (!isDir(e, skills)) continue;
|
|
47
|
+
} catch {
|
|
48
|
+
continue;
|
|
49
|
+
}
|
|
50
|
+
names.add(e.name);
|
|
51
|
+
const declared = declaredName(path.join(skills, e.name, "SKILL.md"));
|
|
52
|
+
if (declared) names.add(declared);
|
|
53
|
+
}
|
|
54
|
+
const commands = path.join(root, "commands");
|
|
55
|
+
const walk = (dir: string, prefix: string) => {
|
|
56
|
+
for (const e of entries(dir)) {
|
|
57
|
+
if (e.isDirectory()) walk(path.join(dir, e.name), `${prefix}${e.name}:`);
|
|
58
|
+
else if (e.name.endsWith(".md"))
|
|
59
|
+
names.add(`${prefix}${e.name.slice(0, -3)}`);
|
|
60
|
+
}
|
|
61
|
+
};
|
|
62
|
+
walk(commands, "");
|
|
63
|
+
return names;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function projectNames(cwd: string): Set<string> {
|
|
67
|
+
const names = new Set<string>();
|
|
68
|
+
const home = os.homedir();
|
|
69
|
+
// The folder and its parents, up to home: a repo's skills reach its subfolders.
|
|
70
|
+
for (let dir = cwd; dir !== home && dir !== path.dirname(dir);) {
|
|
71
|
+
for (const rel of SKILL_DIRS) {
|
|
72
|
+
// SKILL_DIRS are ".claude/skills" style; namesIn wants the parent.
|
|
73
|
+
for (const n of namesIn(path.join(dir, path.dirname(rel)))) names.add(n);
|
|
74
|
+
}
|
|
75
|
+
dir = path.dirname(dir);
|
|
76
|
+
}
|
|
77
|
+
return names;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export function tagSources(
|
|
81
|
+
commands: ChatCommand[],
|
|
82
|
+
cwd: string
|
|
83
|
+
): ChatCommand[] {
|
|
84
|
+
const project = projectNames(cwd);
|
|
85
|
+
const user = namesIn(claudeDir());
|
|
86
|
+
return commands.map((c) => {
|
|
87
|
+
if (project.has(c.name)) return { ...c, source: "project" };
|
|
88
|
+
if (user.has(c.name)) return { ...c, source: "global" };
|
|
89
|
+
if (c.builtin) return { ...c, source: "builtin" };
|
|
90
|
+
const colon = c.name.indexOf(":");
|
|
91
|
+
if (colon > 0)
|
|
92
|
+
return { ...c, source: "plugin", plugin: c.name.slice(0, colon) };
|
|
93
|
+
return { ...c, source: "builtin" };
|
|
94
|
+
});
|
|
95
|
+
}
|
|
@@ -78,9 +78,15 @@ export const ENV_CLASSES: readonly EnvClass[] = [
|
|
|
78
78
|
id: "local-permission",
|
|
79
79
|
label: "Local permission denied",
|
|
80
80
|
scope: "shell",
|
|
81
|
-
|
|
81
|
+
// A quoted phrase is source code (`error: "Permission denied"`), not an error.
|
|
82
|
+
match: [
|
|
83
|
+
/(?<!["'`])Permission denied/,
|
|
84
|
+
/(?<!["'`])\bEACCES\b/,
|
|
85
|
+
/(?<!["'`])Operation not permitted/,
|
|
86
|
+
],
|
|
87
|
+
// Another machine or a container: not this machine's permissions.
|
|
82
88
|
except: {
|
|
83
|
-
command: /\b(?:ssh|scp|sftp|rsync|kubectl|docker
|
|
89
|
+
command: /\b(?:ssh|scp|sftp|rsync|kubectl|docker|podman)\b/,
|
|
84
90
|
output: /\(publickey/,
|
|
85
91
|
},
|
|
86
92
|
fix: null,
|
|
@@ -28,11 +28,48 @@ function commandOf(tool: string, input: unknown): string {
|
|
|
28
28
|
return typeof file === "string" ? `${tool} ${genericPath(file)}` : tool;
|
|
29
29
|
}
|
|
30
30
|
|
|
31
|
+
// The command's parts, split on pipes, `;`, `&&` and newlines outside quotes,
|
|
32
|
+
// so `grep -E "a|b"` stays one part.
|
|
33
|
+
function commandParts(command: string): string[] {
|
|
34
|
+
const parts: string[] = [];
|
|
35
|
+
let cur = "";
|
|
36
|
+
let quote = "";
|
|
37
|
+
for (let i = 0; i < command.length; i++) {
|
|
38
|
+
const ch = command[i];
|
|
39
|
+
if (quote) {
|
|
40
|
+
if (ch === "\\" && quote === '"') cur += ch + (command[++i] ?? "");
|
|
41
|
+
else {
|
|
42
|
+
if (ch === quote) quote = "";
|
|
43
|
+
cur += ch;
|
|
44
|
+
}
|
|
45
|
+
continue;
|
|
46
|
+
}
|
|
47
|
+
if (ch === '"' || ch === "'") quote = ch;
|
|
48
|
+
// A lone `&` (`2>&1`, a background job) doesn't end a part.
|
|
49
|
+
const split =
|
|
50
|
+
ch === "\n" ||
|
|
51
|
+
ch === ";" ||
|
|
52
|
+
ch === "|" ||
|
|
53
|
+
(ch === "&" && command[i + 1] === "&");
|
|
54
|
+
if (split) {
|
|
55
|
+
if (command[i + 1] === ch && ch !== "\n" && ch !== ";") i++;
|
|
56
|
+
parts.push(cur);
|
|
57
|
+
cur = "";
|
|
58
|
+
continue;
|
|
59
|
+
}
|
|
60
|
+
cur += ch;
|
|
61
|
+
}
|
|
62
|
+
parts.push(cur);
|
|
63
|
+
return parts;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// Leading `VAR=value` assignments, as in `W=/path cd $W`.
|
|
67
|
+
const ASSIGNMENTS = /^(?:[A-Za-z_][A-Za-z0-9_]*=(?:"[^"]*"|'[^']*'|\S*)\s*)+/;
|
|
68
|
+
|
|
31
69
|
// True when every part of the command only reads (after `cd`).
|
|
32
70
|
function onlyReads(command: string): boolean {
|
|
33
|
-
const parts = command
|
|
34
|
-
.
|
|
35
|
-
.map((p) => p.trim())
|
|
71
|
+
const parts = commandParts(command)
|
|
72
|
+
.map((p) => p.trim().replace(ASSIGNMENTS, ""))
|
|
36
73
|
.filter((p) => p && !/^cd\s/.test(p));
|
|
37
74
|
return parts.length > 0 && parts.every((p) => READERS.test(p));
|
|
38
75
|
}
|
|
@@ -10,6 +10,7 @@
|
|
|
10
10
|
*/
|
|
11
11
|
|
|
12
12
|
import fs from "fs";
|
|
13
|
+
import type { PermissionMode } from "@anthropic-ai/claude-agent-sdk";
|
|
13
14
|
import os from "os";
|
|
14
15
|
import path from "path";
|
|
15
16
|
import { ORCHESTRATOR_PERMISSIONS } from "./tool-names";
|
|
@@ -26,12 +27,19 @@ export const toolPrefix = (server: string) =>
|
|
|
26
27
|
|
|
27
28
|
// The permissions for an orchestrator whose workspace allows these servers:
|
|
28
29
|
// a bare mcp__<server> rule allows all of that server's tools.
|
|
29
|
-
|
|
30
|
+
//
|
|
31
|
+
// With full access on, the mode is bypassPermissions: Claude Code always asks
|
|
32
|
+
// before writing under .claude/ (skills, settings, hooks), and in dontAsk
|
|
33
|
+
// mode that ask is a denial. Off keeps dontAsk and the write guard.
|
|
34
|
+
export function orchestratorPermissions(servers: string[], fullAccess = false) {
|
|
30
35
|
const rules = [...new Set(servers.map((s) => s.trim()).filter(Boolean))].map(
|
|
31
36
|
toolPrefix
|
|
32
37
|
);
|
|
33
38
|
return {
|
|
34
39
|
...ORCHESTRATOR_PERMISSIONS,
|
|
40
|
+
permissionMode: (fullAccess
|
|
41
|
+
? "bypassPermissions"
|
|
42
|
+
: ORCHESTRATOR_PERMISSIONS.permissionMode) as PermissionMode,
|
|
35
43
|
allowedTools: [...ORCHESTRATOR_PERMISSIONS.allowedTools, ...rules],
|
|
36
44
|
};
|
|
37
45
|
}
|
|
@@ -12,6 +12,7 @@ import {
|
|
|
12
12
|
} from "./tools";
|
|
13
13
|
import type { RoleExtras } from "../chat/worker/extras";
|
|
14
14
|
import { refreshSettings } from "../chat/refresh";
|
|
15
|
+
import { orchestratorFullAccess } from "./full-access";
|
|
15
16
|
import { orchestratorToolRefusal } from "./write-guard";
|
|
16
17
|
|
|
17
18
|
// What an orchestrator's chat worker starts with on top of any chat: its
|
|
@@ -22,7 +23,8 @@ export async function orchestratorExtras(
|
|
|
22
23
|
): Promise<RoleExtras> {
|
|
23
24
|
const baseUrl = agentEnv(session.id).AGENTOS_URL;
|
|
24
25
|
const permissions = orchestratorPermissions(
|
|
25
|
-
getWorkspace(session.workspace_id ?? "")?.orch_mcp_servers ?? []
|
|
26
|
+
getWorkspace(session.workspace_id ?? "")?.orch_mcp_servers ?? [],
|
|
27
|
+
orchestratorFullAccess(session)
|
|
26
28
|
);
|
|
27
29
|
return {
|
|
28
30
|
systemAppend: await loadOrchestratorBrief(session),
|