@phnx-labs/agents-cli 1.22.73 → 1.22.74

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 CHANGED
@@ -1,5 +1,37 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.22.74
4
+
5
+ - **Launching an agent no longer dirties your tracked `.gitignore` (PHNX-3718).**
6
+ The per-harness resource sync self-manages ignore rules for the generated
7
+ `.claude/`, `.cursor/`, `.codex/`, … dirs it writes on every launch. Those
8
+ rules used to be written into the tracked `<project>/.gitignore`, but they are
9
+ never committed upstream — so every launch left the repo with a permanent
10
+ `M .gitignore` (one managed block per harness that had run there) that in turn
11
+ blocked `git pull` with *"local changes to .gitignore would be overwritten by
12
+ merge"*. The managed block now lives in `.git/info/exclude` (git's per-clone,
13
+ uncommitted ignore file, resolved via `git rev-parse --git-path info/exclude`
14
+ so it works from a subdir, linked worktree, or submodule), keeping the
15
+ generated dirs hidden while leaving the working tree clean. On the next launch,
16
+ a repo already dirtied by the old behavior self-heals: the leftover managed
17
+ block is stripped from the tracked `.gitignore` (hand-written rules untouched;
18
+ an untracked file that held only our block is removed). Source:
19
+ `cli/src/lib/project-resources.ts`.
20
+
21
+ - **`sessions preview` of a remote session no longer dead-ends on the dispatcher
22
+ (PHNX-3890).** A box that launched a session running on another device kept a
23
+ transcript-less live row for it whose machine defaulted to itself, so
24
+ `agents sessions preview <full-uuid>` — and the interactive browser's preview
25
+ pane — rendered `Live session — full transcript not indexed here.` instead of
26
+ fetching the owning peer's digest. A passive peer rendered the same session
27
+ fine. The launcher shim is now reconciled against the fleet's own view of which
28
+ device runs the session, and a full-UUID hit only skips the fleet fan-out when
29
+ this box can actually answer for it (a transcript on disk, or a row that already
30
+ names another device). Remote previews fill in asynchronously over SSH; a
31
+ locally readable transcript or synced mirror still renders with no hop, and an
32
+ unreachable owner still fails loud. Source: `cli/src/commands/sessions.ts`,
33
+ `cli/src/lib/session/live-metadata.ts`.
34
+
3
35
  ## 1.22.73
4
36
 
5
37
  - **Ctrl-C works again while `agents sessions` is reaching other machines (PHNX-3791).** The static session list, `Searching the fleet…`, and the cloud session/fetch paths wrap their long SSH/network waits in `ora(...)`. ora defaults to `discardStdin: true`, which on a TTY flips stdin into raw mode for the spinner's lifetime (via `stdin-discarder`) — and raw mode disables the terminal's own ISIG, so a Ctrl-C keystroke no longer raises SIGINT. ora's compensating "re-emit SIGINT when I read a 0x03 byte" only fires when stdin is in *flowing* mode, but a fresh `process.stdin` has `flowing === null`, `isPaused()` returns false, the discarder skips its `resume()`, and `prependListener('data')` doesn't auto-resume — so the byte is never read and SIGINT is never raised. The global handler in `index.ts` (`process.exit(130)`) was correct; it simply never fired. The user-visible result: a sweep that stalls on offline/unreachable boxes (each burning a ~12s SSH `ConnectTimeout`) could not be aborted — measured, `agents sessions --flat` had to be SIGKILLed. New `lib/spinner.ts#interruptibleSpinner` is a drop-in for `ora(text)` built with `discardStdin: false`: the terminal stays in cooked mode, Ctrl-C raises SIGINT normally (and the terminal SIGINTs the whole foreground group, reaping the outstanding `ssh` children), and the process exits promptly — only cosmetic stray-keystroke discarding during the spin is lost. The five cross-machine/network spinners in `sessions.ts` route through it; short local spinners are unchanged. Source: `cli/src/lib/spinner.ts`, `cli/src/lib/spinner.test.ts`, `cli/src/commands/sessions.ts`.
@@ -896,9 +896,58 @@ export declare function metadataResolveOutcome(localMatches: SessionMeta[], remo
896
896
  sessions: SessionMeta[];
897
897
  unreachable: string[];
898
898
  }, selector: string): MetadataResolveOutcome;
