Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
adfec6e
feat(machine): split-and-pick spawn + node-as-workspace panes
2witstudios Jul 12, 2026
85ce1d4
fix(machine): target pane writes at their own node, not the active one
2witstudios Jul 12, 2026
fa1b3a5
fix(machine): a workspace owns a grid; selecting one switches the who…
2witstudios Jul 12, 2026
50efbf7
Merge remote-tracking branch 'origin/master' into pu/machine-split-an…
2witstudios Jul 12, 2026
83ad641
fix(machine): make persisted workspaces safe, and recoverable
2witstudios Jul 12, 2026
88e82b4
test(machine): prove the persist config survives a real storage round…
2witstudios Jul 12, 2026
d9c619b
fix(machine): never anchor a split on a pane that isn't there
2witstudios Jul 12, 2026
329c7e2
fix(machine): a prompt dies with the connect that owned it; open a se…
2witstudios Jul 12, 2026
6685f5d
fix(machine): let the bridge say whether it RESUMED an agent, and pro…
2witstudios Jul 12, 2026
6602150
Merge remote-tracking branch 'origin/master' into pu/machine-split-an…
2witstudios Jul 12, 2026
83effb5
fix(machine): make `resumed` a verified fact, and stop the child-sess…
2witstudios Jul 12, 2026
5d6d53f
fix(machine): tag a rejected connect's error with the pane it came from
2witstudios Jul 12, 2026
152b52e
refactor(machine): drop dead exports and correct two comments the rew…
2witstudios Jul 12, 2026
4021b7b
fix(machine): the prompt waits to learn what it is talking to
2witstudios Jul 12, 2026
0626b70
fix(machine): carry the resumed fact onto the session, and don't swal…
2witstudios Jul 12, 2026
95d4c7b
fix(machine): bound the liveness check, and stop a guess from becomin…
2witstudios Jul 12, 2026
fccfca7
Merge remote-tracking branch 'origin/master' into pu/machine-split-an…
2witstudios Jul 12, 2026
81226a3
fix(development): point the Development surface at the active workspace
2witstudios Jul 12, 2026
285ac78
fix(machine): the durable verdict must fail safe too — I had the asym…
2witstudios Jul 12, 2026
76aa11d
fix(machine): a verdict must constrain what happens, not merely predi…
2witstudios Jul 12, 2026
d64f396
fix(machine): the abandon window is the whole connect, not just the c…
2witstudios Jul 12, 2026
13ae0ce
fix(realtime): an abandoned connect must DECLINE to attach, not attac…
2witstudios Jul 12, 2026
68afeda
fix(realtime): make the connectionId collision unrepresentable, and n…
2witstudios Jul 12, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
587 changes: 564 additions & 23 deletions apps/realtime/src/terminal/__tests__/agent-terminal-handler.test.ts

Large diffs are not rendered by default.

32 changes: 32 additions & 0 deletions apps/realtime/src/terminal/__tests__/terminal-session-map.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@ function fakeSession(sessionKey = 'key1', sandboxId = 'sbx1'): TerminalSession {
closedFn: vi.fn(),
scrollback: [],
scrollbackBytes: 0,
hasOutput: false,
resumedAtCreate: false,
reAuthInterval: undefined,
idleTimer: undefined,
};
Expand Down Expand Up @@ -224,3 +226,33 @@ describe('appendScrollback', () => {
expect(session.scrollbackBytes).toBe(4);
});
});

