@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.
Files changed (104) hide show
  1. package/CHANGELOG.md +131 -1
  2. package/README.md +7 -0
  3. package/dist/bin/agents +0 -0
  4. package/dist/commands/browser.js +61 -0
  5. package/dist/commands/exec.js +83 -16
  6. package/dist/commands/feed.d.ts +2 -1
  7. package/dist/commands/feed.js +47 -20
  8. package/dist/commands/focus.js +22 -1
  9. package/dist/commands/harness.d.ts +0 -1
  10. package/dist/commands/harness.js +60 -4
  11. package/dist/commands/models.js +2 -2
  12. package/dist/commands/monitors.js +2 -2
  13. package/dist/commands/projects.js +122 -107
  14. package/dist/commands/routines.js +2 -2
  15. package/dist/commands/run-account-picker.js +2 -0
  16. package/dist/commands/secrets.js +1 -1
  17. package/dist/commands/sessions-backfill.d.ts +33 -0
  18. package/dist/commands/sessions-backfill.js +83 -1
  19. package/dist/commands/sessions-stats.d.ts +36 -0
  20. package/dist/commands/sessions-stats.js +263 -0
  21. package/dist/commands/sessions.d.ts +1 -1
  22. package/dist/commands/sessions.js +21 -1
  23. package/dist/commands/snapshot.d.ts +11 -0
  24. package/dist/commands/snapshot.js +107 -0
  25. package/dist/commands/teams.js +2 -1
  26. package/dist/commands/view.d.ts +9 -1
  27. package/dist/commands/view.js +63 -12
  28. package/dist/index.js +4 -2
  29. package/dist/lib/activity.js +2 -2
  30. package/dist/lib/agents.js +68 -11
  31. package/dist/lib/analytics/recipes.js +11 -5
  32. package/dist/lib/browser/ipc.js +2 -0
  33. package/dist/lib/browser/profiles.d.ts +15 -7
  34. package/dist/lib/browser/profiles.js +53 -12
  35. package/dist/lib/browser/remote-control.d.ts +35 -0
  36. package/dist/lib/browser/remote-control.js +48 -0
  37. package/dist/lib/browser/service.d.ts +19 -0
  38. package/dist/lib/browser/service.js +19 -1
  39. package/dist/lib/browser/types.d.ts +14 -2
  40. package/dist/lib/byok-usage.d.ts +38 -0
  41. package/dist/lib/byok-usage.js +117 -0
  42. package/dist/lib/capabilities.js +1 -1
  43. package/dist/lib/device-config.js +8 -0
  44. package/dist/lib/exec.d.ts +20 -0
  45. package/dist/lib/exec.js +73 -8
  46. package/dist/lib/feed-outcome.d.ts +1 -0
  47. package/dist/lib/feed-outcome.js +2 -0
  48. package/dist/lib/feed-post.js +1 -1
  49. package/dist/lib/feed-ranking.d.ts +1 -0
  50. package/dist/lib/feed-ranking.js +4 -0
  51. package/dist/lib/feed.d.ts +4 -1
  52. package/dist/lib/feed.js +25 -0
  53. package/dist/lib/hosts/passthrough.d.ts +10 -1
  54. package/dist/lib/hosts/passthrough.js +23 -3
  55. package/dist/lib/hosts/remote-cmd.js +1 -0
  56. package/dist/lib/mcp.js +6 -1
  57. package/dist/lib/menubar/MenubarHelper.app/Contents/CodeResources +0 -0
  58. package/dist/lib/menubar/MenubarHelper.app/Contents/MacOS/MenubarHelper +0 -0
  59. package/dist/lib/model-tiers.js +4 -1
  60. package/dist/lib/models.js +63 -0
  61. package/dist/lib/placement.d.ts +82 -0
  62. package/dist/lib/placement.js +188 -0
  63. package/dist/lib/profiles.d.ts +73 -10
  64. package/dist/lib/profiles.js +133 -15
  65. package/dist/lib/project-focus.d.ts +9 -0
  66. package/dist/lib/project-focus.js +23 -0
  67. package/dist/lib/project-key.d.ts +1 -1
  68. package/dist/lib/project-key.js +1 -1
  69. package/dist/lib/project-probe.d.ts +18 -0
  70. package/dist/lib/project-probe.js +46 -0
  71. package/dist/lib/project-status.d.ts +56 -0
  72. package/dist/lib/project-status.js +125 -18
  73. package/dist/lib/resources/mcp.js +3 -0
  74. package/dist/lib/resources/types.d.ts +1 -1
  75. package/dist/lib/rotate.d.ts +19 -4
  76. package/dist/lib/rotate.js +24 -1
  77. package/dist/lib/routines.d.ts +2 -0
  78. package/dist/lib/runner.d.ts +3 -0
  79. package/dist/lib/runner.js +90 -7
  80. package/dist/lib/secrets/Agents CLI.app/Contents/CodeResources +0 -0
  81. package/dist/lib/secrets/Agents CLI.app/Contents/MacOS/Agents CLI +0 -0
  82. package/dist/lib/secrets/audit.js +1 -1
  83. package/dist/lib/secrets/index.d.ts +1 -0
  84. package/dist/lib/secrets/index.js +29 -15
  85. package/dist/lib/secrets/remote.d.ts +1 -1
  86. package/dist/lib/secrets/remote.js +1 -1
  87. package/dist/lib/session/active.d.ts +2 -0
  88. package/dist/lib/session/bash-command.d.ts +2 -3
  89. package/dist/lib/session/db.d.ts +92 -1
  90. package/dist/lib/session/db.js +230 -1
  91. package/dist/lib/session/digest.d.ts +1 -1
  92. package/dist/lib/session/digest.js +1 -1
  93. package/dist/lib/share/publish.js +24 -0
  94. package/dist/lib/snapshot.d.ts +103 -0
  95. package/dist/lib/snapshot.js +99 -0
  96. package/dist/lib/startup/command-registry.d.ts +1 -1
  97. package/dist/lib/startup/command-registry.js +2 -2
  98. package/dist/lib/subagents-registry.js +3 -0
  99. package/dist/lib/types.d.ts +11 -2
  100. package/dist/lib/usage.d.ts +5 -0
  101. package/dist/lib/usage.js +3 -3
  102. package/package.json +1 -1
  103. package/dist/commands/activity.d.ts +0 -87
  104. 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
