Skip to content

Commit

An archived agent can be found, brought back, or deleted for good. The Agents page ends with Archived, folded, where owners see every archived agent and members their own personal ones, each with when and by whom it was archived and what deleting it would take; Restore brings it back, under a new handle when a live agent has taken its old one, and Delete permanently asks for the handle to be typed and then removes its sessions, replies, memories, routines, versions, drafts, skills and its computer's home, leaving its messages in chat under its saved name, the workspace's spend totals and the audit log. In Chat an archived agent is deactivated the way people expect: never offered as someone to message or mention, never woken, a conversation with it kept in the list, dimmed and marked Archived, open to read with a note in place of the composer and a way to restore it for owners; after deletion the conversation still reads, its messages marked as a deleted agent's. The agents, chat and agent budgets guides say so.

syntaqxcommitted Parentc8d099aBrowse files
32 files+1304−550/32 viewed
+1−1
8181 | View | Shows |
8282 | --- | --- |
8383 | **By day** | Spend each day, UTC. |
84−| **By agent** | Each agent's share, largest first. |
84+| **By agent** | Each agent's share, largest first. An archived agent keeps its share; one [deleted for good](/guides/agents/#archive-restore-and-delete) leaves the breakdown, while what it spent stays in the month's total, on the statement and on Usage. |
8585 | **By team** | Each team's agents together, by the teams they are on. An agent on two teams counts towards both; agents on none are under **Not on a team**. |
8686 | **By kind of work** | Chat replies, sessions, routines, helping colleagues and subagents. |
8787 | **By who asked** | The work done for each person. Routines and agent-to-agent work have no asker. |
+40−3
836836 - **Reset wipes the home.** The agent's memory and artifacts are kept;
837837 its files, clones and command history go.
838838 - **Archiving the agent** puts the computer to sleep and keeps its disk
839− for 30 days, then deletes it.
839+ for 30 days, then deletes it. [Restoring](#archive-restore-and-delete)
840+ the agent keeps the disk; deleting the agent deletes it at once.
840841
841842 Every command runs as the agent in `bash`, with the output returned when it
842843 ends and a timeout of 120 seconds unless the agent asks for more (up to 30
873874 Its status is one of **Idle**, **Working**, **Waiting on you**, **Out of
874875 budget** or **Paused**, on the Agents page and beside its name in Chat.
875876
877+## Archive, restore and delete
878+
876879 To retire an agent, an owner **archives** it from its profile; a member can
877−archive their own personal agent. It stops answering, leaves the member
878−lists, and its history stays.
880+archive their own personal agent. Archiving is like deactivating a person's
881+account, not deleting it:
882+
883+- It stops answering, is never woken again, and shows as typing or working
884+ nowhere.
885+- It leaves the Agents list, the People directory, the sidebar's **Agents**
886+ section, **New message** and `@` autocomplete, and its handle is free for
887+ a new agent.
888+- Its conversations stay. A direct message with it is still in Chat's
889+ sidebar, dimmed and marked **Archived**, and opens to read; what it said
890+ keeps its name and face. See [archived and deleted
891+ agents](/guides/chat/#archived-and-deleted-agents) in the chat guide.
892+- Its sessions, replies, memory, routines and spend are kept, and its
893+ computer's disk for 30 days.
894+
895+Archived agents are listed under **Archived** at the foot of the Agents
896+page, folded, newest first: owners see the workspace's, a member their own
897+archived personal agents. Each row shows who archived it and when, and what
898+is kept about it, such as *14 sessions · 231 replies · 3 memories*. Two
899+things can happen to it from there:
900+
901+| Action | What it does | Who may |
902+| --- | --- | --- |
903+| **Restore** | The agent is live again under its handle, with its definition, versions, memory, routines and conversations as they were; its computer's disk is kept after all. If a live agent has taken the handle meanwhile, you are asked for a new handle to restore it under, or you rename the live one first. A new handle is saved as a new version. | Owners, any agent; a member, their own personal agent |
904+| **Delete permanently** | Type its handle back to confirm. Its definition and every version, sessions and their transcripts, replies, memory, routines, drafts, suggestions, budget roll-ups and its computer's disk are deleted, and it leaves every channel and every team. There is no undo. | Owners, any agent; a member, their own personal agent |
905+
906+Only an archived agent can be deleted: archive it first, so nothing goes
907+by accident. `@g1t` can be neither archived nor deleted.
908+
909+What stays after a delete: its messages in chat, shown under the name it
910+had and marked **Deleted agent**, and direct messages with it, readable by
911+their people; what it spent, in the month's total
912+against the agent budget, on the workspace's statement and on Usage (its
913+own lines leave the Agents page's breakdown); and the audit log, where
914+archiving, restoring and deleting are `archive_agent`, `restore_agent` and
915+`delete_agent`.
879916
880917 ## Coming soon
881918
+19−0
231231 they answer every message from a person, as in any direct message with an
232232 agent.
233233
234+### Archived and deleted agents
235+
236+An owner can [archive an agent](/guides/agents/#archive-restore-and-delete),
237+and a member their own personal agent. Until it is restored or deleted for
238+good, an archived agent is deactivated, not gone:
239+
240+| Where | What you see |
241+| --- | --- |
242+| The sidebar's **Agents** section | It is no longer among the agents you haven't talked to yet, and **New message** and `@` autocomplete no longer offer it. A direct message you already had with it stays listed, dimmed, with **Archived** beside its name where its title was. |
243+| A direct message with it alone | The header says it was archived, when and by whom. A note stands where the composer was: everything said stays to read, and an owner can restore it, or delete it for good, under **Archived** on the Agents page. |
244+| A group message it is in | The composer stays, for the people and live agents there. The archived agent is never woken, never shows as typing, and is not the one an unaddressed message goes to. |
245+| Its messages | Keep its name and face, everywhere. |
246+
247+Once an agent is deleted for good, it leaves every channel and every
248+list. A direct message with it stays in its people's sidebar, marked
249+**Deleted**, and opens to read, with a note where the composer was; its
250+messages, there and in channels, show under the name it had, marked
251+**Deleted agent**.
252+
234253 ## Threads
235254
236255 Reply to any message to start a thread under it. The thread keeps a side
+1−1
2323 export async function answer<T>(intent: string, call: Promise<Result<T>>): Promise<ActionResult> {
2424 const result = await call.catch(() => null);
2525 if (!result) return { ok: false, intent, error: "The agents service didn't answer. Try again in a moment." };
26− if (!result.ok) return { ok: false, intent, error: result.error.message };
26+ if (!result.ok) return { ok: false, intent, error: result.error.message, code: result.error.code };
2727 return { ok: true, intent };
2828 }
2929
+239−0
1+import { Archive, ArchiveRestore, ChevronRight, Trash2 } from "lucide-react";
2+import { useEffect, useId, useState } from "react";
3+import { useFetcher, useLocation } from "react-router";
4+
5+import type { ArchivedAgent } from "@g1t/contracts";
6+
7+import { AgentAvatar } from "../agent-avatar";
8+import {
9+ AlertDialog,
10+ AlertDialogCancel,
11+ AlertDialogContent,
12+ AlertDialogDescription,
13+ AlertDialogFooter,
14+ AlertDialogHeader,
15+ AlertDialogTitle,
16+ AlertDialogTrigger,
17+} from "../ui/alert-dialog";
18+import { Badge } from "../ui/badge";
19+import { Button } from "../ui/button";
20+import { Card } from "../ui/card";
21+import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "../ui/collapsible";
22+import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "../ui/dialog";
23+import { Field, FieldDescription, FieldError, FieldLabel } from "../ui/field";
24+import { Input, InputAddon, InputGroup } from "../ui/input";
25+import { TimeAgo } from "../ui";
26+import { confirmsHandle, countsLine } from "../../lib/agents-archived";
27+import { type ActionResult, useDialogFetcher } from "./dialogs";
28+
29+/**
30+ * The Archived section at the foot of the Agents page
31+ * (docs.g1t.sh/guides/agents/, "Archive, restore and delete"): the
32+ * workspace's archived agents for an owner, a member's own archived
33+ * personal agents for anyone else. Folded by default with its count;
34+ * `#archived` in the address opens it. Each row restores the agent, or
35+ * deletes it for good after its handle is typed back.
36+ */
37+export function ArchivedSection({ slug, agents, owner }: { slug: string; agents: ArchivedAgent[]; owner: boolean }) {
38+ const { hash } = useLocation();
39+ const [open, setOpen] = useState(false);
40+ useEffect(() => {
41+ if (hash === "#archived") setOpen(true);
42+ }, [hash]);
43+ if (agents.length === 0) return null;
44+ return (
45+ <section id="archived" aria-labelledby="archived-heading">
46+ <Collapsible open={open} onOpenChange={setOpen}>
47+ <div className="flex items-baseline justify-between gap-3">
48+ <h2 id="archived-heading" className="text-sm font-medium">
49+ <CollapsibleTrigger className="font-medium text-fg">
50+ <ChevronRight size={14} className="shrink-0 text-faint" />
51+ <Archive size={14} className="text-faint" />
52+ Archived <span className="font-normal text-faint">· {agents.length}</span>
53+ </CollapsibleTrigger>
54+ </h2>
55+ <span className="text-xs text-faint">{owner ? "Restore one, or delete it for good" : "Your archived personal agents"}</span>
56+ </div>
57+ <CollapsibleContent>
58+ <Card asChild divided className="mt-3 overflow-hidden">
59+ <ul>
60+ {agents.map((agent) => (
61+ <ArchivedRow key={agent.id} slug={slug} agent={agent} />
62+ ))}
63+ </ul>
64+ </Card>
65+ </CollapsibleContent>
66+ </Collapsible>
67+ </section>
68+ );
69+}
70+
71+/** One archived agent: its face, dimmed, what it was, when it was archived, what is kept about it, and the two ways out. */
72+function ArchivedRow({ slug, agent }: { slug: string; agent: ArchivedAgent }) {
73+ return (
74+ <li className="flex flex-wrap items-center gap-3 px-4 py-3 sm:flex-nowrap">
75+ <span className="shrink-0 opacity-60 grayscale-[0.4]">
76+ <AgentAvatar agent={{ ...agent, status: null }} size={32} />
77+ </span>
78+ <div className="min-w-0 grow basis-48">
79+ <p className="flex items-center gap-2 text-sm">
80+ <span className="truncate font-medium text-muted">{agent.display_name}</span>
81+ <span className="truncate text-faint">@{agent.handle}</span>
82+ {agent.scope === "personal" && (
83+ <Badge tone="neutral" className="shrink-0 font-normal">
84+ {agent.personal_owner ? `@${agent.personal_owner}'s` : "Personal"}
85+ </Badge>
86+ )}
87+ </p>
88+ <p className="truncate text-xs text-faint">
89+ {agent.title || agent.role}
90+ {" · "}
91+ Archived <TimeAgo at={agent.archived_at} />
92+ {agent.archived_by && ` by @${agent.archived_by}`}
93+ </p>
94+ <p className="truncate text-xs text-faint tabular-nums">{countsLine(agent.counts)}</p>
95+ </div>
96+ <div className="flex shrink-0 flex-wrap items-center gap-2">
97+ <RestoreButton slug={slug} agent={agent} />
98+ <DeleteDialog slug={slug} agent={agent} />
99+ </div>
100+ </li>
101+ );
102+}
103+
104+/**
105+ * Restore, in one press. When a live agent holds the handle now, the
106+ * service says so (`conflict`) and a dialog asks for a new handle to
107+ * restore under instead.
108+ */
109+function RestoreButton({ slug, agent }: { slug: string; agent: ArchivedAgent }) {
110+ const fetcher = useFetcher<ActionResult>({ key: `restore-${agent.id}` });
111+ const [rename, setRename] = useState(false);
112+ const idle = fetcher.state === "idle";
113+ const refused = idle && fetcher.data && !fetcher.data.ok ? fetcher.data : null;
114+ useEffect(() => {
115+ if (refused?.code === "conflict") setRename(true);
116+ }, [refused]);
117+ const busy = !idle;
118+ return (
119+ <>
120+ <fetcher.Form method="post" action={`/${slug}/-/agents?index`} className="flex flex-col items-end gap-1">
121+ <input type="hidden" name="intent" value="restore" />
122+ <input type="hidden" name="id" value={agent.id} />
123+ <input type="hidden" name="handle" value={agent.handle} />
124+ <Button type="submit" variant="outline" size="sm" disabled={busy}>
125+ <ArchiveRestore size={14} />
126+ {busy ? "Restoring…" : "Restore"}
127+ </Button>
128+ {refused && refused.code !== "conflict" && (
129+ <p role="alert" className="text-xs text-danger">
130+ {refused.error}
131+ </p>
132+ )}
133+ </fetcher.Form>
134+ <RenameDialog slug={slug} agent={agent} open={rename} onOpenChange={setRename} why={refused?.code === "conflict" ? refused.error : null} />
135+ </>
136+ );
137+}
138+
139+/** The handle is taken by a live agent: restore under a new one. */
140+function RenameDialog({ slug, agent, open, onOpenChange, why }: { slug: string; agent: ArchivedAgent; open: boolean; onOpenChange: (open: boolean) => void; why: string | null }) {
141+ const fetcher = useFetcher<ActionResult>({ key: `restore-as-${agent.id}` });
142+ const id = useId();
143+ useEffect(() => {
144+ if (fetcher.state === "idle" && fetcher.data?.ok) onOpenChange(false);
145+ }, [fetcher.state, fetcher.data, onOpenChange]);
146+ const error = fetcher.state === "idle" && fetcher.data && !fetcher.data.ok ? fetcher.data.error : null;
147+ const busy = fetcher.state !== "idle";
148+ return (
149+ <Dialog open={open} onOpenChange={onOpenChange}>
150+ <DialogContent>
151+ <DialogHeader>
152+ <DialogTitle>Restore {agent.display_name} under a new handle</DialogTitle>
153+ <DialogDescription>{why ?? `A live agent is called @${agent.handle} now. Rename that one first, or restore this one under a new handle.`}</DialogDescription>
154+ </DialogHeader>
155+ <fetcher.Form method="post" action={`/${slug}/-/agents?index`} className="grid gap-5">
156+ <input type="hidden" name="intent" value="restore" />
157+ <input type="hidden" name="id" value={agent.id} />
158+ <input type="hidden" name="handle" value={agent.handle} />
159+ <Field>
160+ <FieldLabel htmlFor={`${id}-handle`}>New handle</FieldLabel>
161+ <InputGroup>
162+ <InputAddon>@</InputAddon>
163+ <Input id={`${id}-handle`} name="new_handle" defaultValue={`${agent.handle}-2`} required autoComplete="off" autoFocus />
164+ </InputGroup>
165+ <FieldDescription>2 to 32 lowercase letters, digits and hyphens. People mention it by this; its name and face stay.</FieldDescription>
166+ </Field>
167+ <FieldError>{error}</FieldError>
168+ <DialogFooter>
169+ <Button type="button" variant="outline" onClick={() => onOpenChange(false)}>
170+ Cancel
171+ </Button>
172+ <Button type="submit" variant="accent" disabled={busy}>
173+ {busy ? "Restoring…" : "Restore"}
174+ </Button>
175+ </DialogFooter>
176+ </fetcher.Form>
177+ </DialogContent>
178+ </Dialog>
179+ );
180+}
181+
182+/**
183+ * Delete permanently: asked once more, with what goes and what stays
184+ * spelled out, and the handle typed back before the button works.
185+ */
186+function DeleteDialog({ slug, agent }: { slug: string; agent: ArchivedAgent }) {
187+ const { fetcher, open, setOpen, error, busy } = useDialogFetcher(`destroy-${agent.id}`);
188+ const [typed, setTyped] = useState("");
189+ const id = useId();
190+ const kept = countsLine(agent.counts);
191+ return (
192+ <AlertDialog
193+ open={open}
194+ onOpenChange={(next) => {
195+ setOpen(next);
196+ if (!next) setTyped("");
197+ }}
198+ >
199+ <AlertDialogTrigger asChild>
200+ <Button type="button" variant="destructive" size="sm">
201+ <Trash2 size={14} />
202+ Delete permanently
203+ </Button>
204+ </AlertDialogTrigger>
205+ <AlertDialogContent>
206+ <AlertDialogHeader>
207+ <AlertDialogTitle>Delete {agent.display_name} permanently?</AlertDialogTitle>
208+ <AlertDialogDescription asChild>
209+ <div className="space-y-3">
210+ <p>
211+ This deletes <span className="font-medium text-fg">@{agent.handle}</span> for good: its definition and every version, {kept === "Nothing recorded" ? "what little it recorded" : kept.toLowerCase()}, its drafts and suggestions, its budget and its computer&apos;s disk. There is no undo.
212+ </p>
213+ <p>What stays: its messages in chat, shown under the name it had; what it spent, in the month&apos;s totals on the workspace&apos;s statement and on Usage; and the audit log.</p>
214+ </div>
215+ </AlertDialogDescription>
216+ </AlertDialogHeader>
217+ <fetcher.Form method="post" action={`/${slug}/-/agents?index`} className="grid gap-5">
218+ <input type="hidden" name="intent" value="destroy" />
219+ <input type="hidden" name="id" value={agent.id} />
220+ <input type="hidden" name="handle" value={agent.handle} />
221+ <Field>
222+ <FieldLabel htmlFor={`${id}-confirm`}>
223+ Type <span className="font-mono text-fg">@{agent.handle}</span> to confirm
224+ </FieldLabel>
225+ <Input id={`${id}-confirm`} name="confirm" value={typed} onChange={(event) => setTyped(event.target.value)} autoComplete="off" spellCheck={false} aria-describedby={`${id}-confirm-hint`} />
226+ <FieldDescription id={`${id}-confirm-hint`}>Its handle, so nothing goes by accident.</FieldDescription>
227+ </Field>
228+ <FieldError>{error}</FieldError>
229+ <AlertDialogFooter>
230+ <AlertDialogCancel type="button">Cancel</AlertDialogCancel>
231+ <Button type="submit" variant="destructive" disabled={busy || !confirmsHandle(typed, agent.handle)}>
232+ {busy ? "Deleting…" : "Delete"}
233+ </Button>
234+ </AlertDialogFooter>
235+ </fetcher.Form>
236+ </AlertDialogContent>
237+ </AlertDialog>
238+ );
239+}
+1−1
2323 import { PrivateTitle, SpendOfCap } from "./parts";
2424
2525 /** What Agents mode's actions answer. */
26−export type ActionResult = { ok: true; intent?: string } | { ok: false; error: string; field?: string; intent?: string };
26+export type ActionResult = { ok: true; intent?: string } | { ok: false; error: string; field?: string; intent?: string; code?: string };
2727
2828 /** A fetcher whose dialog closes once what it sent has worked. */
2929 export function useDialogFetcher(key?: string) {
+59−7
7171 type Typer,
7272 WORKING_AFTER_MS,
7373 agentsAmong,
74+ archivedAgentLine,
75+ archivedAgentWhen,
76+ archivedAgentsDm,
7477 channelPath,
7578 dmPlaceholder,
7679 isMixed,
206209 viewingConversation(data.channel.id);
207210 return () => viewingConversation(null);
208211 }, [data.channel.id]);
209− const others = members.filter((m) => !(m.member.kind === "user" && m.member.id === me?.id)).map((m) => m.member);
212+ const everyoneElse = members.filter((m) => !(m.member.kind === "user" && m.member.id === me?.id)).map((m) => m.member);
213+ // An agent deleted for good is no longer in the conversation, unless it was only ever the two of you: then it is who the conversation was with.
214+ const stillHere = everyoneElse.filter((m) => m.deleted !== true);
215+ const others = stillHere.length > 0 ? stillHere : everyoneElse;
210216 const isDm = data.channel.kind === "dm";
217+ // A direct message with one archived agent opens read-only: a note stands where the composer was (lib/chat.ts).
218+ const archivedAgent = isDm && others.length === 1 ? archivedAgentsDm({ channel: data.channel, others }) : null;
219+ const owner = chatData?.role === "owner";
211220
212221 // The details panel: open by default on a wide screen, as last left.
213222 useEffect(() => {
740749 </Button>
741750 )}
742751 </Card>
752+ ) : archivedAgent ? (
753+ <Card className="flex flex-wrap items-center justify-between gap-3 px-4 py-3">
754+ <p className="flex items-start gap-2 text-sm text-muted">
755+ <Archive size={15} className="mt-0.5 shrink-0 text-faint" />
756+ <span>
757+ {archivedAgent.deleted ? (
758+ <>{shownName(archivedAgent)} was deleted for good. Everything it said here stays to read.</>
759+ ) : (
760+ <>
761+ {archivedAgentLine(archivedAgent)} Everything said here stays to read.{" "}
762+ {owner ? (
763+ <>
764+ Restore it, or delete it for good, under <span className="font-medium text-fg">Archived</span> on the Agents page.
765+ </>
766+ ) : (
767+ "An owner can restore it from the Agents page."
768+ )}
769+ </>
770+ )}
771+ </span>
772+ </p>
773+ {owner && !archivedAgent.deleted && (
774+ <Button asChild variant="outline" className="px-3 text-fg/90 hover:bg-raised">
775+ <Link to={`/${slug}/-/agents#archived`}>
776+ <ArchiveRestore size={15} />
777+ Restore under Agents
778+ </Link>
779+ </Button>
780+ )}
781+ </Card>
743782 ) : joined ? (
744783 <Composer draftKey={data.channel.id} placeholder={placeholder} people={people} onSend={(body) => void post(body, null)} onTyping={onTyping} autoFocus />
745784 ) : (
920959 <div className="min-w-0">
921960 <h1 className="flex min-w-0 items-center gap-1.5 text-[0.9375rem] leading-tight font-semibold">
922961 <span className="truncate">{others.map(shownName).join(", ") || "Just you"}</span>
923− {agentDm && <AgentPill />}
962+ {agentDm && <AgentPill deleted={agentDm.deleted === true} />}
963+ {agentDm?.archived_at && (
964+ <Badge tone="neutral" className="shrink-0">
965+ <Archive size={11} />
966+ Archived
967+ </Badge>
968+ )}
924969 </h1>
925− {agentDm && (titleOf(agentDm) ?? agentDm.role) && (
926− <p className="truncate text-xs leading-tight text-muted">{titleOf(agentDm) ?? agentDm.role}</p>
970+ {agentDm?.archived_at ? (
971+ <p className="truncate text-xs leading-tight text-muted">{archivedAgentWhen(agentDm)}</p>
972+ ) : (
973+ agentDm &&
974+ (titleOf(agentDm) ?? agentDm.role) && <p className="truncate text-xs leading-tight text-muted">{titleOf(agentDm) ?? agentDm.role}</p>
927975 )}
928976 {mixed && <MixedChip agents={agentsAmong(others)} />}
929977 {/* One person: their status under their name, as their card has it. */}
10001048 </div>
10011049 <h2 className="mt-3 flex items-center gap-2 text-xl font-semibold tracking-tight">
10021050 {others.map(shownName).join(", ") || "Just you"}
1003− {agent && <AgentPill />}
1051+ {agent && <AgentPill deleted={agent.deleted === true} />}
10041052 {mixed && (
10051053 <Badge tone="accent" size="md" className="font-normal">
10061054 with an agent
10081056 )}
10091057 </h2>
10101058 <p className="mt-1 max-w-xl text-sm text-muted">
1011− {agent
1059+ {agent?.deleted
1060+ ? "This agent was deleted. What it said stays here to read."
1061+ : agent?.archived_at
1062+ ? archivedAgentLine(agent)
1063+ : agent
10121064 ? `${agent.role ? `${agent.role}. ` : ""}Ask a question or give it a job: it answers here, and opens a task when the work needs one.`
10131065 : mixed
10141066 ? "A conversation between people and an agent. Everyone here, the agent too, reads every message."
13821434 {shownName(author)}
13831435 </MemberCard>
13841436 {author.kind === "user" && <PersonStatusEmoji person={{ id: author.id, username: author.name }} size={14} className="self-center" />}
1385− {author.kind === "agent" && <AgentPill className="self-center" />}
1437+ {author.kind === "agent" && <AgentPill className="self-center" deleted={author.deleted === true} />}
13861438 <time dateTime={message.created_at} suppressHydrationWarning className="shrink-0 text-xs text-faint tabular-nums">
13871439 {time}
13881440 </time>
+9−5
6464 );
6565 }
6666
67−/** AGENT, beside an agent's name wherever it speaks. */
68−export function AgentPill({ className }: { className?: string }) {
67+/**
68+ * AGENT, beside an agent's name wherever it speaks; DELETED AGENT, in the
69+ * quiet colours, beside the name an agent deleted for good spoke under
70+ * (docs.g1t.sh/guides/chat/, "Archived and deleted agents").
71+ */
72+export function AgentPill({ className, deleted = false }: { className?: string; deleted?: boolean }) {
6973 // The avatar beside it carries the agent marker; this is the word, for where the name stands alone.
70− return <AgentWord className={className} />;
74+ return <AgentWord className={cn(deleted && "bg-raised text-muted", className)}>{deleted ? "Deleted agent" : "Agent"}</AgentWord>;
7175 }
7276
73−function AgentWord({ className }: { className?: string }) {
77+function AgentWord({ className, children }: { className?: string; children: string }) {
7478 return (
7579 <span
7680 className={cn(
7882 className,
7983 )}
8084 >
81− Agent
85+ {children}
8286 </span>
8387 );
8488 }
+12−11
1616 import { Button } from "../ui/button";
1717 import { Hint } from "../ui/hint";
1818 import { Skeleton } from "../ui/skeleton";
19−import { type ChatFilter, agentDmOf, agentsAmong, channelPath, filterEntries, mixedDm, sections } from "../../lib/chat";
19+import { type ChatFilter, agentDmOf, agentsAmong, agentsToTalkTo, archivedAgentsDm, channelPath, filterEntries, mixedDm, sections } from "../../lib/chat";
2020 import { feedConnected } from "../../lib/notify-client";
2121 import { FALLBACK_REFRESH_MS } from "../../lib/notify-store";
2222 import { setMutedConversations } from "../../lib/sound-events";
9595 // The agents you have not talked to yet, g1t first: one click opens a
9696 // conversation, which then lists under Direct messages like anyone's.
9797 // Nothing of theirs is unread, so Unread and Mentions leave them out.
98− const q = query.trim().toLowerCase().replace(/^@/, "");
99− const agents =
100− filter !== "all"
101− ? []
102− : [...(data?.agents ?? [])]
103− .filter((agent) => !agentDms.has(agent.id))
104− .sort((a, b) => Number(isOrchestrator(b)) - Number(isOrchestrator(a)) || a.display_name.localeCompare(b.display_name))
105− .filter((agent) => !q || agent.handle.includes(q) || agent.display_name.toLowerCase().includes(q) || agent.role.toLowerCase().includes(q));
98+ // An archived agent is never offered (lib/chat.ts `agentsToTalkTo`).
99+ const agents = filter !== "all" ? [] : agentsToTalkTo(data?.agents ?? [], agentDms, query);
106100
107101 // Folded sections, remembered per workspace on this device. A folded
108102 // section still shows what is unread and the conversation open now.
471465 const g1t = agent ? isOrchestrator(agent) : false;
472466 // A person and an agent together: both faces, the agent's marked, and the word.
473467 const mixed = mixedDm(entry);
468+ // With an archived agent (lib/chat.ts): still here to read, dimmed, and said to be archived in place of its title.
469+ const archived = archivedAgentsDm(entry);
474470 const icon = agent ? (
475471 <AgentFace agent={agent} g1t={g1t} />
476472 ) : mixed ? (
491487 {...prefetch}
492488 aria-current={current ? "page" : undefined}
493489 className={`${ROW} ${
494− current ? "bg-sidebar-accent text-fg" : unread ? "text-fg hover:bg-sidebar-accent/60" : entry.muted ? "text-faint hover:bg-sidebar-accent/60 hover:text-muted" : "text-muted hover:bg-sidebar-accent/60 hover:text-fg"
490+ current ? "bg-sidebar-accent text-fg" : unread ? "text-fg hover:bg-sidebar-accent/60" : entry.muted || archived ? "text-faint hover:bg-sidebar-accent/60 hover:text-muted" : "text-muted hover:bg-sidebar-accent/60 hover:text-fg"
495491 }`}
496492 >
497− <span className={`flex shrink-0 justify-center ${mixed ? "w-7" : "w-4.5"} ${current || unread ? "text-muted" : "text-faint"}`}>{icon}</span>
493+ <span className={`flex shrink-0 justify-center ${mixed ? "w-7" : "w-4.5"} ${current || unread ? "text-muted" : "text-faint"} ${archived ? "opacity-60" : ""}`}>{icon}</span>
498494 <span className={`min-w-0 grow truncate ${unread ? "font-semibold" : current ? "font-medium" : ""}`}>
499495 {agent ? agent.display_name : entry.title}
500496 {agent && <span className="ml-1.5 text-[0.6875rem] font-normal text-faint">{g1t ? "orchestrator" : agent.title}</span>}
502498 <PersonStatusEmoji person={{ id: other.id, username: other.name }} size={13} className="ml-1.5 align-[-2px]" inert />
503499 )}
504500 </span>
501+ {archived && (
502+ <Badge tone="neutral" className="shrink-0 px-1.5 font-normal">
503+ {archived.deleted ? "Deleted" : "Archived"}
504+ </Badge>
505+ )}
505506 {mixed && (
506507 <Badge tone="accent" className="shrink-0 px-1.5 font-normal">
507508 with an agent
+18−0
1+import assert from "node:assert/strict";
2+import { test } from "node:test";
3+
4+import { confirmsHandle, countsLine } from "./agents-archived.ts";
5+
6+test("an archived agent's row says what deleting it takes, zeros left out", () => {
7+ assert.equal(countsLine({ sessions: 14, replies: 231, memories: 3, routines: 0 }), "14 sessions · 231 replies · 3 memories");
8+ assert.equal(countsLine({ sessions: 1, replies: 1, memories: 1, routines: 1 }), "1 session · 1 reply · 1 memory · 1 routine");
9+ assert.equal(countsLine({ sessions: 1200, replies: 0, memories: 0, routines: 0 }), "1,200 sessions");
10+ assert.equal(countsLine({ sessions: 0, replies: 0, memories: 0, routines: 0 }), "Nothing recorded");
11+});
12+
13+test("deleting is confirmed by the handle, however it is typed", () => {
14+ assert.ok(confirmsHandle("sam", "sam"));
15+ assert.ok(confirmsHandle(" @Sam ", "sam"));
16+ assert.ok(!confirmsHandle("sam2", "sam"));
17+ assert.ok(!confirmsHandle("", "sam"));
18+});
+24−0
1+/**
2+ * The Archived section of the Agents page (docs.g1t.sh/guides/agents/,
3+ * "Archive, restore and delete"): what each archived agent's row says
4+ * about what deleting it would take. Pure, so it is tested on its own.
5+ */
6+import type { ArchivedAgent } from "@g1t/contracts";
7+
8+const COUNTED: [keyof ArchivedAgent["counts"], string, string][] = [
9+ ["sessions", "session", "sessions"],
10+ ["replies", "reply", "replies"],
11+ ["memories", "memory", "memories"],
12+ ["routines", "routine", "routines"],
13+];
14+
15+/** "14 sessions · 231 replies · 3 memories": what is kept about the agent, zeros left out. */
16+export function countsLine(counts: ArchivedAgent["counts"]): string {
17+ const parts = COUNTED.filter(([key]) => counts[key] > 0).map(([key, one, many]) => `${counts[key].toLocaleString("en-US")} ${counts[key] === 1 ? one : many}`);
18+ return parts.length ? parts.join(" · ") : "Nothing recorded";
19+}
20+
21+/** Whether what was typed confirms deleting the agent: its handle, with or without the @, in any case. */
22+export function confirmsHandle(typed: string, handle: string): boolean {
23+ return typed.trim().replace(/^@/, "").toLowerCase() === handle.toLowerCase();
24+}
+39−0
44 import type { ChatMessage, ChatSidebarEntry } from "@g1t/contracts";
55
66 import {
7+ agentsToTalkTo,
8+ archivedAgentLine,
9+ archivedAgentWhen,
10+ archivedAgentsDm,
711 backoff,
812 blocks,
913 channelName,
174178 assert.deepEqual(unreadTotals(all), { unread: 3, mentions: 1 });
175179 });
176180
181+test("an archived agent's conversation stays listed under Agents, read-only; the agent itself is no longer offered", () => {
182+ const archived = { ...bot, id: "a2", name: "scribe", display_name: "Scribe", archived_at: "2026-10-08T09:00:00Z", archived_by: "ana" };
183+ const all = [
184+ entry("d1", { kind: "dm", title: "reviewer", others: [bot], last: "2026-10-02T00:00:00Z" }),
185+ entry("d2", { kind: "dm", title: "scribe", others: [archived], last: "2026-10-05T00:00:00Z" }),
186+ // With a person in it, the conversation is theirs to go on with: not an archived one.
187+ entry("d3", { kind: "dm", title: "ana, scribe", others: [ana, archived], last: "2026-10-03T00:00:00Z" }),
188+ // Two agents, one archived: the live one still answers.
189+ entry("d4", { kind: "dm", title: "reviewer, scribe", others: [bot, archived], last: "2026-10-04T00:00:00Z" }),
190+ // Deleted for good: the conversation stays readable, under the name it had.
191+ entry("d5", { kind: "dm", title: "triage", others: [{ ...bot, id: "a9", name: "triage", display_name: "Triage", deleted: true }], last: "2026-10-01T00:00:00Z" }),
192+ ];
193+ const { agents, agentDms } = sections(all);
194+ assert.deepEqual(agents.map((e) => e.title), ["scribe", "reviewer, scribe", "reviewer", "triage"]);
195+ assert.deepEqual(all.map((e) => archivedAgentsDm(e)?.name ?? null), [null, "scribe", null, null, "triage"]);
196+ assert.equal(archivedAgentsDm(all[4]!)?.deleted, true);
197+ // The agents you haven't talked to yet: live ones without a conversation, g1t first; never an archived one.
198+ const roster = [
199+ { id: "a1", handle: "reviewer", display_name: "Reviewer", role: "Reviews", builtin: false, archived_at: null },
200+ { id: "a2", handle: "scribe", display_name: "Scribe", role: "Writes", builtin: false, archived_at: "2026-10-08T09:00:00Z" },
201+ { id: "a3", handle: "zed", display_name: "Zed", role: "Ships", builtin: false, archived_at: null },
202+ { id: "a4", handle: "g1t", display_name: "g1t", role: "Orchestrates", builtin: true, archived_at: null },
203+ { id: "a5", handle: "old", display_name: "Old", role: "Nothing", builtin: false, archived_at: "2026-09-01T00:00:00Z" },
204+ ];
205+ assert.deepEqual(agentsToTalkTo(roster, agentDms).map((a) => a.handle), ["g1t", "zed"]);
206+ assert.deepEqual(agentsToTalkTo(roster, agentDms, "@ship").map((a) => a.handle), ["zed"]);
207+ assert.deepEqual(agentsToTalkTo(roster, new Map()).map((a) => a.handle), ["g1t", "reviewer", "zed"]);
208+ const now = new Date("2026-10-10T12:00:00Z");
209+ assert.equal(archivedAgentLine(archived, now), "Scribe was archived 2 days ago by @ana. It no longer answers here.");
210+ assert.equal(archivedAgentLine({ ...archived, archived_by: null, archived_at: "2026-10-10T11:30:00Z" }, now), "Scribe was archived 30 minutes ago. It no longer answers here.");
211+ assert.equal(archivedAgentLine({ ...archived, archived_by: null, archived_at: null }, now), "Scribe was archived. It no longer answers here.");
212+ assert.equal(archivedAgentWhen(archived, now), "Archived 2 days ago by @ana");
213+ assert.equal(archivedAgentWhen({ archived_at: "2026-10-10T11:00:00Z", archived_by: null }, now), "Archived 1 hour ago");
214+});
215+
177216 test("a mixed conversation says who the agents are, in the composer and above its first message", () => {
178217 assert.equal(dmPlaceholder([ana, bot]), "Message @ana and @reviewer (agent)");
179218 assert.equal(dmPlaceholder([{ ...ana, display_username: "Ana" }, bot, { ...bot, id: "a2", name: "scribe", display_name: "Scribe" }]), "Message @Ana, @reviewer (agent) and @scribe (agent)");
+62−2
77 * Message bodies are never HTML: they are parsed here into a small tree
88 * that React renders as text, so nothing anyone writes can run.
99 */
10−import type { ChatMessage, ChatSidebarEntry, MemberProfile, Principal } from "@g1t/contracts";
10+import type { ChatMessage, ChatSidebarEntry, MemberProfile, Principal, WorkspaceAgent } from "@g1t/contracts";
1111
1212 /** Two messages from the same author this close together share one header. */
1313 export const GROUP_GAP_MS = 5 * 60 * 1000;
171171 type Members = Pick<MemberProfile, "kind">[];
172172
173173 /** Whether a direct message is with agents alone (one or several, nobody else). */
174−export function agentsOnlyDm(entry: Pick<ChatSidebarEntry, "channel" | "others">): boolean {
174+export function agentsOnlyDm(entry: { channel: { kind: "channel" | "dm" }; others: readonly Pick<MemberProfile, "kind">[] }): boolean {
175175 return entry.channel.kind === "dm" && entry.others.length > 0 && entry.others.every((member) => member.kind === "agent");
176176 }
177177
254254 };
255255 }
256256
257+/**
258+ * The archived (or deleted) agent a direct message is with, when the
259+ * conversation is with agents alone and every one of them is archived or
260+ * deleted for good (docs.g1t.sh/guides/chat/, "Archived and deleted
261+ * agents"): the row is dimmed and says Archived (or Deleted, when the
262+ * agent returned is `deleted`), and a conversation with one agent opens
263+ * read-only. Null for any other conversation, a mixed one included, since
264+ * a person is still there to talk to.
265+ */
266+export function archivedAgentsDm<M extends Pick<MemberProfile, "kind" | "archived_at" | "deleted">>(entry: { channel: { kind: "channel" | "dm" }; others: readonly M[] }): M | null {
267+ if (!agentsOnlyDm(entry)) return null;
268+ return entry.others.every((member) => !!member.archived_at || member.deleted === true) ? (entry.others[0] ?? null) : null;
269+}
270+
271+/**
272+ * The agents you have not talked to yet, for the sidebar's Agents section:
273+ * live ones (an archived agent is never offered, though its old conversation
274+ * stays listed), with no direct message yet, g1t first, then by name,
275+ * narrowed by what is typed in Jump to (handle, name or role).
276+ */
277+export function agentsToTalkTo<A extends Pick<WorkspaceAgent, "id" | "handle" | "display_name" | "role" | "builtin" | "archived_at">>(agents: readonly A[], agentDms: ReadonlyMap<string, unknown>, query = ""): A[] {
278+ const q = query.trim().toLowerCase().replace(/^@/, "");
279+ return agents
280+ .filter((agent) => !agent.archived_at && !agentDms.has(agent.id))
281+ .sort((a, b) => Number(!!b.builtin) - Number(!!a.builtin) || a.display_name.localeCompare(b.display_name))
282+ .filter((agent) => !q || agent.handle.includes(q) || agent.display_name.toLowerCase().includes(q) || agent.role.toLowerCase().includes(q));
283+}
284+
285+/**
286+ * What an archived agent's conversation says in place of its composer:
287+ * who archived it and when, as far as that is known.
288+ */
289+export function archivedAgentLine(agent: Pick<MemberProfile, "name" | "display_name" | "display_username" | "archived_at" | "archived_by">, now: Date = new Date()): string {
290+ return `${shownName(agent)} was ${archivedAgentWhen(agent, now).replace(/^A/, "a")}. It no longer answers here.`;
291+}
292+
293+/** "Archived 2 days ago by @ana": under an archived agent's name, where its title was. */
294+export function archivedAgentWhen(agent: Pick<MemberProfile, "archived_at" | "archived_by">, now: Date = new Date()): string {
295+ const when = agent.archived_at ? ` ${timeSince(agent.archived_at, now)}` : "";
296+ const by = agent.archived_by ? ` by @${agent.archived_by}` : "";
297+ return `Archived${when}${by}`;
298+}
299+
300+/** "3 days ago", "2 hours ago", "just now": for a line of prose. */
301+export function timeSince(at: string, now: Date = new Date()): string {
302+ const elapsed = Math.max(0, now.getTime() - new Date(at).getTime());
303+ const units: [string, number][] = [
304+ ["day", 86_400_000],
305+ ["hour", 3_600_000],
306+ ["minute", 60_000],
307+ ];
308+ for (const [unit, size] of units) {
309+ if (elapsed >= size) {
310+ const n = Math.floor(elapsed / size);
311+ return `${n} ${unit}${n === 1 ? "" : "s"} ago`;
312+ }
313+ }
314+ return "just now";
315+}
316+
257317 /** Where a conversation is, under the workspace. */
258318 export function channelPath(slug: string, channel: { id: string; kind: "channel" | "dm"; name: string | null }): string {
259319 return channel.kind === "dm" || !channel.name ? `/${slug}/-/chat/dm/${channel.id}` : `/${slug}/-/chat/${channel.name}`;
+43−8
1−import { AlertTriangle, CalendarClock, Coins, Hourglass, Lock, Wallet } from "lucide-react";
1+import { AlertTriangle, Archive, CalendarClock, Coins, Hourglass, Lock, Wallet } from "lucide-react";
22 import { Link, data, useFetcher } from "react-router";
33
4−import type { AgentPolicy, AgentsOverview, WorkspaceAgent } from "@g1t/contracts";
4+import type { AgentPolicy, AgentsOverview, ArchivedAgent, WorkspaceAgent } from "@g1t/contracts";
55
66 import type { Route } from "./+types/agents";
77 import { AgentFace, useAgentsData } from "../../components/agents-mode";
88 import { SwitchCard } from "../../components/ui/switch";
99 import { agentsAction, answer, readOrNull } from "../../components/agents/actions.server";
10+import { ArchivedSection } from "../../components/agents/archived";
1011 import { ApproveDialog, BudgetDialog } from "../../components/agents/dialogs";
12+import { Alert } from "../../components/ui/alert";
1113 import { meterTone, monthName, scheduleInWords, shareOf, untilLabel } from "../../components/agents/format";
1214 import { DailyBars, Meter, Panel, Quiet, SessionCard, SessionRow, SliceList, SpendOfCap } from "../../components/agents/parts";
1315 import { StatusDot, statusLabel } from "../../components/chat/marks";
2628 return page(args, { title: `Agents · ${params.owner} · g1t` });
2729 }
2830
29−/** Agents mode's front page: the budget, what is waiting and working, the roster, and where the month went. */
30−export async function loader({ params, context, request }: Route.LoaderArgs): Promise<{ overview: AgentsOverview | null }> {
31+/**
32+ * Agents mode's front page: the budget, what is waiting and working, the
33+ * roster, where the month went, and the archived agents, in the same round
34+ * of calls. `?archived=<handle>` is the way back from archiving one: the
35+ * page says where it went.
36+ */
37+export async function loader({ params, context, request }: Route.LoaderArgs): Promise<{ overview: AgentsOverview | null; archived: ArchivedAgent[]; justArchived: string | null }> {
3138 // Signed out, sign in first (as Chat and Docs do), rather than a 404.
3239 const viewer = requireUser(context, request);
3340 if (!viewer || !roleIn(viewer, params.owner)) throw data(null, { status: 404 });
34− return { overview: await readOrNull(workspaceAgents.overview(params.owner.toLowerCase(), viewer)) };
41+ const slug = params.owner.toLowerCase();
42+ const [overview, archived] = await Promise.all([readOrNull(workspaceAgents.overview(slug, viewer)), readOrNull(workspaceAgents.listArchived(slug, viewer))]);
43+ const justArchived = new URL(request.url).searchParams.get("archived");
44+ return { overview, archived: archived ?? [], justArchived: justArchived && /^[a-z0-9-]{1,40}$/.test(justArchived) ? justArchived : null };
3545 }
3646
37−/** Owners set the workspace's agent budget. */
47+/** Owners set the workspace's agent budget; archived agents are restored or deleted for good from here. */
3848 export async function action({ params, context, request }: Route.ActionArgs) {
3949 const { viewer, slug, form } = await agentsAction(request, context, params.owner);
50+ const intent = String(form.get("intent") ?? "");
4051 // Whether members create personal agents: on unless an owner turns it off.
41− if (form.get("intent") === "personal_agents") {
52+ if (intent === "personal_agents") {
4253 return answer("personal_agents", workspaceAgents.setPolicy(slug, viewer, { members_create_agents: form.get("members_create_agents") === "on" }));
4354 }
55+ // The Archived section (components/agents/archived.tsx): back to life, under its handle or a new one; or gone for good, with its handle typed back.
56+ if (intent === "restore") {
57+ const newHandle = String(form.get("new_handle") ?? "").trim();
58+ return answer("restore", workspaceAgents.restore(slug, String(form.get("handle") ?? ""), viewer, { id: String(form.get("id") ?? "") || null, handle: newHandle || null }));
59+ }
60+ if (intent === "destroy") {
61+ return answer("destroy", workspaceAgents.destroy(slug, String(form.get("handle") ?? ""), viewer, String(form.get("confirm") ?? ""), { id: String(form.get("id") ?? "") || null }));
62+ }
4463 if (form.get("intent") !== "policy") return { ok: false as const, error: "Unknown request." };
4564 const monthly = microsFromDollars(form.get("monthly"));
4665 const agent = microsFromDollars(form.get("default_agent"));
5271 }
5372
5473 export default function AgentsOverviewPage({ loaderData, params }: Route.ComponentProps) {
55− const { overview } = loaderData;
74+ const { overview, archived, justArchived } = loaderData;
5675 const slug = params.owner;
5776 useRefreshWhile(Boolean(overview && (overview.live.length > 0 || overview.waiting_on_you.length > 0)));
5877 if (!overview) {
6685 const handles = new Map(overview.agents.map((agent) => [agent.id, agent]));
6786 return (
6887 <div className="space-y-10">
88+ {/* The way back from archiving: the agent is under Archived, below, not gone. */}
89+ {justArchived && archived.some((agent) => agent.handle === justArchived) && (
90+ <Alert tone="info" role="status" className="flex items-center gap-2.5 px-3.5 py-2.5">
91+ <Archive size={15} className="shrink-0 text-muted" />
92+ <span>
93+ Archived <span className="font-medium">@{justArchived}</span>. It no longer answers; its conversations stay readable.{" "}
94+ <Link to={`/${slug}/-/agents#archived`} className="font-medium underline-offset-4 hover:underline">
95+ Restore or delete it under Archived
96+ </Link>
97+ .
98+ </span>
99+ </Alert>
100+ )}
69101 <BudgetHeader overview={overview} slug={slug} liveCount={liveCount} />
70102
71103 {overview.waiting_on_you.length > 0 && (
175207 )}
176208 </Panel>
177209 </div>
210+
211+ {/* Archived agents, folded: owners see the workspace's, a member their own archived personal agents. */}
212+ <ArchivedSection slug={slug} agents={archived} owner={overview.can_manage} />
178213 </div>
179214 );
180215 }
+2−1
107107 const archived = await workspaceAgents.archive(slug, params.handle.toLowerCase(), viewer).catch(() => null);
108108 if (!archived) return { errors: { form: "The agents service didn't answer. Try again in a moment." }, saved: false };
109109 if (!archived.ok) return { errors: { form: archived.error.message }, saved: false };
110− throw redirect(`/${slug}/-/agents`);
110+ // Back on the Agents page, which says where the agent went: Restore or delete it under Archived.
111+ throw redirect(`/${slug}/-/agents?archived=${encodeURIComponent(params.handle.toLowerCase())}`);
111112 }
112113 const read = readAgentForm(form, { orchestrator: params.handle.toLowerCase() === "g1t" });
113114 if (!read.ok) return { errors: read.errors, saved: false };
+18−0
5454 avatar_seed?: string | null;
5555 /** An agent's chosen face (./agent-look.ts); null for a person, or an agent wearing its seed's face. */
5656 look?: AgentLook | null;
57+ /**
58+ * An archived agent (docs.g1t.sh/guides/chat/, "Archived and deleted
59+ * agents"): when, and by whom. Its conversations stay readable; it is
60+ * never woken and never listed to talk to. Null or absent for a live
61+ * agent and for a person.
62+ */
63+ archived_at?: string | null;
64+ archived_by?: string | null;
65+ /** An agent deleted for good: its messages stay under the name it had. Absent otherwise. */
66+ deleted?: boolean;
5767 };
5868
5969 /**
485495 openDm(workspace: string, viewer: User, members: Principal[]): Promise<Result<Channel>>;
486496 join(workspace: string, channelId: string, viewer: User): Promise<Result<null>>;
487497 leave(workspace: string, channelId: string, viewer: User): Promise<Result<null>>;
498+ /**
499+ * Internal, from the agents service: an agent was deleted for good. It
500+ * leaves every channel of the workspace; its direct messages stay their
501+ * people's, readable, and its messages everywhere stay, shown under the
502+ * name it had (`former_agents`) and marked deleted.
503+ */
504+ agentDestroyed(input: { workspace_id: string; agent_id: string; handle: string; display_name: string; avatar_seed: string | null; look: AgentLook | null }): Promise<Result<null>>;
488505 /** Adds a person or an agent. An invite grants read, never write. */
489506 invite(workspace: string, channelId: string, viewer: User, member: Principal): Promise<Result<null>>;
490507 /**
654671 openDm: (workspace, viewer, members) => call("open_dm", { workspace, viewer, members }),
655672 join: (workspace, channelId, viewer) => call("join", { workspace, channel_id: channelId, viewer }),
656673 leave: (workspace, channelId, viewer) => call("leave", { workspace, channel_id: channelId, viewer }),
674+ agentDestroyed: (input) => call("agent_destroyed", input),
657675 invite: (workspace, channelId, viewer, member) =>
658676 call("invite", { workspace, channel_id: channelId, viewer, member }),
659677 messages: (workspace, channelId, viewer, page) =>
+2−0
986986 computerSleep: (agentId) => call("computer_sleep", { agent_id: agentId }),
987987 computerReset: (agentId) => call("computer_reset", { agent_id: agentId }),
988988 computerForget: (agentId) => call("computer_forget", { agent_id: agentId }),
989+ computerKeep: (agentId) => call("computer_keep", { agent_id: agentId }),
990+ computerErase: (agentId) => call("computer_erase", { agent_id: agentId }),
989991 computerCommands: (agentId, sessionId) => call("computer_commands", { agent_id: agentId, session_id: sessionId ?? null }),
990992 };
991993 }
+4−0
161161 computerReset(agentId: string): Promise<Result<AgentComputerStatus>>;
162162 /** An archived agent's: stopped now, its disk deleted after `COMPUTER_KEEP_DAYS`. */
163163 computerForget(agentId: string): Promise<Result<AgentComputerStatus>>;
164+ /** The agent was restored: its disk is kept after all (undoes `computerForget`). */
165+ computerKeep(agentId: string): Promise<Result<AgentComputerStatus>>;
166+ /** The agent was deleted for good: stopped, its saved home, commands and record deleted now. */
167+ computerErase(agentId: string): Promise<Result<null>>;
164168 /** The most recent commands, newest first; `session_id` narrows them to one session's. */
165169 computerCommands(agentId: string, sessionId?: string | null): Promise<Result<AgentComputerCommand[]>>;
166170 }
+47−0
175175 created_at: string;
176176 updated_at: string;
177177 archived_at: string | null;
178+ /** Who archived it, by username; null while it is live, or for one archived before this was kept. */
179+ archived_by?: string | null;
180+};
181+
182+/**
183+ * An archived agent as the Agents page's Archived section lists it
184+ * (docs.g1t.sh/guides/agents/, "Archive, restore and delete"): enough to
185+ * draw its row and to say what deleting it for good takes with it.
186+ */
187+export type ArchivedAgent = {
188+ id: string;
189+ handle: string;
190+ display_name: string;
191+ title: string;
192+ role: string;
193+ avatar: string | null;
194+ avatar_seed: string;
195+ look: AgentLook | null;
196+ scope: WorkspaceAgentScope;
197+ personal_owner: string | null;
198+ archived_at: string;
199+ archived_by: string | null;
200+ /** What goes with it when it is deleted. */
201+ counts: { sessions: number; replies: number; memories: number; routines: number };
178202 };
179203
180204 /**
865889 ): Promise<Result<WorkspaceAgent>>;
866890 archive(workspace: string, handle: string, viewer: User): Promise<Result<null>>;
867891 /**
892+ * The workspace's archived agents, newest archived first: every one for
893+ * an owner, their own archived personal agents for anyone else.
894+ */
895+ listArchived(workspace: string, viewer: User): Promise<Result<ArchivedAgent[]>>;
896+ /**
897+ * Brings an archived agent back, under its handle or `options.handle`.
898+ * `conflict` when a live agent holds the handle. Owners any agent, a
899+ * member their own personal one.
900+ */
901+ restore(workspace: string, handle: string, viewer: User, options?: { handle?: string | null; id?: string | null }): Promise<Result<WorkspaceAgent>>;
902+ /**
903+ * Deletes an archived agent for good: its definition and versions,
904+ * sessions, replies, memories, routines, drafts and computer, and its
905+ * chat memberships. Its messages stay in chat under its last name, and
906+ * its spend on the workspace's statement. `confirm` must be the handle.
907+ * A live agent is archived first (`invalid`). Owners any agent, a member
908+ * their own personal one; a second call is `not_found`.
909+ */
910+ destroy(workspace: string, handle: string, viewer: User, confirm: string, options?: { id?: string | null }): Promise<Result<null>>;
911+ /**
868912 * Drafts a whole agent from a description, with a fast model call charged
869913 * to the viewer. Nothing is saved. Whoever may create the agent may draft it.
870914 */
10351079 create: (workspace, viewer, input, options) => call("create", { workspace, viewer, input, teams: options?.teams ?? [] }),
10361080 update: (workspace, handle, viewer, changes) => call("update", { workspace, handle, viewer, changes }),
10371081 archive: (workspace, handle, viewer) => call("archive", { workspace, handle, viewer }),
1082+ listArchived: (workspace, viewer) => call("list_archived", { workspace, viewer }),
1083+ restore: (workspace, handle, viewer, options) => call("restore", { workspace, handle, viewer, id: options?.id ?? null, new_handle: options?.handle ?? null }),
1084+ destroy: (workspace, handle, viewer, confirm, options) => call("destroy", { workspace, handle, viewer, confirm, id: options?.id ?? null }),
10381085 draft: (workspace, viewer, input) => call("draft", { workspace, viewer, description: input.description, scope: input.scope ?? null }),
10391086 tryDraft: (workspace, viewer, input) => call("try_draft", { workspace, viewer, definition: input.definition, messages: input.messages }),
10401087 redraft: (workspace, handle, viewer, request) => call("redraft", { workspace, handle, viewer, request }),
+5−0
1+-- Who archived an agent (docs.g1t.sh/guides/agents/, "Archive, restore and
2+-- delete"), by username, so the Archived section and an archived agent's
3+-- direct message can say so without reading the audit log. NULL for an
4+-- agent archived before this column, and for a live one.
5+ALTER TABLE agents ADD COLUMN archived_by TEXT;
+182−0
1+import assert from "node:assert/strict";
2+import { readFileSync, readdirSync } from "node:fs";
3+import { DatabaseSync } from "node:sqlite";
4+import { test } from "node:test";
5+
6+import type { User } from "@g1t/contracts";
7+
8+import { DESTROYED, destroyRefusal, destroyStatements, listArchived, restoreHandle, restoreRefusal } from "./archived.ts";
9+
10+/** D1, as far as archived agents use it, over node's SQLite with the service's migrations: statements, `first`, `all`, `run` and `batch`. */
11+function fakeD1(): D1Database & { raw: DatabaseSync } {
12+ const db = new DatabaseSync(":memory:");
13+ const dir = new URL("../migrations/", import.meta.url);
14+ for (const file of readdirSync(dir).sort()) db.exec(readFileSync(new URL(file, dir), "utf8"));
15+ const statement = (sql: string, params: unknown[] = []) => ({
16+ bind: (...values: unknown[]) => statement(sql, values),
17+ first: async () => (db.prepare(sql).get(...(params as never[])) as unknown) ?? null,
18+ run: async () => {
19+ const r = db.prepare(sql).run(...(params as never[]));
20+ return { meta: { changes: Number(r.changes) } };
21+ },
22+ all: async () => ({ results: db.prepare(sql).all(...(params as never[])) }),
23+ });
24+ return { prepare: (sql: string) => statement(sql), batch: async (statements: { run: () => Promise<unknown> }[]) => Promise.all(statements.map((s) => s.run())), raw: db } as unknown as D1Database & { raw: DatabaseSync };
25+}
26+
27+const WS = "wsp_acme";
28+const owner = { id: "u_owner", kind: "user", username: "ana", verified: true, workspaces: [{ slug: "acme", role: "owner" }] } as unknown as User;
29+const member = { id: "u_mem", kind: "user", username: "mike", verified: true, workspaces: [{ slug: "acme", role: "member" }] } as unknown as User;
30+const other = { id: "u_other", kind: "user", username: "sam", verified: true, workspaces: [{ slug: "acme", role: "member" }] } as unknown as User;
31+
32+type Seed = { id: string; handle: string; archived_at?: string | null; archived_by?: string | null; scope?: string; owner_id?: string | null; owner_username?: string | null; builtin?: number };
33+
34+/** An agent row with what the queries read; everything else is the default. */
35+function seed(db: { raw: DatabaseSync }, a: Seed): void {
36+ db.raw
37+ .prepare(
38+ `INSERT INTO agents (id, workspace_id, handle, display_name, avatar, role, instructions, routing, budget, autonomy, title, created_by, created_at, updated_at, archived_at, archived_by, scope, owner_id, owner_username, builtin)
39+ VALUES (?, ?, ?, ?, NULL, 'Helps', 'Help.', '{}', '{}', '{}', 'Helper', 'ana', '2026-10-01T00:00:00Z', '2026-10-01T00:00:00Z', ?, ?, ?, ?, ?, ?)`,
40+ )
41+ .run(a.id, WS, a.handle, a.handle[0]!.toUpperCase() + a.handle.slice(1), a.archived_at ?? null, a.archived_by ?? null, a.scope ?? "workspace", a.owner_id ?? null, a.owner_username ?? null, a.builtin ?? 0);
42+}
43+
44+/** Rows about one agent across the tables a delete reaches, so the test sees them go. */
45+function seedHistory(db: { raw: DatabaseSync }, agentId: string, n: number): void {
46+ const r = db.raw;
47+ r.prepare("INSERT INTO agent_versions (agent_id, version, definition, changed_by, created_at) VALUES (?, 1, '{}', 'ana', '2026-10-01T00:00:00Z')").run(agentId);
48+ for (let i = 0; i < n; i++) {
49+ r.prepare(
50+ `INSERT INTO agent_sessions (id, workspace_id, agent_id, kind, root_id, payer_agent_id, title, goal, status, workspace, channel_id, channel_kind, created_at, updated_at)
51+ VALUES (?1, ?2, ?3, 'chat', ?1, ?3, 'Did a thing', 'Do the thing', 'done', 'acme', 'chn_1', 'dm', '2026-10-02T00:00:00Z', '2026-10-02T00:00:00Z')`,
52+ ).run(`ses_${agentId}_${i}`, WS, agentId);
53+ r.prepare("INSERT INTO agent_session_events (session_id, seq, kind, body, created_at) VALUES (?, 1, 'asked', 'do it', '2026-10-02T00:00:00Z')").run(`ses_${agentId}_${i}`);
54+ r.prepare(
55+ `INSERT INTO agent_replies (id, agent_id, workspace_id, channel_id, message_id, status, created_at)
56+ VALUES (?, ?, ?, 'chn_1', ?, 'done', '2026-10-02T00:00:00Z')`,
57+ ).run(`rep_${agentId}_${i}`, agentId, WS, `msg_${agentId}_${i}`);
58+ }
59+ r.prepare(
60+ `INSERT INTO agent_memories (id, agent_id, workspace_id, scope, scope_ref, body, source_kind, created_by, created_by_kind, created_at, updated_at)
61+ VALUES (?, ?, ?, 'workspace', '', 'Deploys are on Tuesdays', 'chat', 'ana', 'user', '2026-10-02T00:00:00Z', '2026-10-02T00:00:00Z')`,
62+ ).run(`mem_${agentId}`, agentId, WS);
63+ r.prepare(
64+ `INSERT INTO agent_routines (id, agent_id, workspace_id, workspace, name, instructions, channel_id, sponsor, enabled, created_at, updated_at)
65+ VALUES (?, ?, ?, 'acme', 'Standup', 'Post the standup', 'chn_1', 'u_owner', 1, '2026-10-02T00:00:00Z', '2026-10-02T00:00:00Z')`,
66+ ).run(`rtn_${agentId}`, agentId, WS);
67+ r.prepare("INSERT INTO agent_routine_runs (routine_id, run_key, created_at) VALUES (?, 'day:2026-10-03', '2026-10-03T00:00:00Z')").run(`rtn_${agentId}`);
68+ r.prepare("INSERT INTO agent_spend (agent_id, period, micros, replies) VALUES (?, '2026-10', 1200000, 3)").run(agentId);
69+ r.prepare("INSERT OR IGNORE INTO workspace_agent_spend (workspace_id, period, micros) VALUES (?, '2026-10', 1200000)").run(WS);
70+ r.prepare("INSERT INTO skill_attachments (id, workspace_id, skill_id, scope, target, version, attached_by, attached_at) VALUES (?, ?, 'skl_1', 'agent', ?, 1, 'ana', '2026-10-02T00:00:00Z')").run(`att_${agentId}`, WS, agentId);
71+}
72+
73+const count = (db: { raw: DatabaseSync }, table: string, where: string, ...params: unknown[]) => Number((db.raw.prepare(`SELECT COUNT(*) AS n FROM ${table} WHERE ${where}`).get(...(params as never[])) as { n: number }).n);
74+
75+test("the archived list has archived agents only, newest archived first, with what deleting each would take", async () => {
76+ const db = fakeD1();
77+ seed(db, { id: "agt_live", handle: "live" });
78+ seed(db, { id: "agt_old", handle: "old", archived_at: "2026-09-01T00:00:00Z", archived_by: "ana" });
79+ seed(db, { id: "agt_new", handle: "new", archived_at: "2026-10-05T00:00:00Z", archived_by: "mike" });
80+ seed(db, { id: "agt_g1t", handle: "g1t", builtin: 1 });
81+ seedHistory(db, "agt_new", 2);
82+ const listed = await listArchived(db, WS, { id: owner.id, owner: true });
83+ assert.deepEqual(
84+ listed.map((a) => [a.handle, a.archived_by, a.counts]),
85+ [
86+ ["new", "mike", { sessions: 2, replies: 2, memories: 1, routines: 1 }],
87+ ["old", "ana", { sessions: 0, replies: 0, memories: 0, routines: 0 }],
88+ ],
89+ );
90+ assert.equal(listed[0]!.display_name, "New");
91+ assert.equal(listed[0]!.avatar_seed, "new");
92+ assert.deepEqual(await listArchived(db, "wsp_other", { id: owner.id, owner: true }), []);
93+});
94+
95+test("a member sees only their own archived personal agents", async () => {
96+ const db = fakeD1();
97+ seed(db, { id: "agt_ws", handle: "ws", archived_at: "2026-10-01T00:00:00Z" });
98+ seed(db, { id: "agt_mine", handle: "mine", archived_at: "2026-10-02T00:00:00Z", scope: "personal", owner_id: member.id, owner_username: "mike" });
99+ seed(db, { id: "agt_theirs", handle: "theirs", archived_at: "2026-10-03T00:00:00Z", scope: "personal", owner_id: other.id, owner_username: "sam" });
100+ const mine = await listArchived(db, WS, { id: member.id, owner: false });
101+ assert.deepEqual(
102+ mine.map((a) => [a.handle, a.scope, a.personal_owner]),
103+ [["mine", "personal", "mike"]],
104+ );
105+ // Owners see every member's, and the workspace's.
106+ assert.deepEqual((await listArchived(db, WS, { id: owner.id, owner: true })).map((a) => a.handle), ["theirs", "mine", "ws"]);
107+});
108+
109+test("restoring: owners any agent, a member their own personal one, and only an archived one", () => {
110+ const archived = { handle: "sam", archived_at: "2026-10-01T00:00:00Z", scope: "workspace" };
111+ assert.equal(restoreRefusal(owner, "acme", archived), null);
112+ assert.equal(restoreRefusal(member, "acme", archived)?.code, "forbidden");
113+ const personal = { handle: "me", archived_at: "2026-10-01T00:00:00Z", scope: "personal", owner_id: member.id };
114+ assert.equal(restoreRefusal(member, "acme", personal), null);
115+ assert.equal(restoreRefusal(other, "acme", personal)?.code, "forbidden");
116+ assert.equal(restoreRefusal(owner, "acme", { ...archived, archived_at: null })?.code, "invalid");
117+});
118+
119+test("restoring under a handle a live agent holds is a conflict that says what to do; a new handle is checked", () => {
120+ const agent = { handle: "sam", archived_at: "2026-10-01T00:00:00Z" };
121+ const taken = (h: string) => h === "sam" || h === "ana";
122+ const conflict = restoreHandle(agent, null, taken);
123+ assert.equal(!conflict.ok && conflict.error.code, "conflict");
124+ assert.match(!conflict.ok ? conflict.error.message : "", /Rename that one first, or restore this one under a new handle/);
125+ const again = restoreHandle(agent, "ana", taken);
126+ assert.match(!again.ok ? again.error.message : "", /already called @ana. Pick another/);
127+ assert.deepEqual(restoreHandle(agent, "@Sam-2", taken), { ok: true, handle: "sam-2" });
128+ assert.deepEqual(restoreHandle(agent, "", () => false), { ok: true, handle: "sam" });
129+ const bad = restoreHandle(agent, "a", taken);
130+ assert.equal(!bad.ok && bad.error.code, "invalid");
131+});
132+
133+test("deleting refuses a live agent, the built-in @g1t, the wrong person and a confirmation that is not the handle", () => {
134+ const archived = { handle: "sam", archived_at: "2026-10-01T00:00:00Z", scope: "workspace" };
135+ assert.equal(destroyRefusal(owner, "acme", archived, "sam"), null);
136+ assert.equal(destroyRefusal(owner, "acme", archived, "@Sam "), null);
137+ assert.match(destroyRefusal(owner, "acme", { ...archived, archived_at: null }, "sam")?.message ?? "", /Archive @sam first/);
138+ assert.equal(destroyRefusal(owner, "acme", { ...archived, archived_at: null }, "sam")?.code, "invalid");
139+ assert.match(destroyRefusal(owner, "acme", { ...archived, handle: "g1t", builtin: 1 }, "g1t")?.message ?? "", /can't be deleted/);
140+ assert.equal(destroyRefusal(member, "acme", archived, "sam")?.code, "forbidden");
141+ assert.equal(destroyRefusal(owner, "acme", archived, "same")?.code, "invalid");
142+ assert.equal(destroyRefusal(owner, "acme", archived, undefined)?.code, "invalid");
143+ const personal = { handle: "me", archived_at: "2026-10-01T00:00:00Z", scope: "personal", owner_id: member.id };
144+ assert.equal(destroyRefusal(member, "acme", personal, "me"), null);
145+ assert.equal(destroyRefusal(other, "acme", personal, "me")?.code, "forbidden");
146+});
147+
148+test("deleting removes every row about the agent, and nothing of another's or of the workspace's totals", async () => {
149+ const db = fakeD1();
150+ seed(db, { id: "agt_gone", handle: "gone", archived_at: "2026-10-01T00:00:00Z" });
151+ seed(db, { id: "agt_kept", handle: "kept" });
152+ seedHistory(db, "agt_gone", 3);
153+ seedHistory(db, "agt_kept", 1);
154+ const results = (await db.batch(destroyStatements(db, "agt_gone"))) as { meta: { changes: number } }[];
155+ assert.equal(results.length, DESTROYED.length);
156+ assert.equal(results[results.length - 1]!.meta.changes, 1, "the agents row went last");
157+ for (const { table } of DESTROYED) {
158+ const column = table === "agents" ? "id" : table === "skill_attachments" ? "target" : table === "agent_session_events" ? "session_id" : table === "agent_routine_runs" ? "routine_id" : "agent_id";
159+ const pattern = table === "agent_session_events" ? "ses_agt_gone_%" : table === "agent_routine_runs" ? "rtn_agt_gone" : "agt_gone";
160+ assert.equal(count(db, table, `${column} LIKE ?`, pattern), 0, `${table} still has rows about the deleted agent`);
161+ }
162+ // The other agent is untouched, and the workspace's month total stays for the statement.
163+ assert.equal(count(db, "agents", "id = ?", "agt_kept"), 1);
164+ assert.equal(count(db, "agent_sessions", "agent_id = ?", "agt_kept"), 1);
165+ assert.equal(count(db, "agent_session_events", "session_id LIKE ?", "ses_agt_kept_%"), 1);
166+ assert.equal(count(db, "agent_replies", "agent_id = ?", "agt_kept"), 1);
167+ assert.equal(count(db, "agent_memories", "agent_id = ?", "agt_kept"), 1);
168+ assert.equal(count(db, "agent_routine_runs", "routine_id = ?", "rtn_agt_kept"), 1);
169+ assert.equal(count(db, "skill_attachments", "target = ?", "agt_kept"), 1);
170+ assert.equal(count(db, "workspace_agent_spend", "workspace_id = ?", WS), 1);
171+ // A second delete finds nothing: the agents row is gone.
172+ const again = (await db.batch(destroyStatements(db, "agt_gone"))) as { meta: { changes: number } }[];
173+ assert.equal(again[again.length - 1]!.meta.changes, 0);
174+});
175+
176+test("deleting leaves a live agent's row alone even when asked by id", async () => {
177+ const db = fakeD1();
178+ seed(db, { id: "agt_live", handle: "live" });
179+ const results = (await db.batch(destroyStatements(db, "agt_live"))) as { meta: { changes: number } }[];
180+ assert.equal(results[results.length - 1]!.meta.changes, 0);
181+ assert.equal(count(db, "agents", "id = ?", "agt_live"), 1);
182+});
+160−0
1+/**
2+ * Archived agents (docs.g1t.sh/guides/agents/, "Archive, restore and
3+ * delete"): the Archived section's list, bringing one back, and deleting
4+ * one for good. Archiving itself is `archive` in index.ts.
5+ *
6+ * An archived agent is out of every list, every lookup by handle and every
7+ * conversation's wake, but its row and everything it did stay. Restoring
8+ * clears `archived_at`, under its old handle or a new one if a live agent
9+ * took the old. Deleting removes every row the service keeps about it, in
10+ * one batch (`destroyStatements`), and index.ts then tells the runner and
11+ * chat. What stays: its messages in chat (under the name it had), the
12+ * workspace's month totals (`workspace_agent_spend`), and the audit log.
13+ *
14+ * The decisions are pure and tested on their own (archived.test.ts); the
15+ * reads and the batch take the database.
16+ */
17+import type { ArchivedAgent, User } from "@g1t/contracts";
18+
19+// Relative, with extensions, so Node runs the tests on this file as it is.
20+import { readLook } from "../../../packages/contracts/src/agent-look.ts";
21+import type { Failure, FailureCode } from "../../../packages/contracts/src/result.ts";
22+import { MANAGE_REFUSAL, type Owned, canArchive, canManage, isOwnerOf } from "./access.ts";
23+import { checkHandle } from "./handle.ts";
24+
25+const refusal = (code: FailureCode, message: string): Failure => ({ code, message });
26+
27+type Viewer = Pick<User, "kind" | "username" | "workspaces" | "verified"> & { id?: string };
28+
29+/** What the decisions read of an agent's row. */
30+export type ArchivedLike = Owned & { handle: string; archived_at: string | null };
31+
32+/** The refusal for restoring `agent`, or null when `viewer` may. */
33+export function restoreRefusal(viewer: Viewer | null | undefined, workspace: string, agent: ArchivedLike): Failure | null {
34+ if (!agent.archived_at) return refusal("invalid", `@${agent.handle} isn't archived.`);
35+ if (!canArchive(viewer, workspace, agent)) return refusal("forbidden", MANAGE_REFUSAL);
36+ return null;
37+}
38+
39+/**
40+ * The refusal for deleting `agent` for good, or null when `viewer` may:
41+ * owners any agent, a member their own personal one; never the built-in
42+ * @g1t; only an archived agent, and only with its handle typed back.
43+ */
44+export function destroyRefusal(viewer: Viewer | null | undefined, workspace: string, agent: ArchivedLike, confirm: unknown): Failure | null {
45+ if (agent.builtin) return refusal("invalid", "@g1t is every workspace's orchestrator and can't be deleted.");
46+ if (!canManage(viewer, workspace) && !isOwnerOf(viewer, agent)) return refusal("forbidden", "Only the workspace's owners delete an agent, and a member their own personal agent.");
47+ if (!agent.archived_at) return refusal("invalid", `Archive @${agent.handle} first. Deleting is for archived agents, so nothing goes by accident.`);
48+ if (typeof confirm !== "string" || confirm.trim().replace(/^@/, "").toLowerCase() !== agent.handle) {
49+ return refusal("invalid", `Type @${agent.handle} to confirm.`);
50+ }
51+ return null;
52+}
53+
54+/**
55+ * The handle an agent is restored under: its own, or `given` when the
56+ * person chose a new one. `conflict` when the handle is taken by a live
57+ * agent (`taken` says so), with what to do about it.
58+ */
59+export function restoreHandle(agent: ArchivedLike, given: unknown, taken: (handle: string) => boolean): { ok: true; handle: string } | { ok: false; error: Failure } {
60+ let handle = agent.handle;
61+ if (given !== undefined && given !== null && given !== "") {
62+ const checked = checkHandle(given);
63+ if (!checked.ok) return { ok: false, error: refusal("invalid", checked.message) };
64+ handle = checked.handle;
65+ }
66+ if (taken(handle)) {
67+ return {
68+ ok: false,
69+ error: refusal(
70+ "conflict",
71+ handle === agent.handle
72+ ? `A live agent is called @${handle} now. Rename that one first, or restore this one under a new handle.`
73+ : `A live agent is already called @${handle}. Pick another handle.`,
74+ ),
75+ };
76+ }
77+ return { ok: true, handle };
78+}
79+
80+/** The table and the column each delete runs on, so the docs and the tests say the same. */
81+export const DESTROYED: readonly { table: string; where: string }[] = [
82+ { table: "agent_session_events", where: "session_id IN (SELECT id FROM agent_sessions WHERE agent_id = ?)" },
83+ { table: "agent_sessions", where: "agent_id = ?" },
84+ { table: "agent_routine_runs", where: "routine_id IN (SELECT id FROM agent_routines WHERE agent_id = ?)" },
85+ { table: "agent_routines", where: "agent_id = ?" },
86+ { table: "agent_replies", where: "agent_id = ?" },
87+ { table: "agent_tool_calls", where: "agent_id = ?" },
88+ { table: "agent_memories", where: "agent_id = ?" },
89+ { table: "agent_drafts", where: "agent_id = ?" },
90+ { table: "agent_recommendations", where: "agent_id = ?" },
91+ { table: "agent_ability_requests", where: "agent_id = ?" },
92+ { table: "skill_attachments", where: "scope = 'agent' AND target = ?" },
93+ { table: "agent_spend", where: "agent_id = ?" },
94+ { table: "agent_versions", where: "agent_id = ?" },
95+ // Last, and only while still archived: a restore that raced the delete keeps the agent and all of the above is already gone for it.
96+ { table: "agents", where: "id = ? AND archived_at IS NOT NULL" },
97+];
98+
99+/** Every row the service keeps about one agent, deleted in one batch. The `agents` row goes last. */
100+export function destroyStatements(db: D1Database, agentId: string): D1PreparedStatement[] {
101+ return DESTROYED.map(({ table, where }) => db.prepare(`DELETE FROM ${table} WHERE ${where}`).bind(agentId));
102+}
103+
104+type ArchivedRow = {
105+ id: string;
106+ handle: string;
107+ display_name: string;
108+ title: string | null;
109+ role: string;
110+ avatar: string | null;
111+ avatar_seed: string | null;
112+ look: string | null;
113+ scope: string | null;
114+ owner_id: string | null;
115+ owner_username: string | null;
116+ archived_at: string;
117+ archived_by: string | null;
118+ sessions: number;
119+ replies: number;
120+ memories: number;
121+ routines: number;
122+};
123+
124+/**
125+ * The workspace's archived agents, newest archived first: every one for an
126+ * owner, the viewer's own archived personal agents for anyone else. Each
127+ * with what deleting it would take: its sessions, replies, memories and
128+ * routines, counted in the same read.
129+ */
130+export async function listArchived(db: D1Database, workspaceId: string, viewer: { id: string; owner: boolean }): Promise<ArchivedAgent[]> {
131+ const rows = await db
132+ .prepare(
133+ `SELECT a.id, a.handle, a.display_name, a.title, a.role, a.avatar, a.avatar_seed, a.look, a.scope, a.owner_id, a.owner_username, a.archived_at, a.archived_by,
134+ (SELECT COUNT(*) FROM agent_sessions s WHERE s.agent_id = a.id) AS sessions,
135+ (SELECT COUNT(*) FROM agent_replies r WHERE r.agent_id = a.id) AS replies,
136+ (SELECT COUNT(*) FROM agent_memories m WHERE m.agent_id = a.id) AS memories,
137+ (SELECT COUNT(*) FROM agent_routines t WHERE t.agent_id = a.id) AS routines
138+ FROM agents a
139+ WHERE a.workspace_id = ?1 AND a.archived_at IS NOT NULL AND a.builtin = 0
140+ AND (?2 = 1 OR (a.scope = 'personal' AND a.owner_id = ?3))
141+ ORDER BY a.archived_at DESC, a.handle`,
142+ )
143+ .bind(workspaceId, viewer.owner ? 1 : 0, viewer.id)
144+ .all<ArchivedRow>();
145+ return rows.results.map((row) => ({
146+ id: row.id,
147+ handle: row.handle,
148+ display_name: row.display_name,
149+ title: row.title ?? "",
150+ role: row.role,
151+ avatar: row.avatar,
152+ avatar_seed: row.avatar_seed || row.handle,
153+ look: readLook(row.look),
154+ scope: row.scope === "personal" ? "personal" : "workspace",
155+ personal_owner: row.scope === "personal" ? (row.owner_username ?? null) : null,
156+ archived_at: row.archived_at,
157+ archived_by: row.archived_by ?? null,
158+ counts: { sessions: row.sessions ?? 0, replies: row.replies ?? 0, memories: row.memories ?? 0, routines: row.routines ?? 0 },
159+ }));
160+}
+16−0
145145 .computerForget(agentId)
146146 .catch((error: unknown) => console.error("agents: an archived agent's computer was not told", agentId, String(error)));
147147 }
148+
149+/** A restored agent's computer: its disk is kept after all (undoes `forgetComputer`). Never fails the restore. */
150+export async function keepComputer(env: ComputerEnv, agentId: string): Promise<void> {
151+ if (!env.RUNNER) return;
152+ await runnerComputerClient(env.RUNNER)
153+ .computerKeep(agentId)
154+ .catch((error: unknown) => console.error("agents: a restored agent's computer was not told", agentId, String(error)));
155+}
156+
157+/** A deleted agent's computer: stopped, and its saved home, commands and record deleted now. Never fails the delete. */
158+export async function eraseComputer(env: ComputerEnv, agentId: string): Promise<void> {
159+ if (!env.RUNNER) return;
160+ await runnerComputerClient(env.RUNNER)
161+ .computerErase(agentId)
162+ .catch((error: unknown) => console.error("agents: a deleted agent's computer was not erased", agentId, String(error)));
163+}
+139−3
2323 type InstallRequests,
2424 type McpServer,
2525 type AgentEffortCosts,
26+ type ArchivedAgent,
2627 type AgentRecommendation,
2728 type AgentRecommendations,
2829 type NewWorkspaceAgent,
6667 import { moveAgentTeams } from "./team-move.ts";
6768 import { effortOf } from "./routing.ts";
6869 import * as views from "./views.ts";
69−import { computerCommands, computerView, forgetComputer, resetComputer, sleepComputer, wakeComputer } from "./computer.ts";
70+import { computerCommands, computerView, eraseComputer, forgetComputer, keepComputer, resetComputer, sleepComputer, wakeComputer } from "./computer.ts";
71+import { destroyRefusal, destroyStatements, listArchived, restoreHandle, restoreRefusal } from "./archived.ts";
7072 import { monthKey } from "./budget.ts";
7173 import * as extensions from "./extensions.ts";
7274 import { skillPushes, skillRpc } from "./skill-rpc.ts";
525527 try {
526528 const [archived] = await this.db.batch([
527529 // Archived first, from the version read, so the handle is free for the new one in the same step.
528− this.db.prepare("UPDATE agents SET archived_at = ?, updated_at = ? WHERE id = ? AND version = ? AND archived_at IS NULL").bind(now, now, row.id, row.version),
530+ this.db.prepare("UPDATE agents SET archived_at = ?, archived_by = ?, updated_at = ? WHERE id = ? AND version = ? AND archived_at IS NULL").bind(now, by, now, row.id, row.version),
529531 insertAgent(this.db, id, managed.value, definition, { version, created_by: row.created_by, created_at: now, updated_at: now }),
530532 ...versions.results.map((v) =>
531533 this.db.prepare("INSERT INTO agent_versions (agent_id, version, definition, changed_by, created_at) VALUES (?, ?, ?, ?, ?)").bind(id, v.version, v.definition, v.changed_by, v.created_at),
826828 if (!canArchive(a.viewer, a.workspace, row)) return fail("forbidden", MANAGE_REFUSAL);
827829 if (row.builtin) return fail("invalid", "@g1t is every workspace's orchestrator and can't be archived. You can set its budget to limit it.");
828830 const now = new Date().toISOString();
829− await this.db.prepare("UPDATE agents SET archived_at = ?, updated_at = ? WHERE id = ? AND archived_at IS NULL").bind(now, now, row.id).run();
831+ await this.db.prepare("UPDATE agents SET archived_at = ?, archived_by = ?, updated_at = ? WHERE id = ? AND archived_at IS NULL").bind(now, a.viewer!.username, now, row.id).run();
830832 this.audit(a.viewer!, a.workspace, "archive_agent", row.handle, `Archived @${row.handle}`);
831833 // Its computer goes to sleep now; its disk is kept 30 days, then deleted (computer.ts).
832834 this.defer(forgetComputer(this.env, row.id));
833835 return ok(null);
834836 }
835837
838+ // ── Archived agents (./archived.ts) ──────────────────────────────────────
839+
840+ /** The archived agents the viewer may see: every one for an owner, their own archived personal agents for anyone else. */
841+ async listArchived(a: { workspace: string; viewer: User | null }): Promise<Result<ArchivedAgent[]>> {
842+ if (a?.viewer && awaitsConfirmation(a.viewer)) return UNVERIFIED;
843+ const seen = await this.seen(a?.workspace ?? "", a?.viewer ?? null);
844+ if (!seen.ok) return seen;
845+ const viewer = a.viewer!;
846+ return ok(await listArchived(this.db, seen.value, { id: (viewer.kind ?? "user") === "user" ? viewer.id : "", owner: canManage(viewer, a.workspace) }));
847+ }
848+
849+ /**
850+ * An archived agent the viewer may see, by id, else the one most recently
851+ * archived under `handle` (a handle is unique only among live agents).
852+ */
853+ private async archivedRow(workspace: string, viewer: User | null, a: { id?: unknown; handle?: unknown }): Promise<Result<{ workspaceId: string; row: Row }>> {
854+ if (awaitsConfirmation(viewer)) return UNVERIFIED;
855+ const seen = await this.seen(workspace, viewer);
856+ if (!seen.ok) return seen;
857+ const now = new Date();
858+ const handle = typeof a.handle === "string" ? a.handle.trim().replace(/^@/, "").toLowerCase() : "";
859+ const row =
860+ typeof a.id === "string" && a.id
861+ ? await this.db.prepare(selectAgents("a.workspace_id = ?3 AND a.id = ?4 AND a.archived_at IS NOT NULL")).bind(...periods(now), seen.value, a.id).first<Row>()
862+ : handle
863+ ? await this.db
864+ .prepare(`${selectAgents("a.workspace_id = ?3 AND a.handle = ?4 AND a.archived_at IS NOT NULL")} ORDER BY a.archived_at DESC LIMIT 1`)
865+ .bind(...periods(now), seen.value, handle)
866+ .first<Row>()
867+ : null;
868+ if (!row || !canSeeAgent(viewer, workspace, row)) return fail("not_found", `There is no archived agent called @${handle || String(a.id ?? "")}.`);
869+ return ok({ workspaceId: seen.value, row });
870+ }
871+
872+ /**
873+ * Brings an archived agent back, live again under its handle, or under
874+ * `new_handle` when a live agent took the old one (`conflict` says so).
875+ * Owners any agent, a member their own personal one. A new handle is a
876+ * new version, as any rename is. Its computer's disk is kept after all.
877+ */
878+ async restore(a: { workspace: string; handle?: string; id?: string; viewer: User | null; new_handle?: unknown }): Promise<Result<WorkspaceAgent>> {
879+ const found = await this.archivedRow(a?.workspace ?? "", a?.viewer ?? null, a ?? {});
880+ if (!found.ok) return found;
881+ const { workspaceId, row } = found.value;
882+ const refused = restoreRefusal(a.viewer, a.workspace, row);
883+ if (refused) return { ok: false, error: refused };
884+ const live = await this.db
885+ .prepare("SELECT handle FROM agents WHERE workspace_id = ? AND archived_at IS NULL")
886+ .bind(workspaceId)
887+ .all<{ handle: string }>();
888+ const taken = new Set(live.results.map((r) => r.handle));
889+ const handle = restoreHandle(row, a.new_handle, (h) => taken.has(h));
890+ if (!handle.ok) return handle;
891+ const now = new Date().toISOString();
892+ const renamed = handle.handle !== row.handle;
893+ const version = renamed ? row.version + 1 : row.version;
894+ try {
895+ const [restored] = await this.db.batch([
896+ this.db
897+ .prepare("UPDATE agents SET archived_at = NULL, archived_by = NULL, handle = ?, version = ?, updated_at = ? WHERE id = ? AND archived_at IS NOT NULL")
898+ .bind(handle.handle, version, now, row.id),
899+ ...(renamed ? [this.versionStatement(row.id, version, { ...definitionOf(row), handle: handle.handle }, a.viewer!.username, now)] : []),
900+ ]);
901+ if (!restored.meta.changes) return fail("not_found", `@${row.handle} was restored or deleted meanwhile.`);
902+ } catch (error) {
903+ if (String(error).includes("UNIQUE")) return fail("conflict", `A live agent is called @${handle.handle} now. Rename that one first, or restore this one under a new handle.`);
904+ throw error;
905+ }
906+ const was = renamed ? ` (was @${row.handle})` : "";
907+ this.audit(a.viewer!, a.workspace, "restore_agent", handle.handle, `Restored @${handle.handle}${was}`, "agents", isPersonal(row) ? "member" : "owner");
908+ this.defer(keepComputer(this.env, row.id));
909+ const saved = await this.row(workspaceId, handle.handle);
910+ return ok(toAgent(saved!, new Date()));
911+ }
912+
913+ /**
914+ * Deletes an archived agent for good (archived.ts `DESTROYED`): every row
915+ * the service keeps about it, in one batch; then its computer on the
916+ * runner, its chat memberships (its messages stay under the name it
917+ * had) and its team memberships. Owners any agent, a member their own
918+ * personal one; `confirm` is its handle typed back. A live agent is
919+ * archived first. A second call finds nothing.
920+ */
921+ async destroy(a: { workspace: string; handle?: string; id?: string; viewer: User | null; confirm?: unknown }): Promise<Result<null>> {
922+ const found = await this.archivedRow(a?.workspace ?? "", a?.viewer ?? null, a ?? {});
923+ if (!found.ok) {
924+ // A live agent of that handle: say what to do, rather than "no such archived agent".
925+ if (found.error.code === "not_found" && typeof a?.handle === "string") {
926+ const live = await this.visible(a.workspace, a.viewer, a.handle);
927+ if (live.ok) {
928+ const refused = destroyRefusal(a.viewer, a.workspace, live.value.row, a.confirm);
929+ return { ok: false, error: refused ?? found.error };
930+ }
931+ }
932+ return found;
933+ }
934+ const { workspaceId, row } = found.value;
935+ const refused = destroyRefusal(a.viewer, a.workspace, row, a.confirm);
936+ if (refused) return { ok: false, error: refused };
937+ const results = await this.db.batch(destroyStatements(this.db, row.id));
938+ if (!results[results.length - 1]!.meta.changes) return fail("not_found", `@${row.handle} was restored or deleted meanwhile.`);
939+ this.audit(a.viewer!, a.workspace, "delete_agent", row.handle, `Deleted the archived agent @${row.handle} for good`, "agents", isPersonal(row) ? "member" : "owner");
940+ const slug = a.workspace.toLowerCase();
941+ const definition = definitionOf(row);
942+ this.defer(eraseComputer(this.env, row.id));
943+ this.defer(
944+ chatClient(this.env.CHAT)
945+ .agentDestroyed({ workspace_id: workspaceId, agent_id: row.id, handle: row.handle, display_name: row.display_name, avatar_seed: definition.avatar_seed, look: definition.look })
946+ .then((told) => (told.ok ? null : console.error("agents: chat was not told of a deleted agent", row.id, told.error.message)))
947+ .catch((error: unknown) => console.error("agents: chat was not told of a deleted agent", row.id, String(error))),
948+ );
949+ this.defer(this.leaveTeams(slug, a.viewer!, row.id));
950+ return ok(null);
951+ }
952+
953+ /** A deleted agent comes off every team it was on (memberships live in identity). */
954+ private async leaveTeams(slug: string, by: User, agentId: string): Promise<void> {
955+ try {
956+ const identity = identityClient(this.env.IDENTITY);
957+ const teams = await identity.agentTeams(slug, agentId);
958+ await Promise.all(
959+ teams.map((team) => identity.removeTeamAgent(by, slug, team.slug, agentId).catch((error: unknown) => console.error("agents: a deleted agent was not taken off its team", agentId, team.slug, String(error)))),
960+ );
961+ } catch (error) {
962+ console.error("agents: a deleted agent's teams were not read", agentId, String(error));
963+ }
964+ }
965+
836966 /**
837967 * Makes the workspace's built-in @g1t if it does not exist yet: an
838968 * ordinary agent row, marked builtin, at version 1. Safe to call on
9491079 return Response.json(await service.update(args));
9501080 case "archive":
9511081 return Response.json(await service.archive(args));
1082+ case "list_archived":
1083+ return Response.json(await service.listArchived(args));
1084+ case "restore":
1085+ return Response.json(await service.restore(args));
1086+ case "destroy":
1087+ return Response.json(await service.destroy(args));
9521088 case "draft":
9531089 return Response.json(await service.draft(args));
9541090 case "try_draft":
+3−0
5555 created_at: string;
5656 updated_at: string;
5757 archived_at: string | null;
58+ /** Who archived it, by username; missing before 0013. */
59+ archived_by?: string | null;
5860 /** Joined from agent_spend: this month and today. */
5961 spent_month?: number | null;
6062 spent_day?: number | null;
133135 created_at: row.created_at,
134136 updated_at: row.updated_at,
135137 archived_at: row.archived_at,
138+ archived_by: row.archived_by ?? null,
136139 };
137140 }
138141
+2−1
558558 by_agent: slices(byAgent.results, (k) => {
559559 if (k === BUILDER_ID) return BUILDER_LABEL;
560560 const face = agents.get(k);
561− return face ? `${face.display_name} (@${face.handle})` : "An archived agent";
561+ // Archived agents are still in `agents`; one without a face was deleted for good (its spend stays in the month's total).
562+ return face ? `${face.display_name} (@${face.handle})` : "A deleted agent";
562563 }),
563564 by_team: [...byTeamMap.values()].sort((a, b) => b.micros - a.micros),
564565 by_channel: channels,
+16−0
1+-- Agents deleted for good (docs.g1t.sh/guides/chat/, "Archived and deleted
2+-- agents"): the agents service forgets everything about one, but its
3+-- messages stay in chat, so chat keeps the name and face it had to show
4+-- them under. Written when the agents service says an agent is gone
5+-- (`agent_destroyed`), read when a message's author is an agent the
6+-- agents service no longer knows.
7+CREATE TABLE former_agents (
8+ agent_id TEXT PRIMARY KEY,
9+ workspace_id TEXT NOT NULL,
10+ handle TEXT NOT NULL,
11+ display_name TEXT NOT NULL,
12+ avatar_seed TEXT,
13+ -- Its chosen face (JSON, @g1t/contracts agent-look.ts), or NULL for the seed's.
14+ look TEXT,
15+ deleted_at TEXT NOT NULL
16+);
+21−1
11 import { test } from "node:test";
22 import assert from "node:assert/strict";
33
4−import { MAX_HOPS, type RecentMessage, addressedAgents, chainFor, deliveries, delivery, handOffPlace, handOffRefusal, personalAgentRefusal } from "./delivery.ts";
4+import { MAX_HOPS, type RecentMessage, addressedAgents, chainFor, deliveries, delivery, handOffPlace, handOffRefusal, personalAgentRefusal, wakeable } from "./delivery.ts";
55
66 const ship = { id: "a1", handle: "ship" };
77 const docs = { id: "a2", handle: "docs" };
139139 assert.equal(personalAgentRefusal({ id: "a1", handle: "ship", scope: "workspace" }, { kind: "channel", members: null }), null);
140140 assert.equal(personalAgentRefusal({ id: "a1", handle: "ship" }, { kind: "hand_off", members: null }), null, "agents from before personal ones are the workspace's");
141141 });
142+
143+test("an archived agent stays a member but is never woken; one the agents service no longer knows, or of another workspace, neither", () => {
144+ const live = { id: "a1", handle: "ship", workspace_id: "wsp_1", archived_at: null };
145+ const archived = { id: "a2", handle: "docs", workspace_id: "wsp_1", archived_at: "2026-10-09T10:00:00Z" };
146+ const elsewhere = { id: "a3", handle: "ops", workspace_id: "wsp_2", archived_at: null };
147+ const found = new Map<string, typeof live | null>([
148+ ["a1", live],
149+ ["a2", archived],
150+ ["a3", elsewhere],
151+ // Deleted for good: chat still has its membership row, the agents service has nothing.
152+ ["a4", null],
153+ ]);
154+ assert.deepEqual(wakeable(found.values(), "wsp_1"), [live]);
155+ // In a direct message with the two of them, an unaddressed message goes to the live one alone.
156+ const agents = wakeable(found.values(), "wsp_1").map((a) => ({ id: a.id, handle: a.handle }));
157+ assert.deepEqual(deliveries({ author: "user:u", hops: 0, channelKind: "dm", agents, mentioned: [] }), [{ agent_id: "a1", hops: 0 }]);
158+ // Mentioning the archived one by handle wakes nobody: it is not among the wakeable.
159+ assert.deepEqual(deliveries({ author: "user:u", hops: 0, channelKind: "channel", agents, mentioned: ["docs"] }), []);
160+ assert.deepEqual(wakeable([], "wsp_1"), []);
161+});
+17−0
131131 return agents;
132132 }
133133
134+/** An agent as the agents service hands it to chat, as far as waking reads it. */
135+export type KnownAgent = { id: string; handle: string; workspace_id: string; archived_at: string | null };
136+
137+/**
138+ * The agents of a channel that may be woken at all: members the agents
139+ * service knows, of this workspace, and not archived
140+ * (docs.g1t.sh/guides/chat/, "Archived and deleted agents"). An archived
141+ * agent stays a member of its conversations, so they can be read, but it
142+ * never answers, types or is addressed; one deleted for good is unknown
143+ * to the agents service and is left out the same way.
144+ */
145+export function wakeable<A extends KnownAgent>(found: Iterable<A | null | undefined>, workspaceId: string): A[] {
146+ const out: A[] = [];
147+ for (const agent of found) if (agent && agent.workspace_id === workspaceId && !agent.archived_at) out.push(agent);
148+ return out;
149+}
150+
134151 export function deliveries(input: {
135152 /** Who wrote it: `user:<id>` or `agent:<id>`. */
136153 author: string;
+78−9
2323 openD1,
2424 parsePrincipalKey,
2525 principalKey,
26+ readLook,
2627 workspaceAgentsClient,
2728 type AgentDelivery,
2829 type AgentFoundMessage,
7778 handOffPlace,
7879 handOffRefusal,
7980 personalAgentRefusal,
81+ wakeable,
8082 type Chain,
8183 type RecentMessage,
8284 type Wake,
183185 deleted_at: string | null;
184186 };
185187
188+/** An agent deleted for good, as `former_agents` keeps it (migration 0004). */
189+type FormerAgentRow = { agent_id: string; workspace_id: string; handle: string; display_name: string; avatar_seed: string | null; look: string | null; deleted_at: string };
190+
186191 /** What a method found out about the channel it was asked about. */
187192 type Place = { slug: string; workspace: Workspace; channel: ChannelRow; member: MemberRow | null };
188193
239244 private readonly people = new Map<string, Promise<Map<string, Member>>>();
240245 private readonly usernames = new Map<string, string>();
241246 private readonly agents = new Map<string, WorkspaceAgent | null>();
247+ /** Agents deleted for good, by id, from `former_agents`; null for one chat never heard of. */
248+ private readonly former = new Map<string, FormerAgentRow | null>();
242249 private readonly settingsRows = new Map<string, Promise<SettingsRow | null>>();
243250
244251 /** `defer` runs work after the answer is sent: the request's waitUntil. */
308315 return agent && agent.workspace_id === workspace.id && !agent.archived_at ? agent : null;
309316 }
310317
311− /** How each member key shows, for one workspace. */
318+ /**
319+ * Agents deleted for good, by id, as `former_agents` remembers them: the
320+ * name and face their messages still show under. Asked only for agents
321+ * the agents service no longer knows, and once per request.
322+ */
323+ private async formerAgents(ids: string[]): Promise<Map<string, FormerAgentRow>> {
324+ const wanted = [...new Set(ids)].filter((id) => !this.former.has(id));
325+ if (wanted.length) {
326+ const rows = await this.db
327+ .prepare(`SELECT * FROM former_agents WHERE agent_id IN (${wanted.map(() => "?").join(", ")})`)
328+ .bind(...wanted)
329+ .all<FormerAgentRow>()
330+ .catch((error: unknown) => {
331+ console.error("chat could not read former agents", error);
332+ return { results: [] as FormerAgentRow[] };
333+ });
334+ for (const id of wanted) this.former.set(id, rows.results.find((row) => row.agent_id === id) ?? null);
335+ }
336+ const out = new Map<string, FormerAgentRow>();
337+ for (const id of ids) {
338+ const row = this.former.get(id);
339+ if (row) out.set(id, row);
340+ }
341+ return out;
342+ }
343+
344+ /**
345+ * How each member key shows, for one workspace. An archived agent's
346+ * profile says so (`archived_at`, `archived_by`); one deleted for good
347+ * shows under the name it had, marked `deleted`.
348+ */
312349 private async profiles(slug: string, workspace: Workspace, keys: string[]): Promise<Map<string, MemberProfile>> {
313350 const principals = [...new Set(keys)].map((key) => parsePrincipalKey(key)).filter((p): p is Principal => !!p);
314351 const userIds = principals.filter((p) => p.kind === "user").map((p) => p.id);
320357 this.agentsById(agentIds),
321358 ]);
322359 for (const [id, username] of Object.entries(named)) this.usernames.set(id, username);
360+ const gone = agentIds.filter((id) => !agents.get(id));
361+ const former = gone.length ? await this.formerAgents(gone) : new Map<string, FormerAgentRow>();
323362 const out = new Map<string, MemberProfile>();
324363 for (const p of principals) {
325364 if (p.kind === "user") {
339378 });
340379 } else {
341380 const agent = agents.get(p.id) ?? null;
381+ const was = agent ? null : (former.get(p.id) ?? null);
342382 out.set(principalKey(p), {
343383 ...p,
344− name: agent?.handle ?? p.id,
345− display_name: agent?.display_name ?? "Former agent",
384+ name: agent?.handle ?? was?.handle ?? p.id,
385+ // Never blank: a deleted agent's messages show under the name it had, else as a deleted agent.
386+ display_name: agent?.display_name ?? was?.display_name ?? "A deleted agent",
346387 avatar: agent?.avatar ?? null,
347388 role: agent?.role ?? null,
348389 title: agent?.title || null,
349− avatar_seed: agent?.avatar_seed ?? null,
350− look: agent?.look ?? null,
390+ avatar_seed: agent?.avatar_seed ?? was?.avatar_seed ?? null,
391+ look: agent?.look ?? (was ? readLook(was.look) : null),
392+ archived_at: agent?.archived_at ?? null,
393+ archived_by: agent?.archived_by ?? null,
394+ ...(agent ? {} : { deleted: true }),
351395 });
352396 }
353397 }
848892 return ok(null);
849893 }
850894
895+ /**
896+ * Internal, from the agents service: an agent was deleted for good. It
897+ * comes off every channel of its workspace, and its name and face are
898+ * kept (`former_agents`) so its messages, which stay, show under them.
899+ * Its direct messages keep their membership rows: a conversation is its
900+ * people's, stays in their lists and opens to read, with the agent shown
901+ * as deleted (`profiles`); it is never woken, since the agents service no
902+ * longer knows it (delivery.ts `wakeable`).
903+ */
904+ async agentDestroyed(a: { workspace_id: string; agent_id: string; handle: string; display_name: string; avatar_seed?: string | null; look?: unknown }): Promise<Result<null>> {
905+ if (typeof a?.workspace_id !== "string" || !a.workspace_id || typeof a?.agent_id !== "string" || !a.agent_id) return fail("invalid", "Name the workspace and the agent by id.");
906+ const handle = typeof a.handle === "string" && a.handle ? a.handle : a.agent_id;
907+ const key = principalKey({ kind: "agent", id: a.agent_id });
908+ await this.db.batch([
909+ this.db
910+ .prepare("INSERT OR REPLACE INTO former_agents (agent_id, workspace_id, handle, display_name, avatar_seed, look, deleted_at) VALUES (?, ?, ?, ?, ?, ?, ?)")
911+ .bind(a.agent_id, a.workspace_id, handle, typeof a.display_name === "string" && a.display_name.trim() ? a.display_name : handle, typeof a.avatar_seed === "string" ? a.avatar_seed : null, a.look ? JSON.stringify(a.look) : null, now()),
912+ this.db.prepare("DELETE FROM channel_members WHERE principal = ? AND channel_id IN (SELECT id FROM channels WHERE workspace_id = ? AND kind = 'channel')").bind(key, a.workspace_id),
913+ ]);
914+ this.agents.delete(a.agent_id);
915+ this.former.delete(a.agent_id);
916+ return ok(null);
917+ }
918+
851919 async invite(a: { workspace: string; channel_id: string; viewer: Viewer; member: Principal }): Promise<Result<null>> {
852920 const found = await this.place(a.workspace, a.channel_id, a.viewer, "member");
853921 if (!found.ok) return found;
12041272 .all<{ principal: string }>();
12051273 const ids = members.results.map((m) => m.principal.slice("agent:".length));
12061274 let found = await this.agentsById(ids);
1207− const orchestratorIsMember = [...found.values()].some((agent) => !!agent?.builtin && agent.workspace_id === workspace.id && !agent.archived_at);
1275+ const orchestratorIsMember = wakeable(found.values(), workspace.id).some((agent) => !!agent.builtin);
12081276 if (addsOrchestrator({ channelKind: channel.kind, mentioned: handles, orchestratorIsMember })) {
12091277 // Mentioning @g1t brings it in: every workspace has it, nobody invites it.
12101278 const builtin = await workspaceAgentsClient(this.env.AGENTS)
12211289 }
12221290 }
12231291 if (!ids.length) return;
1224− const agents = [...found.values()].filter(
1225− (agent): agent is WorkspaceAgent => !!agent && agent.workspace_id === workspace.id && !agent.archived_at,
1226− );
1292+ // Archived agents stay members, so their conversations can be read, but are never woken (delivery.ts `wakeable`).
1293+ const agents = wakeable(found.values(), workspace.id);
12271294 const wakes = deliveries({
12281295 author: row.author,
12291296 hops: chain.hops,
20912158 return Response.json(await service.join(args));
20922159 case "leave":
20932160 return Response.json(await service.leave(args));
2161+ case "agent_destroyed":
2162+ return Response.json(await service.agentDestroyed(args));
20942163 case "invite":
20952164 return Response.json(await service.invite(args));
20962165 case "messages":
+19−0
587587 async purge(): Promise<void> {
588588 const kept = await this.ctx.storage.get<Kept>("computer");
589589 if (!kept?.deleteAfter || Date.parse(kept.deleteAfter) > Date.now()) return;
590+ await this.erase(kept);
591+ }
592+
593+ /** The agent was restored: its home is kept after all. The purge alarm finds nothing to do. */
594+ async keep(agentId: string): Promise<Result<AgentComputerStatus>> {
595+ const kept = await this.kept(agentId);
596+ kept.deleteAfter = null;
597+ await this.save(kept);
598+ return ok(statusOf(kept, this.attached));
599+ }
600+
601+ /** The agent was deleted for good: stopped if awake, its saved home and everything kept here gone now. */
602+ async eraseNow(agentId: string): Promise<Result<null>> {
603+ const kept = await this.ctx.storage.get<Kept>("computer");
604+ await this.erase(kept ?? { agentId, state: "asleep" });
605+ return ok(null);
606+ }
607+
608+ private async erase(kept: Pick<Kept, "agentId" | "state">): Promise<void> {
590609 if (kept.state !== "asleep") await this.destroy().catch(() => undefined);
591610 if (this.env.HOMES) await this.env.HOMES.delete(homeKey(kept.agentId)).catch(() => undefined);
592611 await this.ctx.storage.deleteAll();
+6−1
12651265 // Workspace agents' own computers (computer.ts), for the agents service:
12661266 // `computer_status`, `computer_wake`, `computer_exec`, `computer_read_file`,
12671267 // `computer_write_file`, `computer_sleep`, `computer_reset`,
1268− // `computer_forget` and `computer_commands`, each naming its `agent_id`.
1268+ // `computer_forget`, `computer_keep`, `computer_erase` and
1269+ // `computer_commands`, each naming its `agent_id`.
12691270 if (request.method === "POST" && pathname.startsWith("/rpc/computer_")) {
12701271 return Response.json(await this.computer(pathname.slice("/rpc/".length), (await request.json()) as Record<string, unknown>));
12711272 }
12971298 return computer.reset(agentId);
12981299 case "computer_forget":
12991300 return computer.forget(agentId);
1301+ case "computer_keep":
1302+ return computer.keep(agentId);
1303+ case "computer_erase":
1304+ return computer.eraseNow(agentId);
13001305 default:
13011306 return fail("not_found", `There is no method called ${method}.`);
13021307 }