@phnx-labs/agents-cli 1.22.6 → 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.
- package/CHANGELOG.md +87 -1
- package/README.md +7 -0
- package/dist/bin/agents +0 -0
- package/dist/commands/exec.js +35 -4
- package/dist/commands/feed.d.ts +2 -1
- package/dist/commands/feed.js +47 -20
- package/dist/commands/focus.js +22 -1
- package/dist/commands/models.js +2 -2
- package/dist/commands/projects.js +122 -107
- package/dist/commands/secrets.js +1 -1
- package/dist/commands/sessions-backfill.d.ts +33 -0
- package/dist/commands/sessions-backfill.js +83 -1
- package/dist/commands/sessions-stats.d.ts +36 -0
- package/dist/commands/sessions-stats.js +263 -0
- package/dist/commands/sessions.d.ts +1 -1
- package/dist/commands/sessions.js +21 -1
- package/dist/commands/view.d.ts +2 -1
- package/dist/commands/view.js +62 -11
- package/dist/index.js +1 -2
- package/dist/lib/activity.js +2 -2
- package/dist/lib/agents.js +68 -11
- package/dist/lib/analytics/recipes.js +11 -5
- package/dist/lib/browser/profiles.d.ts +15 -7
- package/dist/lib/browser/profiles.js +53 -12
- package/dist/lib/byok-usage.d.ts +38 -0
- package/dist/lib/byok-usage.js +117 -0
- package/dist/lib/capabilities.js +1 -1
- package/dist/lib/exec.d.ts +20 -0
- package/dist/lib/exec.js +73 -8
- package/dist/lib/feed-outcome.d.ts +1 -0
- package/dist/lib/feed-outcome.js +2 -0
- package/dist/lib/feed-post.js +1 -1
- package/dist/lib/feed-ranking.d.ts +1 -0
- package/dist/lib/feed-ranking.js +4 -0
- package/dist/lib/feed.d.ts +4 -1
- package/dist/lib/feed.js +25 -0
- package/dist/lib/hosts/passthrough.js +0 -1
- package/dist/lib/mcp.js +6 -1
- package/dist/lib/menubar/MenubarHelper.app/Contents/CodeResources +0 -0
- package/dist/lib/menubar/MenubarHelper.app/Contents/MacOS/MenubarHelper +0 -0
- package/dist/lib/model-tiers.js +4 -1
- package/dist/lib/models.js +63 -0
- package/dist/lib/profiles.d.ts +42 -1
- package/dist/lib/profiles.js +50 -2
- package/dist/lib/project-focus.d.ts +9 -0
- package/dist/lib/project-focus.js +23 -0
- package/dist/lib/project-key.d.ts +1 -1
- package/dist/lib/project-key.js +1 -1
- package/dist/lib/project-probe.d.ts +18 -0
- package/dist/lib/project-probe.js +46 -0
- package/dist/lib/project-status.d.ts +56 -0
- package/dist/lib/project-status.js +125 -18
- package/dist/lib/resources/mcp.js +3 -0
- package/dist/lib/resources/types.d.ts +1 -1
- package/dist/lib/secrets/Agents CLI.app/Contents/CodeResources +0 -0
- package/dist/lib/secrets/Agents CLI.app/Contents/MacOS/Agents CLI +0 -0
- package/dist/lib/secrets/audit.js +1 -1
- package/dist/lib/secrets/index.d.ts +1 -0
- package/dist/lib/secrets/index.js +29 -15
- package/dist/lib/secrets/remote.d.ts +1 -1
- package/dist/lib/secrets/remote.js +1 -1
- package/dist/lib/session/active.d.ts +2 -0
- package/dist/lib/session/bash-command.d.ts +2 -3
- package/dist/lib/session/db.d.ts +92 -1
- package/dist/lib/session/db.js +230 -1
- package/dist/lib/session/digest.d.ts +1 -1
- package/dist/lib/session/digest.js +1 -1
- package/dist/lib/share/publish.js +24 -0
- package/dist/lib/startup/command-registry.d.ts +0 -1
- package/dist/lib/startup/command-registry.js +0 -2
- package/dist/lib/subagents-registry.js +3 -0
- package/dist/lib/types.d.ts +11 -2
- package/dist/lib/usage.d.ts +5 -0
- package/dist/lib/usage.js +3 -3
- package/package.json +1 -1
- package/dist/commands/activity.d.ts +0 -87
- package/dist/commands/activity.js +0 -346
package/dist/lib/exec.js
CHANGED
|
@@ -480,6 +480,25 @@ export const AGENT_COMMANDS = {
|
|
|
480
480
|
jsonFlags: ['--format', 'json'],
|
|
481
481
|
modelFlag: '--model',
|
|
482
482
|
},
|
|
483
|
+
// Oh My Pi (`omp`). Headless is the positional MESSAGES arg + `-p/--print`.
|
|
484
|
+
// Approval modes map to omp's `--approval-mode`: always-ask (read-only tools
|
|
485
|
+
// auto-approved, writes gated -> our `plan`), write (read + workspace writes
|
|
486
|
+
// auto-approved -> `edit`), yolo (all tiers auto-approved -> `skip`). JSON is
|
|
487
|
+
// omp's `--mode json` event stream. `--model` fuzzy-matches a provider/model
|
|
488
|
+
// selector. Native resume is `-r/--resume <id-prefix>`.
|
|
489
|
+
pi: {
|
|
490
|
+
base: ['omp'],
|
|
491
|
+
promptFlag: 'positional',
|
|
492
|
+
modeFlags: {
|
|
493
|
+
plan: ['--approval-mode', 'always-ask'],
|
|
494
|
+
edit: ['--approval-mode', 'write'],
|
|
495
|
+
skip: ['--approval-mode', 'yolo'],
|
|
496
|
+
},
|
|
497
|
+
jsonFlags: ['--mode', 'json'],
|
|
498
|
+
modelFlag: '--model',
|
|
499
|
+
printFlags: ['-p'],
|
|
500
|
+
resume: { flag: '--resume' },
|
|
501
|
+
},
|
|
483
502
|
openclaw: {
|
|
484
503
|
base: ['openclaw'],
|
|
485
504
|
promptFlag: 'positional',
|
|
@@ -1038,6 +1057,30 @@ export async function execShimPassthrough(agent, rawArgs, cwd, pinnedVersion) {
|
|
|
1038
1057
|
});
|
|
1039
1058
|
});
|
|
1040
1059
|
}
|
|
1060
|
+
/**
|
|
1061
|
+
* Whether a dead pane's failure should be recapped to stderr (RUSH-2185 / EXEC-23a).
|
|
1062
|
+
*
|
|
1063
|
+
* For headless runs only a nonzero exit is a failure worth surfacing — a
|
|
1064
|
+
* clean 0 means the agent finished the task before we could attach.
|
|
1065
|
+
* For interactive runs ANY exit (including 0) is a failure: an instant clean
|
|
1066
|
+
* exit means the harness has no bare REPL and the user would see only a mute
|
|
1067
|
+
* `[detached]` with no explanation.
|
|
1068
|
+
*/
|
|
1069
|
+
export function shouldRecapDeadPane(status, interactive) {
|
|
1070
|
+
return (status ?? 0) !== 0 || interactive;
|
|
1071
|
+
}
|
|
1072
|
+
/**
|
|
1073
|
+
* True only when a `display-message #{pane_dead}` tmux query explicitly returned
|
|
1074
|
+
* "0" (pane alive). Used to distinguish "pane alive" from "query failed" in
|
|
1075
|
+
* situations where `paneExitStatus` conservatively returns `{dead: false}` for
|
|
1076
|
+
* both (RUSH-2185 / EXEC-23a / F3).
|
|
1077
|
+
*
|
|
1078
|
+
* @param code The exit code of the `tmux display-message` command.
|
|
1079
|
+
* @param stdout Its stdout (expected to be "0" when the pane is alive).
|
|
1080
|
+
*/
|
|
1081
|
+
export function isPaneKnownAliveFromQueryResult(code, stdout) {
|
|
1082
|
+
return code === 0 && stdout.trim() === '0';
|
|
1083
|
+
}
|
|
1041
1084
|
/**
|
|
1042
1085
|
* Decide whether to run an interactive agent INSIDE a detached tmux session on
|
|
1043
1086
|
* the shared socket (then attach the current TTY) instead of a bare spawn.
|
|
@@ -1225,29 +1268,51 @@ async function runInTmux(options, executable, args) {
|
|
|
1225
1268
|
// already-dead pane — surface its output + status directly and tear down.
|
|
1226
1269
|
const before = pane ? await paneExitStatus(pane, socket) : { dead: false };
|
|
1227
1270
|
if (before.dead) {
|
|
1228
|
-
//
|
|
1229
|
-
//
|
|
1230
|
-
//
|
|
1231
|
-
|
|
1271
|
+
// F2 (RUSH-2185 / EXEC-23a): for interactive runs, ALWAYS recap — a clean
|
|
1272
|
+
// exit-0 before attach means the harness has no interactive REPL and the
|
|
1273
|
+
// user would see only a bare `[detached]` with no clue why. For headless
|
|
1274
|
+
// runs the old quiet behaviour stands: exit-0 is a successful quick run.
|
|
1275
|
+
if (shouldRecapDeadPane(before.status, resolveInteractive(options))) {
|
|
1232
1276
|
await surfacePaneFailure(before.status, `${options.agent} exited before it could start`);
|
|
1233
1277
|
}
|
|
1234
1278
|
await killSession(name, socket).catch(() => { });
|
|
1235
1279
|
return { exitCode: before.status ?? 0, stderr: '', stdout: '' };
|
|
1236
1280
|
}
|
|
1237
1281
|
await attachTmux({ socket, args: ['attach-session', '-t', name] });
|
|
1282
|
+
// F3 (RUSH-2185 / EXEC-23a): paneExitStatus returns {dead:false} for BOTH
|
|
1283
|
+
// "pane is alive" and "tmux query failed (race / pane already gone)". Require
|
|
1284
|
+
// POSITIVE proof before taking the keep-session path — a separate direct query
|
|
1285
|
+
// that only returns true when tmux explicitly confirms pane_dead=0.
|
|
1286
|
+
const checkPaneKnownAlive = async (p) => {
|
|
1287
|
+
try {
|
|
1288
|
+
const r = await runTmux({ socket, args: ['display-message', '-pt', p, '-p', '#{pane_dead}'], throwOnError: false });
|
|
1289
|
+
return isPaneKnownAliveFromQueryResult(r.code, r.stdout);
|
|
1290
|
+
}
|
|
1291
|
+
catch {
|
|
1292
|
+
return false;
|
|
1293
|
+
}
|
|
1294
|
+
};
|
|
1238
1295
|
const after = pane ? await paneExitStatus(pane, socket) : { dead: false };
|
|
1239
1296
|
if (after.dead) {
|
|
1240
1297
|
// Nonzero exit after attach → the agent crashed rather than the user
|
|
1241
1298
|
// detaching cleanly (a clean detach leaves the pane ALIVE, handled below).
|
|
1242
|
-
//
|
|
1243
|
-
//
|
|
1244
|
-
if ((after.status
|
|
1299
|
+
// F2: for interactive runs, also recap a clean exit-0 — the harness exited
|
|
1300
|
+
// without error but without starting a REPL, which is still a failure.
|
|
1301
|
+
if (shouldRecapDeadPane(after.status, resolveInteractive(options))) {
|
|
1245
1302
|
await surfacePaneFailure(after.status, `${options.agent} exited`);
|
|
1246
1303
|
}
|
|
1247
1304
|
await killSession(name, socket).catch(() => { });
|
|
1248
1305
|
return { exitCode: after.status ?? 0, stderr: '', stdout: '' };
|
|
1249
1306
|
}
|
|
1250
|
-
//
|
|
1307
|
+
// after.dead===false, but that could be a stale/unreadable-pane result.
|
|
1308
|
+
// Require positive proof before keeping the session as "user detached".
|
|
1309
|
+
if (pane && await checkPaneKnownAlive(pane)) {
|
|
1310
|
+
// Confirmed alive: the user pressed Ctrl-b d; keep the session for `agents focus`.
|
|
1311
|
+
return { exitCode: 0, stderr: '', stdout: '' };
|
|
1312
|
+
}
|
|
1313
|
+
// Ambiguous or unreadable pane (race between pane-died hook and our query) —
|
|
1314
|
+
// tear down rather than leave an orphan session.
|
|
1315
|
+
await killSession(name, socket).catch(() => { });
|
|
1251
1316
|
return { exitCode: 0, stderr: '', stdout: '' };
|
|
1252
1317
|
}
|
|
1253
1318
|
/**
|
package/dist/lib/feed-outcome.js
CHANGED
|
@@ -220,6 +220,8 @@ export function enrichBlockFromSession(block, hint) {
|
|
|
220
220
|
}
|
|
221
221
|
if (!next.worktreeSlug && hint.worktreeSlug)
|
|
222
222
|
next.worktreeSlug = hint.worktreeSlug;
|
|
223
|
+
if (!next.project && hint.project)
|
|
224
|
+
next.project = hint.project;
|
|
223
225
|
return next;
|
|
224
226
|
}
|
|
225
227
|
/**
|
package/dist/lib/feed-post.js
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
* Agent status posts — deliberate progress messages into the activity stream.
|
|
3
3
|
*
|
|
4
4
|
* Surface: `agents feed post --title <subject> <body>` (agent-callable; humans
|
|
5
|
-
* watch via `agents feed` / `agents
|
|
5
|
+
* watch via `agents feed` / `agents events --module activity`).
|
|
6
6
|
*
|
|
7
7
|
* Identity is automatic: session id, agent, cwd, launch/pid/tmux provenance
|
|
8
8
|
* are resolved from the process environment and the per-pid launch registry
|
package/dist/lib/feed-ranking.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { classifyBlock } from './ask-classifier.js';
|
|
2
2
|
import { outcomeForBlock } from './feed-outcome.js';
|
|
3
|
+
import { projectKeyFromCwd } from './project-key.js';
|
|
3
4
|
const MINUTES_PER_HOUR = 60;
|
|
4
5
|
const NEEDY_ASKS_PER_HOUR = 6;
|
|
5
6
|
const RUNAWAY_TOK_PER_SEC = 250;
|
|
@@ -26,6 +27,7 @@ export function buildSessionSignals(active, metas = []) {
|
|
|
26
27
|
runtime: s.context,
|
|
27
28
|
pid: s.pid,
|
|
28
29
|
cwd: s.cwd,
|
|
30
|
+
project: s.project ?? projectKeyFromCwd(s.cwd),
|
|
29
31
|
startedAtMs: s.startedAtMs,
|
|
30
32
|
status: s.status,
|
|
31
33
|
tokPerSec: s.tokPerSec,
|
|
@@ -146,6 +148,7 @@ export function needyControlCards(stats, signals = [], now = new Date(), thresho
|
|
|
146
148
|
mailboxId: row.mailboxId,
|
|
147
149
|
host: signal?.host ?? 'local',
|
|
148
150
|
runtime: signal?.runtime ?? signal?.context ?? 'unknown',
|
|
151
|
+
project: signal?.project,
|
|
149
152
|
ts: row.lastAskAt,
|
|
150
153
|
kind: 'control',
|
|
151
154
|
questions: [{
|
|
@@ -200,6 +203,7 @@ export function runawayControlCards(signals, now = new Date()) {
|
|
|
200
203
|
mailboxId: signal.mailboxId ?? id,
|
|
201
204
|
host: signal.host ?? 'local',
|
|
202
205
|
runtime: signal.runtime ?? signal.context ?? 'unknown',
|
|
206
|
+
project: signal.project,
|
|
203
207
|
ts: new Date(signal.startedAtMs ?? nowMs).toISOString(),
|
|
204
208
|
kind: 'control',
|
|
205
209
|
questions: [{
|
package/dist/lib/feed.d.ts
CHANGED
|
@@ -36,6 +36,8 @@ export interface OpenBlock {
|
|
|
36
36
|
mailboxId: string;
|
|
37
37
|
host: string;
|
|
38
38
|
runtime: string;
|
|
39
|
+
/** Project/repo name this block belongs to (derived from cwd, worktree-aware). */
|
|
40
|
+
project?: string;
|
|
39
41
|
ts: string;
|
|
40
42
|
questions: BlockQuestion[];
|
|
41
43
|
/**
|
|
@@ -178,6 +180,7 @@ export interface DeclaringAgent {
|
|
|
178
180
|
mailboxId: string;
|
|
179
181
|
host: string;
|
|
180
182
|
runtime: string;
|
|
183
|
+
cwd?: string;
|
|
181
184
|
}
|
|
182
185
|
export interface DeclareBlockInput {
|
|
183
186
|
/** What the agent needs from the user, front-loaded. */
|
|
@@ -218,7 +221,7 @@ export declare function removeBlock(blockId: string, root?: string): boolean;
|
|
|
218
221
|
* Embedded so it ships with the compiled CLI and can be installed to the
|
|
219
222
|
* CLI-writable user hooks dir without a separate file in the npm tarball.
|
|
220
223
|
*/
|
|
221
|
-
export declare const FEED_PUBLISH_HOOK_SCRIPT = "#!/usr/bin/env python3\n\"\"\"Publish and clear open-block records for `agents feed`.\n\nThe manifest invokes this script for top-level AskUserQuestion calls, waiting\nnotifications, question answers, and session lifecycle events. One atomic file\nper session means a new block replaces the previous block. Answer/resume/stop\nevents remove it so `agents feed` only lists decisions that are still open.\n\nSub-agent gate: when the PreToolUse payload carries `agent_type`, this is a\nTask/Agent subagent -- skip. Only the top-level agent publishes. Verified on\nClaude Code 2.1.170 (2026-07).\n\nFail-open: ANY error is swallowed so a feed hiccup never blocks a tool call.\n\"\"\"\nimport os\nimport sys\nimport json\nimport re\nimport socket\nimport tempfile\nfrom datetime import datetime, timezone\n\nWAITING_NOTIFICATION_TYPES = {\n \"permission_prompt\",\n \"idle_prompt\",\n \"elicitation_dialog\",\n}\nCLEAR_EVENTS = {\n \"PostToolUse\",\n \"Stop\",\n \"SessionEnd\",\n}\n# Codex emits a PermissionRequest event (not Claude's Notification) when it\n# blocks on an approval prompt. Claude never fires PermissionRequest, so the\n# same script handles both: PermissionRequest maps to an approval-class block\n# with a high cost-of-delay so 'agents feed --dispatch' pages it as urgent.\n\n\ndef read_json(path):\n try:\n with open(path) as f:\n return json.load(f)\n except Exception:\n return None\n\n\ndef write_json(path, value):\n dir_name = os.path.dirname(path)\n os.makedirs(dir_name, exist_ok=True)\n fd, tmp = tempfile.mkstemp(dir=dir_name, suffix=\".tmp\")\n try:\n with os.fdopen(fd, \"w\") as f:\n json.dump(value, f, indent=2)\n os.replace(tmp, path)\n except Exception:\n try:\n os.unlink(tmp)\n except Exception:\n pass\n\n\ndef main():\n raw = sys.stdin.read()\n try:\n payload = json.loads(raw) if raw.strip() else {}\n except Exception:\n return\n\n # Sub-agent gate.\n if payload.get(\"agent_type\"):\n return\n\n session_id = payload.get(\"session_id\", \"\")\n if not session_id:\n return\n\n safe_session_id = re.sub(r\"[^A-Za-z0-9._-]\", \"-\", session_id)\n block_id = f\"block-{safe_session_id}\"\n home = os.environ.get(\"HOME\") or os.path.expanduser(\"~\")\n feed_dir = os.path.join(home, \".agents\", \".history\", \"feed\")\n answered_dir = os.path.join(feed_dir, \"answered\")\n asks_dir = os.path.join(feed_dir, \"asks\")\n target = os.path.join(feed_dir, f\"{block_id}.json\")\n hook_event = payload.get(\"hook_event_name\", \"PreToolUse\")\n\n if hook_event in CLEAR_EVENTS:\n # A declared block (`agents feed post --blocked`) is the agent explicitly\n # saying it is stuck. Unlike a question/notification/approval block -- which\n # tracks an in-flight harness prompt that a lifecycle event resolves -- a\n # declared block stays open until it is actually ANSWERED. So while it is\n # still UNANSWERED, Stop/SessionEnd/PostToolUse must never silently drop it:\n # otherwise the needs-you record vanishes the moment the agent parks the block\n # and its turn ends -- exactly when the owner still needs to see and answer it.\n # Once it IS answered (an answered marker exists), it clears like any other\n # block by falling through below -- which frees that marker too, so a later\n # `--blocked` in the same session is not falsely locked as already-answered\n # (recordAnswer creates the marker with O_EXCL).\n try:\n with open(target) as existing_file:\n existing = json.load(existing_file)\n answered = os.path.exists(os.path.join(answered_dir, f\"{block_id}.json\"))\n if existing.get(\"kind\") == \"declared\" and not answered:\n return\n except Exception:\n pass\n # A matcher-less PostToolUse clear (registered for Codex so an approved\n # tool clears its approval card) must NOT wipe an open AskUserQuestion\n # while an unrelated tool runs mid-question -- those are cleared only by\n # the AskUserQuestion-matched PostToolUse. So on PostToolUse, keep a\n # 'question' block; approval/notification blocks clear once the tool runs.\n if hook_event == \"PostToolUse\":\n try:\n with open(target) as existing_file:\n existing = json.load(existing_file)\n if existing.get(\"kind\") == \"question\" and payload.get(\"tool_name\") != \"AskUserQuestion\":\n return\n except Exception:\n pass\n try:\n os.unlink(target)\n except FileNotFoundError:\n pass\n except Exception:\n pass\n # Also clear the answered marker so a future question for this session\n # is not permanently locked.\n try:\n os.unlink(os.path.join(answered_dir, f\"{block_id}.json\"))\n except FileNotFoundError:\n pass\n except Exception:\n pass\n return\n\n # Terminal answers (human typed in the TUI) record an answered marker and\n # remove the block file so the feed stops showing it within one poll cycle.\n # The marker stays behind so a concurrent surface cannot double-answer.\n if hook_event == \"UserPromptSubmit\":\n os.makedirs(answered_dir, exist_ok=True)\n marker = os.path.join(answered_dir, f\"{block_id}.json\")\n try:\n fd = os.open(marker, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o644)\n record = {\n \"answeredAt\": datetime.now(timezone.utc).isoformat(),\n \"answeredFrom\": \"terminal\",\n }\n with os.fdopen(fd, \"w\") as f:\n json.dump(record, f, indent=2)\n except FileExistsError:\n pass\n except Exception:\n pass\n # Remove the visible block so the feed drops the answered question.\n try:\n os.unlink(target)\n except FileNotFoundError:\n pass\n except Exception:\n pass\n return\n\n notification_type = None\n codex_approval = False\n if hook_event == \"Notification\":\n notification_type = payload.get(\"notification_type\", \"\")\n if notification_type not in WAITING_NOTIFICATION_TYPES:\n return\n # Claude emits a generic permission notification after presenting an\n # AskUserQuestion. Keep the structured questions and options already\n # published for this session instead of replacing them with that less\n # useful notification text.\n try:\n with open(target) as existing_file:\n existing = json.load(existing_file)\n if existing.get(\"kind\") == \"question\":\n return\n except Exception:\n pass\n message = payload.get(\"message\", \"\")\n if not message:\n return\n normalized_questions = [{\n \"text\": message,\n \"header\": payload.get(\"title\") or notification_type.replace(\"_\", \" \").title(),\n \"multiSelect\": False,\n }]\n kind = \"notification\"\n elif hook_event == \"PermissionRequest\":\n # Codex approval prompt. The payload mirrors PreToolUse (tool_name,\n # tool_input) but carries no questions -- Codex is asking to run a tool,\n # not asking the operator a multiple-choice question. Publish it as a\n # notification-kind approval block naming the tool so the feed and the\n # phone notifier can surface it, and so the Factory extension can bridge\n # it to a VS Code notification.\n tool_name = payload.get(\"tool_name\") or \"a tool\"\n tool_input = payload.get(\"tool_input\", {})\n command = \"\"\n if isinstance(tool_input, dict):\n command = (\n tool_input.get(\"command\")\n or tool_input.get(\"cmd\")\n or tool_input.get(\"path\")\n or \"\"\n )\n if isinstance(command, list):\n command = \" \".join(str(c) for c in command)\n detail = f\": {command}\" if command else \"\"\n normalized_questions = [{\n \"text\": f\"Codex needs approval to run {tool_name}{detail}\",\n \"header\": \"Approval needed\",\n \"multiSelect\": False,\n }]\n kind = \"notification\"\n notification_type = \"permission_prompt\"\n codex_approval = True\n else:\n tool_input = payload.get(\"tool_input\", {})\n questions = tool_input.get(\"questions\", [])\n if not questions:\n return\n normalized_questions = []\n for q in questions:\n if not isinstance(q, dict):\n continue\n question = {\n \"text\": q.get(\"question\", q.get(\"header\", \"\")),\n \"header\": q.get(\"header\"),\n \"multiSelect\": q.get(\"multiSelect\", False),\n }\n raw_opts = q.get(\"options\", [])\n if raw_opts:\n question[\"options\"] = [\n {\"label\": o.get(\"label\", \"\"), \"description\": o.get(\"description\")}\n for o in raw_opts\n if isinstance(o, dict)\n ]\n normalized_questions.append(question)\n if not normalized_questions:\n return\n kind = \"question\"\n\n # Identity from env (set by agents-cli at spawn).\n mailbox_id = os.path.basename(\n os.environ.get(\"AGENTS_MAILBOX_DIR\", \"\").rstrip(\"/\")\n ) or session_id\n\n now_iso = datetime.now(timezone.utc).isoformat()\n stats_path = os.path.join(asks_dir, f\"{safe_session_id}.json\")\n stats = read_json(stats_path) or {}\n recent = stats.get(\"recentAskTimestamps\") if isinstance(stats, dict) else []\n if not isinstance(recent, list):\n recent = []\n recent.append(now_iso)\n # Keep enough history for rolling one-hour needy detection without unbounded\n # per-session files. The TypeScript reader applies the exact time window.\n recent = recent[-200:]\n write_json(stats_path, {\n \"sessionId\": session_id,\n \"mailboxId\": mailbox_id,\n \"firstAskAt\": stats.get(\"firstAskAt\") or now_iso,\n \"lastAskAt\": now_iso,\n \"totalAskCount\": int(stats.get(\"totalAskCount\") or 0) + 1,\n \"recentAskTimestamps\": recent,\n })\n\n hostname = os.environ.get(\"AGENTS_SYNC_MACHINE_ID\") or socket.gethostname()\n host = hostname.split(\".\")[0].strip().lower()\n host = re.sub(r\"[^a-z0-9_-]\", \"-\", host) or \"unknown\"\n\n runtime = os.environ.get(\"AGENTS_RUNTIME\", \"headless\")\n\n block = {\n \"blockId\": block_id,\n \"sessionId\": session_id,\n \"mailboxId\": mailbox_id,\n \"host\": host,\n \"runtime\": runtime,\n \"ts\": now_iso,\n \"questions\": normalized_questions,\n \"kind\": kind,\n }\n if notification_type:\n block[\"notificationType\"] = notification_type\n\n # A Codex PermissionRequest is a real approval gate: mark it approval-class\n # with a high cost-of-delay so 'agents feed --dispatch' classifies it urgent\n # (isPhoneUrgent gates on costOfDelay >= phoneNotifyThreshold, default\n # 'medium') and pages the phone. A plain 'deny' is the safe default.\n if codex_approval:\n block[\"blockClass\"] = \"approval\"\n block[\"costOfDelay\"] = \"high\"\n block[\"safeDefault\"] = \"deny\"\n\n # Optional multi-operator control metadata passed by the agent in the\n # AskUserQuestion tool_input. Defaults keep the existing behavior. A Codex\n # PermissionRequest carries tool ARGS in tool_input (command/path), not\n # operator controls, so it is excluded here -- its class/cost is stamped\n # above from codex_approval.\n controls = payload.get(\"tool_input\", {}) if hook_event not in (\"Notification\", \"PermissionRequest\") else {}\n block_class = controls.get(\"blockClass\") if isinstance(controls, dict) else None\n if block_class in (\"approval\", \"decision\"):\n block[\"blockClass\"] = block_class\n consequence = controls.get(\"consequence\") if isinstance(controls, dict) else None\n if consequence:\n block[\"consequence\"] = consequence\n allowed = controls.get(\"allowedOperators\") if isinstance(controls, dict) else None\n if isinstance(allowed, list):\n block[\"allowedOperators\"] = [str(a) for a in allowed]\n timeout = controls.get(\"timeoutMinutes\") if isinstance(controls, dict) else None\n if isinstance(timeout, (int, float)) and timeout > 0:\n block[\"timeoutMinutes\"] = int(timeout)\n safe_default = controls.get(\"safeDefault\") if isinstance(controls, dict) else None\n if isinstance(safe_default, str):\n block[\"safeDefault\"] = safe_default\n cost = controls.get(\"costOfDelay\") if isinstance(controls, dict) else None\n if cost in (\"low\", \"medium\", \"high\"):\n block[\"costOfDelay\"] = cost\n\n # Publishing a new question clears any stale answered marker from the\n # previous question in this session.\n try:\n os.unlink(os.path.join(answered_dir, f\"{block_id}.json\"))\n except FileNotFoundError:\n pass\n except Exception:\n pass\n\n # Python's expanduser() ignores HOME on Windows, while agents-cli honors a\n # HOME override on every platform. Use the same anchor so hooks and the CLI\n # always read/write one feed store (including temp-home and sandbox runs).\n os.makedirs(feed_dir, exist_ok=True)\n\n fd, tmp = tempfile.mkstemp(dir=feed_dir, suffix=\".tmp\")\n try:\n with os.fdopen(fd, \"w\") as f:\n json.dump(block, f, indent=2)\n os.replace(tmp, target)\n except Exception:\n try:\n os.unlink(tmp)\n except Exception:\n pass\n\n\nif __name__ == \"__main__\":\n try:\n main()\n except Exception:\n pass # fail open\n";
|
|
224
|
+
export declare const FEED_PUBLISH_HOOK_SCRIPT = "#!/usr/bin/env python3\n\"\"\"Publish and clear open-block records for `agents feed`.\n\nThe manifest invokes this script for top-level AskUserQuestion calls, waiting\nnotifications, question answers, and session lifecycle events. One atomic file\nper session means a new block replaces the previous block. Answer/resume/stop\nevents remove it so `agents feed` only lists decisions that are still open.\n\nSub-agent gate: when the PreToolUse payload carries `agent_type`, this is a\nTask/Agent subagent -- skip. Only the top-level agent publishes. Verified on\nClaude Code 2.1.170 (2026-07).\n\nFail-open: ANY error is swallowed so a feed hiccup never blocks a tool call.\n\"\"\"\nimport os\nimport sys\nimport json\nimport re\nimport socket\nimport tempfile\nfrom datetime import datetime, timezone\n\nWAITING_NOTIFICATION_TYPES = {\n \"permission_prompt\",\n \"idle_prompt\",\n \"elicitation_dialog\",\n}\nCLEAR_EVENTS = {\n \"PostToolUse\",\n \"Stop\",\n \"SessionEnd\",\n}\n# Codex emits a PermissionRequest event (not Claude's Notification) when it\n# blocks on an approval prompt. Claude never fires PermissionRequest, so the\n# same script handles both: PermissionRequest maps to an approval-class block\n# with a high cost-of-delay so 'agents feed --dispatch' pages it as urgent.\n\n\ndef read_json(path):\n try:\n with open(path) as f:\n return json.load(f)\n except Exception:\n return None\n\n\ndef write_json(path, value):\n dir_name = os.path.dirname(path)\n os.makedirs(dir_name, exist_ok=True)\n fd, tmp = tempfile.mkstemp(dir=dir_name, suffix=\".tmp\")\n try:\n with os.fdopen(fd, \"w\") as f:\n json.dump(value, f, indent=2)\n os.replace(tmp, path)\n except Exception:\n try:\n os.unlink(tmp)\n except Exception:\n pass\n\n\ndef project_from_cwd(cwd):\n \"\"\"Basename of cwd, with worktree paths resolved to their repo name.\"\"\"\n if not cwd:\n return None\n norm = cwd.replace(\"\\\\\", \"/\").rstrip(\"/\")\n if not norm:\n return None\n marker = \"/.agents/worktrees/\"\n idx = norm.find(marker)\n if idx > 0:\n repo_path = norm[:idx]\n base = repo_path[repo_path.rfind(\"/\") + 1:]\n if base:\n return base\n base = norm[norm.rfind(\"/\") + 1:]\n return base or None\n\n\ndef main():\n raw = sys.stdin.read()\n try:\n payload = json.loads(raw) if raw.strip() else {}\n except Exception:\n return\n\n # Sub-agent gate.\n if payload.get(\"agent_type\"):\n return\n\n session_id = payload.get(\"session_id\", \"\")\n if not session_id:\n return\n\n safe_session_id = re.sub(r\"[^A-Za-z0-9._-]\", \"-\", session_id)\n block_id = f\"block-{safe_session_id}\"\n home = os.environ.get(\"HOME\") or os.path.expanduser(\"~\")\n feed_dir = os.path.join(home, \".agents\", \".history\", \"feed\")\n answered_dir = os.path.join(feed_dir, \"answered\")\n asks_dir = os.path.join(feed_dir, \"asks\")\n target = os.path.join(feed_dir, f\"{block_id}.json\")\n hook_event = payload.get(\"hook_event_name\", \"PreToolUse\")\n\n if hook_event in CLEAR_EVENTS:\n # A declared block (`agents feed post --blocked`) is the agent explicitly\n # saying it is stuck. Unlike a question/notification/approval block -- which\n # tracks an in-flight harness prompt that a lifecycle event resolves -- a\n # declared block stays open until it is actually ANSWERED. So while it is\n # still UNANSWERED, Stop/SessionEnd/PostToolUse must never silently drop it:\n # otherwise the needs-you record vanishes the moment the agent parks the block\n # and its turn ends -- exactly when the owner still needs to see and answer it.\n # Once it IS answered (an answered marker exists), it clears like any other\n # block by falling through below -- which frees that marker too, so a later\n # `--blocked` in the same session is not falsely locked as already-answered\n # (recordAnswer creates the marker with O_EXCL).\n try:\n with open(target) as existing_file:\n existing = json.load(existing_file)\n answered = os.path.exists(os.path.join(answered_dir, f\"{block_id}.json\"))\n if existing.get(\"kind\") == \"declared\" and not answered:\n return\n except Exception:\n pass\n # A matcher-less PostToolUse clear (registered for Codex so an approved\n # tool clears its approval card) must NOT wipe an open AskUserQuestion\n # while an unrelated tool runs mid-question -- those are cleared only by\n # the AskUserQuestion-matched PostToolUse. So on PostToolUse, keep a\n # 'question' block; approval/notification blocks clear once the tool runs.\n if hook_event == \"PostToolUse\":\n try:\n with open(target) as existing_file:\n existing = json.load(existing_file)\n if existing.get(\"kind\") == \"question\" and payload.get(\"tool_name\") != \"AskUserQuestion\":\n return\n except Exception:\n pass\n try:\n os.unlink(target)\n except FileNotFoundError:\n pass\n except Exception:\n pass\n # Also clear the answered marker so a future question for this session\n # is not permanently locked.\n try:\n os.unlink(os.path.join(answered_dir, f\"{block_id}.json\"))\n except FileNotFoundError:\n pass\n except Exception:\n pass\n return\n\n # Terminal answers (human typed in the TUI) record an answered marker and\n # remove the block file so the feed stops showing it within one poll cycle.\n # The marker stays behind so a concurrent surface cannot double-answer.\n if hook_event == \"UserPromptSubmit\":\n os.makedirs(answered_dir, exist_ok=True)\n marker = os.path.join(answered_dir, f\"{block_id}.json\")\n try:\n fd = os.open(marker, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o644)\n record = {\n \"answeredAt\": datetime.now(timezone.utc).isoformat(),\n \"answeredFrom\": \"terminal\",\n }\n with os.fdopen(fd, \"w\") as f:\n json.dump(record, f, indent=2)\n except FileExistsError:\n pass\n except Exception:\n pass\n # Remove the visible block so the feed drops the answered question.\n try:\n os.unlink(target)\n except FileNotFoundError:\n pass\n except Exception:\n pass\n return\n\n notification_type = None\n codex_approval = False\n if hook_event == \"Notification\":\n notification_type = payload.get(\"notification_type\", \"\")\n if notification_type not in WAITING_NOTIFICATION_TYPES:\n return\n # Claude emits a generic permission notification after presenting an\n # AskUserQuestion. Keep the structured questions and options already\n # published for this session instead of replacing them with that less\n # useful notification text.\n try:\n with open(target) as existing_file:\n existing = json.load(existing_file)\n if existing.get(\"kind\") == \"question\":\n return\n except Exception:\n pass\n message = payload.get(\"message\", \"\")\n if not message:\n return\n normalized_questions = [{\n \"text\": message,\n \"header\": payload.get(\"title\") or notification_type.replace(\"_\", \" \").title(),\n \"multiSelect\": False,\n }]\n kind = \"notification\"\n elif hook_event == \"PermissionRequest\":\n # Codex approval prompt. The payload mirrors PreToolUse (tool_name,\n # tool_input) but carries no questions -- Codex is asking to run a tool,\n # not asking the operator a multiple-choice question. Publish it as a\n # notification-kind approval block naming the tool so the feed and the\n # phone notifier can surface it, and so the Factory extension can bridge\n # it to a VS Code notification.\n tool_name = payload.get(\"tool_name\") or \"a tool\"\n tool_input = payload.get(\"tool_input\", {})\n command = \"\"\n if isinstance(tool_input, dict):\n command = (\n tool_input.get(\"command\")\n or tool_input.get(\"cmd\")\n or tool_input.get(\"path\")\n or \"\"\n )\n if isinstance(command, list):\n command = \" \".join(str(c) for c in command)\n detail = f\": {command}\" if command else \"\"\n normalized_questions = [{\n \"text\": f\"Codex needs approval to run {tool_name}{detail}\",\n \"header\": \"Approval needed\",\n \"multiSelect\": False,\n }]\n kind = \"notification\"\n notification_type = \"permission_prompt\"\n codex_approval = True\n else:\n tool_input = payload.get(\"tool_input\", {})\n questions = tool_input.get(\"questions\", [])\n if not questions:\n return\n normalized_questions = []\n for q in questions:\n if not isinstance(q, dict):\n continue\n question = {\n \"text\": q.get(\"question\", q.get(\"header\", \"\")),\n \"header\": q.get(\"header\"),\n \"multiSelect\": q.get(\"multiSelect\", False),\n }\n raw_opts = q.get(\"options\", [])\n if raw_opts:\n question[\"options\"] = [\n {\"label\": o.get(\"label\", \"\"), \"description\": o.get(\"description\")}\n for o in raw_opts\n if isinstance(o, dict)\n ]\n normalized_questions.append(question)\n if not normalized_questions:\n return\n kind = \"question\"\n\n # Identity from env (set by agents-cli at spawn).\n mailbox_id = os.path.basename(\n os.environ.get(\"AGENTS_MAILBOX_DIR\", \"\").rstrip(\"/\")\n ) or session_id\n\n now_iso = datetime.now(timezone.utc).isoformat()\n stats_path = os.path.join(asks_dir, f\"{safe_session_id}.json\")\n stats = read_json(stats_path) or {}\n recent = stats.get(\"recentAskTimestamps\") if isinstance(stats, dict) else []\n if not isinstance(recent, list):\n recent = []\n recent.append(now_iso)\n # Keep enough history for rolling one-hour needy detection without unbounded\n # per-session files. The TypeScript reader applies the exact time window.\n recent = recent[-200:]\n write_json(stats_path, {\n \"sessionId\": session_id,\n \"mailboxId\": mailbox_id,\n \"firstAskAt\": stats.get(\"firstAskAt\") or now_iso,\n \"lastAskAt\": now_iso,\n \"totalAskCount\": int(stats.get(\"totalAskCount\") or 0) + 1,\n \"recentAskTimestamps\": recent,\n })\n\n hostname = os.environ.get(\"AGENTS_SYNC_MACHINE_ID\") or socket.gethostname()\n host = hostname.split(\".\")[0].strip().lower()\n host = re.sub(r\"[^a-z0-9_-]\", \"-\", host) or \"unknown\"\n\n runtime = os.environ.get(\"AGENTS_RUNTIME\", \"headless\")\n cwd = payload.get(\"cwd\") or os.environ.get(\"AGENTS_CWD\")\n project = project_from_cwd(cwd)\n\n block = {\n \"blockId\": block_id,\n \"sessionId\": session_id,\n \"mailboxId\": mailbox_id,\n \"host\": host,\n \"runtime\": runtime,\n \"ts\": now_iso,\n \"questions\": normalized_questions,\n \"kind\": kind,\n }\n if project:\n block[\"project\"] = project\n if notification_type:\n block[\"notificationType\"] = notification_type\n\n # A Codex PermissionRequest is a real approval gate: mark it approval-class\n # with a high cost-of-delay so 'agents feed --dispatch' classifies it urgent\n # (isPhoneUrgent gates on costOfDelay >= phoneNotifyThreshold, default\n # 'medium') and pages the phone. A plain 'deny' is the safe default.\n if codex_approval:\n block[\"blockClass\"] = \"approval\"\n block[\"costOfDelay\"] = \"high\"\n block[\"safeDefault\"] = \"deny\"\n\n # Optional multi-operator control metadata passed by the agent in the\n # AskUserQuestion tool_input. Defaults keep the existing behavior. A Codex\n # PermissionRequest carries tool ARGS in tool_input (command/path), not\n # operator controls, so it is excluded here -- its class/cost is stamped\n # above from codex_approval.\n controls = payload.get(\"tool_input\", {}) if hook_event not in (\"Notification\", \"PermissionRequest\") else {}\n block_class = controls.get(\"blockClass\") if isinstance(controls, dict) else None\n if block_class in (\"approval\", \"decision\"):\n block[\"blockClass\"] = block_class\n consequence = controls.get(\"consequence\") if isinstance(controls, dict) else None\n if consequence:\n block[\"consequence\"] = consequence\n allowed = controls.get(\"allowedOperators\") if isinstance(controls, dict) else None\n if isinstance(allowed, list):\n block[\"allowedOperators\"] = [str(a) for a in allowed]\n timeout = controls.get(\"timeoutMinutes\") if isinstance(controls, dict) else None\n if isinstance(timeout, (int, float)) and timeout > 0:\n block[\"timeoutMinutes\"] = int(timeout)\n safe_default = controls.get(\"safeDefault\") if isinstance(controls, dict) else None\n if isinstance(safe_default, str):\n block[\"safeDefault\"] = safe_default\n cost = controls.get(\"costOfDelay\") if isinstance(controls, dict) else None\n if cost in (\"low\", \"medium\", \"high\"):\n block[\"costOfDelay\"] = cost\n\n # Publishing a new question clears any stale answered marker from the\n # previous question in this session.\n try:\n os.unlink(os.path.join(answered_dir, f\"{block_id}.json\"))\n except FileNotFoundError:\n pass\n except Exception:\n pass\n\n # Python's expanduser() ignores HOME on Windows, while agents-cli honors a\n # HOME override on every platform. Use the same anchor so hooks and the CLI\n # always read/write one feed store (including temp-home and sandbox runs).\n os.makedirs(feed_dir, exist_ok=True)\n\n fd, tmp = tempfile.mkstemp(dir=feed_dir, suffix=\".tmp\")\n try:\n with os.fdopen(fd, \"w\") as f:\n json.dump(block, f, indent=2)\n os.replace(tmp, target)\n except Exception:\n try:\n os.unlink(tmp)\n except Exception:\n pass\n\n\nif __name__ == \"__main__\":\n try:\n main()\n except Exception:\n pass # fail open\n";
|
|
222
225
|
/** Manifest entry for the feed-publish hook, matching the ManifestHook shape. */
|
|
223
226
|
export declare const FEED_PUBLISH_HOOK_MANIFEST: {
|
|
224
227
|
name: string;
|
package/dist/lib/feed.js
CHANGED
|
@@ -26,6 +26,7 @@ import * as path from 'path';
|
|
|
26
26
|
import * as yaml from 'yaml';
|
|
27
27
|
import { getFeedDir, getUserAgentsDir } from './state.js';
|
|
28
28
|
import { isAdmin, isHighConsequenceAllowed, isKnownOperator } from './operator.js';
|
|
29
|
+
import { projectKeyFromCwd } from './project-key.js';
|
|
29
30
|
/**
|
|
30
31
|
* Stable block id for a session. One block per session -- a new question
|
|
31
32
|
* replaces the previous one (the agent can only ask one question at a time).
|
|
@@ -261,6 +262,7 @@ export function buildDeclaredBlock(agent, input) {
|
|
|
261
262
|
.map((label) => label.trim())
|
|
262
263
|
.filter(Boolean)
|
|
263
264
|
.map((label) => ({ label }));
|
|
265
|
+
const project = projectKeyFromCwd(agent.cwd);
|
|
264
266
|
return {
|
|
265
267
|
blockId: blockIdForSession(agent.sessionId),
|
|
266
268
|
sessionId: agent.sessionId,
|
|
@@ -272,6 +274,7 @@ export function buildDeclaredBlock(agent, input) {
|
|
|
272
274
|
questions: [{ text, header: 'Needs you', ...(options.length ? { options } : {}) }],
|
|
273
275
|
blockClass: input.safeDefault ? 'approval' : 'decision',
|
|
274
276
|
costOfDelay: 'high',
|
|
277
|
+
...(project ? { project } : {}),
|
|
275
278
|
...(input.safeDefault ? { safeDefault: input.safeDefault } : {}),
|
|
276
279
|
...(input.timeoutMinutes !== undefined ? { timeoutMinutes: input.timeoutMinutes } : {}),
|
|
277
280
|
};
|
|
@@ -417,6 +420,24 @@ def write_json(path, value):
|
|
|
417
420
|
pass
|
|
418
421
|
|
|
419
422
|
|
|
423
|
+
def project_from_cwd(cwd):
|
|
424
|
+
"""Basename of cwd, with worktree paths resolved to their repo name."""
|
|
425
|
+
if not cwd:
|
|
426
|
+
return None
|
|
427
|
+
norm = cwd.replace("\\\\", "/").rstrip("/")
|
|
428
|
+
if not norm:
|
|
429
|
+
return None
|
|
430
|
+
marker = "/.agents/worktrees/"
|
|
431
|
+
idx = norm.find(marker)
|
|
432
|
+
if idx > 0:
|
|
433
|
+
repo_path = norm[:idx]
|
|
434
|
+
base = repo_path[repo_path.rfind("/") + 1:]
|
|
435
|
+
if base:
|
|
436
|
+
return base
|
|
437
|
+
base = norm[norm.rfind("/") + 1:]
|
|
438
|
+
return base or None
|
|
439
|
+
|
|
440
|
+
|
|
420
441
|
def main():
|
|
421
442
|
raw = sys.stdin.read()
|
|
422
443
|
try:
|
|
@@ -626,6 +647,8 @@ def main():
|
|
|
626
647
|
host = re.sub(r"[^a-z0-9_-]", "-", host) or "unknown"
|
|
627
648
|
|
|
628
649
|
runtime = os.environ.get("AGENTS_RUNTIME", "headless")
|
|
650
|
+
cwd = payload.get("cwd") or os.environ.get("AGENTS_CWD")
|
|
651
|
+
project = project_from_cwd(cwd)
|
|
629
652
|
|
|
630
653
|
block = {
|
|
631
654
|
"blockId": block_id,
|
|
@@ -637,6 +660,8 @@ def main():
|
|
|
637
660
|
"questions": normalized_questions,
|
|
638
661
|
"kind": kind,
|
|
639
662
|
}
|
|
663
|
+
if project:
|
|
664
|
+
block["project"] = project
|
|
640
665
|
if notification_type:
|
|
641
666
|
block["notificationType"] = notification_type
|
|
642
667
|
|
package/dist/lib/mcp.js
CHANGED
|
@@ -594,6 +594,7 @@ function writeMcpConfigSupportsAgent(agentId) {
|
|
|
594
594
|
case 'grok':
|
|
595
595
|
case 'opencode':
|
|
596
596
|
case 'hermes':
|
|
597
|
+
case 'pi':
|
|
597
598
|
return true;
|
|
598
599
|
default:
|
|
599
600
|
return false;
|
|
@@ -615,7 +616,11 @@ export function writeMcpConfig(agentId, configPath, servers, mode = 'overwrite')
|
|
|
615
616
|
case 'claude':
|
|
616
617
|
case 'cursor':
|
|
617
618
|
case 'kimi':
|
|
618
|
-
case 'droid':
|
|
619
|
+
case 'droid':
|
|
620
|
+
// omp reads the same Claude `{ "mcpServers": {...} }` schema from .mcp.json
|
|
621
|
+
// (stdio: command/args/env; http/sse: url/headers — transport inferred from
|
|
622
|
+
// command/url presence).
|
|
623
|
+
case 'pi': {
|
|
619
624
|
let config = {};
|
|
620
625
|
if (fs.existsSync(configPath)) {
|
|
621
626
|
try {
|
|
Binary file
|
|
Binary file
|
package/dist/lib/model-tiers.js
CHANGED
|
@@ -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
|
-
|
|
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;
|
package/dist/lib/models.js
CHANGED
|
@@ -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 /
|
package/dist/lib/profiles.d.ts
CHANGED
|
@@ -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.
|
package/dist/lib/profiles.js
CHANGED
|
@@ -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
|
|
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;
|