@saadnvd1/agent-os 0.3.468 → 0.3.471

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (64) hide show
  1. package/README.md +67 -37
  2. package/app/api/fresh-start/route.ts +9 -5
  3. package/app/api/projects/clone/route.ts +4 -2
  4. package/app/api/projects/init/route.test.ts +46 -0
  5. package/app/api/projects/init/route.ts +24 -4
  6. package/app/api/usage/route.ts +4 -2
  7. package/components/Chat/ChatPanel.tsx +5 -5
  8. package/components/Chat/StaleChip.tsx +7 -6
  9. package/components/CommandPalette/CommandPalette.tsx +3 -1
  10. package/components/CommandPalette/useAppCommands.ts +10 -0
  11. package/components/Projects/AddProject/AddProjectDialog.tsx +25 -5
  12. package/components/Projects/AddProject/CloneForm.tsx +3 -1
  13. package/components/Projects/AddProject/FolderForm.tsx +98 -4
  14. package/components/Projects/AddProject/NameForm.tsx +3 -1
  15. package/components/Projects/AddProject/WorkspacePick.tsx +56 -0
  16. package/components/Projects/AddProject/useAddExistingProject.ts +58 -0
  17. package/components/SessionList/WorkspaceSwitcher.tsx +10 -0
  18. package/components/Usage/UsageDialog.tsx +31 -2
  19. package/components/Workspaces/FreshStartForm.tsx +13 -13
  20. package/data/projects/add.ts +23 -3
  21. package/data/usage/index.ts +2 -0
  22. package/lib/agents/launch.ts +2 -0
  23. package/lib/chat/driver.ts +8 -0
  24. package/lib/chat/drivers/claude-approvals.ts +40 -0
  25. package/lib/chat/drivers/claude.ts +7 -10
  26. package/lib/chat/refresh.ts +31 -13
  27. package/lib/chat/stale.ts +25 -16
  28. package/lib/chat/worker/extras.ts +1 -0
  29. package/lib/chat/worker/host.ts +61 -22
  30. package/lib/db/migrations.ts +42 -0
  31. package/lib/orchestrator/add-project.ts +35 -0
  32. package/lib/orchestrator/armed.ts +1 -2
  33. package/lib/orchestrator/brief.ts +6 -5
  34. package/lib/orchestrator/claude-cli.ts +5 -0
  35. package/lib/orchestrator/diff.ts +6 -2
  36. package/lib/orchestrator/external-gates.ts +8 -43
  37. package/lib/orchestrator/gates.ts +10 -10
  38. package/lib/orchestrator/pr-wake.ts +2 -2
  39. package/lib/orchestrator/rebase.ts +6 -45
  40. package/lib/orchestrator/review.ts +18 -5
  41. package/lib/orchestrator/scope.ts +9 -0
  42. package/lib/orchestrator/serve.ts +6 -0
  43. package/lib/orchestrator/signoff.ts +13 -8
  44. package/lib/orchestrator/tool-names.ts +13 -6
  45. package/lib/orchestrator/tool-schemas.ts +15 -0
  46. package/lib/orchestrator/tools.ts +2 -0
  47. package/lib/orchestrator/worker.ts +4 -1
  48. package/lib/orchestrator/write-guard.ts +295 -0
  49. package/lib/project-add/index.ts +133 -14
  50. package/lib/project-add/name.ts +1 -0
  51. package/lib/secret-paths.ts +9 -0
  52. package/lib/session-audit/render.ts +1 -1
  53. package/lib/stacks/land.ts +23 -16
  54. package/lib/tasks/brief.ts +3 -14
  55. package/lib/tasks/code-review.ts +69 -107
  56. package/lib/tasks/gh.ts +3 -2
  57. package/lib/tasks/index.ts +1 -1
  58. package/lib/tasks/state.ts +4 -4
  59. package/lib/usage/check-runs.ts +167 -0
  60. package/lib/usage/report.ts +25 -0
  61. package/lib/usage/turns.ts +28 -0
  62. package/package.json +1 -1
  63. package/stores/addProject.ts +12 -3
  64. package/scripts/check-code-review.ts +0 -21
@@ -2,18 +2,52 @@
2
2
 
