@surething/cockpit 1.0.216 → 1.0.218

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 (75) hide show
  1. package/.next-prod/BUILD_ID +1 -1
  2. package/.next-prod/app-path-routes-manifest.json +2 -2
  3. package/.next-prod/build-manifest.json +2 -2
  4. package/.next-prod/prerender-manifest.json +3 -3
  5. package/.next-prod/server/app/_global-error/page_client-reference-manifest.js +1 -1
  6. package/.next-prod/server/app/_global-error.html +1 -1
  7. package/.next-prod/server/app/_global-error.rsc +1 -1
  8. package/.next-prod/server/app/_global-error.segments/_full.segment.rsc +1 -1
  9. package/.next-prod/server/app/_global-error.segments/_global-error/__PAGE__.segment.rsc +1 -1
  10. package/.next-prod/server/app/_global-error.segments/_global-error.segment.rsc +1 -1
  11. package/.next-prod/server/app/_global-error.segments/_head.segment.rsc +1 -1
  12. package/.next-prod/server/app/_global-error.segments/_index.segment.rsc +1 -1
  13. package/.next-prod/server/app/_global-error.segments/_tree.segment.rsc +1 -1
  14. package/.next-prod/server/app/_not-found/page_client-reference-manifest.js +1 -1
  15. package/.next-prod/server/app/_not-found.html +1 -1
  16. package/.next-prod/server/app/_not-found.rsc +3 -3
  17. package/.next-prod/server/app/_not-found.segments/_full.segment.rsc +3 -3
  18. package/.next-prod/server/app/_not-found.segments/_head.segment.rsc +1 -1
  19. package/.next-prod/server/app/_not-found.segments/_index.segment.rsc +3 -3
  20. package/.next-prod/server/app/_not-found.segments/_not-found/__PAGE__.segment.rsc +1 -1
  21. package/.next-prod/server/app/_not-found.segments/_not-found.segment.rsc +1 -1
  22. package/.next-prod/server/app/_not-found.segments/_tree.segment.rsc +2 -2
  23. package/.next-prod/server/app/api/chat/deepseek/route.js +1 -1
  24. package/.next-prod/server/app/api/chat/route.js +1 -1
  25. package/.next-prod/server/app/api/extension/version/route.js.nft.json +1 -1
  26. package/.next-prod/server/app/api/projectGraph/file-functions/route.js +1 -1
  27. package/.next-prod/server/app/api/scheduled-tasks/route.js +1 -1
  28. package/.next-prod/server/app/api/terminal/bubble-order/route.js +1 -1
  29. package/.next-prod/server/app/page_client-reference-manifest.js +1 -1
  30. package/.next-prod/server/app/project/page_client-reference-manifest.js +1 -1
  31. package/.next-prod/server/app/review/[id]/page_client-reference-manifest.js +1 -1
  32. package/.next-prod/server/app-paths-manifest.json +2 -2
  33. package/.next-prod/server/chunks/2939.js +1 -1
  34. package/.next-prod/server/chunks/8916.js +1 -1
  35. package/.next-prod/server/chunks/9658.js +7 -7
  36. package/.next-prod/server/chunks/9877.js +1 -1
  37. package/.next-prod/server/functions-config-manifest.json +1 -0
  38. package/.next-prod/server/middleware-build-manifest.js +1 -1
  39. package/.next-prod/server/pages/404.html +1 -1
  40. package/.next-prod/server/pages/500.html +1 -1
  41. package/.next-prod/server/server-reference-manifest.json +1 -1
  42. package/.next-prod/static/chunks/6345-2637497e8b101740.js +14 -0
  43. package/.next-prod/static/chunks/6917-ed0e9c62a123d529.js +29 -0
  44. package/.next-prod/static/chunks/app/{layout-a0362651ba6e6e6f.js → layout-1659a95e6c4a6bb5.js} +1 -1
  45. package/.next-prod/static/chunks/app/{page-1b14cabf47df9ff7.js → page-afcbd897b4c3600f.js} +1 -1
  46. package/.next-prod/static/chunks/app/project/{page-1b14cabf47df9ff7.js → page-afcbd897b4c3600f.js} +1 -1
  47. package/.next-prod/static/css/f4a773117ca8af75.css +1 -0
  48. package/.next-prod/trace +13 -13
  49. package/.next-prod/trace-build +1 -1
  50. package/README.md +8 -7
  51. package/README.zh.md +8 -7
  52. package/bin/cock-browser.messages.mjs +176 -0
  53. package/bin/cock-browser.mjs +290 -18
  54. package/bin/cock-codegraph.mjs +21 -6
  55. package/bin/cock-connection.mjs +151 -0
  56. package/bin/cock.mjs +12 -1
  57. package/bin/setup-dev.mjs +15 -13
  58. package/chrome-extension/automation.js +684 -32
  59. package/chrome-extension/manifest.json +1 -1
  60. package/chrome-extension/messages.js +45 -0
  61. package/dist/{chunk-CZWJPTRO.mjs → chunk-GCYLMG43.mjs} +2486 -1047
  62. package/dist/chunk-O4P2J44N.mjs +314 -0
  63. package/dist/{chunk-KRTISG5I.mjs → chunk-WOM47O75.mjs} +245 -10
  64. package/dist/httpApi.mjs +140 -7
  65. package/dist/scheduledTasks.mjs +15 -1159
  66. package/dist/{server-OSOMFNXR.mjs → server-SNB4H35J.mjs} +8 -2
  67. package/dist/wsServer.mjs +27 -19
  68. package/package.json +3 -5
  69. package/server.mjs +5 -1
  70. package/.next-prod/static/chunks/5188-415582403ef0e29c.js +0 -29
  71. package/.next-prod/static/chunks/6345-e5ceeb2aeb698eb6.js +0 -14
  72. package/.next-prod/static/css/cc6d733cdf607b30.css +0 -1
  73. package/dist/chunk-ZJ6CC3MH.mjs +0 -223
  74. /package/.next-prod/static/{GAYKr2BmQpFqJgRJfvQ3D → bOkuiIr_nWzG5GjPLNqdN}/_buildManifest.js +0 -0
  75. /package/.next-prod/static/{GAYKr2BmQpFqJgRJfvQ3D → bOkuiIr_nWzG5GjPLNqdN}/_ssgManifest.js +0 -0
