@phnx-labs/agents-cli 1.20.72 → 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 (219) hide show
  1. package/CHANGELOG.md +299 -0
  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/doctor.js +8 -4
  11. package/dist/commands/events.d.ts +11 -10
  12. package/dist/commands/events.js +30 -21
  13. package/dist/commands/exec.d.ts +34 -0
  14. package/dist/commands/exec.js +337 -39
  15. package/dist/commands/feed.d.ts +13 -0
  16. package/dist/commands/feed.js +130 -29
  17. package/dist/commands/hq.d.ts +2 -0
  18. package/dist/commands/hq.js +58 -0
  19. package/dist/commands/lease.d.ts +19 -0
  20. package/dist/commands/lease.js +138 -11
  21. package/dist/commands/login.d.ts +2 -0
  22. package/dist/commands/login.js +123 -0
  23. package/dist/commands/logs.js +1 -1
  24. package/dist/commands/mine.d.ts +27 -0
  25. package/dist/commands/mine.js +207 -0
  26. package/dist/commands/monitors.js +79 -49
  27. package/dist/commands/plugins.js +1 -0
  28. package/dist/commands/profiles.js +158 -0
  29. package/dist/commands/repo.js +2 -60
  30. package/dist/commands/resources.d.ts +5 -0
  31. package/dist/commands/resources.js +95 -0
  32. package/dist/commands/routines.d.ts +2 -0
  33. package/dist/commands/routines.js +139 -37
  34. package/dist/commands/secrets.js +234 -47
  35. package/dist/commands/send.d.ts +15 -0
  36. package/dist/commands/send.js +62 -0
  37. package/dist/commands/sessions-tail.d.ts +1 -0
  38. package/dist/commands/sessions-tail.js +20 -9
  39. package/dist/commands/sessions.js +18 -3
  40. package/dist/commands/setup-mine.d.ts +18 -0
  41. package/dist/commands/setup-mine.js +106 -0
  42. package/dist/commands/setup-share.js +2 -2
  43. package/dist/commands/setup.js +3 -1
  44. package/dist/commands/share.d.ts +8 -1
  45. package/dist/commands/share.js +122 -37
  46. package/dist/commands/ssh.d.ts +9 -0
  47. package/dist/commands/ssh.js +196 -26
  48. package/dist/commands/sync.js +1 -1
  49. package/dist/commands/teams.js +11 -2
  50. package/dist/index.js +79 -10
  51. package/dist/lib/activity.d.ts +124 -0
  52. package/dist/lib/activity.js +542 -0
  53. package/dist/lib/agents.js +16 -5
  54. package/dist/lib/artifact-actions.d.ts +1 -1
  55. package/dist/lib/artifact-actions.js +1 -1
  56. package/dist/lib/ask-classifier.d.ts +2 -1
  57. package/dist/lib/ask-classifier.js +3 -3
  58. package/dist/lib/auth-health.d.ts +32 -4
  59. package/dist/lib/auth-health.js +40 -1
  60. package/dist/lib/auto-pull.js +8 -1
  61. package/dist/lib/brand.d.ts +40 -0
  62. package/dist/lib/brand.js +122 -0
  63. package/dist/lib/browser/drivers/ssh.js +4 -7
  64. package/dist/lib/browser/service.js +6 -8
  65. package/dist/lib/channels/providers/index.d.ts +2 -0
  66. package/dist/lib/channels/providers/index.js +20 -0
  67. package/dist/lib/channels/providers/mailbox.d.ts +2 -0
  68. package/dist/lib/channels/providers/mailbox.js +26 -0
  69. package/dist/lib/channels/providers/openclaw-telegram.d.ts +2 -0
  70. package/dist/lib/channels/providers/openclaw-telegram.js +34 -0
  71. package/dist/lib/channels/providers/rush.d.ts +6 -0
  72. package/dist/lib/channels/providers/rush.js +55 -0
  73. package/dist/lib/channels/registry.d.ts +42 -0
  74. package/dist/lib/channels/registry.js +20 -0
  75. package/dist/lib/channels/resolve.d.ts +11 -0
  76. package/dist/lib/channels/resolve.js +12 -0
  77. package/dist/lib/cli-resources.d.ts +18 -0
  78. package/dist/lib/cli-resources.js +53 -4
  79. package/dist/lib/cloud/codex.js +6 -3
  80. package/dist/lib/cloud/factory.d.ts +1 -0
  81. package/dist/lib/cloud/factory.js +10 -4
  82. package/dist/lib/cloud/rush.d.ts +3 -1
  83. package/dist/lib/cloud/rush.js +6 -1
  84. package/dist/lib/cloud/types.d.ts +2 -0
  85. package/dist/lib/codex-home.d.ts +35 -0
  86. package/dist/lib/codex-home.js +136 -0
  87. package/dist/lib/crabbox/cli.d.ts +44 -0
  88. package/dist/lib/crabbox/cli.js +120 -20
  89. package/dist/lib/crabbox/lease.d.ts +52 -1
  90. package/dist/lib/crabbox/lease.js +117 -16
  91. package/dist/lib/crabbox/progress.d.ts +31 -0
  92. package/dist/lib/crabbox/progress.js +76 -0
  93. package/dist/lib/crabbox/runtimes.d.ts +4 -0
  94. package/dist/lib/crabbox/runtimes.js +33 -8
  95. package/dist/lib/crabbox/setup-copy.d.ts +87 -0
  96. package/dist/lib/crabbox/setup-copy.js +127 -0
  97. package/dist/lib/daemon.d.ts +5 -5
  98. package/dist/lib/daemon.js +9 -9
  99. package/dist/lib/drive-sync.js +6 -3
  100. package/dist/lib/event-stream.d.ts +36 -0
  101. package/dist/lib/event-stream.js +68 -0
  102. package/dist/lib/events.d.ts +1 -1
  103. package/dist/lib/exec.d.ts +11 -0
  104. package/dist/lib/exec.js +23 -2
  105. package/dist/lib/feed-policy.d.ts +1 -1
  106. package/dist/lib/feed-policy.js +14 -9
  107. package/dist/lib/feed-ranking.d.ts +32 -0
  108. package/dist/lib/feed-ranking.js +224 -0
  109. package/dist/lib/feed.d.ts +35 -2
  110. package/dist/lib/feed.js +66 -3
  111. package/dist/lib/fleet/auth-sync.d.ts +53 -0
  112. package/dist/lib/fleet/auth-sync.js +92 -0
  113. package/dist/lib/fleet/remote-login.d.ts +187 -0
  114. package/dist/lib/fleet/remote-login.js +556 -0
  115. package/dist/lib/format.d.ts +30 -3
  116. package/dist/lib/format.js +34 -5
  117. package/dist/lib/fs-atomic.d.ts +7 -2
  118. package/dist/lib/fs-atomic.js +8 -12
  119. package/dist/lib/git.d.ts +16 -0
  120. package/dist/lib/git.js +79 -2
  121. package/dist/lib/heal.js +11 -3
  122. package/dist/lib/hosts/progress.d.ts +28 -10
  123. package/dist/lib/hosts/progress.js +79 -22
  124. package/dist/lib/hosts/remote-cmd.js +4 -0
  125. package/dist/lib/hq/floor.d.ts +87 -0
  126. package/dist/lib/hq/floor.js +226 -0
  127. package/dist/lib/menubar/install-menubar.js +14 -20
  128. package/dist/lib/notify.d.ts +1 -0
  129. package/dist/lib/notify.js +3 -3
  130. package/dist/lib/open-url.d.ts +2 -0
  131. package/dist/lib/open-url.js +19 -0
  132. package/dist/lib/openclaw-keychain.d.ts +56 -0
  133. package/dist/lib/openclaw-keychain.js +236 -0
  134. package/dist/lib/overdue.js +10 -0
  135. package/dist/lib/permissions.d.ts +44 -1
  136. package/dist/lib/permissions.js +284 -7
  137. package/dist/lib/project-launch.d.ts +6 -12
  138. package/dist/lib/project-launch.js +13 -228
  139. package/dist/lib/project-resources.d.ts +7 -0
  140. package/dist/lib/project-resources.js +291 -0
  141. package/dist/lib/pty-client.d.ts +27 -0
  142. package/dist/lib/pty-client.js +136 -10
  143. package/dist/lib/refresh.js +14 -10
  144. package/dist/lib/resource-profiles.d.ts +26 -0
  145. package/dist/lib/resource-profiles.js +157 -0
  146. package/dist/lib/resources/permissions.js +7 -1
  147. package/dist/lib/resources/types.d.ts +1 -1
  148. package/dist/lib/resources.d.ts +1 -1
  149. package/dist/lib/resources.js +32 -3
  150. package/dist/lib/routines.d.ts +11 -0
  151. package/dist/lib/routines.js +56 -15
  152. package/dist/lib/runner.d.ts +1 -0
  153. package/dist/lib/runner.js +66 -5
  154. package/dist/lib/secrets/bundles.d.ts +16 -3
  155. package/dist/lib/secrets/bundles.js +206 -37
  156. package/dist/lib/secrets/icloud-import.d.ts +2 -2
  157. package/dist/lib/secrets/icloud-import.js +9 -6
  158. package/dist/lib/secrets/mcp.js +1 -1
  159. package/dist/lib/secrets/vault-age-helper.d.ts +1 -0
  160. package/dist/lib/secrets/vault-age-helper.js +34 -0
  161. package/dist/lib/secrets/vault.d.ts +49 -0
  162. package/dist/lib/secrets/vault.js +397 -0
  163. package/dist/lib/self-heal/checks/path.js +3 -1
  164. package/dist/lib/self-heal/checks/shadowing.js +10 -2
  165. package/dist/lib/self-heal/checks/shims.js +19 -11
  166. package/dist/lib/session/cloud.d.ts +2 -2
  167. package/dist/lib/session/cloud.js +2 -2
  168. package/dist/lib/session/db.d.ts +4 -0
  169. package/dist/lib/session/db.js +44 -7
  170. package/dist/lib/session/discover.d.ts +2 -0
  171. package/dist/lib/session/discover.js +114 -3
  172. package/dist/lib/session/stream-render.d.ts +3 -0
  173. package/dist/lib/session/stream-render.js +130 -0
  174. package/dist/lib/session/types.d.ts +6 -0
  175. package/dist/lib/share/analytics.d.ts +13 -0
  176. package/dist/lib/share/analytics.js +45 -0
  177. package/dist/lib/share/config.d.ts +19 -5
  178. package/dist/lib/share/config.js +44 -8
  179. package/dist/lib/share/provision.d.ts +58 -6
  180. package/dist/lib/share/provision.js +97 -22
  181. package/dist/lib/share/publish.d.ts +36 -13
  182. package/dist/lib/share/publish.js +55 -8
  183. package/dist/lib/share/worker-template.js +83 -17
  184. package/dist/lib/shims.d.ts +9 -0
  185. package/dist/lib/shims.js +91 -13
  186. package/dist/lib/ssh-exec.d.ts +14 -0
  187. package/dist/lib/ssh-exec.js +57 -0
  188. package/dist/lib/staleness/detectors/hooks.js +25 -1
  189. package/dist/lib/staleness/detectors/permissions.js +66 -0
  190. package/dist/lib/staleness/detectors/workflows.js +20 -0
  191. package/dist/lib/staleness/writers/hooks.js +58 -4
  192. package/dist/lib/staleness/writers/permissions.js +2 -2
  193. package/dist/lib/staleness/writers/skills.js +1 -1
  194. package/dist/lib/staleness/writers/sources.d.ts +10 -1
  195. package/dist/lib/staleness/writers/sources.js +68 -1
  196. package/dist/lib/star-nudge.d.ts +45 -0
  197. package/dist/lib/star-nudge.js +91 -0
  198. package/dist/lib/startup/command-registry.d.ts +6 -1
  199. package/dist/lib/startup/command-registry.js +17 -3
  200. package/dist/lib/state.d.ts +2 -0
  201. package/dist/lib/state.js +3 -0
  202. package/dist/lib/subagents-registry.d.ts +2 -0
  203. package/dist/lib/subagents-registry.js +3 -0
  204. package/dist/lib/teams/parsers.js +214 -6
  205. package/dist/lib/teams/supervisor.d.ts +7 -0
  206. package/dist/lib/teams/supervisor.js +2 -1
  207. package/dist/lib/template.d.ts +1 -1
  208. package/dist/lib/template.js +1 -1
  209. package/dist/lib/triggers/webhook.js +36 -3
  210. package/dist/lib/types.d.ts +73 -0
  211. package/dist/lib/version.d.ts +40 -0
  212. package/dist/lib/version.js +94 -16
  213. package/dist/lib/versions.d.ts +11 -0
  214. package/dist/lib/versions.js +208 -76
  215. package/dist/lib/workflows.d.ts +2 -0
  216. package/dist/lib/workflows.js +88 -0
  217. package/package.json +2 -1
  218. package/dist/commands/daemon.d.ts +0 -10
  219. package/dist/commands/daemon.js +0 -121
