@phnx-labs/agents-cli 1.20.50 → 1.20.51

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 (82) hide show
  1. package/CHANGELOG.md +24 -0
  2. package/dist/commands/browser-picker.js +1 -18
  3. package/dist/commands/cloud.js +1 -25
  4. package/dist/commands/computer.d.ts +1 -0
  5. package/dist/commands/computer.js +129 -8
  6. package/dist/commands/exec.js +49 -6
  7. package/dist/commands/factory.js +1 -4
  8. package/dist/commands/inspect.js +1 -11
  9. package/dist/commands/mcp.js +2 -6
  10. package/dist/commands/message.js +1 -4
  11. package/dist/commands/profiles.js +1 -18
  12. package/dist/commands/repo.js +33 -14
  13. package/dist/commands/resource-view.d.ts +1 -0
  14. package/dist/commands/resource-view.js +5 -17
  15. package/dist/commands/secrets.d.ts +1 -0
  16. package/dist/commands/secrets.js +1 -28
  17. package/dist/commands/sessions-picker.js +1 -18
  18. package/dist/commands/sessions.js +6 -8
  19. package/dist/commands/teams-picker.js +1 -32
  20. package/dist/commands/teams.js +1 -27
  21. package/dist/commands/tmux.js +1 -3
  22. package/dist/commands/view.js +1 -9
  23. package/dist/commands/worktree.js +1 -4
  24. package/dist/lib/agents.d.ts +0 -4
  25. package/dist/lib/agents.js +20 -33
  26. package/dist/lib/auto-dispatch-linear.d.ts +18 -0
  27. package/dist/lib/auto-dispatch-linear.js +107 -0
  28. package/dist/lib/auto-dispatch-provider.d.ts +10 -0
  29. package/dist/lib/auto-dispatch-provider.js +25 -0
  30. package/dist/lib/auto-dispatch.d.ts +87 -0
  31. package/dist/lib/auto-dispatch.js +142 -0
  32. package/dist/lib/browser/cdp.js +11 -2
  33. package/dist/lib/browser/drivers/ssh.d.ts +28 -10
  34. package/dist/lib/browser/drivers/ssh.js +57 -18
  35. package/dist/lib/browser/refs.js +1 -5
  36. package/dist/lib/cli-resources.d.ts +0 -2
  37. package/dist/lib/cli-resources.js +30 -13
  38. package/dist/lib/cloud/rush.d.ts +0 -24
  39. package/dist/lib/cloud/rush.js +0 -31
  40. package/dist/lib/crabbox/cli.js +4 -1
  41. package/dist/lib/crabbox/lease.js +29 -1
  42. package/dist/lib/daemon.js +41 -0
  43. package/dist/lib/exec.js +31 -14
  44. package/dist/lib/format.d.ts +38 -0
  45. package/dist/lib/format.js +108 -0
  46. package/dist/lib/git.d.ts +21 -0
  47. package/dist/lib/git.js +92 -0
  48. package/dist/lib/hooks/cache.d.ts +9 -2
  49. package/dist/lib/hooks/cache.js +220 -8
  50. package/dist/lib/hooks.js +17 -8
  51. package/dist/lib/platform/exec.d.ts +4 -1
  52. package/dist/lib/platform/exec.js +8 -2
  53. package/dist/lib/resources.d.ts +0 -8
  54. package/dist/lib/resources.js +0 -10
  55. package/dist/lib/runner.js +10 -2
  56. package/dist/lib/secrets/Agents CLI.app/Contents/CodeResources +0 -0
  57. package/dist/lib/secrets/Agents CLI.app/Contents/MacOS/Agents CLI +0 -0
  58. package/dist/lib/session/active.d.ts +11 -1
  59. package/dist/lib/session/active.js +3 -0
  60. package/dist/lib/session/db.d.ts +1 -4
  61. package/dist/lib/session/db.js +20 -25
  62. package/dist/lib/session/discover.d.ts +2 -2
  63. package/dist/lib/session/discover.js +61 -48
  64. package/dist/lib/session/parse.js +35 -34
  65. package/dist/lib/session/render.d.ts +7 -3
  66. package/dist/lib/session/render.js +15 -9
  67. package/dist/lib/session/state.d.ts +55 -0
  68. package/dist/lib/session/state.js +87 -10
  69. package/dist/lib/session/types.d.ts +9 -0
  70. package/dist/lib/shims.d.ts +9 -3
  71. package/dist/lib/shims.js +44 -8
  72. package/dist/lib/ssh-tunnel.d.ts +33 -2
  73. package/dist/lib/ssh-tunnel.js +94 -7
  74. package/dist/lib/staleness/types.d.ts +0 -1
  75. package/dist/lib/types.d.ts +14 -1
  76. package/dist/lib/versions.d.ts +0 -26
  77. package/dist/lib/versions.js +2 -145
  78. package/dist/lib/warn-unpushed.d.ts +40 -0
  79. package/dist/lib/warn-unpushed.js +128 -0
  80. package/package.json +3 -1
  81. package/dist/lib/resources/index.d.ts +0 -53
  82. package/dist/lib/resources/index.js +0 -76
