@jackwener/opencli 1.2.0 → 1.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -15,10 +15,10 @@ jobs:
15
15
  runs-on: ubuntu-latest
16
16
  steps:
17
17
  - name: Checkout Code
18
- uses: actions/checkout@v4
18
+ uses: actions/checkout@v6
19
19
 
20
20
  - name: Setup Node.js
21
- uses: actions/setup-node@v4
21
+ uses: actions/setup-node@v6
22
22
  with:
23
23
  node-version: 20
24
24
  cache: 'npm'
@@ -69,7 +69,7 @@ jobs:
69
69
 
70
70
  - name: Attach to GitHub Release
71
71
  if: startsWith(github.ref, 'refs/tags/')
72
- uses: softprops/action-gh-release@v2
72
+ uses: softprops/action-gh-release@v2.6.1
73
73
  with:
74
74
  files: |
75
75
  opencli-extension.zip
@@ -18,9 +18,9 @@ jobs:
18
18
  build:
19
19
  runs-on: ubuntu-latest
20
20
  steps:
21
- - uses: actions/checkout@v4
21
+ - uses: actions/checkout@v6
22
22
 
23
- - uses: actions/setup-node@v4
23
+ - uses: actions/setup-node@v6
24
24
  with:
25
25
  node-version: '22'
26
26
  cache: 'npm'
@@ -43,9 +43,9 @@ jobs:
43
43
  node-version: ['20', '22']
44
44
  shard: [1, 2]
45
45
  steps:
46
- - uses: actions/checkout@v4
46
+ - uses: actions/checkout@v6
47
47
 
48
- - uses: actions/setup-node@v4
48
+ - uses: actions/setup-node@v6
49
49
  with:
50
50
  node-version: ${{ matrix.node-version }}
51
51
  cache: 'npm'
@@ -62,9 +62,9 @@ jobs:
62
62
  needs: build
63
63
  runs-on: ubuntu-latest
64
64
  steps:
65
- - uses: actions/checkout@v4
65
+ - uses: actions/checkout@v6
66
66
 
67
- - uses: actions/setup-node@v4
67
+ - uses: actions/setup-node@v6
68
68
  with:
69
69
  node-version: '22'
70
70
  cache: 'npm'
@@ -13,7 +13,7 @@ jobs:
13
13
  doc-coverage:
14
14
  runs-on: ubuntu-latest
15
15
  steps:
16
- - uses: actions/checkout@v4
16
+ - uses: actions/checkout@v6
17
17
 
18
18
  - name: Check adapter doc coverage
19
19
  run: bash scripts/check-doc-coverage.sh --strict
@@ -22,9 +22,9 @@ jobs:
22
22
  docs-build:
23
23
  runs-on: ubuntu-latest
24
24
  steps:
25
- - uses: actions/checkout@v4
25
+ - uses: actions/checkout@v6
26
26
 
27
- - uses: actions/setup-node@v4
27
+ - uses: actions/setup-node@v6
28
28
  with:
29
29
  node-version: '22'
30
30
  cache: 'npm'
@@ -16,9 +16,9 @@ jobs:
16
16
  runs-on: ubuntu-latest
17
17
  timeout-minutes: 20
18
18
  steps:
19
- - uses: actions/checkout@v4
19
+ - uses: actions/checkout@v6
20
20
 
21
- - uses: actions/setup-node@v4
21
+ - uses: actions/setup-node@v6
22
22
  with:
23
23
  node-version: '22'
24
24
  cache: 'npm'
@@ -13,9 +13,9 @@ jobs:
13
13
  if: ${{ vars.PKG_PR_NEW_ENABLED == 'true' }}
14
14
  runs-on: ubuntu-latest
15
15
  steps:
16
- - uses: actions/checkout@v4
16
+ - uses: actions/checkout@v6
17
17
 
18
- - uses: actions/setup-node@v4
18
+ - uses: actions/setup-node@v6
19
19
  with:
20
20
  node-version: '22'
21
21
  cache: 'npm'
@@ -13,9 +13,9 @@ jobs:
13
13
  release:
14
14
  runs-on: ubuntu-latest
15
15
  steps:
16
- - uses: actions/checkout@v4
16
+ - uses: actions/checkout@v6
17
17
 
18
- - uses: actions/setup-node@v4
18
+ - uses: actions/setup-node@v6
19
19
  with:
20
20
  node-version: '22'
21
21
  registry-url: 'https://registry.npmjs.org'
@@ -27,7 +27,7 @@ jobs:
27
27
  run: npx tsc --noEmit
28
28
 
29
29
  - name: Create GitHub Release
30
- uses: softprops/action-gh-release@v2
30
+ uses: softprops/action-gh-release@v2.6.1
31
31
  with:
32
32
  generate_release_notes: true
33
33
 
@@ -19,9 +19,9 @@ jobs:
19
19
  audit:
20
20
  runs-on: ubuntu-latest
21
21
  steps:
22
- - uses: actions/checkout@v4
22
+ - uses: actions/checkout@v6
23
23
 
24
- - uses: actions/setup-node@v4
24
+ - uses: actions/setup-node@v6
25
25
  with:
26
26
  node-version: '22'
27
27
  cache: 'npm'
package/README.md CHANGED
@@ -342,6 +342,10 @@ git push --follow-tags
342
342
 
343
343
  The CI will automatically build, create a GitHub release, and publish to npm.
344
344
 
