diff --git a/CHANGELOG.md b/CHANGELOG.md index f7eb101..a283efa 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,17 @@ new version heading in the same commit. ## [Unreleased] +## [0.193.0] — 2026-07-14 +### Added +- **Transfer a session to another owner.** A session's accountable human — its `run_as` — can now be + reassigned from the Sessions list. Each session's action row gains a **Transfer** control (a member + picker; labeled button in the grid view, icon in the list view) that hands the run to another teammate: + ownership, the "mine" filter, the owner chip, and connectors/identity of any future effect all follow + the new `run_as`, while provenance (`spawned_by` — what originally triggered the run) is left untouched. + Gated to an owner/admin or the session's current owner (mirrored server-side), and every transfer is + audited `session.transferred` (`{from, to, agent}`). New `TerminalManager.transferSession` + + `POST /api/sessions/:id/transfer`; no schema change (`run_as` already existed). + ## [0.192.0] — 2026-07-14 ### Changed - **The browser terminal now selects and clicks like a web page — no more Option-drag, and links work.** diff --git a/package-lock.json b/package-lock.json index 4378b39..b1f9712 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "agent-os", - "version": "0.192.0", + "version": "0.193.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "agent-os", - "version": "0.192.0", + "version": "0.193.0", "license": "MIT", "bin": { "agent-os": "bin/agent-os" diff --git a/package.json b/package.json index 40da21a..9386ab6 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "agent-os", - "version": "0.192.0", + "version": "0.193.0", "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/server.ts b/src/server.ts index e1e961e..63a7e04 100644 --- a/src/server.ts +++ b/src/server.ts @@ -2082,6 +2082,21 @@ async function handle(os: AgentOS, tm: TerminalManager, autos: Automations, req: const r = tm.renameSession(id, me, String((b as { title?: unknown }).title ?? '')); return sendJson(res, r.ok ? 200 : 400, r); } + // Transfer a session to another owner — reassign its run-as (the accountable human). Beyond the view + // gate, this changes accountability, so restrict it to an owner/admin OR the session's current owner + // (a member handing off their own run). The target is any real member; the tm method validates it. + const transferMatch = p.match(/^\/api\/sessions\/([\w-]+)\/transfer$/); + if (method === 'POST' && transferMatch) { + const id = transferMatch[1]; + if (!tm.sessionAgent(id)) return sendJson(res, 404, { error: 'unknown session' }); + if (!tm.canViewSession(id, me)) return sendJson(res, 403, { error: 'not allowed to manage this session' }); + if (!isAdmin(me) && tm.sessionRunAs(id) !== me.id) return sendJson(res, 403, { error: 'only an owner/admin or the current owner can transfer this session' }); + const b = await readBody(req); + const to = String((b as { to?: unknown }).to ?? '').trim(); + if (!to) return sendJson(res, 400, { error: 'to (member id) is required' }); + const r = tm.transferSession(id, me, to); + return sendJson(res, r.ok ? 200 : 400, r); + } // Deliberately resume a stopped session: lift the stop-block so the ttyd attach wrapper resurrects it // (`claude --resume`) on the next reconnect. The actual relaunch happens in attach.sh when the terminal // (re)connects; this just clears the "stay stopped" sentinel. Same per-member gate as stop. diff --git a/src/terminal.ts b/src/terminal.ts index 3866f7c..d609c9e 100644 --- a/src/terminal.ts +++ b/src/terminal.ts @@ -3300,6 +3300,24 @@ export class TerminalManager { return { ok: true, title: clean }; } + /** Hand a session off to another member: reassign its `run_as` — the accountable human it acts as, and + * the key its ownership/visibility (`ownsSession`, the sessions list "mine" filter, connectors/identity + * of any FUTURE effect) derive from. Provenance (`spawned_by`) is left untouched — the record of what + * originally triggered the run doesn't change, only who now owns it going forward. The target must be a + * real member; a no-op transfer (already owned by them) succeeds quietly. Audited `session.transferred`. + * The caller applies the ownership gate (owner/admin or current owner). */ + transferSession(sessionId: string, by: Member, toMemberId: string): { ok: boolean; error?: string; runAs?: string } { + const r = this.db.prepare('SELECT id, agent, run_as FROM term_sessions WHERE id = ?') + .get<{ id: string; agent: string; run_as: string | null }>(sessionId); + if (!r) return { ok: false, error: 'unknown session' }; + const target = this.os.team.getMember(toMemberId); + if (!target) return { ok: false, error: 'unknown member' }; + if (r.run_as === target.id) return { ok: true, runAs: target.id }; + this.db.prepare('UPDATE term_sessions SET run_as = ?, updated_at = ? WHERE id = ?').run(target.id, Date.now(), sessionId); + this.audit(sessionId, by.email, 'session.transferred', { from: r.run_as, to: target.id, agent: r.agent }); + return { ok: true, runAs: target.id }; + } + /** Path of a session's "do not auto-resurrect" sentinel (see stopSession / attach.sh). */ private stopMarkerPath(sessionId: string): string | null { return this.os.paths ? path.join(this.os.paths.connectors, `session-${sessionId}.stopped`) : null; diff --git a/web/src/App.tsx b/web/src/App.tsx index f2dd379..876048a 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -937,6 +937,14 @@ function Console({ me }: { me: Member }) { const r = await api.renameSession(id, t) if (r.title) setSessions((prev) => prev.map((s) => (s.id === id ? { ...s, title: r.title! } : s))) } + // Hand a session to another owner — reassign its run-as. Optimistic (the owner chip updates instantly); + // the poll reconciles with the server (which also refreshes runAsLabel). + const transferSession = async (id: string, to: string) => { + setSessions((prev) => prev.map((s) => (s.id === id ? { ...s, runAs: to, runAsLabel: members.find((m) => m.id === to)?.name ?? to } : s))) + const r = await api.transferSession(id, to) + if (r.error) alert(r.error) + setSessions(await api.sessions()) + } const deleteSession = async (id: string, tmux: string) => { if (!confirm('Delete this session? Its inbox messages and transcript files are removed; the audit log is kept.')) return await api.deleteSession(id) @@ -1200,7 +1208,7 @@ function Console({ me }: { me: Member }) {