@@ -13,6 +13,7 @@ import * as crypto from 'crypto';
13
13
  import * as readline from 'readline';
14
14
  import { execFile } from 'child_process';
15
15
  import { promisify } from 'util';
16
+ import Database from '../sqlite.js';
16
17
  import { getAgentsDir, getHistoryDir } from '../state.js';
17
18
  const execFileAsync = promisify(execFile);
18
19
  import { AGENTS, agentConfigDirName, getCliVersion } from '../agents.js';
@@ -485,6 +486,7 @@ async function readClaudeMeta(filePath, sessionId, account, label) {
485
486
  ticketId: scan.ticketId,
486
487
  createdTickets: scan.createdTickets,
487
488
  spawnedTeam: scan.spawnedTeam,
489
+ plan: scan.plan,
488
490
  };
489
491
  }
490
492
  else {
@@ -510,6 +512,7 @@ async function readClaudeMeta(filePath, sessionId, account, label) {
510
512
  ticketId: scan.ticketId,
511
513
  createdTickets: scan.createdTickets,
512
514
  spawnedTeam: scan.spawnedTeam,
515
+ plan: scan.plan,
513
516
  };
514
517
  }
515
518
  return { meta, content: scan.contentText || '' };
@@ -1065,20 +1068,29 @@ let cachedOpenCodeAccount;
1065
1068
  async function getOpenCodeAccount() {
1066
1069
  if (cachedOpenCodeAccount !== undefined)
1067
1070
  return cachedOpenCodeAccount || undefined;
1071
+ // Read through the node/bun SQLite wrapper (not the `sqlite3` CLI) so this
1072
+ // works on every OS — the CLI is absent on Windows.
1073
+ let db;
1068
1074
  try {
1069
1075
  if (fs.existsSync(OPENCODE_DB)) {
1070
- const { stdout } = await execFileAsync('sqlite3', [
1071
- OPENCODE_DB,
1072
- 'SELECT email FROM control_account WHERE active=1 LIMIT 1;',
1073
- ], { encoding: 'utf-8' });
1074
- const out = stdout.trim();
1076
+ db = new Database(OPENCODE_DB);
1077
+ const row = db
1078
+ .prepare('SELECT email FROM control_account WHERE active=1 LIMIT 1;')
1079
+ .get();
1080
+ const out = typeof row?.email === 'string' ? row.email.trim() : '';
1075
1081
  if (out) {
1076
1082
  cachedOpenCodeAccount = out;
1077
1083
  return out;
1078
1084
  }
1079
1085
  }
1080
1086
  }
1081
- catch { /* sqlite3 unavailable or DB locked */ }
1087
+ catch { /* DB not accessible, sqlite module unavailable, or query failed */ }
1088
+ finally {
1089
+ try {
1090
+ db?.close();
1091
+ }
1092
+ catch { /* best-effort close */ }
1093
+ }
1082
1094
  cachedOpenCodeAccount = '';
1083
1095
  return undefined;
1084
1096
  }
@@ -1101,18 +1113,21 @@ async function scanOpenCodeIncremental() {
1101
1113
  }
1102
1114
  const account = await getOpenCodeAccount();
1103
1115
  const currentVersion = await getCurrentAgentVersion('opencode');
