@phnx-labs/agents-cli 1.20.73 → 1.20.76

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 (209) hide show
  1. package/CHANGELOG.md +989 -0
  2. package/README.md +36 -10
  3. package/dist/bin/agents +0 -0
  4. package/dist/commands/apply.js +21 -3
  5. package/dist/commands/attach.d.ts +10 -0
  6. package/dist/commands/attach.js +41 -0
  7. package/dist/commands/computer.js +3 -3
  8. package/dist/commands/detach-core.d.ts +46 -0
  9. package/dist/commands/detach-core.js +61 -0
  10. package/dist/commands/detach.d.ts +5 -0
  11. package/dist/commands/detach.js +171 -0
  12. package/dist/commands/doctor.js +31 -1
  13. package/dist/commands/exec.js +31 -3
  14. package/dist/commands/export.d.ts +11 -0
  15. package/dist/commands/export.js +215 -0
  16. package/dist/commands/feed.d.ts +7 -1
  17. package/dist/commands/feed.js +47 -4
  18. package/dist/commands/go.d.ts +9 -2
  19. package/dist/commands/go.js +16 -6
  20. package/dist/commands/harness.d.ts +14 -0
  21. package/dist/commands/harness.js +145 -0
  22. package/dist/commands/import.js +87 -9
  23. package/dist/commands/inspect.js +5 -2
  24. package/dist/commands/profiles.d.ts +20 -0
  25. package/dist/commands/profiles.js +68 -21
  26. package/dist/commands/repo.js +14 -0
  27. package/dist/commands/routines.js +15 -2
  28. package/dist/commands/secrets.js +44 -21
  29. package/dist/commands/sessions-browser.js +1 -1
  30. package/dist/commands/sessions-export.js +19 -5
  31. package/dist/commands/sessions-migrate.d.ts +29 -0
  32. package/dist/commands/sessions-migrate.js +596 -0
  33. package/dist/commands/sessions-picker.js +16 -1
  34. package/dist/commands/sessions.d.ts +36 -13
  35. package/dist/commands/sessions.js +210 -40
  36. package/dist/commands/setup-computer.js +2 -2
  37. package/dist/commands/setup.js +6 -1
  38. package/dist/commands/ssh.js +71 -35
  39. package/dist/commands/status.js +10 -2
  40. package/dist/commands/versions.js +51 -11
  41. package/dist/commands/view.d.ts +5 -0
  42. package/dist/commands/view.js +99 -31
  43. package/dist/commands/watchdog.d.ts +7 -2
  44. package/dist/commands/watchdog.js +73 -57
  45. package/dist/index.js +59 -7
  46. package/dist/lib/activity.d.ts +14 -4
  47. package/dist/lib/activity.js +17 -5
  48. package/dist/lib/actor.d.ts +50 -0
  49. package/dist/lib/actor.js +166 -0
  50. package/dist/lib/agent-spec/provider.js +2 -1
  51. package/dist/lib/agent-spec/resolve.js +19 -5
  52. package/dist/lib/agent-spec/types.d.ts +9 -1
  53. package/dist/lib/agents.d.ts +56 -0
  54. package/dist/lib/agents.js +133 -7
  55. package/dist/lib/cloud/codex.d.ts +2 -0
  56. package/dist/lib/cloud/codex.js +14 -3
  57. package/dist/lib/cloud/session-index.d.ts +32 -0
  58. package/dist/lib/cloud/session-index.js +58 -0
  59. package/dist/lib/cloud/store.d.ts +7 -0
  60. package/dist/lib/cloud/store.js +25 -0
  61. package/dist/lib/config-transfer.d.ts +16 -0
  62. package/dist/lib/config-transfer.js +60 -0
  63. package/dist/lib/daemon.js +18 -2
  64. package/dist/lib/devices/connect.d.ts +15 -1
  65. package/dist/lib/devices/connect.js +15 -1
  66. package/dist/lib/devices/fleet.d.ts +21 -0
  67. package/dist/lib/devices/fleet.js +26 -0
  68. package/dist/lib/devices/health-report.d.ts +38 -0
  69. package/dist/lib/devices/health-report.js +214 -0
  70. package/dist/lib/devices/health.js +4 -1
  71. package/dist/lib/devices/reachability.d.ts +31 -0
  72. package/dist/lib/devices/reachability.js +40 -0
  73. package/dist/lib/devices/registry.d.ts +33 -0
  74. package/dist/lib/devices/registry.js +37 -0
  75. package/dist/lib/devices/resolve-target.d.ts +15 -27
  76. package/dist/lib/devices/resolve-target.js +63 -102
  77. package/dist/lib/devices/sync.d.ts +18 -0
  78. package/dist/lib/devices/sync.js +23 -1
  79. package/dist/lib/devices/tailscale.d.ts +3 -0
  80. package/dist/lib/devices/tailscale.js +1 -0
  81. package/dist/lib/events.d.ts +1 -1
  82. package/dist/lib/exec.d.ts +66 -0
  83. package/dist/lib/exec.js +195 -6
  84. package/dist/lib/export.d.ts +72 -0
  85. package/dist/lib/export.js +269 -0
  86. package/dist/lib/feed-post.d.ts +63 -0
  87. package/dist/lib/feed-post.js +204 -0
  88. package/dist/lib/fleet/apply.d.ts +32 -2
  89. package/dist/lib/fleet/apply.js +97 -10
  90. package/dist/lib/fleet/types.d.ts +11 -0
  91. package/dist/lib/fs-walk.d.ts +13 -0
  92. package/dist/lib/fs-walk.js +16 -7
  93. package/dist/lib/hooks.d.ts +1 -0
  94. package/dist/lib/hooks.js +156 -0
  95. package/dist/lib/hosts/dispatch.d.ts +7 -0
  96. package/dist/lib/hosts/dispatch.js +2 -0
  97. package/dist/lib/hosts/passthrough.js +2 -0
  98. package/dist/lib/hosts/reconnect.d.ts +78 -0
  99. package/dist/lib/hosts/reconnect.js +127 -0
  100. package/dist/lib/hosts/registry.d.ts +75 -14
  101. package/dist/lib/hosts/registry.js +205 -30
  102. package/dist/lib/hosts/remote-cmd.js +1 -0
  103. package/dist/lib/hosts/run-target.d.ts +13 -5
  104. package/dist/lib/hosts/run-target.js +26 -10
  105. package/dist/lib/hosts/session-index.d.ts +18 -1
  106. package/dist/lib/hosts/session-index.js +37 -2
  107. package/dist/lib/hosts/session-marker.d.ts +33 -0
  108. package/dist/lib/hosts/session-marker.js +51 -0
  109. package/dist/lib/hosts/tasks.d.ts +8 -4
  110. package/dist/lib/import.d.ts +25 -0
  111. package/dist/lib/import.js +107 -2
  112. package/dist/lib/isolation-boundary-report.d.ts +10 -0
  113. package/dist/lib/isolation-boundary-report.js +35 -0
  114. package/dist/lib/menubar/install-menubar.d.ts +20 -0
  115. package/dist/lib/menubar/install-menubar.js +45 -1
  116. package/dist/lib/migrate.d.ts +16 -0
  117. package/dist/lib/migrate.js +36 -0
  118. package/dist/lib/models.d.ts +27 -0
  119. package/dist/lib/models.js +54 -1
  120. package/dist/lib/overdue.d.ts +7 -2
  121. package/dist/lib/overdue.js +18 -11
  122. package/dist/lib/picker.js +4 -1
  123. package/dist/lib/platform/process.d.ts +17 -0
  124. package/dist/lib/platform/process.js +70 -0
  125. package/dist/lib/profiles-presets.js +9 -7
  126. package/dist/lib/profiles.d.ts +31 -0
  127. package/dist/lib/profiles.js +70 -0
  128. package/dist/lib/project-resources.js +20 -5
  129. package/dist/lib/pty-server.d.ts +2 -10
  130. package/dist/lib/pty-server.js +4 -38
  131. package/dist/lib/registry.d.ts +1 -1
  132. package/dist/lib/registry.js +48 -8
  133. package/dist/lib/rotate.d.ts +18 -0
  134. package/dist/lib/rotate.js +28 -0
  135. package/dist/lib/routines.d.ts +26 -0
  136. package/dist/lib/routines.js +19 -2
  137. package/dist/lib/runner.js +104 -7
  138. package/dist/lib/secrets/agent.d.ts +66 -1
  139. package/dist/lib/secrets/agent.js +154 -90
  140. package/dist/lib/secrets/bundles.d.ts +34 -0
  141. package/dist/lib/secrets/bundles.js +179 -19
  142. package/dist/lib/secrets/index.d.ts +15 -1
  143. package/dist/lib/secrets/index.js +21 -3
  144. package/dist/lib/secrets/rc-hygiene.d.ts +55 -0
  145. package/dist/lib/secrets/rc-hygiene.js +154 -0
  146. package/dist/lib/secrets/remote.d.ts +7 -4
  147. package/dist/lib/secrets/remote.js +7 -4
  148. package/dist/lib/secrets/sync-commands.d.ts +21 -0
  149. package/dist/lib/secrets/sync-commands.js +21 -0
  150. package/dist/lib/self-update.d.ts +23 -2
  151. package/dist/lib/self-update.js +86 -5
  152. package/dist/lib/session/active.d.ts +114 -7
  153. package/dist/lib/session/active.js +323 -52
  154. package/dist/lib/session/cloud.js +2 -1
  155. package/dist/lib/session/db.d.ts +48 -0
  156. package/dist/lib/session/db.js +238 -14
  157. package/dist/lib/session/detached.d.ts +30 -0
  158. package/dist/lib/session/detached.js +92 -0
  159. package/dist/lib/session/discover.d.ts +465 -2
  160. package/dist/lib/session/discover.js +1648 -468
  161. package/dist/lib/session/fork.js +2 -1
  162. package/dist/lib/session/hook-sessions.d.ts +43 -0
  163. package/dist/lib/session/hook-sessions.js +135 -0
  164. package/dist/lib/session/linear.d.ts +6 -0
  165. package/dist/lib/session/linear.js +55 -0
  166. package/dist/lib/session/migrate-targets.d.ts +65 -0
  167. package/dist/lib/session/migrate-targets.js +94 -0
  168. package/dist/lib/session/migrations.d.ts +37 -0
  169. package/dist/lib/session/migrations.js +60 -0
  170. package/dist/lib/session/parse.d.ts +18 -0
  171. package/dist/lib/session/parse.js +130 -30
  172. package/dist/lib/session/pid-registry.d.ts +34 -2
  173. package/dist/lib/session/pid-registry.js +49 -2
  174. package/dist/lib/session/render.d.ts +7 -2
  175. package/dist/lib/session/render.js +22 -10
  176. package/dist/lib/session/short-id.d.ts +17 -0
  177. package/dist/lib/session/short-id.js +20 -0
  178. package/dist/lib/session/state.js +6 -2
  179. package/dist/lib/shims.d.ts +27 -1
  180. package/dist/lib/shims.js +88 -6
  181. package/dist/lib/sqlite.d.ts +3 -2
  182. package/dist/lib/sqlite.js +27 -4
  183. package/dist/lib/staleness/detectors/commands.js +1 -1
  184. package/dist/lib/staleness/detectors/workflows.js +13 -2
  185. package/dist/lib/staleness/writers/commands.js +7 -7
  186. package/dist/lib/staleness/writers/workflows.d.ts +4 -2
  187. package/dist/lib/startup/command-registry.d.ts +2 -0
  188. package/dist/lib/startup/command-registry.js +5 -0
  189. package/dist/lib/state.d.ts +1 -1
  190. package/dist/lib/state.js +15 -36
  191. package/dist/lib/teams/agents.d.ts +19 -10
  192. package/dist/lib/teams/agents.js +40 -39
  193. package/dist/lib/types.d.ts +48 -2
  194. package/dist/lib/uninstall.js +1 -45
  195. package/dist/lib/usage.d.ts +33 -1
  196. package/dist/lib/usage.js +172 -12
  197. package/dist/lib/versions.d.ts +22 -2
  198. package/dist/lib/versions.js +98 -10
  199. package/dist/lib/watchdog/log.d.ts +43 -0
  200. package/dist/lib/watchdog/log.js +69 -0
  201. package/dist/lib/watchdog/routine.d.ts +44 -0
  202. package/dist/lib/watchdog/routine.js +69 -0
  203. package/dist/lib/watchdog/runner.d.ts +51 -7
  204. package/dist/lib/watchdog/runner.js +239 -64
  205. package/dist/lib/watchdog/watchdog.d.ts +1 -1
  206. package/dist/lib/watchdog/watchdog.js +31 -16
  207. package/dist/lib/workflows.d.ts +16 -0
  208. package/dist/lib/workflows.js +110 -1
  209. package/package.json +1 -1
