@jackwener/opencli 1.7.15 → 1.7.17

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.
Files changed (172) hide show
  1. package/README.md +15 -13
  2. package/README.zh-CN.md +15 -12
  3. package/cli-manifest.json +165 -209
  4. package/clis/chatgpt/ask.js +3 -2
  5. package/clis/chatgpt/commands.test.js +2 -2
  6. package/clis/chatgpt/detail.js +7 -2
  7. package/clis/chatgpt/history.js +1 -1
  8. package/clis/chatgpt/image.js +38 -4
  9. package/clis/chatgpt/image.test.js +68 -1
  10. package/clis/chatgpt/new.js +1 -1
  11. package/clis/chatgpt/read.js +3 -2
  12. package/clis/chatgpt/send.js +3 -2
  13. package/clis/chatgpt/status.js +1 -1
  14. package/clis/chatgpt/utils.js +259 -25
  15. package/clis/chatgpt/utils.test.js +166 -2
  16. package/clis/claude/ask.js +23 -8
  17. package/clis/claude/detail.js +10 -3
  18. package/clis/claude/history.js +1 -1
  19. package/clis/claude/new.js +9 -3
  20. package/clis/claude/read.js +3 -2
  21. package/clis/claude/send.js +9 -4
  22. package/clis/claude/status.js +1 -1
  23. package/clis/claude/utils.js +27 -4
  24. package/clis/deepseek/ask.js +22 -9
  25. package/clis/deepseek/detail.js +10 -2
  26. package/clis/deepseek/history.js +1 -1
  27. package/clis/deepseek/new.js +14 -3
  28. package/clis/deepseek/read.js +3 -2
  29. package/clis/deepseek/send.js +1 -1
  30. package/clis/deepseek/status.js +1 -1
  31. package/clis/deepseek/utils.js +8 -1
  32. package/clis/doubao/ask.js +1 -1
  33. package/clis/doubao/detail.js +1 -1
  34. package/clis/doubao/history.js +1 -1
  35. package/clis/doubao/meeting-summary.js +1 -1
  36. package/clis/doubao/meeting-transcript.js +1 -1
  37. package/clis/doubao/new.js +1 -1
  38. package/clis/doubao/read.js +1 -1
  39. package/clis/doubao/send.js +1 -1
  40. package/clis/doubao/status.js +1 -1
  41. package/clis/gemini/ask.js +1 -1
  42. package/clis/gemini/deep-research-result.js +1 -1
  43. package/clis/gemini/deep-research.js +1 -1
  44. package/clis/gemini/image.js +1 -1
  45. package/clis/gemini/new.js +1 -1
  46. package/clis/grok/ask.js +1 -1
  47. package/clis/grok/detail.js +1 -1
  48. package/clis/grok/history.js +1 -1
  49. package/clis/grok/image.js +1 -1
  50. package/clis/grok/new.js +1 -1
  51. package/clis/grok/read.js +1 -1
  52. package/clis/grok/send.js +1 -1
  53. package/clis/grok/status.js +1 -1
  54. package/clis/linkedin/search.js +8 -11
  55. package/clis/maimai/search-talents.js +10 -6
  56. package/clis/notebooklm/current.js +1 -1
  57. package/clis/notebooklm/get.js +1 -1
  58. package/clis/notebooklm/history.js +1 -1
  59. package/clis/notebooklm/note-list.js +1 -1
  60. package/clis/notebooklm/notes-get.js +1 -1
  61. package/clis/notebooklm/open.js +2 -2
  62. package/clis/notebooklm/open.test.js +1 -1
  63. package/clis/notebooklm/source-fulltext.js +1 -1
  64. package/clis/notebooklm/source-get.js +1 -1
  65. package/clis/notebooklm/source-guide.js +1 -1
  66. package/clis/notebooklm/source-list.js +1 -1
  67. package/clis/notebooklm/summary.js +1 -1
  68. package/clis/openreview/author.js +58 -0
  69. package/clis/openreview/openreview.test.js +83 -1
  70. package/clis/openreview/utils.js +14 -0
  71. package/clis/qwen/ask.js +1 -1
  72. package/clis/qwen/detail.js +1 -1
  73. package/clis/qwen/history.js +1 -1
  74. package/clis/qwen/image.js +1 -1
  75. package/clis/qwen/new.js +1 -1
  76. package/clis/qwen/read.js +1 -1
  77. package/clis/qwen/send.js +1 -1
  78. package/clis/qwen/status.js +1 -1
  79. package/clis/reddit/comment.js +1 -0
  80. package/clis/reddit/frontpage.js +1 -0
  81. package/clis/reddit/popular.js +1 -0
  82. package/clis/reddit/read.js +2 -0
  83. package/clis/reddit/read.test.js +4 -0
  84. package/clis/reddit/save.js +1 -0
  85. package/clis/reddit/saved.js +1 -0
  86. package/clis/reddit/search.js +1 -0
  87. package/clis/reddit/subreddit.js +1 -0
  88. package/clis/reddit/subscribe.js +1 -0
  89. package/clis/reddit/upvote.js +1 -0
  90. package/clis/reddit/upvoted.js +1 -0
  91. package/clis/reddit/user-comments.js +1 -0
  92. package/clis/reddit/user-posts.js +1 -0
  93. package/clis/reddit/user.js +1 -0
  94. package/clis/twitter/article.js +7 -4
  95. package/clis/twitter/bookmark-folder.js +3 -5
  96. package/clis/twitter/bookmark-folder.test.js +5 -2
  97. package/clis/twitter/bookmark-folders.js +3 -5
  98. package/clis/twitter/bookmark-folders.test.js +3 -1
  99. package/clis/twitter/bookmarks.js +3 -5
  100. package/clis/twitter/download.js +1 -0
  101. package/clis/twitter/followers.js +1 -0
  102. package/clis/twitter/following.js +3 -6
  103. package/clis/twitter/following.test.js +2 -1
  104. package/clis/twitter/likes.js +3 -5
  105. package/clis/twitter/list-add.js +4 -3
  106. package/clis/twitter/list-add.test.js +23 -1
  107. package/clis/twitter/list-remove.js +4 -3
  108. package/clis/twitter/list-remove.test.js +23 -1
  109. package/clis/twitter/list-tweets.js +3 -5
  110. package/clis/twitter/lists.js +3 -5
  111. package/clis/twitter/notifications.js +1 -0
  112. package/clis/twitter/profile.js +7 -4
  113. package/clis/twitter/search.js +1 -0
  114. package/clis/twitter/thread.js +5 -7
  115. package/clis/twitter/timeline.js +5 -7
  116. package/clis/twitter/trending.js +4 -4
  117. package/clis/twitter/tweets.js +3 -6
  118. package/clis/youtube/like.js +6 -2
  119. package/clis/youtube/subscribe.js +6 -2
  120. package/clis/youtube/unlike.js +6 -2
  121. package/clis/youtube/unsubscribe.js +6 -2
  122. package/clis/youtube/utils.js +19 -13
  123. package/clis/youtube/utils.test.js +17 -1
  124. package/clis/yuanbao/ask.js +1 -1
  125. package/clis/yuanbao/detail.js +1 -1
  126. package/clis/yuanbao/history.js +1 -1
  127. package/clis/yuanbao/new.js +1 -1
  128. package/clis/yuanbao/read.js +1 -1
  129. package/clis/yuanbao/send.js +1 -1
  130. package/clis/yuanbao/status.js +1 -1
  131. package/dist/src/browser/bridge.d.ts +4 -1
  132. package/dist/src/browser/bridge.js +3 -1
  133. package/dist/src/browser/cdp.d.ts +4 -1
  134. package/dist/src/browser/daemon-client.d.ts +9 -16
  135. package/dist/src/browser/daemon-client.js +8 -9
  136. package/dist/src/browser/daemon-client.test.js +10 -0
  137. package/dist/src/browser/network-cache.d.ts +5 -5
  138. package/dist/src/browser/network-cache.js +8 -8
  139. package/dist/src/browser/network-cache.test.js +4 -4
  140. package/dist/src/browser/page.d.ts +9 -7
  141. package/dist/src/browser/page.js +27 -16
  142. package/dist/src/browser/page.test.js +60 -30
  143. package/dist/src/build-manifest.js +1 -1
  144. package/dist/src/cli.js +91 -125
  145. package/dist/src/cli.test.js +293 -180
  146. package/dist/src/commanderAdapter.js +9 -0
  147. package/dist/src/discovery.js +1 -1
  148. package/dist/src/doctor.d.ts +0 -4
  149. package/dist/src/doctor.js +8 -72
  150. package/dist/src/doctor.test.js +26 -97
  151. package/dist/src/execution.d.ts +3 -0
  152. package/dist/src/execution.js +47 -23
  153. package/dist/src/execution.test.js +68 -45
  154. package/dist/src/external-clis.yaml +24 -0
  155. package/dist/src/help.d.ts +1 -0
  156. package/dist/src/help.js +36 -1
  157. package/dist/src/main.js +0 -29
  158. package/dist/src/manifest-types.d.ts +2 -4
  159. package/dist/src/observation/artifact.js +1 -1
  160. package/dist/src/observation/artifact.test.js +3 -3
  161. package/dist/src/observation/events.d.ts +1 -1
  162. package/dist/src/observation/manager.js +1 -1
  163. package/dist/src/observation/manager.test.js +3 -3
  164. package/dist/src/registry-api.d.ts +1 -1
  165. package/dist/src/registry.d.ts +3 -12
  166. package/dist/src/registry.js +6 -10
  167. package/dist/src/runtime.d.ts +10 -2
  168. package/dist/src/runtime.js +4 -1
  169. package/dist/src/serialization.d.ts +1 -1
  170. package/dist/src/serialization.js +1 -1
  171. package/dist/src/types.d.ts +0 -15
  172. package/package.json +1 -1