3
3
  import { useState } from "react";
4
4
  import { Button } from "@/components/ui/button";
5
- import { useInitProject } from "@/data/projects";
5
+ import {
6
+ ProjectExistsError,
7
+ useInitProject,
8
+ type ExistingProject,
9
+ } from "@/data/projects";
10
+ import { useWorkspacesQuery } from "@/data/workspaces";
6
11
  import { FolderBrowser } from "./FolderBrowser";
7
12
 
8
13
  export function FolderForm({
9
14
  hostId,
15
+ workspaceId,
10
16
  onDone,
11
17
  }: {
12
18
  hostId: string;
19
+ workspaceId: string | null;
13
20
  onDone: (projectId: string) => void;
14
21
  }) {
15
22
  const [path, setPath] = useState("~");
16
23
  const init = useInitProject();
24
+ const [existing, setExisting] = useState<ExistingProject | null>(null);
25
+ const add = (moveExisting = false) =>
26
+ init.mutate(
27
+ { hostId, path, workspaceId, moveExisting },
28
+ {
29
+ onSuccess: (p) => onDone(p.id),
30
+ onError: (e) =>
31
+ e instanceof ProjectExistsError && setExisting(e.existing),
32
+ }
33
+ );
34
+
35
+ if (existing)
36
+ return (
37
+ <AlreadyAProject
38
+ existing={existing}
39
+ workspaceId={workspaceId}
40
+ pending={init.isPending}
41
+ error={init.error instanceof ProjectExistsError ? null : init.error}
42
+ onMove={() => add(true)}
43
+ onOpen={() => onDone(existing.id)}
44
+ onBack={() => {
45
+ setExisting(null);
46
+ init.reset();
47
+ }}
48
+ />
49
+ );
50
+
17
51
  return (
18
52
  <div className="space-y-3">
19
53
  <FolderBrowser hostId={hostId} path={path} onNavigate={setPath} />
@@ -23,12 +57,72 @@ export function FolderForm({
23
57
  <Button
24
58
  className="h-11 w-full md:h-9"
25
59
  disabled={init.isPending}
26
- onClick={() =>
27
- init.mutate({ hostId, path }, { onSuccess: (p) => onDone(p.id) })
28
- }
60
+ onClick={() => add()}
29
61
  >
30
62
  {init.isPending ? "Adding…" : "Use this folder"}
31
63
  </Button>
32
64
  </div>
33
65
  );
34
66
  }
67
+
68
+ // The folder is a project already: move it into the workspace this adds
69
+ // to, or just start in it.
70
+ function AlreadyAProject({
71
+ existing,
72
+ workspaceId,
73
+ pending,
74
+ error,
75
+ onMove,
76
+ onOpen,
77
+ onBack,
78
+ }: {
79
+ existing: ExistingProject;
80
+ workspaceId: string | null;
81
+ pending: boolean;
82
+ error: Error | null;
83
+ onMove: () => void;
84
+ onOpen: () => void;
85
+ onBack: () => void;
86
+ }) {
87
+ const { data: workspaces = [] } = useWorkspacesQuery();
88
+ const nameOf = (id: string | null) =>
89
+ workspaces.find((w) => w.id === id)?.name ?? null;
90
+ const from = nameOf(existing.workspaceId);
91
+ const to = nameOf(workspaceId);
92
+ const canMove = !!to && existing.workspaceId !== workspaceId;
93
+ return (
94
+ <div className="space-y-3">
95
+ <p className="text-sm">
96
+ <span className="font-medium">{existing.name}</span> is already a
97
+ project, {from ? `in ${from}` : "in no workspace"}.
98
+ </p>
99
+ {error && <p className="text-destructive text-sm">{error.message}</p>}
100
+ <div className="flex flex-col gap-2 sm:flex-row">
101
+ {canMove && (
102
+ <Button
103
+ className="h-11 flex-1 md:h-9"
104
+ disabled={pending}
105
+ onClick={onMove}
106
+ >
107
+ {pending ? "Moving…" : `Move it to ${to}`}
108
+ </Button>
109
+ )}
110
+ <Button
111
+ variant={canMove ? "outline" : "default"}
112
+ className="h-11 flex-1 md:h-9"
113
+ disabled={pending}
114
+ onClick={onOpen}
115
+ >
116
+ Start a session in it
117
+ </Button>
118
+ </div>
119
+ <button
120
+ type="button"
121
+ onClick={onBack}
122
+ className="text-primary min-h-11 px-1 text-sm md:min-h-8"
123
+ >
124
+ Pick another folder
125
+ </button>
126
+ </div>
127
+ );
128
+ }
@@ -10,9 +10,11 @@ import { ParentPicker } from "./ParentPicker";
10
10
  // a separate, explicit click.
11
11
  export function NameForm({
12
12
  hostId,
13
+ workspaceId,
13
14
  onDone,
14
15
  }: {
15
16
  hostId: string;
17
+ workspaceId: string | null;
16
18
  onDone: (projectId: string) => void;
17
19
  }) {
18
20
  const [name, setName] = useState("");
@@ -67,7 +69,7 @@ export function NameForm({
67
69
  className="space-y-3"
68
70
  onSubmit={(e) => {
69
71
  e.preventDefault();
70
- init.mutate({ hostId, parent, name: name.trim() });
72
+ init.mutate({ hostId, parent, name: name.trim(), workspaceId });
71
73
  }}
72
74
  >
73
75
  <Input
@@ -0,0 +1,56 @@
1
+ "use client";
2
+
3
+ import { LayoutGrid } from "lucide-react";
4
+ import {
5
+ Select,
6
+ SelectContent,
7
+ SelectItem,
8
+ SelectTrigger,
9
+ SelectValue,
10
+ } from "@/components/ui/select";
11
+ import { useWorkspacesQuery } from "@/data/workspaces";
12
+
13
+ const NONE = "none";
14
+
15
+ // "Adds to Internal Tools", changeable to another workspace or none.
16
+ export function WorkspacePick({
17
+ workspaceId,
18
+ onChange,
19
+ }: {
20
+ workspaceId: string | null;
21
+ onChange: (workspaceId: string | null) => void;
22
+ }) {
23
+ const { data: workspaces = [] } = useWorkspacesQuery();
24
+ if (workspaces.length === 0) return null;
25
+ // One deleted since the dialog opened reads as none.
26
+ const value = workspaces.some((w) => w.id === workspaceId)
27
+ ? (workspaceId as string)
28
+ : NONE;
29
+ return (
30
+ <div className="flex items-center gap-2 text-sm">
31
+ <LayoutGrid className="text-muted-foreground h-3.5 w-3.5 shrink-0" />
32
+ <span className="text-muted-foreground shrink-0">Adds to</span>
33
+ <Select
34
+ value={value}
35
+ onValueChange={(v) => onChange(v === NONE ? null : v)}
36
+ >
37
+ <SelectTrigger
38
+ aria-label="Workspace"
39
+ className="h-11 min-w-0 flex-1 md:h-9"
40
+ >
41
+ <SelectValue />
42
+ </SelectTrigger>
43
+ <SelectContent>
44
+ <SelectItem value={NONE} className="min-h-11 md:min-h-0">
45
+ No workspace
46
+ </SelectItem>
47
+ {workspaces.map((w) => (
48
+ <SelectItem key={w.id} value={w.id} className="min-h-11 md:min-h-0">
49
+ {w.name}
50
+ </SelectItem>
51
+ ))}
52
+ </SelectContent>
53
+ </Select>
54
+ </div>
55
+ );
56
+ }
@@ -0,0 +1,58 @@
1
+ "use client";
2
+
3
+ import { FolderGit2, FolderPlus } from "lucide-react";
4
+ import { toast } from "sonner";
5
+ import type { Workspace } from "@/lib/db";
6
+ import { useProjectsQuery } from "@/data/projects";
7
+ import {
8
+ useMoveProjectToWorkspace,
9
+ useWorkspacesQuery,
10
+ } from "@/data/workspaces";
11
+ import { paletteActions } from "@/stores/palette";
12
+ import { useAddProject } from "./useAddProject";
13
+
14
+ // "Add existing project…": a searchable pick of the projects not in this
15
+ // workspace, under the workspace each is in now; picking one moves it in.
16
+ // A new one is the first choice.
17
+ export function useAddExistingProject() {
18
+ const { data: projects = [] } = useProjectsQuery();
19
+ const { data: workspaces = [] } = useWorkspacesQuery();
20
+ const move = useMoveProjectToWorkspace();
21
+ const addProject = useAddProject();
22
+ return (workspace: Workspace) => {
23
+ const nameOf = (id: string | null) =>
24
+ workspaces.find((w) => w.id === id)?.name ?? "No workspace";
25
+ const others = projects
26
+ .filter((p) => !p.is_uncategorized && p.workspace_id !== workspace.id)
27
+ .sort(
28
+ (a, b) =>
29
+ nameOf(a.workspace_id).localeCompare(nameOf(b.workspace_id)) ||
30
+ a.name.localeCompare(b.name)
31
+ );
32
+ paletteActions.pick(`Add a project to ${workspace.name}…`, [
33
+ {
34
+ id: "add-existing.new",
35
+ title: "New project (folder, clone or name)…",
36
+ group: "",
37
+ icon: FolderPlus,
38
+ run: addProject,
39
+ },
40
+ ...others.map((p) => ({
41
+ id: `add-existing.${p.id}`,
42
+ title: p.name,
43
+ group: nameOf(p.workspace_id),
44
+ keywords: [p.working_directory],
45
+ icon: FolderGit2,
46
+ run: () =>
47
+ move.mutate(
48
+ { projectId: p.id, workspaceId: workspace.id },
49
+ {
50
+ onSuccess: () =>
51
+ toast.success(`${p.name} moved to ${workspace.name}`),
52
+ onError: (e) => toast.error(e.message),
53
+ }
54
+ ),
55
+ })),
56
+ ]);
57
+ };
58
+ }
@@ -7,6 +7,7 @@ import {
7
7
  ChevronDown,
8
8
  CircleCheck,
9
9
  Clock,
10
+ FolderPlus,
10
11
  Gauge,
11
12
  LayoutGrid,
12
13
  Pencil,
@@ -29,6 +30,7 @@ import { archivedUiActions, cleanupUiActions } from "@/stores/archivedUi";
29
30
  import { orchestratorOpenActions } from "@/stores/orchestratorOpen";
30
31
  import { sidebarUiActions } from "@/stores/sidebarUi";
31
32
  import { schedulesUiActions } from "@/stores/schedulesUi";
33
+ import { useAddExistingProject } from "@/components/Projects/AddProject/useAddExistingProject";
32
34
 
33
35
  const icon = "mr-2 h-3.5 w-3.5";
34
36
 
@@ -44,6 +46,7 @@ export function WorkspaceSwitcher({
44
46
  const create = useCreateWorkspace();
45
47
  const update = useUpdateWorkspace();
46
48
  const remove = useDeleteWorkspace();
49
+ const addExisting = useAddExistingProject();
47
50
  const [dialog, setDialog] = useState<"new" | "rename" | null>(null);
48
51
  const name = current?.name ?? "All workspaces";
49
52
 
@@ -94,6 +97,13 @@ export function WorkspaceSwitcher({
94
97
  {current && (
95
98
  <>
96
99
  <DM.DropdownMenuSeparator />
100
+ <DM.DropdownMenuItem
101
+ onClick={() => addExisting(current)}
102
+ className="min-h-11 md:min-h-0"
103
+ >
104
+ <FolderPlus className={icon} />
105
+ Add existing project…
106
+ </DM.DropdownMenuItem>
97
107
  <DM.DropdownMenuItem
98
108
  onClick={() => orchestratorOpenActions.request(current.id)}
99
109
  >
@@ -41,8 +41,8 @@ export function UsageDialog() {
41
41
  <DialogHeader>
42
42
  <DialogTitle>Usage</DialogTitle>
43
43
  <DialogDescription>
44
- The account&apos;s limits, and what chat sessions on this machine
45
- cost.
44
+ The account&apos;s limits, and what chat sessions and gate reviews
45
+ on this machine cost.
46
46
  </DialogDescription>
47
47
  </DialogHeader>
48
48
 
@@ -124,6 +124,35 @@ export function UsageDialog() {
124
124
  </>
125
125
  )}
126
126
  </section>
127
+
128
+ {data?.reviews && data.reviews.total.turns > 0 && (
129
+ <section className="space-y-3">
130
+ <h3 className="text-sm font-medium">Gate reviews</h3>
131
+ <div className="flex items-baseline gap-2">
132
+ <span className="text-2xl font-semibold tabular-nums">
133
+ {formatUsd(data.reviews.total.costUsd)}
134
+ </span>
135
+ <span className="text-muted-foreground text-xs">
136
+ estimate · {data.reviews.total.turns} runs ·{" "}
137
+ {data.reviews.total.tokens.toLocaleString()} tokens
138
+ </span>
139
+ </div>
140
+ <SpendList
141
+ title="By PR"
142
+ rows={data.reviews.sessions.map((s) => ({
143
+ key: s.id,
144
+ label: s.name,
145
+ ...s,
146
+ }))}
147
+ total={data.reviews.total}
148
+ limit={10}
149
+ />
150
+ <p className="text-muted-foreground text-[11px]">
151
+ Each run of the orchestrator&apos;s independent reviewer and scope
152
+ check, from its own result. Not part of chat spend.
153
+ </p>
154
+ </section>
155
+ )}
127
156
  </DialogContent>
128
157
  </Dialog>
129
158
  );
@@ -19,7 +19,7 @@ export function FreshStartForm() {
19
19
  return <p className="text-destructive text-sm">{error.message}</p>;
20
20
  return (
21
21
  <Form
22
- key={`${data.orchestratorTokens}:${data.taskTokens}:${data.staleCompactTokens}`}
22
+ key={`${data.orchestratorTokens}:${data.taskTokens}:${data.compactTokens}`}
23
23
  saved={data}
24
24
  />
25
25
  );
@@ -29,13 +29,13 @@ function Form({ saved }: { saved: RefreshSettings }) {
29
29
  const update = useUpdateFreshStart();
30
30
  const orchestrator = useThreshold(saved.orchestratorTokens, 200);
31
31
  const task = useThreshold(saved.taskTokens, 200);
32
- const stale = useThreshold(saved.staleCompactTokens, 150);
33
- const error = orchestrator.error ?? task.error ?? stale.error;
32
+ const compact = useThreshold(saved.compactTokens, 150);
33
+ const error = orchestrator.error ?? task.error ?? compact.error;
34
34
  const dirty =
35
35
  !error &&
36
36
  (orchestrator.tokens !== saved.orchestratorTokens ||
37
37
  task.tokens !== saved.taskTokens ||
38
- stale.tokens !== saved.staleCompactTokens);
38
+ compact.tokens !== saved.compactTokens);
39
39
 
40
40
  return (
41
41
  <form
@@ -46,7 +46,7 @@ function Form({ saved }: { saved: RefreshSettings }) {
46
46
  {
47
47
  orchestratorTokens: orchestrator.tokens,
48
48
  taskTokens: task.tokens,
49
- staleCompactTokens: stale.tokens,
49
+ compactTokens: compact.tokens,
50
50
  },
51
51
  {
52
52
  onSuccess: () => toast.success("Context settings saved"),
@@ -65,17 +65,17 @@ function Form({ saved }: { saved: RefreshSettings }) {
65
65
  </p>
66
66
  <ThresholdRow label="Orchestrators" field={orchestrator} />
67
67
  <ThresholdRow label="Tasks" field={task} />
68
- <h3 className="pt-2 text-sm font-medium">Compact idle chats</h3>
68
+ <h3 className="pt-2 text-sm font-medium">Compact</h3>
69
69
  <p className="text-muted-foreground text-sm">
70
- A message to a chat idle past its prompt cache re-reads the whole
71
- conversation at full price. Above this size, messages from the
72
- orchestrator, events and schedules compact it first; the composer offers
73
- to.
70
+ Chats and tasks compact when their context passes this size: Claude Code
71
+ does it itself, and between turns AgentOS compacts a chat over it, or
72
+ idle past its prompt cache, before the next message. The composer offers
73
+ to first. Orchestrators start fresh instead.
74
74
  </p>
75
75
  <ThresholdRow
76
- label="Idle chats"
77
- field={stale}
78
- switchLabel="Compact idle chats before a message"
76
+ label="Chats and tasks"
77
+ field={compact}
78
+ switchLabel="Compact chats and tasks at this size"
79
79
  />
80
80
  {error && <p className="text-destructive text-xs">{error}</p>}
81
81
  <div className="flex justify-end">
@@ -2,8 +2,26 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
2
2
  import type { CloneJob, FolderListing } from "@/lib/project-add";
3
3
  import { projectKeys } from "./keys";
4
4
 
5
+ export interface ExistingProject {
6
+ id: string;
7
+ name: string;
8
+ workspaceId: string | null;
9
+ }
10
+
11
+ // A folder that's already a project: the add form offers to move it.
12
+ export class ProjectExistsError extends Error {
13
+ constructor(
14
+ message: string,
15
+ readonly existing: ExistingProject
16
+ ) {
17
+ super(message);
18
+ }
19
+ }
20
+
5
21
  async function json<T>(res: Response): Promise<T> {
6
22
  const data = await res.json();
23
+ if (res.status === 409 && data.existing)
24
+ throw new ProjectExistsError(data.error, data.existing);
7
25
  if (!res.ok) throw new Error(data.error || "Request failed");
8
26
  return data;
9
27
  }
@@ -28,13 +46,14 @@ export function useFolderListing(hostId: string, path: string) {
28
46
  });
29
47
  }
30
48
 
31
- // A project from a folder ({ path }) or a new one from a name.
49
+ // A project from a folder ({ path }) or a new one from a name, in a
50
+ // workspace when given. moveExisting moves the project already at { path }.
32
51
  export function useInitProject() {
33
52
  const queryClient = useQueryClient();
34
53
  return useMutation({
35
54
  mutationFn: async (
36
- input: { hostId: string } & (
37
- | { path: string }
55
+ input: { hostId: string; workspaceId: string | null } & (
56
+ | { path: string; moveExisting?: boolean }
38
57
  | { parent: string; name: string }
39
58
  )
40
59
  ) =>
@@ -54,6 +73,7 @@ export function useStartClone() {
54
73
  hostId: string;
55
74
  parent: string;
56
75
  url: string;
76
+ workspaceId: string | null;
57
77
  }) =>
58
78
  (await json<{ job: CloneJob }>(await post("/api/projects/clone", input)))
59
79
  .job,
@@ -9,6 +9,8 @@ export const usageKeys = {
9
9
 
10
10
  export interface UsageResponse {
11
11
  report: UsageReport;
12
+ // The orchestrator's reviewer and scope check runs, by PR.
13
+ reviews: UsageReport;
12
14
  windows: UsageWindows;
13
15
  }
14
16
 
@@ -9,6 +9,7 @@ import { runInBackground } from "../async-operations";
9
9
  import { trustPromptKeys } from "../tasks/state";
10
10
  import { BUS_BRIEF } from "./brief";
11
11
  import { selfUrl } from "./self-env";
12
+ import { autoCompactEnv } from "../chat/refresh";
12
13
  import { CLAUDE_STATUS_SETTINGS_FLAG } from "../program-status/claude-flag";
13
14
  import {
14
15
  sessionProjectEnv,
@@ -35,6 +36,7 @@ export function ensureBusBrief(): void {
35
36
  export function agentEnv(sessionId: string): Record<string, string> {
36
37
  return {
37
38
  ...sessionProjectEnv(sessionId),
39
+ ...autoCompactEnv(sessionId),
38
40
  AGENTOS_SESSION_ID: sessionId,
39
41
  AGENTOS_URL: selfUrl(),
40
42
  PATH: `${AOS_BIN_DIR}:${process.env.PATH ?? ""}`,
@@ -35,8 +35,16 @@ export interface ChatStartOptions {
35
35
  // A fixed permission mode in place of the access setting, which then
36
36
  // can't be changed for this conversation.
37
37
  permissionMode?: PermissionMode;
38
+ // Checked before every tool call, whatever the permissions say: why it's
39
+ // refused, or null to let it go on to them.
40
+ toolGuard?: ToolGuard;
38
41
  }
39
42
 
43
+ export type ToolGuard = (
44
+ tool: string,
45
+ input: Record<string, unknown>
46
+ ) => Promise<string | null>;
47
+
40
48
  // One live conversation with an agent. Messages sent while a turn runs are
41
49
  // queued by the provider and folded into the conversation.
42
50
  export interface UndoResult extends UndoPreview {
@@ -2,6 +2,7 @@ import fs from "fs";
2
2
  import type {
3
3
  CanUseTool,
4
4
  HookCallback,
5
+ HookCallbackMatcher,
5
6
  PermissionMode,
6
7
  PermissionResult,
7
8
  } from "@anthropic-ai/claude-agent-sdk";
@@ -13,6 +14,7 @@ import type {
13
14
  DriverEvent,
14
15
  } from "../events";
15
16
  import { toolDiff, toolTitle } from "../tools";
17
+ import type { ToolGuard } from "../driver";
16
18
 
17
19
  type Approval = Extract<ChatItem, { kind: "approval" }>;
18
20
 
@@ -235,3 +237,41 @@ export class Approvals {
235
237
  p.resolve(result);
236
238
  }
237
239
  }
240
+
241
+ // A role's guard as a PreToolUse hook: a refusal denies the call before any
242
+ // permission rule could allow it, and a guard that fails refuses too.
243
+ export function guardHook(guard: ToolGuard): HookCallback {
244
+ return async (input) => {
245
+ if (input.hook_event_name !== "PreToolUse") return {};
246
+ const reason = await guard(
247
+ input.tool_name,
248
+ (input.tool_input ?? {}) as Record<string, unknown>
249
+ ).catch((e: Error) => `Couldn't check this call: ${e.message}`);
250
+ if (reason === null) return {};
251
+ return {
252
+ hookSpecificOutput: {
253
+ hookEventName: "PreToolUse",
254
+ permissionDecision: "deny",
255
+ permissionDecisionReason: reason,
256
+ },
257
+ };
258
+ };
259
+ }
260
+
261
+ // What runs before each tool call: questions and plans become cards, and a
262
+ // role's guard (an orchestrator's, on where it writes) sees every call.
263
+ export function preToolUseHooks(
264
+ approvals: Approvals,
265
+ guard?: ToolGuard
266
+ ): HookCallbackMatcher[] {
267
+ return [
268
+ {
269
+ matcher: "AskUserQuestion",
270
+ hooks: [approvals.askQuestions],
271
+ // The reader may take a while to answer.
272
+ timeout: 24 * 60 * 60,
273
+ },
274
+ { matcher: "ExitPlanMode", hooks: [approvals.proposePlan] },
275
+ ...(guard ? [{ hooks: [guardHook(guard)] }] : []),
276
+ ];
277
+ }
@@ -8,7 +8,12 @@ import {
8
8
  import type { ChatDriver, ChatConversation } from "../driver";
9
9
  import type { DriverEvent, FileSuggestion, McpServerView } from "../events";
10
10
  import { InputQueue } from "../queue";
11
- import { Approvals, isPlanFile, sdkMode } from "./claude-approvals";
11
+ import {
12
+ Approvals,
13
+ isPlanFile,
14
+ preToolUseHooks,
15
+ sdkMode,
16
+ } from "./claude-approvals";
12
17
  import { toChatContext, usageTotals } from "../context";
13
18
  import { cacheTtlMs } from "../stale";
14
19
  import { ClaudeMapper, toCommand, type ClaudeMessage } from "./claude-mapper";
@@ -186,15 +191,7 @@ export const claudeDriver: ChatDriver = {
186
191
  allowedTools: options.allowedTools,
187
192
  disallowedTools: options.disallowedTools,
188
193
  hooks: {
189
- PreToolUse: [
190
- {
191
- matcher: "AskUserQuestion",
192
- hooks: [approvals.askQuestions],
193
- // The reader may take a while to answer.
194
- timeout: 24 * 60 * 60,
195
- },
196
- { matcher: "ExitPlanMode", hooks: [approvals.proposePlan] },
197
- ],
194
+ PreToolUse: preToolUseHooks(approvals, options.toolGuard),
198
195
  },
199
196
  settings: { attribution: NO_ATTRIBUTION },
200
197
  enableFileCheckpointing: true,