@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.
Files changed (98) hide show
  1. package/CHANGELOG.md +53 -0
  2. package/README.md +6 -0
  3. package/dist/cli/command-registry.d.ts +1 -0
  4. package/dist/cli/command-registry.js +2 -0
  5. package/dist/commands/browser.js +9 -4
  6. package/dist/commands/doctor.js +1 -1
  7. package/dist/commands/exec.js +35 -1
  8. package/dist/commands/feed.js +3 -1
  9. package/dist/commands/harness-hooks.d.ts +55 -0
  10. package/dist/commands/harness-hooks.js +104 -0
  11. package/dist/commands/harness-wizard.d.ts +33 -14
  12. package/dist/commands/harness-wizard.js +53 -23
  13. package/dist/commands/harness.d.ts +14 -0
  14. package/dist/commands/harness.js +86 -5
  15. package/dist/commands/monitors.js +3 -3
  16. package/dist/commands/reminders.d.ts +9 -0
  17. package/dist/commands/reminders.js +49 -0
  18. package/dist/commands/run-account-picker.d.ts +14 -0
  19. package/dist/commands/run-account-picker.js +13 -0
  20. package/dist/commands/send.js +4 -1
  21. package/dist/commands/teams.d.ts +1 -1
  22. package/dist/commands/teams.js +9 -3
  23. package/dist/index.js +9 -0
  24. package/dist/lib/accounting/rotate.d.ts +63 -0
  25. package/dist/lib/accounting/rotate.js +229 -13
  26. package/dist/lib/browser/drivers/local.d.ts +11 -0
  27. package/dist/lib/browser/drivers/local.js +26 -0
  28. package/dist/lib/browser/profiles.js +8 -6
  29. package/dist/lib/browser/service.d.ts +12 -8
  30. package/dist/lib/browser/service.js +38 -10
  31. package/dist/lib/channels/owner-forward.d.ts +14 -8
  32. package/dist/lib/channels/owner-forward.js +9 -5
  33. package/dist/lib/channels/registry.d.ts +2 -0
  34. package/dist/lib/channels/send.js +13 -1
  35. package/dist/lib/claude-statusline.d.ts +14 -1
  36. package/dist/lib/claude-statusline.js +27 -2
  37. package/dist/lib/daemon/runner.js +17 -2
  38. package/dist/lib/devices/doctor-findings.d.ts +1 -1
  39. package/dist/lib/devices/doctor-findings.js +22 -4
  40. package/dist/lib/doctor-diff.d.ts +21 -5
  41. package/dist/lib/doctor-diff.js +242 -76
  42. package/dist/lib/feed/events.d.ts +1 -1
  43. package/dist/lib/feed/events.js +25 -16
  44. package/dist/lib/feed-broadcast.js +5 -12
  45. package/dist/lib/github/gh-overload.d.ts +58 -0
  46. package/dist/lib/github/gh-overload.js +246 -0
  47. package/dist/lib/github/rest.d.ts +64 -0
  48. package/dist/lib/github/rest.js +111 -0
  49. package/dist/lib/harness-connection-test.d.ts +57 -0
  50. package/dist/lib/harness-connection-test.js +80 -0
  51. package/dist/lib/heal.js +8 -3
  52. package/dist/lib/humans.d.ts +9 -0
  53. package/dist/lib/humans.js +29 -8
  54. package/dist/lib/installations/shims.d.ts +22 -0
  55. package/dist/lib/installations/shims.js +104 -0
  56. package/dist/lib/linear-project-counts.js +8 -0
  57. package/dist/lib/linear-rate-limit.d.ts +26 -0
  58. package/dist/lib/linear-rate-limit.js +163 -0
  59. package/dist/lib/mcp.d.ts +9 -0
  60. package/dist/lib/mcp.js +37 -1
  61. package/dist/lib/notify.d.ts +3 -0
  62. package/dist/lib/notify.js +63 -21
  63. package/dist/lib/open-url.js +5 -3
  64. package/dist/lib/permissions.d.ts +28 -0
  65. package/dist/lib/permissions.js +156 -1
  66. package/dist/lib/refresh.js +9 -1
  67. package/dist/lib/reminders.d.ts +29 -0
  68. package/dist/lib/reminders.js +88 -0
  69. package/dist/lib/resource-content-diff.d.ts +33 -0
  70. package/dist/lib/resource-content-diff.js +103 -0
  71. package/dist/lib/rules/compile.d.ts +7 -0
  72. package/dist/lib/rules/compile.js +7 -1
  73. package/dist/lib/session/active.d.ts +41 -4
  74. package/dist/lib/session/active.js +58 -7
  75. package/dist/lib/session/host-link.d.ts +22 -0
  76. package/dist/lib/session/host-link.js +40 -4
  77. package/dist/lib/session/trajectory.d.ts +42 -0
  78. package/dist/lib/session/trajectory.js +46 -27
  79. package/dist/lib/ssh-exec.d.ts +30 -0
  80. package/dist/lib/ssh-exec.js +37 -5
  81. package/dist/lib/startup/command-registry.js +1 -1
  82. package/dist/lib/subagents-registry.d.ts +18 -0
  83. package/dist/lib/subagents-registry.js +79 -0
  84. package/dist/lib/teams/agents.d.ts +12 -0
  85. package/dist/lib/teams/agents.js +51 -0
  86. package/dist/lib/traces/schema2-build.d.ts +85 -0
  87. package/dist/lib/traces/schema2-build.js +637 -0
  88. package/dist/lib/traces/schema2-danger.d.ts +36 -0
  89. package/dist/lib/traces/schema2-danger.js +185 -0
  90. package/dist/lib/traces/schema2.d.ts +149 -0
  91. package/dist/lib/traces/schema2.js +20 -0
  92. package/dist/lib/traces/sync.d.ts +93 -0
  93. package/dist/lib/traces/sync.js +75 -22
  94. package/dist/lib/traces/worker-template.js +5 -0
  95. package/dist/lib/uninstall.js +10 -1
  96. package/dist/lib/workflows.d.ts +11 -0
  97. package/dist/lib/workflows.js +67 -8
  98. package/package.json +1 -1
@@ -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
- return { action, tool: 'bash', pattern: '*' };
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
+ }
@@ -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
- const COMPILED_HEADER = '<!-- Auto-compiled by agents-cli from ~/.agents/rules/AGENTS.md + imports.\n' +
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 only `idle` / `input_required`. A session still WORKING
780
- * with nobody watching is a normal headless run, and flagging every one would
781
- * bury the real signal. A session sitting idle — or worse, waiting on a
782
- * question — with no client attached is the stranded case: nobody is coming.
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 only `idle` / `input_required`. A session still WORKING
1927
- * with nobody watching is a normal headless run, and flagging every one would
1928
- * bury the real signal. A session sitting idle — or worse, waiting on a
1929
- * question — with no client attached is the stranded case: nobody is coming.
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 link = classifyHostLink({
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;