diff --git a/app/src/components/conversation/resume-picker.tsx b/app/src/components/conversation/resume-picker.tsx
new file mode 100644
index 00000000..af8b08ad
--- /dev/null
+++ b/app/src/components/conversation/resume-picker.tsx
@@ -0,0 +1,104 @@
+import { HistoryIcon } from "lucide-react";
+import { useEffect, useState } from "react";
+
+import { AgentIcon } from "@/components/agent-glyph";
+import { Command, CommandCollection, CommandDialog, CommandDialogPopup, CommandEmpty, CommandFooter, CommandGroup, CommandGroupLabel, CommandInput, CommandItem, CommandList, CommandPanel } from "@/components/ui/command";
+import { Kbd } from "@/components/ui/kbd";
+import { Spinner } from "@/components/ui/spinner";
+import { toastManager } from "@/components/ui/toast";
+import { boxApi, type PastSession } from "@/lib/api";
+import { plainError } from "@/lib/errors";
+import { ago, errorMessage } from "@/lib/format";
+import { scheduleRefresh, useStore } from "@/lib/store";
+
+// A structured chat's agent has no screen for its own /resume picker, so
+// the chat draws one: the agent's past conversations in this folder (those
+// had in its terminal too, for Claude Code), newest first. Picking one
+// carries it on in this pane; the conversation it leaves stays in the
+// worktree's "Resume a conversation".
+
+interface Item {
+ value: string;
+ past: PastSession;
+ label: string;
+}
+
+export function ResumePicker({ box, session, who, query: initial, onClose }: { box: string; session: string; who: string; query: string; onClose(): void }) {
+ const client = useStore((s) => s.client);
+ const [list, setList] = useState<{ agent: string; items: Item[]; note?: string }>();
+ const [error, setError] = useState
();
+ const [query, setQuery] = useState(initial);
+ useEffect(() => {
+ if (!client) return;
+ let alive = true;
+ boxApi.pastSessions(client, box, session).then(
+ (r) => {
+ if (!alive) return;
+ const items = r.sessions.map((p) => ({ value: p.id, past: p, label: p.title || `Untitled ${p.id.slice(0, 8)}` }));
+ setList({ agent: r.agent, items, note: r.note });
+ },
+ (err) => alive && setError(plainError(err)),
+ );
+ return () => {
+ alive = false;
+ };
+ }, [client, box, session]);
+
+ const pick = async (p: PastSession) => {
+ onClose();
+ if (!client || p.current) return;
+ try {
+ await boxApi.resumeSession(client, box, session, p.id);
+ scheduleRefresh(box, ["sessions"]);
+ } catch (err) {
+ toastManager.add({ type: "error", title: "Couldn't resume it", description: errorMessage(err) });
+ }
+ };
+
+ const items = list?.items ?? [];
+ const groups = [{ value: "past", items }];
+ const empty = error ?? list?.note ?? (list ? `${who} has no past conversations in this folder.` : undefined);
+ return (
+ !o && onClose()}>
+
+ `${(i as Item).label} ${(i as Item).past.id}`}>
+
+
+ {!list && !error ? (
+
+ Reading {who}'s conversations…
+
+ ) : (
+ {items.length ? "No conversation matches." : empty}
+ )}
+
+ {(group: (typeof groups)[number]) => (
+
+ {group.items.length > 0 && In this folder, newest first}
+
+ {(item: Item) => (
+ void pick(item.past)}>
+
+ {item.label}
+ {item.past.current && this chat}
+ {item.past.session ? `running in ${item.past.session}` : item.past.updated ? ago(item.past.updated) : ""}
+
+ )}
+
+
+ )}
+
+
+
+
+ ↵ carry it on here
+
+
+ This chat's conversation stays resumable esc
+
+
+
+
+
+ );
+}
diff --git a/app/src/lib/api.ts b/app/src/lib/api.ts
index 52729899..601169e1 100644
--- a/app/src/lib/api.ts
+++ b/app/src/lib/api.ts
@@ -19,6 +19,7 @@ import type {
WaitResult,
WorktreeService,
Location,
+ PastSessions,
PluginInfo,
Service,
Session,
@@ -280,8 +281,13 @@ export const boxApi = {
// Carries a conversation on: in its session, started again if it ended,
// or in a new one when that is gone.
resumeConversation: (c: Client, box: string, id: string) => c.box(box, "POST", `conversations/${encodeURIComponent(id)}/resume`),
- // Starts a structured session whose agent ended again, on its own record.
- resumeSession: (c: Client, box: string, name: string) => c.box(box, "POST", `sessions/${encodeURIComponent(name)}/resume`),
+ // Starts a structured session whose agent ended again, on its own record;
+ // given one of its agent's past conversations (pastSessions), carries
+ // that one on in the session instead.
+ resumeSession: (c: Client, box: string, name: string, agentSessionId?: string) =>
+ c.box(box, "POST", `sessions/${encodeURIComponent(name)}/resume`, agentSessionId ? { agent_session_id: agentSessionId } : undefined),
+ // The agent's own past conversations in a structured session's folder.
+ pastSessions: (c: Client, box: string, name: string) => c.box(box, "GET", `sessions/${encodeURIComponent(name)}/past`),
// Takes a structured session to Claude Code's own terminal, or back to its
// chat (Session.switch).
switchSession: (c: Client, box: string, name: string, to: "terminal" | "chat") =>
diff --git a/app/src/lib/commands.ts b/app/src/lib/commands.ts
index 1337e756..aade1c22 100644
--- a/app/src/lib/commands.ts
+++ b/app/src/lib/commands.ts
@@ -24,6 +24,8 @@ export interface AgentCommand {
local?: boolean;
// Opens the agent's own picker or dialog.
screen?: boolean;
+ // Answered by the app itself, never sent: a structured chat's /resume.
+ app?: boolean;
source?: string;
}
@@ -55,7 +57,8 @@ export function useCommandCatalog(box: string | undefined, session: string | und
useEffect(() => {
if (!box || !session) return;
if (mock) {
- setCat(MOCK_CATALOG);
+ const structured = !!useStore.getState().boxes[box]?.sessions?.find((s) => s.name === session)?.driver;
+ setCat(structured ? withResumePicker(MOCK_CATALOG) : MOCK_CATALOG);
return;
}
if (!client || !supported) {
@@ -111,6 +114,17 @@ export function commandIn(cat: CommandCatalog | undefined, text: string): AgentC
return cat.commands.find((c) => c.name === name || c.aliases?.includes(name));
}
+// canPickPast says whether a box lists a structured session's past
+// conversations (berthd new enough).
+export const canPickPast = (box: string) => !!useStore.getState().boxes[box]?.info?.capabilities?.includes("sessions.past");
+
+// withResumePicker is a structured chat's catalogue, as the box gives it
+// (internal/box/commands.go): its /resume is the app's picker.
+function withResumePicker(cat: CommandCatalog): CommandCatalog {
+ const resume: AgentCommand = { name: "/resume", description: "Pick a past conversation and carry it on here", kind: "builtin", args: "[conversation]", local: true, app: true, aliases: ["/continue"] };
+ return { ...cat, commands: [resume, ...cat.commands.filter((c) => c.name !== "/resume")] };
+}
+
export const KIND_LABEL: Record = { builtin: "Built-in", custom: "Custom", skill: "Skill", plugin: "Plugin", mcp: "MCP" };
// The demo's agent, with a few of each kind.
diff --git a/app/src/lib/mock-conversation.ts b/app/src/lib/mock-conversation.ts
index 9421be72..4e981391 100644
--- a/app/src/lib/mock-conversation.ts
+++ b/app/src/lib/mock-conversation.ts
@@ -287,6 +287,13 @@ export function seedTranscript(box: string, session: string, state: string, work
if (state === "finished") items.push({ kind: "text", id: id(), text: pages ? `All green. ${pages.length === 1 ? "The page above has the coverage" : "The pages above have the numbers and screenshots"}; the branch is ready for review.` : "All green. The branch is ready for review." });
// The search-perf agent then made artifacts here (components/art).
if (box === ART_BOX && session === ART_SESSION) items.push(...artChat());
+ // ?structured: search-perf's Claude then loaded a skill; the chat shows
+ // the step, not the skill's instructions.
+ if (session === "search-perf-claude" && new URLSearchParams(location.search).has("structured")) {
+ items.push({ kind: "user", id: id(), text: "Check the change against our perf budget." });
+ items.push({ kind: "tools", id: id(), verb: "Skill", done: true, items: [{ verb: "Skill", target: "acme-perf-budget", id: "mock-skill-1" }] });
+ items.push({ kind: "text", id: id(), text: "Within budget: /search p95 is 410 ms against 500 ms." });
+ }
// One finished session shows a notice card (chat-controls).
const notice = mockNotice(session);
if (notice) items.push(notice);
diff --git a/app/src/lib/mock.ts b/app/src/lib/mock.ts
index 2a181a8f..795c2291 100644
--- a/app/src/lib/mock.ts
+++ b/app/src/lib/mock.ts
@@ -222,6 +222,13 @@ const stats: Record = {
// ?mock=1&crowd=1: many more agents, on more boxes (lib/mock-crowd.ts).
if (new URLSearchParams(location.search).has("crowd")) crowd({ status, locations, sessions, stats });
+// ?structured: search-perf's Claude runs structured (Labs), its chat's
+// /resume the app's own picker of Claude's past conversations there.
+const structuredDemo = new URLSearchParams(location.search).has("structured");
+if (structuredDemo) {
+ const s = sessions.devl?.find((x) => x.name === "search-perf-claude");
+ if (s) s.driver = "claude";
+}
// ?mock=1&bench=…: far more than the fixtures, for measuring (lib/mock-bench.ts).
if (BENCH === "fleet") benchFleet({ status, locations, sessions, stats });
if (BENCH === "chat") seedBenchChats();
@@ -754,7 +761,7 @@ function boxCall(box: string, method: string, path: string, body?: unknown): Pro
home: HOME,
// gpu runs an older berthd (mockBuilds): it can't keep worktree names,
// and its agent browser has no tabs.
- capabilities: ["diff", "turns", "queue", "ask", "answer", "journal", "runs", "exec.detach", "browser", "browser.devtools", "titles", "sample", "service.terminal", "session.home", "agents.install", "artifacts", "processes", ...(box === "gpu" ? [] : ["worktree.titles", "agents.paths", "browser.tabs"])],
+ capabilities: ["diff", "turns", "queue", "ask", "answer", "journal", "runs", "exec.detach", "browser", "browser.devtools", "titles", "sample", "service.terminal", "session.home", "agents.install", "artifacts", "processes", ...(box === "gpu" ? [] : ["worktree.titles", "agents.paths", "browser.tabs"]), ...(structuredDemo ? ["driver.claude", "conversations", "sessions.past"] : [])],
// Claude Code from npm under nvm, as the person's shell finds it;
// Codex from Shipyard's own installer.
agent_paths: [
@@ -869,6 +876,24 @@ function boxCall(box: string, method: string, path: string, body?: unknown): Pro
setTimeout(() => emit({ type: "worktree.renamed", box, data: { location: loc, name: wt, path: w.path } }), 30);
return delay({ ...w });
}
+ // A structured chat's /resume: Claude's past conversations in its
+ // folder, and carrying one on in the session.
+ const past = /^sessions\/([^/]+)\/past$/.exec(path);
+ if (method === "GET" && past) {
+ const s = sessions[box]?.find((x) => x.name === decodeURIComponent(past[1]));
+ if (!s?.driver) return Promise.reject(new ApiError(`${s?.name ?? "it"} runs in its terminal: its agent's own /resume works there`, 409));
+ return delay({ agent: "claude", sessions: mockPast(s) });
+ }
+ const carry = /^sessions\/([^/]+)\/resume$/.exec(path);
+ if (method === "POST" && carry && (body as { agent_session_id?: string } | undefined)?.agent_session_id) {
+ const s = sessions[box]?.find((x) => x.name === decodeURIComponent(carry[1]));
+ const p = s && mockPast(s).find((x) => x.id === (body as { agent_session_id: string }).agent_session_id);
+ if (!s || !p) return Promise.reject(new ApiError("Claude has no such conversation in this folder", 404));
+ s.title = p.title;
+ mockCarried.set(s.name, p.id);
+ setTimeout(() => emit({ type: "session.renamed", box, data: { name: s.name } }), 30);
+ return delay({ ...s });
+ }
// Renaming a session: its title, or "" to clear it.
if (method === "PATCH" && /^sessions\/[^/]+$/.test(path)) {
const s = sessions[box]?.find((x) => x.name === decodeURIComponent(path.slice("sessions/".length)));
@@ -1630,3 +1655,20 @@ export function mockNotifications() {
// ?notify=1 plays them on start.
if (!fresh && new URLSearchParams(location.search).has("notify")) setTimeout(mockNotifications, 2500);
+
+// mockPast is what Claude has had in a structured session's folder, newest
+// first: the chat's own conversation, ones had in its terminal, and one
+// running in another session.
+const mockCarried = new Map();
+function mockPast(s: Session): { id: string; title: string; updated: string; current?: boolean; session?: string }[] {
+ const minsAgo = (m: number) => new Date(Date.now() - m * 60_000).toISOString();
+ const list = [
+ { id: "6f1c2a9e-4b7d-4e21-9a35-0c8d5e7f1a20", title: "Speed up product search", updated: minsAgo(23) },
+ { id: "b2d84f10-93ae-4c55-8e1b-7a6c0f3d2e91", title: "Cache facet counts in Redis", updated: minsAgo(140), session: "search-perf-claude-2" },
+ { id: "0e9a7c35-1f2b-4d68-b4c0-5e3a9d8f7b16", title: "Find why search is slow on mobile", updated: minsAgo(60 * 26) },
+ { id: "c7f05b2d-8e41-4a9c-9d3e-2b6f1a0c4e58", title: "Add a search-results skeleton", updated: minsAgo(60 * 24 * 3) },
+ { id: "4a6e9d13-2c5f-4b07-a8e2-9f1d3c7b5a64", title: "Try Postgres trigram search", updated: minsAgo(60 * 24 * 9) },
+ ];
+ const current = mockCarried.get(s.name) ?? list[0].id;
+ return list.map((p) => ({ ...p, current: p.id === current || undefined }));
+}
diff --git a/app/src/lib/resume-words.test.ts b/app/src/lib/resume-words.test.ts
new file mode 100644
index 00000000..c3088337
--- /dev/null
+++ b/app/src/lib/resume-words.test.ts
@@ -0,0 +1,20 @@
+// node --experimental-strip-types --test src/lib/resume-words.test.ts (pnpm test)
+import assert from "node:assert/strict";
+import { test } from "node:test";
+
+import { resumeWords } from "./resume-words.ts";
+
+test("/resume and /continue open the picker, filtered by what follows", () => {
+ assert.equal(resumeWords("/resume"), "");
+ assert.equal(resumeWords(" /resume "), "");
+ assert.equal(resumeWords("/continue"), "");
+ assert.equal(resumeWords("/resume release plan"), "release plan");
+ assert.equal(resumeWords("/resume 3f2a9c1e-0b4d-4e8a-9f1c-2d7e5b6a8c90"), "3f2a9c1e-0b4d-4e8a-9f1c-2d7e5b6a8c90");
+});
+
+test("anything else goes to the agent", () => {
+ assert.equal(resumeWords("/resumes"), undefined);
+ assert.equal(resumeWords("/review"), undefined);
+ assert.equal(resumeWords("please /resume the work"), undefined);
+ assert.equal(resumeWords("resume"), undefined);
+});
diff --git a/app/src/lib/resume-words.ts b/app/src/lib/resume-words.ts
new file mode 100644
index 00000000..d878595c
--- /dev/null
+++ b/app/src/lib/resume-words.ts
@@ -0,0 +1,7 @@
+// resumeWords are what a structured chat answers with its picker: /resume
+// (or /continue) and what follows it, which the list opens filtered by.
+// Anything else is undefined, and is sent to the agent.
+export function resumeWords(text: string): string | undefined {
+ const m = text.trim().match(/^\/(?:resume|continue)(?:\s+([\s\S]*))?$/);
+ return m ? (m[1] ?? "").trim() : undefined;
+}
diff --git a/app/src/lib/transcript.ts b/app/src/lib/transcript.ts
index ec96a91d..87e535d0 100644
--- a/app/src/lib/transcript.ts
+++ b/app/src/lib/transcript.ts
@@ -160,6 +160,7 @@ export function toolSummary(t: Extract): stri
if (t.verb === "Read") return `Read ${n} file${n === 1 ? "" : "s"}`;
if (t.verb === "Search") return `Searched ${n} time${n === 1 ? "" : "s"}`;
if (t.verb === "Run") return `Ran ${n} command${n === 1 ? "" : "s"}`;
+ if (t.verb === "Skill") return n === 1 && t.items?.[0] ? `Used the ${t.items[0].target} skill` : `Used ${n} skills`;
return `${t.verb} ${n}`;
}
diff --git a/docs/changelog.mdx b/docs/changelog.mdx
index c440be01..f3b5d152 100644
--- a/docs/changelog.mdx
+++ b/docs/changelog.mdx
@@ -10,12 +10,21 @@ upgrades with `berth upgrade BOX`, or by running the
again.
## Unreleased
-
+- **`/resume` works in a structured agent's chat.** It opens a list of the
+ agent's past conversations in that folder, newest first, including the ones
+ you had in Claude Code's own terminal. Pick one and it carries on in the
+ same tab, with what was said before shown in the chat. The conversation you
+ leave stays under *Resume a conversation*. ACP agents that can list their
+ sessions (OpenCode, for one) get the same list; Codex's isn't there yet.
+- **A skill Claude loads shows as one step.** In a structured chat, the
+ skill's whole text used to show up as a message from you. Now the chat
+ says "Used the berth-orchestrate skill" among the agent's steps.
- **New terminals no longer open with a systemd warning.** On boxes with
systemd 258 or later, every new tab started with *D-Bus property
CPUAccounting is deprecated*. Sessions still report their CPU use as
before. Needs the box upgraded.
+
## v0.3.21
- **The phone app pairs from a pasted link.** Its Pair and Boxes screens have
diff --git a/docs/guides/labs.mdx b/docs/guides/labs.mdx
index 8be54294..2c7a5a3e 100644
--- a/docs/guides/labs.mdx
+++ b/docs/guides/labs.mdx
@@ -255,7 +255,7 @@ own record of the conversation. The box drives it from there:
- **Claude Code's own terminal** is one click away: the pane's Terminal
switch stops the structured agent and opens the same conversation in
Claude Code's screen (`claude --resume`), for what only it does
- (`/login`, `/resume`, `/memory`, `/permissions`, its menus). Conversation
+ (`/login`, `/memory`, `/permissions`, its menus). Conversation
takes it back to the chat, with what you said in the terminal in its place.
Either way needs the agent to be idle. In a folder Claude Code hasn't
seen, its screen first asks whether you trust it.
@@ -265,6 +265,15 @@ own record of the conversation. The box drives it from there:
aren't running under *Resume a conversation*: each carries on, with
Claude Code's `--resume`, in its own record, so the chat shows its earlier
turns.
+- **`/resume`** in the chat opens Shipyard's own list of the agent's past
+ conversations in the folder, newest first: Claude Code's, including those
+ had in its terminal, or what an ACP agent lists (ACP's `session/list`).
+ Picking one carries it on in the same tab, its earlier turns in the chat;
+ the conversation it leaves stays under *Resume a conversation*. One running
+ in another tab is shown but can't be picked. `/resume words` opens the list
+ filtered by them. Codex's threads aren't listed yet: use its terminal.
+- **Skills** the agent loads show as a step ("Used the … skill"), not as the
+ skill's text in a message of yours.
- **Codex** runs as `codex app-server` under the same host: the box opens its
thread, sends each prompt as a turn with the agent's mode, model and effort,
diff --git a/docs/reference/app-api.mdx b/docs/reference/app-api.mdx
index e76df5a9..e50720bf 100644
--- a/docs/reference/app-api.mdx
+++ b/docs/reference/app-api.mdx
@@ -249,7 +249,14 @@ agent_session_id?, mode?, title?, created, updated? }`); `POST
conversations/{id}/resume` carries one on, in its session (started again if
it ended) or a new one, answering the `Session`; `POST
sessions/{name}/resume` starts an ended structured session again on its own
-record. Boxes that take them list `conversations`. `exited` is
+record. Boxes that take them list `conversations`. `GET
+sessions/{name}/past` lists the agent's own past conversations in a
+structured session's folder (`{ agent, sessions: [{ id, title?, updated?,
+current?, session? }], note? }`: `current` is the one it runs, `session`
+another live session running it, `note` why there are none); `POST
+sessions/{name}/resume` with `{ agent_session_id }` carries one on in the
+session, ended or not, in a conversation of its own. Boxes that take them
+list `sessions.past`. `exited` is
true once the program has ended. `title` names the work: the first line of
the prompt it started with, or of the first prompt it was sent or typed, cut
to about 48 characters; `PATCH sessions/{name}` with `{ title }` renames it
diff --git a/internal/box/acpdriver.go b/internal/box/acpdriver.go
index e862423a..c8b32fc7 100644
--- a/internal/box/acpdriver.go
+++ b/internal/box/acpdriver.go
@@ -109,6 +109,7 @@ type acpConv struct {
modeConfig string // its mode's config option id, when it has one
canResume bool
canLoad bool
+ canList bool
authMethod string
bypass bool
busy bool
@@ -367,6 +368,7 @@ func (c *acpConv) apply(e agenthost.Entry, live bool) {
LoadSession bool `json:"loadSession"`
SessionCapabilities struct {
Resume json.RawMessage `json:"resume"`
+ List json.RawMessage `json:"list"`
} `json:"sessionCapabilities"`
} `json:"agentCapabilities"`
AuthMethods []struct {
@@ -375,7 +377,8 @@ func (c *acpConv) apply(e agenthost.Entry, live bool) {
}
if json.Unmarshal(m.Result, &r) == nil {
c.canLoad = r.AgentCapabilities.LoadSession
- c.canResume = len(r.AgentCapabilities.SessionCapabilities.Resume) > 0 && string(r.AgentCapabilities.SessionCapabilities.Resume) != "null"
+ c.canResume = offered(r.AgentCapabilities.SessionCapabilities.Resume)
+ c.canList = offered(r.AgentCapabilities.SessionCapabilities.List)
if len(r.AuthMethods) > 0 {
c.authMethod = r.AuthMethods[0].ID
}
@@ -676,8 +679,10 @@ func (c *acpConv) handshake() {
c.mu.Unlock()
opened := false
if sid != "" && (canResume || canLoad) {
+ // Loaded, it replays the conversation: what a log that has none
+ // of it yet (one picked from its past sessions) shows.
method := "session/load"
- if canResume {
+ if canResume && (!canLoad || acpLogHasTurns(c.log)) {
method = "session/resume"
}
params := map[string]any{"sessionId": sid}
@@ -708,6 +713,27 @@ func (c *acpConv) handshake() {
}
}
+// offered says whether a capability is there: an object, not absent or null.
+func offered(v json.RawMessage) bool {
+ return len(v) > 0 && string(v) != "null"
+}
+
+// acpLogHasTurns says whether an ACP conversation's log has a prompt or a
+// replay in it already.
+func acpLogHasTurns(path string) bool {
+ found := errors.New("found")
+ err := agenthost.ReadLog(path, 0, func(e agenthost.Entry) error {
+ var m struct {
+ Method string `json:"method"`
+ }
+ if e.Dir == "in" && json.Unmarshal(e.Data, &m) == nil && (m.Method == "session/prompt" || m.Method == "session/load") {
+ return found
+ }
+ return nil
+ })
+ return errors.Is(err, found)
+}
+
func (c *acpConv) awaitSession(ctx context.Context) (string, error) {
deadline := time.After(time.Minute)
for {
diff --git a/internal/box/acpdriver_test.go b/internal/box/acpdriver_test.go
index 224a3188..d63fc80c 100644
--- a/internal/box/acpdriver_test.go
+++ b/internal/box/acpdriver_test.go
@@ -16,7 +16,8 @@ import (
// spike recorded from opencode and Cursor Agent (design/structured-agents):
// it wants authenticate before a session, streams its replies as chunks,
// asks permission to "run the tests", works until cancelled on "work", has
-// build and plan modes, and resumes a session by its id.
+// build and plan modes, resumes a session by its id, and lists a past one
+// (ses_old) that it replays when loaded.
const fakeACPAgent = `#!/usr/bin/env python3
import json, sys
state = {"authed": False, "session": None, "mode": "build", "cancelled": False}
@@ -32,7 +33,7 @@ modes = {"currentModeId": "build", "availableModes": [{"id": "build", "name": "B
def handle(m):
method, rid, p = m.get("method"), m.get("id"), m.get("params") or {}
if method == "initialize":
- out({"jsonrpc": "2.0", "id": rid, "result": {"protocolVersion": 1, "agentCapabilities": {"loadSession": True, "sessionCapabilities": {"resume": {}}}, "authMethods": [{"id": "fake_login", "name": "Login"}]}})
+ out({"jsonrpc": "2.0", "id": rid, "result": {"protocolVersion": 1, "agentCapabilities": {"loadSession": True, "sessionCapabilities": {"resume": {}, "list": {}}}, "authMethods": [{"id": "fake_login", "name": "Login"}]}})
elif method == "authenticate":
state["authed"] = True
out({"jsonrpc": "2.0", "id": rid, "result": {}})
@@ -41,10 +42,18 @@ def handle(m):
out({"jsonrpc": "2.0", "id": rid, "error": {"code": -32000, "message": "Authentication required"}})
return
state["session"] = p.get("sessionId") or "ses_fake1"
+ if method == "session/load" and state["session"] == "ses_old":
+ update({"sessionUpdate": "user_message_chunk", "content": {"type": "text", "text": "earlier question"}})
+ chunks("earlier answer")
res = {"modes": modes} if method == "session/new" else {}
if method == "session/new":
res["sessionId"] = state["session"]
out({"jsonrpc": "2.0", "id": rid, "result": res})
+ elif method == "session/list":
+ out({"jsonrpc": "2.0", "id": rid, "result": {"sessions": [
+ {"sessionId": "ses_old", "cwd": p.get("cwd"), "title": "Old work", "updatedAt": "2026-10-01T09:00:00Z"},
+ {"sessionId": "ses_fake1", "cwd": p.get("cwd"), "title": "This one", "updatedAt": "2026-10-02T09:00:00Z"},
+ {"sessionId": "ses_away", "cwd": "/elsewhere", "title": "Another folder's"}]}})
elif method == "session/set_mode":
state["mode"] = p["modeId"]
out({"jsonrpc": "2.0", "id": rid, "result": {}})
diff --git a/internal/box/commands.go b/internal/box/commands.go
index 324683e6..cf0d7b50 100644
--- a/internal/box/commands.go
+++ b/internal/box/commands.go
@@ -42,6 +42,9 @@ type Command struct {
// Screen commands open the agent's own interactive screen (a picker or
// a dialog), which the chat shows as a live terminal.
Screen bool `json:"screen,omitempty"`
+ // App commands are answered by the app itself, never sent: a
+ // structured chat's /resume is its own picker.
+ App bool `json:"app,omitempty"`
// Source is where a custom command or skill was found: user, project
// or the plugin's name.
Source string `json:"source,omitempty"`
@@ -319,7 +322,7 @@ func (b *Box) listCommands(w http.ResponseWriter, r *http.Request) error {
return err
}
agent := sessionAgent(sess)
- key := agent + "\x00" + sess.Dir
+ key := agent + "\x00" + sess.Dir + "\x00" + sess.Driver
if v, ok := commandsCache.Load(sess.Name); ok {
if e := v.(commandsEntry); e.key == key && time.Since(e.at) < commandsTTL && r.URL.Query().Get("fresh") == "" {
writeJSON(w, e.cat)
@@ -328,11 +331,29 @@ func (b *Box) listCommands(w http.ResponseWriter, r *http.Request) error {
}
home, _ := os.UserHomeDir()
cat := Catalog(agent, home, sess.Dir)
+ if sess.Driver == DriverClaude || sess.Driver == DriverACP {
+ cat = withResumePicker(cat)
+ }
commandsCache.Store(sess.Name, commandsEntry{at: time.Now(), key: key, cat: cat})
writeJSON(w, cat)
return nil
}
+// withResumePicker is a structured chat's catalog: its agent has no screen
+// for its own /resume, so the app's picker answers it (pastsessions.go).
+func withResumePicker(cat CommandCatalog) CommandCatalog {
+ resume := Command{Name: "/resume", Description: "Pick a past conversation and carry it on here", Kind: "builtin", Args: "[conversation]", Local: true, App: true}
+ for i, c := range cat.Commands {
+ if c.Name == resume.Name {
+ resume.Aliases = c.Aliases
+ cat.Commands[i] = resume
+ return cat
+ }
+ }
+ cat.Commands = append([]Command{resume}, cat.Commands...)
+ return cat
+}
+
// Catalog is what agent takes after a "/" in dir, for the user whose home
// is home.
func Catalog(agent, home, dir string) CommandCatalog {
diff --git a/internal/box/conversations.go b/internal/box/conversations.go
index 95bc42cc..b9a627ad 100644
--- a/internal/box/conversations.go
+++ b/internal/box/conversations.go
@@ -53,6 +53,7 @@ func (b *Box) mountConversations(route func(string, func(http.ResponseWriter, *h
route("GET /v1/conversations", b.listConversations)
route("POST /v1/conversations/{id}/resume", b.resumeConversation)
route("POST /v1/sessions/{name}/resume", b.resumeSession)
+ route("GET /v1/sessions/{name}/past", b.listPast)
}
// readConversation reads a conversation's meta.json.
@@ -158,10 +159,19 @@ func lastTime(c Conversation) time.Time {
return c.Created
}
-// resumeSession answers POST /v1/sessions/{name}/resume: a structured
-// session whose agent ended starts again on its own log, carrying its
-// conversation on.
+// resumeSession answers POST /v1/sessions/{name}/resume {agent_session_id?}:
+// a structured session whose agent ended starts again on its own log,
+// carrying its conversation on; given agent_session_id, it carries that
+// one of its agent's conversations on instead, ended or not (pastsessions.go).
func (b *Box) resumeSession(w http.ResponseWriter, r *http.Request) error {
+ var req struct {
+ AgentSessionID string `json:"agent_session_id,omitempty"`
+ }
+ if r.ContentLength > 0 {
+ if err := decode(r, &req); err != nil {
+ return err
+ }
+ }
name := r.PathValue("name")
sess, err := b.Sessions.Get(r.Context(), name)
if err != nil {
@@ -170,13 +180,15 @@ func (b *Box) resumeSession(w http.ResponseWriter, r *http.Request) error {
if sess.conv == "" {
return httpError{http.StatusConflict, name + " isn't a structured session: start its agent again instead"}
}
- if !sess.Exited {
+ if !sess.Exited && req.AgentSessionID == "" {
return httpError{http.StatusConflict, name + " is still running"}
}
if err := b.before(r, "session.start", map[string]any{"name": name, "location": sess.Location, "path": sess.Dir, "resume": true}); err != nil {
return err
}
- if sess.Driver == "" {
+ if req.AgentSessionID != "" {
+ err = b.carryOn(r, sess, req.AgentSessionID)
+ } else if sess.Driver == "" {
// Left in Claude Code's terminal: back to its chat, with what was
// said there.
err = b.toChat(r, sess)
diff --git a/internal/box/pastsessions.go b/internal/box/pastsessions.go
new file mode 100644
index 00000000..a2876256
--- /dev/null
+++ b/internal/box/pastsessions.go
@@ -0,0 +1,407 @@
+package box
+
+import (
+ "bufio"
+ "bytes"
+ "context"
+ "encoding/json"
+ "errors"
+ "io"
+ "net/http"
+ "os"
+ "path/filepath"
+ "regexp"
+ "slices"
+ "strings"
+ "time"
+
+ "github.com/cosscom/shipyard/internal/agenthost"
+ "github.com/cosscom/shipyard/internal/integrations/adapters"
+)
+
+// A structured chat has no terminal for its agent's own /resume picker, so
+// the app draws one: GET /v1/sessions/{name}/past lists the agent's own
+// conversations in the session's folder (Claude Code's files there, which
+// a terminal's sessions write too, or what an ACP agent's session/list
+// says), and POST /v1/sessions/{name}/resume {agent_session_id} carries
+// one on in the session's pane. It gets a conversation folder of its own,
+// so the one it leaves keeps its log and stays resumable.
+
+// PastSession is one of an agent's own conversations a chat can carry on.
+type PastSession struct {
+ ID string `json:"id"`
+ Title string `json:"title,omitempty"`
+ Updated time.Time `json:"updated,omitzero"`
+ // Current is the one the session runs now; Session another live
+ // session that runs it.
+ Current bool `json:"current,omitempty"`
+ Session string `json:"session,omitempty"`
+}
+
+// PastSessions is what GET /v1/sessions/{name}/past answers.
+type PastSessions struct {
+ Agent string `json:"agent"`
+ Sessions []PastSession `json:"sessions"`
+ // Note says why the list is empty when the agent can't give one.
+ Note string `json:"note,omitempty"`
+}
+
+const (
+ maxPast = 50
+ // pastPeek is how much of each end of a Claude file is read for its
+ // title: its first prompt is near the start, the titles it is given
+ // as it goes near the end.
+ pastPeek = 256 << 10
+)
+
+// agentSessionIDs are what an agent names its conversations: Claude Code's
+// UUIDs, ACP agents' own ids.
+var agentSessionIDs = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$`)
+
+// listPast answers GET /v1/sessions/{name}/past.
+func (b *Box) listPast(w http.ResponseWriter, r *http.Request) error {
+ ctx := r.Context()
+ sess, err := b.Sessions.Get(ctx, r.PathValue("name"))
+ if err != nil {
+ return err
+ }
+ if sess.conv == "" || !structuredDriver(sess.Driver) {
+ return httpError{http.StatusConflict, sess.Name + " runs in its terminal: its agent's own /resume works there"}
+ }
+ meta, _ := readConversation(sess.conv)
+ out := PastSessions{Agent: firstNonEmpty(meta.Agent, agentFor(sess)), Sessions: []PastSession{}}
+ switch sess.Driver {
+ case DriverClaude:
+ out.Sessions = claudePast(b.claudeHomeFor(ctx, sess), sess.Dir, maxPast)
+ case DriverACP:
+ out.Sessions, out.Note = b.acpPast(ctx, sess)
+ default:
+ out.Note = "Codex's past threads can't be picked here yet. Open its terminal and use Codex's own /resume."
+ }
+ cur := conversationSession(sess.conv)
+ running := b.runningAgentSessions(ctx, sess.Name)
+ for i, p := range out.Sessions {
+ if p.ID == cur {
+ out.Sessions[i].Current = true
+ } else if name, ok := running[p.ID]; ok {
+ out.Sessions[i].Session = name
+ }
+ }
+ writeJSON(w, out)
+ return nil
+}
+
+// claudeHomeFor is the Claude Code folder a session's agent keeps its
+// conversations in: its account's (CLAUDE_CONFIG_DIR), else ~/.claude.
+func (b *Box) claudeHomeFor(ctx context.Context, sess Session) string {
+ if d := b.Sessions.EnvVar(ctx, sess, "CLAUDE_CONFIG_DIR"); d != "" {
+ return d
+ }
+ home, _ := os.UserHomeDir()
+ return firstNonEmpty(os.Getenv("CLAUDE_CONFIG_DIR"), filepath.Join(home, ".claude"))
+}
+
+// claudeFolders are where Claude Code may keep dir's conversations: under
+// dir as it is named, and as it resolves (Claude Code names the folder it
+// runs in by its real path; /tmp is /private/tmp on macOS).
+func claudeFolders(claudeHome, dir string) []string {
+ out := []string{filepath.Join(claudeHome, "projects", claudeProjectChars.ReplaceAllString(dir, "-"))}
+ if real, err := filepath.EvalSymlinks(dir); err == nil && real != dir {
+ out = append(out, filepath.Join(claudeHome, "projects", claudeProjectChars.ReplaceAllString(real, "-")))
+ }
+ return out
+}
+
+// claudeFile is the transcript of Claude Code's conversation sid in dir,
+// or "" when there is none.
+func claudeFile(claudeHome, dir, sid string) string {
+ for _, folder := range claudeFolders(claudeHome, dir) {
+ path := filepath.Join(folder, sid+".jsonl")
+ if st, err := os.Stat(path); err == nil && st.Mode().IsRegular() {
+ return path
+ }
+ }
+ return ""
+}
+
+// claudePast is Claude Code's conversations in dir, newest first: those
+// with a prompt in them, at most limit.
+func claudePast(claudeHome, dir string, limit int) []PastSession {
+ type file struct {
+ id, path string
+ mod time.Time
+ }
+ var files []file
+ seen := map[string]bool{}
+ for _, folder := range claudeFolders(claudeHome, dir) {
+ entries, _ := os.ReadDir(folder)
+ for _, e := range entries {
+ id, ok := strings.CutSuffix(e.Name(), ".jsonl")
+ if !ok || seen[id] || !e.Type().IsRegular() || !agentSessionIDs.MatchString(id) {
+ continue
+ }
+ if info, err := e.Info(); err == nil {
+ seen[id] = true
+ files = append(files, file{id, filepath.Join(folder, e.Name()), info.ModTime()})
+ }
+ }
+ }
+ slices.SortFunc(files, func(a, b file) int { return b.mod.Compare(a.mod) })
+ out := []PastSession{}
+ for _, f := range files {
+ if len(out) >= limit {
+ break
+ }
+ if title, ok := claudeTitle(f.path); ok {
+ out = append(out, PastSession{ID: f.id, Title: title, Updated: f.mod.UTC()})
+ }
+ }
+ return out
+}
+
+// claudeLine is the part of a line of Claude Code's transcript a title is
+// read from.
+type claudeLine struct {
+ Type string `json:"type"`
+ IsMeta bool `json:"isMeta"`
+ IsSidechain bool `json:"isSidechain"`
+ CustomTitle string `json:"customTitle"`
+ AITitle string `json:"aiTitle"`
+ Summary string `json:"summary"`
+ Message json.RawMessage `json:"message"`
+}
+
+// claudeTitle is what a Claude Code conversation is called: the name it
+// was given (/rename), else the one Claude gave it, else its first
+// prompt's first line. ok is false when nobody prompted it.
+func claudeTitle(path string) (title string, ok bool) {
+ f, err := os.Open(path)
+ if err != nil {
+ return "", false
+ }
+ defer f.Close()
+ var custom, ai, summary, prompt string
+ read := func(rd io.Reader) {
+ sc := bufio.NewScanner(rd)
+ sc.Buffer(make([]byte, 0, 64<<10), pastPeek)
+ for sc.Scan() {
+ line := sc.Bytes()
+ // Most lines are neither: skip them before decoding.
+ if !bytes.Contains(line, []byte(`"type":"user"`)) && !bytes.Contains(line, []byte(`itle"`)) && !bytes.Contains(line, []byte(`"summary"`)) {
+ continue
+ }
+ var l claudeLine
+ if json.Unmarshal(line, &l) != nil {
+ continue
+ }
+ switch l.Type {
+ case "custom-title":
+ custom = firstNonEmpty(strings.TrimSpace(l.CustomTitle), custom)
+ case "ai-title":
+ ai = firstNonEmpty(strings.TrimSpace(l.AITitle), ai)
+ case "summary":
+ summary = firstNonEmpty(strings.TrimSpace(l.Summary), summary)
+ case "user":
+ if prompt == "" && !l.IsMeta && !l.IsSidechain {
+ prompt = firstPrompt(l.Message)
+ }
+ }
+ }
+ }
+ read(io.LimitReader(f, pastPeek))
+ if st, err := f.Stat(); err == nil && st.Size() > pastPeek {
+ at := max(st.Size()-pastPeek, pastPeek)
+ if _, err := f.Seek(at, io.SeekStart); err == nil {
+ // The first line read there is likely cut: the scanner drops
+ // it as JSON that doesn't parse.
+ read(f)
+ }
+ }
+ if prompt == "" && custom == "" {
+ return "", false
+ }
+ for _, t := range []string{custom, ai, summary, prompt} {
+ if t != "" {
+ return adapters.Clip(adapters.Title(t), adapters.TitleMax), true
+ }
+ }
+ return "", false
+}
+
+// firstPrompt is a user message's typed text, when it is a person's
+// prompt and not one Claude Code wrote itself (an interruption, a caveat
+// before a command's output).
+func firstPrompt(message json.RawMessage) string {
+ text, ok := promptText(message)
+ text = strings.TrimSpace(text)
+ if !ok || strings.HasPrefix(text, "<") || strings.HasPrefix(text, "[Request interrupted") || strings.HasPrefix(text, "Caveat:") {
+ return ""
+ }
+ return text
+}
+
+// acpPast is what an ACP agent lists of its sessions in the session's
+// folder, or why it can't.
+func (b *Box) acpPast(ctx context.Context, sess Session) ([]PastSession, string) {
+ out := []PastSession{}
+ if sess.Exited {
+ return out, "Start the agent again to list its past sessions."
+ }
+ c, err := b.acp().conv(sess)
+ if err != nil {
+ return out, err.Error()
+ }
+ c.mu.Lock()
+ can := c.canList
+ c.mu.Unlock()
+ if !can {
+ return out, "This agent doesn't list its past sessions over ACP."
+ }
+ raw, err := c.call(ctx, "session/list", map[string]any{"cwd": sess.Dir}, 20*time.Second)
+ if err != nil {
+ return out, "It didn't list its sessions: " + err.Error()
+ }
+ var r struct {
+ Sessions []struct {
+ SessionID string `json:"sessionId"`
+ Cwd string `json:"cwd"`
+ Title string `json:"title"`
+ UpdatedAt string `json:"updatedAt"`
+ } `json:"sessions"`
+ }
+ json.Unmarshal(raw, &r)
+ for _, s := range r.Sessions {
+ if !agentSessionIDs.MatchString(s.SessionID) || (s.Cwd != "" && !sameDir(s.Cwd, sess.Dir)) {
+ continue
+ }
+ p := PastSession{ID: s.SessionID}
+ if t := strings.TrimSpace(s.Title); t != "" {
+ p.Title = adapters.Clip(adapters.Title(t), adapters.TitleMax)
+ }
+ if at, err := time.Parse(time.RFC3339Nano, s.UpdatedAt); err == nil {
+ p.Updated = at.UTC()
+ }
+ out = append(out, p)
+ }
+ slices.SortStableFunc(out, func(a, b PastSession) int { return b.Updated.Compare(a.Updated) })
+ if len(out) > maxPast {
+ out = out[:maxPast]
+ }
+ return out, ""
+}
+
+// runningAgentSessions are the agents' conversations that live structured
+// sessions other than but run now, by the agent's id for each.
+func (b *Box) runningAgentSessions(ctx context.Context, but string) map[string]string {
+ out := map[string]string{}
+ sessions, err := b.Sessions.List(ctx)
+ if err != nil {
+ return out
+ }
+ for _, s := range sessions {
+ if s.Name == but || s.conv == "" || s.Exited {
+ continue
+ }
+ if sid := conversationSession(s.conv); sid != "" {
+ out[sid] = s.Name
+ }
+ }
+ return out
+}
+
+// carryOn starts a structured session's agent on another of its own
+// conversations, sid, in the session's pane.
+func (b *Box) carryOn(r *http.Request, sess Session, sid string) error {
+ ctx := r.Context()
+ if !agentSessionIDs.MatchString(sid) {
+ return badRequest("%q isn't a conversation's id", sid)
+ }
+ if sess.conv == "" || !structuredDriver(sess.Driver) {
+ return httpError{http.StatusConflict, sess.Name + " runs in its terminal: its agent's own /resume works there"}
+ }
+ if sess.Driver == DriverCodex {
+ return badRequest("Codex's past threads can't be picked here yet")
+ }
+ if !sess.Exited && sid == conversationSession(sess.conv) {
+ return nil
+ }
+ if other, ok := b.runningAgentSessions(ctx, sess.Name)[sid]; ok {
+ return httpError{http.StatusConflict, "that conversation is running in " + other + ": open it there"}
+ }
+ meta, _ := readConversation(sess.conv)
+ var history, title string
+ if sess.Driver == DriverClaude {
+ history = claudeFile(b.claudeHomeFor(ctx, sess), sess.Dir, sid)
+ var ok bool
+ if title, ok = claudeTitle(history); history == "" || !ok {
+ return httpError{http.StatusNotFound, "Claude has no conversation " + sid + " in " + sess.Dir}
+ }
+ }
+ dir, sock, err := b.newConversation()
+ if err != nil {
+ return err
+ }
+ fail := func(err error) error {
+ os.RemoveAll(dir)
+ return err
+ }
+ if history != "" {
+ if err := importHistory(dir, history); err != nil {
+ return fail(err)
+ }
+ }
+ mode := firstNonEmpty(modeFor(sess), meta.Mode)
+ noteConversation(dir, func(c *Conversation) {
+ c.Agent, c.Location, c.Dir, c.Session = firstNonEmpty(meta.Agent, agentFor(sess)), sess.Location, sess.Dir, sess.Name
+ c.AgentSessionID, c.Mode, c.Title, c.Created = sid, mode, title, time.Now().UTC()
+ })
+ l, err := b.relaunch(ctx, sess.Location, dir, sock, sid, mode)
+ if err != nil {
+ return fail(err)
+ }
+ l.labels[labelHandoff] = ""
+ b.forgetConversation(sess.Name)
+ if err := b.respawnAgent(ctx, sess, l.command, l.env, l.labels); err != nil {
+ return fail(err)
+ }
+ noteResumed(dir, sess.Name)
+ awaitHost(sock, 15*time.Second)
+ if live, err := b.Sessions.Get(ctx, sess.Name); err == nil {
+ b.startStructured(origin(r), gateOrigin(r), live, "")
+ }
+ // The tab is named after what it holds now.
+ if title != "" && b.Sessions.SetTitle(ctx, sess.Name, title) == nil {
+ b.publish(r, "session.renamed", map[string]any{"name": sess.Name})
+ }
+ return nil
+}
+
+// importHistory copies a Claude Code conversation's transcript into a new
+// conversation's log, so its chat shows what was said before it carries on.
+func importHistory(convDir, path string) error {
+ f, err := os.Open(path)
+ if err != nil {
+ return err
+ }
+ defer f.Close()
+ log, err := agenthost.OpenLog(convDir)
+ if err != nil {
+ return err
+ }
+ defer log.Close()
+ rd := bufio.NewReaderSize(f, 256<<10)
+ for {
+ line, err := rd.ReadBytes('\n')
+ if line = bytes.TrimSpace(line); len(line) > 0 && json.Valid(line) {
+ if _, aerr := log.Append(agenthost.Entry{Dir: "out", Event: "imported", Data: json.RawMessage(line)}); aerr != nil {
+ return aerr
+ }
+ }
+ if errors.Is(err, io.EOF) {
+ return nil
+ }
+ if err != nil {
+ return err
+ }
+ }
+}
diff --git a/internal/box/pastsessions_test.go b/internal/box/pastsessions_test.go
new file mode 100644
index 00000000..37f62f2b
--- /dev/null
+++ b/internal/box/pastsessions_test.go
@@ -0,0 +1,273 @@
+package box
+
+import (
+ "encoding/json"
+ "net/http"
+ "os"
+ "os/exec"
+ "path/filepath"
+ "slices"
+ "strings"
+ "testing"
+ "time"
+
+ "github.com/cosscom/shipyard/internal/transcript"
+)
+
+// writeClaudeFile writes a Claude Code transcript of lines (JSON objects)
+// for conversation sid in dir, as Claude Code keeps it under claudeHome.
+func writeClaudeFile(t *testing.T, claudeHome, dir, sid string, lines ...map[string]any) string {
+ t.Helper()
+ folder := claudeFolders(claudeHome, dir)[0]
+ if err := os.MkdirAll(folder, 0o700); err != nil {
+ t.Fatal(err)
+ }
+ var b strings.Builder
+ for _, l := range lines {
+ raw, _ := json.Marshal(l)
+ b.Write(raw)
+ b.WriteByte('\n')
+ }
+ path := filepath.Join(folder, sid+".jsonl")
+ if err := os.WriteFile(path, []byte(b.String()), 0o600); err != nil {
+ t.Fatal(err)
+ }
+ return path
+}
+
+func userLine(sid, text string, extra map[string]any) map[string]any {
+ l := map[string]any{"type": "user", "sessionId": sid, "uuid": "u-" + text, "timestamp": "2026-10-13T12:00:00Z", "message": map[string]any{"role": "user", "content": text}}
+ for k, v := range extra {
+ l[k] = v
+ }
+ return l
+}
+
+func TestClaudesPastConversationsAreListedByTheirTitlesNewestFirst(t *testing.T) {
+ home, dir := t.TempDir(), "/home/dev/acme"
+ const a, b, c, d = "00000000-0000-4000-8000-00000000000a", "00000000-0000-4000-8000-00000000000b", "00000000-0000-4000-8000-00000000000c", "00000000-0000-4000-8000-00000000000d"
+ // Named by the person, then by Claude: the person's name wins.
+ pa := writeClaudeFile(t, home, dir, a,
+ userLine(a, "/clear", nil),
+ userLine(a, "Caveat: the messages below were generated by the user while running local commands.", map[string]any{"isMeta": true}),
+ userLine(a, "Fix the flaky checkout test\nIt fails one run in ten.", nil),
+ map[string]any{"type": "ai-title", "sessionId": a, "aiTitle": "Fix flaky checkout test"},
+ map[string]any{"type": "custom-title", "sessionId": a, "customTitle": "checkout flake"})
+ // Named by Claude only.
+ pb := writeClaudeFile(t, home, dir, b,
+ userLine(b, "Add a dark theme", nil),
+ map[string]any{"type": "ai-title", "sessionId": b, "aiTitle": "Add dark theme"})
+ // Its first prompt, cut to a title.
+ pc := writeClaudeFile(t, home, dir, c, userLine(c, "Why is the build slow?\nIt takes ten minutes.", nil))
+ // Never prompted: not one to carry on.
+ writeClaudeFile(t, home, dir, d, map[string]any{"type": "permission-mode", "sessionId": d, "permissionMode": "default"})
+ // Another folder's conversation isn't this one's.
+ writeClaudeFile(t, home, "/home/dev/other", "00000000-0000-4000-8000-0000000000ff", userLine("x", "elsewhere", nil))
+ now := time.Now()
+ for i, p := range []string{pa, pb, pc} {
+ at := now.Add(-time.Duration(3-i) * time.Hour)
+ os.Chtimes(p, at, at)
+ }
+
+ got := claudePast(home, dir, 10)
+ var titles []string
+ for _, p := range got {
+ titles = append(titles, p.ID[len(p.ID)-1:]+" "+p.Title)
+ }
+ want := "c Why is the build slow?|b Add dark theme|a checkout flake"
+ if strings.Join(titles, "|") != want {
+ t.Fatalf("listed %q, want %q", strings.Join(titles, "|"), want)
+ }
+ if got := claudePast(home, dir, 2); len(got) != 2 {
+ t.Fatalf("limited to 2, listed %d", len(got))
+ }
+}
+
+func TestAStructuredClaudeChatCarriesOnOneOfClaudesPastConversations(t *testing.T) {
+ if _, err := exec.LookPath("python3"); err != nil {
+ t.Skip("the stand-in Claude needs python3")
+ }
+ bin := t.TempDir()
+ os.WriteFile(filepath.Join(bin, "claude"), []byte(fakeStreamClaude), 0o755)
+ t.Setenv("PATH", bin+string(os.PathListSeparator)+os.Getenv("PATH"))
+ claudeHome := t.TempDir()
+ t.Setenv("CLAUDE_CONFIG_DIR", claudeHome)
+ exe, _ := os.Executable()
+ states := &Turns{}
+ c, bus := servedBox(t, func(b *Box) {
+ b.Turns = states
+ b.ConversationsDir = conversationsDir(t)
+ b.AgentHost = []string{"env", "BERTH_TEST_AGENT_HOST=1", exe}
+ })
+ states.Attach(bus)
+ repo := gitRepo(t)
+ real, _ := filepath.EvalSymlinks(repo)
+ // A conversation had in Claude Code's own terminal, before.
+ const past = "00000000-0000-4000-8000-0000000000p1"
+ writeClaudeFile(t, claudeHome, real, past,
+ userLine(past, "plan the release", nil),
+ map[string]any{"type": "assistant", "sessionId": past, "uuid": "a-1", "timestamp": "2026-10-13T12:00:01Z", "message": map[string]any{"id": "msg_p", "role": "assistant", "content": []any{map[string]any{"type": "text", "text": "Here is the release plan."}}}},
+ map[string]any{"type": "custom-title", "sessionId": past, "customTitle": "release plan"})
+
+ call(t, c, "POST", "/v1/locations", "", map[string]string{"name": "acme", "path": repo}, nil)
+ var sess Session
+ call(t, c, "POST", "/v1/sessions", "", map[string]any{"location": "acme", "agent": "claude", "driver": "claude", "prompt": "hello"}, &sess)
+ t.Cleanup(func() { call(t, c, "DELETE", "/v1/sessions/"+sess.Name, "", nil, nil) })
+ until := func(what string, ok func() bool) {
+ t.Helper()
+ deadline := time.Now().Add(15 * time.Second)
+ for !ok() {
+ if time.Now().After(deadline) {
+ t.Fatalf("%s never happened", what)
+ }
+ time.Sleep(50 * time.Millisecond)
+ }
+ }
+ said := func(text string) func() bool {
+ return func() bool {
+ var res transcript.Result
+ call(t, c, "GET", "/v1/sessions/"+sess.Name+"/transcript?since=0", "", nil, &res)
+ for _, it := range res.Items {
+ if strings.Contains(it.Text, text) {
+ return true
+ }
+ }
+ return false
+ }
+ }
+ until("the first reply", said("echo: hello"))
+ until("its turn's end", func() bool { st, _ := states.State(sess.Name); return st.State == "finished" })
+
+ // The stand-in leaves its own file empty; Claude Code writes the prompt.
+ const own = "00000000-0000-4000-8000-0000000000c1"
+ writeClaudeFile(t, claudeHome, real, own, userLine(own, "hello", nil))
+
+ // The chat's /resume: what Claude has had in this folder, this chat's
+ // own conversation among it.
+ var list PastSessions
+ if status := call(t, c, "GET", "/v1/sessions/"+sess.Name+"/past", "", nil, &list); status != 200 || list.Agent != "claude" {
+ t.Fatalf("past: %d %+v", status, list)
+ }
+ found := map[string]PastSession{}
+ for _, p := range list.Sessions {
+ found[p.ID] = p
+ }
+ if p := found[past]; p.Title != "release plan" || p.Current {
+ t.Fatalf("the past conversation is %+v in %+v", p, list.Sessions)
+ }
+ if p := found[own]; !p.Current || p.Title != "hello" {
+ t.Fatalf("the chat's own conversation is %+v in %+v", p, list.Sessions)
+ }
+ var cat CommandCatalog
+ call(t, c, "GET", "/v1/sessions/"+sess.Name+"/commands", "", nil, &cat)
+ for _, cmd := range cat.Commands {
+ if cmd.Name == "/resume" && (!cmd.App || cmd.Screen || !slices.Contains(cmd.Aliases, "/continue")) {
+ t.Fatalf("a structured chat's /resume is %+v", cmd)
+ }
+ }
+
+ if status := call(t, c, "POST", "/v1/sessions/"+sess.Name+"/resume", "", map[string]string{"agent_session_id": "00000000-0000-4000-8000-000000000404"}, nil); status != http.StatusNotFound {
+ t.Fatalf("one Claude doesn't have: %d", status)
+ }
+ if status := call(t, c, "POST", "/v1/sessions/"+sess.Name+"/resume", "", map[string]string{"agent_session_id": "../etc"}, nil); status != http.StatusBadRequest {
+ t.Fatalf("a path for an id: %d", status)
+ }
+ var resumed Session
+ if status := call(t, c, "POST", "/v1/sessions/"+sess.Name+"/resume", "", map[string]string{"agent_session_id": past}, &resumed); status != 200 || resumed.Exited || resumed.Driver != DriverClaude || resumed.Title != "release plan" {
+ t.Fatalf("resume %s: %d %+v", past, status, resumed)
+ }
+ // What was said in the terminal shows, and the chat carries on from it.
+ until("the past conversation in the chat", said("Here is the release plan."))
+ if said("echo: hello")() {
+ t.Fatal("the conversation it left still shows")
+ }
+ call(t, c, "POST", "/v1/sessions/"+sess.Name+"/send", "", map[string]any{"text": "ship it", "when": "now"}, nil)
+ until("a reply in the carried-on conversation", said("echo: ship it"))
+
+ // The one it left stays, to be resumed later.
+ var convs []Conversation
+ call(t, c, "GET", "/v1/conversations?path="+repo, "", nil, &convs)
+ if len(convs) != 2 {
+ t.Fatalf("conversations = %+v", convs)
+ }
+ for _, cv := range convs {
+ if cv.Title == "hello" && cv.Live {
+ t.Fatalf("the conversation it left is still live: %+v", cv)
+ }
+ }
+}
+
+func TestAnACPChatCarriesOnOneOfItsAgentsPastSessions(t *testing.T) {
+ if _, err := exec.LookPath("python3"); err != nil {
+ t.Skip("the stand-in agent needs python3")
+ }
+ bin := t.TempDir()
+ os.WriteFile(filepath.Join(bin, "opencode"), []byte(fakeACPAgent), 0o755)
+ t.Setenv("PATH", bin+string(os.PathListSeparator)+os.Getenv("PATH"))
+ exe, _ := os.Executable()
+ states := &Turns{}
+ c, bus := servedBox(t, func(b *Box) {
+ b.Turns = states
+ b.ConversationsDir = conversationsDir(t)
+ b.AgentHost = []string{"env", "BERTH_TEST_AGENT_HOST=1", exe}
+ })
+ states.Attach(bus)
+ repo := gitRepo(t)
+ call(t, c, "POST", "/v1/locations", "", map[string]string{"name": "acme", "path": repo}, nil)
+ var sess Session
+ call(t, c, "POST", "/v1/sessions", "", map[string]any{"location": "acme", "agent": "opencode", "driver": "acp", "prompt": "hello"}, &sess)
+ t.Cleanup(func() { call(t, c, "DELETE", "/v1/sessions/"+sess.Name, "", nil, nil) })
+ texts := func() []string {
+ var res transcript.Result
+ call(t, c, "GET", "/v1/sessions/"+sess.Name+"/transcript?since=0", "", nil, &res)
+ var out []string
+ for _, it := range res.Items {
+ out = append(out, it.Kind+":"+strings.TrimSpace(it.Text))
+ }
+ return out
+ }
+ until := func(what string, ok func() bool) {
+ t.Helper()
+ deadline := time.Now().Add(15 * time.Second)
+ for !ok() {
+ if time.Now().After(deadline) {
+ t.Fatalf("%s never happened: %q", what, texts())
+ }
+ time.Sleep(50 * time.Millisecond)
+ }
+ }
+ said := func(text string) func() bool {
+ return func() bool { return slices.Contains(texts(), text) }
+ }
+ until("the first reply", said("text:echo: hello (build)"))
+ until("its turn's end", func() bool { st, _ := states.State(sess.Name); return st.State == "finished" })
+
+ var list PastSessions
+ if status := call(t, c, "GET", "/v1/sessions/"+sess.Name+"/past", "", nil, &list); status != 200 {
+ t.Fatalf("past: %d %+v", status, list)
+ }
+ var got []string
+ for _, p := range list.Sessions {
+ s := p.ID + " " + p.Title
+ if p.Current {
+ s += " (current)"
+ }
+ got = append(got, s)
+ }
+ if strings.Join(got, "|") != "ses_fake1 This one (current)|ses_old Old work" {
+ t.Fatalf("past = %q (%s)", got, list.Note)
+ }
+
+ if status := call(t, c, "POST", "/v1/sessions/"+sess.Name+"/resume", "", map[string]string{"agent_session_id": "ses_old"}, nil); status != 200 {
+ t.Fatalf("resume: %d", status)
+ }
+ // Loaded, it replays what was said: the chat shows it, once.
+ until("the replayed conversation", func() bool {
+ return strings.Join(texts(), "|") == "user:earlier question|text:earlier answer"
+ })
+ call(t, c, "POST", "/v1/sessions/"+sess.Name+"/send", "", map[string]any{"text": "go on", "when": "now"}, nil)
+ until("a reply in the carried-on session", said("text:echo: go on (build)"))
+ if st, _ := states.State(sess.Name); st.AgentSessionID != "ses_old" {
+ t.Fatalf("it carries on %q", st.AgentSessionID)
+ }
+}
diff --git a/internal/box/upgrade.go b/internal/box/upgrade.go
index e265a7b2..9caecbe3 100644
--- a/internal/box/upgrade.go
+++ b/internal/box/upgrade.go
@@ -179,7 +179,9 @@ func (b *Box) Capabilities() []string {
// driver.codex: "driver": "codex", Codex's app server (codexdriver.go).
// driver.acp: "driver": "acp", an agent whose preset says how it
// speaks ACP (acpdriver.go).
- caps = append(caps, "driver.claude", "driver.codex", "driver.acp", "driver.switch", "conversations")
+ // sessions.past: GET /v1/sessions/{name}/past, and .../resume
+ // {agent_session_id}: a structured chat's /resume (pastsessions.go).
+ caps = append(caps, "driver.claude", "driver.codex", "driver.acp", "driver.switch", "conversations", "sessions.past")
if b.AgentDefaults != nil {
// agents.defaults: GET and PUT /v1/agents/defaults, how agents
// start when what starts them names no driver
diff --git a/internal/transcript/acphost.go b/internal/transcript/acphost.go
index 5024f035..1daa5d0a 100644
--- a/internal/transcript/acphost.go
+++ b/internal/transcript/acphost.go
@@ -16,10 +16,15 @@ const SourceACPHost = "acp-host"
type acpHostParser struct {
text strings.Builder
textOff int64
+ // asked is a replayed prompt, gathered as its chunks come.
+ asked strings.Builder
// loading is the session/load being answered: what the agent replays
- // for it is already in the log.
+ // for it is already in the log. A log that starts with a load (a
+ // session picked from the agent's past ones) shows the replay instead.
loading string
- prompts map[string]bool
+ // replaying is a load whose replay is shown: its answer ends it.
+ replaying string
+ prompts map[string]bool
}
type acpUpdate struct {
@@ -43,6 +48,10 @@ type acpUpdate struct {
}
func (p *acpHostParser) flush(c *conv) {
+ if t := strings.TrimSpace(p.asked.String()); t != "" {
+ userText(c, t)
+ }
+ p.asked.Reset()
if t := strings.TrimSpace(p.text.String()); t != "" {
off := c.lineOff
c.lineOff = p.textOff
@@ -91,7 +100,11 @@ func (p *acpHostParser) line(c *conv, b []byte) {
}
p.prompts[id] = true
case "session/load":
- p.loading = id
+ if c.base > 0 || len(c.items) > 0 {
+ p.loading = id
+ } else {
+ p.replaying = id
+ }
}
return
case "out":
@@ -103,6 +116,10 @@ func (p *acpHostParser) line(c *conv, b []byte) {
if id == p.loading {
p.loading = ""
}
+ if id == p.replaying {
+ p.replaying = ""
+ p.flush(c)
+ }
if p.prompts[id] {
delete(p.prompts, id)
p.flush(c)
@@ -120,7 +137,18 @@ func (p *acpHostParser) line(c *conv, b []byte) {
}
u := params.Update
switch u.SessionUpdate {
+ case "user_message_chunk":
+ // Replayed by a load: a prompt from before.
+ if p.text.Len() > 0 {
+ p.flush(c)
+ }
+ if p.asked.Len() < maxText {
+ p.asked.WriteString(u.Content.Text)
+ }
case "agent_message_chunk":
+ if p.asked.Len() > 0 {
+ p.flush(c)
+ }
if p.text.Len() == 0 {
p.textOff = c.lineOff
}
diff --git a/internal/transcript/acphost_test.go b/internal/transcript/acphost_test.go
index e0a06fde..87e2789f 100644
--- a/internal/transcript/acphost_test.go
+++ b/internal/transcript/acphost_test.go
@@ -1,6 +1,8 @@
package transcript
import (
+ "fmt"
+ "os"
"path/filepath"
"strings"
"testing"
@@ -38,3 +40,39 @@ func TestAStructuredACPSessionsLogReadsAsItsConversation(t *testing.T) {
t.Fatalf("source %q, items:\n%s", res.Source, strings.Join(got, "\n"))
}
}
+
+// A log that starts by loading one of the agent's past sessions shows what
+// the agent replays for it; a log that has its conversation already, loaded
+// again when its host starts again, doesn't show it twice.
+func TestALoadedACPSessionShowsItsReplayOnlyOnce(t *testing.T) {
+ replay := `{"seq":%d,"at":1791900003000,"dir":"in","data":{"jsonrpc":"2.0","id":"load","method":"session/load","params":{"sessionId":"ses_1","cwd":"/home/dev/acme"}}}
+{"seq":%d,"at":1791900003100,"dir":"out","data":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"ses_1","update":{"sessionUpdate":"user_message_chunk","content":{"type":"text","text":"Fix the flaky test"}}}}}
+{"seq":%d,"at":1791900003200,"dir":"out","data":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"ses_1","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"Fixed it."}}}}}
+{"seq":%d,"at":1791900003300,"dir":"out","data":{"jsonrpc":"2.0","id":"load","result":{}}}
+`
+ read := func(log string) []string {
+ path := filepath.Join(t.TempDir(), "log.jsonl")
+ if err := os.WriteFile(path, []byte(log), 0o600); err != nil {
+ t.Fatal(err)
+ }
+ res, err := NewReader().Read(SourceACPHost, path, "/home/dev/acme", 0)
+ if err != nil {
+ t.Fatal(err)
+ }
+ var got []string
+ for _, it := range res.Items {
+ got = append(got, it.Kind+":"+it.Text)
+ }
+ return got
+ }
+ if got := read(fmt.Sprintf(replay, 1, 2, 3, 4)); strings.Join(got, "|") != "user:Fix the flaky test|text:Fixed it." {
+ t.Errorf("picked session: %q, want what it replayed", got)
+ }
+ own := `{"seq":1,"at":1791900001000,"dir":"in","data":{"jsonrpc":"2.0","id":"p1","method":"session/prompt","params":{"sessionId":"ses_1","prompt":[{"type":"text","text":"Fix the flaky test"}]}}}
+{"seq":2,"at":1791900001100,"dir":"out","data":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"ses_1","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"Fixed it."}}}}}
+{"seq":3,"at":1791900001200,"dir":"out","data":{"jsonrpc":"2.0","id":"p1","result":{"stopReason":"end_turn"}}}
+`
+ if got := read(own + fmt.Sprintf(replay, 4, 5, 6, 7)); strings.Join(got, "|") != "user:Fix the flaky test|text:Fixed it." {
+ t.Errorf("own conversation loaded again: %q, want it once", got)
+ }
+}
diff --git a/internal/transcript/claude.go b/internal/transcript/claude.go
index ed9893cf..e4e9c729 100644
--- a/internal/transcript/claude.go
+++ b/internal/transcript/claude.go
@@ -20,6 +20,10 @@ type claudeLine struct {
Type string `json:"type"`
Subtype string `json:"subtype"`
IsMeta bool `json:"isMeta"`
+ // IsSynthetic marks what Claude Code's stream writes as the person's
+ // and isn't: a skill's instructions, loaded for the model. Its
+ // transcript file marks the same lines isMeta.
+ IsSynthetic bool `json:"isSynthetic"`
// IsCompactSummary marks the summary a compacted conversation goes on
// from: written as the person's, but not what they said.
IsCompactSummary bool `json:"isCompactSummary"`
@@ -117,7 +121,7 @@ func (claudeParser) line(c *conv, b []byte) {
msg = claudeMessage{}
}
c.lineUUID, c.lineParent = l.UUID, l.ParentUUID
- if l.IsMeta {
+ if l.IsMeta || l.IsSynthetic {
// A message from another agent, Claude Code's notification or a
// note around the person's mid-turn message, marked meta: read for
// what it is (peer.go), not dropped.
@@ -382,6 +386,10 @@ func claudeTool(c *conv, bl claudeBlock, at int64) {
c.add(Item{Kind: "text", ID: c.id(), Text: clip(msg, maxText)})
}
c.byTool[bl.ID] = -1
+ case "Skill":
+ // Its instructions come back as a synthetic user line, not shown:
+ // the step says which skill it loaded.
+ c.call(bl.ID, ToolCall{Verb: "Skill", Target: clip(firstNonEmpty(str("skill"), str("command"), "a skill"), 80)})
case "ToolSearch":
// Bookkeeping, not work worth a line.
case "Artifact":
diff --git a/internal/transcript/host_test.go b/internal/transcript/host_test.go
index d9e9a1e1..211151b4 100644
--- a/internal/transcript/host_test.go
+++ b/internal/transcript/host_test.go
@@ -1,6 +1,8 @@
package transcript
import (
+ "fmt"
+ "os"
"path/filepath"
"strings"
"testing"
@@ -70,3 +72,41 @@ func TestOnlyTheConversationsOwnLinesAreUnwrapped(t *testing.T) {
t.Fatalf("no time: %s", line)
}
}
+
+// A skill loaded in a structured session (as Claude Code 2.1's stream
+// writes it, recorded): its instructions come back as a user line marked
+// isSynthetic, which isn't the person's and isn't shown; the call says
+// which skill it was.
+func TestASkillsInstructionsAreNotShownAsThePersonsMessage(t *testing.T) {
+ lines := []string{
+ `{"type":"user","message":{"role":"user","content":"Use the acme-hello skill to greet acme."},"uuid":"u1","timestamp":"2026-10-15T12:00:00Z"}`,
+ `{"type":"assistant","message":{"id":"msg_1","role":"assistant","content":[{"type":"tool_use","id":"toolu_1","name":"Skill","input":{"skill":"acme-hello","args":"acme"}}]},"uuid":"a1","timestamp":"2026-10-15T12:00:01Z"}`,
+ `{"type":"user","message":{"role":"user","content":[{"type":"tool_result","tool_use_id":"toolu_1","content":"Launching skill: acme-hello"}]},"uuid":"u2","timestamp":"2026-10-15T12:00:02Z"}`,
+ `{"type":"user","isSynthetic":true,"message":{"role":"user","content":[{"type":"text","text":"Base directory for this skill: /home/dev/acme/.claude/skills/acme-hello\n\n# Greeting acme\n\nReply with exactly: Hello from acme."}]},"uuid":"u3","timestamp":"2026-10-15T12:00:02Z"}`,
+ `{"type":"assistant","message":{"id":"msg_2","role":"assistant","content":[{"type":"text","text":"Hello from acme."}]},"uuid":"a2","timestamp":"2026-10-15T12:00:03Z"}`,
+ }
+ var log strings.Builder
+ for i, l := range lines {
+ fmt.Fprintf(&log, `{"seq":%d,"at":1791900001000,"dir":"out","data":%s}`+"\n", i+1, l)
+ }
+ path := filepath.Join(t.TempDir(), "log.jsonl")
+ if err := os.WriteFile(path, []byte(log.String()), 0o600); err != nil {
+ t.Fatal(err)
+ }
+ res, err := NewReader().Read(SourceClaudeHost, path, "/home/dev/acme", 0)
+ if err != nil {
+ t.Fatal(err)
+ }
+ var got []string
+ for _, it := range res.Items {
+ s := it.Kind + ":" + it.Text
+ for _, c := range it.Items {
+ s += c.Verb + " " + c.Target
+ }
+ got = append(got, s)
+ }
+ want := "user:Use the acme-hello skill to greet acme.|tools:Skill acme-hello|text:Hello from acme."
+ if strings.Join(got, "|") != want {
+ t.Fatalf("items %q, want %q", strings.Join(got, "|"), want)
+ }
+}
diff --git a/internal/wire/wire_test.go b/internal/wire/wire_test.go
index f172018b..3595127d 100644
--- a/internal/wire/wire_test.go
+++ b/internal/wire/wire_test.go
@@ -407,14 +407,22 @@ func testRevokeClosesOpenStreams(t *testing.T, every time.Duration, tell bool) {
if tell {
b.server.ClientsChanged()
}
- conn.SetDeadline(time.Now().Add(5 * time.Second))
- conn.Write([]byte("still here\n"))
- got, err := io.ReadAll(conn)
- if err == nil && strings.Contains(string(got), "still here") {
- t.Fatalf("revoked laptop's stream still echoes: %q", got)
- }
- if ne, ok := err.(net.Error); ok && ne.Timeout() {
- t.Fatal("revoked laptop's stream was left open")
+ // The daemon cuts the stream at its next look, or as soon as it is
+ // told: until then a line may still echo (on a slow runner it did), and
+ // once it is cut nothing does. It has to be cut well within a few
+ // seconds either way.
+ deadline := time.Now().Add(5 * time.Second)
+ line := make([]byte, 64)
+ for {
+ conn.SetDeadline(time.Now().Add(200 * time.Millisecond))
+ _, werr := conn.Write([]byte("still here\n"))
+ _, rerr := conn.Read(line)
+ if ne, ok := rerr.(net.Error); werr != nil || (rerr != nil && !(ok && ne.Timeout())) {
+ return
+ }
+ if time.Now().After(deadline) {
+ t.Fatal("revoked laptop's stream was left open")
+ }
}
}
diff --git a/packages/plugin-sdk/src/types.ts b/packages/plugin-sdk/src/types.ts
index 8da451ab..0b484c25 100644
--- a/packages/plugin-sdk/src/types.ts
+++ b/packages/plugin-sdk/src/types.ts
@@ -259,6 +259,26 @@ export interface Conversation {
updated?: string;
}
+// One of a structured agent's own conversations in a session's folder,
+// which its chat's /resume can carry on (GET sessions/{name}/past,
+// "sessions.past" capability): Claude Code's, including those had in its
+// terminal, or what an ACP agent lists.
+export interface PastSession {
+ id: string;
+ title?: string;
+ updated?: string;
+ // The one the session runs now; or another live session that runs it.
+ current?: boolean;
+ session?: string;
+}
+
+export interface PastSessions {
+ agent: string;
+ sessions: PastSession[];
+ // Why the list is empty, when the agent can't give one.
+ note?: string;
+}
+
export interface Ask {
tool?: string;
input?: string;