@phnx-labs/agents-cli 1.22.25 → 1.22.27

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 (178) hide show
  1. package/CHANGELOG.md +427 -0
  2. package/README.md +35 -3
  3. package/dist/bin/agents +0 -0
  4. package/dist/browser.js +14 -4
  5. package/dist/commands/apply.js +52 -8
  6. package/dist/commands/bench.d.ts +2 -0
  7. package/dist/commands/bench.js +101 -0
  8. package/dist/commands/browser.js +35 -0
  9. package/dist/commands/doctor.js +32 -10
  10. package/dist/commands/exec.js +17 -16
  11. package/dist/commands/insights.d.ts +25 -19
  12. package/dist/commands/insights.js +146 -38
  13. package/dist/commands/output.js +100 -25
  14. package/dist/commands/reconnect.d.ts +46 -0
  15. package/dist/commands/reconnect.js +109 -0
  16. package/dist/commands/resume.d.ts +11 -0
  17. package/dist/commands/resume.js +51 -0
  18. package/dist/commands/routines.js +2 -2
  19. package/dist/commands/secrets.d.ts +2 -8
  20. package/dist/commands/secrets.js +29 -105
  21. package/dist/commands/sessions-picker.js +12 -7
  22. package/dist/commands/sessions.d.ts +47 -1
  23. package/dist/commands/sessions.js +349 -37
  24. package/dist/commands/setup-secrets.d.ts +1 -0
  25. package/dist/commands/setup-secrets.js +1 -1
  26. package/dist/commands/setup.d.ts +26 -3
  27. package/dist/commands/setup.js +105 -46
  28. package/dist/commands/ssh.js +184 -11
  29. package/dist/commands/teams.d.ts +6 -0
  30. package/dist/commands/teams.js +46 -3
  31. package/dist/commands/trends.d.ts +8 -0
  32. package/dist/commands/trends.js +10 -156
  33. package/dist/index.js +17 -21
  34. package/dist/lib/agents.d.ts +11 -0
  35. package/dist/lib/agents.js +29 -2
  36. package/dist/lib/analytics/dashboard.d.ts +10 -6
  37. package/dist/lib/analytics/dashboard.js +6 -4
  38. package/dist/lib/analytics/mix-commands.d.ts +53 -0
  39. package/dist/lib/analytics/mix-commands.js +229 -0
  40. package/dist/lib/analytics/recipes.d.ts +19 -14
  41. package/dist/lib/analytics/recipes.js +4 -2
  42. package/dist/lib/auth-health.d.ts +47 -3
  43. package/dist/lib/auth-health.js +74 -15
  44. package/dist/lib/bench/index.d.ts +4 -0
  45. package/dist/lib/bench/index.js +4 -0
  46. package/dist/lib/bench/runner.d.ts +16 -0
  47. package/dist/lib/bench/runner.js +111 -0
  48. package/dist/lib/bench/schema.d.ts +5 -0
  49. package/dist/lib/bench/schema.js +91 -0
  50. package/dist/lib/bench/storage.d.ts +5 -0
  51. package/dist/lib/bench/storage.js +32 -0
  52. package/dist/lib/bench/types.d.ts +40 -0
  53. package/dist/lib/bench/types.js +1 -0
  54. package/dist/lib/browser/ipc.d.ts +26 -0
  55. package/dist/lib/browser/ipc.js +139 -24
  56. package/dist/lib/browser/profiles.d.ts +11 -0
  57. package/dist/lib/browser/profiles.js +1 -1
  58. package/dist/lib/browser/stream.d.ts +14 -0
  59. package/dist/lib/browser/stream.js +71 -0
  60. package/dist/lib/channels/owner-sink.d.ts +27 -0
  61. package/dist/lib/channels/owner-sink.js +93 -0
  62. package/dist/lib/claude-account-token.d.ts +2 -0
  63. package/dist/lib/claude-account-token.js +27 -1
  64. package/dist/lib/crabbox/cli.d.ts +2 -0
  65. package/dist/lib/crabbox/cli.js +2 -0
  66. package/dist/lib/crabbox/lease.js +7 -1
  67. package/dist/lib/daemon.js +20 -0
  68. package/dist/lib/devices/connect.d.ts +2 -0
  69. package/dist/lib/devices/connect.js +7 -0
  70. package/dist/lib/devices/doctor-findings.d.ts +10 -1
  71. package/dist/lib/devices/doctor-findings.js +47 -1
  72. package/dist/lib/devices/harness-inventory.d.ts +97 -0
  73. package/dist/lib/devices/harness-inventory.js +0 -0
  74. package/dist/lib/devices/registry.d.ts +2 -0
  75. package/dist/lib/devices/ssh-config.js +3 -0
  76. package/dist/lib/devices/windows-ssh-enrollment.d.ts +20 -0
  77. package/dist/lib/devices/windows-ssh-enrollment.js +98 -0
  78. package/dist/lib/exec.bench.d.ts +1 -0
  79. package/dist/lib/exec.bench.js +186 -0
  80. package/dist/lib/exec.js +18 -8
  81. package/dist/lib/fleet/apply.d.ts +59 -3
  82. package/dist/lib/fleet/apply.js +191 -12
  83. package/dist/lib/fleet/remote-login.d.ts +4 -3
  84. package/dist/lib/fleet/remote-login.js +11 -9
  85. package/dist/lib/fleet/types.d.ts +21 -2
  86. package/dist/lib/gemini-settings.d.ts +0 -1
  87. package/dist/lib/gemini-settings.js +12 -7
  88. package/dist/lib/hooks/cache.js +15 -0
  89. package/dist/lib/hooks.d.ts +8 -5
  90. package/dist/lib/hooks.js +10 -6
  91. package/dist/lib/hosts/dispatch.js +7 -3
  92. package/dist/lib/hosts/passthrough.d.ts +45 -0
  93. package/dist/lib/hosts/passthrough.js +59 -8
  94. package/dist/lib/hosts/progress.d.ts +2 -1
  95. package/dist/lib/hosts/progress.js +4 -3
  96. package/dist/lib/hosts/providers/devices.js +1 -0
  97. package/dist/lib/hosts/ready.d.ts +6 -2
  98. package/dist/lib/hosts/ready.js +33 -8
  99. package/dist/lib/hosts/reconcile.d.ts +1 -1
  100. package/dist/lib/hosts/reconcile.js +16 -7
  101. package/dist/lib/hosts/reconnect.d.ts +56 -21
  102. package/dist/lib/hosts/reconnect.js +128 -71
  103. package/dist/lib/hosts/registry.d.ts +2 -2
  104. package/dist/lib/hosts/registry.js +3 -5
  105. package/dist/lib/hosts/remote-cmd.d.ts +17 -0
  106. package/dist/lib/hosts/remote-cmd.js +29 -0
  107. package/dist/lib/hosts/tasks.d.ts +2 -0
  108. package/dist/lib/hosts/types.d.ts +1 -0
  109. package/dist/lib/hosts/types.js +3 -0
  110. package/dist/lib/menubar/MenubarHelper.app/Contents/CodeResources +0 -0
  111. package/dist/lib/menubar/MenubarHelper.app/Contents/MacOS/MenubarHelper +0 -0
  112. package/dist/lib/menubar/install-menubar.d.ts +9 -6
  113. package/dist/lib/menubar/install-menubar.js +20 -9
  114. package/dist/lib/models.d.ts +30 -1
  115. package/dist/lib/models.js +42 -144
  116. package/dist/lib/pricing/cost.d.ts +9 -0
  117. package/dist/lib/pricing/cost.js +24 -0
  118. package/dist/lib/pricing/index.d.ts +1 -1
  119. package/dist/lib/pricing/index.js +1 -1
  120. package/dist/lib/redact.js +8 -3
  121. package/dist/lib/remote-agents-json.d.ts +32 -0
  122. package/dist/lib/remote-agents-json.js +47 -16
  123. package/dist/lib/resource-profiles.js +1 -2
  124. package/dist/lib/routine-notify-owner.d.ts +102 -0
  125. package/dist/lib/routine-notify-owner.js +232 -0
  126. package/dist/lib/routines.d.ts +10 -0
  127. package/dist/lib/routines.js +14 -2
  128. package/dist/lib/runner.d.ts +9 -4
  129. package/dist/lib/runner.js +182 -30
  130. package/dist/lib/sandbox.d.ts +0 -2
  131. package/dist/lib/sandbox.js +2 -19
  132. package/dist/lib/secrets/Agents CLI.app/Contents/CodeResources +0 -0
  133. package/dist/lib/secrets/Agents CLI.app/Contents/MacOS/Agents CLI +0 -0
  134. package/dist/lib/secrets/agent.js +3 -2
  135. package/dist/lib/secrets/lease.d.ts +25 -0
  136. package/dist/lib/secrets/lease.js +44 -0
  137. package/dist/lib/secrets/push.d.ts +94 -0
  138. package/dist/lib/secrets/push.js +145 -0
  139. package/dist/lib/secrets/reaper.d.ts +15 -1
  140. package/dist/lib/secrets/reaper.js +30 -3
  141. package/dist/lib/self-update.d.ts +20 -5
  142. package/dist/lib/self-update.js +93 -16
  143. package/dist/lib/session/db.d.ts +38 -3
  144. package/dist/lib/session/db.js +273 -16
  145. package/dist/lib/session/discover.d.ts +22 -1
  146. package/dist/lib/session/discover.js +239 -51
  147. package/dist/lib/session/insights.d.ts +37 -0
  148. package/dist/lib/session/insights.js +219 -9
  149. package/dist/lib/session/recovery.d.ts +17 -1
  150. package/dist/lib/session/recovery.js +111 -4
  151. package/dist/lib/session/remote-list.d.ts +10 -9
  152. package/dist/lib/session/remote-list.js +25 -23
  153. package/dist/lib/session/resume-owner.d.ts +55 -0
  154. package/dist/lib/session/resume-owner.js +69 -0
  155. package/dist/lib/session/team-filter.d.ts +65 -0
  156. package/dist/lib/session/team-filter.js +98 -3
  157. package/dist/lib/session/tool-index.js +133 -22
  158. package/dist/lib/session/tool-store.d.ts +26 -2
  159. package/dist/lib/session/tool-store.js +36 -17
  160. package/dist/lib/session/types.d.ts +33 -0
  161. package/dist/lib/smart-launch.d.ts +31 -5
  162. package/dist/lib/smart-launch.js +43 -7
  163. package/dist/lib/ssh-exec.d.ts +27 -0
  164. package/dist/lib/ssh-exec.js +42 -3
  165. package/dist/lib/ssh-tunnel.d.ts +3 -2
  166. package/dist/lib/ssh-tunnel.js +25 -16
  167. package/dist/lib/startup/command-registry.d.ts +2 -0
  168. package/dist/lib/startup/command-registry.js +6 -0
  169. package/dist/lib/teams/agents.d.ts +13 -0
  170. package/dist/lib/teams/agents.js +75 -7
  171. package/dist/lib/teams/placement-probe.d.ts +21 -0
  172. package/dist/lib/teams/placement-probe.js +135 -0
  173. package/dist/lib/teams/scheduler.d.ts +74 -1
  174. package/dist/lib/teams/scheduler.js +187 -10
  175. package/dist/lib/tmux/session.d.ts +8 -0
  176. package/dist/lib/tmux/session.js +22 -0
  177. package/dist/lib/types.d.ts +2 -0
  178. package/package.json +1 -1
