Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 22 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,28 @@ polish (plan-limit windows, orchestrator-lifecycle fixes, Homebrew formula, firs

Phase 3 (Delight) has no plan by design.

## [0.47.0] - 2026-09-02

### Added

- **Pick up a session Caprock did not start.** It never types into a process it
did not launch — two writers on one terminal interleave characters and ruin
both — so a session you started yourself was readable here and nothing else.

There is now a button on it. `claude --resume` starts a *second* process on
the same conversation, with the history read from disk: nothing is taken away
from the terminal that already has it, and the new process is one Caprock
started, so it can be typed into like any other. The stats, the timeline and
the terminal all work on it.

While the original is still running the copy branches instead —
`--fork-session`, a new id — because two live processes sharing one id would
write a single transcript between them and each end up holding half the
other's turns. Once it has ended, it simply continues.

The command is offered for copying too, for people who would rather stay in
their own terminal.

## [0.46.0] - 2026-09-02

### Added
Expand Down
36 changes: 33 additions & 3 deletions internal/agents/agents.go
Original file line number Diff line number Diff line change
Expand Up @@ -32,10 +32,28 @@ type SpawnRequest struct {
PermissionMode string `json:"permission_mode,omitempty"` // --permission-mode
Command string `json:"command,omitempty"` // default "claude"
// Agent picks which coding agent to launch: "claude" (default) or
// "gemini". They take different flags — gemini has no --session-id and
// spells the model -m — so the argv is built per agent rather than
// "gemini". They take different flags — gemini spells the model -m and
// needs --skip-trust — so the argv is built per agent rather than
// pretending one shape fits both.
Agent string `json:"agent,omitempty"`
// Resume continues an existing conversation instead of starting a new one.
//
// This is how a session that lives in somebody's terminal can be picked up
// inside Caprock. It cannot type into a process it did not start — two
// writers on one PTY interleave characters and ruin both — so it starts a
// second process on the same conversation, which is what `--resume` is for.
// The history is on disk, so nothing is lost and nothing is fought over.
Resume string `json:"resume,omitempty"`
// Fork branches the resumed conversation into a new session id rather than
// reusing the original.
//
// Set when the session being picked up is still running somewhere: two live
// processes claiming one id would write the same transcript and each end up
// with half the other's turns. A fork keeps the history and leaves the
// original alone. Claude Code refuses --session-id alongside --resume
// unless --fork-session is present, which is the same distinction from the
// other side.
Fork bool `json:"fork,omitempty"`
// GeminiKey is the key the daemon holds, passed into the child's
// environment. Never accepted from the browser — the API fills it in from
// settings, so a page cannot hand a spawned process someone else's
Expand Down Expand Up @@ -337,7 +355,19 @@ func (m *Manager) Spawn(ctx context.Context, req SpawnRequest) (*Agent, error) {
args = append(args, req.Args...)
default:
command = m.claude
args = []string{"--session-id", sessionID}
switch {
case req.Resume != "" && req.Fork:
// A branch: the original keeps running under its own id, and this
// process gets a fresh one. Both flags are required together —
// Claude Code refuses --session-id with --resume otherwise.
args = []string{"--resume", req.Resume, "--fork-session", "--session-id", sessionID}
case req.Resume != "":
// Continuing the same conversation, which already has an id.
args = []string{"--resume", req.Resume}
sessionID = req.Resume
default:
args = []string{"--session-id", sessionID}
}
if req.Model != "" {
args = append(args, "--model", req.Model)
}
Expand Down
66 changes: 66 additions & 0 deletions internal/agents/agents_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -612,3 +612,69 @@ func TestUnmappableModeIsLeftOffRatherThanGuessed(t *testing.T) {
t.Errorf("an unmappable mode was guessed at: %v", f.lastSpec.Args)
}
}

// Caprock cannot type into a session it did not start: two writers on one PTY
// interleave characters and ruin both, which is why rule 7 exists. What it can
// do is start a second process on the same conversation — the history is on
// disk, so nothing is lost and nothing is fought over.
func TestResumingContinuesAnExistingConversation(t *testing.T) {
m, _, f := newMgr(t)
defer m.Shutdown()

const existing = "61d26e6d-8788-4ba6-aac2-547c957a9cd2"
if _, err := m.Spawn(context.Background(), SpawnRequest{
Cwd: t.TempDir(), Resume: existing,
}); err != nil {
t.Fatal(err)
}
joined := strings.Join(f.lastSpec.Args, " ")
if !strings.Contains(joined, "--resume "+existing) {
t.Errorf("the conversation was not resumed: %v", f.lastSpec.Args)
}
// Claude Code refuses both: --session-id names a new session, --resume an
// existing one. Sending both is an error at the binary, after the terminal
// has already opened.
if strings.Contains(joined, "--session-id") {
t.Errorf("--session-id was sent alongside --resume: %v", f.lastSpec.Args)
}
}

func TestForkingBranchesRatherThanSharingAnId(t *testing.T) {
// Set when the original is still running somewhere. Two live processes
// claiming one id would write the same transcript and each end up with half
// the other's turns.
m, _, f := newMgr(t)
defer m.Shutdown()

const existing = "61d26e6d-8788-4ba6-aac2-547c957a9cd2"
ag, err := m.Spawn(context.Background(), SpawnRequest{
Cwd: t.TempDir(), Resume: existing, Fork: true,
})
if err != nil {
t.Fatal(err)
}
joined := strings.Join(f.lastSpec.Args, " ")
for _, must := range []string{"--resume " + existing, "--fork-session", "--session-id"} {
if !strings.Contains(joined, must) {
t.Errorf("a fork is missing %q: %v", must, f.lastSpec.Args)
}
}
if ag.SessionID == existing {
t.Error("a fork reused the original's id; both would write one transcript")
}
}

func TestANormalSpawnStillGetsItsOwnId(t *testing.T) {
m, _, f := newMgr(t)
defer m.Shutdown()
if _, err := m.Spawn(context.Background(), SpawnRequest{Cwd: t.TempDir()}); err != nil {
t.Fatal(err)
}
joined := strings.Join(f.lastSpec.Args, " ")
if !strings.Contains(joined, "--session-id fixed-session-id") {
t.Errorf("a plain spawn lost its session id: %v", f.lastSpec.Args)
}
if strings.Contains(joined, "--resume") {
t.Errorf("a plain spawn resumed something: %v", f.lastSpec.Args)
}
}

Large diffs are not rendered by default.

Large diffs are not rendered by default.

4 changes: 2 additions & 2 deletions internal/api/dist/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -20,8 +20,8 @@
} catch (e) {}
})();
</script>
<script type="module" crossorigin src="/assets/index-BI85SuQu.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-BaqgCMsw.css">
<script type="module" crossorigin src="/assets/index-C_Ali4sj.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-JiLc92x0.css">
</head>
<body>
<div id="root"></div>
Expand Down
92 changes: 92 additions & 0 deletions ui/src/components/ContinueSession.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
import { useState } from 'react'
import { api, ApiError } from '@/lib/api'
import { navigate } from '@/lib/router'

