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
11 changes: 11 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.**
Expand Down
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
15 changes: 15 additions & 0 deletions src/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
18 changes: 18 additions & 0 deletions src/terminal.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
44 changes: 42 additions & 2 deletions web/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -1200,7 +1208,7 @@ function Console({ me }: { me: Member }) {
<div className={`min-h-0 flex-1 ${fullBleed ? '' : 'overflow-y-auto p-6'}`}>
{route === 'agents' && <AgentsPage me={me} agents={state?.agents ?? []} selected={detail} onSelect={(id) => nav('agents', id)} run={runAgent} onEdit={openAgent} onNew={() => nav('new-agent')} onDelete={deleteAgent} onDuplicate={duplicateAgent} onRescan={rescanAgents} onImport={importAgent} onRefresh={refreshState} />}
{route === 'new-agent' && <NewAgentPage me={me} onCreated={async (id) => { await refreshState(); nav('agents', id) }} />}
{route === 'sessions' && <SessionsPage me={me} members={members} sessions={sessions} waiting={waiting} selected={selected} hiddenTabs={hiddenTabs} onOpen={openTerminal} onCloseTab={closeTab} onActivity={clearAlerts} onSpawn={() => nav('agents')} onStop={stopSession} onDelete={deleteSession} onRate={rateSession} onRename={renameSession} onBulkStop={stopSessions} onBulkDelete={deleteSessions} urlQuery={urlQuery} onFiltersChange={setUrlQuery} />}
{route === 'sessions' && <SessionsPage me={me} members={members} sessions={sessions} waiting={waiting} selected={selected} hiddenTabs={hiddenTabs} onOpen={openTerminal} onCloseTab={closeTab} onActivity={clearAlerts} onSpawn={() => nav('agents')} onStop={stopSession} onDelete={deleteSession} onRate={rateSession} onRename={renameSession} onTransfer={transferSession} onBulkStop={stopSessions} onBulkDelete={deleteSessions} urlQuery={urlQuery} onFiltersChange={setUrlQuery} />}
{route === 'overview' && me.role === 'owner' && <OverviewPage me={me} sessions={sessions} messages={messages} members={members} agents={state?.agents ?? []} maturity={maturity} onOpen={openTerminal} nav={nav} />}
{route === 'inbox' && <InboxPage messages={messages} me={me} members={members} onOpen={openTerminal} onOpenArtifact={openArtifact} onOpenTask={(id) => nav('tasks', id)} onOpenGoal={(id) => nav('goals', id)} />}
{route === 'chat' && <ChatPage agents={state?.agents ?? []} sessions={sessions} messages={messages} selected={detail} onSelect={(id) => nav('chat', id)} />}
Expand Down Expand Up @@ -2096,8 +2104,37 @@ function EditableSessionTitle({ title, onRename }: { title: string; onRename: (t
)
}

// Hand a session to another owner — reassign its run-as (the accountable human). Visible only to an
// owner/admin or the session's current owner (mirrors the server gate); lists every other member. The
// `variant` matches the two Sessions views: a labeled button (grid) vs. a bare icon (list row).
function TransferMenu({ session, members, me, onTransfer, variant }: {
session: Session; members: Member[]; me: Member; onTransfer: (id: string, to: string) => void; variant: 'label' | 'icon'
}) {
if (me.role !== 'owner' && me.role !== 'admin' && session.runAs !== me.id) return null
const targets = members.filter((m) => m.id !== session.runAs)
if (targets.length === 0) return null
return (
<DropdownMenu>
<DropdownMenuTrigger
render={variant === 'label'
? <Button size="sm" variant="ghost" className="h-6 gap-1 px-2 text-xs" title="transfer — hand this session to another owner"><Users className="h-3 w-3" /> Transfer</Button>
: <Button size="icon" variant="ghost" className="h-7 w-7" title="transfer — hand this session to another owner"><Users className="h-3.5 w-3.5" /></Button>}
/>
<DropdownMenuContent align="end" className="max-h-64 overflow-y-auto">
<div className="px-2 py-1 text-[11px] text-muted-foreground">Transfer to…</div>
{targets.map((m) => (
<DropdownMenuItem key={m.id} className="gap-2 text-xs" onClick={() => onTransfer(session.id, m.id)}>
<MemberAvatar member={m} className="h-4 w-4 text-[9px]" />
<span className="truncate">{m.name}</span>
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
)
}

function SessionsPage({
me, members, sessions, waiting, selected, hiddenTabs, onOpen, onCloseTab, onActivity, onSpawn, onStop, onDelete, onRate, onRename, onBulkStop, onBulkDelete, urlQuery, onFiltersChange,
me, members, sessions, waiting, selected, hiddenTabs, onOpen, onCloseTab, onActivity, onSpawn, onStop, onDelete, onRate, onRename, onTransfer, onBulkStop, onBulkDelete, urlQuery, onFiltersChange,
}: {
me: Member
members: Member[]
Expand All @@ -2115,6 +2152,7 @@ function SessionsPage({
onDelete: (id: string, tmux: string) => void
onRate: (id: string, rating: 'up' | 'down' | null) => void
onRename: (id: string, title: string) => void
onTransfer: (id: string, toMemberId: string) => void
onBulkStop: (ids: string[]) => void
onBulkDelete: (ids: string[]) => void
/** Current hash-query string — the persisted filter state, read once to seed the filters. */
Expand Down Expand Up @@ -2576,6 +2614,7 @@ function SessionsPage({
<Button size="sm" variant="ghost" className="h-6 gap-1 px-2 text-xs" onClick={() => setInspect(s)} title="which agent-os primitives this session used">
<Activity className="h-3 w-3" /> Activity
</Button>
<TransferMenu session={s} members={members} me={me} onTransfer={onTransfer} variant="label" />
<Button size="sm" variant="ghost" className="h-6 gap-1 px-2 text-xs text-destructive" onClick={() => onDelete(s.id, s.tmux)} title="delete session + its messages/files">
<Trash2 className="h-3 w-3" /> Delete
</Button>
Expand Down Expand Up @@ -2648,6 +2687,7 @@ function SessionsPage({
<Button size="icon" variant="ghost" className="h-7 w-7" onClick={() => setInspect(s)} title="activity — which agent-os primitives this session used">
<Activity className="h-3.5 w-3.5" />
</Button>
<TransferMenu session={s} members={members} me={me} onTransfer={onTransfer} variant="icon" />
<Button size="icon" variant="ghost" className="h-7 w-7 text-destructive" onClick={() => onDelete(s.id, s.tmux)} title="delete session + its messages/files">
<Trash2 className="h-3.5 w-3.5" />
</Button>
Expand Down
3 changes: 3 additions & 0 deletions web/src/lib/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1002,6 +1002,9 @@ export const api = {
rateSession: (id: string, rating: 'up' | 'down' | null) => call<{ ok: boolean; error?: string }>('POST', `/api/sessions/${id}/rate`, { rating }),
/** Give a session a human-chosen display title (overrides the auto/AI-generated one). */
renameSession: (id: string, title: string) => call<{ ok: boolean; error?: string; title?: string }>('POST', `/api/sessions/${id}/rename`, { title }),
/** Hand a session to another owner — reassign its run-as (the accountable human). Owner/admin, or the
* session's current owner handing off their own run. `to` is the target member id. */
transferSession: (id: string, to: string) => call<{ ok: boolean; error?: string; runAs?: string }>('POST', `/api/sessions/${id}/transfer`, { to }),
/** Lift the stop-block so a stopped session resurrects (claude --resume) on the next terminal open. */
resumeSession: (id: string) => call<{ ok: boolean; error?: string }>('POST', `/api/sessions/${id}/resume`),
/** Take over a headless run: convert it to an attachable interactive session (claude --resume). Kills
Expand Down
Loading