@phnx-labs/agents-cli 1.22.60 → 1.22.61
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.
- package/CHANGELOG.md +49 -0
- package/dist/cli/command-registry.d.ts +1 -0
- package/dist/cli/command-registry.js +2 -0
- package/dist/commands/browser.js +9 -4
- package/dist/commands/doctor.js +1 -1
- package/dist/commands/exec.js +35 -1
- package/dist/commands/harness-hooks.d.ts +55 -0
- package/dist/commands/harness-hooks.js +104 -0
- package/dist/commands/harness-wizard.d.ts +33 -14
- package/dist/commands/harness-wizard.js +53 -23
- package/dist/commands/harness.d.ts +14 -0
- package/dist/commands/harness.js +86 -5
- package/dist/commands/reminders.d.ts +9 -0
- package/dist/commands/reminders.js +49 -0
- package/dist/commands/run-account-picker.d.ts +14 -0
- package/dist/commands/run-account-picker.js +13 -0
- package/dist/commands/teams.d.ts +1 -1
- package/dist/commands/teams.js +9 -3
- package/dist/index.js +9 -0
- package/dist/lib/accounting/rotate.d.ts +63 -0
- package/dist/lib/accounting/rotate.js +229 -13
- package/dist/lib/browser/drivers/local.d.ts +11 -0
- package/dist/lib/browser/drivers/local.js +26 -0
- package/dist/lib/browser/profiles.js +8 -6
- package/dist/lib/browser/service.d.ts +12 -8
- package/dist/lib/browser/service.js +38 -10
- package/dist/lib/claude-statusline.d.ts +14 -1
- package/dist/lib/claude-statusline.js +27 -2
- package/dist/lib/daemon/runner.js +17 -2
- package/dist/lib/devices/doctor-findings.d.ts +1 -1
- package/dist/lib/devices/doctor-findings.js +22 -4
- package/dist/lib/doctor-diff.d.ts +21 -5
- package/dist/lib/doctor-diff.js +242 -76
- package/dist/lib/feed/events.d.ts +1 -1
- package/dist/lib/feed/events.js +25 -16
- package/dist/lib/github/gh-overload.d.ts +58 -0
- package/dist/lib/github/gh-overload.js +246 -0
- package/dist/lib/github/rest.d.ts +64 -0
- package/dist/lib/github/rest.js +111 -0
- package/dist/lib/harness-connection-test.d.ts +57 -0
- package/dist/lib/harness-connection-test.js +80 -0
- package/dist/lib/heal.js +8 -3
- package/dist/lib/installations/shims.d.ts +22 -0
- package/dist/lib/installations/shims.js +104 -0
- package/dist/lib/linear-project-counts.js +8 -0
- package/dist/lib/linear-rate-limit.d.ts +26 -0
- package/dist/lib/linear-rate-limit.js +163 -0
- package/dist/lib/mcp.d.ts +9 -0
- package/dist/lib/mcp.js +37 -1
- package/dist/lib/open-url.js +5 -3
- package/dist/lib/permissions.d.ts +28 -0
- package/dist/lib/permissions.js +156 -1
- package/dist/lib/refresh.js +9 -1
- package/dist/lib/reminders.d.ts +29 -0
- package/dist/lib/reminders.js +88 -0
- package/dist/lib/resource-content-diff.d.ts +33 -0
- package/dist/lib/resource-content-diff.js +103 -0
- package/dist/lib/rules/compile.d.ts +7 -0
- package/dist/lib/rules/compile.js +7 -1
- package/dist/lib/session/active.d.ts +41 -4
- package/dist/lib/session/active.js +58 -7
- package/dist/lib/session/host-link.d.ts +22 -0
- package/dist/lib/session/host-link.js +40 -4
- package/dist/lib/session/trajectory.d.ts +42 -0
- package/dist/lib/session/trajectory.js +46 -27
- package/dist/lib/ssh-exec.d.ts +30 -0
- package/dist/lib/ssh-exec.js +37 -5
- package/dist/lib/startup/command-registry.js +1 -1
- package/dist/lib/subagents-registry.d.ts +18 -0
- package/dist/lib/subagents-registry.js +79 -0
- package/dist/lib/teams/agents.d.ts +12 -0
- package/dist/lib/teams/agents.js +51 -0
- package/dist/lib/traces/schema2-build.d.ts +85 -0
- package/dist/lib/traces/schema2-build.js +637 -0
- package/dist/lib/traces/schema2-danger.d.ts +36 -0
- package/dist/lib/traces/schema2-danger.js +185 -0
- package/dist/lib/traces/schema2.d.ts +149 -0
- package/dist/lib/traces/schema2.js +20 -0
- package/dist/lib/traces/sync.d.ts +93 -0
- package/dist/lib/traces/sync.js +75 -22
- package/dist/lib/traces/worker-template.js +5 -0
- package/dist/lib/uninstall.js +10 -1
- package/dist/lib/workflows.d.ts +11 -0
- package/dist/lib/workflows.js +67 -8
- package/package.json +1 -1
|
@@ -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
|
-
*
|
|
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
|
-
*
|
|
184
|
-
*
|
|
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
|
|
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;
|
package/dist/lib/ssh-exec.d.ts
CHANGED
|
@@ -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.
|
package/dist/lib/ssh-exec.js
CHANGED
|
@@ -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
|
|
96
|
-
*
|
|
97
|
-
*
|
|
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
|
|
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',
|
|
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. */
|
package/dist/lib/teams/agents.js
CHANGED
|
@@ -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.
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* schema2-build — the PRODUCER's per-tool mappers + `buildSessionDetailV2`
|
|
3
|
+
* (PHNX-3442 step 2, increments 2-4).
|
|
4
|
+
*
|
|
5
|
+
* Populates the `SessionStepV2` discriminated union (schema2.ts) from the parsed
|
|
6
|
+
* session events, reusing the SAME infrastructure the schema-1 path already uses:
|
|
7
|
+
*
|
|
8
|
+
* - the callId pairing loop (`pairSteps` in session/trajectory.ts) — so a step's
|
|
9
|
+
* (use event, result event) triple is recovered without a duplicate loop;
|
|
10
|
+
* - bash unwrap/tokenize/classify (`session/bash-command.ts`) + the effective
|
|
11
|
+
* program resolver (`effectiveProgram`);
|
|
12
|
+
* - the meta / whereItWentWrong / surfacedToolFailures / active-time helpers
|
|
13
|
+
* factored out of sync.ts (`buildDetailMeta`, `buildWhereItWentWrong`, …).
|
|
14
|
+
*
|
|
15
|
+
* The command/patch/output PARSING lives here; the worker stores the shard
|
|
16
|
+
* opaquely and the console reads the union directly and never reparses (spec §5).
|
|
17
|
+
*
|
|
18
|
+
* category / risk / categoryMetrics are DELIBERATELY omitted from the schema-2
|
|
19
|
+
* detail: the shipped consumer (`decodeSessionDetail` → coerceCategory/Risk/Metrics)
|
|
20
|
+
* backfills them to the same neutral defaults it uses for schema-1, so computing
|
|
21
|
+
* them here would be inventing session-level signal this step does not own.
|
|
22
|
+
*/
|
|
23
|
+
import { type BashCategory as ClassifierCategory } from '../session/bash-command.js';
|
|
24
|
+
import { type SessionTrajectory } from '../session/trajectory.js';
|
|
25
|
+
import type { SessionEvent } from '../session/types.js';
|
|
26
|
+
import type { BashAction, BashCategory, SessionStepV2 } from './schema2.js';
|
|
27
|
+
import { activeMsFromTrajectory, type SessionDetail } from './sync.js';
|
|
28
|
+
/**
|
|
29
|
+
* SessionDetailV2 — the schema-2 shard this producer emits.
|
|
30
|
+
*
|
|
31
|
+
* `category` / `risk` / `categoryMetrics` are DELIBERATELY OMITTED. The consumer's
|
|
32
|
+
* `SessionDetailV2` declares them, but its `decodeSchema2` backfills neutral
|
|
33
|
+
* defaults via `coerceCategory`/`coerceRisk`/`coerceMetrics` (never throws) — the
|
|
34
|
+
* same defaulting it applies to schema-1 shards today. This producer does not yet
|
|
35
|
+
* author those fields (their provenance is the prix/api PHNX-3351 hosted backend,
|
|
36
|
+
* not agents-cli), so emitting them here would fabricate classification. Omission
|
|
37
|
+
* is the honest choice and is asserted as a tested contract in
|
|
38
|
+
* `schema2-fixture.test.ts`. Wire real category/risk here once its source is settled.
|
|
39
|
+
*/
|
|
40
|
+
export interface SessionDetailV2 {
|
|
41
|
+
schema: 2;
|
|
42
|
+
id: string;
|
|
43
|
+
meta: SessionDetail['meta'];
|
|
44
|
+
steps: SessionStepV2[];
|
|
45
|
+
gaps: SessionTrajectory['gaps'];
|
|
46
|
+
truncatedSteps: number;
|
|
47
|
+
whereItWentWrong: string | null;
|
|
48
|
+
surfacedToolFailures: Array<{
|
|
49
|
+
tool?: string;
|
|
50
|
+
label: string;
|
|
51
|
+
detail?: string;
|
|
52
|
+
}>;
|
|
53
|
+
}
|
|
54
|
+
/**
|
|
55
|
+
* `unwrapCommand` (bash-command.ts) strips VAR=/sudo/cd&&/npx/loops/subshells but
|
|
56
|
+
* NOT an interpreter wrapper like `/bin/zsh -lc "…"`, `bash -lc '…'`, or `sh -c …`
|
|
57
|
+
* — the exact shape the managed runner wraps every command in. Peel that first,
|
|
58
|
+
* then hand the inner payload to the existing unwrapper so all the wrappers it DOES
|
|
59
|
+
* know still apply. One extra rule, at the source, not a fork of unwrapCommand.
|
|
60
|
+
*/
|
|
61
|
+
export declare function unwrapShellExec(command: string): string;
|
|
62
|
+
/**
|
|
63
|
+
* Map a classifier `BashCategory` (the rich vcs|build-test|install|… taxonomy) to
|
|
64
|
+
* the coarse schema-2 `BashCategory` (build|test|git|network|other). `build-test`
|
|
65
|
+
* needs the argv/subcommand to decide build vs test — `bun test` is test, `bun
|
|
66
|
+
* build` is build — so this takes the tokenized argv too.
|
|
67
|
+
*/
|
|
68
|
+
export declare function mapBashCategory(cat: ClassifierCategory, argv: string[]): BashCategory;
|
|
69
|
+
/** Build the per-segment BashAction list for a bash command. */
|
|
70
|
+
export declare function buildBashActions(unwrapped: string): BashAction[];
|
|
71
|
+
export interface BuildDetailV2Options {
|
|
72
|
+
redact?: boolean;
|
|
73
|
+
knownSecrets?: readonly string[];
|
|
74
|
+
}
|
|
75
|
+
/**
|
|
76
|
+
* Build the schema-2 per-session detail from a pre-built trajectory and its raw
|
|
77
|
+
* events. The trajectory supplies meta/gaps/whereItWentWrong/surfacedToolFailures
|
|
78
|
+
* (via the shared sync.ts helpers) and the truncation count; the raw events supply
|
|
79
|
+
* the per-tool detail the schema-1 flat step could not carry.
|
|
80
|
+
*
|
|
81
|
+
* Re-pairs the events with `pairSteps` (the SAME loop buildTrajectory ran) to
|
|
82
|
+
* recover each step's (use event, result event) triple, then dispatches per tool.
|
|
83
|
+
*/
|
|
84
|
+
export declare function buildSessionDetailV2(traj: SessionTrajectory, events: SessionEvent[], options?: BuildDetailV2Options): SessionDetailV2;
|
|
85
|
+
export { activeMsFromTrajectory };
|