@phnx-labs/agents-cli 1.22.59 → 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 +71 -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/perf.js +10 -0
- 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/sessions-picker.d.ts +13 -0
- package/dist/commands/sessions-picker.js +17 -8
- package/dist/commands/sessions.js +13 -11
- package/dist/commands/teams-picker.js +20 -6
- package/dist/commands/teams.d.ts +3 -3
- package/dist/commands/teams.js +86 -24
- package/dist/index.js +9 -0
- package/dist/lib/accounting/rotate.d.ts +63 -0
- package/dist/lib/accounting/rotate.js +240 -16
- package/dist/lib/accounting/usage-sync.d.ts +12 -2
- package/dist/lib/accounting/usage-sync.js +34 -6
- 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 +28 -15
- 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/perf/db.d.ts +1 -1
- package/dist/lib/perf/db.js +53 -2
- package/dist/lib/perf/types.d.ts +14 -0
- 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/live-metadata.js +3 -3
- 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/teams/api.d.ts +8 -0
- package/dist/lib/teams/api.js +50 -6
- package/dist/lib/teams/delivery.d.ts +14 -4
- package/dist/lib/teams/delivery.js +15 -5
- 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
|
@@ -41,7 +41,7 @@ import { detectProvenance } from './provenance.js';
|
|
|
41
41
|
import { loadDevices } from '../devices/registry.js';
|
|
42
42
|
import { machineId, normalizeHost } from '../machine-id.js';
|
|
43
43
|
import { presenceFromStore } from './detached.js';
|
|
44
|
-
import { classifyHostLink, HOST_HEARTBEAT_STALE_MS } from './host-link.js';
|
|
44
|
+
import { classifyHostLink, hostWindowLost, HOST_HEARTBEAT_STALE_MS } from './host-link.js';
|
|
45
45
|
import { mapBounded } from '../concurrency.js';
|
|
46
46
|
import { linearIssueUrl } from './linear.js';
|
|
47
47
|
import { viewingInLabel } from './viewing-in.js';
|
|
@@ -1810,6 +1810,10 @@ export async function getActiveSessions(opts = {}) {
|
|
|
1810
1810
|
// Last: derive the shown title (recap ladder) from label/tail/topic now that
|
|
1811
1811
|
// every row's live tail + label are resolved (RUSH-3011).
|
|
1812
1812
|
foldRecap(merged);
|
|
1813
|
+
// Project the coarse lifecycle bucket once status is final (foldHostLink above may
|
|
1814
|
+
// have promoted a row to orphaned/crashed) so consumers read `phase` off the row
|
|
1815
|
+
// instead of re-deriving it (PHNX-2484).
|
|
1816
|
+
foldPhase(merged);
|
|
1813
1817
|
return merged;
|
|
1814
1818
|
}
|
|
1815
1819
|
/**
|
|
@@ -1923,10 +1927,15 @@ export async function foldTmuxClients(rows) {
|
|
|
1923
1927
|
* - `crashed` REPLACES `closed`. Both mean the process is gone, but `closed`
|
|
1924
1928
|
* reads as a normal exit; `crashed` says the host window went down with it
|
|
1925
1929
|
* and never cleaned up.
|
|
1926
|
-
* - `orphaned` replaces
|
|
1927
|
-
*
|
|
1928
|
-
*
|
|
1929
|
-
*
|
|
1930
|
+
* - `orphaned` replaces `idle` / `input_required` on ANY `no-client`, and
|
|
1931
|
+
* `running` ONLY when the owning WINDOW was lost (`hostWindowLost`). A
|
|
1932
|
+
* session still working with merely zero tmux clients is a normal headless
|
|
1933
|
+
* run — since RUSH-3125 a detached remote pane is the steady state — so
|
|
1934
|
+
* flagging every one would bury the real signal (the false positive reverted
|
|
1935
|
+
* in 6d973b823). But a running agent whose IDE window stopped republishing
|
|
1936
|
+
* its heartbeat outlived an unclean host death and is genuinely stranded, so
|
|
1937
|
+
* it IS promoted. A session sitting idle — or worse, waiting on a question —
|
|
1938
|
+
* with no client attached is the same stranded case: nobody is coming.
|
|
1930
1939
|
*/
|
|
1931
1940
|
/**
|
|
1932
1941
|
* Attribute each live row to the machine the session actually EXECUTES on.
|
|
@@ -2032,12 +2041,13 @@ export function foldHostLink(rows) {
|
|
|
2032
2041
|
continue;
|
|
2033
2042
|
// `closed` IS the dead-pid status — `lifecycleStatus` assigns it from
|
|
2034
2043
|
// `!pidAlive` and nothing else — so the status is the pid answer here.
|
|
2035
|
-
const
|
|
2044
|
+
const signals = {
|
|
2036
2045
|
pidAlive: s.status !== 'closed',
|
|
2037
2046
|
windowHeartbeatMs: s.windowHeartbeatMs,
|
|
2038
2047
|
tmuxClients: s.tmuxClients,
|
|
2039
2048
|
deliberatelyDetached: s.presence === 'background' || s.presence === 'parked',
|
|
2040
|
-
}
|
|
2049
|
+
};
|
|
2050
|
+
const link = classifyHostLink(signals);
|
|
2041
2051
|
s.hostLink = link;
|
|
2042
2052
|
// `foldPresence` gives every terminal row a DERIVED `attached` — "a live
|
|
2043
2053
|
// interactive TUI you're watching" — which is exactly the claim a lost host
|
|
@@ -2057,6 +2067,14 @@ export function foldHostLink(rows) {
|
|
|
2057
2067
|
else if (link === 'no-client' && (s.status === 'idle' || s.status === 'input_required')) {
|
|
2058
2068
|
s.status = 'orphaned';
|
|
2059
2069
|
}
|
|
2070
|
+
// A still-`running` agent is normally a healthy headless run, so 0 tmux
|
|
2071
|
+
// clients (a detached remote pane) MUST NOT flag it — that false positive was
|
|
2072
|
+
// reverted once already (6d973b823). The ONE exception is a lost WINDOW: its
|
|
2073
|
+
// owning IDE window stopped republishing, so it died uncleanly and the agent
|
|
2074
|
+
// outlived it — genuinely stranded (PHNX-3183, `hostWindowLost`, SES-18a).
|
|
2075
|
+
else if (s.status === 'running' && hostWindowLost(signals)) {
|
|
2076
|
+
s.status = 'orphaned';
|
|
2077
|
+
}
|
|
2060
2078
|
}
|
|
2061
2079
|
}
|
|
2062
2080
|
/** One display line, trimmed and capped, or undefined when empty. */
|
|
@@ -2099,6 +2117,39 @@ export function foldRecap(rows) {
|
|
|
2099
2117
|
s.lastAgentLine = recap.lastAgentLine;
|
|
2100
2118
|
}
|
|
2101
2119
|
}
|
|
2120
|
+
/**
|
|
2121
|
+
* Project a {@link SessionPhase} from the finalized {@link ActiveStatus} (PHNX-2484).
|
|
2122
|
+
* This is the single source of truth the AGI EXT previously mirrored as
|
|
2123
|
+
* `mapStatusToPhase` — the ext now reads the `phase` field instead of re-deriving it.
|
|
2124
|
+
* `queued` counts as `running` (dispatched, in the pipeline); `abandoned`, `orphaned`,
|
|
2125
|
+
* and `crashed` all bucket to `failed` (dangling/dead — needs attention), which is the
|
|
2126
|
+
* drift this fixes: those three used to fall through a status-only map to `idle` and
|
|
2127
|
+
* silently hide a dead agent.
|
|
2128
|
+
*/
|
|
2129
|
+
export function derivePhase(status) {
|
|
2130
|
+
switch (status) {
|
|
2131
|
+
case 'running':
|
|
2132
|
+
case 'queued':
|
|
2133
|
+
return 'running';
|
|
2134
|
+
case 'input_required':
|
|
2135
|
+
return 'waiting';
|
|
2136
|
+
case 'abandoned':
|
|
2137
|
+
case 'orphaned':
|
|
2138
|
+
case 'crashed':
|
|
2139
|
+
return 'failed';
|
|
2140
|
+
case 'closed':
|
|
2141
|
+
return 'done';
|
|
2142
|
+
case 'idle':
|
|
2143
|
+
case 'unknown':
|
|
2144
|
+
default:
|
|
2145
|
+
return 'idle';
|
|
2146
|
+
}
|
|
2147
|
+
}
|
|
2148
|
+
/** Fold the coarse lifecycle {@link SessionPhase} onto every row (see {@link derivePhase}). */
|
|
2149
|
+
export function foldPhase(rows) {
|
|
2150
|
+
for (const s of rows)
|
|
2151
|
+
s.phase = derivePhase(s.status);
|
|
2152
|
+
}
|
|
2102
2153
|
/** Reap only stale sessions with proven-dead pids; unknown or live processes remain recoverable. */
|
|
2103
2154
|
export function isReapableOrphan(row) {
|
|
2104
2155
|
return row.status === 'abandoned' && row.pidAlive === false;
|
|
@@ -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
|
|
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 (
|
|
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 (
|
|
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
|
+
}
|
|
@@ -15,7 +15,8 @@
|
|
|
15
15
|
// exist. It parses no transcript and renders nothing — it only reshapes process
|
|
16
16
|
// state the live registry already computed. When the transcript IS on disk, the
|
|
17
17
|
// synthesized row carries its path, so the downstream `buildPreview` renders the
|
|
18
|
-
// real digest
|
|
18
|
+
// real digest. Without a path, a peer-owned row fetches its digest from that peer;
|
|
19
|
+
// a genuinely local row renders the header + a "not indexed here" note.
|
|
19
20
|
import { deriveShortId } from '../text/short-id.js';
|
|
20
21
|
import { isSessionTrackedAgent } from './types.js';
|
|
21
22
|
/**
|
|
@@ -50,9 +51,8 @@ export function activeSessionToSessionMeta(active, self, nowMs) {
|
|
|
50
51
|
topic: active.topic,
|
|
51
52
|
version: active.version,
|
|
52
53
|
messageCount: undefined,
|
|
53
|
-
// The row runs HERE — the local live registry is machine-local, so a match
|
|
54
|
-
// resolves without an SSH hop back to a peer.
|
|
55
54
|
machine: active.machine ?? self,
|
|
55
|
+
_remote: (active.machine ?? self) !== self,
|
|
56
56
|
ticketId: active.ticket?.id,
|
|
57
57
|
prUrl: active.pr?.url,
|
|
58
58
|
prNumber: active.pr?.number,
|
|
@@ -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
|
-
*
|
|
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
|