@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.
- package/CHANGELOG.md +50 -0
- package/README.md +8 -8
- package/dist/bin/agents +0 -0
- package/dist/bootstrap.js +3 -2
- package/dist/commands/artifacts-setup.d.ts +53 -0
- package/dist/commands/{setup-share.js → artifacts-setup.js} +59 -13
- package/dist/commands/artifacts.d.ts +18 -0
- package/dist/commands/artifacts.js +58 -0
- package/dist/commands/browser.js +2 -0
- package/dist/commands/config.js +31 -1
- package/dist/commands/exec.js +2 -2
- package/dist/commands/models.js +67 -0
- package/dist/commands/setup.js +5 -5
- package/dist/commands/share.d.ts +20 -7
- package/dist/commands/share.js +74 -75
- package/dist/commands/ssh.js +156 -8
- package/dist/lib/browser/hygiene.d.ts +90 -0
- package/dist/lib/browser/hygiene.js +146 -0
- package/dist/lib/browser/ipc.js +12 -0
- package/dist/lib/browser/service.d.ts +75 -1
- package/dist/lib/browser/service.js +201 -11
- package/dist/lib/browser/types.d.ts +44 -1
- package/dist/lib/config-keys.d.ts +11 -3
- package/dist/lib/config-keys.js +22 -3
- package/dist/lib/config-machine-keys.js +1 -0
- package/dist/lib/device-config.d.ts +34 -0
- package/dist/lib/device-config.js +96 -0
- package/dist/lib/devices/pool.d.ts +56 -0
- package/dist/lib/devices/pool.js +85 -0
- package/dist/lib/exec.d.ts +4 -1
- package/dist/lib/exec.js +8 -2
- package/dist/lib/git.d.ts +1 -1
- package/dist/lib/git.js +1 -1
- package/dist/lib/menubar/MenubarHelper.app/Contents/CodeResources +0 -0
- package/dist/lib/menubar/MenubarHelper.app/Contents/MacOS/MenubarHelper +0 -0
- package/dist/lib/migrate.d.ts +36 -0
- package/dist/lib/migrate.js +107 -0
- package/dist/lib/routines.d.ts +3 -1
- package/dist/lib/secrets/Agents CLI.app/Contents/CodeResources +0 -0
- package/dist/lib/secrets/Agents CLI.app/Contents/MacOS/Agents CLI +0 -0
- package/dist/lib/share/analytics.js +1 -1
- package/dist/lib/share/capture.d.ts +1 -1
- package/dist/lib/share/capture.js +3 -3
- package/dist/lib/share/config.d.ts +2 -2
- package/dist/lib/share/config.js +5 -5
- package/dist/lib/share/delete.js +3 -3
- package/dist/lib/share/provision.js +3 -3
- package/dist/lib/share/publish.d.ts +1 -1
- package/dist/lib/share/publish.js +3 -3
- package/dist/lib/share/worker-template.d.ts +12 -1
- package/dist/lib/share/worker-template.js +13 -2
- package/dist/lib/smart-launch.d.ts +33 -3
- package/dist/lib/smart-launch.js +61 -6
- package/dist/lib/startup/command-registry.d.ts +10 -2
- package/dist/lib/startup/command-registry.js +16 -7
- package/dist/lib/tmux/orphan-reap.d.ts +15 -19
- package/dist/lib/tmux/orphan-reap.js +15 -21
- package/dist/lib/tmux/session.js +4 -3
- package/dist/lib/triggers/handlers.js +10 -0
- package/dist/lib/triggers/webhook.js +10 -0
- package/dist/lib/types.d.ts +2 -2
- package/package.json +1 -1
- package/dist/commands/set.d.ts +0 -15
- package/dist/commands/set.js +0 -79
- 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
|
+
}
|
package/dist/lib/browser/ipc.js
CHANGED
|
@@ -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
|
-
//
|
|
336
|
-
//
|
|
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', {
|
|
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:
|
|
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,
|
|
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
|
|
407
|
-
|
|
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] =
|
|
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
|
-
|
|
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
|