@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.
- package/CHANGELOG.md +67 -0
- package/README.md +1 -1
- package/dist/commands/computer.d.ts +1 -0
- package/dist/commands/computer.js +26 -7
- package/dist/commands/sessions.d.ts +50 -1
- package/dist/commands/sessions.js +79 -5
- package/dist/lib/account-registry.js +27 -6
- package/dist/lib/cloud/rush.d.ts +1 -1
- package/dist/lib/cloud/rush.js +2 -2
- package/dist/lib/computer/computer-rpc.d.ts +6 -1
- package/dist/lib/computer/computer-rpc.js +23 -3
- package/dist/lib/computer/des.d.ts +1 -0
- package/dist/lib/computer/des.js +114 -0
- package/dist/lib/computer/rfb-client.d.ts +53 -0
- package/dist/lib/computer/rfb-client.js +562 -0
- package/dist/lib/daemon/auth-sync-service.js +3 -0
- package/dist/lib/daemon/daemon.js +9 -10
- package/dist/lib/daemon/usage-sync-service.js +3 -0
- package/dist/lib/daemon-webhooks.js +15 -2
- package/dist/lib/fleet-shared-repo-sync.d.ts +12 -0
- package/dist/lib/fleet-shared-repo-sync.js +101 -4
- 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/dist/lib/session/remote/remote-list.d.ts +2 -0
- package/dist/lib/session/remote/remote-list.js +4 -0
- package/package.json +1 -1
|
@@ -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(
|
|
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 (
|
|
206
|
-
const status = await git(['status', '--porcelain=v1', '--',
|
|
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', '--',
|
|
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
|
-
'--',
|
|
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,
|
|
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
|
+
}
|
|
@@ -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.
|