/**
* Pick up a conversation that is not Caprock's to type into.
*
* Caprock never writes to a process it did not start — two writers on one PTY
* interleave characters and ruin both, which is what rule 7 protects. So a
* session someone started in their terminal is readable here and not usable,
* and until now the only thing to do with it was look.
*
* `claude --resume <id>` is the way through: it starts a *second* process on
* the same conversation, with the history read from disk. Nothing is taken
* from the terminal that already has it, and the new process is one Caprock
* started, so it can be typed into like any other.
*
* Two shapes, because the situation has two shapes:
*
* - **Continue** when the session has ended. One conversation, carried on.
* - **Branch** when it is still running somewhere. Two live processes sharing
* an id would write one transcript between them and each end up holding
* half the other's turns, so the copy gets a new id (`--fork-session`) and
* the original is left alone.
*
* The command is also offered for a terminal of one's own, because somebody
* who lives in tmux does not want a second place to type.
*/
export function ContinueSession({
sessionID,
cwd,
live,
}: {
sessionID: string
cwd: string
/** Whether the session is still running: decides continue vs branch. */
live: boolean
}) {
const [busy, setBusy] = useState(false)
const [copied, setCopied] = useState(false)
const [error, setError] = useState('')

const command = `claude --resume ${sessionID}`

async function open() {
setBusy(true)
setError('')
try {
const res = await api.spawn({ cwd, resume: sessionID, fork: live })
navigate({ name: 'session', id: res.session_id, tab: 'terminal' })
} catch (e) {
setError(e instanceof ApiError ? e.message : String(e))
} finally {
setBusy(false)
}
}

async function copy() {
try {
await navigator.clipboard.writeText(command)
setCopied(true)
window.setTimeout(() => setCopied(false), 2000)
} catch {
setError('Could not reach the clipboard. Select the command and copy it.')
}
}

return (
<span className="inline-flex items-center gap-2">
<button
onClick={open}
disabled={busy}
title={
live
? 'Open a branch of this conversation here — the original keeps running'
: 'Carry this conversation on, here'
}
className="text-[11px] border border-accent text-accent px-1.5 rounded-sm hover:bg-accent/10 disabled:opacity-50"
>
{busy ? 'opening…' : live ? 'branch here' : 'continue here'}
</button>
<button
onClick={copy}
title={command}
className="text-[11px] text-fg-faint hover:text-fg"
>
{copied ? 'copied' : 'copy command'}
</button>
{error && <span className="text-[11px] text-danger">{error}</span>}
</span>
)
}
8 changes: 8 additions & 0 deletions ui/src/lib/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -407,6 +407,14 @@ export interface SpawnRequest {
agent?: 'claude' | 'gemini'
cwd?: string; chat?: boolean; create?: boolean; worktree?: string
model?: string; permission_mode?: string; args?: string[]
/** Continue an existing conversation instead of starting a new one. Caprock
* cannot type into a process it did not start, so picking a session up
* means starting a second one on the same history. */
resume?: string
/** Branch rather than continue: a new session id for the copy, leaving the
* original alone. Needed when the session being picked up is still
* running, or both would write one transcript between them. */
fork?: boolean
}

