Skip to content
Merged
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
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -52,5 +52,5 @@
"node-datachannel"
]
},
"version": "0.9.0-rc.17"
"version": "0.9.1"
}
2 changes: 1 addition & 1 deletion packages/a2a/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -12,5 +12,5 @@
"dependencies": {
"@markus/shared": "workspace:*"
},
"version": "0.9.0-rc.17"
"version": "0.9.1"
}
2 changes: 1 addition & 1 deletion packages/chrome-extension/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -12,5 +12,5 @@
"esbuild": "^0.25.0",
"typescript": "^5.6.0"
},
"version": "0.9.0-rc.17"
"version": "0.9.1"
}
2 changes: 1 addition & 1 deletion packages/cli/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -41,5 +41,5 @@
"commander": "^14.0.3",
"esbuild": "^0.27.4"
},
"version": "0.9.0-rc.17"
"version": "0.9.1"
}
24 changes: 24 additions & 0 deletions packages/cli/src/commands/start.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1045,6 +1045,30 @@ async function startServerCore(
log.warn('Failed to persist heartbeat interval change', { agentId, error: String(e) });
}
});
// Embedded-browser tab claim/release — UI shows which agent controls a tab.
agentManager.getEventBus().on('browser:tab-ownership', (evt: unknown) => {
const event = evt as { action: 'claimed' | 'released'; pageId: number; agentId: string };
let agentName = event.agentId;
try {
const agent = agentManager.getAgent(event.agentId);
agentName = agent?.config?.name || event.agentId;
} catch { /* agent may already be gone on release */ }
try {
ws.broadcast({
type: 'ui:browser_ownership',
payload: {
action: event.action,
pageId: event.pageId,
agentId: event.action === 'released' ? null : event.agentId,
agentName: event.action === 'released' ? null : agentName,
},
timestamp: new Date().toISOString(),
});
} catch (e) {
log.warn('Failed to broadcast browser ownership', { pageId: event.pageId, error: String(e) });
}
});

// Team Chat right-panel open/collapse — deliver to the interacting user's UI.
agentManager.getEventBus().on('agent:ui-layout', (evt: unknown) => {
const event = evt as {
Expand Down
2 changes: 1 addition & 1 deletion packages/comms/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -12,5 +12,5 @@
"dependencies": {
"@markus/shared": "workspace:*"
},
"version": "0.9.0-rc.17"
"version": "0.9.1"
}
2 changes: 1 addition & 1 deletion packages/core/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -25,5 +25,5 @@
"@types/turndown": "^5.0.6",
"@types/ws": "^8.18.1"
},
"version": "0.9.0-rc.17"
"version": "0.9.1"
}
15 changes: 15 additions & 0 deletions packages/core/src/agent-manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -501,6 +501,9 @@ export class AgentManager {
this.triggerChromeDialogAutoClick(serverName);
});
this.browserSessionManager = new BrowserSessionManager();
this.browserSessionManager.onOwnershipChange((event) => {
this.eventBus.emit('browser:tab-ownership', event);
});
this.browserBridge = new MarkusBrowserBridge();
this.globalSecurityPolicy = options.securityPolicy;
this.globalMcpServers = options.mcpServers;
Expand Down Expand Up @@ -685,6 +688,18 @@ export class AgentManager {
return this.browserBridge;
}

/** Current agent→page ownership for embedded-browser UI badges. */
getBrowserTabOwnership(): Array<{ pageId: number; agentId: string; agentName: string }> {
return this.browserSessionManager.listOwnership().map(({ pageId, agentId }) => {
let agentName = agentId;
try {
const agent = this.agents.get(agentId);
if (agent) agentName = agent.config.name || agentId;
} catch { /* agent may be mid-removal */ }
return { pageId, agentId, agentName };
});
}

async runQuickBrowserTest(): Promise<BrowserTestResult> {
return runQuickBrowserTest(this.browserBridge, this.browserSessionManager);
}
Expand Down
101 changes: 87 additions & 14 deletions packages/core/src/tools/browser-session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,14 @@ const SESSION_KEY = '_browserSessionId';
/** Error substring that chrome-devtools-mcp returns when the selected page is gone. */
const STALE_PAGE_ERROR = 'The selected page has been closed';

