From c4850fe6c1eb360e20a56e5e2fcd7683cbdf483c Mon Sep 17 00:00:00 2001 From: Jason Woltje Date: Sun, 15 Mar 2026 19:04:10 +0000 Subject: [PATCH] feat(cli): add sessions list/resume/destroy subcommands (#146) Co-authored-by: Jason Woltje Co-committed-by: Jason Woltje --- packages/cli/src/cli.ts | 111 ++++++++++++++++++++++++++++ packages/cli/src/tui/gateway-api.ts | 52 +++++++++++++ 2 files changed, 163 insertions(+) diff --git a/packages/cli/src/cli.ts b/packages/cli/src/cli.ts index 7333315..8ab7c23 100644 --- a/packages/cli/src/cli.ts +++ b/packages/cli/src/cli.ts @@ -106,6 +106,117 @@ program }, ); +// ─── sessions ─────────────────────────────────────────────────────────── + +const sessionsCmd = program.command('sessions').description('Manage active agent sessions'); + +sessionsCmd + .command('list') + .description('List active agent sessions') + .option('-g, --gateway ', 'Gateway URL', 'http://localhost:4000') + .action(async (opts: { gateway: string }) => { + const { loadSession, validateSession } = await import('./auth.js'); + const { fetchSessions } = await import('./tui/gateway-api.js'); + + const session = loadSession(opts.gateway); + if (!session) { + console.error('Not signed in. Run `mosaic login` first.'); + process.exit(1); + } + + const valid = await validateSession(opts.gateway, session.cookie); + if (!valid) { + console.error('Session expired. Run `mosaic login` again.'); + process.exit(1); + } + + try { + const result = await fetchSessions(opts.gateway, session.cookie); + if (result.total === 0) { + console.log('No active sessions.'); + return; + } + console.log(`Active sessions (${result.total}):\n`); + for (const s of result.sessions) { + const created = new Date(s.createdAt).toLocaleString(); + const durationSec = Math.round(s.durationMs / 1000); + console.log(` ID: ${s.id}`); + console.log(` Model: ${s.provider}/${s.modelId}`); + console.log(` Created: ${created}`); + console.log(` Prompts: ${s.promptCount}`); + console.log(` Duration: ${durationSec}s`); + if (s.channels.length > 0) { + console.log(` Channels: ${s.channels.join(', ')}`); + } + console.log(''); + } + } catch (err) { + console.error(err instanceof Error ? err.message : String(err)); + process.exit(1); + } + }); + +sessionsCmd + .command('resume ') + .description('Resume an existing agent session in the TUI') + .option('-g, --gateway ', 'Gateway URL', 'http://localhost:4000') + .action(async (id: string, opts: { gateway: string }) => { + const { loadSession, validateSession } = await import('./auth.js'); + + const session = loadSession(opts.gateway); + if (!session) { + console.error('Not signed in. Run `mosaic login` first.'); + process.exit(1); + } + + const valid = await validateSession(opts.gateway, session.cookie); + if (!valid) { + console.error('Session expired. Run `mosaic login` again.'); + process.exit(1); + } + + const { render } = await import('ink'); + const React = await import('react'); + const { TuiApp } = await import('./tui/app.js'); + + render( + React.createElement(TuiApp, { + gatewayUrl: opts.gateway, + conversationId: id, + sessionCookie: session.cookie, + }), + ); + }); + +sessionsCmd + .command('destroy ') + .description('Terminate an active agent session') + .option('-g, --gateway ', 'Gateway URL', 'http://localhost:4000') + .action(async (id: string, opts: { gateway: string }) => { + const { loadSession, validateSession } = await import('./auth.js'); + const { deleteSession } = await import('./tui/gateway-api.js'); + + const session = loadSession(opts.gateway); + if (!session) { + console.error('Not signed in. Run `mosaic login` first.'); + process.exit(1); + } + + const valid = await validateSession(opts.gateway, session.cookie); + if (!valid) { + console.error('Session expired. Run `mosaic login` again.'); + process.exit(1); + } + + try { + await deleteSession(opts.gateway, session.cookie, id); + console.log(`Session ${id} destroyed.`); + } catch (err) { + console.error(err instanceof Error ? err.message : String(err)); + process.exit(1); + } + }); + // ─── prdy ─────────────────────────────────────────────────────────────── const prdyWrapper = buildPrdyCli(); diff --git a/packages/cli/src/tui/gateway-api.ts b/packages/cli/src/tui/gateway-api.ts index d7d5693..787f158 100644 --- a/packages/cli/src/tui/gateway-api.ts +++ b/packages/cli/src/tui/gateway-api.ts @@ -15,6 +15,21 @@ export interface ProviderInfo { models: ModelInfo[]; } +export interface SessionInfo { + id: string; + provider: string; + modelId: string; + createdAt: string; + promptCount: number; + channels: string[]; + durationMs: number; +} + +export interface SessionListResult { + sessions: SessionInfo[]; + total: number; +} + /** * Fetch the list of available models from the gateway. * Returns an empty array on network or auth errors so the TUI can still function. @@ -60,3 +75,40 @@ export async function fetchProviders( return []; } } + +/** + * Fetch the list of active agent sessions from the gateway. + * Throws on network or auth errors. + */ +export async function fetchSessions( + gatewayUrl: string, + sessionCookie: string, +): Promise { + const res = await fetch(`${gatewayUrl}/api/sessions`, { + headers: { Cookie: sessionCookie, Origin: gatewayUrl }, + }); + if (!res.ok) { + const body = await res.text().catch(() => ''); + throw new Error(`Failed to list sessions (${res.status}): ${body}`); + } + return (await res.json()) as SessionListResult; +} + +/** + * Destroy (terminate) an agent session on the gateway. + * Throws on network or auth errors. + */ +export async function deleteSession( + gatewayUrl: string, + sessionCookie: string, + sessionId: string, +): Promise { + const res = await fetch(`${gatewayUrl}/api/sessions/${encodeURIComponent(sessionId)}`, { + method: 'DELETE', + headers: { Cookie: sessionCookie, Origin: gatewayUrl }, + }); + if (!res.ok && res.status !== 204) { + const body = await res.text().catch(() => ''); + throw new Error(`Failed to destroy session (${res.status}): ${body}`); + } +}