@@ -19,6 +19,19 @@
19
19
  * cockpit browser abcd list (list all connected browsers)
20
20
  */
21
21
 
22
+ import {
23
+ TIMEOUT_MSG,
24
+ CONNECT_REFUSED_MSG,
25
+ CLICK_NO_OP_WARN,
26
+ HELP_WHEN_NOT_TO_USE,
27
+ HELP_INTERACTION_BY_SELECTOR,
28
+ HELP_FETCH,
29
+ HELP_HEALTH,
30
+ HELP_WAIT,
31
+ HELP_ASSERT,
32
+ HELP_LIFECYCLE,
33
+ } from './cock-browser.messages.mjs';
34
+
22
35
  const args = process.argv.slice(2);
23
36
 
24
37
  // Help text
@@ -55,14 +68,16 @@ Three templates that always work:
55
68
  # 2) Set a React-controlled <input> via the native setter + input event:
56
69
  evaluate "(() => { const el = document.querySelector('input[name=foo]'); const set = Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, 'value').set; set.call(el, 'hello'); el.dispatchEvent(new Event('input', { bubbles: true })); return el.value; })()"
57
70
 
58
- # 3) Click a button by aria-label / text (refs go stale after re-render):
59
- evaluate "(() => { const btn = Array.from(document.querySelectorAll('button')).find(b => b.getAttribute('aria-label') === 'Send message' || b.textContent.trim() === 'Send'); if (!btn) return 'not-found'; btn.click(); return 'clicked'; })()"
71
+ # 3) Click a button by visible text or aria-label (refs go stale on re-render):
72
+ click --text "Sign in" # preferred shortcut
73
+ click --selector 'button[type="submit"]' # exact selector
74
+ evaluate "(() => document.querySelector('button[aria-label=\\"Save\\"]').click())()" # last resort
60
75
 
