feat tabId

This commit is contained in:
shashwat-runable
2026-04-29 18:26:51 +05:30
parent 94c45980cc
commit 77b4869284
10 changed files with 93 additions and 63 deletions
+17 -7
View File
@@ -85,7 +85,7 @@ Recording:
record status Check recording status
Tabs:
tab list / new [url] / close [n]
tab list / new [url] / close [id]
Other:
js <code> Eval JS in page — strings print raw, objects print JSON
@@ -95,7 +95,7 @@ Other:
Flags:
--timeout <ms> (default: 30000)
--tab <n> target tab (default: 0)
--tab <id> target tab by stable Chrome id (default: first page)
--json structured output (snap, tab list, logs, audit)
--right right-click
--double double-click
@@ -182,15 +182,25 @@ output.
### Tabs
`tab list` prints index, URL, and title for each open tab.
`tab new [url]` opens a new tab (optionally navigating) and prints its index.
`tab close [n]` closes a tab by index (default: last). Cannot close the last
remaining tab.
Every tab has a stable id (Chrome's CDP `targetId`) that survives other tabs
opening or closing. Use `--tab <id>` to target a specific tab from any
command. Without `--tab`, commands operate on the first open page.
`tab list` prints id, URL, and title for each open tab.
`tab new [url]` opens a new tab (optionally navigating) and prints its id.
`tab close [id]` closes a tab by id (default: last opened). Cannot close the
last remaining tab.
```bash
ID=$(mb tab new https://example.com)
mb --tab "$ID" shot example.png
mb tab close "$ID"
```
### JSON output
`--json` on `snap` returns `[{role, name, x, y, state}]`.
`--json` on `tab list` returns `[{index, url, title}]`.
`--json` on `tab list` returns `[{id, url, title}]`.
`--json` on `logs` emits JSON lines `{tab, type, time, message}`.
`--json` on `audit` returns the full audit data as a JSON object.
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@runablehq/mini-browser",
"version": "0.7.1",
"version": "1.0.0",
"type": "module",
"description": "A browser CLI for agents, built with Puppeteer",
"keywords": [
+1 -1
View File
@@ -21,7 +21,7 @@ const getDaemonPath = () => {
interface StartInput {
path: string
tab: number
tab?: string
fps: number
scale: number
}
+45 -23
View File
@@ -1,14 +1,15 @@
import puppeteer from "puppeteer-core"
import { CDP_URL, VIEWPORT } from "../lib/config"
import { targetId } from "../lib/browser"
import type { Flags } from "../lib/flags"
const subcommands = { list, new: newTab, close }
const help = `Usage: mb tab <list|new|close> [args]
list List open tabs
new [url] Open new tab, print index
close [n] Close tab (default: last)`
list List open tabs (id, url, title)
new [url] Open new tab, print id
close [id] Close tab by id (default: last opened)`
export const tab = async (args: string[], flags: Flags) => {
const [sub, ...rest] = args
@@ -24,14 +25,17 @@ export const tab = async (args: string[], flags: Flags) => {
async function list(_args: string[], flags: Flags) {
const browser = await puppeteer.connect({ browserURL: CDP_URL, defaultViewport: VIEWPORT })
const pages = await browser.pages()
const targets = browser.targets().filter((t) => t.type() === "page")
const entries = await Promise.all(
pages.map(async (page, i) => ({
index: i,
url: page.url(),
title: await page.title(),
}))
targets.map(async (target) => {
const page = await target.page()
return {
id: targetId(target),
url: target.url(),
title: page ? await page.title() : "",
}
}),
)
await browser.disconnect()
@@ -41,15 +45,29 @@ async function list(_args: string[], flags: Flags) {
return
}
for (const { index, url, title } of entries) {
console.log(`${index}\t${url}\t${title}`)
for (const { id, url, title } of entries) {
console.log(`${id}\t${url}\t${title}`)
}
}
async function newTab(args: string[], flags: Flags) {
const url = args[0]
const browser = await puppeteer.connect({ browserURL: CDP_URL, defaultViewport: VIEWPORT })
const page = await browser.newPage()
/* page.tagrte is deprecated in puppeteer-core,
so we use the CDP Target.createTarget directly to get the targetId */
const cdp = await browser.target().createCDPSession()
const { targetId: id } = await cdp.send("Target.createTarget", { url: "about:blank" })
await cdp.detach()
const target = await browser.waitForTarget((t) => targetId(t) === id)
const page = await target.page()
if (!page) {
await browser.disconnect()
throw new Error("Failed to attach to new tab")
}
if (url) {
try {
@@ -61,31 +79,35 @@ async function newTab(args: string[], flags: Flags) {
}
}
const pages = await browser.pages()
const index = pages.indexOf(page)
await browser.disconnect()
console.log(index)
console.log(id)
}
async function close(args: string[], _flags: Flags) {
const browser = await puppeteer.connect({ browserURL: CDP_URL, defaultViewport: VIEWPORT })
const pages = await browser.pages()
const targets = browser.targets().filter((t) => t.type() === "page")
if (pages.length <= 1) {
if (targets.length <= 1) {
await browser.disconnect()
throw new Error("Cannot close the last tab")
}
const index = args[0] !== undefined ? Number(args[0]) : pages.length - 1
const requestedId = args[0]
if (!Number.isInteger(index) || index < 0 || index >= pages.length) {
const target = requestedId
? targets.find((t) => targetId(t) === requestedId)
: targets[targets.length - 1]
if (!target) {
await browser.disconnect()
throw new Error(`Invalid tab index: ${args[0]}. Open tabs: 0-${pages.length - 1}`)
throw new Error(`No tab with id: ${requestedId}`)
}
const target = pages[index]!
const id = targetId(target)
const url = target.url()
await target.close()
const page = await target.page()
await page!.close()
await browser.disconnect()
console.log(`Closed tab ${index}\t${url}`)
console.log(`Closed tab ${id}\t${url}`)
}
+3 -3
View File
@@ -60,12 +60,12 @@ Other:
Tabs:
tab list List open tabs
tab new [url] Open new tab, print index
tab close [n] Close tab (default: last)
tab new [url] Open new tab, print id
tab close [id] Close tab by id (default: last opened)
Flags:
--timeout <ms> Timeout (default: 30000)
--tab <n> Tab index (default: 0)
--tab <id> Stable Chrome target id (default: first page)
--json JSON output
--right/--double Right/double click
--fps <n> Recording frame rate (default: 30)
+21 -22
View File
@@ -1,33 +1,32 @@
import puppeteer, { type Page } from "puppeteer-core"
import puppeteer, { type Target } from "puppeteer-core"
import { CDP_URL, VIEWPORT } from "./config"
export const connect = async (tab = 0) => {
/* puppeteer-core does not expose a public method for the underlying CDP
target id, so we reach into `_targetId`. */
export const targetId = (t: Target) =>
(t as unknown as { _targetId: string })._targetId
export const connect = async (tab?: string) => {
const browser = await puppeteer.connect({
browserURL: CDP_URL,
defaultViewport: VIEWPORT,
})
const pages = await browser.pages()
if (tab < 0 || tab >= pages.length) {
const targets = browser.targets().filter((t) => t.type() === "page")
const target = tab ? targets.find((t) => targetId(t) === tab) : targets[0]
const page = target ? await target.page() : null
if (!page) {
await browser.disconnect()
throw new Error(`Invalid tab index: ${tab}. Open tabs: 0-${pages.length - 1}`)
throw new Error(tab ? `No tab with id: ${tab}` : "No open tabs")
}
const page = pages[tab]!
if (!page) throw new Error("No pages found")
return { browser, page, close: () => browser.disconnect() as Promise<void> }
}
interface WithPageInput {
tab: number
}
export const withPage = async <T>(
{ tab }: WithPageInput,
fn: (page: Page) => Promise<T>
) => {
const { page, close } = await connect(tab)
try {
return await fn(page)
} finally {
await close()
return {
browser,
page,
close: () => browser.disconnect() as Promise<void>,
}
}
-1
View File
@@ -1,4 +1,3 @@
export const CDP_URL = "http://127.0.0.1:9222"
export const VIEWPORT = { width: 1024, height: 768 }
export const DEFAULT_TIMEOUT = 30000
export const DEFAULT_TAB = 0
+3 -3
View File
@@ -1,5 +1,5 @@
import { parseArgs } from "node:util"
import { DEFAULT_TAB, DEFAULT_TIMEOUT } from "./config"
import { DEFAULT_TIMEOUT } from "./config"
interface NumericFlagInput {
name: string
@@ -38,7 +38,7 @@ export const parse = (argv: string[]) => {
allowPositionals: true,
options: {
timeout: { type: "string", default: String(DEFAULT_TIMEOUT) },
tab: { type: "string", default: String(DEFAULT_TAB) },
tab: { type: "string" },
json: { type: "boolean", default: false },
right: { type: "boolean", default: false },
double: { type: "boolean", default: false },
@@ -58,7 +58,7 @@ export const parse = (argv: string[]) => {
args: positionals,
flags: {
timeout: parseNumericFlag({ name: "timeout", value: values.timeout }),
tab: parseNumericFlag({ name: "tab", value: values.tab }),
tab: values.tab,
json: values.json!,
right: values.right!,
double: values.double!,
+1 -1
View File
@@ -7,7 +7,7 @@ const STATE_FILE = join(homedir(), ".mb-recorder.json")
export interface RecorderState {
pid: number
path: string
tab: number
tab?: string
fps: number
scale: number
startedAt: number
+1 -1
View File
@@ -8,7 +8,7 @@ import { clearState, writeState, readState } from "./lib/recorder-state"
interface DaemonConfig {
path: string
tab: number
tab?: string
fps: number
scale: number
}