@phnx-labs/agents-cli 1.22.33 → 1.22.35

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 (81) hide show
  1. package/CHANGELOG.md +55 -0
  2. package/README.md +9 -1
  3. package/dist/bin/agents +0 -0
  4. package/dist/commands/accounts.js +3 -3
  5. package/dist/commands/browser-sessions-picker.d.ts +16 -0
  6. package/dist/commands/browser-sessions-picker.js +179 -0
  7. package/dist/commands/browser.js +9 -4
  8. package/dist/commands/hosts.js +1 -5
  9. package/dist/commands/inspect.js +174 -41
  10. package/dist/commands/message.d.ts +6 -1
  11. package/dist/commands/message.js +60 -3
  12. package/dist/commands/sessions.d.ts +54 -4
  13. package/dist/commands/sessions.js +252 -46
  14. package/dist/commands/share.d.ts +18 -0
  15. package/dist/commands/share.js +108 -0
  16. package/dist/commands/ssh.js +17 -5
  17. package/dist/commands/teams.d.ts +28 -0
  18. package/dist/commands/teams.js +148 -13
  19. package/dist/commands/upgrade.d.ts +7 -0
  20. package/dist/commands/upgrade.js +10 -0
  21. package/dist/commands/watchdog.d.ts +2 -0
  22. package/dist/commands/watchdog.js +112 -27
  23. package/dist/index.js +51 -59
  24. package/dist/lib/agents.js +6 -0
  25. package/dist/lib/browser/sessions-list.d.ts +81 -0
  26. package/dist/lib/browser/sessions-list.js +179 -4
  27. package/dist/lib/codex-policy.d.ts +9 -1
  28. package/dist/lib/codex-policy.js +17 -2
  29. package/dist/lib/daemon.js +45 -5
  30. package/dist/lib/devices/connect.d.ts +33 -0
  31. package/dist/lib/devices/connect.js +61 -3
  32. package/dist/lib/devices/doctor-findings.d.ts +4 -2
  33. package/dist/lib/devices/doctor-findings.js +4 -2
  34. package/dist/lib/exec.js +65 -8
  35. package/dist/lib/help.d.ts +3 -2
  36. package/dist/lib/help.js +4 -0
  37. package/dist/lib/hosts/dispatch.d.ts +2 -32
  38. package/dist/lib/hosts/dispatch.js +6 -61
  39. package/dist/lib/hosts/tasks.d.ts +7 -0
  40. package/dist/lib/hosts/tasks.js +9 -0
  41. package/dist/lib/mailbox-target.d.ts +27 -0
  42. package/dist/lib/mailbox-target.js +21 -0
  43. package/dist/lib/mcp.d.ts +10 -0
  44. package/dist/lib/mcp.js +21 -2
  45. package/dist/lib/menubar/MenubarHelper.app/Contents/CodeResources +0 -0
  46. package/dist/lib/menubar/MenubarHelper.app/Contents/MacOS/MenubarHelper +0 -0
  47. package/dist/lib/migrate.d.ts +11 -0
  48. package/dist/lib/migrate.js +40 -0
  49. package/dist/lib/project-key.d.ts +17 -0
  50. package/dist/lib/project-key.js +26 -0
  51. package/dist/lib/project-root.d.ts +47 -0
  52. package/dist/lib/project-root.js +68 -0
  53. package/dist/lib/secrets/Agents CLI.app/Contents/CodeResources +0 -0
  54. package/dist/lib/secrets/Agents CLI.app/Contents/MacOS/Agents CLI +0 -0
  55. package/dist/lib/secrets/agent.d.ts +9 -2
  56. package/dist/lib/secrets/agent.js +52 -7
  57. package/dist/lib/secrets/reaper.d.ts +24 -3
  58. package/dist/lib/secrets/reaper.js +55 -6
  59. package/dist/lib/session/discover.js +12 -0
  60. package/dist/lib/session/render.d.ts +2 -0
  61. package/dist/lib/session/render.js +1 -1
  62. package/dist/lib/share/delete.d.ts +93 -0
  63. package/dist/lib/share/delete.js +127 -0
  64. package/dist/lib/shims.js +48 -4
  65. package/dist/lib/startup/command-registry.d.ts +1 -0
  66. package/dist/lib/startup/command-registry.js +11 -3
  67. package/dist/lib/startup/root-command.d.ts +3 -0
  68. package/dist/lib/startup/root-command.js +10 -0
  69. package/dist/lib/teams/agents.d.ts +136 -6
  70. package/dist/lib/teams/agents.js +324 -58
  71. package/dist/lib/teams/worktree.d.ts +39 -2
  72. package/dist/lib/teams/worktree.js +60 -4
  73. package/dist/lib/types.d.ts +11 -0
  74. package/dist/lib/versions.js +2 -2
  75. package/dist/lib/watchdog/history.d.ts +20 -0
  76. package/dist/lib/watchdog/history.js +46 -0
  77. package/dist/lib/watchdog/log.d.ts +16 -1
  78. package/dist/lib/watchdog/log.js +82 -2
  79. package/dist/lib/watchdog/runner.d.ts +12 -0
  80. package/dist/lib/watchdog/runner.js +20 -0
  81. package/package.json +1 -1
