@phnx-labs/agents-cli 1.22.72 → 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.
Files changed (61) hide show
  1. package/CHANGELOG.md +73 -0
  2. package/README.md +2 -0
  3. package/dist/cli/command-registry.d.ts +1 -1
  4. package/dist/cli/command-registry.js +2 -1
  5. package/dist/commands/packages-materialize.d.ts +17 -0
  6. package/dist/commands/packages-materialize.js +93 -0
  7. package/dist/commands/packages.d.ts +6 -4
  8. package/dist/commands/packages.js +8 -4
  9. package/dist/commands/repo.js +8 -8
  10. package/dist/commands/sessions-picker.js +38 -7
  11. package/dist/commands/sessions.d.ts +50 -1
  12. package/dist/commands/sessions.js +85 -10
  13. package/dist/lib/actor.d.ts +36 -4
  14. package/dist/lib/actor.js +73 -9
  15. package/dist/lib/agent-spec/index.d.ts +4 -0
  16. package/dist/lib/agent-spec/index.js +5 -0
  17. package/dist/lib/agent-spec/materialize.d.ts +13 -0
  18. package/dist/lib/agent-spec/materialize.js +414 -0
  19. package/dist/lib/agent-spec/package-resolve.d.ts +12 -0
  20. package/dist/lib/agent-spec/package-resolve.js +274 -0
  21. package/dist/lib/agent-spec/package-schema.d.ts +5 -0
  22. package/dist/lib/agent-spec/package-schema.js +147 -0
  23. package/dist/lib/agent-spec/package-types.d.ts +121 -0
  24. package/dist/lib/agent-spec/package-types.js +11 -0
  25. package/dist/lib/cloud/rush.d.ts +1 -1
  26. package/dist/lib/cloud/rush.js +2 -2
  27. package/dist/lib/daemon/usage-sync-service.d.ts +12 -5
  28. package/dist/lib/daemon/usage-sync-service.js +27 -5
  29. package/dist/lib/exec.js +3 -0
  30. package/dist/lib/fleet-shared-state.d.ts +30 -0
  31. package/dist/lib/fleet-shared-state.js +5 -0
  32. package/dist/lib/hooks/install.d.ts +31 -1
  33. package/dist/lib/hooks/install.js +44 -2
  34. package/dist/lib/mcp.d.ts +14 -2
  35. package/dist/lib/mcp.js +12 -2
  36. package/dist/lib/packages/output-home.d.ts +39 -0
  37. package/dist/lib/packages/output-home.js +203 -0
  38. package/dist/lib/paths.d.ts +9 -0
  39. package/dist/lib/paths.js +26 -0
  40. package/dist/lib/project-resources.d.ts +6 -2
  41. package/dist/lib/project-resources.js +133 -44
  42. package/dist/lib/rush-session.d.ts +10 -2
  43. package/dist/lib/rush-session.js +12 -3
  44. package/dist/lib/secrets/drivers/rush.js +1 -1
  45. package/dist/lib/session/active.d.ts +12 -0
  46. package/dist/lib/session/active.js +5 -1
  47. package/dist/lib/session/actor-sidecar.d.ts +7 -0
  48. package/dist/lib/session/actor-sidecar.js +2 -0
  49. package/dist/lib/session/cloud.js +1 -1
  50. package/dist/lib/session/db.d.ts +60 -1
  51. package/dist/lib/session/db.js +191 -6
  52. package/dist/lib/session/live-metadata.d.ts +24 -0
  53. package/dist/lib/session/live-metadata.js +55 -0
  54. package/dist/lib/session/mirror.d.ts +67 -0
  55. package/dist/lib/session/mirror.js +158 -0
  56. package/dist/lib/session/types.d.ts +18 -0
  57. package/dist/lib/spinner.d.ts +39 -0
  58. package/dist/lib/spinner.js +41 -0
  59. package/dist/lib/startup/command-registry.js +1 -1
  60. package/dist/lib/types.d.ts +6 -0
  61. package/package.json +1 -1
