Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
582 changes: 579 additions & 3 deletions package-lock.json

Large diffs are not rendered by default.

8 changes: 6 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "browsercode",
"private": true,
"version": "1.1.0",
"version": "1.2.0",
"type": "module",
"scripts": {
"dev": "vite dev",
Expand Down Expand Up @@ -39,9 +39,13 @@
"@iconify-json/mingcute": "^1.2.7",
"@iconify/svelte": "^5.2.1",
"@leaningtech/browserpod": "3.0.1",
"@shikijs/langs": "^4.4.3",
"@shikijs/monaco": "^4.4.3",
"@shikijs/themes": "^4.4.3",
"@sveltejs/adapter-static": "^3.0.10",
"fflate": "^0.8.3",
"monaco-editor": "^0.55.1",
"qrcode": "^1.5.4"
"qrcode": "^1.5.4",
"shiki": "^4.4.3"
}
}
75 changes: 75 additions & 0 deletions src/lib/agents/credential-gate.svelte.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
import type { CredentialSpec } from '$lib/config/tools';
import { describeError } from './boot';

/** `idle` renders no overlay; every other stage covers the terminal. */
export type GateStage = 'idle' | 'loading' | 'signin' | 'error';

export type CredentialGateOptions = {
/** A newly saved secret only reaches the CLI through a fresh launch. */
onRestart: () => void;
};

/**
* Drives the boot overlay for a CLI that declares a `CredentialSpec`. It surfaces a failed boot
* itself because the terminal `bootCLI` wrote that failure into sits behind this overlay.
*/
export class CredentialGate {
stage = $state<GateStage>('idle');
error = $state('');
/** Assumed until `begin()` reads storage, so the loading card does not flash a false prompt. */
hasCredential = $state(true);
/** Not a stage: the toolbar can open this over an idle gate, after a boot has finished. */
changeOpen = $state(false);

private resolveSignIn: ((value: string) => void) | null = null;

constructor(
private credential: CredentialSpec,
private options: CredentialGateOptions
) {}

get overlayVisible(): boolean {
return this.stage !== 'idle' || this.changeOpen;
}

/** Call before `bootCLI`, so the overlay is up before the image starts streaming. */
begin = (): void => {
this.hasCredential = this.credential.get() !== null;
this.stage = 'loading';
};

/**
* Blocks the launch until a secret is stored, rather than overlaying a CLI already running
* without one, which could not be handed it afterwards.
*/
beforeLaunch = async (): Promise<void> => {
if (!this.credential.get()) {
this.stage = 'signin';
this.credential.set(await new Promise<string>((resolve) => (this.resolveSignIn = resolve)));
this.hasCredential = true;
}
this.stage = 'idle';
};

submit = (value: string): void => {
this.resolveSignIn?.(value);
};

saveAndRestart = (value: string): void => {
this.credential.set(value);
this.options.onRestart();
};

reportBootFailure = (error: unknown): void => {
this.error = describeError(error);
this.stage = 'error';
};

openChange = (): void => {
this.changeOpen = true;
};

closeChange = (): void => {
this.changeOpen = false;
};
}
98 changes: 98 additions & 0 deletions src/lib/agents/session.svelte.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
import type { PortalUpdate } from '$lib/pod/portals';
import {
cliConfigs,
isEnabledTool,
resolveToolId,
toolItems,
type CredentialSpec,
type ToolId,
type ToolItem
} from '$lib/config/tools';
import {
installLeaveGuard,
markIntentionalNavigation,
navigateWithLeaveGuard
} from '$lib/stores/leaveWarning.svelte';
import { requestSingleTabLock } from '$lib/utils/tabLock';
import { bootCLI } from './boot';
import { CredentialGate } from './credential-gate.svelte';

/** `pending` covers the wait on the lock, which resolves after a grace period rather than at once. */
export type LockState = 'pending' | 'held' | 'taken';

/** Owns one agent CLI session. */
export class AgentSession {
readonly id: ToolId;
readonly tool: ToolItem;
readonly credential: CredentialSpec | undefined;
/** Null for a CLI that declares no credential; those boot with no overlay at all. */
readonly gate: CredentialGate | null;

lock = $state<LockState>('pending');

private releaseLock: () => void = () => {};
private disposeLeaveGuard: () => void = () => {};

constructor(requestedTool: string | undefined) {
this.id = resolveToolId(requestedTool);
// resolveToolId only ever returns an id that is in toolItems, so this always resolves.
this.tool = toolItems.find((item) => item.id === this.id)!;
// Mirrors bootCLI's own resolution, so the gate matches the config that actually launches.
this.credential = (cliConfigs[this.id] ?? cliConfigs.claude).credential;
this.gate = this.credential
? new CredentialGate(this.credential, { onRestart: this.restart })
: null;
}

async boot(
terminalEl: HTMLElement,
onPortalUpdate: (update: PortalUpdate) => void
): Promise<void> {
const lock = requestSingleTabLock(`agent-session:${this.id}`);
this.releaseLock = lock.release;

if (!(await lock.acquired)) {
this.lock = 'taken';
return;
}
this.lock = 'held';

// Only warn on tab close/refresh/back-button once there is work here to lose.
this.disposeLeaveGuard = installLeaveGuard();

// Covers pod boot, the image streaming in, and any warm-up probe.
this.gate?.begin();

try {
await bootCLI(
this.id,
terminalEl,
onPortalUpdate,
this.gate ? { beforeLaunch: this.gate.beforeLaunch } : undefined
);
} catch (error) {
// bootCLI already logged this and wrote it into the terminal, but a gated boot's overlay
// covers that terminal until it is told to show the failure.
this.gate?.reportBootFailure(error);
}
}

/** The CLI reads its credential at launch, so both a new secret and a retry need a fresh load. */
restart = (): void => {
markIntentionalNavigation();
window.location.reload();
};

/** Always confirms: there is a live session here to tear down. */
switchTo = (id: string): void => {
if (isEnabledTool(id)) navigateWithLeaveGuard(`/agents/${id}`, true);
};

/** No confirmation, unlike `switchTo` */
leave = (): void => navigateWithLeaveGuard('/agents', false);

shutdown(): void {
this.disposeLeaveGuard();
this.releaseLock();
}
}
Loading