@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.
- package/CHANGELOG.md +55 -0
- package/README.md +9 -1
- package/dist/bin/agents +0 -0
- package/dist/commands/accounts.js +3 -3
- package/dist/commands/browser-sessions-picker.d.ts +16 -0
- package/dist/commands/browser-sessions-picker.js +179 -0
- package/dist/commands/browser.js +9 -4
- package/dist/commands/hosts.js +1 -5
- package/dist/commands/inspect.js +174 -41
- package/dist/commands/message.d.ts +6 -1
- package/dist/commands/message.js +60 -3
- package/dist/commands/sessions.d.ts +54 -4
- package/dist/commands/sessions.js +252 -46
- package/dist/commands/share.d.ts +18 -0
- package/dist/commands/share.js +108 -0
- package/dist/commands/ssh.js +17 -5
- package/dist/commands/teams.d.ts +28 -0
- package/dist/commands/teams.js +148 -13
- package/dist/commands/upgrade.d.ts +7 -0
- package/dist/commands/upgrade.js +10 -0
- package/dist/commands/watchdog.d.ts +2 -0
- package/dist/commands/watchdog.js +112 -27
- package/dist/index.js +51 -59
- package/dist/lib/agents.js +6 -0
- package/dist/lib/browser/sessions-list.d.ts +81 -0
- package/dist/lib/browser/sessions-list.js +179 -4
- package/dist/lib/codex-policy.d.ts +9 -1
- package/dist/lib/codex-policy.js +17 -2
- package/dist/lib/daemon.js +45 -5
- package/dist/lib/devices/connect.d.ts +33 -0
- package/dist/lib/devices/connect.js +61 -3
- package/dist/lib/devices/doctor-findings.d.ts +4 -2
- package/dist/lib/devices/doctor-findings.js +4 -2
- package/dist/lib/exec.js +65 -8
- package/dist/lib/help.d.ts +3 -2
- package/dist/lib/help.js +4 -0
- package/dist/lib/hosts/dispatch.d.ts +2 -32
- package/dist/lib/hosts/dispatch.js +6 -61
- package/dist/lib/hosts/tasks.d.ts +7 -0
- package/dist/lib/hosts/tasks.js +9 -0
- package/dist/lib/mailbox-target.d.ts +27 -0
- package/dist/lib/mailbox-target.js +21 -0
- package/dist/lib/mcp.d.ts +10 -0
- package/dist/lib/mcp.js +21 -2
- package/dist/lib/menubar/MenubarHelper.app/Contents/CodeResources +0 -0
- package/dist/lib/menubar/MenubarHelper.app/Contents/MacOS/MenubarHelper +0 -0
- package/dist/lib/migrate.d.ts +11 -0
- package/dist/lib/migrate.js +40 -0
- package/dist/lib/project-key.d.ts +17 -0
- package/dist/lib/project-key.js +26 -0
- package/dist/lib/project-root.d.ts +47 -0
- package/dist/lib/project-root.js +68 -0
- 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/secrets/agent.d.ts +9 -2
- package/dist/lib/secrets/agent.js +52 -7
- package/dist/lib/secrets/reaper.d.ts +24 -3
- package/dist/lib/secrets/reaper.js +55 -6
- package/dist/lib/session/discover.js +12 -0
- package/dist/lib/session/render.d.ts +2 -0
- package/dist/lib/session/render.js +1 -1
- package/dist/lib/share/delete.d.ts +93 -0
- package/dist/lib/share/delete.js +127 -0
- package/dist/lib/shims.js +48 -4
- package/dist/lib/startup/command-registry.d.ts +1 -0
- package/dist/lib/startup/command-registry.js +11 -3
- package/dist/lib/startup/root-command.d.ts +3 -0
- package/dist/lib/startup/root-command.js +10 -0
- package/dist/lib/teams/agents.d.ts +136 -6
- package/dist/lib/teams/agents.js +324 -58
- package/dist/lib/teams/worktree.d.ts +39 -2
- package/dist/lib/teams/worktree.js +60 -4
- package/dist/lib/types.d.ts +11 -0
- package/dist/lib/versions.js +2 -2
- package/dist/lib/watchdog/history.d.ts +20 -0
- package/dist/lib/watchdog/history.js +46 -0
- package/dist/lib/watchdog/log.d.ts +16 -1
- package/dist/lib/watchdog/log.js +82 -2
- package/dist/lib/watchdog/runner.d.ts +12 -0
- package/dist/lib/watchdog/runner.js +20 -0
- package/package.json +1 -1
package/dist/lib/project-root.js
CHANGED
|
@@ -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;
|
|
Binary file
|
|
Binary file
|
|
@@ -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
|
-
|
|
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
|
-
|
|
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})
|
|
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.
|
|
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
|
|
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.
|
|
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
|
|
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
|
-
|
|
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
|
+
}
|