@@ -18,6 +18,7 @@ import { sanitizeForTerminal, redactSecrets } from '../lib/redact.js';
18
18
  import { resolveProjectKey } from '../lib/project-key.js';
19
19
  import { listProjectDefs, resolveProjectNameForCwd } from '../lib/projects.js';
20
20
  import ora from 'ora';
21
+ import { interruptibleSpinner } from '../lib/spinner.js';
21
22
  import { SESSION_AGENTS, isAgentTmuxAlias, sessionDisplayAgent } from '../lib/session/types.js';
22
23
  import { discoverArtifacts, readArtifact, resolveArtifact } from '../lib/session/artifacts.js';
23
24
  import { looksLikePath, toComparablePath, homeDir, needsWindowsShell, composeWin32CommandLine } from '../lib/platform/index.js';
@@ -28,14 +29,14 @@ import { mapPanesToTargets, listClients } from '../lib/tmux/session.js';
28
29
  import { resolveViewingIn, viewingInLabel } from '../lib/session/viewing-in.js';
29
30
  import { machineId, normalizeHost } from '../lib/session/sync/config.js';
30
31
  import { gatherRemoteActive, NO_FANOUT_ENV } from '../lib/session/remote-active.js';
31
- import { loadFleetActiveSessions, loadLocalActiveSessions, } from '../lib/session/session-cache.js';
32
+ import { loadFleetActiveSessions, loadLocalActiveSessions, readActiveSessionsCache, } from '../lib/session/session-cache.js';
32
33
  import { gatherRemoteList, gatherRemoteToolProgramCounts, gatherRemoteToolSearch, runOnPeer } from '../lib/session/remote-list.js';
33
34
  import { gatherRemoteAgentsJson } from '../lib/remote-agents-json.js';
34
35
  import { stringWidth, truncateToWidth, padToWidth, terminalWidth } from '../lib/session/width.js';
35
36
  import { inferSessionState } from '../lib/session/state.js';
36
37
  import { discoverSessions, queryIndexedSessions, countSessionsInScope, resolveSessionById, isCompleteSessionId, looksLikeSessionId, searchContentIndex, getSessionRoots, scopeToManaged } from '../lib/session/discover.js';
37
38
  import { findSessionsById, querySessions, readSessionContent, readArchivedSessionPreview } from '../lib/session/db.js';
38
- import { liveSessionMetas } from '../lib/session/live-metadata.js';
39
+ import { liveSessionMetas, fleetExecutionMachineById, reconcileLiveMetaMachine } from '../lib/session/live-metadata.js';
39
40
  import { filterTeamSessions, shouldShowTeamSessions, safeTeamText, groupSessionsByTeam, NO_TEAM_GROUP_KEY, } from '../lib/session/team-filter.js';
40
41
  import { parseSession } from '../lib/session/parse.js';
41
42
  import { runRemoteSessions, buildForwardedArgs, ensureWholeIndex } from '../lib/session/remote.js';
