From ffdc485631b92d33c43de487d8946b0bd478cbb8 Mon Sep 17 00:00:00 2001 From: Ned Twigg Date: Thu, 10 Sep 2026 04:24:33 -0700 Subject: [PATCH 01/13] Mint a unique first Workspace id for a fresh standalone Window MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every fresh Window installed the lib's `DEFAULT_WORKSPACE_ID`, so a second window opened after the first one closed wrote a blob naming a Workspace id that is already live in another window's blob; a relaunch then met the same id twice and the whole restore was refused. A fresh standalone Window now mints its own id; a bare Wall — one Window's whole application — keeps the default. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01QBf4rQbwuxV6n6v7E3twYe --- standalone/src/window-restore.test.ts | 26 ++++++++++++++++++++++++-- standalone/src/window-restore.ts | 24 ++++++++++++++++++++---- 2 files changed, 44 insertions(+), 6 deletions(-) diff --git a/standalone/src/window-restore.test.ts b/standalone/src/window-restore.test.ts index 7c1b1db63..65096b4b0 100644 --- a/standalone/src/window-restore.test.ts +++ b/standalone/src/window-restore.test.ts @@ -12,6 +12,7 @@ const registryMocks = vi.hoisted(() => ({ vi.mock("dormouse-lib/lib/terminal-registry", () => registryMocks); import type { PlatformAdapter, PtyInfo } from "dormouse-lib/lib/platform/types"; +import { DEFAULT_WORKSPACE_ID } from "dormouse-lib/lib/session-types"; import type { PersistedSession, PersistedWindow } from "dormouse-lib/lib/session-types"; import { forgetHelper, getHelper } from "dormouse-lib/lib/helper-terminal"; import { setPlatform } from "dormouse-lib/lib/platform"; @@ -169,10 +170,31 @@ describe("restoreWindowOrFresh", () => { const plans = await restoreWindowOrFresh(platform); - // One default Workspace, planned and renderable. - expect(Object.keys(plans)).toEqual(["workspace-1"]); + // One freshly minted Workspace, planned and renderable. + const [id] = getWorkspacesSnapshot().workspaces.map((workspace) => workspace.id); + expect(Object.keys(plans)).toEqual([id]); expect(getWorkspacesSnapshot().workspaces).toHaveLength(1); // And the blob that could not be restored is gone. expect(saves[saves.length - 1]?.workspaces).toEqual([]); }); + + it("mints a unique first Workspace id for every fresh Window", async () => { + // Two windows that both started fresh must not both hold `workspace-1`: + // each writes its own blob, and a relaunch would then meet the same + // Workspace id twice and refuse the whole restore. + const first = fakePlatform([], null); + const firstPlans = await restoreWindowOrFresh(first.platform); + const firstId = getWorkspacesSnapshot().workspaces[0].id; + + resetWorkspaces(); + resetWindowSessionAggregator(); + const second = fakePlatform([], null); + const secondPlans = await restoreWindowOrFresh(second.platform); + const secondId = getWorkspacesSnapshot().workspaces[0].id; + + expect(Object.keys(firstPlans)).toEqual([firstId]); + expect(Object.keys(secondPlans)).toEqual([secondId]); + expect(firstId).not.toBe(secondId); + expect(firstId).not.toBe(DEFAULT_WORKSPACE_ID); + }); }); diff --git a/standalone/src/window-restore.ts b/standalone/src/window-restore.ts index 247639c7f..34091cad3 100644 --- a/standalone/src/window-restore.ts +++ b/standalone/src/window-restore.ts @@ -17,8 +17,13 @@ import { installWindowSessionWriter, seedWindowSession, } from "dormouse-lib/lib/window-session-aggregator"; -import { resetWorkspaces, setWorkspaces } from "dormouse-lib/lib/workspace-store"; -import { DEFAULT_WORKSPACE_ID, windowPaneIds } from "dormouse-lib/lib/session-types"; +import { + generateWorkspaceId, + getWorkspacesSnapshot, + resetWorkspaces, + setWorkspaces, +} from "dormouse-lib/lib/workspace-store"; +import { DEFAULT_WORKSPACE_NAME, windowPaneIds } from "dormouse-lib/lib/session-types"; import type { PersistedSession, PersistedWindow, WorkspaceId } from "dormouse-lib/lib/session-types"; import { wallBootFromResult, type WallBootPlans } from "dormouse-lib/components/wall/wall-types"; @@ -117,6 +122,15 @@ export function installWindowPersistence( workspaces: saved.workspaces.map(({ id, name }) => ({ id, name })), activeId: saved.activeWorkspaceId, }); + } else { + // A fresh Window mints its first Workspace's id rather than taking the + // lib's `DEFAULT_WORKSPACE_ID`: every window would otherwise start on the + // same id, and a second window opened after the first one closed would + // write a blob whose Workspace id is already live in another window's blob + // (`docs/specs/standalone.md` → "Persistence"). A bare Wall, which is one + // Window's whole application, keeps the default id. + const id = generateWorkspaceId(); + setWorkspaces({ workspaces: [{ id, name: DEFAULT_WORKSPACE_NAME }], activeId: id }); } // After `setWorkspaces`, so installing does not immediately write back what was // just read. @@ -138,9 +152,11 @@ async function restoreWindow( const live: LivePtys = await collectLivePtys(platform, { ...(hasTerminalPanes ? { retryTimeoutMs: LIST_RETRY_MS } : {}), }); + // The fresh Window's id was minted by `installWindowPersistence` above. + const installed = getWorkspacesSnapshot(); const restoring: Array<{ id: WorkspaceId; session: PersistedSession | null }> = - saved?.workspaces ?? [{ id: DEFAULT_WORKSPACE_ID, session: null }]; - const activeId = saved?.activeWorkspaceId ?? DEFAULT_WORKSPACE_ID; + saved?.workspaces ?? [{ id: installed.activeId, session: null }]; + const activeId = saved?.activeWorkspaceId ?? installed.activeId; const { extra, unowned } = routeUnownedPtys(live.ptys, saved); From f61dd7c38a546c6d3d44ef8c82ae150dec5a3f69 Mon Sep 17 00:00:00 2001 From: Ned Twigg Date: Thu, 10 Sep 2026 04:29:43 -0700 Subject: [PATCH 02/13] Answer the workspace control verbs at the Window MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds `WORKSPACE_CONTROL_METHODS` beside the Surface methods, the `surface.list` scope param, and the window-level handler behind them: one row per Workspace with its union status, background create, rename/switch/close resolving `workspace:` (a name only when exactly one Workspace carries it), and a close that refuses running or touched work unless forced rather than raising a prompt no caller can see. Routing grows two steps: `--workspace` now resolves names, and a target named by its stable Surface id is answered by whichever Workspace holds it, since that handle — unlike `surface:N` — is unique across the Window. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01QBf4rQbwuxV6n6v7E3twYe --- dor/src/commands/types.ts | 106 ++++++++ dor/src/control-client.ts | 41 +++- dor/src/protocol.ts | 19 ++ .../wall/dor-control-router.test.ts | 46 ++++ lib/src/components/wall/dor-control-router.ts | 53 +++- lib/src/components/wall/use-dor-control.ts | 12 +- .../components/wall/workspace-control.test.ts | 231 ++++++++++++++++++ lib/src/components/wall/workspace-control.ts | 225 +++++++++++++++++ .../components/wall/workspace-lifecycle.ts | 18 +- lib/src/lib/workspace-store.test.ts | 31 ++- lib/src/lib/workspace-store.ts | 36 ++- 11 files changed, 790 insertions(+), 28 deletions(-) create mode 100644 lib/src/components/wall/workspace-control.test.ts create mode 100644 lib/src/components/wall/workspace-control.ts diff --git a/dor/src/commands/types.ts b/dor/src/commands/types.ts index ceafea495..f0613e867 100644 --- a/dor/src/commands/types.ts +++ b/dor/src/commands/types.ts @@ -83,12 +83,21 @@ export interface Surface { /** Listening ports opened by this terminal Surface. Present only when the * request set `includePorts` (`dor list --ports`); never on browser Surfaces. */ ports?: SurfacePort[]; + /** The Workspace this Surface belongs to. Present only for a `scope: 'all'` + * listing, where rows from several Workspaces share one list. */ + workspaceRef?: string; } +/** How wide a listing reaches: one Workspace (the default) or every Workspace + * in this Window. */ +export type ListScope = 'workspace' | 'all'; + export interface ListSurfacesRequest { pane?: string; workspace?: string; window?: string; + /** Omitted means `workspace`. */ + scope?: ListScope; /** Enumerate each terminal Surface's listening ports. The host shells out per * pane (lsof / PowerShell), so callers opt in; remote sessions report none. */ includePorts?: boolean; @@ -96,11 +105,76 @@ export interface ListSurfacesRequest { export interface ListSurfacesResponse { surfaces: Surface[]; + /** The Workspace that answered; for `scope: 'all'`, the one the request + * landed in. */ workspaceRef: string; windowRef: string; + /** Present only for `scope: 'all'`: this Window's Workspaces in strip order, + * so the caller can render a header for each group of `surfaces`. */ + workspaces?: WorkspaceRow[]; +} + +/** One Workspace of this Window, as `dor list --workspaces` prints it: its + * positional ref and name, whether it is the active one, and the union status + * over its member Surfaces (`docs/specs/alert.md` → Workspace union). */ +export interface WorkspaceRow { + ref: string; + id: string; + name: string; + active: boolean; + ringing: boolean; + todo: boolean; + /** Member Surfaces owing attention (ringing or TODO); each counts once. */ + count: number; +} + +export interface ListWorkspacesRequest { + window?: string; +} + +export interface ListWorkspacesResponse { + workspaces: WorkspaceRow[]; + windowRef: string; +} + +export interface NewWorkspaceRequest { + /** Defaults host-side to the next `Workspace N`. */ + name?: string; + window?: string; +} + +export interface RenameWorkspaceRequest { + workspace: string; + name: string; + window?: string; +} + +export interface CloseWorkspaceRequest { + workspace: string; + /** Close even though the Workspace holds touched or running Surfaces. */ + force: boolean; + window?: string; +} + +export interface SwitchWorkspaceRequest { + workspace: string; + window?: string; +} + +/** The answer every mutating Workspace verb gives: what it did, and the + * Workspace it did it to. `workspaceRef` is positional, so for `close` it is + * the ref the Workspace had. */ +export interface WorkspaceMutationResponse { + status: 'created' | 'renamed' | 'closed' | 'active'; + workspaceId: string; + workspaceRef: string; + name: string; } export interface SplitSurfaceRequest { + /** Act in this Workspace instead of the caller's (`dor --workspace `). + * Resolved by the router before caller ownership. */ + workspace?: string; /** Raw argv for the initial command; the host quotes it for the target shell. */ command?: string[]; direction: SplitDirection; @@ -123,6 +197,9 @@ export interface SplitSurfaceResponse { } export interface EnsureSurfaceRequest { + /** Act in this Workspace instead of the caller's (`dor --workspace `). + * Resolved by the router before caller ownership. */ + workspace?: string; /** Raw argv for the command; the host quotes it for the target shell. */ command: string[]; minimized: boolean; @@ -143,6 +220,9 @@ export interface EnsureSurfaceResponse { } export interface SendSurfaceRequest { + /** Act in this Workspace instead of the caller's (`dor --workspace `). + * Resolved by the router before caller ownership. */ + workspace?: string; surface: string; input: string; inputCount: number; @@ -156,6 +236,9 @@ export interface SendSurfaceResponse { } export interface ReadSurfaceRequest { + /** Act in this Workspace instead of the caller's (`dor --workspace `). + * Resolved by the router before caller ownership. */ + workspace?: string; lines?: number; scrollback: boolean; surface: string; @@ -180,6 +263,9 @@ export type AwaitCause = 'quiet' | 'exit' | 'bell' | 'idle'; export type AwaitSurfaceOutcome = 'resolved' | 'timeout' | 'died'; export interface AwaitSurfaceRequest { + /** Act in this Workspace instead of the caller's (`dor --workspace `). + * Resolved by the router before caller ownership. */ + workspace?: string; surface: string; until: AwaitUntil; /** The caller's ceiling, enforced host-side so no hop can reap the wait early. */ @@ -202,6 +288,9 @@ export type KillSurfaceConfirmation = | { mode: 'dangerously' }; export interface KillSurfaceRequest { + /** Act in this Workspace instead of the caller's (`dor --workspace `). + * Resolved by the router before caller ownership. */ + workspace?: string; confirmation: KillSurfaceConfirmation; surface: string; } @@ -213,6 +302,9 @@ export interface KillSurfaceResponse { } export interface IframeSurfaceRequest { + /** Act in this Workspace instead of the caller's (`dor --workspace `). + * Resolved by the router before caller ownership. */ + workspace?: string; minimized: boolean; surface?: string; url: string; @@ -227,6 +319,9 @@ export interface IframeSurfaceResponse { } export interface ResolveOpenTargetRequest { + /** Act in this Workspace instead of the caller's (`dor --workspace `). + * Resolved by the router before caller ownership. */ + workspace?: string; /** A terminal Surface handle (surface:N, surface:, surface:self, * surface:focused) whose dev-server URL should be resolved. */ surface: string; @@ -242,6 +337,9 @@ export interface ResolveOpenTargetResponse { } export interface ResolveAgentBrowserSessionRequest { + /** Act in this Workspace instead of the caller's (`dor --workspace `). + * Resolved by the router before caller ownership. */ + workspace?: string; /** A Surface handle (surface:N, surface:, surface:self, * surface:focused, title:) naming the browser Surface to drive. */ surface: string; @@ -257,6 +355,9 @@ export interface ResolveAgentBrowserSessionResponse { } export interface AgentBrowserSurfaceRequest { + /** Act in this Workspace instead of the caller's (`dor --workspace <ref>`). + * Resolved by the router before caller ownership. */ + workspace?: string; /** Managed workspace-scoped key; absent when attaching via raw --session. */ key?: string; /** Resolved agent-browser session name — the join key for the surface. */ @@ -290,6 +391,11 @@ export interface ControlClient { resolveAgentBrowserSession( request: ResolveAgentBrowserSessionRequest, ): Promise<ResolveAgentBrowserSessionResponse>; + listWorkspaces(request: ListWorkspacesRequest): Promise<ListWorkspacesResponse>; + newWorkspace(request: NewWorkspaceRequest): Promise<WorkspaceMutationResponse>; + renameWorkspace(request: RenameWorkspaceRequest): Promise<WorkspaceMutationResponse>; + closeWorkspace(request: CloseWorkspaceRequest): Promise<WorkspaceMutationResponse>; + switchWorkspace(request: SwitchWorkspaceRequest): Promise<WorkspaceMutationResponse>; } export interface AgentBrowserExecResult { diff --git a/dor/src/control-client.ts b/dor/src/control-client.ts index 5e3b1cf33..4c4915ca0 100644 --- a/dor/src/control-client.ts +++ b/dor/src/control-client.ts @@ -14,6 +14,13 @@ import type { KillSurfaceResponse, ListSurfacesRequest, ListSurfacesResponse, + ListWorkspacesRequest, + ListWorkspacesResponse, + NewWorkspaceRequest, + CloseWorkspaceRequest, + RenameWorkspaceRequest, + SwitchWorkspaceRequest, + WorkspaceMutationResponse, ReadSurfaceRequest, ReadSurfaceResponse, ResolveAgentBrowserSessionRequest, @@ -25,7 +32,7 @@ import type { SplitSurfaceRequest, SplitSurfaceResponse, } from './commands/types.js'; -import { SURFACE_CONTROL_METHODS, type SurfaceControlMethod } from './protocol.js'; +import { SURFACE_CONTROL_METHODS, WORKSPACE_CONTROL_METHODS, type DorControlMethod } from './protocol.js'; import type { DorControlResult } from './protocol.js'; export interface SocketControlClientOptions { @@ -62,6 +69,10 @@ function proofMatches(provided: unknown, expected: string): boolean { return timingSafeEqual(a, b); } +/** `dor workspace close` archives and tears down every member Surface, which a + * refused notepad archive can park on; the server's own reaper sits above it. */ +const CLOSE_WORKSPACE_TIMEOUT_MS = 30_000; + export class SocketControlClient implements ControlClient { private readonly socketPath: string; private readonly token: string; @@ -139,6 +150,32 @@ export class SocketControlClient implements ControlClient { ); } + listWorkspaces(request: ListWorkspacesRequest): Promise<ListWorkspacesResponse> { + return this.request<ListWorkspacesResponse>(WORKSPACE_CONTROL_METHODS.list, request); + } + + newWorkspace(request: NewWorkspaceRequest): Promise<WorkspaceMutationResponse> { + return this.request<WorkspaceMutationResponse>(WORKSPACE_CONTROL_METHODS.new, request); + } + + renameWorkspace(request: RenameWorkspaceRequest): Promise<WorkspaceMutationResponse> { + return this.request<WorkspaceMutationResponse>(WORKSPACE_CONTROL_METHODS.rename, request); + } + + // A Workspace close walks every member Surface through the closure + // coordinator, so it can outlast the client's ordinary 5s deadline. + closeWorkspace(request: CloseWorkspaceRequest): Promise<WorkspaceMutationResponse> { + return this.request<WorkspaceMutationResponse>( + WORKSPACE_CONTROL_METHODS.close, + request, + { timeoutMs: CLOSE_WORKSPACE_TIMEOUT_MS }, + ); + } + + switchWorkspace(request: SwitchWorkspaceRequest): Promise<WorkspaceMutationResponse> { + return this.request<WorkspaceMutationResponse>(WORKSPACE_CONTROL_METHODS.switch, request); + } + /** * One request over one socket, preceded by a mutual handshake. * @@ -155,7 +192,7 @@ export class SocketControlClient implements ControlClient { * client gave up. */ private request<T>( - method: SurfaceControlMethod, + method: DorControlMethod, params: unknown, options?: { timeoutMs?: number }, ): Promise<T> { diff --git a/dor/src/protocol.ts b/dor/src/protocol.ts index 403b80796..ac03eb423 100644 --- a/dor/src/protocol.ts +++ b/dor/src/protocol.ts @@ -29,6 +29,25 @@ export const SURFACE_CONTROL_METHODS = { export type SurfaceControlMethod = (typeof SURFACE_CONTROL_METHODS)[keyof typeof SURFACE_CONTROL_METHODS]; +/** + * The wire identifier for each Workspace control operation, enumerated here + * beside the Surface methods for the same reason. These are container verbs, so + * the window-level router answers them itself rather than handing them to a Wall + * (`docs/specs/dor-cli.md` → "dor workspace"). + */ +export const WORKSPACE_CONTROL_METHODS = { + list: 'workspace.list', + new: 'workspace.new', + rename: 'workspace.rename', + close: 'workspace.close', + switch: 'workspace.switch', +} as const; + +export type WorkspaceControlMethod = (typeof WORKSPACE_CONTROL_METHODS)[keyof typeof WORKSPACE_CONTROL_METHODS]; + +/** Every method the control channel carries. */ +export type DorControlMethod = SurfaceControlMethod | WorkspaceControlMethod; + /** A control request as it travels over a transport, correlated by `requestId`. */ export interface DorControlRequestPayload { requestId: string; diff --git a/lib/src/components/wall/dor-control-router.test.ts b/lib/src/components/wall/dor-control-router.test.ts index a89fea198..f37ceaf20 100644 --- a/lib/src/components/wall/dor-control-router.test.ts +++ b/lib/src/components/wall/dor-control-router.test.ts @@ -76,6 +76,52 @@ describe('dor control routing', () => { } }); + it('routes an explicit workspace target by name', () => { + const first = getWorkspacesSnapshot().workspaces[0].id; + createWorkspace({ id: 'ws-2', name: 'build' }); + const target = handleFor('ws-2'); + handleFor(first, ['pane-a']); + for (const value of ['workspace:build', 'build']) { + expect(resolveDorControlRoute(request({ surfaceId: 'pane-a', params: { workspace: value } }))) + .toEqual({ kind: 'handle', handle: target }); + } + createWorkspace({ id: 'ws-3', name: 'build' }); + handleFor('ws-3'); + expect(resolveDorControlRoute(request({ params: { workspace: 'build' } }))) + .toEqual({ + kind: 'error', + message: 'workspace target \'build\' matched multiple Workspaces: workspace:2 "build", workspace:3 "build"', + }); + }); + + it('routes a stable-id target to the Workspace holding it, over the caller', () => { + const first = getWorkspacesSnapshot().workspaces[0].id; + createWorkspace({ id: 'ws-2' }); + const caller = handleFor(first, ['pane-a']); + const owner = handleFor('ws-2', ['pane-b']); + for (const surface of ['pane-b', 'surface:pane-b']) { + expect(resolveDorControlRoute(request({ surfaceId: 'pane-a', params: { surface } }))) + .toEqual({ kind: 'handle', handle: owner }); + } + // A Workspace-scoped `surface:N`, `surface:self` and a title stay with the + // caller: every Workspace has a `surface:1`. + for (const surface of ['surface:1', 'surface:self', 'title:pane-b']) { + expect(resolveDorControlRoute(request({ surfaceId: 'pane-a', params: { surface } }))) + .toEqual({ kind: 'handle', handle: caller }); + } + }); + + it('answers the container verbs and --all at the Window, with no Wall involved', () => { + handleFor(getWorkspacesSnapshot().workspaces[0].id, ['pane-a']); + for (const method of ['workspace.list', 'workspace.new', 'workspace.close']) { + expect(resolveDorControlRoute(request({ method, surfaceId: 'pane-a' }))).toEqual({ kind: 'window' }); + } + expect(resolveDorControlRoute(request({ method: 'surface.list', params: { scope: 'all' } }))) + .toEqual({ kind: 'window' }); + expect(resolveDorControlRoute(request({ method: 'surface.list', params: { scope: 'workspace' } })).kind) + .toBe('handle'); + }); + it('errors on a workspace or window target this Window does not have', () => { handleFor(getWorkspacesSnapshot().workspaces[0].id); expect(resolveDorControlRoute(request({ params: { workspace: 'workspace:9' } }))) diff --git a/lib/src/components/wall/dor-control-router.ts b/lib/src/components/wall/dor-control-router.ts index 54f0e10ef..45b2c37fc 100644 --- a/lib/src/components/wall/dor-control-router.ts +++ b/lib/src/components/wall/dor-control-router.ts @@ -1,25 +1,45 @@ +import { SURFACE_CONTROL_METHODS } from 'dor/protocol'; import { createRefCount } from '../../lib/ref-count'; -import { getActiveWorkspaceId, isWindowRef, workspaceIdForRef } from '../../lib/workspace-store'; +import { getActiveWorkspaceId, isWindowRef, resolveWorkspaceRef } from '../../lib/workspace-store'; import { getWallHandle, wallHandleOwning, type WallHandle } from './wall-handles'; +import { handleWorkspaceControl, isWorkspaceControlMethod, listAllWorkspaceSurfaces } from './workspace-control'; import type { DorControlRequest } from './use-dor-control'; /** * The one window listener for `dormouse:control-request`, deciding which Wall * answers (`docs/specs/dor-cli.md` → "Handle Model"). It replaces the per-Wall * listener, which would have every mounted Workspace answer the same request. + * The container verbs and the cross-Workspace listing are answered here instead, + * by `workspace-control.ts`. */ /** Where one control request lands. */ export type DorControlRoute = | { kind: 'handle'; handle: WallHandle } + /** Answered by the Window itself: a `workspace.*` verb, or `--all`. */ + | { kind: 'window' } | { kind: 'error'; message: string } /** Nothing is mounted that could answer; the request is left to time out. */ | { kind: 'none' }; /** - * Resolution order: an explicit container target, else the caller's own - * Workspace, else the active one. `surface:` targets are resolved by the chosen - * Wall, within its own Workspace. + * Whether a target names a Surface by its stable id — the one handle that is + * unique across the whole Window, so it can be routed to the Workspace holding + * it. `surface:N` is Workspace-scoped and deliberately excluded: every Workspace + * has a `surface:1`. + */ +function stableSurfaceTarget(target: unknown): string | null { + if (typeof target !== 'string') return null; + const id = target.startsWith('surface:') ? target.slice('surface:'.length) : target; + if (!id || /^\d+$/.test(id) || id === 'self' || id === 'focused' || target.startsWith('title:')) return null; + return id; +} + +/** + * Resolution order: the Window's own verbs, an explicit container target, a + * target Surface named by its stable id, else the caller's own Workspace, else + * the active one. `surface:N` targets are resolved by the chosen Wall, within + * its own Workspace. */ export function resolveDorControlRoute(detail: DorControlRequest): DorControlRoute { const params = detail.params ?? {}; @@ -29,15 +49,26 @@ export function resolveDorControlRoute(detail: DorControlRequest): DorControlRou if (params.window !== undefined && (typeof params.window !== 'string' || !isWindowRef(params.window))) { return { kind: 'error', message: `unknown window target '${String(params.window)}'` }; } + // Container verbs belong to no Workspace, and `--all` spans them all. + if (isWorkspaceControlMethod(detail.method)) return { kind: 'window' }; + if (detail.method === SURFACE_CONTROL_METHODS.list && params.scope === 'all') return { kind: 'window' }; if (params.workspace !== undefined) { // A ref of the wrong type, one outside the strip, and one whose Wall is not // mounted are the same answer: this Window has no such Workspace to route to. - const ref = typeof params.workspace === 'string' ? workspaceIdForRef(params.workspace) : null; - const handle = ref ? getWallHandle(ref) : null; + const resolved = typeof params.workspace === 'string' + ? resolveWorkspaceRef(params.workspace) + : { ok: false as const, message: `unknown workspace target '${String(params.workspace)}'` }; + if (!resolved.ok) return { kind: 'error', message: resolved.message }; + const handle = getWallHandle(resolved.id); return handle ? { kind: 'handle', handle } : { kind: 'error', message: `unknown workspace target '${String(params.workspace)}'` }; } + // A stable id names one Surface in the whole Window, so a command targeting + // one is answered by whichever Workspace holds it, caller or not. + const stable = stableSurfaceTarget(params.surface); + const owningTarget = stable ? wallHandleOwning(stable) : null; + if (owningTarget) return { kind: 'handle', handle: owningTarget }; // The caller's own Workspace: `dor split` from a background Workspace lands // beside its caller, not in whichever Workspace the user is looking at. const owner = detail.surfaceId ? wallHandleOwning(detail.surfaceId) : null; @@ -76,13 +107,21 @@ function dispatchDorControl(detail: DorControlRequest, attempt: number): void { error: error instanceof Error ? error.message : String(error), }); try { - const running = route.handle.handleDorControl(detail) as unknown; + const running = route.kind === 'window' + ? runWindowControl(detail) + : (route.handle.handleDorControl(detail) as unknown); if (running instanceof Promise) void running.catch(fail); } catch (error) { fail(error); } } +function runWindowControl(detail: DorControlRequest): Promise<void> { + return isWorkspaceControlMethod(detail.method) + ? handleWorkspaceControl(detail) + : listAllWorkspaceSurfaces(detail); +} + /** * Install the router's window listener, reference-counted so N Walls share one. * Returns its (idempotent) release. diff --git a/lib/src/components/wall/use-dor-control.ts b/lib/src/components/wall/use-dor-control.ts index a3dea0ad0..3c9070796 100644 --- a/lib/src/components/wall/use-dor-control.ts +++ b/lib/src/components/wall/use-dor-control.ts @@ -45,6 +45,9 @@ export type DorControlParams = { restart?: unknown; binaryPath?: unknown; includePorts?: unknown; + name?: unknown; + force?: unknown; + scope?: unknown; pane?: string; session?: unknown; surface?: unknown; @@ -140,7 +143,12 @@ function resolveSurfaceTarget( target: string | undefined, callerSurfaceId: string | undefined, ): ParseResult<DorSurface> { - const resolvedTarget = target ?? callerSurfaceId ?? 'surface:focused'; + // A caller this Wall does not hold cannot be the implicit target: a + // `--workspace` command names another Workspace, and its reference defaults + // to that Workspace's own focused Surface rather than failing on a caller + // that was never in this list. + const callerListed = callerSurfaceId !== undefined && surfaces.some((surface) => surface.id === callerSurfaceId); + const resolvedTarget = target ?? (callerListed ? callerSurfaceId : 'surface:focused'); const titleTarget = surfaceTitleTarget(resolvedTarget); if (titleTarget !== null) { const matches = surfaces.filter((surface) => surface.title === titleTarget); @@ -151,7 +159,7 @@ function resolveSurfaceTarget( const matches = surfaces.filter((surface) => matchesDorSurfaceTarget(resolvedTarget, surface, callerSurfaceId)); const single = pickSingleMatch(matches, resolvedTarget); if (single) return single; - const fallback = !target && !callerSurfaceId ? (surfaces[0] ?? null) : null; + const fallback = !target && !callerListed ? (surfaces[0] ?? null) : null; if (fallback) return { ok: true, value: fallback }; return { ok: false, message: `surface '${resolvedTarget}' was not found` }; } diff --git a/lib/src/components/wall/workspace-control.test.ts b/lib/src/components/wall/workspace-control.test.ts new file mode 100644 index 000000000..7a1a12c4d --- /dev/null +++ b/lib/src/components/wall/workspace-control.test.ts @@ -0,0 +1,231 @@ +/** + * @vitest-environment jsdom + */ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { handleWorkspaceControl, listAllWorkspaceSurfaces, workspaceRows } from './workspace-control'; +import { registerWallHandle, resetWallHandles, stubWallHandle, type WallHandle } from './wall-handles'; +import type { DorControlRequest } from './use-dor-control'; +import { + createWorkspace, + getWorkspacesSnapshot, + renameWorkspace, + resetWorkspaces, +} from '../../lib/workspace-store'; +import { clearTerminalActivity, setTerminalActivity } from '../../lib/session-activity-store'; +import { resetWorkspaceSurfaces, setWorkspaceSurfaces } from '../../lib/workspace-surfaces'; +import { resetWindowSessionAggregator } from '../../lib/window-session-aggregator'; + +const disposers: Array<() => void> = []; + +function handleFor(workspaceId: string, overrides: Partial<WallHandle> = {}): WallHandle { + const handle = stubWallHandle(workspaceId, overrides); + disposers.push(registerWallHandle(handle)); + return handle; +} + +function request(method: string, params: Record<string, unknown> = {}): DorControlRequest & { respond: ReturnType<typeof vi.fn> } { + return { + requestId: 'r1', + method, + params, + respond: vi.fn(), + } as unknown as DorControlRequest & { respond: ReturnType<typeof vi.fn> }; +} + +/** The `result` of a request that succeeded, else the failure's message. */ +function answer(detail: { respond: ReturnType<typeof vi.fn> }): unknown { + const [response] = detail.respond.mock.calls[0] ?? []; + expect(response).toBeDefined(); + return response.ok ? response.result : response.error; +} + +beforeEach(() => { + resetWallHandles(); + resetWorkspaces(); + resetWorkspaceSurfaces(); + resetWindowSessionAggregator(); + clearTerminalActivity(); +}); + +afterEach(() => { + disposers.splice(0).forEach((dispose) => dispose()); +}); + +describe('workspace.list', () => { + it('reports one row per Workspace with its union status', async () => { + const first = getWorkspacesSnapshot().workspaces[0].id; + const second = createWorkspace({ id: 'ws-2', name: 'build', activate: false }).id; + setWorkspaceSurfaces(first, ['a']); + setWorkspaceSurfaces(second, ['b', 'c']); + setTerminalActivity('b', { status: 'ALERT_RINGING' }); + setTerminalActivity('c', { todo: true }); + + const detail = request('workspace.list'); + await handleWorkspaceControl(detail); + + expect(answer(detail)).toEqual({ + windowRef: 'window:1', + workspaces: [ + { ref: 'workspace:1', id: first, name: 'Workspace 1', active: true, ringing: false, todo: false, count: 0 }, + { ref: 'workspace:2', id: 'ws-2', name: 'build', active: false, ringing: true, todo: true, count: 2 }, + ], + }); + expect(workspaceRows()).toHaveLength(2); + }); +}); + +describe('workspace mutation verbs', () => { + it('creates in the background, so the user is not moved', async () => { + const active = getWorkspacesSnapshot().activeId; + const detail = request('workspace.new', { name: ' build ' }); + await handleWorkspaceControl(detail); + + const created = getWorkspacesSnapshot().workspaces[1]; + expect(answer(detail)).toEqual({ + status: 'created', + workspaceId: created.id, + workspaceRef: 'workspace:2', + name: 'build', + }); + expect(getWorkspacesSnapshot().activeId).toBe(active); + }); + + it('renames and switches by positional ref or name', async () => { + createWorkspace({ id: 'ws-2', name: 'build', activate: false }); + + const renamed = request('workspace.rename', { workspace: 'workspace:build', name: 'agents' }); + await handleWorkspaceControl(renamed); + expect(answer(renamed)).toEqual({ + status: 'renamed', workspaceId: 'ws-2', workspaceRef: 'workspace:2', name: 'agents', + }); + + const switched = request('workspace.switch', { workspace: 'agents' }); + await handleWorkspaceControl(switched); + expect(answer(switched)).toMatchObject({ status: 'active', workspaceId: 'ws-2' }); + expect(getWorkspacesSnapshot().activeId).toBe('ws-2'); + }); + + it('refuses an ambiguous name instead of picking, and lists the candidates', async () => { + renameWorkspace(getWorkspacesSnapshot().workspaces[0].id, 'build'); + createWorkspace({ id: 'ws-2', name: 'build', activate: false }); + + const detail = request('workspace.switch', { workspace: 'build' }); + await handleWorkspaceControl(detail); + + expect(answer(detail)).toBe( + 'workspace target \'build\' matched multiple Workspaces: workspace:1 "build", workspace:2 "build"', + ); + }); + + it('requires a target and a name', async () => { + const noTarget = request('workspace.rename', { name: 'x' }); + await handleWorkspaceControl(noTarget); + expect(answer(noTarget)).toBe('workspace is required'); + + const noName = request('workspace.rename', { workspace: 'workspace:1', name: ' ' }); + await handleWorkspaceControl(noName); + expect(answer(noName)).toBe('name is required'); + }); +}); + +describe('workspace.close', () => { + it('refuses a Workspace holding work, and closes it with force', async () => { + const second = createWorkspace({ id: 'ws-2', name: 'build', activate: false }).id; + const closeAll = vi.fn(async () => null); + handleFor(getWorkspacesSnapshot().workspaces[0].id); + handleFor(second, { runningCount: () => 1, closeAll }); + + const refused = request('workspace.close', { workspace: 'workspace:2' }); + await handleWorkspaceControl(refused); + expect(answer(refused)).toBe( + "workspace 'workspace:2' holds running or touched Surfaces; pass --force to close it", + ); + expect(closeAll).not.toHaveBeenCalled(); + expect(getWorkspacesSnapshot().workspaces).toHaveLength(2); + + const forced = request('workspace.close', { workspace: 'workspace:2', force: true }); + await handleWorkspaceControl(forced); + expect(answer(forced)).toEqual({ + status: 'closed', workspaceId: second, workspaceRef: 'workspace:2', name: 'build', + }); + // A command close raises no pane prompt, exactly like `dor kill`. + expect(closeAll).toHaveBeenCalledWith('silent'); + expect(getWorkspacesSnapshot().workspaces).toHaveLength(1); + }); + + it('refuses the last Workspace', async () => { + const only = getWorkspacesSnapshot().workspaces[0].id; + handleFor(only); + const detail = request('workspace.close', { workspace: 'workspace:1' }); + await handleWorkspaceControl(detail); + expect(answer(detail)).toBe( + "workspace 'workspace:1' was not closed: the last Workspace cannot be closed", + ); + expect(getWorkspacesSnapshot().workspaces).toHaveLength(1); + }); + + it('refuses a second close while one is in flight', async () => { + createWorkspace({ id: 'ws-2', activate: false }); + createWorkspace({ id: 'ws-3', activate: false }); + handleFor(getWorkspacesSnapshot().workspaces[0].id); + let releaseFirst = () => {}; + handleFor('ws-2', { closeAll: () => new Promise<string | null>((resolve) => { releaseFirst = () => resolve(null); }) }); + handleFor('ws-3'); + + const first = request('workspace.close', { workspace: 'workspace:2', force: true }); + const running = handleWorkspaceControl(first); + + const second = request('workspace.close', { workspace: 'workspace:3', force: true }); + await handleWorkspaceControl(second); + expect(answer(second)).toBe("workspace 'workspace:3' was not closed: another Workspace is closing"); + + releaseFirst(); + await running; + expect(answer(first)).toMatchObject({ status: 'closed' }); + }); +}); + +describe('surface.list --all', () => { + it('tags every row with its Workspace and carries the directory', async () => { + const first = getWorkspacesSnapshot().workspaces[0].id; + createWorkspace({ id: 'ws-2', name: 'build', activate: false }); + const listing = (refs: string[]) => vi.fn((detail: DorControlRequest) => { + detail.respond({ + ok: true, + result: { + surfaces: refs.map((ref) => ({ ref, id: `${ref}-id` })), + workspaceRef: 'workspace:x', + windowRef: 'window:1', + }, + }); + }); + handleFor(first, { handleDorControl: listing(['surface:1']) }); + const second = listing(['surface:1', 'surface:2']); + handleFor('ws-2', { handleDorControl: second }); + + const detail = request('surface.list', { scope: 'all', includePorts: true }); + await listAllWorkspaceSurfaces(detail); + + const result = answer(detail) as { surfaces: Array<{ ref: string; workspaceRef: string }>; workspaces: unknown[] }; + expect(result.surfaces.map((surface) => [surface.workspaceRef, surface.ref])).toEqual([ + ['workspace:1', 'surface:1'], + ['workspace:2', 'surface:1'], + ['workspace:2', 'surface:2'], + ]); + expect(result.workspaces).toHaveLength(2); + // Each Wall is asked for its own Workspace, with the caller's scope removed. + expect(second.mock.calls[0][0].params).toMatchObject({ scope: 'workspace', includePorts: true }); + }); + + it('fails the whole listing when one Workspace cannot answer', async () => { + createWorkspace({ id: 'ws-2', name: 'build', activate: false }); + handleFor(getWorkspacesSnapshot().workspaces[0].id, { + handleDorControl: (detail) => detail.respond({ ok: true, result: { surfaces: [], workspaceRef: 'workspace:1', windowRef: 'window:1' } }), + }); + handleFor('ws-2', { handleDorControl: () => { throw new Error('boom'); } }); + + const detail = request('surface.list', { scope: 'all' }); + await listAllWorkspaceSurfaces(detail); + expect(answer(detail)).toBe('workspace:2: boom'); + }); +}); diff --git a/lib/src/components/wall/workspace-control.ts b/lib/src/components/wall/workspace-control.ts new file mode 100644 index 000000000..1c167fa6f --- /dev/null +++ b/lib/src/components/wall/workspace-control.ts @@ -0,0 +1,225 @@ +import { WORKSPACE_CONTROL_METHODS } from 'dor/protocol'; +import type { DorControlResult } from 'dor/protocol'; +import type { Surface as DorSurface, ListSurfacesResponse, WorkspaceRow } from 'dor/commands/types'; +import { getActivitySnapshot } from '../../lib/session-activity-store'; +import type { WorkspaceId } from '../../lib/session-types'; +import { + createWorkspace, + currentWindowRef, + getWorkspacesSnapshot, + renameWorkspace, + resolveWorkspaceRef, + setActiveWorkspace, + workspaceRefFor, +} from '../../lib/workspace-store'; +import { getWorkspaceSurfacesSnapshot } from '../../lib/workspace-surfaces'; +import { computeWorkspaceUnion } from '../../lib/workspace-union'; +import { getWallHandle, type WallHandle } from './wall-handles'; +import { closeWorkspaceWithSurfaces, workspaceNeedsCloseConfirmation } from './workspace-lifecycle'; +import type { DorControlParams, DorControlRequest } from './use-dor-control'; + +/** + * The Window-level half of the `dor` control plane: the `workspace.*` container + * verbs, and the `surface.list --all` fan-out across every mounted Wall + * (`docs/specs/dor-cli.md` → "dor workspace"). A Wall answers for the Surfaces + * it holds; nothing below belongs to one Workspace, so the router answers it + * here instead of handing it to a Wall. + */ + +export function isWorkspaceControlMethod(method: string): boolean { + return (Object.values(WORKSPACE_CONTROL_METHODS) as string[]).includes(method); +} + +function errorText(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} + +function stringParam(value: unknown): string | undefined { + return typeof value === 'string' ? value : undefined; +} + +/** This Window's Workspaces in strip order, each with its union status. */ +export function workspaceRows(): WorkspaceRow[] { + const { workspaces, activeId } = getWorkspacesSnapshot(); + const membership = getWorkspaceSurfacesSnapshot(); + const activity = getActivitySnapshot(); + return workspaces.map((workspace, index) => { + const union = computeWorkspaceUnion(membership.get(workspace.id) ?? [], activity); + return { + ref: `workspace:${index + 1}`, + id: workspace.id, + name: workspace.name, + active: workspace.id === activeId, + ringing: union.ringing, + todo: union.todo, + count: union.count, + }; + }); +} + +/** + * Run one request against a Wall and resolve with whatever it answers. The + * fan-out needs the answer rather than the caller's `respond`, so it hands each + * Wall a `respond` of its own; a handler that throws or rejects settles as an + * error, like the router's own dispatch. + */ +function askWall( + handle: WallHandle, + detail: DorControlRequest, + params: DorControlParams, +): Promise<DorControlResult> { + return new Promise((resolve) => { + let settled = false; + const respond = (response: DorControlResult) => { + if (settled) return; + settled = true; + resolve(response); + }; + try { + const running = handle.handleDorControl({ ...detail, params, respond }) as unknown; + if (running instanceof Promise) void running.catch((error) => respond({ ok: false, error: errorText(error) })); + } catch (error) { + respond({ ok: false, error: errorText(error) }); + } + }); +} + +/** + * `dor list --all`: every Workspace's Surfaces in one answer, each row tagged + * with the Workspace it came from and the directory of Workspaces beside them. + * **A Workspace that fails to list fails the whole call** rather than dropping + * out of the answer, which would read as a Workspace holding nothing. + */ +export async function listAllWorkspaceSurfaces(detail: DorControlRequest): Promise<void> { + const params = detail.params ?? {}; + const rows = workspaceRows(); + const surfaces: DorSurface[] = []; + for (const row of rows) { + const handle = getWallHandle(row.id as WorkspaceId); + // A Workspace whose Wall is not mounted contributes nothing; every + // Workspace of a multi-Workspace Window keeps its Wall mounted, so this is + // the tick between `createWorkspace` and the Wall registering. + if (!handle) continue; + const answer = await askWall(handle, detail, { ...params, scope: 'workspace', workspace: undefined }); + if (!answer.ok) { + detail.respond({ ok: false, error: `${row.ref}: ${answer.error ?? 'listing failed'}` }); + return; + } + const listed = answer.result as ListSurfacesResponse; + for (const surface of listed.surfaces) surfaces.push({ ...surface, workspaceRef: row.ref }); + } + detail.respond({ + ok: true, + result: { + surfaces, + workspaces: rows, + workspaceRef: workspaceRefFor(getWorkspacesSnapshot().activeId), + windowRef: currentWindowRef(), + } satisfies ListSurfacesResponse, + }); +} + +/** The Workspace a mutating verb names, or null once the failure is answered. */ +function requireWorkspace( + detail: DorControlRequest, +): { id: WorkspaceId; ref: string; name: string } | null { + const target = stringParam(detail.params?.workspace); + if (!target) { + detail.respond({ ok: false, error: 'workspace is required' }); + return null; + } + const resolved = resolveWorkspaceRef(target); + if (!resolved.ok) { + detail.respond({ ok: false, error: resolved.message }); + return null; + } + const meta = getWorkspacesSnapshot().workspaces.find((workspace) => workspace.id === resolved.id); + if (!meta) { + detail.respond({ ok: false, error: `unknown workspace target '${target}'` }); + return null; + } + return { id: meta.id, ref: workspaceRefFor(meta.id), name: meta.name }; +} + +/** Answer one `workspace.*` request. Every path responds, including a throw. */ +export async function handleWorkspaceControl(detail: DorControlRequest): Promise<void> { + const params = detail.params ?? {}; + + if (detail.method === WORKSPACE_CONTROL_METHODS.list) { + detail.respond({ ok: true, result: { workspaces: workspaceRows(), windowRef: currentWindowRef() } }); + return; + } + + if (detail.method === WORKSPACE_CONTROL_METHODS.new) { + const name = stringParam(params.name)?.trim(); + // Created in the background: a command that moved the user to another + // Workspace would be a bigger theft than the focus one `dor split` avoids + // (`docs/specs/dor-cli.md` → "dor workspace"). `dor workspace switch` is + // the verb that activates. + const meta = createWorkspace({ ...(name ? { name } : {}), activate: false }); + detail.respond({ + ok: true, + result: { + status: 'created', + workspaceId: meta.id, + workspaceRef: workspaceRefFor(meta.id), + name: meta.name, + }, + }); + return; + } + + if (detail.method === WORKSPACE_CONTROL_METHODS.rename) { + const target = requireWorkspace(detail); + if (!target) return; + const name = stringParam(params.name)?.trim(); + if (!name) { + detail.respond({ ok: false, error: 'name is required' }); + return; + } + renameWorkspace(target.id, name); + detail.respond({ + ok: true, + result: { status: 'renamed', workspaceId: target.id, workspaceRef: target.ref, name }, + }); + return; + } + + if (detail.method === WORKSPACE_CONTROL_METHODS.switch) { + const target = requireWorkspace(detail); + if (!target) return; + setActiveWorkspace(target.id); + detail.respond({ + ok: true, + result: { status: 'active', workspaceId: target.id, workspaceRef: target.ref, name: target.name }, + }); + return; + } + + if (detail.method === WORKSPACE_CONTROL_METHODS.close) { + const target = requireWorkspace(detail); + if (!target) return; + // Like `dor kill`, a command close raises no prompt: it refuses instead, + // and `--force` is the caller's answer to the confirmation the strip would + // have shown (`docs/specs/dor-cli.md` → "dor workspace"). + if (params.force !== true && workspaceNeedsCloseConfirmation(target.id)) { + detail.respond({ + ok: false, + error: `workspace '${target.ref}' holds running or touched Surfaces; pass --force to close it`, + }); + return; + } + const refusal = await closeWorkspaceWithSurfaces(target.id, 'silent'); + if (refusal) { + detail.respond({ ok: false, error: `workspace '${target.ref}' was not closed: ${refusal}` }); + return; + } + detail.respond({ + ok: true, + result: { status: 'closed', workspaceId: target.id, workspaceRef: target.ref, name: target.name }, + }); + return; + } + + detail.respond({ ok: false, error: `unsupported Dormouse control method '${detail.method}'` }); +} diff --git a/lib/src/components/wall/workspace-lifecycle.ts b/lib/src/components/wall/workspace-lifecycle.ts index 0abdcb97e..42a7267a0 100644 --- a/lib/src/components/wall/workspace-lifecycle.ts +++ b/lib/src/components/wall/workspace-lifecycle.ts @@ -4,12 +4,13 @@ import { forgetWorkspaceSession } from '../../lib/window-session-aggregator'; import { setPendingWorkspaceClose, setRenamingWorkspace } from '../../lib/workspace-ui-store'; import { closeWorkspace, getWorkspacesSnapshot, setActiveWorkspace } from '../../lib/workspace-store'; import type { WorkspaceId } from '../../lib/session-types'; +import type { CloseSurfaceMode } from './wall-types'; /** * The Workspace close and rename verbs, outside any component: the strip's - * buttons, the command-mode keys, and (later) `dor workspace` all take the same - * route (`docs/specs/layout.md` → "Workspaces"). The strip renders the - * confirmation these open; it decides nothing. + * buttons, the command-mode keys, and `dor workspace` all take the same route + * (`docs/specs/layout.md` → "Workspaces"). The strip renders the confirmation + * these open; it decides nothing. */ /** Whether closing this Workspace asks first: it holds a Surface the user has @@ -34,8 +35,15 @@ let closeInFlight = false; * Workspace itself. Resolves the first refusal's message with the Workspace left * as it was — revealed, so the prompt behind the refusal is on screen — or null * once it is gone. Membership is cleared by the Wall's own unmount. + * + * `mode` is the closure mode each member Surface is closed with: `prompt` for a + * user gesture, `silent` for `dor workspace close`, whose caller is a command + * rather than someone looking at the Wall (`docs/specs/notepad.md` → "Closure"). */ -export async function closeWorkspaceWithSurfaces(id: WorkspaceId): Promise<string | null> { +export async function closeWorkspaceWithSurfaces( + id: WorkspaceId, + mode: CloseSurfaceMode = 'prompt', +): Promise<string | null> { if (closeInFlight) return CLOSE_IN_FLIGHT_REFUSAL; // Re-checked here, not only in `requestWorkspaceClose`: the count can drop // while the typed confirmation is on screen, and emptying the Wall for a @@ -46,7 +54,7 @@ export async function closeWorkspaceWithSurfaces(id: WorkspaceId): Promise<strin const handle = getWallHandle(id); try { if (handle) { - const refusal = await handle.closeAll('prompt'); + const refusal = await handle.closeAll(mode); if (refusal) { setActiveWorkspace(id); return refusal; diff --git a/lib/src/lib/workspace-store.test.ts b/lib/src/lib/workspace-store.test.ts index 6420a0fac..93e418eb4 100644 --- a/lib/src/lib/workspace-store.test.ts +++ b/lib/src/lib/workspace-store.test.ts @@ -10,7 +10,7 @@ import { setActiveWorkspace, setWorkspaces, subscribeToWorkspaces, - workspaceIdForRef, + resolveWorkspaceRef, workspaceRefFor, } from './workspace-store'; import { DEFAULT_WORKSPACE_ID, DEFAULT_WORKSPACE_NAME } from './session-types'; @@ -160,14 +160,31 @@ describe('workspace-store', () => { expect(workspaceRefFor('ws-2')).toBe('workspace:2'); // A Workspace already gone (its Wall is mid-unmount) answers the first ref. expect(workspaceRefFor('missing')).toBe('workspace:1'); - expect(workspaceIdForRef('workspace:2')).toBe('ws-2'); - expect(workspaceIdForRef('2')).toBe('ws-2'); - expect(workspaceIdForRef('workspace:9')).toBeNull(); - expect(workspaceIdForRef('workspace:0')).toBeNull(); - expect(workspaceIdForRef('nonsense')).toBeNull(); + expect(resolveWorkspaceRef('workspace:2')).toEqual({ ok: true, id: 'ws-2' }); + expect(resolveWorkspaceRef('2')).toEqual({ ok: true, id: 'ws-2' }); + for (const ref of ['workspace:9', 'workspace:0', 'nonsense']) { + expect(resolveWorkspaceRef(ref)).toEqual({ ok: false, message: `unknown workspace target '${ref}'` }); + } moveWorkspace('ws-2', 0); expect(workspaceRefFor('ws-2')).toBe('workspace:1'); - expect(workspaceIdForRef('workspace:1')).toBe('ws-2'); + expect(resolveWorkspaceRef('workspace:1')).toEqual({ ok: true, id: 'ws-2' }); + }); + + it('resolves a Workspace by name, and refuses an ambiguous one', () => { + renameWorkspace(DEFAULT_WORKSPACE_ID, 'build'); + createWorkspace({ id: 'ws-2', name: 'agents' }); + expect(resolveWorkspaceRef('workspace:agents')).toEqual({ ok: true, id: 'ws-2' }); + expect(resolveWorkspaceRef('agents')).toEqual({ ok: true, id: 'ws-2' }); + + createWorkspace({ id: 'ws-3', name: 'agents' }); + expect(resolveWorkspaceRef('agents')).toEqual({ + ok: false, + message: 'workspace target \'agents\' matched multiple Workspaces: workspace:2 "agents", workspace:3 "agents"', + }); + // A positional ref is never read as a name, even when a Workspace is named + // for a number. + renameWorkspace('ws-3', '1'); + expect(resolveWorkspaceRef('1')).toEqual({ ok: true, id: DEFAULT_WORKSPACE_ID }); }); }); diff --git a/lib/src/lib/workspace-store.ts b/lib/src/lib/workspace-store.ts index 52d0055dc..6705c8bfd 100644 --- a/lib/src/lib/workspace-store.ts +++ b/lib/src/lib/workspace-store.ts @@ -195,11 +195,37 @@ export function workspaceRefFor(id: WorkspaceId): string { return `workspace:${index === -1 ? 1 : index + 1}`; } -/** Resolve `workspace:<n>` or a bare `<n>` (1-based) to a Workspace id. */ -export function workspaceIdForRef(ref: string): WorkspaceId | null { - const match = /^(?:workspace:)?([1-9]\d*)$/.exec(ref.trim()); - if (!match) return null; - return state.workspaces[Number(match[1]) - 1]?.id ?? null; +/** What a `workspace:<n|name>` target named, or why it named nothing. */ +export type WorkspaceRefResolution = + | { ok: true; id: WorkspaceId } + | { ok: false; message: string }; + +const POSITIONAL_REF = /^[1-9]\d*$/; + +/** + * Resolve `workspace:<n>` / `workspace:<name>` — or either bare — to a + * Workspace of this Window (`docs/specs/dor-cli.md` → "Handle Model"). A + * positional ref wins over a name that reads as one; a name resolves only when + * exactly one Workspace carries it, and an ambiguous one lists the candidates + * rather than picking. + */ +export function resolveWorkspaceRef(ref: string): WorkspaceRefResolution { + const target = ref.trim(); + const bare = (target.startsWith('workspace:') ? target.slice('workspace:'.length) : target).trim(); + if (POSITIONAL_REF.test(bare)) { + const positional = state.workspaces[Number(bare) - 1]; + if (positional) return { ok: true, id: positional.id }; + } else if (bare) { + const matches = state.workspaces.filter((workspace) => workspace.name === bare); + if (matches.length === 1) return { ok: true, id: matches[0].id }; + if (matches.length > 1) { + const candidates = matches + .map((workspace) => `${workspaceRefFor(workspace.id)} ${JSON.stringify(workspace.name)}`) + .join(', '); + return { ok: false, message: `workspace target '${target}' matched multiple Workspaces: ${candidates}` }; + } + } + return { ok: false, message: `unknown workspace target '${target}'` }; } /** Reset to the single default Workspace (fresh start / tests). */ From e8253cb02cbc4782527fed36a58d24151173c41f Mon Sep 17 00:00:00 2001 From: Ned Twigg <ned.twigg@diffplug.com> Date: Thu, 10 Sep 2026 04:37:04 -0700 Subject: [PATCH 03/13] Give the CLI its Workspace commands and flags `dor workspace new|rename|close|switch` mutates; `dor list` keeps every read: `--workspace <ref>` narrows to another Workspace, `--all` groups every Workspace's Surfaces under a header, and `--workspaces` prints the overview. Every action command gains `--workspace <ref>`, including the `dor ab` passthrough, which intercepts it beside the identity flags. One command with a leading action rather than a route map: the published CLI reference renders one help page per top-level command, and a nested one would have no page of its own. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QBf4rQbwuxV6n6v7E3twYe --- dor/skill.md | 20 ++ dor/src/cli.ts | 12 ++ dor/src/commands/agent-browser.ts | 52 +++-- dor/src/commands/await.ts | 15 +- dor/src/commands/ensure.ts | 15 +- dor/src/commands/iframe.ts | 7 +- dor/src/commands/kill.ts | 9 +- dor/src/commands/list.ts | 158 +++++++++++++++- dor/src/commands/open-target.ts | 6 +- dor/src/commands/read.ts | 9 +- dor/src/commands/send.ts | 9 +- dor/src/commands/shared.ts | 15 ++ dor/src/commands/split.ts | 13 +- dor/src/commands/workspace.ts | 179 ++++++++++++++++++ dor/test/cli-output.test.mjs | 157 +++++++++++++++ dor/test/snapshots/help/agent-browser.md | 17 +- dor/test/snapshots/help/await.md | 13 +- dor/test/snapshots/help/dor.md | 20 +- dor/test/snapshots/help/ensure.md | 17 +- dor/test/snapshots/help/iframe.md | 13 +- dor/test/snapshots/help/kill.md | 3 +- dor/test/snapshots/help/list.md | 38 ++-- dor/test/snapshots/help/read.md | 3 +- dor/test/snapshots/help/send.md | 19 +- dor/test/snapshots/help/split.md | 13 +- dor/test/snapshots/help/workspace.md | 42 ++++ .../snapshots/list-all-and-workspace.snap | 5 + dor/test/snapshots/list-all-json.snap | 97 ++++++++++ dor/test/snapshots/list-all-text.snap | 10 + dor/test/snapshots/list-workspaces-json.snap | 27 +++ dor/test/snapshots/list-workspaces-text.snap | 6 + .../list-workspaces-with-filter.snap | 5 + dor/test/snapshots/workspace-close-force.snap | 5 + .../snapshots/workspace-close-refused.snap | 5 + .../snapshots/workspace-force-misuse.snap | 5 + dor/test/snapshots/workspace-list-action.snap | 5 + .../snapshots/workspace-missing-action.snap | 5 + dor/test/snapshots/workspace-new-json.snap | 10 + dor/test/snapshots/workspace-new.snap | 5 + .../snapshots/workspace-rename-arity.snap | 5 + dor/test/snapshots/workspace-rename.snap | 5 + dor/test/snapshots/workspace-switch.snap | 5 + .../snapshots/workspace-unknown-action.snap | 5 + 43 files changed, 981 insertions(+), 103 deletions(-) create mode 100644 dor/src/commands/workspace.ts create mode 100644 dor/test/snapshots/help/workspace.md create mode 100644 dor/test/snapshots/list-all-and-workspace.snap create mode 100644 dor/test/snapshots/list-all-json.snap create mode 100644 dor/test/snapshots/list-all-text.snap create mode 100644 dor/test/snapshots/list-workspaces-json.snap create mode 100644 dor/test/snapshots/list-workspaces-text.snap create mode 100644 dor/test/snapshots/list-workspaces-with-filter.snap create mode 100644 dor/test/snapshots/workspace-close-force.snap create mode 100644 dor/test/snapshots/workspace-close-refused.snap create mode 100644 dor/test/snapshots/workspace-force-misuse.snap create mode 100644 dor/test/snapshots/workspace-list-action.snap create mode 100644 dor/test/snapshots/workspace-missing-action.snap create mode 100644 dor/test/snapshots/workspace-new-json.snap create mode 100644 dor/test/snapshots/workspace-new.snap create mode 100644 dor/test/snapshots/workspace-rename-arity.snap create mode 100644 dor/test/snapshots/workspace-rename.snap create mode 100644 dor/test/snapshots/workspace-switch.snap create mode 100644 dor/test/snapshots/workspace-unknown-action.snap diff --git a/dor/skill.md b/dor/skill.md index f277b8c5e..4af2f7018 100644 --- a/dor/skill.md +++ b/dor/skill.md @@ -71,6 +71,8 @@ dor list --command "npm run dev" --cwd . # exact command + cwd match dor list --port 5173 # which terminal owns port 5173 dor list --kind terminal --view minimized # filters AND together dor list --ports # add each terminal's listening ports +dor list --workspaces # the Workspace overview +dor list --all # every Workspace, grouped ``` Lists every surface in the current workspace — terminals and browser surfaces, @@ -164,6 +166,24 @@ dor kill surface:3 --confirm-dangerously # only when already validated the text (≥4 non-whitespace chars) — use it as a cheap guard that you are killing what you think you are. +### `dor workspace` — the containers around surfaces + +```sh +dor workspace new build # create one, in the background +dor workspace switch workspace:build # move the user to it +dor workspace close workspace:2 --force # close it and everything in it +``` + +A Window holds several Workspaces, each with its own surfaces and its own +`surface:1`. You almost never need these: your commands land in the Workspace +you were started in, and creating one is a change the user sees. When you do, +name one as `workspace:<n>` (positional) or `workspace:<name>`, and pass +`--workspace <ref>` to any command — `split`, `ensure`, `read`, `send`, +`await`, `kill`, `iframe`, `ab` — to act in another one. A surface's stable id +finds it in any Workspace without that flag; `surface:N` does not, since every +Workspace has one. `close` refuses a Workspace holding your running work +unless you pass `--force`. + ### `dor ab` / `dor agent-browser` — agent-drivable browser pane Forwards everything to your installed `agent-browser` CLI (not bundled — diff --git a/dor/src/cli.ts b/dor/src/cli.ts index bec0c14ed..b49fbe9ab 100644 --- a/dor/src/cli.ts +++ b/dor/src/cli.ts @@ -18,6 +18,7 @@ import { sendCommand } from './commands/send.js'; import { skillCommand } from './commands/skill.js'; import { splitCommand } from './commands/split.js'; import { versionCommand } from './commands/version.js'; +import { workspaceCommand } from './commands/workspace.js'; import { errorLine, errorMessage, fail } from './commands/shared.js'; import type { CliEnv, @@ -52,8 +53,17 @@ export type { KillSurfaceConfirmation, KillSurfaceRequest, KillSurfaceResponse, + ListScope, ListSurfacesRequest, ListSurfacesResponse, + ListWorkspacesRequest, + ListWorkspacesResponse, + NewWorkspaceRequest, + CloseWorkspaceRequest, + RenameWorkspaceRequest, + SwitchWorkspaceRequest, + WorkspaceMutationResponse, + WorkspaceRow, ReadSurfaceRequest, ReadSurfaceResponse, ResolvedSplitDirection, @@ -85,6 +95,7 @@ const COMMANDS = [ iframeCommand, agentBrowserCommand, listCommand, + workspaceCommand, ] as const satisfies readonly Command[]; const ROUTES = { @@ -99,6 +110,7 @@ const ROUTES = { iframe: iframeCommand.command, 'agent-browser': agentBrowserCommand.command, list: listCommand.command, + workspace: workspaceCommand.command, }; const DOR_TEXT: ApplicationText = { diff --git a/dor/src/commands/agent-browser.ts b/dor/src/commands/agent-browser.ts index 21c96f059..325da074f 100644 --- a/dor/src/commands/agent-browser.ts +++ b/dor/src/commands/agent-browser.ts @@ -24,7 +24,7 @@ import type { DorCommandContext, ParseResult, } from './types.js'; -import { errorMessage, fail, requireControlClient, stringParser } from './shared.js'; +import { errorMessage, fail, requireControlClient, stringParser, workspaceFlag, workspaceParam } from './shared.js'; import { inferredHttpUrl, isSpecialOpenTarget, @@ -70,14 +70,14 @@ export const agentBrowserCommand: Command = { helpPatches: [ { scope: 'root', - findReplace: ['agent-browser [--key name] [--session name] [--surface handle]<TO-EOL>', 'agent-browser [--key name|--session name|--surface handle] [args...]\n'], + findReplace: ['agent-browser [--key name] [--session name] [--surface handle]<TO-EOL>', 'agent-browser [--key name|--session name|--surface handle] [--workspace ref] [args...]\n'], }, { scope: 'command-usage', - findReplace: ['agent-browser [--key name] [--session name] [--surface handle]<TO-EOL>', 'agent-browser [--key name|--session name|--surface handle] [args...]\n'], + findReplace: ['agent-browser [--key name] [--session name] [--surface handle]<TO-EOL>', 'agent-browser [--key name|--session name|--surface handle] [--workspace ref] [args...]\n'], }, ], - command: buildCommand<{ key?: string; session?: string; surface?: string }, [...args: string[]], DorCommandContext>({ + command: buildCommand<{ key?: string; session?: string; surface?: string; workspace?: string }, [...args: string[]], DorCommandContext>({ docs: { brief: 'Drive a browser surface via your agent-browser install (alias: dor ab).', fullDescription: `Forwards all arguments verbatim to your own agent-browser binary and binds the session to a Dormouse browser surface. @@ -91,6 +91,10 @@ dor intercepts exactly three mutually exclusive identity flags: host which agent-browser session that Surface is bound to, which is the only way to address a GUI-spawned session. +It also intercepts --workspace <ref>, which is not an identity: it says which +Workspace of this Window the browser Surface is opened in and which one a +handle resolves against (workspace:<n> or workspace:<name>). + Everything else — subcommands, flags, selectors — is agent-browser's own command surface. The binary is resolved from PATH (override with DORMOUSE_AGENT_BROWSER_BIN) and is never bundled; install it with: @@ -120,6 +124,7 @@ Examples: key: { kind: 'parsed', parse: stringParser, brief: 'Workspace-scoped browser key (default "default").', optional: true, placeholder: 'name' }, session: { kind: 'parsed', parse: stringParser, brief: 'Raw agent-browser session name (mutually exclusive with --key/--surface).', optional: true, placeholder: 'name' }, surface: { kind: 'parsed', parse: stringParser, brief: 'Surface handle whose bound session to drive (mutually exclusive with --key/--session).', optional: true, placeholder: 'handle' }, + workspace: workspaceFlag, }, positional: { kind: 'array', @@ -127,7 +132,7 @@ Examples: minimum: 0, }, }, - func: async function (this: DorCommandContext, _flags: { key?: string; session?: string; surface?: string }, ..._args: string[]): Promise<void | Error> { + func: async function (this: DorCommandContext, _flags: { key?: string; session?: string; surface?: string; workspace?: string }, ..._args: string[]): Promise<void | Error> { // runCli intercepts every non-help agent-browser invocation before // stricli; reaching this func means that interception regressed. return new Error('internal: agent-browser passthrough was not intercepted'); @@ -138,7 +143,11 @@ Examples: /** The three identity flags dor intercepts, in the order they are reported when * more than one is given. */ const IDENTITY_FLAGS = ['--key', '--session', '--surface'] as const; -type IdentityFlag = (typeof IDENTITY_FLAGS)[number]; + +/** Every flag dor takes out of the forwarded argv: the identities plus the + * container, which names a Workspace rather than a browser. */ +const INTERCEPTED_FLAGS = [...IDENTITY_FLAGS, '--workspace'] as const; +type InterceptedFlag = (typeof INTERCEPTED_FLAGS)[number]; /** Either a session known CLI-side (from `--session`, or namespaced from * `--key`) or a Surface handle for the host to resolve — never neither, never @@ -146,18 +155,18 @@ type IdentityFlag = (typeof IDENTITY_FLAGS)[number]; * the arm that has a surface, by construction. `key` rides along only when it * named the session: a raw or surface-addressed session may be GUI-minted, * which no key names. */ -type ResolvedSessionFlags = { rest: string[] } & ( +type ResolvedSessionFlags = { rest: string[]; workspace?: string } & ( | { session: string; key?: string; surface?: undefined } | { surface: string; session?: undefined; key?: undefined } ); export function extractSessionFlags(args: string[]): ParseResult<ResolvedSessionFlags> { - const values = new Map<IdentityFlag, string>(); + const values = new Map<InterceptedFlag, string>(); const rest: string[] = []; for (let index = 0; index < args.length; index += 1) { const arg = args[index] ?? ''; - const flag = IDENTITY_FLAGS.find((name) => arg === name || arg.startsWith(`${name}=`)); + const flag = INTERCEPTED_FLAGS.find((name) => arg === name || arg.startsWith(`${name}=`)); if (!flag) { rest.push(arg); continue; @@ -191,14 +200,17 @@ export function extractSessionFlags(args: string[]): ParseResult<ResolvedSession return { ok: false, message: `--key must match ${KEY_PATTERN} (it becomes part of an agent-browser session name)` }; } + const container = values.get('--workspace'); + const workspace = container === undefined ? {} : { workspace: container }; + const surface = values.get('--surface'); - if (surface !== undefined) return { ok: true, value: { surface, rest } }; + if (surface !== undefined) return { ok: true, value: { surface, rest, ...workspace } }; const session = values.get('--session'); - if (session !== undefined) return { ok: true, value: { session, rest } }; + if (session !== undefined) return { ok: true, value: { session, rest, ...workspace } }; const resolvedKey = key ?? 'default'; - return { ok: true, value: { key: resolvedKey, session: sessionForKey(resolvedKey), rest } }; + return { ok: true, value: { key: resolvedKey, session: sessionForKey(resolvedKey), rest, ...workspace } }; } export async function runAgentBrowserCli(args: string[], options: CliOptions): Promise<CliResult> { @@ -217,7 +229,7 @@ export async function runAgentBrowserCli(args: string[], options: CliOptions): P // `dor ab open <target>` accepts a Surface handle / bare :port wherever it // takes a URL; resolve it to a URL before forwarding, because agent-browser // only understands URLs. Every other command's args pass through untouched. - const resolvedRest = await resolveOpenTargetArgs(flags.value.rest, options); + const resolvedRest = await resolveOpenTargetArgs(flags.value.rest, options, flags.value.workspace); if (!resolvedRest.ok) return fail(resolvedRest.message); const rest = resolvedRest.value; @@ -268,6 +280,7 @@ export async function runAgentBrowserCli(args: string[], options: CliOptions): P session, wsPort, ...(binaryPath ? { binaryPath } : {}), + ...workspaceParam(flags.value.workspace), }); } catch (error) { stderrSuffix = `Warning: could not open the Dormouse browser surface: ${errorMessage(error)}\n`; @@ -299,7 +312,10 @@ async function resolveSession( const client = requireControlClient(options); if (client instanceof Error) return { ok: false, message: client.message }; try { - const { session } = await client.resolveAgentBrowserSession({ surface: flags.surface }); + const { session } = await client.resolveAgentBrowserSession({ + surface: flags.surface, + ...workspaceParam(flags.workspace), + }); return { ok: true, value: session }; } catch (error) { return { ok: false, message: errorMessage(error) }; @@ -324,7 +340,11 @@ const OPEN_SUBCOMMANDS = new Set(['open', 'goto', 'navigate']); * rejects a bare-integer host so a stray `n:n` value can't become a URL. Only the * first special-shaped arg is rewritten — these verbs take a single target. */ -async function resolveOpenTargetArgs(rest: string[], options: CliOptions): Promise<ParseResult<string[]>> { +async function resolveOpenTargetArgs( + rest: string[], + options: CliOptions, + workspace?: string, +): Promise<ParseResult<string[]>> { const subcommand = rest.find((arg) => !arg.startsWith('-')); if (subcommand === undefined || !OPEN_SUBCOMMANDS.has(subcommand)) return { ok: true, value: rest }; @@ -338,7 +358,7 @@ async function resolveOpenTargetArgs(rest: string[], options: CliOptions): Promi // there is no control endpoint and the error says so. const client = requireControlClient(options); if (client instanceof Error) return { ok: false, message: client.message }; - const resolved = await resolveSurfaceOpenTarget(raw, client); + const resolved = await resolveSurfaceOpenTarget(raw, client, workspace); if (!resolved.ok) return resolved; url = resolved.value; } else { diff --git a/dor/src/commands/await.ts b/dor/src/commands/await.ts index 1d65e49b8..4dae65948 100644 --- a/dor/src/commands/await.ts +++ b/dor/src/commands/await.ts @@ -21,11 +21,14 @@ import { renderJson, requireControlClient, stringParser, + workspaceFlag, + workspaceParam, writeStderr, writeStdout, } from './shared.js'; interface AwaitFlags { + readonly workspace?: string; readonly json?: boolean; readonly timeout?: number; readonly until: AwaitUntil; @@ -91,14 +94,14 @@ export const awaitCommand: Command = { scope: 'root', findReplace: [ ' dor await [--json] [--timeout seconds] (--until condition)<TO-EOL>', - ' dor await <surface> --until condition [--json] [--timeout seconds]\n', + ' dor await <surface> --until condition [--json] [--timeout seconds] [--workspace ref]\n', ], }, ], command: buildCommand<AwaitFlags, [string], DorCommandContext>({ docs: { brief: 'Wait until a terminal surface finishes.', - customUsage: ['<surface> --until condition [--json] [--timeout seconds]'], + customUsage: ['<surface> --until condition [--json] [--timeout seconds] [--workspace ref]'], fullDescription: FULL_DESCRIPTION, }, parameters: { @@ -106,6 +109,7 @@ export const awaitCommand: Command = { json: { kind: 'boolean', brief: 'Print JSON output.', optional: true, withNegated: false }, timeout: { kind: 'parsed', parse: parseTimeoutSeconds, brief: 'Seconds to wait before giving up. Default 600; max 86400.', optional: true, placeholder: 'seconds' }, until: { kind: 'parsed', parse: parseUntil, brief: 'What to wait for: quiet or exit.', optional: false, placeholder: 'condition' }, + workspace: workspaceFlag, }, positional: { kind: 'tuple', @@ -127,7 +131,12 @@ async function runAwaitCommand(this: DorCommandContext, flags: AwaitFlags, surfa let response: AwaitSurfaceResponse; try { - response = await client.awaitSurface({ surface, until: flags.until, timeoutMs: timeoutSeconds * 1000 }); + response = await client.awaitSurface({ + surface, + until: flags.until, + timeoutMs: timeoutSeconds * 1000, + ...workspaceParam(flags.workspace), + }); } catch (error) { return new Error(errorMessage(error)); } diff --git a/dor/src/commands/ensure.ts b/dor/src/commands/ensure.ts index a107572f1..cbec429ef 100644 --- a/dor/src/commands/ensure.ts +++ b/dor/src/commands/ensure.ts @@ -13,10 +13,13 @@ import { renderJson, requireControlClient, stringParser, + workspaceFlag, + workspaceParam, writeStdout, } from './shared.js'; interface EnsureFlags { + readonly workspace?: string; readonly json?: boolean; readonly minimize?: boolean; readonly restart?: boolean; @@ -51,7 +54,7 @@ export function validateEnsureDelimiter(args: string[]): ParseResult<void> { if (arg === '--json' || arg === '--minimize' || arg === '--restart') { continue; } - if (arg === '--cwd' || arg === '--surface') { + if (arg === '--cwd' || arg === '--surface' || arg === '--workspace') { const value = args[index + 1]; if (!value || value.startsWith('-') || index + 1 >= delimiterIndex) { return { ok: false, message: `${arg} requires a value` }; @@ -80,15 +83,15 @@ export const ensureCommand: Command = { { scope: 'root', findReplace: [ - ' dor ensure [--json] [--minimize] [--restart] [--surface id|ref] [--cwd path]<TO-EOL>', - ' dor ensure [--json] [--minimize] [--restart] [--surface id|ref] [--cwd path] -- <command>...\n', + ' dor ensure [--json] [--minimize] [--restart] [--surface id|ref] [--cwd path] [--workspace ref]<TO-EOL>', + ' dor ensure [--json] [--minimize] [--restart] [--surface id|ref] [--cwd path] [--workspace ref] -- <command>...\n', ], }, { scope: 'command-usage', findReplace: [ - ' dor ensure [--json] [--minimize] [--restart] [--surface id|ref] [--cwd path]<TO-EOL>', - ' dor ensure [--json] [--minimize] [--restart] [--surface id|ref] [--cwd path] -- <command>...\n', + ' dor ensure [--json] [--minimize] [--restart] [--surface id|ref] [--cwd path] [--workspace ref]<TO-EOL>', + ' dor ensure [--json] [--minimize] [--restart] [--surface id|ref] [--cwd path] [--workspace ref] -- <command>...\n', ], }, { @@ -141,6 +144,7 @@ JSON output: restart: { kind: 'boolean', brief: 'Restart a matching surface in place.', optional: true, withNegated: false }, surface: { kind: 'parsed', parse: stringParser, brief: 'Surface to split when creating.', optional: true, placeholder: 'id|ref' }, cwd: { kind: 'parsed', parse: stringParser, brief: 'Working directory for matching and for the new command.', optional: true, placeholder: 'path' }, + workspace: workspaceFlag, }, positional: { kind: 'array', @@ -167,6 +171,7 @@ async function runEnsureCommand(this: DorCommandContext, flags: EnsureFlags, ... restart: flags.restart === true, surface: flags.surface, cwd: callerWorkingDirectory(flags.cwd, this.options.env), + ...workspaceParam(flags.workspace), }); writeStdout(this, renderEnsureResponse(response, flags.json === true)); return undefined; diff --git a/dor/src/commands/iframe.ts b/dor/src/commands/iframe.ts index de71559dd..1c4e53637 100644 --- a/dor/src/commands/iframe.ts +++ b/dor/src/commands/iframe.ts @@ -11,6 +11,8 @@ import { renderJson, requireControlClient, stringParser, + workspaceFlag, + workspaceParam, writeStdout, } from './shared.js'; import { @@ -20,6 +22,7 @@ import { } from './open-target.js'; interface IframeFlags { + readonly workspace?: string; readonly json?: boolean; readonly minimize?: boolean; readonly surface?: string; @@ -64,6 +67,7 @@ JSON output: json: { kind: 'boolean', brief: 'Print JSON output.', optional: true, withNegated: false }, minimize: { kind: 'boolean', brief: 'Create or replace the surface minimized.', optional: true, withNegated: false }, surface: { kind: 'parsed', parse: stringParser, brief: 'Surface to replace or split from.', optional: true, placeholder: 'id|ref' }, + workspace: workspaceFlag, }, positional: { kind: 'tuple', @@ -84,7 +88,7 @@ async function runIframeCommand(this: DorCommandContext, flags: IframeFlags, tar // concrete targets (URL / bare :port) were already normalized at parse time. let url = target; if (isSurfaceOpenTarget(target)) { - const resolved = await resolveSurfaceOpenTarget(target, client); + const resolved = await resolveSurfaceOpenTarget(target, client, flags.workspace); if (!resolved.ok) return new Error(resolved.message); url = resolved.value; } @@ -94,6 +98,7 @@ async function runIframeCommand(this: DorCommandContext, flags: IframeFlags, tar minimized: flags.minimize === true, surface: flags.surface, url, + ...workspaceParam(flags.workspace), }); writeStdout(this, renderIframeResponse(response, flags.json === true)); return undefined; diff --git a/dor/src/commands/kill.ts b/dor/src/commands/kill.ts index 458856116..9e09ee26a 100644 --- a/dor/src/commands/kill.ts +++ b/dor/src/commands/kill.ts @@ -13,10 +13,13 @@ import { renderJson, requireControlClient, stringParser, + workspaceFlag, + workspaceParam, writeStdout, } from './shared.js'; interface KillFlags { + readonly workspace?: string; readonly confirmDangerously?: boolean; readonly confirmIfRead?: string; readonly json?: boolean; @@ -29,14 +32,14 @@ export const killCommand: Command = { scope: 'root', findReplace: [ ' dor kill [--confirm-dangerously] [--confirm-if-read text] [--json]<TO-EOL>', - ' dor kill <surface> [--confirm-if-read text|--confirm-dangerously] [--json]\n', + ' dor kill <surface> [--confirm-if-read text|--confirm-dangerously] [--json] [--workspace ref]\n', ], }, ], command: buildCommand<KillFlags, [string], DorCommandContext>({ docs: { brief: 'Kill a surface.', - customUsage: ['<surface> [--confirm-if-read text|--confirm-dangerously] [--json]'], + customUsage: ['<surface> [--confirm-if-read text|--confirm-dangerously] [--json] [--workspace ref]'], fullDescription: `Kills a surface. One confirmation mode is required. --confirm-if-read kills only if dor read <surface> would return visible text containing the provided text. The text must contain at least 4 non-whitespace characters. @@ -58,6 +61,7 @@ JSON output: confirmDangerously: { kind: 'boolean', brief: 'Kill without further confirmation.', optional: true, withNegated: false }, confirmIfRead: { kind: 'parsed', parse: stringParser, brief: 'Kill only if dor read contains this text.', optional: true, placeholder: 'text' }, json: { kind: 'boolean', brief: 'Print JSON output.', optional: true, withNegated: false }, + workspace: workspaceFlag, }, positional: { kind: 'tuple', @@ -81,6 +85,7 @@ async function runKillCommand(this: DorCommandContext, flags: KillFlags, surface const response = await client.killSurface({ confirmation: confirmation.value, surface, + ...workspaceParam(flags.workspace), }); writeStdout(this, renderKillResponse(response, flags.json === true)); return undefined; diff --git a/dor/src/commands/list.ts b/dor/src/commands/list.ts index 834650b4b..a5c9a4919 100644 --- a/dor/src/commands/list.ts +++ b/dor/src/commands/list.ts @@ -4,7 +4,10 @@ * `dor identify` used to print (caller / focused pointers + host block). * * Lists every Surface in the current Workspace, including minimized ones, and - * optionally each terminal's listening ports (`--ports` / `--port`). + * optionally each terminal's listening ports (`--ports` / `--port`). It also + * owns every cross-Workspace read: `--workspace` narrows to one, `--all` groups + * every Workspace's Surfaces, and `--workspaces` is the overview + * (`docs/specs/dor-cli.md` → "dor workspace" owns the mutation half). */ import { buildCommand, type FlagParametersForType } from '@stricli/core'; @@ -14,10 +17,12 @@ import type { DorCommandContext, IdFormat, ListSurfacesResponse, + ListWorkspacesResponse, Surface, SurfaceKind, SurfacePort, SurfaceView, + WorkspaceRow, } from './types.js'; import { hasBrowser, hasTerminal, SURFACE_KINDS } from './types.js'; import { @@ -33,6 +38,7 @@ import { } from './shared.js'; interface ListFlags { + readonly all?: boolean; readonly command?: string; readonly cwd?: string; readonly idFormat?: IdFormat; @@ -41,6 +47,8 @@ interface ListFlags { readonly port?: number; readonly ports?: boolean; readonly view?: SurfaceView; + readonly workspace?: string; + readonly workspaces?: boolean; } const FULL_DESCRIPTION = `Lists every Surface in the current Workspace — terminals and browser Surfaces, including minimized ones (view "minimized"). @@ -55,8 +63,20 @@ Filters are ANDed. --command is an exact match against the running command repor JSON output (--json) always includes both stable ids and refs, and each row carries has_terminal (a PTY) and has_browser (a browser renderer) — gate on those, not on kind, so a Surface that has both still matches. It adds top-level caller_surface_ref/caller_surface_id and focused_surface_ref/focused_surface_id — the calling and focused Surfaces, null when neither is in the list — plus workspace_ref, window_ref, and a host block (app, workspace, cli_js_path, node_path): the identity dump dor identify used to print. +--workspace <ref> lists another Workspace of this Window instead: workspace:<n> (positional) or workspace:<name>, which resolves only when exactly one Workspace carries that name. Both are accepted bare ("2", "build"). + +--all lists every Workspace of this Window, grouped under a Workspace header. Rows keep their own Workspace-scoped surface:N refs, so several groups have a surface:1 and several may carry the focus marker; each JSON row adds workspace_ref, and the payload adds a workspaces array. Target a row from another Workspace by its stable id, or pass --workspace. + +--workspaces prints the Workspace overview instead of any Surface: one row per Workspace with the active marker, its name, [ringing]/[todo] when any member Surface is, and [attention N] for the number owing it. It takes no other flag but --json. + Text output: - * surface:1 terminal - paned ~/projects/site pnpm dev :5173`; + * surface:1 terminal - paned ~/projects/site pnpm dev :5173 + + workspace:1 Workspace 1 [active] + * surface:1 terminal - paned ~/projects/site pnpm dev + + * workspace:1 Workspace 1 + workspace:2 build [ringing] [attention 1]`; export const listCommand: Command = { name: 'list', @@ -65,6 +85,12 @@ export const listCommand: Command = { function buildListCommand(): Command['command'] { const flags: FlagParametersForType<ListFlags, DorCommandContext> = { + all: { + kind: 'boolean', + brief: 'List every Workspace, grouped by a Workspace header.', + optional: true, + withNegated: false, + }, command: { kind: 'parsed', parse: stringParser, @@ -114,12 +140,28 @@ function buildListCommand(): Command['command'] { optional: true, placeholder: 'paned|zoomed|minimized', }, + workspace: { + kind: 'parsed', + parse: stringParser, + brief: 'Workspace to list instead of the caller\'s.', + optional: true, + placeholder: 'ref', + }, + workspaces: { + kind: 'boolean', + brief: 'Print the Workspace overview instead of Surfaces.', + optional: true, + withNegated: false, + }, }; return buildCommand<ListFlags, [], DorCommandContext>({ docs: { brief: 'List Dormouse Surfaces.', - customUsage: ['[--kind terminal|browser] [--view paned|zoomed|minimized] [--command text] [--cwd path] [--port number] [--ports] [--json] [--id-format refs|ids|both]'], + customUsage: [ + '[--workspace ref|--all] [--kind terminal|browser] [--view paned|zoomed|minimized] [--command text] [--cwd path] [--port number] [--ports] [--json] [--id-format refs|ids|both]', + '--workspaces [--json]', + ], fullDescription: FULL_DESCRIPTION, }, parameters: { flags }, @@ -133,12 +175,26 @@ async function runListCommand( flags: ListFlags, context: DorCommandContext, ): Promise<void | Error> { + const scoping = checkScopeFlags(flags); + if (!scoping.ok) return new Error(scoping.message); + const client = requireControlClient(context.options); if (client instanceof Error) return client; try { + if (flags.workspaces === true) { + const overview = await client.listWorkspaces({}); + writeStdout(context, flags.json === true + ? renderWorkspacesJson(overview) + : renderWorkspacesText(overview)); + return undefined; + } const includePorts = flags.ports === true || flags.port !== undefined; - const response = await client.listSurfaces({ includePorts }); + const response = await client.listSurfaces({ + includePorts, + ...(flags.all === true ? { scope: 'all' as const } : {}), + ...(flags.workspace === undefined ? {} : { workspace: flags.workspace }), + }); const env = context.options.env ?? {}; const filtered = applyListFilters(response, flags, env); const idFormat = flags.idFormat ?? 'refs'; @@ -152,6 +208,23 @@ async function runListCommand( } } +/** The three container flags name one scope between them, and the overview is a + * different listing rather than a filter on this one. */ +function checkScopeFlags(flags: ListFlags): { ok: true } | { ok: false; message: string } { + if (flags.all === true && flags.workspace !== undefined) { + return { ok: false, message: '--all and --workspace are mutually exclusive' }; + } + if (flags.workspaces === true) { + const others = Object.entries(flags) + .filter(([name, value]) => name !== 'workspaces' && name !== 'json' && value !== undefined) + .map(([name]) => `--${name.replace(/[A-Z]/g, (upper) => `-${upper.toLowerCase()}`)}`); + if (others.length > 0) { + return { ok: false, message: `dor list --workspaces takes only --json, not ${others.join(', ')}` }; + } + } + return { ok: true }; +} + // Display predicates applied to the host's full surface projection. Cheap by // construction: `--port` is the only filter here that needs host data beyond the // projection, and it pays for it by opting into the port scan up in the caller. @@ -179,14 +252,48 @@ function surfaceLocation(surface: Surface): string { return surface.cwd ?? surface.url ?? ''; } +/** + * Rows for one Workspace, or every group under its Workspace header when the + * answer spans them (`--all`). Column widths are computed across every row, so + * the groups line up with each other. + */ function renderListText( response: ListSurfacesResponse, env: Record<string, string | undefined>, idFormat: IdFormat, includePorts: boolean, ): string { + const rows = surfaceRows(response, env, idFormat, includePorts); + if (!response.workspaces) return rows.length === 0 ? '' : `${rows.join('\n')}\n`; + + const byWorkspace = new Map<string, string[]>(); + response.surfaces.forEach((surface, index) => { + const ref = surface.workspaceRef ?? response.workspaceRef; + const group = byWorkspace.get(ref) ?? []; + group.push(` ${rows[index]}`); + byWorkspace.set(ref, group); + }); + + const groups = response.workspaces + // A Workspace every filter emptied prints no header: the group is not there + // to be listed. + .filter((workspace) => (byWorkspace.get(workspace.ref) ?? []).length > 0) + .map((workspace) => [ + `${workspace.ref} ${workspace.name}${workspace.active ? ' [active]' : ''}`, + ...(byWorkspace.get(workspace.ref) ?? []), + ].join('\n')); + return groups.length === 0 ? '' : `${groups.join('\n\n')}\n`; +} + +/** One text row per Surface, in response order, sharing one set of columns. */ +function surfaceRows( + response: ListSurfacesResponse, + env: Record<string, string | undefined>, + idFormat: IdFormat, + includePorts: boolean, +): string[] { const surfaces = response.surfaces; - if (surfaces.length === 0) return ''; + if (surfaces.length === 0) return []; const callerId = env.DORMOUSE_SURFACE_ID; const handles = surfaces.map((surface) => renderHandle(surface, idFormat)); @@ -219,9 +326,46 @@ function renderListText( return `${marker} ${handle} ${kind} ${renderMode} ${view} ${location} ${surface.title}${trailer}`.trimEnd(); }); + return lines; +} + +/** The Workspace overview (`dor list --workspaces`). */ +function renderWorkspacesText(response: ListWorkspacesResponse): string { + const rows = response.workspaces; + if (rows.length === 0) return ''; + const refWidth = Math.max(...rows.map((row) => row.ref.length)); + const nameWidth = Math.max(...rows.map((row) => row.name.length)); + const lines = rows.map((row) => { + const tags = [ + ...(row.ringing ? ['[ringing]'] : []), + ...(row.todo ? ['[todo]'] : []), + ...(row.count > 0 ? [`[attention ${row.count}]`] : []), + ]; + const trailer = tags.length > 0 ? ` ${tags.join(' ')}` : ''; + return `${row.active ? '*' : ' '} ${row.ref.padEnd(refWidth)} ${row.name.padEnd(nameWidth)}${trailer}`.trimEnd(); + }); return `${lines.join('\n')}\n`; } +function renderWorkspacesJson(response: ListWorkspacesResponse): string { + return renderJson({ + workspaces: response.workspaces.map(renderWorkspaceJson), + window_ref: response.windowRef, + }); +} + +function renderWorkspaceJson(row: WorkspaceRow): Record<string, unknown> { + return { + ref: row.ref, + id: row.id, + name: row.name, + active: row.active, + ringing: row.ringing, + todo: row.todo, + count: row.count, + }; +} + function renderListJson( response: ListSurfacesResponse, env: Record<string, string | undefined>, @@ -239,6 +383,7 @@ function renderListJson( focused_surface_id: focused?.id ?? null, window_ref: response.windowRef, workspace_ref: response.workspaceRef, + ...(response.workspaces ? { workspaces: response.workspaces.map(renderWorkspaceJson) } : {}), host: { app: env.DORMOUSE_HOST ?? null, workspace: env.DORMOUSE_HOST_WORKSPACE ?? null, @@ -276,6 +421,9 @@ function renderSurfaceJson( ...(includePorts && hasTerminal(surface.kind) ? { ports: (surface.ports ?? []).map(renderPortJson) } : {}), + // Only a cross-Workspace listing carries it; within one Workspace the + // top-level `workspace_ref` already says which. + ...(surface.workspaceRef ? { workspace_ref: surface.workspaceRef } : {}), }; } diff --git a/dor/src/commands/open-target.ts b/dor/src/commands/open-target.ts index 2ad5c56e6..2e7c85df4 100644 --- a/dor/src/commands/open-target.ts +++ b/dor/src/commands/open-target.ts @@ -88,9 +88,13 @@ export function normalizeConcreteOpenUrl(target: string): string { export async function resolveSurfaceOpenTarget( target: string, client: ControlClient, + workspace?: string, ): Promise<ParseResult<string>> { try { - const { url } = await client.resolveOpenTarget({ surface: target }); + const { url } = await client.resolveOpenTarget({ + surface: target, + ...(workspace === undefined ? {} : { workspace }), + }); return { ok: true, value: url }; } catch (error) { return { ok: false, message: errorMessage(error) }; diff --git a/dor/src/commands/read.ts b/dor/src/commands/read.ts index fdb67fc18..c4be8f87c 100644 --- a/dor/src/commands/read.ts +++ b/dor/src/commands/read.ts @@ -12,10 +12,13 @@ import { renderJson, requireControlClient, stringParser, + workspaceFlag, + workspaceParam, writeStdout, } from './shared.js'; interface ReadFlags { + readonly workspace?: string; readonly json?: boolean; readonly lines?: number; readonly scrollback?: boolean; @@ -28,14 +31,14 @@ export const readCommand: Command = { scope: 'root', findReplace: [ ' dor read [--json] [--lines count] [--scrollback]<TO-EOL>', - ' dor read <surface> [--json] [--lines count] [--scrollback]\n', + ' dor read <surface> [--json] [--lines count] [--scrollback] [--workspace ref]\n', ], }, ], command: buildCommand<ReadFlags, [string], DorCommandContext>({ docs: { brief: 'Read terminal text from a surface.', - customUsage: ['<surface> [--json] [--lines count] [--scrollback]'], + customUsage: ['<surface> [--json] [--lines count] [--scrollback] [--workspace ref]'], fullDescription: `Reads the visible screen text from the target terminal surface. Use --scrollback to include terminal history, and --lines to limit how much text is returned. Text mode prints terminal text directly. @@ -53,6 +56,7 @@ JSON output: json: { kind: 'boolean', brief: 'Print JSON output.', optional: true, withNegated: false }, lines: { kind: 'parsed', parse: parseLineCount, brief: 'Maximum number of lines to return.', optional: true, placeholder: 'count' }, scrollback: { kind: 'boolean', brief: 'Include terminal scrollback/history instead of only the visible screen.', optional: true, withNegated: false }, + workspace: workspaceFlag, }, positional: { kind: 'tuple', @@ -74,6 +78,7 @@ async function runReadCommand(this: DorCommandContext, flags: ReadFlags, surface ...(flags.lines !== undefined ? { lines: flags.lines } : {}), scrollback: flags.scrollback === true, surface, + ...workspaceParam(flags.workspace), }); writeStdout(this, renderReadResponse(response, flags.json === true)); return undefined; diff --git a/dor/src/commands/send.ts b/dor/src/commands/send.ts index 269b99c0c..a28a393d0 100644 --- a/dor/src/commands/send.ts +++ b/dor/src/commands/send.ts @@ -12,10 +12,13 @@ import { renderJson, requireControlClient, stringParser, + workspaceFlag, + workspaceParam, writeStdout, } from './shared.js'; interface SendFlags { + readonly workspace?: string; readonly json?: boolean; readonly key?: string; readonly raw?: boolean; @@ -82,14 +85,14 @@ export const sendCommand: Command = { scope: 'root', findReplace: [ ' dor send [--json] [--key value] [--raw] [--sequence json] [--stdin] [--text value]<TO-EOL>', - ' dor send <surface> ([--text value] [--key value] | --stdin | --sequence json) [--json] [--raw]\n', + ' dor send <surface> ([--text value] [--key value] | --stdin | --sequence json) [--json] [--raw] [--workspace ref]\n', ], }, ], command: buildCommand<SendFlags, [string], DorCommandContext>({ docs: { brief: 'Send text or key input to a terminal surface.', - customUsage: ['<surface> ([--text value] [--key value] | --stdin | --sequence json) [--json] [--raw]'], + customUsage: ['<surface> ([--text value] [--key value] | --stdin | --sequence json) [--json] [--raw] [--workspace ref]'], fullDescription: `Sends text or key input to a target terminal surface. Special keys must be sent with --key so values like "enter" are never confused with literal text. Exactly one input mode is required: --text/--key, --stdin, or --sequence. --text and --key may be combined only in that order; text is sent first, then the key. Duplicate input flags are rejected. Use --sequence for arbitrary ordering or multiple text/key events. @@ -123,6 +126,7 @@ Examples: sequence: { kind: 'parsed', parse: stringParser, brief: 'Send an ordered JSON sequence of text and key events.', optional: true, placeholder: 'json' }, stdin: { kind: 'boolean', brief: 'Read text from standard input and send it as text.', optional: true, withNegated: false }, text: { kind: 'parsed', parse: stringParser, brief: 'Send literal text.', optional: true }, + workspace: workspaceFlag, }, positional: { kind: 'tuple', @@ -151,6 +155,7 @@ async function runSendCommand(this: DorCommandContext, flags: SendFlags, surface surface, input: encoded.value.input, inputCount: encoded.value.inputCount, + ...workspaceParam(flags.workspace), }); writeStdout(this, renderSendResponse(response, flags.json === true)); return undefined; diff --git a/dor/src/commands/shared.ts b/dor/src/commands/shared.ts index 797ef808e..80772d39d 100644 --- a/dor/src/commands/shared.ts +++ b/dor/src/commands/shared.ts @@ -77,6 +77,21 @@ export function requireControlClient(options: CliOptions, timeoutMs?: number): C return result.ok ? result.value : new Error(result.message); } +/** The `--workspace <ref>` flag every action command carries, defined once so + * its wording cannot drift (`docs/specs/dor-cli.md` → "Handle Model"). */ +export const workspaceFlag = { + kind: 'parsed', + parse: stringParser, + brief: "Workspace to act in, instead of the caller's.", + optional: true, + placeholder: 'ref', +} as const; + +/** The `workspace` field of a request, present only when the flag was given. */ +export function workspaceParam(workspace: string | undefined): { workspace?: string } { + return workspace === undefined ? {} : { workspace }; +} + export function renderHandle(handle: { ref: string; id: string }, idFormat: IdFormat): string { switch (idFormat) { case 'refs': diff --git a/dor/src/commands/split.ts b/dor/src/commands/split.ts index 4ca27ef77..09a97ffb8 100644 --- a/dor/src/commands/split.ts +++ b/dor/src/commands/split.ts @@ -13,10 +13,13 @@ import { renderJson, requireControlClient, stringParser, + workspaceFlag, + workspaceParam, writeStdout, } from './shared.js'; interface SplitFlags { + readonly workspace?: string; readonly auto?: boolean; readonly down?: boolean; readonly json?: boolean; @@ -43,8 +46,8 @@ export const splitCommand: Command = { { scope: 'root', findReplace: [ - ` dor split ${groupedSplitDirectionUsage} [--json] [--minimize] [--surface id|ref]<TO-EOL>`, - ` dor split ${groupedSplitDirectionUsage} [--json] [--minimize] [--surface id|ref] [-- <command>...]\n`, + ` dor split ${groupedSplitDirectionUsage} [--json] [--minimize] [--surface id|ref] [--workspace ref]<TO-EOL>`, + ` dor split ${groupedSplitDirectionUsage} [--json] [--minimize] [--surface id|ref] [--workspace ref] [-- <command>...]\n`, ], }, { @@ -52,8 +55,10 @@ export const splitCommand: Command = { findReplace: [ '[--auto]', `${groupedSplitDirectionUsage}`, + // <TO-EOL> swallows the flags this patch is about to remove, so the + // tail is rewritten whole rather than matched around them. '[--surface id|ref]<TO-EOL>', - '[--surface id|ref] [-- <command>...]\n', + '[--surface id|ref] [--workspace ref] [-- <command>...]\n', ], remove: ['<WS>[--down]', '<WS>[--left]', '<WS>[--right]', '<WS>[--up]'], }, @@ -114,6 +119,7 @@ JSON output: right: { kind: 'boolean', brief: 'Split right of the target surface.', optional: true, withNegated: false }, surface: { kind: 'parsed', parse: stringParser, brief: 'Surface to split.', optional: true, placeholder: 'id|ref' }, up: { kind: 'boolean', brief: 'Split above the target surface.', optional: true, withNegated: false }, + workspace: workspaceFlag, }, positional: { kind: 'array', @@ -143,6 +149,7 @@ async function runSplitCommand(this: DorCommandContext, flags: SplitFlags, ...co // and an initial command alike leave it on the caller. The CLI owns the // whole decision so the host can honor the field as sent. focusNeutral: this.hasArgumentEscape || command !== undefined, + ...workspaceParam(flags.workspace), }); writeStdout(this, renderSplitResponse(response, flags.json === true)); return undefined; diff --git a/dor/src/commands/workspace.ts b/dor/src/commands/workspace.ts new file mode 100644 index 000000000..398147c39 --- /dev/null +++ b/dor/src/commands/workspace.ts @@ -0,0 +1,179 @@ +/** + * `dor workspace` — the Workspace mutation verbs (`workspace.*` control + * methods). Enumeration lives in `dor list` alone (`--workspaces` for the + * overview, `--all` for every Workspace's Surfaces), so this command never + * grows a `list`. + * + * One command with a leading action rather than a route map: `dor` has no other + * nested command, and the generated help — one page per top-level command — is + * what the published CLI reference renders (`docs/specs/website-docs.md`). + */ + +import { buildCommand } from '@stricli/core'; +import type { + Command, + ControlClient, + DorCommandContext, + ParseResult, + WorkspaceMutationResponse, +} from './types.js'; +import { + errorMessage, + renderJson, + requireControlClient, + stringParser, + writeStdout, +} from './shared.js'; + +interface WorkspaceFlags { + readonly force?: boolean; + readonly json?: boolean; +} + +const ACTIONS = ['new', 'rename', 'close', 'switch'] as const; +type WorkspaceAction = (typeof ACTIONS)[number]; + +const USAGE = [ + 'new [name] [--json]', + 'rename <workspace> <name> [--json]', + 'close <workspace> [--force] [--json]', + 'switch <workspace> [--json]', +]; + +export const workspaceCommand: Command = { + name: 'workspace', + helpPatches: [ + { + // stricli renders one usage line per command in root help; the four + // actions collapse to the shape they share. + scope: 'root', + findReplace: [ + ' dor workspace [--force] [--json]<TO-EOL>', + ' dor workspace new|rename|close|switch [args...] [--force] [--json]\n', + ], + }, + ], + command: buildCommand<WorkspaceFlags, string[], DorCommandContext>({ + docs: { + brief: 'Create, rename, close, or switch Workspaces.', + customUsage: USAGE, + fullDescription: `Manages this Window's Workspaces. Listing them is dor list --workspaces (the overview) and dor list --all (every Workspace's Surfaces); this command only mutates. + +A <workspace> target is workspace:<n> — positional, so a strip reorder renumbers it — or workspace:<name>, which resolves only when exactly one Workspace carries that name and otherwise fails listing the candidates. Both forms are also accepted bare ("2", "build"). + +new creates a Workspace in the background and prints its ref: it never moves the user to it, since that is a larger theft than the focus a bare dor split takes. Use dor workspace switch to activate one. Without a name, the Workspace is named "Workspace N". + +close archives and kills every Surface in the Workspace. It refuses — raising no confirmation, because the caller is a command rather than someone watching the Wall — when the Workspace holds a Surface the user has typed into or a running command; --force closes it anyway. The last remaining Workspace cannot be closed. + +Text output: + created workspace:2 "build" + closed workspace:2 "build" + +JSON output: + { + "status": "created", + "workspace_id": "...", + "workspace_ref": "workspace:2", + "name": "build" + }`, + }, + parameters: { + flags: { + force: { kind: 'boolean', brief: 'Close even when the Workspace holds running or touched Surfaces.', optional: true, withNegated: false }, + json: { kind: 'boolean', brief: 'Print JSON output.', optional: true, withNegated: false }, + }, + positional: { + kind: 'array', + minimum: 0, + parameter: { parse: stringParser, brief: 'Action, then its arguments.', placeholder: 'args' }, + }, + }, + func: runWorkspaceCommand, + }), +}; + +async function runWorkspaceCommand( + this: DorCommandContext, + flags: WorkspaceFlags, + ...args: string[] +): Promise<void | Error> { + const action = parseAction(args[0]); + if (!action.ok) return new Error(action.message); + const rest = args.slice(1); + const arity = checkArity(action.value, rest); + if (!arity.ok) return new Error(arity.message); + if (flags.force === true && action.value !== 'close') { + return new Error('--force applies only to dor workspace close'); + } + + const client = requireControlClient(this.options); + if (client instanceof Error) return client; + + try { + const response = await runAction(client, action.value, rest, flags); + writeStdout(this, renderWorkspaceResponse(response, flags.json === true)); + return undefined; + } catch (error) { + return new Error(errorMessage(error)); + } +} + +function parseAction(value: string | undefined): ParseResult<WorkspaceAction> { + const action = ACTIONS.find((candidate) => candidate === value); + if (action) return { ok: true, value: action }; + // The one wrong guess worth answering by name: enumeration lives in dor list. + if (value === 'list') { + return { ok: false, message: 'dor list --workspaces prints the Workspace overview; dor workspace only mutates' }; + } + return { + ok: false, + message: value === undefined + ? `dor workspace requires an action: ${ACTIONS.join(', ')}` + : `unknown dor workspace action '${value}' (expected ${ACTIONS.join(', ')})`, + }; +} + +function checkArity(action: WorkspaceAction, rest: string[]): ParseResult<void> { + const expected: Record<WorkspaceAction, string> = { + new: 'dor workspace new takes an optional name', + rename: 'dor workspace rename takes a workspace and a name', + close: 'dor workspace close takes one workspace', + switch: 'dor workspace switch takes one workspace', + }; + const ok = action === 'new' + ? rest.length <= 1 + : action === 'rename' + ? rest.length === 2 + : rest.length === 1; + return ok ? { ok: true, value: undefined } : { ok: false, message: expected[action] }; +} + +function runAction( + client: ControlClient, + action: WorkspaceAction, + rest: string[], + flags: WorkspaceFlags, +): Promise<WorkspaceMutationResponse> { + switch (action) { + case 'new': + return client.newWorkspace(rest[0] === undefined ? {} : { name: rest[0] }); + case 'rename': + return client.renameWorkspace({ workspace: rest[0], name: rest[1] }); + case 'close': + return client.closeWorkspace({ workspace: rest[0], force: flags.force === true }); + case 'switch': + return client.switchWorkspace({ workspace: rest[0] }); + } +} + +function renderWorkspaceResponse(response: WorkspaceMutationResponse, json: boolean): string { + if (json) { + return renderJson({ + status: response.status, + workspace_id: response.workspaceId, + workspace_ref: response.workspaceRef, + name: response.name, + }); + } + return `${response.status} ${response.workspaceRef} ${JSON.stringify(response.name)}\n`; +} diff --git a/dor/test/cli-output.test.mjs b/dor/test/cli-output.test.mjs index 07e0354e7..377f1e28f 100644 --- a/dor/test/cli-output.test.mjs +++ b/dor/test/cli-output.test.mjs @@ -96,6 +96,13 @@ const fixtureSurfaces = [ }, ]; +// The Window's Workspaces, as the host projects them for `--workspaces` and for +// the `--all` group headers. +const fixtureWorkspaces = [ + { ref: 'workspace:1', id: 'workspace-1', name: 'Workspace 1', active: true, ringing: false, todo: false, count: 0 }, + { ref: 'workspace:2', id: 'workspace-2b1c', name: 'build', active: false, ringing: true, todo: true, count: 2 }, +]; + // Listening ports the host would attach to a terminal Surface for `--ports`. const fixturePortsByRef = { 'surface:1': [{ family: 'IPv4', address: '0.0.0.0', port: 5173, pid: 4242, processName: 'node' }], @@ -107,6 +114,21 @@ function fixtureClient(surfacesFixture = fixtureSurfaces) { requests: [], async listSurfaces(request) { this.requests.push(request); + // Mirror the host's cross-Workspace listing: every Workspace's rows in + // one list, each tagged with the Workspace it came from, plus the + // directory the CLI renders headers from. + if (request.scope === 'all') { + return { + surfaces: [ + { ...fixtureSurfaces[0], workspaceRef: 'workspace:1' }, + { ...fixtureSurfaces[2], ref: 'surface:1', workspaceRef: 'workspace:2' }, + { ...fixtureSurfaces[3], ref: 'surface:2', workspaceRef: 'workspace:2' }, + ], + workspaces: fixtureWorkspaces, + windowRef: 'window:1', + workspaceRef: 'workspace:1', + }; + } const paneTarget = request.pane; const matched = paneTarget ? surfacesFixture.filter((surface) => ( @@ -227,6 +249,35 @@ function fixtureClient(surfacesFixture = fixtureSurfaces) { session: 'dormouse.1.gui-a1b2c3', }; }, + async listWorkspaces(request) { + this.requests.push({ method: 'listWorkspaces', request }); + return { workspaces: fixtureWorkspaces, windowRef: 'window:1' }; + }, + async newWorkspace(request) { + this.requests.push({ method: 'newWorkspace', request }); + return { + status: 'created', + workspaceId: 'workspace-9f3a', + workspaceRef: 'workspace:3', + name: request.name ?? 'Workspace 3', + }; + }, + async renameWorkspace(request) { + this.requests.push({ method: 'renameWorkspace', request }); + return { status: 'renamed', workspaceId: 'workspace-2b1c', workspaceRef: 'workspace:2', name: request.name }; + }, + async closeWorkspace(request) { + this.requests.push({ method: 'closeWorkspace', request }); + // Mirror the host: a Workspace holding work refuses without --force. + if (!request.force) { + throw new Error("workspace 'workspace:2' holds running or touched Surfaces; pass --force to close it"); + } + return { status: 'closed', workspaceId: 'workspace-2b1c', workspaceRef: 'workspace:2', name: 'build' }; + }, + async switchWorkspace(request) { + this.requests.push({ method: 'switchWorkspace', request }); + return { status: 'active', workspaceId: 'workspace-2b1c', workspaceRef: 'workspace:2', name: 'build' }; + }, async resolveOpenTarget(request) { this.requests.push({ method: 'resolveOpenTarget', request }); // Mirror the host: surface:1 owns port 5173; surface:2 owns nothing. @@ -1268,6 +1319,112 @@ test('list tags an awaited surface after its todo', async () => { ); }); +test('list --all groups every Workspace under a header', async () => { + const client = fixtureClient(); + const result = await runCli(['list', '--all'], { client, env: listEnv }); + assert.deepEqual(client.requests, [{ includePorts: false, scope: 'all' }]); + await snapshot('list-all-text', result); +}); + +test('list --all json tags each row with its Workspace and carries the directory', async () => { + const result = await runCli(['list', '--all', '--json'], { client: fixtureClient(), env: listEnv }); + const payload = JSON.parse(result.stdout); + assert.deepEqual( + payload.surfaces.map((surface) => [surface.workspace_ref, surface.ref]), + [['workspace:1', 'surface:1'], ['workspace:2', 'surface:1'], ['workspace:2', 'surface:2']], + ); + assert.deepEqual(payload.workspaces.map((row) => row.ref), ['workspace:1', 'workspace:2']); + await snapshot('list-all-json', result); +}); + +test('list --all applies row filters and drops the Workspaces they empty', async () => { + const result = await runCli(['list', '--all', '--kind', 'browser'], { client: fixtureClient(), env: listEnv }); + assert.equal(result.stdout.includes('workspace:1'), false); + assert.match(result.stdout, /^workspace:2 {2}build\n/); +}); + +test('list --workspace asks the host for another Workspace', async () => { + const client = fixtureClient(); + await runCli(['list', '--workspace', 'build'], { client, env: listEnv }); + assert.deepEqual(client.requests, [{ includePorts: false, workspace: 'build' }]); +}); + +test('list --workspaces prints the overview', async () => { + const client = fixtureClient(); + const result = await runCli(['list', '--workspaces'], { client, env: listEnv }); + assert.deepEqual(client.requests, [{ method: 'listWorkspaces', request: {} }]); + await snapshot('list-workspaces-text', result); + await snapshot( + 'list-workspaces-json', + await runCli(['list', '--workspaces', '--json'], { client: fixtureClient(), env: listEnv }), + ); +}); + +test('list container flags that name two scopes are refused', async () => { + await snapshot( + 'list-all-and-workspace', + await runCli(['list', '--all', '--workspace', '2'], { client: fixtureClient(), env: listEnv }), + ); + await snapshot( + 'list-workspaces-with-filter', + await runCli(['list', '--workspaces', '--kind', 'terminal'], { client: fixtureClient(), env: listEnv }), + ); +}); + +test('workspace mutation verbs', async () => { + const client = fixtureClient(); + await snapshot('workspace-new', await runCli(['workspace', 'new', 'build'], { client, env: listEnv })); + await snapshot( + 'workspace-new-json', + await runCli(['workspace', 'new', '--json'], { client: fixtureClient(), env: listEnv }), + ); + await snapshot( + 'workspace-rename', + await runCli(['workspace', 'rename', 'workspace:2', 'agents'], { client: fixtureClient(), env: listEnv }), + ); + await snapshot( + 'workspace-switch', + await runCli(['workspace', 'switch', 'build'], { client: fixtureClient(), env: listEnv }), + ); + assert.deepEqual(client.requests, [{ method: 'newWorkspace', request: { name: 'build' } }]); +}); + +test('workspace close refuses running work until forced', async () => { + await snapshot( + 'workspace-close-refused', + await runCli(['workspace', 'close', 'workspace:2'], { client: fixtureClient(), env: listEnv }), + ); + const client = fixtureClient(); + await snapshot('workspace-close-force', await runCli(['workspace', 'close', 'workspace:2', '--force'], { client, env: listEnv })); + assert.deepEqual(client.requests, [{ method: 'closeWorkspace', request: { workspace: 'workspace:2', force: true } }]); +}); + +test('workspace usage errors name the action', async () => { + await snapshot('workspace-missing-action', await runCli(['workspace'], { client: fixtureClient(), env: listEnv })); + await snapshot('workspace-unknown-action', await runCli(['workspace', 'destroy', 'x'], { client: fixtureClient(), env: listEnv })); + await snapshot('workspace-list-action', await runCli(['workspace', 'list'], { client: fixtureClient(), env: listEnv })); + await snapshot('workspace-rename-arity', await runCli(['workspace', 'rename', 'workspace:2'], { client: fixtureClient(), env: listEnv })); + await snapshot('workspace-force-misuse', await runCli(['workspace', 'switch', '2', '--force'], { client: fixtureClient(), env: listEnv })); +}); + +test('every action command forwards --workspace to the host', async () => { + const calls = [ + [['split', '--workspace', 'build', '--', 'pnpm', 'dev'], 'splitSurface'], + [['ensure', '--workspace', 'build', '--', 'pnpm', 'dev'], 'ensureSurface'], + [['send', 'surface:1', '--text', 'hi', '--workspace', 'build'], 'sendSurface'], + [['read', 'surface:1', '--workspace', 'build'], 'readSurface'], + [['kill', 'surface:1', '--confirm-dangerously', '--workspace', 'build'], 'killSurface'], + [['iframe', 'http://localhost:5173', '--workspace', 'build'], 'iframeSurface'], + ]; + for (const [argv, method] of calls) { + const client = fixtureClient(); + const result = await runCli(argv, { client, env: listEnv }); + assert.equal(result.exitCode, 0, `${argv[0]} should succeed: ${result.stderr}`); + const call = client.requests.find((entry) => entry.method === method); + assert.equal(call.request.workspace, 'build', `${argv[0]} should forward --workspace`); + } +}); + test('list json output', async () => { await snapshot( 'list-json', diff --git a/dor/test/snapshots/help/agent-browser.md b/dor/test/snapshots/help/agent-browser.md index 4f85072ea..2fc41fc5c 100644 --- a/dor/test/snapshots/help/agent-browser.md +++ b/dor/test/snapshots/help/agent-browser.md @@ -4,7 +4,7 @@ Invocation: `dor agent-browser --help` ```text USAGE - dor agent-browser [--key name|--session name|--surface handle] [args...] + dor agent-browser [--key name|--session name|--surface handle] [--workspace ref] [args...] dor agent-browser --help Forwards all arguments verbatim to your own agent-browser binary and binds the session to a Dormouse browser surface. @@ -18,6 +18,10 @@ dor intercepts exactly three mutually exclusive identity flags: host which agent-browser session that Surface is bound to, which is the only way to address a GUI-spawned session. +It also intercepts --workspace <ref>, which is not an identity: it says which +Workspace of this Window the browser Surface is opened in and which one a +handle resolves against (workspace:<n> or workspace:<name>). + Everything else — subcommands, flags, selectors — is agent-browser's own command surface. The binary is resolved from PATH (override with DORMOUSE_AGENT_BROWSER_BIN) and is never bundled; install it with: @@ -43,11 +47,12 @@ Examples: dor ab --surface surface:4 click @e3 # drives whatever surface:4 is bound to FLAGS - [--key] Workspace-scoped browser key (default "default"). - [--session] Raw agent-browser session name (mutually exclusive with --key/--surface). - [--surface] Surface handle whose bound session to drive (mutually exclusive with --key/--session). - -h --help Print help information and exit - -- All subsequent inputs should be interpreted as arguments + [--key] Workspace-scoped browser key (default "default"). + [--session] Raw agent-browser session name (mutually exclusive with --key/--surface). + [--surface] Surface handle whose bound session to drive (mutually exclusive with --key/--session). + [--workspace] Workspace to act in, instead of the caller's. + -h --help Print help information and exit + -- All subsequent inputs should be interpreted as arguments ARGUMENTS args... Arguments forwarded verbatim to agent-browser. diff --git a/dor/test/snapshots/help/await.md b/dor/test/snapshots/help/await.md index a094cdbd1..bc19ddeb5 100644 --- a/dor/test/snapshots/help/await.md +++ b/dor/test/snapshots/help/await.md @@ -4,7 +4,7 @@ Invocation: `dor await --help` ```text USAGE - dor await <surface> --until condition [--json] [--timeout seconds] + dor await <surface> --until condition [--json] [--timeout seconds] [--workspace ref] dor await --help Waits until a terminal surface finishes what it is doing, then reports why the wait ended. Lets an agent block on a peer it launched with `dor split` instead of polling `dor list` in a loop. @@ -42,11 +42,12 @@ Examples: CAUSE=$(dor await surface:3 --until quiet) FLAGS - [--json] Print JSON output. - [--timeout] Seconds to wait before giving up. Default 600; max 86400. - --until What to wait for: quiet or exit. - -h --help Print help information and exit - -- All subsequent inputs should be interpreted as arguments + [--json] Print JSON output. + [--timeout] Seconds to wait before giving up. Default 600; max 86400. + --until What to wait for: quiet or exit. + [--workspace] Workspace to act in, instead of the caller's. + -h --help Print help information and exit + -- All subsequent inputs should be interpreted as arguments ARGUMENTS surface Surface to wait on. diff --git a/dor/test/snapshots/help/dor.md b/dor/test/snapshots/help/dor.md index 1ee6a0e77..ab0a809a9 100644 --- a/dor/test/snapshots/help/dor.md +++ b/dor/test/snapshots/help/dor.md @@ -4,17 +4,18 @@ Invocation: `dor --help` ```text USAGE - dor split [--left|--right|--up|--down|--auto] [--json] [--minimize] [--surface id|ref] [-- <command>...] - dor ensure [--json] [--minimize] [--restart] [--surface id|ref] [--cwd path] -- <command>... + dor split [--left|--right|--up|--down|--auto] [--json] [--minimize] [--surface id|ref] [--workspace ref] [-- <command>...] + dor ensure [--json] [--minimize] [--restart] [--surface id|ref] [--cwd path] [--workspace ref] -- <command>... dor version [--json] dor skill [--install] [--json] - dor send <surface> ([--text value] [--key value] | --stdin | --sequence json) [--json] [--raw] - dor read <surface> [--json] [--lines count] [--scrollback] - dor await <surface> --until condition [--json] [--timeout seconds] - dor kill <surface> [--confirm-if-read text|--confirm-dangerously] [--json] - dor iframe [--json] [--minimize] [--surface id|ref] <target> - dor agent-browser [--key name|--session name|--surface handle] [args...] - dor list [--command text] [--cwd path] [--id-format refs|ids|both] [--json] [--kind terminal|browser] [--port number] [--ports] [--view paned|zoomed|minimized] + dor send <surface> ([--text value] [--key value] | --stdin | --sequence json) [--json] [--raw] [--workspace ref] + dor read <surface> [--json] [--lines count] [--scrollback] [--workspace ref] + dor await <surface> --until condition [--json] [--timeout seconds] [--workspace ref] + dor kill <surface> [--confirm-if-read text|--confirm-dangerously] [--json] [--workspace ref] + dor iframe [--json] [--minimize] [--surface id|ref] [--workspace ref] <target> + dor agent-browser [--key name|--session name|--surface handle] [--workspace ref] [args...] + dor list [--all] [--command text] [--cwd path] [--id-format refs|ids|both] [--json] [--kind terminal|browser] [--port number] [--ports] [--view paned|zoomed|minimized] [--workspace ref] [--workspaces] + dor workspace new|rename|close|switch [args...] [--force] [--json] dor --help Dormouse bundles the dor CLI into every terminal it launches. @@ -35,5 +36,6 @@ COMMANDS iframe Open a target in an iframe surface. agent-browser Drive a browser surface via your agent-browser install (alias: dor ab). list List Dormouse Surfaces. + workspace Create, rename, close, or switch Workspaces. ``` diff --git a/dor/test/snapshots/help/ensure.md b/dor/test/snapshots/help/ensure.md index 325748bb9..044c54746 100644 --- a/dor/test/snapshots/help/ensure.md +++ b/dor/test/snapshots/help/ensure.md @@ -4,7 +4,7 @@ Invocation: `dor ensure --help` ```text USAGE - dor ensure [--json] [--minimize] [--restart] [--surface id|ref] [--cwd path] -- <command>... + dor ensure [--json] [--minimize] [--restart] [--surface id|ref] [--cwd path] [--workspace ref] -- <command>... dor ensure --help Ensures one surface in the current workspace is running the given command at the given path. If it's already running, no-op. If it isn't, then it creates a split and runs the command. @@ -43,12 +43,13 @@ JSON output: } FLAGS - [--json] Print JSON output. - [--minimize] Create the surface minimized. - [--restart] Restart a matching surface in place. - [--surface] Surface to split when creating. - [--cwd] Working directory for matching and for the new command. - -h --help Print help information and exit - -- All subsequent inputs should be interpreted as arguments + [--json] Print JSON output. + [--minimize] Create the surface minimized. + [--restart] Restart a matching surface in place. + [--surface] Surface to split when creating. + [--cwd] Working directory for matching and for the new command. + [--workspace] Workspace to act in, instead of the caller's. + -h --help Print help information and exit + -- All subsequent inputs should be interpreted as arguments ``` diff --git a/dor/test/snapshots/help/iframe.md b/dor/test/snapshots/help/iframe.md index 138c7a174..9125ffb1f 100644 --- a/dor/test/snapshots/help/iframe.md +++ b/dor/test/snapshots/help/iframe.md @@ -4,7 +4,7 @@ Invocation: `dor iframe --help` ```text USAGE - dor iframe [--json] [--minimize] [--surface id|ref] <target> + dor iframe [--json] [--minimize] [--surface id|ref] [--workspace ref] <target> dor iframe --help Opens a target in a high-fidelity iframe surface for human inspection. @@ -37,11 +37,12 @@ JSON output: } FLAGS - [--json] Print JSON output. - [--minimize] Create or replace the surface minimized. - [--surface] Surface to replace or split from. - -h --help Print help information and exit - -- All subsequent inputs should be interpreted as arguments + [--json] Print JSON output. + [--minimize] Create or replace the surface minimized. + [--surface] Surface to replace or split from. + [--workspace] Workspace to act in, instead of the caller's. + -h --help Print help information and exit + -- All subsequent inputs should be interpreted as arguments ARGUMENTS target URL, host:port, :port, or surface handle to open. diff --git a/dor/test/snapshots/help/kill.md b/dor/test/snapshots/help/kill.md index 3a6ddf4e6..537a5879a 100644 --- a/dor/test/snapshots/help/kill.md +++ b/dor/test/snapshots/help/kill.md @@ -4,7 +4,7 @@ Invocation: `dor kill --help` ```text USAGE - dor kill <surface> [--confirm-if-read text|--confirm-dangerously] [--json] + dor kill <surface> [--confirm-if-read text|--confirm-dangerously] [--json] [--workspace ref] dor kill --help Kills a surface. One confirmation mode is required. @@ -27,6 +27,7 @@ FLAGS [--confirm-dangerously] Kill without further confirmation. [--confirm-if-read] Kill only if dor read contains this text. [--json] Print JSON output. + [--workspace] Workspace to act in, instead of the caller's. -h --help Print help information and exit -- All subsequent inputs should be interpreted as arguments diff --git a/dor/test/snapshots/help/list.md b/dor/test/snapshots/help/list.md index cd6c072b2..a2a7e08f2 100644 --- a/dor/test/snapshots/help/list.md +++ b/dor/test/snapshots/help/list.md @@ -4,7 +4,8 @@ Invocation: `dor list --help` ```text USAGE - dor list [--kind terminal|browser] [--view paned|zoomed|minimized] [--command text] [--cwd path] [--port number] [--ports] [--json] [--id-format refs|ids|both] + dor list [--workspace ref|--all] [--kind terminal|browser] [--view paned|zoomed|minimized] [--command text] [--cwd path] [--port number] [--ports] [--json] [--id-format refs|ids|both] + dor list --workspaces [--json] dor list --help Lists every Surface in the current Workspace — terminals and browser Surfaces, including minimized ones (view "minimized"). @@ -19,19 +20,34 @@ Filters are ANDed. --command is an exact match against the running command repor JSON output (--json) always includes both stable ids and refs, and each row carries has_terminal (a PTY) and has_browser (a browser renderer) — gate on those, not on kind, so a Surface that has both still matches. It adds top-level caller_surface_ref/caller_surface_id and focused_surface_ref/focused_surface_id — the calling and focused Surfaces, null when neither is in the list — plus workspace_ref, window_ref, and a host block (app, workspace, cli_js_path, node_path): the identity dump dor identify used to print. +--workspace <ref> lists another Workspace of this Window instead: workspace:<n> (positional) or workspace:<name>, which resolves only when exactly one Workspace carries that name. Both are accepted bare ("2", "build"). + +--all lists every Workspace of this Window, grouped under a Workspace header. Rows keep their own Workspace-scoped surface:N refs, so several groups have a surface:1 and several may carry the focus marker; each JSON row adds workspace_ref, and the payload adds a workspaces array. Target a row from another Workspace by its stable id, or pass --workspace. + +--workspaces prints the Workspace overview instead of any Surface: one row per Workspace with the active marker, its name, [ringing]/[todo] when any member Surface is, and [attention N] for the number owing it. It takes no other flag but --json. + Text output: * surface:1 terminal - paned ~/projects/site pnpm dev :5173 + workspace:1 Workspace 1 [active] + * surface:1 terminal - paned ~/projects/site pnpm dev + + * workspace:1 Workspace 1 + workspace:2 build [ringing] [attention 1] + FLAGS - [--command] Exact running command to match. - [--cwd] Working directory to match. - [--id-format] Handle format for text output. - [--json] Print JSON output. - [--kind] Surface kind to show. - [--port] Show terminal Surfaces listening on this TCP port. - [--ports] Include each terminal's listening ports. - [--view] Surface view to show. - -h --help Print help information and exit - -- All subsequent inputs should be interpreted as arguments + [--all] List every Workspace, grouped by a Workspace header. + [--command] Exact running command to match. + [--cwd] Working directory to match. + [--id-format] Handle format for text output. + [--json] Print JSON output. + [--kind] Surface kind to show. + [--port] Show terminal Surfaces listening on this TCP port. + [--ports] Include each terminal's listening ports. + [--view] Surface view to show. + [--workspace] Workspace to list instead of the caller's. + [--workspaces] Print the Workspace overview instead of Surfaces. + -h --help Print help information and exit + -- All subsequent inputs should be interpreted as arguments ``` diff --git a/dor/test/snapshots/help/read.md b/dor/test/snapshots/help/read.md index 702cf951d..8f740851f 100644 --- a/dor/test/snapshots/help/read.md +++ b/dor/test/snapshots/help/read.md @@ -4,7 +4,7 @@ Invocation: `dor read --help` ```text USAGE - dor read <surface> [--json] [--lines count] [--scrollback] + dor read <surface> [--json] [--lines count] [--scrollback] [--workspace ref] dor read --help Reads the visible screen text from the target terminal surface. Use --scrollback to include terminal history, and --lines to limit how much text is returned. @@ -23,6 +23,7 @@ FLAGS [--json] Print JSON output. [--lines] Maximum number of lines to return. [--scrollback] Include terminal scrollback/history instead of only the visible screen. + [--workspace] Workspace to act in, instead of the caller's. -h --help Print help information and exit -- All subsequent inputs should be interpreted as arguments diff --git a/dor/test/snapshots/help/send.md b/dor/test/snapshots/help/send.md index 85f686617..b8ab51b4d 100644 --- a/dor/test/snapshots/help/send.md +++ b/dor/test/snapshots/help/send.md @@ -4,7 +4,7 @@ Invocation: `dor send --help` ```text USAGE - dor send <surface> ([--text value] [--key value] | --stdin | --sequence json) [--json] [--raw] + dor send <surface> ([--text value] [--key value] | --stdin | --sequence json) [--json] [--raw] [--workspace ref] dor send --help Sends text or key input to a target terminal surface. Special keys must be sent with --key so values like "enter" are never confused with literal text. @@ -33,14 +33,15 @@ Examples: dor send surface:3 --sequence '[{"text":"npm test"},{"key":"enter"}]' FLAGS - [--json] Print JSON output. - [--key] Send a named key or chord. - [--raw] Do not interpret backslash escapes in text input. - [--sequence] Send an ordered JSON sequence of text and key events. - [--stdin] Read text from standard input and send it as text. - [--text] Send literal text. - -h --help Print help information and exit - -- All subsequent inputs should be interpreted as arguments + [--json] Print JSON output. + [--key] Send a named key or chord. + [--raw] Do not interpret backslash escapes in text input. + [--sequence] Send an ordered JSON sequence of text and key events. + [--stdin] Read text from standard input and send it as text. + [--text] Send literal text. + [--workspace] Workspace to act in, instead of the caller's. + -h --help Print help information and exit + -- All subsequent inputs should be interpreted as arguments ARGUMENTS surface Target surface. diff --git a/dor/test/snapshots/help/split.md b/dor/test/snapshots/help/split.md index 2b18879f5..61cc52407 100644 --- a/dor/test/snapshots/help/split.md +++ b/dor/test/snapshots/help/split.md @@ -4,7 +4,7 @@ Invocation: `dor split --help` ```text USAGE - dor split [--left|--right|--up|--down|--auto] [--json] [--minimize] [--surface id|ref] [-- <command>...] + dor split [--left|--right|--up|--down|--auto] [--json] [--minimize] [--surface id|ref] [--workspace ref] [-- <command>...] dor split --help If no direction is provided, --auto is used. --auto chooses right when the target surface is wide, down when it is narrow, and right when the target is minimized. @@ -39,10 +39,11 @@ JSON output: FLAGS [--left|--right|--up|--down|--auto] Split direction. Mutually exclusive; default is --auto. - [--json] Print JSON output. - [--minimize] Create the surface minimized. - [--surface] Surface to split. - -h --help Print help information and exit - -- All subsequent inputs should be interpreted as arguments + [--json] Print JSON output. + [--minimize] Create the surface minimized. + [--surface] Surface to split. + [--workspace] Workspace to act in, instead of the caller's. + -h --help Print help information and exit + -- All subsequent inputs should be interpreted as arguments ``` diff --git a/dor/test/snapshots/help/workspace.md b/dor/test/snapshots/help/workspace.md new file mode 100644 index 000000000..74e1b480a --- /dev/null +++ b/dor/test/snapshots/help/workspace.md @@ -0,0 +1,42 @@ +# dor workspace + +Invocation: `dor workspace --help` + +```text +USAGE + dor workspace new [name] [--json] + dor workspace rename <workspace> <name> [--json] + dor workspace close <workspace> [--force] [--json] + dor workspace switch <workspace> [--json] + dor workspace --help + +Manages this Window's Workspaces. Listing them is dor list --workspaces (the overview) and dor list --all (every Workspace's Surfaces); this command only mutates. + +A <workspace> target is workspace:<n> — positional, so a strip reorder renumbers it — or workspace:<name>, which resolves only when exactly one Workspace carries that name and otherwise fails listing the candidates. Both forms are also accepted bare ("2", "build"). + +new creates a Workspace in the background and prints its ref: it never moves the user to it, since that is a larger theft than the focus a bare dor split takes. Use dor workspace switch to activate one. Without a name, the Workspace is named "Workspace N". + +close archives and kills every Surface in the Workspace. It refuses — raising no confirmation, because the caller is a command rather than someone watching the Wall — when the Workspace holds a Surface the user has typed into or a running command; --force closes it anyway. The last remaining Workspace cannot be closed. + +Text output: + created workspace:2 "build" + closed workspace:2 "build" + +JSON output: + { + "status": "created", + "workspace_id": "...", + "workspace_ref": "workspace:2", + "name": "build" + } + +FLAGS + [--force] Close even when the Workspace holds running or touched Surfaces. + [--json] Print JSON output. + -h --help Print help information and exit + -- All subsequent inputs should be interpreted as arguments + +ARGUMENTS + args... Action, then its arguments. + +``` diff --git a/dor/test/snapshots/list-all-and-workspace.snap b/dor/test/snapshots/list-all-and-workspace.snap new file mode 100644 index 000000000..95d040cb5 --- /dev/null +++ b/dor/test/snapshots/list-all-and-workspace.snap @@ -0,0 +1,5 @@ +exitCode: 1 +stdout: + +stderr: +Error: --all and --workspace are mutually exclusive diff --git a/dor/test/snapshots/list-all-json.snap b/dor/test/snapshots/list-all-json.snap new file mode 100644 index 000000000..66253aa51 --- /dev/null +++ b/dor/test/snapshots/list-all-json.snap @@ -0,0 +1,97 @@ +exitCode: 0 +stdout: +{ + "surfaces": [ + { + "id": "11111111-1111-4111-8111-111111111111", + "ref": "surface:1", + "kind": "terminal", + "has_terminal": true, + "has_browser": false, + "render_mode": null, + "view": "paned", + "title": "pnpm dev", + "focused": true, + "cwd": "/Users/me/projects/site", + "activity": "running", + "command": "pnpm dev", + "url": null, + "ringing": false, + "todo": false, + "awaited": false, + "workspace_ref": "workspace:1" + }, + { + "id": "33333333-3333-4333-8333-333333333333", + "ref": "surface:1", + "kind": "browser", + "has_terminal": false, + "has_browser": true, + "render_mode": "ab-screencast", + "view": "paned", + "title": "Dormouse", + "focused": false, + "cwd": null, + "activity": null, + "command": null, + "url": "http://localhost:5173/", + "ringing": false, + "todo": false, + "awaited": false, + "workspace_ref": "workspace:2" + }, + { + "id": "44444444-4444-4444-8444-444444444444", + "ref": "surface:2", + "kind": "terminal", + "has_terminal": true, + "has_browser": false, + "render_mode": null, + "view": "minimized", + "title": "<idle> server.js", + "focused": false, + "cwd": "/Users/me/api", + "activity": "finished", + "command": null, + "url": null, + "ringing": true, + "todo": false, + "awaited": false, + "workspace_ref": "workspace:2" + } + ], + "caller_surface_ref": null, + "caller_surface_id": null, + "focused_surface_ref": "surface:1", + "focused_surface_id": "11111111-1111-4111-8111-111111111111", + "window_ref": "window:1", + "workspace_ref": "workspace:1", + "workspaces": [ + { + "ref": "workspace:1", + "id": "workspace-1", + "name": "Workspace 1", + "active": true, + "ringing": false, + "todo": false, + "count": 0 + }, + { + "ref": "workspace:2", + "id": "workspace-2b1c", + "name": "build", + "active": false, + "ringing": true, + "todo": true, + "count": 2 + } + ], + "host": { + "app": "vscode", + "workspace": "/Users/me/projects/site", + "cli_js_path": "/opt/dormouse/dor-cli/dist/dor.js", + "node_path": "/opt/dormouse/node" + } +} + +stderr: diff --git a/dor/test/snapshots/list-all-text.snap b/dor/test/snapshots/list-all-text.snap new file mode 100644 index 000000000..31e950695 --- /dev/null +++ b/dor/test/snapshots/list-all-text.snap @@ -0,0 +1,10 @@ +exitCode: 0 +stdout: +workspace:1 Workspace 1 [active] + * surface:1 terminal - paned /Users/me/projects/site pnpm dev + +workspace:2 build + surface:1 browser ab-screencast paned http://localhost:5173/ Dormouse + surface:2 terminal - minimized /Users/me/api <idle> server.js [ringing] + +stderr: diff --git a/dor/test/snapshots/list-workspaces-json.snap b/dor/test/snapshots/list-workspaces-json.snap new file mode 100644 index 000000000..23de18eb4 --- /dev/null +++ b/dor/test/snapshots/list-workspaces-json.snap @@ -0,0 +1,27 @@ +exitCode: 0 +stdout: +{ + "workspaces": [ + { + "ref": "workspace:1", + "id": "workspace-1", + "name": "Workspace 1", + "active": true, + "ringing": false, + "todo": false, + "count": 0 + }, + { + "ref": "workspace:2", + "id": "workspace-2b1c", + "name": "build", + "active": false, + "ringing": true, + "todo": true, + "count": 2 + } + ], + "window_ref": "window:1" +} + +stderr: diff --git a/dor/test/snapshots/list-workspaces-text.snap b/dor/test/snapshots/list-workspaces-text.snap new file mode 100644 index 000000000..8cb684d8c --- /dev/null +++ b/dor/test/snapshots/list-workspaces-text.snap @@ -0,0 +1,6 @@ +exitCode: 0 +stdout: +* workspace:1 Workspace 1 + workspace:2 build [ringing] [todo] [attention 2] + +stderr: diff --git a/dor/test/snapshots/list-workspaces-with-filter.snap b/dor/test/snapshots/list-workspaces-with-filter.snap new file mode 100644 index 000000000..ca89853a5 --- /dev/null +++ b/dor/test/snapshots/list-workspaces-with-filter.snap @@ -0,0 +1,5 @@ +exitCode: 1 +stdout: + +stderr: +Error: dor list --workspaces takes only --json, not --kind diff --git a/dor/test/snapshots/workspace-close-force.snap b/dor/test/snapshots/workspace-close-force.snap new file mode 100644 index 000000000..a5007ff5a --- /dev/null +++ b/dor/test/snapshots/workspace-close-force.snap @@ -0,0 +1,5 @@ +exitCode: 0 +stdout: +closed workspace:2 "build" + +stderr: diff --git a/dor/test/snapshots/workspace-close-refused.snap b/dor/test/snapshots/workspace-close-refused.snap new file mode 100644 index 000000000..56e9207a6 --- /dev/null +++ b/dor/test/snapshots/workspace-close-refused.snap @@ -0,0 +1,5 @@ +exitCode: 1 +stdout: + +stderr: +Error: workspace 'workspace:2' holds running or touched Surfaces; pass --force to close it diff --git a/dor/test/snapshots/workspace-force-misuse.snap b/dor/test/snapshots/workspace-force-misuse.snap new file mode 100644 index 000000000..df75061e2 --- /dev/null +++ b/dor/test/snapshots/workspace-force-misuse.snap @@ -0,0 +1,5 @@ +exitCode: 1 +stdout: + +stderr: +Error: --force applies only to dor workspace close diff --git a/dor/test/snapshots/workspace-list-action.snap b/dor/test/snapshots/workspace-list-action.snap new file mode 100644 index 000000000..7e9cd963c --- /dev/null +++ b/dor/test/snapshots/workspace-list-action.snap @@ -0,0 +1,5 @@ +exitCode: 1 +stdout: + +stderr: +Error: dor list --workspaces prints the Workspace overview; dor workspace only mutates diff --git a/dor/test/snapshots/workspace-missing-action.snap b/dor/test/snapshots/workspace-missing-action.snap new file mode 100644 index 000000000..64d36e5e5 --- /dev/null +++ b/dor/test/snapshots/workspace-missing-action.snap @@ -0,0 +1,5 @@ +exitCode: 1 +stdout: + +stderr: +Error: dor workspace requires an action: new, rename, close, switch diff --git a/dor/test/snapshots/workspace-new-json.snap b/dor/test/snapshots/workspace-new-json.snap new file mode 100644 index 000000000..b5a8a6854 --- /dev/null +++ b/dor/test/snapshots/workspace-new-json.snap @@ -0,0 +1,10 @@ +exitCode: 0 +stdout: +{ + "status": "created", + "workspace_id": "workspace-9f3a", + "workspace_ref": "workspace:3", + "name": "Workspace 3" +} + +stderr: diff --git a/dor/test/snapshots/workspace-new.snap b/dor/test/snapshots/workspace-new.snap new file mode 100644 index 000000000..744e7274e --- /dev/null +++ b/dor/test/snapshots/workspace-new.snap @@ -0,0 +1,5 @@ +exitCode: 0 +stdout: +created workspace:3 "build" + +stderr: diff --git a/dor/test/snapshots/workspace-rename-arity.snap b/dor/test/snapshots/workspace-rename-arity.snap new file mode 100644 index 000000000..2fba792f5 --- /dev/null +++ b/dor/test/snapshots/workspace-rename-arity.snap @@ -0,0 +1,5 @@ +exitCode: 1 +stdout: + +stderr: +Error: dor workspace rename takes a workspace and a name diff --git a/dor/test/snapshots/workspace-rename.snap b/dor/test/snapshots/workspace-rename.snap new file mode 100644 index 000000000..a2cb76d9a --- /dev/null +++ b/dor/test/snapshots/workspace-rename.snap @@ -0,0 +1,5 @@ +exitCode: 0 +stdout: +renamed workspace:2 "agents" + +stderr: diff --git a/dor/test/snapshots/workspace-switch.snap b/dor/test/snapshots/workspace-switch.snap new file mode 100644 index 000000000..072d270b1 --- /dev/null +++ b/dor/test/snapshots/workspace-switch.snap @@ -0,0 +1,5 @@ +exitCode: 0 +stdout: +active workspace:2 "build" + +stderr: diff --git a/dor/test/snapshots/workspace-unknown-action.snap b/dor/test/snapshots/workspace-unknown-action.snap new file mode 100644 index 000000000..2c7a1981e --- /dev/null +++ b/dor/test/snapshots/workspace-unknown-action.snap @@ -0,0 +1,5 @@ +exitCode: 1 +stdout: + +stderr: +Error: unknown dor workspace action 'destroy' (expected new, rename, close, switch) From 77af3b90a084347fcd55149ed841c0b1190f972d Mon Sep 17 00:00:00 2001 From: Ned Twigg <ned.twigg@diffplug.com> Date: Thu, 10 Sep 2026 04:38:15 -0700 Subject: [PATCH 04/13] Refuse Workspace-spanning dor requests in VS Code MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Each Workspace is its own webview there, so a `--all` listing would report one webview's Workspace as the whole Window and a container verb would move a strip that does not exist. The extension host — the only side that knows how many webviews it holds — answers them with what VS Code can and cannot do, and leaves every other request untouched. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QBf4rQbwuxV6n6v7E3twYe --- vscode-ext/src/dor-workspace-guard.ts | 34 +++++++++++++++++++++ vscode-ext/src/message-router.ts | 9 ++++++ vscode-ext/test/dor-workspace-guard.test.ts | 28 +++++++++++++++++ 3 files changed, 71 insertions(+) create mode 100644 vscode-ext/src/dor-workspace-guard.ts create mode 100644 vscode-ext/test/dor-workspace-guard.test.ts diff --git a/vscode-ext/src/dor-workspace-guard.ts b/vscode-ext/src/dor-workspace-guard.ts new file mode 100644 index 000000000..01731bbea --- /dev/null +++ b/vscode-ext/src/dor-workspace-guard.ts @@ -0,0 +1,34 @@ +/** + * VS Code maps each Workspace to a webview of its own (`docs/specs/vscode.md` → + * Workspaces), so this host has no Window-wide Workspace model to answer with: + * a Workspace-spanning `dor` request would report one webview's Workspace as if + * it were all of them, and a mutation would move a strip that does not exist. + * Every such request is refused here, at the extension host, before it reaches a + * webview. + */ + +import { SURFACE_CONTROL_METHODS, WORKSPACE_CONTROL_METHODS } from 'dor/protocol'; + +/** The one Workspace a VS Code webview has, in both accepted spellings. */ +const THIS_WORKSPACE = new Set(['workspace:1', '1']); + +const REFUSAL = 'Dormouse in VS Code puts each Workspace in its own webview, so'; + +/** + * Why this request cannot be answered here, or null to let it through. Reads + * only the wire request, so it holds for every transport that reaches the + * extension host. + */ +export function dorWorkspaceRefusal(method: string, params: Record<string, unknown> | undefined): string | null { + const workspace = params?.workspace; + if (workspace !== undefined && !(typeof workspace === 'string' && THIS_WORKSPACE.has(workspace.trim()))) { + return `${REFUSAL} it has no workspace '${String(workspace)}' to act on`; + } + if (method === SURFACE_CONTROL_METHODS.list && params?.scope === 'all') { + return `${REFUSAL} dor list --all would list only this one`; + } + if ((Object.values(WORKSPACE_CONTROL_METHODS) as string[]).includes(method)) { + return `${REFUSAL} dor workspace and dor list --workspaces are not available here`; + } + return null; +} diff --git a/vscode-ext/src/message-router.ts b/vscode-ext/src/message-router.ts index 50d0ab206..2bc5f4de5 100644 --- a/vscode-ext/src/message-router.ts +++ b/vscode-ext/src/message-router.ts @@ -23,6 +23,7 @@ import type { TerminalSemanticEvent } from '../../lib/src/lib/terminal-state'; import type { PersistedSession } from '../../lib/src/lib/session-types'; import type { WebviewMessage, ExtensionMessage } from './message-types'; import type { DorControlRequest } from './pty-manager'; +import { dorWorkspaceRefusal } from './dor-workspace-guard'; import { createStreamRelayUrl, runAgentBrowserCommand, runAgentBrowserEdit, runAgentBrowserOpen, runAgentBrowserPopIn, runAgentBrowserPopOut, runAgentBrowserScreenshot, runAgentBrowserStreamStatus } from './agent-browser-host'; import { createIframeProxyUrl } from './iframe-proxy-host'; import { @@ -246,6 +247,14 @@ ptyManager.addCallbacks({ }); ptyManager.onDorControlRequest((request) => { + // Refused here rather than in the webview: only the extension host knows + // this window holds several Dormouse webviews, each its own Workspace + // (`dor-workspace-guard.ts`). + const refusal = dorWorkspaceRefusal(request.method, request.params); + if (refusal) { + ptyManager.respondDorControl({ requestId: request.requestId, ok: false, error: refusal }); + return; + } const routers = [...activeRouters]; const router = request.surfaceId ? routers.find((candidate) => candidate.ownsPty(request.surfaceId!)) diff --git a/vscode-ext/test/dor-workspace-guard.test.ts b/vscode-ext/test/dor-workspace-guard.test.ts new file mode 100644 index 000000000..24ad1b772 --- /dev/null +++ b/vscode-ext/test/dor-workspace-guard.test.ts @@ -0,0 +1,28 @@ +import { describe, expect, it } from 'vitest'; +import { dorWorkspaceRefusal } from '../src/dor-workspace-guard'; + +describe('dorWorkspaceRefusal', () => { + it('lets an ordinary request through, and the one Workspace this webview has', () => { + expect(dorWorkspaceRefusal('surface.list', {})).toBeNull(); + expect(dorWorkspaceRefusal('surface.split', undefined)).toBeNull(); + expect(dorWorkspaceRefusal('surface.list', { scope: 'workspace' })).toBeNull(); + for (const workspace of ['workspace:1', '1', ' workspace:1 ']) { + expect(dorWorkspaceRefusal('surface.kill', { workspace })).toBeNull(); + } + }); + + it('refuses a Workspace this webview does not have', () => { + expect(dorWorkspaceRefusal('surface.split', { workspace: 'workspace:2' })) + .toMatch(/each Workspace in its own webview.*no workspace 'workspace:2'/); + expect(dorWorkspaceRefusal('surface.split', { workspace: 'build' })).not.toBeNull(); + // Whatever crossed the socket, not a validated string. + expect(dorWorkspaceRefusal('surface.split', { workspace: 2 })).not.toBeNull(); + }); + + it('refuses the Workspace-spanning listing and every container verb', () => { + expect(dorWorkspaceRefusal('surface.list', { scope: 'all' })).toMatch(/dor list --all/); + for (const method of ['workspace.list', 'workspace.new', 'workspace.rename', 'workspace.close', 'workspace.switch']) { + expect(dorWorkspaceRefusal(method, {})).toMatch(/dor workspace/); + } + }); +}); From c48ea29bed7bc18d1b6b907b05149639fb073408 Mon Sep 17 00:00:00 2001 From: Ned Twigg <ned.twigg@diffplug.com> Date: Thu, 10 Sep 2026 04:50:35 -0700 Subject: [PATCH 05/13] Promote the Workspace CLI into the specs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit dor-cli.md gains a `dor workspace` section and the promoted Handle Model rules: the name handle and its ambiguity error, the routing order including the Window's own verbs and stable-id targeting, `surface.list`'s scope, and `--workspace` on every action command. Its two staged bullets are gone, and cross-Window targeting — still reserved — is now the Future item the Reserved line points at. The workspaces-rollout scope is empty, so its ledger and every reference to it are retired; layout.md keeps the Workspace model, and vscode.md and standalone.md record the refusal and the minted first Workspace id. Budgets ratcheted: dor-cli 5450, standalone 8350, vscode 7450. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QBf4rQbwuxV6n6v7E3twYe --- AGENTS.md | 4 +- docs/specs/dor-cli.md | 132 +++++++++++++++++++++------------ docs/specs/glossary.md | 2 +- docs/specs/layout.md | 10 +-- docs/specs/standalone.md | 6 ++ docs/specs/tiling-engine.md | 2 +- docs/specs/vscode.md | 2 + scripts/spec-word-budgets.json | 6 +- 8 files changed, 103 insertions(+), 61 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 61edb5cf1..bfa51022c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -48,7 +48,7 @@ A spec is the accurate reference for the current code: it states the invariants **May combine a concise `Files` / `Code Map` section with section-local `Source of truth:` pointers.** The map gives readers key entrypoints to follow through imports; the pointers locate the implementation of a particular rule. Map the useful starting points, not every file. Short specs need no map when their local pointers already make navigation clear. Keep behavior and invariants in their owning sections, rather than repeating them in map descriptions. - **`docs/specs/glossary.md`** — Canonical vocabulary: the Surface model, Session layers, `Window ⊃ Workspace ⊃ Pane ⊃ Surface`, transition verbs, invariants I1–I10. Read first; every spec defers to it for state, kind, and verb names. -- **`docs/specs/layout.md`** — The interaction model over the tiling engine: modes, command-mode dispatch, navigation, minimize/reattach, kill/rename, session lifecycle and persistence recovery, the workspaces-rollout ledger. Read before touching keyboard/navigation/mode/workspace behavior. +- **`docs/specs/layout.md`** — The interaction model over the tiling engine: modes, command-mode dispatch, navigation, minimize/reattach, kill/rename, session lifecycle and persistence recovery, the Workspace model. Read before touching keyboard/navigation/mode/workspace behavior. - **`docs/specs/shortcuts.md`** — Quick-reference table of every shortcut by mode/context; layout.md owns the behavior — update both when a binding changes. - **`docs/specs/tiling-engine.md`** — **Lath**, the in-house headless tiling engine: pure split-tree core, never-re-parent LathHost adapter, wall store + engine, Lath-only persistence. - **`docs/specs/alert.md`** — The Activity layer: alert tracks, attention model, TODO lifecycle, notification protocols with their sanitization rules, the Workspace union projection. @@ -101,7 +101,7 @@ Specs are written ahead of the code: a new component's spec starts as a full des - **The fold.** Everything above `## Future` describes the code as it is — present tense, anchored with `Source of truth:` pointers. Everything unbuilt lives under `## Future`, always the last section; a spec with no unbuilt design has none. - **Design-stage specs.** A spec for a component that does not exist yet keeps its whole design under `## Future`, opens with `> Status: design — nothing here is implemented yet.`, and is indexed above like any other. -- **Named scopes.** A cut is recorded as a named scope at the top of `## Future` (`**Scope: workspaces-rollout**`), listing what remains in staged order. A scope is defined in exactly one spec; other specs link it by name and never restate it. Rollout ledgers live in the owning spec's `## Future`, nowhere else. +- **Named scopes.** A cut is recorded as a named scope at the top of `## Future` (`**Scope: dor-tools**`), listing what remains in staged order. A scope is defined in exactly one spec; other specs link it by name and never restate it. Rollout ledgers live in the owning spec's `## Future`, nowhere else. - **Reservations.** Unbuilt design that constrains present code — a reserved wire field, a reserved ref grammar, an additive-evolution guarantee — is stated in the body, marked `Reserved:`, pointing at the `## Future` item it serves. Test: if deleting the sentence would let someone break future compatibility today, it belongs in the body. - **Promotion is part of done.** A staged item is finished only when its text moves above the fold — "will" rewritten to "is", `Source of truth:` added — and the built portion is deleted from `## Future`. Never leave completed plan text (build orders, phase lists) below the fold; git keeps the record. diff --git a/docs/specs/dor-cli.md b/docs/specs/dor-cli.md index 11023b1c0..adaf54931 100644 --- a/docs/specs/dor-cli.md +++ b/docs/specs/dor-cli.md @@ -250,12 +250,11 @@ and each host's hop in `standalone/src/tauri-adapter.ts`, ## Handle Model -`Window ⊃ Workspace ⊃ Pane ⊃ Surface` (`docs/specs/glossary.md`). **User-facing -`dor` commands expose Surface handles only**, and because a Window can hold -several Workspaces — and standalone can hold several Windows — the handle model -reserves `workspace:<n|name>` and `window:<label>` refs. `Reserved:` no command -targets another Window yet; a request reaches the window that owns its Surface -instead (§Standalone), which is what `## Future` → `dor workspace` builds on. +`Window ⊃ Workspace ⊃ Pane ⊃ Surface` (`docs/specs/glossary.md`). **A command's +positional target is always a Surface; a container is named by `--workspace +<ref>`** ([dor workspace](#dor-workspace)). `Reserved:` no command targets +another Window; a request reaches the window that owns its Surface instead +(§Standalone), and the ref grammar for one is [Future](#future). Invariants: @@ -291,40 +290,52 @@ Invariants: `--id-format refs|ids|both` (`uuids` is a compatibility alias for `ids`). JSON list output always includes both refs and stable ids. - `workspace:<n>` selects a container and is **positional**, so a strip reorder - renumbers it; `workspace:<name>` is the stable handle and is staged with the - `dor workspace` commands (see [Future](#future)). **A Window is `window:<label>` - — its host's own name for it** (`window:main`, `window:ws-2`), and a host with + renumbers it; `workspace:<name>` is the stable handle and **resolves only when + exactly one Workspace carries that name**, else the error lists the + candidates. Both are accepted bare (`2`, `build`), and **a ref that reads as a + number is positional**, never a name. **A Window is `window:<label>` — its + host's own name for it** (`window:main`, `window:ws-2`), and a host with one Window answers `window:1`; each accepts its own ref bare, and **rejects every other Window's**, there being nothing it could do with one. **Every Workspace has a `surface:1`**, so a Surface ref alone never identifies a Workspace. -- **One Wall answers each request**, resolved in order: an explicit - `workspace:<n>`, else the Workspace owning the calling Surface, else the - active one; nothing mounted leaves the request unanswered, after a bounded +- **One Wall answers each request**, resolved in order: the Window's own verbs + ([dor workspace](#dor-workspace), and `dor list --all`, which fans out to + every Wall), an explicit `--workspace`, the Workspace holding the target + Surface when it is named by its **stable id** — unique Window-wide, unlike + `surface:N` — else the Workspace owning the calling Surface, else the active + one; nothing mounted leaves the request unanswered, after a bounded retry that covers the tick between a Workspace being created and its Wall registering. **Every request is answered, including a container ref of the wrong type and a handler that throws** — an unanswered one blocks its caller to the deadline. A Workspace being closed refuses the Surface-creating verbs - (`docs/specs/layout.md` → "Workspaces"). **Surface targets - resolve within the answering Workspace** — refs are Workspace-scoped — so a - `dor split` from a background Workspace lands beside its caller rather than - wherever the user is looking. Cross-Workspace targeting is staged with - Workspace-aware listing. Cross-window duplicate ids follow + (`docs/specs/layout.md` → "Workspaces"). **Surface targets resolve within the + answering Workspace** — refs are Workspace-scoped — so a `dor split` from a + background Workspace lands beside its caller rather than wherever the user is + looking, and **a caller that Workspace does not hold falls back to its focused + Surface** rather than failing, which is what gives `--workspace` a reference + to place against. Cross-window duplicate ids follow `docs/specs/vscode.md` → "Peer surfaces across windows". Source of truth: `dor/src/commands/shared.ts`, `dor/src/commands/types.ts`, -`surfaceRefForId` / `transferSurfaceRef` in `lib/src/components/Wall.tsx`, and +`surfaceRefForId` / `transferSurfaceRef` in `lib/src/components/Wall.tsx`, +`resolveWorkspaceRef` in `lib/src/lib/workspace-store.ts`, and `resolveDorControlRoute` in `lib/src/components/wall/dor-control-router.ts`. ## Current Implemented Commands Implemented commands call private `surface.*` control methods, **enumerated once -in `dor/src/protocol.ts` (`SURFACE_CONTROL_METHODS`)** so the emitting client -and the dispatching webview cannot drift. `surface.list` joins the current -Workspace's Surfaces — visible panes **plus minimized (doored)** ones, each -tagged `view` (`paned` / `zoomed` / `minimized`) — with terminal state and -activity snapshots, and reports the answering Workspace's own `workspace:<n>` -alongside the answering Window's `window:<label>` (Handle Model). Per the +in `dor/src/protocol.ts` (`SURFACE_CONTROL_METHODS`, and `WORKSPACE_CONTROL_METHODS` +beside it)** so the emitting client and the dispatching webview cannot drift. +`surface.list` joins one Workspace's Surfaces — visible panes +**plus minimized (doored)** ones, each tagged `view` (`paned` / `zoomed` / +`minimized`) — with terminal state and activity snapshots, and reports the +answering Workspace's own `workspace:<n>` alongside the answering Window's +`window:<label>` (Handle Model). **Its `scope: 'all'` spans every Workspace of +the Window**, tagging each row with the Workspace it came from and carrying the +Workspace directory beside them; **one Workspace that cannot answer fails the +whole listing**, since a Workspace missing from the answer reads as a Workspace +holding nothing. Per the visible-vs-listed split [Handle Model](#handle-model) states, **a visible split reference adds a pane in Lath, a minimized one a sibling Door in the baseboard.** **`dor list` rows sort by the Workspace-stable `surface:N` ref**, a @@ -382,7 +393,8 @@ The spec keeps the behavior help cannot express: | `await` | **Must name `--until quiet\|exit`; never infer it.** Timeout 1–86400 whole seconds, default 600; `alert.md` owns wake semantics. | | `kill` | **Must select exactly one confirmation mode.** Conditional text needs four non-whitespace characters and must match `read`; browser Surfaces are killable. | | `iframe`, `agent-browser` / `ab` | `dor-browser.md` owns the renderers; see [target resolution](#browser-open-target-resolution) and [addressing](#agent-browser-surface-addressing). The passthrough is intercepted before stricli parses it. | -| `list` | Filters are ANDed client-side; `--port` filters terminals (browser Surfaces never match) and implies the opt-in detail scan, `--ports` only requests it. | +| `list` | Filters are ANDed client-side; `--port` filters terminals (browser Surfaces never match) and implies the opt-in detail scan, `--ports` only requests it. **Owns every Workspace read**: `--workspace` narrows to one, `--all` groups every Workspace's rows under its header (dropping a group its filters emptied), `--workspaces` is the overview, and the three cannot be combined. | +| `workspace` | **Mutation only** ([dor workspace](#dor-workspace)). | | `skill` | Prints the bundled skill or installs its bootstrap stub; [Agent Skill](#agent-skill) owns the contract. | **`await` never prints terminal text.** Stdout is only the resolution cause, the @@ -394,7 +406,13 @@ Surface death; other commands use only 0/1. identity, singleton Workspace/Window refs, and Host identity/runtime paths — but **never the control socket**. **Consumers must gate on `has_terminal` / `has_browser`, not `kind`**, the vocabulary commands also use in target errors. -Activity/state filters and Workspace scope are staged (see [Future](#future)). +Activity/state filters are staged (see [Future](#future)). + +**Every command that acts on a Surface accepts `--workspace <ref>`** — `split`, +`ensure`, `read`, `send`, `await`, `kill`, `iframe`, and the `dor ab` +passthrough, which intercepts it beside its identity flags — naming the +Workspace its targets resolve in and, for a creating verb, the Workspace the new +Surface joins. Source of truth: `dor/src/commands/`, `HELP_PATTERN_TOKENS` and pre-parsing in `dor/src/cli.ts`, `shellCommandKind` / `buildShellCommandForKind` in @@ -403,6 +421,37 @@ Source of truth: `dor/src/commands/`, `HELP_PATTERN_TOKENS` and pre-parsing in `lib/src/components/wall/use-dor-control.ts`; help snapshots in `dor/test/snapshots/help/`, pinned exhaustive by `dor/test/cli-help.test.mjs`. +## dor workspace + +**`dor workspace` mutates and `dor list` enumerates**, so the overview has one +home. Its verbs are container verbs, answered by the Window rather than by a +Wall (Handle Model), and each takes a `workspace:<n|name>` target except `new`: + +| Verb | Contract | +|---|---| +| `new [name]` | **Creates in the background**, never activating: moving the user to another Workspace is a larger theft than the focus a bare `dor split` takes. Answers with the new ref. | +| `rename <ref> <name>` | Renames the Workspace only — no Surface title (`docs/specs/layout.md` → "Workspaces"). | +| `close <ref> [--force]` | **Refuses, raising no confirmation, when the Workspace holds a touched or running Surface** unless `--force` — the caller is a command, not someone watching the Wall, exactly as `dor kill` archives silently. The last Workspace, and a Workspace whose close meets another already in flight, refuse too. Member Surfaces close through the closure coordinator (`docs/specs/notepad.md` → "Closure"). | +| `switch <ref>` | Activates it. | + +**Each verb ships as one action of one command**, not a route map: the published +CLI reference renders one help page per top-level command +(`docs/specs/website-docs.md` → /docs/dor), and a nested command would have +none. + +**VS Code refuses every Workspace-spanning request at the extension host** — +`dor workspace`, `dor list --workspaces`, `dor list --all`, and any +`--workspace` but this webview's own — because each Workspace there is a +separate webview (`docs/specs/vscode.md` → "Workspaces"), so a listing would +report one webview's Workspace as the whole Window. Aggregating them at the +extension host stays in [Future](#future). + +Source of truth: `dor/src/commands/workspace.ts`, `WORKSPACE_CONTROL_METHODS` in +`dor/src/protocol.ts`, `handleWorkspaceControl` / `listAllWorkspaceSurfaces` in +`lib/src/components/wall/workspace-control.ts`, `closeWorkspaceWithSurfaces` in +`lib/src/components/wall/workspace-lifecycle.ts`, and `dorWorkspaceRefusal` in +`vscode-ext/src/dor-workspace-guard.ts`. + ## Browser Open Target Resolution `dor ab open <target>` and `dor iframe <target>` accept, wherever they take an @@ -578,29 +627,18 @@ Source of truth: `buildDorSurfacesInternal` in `lib/src/components/Wall.tsx`; `d npm) distributes the bootstrap stub, never a copy of the content. A user-level `--global` install variant waits until a story needs it. +- **Cross-Window targeting.** `window:<label>` is a listing ref today: a Window + accepts its own and rejects every other's (Handle Model), so no command can + reach a sibling Window's Surfaces. What it would take is a route above the + per-Window router — Rust already owns the window↔Surface map it would consult + (`docs/specs/standalone.md` → Routing) — plus a `--window` flag whose refs + survive a Workspace moving between Windows. +- **Cross-Workspace listing in VS Code.** Each Workspace is its own webview + there, so `dor list --all` would have to aggregate at the extension host + rather than in a per-webview control handler; until it does, VS Code refuses + the Workspace-spanning requests ([dor workspace](#dor-workspace)). - **Additional `dor list` filters** — activity/state filters are deliberately deferred: `--running` as shorthand for `--activity running`, full `--activity unknown|prompt|editing|running|finished`, and possible alert filters such as `--alert` / `--todo`. Add only once a story needs them, each with snapshot-tested help. -- **`dor list` workspace scope** — today `dor list` shows only the active - Workspace, with no workspace rows. When workspaces land, add `--all` (every - Workspace, grouped by a Workspace header), `--workspace <ref>` (narrow to - one), and `--workspaces` (the cheap overview: one row per Workspace with its - `active` flag and union status — ringing / todo / count from - `docs/specs/glossary.md`). `dor list` owns all read/enumeration and `dor - workspace` below owns mutation only, so the overview is never duplicated. Host - asymmetry constrains `--all`: standalone can reach unmounted Workspaces - (stores survive unmount, layouts are persisted, `getOpenPorts` is PTY-keyed), - but VS Code puts each Workspace in a separate webview, so cross-Workspace - listing must aggregate at the extension host, not the per-webview control - handler. This scope also owns cross-Workspace action targeting by stable - Surface id, which today's handler resolves only in the mounted Workspace. - Staged with the workspaces rollout (`docs/specs/layout.md` `## Future`, - workspaces-rollout). -- **Workspace handles and commands** — a `--workspace` target flag and `dor - workspace` management commands (new / rename / close / switch — mutation only) - consuming the reserved `workspace:<n|name>` / `window:<label>` ref grammar above. - Like every command they ship with snapshot-tested help and the control methods - that back them, not ahead of them. Staged with the workspaces rollout - (`docs/specs/layout.md` `## Future`, workspaces-rollout). diff --git a/docs/specs/glossary.md b/docs/specs/glossary.md index 59c9645fa..5abf1a8c5 100644 --- a/docs/specs/glossary.md +++ b/docs/specs/glossary.md @@ -88,7 +88,7 @@ A Workspace's **union status** is its display projection of member Surfaces' Act ### Implementation status -The Pane / Surface model, surface kinds, the Workspace model, per-Workspace persistence, and several Windows each holding several Workspaces are live (`docs/specs/layout.md` → Workspaces). Ledger: `docs/specs/layout.md` `## Future` (**Scope: workspaces-rollout**); this glossary does not track it. +The Pane / Surface model, surface kinds, the Workspace model, per-Workspace persistence, several Windows each holding several Workspaces, and the `dor workspace` verbs over them are all live (`docs/specs/layout.md` → Workspaces); this glossary tracks no rollout. ## Roles diff --git a/docs/specs/layout.md b/docs/specs/layout.md index 828e2e237..8f9f5511f 100644 --- a/docs/specs/layout.md +++ b/docs/specs/layout.md @@ -2,7 +2,7 @@ > See `docs/specs/glossary.md` for canonical state names, layer definitions, and transition verbs. This spec uses the glossary's vocabulary throughout. > -> **Owns:** the interaction model on top of Lath — modes and keyboard dispatch, navigation, minimize/reattach, kill/rename, the selection overlay, session lifecycle + persistence recovery, and the workspaces-rollout ledger. Pane chrome: placement and sizing only. +> **Owns:** the interaction model on top of Lath — modes and keyboard dispatch, navigation, minimize/reattach, kill/rename, the selection overlay, session lifecycle + persistence recovery, and the Workspace model. Pane chrome: placement and sizing only. > > **Defers:** engine internals (split tree, rects, DnD, animator) to `docs/specs/tiling-engine.md`; alert/TODO/speech behavior and visual states to `docs/specs/alert.md`; per-Session semantic state (CWD, command lifecycle, title candidates, header derivation, grouping keys) to `docs/specs/terminal-state.md`; browser surfaces to `docs/specs/dor-browser.md`; selection/copy/paste and the mouse-override icon to `docs/specs/mouse-and-clipboard.md`; persisted shapes to `docs/specs/transport.md`; tokens to `docs/specs/theme.md`. > @@ -10,7 +10,7 @@ ## Conceptual model -A Wall renders one Workspace's Surfaces as Panes in Content or Doors on the Baseboard. Pane↔Door preserves the Surface; a Doored browser Surface keeps its backing session while releasing its viewer resources ([Minimize and reattach](#minimize-and-reattach)). Standalone mounts one Wall per Workspace and switches between them ([Workspaces](#workspaces)); what that feature still owes is staged in [Future](#future) (**Scope: workspaces-rollout**). VS Code maps each Workspace to a webview (`docs/specs/vscode.md`). +A Wall renders one Workspace's Surfaces as Panes in Content or Doors on the Baseboard. Pane↔Door preserves the Surface; a Doored browser Surface keeps its backing session while releasing its viewer resources ([Minimize and reattach](#minimize-and-reattach)). Standalone mounts one Wall per Workspace and switches between them ([Workspaces](#workspaces)). VS Code maps each Workspace to a webview (`docs/specs/vscode.md`). ## Shell layout @@ -165,7 +165,7 @@ The union projection and its indicators are owned by `docs/specs/alert.md` → W Source of truth: `WorkspaceWindow` in `lib/src/components/WorkspaceWindow.tsx`; `registerWallHandle` in `lib/src/components/wall/wall-handles.ts`; `closeAll` in `lib/src/components/Wall.tsx`; `requestWorkspaceClose` in `lib/src/components/wall/workspace-lifecycle.ts`; `createWorkspace` / `closeWorkspace` / `renameWorkspace` / `moveWorkspace` / `setActiveWorkspace` in `lib/src/lib/workspace-store.ts`; `getWorkspaceUiSnapshot` in `lib/src/lib/workspace-ui-store.ts`; `setWorkspaceSurfaces` in `lib/src/lib/workspace-surfaces.ts`. -What the `dor workspace` verbs still owe is staged in [Future](#future) — this spec's `## Future` is the single rollout ledger; other specs link here. +**Every Workspace verb has a `dor` counterpart** (`docs/specs/dor-cli.md` → "dor workspace"), taking the same route as the strip and the command-mode keys: a command close raises no confirmation, refusing instead, and closes its member Surfaces silently. ## Modes @@ -433,10 +433,6 @@ A store commit that empties the tree (last pane killed or minimized) triggers th ## Future -**Scope: workspaces-rollout** — what the multi-Workspace feature still owes. Current implementation: [Workspaces](#workspaces). Persisted containers are owned by `docs/specs/transport.md`; union projection by `docs/specs/alert.md`. This ledger is the single home for what remains; other specs link here rather than restating it. - -- **`dor workspace` verbs.** `new` / `rename` / `close` / `switch`, plus `dor list --all` for cross-Workspace targeting and `workspace:<name>` as the stable handle beside today's positional `workspace:<n>`. - ### Re-arming the WebGL renderer after context loss A pane that loses its WebGL context ([Renderer](#renderer)) stays on the DOM renderer for the rest of its life, even once other panes close and free budget. The eviction order is also backwards for a tiling terminal: browsers evict *oldest-first*, but the pane that most deserves the GPU is the focused one. diff --git a/docs/specs/standalone.md b/docs/specs/standalone.md index 74c7b12b0..e5a56cca7 100644 --- a/docs/specs/standalone.md +++ b/docs/specs/standalone.md @@ -627,6 +627,12 @@ into fresh shells at its saved cwds. - **A restore that throws degrades to a fresh Window and overwrites the blob.** Installing the Workspaces is the step that can reject a stored blob outright, and a throw at boot would leave nothing rendered, on this launch and every later one. +- **A fresh Window mints its first Workspace's id**, rather than taking the lib's + `DEFAULT_WORKSPACE_ID`, which every window would otherwise start on: a second + window opened after the first one closed would write a blob naming a Workspace + id already live in another window's blob, and the next launch would meet the + same id twice and refuse the whole restore. A bare Wall — one Window's whole + application — keeps the default id (`window-restore.test.ts`). Source of truth: `restoreWindowOrFresh` / `routeUnownedPtys` in `standalone/src/window-restore.ts`. diff --git a/docs/specs/tiling-engine.md b/docs/specs/tiling-engine.md index ac54bc407..f81fd3866 100644 --- a/docs/specs/tiling-engine.md +++ b/docs/specs/tiling-engine.md @@ -205,6 +205,6 @@ Source of truth: `LeafMeta` / `LathPersistedLayout` / `lathLayoutFromStore` / `i ## Testing -Ordering constraint: the remaining stages of the **workspaces-rollout** scope (defined in [layout.md](layout.md)) build on this engine — one engine instance per mounted Wall, never a shared one. `onApiReady` (the old tiling-api ready callback) is gone and **must not come back**: its last consumer, the website tutorial, drives off the engine-neutral `WallEvent` stream (`paneAdded`, `selectionChange`). +Ordering constraint: the Workspace model ([layout.md](layout.md) → Workspaces) runs one engine instance per mounted Wall, never a shared one. `onApiReady` (the old tiling-api ready callback) is gone and **must not come back**: its last consumer, the website tutorial, drives off the engine-neutral `WallEvent` stream (`paneAdded`, `selectionChange`). Source of truth: the DOM-free suites in `lib/src/lib/lath/`, the binding suites under `lib/src/components/wall/`, and `lib/src/components/Wall.test.tsx`; live acceptance evidence is retained in the rationale. diff --git a/docs/specs/vscode.md b/docs/specs/vscode.md index 73783692e..a1fd56b2e 100644 --- a/docs/specs/vscode.md +++ b/docs/specs/vscode.md @@ -81,6 +81,8 @@ Consequences: **One webview is one Workspace.** The bottom-panel `WebviewView` ("Dormouse") is the default Workspace; each `dormouse.open` editor-tab `WebviewPanel` is an independent Workspace. Several are visible at once, and VS Code — not Dormouse — owns their tabs, creation, and closing, so **Dormouse adds no create/rename/close affordances here**: the webview mounts a bare `<Wall>`, which leaves the Workspace strip and its shortcuts to standalone (`docs/specs/layout.md` → Workspaces). A Workspace's Surfaces are the terminal Sessions whose PTYs its router tracks (`ownedPtyIds`, `docs/specs/transport.md`) plus the browser Surfaces rendered in it. +**The extension host refuses every Workspace-spanning `dor` request** — the container verbs, `dor list --workspaces` / `--all`, and any `--workspace` but this webview's own — before it routes one to a webview, since no webview can answer for its siblings (`docs/specs/dor-cli.md` → "dor workspace"; `dorWorkspaceRefusal` in `vscode-ext/src/dor-workspace-guard.ts`). + #### Surfacing union status on native chrome The host computes each webview's union (`ringing` / `todo`) from the module-level `AlertManager` scoped to that router's `ownedPtyIds`, delivered via `attachRouter`'s `onUnion` callback. `ownedPtyIds` are PTY-backed, so **VS Code chrome reflects terminal Session ring + TODO only** — a browser Surface's TODO stays webview-local, `alert:state` being keyed by PTY-backed Session ids (see [Future](#future)). diff --git a/scripts/spec-word-budgets.json b/scripts/spec-word-budgets.json index b7b2252dc..74c9513c2 100644 --- a/scripts/spec-word-budgets.json +++ b/scripts/spec-word-budgets.json @@ -6,7 +6,7 @@ "docs/specs/auto-update.md": 1100, "docs/specs/deploy.md": 1900, "docs/specs/dor-browser.md": 4500, - "docs/specs/dor-cli.md": 5100, + "docs/specs/dor-cli.md": 5450, "docs/specs/dor-tool.md": 2100, "docs/specs/glossary.md": 2950, "docs/specs/layout.md": 8450, @@ -24,7 +24,7 @@ "docs/specs/security-supply-chain.md": 1150, "docs/specs/security.md": 1900, "docs/specs/shortcuts.md": 1050, - "docs/specs/standalone.md": 8250, + "docs/specs/standalone.md": 8350, "docs/specs/terminal-context.md": 900, "docs/specs/terminal-escapes.md": 3750, "docs/specs/terminal-state.md": 2350, @@ -32,7 +32,7 @@ "docs/specs/tiling-engine.md": 4500, "docs/specs/transport.md": 5300, "docs/specs/tutorial.md": 1900, - "docs/specs/vscode.md": 7400, + "docs/specs/vscode.md": 7450, "docs/specs/webgl-text.md": 1200, "docs/specs/website-docs.md": 5050 } From f99a705b6e6de8753d0e6dcbcd85dcd4adec82c2 Mon Sep 17 00:00:00 2001 From: Ned Twigg <ned.twigg@diffplug.com> Date: Thu, 10 Sep 2026 04:51:00 -0700 Subject: [PATCH 06/13] Pin that dor ab intercepts --workspace The flag names a Workspace rather than a browser, so it must reach the two control calls and never the agent-browser binary, which knows nothing about it. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QBf4rQbwuxV6n6v7E3twYe --- dor/test/cli-output.test.mjs | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/dor/test/cli-output.test.mjs b/dor/test/cli-output.test.mjs index 377f1e28f..f316be673 100644 --- a/dor/test/cli-output.test.mjs +++ b/dor/test/cli-output.test.mjs @@ -1056,6 +1056,20 @@ test('agent-browser raw --session skips key namespacing', async () => { assert.deepEqual(client.requests[0].request, { key: undefined, session: 'mine', wsPort: 61141 }); }); +test('agent-browser --workspace names the Workspace and never reaches the binary', async () => { + const ab = fakeAgentBrowser(); + const client = fixtureClient(); + await runCli(['ab', '--workspace', 'build', 'open', 'surface:1'], { client, execAgentBrowser: ab.exec }); + // Intercepted like the identity flags: the browser opens in `build`, the + // handle resolves there, and agent-browser sees neither the flag nor its value. + assert.deepEqual(ab.calls, [ + ['agent-browser', '--session', 'dormouse.1.default', 'open', 'http://localhost:5173/'], + ['agent-browser', '--session', 'dormouse.1.default', 'stream', 'status', '--json'], + ]); + assert.equal(client.requests[0].request.workspace, 'build'); + assert.equal(client.requests[1].request.workspace, 'build'); +}); + test('agent-browser open resolves a surface handle to a URL before forwarding', async () => { const ab = fakeAgentBrowser(); const client = fixtureClient(); From 111e0c914d4ac3c3bd73e26d3e17b3a149988d35 Mon Sep 17 00:00:00 2001 From: Ned Twigg <ned.twigg@diffplug.com> Date: Thu, 10 Sep 2026 05:19:31 -0700 Subject: [PATCH 07/13] Simplify the Workspace CLI after review MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Moves each rule to one home. `dor/src/protocol.ts` gains the wire-only predicates the whole control plane shares — `isWorkspaceControlMethod`, `spansWorkspaces`, `parseWorkspaceRef` — so the router, the `workspace.*` handlers, and the VS Code guard read one enumeration and one ref grammar; the guard becomes "this host serves positional 1, nothing that spans". Caller identity is rewritten at the seam that knows it: `dispatchDorControl` drops a `surfaceId` the answering Wall does not hold, so every `surface:self` / implicit-target consumer is correct by construction rather than re-deriving it. One `classifySurfaceTarget` beside the matcher replaces the router's private copy of the target grammar, and the Wall's `DorControlParams` sheds the Window-level params it never read. Elsewhere: one `WorkspaceScopedRequest` / `WorkspaceScopedFlags` behind the per-command copies, `workspaceFlag` / `workspaceParam` at every call site, one `ACTIONS` table for `dor workspace` in place of four parallel enumerations, one tag trailer shared by both listings, a `switch` with an exhaustiveness check over the container verbs, a parallel `surface.list --all` fan-out, and `resolveWorkspaceRef` / `installWindowPersistence` answering with what their callers were reading back out of the store. Behavior is unchanged; the one help edit is `dor list --workspace` taking the shared flag's wording. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QBf4rQbwuxV6n6v7E3twYe --- docs/specs/dor-cli.md | 20 +- dor/src/commands/agent-browser.ts | 3 +- dor/src/commands/await.ts | 4 +- dor/src/commands/ensure.ts | 4 +- dor/src/commands/iframe.ts | 4 +- dor/src/commands/kill.ts | 4 +- dor/src/commands/list.ts | 94 +++++---- dor/src/commands/open-target.ts | 7 +- dor/src/commands/read.ts | 4 +- dor/src/commands/send.ts | 4 +- dor/src/commands/split.ts | 4 +- dor/src/commands/types.ts | 77 +++---- dor/src/commands/workspace.ts | 102 ++++----- dor/src/protocol.ts | 40 ++++ dor/test/snapshots/help/list.md | 2 +- .../wall/dor-control-router.test.ts | 26 ++- lib/src/components/wall/dor-control-router.ts | 61 +++--- lib/src/components/wall/dor-control-shared.ts | 17 ++ lib/src/components/wall/use-dor-control.ts | 103 ++++++--- .../components/wall/workspace-control.test.ts | 5 +- lib/src/components/wall/workspace-control.ts | 195 +++++++++--------- lib/src/lib/workspace-store.test.ts | 13 +- lib/src/lib/workspace-store.ts | 28 ++- standalone/src/window-restore.ts | 9 +- vscode-ext/src/dor-workspace-guard.ts | 22 +- 25 files changed, 491 insertions(+), 361 deletions(-) create mode 100644 lib/src/components/wall/dor-control-shared.ts diff --git a/docs/specs/dor-cli.md b/docs/specs/dor-cli.md index adaf54931..aea95f742 100644 --- a/docs/specs/dor-cli.md +++ b/docs/specs/dor-cli.md @@ -312,15 +312,18 @@ Invariants: (`docs/specs/layout.md` → "Workspaces"). **Surface targets resolve within the answering Workspace** — refs are Workspace-scoped — so a `dor split` from a background Workspace lands beside its caller rather than wherever the user is - looking, and **a caller that Workspace does not hold falls back to its focused - Surface** rather than failing, which is what gives `--workspace` a reference - to place against. Cross-window duplicate ids follow + looking, and **a caller the answering Workspace does not hold is dropped by + the router**, leaving that Workspace's focused Surface as the fallback rather + than a failure, which is what gives `--workspace` a reference to place + against. Cross-window duplicate ids follow `docs/specs/vscode.md` → "Peer surfaces across windows". Source of truth: `dor/src/commands/shared.ts`, `dor/src/commands/types.ts`, -`surfaceRefForId` / `transferSurfaceRef` in `lib/src/components/Wall.tsx`, -`resolveWorkspaceRef` in `lib/src/lib/workspace-store.ts`, and -`resolveDorControlRoute` in `lib/src/components/wall/dor-control-router.ts`. +`parseWorkspaceRef` in `dor/src/protocol.ts`, `surfaceRefForId` / +`transferSurfaceRef` in `lib/src/components/Wall.tsx`, `classifySurfaceTarget` +in `lib/src/components/wall/use-dor-control.ts`, `resolveWorkspaceRef` in +`lib/src/lib/workspace-store.ts`, and `resolveDorControlRoute` in +`lib/src/components/wall/dor-control-router.ts`. ## Current Implemented Commands @@ -446,8 +449,9 @@ separate webview (`docs/specs/vscode.md` → "Workspaces"), so a listing would report one webview's Workspace as the whole Window. Aggregating them at the extension host stays in [Future](#future). -Source of truth: `dor/src/commands/workspace.ts`, `WORKSPACE_CONTROL_METHODS` in -`dor/src/protocol.ts`, `handleWorkspaceControl` / `listAllWorkspaceSurfaces` in +Source of truth: `dor/src/commands/workspace.ts`, `WORKSPACE_CONTROL_METHODS` / +`spansWorkspaces` in `dor/src/protocol.ts`, `handleWorkspaceControl` / +`listAllWorkspaceSurfaces` in `lib/src/components/wall/workspace-control.ts`, `closeWorkspaceWithSurfaces` in `lib/src/components/wall/workspace-lifecycle.ts`, and `dorWorkspaceRefusal` in `vscode-ext/src/dor-workspace-guard.ts`. diff --git a/dor/src/commands/agent-browser.ts b/dor/src/commands/agent-browser.ts index 325da074f..47ec7c815 100644 --- a/dor/src/commands/agent-browser.ts +++ b/dor/src/commands/agent-browser.ts @@ -200,8 +200,7 @@ export function extractSessionFlags(args: string[]): ParseResult<ResolvedSession return { ok: false, message: `--key must match ${KEY_PATTERN} (it becomes part of an agent-browser session name)` }; } - const container = values.get('--workspace'); - const workspace = container === undefined ? {} : { workspace: container }; + const workspace = workspaceParam(values.get('--workspace')); const surface = values.get('--surface'); if (surface !== undefined) return { ok: true, value: { surface, rest, ...workspace } }; diff --git a/dor/src/commands/await.ts b/dor/src/commands/await.ts index 4dae65948..8cae4f29f 100644 --- a/dor/src/commands/await.ts +++ b/dor/src/commands/await.ts @@ -13,6 +13,7 @@ import type { AwaitUntil, Command, DorCommandContext, + WorkspaceScopedFlags, } from './types.js'; import { errorLine, @@ -27,8 +28,7 @@ import { writeStdout, } from './shared.js'; -interface AwaitFlags { - readonly workspace?: string; +interface AwaitFlags extends WorkspaceScopedFlags { readonly json?: boolean; readonly timeout?: number; readonly until: AwaitUntil; diff --git a/dor/src/commands/ensure.ts b/dor/src/commands/ensure.ts index cbec429ef..8ba7d717b 100644 --- a/dor/src/commands/ensure.ts +++ b/dor/src/commands/ensure.ts @@ -6,6 +6,7 @@ import type { DorCommandContext, EnsureSurfaceResponse, ParseResult, + WorkspaceScopedFlags, } from './types.js'; import { callerWorkingDirectory, @@ -18,8 +19,7 @@ import { writeStdout, } from './shared.js'; -interface EnsureFlags { - readonly workspace?: string; +interface EnsureFlags extends WorkspaceScopedFlags { readonly json?: boolean; readonly minimize?: boolean; readonly restart?: boolean; diff --git a/dor/src/commands/iframe.ts b/dor/src/commands/iframe.ts index 1c4e53637..e806d55c9 100644 --- a/dor/src/commands/iframe.ts +++ b/dor/src/commands/iframe.ts @@ -5,6 +5,7 @@ import type { Command, DorCommandContext, IframeSurfaceResponse, + WorkspaceScopedFlags, } from './types.js'; import { errorMessage, @@ -21,8 +22,7 @@ import { resolveSurfaceOpenTarget, } from './open-target.js'; -interface IframeFlags { - readonly workspace?: string; +interface IframeFlags extends WorkspaceScopedFlags { readonly json?: boolean; readonly minimize?: boolean; readonly surface?: string; diff --git a/dor/src/commands/kill.ts b/dor/src/commands/kill.ts index 9e09ee26a..07119677e 100644 --- a/dor/src/commands/kill.ts +++ b/dor/src/commands/kill.ts @@ -7,6 +7,7 @@ import type { KillSurfaceConfirmation, KillSurfaceResponse, ParseResult, + WorkspaceScopedFlags, } from './types.js'; import { errorMessage, @@ -18,8 +19,7 @@ import { writeStdout, } from './shared.js'; -interface KillFlags { - readonly workspace?: string; +interface KillFlags extends WorkspaceScopedFlags { readonly confirmDangerously?: boolean; readonly confirmIfRead?: string; readonly json?: boolean; diff --git a/dor/src/commands/list.ts b/dor/src/commands/list.ts index a5c9a4919..70c6d0e0c 100644 --- a/dor/src/commands/list.ts +++ b/dor/src/commands/list.ts @@ -23,6 +23,7 @@ import type { SurfacePort, SurfaceView, WorkspaceRow, + WorkspaceScopedFlags, } from './types.js'; import { hasBrowser, hasTerminal, SURFACE_KINDS } from './types.js'; import { @@ -34,10 +35,12 @@ import { renderJson, requireControlClient, stringParser, + workspaceFlag, + workspaceParam, writeStdout, } from './shared.js'; -interface ListFlags { +interface ListFlags extends WorkspaceScopedFlags { readonly all?: boolean; readonly command?: string; readonly cwd?: string; @@ -47,7 +50,6 @@ interface ListFlags { readonly port?: number; readonly ports?: boolean; readonly view?: SurfaceView; - readonly workspace?: string; readonly workspaces?: boolean; } @@ -140,13 +142,7 @@ function buildListCommand(): Command['command'] { optional: true, placeholder: 'paned|zoomed|minimized', }, - workspace: { - kind: 'parsed', - parse: stringParser, - brief: 'Workspace to list instead of the caller\'s.', - optional: true, - placeholder: 'ref', - }, + workspace: workspaceFlag, workspaces: { kind: 'boolean', brief: 'Print the Workspace overview instead of Surfaces.', @@ -193,7 +189,7 @@ async function runListCommand( const response = await client.listSurfaces({ includePorts, ...(flags.all === true ? { scope: 'all' as const } : {}), - ...(flags.workspace === undefined ? {} : { workspace: flags.workspace }), + ...workspaceParam(flags.workspace), }); const env = context.options.env ?? {}; const filtered = applyListFilters(response, flags, env); @@ -208,6 +204,20 @@ async function runListCommand( } } +/** Every flag the Workspace overview does not take, spelled as the user typed + * it and listed in help order. `--json` and `--workspaces` are the two it does. */ +const SURFACE_ONLY_FLAGS: ReadonlyArray<[keyof ListFlags, string]> = [ + ['all', '--all'], + ['command', '--command'], + ['cwd', '--cwd'], + ['idFormat', '--id-format'], + ['kind', '--kind'], + ['port', '--port'], + ['ports', '--ports'], + ['view', '--view'], + ['workspace', '--workspace'], +]; + /** The three container flags name one scope between them, and the overview is a * different listing rather than a filter on this one. */ function checkScopeFlags(flags: ListFlags): { ok: true } | { ok: false; message: string } { @@ -215,9 +225,9 @@ function checkScopeFlags(flags: ListFlags): { ok: true } | { ok: false; message: return { ok: false, message: '--all and --workspace are mutually exclusive' }; } if (flags.workspaces === true) { - const others = Object.entries(flags) - .filter(([name, value]) => name !== 'workspaces' && name !== 'json' && value !== undefined) - .map(([name]) => `--${name.replace(/[A-Z]/g, (upper) => `-${upper.toLowerCase()}`)}`); + const others = SURFACE_ONLY_FLAGS + .filter(([name]) => flags[name] !== undefined) + .map(([, spelling]) => spelling); if (others.length > 0) { return { ok: false, message: `dor list --workspaces takes only --json, not ${others.join(', ')}` }; } @@ -266,25 +276,32 @@ function renderListText( const rows = surfaceRows(response, env, idFormat, includePorts); if (!response.workspaces) return rows.length === 0 ? '' : `${rows.join('\n')}\n`; - const byWorkspace = new Map<string, string[]>(); - response.surfaces.forEach((surface, index) => { - const ref = surface.workspaceRef ?? response.workspaceRef; - const group = byWorkspace.get(ref) ?? []; - group.push(` ${rows[index]}`); - byWorkspace.set(ref, group); - }); - + // Every row of a `--all` answer carries the Workspace it came from + // (`GroupedSurface`), so a group is its own rows in response order. const groups = response.workspaces + .map((workspace) => ({ + header: `${workspace.ref} ${workspace.name}${workspace.active ? ' [active]' : ''}`, + lines: response.surfaces.flatMap((surface, index) => ( + surface.workspaceRef === workspace.ref ? [` ${rows[index]}`] : [] + )), + })) // A Workspace every filter emptied prints no header: the group is not there // to be listed. - .filter((workspace) => (byWorkspace.get(workspace.ref) ?? []).length > 0) - .map((workspace) => [ - `${workspace.ref} ${workspace.name}${workspace.active ? ' [active]' : ''}`, - ...(byWorkspace.get(workspace.ref) ?? []), - ].join('\n')); + .filter((group) => group.lines.length > 0) + .map((group) => [group.header, ...group.lines].join('\n')); return groups.length === 0 ? '' : `${groups.join('\n\n')}\n`; } +/** The trailing tag block both listings share: two spaces before each tag. */ +function tagTrailer(tags: string[]): string { + return tags.length > 0 ? ` ${tags.join(' ')}` : ''; +} + +/** The attention tags a Surface row and a Workspace row spell the same way. */ +function attentionTags(row: { ringing: boolean; todo: boolean }): string[] { + return [...(row.ringing ? ['[ringing]'] : []), ...(row.todo ? ['[todo]'] : [])]; +} + /** One text row per Surface, in response order, sharing one set of columns. */ function surfaceRows( response: ListSurfacesResponse, @@ -313,17 +330,16 @@ function surfaceRows( const view = surface.view.padEnd(viewWidth); const location = locations[index].padEnd(locationWidth); - const tags: string[] = []; - if (callerId !== undefined && surface.id === callerId) tags.push('(you)'); - if (surface.ringing) tags.push('[ringing]'); - if (surface.todo) tags.push('[todo]'); - if (surface.awaited) tags.push('[awaited]'); - if (includePorts && surface.ports && surface.ports.length > 0) { - tags.push(surface.ports.map((port) => `:${port.port}`).join(' ')); - } - const trailer = tags.length > 0 ? ` ${tags.join(' ')}` : ''; + const tags = [ + ...(callerId !== undefined && surface.id === callerId ? ['(you)'] : []), + ...attentionTags(surface), + ...(surface.awaited ? ['[awaited]'] : []), + ...(includePorts && surface.ports && surface.ports.length > 0 + ? [surface.ports.map((port) => `:${port.port}`).join(' ')] + : []), + ]; - return `${marker} ${handle} ${kind} ${renderMode} ${view} ${location} ${surface.title}${trailer}`.trimEnd(); + return `${marker} ${handle} ${kind} ${renderMode} ${view} ${location} ${surface.title}${tagTrailer(tags)}`.trimEnd(); }); return lines; @@ -337,12 +353,10 @@ function renderWorkspacesText(response: ListWorkspacesResponse): string { const nameWidth = Math.max(...rows.map((row) => row.name.length)); const lines = rows.map((row) => { const tags = [ - ...(row.ringing ? ['[ringing]'] : []), - ...(row.todo ? ['[todo]'] : []), + ...attentionTags(row), ...(row.count > 0 ? [`[attention ${row.count}]`] : []), ]; - const trailer = tags.length > 0 ? ` ${tags.join(' ')}` : ''; - return `${row.active ? '*' : ' '} ${row.ref.padEnd(refWidth)} ${row.name.padEnd(nameWidth)}${trailer}`.trimEnd(); + return `${row.active ? '*' : ' '} ${row.ref.padEnd(refWidth)} ${row.name.padEnd(nameWidth)}${tagTrailer(tags)}`.trimEnd(); }); return `${lines.join('\n')}\n`; } diff --git a/dor/src/commands/open-target.ts b/dor/src/commands/open-target.ts index 2e7c85df4..08e240747 100644 --- a/dor/src/commands/open-target.ts +++ b/dor/src/commands/open-target.ts @@ -1,7 +1,7 @@ /** Target normalization shared by `dor iframe` and `dor ab open`; see * docs/specs/dor-cli.md → "Browser Open Target Resolution". */ -import { errorMessage } from './shared.js'; +import { errorMessage, workspaceParam } from './shared.js'; import type { ControlClient, ParseResult } from './types.js'; declare const URL: { @@ -91,10 +91,7 @@ export async function resolveSurfaceOpenTarget( workspace?: string, ): Promise<ParseResult<string>> { try { - const { url } = await client.resolveOpenTarget({ - surface: target, - ...(workspace === undefined ? {} : { workspace }), - }); + const { url } = await client.resolveOpenTarget({ surface: target, ...workspaceParam(workspace) }); return { ok: true, value: url }; } catch (error) { return { ok: false, message: errorMessage(error) }; diff --git a/dor/src/commands/read.ts b/dor/src/commands/read.ts index c4be8f87c..81b498af8 100644 --- a/dor/src/commands/read.ts +++ b/dor/src/commands/read.ts @@ -5,6 +5,7 @@ import type { Command, DorCommandContext, ReadSurfaceResponse, + WorkspaceScopedFlags, } from './types.js'; import { errorMessage, @@ -17,8 +18,7 @@ import { writeStdout, } from './shared.js'; -interface ReadFlags { - readonly workspace?: string; +interface ReadFlags extends WorkspaceScopedFlags { readonly json?: boolean; readonly lines?: number; readonly scrollback?: boolean; diff --git a/dor/src/commands/send.ts b/dor/src/commands/send.ts index a28a393d0..ccc8ab198 100644 --- a/dor/src/commands/send.ts +++ b/dor/src/commands/send.ts @@ -6,6 +6,7 @@ import type { DorCommandContext, ParseResult, SendSurfaceResponse, + WorkspaceScopedFlags, } from './types.js'; import { errorMessage, @@ -17,8 +18,7 @@ import { writeStdout, } from './shared.js'; -interface SendFlags { - readonly workspace?: string; +interface SendFlags extends WorkspaceScopedFlags { readonly json?: boolean; readonly key?: string; readonly raw?: boolean; diff --git a/dor/src/commands/split.ts b/dor/src/commands/split.ts index 09a97ffb8..3b962ae9c 100644 --- a/dor/src/commands/split.ts +++ b/dor/src/commands/split.ts @@ -7,6 +7,7 @@ import type { ParseResult, SplitDirection, SplitSurfaceResponse, + WorkspaceScopedFlags, } from './types.js'; import { errorMessage, @@ -18,8 +19,7 @@ import { writeStdout, } from './shared.js'; -interface SplitFlags { - readonly workspace?: string; +interface SplitFlags extends WorkspaceScopedFlags { readonly auto?: boolean; readonly down?: boolean; readonly json?: boolean; diff --git a/dor/src/commands/types.ts b/dor/src/commands/types.ts index f0613e867..94c579174 100644 --- a/dor/src/commands/types.ts +++ b/dor/src/commands/types.ts @@ -84,17 +84,34 @@ export interface Surface { * request set `includePorts` (`dor list --ports`); never on browser Surfaces. */ ports?: SurfacePort[]; /** The Workspace this Surface belongs to. Present only for a `scope: 'all'` - * listing, where rows from several Workspaces share one list. */ + * listing, where rows from several Workspaces share one list — every row of + * one carries it ({@link GroupedSurface}). */ workspaceRef?: string; } +/** A row of a cross-Workspace listing (`dor list --all`): every row says which + * Workspace it came from, so the caller can group them. */ +export type GroupedSurface = Surface & { workspaceRef: string }; + /** How wide a listing reaches: one Workspace (the default) or every Workspace * in this Window. */ export type ListScope = 'workspace' | 'all'; -export interface ListSurfacesRequest { - pane?: string; +/** Every request that may name a container: `dor --workspace <ref>` acts in + * that Workspace instead of the caller's, resolved by the router before caller + * ownership (`docs/specs/dor-cli.md` → "Handle Model"). */ +export interface WorkspaceScopedRequest { workspace?: string; +} + +/** The parsed `--workspace <ref>` flag behind it (`workspaceFlag` in + * `dor/src/commands/shared.ts`), which every action command declares. */ +export interface WorkspaceScopedFlags { + readonly workspace?: string; +} + +export interface ListSurfacesRequest extends WorkspaceScopedRequest { + pane?: string; window?: string; /** Omitted means `workspace`. */ scope?: ListScope; @@ -105,8 +122,8 @@ export interface ListSurfacesRequest { export interface ListSurfacesResponse { surfaces: Surface[]; - /** The Workspace that answered; for `scope: 'all'`, the one the request - * landed in. */ + /** The Workspace that answered; for `scope: 'all'`, which every Workspace + * answers, the active one. */ workspaceRef: string; windowRef: string; /** Present only for `scope: 'all'`: this Window's Workspaces in strip order, @@ -171,10 +188,7 @@ export interface WorkspaceMutationResponse { name: string; } -export interface SplitSurfaceRequest { - /** Act in this Workspace instead of the caller's (`dor --workspace <ref>`). - * Resolved by the router before caller ownership. */ - workspace?: string; +export interface SplitSurfaceRequest extends WorkspaceScopedRequest { /** Raw argv for the initial command; the host quotes it for the target shell. */ command?: string[]; direction: SplitDirection; @@ -196,10 +210,7 @@ export interface SplitSurfaceResponse { command?: string; } -export interface EnsureSurfaceRequest { - /** Act in this Workspace instead of the caller's (`dor --workspace <ref>`). - * Resolved by the router before caller ownership. */ - workspace?: string; +export interface EnsureSurfaceRequest extends WorkspaceScopedRequest { /** Raw argv for the command; the host quotes it for the target shell. */ command: string[]; minimized: boolean; @@ -219,10 +230,7 @@ export interface EnsureSurfaceResponse { minimized: boolean; } -export interface SendSurfaceRequest { - /** Act in this Workspace instead of the caller's (`dor --workspace <ref>`). - * Resolved by the router before caller ownership. */ - workspace?: string; +export interface SendSurfaceRequest extends WorkspaceScopedRequest { surface: string; input: string; inputCount: number; @@ -235,10 +243,7 @@ export interface SendSurfaceResponse { inputCount: number; } -export interface ReadSurfaceRequest { - /** Act in this Workspace instead of the caller's (`dor --workspace <ref>`). - * Resolved by the router before caller ownership. */ - workspace?: string; +export interface ReadSurfaceRequest extends WorkspaceScopedRequest { lines?: number; scrollback: boolean; surface: string; @@ -262,10 +267,7 @@ export type AwaitCause = 'quiet' | 'exit' | 'bell' | 'idle'; * the client is already gone, and nothing it responds with could be delivered. */ export type AwaitSurfaceOutcome = 'resolved' | 'timeout' | 'died'; -export interface AwaitSurfaceRequest { - /** Act in this Workspace instead of the caller's (`dor --workspace <ref>`). - * Resolved by the router before caller ownership. */ - workspace?: string; +export interface AwaitSurfaceRequest extends WorkspaceScopedRequest { surface: string; until: AwaitUntil; /** The caller's ceiling, enforced host-side so no hop can reap the wait early. */ @@ -287,10 +289,7 @@ export type KillSurfaceConfirmation = | { mode: 'if-read'; text: string } | { mode: 'dangerously' }; -export interface KillSurfaceRequest { - /** Act in this Workspace instead of the caller's (`dor --workspace <ref>`). - * Resolved by the router before caller ownership. */ - workspace?: string; +export interface KillSurfaceRequest extends WorkspaceScopedRequest { confirmation: KillSurfaceConfirmation; surface: string; } @@ -301,10 +300,7 @@ export interface KillSurfaceResponse { surfaceRef: string; } -export interface IframeSurfaceRequest { - /** Act in this Workspace instead of the caller's (`dor --workspace <ref>`). - * Resolved by the router before caller ownership. */ - workspace?: string; +export interface IframeSurfaceRequest extends WorkspaceScopedRequest { minimized: boolean; surface?: string; url: string; @@ -318,10 +314,7 @@ export interface IframeSurfaceResponse { minimized: boolean; } -export interface ResolveOpenTargetRequest { - /** Act in this Workspace instead of the caller's (`dor --workspace <ref>`). - * Resolved by the router before caller ownership. */ - workspace?: string; +export interface ResolveOpenTargetRequest extends WorkspaceScopedRequest { /** A terminal Surface handle (surface:N, surface:<stable-id>, surface:self, * surface:focused) whose dev-server URL should be resolved. */ surface: string; @@ -336,10 +329,7 @@ export interface ResolveOpenTargetResponse { port: number; } -export interface ResolveAgentBrowserSessionRequest { - /** Act in this Workspace instead of the caller's (`dor --workspace <ref>`). - * Resolved by the router before caller ownership. */ - workspace?: string; +export interface ResolveAgentBrowserSessionRequest extends WorkspaceScopedRequest { /** A Surface handle (surface:N, surface:<stable-id>, surface:self, * surface:focused, title:<title>) naming the browser Surface to drive. */ surface: string; @@ -354,10 +344,7 @@ export interface ResolveAgentBrowserSessionResponse { session: string; } -export interface AgentBrowserSurfaceRequest { - /** Act in this Workspace instead of the caller's (`dor --workspace <ref>`). - * Resolved by the router before caller ownership. */ - workspace?: string; +export interface AgentBrowserSurfaceRequest extends WorkspaceScopedRequest { /** Managed workspace-scoped key; absent when attaching via raw --session. */ key?: string; /** Resolved agent-browser session name — the join key for the surface. */ diff --git a/dor/src/commands/workspace.ts b/dor/src/commands/workspace.ts index 398147c39..05f2ca302 100644 --- a/dor/src/commands/workspace.ts +++ b/dor/src/commands/workspace.ts @@ -30,15 +30,48 @@ interface WorkspaceFlags { readonly json?: boolean; } -const ACTIONS = ['new', 'rename', 'close', 'switch'] as const; -type WorkspaceAction = (typeof ACTIONS)[number]; +/** One action of `dor workspace`: how it is spelled in help, what argument + * count it takes and what to say when the count is wrong, and the verb it + * calls. Enumerated once so help, parsing, and dispatch cannot drift. */ +interface WorkspaceActionSpec { + /** The arguments and flags after the action name, as help prints them. */ + usage: string; + accepts: (args: string[]) => boolean; + arityError: string; + run: (client: ControlClient, args: string[], flags: WorkspaceFlags) => Promise<WorkspaceMutationResponse>; +} -const USAGE = [ - 'new [name] [--json]', - 'rename <workspace> <name> [--json]', - 'close <workspace> [--force] [--json]', - 'switch <workspace> [--json]', -]; +const ACTIONS = { + new: { + usage: '[name] [--json]', + accepts: (args) => args.length <= 1, + arityError: 'dor workspace new takes an optional name', + run: (client, args) => client.newWorkspace(args[0] === undefined ? {} : { name: args[0] }), + }, + rename: { + usage: '<workspace> <name> [--json]', + accepts: (args) => args.length === 2, + arityError: 'dor workspace rename takes a workspace and a name', + run: (client, args) => client.renameWorkspace({ workspace: args[0], name: args[1] }), + }, + close: { + usage: '<workspace> [--force] [--json]', + accepts: (args) => args.length === 1, + arityError: 'dor workspace close takes one workspace', + run: (client, args, flags) => client.closeWorkspace({ workspace: args[0], force: flags.force === true }), + }, + switch: { + usage: '<workspace> [--json]', + accepts: (args) => args.length === 1, + arityError: 'dor workspace switch takes one workspace', + run: (client, args) => client.switchWorkspace({ workspace: args[0] }), + }, +} as const satisfies Record<string, WorkspaceActionSpec>; + +type WorkspaceAction = keyof typeof ACTIONS; + +const ACTION_NAMES = Object.keys(ACTIONS) as WorkspaceAction[]; +const USAGE = ACTION_NAMES.map((name) => `${name} ${ACTIONS[name].usage}`); export const workspaceCommand: Command = { name: 'workspace', @@ -49,7 +82,7 @@ export const workspaceCommand: Command = { scope: 'root', findReplace: [ ' dor workspace [--force] [--json]<TO-EOL>', - ' dor workspace new|rename|close|switch [args...] [--force] [--json]\n', + ` dor workspace ${ACTION_NAMES.join('|')} [args...] [--force] [--json]\n`, ], }, ], @@ -97,12 +130,12 @@ async function runWorkspaceCommand( flags: WorkspaceFlags, ...args: string[] ): Promise<void | Error> { - const action = parseAction(args[0]); - if (!action.ok) return new Error(action.message); + const parsed = parseAction(args[0]); + if (!parsed.ok) return new Error(parsed.message); + const action = ACTIONS[parsed.value]; const rest = args.slice(1); - const arity = checkArity(action.value, rest); - if (!arity.ok) return new Error(arity.message); - if (flags.force === true && action.value !== 'close') { + if (!action.accepts(rest)) return new Error(action.arityError); + if (flags.force === true && parsed.value !== 'close') { return new Error('--force applies only to dor workspace close'); } @@ -110,7 +143,7 @@ async function runWorkspaceCommand( if (client instanceof Error) return client; try { - const response = await runAction(client, action.value, rest, flags); + const response = await action.run(client, rest, flags); writeStdout(this, renderWorkspaceResponse(response, flags.json === true)); return undefined; } catch (error) { @@ -119,7 +152,7 @@ async function runWorkspaceCommand( } function parseAction(value: string | undefined): ParseResult<WorkspaceAction> { - const action = ACTIONS.find((candidate) => candidate === value); + const action = ACTION_NAMES.find((candidate) => candidate === value); if (action) return { ok: true, value: action }; // The one wrong guess worth answering by name: enumeration lives in dor list. if (value === 'list') { @@ -128,42 +161,9 @@ function parseAction(value: string | undefined): ParseResult<WorkspaceAction> { return { ok: false, message: value === undefined - ? `dor workspace requires an action: ${ACTIONS.join(', ')}` - : `unknown dor workspace action '${value}' (expected ${ACTIONS.join(', ')})`, - }; -} - -function checkArity(action: WorkspaceAction, rest: string[]): ParseResult<void> { - const expected: Record<WorkspaceAction, string> = { - new: 'dor workspace new takes an optional name', - rename: 'dor workspace rename takes a workspace and a name', - close: 'dor workspace close takes one workspace', - switch: 'dor workspace switch takes one workspace', + ? `dor workspace requires an action: ${ACTION_NAMES.join(', ')}` + : `unknown dor workspace action '${value}' (expected ${ACTION_NAMES.join(', ')})`, }; - const ok = action === 'new' - ? rest.length <= 1 - : action === 'rename' - ? rest.length === 2 - : rest.length === 1; - return ok ? { ok: true, value: undefined } : { ok: false, message: expected[action] }; -} - -function runAction( - client: ControlClient, - action: WorkspaceAction, - rest: string[], - flags: WorkspaceFlags, -): Promise<WorkspaceMutationResponse> { - switch (action) { - case 'new': - return client.newWorkspace(rest[0] === undefined ? {} : { name: rest[0] }); - case 'rename': - return client.renameWorkspace({ workspace: rest[0], name: rest[1] }); - case 'close': - return client.closeWorkspace({ workspace: rest[0], force: flags.force === true }); - case 'switch': - return client.switchWorkspace({ workspace: rest[0] }); - } } function renderWorkspaceResponse(response: WorkspaceMutationResponse, json: boolean): string { diff --git a/dor/src/protocol.ts b/dor/src/protocol.ts index ac03eb423..29ffc7174 100644 --- a/dor/src/protocol.ts +++ b/dor/src/protocol.ts @@ -48,6 +48,46 @@ export type WorkspaceControlMethod = (typeof WORKSPACE_CONTROL_METHODS)[keyof ty /** Every method the control channel carries. */ export type DorControlMethod = SurfaceControlMethod | WorkspaceControlMethod; +const WORKSPACE_METHOD_SET: ReadonlySet<string> = new Set(Object.values(WORKSPACE_CONTROL_METHODS)); + +/** Whether this method is a container verb — answered by the Window rather than + * by one Workspace's Wall. */ +export function isWorkspaceControlMethod(method: string): method is WorkspaceControlMethod { + return WORKSPACE_METHOD_SET.has(method); +} + +/** + * Whether this request reaches beyond a single Workspace: a container verb, or + * the `scope: 'all'` listing. A host that cannot answer for more than one + * Workspace refuses exactly these (`docs/specs/vscode.md` → "Workspaces"). + */ +export function spansWorkspaces(method: string, params?: Record<string, unknown>): boolean { + return isWorkspaceControlMethod(method) + || (method === SURFACE_CONTROL_METHODS.list && params?.scope === 'all'); +} + +/** The two readings of a `workspace:<n|name>` target (`docs/specs/dor-cli.md` → + * "Handle Model"). Both spellings are accepted bare. */ +export interface ParsedWorkspaceRef { + /** The target as written, trimmed — what an error message quotes back. */ + target: string; + /** 1-based strip position when the ref reads as a number, else null. */ + position: number | null; + /** The Workspace name it reads as otherwise; empty when it is positional. */ + name: string; +} + +const POSITIONAL_WORKSPACE_REF = /^[1-9]\d*$/; + +/** Split a `workspace:<n|name>` target into its readings. A ref that reads as a + * number is positional, never a name. */ +export function parseWorkspaceRef(ref: string): ParsedWorkspaceRef { + const target = ref.trim(); + const bare = (target.startsWith('workspace:') ? target.slice('workspace:'.length) : target).trim(); + const positional = POSITIONAL_WORKSPACE_REF.test(bare); + return { target, position: positional ? Number(bare) : null, name: positional ? '' : bare }; +} + /** A control request as it travels over a transport, correlated by `requestId`. */ export interface DorControlRequestPayload { requestId: string; diff --git a/dor/test/snapshots/help/list.md b/dor/test/snapshots/help/list.md index a2a7e08f2..d351a4841 100644 --- a/dor/test/snapshots/help/list.md +++ b/dor/test/snapshots/help/list.md @@ -45,7 +45,7 @@ FLAGS [--port] Show terminal Surfaces listening on this TCP port. [--ports] Include each terminal's listening ports. [--view] Surface view to show. - [--workspace] Workspace to list instead of the caller's. + [--workspace] Workspace to act in, instead of the caller's. [--workspaces] Print the Workspace overview instead of Surfaces. -h --help Print help information and exit -- All subsequent inputs should be interpreted as arguments diff --git a/lib/src/components/wall/dor-control-router.test.ts b/lib/src/components/wall/dor-control-router.test.ts index f37ceaf20..6007decf2 100644 --- a/lib/src/components/wall/dor-control-router.test.ts +++ b/lib/src/components/wall/dor-control-router.test.ts @@ -114,10 +114,11 @@ describe('dor control routing', () => { it('answers the container verbs and --all at the Window, with no Wall involved', () => { handleFor(getWorkspacesSnapshot().workspaces[0].id, ['pane-a']); for (const method of ['workspace.list', 'workspace.new', 'workspace.close']) { - expect(resolveDorControlRoute(request({ method, surfaceId: 'pane-a' }))).toEqual({ kind: 'window' }); + expect(resolveDorControlRoute(request({ method, surfaceId: 'pane-a' }))) + .toEqual({ kind: 'window', container: true }); } expect(resolveDorControlRoute(request({ method: 'surface.list', params: { scope: 'all' } }))) - .toEqual({ kind: 'window' }); + .toEqual({ kind: 'window', container: false }); expect(resolveDorControlRoute(request({ method: 'surface.list', params: { scope: 'workspace' } })).kind) .toBe('handle'); }); @@ -244,6 +245,27 @@ describe('dor control routing', () => { } }); + it('drops a caller the answering Wall does not hold', () => { + const first = getWorkspacesSnapshot().workspaces[0].id; + createWorkspace({ id: 'ws-2', name: 'build' }); + handleFor(first, ['pane-a']); + const target = handleFor('ws-2', ['pane-b']); + const release = installDorControlRouter(); + + // `dor split --workspace build` from pane-a: the caller belongs to another + // Workspace, so the answering Wall is handed no caller and falls back to + // its own focused Surface. + const foreign = request({ surfaceId: 'pane-a', params: { workspace: 'build' } }); + window.dispatchEvent(new CustomEvent('dormouse:control-request', { detail: foreign })); + expect(target.handleDorControl).toHaveBeenCalledWith({ ...foreign, surfaceId: undefined }); + + // A caller its own Wall holds arrives untouched. + const own = request({ surfaceId: 'pane-b' }); + window.dispatchEvent(new CustomEvent('dormouse:control-request', { detail: own })); + expect(target.handleDorControl).toHaveBeenLastCalledWith(own); + release(); + }); + it('answers a bad container target instead of handing it to a Wall', () => { const handle = handleFor(getWorkspacesSnapshot().workspaces[0].id); const release = installDorControlRouter(); diff --git a/lib/src/components/wall/dor-control-router.ts b/lib/src/components/wall/dor-control-router.ts index 45b2c37fc..d5dccfa1b 100644 --- a/lib/src/components/wall/dor-control-router.ts +++ b/lib/src/components/wall/dor-control-router.ts @@ -1,9 +1,10 @@ -import { SURFACE_CONTROL_METHODS } from 'dor/protocol'; +import { isWorkspaceControlMethod, SURFACE_CONTROL_METHODS } from 'dor/protocol'; import { createRefCount } from '../../lib/ref-count'; import { getActiveWorkspaceId, isWindowRef, resolveWorkspaceRef } from '../../lib/workspace-store'; +import { errorText } from './dor-control-shared'; import { getWallHandle, wallHandleOwning, type WallHandle } from './wall-handles'; -import { handleWorkspaceControl, isWorkspaceControlMethod, listAllWorkspaceSurfaces } from './workspace-control'; -import type { DorControlRequest } from './use-dor-control'; +import { handleWorkspaceControl, listAllWorkspaceSurfaces, type WindowControlParams } from './workspace-control'; +import { classifySurfaceTarget, type DorControlRequest } from './use-dor-control'; /** * The one window listener for `dormouse:control-request`, deciding which Wall @@ -16,23 +17,23 @@ import type { DorControlRequest } from './use-dor-control'; /** Where one control request lands. */ export type DorControlRoute = | { kind: 'handle'; handle: WallHandle } - /** Answered by the Window itself: a `workspace.*` verb, or `--all`. */ - | { kind: 'window' } + /** Answered by the Window itself: a `workspace.*` container verb + * (`container: true`), or the `--all` listing that spans them. */ + | { kind: 'window'; container: boolean } | { kind: 'error'; message: string } /** Nothing is mounted that could answer; the request is left to time out. */ | { kind: 'none' }; /** - * Whether a target names a Surface by its stable id — the one handle that is - * unique across the whole Window, so it can be routed to the Workspace holding - * it. `surface:N` is Workspace-scoped and deliberately excluded: every Workspace - * has a `surface:1`. + * The Workspace holding the Surface this target names, when the target names + * one Window-wide: only a stable id does (`classifySurfaceTarget`). `surface:N` + * is Workspace-scoped — every Workspace has a `surface:1` — so it stays with + * the Wall that answers. */ -function stableSurfaceTarget(target: unknown): string | null { +function wallHandleOwningTarget(target: unknown): WallHandle | null { if (typeof target !== 'string') return null; - const id = target.startsWith('surface:') ? target.slice('surface:'.length) : target; - if (!id || /^\d+$/.test(id) || id === 'self' || id === 'focused' || target.startsWith('title:')) return null; - return id; + const classified = classifySurfaceTarget(target); + return classified.kind === 'stable' ? wallHandleOwning(classified.id) : null; } /** @@ -42,7 +43,7 @@ function stableSurfaceTarget(target: unknown): string | null { * its own Workspace. */ export function resolveDorControlRoute(detail: DorControlRequest): DorControlRoute { - const params = detail.params ?? {}; + const params: WindowControlParams = detail.params ?? {}; // Typed before use: `params` is whatever crossed the control socket, and a // non-string ref reaching `.trim()` would throw out of the window listener, // leaving the caller to block until its own deadline. @@ -50,8 +51,10 @@ export function resolveDorControlRoute(detail: DorControlRequest): DorControlRou return { kind: 'error', message: `unknown window target '${String(params.window)}'` }; } // Container verbs belong to no Workspace, and `--all` spans them all. - if (isWorkspaceControlMethod(detail.method)) return { kind: 'window' }; - if (detail.method === SURFACE_CONTROL_METHODS.list && params.scope === 'all') return { kind: 'window' }; + if (isWorkspaceControlMethod(detail.method)) return { kind: 'window', container: true }; + if (detail.method === SURFACE_CONTROL_METHODS.list && params.scope === 'all') { + return { kind: 'window', container: false }; + } if (params.workspace !== undefined) { // A ref of the wrong type, one outside the strip, and one whose Wall is not // mounted are the same answer: this Window has no such Workspace to route to. @@ -66,8 +69,7 @@ export function resolveDorControlRoute(detail: DorControlRequest): DorControlRou } // A stable id names one Surface in the whole Window, so a command targeting // one is answered by whichever Workspace holds it, caller or not. - const stable = stableSurfaceTarget(params.surface); - const owningTarget = stable ? wallHandleOwning(stable) : null; + const owningTarget = wallHandleOwningTarget(params.surface); if (owningTarget) return { kind: 'handle', handle: owningTarget }; // The caller's own Workspace: `dor split` from a background Workspace lands // beside its caller, not in whichever Workspace the user is looking at. @@ -102,24 +104,27 @@ function dispatchDorControl(detail: DorControlRequest, attempt: number): void { // Every failure the handler can raise is answered: an unanswered request // blocks its caller until the CLI's own deadline // (`docs/specs/dor-cli.md` → "Handle Model"). - const fail = (error: unknown) => detail.respond({ - ok: false, - error: error instanceof Error ? error.message : String(error), - }); + const fail = (error: unknown) => detail.respond({ ok: false, error: errorText(error) }); try { const running = route.kind === 'window' - ? runWindowControl(detail) - : (route.handle.handleDorControl(detail) as unknown); + ? (route.container ? handleWorkspaceControl(detail) : listAllWorkspaceSurfaces(detail)) + : (route.handle.handleDorControl(callerFor(route.handle, detail)) as unknown); if (running instanceof Promise) void running.catch(fail); } catch (error) { fail(error); } } -function runWindowControl(detail: DorControlRequest): Promise<void> { - return isWorkspaceControlMethod(detail.method) - ? handleWorkspaceControl(detail) - : listAllWorkspaceSurfaces(detail); +/** + * The request as the answering Wall sees it: a caller that Wall does not hold + * is dropped here, so `surface:self` and an omitted target fall back to that + * Workspace's own focused Surface rather than naming a Surface no consumer down + * there can find. Rewritten once, at the seam, instead of re-checked by every + * consumer of the caller id. + */ +function callerFor(handle: WallHandle, detail: DorControlRequest): DorControlRequest { + if (!detail.surfaceId || handle.ownsSurface(detail.surfaceId)) return detail; + return { ...detail, surfaceId: undefined }; } /** diff --git a/lib/src/components/wall/dor-control-shared.ts b/lib/src/components/wall/dor-control-shared.ts new file mode 100644 index 000000000..c9edace78 --- /dev/null +++ b/lib/src/components/wall/dor-control-shared.ts @@ -0,0 +1,17 @@ +/** + * The two conversions every side of the `dor` control plane makes: an + * unvalidated wire param read as a string, and any thrown failure read as the + * text a response carries. Shared by the Wall's handler, the Window-level + * router, and the `workspace.*` handlers, so a request answers the same way + * whichever of them answers it (`docs/specs/dor-cli.md` → "Handle Model"). + */ + +/** A param as it crossed the control socket: whatever is not a string is absent. */ +export function stringParam(value: unknown): string | undefined { + return typeof value === 'string' ? value : undefined; +} + +/** The message a failed response carries, from a throw or a rejection. */ +export function errorText(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} diff --git a/lib/src/components/wall/use-dor-control.ts b/lib/src/components/wall/use-dor-control.ts index 3c9070796..b0af45e15 100644 --- a/lib/src/components/wall/use-dor-control.ts +++ b/lib/src/components/wall/use-dor-control.ts @@ -22,6 +22,7 @@ import { } from '../../lib/terminal-registry'; import { surfaceRunsCommand, type TerminalPaneState } from '../../lib/terminal-state'; import { isAllowedAgentBrowserBinary } from '../../lib/agent-browser-binary'; +import { stringParam } from './dor-control-shared'; import { browserSurfaceUrl, hostPathDisplay } from './browser-url'; import { agentBrowserSessionFromParams } from './browser-surface'; import { listenerUrlsByPort } from './port-url'; @@ -29,6 +30,9 @@ import { dorDirectionForEdge, type LathWallEngine } from './lath-wall-engine'; import type { WallNav } from './keyboard/types'; import type { CloseSurfaceMode, DooredItem } from './wall-types'; +/** The params a Wall reads. The Window-level params (`scope`, and the container + * verbs' own) are the router's, not a Wall's: `WindowControlParams` in + * `workspace-control.ts`. */ export type DorControlParams = { command?: unknown; confirmation?: unknown; @@ -45,9 +49,6 @@ export type DorControlParams = { restart?: unknown; binaryPath?: unknown; includePorts?: unknown; - name?: unknown; - force?: unknown; - scope?: unknown; pane?: string; session?: unknown; surface?: unknown; @@ -103,22 +104,65 @@ type EnsureAgentBrowserSurface = (args: { minimized?: boolean; }) => EnsureAgentBrowserSurfaceResult; -function matchesDorSurfaceTarget( - target: string | undefined, +/** + * What a `dor` Surface target names, in the one grammar + * `docs/specs/dor-cli.md` → "Handle Model" defines. `stable` is the only kind + * that identifies a Surface Window-wide, which is what lets the router send a + * request to whichever Workspace holds it; `ref` is Workspace-scoped (every + * Workspace has a `surface:1`), and `nothing` is a target that names no + * Surface at all (a bare `surface:`). + */ +export type SurfaceTargetKind = + | { kind: 'title'; title: string } + | { kind: 'self' } + | { kind: 'focused' } + | { kind: 'ref'; ref: string } + | { kind: 'stable'; id: string } + | { kind: 'nothing' }; + +const POSITIONAL_SURFACE_REF = /^\d+$/; + +/** Classify a target once, for the matcher below and for the router's routing + * decision (`dor-control-router.ts`). */ +export function classifySurfaceTarget(target: string): SurfaceTargetKind { + if (target.startsWith('title:')) return { kind: 'title', title: target.slice('title:'.length) }; + if (target === 'surface:focused') return { kind: 'focused' }; + if (target === 'surface:self') return { kind: 'self' }; + if (!target.startsWith('surface:')) return { kind: 'stable', id: target }; + const rest = target.slice('surface:'.length); + if (!rest) return { kind: 'nothing' }; + return POSITIONAL_SURFACE_REF.test(rest) ? { kind: 'ref', ref: target } : { kind: 'stable', id: rest }; +} + +function matchesTarget( + classified: SurfaceTargetKind, surface: DorSurface, callerSurfaceId: string | undefined, ): boolean { - if (!target) return true; - if (target === 'surface:focused') return surface.focused; - if (target === 'surface:self') return callerSurfaceId !== undefined && surface.id === callerSurfaceId; - if (target === surface.id || target === surface.ref) return true; - if (!target.startsWith('surface:')) return false; - const stableId = target.slice('surface:'.length); - return stableId.length > 0 && stableId === surface.id; + switch (classified.kind) { + case 'focused': + return surface.focused; + case 'self': + return callerSurfaceId !== undefined && surface.id === callerSurfaceId; + case 'ref': + return classified.ref === surface.ref; + case 'stable': + return classified.id === surface.id; + case 'title': + return surface.title === classified.title; + // What a bare `surface:` names. + case 'nothing': + return false; + } } -function surfaceTitleTarget(target: string): string | null { - return target.startsWith('title:') ? target.slice('title:'.length) : null; +/** Whether one Surface answers a target; an absent target matches every one. */ +function matchesDorSurfaceTarget( + target: string | undefined, + surface: DorSurface, + callerSurfaceId: string | undefined, +): boolean { + return !target || matchesTarget(classifySurfaceTarget(target), surface, callerSurfaceId); } function renderSurfaceForError(surface: DorSurface): string { @@ -143,23 +187,20 @@ function resolveSurfaceTarget( target: string | undefined, callerSurfaceId: string | undefined, ): ParseResult<DorSurface> { - // A caller this Wall does not hold cannot be the implicit target: a - // `--workspace` command names another Workspace, and its reference defaults - // to that Workspace's own focused Surface rather than failing on a caller - // that was never in this list. - const callerListed = callerSurfaceId !== undefined && surfaces.some((surface) => surface.id === callerSurfaceId); - const resolvedTarget = target ?? (callerListed ? callerSurfaceId : 'surface:focused'); - const titleTarget = surfaceTitleTarget(resolvedTarget); - if (titleTarget !== null) { - const matches = surfaces.filter((surface) => surface.title === titleTarget); - return pickSingleMatch(matches, resolvedTarget) - ?? { ok: false, message: `surface target '${resolvedTarget}' was not found` }; - } - - const matches = surfaces.filter((surface) => matchesDorSurfaceTarget(resolvedTarget, surface, callerSurfaceId)); + // A caller this Wall does not hold never reaches here as one: the router + // drops it before dispatching (`dor-control-router.ts`), so an omitted target + // falls back to this Workspace's focused Surface. + const resolvedTarget = target ?? callerSurfaceId ?? 'surface:focused'; + const classified = classifySurfaceTarget(resolvedTarget); + const matches = surfaces.filter((surface) => matchesTarget(classified, surface, callerSurfaceId)); const single = pickSingleMatch(matches, resolvedTarget); if (single) return single; - const fallback = !target && !callerListed ? (surfaces[0] ?? null) : null; + // A title names a Surface the user can see; there is no falling back to + // another one when it names none. + if (classified.kind === 'title') { + return { ok: false, message: `surface target '${resolvedTarget}' was not found` }; + } + const fallback = !target && !callerSurfaceId ? (surfaces[0] ?? null) : null; if (fallback) return { ok: true, value: fallback }; return { ok: false, message: `surface '${resolvedTarget}' was not found` }; } @@ -190,10 +231,6 @@ async function attachSurfacePorts(surfaces: DorSurface[]): Promise<DorSurface[]> })); } -function stringParam(value: unknown): string | undefined { - return typeof value === 'string' ? value : undefined; -} - function booleanParam(value: unknown): boolean { return value === true; } diff --git a/lib/src/components/wall/workspace-control.test.ts b/lib/src/components/wall/workspace-control.test.ts index 7a1a12c4d..cf88e8993 100644 --- a/lib/src/components/wall/workspace-control.test.ts +++ b/lib/src/components/wall/workspace-control.test.ts @@ -213,8 +213,9 @@ describe('surface.list --all', () => { ['workspace:2', 'surface:2'], ]); expect(result.workspaces).toHaveLength(2); - // Each Wall is asked for its own Workspace, with the caller's scope removed. - expect(second.mock.calls[0][0].params).toMatchObject({ scope: 'workspace', includePorts: true }); + // Each Wall is asked for its own Workspace: the caller's container target + // is cleared, and a Wall has no scope of its own to read. + expect(second.mock.calls[0][0].params).toEqual({ scope: 'all', includePorts: true, workspace: undefined }); }); it('fails the whole listing when one Workspace cannot answer', async () => { diff --git a/lib/src/components/wall/workspace-control.ts b/lib/src/components/wall/workspace-control.ts index 1c167fa6f..243f7b9c8 100644 --- a/lib/src/components/wall/workspace-control.ts +++ b/lib/src/components/wall/workspace-control.ts @@ -1,6 +1,11 @@ -import { WORKSPACE_CONTROL_METHODS } from 'dor/protocol'; +import { isWorkspaceControlMethod, WORKSPACE_CONTROL_METHODS } from 'dor/protocol'; import type { DorControlResult } from 'dor/protocol'; -import type { Surface as DorSurface, ListSurfacesResponse, WorkspaceRow } from 'dor/commands/types'; +import type { + GroupedSurface, + ListSurfacesResponse, + WorkspaceMutationResponse, + WorkspaceRow, +} from 'dor/commands/types'; import { getActivitySnapshot } from '../../lib/session-activity-store'; import type { WorkspaceId } from '../../lib/session-types'; import { @@ -11,9 +16,11 @@ import { resolveWorkspaceRef, setActiveWorkspace, workspaceRefFor, + type ResolvedWorkspace, } from '../../lib/workspace-store'; import { getWorkspaceSurfacesSnapshot } from '../../lib/workspace-surfaces'; import { computeWorkspaceUnion } from '../../lib/workspace-union'; +import { errorText, stringParam } from './dor-control-shared'; import { getWallHandle, type WallHandle } from './wall-handles'; import { closeWorkspaceWithSurfaces, workspaceNeedsCloseConfirmation } from './workspace-lifecycle'; import type { DorControlParams, DorControlRequest } from './use-dor-control'; @@ -26,17 +33,13 @@ import type { DorControlParams, DorControlRequest } from './use-dor-control'; * here instead of handing it to a Wall. */ -export function isWorkspaceControlMethod(method: string): boolean { - return (Object.values(WORKSPACE_CONTROL_METHODS) as string[]).includes(method); -} - -function errorText(error: unknown): string { - return error instanceof Error ? error.message : String(error); -} - -function stringParam(value: unknown): string | undefined { - return typeof value === 'string' ? value : undefined; -} +/** What the Window reads on top of a Wall's params: the listing's reach and the + * container verbs' own arguments. */ +export type WindowControlParams = DorControlParams & { + scope?: unknown; + name?: unknown; + force?: unknown; +}; /** This Window's Workspaces in strip order, each with its union status. */ export function workspaceRows(): WorkspaceRow[] { @@ -91,16 +94,19 @@ function askWall( * out of the answer, which would read as a Workspace holding nothing. */ export async function listAllWorkspaceSurfaces(detail: DorControlRequest): Promise<void> { - const params = detail.params ?? {}; + const params: WindowControlParams = detail.params ?? {}; const rows = workspaceRows(); - const surfaces: DorSurface[] = []; - for (const row of rows) { + // Asked in parallel, assembled in strip order: a Workspace whose Wall is not + // mounted contributes nothing, which is the tick between `createWorkspace` + // and the Wall registering. + const answers = await Promise.all(rows.map(async (row) => { const handle = getWallHandle(row.id as WorkspaceId); - // A Workspace whose Wall is not mounted contributes nothing; every - // Workspace of a multi-Workspace Window keeps its Wall mounted, so this is - // the tick between `createWorkspace` and the Wall registering. - if (!handle) continue; - const answer = await askWall(handle, detail, { ...params, scope: 'workspace', workspace: undefined }); + return { row, answer: handle ? await askWall(handle, detail, { ...params, workspace: undefined }) : null }; + })); + + const surfaces: GroupedSurface[] = []; + for (const { row, answer } of answers) { + if (!answer) continue; if (!answer.ok) { detail.respond({ ok: false, error: `${row.ref}: ${answer.error ?? 'listing failed'}` }); return; @@ -108,21 +114,20 @@ export async function listAllWorkspaceSurfaces(detail: DorControlRequest): Promi const listed = answer.result as ListSurfacesResponse; for (const surface of listed.surfaces) surfaces.push({ ...surface, workspaceRef: row.ref }); } + detail.respond({ ok: true, result: { surfaces, workspaces: rows, - workspaceRef: workspaceRefFor(getWorkspacesSnapshot().activeId), + workspaceRef: (rows.find((row) => row.active) ?? rows[0]).ref, windowRef: currentWindowRef(), } satisfies ListSurfacesResponse, }); } /** The Workspace a mutating verb names, or null once the failure is answered. */ -function requireWorkspace( - detail: DorControlRequest, -): { id: WorkspaceId; ref: string; name: string } | null { +function requireWorkspace(detail: DorControlRequest): ResolvedWorkspace | null { const target = stringParam(detail.params?.workspace); if (!target) { detail.respond({ ok: false, error: 'workspace is required' }); @@ -133,93 +138,91 @@ function requireWorkspace( detail.respond({ ok: false, error: resolved.message }); return null; } - const meta = getWorkspacesSnapshot().workspaces.find((workspace) => workspace.id === resolved.id); - if (!meta) { - detail.respond({ ok: false, error: `unknown workspace target '${target}'` }); - return null; - } - return { id: meta.id, ref: workspaceRefFor(meta.id), name: meta.name }; + return resolved; } /** Answer one `workspace.*` request. Every path responds, including a throw. */ export async function handleWorkspaceControl(detail: DorControlRequest): Promise<void> { - const params = detail.params ?? {}; + const params: WindowControlParams = detail.params ?? {}; + const name = stringParam(params.name)?.trim(); - if (detail.method === WORKSPACE_CONTROL_METHODS.list) { - detail.respond({ ok: true, result: { workspaces: workspaceRows(), windowRef: currentWindowRef() } }); - return; - } + /** The one shape every mutating verb answers with. */ + const respondMutation = ( + status: WorkspaceMutationResponse['status'], + workspace: ResolvedWorkspace, + renamedTo?: string, + ) => detail.respond({ + ok: true, + result: { + status, + workspaceId: workspace.id, + workspaceRef: workspace.ref, + name: renamedTo ?? workspace.name, + } satisfies WorkspaceMutationResponse, + }); - if (detail.method === WORKSPACE_CONTROL_METHODS.new) { - const name = stringParam(params.name)?.trim(); - // Created in the background: a command that moved the user to another - // Workspace would be a bigger theft than the focus one `dor split` avoids - // (`docs/specs/dor-cli.md` → "dor workspace"). `dor workspace switch` is - // the verb that activates. - const meta = createWorkspace({ ...(name ? { name } : {}), activate: false }); - detail.respond({ - ok: true, - result: { - status: 'created', - workspaceId: meta.id, - workspaceRef: workspaceRefFor(meta.id), - name: meta.name, - }, - }); + // Narrowed before the switch, whose exhaustiveness is then what makes a new + // container verb a compile error here rather than a silent no-op. + if (!isWorkspaceControlMethod(detail.method)) { + detail.respond({ ok: false, error: `unsupported Dormouse control method '${detail.method}'` }); return; } + switch (detail.method) { + case WORKSPACE_CONTROL_METHODS.list: { + detail.respond({ ok: true, result: { workspaces: workspaceRows(), windowRef: currentWindowRef() } }); + return; + } - if (detail.method === WORKSPACE_CONTROL_METHODS.rename) { - const target = requireWorkspace(detail); - if (!target) return; - const name = stringParam(params.name)?.trim(); - if (!name) { - detail.respond({ ok: false, error: 'name is required' }); + case WORKSPACE_CONTROL_METHODS.new: { + // Created in the background: a command that moved the user to another + // Workspace would be a bigger theft than the focus one `dor split` avoids + // (`docs/specs/dor-cli.md` → "dor workspace"). `dor workspace switch` is + // the verb that activates. + const meta = createWorkspace({ ...(name ? { name } : {}), activate: false }); + respondMutation('created', { ...meta, ref: workspaceRefFor(meta.id) }); return; } - renameWorkspace(target.id, name); - detail.respond({ - ok: true, - result: { status: 'renamed', workspaceId: target.id, workspaceRef: target.ref, name }, - }); - return; - } - if (detail.method === WORKSPACE_CONTROL_METHODS.switch) { - const target = requireWorkspace(detail); - if (!target) return; - setActiveWorkspace(target.id); - detail.respond({ - ok: true, - result: { status: 'active', workspaceId: target.id, workspaceRef: target.ref, name: target.name }, - }); - return; - } + case WORKSPACE_CONTROL_METHODS.rename: { + const target = requireWorkspace(detail); + if (!target) return; + if (!name) { + detail.respond({ ok: false, error: 'name is required' }); + return; + } + renameWorkspace(target.id, name); + respondMutation('renamed', target, name); + return; + } - if (detail.method === WORKSPACE_CONTROL_METHODS.close) { - const target = requireWorkspace(detail); - if (!target) return; - // Like `dor kill`, a command close raises no prompt: it refuses instead, - // and `--force` is the caller's answer to the confirmation the strip would - // have shown (`docs/specs/dor-cli.md` → "dor workspace"). - if (params.force !== true && workspaceNeedsCloseConfirmation(target.id)) { - detail.respond({ - ok: false, - error: `workspace '${target.ref}' holds running or touched Surfaces; pass --force to close it`, - }); + case WORKSPACE_CONTROL_METHODS.switch: { + const target = requireWorkspace(detail); + if (!target) return; + setActiveWorkspace(target.id); + respondMutation('active', target); return; } - const refusal = await closeWorkspaceWithSurfaces(target.id, 'silent'); - if (refusal) { - detail.respond({ ok: false, error: `workspace '${target.ref}' was not closed: ${refusal}` }); + + case WORKSPACE_CONTROL_METHODS.close: { + const target = requireWorkspace(detail); + if (!target) return; + // Like `dor kill`, a command close raises no prompt: it refuses instead, + // and `--force` is the caller's answer to the confirmation the strip would + // have shown (`docs/specs/dor-cli.md` → "dor workspace"). + if (params.force !== true && workspaceNeedsCloseConfirmation(target.id)) { + detail.respond({ + ok: false, + error: `workspace '${target.ref}' holds running or touched Surfaces; pass --force to close it`, + }); + return; + } + const refusal = await closeWorkspaceWithSurfaces(target.id, 'silent'); + if (refusal) { + detail.respond({ ok: false, error: `workspace '${target.ref}' was not closed: ${refusal}` }); + return; + } + respondMutation('closed', target); return; } - detail.respond({ - ok: true, - result: { status: 'closed', workspaceId: target.id, workspaceRef: target.ref, name: target.name }, - }); - return; } - - detail.respond({ ok: false, error: `unsupported Dormouse control method '${detail.method}'` }); } diff --git a/lib/src/lib/workspace-store.test.ts b/lib/src/lib/workspace-store.test.ts index 93e418eb4..312c8f8e4 100644 --- a/lib/src/lib/workspace-store.test.ts +++ b/lib/src/lib/workspace-store.test.ts @@ -160,22 +160,23 @@ describe('workspace-store', () => { expect(workspaceRefFor('ws-2')).toBe('workspace:2'); // A Workspace already gone (its Wall is mid-unmount) answers the first ref. expect(workspaceRefFor('missing')).toBe('workspace:1'); - expect(resolveWorkspaceRef('workspace:2')).toEqual({ ok: true, id: 'ws-2' }); - expect(resolveWorkspaceRef('2')).toEqual({ ok: true, id: 'ws-2' }); + // A resolution carries the Workspace, so a caller needs no second lookup. + expect(resolveWorkspaceRef('workspace:2')).toEqual({ ok: true, id: 'ws-2', name: 'Workspace 2', ref: 'workspace:2' }); + expect(resolveWorkspaceRef('2')).toMatchObject({ ok: true, id: 'ws-2' }); for (const ref of ['workspace:9', 'workspace:0', 'nonsense']) { expect(resolveWorkspaceRef(ref)).toEqual({ ok: false, message: `unknown workspace target '${ref}'` }); } moveWorkspace('ws-2', 0); expect(workspaceRefFor('ws-2')).toBe('workspace:1'); - expect(resolveWorkspaceRef('workspace:1')).toEqual({ ok: true, id: 'ws-2' }); + expect(resolveWorkspaceRef('workspace:1')).toMatchObject({ ok: true, id: 'ws-2', ref: 'workspace:1' }); }); it('resolves a Workspace by name, and refuses an ambiguous one', () => { renameWorkspace(DEFAULT_WORKSPACE_ID, 'build'); createWorkspace({ id: 'ws-2', name: 'agents' }); - expect(resolveWorkspaceRef('workspace:agents')).toEqual({ ok: true, id: 'ws-2' }); - expect(resolveWorkspaceRef('agents')).toEqual({ ok: true, id: 'ws-2' }); + expect(resolveWorkspaceRef('workspace:agents')).toMatchObject({ ok: true, id: 'ws-2', name: 'agents' }); + expect(resolveWorkspaceRef('agents')).toMatchObject({ ok: true, id: 'ws-2' }); createWorkspace({ id: 'ws-3', name: 'agents' }); expect(resolveWorkspaceRef('agents')).toEqual({ @@ -185,6 +186,6 @@ describe('workspace-store', () => { // A positional ref is never read as a name, even when a Workspace is named // for a number. renameWorkspace('ws-3', '1'); - expect(resolveWorkspaceRef('1')).toEqual({ ok: true, id: DEFAULT_WORKSPACE_ID }); + expect(resolveWorkspaceRef('1')).toMatchObject({ ok: true, id: DEFAULT_WORKSPACE_ID }); }); }); diff --git a/lib/src/lib/workspace-store.ts b/lib/src/lib/workspace-store.ts index 6705c8bfd..003c0ae16 100644 --- a/lib/src/lib/workspace-store.ts +++ b/lib/src/lib/workspace-store.ts @@ -1,3 +1,4 @@ +import { parseWorkspaceRef } from 'dor/protocol'; import { DEFAULT_WORKSPACE_ID, DEFAULT_WORKSPACE_NAME, type WorkspaceId } from './session-types'; /** @@ -195,13 +196,17 @@ export function workspaceRefFor(id: WorkspaceId): string { return `workspace:${index === -1 ? 1 : index + 1}`; } +/** A Workspace a target named: its identity, plus the positional ref it had + * when it was resolved (a later reorder renumbers it). */ +export interface ResolvedWorkspace extends WorkspaceMeta { + ref: string; +} + /** What a `workspace:<n|name>` target named, or why it named nothing. */ export type WorkspaceRefResolution = - | { ok: true; id: WorkspaceId } + | ({ ok: true } & ResolvedWorkspace) | { ok: false; message: string }; -const POSITIONAL_REF = /^[1-9]\d*$/; - /** * Resolve `workspace:<n>` / `workspace:<name>` — or either bare — to a * Workspace of this Window (`docs/specs/dor-cli.md` → "Handle Model"). A @@ -210,14 +215,15 @@ const POSITIONAL_REF = /^[1-9]\d*$/; * rather than picking. */ export function resolveWorkspaceRef(ref: string): WorkspaceRefResolution { - const target = ref.trim(); - const bare = (target.startsWith('workspace:') ? target.slice('workspace:'.length) : target).trim(); - if (POSITIONAL_REF.test(bare)) { - const positional = state.workspaces[Number(bare) - 1]; - if (positional) return { ok: true, id: positional.id }; - } else if (bare) { - const matches = state.workspaces.filter((workspace) => workspace.name === bare); - if (matches.length === 1) return { ok: true, id: matches[0].id }; + const { target, position, name } = parseWorkspaceRef(ref); + const found = (meta: WorkspaceMeta): WorkspaceRefResolution => + ({ ok: true, ...meta, ref: workspaceRefFor(meta.id) }); + if (position !== null) { + const positional = state.workspaces[position - 1]; + if (positional) return found(positional); + } else if (name) { + const matches = state.workspaces.filter((workspace) => workspace.name === name); + if (matches.length === 1) return found(matches[0]); if (matches.length > 1) { const candidates = matches .map((workspace) => `${workspaceRefFor(workspace.id)} ${JSON.stringify(workspace.name)}`) diff --git a/standalone/src/window-restore.ts b/standalone/src/window-restore.ts index 34091cad3..777d662fb 100644 --- a/standalone/src/window-restore.ts +++ b/standalone/src/window-restore.ts @@ -109,11 +109,13 @@ export async function restoreWindowOrFresh(platform: PlatformAdapter): Promise<W * Seed the Window's records and install its Workspaces and writer, before any * Wall mounts. Shared with the tear-out boot, whose one Workspace arrives from * another Window rather than from disk (`standalone/src/workspace-move.ts`). + * Answers with the Workspace this Window starts on, which for a fresh one is + * the id minted here. */ export function installWindowPersistence( platform: PlatformAdapter, saved: PersistedWindow | null, -): void { +): { activeId: WorkspaceId } { // A Workspace's first save compares against its own record, and a snapshot // taken mid-boot must not replace a restored Workspace with a blank one. seedWindowSession(saved); @@ -135,13 +137,14 @@ export function installWindowPersistence( // After `setWorkspaces`, so installing does not immediately write back what was // just read. installWindowSessionWriter((snapshot) => platform.saveWindowState?.(snapshot)); + return { activeId: getWorkspacesSnapshot().activeId }; } async function restoreWindow( platform: PlatformAdapter, saved: PersistedWindow | null, ): Promise<WallBootPlans> { - installWindowPersistence(platform, saved); + const installed = installWindowPersistence(platform, saved); // Asked twice when the host says nothing at all and this Window has terminal // panes to lose: `resumeOrRestoreFrom` reads a timed-out list as "no live @@ -152,8 +155,6 @@ async function restoreWindow( const live: LivePtys = await collectLivePtys(platform, { ...(hasTerminalPanes ? { retryTimeoutMs: LIST_RETRY_MS } : {}), }); - // The fresh Window's id was minted by `installWindowPersistence` above. - const installed = getWorkspacesSnapshot(); const restoring: Array<{ id: WorkspaceId; session: PersistedSession | null }> = saved?.workspaces ?? [{ id: installed.activeId, session: null }]; const activeId = saved?.activeWorkspaceId ?? installed.activeId; diff --git a/vscode-ext/src/dor-workspace-guard.ts b/vscode-ext/src/dor-workspace-guard.ts index 01731bbea..68fe912fc 100644 --- a/vscode-ext/src/dor-workspace-guard.ts +++ b/vscode-ext/src/dor-workspace-guard.ts @@ -3,14 +3,12 @@ * Workspaces), so this host has no Window-wide Workspace model to answer with: * a Workspace-spanning `dor` request would report one webview's Workspace as if * it were all of them, and a mutation would move a strip that does not exist. - * Every such request is refused here, at the extension host, before it reaches a - * webview. + * This host serves exactly the one Workspace each webview is — positional 1 — + * and refuses everything that spans, at the extension host, before the request + * reaches a webview. */ -import { SURFACE_CONTROL_METHODS, WORKSPACE_CONTROL_METHODS } from 'dor/protocol'; - -/** The one Workspace a VS Code webview has, in both accepted spellings. */ -const THIS_WORKSPACE = new Set(['workspace:1', '1']); +import { parseWorkspaceRef, spansWorkspaces } from 'dor/protocol'; const REFUSAL = 'Dormouse in VS Code puts each Workspace in its own webview, so'; @@ -21,14 +19,12 @@ const REFUSAL = 'Dormouse in VS Code puts each Workspace in its own webview, so' */ export function dorWorkspaceRefusal(method: string, params: Record<string, unknown> | undefined): string | null { const workspace = params?.workspace; - if (workspace !== undefined && !(typeof workspace === 'string' && THIS_WORKSPACE.has(workspace.trim()))) { - return `${REFUSAL} it has no workspace '${String(workspace)}' to act on`; - } - if (method === SURFACE_CONTROL_METHODS.list && params?.scope === 'all') { - return `${REFUSAL} dor list --all would list only this one`; + if (workspace !== undefined) { + const named = typeof workspace === 'string' ? parseWorkspaceRef(workspace).position : null; + if (named !== 1) return `${REFUSAL} it has no workspace '${String(workspace)}' to act on`; } - if ((Object.values(WORKSPACE_CONTROL_METHODS) as string[]).includes(method)) { - return `${REFUSAL} dor workspace and dor list --workspaces are not available here`; + if (spansWorkspaces(method, params)) { + return `${REFUSAL} dor workspace, dor list --workspaces and dor list --all are not available here`; } return null; } From 8dc0dd5ecbaa918c16b7b65153367a29f298f427 Mon Sep 17 00:00:00 2001 From: Ned Twigg <ned.twigg@diffplug.com> Date: Thu, 10 Sep 2026 06:11:50 -0700 Subject: [PATCH 08/13] Fix the Workspace CLI gaps found in review MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ten confirmed findings on the `dor` Workspace CLI, each pinned by a test: - `dor ab --key` is Workspace-scoped for real. `sessionForKey(key, workspaceId)` namespaces a managed key under the Workspace that holds the browser, and the CLI asks the host for the name (`surface.resolveAgentBrowser` with `key`) rather than building `dormouse.1.<key>` itself. A bare Wall — VS Code, the website — keeps the unscoped names, and so does a `dor ab` with no control endpoint at all. - `dor list --all` marks only the active Workspace's selection focused; every Wall marks its own, and the Window has one focus. - A refused Workspace close reveals the Workspace only in `prompt` mode: a `dor workspace close` gets the message and leaves the user where they were. - `--all` fails the listing on a Workspace whose Wall never registers, after the routing retry, instead of dropping it; the text renderer keeps every Workspace's header so it agrees with the JSON `workspaces` array. - `workspace.close` refuses a Workspace with no registered Wall rather than dropping it with its Sessions still running. - An explicit `--workspace` whose Wall has not registered waits out the same retry and then answers "still mounting", not "unknown workspace target". - `--all --ports` scans once for the whole Window: a batched `getOpenPortsMany` through the sidecar (one process table, one socket scan), Rust, both standalone adapters, the browser-dev bridge, and an optional adapter method with a per-id fallback. - `dor workspace`'s CLI test drives one client across all four verbs and asserts the whole ordered conversation. - The VS Code guard accepts its own Workspace by name as well as by position. - notepad.md's Closure section carries the Workspace close path, prompt vs silent. Plus the cleanups: `workspaceRows()` goes through `workspaceRefFor`, `dor list --workspaces` refuses by allowlist so a new flag is refused by default, and standalone.md cites `window-restore.test.ts` by its full path. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QBf4rQbwuxV6n6v7E3twYe --- docs/specs/dor-browser.md | 24 +++- docs/specs/dor-cli.md | 36 +++-- docs/specs/glossary.md | 2 +- docs/specs/notepad.md | 4 +- docs/specs/standalone.md | 8 +- docs/specs/vscode.md | 2 +- dor-lib-common/src/agent-browser.ts | 25 +++- dor/src/commands/agent-browser.ts | 51 ++++--- dor/src/commands/list.ts | 51 +++---- dor/src/commands/types.ts | 34 +++-- dor/test/cli-output.test.mjs | 126 +++++++++++++----- dor/test/snapshots/help/agent-browser.md | 3 +- dor/test/snapshots/help/list.md | 2 +- lib/src/components/Wall.test.tsx | 23 ++++ lib/src/components/Wall.tsx | 3 + lib/src/components/WorkspaceWindow.test.tsx | 26 ++++ .../wall/dor-control-router.test.ts | 30 +++++ lib/src/components/wall/dor-control-router.ts | 32 ++--- lib/src/components/wall/dor-control-shared.ts | 45 ++++++- .../handle-workspace-shortcuts.test.ts | 15 ++- lib/src/components/wall/surface-ports.ts | 53 ++++++++ lib/src/components/wall/use-dor-control.ts | 47 +++---- .../components/wall/workspace-control.test.ts | 107 ++++++++++++--- lib/src/components/wall/workspace-control.ts | 52 ++++++-- .../wall/workspace-lifecycle.test.ts | 33 ++++- .../components/wall/workspace-lifecycle.ts | 34 +++-- lib/src/lib/platform/types.ts | 9 ++ scripts/spec-word-budgets.json | 8 +- standalone/scripts/dev-agent-browser.mjs | 1 + standalone/sidecar/main.js | 1 + standalone/sidecar/pty-core.js | 67 +++++++++- standalone/sidecar/pty-core.test.js | 44 ++++++ standalone/src-tauri/src/lib.rs | 22 +++ standalone/src/browser-sidecar-adapter.ts | 7 + standalone/src/tauri-adapter.test.ts | 27 ++++ standalone/src/tauri-adapter.ts | 9 ++ vscode-ext/src/dor-workspace-guard.ts | 18 ++- vscode-ext/test/dor-workspace-guard.test.ts | 4 +- 38 files changed, 852 insertions(+), 233 deletions(-) create mode 100644 lib/src/components/wall/surface-ports.ts diff --git a/docs/specs/dor-browser.md b/docs/specs/dor-browser.md index 1334c1745..86e959f1e 100644 --- a/docs/specs/dor-browser.md +++ b/docs/specs/dor-browser.md @@ -209,12 +209,21 @@ PATH. **Both `dor ab` and the host must spawn `agent-browser` through `.cmd`-shim recipe applies even to that absolute path (`docs/specs/dor-cli.md` → Spawning External Binaries). -Managed identity: - -- Default is `--key default`; `--key <name>` maps to `dormouse.1.<name>` and must - match `[A-Za-z0-9._-]+`. `--key`, raw `--session`, `--surface` are mutually - exclusive. -- GUI-spawned sessions use `dormouse.1.gui-<hex>`, which no `--key` names; they +### Managed identity + +- Default is `--key default`; `--key <name>` must match `[A-Za-z0-9._-]+`. + `--key`, raw `--session`, `--surface` are mutually exclusive. +- **A key is namespaced by the Workspace that holds the browser** — + `dormouse.<workspaceId>.<name>`, the Workspace's *stable* id so a strip reorder + renames nothing — and `dormouse.1.<name>` for a bare Wall, which has no + Workspace id (VS Code, the website, Pocket). The same key in two Workspaces is + therefore two browsers, which is what keeps one Surface per session (below) + once several Workspaces each run `dor ab --key default`. **Only the answering Workspace can name it**, + so `dor ab` asks the host (`surface.resolveAgentBrowser` with `key`) before it + forwards anything, and namespaces the key itself only when there is no control + endpoint at all — outside Dormouse, where `dor ab` is a pure passthrough. +- GUI-spawned sessions use `dormouse.1.gui-<hex>`, minted host-wide (the Window's + one agent-browser host, not a Workspace), which no `--key` names; they are reachable by `dor ab --surface <handle>` (`docs/specs/dor-cli.md` → Agent-Browser Surface Addressing). **The host answers only for an agent-browser-rendered Surface** — an `iframe`-rendered Surface has a browser @@ -225,7 +234,8 @@ Managed identity: or render-swapped mid-command leaves the trailing request to mint a fresh pane (rationale). -Source of truth: `dor/src/commands/agent-browser.ts`, `dor/src/commands/types.ts` +Source of truth: `sessionForKey` in `dor-lib-common/src/agent-browser.ts`, +`resolveSession` in `dor/src/commands/agent-browser.ts`, `dor/src/commands/types.ts` (`AgentBrowserSurfaceRequest`, `ResolveAgentBrowserSessionRequest`), `lib/src/components/Wall.tsx` / `lib/src/components/wall/use-dor-control.ts` (`findAgentBrowserSurface`, `surface.agentBrowser`, `surface.resolveAgentBrowser`). diff --git a/docs/specs/dor-cli.md b/docs/specs/dor-cli.md index aea95f742..a0bfaced0 100644 --- a/docs/specs/dor-cli.md +++ b/docs/specs/dor-cli.md @@ -306,7 +306,10 @@ Invariants: `surface:N` — else the Workspace owning the calling Surface, else the active one; nothing mounted leaves the request unanswered, after a bounded retry that covers the tick between a Workspace being created and its Wall - registering. **Every request is answered, including a container ref of the + registering. **A `--workspace` the store resolves but whose Wall has not + registered waits out that same retry**, then answers `workspace '<ref>' is + still mounting` — it is not the unknown-Workspace answer, the Workspace being + there. **Every request is answered, including a container ref of the wrong type and a handler that throws** — an unanswered one blocks its caller to the deadline. A Workspace being closed refuses the Surface-creating verbs (`docs/specs/layout.md` → "Workspaces"). **Surface targets resolve within the @@ -337,8 +340,11 @@ answering Workspace's own `workspace:<n>` alongside the answering Window's `window:<label>` (Handle Model). **Its `scope: 'all'` spans every Workspace of the Window**, tagging each row with the Workspace it came from and carrying the Workspace directory beside them; **one Workspace that cannot answer fails the -whole listing**, since a Workspace missing from the answer reads as a Workspace -holding nothing. Per the +whole listing** — including one whose Wall never registers, after the same +registration-gap wait routing gives (Handle Model) — since a Workspace missing +from the answer reads as a Workspace holding nothing. **Only the active +Workspace's selection is `focused`** in such a listing: each Wall marks its own, +and the Window has one focus. Per the visible-vs-listed split [Handle Model](#handle-model) states, **a visible split reference adds a pane in Lath, a minimized one a sibling Door in the baseboard.** **`dor list` rows sort by the Workspace-stable `surface:N` ref**, a @@ -346,11 +352,18 @@ registry `Wall` owns and persists with the session, independent of Lath layout order. **Port enumeration is opt-in.** With `includePorts` set (`dor list --ports` / -`--port`) the host calls `PlatformAdapter.getOpenPorts(id)` -(`docs/specs/dor-browser.md` → Dev-Server Chip) per terminal Surface in -parallel, shelling out per pane (`lsof` / `Get-NetTCPConnection`) under -`OPEN_PORT_TIMEOUT_MS`. A remote paired session reports none, and any error -degrades to an empty list rather than failing the call. +`--port`) the host scans each terminal Surface's process tree +(`docs/specs/dor-browser.md` → Dev-Server Chip), shelling out (`lsof` / +`Get-NetTCPConnection`) under `OPEN_PORT_TIMEOUT_MS`. **One listing costs one +scan where the adapter can batch it** (`PlatformAdapter.getOpenPortsMany`), and +`getOpenPorts(id)` per Surface in parallel where it cannot — so a listing +spanning Workspaces does not multiply a synchronous host scan by its row count. +**A `--all` listing never forwards `includePorts` to a Wall**, scanning once for +every Workspace's terminals instead. A remote paired session reports none, and +any error degrades to an empty list rather than failing the call. +Source of truth: `attachSurfacePorts` in +`lib/src/components/wall/surface-ports.ts`, `getOpenPortsForPids` in +`standalone/sidecar/pty-core.js`. **`dor` forwards command tails as raw argv; the host quotes them** — `dor` cannot know the configured default shell used for creation, so tails after `--` @@ -396,7 +409,7 @@ The spec keeps the behavior help cannot express: | `await` | **Must name `--until quiet\|exit`; never infer it.** Timeout 1–86400 whole seconds, default 600; `alert.md` owns wake semantics. | | `kill` | **Must select exactly one confirmation mode.** Conditional text needs four non-whitespace characters and must match `read`; browser Surfaces are killable. | | `iframe`, `agent-browser` / `ab` | `dor-browser.md` owns the renderers; see [target resolution](#browser-open-target-resolution) and [addressing](#agent-browser-surface-addressing). The passthrough is intercepted before stricli parses it. | -| `list` | Filters are ANDed client-side; `--port` filters terminals (browser Surfaces never match) and implies the opt-in detail scan, `--ports` only requests it. **Owns every Workspace read**: `--workspace` narrows to one, `--all` groups every Workspace's rows under its header (dropping a group its filters emptied), `--workspaces` is the overview, and the three cannot be combined. | +| `list` | Filters are ANDed client-side; `--port` filters terminals (browser Surfaces never match) and implies the opt-in detail scan, `--ports` only requests it. **Owns every Workspace read**: `--workspace` narrows to one, `--all` groups every Workspace's rows under its header — **every Workspace keeps its header**, including one a filter emptied, so the text listing and the JSON `workspaces` array name the same Workspaces — `--workspaces` is the overview, and the three cannot be combined. **`--workspaces` takes `--json` and nothing else**, by an allowlist, so a flag added to `list` is refused there until it is named. | | `workspace` | **Mutation only** ([dor workspace](#dor-workspace)). | | `skill` | Prints the bundled skill or installs its bootstrap stub; [Agent Skill](#agent-skill) owns the contract. | @@ -434,7 +447,7 @@ Wall (Handle Model), and each takes a `workspace:<n|name>` target except `new`: |---|---| | `new [name]` | **Creates in the background**, never activating: moving the user to another Workspace is a larger theft than the focus a bare `dor split` takes. Answers with the new ref. | | `rename <ref> <name>` | Renames the Workspace only — no Surface title (`docs/specs/layout.md` → "Workspaces"). | -| `close <ref> [--force]` | **Refuses, raising no confirmation, when the Workspace holds a touched or running Surface** unless `--force` — the caller is a command, not someone watching the Wall, exactly as `dor kill` archives silently. The last Workspace, and a Workspace whose close meets another already in flight, refuse too. Member Surfaces close through the closure coordinator (`docs/specs/notepad.md` → "Closure"). | +| `close <ref> [--force]` | **Refuses, raising no confirmation, when the Workspace holds a touched or running Surface** unless `--force` — the caller is a command, not someone watching the Wall, exactly as `dor kill` archives silently. The last Workspace, a Workspace whose close meets another already in flight, and one whose Wall never registers (`still mounting`, after the routing retry — closing past it would leave its Sessions running with nothing holding them) refuse too. Member Surfaces close through the closure coordinator, and a refusal leaves the Workspace open and the user where they were (`docs/specs/notepad.md` → "Closure"). | | `switch <ref>` | Activates it. | **Each verb ships as one action of one command**, not a route map: the published @@ -518,6 +531,9 @@ session name that becomes a filesystem path. **Resolution is host-side**, mirroring `surface.resolveOpen`: the CLI sends the handle to `surface.resolveAgentBrowser` and forwards the session it gets back. +A managed `--key` takes the same method (with `key` in place of `surface`), +because the Workspace that will hold the browser is what namespaces a key +(`docs/specs/dor-browser.md` → Managed identity). The handle resolves against **listed** Surfaces ([Handle Model](#handle-model)), and the host applies two gates in order: diff --git a/docs/specs/glossary.md b/docs/specs/glossary.md index 5abf1a8c5..af94860f9 100644 --- a/docs/specs/glossary.md +++ b/docs/specs/glossary.md @@ -261,7 +261,7 @@ Use glossary names instead. A left-column term retains meaning only where noted. | **reconnect** | Retired: live-PTY case → **resume**; cold start → **restore**. | | **restore** | Keeps its cold-start rehydrate meaning. Never for Door→Pane (**reattach**) or alert-manager seeding (**seed**). | | **attach** | Retired at the DOM layer (`attachTerminal`) → **mount**; user-level **reattach** (Door→Pane) keeps the `re-` prefix. | -| **session** | The durable identity of a **terminal Surface**. Never for the Activity projection (`ActivityState`, not `SessionUiState`), nor for the agent-browser daemon's lowercase `session` string (`dormouse.1.<key>`) — not a Dormouse durable unit. | +| **session** | The durable identity of a **terminal Surface**. Never for the Activity projection (`ActivityState`, not `SessionUiState`), nor for the agent-browser daemon's lowercase `session` string (`dormouse.<workspace>.<key>`) — not a Dormouse durable unit. | | **terminal** | Keeps its meaning for the `xterm.Terminal` instance; prose meaning "the whole thing" is **Session**. | | **surface** | Not retired. **Session** names only the terminal kind; **Surface** covers both. | | **panel / pane / leaf** | Prefer **pane** for the layout slot; **leaf** is Lath's tree node for it (1:1). "panel" survives only in React component names (`TerminalPanel`, `BrowserPanel`, `IframePanel`, `AgentBrowserPanel`). | diff --git a/docs/specs/notepad.md b/docs/specs/notepad.md index 0cd4205d6..3ec7038b4 100644 --- a/docs/specs/notepad.md +++ b/docs/specs/notepad.md @@ -130,11 +130,11 @@ On a failed archive: **An in-place replacement keeps the notepad instead of archiving it.** Renderer swaps, browser/terminal mode changes, and shell replacement each mint a new Surface id, so the notes migrate with the ref wherever `transferSurfaceRef` runs; pins into the disposed terminal are dropped on the way. -**A Workspace closure routes every member Surface through the coordinator**, one at a time, and the first refusal stops it with that Workspace intact (`docs/specs/layout.md` → Workspaces). +**A Workspace closure routes every member Surface through the coordinator**, one at a time, in the closure mode its caller gives, and the first refusal stops it with that Workspace intact (`docs/specs/layout.md` → Workspaces). A user gesture — the strip, the command-mode key — closes in `prompt` mode and a refusal **reveals** the Workspace, the archive-failure prompt being on its Wall. `dor workspace close` closes in `silent` mode: like `dor kill`, **it raises no prompt, leaves the Workspace open and un-revealed, and returns the error to the caller** (`docs/specs/dor-cli.md` → "dor workspace", the `close` row). **Each mounted Wall registers one Surface-metadata resolver**, and a Wall answers `null` for a Surface it does not own, so the first non-null answer is the owning Workspace's — a batch and the volatile mirror describe a Surface identically no matter which Workspace holds it. -Source of truth: `archiveSurfaceNotes` in `lib/src/lib/notepad/close-coordinator.ts`; `closeSurface` / `killPaneImmediately` / `closeAll` in `lib/src/components/Wall.tsx`; `NotepadArchiveFailureModal` in `lib/src/components/NotepadArchiveFailure.tsx`; `beginClosing`, `transferNotepad` and `registerNotepadSurfaceMetaResolver` in `lib/src/lib/notepad/notepad-store.ts`; `useSurfaceClosing` in `lib/src/components/use-notepad.ts`. +Source of truth: `archiveSurfaceNotes` in `lib/src/lib/notepad/close-coordinator.ts`; `closeSurface` / `killPaneImmediately` / `closeAll` in `lib/src/components/Wall.tsx`; `closeWorkspaceWithSurfaces` in `lib/src/components/wall/workspace-lifecycle.ts`; `NotepadArchiveFailureModal` in `lib/src/components/NotepadArchiveFailure.tsx`; `beginClosing`, `transferNotepad` and `registerNotepadSurfaceMetaResolver` in `lib/src/lib/notepad/notepad-store.ts`; `useSurfaceClosing` in `lib/src/components/use-notepad.ts`. ## Standalone quit diff --git a/docs/specs/standalone.md b/docs/specs/standalone.md index e5a56cca7..fd38c664f 100644 --- a/docs/specs/standalone.md +++ b/docs/specs/standalone.md @@ -632,7 +632,7 @@ into fresh shells at its saved cwds. window opened after the first one closed would write a blob naming a Workspace id already live in another window's blob, and the next launch would meet the same id twice and refuse the whole restore. A bare Wall — one Window's whole - application — keeps the default id (`window-restore.test.ts`). + application — keeps the default id (`standalone/src/window-restore.test.ts`). Source of truth: `restoreWindowOrFresh` / `routeUnownedPtys` in `standalone/src/window-restore.ts`. @@ -643,6 +643,12 @@ fans out to every Wall at once, so both adapters put their cwd probe behind in one microtask into a single invoke — the same batching `getCwdsForPids` already does one layer down, extended across the callers. +**A listing that spans terminals costs one `pty_get_open_ports_many`.** Both +adapters carry it, and the sidecar answers every id from one process-table read +and one socket scan (`getOpenPortsForPids`) — the scans are synchronous on its +only event loop, so a `dor list --ports` across Workspaces must not multiply them +by its row count (`docs/specs/dor-cli.md` → "Current Implemented Commands"). + **Nothing is deleted at boot but orphaned session temp files** (`docs/specs/transport.md` → "Retiring the transcripts already on disk"). **The harness mirrors this answer** (`docs/specs/transport.md` → Standalone browser-dev diff --git a/docs/specs/vscode.md b/docs/specs/vscode.md index a1fd56b2e..cace7fb92 100644 --- a/docs/specs/vscode.md +++ b/docs/specs/vscode.md @@ -81,7 +81,7 @@ Consequences: **One webview is one Workspace.** The bottom-panel `WebviewView` ("Dormouse") is the default Workspace; each `dormouse.open` editor-tab `WebviewPanel` is an independent Workspace. Several are visible at once, and VS Code — not Dormouse — owns their tabs, creation, and closing, so **Dormouse adds no create/rename/close affordances here**: the webview mounts a bare `<Wall>`, which leaves the Workspace strip and its shortcuts to standalone (`docs/specs/layout.md` → Workspaces). A Workspace's Surfaces are the terminal Sessions whose PTYs its router tracks (`ownedPtyIds`, `docs/specs/transport.md`) plus the browser Surfaces rendered in it. -**The extension host refuses every Workspace-spanning `dor` request** — the container verbs, `dor list --workspaces` / `--all`, and any `--workspace` but this webview's own — before it routes one to a webview, since no webview can answer for its siblings (`docs/specs/dor-cli.md` → "dor workspace"; `dorWorkspaceRefusal` in `vscode-ext/src/dor-workspace-guard.ts`). +**The extension host refuses every Workspace-spanning `dor` request** — the container verbs, `dor list --workspaces` / `--all`, and any `--workspace` but this webview's own — before it routes one to a webview, since no webview can answer for its siblings (`docs/specs/dor-cli.md` → "dor workspace"). **Its own Workspace is accepted by position *and* by name** (`DEFAULT_WORKSPACE_NAME`, what a bare Wall registers), so a ref read out of `dor list` can be handed straight back. Source of truth: `dorWorkspaceRefusal` in `vscode-ext/src/dor-workspace-guard.ts`. #### Surfacing union status on native chrome diff --git a/dor-lib-common/src/agent-browser.ts b/dor-lib-common/src/agent-browser.ts index 40ebedf15..8a7daa8ed 100644 --- a/dor-lib-common/src/agent-browser.ts +++ b/dor-lib-common/src/agent-browser.ts @@ -1,7 +1,12 @@ -// Workspace id baked into managed agent-browser session names. Hardcoded until -// Dormouse exposes real workspaces; encoded now to avoid a later rename. Private: -// callers build session names through sessionForKey, never by hand. -const WORKSPACE_ID = '1'; +// The scope a Window with one implicit Workspace answers with: a bare Wall (VS +// Code, the website, Pocket) has no Workspace id of its own, so its keys keep +// the names they have always had. Private: callers build session names through +// sessionForKey, never by hand. +const BARE_WALL_SCOPE = '1'; + +// A session name becomes a filesystem path (the daemon's socket dir), so the +// scope is held to the same charset `dor ab --key` is. +const UNSAFE_SCOPE_CHARS = /[^A-Za-z0-9._-]/g; /** Env var that overrides which agent-browser binary to run; shared so `dor ab` * and the host key off the same name. */ @@ -17,13 +22,19 @@ export function streamStatusArgs(session: string): string[] { } /** - * Managed, workspace-scoped agent-browser session name: `dormouse.<workspaceId>.<key>`. + * Managed, workspace-scoped agent-browser session name: + * `dormouse.<workspaceId>.<key>`, and `dormouse.1.<key>` for a Window whose one + * Wall has no Workspace id (`workspaceId` omitted). The scope is what keeps one + * `--key default` per Workspace from being one shared browser + * (`docs/specs/dor-browser.md` → Managed identity). + * * agent-browser session names become filesystem paths (the socket dir), so `/` * can't separate the namespace — the daemon fails to start; dots keep it * readable. Shared by `dor ab` (--key resolution) and the lib host (GUI sessions). */ -export function sessionForKey(key: string): string { - return `dormouse.${WORKSPACE_ID}.${key}`; +export function sessionForKey(key: string, workspaceId?: string): string { + const scope = workspaceId ? workspaceId.replace(UNSAFE_SCOPE_CHARS, '-') : BARE_WALL_SCOPE; + return `dormouse.${scope}.${key}`; } /** diff --git a/dor/src/commands/agent-browser.ts b/dor/src/commands/agent-browser.ts index 47ec7c815..af010d81c 100644 --- a/dor/src/commands/agent-browser.ts +++ b/dor/src/commands/agent-browser.ts @@ -84,7 +84,8 @@ export const agentBrowserCommand: Command = { dor intercepts exactly three mutually exclusive identity flags: --key <name> Managed, workspace-scoped browser identity (default "default"). - Maps to agent-browser session dormouse.1.<name>. + Maps to agent-browser session dormouse.<workspace>.<name>, + so the same key in another Workspace is another browser. --session <name> Attach to a raw agent-browser session by its literal name. --surface <handle> Drive the browser Surface a handle names (surface:N, surface:focused, a stable id, title:<title>). dor asks the @@ -149,14 +150,14 @@ const IDENTITY_FLAGS = ['--key', '--session', '--surface'] as const; const INTERCEPTED_FLAGS = [...IDENTITY_FLAGS, '--workspace'] as const; type InterceptedFlag = (typeof INTERCEPTED_FLAGS)[number]; -/** Either a session known CLI-side (from `--session`, or namespaced from - * `--key`) or a Surface handle for the host to resolve — never neither, never - * both. A union rather than two optionals so the arm that has no session is - * the arm that has a surface, by construction. `key` rides along only when it - * named the session: a raw or surface-addressed session may be GUI-minted, - * which no key names. */ +/** How the browser was named: a raw session known CLI-side, a managed `--key` + * the host namespaces under the target Workspace, or a Surface handle the host + * resolves — exactly one of the three, by construction. `key` rides along only + * when it named the session: a raw or surface-addressed session may be + * GUI-minted, which no key names. */ type ResolvedSessionFlags = { rest: string[]; workspace?: string } & ( - | { session: string; key?: string; surface?: undefined } + | { session: string; key?: undefined; surface?: undefined } + | { key: string; session?: undefined; surface?: undefined } | { surface: string; session?: undefined; key?: undefined } ); @@ -208,8 +209,9 @@ export function extractSessionFlags(args: string[]): ParseResult<ResolvedSession const session = values.get('--session'); if (session !== undefined) return { ok: true, value: { session, rest, ...workspace } }; - const resolvedKey = key ?? 'default'; - return { ok: true, value: { key: resolvedKey, session: sessionForKey(resolvedKey), rest, ...workspace } }; + // The key's session name belongs to the Workspace that will hold the browser, + // so it is resolved host-side rather than built here (`resolveSession`). + return { ok: true, value: { key: key ?? 'default', rest, ...workspace } }; } export async function runAgentBrowserCli(args: string[], options: CliOptions): Promise<CliResult> { @@ -295,13 +297,20 @@ export async function runAgentBrowserCli(args: string[], options: CliOptions): P } /** - * The agent-browser session to forward: the one the flags already produced, or — - * for `--surface <handle>` — the one the host says that Surface is bound to - * (`surface.resolveAgentBrowser`). A handle needs a live control endpoint, and - * the host owns the gating: the target must have a browser, and that browser - * must be agent-browser-rendered with a session (an `iframe` renderer has no - * session to drive). Its messages are printed verbatim; dor does not - * re-interpret them. + * The agent-browser session to forward — one `surface.resolveAgentBrowser` round + * trip for the two forms only the host can name: + * + * - `--session <name>` is already the session; nothing is asked. + * - `--key <name>` is namespaced under the Workspace that will hold the browser, + * which only that Workspace knows (`docs/specs/dor-browser.md` → "Managed identity"). + * **Outside Dormouse the CLI namespaces it itself**, so `dor ab` stays a + * passthrough with no control endpoint. + * - `--surface <handle>` is the session the host says that Surface is bound to. + * The host owns the gating: the target must have a browser, and that browser + * must be agent-browser-rendered with a session (an `iframe` renderer has no + * session to drive). + * + * The host's messages are printed verbatim; dor does not re-interpret them. */ async function resolveSession( flags: ResolvedSessionFlags, @@ -309,10 +318,14 @@ async function resolveSession( ): Promise<ParseResult<string>> { if (flags.session !== undefined) return { ok: true, value: flags.session }; const client = requireControlClient(options); - if (client instanceof Error) return { ok: false, message: client.message }; + if (client instanceof Error) { + return flags.key === undefined + ? { ok: false, message: client.message } + : { ok: true, value: sessionForKey(flags.key) }; + } try { const { session } = await client.resolveAgentBrowserSession({ - surface: flags.surface, + ...(flags.key === undefined ? { surface: flags.surface } : { key: flags.key }), ...workspaceParam(flags.workspace), }); return { ok: true, value: session }; diff --git a/dor/src/commands/list.ts b/dor/src/commands/list.ts index 70c6d0e0c..a00cfc6eb 100644 --- a/dor/src/commands/list.ts +++ b/dor/src/commands/list.ts @@ -67,7 +67,7 @@ JSON output (--json) always includes both stable ids and refs, and each row carr --workspace <ref> lists another Workspace of this Window instead: workspace:<n> (positional) or workspace:<name>, which resolves only when exactly one Workspace carries that name. Both are accepted bare ("2", "build"). ---all lists every Workspace of this Window, grouped under a Workspace header. Rows keep their own Workspace-scoped surface:N refs, so several groups have a surface:1 and several may carry the focus marker; each JSON row adds workspace_ref, and the payload adds a workspaces array. Target a row from another Workspace by its stable id, or pass --workspace. +--all lists every Workspace of this Window, grouped under a Workspace header — every Workspace keeps its header, including one holding nothing and one the filters emptied. Rows keep their own Workspace-scoped surface:N refs, so several groups have a surface:1, but only the active Workspace's selection carries the focus marker; each JSON row adds workspace_ref, and the payload adds a workspaces array. Target a row from another Workspace by its stable id, or pass --workspace. --workspaces prints the Workspace overview instead of any Surface: one row per Workspace with the active marker, its name, [ringing]/[todo] when any member Surface is, and [attention N] for the number owing it. It takes no other flag but --json. @@ -204,19 +204,14 @@ async function runListCommand( } } -/** Every flag the Workspace overview does not take, spelled as the user typed - * it and listed in help order. `--json` and `--workspaces` are the two it does. */ -const SURFACE_ONLY_FLAGS: ReadonlyArray<[keyof ListFlags, string]> = [ - ['all', '--all'], - ['command', '--command'], - ['cwd', '--cwd'], - ['idFormat', '--id-format'], - ['kind', '--kind'], - ['port', '--port'], - ['ports', '--ports'], - ['view', '--view'], - ['workspace', '--workspace'], -]; +/** The only flags the Workspace overview takes — an allowlist, so a flag added + * to this command is refused there until it is named here. */ +const WORKSPACES_FLAGS: ReadonlySet<keyof ListFlags> = new Set(['json', 'workspaces']); + +/** A flag as the user typed it, from the name stricli parsed it into. */ +function flagSpelling(name: string): string { + return `--${name.replace(/[A-Z]/g, (upper) => `-${upper.toLowerCase()}`)}`; +} /** The three container flags name one scope between them, and the overview is a * different listing rather than a filter on this one. */ @@ -225,9 +220,10 @@ function checkScopeFlags(flags: ListFlags): { ok: true } | { ok: false; message: return { ok: false, message: '--all and --workspace are mutually exclusive' }; } if (flags.workspaces === true) { - const others = SURFACE_ONLY_FLAGS - .filter(([name]) => flags[name] !== undefined) - .map(([, spelling]) => spelling); + const others = (Object.keys(flags) as Array<keyof ListFlags>) + .filter((name) => flags[name] !== undefined && !WORKSPACES_FLAGS.has(name)) + .map((name) => flagSpelling(name)) + .sort(); if (others.length > 0) { return { ok: false, message: `dor list --workspaces takes only --json, not ${others.join(', ')}` }; } @@ -277,18 +273,15 @@ function renderListText( if (!response.workspaces) return rows.length === 0 ? '' : `${rows.join('\n')}\n`; // Every row of a `--all` answer carries the Workspace it came from - // (`GroupedSurface`), so a group is its own rows in response order. - const groups = response.workspaces - .map((workspace) => ({ - header: `${workspace.ref} ${workspace.name}${workspace.active ? ' [active]' : ''}`, - lines: response.surfaces.flatMap((surface, index) => ( - surface.workspaceRef === workspace.ref ? [` ${rows[index]}`] : [] - )), - })) - // A Workspace every filter emptied prints no header: the group is not there - // to be listed. - .filter((group) => group.lines.length > 0) - .map((group) => [group.header, ...group.lines].join('\n')); + // (`GroupedSurface`), so a group is its own rows in response order. **Every + // Workspace keeps its header**, even one no row survived: the listing says + // which Workspaces there are, and the JSON payload lists them all either way. + const groups = response.workspaces.map((workspace) => [ + `${workspace.ref} ${workspace.name}${workspace.active ? ' [active]' : ''}`, + ...response.surfaces.flatMap((surface, index) => ( + surface.workspaceRef === workspace.ref ? [` ${rows[index]}`] : [] + )), + ].join('\n')); return groups.length === 0 ? '' : `${groups.join('\n\n')}\n`; } diff --git a/dor/src/commands/types.ts b/dor/src/commands/types.ts index 94c579174..33fcd2a75 100644 --- a/dor/src/commands/types.ts +++ b/dor/src/commands/types.ts @@ -329,18 +329,32 @@ export interface ResolveOpenTargetResponse { port: number; } -export interface ResolveAgentBrowserSessionRequest extends WorkspaceScopedRequest { - /** A Surface handle (surface:N, surface:<stable-id>, surface:self, - * surface:focused, title:<title>) naming the browser Surface to drive. */ - surface: string; -} +/** The two ways `dor ab` asks the host to name a session: a Surface handle whose + * bound session it wants, or a managed `--key`, whose session name is the + * answering Workspace's (`docs/specs/dor-browser.md` → Managed identity). Never + * both — a key names no Surface, and a Surface's session was minted long ago. */ +export type ResolveAgentBrowserSessionRequest = WorkspaceScopedRequest & ( + | { + /** A Surface handle (surface:N, surface:<stable-id>, surface:self, + * surface:focused, title:<title>) naming the browser Surface to drive. */ + surface: string; + key?: undefined; + } + | { + /** A managed browser key (`dor ab --key`), which only the answering + * Workspace can namespace. */ + key: string; + surface?: undefined; + } +); export interface ResolveAgentBrowserSessionResponse { - surfaceId: string; - surfaceRef: string; - /** The agent-browser session bound to that Surface — what `dor ab --surface` - * forwards as `--session`. Includes GUI-minted sessions, which no `--key` - * can name. */ + /** The Surface the handle named; absent when the request named a `key`, which + * is answered whether or not a Surface holds that session yet. */ + surfaceId?: string; + surfaceRef?: string; + /** The agent-browser session — what `dor ab` forwards as `--session`. Includes + * GUI-minted sessions, which no `--key` can name. */ session: string; } diff --git a/dor/test/cli-output.test.mjs b/dor/test/cli-output.test.mjs index f316be673..8fc7cbfa2 100644 --- a/dor/test/cli-output.test.mjs +++ b/dor/test/cli-output.test.mjs @@ -109,6 +109,22 @@ const fixturePortsByRef = { 'surface:4': [{ family: 'IPv6', address: '::1', port: 8080, pid: 5151, processName: 'python' }], }; +/** The Workspace a `workspace:<n|name>` target names in the fixture, the way the + * host resolves one — positional ref, bare position, or exact name. */ +function fixtureWorkspace(target) { + const bare = String(target).replace(/^workspace:/, ''); + return fixtureWorkspaces.find((row) => ( + row.ref === target || row.ref === `workspace:${bare}` || row.name === bare + )) ?? fixtureWorkspaces[0]; +} + +/** The `surface.agentBrowser` request a `dor ab` run made, if it opened one at + * all: every run now asks the host to name its session first, so the surface + * call is never the first entry. */ +function surfaceRequest(client) { + return client.requests.find((entry) => entry.method === 'agentBrowserSurface')?.request; +} + function fixtureClient(surfacesFixture = fixtureSurfaces) { return { requests: [], @@ -240,6 +256,14 @@ function fixtureClient(surfacesFixture = fixtureSurfaces) { // the CLI has one catch and prints whatever comes back. async resolveAgentBrowserSession(request) { this.requests.push({ method: 'resolveAgentBrowserSession', request }); + // A managed key names no Surface: the answering Workspace namespaces it, + // so the same key in `build` is another browser entirely. + if (request.key !== undefined) { + const workspace = fixtureWorkspaces.find((row) => ( + request.workspace === row.name || request.workspace === row.ref + )); + return { session: `dormouse.${workspace ? workspace.id : '1'}.${request.key}` }; + } if (request.surface === 'surface:1') { throw new Error("surface 'surface:1' has no browser (kind: terminal)"); } @@ -264,7 +288,10 @@ function fixtureClient(surfacesFixture = fixtureSurfaces) { }, async renameWorkspace(request) { this.requests.push({ method: 'renameWorkspace', request }); - return { status: 'renamed', workspaceId: 'workspace-2b1c', workspaceRef: 'workspace:2', name: request.name }; + // Mirror the host: the answer names the Workspace the target resolved to, + // so a test can tell a forwarded target from an ignored one. + const target = fixtureWorkspace(request.workspace); + return { status: 'renamed', workspaceId: target.id, workspaceRef: target.ref, name: request.name }; }, async closeWorkspace(request) { this.requests.push({ method: 'closeWorkspace', request }); @@ -276,7 +303,8 @@ function fixtureClient(surfacesFixture = fixtureSurfaces) { }, async switchWorkspace(request) { this.requests.push({ method: 'switchWorkspace', request }); - return { status: 'active', workspaceId: 'workspace-2b1c', workspaceRef: 'workspace:2', name: 'build' }; + const target = fixtureWorkspace(request.workspace); + return { status: 'active', workspaceId: target.id, workspaceRef: target.ref, name: target.name }; }, async resolveOpenTarget(request) { this.requests.push({ method: 'resolveOpenTarget', request }); @@ -1034,10 +1062,32 @@ test('agent-browser resolves --key to a namespaced session and opens a surface', ['agent-browser', '--session', 'dormouse.1.storybook', 'open', 'http://localhost:6006'], ['agent-browser', '--session', 'dormouse.1.storybook', 'stream', 'status', '--json'], ]); - assert.deepEqual(client.requests, [{ - method: 'agentBrowserSurface', - request: { key: 'storybook', session: 'dormouse.1.storybook', wsPort: 61141 }, - }]); + assert.deepEqual(client.requests, [ + // The host names the session, because only the Workspace that will hold the + // browser can namespace a key. + { method: 'resolveAgentBrowserSession', request: { key: 'storybook' } }, + { method: 'agentBrowserSurface', request: { key: 'storybook', session: 'dormouse.1.storybook', wsPort: 61141 } }, + ]); +}); + +test('agent-browser --key in another Workspace drives that Workspace own session', async () => { + const ab = fakeAgentBrowser(); + const client = fixtureClient(); + await runCli(['ab', '--workspace', 'build', '--key', 'default', 'open', 'http://localhost:6006'], { client, execAgentBrowser: ab.exec }); + // The same key in another Workspace is another browser: the session name is + // namespaced by the Workspace's stable id, so nothing here can reach the + // first Workspace's `dormouse.1.default`. + assert.deepEqual(ab.calls, [ + ['agent-browser', '--session', 'dormouse.workspace-2b1c.default', 'open', 'http://localhost:6006'], + ['agent-browser', '--session', 'dormouse.workspace-2b1c.default', 'stream', 'status', '--json'], + ]); + assert.deepEqual(client.requests, [ + { method: 'resolveAgentBrowserSession', request: { key: 'default', workspace: 'build' } }, + { + method: 'agentBrowserSurface', + request: { key: 'default', session: 'dormouse.workspace-2b1c.default', wsPort: 61141, workspace: 'build' }, + }, + ]); }); test('agent-browser defaults to --key default', async () => { @@ -1045,7 +1095,7 @@ test('agent-browser defaults to --key default', async () => { const client = fixtureClient(); await runCli(['agent-browser', 'open', 'http://localhost:5173'], { client, execAgentBrowser: ab.exec }); assert.equal(ab.calls[0][2], 'dormouse.1.default'); - assert.deepEqual(client.requests[0].request, { key: 'default', session: 'dormouse.1.default', wsPort: 61141 }); + assert.deepEqual(client.requests[1].request, { key: 'default', session: 'dormouse.1.default', wsPort: 61141 }); }); test('agent-browser raw --session skips key namespacing', async () => { @@ -1053,7 +1103,11 @@ test('agent-browser raw --session skips key namespacing', async () => { const client = fixtureClient(); await runCli(['ab', '--session', 'mine', 'snapshot'], { client, execAgentBrowser: ab.exec }); assert.equal(ab.calls[0][2], 'mine'); - assert.deepEqual(client.requests[0].request, { key: undefined, session: 'mine', wsPort: 61141 }); + // A raw session is already the session: nothing is asked of the host but the + // surface it binds to. + assert.deepEqual(client.requests, [ + { method: 'agentBrowserSurface', request: { key: undefined, session: 'mine', wsPort: 61141 } }, + ]); }); test('agent-browser --workspace names the Workspace and never reaches the binary', async () => { @@ -1063,11 +1117,16 @@ test('agent-browser --workspace names the Workspace and never reaches the binary // Intercepted like the identity flags: the browser opens in `build`, the // handle resolves there, and agent-browser sees neither the flag nor its value. assert.deepEqual(ab.calls, [ - ['agent-browser', '--session', 'dormouse.1.default', 'open', 'http://localhost:5173/'], - ['agent-browser', '--session', 'dormouse.1.default', 'stream', 'status', '--json'], + ['agent-browser', '--session', 'dormouse.workspace-2b1c.default', 'open', 'http://localhost:5173/'], + ['agent-browser', '--session', 'dormouse.workspace-2b1c.default', 'stream', 'status', '--json'], + ]); + // Every host round trip the run makes names it: the session namespace, the + // handle resolution, and the surface the browser lands in. + assert.deepEqual(client.requests.map((entry) => [entry.method, entry.request.workspace]), [ + ['resolveAgentBrowserSession', 'build'], + ['resolveOpenTarget', 'build'], + ['agentBrowserSurface', 'build'], ]); - assert.equal(client.requests[0].request.workspace, 'build'); - assert.equal(client.requests[1].request.workspace, 'build'); }); test('agent-browser open resolves a surface handle to a URL before forwarding', async () => { @@ -1079,7 +1138,7 @@ test('agent-browser open resolves a surface handle to a URL before forwarding', ['agent-browser', '--session', 'dormouse.1.default', 'open', 'http://localhost:5173/'], ['agent-browser', '--session', 'dormouse.1.default', 'stream', 'status', '--json'], ]); - assert.deepEqual(client.requests[0], { method: 'resolveOpenTarget', request: { surface: 'surface:1' } }); + assert.deepEqual(client.requests[1], { method: 'resolveOpenTarget', request: { surface: 'surface:1' } }); }); test('agent-browser open sugars a bare :port without a host round trip', async () => { @@ -1145,7 +1204,7 @@ test('agent-browser close skips surface management', async () => { const client = fixtureClient(); await runCli(['ab', 'close'], { client, execAgentBrowser: ab.exec }); assert.deepEqual(ab.calls, [['agent-browser', '--session', 'dormouse.1.default', 'close']]); - assert.deepEqual(client.requests, []); + assert.equal(surfaceRequest(client), undefined); }); test('agent-browser without a control endpoint stays a pure passthrough', async () => { @@ -1162,7 +1221,7 @@ test('agent-browser forwards child exit code and skips surface on failure', asyn const result = await runCli(['ab', 'open', 'nope'], { client, execAgentBrowser: ab.exec }); assert.equal(result.exitCode, 1); assert.equal(result.stderr, '✗ boom\n'); - assert.deepEqual(client.requests, []); + assert.equal(surfaceRequest(client), undefined); }); test('agent-browser --surface drives the session the host says the surface is bound to', async () => { @@ -1277,7 +1336,7 @@ test('agent-browser respects DORMOUSE_AGENT_BROWSER_BIN and forwards it as binar env: { DORMOUSE_AGENT_BROWSER_BIN: '/opt/custom/agent-browser' }, }); assert.equal(ab.calls[0][0], '/opt/custom/agent-browser'); - assert.equal(client.requests[0].request.binaryPath, '/opt/custom/agent-browser'); + assert.equal(surfaceRequest(client).binaryPath, '/opt/custom/agent-browser'); }); test('agent-browser resolves the binary on PATH to an absolute binaryPath', async () => { @@ -1297,7 +1356,7 @@ test('agent-browser resolves the binary on PATH to an absolute binaryPath', asyn // resolveBinaryPath splits on the same, so a POSIX-only `:` would hide dir. env: { PATH: ['/nonexistent', dir].join(delimiter) }, }); - assert.equal(client.requests[0].request.binaryPath, binPath); + assert.equal(surfaceRequest(client).binaryPath, binPath); }); }); @@ -1351,10 +1410,12 @@ test('list --all json tags each row with its Workspace and carries the directory await snapshot('list-all-json', result); }); -test('list --all applies row filters and drops the Workspaces they empty', async () => { +test('list --all keeps the header of a Workspace its filters emptied', async () => { const result = await runCli(['list', '--all', '--kind', 'browser'], { client: fixtureClient(), env: listEnv }); - assert.equal(result.stdout.includes('workspace:1'), false); - assert.match(result.stdout, /^workspace:2 {2}build\n/); + // The text listing says which Workspaces there are, exactly as the JSON + // payload's `workspaces` array does — a filtered-out group is a header with + // no rows under it, not a Workspace that vanished. + assert.match(result.stdout, /^workspace:1 {2}Workspace 1 {2}\[active\]\n\nworkspace:2 {2}build\n {4}/); }); test('list --workspace asks the host for another Workspace', async () => { @@ -1386,21 +1447,20 @@ test('list container flags that name two scopes are refused', async () => { }); test('workspace mutation verbs', async () => { + // One client across all four runs, so the assertion below is the whole + // conversation in order: each verb calls exactly its own control method, with + // the target the user typed. const client = fixtureClient(); await snapshot('workspace-new', await runCli(['workspace', 'new', 'build'], { client, env: listEnv })); - await snapshot( - 'workspace-new-json', - await runCli(['workspace', 'new', '--json'], { client: fixtureClient(), env: listEnv }), - ); - await snapshot( - 'workspace-rename', - await runCli(['workspace', 'rename', 'workspace:2', 'agents'], { client: fixtureClient(), env: listEnv }), - ); - await snapshot( - 'workspace-switch', - await runCli(['workspace', 'switch', 'build'], { client: fixtureClient(), env: listEnv }), - ); - assert.deepEqual(client.requests, [{ method: 'newWorkspace', request: { name: 'build' } }]); + await snapshot('workspace-new-json', await runCli(['workspace', 'new', '--json'], { client, env: listEnv })); + await snapshot('workspace-rename', await runCli(['workspace', 'rename', 'workspace:2', 'agents'], { client, env: listEnv })); + await snapshot('workspace-switch', await runCli(['workspace', 'switch', 'build'], { client, env: listEnv })); + assert.deepEqual(client.requests, [ + { method: 'newWorkspace', request: { name: 'build' } }, + { method: 'newWorkspace', request: {} }, + { method: 'renameWorkspace', request: { workspace: 'workspace:2', name: 'agents' } }, + { method: 'switchWorkspace', request: { workspace: 'build' } }, + ]); }); test('workspace close refuses running work until forced', async () => { diff --git a/dor/test/snapshots/help/agent-browser.md b/dor/test/snapshots/help/agent-browser.md index 2fc41fc5c..45fd3ce19 100644 --- a/dor/test/snapshots/help/agent-browser.md +++ b/dor/test/snapshots/help/agent-browser.md @@ -11,7 +11,8 @@ Forwards all arguments verbatim to your own agent-browser binary and binds the s dor intercepts exactly three mutually exclusive identity flags: --key <name> Managed, workspace-scoped browser identity (default "default"). - Maps to agent-browser session dormouse.1.<name>. + Maps to agent-browser session dormouse.<workspace>.<name>, + so the same key in another Workspace is another browser. --session <name> Attach to a raw agent-browser session by its literal name. --surface <handle> Drive the browser Surface a handle names (surface:N, surface:focused, a stable id, title:<title>). dor asks the diff --git a/dor/test/snapshots/help/list.md b/dor/test/snapshots/help/list.md index d351a4841..7c32bdcd2 100644 --- a/dor/test/snapshots/help/list.md +++ b/dor/test/snapshots/help/list.md @@ -22,7 +22,7 @@ JSON output (--json) always includes both stable ids and refs, and each row carr --workspace <ref> lists another Workspace of this Window instead: workspace:<n> (positional) or workspace:<name>, which resolves only when exactly one Workspace carries that name. Both are accepted bare ("2", "build"). ---all lists every Workspace of this Window, grouped under a Workspace header. Rows keep their own Workspace-scoped surface:N refs, so several groups have a surface:1 and several may carry the focus marker; each JSON row adds workspace_ref, and the payload adds a workspaces array. Target a row from another Workspace by its stable id, or pass --workspace. +--all lists every Workspace of this Window, grouped under a Workspace header — every Workspace keeps its header, including one holding nothing and one the filters emptied. Rows keep their own Workspace-scoped surface:N refs, so several groups have a surface:1, but only the active Workspace's selection carries the focus marker; each JSON row adds workspace_ref, and the payload adds a workspaces array. Target a row from another Workspace by its stable id, or pass --workspace. --workspaces prints the Workspace overview instead of any Surface: one row per Workspace with the active marker, its name, [ringing]/[todo] when any member Surface is, and [attention N] for the number owing it. It takes no other flag but --json. diff --git a/lib/src/components/Wall.test.tsx b/lib/src/components/Wall.test.tsx index 9b07c8dee..a9f495502 100644 --- a/lib/src/components/Wall.test.tsx +++ b/lib/src/components/Wall.test.tsx @@ -909,6 +909,13 @@ describe('Wall on the Lath engine', () => { ok: false, error: `surface '${iframeRef}' is not agent-browser rendered (render_mode: iframe)`, }); + + // A managed `--key` names no Surface, and a bare Wall — VS Code, the + // website — keeps the unscoped session names it always had. + expect(await dispatchResolveAgentBrowserKey('storybook')).toEqual({ + ok: true, + result: { session: sessionForKey('storybook') }, + }); } finally { untouchedSpy.mockRestore(); } @@ -1512,6 +1519,22 @@ describe('Wall on the Lath engine', () => { } /** `dor ab --surface <handle>`'s host half; returns the raw control response. */ + /** `dor ab --key <name>` asking this Wall what that key's session is called. */ + async function dispatchResolveAgentBrowserKey(key: string): Promise<unknown> { + let response: unknown; + await act(async () => { + window.dispatchEvent(new CustomEvent('dormouse:control-request', { + detail: { + method: SURFACE_CONTROL_METHODS.resolveAgentBrowser, + params: { key }, + respond: (r: unknown) => { response = r; }, + }, + })); + }); + await flush(); + return response; + } + async function dispatchResolveAgentBrowser(surface: string): Promise<unknown> { let response: unknown; await act(async () => { diff --git a/lib/src/components/Wall.tsx b/lib/src/components/Wall.tsx index 301a5acdc..52d4c522b 100644 --- a/lib/src/components/Wall.tsx +++ b/lib/src/components/Wall.tsx @@ -1533,6 +1533,9 @@ export function Wall({ isClosingWorkspace: useCallback(() => closingWorkspaceRef.current, []), lastAgentBrowserBinaryPathRef, workspaceRef: useCallback(() => workspaceRefFor(effectiveWorkspaceId), [effectiveWorkspaceId]), + // The raw prop, not `effectiveWorkspaceId`: a bare Wall keeps the unscoped + // agent-browser session names (docs/specs/dor-browser.md → Managed identity). + workspaceScope: useCallback(() => workspaceId, [workspaceId]), }); // --- Workspace handle --- diff --git a/lib/src/components/WorkspaceWindow.test.tsx b/lib/src/components/WorkspaceWindow.test.tsx index 3ca99e697..7dcab3c91 100644 --- a/lib/src/components/WorkspaceWindow.test.tsx +++ b/lib/src/components/WorkspaceWindow.test.tsx @@ -303,6 +303,32 @@ describe('WorkspaceWindow', () => { expect(leafIdsIn('ws-2')).toEqual([]); }); + it('names each Workspace its own agent-browser session for the same --key', async () => { + const first = getWorkspacesSnapshot().workspaces[0].id; + await render(); + await act(async () => { createWorkspace({ id: 'ws-2', name: 'build' }); }); + await flush(); + + /** `dor ab --key default` asking whichever Workspace will hold the browser + * what that key's session is called. */ + const sessionFor = (workspaceId: string): string => { + const respond = vi.fn(); + getWallHandle(workspaceId)!.handleDorControl({ + requestId: 'r1', + method: SURFACE_CONTROL_METHODS.resolveAgentBrowser, + params: { key: 'default' }, + respond, + }); + expect(respond).toHaveBeenCalledWith({ ok: true, result: { session: expect.any(String) } }); + return respond.mock.calls[0][0].result.session; + }; + + // One `--key default` per Workspace, not one shared browser: the session + // name carries the Workspace's stable id. + expect(sessionFor(first)).toBe(`dormouse.${first}.default`); + expect(sessionFor('ws-2')).toBe('dormouse.ws-2.default'); + }); + it('keeps a hidden Workspace out of the window keyboard: its kill confirm outlives an Escape next door', async () => { vi.spyOn(terminalRegistry, 'isUntouched').mockReturnValue(false); const first = getWorkspacesSnapshot().workspaces[0].id; diff --git a/lib/src/components/wall/dor-control-router.test.ts b/lib/src/components/wall/dor-control-router.test.ts index 6007decf2..188e3d37c 100644 --- a/lib/src/components/wall/dor-control-router.test.ts +++ b/lib/src/components/wall/dor-control-router.test.ts @@ -228,6 +228,36 @@ describe('dor control routing', () => { } }); + it('waits out the same gap for an explicit --workspace, then says it is still mounting', async () => { + vi.useFakeTimers(); + try { + handleFor(getWorkspacesSnapshot().workspaces[0].id, ['pane-a']); + const release = installDorControlRouter(); + + // `dor workspace new build && dor split --workspace build`: the Workspace + // is in the store, its Wall is one effect away. + createWorkspace({ id: 'ws-2', name: 'build', activate: false }); + const detail = request({ surfaceId: 'pane-a', params: { workspace: 'build' } }); + window.dispatchEvent(new CustomEvent('dormouse:control-request', { detail })); + expect(detail.respond).not.toHaveBeenCalled(); + const target = handleFor('ws-2'); + await vi.advanceTimersByTimeAsync(0); + expect(target.handleDorControl).toHaveBeenCalledTimes(1); + expect(detail.respond).not.toHaveBeenCalled(); + + // One that never registers is answered — not left as "no such Workspace", + // which it is not, and not left unanswered, which blocks the caller. + createWorkspace({ id: 'ws-3', name: 'agents', activate: false }); + const never = request({ params: { workspace: 'agents' } }); + window.dispatchEvent(new CustomEvent('dormouse:control-request', { detail: never })); + await vi.advanceTimersByTimeAsync(10); + expect(never.respond).toHaveBeenCalledWith({ ok: false, error: "workspace 'agents' is still mounting" }); + release(); + } finally { + vi.useRealTimers(); + } + }); + it('gives up after a bounded number of retries when nothing ever mounts', async () => { vi.useFakeTimers(); try { diff --git a/lib/src/components/wall/dor-control-router.ts b/lib/src/components/wall/dor-control-router.ts index d5dccfa1b..068ae611b 100644 --- a/lib/src/components/wall/dor-control-router.ts +++ b/lib/src/components/wall/dor-control-router.ts @@ -1,7 +1,7 @@ import { isWorkspaceControlMethod, SURFACE_CONTROL_METHODS } from 'dor/protocol'; import { createRefCount } from '../../lib/ref-count'; import { getActiveWorkspaceId, isWindowRef, resolveWorkspaceRef } from '../../lib/workspace-store'; -import { errorText } from './dor-control-shared'; +import { errorText, mountingRefusal, ROUTE_RETRIES } from './dor-control-shared'; import { getWallHandle, wallHandleOwning, type WallHandle } from './wall-handles'; import { handleWorkspaceControl, listAllWorkspaceSurfaces, type WindowControlParams } from './workspace-control'; import { classifySurfaceTarget, type DorControlRequest } from './use-dor-control'; @@ -21,6 +21,9 @@ export type DorControlRoute = * (`container: true`), or the `--all` listing that spans them. */ | { kind: 'window'; container: boolean } | { kind: 'error'; message: string } + /** The Workspace exists but its Wall has not registered yet: retried like + * `none`, and answered with `message` if it never does. */ + | { kind: 'pending'; message: string } /** Nothing is mounted that could answer; the request is left to time out. */ | { kind: 'none' }; @@ -56,8 +59,9 @@ export function resolveDorControlRoute(detail: DorControlRequest): DorControlRou return { kind: 'window', container: false }; } if (params.workspace !== undefined) { - // A ref of the wrong type, one outside the strip, and one whose Wall is not - // mounted are the same answer: this Window has no such Workspace to route to. + // A ref of the wrong type and one outside the strip name no Workspace of + // this Window. One whose Wall has simply not registered yet is a different + // answer — the Workspace is there — so it waits like `none` instead. const resolved = typeof params.workspace === 'string' ? resolveWorkspaceRef(params.workspace) : { ok: false as const, message: `unknown workspace target '${String(params.workspace)}'` }; @@ -65,7 +69,7 @@ export function resolveDorControlRoute(detail: DorControlRequest): DorControlRou const handle = getWallHandle(resolved.id); return handle ? { kind: 'handle', handle } - : { kind: 'error', message: `unknown workspace target '${String(params.workspace)}'` }; + : { kind: 'pending', message: mountingRefusal(String(params.workspace).trim()) }; } // A stable id names one Surface in the whole Window, so a command targeting // one is answered by whichever Workspace holds it, caller or not. @@ -81,24 +85,20 @@ export function resolveDorControlRoute(detail: DorControlRequest): DorControlRou return active ? { kind: 'handle', handle: active } : { kind: 'none' }; } -/** - * How many macrotasks a request waits for a Wall to appear. A Wall registers its - * handle in a passive effect, so a request landing between `createWorkspace()` - * and that effect — `dor workspace new && dor split`, the strip's `+` under a - * scripted caller — finds nothing to route to. Retrying is what answers it - * instead of dropping it; the bound keeps a Window with no Walls at all (a - * Storybook strip) from retrying forever. - */ -const ROUTE_RETRIES = 5; - function dispatchDorControl(detail: DorControlRequest, attempt: number): void { const route = resolveDorControlRoute(detail); if (route.kind === 'error') { detail.respond({ ok: false, error: route.message }); return; } - if (route.kind === 'none') { - if (attempt < ROUTE_RETRIES) setTimeout(() => dispatchDorControl(detail, attempt + 1), 0); + if (route.kind === 'none' || route.kind === 'pending') { + if (attempt < ROUTE_RETRIES) { + setTimeout(() => dispatchDorControl(detail, attempt + 1), 0); + return; + } + // A Workspace whose Wall never registered says so; a Window with nothing + // mounted at all has nobody to answer for, and is left to time out. + if (route.kind === 'pending') detail.respond({ ok: false, error: route.message }); return; } // Every failure the handler can raise is answered: an unanswered request diff --git a/lib/src/components/wall/dor-control-shared.ts b/lib/src/components/wall/dor-control-shared.ts index c9edace78..51a966baf 100644 --- a/lib/src/components/wall/dor-control-shared.ts +++ b/lib/src/components/wall/dor-control-shared.ts @@ -1,11 +1,15 @@ /** - * The two conversions every side of the `dor` control plane makes: an - * unvalidated wire param read as a string, and any thrown failure read as the - * text a response carries. Shared by the Wall's handler, the Window-level - * router, and the `workspace.*` handlers, so a request answers the same way - * whichever of them answers it (`docs/specs/dor-cli.md` → "Handle Model"). + * What every side of the `dor` control plane shares: an unvalidated wire param + * read as a string, any thrown failure read as the text a response carries, and + * the registration-gap retry a Wall that has not registered yet is given. + * Used by the Wall's handler, the Window-level router, and the `workspace.*` + * handlers, so a request answers the same way whichever of them answers it + * (`docs/specs/dor-cli.md` → "Handle Model"). */ +import { getWallHandle, type WallHandle } from './wall-handles'; +import type { WorkspaceId } from '../../lib/session-types'; + /** A param as it crossed the control socket: whatever is not a string is absent. */ export function stringParam(value: unknown): string | undefined { return typeof value === 'string' ? value : undefined; @@ -15,3 +19,34 @@ export function stringParam(value: unknown): string | undefined { export function errorText(error: unknown): string { return error instanceof Error ? error.message : String(error); } + +/** + * How many macrotasks a request waits for a Wall to appear. A Wall registers its + * handle in a passive effect, so a request landing between `createWorkspace()` + * and that effect — `dor workspace new && dor split`, the strip's `+` under a + * scripted caller — finds nothing to route to. Retrying is what answers it + * instead of dropping it; the bound keeps a Window with no Walls at all (a + * Storybook strip) from retrying forever. + */ +export const ROUTE_RETRIES = 5; + +/** What a Workspace whose Wall is still registering answers with, rather than + * being treated as a Workspace this Window does not have. */ +export function mountingRefusal(ref: string): string { + return `workspace '${ref}' is still mounting`; +} + +/** + * This Workspace's Wall, waiting out the registration gap the router waits out + * (`ROUTE_RETRIES` macrotasks), or null once it is clear nothing will register. + * The Window's own handlers use it wherever a missing Wall is an error rather + * than a route to retry. + */ +export async function awaitWallHandle(id: WorkspaceId): Promise<WallHandle | null> { + for (let attempt = 0; attempt < ROUTE_RETRIES; attempt += 1) { + const handle = getWallHandle(id); + if (handle) return handle; + await new Promise<void>((resolve) => { setTimeout(resolve, 0); }); + } + return getWallHandle(id); +} diff --git a/lib/src/components/wall/keyboard/handle-workspace-shortcuts.test.ts b/lib/src/components/wall/keyboard/handle-workspace-shortcuts.test.ts index ca113f73e..40bfbcacc 100644 --- a/lib/src/components/wall/keyboard/handle-workspace-shortcuts.test.ts +++ b/lib/src/components/wall/keyboard/handle-workspace-shortcuts.test.ts @@ -3,7 +3,7 @@ */ import { beforeEach, describe, expect, it } from 'vitest'; import { handleWorkspaceShortcuts } from './handle-workspace-shortcuts'; -import { resetWallHandles } from '../wall-handles'; +import { registerWallHandle, resetWallHandles, stubWallHandle } from '../wall-handles'; import { getWorkspaceUiSnapshot, resetWorkspaceUi } from '../../../lib/workspace-ui-store'; import { createWorkspace, @@ -66,16 +66,21 @@ describe('handleWorkspaceShortcuts', () => { expect(getActiveWorkspaceId()).toBe(first); }); - it('opens the strip rename editor and close flow on the ACTIVE Workspace', () => { + it('opens the strip rename editor and close flow on the ACTIVE Workspace', async () => { + const [first] = ids(); createWorkspace({ id: 'ws-2' }); + registerWallHandle(stubWallHandle(first)); + registerWallHandle(stubWallHandle('ws-2')); handleWorkspaceShortcuts(keydown('$'), ctx); expect(getWorkspaceUiSnapshot().renamingId).toBe('ws-2'); - // No Wall is mounted, so nothing is touched and the close goes straight - // through — but the last Workspace still cannot be closed. + // Nothing in the Wall is touched, so the close goes straight through — but + // the last Workspace still cannot be closed. handleWorkspaceShortcuts(keydown('&'), ctx); - expect(ids()).toEqual([getWorkspacesSnapshot().workspaces[0].id]); + await Promise.resolve(); + expect(ids()).toEqual([first]); handleWorkspaceShortcuts(keydown('&'), ctx); + await Promise.resolve(); expect(ids()).toHaveLength(1); }); diff --git a/lib/src/components/wall/surface-ports.ts b/lib/src/components/wall/surface-ports.ts new file mode 100644 index 000000000..f64d24d70 --- /dev/null +++ b/lib/src/components/wall/surface-ports.ts @@ -0,0 +1,53 @@ +/** + * The opt-in port scan behind `dor list --ports` / `--port` + * (`docs/specs/dor-cli.md` → "Current Implemented Commands"), shared by the Wall + * answering for its own Workspace and the Window answering `--all` across them. + */ + +import { hasTerminal, type Surface, type SurfacePort } from 'dor/commands/types'; +import { getPlatform } from '../../lib/platform'; +import type { OpenPort } from '../../lib/platform/types'; + +function toSurfacePort(port: OpenPort): SurfacePort { + return { + family: port.family, + address: port.address, + port: port.port, + pid: port.pid, + ...(port.processName ? { processName: port.processName } : {}), + }; +} + +/** + * Enumerate every terminal Surface's listening ports, in **one host call where + * the adapter can batch it** (`getOpenPortsMany`) and one call per Surface in + * parallel where it cannot. The scan shells out per process table, so a listing + * spanning Workspaces must not pay for it once per row. A failure degrades to no + * ports for the Surfaces it covered, never a rejected listing. + */ +export async function attachSurfacePorts<T extends Surface>(surfaces: T[]): Promise<T[]> { + const platform = getPlatform(); + const terminals = surfaces.filter((surface) => hasTerminal(surface.kind)); + if (terminals.length === 0) return surfaces; + + const batched = platform.getOpenPortsMany; + if (batched) { + let ports: Record<string, OpenPort[]> = {}; + try { + ports = await batched.call(platform, terminals.map((surface) => surface.id)); + } catch { ports = {}; } + return surfaces.map((surface) => (hasTerminal(surface.kind) + ? { ...surface, ports: (ports[surface.id] ?? []).map(toSurfacePort) } + : surface)); + } + + return Promise.all(surfaces.map(async (surface) => { + if (!hasTerminal(surface.kind)) return surface; + try { + const ports = await platform.getOpenPorts(surface.id); + return { ...surface, ports: ports.map(toSurfacePort) }; + } catch { + return { ...surface, ports: [] }; + } + })); +} diff --git a/lib/src/components/wall/use-dor-control.ts b/lib/src/components/wall/use-dor-control.ts index b0af45e15..2b0647f04 100644 --- a/lib/src/components/wall/use-dor-control.ts +++ b/lib/src/components/wall/use-dor-control.ts @@ -1,6 +1,8 @@ import { useCallback, type MutableRefObject } from 'react'; +import { sessionForKey } from 'dor-lib-common/agent-browser'; import { getPlatform, PLATFORM_STRING } from '../../lib/platform'; import { currentWindowRef } from '../../lib/workspace-store'; +import type { WorkspaceId } from '../../lib/session-types'; import type { DorControlRequestPayload, DorControlResult } from 'dor/protocol'; import { SURFACE_CONTROL_METHODS } from 'dor/protocol'; import type { @@ -8,7 +10,6 @@ import type { SplitDirection as DorSplitDirection, ResolvedSplitDirection as DorResolvedSplitDirection, ParseResult, - SurfacePort as DorSurfacePort, } from 'dor/commands/types'; import { hasBrowser, hasTerminal } from 'dor/commands/types'; import { MAX_AWAIT_TIMEOUT_MS } from '../../lib/alert-manager'; @@ -23,6 +24,7 @@ import { import { surfaceRunsCommand, type TerminalPaneState } from '../../lib/terminal-state'; import { isAllowedAgentBrowserBinary } from '../../lib/agent-browser-binary'; import { stringParam } from './dor-control-shared'; +import { attachSurfacePorts } from './surface-ports'; import { browserSurfaceUrl, hostPathDisplay } from './browser-url'; import { agentBrowserSessionFromParams } from './browser-surface'; import { listenerUrlsByPort } from './port-url'; @@ -205,32 +207,6 @@ function resolveSurfaceTarget( return { ok: false, message: `surface '${resolvedTarget}' was not found` }; } -function toSurfacePort(port: OpenPort): DorSurfacePort { - return { - family: port.family, - address: port.address, - port: port.port, - pid: port.pid, - ...(port.processName ? { processName: port.processName } : {}), - }; -} - -/** Enumerate each terminal Surface's listening ports for `dor list --ports`. - * The adapter shells out per pane (and returns `[]` on remote / on error), so - * the fetches run in parallel and failures degrade to no ports, never a reject. */ -async function attachSurfacePorts(surfaces: DorSurface[]): Promise<DorSurface[]> { - const platform = getPlatform(); - return Promise.all(surfaces.map(async (surface) => { - if (!hasTerminal(surface.kind)) return surface; - try { - const ports = await platform.getOpenPorts(surface.id); - return { ...surface, ports: ports.map(toSurfacePort) }; - } catch { - return { ...surface, ports: [] }; - } - })); -} - function booleanParam(value: unknown): boolean { return value === true; } @@ -425,6 +401,7 @@ export function useDorControl({ closeSurface, lastAgentBrowserBinaryPathRef, workspaceRef, + workspaceScope, }: { /** The Lath engine — visible-pane projection (`lath.listPanes()`), aspect-ratio * split resolution (`autoEdgeFor`), and per-leaf param writes. */ @@ -470,6 +447,10 @@ export function useDorControl({ * The Window's own ref rides beside it, so `dor list` says which Window * answered too (`currentWindowRef`). */ workspaceRef: () => string; + /** This Wall's Workspace id, which namespaces the managed `dor ab --key` + * sessions it answers for; `undefined` on a bare Wall, whose keys keep the + * unscoped names (docs/specs/dor-browser.md → Managed identity). */ + workspaceScope: () => WorkspaceId | undefined; }): { /** The live surface (visible pane or minimized door) whose params match, or * null. Shared with the context's port launches in Wall.tsx. */ @@ -1110,6 +1091,16 @@ export function useDorControl({ } if (detail.method === SURFACE_CONTROL_METHODS.resolveAgentBrowser) { + // A managed `--key` names no Surface: it names this Workspace's browser of + // that name, so the answer is the key namespaced under the Workspace that + // will hold it (docs/specs/dor-browser.md → Managed identity). Answered + // whether or not a Surface holds that session yet — `surface.agentBrowser` + // is what creates or reuses one. + const keyParam = stringParam(params.key); + if (keyParam) { + detail.respond({ ok: true, result: { session: sessionForKey(keyParam, workspaceScope()) } }); + return; + } // Resolve a browser Surface handle to the agent-browser session bound to // it, for `dor ab --surface <handle> <verb...>`. Past the browser gate, // web verbs stay renderMode-gated: an `iframe` renderer is a browser @@ -1141,7 +1132,7 @@ export function useDorControl({ } detail.respond({ ok: false, error: `unsupported Dormouse control method '${detail.method}'` }); - }, [buildDorSurfaces, buildDorSurfaceList, closeSurface, createContentSurface, createSplitSurface, ensureAgentBrowserSurface, findSurfaceIdRunningCommand, isClosingWorkspace, requireBrowserSurface, requireListedSurface, requireTerminalSurface, resolveListedSurface, resolveVisibleSurface, surfaceRefForId, lath, nav, workspaceRef]); + }, [buildDorSurfaces, buildDorSurfaceList, closeSurface, createContentSurface, createSplitSurface, ensureAgentBrowserSurface, findSurfaceIdRunningCommand, isClosingWorkspace, requireBrowserSurface, requireListedSurface, requireTerminalSurface, resolveListedSurface, resolveVisibleSurface, surfaceRefForId, lath, nav, workspaceRef, workspaceScope]); return { findSurfaceByParams, updateSurfaceParams, handleDorControl }; } diff --git a/lib/src/components/wall/workspace-control.test.ts b/lib/src/components/wall/workspace-control.test.ts index cf88e8993..d4b22d3f9 100644 --- a/lib/src/components/wall/workspace-control.test.ts +++ b/lib/src/components/wall/workspace-control.test.ts @@ -14,6 +14,8 @@ import { import { clearTerminalActivity, setTerminalActivity } from '../../lib/session-activity-store'; import { resetWorkspaceSurfaces, setWorkspaceSurfaces } from '../../lib/workspace-surfaces'; import { resetWindowSessionAggregator } from '../../lib/window-session-aggregator'; +import { setPlatform } from '../../lib/platform'; +import type { OpenPort, PlatformAdapter } from '../../lib/platform/types'; const disposers: Array<() => void> = []; @@ -153,6 +155,17 @@ describe('workspace.close', () => { expect(getWorkspacesSnapshot().workspaces).toHaveLength(1); }); + it('refuses a Workspace whose Wall never registered, rather than orphaning its Sessions', async () => { + createWorkspace({ id: 'ws-2', name: 'build', activate: false }); + handleFor(getWorkspacesSnapshot().workspaces[0].id); + // The Wall is what walks the member Surfaces; without one, dropping the + // Workspace would leave its PTYs running with nothing holding them. + const detail = request('workspace.close', { workspace: 'workspace:2', force: true }); + await handleWorkspaceControl(detail); + expect(answer(detail)).toBe("workspace 'workspace:2' is still mounting"); + expect(getWorkspacesSnapshot().workspaces).toHaveLength(2); + }); + it('refuses the last Workspace', async () => { const only = getWorkspacesSnapshot().workspaces[0].id; handleFor(only); @@ -185,25 +198,31 @@ describe('workspace.close', () => { }); }); +/** A Wall that answers `surface.list` with these Surfaces. */ +function listing(surfaces: Array<Record<string, unknown>>) { + return vi.fn((detail: DorControlRequest) => { + detail.respond({ + ok: true, + result: { surfaces, workspaceRef: 'workspace:x', windowRef: 'window:1' }, + }); + }); +} + +/** Terminal rows as a Wall reports them, `focused` on the first. Stable ids are + * unique Window-wide, so each Wall's rows carry its own prefix. */ +function terminalRows(prefix: string, refs: string[]): Array<Record<string, unknown>> { + return refs.map((ref, index) => ({ ref, id: `${prefix}-${ref}`, kind: 'terminal', focused: index === 0 })); +} + describe('surface.list --all', () => { it('tags every row with its Workspace and carries the directory', async () => { const first = getWorkspacesSnapshot().workspaces[0].id; createWorkspace({ id: 'ws-2', name: 'build', activate: false }); - const listing = (refs: string[]) => vi.fn((detail: DorControlRequest) => { - detail.respond({ - ok: true, - result: { - surfaces: refs.map((ref) => ({ ref, id: `${ref}-id` })), - workspaceRef: 'workspace:x', - windowRef: 'window:1', - }, - }); - }); - handleFor(first, { handleDorControl: listing(['surface:1']) }); - const second = listing(['surface:1', 'surface:2']); + handleFor(first, { handleDorControl: listing(terminalRows('a', ['surface:1'])) }); + const second = listing(terminalRows('b', ['surface:1', 'surface:2'])); handleFor('ws-2', { handleDorControl: second }); - const detail = request('surface.list', { scope: 'all', includePorts: true }); + const detail = request('surface.list', { scope: 'all' }); await listAllWorkspaceSurfaces(detail); const result = answer(detail) as { surfaces: Array<{ ref: string; workspaceRef: string }>; workspaces: unknown[] }; @@ -215,14 +234,68 @@ describe('surface.list --all', () => { expect(result.workspaces).toHaveLength(2); // Each Wall is asked for its own Workspace: the caller's container target // is cleared, and a Wall has no scope of its own to read. - expect(second.mock.calls[0][0].params).toEqual({ scope: 'all', includePorts: true, workspace: undefined }); + expect(second.mock.calls[0][0].params).toEqual({ scope: 'all', includePorts: false, workspace: undefined }); + }); + + it('marks only the active Workspace selection focused', async () => { + const first = getWorkspacesSnapshot().workspaces[0].id; + // Every Wall marks its own selection; the Window has one focus, and it is + // in the Workspace the user is looking at. + createWorkspace({ id: 'ws-2', name: 'build', activate: true }); + handleFor(first, { handleDorControl: listing(terminalRows('a', ['surface:1', 'surface:2'])) }); + handleFor('ws-2', { handleDorControl: listing(terminalRows('b', ['surface:1'])) }); + + const detail = request('surface.list', { scope: 'all' }); + await listAllWorkspaceSurfaces(detail); + + const result = answer(detail) as { surfaces: Array<{ ref: string; workspaceRef: string; focused: boolean }> }; + expect(result.surfaces.map((surface) => [surface.workspaceRef, surface.ref, surface.focused])).toEqual([ + ['workspace:1', 'surface:1', false], + ['workspace:1', 'surface:2', false], + ['workspace:2', 'surface:1', true], + ]); + }); + + it('scans every Workspace terminal in one batched call, never per Wall', async () => { + const first = getWorkspacesSnapshot().workspaces[0].id; + createWorkspace({ id: 'ws-2', name: 'build', activate: false }); + const port = (value: number): OpenPort => ({ family: 'IPv4', address: '127.0.0.1', port: value, pid: 1 }); + const getOpenPortsMany = vi.fn(async (ids: string[]) => Object.fromEntries( + ids.map((id, index) => [id, [port(5000 + index)]]), + )); + const getOpenPorts = vi.fn(async () => []); + setPlatform({ getOpenPorts, getOpenPortsMany } as unknown as PlatformAdapter); + const firstWall = listing(terminalRows('a', ['surface:1'])); + handleFor(first, { handleDorControl: firstWall }); + handleFor('ws-2', { handleDorControl: listing(terminalRows('b', ['surface:1', 'surface:2'])) }); + + const detail = request('surface.list', { scope: 'all', includePorts: true }); + await listAllWorkspaceSurfaces(detail); + + // One scan for the whole Window, not one per Workspace and not one per row. + expect(getOpenPortsMany).toHaveBeenCalledTimes(1); + expect(getOpenPortsMany).toHaveBeenCalledWith(['a-surface:1', 'b-surface:1', 'b-surface:2']); + expect(getOpenPorts).not.toHaveBeenCalled(); + // The Walls are asked for rows only: a forwarded `includePorts` would be N + // scans again. + expect(firstWall.mock.calls[0][0].params).toMatchObject({ includePorts: false }); + const result = answer(detail) as { surfaces: Array<{ ports: Array<{ port: number }> }> }; + expect(result.surfaces.map((surface) => surface.ports[0].port)).toEqual([5000, 5001, 5002]); + }); + + it('fails the listing when a Workspace Wall never registers', async () => { + createWorkspace({ id: 'ws-2', name: 'build', activate: false }); + handleFor(getWorkspacesSnapshot().workspaces[0].id, { handleDorControl: listing([]) }); + // No Wall for `ws-2`: a Workspace missing from the answer would read as a + // Workspace holding nothing, so the whole listing fails instead. + const detail = request('surface.list', { scope: 'all' }); + await listAllWorkspaceSurfaces(detail); + expect(answer(detail)).toBe("workspace 'workspace:2' is still mounting"); }); it('fails the whole listing when one Workspace cannot answer', async () => { createWorkspace({ id: 'ws-2', name: 'build', activate: false }); - handleFor(getWorkspacesSnapshot().workspaces[0].id, { - handleDorControl: (detail) => detail.respond({ ok: true, result: { surfaces: [], workspaceRef: 'workspace:1', windowRef: 'window:1' } }), - }); + handleFor(getWorkspacesSnapshot().workspaces[0].id, { handleDorControl: listing([]) }); handleFor('ws-2', { handleDorControl: () => { throw new Error('boom'); } }); const detail = request('surface.list', { scope: 'all' }); diff --git a/lib/src/components/wall/workspace-control.ts b/lib/src/components/wall/workspace-control.ts index 243f7b9c8..e23f20bbe 100644 --- a/lib/src/components/wall/workspace-control.ts +++ b/lib/src/components/wall/workspace-control.ts @@ -20,8 +20,9 @@ import { } from '../../lib/workspace-store'; import { getWorkspaceSurfacesSnapshot } from '../../lib/workspace-surfaces'; import { computeWorkspaceUnion } from '../../lib/workspace-union'; -import { errorText, stringParam } from './dor-control-shared'; -import { getWallHandle, type WallHandle } from './wall-handles'; +import { awaitWallHandle, errorText, mountingRefusal, stringParam } from './dor-control-shared'; +import { attachSurfacePorts } from './surface-ports'; +import type { WallHandle } from './wall-handles'; import { closeWorkspaceWithSurfaces, workspaceNeedsCloseConfirmation } from './workspace-lifecycle'; import type { DorControlParams, DorControlRequest } from './use-dor-control'; @@ -46,10 +47,10 @@ export function workspaceRows(): WorkspaceRow[] { const { workspaces, activeId } = getWorkspacesSnapshot(); const membership = getWorkspaceSurfacesSnapshot(); const activity = getActivitySnapshot(); - return workspaces.map((workspace, index) => { + return workspaces.map((workspace) => { const union = computeWorkspaceUnion(membership.get(workspace.id) ?? [], activity); return { - ref: `workspace:${index + 1}`, + ref: workspaceRefFor(workspace.id), id: workspace.id, name: workspace.name, active: workspace.id === activeId, @@ -90,35 +91,50 @@ function askWall( /** * `dor list --all`: every Workspace's Surfaces in one answer, each row tagged * with the Workspace it came from and the directory of Workspaces beside them. - * **A Workspace that fails to list fails the whole call** rather than dropping - * out of the answer, which would read as a Workspace holding nothing. + * **A Workspace that cannot answer fails the whole call** — a Wall that never + * registers included, after the router's own registration-gap wait — rather than + * dropping out of the answer, which would read as a Workspace holding nothing. */ export async function listAllWorkspaceSurfaces(detail: DorControlRequest): Promise<void> { const params: WindowControlParams = detail.params ?? {}; const rows = workspaceRows(); - // Asked in parallel, assembled in strip order: a Workspace whose Wall is not - // mounted contributes nothing, which is the tick between `createWorkspace` - // and the Wall registering. + const includePorts = params.includePorts === true; + // Asked in parallel, assembled in strip order. The port scan is **not** + // forwarded: a Wall would scan its own terminals, so N Workspaces would cost N + // process scans; this listing runs one for all of them below. const answers = await Promise.all(rows.map(async (row) => { - const handle = getWallHandle(row.id as WorkspaceId); - return { row, answer: handle ? await askWall(handle, detail, { ...params, workspace: undefined }) : null }; + const handle = await awaitWallHandle(row.id as WorkspaceId); + return { + row, + answer: handle + ? await askWall(handle, detail, { ...params, workspace: undefined, includePorts: false }) + : null, + }; })); const surfaces: GroupedSurface[] = []; for (const { row, answer } of answers) { - if (!answer) continue; + if (!answer) { + detail.respond({ ok: false, error: mountingRefusal(row.ref) }); + return; + } if (!answer.ok) { detail.respond({ ok: false, error: `${row.ref}: ${answer.error ?? 'listing failed'}` }); return; } const listed = answer.result as ListSurfacesResponse; - for (const surface of listed.surfaces) surfaces.push({ ...surface, workspaceRef: row.ref }); + for (const surface of listed.surfaces) { + // Each Wall marks its own selection focused, but the Window has one focus: + // a row of an inactive Workspace is not it (`docs/specs/dor-cli.md` → + // "Current Implemented Commands"). + surfaces.push({ ...surface, workspaceRef: row.ref, focused: surface.focused && row.active }); + } } detail.respond({ ok: true, result: { - surfaces, + surfaces: includePorts ? await attachSurfacePorts(surfaces) : surfaces, workspaces: rows, workspaceRef: (rows.find((row) => row.active) ?? rows[0]).ref, windowRef: currentWindowRef(), @@ -206,6 +222,14 @@ export async function handleWorkspaceControl(detail: DorControlRequest): Promise case WORKSPACE_CONTROL_METHODS.close: { const target = requireWorkspace(detail); if (!target) return; + // A close is answered only once the Workspace's Wall is there to answer + // for it: the Wall is what knows the member Surfaces, so closing past a + // missing one would drop the Workspace with its Sessions still running + // (`docs/specs/glossary.md` → "Invariants" I4). + if (!await awaitWallHandle(target.id)) { + detail.respond({ ok: false, error: mountingRefusal(target.ref) }); + return; + } // Like `dor kill`, a command close raises no prompt: it refuses instead, // and `--force` is the caller's answer to the confirmation the strip would // have shown (`docs/specs/dor-cli.md` → "dor workspace"). diff --git a/lib/src/components/wall/workspace-lifecycle.test.ts b/lib/src/components/wall/workspace-lifecycle.test.ts index 3f12d63d0..62af1eeb3 100644 --- a/lib/src/components/wall/workspace-lifecycle.test.ts +++ b/lib/src/components/wall/workspace-lifecycle.test.ts @@ -4,12 +4,18 @@ * `WorkspaceWindow.test.tsx`; this pins what the verb refuses. */ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import { closeWorkspaceWithSurfaces, LAST_WORKSPACE_REFUSAL, requestWorkspaceClose } from './workspace-lifecycle'; +import { + closeWorkspaceWithSurfaces, + LAST_WORKSPACE_REFUSAL, + NO_WALL_REFUSAL, + requestWorkspaceClose, +} from './workspace-lifecycle'; import { registerWallHandle, resetWallHandles, stubWallHandle, type WallHandle } from './wall-handles'; import { resetWorkspaceUi, getWorkspaceUiSnapshot } from '../../lib/workspace-ui-store'; import { closeWorkspace, createWorkspace, + getActiveWorkspaceId, getWorkspacesSnapshot, resetWorkspaces, } from '../../lib/workspace-store'; @@ -80,6 +86,31 @@ describe('closeWorkspaceWithSurfaces', () => { expect(ids()).toEqual(['ws-2']); }); + it('reveals a refused Workspace only when a prompt is what refused it', async () => { + const [first] = ids(); + createWorkspace({ id: 'ws-2', activate: false }); + handleFor('ws-2', { closeAll: async () => 'notepad archive failed' }); + + // `dor workspace close`: the caller is a command, so the refusal comes back + // as a message and the user stays where they were. + expect(await closeWorkspaceWithSurfaces('ws-2', 'silent')).toBe('notepad archive failed'); + expect(getActiveWorkspaceId()).toBe(first); + + // A user gesture: the archive-failure prompt is on the refused Workspace's + // Wall, so that Workspace is revealed. + expect(await closeWorkspaceWithSurfaces('ws-2', 'prompt')).toBe('notepad archive failed'); + expect(getActiveWorkspaceId()).toBe('ws-2'); + }); + + it('refuses a Workspace with no registered Wall instead of closing past its Sessions', async () => { + const [first] = ids(); + createWorkspace({ id: 'ws-2', activate: false }); + // No `handleFor('ws-2')`: nothing would walk its member Surfaces, so + // removing the Workspace would leave them running and unreachable. + expect(await closeWorkspaceWithSurfaces('ws-2', 'silent')).toBe(NO_WALL_REFUSAL); + expect(ids()).toEqual([first, 'ws-2']); + }); + it('releases the lock after a refusal, so the next close still works', async () => { const [first] = ids(); createWorkspace({ id: 'ws-2' }); diff --git a/lib/src/components/wall/workspace-lifecycle.ts b/lib/src/components/wall/workspace-lifecycle.ts index 42a7267a0..a699a6361 100644 --- a/lib/src/components/wall/workspace-lifecycle.ts +++ b/lib/src/components/wall/workspace-lifecycle.ts @@ -20,10 +20,11 @@ export function workspaceNeedsCloseConfirmation(id: WorkspaceId): boolean { return !!handle && (handle.hasTouchedSurfaces() || handle.runningCount() > 0); } -/** The two ways a close is turned down before it starts. Both leave every +/** The three ways a close is turned down before it starts. All leave every * Surface where it was. */ export const LAST_WORKSPACE_REFUSAL = 'the last Workspace cannot be closed'; const CLOSE_IN_FLIGHT_REFUSAL = 'another Workspace is closing'; +export const NO_WALL_REFUSAL = 'the workspace is still mounting'; /** One close at a time, for the whole Window. Two closes overlapping would each * see the other's Workspace in the count, empty both Walls, and leave the @@ -33,12 +34,20 @@ let closeInFlight = false; /** * Close every member Surface through the closure coordinator, then drop the * Workspace itself. Resolves the first refusal's message with the Workspace left - * as it was — revealed, so the prompt behind the refusal is on screen — or null - * once it is gone. Membership is cleared by the Wall's own unmount. + * as it was, or null once it is gone. Membership is cleared by the Wall's own + * unmount. * * `mode` is the closure mode each member Surface is closed with: `prompt` for a * user gesture, `silent` for `dor workspace close`, whose caller is a command * rather than someone looking at the Wall (`docs/specs/notepad.md` → "Closure"). + * **A refusal reveals the Workspace only in `prompt` mode** — there is a prompt + * behind it to show; a silent caller gets the message and the user is left where + * they were. + * + * **A Workspace whose Wall is not registered is refused**, never closed: the + * Wall is what walks the member Surfaces, so dropping the Workspace without one + * would leave its Sessions running with nothing holding them + * (`docs/specs/glossary.md` → "Invariants" I4). */ export async function closeWorkspaceWithSurfaces( id: WorkspaceId, @@ -50,20 +59,21 @@ export async function closeWorkspaceWithSurfaces( // `closeWorkspace` the store then refuses would leave the Window's one // Workspace with nothing in it. if (getWorkspacesSnapshot().workspaces.length <= 1) return LAST_WORKSPACE_REFUSAL; - closeInFlight = true; const handle = getWallHandle(id); + if (!handle) return NO_WALL_REFUSAL; + closeInFlight = true; + /** Put the user in front of the prompt a refusal left behind — and only then. */ + const revealForPrompt = () => { if (mode === 'prompt') setActiveWorkspace(id); }; try { - if (handle) { - const refusal = await handle.closeAll(mode); - if (refusal) { - setActiveWorkspace(id); - return refusal; - } + const refusal = await handle.closeAll(mode); + if (refusal) { + revealForPrompt(); + return refusal; } if (!closeWorkspace(id)) { // The Wall is empty and stays mounted, so hand it back its auto-spawn. - handle?.cancelClose(); - setActiveWorkspace(id); + handle.cancelClose(); + revealForPrompt(); return LAST_WORKSPACE_REFUSAL; } forgetWorkspaceSession(id); diff --git a/lib/src/lib/platform/types.ts b/lib/src/lib/platform/types.ts index 390b6bc73..4bef0c21d 100644 --- a/lib/src/lib/platform/types.ts +++ b/lib/src/lib/platform/types.ts @@ -298,6 +298,15 @@ export interface PlatformAdapter { getCwds?(ids: string[]): Promise<Record<string, string | null>>; /** TCP listening ports opened by this terminal's process tree (shell + descendants). */ getOpenPorts(id: string): Promise<OpenPort[]>; + /** + * One answer per id, for a whole listing at once (`dor list --ports`, and + * `--all` across every Workspace). Present where a host can resolve many in + * one scan, for the reason `getCwds` is: standalone walks the process table + * and the socket table synchronously on the sidecar's only event loop, so N + * terminals must cost one pass rather than N. Absent falls back to + * `getOpenPorts` per id. + */ + getOpenPortsMany?(ids: string[]): Promise<Record<string, OpenPort[]>>; // Clipboard support for file references and raw images. readClipboardFilePaths(): Promise<string[] | null>; diff --git a/scripts/spec-word-budgets.json b/scripts/spec-word-budgets.json index 74c9513c2..55bbefcd3 100644 --- a/scripts/spec-word-budgets.json +++ b/scripts/spec-word-budgets.json @@ -6,13 +6,13 @@ "docs/specs/auto-update.md": 1100, "docs/specs/deploy.md": 1900, "docs/specs/dor-browser.md": 4500, - "docs/specs/dor-cli.md": 5450, + "docs/specs/dor-cli.md": 5700, "docs/specs/dor-tool.md": 2100, "docs/specs/glossary.md": 2950, "docs/specs/layout.md": 8450, "docs/specs/mobile-terminal-ui.md": 1950, "docs/specs/mouse-and-clipboard.md": 3750, - "docs/specs/notepad.md": 3900, + "docs/specs/notepad.md": 3950, "docs/specs/pocket-app.md": 4050, "docs/specs/relay.md": 9950, "docs/specs/remote-api.md": 3600, @@ -24,7 +24,7 @@ "docs/specs/security-supply-chain.md": 1150, "docs/specs/security.md": 1900, "docs/specs/shortcuts.md": 1050, - "docs/specs/standalone.md": 8350, + "docs/specs/standalone.md": 8400, "docs/specs/terminal-context.md": 900, "docs/specs/terminal-escapes.md": 3750, "docs/specs/terminal-state.md": 2350, @@ -32,7 +32,7 @@ "docs/specs/tiling-engine.md": 4500, "docs/specs/transport.md": 5300, "docs/specs/tutorial.md": 1900, - "docs/specs/vscode.md": 7450, + "docs/specs/vscode.md": 7500, "docs/specs/webgl-text.md": 1200, "docs/specs/website-docs.md": 5050 } diff --git a/standalone/scripts/dev-agent-browser.mjs b/standalone/scripts/dev-agent-browser.mjs index f8c953de2..f45bde772 100644 --- a/standalone/scripts/dev-agent-browser.mjs +++ b/standalone/scripts/dev-agent-browser.mjs @@ -122,6 +122,7 @@ const invokeMap = { pty_get_cwds: ({ ids }) => requestSidecar('pty:getCwds', { ids }, 'pty:cwds', (data) => data.cwds ?? {}), pty_context: ({ request }) => requestSidecar('pty:context', request, 'pty:context', data => data), pty_get_open_ports: ({ id }) => requestSidecar('pty:getOpenPorts', { id }, 'pty:openPorts', (data) => data.ports ?? []), + pty_get_open_ports_many: ({ ids }) => requestSidecar('pty:getOpenPortsMany', { ids }, 'pty:openPortsMany', (data) => data.ports ?? {}), read_clipboard_file_paths: () => requestSidecar('clipboard:readFiles', {}, 'clipboard:files', (data) => data.paths ?? null), read_clipboard_image_as_file_path: () => requestSidecar('clipboard:readImage', {}, 'clipboard:image', (data) => data.path ?? null), read_clipboard_text: () => requestSidecar('clipboard:readText', {}, 'clipboard:text', (data) => data.text ?? null), diff --git a/standalone/sidecar/main.js b/standalone/sidecar/main.js index 2e448a619..dd851377a 100644 --- a/standalone/sidecar/main.js +++ b/standalone/sidecar/main.js @@ -154,6 +154,7 @@ function handleLine(line) { case 'pty:getCwd': mgr.getCwd(data.id, data.requestId); break; case 'pty:getCwds': mgr.getCwds(data.ids, data.requestId); break; case 'pty:getOpenPorts': mgr.getOpenPorts(data.id, data.requestId); break; + case 'pty:getOpenPortsMany': mgr.getOpenPortsMany(data.ids, data.requestId); break; case 'pty:getShells': mgr.getShells(data.requestId); break; case 'pty:interrupt': mgr.interrupt(data.ids, data.requestId); break; // Quit teardown, first step: press ^C, detect each agent's resume diff --git a/standalone/sidecar/pty-core.js b/standalone/sidecar/pty-core.js index 0efb1fd70..4e064dfbd 100644 --- a/standalone/sidecar/pty-core.js +++ b/standalone/sidecar/pty-core.js @@ -1055,10 +1055,14 @@ module.exports.getListeningPortsForPids = getListeningPortsForPids; * any platform-specific failure rather than throwing. */ function getOpenPortsForPid(rootPid, runtime = {}) { - if (!Number.isInteger(rootPid)) return []; - const pids = getDescendantPids(rootPid, runtime); - const ports = getListeningPortsForPids(pids, runtime); + return getOpenPortsForPids([rootPid], runtime).get(rootPid) ?? []; +} + +module.exports.getOpenPortsForPid = getOpenPortsForPid; +/** De-duplicated by (family, address, port) and sorted by port — the shape a + * caller reads a Surface's ports in. */ +function dedupeListeningPorts(ports) { const seen = new Map(); for (const entry of ports) { const key = `${entry.family}|${entry.address}|${entry.port}`; @@ -1067,7 +1071,36 @@ function getOpenPortsForPid(rootPid, runtime = {}) { return [...seen.values()].sort((a, b) => a.port - b.port || a.address.localeCompare(b.address)); } -module.exports.getOpenPortsForPid = getOpenPortsForPid; +/** + * `rootPid -> listening ports` for a whole set of terminals, in ONE process-table + * read and ONE socket scan. + * + * Batched at this layer for the same reason `getCwdsForPids` is: `dor list --all + * --ports` asks about every terminal of every Workspace at once, and each step is + * a synchronous subprocess on the sidecar's only event loop — two spawns for N + * terminals instead of 2N. Returns [] per pid on any platform failure rather than + * throwing. + */ +function getOpenPortsForPids(rootPids, runtime = {}) { + const byRoot = new Map(); + const roots = [...new Set(rootPids.filter((pid) => Number.isInteger(pid)))]; + if (roots.length === 0) return byRoot; + + const pairs = readProcessTable(runtime); + // A failed scan is tolerated here (unlike helper-work inspection): each root + // then owns only itself, exactly as `getDescendantPids` falls back. + const owned = new Map(roots.map((root) => [root, pairs ? buildDescendantSet(pairs, root) : new Set([root])])); + const union = new Set(); + for (const pids of owned.values()) for (const pid of pids) union.add(pid); + + const ports = getListeningPortsForPids([...union], runtime); + for (const [root, pids] of owned) { + byRoot.set(root, dedupeListeningPorts(ports.filter((entry) => pids.has(entry.pid)))); + } + return byRoot; +} + +module.exports.getOpenPortsForPids = getOpenPortsForPids; /** Directory validation belongs to context(); this only launches the native UI. */ function openNativeDirectory(nativePath, done, runtime = {}) { @@ -1410,6 +1443,30 @@ module.exports.create = function create(send, ptyModule, { replay = false, slice send('openPorts', { id, ports: p ? getOpenPortsForPid(p.pid) : [], requestId }); } + /** One answer for every id a listing asks about, so N terminals cost one + * process scan rather than N (`docs/specs/dor-cli.md` -> "Current Implemented + * Commands"). An id with no live PTY answers `[]`, exactly as `getOpenPorts` + * does. */ + function getOpenPortsMany(ids, requestId) { + const targets = Array.isArray(ids) ? ids : []; + const ports = {}; + const idsByPid = new Map(); + for (const id of targets) { + ports[id] = []; + const p = ptys.get(id); + if (!p) continue; + const sharing = idsByPid.get(p.pid); + if (sharing) sharing.push(id); + else idsByPid.set(p.pid, [id]); + } + const resolved = getOpenPortsForPids([...idsByPid.keys()]); + for (const [pid, sharing] of idsByPid) { + const found = resolved.get(pid) ?? []; + for (const id of sharing) ports[id] = found; + } + send('openPortsMany', { ports, requestId }); + } + // Send ONE ^C to the given PTYs (all live ones when `ids` is omitted), so an // agent prints its resume invocation before the host tears the process down // (docs/specs/vscode.md -> "Capturing agent recovery"). Writes ^C into @@ -1481,6 +1538,6 @@ module.exports.create = function create(send, ptyModule, { replay = false, slice } return { spawn, write, resize, hasPty, kill, killAll, list, context, - getCwd, getCwds, getOpenPorts, interrupt, gracefulKill, getShells, + getCwd, getCwds, getOpenPorts, getOpenPortsMany, interrupt, gracefulKill, getShells, liveIds, receivedChars, outputSince }; }; diff --git a/standalone/sidecar/pty-core.test.js b/standalone/sidecar/pty-core.test.js index 43c912ab7..3ec558cb3 100644 --- a/standalone/sidecar/pty-core.test.js +++ b/standalone/sidecar/pty-core.test.js @@ -24,6 +24,7 @@ const { getDescendantPids, getListeningPortsForPids, getOpenPortsForPid, + getOpenPortsForPids, } = require('./pty-core'); test('resolveSpawnConfig uses POSIX shell and home defaults', () => { @@ -1483,6 +1484,49 @@ test('getOpenPortsForPid returns [] for a non-integer pid', () => { assert.deepEqual(getOpenPortsForPid(undefined, { platform: 'linux' }), []); }); +test('getOpenPortsForPids answers per root pid from ONE process table and ONE socket scan', () => { + // Two terminals, each with a child serving a port. A listing that spans them + // must not pay for a `ps` + `lsof` pair per terminal. + const spawns = []; + const execFileSync = (cmd, args) => { + spawns.push(cmd); + if (cmd === 'ps') return '100 1\n200 100\n300 1\n400 300\n'; + if (cmd === 'lsof') { + assert.ok(args.includes('100,200,300,400'), `one scan over every descendant: ${args.join(' ')}`); + return [ + 'p200', 'cnode', 'tIPv4', 'n*:3000', + 'p400', 'cnode', 'tIPv4', 'n*:5173', + ].join('\n'); + } + throw new Error(`unexpected spawn: ${cmd}`); + }; + + const ports = getOpenPortsForPids([100, 300], { platform: 'darwin', execFileSync }); + + assert.deepEqual(spawns, ['ps', 'lsof']); + // Each root owns only what its own descendants opened. + assert.deepEqual(ports.get(100).map((p) => p.port), [3000]); + assert.deepEqual(ports.get(300).map((p) => p.port), [5173]); +}); + +test('getOpenPortsMany answers a key for every requested id, [] for one with no PTY', () => { + const events = []; + const mgr = create((event, data) => events.push({ event, data }), { + spawn() { + return { pid: 999_002, onData() {}, onExit() {}, resize() {}, write() {}, kill() {} }; + }, + }, { replay: true }); + mgr.spawn('pane-a'); + + mgr.getOpenPortsMany(['pane-a', 'pane-gone'], 'req-2'); + + const answer = events.find((e) => e.event === 'openPortsMany'); + assert.equal(answer.data.requestId, 'req-2'); + assert.deepEqual(Object.keys(answer.data.ports).sort(), ['pane-a', 'pane-gone']); + // A pane with no live PTY is never scanned for. + assert.deepEqual(answer.data.ports['pane-gone'], []); +}); + // The clamping arithmetic itself lives in lib/src/host/replay-buffer.ts and is // tested there; what belongs here is the buffer accounting this file owns and // hands it. diff --git a/standalone/src-tauri/src/lib.rs b/standalone/src-tauri/src/lib.rs index 4fc96b1bd..656d32719 100644 --- a/standalone/src-tauri/src/lib.rs +++ b/standalone/src-tauri/src/lib.rs @@ -1081,6 +1081,27 @@ fn pty_get_open_ports( .unwrap_or_else(|| JsonValue::Array(Vec::new()))) } +/// Every id's listening ports in one sidecar round trip, for a listing that spans +/// terminals (`dor list --ports`, and `--all` across every Workspace). The +/// sidecar resolves them with synchronous process scans on its only event loop, +/// so N terminals must cost one scan rather than N. +#[tauri::command(async)] +fn pty_get_open_ports_many( + state: tauri::State<'_, SidecarState>, + ids: Vec<String>, +) -> Result<JsonValue, String> { + let response = request_from_sidecar_timeout( + &state, + "pty:getOpenPortsMany", + serde_json::json!({ "ids": ids }), + Duration::from_millis(OPEN_PORT_TIMEOUT_MS), + )?; + Ok(response + .get("ports") + .cloned() + .unwrap_or_else(|| JsonValue::Object(JsonMap::new()))) +} + // Wait for PTY exits and their final output before this window goes away. // Async: waits up to `timeout + 1500ms` (margin for the round trip beyond the // sidecar's own kill timer) and must not block the main thread for that long. @@ -3501,6 +3522,7 @@ pub fn run() { pty_get_cwds, pty_context, pty_get_open_ports, + pty_get_open_ports_many, pty_graceful_kill, capture_agent_recovery, take_recovery_commands, diff --git a/standalone/src/browser-sidecar-adapter.ts b/standalone/src/browser-sidecar-adapter.ts index 499ed5fe9..e0931208f 100644 --- a/standalone/src/browser-sidecar-adapter.ts +++ b/standalone/src/browser-sidecar-adapter.ts @@ -199,6 +199,13 @@ export class BrowserSidecarAdapter implements PlatformAdapter { try { return await this.host.invoke("pty_get_open_ports", { id }); } catch { return []; } } + /** See TauriAdapter: one round trip, one process scan, for a whole listing. */ + async getOpenPortsMany(ids: string[]): Promise<Record<string, OpenPort[]>> { + try { + return await this.host.invoke<Record<string, OpenPort[]>>("pty_get_open_ports_many", { ids }); + } catch { return {}; } + } + async readClipboardFilePaths(): Promise<string[] | null> { try { return await this.host.invoke("read_clipboard_file_paths"); } catch { return null; } } diff --git a/standalone/src/tauri-adapter.test.ts b/standalone/src/tauri-adapter.test.ts index 929ea4084..0c0d9cf1e 100644 --- a/standalone/src/tauri-adapter.test.ts +++ b/standalone/src/tauri-adapter.test.ts @@ -108,6 +108,33 @@ describe("TauriAdapter cwd probing", () => { }); }); +describe("TauriAdapter port probing", () => { + it("sends one pty_get_open_ports_many for a whole listing, and fails soft", async () => { + // `dor list --ports` across Workspaces asks once for every terminal: the + // sidecar's scan is synchronous, so one call is one pass over the process + // and socket tables instead of one per terminal. + const adapter = new TauriAdapter(); + const port = (value: number) => ({ family: "IPv4", address: "127.0.0.1", port: value, pid: 1 }); + vi.mocked(rawInvoke).mockImplementation(async (cmd: string, args?: unknown) => { + if (cmd !== "pty_get_open_ports_many") return undefined; + const ids = (args as { ids: string[] }).ids; + return Object.fromEntries(ids.map((id, index) => [id, [port(5000 + index)]])); + }); + + expect(await adapter.getOpenPortsMany(["pane-a", "pane-b"])).toEqual({ + "pane-a": [port(5000)], + "pane-b": [port(5001)], + }); + const calls = vi.mocked(rawInvoke).mock.calls.filter(([cmd]) => cmd === "pty_get_open_ports_many"); + expect(calls).toHaveLength(1); + expect(calls[0][1]).toEqual({ ids: ["pane-a", "pane-b"] }); + + // A failed scan is no ports, never a rejected listing. + vi.mocked(rawInvoke).mockRejectedValueOnce(new Error("sidecar is gone")); + expect(await adapter.getOpenPortsMany(["pane-a"])).toEqual({}); + }); +}); + // docs/specs/transport.md -> "The governing rule": standalone restores window // state, so nothing is deleted at boot and the record is claimed once. describe("TauriAdapter window persistence", () => { diff --git a/standalone/src/tauri-adapter.ts b/standalone/src/tauri-adapter.ts index 5cbf96a09..1ecaedf85 100644 --- a/standalone/src/tauri-adapter.ts +++ b/standalone/src/tauri-adapter.ts @@ -390,6 +390,15 @@ export class TauriAdapter implements PlatformAdapter { } catch { return []; } } + /** Every terminal of a listing in one round trip, so a `dor list --ports` that + * spans Workspaces costs the sidecar one process scan rather than one per + * terminal. Fails soft to no ports, exactly as the per-id call does. */ + async getOpenPortsMany(ids: string[]): Promise<Record<string, OpenPort[]>> { + try { + return await rawInvoke<Record<string, OpenPort[]>>("pty_get_open_ports_many", { ids }); + } catch { return {}; } + } + async readClipboardFilePaths(): Promise<string[] | null> { try { return await rawInvoke<string[]>("read_clipboard_file_paths"); diff --git a/vscode-ext/src/dor-workspace-guard.ts b/vscode-ext/src/dor-workspace-guard.ts index 68fe912fc..d39553ebc 100644 --- a/vscode-ext/src/dor-workspace-guard.ts +++ b/vscode-ext/src/dor-workspace-guard.ts @@ -9,9 +9,22 @@ */ import { parseWorkspaceRef, spansWorkspaces } from 'dor/protocol'; +import { DEFAULT_WORKSPACE_NAME } from '../../lib/src/lib/session-types'; const REFUSAL = 'Dormouse in VS Code puts each Workspace in its own webview, so'; +/** + * Whether a container ref names the one Workspace this webview *is*: its + * position, or the name a bare Wall registers — a caller that read the name out + * of `dor list` must be able to hand it straight back + * (`docs/specs/dor-cli.md` → "Handle Model"). + */ +function namesThisWebviewsWorkspace(workspace: unknown): boolean { + if (typeof workspace !== 'string') return false; + const { position, name } = parseWorkspaceRef(workspace); + return position === 1 || name === DEFAULT_WORKSPACE_NAME; +} + /** * Why this request cannot be answered here, or null to let it through. Reads * only the wire request, so it holds for every transport that reaches the @@ -19,9 +32,8 @@ const REFUSAL = 'Dormouse in VS Code puts each Workspace in its own webview, so' */ export function dorWorkspaceRefusal(method: string, params: Record<string, unknown> | undefined): string | null { const workspace = params?.workspace; - if (workspace !== undefined) { - const named = typeof workspace === 'string' ? parseWorkspaceRef(workspace).position : null; - if (named !== 1) return `${REFUSAL} it has no workspace '${String(workspace)}' to act on`; + if (workspace !== undefined && !namesThisWebviewsWorkspace(workspace)) { + return `${REFUSAL} it has no workspace '${String(workspace)}': this webview is workspace:1, ${JSON.stringify(DEFAULT_WORKSPACE_NAME)}`; } if (spansWorkspaces(method, params)) { return `${REFUSAL} dor workspace, dor list --workspaces and dor list --all are not available here`; diff --git a/vscode-ext/test/dor-workspace-guard.test.ts b/vscode-ext/test/dor-workspace-guard.test.ts index 24ad1b772..98a749628 100644 --- a/vscode-ext/test/dor-workspace-guard.test.ts +++ b/vscode-ext/test/dor-workspace-guard.test.ts @@ -6,7 +6,9 @@ describe('dorWorkspaceRefusal', () => { expect(dorWorkspaceRefusal('surface.list', {})).toBeNull(); expect(dorWorkspaceRefusal('surface.split', undefined)).toBeNull(); expect(dorWorkspaceRefusal('surface.list', { scope: 'workspace' })).toBeNull(); - for (const workspace of ['workspace:1', '1', ' workspace:1 ']) { + // Positionally, and by the name a bare Wall registers — a caller that read + // the name out of `dor list` hands it straight back. + for (const workspace of ['workspace:1', '1', ' workspace:1 ', 'Workspace 1', 'workspace:Workspace 1']) { expect(dorWorkspaceRefusal('surface.kill', { workspace })).toBeNull(); } }); From e2b6e74b28fb464740f9eee66a47ff628b25f514 Mon Sep 17 00:00:00 2001 From: Ned Twigg <ned.twigg@diffplug.com> Date: Thu, 10 Sep 2026 17:38:46 -0700 Subject: [PATCH 09/13] Answer every dor request, budget the batched port scan, and close through the registration gap MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review findings on the workspaces-dor branch: - macListeningPorts threw away the stdout lsof printed before a non-zero exit, so one descendant exiting between `ps` and `lsof` emptied a whole Window's `dor list --all --ports`. It now parses `err.stdout`, the shape getCwdsForPids already documents. The batched `pty_get_open_ports_many` reused the per-terminal 3 s for the whole Window while the sidecar spends it twice serially; it now waits both scans plus `OPEN_PORT_TIMEOUT_PER_ID_MS` per id, with the sidecar's socket scan capped to match. The new constant is mirrored TS/sidecar/Rust and pinned by mirrored-constants.test.ts. - The router left a request with no Wall mounted to the client's 5 s deadline, which every managed `dor ab` now pays. After the bounded retry it answers the mounting refusal for the active Workspace; `dor ab` reports it and exits non-zero without running the binary, and there is deliberately no fallback to a CLI-namespaced key (it would name the wrong Workspace's browser). - The strip's `×` / command-mode `&` swallowed the no-Wall refusal and did nothing. requestWorkspaceClose now waits out the registration gap the way `dor workspace close` does, and the refusal is one wording everywhere (`mountingRefusal`; NO_WALL_REFUSAL is gone). - `dor list --all --json` adds caller_workspace_ref / focused_workspace_ref, since the `_surface_ref` pair names a `surface:N` every Workspace has. - sessionForKey scrubs the key like the scope: it arrives over the control socket from clients other than `dor`. - Specs: layout.md corner case #8 reveals only in `prompt` mode and the close refusals list the no-Wall case; dor-tool.md links the live Workspaces section instead of the deleted workspaces-rollout scope; dor-browser.md states that every managed `dor ab` depends on the host answering. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RChsJ5rMUMyfu22UZDfUus --- docs/specs/dor-browser.md | 7 ++++ docs/specs/dor-cli.md | 19 +++++---- docs/specs/dor-tool.md | 4 +- docs/specs/layout.md | 4 +- docs/specs/standalone.md | 11 ++++- dor-lib-common/src/agent-browser.ts | 11 ++--- dor-lib-common/test/agent-browser.test.mjs | 9 ++++ dor/src/commands/agent-browser.ts | 3 ++ dor/src/commands/list.ts | 11 ++++- dor/test/cli-output.test.mjs | 17 ++++++++ dor/test/snapshots/help/list.md | 2 +- dor/test/snapshots/list-all-json.snap | 2 + .../wall/dor-control-router.test.ts | 14 +++++-- lib/src/components/wall/dor-control-router.ts | 23 +++++++---- .../handle-workspace-shortcuts.test.ts | 38 ++++++++++------- .../wall/workspace-lifecycle.test.ts | 40 ++++++++++++++++-- .../components/wall/workspace-lifecycle.ts | 27 +++++++++--- lib/src/lib/mirrored-constants.test.ts | 17 +++++++- lib/src/lib/platform/types.ts | 10 +++++ scripts/spec-word-budgets.json | 8 ++-- standalone/sidecar/pty-core.js | 41 +++++++++++++++---- standalone/sidecar/pty-core.test.js | 22 +++++++++- standalone/src-tauri/src/lib.rs | 37 ++++++++++++++--- 23 files changed, 300 insertions(+), 77 deletions(-) diff --git a/docs/specs/dor-browser.md b/docs/specs/dor-browser.md index 86e959f1e..f0104e13a 100644 --- a/docs/specs/dor-browser.md +++ b/docs/specs/dor-browser.md @@ -222,6 +222,13 @@ Spawning External Binaries). so `dor ab` asks the host (`surface.resolveAgentBrowser` with `key`) before it forwards anything, and namespaces the key itself only when there is no control endpoint at all — outside Dormouse, where `dor ab` is a pure passthrough. + **Every managed `dor ab` invocation depends on the host answering** — a + passthrough verb included — with no CLI-side fallback: a refusal (a Wall still + mounting, a webview mid-reload, the VS Code guard) fails the command with the + host's message before the binary runs, and the router answers the no-Wall + case after its bounded retry rather than leaving `dor ab` to its deadline + (`docs/specs/dor-cli.md` → "Handle Model"). A CLI-namespaced fallback would + name the wrong Workspace's browser. - GUI-spawned sessions use `dormouse.1.gui-<hex>`, minted host-wide (the Window's one agent-browser host, not a Workspace), which no `--key` names; they are reachable by `dor ab --surface <handle>` (`docs/specs/dor-cli.md` → diff --git a/docs/specs/dor-cli.md b/docs/specs/dor-cli.md index a0bfaced0..c4f7092fa 100644 --- a/docs/specs/dor-cli.md +++ b/docs/specs/dor-cli.md @@ -304,14 +304,15 @@ Invariants: every Wall), an explicit `--workspace`, the Workspace holding the target Surface when it is named by its **stable id** — unique Window-wide, unlike `surface:N` — else the Workspace owning the calling Surface, else the active - one; nothing mounted leaves the request unanswered, after a bounded - retry that covers the tick between a Workspace being created and its Wall - registering. **A `--workspace` the store resolves but whose Wall has not - registered waits out that same retry**, then answers `workspace '<ref>' is - still mounting` — it is not the unknown-Workspace answer, the Workspace being - there. **Every request is answered, including a container ref of the - wrong type and a handler that throws** — an unanswered one blocks its caller - to the deadline. A Workspace being closed refuses the Surface-creating verbs + one; **nothing mounted answers `workspace '<ref>' is still mounting` for the + active Workspace**, after a bounded retry that covers the tick between a + Workspace being created and its Wall registering — never left to the caller's + deadline, which every managed `dor ab` would pay (`docs/specs/dor-browser.md` + → "Managed identity"). **A `--workspace` the store resolves but whose Wall has + not registered waits out that same retry**, then answers the same refusal for + that ref — it is not the unknown-Workspace answer, the Workspace being there. + **Every request is answered, including a container ref of the wrong type and + a handler that throws** — an unanswered one blocks its caller to the deadline. A Workspace being closed refuses the Surface-creating verbs (`docs/specs/layout.md` → "Workspaces"). **Surface targets resolve within the answering Workspace** — refs are Workspace-scoped — so a `dor split` from a background Workspace lands beside its caller rather than wherever the user is @@ -409,7 +410,7 @@ The spec keeps the behavior help cannot express: | `await` | **Must name `--until quiet\|exit`; never infer it.** Timeout 1–86400 whole seconds, default 600; `alert.md` owns wake semantics. | | `kill` | **Must select exactly one confirmation mode.** Conditional text needs four non-whitespace characters and must match `read`; browser Surfaces are killable. | | `iframe`, `agent-browser` / `ab` | `dor-browser.md` owns the renderers; see [target resolution](#browser-open-target-resolution) and [addressing](#agent-browser-surface-addressing). The passthrough is intercepted before stricli parses it. | -| `list` | Filters are ANDed client-side; `--port` filters terminals (browser Surfaces never match) and implies the opt-in detail scan, `--ports` only requests it. **Owns every Workspace read**: `--workspace` narrows to one, `--all` groups every Workspace's rows under its header — **every Workspace keeps its header**, including one a filter emptied, so the text listing and the JSON `workspaces` array name the same Workspaces — `--workspaces` is the overview, and the three cannot be combined. **`--workspaces` takes `--json` and nothing else**, by an allowlist, so a flag added to `list` is refused there until it is named. | +| `list` | Filters are ANDed client-side; `--port` filters terminals (browser Surfaces never match) and implies the opt-in detail scan, `--ports` only requests it. **Owns every Workspace read**: `--workspace` narrows to one, `--all` groups every Workspace's rows under its header — **every Workspace keeps its header**, including one a filter emptied, so the text listing and the JSON `workspaces` array name the same Workspaces — `--workspaces` is the overview, and the three cannot be combined. **`--all --json` adds `caller_workspace_ref` / `focused_workspace_ref`** beside the `_surface_ref` pair, which under `--all` names a `surface:N` every Workspace has; the `_surface_id` halves stay unique. **`--workspaces` takes `--json` and nothing else**, by an allowlist, so a flag added to `list` is refused there until it is named. | | `workspace` | **Mutation only** ([dor workspace](#dor-workspace)). | | `skill` | Prints the bundled skill or installs its bootstrap stub; [Agent Skill](#agent-skill) owns the contract. | diff --git a/docs/specs/dor-tool.md b/docs/specs/dor-tool.md index 0aad3a8c0..7fa52ec28 100644 --- a/docs/specs/dor-tool.md +++ b/docs/specs/dor-tool.md @@ -164,8 +164,8 @@ editor *becomes* its save-file. the minimize itself** (reattach must not cost a boot every time) **or under memory pressure**. The headline case is Workspaces, not shutdown: an inactive Workspace of dehydratable tools drops to zero processes, relieving the -parked-surface pressure the workspaces rollout projects (`docs/specs/layout.md` -→ Future, workspaces-rollout; `docs/specs/tiling-engine.md` → Parked leaves). +parked-surface pressure hidden Workspaces carry (`docs/specs/layout.md` +→ Workspaces; `docs/specs/tiling-engine.md` → Parked leaves). **In-session mechanism.** The payload lives with the running host; survival across a full quit/restart follows each host's session-persistence story diff --git a/docs/specs/layout.md b/docs/specs/layout.md index f59c5fbc5..36e8d846b 100644 --- a/docs/specs/layout.md +++ b/docs/specs/layout.md @@ -159,7 +159,7 @@ Sessions and its notes with it, and killing nothing on the way (`docs/specs/standalone.md` → Transfer). Leaving is not a close and arriving is not a create: a Workspace that arrives mounts from the record it brought. -**Create** adds a Workspace named `Workspace N`, makes it active, and gives its Wall no restored record, so Lath's fresh branch spawns one default-shell pane. **Close** confirms first when the Workspace holds touched Surfaces or running work, reusing the kill-confirm letter and key rule over the Window's content area, then routes every member Surface through the closure coordinator; **the last remaining Workspace cannot be closed** — there is always one active Workspace, as there is always one visible pane (corner case #5). **One close runs at a time for the whole Window**, with the count re-checked after the confirmation, so two of them cannot empty two Walls between them; **a close the store then refuses hands the Wall back its auto-spawn** rather than leaving it mounted and empty. **Rename** edits the Workspace `name` only — no Surface title, and not the per-pane inline rename. **Reorder** moves a tab in the strip and renumbers the positional `workspace:<n>` refs with it. **Every Workspace verb runs outside the strip**, which renders the rename editor and confirmation from a store, so a tab gesture and a command-mode key take one path. +**Create** adds a Workspace named `Workspace N`, makes it active, and gives its Wall no restored record, so Lath's fresh branch spawns one default-shell pane. **Close** confirms first when the Workspace holds touched Surfaces or running work, reusing the kill-confirm letter and key rule over the Window's content area, then routes every member Surface through the closure coordinator; **the last remaining Workspace cannot be closed** — there is always one active Workspace, as there is always one visible pane (corner case #5). **One close runs at a time for the whole Window**, with the count re-checked after the confirmation, so two of them cannot empty two Walls between them; **a close the store then refuses hands the Wall back its auto-spawn** rather than leaving it mounted and empty. **A Workspace whose Wall has not registered is refused** (`workspace '<ref>' is still mounting`, one wording for every caller), never closed past — the Wall walks the member Surfaces, so dropping it would leave its Sessions running unheld (`docs/specs/glossary.md` → "Invariants" I4); **a gesture waits out the registration gap first**, as `dor workspace close` does, so `×` or `&` right after a create closes rather than silently doing nothing. **Rename** edits the Workspace `name` only — no Surface title, and not the per-pane inline rename. **Reorder** moves a tab in the strip and renumbers the positional `workspace:<n>` refs with it. **Every Workspace verb runs outside the strip**, which renders the rename editor and confirmation from a store, so a tab gesture and a command-mode key take one path. The union projection and its indicators are owned by `docs/specs/alert.md` → Workspace union; the strip that renders them by `docs/specs/standalone.md` → AppBar. Persisted containers are owned by `docs/specs/transport.md`: standalone stores one `PersistedWindow` per window, so a relaunch restores every Workspace ([Session persistence](#session-persistence)). @@ -432,7 +432,7 @@ A store commit that empties the tree (last pane killed or minimized) triggers th 5. **Door keeps selection through the auto-spawn refill** ([Auto-spawn refill](#auto-spawn-refill)). Explicit user selection of a pane — a click, a drag, or an embed focusing itself — still moves selection off a door. 6. **Focus-neutral surface creation (`dor ensure` / `dor iframe` / `dor ab`)**: unlike `dor split`, these open in the background without moving focus off the caller (`docs/specs/dor-cli.md`, `docs/specs/dor-browser.md`). An add never re-parents the caller's subtree or steals activation, and the create does not call `selectPane` (`settleAddSelection` returns false for a focus-neutral, non-selection-replacing add). **The one exception**: `dor iframe` / `dor ab` replacing the pane the user is *currently selected on* moves selection to the replacement, else it would dangle on the removed leaf; any other pane, or a door selection, is left untouched. Cleanup of a `dor ensure` temporary Surface follows `docs/specs/notepad.md` → "Closure"; any completed teardown preserves the caller's live selection. 7. **A hidden Workspace is not minimized**: its Surfaces stay `Paned` / `Doored` and its Sessions keep running; only painting stops, because `useSurfaceVisibility` reports a Workspace-inactive Surface as off screen and its streaming bodies idle. -8. **A refused close reveals its Workspace**: a `closeAll` that returns a refusal activates that Workspace, so the prompt behind the refusal is on screen rather than inside a hidden Wall. +8. **A refused close reveals its Workspace only in `prompt` mode**: a `closeAll` that returns a refusal to a user gesture activates that Workspace, so the prompt behind the refusal is on screen rather than inside a hidden Wall; a `silent` close (`dor workspace close`) has no prompt to show and leaves the user where they were (`docs/specs/notepad.md` → "Closure"). ## Future diff --git a/docs/specs/standalone.md b/docs/specs/standalone.md index 6149a9701..294728dbb 100644 --- a/docs/specs/standalone.md +++ b/docs/specs/standalone.md @@ -81,8 +81,8 @@ are *not* forwarded: | `agent_browser_screenshot` | Rust reads the bytes from a sidecar-supplied temp-file *path* | images must never ride the JSON-lines pipe shared with PTY traffic (`docs/specs/dor-browser.md`) | Request/response commands block on the sidecar's reply under a timeout. -`OPEN_PORT_TIMEOUT_MS` in `lib.rs` mirrors the constant in -`lib/src/lib/platform/types.ts` (and `standalone/sidecar/pty-core.js`); +`OPEN_PORT_TIMEOUT_MS` and `OPEN_PORT_TIMEOUT_PER_ID_MS` in `lib.rs` mirror the +constants in `lib/src/lib/platform/types.ts` (and `standalone/sidecar/pty-core.js`); `lib/src/lib/mirrored-constants.test.ts` pins the copies together. **Blocking commands must be `#[tauri::command(async)]`** — Tauri runs a *plain* @@ -653,6 +653,13 @@ adapters carry it, and the sidecar answers every id from one process-table read and one socket scan (`getOpenPortsForPids`) — the scans are synchronous on its only event loop, so a `dor list --ports` across Workspaces must not multiply them by its row count (`docs/specs/dor-cli.md` → "Current Implemented Commands"). +**Its budget scales with the batch**: the socket scan runs under +`OPEN_PORT_TIMEOUT_MS + OPEN_PORT_TIMEOUT_PER_ID_MS × ids`, and the command waits +that plus the process-table read's `OPEN_PORT_TIMEOUT_MS` — one terminal's cap +never bounds the whole Window (`open_ports_many_timeout` in +`standalone/src-tauri/src/lib.rs`). **A macOS socket scan keeps the rows `lsof` +printed before a non-zero exit** — a pid gone mid-batch would otherwise empty +every terminal's answer, as `getCwdsForPids` already guards. **Nothing is deleted at boot but orphaned session temp files** (`docs/specs/transport.md` → "Retiring the transcripts already on disk"). **The diff --git a/dor-lib-common/src/agent-browser.ts b/dor-lib-common/src/agent-browser.ts index 8a7daa8ed..d24613415 100644 --- a/dor-lib-common/src/agent-browser.ts +++ b/dor-lib-common/src/agent-browser.ts @@ -4,9 +4,10 @@ // sessionForKey, never by hand. const BARE_WALL_SCOPE = '1'; -// A session name becomes a filesystem path (the daemon's socket dir), so the -// scope is held to the same charset `dor ab --key` is. -const UNSAFE_SCOPE_CHARS = /[^A-Za-z0-9._-]/g; +// A session name becomes a filesystem path (the daemon's socket dir), so both +// halves are held to the charset `dor ab --key` enforces CLI-side: the key +// arrives over the control socket too, from clients that are not `dor`. +const UNSAFE_SESSION_CHARS = /[^A-Za-z0-9._-]/g; /** Env var that overrides which agent-browser binary to run; shared so `dor ab` * and the host key off the same name. */ @@ -33,8 +34,8 @@ export function streamStatusArgs(session: string): string[] { * readable. Shared by `dor ab` (--key resolution) and the lib host (GUI sessions). */ export function sessionForKey(key: string, workspaceId?: string): string { - const scope = workspaceId ? workspaceId.replace(UNSAFE_SCOPE_CHARS, '-') : BARE_WALL_SCOPE; - return `dormouse.${scope}.${key}`; + const scope = workspaceId ? workspaceId.replace(UNSAFE_SESSION_CHARS, '-') : BARE_WALL_SCOPE; + return `dormouse.${scope}.${key.replace(UNSAFE_SESSION_CHARS, '-')}`; } /** diff --git a/dor-lib-common/test/agent-browser.test.mjs b/dor-lib-common/test/agent-browser.test.mjs index 7da5236ca..1dddb5dec 100644 --- a/dor-lib-common/test/agent-browser.test.mjs +++ b/dor-lib-common/test/agent-browser.test.mjs @@ -5,6 +5,15 @@ import { parseStreamPort, sessionForKey } from '../dist/index.js'; test('sessionForKey namespaces a key under the workspace', () => { assert.equal(sessionForKey('default'), 'dormouse.1.default'); assert.equal(sessionForKey('gui-abc'), 'dormouse.1.gui-abc'); + assert.equal(sessionForKey('default', 'workspace-2b1c'), 'dormouse.workspace-2b1c.default'); +}); + +test('sessionForKey scrubs the key like the scope: a session name is a socket path', () => { + // The key crosses the control socket from any client, not only `dor` (which + // rejects this shape itself), so it cannot be allowed to escape the socket dir. + assert.equal(sessionForKey('../../../tmp/x', 'ws/1'), 'dormouse.ws-1.-.-.-.-tmp-x'); + // A valid key is unchanged. + assert.equal(sessionForKey('a.b_c-D9', 'ws'), 'dormouse.ws.a.b_c-D9'); }); test('parseStreamPort reads a top-level port', () => { diff --git a/dor/src/commands/agent-browser.ts b/dor/src/commands/agent-browser.ts index af010d81c..9eb25f765 100644 --- a/dor/src/commands/agent-browser.ts +++ b/dor/src/commands/agent-browser.ts @@ -311,6 +311,9 @@ export async function runAgentBrowserCli(args: string[], options: CliOptions): P * session to drive). * * The host's messages are printed verbatim; dor does not re-interpret them. + * **A host that refuses fails the command** before the binary runs — there is + * no fallback to a CLI-namespaced key, which would name the wrong Workspace's + * browser (`docs/specs/dor-browser.md` → "Managed identity"). */ async function resolveSession( flags: ResolvedSessionFlags, diff --git a/dor/src/commands/list.ts b/dor/src/commands/list.ts index a00cfc6eb..ec5ad492a 100644 --- a/dor/src/commands/list.ts +++ b/dor/src/commands/list.ts @@ -67,7 +67,7 @@ JSON output (--json) always includes both stable ids and refs, and each row carr --workspace <ref> lists another Workspace of this Window instead: workspace:<n> (positional) or workspace:<name>, which resolves only when exactly one Workspace carries that name. Both are accepted bare ("2", "build"). ---all lists every Workspace of this Window, grouped under a Workspace header — every Workspace keeps its header, including one holding nothing and one the filters emptied. Rows keep their own Workspace-scoped surface:N refs, so several groups have a surface:1, but only the active Workspace's selection carries the focus marker; each JSON row adds workspace_ref, and the payload adds a workspaces array. Target a row from another Workspace by its stable id, or pass --workspace. +--all lists every Workspace of this Window, grouped under a Workspace header — every Workspace keeps its header, including one holding nothing and one the filters emptied. Rows keep their own Workspace-scoped surface:N refs, so several groups have a surface:1, but only the active Workspace's selection carries the focus marker; each JSON row adds workspace_ref, and the payload adds a workspaces array plus caller_workspace_ref/focused_workspace_ref, because caller_surface_ref/focused_surface_ref then name a ref several groups share (the _id halves stay unique). Target a row from another Workspace by its stable id, or pass --workspace. --workspaces prints the Workspace overview instead of any Surface: one row per Workspace with the active marker, its name, [ringing]/[todo] when any member Surface is, and [attention N] for the number owing it. It takes no other flag but --json. @@ -390,7 +390,14 @@ function renderListJson( focused_surface_id: focused?.id ?? null, window_ref: response.windowRef, workspace_ref: response.workspaceRef, - ...(response.workspaces ? { workspaces: response.workspaces.map(renderWorkspaceJson) } : {}), + // Under `--all` a `surface:N` ref is shared by every Workspace, so the two + // ref pointers above name a row only together with the Workspace it is in; + // the `_id` halves stay unique on their own. + ...(response.workspaces ? { + caller_workspace_ref: caller?.workspaceRef ?? null, + focused_workspace_ref: focused?.workspaceRef ?? null, + workspaces: response.workspaces.map(renderWorkspaceJson), + } : {}), host: { app: env.DORMOUSE_HOST ?? null, workspace: env.DORMOUSE_HOST_WORKSPACE ?? null, diff --git a/dor/test/cli-output.test.mjs b/dor/test/cli-output.test.mjs index 8fc7cbfa2..3002428ca 100644 --- a/dor/test/cli-output.test.mjs +++ b/dor/test/cli-output.test.mjs @@ -1267,6 +1267,23 @@ test('agent-browser --surface prints the host gate error and never forwards', as assert.deepEqual(ab.calls, []); }); +test('agent-browser fails fast when the host refuses to name a managed key, never running the binary', async () => { + // A Wall still mounting, a webview mid-reload, the VS Code guard: the host + // answers with a refusal rather than the session, and there is no CLI-side + // fallback that could name the right Workspace's browser + // (`docs/specs/dor-browser.md` → "Managed identity"). + const ab = fakeAgentBrowser(); + const client = fixtureClient(); + client.resolveAgentBrowserSession = async () => { + throw new Error("workspace 'workspace:1' is still mounting"); + }; + const result = await runCli(['ab', 'tab', 'list'], { client, execAgentBrowser: ab.exec }); + assert.equal(result.exitCode, 1); + assert.equal(result.stdout, ''); + assert.equal(result.stderr, "Error: workspace 'workspace:1' is still mounting\n"); + assert.deepEqual(ab.calls, []); +}); + test('agent-browser --surface needs a control endpoint', async () => { const ab = fakeAgentBrowser(); const result = await runCli(['ab', '--surface', 'surface:3', 'reload'], { execAgentBrowser: ab.exec }); diff --git a/dor/test/snapshots/help/list.md b/dor/test/snapshots/help/list.md index 7c32bdcd2..702c6af83 100644 --- a/dor/test/snapshots/help/list.md +++ b/dor/test/snapshots/help/list.md @@ -22,7 +22,7 @@ JSON output (--json) always includes both stable ids and refs, and each row carr --workspace <ref> lists another Workspace of this Window instead: workspace:<n> (positional) or workspace:<name>, which resolves only when exactly one Workspace carries that name. Both are accepted bare ("2", "build"). ---all lists every Workspace of this Window, grouped under a Workspace header — every Workspace keeps its header, including one holding nothing and one the filters emptied. Rows keep their own Workspace-scoped surface:N refs, so several groups have a surface:1, but only the active Workspace's selection carries the focus marker; each JSON row adds workspace_ref, and the payload adds a workspaces array. Target a row from another Workspace by its stable id, or pass --workspace. +--all lists every Workspace of this Window, grouped under a Workspace header — every Workspace keeps its header, including one holding nothing and one the filters emptied. Rows keep their own Workspace-scoped surface:N refs, so several groups have a surface:1, but only the active Workspace's selection carries the focus marker; each JSON row adds workspace_ref, and the payload adds a workspaces array plus caller_workspace_ref/focused_workspace_ref, because caller_surface_ref/focused_surface_ref then name a ref several groups share (the _id halves stay unique). Target a row from another Workspace by its stable id, or pass --workspace. --workspaces prints the Workspace overview instead of any Surface: one row per Workspace with the active marker, its name, [ringing]/[todo] when any member Surface is, and [attention N] for the number owing it. It takes no other flag but --json. diff --git a/dor/test/snapshots/list-all-json.snap b/dor/test/snapshots/list-all-json.snap index 66253aa51..9bca98925 100644 --- a/dor/test/snapshots/list-all-json.snap +++ b/dor/test/snapshots/list-all-json.snap @@ -66,6 +66,8 @@ stdout: "focused_surface_id": "11111111-1111-4111-8111-111111111111", "window_ref": "window:1", "workspace_ref": "workspace:1", + "caller_workspace_ref": null, + "focused_workspace_ref": "workspace:1", "workspaces": [ { "ref": "workspace:1", diff --git a/lib/src/components/wall/dor-control-router.test.ts b/lib/src/components/wall/dor-control-router.test.ts index 188e3d37c..9949e3a81 100644 --- a/lib/src/components/wall/dor-control-router.test.ts +++ b/lib/src/components/wall/dor-control-router.test.ts @@ -149,8 +149,11 @@ describe('dor control routing', () => { expect(resolveDorControlRoute(request({ params: { window: 'window:1' } })).kind).toBe('error'); }); - it('does nothing when no Wall is mounted', () => { - expect(resolveDorControlRoute(request())).toEqual({ kind: 'none' }); + it('names the active Workspace as still mounting when no Wall is mounted', () => { + expect(resolveDorControlRoute(request())).toEqual({ + kind: 'none', + message: "workspace 'workspace:1' is still mounting", + }); }); it('shares one window listener across every Wall that holds it', () => { @@ -258,13 +261,18 @@ describe('dor control routing', () => { } }); - it('gives up after a bounded number of retries when nothing ever mounts', async () => { + it('gives up after a bounded number of retries when nothing ever mounts, and says so', async () => { vi.useFakeTimers(); try { const release = installDorControlRouter(); const detail = request(); window.dispatchEvent(new CustomEvent('dormouse:control-request', { detail })); + expect(detail.respond).not.toHaveBeenCalled(); await vi.advanceTimersByTimeAsync(10); + // Answered promptly, not left to the client's own deadline: `dor ab` + // makes this round trip on every managed invocation and must fail fast. + expect(detail.respond).toHaveBeenCalledTimes(1); + expect(detail.respond).toHaveBeenCalledWith({ ok: false, error: "workspace 'workspace:1' is still mounting" }); // The retry chain is finite: registering afterwards is too late. const handle = handleFor(getWorkspacesSnapshot().workspaces[0].id); await vi.advanceTimersByTimeAsync(10); diff --git a/lib/src/components/wall/dor-control-router.ts b/lib/src/components/wall/dor-control-router.ts index 068ae611b..670d1b809 100644 --- a/lib/src/components/wall/dor-control-router.ts +++ b/lib/src/components/wall/dor-control-router.ts @@ -1,6 +1,6 @@ import { isWorkspaceControlMethod, SURFACE_CONTROL_METHODS } from 'dor/protocol'; import { createRefCount } from '../../lib/ref-count'; -import { getActiveWorkspaceId, isWindowRef, resolveWorkspaceRef } from '../../lib/workspace-store'; +import { getActiveWorkspaceId, isWindowRef, resolveWorkspaceRef, workspaceRefFor } from '../../lib/workspace-store'; import { errorText, mountingRefusal, ROUTE_RETRIES } from './dor-control-shared'; import { getWallHandle, wallHandleOwning, type WallHandle } from './wall-handles'; import { handleWorkspaceControl, listAllWorkspaceSurfaces, type WindowControlParams } from './workspace-control'; @@ -24,8 +24,9 @@ export type DorControlRoute = /** The Workspace exists but its Wall has not registered yet: retried like * `none`, and answered with `message` if it never does. */ | { kind: 'pending'; message: string } - /** Nothing is mounted that could answer; the request is left to time out. */ - | { kind: 'none' }; + /** Nothing is mounted that could answer: retried, and answered with + * `message` — the active Workspace still mounting — if nothing ever does. */ + | { kind: 'none'; message: string }; /** * The Workspace holding the Surface this target names, when the target names @@ -81,8 +82,11 @@ export function resolveDorControlRoute(detail: DorControlRequest): DorControlRou if (owner) return { kind: 'handle', handle: owner }; // An unknown caller (a shell started outside Dormouse, a killed Surface's // late request) is served by the Workspace the user is in. - const active = getWallHandle(getActiveWorkspaceId()); - return active ? { kind: 'handle', handle: active } : { kind: 'none' }; + const activeId = getActiveWorkspaceId(); + const active = getWallHandle(activeId); + return active + ? { kind: 'handle', handle: active } + : { kind: 'none', message: mountingRefusal(workspaceRefFor(activeId)) }; } function dispatchDorControl(detail: DorControlRequest, attempt: number): void { @@ -96,9 +100,12 @@ function dispatchDorControl(detail: DorControlRequest, attempt: number): void { setTimeout(() => dispatchDorControl(detail, attempt + 1), 0); return; } - // A Workspace whose Wall never registered says so; a Window with nothing - // mounted at all has nobody to answer for, and is left to time out. - if (route.kind === 'pending') detail.respond({ ok: false, error: route.message }); + // The Workspace that would have answered says it is still mounting — the + // one named, or the active one when nothing is mounted at all. Dropping + // the request instead would hold the caller to its own deadline, and + // `dor ab` makes this round trip on every managed invocation + // (`docs/specs/dor-browser.md` → "Managed identity"). + detail.respond({ ok: false, error: route.message }); return; } // Every failure the handler can raise is answered: an unanswered request diff --git a/lib/src/components/wall/keyboard/handle-workspace-shortcuts.test.ts b/lib/src/components/wall/keyboard/handle-workspace-shortcuts.test.ts index 40bfbcacc..1bbf9f03b 100644 --- a/lib/src/components/wall/keyboard/handle-workspace-shortcuts.test.ts +++ b/lib/src/components/wall/keyboard/handle-workspace-shortcuts.test.ts @@ -1,7 +1,7 @@ /** * @vitest-environment jsdom */ -import { beforeEach, describe, expect, it } from 'vitest'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; import { handleWorkspaceShortcuts } from './handle-workspace-shortcuts'; import { registerWallHandle, resetWallHandles, stubWallHandle } from '../wall-handles'; import { getWorkspaceUiSnapshot, resetWorkspaceUi } from '../../../lib/workspace-ui-store'; @@ -67,21 +67,29 @@ describe('handleWorkspaceShortcuts', () => { }); it('opens the strip rename editor and close flow on the ACTIVE Workspace', async () => { - const [first] = ids(); - createWorkspace({ id: 'ws-2' }); - registerWallHandle(stubWallHandle(first)); - registerWallHandle(stubWallHandle('ws-2')); - handleWorkspaceShortcuts(keydown('$'), ctx); - expect(getWorkspaceUiSnapshot().renamingId).toBe('ws-2'); + vi.useFakeTimers(); + try { + const [first] = ids(); + createWorkspace({ id: 'ws-2' }); + handleWorkspaceShortcuts(keydown('$'), ctx); + expect(getWorkspaceUiSnapshot().renamingId).toBe('ws-2'); - // Nothing in the Wall is touched, so the close goes straight through — but - // the last Workspace still cannot be closed. - handleWorkspaceShortcuts(keydown('&'), ctx); - await Promise.resolve(); - expect(ids()).toEqual([first]); - handleWorkspaceShortcuts(keydown('&'), ctx); - await Promise.resolve(); - expect(ids()).toHaveLength(1); + // No Wall has registered yet — `&` right after `c` — so the close waits + // for it rather than being refused unseen; nothing in the Wall is + // touched, so it then goes straight through — but the last Workspace + // still cannot be closed. + handleWorkspaceShortcuts(keydown('&'), ctx); + expect(ids()).toEqual([first, 'ws-2']); + registerWallHandle(stubWallHandle(first)); + registerWallHandle(stubWallHandle('ws-2')); + await vi.advanceTimersByTimeAsync(0); + expect(ids()).toEqual([first]); + handleWorkspaceShortcuts(keydown('&'), ctx); + await vi.advanceTimersByTimeAsync(0); + expect(ids()).toHaveLength(1); + } finally { + vi.useRealTimers(); + } }); it('claims the key it handles and leaves every other one alone', () => { diff --git a/lib/src/components/wall/workspace-lifecycle.test.ts b/lib/src/components/wall/workspace-lifecycle.test.ts index 62af1eeb3..5bde31bdf 100644 --- a/lib/src/components/wall/workspace-lifecycle.test.ts +++ b/lib/src/components/wall/workspace-lifecycle.test.ts @@ -7,7 +7,6 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { closeWorkspaceWithSurfaces, LAST_WORKSPACE_REFUSAL, - NO_WALL_REFUSAL, requestWorkspaceClose, } from './workspace-lifecycle'; import { registerWallHandle, resetWallHandles, stubWallHandle, type WallHandle } from './wall-handles'; @@ -106,8 +105,9 @@ describe('closeWorkspaceWithSurfaces', () => { const [first] = ids(); createWorkspace({ id: 'ws-2', activate: false }); // No `handleFor('ws-2')`: nothing would walk its member Surfaces, so - // removing the Workspace would leave them running and unreachable. - expect(await closeWorkspaceWithSurfaces('ws-2', 'silent')).toBe(NO_WALL_REFUSAL); + // removing the Workspace would leave them running and unreachable. The + // wording is the router's, so a `dor` caller reads one refusal either way. + expect(await closeWorkspaceWithSurfaces('ws-2', 'silent')).toBe("workspace 'workspace:2' is still mounting"); expect(ids()).toEqual([first, 'ws-2']); }); @@ -123,3 +123,37 @@ describe('closeWorkspaceWithSurfaces', () => { expect(ids()).toEqual([first]); }); }); + +describe('requestWorkspaceClose', () => { + it('waits out the Wall registration gap, then closes rather than refusing unseen', async () => { + vi.useFakeTimers(); + try { + const [first] = ids(); + createWorkspace({ id: 'ws-2' }); + // The strip's `×` right after a create: the Workspace is in the store, + // its Wall is one passive effect away. A gesture has nobody to hand a + // refusal to, so it waits like the `dor` path instead. + requestWorkspaceClose('ws-2'); + expect(ids()).toEqual([first, 'ws-2']); + const closeAll = vi.fn(async () => null); + handleFor('ws-2', { closeAll }); + + await vi.advanceTimersByTimeAsync(0); + expect(closeAll).toHaveBeenCalledWith('prompt'); + expect(ids()).toEqual([first]); + expect(getWorkspaceUiSnapshot().pendingClose).toBeNull(); + } finally { + vi.useRealTimers(); + } + }); + + it('raises the confirmation once the registered Wall reports work', async () => { + const [first] = ids(); + createWorkspace({ id: 'ws-2' }); + handleFor('ws-2', { runningCount: () => 1 }); + requestWorkspaceClose('ws-2'); + await Promise.resolve(); + expect(getWorkspaceUiSnapshot().pendingClose?.id).toBe('ws-2'); + expect(ids()).toEqual([first, 'ws-2']); + }); +}); diff --git a/lib/src/components/wall/workspace-lifecycle.ts b/lib/src/components/wall/workspace-lifecycle.ts index a699a6361..2036d9029 100644 --- a/lib/src/components/wall/workspace-lifecycle.ts +++ b/lib/src/components/wall/workspace-lifecycle.ts @@ -1,8 +1,9 @@ import { randomKillChar } from '../KillConfirm'; +import { awaitWallHandle, mountingRefusal } from './dor-control-shared'; import { getWallHandle } from './wall-handles'; import { forgetWorkspaceSession } from '../../lib/window-session-aggregator'; import { setPendingWorkspaceClose, setRenamingWorkspace } from '../../lib/workspace-ui-store'; -import { closeWorkspace, getWorkspacesSnapshot, setActiveWorkspace } from '../../lib/workspace-store'; +import { closeWorkspace, getWorkspacesSnapshot, setActiveWorkspace, workspaceRefFor } from '../../lib/workspace-store'; import type { WorkspaceId } from '../../lib/session-types'; import type { CloseSurfaceMode } from './wall-types'; @@ -20,11 +21,12 @@ export function workspaceNeedsCloseConfirmation(id: WorkspaceId): boolean { return !!handle && (handle.hasTouchedSurfaces() || handle.runningCount() > 0); } -/** The three ways a close is turned down before it starts. All leave every - * Surface where it was. */ +/** The ways a close is turned down before it starts — these two, and + * `mountingRefusal` for a Wall that has not registered, the one wording the + * `dor` router uses for the same condition. All leave every Surface where it + * was. */ export const LAST_WORKSPACE_REFUSAL = 'the last Workspace cannot be closed'; const CLOSE_IN_FLIGHT_REFUSAL = 'another Workspace is closing'; -export const NO_WALL_REFUSAL = 'the workspace is still mounting'; /** One close at a time, for the whole Window. Two closes overlapping would each * see the other's Workspace in the count, empty both Walls, and leave the @@ -60,7 +62,7 @@ export async function closeWorkspaceWithSurfaces( // Workspace with nothing in it. if (getWorkspacesSnapshot().workspaces.length <= 1) return LAST_WORKSPACE_REFUSAL; const handle = getWallHandle(id); - if (!handle) return NO_WALL_REFUSAL; + if (!handle) return mountingRefusal(workspaceRefFor(id)); closeInFlight = true; /** Put the user in front of the prompt a refusal left behind — and only then. */ const revealForPrompt = () => { if (mode === 'prompt') setActiveWorkspace(id); }; @@ -91,11 +93,24 @@ export async function closeWorkspaceWithSurfaces( export function requestWorkspaceClose(id: WorkspaceId): void { if (closeInFlight) return; if (getWorkspacesSnapshot().workspaces.length <= 1) return; + void closeOnceWallRegisters(id); +} + +/** + * The Wall is what says whether the Workspace holds work, so a gesture landing + * in the registration gap — the strip's `×` or the `&` key right after a create + * — waits it out, as `dor workspace close` does, rather than deciding on a + * handle that is one effect away and having the close refused where nobody + * reads the refusal (`docs/specs/layout.md` → "Workspaces"). + */ +async function closeOnceWallRegisters(id: WorkspaceId): Promise<void> { + await awaitWallHandle(id); + if (closeInFlight) return; if (workspaceNeedsCloseConfirmation(id)) { setPendingWorkspaceClose({ id, char: randomKillChar() }); return; } - void closeWorkspaceWithSurfaces(id); + await closeWorkspaceWithSurfaces(id); } /** Open the strip's inline rename editor on a Workspace. */ diff --git a/lib/src/lib/mirrored-constants.test.ts b/lib/src/lib/mirrored-constants.test.ts index 37c3ad05a..0c7fe5c16 100644 --- a/lib/src/lib/mirrored-constants.test.ts +++ b/lib/src/lib/mirrored-constants.test.ts @@ -12,7 +12,7 @@ import { PAIRING_CODE_LABEL } from '../remote/pocket-app/App'; import { SCAN_REJECTED_MESSAGE } from '../remote/pocket-app/ScanInvitation'; import { SCAN_LABEL } from '../remote/setup-copy'; import { ITERM2_COMPAT_VERSION } from './terminal-protocol'; -import { OPEN_PORT_TIMEOUT_MS } from './platform/types'; +import { OPEN_PORT_TIMEOUT_MS, OPEN_PORT_TIMEOUT_PER_ID_MS } from './platform/types'; // Pins for constants defined in more than one language/runtime, where an // import is impossible (the sidecar is plain CJS, the Tauri backend is Rust, @@ -230,3 +230,18 @@ describe('OPEN_PORT_TIMEOUT_MS mirrors', () => { expect(Number(ms)).toBe(OPEN_PORT_TIMEOUT_MS); }); }); + +// docs/specs/standalone.md -> "Rust ↔ sidecar bridge" +describe('OPEN_PORT_TIMEOUT_PER_ID_MS mirrors', () => { + it('matches the sidecar copy in standalone/sidecar/pty-core.js', () => { + const file = 'standalone/sidecar/pty-core.js'; + const ms = extract(readRepoFile(file), file, /^const OPEN_PORT_TIMEOUT_PER_ID_MS = (\d+);$/m); + expect(Number(ms)).toBe(OPEN_PORT_TIMEOUT_PER_ID_MS); + }); + + it('matches the Rust copy in standalone/src-tauri/src/lib.rs', () => { + const file = 'standalone/src-tauri/src/lib.rs'; + const ms = extract(readRepoFile(file), file, /^const OPEN_PORT_TIMEOUT_PER_ID_MS: u64 = (\d+);$/m); + expect(Number(ms)).toBe(OPEN_PORT_TIMEOUT_PER_ID_MS); + }); +}); diff --git a/lib/src/lib/platform/types.ts b/lib/src/lib/platform/types.ts index 4bef0c21d..4c484ab1e 100644 --- a/lib/src/lib/platform/types.ts +++ b/lib/src/lib/platform/types.ts @@ -59,6 +59,16 @@ export interface OpenPort { */ export const OPEN_PORT_TIMEOUT_MS = 3000; +/** + * What a batched scan (`getOpenPortsMany`) adds to `OPEN_PORT_TIMEOUT_MS` per + * terminal it covers: the sidecar's socket scan lists every descendant of every + * terminal in one `lsof`, so one terminal's budget cannot be the whole + * Window's. Mirrored as `OPEN_PORT_TIMEOUT_PER_ID_MS` in + * `standalone/sidecar/pty-core.js` and `standalone/src-tauri/src/lib.rs`; + * pinned by `mirrored-constants.test.ts`. + */ +export const OPEN_PORT_TIMEOUT_PER_ID_MS = 100; + export type AlertStateDetail = { id: string } & AlertState; export interface AgentBrowserCommandResult { diff --git a/scripts/spec-word-budgets.json b/scripts/spec-word-budgets.json index 51a2f17ca..344f6b456 100644 --- a/scripts/spec-word-budgets.json +++ b/scripts/spec-word-budgets.json @@ -5,11 +5,11 @@ "docs/specs/alert.md": 6800, "docs/specs/auto-update.md": 1100, "docs/specs/deploy.md": 1900, - "docs/specs/dor-browser.md": 4500, - "docs/specs/dor-cli.md": 5700, + "docs/specs/dor-browser.md": 4600, + "docs/specs/dor-cli.md": 5750, "docs/specs/dor-tool.md": 2100, "docs/specs/glossary.md": 3000, - "docs/specs/layout.md": 8450, + "docs/specs/layout.md": 8500, "docs/specs/mobile-terminal-ui.md": 1950, "docs/specs/mouse-and-clipboard.md": 3750, "docs/specs/notepad.md": 3950, @@ -24,7 +24,7 @@ "docs/specs/security-supply-chain.md": 1150, "docs/specs/security.md": 1900, "docs/specs/shortcuts.md": 1050, - "docs/specs/standalone.md": 8600, + "docs/specs/standalone.md": 8650, "docs/specs/terminal-context.md": 900, "docs/specs/terminal-escapes.md": 3750, "docs/specs/terminal-state.md": 2350, diff --git a/standalone/sidecar/pty-core.js b/standalone/sidecar/pty-core.js index 4e064dfbd..c05dc5e68 100644 --- a/standalone/sidecar/pty-core.js +++ b/standalone/sidecar/pty-core.js @@ -620,6 +620,21 @@ module.exports.getCwdsForPids = getCwdsForPids; const OPEN_PORT_TIMEOUT_MS = 3000; module.exports.OPEN_PORT_TIMEOUT_MS = OPEN_PORT_TIMEOUT_MS; +// Mirrors `OPEN_PORT_TIMEOUT_PER_ID_MS` in `lib/src/lib/platform/types.ts` and +// `standalone/src-tauri/src/lib.rs` — pinned by +// `lib/src/lib/mirrored-constants.test.ts`. A batched scan's socket +// enumeration is capped at `openPortScanTimeoutMs(terminals)`, not the +// per-terminal cap, because its `lsof` argument grows with the batch. +const OPEN_PORT_TIMEOUT_PER_ID_MS = 100; +module.exports.OPEN_PORT_TIMEOUT_PER_ID_MS = OPEN_PORT_TIMEOUT_PER_ID_MS; + +/** Socket-scan budget for one scan covering `count` terminals. The single- + * terminal path (`count` = 1) keeps `OPEN_PORT_TIMEOUT_MS` plus one allowance. */ +function openPortScanTimeoutMs(count) { + return OPEN_PORT_TIMEOUT_MS + OPEN_PORT_TIMEOUT_PER_ID_MS * count; +} +module.exports.openPortScanTimeoutMs = openPortScanTimeoutMs; + /** * Build the set of descendant PIDs (including rootPid) from a flat list of * [pid, ppid] pairs via breadth-first walk. Shared by every platform. @@ -912,17 +927,27 @@ function parseHostPort(token, wildcardFamily = 'IPv4') { function macListeningPorts(pids, runtime = {}) { const execFileSyncFn = runtime.execFileSync || execFileSync; if (pids.length === 0) return []; + let out = ''; try { - const out = execFileSyncFn( + out = execFileSyncFn( 'lsof', ['-nP', '-a', '-iTCP', '-sTCP:LISTEN', '-p', pids.join(','), '-Fpcnt'], - { encoding: 'utf-8', stdio: ['ignore', 'pipe', 'ignore'], timeout: OPEN_PORT_TIMEOUT_MS, windowsHide: true }, + { + encoding: 'utf-8', + stdio: ['ignore', 'pipe', 'ignore'], + timeout: runtime.scanTimeoutMs ?? OPEN_PORT_TIMEOUT_MS, + windowsHide: true, + }, ); - return parseLsofListening(out); - } catch { - // lsof exits non-zero when none of the pids have matching files. - return []; + } catch (err) { + // lsof exits non-zero when ANY requested pid is gone (or has no matching + // files) and still prints the ones it did resolve — the same trap + // `getCwdsForPids` documents. A batched scan covers every descendant of + // every terminal, so a child exiting between `ps` and `lsof` would + // otherwise empty the whole Window's listing. + out = typeof err?.stdout === 'string' ? err.stdout : (err?.stdout?.toString('utf-8') ?? ''); } + return parseLsofListening(out); } /** ConvertTo-Json emits a bare object (not an array) for a single row. */ @@ -1093,7 +1118,9 @@ function getOpenPortsForPids(rootPids, runtime = {}) { const union = new Set(); for (const pids of owned.values()) for (const pid of pids) union.add(pid); - const ports = getListeningPortsForPids([...union], runtime); + // The socket scan's budget scales with the batch; the process-table read + // above does not, its cost being the whole table either way. + const ports = getListeningPortsForPids([...union], { ...runtime, scanTimeoutMs: openPortScanTimeoutMs(roots.length) }); for (const [root, pids] of owned) { byRoot.set(root, dedupeListeningPorts(ports.filter((entry) => pids.has(entry.pid)))); } diff --git a/standalone/sidecar/pty-core.test.js b/standalone/sidecar/pty-core.test.js index 3ec558cb3..a0ca576a4 100644 --- a/standalone/sidecar/pty-core.test.js +++ b/standalone/sidecar/pty-core.test.js @@ -25,6 +25,9 @@ const { getListeningPortsForPids, getOpenPortsForPid, getOpenPortsForPids, + openPortScanTimeoutMs, + OPEN_PORT_TIMEOUT_MS, + OPEN_PORT_TIMEOUT_PER_ID_MS, } = require('./pty-core'); test('resolveSpawnConfig uses POSIX shell and home defaults', () => { @@ -1426,6 +1429,20 @@ test('getListeningPortsForPids (darwin) runs lsof with the descendant pid list', ]); }); +test('getListeningPortsForPids (darwin) keeps the live pids when lsof exits non-zero over a dead one', () => { + // A batched scan covers every descendant of every terminal, so one child + // exiting between `ps` and `lsof` is the common case, not the odd one; the + // stdout lsof printed before its non-zero exit is the answer. + const execFileSync = () => { + const err = new Error('lsof exited 1'); + err.status = 1; + err.stdout = ['p4242', 'cnode', 'tIPv4', 'n*:3000', ''].join('\n'); + throw err; + }; + const ports = getListeningPortsForPids([100, 4242, 999_999], { platform: 'darwin', execFileSync }); + assert.deepEqual(ports.map((p) => [p.pid, p.port]), [[4242, 3000]]); +}); + test('getListeningPortsForPids (win32) prefers Get-NetTCPConnection', () => { const execFileSync = (cmd, args) => { assert.equal(cmd, 'powershell.exe'); @@ -1488,11 +1505,14 @@ test('getOpenPortsForPids answers per root pid from ONE process table and ONE so // Two terminals, each with a child serving a port. A listing that spans them // must not pay for a `ps` + `lsof` pair per terminal. const spawns = []; - const execFileSync = (cmd, args) => { + const execFileSync = (cmd, args, options) => { spawns.push(cmd); if (cmd === 'ps') return '100 1\n200 100\n300 1\n400 300\n'; if (cmd === 'lsof') { assert.ok(args.includes('100,200,300,400'), `one scan over every descendant: ${args.join(' ')}`); + // The socket scan is budgeted for the batch, not for one terminal. + assert.equal(options.timeout, openPortScanTimeoutMs(2)); + assert.equal(options.timeout, OPEN_PORT_TIMEOUT_MS + 2 * OPEN_PORT_TIMEOUT_PER_ID_MS); return [ 'p200', 'cnode', 'tIPv4', 'n*:3000', 'p400', 'cnode', 'tIPv4', 'n*:5173', diff --git a/standalone/src-tauri/src/lib.rs b/standalone/src-tauri/src/lib.rs index 2c376a22a..2cd52c60e 100644 --- a/standalone/src-tauri/src/lib.rs +++ b/standalone/src-tauri/src/lib.rs @@ -1064,6 +1064,19 @@ fn pty_get_cwds( // `lib/src/lib/mirrored-constants.test.ts`. const OPEN_PORT_TIMEOUT_MS: u64 = 3000; +// Mirrors `OPEN_PORT_TIMEOUT_PER_ID_MS` in `lib/src/lib/platform/types.ts` — +// pinned by `lib/src/lib/mirrored-constants.test.ts`. +const OPEN_PORT_TIMEOUT_PER_ID_MS: u64 = 100; + +/// Budget for one `pty:getOpenPortsMany` over `count` ids. The sidecar runs two +/// scans serially: the process table under `OPEN_PORT_TIMEOUT_MS`, then one +/// socket scan under that cap plus `OPEN_PORT_TIMEOUT_PER_ID_MS` per id +/// (`getOpenPortsForPids` in `standalone/sidecar/pty-core.js`) — so the whole +/// Window is not held to one terminal's budget, and the reply outlasts both. +fn open_ports_many_timeout(count: usize) -> Duration { + Duration::from_millis(2 * OPEN_PORT_TIMEOUT_MS + OPEN_PORT_TIMEOUT_PER_ID_MS * count as u64) +} + #[tauri::command(async)] fn pty_get_open_ports( state: tauri::State<'_, SidecarState>, @@ -1090,11 +1103,12 @@ fn pty_get_open_ports_many( state: tauri::State<'_, SidecarState>, ids: Vec<String>, ) -> Result<JsonValue, String> { + let timeout = open_ports_many_timeout(ids.len()); let response = request_from_sidecar_timeout( &state, "pty:getOpenPortsMany", serde_json::json!({ "ids": ids }), - Duration::from_millis(OPEN_PORT_TIMEOUT_MS), + timeout, )?; Ok(response .get("ports") @@ -3635,11 +3649,12 @@ pub fn run() { #[cfg(test)] mod tests { use super::{ - find_node_binary, notepad_archive_lock_path, read_notepad_archive_from, read_session_from, - reset_notepad_archive_at, resolve_dor_cli_paths, resolve_sidecar_path, session_file_name, - state_root_from, strip_windows_verbatim_prefix, sweep_orphan_session_temps, - temp_write_path, write_notepad_archive_to, write_session_to, SESSION_TEMP_SUFFIX, - NOTEPAD_ARCHIVE_FILE, + find_node_binary, notepad_archive_lock_path, open_ports_many_timeout, + read_notepad_archive_from, read_session_from, reset_notepad_archive_at, + resolve_dor_cli_paths, resolve_sidecar_path, session_file_name, state_root_from, + strip_windows_verbatim_prefix, sweep_orphan_session_temps, temp_write_path, + write_notepad_archive_to, write_session_to, NOTEPAD_ARCHIVE_FILE, + OPEN_PORT_TIMEOUT_MS, OPEN_PORT_TIMEOUT_PER_ID_MS, SESSION_TEMP_SUFFIX, }; use super::guard; use std::collections::HashSet; @@ -3649,6 +3664,16 @@ mod tests { use std::sync::Mutex; use std::time::{SystemTime, UNIX_EPOCH}; + /// A Window-wide port listing is budgeted for its batch: both of the + /// sidecar's serial scans, plus the per-id allowance the socket scan gets. + #[test] + fn open_ports_many_timeout_scales_with_the_batch() { + let one = open_ports_many_timeout(1).as_millis() as u64; + let twenty = open_ports_many_timeout(20).as_millis() as u64; + assert_eq!(one, 2 * OPEN_PORT_TIMEOUT_MS + OPEN_PORT_TIMEOUT_PER_ID_MS); + assert_eq!(twenty - one, 19 * OPEN_PORT_TIMEOUT_PER_ID_MS); + } + // RAII guard so a failing assert doesn't leak the temp dir. struct TempDir(PathBuf); impl TempDir { From 8b771bb04b130459af85f468a0910ce5ab0716b9 Mon Sep 17 00:00:00 2001 From: Ned Twigg <ned.twigg@diffplug.com> Date: Thu, 10 Sep 2026 18:24:47 -0700 Subject: [PATCH 10/13] Match port scan deadlines to both scans and correct scrub test --- docs/specs/standalone.md | 5 +++-- dor-lib-common/test/agent-browser.test.mjs | 2 +- scripts/spec-word-budgets.json | 4 ++-- standalone/src-tauri/src/lib.rs | 8 ++++---- 4 files changed, 10 insertions(+), 9 deletions(-) diff --git a/docs/specs/standalone.md b/docs/specs/standalone.md index 16d8c890d..d5042b6ff 100644 --- a/docs/specs/standalone.md +++ b/docs/specs/standalone.md @@ -673,9 +673,10 @@ adapters carry it, and the sidecar answers every id from one process-table read and one socket scan (`getOpenPortsForPids`) — the scans are synchronous on its only event loop, so a `dor list --ports` across Workspaces must not multiply them by its row count (`docs/specs/dor-cli.md` → "Current Implemented Commands"). -**Its budget scales with the batch**: the socket scan runs under +**Must budget both port commands for the serial scans plus 1000 ms for IPC.** +The macOS socket scan runs under `OPEN_PORT_TIMEOUT_MS + OPEN_PORT_TIMEOUT_PER_ID_MS × ids`, and the command waits -that plus the process-table read's `OPEN_PORT_TIMEOUT_MS` — one terminal's cap +that plus the process-table read's `OPEN_PORT_TIMEOUT_MS` and IPC margin — one terminal's cap never bounds the whole Window (`open_ports_many_timeout` in `standalone/src-tauri/src/lib.rs`). **A macOS socket scan keeps the rows `lsof` printed before a non-zero exit** — a pid gone mid-batch would otherwise empty diff --git a/dor-lib-common/test/agent-browser.test.mjs b/dor-lib-common/test/agent-browser.test.mjs index 1dddb5dec..2a71c15e5 100644 --- a/dor-lib-common/test/agent-browser.test.mjs +++ b/dor-lib-common/test/agent-browser.test.mjs @@ -11,7 +11,7 @@ test('sessionForKey namespaces a key under the workspace', () => { test('sessionForKey scrubs the key like the scope: a session name is a socket path', () => { // The key crosses the control socket from any client, not only `dor` (which // rejects this shape itself), so it cannot be allowed to escape the socket dir. - assert.equal(sessionForKey('../../../tmp/x', 'ws/1'), 'dormouse.ws-1.-.-.-.-tmp-x'); + assert.equal(sessionForKey('../../../tmp/x', 'ws/1'), 'dormouse.ws-1...-..-..-tmp-x'); // A valid key is unchanged. assert.equal(sessionForKey('a.b_c-D9', 'ws'), 'dormouse.ws.a.b_c-D9'); }); diff --git a/scripts/spec-word-budgets.json b/scripts/spec-word-budgets.json index 53dc3cdb9..6ea08fd5e 100644 --- a/scripts/spec-word-budgets.json +++ b/scripts/spec-word-budgets.json @@ -9,7 +9,7 @@ "docs/specs/dor-cli.md": 5750, "docs/specs/dor-tool.md": 2100, "docs/specs/glossary.md": 3000, - "docs/specs/layout.md": 8500, + "docs/specs/layout.md": 8550, "docs/specs/mobile-terminal-ui.md": 1950, "docs/specs/mouse-and-clipboard.md": 3750, "docs/specs/notepad.md": 3950, @@ -24,7 +24,7 @@ "docs/specs/security-supply-chain.md": 1150, "docs/specs/security.md": 1900, "docs/specs/shortcuts.md": 1050, - "docs/specs/standalone.md": 8950, + "docs/specs/standalone.md": 9000, "docs/specs/terminal-context.md": 900, "docs/specs/terminal-escapes.md": 3750, "docs/specs/terminal-state.md": 2350, diff --git a/standalone/src-tauri/src/lib.rs b/standalone/src-tauri/src/lib.rs index 8c221aa45..0f6e93325 100644 --- a/standalone/src-tauri/src/lib.rs +++ b/standalone/src-tauri/src/lib.rs @@ -1072,13 +1072,13 @@ const OPEN_PORT_TIMEOUT_MS: u64 = 3000; // pinned by `lib/src/lib/mirrored-constants.test.ts`. const OPEN_PORT_TIMEOUT_PER_ID_MS: u64 = 100; -/// Budget for one `pty:getOpenPortsMany` over `count` ids. The sidecar runs two +/// Budget for either port command over `count` ids, including 1 s for IPC. The sidecar runs two /// scans serially: the process table under `OPEN_PORT_TIMEOUT_MS`, then one /// socket scan under that cap plus `OPEN_PORT_TIMEOUT_PER_ID_MS` per id /// (`getOpenPortsForPids` in `standalone/sidecar/pty-core.js`) — so the whole /// Window is not held to one terminal's budget, and the reply outlasts both. fn open_ports_many_timeout(count: usize) -> Duration { - Duration::from_millis(2 * OPEN_PORT_TIMEOUT_MS + OPEN_PORT_TIMEOUT_PER_ID_MS * count as u64) + Duration::from_millis(2 * OPEN_PORT_TIMEOUT_MS + OPEN_PORT_TIMEOUT_PER_ID_MS * count as u64 + 1000) } #[tauri::command(async)] @@ -1090,7 +1090,7 @@ fn pty_get_open_ports( &state, "pty:getOpenPorts", serde_json::json!({ "id": id }), - Duration::from_millis(OPEN_PORT_TIMEOUT_MS), + open_ports_many_timeout(1), )?; Ok(response .get("ports") @@ -3699,7 +3699,7 @@ mod tests { fn open_ports_many_timeout_scales_with_the_batch() { let one = open_ports_many_timeout(1).as_millis() as u64; let twenty = open_ports_many_timeout(20).as_millis() as u64; - assert_eq!(one, 2 * OPEN_PORT_TIMEOUT_MS + OPEN_PORT_TIMEOUT_PER_ID_MS); + assert_eq!(one, 2 * OPEN_PORT_TIMEOUT_MS + OPEN_PORT_TIMEOUT_PER_ID_MS + 1000); assert_eq!(twenty - one, 19 * OPEN_PORT_TIMEOUT_PER_ID_MS); } From 784a1237a64ad449cfbdf8c0efd342a0d38355e9 Mon Sep 17 00:00:00 2001 From: Ned Twigg <ned.twigg@diffplug.com> Date: Thu, 10 Sep 2026 18:51:21 -0700 Subject: [PATCH 11/13] Budget both VS Code port-scan boundaries for the shared scanner --- docs/specs/standalone.md | 2 +- docs/specs/transport.md | 12 +++++++++++ lib/src/lib/mirrored-constants.test.ts | 11 ++++++++++- lib/src/lib/platform/types.ts | 22 +++++++++++---------- lib/src/lib/platform/vscode-adapter.test.ts | 16 +++++++++++++++ lib/src/lib/platform/vscode-adapter.ts | 4 ++-- scripts/spec-word-budgets.json | 2 +- standalone/src-tauri/src/lib.rs | 9 ++++++--- vscode-ext/src/pty-manager.ts | 4 ++-- vscode-ext/test/pty-manager.test.ts | 12 +++++++++++ 10 files changed, 74 insertions(+), 20 deletions(-) diff --git a/docs/specs/standalone.md b/docs/specs/standalone.md index d5042b6ff..e9bf06932 100644 --- a/docs/specs/standalone.md +++ b/docs/specs/standalone.md @@ -673,7 +673,7 @@ adapters carry it, and the sidecar answers every id from one process-table read and one socket scan (`getOpenPortsForPids`) — the scans are synchronous on its only event loop, so a `dor list --ports` across Workspaces must not multiply them by its row count (`docs/specs/dor-cli.md` → "Current Implemented Commands"). -**Must budget both port commands for the serial scans plus 1000 ms for IPC.** +**Must follow `docs/specs/transport.md` → "Port scan deadlines" for both port commands.** The macOS socket scan runs under `OPEN_PORT_TIMEOUT_MS + OPEN_PORT_TIMEOUT_PER_ID_MS × ids`, and the command waits that plus the process-table read's `OPEN_PORT_TIMEOUT_MS` and IPC margin — one terminal's cap diff --git a/docs/specs/transport.md b/docs/specs/transport.md index 46a7ae497..f338516c2 100644 --- a/docs/specs/transport.md +++ b/docs/specs/transport.md @@ -304,6 +304,18 @@ prompt** (rationale). Source of truth: `getScrollbackReceived` / `getScrollbackSince` in `vscode-ext/src/pty-manager.ts`; the replay filter in `lib/src/lib/terminal-report-filter.ts`. +## Port scan deadlines + +**Must budget port requests for both serial scans and an IPC margin per hop**: +`2 × OPEN_PORT_TIMEOUT_MS + count × OPEN_PORT_TIMEOUT_PER_ID_MS + hops × OPEN_PORT_ROUND_TRIP_MARGIN_MS`. +VS Code's child request uses one hop; its webview request uses two. Tauri's +sidecar request uses one. Pinned by the port-deadline tests in +`lib/src/lib/platform/vscode-adapter.test.ts`, `vscode-ext/test/pty-manager.test.ts`, +and `lib/src/lib/mirrored-constants.test.ts`. + +Source of truth: `openPortRequestTimeoutMs` in `lib/src/lib/platform/types.ts`; +`open_ports_many_timeout` in `standalone/src-tauri/src/lib.rs`. + ## Auxiliary helper metadata **Must carry helper parent identity and captured autorun command in live PTY metadata**, validating that the parent is owned and is not itself a helper. Promotion clears that association without restarting the PTY. Reconnect restores helper entries before reconciling the primary layout, excluding them from ordinary orphan-pane recovery. A missing parent recovers its helper as an ordinary Pane. Recovered helpers conservatively disable automatic refresh. diff --git a/lib/src/lib/mirrored-constants.test.ts b/lib/src/lib/mirrored-constants.test.ts index a8e715733..3e7ca20af 100644 --- a/lib/src/lib/mirrored-constants.test.ts +++ b/lib/src/lib/mirrored-constants.test.ts @@ -12,7 +12,7 @@ import { PAIRING_CODE_LABEL } from '../remote/pocket-app/App'; import { SCAN_REJECTED_MESSAGE } from '../remote/pocket-app/ScanInvitation'; import { SCAN_LABEL } from '../remote/setup-copy'; import { ITERM2_COMPAT_VERSION } from './terminal-protocol'; -import { OPEN_PORT_TIMEOUT_MS, OPEN_PORT_TIMEOUT_PER_ID_MS } from './platform/types'; +import { OPEN_PORT_TIMEOUT_MS, OPEN_PORT_TIMEOUT_PER_ID_MS, OPEN_PORT_ROUND_TRIP_MARGIN_MS } from './platform/types'; import { DEFAULT_RECOVERY_WAIT_MS } from '../host/recovery-capture'; // Pins for constants defined in more than one language/runtime, where an @@ -293,3 +293,12 @@ describe('quit teardown budget mirrors', () => { }, ); }); + + +describe('port request IPC margin', () => { + it('matches the Rust boundary margin', () => { + const file = 'standalone/src-tauri/src/lib.rs'; + const ms = extract(readRepoFile(file), file, /^const OPEN_PORT_ROUND_TRIP_MARGIN_MS: u64 = (\d+);$/m); + expect(Number(ms)).toBe(OPEN_PORT_ROUND_TRIP_MARGIN_MS); + }); +}); diff --git a/lib/src/lib/platform/types.ts b/lib/src/lib/platform/types.ts index 4c484ab1e..72c866723 100644 --- a/lib/src/lib/platform/types.ts +++ b/lib/src/lib/platform/types.ts @@ -47,16 +47,9 @@ export interface OpenPort { processName?: string; } -/** - * End-to-end budget for `getOpenPorts()` at every transport boundary - * (webview → host adapter, host → pty-host child, Tauri command → sidecar) and - * for the per-subprocess execs inside `getOpenPortsForPid()` (lsof, PowerShell, - * `Get-NetTCPConnection`, `netstat`). Wider than the 1 s cwd query because - * enumeration shells out on macOS/Windows; tight enough to fail visibly rather - * than hang a pane header. Mirrored as `OPEN_PORT_TIMEOUT_MS` in - * `standalone/sidecar/pty-core.js` and `standalone/src-tauri/src/lib.rs`; - * pinned by `mirrored-constants.test.ts`. - */ +/** Base subprocess scan budget. The macOS socket scan adds a per-id allowance; + * transport deadlines cover both serial scans plus a margin per IPC hop. + * Rust and sidecar copies are pinned by `mirrored-constants.test.ts`. */ export const OPEN_PORT_TIMEOUT_MS = 3000; /** @@ -69,6 +62,15 @@ export const OPEN_PORT_TIMEOUT_MS = 3000; */ export const OPEN_PORT_TIMEOUT_PER_ID_MS = 100; +/** Margin for each transport hop, mirrored in Rust and pinned by the constants test. */ +export const OPEN_PORT_ROUND_TRIP_MARGIN_MS = 1000; + +export function openPortRequestTimeoutMs(count: number, hops = 1): number { + return 2 * OPEN_PORT_TIMEOUT_MS + OPEN_PORT_TIMEOUT_PER_ID_MS * count + + OPEN_PORT_ROUND_TRIP_MARGIN_MS * hops; +} + + export type AlertStateDetail = { id: string } & AlertState; export interface AgentBrowserCommandResult { diff --git a/lib/src/lib/platform/vscode-adapter.test.ts b/lib/src/lib/platform/vscode-adapter.test.ts index 4dd75f392..3cd6350ea 100644 --- a/lib/src/lib/platform/vscode-adapter.test.ts +++ b/lib/src/lib/platform/vscode-adapter.test.ts @@ -667,3 +667,19 @@ describe('VSCodeAdapter remote host link', () => { } }); }); + + +describe('VSCodeAdapter port deadline', () => { + beforeEach(stubWebviewEnv); + afterEach(() => { vi.unstubAllGlobals(); vi.useRealTimers(); }); + it('allows both scans and the child hop before the host reply', async () => { + vi.useFakeTimers(); + const adapter = new VSCodeAdapter(); + const answer = adapter.getOpenPorts('pane-1'); + const request = postMessage.mock.calls.at(-1)![0]; + await vi.advanceTimersByTimeAsync(7500); + const ports = [{ address: '127.0.0.1', port: 5173, pid: 1 }]; + windowTarget.dispatchEvent(hostMessage({ type: 'pty:openPorts', requestId: request.requestId, ports })); + expect(await answer).toEqual(ports); + }); +}); diff --git a/lib/src/lib/platform/vscode-adapter.ts b/lib/src/lib/platform/vscode-adapter.ts index 00fbf0318..3c47096a8 100644 --- a/lib/src/lib/platform/vscode-adapter.ts +++ b/lib/src/lib/platform/vscode-adapter.ts @@ -1,6 +1,6 @@ import type { HelperIdentity, TerminalContextRequest, TerminalContextInfo } from '../terminal-context-types'; import type { AgentBrowserCommandResult, AgentBrowserEditOp, AgentBrowserEditResult, AgentBrowserOpenResult, AgentBrowserPopResult, AgentBrowserScreenshotResult, AgentBrowserStreamStatusResult, AlertStateDetail, IframeProxyResult, OpenPort, PlatformAdapter, PtyDataDetail, PtyInfo, BurrowLink } from './types'; -import { OPEN_PORT_TIMEOUT_MS } from './types'; +import { openPortRequestTimeoutMs } from './types'; import { createBurrowLinkClient } from '../../host/remote/link-client'; import type { AwaitHandle, AwaitOptions, AwaitOutcome } from '../alert-manager'; import type { AlertSettings } from '../alert-settings'; @@ -363,7 +363,7 @@ export class VSCodeAdapter implements PlatformAdapter { const result = await this.requestResponse<OpenPort[]>( 'pty:getOpenPorts', 'pty:openPorts', { id }, (msg) => msg.ports as OpenPort[], - OPEN_PORT_TIMEOUT_MS, + openPortRequestTimeoutMs(1, 2), ); return result ?? []; } diff --git a/scripts/spec-word-budgets.json b/scripts/spec-word-budgets.json index 6ea08fd5e..07f9df58f 100644 --- a/scripts/spec-word-budgets.json +++ b/scripts/spec-word-budgets.json @@ -30,7 +30,7 @@ "docs/specs/terminal-state.md": 2350, "docs/specs/theme.md": 2150, "docs/specs/tiling-engine.md": 4500, - "docs/specs/transport.md": 5350, + "docs/specs/transport.md": 5450, "docs/specs/tutorial.md": 1900, "docs/specs/vscode.md": 7500, "docs/specs/webgl-text.md": 1200, diff --git a/standalone/src-tauri/src/lib.rs b/standalone/src-tauri/src/lib.rs index 0f6e93325..04e1251ae 100644 --- a/standalone/src-tauri/src/lib.rs +++ b/standalone/src-tauri/src/lib.rs @@ -1072,13 +1072,16 @@ const OPEN_PORT_TIMEOUT_MS: u64 = 3000; // pinned by `lib/src/lib/mirrored-constants.test.ts`. const OPEN_PORT_TIMEOUT_PER_ID_MS: u64 = 100; +// Mirrors platform/types.ts; pinned by mirrored-constants.test.ts. +const OPEN_PORT_ROUND_TRIP_MARGIN_MS: u64 = 1000; + /// Budget for either port command over `count` ids, including 1 s for IPC. The sidecar runs two /// scans serially: the process table under `OPEN_PORT_TIMEOUT_MS`, then one /// socket scan under that cap plus `OPEN_PORT_TIMEOUT_PER_ID_MS` per id /// (`getOpenPortsForPids` in `standalone/sidecar/pty-core.js`) — so the whole /// Window is not held to one terminal's budget, and the reply outlasts both. fn open_ports_many_timeout(count: usize) -> Duration { - Duration::from_millis(2 * OPEN_PORT_TIMEOUT_MS + OPEN_PORT_TIMEOUT_PER_ID_MS * count as u64 + 1000) + Duration::from_millis(2 * OPEN_PORT_TIMEOUT_MS + OPEN_PORT_TIMEOUT_PER_ID_MS * count as u64 + OPEN_PORT_ROUND_TRIP_MARGIN_MS) } #[tauri::command(async)] @@ -3683,7 +3686,7 @@ mod tests { resolve_dor_cli_paths, resolve_sidecar_path, session_file_name, state_root_from, strip_windows_verbatim_prefix, sweep_orphan_session_temps, temp_write_path, write_notepad_archive_to, write_session_to, NOTEPAD_ARCHIVE_FILE, - OPEN_PORT_TIMEOUT_MS, OPEN_PORT_TIMEOUT_PER_ID_MS, SESSION_TEMP_SUFFIX, + OPEN_PORT_TIMEOUT_MS, OPEN_PORT_TIMEOUT_PER_ID_MS, OPEN_PORT_ROUND_TRIP_MARGIN_MS, SESSION_TEMP_SUFFIX, }; use super::guard; use std::collections::HashSet; @@ -3699,7 +3702,7 @@ mod tests { fn open_ports_many_timeout_scales_with_the_batch() { let one = open_ports_many_timeout(1).as_millis() as u64; let twenty = open_ports_many_timeout(20).as_millis() as u64; - assert_eq!(one, 2 * OPEN_PORT_TIMEOUT_MS + OPEN_PORT_TIMEOUT_PER_ID_MS + 1000); + assert_eq!(one, 2 * OPEN_PORT_TIMEOUT_MS + OPEN_PORT_TIMEOUT_PER_ID_MS + OPEN_PORT_ROUND_TRIP_MARGIN_MS); assert_eq!(twenty - one, 19 * OPEN_PORT_TIMEOUT_PER_ID_MS); } diff --git a/vscode-ext/src/pty-manager.ts b/vscode-ext/src/pty-manager.ts index fdc6ad2e1..431a4aff5 100644 --- a/vscode-ext/src/pty-manager.ts +++ b/vscode-ext/src/pty-manager.ts @@ -6,7 +6,7 @@ import { randomBytes } from 'crypto'; import { log } from './log'; import type { DorControlCancelPayload, DorControlRequestPayload, DorControlResponsePayload } from '../../dor/src/protocol'; import type { OpenPort } from '../../lib/src/lib/platform/types'; -import { OPEN_PORT_TIMEOUT_MS } from '../../lib/src/lib/platform/types'; +import { openPortRequestTimeoutMs } from '../../lib/src/lib/platform/types'; import { sliceSince } from '../../lib/src/host/replay-buffer'; export interface PtyCallbacks { @@ -439,7 +439,7 @@ export function getCwd(id: string): Promise<string | null> { } export function getOpenPorts(id: string): Promise<OpenPort[]> { - return requestChild<{ ports?: OpenPort[] }>({ type: 'getOpenPorts', id }, (msg) => msg.type === 'openPorts' && msg.id === id, OPEN_PORT_TIMEOUT_MS) + return requestChild<{ ports?: OpenPort[] }>({ type: 'getOpenPorts', id }, (msg) => msg.type === 'openPorts' && msg.id === id, openPortRequestTimeoutMs(1)) .then((msg) => msg.ports || [], () => []); } diff --git a/vscode-ext/test/pty-manager.test.ts b/vscode-ext/test/pty-manager.test.ts index 6cf766dbc..7837cc0d8 100644 --- a/vscode-ext/test/pty-manager.test.ts +++ b/vscode-ext/test/pty-manager.test.ts @@ -28,6 +28,18 @@ describe('PTY manager lifetime and buffers', () => { vi.clearAllMocks(); }); + it('waits for both serial port scans before timing out the child', async () => { + vi.useFakeTimers(); + try { + const { manager, child } = await startManager(); + const answer = manager.getOpenPorts('pane-a'); + await vi.advanceTimersByTimeAsync(6500); + const ports = [{ address: '127.0.0.1', port: 5173, pid: 1 }]; + child.emit('message', { type: 'openPorts', id: 'pane-a', ports }); + expect(await answer).toEqual(ports); + } finally { vi.useRealTimers(); } + }); + it('caps even a single oversized output chunk and retains absolute stream positions', async () => { const { manager, child } = await startManager(); const data = 'prefix' + 'x'.repeat(1_000_000); From 28d4c32d952c0062708e35bb6d32ac208e27c85f Mon Sep 17 00:00:00 2001 From: Ned Twigg <ned.twigg@diffplug.com> Date: Thu, 10 Sep 2026 19:14:10 -0700 Subject: [PATCH 12/13] Bound Windows port query fallbacks by one socket scan deadline --- docs/specs/standalone.md | 2 +- docs/specs/transport.md | 5 +++- lib/src/lib/platform/types.ts | 2 +- standalone/sidecar/pty-core.js | 11 ++++++- standalone/sidecar/pty-core.test.js | 45 +++++++++++++++++++++++++++++ 5 files changed, 61 insertions(+), 4 deletions(-) diff --git a/docs/specs/standalone.md b/docs/specs/standalone.md index e9bf06932..8512e6a3a 100644 --- a/docs/specs/standalone.md +++ b/docs/specs/standalone.md @@ -674,7 +674,7 @@ and one socket scan (`getOpenPortsForPids`) — the scans are synchronous on its only event loop, so a `dor list --ports` across Workspaces must not multiply them by its row count (`docs/specs/dor-cli.md` → "Current Implemented Commands"). **Must follow `docs/specs/transport.md` → "Port scan deadlines" for both port commands.** -The macOS socket scan runs under +The macOS and Windows socket scans run under `OPEN_PORT_TIMEOUT_MS + OPEN_PORT_TIMEOUT_PER_ID_MS × ids`, and the command waits that plus the process-table read's `OPEN_PORT_TIMEOUT_MS` and IPC margin — one terminal's cap never bounds the whole Window (`open_ports_many_timeout` in diff --git a/docs/specs/transport.md b/docs/specs/transport.md index f338516c2..427246a30 100644 --- a/docs/specs/transport.md +++ b/docs/specs/transport.md @@ -309,7 +309,10 @@ Source of truth: `getScrollbackReceived` / `getScrollbackSince` in `vscode-ext/s **Must budget port requests for both serial scans and an IPC margin per hop**: `2 × OPEN_PORT_TIMEOUT_MS + count × OPEN_PORT_TIMEOUT_PER_ID_MS + hops × OPEN_PORT_ROUND_TRIP_MARGIN_MS`. VS Code's child request uses one hop; its webview request uses two. Tauri's -sidecar request uses one. Pinned by the port-deadline tests in +sidecar request uses one. **Must share the Windows socket-scan allowance across +name lookup, `Get-NetTCPConnection`, and the `netstat` fallback**, reducing each +subprocess timeout by elapsed time and starting none after exhaustion. Pinned by +the Windows budget tests in `standalone/sidecar/pty-core.test.js` and by the port-deadline tests in `lib/src/lib/platform/vscode-adapter.test.ts`, `vscode-ext/test/pty-manager.test.ts`, and `lib/src/lib/mirrored-constants.test.ts`. diff --git a/lib/src/lib/platform/types.ts b/lib/src/lib/platform/types.ts index 72c866723..a3d79cc4f 100644 --- a/lib/src/lib/platform/types.ts +++ b/lib/src/lib/platform/types.ts @@ -47,7 +47,7 @@ export interface OpenPort { processName?: string; } -/** Base subprocess scan budget. The macOS socket scan adds a per-id allowance; +/** Base scan budget. The macOS and Windows socket scans add a per-id allowance; * transport deadlines cover both serial scans plus a margin per IPC hop. * Rust and sidecar copies are pinned by `mirrored-constants.test.ts`. */ export const OPEN_PORT_TIMEOUT_MS = 3000; diff --git a/standalone/sidecar/pty-core.js b/standalone/sidecar/pty-core.js index c05dc5e68..2428b492a 100644 --- a/standalone/sidecar/pty-core.js +++ b/standalone/sidecar/pty-core.js @@ -1026,7 +1026,16 @@ function runPowerShellJson(script, execFileSyncFn) { } function windowsListeningPorts(pids, runtime = {}) { - const execFileSyncFn = runtime.execFileSync || execFileSync; + const spawn = runtime.execFileSync || execFileSync; + const now = runtime.now || (() => performance.now()); + const deadline = now() + (runtime.scanTimeoutMs ?? OPEN_PORT_TIMEOUT_MS); + // Name resolution, the preferred cmdlet, and netstat share one socket-scan + // budget. Each fallback spends only what the preceding subprocesses left. + const execFileSyncFn = (command, args, options) => { + const remaining = Math.ceil(deadline - now()); + if (remaining <= 0) throw new Error('port scan deadline exhausted'); + return spawn(command, args, { ...options, timeout: remaining }); + }; const pidSet = new Set(pids); // Resolve pid -> process name once (best-effort; ports still returned without). diff --git a/standalone/sidecar/pty-core.test.js b/standalone/sidecar/pty-core.test.js index a0ca576a4..959be9a65 100644 --- a/standalone/sidecar/pty-core.test.js +++ b/standalone/sidecar/pty-core.test.js @@ -1479,6 +1479,51 @@ test('getListeningPortsForPids (win32) falls back to netstat when the cmdlet fai ]); }); +test('Windows port subprocesses share the socket budget, including netstat fallback', () => { + let now = 0; + const timeouts = []; + const execFileSync = (cmd, args, options) => { + timeouts.push(options.timeout); + const script = args.at(-1); + if (script.includes('ParentProcessId')) { + now += OPEN_PORT_TIMEOUT_MS; + return JSON.stringify([{ ProcessId: 4242, ParentProcessId: 1 }]); + } + if (script.includes('Win32_Process')) { + now += 500; + return JSON.stringify([{ ProcessId: 4242, Name: 'node.exe' }]); + } + if (script.includes('Get-NetTCPConnection')) { + now += 1000; + throw new Error('cmdlet failed'); + } + assert.equal(cmd, 'netstat'); + now += options.timeout; + return ' TCP 0.0.0.0:3000 0.0.0.0:0 LISTENING 4242\n'; + }; + const result = getOpenPortsForPids([4242], { platform: 'win32', execFileSync, now: () => now }); + const budget = openPortScanTimeoutMs(1); + assert.deepEqual(timeouts, [OPEN_PORT_TIMEOUT_MS, budget, budget - 500, budget - 1500]); + assert.equal(now, OPEN_PORT_TIMEOUT_MS + budget); + assert.equal(result.get(4242)[0].processName, 'node.exe'); +}); + +test('Windows does not start netstat after the socket deadline expires', () => { + let now = 0; + const commands = []; + const execFileSync = (cmd, args, options) => { + commands.push(cmd); + if (args.at(-1).includes('Win32_Process')) return '[]'; + now += options.timeout; + throw new Error('timed out'); + }; + assert.deepEqual(getListeningPortsForPids([4242], { + platform: 'win32', execFileSync, now: () => now, scanTimeoutMs: 3100, + }), []); + assert.deepEqual(commands, ['powershell.exe', 'powershell.exe']); + assert.equal(now, 3100); +}); + test('getOpenPortsForPid de-duplicates and sorts by port', () => { // darwin path: lsof returns a duplicate (same family/addr/port) plus an // out-of-order pair to exercise sorting. From 99161e19e48fd0ddd99fda410fd3184d785f196c Mon Sep 17 00:00:00 2001 From: Ned Twigg <ned.twigg@diffplug.com> Date: Thu, 10 Sep 2026 19:31:48 -0700 Subject: [PATCH 13/13] Prioritize Windows port enumeration over optional process names --- docs/specs/transport.md | 5 +-- scripts/spec-word-budgets.json | 2 +- standalone/sidecar/pty-core.js | 55 +++++++++++++---------------- standalone/sidecar/pty-core.test.js | 27 +++++++++++--- 4 files changed, 52 insertions(+), 37 deletions(-) diff --git a/docs/specs/transport.md b/docs/specs/transport.md index 427246a30..dc9090a85 100644 --- a/docs/specs/transport.md +++ b/docs/specs/transport.md @@ -310,8 +310,9 @@ Source of truth: `getScrollbackReceived` / `getScrollbackSince` in `vscode-ext/s `2 × OPEN_PORT_TIMEOUT_MS + count × OPEN_PORT_TIMEOUT_PER_ID_MS + hops × OPEN_PORT_ROUND_TRIP_MARGIN_MS`. VS Code's child request uses one hop; its webview request uses two. Tauri's sidecar request uses one. **Must share the Windows socket-scan allowance across -name lookup, `Get-NetTCPConnection`, and the `netstat` fallback**, reducing each -subprocess timeout by elapsed time and starting none after exhaustion. Pinned by +`Get-NetTCPConnection`, its `netstat` fallback, then optional name lookup**, reducing each +subprocess timeout by elapsed time and starting none after exhaustion. **Must +return enumerated ports even when optional name lookup times out.** Pinned by the Windows budget tests in `standalone/sidecar/pty-core.test.js` and by the port-deadline tests in `lib/src/lib/platform/vscode-adapter.test.ts`, `vscode-ext/test/pty-manager.test.ts`, and `lib/src/lib/mirrored-constants.test.ts`. diff --git a/scripts/spec-word-budgets.json b/scripts/spec-word-budgets.json index 07f9df58f..0aeff01e8 100644 --- a/scripts/spec-word-budgets.json +++ b/scripts/spec-word-budgets.json @@ -30,7 +30,7 @@ "docs/specs/terminal-state.md": 2350, "docs/specs/theme.md": 2150, "docs/specs/tiling-engine.md": 4500, - "docs/specs/transport.md": 5450, + "docs/specs/transport.md": 5500, "docs/specs/tutorial.md": 1900, "docs/specs/vscode.md": 7500, "docs/specs/webgl-text.md": 1200, diff --git a/standalone/sidecar/pty-core.js b/standalone/sidecar/pty-core.js index 2428b492a..9e6aae250 100644 --- a/standalone/sidecar/pty-core.js +++ b/standalone/sidecar/pty-core.js @@ -1026,51 +1026,46 @@ function runPowerShellJson(script, execFileSyncFn) { } function windowsListeningPorts(pids, runtime = {}) { - const spawn = runtime.execFileSync || execFileSync; + const rawExecFileSync = runtime.execFileSync || execFileSync; const now = runtime.now || (() => performance.now()); const deadline = now() + (runtime.scanTimeoutMs ?? OPEN_PORT_TIMEOUT_MS); - // Name resolution, the preferred cmdlet, and netstat share one socket-scan - // budget. Each fallback spends only what the preceding subprocesses left. + // Mandatory port enumeration gets the budget first; optional process names + // spend only what remains after the cmdlet or its netstat fallback. const execFileSyncFn = (command, args, options) => { const remaining = Math.ceil(deadline - now()); if (remaining <= 0) throw new Error('port scan deadline exhausted'); - return spawn(command, args, { ...options, timeout: remaining }); + return rawExecFileSync(command, args, { ...options, timeout: remaining }); }; const pidSet = new Set(pids); - - // Resolve pid -> process name once (best-effort; ports still returned without). const nameByPid = new Map(); - try { - const rows = runPowerShellJson( - 'Get-CimInstance Win32_Process | Select-Object ProcessId,Name | ConvertTo-Json -Compress', - execFileSyncFn, - ); - for (const row of rows) { - nameByPid.set(Number(row.ProcessId), String(row.Name)); - } - } catch { /* names are optional */ } - - // Preferred: Get-NetTCPConnection (Windows 8+/Server 2012+). + let ports; try { const json = runPowerShell( 'Get-NetTCPConnection -State Listen | Select-Object LocalAddress,LocalPort,OwningProcess | ConvertTo-Json -Compress', execFileSyncFn, ); - return parseNetTcpConnections(json, pidSet, nameByPid); - } catch { /* fall through to netstat */ } - - // Fallback: netstat -ano. - try { - const out = execFileSyncFn('netstat', ['-ano', '-p', 'TCP'], { - encoding: 'utf-8', - stdio: ['ignore', 'pipe', 'ignore'], - timeout: OPEN_PORT_TIMEOUT_MS, - windowsHide: true, // see runPowerShell: avoid the console-allocation deadlock - }); - return parseNetstatListening(out, pidSet, nameByPid); + ports = parseNetTcpConnections(json, pidSet, nameByPid); } catch { - return []; + try { + const out = execFileSyncFn('netstat', ['-ano', '-p', 'TCP'], { + encoding: 'utf-8', + stdio: ['ignore', 'pipe', 'ignore'], + windowsHide: true, + }); + ports = parseNetstatListening(out, pidSet, nameByPid); + } catch { return []; } } + if (!ports.length) return ports; + + // Names are best-effort: exhaustion here must still return the ports. + try { + const rows = runPowerShellJson( + 'Get-CimInstance Win32_Process | Select-Object ProcessId,Name | ConvertTo-Json -Compress', + execFileSyncFn, + ); + for (const row of rows) nameByPid.set(Number(row.ProcessId), String(row.Name)); + } catch { /* names are optional */ } + return ports.map((port) => ({ ...port, processName: nameByPid.get(port.pid) })); } function getListeningPortsForPids(pids, runtime = {}) { diff --git a/standalone/sidecar/pty-core.test.js b/standalone/sidecar/pty-core.test.js index 959be9a65..8e27cebc5 100644 --- a/standalone/sidecar/pty-core.test.js +++ b/standalone/sidecar/pty-core.test.js @@ -1490,7 +1490,7 @@ test('Windows port subprocesses share the socket budget, including netstat fallb return JSON.stringify([{ ProcessId: 4242, ParentProcessId: 1 }]); } if (script.includes('Win32_Process')) { - now += 500; + now += options.timeout; return JSON.stringify([{ ProcessId: 4242, Name: 'node.exe' }]); } if (script.includes('Get-NetTCPConnection')) { @@ -1498,12 +1498,12 @@ test('Windows port subprocesses share the socket budget, including netstat fallb throw new Error('cmdlet failed'); } assert.equal(cmd, 'netstat'); - now += options.timeout; + now += 500; return ' TCP 0.0.0.0:3000 0.0.0.0:0 LISTENING 4242\n'; }; const result = getOpenPortsForPids([4242], { platform: 'win32', execFileSync, now: () => now }); const budget = openPortScanTimeoutMs(1); - assert.deepEqual(timeouts, [OPEN_PORT_TIMEOUT_MS, budget, budget - 500, budget - 1500]); + assert.deepEqual(timeouts, [OPEN_PORT_TIMEOUT_MS, budget, budget - 1000, budget - 1500]); assert.equal(now, OPEN_PORT_TIMEOUT_MS + budget); assert.equal(result.get(4242)[0].processName, 'node.exe'); }); @@ -1520,7 +1520,26 @@ test('Windows does not start netstat after the socket deadline expires', () => { assert.deepEqual(getListeningPortsForPids([4242], { platform: 'win32', execFileSync, now: () => now, scanTimeoutMs: 3100, }), []); - assert.deepEqual(commands, ['powershell.exe', 'powershell.exe']); + assert.deepEqual(commands, ['powershell.exe']); + assert.equal(now, 3100); +}); + +test('a slow optional Windows name lookup cannot hide already enumerated ports', () => { + let now = 0; + const execFileSync = (cmd, args, options) => { + if (args.at(-1).includes('Get-NetTCPConnection')) { + now += 10; + return JSON.stringify([{ LocalAddress: '0.0.0.0', LocalPort: 3000, OwningProcess: 4242 }]); + } + assert.ok(args.at(-1).includes('Win32_Process')); + assert.equal(options.timeout, 3090); + now += options.timeout; + throw new Error('WMI timed out'); + }; + const ports = getListeningPortsForPids([4242], { + platform: 'win32', execFileSync, now: () => now, scanTimeoutMs: 3100, + }); + assert.deepEqual(ports.map(({ port, processName }) => ({ port, processName })), [{ port: 3000, processName: undefined }]); assert.equal(now, 3100); });