@@ -18,6 +18,7 @@ import { readMeta, updateMeta } from './state.js';
18
18
  import { getMainRepoRoot } from './git.js';
19
19
  import { toPosix } from './platform/index.js';
20
20
  import { loadProjectDef, resolveDefinedProjectPath } from './projects.js';
21
+ import { shellQuote } from './ssh-exec.js';
21
22
  const HOME = process.env.HOME ?? os.homedir();
22
23
  /** Rewrite an absolute path under the local home to a `~/`-relative string; pass others through. */
23
24
  export function toHomeRelative(abs) {
@@ -53,6 +54,73 @@ export function toRemotePortable(p) {
53
54
  return toHomeRelative(p);
54
55
  return p;
55
56
  }
57
+ /**
58
+ * If `p` is anchored at the home dir — a leading `~` or `$HOME` — return the
59
+ * remainder (no leading slash), else null. Callers that want a local-home
60
+ * absolute (`/Users/<me>/x`, from a shell-expanded `--cwd ~/x`) re-rooted at the
61
+ * remote home normalize it to `~/x` first (`toRemotePortable`); explicit
62
+ * `--remote-cwd` is left literal and so is never re-rooted here.
63
+ *
64
+ * The canonical home-anchor stripper — shared by `remoteCdPrefix`,
65
+ * `deriveMirroredCwd`, and the interactive-login shell builder
66
+ * (`devices/connect.ts`), so there is exactly one notion of "the part below the
67
+ * home dir".
68
+ */
69
+ export function homeRemainder(p) {
70
+ if (p === '~' || p === '$HOME')
71
+ return '';
72
+ if (p.startsWith('~/'))
73
+ return p.slice(2);
74
+ if (p.startsWith('$HOME/'))
75
+ return p.slice(6);
76
+ return null;
77
+ }
78
+ /**
79
+ * Derive the remote directory to mirror from the local cwd, for a host run the
80
+ * caller gave no `--cwd`/`--remote-cwd` (and for an interactive `agents ssh`
81
+ * login with no command).
82
+ *
83
+ * Without this a `--host` run — or an `agents ssh <device>` login — lands in the
84
+ * remote `$HOME`, so an agent launched from a repo starts with no project
85
+ * context and the user has to `cd` by hand. Only a cwd under the LOCAL home is
86
+ * mirrored — that is the part with a meaningful remote analogue (`~/src/x`
87
+ * re-roots onto the remote home). A path outside home returns undefined:
88
+ * `/opt/thing` on this box says nothing about the target's filesystem, so the
89
+ * run keeps the remote home.
90
+ */
91
+ export function deriveMirroredCwd(localCwd) {
92
+ const portable = toRemotePortable(localCwd);
93
+ return homeRemainder(portable) === null ? undefined : portable;
94
+ }
95
+ /**
96
+ * Build a `cd <dir> && ` prefix that resolves on the REMOTE host.
97
+ *
98
+ * A `~`/`$HOME`-anchored path must resolve against the REMOTE user's home, not
99
+ * the local one (`/home/<me>` vs `/Users/<me>`). We emit an unquoted `"$HOME"`
100
+ * for that segment — the remote login shell expands it — and shell-quote the
101
+ * remainder. Any other path (absolute or relative) is quoted verbatim.
102
+ *
103
+ * `mirror` marks a directory the caller DERIVED from the local cwd rather than
104
+ * one the user asked for (see `deriveMirroredCwd`). The same repo checked out at
105
+ * the same home-relative path on both boxes is the common fleet layout, so
106
+ * mirroring lands the remote agent in the project instead of `$HOME`. It is a
107
+ * best-effort mirror by definition — the host may simply not have that checkout
108
+ * — so a missing directory falls back to the remote home instead of failing the
109
+ * run. An explicit `--cwd`/`--remote-cwd` is never mirrored: the user named that
110
+ * directory, so a missing one must surface as a `cd` error.
111
+ */
112
+ export function remoteCdPrefix(remoteCwd, opts = {}) {
113
+ if (!remoteCwd)
114
+ return '';
115
+ const rest = homeRemainder(remoteCwd);
116
+ if (rest === '')
117
+ return 'cd "$HOME" && ';
118
+ if (rest !== null) {
119
+ const dir = `"$HOME"/${shellQuote(rest)}`;
120
+ return opts.mirror ? `{ cd ${dir} || cd "$HOME"; } && ` : `cd ${dir} && `;
121
+ }
122
+ return `cd ${shellQuote(remoteCwd)} && `;
123
+ }
56
124
  /** The configured projects root (home-relative or absolute), or undefined when unset. */
57
125
  export function getProjectRoot() {
58
126
  return readMeta().projectRoot;
@@ -263,7 +263,7 @@ export declare function brokerPidAlive(): boolean;
263
263
  export declare function runSecretsAgent(opts?: {
264
264
  service?: boolean;
265
265
  }): Promise<{
266
- close(): void;
266
+ close(): void | Promise<void>;
267
267
  } | null>;
268
268
  /**
269
269
  * Host the secrets broker inside the always-on daemon (#416).
@@ -284,8 +284,15 @@ export declare function runSecretsAgent(opts?: {
284
284
  * or null off-darwin (nothing to broker without biometry).
285
285
  */
286
286
  export declare function startHostedBroker(): Promise<{
287
- close(): void;
287
+ close(): void | Promise<void>;
288
288
  } | null>;
289
+ /**
290
+ * Call Node's net.Server.close() and wait for the 'close' event (or a bounded
291
+ * timeout). Without this, close() returned while the listen socket could still
292
+ * be held — a successor bind could race EADDRINUSE against a half-closed server
293
+ * (RUSH-2421). Pure side-effect helper; never throws.
294
+ */
295
+ export declare function closeServerBounded(server: net.Server, timeoutMs?: number): Promise<void>;
289
296
  /** True if a broker socket exists at all. Cheap; gates the sync read so the
290
297
  * never-unlocked path stays a single stat. */
291
298
  export declare function agentSocketExists(): boolean;
@@ -760,6 +760,9 @@ export async function runSecretsAgent(opts = {}) {
760
760
  }
761
761
  let watcher = null;
762
762
  let sweepTimer = null;
763
+ // Sync cleanup for SIGTERM/SIGINT → process.exit: we cannot await the
764
+ // server's 'close' event before exit, so fire-and-forget close + unlink.
765
+ // The public close() path below awaits with a bounded timeout (RUSH-2421).
763
766
  cleanupActive = () => {
764
767
  store.clear();
765
768
  if (sweepTimer)
@@ -806,12 +809,25 @@ export async function runSecretsAgent(opts = {}) {
806
809
  watcher = null;
807
810
  }
808
811
  return {
809
- close() {
812
+ async close() {
810
813
  if (shuttingDown)
811
814
  return;
812
815
  shuttingDown = true;
813
816
  detachSignals();
814
- cleanupActive?.();
817
+ store.clear();
818
+ if (sweepTimer)
819
+ clearInterval(sweepTimer);
820
+ try {
821
+ watcher?.kill();
822
+ }
823
+ catch { /* already gone */ }
824
+ await closeServerBounded(server);
825
+ try {
826
+ fs.unlinkSync(sock);
827
+ }
828
+ catch { /* gone */ }
829
+ releaseBrokerPid();
830
+ releasePid();
815
831
  },
816
832
  };
817
833
  }
@@ -877,17 +893,18 @@ export async function startHostedBroker() {
877
893
  watcher = null;
878
894
  }
879
895
  return {
880
- close() {
896
+ // RUSH-2421: await net.Server's 'close' (bounded) so a caller relying on
897
+ // close() cannot proceed past a socket that is not actually released yet.
898
+ // The daemon may fire-and-forget this promise; tests and any awaiter get
899
+ // the real release boundary.
900
+ async close() {
881
901
  store.clear();
882
902
  clearInterval(sweepTimer);
883
903
  try {
884
904
  watcher?.kill();
885
905
  }
886
906
  catch { /* already gone */ }
887
- try {
888
- server.close();
889
- }
890
- catch { /* not listening */ }
907
+ await closeServerBounded(server);
891
908
  try {
892
909
  fs.unlinkSync(sock);
893
910
  }
@@ -896,6 +913,34 @@ export async function startHostedBroker() {
896
913
  },
897
914
  };
898
915
  }
916
+ /** How long to wait for net.Server.close()'s 'close' event before giving up. */
917
+ const SERVER_CLOSE_TIMEOUT_MS = 2_000;
918
+ /**
919
+ * Call Node's net.Server.close() and wait for the 'close' event (or a bounded
920
+ * timeout). Without this, close() returned while the listen socket could still
921
+ * be held — a successor bind could race EADDRINUSE against a half-closed server
922
+ * (RUSH-2421). Pure side-effect helper; never throws.
923
+ */
924
+ export function closeServerBounded(server, timeoutMs = SERVER_CLOSE_TIMEOUT_MS) {
925
+ return new Promise((resolve) => {
926
+ let settled = false;
927
+ const finish = () => {
928
+ if (settled)
929
+ return;
930
+ settled = true;
931
+ clearTimeout(timer);
932
+ resolve();
933
+ };
934
+ const timer = setTimeout(finish, timeoutMs);
935
+ try {
936
+ server.close(() => finish());
937
+ }
938
+ catch {
939
+ // Already closed / not listening — treat as released.
940
+ finish();
941
+ }
942
+ });
943
+ }
899
944
  // ─── Client ──────────────────────────────────────────────────────────────────
900
945
  /** Open the socket, send one request, resolve the one response. Async path —
901
946
  * used by the unlock/lock/status commands, which already run in async actions. */
@@ -31,9 +31,17 @@ export interface KeychainProcessSnapshot {
31
31
  * True when this process is a REAP-ELIGIBLE helper invocation — the installed
32
32
  * helper binary running a short-lived keychain verb. False for a non-helper
33
33
  * process AND for the long-lived `watch-lock` watcher (see
34
- * {@link isReapableHelperCommand}), which must never be reaped.
34
+ * {@link isReapableHelperCommand}).
35
35
  */
36
36
  isHelper: boolean;
37
+ /**
38
+ * True when this process is the deliberately long-lived `watch-lock` watcher
39
+ * (auto-lock-on-sleep). Mutually exclusive with {@link isHelper}:
40
+ * {@link isReapableHelperCommand} keeps live-parent watch-locks out of the
41
+ * stuck-helper path, and a SEPARATE orphan path in {@link planKeychainReap}
42
+ * reaps them only once the owning daemon is provably dead (RUSH-2419).
43
+ */
44
+ isWatchLock?: boolean;
37
45
  }
38
46
  /**
39
47
  * Tracked state for a stuck `agents` parent that has a live helper child.
@@ -59,7 +67,7 @@ export interface ReapPlan {
59
67
  * Pure predicate: decide which processes to kill given a `ps`-like snapshot.
60
68
  *
61
69
  * Mirrors the shape of {@link isExpiredPoolStray} in `lib/crabbox/lease.ts`:
62
- * a side-effect-free classifier that the impure driver shells `ps` for. Two
70
+ * a side-effect-free classifier that the impure driver shells `ps` for. Three
63
71
  * conservative reap classes:
64
72
  *
65
73
  * 1. Orphaned helper: PPID == 1, path-matches the helper, alive longer than
@@ -68,9 +76,15 @@ export interface ReapPlan {
68
76
  * {@link STUCK_GRACE_SEC}. Recorded on first sight, child killed on the
69
77
  * second consecutive sweep with the same PID + startTime, parent killed on
70
78
  * the third sweep if the helper child is still present.
79
+ * 3. Orphaned `watch-lock` watcher (RUSH-2419): the long-lived auto-lock
80
+ * child whose owning daemon is provably dead. {@link isReapableHelperCommand}
81
+ * still excludes live-parent watch-locks from class 1/2; this path only
82
+ * reaps when the parent is gone from the snapshot (Unix reparents to init
83
+ * after daemon death) and the watcher itself has a start-time fingerprint.
71
84
  *
72
85
  * Never reaps a process whose start time could not be captured, whose path does
73
- * not match the helper, or whose parent is no longer in the snapshot.
86
+ * not match the helper, or (for stuck parents) whose parent is no longer in the
87
+ * snapshot. A watch-lock whose parent IS still in the snapshot is never touched.
74
88
  */
75
89
  export declare function planKeychainReap(snapshots: KeychainProcessSnapshot[], now: number, prevCandidates: ReadonlyMap<number, StuckParentCandidate>): ReapPlan;
76
90
  /**
@@ -92,6 +106,13 @@ export declare function parseEtimeToSeconds(raw: string): number | null;
92
106
  * mistaken for a stuck read and killed. Pure; unit-tested.
93
107
  */
94
108
  export declare function isReapableHelperCommand(command: string, helperPath: string): boolean;
109
+ /**
110
+ * Whether a `ps` command line is the deliberately long-lived `watch-lock`
111
+ * watcher (auto-lock-on-sleep). Inverse of {@link isReapableHelperCommand} for
112
+ * the watch-lock verb only — used by the orphaned-watch-lock reaper path
113
+ * (RUSH-2419). Pure; unit-tested.
114
+ */
115
+ export declare function isWatchLockHelperCommand(command: string, helperPath: string): boolean;
95
116
  /** Test seam: reset the persisted candidate state. */
96
117
  export declare function resetKeychainReaperCandidatesForTest(): void;
97
118
  /**
@@ -23,7 +23,7 @@ export const STUCK_GRACE_SEC = 90;
23
23
  * Pure predicate: decide which processes to kill given a `ps`-like snapshot.
24
24
  *
25
25
  * Mirrors the shape of {@link isExpiredPoolStray} in `lib/crabbox/lease.ts`:
26
- * a side-effect-free classifier that the impure driver shells `ps` for. Two
26
+ * a side-effect-free classifier that the impure driver shells `ps` for. Three
27
27
  * conservative reap classes:
28
28
  *
29
29
  * 1. Orphaned helper: PPID == 1, path-matches the helper, alive longer than
@@ -32,9 +32,15 @@ export const STUCK_GRACE_SEC = 90;
32
32
  * {@link STUCK_GRACE_SEC}. Recorded on first sight, child killed on the
33
33
  * second consecutive sweep with the same PID + startTime, parent killed on
34
34
  * the third sweep if the helper child is still present.
35
+ * 3. Orphaned `watch-lock` watcher (RUSH-2419): the long-lived auto-lock
36
+ * child whose owning daemon is provably dead. {@link isReapableHelperCommand}
37
+ * still excludes live-parent watch-locks from class 1/2; this path only
38
+ * reaps when the parent is gone from the snapshot (Unix reparents to init
39
+ * after daemon death) and the watcher itself has a start-time fingerprint.
35
40
  *
36
41
  * Never reaps a process whose start time could not be captured, whose path does
37
- * not match the helper, or whose parent is no longer in the snapshot.
42
+ * not match the helper, or (for stuck parents) whose parent is no longer in the
43
+ * snapshot. A watch-lock whose parent IS still in the snapshot is never touched.
38
44
  */
39
45
  export function planKeychainReap(snapshots, now, prevCandidates) {
40
46
  const pidMap = new Map(snapshots.map((s) => [s.pid, s]));
@@ -88,6 +94,34 @@ export function planKeychainReap(snapshots, now, prevCandidates) {
88
94
  stage: 'watch',
89
95
  });
90
96
  }
97
+ // Separate path for orphaned watch-lock watchers (RUSH-2419). Does NOT
98
+ // weaken {@link isReapableHelperCommand}: a watch-lock with a live parent
99
+ // stays isHelper=false and is skipped above. Here we only kill when the
100
+ // owning daemon is provably absent from the process table.
101
+ for (const s of snapshots) {
102
+ if (!s.isWatchLock)
103
+ continue;
104
+ // Fail closed: no start-time fingerprint → refuse to kill (pid-reuse guard
105
+ // for the process we are about to target, same as orphan helpers).
106
+ if (s.startTime == null)
107
+ continue;
108
+ if (s.elapsedSec <= ORPHAN_GRACE_SEC)
109
+ continue;
110
+ // Live parent in this snapshot → owning daemon is still up. Leave it alone
111
+ // (this is the property isReapableHelperCommand protects for stuck-helper
112
+ // reaping; re-assert it here so a mis-tagged row cannot be killed either).
113
+ if (s.ppid !== 1) {
114
+ const parent = pidMap.get(s.ppid);
115
+ if (parent)
116
+ continue;
117
+ // Parent pid not listed: the process occupying that slot is gone. On Unix
118
+ // a dead parent reparents the child to init; a missing parent with a
119
+ // non-1 ppid is the race window before reparenting. Either way the owner
120
+ // is dead — safe to reap after grace + fingerprint.
121
+ }
122
+ // ppid===1 (reparented to init/launchd) or parent missing → orphaned.
123
+ kill.push(s.pid);
124
+ }
91
125
  return { kill, nextCandidates };
92
126
  }
93
127
  /**
@@ -159,6 +193,18 @@ export function isReapableHelperCommand(command, helperPath) {
159
193
  const firstArg = command.slice(helperPath.length + 1).trimStart().split(/\s+/)[0];
160
194
  return firstArg !== HELPER_WATCH_LOCK_VERB;
161
195
  }
196
+ /**
197
+ * Whether a `ps` command line is the deliberately long-lived `watch-lock`
198
+ * watcher (auto-lock-on-sleep). Inverse of {@link isReapableHelperCommand} for
199
+ * the watch-lock verb only — used by the orphaned-watch-lock reaper path
200
+ * (RUSH-2419). Pure; unit-tested.
201
+ */
202
+ export function isWatchLockHelperCommand(command, helperPath) {
203
+ if (!command.startsWith(`${helperPath} `))
204
+ return false;
205
+ const firstArg = command.slice(helperPath.length + 1).trimStart().split(/\s+/)[0];
206
+ return firstArg === HELPER_WATCH_LOCK_VERB;
207
+ }
162
208
  /** Module-state for the two-sweep stuck-parent debounce. */
163
209
  let stuckParentCandidates = new Map();
164
210
  /** Test seam: reset the persisted candidate state. */
@@ -206,16 +252,18 @@ export function reapOrphanedKeychainProcesses() {
206
252
  // Reap-eligible = the helper binary running a short-lived keychain verb. The
207
253
  // full-argv match excludes the deliberately long-lived `watch-lock` watcher,
208
254
  // whose live-parent child would otherwise be killed as if it were stuck
209
- // (RUSH-2232 — that silently disabled auto-lock-on-sleep).
255
+ // (RUSH-2232 — that silently disabled auto-lock-on-sleep). Orphaned
256
+ // watch-locks are tracked separately via isWatchLock (RUSH-2419).
210
257
  const isHelper = isReapableHelperCommand(command, helperPath);
211
- rows.push({ pid, ppid, elapsedSec, isHelper, startTime: null });
258
+ const isWatchLock = isWatchLockHelperCommand(command, helperPath);
259
+ rows.push({ pid, ppid, elapsedSec, isHelper, isWatchLock, startTime: null });
212
260
  }
213
261
  const rowByPid = new Map(rows.map((r) => [r.pid, r]));
214
262
  for (const row of rows) {
215
- if (!row.isHelper)
263
+ if (!row.isHelper && !row.isWatchLock)
216
264
  continue;
217
265
  row.startTime = captureProcessStartTime(row.pid);
218
- if (row.ppid !== 1 && row.elapsedSec > STUCK_GRACE_SEC) {
266
+ if (row.isHelper && row.ppid !== 1 && row.elapsedSec > STUCK_GRACE_SEC) {
219
267
  const parent = rowByPid.get(row.ppid);
220
268
  if (parent && parent.startTime === null) {
221
269
  parent.startTime = captureProcessStartTime(row.ppid);
@@ -228,6 +276,7 @@ export function reapOrphanedKeychainProcesses() {
228
276
  elapsedSec: r.elapsedSec,
229
277
  startTime: r.startTime,
230
278
  isHelper: r.isHelper,
279
+ isWatchLock: r.isWatchLock,
231
280
  }));
232
281
  const plan = planKeychainReap(snapshots, Date.now(), stuckParentCandidates);
233
282
  stuckParentCandidates = plan.nextCandidates;
@@ -442,6 +442,18 @@ function normalizeCwd(cwd) {
442
442
  if (process.platform === 'win32' && /^\//.test(cwd) && !/^[a-zA-Z]:/.test(cwd)) {
443
443
  return stripTrailingSep(path.posix.normalize(cwd));
444
444
  }
445
+ // The mirror case (RUSH-2358): a Windows-rooted path (`C:\...`, `C:/...`, or a
446
+ // UNC `\\server\share\...`) read on POSIX belongs to another machine too, but
447
+ // `path.isAbsolute()` here uses POSIX rules and doesn't recognize a drive
448
+ // letter — without this branch such a cwd falls into the `path.resolve()` arm
449
+ // below and gets silently prefixed with THIS process's own cwd, corrupting the
450
+ // path (and, via WORKTREE_RE, can misattribute the worktree slug to whatever
451
+ // worktree the reading process happens to be running in). Normalize with
452
+ // win32 rules so separators survive; never realpath it, for the same
453
+ // cross-drive reason as the mirror branch above.
454
+ if (process.platform !== 'win32' && /^([a-zA-Z]:[\\/]|\\\\)/.test(cwd)) {
455
+ return stripTrailingSep(path.win32.normalize(cwd));
456
+ }
445
457
  const normalized = path.isAbsolute(cwd) ? stripTrailingSep(path.normalize(cwd)) : path.resolve(cwd);
446
458
  return safeRealpathSync(normalized) || normalized;
447
459
  }
@@ -67,6 +67,8 @@ export interface SessionStats {
67
67
  export declare function computeSummaryStats(events: SessionEvent[]): SessionStats;
68
68
  /** Strip the 'claude-' prefix and date suffix from a model identifier. */
69
69
  export declare function shortenModel(model: string): string;
70
+ /** Format a token count as a human-readable string (e.g. 67.5K, 1.2M). */
71
+ export declare function formatTokenCount(n: number): string;
70
72
  /** Format a duration in milliseconds as a human-readable string (e.g. '12 min', '2h 30min'). */
71
73
  export declare function formatDuration(ms: number): string;
72
74
  /**
@@ -219,7 +219,7 @@ export function shortenModel(model) {
219
219
  return model.replace(/^claude-/, '').replace(/-\d{8}$/, '');
220
220
  }
221
221
  /** Format a token count as a human-readable string (e.g. 67.5K, 1.2M). */
222
- function formatTokenCount(n) {
222
+ export function formatTokenCount(n) {
223
223
  if (n === 0)
224
224
  return '0';
225
225
  if (n < 1000)
@@ -0,0 +1,93 @@
1
+ import { type ShareConfig } from './config.js';
2
+ /** DI seam for tests — override the real HTTP DELETE. */
3
+ export type DeleteFn = (url: string, headers: Record<string, string>) => Promise<{
4
+ ok: boolean;
5
+ status: number;
6
+ }>;
7
+ /** DI seam for tests — override the real HTTP existence check (HEAD). */
8
+ export type CheckFn = (url: string) => Promise<{
9
+ status: number;
10
+ }>;
11
+ export interface DeleteEndpoint {
12
+ baseUrl: string;
13
+ token: string;
14
+ }
15
+ export interface ResolvedShareTarget {
16
+ /** R2 object key for the page, `<user>/<slug>`. */
17
+ key: string;
18
+ /** R2 object key for the sibling OG cover, `<user>/<slug>.png`. */
19
+ coverKey: string;
20
+ }
21
+ /**
22
+ * Normalize any of the three accepted target forms to the R2 key that publish
23
+ * would have written:
24
+ * - a full share URL: `https://share.agents-cli.sh/<user>/<slug>`
25
+ * - `<user>/<slug>`
26
+ * - a bare `<slug>` — resolved against the caller's own namespace exactly as
27
+ * `publishToEndpoint` resolves it at publish time (resolveShareUsername +
28
+ * buildShareKey), so a bare slug always targets *your* published page.
29
+ *
30
+ * The URL and `<user>/<slug>` forms are taken as already the exact key a prior
31
+ * publish produced (no re-sanitizing) — only the bare-slug form runs through
32
+ * `buildShareKey`, because that's the one case where the slug hasn't already
33
+ * been normalized by a publish.
34
+ */
35
+ export declare function resolveDeleteTarget(target: string, opts?: {
36
+ githubUser?: string;
37
+ }): Promise<ResolvedShareTarget>;
38
+ export interface DeleteObjectResult {
39
+ key: string;
40
+ url: string;
41
+ /** Whether the object resolved (non-404) before the DELETE was issued. */
42
+ existedBefore: boolean;
43
+ /** Whether the Worker's DELETE call itself reported ok. */
44
+ deleted: boolean;
45
+ /** The postcondition: a follow-up check resolved 404 after the DELETE. */
46
+ verified404: boolean;
47
+ }
48
+ /**
49
+ * Delete one R2 object behind the share Worker and assert the postcondition.
50
+ * `{"ok":true}` from the Worker is not evidence a page came down — R2 delete is
51
+ * idempotent, so a DELETE on a key that was never there also returns `ok:true`.
52
+ * This checks existence before (so callers can tell "deleted" from "was never
53
+ * there") and re-checks after (so callers can tell "deleted" from "still public").
54
+ */
55
+ export declare function deleteObject(endpoint: DeleteEndpoint, key: string, opts?: {
56
+ deleter?: DeleteFn;
57
+ checker?: CheckFn;
58
+ }): Promise<DeleteObjectResult>;
59
+ export interface DeleteShareOptions {
60
+ /** Skip deleting the sibling `<slug>.png` OG cover (default: delete it too). */
61
+ keepCover?: boolean;
62
+ /** Treat an already-missing target as a no-op success instead of an error
63
+ * (mirrors SQL's `DROP ... IF EXISTS`). Default: missing target is an error. */
64
+ ifExists?: boolean;
65
+ /** Override the GitHub username used to resolve a bare-slug target. */
66
+ githubUser?: string;
67
+ /** DI seam for tests — override the persisted share endpoint config. */
68
+ config?: ShareConfig;
69
+ /** DI seam for tests — override the keychain-backed write token. */
70
+ writeToken?: string;
71
+ /** DI seam for tests — override the real HTTP DELETE. */
72
+ deleter?: DeleteFn;
73
+ /** DI seam for tests — override the real HTTP existence check. */
74
+ checker?: CheckFn;
75
+ }
76
+ export interface DeleteShareResult {
77
+ key: string;
78
+ url: string;
79
+ existedBefore: boolean;
80
+ verified404: boolean;
81
+ /** True when `ifExists` was set and the target was already gone — nothing ran. */
82
+ skipped?: boolean;
83
+ cover?: {
84
+ key: string;
85
+ url: string;
86
+ existedBefore: boolean;
87
+ verified404: boolean;
88
+ };
89
+ }
90
+ /** Delete one share target (page + by default its OG cover) and verify both
91
+ * are gone. Throws on an unverified takedown — never reports success for an
92
+ * object that still resolves. */
93
+ export declare function deleteShare(target: string, opts?: DeleteShareOptions): Promise<DeleteShareResult>;
@@ -0,0 +1,127 @@
1
+ // The delete path for `agents share delete` / `agents unshare` — an authed DELETE
2
+ // to the Worker, which already implements it (worker-template.ts). Mirrors
3
+ // publish.ts: pure target-resolution logic is exported for tests, the network
4
+ // calls (a status check + a delete) sit behind an injectable DI seam.
5
+ //
6
+ // The Worker's R2 delete is idempotent — DELETE on a key that never existed still
7
+ // returns `{"ok":true}` — so `{"ok":true}` alone is never proof of a takedown.
8
+ // Every delete here is followed by a status check that must observe 404 before
9
+ // the operation is reported as successful.
10
+ import { readShareConfig, readWriteToken } from './config.js';
11
+ import { buildShareKey, resolveShareUsername } from './publish.js';
12
+ /**
13
+ * Normalize any of the three accepted target forms to the R2 key that publish
14
+ * would have written:
15
+ * - a full share URL: `https://share.agents-cli.sh/<user>/<slug>`
16
+ * - `<user>/<slug>`
17
+ * - a bare `<slug>` — resolved against the caller's own namespace exactly as
18
+ * `publishToEndpoint` resolves it at publish time (resolveShareUsername +
19
+ * buildShareKey), so a bare slug always targets *your* published page.
20
+ *
21
+ * The URL and `<user>/<slug>` forms are taken as already the exact key a prior
22
+ * publish produced (no re-sanitizing) — only the bare-slug form runs through
23
+ * `buildShareKey`, because that's the one case where the slug hasn't already
24
+ * been normalized by a publish.
25
+ */
26
+ export async function resolveDeleteTarget(target, opts = {}) {
27
+ const trimmed = target.trim();
28
+ if (!trimmed)
29
+ throw new Error('Share target is empty.');
30
+ let key;
31
+ if (/^https?:\/\//i.test(trimmed)) {
32
+ const url = new URL(trimmed);
33
+ const segments = url.pathname
34
+ .replace(/^\/+|\/+$/g, '')
35
+ .split('/')
36
+ .filter(Boolean)
37
+ .map(decodeURIComponent);
38
+ if (segments.length < 2) {
39
+ throw new Error(`Not a share page URL (expected .../<user>/<slug>): ${trimmed}`);
40
+ }
41
+ key = segments.slice(0, 2).join('/');
42
+ }
43
+ else if (trimmed.includes('/')) {
44
+ const segments = trimmed.replace(/^\/+|\/+$/g, '').split('/').filter(Boolean);
45
+ if (segments.length !== 2) {
46
+ throw new Error(`Expected <user>/<slug>, got: ${trimmed}`);
47
+ }
48
+ key = segments.join('/');
49
+ }
50
+ else {
51
+ const username = await resolveShareUsername(opts);
52
+ key = buildShareKey(username, trimmed);
53
+ }
54
+ return { key, coverKey: `${key}.png` };
55
+ }
56
+ async function defaultCheck(url) {
57
+ const res = await fetch(url, { method: 'HEAD' });
58
+ return { status: res.status };
59
+ }
60
+ async function defaultDelete(url, headers) {
61
+ const res = await fetch(url, { method: 'DELETE', headers });
62
+ return { ok: res.ok, status: res.status };
63
+ }
64
+ /**
65
+ * Delete one R2 object behind the share Worker and assert the postcondition.
66
+ * `{"ok":true}` from the Worker is not evidence a page came down — R2 delete is
67
+ * idempotent, so a DELETE on a key that was never there also returns `ok:true`.
68
+ * This checks existence before (so callers can tell "deleted" from "was never
69
+ * there") and re-checks after (so callers can tell "deleted" from "still public").
70
+ */
71
+ export async function deleteObject(endpoint, key, opts = {}) {
72
+ const url = `${endpoint.baseUrl.replace(/\/+$/, '')}/${key}`;
73
+ const check = opts.checker ?? defaultCheck;
74
+ const del = opts.deleter ?? defaultDelete;
75
+ const before = await check(url);
76
+ const existedBefore = before.status !== 404;
77
+ const r = await del(url, { authorization: `Bearer ${endpoint.token}` });
78
+ if (!r.ok) {
79
+ throw new Error(`Delete failed (${r.status}) for ${url}. Check the write token, or that 'agents share setup' completed.`);
80
+ }
81
+ const after = await check(url);
82
+ const verified404 = after.status === 404;
83
+ return { key, url, existedBefore, deleted: r.ok, verified404 };
84
+ }
85
+ /** Delete one share target (page + by default its OG cover) and verify both
86
+ * are gone. Throws on an unverified takedown — never reports success for an
87
+ * object that still resolves. */
88
+ export async function deleteShare(target, opts = {}) {
89
+ const cfg = opts.config ?? readShareConfig();
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).");
92
+ }
93
+ const token = opts.writeToken ?? readWriteToken();
94
+ const resolved = await resolveDeleteTarget(target, { githubUser: opts.githubUser });
95
+ const endpoint = { baseUrl: cfg.baseUrl, token };
96
+ const page = await deleteObject(endpoint, resolved.key, { deleter: opts.deleter, checker: opts.checker });
97
+ if (!page.existedBefore) {
98
+ if (opts.ifExists) {
99
+ return { key: page.key, url: page.url, existedBefore: false, verified404: page.verified404, skipped: true };
100
+ }
101
+ throw new Error(`Nothing to delete — ${page.url} was already not found. Pass --if-exists to treat this as a no-op instead of an error.`);
102
+ }
103
+ if (!page.verified404) {
104
+ throw new Error(`Delete reported success but ${page.url} still resolves — takedown NOT verified. Retry, or investigate the Worker/R2 directly.`);
105
+ }
106
+ const result = {
107
+ key: page.key,
108
+ url: page.url,
109
+ existedBefore: page.existedBefore,
110
+ verified404: page.verified404,
111
+ };
112
+ if (!opts.keepCover) {
113
+ const cover = await deleteObject(endpoint, resolved.coverKey, { deleter: opts.deleter, checker: opts.checker });
114
+ result.cover = {
115
+ key: cover.key,
116
+ url: cover.url,
117
+ existedBefore: cover.existedBefore,
118
+ verified404: cover.verified404,
119
+ };
120
+ // A missing cover is normal (non-HTML publishes, or --no-cover at publish
121
+ // time never made one) — only a cover that existed and is still up is a bug.
122
+ if (cover.existedBefore && !cover.verified404) {
123
+ throw new Error(`Cover delete reported success but ${cover.url} still resolves — takedown NOT verified. Retry, or pass --keep-cover and delete it manually.`);
124
+ }
125
+ }
126
+ return result;
127
+ }