@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.
Files changed (119) hide show
  1. package/CHANGELOG.md +264 -0
  2. package/README.md +2 -0
  3. package/dist/bin/agents +0 -0
  4. package/dist/commands/doctor.d.ts +9 -0
  5. package/dist/commands/doctor.js +143 -9
  6. package/dist/commands/exec.js +87 -19
  7. package/dist/commands/repo.js +8 -0
  8. package/dist/commands/routines.js +275 -15
  9. package/dist/commands/secrets.js +65 -17
  10. package/dist/commands/sessions-browser.d.ts +45 -3
  11. package/dist/commands/sessions-browser.js +128 -12
  12. package/dist/commands/sessions-export.d.ts +3 -0
  13. package/dist/commands/sessions-export.js +13 -10
  14. package/dist/commands/sessions-picker.d.ts +28 -1
  15. package/dist/commands/sessions-picker.js +222 -15
  16. package/dist/commands/sessions.d.ts +117 -4
  17. package/dist/commands/sessions.js +350 -62
  18. package/dist/commands/ssh.d.ts +13 -0
  19. package/dist/commands/ssh.js +48 -3
  20. package/dist/index.js +5 -6
  21. package/dist/lib/activity.d.ts +5 -4
  22. package/dist/lib/activity.js +450 -36
  23. package/dist/lib/agents.d.ts +0 -21
  24. package/dist/lib/agents.js +0 -37
  25. package/dist/lib/auto-pull.d.ts +16 -8
  26. package/dist/lib/auto-pull.js +23 -28
  27. package/dist/lib/cloud/session-index.js +2 -2
  28. package/dist/lib/daemon.d.ts +9 -32
  29. package/dist/lib/daemon.js +70 -84
  30. package/dist/lib/devices/fleet-divergence.d.ts +101 -0
  31. package/dist/lib/devices/fleet-divergence.js +188 -0
  32. package/dist/lib/devices/fleet-inventory.d.ts +19 -0
  33. package/dist/lib/devices/fleet-inventory.js +57 -0
  34. package/dist/lib/devices/fleet.d.ts +16 -1
  35. package/dist/lib/devices/fleet.js +10 -2
  36. package/dist/lib/devices/health-report.d.ts +10 -2
  37. package/dist/lib/devices/health-report.js +32 -1
  38. package/dist/lib/events.d.ts +1 -1
  39. package/dist/lib/exec.d.ts +16 -0
  40. package/dist/lib/exec.js +27 -4
  41. package/dist/lib/feed.d.ts +7 -1
  42. package/dist/lib/feed.js +96 -6
  43. package/dist/lib/git.d.ts +13 -0
  44. package/dist/lib/git.js +102 -4
  45. package/dist/lib/heal.d.ts +7 -4
  46. package/dist/lib/heal.js +10 -22
  47. package/dist/lib/hosts/dispatch.d.ts +9 -0
  48. package/dist/lib/hosts/dispatch.js +24 -4
  49. package/dist/lib/hosts/passthrough.js +7 -2
  50. package/dist/lib/hosts/remote-cmd.d.ts +11 -0
  51. package/dist/lib/hosts/remote-cmd.js +33 -8
  52. package/dist/lib/hosts/remote-session-id.d.ts +45 -0
  53. package/dist/lib/hosts/remote-session-id.js +84 -0
  54. package/dist/lib/hosts/run-target.d.ts +4 -0
  55. package/dist/lib/hosts/run-target.js +5 -1
  56. package/dist/lib/hosts/session-index.js +3 -3
  57. package/dist/lib/menubar/MenubarHelper.app/Contents/Info.plist +2 -0
  58. package/dist/lib/menubar/MenubarHelper.app/Contents/MacOS/MenubarHelper +0 -0
  59. package/dist/lib/menubar/MenubarHelper.app/Contents/Resources/AppIcon.icns +0 -0
  60. package/dist/lib/menubar/MenubarHelper.app/Contents/_CodeSignature/CodeResources +15 -2
  61. package/dist/lib/menubar/install-menubar.d.ts +9 -0
  62. package/dist/lib/menubar/install-menubar.js +14 -0
  63. package/dist/lib/menubar/notify-desktop.d.ts +44 -0
  64. package/dist/lib/menubar/notify-desktop.js +78 -0
  65. package/dist/lib/overdue.d.ts +4 -5
  66. package/dist/lib/overdue.js +8 -41
  67. package/dist/lib/routine-notify.d.ts +76 -0
  68. package/dist/lib/routine-notify.js +190 -0
  69. package/dist/lib/routines-placement.d.ts +38 -0
  70. package/dist/lib/routines-placement.js +79 -0
  71. package/dist/lib/routines-project.d.ts +97 -0
  72. package/dist/lib/routines-project.js +349 -0
  73. package/dist/lib/routines.d.ts +68 -0
  74. package/dist/lib/routines.js +74 -7
  75. package/dist/lib/runner.d.ts +12 -2
  76. package/dist/lib/runner.js +156 -28
  77. package/dist/lib/sandbox.js +0 -6
  78. package/dist/lib/secrets/Agents CLI.app/Contents/CodeResources +0 -0
  79. package/dist/lib/secrets/Agents CLI.app/Contents/Info.plist +2 -0
  80. package/dist/lib/secrets/Agents CLI.app/Contents/MacOS/Agents CLI +0 -0
  81. package/dist/lib/secrets/Agents CLI.app/Contents/Resources/AppIcon.icns +0 -0
  82. package/dist/lib/secrets/Agents CLI.app/Contents/_CodeSignature/CodeResources +13 -1
  83. package/dist/lib/secrets/agent.d.ts +11 -4
  84. package/dist/lib/secrets/agent.js +46 -24
  85. package/dist/lib/secrets/bundles.d.ts +21 -7
  86. package/dist/lib/secrets/bundles.js +82 -38
  87. package/dist/lib/secrets/index.d.ts +11 -2
  88. package/dist/lib/secrets/index.js +21 -9
  89. package/dist/lib/secrets/scope.d.ts +26 -0
  90. package/dist/lib/secrets/scope.js +29 -0
  91. package/dist/lib/secrets/session-store.d.ts +16 -2
  92. package/dist/lib/secrets/session-store.js +118 -15
  93. package/dist/lib/secrets/unlock-hints.d.ts +27 -0
  94. package/dist/lib/secrets/unlock-hints.js +36 -0
  95. package/dist/lib/session/active.d.ts +17 -0
  96. package/dist/lib/session/active.js +105 -7
  97. package/dist/lib/session/db.d.ts +43 -0
  98. package/dist/lib/session/db.js +191 -6
  99. package/dist/lib/session/digest.js +126 -21
  100. package/dist/lib/session/discover.d.ts +28 -3
  101. package/dist/lib/session/discover.js +110 -14
  102. package/dist/lib/session/origin-machine.d.ts +18 -0
  103. package/dist/lib/session/origin-machine.js +34 -0
  104. package/dist/lib/session/parse.js +11 -2
  105. package/dist/lib/session/prompt.d.ts +7 -0
  106. package/dist/lib/session/prompt.js +37 -0
  107. package/dist/lib/session/provenance.d.ts +7 -0
  108. package/dist/lib/session/render.js +18 -16
  109. package/dist/lib/session/state.d.ts +5 -0
  110. package/dist/lib/session/state.js +94 -12
  111. package/dist/lib/session/sync/config.js +2 -2
  112. package/dist/lib/session/types.d.ts +25 -1
  113. package/dist/lib/session/types.js +8 -0
  114. package/dist/lib/smart-launch.d.ts +86 -0
  115. package/dist/lib/smart-launch.js +172 -0
  116. package/dist/lib/state.d.ts +7 -3
  117. package/dist/lib/state.js +20 -5
  118. package/dist/lib/types.d.ts +10 -0
  119. 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>): Promise<FanOutDeviceResult<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 probe(target),
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): FleetHealthReport;
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
@@ -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;
@@ -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
- // Mint the launch id once. It doubles as the budget watcher's run id AND is
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. Injected into options.env so every downstream env
1182
- // build (the bare spawn below AND the tmux env prefix in runInTmux) carries it.
1183
- const launchId = randomUUID();
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);
@@ -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[];