345
+ ## Star History
346
+
347
+ [![Star History Chart](https://api.star-history.com/svg?repos=jackwener/opencli&type=Date)](https://star-history.com/#jackwener/opencli&Date)
348
+
345
349
  ## License
346
350
 
347
351
  [Apache-2.0](./LICENSE)
package/README.zh-CN.md CHANGED
@@ -325,6 +325,10 @@ npm version minor # 0.1.0 → 0.2.0
325
325
  git push --follow-tags
326
326
  ```
327
327
 
328
+ ## Star History
329
+
330
+ [![Star History Chart](https://api.star-history.com/svg?repos=jackwener/opencli&type=Date)](https://star-history.com/#jackwener/opencli&Date)
331
+
328
332
  ## License
329
333
 
330
334
  [Apache-2.0](./LICENSE)
@@ -3,6 +3,7 @@
3
3
  *
4
4
  * Provides a typed send() function that posts a Command and returns a Result.
5
5
  */
6
+ import type { BrowserSessionInfo } from '../types.js';
6
7
  export interface DaemonCommand {
7
8
  id: string;
8
9
  action: 'exec' | 'navigate' | 'tabs' | 'cookies' | 'screenshot' | 'close-window' | 'sessions';
@@ -33,8 +34,8 @@ export declare function isDaemonRunning(): Promise<boolean>;
33
34
  export declare function isExtensionConnected(): Promise<boolean>;
34
35
  /**
35
36
  * Send a command to the daemon and wait for a result.
36
- * Retries up to 3 times with 500ms delay for transient failures.
37
+ * Retries up to 4 times: network errors retry at 500ms,
38
+ * transient extension errors retry at 1500ms.
37
39
  */
38
40
  export declare function sendCommand(action: DaemonCommand['action'], params?: Omit<DaemonCommand, 'id' | 'action'>): Promise<unknown>;
39
41
  export declare function listSessions(): Promise<BrowserSessionInfo[]>;
40
- import type { BrowserSessionInfo } from '../types.js';
@@ -44,13 +44,15 @@ export async function isExtensionConnected() {
44
44
  }
45
45
  /**
46
46
  * Send a command to the daemon and wait for a result.
47
- * Retries up to 3 times with 500ms delay for transient failures.
47
+ * Retries up to 4 times: network errors retry at 500ms,
48
+ * transient extension errors retry at 1500ms.
48
49
  */
49
50
  export async function sendCommand(action, params = {}) {
50
- const id = generateId();
51
- const command = { id, action, ...params };
52
- const maxRetries = 3;
51
+ const maxRetries = 4;
53
52
  for (let attempt = 1; attempt <= maxRetries; attempt++) {
53
+ // Generate a fresh ID per attempt to avoid daemon-side duplicate detection
54
+ const id = generateId();
55
+ const command = { id, action, ...params };
54
56
  try {
55
57
  const controller = new AbortController();
56
58
  const timer = setTimeout(() => controller.abort(), 30000);
@@ -63,6 +65,17 @@ export async function sendCommand(action, params = {}) {
63
65
  clearTimeout(timer);
64
66
  const result = (await res.json());
65
67
  if (!result.ok) {
68
+ // Check if error is a transient extension issue worth retrying
69
+ const errMsg = result.error ?? '';
70
+ const isTransient = errMsg.includes('Extension disconnected')
71
+ || errMsg.includes('Extension not connected')
72
+ || errMsg.includes('attach failed')
73
+ || errMsg.includes('no longer exists');
74
+ if (isTransient && attempt < maxRetries) {
75
+ // Longer delay for extension recovery (service worker restart)
76
+ await new Promise(r => setTimeout(r, 1500));
77
+ continue;
78
+ }
66
79
  throw new Error(result.error ?? 'Daemon command failed');
67
80
  }
68
81
  return result.data;
@@ -49,7 +49,9 @@ export class BrowserBridge {
49
49
  this._state = 'closed';
50
50
  }
51
51
  async _ensureDaemon(timeoutSeconds) {
52
- const timeoutMs = Math.max(1, timeoutSeconds ?? Math.ceil(DAEMON_SPAWN_TIMEOUT / 1000)) * 1000;
52
+ // Use default if not provided, zero, or negative
53
+ const effectiveSeconds = (timeoutSeconds && timeoutSeconds > 0) ? timeoutSeconds : Math.ceil(DAEMON_SPAWN_TIMEOUT / 1000);
54
+ const timeoutMs = effectiveSeconds * 1000;
53
55
  if (await isExtensionConnected())
54
56
  return;
55
57
  if (await isDaemonRunning()) {
@@ -164,12 +164,19 @@ export class Page {
164
164
  }
165
165
  async closeTab(index) {
166
166
  await sendCommand('tabs', { op: 'close', ...this._workspaceOpt(), ...(index !== undefined ? { index } : {}) });
167
+ // Invalidate cached tabId — the closed tab might have been our active one.
168
+ // We can't know for sure (close-by-index doesn't return tabId), so reset.
169
+ this._tabId = undefined;
167
170
  }
168
171
  async newTab() {
169
- await sendCommand('tabs', { op: 'new', ...this._workspaceOpt() });
172
+ const result = await sendCommand('tabs', { op: 'new', ...this._workspaceOpt() });
173
+ if (result?.tabId)
174
+ this._tabId = result.tabId;
170
175
  }
171
176
  async selectTab(index) {
172
- await sendCommand('tabs', { op: 'select', index, ...this._workspaceOpt() });
177
+ const result = await sendCommand('tabs', { op: 'select', index, ...this._workspaceOpt() });
178
+ if (result?.selected)
179
+ this._tabId = result.selected;
173
180
  }
174
181
  async networkRequests(includeStatic = false) {
175
182
  const code = networkRequestsJs(includeStatic);
package/dist/daemon.js CHANGED
@@ -118,6 +118,25 @@ const wss = new WebSocketServer({ server: httpServer, path: '/ext' });
118
118
  wss.on('connection', (ws) => {
119
119
  console.error('[daemon] Extension connected');
120
120
  extensionWs = ws;
121
+ // ── Heartbeat: ping every 15s, close if 2 pongs missed ──
122
+ let missedPongs = 0;
123
+ const heartbeatInterval = setInterval(() => {
124
+ if (ws.readyState !== WebSocket.OPEN) {
125
+ clearInterval(heartbeatInterval);
126
+ return;
127
+ }
128
+ if (missedPongs >= 2) {
129
+ console.error('[daemon] Extension heartbeat lost, closing connection');
130
+ clearInterval(heartbeatInterval);
131
+ ws.terminate();
132
+ return;
133
+ }
134
+ missedPongs++;
135
+ ws.ping();
136
+ }, 15000);
137
+ ws.on('pong', () => {
138
+ missedPongs = 0;
139
+ });
121
140
  ws.on('message', (data) => {
122
141
  try {
123
142
  const msg = JSON.parse(data.toString());
@@ -142,6 +161,7 @@ wss.on('connection', (ws) => {
142
161
  });
143
162
  ws.on('close', () => {
144
163
  console.error('[daemon] Extension disconnected');
164
+ clearInterval(heartbeatInterval);
145
165
  if (extensionWs === ws) {
146
166
  extensionWs = null;
147
167
  // Reject all pending requests since the extension is gone
@@ -153,6 +173,7 @@ wss.on('connection', (ws) => {
153
173
  }
154
174
  });
155
175
  ws.on('error', () => {
176
+ clearInterval(heartbeatInterval);
156
177
  if (extensionWs === ws)
157
178
  extensionWs = null;
158
179
  });
@@ -5,5 +5,7 @@ import type { IPage } from '../types.js';
5
5
  export interface PipelineContext {
6
6
  args?: Record<string, unknown>;
7
7
  debug?: boolean;
8
+ /** Max retry attempts per step (default: 2 for browser steps, 0 for others) */
9
+ stepRetries?: number;
8
10
  }
9
11
  export declare function executePipeline(page: IPage | null, pipeline: unknown[], ctx?: PipelineContext): Promise<unknown>;
@@ -4,31 +4,70 @@
4
4
  import { getStep } from './registry.js';
5
5
  import { log } from '../logger.js';
6
6
  import { ConfigError } from '../errors.js';
7
+ /** Steps that interact with the browser and may fail transiently */
8
+ const BROWSER_STEPS = new Set(['navigate', 'evaluate', 'click', 'type', 'press', 'wait', 'snapshot', 'scroll']);
7
9
  export async function executePipeline(page, pipeline, ctx = {}) {
8
10
  const args = ctx.args ?? {};
9
11
  const debug = ctx.debug ?? false;
10
12
  let data = null;
11
13
  const total = pipeline.length;
12
- for (let i = 0; i < pipeline.length; i++) {
13
- const step = pipeline[i];
14
- if (!step || typeof step !== 'object')
15
- continue;
16
- for (const [op, params] of Object.entries(step)) {
17
- if (debug)
18
- debugStepStart(i + 1, total, op, params);
19
- const handler = getStep(op);
20
- if (handler) {
21
- data = await handler(page, params, data, args);
14
+ try {
15
+ for (let i = 0; i < pipeline.length; i++) {
16
+ const step = pipeline[i];
17
+ if (!step || typeof step !== 'object')
18
+ continue;
19
+ for (const [op, params] of Object.entries(step)) {
20
+ if (debug)
21
+ debugStepStart(i + 1, total, op, params);
22
+ const handler = getStep(op);
23
+ if (handler) {
24
+ data = await executeStepWithRetry(handler, page, params, data, args, op, ctx.stepRetries);
25
+ }
26
+ else {
27
+ throw new ConfigError(`Unknown pipeline step "${op}" at index ${i}.`, 'Check the YAML pipeline step name or register the custom step before execution.');
28
+ }
29
+ if (debug)
30
+ debugStepResult(op, data);
22
31
  }
23
- else {
24
- throw new ConfigError(`Unknown pipeline step "${op}" at index ${i}.`, 'Check the YAML pipeline step name or register the custom step before execution.');
32
+ }
33
+ }
34
+ catch (err) {
35
+ // Attempt cleanup: close automation window on pipeline failure
36
+ if (page && typeof page.closeWindow === 'function') {
37
+ try {
38
+ await page.closeWindow();
25
39
  }
26
- if (debug)
27
- debugStepResult(op, data);
40
+ catch { /* ignore */ }
28
41
  }
42
+ throw err;
29
43
  }
30
44
  return data;
31
45
  }
46
+ async function executeStepWithRetry(handler, page, params, data, args, op, configRetries) {
47
+ const maxRetries = configRetries ?? (BROWSER_STEPS.has(op) ? 2 : 0);
48
+ for (let attempt = 0; attempt <= maxRetries; attempt++) {
49
+ try {
50
+ return await handler(page, params, data, args);
51
+ }
52
+ catch (err) {
53
+ if (attempt >= maxRetries)
54
+ throw err;
55
+ // Only retry on transient browser errors
56
+ const msg = err instanceof Error ? err.message : '';
57
+ const isTransient = msg.includes('Extension disconnected')
58
+ || msg.includes('attach failed')
59
+ || msg.includes('no longer exists')
60
+ || msg.includes('CDP connection')
61
+ || msg.includes('Daemon command failed');
62
+ if (!isTransient)
63
+ throw err;
64
+ // Brief delay before retry
65
+ await new Promise(resolve => setTimeout(resolve, 1000));
66
+ }
67
+ }
68
+ // Unreachable
69
+ throw new Error(`Step "${op}" failed after ${maxRetries} retries`);
70
+ }
32
71
  function debugStepStart(stepNum, total, op, params) {
33
72
  let preview = '';
34
73
  if (typeof params === 'string') {
@@ -5,8 +5,33 @@ const WS_RECONNECT_BASE_DELAY = 2e3;
5
5
  const WS_RECONNECT_MAX_DELAY = 6e4;
6
6
 
7
7
  const attached = /* @__PURE__ */ new Set();
8
+ function isDebuggableUrl$1(url) {
9
+ if (!url) return false;
10
+ return !url.startsWith("chrome://") && !url.startsWith("chrome-extension://");
11
+ }
8
12
  async function ensureAttached(tabId) {
9
- if (attached.has(tabId)) return;
13
+ try {
14
+ const tab = await chrome.tabs.get(tabId);
15
+ if (!isDebuggableUrl$1(tab.url)) {
16
+ attached.delete(tabId);
17
+ throw new Error(`Cannot debug tab ${tabId}: URL is ${tab.url ?? "unknown"}`);
18
+ }
19
+ } catch (e) {
20
+ if (e instanceof Error && e.message.startsWith("Cannot debug tab")) throw e;
21
+ attached.delete(tabId);
22
+ throw new Error(`Tab ${tabId} no longer exists`);
23
+ }
24
+ if (attached.has(tabId)) {
25
+ try {
26
+ await chrome.debugger.sendCommand({ tabId }, "Runtime.evaluate", {
27
+ expression: "1",
28
+ returnByValue: true
29
+ });
30
+ return;
31
+ } catch {
32
+ attached.delete(tabId);
33
+ }
34
+ }
10
35
  try {
11
36
  await chrome.debugger.attach({ tabId }, "1.3");
12
37
  } catch (e) {
@@ -89,6 +114,17 @@ function registerListeners() {
89
114
  chrome.debugger.onDetach.addListener((source) => {
90
115
  if (source.tabId) attached.delete(source.tabId);
91
116
  });
117
+ chrome.tabs.onUpdated.addListener((tabId, info) => {
118
+ if (info.url && !isDebuggableUrl$1(info.url)) {
119
+ if (attached.has(tabId)) {
120
+ attached.delete(tabId);
121
+ try {
122
+ chrome.debugger.detach({ tabId });
123
+ } catch {
124
+ }
125
+ }
126
+ }
127
+ });
92
128
  }
93
129
 
94
130
  let ws = null;
@@ -161,7 +197,7 @@ function scheduleReconnect() {
161
197
  }, delay);
162
198
  }
163
199
  const automationSessions = /* @__PURE__ */ new Map();
164
- const WINDOW_IDLE_TIMEOUT = 3e4;
200
+ const WINDOW_IDLE_TIMEOUT = 12e4;
165
201
  function getWorkspaceKey(workspace) {
166
202
  return workspace?.trim() || "default";
167
203
  }
@@ -265,17 +301,30 @@ async function handleCommand(cmd) {
265
301
  };
266
302
  }
267
303
  }
268
- function isWebUrl(url) {
304
+ function isDebuggableUrl(url) {
269
305
  if (!url) return false;
270
306
  return !url.startsWith("chrome://") && !url.startsWith("chrome-extension://");
271
307
  }
272
308
  async function resolveTabId(tabId, workspace) {
273
- if (tabId !== void 0) return tabId;
309
+ if (tabId !== void 0) {
310
+ try {
311
+ const tab = await chrome.tabs.get(tabId);
312
+ if (isDebuggableUrl(tab.url)) return tabId;
313
+ console.warn(`[opencli] Tab ${tabId} URL is not debuggable (${tab.url}), re-resolving`);
314
+ } catch {
315
+ console.warn(`[opencli] Tab ${tabId} no longer exists, re-resolving`);
316
+ }
317
+ }
274
318
  const windowId = await getAutomationWindow(workspace);
275
319
  const tabs = await chrome.tabs.query({ windowId });
276
- const webTab = tabs.find((t) => t.id && isWebUrl(t.url));
277
- if (webTab?.id) return webTab.id;
278
- if (tabs.length > 0 && tabs[0]?.id) return tabs[0].id;
320
+ const debuggableTab = tabs.find((t) => t.id && isDebuggableUrl(t.url));
321
+ if (debuggableTab?.id) return debuggableTab.id;
322
+ const reuseTab = tabs.find((t) => t.id);
323
+ if (reuseTab?.id) {
324
+ await chrome.tabs.update(reuseTab.id, { url: "about:blank" });
325
+ await new Promise((resolve) => setTimeout(resolve, 200));
326
+ return reuseTab.id;
327
+ }
279
328
  const newTab = await chrome.tabs.create({ windowId, url: "about:blank", active: true });
280
329
  if (!newTab.id) throw new Error("Failed to create tab in automation window");
281
330
  return newTab.id;
@@ -292,7 +341,7 @@ async function listAutomationTabs(workspace) {
292
341
  }
293
342
  async function listAutomationWebTabs(workspace) {
294
343
  const tabs = await listAutomationTabs(workspace);
295
- return tabs.filter((tab) => isWebUrl(tab.url));
344
+ return tabs.filter((tab) => isDebuggableUrl(tab.url));
296
345
  }
297
346
  async function handleExec(cmd, workspace) {
298
347
  if (!cmd.code) return { id: cmd.id, ok: false, error: "Missing code" };
@@ -307,28 +356,47 @@ async function handleExec(cmd, workspace) {
307
356
  async function handleNavigate(cmd, workspace) {
308
357
  if (!cmd.url) return { id: cmd.id, ok: false, error: "Missing url" };
309
358
  const tabId = await resolveTabId(cmd.tabId, workspace);
310
- await chrome.tabs.update(tabId, { url: cmd.url });
359
+ const beforeTab = await chrome.tabs.get(tabId);
360
+ const beforeUrl = beforeTab.url ?? "";
361
+ const targetUrl = cmd.url;
362
+ await chrome.tabs.update(tabId, { url: targetUrl });
363
+ let timedOut = false;
311
364
  await new Promise((resolve) => {
312
- chrome.tabs.get(tabId).then((tab2) => {
313
- if (tab2.status === "complete") {
365
+ let urlChanged = false;
366
+ const listener = (id, info, tab2) => {
367
+ if (id !== tabId) return;
368
+ if (info.url && info.url !== beforeUrl) {
369
+ urlChanged = true;
370
+ }
371
+ if (urlChanged && info.status === "complete") {
372
+ chrome.tabs.onUpdated.removeListener(listener);
314
373
  resolve();
315
- return;
316
374
  }
317
- const listener = (id, info) => {
318
- if (id === tabId && info.status === "complete") {
375
+ };
376
+ chrome.tabs.onUpdated.addListener(listener);
377
+ setTimeout(async () => {
378
+ try {
379
+ const currentTab = await chrome.tabs.get(tabId);
380
+ if (currentTab.url !== beforeUrl && currentTab.status === "complete") {
319
381
  chrome.tabs.onUpdated.removeListener(listener);
320
382
  resolve();
321
383
  }
322
- };
323
- chrome.tabs.onUpdated.addListener(listener);
324
- setTimeout(() => {
325
- chrome.tabs.onUpdated.removeListener(listener);
326
- resolve();
327
- }, 15e3);
328
- });
384
+ } catch {
385
+ }
386
+ }, 100);
387
+ setTimeout(() => {
388
+ chrome.tabs.onUpdated.removeListener(listener);
389
+ timedOut = true;
390
+ console.warn(`[opencli] Navigate to ${targetUrl} timed out after 15s`);
391
+ resolve();
392
+ }, 15e3);
329
393
  });
330
394
  const tab = await chrome.tabs.get(tabId);
331
- return { id: cmd.id, ok: true, data: { title: tab.title, url: tab.url, tabId } };
395
+ return {
396
+ id: cmd.id,
397
+ ok: true,
398
+ data: { title: tab.title, url: tab.url, tabId, timedOut }
399
+ };
332
400
  }
333
401
  async function handleTabs(cmd, workspace) {
334
402
  switch (cmd.op) {
@@ -425,7 +493,7 @@ async function handleSessions(cmd) {
425
493
  const data = await Promise.all([...automationSessions.entries()].map(async ([workspace, session]) => ({
426
494
  workspace,
427
495
  windowId: session.windowId,
428
- tabCount: (await chrome.tabs.query({ windowId: session.windowId })).filter((tab) => isWebUrl(tab.url)).length,
496
+ tabCount: (await chrome.tabs.query({ windowId: session.windowId })).filter((tab) => isDebuggableUrl(tab.url)).length,
429
497
  idleMsRemaining: Math.max(0, session.idleDeadlineAt - now)
430
498
  })));
431
499
  return { id: cmd.id, ok: true, data };
@@ -97,7 +97,7 @@ type AutomationSession = {
97
97
  };
98
98
 
99
99
  const automationSessions = new Map<string, AutomationSession>();
100
- const WINDOW_IDLE_TIMEOUT = 30000; // 30s
100
+ const WINDOW_IDLE_TIMEOUT = 120000; // 120s — longer to survive slow pipelines
101
101
 
102
102
  function getWorkspaceKey(workspace?: string): string {
103
103
  return workspace?.trim() || 'default';
@@ -238,7 +238,20 @@ function isDebuggableUrl(url?: string): boolean {
238
238
  * Otherwise, find or create a tab in the dedicated automation window.
239
239
  */
240
240
  async function resolveTabId(tabId: number | undefined, workspace: string): Promise<number> {
241
- if (tabId !== undefined) return tabId;
241
+ // Even when an explicit tabId is provided, validate it is still debuggable.
242
+ // This prevents issues when extensions hijack the tab URL to chrome-extension://
243
+ // or when the tab has been closed by the user.
244
+ if (tabId !== undefined) {
245
+ try {
246
+ const tab = await chrome.tabs.get(tabId);
247
+ if (isDebuggableUrl(tab.url)) return tabId;
248
+ // Tab exists but URL is not debuggable — fall through to auto-resolve
249
+ console.warn(`[opencli] Tab ${tabId} URL is not debuggable (${tab.url}), re-resolving`);
250
+ } catch {
251
+ // Tab was closed — fall through to auto-resolve
252
+ console.warn(`[opencli] Tab ${tabId} no longer exists, re-resolving`);
253
+ }
254
+ }
242
255
 
243
256
  // Get (or create) the automation window
244
257
  const windowId = await getAutomationWindow(workspace);
@@ -255,6 +268,8 @@ async function resolveTabId(tabId: number | undefined, workspace: string): Promi
255
268
  const reuseTab = tabs.find(t => t.id);
256
269
  if (reuseTab?.id) {
257
270
  await chrome.tabs.update(reuseTab.id, { url: 'about:blank' });
271
+ // Wait briefly for the navigation to take effect
272
+ await new Promise(resolve => setTimeout(resolve, 200));
258
273
  return reuseTab.id;
259
274
  }
260
275
 
@@ -294,31 +309,62 @@ async function handleExec(cmd: Command, workspace: string): Promise<Result> {
294
309
  async function handleNavigate(cmd: Command, workspace: string): Promise<Result> {
295
310
  if (!cmd.url) return { id: cmd.id, ok: false, error: 'Missing url' };
296
311
  const tabId = await resolveTabId(cmd.tabId, workspace);
297
- await chrome.tabs.update(tabId, { url: cmd.url });
298
312
 
299
- // Wait for page to finish loading, checking current status first to avoid race
313
+ // Capture the current URL before navigation to detect actual URL change
314
+ const beforeTab = await chrome.tabs.get(tabId);
315
+ const beforeUrl = beforeTab.url ?? '';
316
+ const targetUrl = cmd.url;
317
+
318
+ await chrome.tabs.update(tabId, { url: targetUrl });
319
+
320
+ // Wait for: 1) URL to change from the old URL, 2) tab.status === 'complete'
321
+ // This avoids the race where 'complete' fires for the OLD URL (e.g. about:blank)
322
+ let timedOut = false;
300
323
  await new Promise<void>((resolve) => {
301
- // Check if already complete (e.g. cached pages)
302
- chrome.tabs.get(tabId).then(tab => {
303
- if (tab.status === 'complete') { resolve(); return; }
324
+ let urlChanged = false;
325
+
326
+ const listener = (id: number, info: chrome.tabs.TabChangeInfo, tab: chrome.tabs.Tab) => {
327
+ if (id !== tabId) return;
328
+
329
+ // Track URL change (new URL differs from the one before navigation)
330
+ if (info.url && info.url !== beforeUrl) {
331
+ urlChanged = true;
332
+ }
304
333
 
305
- const listener = (id: number, info: chrome.tabs.TabChangeInfo) => {
306
- if (id === tabId && info.status === 'complete') {
334
+ // Only resolve when both URL has changed AND status is complete
335
+ if (urlChanged && info.status === 'complete') {
336
+ chrome.tabs.onUpdated.removeListener(listener);
337
+ resolve();
338
+ }
339
+ };
340
+ chrome.tabs.onUpdated.addListener(listener);
341
+
342
+ // Also check if the tab already navigated (e.g. instant cache hit)
343
+ setTimeout(async () => {
344
+ try {
345
+ const currentTab = await chrome.tabs.get(tabId);
346
+ if (currentTab.url !== beforeUrl && currentTab.status === 'complete') {
307
347
  chrome.tabs.onUpdated.removeListener(listener);
308
348
  resolve();
309
349
  }
310
- };
311
- chrome.tabs.onUpdated.addListener(listener);
312
- // Timeout fallback
313
- setTimeout(() => {
314
- chrome.tabs.onUpdated.removeListener(listener);
315
- resolve();
316
- }, 15000);
317
- });
350
+ } catch { /* tab gone */ }
351
+ }, 100);
352
+
353
+ // Timeout fallback with warning
354
+ setTimeout(() => {
355
+ chrome.tabs.onUpdated.removeListener(listener);
356
+ timedOut = true;
357
+ console.warn(`[opencli] Navigate to ${targetUrl} timed out after 15s`);
358
+ resolve();
359
+ }, 15000);
318
360
  });
319
361
 
320
362
  const tab = await chrome.tabs.get(tabId);
321
- return { id: cmd.id, ok: true, data: { title: tab.title, url: tab.url, tabId } };
363
+ return {
364
+ id: cmd.id,
365
+ ok: true,
366
+ data: { title: tab.title, url: tab.url, tabId, timedOut },
367
+ };
322
368
  }
323
369
 
324
370
  async function handleTabs(cmd: Command, workspace: string): Promise<Result> {
@@ -8,8 +8,40 @@
8
8
 
9
9
  const attached = new Set<number>();
10
10
 
11
+ /** Check if a URL can be attached via CDP */
12
+ function isDebuggableUrl(url?: string): boolean {
13
+ if (!url) return false;
14
+ return !url.startsWith('chrome://') && !url.startsWith('chrome-extension://');
15
+ }
16
+
11
17
  async function ensureAttached(tabId: number): Promise<void> {
12
- if (attached.has(tabId)) return;
18
+ // Verify the tab URL is debuggable before attempting attach
19
+ try {
20
+ const tab = await chrome.tabs.get(tabId);
21
+ if (!isDebuggableUrl(tab.url)) {
22
+ // Invalidate cache if previously attached
23
+ attached.delete(tabId);
24
+ throw new Error(`Cannot debug tab ${tabId}: URL is ${tab.url ?? 'unknown'}`);
25
+ }
26
+ } catch (e) {
27
+ // Re-throw our own error, catch only chrome.tabs.get failures
28
+ if (e instanceof Error && e.message.startsWith('Cannot debug tab')) throw e;
29
+ attached.delete(tabId);
30
+ throw new Error(`Tab ${tabId} no longer exists`);
31
+ }
32
+
33
+ if (attached.has(tabId)) {
34
+ // Verify the debugger is still actually attached by sending a harmless command
35
+ try {
36
+ await chrome.debugger.sendCommand({ tabId }, 'Runtime.evaluate', {
37
+ expression: '1', returnByValue: true,
38
+ });
39
+ return; // Still attached and working
40
+ } catch {
41
+ // Stale cache entry — need to re-attach
42
+ attached.delete(tabId);
43
+ }
44
+ }
13
45
 
14
46
  try {
15
47
  await chrome.debugger.attach({ tabId }, '1.3');
@@ -122,4 +154,13 @@ export function registerListeners(): void {
122
154
  chrome.debugger.onDetach.addListener((source) => {
123
155
  if (source.tabId) attached.delete(source.tabId);
124
156
  });
157
+ // Invalidate attached cache when tab URL changes to non-debuggable
158
+ chrome.tabs.onUpdated.addListener((tabId, info) => {
159
+ if (info.url && !isDebuggableUrl(info.url)) {
160
+ if (attached.has(tabId)) {
161
+ attached.delete(tabId);
162
+ try { chrome.debugger.detach({ tabId }); } catch { /* ignore */ }
163
+ }
164
+ }
165
+ });
125
166
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jackwener/opencli",
3
- "version": "1.2.0",
3
+ "version": "1.2.1",
4
4
  "publishConfig": {
5
5
  "access": "public"
6
6
  },
@@ -7,6 +7,8 @@
7
7
  const DAEMON_PORT = parseInt(process.env.OPENCLI_DAEMON_PORT ?? '19825', 10);
8
8
  const DAEMON_URL = `http://127.0.0.1:${DAEMON_PORT}`;
9
9
 
10
+ import type { BrowserSessionInfo } from '../types.js';
11
+
10
12
  let _idCounter = 0;
11
13
 
12
14
  function generateId(): string {
@@ -69,17 +71,19 @@ export async function isExtensionConnected(): Promise<boolean> {
69
71
 
70
72
  /**
71
73
  * Send a command to the daemon and wait for a result.
72
- * Retries up to 3 times with 500ms delay for transient failures.
74
+ * Retries up to 4 times: network errors retry at 500ms,
75
+ * transient extension errors retry at 1500ms.
73
76
  */
74
77
  export async function sendCommand(
75
78
  action: DaemonCommand['action'],
76
79
  params: Omit<DaemonCommand, 'id' | 'action'> = {},
77
80
  ): Promise<unknown> {
78
- const id = generateId();
79
- const command: DaemonCommand = { id, action, ...params };
80
- const maxRetries = 3;
81
+ const maxRetries = 4;
81
82
 
82
83
  for (let attempt = 1; attempt <= maxRetries; attempt++) {
84
+ // Generate a fresh ID per attempt to avoid daemon-side duplicate detection
85
+ const id = generateId();
86
+ const command: DaemonCommand = { id, action, ...params };
83
87
  try {
84
88
  const controller = new AbortController();
85
89
  const timer = setTimeout(() => controller.abort(), 30000);
@@ -95,6 +99,17 @@ export async function sendCommand(
95
99
  const result = (await res.json()) as DaemonResult;
96
100
 
97
101
  if (!result.ok) {
102
+ // Check if error is a transient extension issue worth retrying
103
+ const errMsg = result.error ?? '';
104
+ const isTransient = errMsg.includes('Extension disconnected')
105
+ || errMsg.includes('Extension not connected')
106
+ || errMsg.includes('attach failed')
107
+ || errMsg.includes('no longer exists');
108
+ if (isTransient && attempt < maxRetries) {
109
+ // Longer delay for extension recovery (service worker restart)
110
+ await new Promise(r => setTimeout(r, 1500));
111
+ continue;
112
+ }
98
113
  throw new Error(result.error ?? 'Daemon command failed');
99
114
  }
100
115
 
@@ -117,4 +132,4 @@ export async function listSessions(): Promise<BrowserSessionInfo[]> {
117
132
  const result = await sendCommand('sessions');
118
133
  return Array.isArray(result) ? result : [];
119
134
  }
120
- import type { BrowserSessionInfo } from '../types.js';
135
+
@@ -55,7 +55,9 @@ export class BrowserBridge {
55
55
  }
56
56
 
57
57
  private async _ensureDaemon(timeoutSeconds?: number): Promise<void> {
58
- const timeoutMs = Math.max(1, timeoutSeconds ?? Math.ceil(DAEMON_SPAWN_TIMEOUT / 1000)) * 1000;
58
+ // Use default if not provided, zero, or negative
59
+ const effectiveSeconds = (timeoutSeconds && timeoutSeconds > 0) ? timeoutSeconds : Math.ceil(DAEMON_SPAWN_TIMEOUT / 1000);
60
+ const timeoutMs = effectiveSeconds * 1000;
59
61
 
60
62
  if (await isExtensionConnected()) return;
61
63
  if (await isDaemonRunning()) {
@@ -186,14 +186,19 @@ export class Page implements IPage {
186
186
 
187
187
  async closeTab(index?: number): Promise<void> {
188
188
  await sendCommand('tabs', { op: 'close', ...this._workspaceOpt(), ...(index !== undefined ? { index } : {}) });
189
+ // Invalidate cached tabId — the closed tab might have been our active one.
190
+ // We can't know for sure (close-by-index doesn't return tabId), so reset.
191
+ this._tabId = undefined;
189
192
  }
190
193
 
191
194
  async newTab(): Promise<void> {
192
- await sendCommand('tabs', { op: 'new', ...this._workspaceOpt() });
195
+ const result = await sendCommand('tabs', { op: 'new', ...this._workspaceOpt() }) as { tabId?: number };
196
+ if (result?.tabId) this._tabId = result.tabId;
193
197
  }
194
198
 
195
199
  async selectTab(index: number): Promise<void> {
196
- await sendCommand('tabs', { op: 'select', index, ...this._workspaceOpt() });
200
+ const result = await sendCommand('tabs', { op: 'select', index, ...this._workspaceOpt() }) as { selected?: number };
201
+ if (result?.selected) this._tabId = result.selected;
197
202
  }
198
203
 
199
204
  async networkRequests(includeStatic: boolean = false): Promise<unknown[]> {
package/src/daemon.ts CHANGED
@@ -142,6 +142,27 @@ wss.on('connection', (ws: WebSocket) => {
142
142
  console.error('[daemon] Extension connected');
143
143
  extensionWs = ws;
144
144
 
145
+ // ── Heartbeat: ping every 15s, close if 2 pongs missed ──
146
+ let missedPongs = 0;
147
+ const heartbeatInterval = setInterval(() => {
148
+ if (ws.readyState !== WebSocket.OPEN) {
149
+ clearInterval(heartbeatInterval);
150
+ return;
151
+ }
152
+ if (missedPongs >= 2) {
153
+ console.error('[daemon] Extension heartbeat lost, closing connection');
154
+ clearInterval(heartbeatInterval);
155
+ ws.terminate();
156
+ return;
157
+ }
158
+ missedPongs++;
159
+ ws.ping();
160
+ }, 15000);
161
+
162
+ ws.on('pong', () => {
163
+ missedPongs = 0;
164
+ });
165
+
145
166
  ws.on('message', (data: RawData) => {
146
167
  try {
147
168
  const msg = JSON.parse(data.toString());
@@ -168,6 +189,7 @@ wss.on('connection', (ws: WebSocket) => {
168
189
 
169
190
  ws.on('close', () => {
170
191
  console.error('[daemon] Extension disconnected');
192
+ clearInterval(heartbeatInterval);
171
193
  if (extensionWs === ws) {
172
194
  extensionWs = null;
173
195
  // Reject all pending requests since the extension is gone
@@ -180,6 +202,7 @@ wss.on('connection', (ws: WebSocket) => {
180
202
  });
181
203
 
182
204
  ws.on('error', () => {
205
+ clearInterval(heartbeatInterval);
183
206
  if (extensionWs === ws) extensionWs = null;
184
207
  });
185
208
  });
@@ -2,7 +2,7 @@
2
2
  * Pipeline executor: runs YAML pipeline steps sequentially.
3
3
  */
4
4
 
5
- import chalk from 'chalk';
5
+
6
6
  import type { IPage } from '../types.js';
7
7
  import { getStep, type StepHandler } from './registry.js';
8
8
  import { log } from '../logger.js';
@@ -11,8 +11,13 @@ import { ConfigError } from '../errors.js';
11
11
  export interface PipelineContext {
12
12
  args?: Record<string, unknown>;
13
13
  debug?: boolean;
14
+ /** Max retry attempts per step (default: 2 for browser steps, 0 for others) */
15
+ stepRetries?: number;
14
16
  }
15
17
 
18
+ /** Steps that interact with the browser and may fail transiently */
19
+ const BROWSER_STEPS = new Set(['navigate', 'evaluate', 'click', 'type', 'press', 'wait', 'snapshot', 'scroll']);
20
+
16
21
  export async function executePipeline(
17
22
  page: IPage | null,
18
23
  pipeline: unknown[],
@@ -23,28 +28,68 @@ export async function executePipeline(
23
28
  let data: unknown = null;
24
29
  const total = pipeline.length;
25
30
 
26
- for (let i = 0; i < pipeline.length; i++) {
27
- const step = pipeline[i];
28
- if (!step || typeof step !== 'object') continue;
29
- for (const [op, params] of Object.entries(step)) {
30
- if (debug) debugStepStart(i + 1, total, op, params);
31
+ try {
32
+ for (let i = 0; i < pipeline.length; i++) {
33
+ const step = pipeline[i];
34
+ if (!step || typeof step !== 'object') continue;
35
+ for (const [op, params] of Object.entries(step)) {
36
+ if (debug) debugStepStart(i + 1, total, op, params);
31
37
 
32
- const handler = getStep(op);
33
- if (handler) {
34
- data = await handler(page, params, data, args);
35
- } else {
36
- throw new ConfigError(
37
- `Unknown pipeline step "${op}" at index ${i}.`,
38
- 'Check the YAML pipeline step name or register the custom step before execution.',
39
- );
40
- }
38
+ const handler = getStep(op);
39
+ if (handler) {
40
+ data = await executeStepWithRetry(handler, page, params, data, args, op, ctx.stepRetries);
41
+ } else {
42
+ throw new ConfigError(
43
+ `Unknown pipeline step "${op}" at index ${i}.`,
44
+ 'Check the YAML pipeline step name or register the custom step before execution.',
45
+ );
46
+ }
41
47
 
42
- if (debug) debugStepResult(op, data);
48
+ if (debug) debugStepResult(op, data);
49
+ }
50
+ }
51
+ } catch (err) {
52
+ // Attempt cleanup: close automation window on pipeline failure
53
+ if (page && typeof (page as unknown as Record<string, unknown>).closeWindow === 'function') {
54
+ try { await (page as unknown as { closeWindow: () => Promise<void> }).closeWindow(); } catch { /* ignore */ }
43
55
  }
56
+ throw err;
44
57
  }
45
58
  return data;
46
59
  }
47
60
 
61
+ async function executeStepWithRetry(
62
+ handler: StepHandler,
63
+ page: IPage | null,
64
+ params: unknown,
65
+ data: unknown,
66
+ args: Record<string, unknown>,
67
+ op: string,
68
+ configRetries?: number,
69
+ ): Promise<unknown> {
70
+ const maxRetries = configRetries ?? (BROWSER_STEPS.has(op) ? 2 : 0);
71
+
72
+ for (let attempt = 0; attempt <= maxRetries; attempt++) {
73
+ try {
74
+ return await handler(page, params, data, args);
75
+ } catch (err) {
76
+ if (attempt >= maxRetries) throw err;
77
+ // Only retry on transient browser errors
78
+ const msg = err instanceof Error ? err.message : '';
79
+ const isTransient = msg.includes('Extension disconnected')
80
+ || msg.includes('attach failed')
81
+ || msg.includes('no longer exists')
82
+ || msg.includes('CDP connection')
83
+ || msg.includes('Daemon command failed');
84
+ if (!isTransient) throw err;
85
+ // Brief delay before retry
86
+ await new Promise(resolve => setTimeout(resolve, 1000));
87
+ }
88
+ }
89
+ // Unreachable
90
+ throw new Error(`Step "${op}" failed after ${maxRetries} retries`);
91
+ }
92
+
48
93
  function debugStepStart(stepNum: number, total: number, op: string, params: unknown): void {
49
94
  let preview = '';
50
95
  if (typeof params === 'string') {