61
- Refs (e5, e23, …) returned by \`snapshot\` are valid only for the
62
- DOM at snapshot time. Any re-render / route change / focus shift
63
- invalidates them — \`Element ref "eN" not found or disconnected\`
64
- means you need a fresh \`snapshot\` (or just use \`evaluate\` with
65
- CSS selectors).
76
+ Refs returned by \`snapshot\` are \`e<N>#v<epoch>\` — valid only until
77
+ the next snapshot or re-render. The error message
78
+ \`Element ref "..." is stale (current snapshot v=N)\` tells you to
79
+ re-snapshot OR use \`click --text\` / \`click --selector\` /
80
+ \`fill --selector\` for re-render-resilient interaction.
66
81
 
67
82
  ──────────────────────────────────────────────────────
68
83
 
@@ -74,18 +89,29 @@ Navigation:
74
89
  title Get page title
75
90
 
76
91
  Inspection:
77
- snapshot Get element tree (returns refs like [e5])
78
- screenshot Take a screenshot
92
+ snapshot a11y tree (refs like e5#v3; banner explains format)
93
+ --filter <regex> server-side grep
94
+ --include-hidden-text surface collapsed <summary>/container text
95
+ --max-depth N limit walk depth (default 12)
96
+ screenshot PNG saved to /tmp; path printed for Read tool
97
+
98
+ ${HELP_INTERACTION_BY_SELECTOR(prefix)}
79
99
 
80
- Interaction (use ref from snapshot):
81
- click <ref> Click element ⚠ React/SPA: may silently miss; use evaluate
82
- type <ref> <text> Type text ⚠ React/SPA: may silently miss; use evaluate (template 1/2)
83
- fill <ref> <value> Fill input value ⚠ Same — prefer template 2
100
+ type <ref> <text> Type into ref (CDP key events; React-controlled may silently miss → use fill --selector)
84
101
  hover <ref> Hover element
85
102
  focus <ref> Focus element
86
103
  scroll --direction D Scroll page (up/down/left/right)
87
104
  key <key> Press key (e.g. Enter, Ctrl+A)
88
- wait --text T Wait for text to appear
105
+
106
+ ${HELP_WAIT}
107
+
108
+ ${HELP_ASSERT}
109
+
110
+ ${HELP_LIFECYCLE}
111
+
112
+ ${HELP_FETCH}
113
+
114
+ ${HELP_HEALTH}
89
115
 
90
116
  DOM:
91
117
  computed <ref> Get computed styles
@@ -111,6 +137,8 @@ Console & Debug:
111
137
  cookies Get cookies
112
138
  storage [--type T] Get storage (local|session)
113
139
 
140
+ ${HELP_WHEN_NOT_TO_USE}
141
+
114
142
  ── Next step ──────────────────────────────────────────
115
143
  Run \`cockpit browser ${prefix} snapshot\` to inspect the page.
116
144
  It returns an element tree with refs like [e5]. Use those
@@ -191,9 +219,32 @@ const params = parseFlags(args.slice(action === 'list' ? 1 : 2));
191
219
  if (params._positional?.length) {
192
220
  const pos = params._positional;
193
221
  if (action === 'navigate' && !params.url) params.url = pos[0];
194
- if (action === 'click' && !params.ref) params.ref = pos[0];
222
+ // click: positional is the ref (or text fallback for convenience if it does
223
+ // not look like a ref). Refs match e<N>#v<M>; anything else is treated as
224
+ // visible text so `click "Sign in"` Just Works.
225
+ if (action === 'click') {
226
+ if (!params.ref && !params.text && !params.selector) {
227
+ if (/^e\d+#v\d+$/.test(pos[0])) params.ref = pos[0];
228
+ else params.text = pos[0];
229
+ }
230
+ }
195
231
  if (action === 'type' && !params.ref) { params.ref = pos[0]; if (pos[1] && !params.text) params.text = pos[1]; }
196
- if (action === 'fill' && !params.ref) { params.ref = pos[0]; if (pos[1] && !params.value) params.value = pos[1]; }
232
+ // fill: positional[0] is ref (when matches ref pattern) else --selector form.
233
+ // positional[1] is the value when ref-positional is used.
234
+ if (action === 'fill') {
235
+ if (!params.ref && !params.selector) {
236
+ if (/^e\d+#v\d+$/.test(pos[0])) {
237
+ params.ref = pos[0];
238
+ if (pos[1] && !params.value) params.value = pos[1];
239
+ } else {
240
+ // First positional taken as selector if it contains CSS-y chars.
241
+ params.selector = pos[0];
242
+ if (pos[1] && !params.value) params.value = pos[1];
243
+ }
244
+ } else if (params.selector && !params.value && pos[0]) {
245
+ params.value = pos[0];
246
+ }
247
+ }
197
248
  if (action === 'hover' && !params.ref) params.ref = pos[0];
198
249
  if (action === 'focus' && !params.ref) params.ref = pos[0];
199
250
  if (action === 'evaluate' && !params.js) params.js = pos[0];
@@ -206,9 +257,33 @@ if (params._positional?.length) {
206
257
  if (action === 'events' && !params.ref) params.ref = pos[0];
207
258
  if (action === 'network_detail' && !params.id) params.id = parseInt(pos[0]);
208
259
  if (action === 'network_record' && !params.action) params.action = pos[0] || 'status';
260
+ if (action === 'fetch' && !params.url) params.url = pos[0];
261
+ if (action === 'health' && pos[0] === '--deep') params.deep = true;
209
262
  delete params._positional;
210
263
  }
211
264
 
265
+ // kebab → camel for new Phase 2 flags.
266
+ if (params['network-idle']) { params.networkIdle = true; delete params['network-idle']; }
267
+ if (params['dom-stable']) { params.domStable = true; delete params['dom-stable']; }
268
+ if (params['extension-ready']) { params.extensionReady = true; delete params['extension-ready']; }
269
+ if (params['quiet-ms'] != null) { params.quietMs = params['quiet-ms']; delete params['quiet-ms']; }
270
+ if (params['max-request-age-ms'] != null) { params.maxRequestAgeMs = params['max-request-age-ms']; delete params['max-request-age-ms']; }
271
+ if (params['fetch-status'] != null) { params.fetchStatus = params['fetch-status']; delete params['fetch-status']; }
272
+ if (params['fetch-method']) { params.fetchMethod = params['fetch-method']; delete params['fetch-method']; }
273
+ if (params['not-contains'] !== undefined) { params.notContains = params['not-contains']; delete params['not-contains']; }
274
+ if (params['no-cache']) { params.noCache = true; delete params['no-cache']; }
275
+ if (params['console-no-errors']) { params.consoleNoErrors = true; delete params['console-no-errors']; }
276
+ if (params['same-site']) { params.sameSite = params['same-site']; delete params['same-site']; }
277
+ if (params['http-only']) { params.httpOnly = true; delete params['http-only']; }
278
+ if (params['verify-ms'] != null) { params.verifyMs = Number(params['verify-ms']); delete params['verify-ms']; }
279
+
280
+ // kebab → camel for flags that the extension expects camel.
281
+ if (params['include-hidden-text']) { params.includeHiddenText = true; delete params['include-hidden-text']; }
282
+ if (params['max-depth'] != null) { params.maxDepth = params['max-depth']; delete params['max-depth']; }
283
+ if (params['form-selector']) { params.formSelector = params['form-selector']; delete params['form-selector']; }
284
+ if (params['skip-verify']) { params.skipVerify = true; delete params['skip-verify']; }
285
+ if (params['no-verify']) { params.skipVerify = true; delete params['no-verify']; }
286
+
212
287
  // Port: env COCKPIT_PORT > ~/.cockpit/server.json > default 3457
213
288
  let port = process.env.COCKPIT_PORT || 3457;
214
289
  if (!process.env.COCKPIT_PORT) {
@@ -312,6 +387,43 @@ async function autoResolveChunked(baseUrl, id, data, cmdTimeout) {
312
387
  }
313
388
 
314
389
  // Send request
390
+ // F2.7 — wait --extension-ready: poll the cheap server-side health endpoint
391
+ // until the bridge reports quiet conditions for `quietMs` consecutive ms.
392
+ // Replaces the manual `until cockpit browser X evaluate "1+1"` loop that AI
393
+ // has historically used when an evaluate hangs on a busy page.
394
+ async function waitExtensionReady({ quietMs = 500, timeoutMs = 60000 }) {
395
+ const start = Date.now();
396
+ let quietSince = null;
397
+ while (Date.now() - start < timeoutMs) {
398
+ let h = null;
399
+ try {
400
+ const r = await fetch(`${baseUrl}/api/browser/health`, {
401
+ method: 'POST',
402
+ headers: { 'Content-Type': 'application/json' },
403
+ body: JSON.stringify({ id, params: {}, timeout: 1000 }),
404
+ signal: AbortSignal.timeout(2000),
405
+ });
406
+ const j = await r.json();
407
+ h = j.ok ? j.data : null;
408
+ } catch { /* network blip — treat as not-ready */ }
409
+ const ready = h && h.found && h.ws === 'open' && h.pendingCommands === 0;
410
+ if (ready) {
411
+ if (quietSince == null) quietSince = Date.now();
412
+ if (Date.now() - quietSince >= quietMs) {
413
+ return { waited: `extension-ready (quiet=${quietMs}ms)`, elapsedMs: Date.now() - start };
414
+ }
415
+ } else {
416
+ quietSince = null;
417
+ }
418
+ await new Promise(r => setTimeout(r, 100));
419
+ }
420
+ throw new Error(
421
+ `wait --extension-ready timed out after ${timeoutMs}ms.\n` +
422
+ ` The bridge never reported quiet for ${quietMs}ms.\n` +
423
+ ` Consider a service-level test if the page is driven by an async LLM/agent flow.`
424
+ );
425
+ }
426
+
315
427
  async function run() {
316
428
  // Only id provided without action → show help + status
317
429
  if (action === '_status') {
@@ -340,6 +452,22 @@ async function run() {
340
452
  return;
341
453
  }
342
454
 
455
+ // F2.7 — wait --extension-ready runs entirely CLI-side: it polls the cheap
456
+ // server-side `health` endpoint, so it works even when the page is blocked.
457
+ if (action === 'wait' && params.extensionReady) {
458
+ try {
459
+ const r = await waitExtensionReady({
460
+ quietMs: params.quietMs ?? 500,
461
+ timeoutMs: timeout,
462
+ });
463
+ console.log(`waited: ${r.waited} (elapsed ${formatMs(r.elapsedMs)})`);
464
+ return;
465
+ } catch (err) {
466
+ console.error(err.message);
467
+ process.exit(1);
468
+ }
469
+ }
470
+
343
471
  const url = `${baseUrl}/api/browser/${action}`;
344
472
  const body = { id, params, timeout };
345
473
 
@@ -427,13 +555,18 @@ async function run() {
427
555
  }
428
556
  }
429
557
 
558
+ // F1.7 — post-verify silent failures for click / key / submit.
559
+ if (POST_VERIFY_ACTIONS.has(action)) {
560
+ try { await postVerify(action, params, resolved); } catch { /* never fail the command */ }
561
+ }
562
+
430
563
  // Format output
431
564
  await formatOutput(action, resolved);
432
565
  } catch (err) {
433
566
  if (err.name === 'TimeoutError' || err.code === 'ABORT_ERR') {
434
- console.error(`Timeout: No response within ${timeout}ms. Is the browser bubble connected?`);
567
+ console.error(TIMEOUT_MSG(timeout, id));
435
568
  } else if (err.cause?.code === 'ECONNREFUSED') {
436
- console.error(`Connection refused: Cockpit server not running at ${baseUrl}`);
569
+ console.error(CONNECT_REFUSED_MSG(baseUrl));
437
570
  } else {
438
571
  console.error(`Error: ${err.message}`);
439
572
  }
@@ -441,6 +574,57 @@ async function run() {
441
574
  }
442
575
  }
443
576
 
577
+ // F1.7 — Post-verify for click / key / submit. CDP reports "success" even when
578
+ // the framework didn't react. Diff a cheap state probe before and after; if
579
+ // nothing observable changed in the window, warn with actionable templates.
580
+ //
581
+ // Default window: 1000ms (BL-1, observation period). Was 200ms but dogfood
582
+ // showed false positives on React pages whose batched re-renders + XHR fire
583
+ // took >200ms to surface. Users can override per command with --verify-ms;
584
+ // --skip-verify (or --no-verify) opts out entirely.
585
+ const POST_VERIFY_ACTIONS = new Set(['click', 'key', 'submit']);
586
+ const POST_VERIFY_WINDOW_MS_DEFAULT = 1000;
587
+
588
+ async function probeState() {
589
+ const r = await fetch(`${baseUrl}/api/browser/probe_state`, {
590
+ method: 'POST',
591
+ headers: { 'Content-Type': 'application/json' },
592
+ body: JSON.stringify({ id, params: {}, timeout: 2000 }),
593
+ signal: AbortSignal.timeout(3000),
594
+ });
595
+ const j = await r.json();
596
+ return j.ok ? j.data : null;
597
+ }
598
+
599
+ async function postVerify(action, params, originalResolved) {
600
+ if (params.skipVerify) return;
601
+ const windowMs = Number.isFinite(params.verifyMs) && params.verifyMs > 0
602
+ ? params.verifyMs
603
+ : POST_VERIFY_WINDOW_MS_DEFAULT;
604
+ let before;
605
+ try { before = await probeState(); } catch { return; }
606
+ if (!before) return;
607
+ await new Promise(r => setTimeout(r, windowMs));
608
+ let after;
609
+ try { after = await probeState(); } catch { return; }
610
+ if (!after) return;
611
+ const urlChanged = before.url !== after.url;
612
+ const domChanged = before.domHash !== after.domHash || before.domLen !== after.domLen;
613
+ const newRequests = after.lastNetworkId > before.lastNetworkId;
614
+ if (!urlChanged && !domChanged && !newRequests) {
615
+ process.stderr.write(
616
+ '\n' +
617
+ CLICK_NO_OP_WARN(action, id, {
618
+ windowMs,
619
+ urlChanged,
620
+ domChanged,
621
+ newRequests,
622
+ }) +
623
+ '\n'
624
+ );
625
+ }
626
+ }
627
+
444
628
  async function formatOutput(action, data) {
445
629
  if (data === undefined || data === null) {
446
630
  // evaluate-family silently returning undefined/null is a major source of
@@ -486,6 +670,86 @@ async function formatOutput(action, data) {
486
670
  console.log(data);
487
671
  return;
488
672
 
673
+ case 'health':
674
+ // Server-side bridge health snapshot.
675
+ if (!data.found) {
676
+ console.log(`browser "${id}" not registered (no bubble open?)`);
677
+ process.exit(2);
678
+ }
679
+ console.log(
680
+ `extension: ${data.ws === 'open' ? 'alive (ws=open)' : 'unreachable (ws=' + data.ws + ')'}` +
681
+ ` pending: ${data.pendingCommands}` +
682
+ (data.lastSuccessMs !== null
683
+ ? ` last-success: ${formatMs(data.lastSuccessMs)} ago (${data.lastSuccessAction || '?'})`
684
+ : ' last-success: never')
685
+ );
686
+ return;
687
+
688
+ case 'fetch':
689
+ // Default: pretty JSON. If jsonpath was used, print the extracted value plainly.
690
+ if (data && typeof data === 'object' && 'jsonpath' in data) {
691
+ console.log(`[${data.status}] ${data.jsonpath} =`);
692
+ console.log(typeof data.value === 'object' ? JSON.stringify(data.value, null, 2) : data.value);
693
+ } else if (data && typeof data === 'object' && 'data' in data) {
694
+ console.log(`[${data.status}] (${data.contentType || 'unknown'})`);
695
+ console.log(typeof data.data === 'object' ? JSON.stringify(data.data, null, 2) : data.data);
696
+ } else {
697
+ console.log(JSON.stringify(data, null, 2));
698
+ }
699
+ return;
700
+
701
+ case 'submit':
702
+ console.log(`submitted: ${data.submitted}${data.action ? ` → ${data.action}` : ''}`);
703
+ return;
704
+
705
+ case 'wait':
706
+ // Extension returned a structured ack — print one line summary.
707
+ if (data && data.waited) {
708
+ console.log(`waited: ${data.waited}${data.elapsedMs != null ? ` (elapsed ${formatMs(data.elapsedMs)})` : ''}`);
709
+ return;
710
+ }
711
+ break;
712
+
713
+ case 'reset':
714
+ if (data && Array.isArray(data.cleared)) {
715
+ console.log(`cleared: ${data.cleared.join(', ') || '(nothing)'}`);
716
+ if (data.errors?.length) {
717
+ for (const e of data.errors) process.stderr.write(` ⚠ ${e}\n`);
718
+ process.exit(1);
719
+ }
720
+ return;
721
+ }
722
+ break;
723
+
724
+ case 'set':
725
+ if (data && data.set) {
726
+ const note = data.verified === false
727
+ ? ' ⚠ cookie was not accepted (different domain / SameSite / Secure?)'
728
+ : data.length != null ? ` (${data.length} bytes)` : '';
729
+ console.log(`set ${data.set}: ${data.name}${note}`);
730
+ if (data.verified === false) process.exit(1);
731
+ return;
732
+ }
733
+ break;
734
+
735
+ case 'status':
736
+ if (data && data.url != null) {
737
+ console.log(`URL: ${data.url}`);
738
+ console.log(`Title: ${data.title} [${data.readyState}]`);
739
+ if (data.lastConsoleError) {
740
+ console.log(`Last console error: ${data.lastConsoleError.text} (${data.lastConsoleError.ageSec}s ago)`);
741
+ }
742
+ if (data.lastFailedRequest) {
743
+ const r = data.lastFailedRequest;
744
+ console.log(`Last failed request: ${r.method} ${r.url} [${r.status}] (${r.ageSec}s ago)`);
745
+ }
746
+ if (Array.isArray(data.topActions) && data.topActions.length) {
747
+ console.log(`Top actions: ${data.topActions.map(a => `"${a}"`).join(' ')}`);
748
+ }
749
+ return;
750
+ }
751
+ break;
752
+
489
753
  case 'screenshot':
490
754
  if (data.image) {
491
755
  // data URL → save as PNG file, output path (for Read tool to view)
@@ -611,5 +875,13 @@ async function formatOutput(action, data) {
611
875
  }
612
876
  }
613
877
 
878
+ function formatMs(ms) {
879
+ if (ms < 1000) return `${ms}ms`;
880
+ const s = Math.round(ms / 1000);
881
+ if (s < 60) return `${s}s`;
882
+ const m = Math.floor(s / 60);
883
+ return `${m}m${s % 60}s`;
884
+ }
885
+
614
886
  // Export promise for external await
615
887
  export const done = run();
@@ -181,11 +181,17 @@ async function cmdSearch() {
181
181
  const pos = positional(subArgs);
182
182
  const q = pos[0];
183
183
  const limit = getInt(subArgs, '--limit') ?? 15;
184
+ // v1.0.216+: opt-in flag to also surface identifier-shaped string literals
185
+ // (tool names / event names / config keys / route paths) that only exist
186
+ // in source as strings, never as identifiers.
187
+ const includeLiterals = subArgs.includes('--include-literals');
184
188
  if (!q) {
185
189
  stderr.write('codegraph search: missing <query>\n');
186
190
  exit(2);
187
191
  }
188
- const resp = await get('/api/projectGraph/search', { q, limit });
192
+ const params = { q, limit };
193
+ if (includeLiterals) params.includeLiterals = 'true';
194
+ const resp = await get('/api/projectGraph/search', params);
189
195
  const data = await resp.json();
190
196
  if (wantJson) { emitJson(data); exit(0); }
191
197
 
@@ -616,15 +622,22 @@ Examples:
616
622
  // call (which runs before this declaration is reached in module order)
617
623
  // doesn't hit a const TDZ.
618
624
  function getSubHelp() { return {
619
- search: `Usage: cock codegraph search <query> [--limit N=15] [--json]
625
+ search: `Usage: cock codegraph search <query> [--limit N=15] [--include-literals] [--json]
620
626
 
621
627
  Purpose: Find symbols (and files) matching a name fragment. Tokenised
622
- match across name + qualifiedName + filePath. Use this when
623
- you know the symbol's name but not its location.
628
+ match across name + qualifiedName + filePath, with case +
629
+ word-separator folding (so 'build_code_index' 'buildCodeIndex'
630
+ ↔ 'BUILD-CODE-INDEX' all collide). Use this when you know the
631
+ symbol's name but not its location.
624
632
 
625
633
  Flags:
626
- --limit N Max symbol hits to return (default 15)
627
- --json Emit raw JSON {files: [...], symbols: [...]}
634
+ --limit N Max symbol hits to return (default 15)
635
+ --include-literals Also search identifier-shaped string literals
636
+ in source (tool names / event names / config
637
+ keys / route paths). Off by default to keep
638
+ results lean. Surfaces names that never appear
639
+ as identifiers, only as strings.
640
+ --json Emit raw JSON {files: [...], symbols: [...]}
628
641
 
629
642
  Output (plain, TAB-separated):
630
643
  sym <TAB> <file>:<line> <TAB> <kind> <TAB> <qname>
@@ -636,7 +649,9 @@ Exit: 0=hits, 1=no hits, 2=usage, 3=server unreachable
636
649
 
637
650
  Examples:
638
651
  cock codegraph search getCodeIndex
652
+ cock codegraph search build_code_index # snake-case query, still finds buildCodeIndex
639
653
  cock codegraph search useChatStore --limit 5
654
+ cock codegraph search 'agent.fork' --include-literals # find a name hiding in a literal
640
655
  cock codegraph search authenticate --json | jq '.symbols[].target'`,
