@phnx-labs/agents-cli 1.22.37 → 1.22.38
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 +42 -0
- package/README.md +8 -8
- package/dist/bin/agents +0 -0
- package/dist/bootstrap.js +1 -0
- 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/exec.js +1 -1
- 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/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/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/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/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
|
@@ -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
|
|
@@ -77,6 +77,20 @@ export interface Task {
|
|
|
77
77
|
tabs: Record<string, string>;
|
|
78
78
|
currentTabId?: string;
|
|
79
79
|
createdAt: number;
|
|
80
|
+
/**
|
|
81
|
+
* When this task last did anything. Stamped at creation (equal to
|
|
82
|
+
* `createdAt`) and refreshed by every task-scoped action, because every one
|
|
83
|
+
* of them resolves the task through `BrowserService.findTask`.
|
|
84
|
+
*
|
|
85
|
+
* Read by the idle half of the abandoned-task reaper (`hygiene.ts`): a task
|
|
86
|
+
* nobody has driven for `idleMs` is stopped so its tabs stop piling up in the
|
|
87
|
+
* profile window (RUSH-2622).
|
|
88
|
+
*
|
|
89
|
+
* Persisted in tasks.json. Tasks written before RUSH-2622 carry none;
|
|
90
|
+
* `loadTaskState` normalizes those to `createdAt` on read, so every task in
|
|
91
|
+
* memory has one and the reaper never has to guess.
|
|
92
|
+
*/
|
|
93
|
+
lastActionAt: number;
|
|
80
94
|
pid: number;
|
|
81
95
|
/**
|
|
82
96
|
* Resolved actor id (`resolveActor().id`) stamped at task start — WHO launched
|
|
@@ -151,7 +165,24 @@ export interface HistoricalTask {
|
|
|
151
165
|
/** Actor id who launched the task (RUSH-2020); absent for pre-RUSH-2020 history. */
|
|
152
166
|
owner?: string;
|
|
153
167
|
}
|
|
154
|
-
|
|
168
|
+
/**
|
|
169
|
+
* Why the reaper stopped a task. `session-dead` — the agent session (or run)
|
|
170
|
+
* that started it is provably gone; `idle` — nothing has driven it for the
|
|
171
|
+
* idle window. See `hygiene.ts`.
|
|
172
|
+
*/
|
|
173
|
+
export type ReapReason = 'session-dead' | 'idle';
|
|
174
|
+
/** One task the reaper stopped (or, under `dryRun`, would have stopped). */
|
|
175
|
+
export interface ReapedTask {
|
|
176
|
+
task: string;
|
|
177
|
+
profile: string;
|
|
178
|
+
reason: ReapReason;
|
|
179
|
+
}
|
|
180
|
+
export interface ReapResult {
|
|
181
|
+
closed: ReapedTask[];
|
|
182
|
+
/** Tasks left alone this pass: still live, still inside the idle window, or recording. */
|
|
183
|
+
skipped: number;
|
|
184
|
+
}
|
|
185
|
+
export type IPCAction = 'start' | 'gc' | 'record-start' | 'record-stop' | 'done' | 'stop' | 'status' | 'history' | 'navigate' | 'tab-add' | 'tab-focus' | 'tab-close' | 'tab-list' | 'evaluate' | 'screenshot' | 'pdf' | 'refs' | 'click' | 'type' | 'press' | 'hover' | 'scroll' | 'set-viewport' | 'set-device' | 'console' | 'errors' | 'requests' | 'response-body' | 'wait' | 'set-download-path' | 'wait-download' | 'upload' | 'getAppLogs' | 'version';
|
|
155
186
|
export interface IPCRequest {
|
|
156
187
|
action: IPCAction;
|
|
157
188
|
task?: string;
|
|
@@ -201,6 +232,16 @@ export interface IPCRequest {
|
|
|
201
232
|
until?: string;
|
|
202
233
|
appLevel?: string;
|
|
203
234
|
skipDomainSkill?: boolean;
|
|
235
|
+
/**
|
|
236
|
+
* Browser start: always open a new tab. Without it, `start --url` reclaims a
|
|
237
|
+
* tab that an ABANDONED task is still holding on this exact URL rather than
|
|
238
|
+
* opening a duplicate (RUSH-2622). A tab held by a live task, or one nobody's
|
|
239
|
+
* task owns (the user's own), is never taken either way — set this when the
|
|
240
|
+
* caller wants its own tab regardless.
|
|
241
|
+
*/
|
|
242
|
+
fresh?: boolean;
|
|
243
|
+
idleMinutes?: number;
|
|
244
|
+
dryRun?: boolean;
|
|
204
245
|
actor?: string;
|
|
205
246
|
launchId?: string;
|
|
206
247
|
/**
|
|
@@ -236,6 +277,8 @@ export interface IPCResponse {
|
|
|
236
277
|
tabs?: TabInfo[];
|
|
237
278
|
profiles?: ProfileStatus[];
|
|
238
279
|
history?: HistoricalTask[];
|
|
280
|
+
/** `gc`: what the abandoned-task reaper closed (or would close under dryRun). */
|
|
281
|
+
reaped?: ReapResult;
|
|
239
282
|
result?: unknown;
|
|
240
283
|
path?: string;
|
|
241
284
|
bytes?: number;
|
package/dist/lib/git.d.ts
CHANGED
|
@@ -123,7 +123,7 @@ export declare function _resetSnapshotShaCacheForTest(): void;
|
|
|
123
123
|
*/
|
|
124
124
|
export declare function getGitHubUsername(): Promise<string | null>;
|
|
125
125
|
/**
|
|
126
|
-
* Resolve the GitHub username synchronously for `agents share`. Order:
|
|
126
|
+
* Resolve the GitHub username synchronously for `agents artifacts share`. Order:
|
|
127
127
|
* 1. `AGENTS_SHARE_GITHUB_USER` env override
|
|
128
128
|
* 2. `git config --global github.user`
|
|
129
129
|
* Falls back to null so callers can decide whether to require auth or proceed
|
package/dist/lib/git.js
CHANGED
|
@@ -437,7 +437,7 @@ function readGitConfigUser() {
|
|
|
437
437
|
}
|
|
438
438
|
}
|
|
439
439
|
/**
|
|
440
|
-
* Resolve the GitHub username synchronously for `agents share`. Order:
|
|
440
|
+
* Resolve the GitHub username synchronously for `agents artifacts share`. Order:
|
|
441
441
|
* 1. `AGENTS_SHARE_GITHUB_USER` env override
|
|
442
442
|
* 2. `git config --global github.user`
|
|
443
443
|
* Falls back to null so callers can decide whether to require auth or proceed
|
|
Binary file
|
|
Binary file
|
package/dist/lib/routines.d.ts
CHANGED
|
@@ -79,7 +79,9 @@ export interface LinearJobTrigger {
|
|
|
79
79
|
teamKey?: string;
|
|
80
80
|
/** Required issue label name. */
|
|
81
81
|
label?: string;
|
|
82
|
-
/**
|
|
82
|
+
/** Fire on a transition INTO this Linear state (e.g. `Plan`): the current state
|
|
83
|
+
* must match AND the delivery's `updatedFrom` must record a state change, so a
|
|
84
|
+
* later edit that leaves the issue in this state does not re-fire (RUSH-2539). */
|
|
83
85
|
stateTo?: string;
|
|
84
86
|
/** Previous Linear state name that must match (e.g. `Triage`). */
|
|
85
87
|
stateFrom?: string;
|
|
Binary file
|
|
Binary file
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
// Cloudflare Web Analytics injection for `agents share`.
|
|
1
|
+
// Cloudflare Web Analytics injection for `agents artifacts share`.
|
|
2
2
|
//
|
|
3
3
|
// The beacon is cookieless and privacy-first, which matters because a lot of shared
|
|
4
4
|
// content (games, kid-facing pages) should avoid GA4-style tracking. The token is
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Render an HTML file to a 1200×630 PNG — the Open Graph cover for a shared plan.
|
|
3
3
|
*
|
|
4
|
-
* When `agents share plan.html` runs, we screenshot the plan's own hero and use it
|
|
4
|
+
* When `agents artifacts share plan.html` runs, we screenshot the plan's own hero and use it
|
|
5
5
|
* as the `og:image`, so the link unfurls into a card in Slack / iMessage / Twitter /
|
|
6
6
|
* Discord. No AI, no central render service: it's a headless screenshot on the
|
|
7
7
|
* publisher's machine, so it works identically for us and for any user, and costs
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Render an HTML file to a 1200×630 PNG — the Open Graph cover for a shared plan.
|
|
3
3
|
*
|
|
4
|
-
* When `agents share plan.html` runs, we screenshot the plan's own hero and use it
|
|
4
|
+
* When `agents artifacts share plan.html` runs, we screenshot the plan's own hero and use it
|
|
5
5
|
* as the `og:image`, so the link unfurls into a card in Slack / iMessage / Twitter /
|
|
6
6
|
* Discord. No AI, no central render service: it's a headless screenshot on the
|
|
7
7
|
* publisher's machine, so it works identically for us and for any user, and costs
|
|
@@ -112,7 +112,7 @@ export async function captureCover(htmlPath, timeoutMs = 15_000) {
|
|
|
112
112
|
// A silently-dropped cover reads as "the CLI decided this page needs none",
|
|
113
113
|
// when in fact no headless browser was found. Say so and point at the escape
|
|
114
114
|
// hatch instead of leaving the publish coverless with no explanation.
|
|
115
|
-
process.stderr.write('[agents share] no headless browser found for the OG cover — install Chrome/Chromium ' +
|
|
115
|
+
process.stderr.write('[agents artifacts share] no headless browser found for the OG cover — install Chrome/Chromium ' +
|
|
116
116
|
'or set AGENTS_SHARE_BROWSER=/path/to/chrome. Publishing without a preview image.\n');
|
|
117
117
|
return null;
|
|
118
118
|
}
|
|
@@ -159,7 +159,7 @@ export async function captureCover(htmlPath, timeoutMs = 15_000) {
|
|
|
159
159
|
}
|
|
160
160
|
// Every candidate ran but none yielded a cover — surface the last reason so a
|
|
161
161
|
// missing preview card is diagnosable (timeout, crash, bad binary) rather than silent.
|
|
162
|
-
process.stderr.write(`[agents share] OG cover capture failed (${lastFailure || 'unknown error'}) — ` +
|
|
162
|
+
process.stderr.write(`[agents artifacts share] OG cover capture failed (${lastFailure || 'unknown error'}) — ` +
|
|
163
163
|
'publishing without a preview image. Set AGENTS_SHARE_BROWSER to override.\n');
|
|
164
164
|
return null;
|
|
165
165
|
}
|
|
@@ -22,7 +22,7 @@ export declare const DEFAULT_BUCKET_NAME = "agents-share";
|
|
|
22
22
|
export declare const DEFAULT_SHARE_DOMAIN = "share.agents-cli.sh";
|
|
23
23
|
/** The write token may be injected ephemerally into fleet/cloud agents. */
|
|
24
24
|
export declare function readWriteTokenEnv(env?: NodeJS.ProcessEnv): string | null;
|
|
25
|
-
/** Read the persisted endpoint config, or null if `agents share
|
|
25
|
+
/** Read the persisted endpoint config, or null if `agents artifacts setup` / `agents artifacts share join` never ran. */
|
|
26
26
|
export declare function readShareConfig(): ShareConfig | null;
|
|
27
27
|
/** Persist the endpoint config to `agents.yaml` (syncs across the fleet). */
|
|
28
28
|
export declare function writeShareConfig(cfg: ShareConfig): void;
|
|
@@ -46,7 +46,7 @@ export declare function readWriteToken(): string;
|
|
|
46
46
|
* launch. The read is now always `agentOnly` — it resolves the token only from the
|
|
47
47
|
* injected env or an already-held / no-ACL bundle, and silently returns undefined
|
|
48
48
|
* otherwise (the caller runs without auto-share; the agent can still publish via
|
|
49
|
-
* its own `agents share`). To get zero-friction auto-share with no prompt: unlock
|
|
49
|
+
* its own `agents artifacts share`). To get zero-friction auto-share with no prompt: unlock
|
|
50
50
|
* once (`agents secrets unlock share`) or make it no-ACL (`agents secrets policy
|
|
51
51
|
* share never`). */
|
|
52
52
|
export declare function shareRuntimeEnv(): Record<string, string> | undefined;
|
package/dist/lib/share/config.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
// Config + credential glue for `agents share`.
|
|
1
|
+
// Config + credential glue for `agents artifacts share`.
|
|
2
2
|
//
|
|
3
3
|
// - The endpoint config (base URL, account, worker/bucket names) lives in
|
|
4
4
|
// `agents.yaml` under `share:` (Meta.share) so it syncs fleet-wide via
|
|
@@ -23,7 +23,7 @@ export function readWriteTokenEnv(env = process.env) {
|
|
|
23
23
|
const token = env[SHARE_TOKEN_ENV_KEY]?.trim();
|
|
24
24
|
return token ? token : null;
|
|
25
25
|
}
|
|
26
|
-
/** Read the persisted endpoint config, or null if `agents share
|
|
26
|
+
/** Read the persisted endpoint config, or null if `agents artifacts setup` / `agents artifacts share join` never ran. */
|
|
27
27
|
export function readShareConfig() {
|
|
28
28
|
const s = readMeta().share;
|
|
29
29
|
if (!s?.baseUrl || !s.accountId || !s.workerName || !s.bucketName)
|
|
@@ -56,7 +56,7 @@ export function storeWriteToken(token) {
|
|
|
56
56
|
catch {
|
|
57
57
|
bundle = {
|
|
58
58
|
name: SHARE_BUNDLE,
|
|
59
|
-
description: 'agents share — write token for the R2 share endpoint',
|
|
59
|
+
description: 'agents artifacts share — write token for the R2 share endpoint',
|
|
60
60
|
// A NEW share bundle defaults to the `never` tier (no biometry ACL). The R2
|
|
61
61
|
// write token is low-sensitivity automation infra that is auto-read on EVERY
|
|
62
62
|
// `agents run` (shareRuntimeEnv) — a biometry ACL there is what produced the
|
|
@@ -83,7 +83,7 @@ export function readWriteTokenFromBundle() {
|
|
|
83
83
|
const token = env[SHARE_TOKEN_KEY];
|
|
84
84
|
if (!token) {
|
|
85
85
|
throw new Error(`No ${SHARE_TOKEN_KEY} in the '${SHARE_BUNDLE}' secrets bundle. ` +
|
|
86
|
-
`Run 'agents
|
|
86
|
+
`Run 'agents artifacts setup' (to provision your own endpoint) or 'agents artifacts share join' (to use an existing one).`);
|
|
87
87
|
}
|
|
88
88
|
return token;
|
|
89
89
|
}
|
|
@@ -102,7 +102,7 @@ export function readWriteToken() {
|
|
|
102
102
|
* launch. The read is now always `agentOnly` — it resolves the token only from the
|
|
103
103
|
* injected env or an already-held / no-ACL bundle, and silently returns undefined
|
|
104
104
|
* otherwise (the caller runs without auto-share; the agent can still publish via
|
|
105
|
-
* its own `agents share`). To get zero-friction auto-share with no prompt: unlock
|
|
105
|
+
* its own `agents artifacts share`). To get zero-friction auto-share with no prompt: unlock
|
|
106
106
|
* once (`agents secrets unlock share`) or make it no-ACL (`agents secrets policy
|
|
107
107
|
* share never`). */
|
|
108
108
|
export function shareRuntimeEnv() {
|
package/dist/lib/share/delete.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
// The delete path for `agents share delete` / `agents unshare` — an authed DELETE
|
|
1
|
+
// The delete path for `agents artifacts share delete` / `agents unshare` — an authed DELETE
|
|
2
2
|
// to the Worker, which already implements it (worker-template.ts). Mirrors
|
|
3
3
|
// publish.ts: pure target-resolution logic is exported for tests, the network
|
|
4
4
|
// calls (a status check + a delete) sit behind an injectable DI seam.
|
|
@@ -76,7 +76,7 @@ export async function deleteObject(endpoint, key, opts = {}) {
|
|
|
76
76
|
const existedBefore = before.status !== 404;
|
|
77
77
|
const r = await del(url, { authorization: `Bearer ${endpoint.token}` });
|
|
78
78
|
if (!r.ok) {
|
|
79
|
-
throw new Error(`Delete failed (${r.status}) for ${url}. Check the write token, or that 'agents
|
|
79
|
+
throw new Error(`Delete failed (${r.status}) for ${url}. Check the write token, or that 'agents artifacts setup' completed.`);
|
|
80
80
|
}
|
|
81
81
|
const after = await check(url);
|
|
82
82
|
const verified404 = after.status === 404;
|
|
@@ -88,7 +88,7 @@ export async function deleteObject(endpoint, key, opts = {}) {
|
|
|
88
88
|
export async function deleteShare(target, opts = {}) {
|
|
89
89
|
const cfg = opts.config ?? readShareConfig();
|
|
90
90
|
if (!cfg) {
|
|
91
|
-
throw new Error("Not set up yet. Run 'agents
|
|
91
|
+
throw new Error("Not set up yet. Run 'agents artifacts setup' (provision your own endpoint) or 'agents artifacts share join' (use an existing one).");
|
|
92
92
|
}
|
|
93
93
|
const token = opts.writeToken ?? readWriteToken();
|
|
94
94
|
const resolved = await resolveDeleteTarget(target, { githubUser: opts.githubUser });
|