@phnx-labs/agents-cli 1.22.51 → 1.22.53

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 (117) hide show
  1. package/CHANGELOG.md +238 -0
  2. package/README.md +1 -1
  3. package/dist/commands/accounts.js +1 -1
  4. package/dist/commands/attach.js +7 -0
  5. package/dist/commands/browser.js +118 -56
  6. package/dist/commands/daemon.d.ts +2 -0
  7. package/dist/commands/daemon.js +8 -4
  8. package/dist/commands/detach.js +1 -1
  9. package/dist/commands/exec.js +16 -9
  10. package/dist/commands/fleet-capture.js +7 -0
  11. package/dist/commands/focus.d.ts +1 -10
  12. package/dist/commands/focus.js +16 -79
  13. package/dist/commands/go.d.ts +26 -0
  14. package/dist/commands/go.js +65 -6
  15. package/dist/commands/monitors.js +1 -1
  16. package/dist/commands/repo.js +31 -3
  17. package/dist/commands/sessions-inject.js +8 -3
  18. package/dist/commands/sessions-picker.js +2 -1
  19. package/dist/commands/sessions-resume.d.ts +1 -0
  20. package/dist/commands/sessions-resume.js +13 -2
  21. package/dist/commands/sessions-stop.js +1 -1
  22. package/dist/commands/sessions.d.ts +23 -13
  23. package/dist/commands/sessions.js +69 -39
  24. package/dist/commands/setup-browser.d.ts +5 -2
  25. package/dist/commands/setup-browser.js +14 -29
  26. package/dist/commands/setup-preferences.d.ts +22 -3
  27. package/dist/commands/setup-preferences.js +25 -8
  28. package/dist/commands/share.js +12 -8
  29. package/dist/commands/ssh.js +35 -12
  30. package/dist/commands/status.js +5 -0
  31. package/dist/commands/sync.js +102 -2
  32. package/dist/commands/tmux.d.ts +8 -1
  33. package/dist/commands/tmux.js +167 -17
  34. package/dist/lib/account-registry.d.ts +15 -5
  35. package/dist/lib/account-registry.js +150 -50
  36. package/dist/lib/answer-router.js +2 -1
  37. package/dist/lib/browser/ipc.d.ts +44 -0
  38. package/dist/lib/browser/ipc.js +120 -8
  39. package/dist/lib/browser/profiles.d.ts +57 -17
  40. package/dist/lib/browser/profiles.js +77 -53
  41. package/dist/lib/browser/registry.d.ts +44 -14
  42. package/dist/lib/browser/registry.js +141 -45
  43. package/dist/lib/browser/runtime-state.d.ts +4 -2
  44. package/dist/lib/browser/runtime-state.js +4 -2
  45. package/dist/lib/browser/service.js +4 -3
  46. package/dist/lib/channels/owner-forward.d.ts +88 -0
  47. package/dist/lib/channels/owner-forward.js +116 -0
  48. package/dist/lib/channels/owner-sink.js +7 -0
  49. package/dist/lib/daemon/runner.js +10 -2
  50. package/dist/lib/device-config.js +3 -2
  51. package/dist/lib/devices/config-migration.js +147 -1
  52. package/dist/lib/devices/device-docs.d.ts +35 -0
  53. package/dist/lib/devices/device-docs.js +163 -0
  54. package/dist/lib/devices/discovery-policy.d.ts +14 -2
  55. package/dist/lib/devices/discovery-policy.js +31 -21
  56. package/dist/lib/devices/registry.d.ts +11 -5
  57. package/dist/lib/devices/registry.js +46 -18
  58. package/dist/lib/exec.d.ts +66 -28
  59. package/dist/lib/exec.js +71 -26
  60. package/dist/lib/feed/feed.d.ts +10 -2
  61. package/dist/lib/feed/feed.js +12 -1
  62. package/dist/lib/feed-broadcast.js +15 -1
  63. package/dist/lib/git.d.ts +93 -0
  64. package/dist/lib/git.js +232 -0
  65. package/dist/lib/hosts/dispatch.d.ts +4 -3
  66. package/dist/lib/hosts/dispatch.js +12 -8
  67. package/dist/lib/hosts/providers/local.d.ts +9 -3
  68. package/dist/lib/hosts/providers/local.js +23 -12
  69. package/dist/lib/hosts/reconnect.d.ts +7 -4
  70. package/dist/lib/hosts/reconnect.js +29 -25
  71. package/dist/lib/hosts/registry.js +4 -1
  72. package/dist/lib/hosts/remote-os.js +3 -1
  73. package/dist/lib/monitors/remote.d.ts +18 -1
  74. package/dist/lib/monitors/remote.js +15 -2
  75. package/dist/lib/notify.d.ts +7 -0
  76. package/dist/lib/notify.js +15 -1
  77. package/dist/lib/session/active.d.ts +10 -1
  78. package/dist/lib/session/active.js +7 -1
  79. package/dist/lib/session/actor-sidecar.d.ts +7 -0
  80. package/dist/lib/session/actor-sidecar.js +2 -0
  81. package/dist/lib/session/db.d.ts +1 -1
  82. package/dist/lib/session/db.js +39 -3
  83. package/dist/lib/session/discover.js +7 -12
  84. package/dist/lib/session/live-metadata.js +1 -0
  85. package/dist/lib/session/local-tmux-attach.d.ts +69 -0
  86. package/dist/lib/session/local-tmux-attach.js +164 -0
  87. package/dist/lib/session/pid-registry.d.ts +7 -0
  88. package/dist/lib/session/prompt.d.ts +15 -0
  89. package/dist/lib/session/prompt.js +21 -0
  90. package/dist/lib/session/remote-active.d.ts +8 -0
  91. package/dist/lib/session/remote-active.js +1 -0
  92. package/dist/lib/session/types.d.ts +17 -0
  93. package/dist/lib/session/types.js +10 -0
  94. package/dist/lib/share/publish.d.ts +8 -11
  95. package/dist/lib/share/publish.js +16 -20
  96. package/dist/lib/share/worker-template.js +104 -12
  97. package/dist/lib/state.d.ts +8 -0
  98. package/dist/lib/state.js +143 -11
  99. package/dist/lib/sync-status.d.ts +17 -0
  100. package/dist/lib/sync-status.js +21 -2
  101. package/dist/lib/terminal/resolve.d.ts +7 -0
  102. package/dist/lib/terminal/resolve.js +41 -2
  103. package/dist/lib/tmux/index.d.ts +1 -1
  104. package/dist/lib/tmux/index.js +1 -1
  105. package/dist/lib/tmux/session.d.ts +10 -0
  106. package/dist/lib/tmux/session.js +29 -0
  107. package/dist/lib/traces/insights.d.ts +67 -0
  108. package/dist/lib/traces/insights.js +178 -0
  109. package/dist/lib/traces/phenotype.d.ts +67 -0
  110. package/dist/lib/traces/phenotype.js +437 -0
  111. package/dist/lib/traces/segments.d.ts +133 -0
  112. package/dist/lib/traces/segments.js +301 -0
  113. package/dist/lib/traces/sync.d.ts +33 -0
  114. package/dist/lib/traces/sync.js +11 -2
  115. package/dist/lib/types.d.ts +47 -1
  116. package/dist/lib/watchdog/runner.js +18 -4
  117. package/package.json +1 -1
