@phnx-labs/agents-cli 1.20.76 → 1.20.77
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 +162 -0
- package/dist/bin/agents +0 -0
- package/dist/commands/exec.js +58 -16
- package/dist/commands/routines.js +271 -13
- package/dist/commands/secrets.js +59 -17
- package/dist/commands/sessions-browser.js +11 -1
- package/dist/commands/sessions-picker.d.ts +14 -1
- package/dist/commands/sessions-picker.js +168 -15
- package/dist/commands/sessions.d.ts +23 -4
- package/dist/commands/sessions.js +128 -38
- package/dist/commands/ssh.d.ts +13 -0
- package/dist/commands/ssh.js +39 -2
- package/dist/index.js +2 -1
- package/dist/lib/activity.d.ts +5 -4
- package/dist/lib/activity.js +450 -36
- package/dist/lib/cloud/session-index.js +2 -2
- package/dist/lib/daemon.d.ts +10 -1
- package/dist/lib/daemon.js +99 -12
- package/dist/lib/devices/fleet.d.ts +16 -1
- package/dist/lib/devices/fleet.js +10 -2
- 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/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 +31 -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/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 +150 -25
- package/dist/lib/sandbox.js +10 -0
- package/dist/lib/secrets/account-token.d.ts +20 -0
- package/dist/lib/secrets/account-token.js +64 -0
- package/dist/lib/secrets/agent.d.ts +9 -4
- package/dist/lib/secrets/agent.js +30 -20
- package/dist/lib/secrets/bundles.d.ts +20 -6
- package/dist/lib/secrets/bundles.js +70 -34
- package/dist/lib/secrets/index.d.ts +11 -2
- package/dist/lib/secrets/index.js +21 -9
- package/dist/lib/secrets/session-store.d.ts +6 -2
- package/dist/lib/secrets/session-store.js +65 -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 +10 -0
- package/dist/lib/session/active.js +67 -4
- package/dist/lib/session/db.d.ts +6 -0
- package/dist/lib/session/db.js +83 -6
- package/dist/lib/session/discover.d.ts +13 -1
- package/dist/lib/session/discover.js +91 -3
- 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 +4 -0
- package/dist/lib/session/state.js +93 -11
- package/dist/lib/session/types.d.ts +25 -1
- package/dist/lib/session/types.js +8 -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
|
@@ -32,14 +32,15 @@ import { filterTeamSessions } from '../lib/session/team-filter.js';
|
|
|
32
32
|
import { parseSession } from '../lib/session/parse.js';
|
|
33
33
|
import { runRemoteSessions, buildForwardedArgs, ensureWholeIndex } from '../lib/session/remote.js';
|
|
34
34
|
import { formatRelativeTime } from '../lib/session/relative-time.js';
|
|
35
|
-
import { renderConversationMarkdown, renderSummary, renderSummaryHeader, computeSummaryStats, renderJson, filterEvents, parseRoleList } from '../lib/session/render.js';
|
|
35
|
+
import { renderConversationMarkdown, renderSummary, renderSummaryHeader, computeSummaryStats, renderJson, filterEvents, parseRoleList, linkUrl } from '../lib/session/render.js';
|
|
36
|
+
import { linearIssueUrl } from '../lib/session/linear.js';
|
|
36
37
|
import { renderMarkdown } from '../lib/markdown.js';
|
|
37
38
|
import { AGENTS, colorAgent, resolveAgentName } from '../lib/agents.js';
|
|
38
39
|
import { getShimsDir } from '../lib/state.js';
|
|
39
40
|
import { fuzzyMatch, FUZZY_PRESETS } from '../lib/fuzzy.js';
|
|
40
41
|
import { resolveVersionAliasLoose } from '../lib/versions.js';
|
|
41
42
|
import { isInteractiveTerminal, isPromptCancelled } from './utils.js';
|
|
42
|
-
import { sessionPicker, buildPreview } from './sessions-picker.js';
|
|
43
|
+
import { sessionPicker, buildPreview, formatTodoCompact, githubRepoUrlFromCwd, } from './sessions-picker.js';
|
|
43
44
|
import { setHelpSections } from '../lib/help.js';
|
|
44
45
|
import { registerSessionsTailCommand } from './sessions-tail.js';
|
|
45
46
|
import { registerSessionsSyncCommand } from './sessions-sync.js';
|
|
@@ -226,15 +227,24 @@ export function cleanPreview(text) {
|
|
|
226
227
|
.trim();
|
|
227
228
|
}
|
|
228
229
|
/**
|
|
229
|
-
* Build the live description for an active session:
|
|
230
|
-
* preview (the latest turn),
|
|
230
|
+
* Build the live description for an active session: checklist progress (when
|
|
231
|
+
* present) plus the state engine's preview (the latest turn), a user label, or
|
|
232
|
+
* the first-prompt topic. Used by both the flat listing's `doing` cell and as
|
|
233
|
+
* the snippet half of the --active row (identity is layered on in printActiveRow).
|
|
234
|
+
*
|
|
235
|
+
* Covers every ActiveSession context: terminal (interactive), headless, teams,
|
|
236
|
+
* cloud, and sub-agent rows that share the same ActiveSession.todos field.
|
|
231
237
|
*/
|
|
232
238
|
function buildSessionDescription(s) {
|
|
239
|
+
const todo = formatTodoCompact(s.todos);
|
|
233
240
|
if (s.context === 'cloud') {
|
|
234
|
-
|
|
241
|
+
const base = s.preview || `${s.cloudProvider ?? ''}${s.cloudTaskId ? ` · ${s.cloudTaskId.slice(0, 12)}` : ''}`;
|
|
242
|
+
return cleanPreview([todo, base].filter(Boolean).join(' · '));
|
|
235
243
|
}
|
|
236
244
|
if (s.context === 'teams') {
|
|
237
245
|
const parts = [s.teamName];
|
|
246
|
+
if (todo)
|
|
247
|
+
parts.push(todo);
|
|
238
248
|
if (s.preview)
|
|
239
249
|
parts.push(s.preview);
|
|
240
250
|
else if (s.label)
|
|
@@ -243,8 +253,53 @@ function buildSessionDescription(s) {
|
|
|
243
253
|
parts.push(s.topic);
|
|
244
254
|
return cleanPreview(parts.filter(Boolean).join(' · '));
|
|
245
255
|
}
|
|
246
|
-
// Terminal or
|
|
247
|
-
|
|
256
|
+
// Terminal, headless, or sub-agent: todos + live preview, then label, then topic.
|
|
257
|
+
const base = s.preview || s.label || s.topic || '';
|
|
258
|
+
return cleanPreview([todo, base].filter(Boolean).join(' · '));
|
|
259
|
+
}
|
|
260
|
+
/**
|
|
261
|
+
* Identity + checklist + live snippet for an --active / cross-machine row.
|
|
262
|
+
* Surfaces agent-adjacent identity the flat table already has (label, project)
|
|
263
|
+
* with a clickable project when a GitHub URL is resolvable, then the checklist
|
|
264
|
+
* tally and the latest-turn snippet.
|
|
265
|
+
*
|
|
266
|
+
* Free-text fields are cleaned individually; OSC 8 hyperlinks are applied
|
|
267
|
+
* *after* cleaning so `cleanPreview` does not strip the clickable targets
|
|
268
|
+
* (RUSH-2045 review). Ticket is clickable via {@link signalBadges}, not here,
|
|
269
|
+
* so the id is not printed twice.
|
|
270
|
+
*/
|
|
271
|
+
export function formatActiveRowDescription(s) {
|
|
272
|
+
const parts = [];
|
|
273
|
+
const pushText = (t) => {
|
|
274
|
+
const c = t ? cleanPreview(t) : '';
|
|
275
|
+
if (c)
|
|
276
|
+
parts.push(c);
|
|
277
|
+
};
|
|
278
|
+
if (s.context === 'teams' && s.teamName)
|
|
279
|
+
pushText(s.teamName);
|
|
280
|
+
if (s.label)
|
|
281
|
+
pushText(s.label);
|
|
282
|
+
// Project = basename(cwd), same derivation as serializeActiveSessionsForJson.
|
|
283
|
+
const project = s.cwd ? path.basename(s.cwd) : '';
|
|
284
|
+
if (project && project !== s.label && project !== s.teamName) {
|
|
285
|
+
const label = cleanPreview(project);
|
|
286
|
+
const repoUrl = githubRepoUrlFromCwd(s.cwd);
|
|
287
|
+
parts.push(repoUrl ? linkUrl(repoUrl, label) : label);
|
|
288
|
+
}
|
|
289
|
+
const todo = formatTodoCompact(s.todos);
|
|
290
|
+
if (todo)
|
|
291
|
+
parts.push(todo);
|
|
292
|
+
// Latest-turn snippet — avoid repeating label/topic when already used as identity.
|
|
293
|
+
if (s.context === 'cloud') {
|
|
294
|
+
pushText(s.preview || `${s.cloudProvider ?? ''}${s.cloudTaskId ? ` · ${s.cloudTaskId.slice(0, 12)}` : ''}`);
|
|
295
|
+
}
|
|
296
|
+
else if (s.preview) {
|
|
297
|
+
pushText(s.preview);
|
|
298
|
+
}
|
|
299
|
+
else if (!s.label && s.topic) {
|
|
300
|
+
pushText(s.topic);
|
|
301
|
+
}
|
|
302
|
+
return parts.filter(Boolean).join(' · ');
|
|
248
303
|
}
|
|
249
304
|
/** Short human word for a session's activity (falls back to the coarse status). */
|
|
250
305
|
function activityLabel(s) {
|
|
@@ -325,10 +380,15 @@ function signalBadges(s) {
|
|
|
325
380
|
parts.push(chalk.yellow('ask'));
|
|
326
381
|
else if (s.awaitingReason === 'permission')
|
|
327
382
|
parts.push(chalk.yellow('perm'));
|
|
328
|
-
if (s.ticket)
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
parts.push(chalk.
|
|
383
|
+
if (s.ticket) {
|
|
384
|
+
// Clickable when Linear workspace is resolvable (same helper as the picker header).
|
|
385
|
+
const url = linearIssueUrl(s.ticket.id);
|
|
386
|
+
parts.push(chalk.cyan(url ? linkUrl(url, s.ticket.id) : s.ticket.id));
|
|
387
|
+
}
|
|
388
|
+
if (s.pr) {
|
|
389
|
+
const label = `PR#${s.pr.number ?? '?'}`;
|
|
390
|
+
parts.push(chalk.blue(s.pr.url ? linkUrl(s.pr.url, label) : label));
|
|
391
|
+
}
|
|
332
392
|
if (s.worktree)
|
|
333
393
|
parts.push(chalk.magenta(`wt:${s.worktree.slug}`));
|
|
334
394
|
return parts.join(' ');
|
|
@@ -343,8 +403,10 @@ function signalBadges(s) {
|
|
|
343
403
|
function locatorBadge(s) {
|
|
344
404
|
const p = s.provenance;
|
|
345
405
|
const parts = [];
|
|
406
|
+
// An ssh-launched session shows where it was launched FROM when the client IP
|
|
407
|
+
// resolves to a registered device (`ssh←zion`); bare `ssh` when it doesn't.
|
|
346
408
|
if (p?.transport === 'ssh')
|
|
347
|
-
parts.push(chalk.red('ssh'));
|
|
409
|
+
parts.push(chalk.red(p.origin ? `ssh←${p.origin.device}` : 'ssh'));
|
|
348
410
|
if (p?.mux?.kind === 'tmux' && (s.tmuxTarget || p.mux.pane)) {
|
|
349
411
|
parts.push(chalk.green(s.tmuxTarget ?? p.mux.pane));
|
|
350
412
|
// For a tmux-hosted session, say which app+tab is looking at it right now
|
|
@@ -373,13 +435,17 @@ function locatorBadge(s) {
|
|
|
373
435
|
* terminal width so the row never wraps.
|
|
374
436
|
*/
|
|
375
437
|
function printActiveRow(s, indent) {
|
|
438
|
+
// shortId (8-char) · agent · host · status · badges · identity+todos+snippet
|
|
376
439
|
const idCol = chalk.dim(padToWidth((s.sessionId?.slice(0, 8)) ?? '-', 9));
|
|
377
440
|
const kindCol = colorAgent(s.kind)(padToWidth(truncateToWidth(s.kind, 8), 9));
|
|
378
441
|
const hostCol = chalk.gray(padToWidth(truncateToWidth(s.host ?? '-', 8), 9));
|
|
379
442
|
const statusCol = statusColor(s.status)(padToWidth(truncateToWidth(activityLabel(s), 8), 9));
|
|
380
443
|
const fork = s.pidCount && s.pidCount > 1 ? chalk.dim(`×${s.pidCount} `) : '';
|
|
381
444
|
const badges = (fork ? fork : '') + [signalBadges(s), locatorBadge(s)].filter(Boolean).join(' ');
|
|
382
|
-
|
|
445
|
+
// Identity (label/project/ticket clickable) + checklist + live snippet.
|
|
446
|
+
// Cross-machine rows use the same path — remote ActiveSession fields arrive
|
|
447
|
+
// via the SSH fan-out already populated (including todos when the peer has them).
|
|
448
|
+
const desc = formatActiveRowDescription(s) || '-';
|
|
383
449
|
// Fill the remaining width with the preview so nothing wraps under tmux/SSH.
|
|
384
450
|
const fixed = stringWidth(indent) + 9 + 9 + 9 + 9 + (badges ? stringWidth(badges) + 1 : 0);
|
|
385
451
|
const room = Math.max(12, terminalWidth() - fixed - 1);
|
|
@@ -1247,9 +1313,12 @@ function flatSessionRow(session, live, showTicket = false, cols = {}) {
|
|
|
1247
1313
|
const tag = originTag(session) || teamTag(session);
|
|
1248
1314
|
const label = session.label;
|
|
1249
1315
|
const { glyph, preview } = liveGlyphAndPreview(live);
|
|
1250
|
-
// A running session's live preview
|
|
1251
|
-
//
|
|
1252
|
-
|
|
1316
|
+
// A running session's live preview (via liveGlyphAndPreview → buildSessionDescription)
|
|
1317
|
+
// already folds in ActiveSession.todos. For resting / not-live rows, surface
|
|
1318
|
+
// SessionMeta.todos when the scan attached it.
|
|
1319
|
+
const restingTodo = !live ? formatTodoCompact(session.todos) : '';
|
|
1320
|
+
const topicBase = tag ? `${tag}${session.topic ?? ''}` : session.topic;
|
|
1321
|
+
const doing = [restingTodo, preview || topicBase].filter(Boolean).join(' · ') || undefined;
|
|
1253
1322
|
const wt = session.worktreeSlug ? chalk.magenta(`wt:${session.worktreeSlug}`) : '';
|
|
1254
1323
|
// The machine column only earns its width when the listing spans more than one
|
|
1255
1324
|
// box (i.e. the cross-machine fan-out folded remotes in) — same rule and
|
|
@@ -1285,7 +1354,11 @@ function treeSessionRow(session, live) {
|
|
|
1285
1354
|
const tag = originTag(session) || teamTag(session);
|
|
1286
1355
|
const label = session.label;
|
|
1287
1356
|
const { glyph, preview } = liveGlyphAndPreview(live);
|
|
1288
|
-
|
|
1357
|
+
// Match flatSessionRow: live preview already folds ActiveSession.todos; resting
|
|
1358
|
+
// rows surface SessionMeta.todos when present (RUSH-2045).
|
|
1359
|
+
const restingTodo = !live ? formatTodoCompact(session.todos) : '';
|
|
1360
|
+
const topicBase = preview || (tag ? `${tag}${session.topic ?? ''}` : session.topic);
|
|
1361
|
+
const topic = [restingTodo, topicBase].filter(Boolean).join(' · ') || '-';
|
|
1289
1362
|
const badges = signalBadges(metaSignals(session));
|
|
1290
1363
|
const badgeW = badges ? stringWidth(badges) + 1 : 0;
|
|
1291
1364
|
const head = label ? `${label} · ${topic}` : topic;
|
|
@@ -1652,10 +1725,18 @@ export function pickerColumnsFor(sessions) {
|
|
|
1652
1725
|
showTicket: sessions.some((s) => ticketLabel(s) !== ''),
|
|
1653
1726
|
};
|
|
1654
1727
|
}
|
|
1655
|
-
export function formatPickerLabel(s, query, cols = {}) {
|
|
1728
|
+
export function formatPickerLabel(s, query, cols = {}, ssh) {
|
|
1656
1729
|
const agentColor = colorAgent(s.agent);
|
|
1657
1730
|
const when = formatRelativeTime(s.lastActivity ?? s.timestamp);
|
|
1658
1731
|
const project = s.project || '-';
|
|
1732
|
+
// SSH-launch origin (live rows only): mirrors the flat listing's `ssh←<device>`
|
|
1733
|
+
// badge. Rendered as its OWN red segment before the topic cell — folding it into
|
|
1734
|
+
// the topic string loses the colour, because renderTopicCell strips ANSI and
|
|
1735
|
+
// re-whitens every slice. Its width is reserved from the topic budget below
|
|
1736
|
+
// (exactly like `wt`), so the fixed-width columns stay aligned.
|
|
1737
|
+
const sshPlain = ssh ? (ssh.device ? `ssh←${ssh.device} ` : 'ssh ') : '';
|
|
1738
|
+
const sshSeg = sshPlain ? chalk.red(sshPlain) : '';
|
|
1739
|
+
const sshW = sshPlain ? stringWidth(sshPlain) : 0;
|
|
1659
1740
|
const tag = originTag(s) || teamTag(s);
|
|
1660
1741
|
const label = s.label;
|
|
1661
1742
|
const topic = tag ? `${tag}${s.topic ?? ''}` : s.topic;
|
|
@@ -1676,12 +1757,13 @@ export function formatPickerLabel(s, query, cols = {}) {
|
|
|
1676
1757
|
const machineColW = cols.showMachine ? machineW : 0;
|
|
1677
1758
|
const ticketW = cols.showTicket ? TICKET_W + 1 : 0;
|
|
1678
1759
|
const wtW = wt ? stringWidth(wt) + 1 : 0;
|
|
1679
|
-
const topicW = Math.max(16, terminalWidth() - gutter - (10 + 9 + 8 + 16) - machineColW - ticketW - wtW - stringWidth(when) - 1);
|
|
1760
|
+
const topicW = Math.max(16, terminalWidth() - gutter - (10 + 9 + 8 + 16) - machineColW - ticketW - wtW - sshW - stringWidth(when) - 1);
|
|
1680
1761
|
return (chalk.white(padRight(s.shortId, 10)) +
|
|
1681
1762
|
agentColor(padRight(truncate(s.agent, 8), 9)) +
|
|
1682
1763
|
chalk.yellow(padRight(truncate(versionStr, 7), 8)) +
|
|
1683
1764
|
machineCell +
|
|
1684
1765
|
chalk.cyan(padRight(truncate(project, 14), 16)) +
|
|
1766
|
+
sshSeg +
|
|
1685
1767
|
renderTopicCell(label, topic, query, topicW, topicW) +
|
|
1686
1768
|
ticketCell +
|
|
1687
1769
|
(wt ? wt + ' ' : '') +
|
|
@@ -2062,9 +2144,10 @@ function formatSearchMessage(options) {
|
|
|
2062
2144
|
/**
|
|
2063
2145
|
* The single entry point for turning a `sessions <query>` argument into rows.
|
|
2064
2146
|
*
|
|
2065
|
-
*
|
|
2066
|
-
*
|
|
2067
|
-
*
|
|
2147
|
+
* An id-shaped query — a complete id OR a hex short-id/prefix (looksLikeSessionId)
|
|
2148
|
+
* — resolves by id alone, through the index, and never falls back to content
|
|
2149
|
+
* search (a bare id must not surface every transcript that merely mentions it).
|
|
2150
|
+
* A genuine search phrase keeps the ranked metadata+content search.
|
|
2068
2151
|
*/
|
|
2069
2152
|
export function resolveSessionQuery(pool, query) {
|
|
2070
2153
|
// Normalize ONCE here. isCompleteSessionId trims but resolveSessionById does
|
|
@@ -2076,12 +2159,16 @@ export function resolveSessionQuery(pool, query) {
|
|
|
2076
2159
|
const byIdMatches = resolveSessionById(pool, normalized);
|
|
2077
2160
|
if (byIdMatches.length > 0)
|
|
2078
2161
|
return { matches: byIdMatches, byId: true, completeId };
|
|
2079
|
-
if (
|
|
2080
|
-
//
|
|
2081
|
-
//
|
|
2082
|
-
//
|
|
2083
|
-
//
|
|
2084
|
-
//
|
|
2162
|
+
if (looksLikeSessionId(normalized)) {
|
|
2163
|
+
// Any id-shaped query — a complete id OR a bare hex short-id/prefix —
|
|
2164
|
+
// resolves by id ONLY, never by content. The pool is a minority of the
|
|
2165
|
+
// index (measured: 2,798 of 7,614 rows) because it re-reads live agent homes
|
|
2166
|
+
// and skips whole classes of indexed session, so a pool miss isn't absence:
|
|
2167
|
+
// ask the index directly, the same authoritative lookup `fork` and `exec`
|
|
2168
|
+
// use. And an id that resolves to nothing must report "no session with that
|
|
2169
|
+
// id" — NOT fall back to fuzzy content search. A short id like "d3470b57"
|
|
2170
|
+
// otherwise surfaces every transcript that merely MENTIONS the string (a
|
|
2171
|
+
// resume prompt echoes the parent id into the body of many later sessions).
|
|
2085
2172
|
return { matches: findSessionsById(normalized), byId: true, completeId };
|
|
2086
2173
|
}
|
|
2087
2174
|
return { matches: filterSessionsByQuery(pool, normalized), byId: false, completeId };
|
|
@@ -2237,7 +2324,7 @@ async function renderArtifactsGlobal(query, listAll, name, scope) {
|
|
|
2237
2324
|
const { matches: queryMatches, byId, completeId } = resolveSessionQuery(allSessions, query);
|
|
2238
2325
|
if (queryMatches.length === 0) {
|
|
2239
2326
|
spinner.stop();
|
|
2240
|
-
if (
|
|
2327
|
+
if (byId)
|
|
2241
2328
|
notFoundByIdMessage(query).forEach(l => console.error(l));
|
|
2242
2329
|
else
|
|
2243
2330
|
console.error(chalk.red(`No session found matching: ${query}`));
|
|
@@ -2281,11 +2368,14 @@ async function renderOneSession(query, mode, scope) {
|
|
|
2281
2368
|
let queryMatches = resolution.matches;
|
|
2282
2369
|
let byId = resolution.byId;
|
|
2283
2370
|
const completeId = resolution.completeId;
|
|
2284
|
-
// Widen to the transcript content index only for a genuine search phrase.
|
|
2285
|
-
//
|
|
2286
|
-
// session
|
|
2287
|
-
// <uuid>` render an
|
|
2288
|
-
|
|
2371
|
+
// Widen to the transcript content index only for a genuine search phrase.
|
|
2372
|
+
// ANY id-shaped query (a complete id OR a hex short-id/prefix) names a
|
|
2373
|
+
// specific session; widening could only surface a DIFFERENT session that
|
|
2374
|
+
// happens to MENTION the id — which is what made `sessions <uuid>` render an
|
|
2375
|
+
// unrelated transcript and `sessions <shortid>` list every session that
|
|
2376
|
+
// echoes the id in a resume prompt. Gate on looksLikeSessionId, not just
|
|
2377
|
+
// completeId, so a short id resolves to "no match" rather than fuzzy content.
|
|
2378
|
+
if (queryMatches.length === 0 && !looksLikeSessionId(query)) {
|
|
2289
2379
|
const contentResults = searchContentIndex(allSessions, query);
|
|
2290
2380
|
if (contentResults.size > 0) {
|
|
2291
2381
|
const matchedSessions = Array.from(contentResults.values())
|
|
@@ -2312,7 +2402,7 @@ async function renderOneSession(query, mode, scope) {
|
|
|
2312
2402
|
process.exit(1);
|
|
2313
2403
|
}
|
|
2314
2404
|
}
|
|
2315
|
-
else if (
|
|
2405
|
+
else if (byId) {
|
|
2316
2406
|
notFoundByIdMessage(query).forEach(l => console.error(l));
|
|
2317
2407
|
process.exit(1);
|
|
2318
2408
|
}
|
|
@@ -2377,10 +2467,10 @@ export function registerSessionsCommands(program) {
|
|
|
2377
2467
|
.option('--markdown', 'Render the session as markdown (user, assistant, thinking, tool calls)')
|
|
2378
2468
|
.option('--no-redact', 'Disable default secret redaction in rendered session output (--markdown and --json)')
|
|
2379
2469
|
.option('--json', 'Output JSON (session list when browsing, event array when rendering one session)')
|
|
2380
|
-
.option('--include <roles>', 'Only include these roles (comma-separated): user, assistant, thinking, tools')
|
|
2470
|
+
.option('--include <roles>', 'Only include these roles (comma-separated): user, assistant, thinking, tools. "user" is genuine user turns only, not harness-injected scaffolding (bash-input, system-reminder)')
|
|
2381
2471
|
.option('--exclude <roles>', 'Exclude these roles (comma-separated): user, assistant, thinking, tools')
|
|
2382
|
-
.option('--first <n>', 'Keep only the first N turns (a turn starts at each user message)')
|
|
2383
|
-
.option('--last <n>', 'Keep only the last N turns (a turn starts at each user message)')
|
|
2472
|
+
.option('--first <n>', 'Keep only the first N turns (a turn starts at each genuine user message, not harness-injected scaffolding)')
|
|
2473
|
+
.option('--last <n>', 'Keep only the last N turns (a turn starts at each genuine user message, not harness-injected scaffolding)')
|
|
2384
2474
|
.option('--artifacts', 'List all files written or edited during a session')
|
|
2385
2475
|
.option('--artifact <name>', 'Read a specific artifact by filename or path (outputs to stdout)')
|
|
2386
2476
|
.option('--active', 'Show only sessions running right now across terminals, teams, cloud, and headless agents')
|
package/dist/commands/ssh.d.ts
CHANGED
|
@@ -10,6 +10,7 @@
|
|
|
10
10
|
* rsync / `agents sessions --host` resolve the same logical names.
|
|
11
11
|
*/
|
|
12
12
|
import type { Command } from 'commander';
|
|
13
|
+
import { type FanOutDeviceTarget } from '../lib/devices/fleet.js';
|
|
13
14
|
import { type CrabboxBox } from '../lib/crabbox/cli.js';
|
|
14
15
|
/**
|
|
15
16
|
* Live "Leased boxes" section for `agents devices` (F4, RUSH-1923), computed
|
|
@@ -19,5 +20,17 @@ import { type CrabboxBox } from '../lib/crabbox/cli.js';
|
|
|
19
20
|
* the row formatting is deterministic in tests.
|
|
20
21
|
*/
|
|
21
22
|
export declare function renderLeasedBoxesSection(boxes: CrabboxBox[], nowSecs: number): string[];
|
|
23
|
+
/**
|
|
24
|
+
* Race `fanOut` against an overall wall-clock deadline (RUSH-2041).
|
|
25
|
+
*
|
|
26
|
+
* If `fanOut` settles first the result passes through unchanged. If the
|
|
27
|
+
* deadline fires first every pending remote target is mapped to `failed` (or
|
|
28
|
+
* `skipped` for pre-skipped targets), so callers always get a complete result
|
|
29
|
+
* array and the command exits promptly rather than hanging.
|
|
30
|
+
*
|
|
31
|
+
* Exported so the unit test can exercise the real path with a hanging probe
|
|
32
|
+
* instead of reimplementing the logic.
|
|
33
|
+
*/
|
|
34
|
+
export declare function raceFleetPingDeadline<T, Target extends FanOutDeviceTarget>(fanOut: Promise<import('../lib/devices/fleet.js').FanOutDeviceResult<T>[]>, remoteTargets: Target[], overallTimeoutMs: number): Promise<import('../lib/devices/fleet.js').FanOutDeviceResult<T>[]>;
|
|
22
35
|
/** Register both `agents ssh` and `agents devices`. */
|
|
23
36
|
export declare function registerSshCommands(program: Command): void;
|
package/dist/commands/ssh.js
CHANGED
|
@@ -441,13 +441,43 @@ async function probeRemoteAuth(target) {
|
|
|
441
441
|
const isWin = /^win/i.test((target.platform ?? '').trim());
|
|
442
442
|
const env = isWin ? undefined : { PATH: '$HOME/.agents/.cache/shims:$HOME/.local/bin:$PATH' };
|
|
443
443
|
const cmd = buildRemoteAgentsInvocation(['devices', 'ping', '--local', '--json'], undefined, isWin ? 'windows' : undefined, env);
|
|
444
|
-
const res = await sshExecAsync(target.dialTarget, cmd, { timeoutMs:
|
|
444
|
+
const res = await sshExecAsync(target.dialTarget, cmd, { timeoutMs: 15000, multiplex: true });
|
|
445
445
|
if (res.code !== 0) {
|
|
446
446
|
throw new Error(res.timedOut ? 'timed out' : (res.stderr.trim() || `exit ${res.code ?? 'unknown'}`));
|
|
447
447
|
}
|
|
448
448
|
const parsed = JSON.parse(res.stdout);
|
|
449
449
|
return parsed.rows ?? [];
|
|
450
450
|
}
|
|
451
|
+
/**
|
|
452
|
+
* Race `fanOut` against an overall wall-clock deadline (RUSH-2041).
|
|
453
|
+
*
|
|
454
|
+
* If `fanOut` settles first the result passes through unchanged. If the
|
|
455
|
+
* deadline fires first every pending remote target is mapped to `failed` (or
|
|
456
|
+
* `skipped` for pre-skipped targets), so callers always get a complete result
|
|
457
|
+
* array and the command exits promptly rather than hanging.
|
|
458
|
+
*
|
|
459
|
+
* Exported so the unit test can exercise the real path with a hanging probe
|
|
460
|
+
* instead of reimplementing the logic.
|
|
461
|
+
*/
|
|
462
|
+
export async function raceFleetPingDeadline(fanOut, remoteTargets, overallTimeoutMs) {
|
|
463
|
+
const overallDeadline = new Promise((_, reject) => setTimeout(() => reject(new Error('fleet ping overall deadline exceeded')), overallTimeoutMs));
|
|
464
|
+
try {
|
|
465
|
+
return await Promise.race([fanOut, overallDeadline]);
|
|
466
|
+
}
|
|
467
|
+
catch (err) {
|
|
468
|
+
// Overall deadline hit before all devices settled — mark every pending
|
|
469
|
+
// device as failed so the command exits promptly. Individual probes that
|
|
470
|
+
// already settled are not retrievable (Promise.all internals), so we
|
|
471
|
+
// record all remote targets as timed out / skipped.
|
|
472
|
+
const errMsg = err instanceof Error ? err.message : String(err);
|
|
473
|
+
return remoteTargets.map((t) => ({
|
|
474
|
+
name: t.name,
|
|
475
|
+
status: t.skip ? 'skipped' : 'failed',
|
|
476
|
+
reason: t.skip,
|
|
477
|
+
error: t.skip ? undefined : errMsg,
|
|
478
|
+
}));
|
|
479
|
+
}
|
|
480
|
+
}
|
|
451
481
|
async function runFleetPing(opts) {
|
|
452
482
|
const self = machineId();
|
|
453
483
|
const cliVersion = getCliVersion();
|
|
@@ -484,9 +514,16 @@ async function runFleetPing(opts) {
|
|
|
484
514
|
const spinner = isInteractiveTerminal() && !opts.json
|
|
485
515
|
? ora(`Pinging ${probeable} device${probeable === 1 ? '' : 's'}…`).start()
|
|
486
516
|
: undefined;
|
|
517
|
+
// Per-device: 15 s (matches the version probe budget; enough for the ~8 s
|
|
518
|
+
// provider-fetch inside the remote local auth probe, with headroom).
|
|
519
|
+
// Overall: 30 s hard cap so the command can never outlast a reasonable
|
|
520
|
+
// budget when several devices are simultaneously unreachable (RUSH-2041).
|
|
521
|
+
const FLEET_PING_DEVICE_TIMEOUT_MS = 15_000;
|
|
522
|
+
const FLEET_PING_OVERALL_TIMEOUT_MS = 30_000;
|
|
487
523
|
let remote;
|
|
488
524
|
try {
|
|
489
|
-
|
|
525
|
+
const fanOut = fanOutDevices(remoteTargets, probeRemoteAuth, { perDeviceTimeoutMs: FLEET_PING_DEVICE_TIMEOUT_MS });
|
|
526
|
+
remote = await raceFleetPingDeadline(fanOut, remoteTargets, FLEET_PING_OVERALL_TIMEOUT_MS);
|
|
490
527
|
}
|
|
491
528
|
finally {
|
|
492
529
|
spinner?.stop();
|
package/dist/index.js
CHANGED
|
@@ -64,7 +64,8 @@ if (process.argv[2] === SYNC_GET_CMD ||
|
|
|
64
64
|
process.argv[2] === SYNC_LOCK_CMD) {
|
|
65
65
|
const { runAgentGetSync, runAgentPingSync, runAgentLockSync } = await import('./lib/secrets/agent.js');
|
|
66
66
|
const name = process.argv[3] ?? '';
|
|
67
|
-
const
|
|
67
|
+
const harness = process.argv[4] ?? 'cli';
|
|
68
|
+
const code = process.argv[2] === SYNC_GET_CMD ? await runAgentGetSync(name, harness)
|
|
68
69
|
: process.argv[2] === SYNC_PING_CMD ? await runAgentPingSync()
|
|
69
70
|
: await runAgentLockSync(name);
|
|
70
71
|
process.exit(code);
|
package/dist/lib/activity.d.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import type { EventRecord } from './events.js';
|
|
2
2
|
/** Recognizable milestone events, ordered first in any activity lane. */
|
|
3
|
-
export type MilestoneEvent = 'plan.created' | 'pr.opened' | 'pr.merged' | 'worktree.created' | 'worktree.removed' | 'commit.created' | 'pushed' | 'subagent.spawned' | 'artifact.created'
|
|
3
|
+
export type MilestoneEvent = 'plan.created' | 'pr.opened' | 'pr.merged' | 'worktree.created' | 'worktree.removed' | 'commit.created' | 'pushed' | 'subagent.spawned' | 'artifact.created' | 'task.completed' | 'checklist.created'
|
|
4
4
|
/** Deliberate agent-authored progress post (`agents feed post`). */
|
|
5
5
|
| 'status.posted';
|
|
6
6
|
/** Routine activity events, collapsed to counts by readers. */
|
|
@@ -117,10 +117,11 @@ export declare function formatActivityLine(ev: ActivityEvent, opts?: {
|
|
|
117
117
|
* line per event to ~/.agents/.history/activity/<sessionId>.jsonl.
|
|
118
118
|
*
|
|
119
119
|
* Matcher-gated to mutating/milestone tools (Bash|Task|ExitPlanMode|Write|
|
|
120
|
-
* Edit|MultiEdit) so
|
|
121
|
-
*
|
|
120
|
+
* Edit|MultiEdit|TodoWrite|update_plan|TaskUpdate|todo_write|TaskCreate) so
|
|
121
|
+
* read-only tools never pay the hook cost. Fail-open: any error is swallowed so
|
|
122
|
+
* a logging hiccup never blocks a tool call.
|
|
122
123
|
*/
|
|
123
|
-
export declare const ACTIVITY_LOG_HOOK_SCRIPT = "#!/usr/bin/env python3\n\"\"\"Append agent-activity events for `agents feed` / `agents activity`.\n\nBound to PreToolUse (ExitPlanMode, Task) and PostToolUse (Bash, Write, Edit,\nMultiEdit). One append-only file per session; read-only tools never trigger it\nbecause the manifest matcher excludes them.\n\nSub-agent gate: when the payload carries `agent_type`, this is a Task/Agent\nsub-agent -- skip so only the top-level agent logs its own activity.\n\nFail-open: ANY error is swallowed so a logging hiccup never blocks a tool call.\n\"\"\"\nimport os\nimport re\nimport sys\nimport json\nimport shlex\nimport socket\nfrom datetime import datetime, timezone\n\nMAX_LOG_BYTES = 5 * 1024 * 1024 # cap a pathological session's log\nMILESTONE_EVENTS = {\n \"plan.created\", \"pr.opened\", \"pr.merged\", \"worktree.created\",\n \"worktree.removed\", \"commit.created\", \"pushed\", \"subagent.spawned\",\n \"artifact.created\", \"status.posted\",\n}\n\n# Deliverable file types + locations -- a Write here is a recognizable artifact\n# (an HTML plan, a PDF report, a rendered image), not a routine code edit.\nARTIFACT_EXTS = {\n \".html\", \".htm\", \".pdf\", \".png\", \".jpg\", \".jpeg\", \".gif\", \".svg\",\n \".webp\", \".mp4\", \".mov\", \".webm\", \".csv\", \".xlsx\", \".pptx\", \".docx\",\n}\nARTIFACT_DIR_HINTS = (\"/tmp/\", \"/downloads/\", \"/.agents/artifacts/\")\n\n\ndef is_artifact(file_path):\n low = (file_path or \"\").lower()\n if os.path.splitext(low)[1] in ARTIFACT_EXTS:\n return True\n return any(hint in low for hint in ARTIFACT_DIR_HINTS)\n\n\ndef first_line(text, limit=140):\n for raw in (text or \"\").splitlines():\n s = raw.strip().lstrip(\"#\").strip()\n if s:\n return s[:limit]\n return \"\"\n\n\ndef _subcommand(tokens, tool):\n \"\"\"First non-flag token after `tool`, i.e. its subcommand (skips -C <path>,\n -c <cfg>, and other leading flags). None if `tool` isn't the invoked command.\"\"\"\n try:\n i = tokens.index(tool) + 1\n except ValueError:\n return None, []\n while i < len(tokens):\n t = tokens[i]\n if t in (\"-C\", \"-c\", \"--git-dir\", \"--work-tree\"):\n i += 2 # flag that consumes the next token\n continue\n if t.startswith(\"-\"):\n i += 1\n continue\n return t, tokens[i + 1:]\n return None, []\n\n\ndef classify_bash(command):\n \"\"\"Return the milestone event for a git/gh command, else None. Tokenizes so a\n path like `git diff -- src/commit.ts` is not mistaken for a commit.\"\"\"\n try:\n tokens = shlex.split(command or \"\")\n except Exception:\n tokens = (command or \"\").split()\n\n verb, rest = _subcommand(tokens, \"git\")\n if verb == \"worktree\":\n sub = rest[0] if rest else \"\"\n if sub == \"add\":\n return \"worktree.created\"\n if sub == \"remove\":\n return \"worktree.removed\"\n elif verb == \"commit\":\n return \"commit.created\"\n elif verb == \"push\":\n return \"pushed\"\n\n verb, rest = _subcommand(tokens, \"gh\")\n if verb == \"pr\" and rest:\n if rest[0] == \"create\":\n return \"pr.opened\"\n if rest[0] == \"merge\":\n return \"pr.merged\"\n return None\n\n\ndef extract_url(tool_response):\n \"\"\"Pull the first https URL out of a Bash tool response (stdout).\"\"\"\n text = \"\"\n if isinstance(tool_response, dict):\n text = str(tool_response.get(\"stdout\") or tool_response.get(\"output\") or \"\")\n elif isinstance(tool_response, str):\n text = tool_response\n m = re.search(r\"https?://\\S+\", text)\n return m.group(0).rstrip(\").,\") if m else None\n\n\ndef build_event(payload, hook_event):\n tool_name = payload.get(\"tool_name\", \"\")\n tool_input = payload.get(\"tool_input\", {}) or {}\n tool_response = payload.get(\"tool_response\", {})\n\n event = None\n detail = None\n url = None\n\n if hook_event == \"PreToolUse\":\n if tool_name == \"ExitPlanMode\":\n event = \"plan.created\"\n detail = first_line(tool_input.get(\"plan\", \"\")) or \"plan presented\"\n elif tool_name == \"Task\":\n event = \"subagent.spawned\"\n role = tool_input.get(\"subagent_type\") or \"agent\"\n desc = tool_input.get(\"description\") or tool_input.get(\"prompt\") or \"\"\n detail = (role + \": \" + first_line(desc)).strip(\": \").strip()\n elif hook_event == \"PostToolUse\":\n if tool_name == \"Bash\":\n event = classify_bash(tool_input.get(\"command\", \"\"))\n if event:\n detail = first_line(tool_input.get(\"command\", \"\"))\n url = extract_url(tool_response)\n elif tool_name in (\"Write\", \"Edit\", \"MultiEdit\"):\n fp = tool_input.get(\"file_path\") or tool_input.get(\"path\") or \"\"\n # A freshly-written deliverable is a milestone; edits and code\n # writes stay routine and collapse to a count.\n if tool_name == \"Write\" and is_artifact(fp):\n event = \"artifact.created\"\n else:\n event = \"file.edited\"\n detail = os.path.basename(fp) if fp else tool_name\n\n if not event:\n return None, tool_name\n\n tier = \"milestone\" if event in MILESTONE_EVENTS else \"activity\"\n record = {\n \"v\": 1,\n \"ts\": datetime.now(timezone.utc).isoformat(),\n \"event\": event,\n \"tier\": tier,\n }\n if detail:\n record[\"detail\"] = detail\n if url:\n record[\"url\"] = url\n record[\"tool\"] = tool_name\n return record, tool_name\n\n\ndef main():\n raw = sys.stdin.read()\n try:\n payload = json.loads(raw) if raw.strip() else {}\n except Exception:\n return\n\n # Sub-agent gate -- only the top-level agent logs.\n if payload.get(\"agent_type\"):\n return\n\n session_id = payload.get(\"session_id\", \"\")\n if not session_id:\n return\n\n hook_event = payload.get(\"hook_event_name\", \"\")\n record, tool_name = build_event(payload, hook_event)\n if not record:\n return\n\n safe_session = re.sub(r\"[^A-Za-z0-9._-]\", \"-\", session_id) or \"unknown\"\n home = os.environ.get(\"HOME\") or os.path.expanduser(\"~\")\n activity_dir = os.path.join(home, \".agents\", \".history\", \"activity\")\n target = os.path.join(activity_dir, safe_session + \".jsonl\")\n\n # Identity (mirrors 10-feed-publish.py).\n mailbox_id = os.path.basename(\n os.environ.get(\"AGENTS_MAILBOX_DIR\", \"\").rstrip(\"/\")\n ) or session_id\n hostname = os.environ.get(\"AGENTS_SYNC_MACHINE_ID\") or socket.gethostname()\n host = re.sub(r\"[^a-z0-9_-]\", \"-\", hostname.split(\".\")[0].strip().lower()) or \"unknown\"\n\n record[\"sessionId\"] = session_id\n record[\"mailboxId\"] = mailbox_id\n record[\"host\"] = host\n record[\"runtime\"] = os.environ.get(\"AGENTS_RUNTIME\", \"headless\")\n cwd = payload.get(\"cwd\") or os.environ.get(\"AGENTS_CWD\")\n if cwd:\n record[\"cwd\"] = cwd\n agent = os.environ.get(\"AGENTS_AGENT_NAME\") or \"claude\"\n record[\"agent\"] = agent\n\n try:\n os.makedirs(activity_dir, exist_ok=True)\n # Cap growth: once over the size limit, keep only milestones.\n if record[\"tier\"] != \"milestone\":\n try:\n if os.path.getsize(target) > MAX_LOG_BYTES:\n return\n except OSError:\n pass\n with open(target, \"a\") as f:\n f.write(json.dumps(record) + \"\\n\")\n except Exception:\n pass # fail open\n\n\nif __name__ == \"__main__\":\n try:\n main()\n except Exception:\n pass # fail open\n";
|
|
124
|
+
export declare const ACTIVITY_LOG_HOOK_SCRIPT = "#!/usr/bin/env python3\n\"\"\"Append agent-activity events for `agents feed` / `agents activity`.\n\nBound to PreToolUse (ExitPlanMode, Task) and PostToolUse (Bash, Write, Edit,\nMultiEdit, TodoWrite, update_plan, TaskUpdate, todo_write, TaskCreate). One\nappend-only file per session; read-only tools never trigger it because the\nmanifest matcher excludes them.\n\nSub-agent gate: when the payload carries `agent_type`, this is a Task/Agent\nsub-agent -- skip so only the top-level agent logs its own activity.\n\nFail-open: ANY error is swallowed so a logging hiccup never blocks a tool call.\n\"\"\"\nimport os\nimport re\nimport sys\nimport json\nimport shlex\nimport socket\nfrom datetime import datetime, timezone\n\nMAX_LOG_BYTES = 5 * 1024 * 1024 # cap a pathological session's log\nMILESTONE_EVENTS = {\n \"plan.created\", \"pr.opened\", \"pr.merged\", \"worktree.created\",\n \"worktree.removed\", \"commit.created\", \"pushed\", \"subagent.spawned\",\n \"artifact.created\", \"task.completed\", \"checklist.created\", \"status.posted\",\n}\n\n# Deliverable file types + locations -- a Write here is a recognizable artifact\n# (an HTML plan, a PDF report, a rendered image), not a routine code edit.\nARTIFACT_EXTS = {\n \".html\", \".htm\", \".pdf\", \".png\", \".jpg\", \".jpeg\", \".gif\", \".svg\",\n \".webp\", \".mp4\", \".mov\", \".webm\", \".csv\", \".xlsx\", \".pptx\", \".docx\",\n}\nARTIFACT_DIR_HINTS = (\"/tmp/\", \"/downloads/\", \"/.agents/artifacts/\")\n\n# Checklist tools across harnesses. The value is the key that holds the item list.\nCHECKLIST_TOOLS = {\n \"TodoWrite\": \"todos\",\n \"todo_write\": \"todos\",\n \"TaskUpdate\": \"tasks\",\n \"update_plan\": \"plan\",\n}\n\n\ndef is_artifact(file_path):\n low = (file_path or \"\").lower()\n if os.path.splitext(low)[1] in ARTIFACT_EXTS:\n return True\n return any(hint in low for hint in ARTIFACT_DIR_HINTS)\n\n\ndef first_line(text, limit=140):\n for raw in (text or \"\").splitlines():\n s = raw.strip().lstrip(\"#\").strip()\n if s:\n return s[:limit]\n return \"\"\n\n\ndef _subcommand(tokens, tool):\n \"\"\"First non-flag token after `tool`, i.e. its subcommand (skips -C <path>,\n -c <cfg>, and other leading flags). None if `tool` isn't the invoked command.\"\"\"\n try:\n i = tokens.index(tool) + 1\n except ValueError:\n return None, []\n while i < len(tokens):\n t = tokens[i]\n if t in (\"-C\", \"-c\", \"--git-dir\", \"--work-tree\"):\n i += 2 # flag that consumes the next token\n continue\n if t.startswith(\"-\"):\n i += 1\n continue\n return t, tokens[i + 1:]\n return None, []\n\n\ndef classify_bash(command):\n \"\"\"Return the milestone event for a git/gh command, else None. Tokenizes so a\n path like `git diff -- src/commit.ts` is not mistaken for a commit.\"\"\"\n try:\n tokens = shlex.split(command or \"\")\n except Exception:\n tokens = (command or \"\").split()\n\n verb, rest = _subcommand(tokens, \"git\")\n if verb == \"worktree\":\n sub = rest[0] if rest else \"\"\n if sub == \"add\":\n return \"worktree.created\"\n if sub == \"remove\":\n return \"worktree.removed\"\n elif verb == \"commit\":\n return \"commit.created\"\n elif verb == \"push\":\n return \"pushed\"\n\n verb, rest = _subcommand(tokens, \"gh\")\n if verb == \"pr\" and rest:\n if rest[0] == \"create\":\n return \"pr.opened\"\n if rest[0] == \"merge\":\n return \"pr.merged\"\n return None\n\n\ndef extract_url(tool_response):\n \"\"\"Pull the first https URL out of a Bash tool response (stdout).\"\"\"\n text = \"\"\n if isinstance(tool_response, dict):\n text = str(tool_response.get(\"stdout\") or tool_response.get(\"output\") or \"\")\n elif isinstance(tool_response, str):\n text = tool_response\n m = re.search(r\"https?://\\S+\", text)\n return m.group(0).rstrip(\").,\") if m else None\n\n\ndef _checklist_items(tool_input, kind):\n \"\"\"Extract normalized checklist items from tool input.\"\"\"\n if not isinstance(tool_input, dict):\n return []\n if kind == \"todos\":\n arr = tool_input.get(\"todos\") or []\n elif kind == \"tasks\":\n arr = tool_input.get(\"tasks\") or []\n if not arr and \"taskId\" in tool_input:\n arr = [tool_input]\n elif kind == \"plan\":\n arr = tool_input.get(\"plan\") or []\n else:\n return []\n if not isinstance(arr, list):\n return []\n\n items = []\n for t in arr:\n if not isinstance(t, dict):\n continue\n subject = (\n t.get(\"content\") or\n t.get(\"text\") or\n t.get(\"step\") or\n t.get(\"title\") or\n t.get(\"description\") or\n t.get(\"activeForm\") or\n \"\"\n )\n if not isinstance(subject, str):\n subject = str(subject)\n subject = subject.strip()\n item_id = t.get(\"id\") or t.get(\"taskId\") or subject\n if not item_id:\n continue\n status = str(t.get(\"status\", \"\") or \"\").lower()\n items.append({\"id\": item_id, \"subject\": subject, \"status\": status})\n return items\n\n\ndef _read_transcript_checklists(transcript_path, current_tool, current_items):\n \"\"\"Tail-read the session transcript and return the previous checklist state.\n\n The most recent checklist entry is assumed to be the current tool call if\n it carries the same ids; skip that one and return the next older checklist\n state (or None if there isn't one).\n \"\"\"\n if not transcript_path or not os.path.exists(transcript_path):\n return None\n try:\n size = os.path.getsize(transcript_path)\n start = max(0, size - 512 * 1024)\n with open(transcript_path, \"r\", encoding=\"utf-8\", errors=\"ignore\") as f:\n f.seek(start)\n if start > 0:\n f.readline() # drop a leading partial line\n lines = f.readlines()\n except Exception:\n return None\n\n current_ids = {str(item.get(\"id\")) for item in current_items if item.get(\"id\")}\n skipped_current = False\n for line in reversed(lines):\n line = line.strip()\n if not line:\n continue\n try:\n record = json.loads(line)\n except Exception:\n continue\n if not isinstance(record, dict):\n continue\n\n tool_uses = []\n if isinstance(record.get(\"tool_use\"), list):\n tool_uses = record[\"tool_use\"]\n elif record.get(\"name\"):\n tool_uses = [record]\n elif record.get(\"tool_name\"):\n tool_uses = [record]\n\n for tu in reversed(tool_uses):\n if not isinstance(tu, dict):\n continue\n name = tu.get(\"name\") or tu.get(\"tool_name\") or \"\"\n if name not in CHECKLIST_TOOLS:\n continue\n kind = CHECKLIST_TOOLS[name]\n args = tu.get(\"input\") or tu.get(\"tool_input\") or tu.get(\"arguments\") or {}\n if isinstance(args, str):\n try:\n args = json.loads(args)\n except Exception:\n continue\n items = _checklist_items(args, kind)\n if not items:\n continue\n ids = {str(item.get(\"id\")) for item in items if item.get(\"id\")}\n # Skip the most recent matching checklist entry once; that is the\n # current call already reflected in the transcript.\n if not skipped_current and name == current_tool and ids == current_ids:\n skipped_current = True\n continue\n return items\n return None\n\n\ndef _has_previous_task_create(transcript_path, current_tool_use_id=\"\"):\n \"\"\"Return True if the transcript contains a TaskCreate before the current one.\"\"\"\n if not transcript_path or not os.path.exists(transcript_path):\n return False\n try:\n with open(transcript_path, \"r\", encoding=\"utf-8\", errors=\"ignore\") as f:\n lines = f.readlines()\n except Exception:\n return False\n\n for line in reversed(lines):\n line = line.strip()\n if not line:\n continue\n try:\n record = json.loads(line)\n except Exception:\n continue\n if not isinstance(record, dict):\n continue\n\n tool_uses = []\n msg = record.get(\"message\", {})\n if isinstance(msg, dict) and isinstance(msg.get(\"content\"), list):\n tool_uses = [\n c for c in msg[\"content\"]\n if isinstance(c, dict) and c.get(\"type\") == \"tool_use\"\n ]\n elif isinstance(record.get(\"tool_use\"), list):\n tool_uses = record[\"tool_use\"]\n elif record.get(\"name\"):\n tool_uses = [record]\n\n for tu in reversed(tool_uses):\n if not isinstance(tu, dict):\n continue\n if (tu.get(\"name\") or tu.get(\"tool_name\")) == \"TaskCreate\":\n if tu.get(\"id\") != current_tool_use_id:\n return True\n return False\n\n\ndef _claude_task_state(transcript_path, exclude_task_id=None):\n \"\"\"Fold Claude TaskCreate/TaskUpdate calls into a task id -> subject/status map.\n\n TaskCreate provides the subject (and id via toolUseResult); TaskUpdate\n provides the status. If exclude_task_id is given, the last TaskUpdate for\n that id is skipped (it is the current call already reflected in the\n transcript).\n \"\"\"\n state = {}\n if not transcript_path or not os.path.exists(transcript_path):\n return state\n try:\n with open(transcript_path, \"r\", encoding=\"utf-8\", errors=\"ignore\") as f:\n lines = f.readlines()\n except Exception:\n return state\n\n updates = [] # (task_id, status, line_index)\n for idx, line in enumerate(lines):\n line = line.strip()\n if not line:\n continue\n try:\n record = json.loads(line)\n except Exception:\n continue\n if not isinstance(record, dict):\n continue\n\n tool_uses = []\n msg = record.get(\"message\", {})\n if isinstance(msg, dict) and isinstance(msg.get(\"content\"), list):\n tool_uses = [\n c for c in msg[\"content\"]\n if isinstance(c, dict) and c.get(\"type\") == \"tool_use\"\n ]\n elif isinstance(record.get(\"tool_use\"), list):\n tool_uses = record[\"tool_use\"]\n elif record.get(\"name\"):\n tool_uses = [record]\n\n for tu in tool_uses:\n if not isinstance(tu, dict):\n continue\n name = tu.get(\"name\") or \"\"\n args = tu.get(\"input\") or tu.get(\"tool_input\") or {}\n if isinstance(args, str):\n try:\n args = json.loads(args)\n except Exception:\n continue\n if name == \"TaskCreate\":\n subject = (\n args.get(\"subject\") or\n args.get(\"description\") or\n args.get(\"title\") or\n \"\"\n )\n if subject:\n # Link subject to the tool_use id so the result can map it.\n tool_id = tu.get(\"id\")\n if tool_id:\n state.setdefault(\"__pending_subject\", {})[tool_id] = subject\n\n tool_result = record.get(\"toolUseResult\") or {}\n if not isinstance(tool_result, dict):\n continue\n task = tool_result.get(\"task\")\n if isinstance(task, dict):\n task_id = str(task.get(\"id\") or task.get(\"taskId\") or \"\")\n if task_id:\n state.setdefault(task_id, {})\n if task.get(\"subject\"):\n state[task_id][\"subject\"] = task[\"subject\"]\n if task.get(\"status\"):\n state[task_id][\"status\"] = str(task.get(\"status\")).lower()\n # Link any pending subject from the matching tool_use id.\n tool_use_id = record.get(\"tool_use_id\") or \"\"\n pending = state.get(\"__pending_subject\", {})\n if tool_use_id and tool_use_id in pending:\n state[task_id][\"subject\"] = pending[tool_use_id]\n task_id = str(tool_result.get(\"taskId\") or \"\")\n status_change = tool_result.get(\"statusChange\") or {}\n status = status_change.get(\"to\") or tool_result.get(\"status\")\n if task_id and status:\n state.setdefault(task_id, {})\n state[task_id][\"status\"] = str(status).lower()\n updates.append((task_id, str(status).lower(), idx))\n\n if exclude_task_id and updates:\n for i in range(len(updates) - 1, -1, -1):\n if updates[i][0] == exclude_task_id:\n del updates[i]\n break\n # Rebuild statuses from remaining updates.\n for task_id in list(state.keys()):\n if task_id.startswith(\"__\"):\n continue\n if \"subject\" not in state[task_id]:\n del state[task_id]\n else:\n state[task_id].pop(\"status\", None)\n for task_id, status, _ in updates:\n if task_id in state:\n state[task_id][\"status\"] = status\n\n # Drop tasks removed from the checklist (deleted/cancelled): they are gone\n # from the user-visible list, so they must not count toward the N/M total.\n for task_id in list(state.keys()):\n if task_id.startswith(\"__\"):\n continue\n if state[task_id].get(\"status\") in (\"deleted\", \"cancelled\", \"canceled\", \"removed\"):\n del state[task_id]\n\n # Drop internal bookkeeping.\n state.pop(\"__pending_subject\", None)\n return state\n\n\ndef _checklist_events(payload, hook_event):\n \"\"\"Return a list of (event, detail) tuples for checklist tool calls.\"\"\"\n if hook_event != \"PostToolUse\":\n return []\n tool_name = payload.get(\"tool_name\", \"\")\n tool_input = payload.get(\"tool_input\", {}) or {}\n\n if tool_name == \"TaskCreate\":\n subject = (\n tool_input.get(\"subject\") or\n tool_input.get(\"description\") or\n tool_input.get(\"title\") or\n \"task\"\n )\n # Only announce the checklist on the very first task creation.\n current_tool_use_id = payload.get(\"tool_use_id\", \"\")\n transcript_path = payload.get(\"transcript_path\")\n if _has_previous_task_create(transcript_path, current_tool_use_id):\n return []\n return [(\"checklist.created\", subject)]\n\n if tool_name not in CHECKLIST_TOOLS:\n return []\n\n kind = CHECKLIST_TOOLS[tool_name]\n items = _checklist_items(tool_input, kind)\n if not items:\n return []\n\n # Full-list tools (TodoWrite, update_plan) send the whole checklist every\n # time. Per-task update tools (TaskUpdate) send only the changed item, so\n # totals and subject must be resolved from the previous full-list state.\n list_key = {\"todos\": \"todos\", \"tasks\": \"tasks\", \"plan\": \"plan\"}.get(kind)\n is_full_list = list_key is not None and list_key in tool_input\n\n previous = _read_transcript_checklists(\n payload.get(\"transcript_path\"), tool_name, items\n )\n\n if not is_full_list and previous:\n total = len(previous)\n previous_done = {\n str(i.get(\"id\")) for i in previous\n if i.get(\"status\") == \"completed\" and i.get(\"id\")\n }\n done_count = len(previous_done)\n events = []\n for item in items:\n if item.get(\"status\") != \"completed\":\n continue\n item_id = str(item.get(\"id\"))\n if item_id in previous_done:\n continue\n subject = item.get(\"subject\")\n if not subject:\n for p in previous:\n if str(p.get(\"id\")) == item_id and p.get(\"subject\"):\n subject = p[\"subject\"]\n break\n if not subject:\n subject = \"task\"\n done_count += 1\n events.append((\"task.completed\", f\"{subject} {done_count}/{total} done\"))\n return events\n\n # For TaskUpdate without a previous full-list state, fold the Claude\n # TaskCreate/TaskUpdate history to resolve subject and N/M.\n if tool_name == \"TaskUpdate\" and previous is None:\n task_id = str(items[0].get(\"id\")) if items else \"\"\n task_state = _claude_task_state(\n payload.get(\"transcript_path\"), exclude_task_id=task_id\n )\n if task_state:\n total = len(task_state)\n previous_done = {\n tid for tid, info in task_state.items()\n if info.get(\"status\") == \"completed\"\n }\n done_count = len(previous_done)\n if task_id in task_state and items[0].get(\"status\") == \"completed\" and task_id not in previous_done:\n subject = task_state[task_id].get(\"subject\") or \"task\"\n return [(\"task.completed\", f\"{subject} {done_count + 1}/{total} done\")]\n return []\n\n total = len(items)\n completed = [i for i in items if i.get(\"status\") == \"completed\"]\n\n if previous is None:\n # First checklist call in this session.\n events = []\n events.append((\"checklist.created\", f\"{total} task{'s' if total != 1 else ''}\"))\n for item in completed:\n events.append((\n \"task.completed\",\n f\"{item['subject']} {len(completed)}/{total} done\",\n ))\n return events\n\n previous_done = {\n str(i.get(\"id\")) for i in previous\n if i.get(\"status\") == \"completed\" and i.get(\"id\")\n }\n newly_completed = [\n i for i in completed\n if str(i.get(\"id\")) not in previous_done\n ]\n if not newly_completed:\n return []\n\n done_count = len(completed)\n events = []\n for item in newly_completed:\n events.append((\n \"task.completed\",\n f\"{item['subject']} {done_count}/{total} done\",\n ))\n return events\n\n\ndef _make_record(event, detail, tool_name):\n tier = \"milestone\" if event in MILESTONE_EVENTS else \"activity\"\n record = {\n \"v\": 1,\n \"ts\": datetime.now(timezone.utc).isoformat(),\n \"event\": event,\n \"tier\": tier,\n }\n if detail:\n record[\"detail\"] = detail\n record[\"tool\"] = tool_name\n return record\n\n\ndef build_event(payload, hook_event):\n tool_name = payload.get(\"tool_name\", \"\")\n tool_input = payload.get(\"tool_input\", {}) or {}\n tool_response = payload.get(\"tool_response\", {})\n\n # Checklist completions first -- cheap guard above already filtered tool name.\n checklist = _checklist_events(payload, hook_event)\n if checklist:\n return [_make_record(event, detail, tool_name) for event, detail in checklist], tool_name\n\n event = None\n detail = None\n url = None\n\n if hook_event == \"PreToolUse\":\n if tool_name == \"ExitPlanMode\":\n event = \"plan.created\"\n detail = first_line(tool_input.get(\"plan\", \"\")) or \"plan presented\"\n elif tool_name == \"Task\":\n event = \"subagent.spawned\"\n role = tool_input.get(\"subagent_type\") or \"agent\"\n desc = tool_input.get(\"description\") or tool_input.get(\"prompt\") or \"\"\n detail = (role + \": \" + first_line(desc)).strip(\": \").strip()\n elif hook_event == \"PostToolUse\":\n if tool_name == \"Bash\":\n event = classify_bash(tool_input.get(\"command\", \"\"))\n if event:\n detail = first_line(tool_input.get(\"command\", \"\"))\n url = extract_url(tool_response)\n elif tool_name in (\"Write\", \"Edit\", \"MultiEdit\"):\n fp = tool_input.get(\"file_path\") or tool_input.get(\"path\") or \"\"\n # A freshly-written deliverable is a milestone; edits and code\n # writes stay routine and collapse to a count.\n if tool_name == \"Write\" and is_artifact(fp):\n event = \"artifact.created\"\n else:\n event = \"file.edited\"\n detail = os.path.basename(fp) if fp else tool_name\n\n if not event:\n return [], tool_name\n\n record = _make_record(event, detail, tool_name)\n if url:\n record[\"url\"] = url\n return [record], tool_name\n\n\ndef main():\n raw = sys.stdin.read()\n try:\n payload = json.loads(raw) if raw.strip() else {}\n except Exception:\n return\n\n # Sub-agent gate -- only the top-level agent logs.\n if payload.get(\"agent_type\"):\n return\n\n session_id = payload.get(\"session_id\", \"\")\n if not session_id:\n return\n\n hook_event = payload.get(\"hook_event_name\", \"\")\n records, tool_name = build_event(payload, hook_event)\n if not records:\n return\n\n safe_session = re.sub(r\"[^A-Za-z0-9._-]\", \"-\", session_id) or \"unknown\"\n home = os.environ.get(\"HOME\") or os.path.expanduser(\"~\")\n activity_dir = os.path.join(home, \".agents\", \".history\", \"activity\")\n target = os.path.join(activity_dir, safe_session + \".jsonl\")\n\n # Identity (mirrors 10-feed-publish.py).\n mailbox_id = os.path.basename(\n os.environ.get(\"AGENTS_MAILBOX_DIR\", \"\").rstrip(\"/\")\n ) or session_id\n hostname = os.environ.get(\"AGENTS_SYNC_MACHINE_ID\") or socket.gethostname()\n host = re.sub(r\"[^a-z0-9_-]\", \"-\", hostname.split(\".\")[0].strip().lower()) or \"unknown\"\n\n for record in records:\n record[\"sessionId\"] = session_id\n record[\"mailboxId\"] = mailbox_id\n record[\"host\"] = host\n record[\"runtime\"] = os.environ.get(\"AGENTS_RUNTIME\", \"headless\")\n cwd = payload.get(\"cwd\") or os.environ.get(\"AGENTS_CWD\")\n if cwd:\n record[\"cwd\"] = cwd\n agent = os.environ.get(\"AGENTS_AGENT_NAME\") or \"claude\"\n record[\"agent\"] = agent\n\n try:\n os.makedirs(activity_dir, exist_ok=True)\n # Cap growth: once over the size limit, keep only milestones.\n try:\n over_limit = os.path.getsize(target) > MAX_LOG_BYTES\n except OSError:\n over_limit = False\n with open(target, \"a\") as f:\n for record in records:\n if over_limit and record.get(\"tier\") != \"milestone\":\n continue\n f.write(json.dumps(record) + \"\\n\")\n except Exception:\n pass # fail open\n\n\nif __name__ == \"__main__\":\n try:\n main()\n except Exception:\n pass # fail open\n";
|
|
124
125
|
/** Hook manifest entries (agents.yaml shape) for the activity-log hook. */
|
|
125
126
|
export declare const ACTIVITY_HOOK_DEFINITIONS: Record<string, Record<string, unknown>>;
|
|
126
127
|
/**
|