@phnx-labs/agents-cli 1.20.34 → 1.20.35

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.
Files changed (121) hide show
  1. package/CHANGELOG.md +22 -0
  2. package/README.md +5 -1
  3. package/dist/commands/computer-actions.d.ts +15 -0
  4. package/dist/commands/computer-actions.js +120 -22
  5. package/dist/commands/cost.js +17 -12
  6. package/dist/commands/doctor.d.ts +1 -0
  7. package/dist/commands/doctor.js +59 -14
  8. package/dist/commands/events.d.ts +16 -0
  9. package/dist/commands/events.js +173 -0
  10. package/dist/commands/exec.js +6 -1
  11. package/dist/commands/hosts.js +28 -24
  12. package/dist/commands/inspect.d.ts +2 -0
  13. package/dist/commands/inspect.js +54 -7
  14. package/dist/commands/logs.d.ts +17 -0
  15. package/dist/commands/logs.js +139 -0
  16. package/dist/commands/models.d.ts +3 -0
  17. package/dist/commands/models.js +27 -8
  18. package/dist/commands/permissions.js +9 -2
  19. package/dist/commands/repo.d.ts +34 -0
  20. package/dist/commands/repo.js +243 -65
  21. package/dist/commands/resource-view.d.ts +20 -0
  22. package/dist/commands/resource-view.js +90 -28
  23. package/dist/commands/secrets.js +40 -16
  24. package/dist/commands/sessions-resume.d.ts +2 -0
  25. package/dist/commands/sessions-resume.js +209 -0
  26. package/dist/commands/sessions-tail.d.ts +10 -0
  27. package/dist/commands/sessions-tail.js +11 -0
  28. package/dist/commands/sessions.d.ts +73 -1
  29. package/dist/commands/sessions.js +273 -53
  30. package/dist/commands/view.d.ts +3 -0
  31. package/dist/commands/view.js +19 -8
  32. package/dist/commands/wallet.d.ts +6 -0
  33. package/dist/commands/wallet.js +22 -5
  34. package/dist/index.js +58 -31
  35. package/dist/lib/agents.js +30 -9
  36. package/dist/lib/browser/cdp.js +5 -1
  37. package/dist/lib/browser/drivers/ssh.d.ts +8 -0
  38. package/dist/lib/browser/drivers/ssh.js +76 -16
  39. package/dist/lib/browser/ipc.d.ts +3 -0
  40. package/dist/lib/browser/ipc.js +13 -9
  41. package/dist/lib/browser/service.js +71 -15
  42. package/dist/lib/daemon.d.ts +35 -0
  43. package/dist/lib/daemon.js +33 -5
  44. package/dist/lib/devices/connect.d.ts +3 -2
  45. package/dist/lib/devices/connect.js +5 -3
  46. package/dist/lib/events.d.ts +9 -2
  47. package/dist/lib/events.js +70 -11
  48. package/dist/lib/exec.js +22 -0
  49. package/dist/lib/hooks.js +8 -2
  50. package/dist/lib/hosts/dispatch.js +6 -7
  51. package/dist/lib/hosts/logs.d.ts +16 -0
  52. package/dist/lib/hosts/logs.js +45 -0
  53. package/dist/lib/hosts/progress.d.ts +66 -0
  54. package/dist/lib/hosts/progress.js +125 -17
  55. package/dist/lib/hosts/ready.d.ts +23 -2
  56. package/dist/lib/hosts/ready.js +35 -13
  57. package/dist/lib/hosts/reconcile.d.ts +53 -0
  58. package/dist/lib/hosts/reconcile.js +81 -0
  59. package/dist/lib/hosts/tasks.d.ts +8 -0
  60. package/dist/lib/hosts/tasks.js +14 -0
  61. package/dist/lib/permissions.d.ts +4 -0
  62. package/dist/lib/permissions.js +35 -0
  63. package/dist/lib/picker.d.ts +24 -0
  64. package/dist/lib/picker.js +124 -0
  65. package/dist/lib/plugin-marketplace.d.ts +30 -0
  66. package/dist/lib/plugin-marketplace.js +215 -2
  67. package/dist/lib/plugins.d.ts +5 -0
  68. package/dist/lib/plugins.js +45 -4
  69. package/dist/lib/session/active.d.ts +7 -0
  70. package/dist/lib/session/active.js +11 -3
  71. package/dist/lib/session/discover.js +94 -0
  72. package/dist/lib/session/parse.d.ts +6 -0
  73. package/dist/lib/session/parse.js +283 -2
  74. package/dist/lib/session/pid-registry.d.ts +21 -0
  75. package/dist/lib/session/pid-registry.js +80 -0
  76. package/dist/lib/session/remote-active.d.ts +26 -0
  77. package/dist/lib/session/remote-active.js +141 -0
  78. package/dist/lib/session/remote.js +2 -6
  79. package/dist/lib/session/render.js +1 -1
  80. package/dist/lib/session/sync/config.d.ts +8 -0
  81. package/dist/lib/session/sync/config.js +11 -2
  82. package/dist/lib/session/types.d.ts +1 -1
  83. package/dist/lib/session/types.js +1 -1
  84. package/dist/lib/shims.d.ts +11 -3
  85. package/dist/lib/shims.js +54 -30
  86. package/dist/lib/ssh-exec.d.ts +26 -3
  87. package/dist/lib/ssh-exec.js +45 -3
  88. package/dist/lib/ssh-tunnel.d.ts +24 -5
  89. package/dist/lib/ssh-tunnel.js +60 -62
  90. package/dist/lib/staleness/writers/hooks.js +1 -1
  91. package/dist/lib/startup/command-registry.d.ts +2 -0
  92. package/dist/lib/startup/command-registry.js +4 -0
  93. package/dist/lib/teams/registry.js +25 -9
  94. package/dist/lib/terminal/backends/ghostty.d.ts +6 -0
  95. package/dist/lib/terminal/backends/ghostty.js +69 -0
  96. package/dist/lib/terminal/backends/index.d.ts +18 -0
  97. package/dist/lib/terminal/backends/index.js +29 -0
  98. package/dist/lib/terminal/backends/iterm.d.ts +6 -0
  99. package/dist/lib/terminal/backends/iterm.js +62 -0
  100. package/dist/lib/terminal/backends/tmux.d.ts +14 -0
  101. package/dist/lib/terminal/backends/tmux.js +23 -0
  102. package/dist/lib/terminal/engine.d.ts +39 -0
  103. package/dist/lib/terminal/engine.js +54 -0
  104. package/dist/lib/terminal/index.d.ts +14 -0
  105. package/dist/lib/terminal/index.js +5 -0
  106. package/dist/lib/terminal/policy.d.ts +11 -0
  107. package/dist/lib/terminal/policy.js +11 -0
  108. package/dist/lib/terminal/quote.d.ts +11 -0
  109. package/dist/lib/terminal/quote.js +13 -0
  110. package/dist/lib/terminal/shell.d.ts +6 -0
  111. package/dist/lib/terminal/shell.js +23 -0
  112. package/dist/lib/terminal/transport.d.ts +15 -0
  113. package/dist/lib/terminal/transport.js +40 -0
  114. package/dist/lib/terminal/types.d.ts +59 -0
  115. package/dist/lib/terminal/types.js +13 -0
  116. package/dist/lib/types.d.ts +7 -0
  117. package/dist/lib/versions.d.ts +7 -3
  118. package/dist/lib/versions.js +10 -5
  119. package/dist/lib/whats-new.d.ts +9 -0
  120. package/dist/lib/whats-new.js +35 -0
  121. package/package.json +1 -1
