@dench.com/cli 2.1.1 → 2.1.3

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.
package/agent-config.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  /**
2
- * `dench identity / organisation / user / heartbeat / bootstrap /
3
- * model / daily / mem aggregate` — CLI surface for the OpenClaw-
2
+ * `dench identity / organisation / user / bootstrap / model / daily /
3
+ * mem aggregate` — CLI surface for the OpenClaw-
4
4
  * parity self-updating harness.
5
5
  *
6
6
  * Mirrors the auth pattern in `cli/cron.ts`:
@@ -17,14 +17,12 @@
17
17
  * user show | edit
18
18
  * tools show | notes-edit
19
19
  * memory show | append <text> | set (MEMORY.md aggregate)
20
- * heartbeat status | enable | disable | interval <duration> |
21
- * instructions
22
20
  * bootstrap status | complete | reopen | template
23
21
  * model default <id> | model default clear |
24
22
  * model thread <threadId> <id> | model thread <threadId> clear
25
23
  * daily today | daily show <YYYY-MM-DD> | daily append <text>
26
24
  *
27
- * `edit` / `notes-edit` / `instructions` / `template` open the
25
+ * `edit` / `notes-edit` / `template` open the
28
26
  * user's `$EDITOR` (default `vi`) to capture the new body. The
29
27
  * stdin path is also supported via `--stdin`, useful in scripts.
30
28
  */
@@ -60,9 +58,6 @@ const api = {
60
58
  updateUserProfile: makeFunctionReference<"mutation">(
61
59
  "functions/agentConfig:updateUserProfile",
62
60
  ),
63
- updateHeartbeat: makeFunctionReference<"mutation">(
64
- "functions/agentConfig:updateHeartbeat",
65
- ),
66
61
  updateBootstrapTemplate: makeFunctionReference<"mutation">(
67
62
  "functions/agentConfig:updateBootstrapTemplate",
68
63
  ),
@@ -182,12 +177,6 @@ async function loadAgentConfig(ctx: CliCtx): Promise<{
182
177
  completedAt?: number;
183
178
  template?: string;
184
179
  };
185
- heartbeat: {
186
- enabled: boolean;
187
- intervalMs: number;
188
- instructions: string;
189
- lastFiredAt?: number;
190
- };
191
180
  };
192
181
  userProfile: { content: string; updatedAt: number } | null;
193
182
  organizationId: string;
@@ -206,12 +195,6 @@ async function loadAgentConfig(ctx: CliCtx): Promise<{
206
195
  completedAt?: number;
207
196
  template?: string;
208
197
  };
209
- heartbeat: {
210
- enabled: boolean;
211
- intervalMs: number;
212
- instructions: string;
213
- lastFiredAt?: number;
214
- };
215
198
  };
216
199
  userProfile: { content: string; updatedAt: number } | null;
217
200
  organizationId: string;
@@ -306,106 +289,6 @@ async function handleSimpleMagicFile(
306
289
  );
307
290
  }
308
291
 