@@ -1014,44 +1014,144 @@ export function parseAntigravity(dbPath) {
1014
1014
  * Messages have role (user/assistant) and metadata.
1015
1015
  * Parts contain the actual content: text, tool, reasoning, patch, step-start/finish.
1016
1016
  */
1017
+ /**
1018
+ * Parse a Grok (xAI CLI) session into normalized events.
1019
+ *
1020
+ * A Grok session dir holds several files; the conversation transcript is
1021
+ * `chat_history.jsonl`, one JSON object per line with a `type`:
1022
+ * - `system` — the system prompt (skipped; not conversational)
1023
+ * - `user` — `content` is an array of `{ type: 'text', text }` blocks
1024
+ * - `assistant` — `content` is a string, plus a `tool_calls[]` array of
1025
+ * `{ id, name, arguments }` (arguments is a JSON string)
1026
+ * - `reasoning` — chain-of-thought; text (when present) lives in `summary`
1027
+ * - `tool_result` — `{ tool_call_id, content }`, correlated back to the call
1028
+ *
1029
+ * The scanner records `summary.json` as the session's filePath (see
1030
+ * `readGrokMeta` in discover.ts), so resolve `chat_history.jsonl` from the same
1031
+ * dir; also accept being handed the transcript file directly. Per-line
1032
+ * timestamps aren't stored, so every event carries the session's `created_at`
1033
+ * (from summary.json), falling back to the transcript's mtime.
1034
+ */
1017
1035
  export function parseGrok(filePath) {
1018
- // Grok sessions are rich (summary.json + events.jsonl + chat_history.jsonl + updates.jsonl)
1019
- // This is a minimal stub for now so grok appears in `agents sessions`.
1020
- // Full parser (with subagents, tool calls, etc.) can be expanded later.
1036
+ const sessionDir = path.dirname(filePath);
1037
+ const historyPath = filePath.endsWith('chat_history.jsonl')
1038
+ ? filePath
1039
+ : path.join(sessionDir, 'chat_history.jsonl');
1040
+ if (!fs.existsSync(historyPath))
1041
+ return [];
1042
+ let timestamp;
1021
1043
  try {
1022
- const content = fs.readFileSync(filePath, 'utf-8');
1023
- // If it's a summary.json, create a basic event
1024
- if (filePath.endsWith('summary.json')) {
1025
- const summary = JSON.parse(content);
1026
- return [{
1027
- timestamp: summary.created_at || new Date().toISOString(),
1028
- type: 'session_start',
1029
- content: summary.session_summary || 'Grok session',
1030
- agent: 'grok',
1031
- metadata: { sessionId: summary.id, cwd: summary.cwd },
1032
- }];
1044
+ const summaryPath = filePath.endsWith('summary.json')
1045
+ ? filePath
1046
+ : path.join(sessionDir, 'summary.json');
1047
+ if (fs.existsSync(summaryPath)) {
1048
+ const summary = JSON.parse(fs.readFileSync(summaryPath, 'utf-8'));
1049
+ if (typeof summary?.created_at === 'string')
1050
+ timestamp = summary.created_at;
1033
1051
  }
1034
- // For JSONL files (events, chat_history, updates), return basic parsed lines
1035
- if (filePath.endsWith('.jsonl')) {
1036
- const lines = content.trim().split('\n').filter(Boolean);
1037
- return lines.slice(0, 50).map((line, i) => {
1038
- try {
1039
- const obj = JSON.parse(line);
1040
- return {
1041
- timestamp: obj.timestamp || obj.ts || new Date().toISOString(),
1042
- type: obj.type || obj.method || 'grok_event',
1043
- content: typeof obj.content === 'string' ? obj.content : JSON.stringify(obj).slice(0, 200),
1044
- agent: 'grok',
1045
- };
1052
+ }
1053
+ catch { /* fall back to mtime below */ }
1054
+ if (!timestamp) {
1055
+ let mtime = null;
1056
+ try {
1057
+ mtime = fs.statSync(historyPath).mtime;
1058
+ }
1059
+ catch {
1060
+ mtime = null;
1061
+ }
1062
+ timestamp = (mtime ?? new Date()).toISOString();
1063
+ }
1064
+ const content = safeReadSessionFile(historyPath);
1065
+ const lines = content.split('\n').filter(l => l.trim());
1066
+ const events = [];
1067
+ const toolCallMap = new Map();
1068
+ // Grok text is either a plain string (assistant) or an array of typed blocks
1069
+ // (user: `{ type: 'text', text }`; reasoning summary: `{ text | summary_text }`).
1070
+ const extractText = (raw) => {
1071
+ if (typeof raw === 'string')
1072
+ return raw.trim();
1073
+ if (Array.isArray(raw)) {
1074
+ return raw
1075
+ .map((part) => typeof part?.text === 'string'
1076
+ ? part.text
1077
+ : typeof part?.summary_text === 'string'
1078
+ ? part.summary_text
1079
+ : '')
1080
+ .join('')
1081
+ .trim();
1082
+ }
1083
+ return '';
1084
+ };
1085
+ for (const line of lines) {
1086
+ let msg;
1087
+ try {
1088
+ msg = JSON.parse(line);
1089
+ }
1090
+ catch {
1091
+ continue;
1092
+ }
1093
+ const type = msg?.type;
1094
+ if (type === 'user') {
1095
+ const text = extractText(msg.content);
1096
+ if (text)
1097
+ events.push({ type: 'message', agent: 'grok', timestamp, role: 'user', content: text });
1098
+ }
1099
+ else if (type === 'assistant') {
1100
+ const text = extractText(msg.content);
1101
+ if (text)
1102
+ events.push({ type: 'message', agent: 'grok', timestamp, role: 'assistant', content: text });
1103
+ const calls = Array.isArray(msg.tool_calls) ? msg.tool_calls : [];
1104
+ for (const call of calls) {
1105
+ const toolName = typeof call?.name === 'string' ? call.name : 'unknown';
1106
+ let args = {};
1107
+ if (call?.arguments && typeof call.arguments === 'object') {
1108
+ args = call.arguments;
1046
1109
  }
1047
- catch {
1048
- return { timestamp: new Date().toISOString(), type: 'raw', content: line.slice(0, 200), agent: 'grok' };
1110
+ else if (typeof call?.arguments === 'string') {
1111
+ try {
1112
+ args = JSON.parse(call.arguments);
1113
+ }
1114
+ catch {
1115
+ args = { _raw: call.arguments };
1116
+ }
1049
1117
  }
1118
+ if (typeof call?.id === 'string')
1119
+ toolCallMap.set(call.id, toolName);
1120
+ events.push({
1121
+ type: 'tool_use',
1122
+ agent: 'grok',
1123
+ timestamp,
1124
+ tool: toolName,
1125
+ args,
1126
+ path: args.path || args.file_path || undefined,
1127
+ command: typeof args.command === 'string' ? args.command : undefined,
1128
+ });
1129
+ }
1130
+ }
1131
+ else if (type === 'reasoning') {
1132
+ const text = extractText(msg.summary);
1133
+ if (text)
1134
+ events.push({ type: 'thinking', agent: 'grok', timestamp, content: text });
1135
+ }
1136
+ else if (type === 'tool_result') {
1137
+ const callId = typeof msg.tool_call_id === 'string' ? msg.tool_call_id : undefined;
1138
+ const toolName = (callId && toolCallMap.get(callId)) || 'unknown';
1139
+ const output = typeof msg.content === 'string' ? msg.content : extractText(msg.content);
1140
+ const isError = typeof output === 'string' && output.startsWith('Error:');
1141
+ events.push({
1142
+ type: isError ? 'error' : 'tool_result',
1143
+ agent: 'grok',
1144
+ timestamp,
1145
+ tool: toolName,
1146
+ success: !isError,
1147
+ output: output.length > 500 ? output.slice(0, 497) + '...' : output,
1050
1148
  });
1149
+ if (callId)
1150
+ toolCallMap.delete(callId);
1051
1151
  }
1152
+ // `system` lines are the system prompt — intentionally skipped.
1052
1153
  }
1053
- catch { }
1054
- return [];
1154
+ return events;
1055
1155
  }
1056
1156
  export function parseOpenCode(filePath) {
1057
1157
  const [dbPath, sessionId] = filePath.split('#');
@@ -4,6 +4,23 @@ export interface PidSessionEntry {
4
4
  /** The launch session id. Present for agents launched with a known id (Claude). */
5
5
  sessionId?: string;
6
6
  cwd?: string;
7
+ /**
8
+ * The launch id minted by `ag run` and exported to the child as
9
+ * `AGENT_LAUNCH_ID`. The agent's SessionStart hook records the SAME id in its
10
+ * own state file (`terminals/sessions/<pid>.json`, `launch_id`), so the two
11
+ * records reconcile by this key even when the hook runs under a DIFFERENT pid
12
+ * (tmux pane leaf, cmd.exe wrapper) — see resolveHookSessionId in
13
+ * session/hook-sessions.ts. This
14
+ * is how a non-Claude launch (whose id we don't know at spawn) gets an exact
15
+ * session id at listing time instead of the newest-jsonl heuristic.
16
+ */
17
+ launchId?: string;
18
+ /**
19
+ * `AGENT_TERMINAL_ID` when the launch inherited one (a Factory VS Code tab).
20
+ * A secondary join key to the hook's `terminal_id`, for agents the extension
21
+ * spawned. Best-effort — undefined outside the extension.
22
+ */
23
+ terminalId?: string;
7
24
  /**
8
25
  * `$TMUX_PANE` at launch — stored for diagnostics and possible future
9
26
  * disambiguation. NOT currently consulted on read: the listing path keys
@@ -18,5 +35,20 @@ export declare function extractSessionIdArg(args: string[]): string | undefined;
18
35
  export declare function writePidSessionEntry(entry: PidSessionEntry): void;
19
36
  /** Look up a live pid's recorded session. Returns undefined if absent/corrupt. */
20
37
  export declare function readPidSessionEntry(pid: number): PidSessionEntry | undefined;
21
- /** Remove entries whose pid is no longer alive. Best-effort housekeeping. */
22
- export declare function prunePidSessionRegistry(isAlive: (pid: number) => boolean): void;
38
+ /**
39
+ * Every recorded launch, one entry per live-or-dead pid. Used to index launches
40
+ * by their `tmuxPane` so the authoritative tmux source can attribute a pane it did
41
+ * NOT wrap (an agent bare-spawned into an existing pane) to its exact launch —
42
+ * the caller filters to live pids. Best-effort: unreadable/corrupt files are
43
+ * skipped, a missing dir yields `[]`.
44
+ */
45
+ export declare function listPidSessionEntries(): PidSessionEntry[];
46
+ /**
47
+ * Remove entries whose pid is no longer alive. Best-effort housekeeping.
48
+ *
49
+ * `isAlive` receives the entry's recorded `startedAtMs` so it can reject a pid
50
+ * that a newer process recycled (a "zombie" registry entry), not just one that
51
+ * no longer exists. An unreadable/corrupt entry falls back to the pid-only
52
+ * check.
53
+ */
54
+ export declare function prunePidSessionRegistry(isAlive: (pid: number, startedAtMs?: number) => boolean): void;
@@ -81,7 +81,43 @@ export function readPidSessionEntry(pid) {
81
81
  }
82
82
  return undefined;
83
83
  }
84
- /** Remove entries whose pid is no longer alive. Best-effort housekeeping. */
84
+ /**
85
+ * Every recorded launch, one entry per live-or-dead pid. Used to index launches
86
+ * by their `tmuxPane` so the authoritative tmux source can attribute a pane it did
87
+ * NOT wrap (an agent bare-spawned into an existing pane) to its exact launch —
88
+ * the caller filters to live pids. Best-effort: unreadable/corrupt files are
89
+ * skipped, a missing dir yields `[]`.
90
+ */
91
+ export function listPidSessionEntries() {
92
+ let files;
93
+ try {
94
+ files = fs.readdirSync(pidRegistryDir()).filter(f => f.endsWith('.json'));
95
+ }
96
+ catch {
97
+ return [];
98
+ }
99
+ const out = [];
100
+ for (const f of files) {
101
+ try {
102
+ const parsed = JSON.parse(fs.readFileSync(path.join(pidRegistryDir(), f), 'utf8'));
103
+ if (parsed && typeof parsed === 'object' && typeof parsed.pid === 'number') {
104
+ out.push(parsed);
105
+ }
106
+ }
107
+ catch {
108
+ /* raced with a writer/pruner, or corrupt — skip */
109
+ }
110
+ }
111
+ return out;
112
+ }
113
+ /**
114
+ * Remove entries whose pid is no longer alive. Best-effort housekeeping.
115
+ *
116
+ * `isAlive` receives the entry's recorded `startedAtMs` so it can reject a pid
117
+ * that a newer process recycled (a "zombie" registry entry), not just one that
118
+ * no longer exists. An unreadable/corrupt entry falls back to the pid-only
119
+ * check.
120
+ */
85
121
  export function prunePidSessionRegistry(isAlive) {
86
122
  let files;
87
123
  try {
@@ -92,7 +128,18 @@ export function prunePidSessionRegistry(isAlive) {
92
128
  }
93
129
  for (const f of files) {
94
130
  const pid = Number(f.slice(0, -'.json'.length));
95
- if (!Number.isInteger(pid) || isAlive(pid))
131
+ if (!Number.isInteger(pid))
132
+ continue;
133
+ let startedAtMs;
134
+ try {
135
+ const parsed = JSON.parse(fs.readFileSync(path.join(pidRegistryDir(), f), 'utf8'));
136
+ if (typeof parsed?.startedAtMs === 'number')
137
+ startedAtMs = parsed.startedAtMs;
138
+ }
139
+ catch {
140
+ /* unreadable/corrupt — fall back to the pid-only liveness check */
141
+ }
142
+ if (isAlive(pid, startedAtMs))
96
143
  continue;
97
144
  try {
98
145
  fs.unlinkSync(path.join(pidRegistryDir(), f));
@@ -12,10 +12,15 @@ import type { SessionEvent, SessionMeta } from './types.js';
12
12
  */
13
13
  export declare function relativeToCwd(absPath: string, cwd?: string): string;
14
14
  /**
15
- * Wrap label in an OSC 8 hyperlink when the terminal supports it.
16
- * Degrades to plain label otherwise.
15
+ * Wrap a filesystem path label in an OSC 8 `file://` hyperlink when the terminal
16
+ * supports it. Degrades to the plain label otherwise.
17
17
  */
18
18
  export declare function linkPath(absPath: string, label: string): string;
19
+ /**
20
+ * Wrap a label in an OSC 8 hyperlink to an arbitrary URL (a PR on GitHub, a Linear
21
+ * issue, …) when the terminal supports it. Degrades to the plain label otherwise.
22
+ */
23
+ export declare function linkUrl(url: string, label: string): string;
19
24
  /**
20
25
  * Unwrap wrapper prefixes to find the actual executable.
21
26
  */
@@ -28,19 +28,31 @@ export function relativeToCwd(absPath, cwd) {
28
28
  }
29
29
  return absPath;
30
30
  }
31
- /**
32
- * Wrap label in an OSC 8 hyperlink when the terminal supports it.
33
- * Degrades to plain label otherwise.
34
- */
35
- export function linkPath(absPath, label) {
36
- if (process.stdout.isTTY &&
31
+ /** Best-effort feature-detect for OSC 8 hyperlink support in the current TTY. */
32
+ function supportsHyperlinks() {
33
+ return Boolean(process.stdout.isTTY &&
37
34
  (process.env.TERM_PROGRAM ||
38
35
  process.env.WT_SESSION ||
39
36
  process.env.KITTY_WINDOW_ID ||
40
- process.env.WEZTERM_PANE)) {
41
- return `\x1b]8;;file://${absPath}\x1b\\${label}\x1b]8;;\x1b\\`;
42
- }
43
- return label;
37
+ process.env.WEZTERM_PANE));
38
+ }
39
+ /** Wrap `label` in an OSC 8 hyperlink to `target`. Callers gate on {@link supportsHyperlinks}. */
40
+ function osc8(target, label) {
41
+ return `\x1b]8;;${target}\x1b\\${label}\x1b]8;;\x1b\\`;
42
+ }
43
+ /**
44
+ * Wrap a filesystem path label in an OSC 8 `file://` hyperlink when the terminal
45
+ * supports it. Degrades to the plain label otherwise.
46
+ */
47
+ export function linkPath(absPath, label) {
48
+ return supportsHyperlinks() ? osc8(`file://${absPath}`, label) : label;
49
+ }
50
+ /**
51
+ * Wrap a label in an OSC 8 hyperlink to an arbitrary URL (a PR on GitHub, a Linear
52
+ * issue, …) when the terminal supports it. Degrades to the plain label otherwise.
53
+ */
54
+ export function linkUrl(url, label) {
55
+ return supportsHyperlinks() ? osc8(url, label) : label;
44
56
  }
45
57
  // ── Command grouping ──────────────────────────────────────────────────────────
46
58
  /**
@@ -0,0 +1,17 @@
1
+ /**
2
+ * Canonical derivation of a session's 8-char `shortId`.
3
+ *
4
+ * Every session parser used to inline `id.slice(0, 8)` — some with a
5
+ * `.replace(prefix, '')` strip first (`session_`, `api-`, `ses_`). That strip is
6
+ * the bug: an id that is *only* its prefix (a bare `session_` directory, an id of
7
+ * exactly `api-`) strips to `''`, and `''.slice(0, 8)` is still `''`. An empty
8
+ * shortId then passes the `short_id TEXT NOT NULL` constraint (empty string is not
9
+ * NULL) yet matches nothing in the `short_id LIKE ?` picker lookups — a silently
10
+ * corrupt index row.
11
+ *
12
+ * This helper is the single source of truth: it strips the optional prefix, takes
13
+ * the first 8 chars, and — the whole point — guarantees a NON-EMPTY result by
14
+ * falling back to the unstripped id when the strip empties it, so a row always
15
+ * stays addressable by its shortId.
16
+ */
17
+ export declare function deriveShortId(id: string, stripPrefix?: RegExp): string;
@@ -0,0 +1,20 @@
1
+ /**
2
+ * Canonical derivation of a session's 8-char `shortId`.
3
+ *
4
+ * Every session parser used to inline `id.slice(0, 8)` — some with a
5
+ * `.replace(prefix, '')` strip first (`session_`, `api-`, `ses_`). That strip is
6
+ * the bug: an id that is *only* its prefix (a bare `session_` directory, an id of
7
+ * exactly `api-`) strips to `''`, and `''.slice(0, 8)` is still `''`. An empty
8
+ * shortId then passes the `short_id TEXT NOT NULL` constraint (empty string is not
9
+ * NULL) yet matches nothing in the `short_id LIKE ?` picker lookups — a silently
10
+ * corrupt index row.
11
+ *
12
+ * This helper is the single source of truth: it strips the optional prefix, takes
13
+ * the first 8 chars, and — the whole point — guarantees a NON-EMPTY result by
14
+ * falling back to the unstripped id when the strip empties it, so a row always
15
+ * stays addressable by its shortId.
16
+ */
17
+ export function deriveShortId(id, stripPrefix) {
18
+ const stripped = stripPrefix ? id.replace(stripPrefix, '') : id;
19
+ return stripped.slice(0, 8) || id.slice(0, 8) || id;
20
+ }
@@ -378,8 +378,12 @@ export function inferActivity(events, ctx = {}) {
378
378
  // A prose question takes a free-text reply (no select-list), so no options/keys.
379
379
  // Unlike the structural plan/ask signals above, the prose heuristic DECAYS: a
380
380
  // question nobody answered within PROSE_QUESTION_FRESH_MS is a session that
381
- // ended, not one that needs you (RUSH-1522). Unknown mtime keeps the question.
382
- const questionFresh = ctx.mtimeMs == null || Date.now() - ctx.mtimeMs < PROSE_QUESTION_FRESH_MS;
381
+ // ended, not one that needs you (RUSH-1522). A prose "?" is only "waiting on
382
+ // you" while the file is DEMONSTRABLY fresh with no mtime signal at all we
383
+ // cannot assert freshness, so the heuristic must not fire (an unknown-age
384
+ // prose question is a session that ended, closing RUSH-1522's null-mtime hole
385
+ // where a null mtime kept the question forever).
386
+ const questionFresh = ctx.mtimeMs != null && Date.now() - ctx.mtimeMs < PROSE_QUESTION_FRESH_MS;
383
387
  if (questionFresh && looksLikeQuestion(last.content ?? '')) {
384
388
  const text = oneLine(last.content ?? '');
385
389
  return { ...base, activity: 'waiting_input', awaitingReason: 'question', question: { text, reason: 'question' } };
@@ -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 = 26;
80
+ export declare const SHIM_SCHEMA_VERSION = 27;
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.
@@ -508,6 +508,32 @@ export declare function addShimsToPath(overrides?: {
508
508
  shimsDir?: string;
509
509
  }): ShimPathResult;
510
510
  export declare function listAgentsWithInstalledVersions(): AgentId[];
511
+ /**
512
+ * Thrown when an operation would carry an isolated-only agent across the isolation
513
+ * boundary. Callers that can explain the situation catch it and print guidance; the
514
+ * throw is what makes the boundary a property of the code rather than a convention
515
+ * every future call site has to remember.
516
+ */
517
+ export declare class IsolationBoundaryError extends Error {
518
+ readonly agent: AgentId;
519
+ readonly operation: string;
520
+ constructor(agent: AgentId, operation: string);
521
+ }
522
+ /**
523
+ * True when EVERY installed version of `agent` is isolated (and at least one is).
524
+ *
525
+ * This is the switch: installing with `--isolated` is itself the act of opting in,
526
+ * so there is no mode to set and none to forget. It is per-agent, so an isolated
527
+ * codex constrains nothing about claude. And the escape hatch is inherent — remove
528
+ * the isolated copies and the agent is ordinary again, which means the state that
529
+ * grants protection is the same state you delete to drop it.
530
+ *
531
+ * An agent with any NORMAL version is not protected: that install already owns the
532
+ * launcher and the real `~/.<agent>`, so there is no boundary left to defend.
533
+ */
534
+ export declare function isIsolationProtected(agent: AgentId): boolean;
535
+ /** Refuse `operation` when `agent` is isolated-only. */
536
+ export declare function assertIsolationBoundary(agent: AgentId, operation: string): void;
511
537
  export declare function listAgentsWithNonIsolatedInstalledVersions(): AgentId[];
512
538
  /**
513
539
  * Resource diff between two versions. Each field lists resources present in
package/dist/lib/shims.js CHANGED
@@ -222,7 +222,7 @@ async function promptConflictStrategy(conflictInfos) {
222
222
  // The old dispatcher checked only the global dir, so a pinned grok that
223
223
  // installed into the versioned home fell through to the "not installed"
224
224
  // error.
225
- export const SHIM_SCHEMA_VERSION = 26;
225
+ export const SHIM_SCHEMA_VERSION = 27;
226
226
  /** Internal marker string used to embed the schema version in shim scripts. */
227
227
  const SHIM_VERSION_MARKER = 'agents-shim-version:';
228
228
  function shellQuote(value) {
@@ -278,13 +278,20 @@ export COPILOT_HOME="$VERSION_DIR/home/${configDirName}"
278
278
  # This gives agents-cli full versioned isolation + resource sync for grok.
279
279
  export GROK_HOME="$VERSION_DIR/home/.grok"
280
280
  `
281
- : agent === 'kimi'
281
+ : agent === 'opencode'
282
282
  ? `
283
+ # OpenCode reads plugins, agents, commands, and other config-directory
284
+ # resources from OPENCODE_CONFIG_DIR. Point it at the versioned global config
285
+ # tree where agents-cli syncs OpenCode resources.
286
+ export OPENCODE_CONFIG_DIR="$VERSION_DIR/home/.config/opencode"
287
+ `
288
+ : agent === 'kimi'
289
+ ? `
283
290
  # Kimi Code CLI honors KIMI_CODE_HOME to relocate ~/.kimi-code (config.toml,
284
291
  # mcp.json, sessions, skills, hooks). Point it at the versioned home.
285
292
  export KIMI_CODE_HOME="$VERSION_DIR/home/${configDirName}"
286
293
  `
287
- : '';
294
+ : '';
288
295
  const launchArgs = agent === 'codex' ? ' -c check_for_update_on_startup=false' : '';
289
296
  return `#!/bin/bash
290
297
  # Auto-generated by agents-cli - do not edit
@@ -661,6 +668,9 @@ export function shimTargetsFor(platform) {
661
668
  * Create a shim for an agent.
662
669
  */
663
670
  export function createShim(agent) {
671
+ // A bare shim puts agents-cli first on PATH for this agent — the opposite of what
672
+ // an isolated-only install promises.
673
+ assertIsolationBoundary(agent, 'create the bare shim');
664
674
  ensureAgentsDir();
665
675
  const shimsDir = getShimsDir();
666
676
  const agentConfig = AGENTS[agent];
@@ -864,7 +874,7 @@ function assertSafeVersion(version) {
864
874
  * KEEP IN SYNC with the `managedEnv` switch in `generateVersionedAliasScript`.
865
875
  * The colocated test `shims.isolation-capability.test.ts` enforces this.
866
876
  */
867
- export const CONFIG_ENV_ISOLATED_AGENTS = ['claude', 'codex', 'copilot', 'grok', 'kimi'];
877
+ export const CONFIG_ENV_ISOLATED_AGENTS = ['claude', 'codex', 'copilot', 'grok', 'kimi', 'opencode'];
868
878
  /**
869
879
  * Whether an agent supports a clean `--isolated` install — i.e. its config
870
880
  * location can be redirected by an env var so the isolated copy stays fully
@@ -909,13 +919,20 @@ export COPILOT_HOME="$HOME/.agents/.history/versions/${agent}/${version}/home/${
909
919
  # aliases at the versioned home for isolation parity with the main shim.
910
920
  export GROK_HOME="$HOME/.agents/.history/versions/${agent}/${version}/home/${configDirName}"
911
921
  `
912
- : agent === 'kimi'
922
+ : agent === 'opencode'
913
923
  ? `
924
+ # OpenCode reads plugins, agents, commands, and other config-directory
925
+ # resources from OPENCODE_CONFIG_DIR. Point direct aliases at the versioned
926
+ # global config tree where agents-cli syncs OpenCode resources.
927
+ export OPENCODE_CONFIG_DIR="$HOME/.agents/.history/versions/${agent}/${version}/home/.config/opencode"
928
+ `
929
+ : agent === 'kimi'
930
+ ? `
914
931
  # Kimi Code CLI honors KIMI_CODE_HOME to relocate ~/.kimi-code (config.toml,
915
932
  # mcp.json, sessions, skills, hooks). Point direct aliases at the versioned home.
916
933
  export KIMI_CODE_HOME="$HOME/.agents/.history/versions/${agent}/${version}/home/${configDirName}"
917
934
  `
918
- : '';
935
+ : '';
919
936
  const launchArgs = agent === 'codex' ? ' -c check_for_update_on_startup=false' : '';
920
937
  // Resolve the binary the same way the main shim does (see generateShimScript).
921
938
  // Grok and Droid do NOT ship into node_modules/.bin — Grok downloads a native
@@ -1322,6 +1339,8 @@ export function carryForwardAuthFiles(agent, toConfigDir) {
1322
1339
  }
1323
1340
  }
1324
1341
  export async function switchConfigSymlink(agent, version) {
1342
+ // Moves the user's real ~/.<agent> aside and symlinks it into a version home.
1343
+ assertIsolationBoundary(agent, 'repoint your real config directory');
1325
1344
  const configPath = getAgentConfigPath(agent);
1326
1345
  const versionConfigPath = getVersionConfigPath(agent, version);
1327
1346
  // Ensure version config directory exists
@@ -1426,6 +1445,8 @@ export async function switchConfigSymlink(agent, version) {
1426
1445
  * ALL installed versions so they inherit the current account.
1427
1446
  */
1428
1447
  export function switchHomeFileSymlinks(agent, version) {
1448
+ // Same, for home-level files such as ~/.claude.json.
1449
+ assertIsolationBoundary(agent, 'repoint your home-level config files');
1429
1450
  const agentConfig = AGENTS[agent];
1430
1451
  const homeFiles = agentConfig.homeFiles;
1431
1452
  if (!homeFiles || homeFiles.length === 0)
@@ -2076,6 +2097,9 @@ function canonicalOrNull(p) {
2076
2097
  * - Never records our own shim as the "original" (would loop).
2077
2098
  */
2078
2099
  export function adoptShadowingLauncher(agent, overrides) {
2100
+ // Repoints the user's OWN launcher symlink at our shim — the most invasive thing
2101
+ // in the codebase, and the one with no isolated-scoped equivalent at all.
2102
+ assertIsolationBoundary(agent, 'adopt your launcher');
2079
2103
  const shimsDir = overrides?.shimsDir ?? getShimsDir();
2080
2104
  const shimPath = path.join(shimsDir, AGENTS[agent].cliCommand);
2081
2105
  const shimReal = canonical(shimPath);
@@ -2431,6 +2455,64 @@ export function listAgentsWithInstalledVersions() {
2431
2455
  function isInstalledVersionIsolated(agent, version) {
2432
2456
  return fs.existsSync(path.join(getVersionsDir(), agent, version, '.isolated'));
2433
2457
  }
2458
+ /**
2459
+ * Thrown when an operation would carry an isolated-only agent across the isolation
2460
+ * boundary. Callers that can explain the situation catch it and print guidance; the
2461
+ * throw is what makes the boundary a property of the code rather than a convention
2462
+ * every future call site has to remember.
2463
+ */
2464
+ export class IsolationBoundaryError extends Error {
2465
+ agent;
2466
+ operation;
2467
+ constructor(agent, operation) {
2468
+ super(`${agent} is installed only as isolated copies; "${operation}" would adopt it into your local setup.`);
2469
+ this.agent = agent;
2470
+ this.operation = operation;
2471
+ this.name = 'IsolationBoundaryError';
2472
+ }
2473
+ }
2474
+ /**
2475
+ * True when EVERY installed version of `agent` is isolated (and at least one is).
2476
+ *
2477
+ * This is the switch: installing with `--isolated` is itself the act of opting in,
2478
+ * so there is no mode to set and none to forget. It is per-agent, so an isolated
2479
+ * codex constrains nothing about claude. And the escape hatch is inherent — remove
2480
+ * the isolated copies and the agent is ordinary again, which means the state that
2481
+ * grants protection is the same state you delete to drop it.
2482
+ *
2483
+ * An agent with any NORMAL version is not protected: that install already owns the
2484
+ * launcher and the real `~/.<agent>`, so there is no boundary left to defend.
2485
+ */
2486
+ export function isIsolationProtected(agent) {
2487
+ const agentVersionsDir = path.join(getVersionsDir(), agent);
2488
+ let dirs;
2489
+ try {
2490
+ dirs = fs.readdirSync(agentVersionsDir, { withFileTypes: true })
2491
+ .filter((e) => e.isDirectory())
2492
+ .map((e) => e.name);
2493
+ }
2494
+ catch {
2495
+ return false;
2496
+ }
2497
+ // Count only directories that are actually an install. A bare version dir is
2498
+ // scaffolding, not a non-isolated version — and treating it as one would disable
2499
+ // protection at exactly the wrong moment: an adopting path that does
2500
+ // `mkdirSync(<version>/home)` before adopting would flip this to false with its own
2501
+ // first line and then walk straight through the gates. Ignoring scaffolding biases
2502
+ // the predicate toward protecting, which is the safe direction to fail in.
2503
+ const installed = dirs.filter((v) => {
2504
+ const dir = path.join(agentVersionsDir, v);
2505
+ return fs.existsSync(path.join(dir, 'node_modules')) || fs.existsSync(path.join(dir, 'package.json'));
2506
+ });
2507
+ if (installed.length === 0)
2508
+ return false;
2509
+ return installed.every((v) => isInstalledVersionIsolated(agent, v));
2510
+ }
2511
+ /** Refuse `operation` when `agent` is isolated-only. */
2512
+ export function assertIsolationBoundary(agent, operation) {
2513
+ if (isIsolationProtected(agent))
2514
+ throw new IsolationBoundaryError(agent, operation);
2515
+ }
2434
2516
  export function listAgentsWithNonIsolatedInstalledVersions() {
2435
2517
  const versionsDir = getVersionsDir();
2436
2518
  if (!fs.existsSync(versionsDir)) {
@@ -3,8 +3,9 @@
3
3
  *
4
4
  * Picks `bun:sqlite` under Bun and `node:sqlite` under Node (>=22.5). Avoids
5
5
  * the native `better-sqlite3` addon entirely so there is no prebuild compile
6
- * and no Node/Bun ABI mismatch when the same source runs in tests (Bun) and
7
- * production (Node).
6
+ * and no Node/Bun ABI mismatch. Both runtimes are production: `dist/index.js`
7
+ * runs under Node, while the signed standalone `dist/bin/agents` (what the
8
+ * shims exec) embeds Bun.
8
9
  *
9
10
  * Exposes the small better-sqlite3-shaped surface area the rest of the
10
11
  * codebase already uses: `prepare/exec/pragma/transaction/close` on the DB,