899
- /** Injectable dependency for the live-registry cold-miss fallback (RUSH-2682). */
899
+ /**
900
+ * Whether a local candidate answers a full-UUID selector **definitively** — i.e.
901
+ * whether finding it on this box is the whole answer, so the fleet fan-out can be
902
+ * skipped (PHNX-3890).
903
+ *
904
+ * Two shapes qualify, and both are claims this box can actually back:
905
+ *
906
+ * - **A real transcript on this disk** (`filePath`). It renders locally, whoever
907
+ * owns it — a local session or a fleet-synced mirror — so no SSH hop is needed.
908
+ * - **A genuine peer attribution** (`machine` names another box). The row already
909
+ * routes the read to its owner through `transcriptOnPeerOf`, so the peer that
910
+ * would answer the fan-out is the peer the render already dials.
911
+ *
912
+ * What does NOT qualify is the launcher-shim shape: a transcript-less live row
913
+ * whose `machine` is this box only because it DEFAULTED there
914
+ * (`activeSessionToSessionMeta`'s `active.machine ?? self`,
915
+ * `computeLocalMetadataMatches`'s `machine || localMachine`). A dispatcher holds
916
+ * exactly that row for a session whose agent and transcript live on a peer: the
917
+ * launch process is here, the conversation is not. Treating it as definitive is
918
+ * what dead-ended `agents sessions preview <full-uuid>` on the local
919
+ * "Live session — full transcript not indexed here." stub while a passive peer —
920
+ * which has no local row at all, so it fans out — rendered the real digest.
921
+ * Process locality is not transcript locality.
922
+ */
923
+ export declare function isLocallyDefinitiveMatch(session: SessionMeta, self: string): boolean;
924
+ /**
925
+ * Drop the launcher-shim local rows that a peer has since answered for
926
+ * (PHNX-3890). `fleetCandidatesByQuery` groups a logical session's copies per
927
+ * machine and every consumer reads `hits[0]`, with local rows passed first — so
928
+ * simply fanning out is not enough: the self-defaulted shim would still win the
929
+ * attribution and route the read back to a box with no transcript. When the peer
930
+ * that actually owns the session has answered the sweep, its row is the strictly
931
+ * better one, and the shim carries no information the candidate loses (same
932
+ * logical id, so the candidate — and any ambiguity it is part of — survives
933
+ * through the remote hit).
934
+ *
935
+ * A row that is {@link isLocallyDefinitiveMatch} is never dropped, so a locally
936
+ * readable transcript or a synced mirror still renders here with no SSH hop. When
937
+ * no peer answered for that id, the shim is kept: with no fleet evidence, a
938
+ * transcript-less self-attributed row is indistinguishable from a session THIS box
939
+ * just started, and dropping it would re-break the cold-index lookup RUSH-2682
940
+ * fixed.
941
+ */
942
+ export declare function preferOwnerAttribution(localMatches: SessionMeta[], remoteSessions: SessionMeta[], self: string): SessionMeta[];
943
+ /** Injectable dependencies for the live-registry cold-miss fallback (RUSH-2682)
944
+ * and the fleet-attribution reconciliation (PHNX-3890). */
900
945
  export type LiveMetadataDeps = {
901
946
  loadActive?: typeof loadLocalActiveSessions;
947
+ /** The fleet-active snapshot rows used to attribute a self-defaulted launcher
948
+ * shim to its true execution host. Defaults to the cached fleet snapshot; the
949
+ * cache is warmed by any fleet-wide `agents sessions --active`. */
950
+ loadFleetActive?: () => ActiveSession[];
902
951
  };
903
952
  /**
904
953
  * Local metadata candidates for a selector: the indexed SQLite rows, plus — when
@@ -29,14 +29,14 @@ import { mapPanesToTargets, listClients } from '../lib/tmux/session.js';
29
29
  import { resolveViewingIn, viewingInLabel } from '../lib/session/viewing-in.js';
30
30
  import { machineId, normalizeHost } from '../lib/session/sync/config.js';
31
31
  import { gatherRemoteActive, NO_FANOUT_ENV } from '../lib/session/remote-active.js';
32
- import { loadFleetActiveSessions, loadLocalActiveSessions, } from '../lib/session/session-cache.js';
32
+ import { loadFleetActiveSessions, loadLocalActiveSessions, readActiveSessionsCache, } from '../lib/session/session-cache.js';
33
33
  import { gatherRemoteList, gatherRemoteToolProgramCounts, gatherRemoteToolSearch, runOnPeer } from '../lib/session/remote-list.js';
34
34
  import { gatherRemoteAgentsJson } from '../lib/remote-agents-json.js';
35
35
  import { stringWidth, truncateToWidth, padToWidth, terminalWidth } from '../lib/session/width.js';
36
36
  import { inferSessionState } from '../lib/session/state.js';
37
37
  import { discoverSessions, queryIndexedSessions, countSessionsInScope, resolveSessionById, isCompleteSessionId, looksLikeSessionId, searchContentIndex, getSessionRoots, scopeToManaged } from '../lib/session/discover.js';
38
38
  import { findSessionsById, querySessions, readSessionContent, readArchivedSessionPreview } from '../lib/session/db.js';
39
- import { liveSessionMetas } from '../lib/session/live-metadata.js';
39
+ import { liveSessionMetas, fleetExecutionMachineById, reconcileLiveMetaMachine } from '../lib/session/live-metadata.js';
40
40
  import { filterTeamSessions, shouldShowTeamSessions, safeTeamText, groupSessionsByTeam, NO_TEAM_GROUP_KEY, } from '../lib/session/team-filter.js';
41
41
  import { parseSession } from '../lib/session/parse.js';
42
42
  import { runRemoteSessions, buildForwardedArgs, ensureWholeIndex } from '../lib/session/remote.js';
@@ -4844,6 +4844,59 @@ export function metadataResolveOutcome(localMatches, remote, selector) {
4844
4844
  return { kind: 'ambiguous', candidates };
4845
4845
  return { kind: 'resolved', session: candidates[0].hits[0].session };
4846
4846
  }
4847
+ /**
4848
+ * Whether a local candidate answers a full-UUID selector **definitively** — i.e.
4849
+ * whether finding it on this box is the whole answer, so the fleet fan-out can be
4850
+ * skipped (PHNX-3890).
4851
+ *
4852
+ * Two shapes qualify, and both are claims this box can actually back:
4853
+ *
4854
+ * - **A real transcript on this disk** (`filePath`). It renders locally, whoever
4855
+ * owns it — a local session or a fleet-synced mirror — so no SSH hop is needed.
4856
+ * - **A genuine peer attribution** (`machine` names another box). The row already
4857
+ * routes the read to its owner through `transcriptOnPeerOf`, so the peer that
4858
+ * would answer the fan-out is the peer the render already dials.
4859
+ *
4860
+ * What does NOT qualify is the launcher-shim shape: a transcript-less live row
4861
+ * whose `machine` is this box only because it DEFAULTED there
4862
+ * (`activeSessionToSessionMeta`'s `active.machine ?? self`,
4863
+ * `computeLocalMetadataMatches`'s `machine || localMachine`). A dispatcher holds
4864
+ * exactly that row for a session whose agent and transcript live on a peer: the
4865
+ * launch process is here, the conversation is not. Treating it as definitive is
4866
+ * what dead-ended `agents sessions preview <full-uuid>` on the local
4867
+ * "Live session — full transcript not indexed here." stub while a passive peer —
4868
+ * which has no local row at all, so it fans out — rendered the real digest.
4869
+ * Process locality is not transcript locality.
4870
+ */
4871
+ export function isLocallyDefinitiveMatch(session, self) {
4872
+ if (session.filePath)
4873
+ return true;
4874
+ return !!session.machine && session.machine !== self;
4875
+ }
4876
+ /**
4877
+ * Drop the launcher-shim local rows that a peer has since answered for
4878
+ * (PHNX-3890). `fleetCandidatesByQuery` groups a logical session's copies per
4879
+ * machine and every consumer reads `hits[0]`, with local rows passed first — so
4880
+ * simply fanning out is not enough: the self-defaulted shim would still win the
4881
+ * attribution and route the read back to a box with no transcript. When the peer
4882
+ * that actually owns the session has answered the sweep, its row is the strictly
4883
+ * better one, and the shim carries no information the candidate loses (same
4884
+ * logical id, so the candidate — and any ambiguity it is part of — survives
4885
+ * through the remote hit).
4886
+ *
4887
+ * A row that is {@link isLocallyDefinitiveMatch} is never dropped, so a locally
4888
+ * readable transcript or a synced mirror still renders here with no SSH hop. When
4889
+ * no peer answered for that id, the shim is kept: with no fleet evidence, a
4890
+ * transcript-less self-attributed row is indistinguishable from a session THIS box
4891
+ * just started, and dropping it would re-break the cold-index lookup RUSH-2682
4892
+ * fixed.
4893
+ */
4894
+ export function preferOwnerAttribution(localMatches, remoteSessions, self) {
4895
+ if (remoteSessions.length === 0)
4896
+ return localMatches;
4897
+ const answeredByPeer = new Set(remoteSessions.map(session => session.id.toLowerCase()));
4898
+ return localMatches.filter(session => isLocallyDefinitiveMatch(session, self) || !answeredByPeer.has(session.id.toLowerCase()));
4899
+ }
4847
4900
  /**
4848
4901
  * Local metadata candidates for a selector: the indexed SQLite rows, plus — when
4849
4902
  * an id-shaped selector misses the index entirely — the live-session registry
@@ -4881,7 +4934,20 @@ export async function computeLocalMetadataMatches(selector, scope, deps = {}) {
4881
4934
  */
