@jackwener/opencli 1.7.8 → 1.7.9

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 (273) hide show
  1. package/README.md +49 -14
  2. package/README.zh-CN.md +30 -10
  3. package/cli-manifest.json +612 -29
  4. package/clis/36kr/news.js +1 -1
  5. package/clis/apple-podcasts/commands.test.js +4 -4
  6. package/clis/apple-podcasts/episodes.js +1 -1
  7. package/clis/apple-podcasts/search.js +1 -1
  8. package/clis/apple-podcasts/top.js +1 -1
  9. package/clis/arxiv/paper.js +1 -1
  10. package/clis/arxiv/search.js +1 -1
  11. package/clis/band/mentions.js +3 -3
  12. package/clis/bbc/news.js +1 -1
  13. package/clis/bilibili/subtitle.js +2 -2
  14. package/clis/bloomberg/businessweek.js +1 -1
  15. package/clis/bloomberg/economics.js +1 -1
  16. package/clis/bloomberg/industries.js +1 -1
  17. package/clis/bloomberg/main.js +1 -1
  18. package/clis/bloomberg/markets.js +1 -1
  19. package/clis/bloomberg/opinions.js +1 -1
  20. package/clis/bloomberg/politics.js +1 -1
  21. package/clis/bloomberg/tech.js +1 -1
  22. package/clis/boss/search.js +49 -8
  23. package/clis/boss/search.test.js +78 -0
  24. package/clis/boss/send.js +3 -3
  25. package/clis/chatgpt/image.js +37 -8
  26. package/clis/chatgpt/image.test.js +92 -0
  27. package/clis/chatgpt/utils.js +39 -6
  28. package/clis/chatgpt/utils.test.js +63 -0
  29. package/clis/chatgpt-app/ask.js +1 -1
  30. package/clis/chatgpt-app/ax.js +4 -2
  31. package/clis/chatgpt-app/ax.test.js +12 -0
  32. package/clis/chatgpt-app/model.js +1 -1
  33. package/clis/chatgpt-app/new.js +1 -1
  34. package/clis/chatgpt-app/read.js +1 -1
  35. package/clis/chatgpt-app/send.js +1 -1
  36. package/clis/chatgpt-app/status.js +1 -1
  37. package/clis/chatwise/ask.js +2 -2
  38. package/clis/chatwise/model.js +2 -2
  39. package/clis/chatwise/send.js +2 -2
  40. package/clis/claude/ask.js +128 -0
  41. package/clis/claude/ask.test.js +338 -0
  42. package/clis/claude/commands.test.js +118 -0
  43. package/clis/claude/detail.js +29 -0
  44. package/clis/claude/history.js +31 -0
  45. package/clis/claude/new.js +21 -0
  46. package/clis/claude/read.js +24 -0
  47. package/clis/claude/send.js +41 -0
  48. package/clis/claude/status.js +24 -0
  49. package/clis/claude/utils.js +440 -0
  50. package/clis/claude/utils.test.js +148 -0
  51. package/clis/codex/ask.js +2 -2
  52. package/clis/codex/send.js +2 -2
  53. package/clis/ctrip/search.js +1 -1
  54. package/clis/ctrip/search.test.js +4 -4
  55. package/clis/cursor/ask.js +2 -2
  56. package/clis/cursor/composer.js +2 -2
  57. package/clis/cursor/send.js +2 -2
  58. package/clis/deepseek/ask.js +17 -4
  59. package/clis/deepseek/ask.test.js +46 -0
  60. package/clis/deepseek/utils.js +55 -16
  61. package/clis/deepseek/utils.test.js +124 -5
  62. package/clis/doubao/utils.js +53 -11
  63. package/clis/doubao/utils.test.js +22 -2
  64. package/clis/eastmoney/announcement.js +1 -1
  65. package/clis/eastmoney/convertible.js +1 -1
  66. package/clis/eastmoney/etf.js +1 -1
  67. package/clis/eastmoney/holders.js +1 -1
  68. package/clis/eastmoney/index-board.js +1 -1
  69. package/clis/eastmoney/kline.js +1 -1
  70. package/clis/eastmoney/kuaixun.js +1 -1
  71. package/clis/eastmoney/longhu.js +1 -1
  72. package/clis/eastmoney/money-flow.js +1 -1
  73. package/clis/eastmoney/northbound.js +1 -1
  74. package/clis/eastmoney/quote.js +1 -1
  75. package/clis/eastmoney/rank.js +1 -1
  76. package/clis/eastmoney/sectors.js +1 -1
  77. package/clis/facebook/marketplace-inbox.js +83 -0
  78. package/clis/facebook/marketplace-listings.js +83 -0
  79. package/clis/facebook/marketplace.test.js +91 -0
  80. package/clis/google/news.js +1 -1
  81. package/clis/google/suggest.js +1 -1
  82. package/clis/google/trends.js +1 -1
  83. package/clis/google-scholar/cite.js +74 -0
  84. package/clis/google-scholar/cite.test.js +47 -0
  85. package/clis/google-scholar/profile.js +92 -0
  86. package/clis/google-scholar/profile.test.js +49 -0
  87. package/clis/google-scholar/search.js +1 -1
  88. package/clis/google-scholar/search.test.js +15 -0
  89. package/clis/hf/top.js +1 -1
  90. package/clis/jd/item.js +679 -47
  91. package/clis/jd/item.test.js +318 -7
  92. package/clis/jd/item.test.ts +517 -0
  93. package/clis/lesswrong/comments.js +1 -1
  94. package/clis/lesswrong/curated.js +1 -1
  95. package/clis/lesswrong/frontpage.js +1 -1
  96. package/clis/lesswrong/new.js +1 -1
  97. package/clis/lesswrong/read.js +1 -1
  98. package/clis/lesswrong/sequences.js +1 -1
  99. package/clis/lesswrong/shortform.js +1 -1
  100. package/clis/lesswrong/tag.js +1 -1
  101. package/clis/lesswrong/tags.js +1 -1
  102. package/clis/lesswrong/top-month.js +1 -1
  103. package/clis/lesswrong/top-week.js +1 -1
  104. package/clis/lesswrong/top-year.js +1 -1
  105. package/clis/lesswrong/top.js +1 -1
  106. package/clis/lesswrong/user-posts.js +1 -1
  107. package/clis/lesswrong/user.js +1 -1
  108. package/clis/paperreview/commands.test.js +6 -6
  109. package/clis/paperreview/feedback.js +1 -1
  110. package/clis/paperreview/review.js +1 -1
  111. package/clis/paperreview/submit.js +1 -1
  112. package/clis/producthunt/posts.js +1 -1
  113. package/clis/producthunt/today.js +1 -1
  114. package/clis/sinablog/search.js +1 -1
  115. package/clis/sinafinance/news.js +1 -1
  116. package/clis/sinafinance/stock.js +1 -1
  117. package/clis/sinafinance/stock.test.js +2 -2
  118. package/clis/spotify/spotify.js +6 -6
  119. package/clis/substack/search.js +1 -1
  120. package/clis/toutiao/articles.js +5 -6
  121. package/clis/toutiao/articles.test.js +22 -15
  122. package/clis/twitter/followers.js +2 -2
  123. package/clis/twitter/following.js +224 -73
  124. package/clis/twitter/following.test.js +277 -0
  125. package/clis/twitter/post.js +184 -47
  126. package/clis/twitter/post.test.js +114 -34
  127. package/clis/uiverse/_shared.js +63 -4
  128. package/clis/uiverse/_shared.test.js +7 -0
  129. package/clis/uiverse/code.js +1 -0
  130. package/clis/uiverse/navigation.test.js +12 -0
  131. package/clis/uiverse/preview.js +1 -0
  132. package/clis/web/read.js +319 -81
  133. package/clis/web/read.test.js +221 -5
  134. package/clis/weibo/favorites.js +169 -0
  135. package/clis/weibo/favorites.test.js +114 -0
  136. package/clis/weibo/publish.js +282 -0
  137. package/clis/weibo/publish.test.js +183 -0
  138. package/clis/weread/ranking.js +1 -1
  139. package/clis/weread/search-regression.test.js +8 -8
  140. package/clis/weread/search.js +1 -1
  141. package/clis/wikipedia/random.js +1 -1
  142. package/clis/wikipedia/search.js +1 -1
  143. package/clis/wikipedia/summary.js +1 -1
  144. package/clis/wikipedia/trending.js +1 -1
  145. package/clis/xianyu/chat.js +3 -3
  146. package/clis/xianyu/item.js +2 -2
  147. package/clis/xianyu/item.test.js +3 -3
  148. package/clis/xiaohongshu/search.js +17 -2
  149. package/clis/xiaohongshu/search.test.js +37 -1
  150. package/clis/xiaoyuzhou/download.js +1 -1
  151. package/clis/xiaoyuzhou/download.test.js +3 -3
  152. package/clis/xiaoyuzhou/episode.js +1 -1
  153. package/clis/xiaoyuzhou/podcast-episodes.js +1 -1
  154. package/clis/xiaoyuzhou/podcast-episodes.test.js +2 -2
  155. package/clis/xiaoyuzhou/podcast.js +1 -1
  156. package/clis/xiaoyuzhou/transcript.js +1 -1
  157. package/clis/xiaoyuzhou/transcript.test.js +5 -5
  158. package/clis/yollomi/models.js +1 -1
  159. package/clis/youtube/channel.js +24 -1
  160. package/clis/youtube/channel.test.js +59 -0
  161. package/clis/zhihu/answer.js +21 -162
  162. package/clis/zhihu/answer.test.js +26 -53
  163. package/clis/zhihu/collection.js +197 -0
  164. package/clis/zhihu/collection.test.js +290 -0
  165. package/clis/zhihu/collections.js +127 -0
  166. package/clis/zhihu/collections.test.js +182 -0
  167. package/clis/zhihu/comment.js +24 -305
  168. package/clis/zhihu/comment.test.js +31 -35
  169. package/clis/zhihu/favorite.js +44 -182
  170. package/clis/zhihu/favorite.test.js +30 -167
  171. package/clis/zhihu/follow.js +25 -56
  172. package/clis/zhihu/follow.test.js +20 -23
  173. package/clis/zhihu/like.js +22 -67
  174. package/clis/zhihu/like.test.js +19 -42
  175. package/clis/zhihu/search.js +3 -2
  176. package/clis/zhihu/write-shared.js +8 -1
  177. package/clis/zhihu/write-shared.test.js +1 -0
  178. package/clis/zlibrary/commands.test.js +75 -0
  179. package/clis/zlibrary/info.js +47 -0
  180. package/clis/zlibrary/search.js +46 -0
  181. package/clis/zlibrary/utils.js +136 -0
  182. package/dist/src/adapter-source.d.ts +11 -0
  183. package/dist/src/adapter-source.js +24 -0
  184. package/dist/src/adapter-source.test.js +29 -0
  185. package/dist/src/browser/base-page.d.ts +3 -1
  186. package/dist/src/browser/base-page.js +76 -1
  187. package/dist/src/browser/base-page.test.d.ts +1 -0
  188. package/dist/src/browser/base-page.test.js +74 -0
  189. package/dist/src/browser/bridge.d.ts +1 -0
  190. package/dist/src/browser/bridge.js +36 -9
  191. package/dist/src/browser/cdp.d.ts +1 -0
  192. package/dist/src/browser/cdp.js +3 -3
  193. package/dist/src/browser/daemon-client.d.ts +38 -4
  194. package/dist/src/browser/daemon-client.js +24 -7
  195. package/dist/src/browser/daemon-client.test.js +49 -0
  196. package/dist/src/browser/errors.js +3 -0
  197. package/dist/src/browser/errors.test.js +3 -0
  198. package/dist/src/browser/network-cache.d.ts +1 -0
  199. package/dist/src/browser/page.d.ts +3 -1
  200. package/dist/src/browser/page.js +10 -2
  201. package/dist/src/browser/profile.d.ts +14 -0
  202. package/dist/src/browser/profile.js +85 -0
  203. package/dist/src/build-manifest.d.ts +2 -0
  204. package/dist/src/build-manifest.js +13 -3
  205. package/dist/src/build-manifest.test.js +20 -2
  206. package/dist/src/cli.d.ts +6 -0
  207. package/dist/src/cli.js +462 -32
  208. package/dist/src/cli.test.js +209 -2
  209. package/dist/src/commanderAdapter.js +17 -9
  210. package/dist/src/commanderAdapter.test.js +67 -2
  211. package/dist/src/commands/daemon.js +6 -0
  212. package/dist/src/completion-shared.js +1 -2
  213. package/dist/src/completion.test.js +3 -2
  214. package/dist/src/daemon.js +125 -41
  215. package/dist/src/doctor.d.ts +4 -6
  216. package/dist/src/doctor.js +80 -22
  217. package/dist/src/doctor.test.js +82 -0
  218. package/dist/src/engine.test.js +6 -5
  219. package/dist/src/errors.d.ts +14 -8
  220. package/dist/src/errors.js +36 -30
  221. package/dist/src/errors.test.js +5 -5
  222. package/dist/src/execution.d.ts +4 -0
  223. package/dist/src/execution.js +173 -25
  224. package/dist/src/execution.test.js +171 -1
  225. package/dist/src/main.js +10 -0
  226. package/dist/src/observation/artifact.d.ts +16 -0
  227. package/dist/src/observation/artifact.js +260 -0
  228. package/dist/src/observation/artifact.test.d.ts +1 -0
  229. package/dist/src/observation/artifact.test.js +121 -0
  230. package/dist/src/observation/events.d.ts +89 -0
  231. package/dist/src/observation/events.js +1 -0
  232. package/dist/src/observation/index.d.ts +7 -0
  233. package/dist/src/observation/index.js +7 -0
  234. package/dist/src/observation/manager.d.ts +9 -0
  235. package/dist/src/observation/manager.js +27 -0
  236. package/dist/src/observation/manager.test.d.ts +1 -0
  237. package/dist/src/observation/manager.test.js +13 -0
  238. package/dist/src/observation/redaction.d.ts +11 -0
  239. package/dist/src/observation/redaction.js +81 -0
  240. package/dist/src/observation/redaction.test.d.ts +1 -0
  241. package/dist/src/observation/redaction.test.js +32 -0
  242. package/dist/src/observation/retention.d.ts +32 -0
  243. package/dist/src/observation/retention.js +160 -0
  244. package/dist/src/observation/retention.test.d.ts +1 -0
  245. package/dist/src/observation/retention.test.js +118 -0
  246. package/dist/src/observation/ring-buffer.d.ts +22 -0
  247. package/dist/src/observation/ring-buffer.js +45 -0
  248. package/dist/src/observation/ring-buffer.test.d.ts +1 -0
  249. package/dist/src/observation/ring-buffer.test.js +22 -0
  250. package/dist/src/observation/session.d.ts +25 -0
  251. package/dist/src/observation/session.js +50 -0
  252. package/dist/src/pipeline/executor.test.js +1 -0
  253. package/dist/src/pipeline/steps/download.test.js +1 -0
  254. package/dist/src/pipeline/steps/fetch.js +1 -21
  255. package/dist/src/pipeline/steps/fetch.test.js +6 -12
  256. package/dist/src/plugin-scaffold.js +1 -1
  257. package/dist/src/plugin-scaffold.test.js +1 -1
  258. package/dist/src/registry.d.ts +40 -9
  259. package/dist/src/registry.js +3 -1
  260. package/dist/src/runtime-detect.d.ts +10 -0
  261. package/dist/src/runtime-detect.js +19 -0
  262. package/dist/src/runtime-detect.test.js +12 -1
  263. package/dist/src/runtime.d.ts +2 -0
  264. package/dist/src/runtime.js +1 -0
  265. package/dist/src/types.d.ts +22 -0
  266. package/dist/src/update-check.d.ts +31 -1
  267. package/dist/src/update-check.js +62 -16
  268. package/dist/src/update-check.test.js +86 -1
  269. package/package.json +1 -1
  270. package/dist/src/diagnostic.d.ts +0 -63
  271. package/dist/src/diagnostic.js +0 -292
  272. package/dist/src/diagnostic.test.js +0 -302
  273. /package/dist/src/{diagnostic.test.d.ts → adapter-source.test.d.ts} +0 -0