+ }
@@ -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',
@@ -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
- // Only recap a FAILURE. A clean (0) exit before we attached is a successful
1229
- // quick run, not a crash — a red banner there would be spurious (mirrors the
1230
- // post-attach guard below).
1231
- if ((before.status ?? 0) !== 0) {
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
- // The pane-died hook may have yanked the view before the error was readable,
1243
- // so recap it into the shell. A clean (0) exit stays quiet — nothing to say.
1244
- if ((after.status ?? 0) !== 0) {
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
- // Pane still alive → the user detached; keep the session for `agents focus`.
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
  /**
@@ -88,6 +88,7 @@ export interface SessionOutcomeHint {
88
88
  prUrl?: string | null;
89
89
  worktreeSlug?: string | null;
90
90
  branch?: string | null;
91
+ project?: string | null;
91
92
  }
92
93
  /**
93
94
  * Overlay session meta onto a block when the block itself is missing ticket/PR/
@@ -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
  /**
@@ -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 activity` / `agents events --module activity`).
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
@@ -11,6 +11,7 @@ export interface FeedSessionSignal {
11
11
  runtime?: string;
12
12
  pid?: number;
13
13
  cwd?: string;
14
+ project?: string;
14
15
  startedAtMs?: number;
15
16
  status?: ActiveSession['status'];
16
17
  tokPerSec?: number;
@@ -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: [{
@@ -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
- const res = isSelf ? localRunner(cmd) : runner(target.device, cmd);
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
- const env = withActorEnv(doctorPath);
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 {