@@ -21,11 +21,25 @@
21
21
  * the raw file count rather than quietly differing.
22
22
  */
23
23
  import type { SessionEvent } from './types.js';
24
+ /**
25
+ * Minimum quiet time after the assistant's last event before a user message
26
+ * is classified as an **agent silent stall**: the model went quiet on its own
27
+ * and sat idle until the human resumed. Shorter gaps are normal turn-taking.
28
+ * 5 minutes is long enough to exclude "user typing the next instruction" and
29
+ * short enough to catch "went silent mid-task until I said continue."
30
+ */
31
+ export declare const SILENT_STALL_SECONDS = 300;
24
32
  /** Behavioural facets of one session. Serialized as JSON into `session_insights`. */
25
33
  export interface InsightFacets {
26
34
  toolCounts: Record<string, number>;
27
35
  /** Per-model assistant turn counts. `/insights` has no model dimension at all. */
28
36
  models: Record<string, number>;
37
+ /**
38
+ * Silent stalls attributed to the model that last spoke before the idle gap
39
+ * (from the nearest preceding `usage`/message model tag). Some models go
40
+ * quiet more often — this is the laziness split. Key = shortened model id.
41
+ */
42
+ silentStallsByModel: Record<string, number>;
29
43
  languages: Record<string, number>;
30
44
  /** Slash commands the user invoked, by name. */
31
45
  slashCommands: Record<string, number>;
@@ -71,6 +85,10 @@ export interface InsightFacets {
71
85
  assistantTurns: number;
72
86
  toolCount: number;
73
87
  errorCount: number;
88
+ /** Deterministic evidence buckets used by the actions-forward report. */
89
+ frictionSignals: Record<string, number>;
90
+ correctionSignals: Record<string, number>;
91
+ automationSignals: Record<string, number>;
74
92
  }
75
93
  /**
76
94
  * Compute every behavioural facet of one session from its parsed events.
@@ -80,6 +98,11 @@ export interface InsightFacets {
80
98
  * can be re-bucketed for a different display timezone without re-parsing.
81
99
  */
82
100
  export declare function computeInsightFacets(events: SessionEvent[], timezoneOffsetMinutes?: number): InsightFacets;
101
+ /**
102
+ * Classify a long quiet gap after the assistant's last event as an agent silent stall.
103
+ * Optionally also marks an explicit resume nudge ("continue", …) after that silence.
104
+ */
105
+ export declare function classifySilentStall(gapSeconds: number, userText: string, friction: Record<string, number>, corrections: Record<string, number>): void;
83
106
  /** Percentile of a numeric sample, nearest-rank. Returns 0 for an empty sample. */
84
107
  export declare function percentile(values: number[], p: number): number;
85
108
  /** Bucket response gaps for display. Returns every bucket, including empty ones. */
@@ -124,3 +147,17 @@ export declare function topEntries(counts: Record<string, number>, limit: number
124
147
  name: string;
125
148
  count: number;
126
149
  }>;
150
+ export type InsightActionCategory = 'rule' | 'skill' | 'automation' | 'product';
151
+ export interface InsightAction {
152
+ priority: 'high' | 'medium' | 'low';
153
+ category: InsightActionCategory;
154
+ action: string;
155
+ evidenceCount: number;
156
+ sampleSessionIds: string[];
157
+ }
158
+ export interface SessionFacetEvidence {
159
+ id: string;
160
+ facets: InsightFacets;
161
+ }
162
+ /** Build a stable, evidence-backed action list without exposing transcript text. */
163
+ export declare function buildInsightActions(sessions: SessionFacetEvidence[]): InsightAction[];
@@ -47,24 +47,42 @@ const ERROR_CATEGORIES = [
47
47
  [['exit code', 'command failed', 'error:'], 'Command Failed'],
48
48
  ];
49
49
  /**
50
- * Gaps longer than this are someone leaving and coming back, not a reply latency.
51
- * Counted separately rather than silently dropped.
50
+ * Gaps longer than this leave the reply-latency percentiles (someone left for
51
+ * lunch / overnight). They still count as silent stalls when the assistant
52
+ * was the last speaker — the agent had already stopped before the user left.
52
53
  */
53
54
  const GAP_CEILING_SECONDS = 3600;
55
+ /**
56
+ * Minimum quiet time after the assistant's last event before a user message
57
+ * is classified as an **agent silent stall**: the model went quiet on its own
58
+ * and sat idle until the human resumed. Shorter gaps are normal turn-taking.
59
+ * 5 minutes is long enough to exclude "user typing the next instruction" and
60
+ * short enough to catch "went silent mid-task until I said continue."
61
+ */
62
+ export const SILENT_STALL_SECONDS = 300;
54
63
  /** Response-gap buckets, in ascending order. Upper bound is exclusive. */
55
64
  const GAP_BUCKETS = [
56
65
  ['<10s', 10], ['10-30s', 30], ['30s-1m', 60], ['1-2m', 120],
57
66
  ['2-5m', 300], ['5-15m', 900], ['15-60m', Infinity],
58
67
  ];
68
+ /** Silent-stall duration buckets (agent idle after its last event). */
69
+ const SILENT_STALL_BUCKETS = [
70
+ // label, min inclusive, max exclusive (Infinity = open)
71
+ ['silent stall: 5-15m', 300, 900],
72
+ ['silent stall: 15-60m', 900, 3600],
73
+ ['silent stall: 1h+', 3600, Infinity],
74
+ ];
59
75
  function emptyFacets() {
60
76
  return {
61
- toolCounts: {}, models: {}, languages: {}, slashCommands: {}, errorCategories: {},
77
+ toolCounts: {}, models: {}, silentStallsByModel: {}, languages: {},
78
+ slashCommands: {}, errorCategories: {},
62
79
  interruptions: 0, responseGaps: [], gapsOverCeiling: 0,
63
80
  linesTouchedBefore: 0, linesTouchedAfter: 0, editingToolCalls: 0,
64
81
  filesCreated: 0, filesModified: 0, filesDeleted: 0, gitCommits: 0, gitPushes: 0,
65
82
  shellCommandsSeen: 0,
66
83
  messageHours: new Array(24).fill(0), userTurns: 0, assistantTurns: 0,
67
84
  toolCount: 0, errorCount: 0,
85
+ frictionSignals: {}, correctionSignals: {}, automationSignals: {},
68
86
  };
69
87
  }
70
88
  function bump(map, key, by = 1) {
@@ -141,10 +159,19 @@ export function computeInsightFacets(events, timezoneOffsetMinutes = new Date().
141
159
  // reported p50 by 63% (143s against a true 88s), because fast replies are common and
142
160
  // dropping them all shifts the median right. A 0-second reply is a real reply.
143
161
  //
144
- // The upper bound stays: past an hour the user went away and came back, which is not
145
- // a reply latency. It censors 5.5% of gaps, and `gapsOverCeiling` reports how many so
146
- // the number is never quietly truncated.
162
+ // The upper bound stays for *percentiles only*: past an hour the user often left the
163
+ // desk, which is not "reply latency." Those gaps still feed silent-stall classification
164
+ // (the agent had already stopped). `gapsOverCeiling` reports how many so percentiles
165
+ // are never quietly truncated.
166
+ //
167
+ // Silent stall (agent idle): when the gap after the assistant's last event is long
168
+ // enough that the model clearly stopped mid-session and waited for a human nudge
169
+ // ("continue", "keep going", or any later message after minutes of silence). This is
170
+ // the inverse framing of reply latency: same timestamps, attributed to the agent.
147
171
  let lastAssistantTs = null;
172
+ /** Shortened model id of the last assistant activity (for stall attribution). */
173
+ let lastAssistantModel = null;
174
+ let lastFailedTool = null;
148
175
  for (const e of events) {
149
176
  const ts = new Date(e.timestamp).getTime();
150
177
  const hasTs = !Number.isNaN(ts);
@@ -153,22 +180,44 @@ export function computeInsightFacets(events, timezoneOffsetMinutes = new Date().
153
180
  f.interruptions++;
154
181
  break;
155
182
  case 'usage':
156
- // shortenModel so the label matches `agents sessions <id>` and `trends`
183
+ // shortenModel so the label matches `agents sessions <id>` and `insights mix`
157
184
  // rather than printing the raw id beside their shortened one.
158
- if (e.model)
159
- bump(f.models, shortenModel(e.model));
185
+ if (e.model) {
186
+ const m = shortenModel(e.model);
187
+ bump(f.models, m);
188
+ // Usage rows are the reliable model tag for Claude turns; keep as
189
+ // "last model" even when the timestamp is missing so a following
190
+ // tool_use still attributes a stall correctly.
191
+ lastAssistantModel = m;
192
+ }
160
193
  break;
161
194
  case 'error':
162
195
  bump(f.errorCategories, categorizeError(e.content ?? e.output ?? ''));
196
+ if (e.tool && e.tool === lastFailedTool)
197
+ bump(f.frictionSignals, `failed tool loop: ${e.tool}`);
198
+ lastFailedTool = e.tool ?? null;
199
+ classifyFriction(e.content ?? e.output ?? '', f.frictionSignals);
200
+ break;
201
+ case 'tool_result':
202
+ if (e.success !== false && e.outcome !== 'error')
203
+ lastFailedTool = null;
163
204
  break;
164
205
  case 'message':
165
206
  if (e.role === 'assistant') {
166
207
  if (hasTs)
167
208
  lastAssistantTs = ts;
209
+ if (e.model)
210
+ lastAssistantModel = shortenModel(e.model);
168
211
  break;
169
212
  }
170
213
  if (e.role !== 'user')
171
214
  break;
215
+ // Synthetic user rows (stop-hook feedback, injected meta) are not a human
216
+ // resume — skip correction/stall classification and leave lastAssistantTs so
217
+ // the next real user message still measures the full idle gap.
218
+ if (e._synthetic)
219
+ break;
220
+ classifyCorrection(e.content ?? '', f.correctionSignals);
172
221
  if (hasTs) {
173
222
  // Local-time hour. parse.ts falls back to `new Date()` for a record with no
174
223
  // timestamp; those are indistinguishable here, but they are rare and would
@@ -184,6 +233,11 @@ export function computeInsightFacets(events, timezoneOffsetMinutes = new Date().
184
233
  f.responseGaps.push(gap);
185
234
  else if (gap >= GAP_CEILING_SECONDS)
186
235
  f.gapsOverCeiling++;
236
+ // Agent silent stall: model stopped; session sat idle until this message.
237
+ if (gap >= SILENT_STALL_SECONDS) {
238
+ classifySilentStall(gap, e.content ?? '', f.frictionSignals, f.correctionSignals);
239
+ bump(f.silentStallsByModel, lastAssistantModel ?? 'unknown');
240
+ }
187
241
  }
188
242
  }
189
243
  lastAssistantTs = null;
@@ -195,8 +249,12 @@ export function computeInsightFacets(events, timezoneOffsetMinutes = new Date().
195
249
  break;
196
250
  if (hasTs)
197
251
  lastAssistantTs = ts;
252
+ if (e.model)
253
+ lastAssistantModel = shortenModel(e.model);
198
254
  const args = e.args ?? {};
199
255
  const toolName = e.tool ?? '';
256
+ if (/askuserquestion/i.test(toolName))
257
+ classifyAskStall(args, f.correctionSignals);
200
258
  // Keyed on the SHARED cross-harness vocabulary, not Claude's literals. Keying
201
259
  // on 'Edit'|'MultiEdit'|'Write' meant codex (whose vocabulary is exec /
202
260
  // exec_command / write_stdin) reported 5,197 tool calls and exactly zero lines
@@ -218,6 +276,7 @@ export function computeInsightFacets(events, timezoneOffsetMinutes = new Date().
218
276
  f.shellCommandsSeen++;
219
277
  f.gitCommits += countGitOp(e.command, 'commit');
220
278
  f.gitPushes += countGitOp(e.command, 'push');
279
+ classifyAutomation(e.command, f.automationSignals);
221
280
  }
222
281
  break;
223
282
  }
@@ -228,6 +287,77 @@ export function computeInsightFacets(events, timezoneOffsetMinutes = new Date().
228
287
  }
229
288
  return f;
230
289
  }
290
+ const FRICTION_PATTERNS = [
291
+ [/blocked by|guard(?:rail)? (?:blocked|denied)|permission denied/i, 'blocked guard'],
292
+ [/\b(?:ci|check|workflow)\b.*\b(?:red|fail(?:ed|ure)?)\b|\b(?:red|failed)\b.*\bci\b/i, 'CI red loop'],
293
+ [/merge conflict|conflict in |CONFLICT \(/i, 'merge conflict'],
294
+ ];
295
+ const CORRECTION_PATTERNS = [
296
+ [/\b(?:continue|keep going|don'?t stop)\b/i, 'continue / keep going'],
297
+ [/\b(?:yes|go ahead|do it|merge it)\b/i, 'approval repeated'],
298
+ [/\b(?:are we done|done end.to.end|what(?:'s| is) left)\b/i, 'done end-to-end?'],
299
+ [/\b(?:did you merge|merged\??)\b/i, 'did you merge?'],
300
+ [/\bwhat(?:'s| is) next\??\b/i, "what's next?"],
301
+ [/\b(?:check now|check again|try now|did it work)\b/i, 'check now'],
302
+ [/\b(?:don'?t ask|just do it|run what)\b/i, "don't ask / just do it"],
303
+ ];
304
+ /** User text that is a pure resume nudge after the agent went silent. */
305
+ const RESUME_NUDGE_PATTERN = /\b(?:continue|keep going|don'?t stop|resume|pick up|you stopped|still there|wake up|hello\??|are you (?:there|stuck)|go on)\b/i;
306
+ /**
307
+ * Classify a long quiet gap after the assistant's last event as an agent silent stall.
308
+ * Optionally also marks an explicit resume nudge ("continue", …) after that silence.
309
+ */
310
+ export function classifySilentStall(gapSeconds, userText, friction, corrections) {
311
+ for (const [label, min, max] of SILENT_STALL_BUCKETS) {
312
+ if (gapSeconds >= min && gapSeconds < max) {
313
+ bump(friction, label);
314
+ break;
315
+ }
316
+ }
317
+ const normalized = userText.replace(/<[^>]+>/g, ' ').replace(/\s+/g, ' ').trim();
318
+ if (normalized && RESUME_NUDGE_PATTERN.test(normalized)) {
319
+ bump(corrections, 'resume after silent stall');
320
+ }
321
+ }
322
+ const AUTOMATION_PATTERNS = [
323
+ [/\bgh pr (?:checks|view|merge)\b/i, 'PR babysitting'],
324
+ [/\bagents secrets (?:list|exec|unlock|export)\b/i, 'secrets unlock dance'],
325
+ [/\bgit (?:fetch|rebase|merge|push)\b/i, 'git reconcile recipe'],
326
+ [/\b(?:scp|rsync|agents ssh)\b/i, 'fleet file transfer'],
327
+ [/\b(?:release\.sh|deploy\.sh|npm publish)\b/i, 'release / deploy recipe'],
328
+ ];
329
+ function classifyFriction(text, counts) {
330
+ for (const [pattern, label] of FRICTION_PATTERNS)
331
+ if (pattern.test(text))
332
+ bump(counts, label);
333
+ }
334
+ function classifyCorrection(text, counts) {
335
+ const normalized = text.replace(/<[^>]+>/g, ' ').replace(/\s+/g, ' ').trim();
336
+ for (const [pattern, label] of CORRECTION_PATTERNS)
337
+ if (pattern.test(normalized))
338
+ bump(counts, label);
339
+ }
340
+ function classifyAskStall(args, counts) {
341
+ const text = JSON.stringify(args).toLowerCase();
342
+ const categories = [
343
+ [/release|ship|deploy|publish/, 'Ask stall: release / ship / deploy'],
344
+ [/what'?s next|next step|next move/, "Ask stall: what's next?"],
345
+ [/merge|reconcile|rebase/, 'Ask stall: merge / reconcile'],
346
+ [/direction|approach|implementation/, 'Ask stall: direction / approach'],
347
+ ];
348
+ for (const [pattern, label] of categories) {
349
+ if (pattern.test(text)) {
350
+ bump(counts, label);
351
+ return;
352
+ }
353
+ }
354
+ bump(counts, 'AskUserQuestion');
355
+ }
356
+ function classifyAutomation(command, counts) {
357
+ for (const [pattern, label] of AUTOMATION_PATTERNS)
358
+ if (pattern.test(command))
359
+ bump(counts, label);
360
+ }
231
361
  /** Percentile of a numeric sample, nearest-rank. Returns 0 for an empty sample. */
232
362
  export function percentile(values, p) {
233
363
  if (values.length === 0)
@@ -292,12 +422,20 @@ export function mergeFacets(into, add) {
292
422
  bump(into.toolCounts, k, v);
293
423
  for (const [k, v] of Object.entries(add.models))
294
424
  bump(into.models, k, v);
425
+ for (const [k, v] of Object.entries(add.silentStallsByModel ?? {}))
426
+ bump(into.silentStallsByModel, k, v);
295
427
  for (const [k, v] of Object.entries(add.languages))
296
428
  bump(into.languages, k, v);
297
429
  for (const [k, v] of Object.entries(add.slashCommands))
298
430
  bump(into.slashCommands, k, v);
299
431
  for (const [k, v] of Object.entries(add.errorCategories))
300
432
  bump(into.errorCategories, k, v);
433
+ for (const [k, v] of Object.entries(add.frictionSignals ?? {}))
434
+ bump(into.frictionSignals, k, v);
435
+ for (const [k, v] of Object.entries(add.correctionSignals ?? {}))
436
+ bump(into.correctionSignals, k, v);
437
+ for (const [k, v] of Object.entries(add.automationSignals ?? {}))
438
+ bump(into.automationSignals, k, v);
301
439
  into.interruptions += add.interruptions;
302
440
  into.responseGaps.push(...add.responseGaps);
303
441
  into.gapsOverCeiling += add.gapsOverCeiling;
@@ -328,3 +466,75 @@ export function topEntries(counts, limit) {
328
466
  .sort((a, b) => b.count - a.count || a.name.localeCompare(b.name))
329
467
  .slice(0, limit);
330
468
  }
469
+ /** Build a stable, evidence-backed action list without exposing transcript text. */
470
+ export function buildInsightActions(sessions) {
471
+ const specs = [
472
+ { source: 'correctionSignals', label: 'continue / keep going', category: 'rule', action: 'Keep working through the delivery chain without waiting for another “continue”.' },
473
+ { source: 'correctionSignals', label: 'resume after silent stall', category: 'rule', action: 'Never stop mid-task waiting for a human ping — finish the current goal or park with an explicit blocker; keep driving CI/review/merge without going idle.' },
474
+ { source: 'correctionSignals', label: 'approval repeated', category: 'rule', action: 'Treat the original build or ship request as authorization for routine follow-through.' },
475
+ { source: 'correctionSignals', label: 'done end-to-end?', category: 'rule', action: 'Verify the user-visible outcome before declaring the task complete.' },
476
+ { source: 'correctionSignals', label: 'did you merge?', category: 'automation', action: 'Automate PR review, CI watching, and merge-on-green as one durable workflow.' },
477
+ { source: 'correctionSignals', label: "what's next?", category: 'rule', action: 'State and execute the next in-scope step instead of asking the owner to steer implementation.' },
478
+ { source: 'correctionSignals', label: 'check now', category: 'automation', action: 'Add bounded status polling with a terminal success or failure signal.' },
479
+ { source: 'correctionSignals', label: "don't ask / just do it", category: 'rule', action: 'Reserve questions for genuine product or scope choices.' },
480
+ { source: 'correctionSignals', label: 'Ask stall: release / ship / deploy', category: 'skill', action: 'Teach release workflows to carry publish, tag, rollout, and live verification as one chain.' },
481
+ { source: 'correctionSignals', label: "Ask stall: what's next?", category: 'rule', action: 'Remove workflow-stall “what next?” prompts from agent guidance.' },
482
+ { source: 'correctionSignals', label: 'Ask stall: merge / reconcile', category: 'skill', action: 'Encode the safe merge and reconcile path in the git workflow skill.' },
483
+ { source: 'correctionSignals', label: 'Ask stall: direction / approach', category: 'rule', action: 'Let agents choose implementation details after scope is clear.' },
484
+ { source: 'frictionSignals', label: 'silent stall: 5-15m', category: 'rule', action: 'Stop ending turns while work remains open — after a tool batch, take the next step or schedule a real background wait that re-invokes you; do not sit idle until the user says continue.' },
485
+ { source: 'frictionSignals', label: 'silent stall: 15-60m', category: 'rule', action: 'Long idle after the assistant last spoke is an agent stop, not a user pause — drive open PRs/CI/todos to completion or name a true external blocker instead of going silent.' },
486
+ { source: 'frictionSignals', label: 'silent stall: 1h+', category: 'rule', action: 'Sessions that sit idle for an hour+ after the model stops are stranded work — use stop-gates, background watches, and queue drain so a human is not the only resume signal.' },
487
+ { source: 'frictionSignals', label: 'blocked guard', category: 'product', action: 'Make guard failures return the safe next command and exact blocked operation.' },
488
+ { source: 'frictionSignals', label: 'CI red loop', category: 'automation', action: 'Deduplicate CI watchers and turn repeated red checks into one stateful wait.' },
489
+ { source: 'frictionSignals', label: 'merge conflict', category: 'skill', action: 'Standardize conflict diagnosis and fix-forward reconciliation.' },
490
+ { source: 'automationSignals', label: 'PR babysitting', category: 'automation', action: 'Bundle PR checks, review collection, comment handling, and merge-on-green.' },
491
+ { source: 'automationSignals', label: 'secrets unlock dance', category: 'product', action: 'Provide one headless secrets-backed command path for repeated credential operations.' },
492
+ { source: 'automationSignals', label: 'git reconcile recipe', category: 'skill', action: 'Promote repeated git reconciliation commands into the canonical workflow.' },
493
+ { source: 'automationSignals', label: 'fleet file transfer', category: 'product', action: 'Add a first-class fleet file transfer command with host/path validation.' },
494
+ { source: 'automationSignals', label: 'release / deploy recipe', category: 'automation', action: 'Turn repeated release shell recipes into a checked-in release script.' },
495
+ ];
496
+ const actions = [];
497
+ for (const spec of specs) {
498
+ let evidenceCount = 0;
499
+ const ids = [];
500
+ for (const session of sessions) {
501
+ const count = session.facets[spec.source]?.[spec.label] ?? 0;
502
+ if (count <= 0)
503
+ continue;
504
+ evidenceCount += count;
505
+ if (ids.length < 3)
506
+ ids.push(session.id.slice(0, 8));
507
+ }
508
+ if (evidenceCount === 0)
509
+ continue;
510
+ actions.push({
511
+ priority: evidenceCount >= 10 ? 'high' : evidenceCount >= 3 ? 'medium' : 'low',
512
+ category: spec.category,
513
+ action: spec.action,
514
+ evidenceCount,
515
+ sampleSessionIds: ids,
516
+ });
517
+ }
518
+ let failedLoopCount = 0;
519
+ const failedLoopIds = [];
520
+ for (const session of sessions) {
521
+ const count = Object.entries(session.facets.frictionSignals ?? {})
522
+ .filter(([label]) => label.startsWith('failed tool loop:'))
523
+ .reduce((sum, [, value]) => sum + value, 0);
524
+ if (count <= 0)
525
+ continue;
526
+ failedLoopCount += count;
527
+ if (failedLoopIds.length < 3)
528
+ failedLoopIds.push(session.id.slice(0, 8));
529
+ }
530
+ if (failedLoopCount > 0) {
531
+ actions.push({
532
+ priority: failedLoopCount >= 10 ? 'high' : failedLoopCount >= 3 ? 'medium' : 'low',
533
+ category: 'automation',
534
+ action: 'Detect repeated failures of the same tool and stop the retry loop with a different recovery path.',
535
+ evidenceCount: failedLoopCount,
536
+ sampleSessionIds: failedLoopIds,
537
+ });
538
+ }
539
+ return actions.sort((a, b) => b.evidenceCount - a.evidenceCount || a.action.localeCompare(b.action));
540
+ }
@@ -5,6 +5,7 @@ export type SessionRecoveryTarget = {
5
5
  mode: 'native';
6
6
  agent: AgentId;
7
7
  version: string;
8
+ cwd?: string;
8
9
  reason: string;
9
10
  } | {
10
11
  mode: 'continue';
@@ -12,6 +13,13 @@ export type SessionRecoveryTarget = {
12
13
  version: string;
13
14
  reason: string;
14
15
  };
16
+ export type NativeResumeInspection = {
17
+ available: true;
18
+ cwd?: string;
19
+ } | {
20
+ available: false;
21
+ reason: string;
22
+ };
15
23
  export declare class SessionRecoveryError extends Error {
16
24
  constructor(message: string);
17
25
  }
@@ -21,6 +29,14 @@ export declare function sessionOriginDevice(session: Pick<SessionMeta, 'machine'
21
29
  export declare function sessionRecoveryPeer(session: Pick<SessionMeta, 'machine'>, selfCheck?: (host: string) => boolean): string | undefined;
22
30
  /** Whether an explicit placement names the session's origin device. */
23
31
  export declare function sessionRecoveryDestinationMatches(session: Pick<SessionMeta, 'machine'>, requestedHost: string, self?: string): boolean;
32
+ /**
33
+ * Prove that the indexed transcript is reachable from the exact active version
34
+ * home that would receive native resume. Retained trash/backup transcripts are
35
+ * intentionally rejected here: they remain readable by `/continue`, but a new
36
+ * installation with the same version number must not native-resume an empty
37
+ * isolated home.
38
+ */
39
+ export declare function inspectNativeResumeSession(session: SessionMeta, versionHome: string): NativeResumeInspection;
24
40
  /**
25
41
  * Decide how a durable session resumes on the device that owns it.
26
42
  *
@@ -29,7 +45,7 @@ export declare function sessionRecoveryDestinationMatches(session: Pick<SessionM
29
45
  * same harness and uses `/continue`, whose indexed transcript reader can reach
30
46
  * retained version trash. No healthy same-harness account is a loud failure.
31
47
  */
32
- export declare function resolveSessionRecoveryFromCandidates(session: SessionMeta, candidates: RotateCandidate[], supportsNative?: (agent: AgentId, version?: string) => boolean): SessionRecoveryTarget;
48
+ export declare function resolveSessionRecoveryFromCandidates(session: SessionMeta, candidates: RotateCandidate[], supportsNative?: (agent: AgentId, version?: string) => boolean, nativeInspection?: NativeResumeInspection): SessionRecoveryTarget;
33
49
  export declare function resolveSessionRecovery(session: SessionMeta): Promise<SessionRecoveryTarget>;
34
50
  /** Stable self-command used by focus, resume, and attach. The owning device runs
35
51
  * the recovery resolver above; callers must not native-resume another version's
@@ -1,8 +1,11 @@
1
- import { AGENTS } from '../agents.js';
1
+ import * as fs from 'fs';
2
+ import * as path from 'path';
3
+ import { AGENTS, agentConfigDirName } from '../agents.js';
2
4
  import { isSelfHost } from '../devices/self-host.js';
3
5
  import { nativeResume } from '../exec.js';
4
6
  import { machineId, normalizeHost } from '../machine-id.js';
5
7
  import { collectRunCandidates, formatNoHealthyAccountError, pickBalancedCandidate, readinessFromCandidate, } from '../rotate.js';
8
+ import { getVersionHomePath } from '../versions.js';
6
9
  export class SessionRecoveryError extends Error {
7
10
  constructor(message) {
8
11
  super(message);
@@ -41,6 +44,99 @@ function sourceReason(session, candidates) {
41
44
  ? `origin ${session.agent}@${session.version} has no native resume form`
42
45
  : `origin ${session.agent}@${session.version} is ${readiness.reason}`;
43
46
  }
47
+ function isPathInside(candidate, dir) {
48
+ const rel = path.relative(dir, candidate);
49
+ return rel === '' || (!!rel && !rel.startsWith('..') && !path.isAbsolute(rel));
50
+ }
51
+ function existingDirectory(dir) {
52
+ if (!dir)
53
+ return undefined;
54
+ try {
55
+ return fs.statSync(dir, { throwIfNoEntry: false })?.isDirectory() ? dir : undefined;
56
+ }
57
+ catch {
58
+ return undefined;
59
+ }
60
+ }
61
+ /** Read the launch cwd Claude used to choose its projects/<cwd-key> directory.
62
+ * Claude can record attachment envelopes before the first user turn, and those
63
+ * envelopes retain the actual launch cwd even after the session changes dirs. */
64
+ function readClaudeLaunchCwd(filePath) {
65
+ const maxBytes = 2 * 1024 * 1024;
66
+ let fd;
67
+ try {
68
+ fd = fs.openSync(filePath, 'r');
69
+ }
70
+ catch {
71
+ return undefined;
72
+ }
73
+ try {
74
+ const chunk = Buffer.alloc(maxBytes);
75
+ const bytesRead = fs.readSync(fd, chunk, 0, maxBytes, 0);
76
+ const lines = chunk.toString('utf8', 0, bytesRead).split('\n');
77
+ for (const line of lines) {
78
+ if (!line.trim())
79
+ continue;
80
+ try {
81
+ const parsed = JSON.parse(line);
82
+ if (typeof parsed?.cwd !== 'string' || !path.isAbsolute(parsed.cwd))
83
+ continue;
84
+ if (existingDirectory(parsed.cwd))
85
+ return parsed.cwd;
86
+ }
87
+ catch {
88
+ // A malformed line or vanished cwd cannot identify a usable native home.
89
+ }
90
+ }
91
+ }
92
+ finally {
93
+ fs.closeSync(fd);
94
+ }
95
+ return undefined;
96
+ }
97
+ /**
98
+ * Prove that the indexed transcript is reachable from the exact active version
99
+ * home that would receive native resume. Retained trash/backup transcripts are
100
+ * intentionally rejected here: they remain readable by `/continue`, but a new
101
+ * installation with the same version number must not native-resume an empty
102
+ * isolated home.
103
+ */
104
+ export function inspectNativeResumeSession(session, versionHome) {
105
+ let realFile;
106
+ try {
107
+ realFile = fs.realpathSync(session.filePath);
108
+ }
109
+ catch {
110
+ return { available: false, reason: 'the indexed transcript is no longer present in the origin home' };
111
+ }
112
+ const roots = [versionHome, path.join(versionHome, agentConfigDirName(session.agent))];
113
+ const owned = roots.some((root) => {
114
+ try {
115
+ return isPathInside(realFile, fs.realpathSync(root));
116
+ }
117
+ catch {
118
+ return false;
119
+ }
120
+ });
121
+ if (!owned) {
122
+ return {
123
+ available: false,
124
+ reason: `the indexed transcript is retained outside the active ${session.agent}@${session.version ?? 'unknown'} home`,
125
+ };
126
+ }
127
+ if (session.agent === 'claude') {
128
+ const cwd = readClaudeLaunchCwd(realFile);
129
+ if (!cwd) {
130
+ return {
131
+ available: false,
132
+ reason: 'the Claude transcript does not identify an existing original project directory',
133
+ };
134
+ }
135
+ return { available: true, cwd };
136
+ }
137
+ const cwd = existingDirectory(session.cwd);
138
+ return { available: true, cwd };
139
+ }
44
140
  /**
45
141
  * Decide how a durable session resumes on the device that owns it.
46
142
  *
@@ -49,7 +145,7 @@ function sourceReason(session, candidates) {
49
145
  * same harness and uses `/continue`, whose indexed transcript reader can reach
50
146
  * retained version trash. No healthy same-harness account is a loud failure.
51
147
  */
52
- export function resolveSessionRecoveryFromCandidates(session, candidates, supportsNative = nativeResume) {
148
+ export function resolveSessionRecoveryFromCandidates(session, candidates, supportsNative = nativeResume, nativeInspection) {
53
149
  const agent = runnableSessionAgent(session);
54
150
  const device = sessionOriginDevice(session);
55
151
  const source = session.version
@@ -69,11 +165,22 @@ export function resolveSessionRecoveryFromCandidates(session, candidates, suppor
69
165
  }
70
166
  const version = selection.picked.version;
71
167
  if (session.version === version && supportsNative(agent, version)) {
168
+ const inspection = nativeInspection
169
+ ?? inspectNativeResumeSession(session, getVersionHomePath(agent, version));
170
+ if (inspection.available) {
171
+ return {
172
+ mode: 'native',
173
+ agent,
174
+ version,
175
+ cwd: inspection.cwd,
176
+ reason: `origin ${agent}@${version} is installed, healthy, and owns the indexed transcript`,
177
+ };
178
+ }
72
179
  return {
73
- mode: 'native',
180
+ mode: 'continue',
74
181
  agent,
75
182
  version,
76
- reason: `origin ${agent}@${version} is installed and healthy`,
183
+ reason: `${inspection.reason}; continuing with healthy ${agent}@${version}`,
77
184
  };
78
185
  }
79
186
  return {
@@ -2,20 +2,12 @@ import { type DeviceProfile } from '../devices/registry.js';
2
2
  import { type RemoteAgentsJsonParseResult } from '../remote-agents-json.js';
3
3
  import type { SessionMeta } from './types.js';
4
4
  import { type ToolProgramCountEnvelope, type ToolSearchEnvelope } from './tool-index.js';
5
- export declare const REMOTE_STDOUT_MAX_BYTES: number;
5
+ export { REMOTE_STDOUT_MAX_BYTES, RemoteUtf8Accumulator } from '../ssh-exec.js';
6
6
  export declare const REMOTE_TOOL_AGGREGATE_MAX_BYTES: number;
7
7
  export interface RemoteToolByteBudget {
8
8
  remainingBytes: number;
9
9
  exhausted: boolean;
10
10
  }
11
- /** Preserve UTF-8 code points when SSH splits them across stdout chunks. */
12
- export declare class RemoteUtf8Accumulator {
13
- private readonly decoder;
14
- private value;
15
- write(chunk: Buffer): void;
16
- end(): string;
17
- current(): string;
18
- }
19
11
  /** Claim received bytes against one fleet-query budget before retaining them. */
20
12
  export declare function consumeRemoteToolByteBudget(budget: RemoteToolByteBudget, bytes: number): boolean;
21
13
  /** Charge sanitized, machine-stamped evidence because redaction may expand it. */
@@ -130,7 +122,16 @@ export declare function resolvePeerTarget(machine: string): Promise<{
130
122
  * not via a local `--host` hop, which would discover locally and dead-end for a
131
123
  * session that exists only on the peer. Resolves 'no-target' when the machine
132
124
  * isn't a dialable registered device; the caller surfaces a clear message.
125
+ *
126
+ * `opts.env` adds variables to the remote command. It deliberately does NOT
127
+ * carry `AGENTS_FLEET_REMOTE` the way the `--host` passthrough does: that marker
128
+ * gates consent-sensitive actions on the far side
129
+ * (lib/browser/remote-control.ts), and a resumed agent is a long-lived session
130
+ * that would inherit it for its whole life — `agents browser start` inside it
131
+ * would then be refused as a cross-machine drive. A one-shot `--host` command
132
+ * can carry the marker; a session cannot.
133
133
  */
134
134
  export declare function runOnPeer(args: string[], machine: string, opts?: {
135
135
  tty?: boolean;
136
+ env?: Record<string, string>;
136
137
  }): Promise<'ok' | 'no-target'>;