@@ -25,19 +25,96 @@ import { DEFAULT_DAEMON_PORT } from './constants.js';
25
25
  import { EXIT_CODES } from './errors.js';
26
26
  import { log } from './logger.js';
27
27
  import { PKG_VERSION } from './version.js';
28
+ import { DEFAULT_CONTEXT_ID } from './browser/profile.js';
29
+ import { recordExtensionVersion } from './update-check.js';
28
30
  const PORT = parseInt(process.env.OPENCLI_DAEMON_PORT ?? String(DEFAULT_DAEMON_PORT), 10);
29
- // ─── State ───────────────────────────────────────────────────────────
30
- let extensionWs = null;
31
- let extensionVersion = null;
32
- let extensionCompatRange = null;
31
+ const extensionProfiles = new Map();
33
32
  const pending = new Map();
34
33
  const LOG_BUFFER_SIZE = 200;
35
34
  const logBuffer = [];
35
+ class DaemonCommandFailure extends Error {
36
+ errorCode;
37
+ errorHint;
38
+ status;
39
+ constructor(message, errorCode, errorHint, status = 400) {
40
+ super(message);
41
+ this.errorCode = errorCode;
42
+ this.errorHint = errorHint;
43
+ this.status = status;
44
+ this.name = 'DaemonCommandFailure';
45
+ }
46
+ }
36
47
  function pushLog(entry) {
37
48
  logBuffer.push(entry);
38
49
  if (logBuffer.length > LOG_BUFFER_SIZE)
39
50
  logBuffer.shift();
40
51
  }
