@phnx-labs/agents-cli 1.20.71 → 1.20.73

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 (238) hide show
  1. package/CHANGELOG.md +325 -1
  2. package/README.md +40 -4
  3. package/dist/bin/agents +0 -0
  4. package/dist/commands/activity.d.ts +12 -0
  5. package/dist/commands/activity.js +68 -0
  6. package/dist/commands/apply.d.ts +13 -0
  7. package/dist/commands/apply.js +28 -3
  8. package/dist/commands/browser.js +1 -1
  9. package/dist/commands/cloud.js +21 -11
  10. package/dist/commands/commands.js +2 -0
  11. package/dist/commands/doctor.js +8 -4
  12. package/dist/commands/events.d.ts +11 -10
  13. package/dist/commands/events.js +30 -21
  14. package/dist/commands/exec.d.ts +34 -0
  15. package/dist/commands/exec.js +337 -39
  16. package/dist/commands/feed.d.ts +13 -0
  17. package/dist/commands/feed.js +130 -29
  18. package/dist/commands/hq.d.ts +2 -0
  19. package/dist/commands/hq.js +58 -0
  20. package/dist/commands/lease.d.ts +19 -0
  21. package/dist/commands/lease.js +138 -11
  22. package/dist/commands/login.d.ts +2 -0
  23. package/dist/commands/login.js +123 -0
  24. package/dist/commands/logs.js +39 -10
  25. package/dist/commands/mcp.js +2 -0
  26. package/dist/commands/mine.d.ts +27 -0
  27. package/dist/commands/mine.js +207 -0
  28. package/dist/commands/monitors.js +79 -49
  29. package/dist/commands/plugins.js +1 -0
  30. package/dist/commands/profiles.js +158 -0
  31. package/dist/commands/repo.js +2 -60
  32. package/dist/commands/resource-view.d.ts +2 -0
  33. package/dist/commands/resource-view.js +8 -0
  34. package/dist/commands/resources.d.ts +5 -0
  35. package/dist/commands/resources.js +95 -0
  36. package/dist/commands/routines.d.ts +2 -0
  37. package/dist/commands/routines.js +139 -37
  38. package/dist/commands/secrets.js +234 -47
  39. package/dist/commands/send.d.ts +15 -0
  40. package/dist/commands/send.js +62 -0
  41. package/dist/commands/sessions-tail.d.ts +1 -0
  42. package/dist/commands/sessions-tail.js +20 -9
  43. package/dist/commands/sessions.d.ts +6 -0
  44. package/dist/commands/sessions.js +26 -3
  45. package/dist/commands/setup-mine.d.ts +18 -0
  46. package/dist/commands/setup-mine.js +106 -0
  47. package/dist/commands/setup-share.js +2 -2
  48. package/dist/commands/setup.js +3 -1
  49. package/dist/commands/share.d.ts +8 -1
  50. package/dist/commands/share.js +122 -37
  51. package/dist/commands/skills.js +2 -0
  52. package/dist/commands/ssh.d.ts +9 -0
  53. package/dist/commands/ssh.js +196 -26
  54. package/dist/commands/status.js +8 -2
  55. package/dist/commands/subagents.js +3 -1
  56. package/dist/commands/sync.js +1 -1
  57. package/dist/commands/teams.js +11 -2
  58. package/dist/commands/uninstall.d.ts +11 -0
  59. package/dist/commands/uninstall.js +158 -0
  60. package/dist/commands/utils.d.ts +29 -0
  61. package/dist/commands/utils.js +24 -0
  62. package/dist/commands/versions.js +120 -11
  63. package/dist/index.js +83 -12
  64. package/dist/lib/activity.d.ts +124 -0
  65. package/dist/lib/activity.js +542 -0
  66. package/dist/lib/agents.js +16 -5
  67. package/dist/lib/artifact-actions.d.ts +1 -1
  68. package/dist/lib/artifact-actions.js +1 -1
  69. package/dist/lib/ask-classifier.d.ts +2 -1
  70. package/dist/lib/ask-classifier.js +3 -3
  71. package/dist/lib/auth-health.d.ts +32 -4
  72. package/dist/lib/auth-health.js +40 -1
  73. package/dist/lib/auto-pull.js +8 -1
  74. package/dist/lib/brand.d.ts +40 -0
  75. package/dist/lib/brand.js +122 -0
  76. package/dist/lib/browser/drivers/ssh.js +4 -7
  77. package/dist/lib/browser/service.js +6 -8
  78. package/dist/lib/channels/providers/index.d.ts +2 -0
  79. package/dist/lib/channels/providers/index.js +20 -0
  80. package/dist/lib/channels/providers/mailbox.d.ts +2 -0
  81. package/dist/lib/channels/providers/mailbox.js +26 -0
  82. package/dist/lib/channels/providers/openclaw-telegram.d.ts +2 -0
  83. package/dist/lib/channels/providers/openclaw-telegram.js +34 -0
  84. package/dist/lib/channels/providers/rush.d.ts +6 -0
  85. package/dist/lib/channels/providers/rush.js +55 -0
  86. package/dist/lib/channels/registry.d.ts +42 -0
  87. package/dist/lib/channels/registry.js +20 -0
  88. package/dist/lib/channels/resolve.d.ts +11 -0
  89. package/dist/lib/channels/resolve.js +12 -0
  90. package/dist/lib/cli-resources.d.ts +18 -0
  91. package/dist/lib/cli-resources.js +53 -4
  92. package/dist/lib/cloud/codex.js +6 -3
  93. package/dist/lib/cloud/factory.d.ts +1 -0
  94. package/dist/lib/cloud/factory.js +10 -4
  95. package/dist/lib/cloud/rush.d.ts +3 -1
  96. package/dist/lib/cloud/rush.js +6 -1
  97. package/dist/lib/cloud/types.d.ts +2 -0
  98. package/dist/lib/codex-home.d.ts +35 -0
  99. package/dist/lib/codex-home.js +136 -0
  100. package/dist/lib/crabbox/cli.d.ts +44 -0
  101. package/dist/lib/crabbox/cli.js +120 -20
  102. package/dist/lib/crabbox/lease.d.ts +52 -1
  103. package/dist/lib/crabbox/lease.js +117 -16
  104. package/dist/lib/crabbox/progress.d.ts +31 -0
  105. package/dist/lib/crabbox/progress.js +76 -0
  106. package/dist/lib/crabbox/runtimes.d.ts +4 -0
  107. package/dist/lib/crabbox/runtimes.js +33 -8
  108. package/dist/lib/crabbox/setup-copy.d.ts +87 -0
  109. package/dist/lib/crabbox/setup-copy.js +127 -0
  110. package/dist/lib/daemon.d.ts +14 -5
  111. package/dist/lib/daemon.js +52 -9
  112. package/dist/lib/drive-sync.js +6 -3
  113. package/dist/lib/event-stream.d.ts +36 -0
  114. package/dist/lib/event-stream.js +68 -0
  115. package/dist/lib/events.d.ts +1 -1
  116. package/dist/lib/exec.d.ts +11 -0
  117. package/dist/lib/exec.js +23 -2
  118. package/dist/lib/feed-policy.d.ts +1 -1
  119. package/dist/lib/feed-policy.js +14 -9
  120. package/dist/lib/feed-ranking.d.ts +32 -0
  121. package/dist/lib/feed-ranking.js +224 -0
  122. package/dist/lib/feed.d.ts +35 -2
  123. package/dist/lib/feed.js +66 -3
  124. package/dist/lib/fleet/auth-sync.d.ts +53 -0
  125. package/dist/lib/fleet/auth-sync.js +92 -0
  126. package/dist/lib/fleet/remote-login.d.ts +187 -0
  127. package/dist/lib/fleet/remote-login.js +556 -0
  128. package/dist/lib/format.d.ts +30 -3
  129. package/dist/lib/format.js +34 -5
  130. package/dist/lib/fs-atomic.d.ts +7 -2
  131. package/dist/lib/fs-atomic.js +8 -12
  132. package/dist/lib/git.d.ts +16 -0
  133. package/dist/lib/git.js +79 -2
  134. package/dist/lib/heal.js +11 -3
  135. package/dist/lib/hosts/logs.d.ts +12 -0
  136. package/dist/lib/hosts/logs.js +18 -0
  137. package/dist/lib/hosts/progress.d.ts +28 -10
  138. package/dist/lib/hosts/progress.js +79 -22
  139. package/dist/lib/hosts/remote-cmd.js +4 -0
  140. package/dist/lib/hq/floor.d.ts +87 -0
  141. package/dist/lib/hq/floor.js +226 -0
  142. package/dist/lib/menubar/install-menubar.js +14 -20
  143. package/dist/lib/notify.d.ts +1 -0
  144. package/dist/lib/notify.js +3 -3
  145. package/dist/lib/open-url.d.ts +2 -0
  146. package/dist/lib/open-url.js +19 -0
  147. package/dist/lib/openclaw-keychain.d.ts +56 -0
  148. package/dist/lib/openclaw-keychain.js +236 -0
  149. package/dist/lib/overdue.js +10 -0
  150. package/dist/lib/permissions.d.ts +44 -1
  151. package/dist/lib/permissions.js +284 -7
  152. package/dist/lib/project-launch.d.ts +6 -12
  153. package/dist/lib/project-launch.js +13 -228
  154. package/dist/lib/project-resources.d.ts +7 -0
  155. package/dist/lib/project-resources.js +291 -0
  156. package/dist/lib/pty-client.d.ts +27 -0
  157. package/dist/lib/pty-client.js +136 -10
  158. package/dist/lib/refresh.js +14 -10
  159. package/dist/lib/resource-profiles.d.ts +26 -0
  160. package/dist/lib/resource-profiles.js +157 -0
  161. package/dist/lib/resources/permissions.js +7 -1
  162. package/dist/lib/resources/types.d.ts +1 -1
  163. package/dist/lib/resources.d.ts +1 -1
  164. package/dist/lib/resources.js +32 -3
  165. package/dist/lib/routines.d.ts +11 -0
  166. package/dist/lib/routines.js +56 -15
  167. package/dist/lib/runner.d.ts +1 -0
  168. package/dist/lib/runner.js +66 -5
  169. package/dist/lib/secrets/bundles.d.ts +16 -3
  170. package/dist/lib/secrets/bundles.js +206 -37
  171. package/dist/lib/secrets/icloud-import.d.ts +2 -2
  172. package/dist/lib/secrets/icloud-import.js +9 -6
  173. package/dist/lib/secrets/mcp.js +1 -1
  174. package/dist/lib/secrets/vault-age-helper.d.ts +1 -0
  175. package/dist/lib/secrets/vault-age-helper.js +34 -0
  176. package/dist/lib/secrets/vault.d.ts +49 -0
  177. package/dist/lib/secrets/vault.js +397 -0
  178. package/dist/lib/self-heal/checks/path.js +3 -1
  179. package/dist/lib/self-heal/checks/shadowing.js +10 -2
  180. package/dist/lib/self-heal/checks/shims.js +19 -11
  181. package/dist/lib/session/cloud.d.ts +2 -2
  182. package/dist/lib/session/cloud.js +2 -2
  183. package/dist/lib/session/db.d.ts +4 -0
  184. package/dist/lib/session/db.js +44 -7
  185. package/dist/lib/session/discover.d.ts +2 -0
  186. package/dist/lib/session/discover.js +114 -3
  187. package/dist/lib/session/stream-render.d.ts +3 -0
  188. package/dist/lib/session/stream-render.js +130 -0
  189. package/dist/lib/session/types.d.ts +6 -0
  190. package/dist/lib/share/analytics.d.ts +13 -0
  191. package/dist/lib/share/analytics.js +45 -0
  192. package/dist/lib/share/config.d.ts +19 -5
  193. package/dist/lib/share/config.js +44 -8
  194. package/dist/lib/share/provision.d.ts +58 -6
  195. package/dist/lib/share/provision.js +97 -22
  196. package/dist/lib/share/publish.d.ts +36 -13
  197. package/dist/lib/share/publish.js +55 -8
  198. package/dist/lib/share/worker-template.js +83 -17
  199. package/dist/lib/shims.d.ts +36 -0
  200. package/dist/lib/shims.js +120 -16
  201. package/dist/lib/ssh-exec.d.ts +14 -0
  202. package/dist/lib/ssh-exec.js +57 -0
  203. package/dist/lib/staleness/detectors/hooks.js +25 -1
  204. package/dist/lib/staleness/detectors/permissions.js +66 -0
  205. package/dist/lib/staleness/detectors/workflows.js +20 -0
  206. package/dist/lib/staleness/writers/hooks.js +58 -4
  207. package/dist/lib/staleness/writers/permissions.js +2 -2
  208. package/dist/lib/staleness/writers/skills.js +1 -1
  209. package/dist/lib/staleness/writers/sources.d.ts +10 -1
  210. package/dist/lib/staleness/writers/sources.js +68 -1
  211. package/dist/lib/star-nudge.d.ts +45 -0
  212. package/dist/lib/star-nudge.js +91 -0
  213. package/dist/lib/startup/command-registry.d.ts +7 -1
  214. package/dist/lib/startup/command-registry.js +19 -3
  215. package/dist/lib/state.d.ts +2 -0
  216. package/dist/lib/state.js +3 -0
  217. package/dist/lib/subagents-registry.d.ts +2 -0
  218. package/dist/lib/subagents-registry.js +3 -0
  219. package/dist/lib/teams/parsers.js +214 -6
  220. package/dist/lib/teams/supervisor.d.ts +7 -0
  221. package/dist/lib/teams/supervisor.js +2 -1
  222. package/dist/lib/template.d.ts +1 -1
  223. package/dist/lib/template.js +1 -1
  224. package/dist/lib/triggers/webhook.js +36 -3
  225. package/dist/lib/types.d.ts +73 -0
  226. package/dist/lib/uninstall.d.ts +84 -0
  227. package/dist/lib/uninstall.js +347 -0
  228. package/dist/lib/usage.d.ts +13 -1
  229. package/dist/lib/usage.js +32 -14
  230. package/dist/lib/version.d.ts +40 -0
  231. package/dist/lib/version.js +94 -16
  232. package/dist/lib/versions.d.ts +27 -0
  233. package/dist/lib/versions.js +248 -79
  234. package/dist/lib/workflows.d.ts +2 -0
  235. package/dist/lib/workflows.js +88 -0
  236. package/package.json +2 -1
  237. package/dist/commands/daemon.d.ts +0 -10
  238. package/dist/commands/daemon.js +0 -121
