@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 +32 -0
- package/dist/commands/sessions.d.ts +50 -1
- package/dist/commands/sessions.js +79 -5
- package/dist/lib/cloud/rush.d.ts +1 -1
- package/dist/lib/cloud/rush.js +2 -2
- package/dist/lib/project-resources.d.ts +6 -2
- package/dist/lib/project-resources.js +133 -44
- package/dist/lib/rush-session.d.ts +10 -2
- package/dist/lib/rush-session.js +12 -3
- package/dist/lib/secrets/drivers/rush.js +1 -1
- package/dist/lib/session/cloud.js +1 -1
- package/dist/lib/session/live-metadata.d.ts +24 -0
- package/dist/lib/session/live-metadata.js +55 -0
- package/package.json +1 -1
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
|
-
/**
|
|
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
|
|
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);
|
package/dist/lib/cloud/rush.d.ts
CHANGED
|
@@ -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
|
|
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.
|
package/dist/lib/cloud/rush.js
CHANGED
|
@@ -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
|
|
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
|
|
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,
|
|
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,
|
|
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
|
|
43
|
-
//
|
|
44
|
-
//
|
|
45
|
-
|
|
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,
|
|
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,
|
|
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
|
|
97
|
-
if (!
|
|
105
|
+
const fromRoot = toPosixRel(path.relative(referenceRoot, abs));
|
|
106
|
+
if (!fromRoot || fromRoot === '..' || fromRoot.startsWith('../'))
|
|
98
107
|
continue;
|
|
99
|
-
entries.add('/' +
|
|
108
|
+
entries.add('/' + fromRoot);
|
|
100
109
|
}
|
|
101
110
|
return Array.from(entries).sort();
|
|
102
111
|
}
|
|
103
|
-
/**
|
|
104
|
-
*
|
|
105
|
-
*
|
|
106
|
-
*
|
|
107
|
-
*
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
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
|
|
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.
|
|
145
|
-
//
|
|
146
|
-
// so entries.length is never 0 there)
|
|
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
|
|
164
|
-
*
|
|
165
|
-
* and convergent: replaces the block in place
|
|
166
|
-
* actually changes, so the launch hot path does
|
|
167
|
-
* watchers) every run — even in a project synced by
|
|
168
|
-
* sync clears a harness's resources the block does not
|
|
169
|
-
* lone `.agents-managed.json` entry (that file still
|
|
170
|
-
* must stay ignored), so the block is only ever
|
|
171
|
-
* this call path.
|
|
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
|
|
174
|
-
|
|
175
|
-
if
|
|
176
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
|
6
|
-
*
|
|
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
|
package/dist/lib/rush-session.js
CHANGED
|
@@ -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
|
|
6
|
-
*
|
|
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
|
-
|
|
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
|
|
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
|
|
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.
|
|
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",
|