@thinkrun/cli 0.1.27

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 (183) hide show
  1. package/README.md +349 -0
  2. package/dist/bin/thinkrun.d.ts +6 -0
  3. package/dist/bin/thinkrun.d.ts.map +1 -0
  4. package/dist/bin/thinkrun.js +124 -0
  5. package/dist/bin/thinkrun.js.map +1 -0
  6. package/dist/scripts/browse.sh +1107 -0
  7. package/dist/src/adapters/cloud.d.ts +79 -0
  8. package/dist/src/adapters/cloud.d.ts.map +1 -0
  9. package/dist/src/adapters/cloud.js +637 -0
  10. package/dist/src/adapters/cloud.js.map +1 -0
  11. package/dist/src/adapters/index.d.ts +47 -0
  12. package/dist/src/adapters/index.d.ts.map +1 -0
  13. package/dist/src/adapters/index.js +211 -0
  14. package/dist/src/adapters/index.js.map +1 -0
  15. package/dist/src/adapters/local-command-retry.d.ts +12 -0
  16. package/dist/src/adapters/local-command-retry.d.ts.map +1 -0
  17. package/dist/src/adapters/local-command-retry.js +224 -0
  18. package/dist/src/adapters/local-command-retry.js.map +1 -0
  19. package/dist/src/adapters/local.d.ts +136 -0
  20. package/dist/src/adapters/local.d.ts.map +1 -0
  21. package/dist/src/adapters/local.js +1273 -0
  22. package/dist/src/adapters/local.js.map +1 -0
  23. package/dist/src/adapters/types.d.ts +45 -0
  24. package/dist/src/adapters/types.d.ts.map +1 -0
  25. package/dist/src/adapters/types.js +6 -0
  26. package/dist/src/adapters/types.js.map +1 -0
  27. package/dist/src/commands/actions.d.ts +135 -0
  28. package/dist/src/commands/actions.d.ts.map +1 -0
  29. package/dist/src/commands/actions.js +2207 -0
  30. package/dist/src/commands/actions.js.map +1 -0
  31. package/dist/src/commands/agent-init.d.ts +16 -0
  32. package/dist/src/commands/agent-init.d.ts.map +1 -0
  33. package/dist/src/commands/agent-init.js +222 -0
  34. package/dist/src/commands/agent-init.js.map +1 -0
  35. package/dist/src/commands/analyze.d.ts +11 -0
  36. package/dist/src/commands/analyze.d.ts.map +1 -0
  37. package/dist/src/commands/analyze.js +238 -0
  38. package/dist/src/commands/analyze.js.map +1 -0
  39. package/dist/src/commands/cache.d.ts +6 -0
  40. package/dist/src/commands/cache.d.ts.map +1 -0
  41. package/dist/src/commands/cache.js +147 -0
  42. package/dist/src/commands/cache.js.map +1 -0
  43. package/dist/src/commands/cloud.d.ts +6 -0
  44. package/dist/src/commands/cloud.d.ts.map +1 -0
  45. package/dist/src/commands/cloud.js +332 -0
  46. package/dist/src/commands/cloud.js.map +1 -0
  47. package/dist/src/commands/config.d.ts +7 -0
  48. package/dist/src/commands/config.d.ts.map +1 -0
  49. package/dist/src/commands/config.js +208 -0
  50. package/dist/src/commands/config.js.map +1 -0
  51. package/dist/src/commands/doctor.d.ts +127 -0
  52. package/dist/src/commands/doctor.d.ts.map +1 -0
  53. package/dist/src/commands/doctor.js +684 -0
  54. package/dist/src/commands/doctor.js.map +1 -0
  55. package/dist/src/commands/evaluate-helpers.d.ts +6 -0
  56. package/dist/src/commands/evaluate-helpers.d.ts.map +1 -0
  57. package/dist/src/commands/evaluate-helpers.js +13 -0
  58. package/dist/src/commands/evaluate-helpers.js.map +1 -0
  59. package/dist/src/commands/install.d.ts +118 -0
  60. package/dist/src/commands/install.d.ts.map +1 -0
  61. package/dist/src/commands/install.js +975 -0
  62. package/dist/src/commands/install.js.map +1 -0
  63. package/dist/src/commands/release.d.ts +7 -0
  64. package/dist/src/commands/release.d.ts.map +1 -0
  65. package/dist/src/commands/release.js +123 -0
  66. package/dist/src/commands/release.js.map +1 -0
  67. package/dist/src/commands/reset-connection.d.ts +17 -0
  68. package/dist/src/commands/reset-connection.d.ts.map +1 -0
  69. package/dist/src/commands/reset-connection.js +141 -0
  70. package/dist/src/commands/reset-connection.js.map +1 -0
  71. package/dist/src/commands/session-debug.d.ts +23 -0
  72. package/dist/src/commands/session-debug.d.ts.map +1 -0
  73. package/dist/src/commands/session-debug.js +267 -0
  74. package/dist/src/commands/session-debug.js.map +1 -0
  75. package/dist/src/commands/setup.d.ts +53 -0
  76. package/dist/src/commands/setup.d.ts.map +1 -0
  77. package/dist/src/commands/setup.js +249 -0
  78. package/dist/src/commands/setup.js.map +1 -0
  79. package/dist/src/config/store.d.ts +39 -0
  80. package/dist/src/config/store.d.ts.map +1 -0
  81. package/dist/src/config/store.js +290 -0
  82. package/dist/src/config/store.js.map +1 -0
  83. package/dist/src/daemon/access.d.ts +53 -0
  84. package/dist/src/daemon/access.d.ts.map +1 -0
  85. package/dist/src/daemon/access.js +87 -0
  86. package/dist/src/daemon/access.js.map +1 -0
  87. package/dist/src/daemon/bridge-envelope.d.ts +96 -0
  88. package/dist/src/daemon/bridge-envelope.d.ts.map +1 -0
  89. package/dist/src/daemon/bridge-envelope.js +235 -0
  90. package/dist/src/daemon/bridge-envelope.js.map +1 -0
  91. package/dist/src/daemon/utils.d.ts +43 -0
  92. package/dist/src/daemon/utils.d.ts.map +1 -0
  93. package/dist/src/daemon/utils.js +134 -0
  94. package/dist/src/daemon/utils.js.map +1 -0
  95. package/dist/src/errors.d.ts +60 -0
  96. package/dist/src/errors.d.ts.map +1 -0
  97. package/dist/src/errors.js +87 -0
  98. package/dist/src/errors.js.map +1 -0
  99. package/dist/src/local-bridge-timing.d.ts +31 -0
  100. package/dist/src/local-bridge-timing.d.ts.map +1 -0
  101. package/dist/src/local-bridge-timing.js +41 -0
  102. package/dist/src/local-bridge-timing.js.map +1 -0
  103. package/dist/src/obstacle-recovery/classify-script.d.ts +16 -0
  104. package/dist/src/obstacle-recovery/classify-script.d.ts.map +1 -0
  105. package/dist/src/obstacle-recovery/classify-script.js +53 -0
  106. package/dist/src/obstacle-recovery/classify-script.js.map +1 -0
  107. package/dist/src/obstacle-recovery/obstacle-classifier.d.ts +21 -0
  108. package/dist/src/obstacle-recovery/obstacle-classifier.d.ts.map +1 -0
  109. package/dist/src/obstacle-recovery/obstacle-classifier.js +37 -0
  110. package/dist/src/obstacle-recovery/obstacle-classifier.js.map +1 -0
  111. package/dist/src/obstacle-recovery/state-fingerprint.d.ts +26 -0
  112. package/dist/src/obstacle-recovery/state-fingerprint.d.ts.map +1 -0
  113. package/dist/src/obstacle-recovery/state-fingerprint.js +85 -0
  114. package/dist/src/obstacle-recovery/state-fingerprint.js.map +1 -0
  115. package/dist/src/obstacle-recovery/types.d.ts +44 -0
  116. package/dist/src/obstacle-recovery/types.d.ts.map +1 -0
  117. package/dist/src/obstacle-recovery/types.js +16 -0
  118. package/dist/src/obstacle-recovery/types.js.map +1 -0
  119. package/dist/src/output/formatter.d.ts +55 -0
  120. package/dist/src/output/formatter.d.ts.map +1 -0
  121. package/dist/src/output/formatter.js +55 -0
  122. package/dist/src/output/formatter.js.map +1 -0
  123. package/dist/src/output/mode.d.ts +11 -0
  124. package/dist/src/output/mode.d.ts.map +1 -0
  125. package/dist/src/output/mode.js +16 -0
  126. package/dist/src/output/mode.js.map +1 -0
  127. package/dist/src/protected-flow/detector.d.ts +26 -0
  128. package/dist/src/protected-flow/detector.d.ts.map +1 -0
  129. package/dist/src/protected-flow/detector.js +75 -0
  130. package/dist/src/protected-flow/detector.js.map +1 -0
  131. package/dist/src/protected-flow/types.d.ts +24 -0
  132. package/dist/src/protected-flow/types.d.ts.map +1 -0
  133. package/dist/src/protected-flow/types.js +28 -0
  134. package/dist/src/protected-flow/types.js.map +1 -0
  135. package/dist/src/session/agent-identity.d.ts +65 -0
  136. package/dist/src/session/agent-identity.d.ts.map +1 -0
  137. package/dist/src/session/agent-identity.js +133 -0
  138. package/dist/src/session/agent-identity.js.map +1 -0
  139. package/dist/src/session/cli-session-sync.d.ts +72 -0
  140. package/dist/src/session/cli-session-sync.d.ts.map +1 -0
  141. package/dist/src/session/cli-session-sync.js +244 -0
  142. package/dist/src/session/cli-session-sync.js.map +1 -0
  143. package/dist/src/session/context.d.ts +24 -0
  144. package/dist/src/session/context.d.ts.map +1 -0
  145. package/dist/src/session/context.js +165 -0
  146. package/dist/src/session/context.js.map +1 -0
  147. package/dist/src/session/continuity.d.ts +33 -0
  148. package/dist/src/session/continuity.d.ts.map +1 -0
  149. package/dist/src/session/continuity.js +179 -0
  150. package/dist/src/session/continuity.js.map +1 -0
  151. package/dist/src/session/errors.d.ts +9 -0
  152. package/dist/src/session/errors.d.ts.map +1 -0
  153. package/dist/src/session/errors.js +31 -0
  154. package/dist/src/session/errors.js.map +1 -0
  155. package/dist/src/session/local-continuity.d.ts +16 -0
  156. package/dist/src/session/local-continuity.d.ts.map +1 -0
  157. package/dist/src/session/local-continuity.js +146 -0
  158. package/dist/src/session/local-continuity.js.map +1 -0
  159. package/dist/src/session/signal-handler.d.ts +24 -0
  160. package/dist/src/session/signal-handler.d.ts.map +1 -0
  161. package/dist/src/session/signal-handler.js +35 -0
  162. package/dist/src/session/signal-handler.js.map +1 -0
  163. package/dist/src/shared/local-recovery-policy.d.ts +40 -0
  164. package/dist/src/shared/local-recovery-policy.d.ts.map +1 -0
  165. package/dist/src/shared/local-recovery-policy.js +59 -0
  166. package/dist/src/shared/local-recovery-policy.js.map +1 -0
  167. package/dist/src/shared/recovery-state.d.ts +3 -0
  168. package/dist/src/shared/recovery-state.d.ts.map +1 -0
  169. package/dist/src/shared/recovery-state.js +9 -0
  170. package/dist/src/shared/recovery-state.js.map +1 -0
  171. package/dist/src/types.d.ts +131 -0
  172. package/dist/src/types.d.ts.map +1 -0
  173. package/dist/src/types.js +5 -0
  174. package/dist/src/types.js.map +1 -0
  175. package/dist/src/utils.d.ts +50 -0
  176. package/dist/src/utils.d.ts.map +1 -0
  177. package/dist/src/utils.js +147 -0
  178. package/dist/src/utils.js.map +1 -0
  179. package/dist/src/working-location.d.ts +107 -0
  180. package/dist/src/working-location.d.ts.map +1 -0
  181. package/dist/src/working-location.js +651 -0
  182. package/dist/src/working-location.js.map +1 -0
  183. package/package.json +65 -0
