Skip to content

Commit 9fbf8a1

Browse files
Hein van Vuurenclaude
andcommitted
feat: Phase 2 — profiles, session registry, providers, PDF, inactivity
Add multi-profile management with CDP port allocation (18800-18899), per-agent session tab tracking, cloud provider abstraction with Browserbase integration (graceful 402 fallback chain), PDF export tool, and session inactivity cleanup monitor. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent b996d26 commit 9fbf8a1

8 files changed

Lines changed: 829 additions & 0 deletions

File tree

src/config/profiles.ts

Lines changed: 166 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,166 @@
1+
/**
2+
* Boss Ghost MCP — Multi-profile management with CDP port allocation.
3+
*
4+
* Profiles define browser instances with isolated user data dirs,
5+
* CDP ports, and launch configurations. Inspired by OpenClaw's profile system.
6+
*/
7+
8+
import {getConfig} from './config.js';
9+
import type {ProfileConfig} from './types.js';
10+
import {logger} from '../logger.js';
11+
12+
// CDP port range: 18800-18899 (same as OpenClaw)
13+
const CDP_PORT_RANGE_START = 18800;
14+
const CDP_PORT_RANGE_END = 18899;
15+
16+
export interface ResolvedProfile {
17+
name: string;
18+
cdpPort: number;
19+
cdpUrl?: string;
20+
userDataDir?: string;
21+
driver: 'managed' | 'existing-session';
22+
headless: boolean;
23+
channel: 'stable' | 'canary' | 'beta' | 'dev';
24+
executablePath?: string;
25+
extraArgs: string[];
26+
attachOnly: boolean;
27+
}
28+
29+
/**
30+
* Build a ResolvedProfile from a ProfileConfig and its name.
31+
* Fills in defaults for any missing fields.
32+
*/
33+
function resolveFromConfig(
34+
name: string,
35+
config: ProfileConfig,
36+
portIndex: number,
37+
): ResolvedProfile {
38+
const cdpPort = config.cdpPort ?? CDP_PORT_RANGE_START + portIndex;
39+
40+
if (cdpPort < CDP_PORT_RANGE_START || cdpPort > CDP_PORT_RANGE_END) {
41+
logger(
42+
'Profile "%s" CDP port %d is outside range %d-%d',
43+
name,
44+
cdpPort,
45+
CDP_PORT_RANGE_START,
46+
CDP_PORT_RANGE_END,
47+
);
48+
}
49+
50+
return {
51+
name,
52+
cdpPort,
53+
cdpUrl: config.cdpUrl,
54+
userDataDir: config.userDataDir,
55+
driver: config.driver ?? 'managed',
56+
headless: config.headless ?? false,
57+
channel: config.channel ?? 'stable',
58+
executablePath: config.executablePath,
59+
extraArgs: config.extraArgs ?? [],
60+
attachOnly: config.attachOnly ?? false,
61+
};
62+
}
63+
64+
/**
65+
* Create a default profile with sensible defaults.
66+
* Used when no profiles are configured at all.
67+
*/
68+
function createDefaultProfile(): ResolvedProfile {
69+
return {
70+
name: 'default',
71+
cdpPort: CDP_PORT_RANGE_START,
72+
driver: 'managed',
73+
headless: false,
74+
channel: 'stable',
75+
extraArgs: [],
76+
attachOnly: false,
77+
};
78+
}
79+
80+
/**
81+
* Get the default profile name from config, falling back to "default".
82+
*/
83+
function getDefaultProfileName(): string {
84+
const config = getConfig();
85+
return config.defaultProfile ?? 'default';
86+
}
87+
88+
/**
89+
* Get or create the default profile.
90+
* If the default profile is configured, resolves it. Otherwise creates
91+
* a minimal managed profile automatically.
92+
*/
93+
export function getDefaultProfile(): ResolvedProfile {
94+
const config = getConfig();
95+
const defaultName = getDefaultProfileName();
96+
const profiles = config.profiles;
97+
98+
if (profiles && profiles[defaultName]) {
99+
const keys = Object.keys(profiles);
100+
const portIndex = keys.indexOf(defaultName);
101+
return resolveFromConfig(defaultName, profiles[defaultName], portIndex);
102+
}
103+
104+
logger('No profile "%s" found in config — using auto-generated default', defaultName);
105+
return createDefaultProfile();
106+
}
107+
108+
/**
109+
* Resolve a named profile from config.
110+
* Returns null if the profile does not exist.
111+
*/
112+
export function resolveProfile(name: string): ResolvedProfile | null {
113+
const config = getConfig();
114+
const profiles = config.profiles;
115+
116+
if (!profiles || !profiles[name]) {
117+
logger('Profile "%s" not found in config', name);
118+
return null;
119+
}
120+
121+
const keys = Object.keys(profiles);
122+
const portIndex = keys.indexOf(name);
123+
return resolveFromConfig(name, profiles[name], portIndex);
124+
}
125+
126+
/**
127+
* List all configured profiles with their resolved settings.
128+
* If no profiles are configured, returns a single auto-generated default.
129+
*/
130+
export function listProfiles(): ResolvedProfile[] {
131+
const config = getConfig();
132+
const profiles = config.profiles;
133+
134+
if (!profiles || Object.keys(profiles).length === 0) {
135+
return [createDefaultProfile()];
136+
}
137+
138+
const keys = Object.keys(profiles);
139+
return keys.map((name, idx) => resolveFromConfig(name, profiles[name], idx));
140+
}
141+
142+
/**
143+
* Allocate the next available CDP port in the 18800-18899 range.
144+
* Skips any ports already in the usedPorts set.
145+
* Throws if no ports are available.
146+
*/
147+
export function allocateCdpPort(usedPorts: Set<number>): number {
148+
for (let port = CDP_PORT_RANGE_START; port <= CDP_PORT_RANGE_END; port++) {
149+
if (!usedPorts.has(port)) {
150+
return port;
151+
}
152+
}
153+
throw new Error(
154+
`No available CDP ports in range ${CDP_PORT_RANGE_START}-${CDP_PORT_RANGE_END}. ` +
155+
`All ${CDP_PORT_RANGE_END - CDP_PORT_RANGE_START + 1} ports are in use.`,
156+
);
157+
}
158+
159+
/**
160+
* Ensure the default profile exists.
161+
* Returns the resolved default profile, auto-creating one if no profiles
162+
* are configured.
163+
*/
164+
export function ensureDefaultProfile(): ResolvedProfile {
165+
return getDefaultProfile();
166+
}