@@ -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;
@@ -39,6 +39,98 @@ export const KEYCHAIN_BOUND_ON_MAC = new Set(['claude', 'antigravity']);
39
39
  export function isPropagatableAgent(agent) {
40
40
  return agent in FLEET_AUTH_FILES;
41
41
  }
42
+ /** The success-file for an agent — the primary portable credential (first spec). */
43
+ function successFileFor(agent) {
44
+ const specs = FLEET_AUTH_FILES[agent];
45
+ if (!specs || specs.length === 0)
46
+ throw new Error(`no auth file spec for agent '${agent}'`);
47
+ return specs[0].rel;
48
+ }
49
+ /**
50
+ * Per-agent login flows, populated from live login-output captures (the only
51
+ * ground truth — no fixtures exist upstream). Agents whose real device-code
52
+ * output has not been captured are marked honestly (`unknown` / no regexes) so
53
+ * `fleet login` flags them non-remotable instead of guessing.
54
+ *
55
+ * Only the `device-code` entries are ones `fleet login` drives; the rest are
56
+ * carried so the command can explain WHY a logged-out pair is not remotable.
57
+ */
58
+ export const FLEET_LOGIN_FLOWS = {
59
+ // droid: launches its TUI and prints, verbatim:
60
+ // "If the link does not open automatically, please visit
61
+ // https://auth.factory.ai/device and enter code MJQW-NQRM to complete
62
+ // authentication." then "Waiting for authentication to complete...".
63
+ droid: {
64
+ loginCommand: 'droid',
65
+ flowType: 'device-code',
66
+ verificationUrlRegex: /please visit\s+(https:\/\/\S+?)\s+and enter code/i,
67
+ userCodeRegex: /enter code\s+([A-Z0-9]{4}-[A-Z0-9]{4})/i,
68
+ successFile: successFileFor('droid'),
69
+ },
70
+ // codex login shows a numbered menu; option 2 ("Sign in with Device Code",
71
+ // "Sign in from another device with a one-time code") is the remotable one.
72
+ // The post-selection URL/code output was not captured this session, so the
73
+ // regexes are best-effort generic device-code patterns — TODO: tighten once a
74
+ // real codex device-code screen is captured.
75
+ codex: {
76
+ loginCommand: 'codex login',
77
+ flowType: 'device-code',
78
+ deviceCodeSelect: '\x1b[B\r', // down-arrow once (to option 2) + Enter
79
+ verificationUrlRegex: /(https:\/\/\S*(?:device|activate|login|auth)\S*)/i,
80
+ userCodeRegex: /\b([A-Z0-9]{4}-[A-Z0-9]{4})\b/,
81
+ successFile: successFileFor('codex'),
82
+ },
83
+ // kimi: launch bare `kimi`, then `/login` inside the TUI, which prints
84
+ // "Select a platform and authenticate". Post-selection URL/code not captured —
85
+ // best-effort regexes, TODO: tighten with a real capture.
86
+ kimi: {
87
+ loginCommand: 'kimi',
88
+ flowType: 'device-code',
89
+ deviceCodeSelect: '/login\r',
90
+ verificationUrlRegex: /(https:\/\/\S+)/i,
91
+ userCodeRegex: /\b([A-Z0-9]{4}-[A-Z0-9]{4})\b/,
92
+ successFile: successFileFor('kimi'),
93
+ },
94
+ // grok: `grok login`. No captured pattern this session — flowType is
95
+ // device-code (best guess for the remotable path) but regexes are left unset
96
+ // (TODO: capture grok's login output), so scrapeLogin yields nothing until a
97
+ // real pattern is added and driveRemoteLogin will time out rather than guess.
98
+ grok: {
99
+ loginCommand: 'grok login',
100
+ flowType: 'device-code',
101
+ successFile: successFileFor('grok'),
102
+ },
103
+ // antigravity: bare `agy`, Google OAuth. Keychain-bound on macOS and loopback
104
+ // (browser + 127.0.0.1 listener on the box itself) — non-remotable.
105
+ antigravity: {
106
+ loginCommand: 'agy',
107
+ flowType: 'loopback',
108
+ successFile: successFileFor('antigravity'),
109
+ },
110
+ // gemini: bare `gemini`, Google login on launch. Not characterized as a
111
+ // device-code flow (typically loopback) and no captured pattern — mark unknown
112
+ // so it is flagged non-remotable rather than mis-driven.
113
+ gemini: {
114
+ loginCommand: 'gemini',
115
+ flowType: 'unknown',
116
+ successFile: successFileFor('gemini'),
117
+ },
118
+ // opencode: `opencode auth login`. No captured pattern — unknown/non-remotable.
119
+ opencode: {
120
+ loginCommand: 'opencode auth login',
121
+ flowType: 'unknown',
122
+ successFile: successFileFor('opencode'),
123
+ },
124
+ // claude: interactive `/login` inside the TUI is a loopback browser flow, and
125
+ // the token is keychain-bound on macOS. A headless `claude setup-token` device
126
+ // variant exists (TODO: wire as a device-code flow with a captured pattern);
127
+ // until then claude is flagged non-remotable.
128
+ claude: {
129
+ loginCommand: 'claude',
130
+ flowType: 'loopback',
131
+ successFile: successFileFor('claude'),
132
+ },
133
+ };
42
134
  /**
43
135
  * Capture portable credential files for the given agents from a source home.
44
136
  * Returns the readable file payloads plus the list of agents whose auth is