@phnx-labs/agents-cli 1.20.72 → 1.20.74
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +395 -0
- package/README.md +40 -4
- package/dist/bin/agents +0 -0
- package/dist/commands/activity.d.ts +12 -0
- package/dist/commands/activity.js +68 -0
- package/dist/commands/apply.d.ts +13 -0
- package/dist/commands/apply.js +28 -3
- package/dist/commands/browser.js +1 -1
- package/dist/commands/cloud.js +21 -11
- package/dist/commands/doctor.js +21 -5
- package/dist/commands/events.d.ts +11 -10
- package/dist/commands/events.js +30 -21
- package/dist/commands/exec.d.ts +34 -0
- package/dist/commands/exec.js +337 -39
- package/dist/commands/export.d.ts +11 -0
- package/dist/commands/export.js +215 -0
- package/dist/commands/feed.d.ts +13 -0
- package/dist/commands/feed.js +130 -29
- package/dist/commands/hq.d.ts +2 -0
- package/dist/commands/hq.js +58 -0
- package/dist/commands/import.js +51 -3
- package/dist/commands/lease.d.ts +19 -0
- package/dist/commands/lease.js +138 -11
- package/dist/commands/login.d.ts +2 -0
- package/dist/commands/login.js +123 -0
- package/dist/commands/logs.js +1 -1
- package/dist/commands/mine.d.ts +27 -0
- package/dist/commands/mine.js +207 -0
- package/dist/commands/monitors.js +79 -49
- package/dist/commands/plugins.js +1 -0
- package/dist/commands/profiles.js +158 -0
- package/dist/commands/repo.js +2 -60
- package/dist/commands/resources.d.ts +5 -0
- package/dist/commands/resources.js +95 -0
- package/dist/commands/routines.d.ts +2 -0
- package/dist/commands/routines.js +139 -37
- package/dist/commands/secrets.js +234 -47
- package/dist/commands/send.d.ts +15 -0
- package/dist/commands/send.js +62 -0
- package/dist/commands/sessions-tail.d.ts +1 -0
- package/dist/commands/sessions-tail.js +20 -9
- package/dist/commands/sessions.js +18 -3
- package/dist/commands/setup-mine.d.ts +18 -0
- package/dist/commands/setup-mine.js +106 -0
- package/dist/commands/setup-share.js +2 -2
- package/dist/commands/setup.js +9 -2
- package/dist/commands/share.d.ts +8 -1
- package/dist/commands/share.js +122 -37
- package/dist/commands/ssh.d.ts +9 -0
- package/dist/commands/ssh.js +196 -26
- package/dist/commands/sync.js +1 -1
- package/dist/commands/teams.js +11 -2
- package/dist/commands/versions.js +36 -5
- package/dist/commands/view.js +75 -27
- package/dist/index.js +80 -10
- package/dist/lib/activity.d.ts +124 -0
- package/dist/lib/activity.js +542 -0
- package/dist/lib/agents.d.ts +35 -0
- package/dist/lib/agents.js +79 -6
- package/dist/lib/artifact-actions.d.ts +1 -1
- package/dist/lib/artifact-actions.js +1 -1
- package/dist/lib/ask-classifier.d.ts +2 -1
- package/dist/lib/ask-classifier.js +3 -3
- package/dist/lib/auth-health.d.ts +32 -4
- package/dist/lib/auth-health.js +40 -1
- package/dist/lib/auto-pull.js +8 -1
- package/dist/lib/brand.d.ts +40 -0
- package/dist/lib/brand.js +122 -0
- package/dist/lib/browser/drivers/ssh.js +4 -7
- package/dist/lib/browser/service.js +6 -8
- package/dist/lib/channels/providers/index.d.ts +2 -0
- package/dist/lib/channels/providers/index.js +20 -0
- package/dist/lib/channels/providers/mailbox.d.ts +2 -0
- package/dist/lib/channels/providers/mailbox.js +26 -0
- package/dist/lib/channels/providers/openclaw-telegram.d.ts +2 -0
- package/dist/lib/channels/providers/openclaw-telegram.js +34 -0
- package/dist/lib/channels/providers/rush.d.ts +6 -0
- package/dist/lib/channels/providers/rush.js +55 -0
- package/dist/lib/channels/registry.d.ts +42 -0
- package/dist/lib/channels/registry.js +20 -0
- package/dist/lib/channels/resolve.d.ts +11 -0
- package/dist/lib/channels/resolve.js +12 -0
- package/dist/lib/cli-resources.d.ts +18 -0
- package/dist/lib/cli-resources.js +53 -4
- package/dist/lib/cloud/codex.js +6 -3
- package/dist/lib/cloud/factory.d.ts +1 -0
- package/dist/lib/cloud/factory.js +10 -4
- package/dist/lib/cloud/rush.d.ts +3 -1
- package/dist/lib/cloud/rush.js +6 -1
- package/dist/lib/cloud/types.d.ts +2 -0
- package/dist/lib/codex-home.d.ts +35 -0
- package/dist/lib/codex-home.js +136 -0
- package/dist/lib/config-transfer.d.ts +16 -0
- package/dist/lib/config-transfer.js +56 -0
- package/dist/lib/crabbox/cli.d.ts +44 -0
- package/dist/lib/crabbox/cli.js +120 -20
- package/dist/lib/crabbox/lease.d.ts +52 -1
- package/dist/lib/crabbox/lease.js +117 -16
- package/dist/lib/crabbox/progress.d.ts +31 -0
- package/dist/lib/crabbox/progress.js +76 -0
- package/dist/lib/crabbox/runtimes.d.ts +4 -0
- package/dist/lib/crabbox/runtimes.js +33 -8
- package/dist/lib/crabbox/setup-copy.d.ts +87 -0
- package/dist/lib/crabbox/setup-copy.js +127 -0
- package/dist/lib/daemon.d.ts +5 -5
- package/dist/lib/daemon.js +9 -9
- package/dist/lib/drive-sync.js +6 -3
- package/dist/lib/event-stream.d.ts +36 -0
- package/dist/lib/event-stream.js +68 -0
- package/dist/lib/events.d.ts +1 -1
- package/dist/lib/exec.d.ts +11 -0
- package/dist/lib/exec.js +23 -2
- package/dist/lib/export.d.ts +72 -0
- package/dist/lib/export.js +269 -0
- package/dist/lib/feed-policy.d.ts +1 -1
- package/dist/lib/feed-policy.js +14 -9
- package/dist/lib/feed-ranking.d.ts +32 -0
- package/dist/lib/feed-ranking.js +224 -0
- package/dist/lib/feed.d.ts +35 -2
- package/dist/lib/feed.js +66 -3
- package/dist/lib/fleet/auth-sync.d.ts +53 -0
- package/dist/lib/fleet/auth-sync.js +92 -0
- package/dist/lib/fleet/remote-login.d.ts +187 -0
- package/dist/lib/fleet/remote-login.js +556 -0
- package/dist/lib/format.d.ts +30 -3
- package/dist/lib/format.js +34 -5
- package/dist/lib/fs-atomic.d.ts +7 -2
- package/dist/lib/fs-atomic.js +8 -12
- package/dist/lib/git.d.ts +16 -0
- package/dist/lib/git.js +79 -2
- package/dist/lib/heal.js +11 -3
- package/dist/lib/hosts/progress.d.ts +28 -10
- package/dist/lib/hosts/progress.js +79 -22
- package/dist/lib/hosts/remote-cmd.js +4 -0
- package/dist/lib/hq/floor.d.ts +87 -0
- package/dist/lib/hq/floor.js +226 -0
- package/dist/lib/import.d.ts +23 -0
- package/dist/lib/import.js +74 -2
- package/dist/lib/isolation-boundary-report.d.ts +10 -0
- package/dist/lib/isolation-boundary-report.js +35 -0
- package/dist/lib/menubar/install-menubar.js +14 -20
- package/dist/lib/notify.d.ts +1 -0
- package/dist/lib/notify.js +3 -3
- package/dist/lib/open-url.d.ts +2 -0
- package/dist/lib/open-url.js +19 -0
- package/dist/lib/openclaw-keychain.d.ts +56 -0
- package/dist/lib/openclaw-keychain.js +236 -0
- package/dist/lib/overdue.js +10 -0
- package/dist/lib/permissions.d.ts +44 -1
- package/dist/lib/permissions.js +284 -7
- package/dist/lib/project-launch.d.ts +6 -12
- package/dist/lib/project-launch.js +13 -228
- package/dist/lib/project-resources.d.ts +7 -0
- package/dist/lib/project-resources.js +306 -0
- package/dist/lib/pty-client.d.ts +27 -0
- package/dist/lib/pty-client.js +136 -10
- package/dist/lib/refresh.js +14 -10
- package/dist/lib/resource-profiles.d.ts +26 -0
- package/dist/lib/resource-profiles.js +157 -0
- package/dist/lib/resources/permissions.js +7 -1
- package/dist/lib/resources/types.d.ts +1 -1
- package/dist/lib/resources.d.ts +1 -1
- package/dist/lib/resources.js +32 -3
- package/dist/lib/routines.d.ts +11 -0
- package/dist/lib/routines.js +56 -15
- package/dist/lib/runner.d.ts +1 -0
- package/dist/lib/runner.js +66 -5
- package/dist/lib/secrets/bundles.d.ts +16 -3
- package/dist/lib/secrets/bundles.js +206 -37
- package/dist/lib/secrets/icloud-import.d.ts +2 -2
- package/dist/lib/secrets/icloud-import.js +9 -6
- package/dist/lib/secrets/mcp.js +1 -1
- package/dist/lib/secrets/vault-age-helper.d.ts +1 -0
- package/dist/lib/secrets/vault-age-helper.js +34 -0
- package/dist/lib/secrets/vault.d.ts +49 -0
- package/dist/lib/secrets/vault.js +397 -0
- package/dist/lib/self-heal/checks/path.js +3 -1
- package/dist/lib/self-heal/checks/shadowing.js +10 -2
- package/dist/lib/self-heal/checks/shims.js +19 -11
- package/dist/lib/session/cloud.d.ts +2 -2
- package/dist/lib/session/cloud.js +2 -2
- package/dist/lib/session/db.d.ts +4 -0
- package/dist/lib/session/db.js +44 -7
- package/dist/lib/session/discover.d.ts +2 -0
- package/dist/lib/session/discover.js +114 -3
- package/dist/lib/session/stream-render.d.ts +3 -0
- package/dist/lib/session/stream-render.js +130 -0
- package/dist/lib/session/types.d.ts +6 -0
- package/dist/lib/share/analytics.d.ts +13 -0
- package/dist/lib/share/analytics.js +45 -0
- package/dist/lib/share/config.d.ts +19 -5
- package/dist/lib/share/config.js +44 -8
- package/dist/lib/share/provision.d.ts +58 -6
- package/dist/lib/share/provision.js +97 -22
- package/dist/lib/share/publish.d.ts +36 -13
- package/dist/lib/share/publish.js +55 -8
- package/dist/lib/share/worker-template.js +83 -17
- package/dist/lib/shims.d.ts +35 -0
- package/dist/lib/shims.js +159 -13
- package/dist/lib/ssh-exec.d.ts +14 -0
- package/dist/lib/ssh-exec.js +57 -0
- package/dist/lib/staleness/detectors/hooks.js +25 -1
- package/dist/lib/staleness/detectors/permissions.js +66 -0
- package/dist/lib/staleness/detectors/workflows.js +20 -0
- package/dist/lib/staleness/writers/hooks.js +58 -4
- package/dist/lib/staleness/writers/permissions.js +2 -2
- package/dist/lib/staleness/writers/skills.js +1 -1
- package/dist/lib/staleness/writers/sources.d.ts +10 -1
- package/dist/lib/staleness/writers/sources.js +68 -1
- package/dist/lib/star-nudge.d.ts +45 -0
- package/dist/lib/star-nudge.js +91 -0
- package/dist/lib/startup/command-registry.d.ts +7 -1
- package/dist/lib/startup/command-registry.js +19 -3
- package/dist/lib/state.d.ts +2 -0
- package/dist/lib/state.js +17 -4
- package/dist/lib/subagents-registry.d.ts +2 -0
- package/dist/lib/subagents-registry.js +3 -0
- package/dist/lib/teams/parsers.js +214 -6
- package/dist/lib/teams/supervisor.d.ts +7 -0
- package/dist/lib/teams/supervisor.js +2 -1
- package/dist/lib/template.d.ts +1 -1
- package/dist/lib/template.js +1 -1
- package/dist/lib/triggers/webhook.js +36 -3
- package/dist/lib/types.d.ts +84 -0
- package/dist/lib/uninstall.js +1 -45
- package/dist/lib/version.d.ts +40 -0
- package/dist/lib/version.js +94 -16
- package/dist/lib/versions.d.ts +24 -0
- package/dist/lib/versions.js +269 -78
- package/dist/lib/workflows.d.ts +2 -0
- package/dist/lib/workflows.js +88 -0
- package/package.json +2 -1
- package/dist/commands/daemon.d.ts +0 -10
- package/dist/commands/daemon.js +0 -121
|
@@ -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
|
+
}
|
package/dist/lib/feed.d.ts
CHANGED
|
@@ -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 || !
|
|
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":
|
|
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
|