src/config/session-registry.ts

Lines changed: 122 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,122 @@
1+
/**
2+
* Boss Ghost MCP — Per-agent session tab tracking.
3+
*
4+
* Tracks which browser tabs belong to which agent session,
5+
* enabling clean teardown when an agent disconnects.
6+
* Inspired by OpenClaw's session-tab-registry.ts.
7+
*/
8+
9+
import {logger} from '../logger.js';
10+
11+
export interface TrackedTab {
12+
profileName: string;
13+
pageIdx: number;
14+
url: string;
15+
createdAt: number;
16+
}
17+
18+
// Session registry — tracks which tabs belong to which agent session.
19+
// Outer key: sessionKey (agent identifier), inner key: pageIdx.
20+
const sessions = new Map<string, Map<number, TrackedTab>>();
21+
22+
/**
23+
* Register a tab for a session.
24+
* If the session doesn't exist yet, it is created automatically.
25+
*/
26+
export function trackTab(
27+
sessionKey: string,
28+
pageIdx: number,
29+
profileName: string,
30+
url: string,
31+
): void {
32+
let tabs = sessions.get(sessionKey);
33+
if (!tabs) {
34+
tabs = new Map<number, TrackedTab>();
35+
sessions.set(sessionKey, tabs);
36+
logger('Created session tracking for "%s"', sessionKey);
37+
}
38+
39+
const tab: TrackedTab = {
40+
profileName,
41+
pageIdx,
42+
url,
43+
createdAt: Date.now(),
44+
};
45+
46+
tabs.set(pageIdx, tab);
47+
logger(
48+
'Tracked tab %d for session "%s" (profile: %s, url: %s)',
49+
pageIdx,
50+
sessionKey,
51+
profileName,
52+
url,
53+
);
54+
}
55+
56+
/**
57+
* Untrack a tab from a session.
58+
* No-op if the session or tab doesn't exist.
59+
*/
60+
export function untrackTab(sessionKey: string, pageIdx: number): void {
61+
const tabs = sessions.get(sessionKey);
62+
if (!tabs) {
63+
return;
64+
}
65+
66+
const deleted = tabs.delete(pageIdx);
67+
if (deleted) {
68+
logger('Untracked tab %d from session "%s"', pageIdx, sessionKey);
69+
}
70+
71+
// Clean up empty sessions automatically
72+
if (tabs.size === 0) {
73+
sessions.delete(sessionKey);
74+
logger('Session "%s" has no more tabs — removed', sessionKey);
75+
}
76+
}
77+
78+
/**
79+
* Get all tracked tabs for a session.
80+
* Returns an empty array if the session doesn't exist.
81+
*/
82+
export function getSessionTabs(sessionKey: string): TrackedTab[] {
83+
const tabs = sessions.get(sessionKey);
84+
if (!tabs) {
85+
return [];
86+
}
87+
return Array.from(tabs.values());
88+
}
89+
90+
/**
91+
* Get all page indices that should be closed for a session teardown.
92+
* Returns indices in descending order so closing them won't shift
93+
* the indices of remaining tabs.
94+
*/
95+
export function getTabsToClose(sessionKey: string): number[] {
96+
const tabs = sessions.get(sessionKey);
97+
if (!tabs) {
98+
return [];
99+
}
100+
return Array.from(tabs.keys()).sort((a, b) => b - a);
101+
}
102+
103+
/**
104+
* Clean up all tracking data for a session.
105+
*/
106+
export function clearSession(sessionKey: string): void {
107+
const tabs = sessions.get(sessionKey);
108+
const count = tabs?.size ?? 0;
109+
sessions.delete(sessionKey);
110+
logger('Cleared session "%s" (%d tabs)', sessionKey, count);
111+
}
112+
113+
/**
114+
* List all active sessions with their tab counts.
115+
*/
116+
export function listSessions(): {sessionKey: string; tabCount: number}[] {
117+
const result: {sessionKey: string; tabCount: number}[] = [];
118+
for (const [sessionKey, tabs] of sessions) {
119+
result.push({sessionKey, tabCount: tabs.size});
120+
}
121+
return result;
122+
}

