@phnx-labs/agents-cli 1.22.73 → 1.22.75

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.
@@ -18,6 +18,7 @@ import * as yaml from 'yaml';
18
18
  import { exec } from 'child_process';
19
19
  import { getDaemonConfigDir, getRuntimeStateDir } from './state.js';
20
20
  import { atomicWriteFileSync } from './fs-atomic.js';
21
+ import { closeServerBounded } from './secrets/agent.js';
21
22
  import { readAndResolveBundleEnv } from './secrets/bundles.js';
22
23
  import { startWebhookServer, createFileDeliveryStore, waitForListening } from './triggers/webhook.js';
23
24
  import { buildFunnelUpCommand, FUNNEL_PORTS } from './funnel.js';
@@ -195,8 +196,13 @@ export async function startHostedWebhookReceivers(opts) {
195
196
  log('WARN', `webhook ${webhook.source}:${webhook.event} dispatch failed after ack: ${err.message}`);
196
197
  },
197
198
  });
199
+ const sockets = new Set();
200
+ server.on('connection', (socket) => {
201
+ sockets.add(socket);
202
+ socket.once('close', () => sockets.delete(socket));
203
+ });
198
204
  await waitForListening(server);
199
- servers.push(server);
205
+ servers.push({ server, sockets });
200
206
  log('INFO', `webhook receiver bound on 127.0.0.1:${port} (bundle ${receiver.bundle})`);
201
207
  if (receiver.funnel)
202
208
  reconcileFunnel(receiver.funnel.publicPort, port, log);
@@ -210,6 +216,13 @@ export async function startHostedWebhookReceivers(opts) {
210
216
  }
211
217
  return {
212
218
  count: servers.length,
213
- close: () => Promise.all(servers.map((s) => new Promise((resolve) => s.close(() => resolve())))).then(() => undefined),
219
+ close: () => Promise.all(servers.map(async ({ server, sockets }) => {
220
+ // Stop accepting first, then force every persistent HTTP connection
221
+ // closed so keep-alive cannot hold daemon shutdown open indefinitely.
222
+ const closing = closeServerBounded(server);
223
+ for (const socket of sockets)
224
+ socket.destroy();
225
+ await closing;
226
+ })).then(() => undefined),
214
227
  };
215
228
  }
@@ -21,6 +21,18 @@ export interface FleetSharedRepoSyncResult {
21
21
  timedOut: boolean;
22
22
  skipped: string | null;
23
23
  error: string | null;
24
+ /**
25
+ * Untracked working-tree files that collided with origin and were dropped
26
+ * (byte-identical to origin) before rebasing. See clearCollidingUntracked.
27
+ */
28
+ untrackedCleared?: number;
29
+ /**
30
+ * Untracked working-tree files that collided with origin, differed from it,
31
+ * and were moved to untrackedBackupDir before rebasing.
32
+ */
33
+ untrackedBackedUp?: string[];
34
+ /** Directory the differing untracked collisions were moved to, if any. */
35
+ untrackedBackupDir?: string;
24
36
  }
25
37
  /** Run one real process asynchronously with a hard wall-clock/process-tree bound. */
