@phnx-labs/agents-cli 1.22.5 → 1.22.7

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 (78) hide show
  1. package/CHANGELOG.md +90 -0
  2. package/README.md +7 -0
  3. package/dist/bin/agents +0 -0
  4. package/dist/commands/exec.js +46 -5
  5. package/dist/commands/feed.d.ts +2 -1
  6. package/dist/commands/feed.js +47 -20
  7. package/dist/commands/focus.js +22 -1
  8. package/dist/commands/models.js +2 -2
  9. package/dist/commands/monitors.js +1 -1
  10. package/dist/commands/projects.js +122 -107
  11. package/dist/commands/secrets.js +1 -1
  12. package/dist/commands/sessions-backfill.d.ts +33 -0
  13. package/dist/commands/sessions-backfill.js +83 -1
  14. package/dist/commands/sessions-stats.d.ts +36 -0
  15. package/dist/commands/sessions-stats.js +263 -0
  16. package/dist/commands/sessions.d.ts +1 -1
  17. package/dist/commands/sessions.js +21 -1
  18. package/dist/commands/view.d.ts +2 -1
  19. package/dist/commands/view.js +62 -11
  20. package/dist/index.js +3 -3
  21. package/dist/lib/activity.js +2 -2
  22. package/dist/lib/agents.js +68 -11
  23. package/dist/lib/analytics/recipes.js +11 -5
  24. package/dist/lib/browser/profiles.d.ts +15 -7
  25. package/dist/lib/browser/profiles.js +53 -12
  26. package/dist/lib/byok-usage.d.ts +38 -0
  27. package/dist/lib/byok-usage.js +117 -0
  28. package/dist/lib/capabilities.js +1 -1
  29. package/dist/lib/exec.d.ts +20 -0
  30. package/dist/lib/exec.js +73 -8
  31. package/dist/lib/feed-outcome.d.ts +1 -0
  32. package/dist/lib/feed-outcome.js +2 -0
  33. package/dist/lib/feed-post.js +1 -1
  34. package/dist/lib/feed-ranking.d.ts +1 -0
  35. package/dist/lib/feed-ranking.js +4 -0
  36. package/dist/lib/feed.d.ts +4 -1
  37. package/dist/lib/feed.js +25 -0
  38. package/dist/lib/hosts/passthrough.js +0 -1
  39. package/dist/lib/mcp.js +6 -1
  40. package/dist/lib/menubar/MenubarHelper.app/Contents/CodeResources +0 -0
  41. package/dist/lib/menubar/MenubarHelper.app/Contents/MacOS/MenubarHelper +0 -0
  42. package/dist/lib/model-tiers.js +4 -1
  43. package/dist/lib/models.js +63 -0
  44. package/dist/lib/profiles.d.ts +42 -1
  45. package/dist/lib/profiles.js +50 -2
  46. package/dist/lib/project-focus.d.ts +9 -0
  47. package/dist/lib/project-focus.js +23 -0
  48. package/dist/lib/project-key.d.ts +1 -1
  49. package/dist/lib/project-key.js +1 -1
  50. package/dist/lib/project-probe.d.ts +18 -0
  51. package/dist/lib/project-probe.js +46 -0
  52. package/dist/lib/project-status.d.ts +56 -0
  53. package/dist/lib/project-status.js +125 -18
  54. package/dist/lib/resources/mcp.js +3 -0
  55. package/dist/lib/resources/types.d.ts +1 -1
  56. package/dist/lib/secrets/Agents CLI.app/Contents/CodeResources +0 -0
  57. package/dist/lib/secrets/Agents CLI.app/Contents/MacOS/Agents CLI +0 -0
  58. package/dist/lib/secrets/audit.js +1 -1
  59. package/dist/lib/secrets/index.d.ts +1 -0
  60. package/dist/lib/secrets/index.js +29 -15
  61. package/dist/lib/secrets/remote.d.ts +1 -1
  62. package/dist/lib/secrets/remote.js +1 -1
  63. package/dist/lib/session/active.d.ts +2 -0
  64. package/dist/lib/session/bash-command.d.ts +2 -3
  65. package/dist/lib/session/db.d.ts +92 -1
  66. package/dist/lib/session/db.js +230 -1
  67. package/dist/lib/session/digest.d.ts +1 -1
  68. package/dist/lib/session/digest.js +1 -1
  69. package/dist/lib/share/publish.js +24 -0
  70. package/dist/lib/startup/command-registry.d.ts +0 -1
  71. package/dist/lib/startup/command-registry.js +0 -2
  72. package/dist/lib/subagents-registry.js +3 -0
  73. package/dist/lib/types.d.ts +11 -2
  74. package/dist/lib/usage.d.ts +5 -0
  75. package/dist/lib/usage.js +3 -3
  76. package/package.json +1 -1
  77. package/dist/commands/activity.d.ts +0 -87
  78. package/dist/commands/activity.js +0 -346
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
 