@@ -0,0 +1,2207 @@
1
+ /**
2
+ * Browser action commands
3
+ * Includes: navigate, click, type, scroll, wait, hover, select, screenshot, extract, evaluate,
4
+ * back, forward, press, fill, snapshot, wait-for-text, dialog, console, network
5
+ */
6
+ import { Command, Option } from 'commander';
7
+ import chalk from 'chalk';
8
+ import { writeFileSync, copyFileSync, unlinkSync } from 'fs';
9
+ import { getAdapter, getResolutionInfo } from '../adapters/index.js';
10
+ import { ApiError } from '../errors.js';
11
+ import { handleError, readBridgePort, BRIDGE_HEALTH_PROBE_TIMEOUT_MS, BRIDGE_REQUEST_TIMEOUT_MS } from '../utils.js';
12
+ import { isJsonMode } from '../output/mode.js';
13
+ import { emitJson, createSpinner } from '../output/formatter.js';
14
+ import { clearLocalSessionContext, getCloudSessionContext, getLocalSessionContext, setLocalSessionContext, } from '../session/context.js';
15
+ import { isBlockedOutcome } from '../protected-flow/types.js';
16
+ import { setWorkingLocation, releaseWorkingLocation, getLockedBy } from '../working-location.js';
17
+ import { initCliSession, closeCliSession } from '../session/cli-session-sync.js';
18
+ import { resolveAgentId } from '../session/agent-identity.js';
19
+ import { clampEvaluateTimeoutMs } from './evaluate-helpers.js';
20
+ import { getNativeCodeHint } from '../adapters/local.js';
21
+ function withModeOption(command) {
22
+ return command.addOption(new Option('--mode <mode>', 'Execution mode override: local|cloud').choices(['local', 'cloud']));
23
+ }
24
+ /** Add --tab option for tab-scoped commands (bypasses working-location.json). */
25
+ function withTabOption(command) {
26
+ return command.addOption(new Option('-t, --tab <tabId>', 'Target a specific Chromium-based browser tab (bypasses working location)'));
27
+ }
28
+ /** Add both --mode and --tab options. */
29
+ function withActionOptions(command) {
30
+ return withTabOption(withModeOption(command));
31
+ }
32
+ /** Validate tab ID is a positive integer. Returns the normalized numeric string. */
33
+ function validateTabId(value, source) {
34
+ const parsed = parseInt(value, 10);
35
+ if (isNaN(parsed) || parsed <= 0 || String(parsed) !== value.trim()) {
36
+ throw new Error(`Invalid tab ID "${value}" from ${source}. Must be a positive integer. Run: thinkrun tabs`);
37
+ }
38
+ return String(parsed);
39
+ }
40
+ /** Resolve tab override from --tab flag or THINKRUN_TAB_ID env var. */
41
+ function resolveTabOverride(options) {
42
+ let tabId;
43
+ if (options?.tab) {
44
+ tabId = validateTabId(options.tab, '--tab flag');
45
+ }
46
+ else {
47
+ const envTab = process.env.THINKRUN_TAB_ID;
48
+ if (envTab) {
49
+ try {
50
+ tabId = validateTabId(envTab, 'THINKRUN_TAB_ID env var');
51
+ }
52
+ catch {
53
+ process.stderr.write(`[thinkrun] Warning: THINKRUN_TAB_ID="${envTab}" is not a valid tab ID, ignoring\n`);
54
+ }
55
+ }
56
+ }
57
+ // Lock safety check: if tab is owned by a different live agent, block
58
+ if (tabId) {
59
+ const lock = getLockedBy(parseInt(tabId, 10));
60
+ if (lock) {
61
+ const currentAgentId = resolveAgentId();
62
+ const isSameAgent = lock.agentId && lock.agentId === currentAgentId;
63
+ const isSamePid = lock.pid === process.pid;
64
+ if (!isSameAgent && !isSamePid) {
65
+ const ownerPid = lock.ownerShellPid ?? lock.pid;
66
+ let ownerAlive = false;
67
+ try {
68
+ process.kill(ownerPid, 0);
69
+ ownerAlive = true;
70
+ }
71
+ catch (e) {
72
+ if (e.code === 'EPERM')
73
+ ownerAlive = true; // alive but no permission
74
+ // ESRCH = dead process, stale lock — proceed
75
+ }
76
+ if (ownerAlive) {
77
+ const ownerLabel = lock.agentId ? `agent ${lock.agentId}` : `PID ${ownerPid}`;
78
+ throw new Error(`Tab ${tabId} is owned by ${ownerLabel} (PID ${ownerPid}). ` +
79
+ `Use 'thinkrun attach ${tabId}' to take ownership, or wait for the other agent to release.`);
80
+ }
81
+ }
82
+ }
83
+ }
84
+ return tabId;
85
+ }
86
+ async function getCommandAdapter(options) {
87
+ return getAdapter(options?.mode, resolveTabOverride(options));
88
+ }
89
+ /** Handle blocked-outcome from click/type/fill: emit JSON or TTY message and exit(1). No-op if result is success or not a blocked outcome. */
90
+ function handleBlockedResult(result, command, startMs, spinner) {
91
+ if (result.success || !result.data || !isBlockedOutcome(result.data))
92
+ return;
93
+ const data = result.data;
94
+ if (isJsonMode()) {
95
+ emitJson({ success: false, command, durationMs: Date.now() - startMs, blocked: true, data });
96
+ process.exit(1);
97
+ }
98
+ spinner.fail(data.message);
99
+ if (data.handoffRequired) {
100
+ console.log(chalk.yellow(' Human input required. Complete the step manually, then resume.'));
101
+ }
102
+ process.exit(1);
103
+ }
104
+ /** For JSON output: include resolved mode and source when available. */
105
+ async function getModeMetadataForJson(preferredMode) {
106
+ const r = await getResolutionInfo(preferredMode);
107
+ return r.resolvedMode ? { mode: r.resolvedMode, modeSource: r.modeSource } : {};
108
+ }
109
+ async function unregisterLocalAgentSession(port, sessionId) {
110
+ if (!sessionId)
111
+ return;
112
+ const res = await fetch(`http://127.0.0.1:${port}/sessions/unregister`, {
113
+ method: 'DELETE',
114
+ headers: { 'x-session-id': sessionId },
115
+ signal: AbortSignal.timeout(3000),
116
+ });
117
+ if (!res.ok) {
118
+ const body = await res.text();
119
+ throw new Error(`Unregister local session failed: ${res.status} ${body || res.statusText}`);
120
+ }
121
+ }
122
+ function printLocalSessionReceipt(args) {
123
+ const { tabId, group, windowId, controlSessionId, refreshed } = args;
124
+ const controllerId = summarizeControllerId(resolveAgentId());
125
+ const controlSummary = summarizeControlSessionLabel(controlSessionId);
126
+ console.log(chalk.gray(`Session receipt: tab ${tabId}`
127
+ + (windowId !== undefined ? ` (window ${windowId})` : '')
128
+ + (refreshed ? ' [refreshed]' : '')));
129
+ console.log(chalk.gray(`Control lease: ${controlSummary}`));
130
+ console.log(chalk.gray(`Ownership: controller ${controllerId} holds local mutating control for tab ${tabId}.`
131
+ + ' Cross-agent takeover is not supported in v1; use new-window for isolated work.'));
132
+ console.log(chalk.gray('If local context drifts later, run: thinkrun session debug --json'));
133
+ if (group)
134
+ console.log(chalk.gray(`Group: ${group}`));
135
+ }
136
+ function summarizeControllerId(controllerId) {
137
+ if (!controllerId)
138
+ return 'unknown';
139
+ const compact = controllerId.replace(/[^a-zA-Z0-9]/g, '');
140
+ if (compact.length === 0)
141
+ return 'unknown';
142
+ if (compact.length <= 6)
143
+ return '<redacted>';
144
+ return compact.slice(-6);
145
+ }
146
+ function summarizeControlSessionLabel(controlSessionId) {
147
+ if (!controlSessionId)
148
+ return 'unavailable';
149
+ const compact = controlSessionId.replace(/[^a-zA-Z0-9]/g, '');
150
+ if (compact.length === 0)
151
+ return 'unavailable';
152
+ if (compact.length <= 6)
153
+ return 'lease-<redacted>';
154
+ return `lease-${compact.slice(-6)}`;
155
+ }
156
+ // Navigate
157
+ export function createNavigateCommand() {
158
+ return withActionOptions(new Command('navigate'))
159
+ .argument('<url>', 'URL to navigate to')
160
+ .option('--wait-until <condition>', 'Wait condition: load|domcontentloaded|networkidle', 'load')
161
+ .option('--timeout <ms>', 'Navigation timeout in milliseconds', '30000')
162
+ .option('--wait-for <selector>', 'Wait for selector after navigation')
163
+ .description('Navigate to a URL')
164
+ .action(async (url, options) => {
165
+ const startMs = Date.now();
166
+ const spinner = createSpinner(`Navigating to ${url}...`);
167
+ try {
168
+ const adapter = await getCommandAdapter(options);
169
+ // Prepend https:// if no protocol
170
+ if (!url.startsWith('http://') && !url.startsWith('https://')) {
171
+ url = 'https://' + url;
172
+ }
173
+ // Strip trailing slashes to prevent double-slash routes (e.g. //navigate)
174
+ url = url.replace(/\/+$/, '');
175
+ const result = await adapter.navigate(url, {
176
+ waitUntil: options.waitUntil,
177
+ timeout: parseInt(options.timeout),
178
+ waitFor: options.waitFor,
179
+ });
180
+ if (isJsonMode()) {
181
+ const modeMeta = await getModeMetadataForJson(options?.mode);
182
+ emitJson({ success: true, command: 'navigate', durationMs: Date.now() - startMs,
183
+ data: { url: result.data?.url || url, title: result.data?.title }, ...modeMeta });
184
+ return;
185
+ }
186
+ spinner.succeed('Navigation complete');
187
+ if (result.data?.title) {
188
+ console.log(' Title:', chalk.cyan(result.data.title));
189
+ }
190
+ console.log(' URL: ', chalk.cyan(result.data?.url || url));
191
+ }
192
+ catch (error) {
193
+ spinner.fail('Navigation failed');
194
+ handleError(error, 'navigate', startMs);
195
+ }
196
+ });
197
+ }
198
+ // Click
199
+ export function createClickCommand() {
200
+ return withActionOptions(new Command('click'))
201
+ .argument('<selector>', 'CSS selector of element to click')
202
+ .option('--button <type>', 'Mouse button: left|right|middle', 'left')
203
+ .option('--count <n>', 'Click count (for double-click)', '1')
204
+ .option('--delay <ms>', 'Delay between mouse down and up', '0')
205
+ .option('--timeout <ms>', 'Timeout waiting for selector', '15000')
206
+ .description('Click an element')
207
+ .action(async (selector, options) => {
208
+ const startMs = Date.now();
209
+ const spinner = createSpinner(`Clicking ${selector}...`);
210
+ try {
211
+ const adapter = await getCommandAdapter(options);
212
+ const result = await adapter.click(selector, {
213
+ button: options.button,
214
+ count: parseInt(options.count),
215
+ delay: parseInt(options.delay),
216
+ timeout: parseInt(options.timeout),
217
+ });
218
+ handleBlockedResult(result, 'click', startMs, spinner);
219
+ if (isJsonMode()) {
220
+ emitJson({ success: true, command: 'click', durationMs: Date.now() - startMs });
221
+ return;
222
+ }
223
+ spinner.succeed(`Clicked ${selector}`);
224
+ }
225
+ catch (error) {
226
+ spinner.fail('Click failed');
227
+ handleError(error, 'click', startMs);
228
+ }
229
+ });
230
+ }
231
+ // Type
232
+ export function createTypeCommand() {
233
+ return withActionOptions(new Command('type'))
234
+ .argument('<selector>', 'CSS selector of input element')
235
+ .argument('<text>', 'Text to type')
236
+ .option('--delay <ms>', 'Delay between keystrokes', '0')
237
+ .option('--clear', 'Clear field before typing')
238
+ .option('--mask', 'Mask text in output (for passwords)')
239
+ .description('Type text into an element')
240
+ .action(async (selector, text, options) => {
241
+ const startMs = Date.now();
242
+ const displayText = options.mask ? '***' : text;
243
+ const spinner = createSpinner(`Typing into ${selector}...`);
244
+ try {
245
+ const adapter = await getCommandAdapter(options);
246
+ const result = await adapter.type(selector, text, {
247
+ delay: parseInt(options.delay),
248
+ clear: options.clear,
249
+ mask: options.mask,
250
+ });
251
+ handleBlockedResult(result, 'type', startMs, spinner);
252
+ if (isJsonMode()) {
253
+ emitJson({ success: true, command: 'type', durationMs: Date.now() - startMs });
254
+ return;
255
+ }
256
+ spinner.succeed(`Typed "${displayText}" into ${selector}`);
257
+ }
258
+ catch (error) {
259
+ spinner.fail('Type failed');
260
+ handleError(error, 'type', startMs);
261
+ }
262
+ });
263
+ }
264
+ // Fill (clears field first, then sets value)
265
+ export function createFillCommand() {
266
+ return withActionOptions(new Command('fill'))
267
+ .argument('<selector>', 'CSS selector of input element')
268
+ .argument('<value>', 'Value to fill')
269
+ .description('Fill a form field (clears existing value first)')
270
+ .action(async (selector, value, options) => {
271
+ const startMs = Date.now();
272
+ const spinner = createSpinner(`Filling ${selector}...`);
273
+ try {
274
+ const adapter = await getCommandAdapter(options);
275
+ const result = await adapter.fill(selector, value);
276
+ handleBlockedResult(result, 'fill', startMs, spinner);
277
+ if (isJsonMode()) {
278
+ emitJson({ success: true, command: 'fill', durationMs: Date.now() - startMs });
279
+ return;
280
+ }
281
+ spinner.succeed(`Filled ${selector}`);
282
+ }
283
+ catch (error) {
284
+ spinner.fail('Fill failed');
285
+ handleError(error, 'fill', startMs);
286
+ }
287
+ });
288
+ }
289
+ // Scroll
290
+ export function createScrollCommand() {
291
+ return withActionOptions(new Command('scroll'))
292
+ .option('--down <px>', 'Scroll down by pixels')
293
+ .option('--up <px>', 'Scroll up by pixels')
294
+ .option('--to <selector>', 'Scroll element into view')
295
+ .option('--direction <dir>', 'Scroll direction: up or down (use with --amount)')
296
+ .option('--amount <px>', 'Pixels to scroll when using --direction (default: 500)')
297
+ .description('Scroll the page')
298
+ .action(async (options) => {
299
+ const startMs = Date.now();
300
+ const spinner = createSpinner('Scrolling...');
301
+ try {
302
+ const adapter = await getCommandAdapter(options);
303
+ let scrollOptions = {};
304
+ if (options.to) {
305
+ scrollOptions.selector = options.to;
306
+ }
307
+ else if (options.direction) {
308
+ if (options.direction !== 'up' && options.direction !== 'down') {
309
+ spinner.fail('--direction must be "up" or "down"');
310
+ process.exit(1);
311
+ }
312
+ const px = parseStrictIntPixels(String(options.amount ?? '500'));
313
+ if (px === null || px <= 0) {
314
+ spinner.fail('--amount must be a positive integer (pixels)');
315
+ process.exit(1);
316
+ }
317
+ scrollOptions.y = options.direction === 'up' ? -px : px;
318
+ }
319
+ else if (options.down) {
320
+ const n = parseStrictIntPixels(String(options.down));
321
+ if (n === null || n <= 0) {
322
+ spinner.fail('--down must be a positive integer (pixels)');
323
+ process.exit(1);
324
+ }
325
+ scrollOptions.y = n;
326
+ }
327
+ else if (options.up) {
328
+ const n = parseStrictIntPixels(String(options.up));
329
+ if (n === null || n <= 0) {
330
+ spinner.fail('--up must be a positive integer (pixels)');
331
+ process.exit(1);
332
+ }
333
+ scrollOptions.y = -n;
334
+ }
335
+ else {
336
+ spinner.fail('Specify --down <px>, --up <px>, --direction <up|down> [--amount <px>], or --to <selector>');
337
+ process.exit(1);
338
+ }
339
+ await scrollWithRetryAndFallback(adapter, scrollOptions);
340
+ if (isJsonMode()) {
341
+ emitJson({ success: true, command: 'scroll', durationMs: Date.now() - startMs });
342
+ return;
343
+ }
344
+ spinner.succeed('Scrolled');
345
+ }
346
+ catch (error) {
347
+ spinner.fail('Scroll failed');
348
+ handleError(error, 'scroll', startMs);
349
+ }
350
+ });
351
+ }
352
+ // Wait
353
+ export function createWaitCommand() {
354
+ return withActionOptions(new Command('wait'))
355
+ .argument('<condition>', 'Selector to wait for, or milliseconds to wait')
356
+ .option('--visible', 'Wait for element to be visible')
357
+ .option('--hidden', 'Wait for element to be hidden')
358
+ .option('--timeout <ms>', 'Timeout in milliseconds', '30000')
359
+ .description('Wait for element or time')
360
+ .action(async (condition, options) => {
361
+ const startMs = Date.now();
362
+ try {
363
+ const adapter = await getCommandAdapter(options);
364
+ // Check if condition is a number (milliseconds)
365
+ const ms = parseInt(condition);
366
+ if (!isNaN(ms)) {
367
+ const spinner = createSpinner(`Waiting ${ms}ms...`);
368
+ await adapter.wait(ms);
369
+ if (isJsonMode()) {
370
+ emitJson({ success: true, command: 'wait', durationMs: Date.now() - startMs });
371
+ return;
372
+ }
373
+ spinner.succeed(`Waited ${ms}ms`);
374
+ return;
375
+ }
376
+ // Otherwise it's a selector
377
+ const action = options.hidden ? 'hide' : 'appear';
378
+ const spinner = createSpinner(`Waiting for ${condition} to ${action}...`);
379
+ await adapter.wait(condition, {
380
+ visible: !options.hidden,
381
+ hidden: options.hidden,
382
+ timeout: parseInt(options.timeout),
383
+ });
384
+ if (isJsonMode()) {
385
+ emitJson({ success: true, command: 'wait', durationMs: Date.now() - startMs });
386
+ return;
387
+ }
388
+ spinner.succeed(`Element ${condition} ${action}ed`);
389
+ }
390
+ catch (error) {
391
+ handleError(error, 'wait', startMs);
392
+ }
393
+ });
394
+ }
395
+ // Wait for text
396
+ export function createWaitForTextCommand() {
397
+ return withActionOptions(new Command('wait-for-text'))
398
+ .argument('<text>', 'Text to wait for on the page')
399
+ .option('--timeout <ms>', 'Timeout in milliseconds', '30000')
400
+ .description('Wait for text to appear on the page')
401
+ .action(async (text, options) => {
402
+ const startMs = Date.now();
403
+ const spinner = createSpinner(`Waiting for "${text}"...`);
404
+ try {
405
+ const adapter = await getCommandAdapter(options);
406
+ await adapter.waitForText(text, {
407
+ timeout: parseInt(options.timeout),
408
+ });
409
+ if (isJsonMode()) {
410
+ emitJson({ success: true, command: 'wait-for-text', durationMs: Date.now() - startMs });
411
+ return;
412
+ }
413
+ spinner.succeed(`Text "${text}" found`);
414
+ }
415
+ catch (error) {
416
+ spinner.fail(`Text "${text}" not found`);
417
+ handleError(error, 'wait-for-text', startMs);
418
+ }
419
+ });
420
+ }
421
+ // Hover
422
+ export function createHoverCommand() {
423
+ return withActionOptions(new Command('hover'))
424
+ .argument('<selector>', 'CSS selector of element to hover')
425
+ .description('Hover over an element')
426
+ .action(async (selector, options) => {
427
+ const startMs = Date.now();
428
+ const spinner = createSpinner(`Hovering over ${selector}...`);
429
+ try {
430
+ const adapter = await getCommandAdapter(options);
431
+ await adapter.hover(selector);
432
+ if (isJsonMode()) {
433
+ emitJson({ success: true, command: 'hover', durationMs: Date.now() - startMs });
434
+ return;
435
+ }
436
+ spinner.succeed(`Hovered over ${selector}`);
437
+ }
438
+ catch (error) {
439
+ spinner.fail('Hover failed');
440
+ handleError(error, 'hover', startMs);
441
+ }
442
+ });
443
+ }
444
+ // Select
445
+ export function createSelectCommand() {
446
+ return withActionOptions(new Command('select'))
447
+ .argument('<selector>', 'CSS selector of select element')
448
+ .argument('<value>', 'Value to select')
449
+ .description('Select option in dropdown')
450
+ .action(async (selector, value, options) => {
451
+ const startMs = Date.now();
452
+ const spinner = createSpinner(`Selecting "${value}" in ${selector}...`);
453
+ try {
454
+ const adapter = await getCommandAdapter(options);
455
+ await adapter.select(selector, value);
456
+ if (isJsonMode()) {
457
+ emitJson({ success: true, command: 'select', durationMs: Date.now() - startMs });
458
+ return;
459
+ }
460
+ spinner.succeed(`Selected "${value}" in ${selector}`);
461
+ }
462
+ catch (error) {
463
+ spinner.fail('Select failed');
464
+ handleError(error, 'select', startMs);
465
+ }
466
+ });
467
+ }
468
+ // Press key
469
+ export function createPressCommand() {
470
+ return withActionOptions(new Command('press'))
471
+ .argument('<key>', 'Key to press (e.g. Enter, Tab, Escape, ArrowDown)')
472
+ .description('Press a keyboard key')
473
+ .action(async (key, options) => {
474
+ const startMs = Date.now();
475
+ const spinner = createSpinner(`Pressing ${key}...`);
476
+ try {
477
+ const adapter = await getCommandAdapter(options);
478
+ await adapter.press(key);
479
+ if (isJsonMode()) {
480
+ emitJson({ success: true, command: 'press', durationMs: Date.now() - startMs });
481
+ return;
482
+ }
483
+ spinner.succeed(`Pressed ${key}`);
484
+ }
485
+ catch (error) {
486
+ spinner.fail('Press failed');
487
+ handleError(error, 'press', startMs);
488
+ }
489
+ });
490
+ }
491
+ // Back
492
+ export function createBackCommand() {
493
+ return withActionOptions(new Command('back'))
494
+ .description('Navigate back in browser history')
495
+ .action(async (options) => {
496
+ const startMs = Date.now();
497
+ const spinner = createSpinner('Going back...');
498
+ try {
499
+ const adapter = await getCommandAdapter(options);
500
+ await adapter.goBack();
501
+ if (isJsonMode()) {
502
+ emitJson({ success: true, command: 'back', durationMs: Date.now() - startMs });
503
+ return;
504
+ }
505
+ spinner.succeed('Navigated back');
506
+ }
507
+ catch (error) {
508
+ spinner.fail('Back failed');
509
+ handleError(error, 'back', startMs);
510
+ }
511
+ });
512
+ }
513
+ // Forward
514
+ export function createForwardCommand() {
515
+ return withActionOptions(new Command('forward'))
516
+ .description('Navigate forward in browser history')
517
+ .action(async (options) => {
518
+ const startMs = Date.now();
519
+ const spinner = createSpinner('Going forward...');
520
+ try {
521
+ const adapter = await getCommandAdapter(options);
522
+ await adapter.goForward();
523
+ if (isJsonMode()) {
524
+ emitJson({ success: true, command: 'forward', durationMs: Date.now() - startMs });
525
+ return;
526
+ }
527
+ spinner.succeed('Navigated forward');
528
+ }
529
+ catch (error) {
530
+ spinner.fail('Forward failed');
531
+ handleError(error, 'forward', startMs);
532
+ }
533
+ });
534
+ }
535
+ const RESUME_SNAPSHOT_TIMEOUT_MS = 30_000;
536
+ // Resume (re-read page state after handoff; equivalent to snapshot with handoff messaging)
537
+ export function createResumeCommand() {
538
+ return withActionOptions(new Command('resume'))
539
+ .description('Re-read page state after human handoff (captures current snapshot so automation can continue)')
540
+ .action(async (options) => {
541
+ const startMs = Date.now();
542
+ const spinner = createSpinner('Resuming (capturing current state)...');
543
+ try {
544
+ const adapter = await getCommandAdapter(options);
545
+ const result = await Promise.race([
546
+ adapter.snapshot(),
547
+ new Promise((_, reject) => setTimeout(() => reject(new Error(`Resume timed out after ${RESUME_SNAPSHOT_TIMEOUT_MS}ms`)), RESUME_SNAPSHOT_TIMEOUT_MS)),
548
+ ]);
549
+ if (isJsonMode()) {
550
+ const modeMeta = await getModeMetadataForJson(options?.mode);
551
+ emitJson({
552
+ success: true,
553
+ command: 'resume',
554
+ durationMs: Date.now() - startMs,
555
+ data: { content: result.data, resumed: true },
556
+ ...modeMeta,
557
+ });
558
+ return;
559
+ }
560
+ spinner.succeed('Resumed. Current state captured.');
561
+ if (result.data != null) {
562
+ console.log(typeof result.data === 'string' ? result.data : JSON.stringify(result.data));
563
+ }
564
+ }
565
+ catch (error) {
566
+ spinner.fail('Resume failed');
567
+ handleError(error, 'resume', startMs);
568
+ }
569
+ });
570
+ }
571
+ // Snapshot (accessibility tree)
572
+ export function createSnapshotCommand() {
573
+ return withActionOptions(new Command('snapshot'))
574
+ .description('Get accessibility snapshot of the page')
575
+ .action(async (options) => {
576
+ const startMs = Date.now();
577
+ const spinner = createSpinner('Taking snapshot...');
578
+ try {
579
+ const adapter = await getCommandAdapter(options);
580
+ const result = await adapter.snapshot();
581
+ if (isJsonMode()) {
582
+ const modeMeta = await getModeMetadataForJson(options?.mode);
583
+ emitJson({ success: true, command: 'snapshot', durationMs: Date.now() - startMs, data: { content: result.data }, ...modeMeta });
584
+ return;
585
+ }
586
+ spinner.succeed('Snapshot captured');
587
+ console.log(result.data);
588
+ }
589
+ catch (error) {
590
+ spinner.fail('Snapshot failed');
591
+ handleError(error, 'snapshot', startMs);
592
+ }
593
+ });
594
+ }
595
+ // Screenshot
596
+ export function createScreenshotCommand() {
597
+ return withActionOptions(new Command('screenshot'))
598
+ .option('--output <path>', 'Save screenshot to file')
599
+ .option('--full-page', 'Capture full scrollable page')
600
+ .option('--selector <selector>', 'Capture specific element')
601
+ .option('--caption <text>', 'Required when syncing local screenshots to a ThinkRun session')
602
+ .option('--format <type>', 'Image format: png|jpeg|webp', 'png')
603
+ .option('--quality <n>', 'JPEG/WebP quality (0-100)', '80')
604
+ .option('--max-dimension <px>', 'Resize so the largest dimension does not exceed this value (e.g. 1280)')
605
+ .description('Capture screenshot')
606
+ .action(async (options) => {
607
+ const startMs = Date.now();
608
+ const spinner = createSpinner('Capturing screenshot...');
609
+ try {
610
+ const adapter = await getCommandAdapter(options);
611
+ const result = await adapter.screenshot({
612
+ output: options.output,
613
+ fullPage: options.fullPage,
614
+ selector: options.selector,
615
+ format: options.format,
616
+ quality: parseInt(options.quality),
617
+ maxDimension: options.maxDimension ? parseInt(options.maxDimension) : undefined,
618
+ caption: options.caption,
619
+ });
620
+ if (result.data?.local) {
621
+ // Local mode — screenshot already saved to temp file by LocalAdapter
622
+ const savedPath = options.output || result.data.path;
623
+ if (options.output && result.data.path !== options.output) {
624
+ copyFileSync(result.data.path, options.output);
625
+ // Remove the temp file after copying to the requested output path to avoid
626
+ // leaking stale screenshots in /tmp (especially in agent loops).
627
+ try {
628
+ unlinkSync(result.data.path);
629
+ }
630
+ catch { /* ignore cleanup errors */ }
631
+ }
632
+ if (isJsonMode()) {
633
+ emitJson({ success: true, command: 'screenshot', durationMs: Date.now() - startMs,
634
+ data: { path: savedPath, url: result.data?.url } });
635
+ return;
636
+ }
637
+ spinner.succeed(`Screenshot saved to ${savedPath}`);
638
+ if (result.data?.url)
639
+ console.log(' URL:', chalk.cyan(result.data.url));
640
+ }
641
+ else if (options.output && result.data?.url) {
642
+ // Cloud mode — download from R2 to file
643
+ const response = await fetch(result.data.url);
644
+ const buffer = Buffer.from(await response.arrayBuffer());
645
+ writeFileSync(options.output, buffer);
646
+ if (isJsonMode()) {
647
+ emitJson({ success: true, command: 'screenshot', durationMs: Date.now() - startMs,
648
+ data: { path: options.output, url: result.data.url } });
649
+ return;
650
+ }
651
+ spinner.succeed(`Screenshot saved to ${options.output}`);
652
+ }
653
+ else {
654
+ if (isJsonMode()) {
655
+ emitJson({ success: true, command: 'screenshot', durationMs: Date.now() - startMs,
656
+ data: { url: result.data?.url, path: result.data?.path } });
657
+ return;
658
+ }
659
+ spinner.succeed('Screenshot captured');
660
+ if (result.data?.url)
661
+ console.log(' URL:', chalk.cyan(result.data.url));
662
+ if (result.data?.path)
663
+ console.log(' Saved:', chalk.cyan(result.data.path));
664
+ }
665
+ }
666
+ catch (error) {
667
+ spinner.fail('Screenshot failed');
668
+ handleError(error, 'screenshot', startMs);
669
+ }
670
+ });
671
+ }
672
+ // Extract
673
+ export function createExtractCommand() {
674
+ return withActionOptions(new Command('extract'))
675
+ .argument('<selector>', 'CSS selector of element(s) to extract')
676
+ .option('--all', 'Extract all matching elements')
677
+ .option('--attr <name>', 'Extract attribute instead of text')
678
+ .option('--format <type>', 'Output format: text|json|html', 'text')
679
+ .description('Extract content from elements')
680
+ .action(async (selector, options) => {
681
+ const startMs = Date.now();
682
+ const spinner = createSpinner(`Extracting from ${selector}...`);
683
+ try {
684
+ const adapter = await getCommandAdapter(options);
685
+ const result = await adapter.extract(selector, {
686
+ all: options.all,
687
+ attr: options.attr,
688
+ format: options.format,
689
+ });
690
+ if (isJsonMode()) {
691
+ emitJson({ success: true, command: 'extract', durationMs: Date.now() - startMs, data: result.data });
692
+ return;
693
+ }
694
+ spinner.succeed('Extracted');
695
+ if (options.format === 'json') {
696
+ console.log(JSON.stringify(result.data, null, 2));
697
+ }
698
+ else {
699
+ console.log(result.data);
700
+ }
701
+ }
702
+ catch (error) {
703
+ spinner.fail('Extract failed');
704
+ handleError(error, 'extract', startMs);
705
+ }
706
+ });
707
+ }
708
+ // Evaluate
709
+ export function createEvaluateCommand() {
710
+ return withActionOptions(new Command('evaluate'))
711
+ .argument('[script]', 'JavaScript to execute')
712
+ .option('--file <path>', 'Read script from file')
713
+ .option('--stdin', 'Read script from stdin (pipe or heredoc; mutually exclusive with --file and a script argument)')
714
+ .option('--timeout <ms>', 'Maximum time for evaluate in milliseconds (default: 30000)', '30000')
715
+ .description('Execute JavaScript in page context')
716
+ .action(async (script, options) => {
717
+ const startMs = Date.now();
718
+ try {
719
+ const adapter = await getCommandAdapter(options);
720
+ if (options.file && options.stdin) {
721
+ console.error(chalk.red('Error:'), 'Use either --file or --stdin, not both');
722
+ process.exit(1);
723
+ }
724
+ if (options.stdin && script) {
725
+ console.error(chalk.red('Error:'), 'With --stdin, do not pass a script argument');
726
+ process.exit(1);
727
+ }
728
+ if (options.file) {
729
+ const { readFileSync } = await import('fs');
730
+ script = readFileSync(options.file, 'utf-8');
731
+ }
732
+ else if (options.stdin) {
733
+ const { readFileSync } = await import('fs');
734
+ const { isatty } = await import('tty');
735
+ if (isatty(0)) {
736
+ console.error(chalk.red('Error:'), '--stdin requires piped or redirected input (e.g. cat script.js | thinkrun evaluate --stdin)');
737
+ process.exit(1);
738
+ }
739
+ script = readFileSync(0, 'utf-8');
740
+ }
741
+ if (!script) {
742
+ console.error(chalk.red('Error:'), 'Provide script as argument, --file, or --stdin');
743
+ process.exit(1);
744
+ }
745
+ const timeoutMs = clampEvaluateTimeoutMs(options.timeout);
746
+ const spinner = createSpinner('Executing script...');
747
+ const result = await adapter.evaluate(script, [], { timeout: timeoutMs });
748
+ if (isJsonMode()) {
749
+ emitJson({ success: true, command: 'evaluate', durationMs: Date.now() - startMs, data: result.data });
750
+ return;
751
+ }
752
+ spinner.succeed('Script executed');
753
+ console.log(result.data);
754
+ }
755
+ catch (error) {
756
+ handleError(error, 'evaluate', startMs);
757
+ }
758
+ });
759
+ }
760
+ // Dialog
761
+ export function createDialogCommand() {
762
+ const dialogCmd = new Command('dialog')
763
+ .description('Handle browser dialogs (alert, confirm, prompt)');
764
+ dialogCmd.addCommand(withActionOptions(new Command('get'))
765
+ .description('Check for pending dialog')
766
+ .action(async (options) => {
767
+ const startMs = Date.now();
768
+ try {
769
+ const adapter = await getCommandAdapter(options);
770
+ const result = await adapter.getDialog();
771
+ if (isJsonMode()) {
772
+ emitJson({ success: true, command: 'dialog get', durationMs: Date.now() - startMs,
773
+ data: { dialog: result.dialog ?? null, history: result.history ?? [] } });
774
+ return;
775
+ }
776
+ if (result.dialog) {
777
+ console.log(chalk.bold('Dialog:'));
778
+ console.log(' Type: ', chalk.cyan(result.dialog.type));
779
+ console.log(' Message:', result.dialog.message);
780
+ if (result.dialog.defaultValue) {
781
+ console.log(' Default:', result.dialog.defaultValue);
782
+ }
783
+ }
784
+ else {
785
+ console.log(chalk.gray('No pending dialog'));
786
+ }
787
+ if (result.history && result.history.length > 0) {
788
+ console.log('');
789
+ console.log(chalk.bold(`Dialog History (${result.history.length}):`));
790
+ for (const entry of result.history) {
791
+ console.log(` ${chalk.gray(entry.timestamp)} ${entry.type}: "${entry.message}" -> ${entry.action}`);
792
+ }
793
+ }
794
+ }
795
+ catch (error) {
796
+ handleError(error, 'dialog get', startMs);
797
+ }
798
+ }));
799
+ dialogCmd.addCommand(withActionOptions(new Command('accept'))
800
+ .argument('[text]', 'Text for prompt dialogs')
801
+ .description('Accept the pending dialog')
802
+ .action(async (text, options) => {
803
+ const startMs = Date.now();
804
+ const spinner = createSpinner('Accepting dialog...');
805
+ try {
806
+ const adapter = await getCommandAdapter(options);
807
+ await adapter.handleDialog('accept', text);
808
+ if (isJsonMode()) {
809
+ emitJson({ success: true, command: 'dialog accept', durationMs: Date.now() - startMs });
810
+ return;
811
+ }
812
+ spinner.succeed('Dialog accepted');
813
+ }
814
+ catch (error) {
815
+ spinner.fail('Failed to accept dialog');
816
+ handleError(error, 'dialog accept', startMs);
817
+ }
818
+ }));
819
+ dialogCmd.addCommand(withActionOptions(new Command('dismiss'))
820
+ .description('Dismiss the pending dialog')
821
+ .action(async (options) => {
822
+ const startMs = Date.now();
823
+ const spinner = createSpinner('Dismissing dialog...');
824
+ try {
825
+ const adapter = await getCommandAdapter(options);
826
+ await adapter.handleDialog('dismiss');
827
+ if (isJsonMode()) {
828
+ emitJson({ success: true, command: 'dialog dismiss', durationMs: Date.now() - startMs });
829
+ return;
830
+ }
831
+ spinner.succeed('Dialog dismissed');
832
+ }
833
+ catch (error) {
834
+ spinner.fail('Failed to dismiss dialog');
835
+ handleError(error, 'dialog dismiss', startMs);
836
+ }
837
+ }));
838
+ return dialogCmd;
839
+ }
840
+ // Console
841
+ export function createConsoleCommand() {
842
+ return withActionOptions(new Command('console'))
843
+ .description('Get browser console messages')
844
+ .action(async (options) => {
845
+ const startMs = Date.now();
846
+ const spinner = createSpinner('Fetching console messages...');
847
+ try {
848
+ const adapter = await getCommandAdapter(options);
849
+ const result = await adapter.getConsoleMessages();
850
+ if (isJsonMode()) {
851
+ emitJson({ success: true, command: 'console', durationMs: Date.now() - startMs,
852
+ data: { logs: result.logs, count: result.count } });
853
+ return;
854
+ }
855
+ spinner.stop();
856
+ if (!result.logs || result.logs.length === 0) {
857
+ console.log(chalk.gray('No console messages'));
858
+ return;
859
+ }
860
+ console.log(chalk.bold(`Console Messages (${result.count}):`));
861
+ console.log('');
862
+ for (const log of result.logs) {
863
+ const levelColor = log.level === 'error' ? chalk.red
864
+ : log.level === 'warning' ? chalk.yellow
865
+ : log.level === 'info' ? chalk.blue
866
+ : chalk.gray;
867
+ console.log(` ${levelColor(`[${log.level}]`)} ${log.text}`);
868
+ }
869
+ }
870
+ catch (error) {
871
+ spinner.fail('Failed to fetch console messages');
872
+ handleError(error, 'console', startMs);
873
+ }
874
+ });
875
+ }
876
+ // Network
877
+ export function createNetworkCommand() {
878
+ return withActionOptions(new Command('network'))
879
+ .description('Get network requests')
880
+ .action(async (options) => {
881
+ const startMs = Date.now();
882
+ const spinner = createSpinner('Fetching network requests...');
883
+ try {
884
+ const adapter = await getCommandAdapter(options);
885
+ const result = await adapter.getNetworkRequests();
886
+ if (isJsonMode()) {
887
+ emitJson({ success: true, command: 'network', durationMs: Date.now() - startMs,
888
+ data: { requests: result.requests, count: result.count } });
889
+ return;
890
+ }
891
+ spinner.stop();
892
+ if (!result.requests || result.requests.length === 0) {
893
+ console.log(chalk.gray('No network requests'));
894
+ return;
895
+ }
896
+ console.log(chalk.bold(`Network Requests (${result.count}):`));
897
+ console.log('');
898
+ for (const req of result.requests) {
899
+ const statusColor = req.status && req.status < 300 ? chalk.green
900
+ : req.status && req.status < 400 ? chalk.cyan
901
+ : req.status && req.status < 500 ? chalk.yellow
902
+ : chalk.red;
903
+ const status = req.status ? statusColor(req.status.toString()) : chalk.gray('---');
904
+ const method = chalk.bold(req.method.padEnd(6));
905
+ const size = req.size ? chalk.gray(`${(req.size / 1024).toFixed(1)}KB`) : '';
906
+ console.log(` ${status} ${method} ${req.url} ${size}`);
907
+ }
908
+ }
909
+ catch (error) {
910
+ spinner.fail('Failed to fetch network requests');
911
+ handleError(error, 'network', startMs);
912
+ }
913
+ });
914
+ }
915
+ /**
916
+ * Fetch the list of open Chrome tabs from the native host bridge.
917
+ * Accepts an optional fetchFn for testability.
918
+ *
919
+ * Route note: the bridge exposes GET /tabs (no /api prefix) for the tab list.
920
+ * The switch endpoint lives at POST /api/tabs/switch (with /api prefix).
921
+ * This asymmetry is intentional — the bridge inherits browse.sh flat routes
922
+ * for read operations and express-style /api routes for mutations.
923
+ */
924
+ export async function fetchTabsFromBridge(port, fetchFn = fetch) {
925
+ // Tab listing assumes the bridge is already up — not a cold-start probe (see BRIDGE_HEALTH_PROBE_TIMEOUT_MS).
926
+ const res = await fetchFn(`http://127.0.0.1:${port}/tabs`, {
927
+ signal: AbortSignal.timeout(BRIDGE_REQUEST_TIMEOUT_MS),
928
+ });
929
+ if (!res.ok)
930
+ return { ok: false, status: res.status };
931
+ const body = await res.json();
932
+ const tabs = body?.data?.tabs;
933
+ if (!Array.isArray(tabs))
934
+ return { ok: false, error: 'tabs field is not an array' };
935
+ return { ok: true, tabs: tabs };
936
+ }
937
+ export function formatNativeTabLine(tab, maxUrlLen = 60) {
938
+ const activeMarker = tab.active ? chalk.green('*') : ' ';
939
+ const id = chalk.cyan(String(tab.id).padEnd(6));
940
+ const rawUrl = tab.url ?? '';
941
+ const truncatedUrl = rawUrl.length > maxUrlLen
942
+ ? rawUrl.slice(0, maxUrlLen - 1) + '…'
943
+ : rawUrl;
944
+ const url = chalk.cyan(truncatedUrl);
945
+ const title = String(tab.title ?? '');
946
+ const owner = tab.ownerSessionId
947
+ ? chalk.gray(` [owned by ${tab.ownerSessionId}]`)
948
+ : '';
949
+ return ` ${activeMarker} ${id} ${url} ${title}${owner}`;
950
+ }
951
+ const SCROLL_FALLBACK_AFTER_EXCEPTIONS = 'scroll command fell back to evaluate after repeated bridge/API errors';
952
+ const SCROLL_FALLBACK_AFTER_FALSE = 'scroll command fell back to evaluate after scroll returned success:false repeatedly';
953
+ /** Integer pixels only — rejects partial strings like `200px` that parseInt would truncate. */
954
+ function parseStrictIntPixels(raw) {
955
+ const n = Number(raw);
956
+ if (!Number.isFinite(n) || !Number.isInteger(n))
957
+ return null;
958
+ return n;
959
+ }
960
+ /** True for transport-level bridge failures (used by scroll retry and attach/switch-tab/focus). */
961
+ export function isBridgeUnreachableError(error) {
962
+ if (typeof error === 'object' && error !== null && error.name === 'TimeoutError') {
963
+ return true;
964
+ }
965
+ const msg = error instanceof Error ? error.message : String(error);
966
+ return /ECONNREFUSED|fetch failed/i.test(msg);
967
+ }
968
+ function isRetryableScrollFailure(e) {
969
+ if (isBridgeUnreachableError(e))
970
+ return true;
971
+ if (e instanceof ApiError) {
972
+ if (e.retryable === true)
973
+ return true;
974
+ if (e.retryable === false)
975
+ return false;
976
+ const c = e.nativeCode ?? e.code;
977
+ if (c === 'NATIVE_HOST_UNREACHABLE')
978
+ return true;
979
+ if (c === 'API_ERROR') {
980
+ const s = e.statusCode ?? 0;
981
+ if (s === 408 || s === 429 || s >= 500)
982
+ return true;
983
+ }
984
+ return false;
985
+ }
986
+ return false;
987
+ }
988
+ /**
989
+ * Vertical page scroll with retries on transient bridge/API failures, then
990
+ * `evaluate(window.scrollBy)` fallback (0042-B). Selector-based scroll is unchanged.
991
+ */
992
+ export async function scrollWithRetryAndFallback(adapter, scrollOptions) {
993
+ if (scrollOptions.selector !== undefined) {
994
+ await adapter.scroll(scrollOptions);
995
+ return;
996
+ }
997
+ const y = scrollOptions.y;
998
+ if (y === undefined) {
999
+ await adapter.scroll(scrollOptions);
1000
+ return;
1001
+ }
1002
+ if (!Number.isFinite(y)) {
1003
+ throw new Error('scroll amount must be a number');
1004
+ }
1005
+ for (let attempt = 0; attempt < 3; attempt++) {
1006
+ try {
1007
+ const out = await adapter.scroll(scrollOptions);
1008
+ if (out.success !== false)
1009
+ return;
1010
+ if (attempt < 2) {
1011
+ await new Promise((r) => setTimeout(r, 500));
1012
+ continue;
1013
+ }
1014
+ console.error(chalk.yellow('Warning:'), SCROLL_FALLBACK_AFTER_FALSE);
1015
+ await adapter.evaluate('window.scrollBy(0, arguments[0])', [y]);
1016
+ return;
1017
+ }
1018
+ catch (e) {
1019
+ if (!isRetryableScrollFailure(e))
1020
+ throw e;
1021
+ if (attempt < 2) {
1022
+ await new Promise((r) => setTimeout(r, 500));
1023
+ continue;
1024
+ }
1025
+ console.error(chalk.yellow('Warning:'), SCROLL_FALLBACK_AFTER_EXCEPTIONS);
1026
+ await adapter.evaluate('window.scrollBy(0, arguments[0])', [y]);
1027
+ return;
1028
+ }
1029
+ }
1030
+ }
1031
+ function exitWithSwitchTabFailure(tabId, result, jsonCommand, startMs) {
1032
+ if (isJsonMode()) {
1033
+ emitJson({
1034
+ success: false,
1035
+ command: jsonCommand,
1036
+ durationMs: Date.now() - startMs,
1037
+ error: result.message || `HTTP ${result.status}`,
1038
+ code: result.code ?? 'SWITCH_TAB_FAILED',
1039
+ });
1040
+ process.exit(1);
1041
+ }
1042
+ if (result.code === 'TAB_NOT_FOUND') {
1043
+ const tabDesc = tabId != null ? `Tab ${tabId}` : 'Tab';
1044
+ console.error(chalk.red('Error:'), `${tabDesc} not found. Run \`thinkrun tabs\` to list visible tabs. Note: incognito tabs are not visible to the extension.`);
1045
+ process.exit(1);
1046
+ }
1047
+ if (result.code === 'EXTENSION_NOT_CONNECTED') {
1048
+ console.error(chalk.red('Error:'), 'Extension not connected. Open your Chromium-based browser with the ThinkRun extension and try again. Run `thinkrun doctor` to diagnose.');
1049
+ process.exit(1);
1050
+ }
1051
+ console.error(chalk.red('Error:'), result.message || `HTTP ${result.status}`, chalk.gray('Run `thinkrun doctor` if this persists.'));
1052
+ process.exit(1);
1053
+ }
1054
+ function exitWithBridgeUnreachable(command, startMs, error) {
1055
+ const msg = error instanceof Error ? error.message : String(error);
1056
+ if (isJsonMode()) {
1057
+ emitJson({
1058
+ success: false,
1059
+ command,
1060
+ durationMs: Date.now() - startMs,
1061
+ error: msg,
1062
+ code: 'BRIDGE_UNREACHABLE',
1063
+ });
1064
+ process.exit(1);
1065
+ }
1066
+ console.error(chalk.red('Error:'), 'Bridge not running. Run `thinkrun doctor` to diagnose.');
1067
+ process.exit(1);
1068
+ }
1069
+ /**
1070
+ * Switch the active Chrome tab via the native host bridge.
1071
+ * Accepts an optional fetchFn for testability.
1072
+ */
1073
+ export async function switchTabOnBridge(port, tabId, opts, fetchFn = fetch) {
1074
+ const headers = { 'Content-Type': 'application/json' };
1075
+ if (opts?.sessionId) {
1076
+ headers['x-session-id'] = opts.sessionId;
1077
+ }
1078
+ const res = await fetchFn(`http://127.0.0.1:${port}/api/tabs/switch`, {
1079
+ method: 'POST',
1080
+ headers,
1081
+ body: JSON.stringify({ tabId }),
1082
+ signal: AbortSignal.timeout(BRIDGE_REQUEST_TIMEOUT_MS),
1083
+ });
1084
+ const text = await res.text();
1085
+ let body = {};
1086
+ if (text) {
1087
+ try {
1088
+ const parsed = JSON.parse(text);
1089
+ if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
1090
+ body = parsed;
1091
+ }
1092
+ }
1093
+ catch {
1094
+ // non-JSON body
1095
+ }
1096
+ }
1097
+ if (!res.ok) {
1098
+ return {
1099
+ ok: false,
1100
+ status: res.status,
1101
+ code: typeof body.code === 'string' ? body.code : undefined,
1102
+ message: typeof body.error === 'string' ? body.error : undefined,
1103
+ };
1104
+ }
1105
+ return { ok: true };
1106
+ }
1107
+ export async function newTabOnBridge(port, url, fetchFn = fetch) {
1108
+ const res = await fetchFn(`http://127.0.0.1:${port}/api/tabs/new`, {
1109
+ method: 'POST',
1110
+ headers: { 'Content-Type': 'application/json' },
1111
+ body: JSON.stringify(url ? { url } : {}),
1112
+ signal: AbortSignal.timeout(BRIDGE_REQUEST_TIMEOUT_MS),
1113
+ });
1114
+ const text = await res.text();
1115
+ let body = {};
1116
+ if (text) {
1117
+ try {
1118
+ const parsed = JSON.parse(text);
1119
+ if (parsed && typeof parsed === 'object')
1120
+ body = parsed;
1121
+ }
1122
+ catch {
1123
+ // non-JSON body
1124
+ }
1125
+ }
1126
+ if (!res.ok) {
1127
+ return {
1128
+ ok: false,
1129
+ status: res.status,
1130
+ code: typeof body.code === 'string' ? body.code : undefined,
1131
+ message: typeof body.error === 'string' ? body.error : undefined,
1132
+ };
1133
+ }
1134
+ const data = body.data ?? {};
1135
+ const tabId = data.tabId;
1136
+ if (!Number.isFinite(tabId) || !tabId) {
1137
+ return { ok: false, status: 502, code: 'INVALID_RESPONSE', message: 'Native host returned no tabId for new tab' };
1138
+ }
1139
+ return {
1140
+ ok: true,
1141
+ tab: {
1142
+ id: tabId,
1143
+ url: typeof data.url === 'string' ? data.url : (url ?? 'about:blank'),
1144
+ title: typeof data.title === 'string' ? data.title : '',
1145
+ active: false,
1146
+ ...(typeof data.windowId === 'number' ? { windowId: data.windowId } : {}),
1147
+ },
1148
+ };
1149
+ }
1150
+ export async function closeTabOnBridge(port, tabId, opts, fetchFn = fetch) {
1151
+ const headers = { 'Content-Type': 'application/json' };
1152
+ if (opts?.sessionId) {
1153
+ headers['x-session-id'] = opts.sessionId;
1154
+ }
1155
+ const res = await fetchFn(`http://127.0.0.1:${port}/api/tabs/close`, {
1156
+ method: 'POST',
1157
+ headers,
1158
+ body: JSON.stringify({ tabId }),
1159
+ signal: AbortSignal.timeout(BRIDGE_REQUEST_TIMEOUT_MS),
1160
+ });
1161
+ const text = await res.text();
1162
+ let body = {};
1163
+ if (text) {
1164
+ try {
1165
+ const parsed = JSON.parse(text);
1166
+ if (parsed && typeof parsed === 'object')
1167
+ body = parsed;
1168
+ }
1169
+ catch {
1170
+ // non-JSON body
1171
+ }
1172
+ }
1173
+ if (!res.ok) {
1174
+ return {
1175
+ ok: false,
1176
+ status: res.status,
1177
+ code: typeof body.code === 'string' ? body.code : undefined,
1178
+ message: typeof body.error === 'string' ? body.error : undefined,
1179
+ };
1180
+ }
1181
+ return { ok: true };
1182
+ }
1183
+ /**
1184
+ * Register (or refresh) an agent session for a tab via POST /sessions/register.
1185
+ * When `sessionId` is omitted, the extension assigns a new session id.
1186
+ */
1187
+ export async function registerSessionOnBridge(port, tabId, opts, fetchFn = fetch) {
1188
+ const res = await fetchFn(`http://127.0.0.1:${port}/sessions/register`, {
1189
+ method: 'POST',
1190
+ headers: { 'Content-Type': 'application/json' },
1191
+ body: JSON.stringify({
1192
+ tabId,
1193
+ ...(opts?.sessionId ? { sessionId: opts.sessionId } : {}),
1194
+ }),
1195
+ signal: AbortSignal.timeout(BRIDGE_REQUEST_TIMEOUT_MS),
1196
+ });
1197
+ const text = await res.text();
1198
+ let body = {};
1199
+ if (text) {
1200
+ try {
1201
+ const parsed = JSON.parse(text);
1202
+ if (parsed && typeof parsed === 'object')
1203
+ body = parsed;
1204
+ }
1205
+ catch {
1206
+ // non-JSON
1207
+ }
1208
+ }
1209
+ if (!res.ok) {
1210
+ return {
1211
+ ok: false,
1212
+ status: res.status,
1213
+ code: typeof body.code === 'string' ? body.code : undefined,
1214
+ message: typeof body.error === 'string' ? body.error : undefined,
1215
+ };
1216
+ }
1217
+ const data = body.data ?? {};
1218
+ return {
1219
+ ok: true,
1220
+ sessionId: typeof data.sessionId === 'string' ? data.sessionId : undefined,
1221
+ windowId: typeof data.windowId === 'number' ? data.windowId : undefined,
1222
+ };
1223
+ }
1224
+ async function readLiveTabMetadata(port, tabId, fetchFn = fetch) {
1225
+ try {
1226
+ // Best-effort reconciliation only: if /tabs is unavailable or returns an
1227
+ // unexpected shape, fall back silently to the create/register response.
1228
+ const res = await fetchFn(`http://127.0.0.1:${port}/tabs`, {
1229
+ signal: AbortSignal.timeout(BRIDGE_HEALTH_PROBE_TIMEOUT_MS),
1230
+ });
1231
+ if (!res.ok)
1232
+ return {};
1233
+ const text = await res.text();
1234
+ if (!text)
1235
+ return {};
1236
+ const parsed = JSON.parse(text);
1237
+ const liveTab = parsed?.data?.tabs?.find((tab) => tab?.id === tabId);
1238
+ if (!liveTab)
1239
+ return {};
1240
+ return {
1241
+ ...(typeof liveTab.windowId === 'number' ? { windowId: liveTab.windowId } : {}),
1242
+ ...(typeof liveTab.url === 'string' ? { url: liveTab.url } : {}),
1243
+ ...(typeof liveTab.title === 'string' ? { title: liveTab.title } : {}),
1244
+ };
1245
+ }
1246
+ catch {
1247
+ return {};
1248
+ }
1249
+ }
1250
+ function exitWithRegisterFailure(reg, jsonCommand, startMs, spinner) {
1251
+ const hint = getNativeCodeHint(reg.code) ?? 'Run: thinkrun session debug --json';
1252
+ const msg = reg.message || `Session registration failed (HTTP ${reg.status})`;
1253
+ if (isJsonMode()) {
1254
+ emitJson({
1255
+ success: false,
1256
+ command: jsonCommand,
1257
+ durationMs: Date.now() - startMs,
1258
+ error: msg,
1259
+ code: reg.code ?? 'REGISTER_SESSION_FAILED',
1260
+ hint,
1261
+ retryable: false,
1262
+ });
1263
+ process.exit(1);
1264
+ }
1265
+ spinner.fail('Failed to attach to tab');
1266
+ console.error(chalk.red('Error:'), msg);
1267
+ console.error(chalk.yellow('Tip:'), hint);
1268
+ process.exit(1);
1269
+ }
1270
+ // Tabs — list open Chrome tabs via the native host
1271
+ export function createTabsCommand() {
1272
+ return new Command('tabs')
1273
+ .description('List open Chromium-based browser tabs via the native host (local mode)')
1274
+ .action(async () => {
1275
+ const startMs = Date.now();
1276
+ const spinner = createSpinner('Fetching tabs...');
1277
+ try {
1278
+ const port = readBridgePort();
1279
+ const result = await fetchTabsFromBridge(port);
1280
+ if (!result.ok) {
1281
+ const errMsg = 'status' in result
1282
+ ? `Failed to fetch tabs: HTTP ${result.status}`
1283
+ : `Failed to fetch tabs: ${result.error}`;
1284
+ spinner.fail(errMsg);
1285
+ if (isJsonMode()) {
1286
+ // Write structured error to stderr so agents reading stdout don't mix error
1287
+ // envelopes with success envelopes.
1288
+ process.stderr.write(JSON.stringify({ success: false, command: 'tabs', durationMs: Date.now() - startMs, error: errMsg, code: 'NATIVE_HOST_ERROR' }) + '\n');
1289
+ }
1290
+ process.exit(1);
1291
+ }
1292
+ if (isJsonMode()) {
1293
+ emitJson({ success: true, command: 'tabs', durationMs: Date.now() - startMs, data: { tabs: result.tabs } });
1294
+ return;
1295
+ }
1296
+ spinner.stop();
1297
+ const { tabs } = result;
1298
+ if (tabs.length === 0) {
1299
+ console.log(chalk.gray('No tabs found'));
1300
+ return;
1301
+ }
1302
+ const MAX_URL_LEN = 60;
1303
+ console.log(chalk.bold(`Tabs (${tabs.length}):`));
1304
+ console.log('');
1305
+ for (const tab of tabs) {
1306
+ console.log(formatNativeTabLine(tab, MAX_URL_LEN));
1307
+ }
1308
+ }
1309
+ catch (error) {
1310
+ spinner.fail('Failed to fetch tabs');
1311
+ handleError(error, 'tabs', startMs);
1312
+ }
1313
+ });
1314
+ }
1315
+ export function createNewTabCommand() {
1316
+ return withModeOption(new Command('new-tab'))
1317
+ .argument('[url]', 'Optional URL to open in the new tab')
1318
+ .description('Open a new tab in your Chromium-based browser (local mode). ' +
1319
+ 'Unlike new-window, this does not attach to or rebind the active local session.')
1320
+ .action(async (url, options) => {
1321
+ const startMs = Date.now();
1322
+ if (options?.mode === 'cloud') {
1323
+ console.error(chalk.red('Error:'), 'new-tab is local-only; do not use --mode cloud');
1324
+ process.exit(1);
1325
+ }
1326
+ const spinner = createSpinner('Opening new browser tab...');
1327
+ try {
1328
+ const port = readBridgePort();
1329
+ const result = await newTabOnBridge(port, url);
1330
+ if (!result.ok) {
1331
+ spinner.fail('Failed to open new tab');
1332
+ exitWithSwitchTabFailure(undefined, result, 'new-tab', startMs);
1333
+ }
1334
+ if (isJsonMode()) {
1335
+ emitJson({
1336
+ success: true,
1337
+ command: 'new-tab',
1338
+ durationMs: Date.now() - startMs,
1339
+ data: result.tab,
1340
+ });
1341
+ return;
1342
+ }
1343
+ spinner.succeed(`Opened tab ${result.tab.id}`);
1344
+ console.log(chalk.gray('Not attached — use `thinkrun attach <tabId>` if you want to bind this tab.'));
1345
+ console.log(chalk.gray(`URL: ${result.tab.url}`));
1346
+ }
1347
+ catch (error) {
1348
+ spinner.fail('Failed to open new tab');
1349
+ if (isBridgeUnreachableError(error)) {
1350
+ exitWithBridgeUnreachable('new-tab', startMs, error);
1351
+ }
1352
+ handleError(error, 'new-tab', startMs);
1353
+ }
1354
+ });
1355
+ }
1356
+ // URL — get the current page URL
1357
+ export function createUrlCommand() {
1358
+ return withActionOptions(new Command('url'))
1359
+ .description('Get the current page URL')
1360
+ .action(async (options) => {
1361
+ const startMs = Date.now();
1362
+ const spinner = createSpinner('Fetching page URL...');
1363
+ try {
1364
+ const adapter = await getCommandAdapter(options);
1365
+ const result = await adapter.getUrl();
1366
+ if (isJsonMode()) {
1367
+ emitJson({ success: true, command: 'url', durationMs: Date.now() - startMs, data: result.data });
1368
+ return;
1369
+ }
1370
+ spinner.succeed('URL retrieved');
1371
+ console.log(result.data);
1372
+ }
1373
+ catch (error) {
1374
+ spinner.fail('Failed to get URL');
1375
+ handleError(error, 'url', startMs);
1376
+ }
1377
+ });
1378
+ }
1379
+ // Title — get the current page title
1380
+ export function createTitleCommand() {
1381
+ return withActionOptions(new Command('title'))
1382
+ .description('Get the current page title')
1383
+ .action(async (options) => {
1384
+ const startMs = Date.now();
1385
+ const spinner = createSpinner('Fetching page title...');
1386
+ try {
1387
+ const adapter = await getCommandAdapter(options);
1388
+ const result = await adapter.getTitle();
1389
+ if (isJsonMode()) {
1390
+ emitJson({ success: true, command: 'title', durationMs: Date.now() - startMs, data: result.data });
1391
+ return;
1392
+ }
1393
+ spinner.succeed('Title retrieved');
1394
+ console.log(result.data);
1395
+ }
1396
+ catch (error) {
1397
+ spinner.fail('Failed to get title');
1398
+ handleError(error, 'title', startMs);
1399
+ }
1400
+ });
1401
+ }
1402
+ // HTML — get the full page HTML
1403
+ export function createHtmlCommand() {
1404
+ return withActionOptions(new Command('html'))
1405
+ .description('Get the full page HTML')
1406
+ .action(async (options) => {
1407
+ const startMs = Date.now();
1408
+ const spinner = createSpinner('Fetching page HTML...');
1409
+ try {
1410
+ const adapter = await getCommandAdapter(options);
1411
+ const result = await adapter.getHtml();
1412
+ const html = result.data;
1413
+ if (!html) {
1414
+ spinner.fail('No HTML returned');
1415
+ process.exit(1);
1416
+ }
1417
+ if (isJsonMode()) {
1418
+ emitJson({ success: true, command: 'html', durationMs: Date.now() - startMs, data: html });
1419
+ return;
1420
+ }
1421
+ if (process.stdout.isTTY) {
1422
+ spinner.succeed('HTML retrieved');
1423
+ }
1424
+ else {
1425
+ spinner.stop();
1426
+ }
1427
+ process.stdout.write(html + '\n');
1428
+ }
1429
+ catch (error) {
1430
+ spinner.fail('Failed to get HTML');
1431
+ handleError(error, 'html', startMs);
1432
+ }
1433
+ });
1434
+ }
1435
+ // Clear logs — clear console and network logs for the active session
1436
+ export function createClearLogsCommand() {
1437
+ return withActionOptions(new Command('clear-logs'))
1438
+ .description('Clear console and network logs for the active session')
1439
+ .action(async (options) => {
1440
+ const startMs = Date.now();
1441
+ const spinner = createSpinner('Clearing logs...');
1442
+ try {
1443
+ const adapter = await getCommandAdapter(options);
1444
+ await adapter.clearLogs();
1445
+ if (isJsonMode()) {
1446
+ emitJson({ success: true, command: 'clear-logs', durationMs: Date.now() - startMs });
1447
+ return;
1448
+ }
1449
+ spinner.succeed('Logs cleared');
1450
+ }
1451
+ catch (error) {
1452
+ spinner.fail('Failed to clear logs');
1453
+ handleError(error, 'clear-logs', startMs);
1454
+ }
1455
+ });
1456
+ }
1457
+ // Switch tab — switch the active Chrome tab in the browser (local mode only, does NOT update active session config)
1458
+ export function createFocusCommand() {
1459
+ return withModeOption(new Command('focus'))
1460
+ .description('Bring the Chromium-based browser window for the attached tab to the foreground (local mode). ' +
1461
+ 'Uses your current attach session tab — same as switch-tab with that id, without looking it up.')
1462
+ .action(async (options) => {
1463
+ const startMs = Date.now();
1464
+ if (options?.mode === 'cloud') {
1465
+ console.error(chalk.red('Error:'), 'focus is local-only. Pass --mode local, or use a local attach session (see thinkrun config --help).');
1466
+ process.exit(1);
1467
+ }
1468
+ let currentCtx;
1469
+ let tabIdStr;
1470
+ try {
1471
+ currentCtx = getLocalSessionContext();
1472
+ tabIdStr = currentCtx?.tabId;
1473
+ }
1474
+ catch {
1475
+ currentCtx = undefined;
1476
+ tabIdStr = undefined;
1477
+ }
1478
+ if (!tabIdStr) {
1479
+ console.error(chalk.red('Error:'), 'No active local tab. Run: thinkrun tabs\nThen: thinkrun attach <tabId>');
1480
+ process.exit(1);
1481
+ }
1482
+ const tabId = parseInt(tabIdStr, 10);
1483
+ if (isNaN(tabId) || tabId <= 0) {
1484
+ console.error(chalk.red('Error:'), 'Invalid tab id in session context');
1485
+ process.exit(1);
1486
+ }
1487
+ const spinner = createSpinner(`Focusing window for tab ${tabId}...`);
1488
+ try {
1489
+ const port = readBridgePort();
1490
+ const result = await switchTabOnBridge(port, tabId, {
1491
+ sessionId: currentCtx?.agentSessionId,
1492
+ });
1493
+ if (!result.ok) {
1494
+ spinner.fail('Failed to focus');
1495
+ exitWithSwitchTabFailure(tabId, result, 'focus', startMs);
1496
+ }
1497
+ if (isJsonMode()) {
1498
+ emitJson({ success: true, command: 'focus', durationMs: Date.now() - startMs, data: { tabId } });
1499
+ return;
1500
+ }
1501
+ spinner.succeed(`Focused window for tab ${tabId}`);
1502
+ }
1503
+ catch (error) {
1504
+ spinner.fail('Failed to focus');
1505
+ if (isBridgeUnreachableError(error)) {
1506
+ exitWithBridgeUnreachable('focus', startMs, error);
1507
+ }
1508
+ handleError(error, 'focus', startMs);
1509
+ }
1510
+ });
1511
+ }
1512
+ export function createSwitchTabCommand() {
1513
+ return new Command('switch-tab')
1514
+ .argument('<tabId>', 'Chromium-based browser tab ID to switch to')
1515
+ .description('Switch the active tab in your Chromium-based browser via the native host (local mode). Does not change active session config — use attach for that.')
1516
+ .action(async (tabIdArg) => {
1517
+ const startMs = Date.now();
1518
+ const tabId = parseInt(tabIdArg, 10);
1519
+ if (isNaN(tabId) || tabId <= 0) {
1520
+ console.error(chalk.red('Error:'), 'tabId must be a positive integer');
1521
+ process.exit(1);
1522
+ }
1523
+ const spinner = createSpinner(`Switching to tab ${tabId}...`);
1524
+ try {
1525
+ const port = readBridgePort();
1526
+ const result = await switchTabOnBridge(port, tabId);
1527
+ if (!result.ok) {
1528
+ spinner.fail('Failed to switch tab');
1529
+ exitWithSwitchTabFailure(tabId, result, 'switch-tab', startMs);
1530
+ }
1531
+ if (isJsonMode()) {
1532
+ emitJson({ success: true, command: 'switch-tab', durationMs: Date.now() - startMs, data: { tabId } });
1533
+ return;
1534
+ }
1535
+ spinner.succeed(`Switched to tab ${tabId}`);
1536
+ }
1537
+ catch (error) {
1538
+ spinner.fail('Failed to switch tab');
1539
+ if (isBridgeUnreachableError(error)) {
1540
+ exitWithBridgeUnreachable('switch-tab', startMs, error);
1541
+ }
1542
+ handleError(error, 'switch-tab', startMs);
1543
+ }
1544
+ });
1545
+ }
1546
+ // Attach — switch to a Chrome tab by ID and set it as the active session
1547
+ export function createAttachCommand() {
1548
+ return withModeOption(new Command('attach'))
1549
+ .argument('<tabId>', 'Chromium-based browser tab ID to attach to')
1550
+ .description('Attach to a tab in your Chromium-based browser (local mode) and set it as the active session.\n' +
1551
+ 'When an API key is configured, also registers a session in the cloud\n' +
1552
+ 'activity feed so screenshots and actions are visible in the dashboard.')
1553
+ .option('--group <name>', 'Associate this agent with a named group for isolation')
1554
+ .action(async (tabIdArg, options) => {
1555
+ const startMs = Date.now();
1556
+ if (options?.mode === 'cloud') {
1557
+ console.error(chalk.red('Error:'), 'attach is local-only; do not use --mode cloud');
1558
+ process.exit(1);
1559
+ }
1560
+ const tabId = parseInt(tabIdArg, 10);
1561
+ if (isNaN(tabId) || tabId <= 0) {
1562
+ console.error(chalk.red('Error:'), 'tabId must be a positive integer');
1563
+ process.exit(1);
1564
+ }
1565
+ const group = options?.group;
1566
+ const spinner = createSpinner(`Attaching to tab ${tabId}...`);
1567
+ try {
1568
+ const port = readBridgePort();
1569
+ const ctxBefore = getLocalSessionContext();
1570
+ const attachSessionId = ctxBefore?.tabId === String(tabId) && ctxBefore.agentSessionId
1571
+ ? ctxBefore.agentSessionId
1572
+ : undefined;
1573
+ const result = await switchTabOnBridge(port, tabId, attachSessionId ? { sessionId: attachSessionId } : undefined);
1574
+ if (!result.ok) {
1575
+ spinner.fail('Failed to attach to tab');
1576
+ exitWithSwitchTabFailure(tabId, result, 'attach', startMs);
1577
+ }
1578
+ // Idempotent re-attach: same tab + known session — refresh registry without unregistering first.
1579
+ if (ctxBefore?.tabId === String(tabId) && ctxBefore.agentSessionId) {
1580
+ const idem = await registerSessionOnBridge(port, tabId, { sessionId: ctxBefore.agentSessionId });
1581
+ if (idem.ok) {
1582
+ const liveTab = await readLiveTabMetadata(port, tabId);
1583
+ const windowIdIdem = liveTab.windowId ?? idem.windowId;
1584
+ const agentSessionIdIdem = idem.sessionId ?? ctxBefore.agentSessionId;
1585
+ setWorkingLocation({
1586
+ tabId,
1587
+ ...(windowIdIdem !== undefined ? { windowId: windowIdIdem } : {}),
1588
+ ...(group ? { group } : {}),
1589
+ });
1590
+ initCliSession(String(tabId)).catch(() => { });
1591
+ setLocalSessionContext(String(tabId), agentSessionIdIdem, windowIdIdem);
1592
+ const refreshedContext = getLocalSessionContext();
1593
+ if (isJsonMode()) {
1594
+ emitJson({
1595
+ success: true,
1596
+ command: 'attach',
1597
+ durationMs: Date.now() - startMs,
1598
+ data: {
1599
+ tabId,
1600
+ idempotent: true,
1601
+ ...(refreshedContext?.controlSessionId
1602
+ ? { controlSessionLabel: summarizeControlSessionLabel(refreshedContext.controlSessionId) }
1603
+ : {}),
1604
+ ...(group ? { group } : {}),
1605
+ },
1606
+ });
1607
+ return;
1608
+ }
1609
+ spinner.succeed(`Attached to tab ${tabId}`);
1610
+ printLocalSessionReceipt({
1611
+ tabId,
1612
+ windowId: windowIdIdem,
1613
+ group,
1614
+ controlSessionId: refreshedContext?.controlSessionId,
1615
+ refreshed: true,
1616
+ });
1617
+ return;
1618
+ }
1619
+ }
1620
+ const prevSessionId = getLocalSessionContext()?.agentSessionId;
1621
+ const reg = await registerSessionOnBridge(port, tabId);
1622
+ if (!reg.ok) {
1623
+ exitWithRegisterFailure(reg, 'attach', startMs, spinner);
1624
+ }
1625
+ const liveTab = await readLiveTabMetadata(port, tabId);
1626
+ let windowId = liveTab.windowId ?? reg.windowId;
1627
+ let agentSessionId = reg.sessionId;
1628
+ // Acquire per-process working location lock (throws TAB_LOCKED if contested).
1629
+ setWorkingLocation({ tabId, ...(windowId !== undefined ? { windowId } : {}), ...(group ? { group } : {}) });
1630
+ // Fire-and-forget: register CLI session in the cloud activity feed.
1631
+ // Must never block or throw to the caller — cloud sync is best-effort.
1632
+ initCliSession(String(tabId)).catch(() => { });
1633
+ // Lock acquired — safe to update session context now.
1634
+ setLocalSessionContext(String(tabId), agentSessionId, windowId);
1635
+ if (prevSessionId && prevSessionId !== agentSessionId) {
1636
+ try {
1637
+ await unregisterLocalAgentSession(port, prevSessionId);
1638
+ }
1639
+ catch (err) {
1640
+ process.stderr.write(`[thinkrun] warn: failed to unregister previous local session ${prevSessionId}: ${err}\n`);
1641
+ }
1642
+ }
1643
+ const localContext = getLocalSessionContext();
1644
+ if (isJsonMode()) {
1645
+ emitJson({
1646
+ success: true,
1647
+ command: 'attach',
1648
+ durationMs: Date.now() - startMs,
1649
+ data: {
1650
+ tabId,
1651
+ ...(localContext?.controlSessionId
1652
+ ? { controlSessionLabel: summarizeControlSessionLabel(localContext.controlSessionId) }
1653
+ : {}),
1654
+ ...(group ? { group } : {}),
1655
+ },
1656
+ });
1657
+ return;
1658
+ }
1659
+ spinner.succeed(`Attached to tab ${tabId}`);
1660
+ printLocalSessionReceipt({
1661
+ tabId,
1662
+ windowId,
1663
+ group,
1664
+ controlSessionId: localContext?.controlSessionId,
1665
+ });
1666
+ }
1667
+ catch (error) {
1668
+ const errMsg = error instanceof Error ? error.message : String(error);
1669
+ if (errMsg.startsWith('TAB_LOCKED')) {
1670
+ if (isJsonMode()) {
1671
+ emitJson({
1672
+ success: false,
1673
+ command: 'attach',
1674
+ durationMs: Date.now() - startMs,
1675
+ error: errMsg,
1676
+ code: 'TAB_LOCKED',
1677
+ });
1678
+ process.exit(1);
1679
+ }
1680
+ spinner.fail(errMsg);
1681
+ process.exit(1);
1682
+ }
1683
+ spinner.fail('Failed to attach to tab');
1684
+ if (isBridgeUnreachableError(error)) {
1685
+ exitWithBridgeUnreachable('attach', startMs, error);
1686
+ }
1687
+ handleError(error, 'attach', startMs);
1688
+ }
1689
+ });
1690
+ }
1691
+ // New-window — open a dedicated Chrome window for isolated agent testing
1692
+ export function createNewWindowCommand() {
1693
+ return withModeOption(new Command('new-window'))
1694
+ .argument('[url]', 'Optional URL to open in the new window', 'about:blank')
1695
+ .description('Open a new window in your Chromium-based browser and attach to it (local mode).\n' +
1696
+ 'Each window is fully isolated for screenshots — multiple agents can\n' +
1697
+ 'run simultaneously without interfering with each other.\n' +
1698
+ 'When an API key is configured, also registers a session in the cloud\n' +
1699
+ 'activity feed so screenshots and actions are visible in the dashboard.')
1700
+ .option('--no-focus', 'Open the window in the background without stealing focus')
1701
+ .option('--group <name>', 'Associate this agent with a named group for isolation')
1702
+ .action(async (url, opts) => {
1703
+ const startMs = Date.now();
1704
+ if (opts?.mode === 'cloud') {
1705
+ console.error(chalk.red('Error:'), 'new-window is local-only; do not use --mode cloud');
1706
+ process.exit(1);
1707
+ }
1708
+ const group = opts?.group;
1709
+ const spinner = createSpinner('Opening new browser window...');
1710
+ try {
1711
+ const port = readBridgePort();
1712
+ // Ask the extension to open a new Chrome window
1713
+ const res = await fetch(`http://127.0.0.1:${port}/api/sessions/new-window`, {
1714
+ method: 'POST',
1715
+ headers: { 'Content-Type': 'application/json' },
1716
+ body: JSON.stringify({ url, focused: opts.focus }),
1717
+ signal: AbortSignal.timeout(10000),
1718
+ });
1719
+ if (!res.ok) {
1720
+ const body = await res.json().catch(() => ({}));
1721
+ spinner.fail(`Failed to open window: ${body?.error ?? res.status}`);
1722
+ process.exit(1);
1723
+ }
1724
+ const data = (await res.json());
1725
+ const tabId = data?.data?.tabId;
1726
+ const reportedWindowId = data?.data?.windowId;
1727
+ if (!tabId) {
1728
+ spinner.fail('No tabId returned from native host');
1729
+ process.exit(1);
1730
+ }
1731
+ const previousLocalSessionId = getLocalSessionContext()?.agentSessionId;
1732
+ const reg = await registerSessionOnBridge(port, tabId);
1733
+ if (!reg.ok) {
1734
+ exitWithRegisterFailure(reg, 'new-window', startMs, spinner);
1735
+ }
1736
+ const liveTab = await readLiveTabMetadata(port, tabId);
1737
+ const windowId = liveTab.windowId ?? reg.windowId ?? reportedWindowId;
1738
+ const agentSessionId = reg.sessionId;
1739
+ if (!agentSessionId) {
1740
+ if (isJsonMode()) {
1741
+ emitJson({
1742
+ success: false,
1743
+ command: 'new-window',
1744
+ durationMs: Date.now() - startMs,
1745
+ error: 'Session registration succeeded but returned no sessionId',
1746
+ code: 'REGISTER_SESSION_FAILED',
1747
+ hint: 'Run: thinkrun attach <tabId> or thinkrun session debug --json',
1748
+ retryable: false,
1749
+ });
1750
+ process.exit(1);
1751
+ }
1752
+ spinner.fail('Failed to open new window');
1753
+ console.error(chalk.red('Error:'), 'Session registration succeeded but returned no sessionId');
1754
+ console.error(chalk.yellow('Tip:'), 'Run: thinkrun attach <tabId> or thinkrun session debug --json');
1755
+ process.exit(1);
1756
+ }
1757
+ // Acquire per-process working location lock BEFORE updating session context
1758
+ // so a TAB_LOCKED failure doesn't leave context pointing at a contested tab.
1759
+ setWorkingLocation({ tabId, ...(windowId !== undefined ? { windowId } : {}), ...(group ? { group } : {}) });
1760
+ // Fire-and-forget: register CLI session in the cloud activity feed.
1761
+ // Must never block or throw to the caller — cloud sync is best-effort.
1762
+ initCliSession(String(tabId)).catch(() => { });
1763
+ // Lock acquired — safe to update session context now.
1764
+ setLocalSessionContext(String(tabId), agentSessionId, windowId);
1765
+ if (previousLocalSessionId) {
1766
+ try {
1767
+ await unregisterLocalAgentSession(port, previousLocalSessionId);
1768
+ }
1769
+ catch (err) {
1770
+ process.stderr.write(`[thinkrun] warn: failed to unregister previous local session ${previousLocalSessionId}: ${err}\n`);
1771
+ }
1772
+ }
1773
+ const localContext = getLocalSessionContext();
1774
+ if (isJsonMode()) {
1775
+ emitJson({
1776
+ success: true,
1777
+ command: 'new-window',
1778
+ durationMs: Date.now() - startMs,
1779
+ data: {
1780
+ tabId,
1781
+ windowId,
1782
+ url,
1783
+ ...(localContext?.controlSessionId
1784
+ ? { controlSessionLabel: summarizeControlSessionLabel(localContext.controlSessionId) }
1785
+ : {}),
1786
+ ...(group ? { group } : {}),
1787
+ },
1788
+ });
1789
+ return;
1790
+ }
1791
+ spinner.succeed(`New window opened — attached to tab ${tabId} (window ${windowId})`);
1792
+ console.log(chalk.gray(`Screenshot isolation: each agent window captures independently`));
1793
+ printLocalSessionReceipt({
1794
+ tabId,
1795
+ windowId,
1796
+ group,
1797
+ controlSessionId: localContext?.controlSessionId,
1798
+ });
1799
+ }
1800
+ catch (error) {
1801
+ if (error?.message?.startsWith('TAB_LOCKED')) {
1802
+ if (isJsonMode()) {
1803
+ emitJson({
1804
+ success: false,
1805
+ command: 'new-window',
1806
+ durationMs: Date.now() - startMs,
1807
+ error: error.message,
1808
+ code: 'TAB_LOCKED',
1809
+ });
1810
+ process.exit(1);
1811
+ }
1812
+ spinner.fail(error.message);
1813
+ process.exit(1);
1814
+ }
1815
+ spinner.fail('Failed to open new window');
1816
+ handleError(error, 'new-window', startMs);
1817
+ }
1818
+ });
1819
+ }
1820
+ export function createCloseTabCommand() {
1821
+ return withModeOption(new Command('close-tab'))
1822
+ .argument('[tabId]', 'Optional Chromium-based browser tab ID to close (defaults to the current attached tab)')
1823
+ .description('Close a tab in your Chromium-based browser (local mode). ' +
1824
+ 'If no tab ID is provided, closes the currently attached local tab. ' +
1825
+ 'If you close the attached tab, local context and lock state are cleared.')
1826
+ .action(async (tabIdArg, options) => {
1827
+ const startMs = Date.now();
1828
+ if (options?.mode === 'cloud') {
1829
+ console.error(chalk.red('Error:'), 'close-tab is local-only; do not use --mode cloud');
1830
+ process.exit(1);
1831
+ }
1832
+ const currentCtx = getLocalSessionContext();
1833
+ const resolvedTabId = tabIdArg
1834
+ ? parseInt(validateTabId(tabIdArg, 'close-tab argument'), 10)
1835
+ : currentCtx?.tabId
1836
+ ? parseInt(currentCtx.tabId, 10)
1837
+ : NaN;
1838
+ if (!Number.isFinite(resolvedTabId) || resolvedTabId <= 0) {
1839
+ console.error(chalk.red('Error:'), 'No tabId provided and no active local tab is attached. Run: thinkrun tabs\nThen: thinkrun close-tab <tabId>');
1840
+ process.exit(1);
1841
+ }
1842
+ const lock = getLockedBy(resolvedTabId);
1843
+ if (lock) {
1844
+ const currentAgentId = resolveAgentId();
1845
+ const sameAgent = !!lock.agentId && lock.agentId === currentAgentId;
1846
+ const samePid = lock.pid === process.pid;
1847
+ const ownerPid = lock.ownerShellPid ?? lock.pid;
1848
+ let ownerAlive = false;
1849
+ try {
1850
+ process.kill(ownerPid, 0);
1851
+ ownerAlive = true;
1852
+ }
1853
+ catch (err) {
1854
+ if (err.code === 'EPERM')
1855
+ ownerAlive = true;
1856
+ }
1857
+ if (!sameAgent && !samePid && ownerAlive) {
1858
+ const ownerLabel = lock.agentId ? `agent ${lock.agentId}` : `PID ${ownerPid}`;
1859
+ console.error(chalk.red('Error:'), `Tab ${resolvedTabId} is owned by ${ownerLabel}. Refusing to close a live foreign tab.`);
1860
+ process.exit(1);
1861
+ }
1862
+ }
1863
+ const spinner = createSpinner(`Closing tab ${resolvedTabId}...`);
1864
+ try {
1865
+ const port = readBridgePort();
1866
+ const isCurrentTab = currentCtx?.tabId === String(resolvedTabId);
1867
+ const result = await closeTabOnBridge(port, resolvedTabId, {
1868
+ sessionId: currentCtx?.tabId === String(resolvedTabId) ? currentCtx.agentSessionId : undefined,
1869
+ });
1870
+ if (!result.ok) {
1871
+ spinner.fail('Failed to close tab');
1872
+ exitWithSwitchTabFailure(resolvedTabId, result, 'close-tab', startMs);
1873
+ }
1874
+ if (isCurrentTab && currentCtx?.agentSessionId) {
1875
+ try {
1876
+ await unregisterLocalAgentSession(port, currentCtx.agentSessionId);
1877
+ }
1878
+ catch {
1879
+ // best-effort cleanup after the close succeeds; local context cleanup still proceeds
1880
+ }
1881
+ }
1882
+ if (isCurrentTab) {
1883
+ releaseWorkingLocation();
1884
+ clearLocalSessionContext();
1885
+ closeCliSession(String(resolvedTabId), 'completed');
1886
+ }
1887
+ if (isJsonMode()) {
1888
+ emitJson({
1889
+ success: true,
1890
+ command: 'close-tab',
1891
+ durationMs: Date.now() - startMs,
1892
+ data: { tabId: resolvedTabId, clearedContext: Boolean(isCurrentTab) },
1893
+ });
1894
+ return;
1895
+ }
1896
+ spinner.succeed(`Closed tab ${resolvedTabId}`);
1897
+ if (isCurrentTab) {
1898
+ console.log(chalk.gray('Cleared attached local session context.'));
1899
+ }
1900
+ }
1901
+ catch (error) {
1902
+ spinner.fail('Failed to close tab');
1903
+ if (isBridgeUnreachableError(error)) {
1904
+ exitWithBridgeUnreachable('close-tab', startMs, error);
1905
+ }
1906
+ handleError(error, 'close-tab', startMs);
1907
+ }
1908
+ });
1909
+ }
1910
+ // ---------------------------------------------------------------------------
1911
+ // AI Task commands — local mode only (native host task system)
1912
+ // ---------------------------------------------------------------------------
1913
+ const TASK_POLL_INTERVAL_MS = 2000;
1914
+ const TASK_DEFAULT_TIMEOUT_MS = 5 * 60 * 1000; // 5 minutes
1915
+ const EXTENSION_AUTH_HINT = 'Local AI tasks still use the extension session relay. Save your API key in the ThinkRun extension Settings; `thinkrun config set-key` only configures the CLI/cloud client.';
1916
+ /**
1917
+ * Throw the appropriate error for a failed native-host task start.
1918
+ * 401 is gated on the HTTP status (not the error message text) so the
1919
+ * actionable extension-auth hint survives server copy changes.
1920
+ */
1921
+ function throwTaskStartError(status, message) {
1922
+ if (status === 401) {
1923
+ throw new ApiError(message, 401, EXTENSION_AUTH_HINT);
1924
+ }
1925
+ throw new Error(message);
1926
+ }
1927
+ /**
1928
+ * POST a task to the native host and return `{taskId}`.
1929
+ */
1930
+ async function startNativeTask(port, instruction, tabId, maxIterations) {
1931
+ const body = { task: instruction };
1932
+ if (tabId !== undefined)
1933
+ body.tabId = tabId;
1934
+ if (maxIterations !== undefined)
1935
+ body.maxIterations = maxIterations;
1936
+ const res = await fetch(`http://127.0.0.1:${port}/tasks`, {
1937
+ method: 'POST',
1938
+ headers: { 'Content-Type': 'application/json' },
1939
+ body: JSON.stringify(body),
1940
+ signal: AbortSignal.timeout(10000),
1941
+ });
1942
+ const json = (await res.json());
1943
+ // Native host wraps response as { success, data: { taskId, status } }
1944
+ const taskId = json.data?.taskId ?? json.taskId;
1945
+ if (!res.ok || !taskId) {
1946
+ const message = json.error || json.data?.error || `Task start failed: HTTP ${res.status}`;
1947
+ throwTaskStartError(res.status, message);
1948
+ }
1949
+ return taskId;
1950
+ }
1951
+ /**
1952
+ * GET /tasks/:id/status from the native host.
1953
+ */
1954
+ async function getNativeTaskStatus(port, taskId) {
1955
+ const res = await fetch(`http://127.0.0.1:${port}/tasks/${taskId}/status`, {
1956
+ signal: AbortSignal.timeout(BRIDGE_REQUEST_TIMEOUT_MS),
1957
+ });
1958
+ const json = (await res.json());
1959
+ if (!res.ok)
1960
+ throw new Error(json.error || `Status fetch failed: HTTP ${res.status}`);
1961
+ return json;
1962
+ }
1963
+ // Task — run an AI task via the native host
1964
+ export function createTaskCommand() {
1965
+ return new Command('task')
1966
+ .argument('<instruction>', 'Natural language instruction for the AI agent')
1967
+ .option('--async', 'Return immediately with taskId instead of waiting for completion')
1968
+ .option('--stream', 'Stream task events as NDJSON (SSE)')
1969
+ .option('--timeout <ms>', 'Max wait time in milliseconds (default: 5 minutes)', String(TASK_DEFAULT_TIMEOUT_MS))
1970
+ .option('--max-iterations <n>', 'Maximum AI iterations')
1971
+ .description('Run an AI task in the active Chromium-based browser tab (local mode)')
1972
+ .action(async (instruction, options) => {
1973
+ const startMs = Date.now();
1974
+ // Resolve port and active tabId
1975
+ const port = readBridgePort();
1976
+ const localContext = getLocalSessionContext();
1977
+ const cloudContext = getCloudSessionContext();
1978
+ if (!localContext) {
1979
+ const hint = 'List tabs: thinkrun tabs\nThen attach: thinkrun attach <tabId>';
1980
+ const cloudHint = 'The active session is a cloud session. Use: thinkrun attach <tabId> to attach to a local Chromium-based browser tab.';
1981
+ if (isJsonMode()) {
1982
+ process.stderr.write(JSON.stringify({
1983
+ success: false,
1984
+ command: 'task',
1985
+ error: cloudContext ? 'Active session is not a local tab' : 'No active tab',
1986
+ code: 'SESSION_ERROR',
1987
+ hint: cloudContext ? cloudHint : hint,
1988
+ }) + '\n');
1989
+ process.exit(1);
1990
+ }
1991
+ console.error(chalk.red('Error:'), cloudContext ? 'Active session is not a local tab ID' : 'No active tab');
1992
+ console.error(chalk.yellow('Tip:'), cloudContext ? cloudHint : hint);
1993
+ process.exit(1);
1994
+ }
1995
+ const tabId = parseInt(localContext.tabId, 10);
1996
+ // --stream and --async are mutually exclusive
1997
+ if (options.stream && options.async) {
1998
+ const hint = 'Use --stream for live event output, or --async to get the taskId immediately. Not both.';
1999
+ if (isJsonMode()) {
2000
+ process.stderr.write(JSON.stringify({ success: false, command: 'task', error: '--stream and --async are mutually exclusive', code: 'INVALID_OPTIONS', hint }) + '\n');
2001
+ }
2002
+ else {
2003
+ console.error(chalk.red('Error:'), '--stream and --async are mutually exclusive');
2004
+ console.error(chalk.yellow('Tip:'), hint);
2005
+ }
2006
+ process.exit(1);
2007
+ }
2008
+ // --stream mode: connect to SSE endpoint
2009
+ if (options.stream) {
2010
+ const spinner = createSpinner(`Running task (streaming)...`);
2011
+ try {
2012
+ const body = { task: instruction, tabId };
2013
+ if (options.maxIterations)
2014
+ body.maxIterations = parseInt(options.maxIterations);
2015
+ const res = await fetch(`http://127.0.0.1:${port}/tasks/stream`, {
2016
+ method: 'POST',
2017
+ headers: { 'Content-Type': 'application/json' },
2018
+ body: JSON.stringify(body),
2019
+ signal: AbortSignal.timeout(parseInt(options.timeout) || TASK_DEFAULT_TIMEOUT_MS),
2020
+ });
2021
+ if (!res.ok) {
2022
+ const json = (await res.json());
2023
+ const message = json.error || `Stream start failed: HTTP ${res.status}`;
2024
+ throwTaskStartError(res.status, message);
2025
+ }
2026
+ spinner.stop();
2027
+ // Stream SSE lines
2028
+ if (!res.body) {
2029
+ throw new Error('Response body is null — native host returned no stream');
2030
+ }
2031
+ const reader = res.body.getReader();
2032
+ const decoder = new TextDecoder();
2033
+ let buffer = '';
2034
+ // Track task outcome so we can exit non-zero on failure (agents rely on exit code).
2035
+ let lastTaskStatus;
2036
+ try {
2037
+ while (true) {
2038
+ const { done, value } = await reader.read();
2039
+ if (done)
2040
+ break;
2041
+ buffer += decoder.decode(value, { stream: true });
2042
+ const lines = buffer.split('\n');
2043
+ buffer = lines.pop() ?? '';
2044
+ for (const line of lines) {
2045
+ if (line.startsWith('data: ')) {
2046
+ const data = line.slice(6).trim();
2047
+ if (!data || data === '[DONE]')
2048
+ continue;
2049
+ try {
2050
+ const event = JSON.parse(data);
2051
+ if (event?.status)
2052
+ lastTaskStatus = event.status;
2053
+ if (isJsonMode()) {
2054
+ console.log(JSON.stringify(event));
2055
+ }
2056
+ else {
2057
+ const type = event.type ?? 'event';
2058
+ console.log('%s %s', chalk.gray(`[${type}]`), JSON.stringify(event).slice(0, 120));
2059
+ }
2060
+ }
2061
+ catch { /* ignore malformed lines */ }
2062
+ }
2063
+ }
2064
+ }
2065
+ }
2066
+ finally {
2067
+ // Always release the reader to prevent connection leaks
2068
+ reader.cancel().catch(() => { });
2069
+ }
2070
+ // Exit non-zero if the task failed or was cancelled so agents can detect failure.
2071
+ if (lastTaskStatus && lastTaskStatus !== 'completed') {
2072
+ process.exit(1);
2073
+ }
2074
+ }
2075
+ catch (error) {
2076
+ spinner.fail('Task stream failed');
2077
+ handleError(error, 'task', startMs);
2078
+ }
2079
+ return;
2080
+ }
2081
+ const maxIterations = options.maxIterations ? parseInt(options.maxIterations) : undefined;
2082
+ const spinner = createSpinner(`Starting task...`);
2083
+ try {
2084
+ const taskId = await startNativeTask(port, instruction, tabId, maxIterations);
2085
+ // --async mode: return immediately
2086
+ if (options.async) {
2087
+ if (isJsonMode()) {
2088
+ emitJson({ success: true, command: 'task', durationMs: Date.now() - startMs,
2089
+ data: { taskId, status: 'pending' } });
2090
+ }
2091
+ else {
2092
+ spinner.succeed(`Task started: ${taskId}`);
2093
+ console.log(chalk.gray(`Poll with: thinkrun task-status ${taskId}`));
2094
+ }
2095
+ return;
2096
+ }
2097
+ // Synchronous poll until done
2098
+ spinner.text = `Running task ${taskId}...`;
2099
+ const timeoutMs = parseInt(options.timeout) || TASK_DEFAULT_TIMEOUT_MS;
2100
+ const deadline = Date.now() + timeoutMs;
2101
+ while (Date.now() < deadline) {
2102
+ await new Promise(r => setTimeout(r, TASK_POLL_INTERVAL_MS));
2103
+ const status = await getNativeTaskStatus(port, taskId);
2104
+ if (!isJsonMode()) {
2105
+ spinner.text = `Task ${taskId}: ${status.status}${status.iteration ? ` (step ${status.iteration})` : ''}`;
2106
+ }
2107
+ const terminal = ['completed', 'failed', 'cancelled'];
2108
+ if (terminal.includes(status.status)) {
2109
+ if (isJsonMode()) {
2110
+ const envelope = { success: status.status === 'completed', command: 'task',
2111
+ durationMs: Date.now() - startMs, data: status };
2112
+ // Route failure envelopes to stderr so agents reading stdout only see successes.
2113
+ if (status.status === 'completed') {
2114
+ emitJson(envelope);
2115
+ }
2116
+ else {
2117
+ process.stderr.write(JSON.stringify(envelope) + '\n');
2118
+ }
2119
+ }
2120
+ else if (status.status === 'completed') {
2121
+ spinner.succeed(`Task completed (${status.iteration ?? '?'} steps)`);
2122
+ if (status.result)
2123
+ console.log(JSON.stringify(status.result, null, 2));
2124
+ }
2125
+ else {
2126
+ spinner.fail(`Task ${status.status}: ${status.error ?? ''}`);
2127
+ }
2128
+ if (status.status !== 'completed')
2129
+ process.exit(1);
2130
+ return;
2131
+ }
2132
+ }
2133
+ // Timed out
2134
+ spinner.fail(`Task timed out after ${Math.round(timeoutMs / 1000)}s`);
2135
+ if (isJsonMode()) {
2136
+ // Route failure envelopes to stderr — consistent with other failure paths.
2137
+ process.stderr.write(JSON.stringify({ success: false, command: 'task',
2138
+ durationMs: Date.now() - startMs, error: 'Task timed out',
2139
+ code: 'TASK_TIMEOUT', retryable: false }) + '\n');
2140
+ }
2141
+ process.exit(1);
2142
+ }
2143
+ catch (error) {
2144
+ spinner.fail('Task failed');
2145
+ handleError(error, 'task', startMs);
2146
+ }
2147
+ });
2148
+ }
2149
+ // Task-status — poll task status by ID
2150
+ export function createTaskStatusCommand() {
2151
+ return new Command('task-status')
2152
+ .argument('<taskId>', 'Task ID returned by: thinkrun task --async')
2153
+ .description('Get the status of a running or completed AI task (local mode)')
2154
+ .action(async (taskId) => {
2155
+ const startMs = Date.now();
2156
+ const spinner = createSpinner(`Fetching task status...`);
2157
+ try {
2158
+ const port = readBridgePort();
2159
+ const status = await getNativeTaskStatus(port, taskId);
2160
+ if (isJsonMode()) {
2161
+ emitJson({ success: true, command: 'task-status', durationMs: Date.now() - startMs, data: status });
2162
+ return;
2163
+ }
2164
+ spinner.succeed(`Task ${taskId}: ${status.status}`);
2165
+ if (status.result)
2166
+ console.log(JSON.stringify(status.result, null, 2));
2167
+ if (status.error)
2168
+ console.error(chalk.red('Error:'), status.error);
2169
+ }
2170
+ catch (error) {
2171
+ spinner.fail('Failed to get task status');
2172
+ handleError(error, 'task-status', startMs);
2173
+ }
2174
+ });
2175
+ }
2176
+ // Task-cancel — cancel a running task
2177
+ export function createTaskCancelCommand() {
2178
+ return new Command('task-cancel')
2179
+ .argument('<taskId>', 'Task ID to cancel')
2180
+ .description('Cancel a running AI task (local mode)')
2181
+ .action(async (taskId) => {
2182
+ const startMs = Date.now();
2183
+ const spinner = createSpinner(`Cancelling task ${taskId}...`);
2184
+ try {
2185
+ const port = readBridgePort();
2186
+ const res = await fetch(`http://127.0.0.1:${port}/tasks/${taskId}/cancel`, {
2187
+ method: 'POST',
2188
+ headers: { 'Content-Type': 'application/json' },
2189
+ signal: AbortSignal.timeout(BRIDGE_REQUEST_TIMEOUT_MS),
2190
+ });
2191
+ const json = (await res.json());
2192
+ if (!res.ok)
2193
+ throw new Error(json.error || `Cancel failed: HTTP ${res.status}`);
2194
+ if (isJsonMode()) {
2195
+ emitJson({ success: true, command: 'task-cancel', durationMs: Date.now() - startMs,
2196
+ data: { taskId, status: json.status } });
2197
+ return;
2198
+ }
2199
+ spinner.succeed(`Task ${taskId} cancelled`);
2200
+ }
2201
+ catch (error) {
2202
+ spinner.fail('Failed to cancel task');
2203
+ handleError(error, 'task-cancel', startMs);
2204
+ }
2205
+ });
2206
+ }
2207
+ //# sourceMappingURL=actions.js.map