@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.
Files changed (50) hide show
  1. package/CHANGELOG.md +42 -0
  2. package/README.md +8 -8
  3. package/dist/bin/agents +0 -0
  4. package/dist/bootstrap.js +1 -0
  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/exec.js +1 -1
  11. package/dist/commands/models.js +67 -0
  12. package/dist/commands/setup.js +5 -5
  13. package/dist/commands/share.d.ts +20 -7
  14. package/dist/commands/share.js +74 -75
  15. package/dist/lib/browser/hygiene.d.ts +90 -0
  16. package/dist/lib/browser/hygiene.js +146 -0
  17. package/dist/lib/browser/ipc.js +12 -0
  18. package/dist/lib/browser/service.d.ts +75 -1
  19. package/dist/lib/browser/service.js +201 -11
  20. package/dist/lib/browser/types.d.ts +44 -1
  21. package/dist/lib/git.d.ts +1 -1
  22. package/dist/lib/git.js +1 -1
  23. package/dist/lib/menubar/MenubarHelper.app/Contents/CodeResources +0 -0
  24. package/dist/lib/menubar/MenubarHelper.app/Contents/MacOS/MenubarHelper +0 -0
  25. package/dist/lib/routines.d.ts +3 -1
  26. package/dist/lib/secrets/Agents CLI.app/Contents/CodeResources +0 -0
  27. package/dist/lib/secrets/Agents CLI.app/Contents/MacOS/Agents CLI +0 -0
  28. package/dist/lib/share/analytics.js +1 -1
  29. package/dist/lib/share/capture.d.ts +1 -1
  30. package/dist/lib/share/capture.js +3 -3
  31. package/dist/lib/share/config.d.ts +2 -2
  32. package/dist/lib/share/config.js +5 -5
  33. package/dist/lib/share/delete.js +3 -3
  34. package/dist/lib/share/provision.js +3 -3
  35. package/dist/lib/share/publish.d.ts +1 -1
  36. package/dist/lib/share/publish.js +3 -3
  37. package/dist/lib/share/worker-template.d.ts +12 -1
  38. package/dist/lib/share/worker-template.js +13 -2
  39. package/dist/lib/startup/command-registry.d.ts +10 -2
  40. package/dist/lib/startup/command-registry.js +16 -7
  41. package/dist/lib/tmux/orphan-reap.d.ts +15 -19
  42. package/dist/lib/tmux/orphan-reap.js +15 -21
  43. package/dist/lib/tmux/session.js +4 -3
  44. package/dist/lib/triggers/handlers.js +10 -0
  45. package/dist/lib/triggers/webhook.js +10 -0
  46. package/dist/lib/types.d.ts +2 -2
  47. package/package.json +1 -1
  48. package/dist/commands/set.d.ts +0 -15
  49. package/dist/commands/set.js +0 -79
  50. 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
- // 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
@@ -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
- export type IPCAction = 'start' | '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';
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
@@ -79,7 +79,9 @@ export interface LinearJobTrigger {
79
79
  teamKey?: string;
80
80
  /** Required issue label name. */
81
81
  label?: string;
82
- /** Current Linear state name that must match (e.g. `Plan`). */
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;
@@ -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 setup`/`join` never ran. */
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;
@@ -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 setup`/`join` never ran. */
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 share setup' (to provision your own endpoint) or 'agents share join' (to use an existing one).`);
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() {
@@ -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 share setup' completed.`);
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 share setup' (provision your own endpoint) or 'agents share join' (use an existing one).");
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 });