package/dist/lib/exec.js CHANGED
@@ -15,7 +15,8 @@ import { getBinaryPath, getVersionHomePath, isVersionInstalled, resolveVersion }
15
15
  import { resolveModel, buildReasoningFlags } from './models.js';
16
16
  import { maybeRotate, createTimer, redactPrompt, redactArgs } from './events.js';
17
17
  import { sanitizeProcessEnv } from './secrets/bundles.js';
18
- import { getShimsDir } from './state.js';
18
+ import { getShimsDir, getHistoryDir } from './state.js';
19
+ import { resolveCodexHome } from './codex-home.js';
19
20
  import { readCodexConfiguredModel } from './shims.js';
20
21
  import { writePidSessionEntry, extractSessionIdArg } from './session/pid-registry.js';
21
22
  import { recordRunName } from './session/run-names.js';
@@ -153,6 +154,21 @@ export function resolveInteractive(options) {
153
154
  return false;
154
155
  return options.prompt === undefined;
155
156
  }
157
+ /**
158
+ * True when a run resolved to *inferred* interactive intent — no prompt and no
159
+ * explicit `--interactive` — but there is no terminal to host the REPL. Launching
160
+ * would attach a TUI to a dead stdin and hang forever, so the caller should fail
161
+ * fast with the headless alternatives instead (RUSH-1829).
162
+ *
163
+ * An explicit `--interactive` is the caller's deliberate choice and is never
164
+ * blocked (they may be driving a PTY we can't detect). Pure — the TTY state is a
165
+ * parameter so this is unit-testable without touching `process.std*`.
166
+ */
167
+ export function inferredInteractiveWithoutTty(options, isTty) {
168
+ if (options.interactive === true)
169
+ return false;
170
+ return resolveInteractive(options) && !isTty;
171
+ }
156
172
  /**
157
173
  * Decide whether spawnAgent must capture (PIPE + tee) the child's stdout so the
158
174
  * live budget watcher can parse it (issue #346, FIX 3).
@@ -241,7 +257,12 @@ export function buildExecEnv(options) {
241
257
  ? resolvedVersion
242
258
  : (resolvedVersion && isVersionInstalled('codex', resolvedVersion) ? resolvedVersion : null);
243
259
  if (version) {
244
- result.CODEX_HOME = path.join(getVersionHomePath('codex', version), '.codex');
260
+ // On macOS the deep versioned home overflows the Unix-socket SUN_LEN
261
+ // limit for codex's app-server control socket; resolve to a short,
262
+ // SUN_LEN-safe home (migrating once if needed). See codex-home.ts.
263
+ const versionedHome = path.join(getVersionHomePath('codex', version), '.codex');
264
+ const agentsUserDir = path.dirname(getHistoryDir());
265
+ result.CODEX_HOME = resolveCodexHome(versionedHome, agentsUserDir, version);
245
266
  }
246
267
  delete result.CLAUDE_CONFIG_DIR;
247
268
  delete result.COPILOT_HOME;
@@ -27,4 +27,4 @@ export interface PolicyResult {
27
27
  * Apply policy to a single open block. Returns the action taken (none/defaulted/parked).
28
28
  * Caller is responsible for persistence/logging side effects not owned by feed.ts.
29
29
  */