/** Fired when an agent claims or releases a browser tab (for UI badges). */
export type BrowserTabOwnershipEvent = {
action: 'claimed' | 'released';
pageId: number;
agentId: string;
ownerKey: string;
};

/**
* Tracks browser tab ownership per session and wraps chrome-devtools MCP
* tool handlers to enforce strict tab isolation.
Expand Down Expand Up @@ -78,11 +86,75 @@ export class BrowserSessionManager {
private _bringToFront = false;
private _autoCloseTabs = true;

private ownershipListeners = new Set<(e: BrowserTabOwnershipEvent) => void>();

get bringToFront(): boolean { return this._bringToFront; }
set bringToFront(v: boolean) { this._bringToFront = v; }
get autoCloseTabs(): boolean { return this._autoCloseTabs; }
set autoCloseTabs(v: boolean) { this._autoCloseTabs = v; }

/** Subscribe to tab claim/release events (UI badge wiring). */
onOwnershipChange(listener: (e: BrowserTabOwnershipEvent) => void): () => void {
this.ownershipListeners.add(listener);
return () => { this.ownershipListeners.delete(listener); };
}

/** Snapshot of currently owned tabs for UI hydration. */
listOwnership(): Array<{ pageId: number; agentId: string; ownerKey: string }> {
const out: Array<{ pageId: number; agentId: string; ownerKey: string }> = [];
for (const [ownerKey, owned] of this.ownedPages) {
const agentId = this.agentIdFromOwnerKey(ownerKey);
for (const pageId of owned) {
out.push({ pageId, agentId, ownerKey });
}
}
return out;
}

private agentIdFromOwnerKey(ownerKey: string): string {
const idx = ownerKey.indexOf('::');
return idx === -1 ? ownerKey : ownerKey.slice(0, idx);
}

private emitOwnership(event: BrowserTabOwnershipEvent): void {
for (const listener of this.ownershipListeners) {
try { listener(event); } catch (err) {
log.warn('Ownership listener error', { error: String(err) });
}
}
}

/** Claim a page for an owner key; emits `claimed` when newly assigned. */
private assignPage(ownerKey: string, pageId: number): void {
const owned = this.getOwned(ownerKey);
if (owned.has(pageId)) return;
const other = this.findOwnerOfPage(pageId);
if (other && other !== ownerKey) {
this.releasePage(other, pageId);
}
owned.add(pageId);
this.emitOwnership({
action: 'claimed',
pageId,
agentId: this.agentIdFromOwnerKey(ownerKey),
ownerKey,
});
}

/** Release a page from an owner key; emits `released` when removed. */
private releasePage(ownerKey: string, pageId: number): boolean {
const owned = this.ownedPages.get(ownerKey);
if (!owned?.has(pageId)) return false;
owned.delete(pageId);
this.emitOwnership({
action: 'released',
pageId,
agentId: this.agentIdFromOwnerKey(ownerKey),
ownerKey,
});
return true;
}