@@ -48,6 +48,12 @@ export function registerCommandToProgram(siteCmd, cmd) {
48
48
  .option('-f, --format <fmt>', 'Output format: table, plain, json, yaml, md, csv', 'table')
49
49
  .option('--trace <mode>', 'Trace capture: off, on, retain-on-failure', 'off')
50
50
  .option('-v, --verbose', 'Debug output', false);
51
+ if (cmd.browser) {
52
+ subCmd
53
+ .option('--window <mode>', 'Browser window mode: foreground or background')
54
+ .option('--site-session <mode>', 'Adapter site session lifecycle: ephemeral or persistent')
55
+ .option('--keep-tab <bool>', 'Keep the browser tab lease after the command finishes');
56
+ }
51
57
  const originalHelpInformation = subCmd.helpInformation.bind(subCmd);
52
58
  subCmd.helpInformation = ((contextOptions) => {
53
59
  const format = getRequestedHelpFormat();
@@ -102,6 +108,9 @@ export function registerCommandToProgram(siteCmd, cmd) {
102
108
  prepared: true,
103
109
  ...(typeof globals.profile === 'string' && globals.profile.trim() ? { profile: globals.profile.trim() } : {}),
104
110
  ...(typeof optionsRecord.trace === 'string' && optionsRecord.trace !== 'off' ? { trace: optionsRecord.trace } : {}),
111
+ ...(cmd.browser && typeof optionsRecord.window === 'string' ? { windowMode: optionsRecord.window } : {}),
112
+ ...(cmd.browser && typeof optionsRecord.siteSession === 'string' ? { siteSession: optionsRecord.siteSession } : {}),
113
+ ...(cmd.browser && typeof optionsRecord.keepTab === 'string' ? { keepTab: optionsRecord.keepTab } : {}),
105
114
  });
106
115
  if (result === null || result === undefined) {
107
116
  return;
@@ -135,7 +135,7 @@ async function loadFromManifest(manifestPath, clisDir) {
135
135
  pipeline: entry.pipeline,
136
136
  source: entry.sourceFile ? path.resolve(clisDir, entry.sourceFile) : modulePath,
137
137
  navigateBefore: entry.navigateBefore,
138
- browserSession: entry.browserSession,
138
+ siteSession: entry.siteSession,
139
139
  _lazy: true,
140
140
  _modulePath: modulePath,
141
141
  };
@@ -3,13 +3,10 @@
3
3
  *
4
4
  * Simplified for the daemon-based architecture.
5
5
  */
6
- import type { BrowserSessionInfo } from './types.js';
7
6
  import type { BrowserProfileStatus } from './browser/daemon-client.js';
8
7
  import { type AdapterShadow } from './adapter-shadow.js';
9
8
  export type DoctorOptions = {
10
9
  yes?: boolean;
11
- live?: boolean;
12
- sessions?: boolean;
13
10
  cliVersion?: string;
14
11
  };
15
12
  export type ConnectivityResult = {
@@ -28,7 +25,6 @@ export type DoctorReport = {
28
25
  extensionVersion?: string;
29
26
  latestExtensionVersion?: string;
30
27
  connectivity?: ConnectivityResult;
31
- sessions?: BrowserSessionInfo[];
32
28
  profiles?: BrowserProfileStatus[];
33
29
  adapterShadows?: AdapterShadow[];
34
30
  issues: string[];
@@ -6,7 +6,7 @@
6
6
  import { styleText } from 'node:util';
7
7
  import { DEFAULT_DAEMON_PORT } from './constants.js';
8
8
  import { BrowserBridge } from './browser/index.js';
9
- import { getDaemonHealth, listSessions } from './browser/daemon-client.js';
9
+ import { getDaemonHealth } from './browser/daemon-client.js';
10
10
  import { getErrorMessage } from './errors.js';
11
11
  import { getRuntimeLabel } from './runtime-detect.js';
12
12
  import { getCachedLatestExtensionVersion } from './update-check.js';
@@ -66,47 +66,17 @@ export async function checkConnectivity(opts) {
66
66
  }
67
67
  }
68
68
  export async function runBrowserDoctor(opts = {}) {
69
- // Live connectivity check doubles as auto-start (bridge.connect spawns daemon).
70
- let connectivity;
71
- if (opts.live) {
72
- connectivity = await checkConnectivity();
73
- }
74
- else {
75
- // No live probe — daemon may have idle-exited. Do a minimal auto-start
76
- // so we don't misreport a lazy-lifecycle stop as a real failure.
77
- const initialHealth = await getDaemonHealth();
78
- if (initialHealth.state === 'stopped') {
79
- try {
80
- const bridge = new BrowserBridge();
81
- await bridge.connect({ timeout: 5 });
82
- await bridge.close();
83
- }
84
- catch {
85
- // Auto-start failed; we'll report it below.
86
- }
87
- }
88
- }
89
- // Single status read *after* all side-effects (live check / auto-start) settle.
69
+ // Live connectivity check is the core of doctor — it doubles as auto-start
70
+ // (bridge.connect spawns daemon) and validates end-to-end browser bridge health.
71
+ const connectivity = await checkConnectivity();
72
+ // Single status read *after* connectivity side-effects settle.
90
73
  const health = await getDaemonHealth();
91
74
  const daemonRunning = health.state !== 'stopped';
92
75
  const extensionConnected = health.state === 'ready';
93
- const daemonFlaky = !!(connectivity?.ok && !daemonRunning);
94
- const extensionFlaky = !!(connectivity?.ok && daemonRunning && !extensionConnected);
76
+ const daemonFlaky = connectivity.ok && !daemonRunning;
77
+ const extensionFlaky = connectivity.ok && daemonRunning && !extensionConnected;
95
78
  const daemonStale = isDaemonStale(health.status, opts.cliVersion);
96
79
  const profiles = health.status?.profiles;
97
- let sessions;
98
- if (opts.sessions) {
99
- if (profiles && profiles.length > 0) {
100
- const grouped = await Promise.all(profiles.map(async (profile) => {
101
- const rows = await listSessions({ contextId: profile.contextId }).catch(() => []);
102
- return rows.map((row) => ({ ...row, contextId: row.contextId ?? profile.contextId }));
103
- }));
104
- sessions = grouped.flat();
105
- }
106
- else if (health.state === 'ready') {
107
- sessions = await listSessions();
108
- }
109
- }
110
80
  const extensionVersion = health.status?.extensionVersion;
111
81
  const adapterShadows = findShadowedUserAdapters();
112
82
  const issues = [];
@@ -147,7 +117,7 @@ export async function runBrowserDoctor(opts = {}) {
147
117
  ' This usually means an outdated Browser Bridge extension.\n' +
148
118
  ' Reload or reinstall the extension from: https://github.com/jackwener/opencli/releases');
149
119
  }
150
- if (connectivity && !connectivity.ok) {
120
+ if (!connectivity.ok) {
151
121
  issues.push(`Browser connectivity test failed: ${connectivity.error ?? 'unknown'}`);
152
122
  }
153
123
  const extensionCompatRange = health.status?.extensionCompatRange;
@@ -187,7 +157,6 @@ export async function runBrowserDoctor(opts = {}) {
187
157
  extensionVersion,
188
158
  latestExtensionVersion,
189
159
  connectivity,
190
- sessions,
191
160
  profiles,
192
161
  adapterShadows,
193
162
  issues,
@@ -244,39 +213,6 @@ export function renderBrowserDoctorReport(report) {
244
213
  : `failed (${report.connectivity.error ?? 'unknown'})`;
245
214
  lines.push(`${connIcon} Connectivity: ${detail}`);
246
215
  }
247
- else {
248
- lines.push(`${styleText('dim', '[SKIP]')} Connectivity: skipped (--no-live)`);
249
- }
250
- if (report.sessions) {
251
- lines.push('', styleText('bold', 'Sessions:'));
252
- if (report.sessions.length === 0) {
253
- lines.push(styleText('dim', ' • no active automation sessions'));
254
- }
255
- else {
256
- const byContext = new Map();
257
- for (const session of report.sessions) {
258
- const contextId = typeof session.contextId === 'string' && session.contextId ? session.contextId : 'default';
259
- const rows = byContext.get(contextId) ?? [];
260
- rows.push(session);
261
- byContext.set(contextId, rows);
262
- }
263
- for (const [contextId, rows] of byContext) {
264
- if (byContext.size > 1)
265
- lines.push(styleText('dim', ` [profile: ${contextId}]`));
266
- for (const session of rows) {
267
- const idle = session.idleMsRemaining == null
268
- ? 'none'
269
- : `${Math.ceil(session.idleMsRemaining / 1000)}s`;
270
- const target = session.preferredTabId != null
271
- ? `tab ${session.preferredTabId}`
272
- : `window ${session.windowId ?? 'unknown'}`;
273
- const mode = session.ownership ?? (session.owned === false ? 'borrowed' : 'owned');
274
- const surface = session.surface ? `, surface=${session.surface}` : '';
275
- lines.push(styleText('dim', ` • ${session.workspace ?? 'default'} → ${target}, mode=${mode}${surface}, tabs=${session.tabCount ?? 0}, idle=${idle}`));
276
- }
277
- }
278
- }
279
- }
280
216
  if (report.issues.length) {
281
217
  lines.push('', styleText('yellow', 'Issues:'));
282
218
  for (const issue of report.issues) {
@@ -1,14 +1,12 @@
1
1
  import { beforeEach, describe, expect, it, vi } from 'vitest';
2
- const { mockGetDaemonHealth, mockListSessions, mockConnect, mockClose, mockFindShadowedUserAdapters } = vi.hoisted(() => ({
2
+ const { mockGetDaemonHealth, mockConnect, mockClose, mockFindShadowedUserAdapters } = vi.hoisted(() => ({
3
3
  mockGetDaemonHealth: vi.fn(),
4
- mockListSessions: vi.fn(),
5
4
  mockConnect: vi.fn(),
6
5
  mockClose: vi.fn(),
7
6
  mockFindShadowedUserAdapters: vi.fn(),
8
7
  }));
9
8
  vi.mock('./browser/daemon-client.js', () => ({
10
9
  getDaemonHealth: mockGetDaemonHealth,
11
- listSessions: mockListSessions,
12
10
  }));
13
11
  vi.mock('./browser/index.js', () => ({
14
12
  BrowserBridge: class {
@@ -29,6 +27,12 @@ describe('doctor report rendering', () => {
29
27
  beforeEach(() => {
30
28
  vi.clearAllMocks();
31
29
  mockFindShadowedUserAdapters.mockReturnValue([]);
30
+ // Doctor always runs live connectivity. Tests that want connect to fail override.
31
+ mockConnect.mockResolvedValue({
32
+ evaluate: vi.fn().mockResolvedValue(2),
33
+ closeWindow: vi.fn().mockResolvedValue(undefined),
34
+ });
35
+ mockClose.mockResolvedValue(undefined);
32
36
  });
33
37
  it('renders OK-style report when daemon and extension connected', () => {
34
38
  const text = strip(renderBrowserDoctorReport({
@@ -97,34 +101,7 @@ describe('doctor report rendering', () => {
97
101
  }));
98
102
  expect(text).toContain('[OK] Connectivity: connected in 1.2s');
99
103
  });
100
- it('renders connectivity SKIP when not tested', () => {
101
- const text = strip(renderBrowserDoctorReport({
102
- daemonRunning: true,
103
- extensionConnected: true,
104
- issues: [],
105
- }));
106
- expect(text).toContain('[SKIP] Connectivity: skipped (--no-live)');
107
- });
108
- it('renders sessions with tab leases and no idle timer', () => {
109
- const text = strip(renderBrowserDoctorReport({
110
- daemonRunning: true,
111
- extensionConnected: true,
112
- issues: [],
113
- sessions: [
114
- {
115
- workspace: 'bound:default',
116
- windowId: 2,
117
- preferredTabId: 42,
118
- ownership: 'borrowed',
119
- surface: 'borrowed-user-tab',
120
- tabCount: 1,
121
- idleMsRemaining: null,
122
- },
123
- ],
124
- }));
125
- expect(text).toContain('bound:default → tab 42, mode=borrowed, surface=borrowed-user-tab, tabs=1, idle=none');
126
- });
127
- it('renders connected profiles and groups sessions by profile', () => {
104
+ it('renders connected profiles when multiple are present', () => {
128
105
  const text = strip(renderBrowserDoctorReport({
129
106
  daemonRunning: true,
130
107
  extensionConnected: false,
@@ -133,35 +110,10 @@ describe('doctor report rendering', () => {
133
110
  { contextId: 'personal', extensionConnected: true, extensionVersion: '1.2.3', pending: 0 },
134
111
  ],
135
112
  issues: [],
136
- sessions: [
137
- {
138
- contextId: 'work',
139
- workspace: 'bound:default',
140
- windowId: 2,
141
- preferredTabId: 42,
142
- ownership: 'borrowed',
143
- surface: 'borrowed-user-tab',
144
- tabCount: 1,
145
- idleMsRemaining: null,
146
- },
147
- {
148
- contextId: 'personal',
149
- workspace: 'site:foo',
150
- windowId: 1,
151
- preferredTabId: 10,
152
- ownership: 'owned',
153
- surface: 'dedicated-container',
154
- tabCount: 1,
155
- idleMsRemaining: 1000,
156
- },
157
- ],
158
113
  }));
159
114
  expect(text).toContain('Profiles:');
160
115
  expect(text).toContain('work: connected v1.2.3');
161
- expect(text).toContain('[profile: work]');
162
- expect(text).toContain('[profile: personal]');
163
- expect(text).toContain('bound:default → tab 42');
164
- expect(text).toContain('site:foo → tab 10');
116
+ expect(text).toContain('personal: connected v1.2.3');
165
117
  });
166
118
  it('renders unstable extension state when live connectivity and status disagree', () => {
167
119
  const text = strip(renderBrowserDoctorReport({
@@ -185,25 +137,20 @@ describe('doctor report rendering', () => {
185
137
  expect(text).toContain('[WARN] Daemon: unstable');
186
138
  expect(text).toContain('Daemon connectivity is unstable.');
187
139
  });
188
- it('reports daemon not running when no-live and auto-start fails', async () => {
189
- mockGetDaemonHealth.mockResolvedValueOnce({ state: 'stopped', status: null });
140
+ it('reports daemon not running when connectivity fails and daemon stays stopped', async () => {
190
141
  mockConnect.mockRejectedValueOnce(new Error('Could not start daemon'));
191
142
  mockGetDaemonHealth.mockResolvedValueOnce({ state: 'stopped', status: null });
192
- const report = await runBrowserDoctor({ live: false });
143
+ const report = await runBrowserDoctor();
193
144
  expect(report.daemonRunning).toBe(false);
194
145
  expect(report.extensionConnected).toBe(false);
195
- expect(mockGetDaemonHealth).toHaveBeenCalledTimes(2);
146
+ expect(report.connectivity?.ok).toBe(false);
196
147
  expect(report.issues).toEqual(expect.arrayContaining([
197
148
  expect.stringContaining('Daemon is not running'),
198
149
  ]));
199
150
  });
200
151
  it('reports flapping when live check succeeds but final status shows extension disconnected', async () => {
201
- mockConnect.mockResolvedValueOnce({
202
- evaluate: vi.fn().mockResolvedValue(2),
203
- });
204
- mockClose.mockResolvedValueOnce(undefined);
205
152
  mockGetDaemonHealth.mockResolvedValueOnce({ state: 'no-extension', status: { extensionConnected: false } });
206
- const report = await runBrowserDoctor({ live: true });
153
+ const report = await runBrowserDoctor();
207
154
  expect(report.daemonRunning).toBe(true);
208
155
  expect(report.extensionConnected).toBe(false);
209
156
  expect(report.extensionFlaky).toBe(true);
@@ -212,12 +159,8 @@ describe('doctor report rendering', () => {
212
159
  ]));
213
160
  });
214
161
  it('reports daemon flapping when live check succeeds but daemon disappears afterward', async () => {
215
- mockConnect.mockResolvedValueOnce({
216
- evaluate: vi.fn().mockResolvedValue(2),
217
- });
218
- mockClose.mockResolvedValueOnce(undefined);
219
162
  mockGetDaemonHealth.mockResolvedValueOnce({ state: 'stopped', status: null });
220
- const report = await runBrowserDoctor({ live: true });
163
+ const report = await runBrowserDoctor();
221
164
  expect(report.daemonRunning).toBe(false);
222
165
  expect(report.daemonFlaky).toBe(true);
223
166
  expect(report.extensionConnected).toBe(false);
@@ -235,20 +178,11 @@ describe('doctor report rendering', () => {
235
178
  closeWindow,
236
179
  };
237
180
  });
238
- mockClose.mockResolvedValueOnce(undefined);
239
181
  mockGetDaemonHealth.mockResolvedValueOnce({ state: 'ready', status: { extensionConnected: true } });
240
- await runBrowserDoctor({ live: true });
182
+ await runBrowserDoctor();
241
183
  expect(timeoutSeen).toBe(8);
242
184
  expect(closeWindow).toHaveBeenCalledTimes(1);
243
185
  });
244
- it('skips auto-start in no-live mode when daemon is already running', async () => {
245
- mockGetDaemonHealth.mockResolvedValueOnce({ state: 'no-extension', status: { extensionConnected: false } });
246
- mockGetDaemonHealth.mockResolvedValueOnce({ state: 'no-extension', status: { extensionConnected: false } });
247
- const report = await runBrowserDoctor({ live: false });
248
- expect(mockConnect).not.toHaveBeenCalled();
249
- expect(report.daemonRunning).toBe(true);
250
- expect(report.extensionConnected).toBe(false);
251
- });
252
186
  it('reports an issue when the extension is connected but does not report a version', async () => {
253
187
  const status = {
254
188
  state: 'ready',
@@ -257,10 +191,8 @@ describe('doctor report rendering', () => {
257
191
  extensionVersion: undefined,
258
192
  },
259
193
  };
260
- mockGetDaemonHealth
261
- .mockResolvedValueOnce(status)
262
- .mockResolvedValueOnce(status);
263
- const report = await runBrowserDoctor({ live: false });
194
+ mockGetDaemonHealth.mockResolvedValue(status);
195
+ const report = await runBrowserDoctor();
264
196
  expect(report.issues).toEqual(expect.arrayContaining([
265
197
  expect.stringContaining('did not report a version'),
266
198
  ]));
@@ -274,10 +206,8 @@ describe('doctor report rendering', () => {
274
206
  extensionVersion: '1.0.3',
275
207
  },
276
208
  };
277
- mockGetDaemonHealth
278
- .mockResolvedValueOnce(status)
279
- .mockResolvedValueOnce(status);
280
- const report = await runBrowserDoctor({ live: false, cliVersion: '1.7.9' });
209
+ mockGetDaemonHealth.mockResolvedValue(status);
210
+ const report = await runBrowserDoctor({ cliVersion: '1.7.9' });
281
211
  expect(report.daemonStale).toBe(true);
282
212
  expect(report.issues).toEqual(expect.arrayContaining([
283
213
  expect.stringContaining('Stale daemon detected: daemon v1.7.6 != CLI v1.7.9'),
@@ -292,9 +222,7 @@ describe('doctor report rendering', () => {
292
222
  extensionVersion: '1.0.3',
293
223
  },
294
224
  };
295
- mockGetDaemonHealth
296
- .mockResolvedValueOnce(status)
297
- .mockResolvedValueOnce(status);
225
+ mockGetDaemonHealth.mockResolvedValue(status);
298
226
  mockFindShadowedUserAdapters.mockReturnValueOnce([
299
227
  {
300
228
  name: 'instagram/saved',
@@ -302,7 +230,7 @@ describe('doctor report rendering', () => {
302
230
  builtinPath: '/pkg/clis/instagram/saved.js',
303
231
  },
304
232
  ]);
305
- const report = await runBrowserDoctor({ live: false, cliVersion: '1.7.9' });
233
+ const report = await runBrowserDoctor({ cliVersion: '1.7.9' });
306
234
  expect(report.adapterShadows).toHaveLength(1);
307
235
  expect(report.issues).toEqual(expect.arrayContaining([
308
236
  expect.stringContaining('Local adapter overrides shadow packaged adapters'),
@@ -320,10 +248,11 @@ describe('doctor report rendering', () => {
320
248
  ],
321
249
  },
322
250
  };
323
- mockGetDaemonHealth
324
- .mockResolvedValueOnce(status)
325
- .mockResolvedValueOnce(status);
326
- const report = await runBrowserDoctor({ live: false });
251
+ mockGetDaemonHealth.mockResolvedValue(status);
252
+ // Real connectivity would fail in profile-required state; force it here so
253
+ // the test exercises the profile-required issue path, not the flaky path.
254
+ mockConnect.mockRejectedValueOnce(new Error('profile required'));
255
+ const report = await runBrowserDoctor();
327
256
  expect(report.profiles).toHaveLength(2);
328
257
  expect(report.issues).toEqual(expect.arrayContaining([
329
258
  expect.stringContaining('Multiple Chrome profiles are connected'),
@@ -16,6 +16,9 @@ export declare function executeCommand(cmd: CliCommand, rawKwargs: CommandArgs,
16
16
  prepared?: boolean;
17
17
  profile?: string;
18
18
  trace?: string;
19
+ keepTab?: string;
20
+ windowMode?: string;
21
+ siteSession?: string;
19
22
  onTraceExport?: (trace: ObservationExportResult) => void;
20
23
  }): Promise<unknown>;
21
24
  export declare function prepareCommandArgs(cmd: CliCommand, rawKwargs: CommandArgs): CommandArgs;
@@ -29,7 +29,6 @@ const _loadedModules = new Map();
29
29
  /** Track mtime of loaded user adapter files for hot-reload in daemon mode. */
30
30
  const _moduleMtimes = new Map();
31
31
  const _userClisDir = `${os.homedir()}/.opencli/clis/`;
32
- const INTERACTIVE_BROWSER_IDLE_TIMEOUT_SECONDS = 600;
33
32
  function normalizeTraceMode(raw) {
34
33
  if (raw === undefined || raw === null || raw === '' || raw === 'off')
35
34
  return 'off';
@@ -164,8 +163,8 @@ function isDomainRootPreNav(preNavUrl, domain) {
164
163
  return false;
165
164
  }
166
165
  }
167
- async function shouldRunPreNav(cmd, page, reuse, preNavUrl) {
168
- if (reuse !== 'site' || !cmd.domain)
166
+ async function shouldRunPreNav(cmd, page, siteSession, preNavUrl) {
167
+ if (siteSession !== 'persistent' || !cmd.domain)
169
168
  return true;
170
169
  if (!isDomainRootPreNav(preNavUrl, cmd.domain))
171
170
  return true;
@@ -212,16 +211,17 @@ export async function executeCommand(cmd, rawKwargs, debug = false, opts = {}) {
212
211
  const BrowserFactory = getBrowserFactory(cmd.site);
213
212
  const contextId = resolveProfileContextId(opts.profile);
214
213
  const internal = cmd;
215
- const browserReuse = resolveBrowserSessionReuse(cmd);
216
- const workspace = resolveBrowserWorkspace(cmd, browserReuse);
217
- const idleTimeout = browserReuse === 'site' ? INTERACTIVE_BROWSER_IDLE_TIMEOUT_SECONDS : undefined;
214
+ const siteSession = resolveSiteSession(cmd, opts.siteSession);
215
+ const session = resolveAdapterBrowserSession(cmd, siteSession);
216
+ const keepTab = resolveKeepTab(siteSession, opts.keepTab);
217
+ const windowMode = resolveBrowserWindowMode('background', opts.windowMode);
218
218
  result = await browserSession(BrowserFactory, async (page) => {
219
219
  const observation = traceMode === 'off'
220
220
  ? null
221
221
  : new ObservationSession({
222
222
  scope: {
223
223
  contextId,
224
- workspace,
224
+ session,
225
225
  target: page.getActivePage?.(),
226
226
  site: cmd.site,
227
227
  command: fullName(cmd),
@@ -238,7 +238,7 @@ export async function executeCommand(cmd, rawKwargs, debug = false, opts = {}) {
238
238
  await page.startNetworkCapture?.().catch(() => false);
239
239
  }
240
240
  const preNavUrl = resolvePreNav(cmd);
241
- if (preNavUrl && await shouldRunPreNav(cmd, page, browserReuse, preNavUrl)) {
241
+ if (preNavUrl && await shouldRunPreNav(cmd, page, siteSession, preNavUrl)) {
242
242
  observation?.record({
243
243
  stream: 'action',
244
244
  name: 'pre_navigate',
@@ -281,9 +281,6 @@ export async function executeCommand(cmd, rawKwargs, debug = false, opts = {}) {
281
281
  throw wrapped;
282
282
  }
283
283
  }
284
- // --live / OPENCLI_LIVE=1 keeps the current automation tab lease after
285
- // the command finishes, so agents (or humans) can inspect the page state.
286
- const keepOpen = browserReuse !== 'none' || process.env.OPENCLI_LIVE === '1' || process.env.OPENCLI_LIVE === 'true';
287
284
  try {
288
285
  const browserTimeout = userTimeoutSec !== null
289
286
  ? userTimeoutSec + RUNTIME_TIMEOUT_PADDING_SECONDS
@@ -304,7 +301,7 @@ export async function executeCommand(cmd, rawKwargs, debug = false, opts = {}) {
304
301
  // Adapter commands are one-shot — release the current tab lease immediately
305
302
  // instead of waiting for the 30s idle timeout. The automation container
306
303
  // window stays open for reuse.
307
- if (!keepOpen)
304
+ if (!keepTab)
308
305
  await page.closeWindow?.().catch(() => { });
309
306
  return result;
310
307
  }
@@ -329,11 +326,11 @@ export async function executeCommand(cmd, rawKwargs, debug = false, opts = {}) {
329
326
  // Release the tab lease on failure too — without this, the lease lingers
330
327
  // until the extension's idle timer fires (unreliable on Windows where
331
328
  // MV3 service workers may be suspended before setTimeout triggers).
332
- if (!keepOpen)
329
+ if (!keepTab)
333
330
  await page.closeWindow?.().catch(() => { });
334
331
  throw err;
335
332
  }
336
- }, { workspace, cdpEndpoint, contextId, idleTimeout });
333
+ }, { session, cdpEndpoint, contextId, windowMode, surface: 'adapter', siteSession });
337
334
  }
338
335
  else {
339
336
  // Non-browser commands: enforce a timeout only when the command exposes
@@ -443,22 +440,49 @@ export function prepareCommandArgs(cmd, rawKwargs) {
443
440
  * the runtime kills the Promise.
444
441
  */
445
442
  const RUNTIME_TIMEOUT_PADDING_SECONDS = 30;
446
- function readEnvBrowserSessionReuse() {
447
- const raw = process.env.OPENCLI_BROWSER_REUSE;
448
- if (raw === undefined || raw === '')
443
+ function normalizeSiteSession(raw) {
444
+ if (raw === undefined || raw === null || raw === '')
449
445
  return null;
450
- if (raw === 'none' || raw === 'site')
446
+ if (raw === 'ephemeral' || raw === 'persistent')
451
447
  return raw;
452
- throw new ArgumentError(`--reuse must be one of: none, site. Received: "${raw}"`);
448
+ throw new ArgumentError(`--site-session must be one of: ephemeral, persistent. Received: "${String(raw)}"`);
453
449
  }
454
- function resolveBrowserSessionReuse(cmd) {
455
- return readEnvBrowserSessionReuse() ?? cmd.browserSession?.reuse ?? 'none';
450
+ function resolveSiteSession(cmd, rawOption) {
451
+ return normalizeSiteSession(rawOption) ?? cmd.siteSession ?? 'ephemeral';
456
452
  }
457
- function resolveBrowserWorkspace(cmd, reuse) {
458
- if (reuse === 'site')
453
+ function resolveAdapterBrowserSession(cmd, siteSession) {
454
+ if (siteSession === 'persistent')
459
455
  return `site:${cmd.site}`;
460
456
  return `site:${cmd.site}:${crypto.randomUUID()}`;
461
457
  }
458
+ function normalizeBooleanOption(name, raw) {
459
+ if (raw === undefined || raw === '')
460
+ return null;
461
+ if (raw === 'true')
462
+ return true;
463
+ if (raw === 'false')
464
+ return false;
465
+ throw new ArgumentError(`${name} must be one of: true, false. Received: "${String(raw)}"`);
466
+ }
467
+ function resolveKeepTab(siteSession, rawOption) {
468
+ if (siteSession === 'persistent')
469
+ return true;
470
+ return normalizeBooleanOption('--keep-tab', rawOption)
471
+ ?? normalizeBooleanOption('OPENCLI_KEEP_TAB', process.env.OPENCLI_KEEP_TAB)
472
+ ?? false;
473
+ }
474
+ function normalizeWindowMode(name, raw) {
475
+ if (raw === undefined || raw === '')
476
+ return null;
477
+ if (raw === 'foreground' || raw === 'background')
478
+ return raw;
479
+ throw new ArgumentError(`${name} must be one of: foreground, background. Received: "${String(raw)}"`);
480
+ }
481
+ function resolveBrowserWindowMode(defaultMode = 'background', rawOption) {
482
+ return normalizeWindowMode('--window', rawOption)
483
+ ?? normalizeWindowMode('OPENCLI_WINDOW', process.env.OPENCLI_WINDOW)
484
+ ?? defaultMode;
485
+ }
462
486
  /**
463
487
  * Resolve the user-controllable `--timeout` arg, in seconds.
464
488
  *