@phnx-labs/agents-cli 1.22.37 → 1.22.39

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 (65) hide show
  1. package/CHANGELOG.md +50 -0
  2. package/README.md +8 -8
  3. package/dist/bin/agents +0 -0
  4. package/dist/bootstrap.js +3 -2
  5. package/dist/commands/artifacts-setup.d.ts +53 -0
  6. package/dist/commands/{setup-share.js → artifacts-setup.js} +59 -13
  7. package/dist/commands/artifacts.d.ts +18 -0
  8. package/dist/commands/artifacts.js +58 -0
  9. package/dist/commands/browser.js +2 -0
  10. package/dist/commands/config.js +31 -1
  11. package/dist/commands/exec.js +2 -2
  12. package/dist/commands/models.js +67 -0
  13. package/dist/commands/setup.js +5 -5
  14. package/dist/commands/share.d.ts +20 -7
  15. package/dist/commands/share.js +74 -75
  16. package/dist/commands/ssh.js +156 -8
  17. package/dist/lib/browser/hygiene.d.ts +90 -0
  18. package/dist/lib/browser/hygiene.js +146 -0
  19. package/dist/lib/browser/ipc.js +12 -0
  20. package/dist/lib/browser/service.d.ts +75 -1
  21. package/dist/lib/browser/service.js +201 -11
  22. package/dist/lib/browser/types.d.ts +44 -1
  23. package/dist/lib/config-keys.d.ts +11 -3
  24. package/dist/lib/config-keys.js +22 -3
  25. package/dist/lib/config-machine-keys.js +1 -0
  26. package/dist/lib/device-config.d.ts +34 -0
  27. package/dist/lib/device-config.js +96 -0
  28. package/dist/lib/devices/pool.d.ts +56 -0
  29. package/dist/lib/devices/pool.js +85 -0
  30. package/dist/lib/exec.d.ts +4 -1
  31. package/dist/lib/exec.js +8 -2
  32. package/dist/lib/git.d.ts +1 -1
  33. package/dist/lib/git.js +1 -1
  34. package/dist/lib/menubar/MenubarHelper.app/Contents/CodeResources +0 -0
  35. package/dist/lib/menubar/MenubarHelper.app/Contents/MacOS/MenubarHelper +0 -0
  36. package/dist/lib/migrate.d.ts +36 -0
  37. package/dist/lib/migrate.js +107 -0
  38. package/dist/lib/routines.d.ts +3 -1
  39. package/dist/lib/secrets/Agents CLI.app/Contents/CodeResources +0 -0
  40. package/dist/lib/secrets/Agents CLI.app/Contents/MacOS/Agents CLI +0 -0
  41. package/dist/lib/share/analytics.js +1 -1
  42. package/dist/lib/share/capture.d.ts +1 -1
  43. package/dist/lib/share/capture.js +3 -3
  44. package/dist/lib/share/config.d.ts +2 -2
  45. package/dist/lib/share/config.js +5 -5
  46. package/dist/lib/share/delete.js +3 -3
  47. package/dist/lib/share/provision.js +3 -3
  48. package/dist/lib/share/publish.d.ts +1 -1
  49. package/dist/lib/share/publish.js +3 -3
  50. package/dist/lib/share/worker-template.d.ts +12 -1
  51. package/dist/lib/share/worker-template.js +13 -2
  52. package/dist/lib/smart-launch.d.ts +33 -3
  53. package/dist/lib/smart-launch.js +61 -6
  54. package/dist/lib/startup/command-registry.d.ts +10 -2
  55. package/dist/lib/startup/command-registry.js +16 -7
  56. package/dist/lib/tmux/orphan-reap.d.ts +15 -19
  57. package/dist/lib/tmux/orphan-reap.js +15 -21
  58. package/dist/lib/tmux/session.js +4 -3
  59. package/dist/lib/triggers/handlers.js +10 -0
  60. package/dist/lib/triggers/webhook.js +10 -0
  61. package/dist/lib/types.d.ts +2 -2
  62. package/package.json +1 -1
  63. package/dist/commands/set.d.ts +0 -15
  64. package/dist/commands/set.js +0 -79
  65. package/dist/commands/setup-share.d.ts +0 -17