describe('appendScrollback — hasOutput', () => {
it('given a chunk BIGGER than the whole scrollback cap, should still record that the PTY has spoken', () => {
const session = fakeSession();

// The chunk is pushed, then trimmed straight back off: the buffer ends up
// EMPTY for a session that has just produced 64KB+ of output.
appendScrollback(session, 'x'.repeat(MAX_SCROLLBACK_BYTES + 1));

expect({
given: 'one output chunk larger than MAX_SCROLLBACK_BYTES',
should:
'leave hasOutput true even though the trim emptied the buffer — a client that reads an empty scrollback as "still booting, safe to type" would otherwise type a starting prompt into an agent that has been screaming output',
actual: { scrollback: session.scrollback.length, hasOutput: session.hasOutput },
expected: { scrollback: 0, hasOutput: true },
}).toEqual({
given: 'one output chunk larger than MAX_SCROLLBACK_BYTES',
should:
'leave hasOutput true even though the trim emptied the buffer — a client that reads an empty scrollback as "still booting, safe to type" would otherwise type a starting prompt into an agent that has been screaming output',
actual: { scrollback: 0, hasOutput: true },
expected: { scrollback: 0, hasOutput: true },
});
});

it('given no output at all, should report hasOutput false', () => {
const session = fakeSession();

expect(session.hasOutput).toBe(false);
});
});
782 changes: 533 additions & 249 deletions apps/realtime/src/terminal/agent-terminal-handler.ts

Large diffs are not rendered by default.