52
+ function activeProfiles() {
53
+ return [...extensionProfiles.values()].filter((entry) => entry.ws.readyState === WebSocket.OPEN);
54
+ }
55
+ function resolveExtensionConnection(contextId) {
56
+ const requestedContextId = typeof contextId === 'string' && contextId.trim() ? contextId.trim() : undefined;
57
+ if (requestedContextId) {
58
+ const connection = extensionProfiles.get(requestedContextId);
59
+ if (connection?.ws.readyState === WebSocket.OPEN)
60
+ return { connection };
61
+ return {
62
+ errorCode: 'profile_disconnected',
63
+ error: `Browser profile "${requestedContextId}" is not connected.`,
64
+ errorHint: 'Open that Chrome profile and make sure the OpenCLI extension is enabled, or choose another profile with opencli profile use <name>.',
65
+ };
66
+ }
67
+ const connected = activeProfiles();
68
+ if (connected.length === 1)
69
+ return { connection: connected[0] };
70
+ if (connected.length > 1) {
71
+ return {
72
+ errorCode: 'profile_required',
73
+ error: 'Multiple Browser Bridge profiles are connected; choose one with --profile.',
74
+ errorHint: 'Run opencli profile list, then use opencli --profile <name> ... or opencli profile use <name>.',
75
+ };
76
+ }
77
+ return {
78
+ errorCode: 'extension_not_connected',
79
+ error: 'Extension not connected. Please install the opencli Browser Bridge extension.',
80
+ };
81
+ }
82
+ function registerExtensionConnection(ws, rawContextId) {
83
+ const contextId = typeof rawContextId === 'string' && rawContextId.trim()
84
+ ? rawContextId.trim()
85
+ : DEFAULT_CONTEXT_ID;
86
+ const previous = extensionProfiles.get(contextId);
87
+ if (previous && previous.ws !== ws) {
88
+ previous.ws.close();
89
+ }
90
+ const existing = [...extensionProfiles.entries()].find(([, entry]) => entry.ws === ws);
91
+ if (existing && existing[0] !== contextId)
92
+ extensionProfiles.delete(existing[0]);
93
+ const current = extensionProfiles.get(contextId);
94
+ const connection = {
95
+ contextId,
96
+ ws,
97
+ extensionVersion: current?.ws === ws ? current.extensionVersion : null,
98
+ extensionCompatRange: current?.ws === ws ? current.extensionCompatRange : null,
99
+ lastSeenAt: Date.now(),
100
+ };
101
+ extensionProfiles.set(contextId, connection);
102
+ return connection;
103
+ }
104
+ function unregisterExtensionConnection(ws) {
105
+ for (const [contextId, connection] of extensionProfiles.entries()) {
106
+ if (connection.ws !== ws)
107
+ continue;
108
+ extensionProfiles.delete(contextId);
109
+ for (const [id, p] of pending) {
110
+ if (p.contextId !== contextId)
111
+ continue;
112
+ clearTimeout(p.timer);
113
+ p.reject(new DaemonCommandFailure(`Browser profile "${contextId}" disconnected`, 'profile_disconnected', 'Open that Chrome profile and make sure the OpenCLI extension is enabled, or choose another profile with opencli profile use <name>.', 503));
114
+ pending.delete(id);
115
+ }
116
+ }
117
+ }
41
118
  // ─── HTTP Server ─────────────────────────────────────────────────────