4882
4935
  export async function liveMetadataMatches(selector, scope, self, deps = {}) {
4883
4936
  const load = deps.loadActive ?? loadLocalActiveSessions;
4884
- const match = (metas) => resolveIndexedMetadataRows(applyScopeFilters(metas, scope), selector, scope);
4937
+ const loadFleet = deps.loadFleetActive ?? (() => readActiveSessionsCache('fleet')?.sessions ?? []);
4938
+ // A launcher-shim row whose `machine` self-defaulted to this box gets its true
4939
+ // EXECUTION host from the fleet snapshot, so a session dispatched to a peer is
4940
+ // read on that peer instead of dead-ending on the local stub (PHNX-3890). The
4941
+ // read is best-effort: an absent/cold snapshot leaves the row untouched and the
4942
+ // fleet fan-out in resolveSessionMetadataValue supplies the owner instead.
4943
+ let fleetExecMachine;
4944
+ try {
4945
+ fleetExecMachine = fleetExecutionMachineById(loadFleet());
4946
+ }
4947
+ catch {
4948
+ fleetExecMachine = new Map();
4949
+ }
4950
+ const match = (metas) => resolveIndexedMetadataRows(applyScopeFilters(reconcileLiveMetaMachine(metas, fleetExecMachine, self), scope), selector, scope);
4885
4951
  try {
4886
4952
  const cached = liveSessionMetas((await load()).sessions, self, Date.now());
4887
4953
  const hit = match(cached);
@@ -4898,16 +4964,24 @@ export async function liveMetadataMatches(selector, scope, self, deps = {}) {
4898
4964
  * Full UUIDs hit the local SQLite index without any SSH fan-out. */
4899
4965
  export async function resolveSessionMetadataValue(selector, scope = {}, deps = { gatherRemoteList }) {
4900
4966
  const localMatches = await computeLocalMetadataMatches(selector, scope, deps);
4967
+ const localMachine = machineId();
4901
4968
  // A full-UUID local hit resolves with ZERO SSH: a UUID is globally unique, so
4902
4969
  // finding it locally is the whole answer and no peer is dialed. (A label is
4903
4970
  // NOT resolved local-only here — it can collide with a same-label session on a
4904
4971
  // peer, so it must consult the fleet; see isDefinitiveMatch, RUSH-2203.) The
4905
4972
  // local index lookup is synchronous and completes before any fan-out spawns,
4906
4973
  // so this local hit never waits on a peer.
4974
+ //
4975
+ // "Found it locally" must mean this box can actually ANSWER for it, though —
4976
+ // see isLocallyDefinitiveMatch. A transcript-less launcher shim for a session
4977
+ // executing on a peer is a local row that is not a local answer, and
4978
+ // short-circuiting on it skipped the one fan-out that could have named the
4979
+ // owner (PHNX-3890).
4907
4980
  if (FULL_SESSION_ID_RE.test(selector)) {
4908
4981
  const localOutcome = metadataResolveOutcome(localMatches, { sessions: [], unreachable: [] }, selector);
4909
- if (localOutcome.kind === 'resolved')
4982
+ if (localOutcome.kind === 'resolved' && isLocallyDefinitiveMatch(localOutcome.session, localMachine)) {
4910
4983
  return localOutcome;
4984
+ }
4911
4985
  }
4912
4986
  if (scope.local === true)
4913
4987
  return metadataResolveOutcome(localMatches, { sessions: [], unreachable: [] }, selector);
@@ -4920,7 +4994,7 @@ export async function resolveSessionMetadataValue(selector, scope = {}, deps = {
4920
4994
  const remote = await deps.gatherRemoteList(forwarded, scope.hosts, selectorAllowsEarlyExit(selector)
4921
4995
  ? { isDefinitive: (session) => isDefinitiveMatch(session, selector) }
4922
4996
  : undefined);
4923
- return metadataResolveOutcome(localMatches, remote, selector);
4997
+ return metadataResolveOutcome(preferOwnerAttribution(localMatches, remote.sessions, localMachine), remote, selector);
4924
4998
  }
4925
4999
  catch (error) {
4926
5000
  return metadataResolveOutcome(localMatches, { sessions: [], unreachable: [error?.message ?? 'fleet fan-out'] }, selector);
@@ -7,7 +7,7 @@
7
7
  import type { CloudProvider, CloudTask, CloudTaskStatus, CloudEvent, DispatchOptions, ProviderCapabilities, ImageAttachment, SkillRef } from './types.js';
8
8
  /**
9
9
  * Returns true when ~/.rush/user.yaml exists, carries an access_token, and
10
- * the token has not passed its expires_at timestamp (Unix seconds). A missing
10
+ * the token has not passed its expires_at timestamp (Unix milliseconds). A missing
11
11
  * expires_at, or `expires_at: 0` (a non-expiring Phoenix `pid_` bearer), is
12
12
  * treated as non-expired (see isRushSessionExpired, PHNX-3645). Pass yamlPath
13
13
  * to override the default path in tests.
@@ -19,7 +19,7 @@ const PROXY_BASE = process.env.RUSH_PROXY_BASE ?? 'https://api.prix.dev';
19
19
  const USER_YAML = path.join(os.homedir(), '.rush', 'user.yaml');
20
20
  /**
21
21
  * Returns true when ~/.rush/user.yaml exists, carries an access_token, and
22
- * the token has not passed its expires_at timestamp (Unix seconds). A missing
22
+ * the token has not passed its expires_at timestamp (Unix milliseconds). A missing
23
23
  * expires_at, or `expires_at: 0` (a non-expiring Phoenix `pid_` bearer), is
24
24
  * treated as non-expired (see isRushSessionExpired, PHNX-3645). Pass yamlPath
25
25
  * to override the default path in tests.
@@ -58,7 +58,7 @@ export function readToken(yamlPath = USER_YAML) {
58
58
  }
59
59
  const expiresAt = data.session?.expires_at;
60
60
  if (isRushSessionExpired(expiresAt)) {
61
- const expiredAt = new Date(expiresAt * 1000).toISOString();
61
+ const expiredAt = new Date(expiresAt).toISOString();
62
62
  throw new Error(`Rush session expired at ${expiredAt}. Run \`rush login\` to refresh.`);
63
63
  }
64
64
  return token;
@@ -7,7 +7,11 @@ export declare function projectAgentRoot(projectRoot: string, agent: AgentId): s
7
7
  export declare function syncProjectResourcesToAgent(agent: AgentId, version: string, projectAgentsDir: string): ProjectResourceSyncResult;
8
8
  /**
9
9
  * Turn the manifest's managed paths (relative to agentRoot) into anchored,
10
- * POSIX, projectRoot-relative `.gitignore` entries. Two guards keep it honest:
10
+ * POSIX, `referenceRoot`-relative ignore entries. `referenceRoot` is the
11
+ * directory the anchored `/…` patterns resolve against — the git worktree root
12
+ * for a `.git/info/exclude` block, since git anchors info/exclude patterns at
13
+ * the top of the working tree (not at the harness dir). Two guards keep it
14
+ * honest:
11
15
  * - drop any path that escapes the harness config dir (e.g. grok writes
12
16
  * commands back into the tracked `.agents/` tree via a `../` subdir —
13
17
  * ignoring that would hide tracked source; separate bug, PHNX-3718);
@@ -16,7 +20,7 @@ export declare function syncProjectResourcesToAgent(agent: AgentId, version: str
16
20
  * these never masks a hand-authored or committed file (e.g. a repo that
17
21
  * commits its own `.claude/CLAUDE.md` keeps it — it is not in the manifest).
18
22
  */
19
- export declare function managedGitignoreEntries(agentRoot: string, projectRoot: string, managed: string[]): string[];
23
+ export declare function managedGitignoreEntries(agentRoot: string, referenceRoot: string, managed: string[]): string[];
20
24
  /**
21
25
  * One human line for the files a project sync left alone because you already
22
26
  * wrote them. This is the normal steady state — every sync of a project whose
@@ -1,3 +1,4 @@
1
+ import { execFileSync } from 'child_process';
1
2
  import * as fs from 'fs';
2
3
  import * as path from 'path';
3
4
  import { AGENTS, agentConfigDirName, isAgentHardDeprecated } from './agents.js';
@@ -39,10 +40,14 @@ export function syncProjectResourcesToAgent(agent, version, projectAgentsDir) {
39
40
  // .agents/{commands,skills,…}, refreshed on every launch. Left untracked it
40
41
  // dirties `git status` and can block `git merge`/`checkout` when a stray
41
42
  // commit of the same path collides. So the generator owns its ignore rule:
42
- // reconcile a per-agent marker block in <projectRoot>/.gitignore listing
43
- // exactly the paths it manages. Passing the manifest set (empty when a sync
44
- // clears a harness) also prunes the block. See PHNX-3717.
45
- reconcileProjectGitignore(projectRoot, agent, agentRoot, Array.from(next).sort());
43
+ // reconcile a per-agent marker block listing exactly the paths it manages.
44
+ // That block lives in `.git/info/exclude` — git's per-clone, uncommitted
45
+ // ignore file — NOT the tracked `.gitignore`: these entries are never
46
+ // committed upstream, so writing them into `.gitignore` left every launch
47
+ // with a permanent `M .gitignore` that blocked `git pull` (PHNX-3718).
48
+ // Passing the manifest set (empty when a sync clears a harness) also prunes
49
+ // the block. See PHNX-3717 for the self-managed-ignore feature.
50
+ reconcileManagedIgnore(projectRoot, agent, agentRoot, Array.from(next).sort());
46
51
  }
47
52
  return result;
48
53
  }
@@ -75,7 +80,11 @@ function gitignoreMarkers(agent) {
75
80
  }
76
81
  /**
77
82
  * Turn the manifest's managed paths (relative to agentRoot) into anchored,
78
- * POSIX, projectRoot-relative `.gitignore` entries. Two guards keep it honest:
83
+ * POSIX, `referenceRoot`-relative ignore entries. `referenceRoot` is the
84
+ * directory the anchored `/…` patterns resolve against — the git worktree root
85
+ * for a `.git/info/exclude` block, since git anchors info/exclude patterns at
86
+ * the top of the working tree (not at the harness dir). Two guards keep it
87
+ * honest:
79
88
  * - drop any path that escapes the harness config dir (e.g. grok writes
80
89
  * commands back into the tracked `.agents/` tree via a `../` subdir —
81
90
  * ignoring that would hide tracked source; separate bug, PHNX-3718);
@@ -84,7 +93,7 @@ function gitignoreMarkers(agent) {
84
93
  * these never masks a hand-authored or committed file (e.g. a repo that
85
94
  * commits its own `.claude/CLAUDE.md` keeps it — it is not in the manifest).
86
95
  */
87
- export function managedGitignoreEntries(agentRoot, projectRoot, managed) {
96
+ export function managedGitignoreEntries(agentRoot, referenceRoot, managed) {
88
97
  const root = path.resolve(agentRoot);
89
98
  const entries = new Set();
90
99
  for (const rel of managed) {
@@ -93,31 +102,63 @@ export function managedGitignoreEntries(agentRoot, projectRoot, managed) {
93
102
  const abs = path.resolve(agentRoot, rel);
94
103
  if (abs !== root && !abs.startsWith(root + path.sep))
95
104
  continue;
96
- const fromProject = toPosixRel(path.relative(projectRoot, abs));
97
- if (!fromProject || fromProject === '..' || fromProject.startsWith('../'))
105
+ const fromRoot = toPosixRel(path.relative(referenceRoot, abs));
106
+ if (!fromRoot || fromRoot === '..' || fromRoot.startsWith('../'))
98
107
  continue;
99
- entries.add('/' + fromProject);
108
+ entries.add('/' + fromRoot);
100
109
  }
101
110
  return Array.from(entries).sort();
102
111
  }
103
- /** True when `dir` is inside a git working tree — walks up to the filesystem
104
- * root looking for a `.git` entry. `projectRoot` (the parent of the resolved
105
- * `.agents/` dir) is not guaranteed to be the git root: a monorepo subdir can
106
- * carry its own `.agents/` while `.git` lives several levels up. A root-only
107
- * check would silently no-op the whole feature there. */
108
- function isInsideGitRepo(dir) {
109
- let cur = path.resolve(dir);
110
- for (;;) {
111
- if (pathExists(path.join(cur, '.git')))
112
- return true;
113
- const parent = path.dirname(cur);
114
- if (parent === cur)
115
- return false;
116
- cur = parent;
112
+ /**
113
+ * Ask git where the local, per-clone ignore file lives and where the worktree
114
+ * top is, resolved robustly for every layout by delegating to git itself:
115
+ * - normal repo → `<root>/.git/info/exclude`;
116
+ * - monorepo subdir → the same file even when `.git` is several levels up
117
+ * (`projectRoot`, the parent of `.agents/`, is not the git root);
118
+ * - linked worktree / submodule → `.git` is a FILE (`gitdir: …`), and
119
+ * `--git-path info/exclude` resolves to the shared COMMON dir so the block
120
+ * applies across every worktree.
121
+ * `--path-format=absolute` forces absolute paths regardless of the `-C` cwd.
122
+ * One `git rev-parse` yields both paths (exclude path first, worktree root
123
+ * second), so the launch path spawns git ONCE, not twice.
124
+ * Returns null when `dir` is not inside a git repo (git exits non-zero), which
125
+ * fails the feature open (no-op) exactly like the old in-tree check did.
126
+ */
127
+ function resolveGitExcludeTarget(dir) {
128
+ try {
129
+ const out = execFileSync('git', ['-C', dir, 'rev-parse', '--path-format=absolute', '--git-path', 'info/exclude', '--show-toplevel'], { encoding: 'utf-8', stdio: ['ignore', 'pipe', 'ignore'] });
130
+ const [excludePath, worktreeRoot] = out.split('\n').map((l) => l.trim());
131
+ if (!excludePath || !worktreeRoot)
132
+ return null;
133
+ // Fail open on anything that isn't a clean pair of ABSOLUTE paths. A git
134
+ // older than 2.31 (predates `--path-format`) echoes the unrecognized flag
135
+ // back on stdout instead of erroring, which would otherwise shift the parse
136
+ // and have mkdirSync create a stray `--path-format=absolute` dir. The
137
+ // absolute-path check turns that into a clean no-op.
138
+ if (!path.isAbsolute(excludePath) || !path.isAbsolute(worktreeRoot))
139
+ return null;
140
+ return { excludePath, worktreeRoot };
141
+ }
142
+ catch {
143
+ return null;
144
+ }
145
+ }
146
+ /** True when git tracks `absPath` in the repo `dir` sits in. */
147
+ function isTrackedByGit(dir, absPath) {
148
+ try {
149
+ execFileSync('git', ['-C', dir, 'ls-files', '--error-unmatch', '--', absPath], {
150
+ stdio: ['ignore', 'ignore', 'ignore'],
151
+ });
152
+ return true;
153
+ }
154
+ catch {
155
+ return false;
117
156
  }
118
157
  }
119
158
  /**
120
- * Apply this agent's managed block to `.gitignore` content, IN PLACE.
159
+ * Apply this agent's managed block to ignore-file content, IN PLACE — used both
160
+ * to write the block into `.git/info/exclude` and to strip a leftover block from
161
+ * a legacy tracked `.gitignore` (entries `[]` prunes).
121
162
  *
122
163
  * In-place replacement (not strip-then-append) is load-bearing: appending would
123
164
  * move this agent's block behind every other agent's block on each resync, so in
@@ -141,9 +182,11 @@ function applyManagedBlock(content, begin, end, entries) {
141
182
  if (entries.length > 0) {
142
183
  return [...lines.slice(0, bi), begin, ...entries, end, ...lines.slice(ei + 1)].join('\n');
143
184
  }
144
- // Prune the block, tidying the blank lines that hugged it. Vestigial on the
145
- // reconcileProjectGitignore path (its entries always include the manifest,
146
- // so entries.length is never 0 there) — kept for a direct caller/unit test.
185
+ // Prune the block, tidying the blank lines that hugged it. Unreached on the
186
+ // reconcileManagedIgnore write path (its entries always include the manifest,
187
+ // so entries.length is never 0 there), but the load-bearing case for
188
+ // stripLegacyManagedGitignoreBlock, which calls with entries=[] to migrate a
189
+ // leftover block out of the tracked .gitignore.
147
190
  const before = lines.slice(0, bi);
148
191
  const after = lines.slice(ei + 1);
149
192
  while (before.length && before[before.length - 1].trim() === '')
@@ -160,30 +203,42 @@ function applyManagedBlock(content, begin, end, entries) {
160
203
  return body.length > 0 ? `${body}\n\n${block}\n` : `${block}\n`;
161
204
  }
162
205
  /**
163
- * Reconcile a per-agent managed block in `<projectRoot>/.gitignore` so the
164
- * generated per-harness resource dir never shows as untracked dirt. Idempotent
165
- * and convergent: replaces the block in place and writes only when the content
166
- * actually changes, so the launch hot path does not churn the file (or its
167
- * watchers) every run — even in a project synced by several harnesses. When a
168
- * sync clears a harness's resources the block does not vanish: it shrinks to the
169
- * lone `.agents-managed.json` entry (that file still sits in the harness dir and
170
- * must stay ignored), so the block is only ever fully pruned by hand, never via
171
- * this call path. Never creates a `.gitignore` outside a git working tree.
206
+ * Reconcile a per-agent managed block in `.git/info/exclude` so the generated
207
+ * per-harness resource dir never shows as untracked dirt — WITHOUT dirtying the
208
+ * tracked `.gitignore`. Idempotent and convergent: replaces the block in place
209
+ * and writes only when the content actually changes, so the launch hot path does
210
+ * not churn the file (or its watchers) every run — even in a project synced by
211
+ * several harnesses. When a sync clears a harness's resources the block does not
212
+ * vanish: it shrinks to the lone `.agents-managed.json` entry (that file still
213
+ * sits in the harness dir and must stay ignored), so the block is only ever
214
+ * fully pruned by hand, never via this call path. Fails open (no-op) outside a
215
+ * git working tree.
216
+ *
217
+ * Also self-heals repos dirtied by the previous behavior: PHNX-3717 wrote these
218
+ * blocks into `<projectRoot>/.gitignore`, which is never committed upstream, so
219
+ * every launch left a permanent `M .gitignore` that blocked `git pull`
220
+ * (PHNX-3718). `stripLegacyManagedGitignoreBlock` removes this agent's leftover
221
+ * block from that tracked file on the next launch, cleaning the diff instead of
222
+ * stranding it.
172
223
  */
173
- function reconcileProjectGitignore(projectRoot, agent, agentRoot, managed) {
174
- const gitignorePath = path.join(projectRoot, '.gitignore');
175
- if (!isInsideGitRepo(projectRoot) && !pathExists(gitignorePath))
176
- return;
224
+ function reconcileManagedIgnore(projectRoot, agent, agentRoot, managed) {
225
+ // Migrate away from the old tracked-.gitignore location first, so an already
226
+ // dirtied repo cleans itself even if git resolution below fails.
227
+ stripLegacyManagedGitignoreBlock(projectRoot, agent);
228
+ const target = resolveGitExcludeTarget(projectRoot);
229
+ if (!target)
230
+ return; // not a git repo — fail open
177
231
  const { begin, end } = gitignoreMarkers(agent);
178
232
  // Ignore the manifest marker file too, not just the synced resources: the
179
233
  // sync always writes `<agentRoot>/.agents-managed.json`, so without this the
180
234
  // harness dir still shows as untracked in `git status` on the strength of that
181
235
  // one file (defeating the whole point). It lives at agentRoot, so it resolves
182
- // through the same anchoring + escape guard as any managed path.
183
- const entries = managedGitignoreEntries(agentRoot, projectRoot, [MANIFEST_FILE, ...managed]);
236
+ // through the same anchoring + escape guard as any managed path. Anchored to
237
+ // the worktree root, since info/exclude patterns resolve against the tree top.
238
+ const entries = managedGitignoreEntries(agentRoot, target.worktreeRoot, [MANIFEST_FILE, ...managed]);
184
239
  let original = '';
185
240
  try {
186
- original = fs.readFileSync(gitignorePath, 'utf-8');
241
+ original = fs.readFileSync(target.excludePath, 'utf-8');
187
242
  }
188
243
  catch {
189
244
  original = '';
@@ -191,8 +246,42 @@ function reconcileProjectGitignore(projectRoot, agent, agentRoot, managed) {
191
246
  const next = applyManagedBlock(original, begin, end, entries);
192
247
  if (next === null || next === original)
193
248
  return;
194
- const tmp = gitignorePath + '.tmp';
249
+ fs.mkdirSync(path.dirname(target.excludePath), { recursive: true }); // create info/ if missing
250
+ const tmp = target.excludePath + '.tmp';
195
251
  fs.writeFileSync(tmp, next);
252
+ fs.renameSync(tmp, target.excludePath);
253
+ }
254
+ /**
255
+ * Remove this agent's leftover managed block from a tracked `<projectRoot>/
256
+ * .gitignore` written by the pre-PHNX-3718 behavior. Strips ONLY the fenced
257
+ * block (leaving every hand-written rule untouched), never creates the file,
258
+ * and never touches a `.gitignore` that carries no block of ours. If stripping
259
+ * empties a file we created (its only content was our block), the empty file is
260
+ * removed when git does not track it — an empty untracked `.gitignore` would
261
+ * still read as `?? .gitignore` dirt, the very thing this migration clears.
262
+ */
263
+ function stripLegacyManagedGitignoreBlock(projectRoot, agent) {
264
+ const gitignorePath = path.join(projectRoot, '.gitignore');
265
+ let original;
266
+ try {
267
+ original = fs.readFileSync(gitignorePath, 'utf-8');
268
+ }
269
+ catch {
270
+ return; // no .gitignore — nothing to migrate
271
+ }
272
+ const { begin, end } = gitignoreMarkers(agent);
273
+ // Empty entries → applyManagedBlock prunes the block; returns `original`
274
+ // unchanged when there is no block, or null on an orphaned begin marker
275
+ // (which we refuse to touch rather than truncate the user's rules).
276
+ const stripped = applyManagedBlock(original, begin, end, []);
277
+ if (stripped === null || stripped === original)
278
+ return;
279
+ if (stripped === '' && !isTrackedByGit(projectRoot, gitignorePath)) {
280
+ removePath(gitignorePath);
281
+ return;
282
+ }
283
+ const tmp = gitignorePath + '.tmp';
284
+ fs.writeFileSync(tmp, stripped);
196
285
  fs.renameSync(tmp, gitignorePath);
197
286
  }
198
287
  function removeManagedPath(agentRoot, rel) {
@@ -2,8 +2,16 @@
2
2
  * Rush session freshness — the ONE judgment shared by every consumer of
3
3
  * ~/.rush/user.yaml (cloud dispatch, cloud session source, secrets sync driver).
4
4
  *
5
- * A Rush session stores `expires_at` as Unix seconds. Two values mean "never
6
- * expires" and MUST NOT be read as an absolute timestamp:
5
+ * A Rush session stores `expires_at` as Unix MILLISECONDS — the same unit
6
+ * rush/cli writes and reads it in (`sessionTokenFresh` in auth_reader.go compares
7
+ * against `time.Now()...UnixMilli()`), and the same unit the JWT `exp` claim maps
8
+ * to (`exp * 1000`). So freshness must compare it against `Date.now()` (ms), NOT
9
+ * `Date.now() / 1000` (seconds). Comparing ms against seconds was PHNX-3805: a
10
+ * long-expired session (e.g. `expires_at: 1788157222000`) read as `1.788e12 <=
11
+ * 1.788e9` → always false → never expired, so `agents cloud providers` reported
12
+ * Rush `available: true` on a dead session and every dispatch failed with a 401.
13
+ *
14
+ * Two values mean "never expires" and MUST NOT be read as an absolute timestamp:
7
15
  * - `0` — an opaque Phoenix `pid_` bearer written by `rush login`;
8
16
  * `0` = non-expiring by contract (mirrors rush/cli's isFresh,
9
17
  * the same fix RUSH-1310 landed for the daemon-less freshness
@@ -2,8 +2,16 @@
2
2
  * Rush session freshness — the ONE judgment shared by every consumer of
3
3
  * ~/.rush/user.yaml (cloud dispatch, cloud session source, secrets sync driver).
4
4
  *
5
- * A Rush session stores `expires_at` as Unix seconds. Two values mean "never
6
- * expires" and MUST NOT be read as an absolute timestamp:
5
+ * A Rush session stores `expires_at` as Unix MILLISECONDS — the same unit
6
+ * rush/cli writes and reads it in (`sessionTokenFresh` in auth_reader.go compares
7
+ * against `time.Now()...UnixMilli()`), and the same unit the JWT `exp` claim maps
8
+ * to (`exp * 1000`). So freshness must compare it against `Date.now()` (ms), NOT
9
+ * `Date.now() / 1000` (seconds). Comparing ms against seconds was PHNX-3805: a
10
+ * long-expired session (e.g. `expires_at: 1788157222000`) read as `1.788e12 <=
11
+ * 1.788e9` → always false → never expired, so `agents cloud providers` reported
12
+ * Rush `available: true` on a dead session and every dispatch failed with a 401.
13
+ *
14
+ * Two values mean "never expires" and MUST NOT be read as an absolute timestamp:
7
15
  * - `0` — an opaque Phoenix `pid_` bearer written by `rush login`;
8
16
  * `0` = non-expiring by contract (mirrors rush/cli's isFresh,
9
17
  * the same fix RUSH-1310 landed for the daemon-less freshness
@@ -20,5 +28,6 @@ export function isRushSessionExpired(expiresAt) {
20
28
  // `0` (non-expiring pid_ bearer) and a missing value are never expired.
21
29
  if (typeof expiresAt !== 'number' || expiresAt === 0)
22
30
  return false;
23
- return expiresAt <= Date.now() / 1000;
31
+ // expires_at is Unix milliseconds — compare ms to ms.
32
+ return expiresAt <= Date.now();
24
33
  }
@@ -29,7 +29,7 @@ function readRushToken() {
29
29
  }
30
30
  const expiresAt = data.session?.expires_at;
31
31
  if (isRushSessionExpired(expiresAt)) {
32
- const expiredAt = new Date(expiresAt * 1000).toISOString();
32
+ const expiredAt = new Date(expiresAt).toISOString();
33
33
  throw new Error(`Rush session expired at ${expiredAt}. Run \`rush login\` to refresh.`);
34
34
  }
35
35
  return token;
@@ -32,7 +32,7 @@ function readToken() {
32
32
  }
33
33
  const expiresAt = data.session?.expires_at;
34
34
  if (isRushSessionExpired(expiresAt)) {
35
- const expiredAt = new Date(expiresAt * 1000).toISOString();
35
+ const expiredAt = new Date(expiresAt).toISOString();
36
36
  throw new Error(`Rush session expired at ${expiredAt}. Run \`rush login\` to refresh.`);
37
37
  }
38
38
  return token;
@@ -13,3 +13,27 @@ export declare function activeSessionToSessionMeta(active: ActiveSession, self:
13
13
  * (no id, non-agent kind) are dropped. Order follows the input.
14
14
  */
15
15
  export declare function liveSessionMetas(active: ActiveSession[], self: string, nowMs: number): SessionMeta[];
16
+ /**
17
+ * Which box the AGENT of each live session executes on, keyed by lowercased id,
18
+ * as recovered from the fleet-active snapshot (`agents sessions --active`'s
19
+ * cross-machine merge). A dispatcher that launched a session running on a peer
20
+ * has a live shim row locally whose `machine` self-defaulted to itself — the
21
+ * launch process IS here, the agent is not (PHNX-3890). The fleet snapshot
22
+ * already reconciled that against the peer's own self-report, so it is the one
23
+ * place that knows the true execution host without a synced index row (which is
24
+ * why `foldExecutionMachine` can't recover it: there is no local index row to
25
+ * join). Only rows the fleet attributes to a real machine are included.
26
+ */
27
+ export declare function fleetExecutionMachineById(fleet: ActiveSession[]): Map<string, string>;
28
+ /**
29
+ * Correct a live `SessionMeta` candidate's machine to its true EXECUTION host
30
+ * using the fleet-active attribution (PHNX-3890). Only a **self-attributed,
31
+ * transcript-less** row is a candidate for correction — the launcher-shim shape
32
+ * a dispatcher holds for a session whose agent runs on a peer, which is
33
+ * indistinguishable from a genuinely-local just-started session by its local
34
+ * fields alone. A row already attributed to another box, or one carrying a
35
+ * transcript path, is left untouched. A corrected row is stamped `_remote` so
36
+ * the read-vs-resume router (`transcriptOnPeerOf`) sends the preview to the
37
+ * owning peer instead of dead-ending on the local "not indexed here" stub.
38
+ */
39
+ export declare function reconcileLiveMetaMachine(metas: SessionMeta[], fleetExecutionMachine: Map<string, string>, self: string): SessionMeta[];
@@ -76,3 +76,58 @@ export function liveSessionMetas(active, self, nowMs) {
76
76
  }
77
77
  return out;
78
78
  }
79
+ /**
80
+ * Which box the AGENT of each live session executes on, keyed by lowercased id,
81
+ * as recovered from the fleet-active snapshot (`agents sessions --active`'s
82
+ * cross-machine merge). A dispatcher that launched a session running on a peer
83
+ * has a live shim row locally whose `machine` self-defaulted to itself — the
84
+ * launch process IS here, the agent is not (PHNX-3890). The fleet snapshot
85
+ * already reconciled that against the peer's own self-report, so it is the one
86
+ * place that knows the true execution host without a synced index row (which is
87
+ * why `foldExecutionMachine` can't recover it: there is no local index row to
88
+ * join). Only rows the fleet attributes to a real machine are included.
89
+ */
90
+ export function fleetExecutionMachineById(fleet) {
91
+ const byId = new Map();
92
+ for (const s of fleet) {
93
+ // The AGENT machine is `machine` (where the transcript/harness lives), NOT
94
+ // `offloadedFrom` (where the launcher shim runs) — reading follows the
95
+ // transcript owner, so a would-be reader must reach `machine`.
96
+ if (!s.sessionId || !s.machine)
97
+ continue;
98
+ byId.set(s.sessionId.toLowerCase(), s.machine);
99
+ }
100
+ return byId;
101
+ }
102
+ /**
103
+ * Correct a live `SessionMeta` candidate's machine to its true EXECUTION host
104
+ * using the fleet-active attribution (PHNX-3890). Only a **self-attributed,
105
+ * transcript-less** row is a candidate for correction — the launcher-shim shape
106
+ * a dispatcher holds for a session whose agent runs on a peer, which is
107
+ * indistinguishable from a genuinely-local just-started session by its local
108
+ * fields alone. A row already attributed to another box, or one carrying a
109
+ * transcript path, is left untouched. A corrected row is stamped `_remote` so
110
+ * the read-vs-resume router (`transcriptOnPeerOf`) sends the preview to the
111
+ * owning peer instead of dead-ending on the local "not indexed here" stub.
112
+ */
113
+ export function reconcileLiveMetaMachine(metas, fleetExecutionMachine, self) {
114
+ return metas.map(meta => {
115
+ // Already attributed elsewhere, or locally readable — not a self-default.
116
+ if (meta.machine && meta.machine !== self)
117
+ return meta;
118
+ if (meta.filePath)
119
+ return meta;
120
+ const exec = fleetExecutionMachine.get(meta.id.toLowerCase());
121
+ // Only a PEER attribution is positive information. A snapshot entry that
122
+ // names THIS box cannot be trusted as confirmation, because the snapshot is
123
+ // a merge that INCLUDES this box's own rows — so for exactly the session
124
+ // shape being corrected here (no index row to fold from), a `self` entry may
125
+ // be nothing but an echo of the self-default above, recorded while the
126
+ // owning peer had not reported yet or was unreachable during that gather.
127
+ // Treating it as proof would skip the fan-out and dead-end on the local stub
128
+ // for a session genuinely running elsewhere — the PHNX-3890 bug itself.
129
+ if (!exec || exec === self)
130
+ return meta;
131
+ return { ...meta, machine: exec, _remote: true };
132
+ });
133
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@phnx-labs/agents-cli",
3
- "version": "1.22.73",
3
+ "version": "1.22.74",
4
4
  "description": "One CLI for all your AI coding agents - versions, config, cloud dispatch, sessions, and teams (now with first-class Grok Build CLI support)",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",