@phnx-labs/agents-cli 1.20.77 → 1.20.78
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 +102 -0
- package/README.md +2 -0
- package/dist/bin/agents +0 -0
- package/dist/commands/doctor.d.ts +9 -0
- package/dist/commands/doctor.js +143 -9
- package/dist/commands/exec.js +29 -3
- package/dist/commands/repo.js +8 -0
- package/dist/commands/routines.js +4 -2
- package/dist/commands/secrets.js +8 -2
- package/dist/commands/sessions-browser.d.ts +45 -3
- package/dist/commands/sessions-browser.js +118 -12
- package/dist/commands/sessions-export.d.ts +3 -0
- package/dist/commands/sessions-export.js +13 -10
- package/dist/commands/sessions-picker.d.ts +15 -1
- package/dist/commands/sessions-picker.js +58 -4
- package/dist/commands/sessions.d.ts +95 -1
- package/dist/commands/sessions.js +224 -26
- package/dist/commands/ssh.js +9 -1
- package/dist/index.js +3 -5
- package/dist/lib/agents.d.ts +0 -21
- package/dist/lib/agents.js +0 -37
- package/dist/lib/auto-pull.d.ts +16 -8
- package/dist/lib/auto-pull.js +23 -28
- package/dist/lib/daemon.d.ts +9 -41
- package/dist/lib/daemon.js +16 -117
- package/dist/lib/devices/fleet-divergence.d.ts +101 -0
- package/dist/lib/devices/fleet-divergence.js +188 -0
- package/dist/lib/devices/fleet-inventory.d.ts +19 -0
- package/dist/lib/devices/fleet-inventory.js +57 -0
- package/dist/lib/devices/health-report.d.ts +10 -2
- package/dist/lib/devices/health-report.js +32 -1
- package/dist/lib/git.d.ts +13 -0
- package/dist/lib/git.js +102 -4
- package/dist/lib/hosts/remote-cmd.js +2 -0
- package/dist/lib/menubar/MenubarHelper.app/Contents/Info.plist +2 -0
- package/dist/lib/menubar/MenubarHelper.app/Contents/MacOS/MenubarHelper +0 -0
- package/dist/lib/menubar/MenubarHelper.app/Contents/Resources/AppIcon.icns +0 -0
- package/dist/lib/menubar/MenubarHelper.app/Contents/_CodeSignature/CodeResources +15 -2
- package/dist/lib/runner.js +29 -26
- package/dist/lib/sandbox.js +0 -16
- package/dist/lib/secrets/Agents CLI.app/Contents/CodeResources +0 -0
- package/dist/lib/secrets/Agents CLI.app/Contents/Info.plist +2 -0
- package/dist/lib/secrets/Agents CLI.app/Contents/MacOS/Agents CLI +0 -0
- package/dist/lib/secrets/Agents CLI.app/Contents/Resources/AppIcon.icns +0 -0
- package/dist/lib/secrets/Agents CLI.app/Contents/_CodeSignature/CodeResources +13 -1
- package/dist/lib/secrets/agent.d.ts +2 -0
- package/dist/lib/secrets/agent.js +26 -14
- package/dist/lib/secrets/bundles.d.ts +1 -1
- package/dist/lib/secrets/bundles.js +16 -8
- package/dist/lib/secrets/scope.d.ts +26 -0
- package/dist/lib/secrets/scope.js +29 -0
- package/dist/lib/secrets/session-store.d.ts +10 -0
- package/dist/lib/secrets/session-store.js +64 -11
- package/dist/lib/session/active.d.ts +7 -0
- package/dist/lib/session/active.js +38 -3
- package/dist/lib/session/db.d.ts +37 -0
- package/dist/lib/session/db.js +112 -4
- package/dist/lib/session/digest.js +126 -21
- package/dist/lib/session/discover.d.ts +15 -2
- package/dist/lib/session/discover.js +19 -11
- package/dist/lib/session/origin-machine.d.ts +18 -0
- package/dist/lib/session/origin-machine.js +34 -0
- package/dist/lib/session/state.d.ts +1 -0
- package/dist/lib/session/state.js +1 -1
- package/dist/lib/session/sync/config.js +2 -2
- package/dist/lib/smart-launch.d.ts +86 -0
- package/dist/lib/smart-launch.js +172 -0
- package/package.json +1 -1
- package/dist/lib/secrets/account-token.d.ts +0 -20
- package/dist/lib/secrets/account-token.js +0 -64
|
@@ -9,14 +9,15 @@
|
|
|
9
9
|
* Built on {@link dynamicPicker}; every data source (discover, fleet, live index,
|
|
10
10
|
* preview, resume dispatch) is reused from the existing sessions plumbing.
|
|
11
11
|
*/
|
|
12
|
+
import path from 'path';
|
|
12
13
|
import { spawnSync } from 'child_process';
|
|
13
14
|
import { dynamicPicker } from '../lib/picker.js';
|
|
14
|
-
import {
|
|
15
|
+
import { isSessionTrackedAgent } from '../lib/session/types.js';
|
|
15
16
|
import { discoverSessions } from '../lib/session/discover.js';
|
|
16
17
|
import { gatherRemoteList } from '../lib/session/remote-list.js';
|
|
17
18
|
import { machineId, normalizeHost } from '../lib/session/sync/config.js';
|
|
18
19
|
import { buildPreview } from './sessions-picker.js';
|
|
19
|
-
import { formatPickerLabel, pickerColumnsFor, ticketLabel, mergeLocalFirst,
|
|
20
|
+
import { formatPickerLabel, pickerColumnsFor, ticketLabel, mergeLocalFirst, gatherActiveSessions, liveHostLabel, LIVE_ROW_PREFIX, cleanPreview, handlePickedSession, shouldIncludeLocal, remoteHostsToDial, } from './sessions.js';
|
|
20
21
|
/** Ordered window cycle for the `W` key. `undefined` = all time. */
|
|
21
22
|
const WINDOW_CYCLE = [undefined, '1d', '7d', '30d'];
|
|
22
23
|
/** Return the next value in `[undefined, ...options]`, wrapping. */
|
|
@@ -102,20 +103,25 @@ export function activeBrowserSeed(opts) {
|
|
|
102
103
|
agent: opts.agent,
|
|
103
104
|
projectScope: 'all',
|
|
104
105
|
device: normalizeDeviceSeed(opts.host?.[0]),
|
|
105
|
-
window
|
|
106
|
+
// --all widens the window to all-time (project is already 'all' here);
|
|
107
|
+
// --since still overrides.
|
|
108
|
+
window: opts.since ?? (opts.all ? undefined : '30d'),
|
|
106
109
|
};
|
|
107
110
|
}
|
|
108
111
|
/**
|
|
109
112
|
* The initial filter for the bare interactive listing: current-repo subtree by
|
|
110
|
-
* default (matches the static overview's cwd scoping)
|
|
111
|
-
* directory
|
|
113
|
+
* default (matches the static overview's cwd scoping). `--all` sets every
|
|
114
|
+
* non-status filter to its "all" value — every directory (project) AND all-time
|
|
115
|
+
* (window) — so one flag maxes the view; `--since` still overrides the window and
|
|
116
|
+
* `-a`/`--device` still narrow their axis.
|
|
112
117
|
*/
|
|
113
118
|
export function bareBrowserSeed(opts) {
|
|
114
119
|
return {
|
|
115
120
|
teams: !!opts.teams,
|
|
116
121
|
agent: opts.agent,
|
|
122
|
+
// --all maxes every non-status filter: all dirs AND all-time. --since wins.
|
|
117
123
|
projectScope: opts.all ? 'all' : 'repo',
|
|
118
|
-
window: opts.since ?? '30d',
|
|
124
|
+
window: opts.since ?? (opts.all ? undefined : '30d'),
|
|
119
125
|
};
|
|
120
126
|
}
|
|
121
127
|
/** Copy text to the OS clipboard (best-effort; silently no-ops if unavailable). */
|
|
@@ -182,6 +188,99 @@ async function fetchRawPool(f, self, local, hosts) {
|
|
|
182
188
|
}
|
|
183
189
|
return { key: `${since ?? 'all'}|${f.teams}`, rows };
|
|
184
190
|
}
|
|
191
|
+
/**
|
|
192
|
+
* A live session's stable row key: its session id when the agent reported one,
|
|
193
|
+
* else a per-machine handle so an id-less live session (a just-booted harness, a
|
|
194
|
+
* cloud task) still gets exactly one row instead of being dropped. Cloud rows key
|
|
195
|
+
* on the task id because they have no pid — keying them all on the machine alone
|
|
196
|
+
* would collapse two cloud tasks into one row, the same silent-drop this whole
|
|
197
|
+
* change exists to remove.
|
|
198
|
+
*/
|
|
199
|
+
export function liveRowKey(a, self) {
|
|
200
|
+
if (a.sessionId)
|
|
201
|
+
return a.sessionId;
|
|
202
|
+
const handle = a.cloudTaskId ?? (a.pid != null ? String(a.pid) : 'unknown');
|
|
203
|
+
return `${LIVE_ROW_PREFIX}${a.machine ?? self}:${handle}`;
|
|
204
|
+
}
|
|
205
|
+
/** Index live sessions by {@link liveRowKey} — the join key between the live scan
|
|
206
|
+
* and the transcript pool. Id-carrying rows key on the session id, so a live row
|
|
207
|
+
* and its transcript row collapse to one. */
|
|
208
|
+
export function indexLiveRows(rows, self) {
|
|
209
|
+
const byKey = new Map();
|
|
210
|
+
for (const a of rows)
|
|
211
|
+
byKey.set(liveRowKey(a, self), a);
|
|
212
|
+
return byKey;
|
|
213
|
+
}
|
|
214
|
+
/**
|
|
215
|
+
* Project a live session onto the picker's row shape, for a session the transcript
|
|
216
|
+
* pool doesn't carry — a peer's session, a transcript outside the current window,
|
|
217
|
+
* or an agent that has not written one yet. `filePath` is the live transcript path
|
|
218
|
+
* when the scan resolved one and empty otherwise, which is what
|
|
219
|
+
* {@link handlePickedSession} keys "there is nothing to open yet" off.
|
|
220
|
+
*/
|
|
221
|
+
export function liveSessionToMeta(a, self) {
|
|
222
|
+
const machine = a.machine ?? self;
|
|
223
|
+
const started = a.startedAtMs ?? a.lastActivityMs;
|
|
224
|
+
const topic = a.topic ?? a.preview;
|
|
225
|
+
return {
|
|
226
|
+
id: liveRowKey(a, self),
|
|
227
|
+
// No session id to short — name the row by what it IS (a cloud task, a pid),
|
|
228
|
+
// so the id column still identifies the process you'd go looking for.
|
|
229
|
+
shortId: a.sessionId
|
|
230
|
+
? a.sessionId.slice(0, 8)
|
|
231
|
+
: a.cloudTaskId
|
|
232
|
+
? a.cloudTaskId.slice(0, 8)
|
|
233
|
+
: `p:${a.pid ?? '?'}`,
|
|
234
|
+
agent: isSessionTrackedAgent(a.kind) ? a.kind : 'claude',
|
|
235
|
+
timestamp: new Date(started ?? Date.now()).toISOString(),
|
|
236
|
+
lastActivity: a.lastActivityMs ? new Date(a.lastActivityMs).toISOString() : undefined,
|
|
237
|
+
project: a.cwd ? path.basename(a.cwd) : undefined,
|
|
238
|
+
cwd: a.cwd,
|
|
239
|
+
filePath: a.sessionFile ?? '',
|
|
240
|
+
// A live topic is raw transcript text: a newline in it would break the row
|
|
241
|
+
// into two and misalign every column after it. Indexed rows are already
|
|
242
|
+
// cleaned at scan time, so this puts projected rows on the same footing.
|
|
243
|
+
topic: topic ? cleanPreview(topic) : undefined,
|
|
244
|
+
label: a.label ? cleanPreview(a.label) : undefined,
|
|
245
|
+
machine,
|
|
246
|
+
// Reading/resuming a peer's session hops back over SSH — same contract the
|
|
247
|
+
// cross-machine listing sets, so handlePickedSession routes it correctly.
|
|
248
|
+
_remote: machine !== self,
|
|
249
|
+
prUrl: a.pr?.url,
|
|
250
|
+
prNumber: a.pr?.number,
|
|
251
|
+
ticketId: a.ticket?.id,
|
|
252
|
+
worktreeSlug: a.worktree?.slug,
|
|
253
|
+
};
|
|
254
|
+
}
|
|
255
|
+
/**
|
|
256
|
+
* Fold the live scan INTO the transcript pool. The running filter used to be a
|
|
257
|
+
* pure intersection (`pool ∩ live`), which meant a session had to already be in
|
|
258
|
+
* the pool to be shown as running — so every session on another machine, and
|
|
259
|
+
* every local one outside the pool's window, was invisible in the browser while
|
|
260
|
+
* `--active --json` listed it. Live sessions the pool lacks are appended as their
|
|
261
|
+
* own rows, then the whole set is grouped local-machine-first.
|
|
262
|
+
*/
|
|
263
|
+
export function mergeLiveIntoPool(rows, live, self) {
|
|
264
|
+
const known = new Set(rows.map((r) => r.id));
|
|
265
|
+
const extra = [];
|
|
266
|
+
for (const [key, a] of live) {
|
|
267
|
+
if (!known.has(key))
|
|
268
|
+
extra.push(liveSessionToMeta(a, self));
|
|
269
|
+
}
|
|
270
|
+
return extra.length === 0 ? rows : mergeLocalFirst([...rows, ...extra], self);
|
|
271
|
+
}
|
|
272
|
+
/**
|
|
273
|
+
* Whether to render the host-program column. It belongs to the running view
|
|
274
|
+
* only, so this gates on the FILTER — not on the live index being populated.
|
|
275
|
+
* `liveCache` outlives a toggle of the `r` hotkey, so testing `live` alone would
|
|
276
|
+
* keep the column after running is turned back off, widening a plain transcript
|
|
277
|
+
* listing that has no live rows to explain it.
|
|
278
|
+
*/
|
|
279
|
+
export function shouldShowHostColumn(f, live, rows) {
|
|
280
|
+
if (!f.running || !live)
|
|
281
|
+
return false;
|
|
282
|
+
return rows.some((r) => liveHostLabel(live.get(r.id)) !== '');
|
|
283
|
+
}
|
|
185
284
|
/** Apply the cheap in-memory filters (agent / device / project / running). */
|
|
186
285
|
function applyFilters(rows, live, f, self) {
|
|
187
286
|
let out = rows;
|
|
@@ -269,11 +368,14 @@ export async function runSessionBrowser(initial = {}, opts = {}) {
|
|
|
269
368
|
return []; // superseded — don't touch shared state
|
|
270
369
|
pool = fetched;
|
|
271
370
|
}
|
|
272
|
-
// Only pay for the live scan when the running filter needs it.
|
|
371
|
+
// Only pay for the live scan when the running filter needs it. Same fleet
|
|
372
|
+
// sweep the static `--active` view uses, so the two never disagree about
|
|
373
|
+
// what is running.
|
|
273
374
|
let live = liveCache;
|
|
274
375
|
if (f.running && !live) {
|
|
275
376
|
try {
|
|
276
|
-
|
|
377
|
+
const { sessions } = await gatherActiveSessions({ local, hosts });
|
|
378
|
+
live = indexLiveRows(sessions, self);
|
|
277
379
|
}
|
|
278
380
|
catch {
|
|
279
381
|
live = new Map();
|
|
@@ -286,10 +388,14 @@ export async function runSessionBrowser(initial = {}, opts = {}) {
|
|
|
286
388
|
rawCache = pool;
|
|
287
389
|
if (live)
|
|
288
390
|
liveCache = live;
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
const
|
|
391
|
+
// Live sessions the transcript pool lacks become rows of their own, so the
|
|
392
|
+
// running view lists every active session, not just the ones already indexed.
|
|
393
|
+
const rows = f.running && live ? mergeLiveIntoPool(pool.rows, live, self) : pool.rows;
|
|
394
|
+
agentsInPool = distinct(rows.map((r) => r.agent));
|
|
395
|
+
devicesInPool = distinct(rows.map((r) => r.machine ?? self));
|
|
396
|
+
const filtered = applyFilters(rows, live ?? new Map(), f, self);
|
|
292
397
|
cols = pickerColumnsFor(filtered);
|
|
398
|
+
cols.showHost = shouldShowHostColumn(f, live, filtered);
|
|
293
399
|
return filtered;
|
|
294
400
|
};
|
|
295
401
|
const picked = await dynamicPicker({
|
|
@@ -297,7 +403,7 @@ export async function runSessionBrowser(initial = {}, opts = {}) {
|
|
|
297
403
|
initialFilter,
|
|
298
404
|
load,
|
|
299
405
|
keyFor: (s) => s.id,
|
|
300
|
-
labelFor: (s, q) => formatPickerLabel(s, q, cols, sshOriginTagFor(liveCache, s.id)),
|
|
406
|
+
labelFor: (s, q) => formatPickerLabel(s, q, cols, sshOriginTagFor(liveCache, s.id), liveHostLabel(liveCache?.get(s.id))),
|
|
301
407
|
matches: sessionMatchesQuery,
|
|
302
408
|
buildPreview,
|
|
303
409
|
headerFor,
|
|
@@ -1,2 +1,5 @@
|
|
|
1
1
|
import type { Command } from 'commander';
|
|
2
|
+
import type { SessionMeta } from '../lib/session/types.js';
|
|
2
3
|
export declare function registerSessionsExportCommand(sessionsCmd: Command): void;
|
|
4
|
+
/** ids > query > everything-in-scope. Exported for the id-resolution test. */
|
|
5
|
+
export declare function selectSessions(metas: SessionMeta[], selectors: string[]): SessionMeta[];
|
|
@@ -20,7 +20,7 @@
|
|
|
20
20
|
import * as fs from 'fs';
|
|
21
21
|
import * as path from 'path';
|
|
22
22
|
import chalk from 'chalk';
|
|
23
|
-
import { discoverSessions, resolveSessionById,
|
|
23
|
+
import { discoverSessions, resolveSessionById, looksLikeSessionId } from '../lib/session/discover.js';
|
|
24
24
|
import { findSessionsById } from '../lib/session/db.js';
|
|
25
25
|
import { filterSessionsByQuery, parseAgentFilter } from './sessions.js';
|
|
26
26
|
import { listLocalTranscripts, SYNC_AGENTS } from '../lib/session/sync/agents.js';
|
|
@@ -222,8 +222,8 @@ function resolveAgentShorthand(g) {
|
|
|
222
222
|
return 'antigravity';
|
|
223
223
|
return undefined;
|
|
224
224
|
}
|
|
225
|
-
/** ids > query > everything-in-scope. */
|
|
226
|
-
function selectSessions(metas, selectors) {
|
|
225
|
+
/** ids > query > everything-in-scope. Exported for the id-resolution test. */
|
|
226
|
+
export function selectSessions(metas, selectors) {
|
|
227
227
|
if (selectors.length === 0)
|
|
228
228
|
return metas;
|
|
229
229
|
const byId = [];
|
|
@@ -231,9 +231,11 @@ function selectSessions(metas, selectors) {
|
|
|
231
231
|
for (const sel of selectors) {
|
|
232
232
|
const trimmed = sel.trim();
|
|
233
233
|
// Same reason as resolveSessionQuery: the discovered pool is a minority of
|
|
234
|
-
// the index, so
|
|
234
|
+
// the index, so an id-shaped selector absent from it may still be indexed
|
|
235
|
+
// here. Gate on looksLikeSessionId, not isCompleteSessionId, so a SHORT id
|
|
236
|
+
// ("d3470b57") also resolves through the index instead of falling to content.
|
|
235
237
|
const hits = resolveSessionById(metas, trimmed);
|
|
236
|
-
const resolved = hits.length > 0 || !
|
|
238
|
+
const resolved = hits.length > 0 || !looksLikeSessionId(trimmed) ? hits : findSessionsById(trimmed);
|
|
237
239
|
if (resolved.length > 0)
|
|
238
240
|
byId.push(...resolved);
|
|
239
241
|
else
|
|
@@ -243,11 +245,12 @@ function selectSessions(metas, selectors) {
|
|
|
243
245
|
const seen = new Set();
|
|
244
246
|
return byId.filter(s => (seen.has(s.id) ? false : (seen.add(s.id), true)));
|
|
245
247
|
}
|
|
246
|
-
// A selector that is a
|
|
247
|
-
// the id is unique, so the text query below could only bundle
|
|
248
|
-
// merely mention it. Bundling those would ship unrelated
|
|
249
|
-
// whoever receives the export, so select nothing and let the
|
|
250
|
-
|
|
248
|
+
// A selector that is an id (complete OR a short hex id/prefix) and still missed
|
|
249
|
+
// cannot be widened: the id is unique, so the text query below could only bundle
|
|
250
|
+
// sessions that merely mention it. Bundling those would ship unrelated
|
|
251
|
+
// transcripts to whoever receives the export, so select nothing and let the
|
|
252
|
+
// caller report it — a short id like "d3470b57" must never content-search.
|
|
253
|
+
const missingIds = unmatched.filter(looksLikeSessionId);
|
|
251
254
|
if (missingIds.length > 0) {
|
|
252
255
|
process.stderr.write(chalk.red(`No session with id ${missingIds.join(', ')} on this machine.\n`));
|
|
253
256
|
return [];
|
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import type { SessionMeta, TodoProgress } from '../lib/session/types.js';
|
|
1
|
+
import type { SessionEvent, SessionMeta, TodoProgress } from '../lib/session/types.js';
|
|
2
|
+
import { classifyFileChanges } from '../lib/session/digest.js';
|
|
2
3
|
/**
|
|
3
4
|
* Compact checklist tally for list rows and previews (RUSH-2045).
|
|
4
5
|
* Example: `✓6/8 · A5 wiring runner`. Empty string when there is no list.
|
|
@@ -30,5 +31,18 @@ export interface SessionPickerConfig {
|
|
|
30
31
|
}
|
|
31
32
|
/** Build a cached multi-line preview string for display in the session picker. */
|
|
32
33
|
export declare function buildPreview(session: SessionMeta): string;
|
|
34
|
+
/** Optional dirs-touched field the parser teammate may attach; we prefer it. */
|
|
35
|
+
type SessionMetaWithDirs = SessionMeta & {
|
|
36
|
+
dirsTouched?: string[];
|
|
37
|
+
};
|
|
38
|
+
/**
|
|
39
|
+
* Unique directories the session touched, compact and human-readable.
|
|
40
|
+
* Prefer `session.dirsTouched` when the parser teammate has populated it;
|
|
41
|
+
* otherwise derive from file-change + tool paths already available here.
|
|
42
|
+
*/
|
|
43
|
+
export declare function directoriesTouched(session: SessionMetaWithDirs, events: SessionEvent[], changes: ReturnType<typeof classifyFileChanges>): string[];
|
|
44
|
+
/** Relativize a file path to its parent dir, short enough for one preview line. */
|
|
45
|
+
export declare function relativizeDir(filePath: string, cwd?: string): string | undefined;
|
|
33
46
|
/** Show an interactive session picker and return the selected session with its action (resume or view). */
|
|
34
47
|
export declare function sessionPicker(config: SessionPickerConfig): Promise<PickedSession | null>;
|
|
48
|
+
export {};
|
|
@@ -13,7 +13,7 @@ import { parseSession, sanitizeForTerminal } from '../lib/session/parse.js';
|
|
|
13
13
|
import { cleanSessionPrompt, extractSessionTopic } from '../lib/session/prompt.js';
|
|
14
14
|
import { linkPath, linkUrl, relativeToCwd } from '../lib/session/render.js';
|
|
15
15
|
import { linearIssueUrl } from '../lib/session/linear.js';
|
|
16
|
-
import { extractTodoProgress } from '../lib/session/state.js';
|
|
16
|
+
import { extractTodoProgress, WORKTREE_RE } from '../lib/session/state.js';
|
|
17
17
|
import { renderMarkdown } from '../lib/markdown.js';
|
|
18
18
|
import { itemPicker } from '../lib/picker.js';
|
|
19
19
|
import { classifyFileChanges, changeCounts, toolHistogram, detectTestResult } from '../lib/session/digest.js';
|
|
@@ -408,7 +408,7 @@ function formatCompactPreview(events, session) {
|
|
|
408
408
|
* Prefer `session.dirsTouched` when the parser teammate has populated it;
|
|
409
409
|
* otherwise derive from file-change + tool paths already available here.
|
|
410
410
|
*/
|
|
411
|
-
function directoriesTouched(session, events, changes) {
|
|
411
|
+
export function directoriesTouched(session, events, changes) {
|
|
412
412
|
const fromMeta = session.dirsTouched;
|
|
413
413
|
if (Array.isArray(fromMeta) && fromMeta.length > 0) {
|
|
414
414
|
return fromMeta
|
|
@@ -437,19 +437,73 @@ function directoriesTouched(session, events, changes) {
|
|
|
437
437
|
.map(([d]) => d)
|
|
438
438
|
.slice(0, DIRS_TOUCHED_MAX);
|
|
439
439
|
}
|
|
440
|
+
/**
|
|
441
|
+
* Claude names its per-project transcript store `~/.claude/projects/<slug>`, where
|
|
442
|
+
* `<slug>` is the cwd with every `/` AND `.` replaced by `-` — so `/Users/me/app`
|
|
443
|
+
* → `-Users-me-app` and `.agents/worktrees/x` → `--agents-worktrees-x`. That slug
|
|
444
|
+
* leaks into transcript paths as a leading segment (`<slug>/<session-id>/…`).
|
|
445
|
+
*
|
|
446
|
+
* The encoding is LOSSY and irreversible (`-`, `/`, and `.` all collapse to `-`),
|
|
447
|
+
* so we never try to decode it back to a path. Instead we ENCODE the comparison
|
|
448
|
+
* targets (cwd, the worktree marker) into the same slug space and match there. See
|
|
449
|
+
* {@link relativizeDir}. `encodeClaudeSlug` mirrors Claude's own transform.
|
|
450
|
+
*/
|
|
451
|
+
function encodeClaudeSlug(absPath) {
|
|
452
|
+
return absPath.replace(/[/.]/g, '-');
|
|
453
|
+
}
|
|
454
|
+
/** The `.agents/worktrees/<name>` marker, Claude-slug-encoded (`/.` → `--`). */
|
|
455
|
+
const SLUG_WORKTREE_RE = /--agents-worktrees-(.+)$/;
|
|
440
456
|
/** Relativize a file path to its parent dir, short enough for one preview line. */
|
|
441
|
-
function relativizeDir(filePath, cwd) {
|
|
457
|
+
export function relativizeDir(filePath, cwd) {
|
|
442
458
|
const norm = filePath.replace(/\\/g, '/');
|
|
443
459
|
if (!norm || norm.includes('node_modules') || norm.includes('/.git/') || norm.includes('/plans/')) {
|
|
444
460
|
return undefined;
|
|
445
461
|
}
|
|
446
462
|
let dir = path.posix.dirname(norm);
|
|
463
|
+
// Claude project-slug form: a leading `-`-segment (`-home-me-…`) that carries
|
|
464
|
+
// the cwd, lossily encoded. Handle it in SLUG SPACE — never lossy-decode to a
|
|
465
|
+
// fake path. The slug is the first path segment; any real `/`-subdirs after it
|
|
466
|
+
// are Claude's internal storage (`<session-id>/scratchpad|tasks`), not code.
|
|
467
|
+
if (dir.startsWith('-')) {
|
|
468
|
+
const slash = dir.indexOf('/');
|
|
469
|
+
const slug = slash === -1 ? dir : dir.slice(0, slash);
|
|
470
|
+
// CWD FIRST: if the slug is (or is under) this session's own cwd, the leaked
|
|
471
|
+
// path is Claude's internal projects-storage scratch (`<id>/scratchpad`) —
|
|
472
|
+
// not a meaningful code dir — so drop it like node_modules. Precedence over
|
|
473
|
+
// the worktree collapse so a session editing its OWN worktree isn't relabeled.
|
|
474
|
+
if (cwd) {
|
|
475
|
+
const cwdSlug = encodeClaudeSlug(cwd.replace(/\\/g, '/').replace(/\/$/, ''));
|
|
476
|
+
if (slug === cwdSlug || slug.startsWith(cwdSlug + '-'))
|
|
477
|
+
return undefined;
|
|
478
|
+
}
|
|
479
|
+
// Only a DIFFERENT worktree than cwd reaches here: a worktree encodes its
|
|
480
|
+
// `/.agents/worktrees/<name>` marker as `--agents-worktrees-<name>`, so
|
|
481
|
+
// collapse to the worktree name to disambiguate. (The name may contain `-`;
|
|
482
|
+
// we can't losslessly re-split it, so show the whole encoded remainder.)
|
|
483
|
+
const wtSlug = slug.match(SLUG_WORKTREE_RE);
|
|
484
|
+
if (wtSlug)
|
|
485
|
+
return `⧉ ${wtSlug[1]}`;
|
|
486
|
+
// An unattributable slug: don't invent a `/`-joined fake path. Show only the
|
|
487
|
+
// trailing `-`-group as a minimal, honest token.
|
|
488
|
+
const segs = slug.split('-').filter(Boolean);
|
|
489
|
+
return segs.length ? segs[segs.length - 1] : undefined;
|
|
490
|
+
}
|
|
491
|
+
// CWD FIRST for real paths too: strip the session-cwd prefix so a session
|
|
492
|
+
// editing its OWN worktree renders the concise relative remainder (`src/lib`),
|
|
493
|
+
// not the longer `⧉ <slug>/…` collapse.
|
|
447
494
|
if (cwd) {
|
|
448
495
|
const base = cwd.replace(/\\/g, '/').replace(/\/$/, '');
|
|
449
496
|
if (dir === base)
|
|
450
497
|
return '.';
|
|
451
498
|
if (dir.startsWith(base + '/'))
|
|
452
|
-
|
|
499
|
+
return dir.slice(base.length + 1);
|
|
500
|
+
}
|
|
501
|
+
// No cwd match. If the dir is in a (different) git worktree, collapse to the
|
|
502
|
+
// worktree NAME + in-worktree remainder to disambiguate; else home→`~` + trim.
|
|
503
|
+
const wt = dir.match(WORKTREE_RE);
|
|
504
|
+
if (wt) {
|
|
505
|
+
const after = dir.slice(dir.indexOf(wt[0]) + wt[0].length).replace(/^\//, '');
|
|
506
|
+
return after ? `⧉ ${wt[1]}/${after}` : `⧉ ${wt[1]}`;
|
|
453
507
|
}
|
|
454
508
|
// Collapse home prefix.
|
|
455
509
|
const home = (process.env.HOME || '').replace(/\\/g, '/');
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import type { Command } from 'commander';
|
|
2
2
|
import type { SessionAgentId, SessionMeta, ViewMode } from '../lib/session/types.js';
|
|
3
3
|
import { type ActiveSession } from '../lib/session/active.js';
|
|
4
|
+
import { gatherRemoteList, runOnPeer } from '../lib/session/remote-list.js';
|
|
4
5
|
import { type PickedSession } from './sessions-picker.js';
|
|
5
6
|
/**
|
|
6
7
|
* Strip terminal/harness noise from a preview so the column stays a single line
|
|
@@ -150,6 +151,25 @@ export declare function shouldIncludeLocal(hosts: string[] | undefined, self: st
|
|
|
150
151
|
* Exported for unit testing.
|
|
151
152
|
*/
|
|
152
153
|
export declare function remoteHostsToDial(hosts: string[] | undefined, self: string): string[] | undefined;
|
|
154
|
+
/**
|
|
155
|
+
* The fleet-wide live-session set behind every `--active` surface. Local sessions
|
|
156
|
+
* come from `getActiveSessions()` and (unless `--local`) the registered online
|
|
157
|
+
* devices from `ag devices` are folded in over SSH. An explicit `--host`/`--device`
|
|
158
|
+
* list SCOPES the sweep to exactly those machines — the local machine is included
|
|
159
|
+
* only when it is itself named — so `--host` is a filter, not an addition.
|
|
160
|
+
*
|
|
161
|
+
* This is the single gather: the static renderer AND the interactive browser both
|
|
162
|
+
* call it, so the browser can never disagree with `--active --json` about which
|
|
163
|
+
* sessions are live (it used to call the local-only `getActiveSessions()` directly
|
|
164
|
+
* and silently hid every remote session).
|
|
165
|
+
*/
|
|
166
|
+
export declare function gatherActiveSessions(opts?: {
|
|
167
|
+
local?: boolean;
|
|
168
|
+
hosts?: string[];
|
|
169
|
+
}): Promise<{
|
|
170
|
+
sessions: ActiveSession[];
|
|
171
|
+
remoteDeviceCount: number;
|
|
172
|
+
}>;
|
|
153
173
|
/**
|
|
154
174
|
* Group key for the overview: prefer the indexed project name; else fold the cwd
|
|
155
175
|
* to its repo — a worktree (`.../<repo>/.agents/worktrees/<slug>`) folds to the
|
|
@@ -203,6 +223,13 @@ export interface PickerColumns {
|
|
|
203
223
|
machineWidth?: number;
|
|
204
224
|
/** Render the ticket/PR column (only when at least one row carries a ref). */
|
|
205
225
|
showTicket?: boolean;
|
|
226
|
+
/**
|
|
227
|
+
* Render the host-program column — which terminal/editor the session is running
|
|
228
|
+
* in (`ghostty`, `codium`, `tmux→ghostty`, …). Live-only: it comes from the
|
|
229
|
+
* active-session scan, so it is set by the running-filtered browser and stays
|
|
230
|
+
* off for a plain transcript listing, where no row has a host.
|
|
231
|
+
*/
|
|
232
|
+
showHost?: boolean;
|
|
206
233
|
/**
|
|
207
234
|
* Cells the picker prepends before each row: 2 for the single-select cursor
|
|
208
235
|
* ('> '), 6 for the multi-select cursor + checkbox ('> [x] '). Reserved from
|
|
@@ -223,13 +250,30 @@ export declare function machineLabeler(machines: string[]): (m: string) => strin
|
|
|
223
250
|
* than one box, the ticket column only when some row carries a PR/ticket ref.
|
|
224
251
|
*/
|
|
225
252
|
export declare function pickerColumnsFor(sessions: SessionMeta[]): PickerColumns;
|
|
226
|
-
|
|
253
|
+
/**
|
|
254
|
+
* Which program a live session is running in, for the picker's host column: the
|
|
255
|
+
* immediate host app (`codium`, `ghostty`, `tmux`, `iterm`, …) and — when a tmux
|
|
256
|
+
* session is currently being watched through a different app — the app it is
|
|
257
|
+
* viewed in, as `tmux→ghostty`. A tmux session with no attached client stays a
|
|
258
|
+
* bare `tmux`, which is exactly "running detached". Empty when the session has no
|
|
259
|
+
* resolvable host (cloud rows, an unreadable process env).
|
|
260
|
+
*/
|
|
261
|
+
export declare function liveHostLabel(a: ActiveSession | undefined): string;
|
|
262
|
+
export declare function formatPickerLabel(s: SessionMeta, query: string, cols?: PickerColumns, ssh?: SshOriginTag, host?: string): string;
|
|
227
263
|
/**
|
|
228
264
|
* Pick a hint to show above the picker. Deterministic (keys off the pool size)
|
|
229
265
|
* so it stays fixed across the picker's re-renders within a single run.
|
|
230
266
|
*/
|
|
231
267
|
export declare function formatPickerTip(sessions: SessionMeta[]): string;
|
|
232
268
|
export declare function pickSessionInteractive(sessions: SessionMeta[], message?: string, initialSearch?: string, hiddenCount?: number, enterHint?: string): Promise<PickedSession | null>;
|
|
269
|
+
/**
|
|
270
|
+
* Row-id prefix for a live session whose agent has not reported a session id yet
|
|
271
|
+
* (a booting harness, a queued teammate). The browser lists these so nothing live
|
|
272
|
+
* is hidden, but they address no transcript — {@link isIdlessLiveRow} is what the
|
|
273
|
+
* pick handler checks before trying to read or resume one.
|
|
274
|
+
*/
|
|
275
|
+
export declare const LIVE_ROW_PREFIX = "live:";
|
|
276
|
+
export declare function isIdlessLiveRow(s: SessionMeta): boolean;
|
|
233
277
|
export declare function handlePickedSession(picked: PickedSession): Promise<void>;
|
|
234
278
|
/**
|
|
235
279
|
* Resume a session in the current terminal — a foreground takeover of this
|
|
@@ -285,6 +329,56 @@ export interface SessionQueryResolution {
|
|
|
285
329
|
export declare function resolveSessionQuery(pool: SessionMeta[], query: string): SessionQueryResolution;
|
|
286
330
|
/** Filter and rank sessions by a multi-term search query across metadata and content. */
|
|
287
331
|
export declare function filterSessionsByQuery(sessions: SessionMeta[], query: string | undefined): SessionMeta[];
|
|
332
|
+
/**
|
|
333
|
+
* Whether a missed local id lookup should widen to a cross-machine sweep.
|
|
334
|
+
*
|
|
335
|
+
* Gate (all must hold):
|
|
336
|
+
* - the query is id-shaped (`looksLikeSessionId`) — only an identifier resolves
|
|
337
|
+
* across the fleet; a search phrase never does.
|
|
338
|
+
* - not `--local` — the caller opted out of cross-machine lookup (deterministic
|
|
339
|
+
* local behavior for scripts, RUSH-2024 acceptance: "--local still restricts").
|
|
340
|
+
* - `AGENTS_SESSIONS_LOCAL` is unset — we are not ourselves a peer answering a
|
|
341
|
+
* parent's sweep, so a locate can never recurse (RUSH-2024: avoid double-fan-out).
|
|
342
|
+
*
|
|
343
|
+
* Pure + exported so the gate is unit-tested without driving discovery / SSH.
|
|
344
|
+
*/
|
|
345
|
+
export declare function shouldFanOutForId(query: string, local: boolean | undefined): boolean;
|
|
346
|
+
/** Injectable SSH/peer boundary so the fleet-resolve logic is unit-testable
|
|
347
|
+
* without a live tailnet. Production wires these to the real remote-list infra. */
|
|
348
|
+
export interface FleetResolveDeps {
|
|
349
|
+
gatherRemoteList: typeof gatherRemoteList;
|
|
350
|
+
runOnPeer: typeof runOnPeer;
|
|
351
|
+
}
|
|
352
|
+
/** One distinct machine that reported the id, plus its winning row. */
|
|
353
|
+
interface FleetHit {
|
|
354
|
+
machine: string;
|
|
355
|
+
session: SessionMeta;
|
|
356
|
+
}
|
|
357
|
+
/** Group a fleet sweep's rows to the DISTINCT machines that hold the id. Each
|
|
358
|
+
* peer answered `sessions <id> --json --local`, which (post-fix) id-resolves and
|
|
359
|
+
* so returns the matching row(s); a peer with a synced MIRROR of the same id can
|
|
360
|
+
* emit more than one row, so we keep the first per machine. Rows the peer somehow
|
|
361
|
+
* returned that do NOT match the id (defensive against version skew) are dropped
|
|
362
|
+
* so a stray content hit can never masquerade as an exact resolution. */
|
|
363
|
+
export declare function fleetHitsById(rows: SessionMeta[], id: string): FleetHit[];
|
|
364
|
+
/**
|
|
365
|
+
* Locate a full session id across the online fleet and render it from the machine
|
|
366
|
+
* that holds it. The local disk already missed; this fans `sessions <id> --json
|
|
367
|
+
* --all` out to every registered online peer (or the explicit `hosts` set),
|
|
368
|
+
* groups the rows to distinct machines, then:
|
|
369
|
+
*
|
|
370
|
+
* - exactly one machine → delegate rendering to that peer via `runOnPeer`
|
|
371
|
+
* (its transcript and agent binary live there — a local `--host` hop would
|
|
372
|
+
* re-discover locally and dead-end), returning `'rendered'`.
|
|
373
|
+
* - more than one machine → print the conflict with machine labels so the user
|
|
374
|
+
* can disambiguate with `--device <host>`, returning `'conflict'`.
|
|
375
|
+
* - none → `'not-found'`, letting the caller print the local
|
|
376
|
+
* "no session on this machine" message.
|
|
377
|
+
*
|
|
378
|
+
* No fuzzy/content fallback: the sweep forwards a UUID, each peer id-resolves it,
|
|
379
|
+
* and `fleetHitsById` drops anything that isn't an exact id match.
|
|
380
|
+
*/
|
|
381
|
+
export declare function resolveSessionAcrossFleet(id: string, mode: ViewMode, hosts?: string[], deps?: FleetResolveDeps): Promise<'rendered' | 'conflict' | 'not-found'>;
|
|
288
382
|
/** Register the `agents sessions` command with all its options and help text. */
|
|
289
383
|
export declare function registerSessionsCommands(program: Command): void;
|
|
290
384
|
export {};
|