42
119
  const MAX_BODY = 1024 * 1024; // 1 MB — commands are tiny; this prevents OOM
43
120
  function readBody(req) {
@@ -118,14 +195,29 @@ async function handleRequest(req, res) {
118
195
  if (req.method === 'GET' && pathname === '/status') {
119
196
  const uptime = process.uptime();
120
197
  const mem = process.memoryUsage();
198
+ const params = new URL(url, `http://localhost:${PORT}`).searchParams;
199
+ const requestedContextId = params.get('contextId')?.trim() || undefined;
200
+ const route = resolveExtensionConnection(requestedContextId);
201
+ const profiles = activeProfiles().map((profile) => ({
202
+ contextId: profile.contextId,
203
+ extensionConnected: true,
204
+ extensionVersion: profile.extensionVersion ?? undefined,
205
+ extensionCompatRange: profile.extensionCompatRange ?? undefined,
206
+ pending: [...pending.values()].filter((entry) => entry.contextId === profile.contextId).length,
207
+ lastSeenAt: profile.lastSeenAt,
208
+ }));
121
209
  jsonResponse(res, 200, {
122
210
  ok: true,
123
211
  pid: process.pid,
124
212
  uptime,
125
213
  daemonVersion: PKG_VERSION,
126
- extensionConnected: extensionWs?.readyState === WebSocket.OPEN,
127
- extensionVersion,
128
- extensionCompatRange,
214
+ extensionConnected: !!route.connection,
215
+ extensionVersion: route.connection?.extensionVersion ?? undefined,
216
+ extensionCompatRange: route.connection?.extensionCompatRange ?? undefined,
217
+ contextId: route.connection?.contextId ?? requestedContextId,
218
+ profileRequired: route.errorCode === 'profile_required',
219
+ profileDisconnected: route.errorCode === 'profile_disconnected',
220
+ profiles,
129
221
  pending: pending.size,
130
222
  memoryMB: Math.round(mem.rss / 1024 / 1024 * 10) / 10,
131
223
  port: PORT,
@@ -158,8 +250,15 @@ async function handleRequest(req, res) {
158
250
  jsonResponse(res, 400, { ok: false, error: 'Missing command id' });
159
251
  return;
160
252
  }
161
- if (!extensionWs || extensionWs.readyState !== WebSocket.OPEN) {
162
- jsonResponse(res, 503, { id: body.id, ok: false, error: 'Extension not connected. Please install the opencli Browser Bridge extension.' });
253
+ const route = resolveExtensionConnection(typeof body.contextId === 'string' ? body.contextId : undefined);
254
+ if (!route.connection) {
255
+ jsonResponse(res, route.errorCode === 'profile_required' ? 409 : 503, {
256
+ id: body.id,
257
+ ok: false,
258
+ errorCode: route.errorCode,
259
+ error: route.error,
260
+ ...(route.errorHint ? { errorHint: route.errorHint } : {}),
261
+ });
163
262
  return;
164
263
  }
165
264
  const timeoutMs = typeof body.timeout === 'number' && body.timeout > 0
@@ -178,15 +277,18 @@ async function handleRequest(req, res) {
178
277
  pending.delete(body.id);
179
278
  reject(new Error(`Command timeout (${timeoutMs / 1000}s)`));
180
279
  }, timeoutMs);
181
- pending.set(body.id, { resolve, reject, timer });
182
- extensionWs.send(JSON.stringify(body));
280
+ pending.set(body.id, { contextId: route.connection.contextId, resolve, reject, timer });
281
+ route.connection.ws.send(JSON.stringify(body));
183
282
  });
184
283
  jsonResponse(res, 200, result);
185
284
  }
186
285
  catch (err) {
187
- jsonResponse(res, err instanceof Error && err.message.includes('timeout') ? 408 : 400, {
286
+ const commandFailure = err instanceof DaemonCommandFailure ? err : null;
287
+ jsonResponse(res, commandFailure?.status ?? (err instanceof Error && err.message.includes('timeout') ? 408 : 400), {
188
288
  ok: false,
189
289
  error: err instanceof Error ? err.message : 'Invalid request',
290
+ ...(commandFailure?.errorCode ? { errorCode: commandFailure.errorCode } : {}),
291
+ ...(commandFailure?.errorHint ? { errorHint: commandFailure.errorHint } : {}),
190
292
  });
191
293
  }
192
294
  return;
@@ -209,9 +311,6 @@ const wss = new WebSocketServer({
209
311
  });
210
312
  wss.on('connection', (ws) => {
211
313
  log.info('[daemon] Extension connected');
212
- extensionWs = ws;
213
- extensionVersion = null; // cleared until hello message arrives
214
- extensionCompatRange = null;
215
314
  // ── Heartbeat: ping every 15s, close if 2 pongs missed ──
216
315
  let missedPongs = 0;
217
316
  const heartbeatInterval = setInterval(() => {
@@ -236,8 +335,13 @@ wss.on('connection', (ws) => {
236
335
  const msg = JSON.parse(data.toString());
237
336
  // Handle hello message from extension (version handshake)
238
337
  if (msg.type === 'hello') {
239
- extensionVersion = typeof msg.version === 'string' ? msg.version : null;
240
- extensionCompatRange = typeof msg.compatRange === 'string' ? msg.compatRange : null;
338
+ const connection = registerExtensionConnection(ws, msg.contextId);
339
+ connection.extensionVersion = typeof msg.version === 'string' ? msg.version : null;
340
+ connection.extensionCompatRange = typeof msg.compatRange === 'string' ? msg.compatRange : null;
341
+ connection.lastSeenAt = Date.now();
342
+ if (connection.extensionVersion)
343
+ recordExtensionVersion(connection.extensionVersion);
344
+ log.info(`[daemon] Extension profile connected: ${connection.contextId}`);
241
345
  return;
242
346
  }
243
347
  // Handle log messages from extension
@@ -266,31 +370,11 @@ wss.on('connection', (ws) => {
266
370
  ws.on('close', () => {
267
371
  log.info('[daemon] Extension disconnected');
268
372
  clearInterval(heartbeatInterval);
269
- if (extensionWs === ws) {
270
- extensionWs = null;
271
- extensionVersion = null;
272
- extensionCompatRange = null;
273
- // Reject all pending requests since the extension is gone
274
- for (const [id, p] of pending) {
275
- clearTimeout(p.timer);
276
- p.reject(new Error('Extension disconnected'));
277
- }
278
- pending.clear();
279
- }
373
+ unregisterExtensionConnection(ws);
280
374
  });
281
375
  ws.on('error', () => {
282
376
  clearInterval(heartbeatInterval);
283
- if (extensionWs === ws) {
284
- extensionWs = null;
285
- extensionVersion = null;
286
- extensionCompatRange = null;
287
- // Reject pending requests in case 'close' does not follow this 'error'
288
- for (const [, p] of pending) {
289
- clearTimeout(p.timer);
290
- p.reject(new Error('Extension disconnected'));
291
- }
292
- pending.clear();
293
- }
377
+ unregisterExtensionConnection(ws);
294
378
  });
295
379
  });
296
380
  // ─── Start ───────────────────────────────────────────────────────────
@@ -313,8 +397,8 @@ function shutdown() {
313
397
  p.reject(new Error('Daemon shutting down'));
314
398
  }
315
399
  pending.clear();
316
- if (extensionWs)
317
- extensionWs.close();
400
+ for (const profile of extensionProfiles.values())
401
+ profile.ws.close();
318
402
  httpServer.close();
319
403
  process.exit(EXIT_CODES.SUCCESS);
320
404
  }
@@ -3,6 +3,8 @@
3
3
  *
4
4
  * Simplified for the daemon-based architecture.
5
5
  */
6
+ import type { BrowserSessionInfo } from './types.js';
7
+ import type { BrowserProfileStatus } from './browser/daemon-client.js';
6
8
  export type DoctorOptions = {
7
9
  yes?: boolean;
8
10
  live?: boolean;
@@ -24,12 +26,8 @@ export type DoctorReport = {
24
26
  extensionVersion?: string;
25
27
  latestExtensionVersion?: string;
26
28
  connectivity?: ConnectivityResult;
27
- sessions?: Array<{
28
- workspace: string;
29
- windowId: number;
30
- tabCount: number;
31
- idleMsRemaining: number;
32
- }>;
29
+ sessions?: BrowserSessionInfo[];
30
+ profiles?: BrowserProfileStatus[];
33
31
  issues: string[];
34
32
  };
35
33
  /**
@@ -10,6 +10,7 @@ import { getDaemonHealth, listSessions } 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';
13
+ import { aliasForContextId, loadProfileConfig } from './browser/profile.js';
13
14
  const DOCTOR_LIVE_TIMEOUT_SECONDS = 8;
14
15
  /** Parse a semver string into [major, minor, patch]. Returns null on invalid input. */
15
16
  function parseSemver(v) {
@@ -48,9 +49,14 @@ export async function checkConnectivity(opts) {
48
49
  try {
49
50
  const bridge = new BrowserBridge();
50
51
  const page = await bridge.connect({ timeout: opts?.timeout ?? DOCTOR_LIVE_TIMEOUT_SECONDS });
51
- // Try a simple eval to verify end-to-end connectivity
52
- await page.evaluate('1 + 1');
53
- await bridge.close();
52
+ try {
53
+ // Try a simple eval to verify end-to-end connectivity.
54
+ await page.evaluate('1 + 1');
55
+ await page.closeWindow?.();
56
+ }
57
+ finally {
58
+ await bridge.close();
59
+ }
54
60
  return { ok: true, durationMs: Date.now() - start };
55
61
  }
56
62
  catch (err) {
@@ -84,9 +90,20 @@ export async function runBrowserDoctor(opts = {}) {
84
90
  const extensionConnected = health.state === 'ready';
85
91
  const daemonFlaky = !!(connectivity?.ok && !daemonRunning);
86
92
  const extensionFlaky = !!(connectivity?.ok && daemonRunning && !extensionConnected);
87
- const sessions = opts.sessions && health.state === 'ready'
88
- ? await listSessions()
89
- : undefined;
93
+ const profiles = health.status?.profiles;
94
+ let sessions;
95
+ if (opts.sessions) {
96
+ if (profiles && profiles.length > 0) {
97
+ const grouped = await Promise.all(profiles.map(async (profile) => {
98
+ const rows = await listSessions({ contextId: profile.contextId }).catch(() => []);
99
+ return rows.map((row) => ({ ...row, contextId: row.contextId ?? profile.contextId }));
100
+ }));
101
+ sessions = grouped.flat();
102
+ }
103
+ else if (health.state === 'ready') {
104
+ sessions = await listSessions();
105
+ }
106
+ }
90
107
  const extensionVersion = health.status?.extensionVersion;
91
108
  const issues = [];
92
109
  if (daemonFlaky) {
@@ -101,23 +118,33 @@ export async function runBrowserDoctor(opts = {}) {
101
118
  'This usually means the Browser Bridge service worker is reconnecting slowly or Chrome suspended it.');
102
119
  }
103
120
  else if (daemonRunning && !extensionConnected) {
104
- const daemonVersion = health.status?.daemonVersion;
105
- const isStale = opts.cliVersion && (!daemonVersion || daemonVersion !== opts.cliVersion);
106
- if (isStale) {
107
- const reason = daemonVersion
108
- ? `daemon v${daemonVersion} CLI v${opts.cliVersion}`
109
- : `daemon predates version reporting, CLI is v${opts.cliVersion}`;
110
- issues.push(`Stale daemon detected: ${reason}.\n` +
111
- 'The daemon was started by an older CLI version and may have missed the extension registration.\n' +
112
- ' Quick fix: opencli daemon stop && opencli doctor');
121
+ if (health.state === 'profile-required') {
122
+ issues.push('Multiple Chrome profiles are connected to the daemon, but no default profile was selected.\n' +
123
+ ' Run opencli profile list, then opencli profile use <name>, or pass --profile <name>.');
124
+ }
125
+ else if (health.state === 'profile-disconnected') {
126
+ issues.push(`Selected browser profile is not connected: ${health.status?.contextId ?? 'unknown'}.\n` +
127
+ ' Open that Chrome profile and make sure the OpenCLI extension is enabled.');
113
128
  }
114
129
  else {
115
- issues.push('Daemon is running but the Chrome/Chromium extension is not connected.\n' +
116
- 'If the extension is already installed, try: opencli daemon stop && opencli doctor\n' +
117
- 'If the extension is not installed:\n' +
118
- ' 1. Download from https://github.com/jackwener/opencli/releases\n' +
119
- ' 2. Open chrome://extensions/ Enable Developer Mode\n' +
120
- ' 3. Click "Load unpacked" select the extension folder');
130
+ const daemonVersion = health.status?.daemonVersion;
131
+ const isStale = opts.cliVersion && (!daemonVersion || daemonVersion !== opts.cliVersion);
132
+ if (isStale) {
133
+ const reason = daemonVersion
134
+ ? `daemon v${daemonVersion} CLI v${opts.cliVersion}`
135
+ : `daemon predates version reporting, CLI is v${opts.cliVersion}`;
136
+ issues.push(`Stale daemon detected: ${reason}.\n` +
137
+ 'The daemon was started by an older CLI version and may have missed the extension registration.\n' +
138
+ ' Quick fix: opencli daemon stop && opencli doctor');
139
+ }
140
+ else {
141
+ issues.push('Daemon is running but the Chrome/Chromium extension is not connected.\n' +
142
+ 'If the extension is already installed, try: opencli daemon stop && opencli doctor\n' +
143
+ 'If the extension is not installed:\n' +
144
+ ' 1. Download from https://github.com/jackwener/opencli/releases\n' +
145
+ ' 2. Open chrome://extensions/ → Enable Developer Mode\n' +
146
+ ' 3. Click "Load unpacked" → select the extension folder');
147
+ }
121
148
  }
122
149
  }
123
150
  if (extensionConnected && !extensionVersion) {
@@ -162,6 +189,7 @@ export async function runBrowserDoctor(opts = {}) {
162
189
  latestExtensionVersion,
163
190
  connectivity,
164
191
  sessions,
192
+ profiles,
165
193
  issues,
166
194
  };
167
195
  }
@@ -191,6 +219,17 @@ export function renderBrowserDoctorReport(report) {
191
219
  ? 'unstable (connected during live check, then disconnected)'
192
220
  : report.extensionConnected ? 'connected' : 'not connected';
193
221
  lines.push(`${extIcon} Extension: ${extLabel}${extVersion}`);
222
+ if (report.profiles && report.profiles.length > 0) {
223
+ const config = loadProfileConfig();
224
+ lines.push('', styleText('bold', 'Profiles:'));
225
+ for (const profile of report.profiles) {
226
+ const alias = aliasForContextId(config, profile.contextId);
227
+ const aliasText = alias ? ` (${alias})` : '';
228
+ const defaultText = config.defaultContextId === profile.contextId ? ', default' : '';
229
+ const version = profile.extensionVersion ? `v${profile.extensionVersion}` : 'version unknown';
230
+ lines.push(styleText('dim', ` • ${profile.contextId}${aliasText}: connected ${version}${defaultText}`));
231
+ }
232
+ }
194
233
  // Connectivity
195
234
  if (report.connectivity) {
196
235
  const connIcon = report.connectivity.ok ? styleText('green', '[OK]') : styleText('red', '[FAIL]');
@@ -208,8 +247,27 @@ export function renderBrowserDoctorReport(report) {
208
247
  lines.push(styleText('dim', ' • no active automation sessions'));
209
248
  }
210
249
  else {
250
+ const byContext = new Map();
211
251
  for (const session of report.sessions) {
212
- lines.push(styleText('dim', ` • ${session.workspace} window ${session.windowId}, tabs=${session.tabCount}, idle=${Math.ceil(session.idleMsRemaining / 1000)}s`));
252
+ const contextId = typeof session.contextId === 'string' && session.contextId ? session.contextId : 'default';
253
+ const rows = byContext.get(contextId) ?? [];
254
+ rows.push(session);
255
+ byContext.set(contextId, rows);
256
+ }
257
+ for (const [contextId, rows] of byContext) {
258
+ if (byContext.size > 1)
259
+ lines.push(styleText('dim', ` [profile: ${contextId}]`));
260
+ for (const session of rows) {
261
+ const idle = session.idleMsRemaining == null
262
+ ? 'none'
263
+ : `${Math.ceil(session.idleMsRemaining / 1000)}s`;
264
+ const target = session.preferredTabId != null
265
+ ? `tab ${session.preferredTabId}`
266
+ : `window ${session.windowId ?? 'unknown'}`;
267
+ const mode = session.ownership ?? (session.owned === false ? 'borrowed' : 'owned');
268
+ const surface = session.surface ? `, surface=${session.surface}` : '';
269
+ lines.push(styleText('dim', ` • ${session.workspace ?? 'default'} → ${target}, mode=${mode}${surface}, tabs=${session.tabCount ?? 0}, idle=${idle}`));
270
+ }
213
271
  }
214
272
  }
215
273
  }
@@ -78,6 +78,64 @@ describe('doctor report rendering', () => {
78
78
  }));
79
79
  expect(text).toContain('[SKIP] Connectivity: skipped (--no-live)');
80
80
  });
81
+ it('renders sessions with tab leases and no idle timer', () => {
82
+ const text = strip(renderBrowserDoctorReport({
83
+ daemonRunning: true,
84
+ extensionConnected: true,
85
+ issues: [],
86
+ sessions: [
87
+ {
88
+ workspace: 'bound:default',
89
+ windowId: 2,
90
+ preferredTabId: 42,
91
+ ownership: 'borrowed',
92
+ surface: 'borrowed-user-tab',
93
+ tabCount: 1,
94
+ idleMsRemaining: null,
95
+ },
96
+ ],
97
+ }));
98
+ expect(text).toContain('bound:default → tab 42, mode=borrowed, surface=borrowed-user-tab, tabs=1, idle=none');
99
+ });
100
+ it('renders connected profiles and groups sessions by profile', () => {
101
+ const text = strip(renderBrowserDoctorReport({
102
+ daemonRunning: true,
103
+ extensionConnected: false,
104
+ profiles: [
105
+ { contextId: 'work', extensionConnected: true, extensionVersion: '1.2.3', pending: 0 },
106
+ { contextId: 'personal', extensionConnected: true, extensionVersion: '1.2.3', pending: 0 },
107
+ ],
108
+ issues: [],
109
+ sessions: [
110
+ {
111
+ contextId: 'work',
112
+ workspace: 'bound:default',
113
+ windowId: 2,
114
+ preferredTabId: 42,
115
+ ownership: 'borrowed',
116
+ surface: 'borrowed-user-tab',
117
+ tabCount: 1,
118
+ idleMsRemaining: null,
119
+ },
120
+ {
121
+ contextId: 'personal',
122
+ workspace: 'site:foo',
123
+ windowId: 1,
124
+ preferredTabId: 10,
125
+ ownership: 'owned',
126
+ surface: 'dedicated-container',
127
+ tabCount: 1,
128
+ idleMsRemaining: 1000,
129
+ },
130
+ ],
131
+ }));
132
+ expect(text).toContain('Profiles:');
133
+ expect(text).toContain('work: connected v1.2.3');
134
+ expect(text).toContain('[profile: work]');
135
+ expect(text).toContain('[profile: personal]');
136
+ expect(text).toContain('bound:default → tab 42');
137
+ expect(text).toContain('site:foo → tab 10');
138
+ });
81
139
  it('renders unstable extension state when live connectivity and status disagree', () => {
82
140
  const text = strip(renderBrowserDoctorReport({
83
141
  daemonRunning: true,
@@ -142,16 +200,19 @@ describe('doctor report rendering', () => {
142
200
  });
143
201
  it('uses the fast default timeout for live connectivity checks', async () => {
144
202
  let timeoutSeen;
203
+ const closeWindow = vi.fn().mockResolvedValue(undefined);
145
204
  mockConnect.mockImplementationOnce(async (opts) => {
146
205
  timeoutSeen = opts?.timeout;
147
206
  return {
148
207
  evaluate: vi.fn().mockResolvedValue(2),
208
+ closeWindow,
149
209
  };
150
210
  });
151
211
  mockClose.mockResolvedValueOnce(undefined);
152
212
  mockGetDaemonHealth.mockResolvedValueOnce({ state: 'ready', status: { extensionConnected: true } });
153
213
  await runBrowserDoctor({ live: true });
154
214
  expect(timeoutSeen).toBe(8);
215
+ expect(closeWindow).toHaveBeenCalledTimes(1);
155
216
  });
156
217
  it('skips auto-start in no-live mode when daemon is already running', async () => {
157
218
  mockGetDaemonHealth.mockResolvedValueOnce({ state: 'no-extension', status: { extensionConnected: false } });
@@ -177,4 +238,25 @@ describe('doctor report rendering', () => {
177
238
  expect.stringContaining('did not report a version'),
178
239
  ]));
179
240
  });
241
+ it('reports profile-required when multiple profiles are connected without a selection', async () => {
242
+ const status = {
243
+ state: 'profile-required',
244
+ status: {
245
+ extensionConnected: false,
246
+ profileRequired: true,
247
+ profiles: [
248
+ { contextId: 'work', extensionConnected: true, pending: 0 },
249
+ { contextId: 'personal', extensionConnected: true, pending: 0 },
250
+ ],
251
+ },
252
+ };
253
+ mockGetDaemonHealth
254
+ .mockResolvedValueOnce(status)
255
+ .mockResolvedValueOnce(status);
256
+ const report = await runBrowserDoctor({ live: false });
257
+ expect(report.profiles).toHaveLength(2);
258
+ expect(report.issues).toEqual(expect.arrayContaining([
259
+ expect.stringContaining('Multiple Chrome profiles are connected'),
260
+ ]));
261
+ });
180
262
  });
@@ -137,6 +137,7 @@ describe('discoverPlugins', () => {
137
137
  const symlinkTargetDir = path.join(os.tmpdir(), '__test-plugin-symlink-target__');
138
138
  const symlinkPluginDir = path.join(PLUGINS_DIR, '__test-plugin-symlink__');
139
139
  const brokenSymlinkDir = path.join(PLUGINS_DIR, '__test-plugin-broken__');
140
+ const dirSymlinkType = process.platform === 'win32' ? 'junction' : 'dir';
140
141
  afterEach(async () => {
141
142
  try {
142
143
  await fs.promises.rm(testPluginDir, { recursive: true });
@@ -183,14 +184,14 @@ description: Test plugin greeting via symlink
183
184
  strategy: public
184
185
  browser: false
185
186
  `);
186
- await fs.promises.symlink(symlinkTargetDir, symlinkPluginDir, 'dir');
187
+ await fs.promises.symlink(symlinkTargetDir, symlinkPluginDir, dirSymlinkType);
187
188
  await discoverPlugins();
188
189
  const cmd = getRegistry().get('__test-plugin-symlink__/hello');
189
190
  expect(cmd).toBeUndefined();
190
191
  });
191
192
  it('skips broken plugin symlinks without throwing', async () => {
192
193
  await fs.promises.mkdir(PLUGINS_DIR, { recursive: true });
193
- await fs.promises.symlink(path.join(os.tmpdir(), '__missing-plugin-target__'), brokenSymlinkDir, 'dir');
194
+ await fs.promises.symlink(path.join(os.tmpdir(), '__missing-plugin-target__'), brokenSymlinkDir, dirSymlinkType);
194
195
  await expect(discoverPlugins()).resolves.not.toThrow();
195
196
  expect(getRegistry().get('__test-plugin-broken__/hello')).toBeUndefined();
196
197
  });
@@ -210,7 +211,7 @@ describe('executeCommand', () => {
210
211
  args: [
211
212
  { name: 'note-id', required: true, help: 'Note ID' },
212
213
  ],
213
- func: async (_page, kwargs) => [{ noteId: kwargs['note-id'] }],
214
+ func: async (kwargs) => [{ noteId: kwargs['note-id'] }],
214
215
  });
215
216
  const result = await executeCommand(cmd, { 'note-id': 'abc123' });
216
217
  expect(result).toEqual([{ noteId: 'abc123' }]);
@@ -222,7 +223,7 @@ describe('executeCommand', () => {
222
223
  description: 'test command with func',
223
224
  browser: false,
224
225
  strategy: Strategy.PUBLIC,
225
- func: async (_page, kwargs) => {
226
+ func: async (kwargs) => {
226
227
  return [{ title: kwargs.query ?? 'default' }];
227
228
  },
228
229
  });
@@ -260,7 +261,7 @@ describe('executeCommand', () => {
260
261
  name: 'debug-test',
261
262
  description: 'debug test',
262
263
  browser: false,
263
- func: async (_page, _kwargs, debug) => {
264
+ func: async (_kwargs, debug) => {
264
265
  receivedDebug = debug ?? false;
265
266
  return [];
266
267
  },
@@ -13,12 +13,13 @@
13
13
  * 1 Generic / unexpected error
14
14
  * 2 Argument / usage error (ArgumentError)
15
15
  * 66 No input / empty result (EmptyResultError)
16
- * 69 Service unavailable (BrowserConnectError, AdapterLoadError)
16
+ * 69 Service unavailable (BrowserConnectError, adapter load failures)
17
17
  * 75 Temporary failure, retry later (TimeoutError) EX_TEMPFAIL
18
18
  * 77 Permission denied / auth needed (AuthRequiredError)
19
19
  * 78 Configuration error (ConfigError)
20
20
  * 130 Interrupted by Ctrl-C (set by tui.ts SIGINT handler)
21
21
  */
22
+ import type { ObservationTraceReceipt } from './observation/events.js';
22
23
  export declare const EXIT_CODES: {
23
24
  readonly SUCCESS: 0;
24
25
  readonly GENERIC_ERROR: 1;
@@ -40,14 +41,13 @@ export declare class CliError extends Error {
40
41
  readonly exitCode: ExitCode;
41
42
  constructor(code: string, message: string, hint?: string, exitCode?: ExitCode);
42
43
  }
43
- export type BrowserConnectKind = 'daemon-not-running' | 'extension-not-connected' | 'command-failed' | 'unknown';
44
+ export declare function attachTraceReceipt(err: unknown, receipt: ObservationTraceReceipt): void;
45
+ export declare function getTraceReceipt(err: unknown): ObservationTraceReceipt | undefined;
46
+ export type BrowserConnectKind = 'daemon-not-running' | 'extension-not-connected' | 'profile-required' | 'profile-disconnected' | 'command-failed' | 'unknown';
44
47
  export declare class BrowserConnectError extends CliError {
45
48
  readonly kind: BrowserConnectKind;
46
49
  constructor(message: string, hint?: string, kind?: BrowserConnectKind);
47
50
  }
48
- export declare class AdapterLoadError extends CliError {
49
- constructor(message: string, hint?: string);
50
- }
51
51
  export declare class CommandExecutionError extends CliError {
52
52
  constructor(message: string, hint?: string);
53
53
  }
@@ -67,9 +67,8 @@ export declare class ArgumentError extends CliError {
67
67
  export declare class EmptyResultError extends CliError {
68
68
  constructor(command: string, hint?: string);
69
69
  }
70
- export declare class SelectorError extends CliError {
71
- constructor(selector: string, hint?: string);
72
- }
70
+ export declare function adapterLoadError(message: string, hint?: string): CliError;
71
+ export declare function selectorError(selector: string, hint?: string): CliError;
73
72
  export declare class PluginError extends CliError {
74
73
  constructor(message: string, hint?: string);
75
74
  }
@@ -84,6 +83,13 @@ export interface ErrorEnvelope {
84
83
  stack?: string;
85
84
  cause?: string;
86
85
  };
86
+ trace?: {
87
+ traceId: string;
88
+ dir: string;
89
+ summaryPath: string;
90
+ receiptPath: string;
91
+ status: ObservationTraceReceipt['status'];
92
+ };
87
93
  }
88
94
  /** Extract a human-readable message from an unknown caught value. */
89
95
  export declare function getErrorMessage(error: unknown): string;