@phnx-labs/agents-cli 1.22.53 → 1.22.54
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 +190 -0
- package/README.md +41 -8
- package/dist/bootstrap.js +55 -154
- package/dist/cli/command-registry.d.ts +5 -0
- package/dist/cli/command-registry.js +8 -1
- package/dist/commands/accounts.js +219 -173
- package/dist/commands/apply.js +6 -3
- package/dist/commands/auth-mint.d.ts +8 -0
- package/dist/commands/auth-mint.js +96 -0
- package/dist/commands/auth.js +5 -1
- package/dist/commands/browser.js +1 -1
- package/dist/commands/cost.js +8 -2
- package/dist/commands/daemon.js +2 -2
- package/dist/commands/doctor.js +6 -1
- package/dist/commands/exec.js +10 -8
- package/dist/commands/focus.d.ts +1 -0
- package/dist/commands/focus.js +2 -2
- package/dist/commands/go.d.ts +5 -4
- package/dist/commands/go.js +7 -7
- package/dist/commands/insights.js +9 -0
- package/dist/commands/monitors.js +85 -30
- package/dist/commands/output.js +8 -2
- package/dist/commands/repo.js +18 -0
- package/dist/commands/secrets.js +33 -14
- package/dist/commands/sessions.d.ts +20 -12
- package/dist/commands/sessions.js +64 -20
- package/dist/commands/setup-accounts.d.ts +8 -0
- package/dist/commands/setup-accounts.js +47 -0
- package/dist/commands/setup.d.ts +1 -1
- package/dist/commands/setup.js +11 -2
- package/dist/commands/share.d.ts +52 -3
- package/dist/commands/share.js +262 -18
- package/dist/commands/ssh.d.ts +7 -0
- package/dist/commands/ssh.js +18 -2
- package/dist/commands/status.js +14 -0
- package/dist/commands/view.d.ts +3 -1
- package/dist/commands/view.js +5 -4
- package/dist/lib/account-registry.js +15 -3
- package/dist/lib/accounting/rotate.d.ts +20 -6
- package/dist/lib/accounting/rotate.js +38 -7
- package/dist/lib/accounting/usage.d.ts +37 -1
- package/dist/lib/accounting/usage.js +71 -6
- package/dist/lib/agent-spec/agents.d.ts +5 -2
- package/dist/lib/agent-spec/agents.js +25 -7
- package/dist/lib/analytics/mix-commands.js +12 -6
- package/dist/lib/auth-mint.d.ts +150 -0
- package/dist/lib/auth-mint.js +434 -0
- package/dist/lib/browser/remote-control.d.ts +9 -7
- package/dist/lib/browser/remote-control.js +9 -7
- package/dist/lib/claude-account-token.d.ts +10 -0
- package/dist/lib/claude-account-token.js +14 -4
- package/dist/lib/config-drift.d.ts +37 -0
- package/dist/lib/config-drift.js +72 -0
- package/dist/lib/daemon/auth-sync-service.d.ts +19 -0
- package/dist/lib/daemon/auth-sync-service.js +34 -0
- package/dist/lib/daemon/daemon.js +30 -4
- package/dist/lib/daemon-services.d.ts +1 -1
- package/dist/lib/daemon-services.js +5 -0
- package/dist/lib/device-config.d.ts +3 -3
- package/dist/lib/device-config.js +5 -5
- package/dist/lib/devices/connect.d.ts +26 -0
- package/dist/lib/devices/connect.js +48 -1
- package/dist/lib/devices/doctor-findings.d.ts +5 -1
- package/dist/lib/devices/doctor-findings.js +19 -1
- package/dist/lib/exec.d.ts +28 -0
- package/dist/lib/exec.js +73 -7
- package/dist/lib/feed/feed.d.ts +1 -1
- package/dist/lib/feed/feed.js +23 -1
- package/dist/lib/feed-broadcast.js +1 -1
- package/dist/lib/fleet/apply.d.ts +11 -0
- package/dist/lib/fleet/apply.js +23 -3
- package/dist/lib/fleet/auth-sync.js +5 -3
- package/dist/lib/help.d.ts +9 -0
- package/dist/lib/help.js +29 -1
- package/dist/lib/hosts/passthrough.d.ts +1 -10
- package/dist/lib/hosts/passthrough.js +1 -13
- package/dist/lib/installations/versions.js +9 -1
- package/dist/lib/linux-userns.d.ts +58 -0
- package/dist/lib/linux-userns.js +116 -0
- package/dist/lib/memory.d.ts +26 -0
- package/dist/lib/memory.js +80 -1
- package/dist/lib/monitors/config.d.ts +11 -0
- package/dist/lib/monitors/config.js +8 -0
- package/dist/lib/monitors/engine.js +8 -1
- package/dist/lib/monitors/state.d.ts +37 -1
- package/dist/lib/monitors/state.js +79 -4
- package/dist/lib/permissions-registry.d.ts +2 -0
- package/dist/lib/permissions-registry.js +116 -14
- package/dist/lib/permissions.d.ts +5 -3
- package/dist/lib/permissions.js +25 -27
- package/dist/lib/profiles.d.ts +8 -7
- package/dist/lib/profiles.js +12 -0
- package/dist/lib/project-key.d.ts +9 -0
- package/dist/lib/project-key.js +11 -0
- package/dist/lib/secrets/bundles.d.ts +35 -0
- package/dist/lib/secrets/bundles.js +78 -1
- package/dist/lib/secrets/push.d.ts +3 -8
- package/dist/lib/secrets/push.js +18 -14
- package/dist/lib/secrets/remote.d.ts +9 -18
- package/dist/lib/secrets/remote.js +11 -26
- package/dist/lib/secrets/reserved-sync.d.ts +65 -0
- package/dist/lib/secrets/reserved-sync.js +129 -0
- package/dist/lib/self-heal/checks/hook-manifest.d.ts +2 -0
- package/dist/lib/self-heal/checks/hook-manifest.js +56 -0
- package/dist/lib/self-heal/registry.js +4 -0
- package/dist/lib/self-heal/types.d.ts +1 -1
- package/dist/lib/session/active.js +1 -4
- package/dist/lib/session/db.d.ts +39 -4
- package/dist/lib/session/db.js +130 -29
- package/dist/lib/session/discover.d.ts +32 -4
- package/dist/lib/session/discover.js +119 -25
- package/dist/lib/session/insights.d.ts +14 -0
- package/dist/lib/session/insights.js +25 -2
- package/dist/lib/session/linear.js +1 -1
- package/dist/lib/session/shell-programs.d.ts +17 -0
- package/dist/lib/session/shell-programs.js +21 -0
- package/dist/lib/session/state.js +2 -1
- package/dist/lib/session/stream-render.js +2 -1
- package/dist/lib/session/tool-calls.js +2 -5
- package/dist/lib/session/trajectory-html.js +2 -1
- package/dist/lib/session/trajectory.js +3 -12
- package/dist/lib/session/types.d.ts +8 -0
- package/dist/lib/share/publish.d.ts +53 -5
- package/dist/lib/share/publish.js +99 -17
- package/dist/lib/share/worker-template.js +582 -57
- package/dist/lib/startup/root-command.js +2 -1
- package/dist/lib/state.d.ts +16 -0
- package/dist/lib/state.js +178 -46
- package/dist/lib/sync-status.d.ts +4 -0
- package/dist/lib/sync-status.js +3 -0
- package/dist/lib/traces/classify.js +24 -19
- package/dist/lib/usage-refresh.js +2 -1
- package/dist/lib/view-types.d.ts +2 -0
- package/package.json +2 -1
package/dist/lib/feed/feed.d.ts
CHANGED
|
@@ -342,7 +342,7 @@ export declare function removeBlock(blockId: string, root?: string): boolean;
|
|
|
342
342
|
* Embedded so it ships with the compiled CLI and can be installed to the
|
|
343
343
|
* CLI-writable user hooks dir without a separate file in the npm tarball.
|
|
344
344
|
*/
|
|
345
|
-
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 AGI EXT 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 = datetime.now(timezone.utc)\n now_iso = now.isoformat()\n now_ms = int(now.timestamp() * 1000)\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 # Write-time cursor so a new generation is not suppressed when\n # session.lastActivityMs is unresolvable (cloud / remote / index-lag).\n \"sourceCursor\": {\"lastActivityMs\": now_ms},\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";
|
|
345
|
+
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 # A resolution tombstone is written BEFORE unlink (matching TS recordAnswer)\n # so a stale lifecycle re-read cannot resurrect this generation.\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 now_iso = datetime.now(timezone.utc).isoformat()\n try:\n fd = os.open(marker, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o644)\n record = {\n \"answeredAt\": now_iso,\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 # Tombstone first, then drop the open-block view. A missing/corrupt\n # block means there is nothing to resolve; fail open.\n existing = read_json(target)\n if isinstance(existing, dict):\n generation = existing.get(\"generation\") or existing.get(\"ts\")\n if generation:\n tombstone = {\n \"blockId\": block_id,\n \"generation\": generation,\n \"resolvedAt\": now_iso,\n \"reason\": \"answered\",\n }\n source_cursor = existing.get(\"sourceCursor\")\n if source_cursor:\n tombstone[\"sourceCursor\"] = source_cursor\n write_json(\n os.path.join(feed_dir, \"resolutions\", f\"{block_id}.json\"),\n tombstone,\n )\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 AGI EXT 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 = datetime.now(timezone.utc)\n now_iso = now.isoformat()\n now_ms = int(now.timestamp() * 1000)\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 # Write-time cursor so a new generation is not suppressed when\n # session.lastActivityMs is unresolvable (cloud / remote / index-lag).\n \"sourceCursor\": {\"lastActivityMs\": now_ms},\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";
|
|
346
346
|
/** Manifest entry for the feed-publish hook, matching the ManifestHook shape. */
|
|
347
347
|
export declare const FEED_PUBLISH_HOOK_MANIFEST: {
|
|
348
348
|
name: string;
|
package/dist/lib/feed/feed.js
CHANGED
|
@@ -666,13 +666,16 @@ def main():
|
|
|
666
666
|
# Terminal answers (human typed in the TUI) record an answered marker and
|
|
667
667
|
# remove the block file so the feed stops showing it within one poll cycle.
|
|
668
668
|
# The marker stays behind so a concurrent surface cannot double-answer.
|
|
669
|
+
# A resolution tombstone is written BEFORE unlink (matching TS recordAnswer)
|
|
670
|
+
# so a stale lifecycle re-read cannot resurrect this generation.
|
|
669
671
|
if hook_event == "UserPromptSubmit":
|
|
670
672
|
os.makedirs(answered_dir, exist_ok=True)
|
|
671
673
|
marker = os.path.join(answered_dir, f"{block_id}.json")
|
|
674
|
+
now_iso = datetime.now(timezone.utc).isoformat()
|
|
672
675
|
try:
|
|
673
676
|
fd = os.open(marker, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o644)
|
|
674
677
|
record = {
|
|
675
|
-
"answeredAt":
|
|
678
|
+
"answeredAt": now_iso,
|
|
676
679
|
"answeredFrom": "terminal",
|
|
677
680
|
}
|
|
678
681
|
with os.fdopen(fd, "w") as f:
|
|
@@ -681,6 +684,25 @@ def main():
|
|
|
681
684
|
pass
|
|
682
685
|
except Exception:
|
|
683
686
|
pass
|
|
687
|
+
# Tombstone first, then drop the open-block view. A missing/corrupt
|
|
688
|
+
# block means there is nothing to resolve; fail open.
|
|
689
|
+
existing = read_json(target)
|
|
690
|
+
if isinstance(existing, dict):
|
|
691
|
+
generation = existing.get("generation") or existing.get("ts")
|
|
692
|
+
if generation:
|
|
693
|
+
tombstone = {
|
|
694
|
+
"blockId": block_id,
|
|
695
|
+
"generation": generation,
|
|
696
|
+
"resolvedAt": now_iso,
|
|
697
|
+
"reason": "answered",
|
|
698
|
+
}
|
|
699
|
+
source_cursor = existing.get("sourceCursor")
|
|
700
|
+
if source_cursor:
|
|
701
|
+
tombstone["sourceCursor"] = source_cursor
|
|
702
|
+
write_json(
|
|
703
|
+
os.path.join(feed_dir, "resolutions", f"{block_id}.json"),
|
|
704
|
+
tombstone,
|
|
705
|
+
)
|
|
684
706
|
# Remove the visible block so the feed drops the answered question.
|
|
685
707
|
try:
|
|
686
708
|
os.unlink(target)
|
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
* a GitHub issue) hears nothing at all. So a post can also be mirrored outward.
|
|
8
8
|
*
|
|
9
9
|
* Sinks are **argv templates from config**, never hardcoded integrations. This
|
|
10
|
-
* CLI ships Apache-2.0 and must not depend on one person's tracker or messaging
|
|
10
|
+
* CLI ships under FSL-1.1-Apache-2.0 and must not depend on one person's tracker or messaging
|
|
11
11
|
* stack; declaring `[linear, update, "{ticket}", --comment, "{text}"]` in
|
|
12
12
|
* `agents.yaml` keeps the coupling in the operator's config where it belongs,
|
|
13
13
|
* and lets someone else point the same mechanism at `jira`, `gh issue comment`,
|
|
@@ -45,6 +45,13 @@ export interface SourceAuth {
|
|
|
45
45
|
/** The captured file payloads, keyed by agent. */
|
|
46
46
|
filesByAgent: Map<string, AuthFilePayload[]>;
|
|
47
47
|
}
|
|
48
|
+
/**
|
|
49
|
+
* Bundles `fleet apply` considers for push. The reserved file-backed `auth`
|
|
50
|
+
* bundle is always included when it exists locally — it is the fleet-shared
|
|
51
|
+
* setup-token store and must not wait on `--provision-secrets` / a manifest
|
|
52
|
+
* list (PHNX-2371).
|
|
53
|
+
*/
|
|
54
|
+
export declare function fleetSecretsBundles(declared: string[] | undefined): string[];
|
|
48
55
|
export interface DiffContext {
|
|
49
56
|
/** agents-cli version the source is on — the fleet target version. */
|
|
50
57
|
targetCliVersion: string;
|
|
@@ -95,6 +102,10 @@ export interface SecretPushDecision {
|
|
|
95
102
|
* auto-provisions its OWN machine-local key — so each box ends up with an
|
|
96
103
|
* unshared at-rest key and NO passphrase is forwarded. That is the direct
|
|
97
104
|
* alternative to the fleet-wide shared secret this ticket exists to remove.
|
|
105
|
+
*
|
|
106
|
+
* The reserved `auth` bundle is the exception: it is always file-backed
|
|
107
|
+
* (SEC-GAP-3) and is pushed even without `--provision-secrets`, because it is
|
|
108
|
+
* the one fleet-shared setup-token store (PHNX-2371).
|
|
98
109
|
*/
|
|
99
110
|
export declare function decideSecretPush(bundle: string, desired: DeviceDesired, probe: DeviceProbe, ctx: DiffContext): SecretPushDecision;
|
|
100
111
|
export interface ProbeOptions {
|
package/dist/lib/fleet/apply.js
CHANGED
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
*/
|
|
10
10
|
import * as os from 'os';
|
|
11
11
|
import { pushBundleToHost } from '../secrets/push.js';
|
|
12
|
+
import { AUTH_BUNDLE_NAME, inspectReservedAuthBundle, isReservedBundleName } from '../secrets/bundles.js';
|
|
12
13
|
import { deviceIdentityArgs, sshTargetFor } from '../devices/connect.js';
|
|
13
14
|
import { readyProbe, bootstrapAgentsCli } from '../hosts/ready.js';
|
|
14
15
|
import { buildRemoteAgentsInvocation } from '../hosts/remote-cmd.js';
|
|
@@ -88,6 +89,20 @@ export function parseInstalledVersions(stdout) {
|
|
|
88
89
|
return undefined;
|
|
89
90
|
}
|
|
90
91
|
}
|
|
92
|
+
/**
|
|
93
|
+
* Bundles `fleet apply` considers for push. The reserved file-backed `auth`
|
|
94
|
+
* bundle is always included when it exists locally — it is the fleet-shared
|
|
95
|
+
* setup-token store and must not wait on `--provision-secrets` / a manifest
|
|
96
|
+
* list (PHNX-2371).
|
|
97
|
+
*/
|
|
98
|
+
export function fleetSecretsBundles(declared) {
|
|
99
|
+
const out = [...(declared ?? [])];
|
|
100
|
+
const auth = inspectReservedAuthBundle();
|
|
101
|
+
if (auth.exists && auth.ok && !out.includes(AUTH_BUNDLE_NAME)) {
|
|
102
|
+
out.push(AUTH_BUNDLE_NAME);
|
|
103
|
+
}
|
|
104
|
+
return out;
|
|
105
|
+
}
|
|
91
106
|
/** Pure: desired vs probed -> per-device diff + flat action list. */
|
|
92
107
|
export function diffFleet(desired, probes, ctx) {
|
|
93
108
|
const devices = [];
|
|
@@ -208,12 +223,17 @@ export function diffFleet(desired, probes, ctx) {
|
|
|
208
223
|
* auto-provisions its OWN machine-local key — so each box ends up with an
|
|
209
224
|
* unshared at-rest key and NO passphrase is forwarded. That is the direct
|
|
210
225
|
* alternative to the fleet-wide shared secret this ticket exists to remove.
|
|
226
|
+
*
|
|
227
|
+
* The reserved `auth` bundle is the exception: it is always file-backed
|
|
228
|
+
* (SEC-GAP-3) and is pushed even without `--provision-secrets`, because it is
|
|
229
|
+
* the one fleet-shared setup-token store (PHNX-2371).
|
|
211
230
|
*/
|
|
212
231
|
export function decideSecretPush(bundle, desired, probe, ctx) {
|
|
213
232
|
const device = desired.device;
|
|
214
|
-
const
|
|
233
|
+
const reserved = isReservedBundleName(bundle);
|
|
234
|
+
const backend = reserved || probe.platform === 'linux' ? 'file' : 'keychain';
|
|
215
235
|
const manual = `recreate secrets bundle '${bundle}' (\`agents ssh ${device} -- secrets create ${bundle}\`)`;
|
|
216
|
-
if (!ctx.provisionSecrets) {
|
|
236
|
+
if (!ctx.provisionSecrets && !reserved) {
|
|
217
237
|
return { push: false, backend, reason: `${manual} — or re-run with --provision-secrets to push it` };
|
|
218
238
|
}
|
|
219
239
|
if (!probe.reachable) {
|
|
@@ -416,7 +436,7 @@ export function reconcileDevice(row, device, ctx) {
|
|
|
416
436
|
ok = false;
|
|
417
437
|
continue;
|
|
418
438
|
}
|
|
419
|
-
const backend = device.platform === 'linux' ? 'file' : 'keychain';
|
|
439
|
+
const backend = isReservedBundleName(bundle) || device.platform === 'linux' ? 'file' : 'keychain';
|
|
420
440
|
try {
|
|
421
441
|
const out = pushBundleToHost(bundle, target, {
|
|
422
442
|
remoteBackend: backend,
|
|
@@ -136,9 +136,11 @@ export const FLEET_LOGIN_FLOWS = {
|
|
|
136
136
|
successFile: successFileFor('opencode'),
|
|
137
137
|
},
|
|
138
138
|
// claude: interactive `/login` inside the TUI is a loopback browser flow, and
|
|
139
|
-
// the token is keychain-bound on macOS.
|
|
140
|
-
//
|
|
141
|
-
//
|
|
139
|
+
// the token is keychain-bound on macOS. That native login stays per-box and
|
|
140
|
+
// is never copied. The shareable `claude setup-token` mint is a different
|
|
141
|
+
// credential class — wired as `agents accounts mint claude` / `agents auth mint`
|
|
142
|
+
// (`lib/auth-mint.ts`), not as a fleet-login device-code flow (a setup-token
|
|
143
|
+
// is account-scoped, not per-machine).
|
|
142
144
|
claude: {
|
|
143
145
|
loginCommand: 'claude',
|
|
144
146
|
flowType: 'loopback',
|
package/dist/lib/help.d.ts
CHANGED
|
@@ -18,6 +18,15 @@ export interface CommandGroup {
|
|
|
18
18
|
* any group fall back to a plain "Commands:" section below the groups.
|
|
19
19
|
*/
|
|
20
20
|
export declare function registerCommandGroups(parent: Command, groups: readonly CommandGroup[]): void;
|
|
21
|
+
/**
|
|
22
|
+
* Front-door command groups shown on `agents --help`. Derived from measured
|
|
23
|
+
* reach: tier 1 = setup/run/sessions/view, tier 2 = teams/browser/secrets/
|
|
24
|
+
* devices/accounts/add. The remaining groups stay discoverable through the
|
|
25
|
+
* pointer rendered below these groups.
|
|
26
|
+
*/
|
|
27
|
+
export declare const FRONT_DOOR_COMMAND_GROUPS: readonly CommandGroup[];
|
|
28
|
+
/** Mark the root program so its help only renders front-door groups + a pointer. */
|
|
29
|
+
export declare function setCompactRootHelp(program: Command): void;
|
|
21
30
|
/** Examples + Notes blocks attached to a command via setHelpSections. */
|
|
22
31
|
export interface HelpSections {
|
|
23
32
|
examples?: string;
|
package/dist/lib/help.js
CHANGED
|
@@ -7,6 +7,27 @@ const commandGroupRegistry = new WeakMap();
|
|
|
7
7
|
export function registerCommandGroups(parent, groups) {
|
|
8
8
|
commandGroupRegistry.set(parent, groups);
|
|
9
9
|
}
|
|
10
|
+
/**
|
|
11
|
+
* Front-door command groups shown on `agents --help`. Derived from measured
|
|
12
|
+
* reach: tier 1 = setup/run/sessions/view, tier 2 = teams/browser/secrets/
|
|
13
|
+
* devices/accounts/add. The remaining groups stay discoverable through the
|
|
14
|
+
* pointer rendered below these groups.
|
|
15
|
+
*/
|
|
16
|
+
export const FRONT_DOOR_COMMAND_GROUPS = [
|
|
17
|
+
{
|
|
18
|
+
title: 'Quick start',
|
|
19
|
+
names: ['setup', 'view', 'run', 'sessions'],
|
|
20
|
+
},
|
|
21
|
+
{
|
|
22
|
+
title: 'Most-used',
|
|
23
|
+
names: ['teams', 'browser', 'secrets', 'devices', 'accounts', 'add'],
|
|
24
|
+
},
|
|
25
|
+
];
|
|
26
|
+
const compactRootHelp = new WeakMap();
|
|
27
|
+
/** Mark the root program so its help only renders front-door groups + a pointer. */
|
|
28
|
+
export function setCompactRootHelp(program) {
|
|
29
|
+
compactRootHelp.set(program, true);
|
|
30
|
+
}
|
|
10
31
|
const helpSectionRegistry = new WeakMap();
|
|
11
32
|
/**
|
|
12
33
|
* Attach an Examples block (rendered between the description and Arguments)
|
|
@@ -136,7 +157,14 @@ function formatHelpCommandsFirst(cmd, helper) {
|
|
|
136
157
|
output = output.concat([`${title}:`, formatList(subs.map(renderCommand)), '']);
|
|
137
158
|
}
|
|
138
159
|
const remaining = visibleCommands.filter((s) => !placed.has(s.name()));
|
|
139
|
-
if (
|
|
160
|
+
if (compactRootHelp.get(cmd)) {
|
|
161
|
+
output = output.concat([
|
|
162
|
+
'Commands:',
|
|
163
|
+
` See "${cmd.name()} --help-all" for every command.`,
|
|
164
|
+
'',
|
|
165
|
+
]);
|
|
166
|
+
}
|
|
167
|
+
else if (remaining.length > 0) {
|
|
140
168
|
output = output.concat(['Commands:', formatList(remaining.map(renderCommand)), '']);
|
|
141
169
|
}
|
|
142
170
|
}
|
|
@@ -17,7 +17,7 @@
|
|
|
17
17
|
* "not supported" message — never commander's raw `unknown option`.
|
|
18
18
|
*/
|
|
19
19
|
import { type Host } from './types.js';
|
|
20
|
-
import { type
|
|
20
|
+
import { type DeviceRegistry } from '../devices/registry.js';
|
|
21
21
|
import { runLocalCommand, runOnDevice } from '../devices/fleet.js';
|
|
22
22
|
/** Re-export for callers that historically imported flagValue from this module. */
|
|
23
23
|
export { flagValue, hasHostRoutingFlag } from './routing-flag.js';
|
|
@@ -69,15 +69,6 @@ export interface FleetPassthroughOptions {
|
|
|
69
69
|
/** Override this machine's id (tests). Defaults to `machineId()`. */
|
|
70
70
|
self?: string;
|
|
71
71
|
}
|
|
72
|
-
/**
|
|
73
|
-
* Prefix a fan-out remote command so the far side sees AGENTS_FLEET_REMOTE=1 —
|
|
74
|
-
* the same marker the single-target dispatch sets via env. `wrapRemoteCommand`
|
|
75
|
-
* joins the argv with spaces (POSIX) or base64-encodes it for PowerShell, so a
|
|
76
|
-
* shell-appropriate leading token rides through both: `env VAR=1 …` on POSIX,
|
|
77
|
-
* `$env:VAR='1'; …` on PowerShell. Only remote (non-self) targets get it; the
|
|
78
|
-
* self target runs locally and must stay ungated.
|
|
79
|
-
*/
|
|
80
|
-
export declare function markFleetRemote(cmd: string[], device: DeviceProfile): string[];
|
|
81
72
|
/** Run `agents <command> …` across every registered device and render the roster. */
|
|
82
73
|
export declare function runFleetPassthrough(command: string, allArgs: string[], spec: RemoteSpec, opts?: FleetPassthroughOptions): Promise<boolean>;
|
|
83
74
|
/**
|
|
@@ -32,6 +32,7 @@ import { isDeviceInteractive, resolveInteractiveDevice, interactiveUnsetError, }
|
|
|
32
32
|
import { flagValue, hasHostRoutingFlag } from './routing-flag.js';
|
|
33
33
|
import { loadDevices } from '../devices/registry.js';
|
|
34
34
|
import { isSelfHost } from '../devices/self-host.js';
|
|
35
|
+
import { markFleetRemote } from '../devices/connect.js';
|
|
35
36
|
import { fanOutDevices, planFleetTargets, runLocalCommand, runOnDevice, } from '../devices/fleet.js';
|
|
36
37
|
import { platformGroupLabel } from '../devices/health-report.js';
|
|
37
38
|
import { isKnownTopLevelCommand } from '../startup/command-registry.js';
|
|
@@ -368,19 +369,6 @@ function renderFleetRoster(command, forwarded, results, self) {
|
|
|
368
369
|
console.log(chalk.gray(summaryParts.join(' · ')));
|
|
369
370
|
}
|
|
370
371
|
}
|
|
371
|
-
/**
|
|
372
|
-
* Prefix a fan-out remote command so the far side sees AGENTS_FLEET_REMOTE=1 —
|
|
373
|
-
* the same marker the single-target dispatch sets via env. `wrapRemoteCommand`
|
|
374
|
-
* joins the argv with spaces (POSIX) or base64-encodes it for PowerShell, so a
|
|
375
|
-
* shell-appropriate leading token rides through both: `env VAR=1 …` on POSIX,
|
|
376
|
-
* `$env:VAR='1'; …` on PowerShell. Only remote (non-self) targets get it; the
|
|
377
|
-
* self target runs locally and must stay ungated.
|
|
378
|
-
*/
|
|
379
|
-
export function markFleetRemote(cmd, device) {
|
|
380
|
-
return device.shell === 'powershell'
|
|
381
|
-
? [`$env:AGENTS_FLEET_REMOTE='1';`, ...cmd]
|
|
382
|
-
: ['env', 'AGENTS_FLEET_REMOTE=1', ...cmd];
|
|
383
|
-
}
|
|
384
372
|
/** Run `agents <command> …` across every registered device and render the roster. */
|
|
385
373
|
export async function runFleetPassthrough(command, allArgs, spec, opts = {}) {
|
|
386
374
|
const self = opts.self ?? machineId();
|
|
@@ -49,7 +49,7 @@ import { emit } from '../feed/events.js';
|
|
|
49
49
|
import { safeJoin } from '../paths.js';
|
|
50
50
|
import { readSkillSourceCommandMarker, shouldAlsoInstallCommandAsSkill, shouldInstallCommandAsSkill, } from '../command-skills.js';
|
|
51
51
|
import { getWriter, getDetector } from '../staleness/registry.js';
|
|
52
|
-
import { syncMemoryToVersionHome } from '../memory.js';
|
|
52
|
+
import { syncMemoryToVersionHome, syncClaudeProjectMemoryDir } from '../memory.js';
|
|
53
53
|
import { listPluginSkillNames, resolveCommandSource, resolveSkillSource } from '../staleness/writers/sources.js';
|
|
54
54
|
import { syncProjectResourcesToAgent } from '../project-resources.js';
|
|
55
55
|
import { installClaudeStatusLine } from '../claude-statusline.js';
|
|
@@ -2860,6 +2860,14 @@ export function syncResourcesToVersion(agent, version, selection, options = {})
|
|
|
2860
2860
|
if (supports(agent, 'memory', version).ok) {
|
|
2861
2861
|
syncMemoryToVersionHome(agent, versionHome, cwd);
|
|
2862
2862
|
}
|
|
2863
|
+
// Claude Code's own NATIVE per-project auto-memory (.claude/projects/<key>/memory/,
|
|
2864
|
+
// PHNX-2817) is a separate, unmanaged directory Claude writes into itself — make it
|
|
2865
|
+
// version-independent the same way project-level rules already are, via a shared
|
|
2866
|
+
// symlink, so a note survives an agent version upgrade instead of vanishing into a
|
|
2867
|
+
// fresh, empty version home.
|
|
2868
|
+
if (agent === 'claude') {
|
|
2869
|
+
syncClaudeProjectMemoryDir(versionHome, cwd);
|
|
2870
|
+
}
|
|
2863
2871
|
// Prune resources deleted from source (RUSH-2438). Runs only on a repo-scope
|
|
2864
2872
|
// reconcile (`options.prune`) with a caller selection — a full sync
|
|
2865
2873
|
// (`!userPassedSelection`) already sweeps orphans above, and an additive
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
/** Path of the AppArmor knob that gates unprivileged userns on Ubuntu 23.10+. */
|
|
2
|
+
export declare const APPARMOR_USERNS_SYSCTL_PATH = "/proc/sys/kernel/apparmor_restrict_unprivileged_userns";
|
|
3
|
+
export type UsernsState =
|
|
4
|
+
/** A new user namespace with a uid map can be created — Codex's sandbox works. */
|
|
5
|
+
'ok'
|
|
6
|
+
/** Unprivileged userns is restricted — Codex's bwrap sandbox cannot start. */
|
|
7
|
+
| 'blocked'
|
|
8
|
+
/** Could not determine (non-Linux, or the probe could not run). */
|
|
9
|
+
| 'unknown';
|
|
10
|
+
export interface UsernsStatus {
|
|
11
|
+
state: UsernsState;
|
|
12
|
+
/** One-line human reason, present when `blocked` or `unknown`. */
|
|
13
|
+
reason?: string;
|
|
14
|
+
}
|
|
15
|
+
/** The raw signals the pure interpreter reasons over. */
|
|
16
|
+
export interface UsernsInputs {
|
|
17
|
+
platform: NodeJS.Platform;
|
|
18
|
+
/**
|
|
19
|
+
* Contents of {@link APPARMOR_USERNS_SYSCTL_PATH} trimmed, or null when the
|
|
20
|
+
* file is absent (older kernels / no AppArmor userns mediation).
|
|
21
|
+
*/
|
|
22
|
+
apparmorRestrict: string | null;
|
|
23
|
+
/**
|
|
24
|
+
* Result of actually attempting to create a user namespace with a uid map:
|
|
25
|
+
* - 'ok' → the probe created the namespace and mapped root.
|
|
26
|
+
* - 'denied' → the kernel refused the uid_map write (the restricted case).
|
|
27
|
+
* - 'no-tool' → the probe binary (`unshare`) was missing or failed to spawn.
|
|
28
|
+
*/
|
|
29
|
+
unshareProbe: 'ok' | 'denied' | 'no-tool';
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* Decide userns availability from raw signals. Pure — no I/O.
|
|
33
|
+
*
|
|
34
|
+
* The definitive signal is the actual probe: if we successfully created a userns
|
|
35
|
+
* and wrote a uid map, the sandbox works regardless of the sysctl (an AppArmor
|
|
36
|
+
* profile may grant a specific binary `userns` even while the global knob is 1).
|
|
37
|
+
* A denied probe is a hard `blocked`. When the probe tool is missing we fall back
|
|
38
|
+
* to the sysctl: `1` → `blocked`, `0`/absent → `unknown` (we could not prove it,
|
|
39
|
+
* and refuse to claim `ok` we did not observe).
|
|
40
|
+
*/
|
|
41
|
+
export declare function interpretUsernsInputs(inputs: UsernsInputs): UsernsStatus;
|
|
42
|
+
/** Read the AppArmor userns sysctl, or null when the file is absent. */
|
|
43
|
+
export declare function readApparmorRestrict(sysctlPath?: string): string | null;
|
|
44
|
+
/**
|
|
45
|
+
* Actually try to create a user namespace and map root inside it — the same
|
|
46
|
+
* operation bwrap performs (`unshare --user --map-root-user`). This is the ground
|
|
47
|
+
* truth: it observes exactly what the kernel/AppArmor policy permits for *this*
|
|
48
|
+
* process, rather than inferring from the sysctl alone.
|
|
49
|
+
*/
|
|
50
|
+
export declare function probeUnshare(): 'ok' | 'denied' | 'no-tool';
|
|
51
|
+
/**
|
|
52
|
+
* Resolve whether an unprivileged user namespace can be created on this host,
|
|
53
|
+
* cached for the process (the answer is a stable property of the box). Non-Linux
|
|
54
|
+
* short-circuits to `ok` without spawning anything.
|
|
55
|
+
*/
|
|
56
|
+
export declare function probeUnprivilegedUserns(platform?: NodeJS.Platform): UsernsStatus;
|
|
57
|
+
/** Test-only: drop the process cache so a test can re-probe. */
|
|
58
|
+
export declare function resetUsernsCacheForTests(): void;
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Unprivileged user-namespace availability on Linux — the capability Codex's
|
|
3
|
+
* Linux sandbox needs, and the one Ubuntu 23.10+ restricts by default.
|
|
4
|
+
*
|
|
5
|
+
* Codex ≥0.146 implements its `read-only` and `workspace-write` sandbox modes on
|
|
6
|
+
* Linux with a bundled **bubblewrap** (`bwrap`), extracted per-run to
|
|
7
|
+
* `$CODEX_HOME/tmp/arg0/codex-XXXX/` and exec'd from a memfd. bwrap sets up its
|
|
8
|
+
* mounts inside a fresh **unprivileged user namespace** (`--unshare-user`, then a
|
|
9
|
+
* write to `/proc/self/uid_map`). Ubuntu 24.04 ships
|
|
10
|
+
* `kernel.apparmor_restrict_unprivileged_userns=1`, which denies that to an
|
|
11
|
+
* unconfined binary — so bwrap dies with `bwrap: setting up uid map: Permission
|
|
12
|
+
* denied` and a headless Codex run lands zero tools (no file writes, no shell).
|
|
13
|
+
* `danger-full-access` (our `skip` mode) drops the sandbox and is the only mode
|
|
14
|
+
* that avoids bwrap; the legacy Landlock backend is gone (`use_linux_sandbox_bwrap`
|
|
15
|
+
* is `removed`, `use_legacy_landlock` panics under the permission-profile model).
|
|
16
|
+
*
|
|
17
|
+
* This module is the single detector. It is pure at its core
|
|
18
|
+
* ({@link interpretUsernsInputs}) so the decision is unit-testable without a
|
|
19
|
+
* shell, and {@link probeUnprivilegedUserns} gathers the real inputs once per
|
|
20
|
+
* process. See PHNX-3285.
|
|
21
|
+
*/
|
|
22
|
+
import { execFileSync } from 'child_process';
|
|
23
|
+
import * as fs from 'fs';
|
|
24
|
+
/** Path of the AppArmor knob that gates unprivileged userns on Ubuntu 23.10+. */
|
|
25
|
+
export const APPARMOR_USERNS_SYSCTL_PATH = '/proc/sys/kernel/apparmor_restrict_unprivileged_userns';
|
|
26
|
+
/**
|
|
27
|
+
* Decide userns availability from raw signals. Pure — no I/O.
|
|
28
|
+
*
|
|
29
|
+
* The definitive signal is the actual probe: if we successfully created a userns
|
|
30
|
+
* and wrote a uid map, the sandbox works regardless of the sysctl (an AppArmor
|
|
31
|
+
* profile may grant a specific binary `userns` even while the global knob is 1).
|
|
32
|
+
* A denied probe is a hard `blocked`. When the probe tool is missing we fall back
|
|
33
|
+
* to the sysctl: `1` → `blocked`, `0`/absent → `unknown` (we could not prove it,
|
|
34
|
+
* and refuse to claim `ok` we did not observe).
|
|
35
|
+
*/
|
|
36
|
+
export function interpretUsernsInputs(inputs) {
|
|
37
|
+
if (inputs.platform !== 'linux')
|
|
38
|
+
return { state: 'ok' };
|
|
39
|
+
if (inputs.unshareProbe === 'ok')
|
|
40
|
+
return { state: 'ok' };
|
|
41
|
+
if (inputs.unshareProbe === 'denied') {
|
|
42
|
+
const via = inputs.apparmorRestrict === '1'
|
|
43
|
+
? ' (kernel.apparmor_restrict_unprivileged_userns=1)'
|
|
44
|
+
: '';
|
|
45
|
+
return {
|
|
46
|
+
state: 'blocked',
|
|
47
|
+
reason: `the kernel denied creating an unprivileged user namespace${via}`,
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
// Probe tool unavailable — lean on the AppArmor knob.
|
|
51
|
+
if (inputs.apparmorRestrict === '1') {
|
|
52
|
+
return {
|
|
53
|
+
state: 'blocked',
|
|
54
|
+
reason: 'unprivileged user namespaces are AppArmor-restricted ' +
|
|
55
|
+
'(kernel.apparmor_restrict_unprivileged_userns=1) and `unshare` was not available to confirm',
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
return {
|
|
59
|
+
state: 'unknown',
|
|
60
|
+
reason: '`unshare` was not available to probe user-namespace support',
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
/** Read the AppArmor userns sysctl, or null when the file is absent. */
|
|
64
|
+
export function readApparmorRestrict(sysctlPath = APPARMOR_USERNS_SYSCTL_PATH) {
|
|
65
|
+
try {
|
|
66
|
+
return fs.readFileSync(sysctlPath, 'utf8').trim();
|
|
67
|
+
}
|
|
68
|
+
catch {
|
|
69
|
+
return null;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
/**
|
|
73
|
+
* Actually try to create a user namespace and map root inside it — the same
|
|
74
|
+
* operation bwrap performs (`unshare --user --map-root-user`). This is the ground
|
|
75
|
+
* truth: it observes exactly what the kernel/AppArmor policy permits for *this*
|
|
76
|
+
* process, rather than inferring from the sysctl alone.
|
|
77
|
+
*/
|
|
78
|
+
export function probeUnshare() {
|
|
79
|
+
try {
|
|
80
|
+
execFileSync('unshare', ['--user', '--map-root-user', 'true'], {
|
|
81
|
+
stdio: 'ignore',
|
|
82
|
+
timeout: 5000,
|
|
83
|
+
});
|
|
84
|
+
return 'ok';
|
|
85
|
+
}
|
|
86
|
+
catch (err) {
|
|
87
|
+
// ENOENT / spawn failure → the tool isn't here; anything else (nonzero exit
|
|
88
|
+
// from the denied uid_map write) is the restricted case.
|
|
89
|
+
const code = err?.code;
|
|
90
|
+
if (code === 'ENOENT')
|
|
91
|
+
return 'no-tool';
|
|
92
|
+
return 'denied';
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
let cached = null;
|
|
96
|
+
/**
|
|
97
|
+
* Resolve whether an unprivileged user namespace can be created on this host,
|
|
98
|
+
* cached for the process (the answer is a stable property of the box). Non-Linux
|
|
99
|
+
* short-circuits to `ok` without spawning anything.
|
|
100
|
+
*/
|
|
101
|
+
export function probeUnprivilegedUserns(platform = process.platform) {
|
|
102
|
+
if (platform !== 'linux')
|
|
103
|
+
return { state: 'ok' };
|
|
104
|
+
if (cached)
|
|
105
|
+
return cached;
|
|
106
|
+
cached = interpretUsernsInputs({
|
|
107
|
+
platform,
|
|
108
|
+
apparmorRestrict: readApparmorRestrict(),
|
|
109
|
+
unshareProbe: probeUnshare(),
|
|
110
|
+
});
|
|
111
|
+
return cached;
|
|
112
|
+
}
|
|
113
|
+
/** Test-only: drop the process cache so a test can re-probe. */
|
|
114
|
+
export function resetUsernsCacheForTests() {
|
|
115
|
+
cached = null;
|
|
116
|
+
}
|
package/dist/lib/memory.d.ts
CHANGED
|
@@ -53,3 +53,29 @@ export declare function rebuildMemoryIndex(dir: string): void;
|
|
|
53
53
|
export declare function memoryTargetDir(agent: AgentId): string;
|
|
54
54
|
/** Copy canonical layered memory into one version home. Returns fact names written. */
|
|
55
55
|
export declare function syncMemoryToVersionHome(agent: AgentId, versionHome: string, cwd?: string): string[];
|
|
56
|
+
/**
|
|
57
|
+
* Canonical shared dir for Claude Code's NATIVE per-project auto-memory —
|
|
58
|
+
* `<versionHome>/.claude/projects/<project-key>/memory/*.md`, the freeform
|
|
59
|
+
* notes Claude writes for itself during a session. Distinct from the layered
|
|
60
|
+
* `memory` resource above (~/.agents/memory/ facts synced into
|
|
61
|
+
* `.claude/memory/`): this dir is keyed by project (via
|
|
62
|
+
* {@link claudeProjectDirName}), not by agent version, and Claude Code itself
|
|
63
|
+
* decides what goes in it — agents-cli only makes the directory
|
|
64
|
+
* version-independent, never writes into it.
|
|
65
|
+
*/
|
|
66
|
+
export declare function getClaudeProjectMemoryDir(cwd: string): string;
|
|
67
|
+
/**
|
|
68
|
+
* Make Claude Code's native per-project memory dir version-independent by
|
|
69
|
+
* symlinking `<versionHome>/.claude/projects/<project-key>/memory/` into the
|
|
70
|
+
* one canonical dir every installed Claude version's home shares for this
|
|
71
|
+
* project (PHNX-2817). Without this, `getVersionHomePath` gives every
|
|
72
|
+
* installed version its own isolated HOME, so a note written under one
|
|
73
|
+
* version is invisible under another — the directory is just empty there.
|
|
74
|
+
*
|
|
75
|
+
* Idempotent and safe to call on every sync: a dir already linked to the
|
|
76
|
+
* canonical target is left alone; a PRE-EXISTING real directory with content
|
|
77
|
+
* (the common case today, since this bug has always left one behind) has its
|
|
78
|
+
* files migrated into the canonical dir first — never discarded — before
|
|
79
|
+
* being replaced by the symlink.
|
|
80
|
+
*/
|
|
81
|
+
export declare function syncClaudeProjectMemoryDir(versionHome: string, cwd?: string): void;
|