@@ -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',
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 {
@@ -124,7 +124,10 @@ function newer(a, b) {
124
124
  */
125
125
  function rankCatalog(agent, models) {
126
126
  const usable = models.filter((m) => !PSEUDO.test(m.id));
127
- const aggregator = agent === 'cursor';
127
+ // Cursor and Pi (Oh My Pi) are cross-provider aggregators: their ids are
128
+ // provider-qualified (`anthropic/…`, `openai/…`) and span vendors, so price of
129
+ // the normalized base id is the only unifying rank signal.
130
+ const aggregator = agent === 'cursor' || agent === 'pi';
128
131
  const scored = usable.map((m) => {
129
132
  const rawId = m.id;
130
133
  const baseId = aggregator ? normalizeAggregatorId(rawId) : rawId;
@@ -234,6 +234,18 @@ export function locateModelSource(agent, version) {
234
234
  return { path: pathBin, kind: 'cli' };
235
235
  return null;
236
236
  }
237
+ if (agent === 'pi') {
238
+ // omp (Oh My Pi) installs via `bun install -g`; a version-managed install
239
+ // exposes it under node_modules/.bin/omp, otherwise it lives on PATH. We let
240
+ // the CLI produce its own catalog via `omp models --json` (extractPiCatalog).
241
+ const cli = path.join(versionDir, 'node_modules', '.bin', 'omp');
242
+ if (fs.existsSync(cli))
243
+ return { path: cli, kind: 'cli' };
244
+ const pathBin = findOnPath('omp');
245
+ if (pathBin)
246
+ return { path: pathBin, kind: 'cli' };
247
+ return null;
248
+ }
237
249
  return null;
238
250
  }
239
251
  /** Real Grok binaries are ~100MB+; failed-download stubs are tens of bytes. */
@@ -907,6 +919,55 @@ function extractKimiCatalog(binaryPath) {
907
919
  }
908
920
  return { models, aliases: {} };
909
921
  }
922
+ /**
923
+ * Extract Oh My Pi's catalog via `omp models --json`. omp is a cross-provider
924
+ * aggregator: its catalog is the union of every provider it has a key for, so
925
+ * ids are provider-qualified selectors (`anthropic/claude-opus-4-8`,
926
+ * `openai/gpt-5.2`, `xai/grok-4`, `deepseek/deepseek-chat`, …) — exactly the
927
+ * `provider/model` convention `ModelInfo.id` already uses. Output shape:
928
+ * {"models":[{"provider":"anthropic","id":"claude-opus-4-8",
929
+ * "selector":"anthropic/claude-opus-4-8","name":"Claude Opus 4.8",
930
+ * "cost":{...}}, ...]}
931
+ * The catalog is gated per-provider by key presence (no key -> that provider's
932
+ * models are absent, and an empty env yields `{"models":[]}`). That is truthful:
933
+ * the extractor surfaces exactly the providers the user has authenticated.
934
+ * Pricing is attached uniformly by getModelCatalog via getModelPricing(id),
935
+ * which strips the `provider/` prefix — so no per-catalog price shape here.
936
+ */
937
+ function extractPiCatalog(binaryPath) {
938
+ let stdout;
939
+ try {
940
+ stdout = execFileSync(binaryPath, ['models', '--json'], {
941
+ encoding: 'utf-8',
942
+ stdio: ['ignore', 'pipe', 'ignore'],
943
+ timeout: 15_000,
944
+ maxBuffer: 64 * 1024 * 1024,
945
+ });
946
+ }
947
+ catch {
948
+ return { models: [], aliases: {} };
949
+ }
950
+ let parsed;
951
+ try {
952
+ parsed = JSON.parse(stdout.replace(/\x1b\[[0-9;]*[A-Za-z]/g, ''));
953
+ }
954
+ catch {
955
+ return { models: [], aliases: {} };
956
+ }
957
+ if (!parsed || !Array.isArray(parsed.models))
958
+ return { models: [], aliases: {} };
959
+ const models = [];
960
+ const seen = new Set();
961
+ for (const m of parsed.models) {
962
+ // Prefer the provider-qualified selector; fall back to provider/id.
963
+ const id = m.selector || (m.provider && m.id ? `${m.provider}/${m.id}` : m.id);
964
+ if (!id || seen.has(id))
965
+ continue;
966
+ seen.add(id);
967
+ models.push({ id, displayName: typeof m.name === 'string' ? m.name : undefined });
968
+ }
969
+ return { models, aliases: {} };
970
+ }
910
971
  /**
911
972
  * Build (or load from cache) the model catalog for a specific (agent, version).
912
973
  * Cache is keyed on source-file mtime (binary or js module), so re-extracts
@@ -964,6 +1025,8 @@ export function getModelCatalog(agent, version) {
964
1025
  ({ models, aliases } = extractKimiCatalog(src.path));
965
1026
  else if (agent === 'grok')
966
1027
  ({ models, aliases } = extractGrokCatalog(src.path));
1028
+ else if (agent === 'pi')
1029
+ ({ models, aliases } = extractPiCatalog(src.path));
967
1030
  }
968
1031
  // Attach per-token pricing where the offline table knows the model, so the
969
1032
  // catalog carries $/token for the tier display and budgeting. Subscription /
@@ -7,6 +7,7 @@
7
7
  */
8
8
  import type { AgentId } from './types.js';
9
9
  import { type Preset } from './profiles-presets.js';
10
+ import { type ModelTier } from './model-tiers.js';
10
11
  /** A named profile binding an agent host, env vars, and optional keychain auth. */
11
12
  export interface Profile {
12
13
  name: string;
@@ -49,6 +50,19 @@ export interface Profile {
49
50
  * changes; auth, base URL, and every other profile env value are preserved.
50
51
  */
51
52
  fallback_model?: string;
53
+ /**
54
+ * Per-tier model ids for this harness's OWN catalog, keyed by the same cost
55
+ * tiers `agents run --model cheap|default|best|ultra` uses for a native
56
+ * agent. Lets a custom harness (which runs through a host agent's binary,
57
+ * e.g. `deepseek-flash` hosted on `claude`) resolve a tier against its own
58
+ * models instead of colliding with the host agent's native catalog
59
+ * (`resolveTier` in model-tiers.ts, which only knows native agents).
60
+ * An unset tier clamps to the next CHEAPER tier that IS set (see
61
+ * `resolveProfileTierModel`). Omitted entirely -> tiers are not supported
62
+ * for this profile and a requested tier falls back to the harness's single
63
+ * pinned model, unchanged from before this field existed.
64
+ */
65
+ models?: Partial<Record<ModelTier, string>>;
52
66
  }
53
67
  /**
54
68
  * Stable, machine-readable summary used by `agents view` and `--json`.
@@ -193,13 +207,40 @@ export interface ResolvedProfileRun {
193
207
  envKey: string;
194
208
  model: string;
195
209
  };
210
+ /**
211
+ * Set when the caller requested a cost tier (`--model cheap|default|...`)
212
+ * but this profile has no `models:` entry to resolve it against (not even a
213
+ * cheaper tier to clamp to). `env` is returned unmodified — the harness's
214
+ * single pinned model — and this note is informational only, matching the
215
+ * "using harness default" convention exec.ts's native tier block already
216
+ * uses; the caller prints it, it never throws.
217
+ */
218
+ tierNote?: string;
219
+ /**
220
+ * Set when `requestedModel` was a tier token AND this profile resolved it
221
+ * against its own `models:` map. Callers that forward a `--model` value
222
+ * downstream (e.g. as `ExecOptions.model`) should substitute this in place
223
+ * of the original tier token — exec.ts's native tier block only knows how
224
+ * to resolve a tier against the HOST agent's own catalog, which is the
225
+ * wrong catalog for a profile's own harness identity. Undefined both when
226
+ * no tier was requested and when tier resolution degraded (see `tierNote`).
227
+ */
228
+ resolvedModel?: string;
196
229
  }
197
230
  /**
198
231
  * Resolve a name into (agent, version, env). Throws if the name is not a
199
232
  * profile. Callers are expected to try agent-id resolution first and fall
200
233
  * back to this when that fails, so we don't need a "isProfile" probe.
234
+ *
235
+ * `requestedModel` is the caller's raw `--model` value. When it is a cost-tier
236
+ * token (`cheap`/`default`/`best`/`ultra`), it is resolved against the
237
+ * profile's OWN `models:` map (see `resolveProfileTierModel`) and substituted
238
+ * into `env` as a concrete model id BEFORE returning — so exec.ts's native
239
+ * tier-resolution block (which indexes the HOST agent's catalog, e.g. Claude's
240
+ * own models) never sees a tier token for a profile-based run, and can't
241
+ * collide the profile's harness identity with its host's catalog.
201
242
  */
202
- export declare function resolveProfileForRun(name: string): ResolvedProfileRun;
243
+ export declare function resolveProfileForRun(name: string, requestedModel?: string): ResolvedProfileRun;
203
244
  /**
204
245
  * Look up the preset a profile was created from, if any. Used by
205
246
  * `profiles view` to show upstream metadata like signup URLs.
@@ -11,6 +11,7 @@ import * as yaml from 'yaml';
11
11
  import { getUserAgentsDir } from './state.js';
12
12
  import { getKeychainToken, hasKeychainToken, keychainItemName } from './secrets/profiles.js';
13
13
  import { getPreset } from './profiles-presets.js';
14
+ import { MODEL_TIERS, isTierToken } from './model-tiers.js';
14
15
  const PROFILE_NAME_PATTERN = /^[a-z0-9][a-z0-9-_]{0,48}$/i;
15
16
  /** Get the directory where profile YAML files are stored. */
16
17
  export function getProfilesDir() {
@@ -378,17 +379,45 @@ export function resolveProfileEnv(profile) {
378
379
  }
379
380
  return env;
380
381
  }
382
+ /**
383
+ * Resolve a requested cost tier against a profile's `models:` map. An unset
384
+ * tier clamps to the next CHEAPER tier that IS set (ultra -> best -> default
385
+ * -> cheap), mirroring the clamp semantics of `bucketRungs` in
386
+ * model-tiers.ts. Returns null when the profile declares no `models:` at all,
387
+ * or none of the tiers at-or-below the request are set.
388
+ */
389
+ function resolveProfileTierModel(profile, tier) {
390
+ if (!profile.models)
391
+ return null;
392
+ const idx = MODEL_TIERS.indexOf(tier);
393
+ for (let i = idx; i >= 0; i--) {
394
+ const rung = MODEL_TIERS[i];
395
+ const model = profile.models[rung];
396
+ if (model)
397
+ return { model, clampedFrom: rung === tier ? undefined : rung };
398
+ }
399
+ return null;
400
+ }
381
401
  /**
382
402
  * Resolve a name into (agent, version, env). Throws if the name is not a
383
403
  * profile. Callers are expected to try agent-id resolution first and fall
384
404
  * back to this when that fails, so we don't need a "isProfile" probe.
405
+ *
406
+ * `requestedModel` is the caller's raw `--model` value. When it is a cost-tier
407
+ * token (`cheap`/`default`/`best`/`ultra`), it is resolved against the
408
+ * profile's OWN `models:` map (see `resolveProfileTierModel`) and substituted
409
+ * into `env` as a concrete model id BEFORE returning — so exec.ts's native
410
+ * tier-resolution block (which indexes the HOST agent's catalog, e.g. Claude's
411
+ * own models) never sees a tier token for a profile-based run, and can't
412
+ * collide the profile's harness identity with its host's catalog.
385
413
  */
386
- export function resolveProfileForRun(name) {
414
+ export function resolveProfileForRun(name, requestedModel) {
387
415
  const profile = readProfile(name);
416
+ const env = resolveProfileEnv(profile);
388
417
  const resolved = {
389
418
  agent: profile.host.agent,
390
419
  version: profile.host.version,
391
- env: resolveProfileEnv(profile),
420
+ env,
392
421
  profileName: profile.name,
393
422
  };
394
423
  if (profile.fallback_model) {
@@ -397,6 +426,25 @@ export function resolveProfileForRun(name) {
397
426
  resolved.fallbackModel = { envKey, model: profile.fallback_model };
398
427
  }
399
428
  }
429
+ if (isTierToken(requestedModel)) {
430
+ const tierPick = resolveProfileTierModel(profile, requestedModel);
431
+ if (tierPick) {
432
+ const envKey = profileModelEnvKey(profile) ?? modelEnvKeyForHost(profile.host.agent);
433
+ env[envKey] = tierPick.model;
434
+ resolved.resolvedModel = tierPick.model;
435
+ // Mirror the native-harness tier block (lib/exec.ts's resolveTier callers):
436
+ // a clamp is always announced, never silent, so a user asking for "ultra"
437
+ // on a harness that only configures "best" knows what it actually got.
438
+ if (tierPick.clampedFrom) {
439
+ resolved.tierNote = `no "${requestedModel}" model configured on profile '${profile.name}'; using its "${tierPick.clampedFrom}" tier (${tierPick.model})`;
440
+ }
441
+ }
442
+ // No `models:` opt-in at all, or no rung to clamp to: leave `env` and
443
+ // `requestedModel` untouched. `agents commands/exec.ts`'s own profile-tier
444
+ // guard (the "cost tiers don't apply to profile ..." discard) still sees
445
+ // the raw tier token downstream and handles the message -- this function
446
+ // doesn't compete with that canonical fallback for the no-opt-in case.
447
+ }
400
448
  return resolved;
401
449
  }
402
450
  /**
@@ -40,3 +40,12 @@ export declare function rankFocusAreas(files: string[], limit?: number): FocusAr
40
40
  * the user's last fetch, which is the correct trade for a read-only card.
41
41
  */
42
42
  export declare function readFocusAreas(root: string, windowDays: number): Promise<FocusArea[]>;
43
+ /** Compact count: 2329 → "2.3k", under 1000 stays exact. */
44
+ export declare function formatFocusCount(n: number): string;
45
+ /**
46
+ * One scannable focus line: path + count, with a single unit trailer so the
47
+ * bare integer is never mistaken for commits or minutes.
48
+ *
49
+ * apps/cli/src 2.3k · apps/cli/docs 302 · apps/factory/src 245 file-touches (7d)
50
+ */
51
+ export declare function formatFocusAreas(areas: FocusArea[], windowDays: number): string;