26
38
  export declare function runBoundedProcess(command: string, args: string[], options: {
@@ -161,6 +161,72 @@ function retainedAutostash(stashList, objectId) {
161
161
  ref: null,
162
162
  };
163
163
  }
164
+ /**
165
+ * `git rebase` checks out its base (origin/<branch>) and aborts when an
166
+ * untracked working-tree file would be overwritten by a file that origin
167
+ * tracks. `--autostash` only sets aside *tracked* changes, so these untracked
168
+ * collisions wedge the rebase on every sync and the device silently falls
169
+ * hundreds of commits behind — the fleet-wide drift root cause (PHNX-3923).
170
+ *
171
+ * In this shared-state repo an untracked file that also exists on origin is a
172
+ * stale local snapshot: the canonical copy is on origin and every device
173
+ * republishes its own state (this device's own owned file is already committed
174
+ * before we get here, so it is tracked and never appears below). Drop the ones
175
+ * byte-identical to origin (lossless) and move any that differ into a backup
176
+ * dir beside the repo root — outside the repo's tracked tree, so the move
177
+ * cannot create a fresh collision — so nothing is silently destroyed. Then let
178
+ * the rebase check out origin's version. A file we cannot clear is left in
179
+ * place; the rebase may still abort, no worse than today and never losing data.
180
+ */
181
+ async function clearCollidingUntracked(git, root, branch) {
182
+ const others = await git(['ls-files', '--others', '--exclude-standard', '-z']);
183
+ if (others.code !== 0)
184
+ return { error: others };
185
+ const relPaths = others.stdout.split('\0').filter(Boolean);
186
+ let cleared = 0;
187
+ const backedUp = [];
188
+ let backupDir = null;
189
+ for (const rel of relPaths) {
190
+ // rev-parse doubles as the "does origin track this path" test (non-zero =
191
+ // absent) — only paths origin tracks are overwritten by the rebase checkout.
192
+ const originHash = await git(['rev-parse', `origin/${branch}:${rel}`]);
193
+ if (originHash.code !== 0 || !originHash.stdout.trim())
194
+ continue;
195
+ const abs = path.join(root, rel);
196
+ // Byte-exact identity via git blob SHAs. Comparing UTF-8-decoded strings
197
+ // would collapse distinct invalid bytes to U+FFFD and could delete
198
+ // non-identical content. `--no-filters` hashes the raw on-disk bytes (no
199
+ // clean/autocrlf normalization), so `identical` is true only when the local
200
+ // bytes exactly equal origin's stored blob — never a filter-normalized
201
+ // near-match. A hash-object failure (unreadable / vanished under a
202
+ // concurrent writer) means "leave it in place".
203
+ const localHash = await git(['hash-object', '--no-filters', '--', abs]);
204
+ if (localHash.code !== 0 || !localHash.stdout.trim())
205
+ continue;
206
+ const identical = originHash.stdout.trim() === localHash.stdout.trim();
207
+ try {
208
+ if (identical) {
209
+ fs.rmSync(abs, { force: true });
210
+ cleared++;
211
+ }
212
+ else {
213
+ if (!backupDir) {
214
+ // A sibling of the repo root: outside the tracked tree (so the move
215
+ // cannot create a fresh collision) yet colocated for easy recovery.
216
+ backupDir = path.join(`${root}-fleet-sync-backups`, String(Date.now()));
217
+ }
218
+ const dest = path.join(backupDir, rel);
219
+ fs.mkdirSync(path.dirname(dest), { recursive: true });
220
+ fs.renameSync(abs, dest);
221
+ backedUp.push(rel);
222
+ }
223
+ }
224
+ catch {
225
+ // Could not clear this collision; leave it in place.
226
+ }
227
+ }
228
+ return { cleared, backedUp, backupDir };
229
+ }
164
230
  async function performFleetSharedRepoSync(root, device, timeoutMs) {
165
231
  if (!fs.existsSync(path.join(root, '.git'))) {
166
232
  return {
@@ -201,29 +267,57 @@ async function performFleetSharedRepoSync(root, device, timeoutMs) {
201
267
  }
202
268
  const ownedFile = fleetSharedStatePath(device, root);
203
269
  const relativeOwnedFile = path.relative(root, ownedFile).split(path.sep).join('/');
270
+ // The central `agents.yaml` is fleet-shared state too — `agents accounts label`
271
+ // writes version-scoped native account rows there (lib/state.ts writeMetaUnlocked),
272
+ // as a plain file write with no commit. Publishing only the per-device file and
273
+ // then `rebase --autostash`-ing over a dirty central file silently destroyed
274
+ // those labels: every box lost its account labels on its next daemon publish
275
+ // (PHNX-3887). Commit it alongside the device doc so the rebase carries the
276
+ // rows instead of stashing them, which is also what `accounts label --help`
277
+ // already promises ("labels live on the central account rows in agents.yaml,
278
+ // which repo push/pull already syncs fleet-wide").
279
+ const centralFile = path.join(root, 'agents.yaml');
280
+ const publishPaths = [relativeOwnedFile];
281
+ if (fs.existsSync(centralFile))
282
+ publishPaths.push('agents.yaml');
283
+ const existingPaths = publishPaths.filter(rel => fs.existsSync(path.join(root, rel)));
204
284
  let committed = false;
205
- if (fs.existsSync(ownedFile)) {
206
- const status = await git(['status', '--porcelain=v1', '--', relativeOwnedFile]);
285
+ if (existingPaths.length > 0) {
286
+ const status = await git(['status', '--porcelain=v1', '--', ...existingPaths]);
207
287
  if (status.code !== 0)
208
288
  return failure('git status', status);
209
289
  if (status.stdout.trim()) {
210
- const add = await git(['add', '--', relativeOwnedFile]);
290
+ const add = await git(['add', '--', ...existingPaths]);
211
291
  if (add.code !== 0)
212
292
  return failure('git add', add);
213
293
  const commit = await git([
214
294
  '-c', 'commit.gpgsign=false',
215
295
  'commit', '--no-verify', '-m', `chore(devices): publish ${device} daemon state`,
216
- '--', relativeOwnedFile,
296
+ '--', ...existingPaths,
217
297
  ]);
218
298
  if (commit.code !== 0)
219
299
  return failure('git commit', commit);
220
300
  committed = true;
221
301
  }
222
302
  }
303
+ let untrackedCleared = 0;
304
+ const untrackedBackedUp = [];
305
+ let untrackedBackupDir;
223
306
  for (let attempt = 1; attempt <= FLEET_SHARED_REPO_PUSH_ATTEMPTS; attempt++) {
224
307
  const fetch = await git(['fetch', 'origin']);
225
308
  if (fetch.code !== 0)
226
309
  return { ...failure('git fetch', fetch), committed };
310
+ // Untracked files that origin tracks would abort the rebase's checkout
311
+ // (autostash only covers tracked changes). Clear them first — this is the
312
+ // fleet-drift root cause (PHNX-3923).
313
+ const reconcile = await clearCollidingUntracked(git, root, branch);
314
+ if ('error' in reconcile) {
315
+ return { ...failure('git ls-files --others', reconcile.error), committed };
316
+ }
317
+ untrackedCleared += reconcile.cleared;
318
+ untrackedBackedUp.push(...reconcile.backedUp);
319
+ if (reconcile.backupDir)
320
+ untrackedBackupDir = reconcile.backupDir;
227
321
  // Keep enough of the same wall-clock bound available to remove a botched
228
322
  // autostash pop from the operator's live checkout before returning.
229
323
  const rebase = await git(['rebase', '--autostash', `origin/${branch}`], FLEET_SHARED_REPO_REBASE_CLEANUP_RESERVE_MS);
@@ -283,6 +377,9 @@ async function performFleetSharedRepoSync(root, device, timeoutMs) {
283
377
  timedOut: false,
284
378
  skipped: null,
285
379
  error: null,
380
+ untrackedCleared,
381
+ untrackedBackedUp,
382
+ untrackedBackupDir,
286
383
  };
287
384
  }
288
385
  if (attempt === FLEET_SHARED_REPO_PUSH_ATTEMPTS || !isPushRace(push)) {
@@ -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
+ }
@@ -124,6 +124,8 @@ export declare function resolvePeerTarget(machine: string): Promise<{
124
124
  * error, timeout, version-skewed peer with no `--json` preview envelope.
125
125
  */
126
126
  export declare function fetchPeerPreviewDigest(sessionId: string, machine: string, timeoutMs?: number): Promise<unknown | undefined>;
127
+ /** Parse the JSON envelope returned by `sessions preview --json`. */
128
+ export declare function parsePeerPreviewDigest(parsed: unknown): unknown | undefined;
127
129
  /**
128
130
  * Run `agents <args>` ON a peer over SSH, attached to this terminal (inherited
129
131
  * stdio). `args` is the full arg vector after the binary — callers pass e.g.