/**
* Register a reconnect callback for a specific MCP server of an agent.
* Multiple servers can each have their own reconnector without overwriting.
Expand All @@ -105,8 +177,8 @@ export class BrowserSessionManager {
*/
handleTabClosed(pageId: number | undefined): void {
if (pageId === undefined) return;
for (const [key, owned] of this.ownedPages) {
if (owned.delete(pageId)) {
for (const key of [...this.ownedPages.keys()]) {
if (this.releasePage(key, pageId)) {
log.debug(`Removed closed page ${pageId} from ownership set ${key}`);
}
}
Expand Down Expand Up @@ -177,9 +249,9 @@ export class BrowserSessionManager {
const prefix = `${agentId}::`;
for (const [key, owned] of this.ownedPages) {
if (key === agentId || key.startsWith(prefix)) {
for (const pageId of owned) {
for (const pageId of [...owned]) {
if (!liveIds.has(pageId)) {
owned.delete(pageId);
this.releasePage(key, pageId);
log.debug(`Pruned stale page ${pageId} from ${key}`);
}
}
Expand Down Expand Up @@ -413,9 +485,8 @@ export class BrowserSessionManager {
?? pages.find((p) => p.selected)
?? (pages.length > 0 ? pages.reduce((a, b) => (a.id > b.id ? a : b)) : undefined);
if (newPage) {
// Re-fetch owned after potential reconnect (reconnect clears state)
const currentOwned = this.getOwned(ownerKey);
currentOwned.add(newPage.id);
// Re-fetch after potential reconnect, then claim for UI + isolation.
this.assignPage(ownerKey, newPage.id);
this.currentPage.set(ownerKey, newPage.id);
this.lastActiveSession.set(agentId, { ownerKey, pageId: newPage.id });
log.debug(`Page ${newPage.id} (${newPage.url}) assigned to ${ownerKey}`);
Expand Down Expand Up @@ -481,7 +552,7 @@ export class BrowserSessionManager {
if (ok) {
// Remove only the failed page from ownership
if (pageId !== undefined) {
this.getOwned(ownerKey).delete(pageId);
this.releasePage(ownerKey, pageId);
if (this.currentPage.get(ownerKey) === pageId) {
this.currentPage.delete(ownerKey);
}
Expand All @@ -492,7 +563,7 @@ export class BrowserSessionManager {
}

if (pageId !== undefined && !this.isToolError(result)) {
owned.add(pageId);
this.assignPage(ownerKey, pageId);
this.currentPage.set(ownerKey, pageId);
this.lastActiveSession.set(agentId, { ownerKey, pageId });
}
Expand Down Expand Up @@ -525,7 +596,7 @@ export class BrowserSessionManager {
// adjusted currentPage yet, handleTabClosed will delete it outright
// instead of letting us switch to the next owned tab.
if (pageId !== undefined) {
this.getOwned(ownerKey).delete(pageId);
this.releasePage(ownerKey, pageId);
if (this.currentPage.get(ownerKey) === pageId) {
const remaining = this.getOwned(ownerKey);
const next = remaining.size > 0 ? [...remaining][remaining.size - 1] : undefined;
Expand Down Expand Up @@ -640,14 +711,13 @@ export class BrowserSessionManager {
}

if (!this.isStalePageError(result)) {
const owned = this.getOwned(ownerKey);
const pages = this.parsePageEntries(result);
// Prefer highest-ID page (the just-created tab always gets the
// highest auto-incremented ID). Only fall back to [selected] if
// no pages exist at all.
const newPage = (pages.length > 0 ? pages.reduce((a, b) => (a.id > b.id ? a : b)) : undefined);
if (newPage) {
owned.add(newPage.id);
this.assignPage(ownerKey, newPage.id);
this.currentPage.set(ownerKey, newPage.id);
this.lastActiveSession.set(agentId, { ownerKey, pageId: newPage.id });
log.info(`Auto-created page ${newPage.id} (${newPage.url}) for ${ownerKey}`);
Expand Down Expand Up @@ -693,7 +763,7 @@ export class BrowserSessionManager {
const ok = await this.reconnectMcp(agentId);
if (ok) {
if (currentPageId !== undefined) {
this.getOwned(ownerKey).delete(currentPageId);
this.releasePage(ownerKey, currentPageId);
this.currentPage.delete(ownerKey);
}
return `The tab you were operating on was closed externally. `
Expand All @@ -713,9 +783,12 @@ export class BrowserSessionManager {
cleanupAgent(agentId: string): void {
const prefix = `${agentId}::`;
let total = 0;
for (const [key, owned] of this.ownedPages) {
for (const [key, owned] of [...this.ownedPages]) {
if (key === agentId || key.startsWith(prefix)) {
total += owned.size;
for (const pageId of [...owned]) {
this.releasePage(key, pageId);
}
this.ownedPages.delete(key);
this.currentPage.delete(key);
}
Expand Down
26 changes: 26 additions & 0 deletions packages/core/test/browser-session.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,32 @@ describe('BrowserSessionManager', () => {
expect(bsm.getOwnedTabIds(agentId, sessionA)).toEqual([9]);
});

it('emits ownership events on claim and release for UI badges', async () => {
const events: Array<{ action: string; pageId: number; agentId: string }> = [];
const unsub = bsm.onOwnershipChange((e) => {
events.push({ action: e.action, pageId: e.pageId, agentId: e.agentId });
});

const handlers = bsm.wrapToolHandlers([
makeHandler('select_page', () => 'Selected page 4\n'),
makeHandler('close_page', () => 'Closed page 4\n'),
makeHandler('list_pages', () => '4: https://example.com [selected]\n'),
], agentId);

const select = handlers.find(h => h.name.endsWith('__select_page'))!;
await select.execute({ _browserSessionId: sessionA, pageId: 4 });
expect(events).toContainEqual({ action: 'claimed', pageId: 4, agentId });
expect(bsm.listOwnership()).toEqual([
expect.objectContaining({ pageId: 4, agentId }),
]);

const close = handlers.find(h => h.name.endsWith('__close_page'))!;
await close.execute({ _browserSessionId: sessionA, pageId: 4 });
expect(events).toContainEqual({ action: 'released', pageId: 4, agentId });
expect(bsm.listOwnership()).toEqual([]);
unsub();
});

it('list_pages annotates owned vs shared vs other-session tabs', async () => {
const handlers = bsm.wrapToolHandlers([
makeHandler('new_page', (args) => {
Expand Down
2 changes: 1 addition & 1 deletion packages/desktop/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -35,5 +35,5 @@
"esbuild": "^0.27.4",
"typescript": "^5.9.3"
},
"version": "0.9.0-rc.17"
"version": "0.9.1"
}
2 changes: 1 addition & 1 deletion packages/gui/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -17,5 +17,5 @@
"devDependencies": {
"@types/sharp": "^0.32.0"
},
"version": "0.9.0-rc.17"
"version": "0.9.1"
}
2 changes: 1 addition & 1 deletion packages/org-manager/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -21,5 +21,5 @@
"devDependencies": {
"@types/ws": "^8.18.1"
},
"version": "0.9.0-rc.17"
"version": "0.9.1"
}
7 changes: 7 additions & 0 deletions packages/org-manager/src/api-server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10679,6 +10679,12 @@ EXPLANATION_END`;
return;
}

if (path === '/api/browser/tab-ownership' && req.method === 'GET') {
const am = this.orgService.getAgentManager();
this.json(res, 200, { ownership: am.getBrowserTabOwnership() });
return;
}

if (path === '/api/system/storage' && req.method === 'GET') {
try {
const dataDir = join(homedir(), '.markus');
Expand Down Expand Up @@ -12200,6 +12206,7 @@ EXPLANATION_END`;
exact('/api/system/resume-all', 'POST'),
exact('/api/system/emergency-stop', 'POST'),
exact('/api/system/status', 'GET'),
exact('/api/browser/tab-ownership', 'GET'),
exact('/api/system/storage', 'GET'),
exact('/api/system/storage/orphans', 'GET', 'DELETE'),
exact('/api/system/announcements', 'GET', 'POST'),
Expand Down
4 changes: 3 additions & 1 deletion packages/org-manager/src/task-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2457,7 +2457,9 @@ export class TaskService {
}

cancelTask(id: string, cascade: boolean, updatedBy?: string, updatedByType?: 'human' | 'agent' | 'system'): Task {
const task = this.updateTaskStatus(id, 'cancelled', updatedBy, false, false, updatedByType);
// _internal=true: cancel is an explicit endpoint (like reject), so pending→cancelled
// must bypass the "use approve/reject" guard that blocks generic status updates.
const task = this.updateTaskStatus(id, 'cancelled', updatedBy, true, false, updatedByType);
if (task.taskType === 'scheduled' && task.scheduleConfig && !task.scheduleConfig.paused) {
task.scheduleConfig.paused = true;
this.updateScheduleConfig(id, task.scheduleConfig)
Expand Down
7 changes: 7 additions & 0 deletions packages/org-manager/test/task-service.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1281,6 +1281,13 @@ describe('TaskService', () => {
expect(webhook).toHaveBeenCalledWith(expect.objectContaining({ type: 'status_changed', status: 'cancelled' }));
});

it('cancelTask can cancel a pending task without approve/reject', () => {
const task = ts.createTask(createDefaults({ creatorRole: 'human' }) as never);
expect(task.status).toBe('pending');
const cancelled = ts.cancelTask(task.id, false, 'user-1', 'human');
expect(cancelled.status).toBe('cancelled');
});

it('assignTask to new agent updates assignment', () => {
const task = ts.createTask(createDefaults({ creatorRole: 'human' }) as never);
const assigned = ts.assignTask(task.id, AGENT_B, 'user-1');
Expand Down
Loading
Loading