@phnx-labs/agents-cli 1.22.6 → 1.22.8
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 +131 -1
- package/README.md +7 -0
- package/dist/bin/agents +0 -0
- package/dist/commands/browser.js +61 -0
- package/dist/commands/exec.js +83 -16
- package/dist/commands/feed.d.ts +2 -1
- package/dist/commands/feed.js +47 -20
- package/dist/commands/focus.js +22 -1
- package/dist/commands/harness.d.ts +0 -1
- package/dist/commands/harness.js +60 -4
- package/dist/commands/models.js +2 -2
- package/dist/commands/monitors.js +2 -2
- package/dist/commands/projects.js +122 -107
- package/dist/commands/routines.js +2 -2
- package/dist/commands/run-account-picker.js +2 -0
- package/dist/commands/secrets.js +1 -1
- package/dist/commands/sessions-backfill.d.ts +33 -0
- package/dist/commands/sessions-backfill.js +83 -1
- package/dist/commands/sessions-stats.d.ts +36 -0
- package/dist/commands/sessions-stats.js +263 -0
- package/dist/commands/sessions.d.ts +1 -1
- package/dist/commands/sessions.js +21 -1
- package/dist/commands/snapshot.d.ts +11 -0
- package/dist/commands/snapshot.js +107 -0
- package/dist/commands/teams.js +2 -1
- package/dist/commands/view.d.ts +9 -1
- package/dist/commands/view.js +63 -12
- package/dist/index.js +4 -2
- package/dist/lib/activity.js +2 -2
- package/dist/lib/agents.js +68 -11
- package/dist/lib/analytics/recipes.js +11 -5
- package/dist/lib/browser/ipc.js +2 -0
- package/dist/lib/browser/profiles.d.ts +15 -7
- package/dist/lib/browser/profiles.js +53 -12
- package/dist/lib/browser/remote-control.d.ts +35 -0
- package/dist/lib/browser/remote-control.js +48 -0
- package/dist/lib/browser/service.d.ts +19 -0
- package/dist/lib/browser/service.js +19 -1
- package/dist/lib/browser/types.d.ts +14 -2
- package/dist/lib/byok-usage.d.ts +38 -0
- package/dist/lib/byok-usage.js +117 -0
- package/dist/lib/capabilities.js +1 -1
- package/dist/lib/device-config.js +8 -0
- package/dist/lib/exec.d.ts +20 -0
- package/dist/lib/exec.js +73 -8
- package/dist/lib/feed-outcome.d.ts +1 -0
- package/dist/lib/feed-outcome.js +2 -0
- package/dist/lib/feed-post.js +1 -1
- package/dist/lib/feed-ranking.d.ts +1 -0
- package/dist/lib/feed-ranking.js +4 -0
- package/dist/lib/feed.d.ts +4 -1
- package/dist/lib/feed.js +25 -0
- package/dist/lib/hosts/passthrough.d.ts +10 -1
- package/dist/lib/hosts/passthrough.js +23 -3
- package/dist/lib/hosts/remote-cmd.js +1 -0
- package/dist/lib/mcp.js +6 -1
- package/dist/lib/menubar/MenubarHelper.app/Contents/CodeResources +0 -0
- package/dist/lib/menubar/MenubarHelper.app/Contents/MacOS/MenubarHelper +0 -0
- package/dist/lib/model-tiers.js +4 -1
- package/dist/lib/models.js +63 -0
- package/dist/lib/placement.d.ts +82 -0
- package/dist/lib/placement.js +188 -0
- package/dist/lib/profiles.d.ts +73 -10
- package/dist/lib/profiles.js +133 -15
- package/dist/lib/project-focus.d.ts +9 -0
- package/dist/lib/project-focus.js +23 -0
- package/dist/lib/project-key.d.ts +1 -1
- package/dist/lib/project-key.js +1 -1
- package/dist/lib/project-probe.d.ts +18 -0
- package/dist/lib/project-probe.js +46 -0
- package/dist/lib/project-status.d.ts +56 -0
- package/dist/lib/project-status.js +125 -18
- package/dist/lib/resources/mcp.js +3 -0
- package/dist/lib/resources/types.d.ts +1 -1
- package/dist/lib/rotate.d.ts +19 -4
- package/dist/lib/rotate.js +24 -1
- package/dist/lib/routines.d.ts +2 -0
- package/dist/lib/runner.d.ts +3 -0
- package/dist/lib/runner.js +90 -7
- package/dist/lib/secrets/Agents CLI.app/Contents/CodeResources +0 -0
- package/dist/lib/secrets/Agents CLI.app/Contents/MacOS/Agents CLI +0 -0
- package/dist/lib/secrets/audit.js +1 -1
- package/dist/lib/secrets/index.d.ts +1 -0
- package/dist/lib/secrets/index.js +29 -15
- package/dist/lib/secrets/remote.d.ts +1 -1
- package/dist/lib/secrets/remote.js +1 -1
- package/dist/lib/session/active.d.ts +2 -0
- package/dist/lib/session/bash-command.d.ts +2 -3
- package/dist/lib/session/db.d.ts +92 -1
- package/dist/lib/session/db.js +230 -1
- package/dist/lib/session/digest.d.ts +1 -1
- package/dist/lib/session/digest.js +1 -1
- package/dist/lib/share/publish.js +24 -0
- package/dist/lib/snapshot.d.ts +103 -0
- package/dist/lib/snapshot.js +99 -0
- package/dist/lib/startup/command-registry.d.ts +1 -1
- package/dist/lib/startup/command-registry.js +2 -2
- package/dist/lib/subagents-registry.js +3 -0
- package/dist/lib/types.d.ts +11 -2
- package/dist/lib/usage.d.ts +5 -0
- package/dist/lib/usage.js +3 -3
- package/package.json +1 -1
- package/dist/commands/activity.d.ts +0 -87
- package/dist/commands/activity.js +0 -346
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* BYOK (Bring Your Own Key) budget fetcher.
|
|
3
|
+
*
|
|
4
|
+
* For custom harnesses that use a provider's API key stored in the keychain
|
|
5
|
+
* (e.g. OpenRouter), this module fetches the account's credit/usage data so
|
|
6
|
+
* `agents view` can show a budget bar alongside the harness detail row — the
|
|
7
|
+
* same bar style the native usage system uses, just sourced from the provider
|
|
8
|
+
* API rather than the CLI's own usage tracking.
|
|
9
|
+
*/
|
|
10
|
+
import { hasKeychainToken, getKeychainToken } from './secrets/profiles.js';
|
|
11
|
+
import { renderBar, getUsageColor, USAGE_CACHE_FRESH_MS, USAGE_CACHE_SWR_MS } from './usage.js';
|
|
12
|
+
// ─── Injectable fetch seam (tests only) ──────────────────────────────────────
|
|
13
|
+
let _fetch = globalThis.fetch;
|
|
14
|
+
export function setByokFetchForTest(fn) {
|
|
15
|
+
_fetch = fn;
|
|
16
|
+
}
|
|
17
|
+
const _cache = new Map();
|
|
18
|
+
export function resetByokCacheForTest() {
|
|
19
|
+
_cache.clear();
|
|
20
|
+
}
|
|
21
|
+
async function fetchOpenRouter(keychainItem) {
|
|
22
|
+
try {
|
|
23
|
+
if (!hasKeychainToken(keychainItem))
|
|
24
|
+
return { budget: null, error: null };
|
|
25
|
+
}
|
|
26
|
+
catch {
|
|
27
|
+
return { budget: null, error: null };
|
|
28
|
+
}
|
|
29
|
+
let token;
|
|
30
|
+
try {
|
|
31
|
+
token = getKeychainToken(keychainItem);
|
|
32
|
+
}
|
|
33
|
+
catch {
|
|
34
|
+
return { budget: null, error: null };
|
|
35
|
+
}
|
|
36
|
+
try {
|
|
37
|
+
const res = await _fetch('https://openrouter.ai/api/v1/key', {
|
|
38
|
+
headers: { Authorization: `Bearer ${token}` },
|
|
39
|
+
});
|
|
40
|
+
if (!res.ok)
|
|
41
|
+
return { budget: null, error: `HTTP ${res.status}` };
|
|
42
|
+
const json = (await res.json());
|
|
43
|
+
const { limit, limit_remaining, usage } = json.data;
|
|
44
|
+
const limitUsd = limit ?? null;
|
|
45
|
+
const usedUsd = usage;
|
|
46
|
+
const usedPercent = limitUsd !== null && limitUsd > 0 ? (usedUsd / limitUsd) * 100 : null;
|
|
47
|
+
return {
|
|
48
|
+
budget: {
|
|
49
|
+
limitUsd,
|
|
50
|
+
remainingUsd: limit_remaining ?? null,
|
|
51
|
+
usedUsd,
|
|
52
|
+
usedPercent,
|
|
53
|
+
fetchedAt: new Date(),
|
|
54
|
+
},
|
|
55
|
+
error: null,
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
catch (err) {
|
|
59
|
+
return { budget: null, error: String(err) };
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
const BYOK_REGISTRY = {
|
|
63
|
+
openrouter: { fetch: fetchOpenRouter },
|
|
64
|
+
};
|
|
65
|
+
export function hasByokProvider(provider) {
|
|
66
|
+
return Object.prototype.hasOwnProperty.call(BYOK_REGISTRY, provider);
|
|
67
|
+
}
|
|
68
|
+
// ─── Bar renderer ─────────────────────────────────────────────────────────────
|
|
69
|
+
export function renderByokBar(result) {
|
|
70
|
+
if (!result.budget)
|
|
71
|
+
return '';
|
|
72
|
+
const { budget } = result;
|
|
73
|
+
if (budget.limitUsd === null) {
|
|
74
|
+
const bar = renderBar(0, 10, 0);
|
|
75
|
+
return `$: ${bar} $${budget.usedUsd.toFixed(2)} used (unlimited)`;
|
|
76
|
+
}
|
|
77
|
+
const pct = budget.usedPercent ?? 0;
|
|
78
|
+
const bar = renderBar(pct, 10, pct > 0 ? 1 : 0);
|
|
79
|
+
const color = getUsageColor(pct);
|
|
80
|
+
const remaining = budget.remainingUsd !== null ? `$${budget.remainingUsd.toFixed(2)}` : '?';
|
|
81
|
+
const limit = `$${budget.limitUsd.toFixed(2)}`;
|
|
82
|
+
return `$: ${bar} ${color(`${Math.round(pct)}%`)} (${remaining} left of ${limit})`;
|
|
83
|
+
}
|
|
84
|
+
// ─── Public API ───────────────────────────────────────────────────────────────
|
|
85
|
+
/**
|
|
86
|
+
* Fetch the BYOK budget for a harness.
|
|
87
|
+
*
|
|
88
|
+
* Returns `null` when the harness has no registered BYOK provider or no auth.
|
|
89
|
+
* Returns a `ByokUsageResult` with `budget: null` when the token is absent
|
|
90
|
+
* from the keychain (so the caller can skip rendering a bar without error).
|
|
91
|
+
*
|
|
92
|
+
* Results are SWR-cached per `keychainItem` so harnesses sharing one API key
|
|
93
|
+
* deduplicate to a single network request.
|
|
94
|
+
*/
|
|
95
|
+
export async function getByokUsageForHarness(profile, opts) {
|
|
96
|
+
if (!profile.provider || !hasByokProvider(profile.provider) || !profile.auth)
|
|
97
|
+
return null;
|
|
98
|
+
const keychainItem = profile.auth.keychainItem;
|
|
99
|
+
const provider = BYOK_REGISTRY[profile.provider];
|
|
100
|
+
const now = Date.now();
|
|
101
|
+
const cached = _cache.get(keychainItem);
|
|
102
|
+
if (!opts?.forceRefresh && cached) {
|
|
103
|
+
const age = now - cached.fetchedAt;
|
|
104
|
+
if (age < USAGE_CACHE_FRESH_MS) {
|
|
105
|
+
return cached.result;
|
|
106
|
+
}
|
|
107
|
+
if (age < USAGE_CACHE_SWR_MS) {
|
|
108
|
+
void provider.fetch(keychainItem).then((result) => {
|
|
109
|
+
_cache.set(keychainItem, { result, fetchedAt: Date.now() });
|
|
110
|
+
});
|
|
111
|
+
return cached.result;
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
const result = await provider.fetch(keychainItem);
|
|
115
|
+
_cache.set(keychainItem, { result, fetchedAt: now });
|
|
116
|
+
return result;
|
|
117
|
+
}
|
package/dist/lib/capabilities.js
CHANGED
|
@@ -54,7 +54,7 @@ export function supports(agent, cap, version) {
|
|
|
54
54
|
const c = getCapability(agent, cap);
|
|
55
55
|
if (c === false)
|
|
56
56
|
return { ok: false, reason: 'unsupported' };
|
|
57
|
-
if (c === true)
|
|
57
|
+
if (c === true || c === undefined)
|
|
58
58
|
return { ok: true };
|
|
59
59
|
if ('file' in c)
|
|
60
60
|
return { ok: true };
|
|
@@ -67,6 +67,14 @@ export const CONFIG_KEYS = [
|
|
|
67
67
|
type: 'bool',
|
|
68
68
|
description: 'Whether the routines scheduler (daemon) may fire on this device.',
|
|
69
69
|
},
|
|
70
|
+
{
|
|
71
|
+
name: 'browser.remote-control',
|
|
72
|
+
yamlKey: 'browserRemoteControl',
|
|
73
|
+
scope: 'device',
|
|
74
|
+
type: 'bool',
|
|
75
|
+
description: "Whether other fleet machines may drive THIS device's browser over `browser --host <this-device>`. " +
|
|
76
|
+
'Default off — a fleet-remote drive is refused until the owner runs `agents browser remote-control on`.',
|
|
77
|
+
},
|
|
70
78
|
{
|
|
71
79
|
name: 'notes',
|
|
72
80
|
yamlKey: 'notes',
|
package/dist/lib/exec.d.ts
CHANGED
|
@@ -308,6 +308,26 @@ export declare function resolveShimSpawn(platform: NodeJS.Platform, binary: stri
|
|
|
308
308
|
* keeping version resolution in one place instead of reimplementing it in batch.
|
|
309
309
|
*/
|
|
310
310
|
export declare function execShimPassthrough(agent: AgentId, rawArgs: string[], cwd: string, pinnedVersion?: string): Promise<number>;
|
|
311
|
+
/**
|
|
312
|
+
* Whether a dead pane's failure should be recapped to stderr (RUSH-2185 / EXEC-23a).
|
|
313
|
+
*
|
|
314
|
+
* For headless runs only a nonzero exit is a failure worth surfacing — a
|
|
315
|
+
* clean 0 means the agent finished the task before we could attach.
|
|
316
|
+
* For interactive runs ANY exit (including 0) is a failure: an instant clean
|
|
317
|
+
* exit means the harness has no bare REPL and the user would see only a mute
|
|
318
|
+
* `[detached]` with no explanation.
|
|
319
|
+
*/
|
|
320
|
+
export declare function shouldRecapDeadPane(status: number | undefined, interactive: boolean): boolean;
|
|
321
|
+
/**
|
|
322
|
+
* True only when a `display-message #{pane_dead}` tmux query explicitly returned
|
|
323
|
+
* "0" (pane alive). Used to distinguish "pane alive" from "query failed" in
|
|
324
|
+
* situations where `paneExitStatus` conservatively returns `{dead: false}` for
|
|
325
|
+
* both (RUSH-2185 / EXEC-23a / F3).
|
|
326
|
+
*
|
|
327
|
+
* @param code The exit code of the `tmux display-message` command.
|
|
328
|
+
* @param stdout Its stdout (expected to be "0" when the pane is alive).
|
|
329
|
+
*/
|
|
330
|
+
export declare function isPaneKnownAliveFromQueryResult(code: number, stdout: string): boolean;
|
|
311
331
|
/** Inputs that decide whether an interactive spawn is wrapped in a shared-socket tmux session. */
|
|
312
332
|
export interface TmuxWrapContext {
|
|
313
333
|
/** resolveInteractive() result — only interactive REPL launches are wrapped. */
|
package/dist/lib/exec.js
CHANGED
|
@@ -480,6 +480,25 @@ export const AGENT_COMMANDS = {
|
|
|
480
480
|
jsonFlags: ['--format', 'json'],
|
|
481
481
|
modelFlag: '--model',
|
|
482
482
|
},
|
|
483
|
+
// Oh My Pi (`omp`). Headless is the positional MESSAGES arg + `-p/--print`.
|
|
484
|
+
// Approval modes map to omp's `--approval-mode`: always-ask (read-only tools
|
|
485
|
+
// auto-approved, writes gated -> our `plan`), write (read + workspace writes
|
|
486
|
+
// auto-approved -> `edit`), yolo (all tiers auto-approved -> `skip`). JSON is
|
|
487
|
+
// omp's `--mode json` event stream. `--model` fuzzy-matches a provider/model
|
|
488
|
+
// selector. Native resume is `-r/--resume <id-prefix>`.
|
|
489
|
+
pi: {
|
|
490
|
+
base: ['omp'],
|
|
491
|
+
promptFlag: 'positional',
|
|
492
|
+
modeFlags: {
|
|
493
|
+
plan: ['--approval-mode', 'always-ask'],
|
|
494
|
+
edit: ['--approval-mode', 'write'],
|
|
495
|
+
skip: ['--approval-mode', 'yolo'],
|
|
496
|
+
},
|
|
497
|
+
jsonFlags: ['--mode', 'json'],
|
|
498
|
+
modelFlag: '--model',
|
|
499
|
+
printFlags: ['-p'],
|
|
500
|
+
resume: { flag: '--resume' },
|
|
501
|
+
},
|
|
483
502
|
openclaw: {
|
|
484
503
|
base: ['openclaw'],
|
|
485
504
|
promptFlag: 'positional',
|
|
@@ -1038,6 +1057,30 @@ export async function execShimPassthrough(agent, rawArgs, cwd, pinnedVersion) {
|
|
|
1038
1057
|
});
|
|
1039
1058
|
});
|
|
1040
1059
|
}
|
|
1060
|
+
/**
|
|
1061
|
+
* Whether a dead pane's failure should be recapped to stderr (RUSH-2185 / EXEC-23a).
|
|
1062
|
+
*
|
|
1063
|
+
* For headless runs only a nonzero exit is a failure worth surfacing — a
|
|
1064
|
+
* clean 0 means the agent finished the task before we could attach.
|
|
1065
|
+
* For interactive runs ANY exit (including 0) is a failure: an instant clean
|
|
1066
|
+
* exit means the harness has no bare REPL and the user would see only a mute
|
|
1067
|
+
* `[detached]` with no explanation.
|
|
1068
|
+
*/
|
|
1069
|
+
export function shouldRecapDeadPane(status, interactive) {
|
|
1070
|
+
return (status ?? 0) !== 0 || interactive;
|
|
1071
|
+
}
|
|
1072
|
+
/**
|
|
1073
|
+
* True only when a `display-message #{pane_dead}` tmux query explicitly returned
|
|
1074
|
+
* "0" (pane alive). Used to distinguish "pane alive" from "query failed" in
|
|
1075
|
+
* situations where `paneExitStatus` conservatively returns `{dead: false}` for
|
|
1076
|
+
* both (RUSH-2185 / EXEC-23a / F3).
|
|
1077
|
+
*
|
|
1078
|
+
* @param code The exit code of the `tmux display-message` command.
|
|
1079
|
+
* @param stdout Its stdout (expected to be "0" when the pane is alive).
|
|
1080
|
+
*/
|
|
1081
|
+
export function isPaneKnownAliveFromQueryResult(code, stdout) {
|
|
1082
|
+
return code === 0 && stdout.trim() === '0';
|
|
1083
|
+
}
|
|
1041
1084
|
/**
|
|
1042
1085
|
* Decide whether to run an interactive agent INSIDE a detached tmux session on
|
|
1043
1086
|
* the shared socket (then attach the current TTY) instead of a bare spawn.
|
|
@@ -1225,29 +1268,51 @@ async function runInTmux(options, executable, args) {
|
|
|
1225
1268
|
// already-dead pane — surface its output + status directly and tear down.
|
|
1226
1269
|
const before = pane ? await paneExitStatus(pane, socket) : { dead: false };
|
|
1227
1270
|
if (before.dead) {
|
|
1228
|
-
//
|
|
1229
|
-
//
|
|
1230
|
-
//
|
|
1231
|
-
|
|
1271
|
+
// F2 (RUSH-2185 / EXEC-23a): for interactive runs, ALWAYS recap — a clean
|
|
1272
|
+
// exit-0 before attach means the harness has no interactive REPL and the
|
|
1273
|
+
// user would see only a bare `[detached]` with no clue why. For headless
|
|
1274
|
+
// runs the old quiet behaviour stands: exit-0 is a successful quick run.
|
|
1275
|
+
if (shouldRecapDeadPane(before.status, resolveInteractive(options))) {
|
|
1232
1276
|
await surfacePaneFailure(before.status, `${options.agent} exited before it could start`);
|
|
1233
1277
|
}
|
|
1234
1278
|
await killSession(name, socket).catch(() => { });
|
|
1235
1279
|
return { exitCode: before.status ?? 0, stderr: '', stdout: '' };
|
|
1236
1280
|
}
|
|
1237
1281
|
await attachTmux({ socket, args: ['attach-session', '-t', name] });
|
|
1282
|
+
// F3 (RUSH-2185 / EXEC-23a): paneExitStatus returns {dead:false} for BOTH
|
|
1283
|
+
// "pane is alive" and "tmux query failed (race / pane already gone)". Require
|
|
1284
|
+
// POSITIVE proof before taking the keep-session path — a separate direct query
|
|
1285
|
+
// that only returns true when tmux explicitly confirms pane_dead=0.
|
|
1286
|
+
const checkPaneKnownAlive = async (p) => {
|
|
1287
|
+
try {
|
|
1288
|
+
const r = await runTmux({ socket, args: ['display-message', '-pt', p, '-p', '#{pane_dead}'], throwOnError: false });
|
|
1289
|
+
return isPaneKnownAliveFromQueryResult(r.code, r.stdout);
|
|
1290
|
+
}
|
|
1291
|
+
catch {
|
|
1292
|
+
return false;
|
|
1293
|
+
}
|
|
1294
|
+
};
|
|
1238
1295
|
const after = pane ? await paneExitStatus(pane, socket) : { dead: false };
|
|
1239
1296
|
if (after.dead) {
|
|
1240
1297
|
// Nonzero exit after attach → the agent crashed rather than the user
|
|
1241
1298
|
// detaching cleanly (a clean detach leaves the pane ALIVE, handled below).
|
|
1242
|
-
//
|
|
1243
|
-
//
|
|
1244
|
-
if ((after.status
|
|
1299
|
+
// F2: for interactive runs, also recap a clean exit-0 — the harness exited
|
|
1300
|
+
// without error but without starting a REPL, which is still a failure.
|
|
1301
|
+
if (shouldRecapDeadPane(after.status, resolveInteractive(options))) {
|
|
1245
1302
|
await surfacePaneFailure(after.status, `${options.agent} exited`);
|
|
1246
1303
|
}
|
|
1247
1304
|
await killSession(name, socket).catch(() => { });
|
|
1248
1305
|
return { exitCode: after.status ?? 0, stderr: '', stdout: '' };
|
|
1249
1306
|
}
|
|
1250
|
-
//
|
|
1307
|
+
// after.dead===false, but that could be a stale/unreadable-pane result.
|
|
1308
|
+
// Require positive proof before keeping the session as "user detached".
|
|
1309
|
+
if (pane && await checkPaneKnownAlive(pane)) {
|
|
1310
|
+
// Confirmed alive: the user pressed Ctrl-b d; keep the session for `agents focus`.
|
|
1311
|
+
return { exitCode: 0, stderr: '', stdout: '' };
|
|
1312
|
+
}
|
|
1313
|
+
// Ambiguous or unreadable pane (race between pane-died hook and our query) —
|
|
1314
|
+
// tear down rather than leave an orphan session.
|
|
1315
|
+
await killSession(name, socket).catch(() => { });
|
|
1251
1316
|
return { exitCode: 0, stderr: '', stdout: '' };
|
|
1252
1317
|
}
|
|
1253
1318
|
/**
|
package/dist/lib/feed-outcome.js
CHANGED
|
@@ -220,6 +220,8 @@ export function enrichBlockFromSession(block, hint) {
|
|
|
220
220
|
}
|
|
221
221
|
if (!next.worktreeSlug && hint.worktreeSlug)
|
|
222
222
|
next.worktreeSlug = hint.worktreeSlug;
|
|
223
|
+
if (!next.project && hint.project)
|
|
224
|
+
next.project = hint.project;
|
|
223
225
|
return next;
|
|
224
226
|
}
|
|
225
227
|
/**
|
package/dist/lib/feed-post.js
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
* Agent status posts — deliberate progress messages into the activity stream.
|
|
3
3
|
*
|
|
4
4
|
* Surface: `agents feed post --title <subject> <body>` (agent-callable; humans
|
|
5
|
-
* watch via `agents feed` / `agents
|
|
5
|
+
* watch via `agents feed` / `agents events --module activity`).
|
|
6
6
|
*
|
|
7
7
|
* Identity is automatic: session id, agent, cwd, launch/pid/tmux provenance
|
|
8
8
|
* are resolved from the process environment and the per-pid launch registry
|
package/dist/lib/feed-ranking.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { classifyBlock } from './ask-classifier.js';
|
|
2
2
|
import { outcomeForBlock } from './feed-outcome.js';
|
|
3
|
+
import { projectKeyFromCwd } from './project-key.js';
|
|
3
4
|
const MINUTES_PER_HOUR = 60;
|
|
4
5
|
const NEEDY_ASKS_PER_HOUR = 6;
|
|
5
6
|
const RUNAWAY_TOK_PER_SEC = 250;
|
|
@@ -26,6 +27,7 @@ export function buildSessionSignals(active, metas = []) {
|
|
|
26
27
|
runtime: s.context,
|
|
27
28
|
pid: s.pid,
|
|
28
29
|
cwd: s.cwd,
|
|
30
|
+
project: s.project ?? projectKeyFromCwd(s.cwd),
|
|
29
31
|
startedAtMs: s.startedAtMs,
|
|
30
32
|
status: s.status,
|
|
31
33
|
tokPerSec: s.tokPerSec,
|
|
@@ -146,6 +148,7 @@ export function needyControlCards(stats, signals = [], now = new Date(), thresho
|
|
|
146
148
|
mailboxId: row.mailboxId,
|
|
147
149
|
host: signal?.host ?? 'local',
|
|
148
150
|
runtime: signal?.runtime ?? signal?.context ?? 'unknown',
|
|
151
|
+
project: signal?.project,
|
|
149
152
|
ts: row.lastAskAt,
|
|
150
153
|
kind: 'control',
|
|
151
154
|
questions: [{
|
|
@@ -200,6 +203,7 @@ export function runawayControlCards(signals, now = new Date()) {
|
|
|
200
203
|
mailboxId: signal.mailboxId ?? id,
|
|
201
204
|
host: signal.host ?? 'local',
|
|
202
205
|
runtime: signal.runtime ?? signal.context ?? 'unknown',
|
|
206
|
+
project: signal.project,
|
|
203
207
|
ts: new Date(signal.startedAtMs ?? nowMs).toISOString(),
|
|
204
208
|
kind: 'control',
|
|
205
209
|
questions: [{
|
package/dist/lib/feed.d.ts
CHANGED
|
@@ -36,6 +36,8 @@ export interface OpenBlock {
|
|
|
36
36
|
mailboxId: string;
|
|
37
37
|
host: string;
|
|
38
38
|
runtime: string;
|
|
39
|
+
/** Project/repo name this block belongs to (derived from cwd, worktree-aware). */
|
|
40
|
+
project?: string;
|
|
39
41
|
ts: string;
|
|
40
42
|
questions: BlockQuestion[];
|
|
41
43
|
/**
|
|
@@ -178,6 +180,7 @@ export interface DeclaringAgent {
|
|
|
178
180
|
mailboxId: string;
|
|
179
181
|
host: string;
|
|
180
182
|
runtime: string;
|
|
183
|
+
cwd?: string;
|
|
181
184
|
}
|
|
182
185
|
export interface DeclareBlockInput {
|
|
183
186
|
/** What the agent needs from the user, front-loaded. */
|
|
@@ -218,7 +221,7 @@ export declare function removeBlock(blockId: string, root?: string): boolean;
|
|
|
218
221
|
* Embedded so it ships with the compiled CLI and can be installed to the
|
|
219
222
|
* CLI-writable user hooks dir without a separate file in the npm tarball.
|
|
220
223
|
*/
|
|
221
|
-
export declare const FEED_PUBLISH_HOOK_SCRIPT = "#!/usr/bin/env python3\n\"\"\"Publish and clear open-block records for `agents feed`.\n\nThe manifest invokes this script for top-level AskUserQuestion calls, waiting\nnotifications, question answers, and session lifecycle events. One atomic file\nper session means a new block replaces the previous block. Answer/resume/stop\nevents remove it so `agents feed` only lists decisions that are still open.\n\nSub-agent gate: when the PreToolUse payload carries `agent_type`, this is a\nTask/Agent subagent -- skip. Only the top-level agent publishes. Verified on\nClaude Code 2.1.170 (2026-07).\n\nFail-open: ANY error is swallowed so a feed hiccup never blocks a tool call.\n\"\"\"\nimport os\nimport sys\nimport json\nimport re\nimport socket\nimport tempfile\nfrom datetime import datetime, timezone\n\nWAITING_NOTIFICATION_TYPES = {\n \"permission_prompt\",\n \"idle_prompt\",\n \"elicitation_dialog\",\n}\nCLEAR_EVENTS = {\n \"PostToolUse\",\n \"Stop\",\n \"SessionEnd\",\n}\n# Codex emits a PermissionRequest event (not Claude's Notification) when it\n# blocks on an approval prompt. Claude never fires PermissionRequest, so the\n# same script handles both: PermissionRequest maps to an approval-class block\n# with a high cost-of-delay so 'agents feed --dispatch' pages it as urgent.\n\n\ndef read_json(path):\n try:\n with open(path) as f:\n return json.load(f)\n except Exception:\n return None\n\n\ndef write_json(path, value):\n dir_name = os.path.dirname(path)\n os.makedirs(dir_name, exist_ok=True)\n fd, tmp = tempfile.mkstemp(dir=dir_name, suffix=\".tmp\")\n try:\n with os.fdopen(fd, \"w\") as f:\n json.dump(value, f, indent=2)\n os.replace(tmp, path)\n except Exception:\n try:\n os.unlink(tmp)\n except Exception:\n pass\n\n\ndef main():\n raw = sys.stdin.read()\n try:\n payload = json.loads(raw) if raw.strip() else {}\n except Exception:\n return\n\n # Sub-agent gate.\n if payload.get(\"agent_type\"):\n return\n\n session_id = payload.get(\"session_id\", \"\")\n if not session_id:\n return\n\n safe_session_id = re.sub(r\"[^A-Za-z0-9._-]\", \"-\", session_id)\n block_id = f\"block-{safe_session_id}\"\n home = os.environ.get(\"HOME\") or os.path.expanduser(\"~\")\n feed_dir = os.path.join(home, \".agents\", \".history\", \"feed\")\n answered_dir = os.path.join(feed_dir, \"answered\")\n asks_dir = os.path.join(feed_dir, \"asks\")\n target = os.path.join(feed_dir, f\"{block_id}.json\")\n hook_event = payload.get(\"hook_event_name\", \"PreToolUse\")\n\n if hook_event in CLEAR_EVENTS:\n # A declared block (`agents feed post --blocked`) is the agent explicitly\n # saying it is stuck. Unlike a question/notification/approval block -- which\n # tracks an in-flight harness prompt that a lifecycle event resolves -- a\n # declared block stays open until it is actually ANSWERED. So while it is\n # still UNANSWERED, Stop/SessionEnd/PostToolUse must never silently drop it:\n # otherwise the needs-you record vanishes the moment the agent parks the block\n # and its turn ends -- exactly when the owner still needs to see and answer it.\n # Once it IS answered (an answered marker exists), it clears like any other\n # block by falling through below -- which frees that marker too, so a later\n # `--blocked` in the same session is not falsely locked as already-answered\n # (recordAnswer creates the marker with O_EXCL).\n try:\n with open(target) as existing_file:\n existing = json.load(existing_file)\n answered = os.path.exists(os.path.join(answered_dir, f\"{block_id}.json\"))\n if existing.get(\"kind\") == \"declared\" and not answered:\n return\n except Exception:\n pass\n # A matcher-less PostToolUse clear (registered for Codex so an approved\n # tool clears its approval card) must NOT wipe an open AskUserQuestion\n # while an unrelated tool runs mid-question -- those are cleared only by\n # the AskUserQuestion-matched PostToolUse. So on PostToolUse, keep a\n # 'question' block; approval/notification blocks clear once the tool runs.\n if hook_event == \"PostToolUse\":\n try:\n with open(target) as existing_file:\n existing = json.load(existing_file)\n if existing.get(\"kind\") == \"question\" and payload.get(\"tool_name\") != \"AskUserQuestion\":\n return\n except Exception:\n pass\n try:\n os.unlink(target)\n except FileNotFoundError:\n pass\n except Exception:\n pass\n # Also clear the answered marker so a future question for this session\n # is not permanently locked.\n try:\n os.unlink(os.path.join(answered_dir, f\"{block_id}.json\"))\n except FileNotFoundError:\n pass\n except Exception:\n pass\n return\n\n # Terminal answers (human typed in the TUI) record an answered marker and\n # remove the block file so the feed stops showing it within one poll cycle.\n # The marker stays behind so a concurrent surface cannot double-answer.\n if hook_event == \"UserPromptSubmit\":\n os.makedirs(answered_dir, exist_ok=True)\n marker = os.path.join(answered_dir, f\"{block_id}.json\")\n try:\n fd = os.open(marker, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o644)\n record = {\n \"answeredAt\": datetime.now(timezone.utc).isoformat(),\n \"answeredFrom\": \"terminal\",\n }\n with os.fdopen(fd, \"w\") as f:\n json.dump(record, f, indent=2)\n except FileExistsError:\n pass\n except Exception:\n pass\n # Remove the visible block so the feed drops the answered question.\n try:\n os.unlink(target)\n except FileNotFoundError:\n pass\n except Exception:\n pass\n return\n\n notification_type = None\n codex_approval = False\n if hook_event == \"Notification\":\n notification_type = payload.get(\"notification_type\", \"\")\n if notification_type not in WAITING_NOTIFICATION_TYPES:\n return\n # Claude emits a generic permission notification after presenting an\n # AskUserQuestion. Keep the structured questions and options already\n # published for this session instead of replacing them with that less\n # useful notification text.\n try:\n with open(target) as existing_file:\n existing = json.load(existing_file)\n if existing.get(\"kind\") == \"question\":\n return\n except Exception:\n pass\n message = payload.get(\"message\", \"\")\n if not message:\n return\n normalized_questions = [{\n \"text\": message,\n \"header\": payload.get(\"title\") or notification_type.replace(\"_\", \" \").title(),\n \"multiSelect\": False,\n }]\n kind = \"notification\"\n elif hook_event == \"PermissionRequest\":\n # Codex approval prompt. The payload mirrors PreToolUse (tool_name,\n # tool_input) but carries no questions -- Codex is asking to run a tool,\n # not asking the operator a multiple-choice question. Publish it as a\n # notification-kind approval block naming the tool so the feed and the\n # phone notifier can surface it, and so the Factory extension can bridge\n # it to a VS Code notification.\n tool_name = payload.get(\"tool_name\") or \"a tool\"\n tool_input = payload.get(\"tool_input\", {})\n command = \"\"\n if isinstance(tool_input, dict):\n command = (\n tool_input.get(\"command\")\n or tool_input.get(\"cmd\")\n or tool_input.get(\"path\")\n or \"\"\n )\n if isinstance(command, list):\n command = \" \".join(str(c) for c in command)\n detail = f\": {command}\" if command else \"\"\n normalized_questions = [{\n \"text\": f\"Codex needs approval to run {tool_name}{detail}\",\n \"header\": \"Approval needed\",\n \"multiSelect\": False,\n }]\n kind = \"notification\"\n notification_type = \"permission_prompt\"\n codex_approval = True\n else:\n tool_input = payload.get(\"tool_input\", {})\n questions = tool_input.get(\"questions\", [])\n if not questions:\n return\n normalized_questions = []\n for q in questions:\n if not isinstance(q, dict):\n continue\n question = {\n \"text\": q.get(\"question\", q.get(\"header\", \"\")),\n \"header\": q.get(\"header\"),\n \"multiSelect\": q.get(\"multiSelect\", False),\n }\n raw_opts = q.get(\"options\", [])\n if raw_opts:\n question[\"options\"] = [\n {\"label\": o.get(\"label\", \"\"), \"description\": o.get(\"description\")}\n for o in raw_opts\n if isinstance(o, dict)\n ]\n normalized_questions.append(question)\n if not normalized_questions:\n return\n kind = \"question\"\n\n # Identity from env (set by agents-cli at spawn).\n mailbox_id = os.path.basename(\n os.environ.get(\"AGENTS_MAILBOX_DIR\", \"\").rstrip(\"/\")\n ) or session_id\n\n now_iso = datetime.now(timezone.utc).isoformat()\n stats_path = os.path.join(asks_dir, f\"{safe_session_id}.json\")\n stats = read_json(stats_path) or {}\n recent = stats.get(\"recentAskTimestamps\") if isinstance(stats, dict) else []\n if not isinstance(recent, list):\n recent = []\n recent.append(now_iso)\n # Keep enough history for rolling one-hour needy detection without unbounded\n # per-session files. The TypeScript reader applies the exact time window.\n recent = recent[-200:]\n write_json(stats_path, {\n \"sessionId\": session_id,\n \"mailboxId\": mailbox_id,\n \"firstAskAt\": stats.get(\"firstAskAt\") or now_iso,\n \"lastAskAt\": now_iso,\n \"totalAskCount\": int(stats.get(\"totalAskCount\") or 0) + 1,\n \"recentAskTimestamps\": recent,\n })\n\n hostname = os.environ.get(\"AGENTS_SYNC_MACHINE_ID\") or socket.gethostname()\n host = hostname.split(\".\")[0].strip().lower()\n host = re.sub(r\"[^a-z0-9_-]\", \"-\", host) or \"unknown\"\n\n runtime = os.environ.get(\"AGENTS_RUNTIME\", \"headless\")\n\n block = {\n \"blockId\": block_id,\n \"sessionId\": session_id,\n \"mailboxId\": mailbox_id,\n \"host\": host,\n \"runtime\": runtime,\n \"ts\": now_iso,\n \"questions\": normalized_questions,\n \"kind\": kind,\n }\n if notification_type:\n block[\"notificationType\"] = notification_type\n\n # A Codex PermissionRequest is a real approval gate: mark it approval-class\n # with a high cost-of-delay so 'agents feed --dispatch' classifies it urgent\n # (isPhoneUrgent gates on costOfDelay >= phoneNotifyThreshold, default\n # 'medium') and pages the phone. A plain 'deny' is the safe default.\n if codex_approval:\n block[\"blockClass\"] = \"approval\"\n block[\"costOfDelay\"] = \"high\"\n block[\"safeDefault\"] = \"deny\"\n\n # Optional multi-operator control metadata passed by the agent in the\n # AskUserQuestion tool_input. Defaults keep the existing behavior. A Codex\n # PermissionRequest carries tool ARGS in tool_input (command/path), not\n # operator controls, so it is excluded here -- its class/cost is stamped\n # above from codex_approval.\n controls = payload.get(\"tool_input\", {}) if hook_event not in (\"Notification\", \"PermissionRequest\") else {}\n block_class = controls.get(\"blockClass\") if isinstance(controls, dict) else None\n if block_class in (\"approval\", \"decision\"):\n block[\"blockClass\"] = block_class\n consequence = controls.get(\"consequence\") if isinstance(controls, dict) else None\n if consequence:\n block[\"consequence\"] = consequence\n allowed = controls.get(\"allowedOperators\") if isinstance(controls, dict) else None\n if isinstance(allowed, list):\n block[\"allowedOperators\"] = [str(a) for a in allowed]\n timeout = controls.get(\"timeoutMinutes\") if isinstance(controls, dict) else None\n if isinstance(timeout, (int, float)) and timeout > 0:\n block[\"timeoutMinutes\"] = int(timeout)\n safe_default = controls.get(\"safeDefault\") if isinstance(controls, dict) else None\n if isinstance(safe_default, str):\n block[\"safeDefault\"] = safe_default\n cost = controls.get(\"costOfDelay\") if isinstance(controls, dict) else None\n if cost in (\"low\", \"medium\", \"high\"):\n block[\"costOfDelay\"] = cost\n\n # Publishing a new question clears any stale answered marker from the\n # previous question in this session.\n try:\n os.unlink(os.path.join(answered_dir, f\"{block_id}.json\"))\n except FileNotFoundError:\n pass\n except Exception:\n pass\n\n # Python's expanduser() ignores HOME on Windows, while agents-cli honors a\n # HOME override on every platform. Use the same anchor so hooks and the CLI\n # always read/write one feed store (including temp-home and sandbox runs).\n os.makedirs(feed_dir, exist_ok=True)\n\n fd, tmp = tempfile.mkstemp(dir=feed_dir, suffix=\".tmp\")\n try:\n with os.fdopen(fd, \"w\") as f:\n json.dump(block, f, indent=2)\n os.replace(tmp, target)\n except Exception:\n try:\n os.unlink(tmp)\n except Exception:\n pass\n\n\nif __name__ == \"__main__\":\n try:\n main()\n except Exception:\n pass # fail open\n";
|
|
224
|
+
export declare const FEED_PUBLISH_HOOK_SCRIPT = "#!/usr/bin/env python3\n\"\"\"Publish and clear open-block records for `agents feed`.\n\nThe manifest invokes this script for top-level AskUserQuestion calls, waiting\nnotifications, question answers, and session lifecycle events. One atomic file\nper session means a new block replaces the previous block. Answer/resume/stop\nevents remove it so `agents feed` only lists decisions that are still open.\n\nSub-agent gate: when the PreToolUse payload carries `agent_type`, this is a\nTask/Agent subagent -- skip. Only the top-level agent publishes. Verified on\nClaude Code 2.1.170 (2026-07).\n\nFail-open: ANY error is swallowed so a feed hiccup never blocks a tool call.\n\"\"\"\nimport os\nimport sys\nimport json\nimport re\nimport socket\nimport tempfile\nfrom datetime import datetime, timezone\n\nWAITING_NOTIFICATION_TYPES = {\n \"permission_prompt\",\n \"idle_prompt\",\n \"elicitation_dialog\",\n}\nCLEAR_EVENTS = {\n \"PostToolUse\",\n \"Stop\",\n \"SessionEnd\",\n}\n# Codex emits a PermissionRequest event (not Claude's Notification) when it\n# blocks on an approval prompt. Claude never fires PermissionRequest, so the\n# same script handles both: PermissionRequest maps to an approval-class block\n# with a high cost-of-delay so 'agents feed --dispatch' pages it as urgent.\n\n\ndef read_json(path):\n try:\n with open(path) as f:\n return json.load(f)\n except Exception:\n return None\n\n\ndef write_json(path, value):\n dir_name = os.path.dirname(path)\n os.makedirs(dir_name, exist_ok=True)\n fd, tmp = tempfile.mkstemp(dir=dir_name, suffix=\".tmp\")\n try:\n with os.fdopen(fd, \"w\") as f:\n json.dump(value, f, indent=2)\n os.replace(tmp, path)\n except Exception:\n try:\n os.unlink(tmp)\n except Exception:\n pass\n\n\ndef project_from_cwd(cwd):\n \"\"\"Basename of cwd, with worktree paths resolved to their repo name.\"\"\"\n if not cwd:\n return None\n norm = cwd.replace(\"\\\\\", \"/\").rstrip(\"/\")\n if not norm:\n return None\n marker = \"/.agents/worktrees/\"\n idx = norm.find(marker)\n if idx > 0:\n repo_path = norm[:idx]\n base = repo_path[repo_path.rfind(\"/\") + 1:]\n if base:\n return base\n base = norm[norm.rfind(\"/\") + 1:]\n return base or None\n\n\ndef main():\n raw = sys.stdin.read()\n try:\n payload = json.loads(raw) if raw.strip() else {}\n except Exception:\n return\n\n # Sub-agent gate.\n if payload.get(\"agent_type\"):\n return\n\n session_id = payload.get(\"session_id\", \"\")\n if not session_id:\n return\n\n safe_session_id = re.sub(r\"[^A-Za-z0-9._-]\", \"-\", session_id)\n block_id = f\"block-{safe_session_id}\"\n home = os.environ.get(\"HOME\") or os.path.expanduser(\"~\")\n feed_dir = os.path.join(home, \".agents\", \".history\", \"feed\")\n answered_dir = os.path.join(feed_dir, \"answered\")\n asks_dir = os.path.join(feed_dir, \"asks\")\n target = os.path.join(feed_dir, f\"{block_id}.json\")\n hook_event = payload.get(\"hook_event_name\", \"PreToolUse\")\n\n if hook_event in CLEAR_EVENTS:\n # A declared block (`agents feed post --blocked`) is the agent explicitly\n # saying it is stuck. Unlike a question/notification/approval block -- which\n # tracks an in-flight harness prompt that a lifecycle event resolves -- a\n # declared block stays open until it is actually ANSWERED. So while it is\n # still UNANSWERED, Stop/SessionEnd/PostToolUse must never silently drop it:\n # otherwise the needs-you record vanishes the moment the agent parks the block\n # and its turn ends -- exactly when the owner still needs to see and answer it.\n # Once it IS answered (an answered marker exists), it clears like any other\n # block by falling through below -- which frees that marker too, so a later\n # `--blocked` in the same session is not falsely locked as already-answered\n # (recordAnswer creates the marker with O_EXCL).\n try:\n with open(target) as existing_file:\n existing = json.load(existing_file)\n answered = os.path.exists(os.path.join(answered_dir, f\"{block_id}.json\"))\n if existing.get(\"kind\") == \"declared\" and not answered:\n return\n except Exception:\n pass\n # A matcher-less PostToolUse clear (registered for Codex so an approved\n # tool clears its approval card) must NOT wipe an open AskUserQuestion\n # while an unrelated tool runs mid-question -- those are cleared only by\n # the AskUserQuestion-matched PostToolUse. So on PostToolUse, keep a\n # 'question' block; approval/notification blocks clear once the tool runs.\n if hook_event == \"PostToolUse\":\n try:\n with open(target) as existing_file:\n existing = json.load(existing_file)\n if existing.get(\"kind\") == \"question\" and payload.get(\"tool_name\") != \"AskUserQuestion\":\n return\n except Exception:\n pass\n try:\n os.unlink(target)\n except FileNotFoundError:\n pass\n except Exception:\n pass\n # Also clear the answered marker so a future question for this session\n # is not permanently locked.\n try:\n os.unlink(os.path.join(answered_dir, f\"{block_id}.json\"))\n except FileNotFoundError:\n pass\n except Exception:\n pass\n return\n\n # Terminal answers (human typed in the TUI) record an answered marker and\n # remove the block file so the feed stops showing it within one poll cycle.\n # The marker stays behind so a concurrent surface cannot double-answer.\n if hook_event == \"UserPromptSubmit\":\n os.makedirs(answered_dir, exist_ok=True)\n marker = os.path.join(answered_dir, f\"{block_id}.json\")\n try:\n fd = os.open(marker, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o644)\n record = {\n \"answeredAt\": datetime.now(timezone.utc).isoformat(),\n \"answeredFrom\": \"terminal\",\n }\n with os.fdopen(fd, \"w\") as f:\n json.dump(record, f, indent=2)\n except FileExistsError:\n pass\n except Exception:\n pass\n # Remove the visible block so the feed drops the answered question.\n try:\n os.unlink(target)\n except FileNotFoundError:\n pass\n except Exception:\n pass\n return\n\n notification_type = None\n codex_approval = False\n if hook_event == \"Notification\":\n notification_type = payload.get(\"notification_type\", \"\")\n if notification_type not in WAITING_NOTIFICATION_TYPES:\n return\n # Claude emits a generic permission notification after presenting an\n # AskUserQuestion. Keep the structured questions and options already\n # published for this session instead of replacing them with that less\n # useful notification text.\n try:\n with open(target) as existing_file:\n existing = json.load(existing_file)\n if existing.get(\"kind\") == \"question\":\n return\n except Exception:\n pass\n message = payload.get(\"message\", \"\")\n if not message:\n return\n normalized_questions = [{\n \"text\": message,\n \"header\": payload.get(\"title\") or notification_type.replace(\"_\", \" \").title(),\n \"multiSelect\": False,\n }]\n kind = \"notification\"\n elif hook_event == \"PermissionRequest\":\n # Codex approval prompt. The payload mirrors PreToolUse (tool_name,\n # tool_input) but carries no questions -- Codex is asking to run a tool,\n # not asking the operator a multiple-choice question. Publish it as a\n # notification-kind approval block naming the tool so the feed and the\n # phone notifier can surface it, and so the Factory extension can bridge\n # it to a VS Code notification.\n tool_name = payload.get(\"tool_name\") or \"a tool\"\n tool_input = payload.get(\"tool_input\", {})\n command = \"\"\n if isinstance(tool_input, dict):\n command = (\n tool_input.get(\"command\")\n or tool_input.get(\"cmd\")\n or tool_input.get(\"path\")\n or \"\"\n )\n if isinstance(command, list):\n command = \" \".join(str(c) for c in command)\n detail = f\": {command}\" if command else \"\"\n normalized_questions = [{\n \"text\": f\"Codex needs approval to run {tool_name}{detail}\",\n \"header\": \"Approval needed\",\n \"multiSelect\": False,\n }]\n kind = \"notification\"\n notification_type = \"permission_prompt\"\n codex_approval = True\n else:\n tool_input = payload.get(\"tool_input\", {})\n questions = tool_input.get(\"questions\", [])\n if not questions:\n return\n normalized_questions = []\n for q in questions:\n if not isinstance(q, dict):\n continue\n question = {\n \"text\": q.get(\"question\", q.get(\"header\", \"\")),\n \"header\": q.get(\"header\"),\n \"multiSelect\": q.get(\"multiSelect\", False),\n }\n raw_opts = q.get(\"options\", [])\n if raw_opts:\n question[\"options\"] = [\n {\"label\": o.get(\"label\", \"\"), \"description\": o.get(\"description\")}\n for o in raw_opts\n if isinstance(o, dict)\n ]\n normalized_questions.append(question)\n if not normalized_questions:\n return\n kind = \"question\"\n\n # Identity from env (set by agents-cli at spawn).\n mailbox_id = os.path.basename(\n os.environ.get(\"AGENTS_MAILBOX_DIR\", \"\").rstrip(\"/\")\n ) or session_id\n\n now_iso = datetime.now(timezone.utc).isoformat()\n stats_path = os.path.join(asks_dir, f\"{safe_session_id}.json\")\n stats = read_json(stats_path) or {}\n recent = stats.get(\"recentAskTimestamps\") if isinstance(stats, dict) else []\n if not isinstance(recent, list):\n recent = []\n recent.append(now_iso)\n # Keep enough history for rolling one-hour needy detection without unbounded\n # per-session files. The TypeScript reader applies the exact time window.\n recent = recent[-200:]\n write_json(stats_path, {\n \"sessionId\": session_id,\n \"mailboxId\": mailbox_id,\n \"firstAskAt\": stats.get(\"firstAskAt\") or now_iso,\n \"lastAskAt\": now_iso,\n \"totalAskCount\": int(stats.get(\"totalAskCount\") or 0) + 1,\n \"recentAskTimestamps\": recent,\n })\n\n hostname = os.environ.get(\"AGENTS_SYNC_MACHINE_ID\") or socket.gethostname()\n host = hostname.split(\".\")[0].strip().lower()\n host = re.sub(r\"[^a-z0-9_-]\", \"-\", host) or \"unknown\"\n\n runtime = os.environ.get(\"AGENTS_RUNTIME\", \"headless\")\n cwd = payload.get(\"cwd\") or os.environ.get(\"AGENTS_CWD\")\n project = project_from_cwd(cwd)\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 project:\n block[\"project\"] = project\n if notification_type:\n block[\"notificationType\"] = notification_type\n\n # A Codex PermissionRequest is a real approval gate: mark it approval-class\n # with a high cost-of-delay so 'agents feed --dispatch' classifies it urgent\n # (isPhoneUrgent gates on costOfDelay >= phoneNotifyThreshold, default\n # 'medium') and pages the phone. A plain 'deny' is the safe default.\n if codex_approval:\n block[\"blockClass\"] = \"approval\"\n block[\"costOfDelay\"] = \"high\"\n block[\"safeDefault\"] = \"deny\"\n\n # Optional multi-operator control metadata passed by the agent in the\n # AskUserQuestion tool_input. Defaults keep the existing behavior. A Codex\n # PermissionRequest carries tool ARGS in tool_input (command/path), not\n # operator controls, so it is excluded here -- its class/cost is stamped\n # above from codex_approval.\n controls = payload.get(\"tool_input\", {}) if hook_event not in (\"Notification\", \"PermissionRequest\") else {}\n block_class = controls.get(\"blockClass\") if isinstance(controls, dict) else None\n if block_class in (\"approval\", \"decision\"):\n block[\"blockClass\"] = block_class\n consequence = controls.get(\"consequence\") if isinstance(controls, dict) else None\n if consequence:\n block[\"consequence\"] = consequence\n allowed = controls.get(\"allowedOperators\") if isinstance(controls, dict) else None\n if isinstance(allowed, list):\n block[\"allowedOperators\"] = [str(a) for a in allowed]\n timeout = controls.get(\"timeoutMinutes\") if isinstance(controls, dict) else None\n if isinstance(timeout, (int, float)) and timeout > 0:\n block[\"timeoutMinutes\"] = int(timeout)\n safe_default = controls.get(\"safeDefault\") if isinstance(controls, dict) else None\n if isinstance(safe_default, str):\n block[\"safeDefault\"] = safe_default\n cost = controls.get(\"costOfDelay\") if isinstance(controls, dict) else None\n if cost in (\"low\", \"medium\", \"high\"):\n block[\"costOfDelay\"] = cost\n\n # Publishing a new question clears any stale answered marker from the\n # previous question in this session.\n try:\n os.unlink(os.path.join(answered_dir, f\"{block_id}.json\"))\n except FileNotFoundError:\n pass\n except Exception:\n pass\n\n # Python's expanduser() ignores HOME on Windows, while agents-cli honors a\n # HOME override on every platform. Use the same anchor so hooks and the CLI\n # always read/write one feed store (including temp-home and sandbox runs).\n os.makedirs(feed_dir, exist_ok=True)\n\n fd, tmp = tempfile.mkstemp(dir=feed_dir, suffix=\".tmp\")\n try:\n with os.fdopen(fd, \"w\") as f:\n json.dump(block, f, indent=2)\n os.replace(tmp, target)\n except Exception:\n try:\n os.unlink(tmp)\n except Exception:\n pass\n\n\nif __name__ == \"__main__\":\n try:\n main()\n except Exception:\n pass # fail open\n";
|
|
222
225
|
/** Manifest entry for the feed-publish hook, matching the ManifestHook shape. */
|
|
223
226
|
export declare const FEED_PUBLISH_HOOK_MANIFEST: {
|
|
224
227
|
name: string;
|
package/dist/lib/feed.js
CHANGED
|
@@ -26,6 +26,7 @@ import * as path from 'path';
|
|
|
26
26
|
import * as yaml from 'yaml';
|
|
27
27
|
import { getFeedDir, getUserAgentsDir } from './state.js';
|
|
28
28
|
import { isAdmin, isHighConsequenceAllowed, isKnownOperator } from './operator.js';
|
|
29
|
+
import { projectKeyFromCwd } from './project-key.js';
|
|
29
30
|
/**
|
|
30
31
|
* Stable block id for a session. One block per session -- a new question
|
|
31
32
|
* replaces the previous one (the agent can only ask one question at a time).
|
|
@@ -261,6 +262,7 @@ export function buildDeclaredBlock(agent, input) {
|
|
|
261
262
|
.map((label) => label.trim())
|
|
262
263
|
.filter(Boolean)
|
|
263
264
|
.map((label) => ({ label }));
|
|
265
|
+
const project = projectKeyFromCwd(agent.cwd);
|
|
264
266
|
return {
|
|
265
267
|
blockId: blockIdForSession(agent.sessionId),
|
|
266
268
|
sessionId: agent.sessionId,
|
|
@@ -272,6 +274,7 @@ export function buildDeclaredBlock(agent, input) {
|
|
|
272
274
|
questions: [{ text, header: 'Needs you', ...(options.length ? { options } : {}) }],
|
|
273
275
|
blockClass: input.safeDefault ? 'approval' : 'decision',
|
|
274
276
|
costOfDelay: 'high',
|
|
277
|
+
...(project ? { project } : {}),
|
|
275
278
|
...(input.safeDefault ? { safeDefault: input.safeDefault } : {}),
|
|
276
279
|
...(input.timeoutMinutes !== undefined ? { timeoutMinutes: input.timeoutMinutes } : {}),
|
|
277
280
|
};
|
|
@@ -417,6 +420,24 @@ def write_json(path, value):
|
|
|
417
420
|
pass
|
|
418
421
|
|
|
419
422
|
|
|
423
|
+
def project_from_cwd(cwd):
|
|
424
|
+
"""Basename of cwd, with worktree paths resolved to their repo name."""
|
|
425
|
+
if not cwd:
|
|
426
|
+
return None
|
|
427
|
+
norm = cwd.replace("\\\\", "/").rstrip("/")
|
|
428
|
+
if not norm:
|
|
429
|
+
return None
|
|
430
|
+
marker = "/.agents/worktrees/"
|
|
431
|
+
idx = norm.find(marker)
|
|
432
|
+
if idx > 0:
|
|
433
|
+
repo_path = norm[:idx]
|
|
434
|
+
base = repo_path[repo_path.rfind("/") + 1:]
|
|
435
|
+
if base:
|
|
436
|
+
return base
|
|
437
|
+
base = norm[norm.rfind("/") + 1:]
|
|
438
|
+
return base or None
|
|
439
|
+
|
|
440
|
+
|
|
420
441
|
def main():
|
|
421
442
|
raw = sys.stdin.read()
|
|
422
443
|
try:
|
|
@@ -626,6 +647,8 @@ def main():
|
|
|
626
647
|
host = re.sub(r"[^a-z0-9_-]", "-", host) or "unknown"
|
|
627
648
|
|
|
628
649
|
runtime = os.environ.get("AGENTS_RUNTIME", "headless")
|
|
650
|
+
cwd = payload.get("cwd") or os.environ.get("AGENTS_CWD")
|
|
651
|
+
project = project_from_cwd(cwd)
|
|
629
652
|
|
|
630
653
|
block = {
|
|
631
654
|
"blockId": block_id,
|
|
@@ -637,6 +660,8 @@ def main():
|
|
|
637
660
|
"questions": normalized_questions,
|
|
638
661
|
"kind": kind,
|
|
639
662
|
}
|
|
663
|
+
if project:
|
|
664
|
+
block["project"] = project
|
|
640
665
|
if notification_type:
|
|
641
666
|
block["notificationType"] = notification_type
|
|
642
667
|
|
|
@@ -16,7 +16,7 @@
|
|
|
16
16
|
* table or, when `--host`/`--device` is present, exits with a clear
|
|
17
17
|
* "not supported" message — never commander's raw `unknown option`.
|
|
18
18
|
*/
|
|
19
|
-
import { type DeviceRegistry } from '../devices/registry.js';
|
|
19
|
+
import { type DeviceProfile, type DeviceRegistry } from '../devices/registry.js';
|
|
20
20
|
import { runLocalCommand, runOnDevice } from '../devices/fleet.js';
|
|
21
21
|
/** Per-command remote behaviour. Absence from this map = not host-routable here. */
|
|
22
22
|
interface RemoteSpec {
|
|
@@ -36,6 +36,15 @@ export interface FleetPassthroughOptions {
|
|
|
36
36
|
/** Override this machine's id (tests). Defaults to `machineId()`. */
|
|
37
37
|
self?: string;
|
|
38
38
|
}
|
|
39
|
+
/**
|
|
40
|
+
* Prefix a fan-out remote command so the far side sees AGENTS_FLEET_REMOTE=1 —
|
|
41
|
+
* the same marker the single-target dispatch sets via env. `wrapRemoteCommand`
|
|
42
|
+
* joins the argv with spaces (POSIX) or base64-encodes it for PowerShell, so a
|
|
43
|
+
* shell-appropriate leading token rides through both: `env VAR=1 …` on POSIX,
|
|
44
|
+
* `$env:VAR='1'; …` on PowerShell. Only remote (non-self) targets get it; the
|
|
45
|
+
* self target runs locally and must stay ungated.
|
|
46
|
+
*/
|
|
47
|
+
export declare function markFleetRemote(cmd: string[], device: DeviceProfile): string[];
|
|
39
48
|
/** Run `agents <command> …` across every registered device and render the roster. */
|
|
40
49
|
export declare function runFleetPassthrough(command: string, allArgs: string[], spec: RemoteSpec, opts?: FleetPassthroughOptions): Promise<boolean>;
|
|
41
50
|
/**
|
|
@@ -106,7 +106,6 @@ const OWN_HOST_COMMANDS = new Set([
|
|
|
106
106
|
'harnesses',
|
|
107
107
|
'sessions',
|
|
108
108
|
'feed',
|
|
109
|
-
'activity', // fans `--host`/`--device`/`--devices-all` out itself (feed-style)
|
|
110
109
|
'computer',
|
|
111
110
|
'secrets',
|
|
112
111
|
'logs',
|
|
@@ -320,6 +319,19 @@ function renderFleetRoster(command, forwarded, results, self) {
|
|
|
320
319
|
console.log(chalk.gray(summaryParts.join(' · ')));
|
|
321
320
|
}
|
|
322
321
|
}
|
|
322
|
+
/**
|
|
323
|
+
* Prefix a fan-out remote command so the far side sees AGENTS_FLEET_REMOTE=1 —
|
|
324
|
+
* the same marker the single-target dispatch sets via env. `wrapRemoteCommand`
|
|
325
|
+
* joins the argv with spaces (POSIX) or base64-encodes it for PowerShell, so a
|
|
326
|
+
* shell-appropriate leading token rides through both: `env VAR=1 …` on POSIX,
|
|
327
|
+
* `$env:VAR='1'; …` on PowerShell. Only remote (non-self) targets get it; the
|
|
328
|
+
* self target runs locally and must stay ungated.
|
|
329
|
+
*/
|
|
330
|
+
export function markFleetRemote(cmd, device) {
|
|
331
|
+
return device.shell === 'powershell'
|
|
332
|
+
? [`$env:AGENTS_FLEET_REMOTE='1';`, ...cmd]
|
|
333
|
+
: ['env', 'AGENTS_FLEET_REMOTE=1', ...cmd];
|
|
334
|
+
}
|
|
323
335
|
/** Run `agents <command> …` across every registered device and render the roster. */
|
|
324
336
|
export async function runFleetPassthrough(command, allArgs, spec, opts = {}) {
|
|
325
337
|
const self = opts.self ?? machineId();
|
|
@@ -336,7 +348,12 @@ export async function runFleetPassthrough(command, allArgs, spec, opts = {}) {
|
|
|
336
348
|
const results = await fanOutDevices(targets, async (target) => {
|
|
337
349
|
const cmd = ['agents', ...forwarded];
|
|
338
350
|
const isSelf = target.device.name.toLowerCase() === self.toLowerCase() || isSelfHost(target.device.name);
|
|
339
|
-
|
|
351
|
+
// Only `browser` consults the fleet-remote marker (its consent gate), and
|
|
352
|
+
// the fan-out has no separate env channel — the marker must ride the argv.
|
|
353
|
+
// So scope the env-prefix to a REMOTE browser drive: every other command's
|
|
354
|
+
// remote argv stays byte-identical, and the self target is never gated.
|
|
355
|
+
const remoteCmd = !isSelf && command === 'browser' ? markFleetRemote(cmd, target.device) : cmd;
|
|
356
|
+
const res = isSelf ? localRunner(cmd) : runner(target.device, remoteCmd);
|
|
340
357
|
if (res.code !== 0) {
|
|
341
358
|
const detail = (res.stderr || res.stdout || 'unreachable').trim().slice(0, 200);
|
|
342
359
|
throw new Error(detail || 'unreachable');
|
|
@@ -501,7 +518,10 @@ export async function maybeRunOnHost(command, allArgs, opts) {
|
|
|
501
518
|
// UNDER the doctor PATH so that PATH still wins — without this the remote
|
|
502
519
|
// re-resolves the actor from THIS box's SSH_CONNECTION and mis-credits it
|
|
503
520
|
// (RUSH-2028). Flows to both POSIX (export) and Windows ($env:) dialects.
|
|
504
|
-
|
|
521
|
+
// AGENTS_FLEET_REMOTE marks this as a fleet-dispatched `--host` run so the far
|
|
522
|
+
// side can gate consent-sensitive actions — the browser consent gate
|
|
523
|
+
// (lib/browser/remote-control.ts) reads it to allow/deny a cross-machine drive.
|
|
524
|
+
const env = withActorEnv({ ...doctorPath, AGENTS_FLEET_REMOTE: '1' });
|
|
505
525
|
const remoteCmd = buildRemoteAgentsInvocation(forwarded, remoteCwd, remoteOs, env);
|
|
506
526
|
const code = sshStream(target, remoteCmd, { tty: interactive, multiplex: true });
|
|
507
527
|
if (code === 255) {
|
|
@@ -105,6 +105,7 @@ export const RUN_OPTION_FORWARDING = {
|
|
|
105
105
|
disableTmux: 'local-only',
|
|
106
106
|
host: 'local-only',
|
|
107
107
|
device: 'local-only',
|
|
108
|
+
where: 'local-only', // expands into host/lease before dispatch; never re-forwarded
|
|
108
109
|
on: 'local-only',
|
|
109
110
|
computer: 'local-only',
|
|
110
111
|
any: 'local-only',
|
package/dist/lib/mcp.js
CHANGED
|
@@ -594,6 +594,7 @@ function writeMcpConfigSupportsAgent(agentId) {
|
|
|
594
594
|
case 'grok':
|
|
595
595
|
case 'opencode':
|
|
596
596
|
case 'hermes':
|
|
597
|
+
case 'pi':
|
|
597
598
|
return true;
|
|
598
599
|
default:
|
|
599
600
|
return false;
|
|
@@ -615,7 +616,11 @@ export function writeMcpConfig(agentId, configPath, servers, mode = 'overwrite')
|
|
|
615
616
|
case 'claude':
|
|
616
617
|
case 'cursor':
|
|
617
618
|
case 'kimi':
|
|
618
|
-
case 'droid':
|
|
619
|
+
case 'droid':
|
|
620
|
+
// omp reads the same Claude `{ "mcpServers": {...} }` schema from .mcp.json
|
|
621
|
+
// (stdio: command/args/env; http/sse: url/headers — transport inferred from
|
|
622
|
+
// command/url presence).
|
|
623
|
+
case 'pi': {
|
|
619
624
|
let config = {};
|
|
620
625
|
if (fs.existsSync(configPath)) {
|
|
621
626
|
try {
|
|
Binary file
|
|
Binary file
|