26 changes: 25 additions & 1 deletion apps/realtime/src/terminal/terminal-session-map.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,26 @@ export type TerminalSession = {
closedFn: (exitCode: number) => void;
scrollback: string[];
scrollbackBytes: number;
/**
* Has this PTY ever produced a byte? NOT the same question as "is the
* scrollback non-empty": a single chunk larger than MAX_SCROLLBACK_BYTES is
* pushed and then trimmed straight back off, leaving an EMPTY scrollback for a
* session that has been screaming output. A client that types a starting prompt
* into a terminal reads "has produced nothing" as "still booting, safe to type"
* — so it has to be the truth, not an artefact of the trim.
*/
hasOutput: boolean;
/**
* Was this PTY already running when the bridge picked it up? (`openShell`
* resumed a Sprite exec session rather than starting one.)
*
* Kept ALONGSIDE `hasOutput` because the two answer the same question at
* different moments and neither covers the other: a resumed agent that has not
* yet said anything has `hasOutput: false`, and a reattach in that window would
* otherwise be told the PTY is a fresh boot — and a client holding a starting
* prompt would type it into an agent that has been running for hours.
*/
resumedAtCreate: boolean;
/**
* Terminal Epic 3 metering (optional — set only when a `billing` seam is
* wired). `payerId` + `connectedAt` identify who pays for the window that
Expand All @@ -39,8 +59,12 @@ export type TerminalSession = {
pageId?: string;
};

export function appendScrollback(session: Pick<TerminalSession, 'scrollback' | 'scrollbackBytes'>, data: string): void {
export function appendScrollback(
session: Pick<TerminalSession, 'scrollback' | 'scrollbackBytes' | 'hasOutput'>,
data: string,
): void {
const bytes = Buffer.byteLength(data, 'utf8');
session.hasOutput = true;
session.scrollback.push(data);
session.scrollbackBytes += bytes;
while (session.scrollbackBytes > MAX_SCROLLBACK_BYTES && session.scrollback.length > 0) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ vi.mock('@/components/layout/middle-content/MachineKeepAliveHost', () => ({

import DevelopmentLayout from '../layout';
import { usePendingSessionStore } from '@/stores/development/usePendingSessionStore';
import { useMachineWorkspaceStore } from '@/stores/machine-workspace/useMachineWorkspaceStore';
import { useMachineWorkspaceStore, selectActiveWorkspace, panesOf } from '@/stores/machine-workspace/useMachineWorkspaceStore';

const machine = (id: string) => ({ id, title: id, updatedAt: '2026-07-12T00:00:00.000Z' });

Expand All @@ -50,7 +50,7 @@ beforeEach(() => {
vi.clearAllMocks();
hostRenders.length = 0;
usePendingSessionStore.setState({ pending: null });
useMachineWorkspaceStore.setState({ workspaces: {} });
useMachineWorkspaceStore.setState({ machines: {} });
mockUseAuth.mockReturnValue({ user: { role: 'admin' }, isLoading: false });
mockUseDriveMachines.mockReturnValue(driveMachines());
});
Expand Down Expand Up @@ -157,7 +157,7 @@ describe('DevelopmentLayout', () => {

expect(hostRenders.at(-1)!.activePageId).toBeNull();
// Held, not applied — it converges once the machine is displayed again.
expect(useMachineWorkspaceStore.getState().workspaces['machine-1']).toBeUndefined();
expect(useMachineWorkspaceStore.getState().machines['machine-1']).toBeUndefined();
});

test('holds a session intent while the list loads, then applies it once the machine is displayed', () => {
Expand All @@ -174,11 +174,12 @@ describe('DevelopmentLayout', () => {

// The list arrives, and the machine's pane region has ensured a workspace.
mockUseDriveMachines.mockReturnValue(driveMachines({ machines: [machine('machine-1')] }));
useMachineWorkspaceStore.getState().ensureWorkspace('machine-1');
useMachineWorkspaceStore.getState().ensureMachine('machine-1');
rerender(<DevelopmentLayout>{null}</DevelopmentLayout>);

const workspace = useMachineWorkspaceStore.getState().workspaces['machine-1'];
const activePane = workspace.columns.flatMap((c) => c.panes).find((p) => p.id === workspace.activePaneId);
// The session opens in ITS OWN workspace, which becomes the one on screen.
const workspace = selectActiveWorkspace('machine-1')(useMachineWorkspaceStore.getState())!;
const activePane = panesOf(workspace).find((pane) => pane.id === workspace.activePaneId);
expect(activePane?.scope).toMatchObject({ name: 'agent-1' });
});

Expand Down
15 changes: 9 additions & 6 deletions apps/web/src/app/dashboard/[driveId]/development/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import { Cpu } from 'lucide-react';
import MachineKeepAliveHost from '@/components/layout/middle-content/MachineKeepAliveHost';
import { useAuth } from '@/hooks/useAuth';
import { useDriveMachines } from '@/hooks/useDriveMachines';
import { useMachineWorkspaceStore } from '@/stores/machine-workspace/useMachineWorkspaceStore';
import { useMachineWorkspaceStore, selectActiveWorkspace } from '@/stores/machine-workspace/useMachineWorkspaceStore';
import { usePendingSessionStore } from '@/stores/development/usePendingSessionStore';
import { parseSelectedMachineId } from '@/lib/development/development-route';
import { resolvePendingSession } from '@/lib/development/pending-session';
Expand Down Expand Up @@ -196,10 +196,9 @@ function DetailNotice({ title, description }: { title: string; description?: str
* to actually has a workspace to open it into.
*
* The decision is the pure `resolvePendingSession`; this is the plumbing. It
* re-evaluates whenever the workspace changes, so an intent applied against a
* workspace that is then torn down and rebuilt (a remount — StrictMode's
* double-invoke does this on first mount) re-applies to the new one instead of
* being silently lost.
* re-evaluates whenever the workspace on screen changes, so an intent that has
* not converged yet — the pane region has not mounted, or the user is en route —
* is re-applied as soon as it can be, instead of being silently lost.
*
* Leaving the surface drops any unconverged intent: the store is a module
* singleton, so an intent left behind here would otherwise still be sitting
Expand All @@ -209,8 +208,12 @@ function useDrainPendingSession(displayedMachineId: string | null) {
const pending = usePendingSessionStore((state) => state.pending);
const clearPending = usePendingSessionStore((state) => state.clearPending);
const openTerminal = useMachineWorkspaceStore((state) => state.openTerminal);
// The machine's ACTIVE workspace — the grid the middle view is actually
// showing. A machine now holds many workspaces (each sidebar item owns one),
// and the intent converges when the session it names is in the active pane of
// the workspace on screen.
const workspace = useMachineWorkspaceStore((state) =>
pending ? state.workspaces[pending.machineId] : undefined,
pending ? selectActiveWorkspace(pending.machineId)(state) : undefined,
);

useEffect(() => {
Expand Down
Loading