@phnx-labs/agents-cli 1.22.60 → 1.22.62

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 (98) hide show
  1. package/CHANGELOG.md +53 -0
  2. package/README.md +6 -0
  3. package/dist/cli/command-registry.d.ts +1 -0
  4. package/dist/cli/command-registry.js +2 -0
  5. package/dist/commands/browser.js +9 -4
  6. package/dist/commands/doctor.js +1 -1
  7. package/dist/commands/exec.js +35 -1
  8. package/dist/commands/feed.js +3 -1
  9. package/dist/commands/harness-hooks.d.ts +55 -0
  10. package/dist/commands/harness-hooks.js +104 -0
  11. package/dist/commands/harness-wizard.d.ts +33 -14
  12. package/dist/commands/harness-wizard.js +53 -23
  13. package/dist/commands/harness.d.ts +14 -0
  14. package/dist/commands/harness.js +86 -5
  15. package/dist/commands/monitors.js +3 -3
  16. package/dist/commands/reminders.d.ts +9 -0
  17. package/dist/commands/reminders.js +49 -0
  18. package/dist/commands/run-account-picker.d.ts +14 -0
  19. package/dist/commands/run-account-picker.js +13 -0
  20. package/dist/commands/send.js +4 -1
  21. package/dist/commands/teams.d.ts +1 -1
  22. package/dist/commands/teams.js +9 -3
  23. package/dist/index.js +9 -0
  24. package/dist/lib/accounting/rotate.d.ts +63 -0
  25. package/dist/lib/accounting/rotate.js +229 -13
  26. package/dist/lib/browser/drivers/local.d.ts +11 -0
  27. package/dist/lib/browser/drivers/local.js +26 -0
  28. package/dist/lib/browser/profiles.js +8 -6
  29. package/dist/lib/browser/service.d.ts +12 -8
  30. package/dist/lib/browser/service.js +38 -10
  31. package/dist/lib/channels/owner-forward.d.ts +14 -8
  32. package/dist/lib/channels/owner-forward.js +9 -5
  33. package/dist/lib/channels/registry.d.ts +2 -0
  34. package/dist/lib/channels/send.js +13 -1
  35. package/dist/lib/claude-statusline.d.ts +14 -1
  36. package/dist/lib/claude-statusline.js +27 -2
  37. package/dist/lib/daemon/runner.js +17 -2
  38. package/dist/lib/devices/doctor-findings.d.ts +1 -1
  39. package/dist/lib/devices/doctor-findings.js +22 -4
  40. package/dist/lib/doctor-diff.d.ts +21 -5
  41. package/dist/lib/doctor-diff.js +242 -76
  42. package/dist/lib/feed/events.d.ts +1 -1
  43. package/dist/lib/feed/events.js +25 -16
  44. package/dist/lib/feed-broadcast.js +5 -12
  45. package/dist/lib/github/gh-overload.d.ts +58 -0
  46. package/dist/lib/github/gh-overload.js +246 -0
  47. package/dist/lib/github/rest.d.ts +64 -0
  48. package/dist/lib/github/rest.js +111 -0
  49. package/dist/lib/harness-connection-test.d.ts +57 -0
  50. package/dist/lib/harness-connection-test.js +80 -0
  51. package/dist/lib/heal.js +8 -3
  52. package/dist/lib/humans.d.ts +9 -0
  53. package/dist/lib/humans.js +29 -8
  54. package/dist/lib/installations/shims.d.ts +22 -0
  55. package/dist/lib/installations/shims.js +104 -0
  56. package/dist/lib/linear-project-counts.js +8 -0
  57. package/dist/lib/linear-rate-limit.d.ts +26 -0
  58. package/dist/lib/linear-rate-limit.js +163 -0
  59. package/dist/lib/mcp.d.ts +9 -0
  60. package/dist/lib/mcp.js +37 -1
  61. package/dist/lib/notify.d.ts +3 -0
  62. package/dist/lib/notify.js +63 -21
  63. package/dist/lib/open-url.js +5 -3
  64. package/dist/lib/permissions.d.ts +28 -0
  65. package/dist/lib/permissions.js +156 -1
  66. package/dist/lib/refresh.js +9 -1
  67. package/dist/lib/reminders.d.ts +29 -0
  68. package/dist/lib/reminders.js +88 -0
  69. package/dist/lib/resource-content-diff.d.ts +33 -0
  70. package/dist/lib/resource-content-diff.js +103 -0
  71. package/dist/lib/rules/compile.d.ts +7 -0
  72. package/dist/lib/rules/compile.js +7 -1
  73. package/dist/lib/session/active.d.ts +41 -4
  74. package/dist/lib/session/active.js +58 -7
  75. package/dist/lib/session/host-link.d.ts +22 -0
  76. package/dist/lib/session/host-link.js +40 -4
  77. package/dist/lib/session/trajectory.d.ts +42 -0
  78. package/dist/lib/session/trajectory.js +46 -27
  79. package/dist/lib/ssh-exec.d.ts +30 -0
  80. package/dist/lib/ssh-exec.js +37 -5
  81. package/dist/lib/startup/command-registry.js +1 -1
  82. package/dist/lib/subagents-registry.d.ts +18 -0
  83. package/dist/lib/subagents-registry.js +79 -0
  84. package/dist/lib/teams/agents.d.ts +12 -0
  85. package/dist/lib/teams/agents.js +51 -0
  86. package/dist/lib/traces/schema2-build.d.ts +85 -0
  87. package/dist/lib/traces/schema2-build.js +637 -0
  88. package/dist/lib/traces/schema2-danger.d.ts +36 -0
  89. package/dist/lib/traces/schema2-danger.js +185 -0
  90. package/dist/lib/traces/schema2.d.ts +149 -0
  91. package/dist/lib/traces/schema2.js +20 -0
  92. package/dist/lib/traces/sync.d.ts +93 -0
  93. package/dist/lib/traces/sync.js +75 -22
  94. package/dist/lib/traces/worker-template.js +5 -0
  95. package/dist/lib/uninstall.js +10 -1
  96. package/dist/lib/workflows.d.ts +11 -0
  97. package/dist/lib/workflows.js +67 -8
  98. package/package.json +1 -1