@@ -0,0 +1,90 @@
1
+ import { type PidSessionEntry } from '../session/pid-registry.js';
2
+ import type { ReapResult, Task } from './types.js';
3
+ /** Default idle window before an untouched task is reaped. */
4
+ export declare const DEFAULT_IDLE_MS: number;
5
+ /**
6
+ * The slice of `BrowserService` the reaper needs. Structural, so a test drives
7
+ * it with a stub and `BrowserService` satisfies it without an import — which
8
+ * also keeps this module off the service's import cycle.
9
+ */
10
+ export interface ReapableService {
11
+ listTasks(): Array<{
12
+ profile: string;
13
+ task: Task;
14
+ }>;
15
+ recordStatus(taskName: string): Promise<{
16
+ recording: boolean;
17
+ }>;
18
+ stop(taskName: string): Promise<{
19
+ ok: boolean;
20
+ profile?: string;
21
+ }>;
22
+ }
23
+ /**
24
+ * Injectable liveness sources. Same shape as `feed-post.ts`
25
+ * (`input.listEntries ?? listPidSessionEntries`, feed-post.ts:119) — the
26
+ * defaults are the real registry and the real process table, so a test can
27
+ * substitute them without mocking a module.
28
+ */
29
+ export interface ReapDeps {
30
+ listEntries?: () => PidSessionEntry[];
31
+ pidAlive?: (pid: number, startedAtMs?: number) => boolean;
32
+ sessionIdOfPid?: (pid: number) => string | undefined;
33
+ sessionLiveOnProcessTable?: (sessionId: string) => Promise<boolean>;
34
+ }
35
+ export interface ReapOptions {
36
+ /** Idle window in ms. Default {@link DEFAULT_IDLE_MS}. */
37
+ idleMs?: number;
38
+ /** Clock, injectable for tests. Default `Date.now()`. */
39
+ now?: number;
40
+ /** Report what would be closed without closing anything. */
41
+ dryRun?: boolean;
42
+ deps?: ReapDeps;
43
+ }
44
+ export interface LiveIdentities {
45
+ sessions: Set<string>;
46
+ launches: Set<string>;
47
+ }
48
+ /**
49
+ * Session and launch ids belonging to a process that is alive right now.
50
+ *
51
+ * Built from the per-pid launch registry, filtered to live pids —
52
+ * `isPidAlive(pid, startedAtMs)` rather than a bare existence check, so a pid
53
+ * the OS recycled onto an unrelated process does not read as a live agent.
54
+ * An entry with no recorded `sessionId` still contributes one when the live
55
+ * process carries `--session-id` on its argv, which is the RUSH-2384 recovery
56
+ * path the registry itself documents (pid-registry.ts:102-107).
57
+ */
58
+ export declare function resolveLiveIdentities(deps?: ReapDeps): LiveIdentities;
59
+ /**
60
+ * True when the task's owner is PROVABLY gone. The bar is proof, not absence of
61
+ * evidence, because being wrong here closes a working agent's tabs.
62
+ *
63
+ * Only a `sessionId` can carry that proof. It has two independent sources — the
64
+ * per-pid launch registry, and a live process carrying `--session-id <id>` in
65
+ * its argv — so a session the registry missed is still caught by the process
66
+ * table. The registry misses constantly: a wrapper pid exits, a prune sweeps
67
+ * the entry, or the agent was never launched via `agents run`
68
+ * (pid-registry.ts:102-107, RUSH-2384).
69
+ *
70
+ * A `launchId` has NO second source — the registry is its only witness, and
71
+ * that witness is the unreliable one. So a task carrying only a `launchId` is
72
+ * never session-reaped, exactly like a task carrying no identity at all; both
73
+ * fall through to the idle rule. This is not a corner case: `launchId` is
74
+ * minted for every run (`exec.ts` `resolveLaunchId`) while `AGENT_SESSION_ID`
75
+ * is Claude-only and skipped on resume, so treating a missing registry entry as
76
+ * proof of death would close the tabs of every live codex/droid/grok run whose
77
+ * launch pid had already exited.
78
+ *
79
+ * A live `launchId` still RESCUES a task whose `sessionId` looks dead — proof of
80
+ * life needs only one witness, unlike proof of death.
81
+ */
82
+ export declare function taskOwnerIsGone(task: Task, live: LiveIdentities, deps?: ReapDeps): Promise<boolean>;
83
+ /**
84
+ * Stop every task whose owner is gone or that has sat untouched past `idleMs`.
85
+ *
86
+ * Returns what it closed and how many it left alone. A task that is mid-
87
+ * recording is always left alone: reaping it would truncate a capture the user
88
+ * asked for, and an in-flight recording is itself proof the task is in use.
89
+ */
90
+ export declare function reapAbandonedTasks(service: ReapableService, opts?: ReapOptions): Promise<ReapResult>;
@@ -0,0 +1,146 @@
1
+ /**
2
+ * Abandoned browser-task reaper (RUSH-2622).
3
+ *
4
+ * `agents browser done` / `stop` already close a task's tabs, but agents
5
+ * routinely never call them — the run ends, the process exits, and the task's
6
+ * tabs stay open in the profile window forever. Over a day of fleet activity
7
+ * that is dozens of leftover tabs in Comet/Chrome.
8
+ *
9
+ * This closes the loop from the other end: a periodic pass that finds tasks
10
+ * nobody is driving any more and stops them.
11
+ *
12
+ * Two independent reasons, both conservative:
13
+ *
14
+ * - `session-dead` — the task recorded WHICH agent session (`sessionId`) or
15
+ * WHICH run (`launchId`) started it, and neither is alive on this host.
16
+ * That is the honest end of the task: the thing that would have called
17
+ * `done` no longer exists.
18
+ * - `idle` — nothing has touched the task for `idleMs` (default 30 minutes).
19
+ * The catch-all for a task with no recorded identity (a human running
20
+ * `agents browser start` by hand) or an agent that stalled without exiting.
21
+ *
22
+ * Closing always goes through `BrowserService.stop`, never a direct
23
+ * `Target.closeTarget`, so history, the session cache, the target cache, and
24
+ * forked-profile teardown all stay on the one code path that already handles
25
+ * them. Two things this deliberately never does: touch a tab that is not in
26
+ * `task.tabs` (a tab the user opened themselves is not ours to close), and kill
27
+ * the profile window or the browser process because one task went idle.
28
+ */
29
+ import { isPidAlive, isSessionIdLiveOnProcessTable } from '../session/active.js';
30
+ import { listPidSessionEntries, sessionIdFromLivePid } from '../session/pid-registry.js';
31
+ /** Default idle window before an untouched task is reaped. */
32
+ export const DEFAULT_IDLE_MS = 30 * 60_000;
33
+ /**
34
+ * Session and launch ids belonging to a process that is alive right now.
35
+ *
36
+ * Built from the per-pid launch registry, filtered to live pids —
37
+ * `isPidAlive(pid, startedAtMs)` rather than a bare existence check, so a pid
38
+ * the OS recycled onto an unrelated process does not read as a live agent.
39
+ * An entry with no recorded `sessionId` still contributes one when the live
40
+ * process carries `--session-id` on its argv, which is the RUSH-2384 recovery
41
+ * path the registry itself documents (pid-registry.ts:102-107).
42
+ */
43
+ export function resolveLiveIdentities(deps = {}) {
44
+ const listEntries = deps.listEntries ?? listPidSessionEntries;
45
+ const alive = deps.pidAlive ?? isPidAlive;
46
+ const sessionIdOf = deps.sessionIdOfPid ?? sessionIdFromLivePid;
47
+ const sessions = new Set();
48
+ const launches = new Set();
49
+ for (const entry of listEntries()) {
50
+ if (!alive(entry.pid, entry.startedAtMs))
51
+ continue;
52
+ const sessionId = entry.sessionId ?? sessionIdOf(entry.pid);
53
+ if (sessionId)
54
+ sessions.add(sessionId);
55
+ if (entry.launchId)
56
+ launches.add(entry.launchId);
57
+ }
58
+ return { sessions, launches };
59
+ }
60
+ /**
61
+ * True when the task's owner is PROVABLY gone. The bar is proof, not absence of
62
+ * evidence, because being wrong here closes a working agent's tabs.
63
+ *
64
+ * Only a `sessionId` can carry that proof. It has two independent sources — the
65
+ * per-pid launch registry, and a live process carrying `--session-id <id>` in
66
+ * its argv — so a session the registry missed is still caught by the process
67
+ * table. The registry misses constantly: a wrapper pid exits, a prune sweeps
68
+ * the entry, or the agent was never launched via `agents run`
69
+ * (pid-registry.ts:102-107, RUSH-2384).
70
+ *
71
+ * A `launchId` has NO second source — the registry is its only witness, and
72
+ * that witness is the unreliable one. So a task carrying only a `launchId` is
73
+ * never session-reaped, exactly like a task carrying no identity at all; both
74
+ * fall through to the idle rule. This is not a corner case: `launchId` is
75
+ * minted for every run (`exec.ts` `resolveLaunchId`) while `AGENT_SESSION_ID`
76
+ * is Claude-only and skipped on resume, so treating a missing registry entry as
77
+ * proof of death would close the tabs of every live codex/droid/grok run whose
78
+ * launch pid had already exited.
79
+ *
80
+ * A live `launchId` still RESCUES a task whose `sessionId` looks dead — proof of
81
+ * life needs only one witness, unlike proof of death.
82
+ */
83
+ export async function taskOwnerIsGone(task, live, deps = {}) {
84
+ if (!task.sessionId)
85
+ return false;
86
+ if (task.launchId && live.launches.has(task.launchId))
87
+ return false;
88
+ if (live.sessions.has(task.sessionId))
89
+ return false;
90
+ const onProcessTable = deps.sessionLiveOnProcessTable ?? ((id) => isSessionIdLiveOnProcessTable(id));
91
+ return !(await onProcessTable(task.sessionId));
92
+ }
93
+ /**
94
+ * Stop every task whose owner is gone or that has sat untouched past `idleMs`.
95
+ *
96
+ * Returns what it closed and how many it left alone. A task that is mid-
97
+ * recording is always left alone: reaping it would truncate a capture the user
98
+ * asked for, and an in-flight recording is itself proof the task is in use.
99
+ */
100
+ export async function reapAbandonedTasks(service, opts = {}) {
101
+ const idleMs = opts.idleMs ?? DEFAULT_IDLE_MS;
102
+ // Fail loud rather than reap everything. A caller-supplied `0` survives `??`
103
+ // and would close every task including one created a millisecond ago; a
104
+ // non-numeric value makes every `>=` comparison false and silently disables
105
+ // idle reaping. Both are worse than an error.
106
+ if (!Number.isFinite(idleMs) || idleMs <= 0) {
107
+ throw new Error(`idleMs must be a positive number of milliseconds, got ${String(idleMs)}`);
108
+ }
109
+ const now = opts.now ?? Date.now();
110
+ const deps = opts.deps ?? {};
111
+ const live = resolveLiveIdentities(deps);
112
+ const closed = [];
113
+ let skipped = 0;
114
+ for (const { profile, task } of service.listTasks()) {
115
+ if ((await service.recordStatus(task.name)).recording) {
116
+ skipped++;
117
+ continue;
118
+ }
119
+ let reason;
120
+ if (await taskOwnerIsGone(task, live, deps)) {
121
+ reason = 'session-dead';
122
+ }
123
+ else if (now - (task.lastActionAt ?? task.createdAt) >= idleMs) {
124
+ reason = 'idle';
125
+ }
126
+ if (!reason) {
127
+ skipped++;
128
+ continue;
129
+ }
130
+ if (opts.dryRun) {
131
+ closed.push({ task: task.name, profile, reason });
132
+ continue;
133
+ }
134
+ const result = await service.stop(task.name);
135
+ if (result.ok) {
136
+ closed.push({ task: task.name, profile: result.profile ?? profile, reason });
137
+ }
138
+ else {
139
+ // `stop` reports not-found only when the task already left the map — a
140
+ // concurrent `done`, or a profile torn down mid-pass. Nothing was closed,
141
+ // so it is not reported as closed.
142
+ skipped++;
143
+ }
144
+ }
145
+ return { closed, skipped };
146
+ }
@@ -340,6 +340,7 @@ export class BrowserIPCServer {
340
340
  url: request.url,
341
341
  endpointName: request.endpoint,
342
342
  skipDomainSkill: request.skipDomainSkill,
343
+ fresh: request.fresh,
343
344
  actor: request.actor,
344
345
  launchId: request.launchId,
345
346
  sessionId: request.sessionId,
@@ -352,6 +353,17 @@ export class BrowserIPCServer {
352
353
  skill: result.skill,
353
354
  };
354
355
  }