1116
+ // Read through the node/bun SQLite wrapper (not the `sqlite3` CLI) so this
1117
+ // works on every OS — the CLI is absent on Windows.
1118
+ let db;
1104
1119
  try {
1105
1120
  const query = `
1106
1121
  SELECT
1107
- s.id,
1108
- s.title,
1109
- s.directory,
1110
- s.version,
1111
- s.time_created,
1112
- s.time_updated,
1113
- COALESCE(stats.message_count, 0),
1114
- stats.token_count,
1115
- COALESCE(stats.has_token_data, 0)
1122
+ s.id AS id,
1123
+ s.title AS title,
1124
+ s.directory AS directory,
1125
+ s.version AS version,
1126
+ s.time_created AS time_created,
1127
+ s.time_updated AS time_updated,
1128
+ COALESCE(stats.message_count, 0) AS message_count,
1129
+ stats.token_count AS token_count,
1130
+ COALESCE(stats.has_token_data, 0) AS has_token_data
1116
1131
  FROM session s
1117
1132
  LEFT JOIN (
1118
1133
  SELECT
@@ -1133,23 +1148,22 @@ async function scanOpenCodeIncremental() {
1133
1148
  ORDER BY time_created DESC
1134
1149
  LIMIT 1000;
1135
1150
  `.replace(/\n/g, ' ');
1136
- const { stdout: out } = await execFileAsync('sqlite3', ['-separator', '|||', OPENCODE_DB, query], {
1137
- encoding: 'utf-8',
1138
- timeout: 5000,
1139
- maxBuffer: 32 * 1024 * 1024,
1140
- });
1151
+ db = new Database(OPENCODE_DB);
1152
+ const rows = db.prepare(query).all();
1141
1153
  const entries = [];
1142
- for (const line of out.split('\n')) {
1143
- if (!line.trim())
1144
- continue;
1145
- const [id, title, directory, version, timeCreatedStr, timeUpdatedStr, messageCountStr, tokenCountStr, hasTokenDataStr] = line.split('|||');
1154
+ for (const row of rows) {
1155
+ const id = typeof row.id === 'string' ? row.id : '';
1146
1156
  if (!id)
1147
1157
  continue;
1148
- const timeCreated = parseInt(timeCreatedStr, 10);
1149
- const timeUpdated = parseInt(timeUpdatedStr, 10);
1150
- const messageCount = parseInt(messageCountStr, 10);
1151
- const tokenCount = parseInt(tokenCountStr, 10);
1152
- const hasTokenData = hasTokenDataStr === '1';
1158
+ const title = typeof row.title === 'string' ? row.title : '';
1159
+ const directory = typeof row.directory === 'string' ? row.directory : '';
1160
+ const version = typeof row.version === 'string' ? row.version : '';
1161
+ const asInt = (v) => typeof v === 'number' ? v : parseInt(String(v), 10);
1162
+ const timeCreated = asInt(row.time_created);
1163
+ const timeUpdated = asInt(row.time_updated);
1164
+ const messageCount = asInt(row.message_count);
1165
+ const tokenCount = asInt(row.token_count);
1166
+ const hasTokenData = asInt(row.has_token_data) === 1;
1153
1167
  const timestamp = isNaN(timeCreated) ? new Date().toISOString() : new Date(timeCreated).toISOString();
1154
1168
  // OpenCode is one shared DB, not one file per session — its row carries a
1155
1169
  // per-session updated time. Set lastActivity explicitly (falling back to
@@ -1182,6 +1196,12 @@ async function scanOpenCodeIncremental() {
1182
1196
  console.error(`Warning: Could not query OpenCode sessions: ${err.message}`);
1183
1197
  }
1184
1198
  }
1199
+ finally {
1200
+ try {
1201
+ db?.close();
1202
+ }
1203
+ catch { /* best-effort close */ }
1204
+ }
1185
1205
  }
1186
1206
  // ---------------------------------------------------------------------------
1187
1207
  // OpenClaw
@@ -1770,6 +1790,9 @@ export async function scanClaudeSession(filePath) {
1770
1790
  const createdTickets = new Set();
1771
1791
  const pendingTicketTools = new Set();
1772
1792
  let spawnedTeam;
1793
+ // The LAST ExitPlanMode plan wins so a re-planned session surfaces its most
1794
+ // recent plan, matching the semantic the extension's re-parser relied on.
1795
+ let plan;
1773
1796
  try {
1774
1797
  for await (const line of rl) {
1775
1798
  if (!line.trim())
@@ -1802,6 +1825,14 @@ export async function scanClaudeSession(filePath) {
1802
1825
  if (typeof b?.id === 'string' && isTicketCreateTool(b?.name, b?.input?.command)) {
1803
1826
  pendingTicketTools.add(b.id);
1804
1827
  }
1828
+ // ExitPlanMode plan markdown — last one wins so a re-planned session
1829
+ // reports its most recent plan (the semantic parsePlanFromClaudeJsonl
1830
+ // implemented in the extension).
1831
+ if (b?.name === 'ExitPlanMode' && typeof b?.input?.plan === 'string') {
1832
+ const p = b.input.plan.trim();
1833
+ if (p)
1834
+ plan = b.input.plan;
1835
+ }
1805
1836
  }
1806
1837
  }
1807
1838
  if (pendingTicketTools.size > 0 && parsed.type === 'user' && Array.isArray(parsed.message?.content)) {
@@ -1949,6 +1980,7 @@ export async function scanClaudeSession(filePath) {
1949
1980
  ticketId: ticket?.id,
1950
1981
  createdTickets: createdTickets.size > 0 ? [...createdTickets] : undefined,
1951
1982
  spawnedTeam,
1983
+ plan,
1952
1984
  };
1953
1985
  }
1954
1986
  /** Stream a Codex JSONL file and extract scan-level metadata (session ID, cwd, topic, tokens). */
@@ -2152,25 +2184,6 @@ function getOpenClawWorkspaceMap() {
2152
2184
  // ---------------------------------------------------------------------------
2153
2185
  // Utilities
2154
2186
  // ---------------------------------------------------------------------------
2155
- /** Read up to maxLines non-empty lines from the beginning of a file. */
2156
- export function readFirstLines(filePath, maxLines) {
2157
- return new Promise((resolve) => {
2158
- const lines = [];
2159
- const stream = fs.createReadStream(filePath, { encoding: 'utf-8' });
2160
- const rl = readline.createInterface({ input: stream, crlfDelay: Infinity });
2161
- rl.on('line', (line) => {
2162
- if (line.trim()) {
2163
- lines.push(line);
2164
- }
2165
- if (lines.length >= maxLines) {
2166
- rl.close();
2167
- stream.destroy();
2168
- }
2169
- });
2170
- rl.on('close', () => resolve(lines));
2171
- rl.on('error', () => resolve(lines));
2172
- });
2173
- }
2174
2187
  /** Compute the SHA-256 hex digest of a string. */
2175
2188
  function sha256(input) {
2176
2189
  return crypto.createHash('sha256').update(input).digest('hex');
@@ -6,8 +6,8 @@
6
6
  * objects suitable for rendering, filtering, and summarization.
7
7
  */
8
8
  import * as fs from 'fs';
9
+ import { truncate } from '../format.js';
9
10
  import * as path from 'path';
10
- import { execFileSync } from 'child_process';
11
11
  import Database from '../sqlite.js';
12
12
  /**
13
13
  * Largest session file we will load into memory. Above this we throw a clean
@@ -218,10 +218,6 @@ export function summarizeToolUse(tool, args) {
218
218
  }
219
219
  }
220
220
  }
221
- /** Truncate a string to n characters, appending '...' if shortened. */
222
- function truncate(s, n) {
223
- return s.length <= n ? s : s.slice(0, n - 3) + '...';
224
- }
225
221
  /** Replace the home directory prefix with ~ for display. */
226
222
  function shortenPath(p) {
227
223
  const home = process.env.HOME || '';
@@ -1006,45 +1002,50 @@ export function parseOpenCode(filePath) {
1006
1002
  if (!dbPath || !sessionId)
1007
1003
  return [];
1008
1004
  const events = [];
1005
+ // Read through the node/bun SQLite wrapper (not the `sqlite3` CLI) so this
1006
+ // works on every OS — the CLI is absent on Windows.
1007
+ let rows;
1008
+ let db;
1009
1009
  try {
1010
- // Query messages with their parts, ordered chronologically.
1011
- // Each row: msg_role ||| part_type ||| part_data (truncated for tool output) ||| msg_time
1010
+ // Query messages with their parts, ordered chronologically. Tool parts are
1011
+ // truncated to keep large tool outputs from bloating memory; the session id
1012
+ // is bound as a parameter rather than interpolated.
1012
1013
  const query = `
1013
1014
  SELECT
1014
- json_extract(m.data, '$.role'),
1015
- json_extract(p.data, '$.type'),
1015
+ json_extract(m.data, '$.role') AS role,
1016
+ json_extract(p.data, '$.type') AS part_type,
1016
1017
  CASE
1017
1018
  WHEN json_extract(p.data, '$.type') = 'tool'
1018
1019
  THEN substr(p.data, 1, 2000)
1019
1020
  ELSE p.data
1020
- END,
1021
- m.time_created
1021
+ END AS part_data,
1022
+ m.time_created AS time_created
1022
1023
  FROM message m
1023
1024
  JOIN part p ON p.message_id = m.id AND p.session_id = m.session_id
1024
- WHERE m.session_id = '${sessionId.replace(/'/g, "''")}'
1025
+ WHERE m.session_id = ?
1025
1026
  ORDER BY m.time_created ASC, p.time_created ASC;
1026
1027
  `.replace(/\n/g, ' ');
1027
- const out = execFileSync('sqlite3', ['-separator', '|||', dbPath, query], {
1028
- encoding: 'utf-8',
1029
- timeout: 10000,
1030
- maxBuffer: 32 * 1024 * 1024,
1031
- stdio: ['ignore', 'pipe', 'ignore'],
1032
- });
1033
- for (const line of out.split('\n')) {
1034
- if (!line.trim())
1035
- continue;
1036
- const sepIdx1 = line.indexOf('|||');
1037
- if (sepIdx1 === -1)
1038
- continue;
1039
- const sepIdx2 = line.indexOf('|||', sepIdx1 + 3);
1040
- if (sepIdx2 === -1)
1041
- continue;
1042
- const sepIdx3 = line.lastIndexOf('|||');
1043
- const role = line.slice(0, sepIdx1);
1044
- const partType = line.slice(sepIdx1 + 3, sepIdx2);
1045
- const partDataStr = line.slice(sepIdx2 + 3, sepIdx3);
1046
- const timeStr = line.slice(sepIdx3 + 3);
1047
- const timeMs = parseInt(timeStr, 10);
1028
+ db = new Database(dbPath);
1029
+ rows = db.prepare(query).all(sessionId);
1030
+ }
1031
+ catch {
1032
+ /* DB not accessible, sqlite module unavailable, or query failed */
1033
+ return events;
1034
+ }
1035
+ finally {
1036
+ try {
1037
+ db?.close();
1038
+ }
1039
+ catch {
1040
+ /* best-effort close */
1041
+ }
1042
+ }
1043
+ try {
1044
+ for (const row of rows) {
1045
+ const role = typeof row.role === 'string' ? row.role : '';
1046
+ const partType = typeof row.part_type === 'string' ? row.part_type : '';
1047
+ const partDataStr = typeof row.part_data === 'string' ? row.part_data : '';
1048
+ const timeMs = typeof row.time_created === 'number' ? row.time_created : parseInt(String(row.time_created), 10);
1048
1049
  const timestamp = isNaN(timeMs) ? new Date().toISOString() : new Date(timeMs).toISOString();
1049
1050
  let partData;
1050
1051
  try {
@@ -1112,7 +1113,7 @@ export function parseOpenCode(filePath) {
1112
1113
  }
1113
1114
  }
1114
1115
  catch {
1115
- /* DB not accessible or query failed */
1116
+ /* malformed row payload return what we parsed so far */
1116
1117
  }
1117
1118
  return events;
1118
1119
  }
@@ -6,7 +6,7 @@
6
6
  * errors, final message). The markdown renderer emits a full conversation
7
7
  * transcript. Filtering by role and turn slicing is handled here as well.
8
8
  */
9
- import type { SessionEvent } from './types.js';
9
+ import type { SessionEvent, SessionMeta } from './types.js';
10
10
  /**
11
11
  * Return absPath relative to cwd; fall back to ~/… then absolute.
12
12
  */
@@ -103,7 +103,11 @@ export interface RenderConversationMarkdownOptions {
103
103
  }
104
104
  export declare function renderConversationMarkdown(events: SessionEvent[], opts?: RenderConversationMarkdownOptions): string;
105
105
  /**
106
- * Render session as JSON (normalized events).
106
+ * Render one session's JSON output — the shape `agents sessions <id> --json`
107
+ * emits. When `meta` is provided (the standard path), returns a
108
+ * `{ session, events }` wrapper so top-level fields (`plan`, `prUrl`, …) that
109
+ * live on `SessionMeta` are visible without re-parsing events. Legacy
110
+ * callers that pass no meta still get a bare `SessionEvent[]` array.
107
111
  */
108
- export declare function renderJson(events: SessionEvent[]): string;
112
+ export declare function renderJson(events: SessionEvent[], meta?: SessionMeta): string;
109
113
  export {};
@@ -7,6 +7,7 @@
7
7
  * transcript. Filtering by role and turn slicing is handled here as well.
8
8
  */
9
9
  import chalk from 'chalk';
10
+ import { truncate } from '../format.js';
10
11
  import { summarizeToolUse } from './parse.js';
11
12
  import { cleanSessionPrompt, extractSessionTopic } from './prompt.js';
12
13
  import { renderMarkdown } from '../markdown.js';
@@ -336,14 +337,14 @@ function renderCommandsSection(cmds, lines) {
336
337
  if (catEntry.signal === 'mid') {
337
338
  // Mid signal: display the bucket key (e.g. ssh→openclaw browser) with aggregate count
338
339
  const countSuffix = v.count > 1 ? chalk.gray(` × ${v.count}`) : '';
339
- lines.push(` ${chalk.cyan(truncateCmd(key, 80))}${countSuffix}`);
340
+ lines.push(` ${chalk.cyan(truncate(key, 80))}${countSuffix}`);
340
341
  }
341
342
  else {
342
343
  // High signal: display distinct raw sample commands
343
344
  const distinctSamples = pickDistinct(v.samples, 3);
344
345
  for (const sample of distinctSamples) {
345
346
  const countSuffix = v.count > 1 ? chalk.gray(` × ${v.count}`) : '';
346
- lines.push(` ${chalk.cyan(truncateCmd(sample, 80))}${countSuffix}`);
347
+ lines.push(` ${chalk.cyan(truncate(sample, 80))}${countSuffix}`);
347
348
  }
348
349
  }
349
350
  shown++;
@@ -354,7 +355,7 @@ function renderCommandsSection(cmds, lines) {
354
355
  lines.push(` ${chalk.dim('Other')} ${chalk.gray(`(${otherCount})`)}`);
355
356
  for (const [, v] of Array.from(otherKeys.entries()).slice(0, 5)) {
356
357
  const countSuffix = v.count > 1 ? chalk.gray(` × ${v.count}`) : '';
357
- lines.push(` ${chalk.cyan(truncateCmd(v.samples[0] ?? '', 80))}${countSuffix}`);
358
+ lines.push(` ${chalk.cyan(truncate(v.samples[0] ?? '', 80))}${countSuffix}`);
358
359
  }
359
360
  }
360
361
  lines.push('');
@@ -372,9 +373,6 @@ function pickDistinct(samples, max) {
372
373
  }
373
374
  return result.length > 0 ? result : samples.slice(0, max);
374
375
  }
375
- function truncateCmd(cmd, max) {
376
- return cmd.length <= max ? cmd : cmd.slice(0, max - 1) + '…';
377
- }
378
376
  // ── File grouping ─────────────────────────────────────────────────────────────
379
377
  /** Group file paths by their parent directory, relative to cwd. */
380
378
  function groupByParentDir(paths, cwd) {
@@ -916,10 +914,18 @@ export function renderConversationMarkdown(events, opts = {}) {
916
914
  return parts.join('\n\n');
917
915
  }
918
916
  /**
919
- * Render session as JSON (normalized events).
917
+ * Render one session's JSON output — the shape `agents sessions <id> --json`
918
+ * emits. When `meta` is provided (the standard path), returns a
919
+ * `{ session, events }` wrapper so top-level fields (`plan`, `prUrl`, …) that
920
+ * live on `SessionMeta` are visible without re-parsing events. Legacy
921
+ * callers that pass no meta still get a bare `SessionEvent[]` array.
920
922
  */
921
- export function renderJson(events) {
922
- return JSON.stringify(events, null, 2);
923
+ export function renderJson(events, meta) {
924
+ if (!meta)
925
+ return JSON.stringify(events, null, 2);
926
+ // Strip internal-only bookkeeping fields the listing --json path also strips.
927
+ const { _matchedTerms, _bm25Score, _remote, ...session } = meta;
928
+ return JSON.stringify({ session, events }, null, 2);
923
929
  }
924
930
  /** Replace the home directory prefix with ~ for trace display. */
925
931
  function shortenPathTrace(p) {
@@ -17,6 +17,30 @@
17
17
  import type { SessionEvent } from './types.js';
18
18
  export type SessionActivity = 'working' | 'waiting_input' | 'idle';
19
19
  export type AwaitingReason = 'question' | 'plan_review' | 'permission';
20
+ /** One discrete choice the agent offered the user. */
21
+ export interface QuestionOption {
22
+ /** The choice label — also what a free-text reply channel sends back. */
23
+ label: string;
24
+ /** Optional longer description shown under the label. */
25
+ description?: string;
26
+ /**
27
+ * Selection keystroke for an interactive TUI prompt (AskUserQuestion / plan /
28
+ * permission are select-lists, not text inputs): a digit ('1'), or 'esc' to
29
+ * cancel/deny. Absent for a plain prose question, which takes free text.
30
+ */
31
+ key?: string;
32
+ }
33
+ /**
34
+ * The decision an agent handed back to the user, extracted at the SOURCE so every
35
+ * consumer (Factory panel, teams, cloud) gets the real question + options instead
36
+ * of re-deriving them from a truncated preview line. `reason` mirrors
37
+ * {@link AwaitingReason}; `options` is present when the agent offered discrete choices.
38
+ */
39
+ export interface StructuredQuestion {
40
+ text: string;
41
+ reason: AwaitingReason;
42
+ options?: QuestionOption[];
43
+ }
20
44
  export interface DetectedPr {
21
45
  url: string;
22
46
  number?: number;
@@ -40,6 +64,21 @@ export interface SessionState {
40
64
  lastEventKind?: SessionEvent['type'];
41
65
  /** Single-line description of the latest turn (message text or tool action). */
42
66
  preview?: string;
67
+ /**
68
+ * The structured decision the agent is waiting on (question / plan / permission),
69
+ * with its options when it offered any. Set only when activity is waiting_input.
70
+ */
71
+ question?: StructuredQuestion;
72
+ /**
73
+ * The plan markdown from the most recent `ExitPlanMode` tool call, surfaced
74
+ * when `awaitingReason === 'plan_review'`. The state engine detects the
75
+ * handoff off the same tool event; carrying the plan text alongside it lets
76
+ * consumers (the Factory NEEDS-YOU panel, `agents sessions <id> --json`)
77
+ * render the actual plan without re-parsing the transcript.
78
+ */
79
+ plan?: string;
80
+ /** Last few assistant turns (most-recent last), one line each — panel context. */
81
+ tail?: string[];
43
82
  lastActivityMs?: number;
44
83
  pr?: DetectedPr;
45
84
  worktree?: DetectedWorktree;
@@ -85,6 +124,22 @@ export declare function isTicketCreateTool(name?: string, command?: string): boo
85
124
  * take `#<number>`. Returns undefined when neither shape is present.
86
125
  */
87
126
  export declare function extractCreatedTicket(text?: string): string | undefined;
127
+ /**
128
+ * Pull the plan markdown out of an `ExitPlanMode` tool_use event's args. The
129
+ * Claude tool schema is `{ plan: string }`; the transcript parser already
130
+ * lifts `input` onto `event.args`. Returns undefined for a missing/empty plan
131
+ * so consumers can rely on `plan?: string` truthiness.
132
+ */
133
+ export declare function extractPlanText(args?: Record<string, any>): string | undefined;
134
+ /**
135
+ * Structured question from a Claude `AskUserQuestion` tool call. Its input is
136
+ * `{ questions: [{ question, header, options: [{label, description}] }] }` and the
137
+ * whole thing is already parsed onto `event.args` by the transcript parser — this
138
+ * surfaces the first question + its options (instead of collapsing to a generic
139
+ * "Asked you a question"). The prompt is a select-list, so each option carries its
140
+ * 1-based selection digit as `key`.
141
+ */
142
+ export declare function structuredQuestionFromAsk(args?: Record<string, any>): StructuredQuestion | undefined;
88
143
  /**
89
144
  * Infer live activity + a preview from a chronological event slice. `pr` /
90
145
  * `ticket` / `worktree` are attached by `inferSessionState`; this focuses on the
@@ -130,6 +130,69 @@ export function extractCreatedTicket(text) {
130
130
  return `#${gh[1]}`;
131
131
  return undefined;
132
132
  }
133
+ /**
134
+ * Pull the plan markdown out of an `ExitPlanMode` tool_use event's args. The
135
+ * Claude tool schema is `{ plan: string }`; the transcript parser already
136
+ * lifts `input` onto `event.args`. Returns undefined for a missing/empty plan
137
+ * so consumers can rely on `plan?: string` truthiness.
138
+ */
139
+ export function extractPlanText(args) {
140
+ const plan = args?.plan;
141
+ if (typeof plan !== 'string')
142
+ return undefined;
143
+ const trimmed = plan.trim();
144
+ return trimmed ? plan : undefined;
145
+ }
146
+ /**
147
+ * Structured question from a Claude `AskUserQuestion` tool call. Its input is
148
+ * `{ questions: [{ question, header, options: [{label, description}] }] }` and the
149
+ * whole thing is already parsed onto `event.args` by the transcript parser — this
150
+ * surfaces the first question + its options (instead of collapsing to a generic
151
+ * "Asked you a question"). The prompt is a select-list, so each option carries its
152
+ * 1-based selection digit as `key`.
153
+ */
154
+ export function structuredQuestionFromAsk(args) {
155
+ const q = Array.isArray(args?.questions) ? args.questions[0] : undefined;
156
+ if (!q)
157
+ return undefined;
158
+ const text = oneLine(String(q.question ?? q.header ?? '')) || 'Asked you a question';
159
+ const raw = Array.isArray(q.options) ? q.options : [];
160
+ const options = [];
161
+ for (const o of raw) {
162
+ const label = typeof o === 'string' ? oneLine(o) : o?.label != null ? oneLine(String(o.label)) : '';
163
+ if (!label)
164
+ continue;
165
+ const description = typeof o === 'object' && o?.description != null ? oneLine(String(o.description)) : undefined;
166
+ options.push({ label, description, key: String(options.length + 1) });
167
+ }
168
+ return { text, reason: 'question', options: options.length ? options : undefined };
169
+ }
170
+ /**
171
+ * Canonical approve/deny choices for an interactive prompt that carries no
172
+ * agent-supplied option list: Claude's plan-review and permission dialogs. Approve
173
+ * is reliably option 1; deny/keep-planning maps to ESC, which cancels the prompt in
174
+ * every variant (2- or 3-option) — safer than guessing a digit that could differ.
175
+ */
176
+ function planReviewQuestion() {
177
+ return {
178
+ text: 'Plan ready — review it',
179
+ reason: 'plan_review',
180
+ options: [
181
+ { label: 'Approve plan', key: '1' },
182
+ { label: 'Keep planning', key: 'esc' },
183
+ ],
184
+ };
185
+ }
186
+ function permissionQuestion(preview) {
187
+ return {
188
+ text: preview ? `Permission — ${preview}` : 'Waiting on a permission prompt',
189
+ reason: 'permission',
190
+ options: [
191
+ { label: 'Approve', key: '1' },
192
+ { label: 'Deny', key: 'esc' },
193
+ ],
194
+ };
195
+ }
133
196
  /** Does an assistant message read as a question directed at the user? */
134
197
  function looksLikeQuestion(text) {
135
198
  const t = text.trim();
@@ -177,18 +240,27 @@ export function inferActivity(events, ctx = {}) {
177
240
  const lastToolUse = lastOf(meaningful, e => e.type === 'tool_use');
178
241
  // The most informative recent line: a message or tool call as-is, but for a
179
242
  // trailing tool_result/thinking show the tool *call* that produced it (its
180
- // command) rather than a bare "↳ Bash".
243
+ // command), or — failing that — the last assistant message, rather than a bare
244
+ // "↳ Bash" or a content-free "thinking…". This is what stops a trailing thinking
245
+ // block from masking the real turn (the defect behind the "Thinking…" panel).
181
246
  const previewSource = !last
182
247
  ? undefined
183
248
  : last.type === 'message' || last.type === 'tool_use'
184
249
  ? last
185
- : (lastToolUse ?? last);
250
+ : (lastToolUse ?? lastMsg ?? last);
251
+ // Last few assistant turns, most-recent last — context for the decision panel.
252
+ const tail = meaningful
253
+ .filter(e => e.type === 'message' && e.role === 'assistant' && e.content)
254
+ .slice(-3)
255
+ .map(e => oneLine(e.content ?? ''))
256
+ .filter(Boolean);
186
257
  const base = {
187
258
  activity: 'idle',
188
259
  lastRole: lastMsg?.role,
189
260
  lastEventKind: last?.type,
190
261
  lastActivityMs: ctx.mtimeMs,
191
262
  preview: previewSource ? describeEvent(previewSource) : undefined,
263
+ tail: tail.length ? tail : undefined,
192
264
  };
193
265
  if (!last)
194
266
  return base;
@@ -196,12 +268,15 @@ export function inferActivity(events, ctx = {}) {
196
268
  // tool and nothing has come after it.
197
269
  const lastPlanOrAsk = lastOf(meaningful, e => e.type === 'tool_use' && (e.tool === PLAN_TOOL || e.tool === ASK_TOOL));
198
270
  if (lastPlanOrAsk && meaningful.indexOf(lastPlanOrAsk) === meaningful.length - 1) {
199
- return {
200
- ...base,
201
- activity: 'waiting_input',
202
- awaitingReason: lastPlanOrAsk.tool === PLAN_TOOL ? 'plan_review' : 'question',
203
- preview: lastPlanOrAsk.tool === PLAN_TOOL ? 'Plan ready — awaiting your review' : 'Asked you a question',
204
- };
271
+ if (lastPlanOrAsk.tool === PLAN_TOOL) {
272
+ const question = planReviewQuestion();
273
+ const plan = extractPlanText(lastPlanOrAsk.args);
274
+ return { ...base, activity: 'waiting_input', awaitingReason: 'plan_review', preview: question.text, question, plan };
275
+ }
276
+ // AskUserQuestion: surface the real question + options (they're on `args`),
277
+ // not the generic "Asked you a question" that discarded them.
278
+ const question = structuredQuestionFromAsk(lastPlanOrAsk.args) ?? { text: 'Asked you a question', reason: 'question' };
279
+ return { ...base, activity: 'waiting_input', awaitingReason: 'question', preview: question.text, question };
205
280
  }
206
281
  // Pending tool call (tool_use with no following tool_result): mid-turn.
207
282
  if (last.type === 'tool_use') {
@@ -209,7 +284,7 @@ export function inferActivity(events, ctx = {}) {
209
284
  return { ...base, activity: 'working' };
210
285
  // Alive but the file hasn't moved — likely blocked on a permission prompt.
211
286
  if (ctx.pidAlive)
212
- return { ...base, activity: 'waiting_input', awaitingReason: 'permission' };
287
+ return { ...base, activity: 'waiting_input', awaitingReason: 'permission', question: permissionQuestion(base.preview) };
213
288
  return { ...base, activity: 'idle' };
214
289
  }
215
290
  // Thinking or a tool result just landed → agent is mid-turn if recently active.
@@ -223,8 +298,10 @@ export function inferActivity(events, ctx = {}) {
223
298
  return { ...base, activity: canWork ? 'working' : 'idle' };
224
299
  }
225
300
  // Assistant spoke last and stopped. A trailing question → waiting; else idle.
301
+ // A prose question takes a free-text reply (no select-list), so no options/keys.
226
302
  if (looksLikeQuestion(last.content ?? '')) {
227
- return { ...base, activity: 'waiting_input', awaitingReason: 'question' };
303
+ const text = oneLine(last.content ?? '');
304
+ return { ...base, activity: 'waiting_input', awaitingReason: 'question', question: { text, reason: 'question' } };
228
305
  }
229
306
  return { ...base, activity: 'idle' };
230
307
  }
@@ -97,6 +97,15 @@ export interface SessionMeta {
97
97
  * `isTeamOrigin` (which marks sessions spawned BY a team).
98
98
  */
99
99
  spawnedTeam?: string;
100
+ /**
101
+ * The plan markdown from the LAST `ExitPlanMode` tool call in the transcript
102
+ * (Claude sessions only), captured at scan time. Present whenever the session
103
+ * ever entered plan-review; consumers can pair it with a live
104
+ * `awaitingReason === 'plan_review'` to decide whether it is still pending.
105
+ * Fills the gap that forced the Factory extension to re-read raw JSONL to
106
+ * recover the plan text — the CLI now carries it on the metadata row.
107
+ */
108
+ plan?: string;
100
109
  /**
101
110
  * True when the session was spawned programmatically (SDK entrypoint) rather
102
111
  * than by a human at the Claude CLI. Captured at scan time from the JSONL
@@ -77,7 +77,7 @@ export interface ConflictInfo {
77
77
  * top-level entry add/remove — deep edits to plugin contents won't
78
78
  * trigger auto-resync, run `agents sync` for that.
79
79
  */
80
- export declare const SHIM_SCHEMA_VERSION = 25;
80
+ export declare const SHIM_SCHEMA_VERSION = 26;
81
81
  /**
82
82
  * Generate the full bash shim script for the given agent. The returned string
83
83
  * is written to ~/.agents/shims/{cliCommand} and made executable.
@@ -145,8 +145,14 @@ export declare function removeShim(agent: AgentId): boolean;
145
145
  * editor opens the script and the agent never launches. The `.cmd` is
146
146
  * now the only Windows artifact and carries this alias marker so
147
147
  * staleness checks read it directly.
148
- */
149
- export declare const VERSIONED_ALIAS_SCHEMA_VERSION = 12;
148
+ * v13 — grok aliases resolve the binary from the versioned home's
149
+ * .grok/downloads first, then fall back to the global ~/.grok/downloads.
150
+ * The old template checked only the global dir, so a `grok@<version>`
151
+ * alias failed with "not installed" whenever the binary was staged into
152
+ * the versioned home (installer run with GROK_HOME set, or grok
153
+ * self-update under the shim).
154
+ */
155
+ export declare const VERSIONED_ALIAS_SCHEMA_VERSION = 13;
150
156
  /**
151
157
  * Generate a versioned alias script that directly execs a specific version.
152
158
  * e.g., claude@2.0.65 -> directly runs that version's binary