diff --git a/CHANGELOG.md b/CHANGELOG.md
index 314ae1d..77db760 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -8,6 +8,20 @@ new version heading in the same commit.
## [Unreleased]
+## [0.138.2] — 2026-07-13
+### Fixed
+- **Taking over an unattended session no longer breaks file attach with "session is not live."** Take-over
+ (`claimSession`, `POST /api/sessions/:id/interactive`) flipped `headless→0`, set `claimed_by`, and cleared
+ the resume sentinel — but, unlike the resume path (`markResumed`), it never set `status = 'running'`. A
+ take-over can race the Stop-hook turn-end teardown, which may have already moved the run to `done`; the
+ claimed-and-attached run then kept a terminal status, so everything gated on `status === 'running'` —
+ notably `attachFile` — rejected the now-live, steerable session as "not live." Take-over now forces
+ `status = 'running'` (the pane resurrects on re-open via the already-cleared sentinel), matching resume.
+ The console's 📎 attach/drag/paste gate also now keys off the pane being **attached/live** (the same
+ `isLive` rule the green dot uses, plus the just-took-over override) instead of raw `status`, so it stops
+ lagging a poll behind a take-over; the server stays the hard authority.
+ (`TerminalManager.claimSession`, `ImageDropZone` in `web/src/App.tsx`.)
+
## [0.138.1] — 2026-07-13
### Fixed
- **The Profile page now loads instead of bouncing to the Inbox.** `profile` was added to the `Route`
diff --git a/package-lock.json b/package-lock.json
index 71f78b3..81eadfe 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,12 +1,12 @@
{
"name": "agent-os",
- "version": "0.138.1",
+ "version": "0.138.2",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "agent-os",
- "version": "0.138.1",
+ "version": "0.138.2",
"license": "MIT",
"bin": {
"agent-os": "bin/agent-os"
diff --git a/package.json b/package.json
index d861d86..ed5604c 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "agent-os",
- "version": "0.138.1",
+ "version": "0.138.2",
"description": "A generic, governed operating system for running autonomous agents safely across brands. Ships with a local web console.",
"license": "MIT",
"type": "commonjs",
diff --git a/src/terminal.ts b/src/terminal.ts
index 45baafc..afc6390 100644
--- a/src/terminal.ts
+++ b/src/terminal.ts
@@ -1098,7 +1098,13 @@ export class TerminalManager {
if (row.claimed_by) return { ok: true }; // already taken over — the pane is already sticky/attachable
// A prior stop must not veto the deliberate take-over; clear any sentinel so a re-open resurrects.
this.allowResume(sessionId);
- this.db.prepare("UPDATE term_sessions SET headless = 0, claimed_by = ?, claimed_at = ?, updated_at = ? WHERE id = ?")
+ // Force status back to 'running' (like markResumed does for the resume path). A take-over can race the
+ // Stop-hook turn-end teardown, which may have already flipped an unattended run to 'done'; without this,
+ // the claimed run keeps a terminal status and everything gated on `status === 'running'` — notably
+ // attachFile ("session is not live") — wrongly rejects the now-attached, steerable session. The sentinel
+ // is already cleared above, so a re-open resurrects the pane; 'running' is the one flag resume set that
+ // claim was missing.
+ this.db.prepare("UPDATE term_sessions SET headless = 0, status = 'running', claimed_by = ?, claimed_at = ?, updated_at = ? WHERE id = ?")
.run(by, Date.now(), Date.now(), sessionId);
// No kill, no relaunch — the live pane keeps streaming; the caller opens ttyd and attaches to it.
this.audit(sessionId, by, 'session.claimed', { agent: row.agent });
diff --git a/web/src/App.tsx b/web/src/App.tsx
index 46dc2e9..a29dea2 100644
--- a/web/src/App.tsx
+++ b/web/src/App.tsx
@@ -1821,7 +1821,8 @@ function TerminalFrame({ session, tmux, onActivity }: { session?: Session; tmux:
{takingOver ? 'taking over…' : 'Take over'}
)}
- onActivity(session.id) : undefined}
+ onActivity(session.id) : undefined}
fontSize={fontSize} setFontSize={setFontSize}>
@@ -1835,14 +1836,17 @@ function TerminalFrame({ session, tmux, onActivity }: { session?: Session; tmux:
* (file paste). Each uploads the bytes; the server saves them in the agent's folder and types the path
* into the running claude. Now that the terminal is same-document (no iframe), drops/pastes over it
* bubble to our window handlers directly — no cross-document interception needed. */
-function ImageDropZone({ session, children, onActivity, fontSize, setFontSize }: {
- session?: Session; children: ReactNode; onActivity?: () => void
+function ImageDropZone({ session, attachable, children, onActivity, fontSize, setFontSize }: {
+ session?: Session; attachable?: boolean; children: ReactNode; onActivity?: () => void
fontSize: number; setFontSize: (f: number | ((s: number) => number)) => void
}) {
const [drag, setDrag] = useState(false)
const [help, setHelp] = useState(false)
const [toast, setToast] = useState<{ kind: 'ok' | 'err' | 'busy'; text: string } | null>(null)
- const live = session?.status === 'running'
+ // Gate on the pane being attached/live — the same `isLive` notion the green dot uses, plus the
+ // just-took-over override — NOT raw `status === 'running'`, which lags a poll behind a take-over and
+ // reads terminal on a still-attached run. The server (`attachFile`) stays the hard authority.
+ const live = Boolean(attachable)
const wrapRef = useRef(null)
// Keep the latest upload closure reachable from imperative (addEventListener) handlers without
// re-binding them every render.