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 | */ | |
| 16 | import { type Ability, type AbilitySection, type AbilitySource, type McpServer, type MessageCard, type ServiceBinding, type User, chatClient, identityClient, integrationsClient, newId, notifyClient } from "@g1t/contracts"; | |
| 17 | ||
| 18 | import { CONNECTORS, connectorPath, connectorView } from "../../../packages/contracts/src/connectors.ts"; | |
| 19 | import { findAbility, resolveAbilities } from "../../../packages/contracts/src/abilities.ts"; | |
| 20 | import { 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. | 21 | import { 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. | 22 | export { abilitiesSection, saidText } from "./abilities-prompt.ts"; |
| 23 | import type { Definition } from "./definition.ts"; | |
| 24 | import { callMcpTool } from "./mcp-client.ts"; | |
| 25 | import { type Row, isPersonal } from "./store.ts"; | |
| 26 | import type { AbilityPorts, OutsideDone, OutsideItem } from "./tools.ts"; | |
| 27 | ||
| 28 | export 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. */ | |
| 42 | export 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 | ||
| 63 | const iso = () => new Date().toISOString(); | |
| 64 | ||
| 65 | /** The site's address, without a trailing slash. */ | |
| 66 | export 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 | */ | |
| 74 | export 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. */ | |
| 84 | export 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. */ | |
| 90 | export function abilitiesPath(workspace: string, handle: string): string { | |
| 91 | return `/${workspace}/-/agents/${handle}/abilities`; | |
| 92 | } | |
| 93 | ||
| 94 | const 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 | ||
| 96 | const 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 | */ | |
| 103 | export 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. | 197 | function 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 | */ | |
| 206 | export 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. */ | |
| 238 | export 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. */ | |
| 269 | export 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 | */ | |
| 279 | export 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. */ | |
| 351 | export 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. */ | |
| 381 | export 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 | } |