@@ -0,0 +1,173 @@
1
+ /**
2
+ * `agents events` — read the structured audit/event log.
3
+ *
4
+ * The event log (`~/.agents/.cache/logs/events-YYYY-MM-DD.jsonl`) records every
5
+ * `agents <module> <cmd>` invocation plus richer typed events (secrets access,
6
+ * version installs, ...), each stamped with who ran it and from where (OS user,
7
+ * local vs SSH, remote client IP). This command reads it back — the audit trail
8
+ * for "who accessed a secret / created a team / started an agent, and from which
9
+ * host?".
10
+ *
11
+ * Filter by `--module` (top-level group, e.g. teams), `--command` (path prefix,
12
+ * e.g. "teams create"), `--event` (typed event), `--agent`, and `--since`.
13
+ * `--follow` tails today's log live.
14
+ */
15
+ import chalk from 'chalk';
16
+ import * as fs from 'fs';
17
+ import { query, LOGS_PATH } from '../lib/events.js';
18
+ /** Parse `--since`: relative offsets (30s/5m/2h/7d/4w) or an ISO/absolute date. */
19
+ function parseSince(s) {
20
+ const m = s.match(/^(\d+)([smhdw])$/);
21
+ if (m) {
22
+ const n = parseInt(m[1], 10);
23
+ const unitMs = {
24
+ s: 1000, m: 60_000, h: 3_600_000, d: 86_400_000, w: 604_800_000,
25
+ };
26
+ return new Date(Date.now() - n * unitMs[m[2]]);
27
+ }
28
+ const ms = Date.parse(s);
29
+ if (isNaN(ms))
30
+ throw new Error(`Invalid --since value: ${s} (use e.g. 2h, 7d, or an ISO date)`);
31
+ return new Date(ms);
32
+ }
33
+ /** How the run reached this host — 'local' or 'ssh 203.0.113.7'. */
34
+ function originLabel(r) {
35
+ if (r.transport === 'ssh') {
36
+ return chalk.yellow(`ssh${r.sshClientIp ? ' ' + r.sshClientIp : ''}`);
37
+ }
38
+ return chalk.gray('local');
39
+ }
40
+ /** The most useful one-line detail for a record, by event family. */
41
+ function detailFor(r) {
42
+ if (r.command)
43
+ return r.command;
44
+ const bits = [];
45
+ if (typeof r.team === 'string')
46
+ bits.push(`team=${r.team}`);
47
+ if (typeof r.bundle === 'string')
48
+ bits.push(`bundle=${r.bundle}`);
49
+ if (typeof r.skill === 'string')
50
+ bits.push(`skill=${r.skill}`);
51
+ if (typeof r.version === 'string')
52
+ bits.push(`v=${r.version}`);
53
+ if (typeof r.profile === 'string')
54
+ bits.push(`profile=${r.profile}`);
55
+ if (typeof r.error === 'string')
56
+ bits.push(chalk.red(r.error));
57
+ return bits.join(' ');
58
+ }
59
+ function renderRow(r) {
60
+ const time = chalk.gray(r.ts.slice(0, 19).replace('T', ' '));
61
+ const user = `${r.osUser ?? '?'}@${r.hostname}`;
62
+ const ev = r.event.startsWith('error') ? chalk.red(r.event) : chalk.cyan(r.event);
63
+ const agent = r.agent ? chalk.gray(` ${r.agent}`) : '';
64
+ return `${time} ${originLabel(r).padEnd(24)} ${user.padEnd(22)} ${ev.padEnd(26)}${agent} ${detailFor(r)}`;
65
+ }
66
+ export function registerEventsCommand(program) {
67
+ program
68
+ .command('events')
69
+ .description('Read the structured audit/event log (who ran what, from where)')
70
+ .option('--module <name>', 'Only events from this command group (e.g. teams, secrets)')
71
+ .option('--command <path>', 'Only this command path — prefix match (e.g. "teams create")')
72
+ .option('--event <type>', 'Only this typed event (repeatable, e.g. secrets.get)', collect, [])
73
+ .option('--agent <name>', 'Only events tagged with this agent')
74
+ .option('--since <time>', 'Only events newer than this (e.g. 2h, 7d, or ISO date)')
75
+ .option('--limit <n>', 'Max records to show (default 50)', '50')
76
+ .option('--json', 'Output raw records as JSON')
77
+ .option('-f, --follow', "Tail today's log live")
78
+ .addHelpText('after', `
79
+ Examples:
80
+ agents events Recent activity across everything
81
+ agents events --module teams Team lifecycle (create / add / disband)
82
+ agents events --module secrets Every secret accessed or revealed
83
+ agents events --command "teams create" Just team creations
84
+ agents events --event secrets.get --since 7d --json
85
+ agents events -f Live tail`)
86
+ .action(async (options) => {
87
+ if (options.follow) {
88
+ await followLog();
89
+ return;
90
+ }
91
+ const limit = Math.max(1, parseInt(options.limit ?? '50', 10) || 50);
92
+ let startDate;
93
+ try {
94
+ startDate = options.since ? parseSince(options.since) : undefined;
95
+ }
96
+ catch (err) {
97
+ console.error(chalk.red(err.message));
98
+ process.exit(2);
99
+ }
100
+ const records = query({
101
+ startDate,
102
+ eventTypes: options.event && options.event.length ? options.event : undefined,
103
+ agent: options.agent,
104
+ command: options.command,
105
+ module: options.module,
106
+ limit,
107
+ });
108
+ if (options.json) {
109
+ console.log(JSON.stringify(records, null, 2));
110
+ return;
111
+ }
112
+ if (records.length === 0) {
113
+ console.log(chalk.gray('No matching events.'));
114
+ return;
115
+ }
116
+ // query() returns newest-first; print oldest-first so a tail reads naturally.
117
+ for (const r of records.slice().reverse())
118
+ console.log(renderRow(r));
119
+ console.log(chalk.gray(`\n${records.length} event(s). Log: ${LOGS_PATH}`));
120
+ });
121
+ }
122
+ /** commander repeatable-option collector. */
123
+ function collect(value, previous) {
124
+ return previous.concat([value]);
125
+ }
126
+ /** Tail today's event file, printing new lines as they land. */
127
+ async function followLog() {
128
+ const today = new Date();
129
+ const file = `${LOGS_PATH}/events-${today.getFullYear()}-${String(today.getMonth() + 1).padStart(2, '0')}-${String(today.getDate()).padStart(2, '0')}.jsonl`;
130
+ let offset = 0;
131
+ try {
132
+ offset = fs.statSync(file).size;
133
+ }
134
+ catch {
135
+ // File may not exist yet — start at 0 and pick it up on first write.
136
+ }
137
+ console.log(chalk.gray(`Tailing ${file} — Ctrl-C to stop`));
138
+ const drain = () => {
139
+ let size = 0;
140
+ try {
141
+ size = fs.statSync(file).size;
142
+ }
143
+ catch {
144
+ return;
145
+ }
146
+ if (size <= offset) {
147
+ if (size < offset)
148
+ offset = 0; // rotated/truncated
149
+ return;
150
+ }
151
+ const fd = fs.openSync(file, 'r');
152
+ try {
153
+ const buf = Buffer.alloc(size - offset);
154
+ fs.readSync(fd, buf, 0, buf.length, offset);
155
+ offset = size;
156
+ for (const line of buf.toString('utf-8').split('\n').filter(Boolean)) {
157
+ try {
158
+ console.log(renderRow(JSON.parse(line)));
159
+ }
160
+ catch {
161
+ // Skip malformed lines.
162
+ }
163
+ }
164
+ }
165
+ finally {
166
+ fs.closeSync(fd);
167
+ }
168
+ };
169
+ // Poll — simpler and more portable than fs.watch across platforms/editors.
170
+ await new Promise(() => {
171
+ setInterval(drain, 500);
172
+ });
173
+ }
@@ -294,7 +294,12 @@ export function registerRunCommand(program) {
294
294
  console.log(chalk.green(`Dispatched to ${host.name}.`) + chalk.gray(' Track: agents hosts ps · Follow: agents hosts logs <id> -f'));
295
295
  process.exit(0);
296
296
  }
