@phnx-labs/agents-cli 1.20.90 → 1.20.92
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 +276 -0
- package/README.md +1 -1
- package/dist/bin/agents +0 -0
- package/dist/commands/activity.d.ts +72 -6
- package/dist/commands/activity.js +198 -49
- package/dist/commands/beta.js +1 -0
- package/dist/commands/doctor.js +4 -2
- package/dist/commands/exec.d.ts +14 -0
- package/dist/commands/exec.js +144 -14
- package/dist/commands/feed.js +77 -4
- package/dist/commands/hooks.js +22 -6
- package/dist/commands/perf.d.ts +14 -0
- package/dist/commands/perf.js +221 -0
- package/dist/commands/projects.d.ts +12 -0
- package/dist/commands/projects.js +358 -0
- package/dist/commands/routines.js +30 -24
- package/dist/commands/secrets.d.ts +43 -4
- package/dist/commands/secrets.js +217 -32
- package/dist/commands/send.d.ts +5 -1
- package/dist/commands/send.js +1 -1
- package/dist/commands/sessions-picker.d.ts +15 -0
- package/dist/commands/sessions-picker.js +107 -13
- package/dist/commands/sessions-resume.d.ts +2 -0
- package/dist/commands/sessions-resume.js +9 -1
- package/dist/commands/sessions.d.ts +10 -5
- package/dist/commands/sessions.js +65 -27
- package/dist/index.js +19 -3
- package/dist/lib/activity.d.ts +80 -13
- package/dist/lib/activity.js +418 -74
- package/dist/lib/beta.d.ts +1 -1
- package/dist/lib/beta.js +1 -1
- package/dist/lib/catchup.d.ts +105 -0
- package/dist/lib/catchup.js +160 -0
- package/dist/lib/channels/providers/desktop.d.ts +49 -0
- package/dist/lib/channels/providers/desktop.js +132 -0
- package/dist/lib/channels/providers/index.js +2 -0
- package/dist/lib/daemon.js +74 -13
- package/dist/lib/devices/registry.d.ts +14 -0
- package/dist/lib/devices/registry.js +37 -0
- package/dist/lib/events.d.ts +12 -0
- package/dist/lib/events.js +122 -9
- package/dist/lib/exec.js +10 -0
- package/dist/lib/feed-broadcast.d.ts +47 -0
- package/dist/lib/feed-broadcast.js +65 -1
- package/dist/lib/feed-post.d.ts +10 -0
- package/dist/lib/feed-post.js +9 -3
- package/dist/lib/feed.d.ts +47 -1
- package/dist/lib/feed.js +38 -0
- package/dist/lib/hooks/cache.d.ts +2 -0
- package/dist/lib/hooks/cache.js +24 -4
- package/dist/lib/hosts/remote-cmd.js +4 -0
- package/dist/lib/menubar/MenubarHelper.app/Contents/MacOS/MenubarHelper +0 -0
- package/dist/lib/menubar/install-menubar.d.ts +14 -4
- package/dist/lib/menubar/install-menubar.js +20 -6
- package/dist/lib/overdue.d.ts +14 -0
- package/dist/lib/overdue.js +37 -1
- package/dist/lib/perf/db.d.ts +25 -0
- package/dist/lib/perf/db.js +290 -0
- package/dist/lib/perf/spool.d.ts +18 -0
- package/dist/lib/perf/spool.js +79 -0
- package/dist/lib/perf/types.d.ts +45 -0
- package/dist/lib/perf/types.js +2 -0
- package/dist/lib/project-key.d.ts +44 -0
- package/dist/lib/project-key.js +79 -0
- package/dist/lib/project-root.js +16 -0
- package/dist/lib/project-status.d.ts +69 -0
- package/dist/lib/project-status.js +101 -0
- package/dist/lib/projects.d.ts +138 -0
- package/dist/lib/projects.js +301 -0
- package/dist/lib/remote-agents-json.d.ts +9 -0
- package/dist/lib/remote-agents-json.js +11 -5
- package/dist/lib/routines-project.js +6 -0
- package/dist/lib/routines.d.ts +30 -1
- package/dist/lib/routines.js +11 -0
- package/dist/lib/secrets/Agents CLI.app/Contents/CodeResources +0 -0
- package/dist/lib/secrets/Agents CLI.app/Contents/MacOS/Agents CLI +0 -0
- package/dist/lib/secrets/list-filter.d.ts +94 -0
- package/dist/lib/secrets/list-filter.js +245 -0
- package/dist/lib/session/bash-command.d.ts +53 -0
- package/dist/lib/session/bash-command.js +364 -0
- package/dist/lib/session/digest.d.ts +13 -0
- package/dist/lib/session/digest.js +48 -1
- package/dist/lib/session/discover.d.ts +1 -2
- package/dist/lib/session/discover.js +7 -24
- package/dist/lib/session/highlights.d.ts +82 -0
- package/dist/lib/session/highlights.js +251 -0
- package/dist/lib/session/parse.js +23 -1
- package/dist/lib/session/relative-time.d.ts +37 -0
- package/dist/lib/session/relative-time.js +96 -8
- package/dist/lib/session/remote-list.js +5 -2
- package/dist/lib/session/render.d.ts +9 -9
- package/dist/lib/session/render.js +112 -73
- package/dist/lib/session/types.d.ts +4 -1
- package/dist/lib/ssh-exec.d.ts +6 -0
- package/dist/lib/ssh-exec.js +10 -1
- package/dist/lib/startup/command-registry.d.ts +2 -0
- package/dist/lib/startup/command-registry.js +4 -0
- package/dist/lib/state.d.ts +11 -0
- package/dist/lib/state.js +16 -0
- package/dist/lib/terminal/backends/index.d.ts +10 -2
- package/dist/lib/terminal/backends/index.js +14 -2
- package/dist/lib/terminal/backends/terminal-app.d.ts +13 -0
- package/dist/lib/terminal/backends/terminal-app.js +73 -0
- package/dist/lib/terminal/index.d.ts +2 -1
- package/dist/lib/terminal/index.js +2 -1
- package/dist/lib/terminal/preferred.d.ts +89 -0
- package/dist/lib/terminal/preferred.js +87 -0
- package/dist/lib/terminal/run-surface.d.ts +82 -0
- package/dist/lib/terminal/run-surface.js +146 -0
- package/dist/lib/terminal/types.d.ts +1 -1
- package/dist/lib/types.d.ts +1 -1
- package/package.json +4 -1
|
@@ -36,6 +36,7 @@ export function registerSessionsResumeCommand(sessionsCmd) {
|
|
|
36
36
|
.option('--ghostty', 'Force the Ghostty backend')
|
|
37
37
|
.option('--tmux', 'Force the tmux backend')
|
|
38
38
|
.option('--vscodium', 'Force the VSCodium agent-terminal backend (swarm-ext)')
|
|
39
|
+
.option('--terminal-app', 'Force macOS Terminal.app (no split support — panes become tabs)')
|
|
39
40
|
.option('--splits', 'Pack two sessions side by side per tab (default: one tab per session)');
|
|
40
41
|
setHelpSections(cmd, {
|
|
41
42
|
examples: `
|
|
@@ -156,6 +157,12 @@ async function sessionsResumeAction(query, options) {
|
|
|
156
157
|
// recovery; callers can explicitly opt into pairs of side-by-side panes.
|
|
157
158
|
const packing = resolveResumePacking(options);
|
|
158
159
|
const where = options.host ? `${backend} on ${options.host}` : backend;
|
|
160
|
+
// Terminal.app has no scriptable split, so its buildSplit opens a tab. Say so
|
|
161
|
+
// when the user actually asked for panes — the layout silently not happening
|
|
162
|
+
// is worse than one line of warning.
|
|
163
|
+
if (backend === 'terminal' && packing === 'two-per-tab') {
|
|
164
|
+
console.log(chalk.yellow('Terminal.app cannot split panes — opening one tab per session instead.'));
|
|
165
|
+
}
|
|
159
166
|
console.log(chalk.gray(`Opening ${items.length} session${items.length === 1 ? '' : 's'} in ${where} (${packing})…`));
|
|
160
167
|
const results = await openSurfaces(items.map((it) => ({ cwd: it.cwd, command: it.command })), { backend, host: options.host, packing });
|
|
161
168
|
let opened = 0;
|
|
@@ -185,7 +192,8 @@ async function resolveBackend(options, ctx, count) {
|
|
|
185
192
|
: options.ghostty ? 'ghostty'
|
|
186
193
|
: options.tmux ? 'tmux'
|
|
187
194
|
: options.vscodium ? 'vscodium-agent'
|
|
188
|
-
:
|
|
195
|
+
: options.terminalApp ? 'terminal'
|
|
196
|
+
: undefined;
|
|
189
197
|
if (forced)
|
|
190
198
|
return forced;
|
|
191
199
|
// Remote defaults to tmux (headless, no GUI session assumptions); override with a backend flag.
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { type Command } from 'commander';
|
|
2
|
+
import { type ProjectDef } from '../lib/projects.js';
|
|
2
3
|
import type { SessionAgentId, SessionMeta, ViewMode } from '../lib/session/types.js';
|
|
3
4
|
import { type ActiveSession } from '../lib/session/active.js';
|
|
4
5
|
import { gatherRemoteList, runOnPeer } from '../lib/session/remote-list.js';
|
|
@@ -351,11 +352,15 @@ export declare function teamBadge(session: SessionMeta): {
|
|
|
351
352
|
* dashes and needlessly truncate the topic. Worktree stays a trailing badge. */
|
|
352
353
|
export declare function flatSessionRow(session: SessionMeta, live?: ActiveSession, showTicket?: boolean, cols?: PickerColumns, favorite?: boolean): string;
|
|
353
354
|
/**
|
|
354
|
-
* Group key for the overview:
|
|
355
|
-
*
|
|
356
|
-
*
|
|
355
|
+
* Group key for the overview: resolve the cwd through the same canonical
|
|
356
|
+
* resolver the `agents activity` timeline groups by — a defined project's name
|
|
357
|
+
* when `defs` contains the cwd (multi-repo projects read as one group), else
|
|
358
|
+
* the repo-level key, so a monorepo subdir (`<repo>/apps/cli`) reads as
|
|
359
|
+
* `<repo>` in both views. Falls back to the indexed project name (stamped at
|
|
360
|
+
* scan time) when the cwd carries nothing usable, e.g. a remote path this
|
|
361
|
+
* machine cannot see still folds by basename through the same resolver.
|
|
357
362
|
*/
|
|
358
|
-
export declare function overviewProjectKey(s: Pick<SessionMeta, 'project' | 'cwd'
|
|
363
|
+
export declare function overviewProjectKey(s: Pick<SessionMeta, 'project' | 'cwd'>, defs?: ProjectDef[]): string;
|
|
359
364
|
export interface OverviewGroup {
|
|
360
365
|
key: string;
|
|
361
366
|
total: number;
|
|
@@ -369,7 +374,7 @@ export interface OverviewGroup {
|
|
|
369
374
|
* are ordered by their most-recent session so the newest-active project leads.
|
|
370
375
|
* `perProjectCap = Infinity` expands every group. Pure — unit-tested.
|
|
371
376
|
*/
|
|
372
|
-
export declare function buildOverviewGroups(pool: SessionMeta[], perProjectCap: number): {
|
|
377
|
+
export declare function buildOverviewGroups(pool: SessionMeta[], perProjectCap: number, defs?: ProjectDef[]): {
|
|
373
378
|
groups: OverviewGroup[];
|
|
374
379
|
projectCount: number;
|
|
375
380
|
};
|
|
@@ -14,6 +14,8 @@ import { spawn } from 'child_process';
|
|
|
14
14
|
import { Option } from 'commander';
|
|
15
15
|
import chalk from 'chalk';
|
|
16
16
|
import { truncate, padRight } from '../lib/format.js';
|
|
17
|
+
import { resolveProjectKey } from '../lib/project-key.js';
|
|
18
|
+
import { listProjectDefs, resolveProjectNameForCwd } from '../lib/projects.js';
|
|
17
19
|
import ora from 'ora';
|
|
18
20
|
import { SESSION_AGENTS } from '../lib/session/types.js';
|
|
19
21
|
import { discoverArtifacts, readArtifact, resolveArtifact } from '../lib/session/artifacts.js';
|
|
@@ -32,7 +34,7 @@ import { findSessionsById, querySessions } from '../lib/session/db.js';
|
|
|
32
34
|
import { filterTeamSessions, safeTeamText } from '../lib/session/team-filter.js';
|
|
33
35
|
import { parseSession } from '../lib/session/parse.js';
|
|
34
36
|
import { runRemoteSessions, buildForwardedArgs, ensureWholeIndex } from '../lib/session/remote.js';
|
|
35
|
-
import { formatRelativeTime } from '../lib/session/relative-time.js';
|
|
37
|
+
import { formatRelativeTime, sessionAgeParts } from '../lib/session/relative-time.js';
|
|
36
38
|
import { renderConversationMarkdown, renderSummary, renderSummaryHeader, computeSummaryStats, renderJson, filterEvents, parseRoleList, linkPath, linkUrl, shortenModel } from '../lib/session/render.js';
|
|
37
39
|
import { linearIssueUrl } from '../lib/session/linear.js';
|
|
38
40
|
import { renderMarkdown } from '../lib/markdown.js';
|
|
@@ -1768,6 +1770,33 @@ function metaSignals(s) {
|
|
|
1768
1770
|
ticket: s.ticketId ? { id: s.ticketId } : undefined,
|
|
1769
1771
|
};
|
|
1770
1772
|
}
|
|
1773
|
+
/**
|
|
1774
|
+
* Narrowest topic a row is willing to render. The time cell drops its creation
|
|
1775
|
+
* field rather than squeeze the topic past this — a row that wraps is worse than
|
|
1776
|
+
* a row missing one field.
|
|
1777
|
+
*/
|
|
1778
|
+
const MIN_TOPIC_W = 16;
|
|
1779
|
+
/**
|
|
1780
|
+
* The trailing time cell, as two fields: `3d → 2 hours ago` — when the session
|
|
1781
|
+
* was created, then when it was last active. One label answers neither "is this
|
|
1782
|
+
* the old session I'm looking for" nor "how long did it run", since a session
|
|
1783
|
+
* touched an hour ago may have started last week.
|
|
1784
|
+
*
|
|
1785
|
+
* Collapses to last-activity alone when the session ran under a minute (both
|
|
1786
|
+
* halves would name the same moment) or when `topicSlack` — the columns the row
|
|
1787
|
+
* has left for its topic — cannot spare the extra width. `extraW` is what the
|
|
1788
|
+
* creation field cost, for the caller to take off the topic budget.
|
|
1789
|
+
*/
|
|
1790
|
+
function timeCell(age, topicSlack) {
|
|
1791
|
+
const lastOnly = { plain: age.last, text: chalk.gray(age.last), extraW: 0 };
|
|
1792
|
+
if (!age.created)
|
|
1793
|
+
return lastOnly;
|
|
1794
|
+
const prefix = `${age.created} → `;
|
|
1795
|
+
const extraW = stringWidth(prefix);
|
|
1796
|
+
if (topicSlack - extraW < MIN_TOPIC_W)
|
|
1797
|
+
return lastOnly;
|
|
1798
|
+
return { plain: prefix + age.last, text: chalk.dim(prefix) + chalk.gray(age.last), extraW };
|
|
1799
|
+
}
|
|
1771
1800
|
/** One flat table row:
|
|
1772
1801
|
* shortId · agent · version · model · project · [glyph] label·doing · [ticket] · [wt] · time
|
|
1773
1802
|
* `doing` is the live preview when running, else the topic. The `ticket` column
|
|
@@ -1776,7 +1805,7 @@ function metaSignals(s) {
|
|
|
1776
1805
|
* dashes and needlessly truncate the topic. Worktree stays a trailing badge. */
|
|
1777
1806
|
export function flatSessionRow(session, live, showTicket = false, cols = {}, favorite = false) {
|
|
1778
1807
|
const agentColor = colorAgent(session.agent);
|
|
1779
|
-
const
|
|
1808
|
+
const age = sessionAgeParts(session.timestamp, session.lastActivity);
|
|
1780
1809
|
const project = session.project || '-';
|
|
1781
1810
|
const tag = originTag(session) || teamTag(session);
|
|
1782
1811
|
const label = session.label;
|
|
@@ -1813,12 +1842,15 @@ export function flatSessionRow(session, live, showTicket = false, cols = {}, fav
|
|
|
1813
1842
|
// Same conditional 2 cells as the picker's marker, for the same reason.
|
|
1814
1843
|
const favW = cols.showFavorite ? 2 : 0;
|
|
1815
1844
|
const favCell = cols.showFavorite ? (favorite ? chalk.yellow('★ ') : ' ') : '';
|
|
1816
|
-
|
|
1817
|
-
|
|
1845
|
+
// Sized against the last-activity label alone, so the creation field is an
|
|
1846
|
+
// additive decision the row makes only once it knows what space is left.
|
|
1847
|
+
const fixedW = favW + (10 + 9 + 8 + 16) + glyphW + statusW + machineW + ticketW + wtW + team.width + stringWidth(age.last) + 1;
|
|
1848
|
+
const modelSlack = width - fixedW - MIN_TOPIC_W;
|
|
1818
1849
|
const modelW = requestedModelW <= modelSlack
|
|
1819
1850
|
? requestedModelW
|
|
1820
1851
|
: modelSlack >= PICKER_MODEL_MIN ? modelSlack : 0;
|
|
1821
|
-
const
|
|
1852
|
+
const when = timeCell(age, width - fixedW - modelW);
|
|
1853
|
+
const topicW = Math.max(MIN_TOPIC_W, width - fixedW - modelW - when.extraW);
|
|
1822
1854
|
return (favCell +
|
|
1823
1855
|
chalk.white(padToWidth(truncateToWidth(session.shortId, 9), 10)) +
|
|
1824
1856
|
agentColor(padToWidth(truncateToWidth(session.agent, 8), 9)) +
|
|
@@ -1832,12 +1864,12 @@ export function flatSessionRow(session, live, showTicket = false, cols = {}, fav
|
|
|
1832
1864
|
renderTopicCell(label, doing, '', topicW, topicW) +
|
|
1833
1865
|
ticketCell +
|
|
1834
1866
|
(wt ? wt + ' ' : '') +
|
|
1835
|
-
|
|
1867
|
+
when.text);
|
|
1836
1868
|
}
|
|
1837
1869
|
/** One tree-mode row (grouped under a dir header): id · agent · badges · topic · time. No version/project column. */
|
|
1838
1870
|
function treeSessionRow(session, live) {
|
|
1839
1871
|
const agentColor = colorAgent(session.agent);
|
|
1840
|
-
const
|
|
1872
|
+
const age = sessionAgeParts(session.timestamp, session.lastActivity);
|
|
1841
1873
|
const tag = originTag(session) || teamTag(session);
|
|
1842
1874
|
const label = session.label;
|
|
1843
1875
|
const { glyph, preview } = liveGlyphAndPreview(live);
|
|
@@ -1853,7 +1885,9 @@ function treeSessionRow(session, live) {
|
|
|
1853
1885
|
const head = label ? `${label} · ${topic}` : topic;
|
|
1854
1886
|
const { cell: statusCell, width: statusW } = liveStatusCell(live);
|
|
1855
1887
|
const glyphW = glyph ? 2 : 0;
|
|
1856
|
-
const
|
|
1888
|
+
const baseTopicW = terminalWidth() - (2 + 9 + 8) - glyphW - statusW - badgeW - team.width - stringWidth(age.last) - 1;
|
|
1889
|
+
const when = timeCell(age, baseTopicW);
|
|
1890
|
+
const topicW = Math.max(12, baseTopicW - when.extraW);
|
|
1857
1891
|
return (' ' +
|
|
1858
1892
|
chalk.dim(padToWidth(session.shortId, 9)) +
|
|
1859
1893
|
agentColor(padToWidth(truncateToWidth(session.agent, 7), 8)) +
|
|
@@ -1862,7 +1896,7 @@ function treeSessionRow(session, live) {
|
|
|
1862
1896
|
statusCell +
|
|
1863
1897
|
teamSeg +
|
|
1864
1898
|
padToWidth(chalk.white(truncateToWidth(head, topicW)), topicW) +
|
|
1865
|
-
' ' +
|
|
1899
|
+
' ' + when.text);
|
|
1866
1900
|
}
|
|
1867
1901
|
/**
|
|
1868
1902
|
* Live-session index for enriching the default listing, or undefined when
|
|
@@ -1884,21 +1918,21 @@ async function maybeLiveIndex(options) {
|
|
|
1884
1918
|
}
|
|
1885
1919
|
}
|
|
1886
1920
|
/**
|
|
1887
|
-
* Group key for the overview:
|
|
1888
|
-
*
|
|
1889
|
-
*
|
|
1921
|
+
* Group key for the overview: resolve the cwd through the same canonical
|
|
1922
|
+
* resolver the `agents activity` timeline groups by — a defined project's name
|
|
1923
|
+
* when `defs` contains the cwd (multi-repo projects read as one group), else
|
|
1924
|
+
* the repo-level key, so a monorepo subdir (`<repo>/apps/cli`) reads as
|
|
1925
|
+
* `<repo>` in both views. Falls back to the indexed project name (stamped at
|
|
1926
|
+
* scan time) when the cwd carries nothing usable, e.g. a remote path this
|
|
1927
|
+
* machine cannot see still folds by basename through the same resolver.
|
|
1890
1928
|
*/
|
|
1891
|
-
export function overviewProjectKey(s) {
|
|
1929
|
+
export function overviewProjectKey(s, defs) {
|
|
1930
|
+
const resolved = defs?.length ? resolveProjectNameForCwd(s.cwd, defs) : resolveProjectKey(s.cwd);
|
|
1931
|
+
if (resolved)
|
|
1932
|
+
return resolved;
|
|
1892
1933
|
if (s.project && s.project.trim())
|
|
1893
1934
|
return s.project.trim();
|
|
1894
|
-
|
|
1895
|
-
if (!cwd)
|
|
1896
|
-
return '(no project)';
|
|
1897
|
-
const wt = cwd.match(/\/([^/]+)\/\.agents\/worktrees\//);
|
|
1898
|
-
if (wt)
|
|
1899
|
-
return wt[1];
|
|
1900
|
-
const parts = cwd.split('/');
|
|
1901
|
-
return parts[parts.length - 1] || cwd;
|
|
1935
|
+
return '(no project)';
|
|
1902
1936
|
}
|
|
1903
1937
|
/**
|
|
1904
1938
|
* Turn a recency-descending pool into project groups: each group shows its
|
|
@@ -1906,10 +1940,10 @@ export function overviewProjectKey(s) {
|
|
|
1906
1940
|
* are ordered by their most-recent session so the newest-active project leads.
|
|
1907
1941
|
* `perProjectCap = Infinity` expands every group. Pure — unit-tested.
|
|
1908
1942
|
*/
|
|
1909
|
-
export function buildOverviewGroups(pool, perProjectCap) {
|
|
1943
|
+
export function buildOverviewGroups(pool, perProjectCap, defs) {
|
|
1910
1944
|
const byKey = new Map();
|
|
1911
1945
|
for (const s of pool) {
|
|
1912
|
-
const k = overviewProjectKey(s);
|
|
1946
|
+
const k = overviewProjectKey(s, defs);
|
|
1913
1947
|
(byKey.get(k) ?? byKey.set(k, []).get(k)).push(s);
|
|
1914
1948
|
}
|
|
1915
1949
|
const cap = Math.max(1, perProjectCap);
|
|
@@ -1927,7 +1961,7 @@ export function buildOverviewGroups(pool, perProjectCap) {
|
|
|
1927
1961
|
* `· N more` per project and a `+N more projects` when the list is capped.
|
|
1928
1962
|
*/
|
|
1929
1963
|
function printSessionOverview(pool, hiddenCount, liveIndex, opts) {
|
|
1930
|
-
const { groups } = buildOverviewGroups(pool, opts.expand ? Infinity : opts.perProjectCap);
|
|
1964
|
+
const { groups } = buildOverviewGroups(pool, opts.expand ? Infinity : opts.perProjectCap, listProjectDefs());
|
|
1931
1965
|
const shownGroups = opts.expand ? groups : groups.slice(0, OVERVIEW_MAX_PROJECTS);
|
|
1932
1966
|
const hiddenProjects = groups.length - shownGroups.length;
|
|
1933
1967
|
const total = pool.length;
|
|
@@ -2252,7 +2286,7 @@ export function liveHostLabel(a) {
|
|
|
2252
2286
|
}
|
|
2253
2287
|
export function formatPickerLabel(s, query, cols = {}, ssh, host = '', favorite = false, live) {
|
|
2254
2288
|
const agentColor = colorAgent(s.agent);
|
|
2255
|
-
const
|
|
2289
|
+
const age = sessionAgeParts(s.timestamp, s.lastActivity);
|
|
2256
2290
|
const project = s.project || '-';
|
|
2257
2291
|
// SSH-launch origin (live rows only): mirrors the flat listing's `ssh←<device>`
|
|
2258
2292
|
// badge. Rendered as its OWN red segment before the topic cell — folding it into
|
|
@@ -2306,7 +2340,11 @@ export function formatPickerLabel(s, query, cols = {}, ssh, host = '', favorite
|
|
|
2306
2340
|
const status = cols.showStatus ? liveStatusCell(live) : { cell: '', width: 0 };
|
|
2307
2341
|
const statusW = cols.showStatus ? LIVE_STATUS_W : 0;
|
|
2308
2342
|
const statusCell = cols.showStatus ? (status.cell || ' '.repeat(LIVE_STATUS_W)) : '';
|
|
2309
|
-
|
|
2343
|
+
// Sized against the last-activity label alone; the creation field is then an
|
|
2344
|
+
// additive decision made against whatever width is left (see the flat listing).
|
|
2345
|
+
const baseTopicW = terminalWidth() - gutter - favW - statusW - (10 + 9 + 8 + 16) - machineColW - hostW - ticketW - wtW - sshW - team.width - stringWidth(age.last) - 1;
|
|
2346
|
+
const when = timeCell(age, baseTopicW);
|
|
2347
|
+
const topicW = Math.max(MIN_TOPIC_W, baseTopicW - when.extraW);
|
|
2310
2348
|
return (favCell +
|
|
2311
2349
|
// Truncated, not just padded: an indexed shortId is always 8 chars, but a
|
|
2312
2350
|
// live row with no session id is named by its pid or cloud task, which can
|
|
@@ -2323,7 +2361,7 @@ export function formatPickerLabel(s, query, cols = {}, ssh, host = '', favorite
|
|
|
2323
2361
|
renderTopicCell(label, topic, query, topicW, topicW) +
|
|
2324
2362
|
ticketCell +
|
|
2325
2363
|
(wt ? wt + ' ' : '') +
|
|
2326
|
-
|
|
2364
|
+
when.text);
|
|
2327
2365
|
}
|
|
2328
2366
|
/** Hints rotated above the picker so the flags/features stay discoverable. */
|
|
2329
2367
|
const PICKER_TIPS = [
|
package/dist/index.js
CHANGED
|
@@ -94,7 +94,7 @@ if (IS_DEV_BUILD) {
|
|
|
94
94
|
// module on each invocation (which loaded the whole ~50-module tree before the
|
|
95
95
|
// first byte of output), the registry maps a command name to a thunk that
|
|
96
96
|
// imports only what that command needs. See src/lib/startup/command-registry.ts.
|
|
97
|
-
import { COMMAND_LOADERS, LAZY_COMMAND_NAMES, loadView, loadInspect, loadFeedback, loadCommands, loadHooks, loadSkills, loadRules, loadMemory, loadPermissions, loadMcp, loadCli, loadSubagents, loadPlugins, loadWorkflows, loadWorktree, loadVersions, loadImport, loadExport, loadPackages, loadRoutines, loadMonitors, loadRun, loadFork, loadDefaults, loadSet, loadModels, loadPrune, loadTrash, loadRestore, loadDoctor, loadApply, loadStatus, loadProfiles, loadHarness, loadSecrets, loadLogin, loadWallet, loadHelper, loadMenubar, loadBeta, loadSync, loadLock, loadRefreshRules, loadDrive, loadFactory, loadUsage, loadCost, loadOutput, loadBudget, loadAlias, loadMine, loadPty, loadTmux, loadWatchdog, loadBrowser, loadComputer, loadHosts, loadLogs, loadEvents, loadAudit, loadWebhook, loadFunnel, loadSsh, loadPull, loadPush, loadRepo, loadSetup, loadUninstall, loadShare, loadSend, loadHq, loadFeed, loadActivity, loadMailboxes, } from './lib/startup/command-registry.js';
|
|
97
|
+
import { COMMAND_LOADERS, LAZY_COMMAND_NAMES, loadView, loadInspect, loadFeedback, loadCommands, loadHooks, loadSkills, loadRules, loadMemory, loadPermissions, loadMcp, loadCli, loadSubagents, loadPlugins, loadWorkflows, loadWorktree, loadVersions, loadImport, loadExport, loadPackages, loadRoutines, loadMonitors, loadProjects, loadRun, loadFork, loadDefaults, loadSet, loadModels, loadPrune, loadTrash, loadRestore, loadDoctor, loadApply, loadStatus, loadProfiles, loadHarness, loadSecrets, loadLogin, loadWallet, loadHelper, loadMenubar, loadBeta, loadSync, loadLock, loadRefreshRules, loadDrive, loadFactory, loadUsage, loadCost, loadPerf, loadOutput, loadBudget, loadAlias, loadMine, loadPty, loadTmux, loadWatchdog, loadBrowser, loadComputer, loadHosts, loadLogs, loadEvents, loadAudit, loadWebhook, loadFunnel, loadSsh, loadPull, loadPush, loadRepo, loadSetup, loadUninstall, loadShare, loadSend, loadHq, loadFeed, loadActivity, loadMailboxes, } from './lib/startup/command-registry.js';
|
|
98
98
|
import { applyGlobalHelpConventions } from './lib/help.js';
|
|
99
99
|
import { renderWhatsNew } from './lib/whats-new.js';
|
|
100
100
|
import { getCliLaunch } from './lib/cli-entry.js';
|
|
@@ -173,11 +173,24 @@ program.hook('postAction', (_thisCommand, actionCommand) => {
|
|
|
173
173
|
if (parts.length === 0)
|
|
174
174
|
return;
|
|
175
175
|
const started = auditStarts.get(actionCommand);
|
|
176
|
+
const durationMs = started !== undefined ? Date.now() - started : undefined;
|
|
177
|
+
const command = parts.join(' ');
|
|
176
178
|
emit('command.end', {
|
|
177
179
|
module: parts[0],
|
|
178
|
-
command
|
|
179
|
-
...(
|
|
180
|
+
command,
|
|
181
|
+
...(durationMs !== undefined ? { durationMs } : {}),
|
|
180
182
|
});
|
|
183
|
+
// Disposable perf warehouse — fail-soft spool append (no SQLite on this path).
|
|
184
|
+
if (durationMs !== undefined && parts[0] !== 'perf') {
|
|
185
|
+
void import('./lib/perf/spool.js').then(({ recordSample }) => {
|
|
186
|
+
recordSample({
|
|
187
|
+
kind: 'command.end',
|
|
188
|
+
label: command,
|
|
189
|
+
durationMs,
|
|
190
|
+
cwd: process.cwd(),
|
|
191
|
+
});
|
|
192
|
+
}).catch(() => { });
|
|
193
|
+
}
|
|
181
194
|
}
|
|
182
195
|
catch {
|
|
183
196
|
// Best-effort completion record; the start line is the durable audit fact.
|
|
@@ -269,6 +282,7 @@ Credentials and profiles:
|
|
|
269
282
|
Diagnostics:
|
|
270
283
|
doctor [agent[@version]] Diagnose CLI availability, sync status, and resource divergence; --check for the CI drift gate
|
|
271
284
|
usage [agent] Show rate-limit and quota usage per agent
|
|
285
|
+
perf Latency rollups (hooks, commands, runs) from the disposable perf warehouse
|
|
272
286
|
|
|
273
287
|
Config sync:
|
|
274
288
|
drive Sync session history across machines via rsync
|
|
@@ -857,6 +871,7 @@ async function registerAllEagerCommands() {
|
|
|
857
871
|
await reg(loadPackages);
|
|
858
872
|
await reg(loadRoutines);
|
|
859
873
|
await reg(loadMonitors);
|
|
874
|
+
await reg(loadProjects);
|
|
860
875
|
await reg(loadRun);
|
|
861
876
|
await reg(loadFork);
|
|
862
877
|
await reg(loadDefaults);
|
|
@@ -885,6 +900,7 @@ async function registerAllEagerCommands() {
|
|
|
885
900
|
await reg(loadFactory);
|
|
886
901
|
await reg(loadUsage);
|
|
887
902
|
await reg(loadCost);
|
|
903
|
+
await reg(loadPerf);
|
|
888
904
|
await reg(loadOutput);
|
|
889
905
|
await reg(loadBudget);
|
|
890
906
|
await reg(loadAlias);
|
package/dist/lib/activity.d.ts
CHANGED
|
@@ -1,10 +1,22 @@
|
|
|
1
1
|
import type { EventRecord } from './events.js';
|
|
2
2
|
/** Recognizable milestone events, ordered first in any activity lane. */
|
|
3
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
|
+
/** Bash-driven deliverables detected by command parsing. */
|
|
5
|
+
| 'video.rendered' | 'video.converted' | 'image.upscaled' | 'metadata.edited'
|
|
4
6
|
/** Deliberate agent-authored progress post (`agents feed post`). */
|
|
5
|
-
| 'status.posted'
|
|
7
|
+
| 'status.posted'
|
|
8
|
+
/**
|
|
9
|
+
* The same post, but the agent is STUCK (`agents feed post --blocked`).
|
|
10
|
+
*
|
|
11
|
+
* A distinct event rather than a flag on `status.posted` because it is a
|
|
12
|
+
* different kind of thing in the stream: a benign update is history the
|
|
13
|
+
* moment it lands, while a blocked post stays open until someone answers it.
|
|
14
|
+
* Readers that show "what needs a human" select on this; readers that show
|
|
15
|
+
* "what happened" get both.
|
|
16
|
+
*/
|
|
17
|
+
| 'status.blocked';
|
|
6
18
|
/** Routine activity events, collapsed to counts by readers. */
|
|
7
|
-
export type ActivityKind = 'file.edited';
|
|
19
|
+
export type ActivityKind = 'file.edited' | 'bash.executed';
|
|
8
20
|
export type ActivityEventKind = MilestoneEvent | ActivityKind;
|
|
9
21
|
export type ActivityTier = 'milestone' | 'activity';
|
|
10
22
|
/** Well-known attachment kinds; the field is an open string, not an enum. */
|
|
@@ -74,6 +86,10 @@ export interface ActivityEvent {
|
|
|
74
86
|
terminalId?: string;
|
|
75
87
|
/** `$TMUX_PANE` at launch when recorded. */
|
|
76
88
|
tmuxPane?: string;
|
|
89
|
+
/** Bash command taxonomy (set for bash.executed events). */
|
|
90
|
+
category?: string;
|
|
91
|
+
bashTool?: string;
|
|
92
|
+
bashAction?: string;
|
|
77
93
|
/** Generic artifacts attached to a deliberate progress post. */
|
|
78
94
|
attachments?: Attachment[];
|
|
79
95
|
}
|
|
@@ -213,19 +229,29 @@ export interface ActivitySessionHint {
|
|
|
213
229
|
project?: string | null;
|
|
214
230
|
}
|
|
215
231
|
/**
|
|
216
|
-
* Resolve a stable project/repo name from a working directory
|
|
217
|
-
*
|
|
218
|
-
*
|
|
219
|
-
* basename. Pure — no filesystem access, so it works for remote events too.
|
|
232
|
+
* Resolve a stable project/repo name from a working directory — the same fold
|
|
233
|
+
* the `agents sessions` overview groups by ({@link projectKeyFromCwd}), so a
|
|
234
|
+
* project reads identically in both views.
|
|
220
235
|
*/
|
|
221
236
|
export declare function projectFromCwd(cwd?: string | null): string | undefined;
|
|
222
237
|
/**
|
|
223
238
|
* Join session facts (ticket / project / execution host) onto each event by
|
|
224
|
-
* `sessionId`. A hint wins; otherwise
|
|
225
|
-
*
|
|
226
|
-
*
|
|
239
|
+
* `sessionId`. A hint wins; otherwise a CANONICAL def match (`canonicalProject`)
|
|
240
|
+
* upgrades whatever the event carries; otherwise pre-baked enriched fields (from
|
|
241
|
+
* a remote peer that already enriched its own stream) are preserved, and
|
|
242
|
+
* project/host fall back to what the event itself carries (`cwd`, `host`).
|
|
243
|
+
*
|
|
244
|
+
* The def match ranks above the pre-baked stamp because the stamp may be stale
|
|
245
|
+
* (posted before the def existed) or skewed (a peer whose defs haven't synced);
|
|
246
|
+
* the match is pure prefix compare against local defs, so a different-home
|
|
247
|
+
* peer's path simply doesn't match and its stamp is honored as before.
|
|
248
|
+
*
|
|
249
|
+
* `resolveProject` is how a caller reading its OWN machine's logs upgrades the
|
|
250
|
+
* cwd fold to real repository detection ({@link resolveProjectKey}); it defaults
|
|
251
|
+
* to the pure {@link projectFromCwd}, which is all a path from another machine
|
|
252
|
+
* can be resolved with. Pure given pure resolvers.
|
|
227
253
|
*/
|
|
228
|
-
export declare function enrichActivityEvents(events: EnrichedActivityEvent[], hints: ActivitySessionHint[]): EnrichedActivityEvent[];
|
|
254
|
+
export declare function enrichActivityEvents(events: EnrichedActivityEvent[], hints: ActivitySessionHint[], resolveProject?: (cwd?: string | null) => string | undefined, canonicalProject?: (cwd?: string | null) => string | undefined): EnrichedActivityEvent[];
|
|
229
255
|
/**
|
|
230
256
|
* Validate + host-tag one peer's `activity --json` payload for the fan-out
|
|
231
257
|
* merge. Mirrors {@link parseLine}: skip anything missing `event`/`sessionId`/
|
|
@@ -241,6 +267,22 @@ export declare function parseActivityPayload(stdout: string, machine: string): E
|
|
|
241
267
|
* `mergeFeedBlocks`).
|
|
242
268
|
*/
|
|
243
269
|
export declare function mergeActivityEvents(...groups: EnrichedActivityEvent[][]): EnrichedActivityEvent[];
|
|
270
|
+
/**
|
|
271
|
+
* Apply `--limit` to the events a reader will actually SHOW.
|
|
272
|
+
*
|
|
273
|
+
* The default view collapses routine work (`file.edited`) to a count and shows
|
|
274
|
+
* milestones individually, so a plain `slice(limit)` spends the whole budget on
|
|
275
|
+
* churn: one busy machine editing 40 files hid every other device's PRs behind
|
|
276
|
+
* a single `file edited ×40` line. Milestones therefore carry the cap, and the
|
|
277
|
+
* routine events that ride along are the ones newer than the last milestone
|
|
278
|
+
* kept — so the collapsed counts describe exactly the window on screen.
|
|
279
|
+
*
|
|
280
|
+
* With `all` (routine shown inline) every event is displayed, so the cap is the
|
|
281
|
+
* plain slice again. Input must be newest-first; output preserves that order.
|
|
282
|
+
*/
|
|
283
|
+
export declare function capActivityEvents(events: EnrichedActivityEvent[], limit: number, opts?: {
|
|
284
|
+
all?: boolean;
|
|
285
|
+
}): EnrichedActivityEvent[];
|
|
244
286
|
export type ActivityGroupBy = 'project' | 'device' | 'agent';
|
|
245
287
|
/** One bucket of the grouped view. */
|
|
246
288
|
export interface ActivityGroup {
|
|
@@ -267,14 +309,39 @@ export declare function groupActivity(events: EnrichedActivityEvent[], by: Activ
|
|
|
267
309
|
* no-op. Pure.
|
|
268
310
|
*/
|
|
269
311
|
export declare function filterActivityEvents(events: EnrichedActivityEvent[], filter: string): EnrichedActivityEvent[];
|
|
312
|
+
/**
|
|
313
|
+
* Narrow events to one resolved project, exact match on the enriched label
|
|
314
|
+
* (unlike {@link filterActivityEvents}'s cross-field substring). This is the
|
|
315
|
+
* `--project` flag: the label is canonical (a defined project's name) once the
|
|
316
|
+
* reader's resolver has run, so a multi-repo project matches as one bucket. Pure.
|
|
317
|
+
*/
|
|
318
|
+
export declare function filterActivityByProject(events: EnrichedActivityEvent[], project: string): EnrichedActivityEvent[];
|
|
270
319
|
/** One rendered enriched line: the base activity line + `· project · ticket` tags. */
|
|
271
320
|
export declare function formatEnrichedActivityLine(ev: EnrichedActivityEvent, opts?: {
|
|
272
321
|
showHost?: boolean;
|
|
273
322
|
showProject?: boolean;
|
|
274
323
|
indent?: string;
|
|
275
324
|
}): string;
|
|
276
|
-
/**
|
|
277
|
-
export declare
|
|
325
|
+
/** Max device names named in a group header before the rest collapse to `+N`. */
|
|
326
|
+
export declare const GROUP_HEADER_DEVICE_LIMIT = 3;
|
|
327
|
+
/**
|
|
328
|
+
* The distinct machines a group's events ran on, most-active first then
|
|
329
|
+
* alphabetical — so a project header can say WHERE the work happened without
|
|
330
|
+
* sub-grouping the timeline. Prefers the session-joined `executionHost` (where
|
|
331
|
+
* the process really lives) over the raw emitting `host`. Pure.
|
|
332
|
+
*/
|
|
333
|
+
export declare function activityGroupDevices(events: EnrichedActivityEvent[]): string[];
|
|
334
|
+
/**
|
|
335
|
+
* The trailing facts for one grouped bucket: `N events · M milestones`, plus
|
|
336
|
+
* the machines the work ran on when `showDevices` is set (redundant when the
|
|
337
|
+
* grouping dimension IS the device, so the caller decides). The device list is
|
|
338
|
+
* capped at {@link GROUP_HEADER_DEVICE_LIMIT} with a `+N` tail, so a project
|
|
339
|
+
* touched by a dozen boxes stays one scannable line. Separate from the label so
|
|
340
|
+
* a renderer can color the two differently without re-splitting a string.
|
|
341
|
+
*/
|
|
342
|
+
export declare function formatActivityGroupMeta(group: ActivityGroup, opts?: {
|
|
343
|
+
showDevices?: boolean;
|
|
344
|
+
}): string;
|
|
278
345
|
/**
|
|
279
346
|
* The activity-log hook (Python), sibling to 10-feed-publish.py. Classifies
|
|
280
347
|
* PreToolUse/PostToolUse payloads into activity events and appends one JSONL
|
|
@@ -285,7 +352,7 @@ export declare function formatActivityGroupHeader(group: ActivityGroup): string;
|
|
|
285
352
|
* read-only tools never pay the hook cost. Fail-open: any error is swallowed so
|
|
286
353
|
* a logging hiccup never blocks a tool call.
|
|
287
354
|
*/
|
|
288
|
-
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";
|
|
355
|
+
export declare const ACTIVITY_LOG_HOOK_SCRIPT: string;
|
|
289
356
|
/** Hook manifest entries (agents.yaml shape) for the activity-log hook. */
|
|
290
357
|
export declare const ACTIVITY_HOOK_DEFINITIONS: Record<string, Record<string, unknown>>;
|
|
291
358
|
/**
|