@phnx-labs/agents-cli 1.20.76 → 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 +264 -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 +87 -19
- package/dist/commands/repo.js +8 -0
- package/dist/commands/routines.js +275 -15
- package/dist/commands/secrets.js +65 -17
- package/dist/commands/sessions-browser.d.ts +45 -3
- package/dist/commands/sessions-browser.js +128 -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 +28 -1
- package/dist/commands/sessions-picker.js +222 -15
- package/dist/commands/sessions.d.ts +117 -4
- package/dist/commands/sessions.js +350 -62
- package/dist/commands/ssh.d.ts +13 -0
- package/dist/commands/ssh.js +48 -3
- package/dist/index.js +5 -6
- package/dist/lib/activity.d.ts +5 -4
- package/dist/lib/activity.js +450 -36
- 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/cloud/session-index.js +2 -2
- package/dist/lib/daemon.d.ts +9 -32
- package/dist/lib/daemon.js +70 -84
- 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/fleet.d.ts +16 -1
- package/dist/lib/devices/fleet.js +10 -2
- package/dist/lib/devices/health-report.d.ts +10 -2
- package/dist/lib/devices/health-report.js +32 -1
- package/dist/lib/events.d.ts +1 -1
- package/dist/lib/exec.d.ts +16 -0
- package/dist/lib/exec.js +27 -4
- package/dist/lib/feed.d.ts +7 -1
- package/dist/lib/feed.js +96 -6
- package/dist/lib/git.d.ts +13 -0
- package/dist/lib/git.js +102 -4
- package/dist/lib/heal.d.ts +7 -4
- package/dist/lib/heal.js +10 -22
- package/dist/lib/hosts/dispatch.d.ts +9 -0
- package/dist/lib/hosts/dispatch.js +24 -4
- package/dist/lib/hosts/passthrough.js +7 -2
- package/dist/lib/hosts/remote-cmd.d.ts +11 -0
- package/dist/lib/hosts/remote-cmd.js +33 -8
- package/dist/lib/hosts/remote-session-id.d.ts +45 -0
- package/dist/lib/hosts/remote-session-id.js +84 -0
- package/dist/lib/hosts/run-target.d.ts +4 -0
- package/dist/lib/hosts/run-target.js +5 -1
- package/dist/lib/hosts/session-index.js +3 -3
- 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/menubar/install-menubar.d.ts +9 -0
- package/dist/lib/menubar/install-menubar.js +14 -0
- package/dist/lib/menubar/notify-desktop.d.ts +44 -0
- package/dist/lib/menubar/notify-desktop.js +78 -0
- package/dist/lib/overdue.d.ts +4 -5
- package/dist/lib/overdue.js +8 -41
- package/dist/lib/routine-notify.d.ts +76 -0
- package/dist/lib/routine-notify.js +190 -0
- package/dist/lib/routines-placement.d.ts +38 -0
- package/dist/lib/routines-placement.js +79 -0
- package/dist/lib/routines-project.d.ts +97 -0
- package/dist/lib/routines-project.js +349 -0
- package/dist/lib/routines.d.ts +68 -0
- package/dist/lib/routines.js +74 -7
- package/dist/lib/runner.d.ts +12 -2
- package/dist/lib/runner.js +156 -28
- package/dist/lib/sandbox.js +0 -6
- 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 +11 -4
- package/dist/lib/secrets/agent.js +46 -24
- package/dist/lib/secrets/bundles.d.ts +21 -7
- package/dist/lib/secrets/bundles.js +82 -38
- package/dist/lib/secrets/index.d.ts +11 -2
- package/dist/lib/secrets/index.js +21 -9
- 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 +16 -2
- package/dist/lib/secrets/session-store.js +118 -15
- package/dist/lib/secrets/unlock-hints.d.ts +27 -0
- package/dist/lib/secrets/unlock-hints.js +36 -0
- package/dist/lib/session/active.d.ts +17 -0
- package/dist/lib/session/active.js +105 -7
- package/dist/lib/session/db.d.ts +43 -0
- package/dist/lib/session/db.js +191 -6
- package/dist/lib/session/digest.js +126 -21
- package/dist/lib/session/discover.d.ts +28 -3
- package/dist/lib/session/discover.js +110 -14
- package/dist/lib/session/origin-machine.d.ts +18 -0
- package/dist/lib/session/origin-machine.js +34 -0
- package/dist/lib/session/parse.js +11 -2
- package/dist/lib/session/prompt.d.ts +7 -0
- package/dist/lib/session/prompt.js +37 -0
- package/dist/lib/session/provenance.d.ts +7 -0
- package/dist/lib/session/render.js +18 -16
- package/dist/lib/session/state.d.ts +5 -0
- package/dist/lib/session/state.js +94 -12
- package/dist/lib/session/sync/config.js +2 -2
- package/dist/lib/session/types.d.ts +25 -1
- package/dist/lib/session/types.js +8 -0
- package/dist/lib/smart-launch.d.ts +86 -0
- package/dist/lib/smart-launch.js +172 -0
- package/dist/lib/state.d.ts +7 -3
- package/dist/lib/state.js +20 -5
- package/dist/lib/types.d.ts +10 -0
- package/package.json +1 -1
|
@@ -6,12 +6,14 @@
|
|
|
6
6
|
* response) and delegates to the generic `itemPicker` for the interactive UI.
|
|
7
7
|
*/
|
|
8
8
|
import fs from 'node:fs';
|
|
9
|
+
import path from 'node:path';
|
|
9
10
|
import chalk from 'chalk';
|
|
10
11
|
import { truncate, humanDuration } from '../lib/format.js';
|
|
11
12
|
import { parseSession, sanitizeForTerminal } from '../lib/session/parse.js';
|
|
12
13
|
import { cleanSessionPrompt, extractSessionTopic } from '../lib/session/prompt.js';
|
|
13
14
|
import { linkPath, linkUrl, relativeToCwd } from '../lib/session/render.js';
|
|
14
15
|
import { linearIssueUrl } from '../lib/session/linear.js';
|
|
16
|
+
import { extractTodoProgress, WORKTREE_RE } from '../lib/session/state.js';
|
|
15
17
|
import { renderMarkdown } from '../lib/markdown.js';
|
|
16
18
|
import { itemPicker } from '../lib/picker.js';
|
|
17
19
|
import { classifyFileChanges, changeCounts, toolHistogram, detectTestResult } from '../lib/session/digest.js';
|
|
@@ -23,6 +25,32 @@ import { classifyFileChanges, changeCounts, toolHistogram, detectTestResult } fr
|
|
|
23
25
|
function remoteMachineOf(session) {
|
|
24
26
|
return session._remote ? session.machine : undefined;
|
|
25
27
|
}
|
|
28
|
+
/**
|
|
29
|
+
* Compact checklist tally for list rows and previews (RUSH-2045).
|
|
30
|
+
* Example: `✓6/8 · A5 wiring runner`. Empty string when there is no list.
|
|
31
|
+
* Consumes `SessionMeta.todos` / `ActiveSession.todos` as populated by the
|
|
32
|
+
* state engine — does not re-parse transcripts.
|
|
33
|
+
*/
|
|
34
|
+
export function formatTodoCompact(todos) {
|
|
35
|
+
if (!todos || !Number.isFinite(todos.total) || todos.total < 1)
|
|
36
|
+
return '';
|
|
37
|
+
const done = Number.isFinite(todos.done) ? Math.max(0, todos.done) : 0;
|
|
38
|
+
const tally = `✓${done}/${todos.total}`;
|
|
39
|
+
const step = todos.activeForm?.replace(/\s+/g, ' ').trim();
|
|
40
|
+
return step ? `${tally} · ${step}` : tally;
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* Best-effort GitHub repo URL from a checkout path shaped like
|
|
44
|
+
* `…/github.com/<owner>/<repo>/…`. Used to make the project name clickable
|
|
45
|
+
* when no Linear project URL is available.
|
|
46
|
+
*/
|
|
47
|
+
export function githubRepoUrlFromCwd(cwd) {
|
|
48
|
+
if (!cwd)
|
|
49
|
+
return undefined;
|
|
50
|
+
const norm = cwd.replace(/\\/g, '/');
|
|
51
|
+
const m = norm.match(/\/github\.com\/([^/]+\/[^/]+)/);
|
|
52
|
+
return m ? `https://github.com/${m[1]}` : undefined;
|
|
53
|
+
}
|
|
26
54
|
/**
|
|
27
55
|
* SessionMeta originates in discover.ts (gitBranch, cwd, label, etc. read from
|
|
28
56
|
* untrusted session files). parseSession sanitizes event payloads at its
|
|
@@ -31,6 +59,17 @@ function remoteMachineOf(session) {
|
|
|
31
59
|
*/
|
|
32
60
|
function sanitizeMeta(s) {
|
|
33
61
|
const clean = (v) => (v == null ? v : sanitizeForTerminal(v));
|
|
62
|
+
const todos = s.todos
|
|
63
|
+
? {
|
|
64
|
+
...s.todos,
|
|
65
|
+
activeForm: clean(s.todos.activeForm),
|
|
66
|
+
items: s.todos.items.map((it) => ({
|
|
67
|
+
...it,
|
|
68
|
+
content: sanitizeForTerminal(it.content),
|
|
69
|
+
activeForm: clean(it.activeForm),
|
|
70
|
+
})),
|
|
71
|
+
}
|
|
72
|
+
: s.todos;
|
|
34
73
|
return {
|
|
35
74
|
...s,
|
|
36
75
|
id: sanitizeForTerminal(s.id),
|
|
@@ -45,6 +84,7 @@ function sanitizeMeta(s) {
|
|
|
45
84
|
label: clean(s.label),
|
|
46
85
|
ticketId: clean(s.ticketId),
|
|
47
86
|
prUrl: clean(s.prUrl),
|
|
87
|
+
todos,
|
|
48
88
|
};
|
|
49
89
|
}
|
|
50
90
|
const previewCache = new Map();
|
|
@@ -62,7 +102,8 @@ export function buildPreview(session) {
|
|
|
62
102
|
if (remote) {
|
|
63
103
|
const note = ' ' + chalk.gray(`on `) + chalk.bold.white(remote)
|
|
64
104
|
+ chalk.gray(` — enter to resume there, or space then enter to read it over SSH`);
|
|
65
|
-
const
|
|
105
|
+
const metaBody = formatMetaOnlyBody(safe);
|
|
106
|
+
const output = [formatHeader(safe, []), '', note, metaBody].filter(Boolean).join('\n');
|
|
66
107
|
previewCache.set(cacheKey, output);
|
|
67
108
|
return output;
|
|
68
109
|
}
|
|
@@ -70,7 +111,8 @@ export function buildPreview(session) {
|
|
|
70
111
|
// entry (e.g. `sessions go`). Show the header + a clean note, not a parse error.
|
|
71
112
|
if (!session.filePath || !fs.existsSync(session.filePath)) {
|
|
72
113
|
const note = ' ' + chalk.gray('Live session — full transcript not indexed here.');
|
|
73
|
-
const
|
|
114
|
+
const metaBody = formatMetaOnlyBody(safe);
|
|
115
|
+
const output = [formatHeader(safe, []), '', note, metaBody].filter(Boolean).join('\n');
|
|
74
116
|
previewCache.set(cacheKey, output);
|
|
75
117
|
return output;
|
|
76
118
|
}
|
|
@@ -99,19 +141,27 @@ function formatHeader(session, events) {
|
|
|
99
141
|
const { startedAgo, duration } = extractTiming(events);
|
|
100
142
|
const totalMessages = session.messageCount ?? countMessages(events);
|
|
101
143
|
const totalTokens = session.tokenCount;
|
|
102
|
-
// Line 1: Agent v version · model · account
|
|
144
|
+
// Line 1: Agent v version · shortId · model · account
|
|
103
145
|
const line1 = [];
|
|
104
146
|
line1.push(chalk.gray(`${displayAgent(session.agent)}${session.version ? ` v${session.version}` : ''}`));
|
|
147
|
+
if (session.shortId)
|
|
148
|
+
line1.push(chalk.dim(session.shortId));
|
|
105
149
|
if (model)
|
|
106
150
|
line1.push(chalk.bold.white(model));
|
|
107
151
|
if (session.account)
|
|
108
152
|
line1.push(chalk.gray(session.account));
|
|
109
|
-
// Line 2: cwd · branch · started X ago · lasted Y
|
|
153
|
+
// Line 2: cwd · project · branch · started X ago · lasted Y
|
|
154
|
+
// Project is clickable: Linear issue URL is for tickets (line 4); for the
|
|
155
|
+
// project name prefer a GitHub repo URL derived from the checkout path.
|
|
110
156
|
const line2 = [];
|
|
111
157
|
if (session.cwd) {
|
|
112
158
|
const label = relativeToCwd(session.cwd);
|
|
113
159
|
line2.push(chalk.bold.white(linkPath(session.cwd, label)));
|
|
114
160
|
}
|
|
161
|
+
if (session.project) {
|
|
162
|
+
const repoUrl = githubRepoUrlFromCwd(session.cwd);
|
|
163
|
+
line2.push(chalk.cyan(repoUrl ? linkUrl(repoUrl, session.project) : session.project));
|
|
164
|
+
}
|
|
115
165
|
if (session.gitBranch)
|
|
116
166
|
line2.push(chalk.cyan(session.gitBranch));
|
|
117
167
|
if (startedAgo)
|
|
@@ -128,7 +178,10 @@ function formatHeader(session, events) {
|
|
|
128
178
|
}
|
|
129
179
|
if (session.label)
|
|
130
180
|
line3.push(chalk.white(session.label));
|
|
131
|
-
|
|
181
|
+
if (session.filePath)
|
|
182
|
+
line3.push(chalk.gray(linkPath(session.filePath, session.id)));
|
|
183
|
+
else
|
|
184
|
+
line3.push(chalk.gray(session.id));
|
|
132
185
|
// Line 4: ticket + PR — clickable when a URL is resolvable (OSC 8 hyperlink),
|
|
133
186
|
// plain text otherwise. Only rendered when the session carries either.
|
|
134
187
|
const line4 = [];
|
|
@@ -147,6 +200,24 @@ function formatHeader(session, events) {
|
|
|
147
200
|
...(line4.length ? [line4.join(DOT)] : []),
|
|
148
201
|
].join('\n');
|
|
149
202
|
}
|
|
203
|
+
/**
|
|
204
|
+
* Body lines available from SessionMeta alone (no transcript parse) — used for
|
|
205
|
+
* remote / unindexed sessions so checklist progress still surfaces when the
|
|
206
|
+
* parser teammate (or a prior scan) has populated `session.todos`.
|
|
207
|
+
*/
|
|
208
|
+
function formatMetaOnlyBody(session) {
|
|
209
|
+
const lines = [];
|
|
210
|
+
if (session.topic) {
|
|
211
|
+
lines.push(chalk.cyan('Prompt: ') + chalk.white(truncate(session.topic.trim(), (process.stdout.columns || 80) - 12)));
|
|
212
|
+
}
|
|
213
|
+
const compact = formatTodoCompact(session.todos);
|
|
214
|
+
if (compact) {
|
|
215
|
+
lines.push(chalk.cyan('Todos: ') + chalk.white(compact));
|
|
216
|
+
}
|
|
217
|
+
if (lines.length === 0)
|
|
218
|
+
return '';
|
|
219
|
+
return lines.map(l => ' ' + l).join('\n');
|
|
220
|
+
}
|
|
150
221
|
function extractModel(events) {
|
|
151
222
|
for (let i = events.length - 1; i >= 0; i--) {
|
|
152
223
|
const m = events[i].model;
|
|
@@ -204,6 +275,7 @@ function stripTags(text) {
|
|
|
204
275
|
const LAST_RESPONSE_MAX_LINES = 15;
|
|
205
276
|
const LAST_RESPONSE_MAX_LINES_WITH_TODOS = 8;
|
|
206
277
|
const TODOS_MAX_ITEMS = 5;
|
|
278
|
+
const DIRS_TOUCHED_MAX = 5;
|
|
207
279
|
function formatCompactPreview(events, session) {
|
|
208
280
|
let firstUser = '';
|
|
209
281
|
let lastAssistant = '';
|
|
@@ -211,7 +283,8 @@ function formatCompactPreview(events, session) {
|
|
|
211
283
|
const toolCounts = {};
|
|
212
284
|
let toolCalls = 0;
|
|
213
285
|
let planFile = '';
|
|
214
|
-
|
|
286
|
+
/** Latest checklist from the transcript (Claude TodoWrite / Codex update_plan). */
|
|
287
|
+
let latestTodos;
|
|
215
288
|
for (const event of events) {
|
|
216
289
|
if (event.type === 'message') {
|
|
217
290
|
if (event.role === 'user' && !firstUser && event.content) {
|
|
@@ -232,25 +305,53 @@ function formatCompactPreview(events, session) {
|
|
|
232
305
|
if (!planFile && p && /\/plans\/[^/]+\.md$/.test(p)) {
|
|
233
306
|
planFile = p;
|
|
234
307
|
}
|
|
235
|
-
|
|
236
|
-
|
|
308
|
+
// Claude TodoWrite (`todos`) and Codex update_plan (`plan`) — same source as
|
|
309
|
+
// extractTodoProgress in the state engine. Prefer the most recent write.
|
|
310
|
+
if (tool === 'TodoWrite' || tool === 'update_plan') {
|
|
311
|
+
const progress = extractTodoProgress(event.args);
|
|
312
|
+
if (progress)
|
|
313
|
+
latestTodos = progress;
|
|
237
314
|
}
|
|
238
315
|
if (tool)
|
|
239
316
|
toolCounts[tool] = (toolCounts[tool] ?? 0) + 1;
|
|
240
317
|
toolCalls++;
|
|
241
318
|
}
|
|
242
319
|
}
|
|
320
|
+
// Prefer the transcript-derived list when we just re-parsed the file (freshest
|
|
321
|
+
// checklist write); fall back to SessionMeta.todos for rows where the scan/
|
|
322
|
+
// fan-out attached progress but the event stream has no TodoWrite yet.
|
|
323
|
+
const todos = latestTodos ?? session.todos;
|
|
243
324
|
// Digest signals folded into the preview: change lifecycle, tool mix, tests.
|
|
244
325
|
const changes = classifyFileChanges(events);
|
|
245
326
|
const chg = changeCounts(changes);
|
|
246
327
|
const lines = [];
|
|
247
328
|
const termWidth = process.stdout.columns || 80;
|
|
329
|
+
// Originating user prompt (first non-system user turn).
|
|
248
330
|
if (firstUser) {
|
|
249
331
|
const first = extractSessionTopic(firstUser) || cleanSessionPrompt(firstUser).split('\n').find(l => l.trim()) || '';
|
|
250
332
|
if (first) {
|
|
251
333
|
lines.push(chalk.cyan('Prompt: ') + chalk.white(truncate(first.trim(), termWidth - 12)));
|
|
252
334
|
}
|
|
253
335
|
}
|
|
336
|
+
else if (session.topic) {
|
|
337
|
+
lines.push(chalk.cyan('Prompt: ') + chalk.white(truncate(session.topic.trim(), termWidth - 12)));
|
|
338
|
+
}
|
|
339
|
+
// Compact checklist: ✓done/total · current step (RUSH-2045).
|
|
340
|
+
const compact = formatTodoCompact(todos);
|
|
341
|
+
if (compact) {
|
|
342
|
+
lines.push(chalk.cyan('Todos: ') + chalk.white(compact));
|
|
343
|
+
}
|
|
344
|
+
const todosRendered = todos?.items?.length ? renderTodos(todos.items, termWidth) : [];
|
|
345
|
+
if (todosRendered.length > 0) {
|
|
346
|
+
for (const l of todosRendered)
|
|
347
|
+
lines.push(' ' + l);
|
|
348
|
+
}
|
|
349
|
+
// Recent activity = directories touched (not raw tool calls). Prefer a
|
|
350
|
+
// parser-supplied dirsTouched when present; else derive from event paths.
|
|
351
|
+
const dirs = directoriesTouched(session, events, changes);
|
|
352
|
+
if (dirs.length) {
|
|
353
|
+
lines.push(chalk.cyan('Dirs: ') + chalk.white(dirs.join(chalk.gray(' · '))));
|
|
354
|
+
}
|
|
254
355
|
const activity = [];
|
|
255
356
|
const changed = chg.created + chg.modified + chg.deleted;
|
|
256
357
|
if (changed) {
|
|
@@ -287,14 +388,8 @@ function formatCompactPreview(events, session) {
|
|
|
287
388
|
const basename = planFile.split('/').pop() || planFile;
|
|
288
389
|
lines.push(chalk.cyan('Plan: ') + chalk.white(linkPath(planFile, basename)));
|
|
289
390
|
}
|
|
290
|
-
const todosRendered = latestTodos ? renderTodos(latestTodos, termWidth) : [];
|
|
291
|
-
if (todosRendered.length > 0) {
|
|
292
|
-
lines.push(chalk.cyan('Todos:'));
|
|
293
|
-
for (const l of todosRendered)
|
|
294
|
-
lines.push(' ' + l);
|
|
295
|
-
}
|
|
296
391
|
if (lastAssistant) {
|
|
297
|
-
const maxLines = todosRendered.length > 0 ? LAST_RESPONSE_MAX_LINES_WITH_TODOS : LAST_RESPONSE_MAX_LINES;
|
|
392
|
+
const maxLines = todosRendered.length > 0 || compact ? LAST_RESPONSE_MAX_LINES_WITH_TODOS : LAST_RESPONSE_MAX_LINES;
|
|
298
393
|
const rendered = renderLastResponse(lastAssistant, maxLines);
|
|
299
394
|
if (rendered.length > 0) {
|
|
300
395
|
lines.push('');
|
|
@@ -308,6 +403,118 @@ function formatCompactPreview(events, session) {
|
|
|
308
403
|
}
|
|
309
404
|
return lines.map(l => ' ' + l).join('\n');
|
|
310
405
|
}
|
|
406
|
+
/**
|
|
407
|
+
* Unique directories the session touched, compact and human-readable.
|
|
408
|
+
* Prefer `session.dirsTouched` when the parser teammate has populated it;
|
|
409
|
+
* otherwise derive from file-change + tool paths already available here.
|
|
410
|
+
*/
|
|
411
|
+
export function directoriesTouched(session, events, changes) {
|
|
412
|
+
const fromMeta = session.dirsTouched;
|
|
413
|
+
if (Array.isArray(fromMeta) && fromMeta.length > 0) {
|
|
414
|
+
return fromMeta
|
|
415
|
+
.map((d) => sanitizeForTerminal(String(d).trim()))
|
|
416
|
+
.filter(Boolean)
|
|
417
|
+
.slice(0, DIRS_TOUCHED_MAX);
|
|
418
|
+
}
|
|
419
|
+
const counts = new Map();
|
|
420
|
+
const bump = (raw) => {
|
|
421
|
+
const dir = relativizeDir(raw, session.cwd);
|
|
422
|
+
if (!dir)
|
|
423
|
+
return;
|
|
424
|
+
counts.set(dir, (counts.get(dir) ?? 0) + 1);
|
|
425
|
+
};
|
|
426
|
+
for (const ch of changes)
|
|
427
|
+
bump(ch.path);
|
|
428
|
+
for (const event of events) {
|
|
429
|
+
if (event.type !== 'tool_use' || event._local)
|
|
430
|
+
continue;
|
|
431
|
+
const p = event.path || event.args?.file_path || event.args?.path || '';
|
|
432
|
+
if (p)
|
|
433
|
+
bump(p);
|
|
434
|
+
}
|
|
435
|
+
return [...counts.entries()]
|
|
436
|
+
.sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]))
|
|
437
|
+
.map(([d]) => d)
|
|
438
|
+
.slice(0, DIRS_TOUCHED_MAX);
|
|
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-(.+)$/;
|
|
456
|
+
/** Relativize a file path to its parent dir, short enough for one preview line. */
|
|
457
|
+
export function relativizeDir(filePath, cwd) {
|
|
458
|
+
const norm = filePath.replace(/\\/g, '/');
|
|
459
|
+
if (!norm || norm.includes('node_modules') || norm.includes('/.git/') || norm.includes('/plans/')) {
|
|
460
|
+
return undefined;
|
|
461
|
+
}
|
|
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.
|
|
494
|
+
if (cwd) {
|
|
495
|
+
const base = cwd.replace(/\\/g, '/').replace(/\/$/, '');
|
|
496
|
+
if (dir === base)
|
|
497
|
+
return '.';
|
|
498
|
+
if (dir.startsWith(base + '/'))
|
|
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]}`;
|
|
507
|
+
}
|
|
508
|
+
// Collapse home prefix.
|
|
509
|
+
const home = (process.env.HOME || '').replace(/\\/g, '/');
|
|
510
|
+
if (home && dir.startsWith(home + '/'))
|
|
511
|
+
dir = '~' + dir.slice(home.length);
|
|
512
|
+
// Drop ultra-deep absolute noise; keep last 3 segments.
|
|
513
|
+
const parts = dir.split('/').filter(Boolean);
|
|
514
|
+
if (parts.length > 3 && dir.startsWith('/'))
|
|
515
|
+
dir = parts.slice(-3).join('/');
|
|
516
|
+
return dir || undefined;
|
|
517
|
+
}
|
|
311
518
|
function renderLastResponse(content, maxLines = LAST_RESPONSE_MAX_LINES) {
|
|
312
519
|
const cleaned = stripTags(content).trim();
|
|
313
520
|
if (!cleaned)
|
|
@@ -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
|
|
@@ -9,6 +10,18 @@ import { type PickedSession } from './sessions-picker.js';
|
|
|
9
10
|
* a captured transcript tail. Collapses runs of whitespace.
|
|
10
11
|
*/
|
|
11
12
|
export declare function cleanPreview(text: string): string;
|
|
13
|
+
/**
|
|
14
|
+
* Identity + checklist + live snippet for an --active / cross-machine row.
|
|
15
|
+
* Surfaces agent-adjacent identity the flat table already has (label, project)
|
|
16
|
+
* with a clickable project when a GitHub URL is resolvable, then the checklist
|
|
17
|
+
* tally and the latest-turn snippet.
|
|
18
|
+
*
|
|
19
|
+
* Free-text fields are cleaned individually; OSC 8 hyperlinks are applied
|
|
20
|
+
* *after* cleaning so `cleanPreview` does not strip the clickable targets
|
|
21
|
+
* (RUSH-2045 review). Ticket is clickable via {@link signalBadges}, not here,
|
|
22
|
+
* so the id is not printed twice.
|
|
23
|
+
*/
|
|
24
|
+
export declare function formatActiveRowDescription(s: ActiveSession): string;
|
|
12
25
|
/**
|
|
13
26
|
* Index live sessions by their full session UUID so a historical `SessionMeta`
|
|
14
27
|
* row (`meta.id`) can be matched to the session that is still running now.
|
|
@@ -138,6 +151,25 @@ export declare function shouldIncludeLocal(hosts: string[] | undefined, self: st
|
|
|
138
151
|
* Exported for unit testing.
|
|
139
152
|
*/
|
|
140
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
|
+
}>;
|
|
141
173
|
/**
|
|
142
174
|
* Group key for the overview: prefer the indexed project name; else fold the cwd
|
|
143
175
|
* to its repo — a worktree (`.../<repo>/.agents/worktrees/<slug>`) folds to the
|
|
@@ -175,6 +207,12 @@ export declare function renderSessionLog(session: SessionMeta, mode?: ViewMode):
|
|
|
175
207
|
*/
|
|
176
208
|
export declare function renderSessionLogJson(session: SessionMeta): Promise<void>;
|
|
177
209
|
/** Column-visibility flags for the picker row, computed once over the whole pool. */
|
|
210
|
+
/** The SSH-launch origin for a picker row, resolved from the live session's
|
|
211
|
+
* provenance (transport 'ssh'). `device` is set when the client IP matched a
|
|
212
|
+
* registered device; absent for an unresolved IP (renders a bare `ssh`). */
|
|
213
|
+
export interface SshOriginTag {
|
|
214
|
+
device?: string;
|
|
215
|
+
}
|
|
178
216
|
export interface PickerColumns {
|
|
179
217
|
/** Render the machine column (only when the pool spans more than one machine). */
|
|
180
218
|
showMachine?: boolean;
|
|
@@ -185,6 +223,13 @@ export interface PickerColumns {
|
|
|
185
223
|
machineWidth?: number;
|
|
186
224
|
/** Render the ticket/PR column (only when at least one row carries a ref). */
|
|
187
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;
|
|
188
233
|
/**
|
|
189
234
|
* Cells the picker prepends before each row: 2 for the single-select cursor
|
|
190
235
|
* ('> '), 6 for the multi-select cursor + checkbox ('> [x] '). Reserved from
|
|
@@ -205,13 +250,30 @@ export declare function machineLabeler(machines: string[]): (m: string) => strin
|
|
|
205
250
|
* than one box, the ticket column only when some row carries a PR/ticket ref.
|
|
206
251
|
*/
|
|
207
252
|
export declare function pickerColumnsFor(sessions: SessionMeta[]): PickerColumns;
|
|
208
|
-
|
|
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;
|
|
209
263
|
/**
|
|
210
264
|
* Pick a hint to show above the picker. Deterministic (keys off the pool size)
|
|
211
265
|
* so it stays fixed across the picker's re-renders within a single run.
|
|
212
266
|
*/
|
|
213
267
|
export declare function formatPickerTip(sessions: SessionMeta[]): string;
|
|
214
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;
|
|
215
277
|
export declare function handlePickedSession(picked: PickedSession): Promise<void>;
|
|
216
278
|
/**
|
|
217
279
|
* Resume a session in the current terminal — a foreground takeover of this
|
|
@@ -259,13 +321,64 @@ export interface SessionQueryResolution {
|
|
|
259
321
|
/**
|
|
260
322
|
* The single entry point for turning a `sessions <query>` argument into rows.
|
|
261
323
|
*
|
|
262
|
-
*
|
|
263
|
-
*
|
|
264
|
-
*
|
|
324
|
+
* An id-shaped query — a complete id OR a hex short-id/prefix (looksLikeSessionId)
|
|
325
|
+
* — resolves by id alone, through the index, and never falls back to content
|
|
326
|
+
* search (a bare id must not surface every transcript that merely mentions it).
|
|
327
|
+
* A genuine search phrase keeps the ranked metadata+content search.
|
|
265
328
|
*/
|
|
266
329
|
export declare function resolveSessionQuery(pool: SessionMeta[], query: string): SessionQueryResolution;
|
|
267
330
|
/** Filter and rank sessions by a multi-term search query across metadata and content. */
|
|
268
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'>;
|
|
269
382
|
/** Register the `agents sessions` command with all its options and help text. */
|
|
270
383
|
export declare function registerSessionsCommands(program: Command): void;
|
|
271
384
|
export {};
|