356
+ // The out-of-process seam onto the abandoned-task reaper: the daemon
357
+ // owns the live BrowserService, so a CLI verb (`agents browser gc`) can
358
+ // only reach `reapAbandoned` through IPC. The daemon's own periodic tick
359
+ // calls the service method directly.
360
+ case 'gc': {
361
+ const reaped = await this.service.reapAbandoned({
362
+ idleMs: request.idleMinutes !== undefined ? request.idleMinutes * 60_000 : undefined,
363
+ dryRun: request.dryRun,
364
+ });
365
+ return { ok: true, reaped };
366
+ }
355
367
  case 'done': {
356
368
  if (!request.task) {
357
369
  return { ok: false, error: 'Task required' };
@@ -1,5 +1,6 @@
1
1
  import { type ResolvedDomainSkill } from './domain-skills.js';
2
- import { type TabInfo, type ProfileStatus, type HistoricalTask } from './types.js';
2
+ import { type Task, type TabInfo, type ProfileStatus, type HistoricalTask, type ReapResult } from './types.js';
3
+ import { type ReapOptions } from './hygiene.js';
3
4
  import { type RefOpts, type RefNode } from './refs.js';
4
5
  import type { TargetFilter } from './types.js';
5
6
  export type UploadMode = 'auto' | 'input' | 'drop' | 'chooser';
@@ -77,11 +78,17 @@ export declare class BrowserService {
77
78
  private networkRequests;
78
79
  private pendingDownloads;
79
80
  private enabledSessions;
81
+ /** Profile -> when a `touchTask` last persisted tasks.json for it. */
82
+ private lastTouchPersist;
83
+ /** Coalescing window for the `touchTask` write. See {@link touchTask}. */
84
+ private static readonly TOUCH_PERSIST_INTERVAL_MS;
80
85
  start(profileName: string, opts?: {
81
86
  taskName?: string;
82
87
  url?: string;
83
88
  endpointName?: string;
84
89
  skipDomainSkill?: boolean;
90
+ /** Always open a new tab, skipping the abandoned-task reclaim. */
91
+ fresh?: boolean;
85
92
  /** Caller identity, forwarded from the CLI (see IPCRequest.actor/launchId). */
86
93
  actor?: string;
87
94
  launchId?: string;
@@ -95,6 +102,54 @@ export declare class BrowserService {
95
102
  profile: string;
96
103
  skill?: ResolvedDomainSkill;
97
104
  }>;
105
+ /**
106
+ * Reclaim a live page already showing `url` from an ABANDONED task, instead
107
+ * of opening a second copy of the same page (RUSH-2622). Returns its CDP
108
+ * targetId, or undefined when there is nothing safe to reclaim.
109
+ *
110
+ * "Safe" is narrow on purpose, because both of the wider readings close a tab
111
+ * somebody is using:
112
+ *
113
+ * - An UNOWNED matching page is NOT taken. No task claims it, which most
114
+ * often means the user opened it themselves — and adopting it would make
115
+ * this task's `done` close the user's tab.
116
+ * - A page owned by a LIVE task is NOT taken. Stealing it would leave that
117
+ * agent's `screenshot`/`click` throwing "No tabs open for this task", its
118
+ * `navigate` silently opening the duplicate this feature exists to
119
+ * prevent, and any in-flight recording truncated when the new owner calls
120
+ * `done`.
121
+ *
122
+ * What is left is exactly the pile-up case: a page held by a task whose owner
123
+ * is provably gone. Liveness uses the reaper's own predicate
124
+ * (`taskOwnerIsGone`), so "dead" has one definition in this codebase rather
125
+ * than two that can drift. A task the reaper cannot prove dead keeps its tab
126
+ * and is closed later by the idle rule instead.
127
+ *
128
+ * Reclaiming transfers rather than shares: two tasks holding one targetId
129
+ * means the first `done` closes the other's tab. `start --fresh` skips this
130
+ * path entirely.
131
+ *
132
+ * URLs are compared canonically (`new URL(...).href`), so a requested
133
+ * `https://example.com` matches the `https://example.com/` Chrome reports. A
134
+ * page that has since redirected elsewhere simply does not match, and the
135
+ * caller opens a tab as before.
136
+ */
137
+ private adoptTabShowing;
138
+ /**
139
+ * Every live task across every connected profile — the reaper's input
140
+ * (`hygiene.ts`). A read-only view: mutating a returned `Task` mutates the
141
+ * daemon's live state, so callers only read it and act through `stop`.
142
+ */
143
+ listTasks(): Array<{
144
+ profile: string;
145
+ task: Task;
146
+ }>;
147
+ /**
148
+ * Close tasks whose owning agent session is gone, or that have sat untouched
149
+ * past the idle window. The entry point the daemon's periodic tick and
150
+ * `agents browser gc` both call; the policy lives in `hygiene.ts`.
151
+ */
152
+ reapAbandoned(opts?: ReapOptions): Promise<ReapResult>;
98
153
  stop(taskName: string): Promise<{
99
154
  ok: boolean;
100
155
  profile?: string;
@@ -281,6 +336,25 @@ export declare class BrowserService {
281
336
  private getOrCreateWindow;
282
337
  private hasTaskNamed;
283
338
  private generateUniqueTaskName;
339
+ /**
340
+ * Mark a task as active, so the reaper's idle window measures time since the
341
+ * last real use rather than time since `start` (RUSH-2622).
342
+ *
343
+ * Called from `findTask` — the single funnel every task-scoped operation
344
+ * resolves through — rather than from each of the ~26 call sites, so a new
345
+ * action added later cannot forget to stamp it.
346
+ *
347
+ * The in-memory stamp is what the reaper reads: it runs inside the same
348
+ * daemon that owns these `Task` objects. The write to tasks.json exists only
349
+ * so a daemon RESTART does not inherit a stale stamp and reap a task an agent
350
+ * has been clicking through for the last half hour — `click`, `type`,
351
+ * `evaluate`, and `screenshot` never call `saveTaskState` on their own, so
352
+ * without this the on-disk stamp would sit at the last navigation. It is
353
+ * coalesced to at most one write per profile per minute so a screenshot loop
354
+ * does not become a write loop; the resulting on-disk stamp trails by at most
355
+ * a minute, well inside the 30-minute idle window.
356
+ */
357
+ private touchTask;
284
358
  private findTask;
285
359
  private getTabsForTask;
286
360
  private getProfileStatus;
@@ -9,6 +9,7 @@ import { connectSSH, shellQuote } from './drivers/ssh.js';
9
9
  import { clearProfileRuntime, listProfileCacheDirs, readProfileRuntimeMeta, isProcessAlive } from './runtime-state.js';
10
10
  import { resolveDomainSkill } from './domain-skills.js';
11
11
  import { generateTaskId, generateShortId, generateTaskName, } from './types.js';
12
+ import { reapAbandonedTasks, resolveLiveIdentities, taskOwnerIsGone, } from './hygiene.js';
12
13
  import { getRefs, resolveRefToCoords, describeRefs, healRef } from './refs.js';
13
14
  import { clickAtCoords, hoverAtCoords, scrollAtCoords, typeText, pressKey, focusNode } from './input.js';
14
15
  import { typeEditorText } from './editor.js';
@@ -17,6 +18,20 @@ import { emit } from '../events.js';
17
18
  import { resolveActor } from '../actor.js';
18
19
  import { recordBrowserSession } from '../session/db.js';
19
20
  import { sshExecAsync } from '../ssh-exec.js';
21
+ /**
22
+ * Canonical form for comparing a requested URL against what CDP reports for a
23
+ * live target. `new URL('https://example.com').href` is `https://example.com/`,
24
+ * which is exactly what Chrome reports — comparing the raw strings would miss
25
+ * every bare-origin match. Unparseable input compares as itself.
26
+ */
27
+ function canonicalTabUrl(raw) {
28
+ try {
29
+ return new URL(raw).href;
30
+ }
31
+ catch {
32
+ return raw;
33
+ }
34
+ }
20
35
  function isPathInside(candidate, dir) {
21
36
  const rel = path.relative(dir, candidate);
22
37
  return rel === '' || (!!rel && !rel.startsWith('..') && !path.isAbsolute(rel));
@@ -252,6 +267,10 @@ export class BrowserService {
252
267
  networkRequests = new Map();
253
268
  pendingDownloads = new Map();
254
269
  enabledSessions = new Map(); // sessionId -> enabled domains
270
+ /** Profile -> when a `touchTask` last persisted tasks.json for it. */
271
+ lastTouchPersist = new Map();
272
+ /** Coalescing window for the `touchTask` write. See {@link touchTask}. */
273
+ static TOUCH_PERSIST_INTERVAL_MS = 60_000;
255
274
  async start(profileName, opts = {}) {
256
275
  const profile = await getProfile(profileName);
257
276
  if (!profile) {
@@ -331,23 +350,37 @@ export class BrowserService {
331
350
  // Browsers launch with --no-startup-window (session-cookie persistence,
332
351
  // see launchBrowser), so a bare `start` with no --url would otherwise
333
352
  // leave the user staring at a process with zero windows. Recreate the
334
- // old startup-window affordance: if no page target exists, open a blank
335
- // one. Deliberately NOT registered on the task — the startup window
336
- // never was either, and tasks track only tabs they created.
353
+ // old startup-window affordance: if no page target exists, open a blank one.
354
+ //
355
+ // This tab IS registered on the task below. It used to be deliberately
356
+ // unregistered ("tasks track only tabs they created") — but this daemon did
357
+ // create it, and an unregistered tab is one `done`/`stop` can never close:
358
+ // `stop` closes exactly the entries in `task.tabs`. So every bare `start`
359
+ // left a blank globe tab behind forever, which is a large share of the
360
+ // pile-up in RUSH-2622. Tabs the daemon did NOT open are still left alone —
361
+ // the branch only fires when the profile has no page target at all.
362
+ let startupBlankTargetId;
337
363
  if (!opts.url && !conn.electron) {
338
364
  const { targetInfos } = (await conn.cdp.send('Target.getTargets'));
339
365
  if (!targetInfos.some((t) => t.type === 'page')) {
340
- await conn.cdp.send('Target.createTarget', { url: 'about:blank' });
366
+ const created = (await conn.cdp.send('Target.createTarget', {
367
+ url: 'about:blank',
368
+ }));
369
+ startupBlankTargetId = created.targetId;
341
370
  this.invalidateTargetCache(conn);
342
371
  }
343
372
  }
373
+ const now = Date.now();
344
374
  const task = {
345
375
  id: taskId,
346
376
  name: taskName,
347
377
  profile: effectiveProfileName,
348
378
  tabs: {},
349
379
  currentTabId: undefined,
350
- createdAt: Date.now(),
380
+ createdAt: now,
381
+ // A brand-new task has done exactly one thing — start — so its last
382
+ // action is its creation. The reaper's idle window runs from here.
383
+ lastActionAt: now,
351
384
  pid: conn.pid,
352
385
  // Identity is forwarded from the caller (see resolveTaskIdentity): WHO
353
386
  // (owner), WHICH run (launchId), and WHICH agent session (sessionId).
@@ -355,6 +388,11 @@ export class BrowserService {
355
388
  // actor (the RUSH-2020 bug).
356
389
  ...resolveTaskIdentity({ actor: opts.actor, launchId: opts.launchId, sessionId: opts.sessionId }, () => resolveActor().id),
357
390
  };
391
+ if (startupBlankTargetId) {
392
+ const shortId = generateShortId();
393
+ task.tabs[shortId] = startupBlankTargetId;
394
+ task.currentTabId = shortId;
395
+ }
358
396
  // For Electron, get the existing window as the tab
359
397
  if (conn.electron) {
360
398
  const windowId = await this.getOrCreateWindow(conn);
@@ -400,14 +438,17 @@ export class BrowserService {
400
438
  meta: { task: taskName },
401
439
  });
402
440
  }).catch(() => { });
403
- // If URL provided, create tab directly (no about:blank)
441
+ // If URL provided, reclaim a tab an abandoned task is holding on it, else
442
+ // create one directly (no about:blank).
404
443
  let tabId;
405
444
  if (opts.url && !conn.electron) {
406
- const result = (await conn.cdp.send('Target.createTarget', {
407
- url: opts.url,
408
- }));
445
+ const adopted = opts.fresh ? undefined : await this.adoptTabShowing(conn, opts.url);
446
+ const targetId = adopted ??
447
+ (await conn.cdp.send('Target.createTarget', {
448
+ url: opts.url,
449
+ })).targetId;
409
450
  const shortId = generateShortId();
410
- task.tabs[shortId] = result.targetId;
451
+ task.tabs[shortId] = targetId;
411
452
  task.currentTabId = shortId;
412
453
  this.invalidateTargetCache(conn);
413
454
  await this.saveTaskState(effectiveProfileName, conn.tasks);
@@ -428,6 +469,110 @@ export class BrowserService {
428
469
  }
429
470
  return { task: taskId, name: taskName, tabId, profile: effectiveProfileName, skill };
430
471
  }
472
+ /**
473
+ * Reclaim a live page already showing `url` from an ABANDONED task, instead
474
+ * of opening a second copy of the same page (RUSH-2622). Returns its CDP
475
+ * targetId, or undefined when there is nothing safe to reclaim.
476
+ *
477
+ * "Safe" is narrow on purpose, because both of the wider readings close a tab
478
+ * somebody is using:
479
+ *
480
+ * - An UNOWNED matching page is NOT taken. No task claims it, which most
481
+ * often means the user opened it themselves — and adopting it would make
482
+ * this task's `done` close the user's tab.
483
+ * - A page owned by a LIVE task is NOT taken. Stealing it would leave that
484
+ * agent's `screenshot`/`click` throwing "No tabs open for this task", its
485
+ * `navigate` silently opening the duplicate this feature exists to
486
+ * prevent, and any in-flight recording truncated when the new owner calls
487
+ * `done`.
488
+ *
489
+ * What is left is exactly the pile-up case: a page held by a task whose owner
490
+ * is provably gone. Liveness uses the reaper's own predicate
491
+ * (`taskOwnerIsGone`), so "dead" has one definition in this codebase rather
492
+ * than two that can drift. A task the reaper cannot prove dead keeps its tab
493
+ * and is closed later by the idle rule instead.
494
+ *
495
+ * Reclaiming transfers rather than shares: two tasks holding one targetId
496
+ * means the first `done` closes the other's tab. `start --fresh` skips this
497
+ * path entirely.
498
+ *
499
+ * URLs are compared canonically (`new URL(...).href`), so a requested
500
+ * `https://example.com` matches the `https://example.com/` Chrome reports. A
501
+ * page that has since redirected elsewhere simply does not match, and the
502
+ * caller opens a tab as before.
503
+ */
504
+ async adoptTabShowing(conn, url) {
505
+ const { targetInfos } = (await conn.cdp.send('Target.getTargets'));
506
+ const wanted = canonicalTabUrl(url);
507
+ const matching = new Set(targetInfos
508
+ .filter((t) => t.type === 'page' && canonicalTabUrl(t.url) === wanted)
509
+ .map((t) => t.targetId));
510
+ if (matching.size === 0)
511
+ return undefined;
512
+ const candidates = [];
513
+ for (const task of conn.tasks.values()) {
514
+ for (const [shortId, cdpId] of Object.entries(task.tabs)) {
515
+ if (matching.has(cdpId))
516
+ candidates.push({ task, shortId, targetId: cdpId });
517
+ }
518
+ }
519
+ if (candidates.length === 0)
520
+ return undefined;
521
+ const live = resolveLiveIdentities();
522
+ for (const { task, shortId, targetId } of candidates) {
523
+ // An in-flight capture means the task is in use whatever its owner looks
524
+ // like — same guard the reaper applies before stopping anything.
525
+ if (this.recordings.has(task.name))
526
+ continue;
527
+ if (!(await taskOwnerIsGone(task, live)))
528
+ continue;
529
+ // Re-check the claim after the await. Nothing serializes IPC requests —
530
+ // `BrowserIPCServer` registers a per-connection `socket.on('data', async …)`
531
+ // that Node never awaits — so two concurrent `start`s can both pass the
532
+ // liveness check on one candidate and both return the same targetId,
533
+ // putting two live tasks on one tab and re-opening the double-owner bug
534
+ // this reclaim is careful to avoid. Whoever deletes it first owns it.
535
+ if (task.tabs[shortId] !== targetId)
536
+ continue;
537
+ delete task.tabs[shortId];
538
+ delete task.refDescriptors?.[shortId];
539
+ if (task.currentTabId === shortId) {
540
+ const remaining = Object.keys(task.tabs);
541
+ task.currentTabId = remaining.length > 0 ? remaining[remaining.length - 1] : undefined;
542
+ }
543
+ try {
544
+ await conn.cdp.send('Target.activateTarget', { targetId });
545
+ }
546
+ catch {
547
+ // Bringing the tab to the front is cosmetic and not every endpoint
548
+ // implements activateTarget; the tab is reclaimed either way.
549
+ }
550
+ return targetId;
551
+ }
552
+ return undefined;
553
+ }
554
+ /**
555
+ * Every live task across every connected profile — the reaper's input
556
+ * (`hygiene.ts`). A read-only view: mutating a returned `Task` mutates the
557
+ * daemon's live state, so callers only read it and act through `stop`.
558
+ */
559
+ listTasks() {
560
+ const out = [];
561
+ for (const [key, conn] of this.connections) {
562
+ for (const task of conn.tasks.values()) {
563
+ out.push({ profile: conn.profileName ?? key, task });
564
+ }
565
+ }
566
+ return out;
567
+ }
568
+ /**
569
+ * Close tasks whose owning agent session is gone, or that have sat untouched
570
+ * past the idle window. The entry point the daemon's periodic tick and
571
+ * `agents browser gc` both call; the policy lives in `hygiene.ts`.
572
+ */
573
+ async reapAbandoned(opts = {}) {
574
+ return reapAbandonedTasks(this, opts);
575
+ }
431
576
  async stop(taskName) {
432
577
  for (const [profileName, conn] of this.connections) {
433
578
  const task = conn.tasks.get(taskName);
@@ -1877,6 +2022,41 @@ export class BrowserService {
1877
2022
  }
1878
2023
  throw new Error('Could not generate unique task name after 8 attempts');
1879
2024
  }
2025
+ /**
2026
+ * Mark a task as active, so the reaper's idle window measures time since the
2027
+ * last real use rather than time since `start` (RUSH-2622).
2028
+ *
2029
+ * Called from `findTask` — the single funnel every task-scoped operation
2030
+ * resolves through — rather than from each of the ~26 call sites, so a new
2031
+ * action added later cannot forget to stamp it.
2032
+ *
2033
+ * The in-memory stamp is what the reaper reads: it runs inside the same
2034
+ * daemon that owns these `Task` objects. The write to tasks.json exists only
2035
+ * so a daemon RESTART does not inherit a stale stamp and reap a task an agent
2036
+ * has been clicking through for the last half hour — `click`, `type`,
2037
+ * `evaluate`, and `screenshot` never call `saveTaskState` on their own, so
2038
+ * without this the on-disk stamp would sit at the last navigation. It is
2039
+ * coalesced to at most one write per profile per minute so a screenshot loop
2040
+ * does not become a write loop; the resulting on-disk stamp trails by at most
2041
+ * a minute, well inside the 30-minute idle window.
2042
+ */
2043
+ async touchTask(conn, task) {
2044
+ const now = Date.now();
2045
+ task.lastActionAt = now;
2046
+ const last = this.lastTouchPersist.get(task.profile) ?? 0;
2047
+ if (now - last < BrowserService.TOUCH_PERSIST_INTERVAL_MS)
2048
+ return;
2049
+ this.lastTouchPersist.set(task.profile, now);
2050
+ try {
2051
+ await this.saveTaskState(task.profile, conn.tasks);
2052
+ }
2053
+ catch {
2054
+ // Durability here is best-effort — the in-memory stamp above is
2055
+ // authoritative for the running daemon — and an unwritable runtime dir
2056
+ // must not turn a working browser action into a failure. Same guard the
2057
+ // `recordBrowserSession` call in `start` uses for the same reason.
2058
+ }
2059
+ }
1880
2060
  async findTask(taskId, profileName) {
1881
2061
  if (profileName) {
1882
2062
  const conn = this.connections.get(profileName);
@@ -1887,11 +2067,13 @@ export class BrowserService {
1887
2067
  if (!task) {
1888
2068
  throw new Error(`Task "${taskId}" not found on profile "${profileName}"`);
1889
2069
  }
2070
+ await this.touchTask(conn, task);
1890
2071
  return { conn, task, profileName };
1891
2072
  }
1892
2073
  for (const [key, conn] of this.connections) {
1893
2074
  const task = conn.tasks.get(taskId);
1894
2075
  if (task) {
2076
+ await this.touchTask(conn, task);
1895
2077
  return { conn, task, profileName: conn.profileName ?? key };
1896
2078
  }
1897
2079
  }
@@ -2055,11 +2237,19 @@ export class BrowserService {
2055
2237
  tabs,
2056
2238
  currentTabId: tabIds.length > 0 ? tabIds[tabIds.length - 1] : undefined,
2057
2239
  createdAt: task.createdAt,
2240
+ lastActionAt: task.lastActionAt ?? task.createdAt,
2058
2241
  pid: task.pid,
2059
2242
  });
2060
2243
  }
2061
2244
  else {
2062
- tasks.set(key, task);
2245
+ const loaded = task;
2246
+ // Tasks persisted before RUSH-2622 carry no lastActionAt. Normalize on
2247
+ // read so every in-memory task has one: its own createdAt is the last
2248
+ // moment we can prove the task did anything.
2249
+ if (typeof loaded.lastActionAt !== 'number') {
2250
+ loaded.lastActionAt = loaded.createdAt;
2251
+ }
2252
+ tasks.set(key, loaded);
2063
2253
  }
2064
2254
  }
2065
2255
  // Save migrated data back to disk