From b0c7b6493b093f29351562bcd2a7dba118c6a933 Mon Sep 17 00:00:00 2001 From: garrytan Date: Sat, 31 Oct 2026 23:13:03 +0000 Subject: [PATCH] browse: per-session tabs for parallel agents; tab pins stop moving the shared active tab (#1961) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> --- bin/gstack-doctor-components.sh | 4 + browse/SKILL.md | 2 + browse/SKILL.md.tmpl | 2 + browse/src/browse-client.ts | 6 +- browse/src/browser-manager.ts | 77 ++++-- browse/src/cli.ts | 12 +- browse/src/meta-commands.ts | 2 +- browse/src/routes/commands.ts | 2 +- browse/src/server.ts | 36 +-- browse/src/session-name.ts | 98 ++++++++ browse/src/session-tabs.ts | 149 ++++++++++++ browse/test/session-name.test.ts | 79 +++++++ browse/test/session-tabs.test.ts | 219 ++++++++++++++++++ .../_lib/browse-client.ts | 6 +- changelog.d/gsta227-tabs-touch.md | 6 + docs/troubleshooting.md | 38 +++ lib/result-codes-browse.ts | 32 +++ lib/result-codes.ts | 2 + test/fixtures/module-size-ratchet.json | 3 + 19 files changed, 725 insertions(+), 50 deletions(-) create mode 100644 browse/src/session-name.ts create mode 100644 browse/src/session-tabs.ts create mode 100644 browse/test/session-name.test.ts create mode 100644 browse/test/session-tabs.test.ts create mode 100644 changelog.d/gsta227-tabs-touch.md create mode 100644 lib/result-codes-browse.ts diff --git a/bin/gstack-doctor-components.sh b/bin/gstack-doctor-components.sh index 3fa027cb3..69004040e 100644 --- a/bin/gstack-doctor-components.sh +++ b/bin/gstack-doctor-components.sh @@ -350,6 +350,10 @@ gstack_result_anchor() { PROJECT_FLOOR_UNMET) printf '%s' 'project-floor-unmet' ;; PROJECT_NODE_PIN_UNMET) printf '%s' 'project-node-pin-unmet' ;; UPGRADE_SKIPPED) printf '%s' 'upgrade-skipped' ;; + BROWSE_SESSION_INVALID) printf '%s' 'browse-session-invalid' ;; + BROWSE_SESSION_ROOT_ONLY) printf '%s' 'browse-session-root-only' ;; + BROWSE_SESSION_TAB_RESET) printf '%s' 'browse-session-tab-reset' ;; + BROWSE_SESSION_LIMIT) printf '%s' 'browse-session-limit' ;; BUDGET_CAP_REACHED) printf '%s' 'budget-cap-reached' ;; BUDGET_READING_MISSING) printf '%s' 'budget-reading-missing' ;; BUDGET_READING_REJECTED) printf '%s' 'budget-reading-rejected' ;; diff --git a/browse/SKILL.md b/browse/SKILL.md index e5792ca58..778669fcc 100644 --- a/browse/SKILL.md +++ b/browse/SKILL.md @@ -465,5 +465,7 @@ else `$B` can do — extraction, tabs, dialogs, uploads, meta/server commands, a full snapshot-flag reference — lives in the generated section below. Read it before reaching for a `$B` command that is not in the table. +Parallel agents each get their own `$B` tab automatically (one session per agent process). Subagents sharing one process set `BROWSE_SESSION=` to get separate tabs; cookies stay shared. + > **STOP.** Before using any command or snapshot flag beyond the Browser fallback translation table — the full generated reference for every browse command, its argument shape, and every snapshot flag, Read `~/.claude/skills/gstack/browse/sections/command-list.md` and execute it > in full. Do not work from memory — that section is the source of truth for this step. diff --git a/browse/SKILL.md.tmpl b/browse/SKILL.md.tmpl index 0655b388a..80eaf1cb8 100644 --- a/browse/SKILL.md.tmpl +++ b/browse/SKILL.md.tmpl @@ -125,4 +125,6 @@ else `$B` can do — extraction, tabs, dialogs, uploads, meta/server commands, a full snapshot-flag reference — lives in the generated section below. Read it before reaching for a `$B` command that is not in the table. +Parallel agents each get their own `$B` tab automatically (one session per agent process). Subagents sharing one process set `BROWSE_SESSION=` to get separate tabs; cookies stay shared. + {{SECTION:command-list}} diff --git a/browse/src/browse-client.ts b/browse/src/browse-client.ts index 499b366f7..9b37e7182 100644 --- a/browse/src/browse-client.ts +++ b/browse/src/browse-client.ts @@ -42,6 +42,8 @@ export interface BrowseClientOptions { token?: string; /** Tab id to target (every command can scope to a tab). Default: BROWSE_TAB env or undefined (active tab). */ tabId?: number; + /** Session whose own tab runs every command (root token only; tabId wins when both are set). Default: none. */ + session?: string; /** Per-request timeout in milliseconds. Default: 30_000. */ timeoutMs?: number; /** Override state-file path. Default: BROWSE_STATE_FILE env or /.gstack/browse.json. */ @@ -133,6 +135,7 @@ export class BrowseClient { readonly port: number; readonly token: string; readonly tabId?: number; + readonly session?: string; readonly timeoutMs: number; constructor(opts: BrowseClientOptions = {}) { @@ -140,6 +143,7 @@ export class BrowseClient { this.port = auth.port; this.token = auth.token; this.tabId = opts.tabId ?? parseIntegerEnvValue(process.env.BROWSE_TAB); + this.session = opts.session; this.timeoutMs = opts.timeoutMs ?? 30_000; } @@ -150,7 +154,7 @@ export class BrowseClient { const body = JSON.stringify({ command: cmd, args, - ...(this.tabId !== undefined && !isNaN(this.tabId) ? { tabId: this.tabId } : {}), + ...(this.tabId !== undefined && !isNaN(this.tabId) ? { tabId: this.tabId } : this.session ? { session: this.session } : {}), }); let resp: Response; diff --git a/browse/src/browser-manager.ts b/browse/src/browser-manager.ts index a6ff2a7c8..e5fd30ba9 100644 --- a/browse/src/browser-manager.ts +++ b/browse/src/browser-manager.ts @@ -16,6 +16,7 @@ */ import type { ChildProcess } from 'node:child_process'; +import { AsyncLocalStorage } from 'node:async_hooks'; import { chromium, type Browser, type BrowserContext, type BrowserContextOptions, type Page, type Locator, type Cookie } from 'playwright'; import { writeSecureFile, mkdirSecure } from './file-permissions'; import { addConsoleEntry, addNetworkEntry, addDialogEntry, networkBuffer, type DialogEntry } from './buffers'; @@ -355,6 +356,9 @@ export interface BrowserState { }>; } +/** The tab a scoped request runs on; owner set for session scopes (#1961). */ +export interface TabScope { tabId: number; owner?: string } + export class BrowserManager { private browser: Browser | null = null; private context: BrowserContext | null = null; @@ -366,6 +370,14 @@ export class BrowserManager { private pages: Map = new Map(); private tabSessions: Map = new Map(); private activeTabId: number = 0; + /** + * The tab one request runs on (#1961). A request that names a tab or a + * session runs inside this scope, so every getActiveSession() under it, after + * any await, resolves to that tab and never moves activeTabId for other + * callers. A scope with an owner follows that owner's tab across a context + * rebuild, which gives every tab a new id. + */ + private readonly tabScope = new AsyncLocalStorage(); private nextTabId: number = 1; private extraHeaders: Record = {}; private customUserAgent: string | null = null; @@ -1204,11 +1216,14 @@ export class BrowserManager { const id = this.nextTabId++; this.pages.set(id, page); this.tabSessions.set(id, new TabSession(page)); - this.activeTabId = id; + const scope = this.tabScope.getStore(); + if (scope) scope.tabId = id; + else this.activeTabId = id; // Record tab ownership for multi-agent isolation - if (clientId) { - this.tabOwnership.set(id, clientId); + const owner = clientId ?? scope?.owner; + if (owner) { + this.tabOwnership.set(id, owner); } // Wire up console/network/dialog capture @@ -1222,9 +1237,10 @@ export class BrowserManager { } async closeTab(id?: number): Promise { - const tabId = id ?? this.activeTabId; + const tabId = id ?? this.getActiveTabId(); const page = this.pages.get(tabId); if (!page) throw new Error(`Tab ${tabId} not found`); + const scope = this.tabScope.getStore(); // Capture BEFORE close(): the page 'close' event handler wired in // wirePageEvents() can fire while page.close() is awaited. It removes @@ -1234,15 +1250,19 @@ export class BrowserManager { // close() resolves varies across Playwright/Chromium versions and // machines, and losing the race means the last-tab auto-create below // never runs, leaving the manager with zero tabs. - const wasActive = tabId === this.activeTabId; + const wasActive = tabId === this.getActiveTabId(); await page.close(); this.pages.delete(tabId); this.tabSessions.delete(tabId); this.tabOwnership.delete(tabId); - // Switch to another tab if we closed the active one - if (wasActive) { + // Switch to another tab if we closed the active one. A scoped request moves + // only its own scope, to another tab of the same owner when there is one. + if (wasActive && scope && this.pages.size > 0) { + const ownedTabs = scope.owner ? this.tabsOwnedBy(scope.owner) : []; + if (ownedTabs.length > 0) scope.tabId = ownedTabs[ownedTabs.length - 1]!; + } else if (wasActive) { const remaining = [...this.pages.keys()]; if (remaining.length === 0) { // No tabs left — create a new blank one @@ -1257,7 +1277,9 @@ export class BrowserManager { switchTab(id: number, opts?: { bringToFront?: boolean }): void { if (!this.tabSessions.has(id)) throw new Error(`Tab ${id} not found`); - this.activeTabId = id; + const scope = this.tabScope.getStore(); + if (scope) scope.tabId = id; + else this.activeTabId = id; // Only bring to front when explicitly requested (user-initiated tab switch). // Internal tab pinning (BROWSE_TAB) should NOT steal focus. if (opts?.bringToFront !== false) { @@ -1311,7 +1333,34 @@ export class BrowserManager { } getActiveTabId(): number { - return this.activeTabId; + const scope = this.tabScope.getStore(); + return scope ? this.resolveScopeTab(scope) : this.activeTabId; + } + + /** The scope's tab; an owned scope whose tab id went stale (context rebuild) moves to its owner's newest tab. */ + resolveScopeTab(scope: TabScope): number { + if (scope.owner && !(this.pages.has(scope.tabId) && this.tabOwnership.get(scope.tabId) === scope.owner)) { + const ownedTabs = this.tabsOwnedBy(scope.owner); + if (ownedTabs.length > 0) scope.tabId = ownedTabs[ownedTabs.length - 1]!; + } + return scope.tabId; + } + + tabUrl(tabId: number): string | null { + return this.pages.get(tabId)?.url() ?? null; + } + + /** Run fn with every active-tab lookup bound to scope.tabId (see tabScope). */ + runInTab(scope: TabScope, fn: () => Promise): Promise { + return this.tabScope.run(scope, fn); + } + + hasTab(tabId: number): boolean { + return this.pages.has(tabId); + } + + tabsOwnedBy(owner: string): number[] { + return [...this.tabOwnership].filter(([id, o]) => o === owner && this.pages.has(id)).map(([id]) => id).sort((a, b) => a - b); } getTabCount(): number { @@ -1388,7 +1437,8 @@ export class BrowserManager { * TAB_PROBE_TIMEOUT_MS: a tab whose renderer is stuck comes back with * `busy: true` and an empty title instead of hanging the list (#3247). */ - async getTabListWithTitles(): Promise> { + async getTabListWithTitles(): Promise> { + const activeId = this.getActiveTabId(); return Promise.all([...this.pages].map(async ([id, page]) => { const title = await withinTabBudget( this.sharedTabRead(page, 'title', () => page.title().catch(() => '')), TAB_PROBE_TIMEOUT_MS); @@ -1396,8 +1446,9 @@ export class BrowserManager { id, url: page.url(), title: title ?? '', - active: id === this.activeTabId, + active: id === activeId, busy: title === undefined, + owner: this.tabOwnership.get(id) ?? null, }; })); } @@ -1413,7 +1464,7 @@ export class BrowserManager { * true when a new tab had to be opened. */ async ensureActiveTab(): Promise { - if (this.tabSessions.has(this.activeTabId)) return false; + if (this.tabSessions.has(this.getActiveTabId())) return false; const remaining = [...this.tabSessions.keys()]; if (remaining.length > 0) { this.activeTabId = remaining[remaining.length - 1]!; @@ -1447,7 +1498,7 @@ export class BrowserManager { /** Get the TabSession for the active tab. */ getActiveSession(): TabSession { - const session = this.tabSessions.get(this.activeTabId); + const session = this.tabSessions.get(this.getActiveTabId()); if (!session) throw new Error('No active page. Use "browse goto " first.'); return session; } diff --git a/browse/src/cli.ts b/browse/src/cli.ts index 5bea3006e..1768fcad2 100644 --- a/browse/src/cli.ts +++ b/browse/src/cli.ts @@ -23,6 +23,7 @@ import { profileOwner, ensureProjectProfile, runProfilesCommand } from './chromi // Zero side effects on import (documented invariant in token-registry.ts) — // safe to pull the shared pairing default into the CLI. import { DEFAULT_PAIR_SCOPES } from './token-registry'; +import { chooseSession, extractSession, systemProcLookup } from './session-name'; const config = resolveConfig(); const IS_WINDOWS = process.platform === 'win32'; @@ -1088,14 +1089,17 @@ export async function commandTimeoutReport(state: { pid: number; port: number } // ─── Command Dispatch ────────────────────────────────────────── export async function sendCommand(state: ServerState, command: string, args: string[], retries = 0): Promise { - // Precedence: CLI --tab-id flag > BROWSE_TAB env var. + // Precedence: CLI --tab-id flag > BROWSE_TAB env var > --session > BROWSE_SESSION > agent harness. // make-pdf always passes --tab-id; human users typically rely on BROWSE_TAB // or the active tab. const extracted = extractTabId(args); - args = extracted.args; + const sessionFlag = extractSession(extracted.args); + args = sessionFlag.args; const envTab = process.env.BROWSE_TAB; const tabId = extracted.tabId ?? (envTab ? parseInt(envTab, 10) : undefined); - const body = JSON.stringify({ command, args, ...(tabId !== undefined && !isNaN(tabId) ? { tabId } : {}) }); + const hasTab = tabId !== undefined && !isNaN(tabId); + const { session, auto } = chooseSession({ hasTab, flag: sessionFlag.session, env: process.env, parentPid: process.ppid, lookup: systemProcLookup() }); + const body = JSON.stringify({ command, args, ...(hasTab ? { tabId } : {}), ...(session ? { session, ...(auto ? { sessionAuto: true } : {}) } : {}) }); const timeoutMs = command === 'cookie-import-browser' ? 90_000 : 30_000; try { @@ -1863,6 +1867,8 @@ Snapshot: snapshot [-i] [-c] [-d N] [-s sel] [-D] [-a] [-o path] [-C] Compare: diff Multi-step: chain (reads JSON from stdin) Tabs: tabs | tab | newtab [url] | closetab [id] + --session | BROWSE_SESSION=: this caller's own tab + (agents get one per agent process; BROWSE_SESSION= turns it off) Server: status | cookie = | header : useragent | stop | restart profiles [list] | profiles prune [--days N] (per-project headed profiles) diff --git a/browse/src/meta-commands.ts b/browse/src/meta-commands.ts index af49910a9..907bb999b 100644 --- a/browse/src/meta-commands.ts +++ b/browse/src/meta-commands.ts @@ -265,7 +265,7 @@ export async function handleMetaCommand( case 'tabs': { const tabs = await bm.getTabListWithTitles(); return tabs.map(t => - `${t.active ? '→ ' : ' '}[${t.id}] ${t.busy ? '(not responding)' : t.title || '(untitled)'} — ${t.url}` + `${t.active ? '→ ' : ' '}[${t.id}] ${t.busy ? '(not responding)' : t.title || '(untitled)'} — ${t.url}${t.owner ? ` (${t.owner})` : ''}` ).join('\n'); } diff --git a/browse/src/routes/commands.ts b/browse/src/routes/commands.ts index 9cc0f1a09..48e01ea0d 100644 --- a/browse/src/routes/commands.ts +++ b/browse/src/routes/commands.ts @@ -86,7 +86,7 @@ export const commandRoutes: RouteEntry[] = [ continue; } const cr = await ctx.commands.handleInternal( - { command: cmd.command, args: cmd.args, tabId: cmd.tabId }, + { command: cmd.command, args: cmd.args, tabId: cmd.tabId, session: cmd.session ?? body.session }, tokenInfo, { skipRateCheck: true, skipActivity: true }, ); diff --git a/browse/src/server.ts b/browse/src/server.ts index 5cb5507b8..06c479f7f 100644 --- a/browse/src/server.ts +++ b/browse/src/server.ts @@ -14,6 +14,7 @@ */ import { BrowserManager, markDaemonProcess } from './browser-manager'; +import { runInRequestTab } from './session-tabs'; import { handleReadCommand, hasOutArg } from './read-commands'; import { handleWriteCommand } from './write-commands'; import { handleMetaCommand } from './meta-commands'; @@ -968,11 +969,11 @@ interface CommandResult { * chainDepth: recursion guard — reject nested chains (depth > 0 means inside a chain) */ async function handleCommandInternalImpl( - body: { command: string; args?: string[]; tabId?: number }, + body: { command: string; args?: string[] }, tokenInfo?: TokenInfo | null, opts?: { skipRateCheck?: boolean; skipActivity?: boolean; chainDepth?: number }, ): Promise { - const { args = [], tabId } = body; + const { args = [] } = body; const rawCommand = body.command; if (!rawCommand) { @@ -1049,18 +1050,6 @@ async function handleCommandInternalImpl( if (!opts?.skipRateCheck && tokenInfo.token) recordCommand(tokenInfo.token); } - // Pin to a specific tab if requested (set by BROWSE_TAB env var, e.g. per-tab agent contexts). - // This prevents parallel agents from interfering with each other's tab context. - // Safe because Bun's event loop is single-threaded — no concurrent handleCommand. - let savedTabId: number | null = null; - if (tabId !== undefined && tabId !== null) { - savedTabId = browserManager.getActiveTabId(); - // bringToFront: false — internal tab pinning must NOT steal window focus - try { browserManager.switchTab(tabId, { bringToFront: false }); } catch (err: any) { - console.warn('[browse] Failed to pin tab', tabId, ':', err.message); - } - } - // ─── Tab ownership check (own-only tokens / pair-agent isolation) ── // // Only `own-only` tokens (pair-agent over tunnel) are bound to their own @@ -1070,7 +1059,7 @@ async function handleCommandInternalImpl( // // Skip for `newtab` — it creates a tab rather than accessing one. if (command !== 'newtab' && tokenInfo && tokenInfo.clientId !== 'root' && tokenInfo.tabPolicy === 'own-only') { - const targetTab = tabId ?? browserManager.getActiveTabId(); + const targetTab = browserManager.getActiveTabId(); if (!browserManager.checkTabAccess(targetTab, tokenInfo.clientId, { isWrite: isWriteInvocation(command, args), ownOnly: true })) { return { status: 403, json: true, @@ -1277,21 +1266,8 @@ async function handleCommandInternalImpl( }); browserManager.resetFailures(); - // Restore original active tab if we pinned to a specific one - if (savedTabId !== null) { - try { browserManager.switchTab(savedTabId, { bringToFront: false }); } catch (restoreErr: any) { - console.warn('[browse] Failed to restore tab after command:', restoreErr.message); - } - } return { status: 200, result: tabWarning ? `${tabWarning}\n${result}` : result }; } catch (err: any) { - // Restore original active tab even on error - if (savedTabId !== null) { - try { browserManager.switchTab(savedTabId, { bringToFront: false }); } catch (restoreErr: any) { - console.warn('[browse] Failed to restore tab after error:', restoreErr.message); - } - } - // Activity: emit command_end (error) — skipped for chain subcommands const errorDuration = Date.now() - startTime; if (!opts?.skipActivity) { @@ -1337,11 +1313,11 @@ async function handleCommandInternalImpl( * Do not bypass this by calling handleCommandInternalImpl directly. */ async function handleCommandInternal( - body: { command: string; args?: string[]; tabId?: number }, + body: { command: string; args?: string[]; tabId?: number; session?: string }, tokenInfo?: TokenInfo | null, opts?: { skipRateCheck?: boolean; skipActivity?: boolean; chainDepth?: number }, ): Promise { - const cr = await handleCommandInternalImpl(body, tokenInfo, opts); + const cr = await runInRequestTab(browserManager, body, tokenInfo, () => handleCommandInternalImpl(body, tokenInfo, opts)); return { ...cr, result: stripLoneSurrogates(cr.result) }; } diff --git a/browse/src/session-name.ts b/browse/src/session-name.ts new file mode 100644 index 000000000..59cdc4d63 --- /dev/null +++ b/browse/src/session-name.ts @@ -0,0 +1,98 @@ +/** + * Which session a CLI call belongs to (#1961), so parallel agents on one + * daemon each get their own tab. Precedence: an explicit tab (--tab-id, + * BROWSE_TAB) wins and sends no session; then --session ; then + * BROWSE_SESSION (empty or "off" turns sessions off); then the agent harness + * the call runs under. The harness is the first ancestor that is not a shell, + * the same rule as bin/gstack-harness-pid.sh: every tool call an agent makes + * starts a fresh shell under the same agent process, so its pid names the + * agent across calls without any exported variable surviving between them. + * Only known agent harnesses get an automatic session; a human terminal and a + * harness shared by every session on the machine (capy-server) keep today's + * shared active tab. + */ +import * as fs from 'node:fs'; +import { spawnSync } from 'node:child_process'; + +export interface ProcInfo { ppid: number; comm: string } +export type ProcLookup = (pid: number) => ProcInfo | null; +export interface SessionChoice { session?: string; auto?: boolean } + +const SHELLS = new Set(['bash', 'sh', 'zsh', 'dash', 'ksh', 'mksh', 'ash', 'fish', 'busybox']); +const AGENT_HARNESSES = new Set(['claude', 'codex', 'cursor-agent', 'gemini', 'opencode', 'amp', 'droid']); +const SHARED_HARNESSES = new Set(['capy-server']); + +/** Strip `--session ` from args, the way extractTabId strips --tab-id. */ +export function extractSession(args: string[]): { session: string | undefined; args: string[] } { + const stripped: string[] = []; + let session: string | undefined; + for (let i = 0; i < args.length; i++) { + if (args[i] === '--session') session = args[++i] ?? session; + else stripped.push(args[i]!); + } + return { session, args: stripped }; +} + +/** The first non-shell ancestor of `pid` (at most 8 hops), or null when the walk fails. */ +export function harnessOf(pid: number, lookup: ProcLookup): { pid: number; comm: string } | null { + let current = pid; + for (let hops = 0; hops < 8; hops++) { + const info = lookup(current); + if (!info) return null; + if (!SHELLS.has(info.comm)) return { pid: current, comm: info.comm }; + if (info.ppid <= 1) return null; + current = info.ppid; + } + return null; +} + +export function autoSessionName(env: NodeJS.ProcessEnv, parentPid: number, lookup: ProcLookup): string | undefined { + const harness = harnessOf(parentPid, lookup); + if (!harness || SHARED_HARNESSES.has(harness.comm)) return undefined; + if (!AGENT_HARNESSES.has(harness.comm) && env.CLAUDECODE !== '1') return undefined; + return `agent-${harness.pid}`; +} + +export function chooseSession(opts: { + hasTab: boolean; flag: string | undefined; env: NodeJS.ProcessEnv; parentPid: number; lookup: ProcLookup; +}): SessionChoice { + if (opts.hasTab) return {}; + if (opts.flag !== undefined) return opts.flag === '' || opts.flag === 'off' ? {} : { session: opts.flag }; + const fromEnv = opts.env.BROWSE_SESSION; + if (fromEnv !== undefined) return fromEnv === '' || fromEnv === 'off' ? {} : { session: fromEnv }; + const auto = autoSessionName(opts.env, opts.parentPid, opts.lookup); + return auto ? { session: auto, auto: true } : {}; +} + +function basename(comm: string): string { + const name = comm.trim().split('/').pop() ?? ''; + return name.startsWith('-') ? name.slice(1) : name; +} + +/** Linux reads /proc; macOS takes one `ps` table; anything else has no automatic session. */ +export function systemProcLookup(): ProcLookup { + if (process.platform === 'linux') { + return (pid) => { + try { + const stat = fs.readFileSync(`/proc/${pid}/stat`, 'utf8'); + const open = stat.indexOf('('); + const close = stat.lastIndexOf(')'); + const ppid = Number(stat.slice(close + 2).split(' ')[1]); + return Number.isFinite(ppid) ? { ppid, comm: basename(stat.slice(open + 1, close)) } : null; + } catch { return null; } + }; + } + if (process.platform !== 'darwin') return () => null; + let table: Map | null = null; + return (pid) => { + if (!table) { + table = new Map(); + const ps = spawnSync('ps', ['-A', '-o', 'pid=,ppid=,comm='], { encoding: 'utf8', timeout: 2000 }); + for (const line of (ps.stdout ?? '').split('\n')) { + const m = line.match(/^\s*(\d+)\s+(\d+)\s+(.+)$/); + if (m) table.set(Number(m[1]), { ppid: Number(m[2]), comm: basename(m[3]!) }); + } + } + return table.get(pid) ?? null; + }; +} diff --git a/browse/src/session-tabs.ts b/browse/src/session-tabs.ts new file mode 100644 index 000000000..fc0fd41f0 --- /dev/null +++ b/browse/src/session-tabs.ts @@ -0,0 +1,149 @@ +/** + * Per-session tabs for agents sharing one browse daemon (#1961). + * + * A request that names a session runs inside BrowserManager.runInTab with a + * scope owned by `session:`: the session's first command opens its own + * tab, and every later command, chain subcommand included, resolves to that + * tab without moving the daemon's global active tab. Sessions isolate tab + * routing only; cookies and storage stay shared by every tab in the browser. + * Only the root token may name a session, so a paired or tunnel agent cannot + * open tabs through it (a session the CLI derived on its own is ignored for + * them instead). A request that names a tab id runs in the same kind of + * scope with no owner, which replaces the old switch-then-restore pin. + */ +import type { BrowserManager, TabScope } from './browser-manager'; +import type { TokenInfo } from './token-registry'; +import { resultLine } from '../../lib/result-codes'; + +interface Result { status: number; result: string; json?: boolean; headers?: Record } + +interface SessionRecord extends TabScope { + owner: string; + lastUsed: number; + inFlight: number; + lastUrl: string; + opening: Promise | null; +} + +const SESSION_NAME = /^[A-Za-z0-9._:-]{1,64}$/; +const REOPENING_COMMANDS = new Set(['goto', 'newtab']); +const DAEMON_COMMANDS = new Set(['status', 'stop', 'restart']); +const DEFAULT_IDLE_MINUTES = 120; +const DEFAULT_MAX_SESSIONS = 16; + +const sessions = new Map(); + +function numberFromEnv(name: string, fallback: number): number { + const raw = process.env[name]; + if (raw === undefined || raw.trim() === '') return fallback; + const value = Number(raw); + return Number.isFinite(value) && value >= 0 ? value : fallback; +} + +function refuse(status: number, line: string): Result { + return { status, json: true, result: JSON.stringify({ error: line }) }; +} + +/** Close (headless) or release (headed) every tab of sessions idle past BROWSE_SESSION_IDLE_MINUTES (0 = never). */ +async function sweepIdle(bm: BrowserManager, now: number, keep: string): Promise { + const idleMinutes = numberFromEnv('BROWSE_SESSION_IDLE_MINUTES', DEFAULT_IDLE_MINUTES); + if (idleMinutes === 0) return; + for (const [name, record] of sessions) { + if (name === keep || record.inFlight > 0 || now - record.lastUsed < idleMinutes * 60_000) continue; + await dropSession(bm, name, record); + } +} + +async function dropSession(bm: BrowserManager, name: string, record: SessionRecord): Promise { + sessions.delete(name); + if (bm.getConnectionMode() === 'headed') { + bm.releaseClientTabs(record.owner); + return; + } + for (const id of bm.tabsOwnedBy(record.owner)) await bm.closeTab(id).catch(() => {}); +} + +/** Make room for session `name` by dropping the least recently used idle session. */ +async function makeRoom(bm: BrowserManager, name: string): Promise { + const others = [...sessions].filter(([n]) => n !== name); + if (others.length < numberFromEnv('BROWSE_MAX_SESSIONS', DEFAULT_MAX_SESSIONS)) return true; + const idle = others.filter(([, r]) => r.inFlight === 0 && !r.opening).sort((a, b) => a[1].lastUsed - b[1].lastUsed)[0]; + if (!idle) return false; + await dropSession(bm, idle[0], idle[1]); + return true; +} + +/** Open the session's tab once; concurrent first commands share the same promise. */ +function openTab(bm: BrowserManager, name: string, record: SessionRecord): Promise { + record.opening ??= (async () => { + if (!(await makeRoom(bm, name))) throw new Error('limit'); + await bm.runInTab(record, () => bm.newTab(undefined, record.owner)); + })().finally(() => { record.opening = null; }); + return record.opening; +} + +async function runInSession(bm: BrowserManager, name: string, command: string, fn: () => Promise): Promise { + const now = Date.now(); + await sweepIdle(bm, now, name); + let record = sessions.get(name); + let notice = ''; + try { + if (!record) { + record = { tabId: 0, owner: `session:${name}`, lastUsed: now, inFlight: 0, lastUrl: '', opening: null }; + sessions.set(name, record); + await openTab(bm, name, record); + notice = `[browse] session ${name} → tab ${record.tabId}`; + } else if (record.opening) { + await record.opening; + } else if (!bm.hasTab(bm.resolveScopeTab(record))) { + if (!REOPENING_COMMANDS.has(command)) { + const last = record.lastUrl || 'about:blank'; + return refuse(409, resultLine('browse', 'BROWSE_SESSION_TAB_RESET', `session ${name}'s tab ${record.tabId} was closed; it was on ${last}`)); + } + await openTab(bm, name, record); + notice = `[browse] session ${name}: its tab was closed, so this runs on new tab ${record.tabId}`; + } + } catch (err: any) { + if (record && !bm.hasTab(bm.resolveScopeTab(record))) sessions.delete(name); + if (err?.message !== 'limit') throw err; + return refuse(429, resultLine('browse', 'BROWSE_SESSION_LIMIT', `${sessions.size} sessions are running a command`)); + } + record.inFlight++; + record.lastUsed = Date.now(); + try { + const cr = await bm.runInTab(record, fn); + return notice && !cr.json ? { ...cr, result: `${notice}\n${cr.result}` } : cr; + } finally { + record.inFlight--; + record.lastUsed = Date.now(); + record.lastUrl = bm.tabUrl(bm.resolveScopeTab(record)) ?? record.lastUrl; + } +} + +/** + * Run one command on the tab its request names: `tabId` first (BROWSE_TAB, + * --tab-id), then `session`. A request naming neither runs in whatever scope + * it was called from, so chain subcommands inherit their parent's tab. + */ +export async function runInRequestTab( + bm: BrowserManager, + body: { command: string; tabId?: number; session?: string; sessionAuto?: boolean }, + tokenInfo: TokenInfo | null | undefined, + fn: () => Promise, +): Promise { + if (typeof body.tabId === 'number') { + if (bm.hasTab(body.tabId)) return bm.runInTab({ tabId: body.tabId }, fn); + console.warn('[browse] Failed to pin tab', body.tabId, ': not found'); + return fn(); + } + if (body.session === undefined || body.session === null || body.session === '' || DAEMON_COMMANDS.has(body.command)) return fn(); + if (tokenInfo && tokenInfo.clientId !== 'root') { + if (body.sessionAuto) return fn(); + return refuse(403, resultLine('browse', 'BROWSE_SESSION_ROOT_ONLY', `client ${tokenInfo.clientId}`)); + } + const name = String(body.session); + if (!SESSION_NAME.test(name)) { + return refuse(400, resultLine('browse', 'BROWSE_SESSION_INVALID', JSON.stringify(name.slice(0, 80)))); + } + return runInSession(bm, name, body.command, fn); +} diff --git a/browse/test/session-name.test.ts b/browse/test/session-name.test.ts new file mode 100644 index 000000000..f3d2d7e9c --- /dev/null +++ b/browse/test/session-name.test.ts @@ -0,0 +1,79 @@ +/** + * Which session a CLI call sends (#1961): precedence, the off switch, and the + * agent-harness walk that gives each agent its own session across fresh + * shells. The live case runs a real process named "claude" on Linux. + */ +import { describe, expect, test } from 'bun:test'; +import { spawnSync } from 'node:child_process'; +import * as fs from 'node:fs'; +import * as os from 'node:os'; +import * as path from 'node:path'; +import { autoSessionName, chooseSession, extractSession, harnessOf, type ProcLookup } from '../src/session-name'; + +const table = (rows: Record): ProcLookup => (pid) => { + const row = rows[pid]; + return row ? { ppid: row[0], comm: row[1] } : null; +}; +// 400 bash -> 300 sh -> 200 -> 1 +const under = (harness: string) => table({ 400: [300, 'bash'], 300: [200, 'sh'], 200: [1, harness] }); + +describe('extractSession', () => { + test('strips --session and its value anywhere in args', () => { + expect(extractSession(['https://x', '--session', 'a1', '--json'])).toEqual({ session: 'a1', args: ['https://x', '--json'] }); + expect(extractSession(['text'])).toEqual({ session: undefined, args: ['text'] }); + }); +}); + +describe('harness walk', () => { + test('skips shells to the first other ancestor', () => { + expect(harnessOf(400, under('claude'))).toEqual({ pid: 200, comm: 'claude' }); + }); + test('a known agent harness names the session by its pid', () => { + for (const h of ['claude', 'codex', 'cursor-agent']) expect(autoSessionName({}, 400, under(h))).toBe('agent-200'); + }); + test('node counts only with CLAUDECODE=1', () => { + expect(autoSessionName({}, 400, under('node'))).toBeUndefined(); + expect(autoSessionName({ CLAUDECODE: '1' }, 400, under('node'))).toBe('agent-200'); + }); + test('a human terminal and a shared harness get no session', () => { + expect(autoSessionName({}, 400, under('tmux: server'))).toBeUndefined(); + expect(autoSessionName({ CLAUDECODE: '1' }, 400, under('capy-server'))).toBeUndefined(); + expect(autoSessionName({}, 400, () => null)).toBeUndefined(); + }); +}); + +describe('chooseSession precedence', () => { + const base = { hasTab: false, flag: undefined, env: {}, parentPid: 400, lookup: under('claude') }; + test('an explicit tab wins and sends no session', () => { + expect(chooseSession({ ...base, hasTab: true, flag: 'a' })).toEqual({}); + }); + test('--session beats BROWSE_SESSION, which beats the harness', () => { + expect(chooseSession({ ...base, flag: 'f', env: { BROWSE_SESSION: 'e' } })).toEqual({ session: 'f' }); + expect(chooseSession({ ...base, env: { BROWSE_SESSION: 'e' } })).toEqual({ session: 'e' }); + expect(chooseSession(base)).toEqual({ session: 'agent-200', auto: true }); + }); + test('empty or off turns sessions off', () => { + expect(chooseSession({ ...base, env: { BROWSE_SESSION: '' } })).toEqual({}); + expect(chooseSession({ ...base, env: { BROWSE_SESSION: 'off' } })).toEqual({}); + expect(chooseSession({ ...base, flag: 'off' })).toEqual({}); + }); +}); + +describe.if(process.platform === 'linux')('live /proc walk', () => { + test('two fresh shells under one "claude" process get the same session, named by its pid', () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'session-name-')); + try { + const claude = path.join(dir, 'claude'); + fs.copyFileSync('/bin/bash', claude); + fs.chmodSync(claude, 0o755); + const probe = `${JSON.stringify(process.execPath)} -e 'import("${path.resolve(import.meta.dir, '../src/session-name.ts')}").then(m => console.log(m.autoSessionName({}, process.ppid, m.systemProcLookup())))'`; + const r = spawnSync(claude, ['-c', `echo HARNESS=$$; bash -c '${probe.replace(/'/g, `'\\''`)}'; sh -c '${probe.replace(/'/g, `'\\''`)}'; true`], { encoding: 'utf8', timeout: 30_000 }); + const harness = r.stdout.match(/HARNESS=(\d+)/)?.[1]; + const names = r.stdout.split('\n').filter(l => l.startsWith('agent-')); + expect(harness).toBeDefined(); + expect(names).toEqual([`agent-${harness}`, `agent-${harness}`]); + } finally { + fs.rmSync(dir, { recursive: true, force: true }); + } + }); +}); diff --git a/browse/test/session-tabs.test.ts b/browse/test/session-tabs.test.ts new file mode 100644 index 000000000..467adfd72 --- /dev/null +++ b/browse/test/session-tabs.test.ts @@ -0,0 +1,219 @@ +/** + * Per-session tabs (#1961) through the real command dispatch (server.ts + * handleCommandInternal on the module's own BrowserManager). Four sessions + * drive their own tabs at the same time; a slow fixture route forces the + * commands to overlap instead of relying on scheduler timing. The first two + * steps are the before: unsessioned callers share one tab, and the BROWSE_TAB + * pin moved the global active tab for everyone else while it ran. Runs in a + * child process so the module-level server state never touches this process. + */ +import { afterAll, beforeAll, describe, expect, test } from 'bun:test'; +import { spawnSync } from 'node:child_process'; +import * as fs from 'node:fs'; +import * as os from 'node:os'; +import * as path from 'node:path'; + +let dir = ''; +let steps: Record = {}; + +const SCRIPT = (root: string) => ` +import { __testInternals__ } from ${JSON.stringify(path.resolve(import.meta.dir, '../src/server.ts'))}; +const fixture = Bun.serve({ port: 0, hostname: '127.0.0.1', async fetch(req) { + const u = new URL(req.url); + const s = u.searchParams.get('s') || 'x'; + const ms = Number(u.searchParams.get('ms') || 0); + if (ms) await Bun.sleep(ms); + const html = '' + s + '

' + s + '

' + + ''; + return new Response(html, { headers: { 'Content-Type': 'text/html' } }); +} }); +const U = (s, ms = 0) => 'http://127.0.0.1:' + fixture.port + '/page?s=' + s + '&ms=' + ms; +const bm = __testInternals__.browserManager; +const run = (command, args = [], extra = {}, tokenInfo = null) => __testInternals__.handleCommandInternal({ command, args, ...extra }, tokenInfo); +const owned = (owner) => [...bm.pages.keys()].filter(id => bm.getTabOwner(id) === owner); +const steps = {}; +await bm.launch(); +await run('goto', [U('root')]); +const rootTab = bm.getActiveTabId(); + +{ // before: two unsessioned agents share the one active tab + const a = run('goto', [U('a', 400)]); + await Bun.sleep(100); + const b = run('goto', [U('b')]); + await Promise.all([a, b]); + steps.shared = { title: (await run('js', ['document.title'])).result }; + await run('goto', [U('root')]); +} + +{ // BROWSE_TAB pin: an unsessioned caller during the pinned command, and the global tab afterwards + const t2 = JSON.parse((await run('newtab', ['--json'])).result).tabId; + const t3 = JSON.parse((await run('newtab', ['--json'])).result).tabId; + bm.switchTab(rootTab, { bringToFront: false }); + const pinned = run('goto', [U('p', 400)], { tabId: t2 }); + await Bun.sleep(100); + const during = (await run('url')).result; + await pinned; + const p1 = run('goto', [U('p1', 200)], { tabId: t2 }); + await Bun.sleep(100); + const p2 = run('goto', [U('p2', 400)], { tabId: t3 }); + await Promise.all([p1, p2]); + steps.pin = { during, activeAfter: bm.getActiveTabId(), rootTab }; + await bm.closeTab(t2); await bm.closeTab(t3); + bm.switchTab(rootTab, { bringToFront: false }); +} + +{ // four sessions at once, three rounds, overlapping on the slow route + const names = ['a', 'b', 'c', 'd']; + const seen = []; + const flow = async (s, round) => { + await run('goto', [U(s, 60 * (names.indexOf(s) + 1) + 40 * round)], { session: s }); + const snap = (await run('snapshot', ['-i'], { session: s })).result; + await run('click', ['button'], { session: s }); + await run('fill', ['#q', 'hello-' + s], { session: s }); + const title = (await run('js', ['document.title'], { session: s })).result; + const value = (await run('js', ["document.querySelector('#q').value"], { session: s })).result; + seen.push({ s, round, snapOk: snap.includes('Go ' + s), title, value }); + }; + let unsessioned = ''; + for (let round = 0; round < 3; round++) { + const all = Promise.all(names.map(s => flow(s, round))); + await Bun.sleep(80); + unsessioned = (await run('url')).result; + await all; + } + steps.sessions = { seen, unsessioned, activeAfter: bm.getActiveTabId(), rootTab, + tabsPer: names.map(s => owned('session:' + s).length) }; +} + +{ // first use from four parallel commands opens one tab + await Promise.all([1, 2, 3, 4].map(() => run('url', [], { session: 'e' }))); + steps.firstUse = { tabs: owned('session:e').length }; +} + +{ // chain subcommands stay on the session tab + const r = await run('chain', [JSON.stringify([['goto', U('ch', 200)], ['js', 'document.title']])], { session: 'a' }); + steps.chain = { result: r.result, rootUrl: (await run('url')).result }; +} + +{ // per-session newtab and tabs + await run('newtab', [U('n2')], { session: 'c' }); + steps.newtab = { title: (await run('js', ['document.title'], { session: 'c' })).result, + tabsC: owned('session:c').length, activeAfter: bm.getActiveTabId(), rootTab, + tabs: (await run('tabs', [], { session: 'c' })).result }; +} + +{ // a closed session tab: reads fail with the reset code, goto reopens + const id = owned('session:a')[0]; + await bm.closeTab(id); + const text = await run('text', [], { session: 'a' }); + const goto = await run('goto', [U('a2')], { session: 'a' }); + steps.reset = { textStatus: text.status, text: text.result, gotoStatus: goto.status, goto: goto.result, + title: (await run('js', ['document.title'], { session: 'a' })).result }; +} + +{ // context recreation keeps the session's page + await run('goto', [U('rb')], { session: 'b' }); + const ua = await run('useragent', ['Mozilla/5.0 gstack-session-test'], { session: 'b' }); + steps.rebuild = { uaStatus: ua.status, url: (await run('url', [], { session: 'b' })).result, tabsB: owned('session:b').length }; +} + +{ // only root may name a session; names are validated + const scoped = { token: 't', clientId: 'agent-x', type: 'session', scopes: ['read', 'write', 'admin', 'meta'], tabPolicy: 'shared', rateLimit: 0, createdAt: new Date().toISOString(), commandCount: 0 }; + const denied = await run('url', [], { session: 'z' }, scoped); + const bad = await run('url', [], { session: 'bad name!' }); + steps.auth = { deniedStatus: denied.status, denied: denied.result, zTabs: owned('session:z').length, badStatus: bad.status, bad: bad.result }; +} + +{ // idle sessions are closed on the next session lookup + process.env.BROWSE_SESSION_IDLE_MINUTES = '0.01'; + await Bun.sleep(1200); + await run('url', [], { session: 'fresh' }); + steps.sweep = { a: owned('session:a').length, d: owned('session:d').length, fresh: owned('session:fresh').length }; +} + +await Bun.write(${JSON.stringify(path.join(root, 'steps.json'))}, JSON.stringify(steps)); +fixture.stop(true); +await Promise.race([bm.close(), Bun.sleep(3000)]); +process.exit(0); +`; + +beforeAll(() => { + dir = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'browse-session-tabs-'))); + fs.writeFileSync(path.join(dir, 'drive.ts'), SCRIPT(dir)); + const r = spawnSync(process.execPath, [path.join(dir, 'drive.ts')], { + encoding: 'utf8', timeout: 150_000, + env: { ...process.env, GSTACK_HOME: path.join(dir, 'state'), CHROMIUM_PROFILE: path.join(dir, 'profile'), + BROWSE_STATE_FILE: path.join(dir, 'state', 'browse.json'), BROWSE_PARENT_PID: '0' }, + }); + if (!fs.existsSync(path.join(dir, 'steps.json'))) throw new Error(`driver failed (${r.status}): ${r.stderr.slice(-2000)}`); + steps = JSON.parse(fs.readFileSync(path.join(dir, 'steps.json'), 'utf8')); +}, 180_000); +afterAll(() => { if (dir) fs.rmSync(dir, { recursive: true, force: true }); }); + +describe('before: callers without a session share one tab', () => { + test('the second agent navigates the first agent away (negative control)', () => { + expect(steps.shared.title).toBe('b'); + }); +}); + +describe('a pinned tab no longer moves the global active tab', () => { + test('an unsessioned caller during a pinned command still sees its own tab', () => { + expect(steps.pin.during).toContain('s=root'); + }); + test('overlapping pinned commands leave the global active tab where it was', () => { + expect(steps.pin.activeAfter).toBe(steps.pin.rootTab); + }); +}); + +describe('four sessions at once (#1961)', () => { + test('every session only ever sees its own page', () => { + expect(steps.sessions.seen).toHaveLength(12); + for (const row of steps.sessions.seen) { + expect(row).toMatchObject({ snapOk: true, title: row.s + '-clicked', value: 'hello-' + row.s }); + } + }); + test('each session owns one tab and the global active tab is untouched', () => { + expect(steps.sessions.tabsPer).toEqual([1, 1, 1, 1]); + expect(steps.sessions.activeAfter).toBe(steps.sessions.rootTab); + expect(steps.sessions.unsessioned).toContain('s=root'); + }); + test('parallel first commands of one session open a single tab', () => { + expect(steps.firstUse.tabs).toBe(1); + }); + test('chain subcommands run on the session tab', () => { + expect(steps.chain.result).toContain('ch'); + expect(steps.chain.rootUrl).toContain('s=root'); + }); + test('newtab inside a session moves only that session', () => { + expect(steps.newtab).toMatchObject({ title: 'n2', tabsC: 2 }); + expect(steps.newtab.activeAfter).toBe(steps.newtab.rootTab); + expect(steps.newtab.tabs).toContain('session:c'); + }); +}); + +describe('session lifecycle', () => { + test('a read on a closed session tab fails with the last URL; goto reopens it', () => { + expect(steps.reset.textStatus).not.toBe(200); + expect(steps.reset.text).toContain('BROWSE_SESSION_TAB_RESET'); + expect(steps.reset.text).toContain('s=ch'); + expect(steps.reset.gotoStatus).toBe(200); + expect(steps.reset.title).toBe('a2'); + }); + test('context recreation keeps the session on its page', () => { + expect(steps.rebuild.uaStatus).toBe(200); + expect(steps.rebuild.url).toContain('s=rb'); + expect(steps.rebuild.tabsB).toBe(1); + }); + test('a scoped token cannot name a session and opens no tab', () => { + expect(steps.auth.deniedStatus).toBe(403); + expect(steps.auth.denied).toContain('BROWSE_SESSION_ROOT_ONLY'); + expect(steps.auth.zTabs).toBe(0); + }); + test('an invalid session name is refused', () => { + expect(steps.auth.badStatus).toBe(400); + expect(steps.auth.bad).toContain('BROWSE_SESSION_INVALID'); + }); + test('idle sessions are closed on the next lookup', () => { + expect(steps.sweep).toEqual({ a: 0, d: 0, fresh: 1 }); + }); +}); diff --git a/browser-skills/hackernews-frontpage/_lib/browse-client.ts b/browser-skills/hackernews-frontpage/_lib/browse-client.ts index 499b366f7..9b37e7182 100644 --- a/browser-skills/hackernews-frontpage/_lib/browse-client.ts +++ b/browser-skills/hackernews-frontpage/_lib/browse-client.ts @@ -42,6 +42,8 @@ export interface BrowseClientOptions { token?: string; /** Tab id to target (every command can scope to a tab). Default: BROWSE_TAB env or undefined (active tab). */ tabId?: number; + /** Session whose own tab runs every command (root token only; tabId wins when both are set). Default: none. */ + session?: string; /** Per-request timeout in milliseconds. Default: 30_000. */ timeoutMs?: number; /** Override state-file path. Default: BROWSE_STATE_FILE env or /.gstack/browse.json. */ @@ -133,6 +135,7 @@ export class BrowseClient { readonly port: number; readonly token: string; readonly tabId?: number; + readonly session?: string; readonly timeoutMs: number; constructor(opts: BrowseClientOptions = {}) { @@ -140,6 +143,7 @@ export class BrowseClient { this.port = auth.port; this.token = auth.token; this.tabId = opts.tabId ?? parseIntegerEnvValue(process.env.BROWSE_TAB); + this.session = opts.session; this.timeoutMs = opts.timeoutMs ?? 30_000; } @@ -150,7 +154,7 @@ export class BrowseClient { const body = JSON.stringify({ command: cmd, args, - ...(this.tabId !== undefined && !isNaN(this.tabId) ? { tabId: this.tabId } : {}), + ...(this.tabId !== undefined && !isNaN(this.tabId) ? { tabId: this.tabId } : this.session ? { session: this.session } : {}), }); let resp: Response; diff --git a/changelog.d/gsta227-tabs-touch.md b/changelog.d/gsta227-tabs-touch.md new file mode 100644 index 000000000..c0180a172 --- /dev/null +++ b/changelog.d/gsta227-tabs-touch.md @@ -0,0 +1,6 @@ +--- +bump: minor +--- +- **Parallel agents no longer take over each other's browser tab.** When several agents drive gstack's own browser at once, each agent process now gets its own tab on its first command, and its `goto`, `snapshot`, `click` and chain commands stay on that tab (#1961). Name a session yourself with `--session ` or `BROWSE_SESSION=` (subagents that share one agent process need this); `BROWSE_SESSION=` turns it off. Sessions isolate tabs, not logins: cookies and storage stay shared. A session idle for 120 minutes is closed (`BROWSE_SESSION_IDLE_MINUTES`, 0 keeps it), at most 16 run at once (`BROWSE_MAX_SESSIONS`), and only the local root token can name one. `tabs` now shows each tab's owner. +- **`BROWSE_TAB` and `--tab-id` no longer move the active tab for everyone else.** A command pinned to a tab used to switch the daemon's active tab while it ran, so another caller's command during that window landed on the pinned tab, and two overlapping pinned commands could leave the daemon on the wrong tab. Each request now runs on its own tab without touching the shared one. +- New result codes, each with a troubleshooting entry: `BROWSE_SESSION_INVALID`, `BROWSE_SESSION_ROOT_ONLY`, `BROWSE_SESSION_TAB_RESET`, `BROWSE_SESSION_LIMIT`. diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index 74cda9b34..ac600bb06 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -2057,6 +2057,44 @@ command again. **Fixed in.** The first release after v1.91.101.0 (#3167). + + +### ``browse: the session name is not 1-64 letters, digits, dots, colons, underscores or dashes (""); fix: pick a name like agent-1 ... (BROWSE_SESSION_INVALID)`` + +**Meaning.** `BROWSE_SESSION` or `--session` holds a name browse will not use +as a tab owner. + +**Fix.** Use a short name such as `agent-1`. An empty `BROWSE_SESSION=` turns +sessions off and uses the shared active tab. + + +### ``browse: only the local root token may name a session ... (BROWSE_SESSION_ROOT_ONLY)`` + +**Meaning.** A paired or scoped agent sent a session name. Sessions are for +local agents that share the root token; scoped agents already get their own +tabs. + +**Fix.** Run `newtab` and pass the returned `tabId` on every command. + + +### ``browse: this session's tab was closed ... (session 's tab was closed; it was on ); fix: open the page again ... (BROWSE_SESSION_TAB_RESET)`` + +**Meaning.** The tab this session was working in is gone: someone ran +`closetab` on it, closed the window, or it sat idle past +`BROWSE_SESSION_IDLE_MINUTES` (default 120). browse refuses reads instead of +answering from a blank page. + +**Fix.** `browse goto ` in the same session opens a new tab for it, then +`snapshot` again before using `@refs`. + + +### ``browse: every session slot is running a command ... (BROWSE_SESSION_LIMIT)`` + +**Meaning.** `BROWSE_MAX_SESSIONS` sessions (default 16) are all mid-command, +so browse could not close an idle one to open a tab for a new session. + +**Fix.** Retry in a moment, or raise `BROWSE_MAX_SESSIONS`. + --- ## Windows diff --git a/lib/result-codes-browse.ts b/lib/result-codes-browse.ts new file mode 100644 index 000000000..efb6b5c68 --- /dev/null +++ b/lib/result-codes-browse.ts @@ -0,0 +1,32 @@ +/** + * Result codes for the browse daemon's per-agent features: per-session tabs + * (#1961) and phone emulation with touch input (#3058). Kept beside + * lib/result-codes.ts (which spreads this table into RESULT_CODES) so the + * owner module stays within the module-size ratchet; the same rules apply: + * one stable docs/troubleshooting.md anchor, a summary and a fix clause each. + */ +import type { ResultCode } from './result-codes'; + +export const BROWSE_RESULT_CODES = { + // Per-session tabs (#1961). + BROWSE_SESSION_INVALID: { + anchor: 'browse-session-invalid', + summary: 'the session name is not 1-64 letters, digits, dots, colons, underscores or dashes', + fix: 'pick a name like agent-1 (BROWSE_SESSION=agent-1 or --session agent-1)', + }, + BROWSE_SESSION_ROOT_ONLY: { + anchor: 'browse-session-root-only', + summary: 'only the local root token may name a session; paired and scoped agents get their own tab with newtab', + fix: 'drop the session field and run newtab, then pass its tabId on every command', + }, + BROWSE_SESSION_TAB_RESET: { + anchor: 'browse-session-tab-reset', + summary: 'this session\'s tab was closed (by closetab, the window, or the idle sweep), so the page it was reading is gone', + fix: 'open the page again with `browse goto ` in the same session, then snapshot again before using @refs', + }, + BROWSE_SESSION_LIMIT: { + anchor: 'browse-session-limit', + summary: 'every session slot is running a command, so no idle session could be closed to make room', + fix: 'retry when a command finishes, or raise BROWSE_MAX_SESSIONS (default 16)', + }, +} satisfies Record; diff --git a/lib/result-codes.ts b/lib/result-codes.ts index beaeb779b..c43d11627 100644 --- a/lib/result-codes.ts +++ b/lib/result-codes.ts @@ -12,6 +12,7 @@ */ import { RELEASE_RESULT_CODES } from './result-codes-release'; import { CAPY_RESULT_CODES } from './result-codes-capy'; +import { BROWSE_RESULT_CODES } from './result-codes-browse'; import { BUDGET_RESULT_CODES } from './result-codes-budget'; import { EVAL_RESULT_CODES } from './result-codes-eval'; @@ -761,6 +762,7 @@ export const RESULT_CODES = { }, ...RELEASE_RESULT_CODES, ...CAPY_RESULT_CODES, + ...BROWSE_RESULT_CODES, ...BUDGET_RESULT_CODES, ...EVAL_RESULT_CODES, } as const satisfies Record; diff --git a/test/fixtures/module-size-ratchet.json b/test/fixtures/module-size-ratchet.json index 8139bdb26..97e775221 100644 --- a/test/fixtures/module-size-ratchet.json +++ b/test/fixtures/module-size-ratchet.json @@ -68,6 +68,8 @@ { "file": "browse/src/routes/pty.ts", "movedFrom": "browse/src/server.ts" }, { "file": "browse/src/routes/table.ts", "movedFrom": "browse/src/server.ts" }, { "file": "browse/src/routes/tokens.ts", "movedFrom": "browse/src/server.ts" }, + { "file": "browse/src/session-tabs.ts", "movedFrom": "browse/src/server.ts (the BROWSE_TAB pin and restore, replaced by per-request tab scopes and sessions; GSTA-227)" }, + { "file": "browse/src/session-name.ts", "movedFrom": "(new: the CLI side of per-session tabs, the agent harness walk; GSTA-227)" }, { "file": "browse/src/routes/tunnel.ts", "movedFrom": "browse/src/server.ts" }, { "file": "scripts/resolvers/review-dashboard.ts", "movedFrom": "scripts/resolvers/review.ts" }, { "file": "scripts/resolvers/plan-gates.ts", "movedFrom": "scripts/resolvers/review.ts" }, @@ -103,6 +105,7 @@ { "file": "lib/result-codes-capy.ts", "movedFrom": "lib/result-codes.ts (the Capy rows: approval record, Captain brief, PR screenshots; GSTA-61)" }, { "file": "lib/result-codes-budget.ts", "movedFrom": "lib/result-codes.ts (the /ship token budget and handoff rows; GSTA-152)" }, { "file": "lib/result-codes-eval.ts", "movedFrom": "lib/result-codes.ts (the /eval-plan preregistration rows and the no-gstack arm; GSTA-168)" }, + { "file": "lib/result-codes-browse.ts", "movedFrom": "lib/result-codes.ts (the browse per-session tab and phone emulation rows; GSTA-227)" }, { "file": "lib/ship-budget.ts", "movedFrom": "(new: /ship token budget ledger, meters and admission, GSTA-152)" }, { "file": "lib/ship-budget-cli.ts", "movedFrom": "(new: bin/gstack-ship-budget start/check/settle/close/resume, GSTA-152)" }, { "file": "lib/ship-handoff.ts", "movedFrom": "(new: /ship fresh-context handoff, verify, return and reconcile, GSTA-152)" },