641
656
 
642
657
  callers: `Usage: cock codegraph callers <qname> [--file PATH] [--json]
@@ -0,0 +1,151 @@
1
+ #!/usr/bin/env node
2
+
3
+ /**
4
+ * cockpit connection — cross-type bubble enumeration.
5
+ *
6
+ * One subcommand:
7
+ *
8
+ * cockpit connection list [--cwd PATH] [--all] [--json]
9
+ *
10
+ * Returns a unified listing of terminal + browser bubbles, each with the
11
+ * user-set `title` (if any). Designed for /cc slash mode and ad-hoc
12
+ * "what bubbles exist in this project" lookups before driving them via
13
+ * `cockpit terminal <id> ...` / `cockpit browser <id> ...`.
14
+ *
15
+ * By default only alive bubbles (running terminal / connected browser)
16
+ * are returned. Pass `--all` to include stale entries.
17
+ */
18
+ import { readFileSync } from 'fs';
19
+ import { join, resolve } from 'path';
20
+ import { homedir } from 'os';
21
+
22
+ const argv = process.argv.slice(2);
23
+
24
+ function readServerPort() {
25
+ try {
26
+ return JSON.parse(readFileSync(join(homedir(), '.cockpit', 'server.json'), 'utf8')).port;
27
+ } catch {
28
+ return null;
29
+ }
30
+ }
31
+
32
+ const port = process.env.COCKPIT_PORT || readServerPort() || 3457;
33
+ const baseUrl = `http://localhost:${port}`;
34
+
35
+ const { stdout, stderr, exit } = process;
36
+
37
+ function printHelp() {
38
+ stdout.write(`Usage: cock connection list [--cwd PATH] [--all] [--json]
39
+
40
+ Purpose: Enumerate all bubbles (terminal + browser) across the running
41
+ Cockpit server. Each entry carries the user-set title if any
42
+ (set via the ✎ button next to the bubble's short id), so an
43
+ LLM can map cryptic 4-char ids to human-meaningful purposes.
44
+
45
+ Subcommands:
46
+ list List bubbles (the only subcommand)
47
+
48
+ Flags:
49
+ --cwd PATH Only list bubbles whose project cwd matches PATH
50
+ (canonicalised). Use \$PWD to scope to the
51
+ current shell.
52
+ --all Include dead entries (exited terminals,
53
+ disconnected browsers). Off by default.
54
+ --json Emit raw JSON instead of TAB-separated lines.
55
+
56
+ Output (plain, TAB-separated):
57
+ <type> <TAB> <shortId> <TAB> <title-or-(none)> <TAB> <projectCwd-or-?> <TAB> <command-or-empty>
58
+
59
+ JSON: array of { type, shortId, title?, projectCwd?, tabId?, command?, alive }
60
+
61
+ Exit: 0=hits, 1=no bubbles, 2=usage, 3=server unreachable
62
+
63
+ Examples:
64
+ cockpit connection list # all alive bubbles
65
+ cockpit connection list --cwd \$PWD # this project only
66
+ cockpit connection list --all --json | jq # everything, programmatic
67
+ `);
68
+ }
69
+
70
+ if (argv.length === 0 || argv.includes('--help') || argv.includes('-h')) {
71
+ printHelp();
72
+ exit(0);
73
+ }
74
+
75
+ const sub = argv[0];
76
+ if (sub !== 'list') {
77
+ stderr.write(`Unknown subcommand: ${sub}\n`);
78
+ stderr.write(`Run \`cockpit connection --help\` to see usage.\n`);
79
+ exit(2);
80
+ }
81
+
82
+ // Parse list-subcommand flags.
83
+ const rest = argv.slice(1);
84
+ const flags = { cwd: undefined, all: false, json: false };
85
+ for (let i = 0; i < rest.length; i++) {
86
+ const tok = rest[i];
87
+ if (tok === '--all') flags.all = true;
88
+ else if (tok === '--json') flags.json = true;
89
+ else if (tok === '--cwd') {
90
+ flags.cwd = rest[++i];
91
+ if (!flags.cwd) {
92
+ stderr.write('Missing value for --cwd\n');
93
+ exit(2);
94
+ }
95
+ } else if (tok === '--help' || tok === '-h') {
96
+ printHelp();
97
+ exit(0);
98
+ } else {
99
+ stderr.write(`Unknown flag: ${tok}\n`);
100
+ exit(2);
101
+ }
102
+ }
103
+
104
+ // Hit the server.
105
+ let data;
106
+ try {
107
+ const body = {};
108
+ if (flags.cwd) body.cwd = resolve(flags.cwd);
109
+ if (flags.all) body.all = true;
110
+ const res = await fetch(`${baseUrl}/api/connection/list`, {
111
+ method: 'POST',
112
+ headers: { 'Content-Type': 'application/json' },
113
+ body: JSON.stringify(body),
114
+ signal: AbortSignal.timeout(10_000),
115
+ });
116
+ const j = await res.json().catch(() => null);
117
+ if (!res.ok || !j?.ok) {
118
+ stderr.write(`HTTP ${res.status}: ${j?.error || 'unknown error'}\n`);
119
+ exit(3);
120
+ }
121
+ data = j.data;
122
+ } catch (err) {
123
+ if (err?.cause?.code === 'ECONNREFUSED') {
124
+ stderr.write(`Connection refused: Cockpit server not running at ${baseUrl}\n`);
125
+ } else {
126
+ stderr.write(`Error: ${err.message}\n`);
127
+ }
128
+ exit(3);
129
+ }
130
+
131
+ if (!Array.isArray(data) || data.length === 0) {
132
+ // Empty list — exit 1 so callers can short-circuit cleanly.
133
+ exit(1);
134
+ }
135
+
136
+ if (flags.json) {
137
+ stdout.write(JSON.stringify({ connections: data }, null, 2) + '\n');
138
+ exit(0);
139
+ }
140
+
141
+ // Plain text: TAB-separated, one line per bubble.
142
+ // <type> <shortId> <title> <projectCwd> <command-or-empty>
143
+ const TYPE_LABEL = { terminal: 'term', browser: 'brow' };
144
+ for (const c of data) {
145
+ const type = TYPE_LABEL[c.type] || c.type;
146
+ const title = c.title || '(none)';
147
+ const cwd = c.projectCwd || '?';
148
+ const command = (c.command || '').replace(/\s+/g, ' ').slice(0, 120);
149
+ stdout.write(`${type}\t${c.shortId}\t${title}\t${cwd}\t${command}\n`);
150
+ }
151
+ exit(0);
package/bin/cock.mjs CHANGED
@@ -22,6 +22,7 @@ Commands:
22
22
  browser <id> <action> Control browser bubbles