src/providers/base.ts

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
/**
2+
* Boss Ghost MCP — Cloud browser provider abstraction.
3+
*
4+
* Defines the interface contract that all cloud browser providers must implement.
5+
* Inspired by Hermes's browser_providers/base.py pattern.
6+
*/
7+
8+
/** Represents an active cloud browser session. */
9+
export interface ProviderSession {
10+
/** Provider's unique session identifier. */
11+
sessionId: string;
12+
/** Human-readable session name for logging. */
13+
sessionName: string;
14+
/** WebSocket URL for Chrome DevTools Protocol connection. */
15+
cdpUrl: string;
16+
/** Runtime feature flags indicating which capabilities are active. */
17+
features: Record<string, boolean>;
18+
}
19+
20+
/** Contract for cloud browser providers (Browserbase, Browser-Use, etc.). */
21+
export interface CloudBrowserProvider {
22+
/** Provider identifier (e.g. 'browserbase', 'browser-use'). */
23+
readonly providerName: string;
24+
25+
/**
26+
* Check if this provider is configured with valid credentials.
27+
* Must be cheap — no network calls.
28+
*/
29+
isConfigured(): boolean;
30+
31+
/** Create a new cloud browser session. */
32+
createSession(taskId?: string): Promise<ProviderSession>;
33+
34+
/** Close a session gracefully. Returns true if the session was stopped. */
35+
closeSession(sessionId: string): Promise<boolean>;
36+
37+
/**
38+
* Best-effort cleanup for use in exit handlers.
39+
* Must never throw — all errors are swallowed.
40+
*/
41+
emergencyCleanup(sessionId: string): Promise<void>;
42+
}

0 commit comments

Comments
 (0)