@phnx-labs/agents-cli 1.20.76 → 1.20.78
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 +264 -0
- package/README.md +2 -0
- package/dist/bin/agents +0 -0
- package/dist/commands/doctor.d.ts +9 -0
- package/dist/commands/doctor.js +143 -9
- package/dist/commands/exec.js +87 -19
- package/dist/commands/repo.js +8 -0
- package/dist/commands/routines.js +275 -15
- package/dist/commands/secrets.js +65 -17
- package/dist/commands/sessions-browser.d.ts +45 -3
- package/dist/commands/sessions-browser.js +128 -12
- package/dist/commands/sessions-export.d.ts +3 -0
- package/dist/commands/sessions-export.js +13 -10
- package/dist/commands/sessions-picker.d.ts +28 -1
- package/dist/commands/sessions-picker.js +222 -15
- package/dist/commands/sessions.d.ts +117 -4
- package/dist/commands/sessions.js +350 -62
- package/dist/commands/ssh.d.ts +13 -0
- package/dist/commands/ssh.js +48 -3
- package/dist/index.js +5 -6
- package/dist/lib/activity.d.ts +5 -4
- package/dist/lib/activity.js +450 -36
- package/dist/lib/agents.d.ts +0 -21
- package/dist/lib/agents.js +0 -37
- package/dist/lib/auto-pull.d.ts +16 -8
- package/dist/lib/auto-pull.js +23 -28
- package/dist/lib/cloud/session-index.js +2 -2
- package/dist/lib/daemon.d.ts +9 -32
- package/dist/lib/daemon.js +70 -84
- package/dist/lib/devices/fleet-divergence.d.ts +101 -0
- package/dist/lib/devices/fleet-divergence.js +188 -0
- package/dist/lib/devices/fleet-inventory.d.ts +19 -0
- package/dist/lib/devices/fleet-inventory.js +57 -0
- package/dist/lib/devices/fleet.d.ts +16 -1
- package/dist/lib/devices/fleet.js +10 -2
- package/dist/lib/devices/health-report.d.ts +10 -2
- package/dist/lib/devices/health-report.js +32 -1
- package/dist/lib/events.d.ts +1 -1
- package/dist/lib/exec.d.ts +16 -0
- package/dist/lib/exec.js +27 -4
- package/dist/lib/feed.d.ts +7 -1
- package/dist/lib/feed.js +96 -6
- package/dist/lib/git.d.ts +13 -0
- package/dist/lib/git.js +102 -4
- package/dist/lib/heal.d.ts +7 -4
- package/dist/lib/heal.js +10 -22
- package/dist/lib/hosts/dispatch.d.ts +9 -0
- package/dist/lib/hosts/dispatch.js +24 -4
- package/dist/lib/hosts/passthrough.js +7 -2
- package/dist/lib/hosts/remote-cmd.d.ts +11 -0
- package/dist/lib/hosts/remote-cmd.js +33 -8
- package/dist/lib/hosts/remote-session-id.d.ts +45 -0
- package/dist/lib/hosts/remote-session-id.js +84 -0
- package/dist/lib/hosts/run-target.d.ts +4 -0
- package/dist/lib/hosts/run-target.js +5 -1
- package/dist/lib/hosts/session-index.js +3 -3
- package/dist/lib/menubar/MenubarHelper.app/Contents/Info.plist +2 -0
- package/dist/lib/menubar/MenubarHelper.app/Contents/MacOS/MenubarHelper +0 -0
- package/dist/lib/menubar/MenubarHelper.app/Contents/Resources/AppIcon.icns +0 -0
- package/dist/lib/menubar/MenubarHelper.app/Contents/_CodeSignature/CodeResources +15 -2
- package/dist/lib/menubar/install-menubar.d.ts +9 -0
- package/dist/lib/menubar/install-menubar.js +14 -0
- package/dist/lib/menubar/notify-desktop.d.ts +44 -0
- package/dist/lib/menubar/notify-desktop.js +78 -0
- package/dist/lib/overdue.d.ts +4 -5
- package/dist/lib/overdue.js +8 -41
- package/dist/lib/routine-notify.d.ts +76 -0
- package/dist/lib/routine-notify.js +190 -0
- package/dist/lib/routines-placement.d.ts +38 -0
- package/dist/lib/routines-placement.js +79 -0
- package/dist/lib/routines-project.d.ts +97 -0
- package/dist/lib/routines-project.js +349 -0
- package/dist/lib/routines.d.ts +68 -0
- package/dist/lib/routines.js +74 -7
- package/dist/lib/runner.d.ts +12 -2
- package/dist/lib/runner.js +156 -28
- package/dist/lib/sandbox.js +0 -6
- package/dist/lib/secrets/Agents CLI.app/Contents/CodeResources +0 -0
- package/dist/lib/secrets/Agents CLI.app/Contents/Info.plist +2 -0
- package/dist/lib/secrets/Agents CLI.app/Contents/MacOS/Agents CLI +0 -0
- package/dist/lib/secrets/Agents CLI.app/Contents/Resources/AppIcon.icns +0 -0
- package/dist/lib/secrets/Agents CLI.app/Contents/_CodeSignature/CodeResources +13 -1
- package/dist/lib/secrets/agent.d.ts +11 -4
- package/dist/lib/secrets/agent.js +46 -24
- package/dist/lib/secrets/bundles.d.ts +21 -7
- package/dist/lib/secrets/bundles.js +82 -38
- package/dist/lib/secrets/index.d.ts +11 -2
- package/dist/lib/secrets/index.js +21 -9
- package/dist/lib/secrets/scope.d.ts +26 -0
- package/dist/lib/secrets/scope.js +29 -0
- package/dist/lib/secrets/session-store.d.ts +16 -2
- package/dist/lib/secrets/session-store.js +118 -15
- package/dist/lib/secrets/unlock-hints.d.ts +27 -0
- package/dist/lib/secrets/unlock-hints.js +36 -0
- package/dist/lib/session/active.d.ts +17 -0
- package/dist/lib/session/active.js +105 -7
- package/dist/lib/session/db.d.ts +43 -0
- package/dist/lib/session/db.js +191 -6
- package/dist/lib/session/digest.js +126 -21
- package/dist/lib/session/discover.d.ts +28 -3
- package/dist/lib/session/discover.js +110 -14
- package/dist/lib/session/origin-machine.d.ts +18 -0
- package/dist/lib/session/origin-machine.js +34 -0
- package/dist/lib/session/parse.js +11 -2
- package/dist/lib/session/prompt.d.ts +7 -0
- package/dist/lib/session/prompt.js +37 -0
- package/dist/lib/session/provenance.d.ts +7 -0
- package/dist/lib/session/render.js +18 -16
- package/dist/lib/session/state.d.ts +5 -0
- package/dist/lib/session/state.js +94 -12
- package/dist/lib/session/sync/config.js +2 -2
- package/dist/lib/session/types.d.ts +25 -1
- package/dist/lib/session/types.js +8 -0
- package/dist/lib/smart-launch.d.ts +86 -0
- package/dist/lib/smart-launch.js +172 -0
- package/dist/lib/state.d.ts +7 -3
- package/dist/lib/state.js +20 -5
- package/dist/lib/types.d.ts +10 -0
- package/package.json +1 -1
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Cross-device harness divergence for `agents doctor` (RUSH-2027).
|
|
3
|
+
*
|
|
4
|
+
* `agents fleet status` already fans out `agents doctor --json` per device and
|
|
5
|
+
* builds a {@link FleetHealthReport} of coarse device health (reachable, CLIs
|
|
6
|
+
* installed, agents-cli version skew, local sync drift). It deliberately does
|
|
7
|
+
* NOT compare fine-grained *resource presence* across devices — so a plugin like
|
|
8
|
+
* `swarm` installed on one box but missing on another is silent until a user
|
|
9
|
+
* types `/swarm:run` on the wrong machine and gets `Unknown command`.
|
|
10
|
+
*
|
|
11
|
+
* This module is the missing comparison. Each device self-reports its installed
|
|
12
|
+
* inventory (resource kinds, per-agent version sets, `.agents`/`.system` repo
|
|
13
|
+
* state) in its `doctor --json` payload; {@link compareFleetInventories} takes
|
|
14
|
+
* the collected per-device inventories and, treating the local machine as the
|
|
15
|
+
* baseline, flags:
|
|
16
|
+
*
|
|
17
|
+
* 1. Resource presence gaps — a resource present on one device, missing on
|
|
18
|
+
* another (either direction relative to the local baseline).
|
|
19
|
+
* 2. Agent version gaps — an agent version installed on one device but
|
|
20
|
+
* not another (e.g. `yosemite-s0 missing claude@2.1.220`).
|
|
21
|
+
* 3. `.agents`/`.system` repo drift — a device whose config-repo HEAD, branch,
|
|
22
|
+
* or dirty state diverges from the local baseline.
|
|
23
|
+
*
|
|
24
|
+
* Pure and SSH-free: the SSH fan-out lives in the doctor command; this module
|
|
25
|
+
* only consumes the already-collected payloads, so the divergence logic is
|
|
26
|
+
* unit-tested against fixture inventories with no live fleet.
|
|
27
|
+
*/
|
|
28
|
+
export const FLEET_RESOURCE_KINDS = [
|
|
29
|
+
'commands',
|
|
30
|
+
'skills',
|
|
31
|
+
'hooks',
|
|
32
|
+
'rules',
|
|
33
|
+
'mcp',
|
|
34
|
+
'permissions',
|
|
35
|
+
'subagents',
|
|
36
|
+
'plugins',
|
|
37
|
+
'promptcuts',
|
|
38
|
+
'workflows',
|
|
39
|
+
];
|
|
40
|
+
function sortedUnique(list) {
|
|
41
|
+
return Array.from(new Set(list)).sort();
|
|
42
|
+
}
|
|
43
|
+
function repoLabel(repo) {
|
|
44
|
+
return repo === 'agents' ? '.agents' : '.system';
|
|
45
|
+
}
|
|
46
|
+
/** Describe how a remote repo state diverges from the local baseline, or null
|
|
47
|
+
* when they match. Compares HEAD first (the load-bearing difference), then
|
|
48
|
+
* branch, then a dirty tree on either side (naming which side is dirty). */
|
|
49
|
+
function describeRepoDrift(local, remote) {
|
|
50
|
+
if (local.head && remote.head && local.head !== remote.head) {
|
|
51
|
+
return `HEAD ${remote.head} != local ${local.head}`;
|
|
52
|
+
}
|
|
53
|
+
if (local.branch !== remote.branch) {
|
|
54
|
+
return `branch ${remote.branch ?? 'detached'} != local ${local.branch ?? 'detached'}`;
|
|
55
|
+
}
|
|
56
|
+
// Flag a dirty tree on EITHER side (symmetric with HEAD/branch above), and name
|
|
57
|
+
// the side that has the uncommitted changes — the remote, or the local baseline.
|
|
58
|
+
if (remote.dirty !== local.dirty) {
|
|
59
|
+
return remote.dirty
|
|
60
|
+
? 'remote tree has uncommitted changes'
|
|
61
|
+
: 'local tree has uncommitted changes';
|
|
62
|
+
}
|
|
63
|
+
return null;
|
|
64
|
+
}
|
|
65
|
+
/**
|
|
66
|
+
* Compare a set of per-device inventories against the local baseline and emit
|
|
67
|
+
* every cross-device divergence. The baseline is the device whose name equals
|
|
68
|
+
* {@link baselineName} (the local machine); if that device has no inventory the
|
|
69
|
+
* report is empty (nothing to compare against). Devices with no inventory are
|
|
70
|
+
* recorded under `skippedDevices` and never produce false "missing" findings.
|
|
71
|
+
*
|
|
72
|
+
* Ordering is deterministic (device, then category, then name) so the human and
|
|
73
|
+
* JSON output — and the tests — are stable.
|
|
74
|
+
*/
|
|
75
|
+
export function compareFleetInventories(devices, baselineName) {
|
|
76
|
+
const baseline = devices.find((d) => d.name === baselineName)?.inventory ?? null;
|
|
77
|
+
const remotes = devices.filter((d) => d.name !== baselineName);
|
|
78
|
+
const comparedDevices = [];
|
|
79
|
+
const skippedDevices = [];
|
|
80
|
+
const divergences = [];
|
|
81
|
+
if (!baseline) {
|
|
82
|
+
// No local baseline to compare against — record every remote as skipped so
|
|
83
|
+
// the caller can say so, but emit no divergences (we can't know the truth).
|
|
84
|
+
for (const d of remotes)
|
|
85
|
+
skippedDevices.push(d.name);
|
|
86
|
+
return {
|
|
87
|
+
baseline: baselineName,
|
|
88
|
+
divergences: [],
|
|
89
|
+
comparedDevices: [],
|
|
90
|
+
skippedDevices: skippedDevices.sort(),
|
|
91
|
+
hasDivergence: false,
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
for (const remote of remotes) {
|
|
95
|
+
if (!remote.inventory) {
|
|
96
|
+
skippedDevices.push(remote.name);
|
|
97
|
+
continue;
|
|
98
|
+
}
|
|
99
|
+
comparedDevices.push(remote.name);
|
|
100
|
+
const inv = remote.inventory;
|
|
101
|
+
// 1) Resource presence, both directions.
|
|
102
|
+
for (const kind of FLEET_RESOURCE_KINDS) {
|
|
103
|
+
const localSet = new Set(baseline.resources[kind] ?? []);
|
|
104
|
+
const remoteSet = new Set(inv.resources[kind] ?? []);
|
|
105
|
+
for (const name of sortedUnique(baseline.resources[kind] ?? [])) {
|
|
106
|
+
if (!remoteSet.has(name)) {
|
|
107
|
+
divergences.push({
|
|
108
|
+
kind: 'resource-missing-remote',
|
|
109
|
+
device: remote.name,
|
|
110
|
+
category: kind,
|
|
111
|
+
name,
|
|
112
|
+
message: `${remote.name} is missing ${kind.replace(/s$/, '')} '${name}' (present on ${baselineName})`,
|
|
113
|
+
});
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
for (const name of sortedUnique(inv.resources[kind] ?? [])) {
|
|
117
|
+
if (!localSet.has(name)) {
|
|
118
|
+
divergences.push({
|
|
119
|
+
kind: 'resource-missing-local',
|
|
120
|
+
device: remote.name,
|
|
121
|
+
category: kind,
|
|
122
|
+
name,
|
|
123
|
+
message: `${baselineName} is missing ${kind.replace(/s$/, '')} '${name}' (present on ${remote.name})`,
|
|
124
|
+
});
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
// 2) Agent version parity, both directions, per agent id.
|
|
129
|
+
const agentIds = sortedUnique([
|
|
130
|
+
...Object.keys(baseline.agentVersions),
|
|
131
|
+
...Object.keys(inv.agentVersions),
|
|
132
|
+
]);
|
|
133
|
+
for (const agent of agentIds) {
|
|
134
|
+
const localVers = new Set(baseline.agentVersions[agent] ?? []);
|
|
135
|
+
const remoteVers = new Set(inv.agentVersions[agent] ?? []);
|
|
136
|
+
for (const v of sortedUnique(baseline.agentVersions[agent] ?? [])) {
|
|
137
|
+
if (!remoteVers.has(v)) {
|
|
138
|
+
divergences.push({
|
|
139
|
+
kind: 'agent-version-missing-remote',
|
|
140
|
+
device: remote.name,
|
|
141
|
+
category: agent,
|
|
142
|
+
name: v,
|
|
143
|
+
message: `${remote.name} is missing ${agent}@${v} (installed on ${baselineName})`,
|
|
144
|
+
});
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
for (const v of sortedUnique(inv.agentVersions[agent] ?? [])) {
|
|
148
|
+
if (!localVers.has(v)) {
|
|
149
|
+
divergences.push({
|
|
150
|
+
kind: 'agent-version-missing-local',
|
|
151
|
+
device: remote.name,
|
|
152
|
+
category: agent,
|
|
153
|
+
name: v,
|
|
154
|
+
message: `${baselineName} is missing ${agent}@${v} (installed on ${remote.name})`,
|
|
155
|
+
});
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
// 3) `.agents` / `.system` repo drift vs the local baseline.
|
|
160
|
+
for (const repo of ['agents', 'system']) {
|
|
161
|
+
const localRepo = baseline.repos[repo];
|
|
162
|
+
const remoteRepo = inv.repos[repo];
|
|
163
|
+
if (!localRepo || !remoteRepo)
|
|
164
|
+
continue; // one side isn't a readable repo
|
|
165
|
+
const drift = describeRepoDrift(localRepo, remoteRepo);
|
|
166
|
+
if (drift) {
|
|
167
|
+
divergences.push({
|
|
168
|
+
kind: 'repo-drift',
|
|
169
|
+
device: remote.name,
|
|
170
|
+
category: repo,
|
|
171
|
+
name: repoLabel(repo),
|
|
172
|
+
message: `${remote.name} ${repoLabel(repo)} repo diverged: ${drift}`,
|
|
173
|
+
});
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
divergences.sort((a, b) => a.device.localeCompare(b.device) ||
|
|
178
|
+
a.kind.localeCompare(b.kind) ||
|
|
179
|
+
a.category.localeCompare(b.category) ||
|
|
180
|
+
a.name.localeCompare(b.name));
|
|
181
|
+
return {
|
|
182
|
+
baseline: baselineName,
|
|
183
|
+
divergences,
|
|
184
|
+
comparedDevices: comparedDevices.sort(),
|
|
185
|
+
skippedDevices: skippedDevices.sort(),
|
|
186
|
+
hasDivergence: divergences.length > 0,
|
|
187
|
+
};
|
|
188
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Build the self-reported harness inventory a device emits in `doctor --json`
|
|
3
|
+
* for cross-device divergence detection (RUSH-2027).
|
|
4
|
+
*
|
|
5
|
+
* Kept separate from {@link ../devices/fleet-divergence.js} — which stays a pure,
|
|
6
|
+
* SSH-free comparator — because this reads the live install (resource dirs,
|
|
7
|
+
* installed version homes, config repos). One call produces the {@link
|
|
8
|
+
* FleetInventory} that both the local baseline and every remote box serialize
|
|
9
|
+
* into their doctor payload; the comparator then diffs those payloads.
|
|
10
|
+
*/
|
|
11
|
+
import { type FleetInventory } from './fleet-divergence.js';
|
|
12
|
+
/**
|
|
13
|
+
* Collect this machine's harness inventory: installed resources per kind,
|
|
14
|
+
* installed version ids per agent, and `.agents`/`.system` repo state. Pure
|
|
15
|
+
* reads — never mutates the install. `promptcuts` (a single present/absent bit
|
|
16
|
+
* in {@link getAvailableResources}) is surfaced as a one-element list so it
|
|
17
|
+
* compares like any other named resource.
|
|
18
|
+
*/
|
|
19
|
+
export declare function collectLocalFleetInventory(cwd?: string): FleetInventory;
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Build the self-reported harness inventory a device emits in `doctor --json`
|
|
3
|
+
* for cross-device divergence detection (RUSH-2027).
|
|
4
|
+
*
|
|
5
|
+
* Kept separate from {@link ../devices/fleet-divergence.js} — which stays a pure,
|
|
6
|
+
* SSH-free comparator — because this reads the live install (resource dirs,
|
|
7
|
+
* installed version homes, config repos). One call produces the {@link
|
|
8
|
+
* FleetInventory} that both the local baseline and every remote box serialize
|
|
9
|
+
* into their doctor payload; the comparator then diffs those payloads.
|
|
10
|
+
*/
|
|
11
|
+
import { getAvailableResources, listInstalledVersions } from '../versions.js';
|
|
12
|
+
import { getUserAgentsDir, getSystemAgentsDir } from '../state.js';
|
|
13
|
+
import { readRepoState } from '../git.js';
|
|
14
|
+
import { ALL_AGENT_IDS } from '../agents.js';
|
|
15
|
+
import { FLEET_RESOURCE_KINDS } from './fleet-divergence.js';
|
|
16
|
+
function toRepoState(snap) {
|
|
17
|
+
if (!snap)
|
|
18
|
+
return null;
|
|
19
|
+
return { branch: snap.branch, head: snap.head, dirty: snap.dirty };
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* Collect this machine's harness inventory: installed resources per kind,
|
|
23
|
+
* installed version ids per agent, and `.agents`/`.system` repo state. Pure
|
|
24
|
+
* reads — never mutates the install. `promptcuts` (a single present/absent bit
|
|
25
|
+
* in {@link getAvailableResources}) is surfaced as a one-element list so it
|
|
26
|
+
* compares like any other named resource.
|
|
27
|
+
*/
|
|
28
|
+
export function collectLocalFleetInventory(cwd = process.cwd()) {
|
|
29
|
+
const available = getAvailableResources(cwd);
|
|
30
|
+
const resources = {};
|
|
31
|
+
for (const kind of FLEET_RESOURCE_KINDS) {
|
|
32
|
+
if (kind === 'promptcuts') {
|
|
33
|
+
resources[kind] = available.promptcuts ? ['promptcuts.yaml'] : [];
|
|
34
|
+
}
|
|
35
|
+
else if (kind === 'rules') {
|
|
36
|
+
// getAvailableResources exposes top-level rules under `memory`.
|
|
37
|
+
resources[kind] = [...available.memory].sort();
|
|
38
|
+
}
|
|
39
|
+
else {
|
|
40
|
+
resources[kind] = [...(available[kind] ?? [])].sort();
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
const agentVersions = {};
|
|
44
|
+
for (const agent of ALL_AGENT_IDS) {
|
|
45
|
+
const versions = listInstalledVersions(agent);
|
|
46
|
+
if (versions.length > 0)
|
|
47
|
+
agentVersions[agent] = [...versions].sort();
|
|
48
|
+
}
|
|
49
|
+
return {
|
|
50
|
+
resources,
|
|
51
|
+
agentVersions,
|
|
52
|
+
repos: {
|
|
53
|
+
agents: toRepoState(readRepoState(getUserAgentsDir())),
|
|
54
|
+
system: toRepoState(readRepoState(getSystemAgentsDir())),
|
|
55
|
+
},
|
|
56
|
+
};
|
|
57
|
+
}
|
|
@@ -136,5 +136,20 @@ export interface RunFleetOptions {
|
|
|
136
136
|
* recorded as `failed` so one bad device never aborts the rest.
|
|
137
137
|
*/
|
|
138
138
|
export declare function runFleet(targets: FleetTarget[], cmd: string[], opts?: RunFleetOptions): FleetRunResult[];
|
|
139
|
+
export interface FanOutDeviceOptions {
|
|
140
|
+
/**
|
|
141
|
+
* Per-device deadline in milliseconds. When set, any probe that does not
|
|
142
|
+
* settle within this window is abandoned via `Promise.race` against a
|
|
143
|
+
* rejection timer and recorded as a `failed` result with the message
|
|
144
|
+
* `'timed out'`. There is no AbortController — the underlying probe
|
|
145
|
+
* continues running in the background; cancellation of the in-flight work
|
|
146
|
+
* is the caller's responsibility. In practice `probeRemoteAuth` relies on
|
|
147
|
+
* `sshExecAsync`'s own 15 s timer to kill the ssh child independently.
|
|
148
|
+
* The per-device ssh timeout passed directly to {@link sshExecAsync} is
|
|
149
|
+
* the first line of defence; this acts as a hard backstop so one slow
|
|
150
|
+
* device can never stall the entire fan-out past its budget.
|
|
151
|
+
*/
|
|
152
|
+
perDeviceTimeoutMs?: number;
|
|
153
|
+
}
|
|
139
154
|
/** Run one async probe per device in parallel, preserving input order. */
|
|
140
|
-
export declare function fanOutDevices<T, Target extends FanOutDeviceTarget = FanOutDeviceTarget>(targets: Target[], probe: (target: Target) => Promise<T
|
|
155
|
+
export declare function fanOutDevices<T, Target extends FanOutDeviceTarget = FanOutDeviceTarget>(targets: Target[], probe: (target: Target) => Promise<T>, opts?: FanOutDeviceOptions): Promise<FanOutDeviceResult<T>[]>;
|
|
@@ -212,7 +212,7 @@ export function runFleet(targets, cmd, opts = {}) {
|
|
|
212
212
|
return results;
|
|
213
213
|
}
|
|
214
214
|
/** Run one async probe per device in parallel, preserving input order. */
|
|
215
|
-
export async function fanOutDevices(targets, probe) {
|
|
215
|
+
export async function fanOutDevices(targets, probe, opts = {}) {
|
|
216
216
|
return Promise.all(targets.map(async (target) => {
|
|
217
217
|
if (target.skip) {
|
|
218
218
|
return {
|
|
@@ -222,10 +222,18 @@ export async function fanOutDevices(targets, probe) {
|
|
|
222
222
|
};
|
|
223
223
|
}
|
|
224
224
|
try {
|
|
225
|
+
let probePromise = probe(target);
|
|
226
|
+
if (opts.perDeviceTimeoutMs) {
|
|
227
|
+
const timeoutMs = opts.perDeviceTimeoutMs;
|
|
228
|
+
probePromise = Promise.race([
|
|
229
|
+
probePromise,
|
|
230
|
+
new Promise((_, reject) => setTimeout(() => reject(new Error('timed out')), timeoutMs)),
|
|
231
|
+
]);
|
|
232
|
+
}
|
|
225
233
|
return {
|
|
226
234
|
name: target.name,
|
|
227
235
|
status: 'ok',
|
|
228
|
-
value: await
|
|
236
|
+
value: await probePromise,
|
|
229
237
|
};
|
|
230
238
|
}
|
|
231
239
|
catch (err) {
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { type DeviceStats } from './health.js';
|
|
2
2
|
import { type HostAuthSummary } from '../auth-health.js';
|
|
3
3
|
import type { OnlineState } from './reachability.js';
|
|
4
|
+
import { type FleetInventory } from './fleet-divergence.js';
|
|
4
5
|
export interface FleetCliStatus {
|
|
5
6
|
installed: boolean;
|
|
6
7
|
path: string | null;
|
|
@@ -40,9 +41,14 @@ export interface FleetHealthRow {
|
|
|
40
41
|
* an offline row. Sourced from the registry's tailscale snapshot / reachability
|
|
41
42
|
* verdict. Undefined when never recorded. */
|
|
42
43
|
lastSeen?: string;
|
|
44
|
+
/** This host's self-reported harness inventory (resources / agent versions /
|
|
45
|
+
* repo state) from its `doctor --json` `fleet` field, for cross-device
|
|
46
|
+
* divergence detection (RUSH-2027). Undefined for an unreachable box or an
|
|
47
|
+
* older CLI that doesn't emit it. */
|
|
48
|
+
inventory?: FleetInventory;
|
|
43
49
|
}
|
|
44
50
|
export interface FleetWarning {
|
|
45
|
-
kind: 'unreachable' | 'drift' | 'cli' | 'version-skew';
|
|
51
|
+
kind: 'unreachable' | 'drift' | 'cli' | 'version-skew' | 'divergence';
|
|
46
52
|
devices: string[];
|
|
47
53
|
message: string;
|
|
48
54
|
}
|
|
@@ -53,7 +59,9 @@ export interface FleetHealthReport {
|
|
|
53
59
|
hasWarnings: boolean;
|
|
54
60
|
hasDrift: boolean;
|
|
55
61
|
}
|
|
56
|
-
export declare function buildFleetHealthReport(rows: FleetHealthRow[], now?: Date
|
|
62
|
+
export declare function buildFleetHealthReport(rows: FleetHealthRow[], now?: Date, opts?: {
|
|
63
|
+
self?: string;
|
|
64
|
+
}): FleetHealthReport;
|
|
57
65
|
export declare function renderFleetWarnings(report: FleetHealthReport): string[];
|
|
58
66
|
export declare function renderFleetMatrix(report: FleetHealthReport): string[];
|
|
59
67
|
/**
|
|
@@ -2,6 +2,7 @@ import chalk from 'chalk';
|
|
|
2
2
|
import { padToWidth, stringWidth, terminalWidth, truncateToWidth } from '../session/width.js';
|
|
3
3
|
import { fmtBytes, headroom } from './health.js';
|
|
4
4
|
import { formatCheckedAge } from '../auth-health.js';
|
|
5
|
+
import { compareFleetInventories } from './fleet-divergence.js';
|
|
5
6
|
function driftRows(row) {
|
|
6
7
|
return row.sync.filter((s) => s.status !== 'fresh');
|
|
7
8
|
}
|
|
@@ -12,7 +13,7 @@ function installedCliCount(row) {
|
|
|
12
13
|
total: statuses.length,
|
|
13
14
|
};
|
|
14
15
|
}
|
|
15
|
-
export function buildFleetHealthReport(rows, now = new Date()) {
|
|
16
|
+
export function buildFleetHealthReport(rows, now = new Date(), opts = {}) {
|
|
16
17
|
const warnings = [];
|
|
17
18
|
const unreachable = rows
|
|
18
19
|
.filter((r) => r.error || r.skipped)
|
|
@@ -60,6 +61,25 @@ export function buildFleetHealthReport(rows, now = new Date()) {
|
|
|
60
61
|
message: `agents-cli version skew: ${Array.from(versions.keys()).sort().join(', ')}`,
|
|
61
62
|
});
|
|
62
63
|
}
|
|
64
|
+
// Cross-device harness divergence (RUSH-2027): compare every device's
|
|
65
|
+
// self-reported inventory against the local baseline and roll the findings up
|
|
66
|
+
// into one warning per affected device. Only runs when a baseline (`self`) is
|
|
67
|
+
// named and at least one device carries an inventory — an older-CLI fleet with
|
|
68
|
+
// no `fleet` field simply produces no divergence warning.
|
|
69
|
+
if (opts.self && rows.some((r) => r.inventory)) {
|
|
70
|
+
const divergence = compareFleetInventories(rows.map((r) => ({ name: r.name, inventory: r.inventory ?? null })), opts.self);
|
|
71
|
+
const byDevice = new Map();
|
|
72
|
+
for (const d of divergence.divergences)
|
|
73
|
+
byDevice.set(d.device, (byDevice.get(d.device) ?? 0) + 1);
|
|
74
|
+
for (const device of Array.from(byDevice.keys()).sort()) {
|
|
75
|
+
const n = byDevice.get(device);
|
|
76
|
+
warnings.push({
|
|
77
|
+
kind: 'divergence',
|
|
78
|
+
devices: [device],
|
|
79
|
+
message: `${device} diverges from ${opts.self} (${n} resource/version/repo gap${n === 1 ? '' : 's'})`,
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
}
|
|
63
83
|
return {
|
|
64
84
|
generatedAt: now.toISOString(),
|
|
65
85
|
devices: rows,
|
|
@@ -326,6 +346,17 @@ export function buildFleetAttentionItems(report, now = Date.now()) {
|
|
|
326
346
|
.join(' · ');
|
|
327
347
|
items.push({ glyph: 'warn', subject: 'version skew', detail: summary, fix: 'agents upgrade --fleet' });
|
|
328
348
|
}
|
|
349
|
+
// 4) Cross-device harness divergence — one item per diverged box (RUSH-2027).
|
|
350
|
+
for (const w of report.warnings) {
|
|
351
|
+
if (w.kind !== 'divergence')
|
|
352
|
+
continue;
|
|
353
|
+
items.push({
|
|
354
|
+
glyph: 'warn',
|
|
355
|
+
subject: w.devices[0] ?? 'fleet',
|
|
356
|
+
detail: w.message.replace(`${w.devices[0]} `, ''),
|
|
357
|
+
fix: `agents apply ${w.devices[0] ?? ''}`.trim(),
|
|
358
|
+
});
|
|
359
|
+
}
|
|
329
360
|
return items;
|
|
330
361
|
}
|
|
331
362
|
/** Right-aligned `<content>` on the same line as `left`, clamped so it never
|
package/dist/lib/events.d.ts
CHANGED
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
* - Performance tracking: withTiming() wrapper for any async function
|
|
13
13
|
*/
|
|
14
14
|
export type EventLevel = 'audit' | 'warn' | 'info' | 'debug';
|
|
15
|
-
export type EventType = 'agent.run.start' | 'agent.run.end' | 'agent.spawn.start' | 'agent.spawn.end' | 'version.install' | 'version.switch' | 'version.remove' | 'skill.install' | 'skill.remove' | 'browser.launch' | 'browser.close' | 'browser.navigate' | 'browser.screenshot' | 'secrets.get' | 'secrets.set' | 'secrets.delete' | 'secrets.rename' | 'cloud.dispatch' | 'cloud.complete' | 'cloud.cancel' | 'cloud.message' | 'teams.create' | 'teams.add' | 'teams.start' | 'teams.complete' | 'teams.disband' | 'hook.fire' | 'hook.complete' | 'hook.error' | 'mcp.add' | 'mcp.remove' | 'mcp.register' | 'resource.sync' | 'rotation.resolved' | 'command.start' | 'command.end' | 'perf.timing' | 'session.start' | 'session.end' | 'plan.created' | 'pr.opened' | 'pr.merged' | 'worktree.created' | 'worktree.removed' | 'commit.created' | 'pushed' | 'subagent.spawned' | 'artifact.created' | 'status.posted' | 'file.edited' | 'error' | 'warn' | 'info' | 'debug';
|
|
15
|
+
export type EventType = 'agent.run.start' | 'agent.run.end' | 'agent.spawn.start' | 'agent.spawn.end' | 'version.install' | 'version.switch' | 'version.remove' | 'skill.install' | 'skill.remove' | 'browser.launch' | 'browser.close' | 'browser.navigate' | 'browser.screenshot' | 'secrets.get' | 'secrets.set' | 'secrets.delete' | 'secrets.rename' | 'cloud.dispatch' | 'cloud.complete' | 'cloud.cancel' | 'cloud.message' | 'teams.create' | 'teams.add' | 'teams.start' | 'teams.complete' | 'teams.disband' | 'hook.fire' | 'hook.complete' | 'hook.error' | 'mcp.add' | 'mcp.remove' | 'mcp.register' | 'resource.sync' | 'rotation.resolved' | 'command.start' | 'command.end' | 'perf.timing' | 'session.start' | 'session.end' | 'plan.created' | 'pr.opened' | 'pr.merged' | 'worktree.created' | 'worktree.removed' | 'commit.created' | 'pushed' | 'subagent.spawned' | 'artifact.created' | 'task.completed' | 'checklist.created' | 'status.posted' | 'file.edited' | 'error' | 'warn' | 'info' | 'debug';
|
|
16
16
|
export declare function levelFor(event: EventType): EventLevel;
|
|
17
17
|
export interface EventMeta {
|
|
18
18
|
ts: string;
|
package/dist/lib/exec.d.ts
CHANGED
|
@@ -197,6 +197,22 @@ export declare function inferredInteractiveWithoutTty(options: Pick<ExecOptions,
|
|
|
197
197
|
export declare function shouldTapStdout(interactive: boolean, piped: boolean, capsActive: boolean, captureTail?: boolean): boolean;
|
|
198
198
|
/** Parse an array of KEY=VALUE strings into an env record. Returns undefined for empty input. */
|
|
199
199
|
export declare function parseExecEnv(entries: string[]): Record<string, string> | undefined;
|
|
200
|
+
/**
|
|
201
|
+
* Resolve the launch id a run exports as `AGENT_LAUNCH_ID`.
|
|
202
|
+
*
|
|
203
|
+
* The launch id is the stable correlation key the SessionStart hook records
|
|
204
|
+
* alongside the agent's real session id (terminals/sessions/<pid>.json), so it
|
|
205
|
+
* is what maps a launch to its exact session even when the hook runs under a
|
|
206
|
+
* different pid (tmux pane leaf / cmd.exe wrapper) — and, across an SSH hop, what
|
|
207
|
+
* lets a `--host` launcher resolve the remote-coined id for agents that never
|
|
208
|
+
* accept a forced `--session-id`.
|
|
209
|
+
*
|
|
210
|
+
* ADOPT a caller-supplied `AGENT_LAUNCH_ID` (a `--host` launcher forwards one via
|
|
211
|
+
* `--env` so it controls the key end-to-end); MINT a fresh one otherwise (every
|
|
212
|
+
* local run, which passes none). A malformed inbound value is ignored in favour
|
|
213
|
+
* of a fresh mint — the key must be a real correlation id, never an empty string.
|
|
214
|
+
*/
|
|
215
|
+
export declare function resolveLaunchId(envLaunchId: string | undefined): string;
|
|
200
216
|
/**
|
|
201
217
|
* Build the process environment for an agent invocation.
|
|
202
218
|
* Pins CLAUDE_CONFIG_DIR for Claude, CODEX_HOME for Codex, and COPILOT_HOME
|
package/dist/lib/exec.js
CHANGED
|
@@ -218,6 +218,25 @@ export function parseExecEnv(entries) {
|
|
|
218
218
|
}
|
|
219
219
|
return Object.fromEntries(entries.map(parseExecEnvEntry));
|
|
220
220
|
}
|
|
221
|
+
/**
|
|
222
|
+
* Resolve the launch id a run exports as `AGENT_LAUNCH_ID`.
|
|
223
|
+
*
|
|
224
|
+
* The launch id is the stable correlation key the SessionStart hook records
|
|
225
|
+
* alongside the agent's real session id (terminals/sessions/<pid>.json), so it
|
|
226
|
+
* is what maps a launch to its exact session even when the hook runs under a
|
|
227
|
+
* different pid (tmux pane leaf / cmd.exe wrapper) — and, across an SSH hop, what
|
|
228
|
+
* lets a `--host` launcher resolve the remote-coined id for agents that never
|
|
229
|
+
* accept a forced `--session-id`.
|
|
230
|
+
*
|
|
231
|
+
* ADOPT a caller-supplied `AGENT_LAUNCH_ID` (a `--host` launcher forwards one via
|
|
232
|
+
* `--env` so it controls the key end-to-end); MINT a fresh one otherwise (every
|
|
233
|
+
* local run, which passes none). A malformed inbound value is ignored in favour
|
|
234
|
+
* of a fresh mint — the key must be a real correlation id, never an empty string.
|
|
235
|
+
*/
|
|
236
|
+
export function resolveLaunchId(envLaunchId) {
|
|
237
|
+
const inbound = envLaunchId?.trim();
|
|
238
|
+
return inbound ? inbound : randomUUID();
|
|
239
|
+
}
|
|
221
240
|
/**
|
|
222
241
|
* Build the process environment for an agent invocation.
|
|
223
242
|
* Pins CLAUDE_CONFIG_DIR for Claude, CODEX_HOME for Codex, and COPILOT_HOME
|
|
@@ -1172,15 +1191,19 @@ async function spawnAgent(options) {
|
|
|
1172
1191
|
// timeout. Spend is recorded to the shared ledger in the close handler. The
|
|
1173
1192
|
// watcher is dormant (and zero-cost) when no caps are configured.
|
|
1174
1193
|
const cwd = options.cwd || process.cwd();
|
|
1175
|
-
//
|
|
1194
|
+
// Resolve the launch id once. It doubles as the budget watcher's run id AND is
|
|
1176
1195
|
// exported to the child as AGENT_LAUNCH_ID, so the agent's SessionStart hook
|
|
1177
1196
|
// records the SAME id in its own state file (terminals/sessions/<pid>.json).
|
|
1178
1197
|
// That id is the join key that reconciles this launch's pid-registry entry
|
|
1179
1198
|
// with the hook's authoritative session id even when the hook runs under a
|
|
1180
1199
|
// different pid (tmux pane leaf / cmd.exe wrapper) — see pid-registry.ts and
|
|
1181
|
-
// session/hook-sessions.ts.
|
|
1182
|
-
//
|
|
1183
|
-
|
|
1200
|
+
// session/hook-sessions.ts. ADOPT a launch id a `--host` launcher already
|
|
1201
|
+
// forwarded (via `--env AGENT_LAUNCH_ID=…`) so ONE correlation key spans the
|
|
1202
|
+
// SSH hop and the launcher can resolve this run's real session id for every
|
|
1203
|
+
// agent, not just Claude (RUSH-2034); mint a fresh one for every local run.
|
|
1204
|
+
// Injected into options.env so every downstream env build (the bare spawn
|
|
1205
|
+
// below AND the tmux env prefix in runInTmux) carries it.
|
|
1206
|
+
const launchId = resolveLaunchId(options.env?.AGENT_LAUNCH_ID);
|
|
1184
1207
|
const runId = launchId;
|
|
1185
1208
|
options = { ...options, env: { ...options.env, AGENT_LAUNCH_ID: launchId } };
|
|
1186
1209
|
const watcherState = await setupBudgetWatcher(options, cwd, runId);
|
package/dist/lib/feed.d.ts
CHANGED
|
@@ -172,7 +172,7 @@ export declare function removeBlock(blockId: string, root?: string): boolean;
|
|
|
172
172
|
* Embedded so it ships with the compiled CLI and can be installed to the
|
|
173
173
|
* CLI-writable user hooks dir without a separate file in the npm tarball.
|
|
174
174
|
*/
|
|
175
|
-
export declare const FEED_PUBLISH_HOOK_SCRIPT = "#!/usr/bin/env python3\n\"\"\"Publish and clear open-block records for `agents feed`.\n\nThe manifest invokes this script for top-level AskUserQuestion calls, waiting\nnotifications, question answers, and session lifecycle events. One atomic file\nper session means a new block replaces the previous block. Answer/resume/stop\nevents remove it so `agents feed` only lists decisions that are still open.\n\nSub-agent gate: when the PreToolUse payload carries `agent_type`, this is a\nTask/Agent subagent -- skip. Only the top-level agent publishes. Verified on\nClaude Code 2.1.170 (2026-07).\n\nFail-open: ANY error is swallowed so a feed hiccup never blocks a tool call.\n\"\"\"\nimport os\nimport sys\nimport json\nimport re\nimport socket\nimport tempfile\nfrom datetime import datetime, timezone\n\nWAITING_NOTIFICATION_TYPES = {\n \"permission_prompt\",\n \"idle_prompt\",\n \"elicitation_dialog\",\n}\nCLEAR_EVENTS = {\n \"PostToolUse\",\n \"Stop\",\n \"SessionEnd\",\n}\n\n\ndef read_json(path):\n try:\n with open(path) as f:\n return json.load(f)\n except Exception:\n return None\n\n\ndef write_json(path, value):\n dir_name = os.path.dirname(path)\n os.makedirs(dir_name, exist_ok=True)\n fd, tmp = tempfile.mkstemp(dir=dir_name, suffix=\".tmp\")\n try:\n with os.fdopen(fd, \"w\") as f:\n json.dump(value, f, indent=2)\n os.replace(tmp, path)\n except Exception:\n try:\n os.unlink(tmp)\n except Exception:\n pass\n\n\ndef main():\n raw = sys.stdin.read()\n try:\n payload = json.loads(raw) if raw.strip() else {}\n except Exception:\n return\n\n # Sub-agent gate.\n if payload.get(\"agent_type\"):\n return\n\n session_id = payload.get(\"session_id\", \"\")\n if not session_id:\n return\n\n safe_session_id = re.sub(r\"[^A-Za-z0-9._-]\", \"-\", session_id)\n block_id = f\"block-{safe_session_id}\"\n home = os.environ.get(\"HOME\") or os.path.expanduser(\"~\")\n feed_dir = os.path.join(home, \".agents\", \".history\", \"feed\")\n answered_dir = os.path.join(feed_dir, \"answered\")\n asks_dir = os.path.join(feed_dir, \"asks\")\n target = os.path.join(feed_dir, f\"{block_id}.json\")\n hook_event = payload.get(\"hook_event_name\", \"PreToolUse\")\n\n if hook_event in CLEAR_EVENTS:\n try:\n os.unlink(target)\n except FileNotFoundError:\n pass\n except Exception:\n pass\n # Also clear the answered marker so a future question for this session\n # is not permanently locked.\n try:\n os.unlink(os.path.join(answered_dir, f\"{block_id}.json\"))\n except FileNotFoundError:\n pass\n except Exception:\n pass\n return\n\n # Terminal answers (human typed in the TUI) record an answered marker and\n # remove the block file so the feed stops showing it within one poll cycle.\n # The marker stays behind so a concurrent surface cannot double-answer.\n if hook_event == \"UserPromptSubmit\":\n os.makedirs(answered_dir, exist_ok=True)\n marker = os.path.join(answered_dir, f\"{block_id}.json\")\n try:\n fd = os.open(marker, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o644)\n record = {\n \"answeredAt\": datetime.now(timezone.utc).isoformat(),\n \"answeredFrom\": \"terminal\",\n }\n with os.fdopen(fd, \"w\") as f:\n json.dump(record, f, indent=2)\n except FileExistsError:\n pass\n except Exception:\n pass\n # Remove the visible block so the feed drops the answered question.\n try:\n os.unlink(target)\n except FileNotFoundError:\n pass\n except Exception:\n pass\n return\n\n notification_type = None\n if hook_event == \"Notification\":\n notification_type = payload.get(\"notification_type\", \"\")\n if notification_type not in WAITING_NOTIFICATION_TYPES:\n return\n # Claude emits a generic permission notification after presenting an\n # AskUserQuestion. Keep the structured questions and options already\n # published for this session instead of replacing them with that less\n # useful notification text.\n try:\n with open(target) as existing_file:\n existing = json.load(existing_file)\n if existing.get(\"kind\") == \"question\":\n return\n except Exception:\n pass\n message = payload.get(\"message\", \"\")\n if not message:\n return\n normalized_questions = [{\n \"text\": message,\n \"header\": payload.get(\"title\") or notification_type.replace(\"_\", \" \").title(),\n \"multiSelect\": False,\n }]\n kind = \"notification\"\n else:\n tool_input = payload.get(\"tool_input\", {})\n questions = tool_input.get(\"questions\", [])\n if not questions:\n return\n normalized_questions = []\n for q in questions:\n if not isinstance(q, dict):\n continue\n question = {\n \"text\": q.get(\"question\", q.get(\"header\", \"\")),\n \"header\": q.get(\"header\"),\n \"multiSelect\": q.get(\"multiSelect\", False),\n }\n raw_opts = q.get(\"options\", [])\n if raw_opts:\n question[\"options\"] = [\n {\"label\": o.get(\"label\", \"\"), \"description\": o.get(\"description\")}\n for o in raw_opts\n if isinstance(o, dict)\n ]\n normalized_questions.append(question)\n if not normalized_questions:\n return\n kind = \"question\"\n\n # Identity from env (set by agents-cli at spawn).\n mailbox_id = os.path.basename(\n os.environ.get(\"AGENTS_MAILBOX_DIR\", \"\").rstrip(\"/\")\n ) or session_id\n\n now_iso = datetime.now(timezone.utc).isoformat()\n stats_path = os.path.join(asks_dir, f\"{safe_session_id}.json\")\n stats = read_json(stats_path) or {}\n recent = stats.get(\"recentAskTimestamps\") if isinstance(stats, dict) else []\n if not isinstance(recent, list):\n recent = []\n recent.append(now_iso)\n # Keep enough history for rolling one-hour needy detection without unbounded\n # per-session files. The TypeScript reader applies the exact time window.\n recent = recent[-200:]\n write_json(stats_path, {\n \"sessionId\": session_id,\n \"mailboxId\": mailbox_id,\n \"firstAskAt\": stats.get(\"firstAskAt\") or now_iso,\n \"lastAskAt\": now_iso,\n \"totalAskCount\": int(stats.get(\"totalAskCount\") or 0) + 1,\n \"recentAskTimestamps\": recent,\n })\n\n hostname = os.environ.get(\"AGENTS_SYNC_MACHINE_ID\") or socket.gethostname()\n host = hostname.split(\".\")[0].strip().lower()\n host = re.sub(r\"[^a-z0-9_-]\", \"-\", host) or \"unknown\"\n\n runtime = os.environ.get(\"AGENTS_RUNTIME\", \"headless\")\n\n block = {\n \"blockId\": block_id,\n \"sessionId\": session_id,\n \"mailboxId\": mailbox_id,\n \"host\": host,\n \"runtime\": runtime,\n \"ts\": now_iso,\n \"questions\": normalized_questions,\n \"kind\": kind,\n }\n if notification_type:\n block[\"notificationType\"] = notification_type\n\n # Optional multi-operator control metadata passed by the agent in the\n # AskUserQuestion tool_input. Defaults keep the existing behavior.\n controls = payload.get(\"tool_input\", {}) if hook_event != \"Notification\" else {}\n block_class = controls.get(\"blockClass\") if isinstance(controls, dict) else None\n if block_class in (\"approval\", \"decision\"):\n block[\"blockClass\"] = block_class\n consequence = controls.get(\"consequence\") if isinstance(controls, dict) else None\n if consequence:\n block[\"consequence\"] = consequence\n allowed = controls.get(\"allowedOperators\") if isinstance(controls, dict) else None\n if isinstance(allowed, list):\n block[\"allowedOperators\"] = [str(a) for a in allowed]\n timeout = controls.get(\"timeoutMinutes\") if isinstance(controls, dict) else None\n if isinstance(timeout, (int, float)) and timeout > 0:\n block[\"timeoutMinutes\"] = int(timeout)\n safe_default = controls.get(\"safeDefault\") if isinstance(controls, dict) else None\n if isinstance(safe_default, str):\n block[\"safeDefault\"] = safe_default\n cost = controls.get(\"costOfDelay\") if isinstance(controls, dict) else None\n if cost in (\"low\", \"medium\", \"high\"):\n block[\"costOfDelay\"] = cost\n\n # Publishing a new question clears any stale answered marker from the\n # previous question in this session.\n try:\n os.unlink(os.path.join(answered_dir, f\"{block_id}.json\"))\n except FileNotFoundError:\n pass\n except Exception:\n pass\n\n # Python's expanduser() ignores HOME on Windows, while agents-cli honors a\n # HOME override on every platform. Use the same anchor so hooks and the CLI\n # always read/write one feed store (including temp-home and sandbox runs).\n os.makedirs(feed_dir, exist_ok=True)\n\n fd, tmp = tempfile.mkstemp(dir=feed_dir, suffix=\".tmp\")\n try:\n with os.fdopen(fd, \"w\") as f:\n json.dump(block, f, indent=2)\n os.replace(tmp, target)\n except Exception:\n try:\n os.unlink(tmp)\n except Exception:\n pass\n\n\nif __name__ == \"__main__\":\n try:\n main()\n except Exception:\n pass # fail open\n";
|
|
175
|
+
export declare const FEED_PUBLISH_HOOK_SCRIPT = "#!/usr/bin/env python3\n\"\"\"Publish and clear open-block records for `agents feed`.\n\nThe manifest invokes this script for top-level AskUserQuestion calls, waiting\nnotifications, question answers, and session lifecycle events. One atomic file\nper session means a new block replaces the previous block. Answer/resume/stop\nevents remove it so `agents feed` only lists decisions that are still open.\n\nSub-agent gate: when the PreToolUse payload carries `agent_type`, this is a\nTask/Agent subagent -- skip. Only the top-level agent publishes. Verified on\nClaude Code 2.1.170 (2026-07).\n\nFail-open: ANY error is swallowed so a feed hiccup never blocks a tool call.\n\"\"\"\nimport os\nimport sys\nimport json\nimport re\nimport socket\nimport tempfile\nfrom datetime import datetime, timezone\n\nWAITING_NOTIFICATION_TYPES = {\n \"permission_prompt\",\n \"idle_prompt\",\n \"elicitation_dialog\",\n}\nCLEAR_EVENTS = {\n \"PostToolUse\",\n \"Stop\",\n \"SessionEnd\",\n}\n# Codex emits a PermissionRequest event (not Claude's Notification) when it\n# blocks on an approval prompt. Claude never fires PermissionRequest, so the\n# same script handles both: PermissionRequest maps to an approval-class block\n# with a high cost-of-delay so 'agents feed --dispatch' pages it as urgent.\n\n\ndef read_json(path):\n try:\n with open(path) as f:\n return json.load(f)\n except Exception:\n return None\n\n\ndef write_json(path, value):\n dir_name = os.path.dirname(path)\n os.makedirs(dir_name, exist_ok=True)\n fd, tmp = tempfile.mkstemp(dir=dir_name, suffix=\".tmp\")\n try:\n with os.fdopen(fd, \"w\") as f:\n json.dump(value, f, indent=2)\n os.replace(tmp, path)\n except Exception:\n try:\n os.unlink(tmp)\n except Exception:\n pass\n\n\ndef main():\n raw = sys.stdin.read()\n try:\n payload = json.loads(raw) if raw.strip() else {}\n except Exception:\n return\n\n # Sub-agent gate.\n if payload.get(\"agent_type\"):\n return\n\n session_id = payload.get(\"session_id\", \"\")\n if not session_id:\n return\n\n safe_session_id = re.sub(r\"[^A-Za-z0-9._-]\", \"-\", session_id)\n block_id = f\"block-{safe_session_id}\"\n home = os.environ.get(\"HOME\") or os.path.expanduser(\"~\")\n feed_dir = os.path.join(home, \".agents\", \".history\", \"feed\")\n answered_dir = os.path.join(feed_dir, \"answered\")\n asks_dir = os.path.join(feed_dir, \"asks\")\n target = os.path.join(feed_dir, f\"{block_id}.json\")\n hook_event = payload.get(\"hook_event_name\", \"PreToolUse\")\n\n if hook_event in CLEAR_EVENTS:\n # A matcher-less PostToolUse clear (registered for Codex so an approved\n # tool clears its approval card) must NOT wipe an open AskUserQuestion\n # while an unrelated tool runs mid-question -- those are cleared only by\n # the AskUserQuestion-matched PostToolUse. So on PostToolUse, keep a\n # 'question' block; approval/notification blocks clear once the tool runs.\n if hook_event == \"PostToolUse\":\n try:\n with open(target) as existing_file:\n existing = json.load(existing_file)\n if existing.get(\"kind\") == \"question\" and payload.get(\"tool_name\") != \"AskUserQuestion\":\n return\n except Exception:\n pass\n try:\n os.unlink(target)\n except FileNotFoundError:\n pass\n except Exception:\n pass\n # Also clear the answered marker so a future question for this session\n # is not permanently locked.\n try:\n os.unlink(os.path.join(answered_dir, f\"{block_id}.json\"))\n except FileNotFoundError:\n pass\n except Exception:\n pass\n return\n\n # Terminal answers (human typed in the TUI) record an answered marker and\n # remove the block file so the feed stops showing it within one poll cycle.\n # The marker stays behind so a concurrent surface cannot double-answer.\n if hook_event == \"UserPromptSubmit\":\n os.makedirs(answered_dir, exist_ok=True)\n marker = os.path.join(answered_dir, f\"{block_id}.json\")\n try:\n fd = os.open(marker, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o644)\n record = {\n \"answeredAt\": datetime.now(timezone.utc).isoformat(),\n \"answeredFrom\": \"terminal\",\n }\n with os.fdopen(fd, \"w\") as f:\n json.dump(record, f, indent=2)\n except FileExistsError:\n pass\n except Exception:\n pass\n # Remove the visible block so the feed drops the answered question.\n try:\n os.unlink(target)\n except FileNotFoundError:\n pass\n except Exception:\n pass\n return\n\n notification_type = None\n codex_approval = False\n if hook_event == \"Notification\":\n notification_type = payload.get(\"notification_type\", \"\")\n if notification_type not in WAITING_NOTIFICATION_TYPES:\n return\n # Claude emits a generic permission notification after presenting an\n # AskUserQuestion. Keep the structured questions and options already\n # published for this session instead of replacing them with that less\n # useful notification text.\n try:\n with open(target) as existing_file:\n existing = json.load(existing_file)\n if existing.get(\"kind\") == \"question\":\n return\n except Exception:\n pass\n message = payload.get(\"message\", \"\")\n if not message:\n return\n normalized_questions = [{\n \"text\": message,\n \"header\": payload.get(\"title\") or notification_type.replace(\"_\", \" \").title(),\n \"multiSelect\": False,\n }]\n kind = \"notification\"\n elif hook_event == \"PermissionRequest\":\n # Codex approval prompt. The payload mirrors PreToolUse (tool_name,\n # tool_input) but carries no questions -- Codex is asking to run a tool,\n # not asking the operator a multiple-choice question. Publish it as a\n # notification-kind approval block naming the tool so the feed and the\n # phone notifier can surface it, and so the Factory extension can bridge\n # it to a VS Code notification.\n tool_name = payload.get(\"tool_name\") or \"a tool\"\n tool_input = payload.get(\"tool_input\", {})\n command = \"\"\n if isinstance(tool_input, dict):\n command = (\n tool_input.get(\"command\")\n or tool_input.get(\"cmd\")\n or tool_input.get(\"path\")\n or \"\"\n )\n if isinstance(command, list):\n command = \" \".join(str(c) for c in command)\n detail = f\": {command}\" if command else \"\"\n normalized_questions = [{\n \"text\": f\"Codex needs approval to run {tool_name}{detail}\",\n \"header\": \"Approval needed\",\n \"multiSelect\": False,\n }]\n kind = \"notification\"\n notification_type = \"permission_prompt\"\n codex_approval = True\n else:\n tool_input = payload.get(\"tool_input\", {})\n questions = tool_input.get(\"questions\", [])\n if not questions:\n return\n normalized_questions = []\n for q in questions:\n if not isinstance(q, dict):\n continue\n question = {\n \"text\": q.get(\"question\", q.get(\"header\", \"\")),\n \"header\": q.get(\"header\"),\n \"multiSelect\": q.get(\"multiSelect\", False),\n }\n raw_opts = q.get(\"options\", [])\n if raw_opts:\n question[\"options\"] = [\n {\"label\": o.get(\"label\", \"\"), \"description\": o.get(\"description\")}\n for o in raw_opts\n if isinstance(o, dict)\n ]\n normalized_questions.append(question)\n if not normalized_questions:\n return\n kind = \"question\"\n\n # Identity from env (set by agents-cli at spawn).\n mailbox_id = os.path.basename(\n os.environ.get(\"AGENTS_MAILBOX_DIR\", \"\").rstrip(\"/\")\n ) or session_id\n\n now_iso = datetime.now(timezone.utc).isoformat()\n stats_path = os.path.join(asks_dir, f\"{safe_session_id}.json\")\n stats = read_json(stats_path) or {}\n recent = stats.get(\"recentAskTimestamps\") if isinstance(stats, dict) else []\n if not isinstance(recent, list):\n recent = []\n recent.append(now_iso)\n # Keep enough history for rolling one-hour needy detection without unbounded\n # per-session files. The TypeScript reader applies the exact time window.\n recent = recent[-200:]\n write_json(stats_path, {\n \"sessionId\": session_id,\n \"mailboxId\": mailbox_id,\n \"firstAskAt\": stats.get(\"firstAskAt\") or now_iso,\n \"lastAskAt\": now_iso,\n \"totalAskCount\": int(stats.get(\"totalAskCount\") or 0) + 1,\n \"recentAskTimestamps\": recent,\n })\n\n hostname = os.environ.get(\"AGENTS_SYNC_MACHINE_ID\") or socket.gethostname()\n host = hostname.split(\".\")[0].strip().lower()\n host = re.sub(r\"[^a-z0-9_-]\", \"-\", host) or \"unknown\"\n\n runtime = os.environ.get(\"AGENTS_RUNTIME\", \"headless\")\n\n block = {\n \"blockId\": block_id,\n \"sessionId\": session_id,\n \"mailboxId\": mailbox_id,\n \"host\": host,\n \"runtime\": runtime,\n \"ts\": now_iso,\n \"questions\": normalized_questions,\n \"kind\": kind,\n }\n if notification_type:\n block[\"notificationType\"] = notification_type\n\n # A Codex PermissionRequest is a real approval gate: mark it approval-class\n # with a high cost-of-delay so 'agents feed --dispatch' classifies it urgent\n # (isPhoneUrgent gates on costOfDelay >= phoneNotifyThreshold, default\n # 'medium') and pages the phone. A plain 'deny' is the safe default.\n if codex_approval:\n block[\"blockClass\"] = \"approval\"\n block[\"costOfDelay\"] = \"high\"\n block[\"safeDefault\"] = \"deny\"\n\n # Optional multi-operator control metadata passed by the agent in the\n # AskUserQuestion tool_input. Defaults keep the existing behavior. A Codex\n # PermissionRequest carries tool ARGS in tool_input (command/path), not\n # operator controls, so it is excluded here -- its class/cost is stamped\n # above from codex_approval.\n controls = payload.get(\"tool_input\", {}) if hook_event not in (\"Notification\", \"PermissionRequest\") else {}\n block_class = controls.get(\"blockClass\") if isinstance(controls, dict) else None\n if block_class in (\"approval\", \"decision\"):\n block[\"blockClass\"] = block_class\n consequence = controls.get(\"consequence\") if isinstance(controls, dict) else None\n if consequence:\n block[\"consequence\"] = consequence\n allowed = controls.get(\"allowedOperators\") if isinstance(controls, dict) else None\n if isinstance(allowed, list):\n block[\"allowedOperators\"] = [str(a) for a in allowed]\n timeout = controls.get(\"timeoutMinutes\") if isinstance(controls, dict) else None\n if isinstance(timeout, (int, float)) and timeout > 0:\n block[\"timeoutMinutes\"] = int(timeout)\n safe_default = controls.get(\"safeDefault\") if isinstance(controls, dict) else None\n if isinstance(safe_default, str):\n block[\"safeDefault\"] = safe_default\n cost = controls.get(\"costOfDelay\") if isinstance(controls, dict) else None\n if cost in (\"low\", \"medium\", \"high\"):\n block[\"costOfDelay\"] = cost\n\n # Publishing a new question clears any stale answered marker from the\n # previous question in this session.\n try:\n os.unlink(os.path.join(answered_dir, f\"{block_id}.json\"))\n except FileNotFoundError:\n pass\n except Exception:\n pass\n\n # Python's expanduser() ignores HOME on Windows, while agents-cli honors a\n # HOME override on every platform. Use the same anchor so hooks and the CLI\n # always read/write one feed store (including temp-home and sandbox runs).\n os.makedirs(feed_dir, exist_ok=True)\n\n fd, tmp = tempfile.mkstemp(dir=feed_dir, suffix=\".tmp\")\n try:\n with os.fdopen(fd, \"w\") as f:\n json.dump(block, f, indent=2)\n os.replace(tmp, target)\n except Exception:\n try:\n os.unlink(tmp)\n except Exception:\n pass\n\n\nif __name__ == \"__main__\":\n try:\n main()\n except Exception:\n pass # fail open\n";
|
|
176
176
|
/** Manifest entry for the feed-publish hook, matching the ManifestHook shape. */
|
|
177
177
|
export declare const FEED_PUBLISH_HOOK_MANIFEST: {
|
|
178
178
|
name: string;
|
|
@@ -188,6 +188,12 @@ export declare const FEED_NOTIFICATION_HOOK_MANIFEST: {
|
|
|
188
188
|
script: string;
|
|
189
189
|
timeout: number;
|
|
190
190
|
};
|
|
191
|
+
export declare const FEED_PERMISSION_HOOK_MANIFEST: {
|
|
192
|
+
name: string;
|
|
193
|
+
events: string[];
|
|
194
|
+
script: string;
|
|
195
|
+
timeout: number;
|
|
196
|
+
};
|
|
191
197
|
export declare const FEED_ANSWERED_HOOK_MANIFEST: {
|
|
192
198
|
name: string;
|
|
193
199
|
events: string[];
|