297
- process.exit(exitCode === undefined || exitCode === -1 ? 1 : exitCode);
297
+ // -1 = the follow window closed but the run continues on the host (the
298
+ // reattach hint is already printed). That's a detach, not a failure —
299
+ // exit 0. Any real remote code passes through.
300
+ if (exitCode === -1)
301
+ process.exit(0);
302
+ process.exit(exitCode ?? 1);
298
303
  }
299
304
  catch (err) {
300
305
  console.error(chalk.red(err.message));
@@ -7,15 +7,16 @@
7
7
  * hosts. Dispatch itself is `agents run --host <name>` (see commands/exec.ts).
8
8
  */
9
9
  import chalk from 'chalk';
10
+ import { terminalWidth, truncateToWidth, stringWidth } from '../lib/session/width.js';
10
11
  import { checkbox, confirm } from '@inquirer/prompts';
11
12
  import { assertValidSshTarget } from '../lib/ssh-exec.js';
12
13
  import { getProvider, listAllHosts, resolveHost } from '../lib/hosts/registry.js';
13
14
  import { sshTargetFor } from '../lib/hosts/types.js';
14
15
  import { listSshConfigHosts, listKnownHosts, isSshConfigHost } from '../lib/hosts/ssh-config.js';
15
16
  import { probeHost, remoteAgentsVersion, bootstrapAgentsCli, localCliVersion, } from '../lib/hosts/ready.js';
16
- import { listTasks, loadTask, localLogPath } from '../lib/hosts/tasks.js';
17
- import { followHostTask } from '../lib/hosts/progress.js';
18
- import * as fs from 'fs';
17
+ import { listTasks } from '../lib/hosts/tasks.js';
18
+ import { reconcileRunningTasks } from '../lib/hosts/reconcile.js';
19
+ import { showHostTaskLog } from '../lib/hosts/logs.js';
19
20
  /** Parse `user@host` or `host` into its pieces. */
20
21
  function parseTarget(target) {
21
22
  const at = target.indexOf('@');
@@ -23,9 +24,12 @@ function parseTarget(target) {
23
24
  return { address: target };
24
25
  return { user: target.slice(0, at), address: target.slice(at + 1) };
25
26
  }
26
- /** Bootstrap/verify agents-cli on a freshly-enrolled host (best-effort, prompts). */
27
- async function maybeBootstrap(target, hostName) {
28
- const probe = probeHost(target);
27
+ /**
28
+ * Bootstrap/verify agents-cli on a freshly-enrolled host (best-effort, prompts).
29
+ * Callers that just probed the host pass the result through to avoid a second,
30
+ * redundant `uname` round-trip; otherwise we probe here.
31
+ */
32
+ async function maybeBootstrap(target, hostName, probe = probeHost(target)) {
29
33
  if (!probe.reachable) {
30
34
  console.log(chalk.yellow(` Not reachable over SSH yet — skipping bootstrap. Fix key auth, then: agents hosts check ${hostName}`));
31
35
  return;
@@ -75,7 +79,7 @@ async function doAdd(name, target, opts) {
75
79
  await registerHost({ name: c, provider: 'local', source, ...(source === 'inline' ? { address: c } : {}), os: probe.os, caps: opts.cap });
76
80
  console.log(chalk.green(`Enrolled ${c}`) + chalk.gray(` (${source}${probe.os ? `, ${probe.os}` : ''})`));
77
81
  if (opts.enroll !== false)
78
- await maybeBootstrap(c, c);
82
+ await maybeBootstrap(c, c, probe);
79
83
  }
80
84
  return;
81
85
  }
@@ -107,7 +111,7 @@ async function doAdd(name, target, opts) {
107
111
  await registerHost(spec);
108
112
  console.log(chalk.green(`Enrolled ${name}`) + chalk.gray(` (${spec.source}${spec.os ? `, ${spec.os}` : ''}${spec.caps?.length ? `, caps: ${spec.caps.join(',')}` : ''})`));
109
113
  if (opts.enroll !== false)
110
- await maybeBootstrap(sshTarget, name);
114
+ await maybeBootstrap(sshTarget, name, probe);
111
115
  }
112
116
  async function doList(json) {
113
117
  const hosts = await listAllHosts();
@@ -122,8 +126,11 @@ async function doList(json) {
122
126
  console.log(chalk.bold('NAME').padEnd(20) + chalk.bold('SOURCE').padEnd(13) + chalk.bold('TARGET').padEnd(28) + chalk.bold('CAPS'));
123
127
  for (const h of hosts) {
124
128
  const tgt = h.source === 'ssh-config' ? chalk.gray('(ssh-config)') : `${h.user ? h.user + '@' : ''}${h.address ?? ''}`;
129
+ // Cap TARGET at its column so a long user@host can't shove CAPS out of alignment.
130
+ const tgtW = stringWidth(tgt);
131
+ const tgtCol = tgtW > 28 ? truncateToWidth(tgt, 28) : tgt + ' '.repeat(28 - tgtW);
125
132
  const mark = h.enrolled ? '' : chalk.gray(' ·available');
126
- console.log(h.name.padEnd(20) + h.source.padEnd(13) + tgt.padEnd(28) + (h.caps?.join(',') ?? '') + mark);
133
+ console.log(h.name.padEnd(20) + h.source.padEnd(13) + tgtCol + (h.caps?.join(',') ?? '') + mark);
127
134
  }
128
135
  }
129
136
  async function doCheck(name) {
@@ -155,7 +162,10 @@ async function doRemove(name) {
155
162
  console.log(chalk.green(`Removed ${name}`));
156
163
  }
157
164
  async function doPs(json) {
158
- const tasks = listTasks();
165
+ // Heal any 'running' record whose local follower died (dropped connection,
166
+ // laptop sleep) against the remote `.exit` before listing, so `ps` never shows
167
+ // a finished run stuck at 'running'.
168
+ const tasks = reconcileRunningTasks(listTasks());
159
169
  if (json) {
160
170
  console.log(JSON.stringify(tasks, null, 2));
161
171
  return;
@@ -164,30 +174,24 @@ async function doPs(json) {
164
174
  console.log(chalk.gray('No host tasks yet. Dispatch one: agents run <agent> "<task>" --host <name>'));
165
175
  return;
166
176
  }
177
+ const cols = terminalWidth();
167
178
  console.log(chalk.bold('ID').padEnd(11) + chalk.bold('HOST').padEnd(16) + chalk.bold('AGENT').padEnd(10) + chalk.bold('STATUS').padEnd(11) + chalk.bold('PROMPT'));
168
179
  for (const t of tasks) {
169
180
  const status = t.status === 'completed' ? chalk.green(t.status) : t.status === 'failed' ? chalk.red(t.status) : chalk.yellow(t.status);
170
- console.log(t.id.padEnd(11) + t.host.padEnd(16) + t.agent.padEnd(10) + status.padEnd(11) + t.prompt.slice(0, 50));
181
+ // Prompt fills the remaining width instead of a fixed 50-char byte slice (98-char rows).
182
+ const promptCol = truncateToWidth(t.prompt, Math.max(12, cols - (11 + 16 + 10 + 11)));
183
+ console.log(t.id.padEnd(11) + t.host.padEnd(16) + t.agent.padEnd(10) + status.padEnd(11) + promptCol);
171
184
  }
172
185
  }
173
186
  async function doLogs(id, follow) {
174
- const task = loadTask(id);
175
- if (!task) {
187
+ const res = await showHostTaskLog(id, follow);
188
+ if (!res.found) {
176
189
  console.log(chalk.red(`Unknown task "${id}".`));
177
190
  process.exitCode = 1;
178
191
  return;
179
192
  }
180
- if (follow && task.status === 'running') {
181
- const code = await followHostTask(task.target, { remoteLog: task.remoteLog, remoteExit: task.remoteExit, taskId: id, echo: true });
182
- process.exitCode = code === -1 ? 1 : code;
183
- return;
184
- }
185
- try {
186
- process.stdout.write(fs.readFileSync(localLogPath(id), 'utf-8'));
187
- }
188
- catch {
189
- console.log(chalk.gray('(no local log captured for this task)'));
190
- }
193
+ if (res.exitCode !== undefined)
194
+ process.exitCode = res.exitCode;
191
195
  }
192
196
  /** Register the `agents hosts` command tree. */
193
197
  export function registerHostsCommand(program) {
@@ -18,6 +18,8 @@ import { type PluginResourceGroup } from '../lib/plugins.js';
18
18
  /** Resource kinds the inspect command can drill into. */
19
19
  declare const DRILLABLE_KINDS: readonly ["commands", "skills", "hooks", "mcp", "rules", "plugins", "workflows", "subagents"];
20
20
  type DrillableKind = typeof DRILLABLE_KINDS[number];
21
+ /** Wrap a separator-joined value list under `prefix`, preserving a hanging indent. */
22
+ export declare function wrapJoined(prefix: string, items: string[], sep: string, width: number): string[];
21
23
  interface ResourceItem {
22
24
  name: string;
23
25
  source: string;
@@ -31,6 +31,7 @@ import { discoverPlugins, discoverPluginsInDir, pluginResourceGroups } from '../
31
31
  import { PLUGIN_GROUP_COLORS } from './plugins.js';
32
32
  import { countSessionsInScope } from '../lib/session/discover.js';
33
33
  import { damerauLevenshtein } from '../lib/fuzzy.js';
34
+ import { terminalWidth, truncateToWidth, stringWidth, stripAnsi } from '../lib/session/width.js';
34
35
  /** Resource kinds the inspect command can drill into. */
35
36
  const DRILLABLE_KINDS = [
36
37
  'commands',
@@ -68,6 +69,44 @@ const SINGULAR_DRILL_ALIASES = {
68
69
  const CAPABILITY_NAMES = [
69
70
  'hooks', 'mcp', 'skills', 'commands', 'subagents', 'plugins', 'workflows', 'rules', 'allowlist',
70
71
  ];
72
+ /** Wrap a separator-joined value list under `prefix`, preserving a hanging indent. */
73
+ export function wrapJoined(prefix, items, sep, width) {
74
+ if (items.length === 0)
75
+ return [];
76
+ const continuation = ' '.repeat(stringWidth(prefix));
77
+ const fitItem = (item, linePrefix) => {
78
+ const room = Math.max(1, width - stringWidth(linePrefix));
79
+ return stringWidth(item) > room ? truncateToWidth(item, room) : item;
80
+ };
81
+ const lines = [];
82
+ let linePrefix = prefix;
83
+ let line = prefix;
84
+ let hasItem = false;
85
+ for (const raw of items) {
86
+ const item = fitItem(raw, linePrefix);
87
+ const candidate = hasItem ? `${line}${sep}${item}` : `${line}${item}`;
88
+ if (hasItem && stringWidth(candidate) > width) {
89
+ lines.push(line);
90
+ linePrefix = continuation;
91
+ line = continuation + fitItem(raw, linePrefix);
92
+ hasItem = true;
93
+ }
94
+ else {
95
+ line = candidate;
96
+ hasItem = true;
97
+ }
98
+ }
99
+ if (hasItem)
100
+ lines.push(line);
101
+ return lines;
102
+ }
103
+ function printWrappedJoined(prefix, items, sep) {
104
+ for (const line of wrapJoined(prefix, items, sep, terminalWidth()))
105
+ console.log(line);
106
+ }
107
+ function truncateValueForPrefix(prefix, value) {
108
+ return truncateToWidth(stripAnsi(value), Math.max(1, terminalWidth() - stringWidth(prefix)));
109
+ }
71
110
  // ─── Command registration ────────────────────────────────────────────────────
72
111
  export function registerInspectCommand(program) {
73
112
  const cmd = addHostOption(program.command('inspect <target>'))
@@ -425,7 +464,10 @@ function renderRepoSummary(repo, options) {
425
464
  console.log(` ${'git'.padEnd(10)} ${git.branch}${dirty}${url}`);
426
465
  if (git.lastCommit) {
427
466
  const rel = git.lastCommit.relative ? ` ${chalk.gray(`(${git.lastCommit.relative})`)}` : '';
428
- sub('last', `${chalk.cyan(git.lastCommit.sha)} ${truncate(git.lastCommit.subject, 60)}${rel}`);
467
+ const prefix = ` ${''.padEnd(10)} ${chalk.gray('last'.padEnd(8))} `;
468
+ const sha = chalk.cyan(git.lastCommit.sha);
469
+ const subjectWidth = Math.max(8, terminalWidth() - stringWidth(prefix) - stringWidth(sha) - 2 - stringWidth(rel));
470
+ console.log(`${prefix}${sha} ${truncateToWidth(git.lastCommit.subject, subjectWidth)}${rel}`);
429
471
  }
430
472
  if (git.ahead !== null && git.behind !== null && (git.ahead > 0 || git.behind > 0)) {
431
473
  sub('sync', `ahead ${git.ahead} ${chalk.gray('·')} behind ${git.behind}`);
@@ -440,10 +482,12 @@ function renderRepoSummary(repo, options) {
440
482
  console.log(` ${'manifests'.padEnd(10)} ${manifests.join(', ')}`);
441
483
  if (manifest) {
442
484
  if (manifest.versions.length > 0) {
443
- sub('versions', manifest.versions.map(v => `${v.agent} ${chalk.cyan(v.version)}`).join(chalk.gray(' · ')));
485
+ const prefix = ` ${''.padEnd(10)} ${chalk.gray('versions'.padEnd(8))} `;
486
+ printWrappedJoined(prefix, manifest.versions.map(v => `${v.agent} ${chalk.cyan(v.version)}`), chalk.gray(' · '));
444
487
  }
445
488
  if (manifest.strategies.length > 0) {
446
- sub('run', manifest.strategies.map(s => `${s.agent}:${s.strategy}`).join(chalk.gray(' · ')));
489
+ const prefix = ` ${''.padEnd(10)} ${chalk.gray('run'.padEnd(8))} `;
490
+ printWrappedJoined(prefix, manifest.strategies.map(s => `${s.agent}:${s.strategy}`), chalk.gray(' · '));
447
491
  }
448
492
  }
449
493
  }
@@ -551,8 +595,10 @@ async function renderSummary(agent, version, versionHome, options) {
551
595
  ['alias', aliasPath],
552
596
  ['strategy', strategy],
553
597
  ];
554
- for (const [k, v] of rows)
555
- console.log(` ${k.padEnd(10)} ${v}`);
598
+ for (const [k, v] of rows) {
599
+ const prefix = ` ${k.padEnd(10)} `;
600
+ console.log(prefix + truncateValueForPrefix(prefix, v));
601
+ }
556
602
  console.log('\n' + chalk.bold('Capabilities'));
557
603
  for (const cap of CAPABILITY_NAMES) {
558
604
  const res = capabilities[cap];
@@ -603,7 +649,8 @@ function renderItemList(header, jsonHead, kind, items, options) {
603
649
  const tag = chalk.gray(`[${item.source}]`.padEnd(10));
604
650
  console.log(` ${tag} ${termLink(chalk.cyan(item.name), item.linkTarget)}`);
605
651
  if (item.description) {
606
- console.log(` ${chalk.gray(truncate(item.description, 90))}`);
652
+ const prefix = ' ';
653
+ console.log(prefix + chalk.gray(truncateToWidth(item.description, Math.max(1, terminalWidth() - stringWidth(prefix)))));
607
654
  }
608
655
  if (item.groups)
609
656
  printGroupRows(item.groups);
@@ -1171,7 +1218,7 @@ function readFirstProseLine(p) {
1171
1218
  }
1172
1219
  // ─── OSC-8 + path helpers ────────────────────────────────────────────────────
1173
1220
  function termLink(text, filePath) {
1174
- if (!filePath)
1221
+ if (!filePath || !process.stdout.isTTY)
1175
1222
  return text;
1176
1223
  const url = `file://${filePath}`;
1177
1224
  return `\x1b]8;;${url}\x1b\\${text}\x1b]8;;\x1b\\`;
@@ -0,0 +1,17 @@
1
+ /**
2
+ * `agents logs` — unified, discoverable run-log viewer.
3
+ *
4
+ * Resolves a run across two substrates and shows (or `-f` follows) its log:
5
+ * - host-dispatch tasks (`agents run --host`) → combined-stdout log, offset-tailed
6
+ * - sessions (the local index) → transcript, tailed via the sessions tailer
7
+ *
8
+ * `[id]`/`--session` load directly (host task tried first, then session). With no
9
+ * id, `--host`/`--agent`/`--version` filter a merged candidate list; one match is
10
+ * shown, several open the fuzzy picker (or, non-TTY, print the list).
11
+ *
12
+ * Additive: `agents hosts logs` and `agents sessions tail` are unchanged and share
13
+ * the same underlying helpers (showHostTaskLog / streamSessionTail).
14
+ */
15
+ import type { Command } from 'commander';
16
+ /** Register the top-level `agents logs` command. */
17
+ export declare function registerLogsCommand(program: Command): void;
@@ -0,0 +1,139 @@
1
+ /**
2
+ * `agents logs` — unified, discoverable run-log viewer.
3
+ *
4
+ * Resolves a run across two substrates and shows (or `-f` follows) its log:
5
+ * - host-dispatch tasks (`agents run --host`) → combined-stdout log, offset-tailed
6
+ * - sessions (the local index) → transcript, tailed via the sessions tailer
7
+ *
8
+ * `[id]`/`--session` load directly (host task tried first, then session). With no
9
+ * id, `--host`/`--agent`/`--version` filter a merged candidate list; one match is
10
+ * shown, several open the fuzzy picker (or, non-TTY, print the list).
11
+ *
12
+ * Additive: `agents hosts logs` and `agents sessions tail` are unchanged and share
13
+ * the same underlying helpers (showHostTaskLog / streamSessionTail).
14
+ */
15
+ import chalk from 'chalk';
16
+ import { discoverSessions, resolveSessionById } from '../lib/session/discover.js';
17
+ import { parseAgentFilter, renderSessionLog } from './sessions.js';
18
+ import { streamSessionTail, isTailable } from './sessions-tail.js';
19
+ import { showHostTaskLog } from '../lib/hosts/logs.js';
20
+ import { listTasks } from '../lib/hosts/tasks.js';
21
+ import { itemPicker } from '../lib/picker.js';
22
+ /** Compact one-line label used by both the picker and the non-TTY list. */
23
+ function candidateLabel(c) {
24
+ if (c.kind === 'task') {
25
+ const t = c.task;
26
+ const status = t.status === 'completed' ? chalk.green(t.status)
27
+ : t.status === 'failed' ? chalk.red(t.status)
28
+ : chalk.yellow(t.status);
29
+ return `${chalk.gray('task')} ${t.id.slice(0, 8).padEnd(9)} ${t.host.padEnd(14)} ${t.agent.padEnd(8)} ${status} ${t.prompt.slice(0, 40)}`;
30
+ }
31
+ const s = c.session;
32
+ const ver = s.version ? chalk.gray(`@${s.version}`) : '';
33
+ const title = s.label || s.topic || '';
34
+ return `${chalk.gray('sess')} ${s.shortId.padEnd(9)} ${(s.agent + ver).padEnd(14)} ${chalk.gray(s.timestamp.slice(0, 16))} ${title.slice(0, 40)}`;
35
+ }
36
+ /** Show a resolved session — follow (tail) or render its transcript. */
37
+ async function showSession(session, follow) {
38
+ if (follow) {
39
+ if (!isTailable(session.agent)) {
40
+ console.error(chalk.red(`Tailing is supported for claude and codex sessions only (got ${session.agent}).`));
41
+ process.exit(2);
42
+ }
43
+ await streamSessionTail(session, {});
44
+ return;
45
+ }
46
+ await renderSessionLog(session);
47
+ }
48
+ async function showCandidate(c, follow) {
49
+ if (c.kind === 'task') {
50
+ const res = await showHostTaskLog(c.task.id, follow);
51
+ if (res.exitCode !== undefined)
52
+ process.exitCode = res.exitCode;
53
+ return;
54
+ }
55
+ await showSession(c.session, follow);
56
+ }
57
+ /** Resolve an explicit id/--session: host task first, then a session. */
58
+ async function showById(id, follow) {
59
+ const hostRes = await showHostTaskLog(id, follow);
60
+ if (hostRes.found) {
61
+ if (hostRes.exitCode !== undefined)
62
+ process.exitCode = hostRes.exitCode;
63
+ return;
64
+ }
65
+ const sessions = await discoverSessions({ all: true, limit: 5000 });
66
+ const matches = resolveSessionById(sessions, id);
67
+ if (matches.length === 0) {
68
+ console.error(chalk.red(`No run or session found matching "${id}".`));
69
+ process.exit(1);
70
+ }
71
+ await showSession(matches[0], follow);
72
+ }
73
+ async function runLogs(id, opts) {
74
+ const follow = !!opts.follow;
75
+ const directId = opts.session ?? id;
76
+ if (directId) {
77
+ await showById(directId, follow);
78
+ return;
79
+ }
80
+ const { agent, version } = parseAgentFilter(opts.agent);
81
+ const wantVersion = opts.version ?? version;
82
+ // Host tasks carry no session-index metadata; sessions carry no host tag.
83
+ // So --host scopes to dispatched tasks, and --version to sessions.
84
+ const candidates = [];
85
+ let tasks = listTasks();
86
+ if (opts.host)
87
+ tasks = tasks.filter((t) => t.host === opts.host);
88
+ if (agent)
89
+ tasks = tasks.filter((t) => t.agent === agent);
90
+ for (const t of tasks)
91
+ candidates.push({ kind: 'task', task: t });
92
+ if (!opts.host) {
93
+ const sessions = await discoverSessions({ agent, version: wantVersion, limit: 50 });
94
+ for (const s of sessions)
95
+ candidates.push({ kind: 'session', session: s });
96
+ }
97
+ if (candidates.length === 0) {
98
+ console.error(chalk.yellow('No matching runs. Dispatch one: agents run <agent> "<task>" [--host <name>]'));
99
+ process.exit(1);
100
+ }
101
+ if (candidates.length === 1) {
102
+ await showCandidate(candidates[0], follow);
103
+ return;
104
+ }
105
+ // Multiple sessions matched → picker if interactive, else a list to pick from.
106
+ if (!process.stdin.isTTY) {
107
+ console.error(chalk.yellow(`${candidates.length} runs match. Pass an id or --session <id>:`));
108
+ for (const c of candidates.slice(0, 30))
109
+ console.error(' ' + candidateLabel(c));
110
+ process.exit(1);
111
+ }
112
+ const picked = await itemPicker({
113
+ message: 'Select a run to view its log:',
114
+ items: candidates,
115
+ filter: (query) => {
116
+ const q = query.trim().toLowerCase();
117
+ if (!q)
118
+ return candidates;
119
+ return candidates.filter((c) => candidateLabel(c).toLowerCase().includes(q));
120
+ },
121
+ labelFor: (c) => candidateLabel(c),
122
+ shortIdFor: (c) => (c.kind === 'task' ? c.task.id : c.session.shortId),
123
+ });
124
+ if (!picked)
125
+ return;
126
+ await showCandidate(picked.item, follow);
127
+ }
128
+ /** Register the top-level `agents logs` command. */
129
+ export function registerLogsCommand(program) {
130
+ program
131
+ .command('logs [id]')
132
+ .description('Show a run’s log — a host-dispatch task or a session. -f to follow a live one.')
133
+ .option('--host <name>', 'Scope to runs dispatched to a host')
134
+ .option('-a, --agent <agent>', 'Filter by agent (e.g. claude, codex@0.116.0)')
135
+ .option('--version <version>', 'Filter by agent version')
136
+ .option('--session <id>', 'Select a session/run by id (same as the positional id)')
137
+ .option('-f, --follow', 'Follow live output')
138
+ .action((id, opts) => runLogs(id, opts));
139
+ }
@@ -8,3 +8,6 @@
8
8
  import type { Command } from 'commander';
9
9
  /** Register the hidden `agents models` command. */
10
10
  export declare function registerModelsCommand(program: Command): void;
11
+ export declare function formatModelSourceLine(kind: string, sourcePath: string, width?: number): string;
12
+ export declare function formatModelAliasLines(parts: string[], width?: number): string[];
13
+ export declare function formatModelSummaryLine(star: string, id: string, displayName?: string, alias?: string, width?: number): string;
@@ -11,6 +11,8 @@ import { homeDir } from '../lib/platform/index.js';
11
11
  import { resolveAgentName, formatAgentError, agentLabel, } from '../lib/agents.js';
12
12
  import { listInstalledVersions, getGlobalDefault, resolveVersion, resolveVersionAlias } from '../lib/versions.js';
13
13
  import { getModelCatalog, locateModelSource } from '../lib/models.js';
14
+ import { terminalWidth, truncateToWidth, stringWidth } from '../lib/session/width.js';
15
+ import { wrapJoined } from './inspect.js';
14
16
  const MODEL_CAPABLE_AGENTS = ['claude', 'codex', 'gemini', 'opencode', 'cursor', 'openclaw', 'antigravity', 'kimi'];
15
17
  /**
16
18
  * Agents that don't necessarily install under ~/.agents/versions (cursor ships
@@ -131,21 +133,19 @@ function printCatalog(agent, version, isDefault, options) {
131
133
  console.log(chalk.yellow(` No models extracted from ${src.kind} at ${src.path}.`));
132
134
  return;
133
135
  }
134
- console.log(chalk.gray(` source: ${src.kind} (${shortPath(src.path)})`));
136
+ console.log(chalk.gray(formatModelSourceLine(src.kind, shortPath(src.path))));
135
137
  if (Object.keys(catalog.aliases).length > 0) {
136
138
  const parts = Object.entries(catalog.aliases).map(([alias, id]) => `${chalk.cyan(alias)}=${id}`);
137
- console.log(chalk.gray(` aliases: `) + parts.join(', '));
139
+ for (const line of formatModelAliasLines(parts))
140
+ console.log(line);
138
141
  }
139
142
  console.log();
140
143
  for (const model of catalog.models) {
141
144
  const star = model.isDefault ? chalk.green('*') : ' ';
142
- const display = model.displayName && model.displayName !== model.id
143
- ? chalk.gray(` (${model.displayName})`)
144
- : '';
145
- const aliasTag = model.alias ? chalk.cyan(` [${model.alias}]`) : '';
146
- console.log(` ${star} ${chalk.bold(model.id)}${display}${aliasTag}`);
145
+ console.log(formatModelSummaryLine(star, model.id, model.displayName, model.alias));
147
146
  if (model.description) {
148
- console.log(chalk.gray(` ${model.description}`));
147
+ const descPrefix = ' ';
148
+ console.log(chalk.gray(descPrefix + truncateToWidth(model.description, Math.max(1, terminalWidth() - stringWidth(descPrefix)))));
149
149
  }
150
150
  if (options.cloud && model.perCloud) {
151
151
  const c = model.perCloud;
@@ -175,3 +175,22 @@ function printCatalog(agent, version, isDefault, options) {
175
175
  function shortPath(p) {
176
176
  return p.replace(homeDir(), '~');
177
177
  }
178
+ export function formatModelSourceLine(kind, sourcePath, width = terminalWidth()) {
179
+ const prefix = ` source: ${kind} (`;
180
+ const suffix = ')';
181
+ const room = Math.max(1, width - stringWidth(prefix) - stringWidth(suffix));
182
+ return prefix + truncateToWidth(sourcePath, room) + suffix;
183
+ }
184
+ export function formatModelAliasLines(parts, width = terminalWidth()) {
185
+ return wrapJoined(chalk.gray(' aliases: '), parts, ', ', width);
186
+ }
187
+ export function formatModelSummaryLine(star, id, displayName, alias, width = terminalWidth()) {
188
+ const display = displayName && displayName !== id ? ` (${displayName})` : '';
189
+ const aliasTag = alias ? ` [${alias}]` : '';
190
+ const prefix = ` ${star} `;
191
+ const plain = `${id}${display}${aliasTag}`;
192
+ if (stringWidth(plain) > Math.max(1, width - stringWidth(prefix))) {
193
+ return prefix + truncateToWidth(plain, Math.max(1, width - stringWidth(prefix)));
194
+ }
195
+ return prefix + chalk.bold(id) + chalk.gray(display) + chalk.cyan(aliasTag);
196
+ }