@@ -80,3 +80,25 @@ export interface HostLinkInput {
80
80
  * or a running editor.
81
81
  */
82
82
  export declare function classifyHostLink(input: HostLinkInput): HostLink;
83
+ /**
84
+ * Did the owning IDE window die while the agent kept RUNNING? This is the ONE
85
+ * host-link loss that promotes a still-`running` session to `orphaned`
86
+ * (PHNX-3183): a window that WAS republishing its heartbeat every 4 min went
87
+ * stale for {@link HOST_HEARTBEAT_STALE_MS}, so it died uncleanly (a crash,
88
+ * reboot, or dropped SSH) and the agent it hosted outlived it — genuinely
89
+ * stranded, the "my remote agent is still alive after the laptop rebooted" case.
90
+ *
91
+ * Deliberately NARROWER than `no-client`. A tmux attached-client count of zero
92
+ * is also `no-client`, but for a detached remote pane (`agents run --device`,
93
+ * which RUSH-3125 wraps in a detached tmux session) that is the NORMAL steady
94
+ * state between check-ins — launch, detach, return with `agents focus` — not a
95
+ * loss. Promoting on it would relabel every unattended remote agent as
96
+ * `orphaned`, the over-reporting that makes the word worthless. Only a lost
97
+ * WINDOW is a positive "a client was expected and is now gone" for a running
98
+ * agent; mere absence is not.
99
+ *
100
+ * A deliberately-backgrounded session is excluded exactly as
101
+ * {@link classifyHostLink} excludes it: no client is the point of detaching.
102
+ * Pure — same injected signals as the classifier.
103
+ */
104
+ export declare function hostWindowLost(input: HostLinkInput): boolean;
@@ -33,22 +33,32 @@
33
33
  * the extension agree on when a window is dead.
34
34
  */
35
35
  export const HOST_HEARTBEAT_STALE_MS = 10 * 60_000;
36
+ /**
37
+ * Did the session's owning IDE window stop republishing its registry slice? A
38
+ * window republishes every 4 minutes (`HOST_HEARTBEAT_STALE_MS` is 10), so a
39
+ * slice this old means the window is gone, not merely quiet. Shared by
40
+ * {@link classifyHostLink} and {@link hostWindowLost} so the staleness rule has
41
+ * exactly one definition.
42
+ */
43
+ function windowGone(input) {
44
+ const now = input.nowMs ?? Date.now();
45
+ return input.windowHeartbeatMs !== undefined && now - input.windowHeartbeatMs >= HOST_HEARTBEAT_STALE_MS;
46
+ }
36
47
  /**
37
48
  * Classify a live row's host link. Pure — every signal is passed in, so the
38
49
  * whole decision table is unit-testable without a process table, a tmux server,
39
50
  * or a running editor.
40
51
  */
41
52
  export function classifyHostLink(input) {
42
- const now = input.nowMs ?? Date.now();
43
53
  // A session detached on purpose has no client BY DESIGN. It is the one case
44
54
  // that looks identical to an orphan from the outside, so it is excluded first —
45
55
  // otherwise every `agents sessions detach` would raise a false alarm.
46
56
  if (input.deliberatelyDetached)
47
57
  return 'connected';
48
- const windowGone = input.windowHeartbeatMs !== undefined && now - input.windowHeartbeatMs >= HOST_HEARTBEAT_STALE_MS;
58
+ const stale = windowGone(input);
49
59
  // The host window stopped keeping its registry slice alive AND the agent it
50
60
  // owned is dead: the pair went down together without teardown.
51
- if (windowGone && !input.pidAlive)
61
+ if (stale && !input.pidAlive)
52
62
  return 'host-gone';
53
63
  if (!input.pidAlive)
54
64
  return 'connected'; // a plain dead pid is `closed`, not an orphan
@@ -58,7 +68,7 @@ export function classifyHostLink(input) {
58
68
  return 'no-client';
59
69
  // Alive, not tmux-hosted (or tmux says someone is attached), but the window
60
70
  // that owned it is gone. The agent outlived its editor.
61
- if (windowGone)
71
+ if (stale)
62
72
  return 'no-client';
63
73
  // Positive evidence of a client: tmux counted at least one attached client, or
64
74
  // a window is refreshing its registry slice. Either is a real observation.
@@ -68,3 +78,29 @@ export function classifyHostLink(input) {
68
78
  // reporting the healthy answer by default — see {@link HostLink}'s `unknown`.
69
79
  return 'unknown';
70
80
  }
81
+ /**
82
+ * Did the owning IDE window die while the agent kept RUNNING? This is the ONE
83
+ * host-link loss that promotes a still-`running` session to `orphaned`
84
+ * (PHNX-3183): a window that WAS republishing its heartbeat every 4 min went
85
+ * stale for {@link HOST_HEARTBEAT_STALE_MS}, so it died uncleanly (a crash,
86
+ * reboot, or dropped SSH) and the agent it hosted outlived it — genuinely
87
+ * stranded, the "my remote agent is still alive after the laptop rebooted" case.
88
+ *
89
+ * Deliberately NARROWER than `no-client`. A tmux attached-client count of zero
90
+ * is also `no-client`, but for a detached remote pane (`agents run --device`,
91
+ * which RUSH-3125 wraps in a detached tmux session) that is the NORMAL steady
92
+ * state between check-ins — launch, detach, return with `agents focus` — not a
93
+ * loss. Promoting on it would relabel every unattended remote agent as
94
+ * `orphaned`, the over-reporting that makes the word worthless. Only a lost
95
+ * WINDOW is a positive "a client was expected and is now gone" for a running
96
+ * agent; mere absence is not.
97
+ *
98
+ * A deliberately-backgrounded session is excluded exactly as
99
+ * {@link classifyHostLink} excludes it: no client is the point of detaching.
100
+ * Pure — same injected signals as the classifier.
101
+ */
102
+ export function hostWindowLost(input) {
103
+ if (input.deliberatelyDetached)
104
+ return false;
105
+ return input.pidAlive && windowGone(input);
106
+ }
@@ -90,6 +90,48 @@ export interface BuildTrajectoryOptions {
90
90
  /** Cap on drawn steps; the tail is dropped and counted. Default 5000. */
91
91
  maxSteps?: number;
92
92
  }
93
+ /**
94
+ * The effective program a shell command ran, via the shared shell parser
95
+ * (`extractShellPrograms` — which unwraps the wrappers it knows: `env`, `sudo`,
96
+ * `agents ssh`; note `timeout` is deliberately NOT a wrapper there, per SES-37,
97
+ * so `timeout 30 npm test` resolves to `timeout`). On top of that this skips
98
+ * bare builtins/assignments (`cd`, `export`, …) when a real program follows. For
99
+ * a pipeline it takes the LEFTMOST effective program (`cat f | grep x` → `cat`),
100
+ * matching how the parser orders occurrences. Undefined when nothing static is
101
+ * identifiable. Never executes anything.
102
+ */
103
+ export declare function effectiveProgram(command: string | undefined): string | undefined;
104
+ /**
105
+ * One paired step: the drawn {@link TrajectoryStep}, the index of its originating
106
+ * `tool_use`/`thinking` event, and — once pairing completes — the index of the
107
+ * `tool_result`/`error` event that answered it. `buildSessionDetailV2` reads these
108
+ * triples (step, useEvent, resultEvent) to populate the schema-2 per-tool shapes
109
+ * WITHOUT re-running the callId pairing loop.
110
+ */
111
+ export interface StepDraft {
112
+ step: TrajectoryStep;
113
+ eventIndex: number;
114
+ callId?: string;
115
+ resultEventIndex?: number;
116
+ }
117
+ /** Absolute ms per event index (NaN when the timestamp is unparseable). */
118
+ export declare function eventTimestampsMs(events: SessionEvent[]): number[];
119
+ /**
120
+ * Draw a step for each thinking block and each non-local tool_use, in order, and
121
+ * pair each `tool_use` with its `tool_result`/`error` on `callId` (FIFO within a
122
+ * reused id — never across ids, and never by arrival order for concurrent calls,
123
+ * matching `ToolCallCollector.takePending`'s refusal to guess, `tool-calls.ts:509`).
124
+ *
125
+ * `_local` tool calls (Claude `!`-prefixed shell) are excluded to match the
126
+ * header's tool count (`computeSummaryStats` skips them, `render.ts:224`).
127
+ *
128
+ * The single source of the pairing: both {@link buildTrajectory} and
129
+ * `buildSessionDetailV2` consume the returned drafts (each draft's `eventIndex` +
130
+ * `resultEventIndex` are the use/result event indices) so the schema-2 producer
131
+ * never re-implements the loop. Durations/outcomes are still resolved by the
132
+ * caller — this only draws and pairs.
133
+ */
134
+ export declare function pairSteps(events: SessionEvent[], eventMs: number[], firstTs: number, redact: boolean, knownSecrets: readonly string[] | undefined): StepDraft[];
93
135
  /**
94
136
  * Build the derived trajectory for one session's normalized events.
95
137
  *
@@ -45,7 +45,7 @@ const SHELL_NOISE_PROGRAMS = new Set(['export', 'cd', 'set', 'source', '.', 'uns
45
45
  * matching how the parser orders occurrences. Undefined when nothing static is
46
46
  * identifiable. Never executes anything.
47
47
  */
48
- function effectiveProgram(command) {
48
+ export function effectiveProgram(command) {
49
49
  if (!command)
50
50
  return undefined;
51
51
  const { occurrences, programs } = extractShellPrograms(command);
@@ -174,36 +174,26 @@ function resultDetail(event, redact, knownSecrets) {
174
174
  const clipped = clip(text, DETAIL_MAX);
175
175
  return redact ? redactSecrets(clipped, knownSecrets) : clipped;
176
176
  }
177
+ /** Absolute ms per event index (NaN when the timestamp is unparseable). */
178
+ export function eventTimestampsMs(events) {
179
+ return events.map((e) => toMs(e.timestamp));
180
+ }
177
181
  /**
178
- * Build the derived trajectory for one session's normalized events.
179
- *
180
- * Pairs each `tool_use` with its `tool_result`/`error` on `callId` (FIFO within a
182
+ * Draw a step for each thinking block and each non-local tool_use, in order, and
183
+ * pair each `tool_use` with its `tool_result`/`error` on `callId` (FIFO within a
181
184
  * reused id — never across ids, and never by arrival order for concurrent calls,
182
185
  * matching `ToolCallCollector.takePending`'s refusal to guess, `tool-calls.ts:509`).
183
- * A harness with no parseable transcript (OpenClaw, `parse.ts:186`) yields an empty
184
- * event array and therefore an empty trajectory — never a crash or a fabricated one.
186
+ *
187
+ * `_local` tool calls (Claude `!`-prefixed shell) are excluded to match the
188
+ * header's tool count (`computeSummaryStats` skips them, `render.ts:224`).
189
+ *
190
+ * The single source of the pairing: both {@link buildTrajectory} and
191
+ * `buildSessionDetailV2` consume the returned drafts (each draft's `eventIndex` +
192
+ * `resultEventIndex` are the use/result event indices) so the schema-2 producer
193
+ * never re-implements the loop. Durations/outcomes are still resolved by the
194
+ * caller — this only draws and pairs.
185
195
  */
186
- export function buildTrajectory(events, meta, options = {}) {
187
- const redact = options.redact !== false;
188
- const knownSecrets = options.knownSecrets;
189
- const idleThreshold = options.idleThresholdMs ?? DEFAULT_IDLE_THRESHOLD_MS;
190
- const maxSteps = options.maxSteps ?? DEFAULT_MAX_STEPS;
191
- const stats = computeSummaryStats(events);
192
- const firstTs = stats.firstTs;
193
- const spanMs = stats.lastTs > stats.firstTs ? stats.lastTs - stats.firstTs : 0;
194
- // Absolute ms per event index (NaN when the timestamp is unparseable).
195
- const eventMs = events.map((e) => toMs(e.timestamp));
196
- // The next event index (after i) that carries a valid timestamp — the anchor
197
- // for the next-event duration fallback and for idle-gap detection.
198
- const nextValidTs = new Array(events.length).fill(NaN);
199
- for (let i = events.length - 1, later = NaN; i >= 0; i--) {
200
- nextValidTs[i] = later;
201
- if (!Number.isNaN(eventMs[i]))
202
- later = eventMs[i];
203
- }
204
- // Draw a step for each thinking block and each non-local tool_use, in order.
205
- // `_local` tool calls (Claude `!`-prefixed shell) are excluded to match the
206
- // header's tool count (`computeSummaryStats` skips them, `render.ts:224`).
196
+ export function pairSteps(events, eventMs, firstTs, redact, knownSecrets) {
207
197
  const drafts = [];
208
198
  const pendingByCallId = new Map();
209
199
  for (let i = 0; i < events.length; i++) {
@@ -261,6 +251,35 @@ export function buildTrajectory(events, meta, options = {}) {
261
251
  }
262
252
  }
263
253
  }
254
+ return drafts;
255
+ }
256
+ /**
257
+ * Build the derived trajectory for one session's normalized events.
258
+ *
259
+ * Pairs each `tool_use` with its `tool_result`/`error` on `callId` (FIFO within a
260
+ * reused id — never across ids, and never by arrival order for concurrent calls,
261
+ * matching `ToolCallCollector.takePending`'s refusal to guess, `tool-calls.ts:509`).
262
+ * A harness with no parseable transcript (OpenClaw, `parse.ts:186`) yields an empty
263
+ * event array and therefore an empty trajectory — never a crash or a fabricated one.
264
+ */
265
+ export function buildTrajectory(events, meta, options = {}) {
266
+ const redact = options.redact !== false;
267
+ const knownSecrets = options.knownSecrets;
268
+ const idleThreshold = options.idleThresholdMs ?? DEFAULT_IDLE_THRESHOLD_MS;
269
+ const maxSteps = options.maxSteps ?? DEFAULT_MAX_STEPS;
270
+ const stats = computeSummaryStats(events);
271
+ const firstTs = stats.firstTs;
272
+ const spanMs = stats.lastTs > stats.firstTs ? stats.lastTs - stats.firstTs : 0;
273
+ const eventMs = eventTimestampsMs(events);
274
+ // The next event index (after i) that carries a valid timestamp — the anchor
275
+ // for the next-event duration fallback and for idle-gap detection.
276
+ const nextValidTs = new Array(events.length).fill(NaN);
277
+ for (let i = events.length - 1, later = NaN; i >= 0; i--) {
278
+ nextValidTs[i] = later;
279
+ if (!Number.isNaN(eventMs[i]))
280
+ later = eventMs[i];
281
+ }
282
+ const drafts = pairSteps(events, eventMs, firstTs, redact, knownSecrets);
264
283
  // Resolve durations, outcomes, and detail now that pairing is complete.
265
284
  for (const draft of drafts) {
266
285
  const { step } = draft;
@@ -61,6 +61,36 @@ export declare class RemoteUtf8Accumulator {
61
61
  end(): string;
62
62
  current(): string;
63
63
  }
64
+ /**
65
+ * How long OpenSSH keeps a multiplex master alive after its last client exits
66
+ * (`ControlPersist`, in seconds).
67
+ *
68
+ * The master only survives while it stays IDLE under this window, so it helps
69
+ * exactly a *repeated* touch of the same host that arrives within the window; a
70
+ * touch that arrives after the master has expired pays the full cold TCP+auth
71
+ * handshake again (~100-300ms direct, up to ~500ms relayed; measured on the live
72
+ * fleet 2026-08-10, PHNX-2582).
73
+ *
74
+ * The repeated same-host touches are the ad-hoc `--device` and fleet fan-out
75
+ * calls — `sessions --active`, `fleet ping`/`status`, `doctor`, `teams`, a
76
+ * `--device <box>` command run a few times while working — which arrive in
77
+ * BURSTS spread over minutes, not on a fixed clock. (The one truly high-frequency
78
+ * caller, `followHostTask`'s ~1.5s follow poll, was already warm even at 60s and
79
+ * is not the target here.) At the old 60s window any two touches of the same box
80
+ * more than a minute apart were both cold, so a burst paid a fresh handshake
81
+ * almost every time.
82
+ *
83
+ * 10 minutes keeps a multi-minute burst warm (3-5ms per reuse) while still
84
+ * bounding how long an idle master lingers — an important limit, because a reused
85
+ * master to a box that has since slept costs a ~45s ServerAlive teardown on the
86
+ * first touch (`ServerAliveInterval=15 × ServerAliveCountMax=3`), and a wider
87
+ * window would only widen the chance of hitting that. The daemon's periodic
88
+ * SSH-touching services (`usage-sync`/`auth-sync`) tick every 15 minutes, longer
89
+ * than this window ON PURPOSE: warming them would need a wider window for a
90
+ * handshake that is negligible at that cadence. Fan-outs stay bounded regardless
91
+ * by their own per-peer `timeoutMs`.
92
+ */
93
+ export declare const SSH_CONTROL_PERSIST_SECONDS: number;
64
94
  export declare function controlOpts(): string[];
65
95
  /**
66
96
  * Compose an ssh connection-option prefix.
@@ -87,20 +87,52 @@ export class RemoteUtf8Accumulator {
87
87
  return this.value;
88
88
  }
89
89
  }
90
+ /**
91
+ * How long OpenSSH keeps a multiplex master alive after its last client exits
92
+ * (`ControlPersist`, in seconds).
93
+ *
94
+ * The master only survives while it stays IDLE under this window, so it helps
95
+ * exactly a *repeated* touch of the same host that arrives within the window; a
96
+ * touch that arrives after the master has expired pays the full cold TCP+auth
97
+ * handshake again (~100-300ms direct, up to ~500ms relayed; measured on the live
98
+ * fleet 2026-08-10, PHNX-2582).
99
+ *
100
+ * The repeated same-host touches are the ad-hoc `--device` and fleet fan-out
101
+ * calls — `sessions --active`, `fleet ping`/`status`, `doctor`, `teams`, a
102
+ * `--device <box>` command run a few times while working — which arrive in
103
+ * BURSTS spread over minutes, not on a fixed clock. (The one truly high-frequency
104
+ * caller, `followHostTask`'s ~1.5s follow poll, was already warm even at 60s and
105
+ * is not the target here.) At the old 60s window any two touches of the same box
106
+ * more than a minute apart were both cold, so a burst paid a fresh handshake
107
+ * almost every time.
108
+ *
109
+ * 10 minutes keeps a multi-minute burst warm (3-5ms per reuse) while still
110
+ * bounding how long an idle master lingers — an important limit, because a reused
111
+ * master to a box that has since slept costs a ~45s ServerAlive teardown on the
112
+ * first touch (`ServerAliveInterval=15 × ServerAliveCountMax=3`), and a wider
113
+ * window would only widen the chance of hitting that. The daemon's periodic
114
+ * SSH-touching services (`usage-sync`/`auth-sync`) tick every 15 minutes, longer
115
+ * than this window ON PURPOSE: warming them would need a wider window for a
116
+ * handshake that is negligible at that cadence. Fan-outs stay bounded regardless
117
+ * by their own per-peer `timeoutMs`.
118
+ */
119
+ export const SSH_CONTROL_PERSIST_SECONDS = 10 * 60;
90
120
  /**
91
121
  * OpenSSH connection-multiplexing options. The first connection to a host opens
92
122
  * a control socket; subsequent connections (even from a *separate* `agents`
93
123
  * invocation) reuse it, skipping the TCP+auth handshake — so repeated
94
124
  * `--device <name>` calls to the same box feel local instead of paying ~100-300ms
95
- * each. `ControlPersist=60s` keeps the master alive briefly after the last
96
- * client exits. `%C` (a short fixed-length hash of local-host/remote/port/user)
97
- * keeps the socket path well under macOS's 104-char `sun_path` limit.
125
+ * each. `ControlPersist` keeps the master alive after the last client exits, sized
126
+ * to span a multi-minute burst of repeated touches — see
127
+ * {@link SSH_CONTROL_PERSIST_SECONDS} for the value and the rationale. `%C` (a
128
+ * short fixed-length hash of local-host/remote/port/user) keeps the socket path
129
+ * well under macOS's 104-char `sun_path` limit.
98
130
  *
99
131
  * This is **on by default** for every `sshExec`/`sshStream` call: the poll loops
100
132
  * (`followHostTask`), readiness probes, and per-host fan-outs are exactly the
101
133
  * high-frequency callers that benefit most from socket reuse, and they should
102
134
  * never have to remember to opt in. A caller passes `multiplex: false` only for
103
- * a genuine one-shot where a lingering 60s master is pure overhead.
135
+ * a genuine one-shot where a lingering master is pure overhead.
104
136
  *
105
137
  * The socket directory is created lazily; if ssh can't open the control socket
106
138
  * it falls back to a normal connection (multiplexing is an optimisation, never a
@@ -126,7 +158,7 @@ export function controlOpts() {
126
158
  return [
127
159
  '-o', 'ControlMaster=auto',
128
160
  '-o', `ControlPath=${path.join(dir, 'cm-%C')}`,
129
- '-o', 'ControlPersist=60s',
161
+ '-o', `ControlPersist=${SSH_CONTROL_PERSIST_SECONDS}s`,
130
162
  ];
131
163
  }
132
164
  /**
@@ -5,7 +5,7 @@ const LOADED_COMMAND_NAMES = [
5
5
  'routines', 'monitors', 'projects', 'run', 'open', 'reconnect', 'fork', 'config',
6
6
  'models', 'modes', 'trash', 'restore', 'doctor',
7
7
  'route', 'harness', 'harnesses', 'secrets', 'menubar', 'sync',
8
- 'refresh-rules', 'factory', 'insights', 'trace',
8
+ 'refresh-rules', 'factory', 'insights', 'trace', 'reminders',
9
9
  'pty', 'tmux', 'watchdog', 'browser', 'computer', 'logs', 'events',
10
10
  'ssh', 'devices', 'fleet', 'repos', 'repo', 'setup', 'uninstall', 'upgrade', 'sessions',
11
11
  'teams', 'cloud', 'message', 'send', 'notify', 'feed',
@@ -29,6 +29,17 @@ export interface SubagentTarget {
29
29
  occupied(dir: string, name: string): OccupiedEntry[];
30
30
  /** Rich metadata for `name`; `null` skips it from the listing. */
31
31
  read(dir: string, name: string): SubagentMeta | null;
32
+ /**
33
+ * True when the installed subagent `sub` in `dir` byte-matches what `write`
34
+ * would materialize from `sub.path` NOW — the content-drift check `agents
35
+ * doctor` uses. Re-renders the CURRENT source through the same transform the
36
+ * writer uses (never a stored hash), so a prompt-body edit to the source
37
+ * surfaces as drift even though the filename is unchanged.
38
+ */
39
+ matches(dir: string, sub: {
40
+ name: string;
41
+ path: string;
42
+ }): boolean;
32
43
  }
33
44
  /**
34
45
  * Copy every file in `src` into `dest` (created if missing), applying
@@ -55,6 +66,13 @@ export declare function writeSubagentToHome(agent: AgentId, home: string, sub: {
55
66
  }): boolean;
56
67
  /** Installed subagent names for `agent` under `home` (detector + orphan diff). */
57
68
  export declare function listInstalledSubagentNames(agent: AgentId, home: string): string[];
69
+ /**
70
+ * True when subagent `name` installed for `agent` under `home` byte-matches what
71
+ * the writer would produce NOW from `sourceDir` — the content-drift predicate
72
+ * `agents doctor` uses. Returns false when the agent has no registry entry
73
+ * (nothing could have been written) so an unexpected home copy reads as drift.
74
+ */
75
+ export declare function subagentContentMatches(agent: AgentId, home: string, name: string, sourceDir: string): boolean;
58
76
  /**
59
77
  * Rich listing of subagents installed for `agent` under `home`, with parsed
60
78
  * metadata. Enumerates names, then reads each -- entries whose metadata is
@@ -34,7 +34,17 @@ import * as path from 'path';
34
34
  import * as TOML from 'smol-toml';
35
35
  import * as yaml from 'yaml';
36
36
  import { safeJoin } from './paths.js';
37
+ import { filesContentMatch, normalizeResourceContent } from './resource-content-diff.js';
37
38
  import { parseSubagentFrontmatter, transformSubagentForClaude, transformSubagentForCodex, transformSubagentForCopilot, transformSubagentForCursor, transformSubagentForDroid, transformSubagentForGoose, transformSubagentForKiro, transformSubagentForOpenCode, transformSubagentForAntigravity, } from './subagents.js';
39
+ /** Read a file's UTF-8 content, or null when it is missing/unreadable. */
40
+ function readFileSafe(filePath) {
41
+ try {
42
+ return fs.readFileSync(filePath, 'utf-8');
43
+ }
44
+ catch {
45
+ return null;
46
+ }
47
+ }
38
48
  // ── metadata readers (the per-format escape hatch) ───────────────────────────
39
49
  /** Frontmatter, skipping files that lack a valid block (claude/grok/droid). */
40
50
  function metaFrontmatterSkip(filePath) {
@@ -130,6 +140,13 @@ function flatFile(opts) {
130
140
  return null;
131
141
  return { frontmatter, files: [`${name}${opts.ext}`], path: filePath };
132
142
  },
143
+ matches(dir, sub) {
144
+ const filePath = path.join(dir, `${sub.name}${opts.ext}`);
145
+ const installed = readFileSafe(filePath);
146
+ if (installed == null)
147
+ return false;
148
+ return normalizeResourceContent(installed) === normalizeResourceContent(opts.transform(sub.path));
149
+ },
133
150
  };
134
151
  }
135
152
  /** A `<name>/` directory holding one generated `<file>` per subagent. */
@@ -162,6 +179,13 @@ function dirFile(opts) {
162
179
  return null;
163
180
  return { frontmatter, files: [opts.file], path: filePath };
164
181
  },
182
+ matches(dir, sub) {
183
+ const filePath = path.join(dir, sub.name, opts.file);
184
+ const installed = readFileSafe(filePath);
185
+ if (installed == null)
186
+ return false;
187
+ return normalizeResourceContent(installed) === normalizeResourceContent(opts.transform(sub.path));
188
+ },
165
189
  };
166
190
  }
167
191
  /** Copy the whole source directory to `<name>/`, detected by `marker`. */
@@ -204,6 +228,49 @@ function dirCopy(opts) {
204
228
  .sort();
205
229
  return { frontmatter, files, path: subagentDir };
206
230
  },
231
+ matches(dir, sub) {
232
+ // dirCopy materializes every source file (with rename) into <dir>/<name>/.
233
+ // Re-derive the expected file set from source and byte-compare each, so an
234
+ // edit to any copied file — or an added/removed source file — is drift.
235
+ const dest = path.join(dir, sub.name);
236
+ let sourceFiles;
237
+ try {
238
+ sourceFiles = fs.readdirSync(sub.path).filter((f) => {
239
+ try {
240
+ return fs.statSync(path.join(sub.path, f)).isFile();
241
+ }
242
+ catch {
243
+ return false;
244
+ }
245
+ });
246
+ }
247
+ catch {
248
+ return false;
249
+ }
250
+ const expectedDestNames = new Set();
251
+ for (const file of sourceFiles) {
252
+ const destName = opts.rename?.[file] ?? file;
253
+ expectedDestNames.add(destName);
254
+ if (!filesContentMatch(path.join(sub.path, file), path.join(dest, destName)))
255
+ return false;
256
+ }
257
+ // An extra file left in the installed dir (source file removed) is drift.
258
+ let destFiles;
259
+ try {
260
+ destFiles = fs.readdirSync(dest).filter((f) => {
261
+ try {
262
+ return fs.statSync(path.join(dest, f)).isFile();
263
+ }
264
+ catch {
265
+ return false;
266
+ }
267
+ });
268
+ }
269
+ catch {
270
+ return false;
271
+ }
272
+ return destFiles.every((f) => expectedDestNames.has(f));
273
+ },
207
274
  };
208
275
  }
209
276
  // ── the registry ─────────────────────────────────────────────────────────────
@@ -295,6 +362,18 @@ export function listInstalledSubagentNames(agent, home) {
295
362
  return [];
296
363
  return target.names(target.dir(home));
297
364
  }
365
+ /**
366
+ * True when subagent `name` installed for `agent` under `home` byte-matches what
367
+ * the writer would produce NOW from `sourceDir` — the content-drift predicate
368
+ * `agents doctor` uses. Returns false when the agent has no registry entry
369
+ * (nothing could have been written) so an unexpected home copy reads as drift.
370
+ */
371
+ export function subagentContentMatches(agent, home, name, sourceDir) {
372
+ const target = SUBAGENT_TARGETS[agent];
373
+ if (!target)
374
+ return false;
375
+ return target.matches(target.dir(home), { name, path: sourceDir });
376
+ }
298
377
  /**
299
378
  * Rich listing of subagents installed for `agent` under `home`, with parsed
300
379
  * metadata. Enumerates names, then reads each -- entries whose metadata is
@@ -101,6 +101,18 @@ export { captureProcessStartTime };
101
101
  * model_reasoning_effort override). Mode (plan/edit/full) is a separate knob.
102
102
  */
103
103
  export type EffortLevel = 'low' | 'medium' | 'high' | 'xhigh' | 'max' | 'auto';
104
+ /**
105
+ * Append {@link TEAMMATE_PR_POLICY} to a teammate prompt for every WRITE-capable
106
+ * mode (all but plan, which is read-only and opens no PR). Exported so the CLOUD
107
+ * dispatch path (`cloudDispatchOptions` in `commands/teams.ts`) applies the SAME
108
+ * boundary this file's `buildRunArgv` applies to LOCAL and REMOTE teammates. A
109
+ * cloud teammate is the case that needs it MOST: it runs in the provider's
110
+ * sandbox, not the shared local version home, so it never inherits the
111
+ * `merge-guard.sh` PreToolUse hook — the prompt policy is then its ONLY
112
+ * self-merge layer. Routing every dispatch surface through one helper keeps that
113
+ * parity from drifting (PHNX-3236).
114
+ */
115
+ export declare function withTeammatePrPolicy(prompt: string, mode: string): string;
104
116
  export declare const VALID_MODES: readonly ["plan", "edit", "auto", "skip", "full"];
105
117
  type Mode = 'plan' | 'edit' | 'auto' | 'skip';
106
118
  /** Resolve a mode string to a validated Mode, falling back to the given default. */
@@ -255,6 +255,49 @@ When you're done, provide a brief summary of:
255
255
  const CLAUDE_PLAN_MODE_PREFIX = `You are running in HEADLESS PLAN MODE. This mode works like normal plan mode with one exception: you cannot write to ~/.claude/plans/ directory. Instead of writing a plan file, output your complete plan/response as your final message.
256
256
 
257
257
  `;
258
+ // PHNX-3236: the teammate self-merge boundary, injected as a DISPATCH DEFAULT.
259
+ // A write-capable teammate has `gh pr merge` and authenticates as the repo owner,
260
+ // so it can merge its OWN PR past the required non-author-review gate — which is
261
+ // exactly what happened in the RUSH-2988 wave-1 dispatch (PR #1817, #1820). The
262
+ // root cause was that the boundary lived in per-brief wording: one teammate in the
263
+ // batch was told "open the PR, don't merge" and held off; the others weren't and
264
+ // self-merged. Making it a default the runner appends to every non-plan teammate
265
+ // gives one HARNESS-INDEPENDENT layer instead of relying on each dispatch prompt
266
+ // remembering to say it. The HARD enforcement is merge-guard.sh — a PreToolUse hook
267
+ // the teammate inherits from the shared version home, whose self-authored-verdict
268
+ // exclusion was closed in the same ticket (.agents-system #395) so a verdict a
269
+ // teammate posts on its own PR no longer clears the gate. The two layers do NOT
270
+ // overlap everywhere: hook-capable local/remote teammates get both, but cloud
271
+ // teammates (provider sandbox, no inherited hook) and hook-incapable harnesses
272
+ // (Warp/oz — no hook surface, no allowlist) get ONLY this prompt, so for them it is
273
+ // a soft control. That residual is documented in cli/AGENTS.md §6; server-side
274
+ // branch protection is the client-independent way to close it. This is the
275
+ // harness-independent layer plus the operator hand-off contract, not a replacement
276
+ // for the hard block where the hard block can run.
277
+ const TEAMMATE_PR_POLICY = `
278
+
279
+ Teammate PR policy (agents teams): when your work opens a pull request, open it and
280
+ hand it off — do NOT merge your OWN PR unless a NON-AUTHOR review verdict has been
281
+ posted on that same PR. You authenticate as the repo owner and share that one
282
+ GitHub identity with every other teammate, so an APPROVE you post on your own PR
283
+ does not count as a non-author review. \`gh pr merge\` on your own PR is blocked by
284
+ merge-guard until a genuine non-author verdict exists on it; never pass --admin or
285
+ otherwise route around that guard. Report the PR as open and let the orchestrator
286
+ or a separate reviewer take it to merge.`;
287
+ /**
288
+ * Append {@link TEAMMATE_PR_POLICY} to a teammate prompt for every WRITE-capable
289
+ * mode (all but plan, which is read-only and opens no PR). Exported so the CLOUD
290
+ * dispatch path (`cloudDispatchOptions` in `commands/teams.ts`) applies the SAME
291
+ * boundary this file's `buildRunArgv` applies to LOCAL and REMOTE teammates. A
292
+ * cloud teammate is the case that needs it MOST: it runs in the provider's
293
+ * sandbox, not the shared local version home, so it never inherits the
294
+ * `merge-guard.sh` PreToolUse hook — the prompt policy is then its ONLY
295
+ * self-merge layer. Routing every dispatch surface through one helper keeps that
296
+ * parity from drifting (PHNX-3236).
297
+ */
298
+ export function withTeammatePrPolicy(prompt, mode) {
299
+ return mode === 'plan' ? prompt : prompt + TEAMMATE_PR_POLICY;
300
+ }
258
301
  // Canonical modes plus the historical `full` alias (rewritten to `skip` by
259
302
  // normalizeModeValue). Keep `full` listed so user-typed CLI flags and stored
260
303
  // metadata that pre-date the rename continue to parse.
@@ -2530,6 +2573,14 @@ export class AgentManager {
2530
2573
  fullPrompt = CLAUDE_PLAN_MODE_PREFIX + fullPrompt;
2531
2574
  }
2532
2575
  }
2576
+ // PHNX-3236: append the self-merge boundary to every WRITE-capable teammate,
2577
+ // fresh or resumed. A plan-mode teammate produces no PR (read-only), so it is
2578
+ // skipped to keep its prompt clean; every other mode can open — and could
2579
+ // self-merge — a PR, so the policy rides along regardless of harness. The
2580
+ // hard block is still merge-guard.sh (inherited hook); see TEAMMATE_PR_POLICY.
2581
+ // The cloud dispatch path applies the SAME helper (withTeammatePrPolicy) so
2582
+ // local, remote, and cloud teammates never diverge on this boundary.
2583
+ fullPrompt = withTeammatePrPolicy(fullPrompt, mode);
2533
2584
  // Profile target takes precedence — `agents run <profile>` resolves the
2534
2585
  // host harness, version pin, and env injection in one place. Plain
2535
2586
  // version pins only apply when no profile is selected.