@phnx-labs/agents-cli 1.20.71 → 1.20.73
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +325 -1
- 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/commands.js +2 -0
- package/dist/commands/doctor.js +8 -4
- 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/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/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 +39 -10
- package/dist/commands/mcp.js +2 -0
- 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/resource-view.d.ts +2 -0
- package/dist/commands/resource-view.js +8 -0
- 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.d.ts +6 -0
- package/dist/commands/sessions.js +26 -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 +3 -1
- package/dist/commands/share.d.ts +8 -1
- package/dist/commands/share.js +122 -37
- package/dist/commands/skills.js +2 -0
- package/dist/commands/ssh.d.ts +9 -0
- package/dist/commands/ssh.js +196 -26
- package/dist/commands/status.js +8 -2
- package/dist/commands/subagents.js +3 -1
- package/dist/commands/sync.js +1 -1
- package/dist/commands/teams.js +11 -2
- package/dist/commands/uninstall.d.ts +11 -0
- package/dist/commands/uninstall.js +158 -0
- package/dist/commands/utils.d.ts +29 -0
- package/dist/commands/utils.js +24 -0
- package/dist/commands/versions.js +120 -11
- package/dist/index.js +83 -12
- package/dist/lib/activity.d.ts +124 -0
- package/dist/lib/activity.js +542 -0
- package/dist/lib/agents.js +16 -5
- 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/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 +14 -5
- package/dist/lib/daemon.js +52 -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/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/logs.d.ts +12 -0
- package/dist/lib/hosts/logs.js +18 -0
- 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/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 +291 -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 +36 -0
- package/dist/lib/shims.js +120 -16
- 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 +3 -0
- 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 +73 -0
- package/dist/lib/uninstall.d.ts +84 -0
- package/dist/lib/uninstall.js +347 -0
- package/dist/lib/usage.d.ts +13 -1
- package/dist/lib/usage.js +32 -14
- package/dist/lib/version.d.ts +40 -0
- package/dist/lib/version.js +94 -16
- package/dist/lib/versions.d.ts +27 -0
- package/dist/lib/versions.js +248 -79
- 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
package/dist/lib/exec.js
CHANGED
|
@@ -15,7 +15,8 @@ import { getBinaryPath, getVersionHomePath, isVersionInstalled, resolveVersion }
|
|
|
15
15
|
import { resolveModel, buildReasoningFlags } from './models.js';
|
|
16
16
|
import { maybeRotate, createTimer, redactPrompt, redactArgs } from './events.js';
|
|
17
17
|
import { sanitizeProcessEnv } from './secrets/bundles.js';
|
|
18
|
-
import { getShimsDir } from './state.js';
|
|
18
|
+
import { getShimsDir, getHistoryDir } from './state.js';
|
|
19
|
+
import { resolveCodexHome } from './codex-home.js';
|
|
19
20
|
import { readCodexConfiguredModel } from './shims.js';
|
|
20
21
|
import { writePidSessionEntry, extractSessionIdArg } from './session/pid-registry.js';
|
|
21
22
|
import { recordRunName } from './session/run-names.js';
|
|
@@ -153,6 +154,21 @@ export function resolveInteractive(options) {
|
|
|
153
154
|
return false;
|
|
154
155
|
return options.prompt === undefined;
|
|
155
156
|
}
|
|
157
|
+
/**
|
|
158
|
+
* True when a run resolved to *inferred* interactive intent — no prompt and no
|
|
159
|
+
* explicit `--interactive` — but there is no terminal to host the REPL. Launching
|
|
160
|
+
* would attach a TUI to a dead stdin and hang forever, so the caller should fail
|
|
161
|
+
* fast with the headless alternatives instead (RUSH-1829).
|
|
162
|
+
*
|
|
163
|
+
* An explicit `--interactive` is the caller's deliberate choice and is never
|
|
164
|
+
* blocked (they may be driving a PTY we can't detect). Pure — the TTY state is a
|
|
165
|
+
* parameter so this is unit-testable without touching `process.std*`.
|
|
166
|
+
*/
|
|
167
|
+
export function inferredInteractiveWithoutTty(options, isTty) {
|
|
168
|
+
if (options.interactive === true)
|
|
169
|
+
return false;
|
|
170
|
+
return resolveInteractive(options) && !isTty;
|
|
171
|
+
}
|
|
156
172
|
/**
|
|
157
173
|
* Decide whether spawnAgent must capture (PIPE + tee) the child's stdout so the
|
|
158
174
|
* live budget watcher can parse it (issue #346, FIX 3).
|
|
@@ -241,7 +257,12 @@ export function buildExecEnv(options) {
|
|
|
241
257
|
? resolvedVersion
|
|
242
258
|
: (resolvedVersion && isVersionInstalled('codex', resolvedVersion) ? resolvedVersion : null);
|
|
243
259
|
if (version) {
|
|
244
|
-
|
|
260
|
+
// On macOS the deep versioned home overflows the Unix-socket SUN_LEN
|
|
261
|
+
// limit for codex's app-server control socket; resolve to a short,
|
|
262
|
+
// SUN_LEN-safe home (migrating once if needed). See codex-home.ts.
|
|
263
|
+
const versionedHome = path.join(getVersionHomePath('codex', version), '.codex');
|
|
264
|
+
const agentsUserDir = path.dirname(getHistoryDir());
|
|
265
|
+
result.CODEX_HOME = resolveCodexHome(versionedHome, agentsUserDir, version);
|
|
245
266
|
}
|
|
246
267
|
delete result.CLAUDE_CONFIG_DIR;
|
|
247
268
|
delete result.COPILOT_HOME;
|
|
@@ -27,4 +27,4 @@ export interface PolicyResult {
|
|
|
27
27
|
* Apply policy to a single open block. Returns the action taken (none/defaulted/parked).
|
|
28
28
|
* Caller is responsible for persistence/logging side effects not owned by feed.ts.
|
|
29
29
|
*/
|
|
30
|
-
export declare function applyPolicyToBlock(block: OpenBlock, policy: FeedPolicy, now: Date, root?: string): PolicyResult;
|
|
30
|
+
export declare function applyPolicyToBlock(block: OpenBlock, policy: FeedPolicy, now: Date, root?: string, mailboxRoot?: string): PolicyResult;
|
package/dist/lib/feed-policy.js
CHANGED
|
@@ -28,12 +28,12 @@ export const DEFAULT_POLICY = {
|
|
|
28
28
|
export function getPolicyPath(root) {
|
|
29
29
|
return path.join(root ?? getUserAgentsDir(), POLICY_FILE);
|
|
30
30
|
}
|
|
31
|
-
function normalizeClassPolicy(raw) {
|
|
31
|
+
function normalizeClassPolicy(raw, fallback) {
|
|
32
32
|
const p = (raw ?? {});
|
|
33
|
-
const timeout = typeof p.timeoutMinutes === 'number' ? p.timeoutMinutes :
|
|
33
|
+
const timeout = typeof p.timeoutMinutes === 'number' ? p.timeoutMinutes : fallback.timeoutMinutes;
|
|
34
34
|
return {
|
|
35
35
|
timeoutMinutes: Math.max(1, Math.round(timeout)),
|
|
36
|
-
safeDefault: typeof p.safeDefault === 'string' ? p.safeDefault :
|
|
36
|
+
safeDefault: typeof p.safeDefault === 'string' ? p.safeDefault : fallback.safeDefault,
|
|
37
37
|
};
|
|
38
38
|
}
|
|
39
39
|
export function loadPolicy(root) {
|
|
@@ -45,8 +45,8 @@ export function loadPolicy(root) {
|
|
|
45
45
|
const p = parsed;
|
|
46
46
|
const threshold = p.phoneNotifyThreshold;
|
|
47
47
|
return {
|
|
48
|
-
approval: normalizeClassPolicy(p.approval
|
|
49
|
-
decision: normalizeClassPolicy(p.decision
|
|
48
|
+
approval: normalizeClassPolicy(p.approval, DEFAULT_POLICY.approval),
|
|
49
|
+
decision: normalizeClassPolicy(p.decision, DEFAULT_POLICY.decision),
|
|
50
50
|
phoneNotifyThreshold: threshold === 'low' || threshold === 'medium' || threshold === 'high' ? threshold : DEFAULT_POLICY.phoneNotifyThreshold,
|
|
51
51
|
};
|
|
52
52
|
}
|
|
@@ -71,16 +71,21 @@ export function minutesElapsed(block, now) {
|
|
|
71
71
|
return 0;
|
|
72
72
|
return (now.getTime() - ts) / 60_000;
|
|
73
73
|
}
|
|
74
|
+
function timeoutMinutesForBlock(block, policy) {
|
|
75
|
+
if (typeof block.timeoutMinutes === 'number' && Number.isFinite(block.timeoutMinutes) && block.timeoutMinutes > 0) {
|
|
76
|
+
return Math.max(1, Math.round(block.timeoutMinutes));
|
|
77
|
+
}
|
|
78
|
+
return policy[blockClass(block)].timeoutMinutes;
|
|
79
|
+
}
|
|
74
80
|
export function isTimedOut(block, policy, now) {
|
|
75
|
-
const cls = blockClass(block);
|
|
76
81
|
const minutes = minutesElapsed(block, now);
|
|
77
|
-
return minutes >= policy
|
|
82
|
+
return minutes >= timeoutMinutesForBlock(block, policy);
|
|
78
83
|
}
|
|
79
84
|
/**
|
|
80
85
|
* Apply policy to a single open block. Returns the action taken (none/defaulted/parked).
|
|
81
86
|
* Caller is responsible for persistence/logging side effects not owned by feed.ts.
|
|
82
87
|
*/
|
|
83
|
-
export function applyPolicyToBlock(block, policy, now, root) {
|
|
88
|
+
export function applyPolicyToBlock(block, policy, now, root, mailboxRoot) {
|
|
84
89
|
if (block.answer || block.parkedAt || block.defaultedAt) {
|
|
85
90
|
return { blockId: block.blockId, action: 'none' };
|
|
86
91
|
}
|
|
@@ -97,7 +102,7 @@ export function applyPolicyToBlock(block, policy, now, root) {
|
|
|
97
102
|
if (!claim.ok) {
|
|
98
103
|
return { blockId: block.blockId, action: 'none' };
|
|
99
104
|
}
|
|
100
|
-
const msgId = enqueue(mailboxDir(block.mailboxId,
|
|
105
|
+
const msgId = enqueue(mailboxDir(block.mailboxId, mailboxRoot), {
|
|
101
106
|
to: block.mailboxId,
|
|
102
107
|
text: safeDefault,
|
|
103
108
|
from: 'policy',
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import type { OpenBlock, FeedAskStats } from './feed.js';
|
|
2
|
+
import type { Classification } from './ask-classifier.js';
|
|
3
|
+
import type { ActiveSession } from './session/active.js';
|
|
4
|
+
import type { SessionMeta } from './session/types.js';
|
|
5
|
+
export interface FeedSessionSignal {
|
|
6
|
+
sessionId?: string;
|
|
7
|
+
mailboxId?: string;
|
|
8
|
+
kind?: string;
|
|
9
|
+
host?: string;
|
|
10
|
+
context?: ActiveSession['context'];
|
|
11
|
+
runtime?: string;
|
|
12
|
+
pid?: number;
|
|
13
|
+
cwd?: string;
|
|
14
|
+
startedAtMs?: number;
|
|
15
|
+
status?: ActiveSession['status'];
|
|
16
|
+
tokPerSec?: number;
|
|
17
|
+
costUsd?: number;
|
|
18
|
+
durationMs?: number;
|
|
19
|
+
cloudProvider?: string;
|
|
20
|
+
cloudTaskId?: string;
|
|
21
|
+
}
|
|
22
|
+
export interface RankedFeedBlock extends OpenBlock {
|
|
23
|
+
delayRank: NonNullable<OpenBlock['delayRank']>;
|
|
24
|
+
}
|
|
25
|
+
export declare function buildSessionSignals(active: ActiveSession[], metas?: SessionMeta[]): FeedSessionSignal[];
|
|
26
|
+
export declare function decisionIrreducibility(c: Classification): number;
|
|
27
|
+
export declare function burnUsdPerHour(signal: FeedSessionSignal | undefined, block: OpenBlock): number;
|
|
28
|
+
export declare function rankFeedBlocks(blocks: OpenBlock[], signals?: FeedSessionSignal[], now?: Date): RankedFeedBlock[];
|
|
29
|
+
export declare function recentAskCount(stats: FeedAskStats, now?: Date): number;
|
|
30
|
+
export declare function needyControlCards(stats: FeedAskStats[], signals?: FeedSessionSignal[], now?: Date, threshold?: number): OpenBlock[];
|
|
31
|
+
export declare function runawayControlCards(signals: FeedSessionSignal[], now?: Date): OpenBlock[];
|
|
32
|
+
export declare function synthesizeControlCards(signals: FeedSessionSignal[], stats: FeedAskStats[], now?: Date): OpenBlock[];
|
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
import { classifyBlock } from './ask-classifier.js';
|
|
2
|
+
import { outcomeForBlock } from './feed-outcome.js';
|
|
3
|
+
const MINUTES_PER_HOUR = 60;
|
|
4
|
+
const NEEDY_ASKS_PER_HOUR = 6;
|
|
5
|
+
const RUNAWAY_TOK_PER_SEC = 250;
|
|
6
|
+
const RUNAWAY_BURN_USD_PER_HOUR = 25;
|
|
7
|
+
const RELAUNCH_LOOP_COUNT = 3;
|
|
8
|
+
const RELAUNCH_LOOP_WINDOW_MS = 10 * 60_000;
|
|
9
|
+
function finiteNumber(value) {
|
|
10
|
+
return typeof value === 'number' && Number.isFinite(value) ? value : undefined;
|
|
11
|
+
}
|
|
12
|
+
function signalKey(signal) {
|
|
13
|
+
return [signal.mailboxId, signal.sessionId, signal.cloudTaskId].filter((v) => !!v);
|
|
14
|
+
}
|
|
15
|
+
export function buildSessionSignals(active, metas = []) {
|
|
16
|
+
const metaById = new Map(metas.map((m) => [m.id, m]));
|
|
17
|
+
return active.map((s) => {
|
|
18
|
+
const sessionId = s.sessionId;
|
|
19
|
+
const meta = sessionId ? metaById.get(sessionId) : undefined;
|
|
20
|
+
return {
|
|
21
|
+
sessionId,
|
|
22
|
+
mailboxId: s.agentId ?? s.sessionId ?? s.cloudTaskId,
|
|
23
|
+
kind: s.kind,
|
|
24
|
+
host: s.machine ?? s.provenance?.host ?? s.host,
|
|
25
|
+
context: s.context,
|
|
26
|
+
runtime: s.context,
|
|
27
|
+
pid: s.pid,
|
|
28
|
+
cwd: s.cwd,
|
|
29
|
+
startedAtMs: s.startedAtMs,
|
|
30
|
+
status: s.status,
|
|
31
|
+
tokPerSec: s.tokPerSec,
|
|
32
|
+
costUsd: meta?.costUsd,
|
|
33
|
+
durationMs: meta?.durationMs,
|
|
34
|
+
cloudProvider: s.cloudProvider,
|
|
35
|
+
cloudTaskId: s.cloudTaskId,
|
|
36
|
+
};
|
|
37
|
+
});
|
|
38
|
+
}
|
|
39
|
+
function indexSignals(signals) {
|
|
40
|
+
const out = new Map();
|
|
41
|
+
for (const signal of signals) {
|
|
42
|
+
for (const key of signalKey(signal)) {
|
|
43
|
+
if (!out.has(key))
|
|
44
|
+
out.set(key, signal);
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
return out;
|
|
48
|
+
}
|
|
49
|
+
function signalForBlock(block, signals) {
|
|
50
|
+
return signals.get(block.mailboxId) ?? signals.get(block.sessionId);
|
|
51
|
+
}
|
|
52
|
+
export function decisionIrreducibility(c) {
|
|
53
|
+
switch (c.class) {
|
|
54
|
+
case 'decision': return 1;
|
|
55
|
+
case 'approval': return 0.8;
|
|
56
|
+
case 'clarification': return 0.6;
|
|
57
|
+
case 'stall':
|
|
58
|
+
case 'fyi':
|
|
59
|
+
return 0;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
function fallbackBurnRate(block) {
|
|
63
|
+
switch (block.costOfDelay) {
|
|
64
|
+
case 'high': return 10;
|
|
65
|
+
case 'medium': return 3;
|
|
66
|
+
case 'low': return 1;
|
|
67
|
+
default: return 1;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
export function burnUsdPerHour(signal, block) {
|
|
71
|
+
const cost = finiteNumber(signal?.costUsd);
|
|
72
|
+
const duration = finiteNumber(signal?.durationMs);
|
|
73
|
+
if (cost !== undefined && duration !== undefined && duration > 0) {
|
|
74
|
+
return cost / (duration / 3_600_000);
|
|
75
|
+
}
|
|
76
|
+
const existing = finiteNumber(block.delayRank?.burnUsdPerHour);
|
|
77
|
+
if (existing !== undefined)
|
|
78
|
+
return existing;
|
|
79
|
+
return fallbackBurnRate(block);
|
|
80
|
+
}
|
|
81
|
+
function idleMinutes(block, nowMs) {
|
|
82
|
+
const ts = Date.parse(block.ts);
|
|
83
|
+
if (!Number.isFinite(ts))
|
|
84
|
+
return 0;
|
|
85
|
+
return Math.max(0, (nowMs - ts) / 60_000);
|
|
86
|
+
}
|
|
87
|
+
function blastRadii(blocks) {
|
|
88
|
+
const counts = new Map();
|
|
89
|
+
for (const block of blocks) {
|
|
90
|
+
const key = outcomeForBlock(block).key;
|
|
91
|
+
counts.set(key, (counts.get(key) ?? 0) + 1);
|
|
92
|
+
}
|
|
93
|
+
const out = new Map();
|
|
94
|
+
for (const block of blocks) {
|
|
95
|
+
out.set(block.blockId, Math.max(1, finiteNumber(block.downstreamAgents) ?? counts.get(outcomeForBlock(block).key) ?? 1));
|
|
96
|
+
}
|
|
97
|
+
return out;
|
|
98
|
+
}
|
|
99
|
+
export function rankFeedBlocks(blocks, signals = [], now = new Date()) {
|
|
100
|
+
const bySignal = indexSignals(signals);
|
|
101
|
+
const radii = blastRadii(blocks);
|
|
102
|
+
const nowMs = now.getTime();
|
|
103
|
+
return blocks
|
|
104
|
+
.map((block) => {
|
|
105
|
+
const c = classifyBlock(block);
|
|
106
|
+
const idle = idleMinutes(block, nowMs);
|
|
107
|
+
const blastRadius = radii.get(block.blockId) ?? 1;
|
|
108
|
+
const burn = burnUsdPerHour(signalForBlock(block, bySignal), block);
|
|
109
|
+
const irreducible = finiteNumber(block.delayRank?.decisionIrreducibility) ?? decisionIrreducibility(c);
|
|
110
|
+
const score = idle * blastRadius * burn * irreducible;
|
|
111
|
+
return {
|
|
112
|
+
...block,
|
|
113
|
+
delayRank: {
|
|
114
|
+
score,
|
|
115
|
+
idleMinutes: idle,
|
|
116
|
+
blastRadius,
|
|
117
|
+
burnUsdPerHour: burn,
|
|
118
|
+
decisionIrreducibility: irreducible,
|
|
119
|
+
},
|
|
120
|
+
};
|
|
121
|
+
})
|
|
122
|
+
.sort((a, b) => {
|
|
123
|
+
if (b.delayRank.score !== a.delayRank.score)
|
|
124
|
+
return b.delayRank.score - a.delayRank.score;
|
|
125
|
+
return Date.parse(a.ts) - Date.parse(b.ts);
|
|
126
|
+
});
|
|
127
|
+
}
|
|
128
|
+
export function recentAskCount(stats, now = new Date()) {
|
|
129
|
+
const cutoff = now.getTime() - 60 * 60_000;
|
|
130
|
+
return stats.recentAskTimestamps.filter((ts) => {
|
|
131
|
+
const parsed = Date.parse(ts);
|
|
132
|
+
return Number.isFinite(parsed) && parsed >= cutoff;
|
|
133
|
+
}).length;
|
|
134
|
+
}
|
|
135
|
+
export function needyControlCards(stats, signals = [], now = new Date(), threshold = NEEDY_ASKS_PER_HOUR) {
|
|
136
|
+
const bySignal = indexSignals(signals);
|
|
137
|
+
const cards = [];
|
|
138
|
+
for (const row of stats) {
|
|
139
|
+
const count = recentAskCount(row, now);
|
|
140
|
+
if (count < threshold)
|
|
141
|
+
continue;
|
|
142
|
+
const signal = bySignal.get(row.mailboxId) ?? bySignal.get(row.sessionId);
|
|
143
|
+
cards.push({
|
|
144
|
+
blockId: `control-needy-${row.sessionId.replace(/[^A-Za-z0-9._-]/g, '-')}`,
|
|
145
|
+
sessionId: row.sessionId,
|
|
146
|
+
mailboxId: row.mailboxId,
|
|
147
|
+
host: signal?.host ?? 'local',
|
|
148
|
+
runtime: signal?.runtime ?? signal?.context ?? 'unknown',
|
|
149
|
+
ts: row.lastAskAt,
|
|
150
|
+
kind: 'control',
|
|
151
|
+
questions: [{
|
|
152
|
+
header: 'Needy agent',
|
|
153
|
+
text: `${row.mailboxId} asked ${count} times in the last hour. Inspect the session; this is likely a loop bug, not ${count} separate decisions.`,
|
|
154
|
+
}],
|
|
155
|
+
needy: {
|
|
156
|
+
askCountLastHour: count,
|
|
157
|
+
threshold,
|
|
158
|
+
totalAskCount: row.totalAskCount,
|
|
159
|
+
},
|
|
160
|
+
});
|
|
161
|
+
}
|
|
162
|
+
return cards;
|
|
163
|
+
}
|
|
164
|
+
function relaunchLoopCount(signal, signals, nowMs) {
|
|
165
|
+
if (!signal.cwd || !signal.kind || !signal.startedAtMs)
|
|
166
|
+
return 0;
|
|
167
|
+
return signals.filter((candidate) => candidate.kind === signal.kind &&
|
|
168
|
+
candidate.cwd === signal.cwd &&
|
|
169
|
+
candidate.startedAtMs !== undefined &&
|
|
170
|
+
nowMs - candidate.startedAtMs <= RELAUNCH_LOOP_WINDOW_MS).length;
|
|
171
|
+
}
|
|
172
|
+
export function runawayControlCards(signals, now = new Date()) {
|
|
173
|
+
const nowMs = now.getTime();
|
|
174
|
+
const cards = [];
|
|
175
|
+
const seen = new Set();
|
|
176
|
+
for (const signal of signals) {
|
|
177
|
+
const tokPerSec = finiteNumber(signal.tokPerSec);
|
|
178
|
+
const cost = finiteNumber(signal.costUsd);
|
|
179
|
+
const duration = finiteNumber(signal.durationMs);
|
|
180
|
+
const burn = cost !== undefined && duration !== undefined && duration > 0
|
|
181
|
+
? cost / (duration / 3_600_000)
|
|
182
|
+
: undefined;
|
|
183
|
+
const loopCount = relaunchLoopCount(signal, signals, nowMs);
|
|
184
|
+
const reasons = [];
|
|
185
|
+
if (tokPerSec !== undefined && tokPerSec >= RUNAWAY_TOK_PER_SEC)
|
|
186
|
+
reasons.push(`${Math.round(tokPerSec)} tok/s`);
|
|
187
|
+
if (burn !== undefined && burn >= RUNAWAY_BURN_USD_PER_HOUR)
|
|
188
|
+
reasons.push(`$${burn.toFixed(2)}/hr`);
|
|
189
|
+
if (loopCount >= RELAUNCH_LOOP_COUNT)
|
|
190
|
+
reasons.push(`${loopCount} launches in 10 minutes`);
|
|
191
|
+
if (reasons.length === 0)
|
|
192
|
+
continue;
|
|
193
|
+
const id = signal.mailboxId ?? signal.sessionId ?? signal.cloudTaskId;
|
|
194
|
+
if (!id || seen.has(id))
|
|
195
|
+
continue;
|
|
196
|
+
seen.add(id);
|
|
197
|
+
cards.push({
|
|
198
|
+
blockId: `control-runaway-${id.replace(/[^A-Za-z0-9._-]/g, '-')}`,
|
|
199
|
+
sessionId: signal.sessionId ?? id,
|
|
200
|
+
mailboxId: signal.mailboxId ?? id,
|
|
201
|
+
host: signal.host ?? 'local',
|
|
202
|
+
runtime: signal.runtime ?? signal.context ?? 'unknown',
|
|
203
|
+
ts: new Date(signal.startedAtMs ?? nowMs).toISOString(),
|
|
204
|
+
kind: 'control',
|
|
205
|
+
questions: [{
|
|
206
|
+
header: 'Runaway agent',
|
|
207
|
+
text: `${id} is burning abnormally (${reasons.join(', ')}). Pause or kill it before it burns more parallel capacity.`,
|
|
208
|
+
}],
|
|
209
|
+
runaway: {
|
|
210
|
+
reason: reasons.join(', '),
|
|
211
|
+
tokPerSec,
|
|
212
|
+
burnUsdPerHour: burn,
|
|
213
|
+
relaunchesPerTenMinutes: loopCount || undefined,
|
|
214
|
+
},
|
|
215
|
+
});
|
|
216
|
+
}
|
|
217
|
+
return cards;
|
|
218
|
+
}
|
|
219
|
+
export function synthesizeControlCards(signals, stats, now = new Date()) {
|
|
220
|
+
return [
|
|
221
|
+
...runawayControlCards(signals, now),
|
|
222
|
+
...needyControlCards(stats, signals, now),
|
|
223
|
+
];
|
|
224
|
+
}
|
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;
|