@phnx-labs/agents-cli 1.20.91 → 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 +155 -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/projects.d.ts +12 -0
- package/dist/commands/projects.js +358 -0
- package/dist/commands/sessions-picker.d.ts +15 -0
- package/dist/commands/sessions-picker.js +37 -12
- 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 +2 -1
- package/dist/lib/activity.d.ts +69 -12
- package/dist/lib/activity.js +417 -74
- package/dist/lib/beta.d.ts +1 -1
- package/dist/lib/beta.js +1 -1
- package/dist/lib/devices/registry.d.ts +14 -0
- package/dist/lib/devices/registry.js +37 -0
- package/dist/lib/feed-post.js +8 -2
- 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/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/secrets/Agents CLI.app/Contents/CodeResources +0 -0
- package/dist/lib/secrets/Agents CLI.app/Contents/MacOS/Agents CLI +0 -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 +6 -0
- package/dist/lib/session/digest.js +19 -0
- package/dist/lib/session/relative-time.d.ts +23 -0
- package/dist/lib/session/relative-time.js +60 -8
- package/dist/lib/session/remote-list.js +5 -2
- package/dist/lib/session/render.d.ts +2 -9
- package/dist/lib/session/render.js +25 -56
- 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 +1 -0
- package/dist/lib/startup/command-registry.js +2 -0
- package/dist/lib/state.d.ts +2 -0
- package/dist/lib/state.js +5 -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 +2 -1
|
@@ -0,0 +1,364 @@
|
|
|
1
|
+
import { split as shlexSplit } from 'shlex';
|
|
2
|
+
const TOOL_REGISTRY = {
|
|
3
|
+
// VCS
|
|
4
|
+
git: { category: 'vcs', signal: 'mid', action: 'working in git' },
|
|
5
|
+
gh: { category: 'vcs', signal: 'mid', action: 'using GitHub CLI' },
|
|
6
|
+
// Build / test
|
|
7
|
+
bun: { category: 'build-test', signal: 'mid', action: 'running bun' },
|
|
8
|
+
npm: { category: 'build-test', signal: 'mid', action: 'running npm' },
|
|
9
|
+
pnpm: { category: 'build-test', signal: 'mid', action: 'running pnpm' },
|
|
10
|
+
yarn: { category: 'build-test', signal: 'mid', action: 'running yarn' },
|
|
11
|
+
vitest: { category: 'build-test', signal: 'mid', action: 'running vitest' },
|
|
12
|
+
jest: { category: 'build-test', signal: 'mid', action: 'running jest' },
|
|
13
|
+
mocha: { category: 'build-test', signal: 'mid', action: 'running mocha' },
|
|
14
|
+
pytest: { category: 'build-test', signal: 'mid', action: 'running pytest' },
|
|
15
|
+
cargo: { category: 'build-test', signal: 'mid', action: 'running cargo' },
|
|
16
|
+
go: { category: 'build-test', signal: 'mid', action: 'running go' },
|
|
17
|
+
tsc: { category: 'build-test', signal: 'mid', action: 'running tsc' },
|
|
18
|
+
tsx: { category: 'build-test', signal: 'mid', action: 'running tsx' },
|
|
19
|
+
node: { category: 'build-test', signal: 'low', action: 'running node' },
|
|
20
|
+
python: { category: 'build-test', signal: 'low', action: 'running python', aliases: ['python3'] },
|
|
21
|
+
make: { category: 'build-test', signal: 'mid', action: 'running make' },
|
|
22
|
+
// Install
|
|
23
|
+
brew: { category: 'install', signal: 'mid', action: 'installing with brew' },
|
|
24
|
+
pip: { category: 'install', signal: 'mid', action: 'installing with pip', aliases: ['pip3'] },
|
|
25
|
+
apt: { category: 'install', signal: 'mid', action: 'installing with apt' },
|
|
26
|
+
apk: { category: 'install', signal: 'mid', action: 'installing with apk' },
|
|
27
|
+
// Remote
|
|
28
|
+
ssh: { category: 'remote', signal: 'mid', action: 'using ssh' },
|
|
29
|
+
scp: { category: 'remote', signal: 'mid', action: 'using scp' },
|
|
30
|
+
rsync: { category: 'remote', signal: 'mid', action: 'using rsync' },
|
|
31
|
+
// HTTP
|
|
32
|
+
curl: { category: 'http', signal: 'mid', action: 'fetching with curl' },
|
|
33
|
+
wget: { category: 'http', signal: 'mid', action: 'fetching with wget' },
|
|
34
|
+
// Media
|
|
35
|
+
ffmpeg: { category: 'media', signal: 'high', action: 'using ffmpeg' },
|
|
36
|
+
ffprobe: { category: 'media', signal: 'mid', action: 'probing media' },
|
|
37
|
+
magick: { category: 'media', signal: 'mid', action: 'using ImageMagick' },
|
|
38
|
+
convert: { category: 'media', signal: 'mid', action: 'converting images' },
|
|
39
|
+
composite: { category: 'media', signal: 'mid', action: 'compositing images' },
|
|
40
|
+
montage: { category: 'media', signal: 'mid', action: 'montaging images' },
|
|
41
|
+
identify: { category: 'media', signal: 'mid', action: 'identifying images' },
|
|
42
|
+
// Upscaling
|
|
43
|
+
realesrgan: {
|
|
44
|
+
category: 'upscaling',
|
|
45
|
+
signal: 'high',
|
|
46
|
+
action: 'upscaling with realesrgan',
|
|
47
|
+
aliases: ['realesrgan-ncnn-vulkan'],
|
|
48
|
+
},
|
|
49
|
+
waifu2x: {
|
|
50
|
+
category: 'upscaling',
|
|
51
|
+
signal: 'high',
|
|
52
|
+
action: 'upscaling with waifu2x',
|
|
53
|
+
aliases: ['waifu2x-caffe', 'waifu2x-converter-cpp'],
|
|
54
|
+
},
|
|
55
|
+
swin2sr: { category: 'upscaling', signal: 'high', action: 'upscaling with swin2sr' },
|
|
56
|
+
resdet: { category: 'upscaling', signal: 'mid', action: 'detecting upscale' },
|
|
57
|
+
// Metadata
|
|
58
|
+
id3v2: { category: 'metadata', signal: 'mid', action: 'editing id3 tags' },
|
|
59
|
+
exiftool: { category: 'metadata', signal: 'mid', action: 'editing exif metadata' },
|
|
60
|
+
metaflac: { category: 'metadata', signal: 'mid', action: 'editing flac metadata' },
|
|
61
|
+
vorbiscomment: { category: 'metadata', signal: 'mid', action: 'editing vorbis comments' },
|
|
62
|
+
// Shell
|
|
63
|
+
rm: { category: 'shell', signal: 'low', action: 'removing files' },
|
|
64
|
+
mv: { category: 'shell', signal: 'low', action: 'moving files' },
|
|
65
|
+
cp: { category: 'shell', signal: 'low', action: 'copying files' },
|
|
66
|
+
mkdir: { category: 'shell', signal: 'low', action: 'making directories' },
|
|
67
|
+
touch: { category: 'shell', signal: 'low', action: 'touching files' },
|
|
68
|
+
echo: { category: 'shell', signal: 'low', action: 'echoing' },
|
|
69
|
+
printf: { category: 'shell', signal: 'low', action: 'printing' },
|
|
70
|
+
chmod: { category: 'shell', signal: 'low', action: 'changing permissions' },
|
|
71
|
+
ln: { category: 'shell', signal: 'low', action: 'linking files' },
|
|
72
|
+
awk: { category: 'shell', signal: 'low', action: 'running awk' },
|
|
73
|
+
sed: { category: 'shell', signal: 'low', action: 'running sed' },
|
|
74
|
+
tee: { category: 'shell', signal: 'low', action: 'teeing output' },
|
|
75
|
+
xargs: { category: 'shell', signal: 'low', action: 'running xargs' },
|
|
76
|
+
// Probes
|
|
77
|
+
ls: { category: 'probe', signal: 'low', action: 'listing files' },
|
|
78
|
+
cat: { category: 'probe', signal: 'low', action: 'reading files' },
|
|
79
|
+
head: { category: 'probe', signal: 'low', action: 'reading files' },
|
|
80
|
+
tail: { category: 'probe', signal: 'low', action: 'reading files' },
|
|
81
|
+
wc: { category: 'probe', signal: 'low', action: 'counting' },
|
|
82
|
+
stat: { category: 'probe', signal: 'low', action: 'statting files' },
|
|
83
|
+
file: { category: 'probe', signal: 'low', action: 'inspecting files' },
|
|
84
|
+
which: { category: 'probe', signal: 'low', action: 'locating binaries' },
|
|
85
|
+
tree: { category: 'probe', signal: 'low', action: 'listing files' },
|
|
86
|
+
pwd: { category: 'probe', signal: 'low', action: 'printing pwd' },
|
|
87
|
+
// Search
|
|
88
|
+
grep: { category: 'search', signal: 'low', action: 'searching' },
|
|
89
|
+
rg: { category: 'search', signal: 'low', action: 'searching with ripgrep' },
|
|
90
|
+
ag: { category: 'search', signal: 'low', action: 'searching with the silver searcher' },
|
|
91
|
+
fd: { category: 'search', signal: 'low', action: 'searching files' },
|
|
92
|
+
find: { category: 'search', signal: 'low', action: 'finding files' },
|
|
93
|
+
// Wait
|
|
94
|
+
sleep: { category: 'wait', signal: 'low', action: 'sleeping' },
|
|
95
|
+
wait: { category: 'wait', signal: 'low', action: 'waiting' },
|
|
96
|
+
};
|
|
97
|
+
/**
|
|
98
|
+
* Two-level tools (bucket key includes the subcommand) mapped to the flags that
|
|
99
|
+
* consume the *following* token as their value, per tool. The subcommand scan
|
|
100
|
+
* skips both a value flag and its argument, so `git -C /repo commit` → `commit`
|
|
101
|
+
* and `kubectl -n prod get` → `get`. Missing a value flag mis-reads the value as
|
|
102
|
+
* the subcommand; over-listing one only drops the subcommand (a safe tool-only
|
|
103
|
+
* bucket), so err toward listing. A tool with no such leading flags maps to an
|
|
104
|
+
* empty set. TWO_LEVEL_TOOLS is derived from these keys so the two never drift.
|
|
105
|
+
*/
|
|
106
|
+
const VALUE_FLAGS = {
|
|
107
|
+
git: new Set(['-C', '-c', '--git-dir', '--work-tree']),
|
|
108
|
+
gh: new Set(['-R', '--repo']),
|
|
109
|
+
bun: new Set(['--cwd']),
|
|
110
|
+
npm: new Set(['--prefix', '-w', '--workspace']),
|
|
111
|
+
pnpm: new Set(['--filter', '-C', '--dir']),
|
|
112
|
+
yarn: new Set(['--cwd']),
|
|
113
|
+
cargo: new Set(['--manifest-path']),
|
|
114
|
+
docker: new Set(['-H', '--host', '-c', '--context', '--config', '-l', '--log-level']),
|
|
115
|
+
kubectl: new Set(['-n', '--namespace', '--kubeconfig', '--context', '--cluster', '--user', '-s', '--server', '--as', '--token', '--cache-dir', '--request-timeout']),
|
|
116
|
+
rush: new Set(),
|
|
117
|
+
openclaw: new Set(),
|
|
118
|
+
};
|
|
119
|
+
/** Tools whose bucket key includes the second token (subcommand). */
|
|
120
|
+
const TWO_LEVEL_TOOLS = new Set(Object.keys(VALUE_FLAGS));
|
|
121
|
+
/** Remote wrappers whose bucket key carries an `ssh→` prefix on the inner command. */
|
|
122
|
+
const REMOTE_WRAPPERS = new Set(['ssh', 'scp', 'rsync']);
|
|
123
|
+
const ALIAS_MAP = new Map();
|
|
124
|
+
for (const [name, info] of Object.entries(TOOL_REGISTRY)) {
|
|
125
|
+
ALIAS_MAP.set(name, name);
|
|
126
|
+
for (const alias of info.aliases ?? []) {
|
|
127
|
+
ALIAS_MAP.set(alias, name);
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
export function unwrapCommand(cmd) {
|
|
131
|
+
const s = cmd.trim();
|
|
132
|
+
// ssh host command
|
|
133
|
+
const ssh = s.match(/^ssh\s+\S+\s+["']?(.+?)["']?\s*(?:\|.*)?$/);
|
|
134
|
+
if (ssh)
|
|
135
|
+
return unwrapCommand(ssh[1]);
|
|
136
|
+
// VAR=value prefix (value may be a single- or double-quoted string with spaces)
|
|
137
|
+
const env = s.match(/^([A-Za-z_][A-Za-z0-9_]*=(?:"[^"]*"|'[^']*'|\S+)\s+)+(.+)$/);
|
|
138
|
+
if (env)
|
|
139
|
+
return unwrapCommand(env[2]);
|
|
140
|
+
// sudo / time prefix (value-taking flags like `-u user` consume their argument)
|
|
141
|
+
const prefix = s.match(/^(?:sudo|time)(?:\s+(?:-[uUgGhpCrtDR]\s+\S+|-\S+))*\s+(.+)$/);
|
|
142
|
+
if (prefix)
|
|
143
|
+
return unwrapCommand(prefix[1]);
|
|
144
|
+
// cd foo && command
|
|
145
|
+
const cd = s.match(/^cd\s+\S+\s*&&\s*(.+)$/);
|
|
146
|
+
if (cd)
|
|
147
|
+
return unwrapCommand(cd[1]);
|
|
148
|
+
// npx / bunx
|
|
149
|
+
const npx = s.match(/^(?:npx|bunx)\s+(?:-\S+\s+)*(.+)$/);
|
|
150
|
+
if (npx)
|
|
151
|
+
return unwrapCommand(npx[1]);
|
|
152
|
+
return s;
|
|
153
|
+
}
|
|
154
|
+
function splitOnOperators(cmd) {
|
|
155
|
+
const parts = [];
|
|
156
|
+
let current = '';
|
|
157
|
+
let quote = null;
|
|
158
|
+
let escaped = false;
|
|
159
|
+
let i = 0;
|
|
160
|
+
while (i < cmd.length) {
|
|
161
|
+
const ch = cmd[i];
|
|
162
|
+
if (escaped) {
|
|
163
|
+
current += ch;
|
|
164
|
+
escaped = false;
|
|
165
|
+
i += 1;
|
|
166
|
+
continue;
|
|
167
|
+
}
|
|
168
|
+
if (ch === '\\') {
|
|
169
|
+
current += ch;
|
|
170
|
+
escaped = true;
|
|
171
|
+
i += 1;
|
|
172
|
+
continue;
|
|
173
|
+
}
|
|
174
|
+
if (quote) {
|
|
175
|
+
current += ch;
|
|
176
|
+
if (ch === quote)
|
|
177
|
+
quote = null;
|
|
178
|
+
i += 1;
|
|
179
|
+
continue;
|
|
180
|
+
}
|
|
181
|
+
if (ch === '"' || ch === "'") {
|
|
182
|
+
current += ch;
|
|
183
|
+
quote = ch;
|
|
184
|
+
i += 1;
|
|
185
|
+
continue;
|
|
186
|
+
}
|
|
187
|
+
if (cmd.slice(i, i + 2) === '&&' || cmd.slice(i, i + 2) === '||') {
|
|
188
|
+
if (current.trim())
|
|
189
|
+
parts.push(current.trim());
|
|
190
|
+
current = '';
|
|
191
|
+
i += 2;
|
|
192
|
+
continue;
|
|
193
|
+
}
|
|
194
|
+
if (ch === '|' || ch === ';') {
|
|
195
|
+
if (current.trim())
|
|
196
|
+
parts.push(current.trim());
|
|
197
|
+
current = '';
|
|
198
|
+
i += 1;
|
|
199
|
+
continue;
|
|
200
|
+
}
|
|
201
|
+
current += ch;
|
|
202
|
+
i += 1;
|
|
203
|
+
}
|
|
204
|
+
if (current.trim())
|
|
205
|
+
parts.push(current.trim());
|
|
206
|
+
return parts;
|
|
207
|
+
}
|
|
208
|
+
/**
|
|
209
|
+
* Split a possibly-compound Bash command into simple commands, then tokenize each
|
|
210
|
+
* with shlex. Returns an empty array for empty input.
|
|
211
|
+
*/
|
|
212
|
+
export function tokenizeBash(cmd) {
|
|
213
|
+
const unwrapped = unwrapCommand(cmd);
|
|
214
|
+
const segments = splitOnOperators(unwrapped);
|
|
215
|
+
const out = [];
|
|
216
|
+
for (const seg of segments) {
|
|
217
|
+
try {
|
|
218
|
+
const tokens = shlexSplit(seg);
|
|
219
|
+
if (tokens.length)
|
|
220
|
+
out.push(tokens);
|
|
221
|
+
}
|
|
222
|
+
catch {
|
|
223
|
+
// If shlex can't parse, fall back to whitespace split.
|
|
224
|
+
const tokens = seg.split(/\s+/).filter(Boolean);
|
|
225
|
+
if (tokens.length)
|
|
226
|
+
out.push(tokens);
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
return out;
|
|
230
|
+
}
|
|
231
|
+
/**
|
|
232
|
+
* First non-flag token after the executable — its subcommand. Skips leading
|
|
233
|
+
* flags, and skips the argument of a value-taking flag for that tool (see
|
|
234
|
+
* VALUE_FLAGS) so e.g. `git -C /repo commit` resolves to `commit`, not `-C`, and
|
|
235
|
+
* `kubectl -n prod get` resolves to `get`, not `prod`.
|
|
236
|
+
*/
|
|
237
|
+
function scanSubcommand(tokens, tool) {
|
|
238
|
+
const valueFlags = VALUE_FLAGS[tool];
|
|
239
|
+
let i = 1;
|
|
240
|
+
while (i < tokens.length) {
|
|
241
|
+
const t = tokens[i];
|
|
242
|
+
if (t.startsWith('-')) {
|
|
243
|
+
i += valueFlags?.has(t) ? 2 : 1;
|
|
244
|
+
continue;
|
|
245
|
+
}
|
|
246
|
+
return t.toLowerCase();
|
|
247
|
+
}
|
|
248
|
+
return '';
|
|
249
|
+
}
|
|
250
|
+
/**
|
|
251
|
+
* Classify the first simple command in a Bash string. Returns coarse metadata
|
|
252
|
+
* (tool name, category, subcommand, human action) used for summaries and
|
|
253
|
+
* activity logging. Unknown executables fall back to `other`.
|
|
254
|
+
*/
|
|
255
|
+
export function classifyBashCommand(command) {
|
|
256
|
+
const simpleCommands = tokenizeBash(command);
|
|
257
|
+
if (!simpleCommands.length) {
|
|
258
|
+
return { tool: 'other', category: 'other', subcommand: '', action: 'running command', summary: '', signal: 'low' };
|
|
259
|
+
}
|
|
260
|
+
const tokens = simpleCommands[0];
|
|
261
|
+
if (!tokens.length) {
|
|
262
|
+
return { tool: 'other', category: 'other', subcommand: '', action: 'running command', summary: '', signal: 'low' };
|
|
263
|
+
}
|
|
264
|
+
const first = tokens[0];
|
|
265
|
+
const baseRaw = first.replace(/^[./]+/, '').toLowerCase();
|
|
266
|
+
const base = baseRaw.endsWith('.exe') ? baseRaw.slice(0, -4) : baseRaw;
|
|
267
|
+
const canonical = ALIAS_MAP.get(base);
|
|
268
|
+
const info = canonical ? TOOL_REGISTRY[canonical] : undefined;
|
|
269
|
+
if (!info) {
|
|
270
|
+
// Unknown executable. A known two-level tool that isn't in the registry
|
|
271
|
+
// (docker/kubectl/rush/openclaw) still surfaces its subcommand so bucketing
|
|
272
|
+
// stays useful; the category stays honestly 'other'.
|
|
273
|
+
const subcommand = TWO_LEVEL_TOOLS.has(base) ? scanSubcommand(tokens, base) : '';
|
|
274
|
+
const summary = subcommand ? `${base} ${subcommand}` : base;
|
|
275
|
+
return { tool: base, category: 'other', subcommand, action: 'running command', summary, signal: 'low' };
|
|
276
|
+
}
|
|
277
|
+
const subcommand = canonical && TWO_LEVEL_TOOLS.has(canonical) ? scanSubcommand(tokens, canonical) : '';
|
|
278
|
+
const safeCanonical = canonical || base;
|
|
279
|
+
const summary = subcommand ? `${safeCanonical} ${subcommand}` : safeCanonical;
|
|
280
|
+
return {
|
|
281
|
+
tool: safeCanonical,
|
|
282
|
+
category: info.category,
|
|
283
|
+
subcommand,
|
|
284
|
+
action: info.action,
|
|
285
|
+
summary,
|
|
286
|
+
signal: info.signal,
|
|
287
|
+
};
|
|
288
|
+
}
|
|
289
|
+
/**
|
|
290
|
+
* Stable bucket key for grouping similar Bash commands in summaries. Commands run
|
|
291
|
+
* through a remote wrapper (ssh/scp/rsync) get an `ssh→` prefix on the inner key,
|
|
292
|
+
* so `ssh host "git push"` buckets as `ssh→git push`, distinct from a local push.
|
|
293
|
+
*/
|
|
294
|
+
export function bucketKey(command) {
|
|
295
|
+
const info = classifyBashCommand(command);
|
|
296
|
+
const base = !info.tool
|
|
297
|
+
? 'other'
|
|
298
|
+
: info.subcommand && TWO_LEVEL_TOOLS.has(info.tool)
|
|
299
|
+
? `${info.tool} ${info.subcommand}`
|
|
300
|
+
: info.tool;
|
|
301
|
+
const first = command.trim().split(/\s+/)[0]?.toLowerCase() ?? '';
|
|
302
|
+
return REMOTE_WRAPPERS.has(first) ? `ssh→${base}` : base;
|
|
303
|
+
}
|
|
304
|
+
/**
|
|
305
|
+
* Detect high-signal Bash-driven milestones (video renders, upscales, metadata
|
|
306
|
+
* edits, git commits/pushes/worktrees, PR opens). Returns null for routine
|
|
307
|
+
* commands.
|
|
308
|
+
*/
|
|
309
|
+
export function detectBashMilestone(command) {
|
|
310
|
+
const info = classifyBashCommand(command);
|
|
311
|
+
const lower = (command || '').toLowerCase();
|
|
312
|
+
if (info.category === 'upscaling') {
|
|
313
|
+
return { event: 'image.upscaled', detail: info.action };
|
|
314
|
+
}
|
|
315
|
+
if (info.tool === 'ffmpeg') {
|
|
316
|
+
const hasOutput = /\s+\S+\.\w{2,5}\s*$/.test(command || '');
|
|
317
|
+
if (hasOutput || lower.includes('-c:v') || lower.includes('-codec') || lower.includes('libx264')) {
|
|
318
|
+
return { event: 'video.rendered', detail: 'ffmpeg render' };
|
|
319
|
+
}
|
|
320
|
+
return { event: 'video.converted', detail: 'ffmpeg' };
|
|
321
|
+
}
|
|
322
|
+
if (info.category === 'metadata') {
|
|
323
|
+
return { event: 'metadata.edited', detail: info.action };
|
|
324
|
+
}
|
|
325
|
+
if (info.tool === 'git') {
|
|
326
|
+
if (info.subcommand === 'commit')
|
|
327
|
+
return { event: 'commit.created', detail: 'git commit' };
|
|
328
|
+
if (info.subcommand === 'push')
|
|
329
|
+
return { event: 'pushed', detail: 'git push' };
|
|
330
|
+
if (info.subcommand === 'worktree') {
|
|
331
|
+
if (lower.includes('worktree add'))
|
|
332
|
+
return { event: 'worktree.created', detail: 'git worktree add' };
|
|
333
|
+
if (lower.includes('worktree remove'))
|
|
334
|
+
return { event: 'worktree.removed', detail: 'git worktree remove' };
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
if (info.tool === 'gh' && info.subcommand === 'pr') {
|
|
338
|
+
if (lower.includes('pr create'))
|
|
339
|
+
return { event: 'pr.opened', detail: 'gh pr create' };
|
|
340
|
+
if (lower.includes('pr merge'))
|
|
341
|
+
return { event: 'pr.merged', detail: 'gh pr merge' };
|
|
342
|
+
}
|
|
343
|
+
return null;
|
|
344
|
+
}
|
|
345
|
+
/**
|
|
346
|
+
* Human-readable category label for renderers.
|
|
347
|
+
*/
|
|
348
|
+
export function categoryLabel(category) {
|
|
349
|
+
switch (category) {
|
|
350
|
+
case 'vcs': return 'Version control';
|
|
351
|
+
case 'build-test': return 'Build / test';
|
|
352
|
+
case 'install': return 'Install';
|
|
353
|
+
case 'remote': return 'Remote';
|
|
354
|
+
case 'http': return 'HTTP';
|
|
355
|
+
case 'media': return 'Media';
|
|
356
|
+
case 'upscaling': return 'Upscaling';
|
|
357
|
+
case 'metadata': return 'Metadata';
|
|
358
|
+
case 'probe': return 'Probes';
|
|
359
|
+
case 'search': return 'Search';
|
|
360
|
+
case 'shell': return 'Shell';
|
|
361
|
+
case 'wait': return 'Wait';
|
|
362
|
+
default: return 'Other';
|
|
363
|
+
}
|
|
364
|
+
}
|
|
@@ -42,6 +42,12 @@ export declare function toolHistogram(toolCounts: Record<string, number>, top?:
|
|
|
42
42
|
tool: string;
|
|
43
43
|
count: number;
|
|
44
44
|
}>;
|
|
45
|
+
/**
|
|
46
|
+
* Aggregate recognized external tools invoked via Bash across a session.
|
|
47
|
+
* Unknown one-off commands are bucketed as `other` so the ranking surface
|
|
48
|
+
* (e.g. `agents activity --tools`) only surfaces repeatable tools.
|
|
49
|
+
*/
|
|
50
|
+
export declare function bashToolCounts(events: SessionEvent[]): Record<string, number>;
|
|
45
51
|
export interface TestResult {
|
|
46
52
|
runner: string;
|
|
47
53
|
passed?: number;
|
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
* last test/build result. Consumed by the single-session view and the picker
|
|
8
8
|
* preview. No I/O — fully unit-testable.
|
|
9
9
|
*/
|
|
10
|
+
import { bucketKey, classifyBashCommand } from './bash-command.js';
|
|
10
11
|
// Tool vocab mirrors parse.ts / render.ts so classification matches what those
|
|
11
12
|
// modules already recognize across Claude/Codex/others.
|
|
12
13
|
const READ_TOOLS = new Set(['Read', 'read_file', 'view_file', 'cat_file', 'get_file']);
|
|
@@ -127,6 +128,24 @@ export function toolHistogram(toolCounts, top = 8) {
|
|
|
127
128
|
.sort((a, b) => b.count - a.count || a.tool.localeCompare(b.tool))
|
|
128
129
|
.slice(0, top);
|
|
129
130
|
}
|
|
131
|
+
/**
|
|
132
|
+
* Aggregate recognized external tools invoked via Bash across a session.
|
|
133
|
+
* Unknown one-off commands are bucketed as `other` so the ranking surface
|
|
134
|
+
* (e.g. `agents activity --tools`) only surfaces repeatable tools.
|
|
135
|
+
*/
|
|
136
|
+
export function bashToolCounts(events) {
|
|
137
|
+
const counts = {};
|
|
138
|
+
for (const e of events) {
|
|
139
|
+
if (e.type !== 'tool_use' || e._local)
|
|
140
|
+
continue;
|
|
141
|
+
if (e.tool !== 'Bash' || !e.command)
|
|
142
|
+
continue;
|
|
143
|
+
const info = classifyBashCommand(e.command);
|
|
144
|
+
const key = info.category === 'other' ? 'other' : bucketKey(e.command);
|
|
145
|
+
counts[key] = (counts[key] || 0) + 1;
|
|
146
|
+
}
|
|
147
|
+
return counts;
|
|
148
|
+
}
|
|
130
149
|
/**
|
|
131
150
|
* Recognized test/build runners → the label we show.
|
|
132
151
|
*
|
|
@@ -5,6 +5,29 @@
|
|
|
5
5
|
* `remote.ts`, so a back-import would cycle.
|
|
6
6
|
*/
|
|
7
7
|
export declare function formatRelativeTime(isoTimestamp: string): string;
|
|
8
|
+
/**
|
|
9
|
+
* Compact age — `now`, `45m`, `3h`, `2d`, then the calendar date. This is the
|
|
10
|
+
* *creation* half of a session's time cell: it renders right next to the long
|
|
11
|
+
* last-activity label, and one row can afford one long form, not two.
|
|
12
|
+
*/
|
|
13
|
+
export declare function formatCompactAge(isoTimestamp: string): string;
|
|
14
|
+
/** The two time fields a session listing shows for one row. */
|
|
15
|
+
export interface SessionAgeParts {
|
|
16
|
+
/**
|
|
17
|
+
* Compact age of the session's creation, set only when the session ran long
|
|
18
|
+
* enough (>= a minute) for it to say something `last` does not.
|
|
19
|
+
*/
|
|
20
|
+
created?: string;
|
|
21
|
+
/** Last-activity label — the long relative form the listing sorts by. */
|
|
22
|
+
last: string;
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* Split a session's timestamps into the two fields a listing renders: when it
|
|
26
|
+
* was created and when it was last active. One last-activity label answers
|
|
27
|
+
* neither "is this the old session I'm looking for" nor "how long did it run" —
|
|
28
|
+
* a session touched an hour ago may have started last week.
|
|
29
|
+
*/
|
|
30
|
+
export declare function sessionAgeParts(createdIso: string, lastActivityIso?: string): SessionAgeParts;
|
|
8
31
|
/**
|
|
9
32
|
* Parse a time filter string (relative like '7d' or an ISO timestamp) into epoch
|
|
10
33
|
* milliseconds. Backs `--since` on `sessions`, `cost`, `teams`, and `output`, and
|
|
@@ -4,6 +4,19 @@
|
|
|
4
4
|
* offline-cache banner (`remote.ts`) share one formatter — `sessions.ts` imports
|
|
5
5
|
* `remote.ts`, so a back-import would cycle.
|
|
6
6
|
*/
|
|
7
|
+
const MONTHS = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
|
|
8
|
+
/**
|
|
9
|
+
* Calendar label for a timestamp older than the relative window. A 2-digit year
|
|
10
|
+
* is appended outside the current year so "Jun 28" is never ambiguous across
|
|
11
|
+
* years (e.g. "Jun 28 '25").
|
|
12
|
+
*/
|
|
13
|
+
function calendarLabel(thenMs, nowMs) {
|
|
14
|
+
const d = new Date(thenMs);
|
|
15
|
+
const label = `${MONTHS[d.getMonth()]} ${d.getDate()}`;
|
|
16
|
+
return d.getFullYear() === new Date(nowMs).getFullYear()
|
|
17
|
+
? label
|
|
18
|
+
: `${label} '${String(d.getFullYear()).slice(-2)}`;
|
|
19
|
+
}
|
|
7
20
|
export function formatRelativeTime(isoTimestamp) {
|
|
8
21
|
const now = Date.now();
|
|
9
22
|
const then = new Date(isoTimestamp).getTime();
|
|
@@ -21,14 +34,53 @@ export function formatRelativeTime(isoTimestamp) {
|
|
|
21
34
|
return `${diffHrs} hour${diffHrs === 1 ? '' : 's'} ago`;
|
|
22
35
|
if (diffDays < 7)
|
|
23
36
|
return `${diffDays} day${diffDays === 1 ? '' : 's'} ago`;
|
|
24
|
-
// Older: show the date.
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
37
|
+
// Older: show the date.
|
|
38
|
+
return calendarLabel(then, now);
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* Compact age — `now`, `45m`, `3h`, `2d`, then the calendar date. This is the
|
|
42
|
+
* *creation* half of a session's time cell: it renders right next to the long
|
|
43
|
+
* last-activity label, and one row can afford one long form, not two.
|
|
44
|
+
*/
|
|
45
|
+
export function formatCompactAge(isoTimestamp) {
|
|
46
|
+
const now = Date.now();
|
|
47
|
+
const then = new Date(isoTimestamp).getTime();
|
|
48
|
+
if (isNaN(then))
|
|
49
|
+
return isoTimestamp;
|
|
50
|
+
const diffMs = now - then;
|
|
51
|
+
const diffMin = Math.floor(diffMs / 60_000);
|
|
52
|
+
const diffHrs = Math.floor(diffMs / 3_600_000);
|
|
53
|
+
const diffDays = Math.floor(diffMs / 86_400_000);
|
|
54
|
+
if (diffMin < 1)
|
|
55
|
+
return 'now';
|
|
56
|
+
if (diffMin < 60)
|
|
57
|
+
return `${diffMin}m`;
|
|
58
|
+
if (diffHrs < 24)
|
|
59
|
+
return `${diffHrs}h`;
|
|
60
|
+
if (diffDays < 7)
|
|
61
|
+
return `${diffDays}d`;
|
|
62
|
+
return calendarLabel(then, now);
|
|
63
|
+
}
|
|
64
|
+
/**
|
|
65
|
+
* Shortest creation→last-activity gap worth rendering as two fields. Below it
|
|
66
|
+
* both halves name the same minute, so the row would carry a duplicate instead
|
|
67
|
+
* of information.
|
|
68
|
+
*/
|
|
69
|
+
const SPAN_MIN_MS = 60_000;
|
|
70
|
+
/**
|
|
71
|
+
* Split a session's timestamps into the two fields a listing renders: when it
|
|
72
|
+
* was created and when it was last active. One last-activity label answers
|
|
73
|
+
* neither "is this the old session I'm looking for" nor "how long did it run" —
|
|
74
|
+
* a session touched an hour ago may have started last week.
|
|
75
|
+
*/
|
|
76
|
+
export function sessionAgeParts(createdIso, lastActivityIso) {
|
|
77
|
+
const lastIso = lastActivityIso ?? createdIso;
|
|
78
|
+
const last = formatRelativeTime(lastIso);
|
|
79
|
+
const createdMs = new Date(createdIso).getTime();
|
|
80
|
+
const lastMs = new Date(lastIso).getTime();
|
|
81
|
+
if (isNaN(createdMs) || isNaN(lastMs) || lastMs - createdMs < SPAN_MIN_MS)
|
|
82
|
+
return { last };
|
|
83
|
+
return { created: formatCompactAge(createdIso), last };
|
|
32
84
|
}
|
|
33
85
|
/**
|
|
34
86
|
* Parse a time filter string (relative like '7d' or an ISO timestamp) into epoch
|
|
@@ -18,7 +18,7 @@ import chalk from 'chalk';
|
|
|
18
18
|
import { SSH_OPTS, controlOpts, assertValidSshTarget, shellQuote } from '../ssh-exec.js';
|
|
19
19
|
import { sshTargetFor } from '../devices/connect.js';
|
|
20
20
|
import { resolveExplicitTargets } from '../devices/resolve-target.js';
|
|
21
|
-
import { loadDevices, isControlDevice } from '../devices/registry.js';
|
|
21
|
+
import { loadDevices, isControlDevice, isDialableDevice } from '../devices/registry.js';
|
|
22
22
|
import { remoteShellFor, buildWindowsAgentsCommand } from '../hosts/remote-cmd.js';
|
|
23
23
|
import { machineId, normalizeHost } from './sync/config.js';
|
|
24
24
|
import { NO_FANOUT_ENV } from './remote-active.js';
|
|
@@ -163,7 +163,10 @@ export async function gatherRemoteList(forwardedArgs, hosts) {
|
|
|
163
163
|
return { sessions: [], deviceCount: 0, unreachable: ['device registry'] };
|
|
164
164
|
}
|
|
165
165
|
for (const d of Object.values(reg)) {
|
|
166
|
-
|
|
166
|
+
// Live SSH-probe verdict first, cached tailscale snapshot only as a
|
|
167
|
+
// fallback — see isDialableDevice. A manually-registered device has no
|
|
168
|
+
// tailscale peer entry, so gating on `online` alone hid its sessions.
|
|
169
|
+
if (!isDialableDevice(d))
|
|
167
170
|
continue;
|
|
168
171
|
if (normalizeHost(d.name) === self)
|
|
169
172
|
continue;
|
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
* transcript. Filtering by role and turn slicing is handled here as well.
|
|
8
8
|
*/
|
|
9
9
|
import type { SessionEvent, SessionMeta } from './types.js';
|
|
10
|
+
import { unwrapCommand, bucketKey } from './bash-command.js';
|
|
10
11
|
/**
|
|
11
12
|
* Return absPath relative to cwd; fall back to ~/… then absolute.
|
|
12
13
|
*/
|
|
@@ -28,18 +29,11 @@ export declare function linkPath(absPath: string, label: string): string;
|
|
|
28
29
|
* issue, …) when the terminal supports it. Degrades to the plain label otherwise.
|
|
29
30
|
*/
|
|
30
31
|
export declare function linkUrl(url: string, label: string): string;
|
|
31
|
-
/**
|
|
32
|
-
* Unwrap wrapper prefixes to find the actual executable.
|
|
33
|
-
*/
|
|
34
|
-
export declare function unwrapCommand(cmd: string): string;
|
|
35
32
|
/**
|
|
36
33
|
* Normalize a command so trivial flag/pipe variations collapse to the same key.
|
|
37
34
|
*/
|
|
38
35
|
export declare function normalizeForDedup(cmd: string): string;
|
|
39
|
-
|
|
40
|
-
* Return the bucket key for a command (used for grouping within a category).
|
|
41
|
-
*/
|
|
42
|
-
export declare function bucketKey(cmd: string): string;
|
|
36
|
+
export { unwrapCommand, bucketKey };
|
|
43
37
|
interface CmdRun {
|
|
44
38
|
normalized: string;
|
|
45
39
|
raw: string;
|
|
@@ -132,4 +126,3 @@ export declare function renderConversationMarkdown(events: SessionEvent[], opts?
|
|
|
132
126
|
export declare function renderJson(events: SessionEvent[], meta?: SessionMeta, opts?: {
|
|
133
127
|
redact?: boolean;
|
|
134
128
|
}): string;
|
|
135
|
-
export {};
|