async function post<T>(path: string, body: unknown, method = 'POST'): Promise<T> {
Expand Down
11 changes: 11 additions & 0 deletions ui/src/screens/Session.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import { TerminalView } from '@/components/Terminal'
import { costBasisLong } from '@/components/CostBasis'
import { agentName } from '@/components/Projects'
import { usePlan } from '@/components/PlanPicker'
import { ContinueSession } from '@/components/ContinueSession'

type Tab = 'timeline' | 'notes' | 'changes' | 'terminal'

Expand Down Expand Up @@ -66,6 +67,16 @@ export function SessionScreen({ id, tab, at }: { id: string; tab?: string; at?:
{s.git_branch && <span className="mono text-[11px] text-fg-muted">{s.git_branch}</span>}
<Badge health={s.activity.health} />
{s.owned && s.status !== 'ended' && <OwnedControls id={id} />}
{/* A session Caprock did not start is readable and not typeable —
* rule 7, and for a good reason: two writers on one PTY interleave.
* What it can do is start a second process on the same conversation,
* which is what this offers. Only for Claude Code: Gemini has its own
* --resume with different semantics, and offering a button that means
* something slightly different per agent is worse than not offering
* it yet. */}
{!s.owned && (s.agent ?? 'claude') === 'claude' && (
<ContinueSession sessionID={s.session_id} cwd={s.cwd} live={s.status !== 'ended'} />
)}
<span className="text-[12px] text-fg-muted ml-auto num">{s.cwd}</span>
</div>
<div className="text-[13px]">
Expand Down
Loading