@@ -2801,7 +2802,7 @@ limitSource) {
2801
2802
  const forwarded = ensureWholeIndex(buildForwardedArgs(process.argv, new Set(options.host ?? [])));
2802
2803
  if (!forwarded.includes('--json'))
2803
2804
  forwarded.push('--json');
2804
- const fanSpinner = isInteractiveTerminal() ? ora('Reaching other machines...').start() : null;
2805
+ const fanSpinner = isInteractiveTerminal() ? interruptibleSpinner('Reaching other machines...').start() : null;
2805
2806
  try {
2806
2807
  const { sessions: remoteSessions } = await gatherRemoteList(forwarded, undefined);
2807
2808
  if (remoteSessions.length > 0) {
@@ -2841,7 +2842,7 @@ limitSource) {
2841
2842
  const forwarded = buildForwardedArgs(process.argv, new Set(options.host ?? []));
2842
2843
  if (!forwarded.includes('--json'))
2843
2844
  forwarded.push('--json');
2844
- const fanSpinner = isInteractiveTerminal() ? ora('Reaching other machines...').start() : null;
2845
+ const fanSpinner = isInteractiveTerminal() ? interruptibleSpinner('Reaching other machines...').start() : null;
2845
2846
  try {
2846
2847
  const { sessions: remoteSessions } = await gatherRemoteList(forwarded, options.host);
2847
2848
  if (remoteSessions.length > 0) {
@@ -4105,7 +4106,7 @@ async function runCloudSessions(query, options) {
4105
4106
  process.exit(1);
4106
4107
  }
4107
4108
  const mode = resolveViewMode(options, filterOpts);
4108
- const spinner = options.json ? null : ora('Loading cloud sessions...').start();
4109
+ const spinner = options.json ? null : interruptibleSpinner('Loading cloud sessions...').start();
4109
4110
  let sessions;
4110
4111
  try {
4111
4112
  sessions = await discoverCloudSessions({ limit: parseInt(options.limit || '50', 10) });
@@ -4141,7 +4142,7 @@ async function runCloudSessions(query, options) {
4141
4142
  process.exit(1);
4142
4143
  }
4143
4144
  const meta = matches[0];
4144
- const cachedSpinner = options.json ? null : ora('Fetching session...').start();
4145
+ const cachedSpinner = options.json ? null : interruptibleSpinner('Fetching session...').start();
4145
4146
  let cachedPath;
4146
4147
  try {
4147
4148
  cachedPath = await ensureCloudSessionCached(meta.id);
@@ -4843,6 +4844,59 @@ export function metadataResolveOutcome(localMatches, remote, selector) {
4843
4844
  return { kind: 'ambiguous', candidates };
4844
4845
  return { kind: 'resolved', session: candidates[0].hits[0].session };
4845
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
+ }
4846
4900
  /**
4847
4901
  * Local metadata candidates for a selector: the indexed SQLite rows, plus — when
4848
4902
  * an id-shaped selector misses the index entirely — the live-session registry
@@ -4880,7 +4934,20 @@ export async function computeLocalMetadataMatches(selector, scope, deps = {}) {
4880
4934
  */
4881
4935
  export async function liveMetadataMatches(selector, scope, self, deps = {}) {
4882
4936
  const load = deps.loadActive ?? loadLocalActiveSessions;
4883
- 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);
4884
4951
  try {
4885
4952
  const cached = liveSessionMetas((await load()).sessions, self, Date.now());
4886
4953
  const hit = match(cached);
@@ -4897,16 +4964,24 @@ export async function liveMetadataMatches(selector, scope, self, deps = {}) {
4897
4964
  * Full UUIDs hit the local SQLite index without any SSH fan-out. */
4898
4965
  export async function resolveSessionMetadataValue(selector, scope = {}, deps = { gatherRemoteList }) {
4899
4966
  const localMatches = await computeLocalMetadataMatches(selector, scope, deps);
4967
+ const localMachine = machineId();
4900
4968
  // A full-UUID local hit resolves with ZERO SSH: a UUID is globally unique, so
4901
4969
  // finding it locally is the whole answer and no peer is dialed. (A label is
4902
4970
  // NOT resolved local-only here — it can collide with a same-label session on a
4903
4971
  // peer, so it must consult the fleet; see isDefinitiveMatch, RUSH-2203.) The
4904
4972
  // local index lookup is synchronous and completes before any fan-out spawns,
4905
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).
4906
4980
  if (FULL_SESSION_ID_RE.test(selector)) {
4907
4981
  const localOutcome = metadataResolveOutcome(localMatches, { sessions: [], unreachable: [] }, selector);
4908
- if (localOutcome.kind === 'resolved')
4982
+ if (localOutcome.kind === 'resolved' && isLocallyDefinitiveMatch(localOutcome.session, localMachine)) {
4909
4983
  return localOutcome;
4984
+ }
4910
4985
  }
4911
4986
  if (scope.local === true)
4912
4987
  return metadataResolveOutcome(localMatches, { sessions: [], unreachable: [] }, selector);
@@ -4919,7 +4994,7 @@ export async function resolveSessionMetadataValue(selector, scope = {}, deps = {
4919
4994
  const remote = await deps.gatherRemoteList(forwarded, scope.hosts, selectorAllowsEarlyExit(selector)
4920
4995
  ? { isDefinitive: (session) => isDefinitiveMatch(session, selector) }
4921
4996
  : undefined);
4922
- return metadataResolveOutcome(localMatches, remote, selector);
4997
+ return metadataResolveOutcome(preferOwnerAttribution(localMatches, remote.sessions, localMachine), remote, selector);
4923
4998
  }
4924
4999
  catch (error) {
4925
5000
  return metadataResolveOutcome(localMatches, { sessions: [], unreachable: [error?.message ?? 'fleet fan-out'] }, selector);
@@ -4972,7 +5047,7 @@ export async function resolveSessionMetadata(selector, scope, deps = { gatherRem
4972
5047
  process.stdout.write(serializeResolvedSessionsJson([outcome.session]));
4973
5048
  }
4974
5049
  export async function resolveSessionAcrossFleet(query, mode, hosts, deps = { gatherRemoteList, runOnPeer }) {
4975
- const spinner = isInteractiveTerminal() ? ora('Searching the fleet...').start() : null;
5050
+ const spinner = isInteractiveTerminal() ? interruptibleSpinner('Searching the fleet...').start() : null;
4976
5051
  let candidates = [];
4977
5052
  let deviceCount = 0;
4978
5053
  let unreachable = [];
@@ -13,6 +13,13 @@ export interface ResolvedActor {
13
13
  email?: string;
14
14
  /** GitHub handle, when the actors map records one. */
15
15
  github?: string;
16
+ /**
17
+ * Phoenix (work) identity id for this human, when the actors map records one.
18
+ * Bridges a personal tailnet login (e.g. a personal gmail) to the stable
19
+ * internal work identity, so attribution survives whichever email a person
20
+ * happens to be signed into tailscale with.
21
+ */
22
+ phoenixId?: string;
16
23
  }
17
24
  /** Result of `tailscale whois --json <ip>` we care about. */
18
25
  export interface WhoisIdentity {
@@ -28,16 +35,41 @@ export interface WhoisIdentity {
28
35
  */
29
36
  export declare function actorFromIdentity(who: WhoisIdentity | undefined, host: string, actors: Record<string, ActorConfig>): ResolvedActor;
30
37
  /**
31
- * Compute the actor for a given environment. Pure with respect to `env` (the
32
- * only impurity is the `tailscale whois` / config read on the fresh-SSH path),
33
- * so tests can drive every branch by passing an env explicitly.
38
+ * Injectable tailscale resolvers, so tests can drive the SSH-whois and
39
+ * local-self branches deterministically without a real tailscale on the box
40
+ * (a dev machine that *is* on the tailnet would otherwise make the local path
41
+ * non-deterministic). Production callers use the defaults.
34
42
  */
35
- export declare function computeActor(env?: NodeJS.ProcessEnv): ResolvedActor;
43
+ export interface ActorResolvers {
44
+ whois: (ip: string) => WhoisIdentity | undefined;
45
+ self: () => WhoisIdentity | undefined;
46
+ }
47
+ /**
48
+ * Compute the actor for a given environment. The only impurity is the tailscale
49
+ * shell-out (injectable via `resolvers`), so tests drive every branch explicitly.
50
+ *
51
+ * Resolution order: an inherited env actor wins; otherwise an SSH run whois-es
52
+ * its client IP; a local run (no SSH) credits the device's own tailnet owner;
53
+ * and anything unresolvable degrades to `UNRESOLVED@<host>`. Note the self
54
+ * fallback fires ONLY for a truly local run — an SSH run whose whois fails must
55
+ * NOT be credited to the box owner (that would misattribute a remote human to
56
+ * whoever owns the machine).
57
+ */
58
+ export declare function computeActor(env?: NodeJS.ProcessEnv, resolvers?: ActorResolvers): ResolvedActor;
36
59
  /**
37
60
  * Resolve the actor for the current process, cached for the process lifetime
38
61
  * (the SSH `whois` shell-out runs at most once).
39
62
  */
40
63
  export declare function resolveActor(): ResolvedActor;
64
+ /**
65
+ * Test-only: pin the tailscale resolvers `resolveActor()` uses, so a test that
66
+ * exercises the cached production entrypoint (e.g. `withActorEnv()`) is isolated
67
+ * from whether the box running it is on the tailnet. `computeActor` already takes
68
+ * injected resolvers for its unit tests; this extends the same seam to the cached
69
+ * path. Pass `undefined` to restore the real tailscale resolvers. Resets the
70
+ * cache so the next `resolveActor()` recomputes under the new resolvers.
71
+ */
72
+ export declare function setActorResolvers(resolvers: ActorResolvers | undefined): void;
41
73
  /** Clear the per-process cache. For tests, and for env changes within a run. */
42
74
  export declare function resetActorCache(): void;
43
75
  /**
package/dist/lib/actor.js CHANGED
@@ -7,8 +7,10 @@
7
7
  *
8
8
  * - Over SSH (the shared-fleet case): `tailscale whois` the SSH client IP to
9
9
  * the connecting tailnet identity -- a real name + login email.
10
- * - Locally (non-SSH): we can't honestly say who is at the box, so the id is
11
- * `UNRESOLVED@<host>` and no personal git identity is claimed.
10
+ * - Locally (non-SSH): the run belongs to whoever owns this device on the
11
+ * tailnet, so we read that owner from `tailscale status` (`.Self.UserID` ->
12
+ * `.User[]`). Only if tailscale can't name the device owner either do we fall
13
+ * back to the honest `UNRESOLVED@<host>` with no personal git identity claimed.
12
14
  * - Inherited: a child spawn trusts the `AGENTS_ACTOR*` env its parent
13
15
  * stamped rather than re-resolving, so the whole spawn tree shares one actor.
14
16
  *
@@ -54,6 +56,38 @@ function tailscaleWhois(ip) {
54
56
  return undefined;
55
57
  }
56
58
  }
59
+ /**
60
+ * Resolve this device's own tailnet owner via `tailscale status --json`:
61
+ * `.Self.UserID` indexes into the `.User` map for the owner's login + display
62
+ * name. Used for a LOCAL run, where there is no SSH client to whois — the run
63
+ * belongs to whoever owns the box on the tailnet. Same graceful-undefined +
64
+ * timeout discipline as `tailscaleWhois`: tailscale absent, a wedged daemon, a
65
+ * tagged (owner-less) device, or a parse failure all yield undefined, never an
66
+ * error and never a hang on the spawn hot path.
67
+ */
68
+ function tailscaleSelf() {
69
+ try {
70
+ const res = spawnSync('tailscale', ['status', '--json'], {
71
+ encoding: 'utf-8',
72
+ windowsHide: true,
73
+ timeout: WHOIS_TIMEOUT_MS,
74
+ });
75
+ if (res.status !== 0 || !res.stdout)
76
+ return undefined;
77
+ const data = JSON.parse(res.stdout);
78
+ const uid = data.Self?.UserID;
79
+ if (uid == null)
80
+ return undefined;
81
+ // The User map is keyed by the UserID rendered as a string.
82
+ const u = data.User?.[String(uid)];
83
+ if (!u?.LoginName)
84
+ return undefined;
85
+ return { login: u.LoginName, displayName: u.DisplayName };
86
+ }
87
+ catch {
88
+ return undefined;
89
+ }
90
+ }
57
91
  /** Read the actors map from config, tolerant of a missing/unreadable config. */
58
92
  function readActors() {
59
93
  try {
@@ -96,6 +130,7 @@ export function actorFromIdentity(who, host, actors) {
96
130
  name: cfg?.name ?? who?.displayName,
97
131
  email: cfg?.email ?? emailFromLogin,
98
132
  github: cfg?.github,
133
+ phoenixId: cfg?.phoenixId,
99
134
  };
100
135
  }
101
136
  /** Reconstruct an actor an ancestor process already resolved into the env. */
@@ -109,31 +144,58 @@ function inheritedActor(env) {
109
144
  name: env.AGENTS_ACTOR_NAME || undefined,
110
145
  email: env.AGENTS_ACTOR_EMAIL || undefined,
111
146
  github: env.AGENTS_ACTOR_GITHUB || undefined,
147
+ phoenixId: env.AGENTS_ACTOR_PHOENIX_ID || undefined,
112
148
  };
113
149
  }
150
+ const defaultResolvers = { whois: tailscaleWhois, self: tailscaleSelf };
114
151
  /**
115
- * Compute the actor for a given environment. Pure with respect to `env` (the
116
- * only impurity is the `tailscale whois` / config read on the fresh-SSH path),
117
- * so tests can drive every branch by passing an env explicitly.
152
+ * Compute the actor for a given environment. The only impurity is the tailscale
153
+ * shell-out (injectable via `resolvers`), so tests drive every branch explicitly.
154
+ *
155
+ * Resolution order: an inherited env actor wins; otherwise an SSH run whois-es
156
+ * its client IP; a local run (no SSH) credits the device's own tailnet owner;
157
+ * and anything unresolvable degrades to `UNRESOLVED@<host>`. Note the self
158
+ * fallback fires ONLY for a truly local run — an SSH run whose whois fails must
159
+ * NOT be credited to the box owner (that would misattribute a remote human to
160
+ * whoever owns the machine).
118
161
  */
119
- export function computeActor(env = process.env) {
162
+ export function computeActor(env = process.env, resolvers = defaultResolvers) {
120
163
  const inherited = inheritedActor(env);
121
164
  if (inherited)
122
165
  return inherited;
123
- const ssh = env.SSH_CONNECTION ? parseSshConnection(env.SSH_CONNECTION) : undefined;
124
- const who = ssh?.clientIp ? tailscaleWhois(ssh.clientIp) : undefined;
166
+ const sshRaw = env.SSH_CONNECTION;
167
+ const ssh = sshRaw ? parseSshConnection(sshRaw) : undefined;
168
+ let who = ssh?.clientIp ? resolvers.whois(ssh.clientIp) : undefined;
169
+ // Self-credit only for a genuinely LOCAL run (no SSH_CONNECTION at all). An
170
+ // SSH session whose connection is unparseable or unresolvable stays
171
+ // UNRESOLVED rather than being misattributed to the box's owner.
172
+ if (!who && !sshRaw)
173
+ who = resolvers.self();
125
174
  return actorFromIdentity(who, machineId(), readActors());
126
175
  }
127
176
  let cached;
177
+ let resolverOverride;
128
178
  /**
129
179
  * Resolve the actor for the current process, cached for the process lifetime
130
180
  * (the SSH `whois` shell-out runs at most once).
131
181
  */
132
182
  export function resolveActor() {
133
183
  if (!cached)
134
- cached = computeActor(process.env);
184
+ cached = computeActor(process.env, resolverOverride ?? defaultResolvers);
135
185
  return cached;
136
186
  }
187
+ /**
188
+ * Test-only: pin the tailscale resolvers `resolveActor()` uses, so a test that
189
+ * exercises the cached production entrypoint (e.g. `withActorEnv()`) is isolated
190
+ * from whether the box running it is on the tailnet. `computeActor` already takes
191
+ * injected resolvers for its unit tests; this extends the same seam to the cached
192
+ * path. Pass `undefined` to restore the real tailscale resolvers. Resets the
193
+ * cache so the next `resolveActor()` recomputes under the new resolvers.
194
+ */
195
+ export function setActorResolvers(resolvers) {
196
+ resolverOverride = resolvers;
197
+ cached = undefined;
198
+ }
137
199
  /** Clear the per-process cache. For tests, and for env changes within a run. */
138
200
  export function resetActorCache() {
139
201
  cached = undefined;
@@ -156,6 +218,8 @@ export function actorEnv(actor) {
156
218
  env.AGENTS_ACTOR_EMAIL = actor.email;
157
219
  if (actor.github)
158
220
  env.AGENTS_ACTOR_GITHUB = actor.github;
221
+ if (actor.phoenixId)
222
+ env.AGENTS_ACTOR_PHOENIX_ID = actor.phoenixId;
159
223
  if (actor.kind === 'human' && actor.name && actor.email) {
160
224
  env.GIT_AUTHOR_NAME = actor.name;
161
225
  env.GIT_AUTHOR_EMAIL = actor.email;
@@ -2,6 +2,10 @@ import type { AgentId } from '../types.js';
2
2
  import type { AgentTarget, ResolveOptions, VersionFilter } from './types.js';
3
3
  export * from './types.js';
4
4
  export * from './primitives.js';
5
+ export * from './package-types.js';
6
+ export { parseAgentPackageManifest, loadAgentPackageManifest } from './package-schema.js';
7
+ export { resolveAgentPackage, effectiveResources } from './package-resolve.js';
8
+ export { materializeAgentPackage, sha256OfReceiptFile } from './materialize.js';
5
9
  /** Shared `--help` epilog so every agent-spec command documents the same grammar. */
6
10
  export declare const AGENT_SPEC_HELP: string;
7
11
  /** Resolve a spec (single or comma-list) into concrete installed targets. */
@@ -10,6 +10,11 @@ import { defaultVersionProvider } from './provider.js';
10
10
  import * as core from './resolve.js';
11
11
  export * from './types.js';
12
12
  export * from './primitives.js';
13
+ // Portable agent-package resolver + native-home materializer (PHNX-3838).
14
+ export * from './package-types.js';
15
+ export { parseAgentPackageManifest, loadAgentPackageManifest } from './package-schema.js';
16
+ export { resolveAgentPackage, effectiveResources } from './package-resolve.js';
17
+ export { materializeAgentPackage, sha256OfReceiptFile } from './materialize.js';
13
18
  /** Shared `--help` epilog so every agent-spec command documents the same grammar. */
14
19
  export const AGENT_SPEC_HELP = 'Agent spec: <agent>[@<qualifier>]. Qualifiers: ' +
15
20
  '@latest (highest installed), @oldest (lowest installed), ' +
@@ -0,0 +1,13 @@
1
+ import type { MaterializationReceipt, MaterializeOptions, ResolvedAgentPackage } from './package-types.js';
2
+ /**
3
+ * Materialize `resolved` into a fresh native home for `options.harness`. Fails
4
+ * closed (throws `AgentPackageError`, writes nothing new) when the harness is
5
+ * not declared supported by the package, or when any effective resource needs
6
+ * a capability the harness+version does not have. Idempotent and
7
+ * deterministic: re-running against the same `outputHome` with the same
8
+ * inputs produces a byte-identical receipt and prunes any managed path this
9
+ * materializer previously wrote that the current resource set no longer needs.
10
+ */
11
+ export declare function materializeAgentPackage(resolved: ResolvedAgentPackage, options: MaterializeOptions): MaterializationReceipt;
12
+ /** Lowercase hex sha256 of arbitrary bytes — exposed for callers that want to verify the receipt file's own digest (Factory's observed-digest record). */
13
+ export declare function sha256OfReceiptFile(receiptPath: string): string;