30
- export declare function applyPolicyToBlock(block: OpenBlock, policy: FeedPolicy, now: Date, root?: string): PolicyResult;
30
+ export declare function applyPolicyToBlock(block: OpenBlock, policy: FeedPolicy, now: Date, root?: string, mailboxRoot?: string): PolicyResult;
@@ -28,12 +28,12 @@ export const DEFAULT_POLICY = {
28
28
  export function getPolicyPath(root) {
29
29
  return path.join(root ?? getUserAgentsDir(), POLICY_FILE);
30
30
  }
31
- function normalizeClassPolicy(raw) {
31
+ function normalizeClassPolicy(raw, fallback) {
32
32
  const p = (raw ?? {});
33
- const timeout = typeof p.timeoutMinutes === 'number' ? p.timeoutMinutes : DEFAULT_POLICY.approval.timeoutMinutes;
33
+ const timeout = typeof p.timeoutMinutes === 'number' ? p.timeoutMinutes : fallback.timeoutMinutes;
34
34
  return {
35
35
  timeoutMinutes: Math.max(1, Math.round(timeout)),
36
- safeDefault: typeof p.safeDefault === 'string' ? p.safeDefault : undefined,
36
+ safeDefault: typeof p.safeDefault === 'string' ? p.safeDefault : fallback.safeDefault,
37
37
  };
38
38
  }
39
39
  export function loadPolicy(root) {
@@ -45,8 +45,8 @@ export function loadPolicy(root) {
45
45
  const p = parsed;
46
46
  const threshold = p.phoneNotifyThreshold;
47
47
  return {
48
- approval: normalizeClassPolicy(p.approval ?? { timeoutMinutes: DEFAULT_POLICY.approval.timeoutMinutes, safeDefault: DEFAULT_POLICY.approval.safeDefault }),
49
- decision: normalizeClassPolicy(p.decision ?? { timeoutMinutes: DEFAULT_POLICY.decision.timeoutMinutes }),
48
+ approval: normalizeClassPolicy(p.approval, DEFAULT_POLICY.approval),
49
+ decision: normalizeClassPolicy(p.decision, DEFAULT_POLICY.decision),
50
50
  phoneNotifyThreshold: threshold === 'low' || threshold === 'medium' || threshold === 'high' ? threshold : DEFAULT_POLICY.phoneNotifyThreshold,
51
51
  };
52
52
  }
@@ -71,16 +71,21 @@ export function minutesElapsed(block, now) {
71
71
  return 0;
72
72
  return (now.getTime() - ts) / 60_000;
73
73
  }
74
+ function timeoutMinutesForBlock(block, policy) {
75
+ if (typeof block.timeoutMinutes === 'number' && Number.isFinite(block.timeoutMinutes) && block.timeoutMinutes > 0) {
76
+ return Math.max(1, Math.round(block.timeoutMinutes));
77
+ }
78
+ return policy[blockClass(block)].timeoutMinutes;
79
+ }
74
80
  export function isTimedOut(block, policy, now) {
75
- const cls = blockClass(block);
76
81
  const minutes = minutesElapsed(block, now);
77
- return minutes >= policy[cls].timeoutMinutes;
82
+ return minutes >= timeoutMinutesForBlock(block, policy);
78
83
  }
79
84
  /**
80
85
  * Apply policy to a single open block. Returns the action taken (none/defaulted/parked).
81
86
  * Caller is responsible for persistence/logging side effects not owned by feed.ts.
82
87
  */
83
- export function applyPolicyToBlock(block, policy, now, root) {
88
+ export function applyPolicyToBlock(block, policy, now, root, mailboxRoot) {
84
89
  if (block.answer || block.parkedAt || block.defaultedAt) {
85
90
  return { blockId: block.blockId, action: 'none' };
86
91
  }
@@ -97,7 +102,7 @@ export function applyPolicyToBlock(block, policy, now, root) {
97
102
  if (!claim.ok) {
98
103
  return { blockId: block.blockId, action: 'none' };
99
104
  }
100
- const msgId = enqueue(mailboxDir(block.mailboxId, root ?? undefined), {
105
+ const msgId = enqueue(mailboxDir(block.mailboxId, mailboxRoot), {
101
106
  to: block.mailboxId,
102
107
  text: safeDefault,
103
108
  from: 'policy',
@@ -0,0 +1,32 @@
1
+ import type { OpenBlock, FeedAskStats } from './feed.js';
2
+ import type { Classification } from './ask-classifier.js';
3
+ import type { ActiveSession } from './session/active.js';
4
+ import type { SessionMeta } from './session/types.js';
5
+ export interface FeedSessionSignal {
6
+ sessionId?: string;
7
+ mailboxId?: string;
8
+ kind?: string;
9
+ host?: string;
10
+ context?: ActiveSession['context'];
11
+ runtime?: string;
12
+ pid?: number;
13
+ cwd?: string;
14
+ startedAtMs?: number;
15
+ status?: ActiveSession['status'];
16
+ tokPerSec?: number;
17
+ costUsd?: number;
18
+ durationMs?: number;
19
+ cloudProvider?: string;
20
+ cloudTaskId?: string;
21
+ }
22
+ export interface RankedFeedBlock extends OpenBlock {
23
+ delayRank: NonNullable<OpenBlock['delayRank']>;
24
+ }
25
+ export declare function buildSessionSignals(active: ActiveSession[], metas?: SessionMeta[]): FeedSessionSignal[];
26
+ export declare function decisionIrreducibility(c: Classification): number;
27
+ export declare function burnUsdPerHour(signal: FeedSessionSignal | undefined, block: OpenBlock): number;
28
+ export declare function rankFeedBlocks(blocks: OpenBlock[], signals?: FeedSessionSignal[], now?: Date): RankedFeedBlock[];
29
+ export declare function recentAskCount(stats: FeedAskStats, now?: Date): number;
30
+ export declare function needyControlCards(stats: FeedAskStats[], signals?: FeedSessionSignal[], now?: Date, threshold?: number): OpenBlock[];
31
+ export declare function runawayControlCards(signals: FeedSessionSignal[], now?: Date): OpenBlock[];
32
+ export declare function synthesizeControlCards(signals: FeedSessionSignal[], stats: FeedAskStats[], now?: Date): OpenBlock[];
@@ -0,0 +1,224 @@
1
+ import { classifyBlock } from './ask-classifier.js';
2
+ import { outcomeForBlock } from './feed-outcome.js';
3
+ const MINUTES_PER_HOUR = 60;
4
+ const NEEDY_ASKS_PER_HOUR = 6;
5
+ const RUNAWAY_TOK_PER_SEC = 250;
6
+ const RUNAWAY_BURN_USD_PER_HOUR = 25;
7
+ const RELAUNCH_LOOP_COUNT = 3;
8
+ const RELAUNCH_LOOP_WINDOW_MS = 10 * 60_000;
9
+ function finiteNumber(value) {
10
+ return typeof value === 'number' && Number.isFinite(value) ? value : undefined;
11
+ }
12
+ function signalKey(signal) {
13
+ return [signal.mailboxId, signal.sessionId, signal.cloudTaskId].filter((v) => !!v);
14
+ }
15
+ export function buildSessionSignals(active, metas = []) {
16
+ const metaById = new Map(metas.map((m) => [m.id, m]));
17
+ return active.map((s) => {
18
+ const sessionId = s.sessionId;
19
+ const meta = sessionId ? metaById.get(sessionId) : undefined;
20
+ return {
21
+ sessionId,
22
+ mailboxId: s.agentId ?? s.sessionId ?? s.cloudTaskId,
23
+ kind: s.kind,
24
+ host: s.machine ?? s.provenance?.host ?? s.host,
25
+ context: s.context,
26
+ runtime: s.context,
27
+ pid: s.pid,
28
+ cwd: s.cwd,
29
+ startedAtMs: s.startedAtMs,
30
+ status: s.status,
31
+ tokPerSec: s.tokPerSec,
32
+ costUsd: meta?.costUsd,
33
+ durationMs: meta?.durationMs,
34
+ cloudProvider: s.cloudProvider,
35
+ cloudTaskId: s.cloudTaskId,
36
+ };
37
+ });
38
+ }
39
+ function indexSignals(signals) {
40
+ const out = new Map();
41
+ for (const signal of signals) {
42
+ for (const key of signalKey(signal)) {
43
+ if (!out.has(key))
44
+ out.set(key, signal);
45
+ }
46
+ }
47
+ return out;
48
+ }
49
+ function signalForBlock(block, signals) {
50
+ return signals.get(block.mailboxId) ?? signals.get(block.sessionId);
51
+ }
52
+ export function decisionIrreducibility(c) {
53
+ switch (c.class) {
54
+ case 'decision': return 1;
55
+ case 'approval': return 0.8;
56
+ case 'clarification': return 0.6;
57
+ case 'stall':
58
+ case 'fyi':
59
+ return 0;
60
+ }
61
+ }
62
+ function fallbackBurnRate(block) {
63
+ switch (block.costOfDelay) {
64
+ case 'high': return 10;
65
+ case 'medium': return 3;
66
+ case 'low': return 1;
67
+ default: return 1;
68
+ }
69
+ }
70
+ export function burnUsdPerHour(signal, block) {
71
+ const cost = finiteNumber(signal?.costUsd);
72
+ const duration = finiteNumber(signal?.durationMs);
73
+ if (cost !== undefined && duration !== undefined && duration > 0) {
74
+ return cost / (duration / 3_600_000);
75
+ }
76
+ const existing = finiteNumber(block.delayRank?.burnUsdPerHour);
77
+ if (existing !== undefined)
78
+ return existing;
79
+ return fallbackBurnRate(block);
80
+ }
81
+ function idleMinutes(block, nowMs) {
82
+ const ts = Date.parse(block.ts);
83
+ if (!Number.isFinite(ts))
84
+ return 0;
85
+ return Math.max(0, (nowMs - ts) / 60_000);
86
+ }
87
+ function blastRadii(blocks) {
88
+ const counts = new Map();
89
+ for (const block of blocks) {
90
+ const key = outcomeForBlock(block).key;
91
+ counts.set(key, (counts.get(key) ?? 0) + 1);
92
+ }
93
+ const out = new Map();
94
+ for (const block of blocks) {
95
+ out.set(block.blockId, Math.max(1, finiteNumber(block.downstreamAgents) ?? counts.get(outcomeForBlock(block).key) ?? 1));
96
+ }
97
+ return out;
98
+ }
99
+ export function rankFeedBlocks(blocks, signals = [], now = new Date()) {
100
+ const bySignal = indexSignals(signals);
101
+ const radii = blastRadii(blocks);
102
+ const nowMs = now.getTime();
103
+ return blocks
104
+ .map((block) => {
105
+ const c = classifyBlock(block);
106
+ const idle = idleMinutes(block, nowMs);
107
+ const blastRadius = radii.get(block.blockId) ?? 1;
108
+ const burn = burnUsdPerHour(signalForBlock(block, bySignal), block);
109
+ const irreducible = finiteNumber(block.delayRank?.decisionIrreducibility) ?? decisionIrreducibility(c);
110
+ const score = idle * blastRadius * burn * irreducible;
111
+ return {
112
+ ...block,
113
+ delayRank: {
114
+ score,
115
+ idleMinutes: idle,
116
+ blastRadius,
117
+ burnUsdPerHour: burn,
118
+ decisionIrreducibility: irreducible,
119
+ },
120
+ };
121
+ })
122
+ .sort((a, b) => {
123
+ if (b.delayRank.score !== a.delayRank.score)
124
+ return b.delayRank.score - a.delayRank.score;
125
+ return Date.parse(a.ts) - Date.parse(b.ts);
126
+ });
127
+ }
128
+ export function recentAskCount(stats, now = new Date()) {
129
+ const cutoff = now.getTime() - 60 * 60_000;
130
+ return stats.recentAskTimestamps.filter((ts) => {
131
+ const parsed = Date.parse(ts);
132
+ return Number.isFinite(parsed) && parsed >= cutoff;
133
+ }).length;
134
+ }
135
+ export function needyControlCards(stats, signals = [], now = new Date(), threshold = NEEDY_ASKS_PER_HOUR) {
136
+ const bySignal = indexSignals(signals);
137
+ const cards = [];
138
+ for (const row of stats) {
139
+ const count = recentAskCount(row, now);
140
+ if (count < threshold)
141
+ continue;
142
+ const signal = bySignal.get(row.mailboxId) ?? bySignal.get(row.sessionId);
143
+ cards.push({
144
+ blockId: `control-needy-${row.sessionId.replace(/[^A-Za-z0-9._-]/g, '-')}`,
145
+ sessionId: row.sessionId,
146
+ mailboxId: row.mailboxId,
147
+ host: signal?.host ?? 'local',
148
+ runtime: signal?.runtime ?? signal?.context ?? 'unknown',
149
+ ts: row.lastAskAt,
150
+ kind: 'control',
151
+ questions: [{
152
+ header: 'Needy agent',
153
+ text: `${row.mailboxId} asked ${count} times in the last hour. Inspect the session; this is likely a loop bug, not ${count} separate decisions.`,
154
+ }],
155
+ needy: {
156
+ askCountLastHour: count,
157
+ threshold,
158
+ totalAskCount: row.totalAskCount,
159
+ },
160
+ });
161
+ }
162
+ return cards;
163
+ }
164
+ function relaunchLoopCount(signal, signals, nowMs) {
165
+ if (!signal.cwd || !signal.kind || !signal.startedAtMs)
166
+ return 0;
167
+ return signals.filter((candidate) => candidate.kind === signal.kind &&
168
+ candidate.cwd === signal.cwd &&
169
+ candidate.startedAtMs !== undefined &&
170
+ nowMs - candidate.startedAtMs <= RELAUNCH_LOOP_WINDOW_MS).length;
171
+ }
172
+ export function runawayControlCards(signals, now = new Date()) {
173
+ const nowMs = now.getTime();
174
+ const cards = [];
175
+ const seen = new Set();
176
+ for (const signal of signals) {
177
+ const tokPerSec = finiteNumber(signal.tokPerSec);
178
+ const cost = finiteNumber(signal.costUsd);
179
+ const duration = finiteNumber(signal.durationMs);
180
+ const burn = cost !== undefined && duration !== undefined && duration > 0
181
+ ? cost / (duration / 3_600_000)
182
+ : undefined;
183
+ const loopCount = relaunchLoopCount(signal, signals, nowMs);
184
+ const reasons = [];
185
+ if (tokPerSec !== undefined && tokPerSec >= RUNAWAY_TOK_PER_SEC)
186
+ reasons.push(`${Math.round(tokPerSec)} tok/s`);
187
+ if (burn !== undefined && burn >= RUNAWAY_BURN_USD_PER_HOUR)
188
+ reasons.push(`$${burn.toFixed(2)}/hr`);
189
+ if (loopCount >= RELAUNCH_LOOP_COUNT)
190
+ reasons.push(`${loopCount} launches in 10 minutes`);
191
+ if (reasons.length === 0)
192
+ continue;
193
+ const id = signal.mailboxId ?? signal.sessionId ?? signal.cloudTaskId;
194
+ if (!id || seen.has(id))
195
+ continue;
196
+ seen.add(id);
197
+ cards.push({
198
+ blockId: `control-runaway-${id.replace(/[^A-Za-z0-9._-]/g, '-')}`,
199
+ sessionId: signal.sessionId ?? id,
200
+ mailboxId: signal.mailboxId ?? id,
201
+ host: signal.host ?? 'local',
202
+ runtime: signal.runtime ?? signal.context ?? 'unknown',
203
+ ts: new Date(signal.startedAtMs ?? nowMs).toISOString(),
204
+ kind: 'control',
205
+ questions: [{
206
+ header: 'Runaway agent',
207
+ text: `${id} is burning abnormally (${reasons.join(', ')}). Pause or kill it before it burns more parallel capacity.`,
208
+ }],
209
+ runaway: {
210
+ reason: reasons.join(', '),
211
+ tokPerSec,
212
+ burnUsdPerHour: burn,
213
+ relaunchesPerTenMinutes: loopCount || undefined,
214
+ },
215
+ });
216
+ }
217
+ return cards;
218
+ }
219
+ export function synthesizeControlCards(signals, stats, now = new Date()) {
220
+ return [
221
+ ...runawayControlCards(signals, now),
222
+ ...needyControlCards(stats, signals, now),
223
+ ];
224
+ }
@@ -38,7 +38,7 @@ export interface OpenBlock {
38
38
  runtime: string;
39
39
  ts: string;
40
40
  questions: BlockQuestion[];
41
- kind?: 'question' | 'notification';
41
+ kind?: 'question' | 'notification' | 'control';
42
42
  notificationType?: string;
43
43
  ticket?: string;
44
44
  pr?: string;
@@ -58,6 +58,29 @@ export interface OpenBlock {
58
58
  safeDefault?: string;
59
59
  /** Cost-of-delay for notification routing: low/medium/high. */
60
60
  costOfDelay?: 'low' | 'medium' | 'high';
61
+ /** Number of agents downstream of this blocked agent, when known. */
62
+ downstreamAgents?: number;
63
+ /** Computed cost-of-delay rank metadata, stamped by `agents feed`. */
64
+ delayRank?: {
65
+ score: number;
66
+ idleMinutes: number;
67
+ blastRadius: number;
68
+ burnUsdPerHour: number;
69
+ decisionIrreducibility: number;
70
+ };
71
+ /** Token/cost runaway signal for synthetic feed control cards. */
72
+ runaway?: {
73
+ reason: string;
74
+ tokPerSec?: number;
75
+ burnUsdPerHour?: number;
76
+ relaunchesPerTenMinutes?: number;
77
+ };
78
+ /** Chronic-ask signal for synthetic feed control cards. */
79
+ needy?: {
80
+ askCountLastHour: number;
81
+ threshold: number;
82
+ totalAskCount: number;
83
+ };
61
84
  /** Set once the block has been answered; see `recordAnswer`. */
62
85
  answer?: AnswerRecord;
63
86
  /** Per-message delivery receipts for answers to this block. */
@@ -71,6 +94,14 @@ export interface OpenBlock {
71
94
  /** ISO-8601 timestamp when a decision block was hard-parked. */
72
95
  parkedAt?: string;
73
96
  }
97
+ export interface FeedAskStats {
98
+ sessionId: string;
99
+ mailboxId: string;
100
+ firstAskAt: string;
101
+ lastAskAt: string;
102
+ totalAskCount: number;
103
+ recentAskTimestamps: string[];
104
+ }
74
105
  /**
75
106
  * Stable block id for a session. One block per session -- a new question
76
107
  * replaces the previous one (the agent can only ask one question at a time).
@@ -132,6 +163,8 @@ export declare function clearBlockLifecycle(blockId: string, root?: string): voi
132
163
  export declare function publishBlock(block: OpenBlock, root?: string): void;
133
164
  /** Read all block records. Returns them sorted by stable block filename. */
134
165
  export declare function listBlocks(root?: string): OpenBlock[];
166
+ /** Read per-session ask history written by the feed publish hook. */
167
+ export declare function listAskStats(root?: string): FeedAskStats[];
135
168
  /** Remove a block record and its lifecycle sidecars. Returns true if the file was deleted. */
136
169
  export declare function removeBlock(blockId: string, root?: string): boolean;
137
170
  /**
@@ -139,7 +172,7 @@ export declare function removeBlock(blockId: string, root?: string): boolean;
139
172
  * Embedded so it ships with the compiled CLI and can be installed to the
140
173
  * CLI-writable user hooks dir without a separate file in the npm tarball.
141
174
  */
142
- 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 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 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\": datetime.now(timezone.utc).isoformat(),\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\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";
143
176
  /** Manifest entry for the feed-publish hook, matching the ManifestHook shape. */
144
177
  export declare const FEED_PUBLISH_HOOK_MANIFEST: {
145
178
  name: string;
package/dist/lib/feed.js CHANGED
@@ -25,7 +25,7 @@ import * as fs from 'fs';
25
25
  import * as path from 'path';
26
26
  import * as yaml from 'yaml';
27
27
  import { getFeedDir, getUserAgentsDir } from './state.js';
28
- import { isHighConsequenceAllowed } from './operator.js';
28
+ import { isAdmin, isHighConsequenceAllowed, isKnownOperator } from './operator.js';
29
29
  /**
30
30
  * Stable block id for a session. One block per session -- a new question
31
31
  * replaces the previous one (the agent can only ask one question at a time).
@@ -42,6 +42,7 @@ function blockPath(root, blockId) {
42
42
  }
43
43
  function answeredDir(root) { return path.join(root, 'answered'); }
44
44
  function receiptDir(root) { return path.join(root, 'receipts'); }
45
+ function askStatsDir(root) { return path.join(root, 'asks'); }
45
46
  function ensureDir(dir) {
46
47
  fs.mkdirSync(dir, { recursive: true });
47
48
  }
@@ -80,13 +81,29 @@ export function recordAnswer(blockId, answer, root) {
80
81
  const operatorId = answer.operatorId;
81
82
  if (block?.consequence && block.consequence !== 'normal') {
82
83
  // Operators live in ~/.agents/operators.yaml — never the feed store root.
83
- if (!operatorId || answer.verified !== true || !isHighConsequenceAllowed(block.consequence, operatorId)) {
84
+ if (!operatorId || answer.verified !== true || !isKnownOperator(operatorId)) {
84
85
  return {
85
86
  ok: false,
86
87
  unauthorized: true,
87
88
  reason: `High-consequence block '${block.consequence}' requires a verified, authorized operator.`,
88
89
  };
89
90
  }
91
+ const allowedByBlock = block.allowedOperators?.includes(operatorId) ?? false;
92
+ const allowedByCapability = isHighConsequenceAllowed(block.consequence, operatorId);
93
+ if (!allowedByBlock && !allowedByCapability) {
94
+ return {
95
+ ok: false,
96
+ unauthorized: true,
97
+ reason: `High-consequence block '${block.consequence}' requires a verified, authorized operator.`,
98
+ };
99
+ }
100
+ if (block.allowedOperators?.length && !allowedByBlock && !isAdmin(operatorId)) {
101
+ return {
102
+ ok: false,
103
+ unauthorized: true,
104
+ reason: `High-consequence block '${block.consequence}' is restricted to: ${block.allowedOperators.join(', ')}.`,
105
+ };
106
+ }
90
107
  }
91
108
  ensureDir(answeredDir(dir));
92
109
  const marker = path.join(answeredDir(dir), `${blockId}.json`);
@@ -253,6 +270,32 @@ export function listBlocks(root) {
253
270
  }
254
271
  return blocks;
255
272
  }
273
+ /** Read per-session ask history written by the feed publish hook. */
274
+ export function listAskStats(root) {
275
+ const dir = askStatsDir(root ?? getFeedDir());
276
+ let names;
277
+ try {
278
+ names = fs.readdirSync(dir);
279
+ }
280
+ catch {
281
+ return [];
282
+ }
283
+ const stats = [];
284
+ for (const name of names.filter(n => n.endsWith('.json')).sort()) {
285
+ const parsed = safeReadJson(path.join(dir, name));
286
+ if (!parsed?.sessionId || !parsed.mailboxId || !parsed.lastAskAt)
287
+ continue;
288
+ stats.push({
289
+ sessionId: parsed.sessionId,
290
+ mailboxId: parsed.mailboxId,
291
+ firstAskAt: parsed.firstAskAt ?? parsed.lastAskAt,
292
+ lastAskAt: parsed.lastAskAt,
293
+ totalAskCount: parsed.totalAskCount ?? parsed.recentAskTimestamps?.length ?? 0,
294
+ recentAskTimestamps: Array.isArray(parsed.recentAskTimestamps) ? parsed.recentAskTimestamps : [],
295
+ });
296
+ }
297
+ return stats;
298
+ }
256
299
  /** Remove a block record and its lifecycle sidecars. Returns true if the file was deleted. */
257
300
  export function removeBlock(blockId, root) {
258
301
  const dir = root ?? getFeedDir();
@@ -350,6 +393,7 @@ def main():
350
393
  home = os.environ.get("HOME") or os.path.expanduser("~")
351
394
  feed_dir = os.path.join(home, ".agents", ".history", "feed")
352
395
  answered_dir = os.path.join(feed_dir, "answered")
396
+ asks_dir = os.path.join(feed_dir, "asks")
353
397
  target = os.path.join(feed_dir, f"{block_id}.json")
354
398
  hook_event = payload.get("hook_event_name", "PreToolUse")
355
399
 
@@ -453,6 +497,25 @@ def main():
453
497
  os.environ.get("AGENTS_MAILBOX_DIR", "").rstrip("/")
454
498
  ) or session_id
455
499
 
500
+ now_iso = datetime.now(timezone.utc).isoformat()
501
+ stats_path = os.path.join(asks_dir, f"{safe_session_id}.json")
502
+ stats = read_json(stats_path) or {}
503
+ recent = stats.get("recentAskTimestamps") if isinstance(stats, dict) else []
504
+ if not isinstance(recent, list):
505
+ recent = []
506
+ recent.append(now_iso)
507
+ # Keep enough history for rolling one-hour needy detection without unbounded
508
+ # per-session files. The TypeScript reader applies the exact time window.
509
+ recent = recent[-200:]
510
+ write_json(stats_path, {
511
+ "sessionId": session_id,
512
+ "mailboxId": mailbox_id,
513
+ "firstAskAt": stats.get("firstAskAt") or now_iso,
514
+ "lastAskAt": now_iso,
515
+ "totalAskCount": int(stats.get("totalAskCount") or 0) + 1,
516
+ "recentAskTimestamps": recent,
517
+ })
518
+
456
519
  hostname = os.environ.get("AGENTS_SYNC_MACHINE_ID") or socket.gethostname()
457
520
  host = hostname.split(".")[0].strip().lower()
458
521
  host = re.sub(r"[^a-z0-9_-]", "-", host) or "unknown"
@@ -465,7 +528,7 @@ def main():
465
528
  "mailboxId": mailbox_id,
466
529
  "host": host,
467
530
  "runtime": runtime,
468
- "ts": datetime.now(timezone.utc).isoformat(),
531
+ "ts": now_iso,
469
532
  "questions": normalized_questions,
470
533
  "kind": kind,
471
534
  }
@@ -29,6 +29,59 @@ export declare const FLEET_AUTH_FILES: Record<string, AuthFileSpec[]>;
29
29
  export declare const KEYCHAIN_BOUND_ON_MAC: ReadonlySet<string>;
30
30
  /** Which agents `apply` can propagate auth for at all. */
31
31
  export declare function isPropagatableAgent(agent: string): boolean;
32
+ /**
33
+ * The shape of a login flow — enough for `agents fleet login` to drive a remote
34
+ * box's device-code OAuth over SSH and scrape the verification URL + user code.
35
+ *
36
+ * `flowType` decides remotability:
37
+ * - `device-code` — the remote box prints a URL + short code the human enters in
38
+ * a browser on THIS machine. The only flow `fleet login` can orchestrate.
39
+ * - `loopback` — the CLI opens a browser and listens on 127.0.0.1 on the
40
+ * remote box; the redirect must reach that box's own loopback, so it can only
41
+ * be completed with a browser on the same machine. Non-remotable.
42
+ * - `api-key` — a pasted key, not an OAuth handshake. Non-remotable.
43
+ * - `unknown` — flow not yet characterized (no captured output). Non-remotable
44
+ * until someone captures the real pattern and fills the regexes.
45
+ */
46
+ export type LoginFlowType = 'device-code' | 'loopback' | 'api-key' | 'unknown';
47
+ export interface LoginFlow {
48
+ /**
49
+ * The exact command that starts the login flow on the remote box, run as
50
+ * `ssh -tt <box> <loginCommand>`. Bare `<cli>` for agents whose device flow
51
+ * starts on launch (droid, kimi, gemini, antigravity), `<cli> login` for
52
+ * codex/grok, `<cli> auth login` for opencode.
53
+ */
54
+ loginCommand: string;
55
+ flowType: LoginFlowType;
56
+ /**
57
+ * Keystrokes sent after launch to force the device-code path when the CLI
58
+ * presents a menu (codex: pick "Sign in with Device Code") or requires a
59
+ * sub-command inside a TUI (kimi: `/login`). Sent verbatim through the PTY,
60
+ * so it must include the submitting CR (`\r`). Absent when the flow needs no
61
+ * steering.
62
+ */
63
+ deviceCodeSelect?: string;
64
+ /** Captures the verification URL from scraped login output (group 1 preferred). */
65
+ verificationUrlRegex?: RegExp;
66
+ /** Captures the user code from scraped login output (group 1 preferred). */
67
+ userCodeRegex?: RegExp;
68
+ /**
69
+ * Home-relative credential file that appears / bumps mtime on success — the
70
+ * completion signal. Mirrors {@link FLEET_AUTH_FILES} (first entry for agents
71
+ * with several).
72
+ */
73
+ successFile: string;
74
+ }
75
+ /**
76
+ * Per-agent login flows, populated from live login-output captures (the only
77
+ * ground truth — no fixtures exist upstream). Agents whose real device-code
78
+ * output has not been captured are marked honestly (`unknown` / no regexes) so
79
+ * `fleet login` flags them non-remotable instead of guessing.
80
+ *
81
+ * Only the `device-code` entries are ones `fleet login` drives; the rest are
82
+ * carried so the command can explain WHY a logged-out pair is not remotable.
83
+ */
84
+ export declare const FLEET_LOGIN_FLOWS: Record<string, LoginFlow>;
32
85
  export interface SnapshotOptions {
33
86
  /** Home directory to read credential files from. */
34
87
  home: string;