@phnx-labs/agents-cli 1.22.33 → 1.22.35
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 +55 -0
- package/README.md +9 -1
- package/dist/bin/agents +0 -0
- package/dist/commands/accounts.js +3 -3
- package/dist/commands/browser-sessions-picker.d.ts +16 -0
- package/dist/commands/browser-sessions-picker.js +179 -0
- package/dist/commands/browser.js +9 -4
- package/dist/commands/hosts.js +1 -5
- package/dist/commands/inspect.js +174 -41
- package/dist/commands/message.d.ts +6 -1
- package/dist/commands/message.js +60 -3
- package/dist/commands/sessions.d.ts +54 -4
- package/dist/commands/sessions.js +252 -46
- package/dist/commands/share.d.ts +18 -0
- package/dist/commands/share.js +108 -0
- package/dist/commands/ssh.js +17 -5
- package/dist/commands/teams.d.ts +28 -0
- package/dist/commands/teams.js +148 -13
- package/dist/commands/upgrade.d.ts +7 -0
- package/dist/commands/upgrade.js +10 -0
- package/dist/commands/watchdog.d.ts +2 -0
- package/dist/commands/watchdog.js +112 -27
- package/dist/index.js +51 -59
- package/dist/lib/agents.js +6 -0
- package/dist/lib/browser/sessions-list.d.ts +81 -0
- package/dist/lib/browser/sessions-list.js +179 -4
- package/dist/lib/codex-policy.d.ts +9 -1
- package/dist/lib/codex-policy.js +17 -2
- package/dist/lib/daemon.js +45 -5
- package/dist/lib/devices/connect.d.ts +33 -0
- package/dist/lib/devices/connect.js +61 -3
- package/dist/lib/devices/doctor-findings.d.ts +4 -2
- package/dist/lib/devices/doctor-findings.js +4 -2
- package/dist/lib/exec.js +65 -8
- package/dist/lib/help.d.ts +3 -2
- package/dist/lib/help.js +4 -0
- package/dist/lib/hosts/dispatch.d.ts +2 -32
- package/dist/lib/hosts/dispatch.js +6 -61
- package/dist/lib/hosts/tasks.d.ts +7 -0
- package/dist/lib/hosts/tasks.js +9 -0
- package/dist/lib/mailbox-target.d.ts +27 -0
- package/dist/lib/mailbox-target.js +21 -0
- package/dist/lib/mcp.d.ts +10 -0
- package/dist/lib/mcp.js +21 -2
- package/dist/lib/menubar/MenubarHelper.app/Contents/CodeResources +0 -0
- package/dist/lib/menubar/MenubarHelper.app/Contents/MacOS/MenubarHelper +0 -0
- package/dist/lib/migrate.d.ts +11 -0
- package/dist/lib/migrate.js +40 -0
- package/dist/lib/project-key.d.ts +17 -0
- package/dist/lib/project-key.js +26 -0
- package/dist/lib/project-root.d.ts +47 -0
- package/dist/lib/project-root.js +68 -0
- package/dist/lib/secrets/Agents CLI.app/Contents/CodeResources +0 -0
- package/dist/lib/secrets/Agents CLI.app/Contents/MacOS/Agents CLI +0 -0
- package/dist/lib/secrets/agent.d.ts +9 -2
- package/dist/lib/secrets/agent.js +52 -7
- package/dist/lib/secrets/reaper.d.ts +24 -3
- package/dist/lib/secrets/reaper.js +55 -6
- package/dist/lib/session/discover.js +12 -0
- package/dist/lib/session/render.d.ts +2 -0
- package/dist/lib/session/render.js +1 -1
- package/dist/lib/share/delete.d.ts +93 -0
- package/dist/lib/share/delete.js +127 -0
- package/dist/lib/shims.js +48 -4
- package/dist/lib/startup/command-registry.d.ts +1 -0
- package/dist/lib/startup/command-registry.js +11 -3
- package/dist/lib/startup/root-command.d.ts +3 -0
- package/dist/lib/startup/root-command.js +10 -0
- package/dist/lib/teams/agents.d.ts +136 -6
- package/dist/lib/teams/agents.js +324 -58
- package/dist/lib/teams/worktree.d.ts +39 -2
- package/dist/lib/teams/worktree.js +60 -4
- package/dist/lib/types.d.ts +11 -0
- package/dist/lib/versions.js +2 -2
- package/dist/lib/watchdog/history.d.ts +20 -0
- package/dist/lib/watchdog/history.js +46 -0
- package/dist/lib/watchdog/log.d.ts +16 -1
- package/dist/lib/watchdog/log.js +82 -2
- package/dist/lib/watchdog/runner.d.ts +12 -0
- package/dist/lib/watchdog/runner.js +20 -0
- package/package.json +1 -1
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { SessionMeta } from '../session/types.js';
|
|
1
2
|
export type ArtifactKind = 'screenshot' | 'pdf' | 'recording' | 'download';
|
|
2
3
|
export interface BrowserArtifact {
|
|
3
4
|
kind: ArtifactKind;
|
|
@@ -19,6 +20,10 @@ export declare function listProfileArtifacts(profile: string): BrowserArtifact[]
|
|
|
19
20
|
* when empty); otherwise every profile dir on disk that has at least one capture.
|
|
20
21
|
*/
|
|
21
22
|
export declare function listBrowserSessions(only?: string): ProfileArtifacts[];
|
|
23
|
+
export declare function formatBytes(n: number): string;
|
|
24
|
+
/** Per-kind counts over a flat artifact list — shared by the printed table and
|
|
25
|
+
* the interactive row labels. */
|
|
26
|
+
export declare function countByKind(artifacts: BrowserArtifact[]): Record<ArtifactKind, number>;
|
|
22
27
|
/** Human table for the CLI. Returns lines (no trailing newline). */
|
|
23
28
|
export declare function renderBrowserSessions(groups: ProfileArtifacts[]): string;
|
|
24
29
|
/**
|
|
@@ -28,6 +33,82 @@ export declare function renderBrowserSessions(groups: ProfileArtifacts[]): strin
|
|
|
28
33
|
export declare function resolveArtifact(groups: ProfileArtifacts[], selector: string): string | null;
|
|
29
34
|
/** Open a file in the OS default app. Returns true on success. */
|
|
30
35
|
export declare function openArtifact(filePath: string): boolean;
|
|
36
|
+
/** The identity fields this module cares about from a profile's `tasks.json`. */
|
|
37
|
+
export interface TaskIdentity {
|
|
38
|
+
owner?: string;
|
|
39
|
+
launchId?: string;
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* Read a profile's `tasks.json` for the identity of its CURRENTLY LIVE tasks,
|
|
43
|
+
* keyed by task name. A task's entry is deleted on `agents browser stop` (see
|
|
44
|
+
* service.ts `saveTaskState`), so a task whose run has already ended is absent
|
|
45
|
+
* here even though its captures remain on disk — that is exactly the
|
|
46
|
+
* "unlinked legacy task" case {@link groupIntoRows} reports. Same read-straight-
|
|
47
|
+
* from-disk approach as the rest of this module: works whether or not the
|
|
48
|
+
* browser daemon is running.
|
|
49
|
+
*/
|
|
50
|
+
export declare function loadTaskIdentities(profile: string): Map<string, TaskIdentity>;
|
|
51
|
+
/** launchId -> indexed session id, built once from every source that records
|
|
52
|
+
* this join, so a task-heavy profile resolves without re-scanning per task. */
|
|
53
|
+
export interface LaunchSessionIndex {
|
|
54
|
+
byLaunchId: Map<string, string>;
|
|
55
|
+
}
|
|
56
|
+
/**
|
|
57
|
+
* Build the launchId -> sessionId index from the same three sources
|
|
58
|
+
* `feed-post.ts` `resolvePostIdentity` already uses for this exact problem —
|
|
59
|
+
* a browser task's `launchId` IS the `AGENT_LAUNCH_ID` of the CLI run that
|
|
60
|
+
* called `agents browser start` (see service.ts `resolveTaskIdentity`), so
|
|
61
|
+
* resolving "which session ran this launch" is the same join, not a new one.
|
|
62
|
+
* Lowest-confidence source first so a later, more specific source overwrites
|
|
63
|
+
* it on a collision:
|
|
64
|
+
* 1. the activity log (durable, but least specific — read last)
|
|
65
|
+
* 2. the SessionStart hook's live-session index (kept for parity with
|
|
66
|
+
* `hook-sessions.ts`; empty on this fleet today, harmless when so)
|
|
67
|
+
* 3. the per-pid launch registry (`ag run`, launch-scoped, most authoritative)
|
|
68
|
+
* Computed once per interactive session, not per row.
|
|
69
|
+
*/
|
|
70
|
+
export declare function buildLaunchSessionIndex(): LaunchSessionIndex;
|
|
71
|
+
/** Resolve one launchId through the index to its canonical `SessionMeta`, or
|
|
72
|
+
* `null` when the join has no hit or the resolved session isn't indexed here. */
|
|
73
|
+
export declare function resolveLaunchSession(index: LaunchSessionIndex, launchId: string): SessionMeta | null;
|
|
74
|
+
/** Why a row carries no session digest: `linked` resolved one, `unresolved`
|
|
75
|
+
* has a launchId but no matching indexed session, `unlinked` has no launchId
|
|
76
|
+
* at all (a legacy task, or one whose owning run already stopped). */
|
|
77
|
+
export type BrowserSessionLinkStatus = 'linked' | 'unresolved' | 'unlinked';
|
|
78
|
+
/** One task-first row: every capture for one browser task (or, for
|
|
79
|
+
* `kind: 'downloads'`, one profile's downloads bucket), plus the agent
|
|
80
|
+
* session it links to when resolvable. */
|
|
81
|
+
export interface BrowserSessionRow {
|
|
82
|
+
kind: 'task' | 'downloads';
|
|
83
|
+
profile: string;
|
|
84
|
+
/** Task name; undefined for the downloads row. */
|
|
85
|
+
task?: string;
|
|
86
|
+
owner?: string;
|
|
87
|
+
launchId?: string;
|
|
88
|
+
linkStatus: BrowserSessionLinkStatus;
|
|
89
|
+
linkedSession?: SessionMeta;
|
|
90
|
+
/** Newest first. */
|
|
91
|
+
artifacts: BrowserArtifact[];
|
|
92
|
+
counts: Record<ArtifactKind, number>;
|
|
93
|
+
/** Most recent capture's mtime — the row's sort/age key. */
|
|
94
|
+
latestMtimeMs: number;
|
|
95
|
+
}
|
|
96
|
+
/**
|
|
97
|
+
* Group flat per-profile artifacts into task-first rows, newest first. Pure:
|
|
98
|
+
* identities and the launch resolver are passed in, so this has no filesystem
|
|
99
|
+
* or session-index dependency and is unit-testable with synthetic data (see
|
|
100
|
+
* {@link buildBrowserSessionRows} for the impure disk/index-reading caller).
|
|
101
|
+
*/
|
|
102
|
+
export declare function groupIntoRows(groups: ProfileArtifacts[], taskIdentities: Map<string, Map<string, TaskIdentity>>, resolveLaunch?: (launchId: string) => SessionMeta | null): BrowserSessionRow[];
|
|
103
|
+
/** Build task-first rows for one profile (or every profile with captures),
|
|
104
|
+
* reading `tasks.json` and resolving launchIds against the live indexes.
|
|
105
|
+
* The interactive picker's data source. */
|
|
106
|
+
export declare function buildBrowserSessionRows(profile?: string): BrowserSessionRow[];
|
|
107
|
+
/**
|
|
108
|
+
* Search predicate for the interactive picker: task name, profile, the linked
|
|
109
|
+
* session's agent/topic/label, and any artifact filename in the row.
|
|
110
|
+
*/
|
|
111
|
+
export declare function matchesBrowserSessionRow(row: BrowserSessionRow, query: string): boolean;
|
|
31
112
|
/**
|
|
32
113
|
* Shared CLI action for `agents browser sessions` and `agents sessions --browser`.
|
|
33
114
|
* `open` is the Commander value for `--open [selector]`: undefined when the flag
|
|
@@ -4,12 +4,25 @@
|
|
|
4
4
|
* Reads straight from `.cache/browser/<profile>/`, so it works whether or not the
|
|
5
5
|
* browser daemon is running. Backs both `agents browser sessions` and the
|
|
6
6
|
* `agents sessions --browser` alias.
|
|
7
|
+
*
|
|
8
|
+
* Also owns the task-first grouping (RUSH-2407): browser captures are per-task
|
|
9
|
+
* (`sessions/<task>/`), and a task persists `owner`/`launchId` in `tasks.json`
|
|
10
|
+
* while it is live (see service.ts `resolveTaskIdentity`). This module groups
|
|
11
|
+
* artifacts by task and, when a launchId is available, resolves it to the
|
|
12
|
+
* agent session that ran it — reusing the session index (`getSessionById`) and
|
|
13
|
+
* the same launchId join keys the rest of the CLI already uses for this exact
|
|
14
|
+
* problem (pid registry, the SessionStart hook index, the activity log; see
|
|
15
|
+
* `feed-post.ts` `resolvePostIdentity`), never a second parser.
|
|
7
16
|
*/
|
|
8
17
|
import * as fs from 'fs';
|
|
9
18
|
import * as path from 'path';
|
|
10
19
|
import { spawnSync } from 'child_process';
|
|
11
20
|
import { getBrowserRuntimeDir, getProfileRuntimeDir } from './profiles.js';
|
|
12
21
|
import { formatRelativeTime } from '../session/relative-time.js';
|
|
22
|
+
import { getSessionById } from '../session/db.js';
|
|
23
|
+
import { listPidSessionEntries } from '../session/pid-registry.js';
|
|
24
|
+
import { loadHookSessionIndex } from '../session/hook-sessions.js';
|
|
25
|
+
import { readRecentActivity } from '../activity.js';
|
|
13
26
|
const EXT_KIND = {
|
|
14
27
|
'.png': 'screenshot',
|
|
15
28
|
'.jpg': 'screenshot',
|
|
@@ -100,7 +113,7 @@ export function listBrowserSessions(only) {
|
|
|
100
113
|
.map((p) => ({ profile: p, artifacts: listProfileArtifacts(p) }))
|
|
101
114
|
.filter((r) => !!only || r.artifacts.length > 0);
|
|
102
115
|
}
|
|
103
|
-
function formatBytes(n) {
|
|
116
|
+
export function formatBytes(n) {
|
|
104
117
|
if (n < 1024)
|
|
105
118
|
return `${n} B`;
|
|
106
119
|
const units = ['KB', 'MB', 'GB'];
|
|
@@ -112,15 +125,21 @@ function formatBytes(n) {
|
|
|
112
125
|
}
|
|
113
126
|
return `${val < 10 ? val.toFixed(1) : Math.round(val)} ${units[i]}`;
|
|
114
127
|
}
|
|
128
|
+
/** Per-kind counts over a flat artifact list — shared by the printed table and
|
|
129
|
+
* the interactive row labels. */
|
|
130
|
+
export function countByKind(artifacts) {
|
|
131
|
+
const counts = { screenshot: 0, pdf: 0, recording: 0, download: 0 };
|
|
132
|
+
for (const a of artifacts)
|
|
133
|
+
counts[a.kind]++;
|
|
134
|
+
return counts;
|
|
135
|
+
}
|
|
115
136
|
/** Human table for the CLI. Returns lines (no trailing newline). */
|
|
116
137
|
export function renderBrowserSessions(groups) {
|
|
117
138
|
if (groups.length === 0)
|
|
118
139
|
return 'No browser profiles found.';
|
|
119
140
|
const lines = [];
|
|
120
141
|
for (const g of groups) {
|
|
121
|
-
const counts =
|
|
122
|
-
for (const a of g.artifacts)
|
|
123
|
-
counts[a.kind]++;
|
|
142
|
+
const counts = countByKind(g.artifacts);
|
|
124
143
|
lines.push(`${g.profile} ` +
|
|
125
144
|
`screenshots ${counts.screenshot} pdfs ${counts.pdf} recordings ${counts.recording} downloads ${counts.download}`);
|
|
126
145
|
if (g.artifacts.length === 0) {
|
|
@@ -161,6 +180,162 @@ export function openArtifact(filePath) {
|
|
|
161
180
|
}
|
|
162
181
|
return false;
|
|
163
182
|
}
|
|
183
|
+
/**
|
|
184
|
+
* Read a profile's `tasks.json` for the identity of its CURRENTLY LIVE tasks,
|
|
185
|
+
* keyed by task name. A task's entry is deleted on `agents browser stop` (see
|
|
186
|
+
* service.ts `saveTaskState`), so a task whose run has already ended is absent
|
|
187
|
+
* here even though its captures remain on disk — that is exactly the
|
|
188
|
+
* "unlinked legacy task" case {@link groupIntoRows} reports. Same read-straight-
|
|
189
|
+
* from-disk approach as the rest of this module: works whether or not the
|
|
190
|
+
* browser daemon is running.
|
|
191
|
+
*/
|
|
192
|
+
export function loadTaskIdentities(profile) {
|
|
193
|
+
const out = new Map();
|
|
194
|
+
let raw;
|
|
195
|
+
try {
|
|
196
|
+
raw = fs.readFileSync(path.join(getProfileRuntimeDir(profile), 'tasks.json'), 'utf8');
|
|
197
|
+
}
|
|
198
|
+
catch {
|
|
199
|
+
return out;
|
|
200
|
+
}
|
|
201
|
+
let parsed;
|
|
202
|
+
try {
|
|
203
|
+
parsed = JSON.parse(raw);
|
|
204
|
+
}
|
|
205
|
+
catch {
|
|
206
|
+
return out;
|
|
207
|
+
}
|
|
208
|
+
if (!parsed || typeof parsed !== 'object')
|
|
209
|
+
return out;
|
|
210
|
+
for (const [name, value] of Object.entries(parsed)) {
|
|
211
|
+
if (!value || typeof value !== 'object')
|
|
212
|
+
continue;
|
|
213
|
+
const t = value;
|
|
214
|
+
out.set(name, {
|
|
215
|
+
owner: typeof t.owner === 'string' ? t.owner : undefined,
|
|
216
|
+
launchId: typeof t.launchId === 'string' ? t.launchId : undefined,
|
|
217
|
+
});
|
|
218
|
+
}
|
|
219
|
+
return out;
|
|
220
|
+
}
|
|
221
|
+
/**
|
|
222
|
+
* Build the launchId -> sessionId index from the same three sources
|
|
223
|
+
* `feed-post.ts` `resolvePostIdentity` already uses for this exact problem —
|
|
224
|
+
* a browser task's `launchId` IS the `AGENT_LAUNCH_ID` of the CLI run that
|
|
225
|
+
* called `agents browser start` (see service.ts `resolveTaskIdentity`), so
|
|
226
|
+
* resolving "which session ran this launch" is the same join, not a new one.
|
|
227
|
+
* Lowest-confidence source first so a later, more specific source overwrites
|
|
228
|
+
* it on a collision:
|
|
229
|
+
* 1. the activity log (durable, but least specific — read last)
|
|
230
|
+
* 2. the SessionStart hook's live-session index (kept for parity with
|
|
231
|
+
* `hook-sessions.ts`; empty on this fleet today, harmless when so)
|
|
232
|
+
* 3. the per-pid launch registry (`ag run`, launch-scoped, most authoritative)
|
|
233
|
+
* Computed once per interactive session, not per row.
|
|
234
|
+
*/
|
|
235
|
+
export function buildLaunchSessionIndex() {
|
|
236
|
+
const byLaunchId = new Map();
|
|
237
|
+
for (const ev of readRecentActivity({ maxBytesPerSession: 64 * 1024 })) {
|
|
238
|
+
if (ev.launchId && ev.sessionId)
|
|
239
|
+
byLaunchId.set(ev.launchId, ev.sessionId);
|
|
240
|
+
}
|
|
241
|
+
for (const [launchId, rec] of loadHookSessionIndex().byLaunchId) {
|
|
242
|
+
if (rec.session_id)
|
|
243
|
+
byLaunchId.set(launchId, rec.session_id);
|
|
244
|
+
}
|
|
245
|
+
for (const entry of listPidSessionEntries()) {
|
|
246
|
+
if (entry.launchId && entry.sessionId)
|
|
247
|
+
byLaunchId.set(entry.launchId, entry.sessionId);
|
|
248
|
+
}
|
|
249
|
+
return { byLaunchId };
|
|
250
|
+
}
|
|
251
|
+
/** Resolve one launchId through the index to its canonical `SessionMeta`, or
|
|
252
|
+
* `null` when the join has no hit or the resolved session isn't indexed here. */
|
|
253
|
+
export function resolveLaunchSession(index, launchId) {
|
|
254
|
+
const sessionId = index.byLaunchId.get(launchId);
|
|
255
|
+
return sessionId ? getSessionById(sessionId) : null;
|
|
256
|
+
}
|
|
257
|
+
/**
|
|
258
|
+
* Group flat per-profile artifacts into task-first rows, newest first. Pure:
|
|
259
|
+
* identities and the launch resolver are passed in, so this has no filesystem
|
|
260
|
+
* or session-index dependency and is unit-testable with synthetic data (see
|
|
261
|
+
* {@link buildBrowserSessionRows} for the impure disk/index-reading caller).
|
|
262
|
+
*/
|
|
263
|
+
export function groupIntoRows(groups, taskIdentities, resolveLaunch) {
|
|
264
|
+
const rows = [];
|
|
265
|
+
for (const g of groups) {
|
|
266
|
+
const identities = taskIdentities.get(g.profile) ?? new Map();
|
|
267
|
+
const byTask = new Map();
|
|
268
|
+
const downloads = [];
|
|
269
|
+
for (const a of g.artifacts) {
|
|
270
|
+
if (a.kind === 'download' || !a.task) {
|
|
271
|
+
downloads.push(a);
|
|
272
|
+
continue;
|
|
273
|
+
}
|
|
274
|
+
const list = byTask.get(a.task) ?? [];
|
|
275
|
+
list.push(a);
|
|
276
|
+
byTask.set(a.task, list);
|
|
277
|
+
}
|
|
278
|
+
for (const [task, artifacts] of byTask) {
|
|
279
|
+
artifacts.sort((a, b) => b.mtimeMs - a.mtimeMs);
|
|
280
|
+
const identity = identities.get(task);
|
|
281
|
+
const linkedSession = identity?.launchId ? resolveLaunch?.(identity.launchId) ?? null : null;
|
|
282
|
+
rows.push({
|
|
283
|
+
kind: 'task',
|
|
284
|
+
profile: g.profile,
|
|
285
|
+
task,
|
|
286
|
+
owner: identity?.owner,
|
|
287
|
+
launchId: identity?.launchId,
|
|
288
|
+
linkStatus: linkedSession ? 'linked' : identity?.launchId ? 'unresolved' : 'unlinked',
|
|
289
|
+
linkedSession: linkedSession ?? undefined,
|
|
290
|
+
artifacts,
|
|
291
|
+
counts: countByKind(artifacts),
|
|
292
|
+
latestMtimeMs: artifacts[0]?.mtimeMs ?? 0,
|
|
293
|
+
});
|
|
294
|
+
}
|
|
295
|
+
if (downloads.length > 0) {
|
|
296
|
+
downloads.sort((a, b) => b.mtimeMs - a.mtimeMs);
|
|
297
|
+
rows.push({
|
|
298
|
+
kind: 'downloads',
|
|
299
|
+
profile: g.profile,
|
|
300
|
+
linkStatus: 'unlinked',
|
|
301
|
+
artifacts: downloads,
|
|
302
|
+
counts: countByKind(downloads),
|
|
303
|
+
latestMtimeMs: downloads[0]?.mtimeMs ?? 0,
|
|
304
|
+
});
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
rows.sort((a, b) => b.latestMtimeMs - a.latestMtimeMs);
|
|
308
|
+
return rows;
|
|
309
|
+
}
|
|
310
|
+
/** Build task-first rows for one profile (or every profile with captures),
|
|
311
|
+
* reading `tasks.json` and resolving launchIds against the live indexes.
|
|
312
|
+
* The interactive picker's data source. */
|
|
313
|
+
export function buildBrowserSessionRows(profile) {
|
|
314
|
+
const groups = listBrowserSessions(profile);
|
|
315
|
+
const taskIdentities = new Map(groups.map((g) => [g.profile, loadTaskIdentities(g.profile)]));
|
|
316
|
+
const index = buildLaunchSessionIndex();
|
|
317
|
+
return groupIntoRows(groups, taskIdentities, (launchId) => resolveLaunchSession(index, launchId));
|
|
318
|
+
}
|
|
319
|
+
/**
|
|
320
|
+
* Search predicate for the interactive picker: task name, profile, the linked
|
|
321
|
+
* session's agent/topic/label, and any artifact filename in the row.
|
|
322
|
+
*/
|
|
323
|
+
export function matchesBrowserSessionRow(row, query) {
|
|
324
|
+
const q = query.trim().toLowerCase();
|
|
325
|
+
if (!q)
|
|
326
|
+
return true;
|
|
327
|
+
if (row.task?.toLowerCase().includes(q))
|
|
328
|
+
return true;
|
|
329
|
+
if (row.profile.toLowerCase().includes(q))
|
|
330
|
+
return true;
|
|
331
|
+
if (row.kind === 'downloads' && 'downloads'.includes(q))
|
|
332
|
+
return true;
|
|
333
|
+
const s = row.linkedSession;
|
|
334
|
+
if (s && (s.agent.toLowerCase().includes(q) || s.topic?.toLowerCase().includes(q) || s.label?.toLowerCase().includes(q))) {
|
|
335
|
+
return true;
|
|
336
|
+
}
|
|
337
|
+
return row.artifacts.some((a) => a.name.toLowerCase().includes(q));
|
|
338
|
+
}
|
|
164
339
|
/**
|
|
165
340
|
* Shared CLI action for `agents browser sessions` and `agents sessions --browser`.
|
|
166
341
|
* `open` is the Commander value for `--open [selector]`: undefined when the flag
|
|
@@ -1,7 +1,15 @@
|
|
|
1
1
|
export type CodexPolicyMode = 'plan' | 'edit' | 'skip';
|
|
2
2
|
export declare const CODEX_PLAN_PROFILE = "agents-plan";
|
|
3
3
|
export declare const CODEX_EDIT_PROFILE = "agents-edit";
|
|
4
|
-
|
|
4
|
+
/**
|
|
5
|
+
* Writable roots for Codex's `edit` profile: the managed user `.agents` dir, the
|
|
6
|
+
* baseline toolchain caches, and — when `cwd` is inside a repo — that repo's
|
|
7
|
+
* `.agents` directory. The last entry is what lets an in-repo build write under
|
|
8
|
+
* `.agents/worktrees/`; Codex's `workspace-write` sandbox hardcodes `.agents/`
|
|
9
|
+
* read-only, and naming the directory as an explicit writable root is the only
|
|
10
|
+
* thing that overrides it (a nested sub-path does not — bwrap refuses the mount).
|
|
11
|
+
*/
|
|
12
|
+
export declare function codexEditWritableRoots(cwd?: string): string[];
|
|
5
13
|
export declare function codexPermissionProfileConfig(mode: Exclude<CodexPolicyMode, 'skip'>, writableRoots?: string[]): string;
|
|
6
14
|
/**
|
|
7
15
|
* Canonical Codex safety policy used by every native launch path.
|
package/dist/lib/codex-policy.js
CHANGED
|
@@ -1,12 +1,27 @@
|
|
|
1
|
+
import * as fs from 'fs';
|
|
1
2
|
import { getUserAgentsDir } from './state.js';
|
|
2
3
|
import { codexDefaultWritableRoots } from './permissions.js';
|
|
4
|
+
import { repoAgentsDirForCwd } from './project-key.js';
|
|
3
5
|
export const CODEX_PLAN_PROFILE = 'agents-plan';
|
|
4
6
|
export const CODEX_EDIT_PROFILE = 'agents-edit';
|
|
5
7
|
function unique(values) {
|
|
6
8
|
return [...new Set(values)];
|
|
7
9
|
}
|
|
8
|
-
|
|
9
|
-
|
|
10
|
+
/**
|
|
11
|
+
* Writable roots for Codex's `edit` profile: the managed user `.agents` dir, the
|
|
12
|
+
* baseline toolchain caches, and — when `cwd` is inside a repo — that repo's
|
|
13
|
+
* `.agents` directory. The last entry is what lets an in-repo build write under
|
|
14
|
+
* `.agents/worktrees/`; Codex's `workspace-write` sandbox hardcodes `.agents/`
|
|
15
|
+
* read-only, and naming the directory as an explicit writable root is the only
|
|
16
|
+
* thing that overrides it (a nested sub-path does not — bwrap refuses the mount).
|
|
17
|
+
*/
|
|
18
|
+
export function codexEditWritableRoots(cwd) {
|
|
19
|
+
const repoAgents = repoAgentsDirForCwd(cwd);
|
|
20
|
+
// Only widen the sandbox for a `.agents` that actually exists — most repos
|
|
21
|
+
// have none, and there is no point naming a directory that isn't there. (Codex
|
|
22
|
+
// tolerates a missing writable root, so this is tidiness, not a hard guard.)
|
|
23
|
+
const repoRoots = repoAgents && fs.existsSync(repoAgents) ? [repoAgents] : [];
|
|
24
|
+
return unique([getUserAgentsDir(), ...codexDefaultWritableRoots(), ...repoRoots]);
|
|
10
25
|
}
|
|
11
26
|
function inlineWorkspaceRoots(roots) {
|
|
12
27
|
return roots.map((root) => `${JSON.stringify(root)} = true`).join(', ');
|
package/dist/lib/daemon.js
CHANGED
|
@@ -1176,11 +1176,22 @@ export function startDaemon(agentsBin) {
|
|
|
1176
1176
|
const pid = waitForPid(3000);
|
|
1177
1177
|
return { pid, method: 'already-starting' };
|
|
1178
1178
|
}
|
|
1179
|
+
// Released by startDaemonLocked the moment the launch has been ISSUED, and
|
|
1180
|
+
// again here as the backstop for every path that returned before reaching
|
|
1181
|
+
// that point (a throw, or the platform default branch). Idempotent so the
|
|
1182
|
+
// double call is a no-op rather than unlinking a lock a later claimer owns.
|
|
1183
|
+
let released = false;
|
|
1184
|
+
const releaseOnce = () => {
|
|
1185
|
+
if (released)
|
|
1186
|
+
return;
|
|
1187
|
+
released = true;
|
|
1188
|
+
releaseLock();
|
|
1189
|
+
};
|
|
1179
1190
|
try {
|
|
1180
|
-
return startDaemonLocked(agentsBin ?? getAgentsBinPath());
|
|
1191
|
+
return startDaemonLocked(agentsBin ?? getAgentsBinPath(), releaseOnce);
|
|
1181
1192
|
}
|
|
1182
1193
|
finally {
|
|
1183
|
-
|
|
1194
|
+
releaseOnce();
|
|
1184
1195
|
}
|
|
1185
1196
|
}
|
|
1186
1197
|
/**
|
|
@@ -1207,8 +1218,33 @@ export function ensureDaemonStarted() {
|
|
|
1207
1218
|
return null;
|
|
1208
1219
|
}
|
|
1209
1220
|
}
|
|
1210
|
-
|
|
1221
|
+
/**
|
|
1222
|
+
* Issue the launch, then wait for the child to record its pid.
|
|
1223
|
+
*
|
|
1224
|
+
* RUSH-2417: the wait phase MUST NOT hold the start lock. `acquireStartLock`
|
|
1225
|
+
* and `claimDaemonInstance` resolve the same `<daemonDir>/daemon.lock`, so a
|
|
1226
|
+
* parent that busy-waits on `waitForPid` while still holding it deterministically
|
|
1227
|
+
* defeats the child it just launched: the child's `claimDaemonInstance` hits
|
|
1228
|
+
* EEXIST, reads a holder pid that IS alive (this process), and exits with the
|
|
1229
|
+
* false "another daemon is mid-takeover" warning — every launchd/systemd start
|
|
1230
|
+
* on a fresh install. The lock's job is to keep two concurrent `startDaemon()`
|
|
1231
|
+
* calls from both launching, and that is done once `launchctl load` /
|
|
1232
|
+
* `systemctl start` / the detached spawn has been issued, so `releaseLock()` is
|
|
1233
|
+
* called there rather than in the caller's `finally`.
|
|
1234
|
+
*
|
|
1235
|
+
* Releasing early cannot produce two daemons: launchd (one plist label) and
|
|
1236
|
+
* systemd (one unit) are singletons that no-op a second start, and the detached
|
|
1237
|
+
* path is covered by `claimDaemonInstance`'s last-wins takeover (SING-11) —
|
|
1238
|
+
* a second claimer evicts the incumbent rather than running beside it.
|
|
1239
|
+
*/
|
|
1240
|
+
function startDaemonLocked(agentsBin, releaseLock) {
|
|
1211
1241
|
const platform = os.platform();
|
|
1242
|
+
// Same contract on the fallback path: the spawn IS the launch, so the lock is
|
|
1243
|
+
// dropped before the child exists rather than in a `finally` the child races.
|
|
1244
|
+
const detachedFallback = () => {
|
|
1245
|
+
releaseLock();
|
|
1246
|
+
return startDetached({ agentsBin });
|
|
1247
|
+
};
|
|
1212
1248
|
if (platform === 'darwin') {
|
|
1213
1249
|
try {
|
|
1214
1250
|
const plistPath = getLaunchdPlistPath();
|
|
@@ -1229,6 +1265,8 @@ function startDaemonLocked(agentsBin) {
|
|
|
1229
1265
|
// of success. If no pid materializes within the window, give up on
|
|
1230
1266
|
// launchd and fall through to a plain detached spawn.
|
|
1231
1267
|
execFileSync('launchctl', ['load', plistPath], { encoding: 'utf-8', stdio: ['ignore', 'pipe', 'pipe'] });
|
|
1268
|
+
// Launch issued — the child needs this lock to claim (RUSH-2417).
|
|
1269
|
+
releaseLock();
|
|
1232
1270
|
const pid = waitForPid(3000) ?? readServiceManagerPid();
|
|
1233
1271
|
if (pid)
|
|
1234
1272
|
return { pid, method: 'launchd' };
|
|
@@ -1237,7 +1275,7 @@ function startDaemonLocked(agentsBin) {
|
|
|
1237
1275
|
catch {
|
|
1238
1276
|
// load threw — fall through to detached spawn
|
|
1239
1277
|
}
|
|
1240
|
-
return
|
|
1278
|
+
return detachedFallback();
|
|
1241
1279
|
}
|
|
1242
1280
|
if (platform === 'linux') {
|
|
1243
1281
|
try {
|
|
@@ -1252,6 +1290,8 @@ function startDaemonLocked(agentsBin) {
|
|
|
1252
1290
|
execFileSync('systemctl', ['--user', 'daemon-reload'], { encoding: 'utf-8' });
|
|
1253
1291
|
execFileSync('systemctl', ['--user', 'enable', SYSTEMD_UNIT], { encoding: 'utf-8' });
|
|
1254
1292
|
execFileSync('systemctl', ['--user', 'start', SYSTEMD_UNIT], { encoding: 'utf-8' });
|
|
1293
|
+
// Launch issued — the child needs this lock to claim (RUSH-2417).
|
|
1294
|
+
releaseLock();
|
|
1255
1295
|
const pid = waitForPid(3000) ?? readServiceManagerPid();
|
|
1256
1296
|
if (pid)
|
|
1257
1297
|
return { pid, method: 'systemd' };
|
|
@@ -1261,7 +1301,7 @@ function startDaemonLocked(agentsBin) {
|
|
|
1261
1301
|
catch {
|
|
1262
1302
|
// start threw — fall through to detached spawn
|
|
1263
1303
|
}
|
|
1264
|
-
return
|
|
1304
|
+
return detachedFallback();
|
|
1265
1305
|
}
|
|
1266
1306
|
return startDetached({ agentsBin });
|
|
1267
1307
|
}
|
|
@@ -37,6 +37,30 @@ export declare function fleetDialTarget(device: DeviceProfile): string;
|
|
|
37
37
|
* login).
|
|
38
38
|
*/
|
|
39
39
|
export declare function wrapRemoteCommand(device: DeviceProfile, cmd: string[]): string | undefined;
|
|
40
|
+
/**
|
|
41
|
+
* Build the remote command that starts an INTERACTIVE LOGIN shell inside a
|
|
42
|
+
* mirrored project directory, falling back to the remote home when that
|
|
43
|
+
* directory is absent. Returns undefined when there is nothing to mirror
|
|
44
|
+
* (`mirrorCwd` is undefined, or resolves to the home root itself) so the caller
|
|
45
|
+
* keeps the plain no-command interactive login.
|
|
46
|
+
*
|
|
47
|
+
* This is the interactive analogue of `agents run --host`'s cwd mirroring, and
|
|
48
|
+
* it reuses the SAME machinery so there is no second resolver: the portable
|
|
49
|
+
* `mirrorCwd` comes from `deriveMirroredCwd`, and — for POSIX — the best-effort
|
|
50
|
+
* `cd` comes from `remoteCdPrefix({ mirror: true })`, whose `|| cd "$HOME"`
|
|
51
|
+
* guarantees a missing checkout never fails the login (acceptance #2). It then
|
|
52
|
+
* replaces the wrapper with a login shell (`exec "$SHELL" -l`); running under
|
|
53
|
+
* the forced tty (`-tt`, see {@link buildSshInvocation}) makes that login shell
|
|
54
|
+
* interactive, so prompt, startup files, and login behavior match a plain
|
|
55
|
+
* `ssh <host>` (acceptance #1).
|
|
56
|
+
*
|
|
57
|
+
* PowerShell hosts get a profile-loading interactive shell (`-NoExit`, and
|
|
58
|
+
* deliberately NOT `-NoProfile` so the user's profile still runs) that
|
|
59
|
+
* `Set-Location`s into the mirrored dir when it exists. The path is carried
|
|
60
|
+
* through `-EncodedCommand` (base64 UTF-16LE), so it is literal and
|
|
61
|
+
* injection-safe regardless of spaces or shell metacharacters (acceptance #3).
|
|
62
|
+
*/
|
|
63
|
+
export declare function buildInteractiveShellCommand(device: DeviceProfile, mirrorCwd: string | undefined): string | undefined;
|
|
40
64
|
/** Host-key posture for {@link buildSshInvocation}. */
|
|
41
65
|
export interface SshHostKeyOptions {
|
|
42
66
|
/**
|
|
@@ -65,9 +89,18 @@ export declare function deviceIdentityArgs(device: DeviceProfile): string[];
|
|
|
65
89
|
* `opts.agentOnly` marks the connection as a read-only probe: for password auth
|
|
66
90
|
* it sets {@link ASKPASS_AGENT_ONLY_ENV} in the overlay so the askpass resolve
|
|
67
91
|
* stays broker-only and never pops a foreground biometric (RUSH-1970).
|
|
92
|
+
*
|
|
93
|
+
* `opts.interactiveCwd` is the portable (`~/…`) directory to mirror on an
|
|
94
|
+
* interactive login (no `cmd`) — from `deriveMirroredCwd(process.cwd())`. When
|
|
95
|
+
* it names a real home-relative sub-path, the login starts there via
|
|
96
|
+
* {@link buildInteractiveShellCommand} (best-effort — a missing dir falls back
|
|
97
|
+
* to the remote home), matching `agents run --host`. It is ignored when a `cmd`
|
|
98
|
+
* is given: an explicit command keeps its current cwd (the remote home) and its
|
|
99
|
+
* behavior unchanged (RUSH-2412).
|
|
68
100
|
*/
|
|
69
101
|
export declare function buildSshInvocation(device: DeviceProfile, cmd: string[], askpassShimPath: string, hostKey?: SshHostKeyOptions, opts?: {
|
|
70
102
|
agentOnly?: boolean;
|
|
103
|
+
interactiveCwd?: string;
|
|
71
104
|
}): {
|
|
72
105
|
args: string[];
|
|
73
106
|
env: Record<string, string>;
|
|
@@ -18,6 +18,7 @@ import * as path from 'path';
|
|
|
18
18
|
import { assertValidSshTarget, shellQuote } from '../ssh-exec.js';
|
|
19
19
|
import { getCliLaunch } from '../cli-entry.js';
|
|
20
20
|
import { encodePwshBase64 } from '../pwsh.js';
|
|
21
|
+
import { homeRemainder, remoteCdPrefix } from '../project-root.js';
|
|
21
22
|
import { getCacheDir } from '../state.js';
|
|
22
23
|
import { hostKeyCheckingOpts } from './known-hosts.js';
|
|
23
24
|
import { hostNameFor } from './ssh-config.js';
|
|
@@ -82,6 +83,48 @@ export function wrapRemoteCommand(device, cmd) {
|
|
|
82
83
|
}
|
|
83
84
|
return joined;
|
|
84
85
|
}
|
|
86
|
+
/**
|
|
87
|
+
* Build the remote command that starts an INTERACTIVE LOGIN shell inside a
|
|
88
|
+
* mirrored project directory, falling back to the remote home when that
|
|
89
|
+
* directory is absent. Returns undefined when there is nothing to mirror
|
|
90
|
+
* (`mirrorCwd` is undefined, or resolves to the home root itself) so the caller
|
|
91
|
+
* keeps the plain no-command interactive login.
|
|
92
|
+
*
|
|
93
|
+
* This is the interactive analogue of `agents run --host`'s cwd mirroring, and
|
|
94
|
+
* it reuses the SAME machinery so there is no second resolver: the portable
|
|
95
|
+
* `mirrorCwd` comes from `deriveMirroredCwd`, and — for POSIX — the best-effort
|
|
96
|
+
* `cd` comes from `remoteCdPrefix({ mirror: true })`, whose `|| cd "$HOME"`
|
|
97
|
+
* guarantees a missing checkout never fails the login (acceptance #2). It then
|
|
98
|
+
* replaces the wrapper with a login shell (`exec "$SHELL" -l`); running under
|
|
99
|
+
* the forced tty (`-tt`, see {@link buildSshInvocation}) makes that login shell
|
|
100
|
+
* interactive, so prompt, startup files, and login behavior match a plain
|
|
101
|
+
* `ssh <host>` (acceptance #1).
|
|
102
|
+
*
|
|
103
|
+
* PowerShell hosts get a profile-loading interactive shell (`-NoExit`, and
|
|
104
|
+
* deliberately NOT `-NoProfile` so the user's profile still runs) that
|
|
105
|
+
* `Set-Location`s into the mirrored dir when it exists. The path is carried
|
|
106
|
+
* through `-EncodedCommand` (base64 UTF-16LE), so it is literal and
|
|
107
|
+
* injection-safe regardless of spaces or shell metacharacters (acceptance #3).
|
|
108
|
+
*/
|
|
109
|
+
export function buildInteractiveShellCommand(device, mirrorCwd) {
|
|
110
|
+
if (!mirrorCwd)
|
|
111
|
+
return undefined;
|
|
112
|
+
const rest = homeRemainder(mirrorCwd);
|
|
113
|
+
// Only a real sub-path of the home dir is worth mirroring; the home root
|
|
114
|
+
// itself (rest === '') is where a plain login already lands, and a
|
|
115
|
+
// non-home-anchored path (rest === null) has no meaningful remote analogue.
|
|
116
|
+
if (rest === null || rest === '')
|
|
117
|
+
return undefined;
|
|
118
|
+
if (device.shell === 'powershell') {
|
|
119
|
+
// Single-quoted PowerShell literal: the only escape inside '…' is '' for a
|
|
120
|
+
// literal quote, so this is injection-safe for any path.
|
|
121
|
+
const literal = rest.replace(/'/g, "''");
|
|
122
|
+
const script = `$d = Join-Path -Path $HOME -ChildPath '${literal}'; ` +
|
|
123
|
+
`if (Test-Path -LiteralPath $d) { Set-Location -LiteralPath $d }`;
|
|
124
|
+
return `powershell -NoLogo -NoExit -EncodedCommand ${encodePwshBase64(script)}`;
|
|
125
|
+
}
|
|
126
|
+
return `${remoteCdPrefix(mirrorCwd, { mirror: true })}exec "$SHELL" -l`;
|
|
127
|
+
}
|
|
85
128
|
/** OpenSSH argv that makes an explicit device key authoritative. */
|
|
86
129
|
export function deviceIdentityArgs(device) {
|
|
87
130
|
return device.auth?.method === 'key' && device.auth.identityFile
|
|
@@ -101,10 +144,24 @@ export function deviceIdentityArgs(device) {
|
|
|
101
144
|
* `opts.agentOnly` marks the connection as a read-only probe: for password auth
|
|
102
145
|
* it sets {@link ASKPASS_AGENT_ONLY_ENV} in the overlay so the askpass resolve
|
|
103
146
|
* stays broker-only and never pops a foreground biometric (RUSH-1970).
|
|
147
|
+
*
|
|
148
|
+
* `opts.interactiveCwd` is the portable (`~/…`) directory to mirror on an
|
|
149
|
+
* interactive login (no `cmd`) — from `deriveMirroredCwd(process.cwd())`. When
|
|
150
|
+
* it names a real home-relative sub-path, the login starts there via
|
|
151
|
+
* {@link buildInteractiveShellCommand} (best-effort — a missing dir falls back
|
|
152
|
+
* to the remote home), matching `agents run --host`. It is ignored when a `cmd`
|
|
153
|
+
* is given: an explicit command keeps its current cwd (the remote home) and its
|
|
154
|
+
* behavior unchanged (RUSH-2412).
|
|
104
155
|
*/
|
|
105
156
|
export function buildSshInvocation(device, cmd, askpassShimPath, hostKey = {}, opts = {}) {
|
|
106
157
|
const target = sshTargetFor(device);
|
|
107
|
-
|
|
158
|
+
// No cmd ⇒ interactive login. It may still carry a derived cd+login-shell
|
|
159
|
+
// wrapper (interactiveCwd), which is an interactive login too and still needs
|
|
160
|
+
// a real tty below.
|
|
161
|
+
const interactive = cmd.length === 0;
|
|
162
|
+
const remote = interactive
|
|
163
|
+
? buildInteractiveShellCommand(device, opts.interactiveCwd)
|
|
164
|
+
: wrapRemoteCommand(device, cmd);
|
|
108
165
|
const env = {};
|
|
109
166
|
const args = [
|
|
110
167
|
...hostKeyCheckingOpts(hostKey.pinned ?? false, hostKey.knownHostsFile),
|
|
@@ -126,8 +183,9 @@ export function buildSshInvocation(device, cmd, askpassShimPath, hostKey = {}, o
|
|
|
126
183
|
args.push('-o', 'BatchMode=yes');
|
|
127
184
|
args.push(...deviceIdentityArgs(device));
|
|
128
185
|
}
|
|
129
|
-
// An interactive login
|
|
130
|
-
|
|
186
|
+
// An interactive login needs a real tty — whether it starts a bare login
|
|
187
|
+
// shell (no remote command) or the derived cd+login-shell mirror.
|
|
188
|
+
if (interactive)
|
|
131
189
|
args.push('-tt');
|
|
132
190
|
args.push(target);
|
|
133
191
|
if (remote)
|
|
@@ -55,8 +55,10 @@ export interface DoctorFinding {
|
|
|
55
55
|
* The exact remediation for a finding. Login fixes are harness-native
|
|
56
56
|
* (`loginHint`); a per-version login is offered ONLY for agents that isolate the
|
|
57
57
|
* credential per home (`agents run <agent>@<version>` then log in) — for
|
|
58
|
-
* gemini/antigravity/droid
|
|
59
|
-
* faking a per-version fix.
|
|
58
|
+
* gemini/antigravity/droid the login is shared, so we say so instead of
|
|
59
|
+
* faking a per-version fix. (Cursor now isolates its token per version home via
|
|
60
|
+
* XDG_CONFIG_HOME — see buildExecEnv — so it gets the per-version run.) Every
|
|
61
|
+
* other kind maps to its canonical command.
|
|
60
62
|
*/
|
|
61
63
|
export declare function remediationFor(finding: DoctorFinding): string;
|
|
62
64
|
export interface LocalFindingInputs {
|
|
@@ -168,8 +168,10 @@ function sortedAgentIds(ids) {
|
|
|
168
168
|
* The exact remediation for a finding. Login fixes are harness-native
|
|
169
169
|
* (`loginHint`); a per-version login is offered ONLY for agents that isolate the
|
|
170
170
|
* credential per home (`agents run <agent>@<version>` then log in) — for
|
|
171
|
-
* gemini/antigravity/droid
|
|
172
|
-
* faking a per-version fix.
|
|
171
|
+
* gemini/antigravity/droid the login is shared, so we say so instead of
|
|
172
|
+
* faking a per-version fix. (Cursor now isolates its token per version home via
|
|
173
|
+
* XDG_CONFIG_HOME — see buildExecEnv — so it gets the per-version run.) Every
|
|
174
|
+
* other kind maps to its canonical command.
|
|
173
175
|
*/
|
|
174
176
|
export function remediationFor(finding) {
|
|
175
177
|
const { kind, agent, version } = finding;
|