23
23
  terminal <id> <action> Control terminal bubbles
24
24
  codegraph <subcmd> [...] Query the project code graph (search/risk/affected/...)
25
+ connection list [--cwd …] List all bubbles (term + browser) with user-set titles
25
26
  update Update to latest version
26
27
 
27
28
  Options:
@@ -101,6 +102,16 @@ if (process.argv[2] === 'codegraph') {
101
102
  await flushAndExit(0);
102
103
  }
103
104
 
105
+ if (process.argv[2] === 'connection') {
106
+ process.argv.splice(2, 1);
107
+ // cock-connection.mjs handles flow + exit itself (single subcmd, simple).
108
+ await import('./cock-connection.mjs');
109
+ // import() resolves when the module finishes top-level await; that script
110
+ // already calls process.exit() on its own paths, so this fallback only
111
+ // fires for the 0-exit happy path that finished normally.
112
+ await flushAndExit(0);
113
+ }
114
+
104
115
  if (process.argv[2] === 'update') {
105
116
  console.log('Updating @surething/cockpit...');
106
117
  const result = spawnSync('npm', ['install', '-g', '@surething/cockpit@latest'], { stdio: 'inherit' });
@@ -118,7 +129,7 @@ if (process.argv[2] === 'update') {
118
129
  const { existsSync, mkdirSync } = await import('fs');
119
130
  const { homedir } = await import('os');
120
131
 
121
- const knownCommands = new Set(['browser', 'terminal', 'update', 'help', 'codegraph']);
132
+ const knownCommands = new Set(['browser', 'terminal', 'update', 'help', 'codegraph', 'connection']);
122
133
  const arg = process.argv[2];
123
134
  let projectDir = null;
124
135