@phnx-labs/agents-cli 1.22.60 → 1.22.62
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 +53 -0
- package/README.md +6 -0
- package/dist/cli/command-registry.d.ts +1 -0
- package/dist/cli/command-registry.js +2 -0
- package/dist/commands/browser.js +9 -4
- package/dist/commands/doctor.js +1 -1
- package/dist/commands/exec.js +35 -1
- package/dist/commands/feed.js +3 -1
- package/dist/commands/harness-hooks.d.ts +55 -0
- package/dist/commands/harness-hooks.js +104 -0
- package/dist/commands/harness-wizard.d.ts +33 -14
- package/dist/commands/harness-wizard.js +53 -23
- package/dist/commands/harness.d.ts +14 -0
- package/dist/commands/harness.js +86 -5
- package/dist/commands/monitors.js +3 -3
- package/dist/commands/reminders.d.ts +9 -0
- package/dist/commands/reminders.js +49 -0
- package/dist/commands/run-account-picker.d.ts +14 -0
- package/dist/commands/run-account-picker.js +13 -0
- package/dist/commands/send.js +4 -1
- package/dist/commands/teams.d.ts +1 -1
- package/dist/commands/teams.js +9 -3
- package/dist/index.js +9 -0
- package/dist/lib/accounting/rotate.d.ts +63 -0
- package/dist/lib/accounting/rotate.js +229 -13
- package/dist/lib/browser/drivers/local.d.ts +11 -0
- package/dist/lib/browser/drivers/local.js +26 -0
- package/dist/lib/browser/profiles.js +8 -6
- package/dist/lib/browser/service.d.ts +12 -8
- package/dist/lib/browser/service.js +38 -10
- package/dist/lib/channels/owner-forward.d.ts +14 -8
- package/dist/lib/channels/owner-forward.js +9 -5
- package/dist/lib/channels/registry.d.ts +2 -0
- package/dist/lib/channels/send.js +13 -1
- package/dist/lib/claude-statusline.d.ts +14 -1
- package/dist/lib/claude-statusline.js +27 -2
- package/dist/lib/daemon/runner.js +17 -2
- package/dist/lib/devices/doctor-findings.d.ts +1 -1
- package/dist/lib/devices/doctor-findings.js +22 -4
- package/dist/lib/doctor-diff.d.ts +21 -5
- package/dist/lib/doctor-diff.js +242 -76
- package/dist/lib/feed/events.d.ts +1 -1
- package/dist/lib/feed/events.js +25 -16
- package/dist/lib/feed-broadcast.js +5 -12
- package/dist/lib/github/gh-overload.d.ts +58 -0
- package/dist/lib/github/gh-overload.js +246 -0
- package/dist/lib/github/rest.d.ts +64 -0
- package/dist/lib/github/rest.js +111 -0
- package/dist/lib/harness-connection-test.d.ts +57 -0
- package/dist/lib/harness-connection-test.js +80 -0
- package/dist/lib/heal.js +8 -3
- package/dist/lib/humans.d.ts +9 -0
- package/dist/lib/humans.js +29 -8
- package/dist/lib/installations/shims.d.ts +22 -0
- package/dist/lib/installations/shims.js +104 -0
- package/dist/lib/linear-project-counts.js +8 -0
- package/dist/lib/linear-rate-limit.d.ts +26 -0
- package/dist/lib/linear-rate-limit.js +163 -0
- package/dist/lib/mcp.d.ts +9 -0
- package/dist/lib/mcp.js +37 -1
- package/dist/lib/notify.d.ts +3 -0
- package/dist/lib/notify.js +63 -21
- package/dist/lib/open-url.js +5 -3
- package/dist/lib/permissions.d.ts +28 -0
- package/dist/lib/permissions.js +156 -1
- package/dist/lib/refresh.js +9 -1
- package/dist/lib/reminders.d.ts +29 -0
- package/dist/lib/reminders.js +88 -0
- package/dist/lib/resource-content-diff.d.ts +33 -0
- package/dist/lib/resource-content-diff.js +103 -0
- package/dist/lib/rules/compile.d.ts +7 -0
- package/dist/lib/rules/compile.js +7 -1
- package/dist/lib/session/active.d.ts +41 -4
- package/dist/lib/session/active.js +58 -7
- package/dist/lib/session/host-link.d.ts +22 -0
- package/dist/lib/session/host-link.js +40 -4
- package/dist/lib/session/trajectory.d.ts +42 -0
- package/dist/lib/session/trajectory.js +46 -27
- package/dist/lib/ssh-exec.d.ts +30 -0
- package/dist/lib/ssh-exec.js +37 -5
- package/dist/lib/startup/command-registry.js +1 -1
- package/dist/lib/subagents-registry.d.ts +18 -0
- package/dist/lib/subagents-registry.js +79 -0
- package/dist/lib/teams/agents.d.ts +12 -0
- package/dist/lib/teams/agents.js +51 -0
- package/dist/lib/traces/schema2-build.d.ts +85 -0
- package/dist/lib/traces/schema2-build.js +637 -0
- package/dist/lib/traces/schema2-danger.d.ts +36 -0
- package/dist/lib/traces/schema2-danger.js +185 -0
- package/dist/lib/traces/schema2.d.ts +149 -0
- package/dist/lib/traces/schema2.js +20 -0
- package/dist/lib/traces/sync.d.ts +93 -0
- package/dist/lib/traces/sync.js +75 -22
- package/dist/lib/traces/worker-template.js +5 -0
- package/dist/lib/uninstall.js +10 -1
- package/dist/lib/workflows.d.ts +11 -0
- package/dist/lib/workflows.js +67 -8
- package/package.json +1 -1
package/dist/lib/permissions.js
CHANGED
|
@@ -776,7 +776,16 @@ export function convertToGrokFormat(set) {
|
|
|
776
776
|
}
|
|
777
777
|
function canonicalToGrokRule(perm, action) {
|
|
778
778
|
if (BLANKET_BASH_FORMS.has(perm)) {
|
|
779
|
-
|
|
779
|
+
// Grok's `*` is a SINGLE-LEVEL wildcard, so a `pattern: '*'` bash rule does
|
|
780
|
+
// NOT auto-approve a multi-token command like `ssh host cmd` or `scp a b`
|
|
781
|
+
// (PHNX-3294 — verified: two boxes carrying the identical `pattern="*"` rule
|
|
782
|
+
// differed only by `[ui].permission_mode`, and only the always-approve box
|
|
783
|
+
// ran ssh without prompting). Grok's documented "bare prefix matches all
|
|
784
|
+
// invocations" idiom is a rule with NO `pattern` key — the true allow-all
|
|
785
|
+
// shell form, matching how kimi (bare `Bash`), droid (`*`) and claude
|
|
786
|
+
// express a blanket Bash grant. This reads back as `Bash(*)` via the
|
|
787
|
+
// registry's pattern-less path, so the round-trip is unchanged.
|
|
788
|
+
return { action, tool: 'bash' };
|
|
780
789
|
}
|
|
781
790
|
const parsed = parseCanonicalPattern(perm);
|
|
782
791
|
if (!parsed)
|
|
@@ -1885,3 +1894,149 @@ export function saveDefaultPermissionSet(set) {
|
|
|
1885
1894
|
set.name = DEFAULT_PERMISSION_SET_NAME;
|
|
1886
1895
|
return savePermissionSet(set);
|
|
1887
1896
|
}
|
|
1897
|
+
// ============================================================================
|
|
1898
|
+
// Content-drift check (agents doctor, PHNX-3504)
|
|
1899
|
+
// ============================================================================
|
|
1900
|
+
/**
|
|
1901
|
+
* Harnesses whose native permission file carries a per-rule allow/deny list the
|
|
1902
|
+
* writer emits verbatim, so `agents doctor` can verify a group's rules survived
|
|
1903
|
+
* into the version home rule-for-rule. The compare is done in the harness's OWN
|
|
1904
|
+
* native vocabulary (Cursor `Shell(...)`, Droid command arrays, …) — NOT
|
|
1905
|
+
* canonical — because every target's canonical round-trip is lossy
|
|
1906
|
+
* (`lossyBecause` in `permissions-registry.ts`), so a canonical subset check
|
|
1907
|
+
* would false-diff a correctly-synced home.
|
|
1908
|
+
*
|
|
1909
|
+
* Every other allowlist harness (codex/grok/kimi/kiro/antigravity/hermes/copilot)
|
|
1910
|
+
* stores a lossy projection — a sandbox flag, whole-tool gate, per-directory
|
|
1911
|
+
* approval, or a split/merged pattern — with no faithful per-group provenance, so
|
|
1912
|
+
* doctor stays presence-only there and says so (`detail: 'format cannot verify
|
|
1913
|
+
* content'`) rather than faking `ok`.
|
|
1914
|
+
*/
|
|
1915
|
+
export const PERMISSIONS_REPRESENTABLE = new Set([
|
|
1916
|
+
'claude',
|
|
1917
|
+
'opencode',
|
|
1918
|
+
'cursor',
|
|
1919
|
+
'droid',
|
|
1920
|
+
'openclaw',
|
|
1921
|
+
]);
|
|
1922
|
+
function toStringSet(v) {
|
|
1923
|
+
return new Set(Array.isArray(v) ? v.filter((x) => typeof x === 'string') : []);
|
|
1924
|
+
}
|
|
1925
|
+
function readJsonFileSafe(filePath) {
|
|
1926
|
+
try {
|
|
1927
|
+
return JSON.parse(fs.readFileSync(filePath, 'utf-8'));
|
|
1928
|
+
}
|
|
1929
|
+
catch {
|
|
1930
|
+
return null;
|
|
1931
|
+
}
|
|
1932
|
+
}
|
|
1933
|
+
/** allow/deny rule strings in `agent`'s NATIVE vocabulary from its version home. */
|
|
1934
|
+
function homeNativePermissionRules(agent, versionHome) {
|
|
1935
|
+
const target = PERMISSION_TARGETS[agent];
|
|
1936
|
+
if (!target)
|
|
1937
|
+
return null;
|
|
1938
|
+
const configPath = target.home(versionHome);
|
|
1939
|
+
if (!fs.existsSync(configPath))
|
|
1940
|
+
return null;
|
|
1941
|
+
switch (agent) {
|
|
1942
|
+
case 'claude':
|
|
1943
|
+
case 'cursor': {
|
|
1944
|
+
const c = readJsonFileSafe(configPath);
|
|
1945
|
+
const perms = (c?.permissions ?? {});
|
|
1946
|
+
return { allow: toStringSet(perms.allow), deny: toStringSet(perms.deny) };
|
|
1947
|
+
}
|
|
1948
|
+
case 'droid': {
|
|
1949
|
+
const c = readJsonFileSafe(configPath);
|
|
1950
|
+
return { allow: toStringSet(c?.commandAllowlist), deny: toStringSet(c?.commandDenylist) };
|
|
1951
|
+
}
|
|
1952
|
+
case 'openclaw': {
|
|
1953
|
+
const c = readJsonFileSafe(configPath);
|
|
1954
|
+
const tools = (c?.tools ?? {});
|
|
1955
|
+
return { allow: toStringSet(tools.alsoAllow), deny: toStringSet(tools.deny) };
|
|
1956
|
+
}
|
|
1957
|
+
case 'opencode': {
|
|
1958
|
+
let c = null;
|
|
1959
|
+
try {
|
|
1960
|
+
c = JSON.parse(stripJsonComments(fs.readFileSync(configPath, 'utf-8')));
|
|
1961
|
+
}
|
|
1962
|
+
catch {
|
|
1963
|
+
return null;
|
|
1964
|
+
}
|
|
1965
|
+
const bash = (c?.permission?.bash ?? {});
|
|
1966
|
+
const allow = new Set();
|
|
1967
|
+
const deny = new Set();
|
|
1968
|
+
for (const [pattern, action] of Object.entries(bash)) {
|
|
1969
|
+
if (action === 'allow')
|
|
1970
|
+
allow.add(pattern);
|
|
1971
|
+
else if (action === 'deny')
|
|
1972
|
+
deny.add(pattern);
|
|
1973
|
+
}
|
|
1974
|
+
return { allow, deny };
|
|
1975
|
+
}
|
|
1976
|
+
default:
|
|
1977
|
+
return null;
|
|
1978
|
+
}
|
|
1979
|
+
}
|
|
1980
|
+
/** allow/deny rule strings in `agent`'s NATIVE vocabulary the writer WOULD emit. */
|
|
1981
|
+
function expectedNativePermissionRules(agent, set) {
|
|
1982
|
+
switch (agent) {
|
|
1983
|
+
case 'claude': {
|
|
1984
|
+
const c = convertToClaudeFormat(set);
|
|
1985
|
+
return { allow: new Set(c.permissions.allow), deny: new Set(c.permissions.deny) };
|
|
1986
|
+
}
|
|
1987
|
+
case 'cursor': {
|
|
1988
|
+
const c = convertToCursorFormat(set);
|
|
1989
|
+
return { allow: new Set(c.permissions.allow), deny: new Set(c.permissions.deny ?? []) };
|
|
1990
|
+
}
|
|
1991
|
+
case 'droid': {
|
|
1992
|
+
const c = convertToDroidFormat(set);
|
|
1993
|
+
return { allow: new Set(c.commandAllowlist), deny: new Set(c.commandDenylist) };
|
|
1994
|
+
}
|
|
1995
|
+
case 'openclaw': {
|
|
1996
|
+
const c = convertToOpenClawFormat(set);
|
|
1997
|
+
return { allow: new Set(c.alsoAllow), deny: new Set(c.deny) };
|
|
1998
|
+
}
|
|
1999
|
+
case 'opencode': {
|
|
2000
|
+
const c = convertToOpenCodeFormat(set);
|
|
2001
|
+
const allow = new Set();
|
|
2002
|
+
const deny = new Set();
|
|
2003
|
+
for (const [pattern, action] of Object.entries(c.permission.bash)) {
|
|
2004
|
+
if (action === 'allow')
|
|
2005
|
+
allow.add(pattern);
|
|
2006
|
+
else if (action === 'deny')
|
|
2007
|
+
deny.add(pattern);
|
|
2008
|
+
}
|
|
2009
|
+
return { allow, deny };
|
|
2010
|
+
}
|
|
2011
|
+
default:
|
|
2012
|
+
return { allow: new Set(), deny: new Set() };
|
|
2013
|
+
}
|
|
2014
|
+
}
|
|
2015
|
+
/**
|
|
2016
|
+
* True when permission GROUP `groupName` is faithfully present in `agent`'s
|
|
2017
|
+
* version home — every rule the group renders into that harness's native format
|
|
2018
|
+
* is on disk. Only meaningful for {@link PERMISSIONS_REPRESENTABLE} agents (the
|
|
2019
|
+
* caller keeps the lossy harnesses presence-only); returns true for a lossy
|
|
2020
|
+
* harness or an empty/header group so the caller does not down-rank it.
|
|
2021
|
+
*
|
|
2022
|
+
* The expected rules are re-derived from the CURRENT source group every call
|
|
2023
|
+
* (never a stored hash), so a rule edited/added in the source group surfaces as
|
|
2024
|
+
* drift even though the group name is unchanged (PHNX-3504).
|
|
2025
|
+
*/
|
|
2026
|
+
export function permissionsGroupMatches(agent, versionHome, groupName) {
|
|
2027
|
+
if (!PERMISSIONS_REPRESENTABLE.has(agent))
|
|
2028
|
+
return true;
|
|
2029
|
+
const expected = expectedNativePermissionRules(agent, buildPermissionsFromGroups([groupName]));
|
|
2030
|
+
if (expected.allow.size === 0 && expected.deny.size === 0)
|
|
2031
|
+
return true; // header / empty group
|
|
2032
|
+
const home = homeNativePermissionRules(agent, versionHome);
|
|
2033
|
+
if (!home)
|
|
2034
|
+
return false;
|
|
2035
|
+
for (const r of expected.allow)
|
|
2036
|
+
if (!home.allow.has(r))
|
|
2037
|
+
return false;
|
|
2038
|
+
for (const r of expected.deny)
|
|
2039
|
+
if (!home.deny.has(r))
|
|
2040
|
+
return false;
|
|
2041
|
+
return true;
|
|
2042
|
+
}
|
package/dist/lib/refresh.js
CHANGED
|
@@ -20,7 +20,7 @@ import { readManifest, MANIFEST_FILENAME } from './manifest.js';
|
|
|
20
20
|
import { getUserAgentsDir } from './state.js';
|
|
21
21
|
import { installVersion, listInstalledVersions, isVersionIsolated, getGlobalDefault, setGlobalDefault, getVersionHomePath, syncResourcesToVersion, getAvailableResources, getActuallySyncedResources, getNewResources, getProjectOnlyResources, hasNewResources, promptNewResourceSelection, promptResourceSelection, resolveConfiguredAgentTargets, } from './installations/versions.js';
|
|
22
22
|
import { listCliStatus, installCli, describeMethod, describeCheck, selectInstallMethod, } from './cli-resources.js';
|
|
23
|
-
import { ensureShimCurrent, isShimsInPath, addShimsToPath, getPathSetupInstructions, switchConfigSymlink, switchHomeFileSymlinks, } from './installations/shims.js';
|
|
23
|
+
import { ensureShimCurrent, ensureGhOverloadShim, isShimsInPath, addShimsToPath, getPathSetupInstructions, switchConfigSymlink, switchHomeFileSymlinks, } from './installations/shims.js';
|
|
24
24
|
import { parseHookManifest, registerHooksToSettings } from './hooks/install.js';
|
|
25
25
|
import { isPromptCancelled } from './format.js';
|
|
26
26
|
/**
|
|
@@ -240,6 +240,14 @@ export async function refresh(options = {}) {
|
|
|
240
240
|
}
|
|
241
241
|
}
|
|
242
242
|
// 5. Auto-add shims to PATH
|
|
243
|
+
// Refresh the gh overload shim so `gh pr checks` escapes the GraphQL rate limit
|
|
244
|
+
// for every user, transparently (PHNX-3501). Idempotent; POSIX-only in v1.
|
|
245
|
+
try {
|
|
246
|
+
ensureGhOverloadShim();
|
|
247
|
+
}
|
|
248
|
+
catch {
|
|
249
|
+
// Never let a shim-write hiccup break sync — real gh stays fine without it.
|
|
250
|
+
}
|
|
243
251
|
if (!isShimsInPath()) {
|
|
244
252
|
const pathResult = addShimsToPath();
|
|
245
253
|
if (pathResult.success && !pathResult.alreadyPresent) {
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
export interface Reminder {
|
|
2
|
+
/** Succinct form shown in the statusline (a few words). */
|
|
3
|
+
short: string;
|
|
4
|
+
/** Full principle, shown by `agents reminders`. Falls back to `short`. */
|
|
5
|
+
full: string;
|
|
6
|
+
}
|
|
7
|
+
/**
|
|
8
|
+
* Point the reminders file at a fixture for tests, mirroring
|
|
9
|
+
* `setClaudeUsageCachePathForTest` in accounting/usage.ts. Returns the prior
|
|
10
|
+
* override so a test can restore it. Pass `null` to clear.
|
|
11
|
+
*/
|
|
12
|
+
export declare function setRemindersFilePathForTest(filePath: string | null): string | null;
|
|
13
|
+
export declare function remindersFilePath(): string;
|
|
14
|
+
/**
|
|
15
|
+
* Load reminders from disk.
|
|
16
|
+
*
|
|
17
|
+
* Returns `[]` when the file does not exist — the feature is simply not opted
|
|
18
|
+
* in. Throws on a present-but-malformed file so `agents reminders` can surface
|
|
19
|
+
* the problem; the statusline caller deliberately swallows that, because a
|
|
20
|
+
* broken prompt is worse than a missing reminder line.
|
|
21
|
+
*/
|
|
22
|
+
export declare function loadReminders(filePath?: string): Reminder[];
|
|
23
|
+
/**
|
|
24
|
+
* Deterministically pick one reminder for a session. Same `sessionId` always
|
|
25
|
+
* maps to the same reminder, so it is stable within a session; different session
|
|
26
|
+
* ids spread across the list, so concurrent agents show different reminders.
|
|
27
|
+
* Returns `null` when there are no reminders.
|
|
28
|
+
*/
|
|
29
|
+
export declare function pickReminderForSession(reminders: Reminder[], sessionId?: string): Reminder | null;
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Personal operating reminders.
|
|
3
|
+
*
|
|
4
|
+
* A small, user-owned list of principles kept in
|
|
5
|
+
* `~/.agents/reminders/reminders.yaml` and surfaced succinctly in the Claude
|
|
6
|
+
* statusline — one per session, chosen deterministically from the session id so
|
|
7
|
+
* concurrent agents each show a different one and it stays stable within a
|
|
8
|
+
* session. Presence of the file with at least one entry is the opt-in; there is
|
|
9
|
+
* no separate flag. The file syncs across the fleet via `agents repo push/pull`.
|
|
10
|
+
*/
|
|
11
|
+
import * as fs from 'node:fs';
|
|
12
|
+
import * as path from 'node:path';
|
|
13
|
+
import { parse as parseYaml } from 'yaml';
|
|
14
|
+
import { getUserAgentsDir } from './state.js';
|
|
15
|
+
let remindersFilePathOverride = null;
|
|
16
|
+
/**
|
|
17
|
+
* Point the reminders file at a fixture for tests, mirroring
|
|
18
|
+
* `setClaudeUsageCachePathForTest` in accounting/usage.ts. Returns the prior
|
|
19
|
+
* override so a test can restore it. Pass `null` to clear.
|
|
20
|
+
*/
|
|
21
|
+
export function setRemindersFilePathForTest(filePath) {
|
|
22
|
+
const prev = remindersFilePathOverride;
|
|
23
|
+
remindersFilePathOverride = filePath;
|
|
24
|
+
return prev;
|
|
25
|
+
}
|
|
26
|
+
export function remindersFilePath() {
|
|
27
|
+
return remindersFilePathOverride ?? path.join(getUserAgentsDir(), 'reminders', 'reminders.yaml');
|
|
28
|
+
}
|
|
29
|
+
function isRecord(value) {
|
|
30
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* Load reminders from disk.
|
|
34
|
+
*
|
|
35
|
+
* Returns `[]` when the file does not exist — the feature is simply not opted
|
|
36
|
+
* in. Throws on a present-but-malformed file so `agents reminders` can surface
|
|
37
|
+
* the problem; the statusline caller deliberately swallows that, because a
|
|
38
|
+
* broken prompt is worse than a missing reminder line.
|
|
39
|
+
*/
|
|
40
|
+
export function loadReminders(filePath = remindersFilePath()) {
|
|
41
|
+
let raw;
|
|
42
|
+
try {
|
|
43
|
+
raw = fs.readFileSync(filePath, 'utf8');
|
|
44
|
+
}
|
|
45
|
+
catch (err) {
|
|
46
|
+
if (err?.code === 'ENOENT')
|
|
47
|
+
return [];
|
|
48
|
+
throw err;
|
|
49
|
+
}
|
|
50
|
+
const parsed = parseYaml(raw);
|
|
51
|
+
const list = isRecord(parsed) && Array.isArray(parsed.reminders) ? parsed.reminders : null;
|
|
52
|
+
if (!list) {
|
|
53
|
+
throw new Error(`reminders file has no 'reminders:' list: ${filePath}`);
|
|
54
|
+
}
|
|
55
|
+
const reminders = [];
|
|
56
|
+
for (const item of list) {
|
|
57
|
+
if (!isRecord(item))
|
|
58
|
+
continue;
|
|
59
|
+
const short = typeof item.short === 'string' ? item.short.trim() : '';
|
|
60
|
+
if (!short)
|
|
61
|
+
continue;
|
|
62
|
+
const full = typeof item.full === 'string' && item.full.trim() ? item.full.trim() : short;
|
|
63
|
+
reminders.push({ short, full });
|
|
64
|
+
}
|
|
65
|
+
return reminders;
|
|
66
|
+
}
|
|
67
|
+
/**
|
|
68
|
+
* Deterministically pick one reminder for a session. Same `sessionId` always
|
|
69
|
+
* maps to the same reminder, so it is stable within a session; different session
|
|
70
|
+
* ids spread across the list, so concurrent agents show different reminders.
|
|
71
|
+
* Returns `null` when there are no reminders.
|
|
72
|
+
*/
|
|
73
|
+
export function pickReminderForSession(reminders, sessionId) {
|
|
74
|
+
if (reminders.length === 0)
|
|
75
|
+
return null;
|
|
76
|
+
const key = sessionId?.trim();
|
|
77
|
+
const index = key ? hashString(key) % reminders.length : 0;
|
|
78
|
+
return reminders[index];
|
|
79
|
+
}
|
|
80
|
+
/** FNV-1a 32-bit — stable, dependency-free, well-distributed for short ids. */
|
|
81
|
+
function hashString(value) {
|
|
82
|
+
let hash = 0x811c9dc5;
|
|
83
|
+
for (let i = 0; i < value.length; i++) {
|
|
84
|
+
hash ^= value.charCodeAt(i);
|
|
85
|
+
hash = Math.imul(hash, 0x01000193);
|
|
86
|
+
}
|
|
87
|
+
return hash >>> 0;
|
|
88
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared content comparison for the resource-diff engine.
|
|
3
|
+
*
|
|
4
|
+
* `agents doctor` (and the plugin-drift describer) reconcile a version home's
|
|
5
|
+
* copy of a resource against its resolved source by comparing bytes. Two shapes
|
|
6
|
+
* of resource need it: a single file (a command, a rule, a transformed subagent)
|
|
7
|
+
* and a whole directory tree (a skill, a plugin mirror, a copied workflow).
|
|
8
|
+
* Both live here so every kind's differ shares ONE normalize rule, ONE ignore
|
|
9
|
+
* set, and ONE symlink-skip policy instead of re-deriving them (the duplication
|
|
10
|
+
* `doctor-diff.ts`'s `dirsContentMatch` had before PHNX-3504; the parallel
|
|
11
|
+
* `skillDirsMatch` copies in `versions.ts` / `detectors/skills.ts` are a separate
|
|
12
|
+
* follow-up cleanup so this change stays inside the fast-lane CI impact budget).
|
|
13
|
+
*
|
|
14
|
+
* Normalize: CRLF → LF and trim, so a trailing-newline / line-ending difference
|
|
15
|
+
* between two independently-written trees is not reported as content drift. This
|
|
16
|
+
* mirrors the byte compare `diffCommands` / `diffRules` already apply per file.
|
|
17
|
+
*/
|
|
18
|
+
/** OS metadata / local tooling that is never synced into a version home. */
|
|
19
|
+
export declare const RESOURCE_CONTENT_IGNORE: Set<string>;
|
|
20
|
+
/** CRLF → LF and trim, so line-ending / trailing-newline skew is not drift. */
|
|
21
|
+
export declare function normalizeResourceContent(content: string): string;
|
|
22
|
+
/**
|
|
23
|
+
* True when two files exist and their normalized content is identical. A missing
|
|
24
|
+
* or unreadable file on either side is a mismatch (never a silent match).
|
|
25
|
+
*/
|
|
26
|
+
export declare function filesContentMatch(a: string, b: string): boolean;
|
|
27
|
+
/**
|
|
28
|
+
* True when two directory trees hold the same set of names and every file under
|
|
29
|
+
* them matches by normalized content. Symlinks and ignored entries are skipped
|
|
30
|
+
* on both sides; a name present on one side only, or a file/dir type mismatch,
|
|
31
|
+
* is a mismatch. An unreadable directory on either side is a mismatch.
|
|
32
|
+
*/
|
|
33
|
+
export declare function dirsContentMatch(src: string, dst: string): boolean;
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared content comparison for the resource-diff engine.
|
|
3
|
+
*
|
|
4
|
+
* `agents doctor` (and the plugin-drift describer) reconcile a version home's
|
|
5
|
+
* copy of a resource against its resolved source by comparing bytes. Two shapes
|
|
6
|
+
* of resource need it: a single file (a command, a rule, a transformed subagent)
|
|
7
|
+
* and a whole directory tree (a skill, a plugin mirror, a copied workflow).
|
|
8
|
+
* Both live here so every kind's differ shares ONE normalize rule, ONE ignore
|
|
9
|
+
* set, and ONE symlink-skip policy instead of re-deriving them (the duplication
|
|
10
|
+
* `doctor-diff.ts`'s `dirsContentMatch` had before PHNX-3504; the parallel
|
|
11
|
+
* `skillDirsMatch` copies in `versions.ts` / `detectors/skills.ts` are a separate
|
|
12
|
+
* follow-up cleanup so this change stays inside the fast-lane CI impact budget).
|
|
13
|
+
*
|
|
14
|
+
* Normalize: CRLF → LF and trim, so a trailing-newline / line-ending difference
|
|
15
|
+
* between two independently-written trees is not reported as content drift. This
|
|
16
|
+
* mirrors the byte compare `diffCommands` / `diffRules` already apply per file.
|
|
17
|
+
*/
|
|
18
|
+
import * as fs from 'fs';
|
|
19
|
+
import * as path from 'path';
|
|
20
|
+
/** OS metadata / local tooling that is never synced into a version home. */
|
|
21
|
+
export const RESOURCE_CONTENT_IGNORE = new Set([
|
|
22
|
+
'.DS_Store',
|
|
23
|
+
'.git',
|
|
24
|
+
'.gitignore',
|
|
25
|
+
'.venv',
|
|
26
|
+
'__pycache__',
|
|
27
|
+
'node_modules',
|
|
28
|
+
]);
|
|
29
|
+
/** CRLF → LF and trim, so line-ending / trailing-newline skew is not drift. */
|
|
30
|
+
export function normalizeResourceContent(content) {
|
|
31
|
+
return content.replace(/\r\n/g, '\n').trim();
|
|
32
|
+
}
|
|
33
|
+
function readSafe(file) {
|
|
34
|
+
try {
|
|
35
|
+
return fs.readFileSync(file, 'utf-8');
|
|
36
|
+
}
|
|
37
|
+
catch {
|
|
38
|
+
return null;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* True when two files exist and their normalized content is identical. A missing
|
|
43
|
+
* or unreadable file on either side is a mismatch (never a silent match).
|
|
44
|
+
*/
|
|
45
|
+
export function filesContentMatch(a, b) {
|
|
46
|
+
const ac = readSafe(a);
|
|
47
|
+
const bc = readSafe(b);
|
|
48
|
+
if (ac == null || bc == null)
|
|
49
|
+
return false;
|
|
50
|
+
return normalizeResourceContent(ac) === normalizeResourceContent(bc);
|
|
51
|
+
}
|
|
52
|
+
/**
|
|
53
|
+
* True when two directory trees hold the same set of names and every file under
|
|
54
|
+
* them matches by normalized content. Symlinks and ignored entries are skipped
|
|
55
|
+
* on both sides; a name present on one side only, or a file/dir type mismatch,
|
|
56
|
+
* is a mismatch. An unreadable directory on either side is a mismatch.
|
|
57
|
+
*/
|
|
58
|
+
export function dirsContentMatch(src, dst) {
|
|
59
|
+
const srcEntries = (() => {
|
|
60
|
+
try {
|
|
61
|
+
return fs.readdirSync(src, { withFileTypes: true });
|
|
62
|
+
}
|
|
63
|
+
catch {
|
|
64
|
+
return null;
|
|
65
|
+
}
|
|
66
|
+
})();
|
|
67
|
+
const dstEntries = (() => {
|
|
68
|
+
try {
|
|
69
|
+
return fs.readdirSync(dst, { withFileTypes: true });
|
|
70
|
+
}
|
|
71
|
+
catch {
|
|
72
|
+
return null;
|
|
73
|
+
}
|
|
74
|
+
})();
|
|
75
|
+
if (!srcEntries || !dstEntries)
|
|
76
|
+
return false;
|
|
77
|
+
const filter = (es) => es
|
|
78
|
+
.filter((e) => !e.isSymbolicLink() && !RESOURCE_CONTENT_IGNORE.has(e.name))
|
|
79
|
+
.sort((a, b) => a.name.localeCompare(b.name));
|
|
80
|
+
const srcF = filter(srcEntries);
|
|
81
|
+
const dstF = filter(dstEntries);
|
|
82
|
+
if (srcF.length !== dstF.length)
|
|
83
|
+
return false;
|
|
84
|
+
for (let i = 0; i < srcF.length; i++) {
|
|
85
|
+
if (srcF[i].name !== dstF[i].name)
|
|
86
|
+
return false;
|
|
87
|
+
const a = path.join(src, srcF[i].name);
|
|
88
|
+
const b = path.join(dst, dstF[i].name);
|
|
89
|
+
if (srcF[i].isDirectory()) {
|
|
90
|
+
if (!dstF[i].isDirectory())
|
|
91
|
+
return false;
|
|
92
|
+
if (!dirsContentMatch(a, b))
|
|
93
|
+
return false;
|
|
94
|
+
}
|
|
95
|
+
else if (srcF[i].isFile()) {
|
|
96
|
+
if (!dstF[i].isFile())
|
|
97
|
+
return false;
|
|
98
|
+
if (!filesContentMatch(a, b))
|
|
99
|
+
return false;
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
return true;
|
|
103
|
+
}
|
|
@@ -8,6 +8,13 @@
|
|
|
8
8
|
*/
|
|
9
9
|
import type { AgentId } from '../types.js';
|
|
10
10
|
import { type RulesLayer } from './compose.js';
|
|
11
|
+
/**
|
|
12
|
+
* Header the non-@-import compile path (`agents refresh-rules` → compileRulesForAgent)
|
|
13
|
+
* prepends to a compiled instruction file. Exported so `doctor-diff` can strip it
|
|
14
|
+
* before content-comparing a home rules file against the raw preset composition —
|
|
15
|
+
* a header-compiled home must still reconcile (the header is not source content).
|
|
16
|
+
*/
|
|
17
|
+
export declare const COMPILED_HEADER: string;
|
|
11
18
|
export declare const COMPILED_HEADER_PROJECT: string;
|
|
12
19
|
/** Sidecar manifest recording source file hashes for staleness detection. */
|
|
13
20
|
export interface CompileManifest {
|
|
@@ -18,7 +18,13 @@ import { composeRules, composeRulesFromState } from './compose.js';
|
|
|
18
18
|
// whitespace (if any) is captured so we can preserve it in the output.
|
|
19
19
|
const IMPORT_RE = /(^|\s)@(\S+)/g;
|
|
20
20
|
const MAX_DEPTH = 5;
|
|
21
|
-
|
|
21
|
+
/**
|
|
22
|
+
* Header the non-@-import compile path (`agents refresh-rules` → compileRulesForAgent)
|
|
23
|
+
* prepends to a compiled instruction file. Exported so `doctor-diff` can strip it
|
|
24
|
+
* before content-comparing a home rules file against the raw preset composition —
|
|
25
|
+
* a header-compiled home must still reconcile (the header is not source content).
|
|
26
|
+
*/
|
|
27
|
+
export const COMPILED_HEADER = '<!-- Auto-compiled by agents-cli from ~/.agents/rules/AGENTS.md + imports.\n' +
|
|
22
28
|
' Edit the source files under ~/.agents/rules/ — edits to this file will be overwritten on next sync. -->\n\n';
|
|
23
29
|
export const COMPILED_HEADER_PROJECT = '<!-- Auto-compiled by agents-cli from .agents/rules/AGENTS.md + imports.\n' +
|
|
24
30
|
' Edit the source files under .agents/rules/ — edits to this file will be overwritten on next sync. -->\n\n';
|
|
@@ -93,6 +93,19 @@ export type ActiveStatus = 'running' | 'idle' | 'queued' | 'input_required' | 'c
|
|
|
93
93
|
| 'orphaned'
|
|
94
94
|
/** The host window died and took the agent with it — an unclean exit, not a normal close. */
|
|
95
95
|
| 'crashed' | 'unknown';
|
|
96
|
+
/**
|
|
97
|
+
* Coarse lifecycle bucket a UI groups a row by, projected once at the source from
|
|
98
|
+
* {@link ActiveStatus} so consumers stop re-deriving it (PHNX-2484). The vocabulary
|
|
99
|
+
* is deliberately small — five terminal-agnostic buckets — so the AGI EXT Fleet panel
|
|
100
|
+
* reads `phase` straight off the `sessions watch --json` row instead of mirroring the
|
|
101
|
+
* CLI status word onto its own enum (the ext's `mapStatusToPhase`, now deleted):
|
|
102
|
+
* `running` — dispatched or actively working;
|
|
103
|
+
* `waiting` — blocked on the operator (needs-you);
|
|
104
|
+
* `failed` — dangling/dead and needing attention (crashed, orphaned, abandoned);
|
|
105
|
+
* `done` — the process exited cleanly;
|
|
106
|
+
* `idle` — alive but quiet, or an unclassified state.
|
|
107
|
+
*/
|
|
108
|
+
export type SessionPhase = 'running' | 'waiting' | 'failed' | 'done' | 'idle';
|
|
96
109
|
/**
|
|
97
110
|
* Which rung of the recap ladder produced a row's shown {@link ActiveSession.title}
|
|
98
111
|
* (RUSH-3011), best-first:
|
|
@@ -221,6 +234,13 @@ export interface ActiveSession {
|
|
|
221
234
|
*/
|
|
222
235
|
lastActivityMs?: number;
|
|
223
236
|
status: ActiveStatus;
|
|
237
|
+
/**
|
|
238
|
+
* Coarse lifecycle bucket derived once from {@link status} (see {@link SessionPhase}).
|
|
239
|
+
* Folded on at the end of {@link getActiveSessions} by {@link foldPhase}, AFTER
|
|
240
|
+
* {@link foldHostLink} has finalized `status` — so `orphaned`/`crashed` land in the
|
|
241
|
+
* `failed` bucket rather than being re-derived (and mis-bucketed as `idle`) downstream.
|
|
242
|
+
*/
|
|
243
|
+
phase?: SessionPhase;
|
|
224
244
|
/** Indexed launch origin, backfilled by the sessions command for JSON consumers. */
|
|
225
245
|
origin?: 'cli' | 'routine';
|
|
226
246
|
/** Routine definition name when origin is `routine`. */
|
|
@@ -776,10 +796,15 @@ export declare function foldTmuxClients(rows: ActiveSession[]): Promise<void>;
|
|
|
776
796
|
* - `crashed` REPLACES `closed`. Both mean the process is gone, but `closed`
|
|
777
797
|
* reads as a normal exit; `crashed` says the host window went down with it
|
|
778
798
|
* and never cleaned up.
|
|
779
|
-
* - `orphaned` replaces
|
|
780
|
-
*
|
|
781
|
-
*
|
|
782
|
-
*
|
|
799
|
+
* - `orphaned` replaces `idle` / `input_required` on ANY `no-client`, and
|
|
800
|
+
* `running` ONLY when the owning WINDOW was lost (`hostWindowLost`). A
|
|
801
|
+
* session still working with merely zero tmux clients is a normal headless
|
|
802
|
+
* run — since RUSH-3125 a detached remote pane is the steady state — so
|
|
803
|
+
* flagging every one would bury the real signal (the false positive reverted
|
|
804
|
+
* in 6d973b823). But a running agent whose IDE window stopped republishing
|
|
805
|
+
* its heartbeat outlived an unclean host death and is genuinely stranded, so
|
|
806
|
+
* it IS promoted. A session sitting idle — or worse, waiting on a question —
|
|
807
|
+
* with no client attached is the same stranded case: nobody is coming.
|
|
783
808
|
*/
|
|
784
809
|
/**
|
|
785
810
|
* Attribute each live row to the machine the session actually EXECUTES on.
|
|
@@ -845,6 +870,18 @@ export declare function deriveSessionRecap(row: Pick<ActiveSession, 'label' | 't
|
|
|
845
870
|
};
|
|
846
871
|
/** Fold the recap ladder onto every row (see {@link deriveSessionRecap}). */
|
|
847
872
|
export declare function foldRecap(rows: ActiveSession[]): void;
|
|
873
|
+
/**
|
|
874
|
+
* Project a {@link SessionPhase} from the finalized {@link ActiveStatus} (PHNX-2484).
|
|
875
|
+
* This is the single source of truth the AGI EXT previously mirrored as
|
|
876
|
+
* `mapStatusToPhase` — the ext now reads the `phase` field instead of re-deriving it.
|
|
877
|
+
* `queued` counts as `running` (dispatched, in the pipeline); `abandoned`, `orphaned`,
|
|
878
|
+
* and `crashed` all bucket to `failed` (dangling/dead — needs attention), which is the
|
|
879
|
+
* drift this fixes: those three used to fall through a status-only map to `idle` and
|
|
880
|
+
* silently hide a dead agent.
|
|
881
|
+
*/
|
|
882
|
+
export declare function derivePhase(status: ActiveStatus | undefined): SessionPhase;
|
|
883
|
+
/** Fold the coarse lifecycle {@link SessionPhase} onto every row (see {@link derivePhase}). */
|
|
884
|
+
export declare function foldPhase(rows: ActiveSession[]): void;
|
|
848
885
|
/** Reap only stale sessions with proven-dead pids; unknown or live processes remain recoverable. */
|
|
849
886
|
export declare function isReapableOrphan(row: Pick<ActiveSession, 'status' | 'pidAlive'>): boolean;
|
|
850
887
|
/**
|
|
@@ -41,7 +41,7 @@ import { detectProvenance } from './provenance.js';
|
|
|
41
41
|
import { loadDevices } from '../devices/registry.js';
|
|
42
42
|
import { machineId, normalizeHost } from '../machine-id.js';
|
|
43
43
|
import { presenceFromStore } from './detached.js';
|
|
44
|
-
import { classifyHostLink, HOST_HEARTBEAT_STALE_MS } from './host-link.js';
|
|
44
|
+
import { classifyHostLink, hostWindowLost, HOST_HEARTBEAT_STALE_MS } from './host-link.js';
|
|
45
45
|
import { mapBounded } from '../concurrency.js';
|
|
46
46
|
import { linearIssueUrl } from './linear.js';
|
|
47
47
|
import { viewingInLabel } from './viewing-in.js';
|
|
@@ -1810,6 +1810,10 @@ export async function getActiveSessions(opts = {}) {
|
|
|
1810
1810
|
// Last: derive the shown title (recap ladder) from label/tail/topic now that
|
|
1811
1811
|
// every row's live tail + label are resolved (RUSH-3011).
|
|
1812
1812
|
foldRecap(merged);
|
|
1813
|
+
// Project the coarse lifecycle bucket once status is final (foldHostLink above may
|
|
1814
|
+
// have promoted a row to orphaned/crashed) so consumers read `phase` off the row
|
|
1815
|
+
// instead of re-deriving it (PHNX-2484).
|
|
1816
|
+
foldPhase(merged);
|
|
1813
1817
|
return merged;
|
|
1814
1818
|
}
|
|
1815
1819
|
/**
|
|
@@ -1923,10 +1927,15 @@ export async function foldTmuxClients(rows) {
|
|
|
1923
1927
|
* - `crashed` REPLACES `closed`. Both mean the process is gone, but `closed`
|
|
1924
1928
|
* reads as a normal exit; `crashed` says the host window went down with it
|
|
1925
1929
|
* and never cleaned up.
|
|
1926
|
-
* - `orphaned` replaces
|
|
1927
|
-
*
|
|
1928
|
-
*
|
|
1929
|
-
*
|
|
1930
|
+
* - `orphaned` replaces `idle` / `input_required` on ANY `no-client`, and
|
|
1931
|
+
* `running` ONLY when the owning WINDOW was lost (`hostWindowLost`). A
|
|
1932
|
+
* session still working with merely zero tmux clients is a normal headless
|
|
1933
|
+
* run — since RUSH-3125 a detached remote pane is the steady state — so
|
|
1934
|
+
* flagging every one would bury the real signal (the false positive reverted
|
|
1935
|
+
* in 6d973b823). But a running agent whose IDE window stopped republishing
|
|
1936
|
+
* its heartbeat outlived an unclean host death and is genuinely stranded, so
|
|
1937
|
+
* it IS promoted. A session sitting idle — or worse, waiting on a question —
|
|
1938
|
+
* with no client attached is the same stranded case: nobody is coming.
|
|
1930
1939
|
*/
|
|
1931
1940
|
/**
|
|
1932
1941
|
* Attribute each live row to the machine the session actually EXECUTES on.
|
|
@@ -2032,12 +2041,13 @@ export function foldHostLink(rows) {
|
|
|
2032
2041
|
continue;
|
|
2033
2042
|
// `closed` IS the dead-pid status — `lifecycleStatus` assigns it from
|
|
2034
2043
|
// `!pidAlive` and nothing else — so the status is the pid answer here.
|
|
2035
|
-
const
|
|
2044
|
+
const signals = {
|
|
2036
2045
|
pidAlive: s.status !== 'closed',
|
|
2037
2046
|
windowHeartbeatMs: s.windowHeartbeatMs,
|
|
2038
2047
|
tmuxClients: s.tmuxClients,
|
|
2039
2048
|
deliberatelyDetached: s.presence === 'background' || s.presence === 'parked',
|
|
2040
|
-
}
|
|
2049
|
+
};
|
|
2050
|
+
const link = classifyHostLink(signals);
|
|
2041
2051
|
s.hostLink = link;
|
|
2042
2052
|
// `foldPresence` gives every terminal row a DERIVED `attached` — "a live
|
|
2043
2053
|
// interactive TUI you're watching" — which is exactly the claim a lost host
|
|
@@ -2057,6 +2067,14 @@ export function foldHostLink(rows) {
|
|
|
2057
2067
|
else if (link === 'no-client' && (s.status === 'idle' || s.status === 'input_required')) {
|
|
2058
2068
|
s.status = 'orphaned';
|
|
2059
2069
|
}
|
|
2070
|
+
// A still-`running` agent is normally a healthy headless run, so 0 tmux
|
|
2071
|
+
// clients (a detached remote pane) MUST NOT flag it — that false positive was
|
|
2072
|
+
// reverted once already (6d973b823). The ONE exception is a lost WINDOW: its
|
|
2073
|
+
// owning IDE window stopped republishing, so it died uncleanly and the agent
|
|
2074
|
+
// outlived it — genuinely stranded (PHNX-3183, `hostWindowLost`, SES-18a).
|
|
2075
|
+
else if (s.status === 'running' && hostWindowLost(signals)) {
|
|
2076
|
+
s.status = 'orphaned';
|
|
2077
|
+
}
|
|
2060
2078
|
}
|
|
2061
2079
|
}
|
|
2062
2080
|
/** One display line, trimmed and capped, or undefined when empty. */
|
|
@@ -2099,6 +2117,39 @@ export function foldRecap(rows) {
|
|
|
2099
2117
|
s.lastAgentLine = recap.lastAgentLine;
|
|
2100
2118
|
}
|
|
2101
2119
|
}
|
|
2120
|
+
/**
|
|
2121
|
+
* Project a {@link SessionPhase} from the finalized {@link ActiveStatus} (PHNX-2484).
|
|
2122
|
+
* This is the single source of truth the AGI EXT previously mirrored as
|
|
2123
|
+
* `mapStatusToPhase` — the ext now reads the `phase` field instead of re-deriving it.
|
|
2124
|
+
* `queued` counts as `running` (dispatched, in the pipeline); `abandoned`, `orphaned`,
|
|
2125
|
+
* and `crashed` all bucket to `failed` (dangling/dead — needs attention), which is the
|
|
2126
|
+
* drift this fixes: those three used to fall through a status-only map to `idle` and
|
|
2127
|
+
* silently hide a dead agent.
|
|
2128
|
+
*/
|
|
2129
|
+
export function derivePhase(status) {
|
|
2130
|
+
switch (status) {
|
|
2131
|
+
case 'running':
|
|
2132
|
+
case 'queued':
|
|
2133
|
+
return 'running';
|
|
2134
|
+
case 'input_required':
|
|
2135
|
+
return 'waiting';
|
|
2136
|
+
case 'abandoned':
|
|
2137
|
+
case 'orphaned':
|
|
2138
|
+
case 'crashed':
|
|
2139
|
+
return 'failed';
|
|
2140
|
+
case 'closed':
|
|
2141
|
+
return 'done';
|
|
2142
|
+
case 'idle':
|
|
2143
|
+
case 'unknown':
|
|
2144
|
+
default:
|
|
2145
|
+
return 'idle';
|
|
2146
|
+
}
|
|
2147
|
+
}
|
|
2148
|
+
/** Fold the coarse lifecycle {@link SessionPhase} onto every row (see {@link derivePhase}). */
|
|
2149
|
+
export function foldPhase(rows) {
|
|
2150
|
+
for (const s of rows)
|
|
2151
|
+
s.phase = derivePhase(s.status);
|
|
2152
|
+
}
|
|
2102
2153
|
/** Reap only stale sessions with proven-dead pids; unknown or live processes remain recoverable. */
|
|
2103
2154
|
export function isReapableOrphan(row) {
|
|
2104
2155
|
return row.status === 'abandoned' && row.pidAlive === false;
|