@@ -0,0 +1,301 @@
1
+ /**
2
+ * Group-by dimensions + time-to-first-tool latency for the traces insight
3
+ * engine (the console's Issue bar).
4
+ *
5
+ * Pure functions, no I/O. Classifiers are DATA-DRIVEN TABLES — a new task type
6
+ * or timing bucket is a row, not a new if/else-by-name arm. The integrator
7
+ * (`insights.ts` / `sync.ts`) tags each session; this file does not write the
8
+ * shard.
9
+ *
10
+ * Input is the redacted `SessionDetail` from `agents traces sync` plus the
11
+ * SyncRow fields the detail strips (prompt, gitBranch, files). Structural —
12
+ * `SessionDetail` is a valid `SegmentSession`.
13
+ */
14
+ // ---------------------------------------------------------------------------
15
+ // Public types
16
+ // ---------------------------------------------------------------------------
17
+ export const TASK_TYPES = ['bugfix', 'feature', 'refactor', 'test', 'chore', 'other'];
18
+ export const FAILURE_TIMINGS = ['early', 'mid', 'late'];
19
+ // ---------------------------------------------------------------------------
20
+ // Tables — the classifiers. Add a row; do not add an if/else-by-name arm.
21
+ // ---------------------------------------------------------------------------
22
+ /**
23
+ * Tool name → file action. Lowercased. Write-family is a new file; Edit-family
24
+ * is a patch. Anything else is ignored for diff-shape.
25
+ */
26
+ export const FILE_ACTION_TOOLS = [
27
+ { action: 'new', tools: ['write', 'write_file', 'create_file', 'create'] },
28
+ { action: 'edit', tools: ['edit', 'strreplace', 'apply_patch', 'applypatch', 'notebookedit'] },
29
+ ];
30
+ /** Path heuristics for a test-only diff. */
31
+ const TEST_PATH_PATTERN = /(?:^|\/)(?:__tests__|testdata|test)\/|\.(?:test|spec)\.[cm]?[jt]sx?$|_test\.(?:go|py|rs)$/i;
32
+ /** Path heuristics for a chore-only diff (lockfiles, CI, docs). */
33
+ const CHORE_PATH_PATTERN = /(?:^|\/)(?:package(?:-lock)?\.json|bun\.lockb?|pnpm-lock\.yaml|yarn\.lock|Cargo\.lock|go\.sum|Dockerfile|Makefile|\.github\/|CHANGELOG[^/]*|README[^/]*|\.gitignore)$/i;
34
+ const DIFF_SHAPE_MATCHERS = [
35
+ {
36
+ key: 'testOnly',
37
+ match: (files) => files.length > 0 && files.every((f) => TEST_PATH_PATTERN.test(f.path)),
38
+ },
39
+ {
40
+ key: 'newOnly',
41
+ match: (files) => files.length > 0 && files.every((f) => f.action === 'new'),
42
+ },
43
+ {
44
+ key: 'choreOnly',
45
+ match: (files) => files.length > 0 && files.every((f) => CHORE_PATH_PATTERN.test(f.path)),
46
+ },
47
+ ];
48
+ /**
49
+ * Task-type rules, first match wins. Prompt/branch keywords outrank diff shape
50
+ * except where `shape` is set (test-only files, chore-only files, all-new files).
51
+ *
52
+ * Order is the product priority: test-only work is not a "feature"; a `fix/`
53
+ * branch is a bugfix even if files are new; chore lockfile edits are not features.
54
+ */
55
+ export const TASK_TYPE_RULES = [
56
+ {
57
+ type: 'test',
58
+ patterns: [
59
+ /\bunit tests?\b/,
60
+ /\bintegration tests?\b/,
61
+ /\btestdata\b/,
62
+ /\bcoverage\b/,
63
+ /\b(?:add|write|update|fix)\s+tests?\b/,
64
+ /\btest[- ]only\b/,
65
+ /(?:^|\/)tests?\//,
66
+ /^test\//,
67
+ /\.(?:test|spec)\./,
68
+ ],
69
+ shape: 'testOnly',
70
+ },
71
+ {
72
+ type: 'bugfix',
73
+ patterns: [
74
+ /\bbugfix(?:es)?\b/,
75
+ /\bhot[- ]?fix(?:es)?\b/,
76
+ /\bfixes\b/,
77
+ /\bfixing\b/,
78
+ /\bfix\b/,
79
+ /\bbugs?\b/,
80
+ /\bregression\b/,
81
+ /\bcrash(?:es|ed|ing)?\b/,
82
+ /\bbroken\b/,
83
+ /(?:^|\/)fix(?:es)?\//,
84
+ /^bugfix\//,
85
+ ],
86
+ },
87
+ {
88
+ type: 'refactor',
89
+ patterns: [
90
+ /\brefactor(?:s|ing|ed)?\b/,
91
+ /\bclean[- ]?ups?\b/,
92
+ /\bextract(?:s|ing|ed)?\b/,
93
+ /\brename(?:s|d|ing)?\b/,
94
+ /(?:^|\/)refactor\//,
95
+ ],
96
+ },
97
+ {
98
+ type: 'chore',
99
+ patterns: [
100
+ /\bchore\b/,
101
+ /\bdependenc(?:y|ies)\b/,
102
+ /\bbump\b/,
103
+ /\blint(?:ing|er)?\b/,
104
+ /\bformat(?:ting)?\b/,
105
+ /(?:^|\/)chore\//,
106
+ /\b(?:ci|cd)[-_/]/,
107
+ ],
108
+ shape: 'choreOnly',
109
+ },
110
+ {
111
+ type: 'feature',
112
+ patterns: [
113
+ /\bfeat(?:ure)?s?\b/,
114
+ /\bimplement(?:s|ing|ed)?\b/,
115
+ /\bnew feature\b/,
116
+ /(?:^|\/)feat(?:ure)?\//,
117
+ ],
118
+ shape: 'newOnly',
119
+ },
120
+ ];
121
+ /**
122
+ * Normalized position of the first failing step inside the session span.
123
+ * Early failures cascade — highest-signal bucket. Exclusive upper bound.
124
+ */
125
+ export const FAILURE_TIMING_BUCKETS = [
126
+ { timing: 'early', maxExclusive: 1 / 3 },
127
+ { timing: 'mid', maxExclusive: 2 / 3 },
128
+ { timing: 'late', maxExclusive: Number.POSITIVE_INFINITY },
129
+ ];
130
+ const AGENT_FIELDS = {
131
+ model: [
132
+ (s) => s.meta?.model,
133
+ (s) => s.model,
134
+ ],
135
+ harness: [
136
+ (s) => s.meta?.agent,
137
+ (s) => s.harness,
138
+ (s) => s.agent,
139
+ ],
140
+ };
141
+ // ---------------------------------------------------------------------------
142
+ // Public functions
143
+ // ---------------------------------------------------------------------------
144
+ /** Model × harness unit — the #1 group-by axis. */
145
+ export function deriveAgent(session) {
146
+ return {
147
+ model: firstPresent(AGENT_FIELDS.model.map((read) => read(session))),
148
+ harness: firstPresent(AGENT_FIELDS.harness.map((read) => read(session))),
149
+ };
150
+ }
151
+ /**
152
+ * Classify the session's work from the opening prompt + diff shape (files
153
+ * touched, new vs edit, test-only). First matching table row wins.
154
+ */
155
+ export function classifyTaskType(session) {
156
+ const haystack = taskHaystack(session);
157
+ const shape = inferDiffShape(session);
158
+ for (const rule of TASK_TYPE_RULES) {
159
+ if (rule.patterns.some((pattern) => pattern.test(haystack)))
160
+ return rule.type;
161
+ if (rule.shape && shape[rule.shape])
162
+ return rule.type;
163
+ }
164
+ return 'other';
165
+ }
166
+ /**
167
+ * Bucket the FIRST failing step by its normalized position in the session
168
+ * span (`firstFailure.startMs / spanMs`). Sessions with no failing step
169
+ * return null — they do not belong on the failure-timing axis.
170
+ */
171
+ export function failureTiming(session) {
172
+ const firstFailure = (session.steps ?? []).find((step) => step.outcome === 'error');
173
+ if (!firstFailure)
174
+ return null;
175
+ const span = sessionSpanMs(session);
176
+ const position = span > 0 ? firstFailure.startMs / span : 0;
177
+ return FAILURE_TIMING_BUCKETS.find((bucket) => position < bucket.maxExclusive)?.timing ?? null;
178
+ }
179
+ /**
180
+ * Time-to-first-tool latency from `steps[0].startMs` across sessions that
181
+ * recorded at least one step. Nearest-rank percentiles (same formula as the
182
+ * traces index stats) so p99 on `/tmp/traces-real` lands at ~128s.
183
+ */
184
+ export function computeLatency(sessions) {
185
+ const samples = [];
186
+ for (const session of sessions) {
187
+ const steps = session.steps ?? [];
188
+ if (steps.length === 0)
189
+ continue;
190
+ const startMs = steps[0]?.startMs;
191
+ if (typeof startMs !== 'number' || !Number.isFinite(startMs))
192
+ continue;
193
+ samples.push(startMs);
194
+ }
195
+ return {
196
+ firstToolMs: {
197
+ p50: percentileNearest(samples, 0.5),
198
+ p90: percentileNearest(samples, 0.9),
199
+ p99: percentileNearest(samples, 0.99),
200
+ max: samples.length === 0 ? 0 : Math.max(...samples),
201
+ },
202
+ };
203
+ }
204
+ /** All three group-by dimensions for one session. */
205
+ export function deriveDimensions(session) {
206
+ return {
207
+ agent: deriveAgent(session),
208
+ taskType: classifyTaskType(session),
209
+ failureTiming: failureTiming(session),
210
+ };
211
+ }
212
+ // ---------------------------------------------------------------------------
213
+ // Internals
214
+ // ---------------------------------------------------------------------------
215
+ function firstPresent(values) {
216
+ for (const value of values) {
217
+ if (typeof value === 'string' && value.length > 0)
218
+ return value;
219
+ }
220
+ return 'unknown';
221
+ }
222
+ function taskHaystack(session) {
223
+ const filePaths = inferFiles(session).map((file) => file.path);
224
+ const writeLabels = (session.steps ?? [])
225
+ .filter((step) => fileActionForTool(step.tool) !== undefined)
226
+ .map((step) => step.label ?? '');
227
+ return [
228
+ session.prompt,
229
+ session.gitBranch,
230
+ session.topic,
231
+ session.label,
232
+ session.meta?.repo,
233
+ ...filePaths,
234
+ ...writeLabels,
235
+ ]
236
+ .filter((value) => typeof value === 'string' && value.length > 0)
237
+ .join('\n')
238
+ .toLowerCase();
239
+ }
240
+ function inferFiles(session) {
241
+ if (session.files && session.files.length > 0)
242
+ return session.files;
243
+ const files = [];
244
+ for (const step of session.steps ?? []) {
245
+ const action = fileActionForTool(step.tool);
246
+ if (!action)
247
+ continue;
248
+ const path = filePathFromLabel(step.label);
249
+ if (!path)
250
+ continue;
251
+ files.push({ path, action });
252
+ }
253
+ return files;
254
+ }
255
+ function fileActionForTool(tool) {
256
+ if (!tool)
257
+ return undefined;
258
+ const name = tool.toLowerCase();
259
+ for (const row of FILE_ACTION_TOOLS) {
260
+ if (row.tools.includes(name))
261
+ return row.action;
262
+ }
263
+ return undefined;
264
+ }
265
+ /** Best-effort path from a redacted Write/Edit label (`path/to/file.ts`). */
266
+ function filePathFromLabel(label) {
267
+ if (!label)
268
+ return undefined;
269
+ const match = label.match(/(?:^|[\s`'"])(\/?[\w.@-]+(?:\/[\w.@-]+)+\.[A-Za-z][\w.]*)/);
270
+ return match?.[1];
271
+ }
272
+ function inferDiffShape(session) {
273
+ const files = inferFiles(session);
274
+ const shape = { files, testOnly: false, newOnly: false, choreOnly: false };
275
+ for (const matcher of DIFF_SHAPE_MATCHERS) {
276
+ shape[matcher.key] = matcher.match(files);
277
+ }
278
+ return shape;
279
+ }
280
+ function sessionSpanMs(session) {
281
+ const declared = session.meta?.spanMs ?? session.spanMs ?? 0;
282
+ if (declared > 0)
283
+ return declared;
284
+ let max = 0;
285
+ for (const step of session.steps ?? []) {
286
+ max = Math.max(max, step.startMs + (step.durationMs ?? 0));
287
+ }
288
+ return max;
289
+ }
290
+ /**
291
+ * Nearest-rank percentile, ratio in (0, 1]. Same index rule as
292
+ * `sync.ts`'s private `percentile` — linear interpolation (`lib/percentile.ts`)
293
+ * would report ~71s for p99 on the 738-session corpus; the published figure is
294
+ * 128s / 2m8s.
295
+ */
296
+ function percentileNearest(values, ratio) {
297
+ if (values.length === 0)
298
+ return 0;
299
+ const sorted = [...values].sort((a, b) => a - b);
300
+ return sorted[Math.min(sorted.length - 1, Math.ceil(sorted.length * ratio) - 1)];
301
+ }
@@ -21,6 +21,8 @@
21
21
  */
22
22
  import { type SessionTrajectory } from '../session/trajectory.js';
23
23
  import { type BucketStats, type DriftSignal, type TraceFailureCause, type TraceTopicGroup } from './classify.js';
24
+ import { type FailurePattern } from './insights.js';
25
+ import type { LatencyInsight } from './segments.js';
24
26
  export interface SyncOpts {
25
27
  /** Limit to N sessions (for testing / --dry-run); no limit when undefined. */
26
28
  limit?: number;
@@ -82,6 +84,12 @@ export interface TracesIndexShard {
82
84
  bucketHistory: BucketStats[][];
83
85
  /** Movement signals for buckets with ≥3 days of history. */
84
86
  driftSignals: DriftSignal[];
87
+ /** Cross-session failure clusters ranked by wasted time, from computeInsights(). Top-K, bounded. */
88
+ failurePatterns: FailurePattern[];
89
+ /** Sum of wastedMs across every cluster (not just the top-K rows above) — the headline number. */
90
+ wastedMsTotal: number;
91
+ /** Time-to-first-tool percentiles across this device's sessions. */
92
+ latency: LatencyInsight;
85
93
  }
86
94
  export interface IndexedSession {
87
95
  id: string;
@@ -93,6 +101,21 @@ export interface IndexedSession {
93
101
  severity: number;
94
102
  flags: string[];
95
103
  }
104
+ /** A row from `tool_calls`. `ordinal`/`timestamp` order calls within a session for computeInsights(). */
105
+ export interface ToolCallRow {
106
+ session_id: string;
107
+ ordinal: number;
108
+ timestamp: string;
109
+ tool: string;
110
+ outcome: string;
111
+ exit_code: number | null;
112
+ status_code: number | null;
113
+ error_code: string | null;
114
+ error: string | null;
115
+ parse_error: string | null;
116
+ }
117
+ /** Human description of a failed call, keyed by (tool, desc, cause) for grouping. Exported for computeInsights(). */
118
+ export declare function failureDescription(call: ToolCallRow, cause: TraceFailureCause): string;
96
119
  /** Build the redacted rich console shard from indexed metadata and derived caches. */
97
120
  export declare function buildIndexShard(rows: SyncRow[], device: string, owner: string, prevShard?: TracesIndexShard | null): TracesIndexShard;
98
121
  /** Per-session drill-down detail the console consumes (sessions/<id>.json). */
@@ -116,6 +139,16 @@ export interface SessionDetail {
116
139
  /** Steps dropped from `steps` when a run was too long — surfaced so truncation is never silent. */
117
140
  truncatedSteps: number;
118
141
  whereItWentWrong: string | null;
142
+ /**
143
+ * Failed tool steps, surfaced even when `meta.outcome === 'completed'` — a run
144
+ * can finish without throwing while a tool call inside it failed (the
145
+ * LangSmith trap: a green run-level status hiding a real failure).
146
+ */
147
+ surfacedToolFailures: Array<{
148
+ tool?: string;
149
+ label: string;
150
+ detail?: string;
151
+ }>;
119
152
  }
120
153
  /**
121
154
  * Map the derived trajectory to the console's SessionDetail shape, stripping
@@ -30,6 +30,7 @@ import { knownSecretValuesFromEnv, redactSecrets } from '../redact.js';
30
30
  import { getRuntimeStateDir } from '../state.js';
31
31
  import { resolveTracesBackend } from './backend.js';
32
32
  import { classifyCause, classifyTopic, computeDriftSignal, } from './classify.js';
33
+ import { computeInsights } from './insights.js';
33
34
  /** Push derived, redacted trajectories for this device to the traces store. */
34
35
  export async function syncTraces(opts = {}) {
35
36
  const dryRun = opts.dryRun === true;
@@ -210,7 +211,8 @@ function percentile(values, ratio) {
210
211
  const sorted = [...values].sort((a, b) => a - b);
211
212
  return sorted[Math.min(sorted.length - 1, Math.ceil(sorted.length * ratio) - 1)];
212
213
  }
213
- function failureDescription(call, cause) {
214
+ /** Human description of a failed call, keyed by (tool, desc, cause) for grouping. Exported for computeInsights(). */
215
+ export function failureDescription(call, cause) {
214
216
  if (cause === 'guard')
215
217
  return /main-branch-guard/i.test(`${call.error_code ?? ''} ${call.error ?? ''}`)
216
218
  ? 'main branch guard' : 'git guard';
@@ -254,7 +256,7 @@ export function buildIndexShard(rows, device, owner, prevShard) {
254
256
  for (let i = 0; i < ids.length; i += 400) {
255
257
  const chunk = ids.slice(i, i + 400);
256
258
  calls.push(...db.prepare(`
257
- SELECT session_id, tool, outcome, exit_code, status_code, error_code, error, parse_error
259
+ SELECT session_id, ordinal, timestamp, tool, outcome, exit_code, status_code, error_code, error, parse_error
258
260
  FROM tool_calls
259
261
  WHERE session_id IN (${chunk.map(() => '?').join(',')})
260
262
  `).all(...chunk));
@@ -359,6 +361,7 @@ export function buildIndexShard(rows, device, owner, prevShard) {
359
361
  const prevHistory = prevShard?.bucketHistory ?? [];
360
362
  const bucketHistory = [...prevHistory, todayStats].slice(-14);
361
363
  const driftSignals = computeDriftSignal(prevHistory, todayStats);
364
+ const patternInsights = computeInsights(rows, calls, prevShard);
362
365
  return {
363
366
  schema: 1,
364
367
  device,
@@ -379,6 +382,9 @@ export function buildIndexShard(rows, device, owner, prevShard) {
379
382
  },
380
383
  bucketHistory,
381
384
  driftSignals,
385
+ failurePatterns: patternInsights.failurePatterns,
386
+ wastedMsTotal: patternInsights.wastedMsTotal,
387
+ latency: patternInsights.latency,
382
388
  };
383
389
  }
384
390
  /** Plain-language summary of the friction in a run, or null when it ran clean. */
@@ -426,6 +432,9 @@ export function buildSessionDetail(traj) {
426
432
  gaps: traj.gaps,
427
433
  truncatedSteps: traj.truncatedSteps,
428
434
  whereItWentWrong: buildWhereItWentWrong(traj),
435
+ surfacedToolFailures: traj.steps
436
+ .filter((step) => step.outcome === 'error')
437
+ .map((step) => ({ tool: step.tool, label: step.label, detail: step.detail })),
429
438
  };
430
439
  }
431
440
  async function getIndexShard(backend, device) {
@@ -820,7 +820,11 @@ export interface Meta {
820
820
  /** Preferred provider account per harness. Explicit --account wins. */
821
821
  accounts?: {
822
822
  defaults?: Partial<Record<AgentId, string>>;
823
- /** Named harness-owned identities. Metadata only; OAuth credentials stay in the harness home. */
823
+ /**
824
+ * Named harness-owned identities. Metadata only; OAuth credentials stay in
825
+ * the harness home. Central-synced via agents.yaml; labels bind to
826
+ * `(agent, identityKey)`.
827
+ */
824
828
  native?: Record<string, {
825
829
  id: string;
826
830
  name: string;
@@ -832,6 +836,26 @@ export interface Meta {
832
836
  /** Exact installation/custom-harness target -> stable account id. */
833
837
  bindings?: Record<string, string>;
834
838
  };
839
+ /**
840
+ * Device-scoped slice of {@link Meta.accounts}, written as `accounts:` in
841
+ * `~/.agents/devices/<machine>/agents.yaml` (PHNX-3315). Holds this box's own
842
+ * `scope: 'device'` native identities and the bindings that target them, so a
843
+ * per-box login no longer rewrites the fleet-shared central `agents.yaml` (and
844
+ * its identity PII no longer lands on that one file). The effective account
845
+ * view is the union of central (fleet-shared `defaults` + `scope:'version'`
846
+ * natives) and every device doc's block; only this machine writes this key.
847
+ */
848
+ deviceAccounts?: {
849
+ native?: Record<string, {
850
+ id: string;
851
+ name: string;
852
+ agent: AgentId;
853
+ identityKey: string;
854
+ identityLabel?: string;
855
+ scope: 'version' | 'device';
856
+ }>;
857
+ bindings?: Record<string, string>;
858
+ };
835
859
  agents?: Partial<Record<AgentId, string>>;
836
860
  /**
837
861
  * Per-agent preferred ISOLATED version — which copy a bare `agents run <agent>`
@@ -1012,6 +1036,15 @@ export interface Meta {
1012
1036
  * are never copied. `inline` hosts carry their own address/user.
1013
1037
  */
1014
1038
  hosts?: Record<string, HostEntry>;
1039
+ /**
1040
+ * Device-scoped agent-host registry, written as `hosts:` in
1041
+ * `~/.agents/devices/<machine>/agents.yaml` (PHNX-3315). Locally-discovered
1042
+ * SSH hosts and inline registrations land here so one box's enrollment no
1043
+ * longer rewrites the fleet-shared `hosts:` map (the source of pull conflicts).
1044
+ * The effective host view is the union across every device doc (plus any
1045
+ * lingering central legacy entries); only this machine writes this key.
1046
+ */
1047
+ deviceHosts?: Record<string, HostEntry>;
1015
1048
  /**
1016
1049
  * Declarative fleet profile (`agents apply` / `ag apply`). Additive to the
1017
1050
  * schema — project `agents:` version-pins are untouched. Declares which agents
@@ -1022,6 +1055,19 @@ export interface Meta {
1022
1055
  * Full shape in `lib/fleet/types.ts` (FleetManifest).
1023
1056
  */
1024
1057
  fleet?: import('./fleet/types.js').FleetManifest;
1058
+ /**
1059
+ * Device-scoped slice of {@link Meta.fleet}: THIS box's own discovery
1060
+ * decisions and dismissals, written as `fleet:` in
1061
+ * `~/.agents/devices/<machine>/agents.yaml` (PHNX-3315). Each box records only
1062
+ * its own choices here, so N boxes no longer rewrite one shared
1063
+ * `fleet.discovery`/`fleet.ignored` map (the guaranteed pull conflict). The
1064
+ * effective fleet view is computed as a UNION across every device doc (plus
1065
+ * lingering central legacy) at read time; only this machine writes this key.
1066
+ */
1067
+ deviceFleet?: {
1068
+ discovery?: Record<string, 'approved' | 'ignored'>;
1069
+ ignored?: import('./fleet/types.js').IgnoredDeviceEntry[];
1070
+ };
1025
1071
  /** Artifact share endpoint (Cloudflare R2 + Worker). Set by `agents artifacts
1026
1072
  * setup`/`join`; syncs fleet-wide via `agents repo push/pull`. The write token
1027
1073
  * lives in the `share` secrets bundle, not here. */
@@ -146,7 +146,13 @@ function planDelivery(session, chosenText, block, allowGhosttyFocus) {
146
146
  // silently dropping a nudge into a spool it will never read.
147
147
  if (route.kind === 'mailbox' && isOpenQuestionBlock(block))
148
148
  return { via: 'mailbox', mailboxId };
149
- return { via: 'refuse', reason: route.kind === 'refuse' ? route.reason : resolution.reason };
149
+ return {
150
+ via: 'refuse',
151
+ // answer-router's refuse reason already bakes in the recovery hint; the
152
+ // resolver's reason does not, so carry hint separately for the caller.
153
+ reason: route.kind === 'refuse' ? route.reason : resolution.reason,
154
+ hint: route.kind === 'refuse' ? undefined : resolution.hint,
155
+ };
150
156
  }
151
157
  /** Default open-block reader — the same lookup `agents message` uses. */
152
158
  function defaultOpenBlockFor(session) {
@@ -596,7 +602,9 @@ export async function runWatchdogTick(opts = {}) {
596
602
  flags[sid] = { reason: `rotate: ${resolution.reason}`, host: session.host, atMs: nowMs };
597
603
  outcomes.push({
598
604
  ...base, decision: 'skip', addressable: false,
599
- reason: `rate-limited but un-addressable — ${resolution.reason}`,
605
+ reason: resolution.hint
606
+ ? `rate-limited but un-addressable — ${resolution.reason} — ${resolution.hint}`
607
+ : `rate-limited but un-addressable — ${resolution.reason}`,
600
608
  });
601
609
  continue;
602
610
  }
@@ -737,7 +745,11 @@ export async function runWatchdogTick(opts = {}) {
737
745
  const machineHost = session.provenance?.host ?? 'unknown';
738
746
  const runtime = session.kind;
739
747
  try {
740
- const declaredBlock = buildDeclaredBlock({ sessionId: session.sessionId, mailboxId, host: machineHost, runtime, cwd: session.cwd }, { text: `Session genuinely needs Muqsit and is un-addressable — ${decision.reason}. Needs attention.` });
748
+ const declaredBlock = buildDeclaredBlock({ sessionId: session.sessionId, mailboxId, host: machineHost, runtime, cwd: session.cwd }, {
749
+ text: resolution.hint
750
+ ? `Session genuinely needs Muqsit and is un-addressable — ${decision.reason}. Needs attention. ${resolution.hint}`
751
+ : `Session genuinely needs Muqsit and is un-addressable — ${decision.reason}. Needs attention.`,
752
+ });
741
753
  publishBlockFn(declaredBlock);
742
754
  ledgerUpdates[session.sessionId] = nowMs;
743
755
  }
@@ -769,7 +781,9 @@ export async function runWatchdogTick(opts = {}) {
769
781
  flags[session.sessionId] = { reason: plan.reason, host: session.host, atMs: nowMs };
770
782
  outcomes.push({
771
783
  ...base, decision: 'skip', addressable: false,
772
- reason: `nudge-worthy but un-addressable — ${plan.reason}`,
784
+ reason: plan.hint
785
+ ? `nudge-worthy but un-addressable — ${plan.reason} — ${plan.hint}`
786
+ : `nudge-worthy but un-addressable — ${plan.reason}`,
773
787
  nudgeText: chosenText,
774
788
  });
775
789
  continue;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@phnx-labs/agents-cli",
3
- "version": "1.22.51",
3
+ "version": "1.22.53",
4
4
  "description": "One CLI for all your AI coding agents - versions, config, cloud dispatch, sessions, and teams (now with first-class Grok Build CLI support)",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",