@monster-orange/den 0.4.13 → 0.4.15
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/CHANGELOG.md +176 -0
- package/README.md +7 -7
- package/docs/distributions.md +185 -7
- package/package.json +1 -1
- package/src/shared/actor-token.ts +12 -0
- package/src/shared/agents/conversations.ts +256 -0
- package/src/shared/agents/den-claude.ts +19 -0
- package/src/shared/agents/records.ts +23 -0
- package/src/shared/agents/runtime.ts +14 -0
- package/src/shared/claim-code.ts +163 -0
- package/src/shared/cli-command.ts +31 -0
- package/src/shared/den-config.ts +14 -0
- package/src/shared/environment-manager/service-runner.ts +31 -12
- package/src/shared/environment-manager/workspaces.ts +31 -0
- package/src/shared/web-session.ts +64 -5
- package/src/ui/cli/agent-ops.ts +320 -18
- package/src/ui/cli/agents-command.ts +89 -2
- package/src/ui/cli/doctor-command.ts +29 -3
- package/src/ui/cli/doctor-onboard.ts +26 -0
- package/src/ui/cli/flag-parser.ts +9 -1
- package/src/ui/cli/index.ts +29 -3
- package/src/ui/cli/onboard-command.ts +180 -0
- package/src/ui/cli/onboard-reads.ts +613 -0
- package/src/ui/cli/repo-command.ts +156 -10
- package/src/ui/cli/web-command.ts +92 -1
- package/src/ui/mcp/api.ts +26 -3
- package/src/ui/mcp/server.ts +12 -2
- package/src/ui/mcp/tools.ts +427 -1
- package/src/ui/server/agent-resume-grant.ts +35 -0
- package/src/ui/server/agent-routes.ts +149 -1
- package/src/ui/server/approval-routes.ts +390 -0
- package/src/ui/server/approvals.ts +416 -0
- package/src/ui/server/github-routes.ts +12 -4
- package/src/ui/server/held-actions.ts +203 -0
- package/src/ui/server/http-helpers.ts +35 -0
- package/src/ui/server/index.ts +286 -16
- package/src/ui/server/lifecycle-exec.ts +70 -0
- package/src/ui/server/onboard-routes.ts +132 -0
- package/src/ui/server/pair-routes.ts +7 -4
- package/src/ui/server/public/agents-page.js +288 -8
- package/src/ui/server/public/agents.html +18 -0
- package/src/ui/server/public/error-page.js +27 -3
- package/src/ui/server/public/header.js +1 -1
- package/src/ui/server/public/i18n/es.js +52 -0
- package/src/ui/server/public/i18n/ja.js +55 -0
- package/src/ui/server/public/index.html +1 -1
- package/src/ui/server/public/local-dev.html +5 -4
- package/src/ui/server/public/persona-ui.css +45 -0
- package/src/ui/server/public/release.html +0 -1
- package/src/ui/server/public/settings-registry.js +3 -1
- package/src/ui/server/public/settings.html +9 -1
- package/src/ui/server/public/shell.js +336 -0
- package/src/ui/server/request-guard.ts +19 -4
- package/src/ui/server/route-classes.ts +41 -1
- package/src/ui/server/session-routes.ts +11 -3
- package/src/ui/server/workspace-routes.ts +21 -10
- package/themes/den/den-theme.json +6 -6
package/src/ui/mcp/tools.ts
CHANGED
|
@@ -64,6 +64,15 @@ export interface ToolContext {
|
|
|
64
64
|
* behaves as it always has.
|
|
65
65
|
*/
|
|
66
66
|
scope?: McpScope;
|
|
67
|
+
/**
|
|
68
|
+
* How a write waits for the person's answer (ON-3, approvals): poll
|
|
69
|
+
* every `intervalMs` for up to `waitMs`. Tests pass a short one.
|
|
70
|
+
*/
|
|
71
|
+
approvalWait?: {
|
|
72
|
+
intervalMs: number;
|
|
73
|
+
waitMs: number;
|
|
74
|
+
sleep?: (ms: number) => Promise<void>;
|
|
75
|
+
};
|
|
67
76
|
}
|
|
68
77
|
|
|
69
78
|
type Args = Record<string, unknown>;
|
|
@@ -178,6 +187,20 @@ const read = (
|
|
|
178
187
|
): DenTool => ({ name, title, description, action: false, shape, run });
|
|
179
188
|
|
|
180
189
|
const READ_TOOLS: DenTool[] = [
|
|
190
|
+
read(
|
|
191
|
+
"approval_check",
|
|
192
|
+
"Check an approval",
|
|
193
|
+
"Where one of your write requests stands (workspace_create, repo_add, service_try): `state` pending, done (with the server's `result`), denied, or expired. `id` is the request id the write tool gave you. Reads only.",
|
|
194
|
+
{ id: z.string().describe("The request id a write tool returned") },
|
|
195
|
+
async (a, { api }) => {
|
|
196
|
+
const id = str(a.id)?.trim() ?? "";
|
|
197
|
+
if (!/^[A-Za-z0-9_-]{16}$/.test(id))
|
|
198
|
+
throw new Refusal(
|
|
199
|
+
"approval_check: give the request id a write tool returned.",
|
|
200
|
+
);
|
|
201
|
+
return getApproval(api, id);
|
|
202
|
+
},
|
|
203
|
+
),
|
|
181
204
|
read(
|
|
182
205
|
"status",
|
|
183
206
|
"Service health",
|
|
@@ -528,6 +551,79 @@ const READ_TOOLS: DenTool[] = [
|
|
|
528
551
|
);
|
|
529
552
|
},
|
|
530
553
|
),
|
|
554
|
+
read(
|
|
555
|
+
"onboard_status",
|
|
556
|
+
"Onboarding status",
|
|
557
|
+
"What's set up and the one next step: `checks` (distribution, node, git, docker, web, workspaces, repos, service, mcp), each `ok`, `warn` or `todo` with its `detail` and, when there is one, a `fix` command or an `ask` for the person; then `next`: the step in plain words and its `command`. Reads only. Start here when setting Den up.",
|
|
558
|
+
{},
|
|
559
|
+
(_a, { api }) => api.get("/api/onboard/status"),
|
|
560
|
+
),
|
|
561
|
+
read(
|
|
562
|
+
"repos_detect",
|
|
563
|
+
"Find repos in a folder",
|
|
564
|
+
"Scan one folder for git repos (the folder itself, or one or two levels down) and say how each runs: `manifest` (its own, a side-car, or none), `runs` (the commands Den detected) or `ask` and `choices` when there are several ways. `workspaces` suggests a grouping: the repos in one parent folder go in a workspace named after it. `path` is required: an absolute folder (or ~/…) inside the person's home, not Den's state folder. Reads only: it never writes, runs a repo's tools or follows a symlink. Skips node_modules, vendor and dot-folders; at most `max` repos (50).",
|
|
565
|
+
{
|
|
566
|
+
path: z
|
|
567
|
+
.string()
|
|
568
|
+
.describe("The folder to scan: absolute, or starting with ~/"),
|
|
569
|
+
max: z
|
|
570
|
+
.number()
|
|
571
|
+
.int()
|
|
572
|
+
.min(1)
|
|
573
|
+
.max(50)
|
|
574
|
+
.optional()
|
|
575
|
+
.describe("At most this many repos (default and ceiling 50)"),
|
|
576
|
+
},
|
|
577
|
+
async (a, { api, scope }) => {
|
|
578
|
+
// Scanning folders is Den-wide, not one workspace's: the server's own
|
|
579
|
+
// (unscoped) agent does onboarding.
|
|
580
|
+
if (scope)
|
|
581
|
+
throw new ScopeRefusal(
|
|
582
|
+
`repos_detect: this MCP server is scoped to workspace "${scope.workspace}" (${scope.actor}), and scanning folders reaches outside any workspace; nothing was read. Den's own unscoped MCP server does onboarding.`,
|
|
583
|
+
);
|
|
584
|
+
const p = needPath("repos_detect", a);
|
|
585
|
+
return refusedOn400("repos_detect", () =>
|
|
586
|
+
api.get("/api/onboard/repos", {
|
|
587
|
+
path: p,
|
|
588
|
+
...(typeof a.max === "number" ? { max: String(a.max) } : {}),
|
|
589
|
+
}),
|
|
590
|
+
);
|
|
591
|
+
},
|
|
592
|
+
),
|
|
593
|
+
read(
|
|
594
|
+
"manifest_draft",
|
|
595
|
+
"Draft a side-car manifest",
|
|
596
|
+
"The side-car manifest `repo add` would write for one repo, checked by the same validator, with nothing written (`repo add --dry-run`): `draft` (the YAML), `validation`, `manifest.file` (where it would go, never in the repo) and `services`. When the repo can start in several ways it answers `ask` and `choices` instead: call again with `run` set to the one the person picks. A repo with its own manifest needs no draft (`manifest.source: repo`). `path`: the repo's absolute folder (or ~/…) inside the person's home.",
|
|
597
|
+
{
|
|
598
|
+
path: z.string().describe("The repo's folder: absolute, or ~/…"),
|
|
599
|
+
name: z
|
|
600
|
+
.string()
|
|
601
|
+
.optional()
|
|
602
|
+
.describe("The repo name (default: its folder)"),
|
|
603
|
+
run: z
|
|
604
|
+
.string()
|
|
605
|
+
.optional()
|
|
606
|
+
.describe("The start command, when detection asked which one"),
|
|
607
|
+
down: z.string().optional().describe("The stop command"),
|
|
608
|
+
health: z
|
|
609
|
+
.string()
|
|
610
|
+
.optional()
|
|
611
|
+
.describe("An http(s) URL for the health check"),
|
|
612
|
+
},
|
|
613
|
+
async (a, { api, scope }) => {
|
|
614
|
+
const p = needPath("manifest_draft", a);
|
|
615
|
+
if (scope) await assertPathInScope("manifest_draft", api, scope, p);
|
|
616
|
+
return refusedOn400("manifest_draft", () =>
|
|
617
|
+
api.get("/api/onboard/manifest", {
|
|
618
|
+
path: p,
|
|
619
|
+
name: str(a.name),
|
|
620
|
+
run: str(a.run),
|
|
621
|
+
down: str(a.down),
|
|
622
|
+
health: str(a.health),
|
|
623
|
+
}),
|
|
624
|
+
);
|
|
625
|
+
},
|
|
626
|
+
),
|
|
531
627
|
read(
|
|
532
628
|
"edge_status",
|
|
533
629
|
"Edge status",
|
|
@@ -551,6 +647,28 @@ const READ_TOOLS: DenTool[] = [
|
|
|
551
647
|
),
|
|
552
648
|
];
|
|
553
649
|
|
|
650
|
+
/** The `path` argument, or a refusal before anything is asked. */
|
|
651
|
+
function needPath(tool: string, a: Args): string {
|
|
652
|
+
const p = str(a.path)?.trim();
|
|
653
|
+
if (!p)
|
|
654
|
+
throw new Refusal(`${tool}: give a path (an absolute folder, or ~/…).`);
|
|
655
|
+
return p;
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
/** The web server's 400 (the path rule said no) as a refusal naming why. */
|
|
659
|
+
async function refusedOn400(
|
|
660
|
+
tool: string,
|
|
661
|
+
call: () => Promise<unknown>,
|
|
662
|
+
): Promise<unknown> {
|
|
663
|
+
try {
|
|
664
|
+
return await call();
|
|
665
|
+
} catch (e) {
|
|
666
|
+
if (e instanceof DenHttpError && e.status === 400)
|
|
667
|
+
throw new Refusal(`${tool}: ${e.body}`);
|
|
668
|
+
throw e;
|
|
669
|
+
}
|
|
670
|
+
}
|
|
671
|
+
|
|
554
672
|
/**
|
|
555
673
|
* The /api/workspaces body a scoped answer is counted against, read at most
|
|
556
674
|
* once every few seconds per scope (one tool call often makes several).
|
|
@@ -776,6 +894,63 @@ const agentResumeTool: DenTool = {
|
|
|
776
894
|
},
|
|
777
895
|
};
|
|
778
896
|
|
|
897
|
+
/** A runtime's conversation id (Claude Code: a uuid). */
|
|
898
|
+
const SESSION_RE = /^[A-Za-z0-9][A-Za-z0-9_-]{0,127}$/;
|
|
899
|
+
|
|
900
|
+
/**
|
|
901
|
+
* MCP agent_import (AG-17.1): bring in a conversation started outside Den
|
|
902
|
+
* as an agent of this workspace (or a granted one). No session: the list.
|
|
903
|
+
* Opt-in through mcp.actions; the web server checks the scope again.
|
|
904
|
+
*/
|
|
905
|
+
const agentImportTool: DenTool = {
|
|
906
|
+
name: "agent_import",
|
|
907
|
+
title: "Bring in a conversation",
|
|
908
|
+
description:
|
|
909
|
+
"Bring in a conversation started outside Den (in the workspace's folder) as a Den agent: its runtime's own resume, in the folder it ran in, under Den's token, hooks and guards. Without session: lists the conversations there are (id, title, lastAt, cwd, runtime, open, agent). With session and persona: brings that one in and answers with the agent. Refused while it is still open in another terminal (written in the last minute), or when an agent already continues it.",
|
|
910
|
+
action: true,
|
|
911
|
+
shape: {
|
|
912
|
+
session: z
|
|
913
|
+
.string()
|
|
914
|
+
.optional()
|
|
915
|
+
.describe("The runtime's conversation id (omit to list them)"),
|
|
916
|
+
persona: z.string().optional().describe("The persona to run it as"),
|
|
917
|
+
runtime: z.string().optional().describe("The runtime (default den-claude)"),
|
|
918
|
+
workspace: workspaceArg,
|
|
919
|
+
},
|
|
920
|
+
async run(a, { api, scope }) {
|
|
921
|
+
let ws = str(a.workspace) ?? scope?.workspace;
|
|
922
|
+
if (!ws) {
|
|
923
|
+
const focused = (await api.get("/api/workspaces")) as {
|
|
924
|
+
focus?: unknown;
|
|
925
|
+
};
|
|
926
|
+
ws = typeof focused?.focus === "string" ? focused.focus : undefined;
|
|
927
|
+
}
|
|
928
|
+
if (!ws || !NAME_RE.test(ws))
|
|
929
|
+
throw new Refusal("agent_import: name the workspace");
|
|
930
|
+
if (scope && ws !== scope.workspace && !(scope.grants ?? []).includes(ws))
|
|
931
|
+
throw new ScopeRefusal(
|
|
932
|
+
`agent_import: workspace "${ws}" is outside this agent's scope ("${scope.workspace}")`,
|
|
933
|
+
);
|
|
934
|
+
const runtime = str(a.runtime);
|
|
935
|
+
if (runtime && !NAME_RE.test(runtime))
|
|
936
|
+
throw new Refusal("agent_import: runtime must be a runtime id");
|
|
937
|
+
const session = str(a.session);
|
|
938
|
+
const base = `/api/workspaces/${encodeURIComponent(ws)}/conversations`;
|
|
939
|
+
if (!session) return api.get(base, runtime ? { runtime } : {}, "GET");
|
|
940
|
+
if (!SESSION_RE.test(session))
|
|
941
|
+
throw new Refusal("agent_import: session must be a conversation id");
|
|
942
|
+
const persona = str(a.persona);
|
|
943
|
+
if (!persona || !NAME_RE.test(persona))
|
|
944
|
+
throw new Refusal("agent_import: name the persona to run it as");
|
|
945
|
+
return api.get(
|
|
946
|
+
`${base}/${encodeURIComponent(session)}/import`,
|
|
947
|
+
{},
|
|
948
|
+
"POST",
|
|
949
|
+
{ persona, ...(runtime ? { runtime } : {}) },
|
|
950
|
+
);
|
|
951
|
+
},
|
|
952
|
+
};
|
|
953
|
+
|
|
779
954
|
const ACTION_TOOLS: Record<McpAction, DenTool> = {
|
|
780
955
|
service_up: lifecycleTool("up"),
|
|
781
956
|
service_down: lifecycleTool("down"),
|
|
@@ -865,15 +1040,259 @@ const ACTION_TOOLS: Record<McpAction, DenTool> = {
|
|
|
865
1040
|
},
|
|
866
1041
|
},
|
|
867
1042
|
agent_resume: agentResumeTool,
|
|
1043
|
+
agent_import: agentImportTool,
|
|
868
1044
|
};
|
|
869
1045
|
|
|
870
1046
|
export const READ_TOOL_NAMES: readonly string[] = READ_TOOLS.map((t) => t.name);
|
|
871
1047
|
export const ACTION_TOOL_NAMES: readonly McpAction[] = MCP_ACTIONS;
|
|
872
1048
|
|
|
873
|
-
|
|
1049
|
+
// ── Writes held for the person (ON-3, 0.4.15) ──────────────────────────────
|
|
1050
|
+
|
|
1051
|
+
/** How long a write waits for the person by default, and how often it looks. */
|
|
1052
|
+
const APPROVAL_WAIT: NonNullable<ToolContext["approvalWait"]> = {
|
|
1053
|
+
intervalMs: 2_000,
|
|
1054
|
+
waitMs: 120_000,
|
|
1055
|
+
};
|
|
1056
|
+
|
|
1057
|
+
/**
|
|
1058
|
+
* The tickets this MCP server was handed for its own asks (id → ticket):
|
|
1059
|
+
* with no agent token, a ticket is what lets it read its own request.
|
|
1060
|
+
* Never shown to the agent. Bounded; the oldest go first.
|
|
1061
|
+
*/
|
|
1062
|
+
const TICKETS = new Map<string, string>();
|
|
1063
|
+
const MAX_TICKETS = 200;
|
|
1064
|
+
function keepTicket(id: string, ticket: unknown): void {
|
|
1065
|
+
if (typeof ticket !== "string" || !ticket) return;
|
|
1066
|
+
TICKETS.set(id, ticket);
|
|
1067
|
+
while (TICKETS.size > MAX_TICKETS)
|
|
1068
|
+
TICKETS.delete(TICKETS.keys().next().value as string);
|
|
1069
|
+
}
|
|
1070
|
+
/** GET one approval, with its ticket when this server asked for it. */
|
|
1071
|
+
function getApproval(api: DenApi, id: string): Promise<unknown> {
|
|
1072
|
+
const t = TICKETS.get(id);
|
|
1073
|
+
return api.get(
|
|
1074
|
+
`/api/approvals/${id}`,
|
|
1075
|
+
{},
|
|
1076
|
+
"GET",
|
|
1077
|
+
undefined,
|
|
1078
|
+
t ? { "X-Den-Approval-Ticket": t } : undefined,
|
|
1079
|
+
);
|
|
1080
|
+
}
|
|
1081
|
+
|
|
1082
|
+
/**
|
|
1083
|
+
* Ask the web server to hold `action` for the person (POST /api/approvals),
|
|
1084
|
+
* then wait for the answer. Allowed: what the server's run said (an error
|
|
1085
|
+
* when it failed). Denied, expired, the agent stopped: a refusal. Still
|
|
1086
|
+
* pending: a plain answer naming approval_check.
|
|
1087
|
+
*/
|
|
1088
|
+
async function askPerson(
|
|
1089
|
+
tool: string,
|
|
1090
|
+
action: string,
|
|
1091
|
+
args: Args,
|
|
1092
|
+
note: unknown,
|
|
1093
|
+
ctx: ToolContext,
|
|
1094
|
+
): Promise<unknown> {
|
|
1095
|
+
const clean = Object.fromEntries(
|
|
1096
|
+
Object.entries(args).filter(([, v]) => v !== undefined && v !== ""),
|
|
1097
|
+
);
|
|
1098
|
+
let asked: { id?: unknown; ticket?: unknown };
|
|
1099
|
+
try {
|
|
1100
|
+
asked = (await ctx.api.get("/api/approvals", {}, "POST", {
|
|
1101
|
+
action,
|
|
1102
|
+
args: clean,
|
|
1103
|
+
...(typeof note === "string" && note.trim() ? { note: note.trim() } : {}),
|
|
1104
|
+
})) as { id?: unknown; ticket?: unknown };
|
|
1105
|
+
} catch (e) {
|
|
1106
|
+
if (e instanceof DenHttpError && [400, 403, 409, 429].includes(e.status))
|
|
1107
|
+
throw new Refusal(`${tool}: ${e.body}`);
|
|
1108
|
+
throw e;
|
|
1109
|
+
}
|
|
1110
|
+
const id = String(asked?.id ?? "");
|
|
1111
|
+
keepTicket(id, asked?.ticket);
|
|
1112
|
+
const w = ctx.approvalWait ?? APPROVAL_WAIT;
|
|
1113
|
+
const sleep =
|
|
1114
|
+
w.sleep ?? ((ms: number) => new Promise((r) => setTimeout(r, ms)));
|
|
1115
|
+
const until = Date.now() + w.waitMs;
|
|
1116
|
+
for (;;) {
|
|
1117
|
+
const a = (await getApproval(ctx.api, id)) as {
|
|
1118
|
+
state?: string;
|
|
1119
|
+
result?: unknown;
|
|
1120
|
+
endedBecause?: string;
|
|
1121
|
+
};
|
|
1122
|
+
if (a.state === "done") {
|
|
1123
|
+
const result = a.result as { ok?: unknown } | undefined;
|
|
1124
|
+
const out = { approved: true, id, result };
|
|
1125
|
+
if (result && result.ok === false) throw new ActionFailed(out);
|
|
1126
|
+
return out;
|
|
1127
|
+
}
|
|
1128
|
+
if (a.state === "denied")
|
|
1129
|
+
throw new Refusal(
|
|
1130
|
+
a.endedBecause === "actor_gone"
|
|
1131
|
+
? `${tool}: the agent that asked was stopped before the person allowed it (request ${id}); nothing ran.`
|
|
1132
|
+
: `${tool}: the person denied it (request ${id}); nothing ran.`,
|
|
1133
|
+
);
|
|
1134
|
+
if (a.state === "expired")
|
|
1135
|
+
throw new Refusal(
|
|
1136
|
+
`${tool}: nobody answered in time (request ${id}); nothing ran. Ask again when the person is at Den.`,
|
|
1137
|
+
);
|
|
1138
|
+
if (Date.now() >= until) break;
|
|
1139
|
+
await sleep(w.intervalMs);
|
|
1140
|
+
}
|
|
1141
|
+
return `Waiting for the person to approve in Den (request ${id}); call approval_check with this id later.`;
|
|
1142
|
+
}
|
|
1143
|
+
|
|
1144
|
+
/** A required string argument, or a refusal before anything is asked. */
|
|
1145
|
+
function needArg(tool: string, a: Args, k: string, what: string): string {
|
|
1146
|
+
const v = str(a[k])?.trim();
|
|
1147
|
+
if (!v) throw new Refusal(`${tool}: give ${k} (${what}).`);
|
|
1148
|
+
return v;
|
|
1149
|
+
}
|
|
1150
|
+
|
|
1151
|
+
const noteArg = z
|
|
1152
|
+
.string()
|
|
1153
|
+
.max(500)
|
|
1154
|
+
.optional()
|
|
1155
|
+
.describe(
|
|
1156
|
+
'One line for the person on why (shown quoted as "Agent says"; Den writes the request itself)',
|
|
1157
|
+
);
|
|
1158
|
+
|
|
1159
|
+
/** Onboarding writes are the unscoped server agent's. */
|
|
1160
|
+
function refuseScoped(tool: string, scope: McpScope | undefined): void {
|
|
1161
|
+
if (scope)
|
|
1162
|
+
throw new ScopeRefusal(
|
|
1163
|
+
`${tool}: this MCP server is scoped to workspace "${scope.workspace}" (${scope.actor}), and ${tool} changes Den beyond it; nothing was asked. Den's own unscoped MCP server does onboarding.`,
|
|
1164
|
+
);
|
|
1165
|
+
}
|
|
1166
|
+
|
|
1167
|
+
const HELD =
|
|
1168
|
+
" Nothing changes until the person allows it in Den's browser dialog: the request waits up to 10 minutes; this call waits up to 2 for the answer, then returns the request id for approval_check.";
|
|
1169
|
+
|
|
1170
|
+
const APPROVAL_TOOLS: DenTool[] = [
|
|
1171
|
+
{
|
|
1172
|
+
name: "workspace_create",
|
|
1173
|
+
title: "Create a workspace",
|
|
1174
|
+
description: "Create an empty Den workspace named `name`." + HELD,
|
|
1175
|
+
action: true,
|
|
1176
|
+
shape: {
|
|
1177
|
+
name: z
|
|
1178
|
+
.string()
|
|
1179
|
+
.describe(
|
|
1180
|
+
"The workspace name (no quotes, slashes or control characters)",
|
|
1181
|
+
),
|
|
1182
|
+
note: noteArg,
|
|
1183
|
+
},
|
|
1184
|
+
async run(a, ctx) {
|
|
1185
|
+
refuseScoped("workspace_create", ctx.scope);
|
|
1186
|
+
const name = needArg("workspace_create", a, "name", "the workspace name");
|
|
1187
|
+
return askPerson(
|
|
1188
|
+
"workspace_create",
|
|
1189
|
+
"workspace_create",
|
|
1190
|
+
{ name },
|
|
1191
|
+
a.note,
|
|
1192
|
+
ctx,
|
|
1193
|
+
);
|
|
1194
|
+
},
|
|
1195
|
+
},
|
|
1196
|
+
{
|
|
1197
|
+
name: "repo_add",
|
|
1198
|
+
title: "Add a repo",
|
|
1199
|
+
description:
|
|
1200
|
+
"Add the local repo at `path` to a workspace, as `repo add` does (manifest_draft first shows what it would write)." +
|
|
1201
|
+
HELD,
|
|
1202
|
+
action: true,
|
|
1203
|
+
shape: {
|
|
1204
|
+
path: z
|
|
1205
|
+
.string()
|
|
1206
|
+
.describe(
|
|
1207
|
+
"The repo's folder: absolute, or ~/… inside the person's home",
|
|
1208
|
+
),
|
|
1209
|
+
workspace: workspaceArg,
|
|
1210
|
+
name: z
|
|
1211
|
+
.string()
|
|
1212
|
+
.optional()
|
|
1213
|
+
.describe("The repo name (default: its folder)"),
|
|
1214
|
+
run: z
|
|
1215
|
+
.string()
|
|
1216
|
+
.optional()
|
|
1217
|
+
.describe("The start command, when detection asked which"),
|
|
1218
|
+
down: z.string().optional().describe("The stop command"),
|
|
1219
|
+
health: z
|
|
1220
|
+
.string()
|
|
1221
|
+
.optional()
|
|
1222
|
+
.describe("An http(s) URL for the health check"),
|
|
1223
|
+
note: noteArg,
|
|
1224
|
+
},
|
|
1225
|
+
async run(a, ctx) {
|
|
1226
|
+
refuseScoped("repo_add", ctx.scope);
|
|
1227
|
+
const p = needArg("repo_add", a, "path", "the repo's folder");
|
|
1228
|
+
return askPerson(
|
|
1229
|
+
"repo_add",
|
|
1230
|
+
"repo_add",
|
|
1231
|
+
{
|
|
1232
|
+
path: p,
|
|
1233
|
+
workspace: str(a.workspace),
|
|
1234
|
+
name: str(a.name),
|
|
1235
|
+
run: str(a.run),
|
|
1236
|
+
down: str(a.down),
|
|
1237
|
+
health: str(a.health),
|
|
1238
|
+
},
|
|
1239
|
+
a.note,
|
|
1240
|
+
ctx,
|
|
1241
|
+
);
|
|
1242
|
+
},
|
|
1243
|
+
},
|
|
1244
|
+
{
|
|
1245
|
+
name: "service_try",
|
|
1246
|
+
title: "Start one service",
|
|
1247
|
+
description:
|
|
1248
|
+
"Start one service locally (`up`, as the Services page does) and report how it went: the exit code and the last lines of output." +
|
|
1249
|
+
HELD,
|
|
1250
|
+
action: true,
|
|
1251
|
+
shape: {
|
|
1252
|
+
service: z.string().describe("The service: <repo> or <repo>/<service>"),
|
|
1253
|
+
workspace: workspaceArg,
|
|
1254
|
+
note: noteArg,
|
|
1255
|
+
},
|
|
1256
|
+
async run(a, ctx) {
|
|
1257
|
+
const service = needArg(
|
|
1258
|
+
"service_try",
|
|
1259
|
+
a,
|
|
1260
|
+
"service",
|
|
1261
|
+
"<repo> or <repo>/<service>",
|
|
1262
|
+
);
|
|
1263
|
+
return askPerson(
|
|
1264
|
+
"service_try",
|
|
1265
|
+
"service_try",
|
|
1266
|
+
{ service, workspace: str(a.workspace) },
|
|
1267
|
+
a.note,
|
|
1268
|
+
ctx,
|
|
1269
|
+
);
|
|
1270
|
+
},
|
|
1271
|
+
},
|
|
1272
|
+
];
|
|
1273
|
+
|
|
1274
|
+
/**
|
|
1275
|
+
* The held writes: registered unless `mcp.approvals: false` (the person's
|
|
1276
|
+
* Allow is the gate, so they are on by default).
|
|
1277
|
+
*/
|
|
1278
|
+
export const APPROVAL_TOOL_NAMES: readonly string[] = APPROVAL_TOOLS.map(
|
|
1279
|
+
(t) => t.name,
|
|
1280
|
+
);
|
|
1281
|
+
|
|
1282
|
+
/** One line saying whether the held writes are on (MCP instructions, --list, doctor). */
|
|
1283
|
+
export function approvalsState(
|
|
1284
|
+
cfg: Pick<ResolvedMcpConfig, "approvals">,
|
|
1285
|
+
): string {
|
|
1286
|
+
return cfg.approvals
|
|
1287
|
+
? `held writes on: ${APPROVAL_TOOL_NAMES.join(", ")} run only after the person's Allow in the browser (mcp.approvals: false turns them off)`
|
|
1288
|
+
: `held writes off (mcp.approvals: false): ${APPROVAL_TOOL_NAMES.join(", ")} are not registered`;
|
|
1289
|
+
}
|
|
1290
|
+
|
|
1291
|
+
/** The tools to register: every read tool, the held writes (unless turned off), plus the actions the org named. */
|
|
874
1292
|
export function toolsFor(cfg: ResolvedMcpConfig, scope?: McpScope): DenTool[] {
|
|
875
1293
|
const tools = [
|
|
876
1294
|
...READ_TOOLS,
|
|
1295
|
+
...(cfg.approvals ? APPROVAL_TOOLS : []),
|
|
877
1296
|
...MCP_ACTIONS.filter((a) => cfg.actions.includes(a)).map(
|
|
878
1297
|
(a) => ACTION_TOOLS[a],
|
|
879
1298
|
),
|
|
@@ -896,6 +1315,8 @@ const WORKSPACE_TOOLS = new Set([
|
|
|
896
1315
|
"logs_tail",
|
|
897
1316
|
"tests_available",
|
|
898
1317
|
"manifest_validate",
|
|
1318
|
+
"manifest_draft",
|
|
1319
|
+
"service_try",
|
|
899
1320
|
"stacks",
|
|
900
1321
|
"service_up",
|
|
901
1322
|
"service_down",
|
|
@@ -903,6 +1324,7 @@ const WORKSPACE_TOOLS = new Set([
|
|
|
903
1324
|
"workspace_sync",
|
|
904
1325
|
"run_tests",
|
|
905
1326
|
"agent_resume",
|
|
1327
|
+
"agent_import",
|
|
906
1328
|
]);
|
|
907
1329
|
|
|
908
1330
|
/**
|
|
@@ -947,6 +1369,10 @@ export function pinnedTool(t: DenTool, scope: McpScope): DenTool {
|
|
|
947
1369
|
* than the web server it talks to.
|
|
948
1370
|
*/
|
|
949
1371
|
export const ROUTE_SINCE: Readonly<Record<string, string>> = {
|
|
1372
|
+
"/api/approvals": "0.4.15",
|
|
1373
|
+
"/api/onboard/status": "0.4.15",
|
|
1374
|
+
"/api/onboard/repos": "0.4.15",
|
|
1375
|
+
"/api/onboard/manifest": "0.4.15",
|
|
950
1376
|
"/api/stacks": "0.2.0",
|
|
951
1377
|
"/api/edge": "0.2.0",
|
|
952
1378
|
"/api/security/plan": "0.2.0",
|
|
@@ -51,3 +51,38 @@ export function agentResumeTarget(
|
|
|
51
51
|
return null;
|
|
52
52
|
}
|
|
53
53
|
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* MCP `agent_import` (AG-17.1): bring a conversation in as an agent, and
|
|
57
|
+
* list the ones there are. Off unless `mcp.actions` lists it; only in the
|
|
58
|
+
* caller's workspace or one its grants give it. A person's otherwise.
|
|
59
|
+
*/
|
|
60
|
+
export const AGENT_IMPORT_ACTION = "agent_import";
|
|
61
|
+
|
|
62
|
+
/** GET /api/workspaces/<ws>/conversations, POST …/<id>/import. */
|
|
63
|
+
export const AGENT_IMPORT_ROUTE =
|
|
64
|
+
/^\/api\/workspaces\/([^/]+)\/conversations(?:\/([^/]+)\/import)?$/;
|
|
65
|
+
|
|
66
|
+
/** Why this agent may not bring in a conversation of `ws`, or null. */
|
|
67
|
+
export function agentImportRefusal(
|
|
68
|
+
p: ResumingAgent,
|
|
69
|
+
ws: string,
|
|
70
|
+
actions: readonly string[],
|
|
71
|
+
): string | null {
|
|
72
|
+
if (!actions.includes(AGENT_IMPORT_ACTION))
|
|
73
|
+
return `${p.actor} can't bring in conversations: the org config's mcp.actions doesn't list ${AGENT_IMPORT_ACTION}.`;
|
|
74
|
+
if (ws !== p.workspace && !p.grants.includes(ws))
|
|
75
|
+
return `${p.actor} can bring in conversations of workspace ${p.workspace} only${p.grants.length ? ` (or ${p.grants.join(", ")}, its grants)` : ""}, not ${ws}.`;
|
|
76
|
+
return null;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** The import route's workspace, decoded; null when it isn't it. */
|
|
80
|
+
export function agentImportTarget(pathname: string): { ws: string } | null {
|
|
81
|
+
const m = AGENT_IMPORT_ROUTE.exec(pathname);
|
|
82
|
+
if (!m) return null;
|
|
83
|
+
try {
|
|
84
|
+
return { ws: decodeURIComponent(m[1]) };
|
|
85
|
+
} catch {
|
|
86
|
+
return null;
|
|
87
|
+
}
|
|
88
|
+
}
|