309
- function formatInterval(ms: number): string {
310
- if (ms < 60_000) return `${Math.round(ms / 1000)}s`;
311
- if (ms < 60 * 60_000) return `${Math.round(ms / 60_000)}m`;
312
- if (ms < 24 * 60 * 60_000) {
313
- const hours = ms / (60 * 60_000);
314
- return Number.isInteger(hours) ? `${hours}h` : `${hours.toFixed(1)}h`;
315
- }
316
- return `${(ms / (24 * 60 * 60_000)).toFixed(1)}d`;
317
- }
318
-
319
- const DURATION_RE = /^(\d+)\s*(ms|s|m|h|d)$/i;
320
- function parseDurationFlagToMs(input: string): number | null {
321
- const m = DURATION_RE.exec(input.trim());
322
- if (!m) return null;
323
- const n = parseInt(m[1], 10);
324
- if (!Number.isFinite(n) || n <= 0) return null;
325
- switch (m[2].toLowerCase()) {
326
- case "ms":
327
- return n;
328
- case "s":
329
- return n * 1000;
330
- case "m":
331
- return n * 60_000;
332
- case "h":
333
- return n * 3_600_000;
334
- case "d":
335
- return n * 86_400_000;
336
- }
337
- return null;
338
- }
339
-
340
- async function handleHeartbeat(ctx: CliCtx, args: string[]): Promise<void> {
341
- const sub = args[0];
342
- if (!sub || sub === "status" || sub === "show") {
343
- const config = await loadAgentConfig(ctx);
344
- const hb = config.agentConfig.heartbeat;
345
- if (ctx.jsonOutput) {
346
- out(ctx, hb);
347
- return;
348
- }
349
- out(
350
- ctx,
351
- `enabled=${hb.enabled} interval=${formatInterval(hb.intervalMs)}\n\n${hb.instructions}`,
352
- );
353
- return;
354
- }
355
- if (sub === "enable") {
356
- await runMutation(ctx, api.functions.agentConfig.updateHeartbeat, {
357
- enabled: true,
358
- });
359
- out(ctx, { ok: true, enabled: true });
360
- return;
361
- }
362
- if (sub === "disable") {
363
- await runMutation(ctx, api.functions.agentConfig.updateHeartbeat, {
364
- enabled: false,
365
- });
366
- out(ctx, { ok: true, enabled: false });
367
- return;
368
- }
369
- if (sub === "interval") {
370
- const raw = args[1];
371
- if (!raw) {
372
- throw new AgentConfigCliError(
373
- "Usage: dench heartbeat interval <duration> (e.g. 30m, 2h, 1d)",
374
- );
375
- }
376
- const ms = parseDurationFlagToMs(raw);
377
- if (!ms) throw new AgentConfigCliError(`Invalid duration: ${raw}`);
378
- await runMutation(ctx, api.functions.agentConfig.updateHeartbeat, {
379
- intervalMs: ms,
380
- });
381
- out(ctx, { ok: true, intervalMs: ms, label: formatInterval(ms) });
382
- return;
383
- }
384
- if (sub === "instructions" || sub === "edit") {
385
- let content: string;
386
- if (hasFlag(args, "--stdin") || getFlag(args, "--content")) {
387
- content = await resolveContent(args);
388
- } else {
389
- const config = await loadAgentConfig(ctx);
390
- content = openEditor(
391
- config.agentConfig.heartbeat.instructions,
392
- "heartbeat",
393
- );
394
- }
395
- if (!content.trim().length) {
396
- throw new AgentConfigCliError("heartbeat instructions cannot be empty");
397
- }
398
- await runMutation(ctx, api.functions.agentConfig.updateHeartbeat, {
399
- instructions: content,
400
- });
401
- out(ctx, { ok: true, kind: "heartbeat" });
402
- return;
403
- }
404
- throw new AgentConfigCliError(
405
- `Unknown heartbeat subcommand: "${sub}". Try "status", "enable", "disable", "interval <duration>", "instructions".`,
406
- );
407
- }
408
-
409
292
  async function handleBootstrap(ctx: CliCtx, args: string[]): Promise<void> {
410
293
  const sub = args[0];
411
294
  if (!sub || sub === "status" || sub === "show") {
@@ -598,10 +481,6 @@ export async function runMemoryAggregateCommand(ctx: CliCtx): Promise<void> {
598
481
  );
599
482
  }
600
483
 
601
- export async function runHeartbeatCommand(ctx: CliCtx): Promise<void> {
602
- await handleHeartbeat(ctx, ctx.args);
603
- }
604
-
605
484
  export async function runBootstrapCommand(ctx: CliCtx): Promise<void> {
606
485
  await handleBootstrap(ctx, ctx.args);
607
486
  }
@@ -666,8 +545,6 @@ export function printAgentConfigCliHelp(): void {
666
545
  dench user show | edit USER.md (per-member)
667
546
  dench tools show | notes-edit TOOLS.md notes section
668
547
  dench mem show | append <text> | set MEMORY.md aggregate
669
- dench heartbeat status | enable | disable Heartbeat cron
670
- | interval <30m|1h|…> | instructions
671
548
  dench bootstrap status | complete | reopen | template
672
549
  dench model default <id|clear> | model thread <threadId> <id|clear>
673
550
  dench daily today | show <YYYY-MM-DD> Activity log paths
package/browser.ts ADDED
@@ -0,0 +1,507 @@
1
+ /**
2
+ * `dench browser` — CLI control for the cloud Browserbase browser.
3
+ *
4
+ * The CLI does not launch Chrome locally and never receives Browserbase
5
+ * credentials. It sends a bearer-authenticated JSON command to
6
+ * /api/browser/cli, where Dench Cloud drives Browserbase server-side.
7
+ */
8
+
9
+ import { createHash } from "node:crypto";
10
+ import { mkdir, readFile, unlink, writeFile } from "node:fs/promises";
11
+ import { homedir } from "node:os";
12
+ import { dirname, join } from "node:path";
13
+ import { hasFlag } from "./lib/cli-args";
14
+
15
+ type RuntimeBundle = {
16
+ host: string;
17
+ bearerToken: string;
18
+ };
19
+
20
+ type BrowserCliContext = {
21
+ runtime: RuntimeBundle;
22
+ args: string[];
23
+ jsonOutput: boolean;
24
+ };
25
+
26
+ type BrowserState = {
27
+ sessions?: Record<
28
+ string,
29
+ {
30
+ threadId: string;
31
+ updatedAt: number;
32
+ }
33
+ >;
34
+ };
35
+
36
+ type BrowserResponse = Record<string, unknown> & {
37
+ action?: string;
38
+ currentUrl?: string | null;
39
+ error?: string;
40
+ hint?: string;
41
+ ok?: boolean;
42
+ pageTitle?: string | null;
43
+ screenshot?: { data?: string; mimeType?: string };
44
+ text?: string | null;
45
+ threadId?: string;
46
+ threadUrl?: string;
47
+ };
48
+
49
+ class BrowserCliError extends Error {}
50
+
51
+ const ACTIONS = new Set([
52
+ "open",
53
+ "state",
54
+ "inspect",
55
+ "navigate",
56
+ "click",
57
+ "clickAt",
58
+ "fill",
59
+ "type",
60
+ "key",
61
+ "scroll",
62
+ "wait",
63
+ "evaluate",
64
+ "tabs",
65
+ "newTab",
66
+ "activateTab",
67
+ "closeTab",
68
+ "screenshot",
69
+ "stop",
70
+ ]);
71
+
72
+ function browserHelp(): void {
73
+ console.log(`Usage: dench browser <action> [options]
74
+
75
+ Control the cloud Browserbase browser without opening dench.com locally.
76
+ Commands authenticate with the active dench signin session or DENCH_API_KEY.
77
+
78
+ Common:
79
+ dench browser open [url] [--json]
80
+ dench browser inspect [--json]
81
+ dench browser navigate <url>
82
+ dench browser click --selector <css>
83
+ dench browser click --text "Button text"
84
+ dench browser click-at <x> <y>
85
+ dench browser fill --label "Email" --value "me@example.com"
86
+ dench browser type "text for focused field"
87
+ dench browser key Enter
88
+ dench browser scroll [up|down] [--pixels N]
89
+ dench browser wait --text "Done" [--timeout-ms N]
90
+ dench browser evaluate "return document.title"
91
+ dench browser screenshot [--output page.jpg] [--full-page]
92
+ dench browser tabs
93
+ dench browser new-tab <url>
94
+ dench browser activate-tab <targetId>
95
+ dench browser close-tab <targetId>
96
+ dench browser stop
97
+
98
+ State:
99
+ The CLI remembers the latest browser thread per Dench host/session.
100
+ Use --thread <threadId> to control a specific thread, or
101
+ dench browser reset to forget the local remembered thread.
102
+ `);
103
+ }
104
+
105
+ function consumeFlagValue(args: string[], name: string): string | undefined {
106
+ const idx = args.indexOf(name);
107
+ if (idx === -1) return undefined;
108
+ const value = args[idx + 1];
109
+ args.splice(idx, 2);
110
+ return value;
111
+ }
112
+
113
+ function consumeFlag(args: string[], name: string): boolean {
114
+ const idx = args.indexOf(name);
115
+ if (idx === -1) return false;
116
+ args.splice(idx, 1);
117
+ return true;
118
+ }
119
+
120
+ function parseNumberFlag(args: string[], name: string): number | undefined {
121
+ const raw = consumeFlagValue(args, name);
122
+ if (raw === undefined) return undefined;
123
+ const value = Number(raw);
124
+ if (!Number.isFinite(value)) {
125
+ throw new BrowserCliError(`Invalid ${name} value: ${raw}`);
126
+ }
127
+ return value;
128
+ }
129
+
130
+ function statePath(): string {
131
+ return (
132
+ process.env.DENCH_BROWSER_STATE_PATH?.trim() ||
133
+ join(homedir(), ".dench", "browser-state.json")
134
+ );
135
+ }
136
+
137
+ function stateKey(runtime: RuntimeBundle): string {
138
+ const tokenHash = createHash("sha256")
139
+ .update(runtime.bearerToken)
140
+ .digest("hex")
141
+ .slice(0, 24);
142
+ return `${runtime.host.replace(/\/+$/, "")}#${tokenHash}`;
143
+ }
144
+
145
+ async function readState(): Promise<BrowserState> {
146
+ try {
147
+ const raw = await readFile(statePath(), "utf8");
148
+ const parsed = JSON.parse(raw) as unknown;
149
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
150
+ return {};
151
+ }
152
+ return parsed as BrowserState;
153
+ } catch {
154
+ return {};
155
+ }
156
+ }
157
+
158
+ async function writeState(state: BrowserState): Promise<void> {
159
+ const target = statePath();
160
+ await mkdir(dirname(target), { recursive: true });
161
+ await writeFile(target, JSON.stringify(state, null, 2), "utf8");
162
+ }
163
+
164
+ async function readRememberedThreadId(
165
+ runtime: RuntimeBundle,
166
+ ): Promise<string | undefined> {
167
+ const state = await readState();
168
+ const session = state.sessions?.[stateKey(runtime)];
169
+ return session?.threadId;
170
+ }
171
+
172
+ async function rememberThreadId(
173
+ runtime: RuntimeBundle,
174
+ threadId: string,
175
+ ): Promise<void> {
176
+ const state = await readState();
177
+ await writeState({
178
+ ...state,
179
+ sessions: {
180
+ ...(state.sessions ?? {}),
181
+ [stateKey(runtime)]: { threadId, updatedAt: Date.now() },
182
+ },
183
+ });
184
+ }
185
+
186
+ async function forgetThreadId(runtime: RuntimeBundle): Promise<boolean> {
187
+ const state = await readState();
188
+ const sessions = { ...(state.sessions ?? {}) };
189
+ const key = stateKey(runtime);
190
+ const existed = Boolean(sessions[key]);
191
+ delete sessions[key];
192
+ if (Object.keys(sessions).length === 0) {
193
+ try {
194
+ await unlink(statePath());
195
+ } catch {
196
+ // Ignore a concurrent/manual delete.
197
+ }
198
+ return existed;
199
+ }
200
+ await writeState({ ...state, sessions });
201
+ return existed;
202
+ }
203
+
204
+ function normalizeBrowserUrl(input: string | undefined): string | undefined {
205
+ const value = input?.trim();
206
+ if (!value) return undefined;
207
+ if (/^https?:\/\//i.test(value)) return value;
208
+ if (!/\s/.test(value) && value.includes(".")) return `https://${value}`;
209
+ return `https://www.google.com/search?q=${encodeURIComponent(value)}`;
210
+ }
211
+
212
+ function buildCliBrowserUrl(host: string): string {
213
+ return `${host.replace(/\/+$/, "")}/api/browser/cli`;
214
+ }
215
+
216
+ async function postJson(
217
+ url: string,
218
+ bearer: string,
219
+ body: unknown,
220
+ ): Promise<{ ok: boolean; payload: unknown; status: number }> {
221
+ const response = await fetch(url, {
222
+ method: "POST",
223
+ headers: {
224
+ accept: "application/json",
225
+ authorization: `Bearer ${bearer}`,
226
+ "content-type": "application/json",
227
+ },
228
+ body: JSON.stringify(body),
229
+ });
230
+ const text = await response.text();
231
+ let payload: unknown;
232
+ try {
233
+ payload = text ? JSON.parse(text) : null;
234
+ } catch {
235
+ payload = { raw: text };
236
+ }
237
+ return { ok: response.ok, payload, status: response.status };
238
+ }
239
+
240
+ function payloadError(payload: unknown): string {
241
+ if (payload && typeof payload === "object" && !Array.isArray(payload)) {
242
+ const error = (payload as { error?: unknown }).error;
243
+ if (typeof error === "string" && error.trim()) return error;
244
+ }
245
+ return "Cloud browser request failed";
246
+ }
247
+
248
+ async function maybeWriteScreenshot(
249
+ response: BrowserResponse,
250
+ outputPath: string | undefined,
251
+ ): Promise<string | undefined> {
252
+ const data = response.screenshot?.data;
253
+ if (!outputPath || typeof data !== "string") return undefined;
254
+ await writeFile(outputPath, Buffer.from(data, "base64"));
255
+ return outputPath;
256
+ }
257
+
258
+ function formatInspectElements(value: unknown): string[] {
259
+ if (!Array.isArray(value)) return [];
260
+ return value.slice(0, 10).flatMap((item, index) => {
261
+ if (!item || typeof item !== "object") return [];
262
+ const record = item as {
263
+ selector?: unknown;
264
+ tagName?: unknown;
265
+ text?: unknown;
266
+ };
267
+ const label =
268
+ typeof record.text === "string" && record.text.trim()
269
+ ? ` ${record.text.trim()}`
270
+ : "";
271
+ const selector =
272
+ typeof record.selector === "string" ? ` ${record.selector}` : "";
273
+ const tag = typeof record.tagName === "string" ? record.tagName : "el";
274
+ return [`${index + 1}. ${tag}${label}${selector}`];
275
+ });
276
+ }
277
+
278
+ function formatHuman(response: BrowserResponse, screenshotPath?: string) {
279
+ const lines: string[] = [];
280
+ const action = response.action ?? "browser";
281
+ if (response.ok === false) {
282
+ lines.push(
283
+ `Browser ${action} failed: ${response.error ?? "unknown error"}`,
284
+ );
285
+ } else {
286
+ lines.push(`Browser ${action} ok.`);
287
+ }
288
+ if (response.threadId) lines.push(`threadId: ${response.threadId}`);
289
+ if (response.currentUrl) lines.push(`url: ${response.currentUrl}`);
290
+ if (response.pageTitle) lines.push(`title: ${response.pageTitle}`);
291
+ if (screenshotPath) lines.push(`screenshot: ${screenshotPath}`);
292
+ if (response.screenshot?.data && !screenshotPath) {
293
+ lines.push("screenshot captured; pass --output <file> to save it.");
294
+ }
295
+ if (typeof response.text === "string" && response.text.trim()) {
296
+ const text = response.text.trim();
297
+ lines.push("");
298
+ lines.push(text.length > 2000 ? `${text.slice(0, 2000)}...` : text);
299
+ }
300
+ const elements = formatInspectElements(response.elements);
301
+ if (elements.length > 0) {
302
+ lines.push("");
303
+ lines.push("Elements:");
304
+ lines.push(...elements);
305
+ }
306
+ if (response.hint) {
307
+ lines.push("");
308
+ lines.push(String(response.hint));
309
+ }
310
+ if (response.threadUrl) {
311
+ lines.push("");
312
+ lines.push(`Open in Dench: ${response.threadUrl}`);
313
+ }
314
+ return lines.join("\n");
315
+ }
316
+
317
+ function output(ctx: BrowserCliContext, value: unknown): void {
318
+ if (ctx.jsonOutput) {
319
+ console.log(JSON.stringify(value, null, 2));
320
+ return;
321
+ }
322
+ console.log(String(value));
323
+ }
324
+
325
+ function parseActionAndBody(ctx: BrowserCliContext): {
326
+ body: Record<string, unknown>;
327
+ outputPath?: string;
328
+ } {
329
+ const actionArg = ctx.args.shift();
330
+ const action =
331
+ actionArg === "new-tab"
332
+ ? "newTab"
333
+ : actionArg === "activate-tab"
334
+ ? "activateTab"
335
+ : actionArg === "close-tab"
336
+ ? "closeTab"
337
+ : actionArg === "click-at"
338
+ ? "clickAt"
339
+ : (actionArg ?? "open");
340
+ if (!ACTIONS.has(action)) {
341
+ throw new BrowserCliError(`Unknown browser action: ${actionArg}`);
342
+ }
343
+
344
+ const threadId = consumeFlagValue(ctx.args, "--thread");
345
+ const selector = consumeFlagValue(ctx.args, "--selector");
346
+ const text = consumeFlagValue(ctx.args, "--text");
347
+ const label = consumeFlagValue(ctx.args, "--label");
348
+ const value = consumeFlagValue(ctx.args, "--value");
349
+ const key = consumeFlagValue(ctx.args, "--key");
350
+ const targetId =
351
+ consumeFlagValue(ctx.args, "--target-id") ||
352
+ consumeFlagValue(ctx.args, "--targetId");
353
+ const pixels = parseNumberFlag(ctx.args, "--pixels");
354
+ const timeoutMs =
355
+ parseNumberFlag(ctx.args, "--timeout-ms") ??
356
+ parseNumberFlag(ctx.args, "--timeoutMs");
357
+ const x = parseNumberFlag(ctx.args, "--x");
358
+ const y = parseNumberFlag(ctx.args, "--y");
359
+ const outputPath = consumeFlagValue(ctx.args, "--output");
360
+ const fullPage = consumeFlag(ctx.args, "--full-page");
361
+
362
+ if (action === "open" || action === "navigate" || action === "newTab") {
363
+ const url = normalizeBrowserUrl(
364
+ consumeFlagValue(ctx.args, "--url") ?? ctx.args.join(" "),
365
+ );
366
+ ctx.args.length = 0;
367
+ return {
368
+ body: { action, threadId, url },
369
+ outputPath,
370
+ };
371
+ }
372
+
373
+ if (action === "type") {
374
+ return {
375
+ body: { action, threadId, value: value ?? ctx.args.join(" ") },
376
+ outputPath,
377
+ };
378
+ }
379
+
380
+ if (action === "key") {
381
+ return {
382
+ body: { action, threadId, key: key ?? ctx.args.join(" ") },
383
+ outputPath,
384
+ };
385
+ }
386
+
387
+ if (action === "scroll") {
388
+ const direction = ctx.args[0] === "up" ? "up" : "down";
389
+ return {
390
+ body: { action, direction, pixels, threadId },
391
+ outputPath,
392
+ };
393
+ }
394
+
395
+ if (action === "evaluate") {
396
+ const script = consumeFlagValue(ctx.args, "--script") ?? ctx.args.join(" ");
397
+ return {
398
+ body: { action, script, threadId },
399
+ outputPath,
400
+ };
401
+ }
402
+
403
+ if (action === "clickAt") {
404
+ const positionalX = ctx.args[0] !== undefined ? Number(ctx.args[0]) : x;
405
+ const positionalY = ctx.args[1] !== undefined ? Number(ctx.args[1]) : y;
406
+ return {
407
+ body: { action, threadId, x: positionalX, y: positionalY },
408
+ outputPath,
409
+ };
410
+ }
411
+
412
+ if (action === "activateTab" || action === "closeTab") {
413
+ return {
414
+ body: { action, targetId: targetId ?? ctx.args[0], threadId },
415
+ outputPath,
416
+ };
417
+ }
418
+
419
+ return {
420
+ body: {
421
+ action,
422
+ fullPage,
423
+ label,
424
+ pixels,
425
+ selector,
426
+ targetId,
427
+ text,
428
+ threadId,
429
+ timeoutMs,
430
+ value,
431
+ },
432
+ outputPath,
433
+ };
434
+ }
435
+
436
+ export async function runBrowserCommand(opts: {
437
+ args: string[];
438
+ runtime: RuntimeBundle;
439
+ }): Promise<void> {
440
+ const args = [...opts.args];
441
+ const jsonOutput = hasFlag(args, "--json");
442
+ const ctx: BrowserCliContext = {
443
+ args,
444
+ jsonOutput,
445
+ runtime: opts.runtime,
446
+ };
447
+ const first = args[0];
448
+ if (!first || first === "help" || first === "--help" || first === "-h") {
449
+ browserHelp();
450
+ return;
451
+ }
452
+
453
+ if (first === "reset") {
454
+ await forgetThreadId(opts.runtime);
455
+ output(
456
+ ctx,
457
+ jsonOutput ? { ok: true, reset: true } : "Forgot CLI browser thread.",
458
+ );
459
+ return;
460
+ }
461
+
462
+ const { body, outputPath } = parseActionAndBody(ctx);
463
+ if (!body.threadId) {
464
+ body.threadId = await readRememberedThreadId(opts.runtime);
465
+ }
466
+ if (body.action === "stop" && !body.threadId) {
467
+ output(
468
+ ctx,
469
+ ctx.jsonOutput
470
+ ? { ok: true, action: "stop", stopped: false, reason: "no_thread" }
471
+ : "No remembered CLI browser thread. Pass --thread <threadId> to stop a specific one.",
472
+ );
473
+ return;
474
+ }
475
+
476
+ const result = await postJson(
477
+ buildCliBrowserUrl(opts.runtime.host),
478
+ opts.runtime.bearerToken,
479
+ body,
480
+ );
481
+ if (!result.ok) {
482
+ throw new BrowserCliError(
483
+ `${payloadError(result.payload)} (${result.status})`,
484
+ );
485
+ }
486
+
487
+ const response =
488
+ result.payload && typeof result.payload === "object"
489
+ ? (result.payload as BrowserResponse)
490
+ : ({} as BrowserResponse);
491
+ if (typeof response.threadId === "string" && response.threadId.trim()) {
492
+ await rememberThreadId(opts.runtime, response.threadId.trim());
493
+ }
494
+ const screenshotPath = await maybeWriteScreenshot(response, outputPath);
495
+ if (ctx.jsonOutput) {
496
+ output(ctx, screenshotPath ? { ...response, screenshotPath } : response);
497
+ return;
498
+ }
499
+
500
+ const printable =
501
+ screenshotPath && response.screenshot
502
+ ? { ...response, screenshot: undefined }
503
+ : response;
504
+ output(ctx, formatHuman(printable, screenshotPath));
505
+ }
506
+
507
+ export { BrowserCliError };
package/chat.ts CHANGED
@@ -433,7 +433,7 @@ async function runChatDeleteCommand(ctx: ChatCliContext): Promise<void> {
433
433
  );
434
434
  }
435
435
  const automatedSuffix = includeAutomated
436
- ? " (INCLUDING ROUTINES / HEARTBEATS — automated thread protection bypassed)"
436
+ ? " (INCLUDING AUTOMATED ROUTINES — automated thread protection bypassed)"
437
437
  : "";
438
438
  const ok = await promptYesNo(
439
439
  `PERMANENTLY queue delete of ${threadIds.length} chat${