Skip to content
386 linesCodeBlameRaw

Pick any line to see why it is the way it is: the commit, the pull request and issue it came from, and what the agent was thinking.

Each agent has an Abilities tab: g1t's built-ins, always on within the asker's access; its computer, coming; each connected integration's actions one row each, read, import, comment or resolve, with a level for each, alone, alone when the person asked for it, ask first or never, and whose connection it runs on; and MCP servers an owner adds. Reading is alone, writing inside g1t follows today's choices, anything that leaves g1t asks first, and production deploys can't go above ask. The agents service enforces every level: ask first posts a card to allow or deny and parks a session until it's answered, a refusal names its rule in the transcript and the audit log, and a missing ability posts a request to owners. The agent abilities guide says how.1/**
2 * An agent's abilities at work (docs.g1t.sh/guides/agent-abilities/): what
3 * the workspace has connected, the agent's level for each ability
4 * (@g1t/contracts abilities.ts), and the ports that carry a call out once
5 * the tool box's gate allowed it: the integrations service, an MCP server,
6 * and the cards in chat that ask first, offer to connect, or request
7 * something from the owners.
8 *
9 * Asked first: the call is kept in `agent_ability_requests` with its
10 * arguments and a card is posted. Whoever may allow it (the person the
11 * agent acts for, or an owner) presses Allow, the call runs as them, and
12 * the agent hears the result: a session reads it at its next step
13 * (cards.ts). Every refusal names its rule in the transcript, through the
14 * tool's result, and in the audit log, through `refused`.
15 */
16import { type Ability, type AbilitySection, type AbilitySource, type McpServer, type MessageCard, type ServiceBinding, type User, chatClient, identityClient, integrationsClient, newId, notifyClient } from "@g1t/contracts";
17
18import { CONNECTORS, connectorPath, connectorView } from "../../../packages/contracts/src/connectors.ts";
19import { findAbility, resolveAbilities } from "../../../packages/contracts/src/abilities.ts";
20import { abilityCard, connectCard, requestCard } from "./card-views.ts";
Every agent can have its own computer. A session that needs one wakes it: a home of its own on g1t cloud, one per agent and never shared, where it runs commands, reads and writes files and keeps what it made, with each session working in its own folder under a shared home; after ten idle minutes it sleeps, its home kept as a snapshot and restored when it wakes, and Reset wipes the home while memory and artifacts stay. Its shell and files are abilities with the usual choices, Alone, Alone when asked, Ask first or Never, offered only inside sessions and never to a chat reply; every command shows on the session with its output, and the agent's new Computer tab shows the state, the disk used of the five gigabytes included, the recent commands, and Wake, Put to sleep and Reset. Machine time counts only while it is awake, on the sandbox lines of the ledger that name the agent and who asked, held to the same spend caps as the session; the disk itself costs nothing in this version. The runner gained a long-lived supervisor that answers the computer's requests inside the container, and the runner service a computer per agent that keeps its snapshot in the agent homes bucket when one is attached, and says so when none is. The REST API and the agent tool can read a computer, wake it, put it to sleep and reset it. The agents, abilities, sessions, runners, billing and deploy guides say how it works and what an operator sets up; pinning a computer to your own runner, its browser and take-over come next.21import { computerPorts } from "./computer.ts";
Each agent has an Abilities tab: g1t's built-ins, always on within the asker's access; its computer, coming; each connected integration's actions one row each, read, import, comment or resolve, with a level for each, alone, alone when the person asked for it, ask first or never, and whose connection it runs on; and MCP servers an owner adds. Reading is alone, writing inside g1t follows today's choices, anything that leaves g1t asks first, and production deploys can't go above ask. The agents service enforces every level: ask first posts a card to allow or deny and parks a session until it's answered, a refusal names its rule in the transcript and the audit log, and a missing ability posts a request to owners. The agent abilities guide says how.22export { abilitiesSection, saidText } from "./abilities-prompt.ts";
23import type { Definition } from "./definition.ts";
24import { callMcpTool } from "./mcp-client.ts";
25import { type Row, isPersonal } from "./store.ts";
26import type { AbilityPorts, OutsideDone, OutsideItem } from "./tools.ts";
27
28export type AbilityEnv = {
29 DB: D1Database;
30 INTEGRATIONS: ServiceBinding;
31 CHAT: ServiceBinding;
32 IDENTITY: ServiceBinding;
33 EVENTS: ServiceBinding;
34 NOTIFY?: ServiceBinding;
Every agent can have its own computer. A session that needs one wakes it: a home of its own on g1t cloud, one per agent and never shared, where it runs commands, reads and writes files and keeps what it made, with each session working in its own folder under a shared home; after ten idle minutes it sleeps, its home kept as a snapshot and restored when it wakes, and Reset wipes the home while memory and artifacts stay. Its shell and files are abilities with the usual choices, Alone, Alone when asked, Ask first or Never, offered only inside sessions and never to a chat reply; every command shows on the session with its output, and the agent's new Computer tab shows the state, the disk used of the five gigabytes included, the recent commands, and Wake, Put to sleep and Reset. Machine time counts only while it is awake, on the sandbox lines of the ledger that name the agent and who asked, held to the same spend caps as the session; the disk itself costs nothing in this version. The runner gained a long-lived supervisor that answers the computer's requests inside the container, and the runner service a computer per agent that keeps its snapshot in the agent homes bucket when one is attached, and says so when none is. The REST API and the agent tool can read a computer, wake it, put it to sleep and reset it. The agents, abilities, sessions, runners, billing and deploy guides say how it works and what an operator sets up; pinning a computer to your own runner, its browser and take-over come next.35 /** The runner, for a call on the agent's computer allowed from a card (computer.ts). */
36 RUNNER?: ServiceBinding;
Each agent has an Abilities tab: g1t's built-ins, always on within the asker's access; its computer, coming; each connected integration's actions one row each, read, import, comment or resolve, with a level for each, alone, alone when the person asked for it, ask first or never, and whose connection it runs on; and MCP servers an owner adds. Reading is alone, writing inside g1t follows today's choices, anything that leaves g1t asks first, and production deploys can't go above ask. The agents service enforces every level: ask first posts a card to allow or deny and parks a session until it's answered, a refusal names its rule in the transcript and the audit log, and a missing ability posts a request to owners. The agent abilities guide says how.37 /** The site's address (https://g1t.sh), for links that leave g1t. */
38 SITE_URL?: string;
39};
40
41/** A call waiting to be allowed, as kept. */
42export type AbilityRequestRow = {
43 id: string;
44 agent_id: string;
45 workspace_id: string;
46 workspace: string;
47 channel_id: string;
48 message_id: string | null;
49 session_id: string | null;
50 ability: string;
51 tool: string;
52 input: string;
53 summary: string;
54 asked_by: string | null;
55 status: string;
56 decided_by: string | null;
57 decided_at: string | null;
58 result: string | null;
59 created_at: string;
60 updated_at: string;
61};
62
63const iso = () => new Date().toISOString();
64
65/** The site's address, without a trailing slash. */
66export function siteUrl(env: { SITE_URL?: string }): string {
67 return (env.SITE_URL ?? "").trim().replace(/\/+$/, "") || "https://g1t.sh";
68}
69
70/**
71 * The connector ids the workspace has connected, as the integrations
72 * service lists its connections to a member. Empty when it can't say.
73 */
74export async function connectedConnectors(env: Pick<AbilityEnv, "INTEGRATIONS">, workspace: string, viewer: User): Promise<string[]> {
75 const listed = await integrationsClient(env.INTEGRATIONS)
76 .list(workspace, viewer)
77 .catch(() => null);
78 if (!listed?.ok) return [];
79 const providers = new Set(listed.value.map((connection) => connection.provider));
80 return CONNECTORS.filter((connector) => connector.provider && providers.has(connector.provider)).map((connector) => connector.id);
81}
82
83/** The agent's abilities for a turn: resolved against what is connected. */
84export async function abilitiesFor(env: Pick<AbilityEnv, "INTEGRATIONS">, input: { agent: Row; definition: Definition; workspace: string; asker: User }): Promise<AbilitySection[]> {
85 const connected = await connectedConnectors(env, input.workspace, input.asker);
86 return resolveAbilities({ connectors: CONNECTORS, abilities: input.definition.abilities, autonomy: input.definition.autonomy, connected, personal: isPersonal(input.agent) });
87}
88
89/** Where a request's card and a session's wait point: the Abilities tab. */
90export function abilitiesPath(workspace: string, handle: string): string {
91 return `/${workspace}/-/agents/${handle}/abilities`;
92}
93
94const item = (c: { provider: string; key: string; title: string; url: string; status: string | null; body: string }): OutsideItem => ({ provider: c.provider, key: c.key, title: c.title, url: c.url, status: c.status, body: c.body });
95
96const outcome = <T, U>(result: { ok: true; value: T } | { ok: false; error: { code: string; message: string } }, map: (value: T) => U): OutsideDone<U> =>
97 result.ok ? { ok: true, value: map(result.value) } : { ok: false, code: result.error.code, message: result.error.message };
98
99/**
100 * The ports for one turn. `postCard` posts where the agent is working (a
101 * reply's conversation, a session's thread) and gives the message id.
102 */
103export function abilityPorts(
104 env: AbilityEnv,
105 input: {
106 agent: Row;
107 workspace: string;
108 channel_id: string;
109 session: { id: string; title: string } | null;
110 asker: { id: string | null; username: string | null };
111 postCard: (card: MessageCard) => Promise<string | null>;
112 },
113): AbilityPorts {
114 const integrations = integrationsClient(env.INTEGRATIONS);
115 const { agent, workspace } = input;
116 const link = `${siteUrl(env)}/${workspace}/-/chat/${input.channel_id}`;
117 return {
118 lookup: async (asker, reference) => outcome(await integrations.resolve(workspace, asker, reference), item),
119 import: async (asker, repo, reference) =>
120 outcome(await integrations.import(asker, { namespace: repo.namespace, name: repo.name }, reference, false), (v) => ({ number: v.number, item: item(v.item), created: v.created })),
121 act: async (asker, reference, action, text) => {
122 const signed = `${text}\n\n— ${agent.display_name} (@${agent.handle}), a g1t agent, for @${asker.username}.`;
123 const done = action === "resolve" ? await integrations.close(asker, workspace, reference, signed, link) : await integrations.comment(asker, workspace, reference, signed, link);
124 return outcome(done, item);
125 },
126 // Personal connections to Linear, Jira and Sentry aren't available yet (connectors.ts says so), so nobody has one.
127 askerConnected: async () => false,
128 mcp: async (server, tool, args) => {
129 const done = await callMcpTool(server.url, tool.name, args);
130 return done.ok ? { ok: true, value: done.text } : { ok: false, code: "error", message: done.message };
131 },
132 async askFirst({ ability, source, tool, args, summary, note }) {
133 const id = newId("abr");
134 const now = iso();
135 const row: AbilityRequestRow = {
136 id,
137 agent_id: agent.id,
138 workspace_id: agent.workspace_id,
139 workspace,
140 channel_id: input.channel_id,
141 message_id: null,
142 session_id: input.session?.id ?? null,
143 ability: ability.id,
144 tool,
145 input: JSON.stringify(args).slice(0, 20_000),
146 summary: summary.slice(0, 300),
147 asked_by: input.asker.id,
148 status: "pending",
149 decided_by: null,
150 decided_at: null,
151 result: null,
152 created_at: now,
153 updated_at: now,
154 };
155 await env.DB.prepare(
156 `INSERT INTO agent_ability_requests (id, agent_id, workspace_id, workspace, channel_id, session_id, ability, tool, input, summary, asked_by, status, created_at, updated_at)
157 VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'pending', ?, ?)`,
158 )
159 .bind(id, row.agent_id, row.workspace_id, row.workspace, row.channel_id, row.session_id, row.ability, row.tool, row.input, row.summary, row.asked_by, now, now)
160 .run();
161 const messageId = await input.postCard(abilityCard(row, { agent: agent.display_name, asker: input.asker.username, rule: `${source.name}: ${ability.label}`, level: ability.level, note, body: bodyOf(args) }));
162 if (!messageId) {
163 await env.DB.prepare("UPDATE agent_ability_requests SET status = 'failed', result = ?, updated_at = ? WHERE id = ?").bind("The card couldn't be posted.", iso(), id).run();
164 return null;
165 }
166 await env.DB.prepare("UPDATE agent_ability_requests SET message_id = ? WHERE id = ?").bind(messageId, id).run();
167 return id;
168 },
169 async connect(source, ability) {
170 const connector = CONNECTORS.find((c) => c.id === source.id);
171 const view = connector ? connectorView(connector, "personal") : null;
172 const href = view?.href ? connectorPath(view.href, workspace) : "/settings/integrations";
173 const messageId = await input.postCard(connectCard({ connector: source.name, agent: agent.display_name, ability: ability.label, asker: input.asker.username, href }));
174 return messageId !== null;
175 },
176 async request({ connector, ability, source, why }) {
177 const found = connector ? CONNECTORS.find((c) => c.id === connector) : null;
178 if (!ability && !found) return false;
179 const card = requestCard({
180 agent: { id: agent.id, handle: agent.handle, display_name: agent.display_name },
181 workspace,
182 connector: found ? { id: found.id, name: found.name, available: found.status === "available" && found.scopes.includes("workspace") } : null,
183 ability: ability && source ? { id: ability.id, label: `${source.name}: ${ability.label}` } : null,
184 why,
185 status: "open",
186 by: null,
187 });
188 return (await input.postCard(card)) !== null;
189 },
190 refused({ ability, source, rule, call }) {
191 recordRefusal(env, { agent, workspace, asker: input.asker.username, rule, message: `Refused "${call}": ${source.name}: ${ability.label} is ${rule.split("=")[1] ?? ability.level}.` });
192 },
193 };
194}
195
Every agent can have its own computer. A session that needs one wakes it: a home of its own on g1t cloud, one per agent and never shared, where it runs commands, reads and writes files and keeps what it made, with each session working in its own folder under a shared home; after ten idle minutes it sleeps, its home kept as a snapshot and restored when it wakes, and Reset wipes the home while memory and artifacts stay. Its shell and files are abilities with the usual choices, Alone, Alone when asked, Ask first or Never, offered only inside sessions and never to a chat reply; every command shows on the session with its output, and the agent's new Computer tab shows the state, the disk used of the five gigabytes included, the recent commands, and Wake, Put to sleep and Reset. Machine time counts only while it is awake, on the sandbox lines of the ledger that name the agent and who asked, held to the same spend caps as the session; the disk itself costs nothing in this version. The runner gained a long-lived supervisor that answers the computer's requests inside the container, and the runner service a computer per agent that keeps its snapshot in the agent homes bucket when one is attached, and says so when none is. The REST API and the agent tool can read a computer, wake it, put it to sleep and reset it. The agents, abilities, sessions, runners, billing and deploy guides say how it works and what an operator sets up; pinning a computer to your own runner, its browser and take-over come next.196/** A card's preview of what a call would write or run: the text of a comment or note, or the command. */
Each agent has an Abilities tab: g1t's built-ins, always on within the asker's access; its computer, coming; each connected integration's actions one row each, read, import, comment or resolve, with a level for each, alone, alone when the person asked for it, ask first or never, and whose connection it runs on; and MCP servers an owner adds. Reading is alone, writing inside g1t follows today's choices, anything that leaves g1t asks first, and production deploys can't go above ask. The agents service enforces every level: ask first posts a card to allow or deny and parks a session until it's answered, a refusal names its rule in the transcript and the audit log, and a missing ability posts a request to owners. The agent abilities guide says how.197function bodyOf(args: Record<string, unknown>): string | null {
Every agent can have its own computer. A session that needs one wakes it: a home of its own on g1t cloud, one per agent and never shared, where it runs commands, reads and writes files and keeps what it made, with each session working in its own folder under a shared home; after ten idle minutes it sleeps, its home kept as a snapshot and restored when it wakes, and Reset wipes the home while memory and artifacts stay. Its shell and files are abilities with the usual choices, Alone, Alone when asked, Ask first or Never, offered only inside sessions and never to a chat reply; every command shows on the session with its output, and the agent's new Computer tab shows the state, the disk used of the five gigabytes included, the recent commands, and Wake, Put to sleep and Reset. Machine time counts only while it is awake, on the sandbox lines of the ledger that name the agent and who asked, held to the same spend caps as the session; the disk itself costs nothing in this version. The runner gained a long-lived supervisor that answers the computer's requests inside the container, and the runner service a computer per agent that keeps its snapshot in the agent homes bucket when one is attached, and says so when none is. The REST API and the agent tool can read a computer, wake it, put it to sleep and reset it. The agents, abilities, sessions, runners, billing and deploy guides say how it works and what an operator sets up; pinning a computer to your own runner, its browser and take-over come next.198 const text = typeof args.text === "string" ? args.text.trim() : typeof args.command === "string" ? `$ ${args.command.trim()}` : "";
Each agent has an Abilities tab: g1t's built-ins, always on within the asker's access; its computer, coming; each connected integration's actions one row each, read, import, comment or resolve, with a level for each, alone, alone when the person asked for it, ask first or never, and whose connection it runs on; and MCP servers an owner adds. Reading is alone, writing inside g1t follows today's choices, anything that leaves g1t asks first, and production deploys can't go above ask. The agents service enforces every level: ask first posts a card to allow or deny and parks a session until it's answered, a refusal names its rule in the transcript and the audit log, and a missing ability posts a request to owners. The agent abilities guide says how.199 return text ? text.slice(0, 900) : null;
200}
201
202/**
203 * A refusal in the workspace's audit log, by the agent, naming the rule
204 * (`integration:linear:comment=never`). Never fails the turn.
205 */
206export function recordRefusal(env: Pick<AbilityEnv, "EVENTS">, input: { agent: Row; workspace: string; asker: string | null; rule: string; message: string }): void {
207 const entry = {
208 actorKind: "agent",
209 actor: input.agent.handle,
210 actorId: input.agent.id,
211 agent: input.agent.handle,
212 onBehalfOf: input.asker,
213 runId: null,
214 runKind: null,
215 credentialId: null,
216 action: "ability_refused",
217 // The audit log knows the surfaces people use; an agent acts through the site on their behalf.
218 surface: "web",
219 workspace: input.workspace.toLowerCase(),
220 repo: null,
221 number: null,
222 gitRef: null,
223 path: `agents/${input.agent.handle}`,
224 outcome: "denied",
225 rule: input.rule,
226 result: "refused",
227 message: input.message,
228 requestId: `req_${crypto.randomUUID()}`,
229 };
230 env.EVENTS.fetch("https://service/rpc/audit_record", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ entries: [entry] }) })
231 .then((response) => {
232 if (!response.ok) throw new Error(`status ${response.status}`);
233 })
234 .catch((error: unknown) => console.error("agents: a refusal was not written to the audit log", String(error)));
235}
236
237/** An allowed or denied call in the audit log, by the person who decided. */
238export function recordDecision(env: Pick<AbilityEnv, "EVENTS">, input: { by: User; agent: Row; workspace: string; request: AbilityRequestRow; allowed: boolean }): void {
239 const entry = {
240 actorKind: "person",
241 actor: input.by.username,
242 actorId: input.by.id,
243 agent: input.agent.handle,
244 onBehalfOf: null,
245 runId: null,
246 runKind: null,
247 credentialId: null,
248 action: input.allowed ? "ability_allowed" : "ability_denied",
249 surface: "web",
250 workspace: input.workspace.toLowerCase(),
251 repo: null,
252 number: null,
253 gitRef: null,
254 path: `agents/${input.agent.handle}`,
255 outcome: "allowed",
256 rule: `${input.request.ability}=ask`,
257 result: "ok",
258 message: `${input.allowed ? "Allowed" : "Denied"} @${input.agent.handle}: ${input.request.summary}`,
259 requestId: `req_${crypto.randomUUID()}`,
260 };
261 env.EVENTS.fetch("https://service/rpc/audit_record", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ entries: [entry] }) })
262 .then((response) => {
263 if (!response.ok) throw new Error(`status ${response.status}`);
264 })
265 .catch((error: unknown) => console.error("agents: a decision was not written to the audit log", String(error)));
266}
267
268/** Pending requests of a session: what it waits on. */
269export async function pendingRequests(db: D1Database, sessionId: string): Promise<AbilityRequestRow[]> {
270 const { results } = await db.prepare("SELECT * FROM agent_ability_requests WHERE session_id = ? AND status = 'pending' ORDER BY created_at").bind(sessionId).all<AbilityRequestRow>();
271 return results;
272}
273
274/**
275 * Runs an allowed call as `by`, the person who allowed it, with the
276 * agent's abilities as they are now (a server or a connection may have
277 * gone since). What the agent is told.
278 */
279export async function runAllowed(env: AbilityEnv, request: AbilityRequestRow, agent: Row, definition: Definition, by: User): Promise<{ ok: boolean; message: string }> {
280 const sections = await abilitiesFor(env, { agent, definition, workspace: request.workspace, asker: by });
281 const found = findAbility(sections, request.ability);
282 if (!found) return { ok: false, message: `The ability ${request.ability} is no longer there.` };
283 if (!found.source.connected) return { ok: false, message: `${found.source.name} is no longer connected.` };
284 if (found.ability.level === "never") return { ok: false, message: `${found.source.name}: ${found.ability.label} is Never now.` };
285 let args: Record<string, unknown> = {};
286 try {
287 args = JSON.parse(request.input) as Record<string, unknown>;
288 } catch {
289 return { ok: false, message: "The call's arguments couldn't be read." };
290 }
291 const ports = abilityPorts(env, { agent, workspace: request.workspace, channel_id: request.channel_id, session: null, asker: { id: by.id, username: by.username }, postCard: async () => null });
292 const reference = String(args.reference ?? "").trim();
293 try {
294 switch (request.tool) {
295 case "lookup_outside": {
296 const done = await ports.lookup(by, reference);
297 return done.ok ? { ok: true, message: `${done.value.key}: ${done.value.title}${done.value.status ? ` [${done.value.status}]` : ""}\n${done.value.url}\n\n${done.value.body}`.slice(0, 20_000) } : { ok: false, message: done.message };
298 }
299 case "import_outside": {
300 const repo = String(args.repo ?? "").trim().toLowerCase();
301 const [namespace, name] = repo.includes("/") ? repo.split("/") : [request.workspace, repo];
302 if (!namespace || !name) return { ok: false, message: "The call named no repository." };
303 const done = await ports.import(by, { id: "", namespace, name, isPrivate: true, defaultBranch: "main" }, reference);
304 return done.ok ? { ok: true, message: `${done.value.created ? "Opened" : "Already imported as"} ${namespace}/${name}#${done.value.number} from ${done.value.item.key}.` } : { ok: false, message: done.message };
305 }
306 case "act_outside": {
307 const action = args.action === "resolve" ? "resolve" : "comment";
308 const done = await ports.act(by, reference, action, String(args.text ?? "").trim().slice(0, 8000));
309 return done.ok ? { ok: true, message: `${action === "resolve" ? "Resolved" : "Commented on"} ${done.value.key} (${done.value.url}).` } : { ok: false, message: done.message };
310 }
Every agent can have its own computer. A session that needs one wakes it: a home of its own on g1t cloud, one per agent and never shared, where it runs commands, reads and writes files and keeps what it made, with each session working in its own folder under a shared home; after ten idle minutes it sleeps, its home kept as a snapshot and restored when it wakes, and Reset wipes the home while memory and artifacts stay. Its shell and files are abilities with the usual choices, Alone, Alone when asked, Ask first or Never, offered only inside sessions and never to a chat reply; every command shows on the session with its output, and the agent's new Computer tab shows the state, the disk used of the five gigabytes included, the recent commands, and Wake, Put to sleep and Reset. Machine time counts only while it is awake, on the sandbox lines of the ledger that name the agent and who asked, held to the same spend caps as the session; the disk itself costs nothing in this version. The runner gained a long-lived supervisor that answers the computer's requests inside the container, and the runner service a computer per agent that keeps its snapshot in the agent homes bucket when one is attached, and says so when none is. The REST API and the agent tool can read a computer, wake it, put it to sleep and reset it. The agents, abilities, sessions, runners, billing and deploy guides say how it works and what an operator sets up; pinning a computer to your own runner, its browser and take-over come next.311 case "run_command":
312 case "computer_read_file":
313 case "computer_write_file": {
314 // On the agent's own computer, in the session that asked (or a directory of its own for a reply's card).
315 const computer = computerPorts(env, { agent, workspace: request.workspace, session: { id: request.session_id ?? `card-${request.id}` }, asker: { username: by.username }, onCommand: () => undefined });
316 if (!computer) return { ok: false, message: "Agents' computers aren't available on this installation." };
317 const path = String(args.path ?? "").trim();
318 if (request.tool === "run_command") {
319 const command = String(args.command ?? "").trim();
320 if (!command) return { ok: false, message: "The call named no command." };
321 const timeout = Number.isFinite(Number(args.timeout_seconds)) && Number(args.timeout_seconds) > 0 ? Math.min(1800, Math.floor(Number(args.timeout_seconds))) : null;
322 const ran = await computer.exec(command, typeof args.cwd === "string" && args.cwd.trim() ? args.cwd.trim() : null, timeout);
323 if (!ran.ok) return { ok: false, message: ran.message };
324 const how = [`exit ${ran.value.exit_code}`, `${(ran.value.duration_ms / 1000).toFixed(1)} s`, ran.value.timed_out ? "killed at the timeout" : null, ran.value.truncated ? "output cut" : null].filter(Boolean).join(", ");
325 return { ok: ran.value.exit_code === 0, message: `$ ${ran.value.cmd}\n(${how}; in ${ran.value.cwd})\n${ran.value.output || "(no output)"}`.slice(0, 20_000) };
326 }
327 if (!path) return { ok: false, message: "The call named no path." };
328 if (request.tool === "computer_read_file") {
329 const read = await computer.readFile(path);
330 return read.ok ? { ok: true, message: read.value.text.slice(0, 20_000) } : { ok: false, message: read.message };
331 }
332 const wrote = await computer.writeFile(path, String(args.text ?? ""));
333 return wrote.ok ? { ok: true, message: `Wrote ${wrote.value.bytes} bytes to ${wrote.value.path}.` } : { ok: false, message: wrote.message };
334 }
Each agent has an Abilities tab: g1t's built-ins, always on within the asker's access; its computer, coming; each connected integration's actions one row each, read, import, comment or resolve, with a level for each, alone, alone when the person asked for it, ask first or never, and whose connection it runs on; and MCP servers an owner adds. Reading is alone, writing inside g1t follows today's choices, anything that leaves g1t asks first, and production deploys can't go above ask. The agents service enforces every level: ask first posts a card to allow or deny and parks a session until it's answered, a refusal names its rule in the transcript and the audit log, and a missing ability posts a request to owners. The agent abilities guide says how.335 default: {
336 // An MCP tool: `<server>__<tool>`.
337 const [, serverId, ...rest] = request.ability.split(":");
338 const server = (definition.abilities.mcp_servers ?? []).find((s: McpServer) => s.id === serverId);
339 const tool = server?.tools.find((t) => t.name === rest.join(":"));
340 if (!server || !tool) return { ok: false, message: "That MCP tool is no longer there." };
341 const done = await ports.mcp(server, tool, args);
342 return done.ok ? { ok: true, message: done.value.slice(0, 20_000) } : { ok: false, message: done.message };
343 }
344 }
345 } catch (error) {
346 return { ok: false, message: `It failed: ${String(error).slice(0, 200)}` };
347 }
348}
349
350/** Tells the owners (and whoever asked, for a request on their behalf) that a Request card was pressed. */
351export async function tellOwnersOfRequest(env: AbilityEnv, input: { workspace: string; by: User; title: string; body: string; href: string; id: string }): Promise<void> {
352 if (!env.NOTIFY) return;
353 const members = await identityClient(env.IDENTITY)
354 .listMembers(input.workspace, input.by)
355 .catch(() => null);
356 if (!members?.ok) return;
357 const notify = notifyClient(env.NOTIFY);
358 const owners = members.value.filter((member) => member.role === "owner").slice(0, 20);
359 await Promise.all(
360 owners.map((owner) =>
361 notify
362 .notify(
363 { username: owner.username },
364 {
365 id: `ability-request:${input.id}:${owner.username}`,
366 kind: "approval",
367 workspace: input.workspace,
368 title: input.title,
369 body: input.body,
370 href: input.href,
371 actor: { kind: "user", id: input.by.id, name: input.by.username, avatar: input.by.avatar ?? null, avatar_seed: null },
372 created_at: iso(),
373 },
374 )
375 .catch(() => undefined),
376 ),
377 );
378}
379
380/** Posts a card in a conversation as the agent; its message id, or null. */
381export async function postCardAsAgent(env: Pick<AbilityEnv, "CHAT">, workspace: string, channelId: string, agentId: string, card: MessageCard, threadRoot: string | null, askedBy: string | null): Promise<string | null> {
382 const posted = await chatClient(env.CHAT)
383 .postAsAgent(workspace, channelId, agentId, { body: "", card, thread_root: threadRoot, asked_by: askedBy })
384 .catch(() => null);
385 return posted?.ok ? posted.value.id : null;
386}